说个可能有点反直觉的结论:在“优选算法”这条路上,很多数据结构到最后都是靠栈撑起来的。你写代码,函数调用是栈;你处理括号嵌套,用的是栈;遇到“找右边第一个比它大的数”这种题,还得靠单调栈。栈的核心就四个字——后进先出,可这四个字铺开之后,能演出来的套路远比大多数人想象的要多。
这篇栈专题,主要面向两类人:一类是刚开始刷题、想搞明白“什么时候该用栈”的新手;另一类是基础操作已经会了,却总在单调栈、表达式求值、递归改迭代这些变体上反复卡壳的进阶选手。我会把原理讲透,但更侧重我在实际调试里积攒下来的判断方法和踩坑记录,尽量做到每一步都能对照着操作。
1. 栈的“后进先出”到底撑起了多少套路
1.1 函数调用本身就是一座栈
很多人第一次接触栈是在数据结构的课本上,但真正每天在用栈而没意识到的人更多——函数调用就是最典型的栈。
每次你调用一个函数,系统的调用栈会压入一个“栈帧”,里面保存了局部变量、参数、函数返回后要继续执行的地址。函数执行完毕,栈帧被弹出,程序回到原来的位置继续跑。递归之所以让新手头疼,不是因为“函数调自己”这个表面行为,而是因为它会一层层把状态压进系统栈里,等到最底层的终止条件满足,再一层层弹出并返回。理解了这个机制,后面我们用显式栈代替递归时,思路就完全打开了。
这也是为什么,很多语言把栈溢出报错叫“Stack Overflow”,而不是“Queue Overflow”。不是队列不够好,是调用天然就符合后进先出:最近开始的函数,最先结束。
1.2 栈的“笨”恰恰是它的聪明之处
栈只能从栈顶操作,不能随便访问中间元素,看起来像个限制。但算法题里,这种限制反而是优点。
想想看:当一个问题只跟“最近的上下文”有关时,栈可以做到:
- 入栈、出栈、取栈顶,全部 O(1) 时间;
- 不需要维护全局状态,只保留一条“最近状态链”;
- 天然对应递归调用的层次结构。
我常拿一摞盘子做类比。你从一摞盘子里拿盘子,永远先拿最上面的;放盘子,也永远放最上面。栈就是这么个东西。你要拿中间的盘子?可以,但那要先把上面的全部移开,对应到算法里就是 O(n) 的随机访问代价。
用表来对比栈和队列,会更清楚:
| 维度 | 栈 | 队列 |
|---|---|---|
| 操作位置 | 只能在栈顶 | 队尾入队、队头出队 |
| 数据结构语义 | 后进先出(LIFO) | 先进先出(FIFO) |
| 典型遍历场景 | DFS 深度优先 | BFS 广度优先 |
| 系统调用模拟 | 函数调用栈 | 打印任务队列 |
| 适合处理的信号 | 最近状态、嵌套关系 | 顺序处理、层序关系 |
1.3 看到什么信号,应该先想到栈
判断一道题要不要用栈,我总结出四个比较可靠的特征信号,基本覆盖了绝大多数栈题:
- 嵌套结构:字符串里有括号、HTML标签、JSON对象等需要层层匹配的内容。
- 成对出现且要求匹配:左右括号、左右引号、开始标签和结束标签,这类题不一定是修改字符串,但往往需要一个“最近打开的东西”来和当前元素配对。
- 依赖“最近状态”而不是“全局状态”:比如某个括号要跟它最近的前一个括号配对,某个数要找右边第一个比它大的数,都要依赖局部邻近关系。
- 撤销、回退、逆序处理:编辑器的 Ctrl+Z、浏览器的后退按钮、把字符串逆序输出,这些场景天然是栈的语义。
反过来,如果你的核心逻辑是“我要遍历一遍,把所有元素按顺序收集起来”,那多半不需要栈,也许数组或哈希表就够了。栈不是万能钥匙,判断标准就一条:这个问题,是否依赖距离当前位置最近的元素或状态?
2. 掌握三个栈式模型,入门题能解一大半
栈的基础题多得吓人,但拆开看,绝大多数都跑不出三个核心模型:括号配对、表达式求值、栈模拟 DFS。我逐个说透。
2.1 括号配对:栈的最经典打开方式
题目本身很直白:给一个只包含()[]{}的字符串,判断括号是否合法。
第一次写这题,很多人会直接用字符串替换,比如把"()"替换成空串,循环到最后看剩不剩内容。这么做确实能过简单的测试,但遇到"(((())))"这种深度很大的数据,替换串的复杂度会退化,而且很容易把非括号字符也误处理。正确的做法就是用栈。
伪代码的思路是:
- 遇到左括号,入栈;
- 遇到右括号,检查栈顶是不是对应的左括号,是则弹出,不是则直接判定不合法;
- 遍历结束后,如果栈为空,说明全部配对,否则说明有左括号没被关闭。
为什么栈能解决这个问题?因为括号匹配是层层嵌套的,最内层的右括号,一定要和最内层还没关闭的左括号配对。所谓“最近未关闭”,正是栈顶。
我给一个实际测试过的 Python 版本:
def is_valid(s: str) -> bool: stack = [] pairs = {')': '(', ']': '[', '}': '{'} for ch in s: if ch in pairs.values(): stack.append(ch) else: if not stack or stack[-1] != pairs[ch]: return False stack.pop() return not stack这段代码有两个容易漏掉的关键点,后面排错章节我还会展开讲:一个是右括号到来时,栈可能为空;另一个是遍历结束后,栈还有剩余左括号。这两点漏了任何一处,都会在测试用例上翻车。
2.2 双栈求表达式:把优先级变成“出栈时机”
表达式求值是栈的另一个经典模型。很多人以为这题很难,其实核心就一句话:用数字栈保存操作数,用运算符栈保存尚未执行的运算符,遇到右括号或优先级更低的运算符时,就先把栈里的高优先级运算算掉。
举个例子,计算"3 + 4 * 2":
- 数字 3 入数字栈;
- 运算符
+入运算符栈; - 数字 4 入数字栈;
- 运算符
*优先级高于栈顶+,于是*直接入栈; - 数字 2 入数字栈;
- 字符串遍历结束,开始逐个弹出运算符计算:弹出
*,取 4 和 2 得 8,压回数字栈;再弹出+,取 3 和 8 得 11。
如果遇到括号,比如"3 * ( 2 + 4 )",遇到)时就要把括号内的运算符一并结算,直到弹出左括号为止。
这种“延迟计算”的思想,本质就是栈的“后进先出”在控制流程上的体现。高优先级的运算符相当于“后到先算”,被压得越深,反而越早执行。
讲到表达式求值,还有一个更简化的变种叫逆波兰表达式,也就是“运算符在后”的写法,比如"3 4 +"就表示 3+4。逆波兰表达式的计算只需要一个栈,遇到数字就入栈,遇到运算符就弹出两个数运算,再把结果入栈。如果你要手写计算器或做算术题解析,这是非常好上手的方案。
2.3 用栈模拟 DFS:递归不写出来的迭代写法
很多人递归写得熟练,但一到需要自己维护栈来遍历树、图或者网格,就懵了。其实递归和显式栈是同一个东西的两副面孔:递归使用的是系统调用栈,显式栈则是你自己维护一个栈来保存“下一步要做什么”。
用树的前序遍历举例,递归写法几行就完事:
def dfs_recursive(node): if not node: return print(node.val) dfs_recursive(node.left) dfs_recursive(node.right)但有些场景递归有硬伤:Python 默认递归深度大约 1000,树很深时会直接报递归错误;某些回溯场景也需要显式控制路径。这时候用显式栈模拟 DFS 就更稳:
def dfs_iterative(root): if not root: return stack = [root] while stack: node = stack.pop() print(node.val) if node.right: stack.append(node.right) if node.left: stack.append(node.left)注意一个细节:因为是栈,先压入右子树、再压入左子树,弹出时才会优先访问左子树,从而保持和递归前序遍历一致。这个顺序反了,遍历结果就变了。实测很多人在这个细节上栽跟头,不是不会用栈,是没搞懂“入栈顺序决定出栈顺序”。
3. 单调栈:让我重新佩服“后进先出”的那个变体
如果说基础栈是入门级别,那单调栈就是栈专题里的第一个分水岭。它长着一张“数组题”的脸,骨子里却完全是栈的思维。我第一次做这类题时,最大感受是:原来栈不是只能匹配括号,它还能在遍历过程中把“没用的信息”及时丢掉,从而把时间复杂度从 O(n²) 降到 O(n)。
3.1 单调栈到底在做什么
单调栈,就是栈内元素按某种单调性排列,从栈底到栈顶严格递增或严格递减。它解决的问题通常是:对于数组中的每个元素,找到它右边(或左边)第一个比它大(或小)的元素。
最直观的例子是“每日温度”这类题:给出未来几天的气温,求每个温度再过几天才会碰到更高的温度。
暴力做法是两层循环,对每个温度往后扫描,最坏 O(n²)。数据量大一点就超时。单调栈的做法是只遍历一次:
- 栈里保存的是数组下标;
- 栈内温度保持递减(从栈底到栈顶依次更冷);
- 每遇到一个新温度,就把栈顶所有比它小的下标弹出,每弹出一个,就说明该下标找到了“下一个更高的温度”,距离就是当前下标减去被弹出的下标。
为什么这样正确?因为当你遇到一个更高的温度时,栈顶上那些更低的温度,它们等待“第一个更高温度”的答案已经被确定了——当前就是最近的那一个。栈顶是“最近未解决”的元素,所有未解决的元素都在栈里按温度从高到低排着。这又是一个“最近状态”问题。
3.2 一个模板吃透“下一个更大元素”
很多人记不住单调栈代码,是因为没有把它当成“模板”来理解。我用 Python 写一个最通用的版本,目标:求数组中每个元素右边第一个比它大的元素,不存在则记为 -1。
def next_greater(nums): n = len(nums) ans = [-1] * n stack = [] for i in range(n): while stack and nums[i] > nums[stack[-1]]: idx = stack.pop() ans[idx] = nums[i] stack.append(i) return ans核心就三句话:
- 当前元素来临时,把所有“不如我大”的栈顶下标弹出;
- 弹出的下标,其答案就是当前元素;
- 当前下标入栈,等待下一个更大的元素。
每个元素最多入栈一次、出栈一次,所以整体是 O(n)。这个复杂的分析很多人忽略,但它恰恰是单调栈的精髓——它不是在优化常数,而是从 O(n²) 的重复扫描中,把每个元素只处理一遍。
3.3 柱状图中最大矩形:单调栈的进阶形态
如果只是求“下一个更大元素”,很多人会觉得自己学会了,但一旦碰到“柱状图中最大的矩形”这类题,又开始怀疑之前学的是不是个假的单调栈。
这道题要求:给一组非负整数表示柱子高度,求柱状图中能勾勒出的最大矩形面积。暴力是枚举左右边界,O(n²)。单调栈的做法需要多加一层思考:当一根柱子比栈顶矮时,它就把栈顶柱子的右边界确定了下来,此时可以结算以栈顶柱子为高度的矩形面积。
这里我强烈建议在数组开头和结尾各加一个高度为 0 的“哨兵”柱子。开头的 0 保证栈里还有处理空间,结尾的 0 保证结束后所有柱子都被结算,省去最后清空栈的特殊处理。这也是我踩过坑之后学到的,越早加哨兵,代码越不容易出现边界遗漏。
3.4 我在单调栈上踩过的三个典型坑
说三个我自己实测时踩过的坑,比看十遍理论都管用:
第一,栈里存的是下标,不是值。如果只存值,你确实能知道某个元素右边有更大的数,但没法算出“距离是多少”。每日温度这类题要求距离,所以必须存下标。哪怕题目只要求输出更大的数,我也建议统一存下标,因为下标能同时拿到值和位置。
第二,递增栈还是递减栈,方向特别容易搞混。求“右边第一个更大的数”,维护的是栈底到栈顶递减的栈;求“右边第一个更小的数”,维护的是递增栈。我的记忆方法是:你想弹出谁,谁就是被寻找答案的一方。新元素比栈顶“大”,就弹出栈顶,所以栈内从底到顶是递减的,该弹出时就是栈顶元素找到了答案。
第三,遍历结束后,栈里还可能剩元素。表示它们右边没有更符合条件的元素,答案保持初始值。如果漏了这一步,在某些变体题里会少算,比如最大矩形题就必须在末尾把剩余的柱子全部结算。加哨兵是避免这类问题最省心的方案。
4. 一次完整的括号匹配排错实录:从空栈到边界
理论知识讲完,我再复盘一次我实际排错的全过程。某开发者朋友写过一版括号匹配,自认为没问题,结果在线判题平台上来来回回错了好几轮。我把排查链路完整写出来,这比直接给你看正确答案更有价值。
4.1 第一版代码:直觉版的漏洞
他最初写的是这种结构:
def is_valid(s: str) -> bool: mapping = {')': '(', ']': '[', '}': '{'} stack = [] for ch in s: if ch in mapping.values(): stack.append(ch) else: if stack[-1] != mapping[ch]: return False stack.pop() return True看起来逻辑很顺:左括号入栈,右括号时比对栈顶。但第一轮提交就错了。
4.2 三个测试用例,逐个定位问题
我让他别急着改,先把这几个用例跑一遍:
"]":这个字符串只有一个右括号。代码走到else分支,stack为空,这时访问stack[-1]会直接报索引错误。问题定位:右括号到来时没有检查栈是否为空。"[)":左括号[入栈,右括号)跟栈顶[不匹配,代码能正确返回 False。这个用例没问题。"([)]":这个用例特别刁钻。左括号(入栈,[入栈,然后遇到),栈顶是[,不匹配,返回 False。这其实是正确结果,因为([)]确实不是合法嵌套。但朋友一开始不理解,觉得字符串里左右括号数量是相等的,为什么不行?这里得讲清楚:括号匹配不仅要数量相等,还要嵌套结构正确。([)]的嵌套顺序是( [ ) ],最内层一个左方括号配了对圆括号,结构被破坏,必须判 False。
第三类隐患是:如果字符串是"(()",遍历结束栈里还剩一个左括号,但代码返回的是True,因为return True没有检查栈是否为空。问题定位:遍历结束后没有返回not stack。
4.3 修复后的最终版本
三个问题定位之后,修复起来就很简单:
def is_valid(s: str) -> bool: pairs = {')': '(', ']': '[', '}': '{'} stack = [] for ch in s: if ch in pairs.values(): stack.append(ch) else: if not stack or stack[-1] != pairs[ch]: return False stack.pop() return not stack关键改动就两处:else分支先判断not stack;最后返回not stack。
这道题最值得记住的一点:凡是“配对消消乐”类型的题,三大边界分别是空栈、错配、栈未清空。把这三个点当成检查清单,以后遇到任何括号变体题,至少能省一半的调试时间。
4.4 这一类问题的通用思考方式
从这次排错里,我总结出一个更通用的判断方法:写栈相关代码时,每次对栈做stack[-1]或stack.pop()前,先问自己一句“栈一定是非空的吗?”如果不是,必须补空栈判断。每次写完整个循环,再问一句“栈里的元素如果还有剩余,代表什么?”如果代表非法,就必须返回not stack或类似结果。
这两句灵魂提问,能拦截掉大部分入门级别到中等难度的栈题 bug。实测下来,比简单背诵代码模板管用得多。
5. 栈进入工程:撤销、调用和递归改迭代
栈刷题刷到一定程度,你会发现它不只是比赛和面试里的“八股”,工程里的很多机制本来就长在栈上。聊几个我实际开发中明显用到的场景。
5.1 编辑器撤销与重做:双栈的经典工程应用
编辑器的 Ctrl+Z 撤销功能,很多人以为只是一个栈,实际情况是双栈:一个撤销栈,一个重做栈。
- 每次操作,压入撤销栈;
- 按撤销,从撤销栈弹出操作并执行反向操作,再把原操作压入重做栈;
- 按重做,从重做栈弹出,重新执行,再压回撤销栈;
- 关键细节:当你撤销了一部分操作、又做了新操作时,必须清空重做栈——因为新操作打断了历史分支。
我在某个模拟项目里实现简易文档编辑器时,最开始没清空重做栈,导致用户回退几步后做新编辑,再按重做,会把旧操作重新蹦出来。这个 bug 的根因就是“重做栈不再有效,但没被清空”。如果你也遇到过类似问题,多半是忘了这步。
5.2 浏览器后退与前进:语义上就是双栈
浏览器里点“后退”,页面回到上一页;点“前进”,又回到刚才那页。这就是两个栈的配合。
我在一个教学模拟项目里做过简化版浏览历史:每次访问新页面压入后退栈,清空前进栈;点击后退,当前页压入前进栈,后退栈弹出;点击前进则反向。这套双栈模型写起来简单,但工程上的难点在于内存管理和会话恢复——页面状态不能无限堆在栈里,需要做容量限制。这正好说明了栈在工程里的一个原则:能用栈解决,不等于就该无限使用栈,容量上限和清空时机同样重要。
5.3 递归太深?换显式栈来救场
用一个真实踩坑经历来讲。我在处理一个嵌套非常深的 JSON 结构时,先用递归去解析,结果数据一多,直接触发递归深度限制,程序中途退出。
当时我下意识想“把递归限制调大”,后来意识到这不是正路。深层嵌套的本质是系统栈被压满了,显式栈才是稳定解法:手动维护一个工作栈,把需要处理的任务节点压进去,循环处理,完全不依赖系统调用栈的深度。虽然代码比递归长一点,但可控性和稳定性明显更好。
从那以后,我养成了一个习惯:凡是遇到“递归深度可能随数据规模增长”的场景,直接先写显式栈版本,而不是赌数据不会太大。面试、竞赛、生产环境,这个习惯都能帮你避开很多坑。
5.4 栈帧、闭包和上下文切换,其实是一家
再往深处说一点。函数调用栈里不仅有局部变量,还有当前执行到的位置和调用者的环境信息。很多语言实现闭包时,捕获的也是外层函数栈帧里的变量。
理解这一点,对你写代码有什么实际影响?最直接的影响是:当一个递归函数持有较大的局部变量时,每一层递归都会把整份变量压进栈帧,内存占用会成倍上涨;改写成显式栈后,你可以更精细地控制“哪些状态需要保存,哪些可以丢弃”。这在处理大数据量任务时,差别非常明显。
栈不是一个只在课本里存在的抽象概念,它就在每一次函数调用的幕后,在编辑器的撤销记录里,在浏览器的历史记录里。理解了栈在真实世界的分布方式,再回头刷栈专题,你会有一种“原来如此”的贯通感。
最后分享一个我自己实践多年的习惯:刷栈专题时,不要只盯着代码模板背,而是先在纸上画出“下一个未解决状态”是什么,栈顶代表谁被卡住了,弹出意味着什么。把这个思考过程练熟了,你会发现自己可以在很短时间内判断一道新题到底该不该用栈、该维护什么样的栈。栈的内容不算多,但它值得你花时间把每一个变体都吃透,因为后面的很多专题——树的遍历、图搜索、回溯、表达式解析——都会回头用到今天这套最朴素的后进先出逻辑。