☰
最大子数组和与Kadane算法:从暴力到动态规划的最优解
2026/10/2 9:45:50 网站建设 项目流程

刷 LeetCode Hot100 的朋友应该都有这种感觉:前几道题还能靠直觉顶着,到第十题就开始碰到“看着简单、上手就卡”的题了。53. 最大子数组和就是这样一道典型题。题面一句话能说完——给定一个整数数组 nums,找出具有最大和的连续非空子数组,返回其最大和——但真正动手写,很多人第一版就是三层循环,一提交直接超时。这篇文章我打算从最笨的解法讲起,把从 O(n³) 到 O(n) 的完整推导链条梳理一遍,同时把 Kadane 算法背后的状态设计、代码实现、边界陷阱以及面试追问的变体题都聊透。适合正在刷 Hot100 的初学者,也适合准备面试、想把这题答出层次感的同学。

1. 先从题目本身说起:这道题到底在考什么

1.1 题面拆解与容易忽略的边界条件

最大子数组和(Maximum Subarray)题号 53,是 LeetCode Hot100 里动态规划板块的开场题之一。题面看起来极其简单,却很考验阅读精度。注意三个关键词:连续、非空、子数组。连续意味着nums[i..j]这种区间,顺序不能打乱;非空意味着至少要选一个元素,不能返回 0 来逃避;子数组不能是子序列,中间不能跳过元素。这三个词看起来很基础,但几乎所有坑都跟它们有关。

一个非常常见的犯错示范:数组是[-2, -3, -1],全负数,正确最大和应该是-1(选那个最大的负数)。但不少新手把初始答案设成 0,然后发现所有候选结果都比 0 小,最终输出 0,直接判错。这就是“非空”约束在捣乱。正确做法是初始值取第一个元素,或者取负无穷大,而不是取 0。

另一个容易被忽略的点:题目只要求返回最大和,不需要返回子数组本身。这意味着你不需要真的把区间记录下来,只需要维护一个代表“当前最优子数组”的状态。这一点理解了,后面 Kadane 算法的代码为什么能写得那么短,也就顺理成章了。虽然这题在 LeetCode 上标的是中等(Medium),但它在 Hot100 里的地位,远不止中等难度这么简单。

1.2 为什么它值得排在 Hot100 第10题

Hot100 的题目顺序不是简单按难度排的,但把最大子数组和放在这么靠前的位置,我认为有三个原因。第一,它是动态规划入门的最佳样例:状态定义、转移方程、空间压缩三个核心概念全部浓缩在这一道题里;第二,它是大量后续题目的基石,环形子数组最大和、最大子矩阵、买卖股票的最佳时机、最大乘积子数组,都能从这题的状态设计上找到影子;第三,它的代码量极小,非常适合面试官在短时间内考察候选人对“状态”和“最优子结构”的理解。

我自己刷下来的体会是,Hot100 前十道题里,这道题是典型的“分水岭”。能独立 AC 的人,说明基本建立了 DP 的“状态思维”;AC 不了的人,往往不是代码能力问题,而是还没有习惯“用之前推导出的状态来求解当前答案”这一套思路。很多面经里也印证了这一点:面试官喜欢拿这道题做热身,因为它短小,却能快速试探出候选人的算法功底。

读到这里你会发现,这题真正的考点不是你会不会写 for 循环,而是你能不能设计出一个正确的状态,并且把转移逻辑讲清楚。接下来我按一条完整的思考链条,从暴力解法一步步走到最优解。

2. 从暴力到前缀和:一条被很多人跳过的思考链条

2.1 三层循环:最直觉但最不可取的写法

很多人的第一反应是枚举所有子数组。子数组由起点 i 和终点 j 共同确定,所以两层循环枚举区间,再用第三层循环累加区间和,时间复杂度 O(n³)。这套代码逻辑完全正确,甚至能通过小规模测试,但 LeetCode 的 n 可以到 10^5,O(n³) 是必然超时的。

