【C++初阶】:(14)从底层结构理解 deque——为什么它成为 stack 和 queue 的默认容器

前言

前面分析 stackqueue 时,我们会反复看到一个很容易被忽略的细节:

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 接口更有意思的地方。

相关推荐
呉師傅2 小时前
关于惠普LaserJet1018打印机在Windows11上打印颜色偏淡的问题解决方法
运维·服务器·windows·计算机外设·电脑
永不复还3 小时前
Windows驱动开发:IRP、完成例程与 MDL 生命周期深度解析
windows·驱动开发
百事牛科技3 小时前
只可读不可改!PDF禁止修改实操方法
windows·pdf
老王的笔记v4 小时前
42期 鼠鼠格式转换工具整合图片文档音视频转换,Windows和Mac免费使用
windows·开源·github·音视频
可爱系程序猿4 小时前
Windows 11 添加共享打印机提示 0x0000011b:从 RPC 到驱动架构排查
windows·rpc·架构
渣渣盟4 小时前
Nginx 从零到上手:Windows & Linux 双环境教程
linux·windows·nginx
魂祈梦4 小时前
windows终端乱码问题
windows
0566464 小时前
python高级——Python 类型提示与 Pydantic
网络·人工智能·windows·python·学习
小桥流水---人工智能5 小时前
Windows下Conda无法创建Python 3.11虚拟环境:从镜像源404到PackagesNotFoundError的完整排查与解决
windows·conda·python3.11