☰
最大子数组和问题全解:前缀和、Kadane算法与分治
2026/10/15 5:49:57 网站建设 项目流程

刷力扣hot100的人,大概率都见过第53题最大子数组和。题目很短:给定一个整数数组 nums,找出一个具有最大和的连续子数组并返回其最大和。很多人在第一次碰这道题时,第一反应是“这不就是两层循环吗”,直到被卡在 n=10^5 的数据规模上才反应过来,问题比看上去复杂。我最初学这题也走过弯路:先背了最优解代码,却完全说不清楚为什么状态要定义成“以 i 结尾”,面试官顺势追问一个变体,我就露馅了。这篇文章想用我自己最后沉淀下来的讲解顺序,把这道题从暴力到前缀和、动态规划、分治,再到常见变体完整过一遍,顺便把那些题解里很少写、但面试很容易踩的坑都挑出来。

如果你是刚开始刷题,建议照着代码自己在本地跑一遍,别只收藏;如果你已经会用最优解,可以重点看第4、第5部分,分治和变体才是真正区分理解深度的位置。下面按思考顺序来讲。

1. 先把题目读透:暴力解不是没价值

1.1 题目约定和边界条件

动笔写代码之前,一定要先确认边界。这道题要求返回的是连续子数组的最大和,子数组至少包含一个元素,既不能为空,也不能跳过中间元素。数组长度 n 的范围是 1 到 10^5,元素大小在 -10^4 到 10^4 之间。这意味着如果所有元素都是负数,答案只能是最大的那个负数,而不是 0,因为空子数组不被允许。

这个边界条件直接决定了初始化策略。最典型的错误写法是把答案初始化为 0,然后不断用当前和去更新答案。遇到类似 [-1, -2] 这样的输入时,正确结果是 -1,但初始化 0 的代码会输出 0。这个错误在力扣的测试用例里几乎必然会触发,因为题目明确包含了全负数的场景。所以任何版本的解法,第一步都应该是把答案初始化为 nums[0],或者初始化为负无穷,再进入循环。

另外,如果只要求返回最大和,不需要返回具体下标,很多实现可以简化。但面试时经常会有追问,比如“能不能输出最大子数组本身”,我后面会专门讲怎么扩展。记住这个需求差异,写代码时保持状态定义清晰,后面扩展才不会改得面目全非。

1.2 三重循环暴力与第一次优化

先写最直接的暴力解。枚举所有可能的左端点 i 和右端点 j,计算 nums[i..j] 的和,取最大值。如果三重循环内层还写一个 for k 从 i 到 j 累加,复杂度是 O(n^3)。虽然这个版本没法通过题目的大数据,但它是所有后续优化的正确性基准,写出来也有助于理清思路。

def max_subarray_bruteforce(nums): n = len(nums) ans = nums[0] for i in range(n): for j in range(i, n): cur = 0 for k in range(i, j + 1): cur += nums[k] ans = max(ans, cur) return ans

这段代码的循环枚举了所有子数组,长度从 1 到 n,数量是 n(n+1)/2。每个子数组求和需要 O(长度) 时间,因此总复杂度是 O(n^3)。当 n 是 1000 时已经比较吃力,n 到 10^5 时完全不可行。但暴力解能帮助我们验证题意:子数组连续、至少一个元素、存在负数时取最大负数。

随后的第一层优化很自然:固定左端点 i 后,右端点 j 向右移动时,子数组和是增量累加的。不需要每次重新从 i 开始算,用一个变量 cur 保存当前和即可。优化后变成两层循环,复杂度降到 O(n^2)。

def max_subarray_bruteforce2(nums): n = len(nums) ans = nums[0] for i in range(n): cur = 0 for j in range(i, n): cur += nums[j] ans = max(ans, cur) return ans

这个版本在本地测试和小规模随机数据下已经足够快,而且实现简单。我推荐把 O(n^2) 版本当作“暴力基准”,后续写最优解时,用随机数据把两个版本的结果做对比,能快速发现边界 bug。这个习惯在做算法题时非常实用,不是只能靠在线判断来验证。

2. 前缀和视角:把最大子数组和变成“历史最低点买入”

2.1 子数组和就是前缀和之差

如果说暴力解是靠枚举左右端点来硬算,那么前缀和视角提供了一种更抽象的观察方式。定义前缀和 pre[i] 为数组前 i 个元素的和,pre[0] = 0。那么任意子数组 nums[i..j] 的和可以写成 pre[j+1] - pre[i]。这里 i 的范围是 0 到 j,pre[i] 表示子数组起点之前的累计和。

