课程 · C++ 速查 · STL 专题 · 第 6 块

queue / stack / deque:一头动还是两头动

学会三个容器的常用动作,认准「谁从哪头进出」,以及 BFS 那五句骨架。

第 6 块(共 12 块已写好)12 条动作3 处陷阱2 道例题2 道练习4 份可运行代码

读这一页之前,先知道读完要能做什么

  • 写出来BFS 的队列五句:起点 push → while (!empty) → front → pop → 邻居 push
  • 写出来栈的三句 push / top / pop,并知道它和 vector 当栈是一回事
  • 说清deque 两头都能动,滑动窗口的「右边进、左边出」就是靠它

这是什么

6 段

一句话:三个都是「只能按规矩进出」的容器,区别只在哪一头能动。

  queue:一头进、另一头出(排队买票)⇒ 用在 BFS

  stack:同一头进出(摞盘子)⇒ 用在括号、单调栈

  deque:两头都能进、两头都能出 ⇒ 用在滑动窗口

★★ 动作的名字要背准:queue 取排头叫 front,stack 取顶叫 top;pop 一律不给值 —— 和堆一样,先看再扔,两句连着写。

⚠ 三个基本都不能按下标取、也不能遍历(deque 是例外,它能 d[i])。要按下标、要遍历、要留一份,就用 vector —— 第 2 块讲过「vector 当栈」,书里 stack<int> 只有几份在用,vector 有一千五百多份。

完整动作表

12 条

复杂度写的是这个动作自己的代价,不是整个程序的。「这一栏最容易错的地方」那一列是重点。

写法干什么代价
queue<int> q;开一个队列(先进先出)
★ 装什么都行:queue<pair<int, int>> 是 BFS 的标配
O(1)
q.push(x)从队尾进一个O(1)
q.front()看排头那个(不弹)
⚠ 空队列上调它会出事 —— 先判 q.empty()
O(1)
q.pop()让排头走人(不给返回值)
★★★ 先 front() 存下来,再 pop() —— 顺序反了拿到的是下一个
O(1)
q.empty() / q.size()空了吗 / 里面几个
⚠ 没有 q.clear()(vector 才有)
O(1)
stack<int> st;开一个栈(后进先出)
★ 取顶叫 top,不叫 front
O(1)
st.push(x) / st.top() / st.pop()放 / 看顶 / 弹
★★ 和 vector 的 push_back / back / pop_back 一一对应
O(1)
deque<int> d;开一个双端队列
★ 它能 d[i] 下标取、也能范围 for 遍历 —— queue 和 stack 都不行
O(1)
d.push_front(x) / d.push_back(x)两头进
★ 队列只有后面那半句
O(1)
d.pop_front() / d.pop_back()两头出
★★ 滑动窗口的两句:新来的 push_back、过期的 pop_front
O(1)
d.front() / d.back()两头的元素O(1)
d[i]按下标取第 i 个
★ 这是 deque 和 queue / stack 最实在的区别
O(1)

⭐ 这一节最容易错的地方(3 处)

这一节排在例题前面是故意的:读到一个坑,再去看正确的例子,记得住。 下面每一条都是「编译得过去、答案可能是错的」那种 —— 它们才是 OI 里真正扣分的地方。

① 取排头是两句:先 `front()` 再 `pop()`

pop() 只负责让排头走人,它不给任何值。 ★ 写反了(先 pop 再 front)拿到的是下一个要处理的点,BFS 的处理顺序整个乱掉;只写 pop 忘了 front,这个点根本没被处理;★ 忘了 pop 最要命 —— 排头永远在,while 变成死循环,跑出来是 TLE,不是报错。
改成
int u = q.front();   // 先拿
q.pop();             // 再让排头走人
★★ 和堆一模一样:看一眼、扔一眼,两句话连着写。

② 一题多组数据,忘了清空 —— 而 `q.clear()` 不存在

上一组的点还留在队列里,下一组跑的时候它们一起被处理 —— 答案错,或者下标越界直接崩。 ★ 而 queue 没有 clear():照着 vector 的习惯写 q.clear(); 会编译不过 —— 这反而是运气好的时候,运气差的是你根本没想到要清。
改成
// 最省事:声明在每一轮的开头,每轮都是新的
while (T--) {
  queue<int> q;      // ← 就这一句,清空的问题没了
  ...
}