def max_subarray_brute(nums): n = len(nums) best = float('-inf') for i in range(n): for j in range(i, n): s = 0 for k in range(i, j + 1): s += nums[k] best = max(best, s) return best

三层循环最大的问题不是慢,而是存在大量重复计算:区间(i, j)的和,明明可以由区间(i, j-1)的和加上nums[j]得到,根本不需要第三层循环从头累加。认识到这一点,下一步优化方向就非常自然了。

2.2 前缀和优化:以空间换时间的过渡方案

把“重复累加”变成“一次减法”,最经典的手段就是前缀和。预处理一个数组prefix,让prefix[i]表示nums[0..i-1]的和,那么任意区间nums[i..j]的和就等于prefix[j+1] - prefix[i],区间求和变成 O(1),整体复杂度降到 O(n²)。

def max_subarray_prefix(nums): n = len(nums) prefix = [0] * (n + 1) for i in range(n): prefix[i + 1] = prefix[i] + nums[i] best = float('-inf') for i in range(n): for j in range(i, n): best = max(best, prefix[j + 1] - prefix[i]) return best

前缀和的思路本身是很好的基础功底,但它依然解决不了本题的规模问题:n=10^5 时,O(n²) 是 10^10 量级的计算,依旧不可行。不过这个版本揭示了一个非常关键的信息——我们要最大化的对象是prefix[j+1] - prefix[i](其中 j ≥ i)。

换个角度看这个式子:固定右端点 j 时,要让差值最大,只需要让prefix[i]尽可能小,也就是在i ∈ [0, j]范围内维护一个“最小前缀和”。边遍历边维护最小值,时间复杂度就能降到 O(n)。这条路其实也能走到正确答案,只是思路稍绕。更干净、也更经典的路线,是下面要讲的 Kadane 算法。

提示:暴力→前缀和→最优解这条推导链,值得在纸上亲手走一遍。很多面试官问这题,不只看你会不会最优解,更看你能不能展示出“从笨办法一步步优化”的能力。

3. Kadane算法的核心:动态规划状态为什么这样设计

3.1 状态定义与转移方程推导

Kadane 算法的本质是动态规划,而 DP 的关键在“状态怎么定义”。初学者最常见的困惑是:为什么要把dp[i]定义成“以 nums[i] 结尾的最大子数组和”,而不是“前 i 个元素的最大子数组和”?

这两个定义的区别很微妙,但结果是天壤之别。如果定义成“前 i 个元素的最大和”,那么dp[i]和dp[i-1]之间没有清晰可计算的转移关系——第 i 个元素可能加入之前的某个子数组,也可能自己开一个新子数组,但你不知道之前那个最优子数组结束在哪里,信息不足,无法递推。而定义成“以 nums[i] 结尾”之后,局面就完全清晰了:以 nums[i] 结尾的子数组只有两种可能,要么是“以 nums[i-1] 结尾的最大子数组”接上 nums[i],要么是抛弃之前所有结果,让 nums[i] 单独成为新子数组。

于是转移方程只有一行:

  • dp[i] = max(dp[i-1] + nums[i], nums[i])

这个方程翻译成人话就是:如果dp[i-1]是负数,把它加进来只会拖累当前的 nums[i],不如从 nums[i] 重新开始;如果dp[i-1]是正数,延续它会比单独拿当前元素更好。所谓“最优子结构”,在这里表现得非常直观。

还有一个初学者最容易犯的错误:最终答案不是dp[n-1],而是所有dp[i]的最大值。因为dp[n-1]只代表“以最后一个元素结尾”的子数组和,而最大子数组完全可能在数组中间就结束了。所以必须用一个全局变量,在遍历过程中不断更新历史最大值。

3.2 滚动变量压缩的合理性说明

标准 DP 解法需要维护一整个dp数组,空间复杂度 O(n)。但我们发现,dp[i]只依赖dp[i-1],从不需要回头看更早的状态。这种“只用前一个状态”的递推,完全可以用一个变量滚动维护,这就是我们最常见的 Kadane 写法:

