一、题目与初始思路误区
题目要求
给你一个整数数组 nums,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
- 子数组是连续的,子序列可以不连续,注意区分
- 数组中包含正数、负数、0
- 全负数数组返回最大的那个负数
一开始的双指针思路为什么行不通?
我最开始的直觉是用左右指针维护子数组区间、边走边更新和,也就是滑动窗口思路,但这个思路在这道题里是走不通的。
核心原因:滑动窗口的前提是「单调性」------ 右指针右移时区间和有固定的增减趋势,左指针右移时也有固定的增减趋势。
- 对于全正数数组:右移右指针和一定变大,右移左指针和一定变小,双指针成立
- 对于有正有负的数组:右指针往右走,遇到负数和会变小;左指针往右走,去掉负数和反而会变大
- 没有固定的单调性,就没法总结出指针移动的统一规则,滑动窗口自然就不适用
这也是这道题的核心考点:不是所有子数组问题都能用滑动窗口,有正有负的求和问题优先考虑动态规划。
二、思路推导:Kadane 算法
动态规划思路
定义状态:dp[i] 表示以第 i 个元素结尾的最大子数组和。 对于每个元素,有两种选择:
-
接上前面的子数组:和为
dp[i-1] + nums[i] -
自己重新开一个子数组:和为
nums[i]dp[i] = Math.max(nums[i], dp[i-1] + nums[i])
最终答案就是整个 dp 数组中的最大值。
- 时间复杂度:O (n)
- 空间复杂度:O (n)
3. Kadane 算法:空间优化版
观察状态转移方程可以发现,计算 dp[i] 只需要用到 dp[i-1] 这一个前序状态,不需要保存整个 dp 数组。 只用一个变量 preSum 维护前一个位置的状态,就能完成全部计算,空间复杂度直接优化到 O (1)。
核心思想一句话概括:遍历每个元素时,只判断一件事 ------ 前面的子数组有没有正向收益。有就带上,没有就从当前元素重新开始。
三、复盘
坑 1:maxSum 初始化为 0 → 全负数场景直接报错
错误代码片段
int maxSum = 0;
现象
输入全负数数组 [-3,-1,-2],正确答案是 -1,但代码返回 0。
原因
题目要求子数组至少包含一个元素,不能为空。初始化为 0 相当于默认 "空数组和为 0" 是合法解,违反了题目要求。
修正
maxSum 必须初始化为 nums[0],天然兼容全负数场景 ------ 哪怕全是负数,最大的那个负数也会被正确记录。
坑 2:preSum 初始为 nums 0 + 从 i=0 开始遍历 → 首元素重复计算
错误代码片段
int preSum = nums[0];
for (int i = 0; i < nums.length; i++) {
preSum += nums[i];
// ...
}
现象
输入单元素数组 [2],正确答案是 2,但代码返回 4。
原因
初始化时已经把第一个元素算进了 preSum,循环又从第 0 个元素开始,第一个元素被累加了两遍,结果必然翻倍。
修正规则
初始化占了第一个元素,循环就从第二个元素开始;初始化没占,循环就从第一个元素开始。两边不能重复。
- 方案 A:
preSum = nums[0]+for(i=1) - 方案 B:
preSum = 0+ 从头遍历所有元素
两种正确初始化方式对比
| 方案 | preSum 初始值 | 遍历起始 | 特点 |
|---|---|---|---|
| A | nums0 | i=1(第二个元素) | 下标逻辑清晰,对应动态规划 dp 数组思路 |
| B | 0 | 从第一个元素开始 | 增强 for 循环不用管下标,手写不易错 |
四、最终
最终选择「preSum 初始为 0 + 增强 for 循环全量遍历」的方案,原因有三个:
- 避开下标坑:不用纠结 i 从 0 还是 1 开始,彻底杜绝首元素重复计算的错误
- 逻辑贴合直觉:完全对应「正收益就累加、负收益就重开」的朴素思路,不用死记状态转移方程
- 代码极简:增强 for 循环代码更短,手写速度快,出错概率低
完整可 AC 代码
java
class Solution {
public int maxSubArray(int[] nums) {
// 全局最大子数组和:必须初始为第一个元素,兼容全负数场景
int maxSum = nums[0];
// 当前累计和:初始为0作为空前缀,第一个元素会自动重置
int preSum = 0;
// 增强for遍历所有元素,无需处理下标
for (int num : nums) {
if (preSum > 0) {
// 前面累计和为正,有正向收益,接上当前元素
preSum += num;
} else {
// 前面累计和≤0,是累赘,从当前元素重新开始
preSum = num;
}
// 每一步都更新全局最大值
maxSum = Math.max(maxSum, preSum);
}
return maxSum;
}
}
五、逐行拆解 + 样例模拟
两个变量的职责分工
preSum:临时变量,表示以当前元素结尾的子数组的最大和,只关心当前段的累计收益maxSum:全局变量,记录遍历过程中出现过的所有子数组和的最大值,是最终答案
标准样例完整模拟
输入:nums = [-2,1,-3,4,-1,2,1,-5,4] 正确答案:6(对应子数组 [4,-1,2,1])
一步步执行:
- 初始:
maxSum = -2,preSum = 0 - num=-2:preSum≤0 → preSum=-2;maxSum = max(-2,-2) = -2
- num=1:preSum=-2≤0 → preSum=1;maxSum = max(-2,1) = 1
- num=-3:preSum=1>0 → preSum=1+(-3)=-2;maxSum = max(1,-2) = 1
- num=4:preSum=-2≤0 → preSum=4;maxSum = max(1,4) = 4
- num=-1:preSum=4>0 → preSum=4+(-1)=3;maxSum = max(4,3) = 4
- num=2:preSum=3>0 → preSum=3+2=5;maxSum = max(4,5) = 5
- num=1:preSum=5>0 → preSum=5+1=6;maxSum = max(5,6) = 6
- num=-5:preSum=6>0 → preSum=6+(-5)=1;maxSum = max(6,1) = 6
- num=4:preSum=1>0 → preSum=1+4=5;maxSum = max(6,5) = 6
最终返回 6,与标准答案完全一致。
边界用例验证:全负数
输入:nums = [-3,-1,-2]
- 初始:
maxSum = -3,preSum = 0 - num=-3:preSum≤0 → preSum=-3;maxSum=-3
- num=-1:preSum=-3≤0 → preSum=-1;maxSum=-1
- num=-2:preSum=-1≤0 → preSum=-2;maxSum=-1
返回 -1,完全正确。
六、面试相关
口述思路
这道题我用 Kadane 算法来做,本质是动态规划的空间优化版本。定义 preSum 表示以当前元素结尾的最大子数组和,对于每个元素,如果前面的累计和大于 0,说明有正向收益,就接上前面的子数组;如果累计和小于等于 0,说明前面是累赘,就从当前元素重新开始。
因为每一步只需要前一个状态,所以不需要完整的 dp 数组,只用一个变量维护即可。遍历过程中同时维护全局最大值,最终返回。时间复杂度 O (n),空间复杂度 O (1)。
高频追问
-
时间复杂度和空间复杂度分别是多少? 时间 O (n),只遍历一次数组;空间 O (1),只用了两个变量。
-
分治法怎么做? 把数组分成左右两半,最大子数组和要么在左半段,要么在右半段,要么跨越中点。分别计算三种情况取最大值,时间复杂度 O (nlogn),实际运行不如 Kadane 算法高效,了解思路即可。
-
如果要求返回最大子数组的起止下标怎么办? 在更新 preSum 重置的时候记录左边界,更新 maxSum 的时候记录左右边界即可,核心逻辑不变,只需要额外加几个变量记录位置。
-
为什么不能用滑动窗口? 因为数组中有正有负,区间和不具备单调性 ------ 右指针右移和不一定变大,左指针右移和不一定变小,没有固定的指针移动规则,所以滑动窗口不适用。
七、复习速记
累计为正就累加,累计为负就重开; 全局最值首值起,全负场景不翻车。
总结
这道题是动态规划的入门经典,也是「思路简单、细节坑多」的典型代表。核心算法逻辑一句话就能讲完,但新手手写非常容易在初始化、下标这些细节上翻车。
复习优先级:
- 先记核心思想:正收益累加、负收益重开
- 再记两个红线:maxSum 绝对不能初始为 0、初始化和遍历不能重复覆盖首元素
- 最后选最顺手的写法背熟:增强 for 循环版本,天然避开下标坑