C++ 中 std::deque 的原理?它内部是如何实现的?

一、什么是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(尾部操作)
  1. 如果最后一个块还有空位(_M_finish._M_cur != _M_finish._M_last):直接在 _M_finish._M_cur 写值,然后 ++_M_finish._M_cur。
  2. 如果最后一个块满:
  • 分配一个新块(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 中被低估却最实用的容器之一。

相关推荐
John_ToDebug2 小时前
浏览器扩展延迟加载优化实战:如何让浏览器启动速度提升50%
c++·chrome·windows
SuperEugene2 小时前
Axios 接口请求规范实战:请求参数 / 响应处理 / 异常兜底,避坑中后台 API 调用混乱|API 与异步请求规范篇
开发语言·前端·javascript·vue.js·前端框架·axios
xuxie993 小时前
N11 ARM-irq
java·开发语言
wefly20174 小时前
从使用到原理,深度解析m3u8live.cn—— 基于 HLS.js 的 M3U8 在线播放器实现
java·开发语言·前端·javascript·ecmascript·php·m3u8
luanma1509804 小时前
PHP vs C++:编程语言终极对决
开发语言·c++·php
寂静or沉默4 小时前
2026最新Java岗位从P5-P7的成长面试进阶资源分享!
java·开发语言·面试
csdn_aspnet4 小时前
C/C++ 两个凸多边形之间的切线(Tangents between two Convex Polygons)
c语言·c++·算法
kyriewen115 小时前
给浏览器画个圈:CSS contain 如何让页面从“卡成PPT”变“丝滑如德芙”
开发语言·前端·javascript·css·chrome·typescript·ecmascript
娇娇yyyyyy5 小时前
QT编程(18): Qt QItemSelectionModel介绍
开发语言·qt