于是问题就变成了:找到两个下标 i < j,使得 pre[j] - pre[i] 最大。固定右端点 j 时,为了让差值最大,我们只需要让 pre[i] 尽可能小。这个逻辑很像一次股票交易:在历史最低点买入,在当前点卖出,收益最大。所以每扫描一个新元素,得到新的前缀和 pre,先用它减去“之前出现的最小前缀和”,更新答案,再把当前 pre 纳入历史最小值集合。

用数学语言说,设 min_pre 是当前已经扫描到的所有前缀和中的最小值,那么当前元素作为右端点时,最优子数组和就是 pre - min_pre。对整个数组取 max,就是最终答案。这段推导非常清晰,也是面试时一招就能让面试官眼前一亮的角度。

2.2 一次遍历的代码和两个易错点

对应这个思路,可以写出 O(n) 时间、O(1) 空间的代码:

def max_subarray_prefix(nums): ans = nums[0] pre = 0 min_pre = 0 for x in nums: pre += x ans = max(ans, pre - min_pre) min_pre = min(min_pre, pre) return ans

这里有两个容易被忽略的顺序问题。第一个是 ans 的初始化:必须设置为 nums[0] 或负无穷,不能是 0。前面说过,全负数场景会暴露这个错误。第二个是“先更新答案,再更新 min_pre”的顺序:如果先把当前 pre 加入 min_pre,再计算 pre - min_pre,结果为 0,相当于允许了一个空子数组,答案会被错误地抬高。由于题目不允许空子数组,右端点 j 对应的左端点 i 必须小于 j,也就是前缀和 pre 必须来自当前元素之前,因此当前 pre 不能马上成为自己的“历史最小值”。

为了验证这个版本的正确性,可以跑几个例子。比如 nums = [-2, 1, -3, 4, -1, 2, 1, -5, 4],前缀和依次为 -2, -1, -4, 0, -1, 1, 2, -3, 1。过程中 pre - min_pre 的最大值出现在前缀和 2 时,对应子数组 [4, -1, 2, 1],和是 6。这个例子也是力扣题中经典的示例,后续所有版本都应该返回 6。

3. 动态规划:核心是“以 i 结尾”

3.1 状态设计为什么不能是“前 i 个元素的最大和”

很多人学动态规划时习惯把状态定义成“前 i 个元素的最大值”,但最大子数组和这题如果这样定义,递推关系会非常别扭。原因在于,子数组是连续的,如果只知道“前 i 个元素的最大子数组和”,我们并不知道这个最大子数组的结尾位置在哪里,也就无法判断当前元素 nums[i] 能不能接上去。

举个例子,nums = [10, -100, 1, 2]。前两个元素的最大子数组和是 10,对应子数组 [10],结尾在下标 0;但如果只记录这个全局最大值,处理到最后一个元素 2 时,你会以为无法把 2 接到 [10] 后面,因为中间隔着 -100。其实正确子数组 [1, 2] 的和是 3,并不依赖 [10]。这个例子说明,全局最大值这个状态不够用,它丢掉了子数组的结尾信息。

正确的做法是把状态定义成:dp[i] 表示以 nums[i] 结尾的最大连续子数组和。这个定义把“连续性”直接固化在状态里。当我们处理 nums[i] 时,只有两种选择:要么把 nums[i] 接到以 nums[i-1] 结尾的最佳连续段后面,使得新的连续段仍然以 nums[i] 结尾;要么从 nums[i] 重新开始,开启一个新的连续段。于是递推式就是:

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

初始化 dp[0] = nums[0],最终答案是所有 dp[i] 中的最大值。这个递推式也被称为 Kadane 算法,是解决这类连续子数组问题最核心的套路。

3.2 滚动数组降维与等价贪心写法

观察递推式会发现,dp[i] 只依赖 dp[i-1],之前的 dp 值再也不会被用到。因此不需要用一个完整的数组保存所有状态,只需要一个变量记录“以当前位置结尾的最大连续子数组和”即可。这个空间优化在面试中经常被问到,属于“你写完后我随手追问”的常规问题,实现也不复杂。

def max_subarray_kadane(nums): cur = nums[0] ans = cur for i in range(1, len(nums)): cur = max(nums[i], cur + nums[i]) ans = max(ans, cur) return ans

这里 cur 维护的是 dp[i],也就是以 nums[i] 结尾的最大连续段和;ans 维护的是全局最佳答案。每次比较完 cur 后再更新 ans,顺序无所谓,因为 cur 已经是最新的状态值。这个写法空间 O(1),时间 O(n),是解决第 53 题的标准答案。

还有一个等价写法,很多人叫它贪心,其实本质也是 Kadane:

def max_subarray_greedy(nums): ans = nums[0] cur = 0 for x in nums: cur += x ans = max(ans, cur) if cur < 0: cur = 0 return ans

