CSP-S 提高级课程中心 / 数据结构·线性与特殊树 / 第10讲
CSP-S 提高级 · 数据结构·线性与特殊树 · s3-01

双端栈、双端队列 与 单调队列

从「两头都能进出」的双端结构讲起,一路推到竞赛必备的单调队列——把最朴素的 O(nk) 滑动窗口最值,压到只需要 O(n) 的均摊时间。

O(n) 滑动窗口最值 均摊 O(1) 单次入队/出队 4 个递进案例 5 处交互动画 12 道洛谷原题
本讲 · 第 10/52 讲双端栈、双端队列与单调队列
模块 01

情景导航

从一条物流传送带上的监控屏,引出本讲的全部结构。

故事传送带上的监控屏

某物流分拣中心有一条长传送带,上面依次排列着 n 个包裹,第 i 个包裹的重量是 a[i] 千克。

质检员站在传送带旁,手里有一块只能看到连续 k 个包裹的取景框。传送带每向前移动一格,取景框里的包裹就整体向右挪一位:

[a1 a2 … ak] → [a2 a3 … ak+1] → [a3 a4 … ak+2] → …

监控屏的任务:取景框每移动一格,就立刻在屏幕上显示「当前框内最轻的包裹重量」。

实习生小 A 的写法:每移动一格,就把框里的 k 个包裹从头到尾看一遍,找出最小的那个。程序写起来只要 5 行,跑起来……

工程师老 B 的算法:他用了一个奇怪的习惯——每次只瞟一眼框里的包裹,把「没用的」直接从记事本上划掉,永不回头。写完也只有 10 行,但速度是小 A 的 几百倍。

老 B 说:「你每挪一格都重新看一遍,其实框里大部分信息早就确定了。我只需要维护一条永远递增的小队伍——队首就是当前窗口的最小值,队里的每个成员在我离开窗口之前都不会失效。」

这条「永远递增的小队伍」,就是本讲的主角——单调队列。

💡 为什么朴素做法会爆炸

小 A 的做法是:for (i = k; i <= n; i++) { m = a[i-k+1]; for (j = i-k+2; j <= i; j++) m = min(m, a[j]); }。

外层大约 n 次,内层固定 k 次,总共 O(nk)。当 n = 106、k = 5×105 时,运算量大约是 5×1011 次——即使每秒执行 108 次也要跑 5000 秒。而在竞赛里,这类题往往要求 1 秒内跑完。

问题的本质是「重复劳动」:窗口从 [1..k] 滑到 [2..k+1],中间那 k-1 个元素一个都没变,小 A 却把它们重新比较了一遍。单调队列的核心思想就是:一个元素一旦被比较过,就永不再被比较第二次——每个元素最多进队一次、出队一次,总代价 O(n)。

①生活类比
🚇

地铁车厢 = 双端队列

两头都有门:前面下车、后面上车;紧急时前面也能上人、后面也能下人。

→ push/pop_front/back
🍽️

两摞相向的餐盘 = 双端栈

一排长桌,左端放一摞盘子,右端放一摞盘子,各叠各的,互不干扰。

→ 共享一段数组
📏

排身高 = 单调队列

队伍永远从矮到高站。新来的如果比队尾矮,就把队尾请出去——因为「又高又先来」的人永远没机会了。

→ 队首即窗口最值
🔭

往后找更高的山 = 单调栈

站在山顶往前看,被更矮的山挡住视线;只要找到第一座比自己高的山,再往后的就都看不见了。

→ 下一个更大元素
②核心定义

本讲涉及四个名词,先把它们的「一句话定义」记牢,后面所有内容都是这几句话的展开。

双端栈(Double-Ended Stack)

用一段连续数组同时承载两个栈:一个从下标 1 向右生长,另一个从下标 n 向左生长。两个栈顶指针相向而行,只有它们「撞上」时才判满。

双端队列(Deque)

两端都支持插入和删除的线性表。它同时具备栈(同端进出,LIFO)和队列(异端进出,FIFO)的能力,还额外支持随机访问。

单调队列(Monotonic Queue)

队列内元素始终按值单调,且它们的下标也一定递增。队首永远是当前「合法范围」内的最值。核心操作是「队尾淘汰」。本讲重点

单调栈(Monotonic Stack)

只能从同一端进出的单调结构。它回答的问题是「往左/往右第一个比它大/小的元素在哪」。

⚠️ 一个必须澄清的误区:单调队列里的元素并不是有序排列的完整序列,它只保留「有可能成为未来最值」的候选者。比如窗口是 [5, 3, 8](求最小值),队列里可能只存着 [3]——因为 5 比 3 更早进窗口所以会先过期,8 比 3 大所以永远轮不上。队列「省略掉的」信息,恰恰是确定无用的信息。

③核心口诀
双端栈,两头长,指针相撞才算满
双端队,四操作,栈与队列全都干
单调队,看队首,又老又小先请走
新来的,比队尾,矮的挤走大的留
每个元素进一次,总体线性绝不愁

口诀里的「又老又小先请走」指的是过期弹出(下标超出窗口的左边界),「矮的挤走大的留」指的是队尾淘汰(新元素更小,则队尾元素永远不可能成为最小值)。这两步加在一起,就是单调队列的全部。

模块 02

知识探究

六个知识点层层递进:先看清「两端操作」的物理结构,再提炼出单调队列这个高效工具,最后把它用到 DP 优化上。

①双端栈:一段数组,两摞盘子

问题背景:假设我们需要同时维护两个栈,每个栈最多可能存 n 个元素。如果各开一个 n 大小的数组,最坏情况需要 2n 的空间,而实际上「两个栈同时接近满」几乎不会发生——这就是空间浪费。双端栈就是为解决这个浪费而生的。

核心思想:让两个栈共享同一段数组,各自从两端向中间生长。定义左栈顶指针 top1(初始 0)和右栈顶指针 top2(初始 n+1):

7 5 空 空 9 2 top1=1 2 top2=6 5 下标 1 … n,两栈相向生长
图 1:双端栈——左栈从下标 1 向右长,右栈从下标 n 向左长
双端栈的三条铁律
  1. 判空:左栈空 ⟺ top1 == 0;右栈空 ⟺ top2 == n+1。
  2. 判满:当 top1 + 1 == top2 时,两栈指针相邻,中间已无空位,整个数组满了(注意:不是某一个栈满了,而是两栈之和满了)。
  3. 入栈:左栈写 s[++top1] = x;右栈写 s[--top2] = x,两者都只需 O(1)。
C++双端栈的完整实现(共享数组版)
const int N = 100005;
int s[N], top1 = 0, top2 = N - 1;   // 数组下标 1..N-1 都可用

bool full() { return top1 + 1 == top2; }   // 两指针相邻 = 满

bool pushL(int x) {                    // 左栈:向右生长
    if (full()) return false;
    s[++top1] = x;
    return true;
}
bool pushR(int x) {                    // 右栈:向左生长
    if (full()) return false;
    s[--top2] = x;
    return true;
}
int popL() { return top1 == 0    ? -1 : s[top1--]; }
int popR() { return top2 == N - 1 ? -1 : s[top2++]; }
int topL() { return top1 == 0    ? -1 : s[top1]; }
int topR() { return top2 == N - 1 ? -1 : s[top2]; }
运行结果
pushL(7) pushL(5) pushR(9) pushR(2)   →  top1=2, top2=N-3
topL() = 5, topR() = 2, full() = 0
popL() = 5 → top1=1
popR() = 2 → top2=N-2
剩余内容 : [7] [9]

逐步播放上面这串操作,观察两个栈顶指针如何「相向而行」:

🎬 双端栈操作演示(pushL 7 → pushL 5 → pushR 9 → pushR 2 → popL → popR)
1初始:top1 = 0(左栈空)、top2 = N-1(右栈空)。注意空位判据:top1 + 1 == top2 才算满
2pushL(7):先 ++top1 再写 → s[1]=7,top1=1;pushL(5) → s[2]=5,top1=2
3pushR(9):先 --top2 再写 → s[N-2]=9,top2=N-2;pushR(2) → s[N-3]=2,top2=N-3(与运行结果一致)
4此时 topL() = s[2] = 5、topR() = s[N-3] = 2;full() 检查 top1+1=3 与 top2=N-3 相差甚远 → 0,还远没满
5popL():返回 s[top1--] = 5,top1 回落到 1;popR():返回 s[top2++] = 2,top2 回落到 N-2
6剩余内容 [7] [9] —— 两个栈互不干扰;若继续塞到 top1 + 1 == top2,整个数组才算满。易错点:左栈是「先加再写」(s[++top1]),右栈是「先减再写」(s[--top2]),写反就覆盖对方的数据
📎 记忆要点:判满看「两指针是否相邻」,判空看「各自回到起点」;空间从 2n 压到 n。

🔍 双端栈在竞赛里到底有什么用?

用途 1:空间利用率。朴素双栈需要 2n 的空间,双端栈只需要 n。当 n = 107、每个元素 4 字节时,这一下就省下了 40 MB 内存——在 256 MB 限制的题目里是生死线。

用途 2:单调栈的直觉来源。双端栈让你习惯「用一个数组、两端各维护一个指针」的思维方式。下一节的手写单调队列用的正是同一套路——只不过把「两端各一个栈」换成了「队头出、队尾出」。

用途 3:回文 / 对撞类问题。判断序列是否回文、双指针相向扫描时,双端栈天然契合「左端取一个、右端取一个」的操作模式。

注意:标准库没有双端栈这个类型(stack 不支持两端)。需要时就用上面的数组手写,二三十行足够。

②双端队列:两端都能插、都能删

一句话区分三种线性表:栈只在一端进出,队列一端进、另一端出,而双端队列两端都能进、两端都能出。所以栈和队列都是双端队列的「特例」。

🧱 栈 stack

插入:只有 push(栈顶)

删除:只有 pop(栈顶)

规则:后进先出 LIFO

典型:括号匹配、DFS

🚌 队列 queue

插入:只有 push(队尾)

删除:只有 pop(队头)

规则:先进先出 FIFO

典型:BFS 层序遍历

↔️ 双端队列 deque

插入:push_front / push_back

删除:pop_front / pop_back

规则:两端自由,可当栈也可当队列

典型:单调队列、0-1 BFS

C++ 标准库的 <deque> 提供下面这套接口,建议全部记住(后面单调队列我们还会用 deque 写一版对照):

操作含义复杂度
dq.push_back(x)在队尾插入元素 xO(1) 均摊
dq.push_front(x)在队头插入元素 xO(1) 均摊
dq.pop_back()删除队尾元素(不返回值)O(1) 均摊
dq.pop_front()删除队头元素(不返回值)O(1) 均摊
dq.front() / dq.back()查看队头 / 队尾元素O(1)
dq[i] 或 dq.at(i)随机访问第 i 个元素O(1)(常数比 vector 略大)
dq.size() / dq.empty()元素个数 / 是否为空O(1)
dq.clear()清空全部元素O(n)
🎬 双端队列的进出演示(FIFO:队尾进、队头出)

上面演示的是「从队尾进、从队头出」的普通队列用法。如果改成从同一端进出,它就是栈;如果一会儿从前面进、一会儿从后面出,那就是真正的双端队列——三种用法共用一套接口,这正是 deque 的灵活之处。

deque 内部是怎么实现的?它不是一整块连续内存(像 vector 那样),而是「分段连续 + 中控数组」:内存被切成若干个固定大小的缓冲区(chunk),再用一个「中控数组」(指针数组)把这些 chunk 串起来。当在队头插入元素而当前 chunk 满了,就新开一个 chunk 挂到中控数组前面——所以两端的插入都不会像 vector 那样引发整体搬家。

