学完 vector 和 list 之后,很多人会对 STL 里另一组数据结构感到困惑:stack 和 queue 到底算什么?它俩不像 vector 那样支持随机访问,也不像 list 那样能在任意位置插入删除,翻来覆去就 push、pop、top、front 几个操作。这篇 C++ 初阶学习笔记第 8 篇,就是专门把 stack(栈)和 queue(队列)讲透。我会从容器适配器的设计原理出发,聊清楚它们底层为什么用 deque,再通过最小栈、括号匹配、层序遍历、单调栈这几道高频题打通实操,最后整理一批我在实际写代码时踩过的坑。刚学完 STL 容器想巩固基础、或者准备面试数据结构部分的同学,这篇文章应该能帮你把散落的知识点串成一条线。
1. 先搞清楚 stack 和 queue 到底是什么
1.1 容器适配器:STL 为什么不直接提供一个“栈类”
很多人初学 STL 时,会下意识把 stack 和 vector、list 放在同一个层级理解,其实它们在分类上完全不一样。vector、list、deque 是真正的容器(container),它们各自管理一段内存,有自己的迭代器接口;而 stack 和 queue 在官方文档里的定位是 container adapters,也就是容器适配器。适配器的意思很直白:它自己不直接存储数据,而是“包住”一个底层容器,把底层容器的接口改造成另一种形态。
这个设计很像餐厅厨房的出餐口。厨房里面可能同时有好几个窗口、好几条动线,但传给顾客时只开一个口:从窗口递出去一盘菜,顾客拿完就走,不能把手伸进后厨翻来翻去。stack 和 queue 就是对底层容器做这样的“接口封装”:底层容器还是那套存储结构,但对外只暴露符合栈或队列语义的操作。这样做最大的好处是强制约束使用方式。如果你用 vector 当栈用,写代码时很容易顺手就vec[0] = xxx或vec.erase(...),一些违规操作很难被发现;而 stack 把接口收窄之后,你根本不可能做到随机访问,数据流的顺序天然就是后进先出(LIFO)或先进先出(FIFO),程序意图一眼就能看清楚。
从设计哲学上讲,这也体现了 C++ 里“用类型表达意图”的思想。看到std::stack<int>,读者立刻知道这里的数据流是后进先出;看到std::queue<Task>,立刻知道任务会按到达顺序被处理。类型本身就是文档,而且是编译器能检查的文档,这比任何注释都可靠。
1.2 底层容器为什么默认是 deque
stack 和 queue 的默认底层容器都是 deque(双端队列)。很多人会好奇:栈只在一端操作,队列也主要在两端操作,为什么不用 vector 或者 list?这个问题需要从三个候选容器的优缺点说起。
先看 vector。vector 在尾部 push_back 和 pop_back 是 O(1) 的均摊复杂度,看起来很适合当栈底。但真正的瓶颈在于扩容:vector 满了之后要申请新内存、把旧元素拷贝或移动过去,这个过程会带来大量拷贝开销,而且旧元素的内存地址全部失效。栈是一个高频增删的数据结构,每次扩容都全员搬家,代价明显偏大。队列就更不用说了,vector 头部删除是 O(n) 的,用它当队列底层,理论上就站不住脚。
再看 list。list 的任意位置插入删除都是 O(1),没有扩容问题,听起来也不错。但它的致命弱点是内存不连续:每个节点单独分配一块内存,节点之间靠指针串联,遍历和访问时 CPU 缓存命中率很差。栈和队列的核心操作集中在某一端或两端,list 的“能在任意位置插入”这个优点完全用不上,反而白白承担了缓存不友好、内存碎片化的代价。
deque 能成为默认选项,是因为它从设计上就为“两端操作”优化。deque 的内部结构是“一段一段的定长连续缓冲区”加一个中控器(map 数组)来索引这些缓冲区。头尾插入删除都是 O(1),随机访问也是 O(1),虽然比 vector 略慢,但在栈和队列的使用场景里,随机访问根本不是刚需。更重要的是,deque 扩容时不需要搬动已有元素,只需要调整中控器索引,这就避免了 vector 扩容的拷贝风暴。所以从综合表现看,deque 是“削足适履”之后最适合当适配器底座的那个容器。
| 底层容器 | 尾部操作 | 头部操作 | 扩容代价 | 缓存友好度 | 适不适合当 stack/queue 底座 |
|---|---|---|---|---|---|
| vector | O(1) 均摊 | O(n) | 高,全员拷贝 | 高 | 只适合当 stack 的特例 |
| list | O(1) | O(1) | 无,节点独立 | 低 | 功能过剩,缓存差 |
| deque | O(1) | O(1) | 低,只调索引 | 中 | 最均衡,默认选择 |
顺便说一句,在真正了解这个设计背景之前,我一度以为 stack 底层一定是 vector 或者 list,后来自己动手用三种容器分别实现了迷你栈,对比跑了 100 万次 push/pop,才发现 deque 的均衡性确实是最好的。这个底层选型问题如果面试被问到,从“扩容代价 + 缓存友好度 + 头部操作复杂度”三个角度答,基本可以拿满分。
1.3 一个入口一个出口:LIFO 与 FIFO 的日常映射
栈和队列的语义规则,用一句话概括:栈只有一个出入口,最后放进去的东西最先拿出来;队列有两个口,队尾进、队头出,谁先进来谁先走。这个规则看着简单,但它实际上决定了这两种结构在工程里各自擅长处理什么类型的问题。
栈的 LIFO 特性最典型的例子就是函数调用。程序运行到函数 A 时,A 的局部变量压入调用栈;A 调用了 B,B 的栈帧继续压在上方;B 返回后,B 的栈帧先弹出,控制权回到 A。正是这种后进先出的机制,保证了嵌套调用的现场恢复。编辑器里的撤销操作也是栈:每次操作入栈,撤销时从栈顶弹出最近一次动作,撤销次数有限时非常符合直觉。还有括号匹配、进制转换、表达式求值,底层逻辑全是栈。你只要记住“需要回溯到最近状态”的问题,首选思路就是栈。
队列的 FIFO 特性同样无处不在。打印机接收多个任务时,先提交的文档先打印,不可能后提交的任务插队抢先。操作系统的进程调度、网络数据包缓冲、商家处理排队请求,用的都是同一套先进先出的逻辑。在算法里,最广为人知的是 BFS(广度优先搜索):从起点出发,把相邻节点按顺序放入队列,每次从队头取出一个节点继续扩展,这样就能保证“距离短的先被处理”,层序遍历就是这种思想的直接产物。
理解这两种语义之后,再去学 API、刷题,心里就有一条主线了:栈擅长递归逆序和最近状态回溯,队列擅长顺序缓冲和逐层扩散。
2. 核心接口与实操要点:把 push、pop、top 用明白
2.1 接口速查:stack 和 queue 到底有哪些操作
栈和队列的接口非常精简,官方给了它们的 API 就是为了刻意限制操作范围。stack 的核心接口是 push、pop、top、size、empty、swap、emplace;queue 的核心接口是 push、pop、front、back、size、empty、swap、emplace。初学阶段先记住这五个基本操作就够了。
| 接口 | stack 作用 | queue 作用 |
|---|---|---|
| push(x) | 将 x 压入栈顶 | 将 x 插入队尾 |
| pop() | 弹出栈顶元素(无返回值) | 弹出队首元素(无返回值) |
| top() / front() | 返回栈顶元素引用 | 返回队首元素引用 |
| back() | 不存在 | 返回队尾元素引用 |
| size() | 返回元素个数 | 返回元素个数 |
| empty() | 判断是否为空 | 判断是否为空 |
| emplace(args) | 在栈顶原地构造元素 | 在队尾原地构造元素 |
| swap(other) | 交换两个容器内容 | 交换两个容器内容 |
这里最反直觉的一个设计是:pop 明明把元素移出了容器,却不返回这个元素。很多从 Java 转过来的同学第一次见都觉得别扭——Java 的 Stack.pop() 可是既弹出又返回的。C++ 这么设计的核心原因是异常安全。假设 pop 同时负责“返回值”和“删除元素”,如果元素在拷贝返回的过程中抛异常,容器里的元素已经被删掉了,数据丢失,状态不一致;如果先拷贝再删除,又必须依赖“拷贝一定成功”的假设。所以 C++ 选择了把操作拆开:top 负责读取,pop 负责删除。使用的时候永远是“先读后删”,这是栈和队列使用的第一条铁律。
emplace 则是 C++11 才引入的接口,它的作用是“原地构造”。比如你在队列里压入一个std::string,用push("abc")会先构造一个临时 string,再把它拷贝或移动到容器里;用emplace("abc")则直接在底层容器内部构造,少一次临时对象开销。对基类型来说差别不明显,但如果存的是自定义的复杂类型,emplace 的性能优势就体现出来了。
2.2 三个容易被坑的操作细节
第一个坑是 top 和 pop 的配对问题。拿到一个 stack 之后,想取出栈顶元素再删掉,正确写法是:
std::stack<int> st; st.push(42); int val = st.top(); // 先读 st.pop(); // 再删我见过不少人把st.pop()写在前面,或者直接用st.top()的返回值做后续处理,导致结果怪怪的。养成“先读后删”的习惯之后,这类错误基本就消失了。
第二个坑是空容器的操作。top()、front()、pop()在容器为空时是未定义行为,不同编译器的表现完全不一样,有的返回垃圾值,有的直接崩溃。用的时候务必先检查 empty。这个约束也带来一个很微妙的点:如果弹出的元素需要保存下来,顺序一定是先 top 保存到局部变量,再 pop,否则等你 pop 完再想去拿元素,已经晚了。
第三个坑是容器适配器没有迭代器。你写不出for (auto x : st)这种代码,因为 stack 和 queue 根本没有 begin/end 接口。想遍历怎么办?最简单的办法是做一个副本,然后循环弹空它:
std::stack<int> backup = st; while (!backup.empty()) { int v = backup.top(); backup.pop(); // 处理 v }这种做法不会破坏原栈,代价是多占一份内存。如果你对遍历有高频需求,说明你用的数据结构可能根本不应该是 stack,而应该是 vector 或 deque——这正是“适配器限制访问方式”的设计初衷在提醒你换思路。
还有一个细节值得注意:top()返回的是引用,但这个引用在 pop 之后会失效。因为 pop 可能触发底层容器的内存回收,旧的引用指向的内存已经不属于你了。如果你把这个引用保存下去,后续再读就是悬空引用,属于典型的未定义行为。
2.3 两个高频手写题:互用栈和队列模拟
“两个栈实现队列”和“两个队列实现栈”是初学阶段必练的两道经典题。它们本身不代表真实工程场景,但做完之后你对 push、pop、top 的行为边界、元素顺序反转的理解会非常深。
先说两个栈实现队列。队列是先进先出,栈是后进先出,多叠一层栈,顺序正好反一次;那反两次,顺序就恢复原样了。于是可以定义两个栈:in 负责接收新元素,out 负责输出。push 时直接压进 in;pop 时先把 in 里的所有元素倒进 out,让栈底元素变成 out 的栈顶,再从 out 弹出。代码如下:
#include <stack> class MyQueue { public: void push(int x) { in_.push(x); } int pop() { int val = peek(); out_.pop(); return val; } int peek() { if (out_.empty()) { while (!in_.empty()) { out_.push(in_.top()); in_.pop(); } } return out_.top(); } bool empty() { return in_.empty() && out_.empty(); } private: std::stack<int> in_; std::stack<int> out_; };注意一个关键细节:只有在 out 为空时,才一次性把 in 里的所有元素全部倒过来。如果倒一半就停止,顺序就会错乱,比如先进 1、2、3,倒了一个 3 到 out 之后停下来,再 pop 时拿到的不是队首的 1。所以判断条件必须是if (out_.empty()),且 while 循环要把 in 倒空。
每次 pop 的最坏复杂度是 O(n),但每个元素只会从 in 倒到 out 一次,均摊下来还是 O(1)。这在数据结构课程里叫摊还分析,理解它比记住结论更重要。
再说两个队列实现栈。这里用两个队列 q 和辅助队列 tmp 反复“扒皮”。每次 push 时,先把 q 里已有元素搬到 tmp,把新元素放进 q,再把 tmp 的元素搬回来。这样新元素永远排在队头,pop 时直接弹出队头就是栈顶。也可以不单独维护另一个队列,直接在 pop 时把前 size-1 个元素循环搬到自己队尾,再弹出队首:
#include <queue> class MyStack { public: void push(int x) { q_.push(x); } int pop() { int n = q_.size(); for (int i = 0; i < n - 1; ++i) { q_.push(q_.front()); q_.pop(); } int val = q_.front(); q_.pop(); return val; } int top() { int n = q_.size(); for (int i = 0; i < n - 1; ++i) { q_.push(q_.front()); q_.pop(); } int val = q_.front(); q_.push(val); // 取完再放回去,保持原状 q_.pop(); return val; } bool empty() { return q_.empty(); } private: std::queue<int> q_; };这两个题的收获不在于“把 A 变成 B”本身,而在于让你切身体会到:数据结构的语义是由操作规则定义的,只要控制好入口和出口,底层怎么存都可以灵活变通。这在工程里是一种非常实用的抽象思维。
3. 四道经典题带你打通栈和队列
3.1 最小栈:辅助栈保存“历史最低点”
最小栈是栈类题目里最经典的一道。要求实现一个栈,除了 push、pop、top 之外,还要支持在 O(1) 时间内返回当前栈中的最小值。思路很直接:再开一个辅助栈 minStack,它里面每个元素记录的是“主栈走到当前状态时的全局最小值”。主栈 push 一个元素 x 时,比较 x 和 minStack 栈顶,把较小者压入 minStack;主栈 pop 时,minStack 同步弹出。这样 minStack 的栈顶永远是当前主栈状态下的最小值。
#include <stack> class MinStack { public: void push(int x) { st_.push(x); if (min_.empty() || x < min_.top()) { min_.push(x); } else { min_.push(min_.top()); } } void pop() { st_.pop(); min_.pop(); } int top() { return st_.top(); } int getMin() { return min_.top(); } private: std::stack<int> st_; std::stack<int> min_; };辅助栈之所以能成立,是因为栈操作只影响栈顶。每次 push 时,唯一可能改变“最小值”的只有新元素 x,所以 minStack 只需记录“新元素 vs 旧最小值”的胜负关系;pop 时把对应的记录也弹掉,就能自动回到之前的最小值状态。这种“用另一个栈同步记录状态”的模式,在后续做括号版本、双向版本的最小栈时都能复用。
3.2 括号匹配:栈催生的编译器基本功
括号匹配题几乎是面试白板题里的钉子户:给定一个只包含(){}[]的字符串,判断括号是否成对且顺序正确。它的直觉就是“最近打开的括号最先关闭”,换句话说,后进先出,天然用栈。
#include <string> #include <stack> bool isValid(const std::string& s) { std::stack<char> 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(); }两个最容易出错的边界条件:第一,遇到右括号但栈已经空了,说明前面没有匹配的左括号,直接返回 false;第二,遍历结束时栈不为空,说明有左括号没被关闭,也要返回 false。这两个条件漏一个,测试用例就会翻车。
这个题的扩展意义远超题目本身。编译器做语法分析时,符号表的嵌套作用域、函数块的进入和退出,本质上也是这么一组“压栈-出栈”的动作。理解了括号匹配,你就理解了为什么编程语言里的块结构天然适合用栈来解析。
3.3 二叉树层序遍历:队列实现 BFS 的标准模板
层序遍历是一道典型的队列应用题。二叉树的层序遍历要求按层从左到右输出所有节点,而且要把每一层的节点单独放在一个数组里。用队列实现 BFS 的关键点在于:如何知道当前层什么时候结束。做法是在每次循环开头记录当前队列的长度 sz,然后连续弹出 sz 个节点,这 sz 个节点正好就是同一层的所有节点。
#include <queue> #include <vector> struct TreeNode { int val; TreeNode* left; TreeNode* right; TreeNode(int v) : val(v), left(nullptr), right(nullptr) {} }; std::vector<std::vector<int>> levelOrder(TreeNode* root) { std::vector<std::vector<int>> ans; if (!root) return ans; std::queue<TreeNode*> q; q.push(root); while (!q.empty()) { int sz = q.size(); std::vector<int> level; for (int i = 0; i < sz; ++i) { 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); } ans.push_back(level); } return ans; }这里需要反复强调一个细节:int sz = q.size()必须写在 for 循环之前固定下来,不能在循环里直接写i < q.size()。因为循环体里不断有新节点入队,q.size()一直在变,这么写会让循环多跑很多次,把下一层的节点也混进当前层。这个坑我见过好几个人踩,层序遍历写错分层就是从这里开始的。
如果不需要分层,只做普通 BFS,那就更简单:while 循环里每次取队头节点处理,再把邻居入队。图的无权最短路径问题、状态空间搜索,用的都是这套模板。
3.4 单调栈:在 O(n) 时间里找“下一个更大元素”
单调栈属于栈的进阶用法,但初学阶段就可以掌握它的核心思想。所谓单调栈,就是栈内元素按单调递增或单调递减的顺序排列。经典的“下一个更大元素”问题:给一个数组,对每个元素求出右边第一个比它大的数,没有就返回 -1。
暴力做法是双重循环 O(n^2),单调栈可以做到 O(n)。核心思路是从右往左扫描,维护一个栈顶最小、栈底最大的单调递减栈。每遇到一个新元素 nums[i],就把栈里所有比 nums[i] 小的元素弹出去,因为对 i 左边的元素来说,nums[i] 比这些被弹出的元素更大且更靠右,它们再也任何机会被当作“下一个更大元素”。弹出结束后,栈顶就是答案,然后把 nums[i] 压栈并重复这个过程。
#include <vector> #include <stack> std::vector<int> nextGreater(std::vector<int>& nums) { int n = nums.size(); std::vector<int> ans(n, -1); std::stack<int> st; for (int i = n - 1; i >= 0; --i) { while (!st.empty() && st.top() <= nums[i]) { st.pop(); } ans[i] = st.empty() ? -1 : st.top(); st.push(nums[i]); } return ans; }单调栈的本质是“维护候选答案的历史顺序”。因为栈压入元素时天然保留时间顺序,弹出元素时又能及时淘汰“永远不会成为答案”的旧元素,所以每个元素最多进栈出栈一次,总复杂度为 O(n)。除了“下一个更大元素”,柱状图中最大的矩形、每日温度、接雨水等问题都是同一个思想的不同变体。初学阶段先把这个模板跑通,后面碰到类似题再举一反三,会轻松很多。
4. 进阶:priority_queue 与自定义类型排序
4.1 优先级队列的堆结构与参数选型
说完 stack 和 queue,初学者很快会遇到一个和它们长得很像的兄弟:priority_queue,优先级队列。它同样属于容器适配器,但语义既不是 LIFO 也不是 FIFO,而是“优先级高的先出”。底层结构并不是队列,而是一个二叉堆;默认情况下是大顶堆,也就是 top() 返回的是整个容器中最大的元素。
#include <queue> std::priority_queue<int> bigq; // 大顶堆,堆顶最大 std::priority_queue<int, std::vector<int>, std::greater<int>> smallq; // 小顶堆,堆顶最小priority_queue 的模板参数有三个:元素类型、底层容器、比较器。默认底层容器是 vector,默认比较器是 less,对应大顶堆。想用小顶堆时,必须显式写出三个参数:元素类型、vector<int>、greater<int>。这个语法很啰嗦,但它是 C++ 模板设计里“只提供需要覆盖的参数”的代价,习惯就好。
堆的 push 和 pop 都是 O(log n),top 是 O(1)。这个复杂度意味着它适合处理“动态维护最值”的场景,比如任务调度里每次取优先级最高的任务、数据流中维护前 K 大的数。和 stack、queue 不同,priority_queue 不允许你看到队里的其他元素,也不支持删除任意元素,能做的只有塞进去、取堆顶、弹出堆顶,限制比栈和队列更严格。
初学阶段容易把 priority_queue 想象成“自动排序的队列”,这是误解。堆并不是完全有序结构,它只保证父节点大于(或小于)子节点,你拿到的只有顶端那一个最值。真正需要全序时,应该用 vector 加 sort,而不是 priority_queue。
4.2 自定义对象进堆时的比较器方向问题
用 priority_queue 存自定义类型时,默认的 less 比较器并不认识你的类型。最常见的做法是重载operator<,或者写一个仿函数(函数对象)作为第三个模板参数。
下面用 Task 类型举例,它有一个 priority 字段,想按 priority 大的先出队:
#include <queue> #include <string> struct Task { int priority; std::string name; }; struct TaskCmp { bool operator()(const Task& a, const Task& b) const { return a.priority < b.priority; // 大顶堆方向 } }; std::priority_queue<Task, std::vector<Task>, TaskCmp> pq;这里最绕的地方就是比较器的方向。你可能会想:priority 大的先出,比较器应该是 a.priority > b.priority 才对。但实际上 priority_queue 的默认比较器 less 基于operator<,它所表达的语义是“如果 a < b 成立,则 a 的优先级更低,会被压到堆下”。也就是说,<代表“后出”,>代表“先出”。想实现大顶堆效果,就老老实实写<;想实现小顶堆效果,就得反过来写>,或者直接包一层std::greater<Task>。
这个方向问题在写 TopK 题时最容易混淆。找数组中前 K 大的数,应该维护一个大小为 K 的小顶堆,堆顶永远是目前最小的候选,每来一个新数,比堆顶大就替换堆顶;找前 K 小的数,则维护大顶堆。如果你把比较器方向搞反,最后拿到的结果会完全相反。我的经验是每次写自定义比较器前,先在草稿纸上验证一个小例子,比如四个数的插入顺序,确认堆顶是不是自己期望的那个元素,再继续往下写。
还有一个值得注意的坑:重载operator<时,必须写成 const 成员函数,参数传 const 引用,否则部分标准库实现无法正确调用比较器。另外,如果两个对象的 priority 相等,比较器应当返回 false,否则会影响堆结构的稳定性——严格弱序的要求是“等价”时比较结果都为 false,这是堆算法成立的前提。
5. 常见问题与排查技巧实录
5.1 自定义类型为什么存不进栈?
初学阶段往 stack 或 queue 里塞自定义类型时,经常会碰到编译错误,提示找不到对应的构造函数、拷贝构造函数不可访问之类。排查这个问题之前,先要明白栈和队列对元素类型的基本要求:元素必须可拷贝或可移动,因为 push 的时候底层容器要么拷贝传入对象,要么移动它;top/front 返回引用但不拷贝,pop 不返回元素,但内部删除操作依然可能依赖元素的可移动性。
如果自定义类显式删除了拷贝构造函数(比如A(const A&) = delete),那 stack 基本用不了。另一种常见情况是类里有 unique_ptr 之类的不可拷贝成员,移动构造又没写对,编译器会报一堆摸不着头脑的错误。解决方案通常有两个方向:第一个,确认你的类满足“可拷贝或可移动”,必要时自己实现移动构造;第二个,用emplace原地构造对象,避免在 push 阶段产生额外的拷贝需求。
此外还要注意,往栈里存指针(如std::stack<Node*>)虽然简单,但带来了另一层责任:谁负责释放指针指向的对象?栈不会替你 delete 任何东西,弹出的指针一旦丢失,就是内存泄漏。工程上更推荐直接用std::stack<std::unique_ptr<Node>>或者把 Node 按值存取,把生命周期管理交给 RAII,而不是裸指针。
5.2 深度优先:递归栈还是手动栈?
栈和递归本来就共享同一个调用栈空间。写递归函数时,每次函数调用都会在系统调用栈上压入一个栈帧,递归返回时再弹出。所以“递归转迭代”的核心操作,往往就是用一个显式的 std::stack 模拟系统的调用栈。
那什么时候该用显式栈?我的经验是:递归深度超过一万层就要警惕了。默认系统调用栈的大小有限,递归过深会导致栈溢出,程序直接崩掉。显式栈把状态数据放在堆上,容量大得多,而且可以精确控制每次压栈的内容,只存必要信息,相比递归的完整栈帧更节省空间。
举一个最简单的例子,二叉树的前序遍历,递归版本三行搞定,但非递归版本就必须用一个 stack 模拟:
#include <stack> #include <vector> std::vector<int> preorder(TreeNode* root) { std::vector<int> ans; if (!root) return ans; std::stack<TreeNode*> st; st.push(root); while (!st.empty()) { TreeNode* node = st.top(); st.pop(); ans.push_back(node->val); if (node->right) st.push(node->right); if (node->left) st.push(node->left); } return ans; }这里需要注意压栈顺序:期望的遍历顺序是“根左右”,而栈是后进先出,所以要先压右子节点、再压左子节点,弹出时才能保证左优先。很多人在递归转栈这一步栽跟头,其实只要记住“栈的弹出顺序和压栈顺序相反”这条铁律,对照想要的输出顺序反推就行。
工程里经常说“不要用递归处理深度不可控的搜索”,就是因为系统栈容量不可控。显式栈虽然代码看起来啰嗦一点,但它把“存储什么状态、何时弹出、何时终止”暴露在显式逻辑中,可调试性和可控性都更好。
5.3 底层容器替换与适配器边界
stack 和 queue 的第二个模板参数是可以换的,这给了我们一点定制空间。比如栈完全可以用 vector 当底层容器,写法是std::stack<int, std::vector<int>> st;,此时内部自动调用 vector 的 push_back 和 pop_back,语义依然是栈。queue 则比较挑食,它要求底层容器支持 pop_front,而 vector 没有这个能力,所以一般只能用 deque 或 list。
但是这里有一个容易误解的点:换了底层容器,也不代表你能享受底层容器的全部能力。std::stack<int, std::vector<int>>依然没有 begin(),没有 operator[],没有迭代器。适配器把底层容器的公开放行口收窄了,它不会因为你换了 vector 就把 vector 的全套接口都暴露出来。如果你想用 vector 的随机访问,那直接声明std::vector<int>就好了,用 stack 的意义就在“限制”,不在“释放”。
需要底层容器能力的情况很典型:调试时想打印栈里所有元素,发现 stack 无能为力;想快速判断栈顶以下第二个元素是多少,也做不到。这时候我通常直接换数据结构,或者设计专门的辅助结构,而不是硬扛。记住栈和队列是“行为约束层”,它们保证的是语义正确性,牺牲的是灵活性,这是取舍,不是缺陷。
5.4 问题速查:常见错误与解法一览
| 现象 | 根因 | 解决办法 |
|---|---|---|
| top() 取到垃圾值 | 栈为空时调用 top | 先 empty() 判断,再 top() |
| 弹出顺序不对 | pop 和 push 配对错乱,或倒腾时未倒完 | 先读后删;两个栈倒换时一次性倒空 |
| 遍历不到栈内元素 | stack/queue 没有迭代器 | 用副本循环弹空;或直接改用 vector/deque |
| 自定义类型编译错误 | 类型不可拷贝/移动 | 检查拷贝构造、移动构造,用 emplace 替代 push |
| 递归深度一大就崩 | 系统调用栈容量有限 | 改用显式 std::stack 模拟递归 |
| priority_queue 堆顶方向反了 | 比较器写反 | 大顶堆用<,小顶堆用>,先用小例子验证 |
| q.size() 在循环里变化导致分层错误 | 循环内新节点入队改变 size | 循环前固定int sz = q.size() |
最后再分享一个我自己的调试习惯。遇到栈或队列相关的神秘 bug,我第一件事不是看算法逻辑,而是把所有涉及 top/pop 的地方打印出来,标注当前栈的 size。栈的 size 是一切判断的锚点,只要 size 的变化和你的压入弹出次数对不上,问题基本出在“重复弹出”或“空容器操作”上。这个方法在初学阶段帮我抓出了大量自己都意识不到的越界操作。栈和队列看着简单,真正用熟,靠的是一遍一遍在报错和排查中积累起来的边界感。