这个版本维护的是“当前保留的连续段的和”。如果当前段的累计和已经变成负数,那么它对接下来的任何正数都是拖累,不如清零,从下一个位置重新开始。为什么它和 Kadane 等价?因为 cur = max(x, cur + x) 恰好等价于“先 cur += x,如果 cur < 0 就清零,再加下一个 x 之前的状态恢复”。不过注意,这个写法里 ans 必须在清零前更新,否则全负数数组会错误输出 0。

3.3 易错点:空子数组、全负数、初始化顺序

这一题最经典的三个易错点都集中在递推边界的处理上。

第一,全负数场景。无论用哪种动态规划写法,都必须保证结果是一个负数而不是 0。Kadane 版本中 cur = max(nums[i], cur + nums[i]) 天然会把 cur 更新为当前最大负数,ans 也能记录最大负数。贪心版本的 cur 清零逻辑依赖 ans 先更新,所以顺序一旦写反,全负数用例就会挂。

第二,如果题目允许返回空子数组,那递推式需要改成 dp[i] = max(0, dp[i-1] + nums[i]),最终答案至少是 0。力扣 53 题明确不允许空子数组,但很多变体会允许,所以做题时一定要先和出题人确认,或者自己主动判断。

第三,不要试图把 dp[i] 里的负数直接丢掉。虽然标准最大子数组和中,我们几乎不会让一个负数前缀成为最优解,但 dp[i] 表示的是“以 i 结尾”的最优值,它可能为负。这个负值不会直接出现在最终答案里,但如果后面出现超大正数,它又可能通过“接上去”参与候选。理解这一点,后续做变体题时不容易钻牛角尖。

4. 分治解法:把最大子数组和切成两半

4.1 三类划分与跨中点计算的细节

如果你面试的是偏底层的岗位,面试官可能会让你额外写一个分治版本。分治的切入点很自然:任意一个连续子数组,要么完全位于左半部分,要么完全位于右半部分,要么跨过中点。前两种情况递归处理即可,第三种情况需要单独计算。

跨中点的最大子数组由两部分组成:以 mid 为终点,向左延伸得到的最大后缀和;以 mid+1 为起点,向右延伸得到的最大前缀和。把这两个最大值相加,就是跨中点情况的上界。这里的关键在于,左右两侧都不能为空,因为子数组必须至少包含一个元素。很多人在实现时初始化左右最大值为 0,遇到全负数数组就会得到 0,完全错误。正确做法是初始化为负无穷,然后从 mid 和 mid+1 开始逐个累加。

具体的递归代码可以这样写:

def max_subarray_divide(nums, l, r): if l == r: return nums[l] mid = (l + r) // 2 left = max_subarray_divide(nums, l, mid) right = max_subarray_divide(nums, mid + 1, r) left_sum = float('-inf') cur = 0 for i in range(mid, l - 1, -1): cur += nums[i] left_sum = max(left_sum, cur) right_sum = float('-inf') cur = 0 for i in range(mid + 1, r + 1): cur += nums[i] right_sum = max(right_sum, cur) cross = left_sum + right_sum return max(left, right, cross)

这段代码先递归计算左右两个半区内的最大值,再用两个单方向循环计算跨中点最大值。叶子条件是 l == r,此时直接返回 nums[l],保证最小值场景不会出错。你可以手动跑一遍 nums = [-2, 1, -3, 4, -1, 2, 1, -5, 4],最终结果也是 6。分治版本虽然比 Kadane 复杂,但它的优点在于思路普适,很多区间问题都能套用这种“左、右、跨中点”的三分法。

4.2 复杂度分析和从分治到线段树

分治版的复杂度可以通过递推式 T(n) = 2T(n/2) + O(n) 得到,结果是 O(n log n),其中 n 是数组长度。每一层递归都需要遍历一次当前区间来算跨中点的左右最大延伸,左右两边递归处理子区间,所以总时间是对数级别乘线性级别。空间复杂度是递归栈的 O(log n),不算额外数组。

这个思路可以进一步延伸到线段树。如果把一个区间用四个字段描述:区间总和 sum、区间最大前缀和 pre、区间最大后缀和 suf、区间最大子数组和 best,那么两个相邻区间合并时,就能用公式计算出父区间的四个字段。合并公式是:

sum = left.sum + right.sum pre = max(left.pre, left.sum + right.pre) suf = max(right.suf, right.sum + left.suf) best = max(left.best, right.best, left.suf + right.pre)

这套公式本质就是分治思想的数据结构化。如果后续遇到“支持单点修改、区间多次查询最大子数组和”的问题,线段树版本就派上用场了。这说明第 53 题表面简单,但它把分治、合并、状态设计这些底层能力都串联起来了。