中控数组 map · → → → · chunk chunk chunk 每块内部连续,块与块之间靠 map 索引 ↔ 两端插入只需新挂一块,不必整体搬家
图 2:deque 的「分段连续」结构
deque 与 vector 的关键差异
  • 头部插入:vector 是 O(n)(要整体后移),deque 是 O(1) 均摊。
  • 随机访问:两者都是 O(1),但 deque 需要先查中控数组再定位 chunk,常数更大,频繁下标访问时比 vector 慢 2~3 倍。
  • 内存连续性:vector 保证整块连续,可以安全传给 C 风格 API;deque 不保证。
  • 迭代器:deque 的迭代器是「复杂迭代器」,++ 时要判断是否跨 chunk,比 vector 的裸指针慢。
  • 扩容:vector 满了会整体搬家(可能使所有旧指针失效),deque 只在两端挂新块,不会搬家。

选型口诀:要随机访问 + 遍历为主 → vector;要在两端频繁增删 → deque;只在尾部增删 → vector。

手写数组版双端队列:竞赛中为了极致速度(或者用了不支持 STL 的环境),我们常用一个数组加两个指针来模拟双端队列。核心技巧是把数组中间位置作为起点,向两侧扩展,避免「下标变成负数」:

C++手写数组版双端队列(双指针法)
const int MAXN = 2000005;
int q[MAXN];
int h = MAXN / 2, t = MAXN / 2 - 1;   // h = 队头下标, t = 队尾下标
// 约定:h > t 表示队空;元素存放在 q[h .. t] 这段连续区间内

// ---------- 队尾操作 ----------
void push_back(int x) { q[++t] = x; }
void pop_back()       { t--; }
int  back()           { return q[t]; }

// ---------- 队头操作 ----------
void push_front(int x) { q[--h] = x; }
void pop_front()       { h++; }
int  front()           { return q[h]; }

// ---------- 通用 ----------
bool empty() { return h > t; }
int  size()  { return t - h + 1; }

💡 为什么把 h 和 t 放在数组中间?因为 push_front 要做 --h,如果初始 h = 0,第一次 push_front 就会访问 q[-1]——数组越界(未定义行为)。把起点放在 MAXN/2,就等于给队头预留了 MAXN/2 个「向左生长的空间」。同理,单调队列的窗口长度最多为 k,那么预留 k 个位置就够。

⚠️易错点:h 与 t 的初始化

错误写法:int h = 0, t = 0; 然后用 h > t 判空——此时初始状态 h == t == 0,会被判成「非空」,front() 返回的是未初始化的 q[0]。

正确写法:int h = 1, t = 0;(此时 h > t,判空成立);用 push 时先 ++t 再赋值。这是「数组下标从 1 开始」的经典写法。或者像上面那样 h 指向队头、t 指向队尾,用 h > t 判空,也同样要求初始 h == t + 1。

③单调队列:把「永远没用的」当场开除

先看一个残酷的事实:窗口从 [i-k+1, i] 滑到 [i-k+2, i+1] 时,只有 两个元素发生了变化(出去一个、进来一个),却要重新比较 k 个值。这 k-1 次比较里藏着大量纯重复。

关键洞察(单调队列的唯一原理):假设现在要维护窗口最小值,且窗口内有两个元素 a[j] 和 a[i] 满足 j < i 且 a[j] ≥ a[i]。那么:

① a[i] 比 a[j] 更小——所以只要 a[i] 还在窗口里,最小值就不可能轮到 a[j]。

② a[i] 比 a[j] 进窗口更晚——所以 a[i] 一定比 a[j] 更晚离开窗口。

两条合起来 → 当 a[j] 有用的时候 a[i] 也在,而 a[i] 更小;当 a[i] 离开时 a[j] 早已离开。结论:a[j] 永远没有出头之日,可以直接从候选集合里删掉!

把这条规则反复应用,候选集合里剩下的元素就必然满足:越靠后进来的,值越严格递增。再加上「下标本来天然递增」,于是候选集合形成了一条下标递增 + 值递增的双调序列——这就是单调队列。

性质 1 · 队首即答案

队列里的元素全部来自当前窗口(过期元素已被弹出),而其中最小的一定在队首。所以取答案只要 a[q[h]],O(1)。

性质 2 · 下标单调递增

元素按入队时间排列,所以「越靠队首的越老」——判断过期只需看队首。

性质 3 · 值单调(求最小值时递增)

由上面的「开除规则」保证。求最大值时方向反过来,队列呈递减。

性质 4 · 均摊 O(1)

每个元素最多入队一次、出队一次,所有 n 个元素的总操作次数 ≤ 2n,因此整体 O(n)。

操作只有三步(以「求窗口最小值」为例,窗口右端为 i、长度为 k):

① 过期弹出 while (h<=t && q[h] < i-k+1) h++ ② 队尾淘汰 while (h<=t && a[q[t]] >= a[i]) t-- ③ 入队 + 取值 q[++t] = i; if (i>=k) print a[q[h]] 单调队列 vs 优先队列 队列里的元素必须能「主动退休」
图 3:单调队列的三步操作(顺序不能颠倒)
为什么不能颠倒顺序?
  • ①必须最先做:如果先入队再弹过期,队列里会混入「已过期但值很小」的元素,队首可能返回一个早就不在窗口里的值。
  • ②必须在③之前做:淘汰的依据是「新元素 a[i]」,所以必须先拿到 a[i] 才能比较;而且新元素不参与对自己的淘汰(用的是 >= 还是 > 决定是否保留等值元素,见后面的易错点)。
  • ③的取值要加条件:前 k-1 次迭代窗口还没长满,此时不该输出。所以用 if (i >= k) 卡住。
🎬 单调队列求滑动窗口最小值全程推演(a = 1 3 -1 -3 5 3 6 7,k = 3)
1i=1, a[1]=1:队列空,直接入队 → q = [1(值1)]。窗口未满(1 < 3),不输出
2i=2, a[2]=3:队尾值 1 ≤ 3,无需淘汰;入队 → q = [1(1), 2(3)]。未满,不输出
3i=3, a[3]=-1:队首下标 1 ≥ 1 不过期;队尾值 3 ≥ -1 淘汰;再比队尾值 1 ≥ -1 淘汰;队列空,入队 → q = [3(-1)]。输出队首 -1
4i=4, a[4]=-3:队首下标 3 ≥ 2 不过期;队尾值 -1 ≥ -3 淘汰;入队 → q = [4(-3)]。输出 -3
5i=5, a[5]=5:队首下标 4 ≥ 3 不过期;队尾值 -3 ≤ 5 不淘汰;入队 → q = [4(-3), 5(5)]。输出 -3
6i=6, a[6]=3:队首下标 4 ≥ 4 不过期;队尾值 5 ≥ 3 淘汰;队尾值 -3 ≤ 3 停;入队 → q = [4(-3), 6(3)]。输出 -3
7i=7, a[7]=6:队首下标 4 < 5(= 7-3+1)→ 过期弹出!队列变 [6(3)];队尾值 3 ≤ 6 不淘汰;入队 → q = [6(3), 7(6)]。输出 3
8i=8, a[8]=7:队首下标 6 ≥ 6 不过期;队尾值 6 ≤ 7 不淘汰;入队 → q = [6(3), 7(6), 8(7)]。输出 3
最终最小值序列:-1   -3   -3   -3   3   3。注意队列内值始终递增(-3, 3, 6, 7),而数组本身是乱序的——这就是单调队列「只留候选、不留全部」的直观体现。
C++单调队列模板(手写数组版 · 求窗口最小值)
const int N = 1000005;
int n, k, a[N];
int q[N], h = 1, t = 0;      // q 存下标;h > t 表示队空

int main() {
    scanf("%d%d", &n, &k);
    for (int i = 1; i <= n; i++) scanf("%d", &a[i]);

    for (int i = 1; i <= n; i++) {
        // ① 过期弹出:队首下标已经滑出窗口左边界 i-k+1
        while (h <= t && q[h] < i - k + 1) h++;

        // ② 队尾淘汰:新元素更小(或相等),队尾永远没机会
        while (h <= t && a[q[t]] >= a[i]) t--;

        // ③ 入队 + 取值
        q[++t] = i;
        if (i >= k) printf("%d ", a[q[h]]);
    }
}
运行结果
输入:8 3
      1 3 -1 -3 5 3 6 7
输出:-1 -3 -3 -3 3 3

🔍 代码逐段解析 · 单调队列模板(手写数组版)

① 全局声明:q[N] 存的是下标不是值;h = 1, t = 0 使 h > t 表示队空——这个初值配对是「先 ++ 再写」写法的标配。

② 过期弹出(第 14 行):while (h <= t && q[h] < i - k + 1) h++; —— 窗口左边界是 i-k+1,队首下标比它小就永久出队。必须放在循环第一步。

③ 队尾淘汰(第 17 行):while (h <= t && a[q[t]] >= a[i]) t--; —— 新元素更小(或相等)且更晚过期,队尾永无出头之日。比较的是 a[q[t]] 与 a[i],两个都是「值」。

④ 入队 + 取值(第 19~21 行):q[++t] = i 先加再写;if (i >= k) 才输出 a[q[h]] —— 前 k−1 次窗口未满不输出。

易错点:① 三步顺序不能颠倒(先过期、再淘汰、后入队);② q[h] < i-k+1 写成 <= i-k 之外的形式(如漏掉 +1)会把合法元素当过期弹出。

用 deque 写同一件事(代码更短、更不易错,但常数稍大):

C++单调队列模板(STL deque 版 · 求窗口最小值)
#include <bits/stdc++.h>
using namespace std;
int n, k, a[1000005];

int main() {
    scanf("%d%d", &n, &k);
    for (int i = 1; i <= n; i++) scanf("%d", &a[i]);

    deque<int> q;                    // 存下标
    for (int i = 1; i <= n; i++) {
        while (!q.empty() && q.front() < i - k + 1) q.pop_front();  // ①
        while (!q.empty() && a[q.back()] >= a[i])      q.pop_back();   // ②
        q.push_back(i);                                                 // ③
        if (i >= k) printf("%d ", a[q.front()]);
    }
}

🔍 代码逐段解析 · 单调队列模板(STL deque 版)

① deque<int> q:直接存下标,省掉手写 h/t;q.empty() 替代 h <= t。

② 过期弹出:while (!q.empty() && q.front() < i - k + 1) q.pop_front(); —— 与手写版逐字对应,只是 q[h] 变成 q.front()。

③ 队尾淘汰:while (!q.empty() && a[q.back()] >= a[i]) q.pop_back(); —— q[t] 变成 q.back()。注意 pop_back / pop_front 都不返回值,需要值时先取 back() 再 pop。

④ 入队 + 取值:q.push_back(i); if (i >= k) printf("%d ", a[q.front()]);。

易错点:① 混用 front/back(过期看队首、淘汰看队尾,写反必错);② 忘记 empty() 判断,空队调用 front() 是未定义行为。

💡 两种写法怎么选?

手写数组版:常数最小,n 达到 106 量级、或多组数据时更稳;缺点是要自己管 h/t 的边界。

deque 版:代码短、语义清晰、不会因为下标算错而爆炸;缺点是有额外的指针维护开销,实测大约慢 1.5~2 倍。

建议:平时练手和调试用 deque 版,正式比赛遇到大数据量时换成手写数组版。两者逻辑完全一致,可以互相翻译。

④滑动窗口最值:把最小值改成最大值只需一个符号

求窗口最大值时,把「淘汰条件」的不等号反过来即可——原来「队尾值 ≥ 新值就淘汰」,改成「队尾值 ≤ 新值就淘汰」。此时队内元素递减,队首是最大值。

🔽 求最小值(队内递增)

while (a[q[t]] >= a[i]) t--;

队首 a[q[h]] = 窗口最小

队列示例:[-3, 3, 6, 7]

🔼 求最大值(队内递减)

while (a[q[t]] <= a[i]) t--;

队首 a[q[h]] = 窗口最大

队列示例:[8, 5, 2]

下面用格子图展示 P1886 的样例数据。可以留意「下标越靠后的元素,值反而越小或越大」这种单调性——这正是队列内部的样子:

1
3
-1
-3
5
3
6
7

对上这 8 个数字、取 k = 3,最小值序列与最大值序列分别是:

运行结果
最小值序列:-1 -3 -3 -3  3  3
最大值序列: 3  3  5  5  6  7

📊 复杂度对比:三层解法,天壤之别

解法一 · 暴力:每个窗口扫一遍,时间 O(nk)。n = 106、k = 105 时为 1011,超时。

解法二 · 分块 / ST 表:预处理 O(n log n),查询 O(1)。总时间 O(n log n),能过,但需要额外 O(n log n) 空间,且代码更长(ST 表见下一讲)。

解法三 · 单调队列:时间 O(n)、空间 O(k)。这就是本题的标准解法,也是「滑动窗口最值」这一类问题的唯一正解。

为什么单调队列比 ST 表还快?因为 ST 表的 O(1) 查询是「预知所有查询」为前提的离线预处理;而单调队列是在线的,元素一个一个进来就能即时给出答案,天然适合「数据边读边处理」的场景。

⚠️易错点:过期判断的边界差一位

错误写法:while (h <= t && q[h] <= i - k) h++; —— 这是对「窗口左边界」理解错误。当前窗口是 [i-k+1, i],下标 i-k 已经不在窗口里了,应该弹;但 i-k+1 是合法的,不能弹。用 <= i-k 恰好等价于 < i-k+1,是对的——真正错的是写成 q[h] < i - k(漏掉等号),这样会把合法的 i-k 当作过期元素保留下来。

保险写法:统一用 q[h] < i - k + 1,把「窗口左边界」这个式子写全,一眼就能看出对应关系。或者更直白地写 if (i - q[h] >= k) h++;——「队首离当前已经跨了 k 步」,那也是过期。

⑤单调栈与单调队列:一对孪生兄弟

先看它们的血缘关系:单调栈其实就是「只能在同一端进出的单调队列」。因为进和出都在栈顶,所以单调栈只能回答「往左第一个比它大/小的元素」这类问题;而单调队列因为两端都能动,能回答「一段滑动区间内的最值」。

单调栈同端进出 · LIFO · 回答「最近的更大/更小」
单调队列两端可动 · FIFO · 回答「窗口内的最值」
共同点①都用「淘汰永无用元素」维持单调
共同点②每元素进出各一次 → 均摊 O(1)、总 O(n)
区别单调队列多一步「过期弹出」,单调栈没有

单调栈的经典应用:下一个更大元素。给定序列 a[1..n],对每个 i 求最小的 j > i 使得 a[j] > a[i];若不存在则输出 -1。

数组 a(下标 1..5) 3 4 2 6 5 下一个更大元素 4 6 6 -1 -1 a[1]=3 遇到 4 → 出栈;a[4]=6 之后没有更大的 → -1 栈内始终保持「从栈底到栈顶递减」
图 4:单调栈求「下一个更大元素」
单调栈的三步(求下一个更大元素)
  1. 遍历 i 从 1 到 n:当栈非空且 a[栈顶] < a[i] 时,说明 a[i] 就是栈顶元素的「下一个更大元素」,记录答案并出栈。这个 while 可能连续弹出多个。
  2. 把 i 压入栈。
  3. 遍历结束后仍在栈里的下标,它们的答案是 -1。

为什么正确?栈里保留的永远是「还没有找到更大元素」的下标,且它们的值递减。当 a[i] 到来时,所有比它小的栈顶元素都找到了答案——而且 a[i] 是它们最近的更大元素(因为下标更大的都在栈顶,先被处理)。

C++单调栈模板:下一个更大元素
const int N = 3000005;
int n, a[N], ans[N], st[N], top = 0;

int main() {
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) scanf("%d", &a[i]);

    for (int i = 1; i <= n; i++) {
        // 栈顶元素比 a[i] 小 → a[i] 就是它的「下一个更大」
        while (top && a[st[top]] < a[i]) {
            ans[st[top]] = i;      // 记录是「第几个位置」,也可以记 a[i]
            top--;
        }
        st[++top] = i;             // 自己入栈,等后面的元素来找
    }
    for (int i = 1; i <= n; i++)
        printf("%d ", ans[i] ? a[ans[i]] : -1);   // ans 存的是下标,输出值要再取一次 a
    return 0;
}
运行结果
输入:5
      3 4 2 6 5
输出:4 6 6 -1 -1        ← 输出的是「下一个更大元素的值」

🔍 代码逐段解析 · 单调栈模板(下一个更大元素)

① 全局声明:ans[N] 存「下一个更大元素的下标」(0 表示无)、st[N] 存下标、top = 0 栈空。

② 弹栈(第 12~15 行):while (top && a[st[top]] < a[i]) —— 栈顶比 a[i] 小,说明 a[i] 就是栈顶「下一个更大」的元素:ans[st[top]] = i 记录下标后弹出。求严格更大用 <(相等不算),求非严格用 <=。

③ 入栈:st[++top] = i —— 自己进入候选,等后面的元素来认领。栈内保持「从栈底到栈顶递减」。

④ 输出(第 18~19 行):printf("%d ", ans[i] ? a[ans[i]] : -1) —— ans[i] 是下标,要输出「值」必须再取 a[ans[i]];没被更新(仍为 0)的输出 -1。

易错点:① 把 ans[i](下标)直接当值输出——本题运行结果是「值序列 4 6 6 -1 -1」,若直接打印 ans 会得到「2 4 4 -1 -1」;② 栈里存值不存下标,导致无法回写 ans。

⚠️易错点:栈里存「值」还是存「下标」?

只存值的后果:栈里存的是 a 的值,那么你只能知道「有更大的元素」,却无法知道它在下标几。而绝大多数题目(求下一个更大元素的位置、求柱状图最大矩形、求「每个元素左边第一个更小的」)都需要下标来定位和计算宽度。

正确做法:栈里永远存下标,需要值时用 a[st[top]] 取。这样既知道值、又知道位置,一举两得。单调队列同理——q 里存的也永远是下标,不是值。

⑥单调队列优化 DP:把 O(n²) 压成 O(n)

什么样的 DP 能被单调队列优化?看转移方程的形状。如果它长这样:

dp[i] = max / min { dp[j] + w(i) }    其中 j 属于区间 [L(i), R(i)]

也就是说:决策候选是一段连续的、随 i 单调移动的区间(比如 j ∈ [i-m, i-1]),而且 w(i) 这一项与 j 无关(可以提到 max/min 外面)。这时我们就可以用一个单调队列维护 dp[j] 在滑动区间内的最值——每次转移的代价从 O(m) 降到 O(1),总复杂度从 O(nm) 降到 O(n)。

识别信号 1

转移里出现了「滑动窗口」式的限制:j ∈ [i-m, i-1]、j < i 且 i-j ≤ m。

识别信号 2

要取的是 dp[j] 或 dp[j] + 常数 的最大/最小值,与 i 的具体值无关。

识别信号 3

L(i) 和 R(i) 都随 i 单调不减(这是能用队列而不是堆的必要条件)。

常见变体

若区间左端点不是 i-m 而是「满足某个单调条件的最小 j」(如二分答案 + check),则用双指针 + 单调队列。

具体的「切蛋糕」模型:给定 n 个数 a[1..n],求所有长度不超过 m 的连续子段的最大和。

① 前缀和表示子段和 sum(i,j) = S[i] - S[j-1],长度 = i-j+1 ≤ m ② 以 S 的下标重写条件 固定 i,则 j 属于 [i-m+1, i],等价于 S 的下标 k=j-1 ∈ [i-m, i-1] ③ 答案形式 ans = max over i { S[i] - min{ S[k] : k ∈ [i-m, i-1] } }
图 5:切蛋糕问题的三步推导
关键一步:为什么可以「提出来」

sum(i,j) = S[i] - S[j-1],其中 S[i] 只和 i 有关、S[j-1] 只和 j 有关——两者成功分离。于是:

max{ S[i] - S[k] } = S[i] - min{ S[k] }

里层的 min 只依赖 k 的取值范围(一个随 i 滑动、长度固定为 m 的区间),与 i 的具体值无关——这正是单调队列的用武之地。

复杂度:直接枚举 (i, j) 是 O(nm),用单调队列降到 O(n)。

C++单调队列优化 DP:长度不超过 m 的最大子段和
#include <bits/stdc++.h>
using namespace std;
const int N = 500005;
int n, m, q[N], h = 1, t = 0;
long long S[N];

int main() {
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) {
        int x; scanf("%d", &x);
        S[i] = S[i - 1] + x;              // 前缀和
    }

    long long ans = LLONG_MIN;
    q[++t] = 0;                            // 关键:把 S[0] 先放进去,代表「从头开始取」
    for (int i = 1; i <= n; i++) {
        // ① 过期:k 必须不小于 i-m
        while (h <= t && q[h] < i - m) h++;
        ans = max(ans, S[i] - S[q[h]]);       // ② 用队首(区间内 S 最小)转移
        // ③ 把 S[i] 入队,保持 S 值递增(求最小)
        while (h <= t && S[q[t]] >= S[i]) t--;
        q[++t] = i;
    }
    printf("%lld\n", ans);
    return 0;
}
运行结果
输入:5 3
      2 -3 5 -1 4
输出:8
(子段 [5, -1, 4] 长度 3 ≤ m,和为 8)

用样例 a = 2 -3 5 -1 4(m=3)逐步播放「前缀和单调队列」的演化:

🎬 切蛋糕 DP 的单调队列推演(S = 0 2 -1 4 3 7,队列存下标、按 S 递增)
1预置 q = [0]:S[0]=0 代表「从头开始取」,这是陷阱 1
2i=1:过期无;转移 S[1]-S[0] = 2,ans=2;淘汰:S[0]=0 ≥ S[1]=2?否 → q = [0, 1]
3i=2:转移 S[2]-S[0] = -1,ans 仍 2;淘汰:S[1]=2 ≥ -1 弹、S[0]=0 ≥ -1 弹 → q = [2](S 值 -1 是全局最小,前面全被挤走)
4i=3:转移 S[3]-S[2] = 4-(-1) = 5,ans=5;S[2]=-1 ≥ 4?否 → q = [2, 3]
5i=4:转移 S[4]-S[2] = 3-(-1) = 4,ans 仍 5;淘汰:S[3]=4 ≥ 3 弹 → q = [2, 4]
6i=5:过期检查 front=2 < 5-3=2?否(恰好压线合法);转移 S[5]-S[2] = 7-(-1) = 8,ans=8 ✅ —— 对应子段 a[3..5] = 5, -1, 4
📎 注意本题语句顺序是「先转移、后入队」——因为决策区间是 [i-m, i-1],不含 i 自己(细节 2)。

🔍 三个必须注意的细节

细节 1:先 q[++t] = 0。因为子段可以从第一个元素开始,此时 j = 1、k = 0,需要 S[0] = 0 参与比较。如果漏掉这一步,所有「从开头开始」的答案都会算不出来。

细节 2:转移和入队的顺序。本题是「先转移、后入队」,因为 S[i] 不能减自己(长度至少为 1,k ≤ i-1)。但上一节滑动窗口是「先入队、后取值」。顺序取决于 k 的区间端点是否包含 i 本身,写之前一定要把区间式子写清楚。

细节 3:单调队列里滑动的量是 S[j] 而不是 a[j]。因为转移式里出现的是前缀和。这是初学者最常犯的错——记住「DP 转移里出现谁,队列里就维护谁」。

🔍 代码逐段解析 · 切蛋糕 DP(手写数组版)

① 前缀和:S[i] = S[i-1] + x —— 子段和 a[j+1..i] = S[i] − S[j]。

② q[++t] = 0:S[0] 预入队,代表「从第 1 块开始取」的决策;漏掉则 i ≤ m 时队列可能空、q[h] 越界。

③ 过期(第 16 行):while (h <= t && q[h] < i - m) h++; —— 决策 k ∈ [i-m, i-1],左端 i-m;与滑动窗口的 i-k+1 差 1,来源是「子段长度 = i-k」这个式子。

