一、 题目与代码重现
题目描述
-
题目名称:53. 最大子数组和
-
问题描述:给你一个整数数组
nums,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。子数组是数组中的一个连续部分。 -
输入输出示例:
- 示例 1:输入
nums = [-2,1,-3,4,-1,2,1,-5,4],输出6(子数组[4,-1,2,1]) - 示例 2:输入
nums = [1],输出1 - 示例 3:输入
nums = [5,4,-1,7,8],输出23
- 示例 1:输入
-
限制条件:
1 <= nums.length <= 10^5-10^4 <= nums[i] <= 10^4
原始代码
javascript
/**
* @param {number[]} nums
* @return {number}
*/
var maxSubArray = function(nums) {
let currentSum = nums[0]; // 以当前元素结尾的最大子数组和
let maxSum = nums[0]; // 全局最大子数组和
for (let i = 1; i < nums.length; i++) {
// 要么从 nums[i] 重新开始,要么继续累加
currentSum = Math.max(nums[i], currentSum + nums[i]);
maxSum = Math.max(maxSum, currentSum);
}
return maxSum;
};
二、 标准解题逻辑步骤
本题的核心思想是 Kadane 算法,属于动态规划的一种简化形式。
核心思路梳理
局部最优 + 全局最优:遍历数组时,维护两个关键值——以当前元素结尾的最大子数组和和全局最大子数组和。
详细步骤解析
| 步骤 | 操作说明 | 逻辑依据 |
|---|---|---|
| 1. 初始化 | 将 currentSum 和 maxSum 都设为 nums[0] | 因为数组非空,第一个元素本身就是一个有效的子数组 |
| 2. 遍历数组 | 从索引 i = 1 开始遍历到末尾 | 每一步都在做决策:是否将当前元素纳入之前的子数组 |
| 3. 状态转移 | currentSum = Math.max(nums[i], currentSum + nums[i]) | 如果 nums[i] 比 currentSum + nums[i] 还大,说明前面累加的部分对当前元素是"拖累",不如从当前元素重新开始 |
| 4. 更新全局最优 | maxSum = Math.max(maxSum, currentSum) | 每一步都检查当前局部最优是否超越了之前的全局最优 |
| 5. 返回结果 | 循环结束后返回 maxSum | 遍历完成时已保存全局最大子数组和 |
直观理解(用示例解释)
以 nums = [-2, 1, -3, 4, -1, 2, 1, -5, 4] 为例:
| i | nums[i] | currentSum(计算过程) | maxSum(更新后) |
|---|---|---|---|
| 0 | -2 | -2(初始化) | -2 |
| 1 | 1 | max(1, -2+1=-1) = 1 | max(-2, 1) = 1 |
| 2 | -3 | max(-3, 1-3=-2) = -2 | max(1, -2) = 1 |
| 3 | 4 | max(4, -2+4=2) = 4 | max(1, 4) = 4 |
| 4 | -1 | max(-1, 4-1=3) = 3 | max(4, 3) = 4 |
| 5 | 2 | max(2, 3+2=5) = 5 | max(4, 5) = 5 |
| 6 | 1 | max(1, 5+1=6) = 6 | max(5, 6) = 6 |
| 7 | -5 | max(-5, 6-5=1) = 1 | max(6, 1) = 6 |
| 8 | 4 | max(4, 1+4=5) = 5 | max(6, 5) = 6 |
最终返回 6 ✅
三、 API 与语法深度解析
| API/语法 | 用途说明 | 参数详解 | 本题适用场景 |
|---|---|---|---|
Math.max(a, b) | 返回两个数值中的较大值 | a、b 为数字类型 | 用于状态转移和全局最优更新,简洁高效 |
nums.length | 获取数组长度 | 无参数 | 控制循环边界,时间复杂度与长度成正比 |
var maxSubArray = function(nums) { ... } | 函数表达式定义 | nums 为整数数组参数 | LeetCode 标准的函数定义方式 |
let 声明 | 块级作用域变量 | 可重新赋值 | 用于存储动态变化的 currentSum 和 maxSum |
关键语法点说明
Math.max(nums[i], currentSum + nums[i]):这里巧妙地用nums[i]作为"重新开始"的选项。如果currentSum是负数,加上nums[i]会变得更小,此时nums[i]大于currentSum + nums[i],相当于放弃了前面的累加,从当前位置重新开始子数组。- 初始化
nums[0]:由于题目保证nums.length >= 1,直接取第一个元素是安全的。这避免了对空数组的特判。
四、 得分重点与理解难点划分
得分重点(考察核心)
| 考点 | 说明 |
|---|---|
| 动态规划思想 | 能否识别出"最优子结构"——即以每个位置结尾的最大子数组和依赖于前一个位置的状态 |
| 状态压缩 | 能否将二维 DP 压缩为一维变量(只需 currentSum 和 maxSum) |
| 负数的正确处理 | 遇到负数时不能简单地跳过,需要判断是否值得累加 |
| 一次遍历 O(n) | 是否能在单次遍历中完成所有计算 |
理解难点(思维盲区)
| 难点 | 解析 |
|---|---|
为什么 currentSum + nums[i] 可能小于 nums[i]? | 当 currentSum 为负数时,累加反而会拖累当前值,此时应该"丢弃"前面的子数组,从当前元素重新开始。许多初学者误以为必须包含前面的元素。 |
| 全局最大不一定在最后 | 最大子数组可能出现在数组的中间位置,因此每一步都需要更新 maxSum,不能只依赖最后的 currentSum。 |
| 全负数数组的处理 | 即使所有元素都是负数,答案也是其中最大的那个负数。算法中 currentSum = max(nums[i], currentSum + nums[i]) 保证了这一点。 |
五、 极易踩错的细节与边界错误
边界条件清单
| 场景 | 错误写法示例 | 正确写法 | 原因分析 |
|---|---|---|---|
| 数组长度为 1 | 直接循环 for (let i=0; i<nums.length; i++) 导致重复计算 | 从 i=1 开始遍历,或初始化 currentSum=0 从 i=0 开始 | 避免重复累加第一个元素 |
| 全负数数组 | 初始化 maxSum = 0,返回 0 | 初始化 maxSum = nums[0] | 题目要求子数组至少包含一个元素,空数组和为 0 不合法 |
| 数值溢出风险 | 使用 Number.MAX_SAFE_INTEGER 作为初始比较值 | 直接用 nums[0] 初始化 | JS 中虽然不会溢出,但用 nums[0] 更简洁规范 |
| 空数组(虽题目保证非空) | 直接访问 nums[0] 报错 | 添加 if (nums.length === 0) return 0 | 虽然本题不涉及,但这是良好的防御性编程习惯 |
常见逻辑陷阱
- 双重循环暴力法:
for (let i=0; i<n; i++) for (let j=i; j<n; j++)会产生 O(n²) 的复杂度,在n=10^5时会超时。 - 错误的状态转移:
currentSum = currentSum + nums[i]而不做max比较,会导致负数累积拖垮结果。 - 忘记更新全局最优:只维护
currentSum而不更新maxSum,最后返回currentSum会丢失中间出现的更大值。
六、 复杂度对比与优化方案
基础写法对比
| 解法 | 核心思路 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|---|
| 暴力枚举 | 枚举所有子数组起点和终点并求和 | O(n³) 或 O(n²) | O(1) | 仅在 n ≤ 100 时可用 |
| 前缀和优化 | 先求前缀和,再枚举所有子数组区间 | O(n²) | O(n) | n ≤ 5000 时勉强可用 |
| 分治算法 | 将数组分为左右两半,递归求解跨中点的最大子数组 | O(n log n) | O(log n) | 可作为思维拓展,但编码复杂 |
| Kadane 算法(当前解法) | 动态规划状态压缩,一次遍历 | O(n) ✅ | O(1) ✅ | 本题最优解 |
最优解优化思路
当前 Kadane 算法已经是时间复杂度 O(n)、空间复杂度 O(1) 的最优解。不存在更优的渐进复杂度(因为至少需要遍历所有元素一次)。
完整优化后代码(与原始代码一致,增加防御性)
javascript
/**
* @param {number[]} nums
* @return {number}
*/
var maxSubArray = function(nums) {
// 防御性处理(题目已保证非空,但保留增强健壮性)
if (!nums || nums.length === 0) {
return 0;
}
let currentSum = nums[0];
let maxSum = nums[0];
for (let i = 1; i < nums.length; i++) {
// 核心状态转移:要么继续累加,要么重新开始
currentSum = Math.max(nums[i], currentSum + nums[i]);
// 维护全局最大值
maxSum = Math.max(maxSum, currentSum);
}
return maxSum;
};
进一步拓展:如何获取最大子数组本身?
如果题目进阶要求返回最大子数组的起始和结束索引,可以增加两个变量记录:
javascript
var maxSubArrayWithIndex = function(nums) {
let currentSum = nums[0], maxSum = nums[0];
let start = 0, end = 0, tempStart = 0;
for (let i = 1; i < nums.length; i++) {
if (nums[i] > currentSum + nums[i]) {
currentSum = nums[i];
tempStart = i; // 重新开始,记录新起点
} else {
currentSum = currentSum + nums[i];
}
if (currentSum > maxSum) {
maxSum = currentSum;
start = tempStart;
end = i;
}
}
return { maxSum, subarray: nums.slice(start, end + 1) };
};
这个变体展示了如何将状态转移与索引记录相结合,但核心思想依然是 Kadane 算法。