LeetCode 918. 环形子数组的最大和:两种解法详解

0 阅读6分钟

刷题路上遇到环形数组的问题,总容易被“环形”这个条件绕晕——子数组不仅能是常规的连续片段,还能跨数组首尾连接。今天就来拆解 LeetCode 918 题「环形子数组的最大和」,分享两种高效解法,从原理到代码一步步讲透,帮你彻底搞懂这类环形数组问题。

先看题目核心:给定一个长度为 n 的环形整数数组 nums,返回非空子数组的最大可能和。这里要注意两个关键约束:一是环形意味着数组首尾相连,二是子数组不能重复使用元素(也就是说,跨首尾的子数组比如 nums[n-1], nums[0], nums[1] 是允许的,但不能包含 nums[0] 两次)。

题目核心难点

常规的子数组最大和(比如 LeetCode 53 题),用 Kadane 算法就能轻松解决,但环形数组多了“跨首尾”的情况,这就需要我们跳出常规思维:

  • 常规子数组:从 i 到 j(i ≤ j),连续且不跨首尾;

  • 环形子数组:从 j 到 n-1,再从 0 到 i(j > i),本质是“数组总和 - 中间一段最小子数组的和”。

基于这个思路,衍生出两种经典解法,下面分别详细讲解。

解法一:全局最大值 = max(常规最大和, 总和 - 常规最小和)

核心原理

这是最直观、最易理解的解法,核心逻辑分两种情况:

  1. 最大子数组不跨首尾:就是常规的子数组最大和,用 Kadane 算法直接求解;

  2. 最大子数组跨首尾:此时最大和 = 数组总和 - 最小子数组的和(因为总和减去中间一段最小的子数组,剩下的就是跨首尾的最大子数组)。

还有一个特殊情况:如果数组中所有元素都是负数,那么“总和 - 最小子数组和”会得到 0(因为总和 = 最小子数组和),但题目要求子数组非空,所以此时直接返回常规最大和(即数组中最大的那个负数)。

代码解析(TypeScript)

function maxSubarraySumCircular_1(nums: number[]): number {
  if (nums.length === 0) return 0;
  let curMax = nums[0], maxSum = nums[0]; // 常规最大和相关
  let curMin = nums[0], minSum = nums[0]; // 常规最小和相关
  let totalSum = nums[0]; // 数组总和

  for (let i = 1; i < nums.length; i++) {
    // 常规Kadane算法求最大子数组和
    curMax = Math.max(nums[i], curMax + nums[i]);
    maxSum = Math.max(maxSum, curMax);

    // 同理,求最小子数组和(Kadane算法变种)
    curMin = Math.min(nums[i], curMin + nums[i]);
    minSum = Math.min(minSum, curMin);

    // 累加计算数组总和
    totalSum += nums[i];
  }

  // 特殊情况:所有元素都是负数,直接返回最大和(非空)
  if (maxSum < 0) {
    return maxSum;
  }

  // 两种情况取最大值:常规最大和 vs 总和 - 最小子数组和
  return Math.max(maxSum, totalSum - minSum);
};

关键细节

  • curMax 和 curMin 分别记录“以当前元素结尾的最大子数组和”和“以当前元素结尾的最小子数组和”,每次迭代更新;

  • totalSum 必须在迭代中累加,避免二次遍历数组,保证时间复杂度 O(n);

  • 判断 maxSum < 0 是核心容错,避免所有元素为负时返回 0(不符合非空子数组要求)。

解法二:前缀和 + 后缀枚举(避免总和为负的判断)

核心原理

这种解法的思路是“拆分环形子数组”:跨首尾的子数组可以拆分为「前缀子数组」(从 0 开始)和「后缀子数组」(到 n-1 结束)。我们可以:

  1. 先计算常规的最大子数组和(不跨首尾);

  2. 再计算“后缀子数组 + 前缀子数组”的最大和:用 leftMax 数组记录「从 0 到 i 的最大前缀和」,再从右到左枚举后缀子数组,每次将后缀和与 leftMax[i-1](前 i-1 个元素的最大前缀和)相加,取最大值。

这种方法不需要判断数组是否全为负,因为枚举的后缀和 + 前缀和都是非空的,且常规最大和已经覆盖了全负的情况。

代码解析(TypeScript)

function maxSubarraySumCircular_2(nums: number[]): number {
  let n: number = nums.length;
  // leftMax[i]:从0开始,到i为止的最大前缀和(必须包含0,保证前缀非空)
  const leftMax = new Array(n).fill(0);
  leftMax[0] = nums[0]; // 初始值:只有第一个元素的前缀和
  let leftSum: number = nums[0]; // 累加前缀和
  let pre: number = nums[0]; // 常规最大子数组和的中间变量(Kadane)
  let res: number = nums[0]; // 最终结果,初始化为第一个元素

  // 第一次遍历:计算常规最大和 + leftMax数组
  for (let i = 1; i < n; i++) {
    // 常规Kadane算法求最大子数组和
    pre = Math.max(pre + nums[i], nums[i]);
    res = Math.max(res, pre);

    // 累加前缀和,更新leftMax(保证leftMax[i]是0到i的最大前缀和)
    leftSum += nums[i];
    leftMax[i] = Math.max(leftMax[i - 1], leftSum);
  }

  // 第二次遍历:从右到左枚举后缀子数组,计算后缀和 + 对应最大前缀和
  let rightSum = 0;
  for (let i = n - 1; i > 0; i--) {
    rightSum += nums[i]; // 后缀和:从i到n-1的和
    // 后缀和(i到n-1) + 前缀和(0到i-1的最大),更新结果
    res = Math.max(res, rightSum + leftMax[i - 1]);
  }

  return res;
};

关键细节

  • leftMax 数组的核心作用:记录“以 0 为起点,到 i 为止”的最大前缀和,确保后续枚举后缀时,能快速找到对应的最大前缀;

  • 第二次遍历从 n-1 到 1(不包含 0),因为当 i=0 时,leftMax[i-1] 越界,且此时后缀和就是整个数组,已经被常规最大和覆盖;

  • 时间复杂度依然是 O(n),空间复杂度 O(n)(leftMax 数组),相比解法一多了一点空间,但避免了总和为负的判断,逻辑更简洁。

两种解法对比

解法时间复杂度空间复杂度核心优势适用场景
解法一(总和 - 最小和)O(n)O(1)空间最优,逻辑直观追求空间效率,能记住“全负判断”的场景
解法二(前缀+后缀)O(n)O(n)无需特殊判断,逻辑更简洁不想处理边界条件,追求代码简洁

刷题总结

环形子数组的最大和,本质是“常规子数组”和“跨首尾子数组”的最大值求解。两种解法都基于 Kadane 算法的延伸,核心是找到“跨首尾子数组”的等价转换方式——要么用总和减去最小子数组和,要么拆分为前缀+后缀。

刷题时可以根据自己的习惯选择:如果喜欢空间最优,优先解法一;如果怕遗漏边界条件,解法二更友好。另外,建议多动手模拟几个测试用例(比如全负数组、全正数组、混合数组),就能彻底掌握两种解法的逻辑。