C++ 竞赛入门
deque 双端队列
两端都能高效进出,也支持下标访问;但没有 capacity 与 reserve。
deque 双端队列
读完能做什么
- 能创建
deque,用push_front、push_back在两端加入元素,用pop_front、pop_back在两端删除元素,并说出每一步之后的内容、size()和两端分别是哪个值。 - 能用下标和
at()访问任意位置的元素,并说出deque和vector在“能不能从头部增删”上的区别。 - 能解释
deque和普通排队(queue)的区别:一个是能随便操作两端的容器,一个只允许“一头进、一头出”。 - 能说出
deque没有capacity()/reserve(),以及空容器不能取端点、不能弹出端点。
为什么需要它
vector 很好用,但只能高效地在尾部增删。如果任务要求“在头部也能加数据”,用 vector 就得写 v.insert(v.begin(), x):
vector<int> v = {20, 30};
v.insert(v.begin(), 10); // 片段:头部插入要把所有元素整体右移一格
这是一段片段(省略了头文件与 main)。它没有语法错误,但每次在头部插入,已有的元素全部要往右挪一格,元素越多越慢,是 O(n)。而“头部插入”在竞赛里很常见:滑动窗口、双端队列优化的动态规划、两边都能进出的模拟题……
我们需要的是一种两端都能快速增删、同时还能像数组一样按下标访问的容器,这就是 deque(读作 “deck”,是 double-ended queue 的缩写)。
它是什么
一句话:deque 是标准库提供的双端队列——在头部和尾部都能高效地加入、删除元素,同时支持像数组一样按下标随机访问。
直观模型是一段可以向左、向右延伸的序列,两端都是“开口”的:
graph LR
L["头部 front:能加入、能删除"] --- M["中间:按下标访问,插删较慢"] --- R["尾部 back:能加入、能删除"]
以 [20, 30] 为例,下标仍然从 0 开始,front() 是头部、back() 是尾部:
| 位置 | 头部(下标 0) | 尾部(下标 1) |
|---|---|---|
| 元素 | 20 | 30 |
| 能在这里做的操作 | push_front() 加入、pop_front() 删除、front() 查看 |
push_back() 加入、pop_back() 删除、back() 查看 |
deque 和 vector 的区别。 两者都能按下标随机访问,主要差别在头部:deque 的头部增删是高效的,vector 的头部增删要把所有元素整体搬动。反过来说,vector 是一整块连续内存,deque 为了做到两端都能扩展,内部把数据分成若干段管理——这是实现细节,不影响使用,本页只需要记住“两端都快,中间插删慢”。
deque 和 queue 的区别。 名字里都有 “queue”,但完全是两种东西:
| 对比项 | deque |
queue |
|---|---|---|
| 是什么 | 容器:数据存放在里面 | 容器适配器:把某个容器的接口包装成队列 |
| 两端操作 | 头部、尾部都能加入和删除 | 只能从尾部加入、从头部删除 |
| 按下标访问 | 可以 d[i]、d.at(i) |
不可以 |
| 遍历 | 可以(下标或迭代器) | 不可以(没有迭代器) |
queue 只允许“一头进、一头出”,正是为了强制“先来先处理”的规则,细节见 queue 队列。deque 不做这种限制,所以它更自由,也就要自己保证不乱套。
随机访问容器,但没有 capacity() / reserve()。 deque 的迭代器支持 it + n 和相减(这叫随机访问迭代器),所以能 d[2]、也能 d.begin() + 2。但 vector 上的 capacity()、reserve() 在 deque 上不存在:deque 的内存是一段一段申请的,没有“当前这一块能装几个”这个概念。写 d.reserve(10) 会直接编译失败。
模板尖括号。 deque<int> 里的 <int> 表示容器里放什么类型,deque<string> 就是存字符串的双端队列。这是 C++ 的模板语法,只需照着填类型。
适用场景:需要在两端频繁增删、同时又要按下标随机访问的数据。使用时要 #include <deque>,本页示例用 #include <bits/stdc++.h> 一次包含全部标准头文件(这两句的解释在什么是 STL)。
最小可运行示例
数据写在代码里,不需要输入:
#include <bits/stdc++.h>
using namespace std;
int main() {
deque<int> d = {20, 30}; // 两端都能操作的序列
cout << "size = " << d.size() << '\n';
cout << "d[0] = " << d[0] << ", d[1] = " << d[1] << '\n';
cout << "front = " << d.front() << ", back = " << d.back() << '\n';
d[0] = 25; // 支持像数组一样用下标改写
cout << "改 d[0] 后:";
for (int i = 0; i < (int)d.size(); i++) cout << d[i] << ' ';
cout << '\n';
return 0;
}
输出:
size = 2
d[0] = 20, d[1] = 30
front = 20, back = 30
改 d[0] 后:25 30
逐行看:
deque<int> d = {20, 30};创建一个deque<int>,按顺序放入 2 个元素,下标 0 和 1。d.size()是元素个数 2。循环里写(int)d.size()是为了和int i比较时不出现“有符号与无符号比较”的警告。d[0]是头部元素、d[1]是尾部元素;front()读头部,back()读尾部。只有两个元素时,d[0]和front()是同一个,d[1]和back()是同一个。d[0] = 25用下标直接改写头部元素,长度仍是 2。
一步步看变化
沿 [20, 30] 这组数据走完“两端加入 → 两端删除”的完整路线:
| 步骤 | 操作 | 操作前 | 操作后 | size() |
front() |
back() |
|---|---|---|---|---|---|---|
| 0 | 初始 | — | [20,30] |
2 | 20 | 30 |
| 1 | d.push_front(10) |
[20,30] |
[10,20,30] |
3 | 10 | 30 |
| 2 | d.push_back(40) |
[10,20,30] |
[10,20,30,40] |
4 | 10 | 40 |
| 3 | d.pop_front() |
[10,20,30,40] |
[20,30,40] |
3 | 20 | 40 |
| 4 | d.pop_back() |
[20,30,40] |
[20,30] |
2 | 20 | 30 |
每一步的“操作后”都对应输出里的一行:
#include <bits/stdc++.h>
using namespace std;
void printDeque(const string& tag, const deque<int>& d) {
cout << tag << ":[";
for (int i = 0; i < (int)d.size(); i++) {
if (i > 0) cout << ",";
cout << d[i];
}
cout << "] size = " << d.size()
<< ", front = " << d.front() << ", back = " << d.back() << '\n';
}
int main() {
deque<int> d = {20, 30};
printDeque("初始", d);
d.push_front(10);
printDeque("push_front(10) 后", d);
d.push_back(40);
printDeque("push_back(40) 后", d);
d.pop_front();
printDeque("pop_front() 后", d);
d.pop_back();
printDeque("pop_back() 后", d);
return 0;
}
输出:
初始:[20,30] size = 2, front = 20, back = 30
push_front(10) 后:[10,20,30] size = 3, front = 10, back = 30
push_back(40) 后:[10,20,30,40] size = 4, front = 10, back = 40
pop_front() 后:[20,30,40] size = 3, front = 20, back = 40
pop_back() 后:[20,30] size = 2, front = 20, back = 30
几点说明:
printDeque的参数是const deque<int>&,用引用避免复制整个容器,const表示函数里不会改动它。这是“只想看一眼内容”的标准写法(引用的解释见 vector 动态数组)。- 第 1 步的 10 出现在头部:逻辑上下标整体往后一位——原来的 20 变成
d[1]、30 变成d[2];元素本身没有被搬动,这正是deque头部插入高效(摊还 O(1))的原因。 - 第 2 步的 40 出现在尾部,前面的元素下标不变,新元素的下标是
size() - 1。 pop_front()删掉头部、pop_back()删掉尾部,两者都不返回被删掉的值。想用这个值就先front()/back()取出来再删。- 两步删除之后回到
[20,30],和初始状态一样——push_front(10)加入的 10 和push_back(40)加入的 40 都被删掉了。
每一步的头部和尾部各是哪个值,可以只看这张表对照:
| 时刻 | 下标 0 | 下标 1 | 下标 2 | 下标 3 | front() |
back() |
|---|---|---|---|---|---|---|
| 初始 | 20 | 30 | — | — | 20 | 30 |
push_front(10) |
10 | 20 | 30 | — | 10 | 30 |
push_back(40) |
10 | 20 | 30 | 40 | 10 | 40 |
pop_front() |
20 | 30 | 40 | — | 20 | 40 |
pop_back() |
20 | 30 | — | — | 20 | 30 |
逐项扩展核心操作
两端加入:push_front 与 push_back
#include <bits/stdc++.h>
using namespace std;
void printDeque(const deque<int>& d) {
cout << "[";
for (int i = 0; i < (int)d.size(); i++) {
if (i > 0) cout << ",";
cout << d[i];
}
cout << "] size = " << d.size() << '\n';
}
int main() {
deque<int> d = {20, 30};
cout << "操作前:";
printDeque(d);
d.push_front(10); // 从头部加入 10
cout << "操作后:";
printDeque(d);
cout << "front = " << d.front() << ", back = " << d.back() << '\n';
return 0;
}
输出:
操作前:[20,30] size = 2
操作后:[10,20,30] size = 3
front = 10, back = 30
push_front(10) 把 10 放到头部,长度加 1,新的 front() 变成 10,back() 仍是 30。这正是 vector 做不到的高效操作。
push_back 放在尾部,和 vector 一样:
#include <bits/stdc++.h>
using namespace std;
void printDeque(const deque<int>& d) {
cout << "[";
for (int i = 0; i < (int)d.size(); i++) {
if (i > 0) cout << ",";
cout << d[i];
}
cout << "] size = " << d.size() << '\n';
}
int main() {
deque<int> d = {10, 20, 30};
cout << "操作前:";
printDeque(d);
d.push_back(40); // 从尾部加入 40
cout << "操作后:";
printDeque(d);
cout << "front = " << d.front() << ", back = " << d.back() << '\n';
return 0;
}
输出:
操作前:[10,20,30] size = 3
操作后:[10,20,30,40] size = 4
front = 10, back = 40
| 操作 | 加到哪一端 | 状态变化 | 谁变了 |
|---|---|---|---|
d.push_front(10) |
头部 | [20,30] → [10,20,30] |
front() 变成 10,back() 不变 |
d.push_back(40) |
尾部 | [10,20,30] → [10,20,30,40] |
back() 变成 40,front() 不变 |
两端删除:pop_front 与 pop_back
#include <bits/stdc++.h>
using namespace std;
void printDeque(const deque<int>& d) {
cout << "[";
for (int i = 0; i < (int)d.size(); i++) {
if (i > 0) cout << ",";
cout << d[i];
}
cout << "] size = " << d.size() << '\n';
}
int main() {
deque<int> d = {10, 20, 30, 40};
cout << "操作前:";
printDeque(d);
d.pop_front(); // 删除头部元素 10,不返回它的值
cout << "操作后:";
printDeque(d);
cout << "front = " << d.front() << ", back = " << d.back() << '\n';
return 0;
}
输出:
操作前:[10,20,30,40] size = 4
操作后:[20,30,40] size = 3
front = 20, back = 40
pop_back() 对称地删掉尾部元素:
#include <bits/stdc++.h>
using namespace std;
void printDeque(const deque<int>& d) {
cout << "[";
for (int i = 0; i < (int)d.size(); i++) {
if (i > 0) cout << ",";
cout << d[i];
}
cout << "] size = " << d.size() << '\n';
}
int main() {
deque<int> d = {20, 30, 40};
cout << "操作前:";
printDeque(d);
d.pop_back(); // 删除尾部元素 40
cout << "操作后:";
printDeque(d);
cout << "front = " << d.front() << ", back = " << d.back() << '\n';
return 0;
}
输出:
操作前:[20,30,40] size = 3
操作后:[20,30] size = 2
front = 20, back = 30
| 操作 | 删掉哪一个 | 状态变化 | 输出里的证据 |
|---|---|---|---|
d.pop_front() |
头部元素 10 | [10,20,30,40] → [20,30,40] |
front() 从 10 变成 20,长度 4 → 3 |
d.pop_back() |
尾部元素 40 | [20,30,40] → [20,30] |
back() 从 40 变成 30,长度 3 → 2 |
pop_front() 和 pop_back() 都是 void,没有返回值:它们只负责删除,不告诉你删掉了谁。想用被删的值,先取再删:
int x = d.front(); // 先取出来
d.pop_front(); // 再删掉
这是片段,省略了头文件与 main。写成 int x = d.pop_front(); 会编译失败,因为 void 不能赋值给 int。
访问与遍历:下标、at、迭代器
#include <bits/stdc++.h>
using namespace std;
int main() {
deque<int> d;
d.push_back(20);
d.push_back(30);
d.push_front(10);
d.push_back(40);
cout << "遍历(从头部到尾部):";
for (int i = 0; i < (int)d.size(); i++) cout << d[i] << ' ';
cout << '\n';
cout << "front = " << d.front() << ", back = " << d.back() << '\n';
cout << "d.at(2) = " << d.at(2) << '\n'; // 带越界检查的访问
deque<int>::iterator it = d.begin(); // 迭代器也能用
it = it + 2; // deque 支持随机访问:it + n
cout << "*(begin() + 2) = " << *it << '\n';
return 0;
}
输出:
遍历(从头部到尾部):10 20 30 40
front = 10, back = 40
d.at(2) = 30
*(begin() + 2) = 30
d[i]和d.at(i)都能按下标读,区别是at会检查越界并在越界时抛异常,[]不检查。竞赛代码一般用[],越界责任在自己。for (int x : d)这种范围 for 也能用,效果和下标遍历一样。d.begin()是指向头部的迭代器(表示位置的游标),d.begin() + 2指向下标 2;*it取出它指向的元素。deque支持it + n和两个迭代器相减,这叫随机访问迭代器,和vector、string一样。迭代器的系统讲解见容器、迭代器与区间。
清空与重新使用
#include <bits/stdc++.h>
using namespace std;
int main() {
deque<int> d;
cout << "空容器:size = " << d.size() << ", empty = " << d.empty() << '\n';
d.push_back(1);
d.push_front(2);
d.push_back(3);
cout << "push_back(1)、push_front(2)、push_back(3) 后:";
for (int x : d) cout << x << ' ';
cout << '\n';
d.clear(); // 删掉全部元素
cout << "clear 后:size = " << d.size() << ", empty = " << d.empty() << '\n';
d.push_front(7); // 清空后还能继续用
cout << "再 push_front(7) 后:front = " << d.front() << ", size = " << d.size() << '\n';
return 0;
}
输出:
空容器:size = 0, empty = 1
push_back(1)、push_front(2)、push_back(3) 后:2 1 3
clear 后:size = 0, empty = 1
再 push_front(7) 后:front = 7, size = 1
- 空
deque的size()是 0,empty()返回true,打印成 1。 - 三次加入的顺序是“尾、头、尾”,所以最终顺序是
2 1 3:先push_back(1)得到[1],再push_front(2)得到[2,1],最后push_back(3)得到[2,1,3]。加入的先后不等于排列的先后,位置由你选的是哪一端决定。 clear()一次删掉所有元素,size()变 0。清空后的容器可以继续使用,不需要重新创建。
空容器不能取端点
// 错误示例:空 deque 既不能取端点,也不能弹出端点
#include <bits/stdc++.h>
using namespace std;
int main() {
deque<int> d; // d 是空的
cout << d.front() << '\n'; // 空容器没有首元素,结果不确定
cout << d.back() << '\n'; // 空容器没有尾元素,结果不确定
d.pop_front(); // 空容器没有元素可删,结果不确定
return 0;
}
这段不能作为程序使用。空容器上调用 front()、back()、pop_front()、pop_back() 都属于越界访问,标准没有规定结果:可能读到垃圾值,也可能直接崩溃(本机实测这份代码以退出码 134 中止)。所以取端点之前先判断非空,固定写法是 while (!d.empty()),或者 if (!d.empty()) { ... }。空容器上可以安全调用的是 size()、empty()、push_front()、push_back()、clear()。
什么时候选它、哪里容易错
空容器不能直接访问或弹出端点。 front()、back()、pop_front()、pop_back() 都要求容器非空。竞赛里最常见的崩溃就是“忘了判空”。判空用 d.empty() 或 d.size() == 0。
两端高效不代表所有位置都同样高效。 在头部、尾部增删是常数时间;在中间 insert、erase 仍然要搬动元素,代价与元素个数成正比(而且要传迭代器,不是下标)。如果任务只是“两端进出”,deque 很合适;如果还要在中间频繁插删,应该考虑 list 链表。
没有 capacity() 和 reserve()。 deque 的内存是分段管理的,没有“当前这一块能放几个”的概念。写 d.capacity() 或 d.reserve(10) 都会编译失败。想预先占好空间是 vector 才有的做法。
按下标访问不检查越界。 d[i] 和 vector 一样不做检查,d[d.size()] 是越界访问,结果不确定。要检查就用 d.at(i),越界会抛异常。
pop_front() / pop_back() 没有返回值。 它们是 void,取值和删除要分两步。
用 deque 模拟排队时,规则得自己守。 deque 允许从两端任意操作,queue 只允许一头进一头出。如果任务是“先来先处理”,用 queue 队列 更不容易写错;deque 适合“两端都要用”的场景。
复杂度。 头部和尾部的 push_front、push_back、pop_front、pop_back 是摊还常数时间(“摊还”指偶尔一次重新分配比较贵,把开销平摊到很多次操作上,平均仍是常数);按下标访问 d[i] 是常数时间;中间 insert、erase 是 O(n)。
选读:为什么 queue 默认用 deque 当底座。 容器适配器 queue 的默认底层容器就是 deque,因为它需要“一头进、一头出”,正好用上 deque 两端高效的特点。这里只当作背景了解,不影响使用。
选读:搜索类算法的应用。 如果已经学过图的广度优先搜索,会发现“每次从头部取出一个点、把新发现的点加到尾部”正好是 deque 的两端操作(实际写代码时通常直接用 queue 队列)。本课目前没有图遍历的专页,图论知识不是理解本页的前提;想先看看“探索 + 标记 + 撤销”的写法,可以读 递归的应用:枚举,其余内容等学到时再补。
操作速查表
| 操作 | 作用 | 备注 |
|---|---|---|
deque<int> d; |
创建空的双端队列 | 尖括号里填元素类型 |
deque<int> d = {20,30}; |
用一组初值创建 | 下标从 0 开始 |
d.size() |
元素个数 | 类型是无符号整数 |
d.empty() |
是否为空 | 打印成 0/1 |
d[i] |
下标访问 | 不检查越界 |
d.at(i) |
下标访问 | 越界会抛异常 |
d.front() / d.back() |
头部 / 尾部元素 | 空容器不能调用 |
d.push_front(x) |
在头部加入 x |
摊还 O(1),vector 没有这个操作 |
d.push_back(x) |
在尾部加入 x |
摊还 O(1) |
d.pop_front() |
删除头部元素 | 无返回值,空容器不能调用 |
d.pop_back() |
删除尾部元素 | 无返回值,空容器不能调用 |
d.insert(it, x) |
在迭代器 it 前插入 x |
中间插删 O(n) |
d.erase(it) |
删除迭代器 it 位置的元素 |
收的是迭代器,不是下标;中间插删 O(n) |
d.clear() |
删除全部元素 | size() 变 0,容器还能继续用 |
d.begin() / d.end() |
头部 / 尾后位置的迭代器 | 支持 it + n 和相减 |
d.capacity() / d.reserve(n) |
不存在 | deque 没有这两个操作 |
动手检查
第 1 题:预测输出
下面是一段片段(省略了头文件和 main),请先手推出输出,再运行核对:
deque<int> d = {1, 2};
d.push_front(0);
d.push_back(3);
d.pop_front();
d.push_front(9);
cout << d.front() << ' ' << d.back() << ' ' << d.size() << '\n';
第 2 题:修改代码
下面这段片段模拟“两端都能进出的通道”:请把 5 加入头部,让输出变成 5 1 2 3,长度 4:
deque<int> d = {1, 2, 3};
// 在这里补一行:把 5 加到头部
for (int i = 0; i < (int)d.size(); i++) cout << d[i] << ' ';
验证条件:先说出补完之后 front()、back()、size() 分别是多少,再运行核对。
参考结果与解释
第 1 题:输出是 9 3 4。逐步推:
| 时刻 | 内容 | front() |
back() |
size() |
|---|---|---|---|---|
| 初始 | [1,2] |
1 | 2 | 2 |
push_front(0) |
[0,1,2] |
0 | 2 | 3 |
push_back(3) |
[0,1,2,3] |
0 | 3 | 4 |
pop_front() |
[1,2,3] |
1 | 3 | 3 |
push_front(9) |
[9,1,2,3] |
9 | 3 | 4 |
所以 front() 是 9,back() 是 3,size() 是 4。完整程序与实测输出:
#include <bits/stdc++.h>
using namespace std;
int main() {
deque<int> d = {1, 2};
d.push_front(0);
d.push_back(3);
d.pop_front();
d.push_front(9);
cout << d.front() << ' ' << d.back() << ' ' << d.size() << '\n';
return 0;
}
输出:
9 3 4
push_front(9) 把 9 放到最前面,它不会替换掉原来的头部元素 1,只是把 1 挤到下标 1。
第 2 题:补上 d.push_front(5); 即可。用 d.push_back(5) 会得到 1 2 3 5,位置不对。
#include <bits/stdc++.h>
using namespace std;
int main() {
deque<int> d = {1, 2, 3};
d.push_front(5); // 加到头部
cout << "size = " << d.size()
<< ", front = " << d.front() << ", back = " << d.back() << '\n';
for (int i = 0; i < (int)d.size(); i++) cout << d[i] << ' ';
cout << '\n';
return 0;
}
输出:
size = 4, front = 5, back = 3
5 1 2 3
补完之后 size() 是 4,front() 是 5,back() 还是 3。
立即练习
| 难度 | 题目 | 题解 | 训练目标 |
|---|---|---|---|
| 普及- | 洛谷 P2952:[USACO09OPEN] Cow Line S | 题解 | 模拟一头牛从队伍两端进出:用 deque 保存队伍,按指令在头部或尾部加入、在头部或尾部删除,最后按顺序输出。前置知识:数组、循环、本页的两端操作。 |
做完后请说明:这道题为什么用 deque 比用 vector 更合适?如果所有操作都只发生在尾部,两者还有区别吗?