④ 转移(第 17 行):ans = max(ans, S[i] - S[q[h]]) —— 队首是窗口内最小 S;注意这句在入队之前(先转移后入队)。

⑤ 淘汰 + 入队(第 19~20 行):while (S[q[t]] >= S[i]) t--; q[++t] = i; —— 队列按 S 值递增维护,比较对象是 S 不是 a。

易错点:① while 条件写成 a[q[t]] >= a[i](照抄滑动窗口)——负数会放大错误,队首不再是最小前缀和;② ans 初值 0 而非 LLONG_MIN。

案例案例1:滑动窗口双最值(P1886 【模板】单调队列) 洛谷真题

题目:给出 n 个数和窗口长度 k。窗口从序列最左端滑到最右端,每次输出当前窗口内的最小值和最大值。

C++P1886 完整代码(手写数组版,一次遍历同时求最值)
#include <bits/stdc++.h>
using namespace std;
const int N = 1000005;
int n, k, a[N];
int q[N], h, t;                        // 单调队列(存下标)

int main() {
    scanf("%d%d", &n, &k);
    for (int i = 1; i <= n; i++) scanf("%d", &a[i]);

    // ---------- 第一遍:求每个窗口的最小值 ----------
    h = 1, t = 0;
    for (int i = 1; i <= n; i++) {
        while (h <= t && q[h] < i - k + 1) h++;      // 过期
        while (h <= t && a[q[t]] >= a[i]) t--;            // 队内递增
        q[++t] = i;
        if (i >= k) printf("%d ", a[q[h]]);
    }
    puts("");

    // ---------- 第二遍:求每个窗口的最大值(不等号反向) ----------
    h = 1, t = 0;
    for (int i = 1; i <= n; i++) {
        while (h <= t && q[h] < i - k + 1) h++;
        while (h <= t && a[q[t]] <= a[i]) t--;            // 队内递减
        q[++t] = i;
        if (i >= k) printf("%d ", a[q[h]]);
    }
    puts("");
    return 0;
}
运行结果
输入:8 3
      1 3 -1 -3 5 3 6 7
输出:-1 -3 -3 -3 3 3
      3 3 5 5 6 7

🔍 两遍遍历能不能合成一遍?

可以。开两个队列 q1(求最小,队内递增)和 q2(求最大,队内递减),在同一次 for 循环里对 i 都做「过期 + 淘汰 + 入队」,然后同时输出。代码量基本相同,只是要多维护一组 h/t。

为什么这里选「两遍」?因为两遍写法更清晰、更不容易搞混两个队列的指针;而且本题 n ≤ 106,两遍也只要约 200 万次操作,完全在时限内。考试时优先选自己能写对的版本。

注意:两遍写法里两个队列都必须重新初始化 h = 1, t = 0。忘了重置 h/t 是本题最隐蔽的 bug——第一遍结束后 q 数组里还残留着旧下标,第二遍会莫名其妙地输出错误结果。

🔍 代码逐段解析 · P1886(两遍扫描版)

① 声明:q[N], h, t 是全局数组——正因如此第一遍结束后残留状态会带进第二遍,所以第 15 行与第 25 行的 h = 1, t = 0 各出现一次,一行都不能少。

② 第一遍(求最小):过期 q[h] < i-k+1 → 淘汰 a[q[t]] >= a[i](队内递增)→ 入队 → i ≥ k 时输出队首。

③ 第二遍(求最大):唯一差别是淘汰条件改成 a[q[t]] <= a[i](队内递减),队首即窗口最大。

④ puts(""):每遍结束换行,让两组输出各占一行,与题目「两行输出」格式对应。

易错点:① 第二遍忘了重置 h/t(残留旧下标 → 输出错乱);② 两遍的淘汰不等号抄成同一个(两行输出完全相同,其实是都求了最小或都求了最大)。

案例案例2:求 m 区间内的最小值(P1440) 洛谷真题

题目:给出 n 个数 a[1..n] 和 m。对每个 i,输出 a[max(1, i-m) .. i-1] 中的最小值(也就是「i 之前 m 个数」里的最小)。当 i = 1 时输出 0。

与案例1 的三点区别:

区别 1 · 窗口位置不同

P1886 的窗口是 [i-k+1, i](包含 i 自己);本题是 [i-m, i-1](不含 i 自己)。所以本题要「先取答案、再把 a[i] 入队」。

区别 2 · 窗口不足时

P1886 前 k-1 次不输出;本题必须输出——窗口没长满时,就用「已入队的全部元素」的最小值(左边界自动被 max(1, i-m) 兜住)。

区别 3 · i = 1 特判

i = 1 时前面没有任何数,直接输出 0。这一步不能忘。

共同点

都是「固定长度窗口 + 取最值」,都属于单调队列 O(n)。

C++P1440 完整代码
#include <bits/stdc++.h>
using namespace std;
const int N = 2000005;
int n, m, a[N], q[N], h = 1, t = 0;

int main() {
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) scanf("%d", &a[i]);

    for (int i = 1; i <= n; i++) {
        while (h <= t && q[h] < i - m) h++;          // ① 过期:下标 < i-m

        if (i == 1) printf("0\n");                 // ② 取答案(此时队列里是 [i-m, i-1] 的候选)
        else       printf("%d\n", a[q[h]]);

        while (h <= t && a[q[t]] >= a[i]) t--;         // ③ 入队前先淘汰队尾
        q[++t] = i;
    }
    return 0;
}
运行结果
输入:6 2
      7 8 1 4 3 2
输出:0
      7
      7
      1
      1
      3
⚠️易错点:把「先入队」和「先取答案」写反

错误写法:照抄 P1886 的结构,先 q[++t] = i 再输出 a[q[h]]。这样窗口变成了 [i-m+1, i],把 a[i] 自己也包含了进去。本题一旦包含 a[i],当 a[i] 恰好是区间最小值时,答案就会从「前面的最小值」变成 a[i],全盘错。

正确写法:先把 a[i] 入队的动作放到循环最后,输出时队列里只有 [max(1,i-m), i-1] 范围的元素。通用法则:先写下窗口的数学区间,再决定语句顺序。本题区间右端是 i-1,所以「取答案」必须在「a[i] 入队」之前。

🔍 代码逐段解析 · P1440

① ① 过期条件:while (h <= t && q[h] < i - m) h++; —— 注意是 i-m 不是 i-m+1:本题窗口是 [i-m, i-1],左端点与 P1886 差 1。

② 取答案:if (i == 1) printf("0\n") 特判;否则输出 a[q[h]] —— 此时队列里只有 [i-m, i-1] 的候选,a[i] 还没入队。

③ 淘汰 + 入队:while (a[q[t]] >= a[i]) t--; q[++t] = i; 放在输出之后 —— 顺序错了窗口就变成 [i-m+1, i]。

易错点:① 三条语句的顺序(过期 → 取答案 → 入队)与 P1886(过期 → 入队 → 取答案)不同,本质原因是窗口是否包含 i;② i=1 忘特判,输出 a[1] 而不是 0。

案例案例3:切蛋糕(P1714 单调队列优化 DP) 洛谷真题

题目:有 n 块蛋糕排成一行,第 i 块有幸运值 a[i](可能为负)。一次可以吃连续不超过 m 块,求吃到的幸运值之和的最大值。

暴力做法:枚举左右端点 (l, r) 满足 r-l+1 ≤ m,累加求和。时间 O(nm),n = 5×105、m = 5×105 时必然超时。

单调队列优化:推导过程已在知识点⑥ 完整给出,这里重点解决实现时的三个陷阱。

C++P1714 完整代码(deque 版)
#include <bits/stdc++.h>
using namespace std;
const int N = 500005;
long long S[N];

int main() {
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) {
        int x; scanf("%d", &x);
        S[i] = S[i - 1] + x;
    }

    deque<int> q;                       // 存前缀和的下标 k,S[q] 递增
    q.push_back(0);                        // 陷阱1:S[0] 必须先入队
    long long ans = LLONG_MIN;

    for (int i = 1; i <= n; i++) {
        // 陷阱2:k 的取值范围是 [i-m, i-1](不含 i)
        while (!q.empty() && q.front() < i - m) q.pop_front();

        ans = max(ans, S[i] - S[q.front()]);       // 用队首(最小前缀和)转移

        while (!q.empty() && S[q.back()] >= S[i]) q.pop_back();  // 陷阱3:维护 S 递增
        q.push_back(i);
    }
    printf("%lld\n", ans);
    return 0;
}
运行结果
输入:5 3
      2 -3 5 -1 4
输出:8

🔍 三个陷阱的成因

陷阱 1(S[0] 未入队):当最优子段从第 1 块蛋糕开始时,需要 j = 0,即用 S[0] = 0。若不预先 push_back(0),答案只会从「至少跳过一块」的情况里找,漏掉最优解。

陷阱 2(k 的区间写错):子段是 a[j+1..i](用前缀和的下标 j 表示),长度 i-j ≤ m 即 j ≥ i-m;同时必须 j < i(长度至少 1)。所以 j ∈ [i-m, i-1]。这一步必须先在纸上写出来,再翻译成代码:过期条件是 q.front() < i-m,而 i 的入队放在转移之后。

陷阱 3(队列维护谁):转移式里要取 min 的是 S[j],所以队列必须按 S 的值维持递增——不能按 a 的值。这是「照抄滑动窗口模板」最容易犯的错:模板里都比 a[q[t]],这里必须改成 S[q[t]]。

负数的坑:a[i] 可能为负,ans 的初值不能设为 0(否则当最优解为负数时会输出 0)。要用 LLONG_MIN 或 -1e18。

🔍 代码逐段解析 · P1714(deque 版)

① 前缀和:S[i] = S[i-1] + x,S 开 long long——|a| ≤ 10⁹、n ≤ 5×10⁵,前缀和可达 5×10¹⁴。

② q.push_back(0):陷阱 1 的修复——S[0] 代表「第 1 块开始吃」,不预置则所有从头开始的子段都被漏掉。

③ 转移(第 15~16 行):先过期 q.front() < i - m(决策 j ≥ i-m),再 ans = max(ans, S[i] - S[q.front()]) —— 队首是区间内最小 S,减去它就是最大子段和。

④ 淘汰 + 入队(第 18~19 行):S[q.back()] >= S[i] 弹队尾、push i —— 队列按 S 值递增,不是按 a 值(陷阱 3)。

易错点:① 语句顺序「先转移后入队」,因为 j ≤ i-1 不含 i;② ans 初值写成 0,全负数据输出错。

案例案例4:理想的正方形(P2216 二维单调队列) 洛谷真题

题目:有一个 a×b 的整数矩阵,找出一个 n×n 的正方形区域,使得区域内最大值与最小值之差最小。输出这个最小的差值。

核心思想:降维。二维窗口最值无法一次求出,但可以拆成两次一维:

第 1 步逐行做窗口最大值 → a×(b-n+1) 的矩阵 rm
第 2 步逐行做窗口最小值 → 矩阵 rn(同尺寸)
第 3 步对 rm 逐列做窗口最大值 → (a-n+1)×(b-n+1)
第 4 步对 rn 逐列做窗口最小值 → (a-n+1)×(b-n+1)
第 5 步两块矩阵逐格相减,取最小 → 答案

为什么两次一维就够了?一个 n×n 方阵的最大值 = 「先对每行取 n 个数的最大值(得到 n 个行最值),再对这 n 个行最值取最大」。而这两步恰好都是一维滑动窗口——第一次沿行方向、第二次沿列方向。最大值和最小值各做一遍,一共 4 次单调队列,总复杂度 O(ab)。

C++P2216 完整代码(二维单调队列)
#include <bits/stdc++.h>
using namespace std;
const int N = 1005;
int a, b, n;
int g[N][N], rm[N][N], rn[N][N], cm[N][N], cn[N][N];
int q[N], h, t;