def max_sub_array(nums): cur = best = nums[0] for x in nums[1:]: cur = max(x, cur + x) best = max(best, cur) return best

这里的cur就是压缩后的dp[i],表示“以当前元素结尾的最大子数组和”;best是历史最大值。空间压缩后,时间 O(n),空间 O(1)。需要提醒的是,压缩后的代码虽然只有几行,但逻辑和原版 DP 是完全等价的。面试时如果直接写压缩版,最好能用一句话说清楚“我用 cur 表示以当前元素结尾的最大和”,避免面试官误以为你在背答案。

3.3 贪心视角的等价理解

除了 DP 视角,Kadane 算法还可以被理解成一种贪心策略:维护一个“当前子数组的和”,只要它大于 0,就说明它对后续元素还有增益,继续累加;一旦它变成负数,就把它整个丢掉,从下一个元素重新开始。注意这里的“丢掉”指的是整个子数组重置,而不是只丢掉最后一个负数。

这两种理解建议都掌握。DP 视角更严谨,适合在面试中讲“状态设计”;贪心视角更形象,适合快速写代码和跟人解释直觉。它们本质上是一体两面:贪心里“要不要继续累加”的判断,对应的正是 DP 方程里max(cur + x, x)这个选择。能在一分钟内切换两种视角解释同一段代码,才是真正吃透了这题。

4. 代码实现与语言细节:三种主流写法对比

4.1 标准Kadane实现(Python/Java/C++)

先给出三种主流语言的完整实现,逻辑完全一致。

# Python class Solution: def maxSubArray(self, nums: List[int]) -> int: cur = best = nums[0] for x in nums[1:]: cur = max(x, cur + x) best = max(best, cur) return best
// Java class Solution { public int maxSubArray(int[] nums) { int cur = nums[0], best = nums[0]; for (int i = 1; i < nums.length; i++) { cur = Math.max(nums[i], cur + nums[i]); best = Math.max(best, cur); } return best; } }
// C++ class Solution { public: int maxSubArray(vector<int>& nums) { int cur = nums[0], best = nums[0]; for (int i = 1; i < nums.size(); i++) { cur = max(nums[i], cur + nums[i]); best = max(best, cur); } return best; } };

唯一值得提醒的细节在 Python:nums[1:]会创建新数组,增加额外内存。LeetCode 上通常能过,但严格起见可以改成for i in range(1, len(nums))的方式,避免无谓拷贝。实测在接近 10^5 长度的用例下,切片写法的不必要开销是真实存在的,虽然不影响正确性,但养成好习惯总没错。

4.2 进阶变体:返回最大子数组的起止下标

真实工程场景里,“只返回和”往往不够,产品需要知道这个最大子数组到底从哪开始、到哪结束。这个变体经常作为面试追问出现,实现时的关键坑在于:什么时候更新起点。

def max_subarray_with_indices(nums): cur = best = nums[0] start = end = temp_start = 0 for i in range(1, len(nums)): if cur + nums[i] > nums[i]: cur = cur + nums[i] else: cur = nums[i] temp_start = i if cur > best: best = cur start = temp_start end = i return best, start, end

这里的核心是维护一个“临时起点”temp_start。只有确认当前子数组比历史最佳更好时,才把临时起点同步到最终结果。如果只在更新 best 时才尝试记录起点,就会出错——因为起点的生效时机是“决定重新开始的那一刻”,而不是“best 被刷新的那一刻”。这个细节非常容易写错,强烈建议亲手跑几个用例验证,比如[1, -2, 3, 4]和[-2, 1, -3, 4, -1, 2, 1, -5, 4]。

4.3 分治法的思路与适用场景

除了 DP 和贪心,这题还有经典的分治做法:把数组从中间对半切开,最大子数组要么完全在左半、要么完全在右半、要么横跨中点。前两种情况递归求解,第三种情况从中间向两侧扩展,分别求出“包含中点的最大左后缀”和“包含中点下一个位置的最大右前缀”,相加即可。

