动态规划

本文件维护第一章第三类题目的题解与面试追问。所有题解都要明确状态定义、转移来源、初始化、遍历顺序和答案位置。

字节核心题单

顺序 题目 字节频次 数据版本 其他统计覆盖
1 LC 5 最长回文子串 46 08 更新 3/4
2 LC 300 最长递增子序列 43 08 更新 3/4
3 LC 72 编辑距离 34 08 更新 3/4
4 LC 1143 最长公共子序列 23 08 更新 1/4
5 LC 53 最大子数组和 30 04 基线 3/4
6 LC 121 买卖股票的最佳时机 23 04 基线 3/4
7 LC 322 零钱兑换 14 04 基线 2/4
8 LC 122 买卖股票的最佳时机 II 12 04 基线 0/4
9 LC 198 打家劫舍 8 04 基线 0/4
10 LC 70 爬楼梯 7 04 基线 3/4

其他来源新增题

题目 发现来源
LC 32 最长有效括号 美团、Hot 100 综合榜
LC 64 最小路径和 华为、Hot 100 综合榜
LC 118 杨辉三角 Hot 100 综合榜
LC 120 三角形最小路径和 美团
LC 279 完全平方数 Hot 100 综合榜
LC 416 分割等和子集 Hot 100 综合榜
LC 647 回文子串 美团
LC 718 最长重复子数组 美团、腾讯
LC 918 环形子数组的最大和 腾讯
LC 1262 可被三整除的最大和 腾讯

题解与追问重点

  • 先给二维或完整状态,再讨论滚动数组、状态压缩和原地更新。
  • LC 300 必须比较 $O(n^2)$ 动态规划与 $O(n\log n)$ 贪心加二分。
  • 回文题比较动态规划与中心扩展,明确子串、子数组和子序列的区别。
  • 股票题不仅给公式,还要解释状态机含义及交易次数变化时如何扩展。
  • 背包题重点说明物品与容量的遍历顺序,避免把 0-1 背包写成完全背包。

LC 5 最长回文子串

考频与考点:字节高频题。考查区间动态规划、中心扩展,以及“子串必须连续”这一约束。

写代码前确认:输入非空;长度相同的最优答案返回任意一个即可。先确认面试官更关注 DP 模型,还是更短的中心扩展实现。

状态与思路:令 dp[i][j] 表示闭区间 s[i:j+1] 是否为回文。转移为 s[i] == s[j] and (j - i <= 1 or dp[i+1][j-1])。单字符初始化为真;i 必须从右向左遍历,j 从 i 向右遍历,保证内部状态已计算。答案不在固定单元格,而是在遍历中维护最长区间。中心扩展使用相同的回文结构,空间降为常数,是面试编码首选。

class Solution:
    def longestPalindrome(self, s: str) -> str:
        n = len(s)
        dp = [[False] * n for _ in range(n)]
        start = 0
        best_len = 1

        for i in range(n - 1, -1, -1):
            for j in range(i, n):
                dp[i][j] = s[i] == s[j] and (j - i <= 1 or dp[i + 1][j - 1])
                if dp[i][j] and j - i + 1 > best_len:
                    start, best_len = i, j - i + 1
        return s[start:start + best_len]
from typing import Tuple

class SolutionCenter:
    def longestPalindrome(self, s: str) -> str:
        left = right = 0

        def expand(i: int, j: int) -> Tuple[int, int]:
            while i >= 0 and j < len(s) and s[i] == s[j]:
                i -= 1
                j += 1
            return i + 1, j - 1

        for center in range(len(s)):
            for i, j in (expand(center, center), expand(center, center + 1)):
                if j - i > right - left:
                    left, right = i, j
        return s[left:right + 1]

复杂度:两种解法时间均为 O(n^2);DP 空间 O(n^2),中心扩展空间 O(1)。

边界与易错点:偶数长度回文需要以两个字符为中心;DP 的遍历方向不能让 dp[i+1][j-1] 尚未计算。

