1. 问题分析与解题思路
这道LeetCode 689题要求我们在一个正整数数组中找到三个互不重叠且长度均为k的子数组,使得它们的总和最大,并返回这三个子数组的起始下标。如果有多个解,需要返回字典序最小的那个。
1.1 问题重述与理解
首先我们需要明确几个关键点:
- 三个子数组必须互不重叠
- 每个子数组长度必须严格等于k
- 需要返回的是起始下标(从0开始)
- 当存在多个解时,选择字典序最小的那个
举个例子,假设数组是[1,2,1,2,6,7,5,1],k=2。那么可能的解有:
- [0,3,5]对应的子数组是[1,2],[2,6],[7,5],和为1+2+2+6+7+5=23
- [1,3,5]对应的子数组是[2,1],[2,6],[7,5],和为2+1+2+6+7+5=23
- [0,3,6]对应的子数组是[1,2],[2,6],[5,1],和为1+2+2+6+5+1=17
显然前两个解的和都是23,但[0,3,5]的字典序比[1,3,5]小,所以正确答案是[0,3,5]。
1.2 解题思路选择
这道题有多种解法,包括暴力法、动态规划等,但最优解是使用滑动窗口结合前后缀最大值预处理的方法。这种方法的时间复杂度是O(n),空间复杂度也是O(n),非常高效。
为什么选择这种方法?
- 暴力法需要枚举所有可能的三个子数组组合,时间复杂度高达O(n^3),显然不可行
- 动态规划虽然可以将时间复杂度降到O(n),但实现起来较为复杂
- 滑动窗口+预处理的方法直观易懂,且能很好地处理字典序最小的要求
2. 详细解法解析
2.1 预处理阶段
2.1.1 计算所有长度为k的子数组和
首先我们需要计算所有可能的长度为k的子数组的和。这里可以使用滑动窗口的技巧来高效计算。
java复制int[] range = new int[n];
int sum = 0;
for (int i = 0; i < k; i++) {
sum += nums[i];
}
range[k - 1] = sum;
for (int i = k; i < n; i++) {
sum += nums[i] - nums[i - k];
range[i] = sum;
}
这段代码先计算前k个元素的和,然后通过滑动窗口的方式,每次减去最左边的元素,加上新的右边元素,得到新的子数组和。
2.1.2 预处理左前缀最大值
我们需要预处理一个left数组,left[i]表示在[0,i]范围内,和最大的长度为k的子数组的结束下标。如果有多个相同的最大值,选择最左边的那个以保证字典序最小。
java复制int[] left = new int[n];
left[k - 1] = k - 1;
int max = range[k - 1];
for (int i = k; i < n; i++) {
if (range[i] > max) {
max = range[i];
left[i] = i;
} else {
left[i] = left[i - 1];
}
}
2.1.3 预处理右后缀最大值
同样地,我们需要预处理一个right数组,right[i]表示在[i,n-1]范围内,和最大的长度为k的子数组的起始下标。同样要保证字典序最小。
java复制int[] right = new int[n];
right[n - k] = n - k;
max = range[n - 1];
for (int i = n - k - 1; i >= 0; i--) {
if (range[i + k - 1] >= max) {
max = range[i + k - 1];
right[i] = i;
} else {
right[i] = right[i + 1];
}
}
注意这里使用了>=而不是>,这是为了保证当有多个相同最大值时,选择更靠左的起始下标。
2.2 枚举中间子数组
现在我们已经有了:
- 所有长度为k的子数组的和(range数组)
- 任意位置i左边的最佳子数组(left数组)
- 任意位置i右边的最佳子数组(right数组)
接下来我们只需要枚举中间子数组的位置i,然后组合左右两边的最佳子数组即可。
java复制int maxSum = 0;
int a = 0, b = 0, c = 0;
for (int i = k; i <= n - 2 * k; i++) {
int leftEnd = left[i - 1];
int rightStart = right[i + k];
int total = range[leftEnd] + range[i + k - 1] + range[rightStart + k - 1];
if (total > maxSum) {
maxSum = total;
a = leftEnd - k + 1;
b = i;
c = rightStart;
}
}
这里有几个关键点:
- 中间子数组的起始位置i的范围是[k, n-2k],这样才能保证左右两边都有足够的空间放置另外两个子数组
- 左边的子数组选择left[i-1]对应的
- 右边的子数组选择right[i+k]对应的
- 计算三者之和,如果大于当前最大值就更新结果
2.3 返回结果
最后,我们将三个起始下标a,b,c放入数组返回即可:
java复制return new int[]{a, b, c};
3. 完整代码实现
下面是完整的Java实现,包含了详细的注释:
java复制public int[] maxSumOfThreeSubarrays(int[] nums, int k) {
int n = nums.length;
// Step 1: 计算每个以i结尾的长度为k的子数组之和
int[] range = new int[n];
int sum = 0;
for (int i = 0; i < k; i++) {
sum += nums[i];
}
range[k - 1] = sum;
for (int i = k; i < n; i++) {
sum += nums[i] - nums[i - k];
range[i] = sum;
}
// Step 2: left[i] = 在[0,i]中,和最大的子数组的结束下标
int[] left = new int[n];
left[k - 1] = k - 1;
int max = range[k - 1];
for (int i = k; i < n; i++) {
if (range[i] > max) {
max = range[i];
left[i] = i;
} else {
left[i] = left[i - 1];
}
}
// Step 3: right[i] = 在[i,n-1]中,和最大的子数组的起始下标
int[] right = new int[n];
right[n - k] = n - k;
max = range[n - 1];
for (int i = n - k - 1; i >= 0; i--) {
if (range[i + k - 1] >= max) {
max = range[i + k - 1];
right[i] = i;
} else {
right[i] = right[i + 1];
}
}
// Step 4: 枚举中间子数组的起始位置
int maxSum = 0;
int a = 0, b = 0, c = 0;
for (int i = k; i <= n - 2 * k; i++) {
int leftEnd = left[i - 1];
int rightStart = right[i + k];
int total = range[leftEnd] + range[i + k - 1] + range[rightStart + k - 1];
if (total > maxSum) {
maxSum = total;
a = leftEnd - k + 1;
b = i;
c = rightStart;
}
}
return new int[]{a, b, c};
}
4. 复杂度分析与优化
4.1 时间复杂度分析
让我们分析一下这个算法的时间复杂度:
- 计算range数组:O(n)
- 计算left数组:O(n)
- 计算right数组:O(n)
- 枚举中间子数组:O(n)
因此总的时间复杂度是O(n),非常高效。
4.2 空间复杂度分析
空间复杂度主要来自三个辅助数组:
- range数组:O(n)
- left数组:O(n)
- right数组:O(n)
因此总的空间复杂度也是O(n)。
4.3 可能的优化
虽然这个算法已经很高效了,但我们还可以考虑一些优化:
- 可以尝试将range、left、right三个数组合并计算,减少空间使用
- 在枚举中间子数组时,可以提前终止一些不可能成为最优解的情况
- 如果内存紧张,可以复用一些数组空间
不过这些优化通常只能带来常数级的改进,对于大O表示法来说没有变化。
5. 边界条件与测试用例
5.1 重要边界条件
在实现这个算法时,需要注意以下几个边界条件:
- 当k=1时,每个子数组就是一个元素
- 当n=3k时,只有一种可能的解
- 当所有元素相同时,任何三个不重叠的子数组和都相同,需要返回字典序最小的
- 当数组中有负数时(虽然题目说是正整数数组,但作为扩展考虑)
5.2 测试用例设计
下面是一些重要的测试用例:
-
基本测试用例:
java复制nums = [1,2,1,2,6,7,5,1], k = 2 期望输出:[0,3,5] -
多个解的情况:
java复制nums = [1,1,1,1,1,1,1,1], k = 2 期望输出:[0,2,4] -
最小可能输入:
java复制nums = [1,2,3,4,5,6], k = 1 期望输出:[0,1,2] -
最大可能输入:
java复制nums = [1,2,3,...,10000], k = 1000 需要验证算法在大数据量下的表现
6. 常见问题与调试技巧
6.1 常见错误
在实现这个算法时,容易犯以下几个错误:
- 计算range数组时,滑动窗口的更新不正确
- 预处理left和right数组时,没有正确处理相等情况(导致字典序不是最小的)
- 枚举中间子数组时,范围计算错误(i的范围应该是[k, n-2k])
- 下标转换错误(比如leftEnd是结束下标,转换为起始下标需要减去k-1)
6.2 调试技巧
当你的代码出现问题时,可以尝试以下调试方法:
- 打印出range、left、right数组,检查预处理是否正确
- 对于小测试用例,手动计算预期结果并与程序输出比较
- 检查边界条件,特别是当i接近数组边界时
- 使用IDE的调试功能,逐步执行并观察变量变化
6.3 性能优化建议
虽然这个算法已经是O(n)复杂度了,但在实际面试或竞赛中,还可以考虑:
- 减少不必要的变量和计算
- 尽量复用数组空间
- 对于特别大的n,可以考虑并行计算某些部分
7. 算法扩展与变种
7.1 扩展到m个子数组
这个问题可以扩展为寻找m个不重叠的子数组的最大和。对于一般情况,可以使用动态规划来解决:
定义dp[i][j]表示前i个元素中选择j个子数组的最大和。状态转移方程为:
dp[i][j] = max(dp[i-1][j], dp[i-k][j-1] + sum(nums[i-k+1..i]))
这种解法的时间复杂度是O(mn),空间复杂度也是O(mn)。
7.2 允许子数组长度不同
另一个变种是允许子数组的长度不同,但总和最大。这种情况下,动态规划可能是更好的选择,因为滑动窗口的方法依赖于固定长度。
7.3 处理负数情况
虽然题目限定是正整数数组,但如果允许负数,算法需要相应调整。可能需要使用Kadane算法等处理包含负数的情况。
8. 实际应用场景
这种算法在实际中有多种应用场景:
- 金融分析:寻找多个时间段的最大收益
- 信号处理:检测信号中的多个高峰
- 资源分配:在时间线上分配多个资源块
- 广告投放:选择多个时间段投放广告以获得最大曝光
理解这类问题的解法,可以帮助我们解决许多实际中的序列分析问题。