def max_subarray_divide(nums, l, r): if l == r: return nums[l] m = (l + r) // 2 left_best = max_subarray_divide(nums, l, m) right_best = max_subarray_divide(nums, m + 1, r) left_sum = float('-inf') s = 0 for i in range(m, l - 1, -1): s += nums[i] left_sum = max(left_sum, s) right_sum = float('-inf') s = 0 for i in range(m + 1, r + 1): s += nums[i] right_sum = max(right_sum, s) return max(left_best, right_best, left_sum + right_sum)

分治的时间复杂度是 O(n log n),不是本题的最优解,但它是一种完全不同的算法设计思路。有些面试官会故意追问“除了 Kadane 还有别的做法吗”,这时候能说出分治思路会很加分。另外,分治版的“横跨中点”处理方式,对后面理解二维最大子矩阵问题很有启发。时间充裕的话,分治版值得手写一遍。

我顺手把上面几种写法的时间空间复杂度整理成了表格,方便对照:

解法时间复杂度空间复杂度适用场景
三层暴力O(n³)O(1)极小数据量,教学演示
前缀和枚举O(n²)O(n)理解区间和优化
KadaneO(n)O(1)本题最优解,面试首选
分治O(n log n)O(log n)展示算法多样性、为二维题做铺垫

5. 亲手踩过的坑:全负数、溢出与边界条件

5.1 全负数数组的初始化陷阱

前面反复提到全负数数组,这里展开说。LeetCode 官方示例里总是带正数的数组,导致很多人在本地自测时没有覆盖全负数场景。我第一次写这题时,best初始化为 0,遇到[-1, -2, -3]直接返回 0,提交直接红。后来复盘才意识到,正确答案应该是 -1。

正确初始化方式有两种:一是best = nums[0],二是best = float('-inf')。前者代码简洁,而且天然处理了单元素数组;后者更通用,适合“数组可能为空”的扩展场景。同理,cur也必须初始化为nums[0],如果初始化为 0,整套逻辑的边界就会变得很别扭。记住一条原则:凡是“至少选一个元素”的题目,首元素参与初始化,而不是用默认值参与初始化。

5.2 int溢出与测试数据上限

LeetCode 原题的数据范围是nums[i] ∈ [-10^4, 10^4],数组长度最大 10^5,理论最大和不超 10^9,32 位 int 勉强够用。但如果你想扩展自测,或者把这个问题带到工程里去,cur + x就完全可能溢出。Python 因为 int 变长没有这个问题,Java/C++ 必须小心。

我在实际做题时习惯统一用 long 类型维护cur和best,最后再转成 int。这个习惯帮我避免过好几次低级事故——比如面试官随手写了个[10^9, 10^9, 10^9],C++ 的 int 加法直接溢出成负数,整个答案就崩了。虽然这类数据原题一般不给,但“用更大位宽做中间计算”是成本极低的防御性写法。

5.3 空数组、单元素与返回值约定

题目明确说数组非空,所以标准解法不需要处理空数组。但面试官经常追加一句“如果数组可以为空呢”。这时候行业里通常有两种约定:返回 0,或者抛异常。无论选哪种,都应该先跟面试官确认约束再写代码。

单元素数组则直接返回那个元素,上面的初始化方式已经覆盖。还有一个隐蔽的边界值得注意:数组长度为 2 时,例如[100, -1],最大子数组是[100]而不是[100, -1],因为后者和为 99 更小。用cur = max(x, cur + x)验证:x=-1 时,cur 从 100 变成 max(-1, 99)=99,best 仍旧 100,正确。这个例子能很好地验证你对“连续性”和“何时断开”的理解——负数并不一定导致断开,只有累加结果小于当前元素本身时,才需要重新开始。

5.4 一次错误提交的完整排查过程(复现)

