C++ STL之deque详解:从使用到底层,再到面试八股
本文面向面试和日常开发,先讲调用,再讲原理,最后给口语化面试答案。
一、用法速查
1.1 头文件与初始化
#include <deque>
#include <iostream>
#include <string>
using namespace std;
int main() {
deque<int> dq1; // 空deque
deque<int> dq2(5); // 5个元素,默认0
deque<int> dq3(5, 1); // 5个1
deque<int> dq4{10, 20, 30, 40}; // 初始化列表
deque<int> dq5(dq4); // 拷贝构造
for (int x : dq4) cout << x << " "; // 10 20 30 40
}
1.2 元素访问
#include <deque>
#include <iostream>
using namespace std;
int main() {
deque<int> dq{10, 20, 30, 40, 50};
cout << dq[0] << "\n"; // 10,不检查越界
cout << dq.at(1) << "\n"; // 20,越界抛 out_of_range
cout << dq.front() << "\n"; // 10
cout << dq.back() << "\n"; // 50
}
1.3 两端操作
所有四端操作都是 O(1)——这是 deque 的核心优势。
#include <deque>
#include <iostream>
using namespace std;
void print(const deque<int>& dq) {
for (int x : dq) cout << x << " ";
cout << "\n";
}
int main() {
deque<int> dq;
dq.push_back(10);
dq.push_back(20);
dq.push_front(5);
dq.push_front(1);
print(dq); // 1 5 10 20
dq.pop_back();
print(dq); // 1 5 10
dq.pop_front();
print(dq); // 5 10
}
1.4 中间插入与删除
#include <deque>
#include <iostream>
using namespace std;
void print(const deque<int>& dq) {
for (int x : dq) cout << x << " ";
cout << "\n";
}
int main() {
deque<int> dq{10, 20, 30, 40, 50};
auto it = dq.begin() + 2;
dq.insert(it, 99); // 在20和30之间插入99
print(dq); // 10 20 99 30 40 50
dq.erase(dq.begin() + 1); // 删除20
print(dq); // 10 99 30 40 50
dq.erase(dq.begin() + 1, dq.begin() + 3); // 删除 [99, 30)
print(dq); // 10 40 50
}
注意:deque 的中间插入/删除是 O(n),因为需要移动插入点所在块以及后续所有块的元素——后面的面试题会细讲。
1.5 大小操作
#include <deque>
#include <iostream>
using namespace std;
int main() {
deque<int> dq{1, 2, 3, 4, 5};
cout << dq.size() << "\n"; // 5
cout << dq.empty() << "\n"; // 0 (false)
dq.resize(3); // 只保留前3个
cout << dq.size() << "\n"; // 3
for (int x : dq) cout << x << " "; // 1 2 3
cout << "\n";
dq.resize(5, 99); // 扩充到5,新元素填99
for (int x : dq) cout << x << " "; // 1 2 3 99 99
cout << "\n";
dq.clear();
cout << dq.size() << "\n"; // 0
}
与 vector 的显著区别:
// deque 没有这两个函数——编译错误
// dq.capacity(); // ❌
// dq.reserve(100); // ❌
deque 没有 capacity() 和 reserve(),因为它的分段连续结构不需要整体搬移——底层原理部分解释原因。
1.6 遍历与排序
#include <deque>
#include <iostream>
#include <algorithm>
using namespace std;
int main() {
deque<int> dq{5, 1, 4, 2, 3};
// 迭代器遍历
for (auto it = dq.begin(); it != dq.end(); ++it)
cout << *it << " "; // 5 1 4 2 3
cout << "\n";
// 范围for
for (int x : dq) cout << x << " ";
cout << "\n";
// 反向迭代器
for (auto it = dq.rbegin(); it != dq.rend(); ++it)
cout << *it << " "; // 3 2 4 1 5
cout << "\n";
// sort排序——deque支持随机访问迭代器,可以调用std::sort
sort(dq.begin(), dq.end());
for (int x : dq) cout << x << " "; // 1 2 3 4 5
cout << "\n";
}
1.7 函数速查表
| 方法 | 含义 | 复杂度 |
|---|---|---|
dq[i] | 下标访问,不检查越界 | O(1) |
dq.at(i) | 带边界检查,越界抛异常 | O(1) |
front() / back() | 队首/队尾元素 | O(1) |
push_front(v) / push_back(v) | 首/尾插入 | O(1) |
pop_front() / pop_back() | 首/尾删除 | O(1) |
insert(pos, v) | 中间插入 | O(n) |
erase(pos) / erase(first, last) | 中间删除 | O(n) |
size() / empty() | 大小/判空 | O(1) |
resize(n) | 改变大小 | O(n) |
clear() | 清空 | O(n) |
begin() / end() | 迭代器 | O(1) |
二、底层原理
2.1 分段连续存储结构
deque 的底层既不是 vector 的整块连续内存,也不是 list 的完全离散节点。它采用了一种巧妙的折中——分段连续存储(segmented contiguous storage)。
中控器 (map)
┌──────────────────────────┐
│ ptr │ ptr │ ptr │ … │
└───┬───┴───┬───┴───┬───┴──┘
│ │ │
┌─────┘ │ └─────┐
▼ ▼ ▼
┌──────┐ ┌──────┐ ┌──────┐
│ 2 │ │ 25 │ │ 8 │
│ 10 │ │ 30 │ │ 12 │
│ 15 │ │ 35 │ │ — │
│ 20 │ │ — │ │ — │
└──────┘ └──────┘ └──────┘
buffer0 buffer1 buffer2
(块0) (块1) (块2)
数据被切分成若干个固定大小的块(buffer),每个块内部是连续内存。块与块之间不连续,但通过一个中控器(通常称作 map,实际上是指针数组)来串联管理。
2.2 operator[] 的两步寻址
当你执行 dq[i] 时,deque 内部通过两步找到真正的内存位置:
block_index = i / block_size
offset = i % block_size
result = map[block_index][offset] // 等价于 *(map[block_index] + offset)
以 dq[3] 为例,如果块大小是 4:
block_index = 3 / 4 = 0
offset = 3 % 4 = 3
→ 取 map[0] 指向的块,再取该块第 3 个位置
这个两步寻址就是 O(1) 的——一次数组索引 + 一次指针偏移,没有循环。
2.3 块大小
块大小依赖实现,一般规则是:
// 典型实现(gcc/libstdc++)
static const size_t block_size = max(1, 512 / sizeof(T));
- 对于
int(4 字节):512 / 4 = 128个元素每块 - 对于
double(8 字节):512 / 8 = 64个元素每块 - 对于自定义大对象(sizeof > 512):每块只放 1 个元素
512 字节是常见的内存页对齐值,由 malloc 的元数据效率决定。
2.4 push_front / pop_front 为何是 O(1)
vector 的 push_front 是 O(n) 的,因为需要把所有元素后移一位。但 deque 的分段连续结构完美解决了这个问题:
块0(头部有空位) 块1 块2
┌──────┬──┐ ┌──────┐ ┌──────┐
│ — │ 5 │←front │ 10 │ │ 20 │
│ — │ 3 │ │ 15 │ │ 25 │
│ — │ 1 │ │ — │ │ — │
│ — │ 2 │ │ — │ │ — │
└──────┴──┘ └──────┘ └──────┘
每个块在两端都预留了空间。执行 push_front 时:
- 如果当前首块前端还有空位,直接在该块的前一个槽位写入
- 如果首块前端已满,新分配一个块放到中控器前端,在新块尾部写入
pop_front 对称——直接移动首块内的起始指针,如果整块元素全 pop 完,释放该块。
这就是为什么 deque 两端操作能保持 O(1):不需要搬移已有元素,只需要操作首尾块的空位或分配释放块。
2.5 deque vs vector vs list 三维对比
| 维度 | deque | vector | list |
|---|---|---|---|
| 内存布局 | 分段连续(多块) | 整块连续 | 节点离散,指针连接 |
| 随机访问 | O(1)(两步寻址) | O(1)(直接偏移) | O(n)(逐个遍历) |
| 头插/头删 | O(1) | O(n)(整体后移) | O(1) |
| 尾插/尾删 | O(1) | O(1)(均摊) | O(1) |
| 中间插入/删除 | O(n) | O(n) | O(1)(有迭代器时) |
| 缓存局部性 | 中等(块内连续) | 最佳(整块连续) | 差(离散节点) |
| 迭代器失效(插入) | 仅两端操作不失效 | 可能全部失效 | 仅被删节点失效 |
| 有 capacity/reserve | 无 | 有 | 无 |
| 默认底层(stack/queue) | 自身 | — | — |
2.6 为什么 deque 没有 capacity 和 reserve
vector 有 capacity() 是因为它的底层是一整块连续内存——当容量不够时必须整体搬迁,reserve 可以预分配来减少扩容次数。
deque 不需要这两个函数,根本原因在于它的 分段结构:
- push_back 时当前尾块满了,只需分配一个新块挂到中控器尾部,不需要搬移已有元素
- push_front 同理,满则分配新块挂到头部
- 没有"整体扩容"的概念——每次分配的都是单个小块的尺寸,无需整体搬迁
所以 deque 不需要预知最终大小来预留空间。如果一定要预留,用 resize 即可。
2.7 为什么 stack 和 queue 默认底层是 deque
template <class T, class Container = deque<T>>
class stack;
template <class T, class Container = deque<T>>
class queue;
stack 需要一端插入 + 一端删除;queue 需要一端插入 + 另一端删除。deque 刚好对两端操作都是 O(1),而且支持随机访问(虽然 adapter 不暴露这个能力)。相比之下,vector 只有尾部高效(push_front O(n)),list 虽然两端也快但缓存不友好且节点内存开销大。deque 是对 adapter 来说最均衡的选择。
三、面试题 + 口语化答案
Q1:deque 底层是什么数据结构?
“deque 底层是分段连续存储——由一个中控器(指针数组)管理多个固定大小的连续内存块。每个块内部是连续的,块与块之间不连续。访问元素时通过两步寻址:map[block_index] + offset,两次指针偏移就到目标位置了。这个结构让 deque 同时做到了两端 O(1) 操作和随机访问 O(1)——相当于在 vector 和 list 之间取了一个折中。”
Q2:为什么 deque 没有 capacity() 和 reserve()?
“因为 deque 不需要整体搬移。vector 需要 reserve 是因为当整块连续内存不够时必须整体扩容——搬迁所有元素到新内存。deque 是分段连续的,尾块满了就分配一个小块挂到中控器尾部,头块满同理。每块大小是固定的,每次只分配一小块,不存在 vector 那种 ‘一次性搬走所有元素’ 的大扩容。所以 capacity 和 reserve 对 deque 没有意义——用 resize 预占位就行。”
Q3:deque 的迭代器和 vector 的迭代器有什么不同?
“两个核心区别。第一,大小不同——vector 的迭代器可以就是一个 T* 指针,8 字节就够(x64)。deque 的迭代器至少包含三个字段:当前元素的指针、当前所在块的指针、中控器的位置指针,在 GCC 里大约 16-24 字节。第二,失效规则不同——vector 只要触发扩容或中间插入删除,从操作位置往后的迭代器全部失效;deque 只在中间插入删除时失效,两端的 push/pop 操作不影响已有迭代器(push_front 和 push_back 不失效)。”
Q4:什么时候用 deque 而不是 vector?
“典型的场景是需要在两端频繁操作——比如任务队列、滑动窗口、双端 BFS。如果大部分操作是 push_back + 随机访问,那就用 vector。如果要在头部频繁 push/pop,vector 是 O(n) 的扛不住,list 随机访问太慢,deque 是刚好合适的选择。另外如果不想处理 vector 的那个 vector<bool> 特化陷阱,也可以用 deque<bool> 替代——deque 没有 bool 特化,返回的是真的 bool&。”
Q5:deque 中间插入为什么是 O(n)?
“这个 O(n) 和 vector 的 O(n) 性质不同。vector 的中间插入 O(n) 是因为需要把插入点之后的所有元素后移一位——一次连续的 memmove。deque 的插入也是 O(n),但原因是分段连续结构下的块间元素挪动:假设你在块 2 的某个位置插入了元素,块 2 在这个插入点之后的元素要往后挪;如果块 2 满了,最后一个元素要移到块 3 的头部,然后块 3 的所有元素顺次后移,以此类推——相当于把插入点后面的所有分段依次往后推了一个位置。如果面试官追问能不能比 vector 快,可以说不一定——vector 的 memmove 流水线友好,deque 的跨块挪动涉及多次拷贝且破坏缓存连续,实际常数可能更大。”
Q6:deque 的块大小在不同平台下是多少?
“块大小取决于实现,GCC libstdc++ 和 Clang libc++ 采用的是 max(1, 512 / sizeof(T))——512 字节除元素类型大小,至少为 1。对于 int 是 128 个元素每块,double 是 64 个。MSVC 的实现略有不同,但整体思路一致——用 512 字节这个对齐边界是因为现代 malloc 对 512 字节以下的小块分配效率最高,且能最小化内存碎片。这个常数是编译器决定的,标准没有强制,但主流实现大同小异。”
Q7:deque 中间插入后迭代器失效的范围是多大?
“只在被插入/删除位置所在的块以及之后的所有块中,迭代器才有可能失效。具体来说,如果你插入的是整个 deque 的中间位置,从插入点开始到末尾的所有迭代器都可能失效——因为后续的元素在块内挪动了位置。不像 vector 那么极端(扩容就全死),但比 list 严重(list 只死被删的那个)。另外两端的 push/pop 不会让任何已有迭代器失效——这是 deque 的一个优势。”
Q8:deque 和 list 都是两端操作高效,为什么 stack/queue 选 deque 作为默认底层?
“两个原因。第一,随机访问——list 的迭代器是双向的,不支持随机访问(所以 list 不能传给 std::sort);deque 的迭代器是随机访问的,虽然 adapter 不会暴露这个能力,但内部实现可以用 operator[] 做更高效的处理。第二,内存效率——list 每个节点都要存储 prev 和 next 两个指针,加上元素数据,额外开销差不多 16 字节(x64)。deque 按块分配,块内元素没有任何额外指针开销。数据量大时 deque 的内存占用比 list 少很多。所以 deque 是 adapter 的那个 ‘最均衡’ 的选择。”
一句话总结:deque 通过分段连续存储在中插 O(n) 的代价下换来了两端 O(1) 和随机访问 O(1) 的双重收益,是 vector 和 list 之间的最佳折中——它的迭代器结构、无 capacity 的设计、以及作为 stack/queue 默认底层的选择,都是面试中值得展开聊的考点。
358



被折叠的 条评论
为什么被折叠?



