
stack 和 queue 这两个东西看起来是 STL 里最“没技术含量”的容器一个后进先出一个先进先出接口加起来不到十个。但真要在面试或工程项目里把它讲清楚、用得对很多人都会卡壳——为什么 stack 的默认底层是 deque 而不是 vector为什么 queue 不提供迭代器为什么单调栈、单调队列这类算法题里C 选手几乎都直接拿 stack 和 deque 当武器这篇内容我会先拆解 stack/queue 的底层设计思路再手写一份可用的模拟实现最后落到三个典型算法场景单调栈、BFS 层序遍历、滑动窗口最大值。整个过程会带出不少实操细节和踩坑记录适合正在学 STL 源码的初学者也适合准备算法面试、想把容器适配器理解透的开发者。1. 整体设计与思路拆解容器适配器到底在适配什么1.1 先搞清楚定位stack/queue 不是容器是“约束”很多人第一次接触 stack 和 queue 时会把它们和 vector、list、deque 并列看待这是最大的认知误区。标准里给它们的定义是 container adapter也就是容器适配器。适配器的意思是我不自己管理内存也不亲手存数据我只是把一个现成的底层容器包装一下只暴露符合“栈语义”或“队列语义”的那几个接口。这个设计的价值在于你用 stack 的时候不需要关心它底层到底是一块连续内存还是一串链表节点你只需要知道push 进去的东西pop 出来时顺序是反的。反过来你也可以随时换掉底层容器只要它满足接口要求就行。也就是说适配器把“数据结构的行为”和“物理存储的方式”解耦了。这个思路放到工程里其实很常见。比如你封装一个缓存模块内部可以先用 vector 实现后面发现并发访问瓶颈在锁又改成无锁队列但对外接口不变调用方完全无感。stack/queue 就是这种“接口稳定、实现可替换”思想的标准化产物。1.2 为什么默认底层是 deque而不是 vector 或 list这是面试高频问题也是理解适配器性能模型的关键。直接说结论deque 在大多数场景下是 stack 和 queue 的最优默认选择。如果底层是 vectorstack 的表现其实还不错因为 stack 只需要在尾部插入和删除vector 尾插尾删均摊 O(1)。但 queue 就完全不同了queue 需要在头部弹出元素而 vector 的头部删除是 O(n)每次 pop 都要把后面的所有元素往前挪。你要是用一个 vector 当底层去实现一个十万级数据的队列一次 pop 就要移动大量内存性能和“队列”这个名字完全不匹配。如果底层是 listdouble-ended 操作都是 O(1)看起来两头都合适。但 list 的节点是独立分配的每个节点还要额外存两个指针空间开销高更重要的是链式结构对 CPU 缓存极不友好。你遍历一个 list 时内存地址是跳着访问的cache miss 率很高数据量一上来性能差距非常明显。deque 是分段连续存储它在内存中由若干段连续的 buffer 组成通过一个中控器管理这些段的指针。双端插入删除都能做到 O(1)内存访问又比 list 连续得多。所以标准库把 deque 作为 stack 和 queue 的默认底层本质上是“兼顾双端操作效率和缓存友好性”的权衡结果。1.3 换底层容器时接口约束是你的验收清单适配器不是随便拿个容器就能垫底的它对底层容器有一套明确的接口要求。stack 要求底层支持push_back、pop_back、backqueue 要求底层支持push_back、pop_front、front、back。这套要求其实就是“替换底层容器”时的验收清单。底层容器适合作 stack适合作 queue原因vector适合不合适stack 尾插尾删效率高queue 头部删除 O(n)list适合适合双端操作都是 O(1)但缓存性差、节点开销大deque最适合最适合双端 O(1)内存分段连续默认首选我在实际工程里见过有人手写一个 allocator 然后把 stack 的底层换成一个固定容量环形缓冲区的这种用法在嵌入式或者对内存分配次数敏感的场景确实能提升稳定性但前提就是前面说的必须满足 stack 的接口约束。只要你的容器支持back()和pop_back()它就能当 stack 的底层。2. 核心实现细节自己动手实现一份可用的 stack 和 queue2.1 stack 模拟实现与关键设计决策手写一份模拟实现并不难难的是设计得跟标准库一样“克制”。这里我给出一个保留了核心接口的版本去掉了异常说明和 [[nodiscard]] 等修饰便于阅读#include deque #include utility namespace my_stl { template typename T, typename Container std::dequeT class stack { public: using container_type Container; using value_type typename Container::value_type; using reference typename Container::reference; using const_reference typename Container::const_reference; using size_type typename Container::size_type; stack() default; explicit stack(const Container cont) : _c(cont) {} explicit stack(Container cont) : _c(std::move(cont)) {} bool empty() const { return _c.empty(); } size_type size() const { return _c.size(); } reference top() { return _c.back(); } const_reference top() const { return _c.back(); } void push(const value_type value) { _c.push_back(value); } void push(value_type value) { _c.push_back(std::move(value)); } template typename... Args void emplace(Args... args) { _c.emplace_back(std::forwardArgs(args)...); } void pop() { _c.pop_back(); } void swap(stack other) noexcept(std::is_nothrow_swappable_vContainer) { using std::swap; swap(_c, other._c); } bool operator(const stack other) const { return _c other._c; } bool operator!(const stack other) const { return _c ! other._c; } protected: Container _c; }; }这里有个值得展开的设计点为什么我不把 Container 直接写成std::dequeT而是暴露成模板参数因为这是适配器“可替换性”的落地手段。你传入std::vectorint它就是一个基于连续内存的栈传入std::listint它就变成链表栈业务代码里可以针对不同的性能诉求更换底层。另外注意top()返回的是Container::reference本质上是_c.back()的引用。这意味着你可以通过s.top() 42;直接修改栈顶元素。有的初学者会误以为 stack 的元素是只读的其实标准库允许修改。push我写了左值和右值两个重载emplace用可变参数模板完美转发。这三个接口的存在意义是当你压入一个自定义类型如果只提供push(const T)每一次push都可能触发一次拷贝构造有了右值版本和 emplace就能在容器内部直接构造对象省掉临时对象和拷贝性能差距在大对象场景下非常可观。2.2 queue 模拟实现注意 pop 的层次queue 的实现思路和 stack 几乎对称只是接口换成front/back/pop_frontnamespace my_stl { template typename T, typename Container std::dequeT class queue { public: using container_type Container; using value_type typename Container::value_type; using reference typename Container::reference; using const_reference typename Container::const_reference; using size_type typename Container::size_type; queue() default; explicit queue(const Container cont) : _c(cont) {} explicit queue(Container cont) : _c(std::move(cont)) {} bool empty() const { return _c.empty(); } size_type size() const { return _c.size(); } reference front() { return _c.front(); } const_reference front() const { return _c.front(); } reference back() { return _c.back(); } const_reference back() const { return _c.back(); } void push(const value_type value) { _c.push_back(value); } void push(value_type value) { _c.push_back(std::move(value)); } template typename... Args void emplace(Args... args) { _c.emplace_back(std::forwardArgs(args)...); } void pop() { _c.pop_front(); } void swap(queue other) noexcept(std::is_nothrow_swappable_vContainer) { using std::swap; swap(_c, other._c); } bool operator(const queue other) const { return _c other._c; } bool operator!(const queue other) const { return _c ! other._c; } protected: Container _c; }; }这里最值得强调的是pop()和front()的分离使用。C 语言里用数组模拟队列时通常是先取队头再往前走指针但 C 标准库的 queue 明确把“查看队头”和“弹出队头”拆成两个操作目的就是为了安全性不让元素在被消费之前就消失。如果你在算法题里写出int x q.front(); q.pop();这是标准用法但如果你先q.pop();再去读q.front()那就是未定义行为因为容器里已经没有元素了。类似地stack 的top()和pop()也必须按顺序使用。这个细节在笔试或面试手写代码时特别容易忽略一紧张就容易把顺序写反然后程序运行起来行为随机还找不到原因。2.3 为什么 stack/queue 不提供迭代器这是我自己学 STL 时困惑很久的问题。vector 有 begin/endlist 有 begin/end凭什么 stack 和 queue 不给迭代器因为一旦提供了迭代器你就可以拿着迭代器在中间位置插入、删除、跳跃遍历那栈“只能从顶部操作”的语义就形同虚设了。适配器存在的意义就是做减法把底层容器的能力藏起来只保留符合逻辑结构的那几个操作。所以我们调试时无法直接for (auto x : st)去打印 stack只能通过top加pop的方式边弹边看或者干脆在写代码时用一个额外的辅助容器做快照。3. 典型算法场景实践单调栈、BFS 与单调队列3.1 单调栈场景下一个更大元素先说一个非常经典的题目给定数组[2,1,5,6,2,3]返回每个元素右边第一个比它大的元素不存在则为 -1。暴力做法是对每个元素向右扫描时间复杂度 O(n^2)数据量一大就完蛋。单调栈的做法是用一个栈保存“还没找到答案的下标”栈内元素从底到顶保持严格单调递减也就是栈顶是目前待结算元素中“最大”的那个。当新元素比栈顶大说明新元素就是栈顶元素的“下一个更大元素”这时出栈并记录答案#include vector #include stack std::vectorint nextGreater(const std::vectorint nums) { int n (int)nums.size(); std::vectorint ans(n, -1); std::stackint st; // 存下标 for (int i 0; i n; i) { while (!st.empty() nums[st.top()] nums[i]) { ans[st.top()] nums[i]; st.pop(); } st.push(i); } return ans; }核心在while而不是if当前遍历到的nums[i]可能同时是栈里多个元素右边第一个更大的值。每一轮把栈里所有小于它的全部弹出结算完毕再入栈。这样每个元素最多入栈一次、出栈一次整体复杂度退化为 O(n)空间复杂度 O(n)。这个场景可以说是 stack 的“高光时刻”也是我面试时答过无数次的问题。关键不在于背模板而在于理解栈里保存的是“待定状态”那些还没找到答案的元素它们之间一定是单调递减的关系否则早就被前面的元素结算出栈了。3.2 BFS 与 queue二叉树层序遍历queue 在算法里最典型的使用场景是 BFS广度优先遍历。二叉树层序遍历是入门题但它的写法体现了 BFS 的一个关键技巧通过queue.size()在入队之前锁定当前层的节点数。#include vector #include queue struct TreeNode { int val; TreeNode* left; TreeNode* right; }; std::vectorstd::vectorint levelOrder(TreeNode* root) { std::vectorstd::vectorint result; if (root nullptr) return result; std::queueTreeNode* q; q.push(root); while (!q.empty()) { int levelSize (int)q.size(); std::vectorint level; while (levelSize--) { TreeNode* node q.front(); q.pop(); level.push_back(node-val); if (node-left) q.push(node-left); if (node-right) q.push(node-right); } result.push_back(std::move(level)); } return result; }为什么要先把q.size()存下来因为队列在遍历过程中会被不断 push 新元素如果直接for (int i 0; i q.size(); i)每次循环都会重新计算q.size()新加入的孩子节点也会被当成当前层的节点来处理层就乱了。锁定levelSize是精确控制“我只处理这一层原有数量”的关键。BFS 和 queue 的匹配是必然的先进先出的特性保证节点按层推进而如果用 stack 替代 queue就会变成深度优先搜索遍历顺序彻底改变。在图的无权最短路径、多源 BFS、拓扑排序等场景里queue都是那个“一层层扩散”的引擎。3.3 单调队列场景滑动窗口最大值第三个场景难度再上一个台阶求数组每个长度为 k 的滑动窗口中的最大值。暴力做法每个窗口扫描一遍是 O(nk)经典解法用单调队列能做到 O(n)。这里的核心数据结构不是普通 queue而是 deque因为我们需要从尾部删除比当前元素小的值#include vector #include deque std::vectorint slidingWindowMax(const std::vectorint nums, int k) { std::dequeint dq; // 存下标窗口内值从大到小排列 std::vectorint ans; for (int i 0; i (int)nums.size(); i) { // 1. 淘汰窗口外的下标 if (!dq.empty() dq.front() i - k) dq.pop_front(); // 2. 从尾部弹出所有小于当前值的下标 while (!dq.empty() nums[dq.back()] nums[i]) dq.pop_back(); dq.push_back(i); // 3. 窗口成形后队头就是当前窗口最大值 if (i k - 1) ans.push_back(nums[dq.front()]); } return ans; }我当初学这个算法时想不通一个问题为什么要从尾部弹出比当前值小的元素它们以后还可能用到啊。答案是当前元素比它们大、又比它们新在后续的所有窗口里当前元素都会“覆盖”它们。一个元素既比另一个旧又比它小那就永远没机会成为窗口最大值所以可以提前淘汰。这就是“单调队列”的核心思想——把注定无用的信息尽早剔除。这个场景里deque 的双端能力是刚需队尾要 push、队头要 pop、队尾还要 pop_back三个操作缺一不可。这也是为什么我在第 2 节强调底层容器接口约束因为如果你试图用普通 queue 实现这个算法就只能被迫自己造一个支持双端操作的类或者用 vector head 指针手写代码量立刻上去。3.4 三个场景的算法复杂度与选型小结场景核心结构时间复杂度优化本质下一个更大元素stackO(n)用栈保存待定元素一旦遇到更大值立刻结算二叉树层序遍历queueO(n)FIFO 保证按层扩展用 size 锁定当前层滑动窗口最大值dequeO(n)用单调性提前淘汰不可能成为答案的元素如果你把这三个算法的共性抽象出来会发现一个共同点它们都不是简单地“存数据再取数据”而是利用栈或队列的进出顺序维护了一个额外的“单调性或层级性”约束从而把暴力枚举中大量重复无效的比较提前消掉。这也是面试官在算法题里使用 stack/queue 的真正考察意图。4. 常见问题与排查技巧实录4.1 stack/queue 不能直接遍历怎么调试没有迭代器就没有for (auto e : st)初学者调试时往往一脸懵。我的做法是在自定义类或局部代码里写一个快照函数把元素依次弹到一个临时 vector 中记录完再按原序压回去。或用辅助容器std::vectorint snapshot(std::stackint s) // 注意传值不修改原栈 { std::vectorint v; while (!s.empty()) { v.push_back(s.top()); s.pop(); } return v; }因为参数是按值传递栈内数据会被复制一份原栈不受影响。这个函数写起来很丑但调试时确实有效。更好的办法是你在写业务代码时尽量在关键节点直接记录日志而不是事后翻栈内容。4.2 为什么不要用 vector 当 queue 的底层我在前面的表格里写过一个结论vector 当 queue 底层pop_front 是 O(n)。但这个结论离“体验”还有距离。实践一下就知道数据量到十万级时每次头部弹出都触发 memmove整个队列的操作退化成近似 O(n^2)程序会明显卡顿。而一旦你对盆友说“queue 很快”然后在一个隐藏底层是 vector 的伪 queue 上压测结果会非常打脸。这是 STL 选底层时的核心教训不要只看接口是否满足需求还要看操作的复杂度是否满足业务量级。queue 的三个操作都要求 O(1) 均摊所以底层容器必须支持高效的头部删除和尾部插入。4.3 size_t 混用 int 的坑死循环实测这是个细节但致命的坑。stack::size()返回的是size_type也就是无符号类型。如果你这样写for (int i 0; i st.size(); i)当st.size()大于INT_MAX时i永远达不到上界循环可能进入死循环或行为异常。就算没到那么大的数量级i st.size()两边的类型一个是 int 一个是 unsigned int也会触发隐式转换。我的习惯是一律用auto或size_t承接 size 返回值涉及下标时才强制转 int并在转换前做好长度判断。这个习惯在滑动窗口、层序遍历这类频繁使用q.size()的代码里尤其重要。4.4 递归改循环显式栈的正确打开方式工程里遇到递归深度过大导致栈溢出时最常见的修正方案是用“显式栈”手动模拟系统栈。比如递归的二叉树前序遍历可以通过 stack 改成迭代版std::vectorint preorder(TreeNode* root) { std::vectorint result; if (!root) return result; std::stackTreeNode* st; st.push(root); while (!st.empty()) { TreeNode* node st.top(); st.pop(); result.push_back(node-val); if (node-right) st.push(node-right); if (node-left) st.push(node-left); } return result; }注意这里入栈顺序是先右后左因为栈是后进先出先处理左子树就要让左子树后入栈。这是一个容易写反的细节刷题时最好自己推演一遍。顺着这个思路很多递归算法都可以改成循环实现而 stack 就是那个帮你还原“调用现场”的利器。实际项目里如果一个递归算法因为深度过高频繁崩溃我会优先看一下是否存在指数级重复计算如果没有再用显式栈替换换完之后性能往往还有提升。4.5 工程里的取舍什么时候别用 stack/queue算法题里 stack/queue 是默认首选但工程实践要灵活。如果队列的数据量是已知固定大小或者你明确想避免动态内存分配用环形缓冲区实现队列是更好的选择如果栈在并发场景下频繁读写标准库容器本身不是线程安全的需要加锁或者换用无锁结构。另外一个非常实际的选择建议当栈的元素是关键业务对象且生命周期复杂时优先考虑存储“句柄”或“下标”而不是对象本身。比如在使用单调栈时我们存下标而不是值原因不只是为了回溯数组元素还避免了值拷贝的开销。这个思路在工程里同样适用——栈里存轻量标识业务数据留在原容器中职责分离调试也更方便。最后分享一点个人体会我做了这么多年 C 开发见过不少能把 STL 用得很溜的同事也见过很多死记 API 的初学者。stack 和 queue 看起来简单但它们背后的“适配器”思想、底层容器接口约束、以及单调栈和 BFS 中的应用模式恰恰是区分“会用”和“理解”的分水岭。如果这篇文章只留一个核心观点给你我会说不要满足于“stack 能用、queue 能跑”多去想一想它的底层是谁、为什么是它、换一个底层会怎样。想清楚这三个问题你在算法面试、工程选型、甚至是阅读 STL 源码时都会比别人多一层底气。