// 对长度为 len 的一维数组 v 做窗口大小 n 的最值,结果写入 out(mx=1 求最大)
void slide(int* v, int len, int* out, int mx) {
    h = 1, t = 0;
    for (int i = 1; i <= len; i++) {
        while (h <= t && q[h] < i - n + 1) h++;
        while (h <= t && (mx ? v[q[t]] <= v[i] : v[q[t]] >= v[i])) t--;
        q[++t] = i;
        if (i >= n) out[i - n + 1] = v[q[h]];
    }
}

int main() {
    scanf("%d%d%d", &a, &b, &n);
    for (int i = 1; i <= a; i++)
        for (int j = 1; j <= b; j++) scanf("%d", &g[i][j]);

    // 第 1、2 步:逐行做窗口最值(行方向)
    for (int i = 1; i <= a; i++) {
        slide(g[i], b, rm[i], 1);   // 行最大值
        slide(g[i], b, rn[i], 0);   // 行最小值
    }

    // 第 3、4 步:逐列做窗口最值(列方向),先取列数组再调用 slide
    int col[N], out[N], cols = b - n + 1;
    for (int j = 1; j <= cols; j++) {
        for (int i = 1; i <= a; i++) col[i] = rm[i][j];
        slide(col, a, out, 1);
        for (int i = 1; i <= a - n + 1; i++) cm[i][j] = out[i];

        for (int i = 1; i <= a; i++) col[i] = rn[i][j];
        slide(col, a, out, 0);
        for (int i = 1; i <= a - n + 1; i++) cn[i][j] = out[i];
    }

    // 第 5 步:逐格相减取最小
    int ans = INT_MAX;
    for (int i = 1; i <= a - n + 1; i++)
        for (int j = 1; j <= cols; j++)
            ans = min(ans, cm[i][j] - cn[i][j]);

    printf("%d\n", ans);
    return 0;
}
运行结果
输入:5 4 2
      1 2 5 6
      8 7 6 5
      8 5 5 5
      1 1 1 1
      9 9 9 9
输出:1
(第 2 行第 3~4 列与第 3 行第 3~4 列组成的 2×2 方阵
  元素是 {6,5,5,5},最大值 6、最小值 5,差值 1)
⚠️易错点:列方向的窗口长度搞错

错误写法:列方向也用 slide(col, b, out, 1)——把「行数 a」和「列数 b」搞混。列数组的长度是 a(有多少行就有多少个元素),不是 b。

正确写法:行方向调用 slide(g[i], b, ...)(长度 b,输出 b-n+1 个);列方向调用 slide(col, a, ...)(长度 a,输出 a-n+1 个)。建议把 slide 的第二个参数统一命名为 len,调用时显式写出 b 或 a,避免混淆。另外,本题若 a 或 b 小于 n 则无解,需要特判(题目通常保证 a, b ≥ n)。

🔍 代码逐段解析 · P2216(二维单调队列)

① slide 函数(第 10~17 行):一维滑动窗口的通用封装。参数 mx 用三目运算符切换方向:mx ? v[q[t]] <= v[i] : v[q[t]] >= v[i] —— 求最大时队内递减(弹 ≤)、求最小时队内递增(弹 ≥);输出 out[i-n+1] = v[q[h]],即第 i-n+1 个窗口的最值。h、t 是全局变量,每次调用开头必须重置 h=1, t=0。

② 行方向(第 27~31 行):对每行 i 调 slide(g[i], b, rm[i], 1) 和 slide(g[i], b, rn[i], 0) —— g[i] 本身就是连续一维数组,可直接传。

③ 列方向(第 34~41 行):把 rm 的第 j 列抽进临时数组 col(列不连续必须先抽取),再 slide(col, a, out, 1) —— 长度是行数 a;结果写进 cm[i][j]。rn 同理得 cn。

④ 汇总(第 44~47 行):ans = min(ans, cm[i][j] - cn[i][j]) 逐格取最小差值;ans 初值 INT_MAX。

易错点:① 列方向长度误传 b(上面 err-card 的错误);② slide 里忘了重置 h/t,第二行开始全部错乱;③ 四个二维数组 rm/rn/cm/cn 每个都是 a×b,共约 4×10⁶ int ≈ 16MB,本题 128MB 可承受但已不小。

模块 03

实践应用

三道递进的编程题,把单调队列和单调栈从「看懂」练到「写对」。

A基础模板普及-

给定 n 个数 a[1..n] 和窗口长度 k,对每个窗口输出「最大值 − 最小值」。n ≤ 106。

查看参考答案

思路:开两个单调队列——一个队内递增(队首为最小值)、一个队内递减(队首为最大值)。在同一次 for 循环里对 i 都完成「过期、淘汰、入队」三步,当 i ≥ k 时输出两个队首之差。

#include <bits/stdc++.h>
using namespace std;
const int N = 1000005;
int n, k, a[N];
int q1[N], h1 = 1, t1 = 0;   // 求最小:队内递增
int q2[N], h2 = 1, t2 = 0;   // 求最大:队内递减

int main() {
    scanf("%d%d", &n, &k);
    for (int i = 1; i <= n; i++) scanf("%d", &a[i]);

    for (int i = 1; i <= n; i++) {
        // 队列 1:求最小值
        while (h1 <= t1 && q1[h1] < i - k + 1) h1++;
        while (h1 <= t1 && a[q1[t1]] >= a[i]) t1--;
        q1[++t1] = i;

        // 队列 2:求最大值
        while (h2 <= t2 && q2[h2] < i - k + 1) h2++;
        while (h2 <= t2 && a[q2[t2]] <= a[i]) t2--;
        q2[++t2] = i;

        if (i >= k) printf("%d ", a[q2[h2]] - a[q1[h1]]);
    }
    return 0;
}

验证:输入 8 3 / 1 3 -1 -3 5 3 6 7,各窗口 (max, min) 依次是 (3,−1) (3,−3) (5,−3) (5,−3) (6,3) (7,3),差值输出 4 6 8 8 3 4。

易错:两个队列必须各自维护 h/t,不能共用一个变量;比较符号一个是 >=、一个是 <=,写反了答案会完全颠倒。

逐段解析:① q1/h1/t1 与 q2/h2/t2 完全独立、全局初值即队空;② 队列 1 走「过期 → >= 淘汰 → 入队」维护递增,队列 2 走「过期 → <= 淘汰 → 入队」维护递减——两个 while 块逐行对称;③ i ≥ k 时输出 a[q2[h2]] - a[q1[h1]](最大减最小),两队列窗口定义相同所以过期条件一致。

B单调栈变体普及/提高-

给定 n 个数 a[1..n],对每个 i 输出「i 左边第一个严格大于 a[i] 的元素的下标」;不存在则输出 0。n ≤ 105。

查看参考答案

思路:把「下一个更大元素」的单调栈从左往右改成从右往左不行(方向反了会更麻烦)。更简单的做法是仍然从左往右扫,但栈内保持递减:弹出所有 ≤ a[i] 的栈顶元素后,剩下的栈顶就是「左边第一个严格大于 a[i]」的下标。

#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
int n, a[N], st[N], top = 0;

int main() {
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) scanf("%d", &a[i]);

    for (int i = 1; i <= n; i++) {
        // 把「不可能成为答案」的栈顶弹掉:它们都 ≤ a[i]
        while (top && a[st[top]] <= a[i]) top--;

        printf("%d ", top ? st[top] : 0);   // 剩下的栈顶就是答案

        st[++top] = i;                            // 自己入栈,供后面的人查询
    }
    return 0;
}

验证:输入 5 / 3 4 2 6 5。
i=1:栈空 → 0,入栈得 [1]。
i=2:a[1]=3 ≤ 4 弹出 → 栈空,输出 0,入栈得 [2]。
i=3:a[2]=4 > 2 不弹,输出 2,入栈得 [2,3]。
i=4:弹出 3(a=2≤6)、弹出 2(a=4≤6)→ 栈空,输出 0,入栈得 [4]。
i=5:a[4]=6 > 5 不弹,输出 4,入栈得 [4,5]。
结果:0 0 2 0 4。

易错:① 题目要求严格大于,所以弹栈条件必须是 a[st[top]] <= a[i]——若写成 < 就会保留「相等」的元素,返回一个不严格大于的下标;② 弹栈用的是 while 不是 if,可能连续弹多个;③ 输出必须在弹栈之后、入栈之前。

逐段解析:① st[N], top = 0 存下标、栈空;② 第 8 行 while (top && a[st[top]] <= a[i]) top--; 弹掉所有「不严格大于 a[i]」的候选——它们要么 ≤ a[i] 永远当不了答案,要么已被更近的候选覆盖;③ 第 10 行 printf("%d ", top ? st[top] : 0):栈非空时栈顶就是「左边第一个严格大于 a[i]」的下标,空栈输出 0;④ st[++top] = i 最后入栈,供后面的元素查询。

C综合编程提高+/省选-

环形版本:n 个数 首尾相接成一个环,求长度不超过 m 的连续子段(可跨过首尾)的最大和。n ≤ 3×105。

查看参考答案

思路:环形 → 直线化的标准技巧是把数组复制一份接在后面,得到长度 2n 的序列 b[1..2n](b[i] = a[(i-1) mod n + 1])。然后在 b 上求「长度不超过 m 的最大子段和」,再限制子段长度 不超过 n(因为环形取超过 n 个元素一定会重复取同一块,不会更优)。

#include <bits/stdc++.h>
using namespace std;
const int N = 600005;
int n, m, q[N], h = 1, t = 0;
long long S[N];

int main() {
    scanf("%d%d", &n, &m);
    if (m > n) m = n;                  // 长度上限不可能超过 n
    for (int i = 1; i <= n; i++) {
        int x; scanf("%d", &x);
        S[i] = S[i - 1] + x;
    }
    for (int i = 1; i <= n; i++) {       // 复制一份接到后面 → 长度 2n 的前缀和
        S[n + i] = S[n + i - 1] + (S[i] - S[i - 1]);
    }

    long long ans = LLONG_MIN;
    q[++t] = 0;
    for (int i = 1; i <= 2 * n; i++) {
        while (h <= t && q[h] < i - m) h++;             // 长度 ≤ m
        ans = max(ans, S[i] - S[q[h]]);
        while (h <= t && S[q[t]] >= S[i]) t--;
        q[++t] = i;
    }
    printf("%lld\n", ans);
    return 0;
}

验证:输入 5 2 / 3 -2 -1 4 -5。注意 a[4]=4 与 a[1]=3 在环上并不相邻(中间隔着 a[5]=−5),不能拼成「4, 3」的子段;程序输出 4(最优子段就是单个 a[4]=4)。
手算对照:复制后 b = 3 −2 −1 4 −5 3 −2 −1 4 −5,前缀和 S = 0 3 1 0 4 −1 2 0 −1 3 −2。i=4 时窗口 j ∈ [2, 3],最小前缀和 S[2] = 0,得 S[4] − S[2] = 4;i=6(b[6]=a[1]=3,对应跨过首尾的子段 −5, 3)时窗口 j ∈ [4, 5],S[5] = −1 最小,得 S[6] − S[5] = 3;i=9 时同理再得 4。全程最大值 4 ✓

为避免符号混乱,这里换一组更清晰的验证数据:4 3 / 5 -10 5 5。环形最优是取 b[3]=5、b[4]=5、b[5]=b[1]=5,和 15。程序在 i=5 时窗口 j ∈ [2,4],最小前缀和 S[2] = -5,得 S[5] − S[2] = 10 − (−5) = 15 ✓

易错:① m 要先和 n 取 min,否则窗口长度允许超过一圈,会重复计算;② 前缀和数组要开到 2n+5,ans 初值要用 LLONG_MIN(元素可能全为负);③ 数组复制时别写错下标——S[n+i] = S[n+i-1] + a[i],其中 a[i] = S[i] - S[i-1]。