面试追问:若求最长回文子序列,状态仍是区间 DP,但字符不等时可舍弃任一端;若要求线性时间,可进一步讨论 Manacher 算法。

LC 300 最长递增子序列

考频与考点:字节高频题。必须掌握 O(n^2) DP,以及 O(n log n) 的贪心加二分优化。

写代码前确认:题目要求严格递增,因此二分找第一个“大于等于”当前位置的元素;子序列不要求连续。

状态与思路:DP 定义 dp[i] 为以 nums[i] 结尾的最长递增子序列长度;若 j < i 且 nums[j] < nums[i],转移为 dp[i] = max(dp[i], dp[j] + 1)。所有位置初始化为 1,按 i 从左向右、j 在其左侧遍历,答案为 max(dp)。优化解法维护 tails[k]:长度为 k+1 的递增子序列可取得的最小末尾值;它不是实际答案序列,但越小越利于接入后续元素。

from typing import List

class SolutionDP:
    def lengthOfLIS(self, nums: List[int]) -> int:
        dp = [1] * len(nums)
        for i in range(len(nums)):
            for j in range(i):
                if nums[j] < nums[i]:
                    dp[i] = max(dp[i], dp[j] + 1)
        return max(dp)
from typing import List

from bisect import bisect_left

class Solution:
    def lengthOfLIS(self, nums: List[int]) -> int:
        tails: List[int] = []
        for value in nums:
            index = bisect_left(tails, value)
            if index == len(tails):
                tails.append(value)
            else:
                tails[index] = value
        return len(tails)

复杂度:DP 时间 O(n^2)、空间 O(n);贪心加二分时间 O(n log n)、空间 O(n)。

边界与易错点:严格递增用 bisect_left;若允许相等则用 bisect_right。不要把 tails 直接当作原数组中的一条 LIS。

面试追问:如何恢复一条实际 LIS?为每个位置记录前驱与其在 tails 中的层级,再从最后一层回溯。

LC 72 编辑距离

考频与考点:字节高频二维 DP。考查两个字符串前缀之间的最优编辑代价。

写代码前确认:一次操作可以插入、删除或替换一个字符,每种代价均为 1。

状态与思路:dp[i][j] 表示 word1[:i] 转成 word2[:j] 的最少操作数。末字符相等时继承 dp[i-1][j-1];否则取删除 dp[i-1][j]、插入 dp[i][j-1]、替换 dp[i-1][j-1] 的最小值再加 1。初始化 dp[i][0] = i、dp[0][j] = j;按前缀长度递增遍历;答案在 dp[m][n]。

class Solution:
    def minDistance(self, word1: str, word2: str) -> int:
        m, n = len(word1), len(word2)
        dp = [[0] * (n + 1) for _ in range(m + 1)]
        for i in range(m + 1):
            dp[i][0] = i
        for j in range(n + 1):
            dp[0][j] = j

        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if word1[i - 1] == word2[j - 1]:
                    dp[i][j] = dp[i - 1][j - 1]
                else:
                    dp[i][j] = 1 + min(
                        dp[i - 1][j],
                        dp[i][j - 1],
                        dp[i - 1][j - 1],
                    )
        return dp[m][n]

复杂度:时间 O(mn),空间 O(mn);只保留上一行可压缩到 O(n)。

边界与易错点:数组下标表示前缀长度,字符下标要减 1;“从 word1 插入字符”等价于目标前缀缩短一位,对应左侧状态。

面试追问:操作代价不同时如何修改?把三个候选分别加各自代价;如何输出操作序列?从 dp[m][n] 反向追踪转移来源。

LC 1143 最长公共子序列

考频与考点:考查双序列 DP,是编辑距离、最长重复子数组等模型的基础。

写代码前确认:求子序列而非连续子串;相对顺序必须保留。

状态与思路:dp[i][j] 表示 text1[:i] 与 text2[:j] 的 LCS 长度。末字符相等时由 dp[i-1][j-1] + 1 转移;否则舍弃任一末字符,取 max(dp[i-1][j], dp[i][j-1])。空前缀所在行列初始化为 0;两个维度均递增;答案为 dp[m][n]。

