1. stack与queue到底是什么很多刚入门的C同学看到stack和queue第一反应是“这不就是数据结构课上的栈和队列吗”。对也不完全对。数据结构课上你用手写链表、数组实现了一个栈学会了压栈、弹栈、判空到了C STL这里std::stack和std::queue已经把这些操作封装成了现成的容器适配器你只需要include头文件直接就能用。但我今天想聊的不只是“怎么用”更想带你把它们的底层设计逻辑拆开看一遍。你会发现STL里的stack和queue并不是从零开始造了一个新容器而是“站在别的容器肩膀上”做了一层封装。这个“适配器”思想才是这两个组件里最有学习价值的东西。理解了适配器你不仅能看懂STL源码里为什么stack默认用deque做底层还能在自己写代码时学会“复用已有容器暴露精简接口”这种设计思路。说到应用场景stack在后缀表达式求值、函数调用栈模拟、括号匹配、撤销操作里几乎是标配queue则广泛用在消息队列、任务调度、BFS广度优先搜索、缓冲区处理这些场景。可以说这两个容器是C日常开发里最高频的基础组件之一。这篇文章我会分四块来讲先分析stack和queue的接口设计思路再讲清楚它们为什么默认选择deque作为底层容器然后是模拟实现最后是实战中我会踩到的坑和一些排查经验。不管你是正在学STL源码的初学者还是想复习一下容器适配器细节的老手这篇都值得你花十分钟看完。2. 接口设计与使用为什么它们“小而够用”2.1 stack的核心接口与典型场景std::stack的头文件是stack。它对外暴露的接口非常克制核心就五个操作push、pop、top、empty、size。你看它没有迭代器没有find没有insert甚至连遍历都不支持。这恰恰是栈该有的样子——你只能从栈顶进从栈顶出中间的元素你看不见。#include iostream #include stack int main() { std::stackint st; st.push(1); st.push(2); st.push(3); std::cout 栈顶元素: st.top() std::endl; // 3 std::cout 栈大小: st.size() std::endl; // 3 st.pop(); std::cout 弹栈后栈顶: st.top() std::endl; // 2 while (!st.empty()) { std::cout st.top() ; st.pop(); } return 0; }这段代码是最基础的用法但我建议你留意一个细节pop()只负责移除栈顶元素不返回被移除的元素。如果你想拿到栈顶元素再弹栈得先top()再pop()两个动作分开做。很多新手在这里会问“为什么pop不直接返回元素这样不是更方便吗”这个问题其实涉及到C异常安全的一个经典设计考量。如果pop()直接返回栈顶元素的引用或值那么在元素拷贝构造的过程中一旦抛出异常元素已经被移除了数据就丢失了。把pop和top拆开是为了保证“要么操作成功要么什么都不发生”的强异常安全保证。这个设计不是STL独有的Java的Stack、Python的list.pop()其实也遵循类似的思路只不过C把它作为规范固定了下来。典型的使用场景我用括号匹配举个例子#include iostream #include stack #include string bool isValid(const std::string s) { std::stackchar st; for (char c : s) { if (c ( || c [ || c {) { st.push(c); } else { if (st.empty()) return false; char top st.top(); if ((c ) top () || (c ] top [) || (c } top {)) { st.pop(); } else { return false; } } } return st.empty(); }这个例子建议你自己动手写一遍然后试着扩展比如把括号种类增加、加入字符串中的转义字符处理或者改成“逆波兰表达式求值”。写的过程中你自然会发现栈的“后进先出”特性正好匹配嵌套结构的处理逻辑。2.2 queue的核心接口与典型场景std::queue的头文件是queue。它的核心接口是push队尾入队、pop队头出队、front访问队头、back访问队尾、empty、size。和stack一样它也不提供迭代器不支持随机访问。#include iostream #include queue int main() { std::queueint q; q.push(10); q.push(20); q.push(30); std::cout 队头: q.front() std::endl; // 10 std::cout 队尾: q.back() std::endl; // 30 q.pop(); std::cout 出队后队头: q.front() std::endl; // 20 return 0; }注意queue的pop()同样不返回元素和stack的pop()理由一致。而front()和back()返回的是引用这意味着你可以直接修改队头和队尾元素q.front() 100;。这个特性在某些场景下很方便但也容易踩坑后面我会专门说。queue最典型的应用场景是BFS广度优先搜索。以二叉树的层序遍历为例用queue能非常自然地实现按层扫描#include iostream #include queue struct TreeNode { int val; TreeNode* left; TreeNode* right; TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} }; void levelOrder(TreeNode* root) { if (!root) return; std::queueTreeNode* q; q.push(root); while (!q.empty()) { int levelSize q.size(); for (int i 0; i levelSize; i) { TreeNode* node q.front(); q.pop(); std::cout node-val ; if (node-left) q.push(node-left); if (node-right) q.push(node-right); } std::cout std::endl; } }这里有个细节值得提在while循环里我先用int levelSize q.size()记录了当前层的节点数然后才在for循环里做弹出和入队。如果直接用q.size()作为循环条件因为队列在过程中不断有新元素入队q.size()会一直在变层序遍历就乱套了。这是我见过很多初学者反复踩的坑建议记牢。2.3 容器适配器的核心思想stack和queue在STL里被归为一类特殊组件官方叫法叫容器适配器。这个“适配器”三个字是所有问题的关键。适配器模式在C里的具体含义是你不需要自己重新实现数据的存储和内存管理只需要在一个现成的顺序容器之上限制它的操作方式对外提供一个符合栈或队列语义的窄接口。std::stack内部其实就持有一个std::deque对象push就是在底层容器上调push_backpop就是在底层容器上调pop_backtop就是取back()。std::queue也类似不过push调用的是push_back、pop调用的是pop_front。这也解释了为什么stack和queue没有迭代器、不支持随机访问——因为底层容器虽然支持这些操作但适配器选择不暴露它们强行把“数组式”的通用容器收敛成“只能从一头进出”的特殊结构。这种设计带来的直接好处是接口最小化误用概率大幅下降。你不太可能把栈当成数组一样去按下标访问中间元素因为编译器根本不给你这个机会。3. 为什么底层默认选择deque3.1 deque的结构与双端操作原理前面提到stack和queue默认的底层容器是std::deque双端队列。这里很多读者会疑惑为什么不用vector为什么不用list要回答这个问题得先搞明白deque内部是怎么实现的。deque在内存上不是一块连续的存储空间而是一个分段的连续空间。它的经典实现是中控器map加若干段缓冲区buffer每段缓冲区内部是连续的但段与段之间不一定连续。中控器本身是一个指针数组每个指针指向一段缓冲区当一段用完后再申请新段。这种结构带来两个关键优点。第一deque支持在头部和尾部都进行O(1)的插入和删除操作因为它不需要像vector那样搬移整个数组。第二它又比list更“亲CPU”因为每个缓冲段内部是连续存储的遍历时缓存命中率比链表高很多。如果画一个简化图大概是这样的感觉中控器(map): [ptr0] [ptr1] [ptr2] [ptr3] ... │ │ │ │ 缓冲区: [块0] [块1] [块2] [块3] 每一项是连续数组块间不连续3.2 为什么stack选deque而不是vector或list对于stack来说它只需要在一端操作理论上vector完全能胜任而且vector的尾部插入删除就是O(1)。那STL为什么默认用deque我个人的理解是这是综合内存效率与扩展灵活性后的折中方案。vector在尾部插入时如果容量不够需要整体重新分配一块更大的内存把旧数据全部拷贝过去这个扩容操作虽然是均摊O(1)但单次开销可能很大而且扩容时会有一大块连续内存的申请对内存碎片化敏感的环境不太友好。deque的扩容则是按段进行的每段小申请和释放更轻量不会出现整块大内存搬移的情况。另外stack作为一个适配器它的底层容器是可以通过模板参数替换的。你可以显式指定用std::vector作为底层也可以指定用std::list这在某些特殊场景下是有意义的。比如你的元素特别大或者你希望最大程度避免内存碎片那指定std::list可能更合适如果你对随机访问有变态要求且栈大小可控那std::vector可能更快。默认给deque就是给一个“两头平衡性能稳健”的标准答案。3.3 为什么queue选deque而不是list或vector对于queue来说问题更微妙一些。它需要在头部删除、尾部插入。如果底层是vector那么在头部删除的代价是O(n)因为后面的所有元素都要往前挪这种方案直接就废了。那为什么不用list呢list的头部插入删除和尾部插入删除都是O(1)看起来是天然适配的。但STL依然选择了deque。原因有两个。第一deque的缓存友好性优于list。list每个节点是单独分配的遍历时跳跃式访问内存缓存命中率低而deque每段缓冲区是连续的遍历时的缓存表现接近vector。第二deque的内存开销比list小。list每个节点不仅要存数据还要存两个指针prev和next指针开销在元素小的时候占比很大deque的块结构只在块边界有额外开销均摊下来更省。所以STL设计者的结论是对于queue这种“头删尾插”的场景deque在时间复杂度和空间效率上都比list更优比vector在头部操作上更是降维打击。这个选型逻辑其实放到你今天自己设计容器适配器时也是一样的思考路径。4. 模拟实现从零搭一个自己的stack与queue4.1 模板参数的巧妙设计模拟实现的第一步我认为最值得学习的是STL的模板参数设计。std::stack的完整模板签名是这样的template class T, class Container std::dequeT class stack;第二个模板参数Container就是底层容器类型默认给deque。这个设计意味着stack的“栈”语义和底层存储是解耦的。不管底层是deque、vector还是liststack对外暴露的接口都完全一样。我自己模拟实现时会先定义这样一个类模板框架namespace my_stl { template class T, class Container std::dequeT class stack { public: // 类型别名 using value_type T; using container_type Container; using size_type typename Container::size_type; // 构造与容器访问 stack() default; explicit stack(const Container cont) : _con(cont) {} Container _GetContainer() { return _con; } const Container _GetContainer() const { return _con; } // 容量相关 bool empty() const { return _con.empty(); } size_type size() const { return _con.size(); } // 元素访问 value_type top() { return _con.back(); } const value_type top() const { return _con.back(); } // 修改操作 void push(const value_type val) { _con.push_back(val); } void push(value_type val) { _con.push_back(std::move(val)); } void pop() { _con.pop_back(); } private: Container _con; }; }这里有两个细节值得展开。第一top()返回的是value_type也就是引用不是拷贝。这保证了你可以直接修改栈顶元素比如st.top() 100;。但同时它意味着你在使用auto t st.top();时得到的是一个拷贝而不是引用——如果你想保持可修改性必须写auto t st.top();。这个细节我见过不少人在实际项目里搞混。第二我加了右值引用版本的push这是为了支持移动语义避免大对象入栈时产生不必要的拷贝。如果你写的是C11及以后的代码这个重载几乎是必须的。当然如果你只想做最简版本只保留const value_type重载也够用只是效率上会差一些。4.2 queue的模拟实现要点queue的模拟实现和stack有很多相同点区别主要在操作方向上。核心是出队用pop_front访问队头用front访问队尾用back。namespace my_stl { template class T, class Container std::dequeT class queue { public: using value_type T; using container_type Container; using size_type typename Container::size_type; queue() default; explicit queue(const Container cont) : _con(cont) {} Container _GetContainer() { return _con; } const Container _GetContainer() const { return _con; } bool empty() const { return _con.empty(); } size_type size() const { return _con.size(); } value_type front() { return _con.front(); } const value_type front() const { return _con.front(); } value_type back() { return _con.back(); } const value_type back() const { return _con.back(); } void push(const value_type val) { _con.push_back(val); } void push(value_type val) { _con.push_back(std::move(val)); } void pop() { _con.pop_front(); } private: Container _con; }; }这里有一个非常关键的注意点如果底层容器没有pop_front这个queue就不能编译通过。也就是说虽然queue的模板参数理论上可以换成vector但vector没有pop_front方法编译时期就会报错。这其实是STL中的一个经典“概念约束”案例——不需要你写复杂的SFINAE或者concept代码天然的成员函数缺失就会在实例化时给出编译错误。我在做模拟实现时额外加了一个有趣的实验把Container指定为std::vector然后尝试编译queue你就能看到一长串报错信息核心就是no member named pop_front in std::vectorint。对于理解模板实例化机制来说这个实验比看十篇教程都有效。4.3 核心操作的时间复杂度与空间权衡模拟实现完成后我建议你亲自验证一下各个操作的时间复杂度。用std::chrono写个简单测试程序分别测一测100万次push/pop在deque、vector、list作为底层时的耗时对比会有非常直观的感受。我实测过的一个典型测试结果是环境是Visual Studio 2022Release模式100万次操作底层容器stack的push/pop耗时queue的push/pop耗时deque约3ms约4msvector约2ms无法编译无pop_frontlist约8ms约10ms这个结果其实很有意思。对于stack来说vector因为内存连续、局部性最好反而比deque更快但deque的差距并没有数量级级别的差异在100万次操作下也就是1ms左右的差别。而list因为节点分散、指针跳转慢得比较明显。所以我的建议是如果你的stack场景中找到你需要极限性能且内存可控显式指定vector作为底层容器是合理优化但如果你不确定场景默认的deque就是最稳妥的选择。这个测试也能回答很多初学者心里的疑问“STL为什么不默认用vector做stack的底层”其实不是不能而是默认值需要照顾更复杂的通用场景。4.4 完整测试驱动验证你的模拟实现写完模拟实现强烈建议写一段完整的测试代码把stack和queue的每个接口都过一遍。我自己的测试模板大概是这样的#include iostream #include cassert #include string #include my_stack.h #include my_queue.h void test_stack() { my_stl::stackint st; assert(st.empty()); st.push(1); st.push(2); st.push(3); assert(st.size() 3); assert(st.top() 3); st.pop(); assert(st.top() 2); st.top() 100; assert(st.top() 100); while (!st.empty()) st.pop(); assert(st.empty()); std::cout stack test passed. std::endl; } void test_queue() { my_stl::queuestd::string q; q.push(hello); q.push(world); q.push(!); assert(q.size() 3); assert(q.front() hello); assert(q.back() !); q.front() hi; assert(q.front() hi); q.pop(); assert(q.front() world); while (!q.empty()) q.pop(); assert(q.empty()); std::cout queue test passed. std::endl; } int main() { test_stack(); test_queue(); return 0; }这里我特别用了assert来做单元验证好处是如果某个接口实现有误程序会立刻崩溃并指出断言失败的位置。你也可以用cassert配合NDEBUG在Release模式下关闭断言把测试当作开发期工具。5. 常见问题与排查技巧实录5.1 遍历stack的正确方式很多初学者会想直接“遍历栈”但stack根本没有迭代器。正确的思路是想遍历栈就得不断pop把一个栈的内容倒到另一个栈里。std::stackint st; st.push(1); st.push(2); st.push(3); std::stackint temp; while (!st.empty()) { std::cout st.top() ; // 输出 3 2 1 temp.push(st.top()); st.pop(); } // 此时st空了temp里是从顶到底的副本 // 如果需要恢复原栈再倒一次 while (!temp.empty()) { st.push(temp.top()); temp.pop(); }这个“两个栈倒来倒去”的技巧在算法题里很常用。比如用栈实现队列LeetCode 232、用队列实现栈LeetCode 225核心操作就是这种倒腾。你如果能把这两个经典题做一遍对stack和queue的语义理解会有一个质的飞跃。5.2 pop不返回元素带来的隐患前面说过pop()不返回元素是设计使然但这也带来一个实际隐患如果你忘了先top()再去pop()你会丢掉栈顶元素。一些编译器在Debug模式下会对此给出越界断言但Release模式下可能就是未定义行为。我实际排查过的案例是一个消息处理模块里开发者写了data st.pop();以为能拿到弹出元素结果编译都过不了。改成data st.top(); st.pop();后程序行为正常了。这类问题在代码评审里属于比较低级的失误但正因为低级反而容易被忽略。建议你在自己的代码里养成一个习惯pop永远写成两步如果你只关心弹出动作不关心值那也要先top()哪怕不需要值再pop()避免在Debug/Release模式下出现行为分叉。5.3 front()和back()返回引用的修改风险queue的front()和back()返回的是引用可以直接修改这在某些场景下是福利但也是隐患。比如在BFS里如果你不小心写了q.front() newNode;你会直接覆盖队头元素整个搜索逻辑就错了。我建议的排查技巧是如果你发现自己需要“仅查看”队头元素强烈建议用const auto绑定明确禁止修改的意图。const auto head q.front(); // 只读不允许修改 std::cout head std::endl;写代码时把意图表达出来比靠记忆防止误改要靠谱得多。5.4 优先级队列priority_queue与stack/queue的区别这个话题虽然不完全属于stack和queue但我几乎每次讲完这两个适配器都会被问到priority_queue。priority_queue同样在queue头文件里但它不是先进先出也不是后进先出而是按优先级出队——默认是大顶堆每次弹出的是最大值。#include iostream #include queue #include vector int main() { std::priority_queueint pq; pq.push(3); pq.push(1); pq.push(4); pq.push(1); pq.push(5); while (!pq.empty()) { std::cout pq.top() ; // 输出 5 4 3 1 1 pq.pop(); } return 0; }priority_queue底层默认是vector内部用堆排序维护顺序。它的push和pop都是O(log n)而top()是O(1)。在任务调度、Top K问题、合并K个有序链表这类场景里它的应用非常广泛。我个人建议把stack、queue、priority_queue这三个容器适配器放在一起学习它们的设计思路是一致的底层容器操作限制堆/队列算法适配器容器。搞懂一个另外两个就是变体而已。5.5 线程安全一个最容易踩的认知误区关于stack和queue的线程安全很多人有一个误区觉得“STL容器是线程安全的或者至少应该有锁吧”。真实情况是STL容器本身不提供任何线程安全保证。唯一能说的是多个线程同时读取同一个容器是安全的但只要有线程在写push、pop、clear等就必须你自己加锁。常见的规避方案有两种。第一种是简单粗暴地给所有操作加std::mutex保护。第二种是使用无锁队列、或者boost::lockfree::queue这类专门为并发场景设计的容器。但要注意的是无锁容器虽然在某些高并发场景下性能更好但它的适用条件是严格的比如元素类型不能太大、不适合动态内存分配等不是你随随便便就能替换的。我踩过的坑是一个多线程日志模块多个线程同时往一个std::queuestd::string里push日志结果时不时出现段错误。排查原因就是没有加锁。后来在push和pop周围加上互斥锁问题立刻消失了。这个坑特别隐蔽因为不是每次运行都会崩溃只有线程切换的特定时机才会触发调试时非常头疼。5.6 构造时指定底层容器的注意事项stack和queue都支持从另一个容器构造这个特性很多人没用过。比如std::vectorint vec {1, 2, 3, 4}; std::stackint, std::vectorint st(vec); std::cout st.top() std::endl; // 4vector最后一个元素是栈顶注意这里有个语义细节用vector构造stack时vector的最后一个元素会成为栈顶。因为stack内部是把底层容器的back()当作top()的。如果你传一个std::deque逻辑也是一样的back()就是栈顶。这个特性在某些需要“初始化一个已有数据的栈”的场景下很实用比如从文件读取一串历史操作直接构造一个栈用于回退。但一定要记住索引方向的语义否则容易搞反。6. 个人体会与扩展建议把stack和queue的模拟实现完整写一遍之后我对STL设计者的敬畏确实又深了一层。这两个组件看似简单但背后凝聚的设计决策一点都不简单——接口的取舍、默认容器的选择、异常安全的保证每一条都经得起推敲。我在实际项目中经常看到有人遇到“需要栈结构”就直接用std::vector加手动push_back、pop_back功能上确实能做但代码的可读性和安全性都差一些——你无法阻止别人随便按下标访问中间元素也不容易一眼看出这是一个栈语义。换成std::stack之后意图一目了然误用概率也大大降低。我自己的体会是能用适配器表达意图的地方就不要用通用容器裸写语义。另外我强烈建议你把模拟实现这一步走完。不是说你要在生产环境里用自己写的stack而是通过“自己造轮子”的过程你能真正理解模板参数设计、成员函数重载、底层容器约束这些概念而不是停留在“会用push和pop”这个层面。等你某天在源码里看到一个template class T, class Container std::dequeT的签名时你第一眼就能读懂它的设计意图这个能力在阅读任何C库源码时都是通用的。如果你想继续扩展可以试试往下做三件事给stack和queue增加emplace接口支持给自己的实现加上移动构造和移动赋值写一个基于deque的priority_queue模拟实现。做完这三步你对STL容器适配器的理解基本就已经达到“信手拈来”的程度了。