NOI C++ 课程体系 / 数据结构篇 / 第3讲
CSP-J 入门级 · 线性数据结构 · m5-03

队列
先进先出的线性表

从食堂排队到广度优先搜索 —— 掌握队列的定义与基本操作,攻克循环队列的判空判满,熟练运用数组模拟与 STL queue 解决约瑟夫、BFS、排队模拟等经典问题。

6 大模块 2 种实现方式 3 类经典应用 5 大易错点 12+20 道练习
本讲 · 第 43/56 讲队列
模块 01

情景导航

从食堂窗口前的队伍开始,直觉理解"先来后到"的秩序。

故事食堂排队打饭

中午下课铃一响,同学们冲向食堂。先到的同学站在队伍前面,后到的只能排在队尾。

队头 ← 小明 · 小红 · 小刚 · 小李 ← 队尾

队伍有两个固定规矩:

① 新来的人只能从队尾加入(谁都不许插队);

② 打完饭的人只能从队头离开(前面的人不走,后面的人就等着)。

小明第一个打完饭离开后,小红自动成为新的队头;这时小强来了,只能排到小李后面。

先来的先服务,后来的后服务 —— 这就是队列的"先进先出"!🚶‍♂️🚶‍♀️

如果有人能"插队"(比如老师优先),那就不叫普通队列了,而叫优先队列(本讲拓展部分会提到)。

💡 和栈对比一下

如果食堂只有一个死胡同窗口,先进去的人最后才能出来 —— 那是栈(后进先出)。

而队列是一条两头通的通道:一头只进,另一头只出,先来的必定先走。

①生活类比:队列无处不在
🚇

地铁安检排队

先到闸机口的先过,后来的人排在队尾,谁也不能插队。

→ 先进先出 FIFO
🖨️

打印机任务队列

先提交的文档先打印,后提交的排在后面排队等待。

→ 队尾入队 push
🎢

游乐场过山车

从队尾排队进场,从队头检票上车,先排的先玩。

→ 队头出队 pop
📞

客服呼叫中心

先打进电话的先被接通,占线时后来者进入等待队列。

→ 排队与缓冲
②编程定义

队列(Queue)是一种操作受限的线性表:只允许在一端插入(称为队尾 rear / tail),在另一端删除(称为队头 front / head)。

它的核心特性是 先进先出(FIFO,First In First Out):最早进入队列的元素,必定最早离开队列。

📌 本讲核心知识地图

板块核心内容对应大纲
队列概念定义、FIFO 特性、队头队尾、基本操作队列的基本概念
顺序队列数组实现、head/tail 指针、假溢出问题队列的顺序存储
循环队列取模回绕、判空判满、元素个数计算循环队列
队列应用BFS、约瑟夫问题、排队模拟、缓存淘汰队列的应用
③核心口诀
🔑 队列

队尾进(push),队头出(pop);
先进先出 FIFO,中间不许插队;
取头看 front,取尾看 back,个数看 size!

🔑 循环队列

头尾相连成圆环,下标取模要记牢;
入队 tail=(tail+1)%N,出队 head=(head+1)%N;
判空 head==tail,判满 (tail+1)%N==head,
长度 (tail-head+N)%N,容量是 N-1!

模块 02

知识探究

从顺序队列的"假溢出"出发,彻底搞懂循环队列,再掌握竞赛中两种必备写法。

1队列的基本概念 核心
▶ 队列动画演示FIFO · 先进先出

结构约定:用 head(或 front)指向队头元素所在位置,用 tail(或 rear)指向队尾元素的下一个可插入位置。有效元素区间是左闭右开的 [head, tail)。

队头与队尾
  • 队头 head:唯一允许删除的一端
  • 队尾 tail:唯一允许插入的一端
  • 取队头:q[head]
  • 取队尾:q[(tail-1+N)%N]
五种基本操作
  • push(x):从队尾入队
  • pop():队头出队
  • front():读取队头元素
  • empty():判断是否为空
  • size():返回元素个数
时间复杂度
  • 入队:O(1)(直接放到 tail)
  • 出队:O(1)(head 后移)
  • 取队头:O(1)
  • 查找第 i 个:O(n)(不支持随机访问)
线性结构插入端删除端核心特点CSP-J 常见用途
数组(普通线性表)任意位置任意位置随机访问 O(1)存数据、前缀和
栈(Stack)栈顶栈顶后进先出 LIFODFS、括号匹配、表达式求值
队列(Queue)队尾队头先进先出 FIFOBFS、约瑟夫、排队模拟
2顺序队列与"假溢出" 必会

顺序队列:用数组 q[MAXN] 存储,配合 head、tail 两个下标(不是指针,只是数组下标)。

入队时 tail++,出队时 head++。看起来很简单,但它有一个致命缺陷 —— 请看演示:

📊 顺序队列演示:数组 q[6]

📦 出队后前面空出的位置,再也用不上了
初始head=0tail=0
0·
1·
2·
3·
4·
5·
size=0
入队 Ahead=0tail=1
0A
1·
2·
3·
4·
5·
size=1
入队 Bhead=0tail=2
0A
1B
2·
3·
4·
5·
size=2
入队 Chead=0tail=3
0A
1B
2C
3·
4·
5·
size=3
入队 Dhead=0tail=4
0A
1B
2C
3D
4·
5·
size=4
出队 Ahead=1tail=4
0A
1B
2C
3D
4·
5·
size=3
出队 Bhead=2tail=4
0A
1B
2C
3D
4·
5·
size=2
入队 Ehead=2tail=5
0A
1B
2C
3D
4E
5·
size=3
入队 Fhead=2tail=6
0A
1B
2C
3D
4E
5F
size=4 · tail 已到数组末尾
入队 Ghead=2tail=6
0A
1B
2C
3D
4E
5F
size=4 · tail=6 越界,假溢出
问题

