1. 从一道 Hot 100 题单说起:栈为什么值得单独开一个专题
最近在刷 LeetCode Hot 100 的时候,把栈相关的题目集中过了一遍,说实话,以前做题是碰到一道看一道,感觉栈就是“括号匹配”“进出顺序”这点事,没什么花头。但当你把 Hot 100 里所有栈标签的题目放在一起横向比较,会发现栈这个数据结构在算法题里的地位被严重低估了。
它不只是“后进先出”这么简单,它几乎是理解递归、表达式求值、单调性维护、深度优先搜索这几大算法主题的底层骨架。很多题目表面上看是字符串处理、是数组问题、是树的问题,但最后解法核心全是栈。这也是为什么 Hot 100 里栈题目虽然数量不算最多,但每一道拿出来都能延伸出一类题型的解法套路。
这篇文章我想把栈这个专题从原理到刷题、再到实战经验做一个系统梳理。不管你是准备面试、考研复试,还是单纯想把算法基础打扎实,这篇内容都能帮你把这批题目真正吃透。我会结合 Hot 100 里几道典型题目,讲清楚每一类题型的识别特征、解题模板、常见坑点,以及我在实际刷题过程中踩过的那些坑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 栈的本质与应用场景:先搞清楚它和队列的根本区别
2.1 栈的底层逻辑和生活化理解
栈是一种操作受限的线性表,只允许在一端进行插入和删除操作。这个“一端”就是栈顶,插入叫入栈(push),删除叫出栈(pop),看一眼栈顶元素叫 peek。它的核心特性就是 “后进先出”(LIFO, Last In First Out)。
理解栈最直观的方式是想象一个装薯片的圆柱形筒子,最后放进去的那片薯片一定是第一个被拿出来。再比如浏览器后退按钮,每访问一个新页面就相当于压栈,点后退就是从栈顶弹出当前页面。写代码时函数的调用过程本身就是栈的体现:main 函数调用 funcA,funcA 调用 funcB,funcB 执行完回到 funcA,回到 main,这个返回顺序完全是栈式的。
和队列对比一下会更容易建立认知。队列是“先进先出”(FIFO),就像食堂排队打饭,先到的人先打到饭。这两种结构的差异决定了它们各自擅长解决的问题不同:队列适合做按顺序的调度和缓冲,栈适合做“需要回溯”“需要记录中间状态”“需要反转顺序”的场景。
2.2 从 Hot 100 看栈的六大典型应用场景
我把 Hot 100 里的栈题目翻出来归类了一下,虽然每道题的场景千变万化,但本质上栈的应用逃不出下面这六大场景:
- 括号/符号匹配:检查字符串中括号是否成对、顺序是否正确。典型题目就是“有效的括号”。
- 表达式求值与转换:中缀表达式转后缀表达式、计算逆波兰表达式的值。栈在这里承担了运算符优先级管理的职责。
- 单调栈:维护栈内元素有序,用于快速找到数组中每个元素左边或右边第一个比它大/小的元素。典型题目是“每日温度”“柱状图中最大的矩形”。
- 栈与递归/DFS 的相互转换:递归底层的函数调用栈,以及用显式栈模拟递归过程,这在二叉树遍历、岛屿类问题里很常见。
- 字符串处理与编码解码:利用栈的嵌套特性处理带括号和数字重复的字符串,典型题目“字符串解码”。
- 最小/最大栈:在 O(1) 时间内获取栈内最小值或最大值,利用辅助栈记录历史极值。
这六大场景基本覆盖了面试中栈相关题目的全部方向。你在刷题时看到题目有“匹配”“嵌套”“最近更大/更小”“回溯”这些关键词,第一反应就应该是栈。
2.3 栈的两种实现方式对比:数组栈 vs 链表栈
栈作为一种逻辑结构,底层实现通常有两种方式:顺序栈(数组实现)和链式栈(链表实现)。在刷题阶段你用现成的 Stack 类或 ArrayDeque 就够了,但在实际工程或自己造轮子时,选择哪种实现还是有讲究的。
数组栈的优势是内存连续、CPU 缓存友好、访问速度快;缺点是扩容时需要拷贝数据,存在一次性开销。链表栈的优势是无需扩容,用多少申请多少;缺点是每个节点需要额外的指针存储空间,且节点分散在内存中,遍历性能不如数组。
我个人的经验是:在算法题和一般工程代码里,优先用数组实现的栈。Java 里 ArrayDeque 就是很好的选择,它虽然是双端队列,但完全可以当栈用,性能比 Stack 类好。C++ 里直接用 std::vector 当栈也完全没问题,push_back 和 pop_back 就是入栈出栈。Python 里用列表(list)模拟栈是最自然不过的事情。
3. Hot 100 栈题目核心细节拆解:四种必须掌握的解题套路
3.1 括号匹配类:一切嵌套问题的基础模板
“有效的括号”是 Hot 100 里最基础的一道栈题目,题目要求判断一个只包含 ()[]{} 的字符串是否有效,也就是说左括号必须用相同类型的右括号闭合,并且按正确顺序闭合。
这道题的解法思路非常直接:遍历字符串,遇到左括号就入栈,遇到右括号就检查栈顶元素是否是对应的左括号。如果是,弹出;如果不是或者栈为空,直接判定无效。最后检查栈是否为空,为空说明全部匹配成功。
python复制def isValid(s: str) -> bool:
# 使用字典建立左右括号的映射关系
mapping = {')': '(', ']': '[', '}': '{'}
stack = []
for char in s:
if char in mapping: # 右括号
# 栈空说明没有对应的左括号,或者栈顶不是匹配的左括号
if not stack or stack[-1] != mapping[char]:
return False
stack.pop()
else: # 左括号
stack.append(char)
# 遍历完栈为空才算完全匹配
return not stack
这个模板的扩展性非常强。比如“括号生成”虽然是一道回溯题,但本质还是括号匹配过程中对“当前已放置左括号数量不能小于右括号数量”这一约束的利用。再比如“最长有效括号”,需要把匹配索引记录下来,然后计算最大间隔。
做这类题有一个很核心的思维:不要只把栈当成存储容器,要把它当成“当前待匹配状态”的记录器。栈里存的不一定非得是括号字符本身,可以是索引,可以是自定义对象,关键看你需要从栈中获取什么信息来决定后续逻辑。
3.2 辅助栈技巧:以空间换时间的极致体现
“最小栈”这道题的要求是设计一个支持 push、pop、top 操作,并能在常数时间内检索到最小元素的栈。如果只用一个栈,获取最小值必须遍历整个栈,时间复杂度是 O(n)。为了达到 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 时判断主栈弹出的值是否等于辅助栈栈顶,是才弹。这个方案在大多数情况下能用,但 pop 时的判断逻辑需要额外注意等于的情况,否则会出错。做笔试题追求正确性和简洁度,同步法反而是更好的选择。
3.3 表达式求值:栈如何管理运算符优先级
Hot 100 里有几道表达式相关的题目,比如“逆波兰表达式求值”。逆波兰表达式也叫后缀表达式,运算符在操作数之后,例如 ["2", "1", "+", "3", "*"] 表示 (2 + 1) * 3 = 9。它的特点是不需要括号来改变运算优先级,因为顺序已经隐含了优先级信息。
计算规则很简单:遍历 token,遇到数字就压栈,遇到运算符就弹出两个操作数,先弹出来的是右操作数,后弹出来的是左操作数,计算完再把结果压回栈。
python复制def evalRPN(tokens: list[str]) -> int:
stack = []
for token in tokens:
if token in ('+', '-', '*', '/'):
# 注意弹出顺序:先弹右操作数,再弹左操作数
right = stack.pop()
left = stack.pop()
if token == '+':
stack.append(left + right)
elif token == '-':
stack.append(left - right)
elif token == '*':
stack.append(left * right)
else:
# 题目要求的除法是向零截断,Python 的 int() 正好满足
stack.append(int(left / right))
else:
stack.append(int(token))
return stack[-1]
这里有一个非常容易踩的坑:注意操作数的弹出顺序。栈是后进先出的,入栈顺序是先左后右,出栈时先拿到的反而是右操作数。减法和除法具有不可交换性,顺序写反结果全错。另外一个坑是除法取整方向,Python 里 // 是向下取整,对于负数会出错,正确做法是 int(a / b) 做向零截断。
3.4 单调栈:解决“下一个更大/更小元素”的万能钥匙
单调栈是栈专题中最有含金量的一类题型,也是 Hot 100 里栈题目考察的重点。所谓单调栈,就是栈内元素保持单调递增或单调递减。每次新元素入栈时,把破坏单调性的元素全部弹出,弹出来的过程中就完成了信息的收集。
经典的“每日温度”问题:给定每日温度数组,返回一个数组,每个位置表示要等几天才能等到更高温度。暴力解法是双重循环 O(n²),而单调栈能做到 O(n)。
python复制def dailyTemperatures(temperatures: list[int]) -> list[int]:
n = len(temperatures)
result = [0] * n
stack = [] # 栈内存索引,保证栈底到栈顶对应的温度递减
for i, temp in enumerate(temperatures):
# 当前温度比栈顶索引对应的温度高,说明找到了“下一个更高温度”
while stack and temp > temperatures[stack[-1]]:
index = stack.pop()
result[index] = i - index
stack.append(i)
return result
核心逻辑就一句话:当栈顶元素被弹出时,当前遍历到的元素就是它的“下一个更大元素”。栈存的是索引而不是值,因为计算“隔了几天”需要索引差。
理解单调栈可以从一个调整过的比喻来看:想象你面前站着一列人,每个人都想知道自己右边第一个比自己高的人是谁。从左边开始处理,你手里维护一个“身高递减的候选名单”,新来一个人比名单最后一个高,说明最后一个找到了答案,可以把它从名单里划掉。这个“候选名单”就是单调栈。
Hot 100 里的“柱状图中最大的矩形”是单调栈的进阶应用。它不仅仅要找到“下一个更小元素”,还要同时利用左右两侧第一个更小元素的位置来确定矩形宽度。这个题对单调栈的理解深度要求更高,值得单独刷三遍以上。
4. 实操过程与核心环节实现:两道 Hard 题从读题到 AC 的完整复盘
4.1 字符串解码:嵌套结构的拆解与重建
“字符串解码”这道题输入类似 "3[a2[c]]",要求解码为 "accaccacc"。规则是 k[encoded_string],方括号内的字符串重复 k 次,且允许嵌套。看到“嵌套”和“括号”,第一反应就应该是栈。
这道题的难点在于嵌套层级的处理。我的做法是用两个栈,一个存数字(重复次数),一个存字符串前缀。遍历字符时遇到数字就累加乘数,遇到左括号就把当前的乘数和已构建字符串压栈,遇到右括号就弹出乘数和前缀,把当前括号内的字符串重复后拼接到前缀上。
python复制def decodeString(s: str) -> str:
stack = [] # 栈内元素是 [倍数, 前缀字符串]
current_num = 0
current_str = ""
for char in s:
if char.isdigit():
current_num = current_num * 10 + int(char)
elif char == '[':
stack.append([current_num, current_str])
current_num = 0
current_str = ""
elif char == ']':
num, prefix = stack.pop()
current_str = prefix + current_str * num
else:
current_str += char
return current_str
调试这个代码时我踩过一个节奏上的坑:一开始我用了一个“连续数字可能是多位数”的小陷阱。比如 "10[abc]",如果只读一位数字会变成 1 和 0 分开处理,结果完全不对。解决办法就是 current_num = current_num * 10 + int(char),这个累加方式在处理多位数时非常关键。
这道题做完之后可以对比一下“逆波兰表达式求值”和“基本计算器”,它们的内核其实是同一件事:用一个栈来管理工作目录级别的上下文,运算只发生在当前层。理解了这个层级关系,嵌套类题目基本就通了。
4.2 最长有效括号:索引入栈的边界条件分析
“最长有效括号”是 Hot 100 栈专题里错误率最高的一道题。题目给定一个只包含 ( 和 ) 的字符串,求最长有效括号子串的长度。有效括号子串意味着括号必须正确匹配且连续。
解法可以借助“有效的括号”那套思路,但栈里存的是索引而非字符。关键设计是:栈底始终保存着当前连续有效子串的前一个位置,这样当右括号匹配不上时,用当前位置减去栈底元素就能得到有效子串长度。
python复制def longestValidParentheses(s: str) -> int:
stack = [-1] # 初始化栈底为 -1,表示有效子串起始位置的前一个下标
max_length = 0
for i, char in enumerate(s):
if char == '(':
stack.append(i)
else:
stack.pop()
if not stack:
# 栈空了,说明这个右括号没有匹配的左括号
# 将它入栈,作为新的“基准位置”
stack.append(i)
else:
# 栈不为空,当前右括号找到了匹配
length = i - stack[-1]
max_length = max(max_length, length)
return max_length
这个代码最绝妙的地方在于哨兵元素 -1 的运用。它让算法在遍历第一个有效子串之前就有一个合法的“前置位置”用来计算长度。当碰到无法匹配的右括号时,把当前索引入栈作为新的基准,这保证了栈底永远是“上一个无效位置”,隔断了一串连续有效区间之间的串联。
我在第一次刷这道题的时候,用的方法是正反向各遍历一次,虽然也能 AC,但理解不够深。后来用这个“哨兵栈”的写法才真正弄明白了“连续有效子串”的计算逻辑。刷题不要只满足于 AC,把高手的解法吃透才是提升的关键。
4.3 用 MATLAB 手写一个简易的进制转换工具
这里岔开一个话题,纯粹分享一个栈在实际应用中的小例子。如果你像我一样偶尔会用 MATLAB 做数据处理,遇到要把十进制数转成二进制、八进制、十六进制的情况,其实不需要调用 dec2bin 这种内置函数,自己用栈的思路写一个完全可行,而且对理解栈特别有帮助。
matlab复制function result = dec2any_base(num, base)
% 原理:短除法,余数入栈,最后出栈得到结果
if num == 0
result = '0';
return
end
digits = '0123456789ABCDEF';
stack = [];
while num > 0
remainder = mod(num, base);
stack = [stack, digits(remainder + 1)];
num = floor(num / base);
end
result = reverse(stack); % 出栈:后进先出
end
这个例子把“除基取余,逆序排列”这个经典的进制转换过程和栈的后进先出特点做了完美结合。你用列表存余数,最后反转,其实就是在模拟栈的出栈顺序。这种“生活中的算法”比单纯刷题更能建立对数据结构的直觉。
5. 容易出错的细节与问题排查:我刷栈专题时踩过的坑
5.1 相等元素的处理:单调栈里要不要出栈
在单调栈算法里,遇到当前元素等于栈顶元素时要不要弹出,很多人会纠结。以“每日温度”为例,如果温度序列里有连续相同的温度,遇到相等温度时,到底算不算“找到更高温度”?
答案是:单调栈通常选择“严格不等才出栈”。也就是说,只有当前元素比栈顶元素更大(或更小)时才弹出。相等时不弹出,直接入栈。这样保证相等元素之间不会错误匹配,结果才是正确的。但注意,在“柱状图中最大的矩形”这类题里,遇到相等元素时如果不弹出,后面计算宽度时会漏掉一些情况,需要用到“小于等于才弹出”的变体。规则不唯一,关键是搞清楚单调性维护的目标是什么。
5.2 栈空判断:最容易忽略的运行时错误
在栈相关题目的实现中,最容易引发运行时错误的就是在栈为空时访问栈顶或执行弹出操作。每次循环中只要涉及 stack[-1] 或 stack.pop(),都要在脑子里先过一遍“这个时刻栈是空的吗”。
尤其是在处理“右括号开头的字符串”这类输入时,很容易在第一个字符就触发空栈访问。一种稳妥的做法是在循环条件里加上栈非空的判断,比如 while stack and ...。Python 的 and 短路求值特性,保证栈空时不会执行后面的 stack[-1] 访问,避免异常。养成条件里先判空再取栈顶的习惯,可以帮你少调试很多半夜找 bug 的痛苦时光。
5.3 Python 和 Java 框架下的选择对比
不同编程语言在栈的实现选择上也有一些讲究。Java 里官方 Stack 类继承了 Vector,所有方法都加了同步锁,性能上在单线程场景下有不必要的开销。刷题和工程实践中更推荐使用 ArrayDeque:
java复制Deque<Integer> stack = new ArrayDeque<>();
stack.push(1); // 入栈
stack.pop(); // 出栈
stack.peek(); // 看栈顶
Python 里直接用 list 模拟栈就够了,它的 append 和 pop 都是 O(1) 的摊还复杂度,配合 [-1] 索引访问栈顶,写法非常干净。C++ 选手可以直接用 std::stack,它是默认基于 deque 的适配器,性能足够好。
5.4 递归溢出与递归栈回溯问题
栈和递归天然相关,因为函数调用的底层就是栈帧的压入和弹出。当你在刷“二叉树的中序遍历”这类可以用递归解的题目时,偶尔会遇到递归深度过大导致的栈溢出。这时候可以用显式栈把递归改写成迭代版本,这也是“将递归转化为迭代”的核心手段。
栈回溯(stack trace)在调试中也非常实用。程序出错时打印出来的调用链就是当前函数调用栈的完整状态,能直接看到是哪个函数调了哪个函数,走到了哪一行。学会读栈回溯是排查线上问题的基本功,尤其是 Java 和 Python 这类以异常机制为主的语言。
6. 栈刷题的时间分配建议与拓展方向
如果你正在准备面试,Hot 100 的栈题目按辛苦程度可以这么分配时间:基础题(有效的括号、最小栈之类)建议一天内过完,重在建立模板感;进阶题(每日温度、逆波兰表达式)用两天时间,重点理解单调栈和栈状态管理的用意;硬骨头(柱状图中最大的矩形、最长有效括号)建议放到周末,留出整块时间反复推敲。
刷完之后有几个拓展方向值得继续深入。一是把栈和队列结合着学,比如“用栈实现队列”“用队列实现栈”这两道题,特别能检验你是否真正理解两者的本质区别。二是关注单调队列在滑动窗口最大值中的应用,它和单调栈一脉相承但维护策略不同。三是把栈推广到树的非递归遍历,你会发现先序遍历、中序遍历、后序遍历三兄弟的实现差异,本质上就是对栈操作时机的把握。
我在实际项目里用栈的场景也不少:撤销功能的实现、浏览器的前进后退、公式编辑器里括号匹配检查、配置文件中的作用域嵌套关系。这些看似八竿子打不着的场景,底层都是同一个数据结构在支撑。数据结构不是只在面试题里有用,而是代码世界里最底层的通用语言。把这个专题吃透,收获的远不止几道题的 AC 数。
