LeetCode经典题目「53. 最大子数组和」,这道题是动态规划和分治思想的典型应用,也是面试中高频考察的基础题。题目难度不算高,但两种解法各有侧重,吃透能帮我们更好地理解两类算法的核心逻辑,话不多说,直接进入正题。
一、题目回顾
题目要求:给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。注意,子数组是数组中的一个连续部分,和子序列(不要求连续)是完全不同的概念哦。
举个简单例子:输入 nums = [-2,1,-3,4,-1,2,1,-5,4],输出应该是 6。因为连续子数组 [4,-1,2,1] 的和最大,等于6。
这道题的核心难点在于「连续」和「最大和」,如果暴力枚举所有连续子数组,时间复杂度会达到 O(n²),对于大规模数组会超时,所以我们需要更高效的解法------今天重点讲两种 O(n) 和 O(nlogn) 复杂度的解法。
二、解法一:动态规划(DP)------ 最优时间复杂度 O(n)
1. 核心思路
动态规划的核心是「状态定义」和「状态转移方程」,这道题我们可以这样拆解:
定义状态 pre:表示以当前元素结尾的连续子数组的最大和。
对于每个元素 nums[i],我们有两个选择:
-
将当前元素加入到之前的连续子数组中(即 pre + nums[i]);
-
放弃之前的子数组,以当前元素为起点重新开始一个子数组(即 nums[i])。
所以状态转移方程就是:pre = Math.max(pre + x, x)(x 是当前遍历到的元素)。
同时,我们需要一个变量 maxAns 来记录遍历过程中出现的最大 pre 值,这个值就是最终的最大子数组和。
2. 代码解读
给出的代码非常简洁,我们逐行拆解:
typescript
function maxSubArray_1(nums: number[]): number {
// pre:以当前元素结尾的连续子数组最大和;maxAns:全局最大和
let pre: number = 0, maxAns: number = nums[0];
// 遍历数组中的每个元素x
nums.forEach((x) => {
// 状态转移:选择加入前序子数组,或重新开始
pre = Math.max(pre + x, x);
// 更新全局最大和
maxAns = Math.max(maxAns, pre);
});
return maxAns;
};
举个例子辅助理解(以 nums = [-2,1,-3,4,-1,2,1,-5,4] 为例):
-
初始:pre=0,maxAns=-2(nums[0]);
-
遍历x=-2:pre = max(0+(-2), -2) = -2,maxAns = max(-2, -2) = -2;
-
遍历x=1:pre = max(-2+1, 1) = 1,maxAns = max(-2, 1) = 1;
-
遍历x=-3:pre = max(1+(-3), -3) = -2,maxAns 仍为1;
-
遍历x=4:pre = max(-2+4, 4) = 4,maxAns = 4;
-
后续遍历依次更新,最终 maxAns 为6,和预期一致。
这种解法的优势的是:一次遍历完成,时间复杂度 O(n),空间复杂度 O(1)(只用到两个变量),是这道题的最优解法,面试中优先推荐写这种。
三、解法二:分治思想 ------ 时间复杂度 O(nlogn)
分治思想的核心是「分而治之」:将数组分成左右两部分,最大子数组和要么在左半部分,要么在右半部分,要么横跨左右两部分。我们需要分别计算这三种情况的最大值,取三者中的最大者。
1. 核心思路
为了高效计算「横跨左右两部分」的最大和,我们需要定义一个 Status 类,存储每个区间的四个关键信息:
-
lSum:该区间的最大前缀和(从区间左端点开始,连续子数组的最大和);
-
rSum:该区间的最大后缀和(从区间右端点开始,连续子数组的最大和);
-
mSum:该区间的最大子数组和(就是我们需要的核心值);
-
iSum:该区间的所有元素和(用于计算横跨左右的最大和)。
然后通过「递归拆分」和「合并区间」(pushUp 函数),逐步计算出整个数组的 mSum,即为答案。
2. 代码解读
typescript
class Status {
lSum: number; // 区间最大前缀和
rSum: number; // 区间最大后缀和
mSum: number; // 区间最大子数组和
iSum: number; // 区间总元素和
constructor(l: number, r: number, m: number, i: number) {
this.lSum = l;
this.rSum = r;
this.mSum = m;
this.iSum = i;
}
}
function maxSubArray_2(nums: number[]): number {
// 合并两个区间的Status,计算出父区间的四个关键值
const pushUp = (l: Status, r: Status): Status => {
const iSum = l.iSum + r.iSum; // 父区间总和 = 左区间总和 + 右区间总和
// 父区间最大前缀和:要么是左区间的最大前缀和,要么是左区间总和+右区间最大前缀和
const lSum = Math.max(l.lSum, l.iSum + r.lSum);
// 父区间最大后缀和:要么是右区间的最大后缀和,要么是右区间总和+左区间最大后缀和
const rSum = Math.max(r.rSum, r.iSum + l.rSum);
// 父区间最大子数组和:三者取最大(左区间最大、右区间最大、横跨左右的最大)
const mSum = Math.max(Math.max(l.mSum, r.mSum), l.rSum + r.lSum);
return new Status(lSum, rSum, mSum, iSum);
}
// 递归获取区间 [l, r] 的Status
const getInfo = (a: number[], l: number, r: number): Status => {
if (l === r) { // 递归终止:区间只有一个元素时,四个值都等于该元素
return new Status(a[l], a[l], a[l], a[l]);
}
const m = Math.floor((l + r) / 2); // 拆分区间为左右两部分
const lSub = getInfo(a, l, m); // 左区间Status
const rSub = getInfo(a, m + 1, r); // 右区间Status
return pushUp(lSub, rSub); // 合并左右区间,返回父区间Status
}
// 整个数组的区间是 [0, nums.length-1],其mSum就是答案
return getInfo(nums, 0, nums.length - 1).mSum;
};
3. 补充说明
分治解法的时间复杂度是 O(nlogn),空间复杂度是 O(logn)(递归调用栈的深度)。虽然效率不如动态规划,但这种思想很重要------在解决更复杂的区间问题(如最大子矩阵和)时,分治+区间信息合并的思路会非常有用。
四、两种解法对比总结
| 解法 | 时间复杂度 | 空间复杂度 | 核心优势 | 适用场景 |
|---|---|---|---|---|
| 动态规划 | O(n) | O(1) | 高效、简洁,空间开销小 | 单独求解最大子数组和,面试首选 |
| 分治思想 | O(nlogn) | O(logn) | 思路通用,可扩展到复杂区间问题 | 区间相关延伸题,理解分治思想 |
五、刷题思考
这道题虽然简单,但能帮我们理清两个重要算法思想的应用:
-
动态规划的核心是「抓住当前状态的最优选择」,不需要回溯,通过状态转移逐步推导全局最优;
-
分治思想的核心是「拆分+合并」,将大问题拆成小问题解决,再通过合并小问题的结果得到大问题的答案。