什么是"假溢出"?

出队时 head 不断后移,前面空出来的格子永远被"抛弃";而 tail 只增不减,一路顶到数组末尾。此时数组中明明只存了 4 个元素,却再也无法入队 —— 这种"数组没满却说队满"的现象,就叫假溢出。

C++seq_queue.cpp — 有假溢出问题的版本
#include <iostream>
using namespace std;
const int MAXN = 6;
int q[MAXN];
int head = 0, tail = 0;          // 有效区间是 [head, tail) 左闭右开

void push(int x) {
    if (tail == MAXN) { cout << "队满(假溢出)!" << endl; return; }
    q[tail++] = x;               // 入队:放到 tail 位置,tail 后移
}
void pop() {
    if (head == tail) { cout << "队空!" << endl; return; }
    head++;                      // 出队:head 后移(tail 不动!)
}
int main() {
    push(1); push(2); push(3); push(4);
    pop(); pop();                // 1、2 出队,前面空出 2 个位置
    push(5); push(6);            // tail 移到 6
    push(7);                     // 明明只存了 4 个数,却说队满
    return 0;
}
运行结果
队满(假溢出)!

🔍 关键细节分析

判空条件:head == tail。此时区间 [head, tail) 为空,没有元素。

元素个数:tail - head。因为是顺序(非循环)存储,tail 一定 ≥ head,不会为负。

缺陷根源:head 和 tail 都只朝一个方向移动,数组空间是一次性的,用过就作废。

解决思路:把数组"掰弯"成一个环,让下标走到末尾后能绕回开头 —— 这就是循环队列。

3循环队列(Circular Queue)必考

核心思想:把数组看成首尾相连的圆环。下标走到末尾后不是越界,而是取模绕回开头,前面出队空出来的位置可以被重新利用。

🔄 两个关键公式
  • 入队:q[tail]=x; tail=(tail+1)%MAXN;
  • 出队:head=(head+1)%MAXN;
  • %MAXN 就是"走到头就绕回来"的魔法
  • MAXN 必须是编译时常量(const int)
⚠️ 队空与队满的冲突
  • 判空:head == tail
  • 如果也用 head == tail 判满,就区分不开了
  • 本讲采用浪费一个存储单元的方案
  • 判满:(tail+1)%MAXN == head

📊 循环队列演示:数组 q[6],最多只能存 5 个元素

🔄 循环队列逐步演示(head 队头 / tail 下一个可插入位置 / 下标 1 为保留空位)
初始head=0tail=0
0·
1·
2·
3·
4·
5·
size=0
入队 Ahead=0tail=1
0A
1·
2·
3·
4·
5·
size=1
入队 Bhead=0tail=2
0A
1B
2·
3·
4·
5·
size=2
入队 Chead=0tail=3
0A
1B
2C
3·
4·
5·
size=3
出队 Ahead=1tail=3
0·
1B
2C
3·
4·
5·
size=2
出队 Bhead=2tail=3
0·
1·
2C
3·
4·
5·
size=1
入队 Dhead=2tail=4
0·
1·
2C
3D
4·
5·
size=2
入队 Ehead=2tail=5
0·
1·
2C
3D
4E
5·
size=3
入队 Fhead=2tail=0
0·
1·
2C
3D
4E
5F
size=4 · tail=(5+1)%6=0 回绕
入队 Ghead=2tail=1
0G
1·
2C
3D
4E
5F
size=5 · 复用了空位 0
入队 Hhead=2tail=1
0G
1·
2C
3D
4E
5F
size=5 · (1+1)%6=2=head → 队满

💡 为什么要"浪费一个格子"?

如果数组中每个位置都用来存元素,那么"队空"和"队满"都表现为 head == tail,程序无法区分。

约定队尾指针指向的位置永远空着(即 tail 指向"下一个可插入位置"且不存有效元素),就能用 (tail+1)%MAXN == head 唯一地表示"队满"。

代价:容量从 MAXN 变成 MAXN-1。所以开数组时通常写成 const int MAXN = 1005; 来存 1000 个元素,多留一点余量。

4数组模拟 vs STL queue 必会

竞赛中有两种写法:数组模拟循环队列(能看清内部细节、方便调试、适合初赛手推)和 STL queue(代码短、不易出错,实际复赛首选)。两种都必须会。

A写法一:数组模拟循环队列
C++circular_queue.cpp — 循环队列(竞赛推荐写法)
#include <iostream>
using namespace std;
const int MAXN = 1005;
int q[MAXN], head = 0, tail = 0;   // head 指向队头,tail 指向下一个可插入位置

bool empty() { return head == tail; }                    // 判空
bool full()  { return (tail + 1) % MAXN == head; }       // 判满(浪费一个单元)
int  size()  { return (tail - head + MAXN) % MAXN; }     // 元素个数
int  front() { return q[head]; }                         // 取队头

void push(int x) {                                       // 入队
    if (full()) { cout << "Queue Full!\n"; return; }
    q[tail] = x;
    tail = (tail + 1) % MAXN;                            // 取模回绕
}
void pop() {                                             // 出队
    if (empty()) { cout << "Queue Empty!\n"; return; }
    head = (head + 1) % MAXN;                            // 取模回绕
}

int main() {
    push(10); push(20); push(30);
    cout << front() << endl;   // 10
    pop();
    cout << front() << endl;   // 20
    push(40);
    cout << size() << endl;    // 3
    while (!empty()) { cout << front() << " "; pop(); }
    return 0;
}
运行结果
10
20
3
20 30 40
B写法二:STL queue

使用 STL 需要 #include <queue>。常用成员函数如下:

函数作用返回值注意事项
q.push(x)从队尾入队void唯一的插入方式
q.pop()队头出队void不返回元素!要先用 front() 取
q.front()读取队头元素元素引用队空时调用是未定义行为
q.back()读取队尾元素元素引用队空时调用是未定义行为
q.size()元素个数无符号整数与 int 比较建议写 (int)q.size()
q.empty()是否为空bool遍历循环的条件
C++stl_queue.cpp — STL queue 的基本操作
#include <iostream>
#include <queue>
using namespace std;
int main() {
    queue<int> q;
    q.push(10); q.push(20); q.push(30);          // 从队尾入队
    cout << q.front() << " " << q.back() << endl; // 队头 10,队尾 30
    q.pop();                                      // 队头出队(不返回值!)
    cout << q.front() << " " << q.size() << endl; // 20 2
    while (!q.empty()) {                          // 遍历队列的唯一方式:边取边弹
        cout << q.front() << " ";
        q.pop();
    }
    return 0;
}
运行结果
10 30
20 2
20 30
注意

STL 的 queue 没有 clear(),也不能用下标访问

queue 是容器适配器,只暴露队头队尾,不支持 q[i]、q.begin()、范围 for 遍历。想清空只能 while(!q.empty()) q.pop();,或者干脆重新定义一个新队列。

5队列 vs 栈:一张表彻底分清
对比项栈 stack队列 queue
进出规则后进先出 LIFO先进先出 FIFO
插入端栈顶 push()队尾 push()
删除端栈顶 pop()队头 pop()
读取端只有栈顶 top()队头 front() 与队尾 back()
典型应用DFS、递归、括号匹配、表达式求值BFS、约瑟夫、排队模拟、缓冲区
共同点都是操作受限的线性表,插入删除都是 O(1),都不支持随机访问
口诀:栈是死胡同(后进先出),队列是通道(先进先出)!
6队列的三大经典应用 必考
①应用一:广度优先搜索 BFS 的发动机

BFS 要求"按层扩展":先访问距离起点为 1 的所有点,再访问距离为 2 的…… 队列的 FIFO 特性天然保证了先入队的点先被扩展,因此 BFS 必须用队列。

C++bfs_queue.cpp — BFS 为什么必须用队列
#include <iostream>
#include <queue>
#include <vector>
using namespace std;
const int N = 1005;
vector<int> g[N];      // 邻接表存图
bool vis[N];
int dist[N];

// 队列驱动的 BFS 通用框架
void bfs(int s) {
    queue<int> q;
    q.push(s);
    vis[s] = true;
    dist[s] = 0;
    while (!q.empty()) {
        int u = q.front(); q.pop();       // 1. 取队头并出队
        for (int v : g[u]) {              // 2. 扩展 u 的所有邻点
            if (vis[v]) continue;         //    访问过的跳过,保证每个点只入队一次
            vis[v] = true;
            dist[v] = dist[u] + 1;        //    层数 = 上一层 + 1
            q.push(v);                    // 3. 新节点从队尾入队
        }
    }
}

🔍 为什么不能用栈?

用栈会变成"一条路走到黑"的深度优先搜索(DFS),先访问的可能是很远的节点,无法保证第一次到达终点时步数最短。

队列保证的最短路性质:在边权都为 1(或相同)的图中,队列中节点的 dist 值单调不减,第一次弹出终点时即最短距离。

②应用二:约瑟夫问题(CSP-J 经典)

题意:n 个人围成一圈,从 1 号开始报数,报到 m 的人出圈,剩下的人继续从 1 报数,求出圈顺序和最后剩下的人。

队列解法:把 1~n 依次入队。每轮让前 m-1 个人"出队后立即从队尾重新入队"(相当于报了数但没报到 m),第 m 个人出队后不再入队(出圈)。

C++josephus_queue.cpp — 约瑟夫问题
#include <iostream>
#include <queue>
using namespace std;
int main() {
    int n, m;
    cin >> n >> m;
    queue<int> q;
    for (int i = 1; i <= n; i++) q.push(i);   // 1~n 依次入队
    while (q.size() > 1) {                    // 只剩 1 人时结束
        for (int i = 1; i < m; i++) {         // 前 m-1 个人:出队后立刻从队尾重新入队
            q.push(q.front());
            q.pop();
        }
        cout << q.front() << " ";             // 第 m 个人出圈(出队后不再入队)
        q.pop();
    }
    cout << endl << q.front() << endl;        // 换行后输出最后剩下的人
    return 0;
}
输入 10 3
3 6 9 2 7 1 8 5 10
4
③应用三:排队模拟与缓存淘汰(P1540 机器翻译)

题意:内存最多存 m 个单词。查文章时,若单词已在内存则直接查;否则要查词典并把它存入内存,内存满时淘汰最早进入的那个。求查词典的次数。

"淘汰最早进入的"正是队头 —— 一个队列就搞定了。

C++machine_translation.cpp — P1540 机器翻译
#include <iostream>
#include <queue>
using namespace std;
const int MAXW = 1005;
bool inCache[MAXW];                       // inCache[x]:单词 x 是否在内存中
int main() {
    int m, n, x, ans = 0;
    cin >> m >> n;                        // m:内存容量,n:文章单词数
    queue<int> q;                         // 内存中的单词,按进入顺序排队
    for (int i = 0; i < n; i++) {
        cin >> x;
        if (inCache[x]) continue;         // 已在内存,直接查,不查词典
        ans++;                            // 需要查词典
        if ((int)q.size() == m) {         // 内存已满:淘汰最早进入的(队头)
            inCache[q.front()] = false;
            q.pop();
        }
        q.push(x);                        // 新单词从队尾进入
        inCache[x] = true;
    }
    cout << ans << endl;
    return 0;
}
输入 3 7 / 1 2 1 5 4 4 1
5
模块 03

实践应用

通过详细分析题,巩固循环队列的手推能力与队列建模能力。

