前言
前面分析 stack 和 queue 时,我们会反复看到一个很容易被忽略的细节:
cpp
template<class T, class Container = deque<T>>
class stack;
template<class T, class Container = deque<T>>
class queue;
两种容器适配器的默认底层容器都是:
cpp
deque
如果只停留在"deque 支持头尾操作,所以适合 stack 和 queue"这一层,其实还没有真正解释清楚 STL 的设计选择。
因为这里至少还存在几个问题:
cpp
vector 的尾部操作同样很快,为什么 stack 不默认使用 vector?
list 的头删、尾插也都是 O(1),为什么 queue 不默认使用 list?
deque 明明没有 vector 那样漂亮的连续内存,为什么反而成了折中的默认方案?
要回答这些问题,就需要稍微往 deque 的内部再走一步。
一、deque 的核心并不是"双端",而是它如何做到"双端"
deque 的全称是:
cpp
double-ended queue
也就是"双端队列"。
它最直观的能力是:
头部可以插入
头部可以删除
尾部可以插入
尾部可以删除
从接口上看就是:
cpp
push_front();
pop_front();
push_back();
pop_back();
并且两端插入、删除都可以做到很高的效率。

但真正值得关注的不是"deque 两头都能操作"这句话,而是:
它为什么能避免 vector 在头部操作时的大规模元素移动?
答案就在 deque 的存储结构里。
二、deque 不是"大 vector",而是"分段连续 + 集中管理"
很多初学者第一次看到 deque,会下意识把它理解成:
可以从两头操作的 vector
这种理解只能描述它的使用方式,却不能解释它的底层机制。
vector 的核心特点是:
所有元素处于一整块连续内存中。
可以简单表示成:
┌────┬────┬────┬────┬────┐
│ 10 │ 20 │ 30 │ 40 │ 50 │
└────┴────┴────┴────┴────┘
连续存储给 vector 带来了非常优秀的:
随机访问能力
缓存局部性
遍历性能
但代价也很明显:
当现有连续空间不足时,vector 可能需要重新申请一块更大的连续空间,再搬迁已有元素。
deque 选择了另一条路线。
它通常不是申请一整块巨大的连续空间,而是把数据拆分到若干块固定大小或实现相关大小的缓冲区中:
buffer 1
┌────┬────┬────┬────┐
│ │ │ │ │
└────┴────┴────┴────┘
buffer 2
┌────┬────┬────┬────┐
│ │ │ │ │
└────┴────┴────┴────┘
buffer 3
┌────┬────┬────┬────┐
│ │ │ │ │
└────┴────┴────┴────┘
然后再通过一个用于管理这些缓冲区的结构,将它们组织成一个逻辑上的整体。
在常见 STL 实现中,可以把这个结构理解为:
map
↓
保存若干 buffer 的地址
于是整体关系更接近:
map
┌────┬────┬────┬────┬────┐
│ * │ * │ * │ * │ * │
└─┬──┴─┬──┴─┬──┴─┬──┴─┬──┘
│ │ │ │ │
↓ ↓ ↓ ↓ ↓
buf buf buf buf buf
这里有一个需要说得严谨一点的地方:
C++ 标准规定的是 deque 对外表现出的行为和复杂度要求,并没有要求所有标准库必须采用完全相同的内部布局。
不同编译器、不同 STL 实现的细节可能不同。
但是在理解 deque 时,使用"分段连续空间 + 中央映射结构管理各段 buffer"这个典型模型,是非常合适的。
这种设计到底解决了什么问题?
假设 deque 尾部空间已经用完。
它不一定需要像 vector 那样:
重新申请一整块更大的连续空间
↓
搬迁所有已有元素
↓
释放旧空间
而是可以继续增加新的 buffer,再让管理结构记录新的缓冲区。
因此从设计思想上来看:
vector
更强调"整体连续"
deque
更强调"分段组织"
这使 deque 在不断向两端增长时具有很大的灵活性。