class Solution:
    def longestCommonSubsequence(self, text1: str, text2: str) -> int:
        m, n = len(text1), len(text2)
        dp = [[0] * (n + 1) for _ in range(m + 1)]
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if text1[i - 1] == text2[j - 1]:
                    dp[i][j] = dp[i - 1][j - 1] + 1
                else:
                    dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
        return dp[m][n]

复杂度:时间 O(mn),空间 O(mn);滚动数组可降至 O(min(m,n))。

边界与易错点:字符相等时不能写成同一行或同一列加 1;这会重复使用字符。

面试追问:最长公共子串为何不同?其状态表示“以两个位置结尾的公共连续段”,字符不等时必须归零。

LC 53 最大子数组和

考频与考点:字节高频题。考查 Kadane 算法和连续子数组状态设计。

写代码前确认:子数组不能为空;全为负数时应返回最大的负数。

状态与思路:dp[i] 表示必须以 nums[i] 结尾的最大子数组和,转移为 max(nums[i], dp[i-1] + nums[i])。dp[0] = nums[0],从左向右遍历,答案是所有 dp[i] 的最大值而非最后一个状态。由于只依赖前一项,可压缩为变量 ending。

from typing import List

class Solution:
    def maxSubArray(self, nums: List[int]) -> int:
        ending = answer = nums[0]
        for value in nums[1:]:
            ending = max(value, ending + value)
            answer = max(answer, ending)
        return answer

复杂度:时间 O(n),空间 O(1)。

边界与易错点:不能将初值设为 0,否则全负数组会错误地选择空数组。

面试追问:如何返回区间?当 ending 从当前值重新开始时更新候选左端点,在全局答案变大时记录左右端点。

LC 121 买卖股票的最佳时机

考频与考点:单次交易模型,考查状态机 DP 与前缀最小值。

写代码前确认:只能买卖各一次,且必须先买后卖;允许不交易,此时利润为 0。

状态与思路:第 i 天结束后,cash 表示未持股最大利润,hold 表示持股最大利润。转移为 cash = max(cash, hold + price)、hold = max(hold, -price),后者只能从未交易状态买入。初始化 cash = 0、hold = -prices[0];按天从左到右;答案为最后的 cash。等价写法是维护历史最低价格。

from typing import List

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        min_price = prices[0]
        answer = 0
        for price in prices[1:]:
            answer = max(answer, price - min_price)
            min_price = min(min_price, price)
        return answer

复杂度:时间 O(n),空间 O(1)。

边界与易错点:不能用未来最低价减当前价格;最低价只能来自卖出日之前。

面试追问:与 LC 122 的差别在哪里?LC 121 的持股状态只能由初始现金买入,LC 122 可用此前卖出所得再次买入。

LC 322 零钱兑换

考频与考点:完全背包最少数量问题,重点是可重复选择与不可达状态。

写代码前确认:硬币可无限次使用;只求最少枚数,不要求方案数或具体组合。

状态与思路:dp[x] 表示凑成金额 x 的最少硬币数,转移为 dp[x] = min(dp[x], dp[x-coin] + 1)。dp[0] = 0,其余初始化为无穷。按硬币在外、金额从小到大遍历体现完全背包;若只求最少数,金额在外也能得到正确值。答案为 dp[amount],仍为无穷则返回 -1。

from typing import List

class Solution:
    def coinChange(self, coins: List[int], amount: int) -> int:
        unreachable = amount + 1
        dp = [unreachable] * (amount + 1)
        dp[0] = 0
        for coin in coins:
            for value in range(coin, amount + 1):
                dp[value] = min(dp[value], dp[value - coin] + 1)
        return -1 if dp[amount] == unreachable else dp[amount]

复杂度:时间 O(n * amount),空间 O(amount)。

边界与易错点:金额必须正序遍历才允许同一硬币重复使用;不可达初值不能设为 0。

