1. 从"认识工具"到"识别场景":Day11的训练重点
1.1 先说清楚part01和part02到底差在哪
我所在的算法训练营,Day10刚把栈和队列的基本概念过了一遍:栈是后进先出,队列是先进先出,还做了两道互相模拟实现的题目,那会儿大家的感觉是"规则简单,代码也不难"。到了Day11,画风突然就变了——当天安排的三道题里,没有一道会在题目描述里直接告诉你"请用栈",而是需要你自己从场景里认出它来。这个转折非常关键,也是我写这篇文章的动机。
这三道题分别是:经典的括号匹配、字符串相邻重复项消除、逆波兰表达式求值。题目之间看起来毫不相干,但实际上全部围绕同一个核心动作:在遍历的过程中,维护一个"最近一个还没处理完的元素"。这正是栈最擅长的事,也是队列很难替代的事。如果你最近也在按专题刷题,或者正准备系统地啃栈和队列,这篇文章会把当天的完整思路、代码细节、踩过的坑都整理出来,照着走一遍应该能少走不少弯路。
1.2 三道题都在追同一个东西:"最近未处理元素"
先别急着看题解,我们把共性抽出来。括号匹配里,遇右括号时要找谁?找"最近的左括号"。字符串消消乐里,遇重复字符时要消谁?消"栈顶那一个"。逆波兰表达式里,遇运算符时要计算谁?计算"最近的两个数字"。发现没有,这三个场景都带着"最近"两个字。
为什么会这样?因为现实世界很多操作本来就具备后进先出的属性:最后打开的括号先闭合,最后出现的相邻字符先被消除,最后入栈的操作数先参与运算。所以判断一道题要不要用栈,关键不是看它长什么样,而是看它需不需要"回到最近的状态"。用队列行不行呢?不行。队列的特点是先进先出,它会强行把处理顺序改成"最早出现的先处理",和这些场景天然拧着来。认识到这一步,比单纯记住"栈是LIFO"要实用得多——面试时能说出这一层理解,往往比闷头写代码更让考官点头。
1.3 为什么这种能力在面试中才是加分项
很多刚开始刷题的读者容易陷入一个误区:把精力花在背各种数据结构的API上,却忽略了"场景识别"。实际上,真正决定题目能不能做出来的,是你能不能从一段普通文字里闻出栈的味道。比如看到"嵌套""撤销""匹配""表达式求值",脑子里就应该自动弹出"栈"这个候选答案;看到"排队""层序""滑动窗口",再往队列那边靠。
Day11这三道题就是最好的训练素材:它们的解法本质上都是把现实场景抽象成"栈顶操作"。等这种抽象能力练出来之后,你会发现后面做二叉树遍历、做深度优先搜索,甚至看编译器相关的知识,很多地方都能串起来。这个我在3.3节还会展开说。
2. 用"消消乐"理解栈顶:括号匹配与相邻字符消除
2.1 括号匹配的三种失效场景与一次扫描判定
括号匹配是一道非常经典的入门题,但"经典"不代表"简单",恰恰因为它的边界条件多,很多人在训练营里第一次提交就翻车。题目的要求是:给定一个只包含()[]{}的字符串,判断括号是否有效。有效规则有三条:左括号必须用相同类型的右括号闭合;左括号必须以正确的顺序闭合;每个右括号都有一个对应的左括号。
用栈的解法一句话就能概括:遇到左括号就压栈,遇到右括号就对比栈顶。但这个对比过程要仔细拆,因为失效情况其实有三种,漏掉任何一种都会出bug。
第一种是左括号多了,比如(((),遍历结束后栈里还剩东西,说明有左括号一直没等到右括号,直接判false。第二种是右括号多了,比如()),走到第二个右括号时栈已经空了,没有左括号可以和它配对,也要判false。第三种是左右括号类型不匹配,比如(],栈顶是(,来的却是],同样失败。
我推荐的写法是这样:先用一个字典把右括号映射到左括号,然后遍历字符串,遇到右括号的时候,要么栈空返回false,要么栈顶不是对应的左括号返回false,匹配上了就弹出;遇到左括号就入栈。最后返回not stack,也就是栈必须为空。
def isValid(s: str) -> bool: if len(s) % 2 == 1: return False pairs = { ")": "(", "]": "[", "}": "{", } stack = [] for ch in s: if ch in pairs: # 右括号 if not stack or stack[-1] != pairs[ch]: return False stack.pop() else: # 左括号 stack.append(ch) return not stack有几个细节值得说明。第一,开头判断长度为奇数可以直接返回false,因为左右括号一定是成对出现的,奇数长度不可能有效,这是一个零成本的剪枝。第二,用字典做映射,比写一堆if ch == ')' and stack[-1] != '('要清爽得多,也更容易扩展。第三,先判断栈空再访问栈顶,这个顺序不能反,否则空栈访问栈顶会直接报错。这个习惯几乎适用于所有栈类题目,建议刻进肌肉记忆。
2.2 相邻重复项消除:用字符串本身当栈的小技巧
第二道题是字符串相邻重复项消除,听起来像消消乐游戏。规则是:每次删除字符串中相邻且相同的两个字符,然后继续处理新的相邻重复,直到不能再消为止。举例来说,输入"abbaca",第一步删除中间的"bb",得到"aaca",此时"aa"又相邻重复了,再删除,最后剩下"ca"。
为什么这题天然适合栈?因为删除行为会产生"连锁反应":手动删除一对之后,原本不相邻的字符可能会变成新的相邻重复,而这个"新邻居"恰好就是删除位置往前看的那一个。这跟括号匹配完全是一个模式——永远在处理最近的信息。
代码很短,但很值得逐行理解:
def removeDuplicates(s: str) -> str: stack = [] for ch in s: if stack and stack[-1] == ch: stack.pop() else: stack.append(ch) return "".join(stack)每一步都是:看当前字符和栈顶是否相同,相同说明这一对相邻重复了,把栈顶干掉;不同就压栈。等所有字符都处理完,栈里从底到顶的顺序,就是最终字符串。
这里有一个小技巧:很多人在最后会把栈转成字符串,但其实你可以直接用可变序列来模拟栈,连转换都省了。在C++里可以用string当作栈,push_back和pop_back都有;在Java里可以用StringBuilder;Python里直接list存字符,最后"".join(stack)。理解了"栈只是一种操作抽象,不一定非要叫stack"这一点,写代码会更灵活。
2.3 调试括号类题目时,把"栈顶状态"写在纸上
刷括号匹配这类题时,新手最容易卡住的地方是:脑子里转得过来,但一写代码就乱。我自己的经验是,遇到逻辑绕不清的时候,不要硬想,直接在纸上画一个栈,手动跑一组用例,把每一步的栈顶写出来。
举个例子,字符串"([)]"是很多训练营学员都会犯迷糊的用例。它左右括号数量对得上,每个右括号也都有对应的左括号,但它是无效的——因为[)跨层匹配了。用栈模拟一遍就很清楚:(入栈,[入栈,遇到)时栈顶是[,不是(,直接判false。画完这一步,为什么"正确顺序闭合"会被单独列为一条规则,瞬间就明白了。
还有,手写用例时最好把三种失效方式各准备一个:左括号多((())、右括号多(()))、类型不匹配((]),写代码之前先用这三个用例过一遍思路,能拦下很多低级错误。
3. 逆波兰表达式求值:把"优先级"翻译成"最近两个数"
3.1 后缀表达式为什么天生适合栈
逆波兰表达式,也叫后缀表达式,简单说就是把运算符写在操作数后面。平时我们写1 + 2是中缀表达式,改成后缀就是1 2 +。题目会给一个用后缀表示法写的字符串数组,让你求值,比如["2", "1", "+", "3", "*"]对应的是(2 + 1) * 3,结果应该是9。
为什么后缀表达式这么适合栈?因为求值时完全不需要考虑优先级和括号,只需要从左往右扫描:遇到数字就入栈,遇到运算符就从栈里弹出两个数字计算,把结果再压回栈里。这个过程完美匹配了"最近的两个操作数和这个运算符产生联系"的语义,所以栈就是这个问题的天然结构。
顺带说一句,这也是计算机领域的经典命题:很多计算器程序的底层,都会先把中缀表达式转成后缀,再交给栈去求值。中缀表达式要判断括号和优先级,做起来很麻烦;而后缀表达式一扫就能算完,形式极其统一。
3.2 求值代码里面的三个细节
直接给一份Python实现,然后说三个值得注意的细节。
OPERATORS = {"+", "-", "*", "/"} def evalRPN(tokens: list[str]) -> int: stack = [] for token in tokens: if token in OPERATORS: # 判断是不是运算符 b = stack.pop() a = stack.pop() if token == "+": res = a + b elif token == "-": res = a - b elif token == "*": res = a * b else: # token == "/" res = int(a / b) # 向零截断的关键 stack.append(res) else: stack.append(int(token)) return stack[0]第一个细节是弹出的顺序。后进先出,所以先弹出来的是b,后弹出来的才是a。做加法乘法可能感受不到差异,但做减法除法时顺序错了结果整个就错了。比如["3", "4", "-"],正确结果是3 - 4 = -1,如果你写b - a,那就成了4 - 3 = 1,南辕北辙。我的口诀是:"先弹出的是右操作数,后弹出的是左操作数"。
第二个细节是除法的向零截断。题目要求除法向零截断,不是向下取整。在C++里直接用/就行,因为C++的整数除法就是向零截断;但在Python里,/得到的是浮点数,如果直接int(a / b),对正数没问题,对负数要小心。比如-7 / 2,在Python里浮点结果是-3.5,int(-3.5)是-3,这正好是向零截断;而如果用//,结果是-4,那是向下取整,就错了。所以这里用int(a / b)是一个很关键的细节。
第三个细节是判断运算符的方式。很多人会写if token.isdigit()来判断数字,但这样遇到负数"-2"就会出错,因为"-2".isdigit()是false。用集合OPERATORS来判断,只要不在运算符集合里,就按数字处理,负数也安全落在数字分支。这是一个很小的点,但训练营当天真的有人在这上面卡了十几分钟。
3.3 拓展练习:中缀转后缀,把今天的知识串起来
只做求值还不够,如果想彻底吃透栈在表达式里的作用,强烈建议顺手练一下"中缀表达式转后缀表达式"。这个转换的规则并不复杂,也很适合用栈实现:从左往右扫描中缀表达式,遇到操作数直接输出;遇到运算符时,只要栈顶运算符的优先级不低于当前运算符,就弹出来,再把当前运算符压栈;遇到左括号直接压栈,遇到右括号则弹出栈顶直到左括号为止。
当你亲手做完"中缀转后缀"和"后缀求值"这两件事,再看栈的很多题目,会有一种"原来如此"的感觉。比方说,一个中缀表达式对应一棵二叉树的中序遍历,而后缀表达式对应的正是这棵树的后序遍历。到了二叉树那部分再回头看逆波兰,会发现知识之间都是串着的。这也是训练营的节奏安排里,为什么一定要把栈和队列先彻底搞明白的原因。
4. 当天踩过的三个坑和一整套边界用例
4.1 三个反复出现的低级错误
再厉害的人,集中刷题时也会被一些小问题绊住。我当天就犯了三个低级错误,写出来给大家当反面教材。
第一个是pop前没判空。写括号匹配时,一心想的是"遇到右括号就弹出栈顶",忘了考虑栈本来就是空的情况。结果在线评测给了个右括号开头的用例,直接运行时报错。修复方法很简单:访问栈顶之前永远先问一句"栈空不空"。这个习惯多练几次就会变成条件反射,但前期几乎每个人都会踩。
第二个是Python除法想当然。做逆波兰的时候,我第一版写的是a // b,觉得"都是整数除法,应该没毛病"。结果跑["10", "6", "/"]没问题,但换上负数用例就错了。因为在Python里//是向下取整,不是向零截断,必须写成int(a / b),或者自己处理正负号。这种语言特性差异,光靠看文档记不住,亲自踩一次才刻骨铭心。
第三个是把负数当成运算符误判。其实我的第一版判断数字用的是token[-1].isdigit(),心想"最后一个字符是数字,那整个token就是数字",但遇到"-2"时,最后一个字符'2'是数字,所以判断是对的;可我又用了if token.isdigit()之类的写法,直接把"-2"归进运算符分支。改用固定的运算符集合后,这个问题就彻底消失了。
4.2 我用来"怼"边界条件的用例表
当天复盘时,我整理了一张边界用例表,每道题都准备了几个"刁钻"输入。现在这份表已经成了我的通用工具,做任何栈和队列题目都会先过一遍:
| 题目类型 | 用例 | 期望输出 | 验证的边界 |
|---|---|---|---|
| 括号匹配 | "" | true | 空串处理 |
| 括号匹配 | "()" | true | 最简单有效 |
| 括号匹配 | "()[]{}" | true | 多类型并列 |
| 括号匹配 | "(]" | false | 类型不匹配 |
| 括号匹配 | "([)]" | false | 跨层匹配 |
| 括号匹配 | "(((((((()" | false | 左括号溢出 |
| 括号匹配 | "))))" | false | 右括号溢出 |
| 相邻消除 | "abbaca" | "ca" | 连锁消除 |
| 相邻消除 | "aaaa" | "" | 全部消光 |
| 相邻消除 | "a" | "a" | 单字符 |
| 逆波兰 | ["2","1","+","3","*"] | 9 | 基本运算 |
| 逆波兰 | ["4","13","5","/","+"] | 6 | 除法顺序 |
| 逆波兰 | ["10","6","/"] | 1 | 整数除法 |
| 逆波兰 | ["-2","3","*"] | -6 | 负数操作数 |
这套用例花不了太多时间,但每一行都对应一类真实 bug。别觉得麻烦,训练营里写代码写着写着就报错的时刻,多半就是漏了这类边界。提前用它们给代码验一遍,能省下大量调试时间。
4.3 一个高效复盘的三段式
每天刷完题,我都会做一个很短的三段式复盘,一般控制在十分钟以内。第一段写这道题的场景关键词,比如"括号匹配 = 最近左括号""逆波兰 = 最近两个数",这一步逼自己把题目抽象成数据结构的语义。第二段写用到的栈操作,是push、pop,还是访问栈顶、判断栈空,把解题模板沉淀下来。第三段写"如果再写一次要注意什么",把当天踩的坑变成下一轮的提醒。
之前总感觉刷题是"做一题忘一题",开始复盘之后,题型之间的相似性才慢慢显现出来。到了Day11这一天,我能明显感觉到:栈类题的操作其实就那几个,难点从来不是API,而是"我什么时候该用栈,以及栈里到底存什么"。存的是字符、是数字、还是下标?这个问题想清楚,思路基本就通了。
5. 栈与队列的应用地图,以及给Day12留的接口
5.1 工程世界里栈和队列都在哪
很多刷题的人都会问:栈和队列除了应付面试,日常开发里到底有什么用?其实只要你写过代码,就天天在用。函数调用本身就是栈:函数A调用函数B,B调用C,C执行完回到B,B执行完回到A,这就是典型的后进先出。浏览器的后退按钮是栈,编辑器的撤销操作也是栈,一堆左括号的嵌套解析还是栈。
队列那边同样无处不在:操作系统的任务调度、生产者消费模型里的消息队列、Web服务器的请求排队,都符合先进先出的语义。包括我们常用的异步任务,本质也是往队列里塞任务,再按顺序消费。所以栈和队列不是抽象概念,它们是工程世界的基本骨架,只是平时见得太多,反而忘了它们有名字。
这也是为什么训练营愿意花好几天在这两个结构上:基础不是不重要,而是太重要,必须放到足够深。把这几道题做扎实,之后读很多框架源码,你会有一种亲切感——原来底层就是栈和队列在撑着。
5.2 Day12要出现的两个新工具:单调队列与优先队列
Day11结束的时候,训练营群里已经开始预热第二天的内容了。从节奏上看,栈和队列的part03大概率会碰到两个新工具:单调队列和优先队列。典型题目就是滑动窗口最大值,以及前K个高频元素这种Top K问题。
这两个工具很有意思,它们是在基础结构上加了"约束条件"。单调队列要维护队列内部的单调性,队首永远是最值,这样滑动窗口移动时,取最大值的操作就是O(1)的;优先队列则用堆实现,能在海量数据里迅速找到最大或最小的那一个,不需要做全量排序。我在Day11先简单看了下概念,发现它们和今天的内容是同一根藤上的瓜——今天是用栈顶维护"最近",明天是用队首维护"最值",核心都是"在动态变化中维护某一个关键信息"。
如果你也是按训练营节奏在走,建议当天把前一天的题目快速过一眼再预习新内容,这种"新老相连"的记忆效果比直接开新题好很多。
5.3 给训练营同学的时间安排建议
最后聊点实在的:如果当天时间有限,这三道题该怎么安排?我的建议是把括号匹配和逆波兰表达式作为必做,它们各自代表了一类栈的典型应用;相邻重复项消除更像热身题,可以穿插在中间调节节奏。三道题里,我觉得难度排序是:逆波兰大于括号匹配大于相邻消除。
做题的时候别一上来就看题解。先自己想五到十分钟,哪怕只能想到"这题可能要用栈",也已经赚到了——因为场景识别能力就是这么练出来的。想出思路就直接写,写挂了再看题解修正;想不出思路,看题解之后一定要把题解关掉,自己从头写一遍。这个"关书重写"的过程,才是把别人的思路变成自己能力的关键一步。
踩到栈空、顺序颠倒、除零这些问题时,也别着急,返回去看看4.2那套用例表,基本都能定位。刷题不是比谁一次就对,而是比谁在出错之后能更快地找到原因。
最后分享一个我自己的小习惯:每天整理笔记时,我会把当天的核心结论浓缩成一张小卡片。Day11的卡片上只写了一句话——"栈的题目,本质都在跟踪最近未完成的上下文"。这句话在之后做二叉树遍历、深度优先搜索时反复被验证,也算是我这一天最大的收获了。