LeetCode 862:和至少为 K 的最短子数组 —— 思考与复盘
问题:给定整数数组
nums(可以有负数)和整数k,求和 至少 为k的最短非空子数组长度,不存在返回-1。
这份笔记重点不是“记住代码”,而是把这两天反复思考的核心想法整理出来,方便以后复盘。
1. 从暴力到优化:问题的本质
1.1 暴力思路:前缀和 + 双重循环
先引入前缀和:
pre[0] = 0pre[i+1] = pre[i] + nums[i](i 从 0 到 n-1)
任意子数组 nums[l..r] 的和:
sum(l..r) = pre[r+1] - pre[l]
题目要找:
最短的
(l, r),使得pre[r+1] - pre[l] >= k。
把右端点写成 i = r+1,左端点写成 j = l,要求:
j < i
pre[i] - pre[j] >= k
长度 = i - j 最小
暴力做法:
for i in [0..n]:
for j in [0..i-1]:
如果 pre[i] - pre[j] >= k:
尝试更新 ans = min(ans, i - j)
时间复杂度 O(n²),n 到 10⁵ 不现实。
关键问题:
这 n² 个
(j, i)组合里,有没有一大批是“根本不可能成为最优答案”的,可以剪掉?
2. 把注意力集中到“左端点 j”的质量上
对固定的 i,我们要在所有 j < i 里选一个,让:
pre[i] - pre[j] >= k(合法)i - j尽量小(子数组最短)
对同一个 i 来说,两个不同的左端点 j1 和 j2 可以比较一下:
- 谁更靠右:j 越大,长度
i - j越短(好) - 谁前缀和越小:
pre[j]越小,pre[i] - pre[j]越大,更容易 ≥ k(好)
所以,一个好的左端点 j 应该:
- 尽量靠右;
- 前缀和尽量小。
自然产生一个想法:
有没有一些 j,被另外一个 j' 完全“打败”,无论对哪个右端点 i 都不可能比 j' 更好?
如果有,就可以直接把这些 j 从考虑范围中删掉 —— 这就是剪枝。
3. “完全支配”的概念:谁可以被删?
考虑两个左端点候选:
- 老的
j - 新的
i(注意:这里只是“左端点的下标”,暂时别管它是不是“右端点”)
假设满足:
j < i 且 pre[j] >= pre[i]
也就是说,(i, pre[i]) 在前缀和平面上,位于 (j, pre[j]) 的右下方或右侧同高。
对于任何一个未来的右端点 t > i,比较两种区间:
-
左端点用 j:
和:sum_j = pre[t] - pre[j] 长度:len_j = t - j -
左端点用 i:
和:sum_i = pre[t] - pre[i] 长度:len_i = t - i
因为 pre[j] >= pre[i],所以:
sum_i = pre[t] - pre[i] >= pre[t] - pre[j] = sum_j
又因为 j < i,所以:
len_i = t - i < t - j = len_j
结论(非常重要):
对任何右端点 t:
(i, t)的和 ≥(j, t)的和;(i, t)的长度 <(j, t)的长度。
也就是说,i作为左端点总是比j更有优势。
接下来,分两种情况:
- 如果
(j, t)不合法(和 < k),那本来就不可能成为答案; - 如果
(j, t)合法(pre[t] - pre[j] >= k),因为sum_i >= sum_j >= k,所以(i, t)也合法,并且长度更短 →(i, t)更可能成为最优。
因此:
一旦存在这样一个
i,旧的j就永远不可能再成为“最优左端点”,可以放心删掉。
这个“被完全支配的 j”,就是我们从队尾删除的“劣质候选人”。
4. 单调队列的角色:维护“未被支配的候选左端点”
我们希望维护一批“有潜力”的左端点 j,随时可以用来和当前右端点 i 组成答案。
设计一个双端队列 dq,里面存一些下标 j,满足:
-
下标单调递增:
dq[0] < dq[1] < ... < dq[last] -
对应前缀和也单调递增:
pre[dq[0]] < pre[dq[1]] < ... < pre[dq[last]]
这就保证队列里的任意两个候选 (j1, j2)(下标递增)都满足:
j1 < j2 且 pre[j1] < pre[j2]
即:
- 没有一个点在另一个点的“右下方”;
- 队列里的每个点都没有被右侧更低的点支配;
- 换句话说:队列里的每个 j 都“有潜力”成为某个 i 的最优左端点。
5. 代码中的三个关键步骤
以 Java 思路写伪代码(去掉语法细节):
pre[0] = 0;
for i in [0..n-1]:
pre[i+1] = pre[i] + nums[i];
Deque<Integer> dq = new LinkedList<>();
ans = n + 1;
for i in [0..n]: // 注意:i 是前缀和下标,0..n
// 步骤 1:尝试用当前 i 作为右端点,更新答案(从队头弹)
while dq 非空 且 pre[i] - pre[dq[0]] >= k:
ans = min(ans, i - dq[0]);
dq 弹出队头; // 这个 j 的“最短合法区间”已经找到,退休
// 步骤 2:维护队列中前缀和单调递增(从队尾弹)
while dq 非空 且 pre[i] <= pre[dq[last]]:
dq 弹出队尾; // 这个 j 被 i 完全支配,不可能再是最优左端点
// 步骤 3:把 i 加入队尾,作为未来的候选左端点
dq 将 i 加到队尾;
返回 ans == n+1 ? -1 : ans;
5.1 步骤 1:队头弹出 —— “你已经干完最好的活了”
条件:
pre[i] - pre[dq[0]] >= k
说明:
- 以
j = dq[0]为左端点、以i为右端点,形成的区间[j..i-1]已经合法(和 ≥ k); - 对这个 j 来说,这是它能遇到的最靠右但仍然合法的“最短区间”:
- 如果以后右端 t > i,再用 j,长度
t - j只会更长; - 而我们还可以用更大的左端点(队头后面的人)去拼更短的区间;
- 如果以后右端 t > i,再用 j,长度
所以:
对这个 j 来说,它的最佳时刻已经到来并被记录,
再留着它只会造成更长的区间,没有必要。
因此,更新完答案后,把队头弹出,继续看下一个队头是否也能和当前 i 组成合法区间。
5.2 步骤 2:队尾弹出 —— “你从一开始就不可能比别人好”
条件:
pre[i] <= pre[dq[last]]
根据前面支配关系的分析,这意味着:
dq[last]这个 j:- 在下标上比 i 更靠左;
- 在前缀和上比 i 更高或相等;
- 对任何未来右端 t:
(j, t)永远不如(i, t); - 所以 j 永远不可能成为“最优左端点”。
因此:
只要新来的 i 在前缀和上不高于队尾,就把队尾这个 j 删掉。
这一过程保证队列中的前缀和始终递增。
5.3 步骤 3:把 i 入队
- i 是当前看到的“最新的左端点候选”;
- 它不会被队列里任何人支配(因为队尾已经把更差的清理掉了);
- 所以可以放心加入队尾。
6. 重要细节 & 容易踩的坑
-
前缀和的定义要统一
- 推荐:
pre[0] = 0,pre[i+1] = pre[i] + nums[i]; - 避免写成“从 1 开始算”,否则
pre[i]和nums的下标对应关系会乱。
- 推荐:
-
i 遍历的是前缀和下标 0..n
- 数组
nums长度是 n,前缀和有 n+1 个; - 要让 i 取 0..n,才能覆盖所有以右端点 n-1 结尾的子数组。
- 数组
-
队头弹出和队尾弹出的条件不能搞混
- 队头:
pre[i] - pre[dq[0]] >= k(涉及 k,表示“已经合法了”); - 队尾:
pre[i] <= pre[dq[last]](完全不看 k,只看“谁支配谁”)。
- 队头:
-
队头弹出时要用
peekFirst,不要边 poll 边用
示例逻辑:while (!dq.isEmpty() && pre[i] - pre[dq.peekFirst()] >= k) { ans = Math.min(ans, i - dq.peekFirst()); dq.pollFirst(); }不要写成
pre[dq.poll()]再i - dq.poll()这种会 poll 两次的写法。 -
前缀和要用 long(防止整型溢出)
nums[i]可能大,n 又大时,前缀和会超过int范围;pre用long[]更保险。
-
这个算法与 k 的正负无关
- “队尾删人”的支配关系只用到
pre[j], pre[i]的大小比较; - 无论 k 是正、0、负,谁更优的关系都不变;
- k 只影响“什么时候队头 j 能和 i 组成合法区间”。
- “队尾删人”的支配关系只用到
7. 和“正整数滑动窗口”的类比与区别
7.1 正整数滑动窗口(全部 nums[i] > 0)
如果已知:
- 数组里全是正数;
- 只需要
sum >= k;
则可以用简单的双指针滑窗:
- 右指针右移,窗口和单调增加;
- 只要
sum >= k,就尝试移动左指针缩短窗口; - 时间复杂度 O(n),实现简单。
7.2 本题(允许负数)的难点
因为 nums 允许有负数:
- 右指针右移,遇到负数,sum 可能减小;
- 左指针右移,遇到负数,sum 可能反而增大;
- “窗口和单调性”被破坏,经典滑窗失效。
所以:
- 要先上升到“前缀和”的层面;
- 再用单调队列维护一个“未被支配的候选左端点集合”;
- 在这个集合上,用类似“滑动窗口”的思想移动队头、队尾。
一句话类比:
正整数滑窗是在原数组上直接利用“窗口和单调”。
这题是在前缀和空间里,通过“删掉被支配的 j”,人为构造一个“单调序列”,然后在这条序列上滑动。
8. 最终完整代码(Java 思想版)
class Solution {
public int shortestSubarray(int[] nums, int k) {
int n = nums.length;
long[] pre = new long[n + 1];
for (int i = 0; i < n; i++) {
pre[i + 1] = pre[i] + nums[i];
}
int ans = n + 1;
Deque<Integer> dq = new LinkedList<>();
for (int i = 0; i <= n; i++) {
// 1. 当前 pre[i] 作为右端点,尝试从队头找所有合法 j
while (!dq.isEmpty() && pre[i] - pre[dq.peekFirst()] >= k) {
ans = Math.min(ans, i - dq.peekFirst());
dq.pollFirst();
}
// 2. 维护队列前缀和单调递增:删掉被 i 完全支配的 j
while (!dq.isEmpty() && pre[i] <= pre[dq.peekLast()]) {
dq.pollLast();
}
// 3. 当前 i 作为未来的候选左端点入队
dq.offerLast(i);
}
return ans == n + 1 ? -1 : ans;
}
}
9. 心理层面的复盘
最后留几句给“自己看”的话:
- 理解这种算法难,不丢人:
它把“前缀和”、“支配关系”、“单调队列”、“滑动窗口思想”揉在了一起,本身就不是直观题。 - 真正重要的是:
- 你已经从“记代码”升级到能用“支配关系”看出哪些枚举是冗余的;
- 你能从暴力出发,一步步思考:
“在这些 (j, i) 里,有谁永远不可能成为答案?”。
- 以后再遇到类似的问题,可以用这套套路:
- 写出前缀和 + 暴力形式;
- 找出“谁更有希望成为最优”(靠右 + 更小 pre);
- 判断有没有“被完全支配”的点可以剪掉;
- 用单调结构维护这批候选。
理解这道题,证明你已经不是在“死记模板”,而是真正在学“算法如何从暴力演化到优化”。