C++的STL容器家族里,deque(双端队列)一直是个“存在感不强但相当能打”的角色。学完vector和list之后,很多人会下意识跳过它,觉得不过是个“两头都能插的 vector”,真到用的时候又想不起来。但只要你写过滑动窗口、消息缓冲、undo/redo这些场景,就会明白deque在STL容器里的生态位其实非常独特。它既不承诺连续内存,也不付出链表式的节点开销,却能在头尾两端都做到常数时间的插入删除,还保留随机访问能力。这篇我打算把它从底层实现到API使用、从性能实测到踩坑记录完整讲一遍,适合刚开始学C++的读者建立容器选型直觉,也适合已经写过一段时间、想搞懂“deque到底凭什么两头都快”的人。
1. 为什么有了vector和list,还得有deque
1.1 vector的头部之痛与list的缓存之痛
我们先说vector。vector是连续内存,访问极其舒服,缓存命中率最高,但它的致命弱点有两个:第一是在头部插入或删除元素,需要把整个数组后面的元素全部搬移一次,O(n)的时间在数据量大了以后非常难看;第二是扩容时会申请更大的内存然后把旧元素全部拷贝或移动过去,这不仅是时间开销,还会让所有迭代器、指针、引用全部失效。
list呢?list解决了头部插入的问题,因为它是双向链表,每个节点独立分配,插入删除只需要改指针,理论上到哪儿都是O(1)。但list的问题是,它的每个元素都要额外存两个指针(prev和next),内存开销大;更麻烦的是,节点在堆上东一个西一个,遍历的时候缓存完全不友好。我做过一个简单的性能测试,同样遍历一百万个整数,vector能比list快一个数量级以上,原因就是list的节点在内存里不连续,CPU缓存频繁miss。
1.2 deque的生态位:两头O(1)、中间能扛、还能随机访问
deque恰好站在两者中间。它的设计目标是:既要头尾两端的插入删除都是O(1),又要支持随机访问(虽然比vector稍慢),还要避免list那种节点指针带来的内存浪费。
关键来了:deque并不是简单地“两个vector拼起来”。如果只是两个vector,头部那个vector在push_front的时候依然会遇到扩容搬移问题。deque的底层用了分段连续存储,我下一节会细讲。这里你先记住结论:deque的push_front和push_back都是均摊O(1),随机访问是O(1),但常数比vector大;中间插入删除是O(n),但比vector的O(n)还要再差一点,因为它要移动的不仅是元素,还要处理缓冲区边界。
1.3 哪些场景天然适合deque
根据我平时的开发经验,这些场景里deque几乎是“官方指定容器”:
- 滑动窗口类问题。比如求数组每个长度为k的窗口的最大值/最小值,经典解法就是维护一个单调双端队列,从尾巴push、从脑袋pop,deque两头都快的特性在这里体现得淋漓尽致。
- 消息队列/任务缓冲。生产者往尾部投递任务,消费者从头部取出任务,这正是双端队列的天然节奏。
- undo/redo历史记录。撤销和重做本质就是一个栈,但如果希望历史记录超过一定数量就从底部淘汰,deque比vector和list都好用。
- 需要在两端交替操作的算法。比如回文判断,从两头取字符比较,deque很顺手。
2. 剥开deque底层:中控器、缓冲区与四指针迭代器
2.1 分段连续存储:deque不是“连环数组”
理解deque,核心就一句话:deque是分段的连续存储。它内部由一个中控器(通常叫map)和若干缓冲区(buffer/block)组成。
中控器本身是一个指针数组,数组里每个元素都指向一个缓冲区。每个缓冲区是一块独立的连续内存,里面存放若干元素。deque的元素就分布在所有这些缓冲区里,但缓冲区之间不要求物理连续。
这样设计的好处非常直接:从头部插入元素时,如果当前头部缓冲区已经满了,就新分配一块缓冲区,让中控器新指针指向它,原来的头部指针变成第二块。这一操作完全不需要搬动任何已有元素,所以能稳定做到O(1)。同样的逻辑也适用于push_back。
中控器本身也需要增长。当指针数组满了,中控器会搬一次家,重新分配一块更大区域的指针数组,把旧的指针全部拷过去。这里搬的是指针,不是元素数据,所以成本低得多。这一点跟vector扩容形成鲜明对比:vector扩容搬的是实实在在的元素,大对象时构造和析构的开销很吓人。
2.2 迭代器里藏着4个指针
这是很多C++程序员觉得deque“别扭”的重要原因。vector的迭代器本质就是一个指针,list的迭代器本质是指向节点的指针,而deque的迭代器为了能在分段存储上完成自增、自减、跳转,内部维护了四个指针:
cur:当前元素在缓冲区内的位置;first:当前元素所在缓冲区的起始位置;last:当前元素所在缓冲区的结束位置;node:指向中控器里记录当前缓冲区的那个指针条目。
每次执行++it,迭代器先让cur加一。如果cur已经到达last,说明走到了当前缓冲区的末尾,于是通过node找到下一块缓冲区的指针,把first、last、cur更新到下一块的起始位置。--it是同样的逻辑反向操作。
这就是为什么deque的operator[]比vector慢:vector拿地址直接加偏移量就行,只需一次内存访问;deque需要先根据下标定位到哪一块缓冲区,再在缓冲区内部算偏移,本质上多了一次间接跳转。而这也解释了为什么deque没有data()成员函数——因为它的元素根本不在同一块连续内存里,没法给你一个完整的裸指针。
注意:deque的随机访问迭代器虽然也是随机访问迭代器(Random Access Iterator),但它的“随机访问”质量比vector弱一档。
std::sort这样的算法对deque依然适用,但排序的常数因子会比vector大。
2.3 为什么push_front能做到O(1)
我们以libstdc++(GCC的标准库)为例,它默认每块缓冲区能容纳512字节的数据。假设int是4字节,那么每块能放128个int。当你在头部插入时,deque首先判断头部缓冲区是否还有位置。如果还有,直接在first前面一个位置写入元素即可;如果满了,就新分配一块缓冲区挂到中控器前面。
分配一块新缓冲区是“偶尔”发生的事情,绝大多数push_front只是在一个已有缓冲区的空闲位置写一个值。均摊下来,每次push_front还是O(1)。同样的道理也适用于push_back。这种头尾对称的设计是deque区别于vector的最本质差异:vector只会向后扩容,deque两头都会扩容,而且扩容都不搬数据。
3. deque常用API实战:一段代码覆盖90%日常用法
3.1 构造与赋值
deque的构造函数种类和vector几乎一一对应:
std::deque<int> d1; // 空deque std::deque<int> d2(10); // 10个默认值(0) std::deque<int> d3(10, 42); // 10个42 std::deque<int> d4(d3.begin(), d3.end()); // 迭代器范围构造 std::deque<int> d5 = {1, 2, 3, 4, 5}; // 初始化列表赋值可以用assign,作用和vector一致:
std::deque<int> d; d.assign(5, 100); // 变成5个100 d.assign({1, 2, 3}); // 变成1,2,33.2 读写访问:[]、at()、front、back
访问方面,deque提供了和vector一样的接口:
std::deque<int> dq = {1, 2, 3, 4, 5}; int a = dq[2]; // a = 3,不检查越界 int b = dq.at(2); // b = 3,越界抛std::out_of_range int f = dq.front(); // f = 1 int ba = dq.back(); // ba = 5 dq[1] = 20; // 可写 dq.at(4) = 50; // 也可写每天写容器代码都会遇到一个问题:到底优先用[]还是at()?我的建议是:下标访问如果能保证不越界,就用[],因为at()每次都会做边界判断,有额外开销;但在处理用户输入、不可信边界逻辑时,优先at(),宁可让程序抛异常也不要让未定义行为溜进生产环境。
3.3 增删操作与迭代器遍历
这是deque的主场,头尾操作尤其亮眼:
std::deque<int> dq; dq.push_back(1); // 尾部插入 dq.push_back(2); dq.push_front(0); // 头部插入 dq.emplace_back(3); // C++11起,尾部门前构造 dq.emplace_front(-1); // 头部门前构造 dq.pop_back(); // 尾部删除 dq.pop_front(); // 头部删除emplace_back和push_back的区别是:push_back传入一个已构造好的对象,可能会触发拷贝或移动;emplace_back则直接把构造参数传进去,在容器内部原位构造,少一次不必要的临时对象构造。对deque这种不需要搬动已有元素的结构来说,emplace的优势更明显。
至于中间插入和删除,用法上和vector完全一样,就是insert和erase:
auto it = std::find(dq.begin(), dq.end(), 20); if (it != dq.end()) { dq.insert(it, 99); // 在元素20前面插入99 dq.erase(it); // 删除it指向的元素 }需要明确一点:deque的中间insert/erase是O(n),它没有list那种O(1)的中间插入能力。试图靠deque实现“频繁在中间维护有序集合”会非常疼,这种场景请选择list或者map类容器。
遍历时正向和反向都很自然:
for (auto x : dq) { /* 顺序遍历 */ } for (auto it = dq.rbegin(); it != dq.rend(); ++it) { // 反向遍历 }3.4 一个小例子:滑动窗口最大值就用deque
我直接给出一个最经典的实战代码:给定数组和一个窗口大小k,求每个窗口里的最大值。这是单调队列的教科书应用,也是deque头尾特性的完美展示:
#include <vector> #include <deque> std::vector<int> maxSlidingWindow(const std::vector<int>& nums, int k) { std::deque<int> dq; // 存下标,队列内下标对应的元素单调递减 std::vector<int> res; for (int i = 0; i < (int)nums.size(); ++i) { if (!dq.empty() && dq.front() <= i - k) { dq.pop_front(); // 队头滑出窗口 } while (!dq.empty() && nums[dq.back()] <= nums[i]) { dq.pop_back(); // 队尾弹出所有更小的元素 } dq.push_back(i); // 当前下标入队尾 if (i >= k - 1) { res.push_back(nums[dq.front()]); // 队头是窗口最大值 } } return res; }这段代码里,deque的两端操作非常密集:pop_front淘汰过期元素,pop_back维护单调性,push_back追加新元素。三个操作全是O(1),整体算法就是O(n)。如果换成vector,每次窗口滑动都要删头部,那就是O(n*k);换成list,随机访问又做不到。这就是“选对容器,算法复杂度直接降一个量级”的真实案例。
4. 性能实测与容器选型:deque、vector、list的正面PK
4.1 实测场景与测试思路
光看理论不够,我平时做选型时习惯跑一组微基准测试。测试环境各机器不同,这里我关注的是相对趋势,不是绝对数值。测试内容分四组:
- 尾部插入100万个元素;
- 头部插入100万个元素;
- 随机访问一千万次;
- 顺序遍历并求和。
每组分别用vector、list、deque执行。看相对耗时的话,结果基本稳定。
| 操作 | vector | list | deque |
|---|---|---|---|
| 尾部插入100万 | 基准(最快档) | 慢于vector | 接近vector,略慢 |
| 头部插入100万 | 极慢(每次搬移全部元素) | 快 | 快,与list同档 |
| 随机访问1000万次 | 最快 | 极慢(遍历链表节点) | 慢于vector约20%~50% |
| 顺序遍历求和 | 最快 | 慢一个数量级 | 慢于vector约10%~30% |
4.2 结果解读:缓存局部性决定了什么
vector之所以在尾部插入和遍历上全面领先,是因为连续内存让CPU缓存命中率极高。deque虽然也是分段连续,但缓冲区之间是离散的,遍历跨缓冲区时会产生一部分缓存miss,所以比vector慢一点点。
list在尾部插入这项居然没有拉开差距,是因为我们测试的是纯插入耗时,list尾部插入也是O(1),但每个节点分配内存的开销被均摊了,所以没觉得慢。可一旦进入遍历和随机访问,list的缓存劣势立刻暴露,速度掉得惨不忍睹。
这个测试结论非常值得你记住:如果数据规模大到需要经常遍历,list通常是下策。“以后可能会在中间插入”这句话不能成为无脑选list的理由。很多中间插入的需求,如果插入次数不频繁,用vector甚至deque反而整体更快,因为遍历和随机访问的收益远超偶尔插入消耗的成本。
4.3 选型建议:什么情况下果断上deque
根据我这些年的实际体验,选型逻辑大致是这样:
- 主要在尾部追加、偶尔访问所有元素的,选vector;
- 头尾都要频繁插入删除、同时还需要随时按下标访问的,选deque;
- 中间需要频繁插入删除、且对遍历性能不敏感的,选list;
- 只要一头一尾两种操作、不按下标访问的,其实
std::queue和std::stack底层就已经默认用deque了,直接用适配器就行。
另外有个不算冷的知识:deque没有reserve和capacity。因为它不是一整块连续内存,不存在“预分配一整块大空间”的概念。但它有shrink_to_fit()(C++11起),会建议标准库释放不再使用的缓冲区。注意这个函数是“建议性”的,标准库实现可以忽略它,不要依赖它一定生效。
5. 藏在适配器里的deque:std::stack和std::queue的默认底座
5.1 为什么默认容器偏偏是deque
有一个细节可能很多初学者没注意:std::stack和std::queue这两个容器适配器的默认底层容器都是deque,而不是vector或list。这不是随机选的,而是基于两种适配器的操作模式做的设计。
stack需要的是LIFO,只在一端操作。vector也能做stack,但由于vector扩容时要搬元素,偶尔会有一次较大开销;list可以,但节点缓存不友好。deque两头操作都O(1)、扩容不搬元素、遍历又比list快,所以是最平衡的选择。
queue需要的是FIFO,一端入、一端出。如果拿vector实现queue,每次出队都要把头元素删掉,把后面所有元素往前搬,O(n)的操作频繁发生,非常浪费。如果拿list实现queue,又掉回缓存不友好的老问题。deque天然就是为这种“头和尾同时操作”设计的,所以它成为queue的默认底座可以说是天经地义。
5.2 用deque实现stack、queue的简单示例
直接看用法:
#include <stack> #include <queue> #include <iostream> int main() { std::stack<int> st; st.push(1); st.push(2); std::cout << st.top() << "\n"; // 2 st.pop(); // 删除2 std::queue<int> q; q.push(1); q.push(2); std::cout << q.front() << "\n"; // 1 std::cout << q.back() << "\n"; // 2 q.pop(); // 删除1 }如果想显式指定底层容器,模板的第二个参数就能换:
std::stack<int, std::vector<int>> st2; std::queue<int, std::list<int>> q2;我个人不建议在绝大多数场景下换掉默认的deque。除非你明确知道这个stack要被反复拷贝很多次(vector的拷贝可能比deque快),或者你对缓存命中率有极其极端的追求,否则默认选择就是最优解。
5.3 双端历史与任务调度的真实玩法
stack和queue是deque最常见的“官方应用”,但deque本身更灵活。我之前在一个小工具里用它做操作历史记录:用户每执行一个操作就push_back一条记录,一旦历史记录超过100条就从头部pop_front掉最老的记录。用vector实现这个需求会很别扭,因为头部删除会触发整体搬移;用list又怕遍历历史时缓存太差。deque刚好两头都高效。
另一个场景是任务调度里的双端任务窃取(work stealing)。多线程环境下,每个线程维护一个deque作为任务队列,本地线程从尾部取任务,空闲线程从别的队列头部窃取任务。这正好用上了deque两头都能O(1)操作的特点。当然多线程下要加锁或者用无锁实现,但数据结构层面,deque是这类方案的天然底座。
6. 实战中踩过的deque的坑,以及绕坑指南
6.1 迭代器失效规则和vector完全不一样
这是我在实际开发里吃过亏的地方。vector的insert/erase会使指定位置之后的迭代器失效,但deque的规则复杂得多,一定不要想当然:
- 在中间insert:所有迭代器都失效。如果中控器发生重新分配,所有引用和指针也失效。
- 在两端push/pop:所有迭代器失效,但指向已有元素的引用和指针仍然有效。
- 在两端erase:只有被删除元素的迭代器和引用失效。
换句话说,如果你在两段push了一个元素,你手里的auto it = dq.begin()可能已经废掉了,不能再解引用,但auto& ref = dq[0]这根引用还能安全使用。我见过同事把deque当vector写,在push_back之后继续用之前保存的迭代器进行遍历,结果输出乱序、偶尔还崩。这类问题的排查在大型工程里非常费时间,写代码之前先把失效规则刻进脑子里,比什么都重要。
6.2 没有data()、没有reserve,别期待它像vector
deque没有data()成员函数,因为它的元素不是连续存放的。很多要跟C风格API打交道的代码(比如void foo(const int* p))无法直接对deque调用,你得先把元素拷到vector里再传指针。这不是bug,是设计使然,别浪费时间去翻标准库有没有隐藏的取地址接口。
同时,没有capacity()就意味着一件事:不要用deque存储需要极高性能的固定大数组,也不要频繁做大对象的中等规模随机访问。追求极致线性地址空间的场景,直接选vector,不要犹豫。
6.3 at()与operator[]的越界差异
我见过不少新手在deque上用[]访问一个不存在的下标,程序没有立刻报错,但数据已经被写进了一片错误的内存区域,残留的bug在数小时后才爆发。相比之下at()会抛std::out_of_range异常,至少能让错误在第一时间暴露。
所以我的建议是:调试阶段尽量用at(),跑性能测试或者确定越界不可能发生时再用[]。这个原则对vector同样适用,但在deque上尤其重要,因为deque的内部寻址多了一层映射,越界后写坏的位置更难直观判断。
6.4 频繁两端操作的内存分配陷阱
deque两端插入虽然快,但每一次头部扩容(缓冲区满时)都会产生一次小块内存分配。如果push_front和pop_front交替进行几十万次,系统会频繁地分配和释放小块缓冲区,造成性能抖动和潜在的堆碎片。
有一种缓解方式是控制操作模式:尽量避免“每次只进出几个元素就触发一次缓冲区切换”的病态模式。如果确实需要高频且可预测的双端操作,可以考虑自己实现一个环形缓冲区的队列,而不是直接用deque。
另外,pop_front不会自动把空缓冲区立即归还给操作系统,这些缓冲区一般会留在中控器里缓存复用。所以如果你在很长一段时间内分批处理大量任务,deque的常驻内存可能会比你预期的高一些。连续处理完后再调用shrink_to_fit(),能释放不少空间。
最后分享一条我自己的习惯:在工程里遇到“缓冲区”“窗口”“队列历史”这类词,我都会先把deque列进候选项。它不像vector那样到处刷存在感,也不像list那样总被新手低估,但在合适的场景里,它确实是STL容器里最省心的那一个。