LeetCode 862:和至少为 K 的最短子数组 —— 思考与复盘

36 阅读9分钟

LeetCode 862:和至少为 K 的最短子数组 —— 思考与复盘

问题:给定整数数组 nums(可以有负数)和整数 k,求和 至少k 的最短非空子数组长度,不存在返回 -1

这份笔记重点不是“记住代码”,而是把这两天反复思考的核心想法整理出来,方便以后复盘。


1. 从暴力到优化:问题的本质

1.1 暴力思路:前缀和 + 双重循环

先引入前缀和:

  • pre[0] = 0
  • pre[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 里选一个,让:

  1. pre[i] - pre[j] >= k(合法)
  2. i - j 尽量小(子数组最短)

对同一个 i 来说,两个不同的左端点 j1j2 可以比较一下:

  • 谁更靠右: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,比较两种区间:

  1. 左端点用 j:

    和:sum_j = pre[t] - pre[j]
    长度:len_j = t - j
    
  2. 左端点用 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,满足:

  1. 下标单调递增:

    dq[0] < dq[1] < ... < dq[last]
    
  2. 对应前缀和也单调递增:

    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 只会更长;
    • 而我们还可以用更大的左端点(队头后面的人)去拼更短的区间;

所以:

对这个 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. 重要细节 & 容易踩的坑

  1. 前缀和的定义要统一

    • 推荐:pre[0] = 0pre[i+1] = pre[i] + nums[i]
    • 避免写成“从 1 开始算”,否则 pre[i]nums 的下标对应关系会乱。
  2. i 遍历的是前缀和下标 0..n

    • 数组 nums 长度是 n,前缀和有 n+1 个;
    • 要让 i 取 0..n,才能覆盖所有以右端点 n-1 结尾的子数组。
  3. 队头弹出和队尾弹出的条件不能搞混

    • 队头:pre[i] - pre[dq[0]] >= k(涉及 k,表示“已经合法了”);
    • 队尾:pre[i] <= pre[dq[last]](完全不看 k,只看“谁支配谁”)。
  4. 队头弹出时要用 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 两次的写法。

  5. 前缀和要用 long(防止整型溢出)

    • nums[i] 可能大,n 又大时,前缀和会超过 int 范围;
    • prelong[] 更保险。
  6. 这个算法与 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) 里,有谁永远不可能成为答案?”。
  • 以后再遇到类似的问题,可以用这套套路:
    1. 写出前缀和 + 暴力形式;
    2. 找出“谁更有希望成为最优”(靠右 + 更小 pre);
    3. 判断有没有“被完全支配”的点可以剪掉;
    4. 用单调结构维护这批候选。

理解这道题,证明你已经不是在“死记模板”,而是真正在学“算法如何从暴力演化到优化”。