// 已经声明在外面了:
while (!q.empty()) q.pop();
★★ 要清空的容器,声明在循环体里 —— 一劳永逸。

③ 滑动窗口:新来的走队**尾**,过期的走队**头**

deque 两头都能动,所以方向写反了不会编译报错,只会算出别的数。 ★ 规矩是「尾巴进、头出」:新元素 push_back,滑出窗口的 pop_front。反过来写,队列里留下的就是最旧的那几个,front() 拿到的也成了最新进来的 —— 程序照跑,最大值是别的数。
改成
while (!d.empty() && d.front() <= i - k) d.pop_front();   // 过期:队头走
d.push_back(i);                                           // 新来的:队尾进
int mx = a[d.front()];                                    // 队头留着的才是最大
★ 记法:队列永远是「尾巴进、头出」 —— deque 只是把「头」那半边也借给你用了一下。

本书哪一章真的用到它

3 处

⚠ 下表里的文件路径和那串 probe 是逐字验过的(check:docs 第 ⑳ 条)—— 改了那份代码这儿会红,正是要的。点章号直接去看那一页。

章书里的哪一份代码用它来干什么
第 14 章code/14-bfs-maze/fast.cpp★★ 第 14 章 BFS —— 队列五句的原产地,第 14 章起每章都是它
第 35 章code/35-monotonic/p5788Stk.cpp★ 单调栈:把手写的 stk 数组换成 std::stack,动作一个不少
第 35 章code/35-monotonic/winWrongMax.cpp★★ 滑动窗口用 deque —— 例题 ② 同款,名字里那个 Wrong 就是「方向反了」的对照

例题(2 道)

每道都给全套:题面 → 样例 → 参考代码 → 一步步的讲解。先自己读题想一遍,再看代码。

例题 ① BFS 的队列五句

q-bfs.cpp

