我们来深入解析 C++ STL 中 deque
的底层结构实现原理 ,它其实比 vector
和 list
都更复杂一些,是一种 分段连续内存结构 + 中央控制器 的设计。
本文主要针对deque的设计思想进行讨论。
一、整体设计思想:分段数组 + 中控指针数组
deque
≠ 一块连续内存(像 vector
),也 ≠ 单个链表(像 list
),而是:
一段段固定大小的小数组 + 一个中央控制结构(map),指向这些数组块
组成:
- Map(中控器) :
- 是一个指针数组:
T** map
(其中每个T*
指向一块缓冲区) - 记录了所有内存块的指针,每个块可以容纳固定个数的元素
- map 本身也可能会动态扩容(不过不频繁)
- 是一个指针数组:
- 缓冲区(Buffer) :
- 每个是固定大小(通常 512 字节)的数组,比如
T[64]
(元素个数视元素大小而定)
- 每个是固定大小(通常 512 字节)的数组,比如
- 迭代器实现 :
- 是四个指针:
cur
(当前元素)、first
(当前缓冲区起点)、last
(缓冲区终点)、node
(当前块的指针位置)
- 是四个指针:
二、可视化结构示意
css
+--------+--------+--------+--------+--------+
map → | p0 | p1 | p2 | p3 | p4 |
+--------+--------+--------+--------+--------+
↓ ↓ ↓ ↓ ↓
[64] [64] [64] [64] [64] ← 每块内存可容纳 64 个元素
- 中控器
map
本身是一个指针数组,指向若干 固定大小的缓冲区 - 所有元素分布在这些小数组里,整体上形成逻辑上的连续空间
deque
就是用这些拼起来模拟可双端扩展、快速随机访问的序列容器
⚙ 三、双端插入删除是如何实现的?
1. 插入尾部 push_back()
:
- 如果尾部缓冲区还有空间:直接插入,O(1)
- 如果缓冲区满了:
- 分配一个新缓冲区
- map 中尾部添加指向新块的指针
- 插入新块的开头位置,O(1)
2. 插入头部 push_front()
:
- 同理,如果头部缓冲区有空间:直接插入
- 没有的话,分配新块,map 前部添加指针
所以双端插入效率为 O(1),除非 map 扩容
四、为什么 deque
可以随机访问?
虽然底层是分段数组,但 deque
重载了 operator[]
:
-
它通过:
cppindex / block_size → 找到 map 中的块位置 index % block_size → 找到块内偏移
-
从而可以实现逻辑上的连续下标访问,时间复杂度为 O(1)
不像 list
,每次都要顺着链表遍历
五、扩容机制与复杂度
map 扩容:
- 如果 map 空间满了,需要:
- 分配更大的 map
- 将原来的指针复制过去(新 map 的中心区域)
- 这个过程代价较大,但很少发生
与 vector 的区别:
扩容代价 | vector |
deque |
---|---|---|
扩容是否搬移数据? | 是(所有元素都要搬) | 否(只扩容 map 或添加块) |
是否复制原数据? | 全部复制 | 指针级别复制即可 |
扩容频率 | 较高(尾插增长时) | 较低(头尾满时 + map 满时) |
六、迭代器实现细节
deque
的迭代器并不是简单的指针,而是一个复杂对象,内部结构大致如下(源码实现略简化):
cpp
template<typename T>
struct deque_iterator {
T* cur; // 当前元素位置
T* first; // 当前块起始地址
T* last; // 当前块结束地址
T** node; // 指向当前 map 中的块指针
};
当 ++it
时:
- 如果
cur + 1 < last
:直接移动到下一个元素 - 否则跨块:
node++
,进入下一块,更新first/last/cur
所以 deque 的迭代器不是普通指针,移动时要判断是否跨块
七、为什么 deque
不能保留迭代器稳定性?
因为:
- 插入新块或 map 扩容后,原来
node
指针位置可能失效 - 所以不如
list
稳定
总结
deque
是用 多个定长内存块 + 中控器 map 组成的逻辑连续结构,既支持随机访问,也支持头尾快速插入/删除,是一种折中的设计,牺牲部分性能换取了更强的通用性。