最后分享一次我实际遇到的排查过程。当时我写了一个自认为正确的版本,提交后挂在某个全负数的用例上。我的第一反应不是直接看官方题解,而是先本地构造边界用例:先跑[-1],输出 -1,正常;再跑[-1, -2],输出 -1,正常;再跑[-2, -1],问题出现了,输出 -2,而正确是 -1。

定位过程是这样的:首先确认best初始化为 0,那么[-2, -1]遍历后cur分别是 -2 和 -1,max(best, cur)永远取 0,所以输出 0 才对,为什么输出 -2?这说明我的初始化其实不是 0,而是nums[0]。进一步检查发现,我在某个分支里把cur更新后直接赋给了best,没有用max做历史比较。于是修复方案就清晰了:保证best = max(best, cur)这行代码在每次迭代都会执行,且初始化为负无穷而不是 0。这个排查花了我不到三分钟,但给了我一个非常宝贵的经验——边界用例不是用来“炫技”的,它们是指向 bug 的最短路径。以后遇到任何数组题,我都会在脑子里先跑一遍全负数、单元素、两元素这三个用例,能省下大量调试时间。

6. 从这道题延伸出去:变形题与面试追问

6.1 与最大乘积子数组的状态设计差异

最大乘积子数组是题号 152,也是 53 题最常见的变体。乘积和加法最大的区别在于符号:两个负数相乘得正数,所以“当前最大”完全可能由“之前最小”转过来。这就导致单一状态不够用了,需要同时维护两个状态:max_cur和min_cur,并且在每个位置分别用当前元素、当前元素乘最大、当前元素乘最小这三者来做更新。

面试官经常用这题来考察你是否真正理解了 53 题。如果只会背 Kadane 代码,看到乘积就会懵;如果你理解了“状态必须完整描述当前局面”这个本质,就能自然推理出:既然乘法有符号翻转,单一cur无法描述全貌,至少需要两个状态。这个从“一个状态”到“两个状态”的推导过程,比题目本身更值得展示。

6.2 环形最大子数组和的两种解法

题号 918 是环形数组版本。环形数组的最大子数组和分两类:一类没有跨越首尾边界,答案就是普通 Kadane;另一类跨越了边界,等价于“整个数组的总和减去数组中间最小的子数组和”。所以答案可以写成max(普通 Kadane 结果, total - 最小子数组和)。

这里有一个极易踩的坑:如果数组全负数,最小子数组和就是整个数组,total - minSubarray会得到 0,但环形数组的非空子数组和不可能为 0,正确答案应该是普通 Kadane 的结果(最大负数)。因此全负数时必须特判。这个边界两种解法都会遇到,记进错题本比临场推导更稳妥。

6.3 二维矩阵最大子矩阵和的降维思路

二维版本是我在面试中被追问过的问题:在一个 m×n 的矩阵里,找和最大的子矩阵。暴力枚举所有子矩阵是 O(m²n²),太慢。经典做法是:枚举上下边界(O(m²)),把上下边界之间的每一列纵向求和,得到一个长度为 n 的临时一维数组,然后在这个一维数组上跑 Kadane。总体复杂度 O(m²·n)。

这里的“降维”思路非常典型:把二维问题逐列压缩成一维问题,然后复用 53 题的解法。很多面试官问完 53 题之后,会顺手把二维版本抛出来,看你能不能举一反三。如果你在纸上画过“上下边界夹出的列和数组”,理解了 53 题为什么能直接套进去,这道升级题基本就不需要额外准备了。

最后说一点个人习惯:我刷这类“短小精悍”的经典题时,会给它单独建一个索引卡,正面写题目、反面写三件事——状态定义、一个反例用例、一个常见变体。比如这题的反例是[-2, -1],变体是环形数组和最大乘积。面试前翻一遍这些卡片,比临时抱佛脚刷十道新题有用得多。53 题的价值从来不在于代码多短,而在于你能不能把“状态设计”这四个字讲成一段让人信服的故事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询