题目描述
给你一个整数数组 nums,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
子数组 是数组中的一个连续部分。
输入
整数数组 nums
输出
最大子数组和
约束条件
1 <= nums.length <= 10^5-10^4 <= nums[i] <= 10^4
进阶约束
如果你已经实现复杂度为 的解法,尝试使用更为精妙的分治法求解。
示例1
输入:nums = [-2,1,-3,4,-1,2,1,-5,4]输出:6解释:连续子数组 [4,-1,2,1] 的和最大,为 6。
示例2
输入:nums = [1]输出:1示例3
输入:nums = [5,4,-1,7,8]输出:23解题思路
这是一道经典的最大子数组和问题,也是 Kadane 算法的代表性题目。数组中的元素可能为负数,因此不能简单地从第一个正数开始一直累加——负数的出现会让累加和”缩水”,但有时为了连接后面的正数段,我们又必须”忍耐”中间的负数。
💡 一句话概括:在遍历数组时,我们始终面临一个选择——是延续前面的累加,还是重新开始。如果前面的累加是正的,保留它;如果是负的,丢弃它。
🔗 延伸阅读:Kadane 算法的完整原理推导(DP 视角 + 贪心视角)、变体扩展和线段树推广,详见 Kadane 算法详解(知识点专题文章)。
核心问题可以归结为:当遍历到某个位置时,我该延续前面的子数组,还是从这里重新开始?
从暴力枚举出发
最直接的做法是枚举所有子数组并计算和:
class Solution: def maxSubArray(self, nums: List[int]) -> int: n = len(nums) ans = float('-inf')
for i in range(n): cur = 0 for j in range(i, n): cur += nums[j] ans = max(ans, cur)
return ans这段代码枚举了所有可能的起点 i 和终点 j,在 时间内计算出最大子数组和。
瓶颈分析:当 时, 次操作,远超时间限制。而且内部循环做了大量重复计算——以子数组 [1,3] 和 [2,3] 为例,它们的求和区间高度重叠,但暴力法每次都是从 i 重新累加,完全没有复用前一次的信息。
优化方向:能否只遍历一次数组,在遍历过程中动态地做决策?
动态规划:Kadane 算法
状态定义
定义 为以 结尾的最大子数组和。注意这里的关键限制是”必须以 结尾”——这让我们可以从子问题递推。
💡 DP 设计技巧:当问题问”任意区间的最优值”,常见的套路是强制状态以当前位置结尾,然后在所有 中取最大值。这让我们获得了”前一个状态 + 当前元素”的递推关系。
对于位置 ,我们有两种选择:
- 自成一派:从 开始一个全新的子数组,和为
- 延续前朝:把 接到以 结尾的最大子数组后面,和为
取两者中的较大值,得到状态转移方程:
最终答案就是 。
空间优化
观察转移方程: 仅依赖于 ,我们不需要维护整个 dp 数组,只需一个变量 pre 记录 即可。
同时注意到, 只有在 > 0 时才对 有正向贡献——如果 ,那么 ,等价于「丢弃之前的所有累加」。
因此代码可以简化为:
class Solution: def maxSubArray(self, nums: List[int]) -> int: ans = float('-inf') pre = 0
for x in nums: pre = max(pre, 0) + x ans = max(ans, pre)
return ans关键一行 pre = max(pre, 0) + x 的语义:
- 如果
pre > 0,说明前面的累积对当前元素有正向贡献 → 累加上x - 如果
pre \le 0,说明前面的累积只会拖累当前元素 → 丢弃,从x重新开始
🧠 直觉理解:想象你是一个股民,每天都有盈亏。
pre就是你当前的累计收益,max(pre, 0)就是在问自己——“我之前是赚了还是亏了?“亏了就清仓从头开始,赚了就继续持有。
这正是 Kadane 算法最优雅的写法——将 DP 状态转移与贪心决策融为一体。
执行过程追踪
以 nums = [-2, 1, -3, 4, -1, 2, 1, -5, 4] 为例,追踪 pre 和 ans 的变化:
i | x | 决策 | pre | ans |
|---|---|---|---|---|
| — | — | — | 0 | -∞ |
| 0 | -2 | pre ≤ 0,丢弃,0 + (-2) | -2 | -2 |
| 1 | 1 | pre ≤ 0,丢弃,0 + 1 | 1 | 1 |
| 2 | -3 | pre > 0,保留,1 + (-3) | -2 | 1 |
| 3 | 4 | pre ≤ 0,丢弃,0 + 4 | 4 | 4 |
| 4 | -1 | pre > 0,保留,4 + (-1) | 3 | 4 |
| 5 | 2 | pre > 0,保留,3 + 2 | 5 | 5 |
| 6 | 1 | pre > 0,保留,5 + 1 | 6 | 6 ✅ |
| 7 | -5 | pre > 0,保留,6 + (-5) | 1 | 6 |
| 8 | 4 | pre > 0,保留,1 + 4 | 5 | 6 |
最终 ans = 6,对应子数组 [4, -1, 2, 1]。
可以观察到,pre 在位置 i=0, 1, 3 处 ≤ 0 时被”重置”——这恰好对应了”切断负数前缀,重新开始”的贪心决策。最终的最大值 6 出现在 i=6。
复杂度
- 时间复杂度:,只需遍历数组一次。
- 空间复杂度:,仅使用
pre和ans两个变量。
已是最优——因为输出结果至少需要遍历一次数组才能确定。
解题思路(分治法)
除了 DP,题目的进阶要求也提示我们尝试分治法。分治法的思路是将数组从中间分成左右两半,那么最大子数组有三种可能的位置:
| 情况 | 描述 | 求解方式 |
|---|---|---|
| ① 完全在左半 | 最大子数组的左右端点均 ≤ mid | 递归左半 |
| ② 完全在右半 | 最大子数组的左右端点均 > mid | 递归右半 |
| ③ 跨越中点 | 左端点在左半,右端点在右半 | 从中点向两侧扫描 |
前两种情况直接递归求解,第三种情况需要额外处理——从中点向左扫描求最大后缀和,从中点+1 向右扫描求最大前缀和,两者相加:
class Solution: def maxSubArray(self, nums: List[int]) -> int: def divide(l: int, r: int) -> int: if l == r: return nums[l]
mid = (l + r) // 2
# 递归求解左右 left_max = divide(l, mid) right_max = divide(mid + 1, r)
# 跨越中点的最大和 cur = 0 left_cross = float('-inf') for i in range(mid, l - 1, -1): cur += nums[i] left_cross = max(left_cross, cur)
cur = 0 right_cross = float('-inf') for i in range(mid + 1, r + 1): cur += nums[i] right_cross = max(right_cross, cur)
cross_max = left_cross + right_cross
return max(left_max, right_max, cross_max)
return divide(0, len(nums) - 1)💡 为什么学分治法? 虽然复杂度不如 Kadane,但分治法中”合并区间”的操作,正是线段树维护区间最大子段和的核心——当需要支持动态修改数组元素并快速查询任意区间答案时,分治 + 线段树是唯一选择。Kadane 做不到这一点。
两种方法的对比:
| 对比项 | Kadane(DP) | 分治法 |
|---|---|---|
| 时间复杂度 | ||
| 空间复杂度 | ||
| 代码简洁度 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| 思维难度 | 需理解 DP 状态定义 | 结构清晰,但跨越中点处理稍繁琐 |
| 扩展性 | 仅适合静态最大子数组和 | 可扩展为线段树维护区间信息 |
代码优化
max(pre, 0) + x 与 max(x, pre + x) 的等价性
# 写法 1:强调"丢弃负前缀"(更直观)pre = max(pre, 0) + x
# 写法 2:强调"取或不取"(更贴近 DP 方程)pre = max(x, pre + x)写法 1 更直观地体现了贪心决策——「之前和为正则保留,为负则丢弃」。写法 2 更贴近标准 DP 状态转移方程。两种写法完全等价,按个人偏好选择。
初始化细节
ans = float('-inf') # 正确处理全负数数组pre = 0⚠️ 注意:
ans初始化为float('-inf')而非0,确保全负数数组(如nums = [-3, -2, -1])也能正确返回-1。若用0则会错误地返回0(空子数组在本题中是不允许的)。
等价的初始化方式:ans = nums[0](题目保证 nums.length >= 1),两种写法都是安全的。
扩展:返回子数组本身
如果题目要求返回最大子数组而不仅是最大值,可以在维持 pre 的同时追踪子数组的左右端点:
class Solution: def maxSubArray(self, nums: List[int]) -> List[int]: ans = float('-inf') pre = 0 start = end = 0 cur_start = 0
for i, x in enumerate(nums): if pre <= 0: pre = x cur_start = i # 丢弃前缀,重新标记起点 else: pre += x
if pre > ans: ans = pre start = cur_start end = i
return nums[start:end + 1]💡 技巧:每次
pre <= 0触发”丢弃”时,同步更新cur_start为当前位置;当pre刷新历史记录时,将cur_start封存到start。 额外空间即可追踪子数组边界。
总结
「最大子数组和」是动态规划的入门经典,它的核心在于定义以每个位置结尾的最优状态,通过一个精妙的转移方程将问题降维到 。
解决本题后,推荐按以下顺序挑战延伸题目——这条路径将 Kadane 的思想逐步泛化,覆盖 DP、贪心、分治、线段树四个领域:
- 152. 乘积最大子数组:将加法换为乘法,负数会翻转符号——需要同时维护最大值和最小值
- 918. 环形子数组的最大和:首尾相连,诀窍是”环形最大 = max(普通最大, 总和 - 普通最小)”
- 线段树维护区间最大子段和:数组可修改时,如何用分治结构快速回答任意区间的最大子数组和?
🧠 核心收获:掌握了”以某位置结尾的最优状态”这一 DP 设计技巧,你就能从容应对一大类子数组问题。这不是一道题的技巧,而是一类题的思维框架。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时









