算法讲解055 必备 单调队列-下

来源:算法讲解055【必备】单调队列-下 · 2026-10-20
依据:视频字幕 2000 字

一句话概括

单调队列维护"前缀和的下标窗口",在 O(n) 内求出"累加和 ≥ K 的最短子数组长度"(数组含负数也成立)。

它解决什么问题

给定数组 arr(可正可负可零)和正数 K,求所有连续子数组中,累加和 >= K 的最短长度。返回长度,不存在返回 0(或 -1,按题目约定)。

关键点:数组有负数,所以不能用双指针(滑窗收缩不单调),必须用单调队列 + 前缀和。

基础知识(前置概念,用大白话解释)

原理(为什么这么做是对的,尽量给直觉)

固定右端点 r(对应前缀和下标 r,即子数组以 arr[r-1] 结尾),我们要找最靠右的左端点 l,使得 pre[r] - pre[l] >= K,即 pre[l] <= pre[r] - K。

一句话直觉:队列里存的是"还有希望成为最优左端点"的下标,按 pre 值递增排好,队头最便宜,队尾最贵;新的来了把贵的、不划算的都挤掉。

步骤(编号,可照着写)

  1. 建前缀和数组 pre[0..n],pre[0]=0,pre[i]=pre[i-1]+arr[i-1]。
  2. 建空双端队列 dq(存下标),先把 0 压入(pre[0]=0 是候选左端点)。
  3. ans = INF(或 n+1)。
  4. 对 r = 1..n:

- 弹队头:while dq 非空 且 pre[r] - pre[dq.front()] >= K:ans = min(ans, r - dq.front()),dq.pop_front()。

- 弹队尾:while dq 非空 且 pre[dq.back()] >= pre[r]:dq.pop_back()。

- dq.push_back(r)。

  1. 返回 ans == INF ? 0 : ans(按题目要求)。

注意顺序:先弹队头(用当前 r 结算),再弹队尾(维护单调),最后入队。这样 r 不会自己配自己(长度 0)。

样例(一个具体小例子,带每步变化)

arr = [1, 1, 1, 6, 8, 2],K = 10。pre = [0, 1, 2, 3, 9, 17, 19]。

| r | pre[r] | 弹队头(pre[r]-pre[front]>=10) | 弹队尾(pre[back]>=pre[r]) | 入队后 dq | ans |

|---|---|---|---|---|---|

| 初始 | - | - | - | [0] | INF |

| 1 | 1 | 1-0=1<10,不弹 | pre[0]=0<1,不弹 | [0,1] | INF |

| 2 | 2 | 2-0=2<10 | pre[1]=1<2 | [0,1,2] | INF |

| 3 | 3 | 3-0=3<10 | pre[2]=2<3 | [0,1,2,3] | INF |

| 4 | 9 | 9-0=9<10 | pre[3]=3<9 | [0,1,2,3,4] | INF |

| 5 | 17 | 17-0=17≥10 → ans=5-0=5,弹0;17-1=16≥10 → ans=min(5,4)=4,弹1;17-2=15≥10 → ans=min(4,3)=3,弹2;17-3=14≥10 → ans=min(3,2)=2,弹3;17-9=8<10 停 | pre[4]=9<17 | [4,5] | 2 |

| 6 | 19 | 19-9=10≥10 → ans=min(2,6-4=2)=2,弹4;19-17=2<10 停 | pre[5]=17<19 | [5,6] | 2 |

答案 2(子数组 [6,8] 或 [8,2])。

代码

Python(好读版)

from collections import deque

def shortest_subarray_sum_at_least_k(arr, K):
    n = len(arr)
    pre = [0] * (n + 1)
    for i in range(n):
        pre[i + 1] = pre[i] + arr[i]

    dq = deque([0])          # 存下标,pre 值严格递增
    ans = n + 1
    for r in range(1, n + 1):
        # 1) 用当前 r 结算队头能配上的左端点
        while dq and pre[r] - pre[dq[0]] >= K:
            ans = min(ans, r - dq[0])
            dq.popleft()
        # 2) 维护单调:队尾 pre 值 >= pre[r] 的都没用了
        while dq and pre[dq[-1]] >= pre[r]:
            dq.pop()
        # 3) 入队
        dq.append(r)

    return 0 if ans == n + 1 else ans

C++(竞赛风,可直接抄)

#include <bits/stdc++.h>
using namespace std;

// 返回累加和 >= K 的最短子数组长度;不存在返回 0
// arr 可含负数;K > 0
int shortestSubarraySumAtLeastK(const vector<int>& arr, long long K) {
    int n = (int)arr.size();
    vector<long long> pre(n + 1, 0);
    for (int i = 0; i < n; ++i) pre[i + 1] = pre[i] + arr[i];

    // 手写双端队列(数组模拟,比 deque 快,常数小)
    vector<int> dq(n + 1);
    int head = 0, tail = 0;      // [head, tail) 区间
    dq[tail++] = 0;

    int ans = n + 1;
    for (int r = 1; r <= n; ++r) {
        // 1) 结算队头
        while (head < tail && pre[r] - pre[dq[head]] >= K) {
            ans = min(ans, r - dq[head]);
            ++head;
        }
        // 2) 维护单调递增(pre 值)
        while (head < tail && pre[dq[tail - 1]] >= pre[r]) --tail;
        // 3) 入队
        dq[tail++] = r;
    }
    return ans == n + 1 ? 0 : ans;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n; long long K;
    cin >> n >> K;
    vector<int> a(n);
    for (auto& x : a) cin >> x;
    cout << shortestSubarraySumAtLeastK(a, K) << '\n';
    return 0;
}