面试追问:若求组合数,硬币在外可避免不同排列重复计数;若求排列数,则金额在外、硬币在内。

LC 122 买卖股票的最佳时机 II

考频与考点:无限次交易的股票状态机,也可转化为累加所有正收益。

写代码前确认:同一时刻最多持有一股,卖出后可以再次买入;允许当天卖出再买入不影响最大利润。

状态与思路:cash、hold 分别表示当天结束未持股和持股的最大利润。转移为 new_cash = max(cash, hold + price)、new_hold = max(hold, cash - price);初始化为 0 和 -prices[0];从左到右遍历;答案是最终 cash。由于每段上涨都可独立兑现,也可贪心累加相邻正差值。

from typing import List

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        cash, hold = 0, -prices[0]
        for price in prices[1:]:
            cash, hold = max(cash, hold + price), max(hold, cash - price)
        return cash
from typing import List

class SolutionGreedy:
    def maxProfit(self, prices: List[int]) -> int:
        return sum(max(0, prices[i] - prices[i - 1]) for i in range(1, len(prices)))

复杂度:两种解法均为时间 O(n)、空间 O(1)。

边界与易错点:状态转移应基于上一日状态;Python 同时赋值右侧会先整体求值,因此这里可以安全更新。

面试追问:加入手续费、冷冻期或最多 k 次交易后,贪心通常失效,但持股/未持股状态机可以继续扩展。

LC 198 打家劫舍

考频与考点:线性 DP,核心是相邻位置不可同时选择。

写代码前确认:金额非负;房屋首尾不相邻。若首尾也相邻,则是环形版本。

状态与思路:dp[i] 表示考虑前 i 间房的最高金额,转移为 dp[i] = max(dp[i-1], dp[i-2] + nums[i-1])。初始化 dp[0] = 0、dp[1] = nums[0];按房屋顺序向右;答案在 dp[n]。代码用两个变量保存 dp[i-2] 与 dp[i-1]。

from typing import List

class Solution:
    def rob(self, nums: List[int]) -> int:
        two_back = one_back = 0
        for money in nums:
            two_back, one_back = one_back, max(one_back, two_back + money)
        return one_back

复杂度:时间 O(n),空间 O(1)。

边界与易错点:滚动更新时右侧必须同时基于旧值;只有一间房时也能由统一初始化处理。

面试追问:环形房屋如何处理?分别计算“不选最后一间”和“不选第一间”的线性答案,再取最大值。

LC 70 爬楼梯

考频与考点:最基础的线性 DP,用于考查状态定义与空间压缩。

写代码前确认:每次只能走 1 或 2 阶,n >= 1;顺序不同视为不同走法。

状态与思路:dp[i] 表示到达第 i 阶的方法数,最后一步来自 i-1 或 i-2,故 dp[i] = dp[i-1] + dp[i-2]。初始化 dp[0] = 1、dp[1] = 1;按阶数递增;答案为 dp[n]。只依赖前两项,可滚动压缩。

class Solution:
    def climbStairs(self, n: int) -> int:
        previous, current = 1, 1
        for _ in range(n):
            previous, current = current, previous + current
        return previous

复杂度:时间 O(n),空间 O(1)。

边界与易错点:dp[0] = 1 表示“什么都不做”这一种空方案,是递推的中性起点。

面试追问:步长扩展为集合时可做完全背包;若 n 极大,可用矩阵快速幂将时间降至 O(log n)。

LC 32 最长有效括号

考频与考点:新增高频题。考查位置 DP,或用栈维护最近一个无法匹配的位置。

写代码前确认:求连续子串长度;输入只含左右括号。

状态与思路:dp[i] 表示必须以 s[i] 结尾的最长有效括号长度。只有 s[i] == ')' 才可能转移:若前一字符为左括号,加上 dp[i-2];否则跳过前一段有效串,检查位置 i-dp[i-1]-1 是否为左括号,再接上更早的有效串。数组初始化为 0;i 从 1 向右;答案为 max(dp)。