逐段解析:① if (m > n) m = n 防一圈重复;② 第 11~13 行复制序列:用 S[i] - S[i-1] 反推 a[i] 再接到尾部,等价于 b[n+i] = a[i];③ q[++t] = 0 预置 S[0];④ 主循环跑 i = 1..2n:过期 q[h] < i-m(长度 ≤ m)→ 转移 S[i] - S[q[h]] → 按 S 值淘汰入队——与直线版切蛋糕完全同构,只是序列翻倍、答案不按 i 截断(因为环形里每个起点都会在 2n 序列中被扫到)。

模块 04

总结提升

把零散的知识点收拢成一张判断表,再用三个拓展案例开阔视野。

📌核心要点总结
① 双端栈

一段数组、两个相向的栈顶指针。判满条件是 top1 + 1 == top2。省一半空间,是「一数组两指针」思维的热身。

② 双端队列

两端都能插入删除,同时具备栈和队列的能力,还支持 O(1) 随机访问。STL 里是 <deque>,内部为「分段连续 + 中控数组」,两端插入不搬家,但随机访问常数大于 vector。

③ 单调队列三步

过期弹出(队首下标 < 窗口左边界)→ 队尾淘汰(新元素使队尾永远无用)→ 入队并取值。求最小值队内递增,求最大值队内递减。

④ 为什么是 O(n)

每个元素最多入队 1 次、出队 1 次,总操作 ≤ 2n。复杂度分析的关键不是「每次 while 有多长」,而是「while 总共执行了多少次」——这就是均摊分析。

⑤ 单调队列 vs 单调栈

单调栈是「只能同端进出」的单调队列。栈回答「最近的更大/更小」,队列回答「一段滑动区间内的最值」。队列比栈多一步「过期弹出」。

⑥ 优化 DP 的识别信号

转移形如 dp[i] = min/max{dp[j] + w(i)}、j 取自随 i 单调滑动的区间、w(i) 与 j 无关 → 可用单调队列把 O(nm) 压到 O(n)。

🚀拓展案例

拓展 1 · 单调栈求柱状图中最大的矩形

题目:给出 n 个宽度为 1、高度为 h[i] 的柱子紧挨着排成一行,求其中能框出的最大矩形面积。

核心转化:固定「矩形的高度必须等于某根柱子的高度 h[i]」,那么以 h[i] 为高的最大矩形,其左右边界就是「左边第一个比 h[i] 矮的柱子」和「右边第一个比 h[i] 矮的柱子」之间的全部柱子。设左右边界下标为 L 和 R,则该矩形面积为 h[i] × (R - L - 1)。

做法:用单调栈从左往右扫一遍求出每个 i 的 L[i](栈内保持递增,弹出时是「遇到更矮的」),再从右往左扫一遍求 R[i];最后对每个 i 取面积最大值。总复杂度 O(n)。

C++柱状图最大矩形(单调栈两遍扫描)
#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
int n, h[N], L[N], R[N], st[N];

int main() {
    while (scanf("%d", &n) == 1 && n) {
        for (int i = 1; i <= n; i++) scanf("%d", &h[i]);

        // 左边界:左边第一个严格矮于 h[i] 的下标
        int top = 0;
        for (int i = 1; i <= n; i++) {
            while (top && h[st[top]] >= h[i]) top--;
            L[i] = top ? st[top] : 0;      // 0 表示左边没有更矮的
            st[++top] = i;
        }
        // 右边界:右边第一个严格矮于 h[i] 的下标
        top = 0;
        for (int i = n; i >= 1; i--) {
            while (top && h[st[top]] >= h[i]) top--;
            R[i] = top ? st[top] : n + 1;    // n+1 表示右边没有更矮的
            st[++top] = i;
        }
        long long ans = 0;
        for (int i = 1; i <= n; i++)
            ans = max(ans, (long long)h[i] * (R[i] - L[i] - 1));
        printf("%lld\n", ans);
    }
    return 0;
}
运行结果
输入:7
      2 1 4 5 1 3 3
输出:8
(高度 4 与 5 两根柱子可框出 4×2 = 8 的矩形;
  也可以用三根高度 3 的柱子框出 3×2 = 6,都不如 8)

为什么这个方法对?对任意一个最大矩形,把它向上顶到不能再顶(即上边界贴着某根柱子的顶),就得到一个「以某根柱子的高为高」的矩形,且它的宽度恰好是「左右第一个更矮的柱子之间」的宽度。所以枚举所有柱子的高度就覆盖了最优解。

🔍 代码逐段解析 · 柱状图最大矩形

① 外层 while:while (scanf("%d", &n) == 1 && n) 多组数据、n=0 结束——每组数据内部 top 等指针都要重新初始化。

② 求左边界(第 11~16 行):从左往右扫,while (top && h[st[top]] >= h[i]) top--; 弹掉所有「不比 h[i] 矮」的栈顶——它们不可能是任何右侧柱子的「左边第一个更矮」;L[i] = top ? st[top] : 0,0 作虚拟左哨兵。栈内保持递增。

③ 求右边界(第 17~21 行):完全对称,从右往左扫,R[i] = top ? st[top] : n+1,n+1 作虚拟右哨兵。

④ 统计(第 22~24 行):h[i] * (R[i] - L[i] - 1) —— L、R 本身是「更矮的」柱子要排除,所以宽度减 1;强转 long long 防止 10⁵×10⁵ 溢出。

易错点:① 两遍扫描的比较条件都是 >=(弹掉等高柱子才不会把矩形截断在等高处);② 宽度公式漏减 1,答案整体偏大。

拓展 2 · 单调队列 + 二分答案

题目(思路框架):一条数轴上从左到右有若干格子,第 i 格有一个分数 a[i] 或(负)标记。从原点出发,每次可以跳到 [d-g, d+g] 范围内前方的格子上,求能拿到 ≥ K 分所需的最小 g。

为什么能二分:「g 越大,可跳范围越大,能拿到的最大分数不减」——单调性成立。所以可以二分 g,用 check(g) 判断可行性。

check(g) 怎么做到 O(n):设 dp[i] 为跳到第 i 格时的最大得分,则

dp[i] = a[i] + max{ dp[j] }   其中 j < i、可跳到、且 d-g ≤ i-j ≤ d+g(或 i < d-g 时起点直接可达)

候选区间 j ∈ [i-d-g, i-d+g] 是随 i 单调滑动的定长窗口,且要与 dp 取 max——正是单调队列的形态。于是 check 是 O(n),整体 O(n log n)。

🧩 提炼出的通用套路:当 DP 的候选区间左端点也随答案参数变化时,先用二分固定参数,再在 check 内部用单调队列优化 DP。这类「二分答案 + 单调队列优化 DP」的组合在提高组和省选里非常高频。

拓展 3 · 单调队列优化多重背包

问题:背包容量 V,有 n 种物品,第 i 种体积 w[i]、价值 v[i]、数量 c[i]。求最大价值。朴素做法是枚举每种物品取几个,复杂度 O(V·Σc)。

关键观察:按「模 w 的余数」把容量分组。对固定余数 r,考虑容量 r, r+w, r+2w, … 这些位置,设 p = (V - r) / w,则

dp[r + k·w] = max over j ∈ [max(0, k-c), k-1] { dp[r + j·w] + (k - j)·v }
            = k·v + max over j ∈ [max(0, k-c), k-1] { dp[r + j·w] - j·v }

把 dp[r + j·w] - j·v 看作新数组,则候选区间 j ∈ [k-c, k-1] 是定长滑动窗口,取最大值——又是单调队列。复杂度从 O(V·Σc) 降到 O(V·n)。

💡 拓展 3 的启示:单调队列不是「某类题专用的工具」,而是一种把「区间最值」这件事做快的通用加速器。只要你能在 DP 转移中分离出「与决策无关的项」,并把剩下的部分写成滑动区间的最值,就能套上它。识别这一模式,比记住具体题目重要得多。

🤔思考与启发

思考 1:单调队列在「队尾淘汰」时,如果新元素与队尾元素值相等,该不该把队尾弹掉?如果弹(用 >=)和不弹(用 >),对最终答案有没有影响?对「求最值」和「求最值的位置」两类问题,答案一样吗?

提示:对「求最值」没影响(谁留都行,值相同);但对「求最值第一次出现的位置」有影响——弹掉旧元素会得到更晚的位置,保留则会得到更早的位置。所以要看题目要的是哪一个。

思考 2:为什么单调队列的复杂度是 O(n) 而不是 O(nk)?while 循环明明可能一次弹出很多个元素,为什么总代价不是 k 倍?

提示:这就是均摊分析。每个元素最多被压入队列一次,也就最多被弹出一次;因此「所有 while 循环体”总共执行的次数 ≤ n。把目光从「单次操作」移到「整体操作次数」,是复杂度分析的重要思维转变。

思考 3:如果窗口长度 k 不固定,而是「满足某个条件的最大区间」(例如「区间和 ≤ S 的最长区间」),还能用单调队列吗?如果不能,该用什么?

提示:单调队列要求窗口边界随 i 单调移动。若左边界随 i 单调不减(比如区间和超限就右移左端点),那么仍然是「单调队列 / 双指针」的范围;若左边界会来回摆动,就需要换成别的结构(如线段树、平衡树)。

思考 4:「求窗口最大值」既可以用单调队列(O(n))、也可以用优先队列做懒惰删除(O(n log n))。二者各有什么优劣?什么时候必须用单调队列?

提示:优先队列写法更简单(只需「堆顶过期就弹」),但堆里会堆大量已过期的元素,空间可能到 O(n),并且常数更大。而单调队列空间只要 O(k)、时间严格 O(n),且能在线处理——当 n 达到 107 或内存吃紧时,单调队列是唯一选择。

模块 05

学习检测

五个最常踩的坑 + 一张速查表 + 一张笔记卡,考前扫一遍就够。

⚠️高频错误诊断
①漏写「过期弹出」,队列里混入已滑出窗口的元素

症状:答案从某一位起突然变得「偏小」(求最小值)或「偏大」(求最大值),而且往往是在前几个窗口之后开始出错。

原因:队首元素虽然已经不在窗口里,但只要它很小(求最小值时),它就会一直被当作队首返回。
修复:在每次循环的第一件事就是 while (h <= t && q[h] < i - k + 1) h++;。写完后可以自问一句:「如果窗口左边界往前挪一格,队列里的元素都会被动到吗?」

②把「队内递增」和「队内递减」写反

症状:求最小值的题,输出恰好是最大值序列。

原因:混淆了「求最值时队列该按什么顺序」。
记忆法:单调队列的队首就是答案 → 求最小值,队首最小,队列从队首到队尾递增(队首最小);求最大值,队列递减(队首最大)。淘汰条件里,>= 对应「递增队列」(新元素更小就挤走老的),<= 对应「递减队列」。一句话:不等号方向就是队列方向。

③「先入队」和「先取答案」的顺序搞错

症状:答案整体偏移一位,或者把 a[i] 自己算进了「前 m 个数的最小值」。

原因:没有先写下窗口的数学区间就直接抄模板。
修复流程:① 先写出窗口区间,例如 [i-k+1, i] 或 [i-m, i-1];② 看右端点是否包含 i——包含 i 就「先入队后取值」,不包含 i 就「先取值后入队」;③ 左端点决定过期条件里的常数。这三步写清楚,顺序和常数都不会错。

④队列里存「值」而不是存「下标」

症状:无法判断队首是否过期,或者无法回答「位置在哪」的追问。

原因:过期判断需要下标比较(q[h] < i - k + 1),存值就做不了这件事。
铁律:单调队列的 q 数组、单调栈的 st 数组,一律存下标;需要值时用 a[q[h]] 或 h[st[top]] 取出。这样既保留了「位置信息」,也不损失「值信息」。

⑤DP 优化时「队列里维护的量」与「转移式里的量」不一致

症状:优化后的 DP 结果与暴力对拍不上,但逻辑看起来完全正确。

