题目要求
输入输出
- 输入:整数数组
nums - 输出:返回连续非空子数组的最大和
- 子数组必须连续
- 至少选一个元素
- 约束:
1 <= nums.length <= 10^5,-10^4 <= nums[i] <= 10^4
示例
输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:[4,-1,2,1] 的和最大,为 6
输入:nums = [1]
输出:1
这篇只用 Python,从 dp[i] 的含义一步一步推出 Kadane 算法。
从 [-2,1,-3,4] 的断点开始
看一个前缀例子:
nums = [-2, 1, -3, 4]
如果走到 4,有两种选择:
- 把前面的某个连续子数组接到
4前面 - 从
4自己重新开始
这里的关键不是“所有子数组怎么枚举”,而是:
当我们决定一个最优子数组必须以当前位置结尾时,它的来源只有两种:接上前一个位置的最优结尾,或者从当前位置重新开始。
这就是一维 DP 的入口。
Step 1:先定义一个更小的问题
直接问“整个数组的最大子数组和是多少”太大。我们先强加一个限制:
如果子数组必须以第
i个元素结尾,它的最大和是多少?
这个值记为 dp[i]。
这里的 dp[i] 不是“前 i 个元素里的最大子数组和”。它更窄:
dp[i] = 必须以 nums[i] 结尾的连续子数组最大和
先写最小骨架:
def max_sub_array(nums: list[int]) -> int:
n = len(nums)
dp = [0] * n
现在这个版本能做到:
- 给每个位置预留一个状态。
- 明确
dp[i]绑定的是“以 i 结尾”。
它还缺:
- 第一个位置的值。
- 后续位置如何从前一个状态转移。
- 最终答案从哪里取。
Step 2:第一个位置只能选自己
在上一版基础上,先填 base case。
如果子数组必须以 0 结尾,那它只能是:
[nums[0]]
所以新增:
dp[0] = nums[0]
现在代码是:
def max_sub_array(nums: list[int]) -> int:
n = len(nums)
dp = [0] * n
dp[0] = nums[0]
现在这个版本能做到:
- 正确处理第一个位置的“以 0 结尾”状态。
它还缺:
i > 0时的状态转移。- 整个数组的答案。
Step 3:当前位置只有“接上”或“重开”两种来源
现在考虑 i > 0。
如果最大子数组必须以 i 结尾,它最后一个元素一定是 nums[i]。它的前面只有两种情况:
- 接上一个也以
i - 1结尾的最优子数组 - 不接任何前缀,从
nums[i]重新开始
所以在上一版基础上新增转移:
for i in range(1, n):
dp[i] = max(dp[i - 1] + nums[i], nums[i])
这条转移里的两项含义是:
dp[i - 1] + nums[i]:接上前一个位置的最佳结尾nums[i]:从当前位置重新开始
现在代码是:
def max_sub_array(nums: list[int]) -> int:
n = len(nums)
dp = [0] * n
dp[0] = nums[0]
for i in range(1, n):
dp[i] = max(dp[i - 1] + nums[i], nums[i])
现在这个版本能做到:
- 算出每个位置作为结尾时的最大连续和。
它还缺:
- 最终答案不一定以最后一个位置结尾。
Step 4:答案是所有 dp[i] 的最大值
上一版算出的 dp[i] 都是“必须以 i 结尾”的局部答案。题目问的是任意位置结尾的最大值。
所以新增:
return max(dp)
得到第一版完整正确代码:
def max_sub_array(nums: list[int]) -> int:
n = len(nums)
dp = [0] * n
dp[0] = nums[0]
for i in range(1, n):
dp[i] = max(dp[i - 1] + nums[i], nums[i])
return max(dp)
现在这个版本能做到:
- 正确返回最大子数组和。
- 清楚保留每个位置的
dp[i],适合学习状态含义。
它还缺:
- 空间还可以优化,因为转移只依赖
dp[i - 1]。
Step 5:慢速走一遍表
用示例:
nums = [-2, 1, -3, 4, -1, 2, 1, -5, 4]
逐步计算:
| i | nums[i] | 接上 dp[i-1] + nums[i] | 重开 nums[i] | dp[i] |
|---|---|---|---|---|
| 0 | -2 | - | -2 | -2 |
| 1 | 1 | -1 | 1 | 1 |
| 2 | -3 | -2 | -3 | -2 |
| 3 | 4 | 2 | 4 | 4 |
| 4 | -1 | 3 | -1 | 3 |
| 5 | 2 | 5 | 2 | 5 |
| 6 | 1 | 6 | 1 | 6 |
| 7 | -5 | 1 | -5 | 1 |
| 8 | 4 | 5 | 4 | 5 |
所以:
max(dp) = 6
这一步要确认的不是表本身,而是 dp[i] 的含义始终没有变:
dp[i]只回答“必须以 i 结尾”的最大和。
Step 6:把 dp 数组压成两个变量
上一版的瓶颈是空间。
转移:
dp[i] = max(dp[i - 1] + nums[i], nums[i])
只依赖上一个状态 dp[i - 1],不需要保留整张表。
把 dp[i - 1] 替换成 cur:
cur = max(cur + x, x)
同时题目最终要的是所有 dp[i] 的最大值,所以保留 best:
best = max(best, cur)
最终完整代码是:
class Solution:
def maxSubArray(self, nums: list[int]) -> int:
cur = nums[0]
best = nums[0]
for x in nums[1:]:
cur = max(cur + x, x)
best = max(best, cur)
return best
if __name__ == "__main__":
print(Solution().maxSubArray([-2, 1, -3, 4, -1, 2, 1, -5, 4]))
print(Solution().maxSubArray([1]))
现在这个版本能做到:
- 保持和
dp表版本相同的转移含义。 - 把额外空间从
O(n)降到O(1)。
它还缺:
- 如果题目要求返回子数组本身,还需要额外记录起止下标;本题只要求返回最大和。
正确性
不变量:
- 处理到位置
i后,cur等于“必须以i结尾的最大连续子数组和”。 best等于“到目前为止见过的最大cur”。
为什么转移正确:
- 任何以
i结尾的连续子数组,要么只包含nums[i],要么由某个以i - 1结尾的连续子数组接上nums[i]。 - 如果要接上前面,接的那段必须是以
i - 1结尾的最大者,否则可以替换成更大的,得到更优结果。 - 所以
cur = max(cur + x, x)覆盖了全部可能来源。
为什么返回 best:
- 最大子数组可以结束在任意位置。
- 每个结束位置的最优值都会成为某一次
cur。 best记录所有这些cur的最大值。
复杂度
- 时间复杂度:
O(n),每个元素只扫描一次。 - 额外空间:
O(1),只保存cur和best。
常见错误
- 把
dp[i]定义成“前 i 个元素里的最大和”,然后又写dp[i - 1] + nums[i],状态含义不一致。 - 初始化
cur = 0,会在全负数组里错误返回0。 - 只返回最后的
cur,会漏掉中途出现的最大值。 - 把子序列和子数组混淆;本题必须连续。
小结
- 一维 DP 先写清楚
dp[i]的含义,再写转移。 - 本题最稳定的状态是:
dp[i]表示必须以i结尾的最大连续和。 - 转移来源只有两个:接上前一个最优结尾,或从当前位置重新开始。
- Kadane 只是把
dp表压成cur + best的空间优化版本。
参考与延伸
- LeetCode 53:Maximum Subarray
- LeetCode 70:Climbing Stairs
- LeetCode 746:Min Cost Climbing Stairs
- LeetCode 198:House Robber
Notes
- 题意和示例来自当前仓库旧稿中的 LeetCode 53 摘要。
- 代码语言按本仓库当前 LeetCode 教程默认选择 Python。