class Solution:
    def longestValidParentheses(self, s: str) -> int:
        dp = [0] * len(s)
        answer = 0
        for i in range(1, len(s)):
            if s[i] != ")":
                continue
            if s[i - 1] == "(":
                dp[i] = 2 + (dp[i - 2] if i >= 2 else 0)
            else:
                left = i - dp[i - 1] - 1
                if left >= 0 and s[left] == "(":
                    dp[i] = dp[i - 1] + 2
                    if left > 0:
                        dp[i] += dp[left - 1]
            answer = max(answer, dp[i])
        return answer

复杂度:时间 O(n),空间 O(n);双向计数扫描可做到 O(1) 空间。

边界与易错点:匹配 ...)) 时,要先跨过 dp[i-1] 找潜在左括号,并拼接左侧已有有效段。

面试追问:栈解法为何先压入 -1?它代表最近一个不可参与匹配的位置,使当前有效长度可以直接用下标差得到。

LC 64 最小路径和

考频与考点:新增二维网格 DP,考查边界初始化与原地状态压缩。

写代码前确认:只能向右或向下,网格非空;是否允许覆盖输入需要提前确认。

状态与思路:dp[i][j] 表示到达 (i,j) 的最小路径和,转移为当前值加上方、左方的较小者。左上角初始化为自身,首行只能来自左侧,首列只能来自上方;按行从左到右遍历;答案在右下角。代码直接把 grid 当作 DP 表,代价是覆盖原数据。

from typing import List

class Solution:
    def minPathSum(self, grid: List[List[int]]) -> int:
        rows, cols = len(grid), len(grid[0])
        for i in range(rows):
            for j in range(cols):
                if i == 0 and j == 0:
                    continue
                if i == 0:
                    grid[i][j] += grid[i][j - 1]
                elif j == 0:
                    grid[i][j] += grid[i - 1][j]
                else:
                    grid[i][j] += min(grid[i - 1][j], grid[i][j - 1])
        return grid[-1][-1]

复杂度:时间 O(mn),除输入外空间 O(1);不修改输入时可用 O(n) 滚动数组。

边界与易错点:首行和首列只有一个来源,不能用不存在的方向参与最小值比较。

面试追问:若允许四个方向,普通 DP 的无环依赖被破坏,应建图后使用 Dijkstra 等最短路算法。

LC 118 杨辉三角

考频与考点:新增基础 DP。考查上一行到下一行的构造关系。

写代码前确认:返回前 numRows 行,而不是某一行;每行两端固定为 1。

状态与思路:triangle[i][j] 表示第 i 行第 j 个数,内部位置由上一行相邻两数相加得到。第一行初始化为 [1];按行递增构造,每行先填充 1,再更新内部位置;答案是完整二维列表。

from typing import List

class Solution:
    def generate(self, numRows: int) -> List[List[int]]:
        triangle: List[List[int]] = []
        for row_index in range(numRows):
            row = [1] * (row_index + 1)
            for j in range(1, row_index):
                row[j] = triangle[-1][j - 1] + triangle[-1][j]
            triangle.append(row)
        return triangle

复杂度:共生成 O(numRows^2) 个数,时间和输出空间均为 O(numRows^2)。

边界与易错点:只有一行时内部循环为空;输出本身需要二维空间,不能将其算作可省略的辅助空间。

面试追问:只求第 k 行时如何降到 O(k) 空间?使用一维数组并从右向左原地更新,避免覆盖上一层依赖。

LC 120 三角形最小路径和

考频与考点:新增不规则网格 DP,适合考查自底向上压缩。

写代码前确认:相邻下一层位置是同下标或下标加一;路径必须从顶到底。

状态与思路:自底向上定义 dp[j] 为从当前层位置 j 到底部的最小路径和。以最后一行为初始化;处理第 i 层时,转移为 dp[j] = triangle[i][j] + min(dp[j], dp[j+1])。层从倒数第二层向上、位置从左向右;最终答案在 dp[0]。

from typing import List