给一个 n × m 的网格(. 能走、# 是墙),从左上角走到右下角,每步只能走上下左右,求最少走几步。

★ 这一题的重点不是算法,是队列那五句 —— 把每次从队头取出的点打印出来,你就能亲眼看到「先进先出」长什么样。

输入格式第一行 n m。之后 n 行,每行一个长度 m 的串。
输出格式访问顺序(每行不超过 50 列)、最少步数、一共进队出队几个点。

输入

4 4
....
.#..
...#
....

输出

访问顺序(每次从队头取出的点):
(0,0) (1,0) (0,1) (2,0) (0,2) (3,0) (2,1) (1,2) (0,3) 
(3,1) (2,2) (1,3) (3,2) (3,3) 
到右下角最少走 6 步
一共进队出队 14 个点,最后队列空了
注意顺序是一层一层摊开的:先 (0,0) 的四个邻居,再下一层 —— 这就是「先进先出」。右下角在第 6 层,所以最少 6 步。
先想一想

★ 五句骨架照抄:起点 push → while (!q.empty()) → front 取出 → pop → 邻居 push。⚠ 还有一件事队列不帮你做 —— 「来过没有」要另记一张表。

q-bfs.cpp参考代码
// STL 专题 · queue —— BFS 的骨架就是这五句
//
// ① 起点 push 进去 ② while (!q.empty()) ③ front 取出排头
// ④ pop 让排头走人 ⑤ 邻居 push 进去
//
// ⚠ ③④ 是**两句**,而且顺序不能反:pop() 只负责让人走,它不给任何值。
// ⚠ 「来过没有」要另记一张表(这里用 dist == -1)—— 队列本身不帮你去重。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
cin >> n >> m;
vector<string> g(n);
for (string &row : g) cin >> row;
vector<vector<int>> dist(n, vector<int>(m, -1)); // -1 = 还没来过
queue<pair<int, int>> q;
dist[0][0] = 0;
q.push({0, 0});
const int dr[4] = {-1, 1, 0, 0};
const int dc[4] = {0, 0, -1, 1};
string order, line;
int seen = 0;
while (!q.empty()) {
auto [r, c] = q.front(); // ★ 先看排头是谁
q.pop(); // ★ 再让他走人
seen++;
line += "(" + to_string(r) + "," + to_string(c) + ") ";
if (line.size() > 50) { order += line + "\n"; line.clear(); } // 每行不超过 50 列
for (int k = 0; k < 4; k++) {
int nr = r + dr[k], nc = c + dc[k];
if (nr < 0 || nr >= n || nc < 0 || nc >= m) continue;
if (g[nr][nc] == '#') continue;
if (dist[nr][nc] != -1) continue; // ★ 来过就不再进队
dist[nr][nc] = dist[r][c] + 1;
q.push({nr, nc});
}
}
if (!line.empty()) order += line;
cout << "访问顺序(每次从队头取出的点):\n" << order;
cout << "\n到右下角最少走 " << dist[n - 1][m - 1] << " 步\n";
cout << "一共进队出队 " << seen << " 个点,最后队列"
<< (q.empty() ? "空了" : "没空") << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
这段代码是怎么想出来的
  1. 记距离:dist[r][c] == -1 表示还没来过,顺手当「访问过没有」用 —— 队列本身不去重,不记的话同一个点会进队好几次。
  2. ★★ 取出是两句:auto [r, c] = q.front(); 然后 q.pop(); —— pop 不给任何值,写反了就拿到下一个点了。
  3. 四邻判定:出界、是墙、来过 —— 三条 continue,全过了才 dist = 上一个 + 1 再 push。
  4. 答案在 push 的时候就写好了:dist 是入队时定的,所以走到右下角时 dist[n-1][m-1] 就是最少步数(BFS 先到的层数最浅)。
记住这一句

★★★ BFS 的骨架永远是那五句,第 14 章到第 30 章换的只是「点是什么」—— 点是坐标、是数字、是一个状态,队列的写法一个字都不变。

用到的写法:queue<int> q; q.push(x) q.front() q.pop() q.empty()

例题 ② 滑动窗口的最值:两头各管一头

d-win.cpp

给 n 个数和窗口长度 k,窗口从左往右每次挪一格,输出每个窗口里的最大值。

★ 这是 deque 存在的理由:新来的从队尾进、过期的从队头走 —— queue 只能动一头,做不了这件事。

输入格式第一行 n k。第二行 n 个整数。
输出格式每个窗口(下标区间、内容)和它的最大值,最后队列里剩几个候选。

输入

8 3
3 1 4 1 5 9 2 6

输出

每个窗口的最大值:
窗口 [0, 2]:3 1 4  ->  最大 4
窗口 [1, 3]:1 4 1  ->  最大 4
窗口 [2, 4]:4 1 5  ->  最大 5
窗口 [3, 5]:1 5 9  ->  最大 9
窗口 [4, 6]:5 9 2  ->  最大 9
窗口 [5, 7]:9 2 6  ->  最大 9

走完之后队列里还剩 2 个候选下标(队头就是全局最大值的下标)
6 个窗口的最大值是 4 4 5 9 9 9。★ 注意队列里存的是下标不是值 —— 「过期了没有」只有下标才答得上来。
先想一想

★ 三个动作的顺序别乱:① 队头过期了就 pop_front;② 队尾比新来的小的,永远轮不到它当最大,pop_back 弹掉;③ 新来的 push_back。

d-win.cpp参考代码
// STL 专题 · deque —— 滑动窗口的最小值 / 最大值
//
// ★ 窗口从左往右挪:**新来的从队尾进,过期的从队头走** —— 两头各管一头,
// 这就是 deque 存在的理由(queue 只能动一头)。
//
// ★ 队列里存的是**下标**,不是值:
// 过期判据 d.front() <= i - k (队头下标滑出窗口了)
// 保单调 比新来的小的队尾元素,后面再也轮不到它当最大 → 弹掉
// 打印 a[d.front()] 就是这个窗口的最大值
//
// ⚠ 方向写反不会编译报错,只会算出别的数 —— 见陷阱 ③。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, k;
cin >> n >> k;
vector<int> a(n);
for (int &x : a) cin >> x;
deque<int> d; // 存下标
cout << "每个窗口的最大值:\n";
for (int i = 0; i < n; i++) {
while (!d.empty() && d.front() <= i - k) d.pop_front(); // ★ 过期的从队头走
while (!d.empty() && a[d.back()] < a[i]) d.pop_back(); // 队尾比它小的,永远轮不到了
d.push_back(i); // ★ 新来的从队尾进
if (i >= k - 1) {
cout << "窗口 [" << (i - k + 1) << ", " << i << "]:";
for (int j = i - k + 1; j <= i; j++) cout << a[j] << (j == i ? "" : " ");
cout << " -> 最大 " << a[d.front()] << "\n";
}
}
cout << "\n走完之后队列里还剩 " << d.size() << " 个候选下标(队头就是全局最大值的下标)\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
这段代码是怎么想出来的
  1. 存下标、不存值:deque<int> d; 里放的是下标 —— 因为要回答「队头那个还在不在窗口里」,只有下标答得上来。
  2. ★★ 过期:while (!d.empty() && d.front() <= i - k) d.pop_front(); —— 队头下标滑出窗口了就走人(这就是「头出」)。
  3. 保单调:队尾那些比 a[i] 小的,只要 a[i] 在窗口里它们就永远轮不上 —— while (!d.empty() && a[d.back()] < a[i]) d.pop_back(); 弹掉。
  4. 进:d.push_back(i);;i >= k - 1 之后,a[d.front()] 就是这个窗口的最大值。
记住这一句

★★ deque = 两头都能动的 queue。「右边进、左边出」的题(滑动窗口最值、单调队列)都是这三句;第 35 章会展开讲。

用到的写法:deque<int> d; d.push_front(x) / d.push_back(x) d.pop_front() / d.pop_back() d.front() / d.back()

练习(2 道)

只给题面、样例和一句提示,答案是折叠着的 —— 先自己写一版。

练习 ① 后缀表达式:栈就是「等着被算」的那一摞

st-postfix.cpp

一行里是空格隔开的后缀表达式:数字直接进栈,遇到 + - * / 就弹出两个算完再压回去。求最后的值。

★ 后缀的好处是不用括号 —— 读的顺序就是算的顺序,而「等着被算的」全在栈里。

输入格式一行,若干个 token(正整数或 + - * /),空格隔开。
输出格式每处理一个 token 后栈里的内容(顶在最左),最后一行结果。

输入

3 4 + 2 * 7 -

输出

3   ->  栈里(顶在最左)3 
4   ->  栈里(顶在最左)4 3 
+   ->  栈里(顶在最左)7 
2   ->  栈里(顶在最左)2 7 
*   ->  栈里(顶在最左)14 
7   ->  栈里(顶在最左)7 14 
-   ->  栈里(顶在最左)7 

最后栈里剩 1 个,结果 = 7
提示

★ 想清楚两件事:① 见到运算符要弹两个,谁是左谁是右 —— 先弹出来的是右操作数;② 算完的结果要压回去。

st-postfix.cpp参考答案
记住这一句

★★ 见到数就 push,见到运算符就弹两个、算完 push。⚠ 减法和除法会立刻暴露「左右写反」—— 加法乘法看不出来,所以别拿加法试。

用到的写法:stack<int> st; st.push(x) / st.top() / st.pop()

练习 ② 约瑟夫环:队列就是「转圈」

q-joseph.cpp

n 个人围成一圈报数,报到 m 的人出列,然后从下一个人重新报数,直到只剩一个。输出出列顺序和剩下的人。

★ 队列把「围圈」这件事变成了两句:不出列的塞回队尾(转一圈),出列的不再塞回去。

输入格式一行两个整数 n m。
输出格式出列顺序(每行不超过 50 列),最后一行剩下的人。

输入

7 3

输出

出列顺序:
3 6 2 7 5 1 
最后剩下的是 4(队里还剩 1 个)
提示

★ 一轮里要做两件事:前 m − 1 个人「拿出来再塞回队尾」,第 m 个人「拿出来就不要了」。⚠ 想清楚塞回队尾这一步忘了会怎样。

q-joseph.cpp参考答案
记住这一句

★★★ 「转圈」就是 q.push(q.front()); q.pop(); 两句话 —— 这两句的顺序也别反(先 pop 的话排头那个就永远拿不到了)。

用到的写法:queue<int> q; q.push(x) q.front() q.pop() q.empty() / q.size()

做完了去哪儿

← 上一块:lower_bound 和 upper_bound:在排好序的数组上找 · 回 STL 专题目录 · 下一块:priority_queue:每次拿走最大的那个 →

⚠ 本页说的是 STL 怎么用。算法本身(Dijkstra、单调栈、状压……)在53 章正课里,而每一章末尾都会告诉你它用到了哪些 STL。