动态规划经典题:最大子数组和的状态设计与Kadane算法详解 1. 从暴力枚举到动态规划最大子数组和难在哪儿我遇到过不少同学拿到这道题第一反应就是这不就两层循环暴力枚举嘛然后兴奋地写出一个O(n^2)的解法提交之后发现数据稍微大一点直接超时。等看完题解又觉得 就这但下次换个马甲比如环形数组、二维矩阵又不会做了。这道题真正的价值不在答案本身而在于它逼你想清楚一件事动态规划的状态到底该怎么设计。1.1 暴力枚举为什么注定走不远暴力思路非常直白枚举所有可能的子数组起点i和终点j计算区间[i, j]的和取最大值。int maxSubArray(vectorint nums) { int n nums.size(); int ans INT_MIN; for (int i 0; i n; i) { int sum 0; for (int j i; j n; j) { sum nums[j]; ans max(ans, sum); } } return ans; }这个解法时间复杂度是O(n^2)空间复杂度O(1)。问题在于当数组长度达到10^5级别时10^10次操作在绝大多数评测环境下都是不可接受的。你可能会想那我用前缀和优化一下枚举 确实可以省掉内层循环里的累加但枚举的区间数量依然是n(n1)/2个一个都少不了。说句实在话暴力法作为验证答案对不对的工具是有价值的但作为面试或竞赛的最终解法基本拿不到分。这也引出了核心矛盾我们能不能在枚举区间这个思路上做文章把复杂度降到O(n)1.2 以 i 结尾这个视角才是破局点很多人第一次接触动态规划时最自然的想法是让dp[i]表示前 i 个元素中的最大子数组和。这个定义听起来没问题但真正写状态转移方程时会卡住dp[i]和dp[i-1]之间没有干净的递推关系。因为前 i 个元素的最大子数组并不知道自己结束在哪个位置也就无法判断下一个元素能不能接上来。换个角度如果dp[i]表示以第 i 个元素结尾的最大子数组和那么问题就变得清晰了。每个dp[i]只关心两件事要么把nums[i]接到前面某个以i-1结尾的最优子数组后面要么干脆自己单独成为一个子数组。这种以当前位置结尾的设计在字符串DP比如最长递增子序列里也很常见区别在于这里的转移条件简单得多——不需要比较元素大小只取决于累加和的正负。我自己的体会是动态规划最难的一步从来不是写方程而是选对状态定义。以结尾位置作为切入点相当于把一个看不见全局的局部问题转化为一串有递推关系的子问题数学归纳法的味道一下就出来了。2. 状态设计与转移方程为什么dp[i] max(nums[i], dp[i-1] nums[i])是唯一正解2.1 从要不要接上到接上是否划算假设我们已经知道了dp[i-1]也就是以第i-1个元素结尾的最大子数组和。现在来了一个新元素nums[i]我们要决定nums[i]是加入前面那个子数组即dp[i-1] nums[i]还是自己另起炉灶即nums[i]。直觉上这个决定取决于dp[i-1]的正负。如果dp[i-1]是负数把它加进来只会拖累nums[i]这时候重新开始一定更优如果dp[i-1]是正数那么接上去能让总和更大。更有趣的是这个直觉可以用数学关系严格证明对于任意实数xx y ≥ y当且仅当x ≥ 0。所以max(nums[i], dp[i-1] nums[i])这个形式其实是在dp[i-1] ≥ 0时选后者在dp[i-1] 0时选前者。一个max就考虑完了所有情况简洁得让人拍案。2.2 完整递推过程的推演我们还是把整个过程走一遍用一个具体的例子。假设数组是[-2, 1, -3, 4, -1, 2, 1, -5, 4]dp[0] -2因为以第一个元素结尾的只有它自己。dp[1] max(1, -2 1) 1说明从1重新开始比接上-2好。dp[2] max(-3, 1 (-3)) -2此时虽然结果是负的但依然保留了以位置2结尾的最优值。dp[3] max(4, -2 4) 4。dp[4] max(-1, 4 (-1)) 3这里4加上-1还是正的说明接上去不亏。继续算到dp[6] 6。dp[7] max(-5, 6 (-5)) 1累加和缩水了但没变成负数。dp[8] max(4, 1 4) 5。最终答案不是dp[8] 5而是所有dp[i]里的最大值6。这一步很多人会漏dp数组的最后一个值并不一定是最终答案因为最大子数组不一定以最后一个元素结尾。这就是为什么我们需要在递推过程中同步维护一个全局最大值。提示全局最大值ans的初始值不能用0而要设为INT_MIN或nums[0]。有些同学习惯把答案初始化为0遇到全负数数组时就会得到错误结果0而不是真正的最大子数组和。2.3 为什么不能等最后再取 max也有同学会问那我能不能在递推结束后对dp数组统一取最大值当然可以但那样你需要一个额外数组存下所有dp[i]空间复杂度从O(1)变成O(n)。更关键的是如果以后要扩展成实时流式处理数据源源不断到来随时查询当前最大子数组和你就必须在递推过程中就维护好ans。养成这个习惯对后续的变体题很有帮助。另外我还想强调一个理解误区dp[i]允许是负数。这个设计不是缺陷而是必然。因为dp[i]是以 i 结尾的最优值在全是负数的数组里最优值当然也是负数最不济也要包含nums[i]本身。理解这一点才不会在看到dp[i] 0时误以为是算法错了。3. C 代码实现从基础版到生产级的四个细节3.1 最精简的 Kadane 算法写法以下是用滚动变量优化后的经典实现也是很多教材里的标准答案int maxSubArray(vectorint nums) { int n nums.size(); if (n 0) return 0; // 根据题目约定决定空数组返回值 int dp nums[0]; // dp 记录以当前元素结尾的最大子数组和 int ans nums[0]; // ans 记录全局最大值 for (int i 1; i n; i) { // 状态转移接上前面 vs 自己重开 dp max(nums[i], dp nums[i]); // 更新全局最大值 ans max(ans, dp); } return ans; }这里面有几个细节值得展开说说初始化dp和ans都用nums[0]而不是0。这样可以天然处理数组长度为1和全负数两种边界情况不用额外加判断。从小到大遍历一遍不需要额外数据结构时间O(n)、空间O(1)这就是 Kadane 算法的核心。在函数开头处理空数组是必要的。虽然很多 LeetCode 风格题目保证至少一个元素但工程实践中数组可能是空的这时建议根据需求返回值而不是直接访问nums[0]导致未定义行为。3.2 两个隐藏坑int 溢出与负数初始值第一个坑是int溢出。当数组元素绝对值很大比如接近INT_MAX且数量很多时dp nums[i]可能超过int范围。在标准题目里数据范围通常不会让int溢出但我在实际工程中见过真实数据把累计和干爆的情况。稳妥的做法是内部计算用long long最后再转回int或根据题目要求直接返回long long。例如long long maxSubArray(vectorint nums) { long long dp nums[0]; long long ans nums[0]; for (int i 1; i nums.size(); i) { dp max(static_castlong long(nums[i]), dp nums[i]); ans max(ans, dp); } return ans; }第二个坑是初始值选错。如果写成int dp 0那么在nums全为负数时dp max(nums[i], 0 nums[i])永远等于nums[i]这个问题倒不大但ans如果也初始化为0就永远返回0了。所以ans的初始值必须是nums[0]或INT_MIN二者选一。我推荐优先用nums[0]因为它在数组长度为 1 时天然正确而且不会产生INT_MIN 负数这类溢出风险虽然现代编译器通常正常但没必要冒险。3.3 进阶需求同时返回最大子数组的左右端点有时候题目不止要最大值还要你输出对应的子数组区间。这只需要额外维护两个变量当前区间的起点start和最优区间的起点终点bestL、bestR。注意一个容易错的地方当nums[i] dp nums[i]时说明要重新开始此时应该更新start i否则维持原来的起点不变。vectorint maxSubArrayWithIndex(vectorint nums) { int n nums.size(); long long dp nums[0], ans nums[0]; int start 0, bestL 0, bestR 0; for (int i 1; i n; i) { if (nums[i] dp nums[i]) { // 重新开始更优 dp nums[i]; start i; } else { dp dp nums[i]; } if (dp ans) { ans dp; bestL start; bestR i; } } return {bestL, bestR}; }这里判断条件用而不是是为了在最大值相等时优先保留较短的区间具体看题目要求。如果要求最长区间就改成并注意bestR - bestL的更新规则。这种小细节在面试里特别容易被追问提前想清楚能省很多麻烦。4. 空间优化一维滚动变量背后的DP压缩规律4.1 为什么可以用两个变量替代整个 dp 数组很多教材直接给出滚动变量版本但没有解释为什么能滚动。对于最大子数组和我们的dp[i]在转移时只依赖dp[i-1]更早的dp[0]、dp[1]等一旦计算出dp[i]就不再需要了。这就像排队打饭你只需要知道前一个人的状态不需要记住整条队伍里所有人的状态。这类DP的通用判断标准是状态转移方程中dp[i]只依赖dp[i-1]或更少的历史状态且最终答案可以在转移过程中同步维护。满足这个条件时就可以用固定数量的变量滚动更新把空间复杂度从O(n)降到O(1)。相反如果某个DP的dp[i]依赖dp[i-1]和dp[i-2]比如斐波那契数列那就需要两个历史变量如果依赖dp[i/2]比如某些分治DP那就没法滚动必须保留整个数组或额外数据结构。4.2 滚动变量的通用写法与调试技巧滚动变量写起来很简单但调试时有个坑你失去了完整的dp历史出问题时很难定位。我通常的做法是先写带完整dp数组的版本用小数据跑通再压缩成滚动变量。这样既能确认状态转移方程正确又能保证压缩后行为一致。// 第一阶段完整dp数组用于验证正确性 vectorlong long dp(n); dp[0] nums[0]; long long ans dp[0]; for (int i 1; i n; i) { dp[i] max((long long)nums[i], dp[i-1] nums[i]); ans max(ans, dp[i]); } // 第二阶段确认只依赖前一个状态后压缩成滚动变量 long long dp_cur nums[0]; long long ans dp_cur; for (int i 1; i n; i) { dp_cur max((long long)nums[i], dp_cur nums[i]); ans max(ans, dp_cur); }这种先验证再压缩的套路几乎适用于所有一维DP。我在处理其他算法题时也一直沿用这个流程它节省了大量调试时间强烈推荐。4.3 空间压缩的前置条件总结根据我自己的经验DP空间压缩一般要满足以下三个条件缺一不可依赖范围有限dp[i]只依赖少数几个历史状态通常是连续的前几个。无后效性未来决策只跟当前状态有关跟前序路径无关。最大子数组和完全满足这一点因为状态值本身已经包含了以i结尾的最优历史。结果可在线维护最终答案能在滚动过程中用一个变量同步记录而不需要返回去看所有dp[i]。如果你遇到一个题目的dp[i]依赖所有前面的状态比如某些最长公共子序列的变体那就别强行滚动老老实实用二维数组。空间压缩不是炫技而是确有必要时才用。5. 三道高频变体题环形数组、返回区间、二维矩阵面试或竞赛中出题人总喜欢给最大子数组和套上各种外壳。但只要理解了核心状态设计这些变体其实都能化归到同一个模型。5.1 变体一环形数组中的最大子数组和环形数组允许子数组绕到开头继续也就是说最优解可能是数组中间一段 首尾相连一段。怎么处理一个很经典的转化是最大子数组和 max(普通最大子数组和, 总和 - 最小子数组和)理由很简单如果最优解绕环了那么剩下的部分即环上被挖掉的部分一定是一个和最小的连续子数组。注意这里要求最小子数组和不能是整个数组否则挖掉整段后子数组为空所以需要对边界情况加判断。int maxSubarraySumCircular(vectorint nums) { int total 0; int curMax 0, maxSum INT_MIN; int curMin 0, minSum INT_MAX; for (int x : nums) { total x; curMax max(x, curMax x); maxSum max(maxSum, curMax); curMin min(x, curMin x); minSum min(minSum, curMin); } // 如果所有数都是负数maxSum就是最大值直接返回 return maxSum 0 ? max(maxSum, total - minSum) : maxSum; }这个解法在nums全为正数、全为负数、有正有负时都正确但需要理解最大子数组和最小子数组之间的关系。坦白说第一次见到这题的解法时我也愣了一下绕环和不绕环两种情况用maxSum和total - minSum两条路径覆盖非常巧妙。5.2 变体二返回最大子数组的区间而不是只返回和这个在前面已经写过了但我再补充一点实战经验当数据量很大时vector返回区间要格外注意索引的更新时机。比如dp更新为nums[i]时即重新开始start要立即变成ians更新时才把bestL和bestR刷新。如果代码里把start的更新放在ans更新之后就会导致新区间配旧起点差之毫厘谬以千里。我建议在提交前加一层断言// 验证返回区间内的子数组和等于答案 assert(ans accumulate(nums.begin() bestL, nums.begin() bestR 1, 0LL));这种自校验在调试阶段能帮你快速定位问题面试时也能展示你对代码正确性的严谨态度。5.3 变体三二维矩阵的最大子矩阵和二维版本是很多人的噩梦但它的思路其实是一维 Kadane 的自然扩展先枚举矩阵的上下边界top和bottom把每一列的元素累加成一个一维数组然后对这个一维数组求最大子数组和。列累加可以用前缀和优化让内层变成O(n)总复杂度O(n^3)。int maxSubMatrix(vectorvectorint matrix) { int rows matrix.size(); if (rows 0) return 0; int cols matrix[0].size(); int ans INT_MIN; for (int top 0; top rows; top) { vectorlong long colSum(cols, 0); for (int bottom top; bottom rows; bottom) { for (int c 0; c cols; c) { colSum[c] matrix[bottom][c]; } // 对 colSum 跑一维 Kadane long long dp colSum[0]; ans max(ans, (int)dp); for (int c 1; c cols; c) { dp max(colSum[c], dp colSum[c]); ans max(ans, (int)dp); } } } return ans; }这类二维题目的核心价值在于它教会你如何把未知维度转化为已知模型。遇到二维时先想能不能枚举一个维度降成一维这比硬记模板有用得多。6. 复杂度分析、测试用例设计与个人踩坑记录6.1 时间与空间复杂度Kadane 算法的时间复杂度是O(n)空间复杂度是O(1)滚动变量版。这已经是最优解因为任何算法至少需要读取每个元素一次。如果你看到有人用分治做这题O(n log n)那更多是为了练习分治思想实际工程中 Kadane 就够了。这里可以做个简单对比解法时间复杂度空间复杂度适用场景暴力枚举O(n^2)O(1)极小数据、验证答案前缀和优化枚举O(n^2)O(n)需要频繁查询任意区间和动态规划KadaneO(n)O(1)一次性求全局最大子数组和分治法O(n log n)O(log n)教学演示、涉及区间合并统计我在实际项目中选 Kadane 的最主要理由不是它优美而是它流式友好——数据可以一边读一边处理不需要把整个数组载入内存。在处理超大日志文件或实时指标流时这非常有用。6.2 我实测过的几组边界数据和对应结果边界测试是这类题最容易翻车的地方。下面是我常用的几组用例分享出来大家可以参考// 1. 单个元素 nums {5}; // 预期 5 nums {-7}; // 预期 -7 // 2. 全负数 nums {-1, -2, -3}; // 预期 -1 // 3. 全正数 nums {1, 2, 3, 4}; // 预期 10 // 4. 正负交替最大子数组跨越负数 nums {2, -1, 2, 3, -5}; // 预期 6对应 [2, -1, 2, 3] // 5. 较大数值测试溢出 nums {INT_MAX, INT_MAX}; // 内部用 long long 时不溢出我踩过的一个真实坑是这样的某次给一个数据流处理模块写最大均值子数组的算法最初用了 int 存储中间和线上跑了不到一个月就出现了负数结果。排查到最后发现是累加值越界后变成了INT_MIN/2一类的未定义行为换成long long后问题当场消失。从那以后凡是涉及累加求和的算法我都默认内部用long long只有确认数据范围安全时才退回int。6.3 一个容易被忽略的问题题目里连续到底意味着什么最大子数组的完整定义是最大连续子数组和。连续两个字是灵魂它决定了你不能像背包问题那样跳过某些元素。很多变体题比如最大子序列和如果去掉连续直接对所有正数求和就行了题目难度骤降。所以在读题时务必先确认数组定义是 subarray连续还是 subsequence不连续。这个细节我见过不止一次被忽略导致算法完全偏离方向。6.4 我的个人体会与推荐学习路径如果让我给初学者一条清晰的学习路径我会建议按以下顺序先能写出暴力枚举并且用前缀和优化一次理解O(n^2)的瓶颈所在。再尝试设计以 i 结尾的状态推导转移方程写出完整 DP 数组版本。确认正确后再压缩成滚动变量并配合边界用例测试。最后延伸做环形数组、二维矩阵、返回区间这三个变体。动笔写代码前花十分钟把状态定义、转移方程、初始值、遍历顺序这四件事想清楚比反复改代码有效得多。这也是我做算法题十几年的一个习惯。最大子数组和作为一个入门级 DP它的价值恰恰在于用最少的额外知识让你体会一次设计状态而不是套模板的思考过程。如果你能把这道题的思维方式真正吃透后面遇到最长递增子序列、编辑距离、背包问题等一大批 DP 题都会觉得顺滑很多。说到底动态规划不是背公式而是一种把问题拆成有递推关系的子问题的思考方式。