栈 stack:从零搭起一套「后进先出」的工具箱
栈(stack)是一种「后进先出」的数据结构——想象一摞只能从顶部放、取的书,最后放上去的最先被拿走。这一篇把它的 API、四道经典栈题、以及延伸出来的「表达式」相关概念串成一份工具笔记。
一、五分钟上手:std::stack
栈在 C++ 标准库里只对外暴露 5 个常用接口:
| 方法 | 作用 | 是否改动栈 |
|---|---|---|
push(x) |
把 x 压到栈顶 |
✅ |
pop() |
弹出栈顶(不返回值) | ✅ |
top() |
返回栈顶元素的引用 | ❌ |
empty() |
栈是否为空 | ❌ |
size() |
元素个数 | ❌ |
最容易踩的坑:pop() 不返回值。要拿到栈顶值,必须先 top() 再 pop()。
1 |
|
std::stack 只是一个适配器,底层默认是 deque,也可以指定为 vector 或 list:
1 | std::stack<int, std::vector<int>> s2; // 底层换成 vector |
绝大多数题用默认 deque 就够了,不必纠结。
二、经典题型一:括号匹配 P1739
题目:给一个只含 ( 和 ) 的字符串,判断括号是否完全配对。
思路:用栈模拟「期待」。
- 遇到
(:压栈,相当于「期待一个)」; - 遇到
):- 如果栈顶是
(,说明匹配上了,弹栈; - 否则(栈空 / 栈顶不是
(),直接输出NO——多出的右括号永远消不掉。
- 如果栈顶是
- 全部跑完后栈空说明完全匹配,否则说明
(多出来。
1 |
|
易错点:原文里 for(int i=0;i<s.length()-1;i++) 这种写法会把最后一个字符丢掉,对于空字符串还会变成 size_t 的巨大值——范围 for + 空判断是更稳的写法。
三、经典题型二:验证栈序列 P4387
题目:给两个长度 n 的排列 a(入栈顺序)和 b(期望出栈顺序),问 b 是不是一个合法的出栈序列。
思路:模拟入栈过程,一有机会就出栈。
- 维护
j指向当前期望从b出栈的位置; - 把
a[i]一个一个压栈;每次压完立刻检查栈顶是不是b[j]——是就弹栈、j++,循环到不能再匹配为止; - 全部入栈完后,
j == n就说明b是合法出栈序列,否则不是。
为什么这样是对的:任何出栈动作都必须紧跟某个入栈动作,所以我们把「入栈 → 尽量匹配出栈」夹在一起模拟,不会错过任何合法的出栈时机。
1 |
|
四、经典题型三:后缀表达式求值 P1449
后缀表达式(逆波兰式)把运算符写在操作数之后。计算规则只有一句话:用一个栈扫一遍表达式,遇到数压栈,遇到运算符弹两个数算一下压回去。
洛谷 P1449 的字符串格式:用 . 分隔数字,运算符单字符,@ 结尾,例如 1.2.+3.*@ 计算 1+2 得 3,再乘 3,得 9。
1 |
|
关键细节:
- 弹栈顺序:先弹的当右操作数。表达式
b - a,a是右侧数字,先出栈; - 用
while循环读数字,因为数字可能不止一位(12、345); - 用
@作为终止符,不要写s.size() - 1这种 off-by-one。
五、经典题型四:出栈序列数 P1044
题目:1, 2, …, n 依次入栈,任何时刻可以「入栈」或「出栈」,问能得到多少种不同的出栈序列。
这就是卡特兰数(Catalan number):
直接用公式:
1 |
|
为什么是卡特兰数?把入栈看作 +1、出栈看作 -1,任意时刻栈里元素数 ≥ 0,等价于从 (0, 0) 走到 (2n, 0)、不穿越 x 轴的路径数。著名的「Dyck 路径计数」,结果就是卡特兰数。n ≤ 18 时答案 ≤ 6564120420,long long 装得下。
六、进阶:单调栈 P5788
题目:数组 a[1..n],对每个位置 i,求右边第一个比 a[i] 大的元素的下标。
思路:从右往左扫描,维护一个「栈顶元素对应数值单调递增」的栈,里面存下标。当处理 i 时:
- 栈顶 ≤
a[i]的位置:在i的右边、但比a[i]小或相等,永远不会是a[i]的答案,弹掉; - 弹完之后如果栈不为空,栈顶就是「右边第一个比
a[i]大的位置」; - 把
i压栈。
1 |
|
为什么是对每个 i,栈里剩下的就是「右边第一个比 a[i] 大的」? 因为我们从右往左构造这个栈,每个位置被压栈之前都已经把「比它矮的」全清掉了,所以栈顶永远是剩余元素中值最小的那个下标;同时栈顶也最靠右。因此栈顶恰好就是「右边第一个 ≥ 自己」的、且一定严格更大(因为等于的已被弹出)。
复杂度:每个元素最多入栈、出栈各一次 → O(n)。
单调栈套路总结:
「右边第一个比我大/小」:从右往左扫,弹掉栈顶 ≤(或 ≥)自己的元素,剩下栈顶就是答案。
七、前缀/中缀/后缀表达式
三种记法的对应关系以 1 + (6 + 2) * 3 为例:
| 记法 | 别名 | 例子 | 运算符位置 |
|---|---|---|---|
| 中缀 | infix | 1 + (6 + 2) * 3 |
操作数中间(人类写法) |
| 前缀 | 波兰式 | + 1 * + 6 2 3 |
操作数前面 |
| 后缀 | 逆波兰式 | 1 6 2 + 3 * + |
操作数后面 |
手算转换:括号法
前缀:先把表达式按运算顺序全加括号,然后把每个运算符移动到对应括号的左边,最后去括号。
1 | 1 + ((6 + 2) * 3) 全加括号 |
后缀:同样的括号化,但把每个运算符移动到对应括号的右边。
1 | 1 + ((6 + 2) * 3) |
计算方法
- 前缀表达式:从右往左扫描;遇到数压栈,遇到运算符弹两个数计算(左先出、右后出),结果压栈。
- 后缀表达式:从左往右扫描;遇到数压栈,遇到运算符弹两个数(右先出、左后出),结果压栈。
口诀:
前缀:右进栈,从右往左,后弹先算。
后缀:左进栈,从左往右,先弹先算。
八、易错点清单
pop()不返回值。要top()先取再pop(),或者干脆auto x = s.top(); s.pop();。- 空栈访问
top()或pop()是未定义行为。每次访问前先!empty()判断。 - 括号题里
s.length()-1容易越界。空字符串时size_t - 1会变成巨大值;而且会丢掉最后一个字符。用范围 for 就好。 - 后缀表达式求值:弹栈时先弹的当右操作数。
b - a中,a是右侧的a,先出栈。 - 单调栈别忘了「等于自己」的处理。「右边第一个更大」要弹掉
≤,「右边第一个更小」要弹掉≥。要不要弹「等于自己」取决于题目的严格 / 非严格。 - 卡特兰数除法方向:先
* (n+i)再/ i,n ≤ 18时都能整除;如果用高精度要记得消去因子。
九、小结
栈的核心只有一句话:FILO(First In Last Out)。只要把这一条背熟,所有「先进后出」「最近匹配」「最近的还没处理」的场景都能往栈上靠:
| 场景 | 触发条件 |
|---|---|
| 括号匹配 / 嵌套结构 | 遇到开括号压栈,遇到闭括号匹配栈顶 |
| 表达式求值 | 遇数压栈、遇运算符弹两算一 |
| 出栈序列验证 | 模拟入栈、一有机会就出栈 |
| 单调栈 | 维护单调性、找「最近一个更大 / 更小」的位置 |
| DFS / 函数调用 | 递归本身就是一个调用栈 |