注意这里不要简单理解为:
deque 扩容永远什么都不用调整。
如果用于管理 buffer 的映射结构空间本身不足,它同样可能需要调整自己的管理结构。
真正的区别在于:
deque 不需要为了维持"一整块元素连续内存",像 vector 那样搬迁全部已有元素。
这才是两者扩展机制上的核心差别。
三、为什么 deque 看起来连续,实际上却不是连续的?
站在使用者角度,我们完全可以这样写:
cpp
deque<int> dq;
dq.push_back(10);
dq.push_back(20);
dq.push_back(30);
cout << dq[1] << endl;
甚至可以:
cpp
for (auto it = dq.begin(); it != dq.end(); ++it)
{
cout << *it << " ";
}
看起来和 vector 非常相似。
但两者底层发生的事情完全不同。
vector 的迭代器可以粗略理解成一个非常接近普通指针的东西:
cpp
当前位置
↓
[10][20][30][40]
↓
++it
↓
直接走到下一个元素
因为元素真正连续。
deque 则要复杂得多。
假设当前元素位于某一个 buffer 中:
buffer A
[10][20][30][40]
↑
cur
只要还没有走到当前 buffer 的末尾:
cpp
++it;
仍然比较简单。
但如果已经来到:
buffer A 的最后一个元素
再执行:
++it;
迭代器就必须完成类似下面的工作:
当前 buffer 已经结束
↓
找到当前 buffer 在 map 中的位置
↓
定位下一个 buffer
↓
跳到下一个 buffer 的起始位置
↓
继续遍历
所以典型 deque 迭代器需要维护的不只是:
"当前元素在哪里"
还要知道:
当前 buffer 的开始位置
当前 buffer 的结束位置
当前 buffer 在 map 中的位置
因此在一些经典 STL 实现分析中,经常能看到类似:
cur
first
last
node
这样的成员。
可以粗略理解成:
cur
→ 当前正在访问哪个元素
first
→ 当前 buffer 的起始位置
last
→ 当前 buffer 的结束位置
node
→ 当前 buffer 在 map 中的位置

这样 deque 才能给用户制造出一种:
"虽然物理空间并不完全连续,但我仍然可以像访问一个整体容器一样进行迭代"
的效果。
这也是 deque 实现中一个很有意思的地方:
连续性不一定非要由底层内存天然提供,也可以通过迭代器这一抽象层在逻辑上重新构造出来。

