如果你刷过一阵子算法题,或者在真实项目里啃过一段时间的底层逻辑,大概率会有同一种感受:真正让你卡住的,往往不是那些名字很唬人的复杂算法,反而是像栈这样看起来简单到不行的基础结构。栈的代码量通常不超过十行,但它背后串起来的场景极多——括号匹配、表达式求值、函数调用、深度优先搜索、单调栈找极值,全是高频考点。
这篇是“优选算法”系列的栈专场。我会从栈的底层原理开始,讲清楚为什么“后进先出”这条规则这么重要;接着用经典实战场景把栈的典型用法跑一遍;再把重点放在单调栈上,这是面试和工程里最容易拉开差距的部分;最后补上栈、队列、递归之间的选型判断,以及我这些年用栈写代码踩过的坑。如果你是正在补数据结构基础的初学者,或者准备面试想快速复盘一遍栈,这篇可以直接拿来用。
1. 栈的核心原理:后进先出到底解决什么问题
很多教材一上来就给你定义:栈是一种只能在一端进行插入和删除操作的线性表,这一端叫栈顶,另一端叫栈底。这定义没问题,但太干了。我更喜欢用“弹簧弹夹”来理解它——你往弹夹里压子弹,最后压进去的子弹最先被顶出去,最先压进去的反而最后才出来。栈的行为和这个完全一致,所以它的核心规则就是四个字:后进先出(LIFO,Last In First Out)。
这种“后进先出”的规则在现实生活里其实到处都是。你有一堆盘子要叠起来,常用的盘子一定放在最上面,用完往回摞的时候也是放最上面;浏览器里的后退按钮,你访问的顺序是 A -> B -> C,点后退时回的是 C,然后再到 B,再到 A;编辑器里的撤销操作,撤销的总是最近一次修改。这些场景底层都是“栈”的行为。
理解了规则之后,再看它的操作就非常清晰了。栈的核心操作只有三个:
- push:把元素压入栈顶。
- pop:把栈顶元素弹出。
- top / peek:看一眼栈顶元素,但不出栈。
实现方式有两种。一种是用数组模拟,栈顶就是数组末尾,push 就是 append,pop 就是 pop 掉最后一个元素,这种实现简单直观;另一种是用链表模拟,用一个 head 指针指向栈顶节点,每次都在头部插入或删除。实际刷题和开发里,数组模拟几乎总是首选,因为数组的内存是连续的,CPU 缓存友好,性能更好。
用 Python 写一个最简栈实现,其实就是 List 的几个方法:
python复制stack = []
stack.append(1) # push 1
stack.append(2) # push 2
top = stack[-1] # peek,结果是 2
stack.pop() # pop,弹出 2,栈里剩 [1]
C++ 则可以直接用标准库:
cpp复制#include <stack>
stack<int> st;
st.push(1);
st.push(2);
int top = st.top(); // 2
st.pop();
那为什么计算机里这么需要“后进先出”这种规则?这里要说到它和人类思考方式的匹配度了。人在做嵌套任务时天然是“先开始的后结束”:你打开了浏览器页面,页面里点进了一个帖子,帖子里又播放了一个视频,你要退出一定是先关视频,再退帖子,最后关浏览器。函数调用也是这样的,main 函数调用了 funcA,funcA 又调用了 funcB,funcB 执行完先返回,然后 funcA 才能继续,最后才回到 main。系统用调用栈来管理这一整个流程,每一层函数的局部变量、参数、返回地址都压在一个栈帧里,返回时就弹出。没有这种嵌套管理机制,程序连“函数返回后回到正确位置”都做不到。
很多人容易把这里的“栈”和“技术栈”搞混,尤其是刚接触全栈开发的朋友。前端、后端、运维那套叫 technology stack,说的是你用了哪些技术组合;数据结构里的栈说的是内存里的一种组织方式。两者只是中文翻译撞了个车,本质上没有任何关系。我见过不少简历写“技术栈:Vue + Spring Boot”,这里的栈严格来说应该翻译成“技术组合”,只是大家叫习惯了。区分清楚这件事,不仅是为了概念正确,更是为了理解:全栈开发里系统分层的思想,其实也是“后进先出”——请求先进 Controller 层,然后进 Service 层,再进 DAO 层,响应再一层层返回,和栈的进出模型很像。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三个高频实战场景:括号匹配、表达式求值与递归调用
明白了栈的底层结构以后,还得看它到底能干哪些活。栈在算法题里的出场率极高,但只要你能识别出“匹配”“逆序”“嵌套”这三个特征,基本就能判断出题目和栈相关。下面我用三个最典型的场景来说明。
2.1 括号匹配:最经典的栈入门题
先看一个几乎所有刷题人都做过的题:判断一个只包含括号的字符串是否有效,比如 "()[]{}" 是合法的,"{[}]" 是非法的。这类题叫什么名字不重要,重要的是它考察的核心逻辑——括号必须成对出现,并且嵌套顺序要正确。
暴力做法不是不行,你每遇到一个右括号就往前找最近的对应左括号,但如果括号还嵌套多层,这个方法很快就会乱套。用栈来做,逻辑一下子就清晰了:从头到尾遍历字符串,遇到左括号就压栈,等待一个右括号来和它配对;遇到右括号时,检查栈顶元素是不是对应的左括号,是就弹出,不是或者栈已经空了就说明配对失败。遍历结束后,如果栈是空的,说明所有左括号都找到了自己的另一半。
代码也很短:
python复制def is_valid(s: str) -> bool:
stack = []
pairs = {')': '(', ']': '[', '}': '{'}
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
这个解法时间复杂度是 O(n),空间复杂度也是 O(n)。为什么栈恰好适合这个问题?因为括号配对天然是“最近匹配”:最后一个出现的左括号,要最先遇到它的右括号。这就是后进先出的特点。你把这个过程类比成生活中的“嵌套任务”,最里层的任务最先结束,栈刚好完美复刻了这个规则。
这个题在真实面试里经常被扩展,比如要求在带通配符的字符串里判断括号是否合法、要求找出最长的有效括号子串长度、或者是括号还要配合加减法运算来做。核心思路不变,但题目难度会从 Easy 一路涨到 Hard。
2.2 表达式求值:编译器里的核心算法
你在代码里写 3 + 4 * 2,计算机会不会懵?会。因为人对运算优先级的理解是内置的,先乘除后加减,但计算机从左到右扫描,它默认的操作方式没有那么智能。为了让机器能正确算数学表达式,编译器要做一件事:把中缀表达式(人写的)转成后缀表达式(机器好算的),或者用两个栈来直接处理。
拿 3 + 4 * 2 举例。转成后缀表达式是 3 4 2 * +。后缀表达式的计算规则特别简单:遇到数字就压栈,遇到运算符就从栈里弹出两个数,先弹出的放右边,后弹出的放左边,运算完再把结果压回去。整个过程只需要一个操作数栈,不用回头管什么优先级。
具体走一遍:数字 3 入栈,数字 4 入栈,数字 2 入栈;遇到 * 号,弹出 2 和 4,计算 4 * 2 = 8,把 8 压回去;遇到 + 号,弹出 8 和 3,计算 3 + 8 = 11,栈里最终剩下 11。每一步都是确定性的,没有任何歧义。
如果不想先转后缀,也可以一边扫描一边用两个栈处理:一个栈放数字,一个栈放运算符。遇到运算符时,如果当前运算符的优先级低于或等于栈顶运算符的优先级,就先把栈顶运算符算掉,再压入当前运算符。这个过程叫“算符优先分析”,是编译原理课程里的经典内容。括号的处理就是:遇到左括号压栈,遇到右括号则一直弹出运算符参与运算,直到弹出的运算符是左括号为止。
你可能会说,现在谁还自己手写表达式求值啊,数据库、模板引擎、规则引擎里不都内置了?但问题是,理解这套机制,你才能明白像 Drools 这类规则引擎在处理大量规则事实时,为什么可以选择栈式的遍历和匹配方式——规则的条件循环嵌套,本质还是在做“先进入的条件后退出”的匹配过程。而且很多脚本语言里的 eval 功能、业务系统里的动态公式计算,底层都是这套逻辑。这个场景中栈的价值是:它允许我们“把一部分工作延迟处理”,等拿到足够信息后再回算,这种思想在工程里极其重要。
2.3 函数调用与递归:看不见的运行时栈
前面提到,程序在运行时会维护一个调用栈。每调用一个函数,系统就压入一个栈帧;函数返回时,弹出这个栈帧。你随手写个递归函数,比如二叉树的前序遍历:
cpp复制void preorder(TreeNode* root) {
if (!root) return;
visit(root);
preorder(root->left);
preorder(root->right);
}
这个函数之所以能一层层进入左右子树再正确返回,全靠运行时栈保存每一层的现场。一旦递归深度过大,比如你处理一个单链表形态的树,递归深度达到几十万层,栈空间被耗尽,就会报“栈溢出”(Stack Overflow)。这个错误本质上不是算法逻辑错了,而是运行时栈容纳不下这么多嵌套的栈帧了。
理解这一点后你会发现,很多“递归改迭代”的题目,本质就是把隐式的调用栈改成显式的栈,由你自己模拟 push 和 pop。以二叉树前序遍历为例,显式栈版本大概是:
cpp复制vector<int> preorderTraversal(TreeNode* root) {
vector<int> res;
stack<TreeNode*> st;
if (root) st.push(root);
while (!st.empty()) {
TreeNode* node = st.top();
st.pop();
res.push_back(node->val);
if (node->right) st.push(node->right);
if (node->left) st.push(node->left);
}
return res;
}
为什么先压右孩子再压左孩子?因为栈是后进先出,左孩子后压进去会被先弹出,这样访问顺序才能保持“根 -> 左 -> 右”。这就是栈在深度优先搜索里的通用套路。很多你看起来炫酷的深度优先遍历,底层其实就是这么朴素的入栈、出栈循环。
这个场景对开发者的启发是:写递归函数之前先估算一下调用深度,如果深了,就换成显式栈,不要等线上爆栈了才来排查。尤其是用 Python 写递归,默认递归深度限制大概是 1000 层,稍不注意就触顶。
3. 单调栈的进阶思路:把 O(n^2) 暴力循环优化成 O(n)
说完了栈的基础应用,我们来聊点更有分量的东西:单调栈。为什么说单调栈是栈里最有“优选”气质的算法?因为它能把一类看起来很笨的题目,从 O(n^2) 的时间复杂度一口气优化到 O(n),而且代码量还特别短。
3.1 单调栈要解决什么问题
这类问题的典型描述是:给定一个数组,对数组中的每个元素,找到它右边第一个比它大的元素,或者左边第一个比它小的元素,或者类似变种。比如力扣 496 题“下一个更大元素”、739 题“每日温度”、84 题“柱状图中最大的矩形”、42 题“接雨水”,全是这一类。
暴力思路很好理解,双重循环,对每个元素都向右扫描找目标,代码大概长这样:
python复制def next_greater(nums):
n = len(nums)
res = [-1] * n
for i in range(n):
for j in range(i + 1, n):
if nums[j] > nums[i]:
res[i] = nums[j]
break
return res
这个写法正确性没问题,但遇到 n = 10^5 这种规模就彻底不行了,最坏情况 O(n^2) 要跑一百亿次操作。单调栈的思想就是借用一个辅助栈,把扫描过的元素的信息保留下来,在遍历过程中维护一个“从栈底到栈顶单调递减或单调递增”的序列。
3.2 一步步推演单调栈的运行过程
我拿数组 [2, 1, 3, 4, 2] 举例,目标是求每个元素右边第一个比它大的元素。我们选择单调递减栈(栈底到栈顶递减,也就是栈顶元素最小)。
规则是:从左往右遍历数组,当栈为空或者当前元素小于等于栈顶元素时,当前元素入栈;当当前元素大于栈顶元素时,说明栈顶元素遇到了右边第一个更大的元素,于是弹出栈顶并记录结果,一直弹到栈为空或者栈顶元素大于等于当前元素为止,再把当前元素入栈。
为什么这里要维护“递减”的栈?简单说,栈里存的都是“还没找到右边更大元素”的候选者,它们从栈底到栈顶递减,意味着栈顶是候选者里最小的那个,任何新来的“更大元素”都会优先解决它。这就是把 O(n^2) 变成 O(n) 的关键:每个元素最多入栈一次、出栈一次,总操作次数是 O(n)。
走一遍流程:
- i=0,值 2,栈空,入栈,栈里 [2]。
- i=1,值 1,1 < 2,入栈,栈里 [2, 1]。
- i=2,值 3,3 > 栈顶 1,弹出 1,记录“1 的右边第一个更大元素是 3”;再比较,3 > 新栈顶 2,弹出 2,记录“2 的右边第一个更大元素是 3”;栈空,入栈 3,栈里 [3]。
- i=3,值 4,4 > 栈顶 3,弹出 3,记录“3 的右边第一个更大元素是 4”;入栈 4,栈里 [4]。
- i=4,值 2,2 < 4,入栈,栈里 [4, 2]。
- 遍历结束,栈里剩下的 [4, 2] 都没有右边第一个更大元素,结果记 -1。
代码实现时,栈里存的建议是数组下标而不是值,因为很多题目后续要通过下标计算距离或位置。代码如下:
python复制def next_greater(nums):
n = len(nums)
res = [-1] * n
stack = []
for i in range(n):
while stack and nums[i] > nums[stack[-1]]:
idx = stack.pop()
res[idx] = nums[i]
stack.append(i)
return res
代码就这么几行,但你要真想掌握它,我建议你拿个小本子,把上面那个流程自己亲手画一遍。画完你就能理解为什么这个算法能省掉大量重复比较。
3.3 经典题拆解:每日温度、柱状图最大矩形、接雨水
单调栈最出名的三个变体题,我逐个说一下它们怎么套用。
每日温度(LeetCode 739):给你一个气温列表,返回一个列表,内容是每一天要等几天才能等到一个更高的温度,如果等不到则为 0。这题就是上文“找右边第一个更大元素”的变体,只不过结果不是记录更大的值,而是记录下标之差。做法和你刚看到的 next_greater 一模一样,只不过在弹出栈顶时记录 i - idx 而不是 nums[i]。
柱状图中最大的矩形(LeetCode 84):这题需要找每个柱子能延伸到的左右边界。对每根柱子来说,以它为中心能形成的最大矩形,取决于左边第一个比它矮的位置和右边第一个比它矮的位置。这就要用到单调递增栈——栈里从栈底到栈顶递增,这样当前柱子的高度大于栈顶时,栈顶能确定的左右边界就是已知的。弹出栈顶时,当前遍历到的下标就是它的右边界,弹出后新的栈顶位置就是它的左边界,然后用高度乘以宽度得到面积。这个题的实现细节比较多,但核心还是单调栈的进进出出。
接雨水(LeetCode 42):这题思路也类似。你可以按列去算能接多少水,这就需要在每个位置找到左边最大高度和右边最大高度,然后取较小值减去当前高度。如果用单调栈来做,维护一个递减栈,当新来的柱子高度大于栈顶时,说明形成了一个凹槽,弹出栈顶后,当前栈顶和当前下标之间的位置就是能储水的区域。凹槽的宽度由左右柱子之间的下标距离决定,高度由两侧较矮的柱子决定。
这类题刷上两三道,你就会发现单调栈的套路非常统一:确定要求的是“下一个更大”还是“下一个更小”,据此决定单调栈的增减方向;想清楚栈里存下标还是值;再想明白弹出时记录什么信息。三步走完,剩下的事情就是写代码了。这个模式比我见过的很多玄学分治、堆优先队列方案都要直接,也是“优选算法”这个系列我最想推荐给你的原因之一。
4. 栈、队列与递归如何选型:一张表看懂取舍
很多初学者最大的困惑不是不知道栈怎么用,而是拿到一道题以后不知道用栈、用队列,还是用递归。它们的区别其实就一句话:栈负责“逆序处理”,队列负责“顺序处理”,递归是靠栈实现的、天然适合“分而治之”。
我用一个表格把三种选择的关键点列出来:
| 结构 | 存取规则 | 典型应用 | 适合题型 | 主要风险 |
|---|---|---|---|---|
| 栈 | 后进先出 | 括号配对、表达式求值、深度优先、单调栈 | 找前后最近更大/更小、嵌套匹配、反转顺序 | 空栈访问、栈溢出 |
| 队列 | 先进先出 | 广度优先搜索、消息队列、缓存淘汰 | 层序遍历、逐层扩散、顺序处理 | 忘记记录层数 |
| 递归 | 借助运行时栈 | 树、分治、回溯 | 具有天然嵌套结构的问题 | 递归深度过深,爆栈 |
怎么选?有一个很实用的小技巧:拿到题目先判断它是在处理“嵌套结构”还是“线性推进”。如果是嵌套结构,比如括号、函数调用、目录树遍历、迷宫回溯,那大概率走栈或者递归;如果是按层推进,比如地图上从一个点向外逐层扩散、打印二叉树的层序遍历,那走队列。如果某题用递归写起来特别自然,但深度不可控,那就用显式栈把递归改成迭代。
就我以前做全栈项目时的经验来说,后端在处理请求链路时,中间件剥洋葱式的执行顺序也是栈式的;而异步任务、消息队列则明显是队列式的。理解了这些底层容器思想的区别,你再去看框架源码,会少很多莫名其妙的感觉。比如很多限流组件用滑动窗口,本质上就是对“先进先出”的队列做了裁剪;而撤销重做功能的开发,干脆就是两个栈来回倒。
还有一个常见的思维误区:递归和栈是两套东西,选递归就不要用栈,选栈就不要想递归。其实不是,递归本身就依赖函数调用栈。当你显式用 stack 模拟递归过程的时候,你是在把系统隐藏的行为摊开在眼前,控制的粒度更细,可以自由选择先处理哪一步、什么时候跳出去。区别在于,递归代码更短、更符合人的直接思维;显式栈代码更长,但能完全掌控运行过程,也不怕系统栈空间不够。所以选型的关键变量只有两个:一是代码可读性,二是数据规模。数据规模大、递归深度不确定,就显式栈;数据规模可控、递归写法足够清晰,就用递归。
5. 我用栈时踩过的坑:空栈、边界与调试经验
基础讲完,进阶的套路也过了一遍,最后我想分享一些实战里特别容易踩的坑。这些东西教科书上不太会写,但每次踩完都要花大把时间去排查,我列成清单给你避雷。
5.1 访问空栈:最常见的运行时错误
无论是 C++ 的 stack.top()、Python 的 stack[-1],还是自己实现的栈,访问空栈都会导致未定义行为或直接抛异常。很多人在写 while stack and ... 的时候,只记得判断当前元素和栈顶的大小关系,忘了在最前面加一个“栈是否为空”的检查。尤其是用 C++ 写单调栈时,如果对空栈调用 st.top(),程序可能在本地跑得好好的,一到线上或者全量测试就崩,而且崩溃信息还不明显。
注意:只要访问栈顶,前面就必须有栈非空的前提。我的个人习惯是,所有涉及
stack[-1]或st.top()的 while 条件里,都把not stack/!st.empty()放在最前面,利用逻辑运算符短路求值的特性避免空栈访问。
5.2 栈里存值还是存下标:决定了题目能不能往下做
很多新手一上来把值直接压进栈,然后发现题目要的是“坐标差”或者要定位到数组的具体位置,傻眼了。经验告诉我:单调栈题目默认存下标,值的比较通过 nums[index] 来取。这样在弹出栈顶的时候,你既知道这个元素的值,也知道它的位置,结果怎么算都能应付。存值虽然也能通过字典映射回去,但多一层转换就多一处出错的可能,完全没必要。
5.3 相等元素处理不当:结果差之毫厘
单调栈里遇到相邻元素相等的情况是一个经典边界。比如数组 [2, 2, 3],如果题目要的是“右边第一个严格大于当前元素的位置”,那第二个 2 在遇到后面的 3 之前不能弹出,所以两个 2 都会留在栈里;如果题目要的是“右边第一个大于等于当前元素的位置”,那第二个 2 在遍历到它时,由于 nums[i] > nums[stack[-1]] 或 >= 的条件选择不同,结果会完全不同。写代码之前先问自己一句:相等时要不要出栈?然后据此选择 > 还是 >=。这个细节面试官特别喜欢追问。
5.4 递归转显式栈时的状态保存
有时候我们需要把递归改成显式栈,最典型的是二叉树非递归遍历。很多人的实现会写得非常别扭,原因是没有把“栈帧”的概念迁移过来。递归中每个函数调用都有自己独立的局部变量,模拟时就要把一个元组或结构体压进栈里,里面至少包含两个信息:当前节点的引用、当前处理到哪一步。比如模拟深度优先遍历时,栈里可以存 (node, visited),visited 标识这个节点是第一次进入还是孩子都处理完了。这个思想可以通用到所有递归转迭代的代码里,弄明白它在“原递归的第几层、执行到第几条语句”,你的迭代代码就很难写错。
5.5 调试技巧:把每一步栈的状态打出来
说实话,我很不建议脑子空转去推栈的运行过程,尤其是单调栈,嵌套几个循环之后人很容易乱。我调试这类代码时有一个土办法:在 while 循环里把当前索引、当前元素、弹出元素、弹完后栈的内容都打印出来。肉眼看着这个过程跑一遍,很多逻辑错误一眼就能看出来。如果懒得加打印,就写一个极小的测试用例,比如 [2, 1, 3, 4, 2],手动推一遍然后和程序输出对比,几条样本数据跑完基本就能确认逻辑对不对。
最后分享一个判断是不是栈题的小技巧:当你在某个问题里发现有“最近匹配”“反转顺序”“嵌套结构”“前后比较”这四种气味中的任何一种,优先往栈的方向想。我刷题这几年,遇到不少一开始动不动想用堆优先队列或者平衡树的题目,最后回过头来发现单调栈一行 while 循环就解决了。它可能不是唯一解,但往往是代码最短、细节最少、最不容易出错的那个方案。这也正是我想在“优选算法”系列里传递的核心观点:真正优秀的方法,往往不是最炫技的,而是把复杂问题化简到几乎没有多余操作的那一个。