class Solution:
    def minimumTotal(self, triangle: List[List[int]]) -> int:
        dp = triangle[-1][:]
        for i in range(len(triangle) - 2, -1, -1):
            for j in range(i + 1):
                dp[j] = triangle[i][j] + min(dp[j], dp[j + 1])
        return dp[0]

复杂度:时间 O(n^2),空间 O(n)。

边界与易错点:自底向上时 dp[j] 与 dp[j+1] 均来自下一层;若改为自顶向下原地更新,需要从右向左。

面试追问:若要恢复路径,可保留二维 DP,并从顶点依次选择代价更小的两个下一层状态。

LC 279 完全平方数

考频与考点:新增完全背包题,与零钱兑换同构。

写代码前确认:每个完全平方数可以重复使用,目标是最少数量。

状态与思路:dp[value] 表示组成 value 的最少平方数个数,枚举平方数 square 后转移 dp[value] = min(dp[value], dp[value-square] + 1)。dp[0] = 0,其余为无穷;平方数在外,容量从小到大;答案为 dp[n]。

from math import isqrt

class Solution:
    def numSquares(self, n: int) -> int:
        dp = [0] + [n + 1] * n
        for base in range(1, isqrt(n) + 1):
            square = base * base
            for value in range(square, n + 1):
                dp[value] = min(dp[value], dp[value - square] + 1)
        return dp[n]

复杂度:时间 O(n * sqrt(n)),空间 O(n)。

边界与易错点:容量正序表示完全背包;isqrt 可避免浮点开方误差。

面试追问:也可将每个整数看成图节点,用 BFS 找最少层数;数论解法可依据四平方和定理进一步优化。

LC 416 分割等和子集

考频与考点:新增 0-1 背包可达性问题,遍历方向是关键。

写代码前确认:每个元素只能使用一次;总和为奇数时必然无解。

状态与思路:目标容量是总和的一半,dp[value] 表示能否从已处理元素中选出和为 value 的子集。dp[0] = True,其余为假;对每个数,容量必须从目标值向下遍历,转移为 dp[value] |= dp[value-num];答案在 dp[target]。倒序保证本轮不会重复使用当前元素。

from typing import List

class Solution:
    def canPartition(self, nums: List[int]) -> bool:
        total = sum(nums)
        if total % 2:
            return False
        target = total // 2
        dp = [False] * (target + 1)
        dp[0] = True
        for num in nums:
            for value in range(target, num - 1, -1):
                dp[value] = dp[value] or dp[value - num]
        return dp[target]

复杂度:时间 O(n * target),空间 O(target)。

边界与易错点:容量若正序更新,会把一个元素使用多次,错误地变成完全背包。

面试追问:若要求恰好选 k 个数,应增加“已选数量”维度;若有负数,容量下标需平移或改用集合维护可达和。

LC 647 回文子串

考频与考点:新增回文 DP。与 LC 5 共用状态,但目标由最长长度变为计数。

写代码前确认:相同内容出现在不同位置要分别计数;单字符也是回文子串。

状态与思路:dp[i][j] 表示闭区间是否为回文,转移为 s[i] == s[j] and (j - i <= 1 or dp[i+1][j-1])。初始化依靠统一短区间条件;i 从右到左、j 从 i 向右;每个为真的状态给答案加一,答案是所有状态之和。

class Solution:
    def countSubstrings(self, s: str) -> int:
        n = len(s)
        dp = [[False] * n for _ in range(n)]
        count = 0
        for i in range(n - 1, -1, -1):
            for j in range(i, n):
                dp[i][j] = s[i] == s[j] and (j - i <= 1 or dp[i + 1][j - 1])
                count += int(dp[i][j])
        return count

复杂度:时间和空间均为 O(n^2);中心扩展可将空间降到 O(1)。

边界与易错点:这是按位置计数,不应使用集合去重;遍历方向仍需满足对内部区间的依赖。

面试追问:中心扩展总共有 2n-1 个中心,每成功扩展一次就得到一个不同位置的回文子串。