5. 面试追问与变形:一通百通

5.1 如果要返回最大子数组的起止下标

力扣原题只要返回最大和,不需要下标,但面试官非常喜欢改成“顺便把最大子数组打印出来”。这时如果直接基于 Kadane 写,需要额外维护当前连续段的起点,以及最终最优段的起点和终点。

在动态规划递推时,如果选择 cur + nums[i] > nums[i],说明接上前面的连续段更优,起点保持不变;否则应该从 i 重新开始,起点更新为 i。每次更新答案时,同时把当前的起点和终点记为最优区间。

def max_subarray_with_index(nums): cur = nums[0] ans = nums[0] start = 0 bestL = bestR = 0 for i in range(1, len(nums)): if cur + nums[i] > nums[i]: cur += nums[i] else: cur = nums[i] start = i if cur > ans: ans = cur bestL, bestR = start, i return ans, nums[bestL:bestR + 1]

这段代码里最容易被忽略的是 start 的更新逻辑:它必须在“重新开始”的分支里同步更新,不能只更新 cur。另一个细节是,如果 cur + nums[i] == nums[i],接与不接的结果一样,但返回的子数组可能不同。实际面试中只要向面试官说明“这里我选择从当前位置开始,因为它同样能达到最优和”,就能避免不必要的歧义。

5.2 高频变体:环形数组、二维矩阵、至少 K 长度

第 53 题之所以被放进 hot100,是因为它几乎是一堆连续子数组问题的母题。理解透它之后,很多所谓中等题都只是在它外面包了一层外壳。

第一个常见变体是环形数组的最大子数组和。由于数组首尾相连,最大值要么来自不跨边界的普通子数组,要么来自跨越边界的情况。跨边界场景等价于“总和减去数组的最小子数组和”。实现时要注意特判:如果普通最大子数组和都是负数,说明数组中全是负数,此时直接返回最大单元素即可,不能使用“总和减最小子数组”的公式,否则会意外得到 0。

第二个常见变体是二维矩阵的最大子矩阵和。做法是枚举行上边界和下边界,把矩阵按列压缩成一个一维数组,列的求和作为这个一维数组的每个元素,然后再跑一遍 Kadane。固定上下边界后,任意连续列区间就对应一个子矩阵,而一维最大子数组和恰好能找出最优列区间。这样复杂度是 O(m^2 * n),其中 m 是行数。

第三个变体是“至少包含 K 个元素的最大子数组和”。普通 Kadane 无法强制长度下界,正确姿势是用前缀和加单调队列:扫描到位置 j 时,从队列中移除下标小于 j-K 的过期前缀,再取队头最小前缀来计算候选答案。这样能在 O(n) 时间内解决带长度约束的同类问题。

这三个变体都出现在真实面试中,第 53 题的最优解只是它们的基石。如果你能把前面几节的原理真正理解,而不是死记代码,遇到这些变体时会自然地想到“能不能用前缀和/DP/分治”这三个方向。

5.3 本地对拍验证:写算法题的工程习惯

最后分享一个提升代码正确率的小习惯:用暴力做对拍。很多人写算法题只依赖在线上平台点“提交”,一旦出错,只能对着错误用例发呆。更好的做法是本地写一个小脚本,把暴力 O(n^2) 版本和最优解放在一起,用随机数据比较结果。

import random def max_subarray_kadane(nums): cur = nums[0] ans = cur for i in range(1, len(nums)): cur = max(nums[i], cur + nums[i]) ans = max(ans, cur) return ans def max_subarray_bruteforce(nums): n = len(nums) ans = nums[0] for i in range(n): cur = 0 for j in range(i, n): cur += nums[j] ans = max(ans, cur) return ans for _ in range(20000): n = random.randint(1, 20) nums = [random.randint(-10, 10) for _ in range(n)] assert max_subarray_kadane(nums) == max_subarray_bruteforce(nums), nums print("all passed")

这个脚本虽然简单,但非常有效。它能在几秒内测试几万个随机小规模用例,覆盖全负数、单元素、正负交替等最容易出错的情况。真正刷题时,我用这种方式排查过大量类似 bug,比如初始化顺序写反、跨中点两侧取空、长度约束边界漏判等。养成这个习惯后,线上代码的通过率会明显提升。

最后再说一点个人体会:我准备面试时,会把最大子数组和、最佳买卖股票时机、乘积最大子数组这三道题放在一起复盘,因为它们的共同内核都是“以当前位置结尾”的状态设计。最初觉得这是套路,后来才意识到,把状态定义到能精确覆盖连续性约束的位置,才是动态规划真正的题眼。希望这篇能把第 53 题讲透,让你在面试被追问时不再慌。

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

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

立即咨询