☰
STL容器适配器深度剖析:stack与queue的实现与工程应用
2026/10/6 3:04:22 网站建设 项目流程

有同学在群里问:stack 和 queue 不就是往一个容器上套一层 push/pop 的壳吗,STL 里居然还专门搞出两个类,值得单独写一篇?说实话,我刚开始学 C++ 时也是这么想的,后来拿着调试器翻开 libstdc++ 的实现,才意识到这两个看似“最简单”的容器适配器,恰恰是理解 STL 设计哲学的最好入口。它们背后牵出一连串问题:为什么 queue 默认用 deque 而不是 vector?为什么 pop 不返回被弹出的元素?为什么 stack 没有迭代器?把这些问题逐个想明白,你再看其他容器组件会顺畅得多。这篇文章就围绕“使用”和“模拟实现”两条线展开,适合学过 vector、list 之后想深入 STL 本质的读者,也适合正在准备 C++ 面试的同学。下面所有代码基于 C++11 及以上标准,环境能正常编译就行。

1. 先搞清楚“容器适配器”:stack 与 queue 在 STL 中的真实身份

1.1 官方文档为什么把 stack 叫作 container adapter

先看类模板声明,这是理解一切的起点:

template<class T, class Container = std::deque<T>> class stack; template<class T, class Container = std::deque<T>> class queue;

注意关键字是class Container = std::deque<T>。stack 和 queue 都不是真正的容器,而是对另一个容器的接口做了一层“翻译”的适配器。官方术语叫container adaptor,这里的 adapter 是设计模式里的适配器:底层已有 vector、deque、list 这些功能丰富的序列容器,但你的需求只是“后进先出”或“先进先出”,于是 STL 用一个外壳把不需要的接口全部挡住。

两者的关系不是继承(is-a),而是组合(has-a)。标准实现里有一个受保护的成员,通常命名为c,类型就是Container。所有操作最终都转发到c上:stack 的top()调c.back(),queue 的push()调c.push_back()。这个壳的价值在于“强制约束”:一旦你声明了一个std::stack,你根本不可能调用push_front,想在中间插一个元素更是无从下手。错误用法在编译期就被拦住了,这比任何注释和文档都可靠。

理解这层身份很重要。你会明白为什么换个底层容器,业务代码一行都不用改;也会明白为什么以后看到priority_queue时,第一反应应该是“这也是个容器适配器”。

1.2 默认底层 deque 是怎么被选中的

stack 和 queue 默认底层都是std::deque<T>,这不是随手选的。要让同一个底层容器既能当 stack 用,又能当 queue 用,它必须满足两边最苛刻的接口要求:

  • stack 需要:back()、push_back()、pop_back()。
  • queue 需要:front()、back()、push_back()、pop_front()。

vector 支持尾进尾出,但pop_front是 O(n),当 queue 的底层容器不合格。list 支持两端操作,但每个节点要额外存储前后指针,而且节点在堆上分散分布,对 CPU 缓存极不友好。deque 走的是“分段连续”路线,能在头尾两端都以均摊 O(1) 完成插入删除,于是成了最平衡的选择:既能当 stack 使,也能当 queue 使,且不需要在 stack 和 queue 里写两个不同的默认容器。

我在面试时经常问候选人一个问题:std::stack<int, std::vector<int>> st;能不能编译?答案是可以。那std::queue<int, std::vector<int>> qu;呢?不行,因为 vector 没有pop_front,编译到pop()成员函数实例化时直接报错。很多人答不上来,本质就是没有理解“默认底层容器为何是 deque”这条主线。

2. 使用层速览:接口设计逻辑与三个典型应用

2.1 常用接口对照表

使用层不需要太多技巧,先把接口背熟。stack 和 queue 的常用接口如下:

操作stack 接口queue 接口背后调用的底层操作
入栈 / 入队push(x)push(x)push_back
出栈 / 出队pop()pop()stack:pop_back,queue:pop_front
读栈顶 / 读队头top()front()stack:back,queue:front
读队尾无back()back
判空empty()empty()empty
元素个数size()size()size
原地构造emplace(args...)emplace(args...)emplace_back
交换swap(other)swap(other)swap