LC 718 最长重复子数组

考频与考点:新增双序列 DP。名称是“子数组”,因此要求连续。

写代码前确认:两个数组中的重复片段都必须连续;只返回长度。

状态与思路:dp[i][j] 表示分别以 nums1[i-1]、nums2[j-1] 结尾的最长公共连续片段长度。元素相等时由左上角加一,否则归零。空前缀行列初始化为 0;两个维度递增;答案是所有 dp[i][j] 的最大值,不一定在右下角。

from typing import List

class Solution:
    def findLength(self, nums1: List[int], nums2: List[int]) -> int:
        m, n = len(nums1), len(nums2)
        dp = [[0] * (n + 1) for _ in range(m + 1)]
        answer = 0
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if nums1[i - 1] == nums2[j - 1]:
                    dp[i][j] = dp[i - 1][j - 1] + 1
                    answer = max(answer, dp[i][j])
        return answer

复杂度:时间 O(mn),空间 O(mn);一维倒序更新可降到 O(n)。

边界与易错点:字符不等时状态必须为 0,不能像 LCS 那样取上方或左方最大值。

面试追问:数据规模更大时,可二分答案并用滚动哈希检查公共片段,或使用后缀自动机。

LC 918 环形子数组的最大和

考频与考点:新增 Kadane 变形。考查把跨边界区间转化为“总和减去中间最小子数组”。

写代码前确认:子数组不能为空;每个元素最多使用一次。

状态与思路:分别维护以当前位置结尾的最大和 max_ending 与最小和 min_ending,转移均为“从当前重启或接在前段之后”;初始化为首元素,按数组从左向右遍历。非环形答案是最大子数组和,环形答案是总和减最小子数组和。若最大值小于 0,说明全为负数,不能用总和减整个数组得到空数组,直接返回最大值。

from typing import List

class Solution:
    def maxSubarraySumCircular(self, nums: List[int]) -> int:
        total = nums[0]
        max_ending = max_sum = nums[0]
        min_ending = min_sum = nums[0]
        for value in nums[1:]:
            max_ending = max(value, max_ending + value)
            max_sum = max(max_sum, max_ending)
            min_ending = min(value, min_ending + value)
            min_sum = min(min_sum, min_ending)
            total += value
        return max_sum if max_sum < 0 else max(max_sum, total - min_sum)

复杂度:时间 O(n),空间 O(1)。

边界与易错点:全负数组必须特判,否则环形候选会错误地选择空子数组。

面试追问:如何返回环形区间下标?同时记录最大、最小子数组边界;跨边界答案对应最小区间之外的两段。

LC 1262 可被三整除的最大和

考频与考点:新增余数状态 DP。考查将大数值容量压缩为有限模状态。

写代码前确认:每个元素最多选择一次,可以选择空集,因此答案至少为 0。

状态与思路:dp[r] 表示已处理元素中,和模 3 为 r 的最大值。初始化 dp = [0, -inf, -inf];处理每个数时必须基于旧数组转移,选择它后更新余数 (r + num) % 3。按元素从左到右,余数维度遍历旧状态;答案在 dp[0]。

from typing import List

class Solution:
    def maxSumDivThree(self, nums: List[int]) -> int:
        negative_infinity = float("-inf")
        dp: List[float] = [0, negative_infinity, negative_infinity]
        for num in nums:
            next_dp = dp[:]
            for remainder in range(3):
                if dp[remainder] != negative_infinity:
                    new_remainder = (remainder + num) % 3
                    next_dp[new_remainder] = max(
                        next_dp[new_remainder], dp[remainder] + num
                    )
            dp = next_dp
        return int(dp[0])

复杂度:时间 O(n),空间 O(1),因为余数状态固定为 3 个。

边界与易错点:不可达状态不能初始化为 0;直接原地更新可能在同一轮重复选择当前元素。

面试追问:若要求和能被 k 整除,只需把状态扩展为 k 个余数,时间 O(nk)、空间 O(k)。