LeetCode 53. 最大子数组和:两种高效解法(动态规划+分治)

0 阅读6分钟

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. 代码解读

给出的代码非常简洁,我们逐行拆解:

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. 代码解读

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)思路通用,可扩展到复杂区间问题区间相关延伸题,理解分治思想

五、刷题思考

这道题虽然简单,但能帮我们理清两个重要算法思想的应用:

  1. 动态规划的核心是「抓住当前状态的最优选择」,不需要回溯,通过状态转移逐步推导全局最优;

  2. 分治思想的核心是「拆分+合并」,将大问题拆成小问题解决,再通过合并小问题的结果得到大问题的答案。