题1循环队列过程追踪

题目:设循环队列用数组 q[6] 实现,head 指向队头元素,tail 指向队尾元素的下一个位置,以 (tail+1)%6==head 判满。初始为空,依次执行以下操作:

入队 A、B、C;出队两次;入队 D、E、F;入队 G;入队 H。

请写出每一步之后 head、tail 的值、数组中从队头到队尾的元素序列、元素个数,并指出哪一步失败及原因。这个队列最多能存几个元素?

提示:时刻记住 tail 指向"下一个可插入位置",并且它指向的格子不存有效元素。
查看参考答案

逐步追踪表

操作headtail队头 → 队尾个数说明
初始00(空)0head==tail,队空
入队 A01A1q[0]=A
入队 B02A B2q[1]=B
入队 C03A B C3q[2]=C
出队13B C2A 出队
出队23C1B 出队
入队 D24C D2q[3]=D
入队 E25C D E3q[4]=E
入队 F20C D E F4q[5]=F,tail=(5+1)%6=0 回绕
入队 G21C D E F G5判满 (0+1)%6=1 ≠ head=2,可入;q[0]=G
入队 H21C D E F G5判满 (1+1)%6=2 == head=2 → 失败,队满

结论:入队 H 失败。数组大小为 6 的循环队列,最多只能存 5 个元素。

个数验算:用公式 (tail-head+6)%6,最后一步 (1-2+6)%6 = 5,与表中一致。

最终数组内容:q[0]=G,q[1]=(保留空位),q[2]=C,q[3]=D,q[4]=E,q[5]=F。注意元素在数组中并不是连续排列的,中间"绕了一圈"。

💡 解析思路:循环队列一切围绕「下标取模」:入队 rear=(rear+1)%M,出队 front=(front+1)%M。每步操作后都画出环形格子并标出 front/rear 位置。
⚠️ 易错:判断「队满」的三种约定要分清:①牺牲一格((rear+1)%M==front 为满)②count 计数 ③tag 标志位。考试先看题目用哪种约定!
题2约瑟夫问题手推

题目:n=7,m=3。编号 1~7 的人围成一圈,从 1 号开始报数,报到 3 的人出圈,后面的人重新从 1 报起。请依次写出出圈顺序和最后剩下的人的编号,并说明用队列如何模拟这一过程。

查看参考答案

手推过程(括号内为报的数):

① 1(1) 2(2) 3(3) → 3 出圈。剩余:1 2 4 5 6 7

② 4(1) 5(2) 6(3) → 6 出圈。剩余:1 2 4 5 7

③ 7(1) 1(2) 2(3) → 2 出圈。剩余:1 4 5 7

④ 4(1) 5(2) 7(3) → 7 出圈。剩余:1 4 5

⑤ 1(1) 4(2) 5(3) → 5 出圈。剩余:1 4

⑥ 1(1) 4(2) 1(3) → 1 出圈。剩余:4

出圈顺序:3 6 2 7 5 1,最后剩下 4 号。

队列模拟思路:队列初始为 [1,2,3,4,5,6,7](队头是 1)。

每一轮:把队头元素取出再从队尾放入,重复 m-1=2 次(模拟"报了 1、2 但没报到 3"的人绕回队尾);

此时队头就是报到 3 的人,只出队、不再入队,即为出圈。

一直做到队列只剩 1 人,那个人就是幸存者。

复杂度:每轮移动 m-1 次,共 n-1 轮,时间 O(n·m)。当 n、m ≤ 10⁵ 时可用 O(n) 递推公式(见 C3)。

💡 解析思路:用队列模拟「报数—淘汰—重新排队」:报数没到 m 的出队后立刻重新入队(挪到队尾),报到 m 的出队后淘汰不再入队。循环直到队列只剩一人。
⚠️ 易错:报数的起点是「出队后再入队的那个人的下一位」;别忘了最后剩的那个人也可能就是答案本身。
题3综合编程:迷宫最短路(BFS + 队列)

