最近在二刷 LeetCode Hot 100,这个系列文章写到第 12 期,正好轮到「栈」。
说实话,Hot 100 里栈相关题目的数量不算多,但每道都称得上小而经典:20 有效的括号、155 最小栈、84 柱状图中最大的矩形、42 接雨水……这几道题分布在算法面试、周赛、日常手撕代码里出现频率都很高。我以前第一遍刷的时候是跟着题号硬刷,结果遇到 42 接雨水和 84 柱状图这种题直接懵,总觉得栈就是个“先进后出”的容器,用起来却抓不住时机。后来把栈专题拿出来集中复盘,才发现栈题根本不是靠背题,而是靠几个固定模型:括号对称匹配、最小栈历史记录、单调栈找边界、栈加回溯处理嵌套场景。把这几类模型吃透,Hot 100 的栈题基本就通了。
这篇文章我会按“题型分类 → 题单总体评估 → 核心套路拆解 → 代码级实操 → 踩坑实录 → 工程应用”这个顺序来整理。内容适合正在二刷 Hot 100 的选手,也适合刚学完基础数据结构、想知道栈到底能解决什么问题的读者。Hot 100 各平台/版本的题序会有小幅差异,我以栈相关性最强的几道题为对齐标准来写。
1. 先盘清楚:Hot 100 栈题到底在考什么?
1.1 栈类题目的三大题型
栈这个数据结构本身很简单,就一句话:后进先出。但为什么面试官这么爱考栈?因为栈考的不是“你会不会 push 和 pop”,而是你能否在正确的时间点做正确的操作。Hot 100 里的栈题,剥开外壳之后基本就三类。
第一类是“对称匹配型”。代表性题目是 20. 有效的括号。它的本质是嵌套结构合法性判断,比如 ([{}]) 这种,用栈去匹配成对的符号。这类题的核心是:遇到左符号就入栈,遇到右符号就检查栈顶是否匹配。虽然题目简单,但它背后的能力是“解析嵌套文本”,JSON 解析器、编译器语法检查、HTML 标签配对都在用同一套逻辑。
第二类是“单调栈型”。代表性题目是 739. 每日温度、84. 柱状图中最大的矩形、42. 接雨水。这类题考的是寻找“下一个更大/更小元素”或者“左右边界”。单调栈维护的是一个有序的栈内序列,配合元素出栈时的时机来计算答案。Hot 100 里最难的几道栈题几乎都属于这个模型,而且它们之间高度相似:84 会了,42 基本就通了一半。
第三类是“状态栈型”。代表性题目是 155. 最小栈、394. 字符串解码。这类题用栈记录历史状态,要么是记录历史最小值,要么是记录嵌套上下文,配合回溯和展开来完成多层结构处理。最小的状态就是括号嵌套加数字重复的展开,做完 394 再做编译原理里的 AST 相关题目,会有一种“原来如此”的感觉。
1.2 为什么笔试面试总盯着这几道题
一个很现实的问题是:栈题在所有数据结构题里占比并不高,但出镜率极高。原因不是栈本身难,而是栈背后的能力模型很值钱。
第一,栈题能顺带考察“时间复杂度和空间复杂度的取舍”。比如 155. 最小栈,题目要求 O(1) 取最小值,最自然的想法是每次遍历找最小值,但这样 getMin 是 O(n),要压到 O(1) 就得额外维护一个辅助栈。这不是脑筋急转弯,而是工程里常见的时间换空间、空间换时间的权衡。
第二,栈题天然和递归、回溯、深度优先搜索绑定。函数调用本来就用调用栈实现,很多递归能改写成栈迭代,比如 394. 字符串解码既可以用 DFS 递归,也可以用栈迭代。会做栈题,意味着你对“嵌套结构”和“递归调用关系”有直觉,这对理解系统设计里的调用链、消息队列里的重入、任务调度里的依赖关系都有帮助。
第三,栈题可以设计成递进式追问。面试官从“写一个判断有效括号的函数”开始,然后追问“如果是包含星号的通配符匹配呢”“如果括号类型变成三种呢”“如果要求最长有效括号长度呢”,一道基础题能延伸出 32. 最长有效括号这种 Hard 题。考察的是候选人能不能从基础模型上做泛化,而不是背模板。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 把题单摊开:栈专题要掌握到什么程度
2.1 七道核心题总览表
Hot 100 页面在不同时间点会有调整,不同版本甚至会把接雨水归类到双指针、把最长有效括号归类到动态规划。从“栈解法”角度出发,我按自己的刷题节奏整理了一份栈专题题单,并且按难度和必刷程度打了分:
| 题目 | 题号 | 核心模型 | 推荐解法 | 时间复杂度 | 空间复杂度 | 必刷指数 |
|---|---|---|---|---|---|---|
| 有效的括号 | 20 | 对称匹配 | 辅助栈 | O(n) | O(n) | 必刷 |
| 最小栈 | 155 | 状态记录 | 辅助栈 | O(1) 全部操作 | O(n) | 必刷 |
| 每日温度 | 739 | 单调栈 | 单调递减栈 | O(n) | O(n) | 必刷 |
| 字符串解码 | 394 | 嵌套展开 | 双栈迭代 | O(n) | O(n) | 高 |
| 柱状图中最大的矩形 | 84 | 单调栈 | 单调递增栈 + 哨兵 | O(n) | O(n) | 高 |
| 接雨水 | 42 | 单调栈 | 单调递减栈 | O(n) | O(n) | 高 |
| 最长有效括号 | 32 | 栈记录索引 | 栈底哨兵法 | O(n) | O(n) | 中高 |
另外,如果版本里有 71. 简化路径,也建议顺手刷一下。它表面是在模拟文件路径解析,实际上考察的是对栈进出时机的理解,尤其是遇到 .. 时要出栈、遇到正常目录名要入栈,这种题目可以作为热身练习。
2.2 怎么判断一道题该不该用栈
很多同学卡在“不知道什么时候该想到栈”这一步。我给一个自己的判断标准:一道题里出现“嵌套结构”“最近匹配”“逐层展开”“撤销/恢复”“单调边界”这些特征时,大概率能往栈上想。
举几个具体场景。括号成对出现,天然符合“最近匹配”,用栈最舒服。计算器求值要考虑运算符优先级,本质也是先把一部分操作暂存,等优先级更高的算完再回来处理,这个“暂存再回来”的过程就是栈。浏览器的前进后退为什么用两个栈?因为你后退之后前进历史会被清空,这就是栈的天然性质。React Fiber、Redux 中间件这些前端技术里也大量使用调用栈来维护执行上下文。
还有一个小技巧:如果题目要求的结果和“数组里每个元素向左或向右看第一个满足某个条件的元素位置”有关,那就别犹豫,直接往单调栈上想。这不是玄学,因为“第一个大于/小于当前元素”这种问题,暴力解法是 O(n²),单调栈可以把每个元素最多入栈一次、出栈一次,整体降到 O(n)。面试时你说出这个复杂度分析,比单纯背答案有用得多。
2.3 栈的两种实现:容器栈和数组模拟
刷题的时候栈的实现不纠结,Python 里直接用列表当栈,Java 里用 Deque,C++ 用 stack。但我建议自己在练习时偶尔用数组模拟一下栈,尤其是在处理最大矩形和接雨水这种下标敏感的问题时,数组模拟会方便很多。
比如 C++ 的 stack<int> 不支持随机访问,你想看栈顶下面一个元素是什么就麻烦了;而用 vector<int> st 模拟时,st.back() 是栈顶,st[st.size() - 2] 可以拿到次栈顶。很多写 84 柱状图题解的人喜欢用数组模拟,不是没道理的——当你想在弹出栈顶之后立刻拿新栈顶作为左边界时,数组模拟会让代码更直白。
数组模拟栈的核心就是三个操作:push 对应 st.push_back(x),pop 对应 st.pop_back(),peek 对应 st.back()。虽然比语言自带的栈稍微糙一点,但刷算法题完全够用,而且在调试的时候打印整个数组比打印一堆栈元素直观太多。
3. 四类核心套路,配合逐步推导
3.1 对称匹配:出入栈的时机决定一切
- 有效的括号是栈题的基础,很多人看一眼就会写,但没想清楚为什么对。
思路很简单:遍历字符串,遇到左括号就压栈,遇到右括号就弹出栈顶比较。问题是“什么时候发现不匹配”这个判断,必须放在“弹出之前”先判断栈是否为空。如果字符串是 ")(",第一个字符就是右括号,此时栈是空的,按道理是非法字符串,如果直接 pop() 就会在运行时崩溃,返回结果倒是能莫名通过,因为很多语言栈空 pop 会抛异常,代码直接报了运行时错误也算“失败”。
这里我提一个很多人忽略的细节:题目保证字符串里只有六种括号字符,所以可以用 dict 把左右括号做映射,遇到右括号时直接取映射对比。每次判断都写 if ch == '(' || ch == '[' || ch == '{' 再 else 里继续嵌套,当然也能跑,但可读性差。更优雅的写法是右括号到左括号的映射 closing_to_opening = {')': '(', ']': '[', '}': '{'},遇到右括号时看 stack[-1] == closing_to_opening[ch]。
还有一个剪枝技巧:如果字符串长度是奇数,直接返回 False,根本不需要遍历。这个剪枝不改变复杂度,但在面试手写代码时可以展示你对边界的敏感度。
3.2 单调栈:出栈时结算,入栈时等待
单调栈是栈题里最重要的模型,没有之一。什么叫单调栈?就是栈内元素从栈底到栈顶保持单调递增或单调递减。它在解决“下一个更大元素”“上一个更小元素”“最大区间/高度”等问题时非常好用。
理解单调栈有一个很朴素的类比:排队等一个比自己高的人。你现在在一个队伍里,你想知道右边第一个比自己高的人离自己多远,于是你一边排队一边往后看。比你矮的人进队之后,发现挡不住你的视线,就会被“淘汰”(出栈),直到遇到一个比你高的人,你就把位置记录下来,轮到他继续往前看。每个元素入栈一次、出栈一次,总时间 O(n)。
在这套流程里有两个关键点。第一,“谁出栈,谁结算”。不是当前元素入栈的时候计算结果,而是当它因为遇到一个破坏单调性的元素而出栈时,答案才有意义。第二,“保留状态,等待触发”。栈里留下的元素是那些“还没遇到答案”的候选者,它们按单调顺序排列,保证之后任意一个触发元素进来,都可以连续处理多个候选。
3.3 单调栈方向该怎么选:递增还是递减
很多人在单调栈这里容易迷,我直接给结论,再解释为什么。
找“下一个更大的元素”,从左往右遍历时,栈里维护的是递减序列,因为一旦碰到一个更大的数,栈顶的较小数就可以出栈并得到答案。这里栈内从栈底到栈顶是递减还是递增要看具体实现,容易绕。更稳妥的判断方法是:模拟一次,看弹出条件是什么。比如 739. 每日温度,遍历到 temperature[i],如果它大于栈顶索引对应的温度,就说明栈顶那天的“下一个更高温度”找到了,弹出并计算天数。所以出栈条件是“当前元素大于栈顶”,这个条件决定了栈内元素从栈底到栈顶是递减的。也就是说:维护递减栈能解决“下一个更大元素”。
找“左右两边第一个更小元素”时,比如 84. 柱状图中最大的矩形,需要维护递增栈,因为当当前柱高小于栈顶柱高时,栈顶柱子的右侧边界出现了,弹出并计算以它为高度的最大矩形。所以出栈条件是“当前元素小于栈顶”,栈内从栈底到栈顶保持递增。维护递增栈能解决“下一个更小元素”。
做题多了之后不需要背结论,观察入栈和出栈的条件即可:当前元素比栈顶大才可能触发结算,那就是递减栈;当前元素比栈顶小才可能触发结算,那就是递增栈。
3.4 状态记录:用栈记录历史最小值
最小栈 155 这类题的思路是一种动态规划加辅助结构的结合,只是用栈来实现。
需求很简单:实现一个栈,支持 push、pop、top,同时支持 O(1) 获取栈内最小值。难点在于 O(1)。如果你只维护一个变量 minVal,当这个最小值被 pop 掉之后,你就丢掉历史了,拿不到“之前第二小”的值。解决方案是再开一个栈,记录栈内每个状态下的最小值。
我在写这道题时踩过一个细节坑:辅助栈中压入值的时机。如果当前新元素 val 小于等于辅助栈栈顶,就在辅助栈压入 val;否则辅助栈压入当前栈顶值。注意是“小于等于”,不是“小于”。因为如果等于最小值时不压入,pop 掉一个重复最小值,辅助栈很可能也把这个最小值弹掉了,那主栈里其实还有一个同样的最小值,但辅助栈已经丢了,后续查询得到的新最小值就会偏大。用“小于等于”可以保证主栈中无论有多少重复最小值,辅助栈都有对应记录。
3.5 嵌套和回溯:一个栈不够就两个
遇到 394. 字符串解码这种带嵌套结构题,很多人会纠结用递归还是栈。实际上递归的调用过程也就是隐式用栈,显式写栈能把过程摊开,更容易控制边界。
解法用两个栈:一个栈存“这个括号之前的字符串”,一个栈存“这个括号要重复的次数”。遍历字符串时,遇到数字就累加成 num,遇到 [ 就把历史字符串和次数推入各自的栈,然后清空当前状态;遇到 ] 时弹出次数和之前的字符串,把当前字符串重复拼接后接到之前字符串后面。
这两个栈一个装状态,一个装动作。你可以把它理解成做手工艺活时先把半成品放到一边,处理完括号内部的内容后,再拿出来组合。题目本身不复杂,但考察了“是否能在嵌套过程里保持多层上下文”,这也是栈在编译原理中的核心作用。
4. 七道必刷题的代码级拆解
4.1 20. 有效的括号:最容易被忽略的三种错误
题目要求就是判断给定字符串是否括号匹配,直接用栈。这是我推荐每个人都手写一遍并讲清楚细节的题,因为它太常考了。
Python 写法:
python复制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,这属于边界剪枝。第二,遇到右括号时,要先判断栈空不空。如果不空再取栈顶比较,否则拿一个空栈的顶,直接报错。第三,最终返回值必须是 not stack,不能直接 return True。很多第一次刷的人忘了处理 "(((" 这种左边多的情况,走到最后才发现栈不空。
我自己的经验是,这类题目如果你能在写完代码后主动说出这三类边界情况:左边多、右边多、括号类型不匹配,面试官基本不会再追问细节了。
4.2 155. 最小栈:O(1) 取最小值的两种写法
题目要求实现一个栈,额外支持 getMin(),并且要求 O(1)。前面提到辅助栈的思路,这里给代码。
python复制class MinStack:
def __init__(self):
self.stack = []
self.min_stack = []
def push(self, val: int) -> None:
self.stack.append(val)
if not self.min_stack or val <= self.min_stack[-1]:
self.min_stack.append(val)
else:
self.min_stack.append(self.min_stack[-1])
def pop(self) -> None:
self.stack.pop()
self.min_stack.pop()
def top(self) -> int:
return self.stack[-1]
def getMin(self) -> int:
return self.min_stack[-1]
这种写法的好处是 push 和 pop 永远同步进行,代码简单不容易出错。代价是辅助栈里可能存了很多重复的当前最小值,浪费一点空间。
另一种写法是只在最小值发生变化时才往辅助栈里压,pop 时判断主栈弹出的值是否等于辅助栈栈顶,等于才一起弹出。这样做省空间,但代码逻辑稍微绕一点,容易漏条件。我建议新手先掌握同步压栈版本,跑几个测试用例稳定之后,再考虑优化空间。
还有个小考察点:如果题目说“数字范围是 -2^31 到 2^31-1”,那 int 就够;如果不说范围,在 Python 里无所谓,Java/C++ 要考虑用 long。这不是刁难,而是工程里最常见的数据范围问题。
4.3 739. 每日温度:单调栈经典场景
题意是给一个每日温度数组,返回一个等长数组,每个位置表示要等多少天才能等到更高的温度,如果之后没有更高温度就是 0。
暴力解法对每个位置往后扫描,最坏 O(n²)。数据量一大就挂。单调栈解法核心是遍历时维护一个“温度值递减”的栈,栈里存下标,不存温度值。
python复制def dailyTemperatures(temperatures: list[int]) -> list[int]:
n = len(temperatures)
ans = [0] * n
stack = [] # 存下标,从栈底到栈顶对应的温度递减
for i in range(n):
while stack and temperatures[i] > temperatures[stack[-1]]:
prev = stack.pop()
ans[prev] = i - prev
stack.append(i)
return ans
为什么栈里要存下标?因为最后要计算天数差,只存温度拿不到位置信息。为什么是 while 而不是 if?因为新来的高温可能一次性解决栈里多个等待者,必须把这些可以结算的都弹出。比如温度是 [40, 35, 30, 50],遍历到 50 时,30、35、40 三个都会依次出栈。
这道题我再提醒一个容易踩的点:如果遍历过程中的当前温度一直没有比栈顶更高,那就一直入栈,比如 [30, 40, 50, 60] 这种已经是递增序列,那每个元素入栈后都不需要等,结果全是 0,栈会一直增长到最后,时间复杂度还是 O(n),因为每个元素只进出一次。
4.4 42. 接雨水:三种解法里为什么栈相对通用
接雨水这道题解法太多,常见的有双指针、动态规划、单调栈。如果从“一道题能覆盖多少知识”的角度看,我建议优先掌握单调栈解法,因为它和 84 题能形成联动。
单调栈解法按“层”来计算雨水,维护一个递减栈。当遍历到一根比栈顶更高的柱子时,说明栈顶元素和新的右柱子之间可能形成一个凹槽,这时候把栈顶弹出作为凹槽底部,然后看当前栈顶,也就是凹槽左边的柱子,能否构成一个存水区间。
python复制def trap(height: list[int]) -> int:
ans = 0
stack = [] # 存下标,栈底到栈顶对应高度递减
for i in range(len(height)):
while stack and height[i] > height[stack[-1]]:
bottom = stack.pop()
if not stack:
break
left = stack[-1]
width = i - left - 1
h = min(height[left], height[i]) - height[bottom]
ans += width * h
stack.append(i)
return ans
这里有两个重要边界。第一,弹出底部之后,如果栈空了,说明左边没有柱子可以存水,直接 break,不算这一段。第二,当前柱子比栈顶高就触发计算,但计算完后当前柱子可能仍然比新的栈顶高,所以要继续 while 循环。整个代码里最容易被忽略的是 width = i - left - 1,很多人想当然写成 i - bottom,左右边界就错了。宽度应该是左右边界之间去掉底部柱子的横向距离。
如果你已经掌握了双指针解法,也可以对比一下。双指针适合表达“两边最高柱子的较小值减去当前高度”这一层语义,代码更短;但双指针不便于延伸,一旦题目改成“能存水的总区域由多个不同高度的容器组成”,双指针就很难套。单调栈更像一套通用的“边界结算”模板,会了它,下次做“接雨水 II”这种二维扩展题,至少能看到墙在哪里。
4.5 84. 柱状图中最大的矩形:哨兵写法清空栈
和接雨水很相似但又不同,84 求的是以每个柱子为最小高度时,向左向右各能延伸多远,然后取最大面积。
核心思路:对每个柱子,找到它左边第一个比它矮的柱子和右边第一个比它矮的柱子,这两个位置之间的距离乘以当前柱高,就是以当前柱高为最低高度的最大矩形面积。单调栈从左往右遍历,栈内维护递增高度。
这里我推荐用“哨兵”写法,给原数组头尾各加一个高度为 0 的柱子,这样能保证所有元素最终都会出栈,不需要写额外的收尾循环。
python复制def largestRectangleArea(heights: list[int]) -> int:
heights = [0] + heights + [0]
n = len(heights)
stack = [0] # 存下标,栈底到栈顶对应高度递增
ans = 0
for i in range(1, n):
while heights[i] < heights[stack[-1]]:
cur = stack.pop()
height = heights[cur]
width = i - stack[-1] - 1
ans = max(ans, height * width)
stack.append(i)
return ans
注意哨兵代码里 stack 初始化是 [0],对应高度数组里第一个哨兵 0,遍历从下标 1 开始。当遍历到最后一个哨兵 0 时,因为所有柱子高度都大于等于 0,所以触发连续弹出,完成所有结算。
这道题有个隐藏难点:重复高度。如果两个柱子高度相等,到底弹不弹?当使用严格小于 heights[i] < heights[stack[-1]] 时,相同高度的柱子不会让前一个出栈,它会在更后面被结算。这会导致同一高度算的面积推迟,但最终结果不会错,因为后一个相同高度柱子弹出的宽度会包含前一个相同高度柱子的范围,面积值取最大值时依然正确。有些题解会把条件写成 <=,那相同高度就会提前结算一次,结果也正确。两种写法都能过,但如果你想彻底理解单调栈的结算时机,建议用严格小于这个版本,配合哨兵走一遍 [2,1,5,6,2,3] 的用例。
4.6 32. 最长有效括号:用栈记录索引而不是字符
这道题在 Hot 100 里属于 Hard,但如果你能想明白“栈里存的是下标,而不是括号字符”,代码并不长。
思路:维护一个栈,栈底先放一个哨兵 -1,表示“最后一个未匹配的右括号的位置”。遍历字符串时,遇到左括号就把下标入栈;遇到右括号就弹出栈顶元素,表示这个右括号和之前的左括号匹配上了。弹出之后如果栈为空,说明这个右括号没有匹配对象,把它自己作为新的哨兵入栈;如果栈不为空,当前下标减去栈顶下标就是一段有效括号的长度,更新最大长度。
python复制def longestValidParentheses(s: str) -> int:
stack = [-1]
ans = 0
for i, ch in enumerate(s):
if ch == '(':
stack.append(i)
else:
stack.pop()
if not stack:
stack.append(i)
else:
ans = max(ans, i - stack[-1])
return ans
这个写法我第一次看的时候觉得很神奇,仔细想其实抓住了有效括号串的一个关键特征:任何一段连续有效括号串,必然有一个“起点前一个未匹配位置”,栈底哨兵就是用来记录每个新段起点的。遇到无法匹配的右括号时,它把之前的段切断了,同时自己也变成新段的起点前驱。
这道题还有一个容易走偏的方向:直接用栈存字符,然后统计栈里剩多少,这是错的做法,因为像 "()(()" 这种字符串栈里会剩左括号,但最长有效长度是 2,不是从剩余字符里直接推出来的。这就是为什么必须记下标。
4.7 394. 字符串解码:嵌套展开用两个状态栈
给一个形如 "3[a2[c]]" 的字符串,返回 "accaccacc"。这里数字可能有多位,也支持字符串嵌套。
双栈写法:
python复制def decodeString(s: str) -> str:
num_stack = []
str_stack = []
cur_num = 0
cur_str = ""
for ch in s:
if ch.isdigit():
cur_num = cur_num * 10 + int(ch)
elif ch == '[':
num_stack.append(cur_num)
str_stack.append(cur_str)
cur_num = 0
cur_str = ""
elif ch == ']':
repeat_times = num_stack.pop()
prev_str = str_stack.pop()
cur_str = prev_str + cur_str * repeat_times
else:
cur_str += ch
return cur_str
重点在于:遇到 [ 时,把当前数字和当前字符串都保存起来,然后重置,这样可以处理多层嵌套;遇到 ] 时,把之前保存的字符串和当前展开后的字符串拼起来。这里要特别小心 cur_num = cur_num * 10 + int(ch) 这一段,因为数字可能是两位数甚至三位数,比如 "10[a]",如果你每次只取 int(ch) 而不累乘,就只会把 1 存下来然后 0 被当成独立状态,结果就不对了。
我刷这道题时犯过的低级错误是:把 cur_str = prev_str + cur_str * repeat_times 写成了 cur_str = cur_str * repeat_times,结果丢掉了括号前面的字符串。比如 "2[ab3[c]]",走到中间括号算完 "abccc" 之后,如果没有接上外层 prev_str,最后结果就少了外层拼接部分。
5. 我踩过的坑:栈题调试实录
5.1 空栈 pop:运行时错误的重灾区
刷栈题最常见的崩溃原因就是空栈 pop。Python 列表空栈 pop 会直接 IndexError: pop from empty list,C++ 的 stack::pop() 是未定义行为,Java 的 Deque 空栈 pop 会抛异常。总之都要避免。
怎么避免?两条铁律:第一,pop 之前一定要判断栈非空;第二,如果题目设计的逻辑里栈顶必然存在,比如加了哨兵,那么你的初始化一定要写得足够清晰,别把哨兵忘了。20 题的右括号处理、42 题的左边无柱判断,都属于这种场景。
我建议在本地调试时统一封装几个小函数,比如 def peek(st): return st[-1] if st else None,或者在关键位置打印栈状态。有些 IDE 里直接断点看列表也比干瞪眼强。
5.2 栈顶比较方向写反:等号判断和左右边界
在 84、42 这种需要比较栈顶和下标的题里,最常见的错误是把 stack[-1] 和当前元素的关系写反。比如 84 题,应该是当前柱高小于栈顶柱高时结算,我一开始写成了大于,结果柱子全在栈里堆积,最后只结算出一根柱子的面积,答案明显不对。
还有一个细节:比较的是“柱高”,不是“下标”。所以代码里要写成 heights[i] < heights[stack[-1]],每次都通过下标去索引高度。如果你在循环里图省事,先把 height = heights[i] 存下来再比较,就要格外注意栈顶也是下标,容易串。
5.3 多位数和负数的处理
字符串解码和计算器类题目里,数字不只是一位。用 num = num * 10 + int(ch) 来累乘是标准解法,很多新手一上来直接 stack.append(int(ch)),遇到 10 就裂开了。
另外,虽然 LeetCode 栈题很少故意塞负数,但如果你经常手写栈相关的工具,要考虑负数对单调栈的影响。最简单的处理是在加入数字前用一个 sign 变量记录正负,把负数转换成取反后的绝对值入栈,或者统一用大数偏移。应付刷题的话,先确认题目数据范围有没有说明,没有说明就按 int 处理,有溢出风险再改 long。
5.4 全同、全升、全降:测试用例不能少
每次写完栈题,我建议至少拿五类用例过一遍:空数组/空字符串、单元素、全部相同、严格递增、严格递减。
原因很简单,这几类用例最能暴露单调栈收尾逻辑的问题。比如 84 题,如果高度全部递增 [1,2,3,4,5],没有哨兵的写法很容易在遍历结束后忘记清空栈,导致只算了全部矩形中的一个子集;加了哨兵之后,遍历到末尾的 0 时会自动把所有柱子弹出,这才稳妥。42 题如果高度是 [5,4,3,2,1],因为右边没有更高的柱子,整个过程中几乎不会触发 while 分支,这种情况下代码不会崩,但你必须确认最终答案真的是 0。
5.5 用“打印栈状态”代替瞎猜
我记得第一次调 84 题的时候,面积结果总是偏小,怎么都想不明白。后来我在 while 循环里加了一行打印:打印当前下标、当前柱高、栈内下标和对应高度。打印出来就发现,栈里居然残留了两个下标没被弹出,因为我把 stack.append(i) 放到了 while 循环内部,导致每个新柱子还在结算没结束时就被压进去了。这种问题不看栈状态很难发现,看打印一眼就明白。
调试栈题的通用技巧:不要只打印栈内元素值,还要打印栈内元素对应的下标。因为很多题需要靠下标计算距离,只看值会忽略位置关系。有条件的话,可以在每次入栈和出栈时各打一行,对比手动模拟的轨迹。
6. 跳出题目:栈在工程和「全栈」视角里的应用
6.1 函数调用栈与错误堆栈
算法里的栈是抽象数据结构,运行时的函数调用栈则是硬件/虚拟机实现。但两者本质一致:后进先出。
你写代码调用函数 A,A 调用 B,B 调用 C,执行完 C 之后一定先回到 B,再回到 A,最后回到调用入口。这就像一个栈:每次函数调用相当于 push 一个栈帧,函数 return 相当于 pop 一个栈帧。线上服务异常时打印出来的错误堆栈,就是把这个函数调用栈从栈顶到栈底排列出来给你看,一层一层地“回溯”,这也是为什么很多调试工具叫“stack trace”。
理解这个模型对排查线上问题很有帮助。有一次我碰到一个递归函数栈溢出的问题,以为是自己代码递归层数太多,后来发现是某个接口的调用链非常深,A 调 B,B 调 C,C 再调 A,形成隐式循环,函数调用栈越来越深直到爆掉。当时如果对调用栈的 LIFO 机制足够敏感,看一眼错误堆栈里重复的调用序列就能定位到循环依赖。
6.2 浏览器回退、编辑器撤销和路由栈
工程里栈最常见的应用是“撤销/重做”和“前进/后退”。文本编辑器的 undo 可以用一个栈保存操作记录,每做一次操作就入栈,撤销就出栈;redo 则是另一个栈,撤销时把出栈的操作压入 redo 栈,新操作产生时清空 redo 栈。这套设计和浏览器前进后退完全一致:你从 A 页面进入 B 页面,再进入 C,然后点回退到 B,此时 C 已经出栈,如果你在 B 页面点了一个新链接 D,C 就永远不会出现在前进列表里了,因为那条历史分支已经被清空。这也解释了为什么浏览器的前进按钮在某些情况下会变灰。
前端框架里的路由也可以类比为栈结构,尤其是页面栈式的移动端应用,每个页面进入是一层 push,返回是 pop。如果你做过全栈项目,后端接口的调用链也是一层套一层,日志系统里用 requestId 串起调用链,本质上也是在帮你在栈里寻找某个调用点的上下文。
6.3 “技术栈”的栈:语言、框架、中间件和经验栈
热词里经常提到“全栈工程师”“技术栈”这些说法。这里的栈显然是比喻义,指一组技术选型的集合。但我会延伸一句:一个合格的全栈工程师,未必是前后端每个框架都精通,而是能“在不同技术层次之间正确地入栈出栈”——该深入业务时深入,该抽象到通用组件时抽象出来。
我在做全栈项目时用的技术栈大抵是前端框架加后端框架加数据库加部署工具,但真正让项目稳定交付的,是那些算法课上打下的基本功。栈这个数据结构尤其典型:理解了调用栈,就能理解为什么中间件要按洋葱模型一层层执行;理解了栈帧,就能理解为什么错误堆栈会一层层往上抛;理解了单调栈的“延迟结算”,就理解了异步任务为什么有时候要攒一批再处理,而不是来一个处理一个。这些概念不直接写进简历,但会体现在代码设计和排查问题的思路上。
6.4 栈在表达式求值、编译器和运行时里的位置
最后补一个看起来离刷题远、其实很近的应用:表达式求值。中缀表达式转后缀表达式(逆波兰式)就是经典栈应用,运算符按优先级入栈出栈,计算器就是这么工作的。很多语言编译器在解析语法树时也靠栈来维护操作符和操作数的状态,只是普通业务开发看不到这一层。
老一点的 x86 浮点处理单元里甚至直接把浮点寄存器组设计成了栈结构,操作数压栈和出栈,通过 ST(0) 访问栈顶。现在这套设计已经被 SSE 指令集的寄存器替代,但它告诉我们,栈在计算机体系结构里是基础到不能再基础的设计:后进先出可以让硬件和编译器实现得足够简单。
如果你对“栈”的认知停留在“刷题会做括号匹配就行”,那就浪费了这个数据结构最主要的价值。栈在编译原理里的“符号表”“作用域链”“运行时栈帧”中都有出现,我建议在刷完 Hot 100 栈专题之后,花一个下午去看一篇关于函数调用栈的文章,配合画图理解栈帧里的返回地址、参数、局部变量是怎么布局的。看完你会对这道栈题有另一种理解:原来算法的抽象结构在真实运行环境里也是这么用的。
我个人刷到第三遍栈专题的时候,最大的体会是:栈题在于精而不在于多。Hot 100 里真正需要深挖的就那七道题,把括号匹配做到条件反射,把单调栈做到条件反射,再把状态栈的双栈模型吃透,后面遇到编译器、计算器、表达式求值、嵌套展开相关的题目,你会发现自己不再需要“套模板”,而是自然就知道该在什么时机入栈、什么时机出栈。
最后再分享一个小技巧:面试遇到栈题时,先不急着写,先在白板上画一个入栈出栈的过程图。画完栈的变化轨迹,代码一般就顺理成章地出来了。栈这个结构是肉眼最容易追踪的抽象数据结构,你的画图能力就是你的解题能力。
