目录
摘要:
一:stack和queue叫适配器的原因
二:stack和queue的写法固定的原因
1:stack实现代码
2:queue实现代码
3:写法固定的原因
三:deque的优缺点
1:deque的优点
2:deque的结构
3:deqeu的缺点
四:deque和vector的排序速度
五:总结deque
摘要:
本博客围绕STL库中的stack和queue进行讲解,为什么二者不是容器类而是适配器?为什么二者模拟实现时代码是写死的,不能改变?为什么二者底层默认的容器是deque,可以随意切换吗?deque没有缺点吗?
一:stack和queue叫适配器的原因
四段话理解为什么stack和queue叫作容器适配器!
①:在 STL 的官方分类中,string、vector、list属于容器(Container),而stack与queue却被归类为容器适配器(Container Adapter)。这并非只有命名上区别,而是二者在设计上的不同!
②:如何理解"适配器"?不妨回想数据结构中:实现顺序表或链表时,我们需要独立管理内存、处理增删改查;但实现栈和队列时,我们往往直接复用已写好的顺序表或链表,仅对其操作接口加以限制——栈只允许在一端进出,队列只允许一端入另一端出。STL 的设计者采用了完全相同的思路。
③:正因如此,STL 并没有为stack和queue重新设计一套内存管理方案,而是在其内部封装了一个底层容器对象(默认是deque)。stack和queue自身不存储任何数据,所有数据的增删改查均由底层容器的成员函数代为完成,它们只做一件事:将底层容器的通用接口(如push_back、pop_back)包装成符合栈或队列语义的专用接口。
④:这便是适配器——不创造新能力,只转换旧接口。这种设计既避免了代码冗余,又赋予了使用者灵活切换底层容器的自由(如将vector或list作为stack的底层容器)因此,stack与queue不是容器,而是容器的"皮肤",它们依赖容器而存在,却定义了容器在特定场景下的使用规则。
理解了stack和queue叫作容器适配器的原因,则需要注意以下几个点:
接口封装层面:模拟实现stack和queue时,类的成员函数直接调用底层封装容器的对应接口(如push调用push_back,pop调用pop_back)。这些调用的逻辑意图是固定的(维护 LIFO 或 FIFO 特性),但具体调用的底层函数由模板参数所指定的容器类型决定。
功能裁剪层面:stack和queue不提供迭代器,因为迭代器会暴露底层容器的内部元素,破坏 LIFO/FIFO 的访问约束。对于默认成员函数,我们大部分情况下,是不用自己实现的,因为底层容器自带默认成员函数!
底层可配层面:STL 中stack和queue默认封装的是deque容器。但底层容器并非固定不变:stack和queue的类模板中设计了专门的类型参数(通常命名为Container),并赋予其缺省值deque<T>,使用者可根据需求自由替换为vector或list。这种设计使得适配器在不改变外部接口的前提下,获得了底层存储策略的灵活可配性。
二:stack和queue的写法固定的原因
我们在实现satck和queue的时候,非常简单,代码是固定的!
1:stack实现代码
namespace cl //防止命名冲突 { template<class T, class Container = std::deque<T>> class stack { public: //元素入栈 void push(const T& x) { _con.push_back(x); } //元素出栈 void pop() { _con.pop_back(); } //获取栈顶元素 T& top() { return _con.back(); } const T& top() const { return _con.back(); } //获取栈中有效元素个数 size_t size() const { return _con.size(); } //判断栈是否为空 bool empty() const { return _con.empty(); } //交换两个栈中的数据 void swap(stack<T, Container>& st) { _con.swap(st._con); } private: Container _con; }; }2:queue实现代码
namespace cl //防止命名冲突 { template<class T, class Container = std::deque<T>> class queue { public: //队尾入队列 void push(const T& x) { _con.push_back(x); } //队头出队列 void pop() { _con.pop_front(); } //获取队头元素 T& front() { return _con.front(); } const T& front() const { return _con.front(); } //获取队尾元素 T& back() { return _con.back(); } const T& back() const { return _con.back(); } //获取队列中有效元素个数 size_t size() const { return _con.size(); } //判断队列是否为空 bool empty() const { return _con.empty(); } //交换两个队列中的数据 void swap(queue<T, Container>& q) { _con.swap(q._con); } private: Container _con; }; }3:写法固定的原因
如上所示,我们在实现stack和queue时,代码非常简单且形式固定。无论谁来写,成员函数的命名和函数体内的调用都是一成不变的:
stack的push调用push_back,pop调用pop_back,top调用backqueue的push调用push_back,pop调用pop_front,front调用front
💡:不是不能变,而是不变是最好的。为什么?
因为这种固定的写法,本质上是对底层容器提出了一套接口要求。以stack为例:一个容器若想作为它的底层,必须支持push_back()、pop_back()、back()三个操作。这意味着该容器必须具备尾部高效操作的能力——尾插 O(1)O(1)、尾删 O(1)O(1)、尾访问 O(1)O(1)。
而这恰恰就是栈所需要的全部能力。栈的核心语义是"后进先出",所有操作都发生在同一端(栈顶),对应到底层就是尾部。所以这种固定写法精准匹配了栈的操作模式。
❓️:为什么容器支持了push_back()、pop_back()、back()三个操作。就意味着该容器具备尾部高效操作的能力?
💡:这就是STL设计容器类时的智慧了!STL 中的容器类,只实现自己擅长且高效的操作。比如vector,它实现了push_back和pop_back,因为尾部增删对它来说是 O(1)O(1) 的拿手好戏;但它不会实现push_front和pop_front,因为头部增删需要挪动所有元素,代价是 O(n)O(n)。STL 宁愿让你用insert和erase间接实现头插头删,也不愿提供一个"低效"的接口来误导使用者。所以:一个容器能否作为stack的底层,取决于它是否在尾部操作上足够优秀。vector、deque、list都满足,所以都可以。
❗️:所以实现代码不变,其实是一种筛选机制,筛选出符合效率的容器类!
但如果我们强行破坏这种固定写法呢?比如非要让vector作为queue的底层——queue需要pop_front(),而vector没有。我们就尝试用erase间接实现头删,的确让代码跑起来了。但代价是什么?原本queue的出队应该是 O(1)O(1) 的常数操作,被你硬生生变成了 O(n)O(n) 的线性操作,栈和队列的效率优势瞬间荡然无存。
所以,代码"固定"的本质,是强制底层容器必须提供高效匹配的接口。 这种约束保证了适配器始终运行在最优效率下,也防止了使用者误用低效容器导致性能崩盘。这也正是 STL 将stack和queue设计为容器适配器而非独立容器的深层考量——复用容器的存储能力,同时用固定的接口约束来保证语义和效率的双重正确性。
适配的容器类:
stack 底层类可以是:deque(默认)、vector、list
queue 底层类可以是:deque(默认)、list标准库只推荐用 deque 和 list,不推荐自定义容器——除非你自己保证复杂度!
❓️:我理解了在写法固定情况下,若一个容器类能适配stack或queue,就能保证stack/queue的效率!但是容器都有优缺点,难道stack和queue就能避开底层适配的容器类的缺点了吗?
💡:是的,避开了!
stack只使用底层容器的尾部操作(push_back/pop_back),queue只使用头尾两端操作(push_back/pop_front)
这意味着:
vector头插慢的缺点,stack永远用不到,所以这个缺点对stack而言不存在。list不支持随机访问的缺点,stack和queue也永远用不到(因为它们根本不支持下标访问)。
一句话总结:适配器通过限制操作接口,让使用者只能走“高效通道”,从而天然避开了底层容器在其他方向上的性能短板。
三:deque的优缺点
1:deque的优点
vector:
优点:下标随机访问
缺点:中间,头部的插入删除效率低;扩容有消耗,异地需挪动,空间有浪费
list:
优点:任意位置插入删除效率高;扩容无消耗,按需申请释放
缺点:不支持下标随机访问
而deque:double-ended queue(双端队列),就是综合了二者的优点的数据结构,其是C++ STL 中的一个序列容器,允许在两端(头部和尾部)以及随机访问的快速的插入和删除操作,并且缓存命中率高于list,这也是为什么stack和queue都默认使用它的原因!
2:deque的结构
那deqeu真的这么牛吗?真的这么牛的话,为什么数据结构不学呢,直接淘汰vector和list只学deque不就好了?当然不是,世界上没有完美的东西,所以deque的缺点也是极其严重的!!
下面我们探究deque的底层结构:
deque 并不是真正连续的空间,而是由一段段连续的小空间拼接而成的,实际的 deque 类似于一个动态的二维数组,其底层结构如下所示:
解释:
①:双端队列底层是一个假想的连续空间,实际是分段连续的,所以缓存命中率虽然高于list,但仍然低于vector!
②:map是一个指针数组,其每个元素指向一个数组,当map满了也需要扩容!
为了维护其 "整体连续" 、以及随机访问的假象,其重任落在了 deque 的迭代器身上。因此 deque 的迭代器设计就尤为复杂,如下图所示:
解释:
①:deque的迭代器类中,有四个指针!极其复杂!
②:cur指向当前遍历到的元素,first指向当前buffer的起始地址,last指向当前buffer的末尾元素地址,而node指向map的一个元素
③:而map的元素是指针类型,所以node就是一个二级指针类型
源码中可见迭代器类的四个指针变量如下:
可见,node就是一个二级指针类型!
所以deque的底层结构总的如下:
解释:
❓️:如何随机访问[ ]呢?
💡:每个buffer都是定长的,所以根据索引的下标进行取余取模运算,就知道是第几个buffer的第几个元素!但是这里的除法/取模运算会带来一定开销,所以仍然不及vector的效率!
❓️:如何插入删除呢?
💡:头插则新开辟一个buffer,尾插有空间直接放,没空间也要扩容开辟新的buffer,最难崩的就是中间插入,这意味着我们要挪动多个buffer内的数据,且涉及多个 buffer 的跨块移动,消耗巨大!!;而删除中间数据,也要挪动数据,消耗同样巨大!
❓️:插入删除不能原地进行吗?也就是原地扩容缩容,不用移动其他buffer的数据了!
💡:进行原地扩容缩容,这就意味着每个buffer的大小不一致的,那取模取余实现随机访问不存在了!随机访问的公式立刻失效,退化为链表式的低效率顺序遍历。
❓️:头插是要开辟新的buffer的,那现在假设第一个buffer只有头插的那一个数据,那现在怎么进行随机访问[ ]呢?
💡:所以,deque不是直接用下标对buffer_size取模,而是先减去第一个 buffer 的起始偏移,再做除法。
3:deqeu的缺点
所以缺点如下:
① 中间插入/删除极慢
在deque中间插入或删除元素,需要挪动插入点之后(或之前)的大量数据,且这些数据可能横跨多个非连续的 buffer,涉及跨块复制,效率远低于vector的整块内存移动,复杂度为 O(n)O(n) 且常数因子较大。
② 随机访问的常数因子较大
相比vector的“基址 + 偏移”一次寻址,deque的随机访问需要先除法取模定位 buffer,再通过中控数组二次寻址。除法指令本身较慢,频繁随机访问时性能不如vector。
③ 内存不连续,遍历时缓存不友好
虽然各 buffer 内部是连续的,但 buffer 之间在物理地址上并不相邻。遍历deque时,跨越 buffer 边界会导致 CPU 缓存失效(cache miss),迭代速度慢于连续内存的vector。
④ 额外的空间开销
中控数组本身需要额外存储指针。当存储的元素较小时(如int),中控数组的开销占比会变得显著,不如vector紧凑。
需要明白的是,deque 是一个专为头尾操作优化的选手,在需要双端高频增删且偶尔随机访问的场景下表现优异;但其中间插入删除的低效和遍历时的缓存不友好决定了它不适合频繁中间插入或大量遍历的场合。理解这些取舍,才能在不同场景下做出正确的容器选择。
而我们的stack和list不就只是单纯的需要头尾的插入删除吗?这也是为什么选择deqeu的原因!依旧是恰好避开了deque的缺点!!
四:deque和vector的排序速度
两个代码体现deque的效率低下:
test_op1():直接对比vector和deque的排序效率
在完全相同的随机数据集上,分别对vector和deque进行排序,对比二者的耗时差异。
test_op2():验证"拷贝后排序"策略是否有效
既然deque排序慢,那先把deque的数据拷贝到vector,排完序再拷回来,总耗时会不会比直接在deque上排序更快?
#include <iostream> #include <vector> #include <deque> #include <algorithm> #include <ctime> #include <cstdlib> #include <cstdio> using namespace std; void test_op1() { srand((unsigned int)time(nullptr)); const int N = 1000000; deque<int> dq; vector<int> v; // 预留空间,避免 vector 频繁扩容影响测试结果 v.reserve(N); for (int i = 0; i < N; ++i) { auto e = rand() + i; v.push_back(e); dq.push_back(e); } int begin1 = clock(); sort(v.begin(), v.end()); int end1 = clock(); int begin2 = clock(); sort(dq.begin(), dq.end()); int end2 = clock(); printf("vector sort time: %d ms\n", end1 - begin1); printf("deque sort time: %d ms\n", end2 - begin2); printf("性能差距: %.2f 倍\n", (double)(end2 - begin2) / (end1 - begin1)); } void test_op2() { srand((unsigned int)time(nullptr)); const int N = 1000000; deque<int> dq1; deque<int> dq2; for (int i = 0; i < N; ++i) { auto e = rand() + i; dq1.push_back(e); dq2.push_back(e); } int begin1 = clock(); sort(dq1.begin(), dq1.end()); int end1 = clock(); int begin2 = clock(); // 拷贝到 vector 排序,再拷回 deque vector<int> v(dq2.begin(), dq2.end()); sort(v.begin(), v.end()); dq2.assign(v.begin(), v.end()); int end2 = clock(); printf("直接在 deque 上排序: %d ms\n", end1 - begin1); printf("拷贝到 vector 排序后拷回: %d ms\n", end2 - begin2); printf("性能差距: %.2f 倍\n", (double)(end1 - begin1) / (end2 - begin2)); } int main() { printf("========== test_op1: vector vs deque 排序 ==========\n"); test_op1(); printf("\n========== test_op2: deque 直接排序 vs 拷贝后排序 ==========\n"); test_op2(); return 0; }导致排序慢的原因,就是deque的缺点所致!
五:总结deque
| 维度 | 具体表现 | 底层原因 | 对标容器 |
|---|---|---|---|
| 头尾插入/删除 | ✅ 效率极高,均为 O(1)O(1) | 中控数组 + 定长 buffer,头尾操作无需移动任何元素 | 优于vector(头插慢),与list持平 |
| 随机访问 | ✅ 支持 O(1)O(1) 下标访问 | 通过除法/取模定位 buffer + 中控数组二次寻址 | 弱于vector(一次寻址),强于list(不支持) |
| 扩容代价 | ✅ 低,近似 O(1)O(1) | 仅需在中控数组中新增指针,已有元素不动 | 优于vector(需拷贝/移动所有元素) |
| 内存利用率 | ✅ 按需分配,空间浪费少 | 按需开辟 buffer,无需预留大量闲置空间 | 优于vector(可能预分配过多) |
| 缓存命中率 | ⚠️ 中等 | buffer 内部连续,但跨 buffer 跳跃导致 cache miss | 优于list(完全离散),弱于vector(完全连续) |
| 中间插入/删除 | ❌ 极慢,O(n)O(n) 且常数因子大 | 需跨多个 buffer 挪动数据,涉及跨块复制 | 弱于list(O(1)O(1)),弱于vector(单块内存移动) |
| 遍历效率 | ⚠️ 一般 | buffer 边界处缓存失效 | 弱于vector,强于list |
| 空间开销 | ⚠️ 额外开销 | 中控数组存储指针,小元素时占比显著 | 弱于vector(更紧凑),优于list(每个节点两个指针) |
deque是一个专为头尾操作优化的全能型选手,它综合了vector的随机访问能力和list的头尾高效增删能力,但在中间操作和纯遍历场景下均不如vector。正因stack和queue只涉及头尾操作,完美避开deque的短板,所以deque成为它们的默认底层容器。😊
📌 [ 作者 ] shylyly
📃 [ 首次发布 ] 2026.9.2
❌ [ 最新修改 ] 2026.10.3
📜 [ 声明 ] 由于笔者水平有限,文中难免有疏漏或不妥之处,还望读者不吝赐教