一、什么是std::deque?它解决了什么问题?
std::deque(double-ended queue,双端队列)是 STL 中的序列容器,支持在头部和尾部高效插入/删除,同时提供随机访问能力。
其核心接口复杂度:
- push_front / pop_front、push_back / pop_back :摊销O(1)
- operator[ ]、at() 随机访问:O(1)
- 中间 insert / erase :O(n)
- size()、empty():O(1)
为什么需要 deque?对比其他容器就能看出它的设计价值:
- std::vector:内存连续,尾部操作 O(1),但头部插入/删除需要移动全部元素 -> O(n),且扩容会使所有迭代器失效。
- std::list:双向链表,两端操作 O(1),但随机访问 O(n),缓存不友好(元素分散)。
- std::deque:折中方案,既要两端高效,又要随机访问,还不能像 vector 那样要求全局连续。
因此,deque 采用了"分块连续 + 中控索引"的混合结构,既保留了数组的随机访问优势,又避免了 vector 头部操作的全局移动。
二、deque 的核心设计思想(中控map+固定大小数据块)
deque 不是单一连续数组,也不是链表,而是:
一个动态指针数组(称为map或中控数组)+ 多个独立分配的固定大小连续内存块(block /buffer)。
- map:一个 T** 类型的动态数组,里面每个元素都是一个指针,指向一个真正存放数据的"块"。
- 块(block):每个块都是一个固定长度的 T 数组,元素在块内连续存放。
- 所有有效元素分散在这些块中,map 负责把它们串起来。
- deque 只记录起始位置(_M_start)和结束位置(_M_finish),有效元素范围就是 [start , finish)。
这样设计的好处:
- 在头部或尾部添加元素时,只需在对应块的头/尾操作,或偶尔分配一个新块(摊销 O(1))。
- 随机访问时,通过"块编号 + 块内偏移"两次间接寻址即可 O(1)。
- 内存不要求全局连续,扩容时不需要移动已有元素(只需移动 map 中的指针,指针复制极快)。
三、典型实现中的关键数据成员(以 libstdc++ 为例)
在gcc的bits/stl_deque.h 中,简化后的std::deque 的核心成员如下:
cpp
template<typename _Tp, typename _Alloc = std::allocator<_Tp>>
class deque {
private:
typedef _Tp** _Map_pointer;// map 中的每个元素类型:指向块的指针
_Map_pointer _M_map; // 中控数组(指针数组)
size_t _M_map_size; // map 当前能容纳的块指针数量
// 迭代器类型(后面详细讲)
struct _Deque_iterator { ... };
_Deque_iterator _M_start;// 指向第一个有效元素
_Deque_iterator _M_finish;// 指向最后一个有效元素之后的位置
};
注意:map 本身也是动态的(类似 vector),当块指针用完时会重新分配更大的 map 并复制指针(因为指针很小,复制代价低)。
四、块大小是如何确定的?
每个块的大小是实现定义的,但主流实现都有固定策略(不同编译器有所差异):
- libstdc++(gcc):
cpp
inline size_t _deque_buf_size(size_t __size) {
return (__size < 512) ? 512 / __size : 1; // 确保块大致 <= 512 字节
}
例如 sizeof(T) == 4(int)时,一个块大约存 128 个元素。
- libc++ (Clang):通常固定 4096 字节 一个块(更利于大缓存)。
- MSVC:较小(默认 16 个元素,或当 sizeof(T) > 8 时甚至 1 个元素)。
为什么选这样的大小?两个因素:
1.块太大->map太稀疏,浪费内存。
2.块太小->map太大,随机访问间接层太多。
实践中,deque即使只存1个元素,也会分配至少一个完整块(最小内存开销比vector大)。
五、迭代器的特殊实现(deque随机访问O(1)的关键)
deque 的迭代器不是普通指针,而是一个结构,记录了"当前在哪个块、块的边界、map 中的位置":
cpp
struct _Deque_iterator {
_Tp* _M_cur; // 当前指向的元素
_Tp* _M_first; // 当前块的起始地址
_Tp* _M_last; // 当前块的结束地址(one-past-last)
_Map_pointer _M_node; // 指向 map 中"指向本块的指针"的指针
};
为什么需要这么多信息?
- _M_node 让迭代器知道自己在 map 中的"块索引"。
- 当 _M_cur 走到块边界时,通过 _M_node + 1 就能跳到下一个块。
- 随机访问 deque[n] 的伪代码(极简):
cpp
size_t __offset = n + (_M_start._M_cur - _M_start._M_first); // 从 start 开始的总偏移
_Map_pointer __node = _M_start._M_node + (__offset / __buf_size);
_Tp* __ptr = *__node + (__offset % __buf_size);
return *__ptr;
只有两次间接寻址(map 指针 + 块内偏移),真正做到了 O(1)。
注意:begin() 返回的迭代器 _M_cur 并不一定指向块的第一个元素!第一个块的前面可能留有空位,专门供 push_front 使用。
六、内存布局示意图
假设块大小为 4 个元素,当前 deque 存了 7 个元素:
bash
map (中控数组): [ nullptr | block0 | block1 | block2 | nullptr | ... ]
^ ^ ^
| | |
_M_map _M_start _M_finish
.node .node
block0: [ 空 | 空 | e0 | e1 ] ← 第一个块可能前面留空
block1: [ e2 | e3 | e4 | e5 ]
block2: [ e6 | 空 | 空 | 空 ] ← 最后一个块可能后面留空
- _M_start 指向 block0 的 e0。
- _M_finish 指向 block2 的 e6 之后。
- 两端都有"备用空间",push_front 可以直接把 _M_start._M_cur--。
七、关键操作的实现原理
1.push_back / pop_back(尾部操作)
- 如果最后一个块还有空位(_M_finish._M_cur != _M_finish._M_last):直接在 _M_finish._M_cur 写值,然后 ++_M_finish._M_cur。
- 如果最后一个块满:
- 分配一个新块(allocator 分配)。
- 把新块指针添加到 map 的尾部(_M_finish._M_node + 1)。
- 如果 map 容量不够 → 重新分配更大的 map,复制所有块指针,然后把新块指针放进去。
- 更新 _M_finish 指向新块的开头。
pop_back 相反:销毁元素,--_M_finish._M_cur,若当前块变空则释放块(但通常不立即释放 map)。
2.push_front / pop_front(头部操作)
逻辑完全对称,只是方向相反:
- 如果第一个块前面还有空位(_M_start._M_cur != _M_start._M_first):直接 --_M_start._M_cur 写值。
- 如果第一个块前面已满:分配新块,插入到 map 的头部(_M_start._M_node - 1)。
- map 头部空间不足时同样扩容 map。
#:map 通常在初始化时预留一些空槽(前后各几个),让两端扩容更少触发 map 重分配。
3.operator[] / at()(随机访问)
如第五节所示,通过块索引 + 块内偏移计算,完全 O(1),无需遍历。
4.中间 insert / erase
- 找到插入位置(O(1) 定位块)。
- 比较前后哪边元素更少,决定向左还是向右整体搬移(最小化移动)。
- 搬移过程中可能涉及块的拆分/合并。
- 代价 O(n),和 vector 相同。
八、扩容机制与内存管理
- 块扩容:只在需要新块时发生,摊销 O(1)。
- map 扩容:当所有块指针用完时,分配一个更大的 map(通常 2 倍),把原有指针复制过去。因为复制的是指针,不是元素,代价极低。
- 释放:pop 时只销毁元素,块只有在完全空时才可能释放(实现可选择延迟释放以复用)。
- 迭代器/引用有效性:只要不触发块/ map 重分配,两端操作不会使指向其他元素的迭代器失效(这是 deque 比 vector 强的地方)。
九、性能与优缺点总结
优点:
两端操作快 + 随机访问快 + 缓存友好(块内连续);
扩容不移动已有元素(迭代器更稳定)。
缺点:
内存开销比 vector 大(每个块都有固定大小 + map 指针数组);
随机访问比 vector 慢一点(两次间接寻址);
中间操作仍 O(n)。
适用场景:
需要频繁在两端插入/删除,同时偶尔随机访问的场景(如任务队列、滑动窗口、大数据流处理);
std::stack 和 std::queue 默认底层就是 deque。
std::deque 的精髓就是"分块 + 中控 map + 智能迭代器"。它用少量间接寻址换来了两端高效操作,同时保留了随机访问能力,是 STL 中被低估却最实用的容器之一。