这段代码使用了前缀和+单调队列的组合策略来高效解决"和至少为K的最短子数组"问题。我将从问题定义、核心思路到代码实现逐步拆解:
问题定义
给定数组 nums
和整数 k
,找到和 ≥k 的最短非空子数组,返回其长度。
示例:nums = [2,-1,2]
, k = 3
→ 子数组 [2,-1,2]
和为3,长度3,返回3。
核心思路
1. 前缀和数组
前缀和数组 prefix[i]
表示 nums
前 i
个元素的和。
- 作用:快速计算子数组和。子数组
nums[i..j]
的和 =prefix[j+1] - prefix[i]
。 - 示例:
nums = [2,-1,2]
→prefix = [0, 2, 1, 3]
。
子数组[2,-1]
的和 =prefix[2] - prefix[0] = 1 - 0 = 1
。
2. 单调队列的作用
单调队列 q
存储前缀和数组的下标,确保队列中的下标对应的前缀和严格递增。
- 目标:对于每个右边界
j
,快速找到满足prefix[j] - prefix[i] ≥k
的最大左边界i
(使子数组长度j-i
最小)。 - 优化逻辑:
- 淘汰不可能的左边界:若存在
i1 < i2
且prefix[i1] ≥ prefix[i2]
,则i1
永远不可能是最优解(因为i2
更靠右且前缀和更小,使差值更大)。 - 单调性加速查询:队列中前缀和递增,若队首
i
不满足条件,则后续元素更不可能满足,直接停止检查。
- 淘汰不可能的左边界:若存在
代码实现解析
int shortestSubarray(vector<int>& nums, int k) {
int n = nums.size();
// 计算前缀和数组
vector<long long> prefix(n + 1, 0);
for (int i = 0; i < n; ++i) {
prefix[i + 1] = prefix[i] + nums[i];
}
deque<int> q; // 存储前缀和数组的下标,按prefix值单调递增
int ans = INT_MAX;
for (int j = 0; j <= n; ++j) {
// 移除队尾较大的元素,保持队列单调性
while (!q.empty() && prefix[j] <= prefix[q.back()]) {
q.pop_back();
}
// 检查队首是否满足条件,更新最短长度
while (!q.empty() && prefix[j] - prefix[q.front()] >= k) {
ans = min(ans, j - q.front());
q.pop_front(); // 队首已经找到最优解,后续无需再考虑
}
q.push_back(j); // 将当前下标加入队列
}
return ans == INT_MAX ? -1 : ans;
}
关键步骤详解
-
前缀和计算:
prefix[i+1] = prefix[i] + nums[i]
,确保prefix[j+1] - prefix[i]
表示子数组nums[i..j]
的和。 -
维护单调队列:
- 移除队尾较大元素:若
prefix[j] ≤ prefix[q.back()]
,则弹出队尾。
目的:保持队列单调性,确保后续查询时队首是最小前缀和。 - 检查队首条件:若
prefix[j] - prefix[q.front()] ≥k
,则更新最短长度并弹出队首。
原理:队首是当前最小前缀和,若其不满足条件,则后续元素更不可能满足;若满足,则队首是当前最优左边界(对应最短长度),弹出后不再考虑。
- 移除队尾较大元素:若
-
添加当前下标:
将j
加入队列尾部,确保队列中的下标按前缀和递增。
示例演示
输入:nums = [2,-1,2]
, k = 3
前缀和:prefix = [0, 2, 1, 3]
j | prefix[j] | 队列 q | 操作 | 最短长度 |
---|---|---|---|---|
0 | 0 | [0] | 初始化队列 | ∞ |
1 | 2 | [0, 1] | 2-0=2<3,加入1 | ∞ |
2 | 1 | [0, 2] | 弹出1(1≥2),加入2 | ∞ |
3 | 3 | [2, 3] | 3-0=3≥3 → 长度3-0=3 | 3 |
复杂度分析
- 时间复杂度:O(n)。每个元素最多入队和出队一次。
- 空间复杂度:O(n)。队列最坏情况下存储所有下标。
关键点总结
- 前缀和:将子数组和转化为前缀和的差值,O(1)时间计算。
- 单调队列:
- 单调性:确保队列中的前缀和递增,队首是最小前缀和。
- 淘汰机制:移除不可能的左边界,加速查询。
- 队首弹出逻辑:一旦队首满足条件,其对应的是当前最短长度,后续无需再考虑,直接弹出。
这种方法高效解决了子数组和的优化问题,是处理类似问题的经典策略。
在这段代码中,使用 while
循环而非 if
是由单调队列的性质和问题的最优解特性共同决定的。以下是详细分析:
一、为什么必须用 while
循环?
场景假设:
假设队列 q
中存储的前缀和下标为 [i1, i2, i3]
,对应的前缀和满足 prefix[i1] < prefix[i2] < prefix[i3]
(队列递增)。
当遍历到 j
时,prefix[j] - prefix[i1] ≥k
,说明:
i1
是可行的左边界,对应子数组长度为j - i1
。- 由于
prefix[i2] > prefix[i1]
,则prefix[j] - prefix[i2] < prefix[j] - prefix[i1]
,但可能仍≥k
。
例如:prefix[j]=10
,prefix[i1]=2
,prefix[i2]=5
,k=3
→10-2=8≥3
,10-5=5≥3
。
结论:队首 i1
满足条件时,后续的 i2
、i3
可能也满足条件,且对应的子数组长度更短(因为 i2 > i1
,j-i2 < j-i1
)。
因此需要持续检查队首之后的元素,直到找到不满足条件的队首,才能保证不会遗漏更优解。
二、while
循环的核心作用
1. 找到所有可行的左边界
队列中的前缀和递增,因此当 prefix[j] - prefix[q.front()] ≥k
时:
- 队首是当前最小的前缀和,对应最大的可行左边界
q.front()
(子数组长度最短)。 - 但后续元素的前缀和更大,可能仍满足条件,且对应更短的子数组。
示例:
prefix = [0, 1, 3, 5]
, k=2
, j=3
(prefix[j]=5
)。
- 队首
i=0
:5-0=5≥2
→ 长度3-0=3。 - 弹出队首后,新队首
i=1
:5-1=4≥2
→ 长度3-1=2(更优)。 - 弹出队首后,新队首
i=2
:5-3=2≥2
→ 长度3-2=1(最优)。 - 最终队列为
[3]
,循环停止。
若用 if
仅检查一次队首,会漏掉后续更优的解(如长度2和1)。
2. 维护队列的单调性和有效性
每次弹出队首后,新的队首可能仍满足条件,需要继续检查:
- 队首一旦不满足条件,后续元素的前缀和更大,差值更小,必然不满足条件,循环可以终止。
- 队首满足条件时,弹出队首是安全的,因为:
- 该队首对应的子数组长度是当前可行解中最短的(因为队首是最大的左边界)。
- 后续的左边界
i
更大(i > q.front()
),对应的子数组长度更小,可能更优。
三、若用 if
会发生什么?
错误示例:
// 错误:用if替代while
if (!q.empty() && prefix[j] - prefix[q.front()] >= k) {
ans = min(ans, j - q.front());
q.pop_front();
}
场景:
prefix = [0, 2, 3]
, k=2
, j=2
(prefix[j]=3
)。
- 队首
i=0
:3-0=3≥2
→ 记录长度2-0=2,弹出队首。 - 此时队列变为
[1]
,prefix[1]=2
,3-2=1<2
,不满足条件。 - 正确解:子数组
[2]
(下标1-1),和为2,长度1。 - 错误原因:用
if
仅检查初始队首,未发现后续队首i=1
可能满足条件(虽然本例中不满足,但存在其他情况)。
结论:if
只能处理队首的单次检查,无法处理队列中多个连续满足条件的元素,导致遗漏更优解。
四、代码中的逻辑正确性
while (!q.empty() && prefix[j] - prefix[q.front()] >= k) {
ans = min(ans, j - q.front()); // 记录当前最优解(可能不是全局最优)
q.pop_front(); // 弹出队首,检查下一个元素
}
- 循环条件:只要队首满足条件,就继续检查。
- 操作顺序:先记录解,再弹出队首。
- 因为队首是当前最大的左边界,对应的长度最短,弹出后新队首可能更优(长度更短)。
- 例如:队首
i1
对应长度L1
,下一个队首i2 > i1
对应长度L2 < L1
,必须记录L2
。
五、总结:while
的必要性
- 处理多个连续可行解:队列中可能存在多个满足条件的左边界,
while
确保遍历所有可能。 - 利用单调性剪枝:一旦队首不满足条件,后续元素必然不满足,直接终止循环,不会增加额外开销。
- 确保最优解:通过持续弹出队首,每次记录当前最短长度,最终得到全局最优解。
因此,while
循环是该算法正确性的关键,不能用 if
替代。