原因:照抄滑动窗口模板时,习惯性地写了 a[q[t]] >= a[i],但 DP 转移里要取 min 的其实是 dp[j](或 S[j] 这类派生量)。
铁律:转移式里出现谁,单调队列就维护谁。如果转移式是 dp[i] = a[i] + min{dp[j]},那么队列的比较必须是 dp[q[t]],入队的比较对象也要相应改成 dp[i]。写完代码后逐字对照转移式和 while 条件,是避免这类错误最有效的办法。

📋速查表

表 1 · 单调队列模板(求窗口最小值)

// q 存下标,h/t 为队头/队尾指针,初值 h = 1, t = 0(队空)
for (int i = 1; i <= n; i++) {
    while (h <= t && q[h] < i - k + 1) h++;      // ① 过期弹出
    while (h <= t && a[q[t]] >= a[i]) t--;            // ② 队尾淘汰(求最大改 <=)
    q[++t] = i;                                        // ③ 入队
    if (i >= k) printf("%d ", a[q[h]]);            // ④ 取答案(窗口满才输出)
}

表 2 · 单调栈模板(下一个更大元素)

// st 存下标,top 为栈顶指针,初值 top = 0(栈空)
for (int i = 1; i <= n; i++) {
    while (top && a[st[top]] < a[i]) {                 // 栈顶找到了「下一个更大」
        ans[st[top]] = i;      // 或 a[i];求严格更大用 <,非严格用 <=
        top--;
    }
    st[++top] = i;
}
// 循环结束后仍在栈里的下标 → 答案为 -1 / 0

表 3 · 单调队列优化 DP 的检查清单

检查项要问自己的问题
转移形状是否形如 dp[i] = min/max{ dp[j] } + w(i)?w(i) 是否与 j 无关?
候选区间j 的取值范围是不是一段随 i 单调滑动的区间?端点公式写对了吗?
维护对象队列里比较的量是 dp[j],不是 a[j](除非题目恰好如此)?
初始元素是否需要预先 push 一个 0(代表「从开头开始」)?
语句顺序「转移」和「入队」谁先谁后?取决于候选区间是否包含 i 本身。
初值安全ans 是否用了 LLONG_MIN / -1e18 而不是 0(元素可能全为负)?

表 4 · 结构选型对照

需求推荐结构复杂度
固定长度窗口的最值单调队列O(n)
任意区间最值(可重复贡献)ST 表预处理 O(n log n) / 查询 O(1)
任意区间最值 + 支持修改线段树 / 树状数组O(log n)
动态插入 + 取全局最值优先队列(堆)O(log n)
左边/右边第一个更大或更小单调栈O(n)
两端都能增删deque / 手写数组O(1) 均摊
📌笔记卡
单调队列 = 会「主动退休」的候选集合
队首就是答案,队内永远单调
过期看下标,淘汰看数值,两件事都不许省
每个元素进出各一次 → 顺理成章的 O(n)
单调栈是单调队列的「单端版」,回答的是「最近的更大/更小」
DP 里出现滑动区间最值,就是单调队列登场的信号
本讲最小可交付

能手写「三步单调队列模板」并在 30 秒内改出「求最大值」版本;能解释为什么复杂度是 O(n)。

本讲典型陷阱

过期条件差一位、队内方向写反、取值与入队顺序颠倒、队列里存值不存下标。

下讲衔接

下一讲把「取最值」这件事抽象成 优先队列(二叉堆)——当候选集合不再是固定窗口、而是不断动态插入删除时,堆才是正确工具。

课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增。C组后附 12 道洛谷原题。

🟢 A组 · 基础巩固(4题)

A1概念题

双端栈中,为什么判满条件是 top1 + 1 == top2,而不是 top1 == top2?请画图说明。

查看答案

top1 指向左栈栈顶元素所在的位置,top2 指向右栈栈顶元素所在的位置。左栈要写入新元素必须先 ++top1,所以要求 top1 + 1 这个位置是空的;右栈要写入必须先 --top2,要求 top2 - 1 是空的。

因此「还有空位」的条件是 top1 + 1 < top2 - 1 + 1 即 top1 + 1 < top2;反过来「已满」就是 top1 + 1 == top2(此时中间只剩 0 个空位,两栈指针相邻)。

若写成 top1 == top2:意味着两指针指向同一个格子,但按定义那个格子已经被两个栈同时占用了——说明在到达这个状态之前就已经越界写入了。

A2模板默写

不看笔记,默写「求滑动窗口最小值」的单调队列模板(手写数组版),并说明每一步的作用。

查看答案
for (int i = 1; i <= n; i++) {
    while (h <= t && q[h] < i - k + 1) h++;   // ① 过期弹出
    while (h <= t && a[q[t]] >= a[i]) t--;         // ② 队尾淘汰
    q[++t] = i;                                     // ③ 入队
    if (i >= k) printf("%d ", a[q[h]]);          // ④ 取值
}

① 过期弹出:队首下标若已滑出窗口左边界 i-k+1,就永久出队——因为后续窗口只会更靠右,它永远不会再回来。

② 队尾淘汰:新元素 a[i] 更小且更晚过期,队尾元素无论何时都不可能成为最小值,直接踢掉。这个 while 保证「队内值递增」。

③ 入队:把当前下标写入队尾。注意存的是下标。

④ 取值:前 k-1 次窗口还没满,不输出;从 i ≥ k 起,队首就是当前窗口最小值。

常见扣分点:把 ① 和 ② 的顺序写反;把 >= 写成 >(其实也能过,但要理解差别);忘记 ④ 的条件判断;初值 h = 0, t = 0 导致判空失败。

A3推演题

数组 a = [5, 2, 4, 1, 3],窗口长度 k = 3。请手算每个窗口的最大值,并写出每次迭代后单调队列的内容。

查看答案

窗口与答案:[5,2,4] → 5;[2,4,1] → 4;[4,1,3] → 4。最大值序列:5 4 4。

队列演化(存下标,队内按值递减):

i=1,a=5:队空 → 入队。q = [1(5)]。未满不输出。
i=2,a=2:队尾值 5 ≤ 2?否,不淘汰 → 入队。q = [1(5), 2(2)]。未满不输出。
i=3,a=4:队首下标 1 ≥ 1 不过期;队尾值 2 ≤ 4 → 淘汰;队尾值 5 ≤ 4?否,停 → 入队。q = [1(5), 3(4)]。输出 5。
i=4,a=1:队首下标 1 < 2(= 4-3+1)→ 过期弹出!队列变 [3(4)];队尾值 4 ≤ 1?否 → 入队。q = [3(4), 4(1)]。输出 4。
i=5,a=3:队首下标 3 ≥ 3 不过期;队尾值 1 ≤ 3 → 淘汰;队尾值 4 ≤ 3?否 → 入队。q = [3(4), 5(3)]。输出 4。

A4辨析题

单调队列和优先队列(堆)都能取最值,各自的时间复杂度是多少?为什么滑动窗口问题通常不用堆?

查看答案

复杂度对比:单调队列 O(n)(每元素进出各一次);优先队列做「懒惰删除」是 O(n log n)(n 次 push + 最多 n 次 pop)。

为什么不用堆:

① 时间更慢:log n 的常数与层级跳转让堆比单调队列慢一个量级,n = 107 时可能直接超时。
② 空间更差:堆无法主动删除「过期」元素(堆只能弹堆顶),只能等它飘到堆顶再弹。最坏情况下堆里会积压 O(n) 个无用元素,而单调队列只需 O(k)。
③ 需要额外判空:堆顶过期时要在循环里判「堆顶元素的下标是否还在窗口内」,稍不留神就死循环。
结论:「窗口最值」用单调队列;「动态插入 + 任意时刻取全局最值」才用堆(见下一讲)。

🟡 B组 · 提高训练(4题)

B1编程题

读入 n 个数与 m,输出「每个长度恰好为 m 的窗口内最大值 − 最小值」,要求 只用一次遍历(不能开两个 for 循环分别求最值)。

查看答案
for (int i = 1; i <= n; i++) {
    // 最小值队列(队内递增)
    while (h1 <= t1 && q1[h1] < i - m + 1) h1++;
    while (h1 <= t1 && a[q1[t1]] >= a[i]) t1--;
    q1[++t1] = i;

    // 最大值队列(队内递减)
    while (h2 <= t2 && q2[h2] < i - m + 1) h2++;
    while (h2 <= t2 && a[q2[t2]] <= a[i]) t2--;
    q2[++t2] = i;

    if (i >= m) printf("%d ", a[q2[h2]] - a[q1[h1]]);
}

要点:两个队列的 h/t、数组名都不能混用;过期条件里的 i-m+1 对两个队列完全相同(窗口定义一样),只有淘汰条件的不等号不同。

逐段解析:① 前两个 while 块分别服务「最小值队 q1」(>= 淘汰、递增)与「最大值队 q2」(<= 淘汰、递减),各自只碰自己的 h1/t1、h2/t2;② 输出行 a[q2[h2]] - a[q1[h1]] 两个队首相减,恰好是「最大 − 最小」。

扩展思考:如果题目改成「请求出最大差值第一次出现在哪个窗口」,用 >= 淘汰与用 > 淘汰会给出不同的位置——前者保留更晚的元素,得到更晚的位置;后者保留更早的元素,得到更早的位置。要按题意选择。

B2编程题

给定 n 个数,求最长的一段连续区间,使得区间内任意两数的差(最大值 − 最小值)不超过 K。输出最长长度。n ≤ 3×105。

查看答案

思路:双指针 + 双单调队列。维护左右指针 l, r,两个单调队列分别给出 [l, r] 内的最大值与最小值。若 max - min > K,则右移 l(并同步做过期弹出),直到条件满足;每次用 r-l+1 更新答案。

int l = 1, ans = 0;
int h1=1,t1=0, h2=1,t2=0;
for (int r = 1; r <= n; r++) {
    while (h1 <= t1 && a[q1[t1]] >= a[r]) t1--;   q1[++t1] = r;   // 最小值队
    while (h2 <= t2 && a[q2[t2]] <= a[r]) t2--;   q2[++t2] = r;   // 最大值队

    while (a[q2[h2]] - a[q1[h1]] > K) {          // 区间不合法 → 缩左端点
        l++;
        if (q1[h1] < l) h1++;
        if (q2[h2] < l) h2++;
    }
    ans = max(ans, r - l + 1);
}

易错:① 缩左端点时必须同时更新两个队列的队首(用 if 就够,因为 l 每次只 +1,最多让一个队首失效);② while 条件用的是「当前队首」而不是「a[l]」——队列队首不一定就是 l;③ 因为 l 与 r 都单调不减,总复杂度仍是 O(n)。

逐段解析:① 每轮先把 r 压入两个队列(先淘汰后入队,队内分别递增/递减);② while (a[q2[h2]] - a[q1[h1]] > K) 用两个队首实时读取当前区间的 max 与 min,不合法就 l++;③ 缩左端点后用 if (q1[h1] < l) h1++ 同步弹出越界队首——注意是 if 不是 while(l 每次 +1,队首下标至多落后 1 步);④ 循环结束后 r - l + 1 即当前合法最长长度。

B3编程题

用单调栈求出:对每个 i,输出「i 右边第一个严格大于 a[i] 的元素的下标」,不存在输出 0。

查看答案

思路:本题标准做法是从右往左扫,栈内保持递增:弹出所有 ≤ a[i] 的栈顶(它们都比 a[i] 小或相等,不可能成为 a[i] 的答案),剩下的栈顶就是「右边第一个严格大于 a[i]」的下标。

for (int i = n; i >= 1; i--) {
    while (top && a[st[top]] <= a[i]) top--;    // 比 a[i] 小的都不可能是答案
    ans[i] = top ? st[top] : 0;
    st[++top] = i;
}