注意 stack 的top()实际上返回的是底层容器的最后一个元素。很多人刚学时有个错觉,觉得栈顶应该叫front,但底层容器的尾部才是栈的开口。queue 则更直观:队头是front(),队尾是back()。名字上虽然有点绕,但这恰恰是适配器的设计精髓:接口语义和底层存储细节解耦,上层使用者只管“栈顶”“队头”这些抽象概念,不需要关心底层是 deque 还是 list。

2.2 为什么 stack 和 queue 不提供迭代器

这是 STL 里非常容易被忽略的设计决策。很多容器都有begin()、end(),凭啥 stack 和 queue 没有?因为迭代器意味着“可以自由遍历中间元素”,一旦能遍历,LIFO 和 FIFO 的不变式就被打破了。你拿着迭代器把中间某个元素改了,那它还是栈吗?还是队列吗?适配器的意义就是收紧接口,让不合适的操作在编译期直接失败。

另外还有一层现实原因:容器适配器允许替换底层容器,如果它提供的是随机访问迭代器,底层换成 list 时语义就崩了;如果提供的是弱化版前向迭代器,那这个接口对 vector 用户来说又是鸡肋。与其这样,标准干脆不给 stack 和 queue 迭代器,让它们保持最纯粹的“受限线性结构”语义。这也解释了为什么模拟实现时,我们不需要写begin()和end(),写出来反而是错的。

2.3 三个最能体现“受限接口价值”的场景

第一个是括号匹配。这是 stack 的经典入门题,用受限的 LIFO 接口表达“最近一个左括号必须最先闭合”的直觉:

bool isValid(const std::string& s) { std::stack<char> st; for (char ch : s) { if (ch == '(' || ch == '[' || ch == '{') { st.push(ch); } else { if (st.empty()) return false; char top = st.top(); if ((top == '(' && ch == ')') || (top == '[' && ch == ']') || (top == '{' && ch == '}')) { st.pop(); } else { return false; } } } return st.empty(); }

代码很朴素,但里面藏着一个重要意识:top()只是“看一眼”,pop()才是“拿掉”。这两个操作分开,是在为异常安全做铺垫,后面模拟实现部分会展开讲。

第二个是广度优先搜索(BFS)。BFS 按层扩展,天然是先进先出:

void bfs(Node* root) { if (!root) return; std::queue<Node*> q; q.push(root); while (!q.empty()) { Node* cur = q.front(); q.pop(); // 处理 cur for (Node* child : cur->children) { q.push(child); } } }

queue在这里的价值是让待处理节点严格按照入队顺序出队。如果你用 vector 手写一个队列,会面临头部删除导致的 O(n) 搬移,或者需要维护头尾下标、自己处理循环索引,极易出错。标准库的std::queue把这块复杂度封装干净了。

第三个是逆波兰表达式求值。它展示了 stack 在表达式计算中的核心地位:

int evalRPN(const std::vector<std::string>& tokens) { std::stack<int> s; for (const std::string& t : tokens) { if (t == "+" || t == "-" || t == "*" || t == "/") { int b = s.top(); s.pop(); int a = s.top(); s.pop(); if (t == "+") s.push(a + b); else if (t == "-") s.push(a - b); else if (t == "*") s.push(a * b); else s.push(a / b); } else { s.push(std::stoi(t)); } } return s.top(); }

这里 stack 保存的是“还没被消费的操作数或中间结果”。后进先出的特性让最近的操作数最先被拿到,完全符合表达式计算“从后往前处理”的需求。

3. 手写一份可用的 my::stack 与 my::queue

3.1 模板骨架:Container 参数的本质约束

模拟实现前先想清楚:要模拟到什么程度?标准库完整实现牵扯 allocator、SFINAE、完美转发、比较运算符全套,几十行根本讲不完。但核心骨架完全可以提炼出来,让读者看清本质。

类模板的签名照抄标准库结构:

template <typename T, typename Container = std::deque<T>> class stack;

这里隐藏一个编译期约束:Container 必须提供 stack 所需的那些成员函数。如果传入一个不满足要求的类型,只有在调用对应操作时才会报错。因为模板是惰性实例化的,即使你传了一个错误的容器,只要没调用pop(),程序也可能编译通过。这是模板的“鸭子类型”特性,理解这一点可以帮助你排查模板报错。

成员设计上,标准库选择把Container c_放在 protected 区域。为什么不是 private?因为标准库允许派生类访问底层容器,某些进阶场景需要借助它做定制。我们模拟实现时也沿用这个约定。

3.2 my::stack 完整实现

下面是一个简化但可用的版本:

namespace my { template <typename T, typename Container = std::deque<T>> class stack { public: using container_type = Container; using value_type = typename Container::value_type; using size_type = typename Container::size_type; using reference = typename Container::reference; using const_reference = typename Container::const_reference; protected: Container c_; public: stack() = default; explicit stack(const Container& cont) : c_(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::forward<Args>(args)...); } void pop() { c_.pop_back(); } void swap(stack& other) noexcept(noexcept(std::swap(c_, other.c_))) { using std::swap; swap(c_, other.c_); } friend bool operator==(const stack& lhs, const stack& rhs) { return lhs.c_ == rhs.c_; } friend bool operator!=(const stack& lhs, const stack& rhs) { return !(lhs == rhs); } }; } // namespace my

这段代码的每一行都有取舍。using container_type = Container;是给使用者一个“公开窗口”,通过stack<int, std::vector<int>>::container_type能拿到底层类型。top()返回引用而不是值,是为了避免不必要的拷贝,也允许调用者直接修改栈顶元素,比如my::stack<std::string> s; s.top().append("x");是合法的。push同时提供左值和右值两个重载,右值版本会触发底层容器的移动语义。emplace用变参模板转发构造参数,这样s.emplace("abc")可以直接在底层容器里原地构造 string,省掉一次拷贝。

3.3 my::queue 完整实现

queue 的骨架几乎一样,差别只在于front()、back()和pop()的转发目标:

namespace my { template <typename T, typename Container = std::deque<T>> class queue { public: using container_type = Container; using value_type = typename Container::value_type; using size_type = typename Container::size_type; using reference = typename Container::reference; using const_reference = typename Container::const_reference; protected: Container c_; public: queue() = default; explicit queue(const Container& cont) : c_(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::forward<Args>(args)...); } void pop() { c_.pop_front(); } void swap(queue& other) noexcept(noexcept(std::swap(c_, other.c_))) { using std::swap; swap(c_, other.c_); } friend bool operator==(const queue& lhs, const queue& rhs) { return lhs.c_ == rhs.c_; } friend bool operator!=(const queue& lhs, const queue& rhs) { return !(lhs == rhs); } }; } // namespace my

queue 的front()转发到底层容器的front(),pop()转发到pop_front()。如果底层是 deque,这一切都是 O(1);如果底层误用了 vector,编译期就会在pop()处死掉。同样是组合设计,queue 和 stack 没有继承关系,也不需要多态,所以默认生成的析构函数、拷贝构造函数可以放心用。

3.4 模拟实现中最关键的决策:pop 为什么不返回元素

写模拟实现时,最容易被质疑的设计就是void pop()。很多语言里的 pop 会直接返回被删除的元素,Java 的Stack.peek()和pop()也是分开的,但 C++ 连返回“被弹出的值”都不给,为什么?

核心是异常安全。假如pop()被设计成:

T pop() { T value = top(); // 如果这一步拷贝构造抛出异常 c_.pop_back(); // 元素已经被移除了 return value; // 已经回不去了 }

当T的拷贝构造函数抛异常时,元素已经被弹出,栈的状态被破坏,调用方却拿不到返回值,也无法恢复。如果反过来先pop_back()再拷贝,那么元素已经没了,返回的值从哪来?用动态内存先保存?那会引入额外拷贝和抛异常点。无论怎么设计,T pop()都很难保证强异常安全。

标准库的解法是把“读”和“删”彻底分离:top()负责读,pop()负责删。正确用法是:

T value = s.top(); s.pop();

如果第二行pop()抛出异常(实际上底层 deque 的pop_back通常不会,但理论上可抛出),这时栈顶元素还在,栈状态不变。这就是 strong exception safety。C++ 标准这么设计,并不是保守,而是把异常安全放在易用性之前。这也是我在模拟实现时坚持保留void pop()的原因。

4. 底层容器的性能账:vector 能做 stack 为何做不好 queue

4.1 三种常见底层容器的操作复杂度对比

很多人做性能优化时,第一反应是换容器,但往往不清楚换容器到底优化了什么。先看一张复杂度对照表:

操作vectordequelist
push_back / pop_backO(1) 均摊O(1)O(1)
push_front / pop_frontO(n)O(1)O(1)
随机访问O(1)O(1)O(n)
迭代器稳定性扩容后失效push/pop 时不失效始终稳定

vector 之所以不能当 queue 的默认底层,根因就是push_front/pop_front是 O(n)。想象一排人排队,现在要删队头,后面所有人必须往前走一步,整个数组元素整体搬移一次。元素越多,代价越大。

stack 只需要尾进尾出,push_back/pop_back恰好是 vector 的强项,所以std::stack<int, std::vector<int>>能编译,而且通常比std::stack<int>更快。下一节解释原因。

4.2 deque 的分段结构,以及它如何实现两头 O(1)

deque 的内部不是一个连续大数组,而是一组“分段连续”的小缓冲区,再加一个中控器指针数组。文字描述可以这么理解:中控器是一个指针数组,每个指针指向一块固定大小的连续内存块。当头部需要插入时,如果当前头部块还有空位,直接往前填入;如果满了,就在中控器前面申请一块新缓冲块挂上去。弹出头部同理,当前块弹空后释放或回收整块。无论元素有多少,都只涉及“当前边界块”的局部操作,所以头尾插入删除都是 O(1)。

但天下没有免费的午餐。deque 的随机访问虽然也是 O(1),但需要先查中控器、再找到具体块、最后做偏移计算,比 vector 的“基址加偏移”多一层间接跳转。对 CPU 缓存来说,vector 的单块连续内存是最友好的,遍历时预取效率最高。deque 的元素分布在不同块里,遍历时可能跨块跳转,缓存命中率略低。

这带来了一个反直觉的结论:如果你的程序只需要栈语义,std::stack<int, std::vector<int>>很可能比默认实现更快,因为 vector 内存紧凑、扩容均摊成本低、尾部操作极快。默认用 deque 是为了“一个容器同时服务 stack 和 queue”的通用性,而不是为了栈场景的极致性能。知道这个区别,你才敢在性能敏感代码里做容器替换。

4.3 自定义底层容器的现实例子

容器适配器最大的好处是底层可替换,这里分享三个真实场景。

第一个是栈的容量预分配。std::stack本身没有reserve(),但可以通过构造时传入预分配好的 vector 实现:

std::vector<int> v; v.reserve(10000); std::stack<int, std::vector<int>> st(v); // 后续 push 10000 个元素,不会触发扩容移动

如果你的元素很大或不可移动,频繁扩容会导致大量拷贝,这一步预分配能省掉大坑。

第二个是固定容量队列。在高性能网络收发模块中,频繁动态分配内存是不能接受的。可以自己实现一个环形缓冲区容器,提供front()、back()、push_back()、pop_front(),再把它作为 queue 的底层容器。这样上层代码完全不用改,只需要换模板参数,底层就变成无动态分配的有界队列。这是“适配器模式”在工程里最漂亮的应用。

第三个是避免 deque 的迭代器失效陷阱。虽然 stack 和 queue 不暴露迭代器,但如果你把底层容器成员c拿出来操作,就要小心:deque 在中间插入会让所有迭代器失效,但在头尾 push/pop 时,除了begin()迭代器可能失效,其余迭代器通常保持有效。这个细节在写底层算法时容易踩坑,建议只在明确理解规则的前提下才直接操作底层容器。

5. 第二层功夫:stack/queue 的经典组合玩法与工程避坑

5.1 用两个 stack 实现 queue

面试高频题,看起来是“容器互相模拟”,本质是考察数据结构操作的均摊分析。思路是维护两个栈:in管入队,out管出队。

template <typename T> class MyQueue { public: void push(const T& x) { in_.push(x); } void pop() { moveIfNeeded(); out_.pop(); } T& front() { moveIfNeeded(); return out_.top(); } bool empty() const { return in_.empty() && out_.empty(); } private: std::stack<T> in_, out_; void moveIfNeeded() { if (out_.empty()) { while (!in_.empty()) { out_.push(in_.top()); in_.pop(); } } } };

复杂度为什么是均摊 O(1)?每个元素从push进去,到pop出去,最多经历三次操作:被压进in、被搬到out、从out弹出。搬移是一次性的,不是每次出队都搬所有元素。只要out不为空,出队直接走out.pop(),均摊下来每个元素成本是常数。面试时别忘了补一句“当访问空队列的 front 时是未定义行为,需要调用方配合 empty() 判断”。

5.2 用队列实现栈:旋转而不是搬来搬去

用两个队列模拟栈也是经典题,但有一个更简洁的变体:只用一个队列,在 push 时做“旋转”,让新元素始终排在队头。

template <typename T> class MyStack { public: void push(const T& x) { q_.push(x); size_t sz = q_.size(); while (--sz) { q_.push(q_.front()); q_.pop(); } } void pop() { q_.pop(); } T& top() { return q_.front(); } bool empty() const { return q_.empty(); } private: std::queue<T> q_; };

解释一下原理:push 时先把新元素放进队尾,再把前面所有元素依次弹出并塞回队尾,相当于把队列旋转了一圈,新元素被转到队头。这样top()直接读队头,pop()直接删队头。push 是 O(n),pop 和 top 是 O(1)。两个队列的做法本质上也是把一个队列当临时缓冲,旋转方案代码量更少,边界更容易想清楚。理解这个旋转过程,对操作系统的任务队列、消息循环里的优先级调整也有启发。

5.3 单调栈:stack 不只是存数据的容器

单调栈在算法题里出场率极高,核心价值是“维护一个单调的候选序列”,典型应用是下一个更大元素:

std::vector<int> nextGreater(const std::vector<int>& nums) { int n = static_cast<int>(nums.size()); std::vector<int> res(n, -1); std::stack<int> st; // 存下标 for (int i = 0; i < n; ++i) { while (!st.empty() && nums[st.top()] < nums[i]) { res[st.top()] = nums[i]; st.pop(); } st.push(i); } return res; }

这里 stack 里存的是下标,不是值。每次遇到nums[i]更大时,就把栈顶对应的答案确定下来并弹出。每个元素入栈一次、出栈一次,整体 O(n)。如果不用单调栈,暴力解法是 O(n^2)。这个例子告诉你:stack 的“后进先出”不只是在做括号匹配,它还非常擅长处理“当前信息出来后,回填历史候选”这类问题。接雨水、柱状图中最大矩形等题目也都是这个套路的变体。

5.4 工程中容易被忽略的细节清单

最后列几个我实际踩过、或者review过多次的坑:

  • 空容器上调用top()/front()/back()是未定义行为。标准库不会帮你检查,线上 crash 和编译错误之间往往就差一个if (!q.empty())。
  • stack 和 queue 的拷贝是底层容器的深拷贝。如果你把它们当作成员变量传来传去,对象很大时会带来明显开销,优先考虑移动或用引用/指针传递。
  • 标准容器不保证线程安全。多线程环境需要自己加锁,或者改用无锁有界队列,别指望std::queue内部会替你同步。
  • std::stack没有容量上限。需要“最多放 N 个”这种约束时,必须自己包一层,在push前检查size()。
  • 想改内存分配策略,不要改 stack 本身,而是给底层 Container 传自定义 allocator。比如std::deque<T, MyAlloc<T>>。
  • 关系运算符基于底层容器的字典序。也就是说stack1 == stack2等价于比较它们的底层容器是否相等,stack 本身没有独立比较逻辑。

这些细节在面试里属于加分项,在实际工程里属于“晚知道一天,线上多熬一晚”的教训。

真把 stack 和 queue 的实现吃透之后,再去看 priority_queue、甚至 set/map 的接口设计,你会觉得格外顺眼。我自己做网络服务时,习惯用 queue 管理连接事件、用 stack 管理嵌套协议状态机,上层从不暴露底层容器,后来把 queue 底层换成定制环形缓冲,一行业务代码都没改。这就是“容器适配器”在实际工程里最有价值的回报。建议你也把 libstdc++ 的stl_stack.h和stl_queue.h打开读一遍,篇幅不长,二十分钟足够,收益比反复背 API 列表高得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询