复试确定之后,我把练手标准定成了“每天稳定3题”:不求一天刷十道,但求当天这三题每一个细节都能讲清楚。某高校复试OJ编号130到132,就是我某天的打卡清单。当时我只觉得是普通三题,做完之后回头整理,才发现三题刚好是一条很完整的栈学习链路:第一题用栈做表达式求值,第二题用栈做括号配对并统计深度,第三题直接上升到“最长合法括号子串”。本来只想简单记录,但复盘的收获比我预期大得多,所以整理成这篇完整笔记,分享给同样在准备复试机试或者刚学完栈、想找典型题练手的读者。
1. 为什么把130~132放在一起复盘
如果只看题号,你会以为这只是从题库里随机分页出来的三道题,实际做完就会发现,它们之间有一条隐蔽的递进。130是最基本的后缀表达式求值,数据结构和算法关系非常直白;131虽然也是括号题,但多了一个输出配对位置的要求,于是必须从“记录个数”升级到“记录下标”;132表面仍然是一道括号题,可它要的不是“是否合法”,而是“最长合法段的长度”,这已经超出简单栈模拟,需要借助虚拟栈底或者动态规划。这三题放在一起复盘,正好覆盖了栈应用的三个典型层次。
1.1 三道题分别对应栈的三种角色
我的理解里,栈在这三题里不是同一个东西。
130题里,栈是一个计算容器。从左到右的每个 token 要么压栈,要么弹两个数算完再压回去,栈顶就是当前的值。本质是“后进先出”对应了后缀表达式的运算顺序。
131题里,栈是一个配对记录器。左括号出现时压栈,右括号出现时弹栈,栈空说明配对失败。但如果只压“字符”,最后就没办法告诉用户“第几个左括号匹配第几个右括号”,所以栈元素必须从字符变成下标。
132题里,栈既要记录位置,还要维护一个“分割点”。栈底始终保存着“最后一个还没匹配的右括号的位置”,每次能算出当前合法段的长度。这一步会让很多习惯把栈想成“只管括号进出”的人卡住,我自己就是其中一个。
把这些角色归类后,再看到任何一道新题,你就不会只会套“left push,right pop”,而是会先问一句:这个栈到底在维护什么东西?
1.2 我的做题时间与卡点记录
三道题我实际花费的时间差别很大:130接近8分钟,属于热身;131花了大约15分钟,其中大部分时间花在思考怎么输出配对顺序,实际上是因为我一开始用了计数法,发现满足不了输出要求;132花了超过35分钟,第一次提交还错了一个样例,原因是栈底的分割点维护得不对。做完之后我专门用一个小时把三种写法和两种解法全部梳理了一遍。
这个时间分布本身也说明了一些问题:如果只刷题不复盘,130和131我可能第二天就忘干净,132也只是迷迷糊糊过了。但把三种角色串起来之后,后续几天再遇到“去除重复字母”“每日温度”这类栈题,我的思路明显顺了很多。这就是把一组题放在一起复盘的价值。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第130题:后缀表达式求值的进出栈顺序
这一题看起来简单,但它奠定了整组题的基本盘。如果进出栈顺序没搞对,后面两道题即使思路正确,也可能在类似的细节上翻车。
2.1 题面与样例
当时的题面大概是:输入一个用空格分隔的后缀表达式,包含整数操作数和 +、-、*、/ 四种二元运算符,操作数可能为负,要求输出表达式的值。除法按整数除法处理,向零取整。
“后缀表达式”也叫逆波兰表达式,特点是运算符写在两个操作数后面。比如中缀 (2 + 3) * 5 写成后缀就是 2 3 + 5 *,它的值是 25。如果是 -2 3 * 5 +,含义就是 (-2) * 3 + 5,输出 -1。
实际评测时输入可能像这样:
text复制3
1 2 +
4 2 - 3 *
-2 3 * 5 +
对应输出 3、6、-1。
2.2 为什么后缀表达式天然对应栈处理
后缀表达式里没有括号,没有优先级,运算顺序完全由 token 的排列决定:从左到右扫描,只要遇到数字就暂时存储,遇到运算符就把最近存储的两个数字取出来做运算。这个“最近存储”恰好是后进先出,所以栈是自然选择。
用生活类比:把栈想成一摞盘子,数字就是盘子。碰到数字就把新盘子放上去,碰到运算符就取最上面的两只盘子,算完再把结果盘子放回去。整个过程不需要回看前面的 token,也不用担心优先级,因为后缀式已经把所有优先级信息编码在顺序里了。
2.3 最容易错的:减法和除法的操作数顺序
这是几乎每个新手都会遇到的一个坑。弹出栈顶时,第一个弹出的数字是后缀式里靠后的那个操作数,也就是右操作数;第二个弹出的才是左操作数。所以 a - b 的代码必须顺序写成“先取出 b,再取出 a,然后计算 a - b”,除法同理。
我举一个实际例子:表达式 2 - 3 的后缀是 2 3 -,正确计算得到 -1。如果写反了,写成 b - a,得到的会是 1。数字一正一负的误差放到整套题里,可能就决定这次提交是否 AC。尤其是减法,很多人心里清楚必须 a - b,但代码写起来顺序一乱,恰好把左右颠倒了。
这里有个自查口诀:栈里先弹出来的,永远是后缀式里靠后的那个操作数,也就是右操作数;后弹出来的才是左操作数。做减法除法前,先在草稿纸上把两个数标成 a、b,再写表达式,能省掉一次提交。
2.4 完整实现与负数 token 的边界处理
下面是一版可以直接跑的 C++ 核心代码:
cpp复制int evalRPN(vector<string>& tokens) {
stack<int> stk;
for (const string& s : tokens) {
if (s == "+" || s == "-" || s == "*" || s == "/") {
int b = stk.top(); stk.pop();
int a = stk.top(); stk.pop();
if (s == "+") {
stk.push(a + b);
} else if (s == "-") {
stk.push(a - b);
} else if (s == "*") {
stk.push(a * b);
} else {
stk.push(a / b);
}
} else {
stk.push(stoi(s));
}
}
return stk.top();
}
值得特别说明的是负数 token 的边界。-2 在字符串数组里是 "-2",它的首字符也是 -。如果判断运算符时写成 if (s[0] == '-'),程序就会把 -2 当成减号去弹栈,然后 stoi 时报错或得到完全错误的结果。稳妥写法是拿整个字符串去和运算符比较,而不是看第一个字符。
除法的取整方式也要和题面对齐。C++ 的整数除法在 C++11 及之后是向零取整的,也就是说 -7 / 2 = -3。如果某道题要求向负无穷取整,那就不能直接用默认除法,需要根据正负号做修正。复试机试里常见要求是向零取整,所以默认行为通常够用。
2.5 两个容易被忽视的小细节
第一个是弹出操作数之前最好确认栈不为空。虽然题面保证表达式合法,但调试时如果因为别的原因混入一个非法 token,运行时崩溃会很难定位。第二个是结果也可能是负数,不要默认表达式结果都是非负整数。
这类题并不难,但正是“不难”才可怕:一旦手滑,复查时人眼往往看不出逻辑问题,因为它错在方向性细节上。130 题我建议控制在 10 分钟内写完并跑稳,复试时才有余力处理后面更麻烦的题。
3. 第131题:括号配对与深度统计,栈里到底存什么
这道题是从“纯数值栈”进入“栈存下标”的一道转折题。147 的教训让我明白,看到括号题先别急着敲代码,要先问一句:最终需要输出什么?
3.1 题面、样例与分析目标
题面大概是:给定一个由小括号、英文字母和数字组成的字符串,要求先判断括号是否匹配;如果匹配,输出最大嵌套深度,并输出每个左括号对应的右括号位置;如果不匹配,输出 -1。
举例,字符串 a(b(c)d)e 中,字母数字都不影响括号,共有两对括号:外层左括号在第2个字符位置,匹配的右括号在第8个字符位置;内层左括号在第4个位置,匹配的右括号在第6个位置。最大嵌套深度为 2。
如果使用 1-based 输出,示例就是:
text复制2
2 8
4 6
第一行是最大深度,后面行是配对坐标。
这里的难点不是“判合法性”,而是“给出配对关系”。只用一个计数器就能判断是否匹配,但计数器无法回答“外层的右括号到底对应哪一个左括号”。
3.2 只判断合法与深度的计数法为什么成立
如果不要求输出配对位置,有一个更轻量级的做法:维护一个变量 cnt,扫到 ( 时 cnt 加 1,扫到 ) 时 cnt 减 1。任何时刻 cnt 小于 0,说明出现了一个没有左括号匹配的右括号,直接判非法;扫描结束后 cnt 必须为 0,否则说明有没被匹配的左括号。最大深度就是扫到 ( 之后 cnt 的最大值。
为什么这个简单方法有效?因为括号配对本质上是一种“嵌套匹配”,可以仅通过数量关系判断合法性,不需要记忆每个括号的位置。cnt 表示“当前还有多少层左括号没被关闭”,只要过程中的每一次减法都不是在 cnt 为 0 时发生,最终又能归零,括号串就合法。这条性质经常用在面试手写回答里,但它的表达能力有限:一旦需要具体指出配对关系,就必须升级。
3.3 输出配对位置:栈从“存字符”升级到“存下标”
计数器没有历史记录,所以无法知道“当前右括号对应哪个左括号”。栈天然能补上这个能力:把左括号的下标压栈,而不是把字符 ( 压栈。遇到右括号时,弹出栈顶,弹出来的就是它对应的左括号下标。
这就回答了不少人的疑问:“为什么有的题解里栈里存 int,有的存 char?”答案是看需求。131 题要求输出配对的左右坐标,所以存下标;130 题只需要栈顶数值,所以存操作数结果;132 题需要计算长度,所以既存位置也维护分割点。这是复盘里最值得记住的一条——先想清楚栈元素代表什么,再动手。
3.4 完整实现与最大深度的顺手统计
我给出核心代码,按 0-based 扫描,输出时转成 1-based:
cpp复制vector<pair<int,int>> solve(string s) {
stack<int> stk;
vector<pair<int,int>> pairs;
int maxDepth = 0;
for (int i = 0; i < (int)s.size(); ++i) {
if (s[i] == '(') {
stk.push(i);
maxDepth = max(maxDepth, (int)stk.size());
} else if (s[i] == ')') {
if (stk.empty()) {
return {}; // 非法
}
int left = stk.top();
stk.pop();
pairs.push_back({left + 1, i + 1});
}
// 其它字符直接忽略
}
if (!stk.empty()) return {}; // 非法
return pairs;
}
需要额外说明的是深度更新方式:stk.size() 在每次遇到新的左括号之后,正好等于这个左括号当前的嵌套层数,用它更新最大深度,不需要再单独维护第二个计数器。配对坐标存在 pairs 里,函数最终返回时,合法情况就带回所有配对。
3.5 我踩过的两个输出坑
第一个坑是输出顺序。扫描过程中,配对记录的顺序和右括号出现顺序一致,也就是从内层到外层。如果题目要求按左括号位置升序输出,就不能边扫描边直接打印。比如 (a(b)c) 扫描时会先记录内层配对,再记录外层配对,直接输出就会顺序颠倒。稳妥做法是把配对结果存进数组,最后统一按左括号下标排序。
第二个坑是非法情况的判断时机。要记得检查扫描结束后栈是否仍非空,例如字符串 ((()) 就有一个左括号没有匹配。如果忘记这一步,会把这个字符串也当作合法处理,从而得到错误答案。反过来,如果扫描过程中遇到右括号时栈已经为空,也要立即判非法,不要等扫描结束再处理。
4. 第132题:最长合法括号子串,从分割点到动态规划
这道题是整组题里区分度最高的一道,也是我自己的翻车现场。题面很短,但解法里藏着一个很关键的设计。
4.1 题面与样例
给定一个只包含 ( 和 ) 的字符串,返回最长合法括号子串的长度。
“子串”意味着连续的一段,并且这段本身是一个合法的括号序列。例如 ()(() 中,合法的连续子串只有 (),长度 2;(() 的答案也是 2;)()()) 的答案则是 4,因为从下标 1 到 4 的 ()() 是连续的合法段,长度 4。
这道题虽然和 131 一样都在玩括号,但难度明显提升:131 只需要判断整个串是否合法,132 需要在连续的串里找“一段最长的合法区间”。数量和配对关系都不够,还要管理“区间边界”。
4.2 为什么简单方法会挂:计数法在这里失灵
有人会想:我沿用计数法,把 cnt 清零的每个区间记录下来,选最长的,不就行了吗?这个思路在 (()) 上没问题,但遇到 (() 就会出错。扫描 (() 时,cnt 从 1 到 2 再到 1,全程没有归零,按“归零才更新答案”的逻辑,答案就是 0,但最长合法子串是下标 1 和 2 组成的 (),长度是 2。
问题根源在于,一旦字符串里存在多余的左括号,cnt 永远不会归零,但最长合法段可能在某个局部已经闭合。计数法只关心全局的“已配平”点,天然会丢掉这些局部合法段。栈解法要解决的正是这个问题。
4.3 栈解法:栈底的分割点是核心
我在复盘时把这道题的栈解法拆成三句话:
第一句:扫描从左到右,遇到 ( 就把下标入栈。
第二句:遇到 ) 就弹出栈顶,弹出来的是它对应的左括号,也可能是分割点。
第三句:弹完之后如果栈空,说明这个 ) 没有可匹配的左括号,它自己就是新的分割点,把当前下标入栈;如果栈不空,栈顶元素就是当前合法段左边的分割点,长度 = 当前下标 - 栈顶。
为什么初始化时要先压入 -1?因为字符串开头可能就是合法段的起点,此时需要一个“左边界”。-1 相当于哨兵,让 () 从一开始就能计算出 1 - (-1) = 2 的长度。如果不加这个哨兵,就得为 i=0 单独写分支,代码容易漏。
给出代码:
cpp复制int longestValidParentheses(string s) {
stack<int> stk;
stk.push(-1);
int ans = 0;
for (int i = 0; i < (int)s.size(); ++i) {
if (s[i] == '(') {
stk.push(i);
} else {
stk.pop();
if (stk.empty()) {
stk.push(i);
} else {
ans = max(ans, i - stk.top());
}
}
}
return ans;
}
以 )()()) 为例:i=0 是 ),弹掉 -1,栈空,push 0;i=1 是 (,push 1;i=2 是 ),弹掉 1,栈此时只剩 [0],ans = 2 - 0 = 2;i=3 是 (,push 3;i=4 是 ),弹掉 3,栈 [0],ans = 4 - 0 = 4;i=5 是 ),弹掉 0,栈空,push 5;最终答案 4。这条路径会把“每个多余右括号都变成新的分割点”这个逻辑演示得很清楚。
4.4 动态规划解法:状态以“i结尾”来定义
如果不想用栈,也可以从动态规划角度想。定义 dp[i] 为“以 i 结尾的最长合法括号子串长度”。这一定义的好处是,合法子串必然是连续区间,而区间的结尾必须落在某个右括号处。
转移分两种情况:
如果 s[i] 是 ) 且 s[i-1] 是 (,那么 () 直接配对,长度至少 2,还能接上 i-2 之前的合法段,所以 dp[i] = dp[i-2] + 2(如果 i-2 存在)。
如果 s[i] 是 ) 且 s[i-1] 也是 ),说明 i-1 处已经有一段合法串,长度 dp[i-1],这段合法串左端的前一个字符如果恰好是 (,就能和 s[i] 配对,配完后再接上该左括号前面的合法段。令 pre = i - dp[i-1] - 1,如果 pre 位置是 (,则 dp[i] = dp[i-1] + 2 + dp[pre-1]。
核心代码:
cpp复制int longestValidParentheses(string s) {
int n = s.size();
vector<int> dp(n, 0);
int ans = 0;
for (int i = 1; i < n; ++i) {
if (s[i] == ')') {
if (s[i-1] == '(') {
dp[i] = (i >= 2 ? dp[i-2] : 0) + 2;
} else {
int pre = i - 1 - dp[i-1];
if (pre >= 0 && s[pre] == '(') {
dp[i] = dp[i-1] + 2 + (pre >= 1 ? dp[pre-1] : 0);
}
}
ans = max(ans, dp[i]);
}
}
return ans;
}
为了不绕晕,我用 ()(()) 验证一下:i=5 是 ),dp[4]=2,pre = 5 - 1 - 2 = 2,s[2] 是 (,所以 dp[5] = 2 + 2 + dp[1] = 2 + 2 + 2 = 6,整体合法长度 6。这个例子能覆盖第二种转移的全部细节,建议手动跑一遍。
4.5 两种解法的对比与使用场景
| 解法 | 时间 | 额外空间 | 记忆难度 | 适用情况 |
|---|---|---|---|---|
| 栈 | O(n) | O(n) | 低 | 面试/机试首选,代码短,容易自证 |
| 动态规划 | O(n) | O(n) | 中 | 要求“连续子段极值”时可迁移,DP思维更通用 |
复试机试时间紧张,我更推荐栈解法。它的核心就一条规则:遇到右括号先弹栈,弹空再压入当前下标作为分割点。动态规划解法适合放到复盘阶段理解,因为“以 i 结尾”的定义方式是许多字符串 DP 题的共同起点,理解以后遇到“最长有效括号”的变式会有更稳的底子。
这里有一个我自己的失败记录:第一次写栈解法时,我在遇到 ) 时先判 if (stk.size() > 1) 再决定是否弹栈,导致 i=0 的 ) 没有正确变成分割点,整个答案从开头就错了。看完题解才发现,标准流程就是“遇到右括号:先弹,弹空再压”。这个顺序不能改。
5. 三道题横向对比:考点、代码量、易错点的区别
把三道题放在一张表里看,递进关系会非常清楚。
5.1 一组直观的对比表
| 题号 | 核心考点 | 栈维护的内容 | 最大易错点 | 建议完成时间 | 核心代码量 |
|---|---|---|---|---|---|
| 130 | 后缀表达式求值 | 运算中间结果 | 减除法的操作数顺序 | 8-10分钟 | 约20行 |
| 131 | 括号配对 + 深度 | 左括号下标 | 输出顺序和栈空判断 | 12-15分钟 | 约30行 |
| 132 | 最长合法括号子串 | 下标 + 分割点 | 分割点维护时机 | 20-35分钟 | 栈约15行,DP约20行 |
表中代码量按核心函数计算,不含输入输出。顺序看下来,三道题对应了“栈存数据 → 栈存下标 → 栈存边界”三个阶段。如果一道新题能快速判定它属于哪个阶段,基本就能确定用什么模板了。
5.2 从这三题提炼出的四个通用习惯
第一个习惯是运算符配对时,先取出的数当作右操作数。这不只在 130 有用,凡是“两两配对然后运算”的问题都要警惕顺序。
第二个习惯是当需要坐标、序号、位置信息时,栈里存的应该是下标而不是字符。131 给出配对位置,132 用下标算长度,都是这个习惯的具体应用。
第三个习惯是边界哨兵能省掉大量 if。132 里的 -1 就是典型。字符串 DP 里的负下标通常也要用哨兵或越界判断补上。学会主动加哨兵,代码会清晰很多。
第四个习惯是字符串合法段问题先想两个方向:栈和“以 i 结尾”的 DP。栈适合当场求解,DP 适合在复盘阶段加深理解。两者不是互斥的,很多题两种都能解。
5.3 复盘要落在“模型层”
刷题如果只记住代码,过一周见到变式还是会懵。我复盘时会把题压缩成一句话模型:130 是“后缀式求值,遇到运算符弹两个数”,131 是“需要配对坐标,栈内存下标”,132 是“最长合法子串,栈底放着分割点”。下次看到新题,先尝试把题面映射到这些模型里。比如“去除相邻重复字母”可以映射到“单调栈维护结果串”,“每日温度”映射到“单调栈维护下标”。模型到位,写代码只是体力活。
6. 复试刷题复盘的具体建议
复盘方法本身也需要讲究。每天只刷题不复盘,就像拍照不修图,数量上去了,质量还在原地。我在这里补充三条自己验证过有效的操作习惯。
6.1 每天三题的时间分配
我的建议是每天固定一个时段,比如上午 9 点到 10 点,只安排三题。每题先给自己一个倒计时:简单题 12 分钟,中等题 20 分钟,区分题 35 分钟。时间到却没有 AC,不要继续耗,直接看题解,然后在复盘笔记里记下卡点。相比刷完不管,把一题在复盘阶段彻底想透,对复试的短期冲刺帮助更大。
当然,这个时间标准因人而异。如果你刚接触栈,先不用强求 8 分钟做完 130,慢慢把正确率提上去,再逐步压缩时间。机试看的是规定时间内的总分,不是单题速度。
6.2 我自己一直用的复盘模板
每次打卡后,我会按固定的模板写几条:
- 题号与题名:让后续复习可以快速定位。
- 一句话题面:把整个问题压缩成一句话。
- 我的解法和题解的差异:如果完全一致就写“一次 AC,与题解同思路”,不一致就写清楚差在哪。
- 卡了多长时间、卡在哪个判断:比“不会做”更有用,因为下次可以针对性预防。
- 可以迁移的模型:例如“出现括号成对问题,优先想栈存下标”。
这套模板把刷题从“过脑子”变成“留证据”。复试前一晚只需要翻一本笔记,而不是打开几百道题的提交记录。
6.3 机试现场遇到同类题的应对顺序
真到机试现场,遇到括号、表达式、栈相关的题,我建议按这个顺序走:
先看时间复杂度。如果 n 不超过 10 的 5 次方,O(n) 的栈或 DP 通常就是正解。然后看需要输出什么:只输出长度,优先栈;需要输出配对或其他坐标,栈里存下标;需要判断合法性,计数法往往也能过。最后手写两三个例子,尤其要试 )() 和 (( 这类边界,避免分割点写错导致全盘皆输。
这种迁移式的应对能力,比背模板可靠得多。
这三题做完之后,我最大的感受是:栈看起来是个基础数据结构,但“栈里存什么”这个一念之间的选择,决定了一道题是简单还是困难。复试准备越到后期,越要相信“稳定输出”比“大量刷题”值钱。130 到 132 这套复盘,帮我花最短的时间把栈这个点彻底钉牢了。后面几天我刷到“最小栈”“接雨水”这类题时,明显感觉到这个复盘的余效还在。希望你也能给自己留出复盘时间,用一组题把一类模型聊透,比浮光掠影刷十道题有用得多。