验证:a = [3, 4, 2, 6, 5]。i=5 → 栈空,ans=0,栈=[5];i=4(a=6)→ 弹出 5(5≤6),栈空,ans=0,栈=[4];i=3(a=2)→ 栈顶 a[4]=6 > 2 不弹,ans=4,栈=[4,3];i=2(a=4)→ 弹 3(2≤4),栈顶 6 > 4 停,ans=4,栈=[4,2];i=1(a=3)→ 弹 2(4≤3?否,不弹)→ ans=2,栈=[4,2,1]。结果:2 4 4 0 0。

与「从左往右」写法的区别:从左往右是「栈顶元素找到了答案,然后被 a[i] 弹出」;从右往左是「a[i] 找到答案后自己入栈」。两种写法都正确,但从右往左更容易理解「我要找右边」这个方向。写的时候务必看清题目要的是「左边」还是「右边」。

逐段解析:① for (i = n; i >= 1; i--) 逆序扫描;② while (top && a[st[top]] <= a[i]) top--; 弹掉所有 ≤ a[i] 的栈顶——栈中下标都比 i 大,其中值 ≤ a[i] 的不可能成为「右边第一个严格大于」;③ ans[i] = top ? st[top] : 0:栈顶就是右边最近的严格更大者,空栈记 0;④ st[++top] = i 自己入栈,供更左边的元素查询。

B4编程题

给定 n 个数,求所有长度恰好为 m 的连续子段中,和的最大值。要求 O(n)。

查看答案

思路:定长子段和 = S[i] - S[i-m],其中 S 是前缀和。因为长度固定,所以不需要单调队列——直接一遍扫描即可:

long long ans = LLONG_MIN;
for (int i = m; i <= n; i++)
    ans = max(ans, S[i] - S[i - m]);
printf("%lld\n", ans);

为什么这里不用单调队列?因为 i-m 是确定的下标,不是「一段区间中取最值」。单调队列解决的是「j 在区间 [i-m, i-1] 中任选,要求 dp[j] 的最值」——前者是定值,后者是区间最值,这是本质区别。
自检方法:看到题先问一句「决策变量是确定的还是可自由选择的?」确定的就直接算,可自由选择且落在滑动区间里才用单调队列。

逐段解析:① ans = LLONG_MIN 防全负数据;② 枚举右端点 i = m..n,每个定长子段和就是 S[i] - S[i-m]——右端点确定时左端点唯一,没有任何「选择」;③ O(n) 一遍扫描,这是「定长窗口不需要单调队列」的示范。

🔴 C组 · 综合挑战(4题)

C1DP 优化

有 n 个格子排成一行,第 i 格有分数 a[i](可正可负)。起点在 0 号格(分数 0),每次向前跳 1 到 k 格,求跳到最后一格(或之后)能获得的最大总分。

查看答案

转移:dp[i] = a[i] + max{ dp[j] : i-k ≤ j ≤ i-1 },其中 dp[0] = 0。

#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
int n, k, a[N], q[N], h = 1, t = 0;
long long dp[N];

int main() {
    scanf("%d%d", &n, &k);
    for (int i = 1; i <= n; i++) scanf("%d", &a[i]);

    dp[0] = 0;
    q[++t] = 0;                              // dp[0] 先入队
    for (int i = 1; i <= n; i++) {
        while (h <= t && q[h] < i - k) h++;      // ① j 必须 ≥ i-k
        dp[i] = dp[q[h]] + a[i];                // ② 用队首(dp 最大)转移
        while (h <= t && dp[q[t]] <= dp[i]) t--; // ③ 维护 dp 递减(队首最大)
        q[++t] = i;
    }
    long long ans = LLONG_MIN;
    for (int i = max(1, n - k); i <= n; i++) ans = max(ans, dp[i]);   // i ≥ n-k:从 i 再跳一步可达 ≥ n
    printf("%lld\n", ans);
    return 0;
}

要点:① 维护的是 dp[j] 而不是 a[j];② q[++t] = 0 不能漏(否则起跳点不在候选里);③ 「先转移、后入队」——因为 j ≤ i-1,不含 i;④ 最后答案要在「能一步跳到终点」的下标集合里取最大:从 i 出发跳至多 k 步能到达 ≥ n,即 i ≥ n-k(注意不是 n-k+1——站在 i = n-k 上恰好可以一步跳到 n;写成 n-k+1 会漏掉这类起跳点,比如 n=5、k=3 时会漏掉 dp[2])。若 dp[q[t]] 比较用 <= 则为递减队列(队首最大),若想求最小值就改成 >=。

逐段解析:① dp[0] = 0 且 q[++t] = 0 —— 起点分数 0 先入队;② 过期 q[h] < i-k:决策 j 必须 ≥ i-k(一步最多跳 k 格);③ 转移 dp[i] = dp[q[h]] + a[i]:队首是区间内 dp 最大值(队列按 dp 递减);④ 淘汰 dp[q[t]] <= dp[i] 后入队 i;⑤ 收尾在第 21~22 行的 [max(1, n-k), n] 中取 max——可以手算验证:n=5、k=3、a = {0, 100, -100, -100, -100} 时,最优路线是 0→2→5,答案 100,只有把 i=2(= n-k)纳入统计才能得到。

C2综合编程

二维版本:给定 a×b 的矩阵和 n,求所有 n×n 方阵中「最大值 − 最小值」的最小值。空间限制较紧,要求只用 O(ab) 的空间。

查看答案

思路:与案例4 完全一致——逐行做窗口最值、再逐列做窗口最值,共 4 次单调队列。空间优化的关键在于滚动处理列:

// 逐列处理时,只需临时存「当前列」这一个一维数组,不必开 4 个 a×b 的矩阵
for (int j = 1; j <= b - n + 1; j++) {
    for (int i = 1; i <= a; i++) tmp[i] = rowmax[i][j];
    slide(tmp, a, out, 1);                       // 列方向求最大
    for (int i = 1; i <= a - n + 1; i++) colmax[i][j] = out[i];
    // ... 最小值同理
}

空间账:原矩阵 a×b 一份;行方向结果 2 个 a×(b-n+1);列方向结果 2 个 (a-n+1)×(b-n+1);加上临时的 tmp/out 各 O(a)。总量仍是 O(ab),与题目要求一致。
若空间再紧:可以只保留行方向的最近 n 行结果,边算边做列方向滑动窗口——把空间降到 O(nb)。这类「滑动的二维窗口」在省选题里出现过。

逐段解析:① 外层按列 j 扫描——每列只需 tmp[i] = rowmax[i][j] 抽出一维;② slide(tmp, a, out, 1) 沿行方向(长度 a)做列向窗口最大值;③ 结果 colmax[i][j] 覆盖所有以 (i, j) 为左上角的 n×n 方阵;④ 最小值用 mx=0 再跑一遍,最后逐格相减取 min。

C3综合编程

给定 n 个柱子的高度,用单调栈求其中能框出的最大矩形面积,要求只扫一遍(不需要左右各扫一遍)。

查看答案

技巧:在两端各加一根高度为 0 的「哨兵柱」,就可以一趟遍历同时完成左右边界的确定——每个元素被弹出时,它的右边界就是当前下标 i,左边界是弹出后新的栈顶(或 0)。

#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
int n, h[N], st[N], top = 0;

int main() {
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) scanf("%d", &h[i]);
    h[0] = h[n + 1] = 0;                // 两端哨兵,保证所有柱子都被弹出

    long long ans = 0;
    for (int i = 0; i <= n + 1; i++) {
        while (top && h[st[top]] > h[i]) {
            int cur = st[top--];             // 被弹出的柱子 = 矩形的高所在位置
            int L = st[top];                 // 左边界:弹出后的新栈顶
            int R = i;                       // 右边界:当前下标
            ans = max(ans, (long long)h[cur] * (R - L - 1));
        }
        st[++top] = i;
    }
    printf("%lld\n", ans);
    return 0;
}

验证:h = [2, 1, 4, 5, 1, 3, 3](加哨兵后下标 0..8)→ 输出 8(高度 4、5 两根柱子,宽 2)。

要点:① 哨兵 h[0] = h[n+1] = 0,保证循环结束时栈内不会残留元素(每根柱子都会被弹出、都算过一次面积);② 弹出时 R - L - 1 是宽度公式——因为 L 与 R 本身都不满足高度要求,要排除;③ 面积用 long long,h 和宽都可能到 105。

C4思维拓展

用单调队列优化多重背包:n 种物品、容量 V、第 i 种体积 w、价值 v、数量 c,求最大价值。说明如何把它化为「滑动窗口取最大值」。

查看答案

第一步:按余数分组。固定余数 r ∈ [0, w),考虑容量 r, r+w, r+2w, … 这些位置,令 k 为「当前容量 = r + k·w」的编号。

第二步:写出转移并分离变量。

dp[r+k·w] = maxj∈[k-c, k-1] { dp[r+j·w] + (k-j)·v }
          = k·v + maxj∈[k-c, k-1] { dp[r+j·w] − j·v }

第三步:识别滑动窗口。把 f[j] = dp[r+j·w] − j·v 看作新数组(同一余数分组内),则 max{f[j] : k-c ≤ j ≤ k-1} 就是「长度不超过 c 的滑动窗口最大值」。

for (int r = 0; r < w && r <= V; r++) {
    h = 1, t = 0;
    int cnt = 0;
    for (int p = r; p <= V; p += w, cnt++) {
        // ① 过期:j < cnt - c
        while (h <= t && q[h] < cnt - c) h++;
        // ② 转移:f[cnt] = dp[p] - cnt * v
        long long f = dp[p] - (long long)cnt * v;
        if (h <= t) dp[p] = max(dp[p], dp[r + q[h] * w] + (long long)(cnt - q[h]) * v);
        // ③ 淘汰 + 入队(按 f 值递减)
        while (h <= t && dp[r + q[t] * w] - (long long)q[t] * v <= f) t--;
        q[++t] = cnt;
    }
}

复杂度:每组余数处理 O(V/w) 个位置,共 w 组,总工作量 O(V)——对每种物品是 O(V),n 种物品合计 O(nV)(对比朴素枚举取几个的 O(V·Σc))。
易错点:① 每个余数分组都必须重置 h = 1, t = 0;② 队列里存的是「编号 cnt」而不是容量;③ 一定要用 long long,因为 cnt·v 可能很大;④ 本题也可用「二进制拆分」做,复杂度 O(V log c),写法更简单——两种方法都值得掌握,考试时优先选有把握的。

逐段解析:① 外层 for (r = 0; r < w && r <= V; r++) 枚举余数分组,每组开头重置队列;② p 沿容量轴以步长 w 前进、cnt 计数;③ 过期条件 q[h] < cnt - c:同组内第 j 个物品最多用 c 次,决策编号差 ≤ c;④ 转移 dp[p] = max(dp[p], dp[r + q[h]*w] + (cnt - q[h]) * v) —— 队首是 dp - j·v 最大(即 f 最大)的决策;⑤ 淘汰条件 dp[r+q[t]*w] - q[t]*v <= f 保持 f 递减(求最大值,队首最大)。

🏆 洛谷原题精选(12道)

以下 12 道题均与「双端队列 / 单调队列 / 单调栈」直接相关,按难度从入门到省选排列,建议按顺序刷。

1
P1886
滑动窗口【模板】单调队列普及/提高-
2
P1440
求m区间内的最小值普及-
3
P2032
扫描(窗口最大值)普及-
4
P2251
质量检测(区间最小值)普及-
5
P1638
逛画展(双指针 + 队列)普及/提高-
6
P1714
切蛋糕(单调队列优化 DP)提高+/省选-
7
P1725
琪露诺(单调队列优化 DP)提高+/省选-
8
P2216
[HAOI2007] 理想的正方形提高+/省选-
9
P2698
[USACO12MAR] 花盆Flowerpot提高+/省选-
10
P2827
[NOIP2016 提高组] 蚯蚓提高+/省选-
11
P2569
[SCOI2010] 股票交易省选/NOI-
12
P3957
[NOIP2017 普及组] 跳房子提高+/省选-