题目:给定一个 n×m 的迷宫(. 表示可通行,# 表示墙),从起点 (sx,sy) 出发,每次只能上下左右移动一格,求到达终点 (ex,ey) 的最少步数;无法到达输出 -1。

提示:用两个队列分别存横坐标和纵坐标,或者用一个 queue<pair<int,int> >。dist 数组同时充当"是否访问过"的标记(-1 表示没走过)。
查看参考答案
C++maze_bfs.cpp — 迷宫最短路
#include <iostream>
#include <queue>
#include <cstring>
using namespace std;
const int N = 35;
int n, m, sx, sy, ex, ey;
char g[N][N];
int dist[N][N];
int dx[4] = {1, -1, 0, 0};
int dy[4] = {0, 0, 1, -1};

int bfs() {
    memset(dist, -1, sizeof(dist));           // -1 表示还没走到
    queue<int> qx, qy;                        // 两个队列分别存横纵坐标
    qx.push(sx); qy.push(sy);
    dist[sx][sy] = 0;
    while (!qx.empty()) {
        int x = qx.front(), y = qy.front();
        qx.pop(); qy.pop();
        if (x == ex && y == ey) return dist[x][y];   // 第一次到达即最短
        for (int k = 0; k < 4; k++) {
            int nx = x + dx[k], ny = y + dy[k];
            if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue;  // 越界
            if (g[nx][ny] == '#') continue;                        // 墙
            if (dist[nx][ny] != -1) continue;                      // 走过
            dist[nx][ny] = dist[x][y] + 1;
            qx.push(nx); qy.push(ny);
        }
    }
    return -1;                                // 无法到达
}
int main() {
    cin >> n >> m;
    for (int i = 0; i < n; i++) cin >> g[i];
    cin >> sx >> sy >> ex >> ey;              // 下标从 0 开始
    cout << bfs() << endl;
    return 0;
}

样例输入

输入
5 5
.....
.###.
.....
.###.
.....
0 0
4 4
输出
8

解析:起点 (0,0),终点 (4,4)。曼哈顿距离为 8,且存在一条长度为 8 的通路(先向下到第 2 行,横穿到最右,再向下),因此答案为 8。

为什么 BFS 能保证最短?队列保证"先入队的先扩展",而第 k 层(距离起点 k 步)的节点一定全部在第 k+1 层之前出队,所以第一次到达终点的步数就是最短步数。

易错提醒:入队时就要标记 dist(或 vis),不能等到出队才标记,否则同一个点会被重复入队,队列爆炸。

模块 04

总结提升

1核心要点总结
知识板块必须记住的核心
队列定义只在一端(队尾)插入、另一端(队头)删除的线性表,先进先出
指针约定head 指向队头元素,tail 指向下一个可插入位置
假溢出顺序队列 head/tail 只增不减,数组没满却无法入队 → 用循环队列解决
循环队列入队 tail=(tail+1)%N,出队 head=(head+1)%N
判空 / 判满head==tail / (tail+1)%N==head(牺牲一个单元)
元素个数(tail-head+N)%N,加 N 再取模防止出现负数
实际容量N-1,所以数组要多开几个位置(如 1005 存 1000)
STL queuepush/pop/front/back/size/empty;没有 clear(),不能下标访问
复杂度入队、出队、取队头均为 O(1);不支持随机访问
三大应用BFS、约瑟夫问题、排队模拟 / 缓存淘汰
2拓展:双端队列、优先队列与单调队列 CSP-J 不要求
说明

以下内容属于 CSP-S(提高级)范围

本讲主体内容严格限定在 CSP-J 入门级大纲内。下面三种结构仅作了解,知道"有这个东西、能解决什么问题"即可,初赛复赛均不作要求。

双端队列 deque
  • 两端都能插入和删除
  • push_front / push_back
  • pop_front / pop_back
  • 支持下标访问 dq[i]
  • 可看成"栈 + 队列"的合体
优先队列 priority_queue
  • 不按进入顺序,按优先级出队
  • 默认大根堆,top() 取最大
  • 小根堆:greater<int>
  • 典型题:P1090 合并果子
  • 插入删除 O(log n)
单调队列
  • 队列内元素保持单调
  • 用于滑动窗口最值
  • 每个元素最多进出各一次
  • 均摊 O(n)
  • 典型题:P1886 滑动窗口
C++extend.cpp — deque 与 priority_queue
#include <iostream>
#include <deque>
#include <queue>
#include <vector>
#include <functional>
using namespace std;
int main() {
    deque<int> dq;                                         // 双端队列:两端都能进出
    dq.push_back(1); dq.push_front(2);
    dq.pop_front(); dq.pop_back();
    cout << dq.size() << endl;                             // 0

priority_queue<int> pq;                                // 优先队列(大根堆),每次取最大
    pq.push(3); pq.push(1); pq.push(5);
    cout << pq.top() << endl;                              // 5(不是 3!)

priority_queue<int, vector<int>, greater<int> > pq2;   // 小根堆
    pq2.push(3); pq2.push(1); pq2.push(5);
    cout << pq2.top() << endl;                             // 1
    return 0;
}
3思考与启发

💡 学完本讲,你应该掌握的6项能力

1. 能说出队列的定义与 FIFO 特性,并区分队头、队尾。

2. 能解释什么是假溢出,以及循环队列如何解决它。

3. 能熟练写出循环队列的入队、出队、判空、判满、求长度五个式子。

4. 能手推循环队列的操作过程,正确算出 head、tail 与元素个数。

5. 能用 STL queue 解决约瑟夫问题与排队模拟问题。

6. 能理解为什么 BFS 必须用队列,并写出 BFS 的完整框架。

模块 05

学习检测

5 个初学者最常犯的错误。

易错1

判空与判满搞混

❌ 错误
if (head == tail) cout << "队满"; — head==tail 只表示队空。
✅ 正确
判空 head == tail;判满 (tail + 1) % MAXN == head。
口诀:相等是空,差一步才是满!
易错2

入队出队忘记取模,下标越界 RE

❌ 错误
q[tail] = x; tail++; — tail 一路加到 MAXN 就数组越界,直接 RE。
✅ 正确
q[tail] = x; tail = (tail + 1) % MAXN; — 走到末尾自动绕回 0。
口诀:循环队列走一步,%MAXN 不能丢!
易错3

元素个数算成负数

❌ 错误
tail - head — 循环后 tail 可能小于 head(如 head=5, tail=2),结果是 -3。
✅ 正确
(tail - head + MAXN) % MAXN — 先加一个 MAXN 再取模,结果一定落在 [0, MAXN-1]。
口诀:先加 MAXN 再取模,负数从此不出现!
易错4

对空队列取队头或执行出队

❌ 错误
循环写成 while (true) { ...q.pop(); },或队列已空还调用 front(),读到垃圾值甚至崩溃。
✅ 正确
出队前先 if (empty()) return;;遍历一律写成 while (!q.empty())。
口诀:取之前先看空,pop 之前先判空!
易错5

把 STL queue 当成数组用

❌ 错误
q[i]、for (auto x : q)、q.clear() — queue 全都不支持,编译直接报错。
✅ 正确
只能 while (!q.empty()) { 用 q.front(); q.pop(); } 边取边弹;清空同样用这个循环。
口诀:queue 只露两头,想遍历就边取边弹!
速查队列速查表
知识点关键结论
队列特性先进先出 FIFO,队尾进、队头出
判空head == tail | q.empty()
判满(tail + 1) % MAXN == head
入队q[tail] = x; tail = (tail + 1) % MAXN | q.push(x)
出队head = (head + 1) % MAXN | q.pop()(无返回值)
取队头q[head] | q.front()
取队尾q[(tail - 1 + MAXN) % MAXN] | q.back()
元素个数(tail - head + MAXN) % MAXN | q.size()
最大容量MAXN - 1(牺牲一个单元区分空与满)
假溢出顺序队列 tail 只增不减 → 改用循环队列
STL 头文件#include <queue>
时间复杂度入队 / 出队 / 取队头 均为 O(1)
📌知识点笔记卡

📥 入队 push

从队尾进入

tail=(tail+1)%N

O(1)

📤 出队 pop

从队头离开

head=(head+1)%N

O(1)

🔄 循环队列

头尾相连成环

下标取模回绕

解决假溢出

⚠️ 判空判满

空:head==tail

满:(tail+1)%N==head

容量 N-1

🔢 队列长度

(tail-head+N)%N

先加 N 防负数

结果 ∈ [0, N-1]

🏆 三大应用

BFS 按层扩展

约瑟夫出圈

排队与缓存淘汰

课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增,点击展开答案与解析。

🟢 A组 · 基础(4题)

A1选择题

队列的特点是()

A. 先进后出   B. 先进先出   C. 只能在一端进行插入和删除   D. 可以随机访问任意元素

查看参考答案

答案:B

队列是先进先出(FIFO)的线性表。A 是栈的特点;C 描述的也是栈(队列在两端操作,一端插入一端删除);D 是普通数组的特点,队列不支持随机访问。

考点:队列在队尾插入、队头删除,是先进先出(FIFO)的线性表。

易错:与栈的 LIFO 混淆,或误以为队列两端都能任意插入删除。

A2选择题

用数组 q[8] 实现循环队列,head 指向队头元素,tail 指向队尾元素的下一个位置,以 (tail+1)%8==head 判满。该队列最多能存放的元素个数是()

A. 6   B. 7   C. 8   D. 9

查看参考答案

答案:B. 7

采用"牺牲一个存储单元"的方案时,队尾指针指向的位置必须保持为空,用来区分队空与队满。因此容量是 MAXN - 1 = 7。

这也是为什么竞赛中常写成 const int MAXN = 1005; —— 实际只需存 1000 个元素,多开 5 个留足余量。

考点:用 (tail+1)%8 == head 判满,说明 tail 指向的位置始终留空用来区分队空与队满,所以真正能存的元素比数组长度少 1。

易错:直接答 8,忽略了那个保留的空位。

A3选择题

下列操作中,普通队列不支持的是()

A. 从队尾插入元素   B. 从队头删除元素   C. 读取队头元素   D. 按下标直接访问第 i 个元素

查看参考答案

答案:D

队列是操作受限的线性表,只允许在队尾插入、队头删除、读取队头/队尾。想拿到第 i 个元素,只能不断出队,时间 O(n),不存在 O(1) 的随机访问。

考点:队列是操作受限的线性表,只能取队头与队尾;想拿第 i 个元素只能连续出队,时间为 O(n)。

易错:以为用 head + i 就能像数组下标那样 O(1) 取到中间元素。

A4编程题

用 STL queue 读入 n 个整数并依次入队,输出队头、队尾和元素个数,然后全部出队输出。

查看参考答案
C++a4.cpp — 入队与出队
#include <iostream>
#include <queue>
using namespace std;
int main() {
    int n; cin >> n;
    queue<int> q;
    for (int i = 0; i < n; i++) { int x; cin >> x; q.push(x); }
    cout << "front=" << q.front() << " back=" << q.back()
         << " size=" << q.size() << endl;
    while (!q.empty()) { cout << q.front() << " "; q.pop(); }
    return 0;
}
输入
5
10 20 30 40 50
输出
front=10 back=50 size=5
10 20 30 40 50

输出顺序与输入顺序完全一致,这就是 FIFO 的直接体现。

思路:STL queue 用 push 入队;front() / back() 分别取队头与队尾,size() 给出元素个数;清空时 while(!q.empty()){ 输出 q.front(); q.pop(); }。

易错:没判空就调用 front() / back(),或把 back() 误当成下一个要出队的元素。

🟡 B组 · 提高(4题)

B1分析题

循环队列用数组 q[6] 实现,当前 head=3,tail=1,数组内容为(下标 0~5):G、_、_、C、D、E(_ 表示无效值)。请回答:队列中从队头到队尾依次是哪些元素?共有几个元素?

查看参考答案

元素个数:(tail - head + 6) % 6 = (1 - 3 + 6) % 6 = 4 → 4 个。

从队头到队尾:head=3 → q[3]=C,q[4]=D,q[5]=E,绕回 q[0]=G,到 tail=1 停止。

答案:C → D → E → G,共 4 个元素。

注意元素在数组中跨越了数组末尾绕回开头,这正是循环队列的精髓。q[1] 是 tail 指向的保留空位,q[2] 是空闲位置。

思路:元素个数 = (tail - head + MAXN) % MAXN;遍历时从 head 出发每步 +1 并对 MAXN 取模,绕回数组开头继续,直到走到 tail 停。

B2编程题

用数组模拟的循环队列实现约瑟夫问题:输入 n、m,输出出圈顺序,最后一行输出幸存者编号。

查看参考答案
C++b2.cpp — 循环队列版约瑟夫
#include <iostream>
using namespace std;
const int MAXN = 1005;
int q[MAXN], head = 0, tail = 0;              // 循环队列
int main() {
    int n, m;
    cin >> n >> m;
    for (int i = 1; i <= n; i++) q[tail++] = i;           // 1~n 依次入队
    while ((tail - head + MAXN) % MAXN > 1) {             // 元素个数 > 1 就继续
        for (int i = 1; i < m; i++) {                     // 前 m-1 个:队头 → 队尾
            q[tail] = q[head];
            tail = (tail + 1) % MAXN;
            head = (head + 1) % MAXN;
        }
        cout << q[head] << " ";                           // 第 m 个出圈
        head = (head + 1) % MAXN;
    }
    cout << endl << q[head] << endl;                      // 最后剩下的人
    return 0;
}
输入 10 3
3 6 9 2 7 1 8 5 10
4

关键点:把"报了数但没出圈"的人用 q[tail]=q[head] 搬到队尾,必须用取模防止越界。

循环条件的写法:用 (tail - head + MAXN) % MAXN > 1 表示"元素个数多于 1",不要写成 tail != head(队空队满都满足,会死循环)。

思路:把 1~n 依次入队;每轮让队头元素出队后立刻重新入队,累计报到第 m 个就出队并输出,如此循环直到队空,最后留下的就是幸存者。

易错:计数基准弄错:向前走 m-1 次(或数到 0 起算)会导致每次报数位置整体偏移一位。

B3分析题

用 (tail+1)%MAXN==head 判满,为什么要浪费一个存储单元?如果不浪费,还有什么办法区分队空和队满?

查看参考答案

原因:如果每个位置都存有效元素,那么"队空"时 head==tail,"队满"时同样是 head==tail,两种状态无法区分,程序不知道该拒绝入队还是允许入队。

解决办法:约定 tail 指向的位置永不存元素,用"tail 的下一个位置就是 head"表示队满,代价是少存 1 个元素。

不浪费空间的两种方案:

① 增设计数器 cnt:入队 cnt++,出队 cnt--。判空 cnt==0,判满 cnt==MAXN,可存满 MAXN 个。

② 增设标记 tag:记录最后一次操作是入队(1)还是出队(0)。只有当 head==tail && tag==1 时才是队满。

C++b3.cpp — 不浪费空间的两种方案
const int MAXN = 1005;
// 方案二:增设计数器 cnt,不必浪费存储单元,可存满 MAXN 个
int q[MAXN], head = 0, tail = 0, cnt = 0;
bool empty() { return cnt == 0; }
bool full()  { return cnt == MAXN; }
int  size()  { return cnt; }
void push(int x) {
    if (full()) return;
    q[tail] = x;
    tail = (tail + 1) % MAXN;
    cnt++;
}
void pop() {
    if (empty()) return;
    head = (head + 1) % MAXN;
    cnt--;
}

// 方案三:增设标记 tag,记录最后一次操作是入队(1)还是出队(0)
// 判空:head == tail && tag == 0
// 判满:head == tail && tag == 1

思路:不浪费空间就得额外记录信息来区分空与满:①增设计数器 cnt,判空 cnt==0、判满 cnt==MAXN;②增设标记 tag,记录最后一次是入队还是出队,据此判断 head==tail 到底表示空还是满。

易错:认为“牺牲一个存储单元”是唯一做法,忽略了计数器或标记位同样能区分两种状态。

B4分析题

依次执行:入队 1、2、3、4;出队两次;入队 5、6。请写出此时从队头到队尾的元素序列、队头元素、队尾元素和元素个数。

查看参考答案
C++完整参考代码
#include <iostream>
#include <queue>
using namespace std;

int main() {
    queue<int> q;

    for (int x : {1, 2, 3, 4}) q.push(x);   // 入队 1 2 3 4
    q.pop();                                 // 出队 1
    q.pop();                                 // 出队 2
    for (int x : {5, 6}) q.push(x);          // 入队 5 6

    cout << "队头元素 = " << q.front() << endl;   // 3
    cout << "队尾元素 = " << q.back()  << endl;   // 6
    cout << "元素个数 = " << q.size()  << endl;   // 4
    cout << "队头到队尾:";
    while (!q.empty()) {                     // 依次从队头弹出
        cout << q.front() << " ";
        q.pop();
    }
    cout << endl;                            // 3 4 5 6
    return 0;
}

答案:队头到队尾是 3 4 5 6;队头元素 = 3,队尾元素 = 6,共 4 个元素。

思路:队列是"先进先出(FIFO)":入队从队尾进、出队从队头走。入队 1 2 3 4 得到 [1,2,3,4];出队两次让 1、2 离开,剩 [3,4];再入队 5、6 就变成 [3,4,5,6],所以队头是 3、队尾是 6、一共 4 个。

考点:队列的 FIFO 特性、队头 / 队尾 / 元素个数。

易错:出队永远发生在队头,入队永远发生在队尾。思考题:如果换成栈做同样的操作,继续出栈的顺序会是 6 5 4 3 —— 恰好和队列完全相反,这就是 LIFO(后进先出)和 FIFO 的本质差别。

🔴 C组 · 挑战(4题)

C1初赛真题

设循环队列的存储空间为 Q(1:50),初始状态为 front = rear = 50。经过一系列入队和出队操作后,front = 20,rear = 10,则此时队列中元素的个数为()

A. 20   B. 30   C. 40   D. 50

查看参考答案

答案:C. 40

下标范围是 1~50,共 50 个存储单元,取模的模数是 50。

元素个数 = (rear - front + 50) % 50 = (10 - 20 + 50) % 50 = 40 % 50 = 40

易错提醒:不能直接算 10 - 20 = -10。只要 rear 已经绕回到 front 前面,就必须先加一个模数再取模。

思路:元素个数 = (rear - front + 50) % 50:下标范围 1~50 共 50 个单元,取模的模数就是 50;rear 已绕到 front 前面时,必须先加模数再取模。

C2初赛真题

下列关于队列的叙述中,正确的是()

A. 队列是先进后出的线性表   B. 队列只能用链表实现,不能用数组实现
C. 队列可用数组实现,且循环队列能解决"假溢出"问题   D. 队列可以在任意位置插入和删除元素

查看参考答案

答案:C

A 错:队列是先进先出,先进后出是栈。

B 错:队列既可以用数组实现(顺序队列 / 循环队列),也可以用链表实现(链队列)。

C 对:循环队列通过取模回绕让下标循环使用,正好解决顺序队列的假溢出。

D 错:队列只能在队尾插入、队头删除,任意位置操作的是普通线性表。

考点:循环队列靠取模回绕让下标循环使用,正好解决顺序队列的假溢出;队列既可以用数组实现(顺序队列/循环队列),也可以用链表实现(链队列)。

易错:记成“队列只能数组实现”,或把假溢出说成是栈的问题。

C3综合编程

约瑟夫问题只需最后幸存者的编号(不要求出圈顺序)。当 n、m 达到 10⁵ 时,O(n·m) 的队列模拟会超时。请用 O(n) 递推公式求解:f(1)=0,f(i) = (f(i-1) + m) % i(编号从 0 开始),答案为 f(n)+1。

查看参考答案
C++c3.cpp — 约瑟夫幸存者 O(n) 递推
#include <iostream>
using namespace std;
int main() {
    int n, m;
    cin >> n >> m;
    int f = 0;                        // f(1) = 0(编号从 0 开始)
    for (int i = 2; i <= n; i++)
        f = (f + m) % i;              // 递推式:f(i) = (f(i-1) + m) % i
    cout << f + 1 << endl;            // 转回 1 号编号
    return 0;
}
输入 10 3
4

推导过程(n=10, m=3):

f(1)=0 → f(2)=1 → f(3)=1 → f(4)=0 → f(5)=3 → f(6)=0 → f(7)=3 → f(8)=6 → f(9)=0 → f(10)=3

答案 = f(10) + 1 = 4,与队列模拟的结果完全一致,但时间从 O(n·m) 降到 O(n)。

思路:每淘汰一个人,就把剩下的人重新编号,把"从下一个人开始报数"转化为"从 0 号开始报数"的子问题,从而得到上述递推式。

易错:递推公式的编号基准弄错:必须按 0-based 递推(f(1)=0),最后再 +1 转回题目的 1-based 编号,否则答案整体偏移一位。

C4综合编程

马的遍历:给定 n×m 棋盘和起点 (sx, sy)(下标从 1 开始),马按"日"字走(8 个方向),求马到达每个格子所需的最少步数,无法到达输出 -1。

查看参考答案
C++c4.cpp — 马的遍历(8 方向 BFS)
#include <iostream>
#include <queue>
#include <cstring>
using namespace std;
const int N = 105;
int n, m, sx, sy;
int dist[N][N];
int dx[8] = {1, 1, 2, 2, -1, -1, -2, -2};     // 马的 8 个方向
int dy[8] = {2, -2, 1, -1, 2, -2, 1, -1};

void bfs() {
    memset(dist, -1, sizeof(dist));
    queue<pair<int, int> > q;                 // 一个队列存坐标对
    q.push(make_pair(sx, sy));
    dist[sx][sy] = 0;
    while (!q.empty()) {
        int x = q.front().first, y = q.front().second;
        q.pop();
        for (int k = 0; k < 8; k++) {
            int nx = x + dx[k], ny = y + dy[k];
            if (nx < 1 || nx > n || ny < 1 || ny > m) continue;
            if (dist[nx][ny] != -1) continue;
            dist[nx][ny] = dist[x][y] + 1;
            q.push(make_pair(nx, ny));
        }
    }
}
int main() {
    cin >> n >> m >> sx >> sy;                // 下标从 1 开始
    bfs();
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) cout << dist[i][j] << " ";
        cout << endl;
    }
    return 0;
}
输入 3 3 1 1
0 3 2
3 -1 1
2 1 4