复杂度

竞赛里什么时候用(看到什么题面特征就该想到它)

竞赛模板(C++,可直接背下来的写法)

// 单调队列通用骨架:维护下标窗口,队内按值单调
// 这里以"前缀和单调递增 + 求最短子数组和 >= K"为例
int solve(const vector<int>& a, long long K) {
    int n = a.size();
    vector<long long> pre(n + 1, 0);
    for (int i = 0; i < n; ++i) pre[i + 1] = pre[i] + a[i];

    vector<int> q(n + 1);
    int h = 0, t = 0;
    q[t++] = 0;
    int ans = n + 1;
    for (int r = 1; r <= n; ++r) {
        while (h < t && pre[r] - pre[q[h]] >= K) ans = min(ans, r - q[h++]);
        while (h < t && pre[q[t - 1]] >= pre[r]) --t;
        q[t++] = r;
    }
    return ans == n + 1 ? 0 : ans;
}

记忆口诀:"先结算队头,再挤队尾,最后入队";队列存下标,比较用值。

边界与易错点(WA 的常见原因,越具体越好)

  1. 前缀和爆 int:n=1e6、|arr[i]|=1e9 时和可达 1e15,必须 long long。K 也要 long long。
  2. 弹队头 / 弹队尾顺序反了:先弹队头(用当前 r 结算),再弹队尾(维护单调)。如果先弹队尾,可能把当前 r 该配的左端点挤掉;如果先入队再弹队头,r 会自己配自己,长度 0 混进答案。
  3. pre[dq[back]] >= pre[r] 用 >= 还是 >:用 >=。相等时保留靠右的(下标更大、长度更短),靠左的没优势。
  4. 队头结算用 while 不是 if:一个 r 可能连续弹出多个队头(见样例 r=5)。
  5. 返回 0 还是 -1:题目约定不同,看清题面。LeetCode 862 返回 -1,视频里说返回长度(不存在返回 0 或按题)。
  6. K <= 0:本题约定 K 是正数。若 K 可为 0 或负,逻辑要改(K=0 时答案可能是 1,K<0 时任意非空子数组都行,答案 1 或 0)。
  7. 空数组:n=0 返回 0。
  8. 多测清空:队列用局部变量(vector<int> q(n+1) 每次重建),别用全局数组忘了清。
  9. 下标从 0 还是 1:前缀和 pre[0..n],pre[i] 表示前 i 个元素和,子数组 arr[l..r] 对应 pre[r+1]-pre[l]。写代码时统一用 r 表示"前 r 个",别混。

同类题 / 变形(说清楚怎么改)

常见坑

拓展(变体、实际用途、面试常问)

自测 3 题

题 1:arr = [2, -1, 2],K = 3,答案是多少?为什么不能用双指针?

答案

答案 3(整个数组和 = 3)。双指针失效:[2,-1] 和 = 1 < 3,但加上后面的 2 变成 3;收缩左端点时和可能变大(去掉 -1 反而变大),窗口和不单调,无法用"和大了就缩左"的贪心。

</details>

题 2:arr = [1, 2, 3, 4, 5],K = 11,用单调队列手推一遍,答案是多少?

答案

pre = [0,1,3,6,10,15]。r=5 时 pre[5]=15,15-pre[0]=15≥11 ans=5 弹0;15-pre[1]=14≥11 ans=4 弹1;15-pre[2]=12≥11 ans=3 弹2;15-pre[3]=9<11 停。答案 3(子数组 [3,4,5] 和 12,或 [4,5] 和 9 不够,[3,4,5] 是长度 3;其实 [2,3,4,5] 和 14 长度 4,[3,4,5] 长度 3 最短)。

</details>

题 3:把本题改成"求累加和 ≥ K 的最长子数组长度"(数组含负),单调队列怎么改?提示:想想队列该维护递增还是递减,队头结算条件怎么变。

答案

求最长 → 要最靠左的合法 l。队列维护 pre 递减(队头 pre 最大),对每个 r,队头若 pre[r]-pre[front] >= K 就结算 ans = max(ans, r-front) 但不弹(因为更靠左的 l 对更长有利,但队头已经是队列里最靠左的候选了……)。补充:更稳妥的做法是"对每个 l 找最靠右的 r",或前缀和 + 单调栈 + 二分。单调队列直接求最长要小心"弹出后丢失更优候选",建议用"维护递减队列,队头满足条件时结算并弹出,因为弹出后长度只会更长但 l 更靠右反而更短"——这里方向容易绕晕,竞赛里求最长通常用前缀和 + 单调栈 + 二分(O(n log n))或分治,不建议硬套单调队列。核心结论:单调队列天然适合"最短/最值",求最长要换思路。

</details>