四、deque 为什么说是一种"折中型容器"?
理解了内部结构以后,再比较:
cpp
vector
deque
list
会更加清楚。
vector 的优势非常极端
vector 追求:
连续存储
所以它特别擅长:
随机访问
顺序遍历
缓存命中
尾部操作
但如果需要从最前面删除一个元素:
cpp
1 2 3 4 5
删除 1 后:
cpp
2 3 4 5
为了维持连续结构,后面的元素需要向前移动。
因此头删不是 vector 擅长的事情。
list 走的是另一个极端
list 不要求元素连续。
每个元素通常以节点形式组织:
┌──────┐ ┌──────┐ ┌──────┐
│ node │ ⇄ │ node │ ⇄ │ node │
└──────┘ └──────┘ └──────┘
因此已知位置附近的插入和删除非常灵活。
但是每个节点除了真正的数据以外,通常还需要保存额外的链接信息。
而且节点可能分散在内存的不同位置。
这意味着:
额外指针空间
+
缓存局部性相对较差
deque 站在两者中间
deque 没有要求:
所有元素整体连续
所以它不会完全受到 vector 那种连续空间要求的限制。
但它也没有采用:
一个元素对应一个独立链表节点
这样的组织方式。
而是:
很多个元素
↓
组成一个连续 buffer
多个 buffer
↓
通过 map 组织起来
因此可以把它理解成:
vector
↓
整体连续
│
│
deque:分段连续
│
│
↓
list
节点式离散
这也是为什么我更愿意把 deque 称为:
一种在连续存储和链式组织之间进行工程折中的容器。
它不是所有指标上最强的那个,但是它把:
两端操作
随机访问
空间扩展
缓存局部性
这些需求平衡得比较好。
五、为什么它恰好适合 stack 和 queue?
现在再回过头看 STL 的选择,就比较容易理解了。
先看 stack。
stack 需要的核心能力其实非常少:
cpp
push();
pop();
top();
映射到底层就是:
cpp
push()
↓
push_back()
pop()
↓
pop_back()
top()
↓
back()
所以 stack 要求的核心能力其实就是:
高效操作容器尾部。
这一点:
vector
deque
list
都可以做到。
因此我们确实可以写:
cpp
stack<int, vector<int>> st1;
stack<int, deque<int>> st2;
stack<int, list<int>> st3;
再来看 queue。
queue 的操作方向不同:
新元素
↓
从队尾进入
旧元素
↓
从队头离开
因此底层要求:
cpp
push_back()
+
pop_front()
+
front()
+
back()
这里需要特别说明一个容易说得不够严谨的地方。
很多时候会看到:
vector 头删效率低,所以不适合 queue。
这句话方向没错,但如果讨论的是:
cpp
std::queue<T, Container>
还可以再准确一步:
vector 本身没有
pop_front(),因此它并不满足标准 queue 对底层容器所需要的接口。
也就是说:
cpp
queue<int, vector<int>>
并不是单纯"能用但效率低",而是标准接口层面就不满足要求。
如果我们自己设计一个 queue,然后强行使用:
cpp
v.erase(v.begin());
模拟头删,才会进一步遇到:
cpp
后续元素整体移动
↓
O(n)
的问题。
而:
cpp
deque
list
都天然支持:
cpp
pop_front();
所以两者都可以作为 queue 的底层容器。
那为什么最终选 deque,而不是 list?
这才是最值得分析的地方。
queue 只需要:
头部
+
尾部
的操作。
它根本不要求使用者:
从中间遍历
进行复杂迭代器操作
stack 更是只需要操作:
尾部
而 stack 和 queue 本身都没有给用户暴露普通迭代器。
于是 deque 的一个主要复杂点:
迭代器需要处理跨 buffer 跳转
在 stack / queue 这个场景中几乎不会直接暴露给用户。
与此同时,deque 的优势:
头部操作效率高
尾部操作效率高
不依赖整体连续扩容
比链表具有更好的局部性
却刚好全部能够发挥出来。
这实际上是一种非常典型的工程设计:
不是选择"理论上某一项性能最强"的结构,而是选择"优点正好匹配需求,同时缺点在当前场景中又不明显"的结构。
这也是 deque 成为:
cpp
stack
queue
默认底层容器的重要原因。
六、从 deque 再看一次"容器适配器"
学到这里,其实还能反过来加深我们对:
cpp
Container Adapter
容器适配器
这个概念的理解。
stack 和 queue 自己并不关心:
你底层到底是怎么申请内存的。
它们真正关心的是:
你能不能提供我需要的操作?
例如 stack 需要:
cpp
back
push_back
pop_back
queue 需要:
cpp
front
back
push_back
pop_front
只要底层容器满足这些能力,上层适配器就可以把它包装成:
stack
或者
queue
于是整个设计关系就变成:
底层容器
负责:
数据到底怎么存
↓
容器适配器
负责:
数据允许怎么被使用
↓
stack
LIFO 后进先出
queue
FIFO 先进先出
这也是我觉得学习 deque 最有价值的地方。
表面上这一节只是在补充一个 STL 容器,但实际上它把前面很多内容重新串了起来:
vector 的连续存储
↓
list 的链式存储
↓
deque 的分段连续存储
↓
模板参数替换底层容器
↓
stack / queue 容器适配器
到了这里,我们已经不只是知道:
cpp
queue<int> q;
"默认用了 deque"。
而是开始能够解释:
为什么设计者会选择 deque。
总结
如果让我现在用一句话描述 deque,我不会只说:
deque 是一个双端队列。
我更愿意把它理解成:
deque 通过分段连续存储和额外的映射管理结构,在保持随机访问能力的同时,获得了高效的两端扩展能力,是一种典型的工程折中型顺序容器。
把它放回 stack 和 queue 中:
deque
│
├── 两端操作高效
│
├── 不要求整体连续扩容
│
├── 相比 list 局部性更好
│
└── 迭代器虽然复杂
但 stack / queue 又不暴露迭代器
↓
非常契合适配器需求
↓
成为默认底层容器
所以真正值得记住的并不是:
stack 默认 deque
queue 默认 deque
而是背后的设计逻辑:
一个好的底层数据结构,不一定每一项能力都做到极致,而是它的优势刚好覆盖上层需求,它的缺点又恰好不会成为当前场景的主要矛盾。
这才是理解 STL 设计比单纯记住 STL 接口更有意思的地方。