1. 问题背景与核心挑战
今天咱们来啃一道LeetCode上的硬骨头——第689题"三个无重叠子数组的最大和"。这道题在2023年字节跳动秋招中出现了变种,也是谷歌面试的高频题目。题目要求我们从一个整数数组nums中,找到三个长度为k的非重叠子数组,使得这三个子数组的和最大,并返回这三个子数组的起始索引。
我第一次看到这个题目时,直觉告诉我这肯定要用滑动窗口,但具体怎么处理三个窗口的约束条件,确实让我琢磨了好一阵子。经过多次调试和优化,终于总结出了一套清晰的解题思路,下面就把我的完整思考过程和实现方案分享给大家。
2. 暴力解法与初步优化
2.1 最直观的暴力解法
最直接的想法就是枚举所有可能的三元组组合。假设数组长度为n,我们需要:
- 枚举第一个子数组的起始位置i(0 ≤ i ≤ n-3k)
- 枚举第二个子数组的起始位置j(i+k ≤ j ≤ n-2k)
- 枚举第三个子数组的起始位置l(j+k ≤ l ≤ n-k)
- 计算这三个子数组的和,记录最大值
这种解法的时间复杂度是O(n³),当n=10000时(LeetCode的测试用例规模),这显然会超时。
2.2 前缀和优化
我们可以先用前缀和数组预处理,将子数组求和的时间复杂度从O(k)降到O(1)。前缀和数组prefixSum的构建方式如下:
java复制int[] prefixSum = new int[nums.length + 1];
for (int i = 0; i < nums.length; i++) {
prefixSum[i+1] = prefixSum[i] + nums[i];
}
这样,任意子数组nums[i..j]的和就可以用prefixSum[j+1] - prefixSum[i]来计算。优化后,暴力解法的时间复杂度仍然是O(n³),但实际运行时间会有所改善。
3. 动态规划解法
3.1 动态规划状态定义
这道题的正解是动态规划。我们需要定义三个DP数组:
- dp1[i]:表示nums[0..i]范围内,1个长度为k的子数组的最大和
- dp2[i]:表示nums[0..i]范围内,2个长度为k的子数组的最大和
- dp3[i]:表示nums[0..i]范围内,3个长度为k的子数组的最大和
同时,我们还需要三个数组来记录对应的索引位置:
- pos1[i]:dp1[i]对应的子数组起始位置
- pos2_left[i], pos2_right[i]:dp2[i]对应的两个子数组起始位置
- pos3_left[i], pos3_mid[i], pos3_right[i]:dp3[i]对应的三个子数组起始位置
3.2 DP状态转移方程
我们从左到右计算这些DP数组:
- 首先计算所有长度为k的子数组的和:
java复制int[] windowSums = new int[nums.length - k + 1];
int sum = 0;
for (int i = 0; i < nums.length; i++) {
sum += nums[i];
if (i >= k) sum -= nums[i - k];
if (i >= k - 1) windowSums[i - k + 1] = sum;
}
- 计算dp1和pos1:
java复制int[] dp1 = new int[windowSums.length];
int[] pos1 = new int[windowSums.length];
dp1[0] = windowSums[0];
pos1[0] = 0;
for (int i = 1; i < windowSums.length; i++) {
if (windowSums[i] > dp1[i-1]) {
dp1[i] = windowSums[i];
pos1[i] = i;
} else {
dp1[i] = dp1[i-1];
pos1[i] = pos1[i-1];
}
}
- 计算dp2和对应的位置:
java复制int[] dp2 = new int[windowSums.length];
int[] pos2_left = new int[windowSums.length];
int[] pos2_right = new int[windowSums.length];
dp2[k] = windowSums[0] + windowSums[k];
pos2_left[k] = 0;
pos2_right[k] = k;
for (int i = k+1; i < windowSums.length; i++) {
if (windowSums[i] + dp1[i-k] > dp2[i-1]) {
dp2[i] = windowSums[i] + dp1[i-k];
pos2_left[i] = pos1[i-k];
pos2_right[i] = i;
} else {
dp2[i] = dp2[i-1];
pos2_left[i] = pos2_left[i-1];
pos2_right[i] = pos2_right[i-1];
}
}
- 最后计算dp3和最终结果:
java复制int[] dp3 = new int[windowSums.length];
int[] pos3_left = new int[windowSums.length];
int[] pos3_mid = new int[windowSums.length];
int[] pos3_right = new int[windowSums.length];
dp3[2*k] = windowSums[0] + windowSums[k] + windowSums[2*k];
pos3_left[2*k] = 0;
pos3_mid[2*k] = k;
pos3_right[2*k] = 2*k;
for (int i = 2*k+1; i < windowSums.length; i++) {
if (windowSums[i] + dp2[i-k] > dp3[i-1]) {
dp3[i] = windowSums[i] + dp2[i-k];
pos3_left[i] = pos2_left[i-k];
pos3_mid[i] = pos2_right[i-k];
pos3_right[i] = i;
} else {
dp3[i] = dp3[i-1];
pos3_left[i] = pos3_left[i-1];
pos3_mid[i] = pos3_mid[i-1];
pos3_right[i] = pos3_right[i-1];
}
}
3.3 复杂度分析
- 时间复杂度:O(n),我们只需要遍历数组几次
- 空间复杂度:O(n),需要存储多个DP数组和位置数组
4. 代码实现与优化
4.1 完整Java实现
将上述思路整合,得到完整解法:
java复制public int[] maxSumOfThreeSubarrays(int[] nums, int k) {
int n = nums.length;
int[] windowSums = new int[n - k + 1];
int sum = 0;
for (int i = 0; i < n; i++) {
sum += nums[i];
if (i >= k) sum -= nums[i - k];
if (i >= k - 1) windowSums[i - k + 1] = sum;
}
int[] left = new int[windowSums.length];
int best = 0;
for (int i = 0; i < windowSums.length; i++) {
if (windowSums[i] > windowSums[best]) best = i;
left[i] = best;
}
int[] right = new int[windowSums.length];
best = windowSums.length - 1;
for (int i = windowSums.length - 1; i >= 0; i--) {
if (windowSums[i] >= windowSums[best]) best = i;
right[i] = best;
}
int[] ans = new int[]{-1, -1, -1};
for (int m = k; m < windowSums.length - k; m++) {
int l = left[m - k], r = right[m + k];
if (ans[0] == -1 ||
windowSums[l] + windowSums[m] + windowSums[r] >
windowSums[ans[0]] + windowSums[ans[1]] + windowSums[ans[2]]) {
ans[0] = l;
ans[1] = m;
ans[2] = r;
}
}
return ans;
}
4.2 空间优化技巧
上面的实现已经做了空间优化,只保留了必要的left和right数组。进一步优化可以:
- 在计算windowSums时,可以复用nums数组(如果允许修改原数组)
- left和right数组可以合并到一次遍历中,但会牺牲一些可读性
- 最终结果可以边计算边更新,不需要存储所有中间状态
5. 边界条件与测试用例
5.1 常见边界情况
- 数组长度正好是3k
- 数组中有负数
- 所有元素相同
- k=1的特殊情况
- 多个解时返回字典序最小的
5.2 测试用例示例
java复制// 测试用例1: 常规情况
int[] nums1 = {1,2,1,2,6,7,5,1};
int k1 = 2;
// 预期输出: [0,3,5]
// 测试用例2: 有负数
int[] nums2 = {-1,-2,-3,-4,-5,10};
int k2 = 2;
// 预期输出: [2,4,5]
// 测试用例3: 多个解
int[] nums3 = {1,1,1,1,1,1};
int k3 = 1;
// 预期输出: [0,1,2]
6. 同类问题扩展
掌握了这道题的解法后,可以解决一系列类似问题:
- 两个无重叠子数组的最大和
- m个无重叠子数组的最大和(通用解法)
- 允许子数组长度不同的变种
- 子数组可以有重叠但限制最大重叠长度
- 求最小和而非最大和
7. 实际应用场景
这类问题在实际中有广泛的应用:
- 广告投放:在时间轴上选择三个不重叠的时间段投放广告,使总点击量最大
- 基因分析:在DNA序列中找到多个高表达区域
- 金融分析:选择多个不重叠的时间段进行投资,使总收益最大
- 视频处理:从视频中选择多个精彩片段进行拼接
8. 解题心得与技巧
- 对于多个子数组的问题,通常可以考虑从左到右和从右到左分别计算
- 滑动窗口和前缀和是处理子数组问题的两大法宝
- 动态规划的状态定义要清晰,这道题需要分别考虑1个、2个、3个子数组的情况
- 在LeetCode上测试时,要注意大数情况,避免使用int导致溢出
- 当有多个解时,题目通常要求返回字典序最小的解,这在编码时要特别注意
这道题的难点在于如何高效地处理三个子数组之间的约束关系。通过动态规划逐步构建解,并合理利用辅助数组记录位置信息,我们最终得到了一个线性时间复杂度的优雅解法。