解析:中心格 (2,2) 在 3×3 棋盘上永远走不到(没有任何"日"字跳法能落到中心),所以输出 -1。

与题3的区别:方向数组从 4 个变成 8 个;用 queue<pair<int,int> > 一个队列存坐标对,写法更通用。

兼容性提醒:老版评测环境不支持 C++17 的结构化绑定,请统一写成 q.front().first / q.front().second,并用 make_pair() 入队。

易错:马的 8 个方向没写全(只覆盖 2 个象限),或忘记把起点的 dist 置 0、不可达的格子保持 -1。

🏆洛谷原题精选(20道)

以下题目均以队列为核心考点,按难度从易到难排列。建议在洛谷官网提交代码评测。

1B3616【模板】队列入门
2P1996约瑟夫问题入门
3B3625迷宫寻路入门
4P1540[NOIP2010] 机器翻译普及-
5P1443[USACO1.5] 马的遍历普及-
6P1746离开中山路普及-
7P1135奇怪的电梯普及-
8P1747好奇怪的游戏普及-
9P1162填涂颜色普及-
10P1506拯救 oibh 总部普及-
11P3958[NOIP2017] 奶酪普及-
12P2058[NOIP2016] 海港普及-
13一本通1332Blah数集普及-
14P2032扫描普及-
15P2952[USACO09OPEN] Cow Line S普及-
16P3399丝绸之路普及-
17P1825[USACO11OPEN] Corn Maze S普及+
18P2895[USACO08FEB] Meteor Shower S普及+
19P1330封锁阳光大学普及+
20P1886【模板】单调队列(提高·拓展)提高
🔑 刷题建议

① 先做入门题(B3616、P1996、B3625),把队列的基本操作和 BFS 框架练熟

② 再做普及- 题(P1540 机器翻译、P1443 马的遍历、P2058 海港),掌握"队列 + 模拟/搜索"的建模方法

③ 最后挑战普及+ 题(P1825 Corn Maze、P2895 流星雨),需要队列 + 状态设计的综合能力

④ 第 20 题(P1886 单调队列)属于提高级内容,CSP-J 阶段可以先跳过