我第一次认认真真地在力扣上刷题,挑的就是第20题“有效的括号”。当时觉得这不就是小学算术里的括号配对吗,结果连交三次全是红叉。后来回头看,这道题几乎是我算法入门的分水岭——它表面上是字符串处理,内核却是栈这种数据结构的第一次实战。这篇文章就把我从看到题到彻底吃透的全过程写下来,包括栈为什么是最优解、Python和Go两种写法的细节,以及那些我踩过之后才知道的坑。适合刚开刷力扣、准备面试手写题的读者,也适合想把基础数据结构彻底弄明白的人。
这道题的知名度实在太高,力扣热题100里有它,各大厂一面手写题里也有它。但“热门”和“简单”是两回事,我见过不少人能背出题解,却在面试官追问“为什么用栈”的时候卡壳。所以这次不打算只贴一段能通过的代码,而是把题目背后的考察点、数据结构选型逻辑和真实调试过程都摊开来讲。
1. 从一道“简单题”看力扣的套路:题目真正想考察什么
1.1 题目描述里容易被忽略的三个事实
题目描述不算长:给定一个只包括 '(',')','{','}','[',']' 的字符串 s,判断字符串是否有效。有效字符串需满足:左括号必须用相同类型的右括号闭合;左括号必须以正确的顺序闭合。
重读三遍,你会发现三个容易被忽略的事实。
第一,输入被限制得很死。字符串里只会出现这六种字符,不会混入字母、数字、空格。这意味着我们不需要对非法字符做额外容错,逻辑可以非常干净。很多人刷题时会习惯性写一堆防御代码,但在这道题里完全没必要。
第二,“有效的”不等于“数量成对”。"([)]" 这个字符串里,左括号和右括号的数量是匹配的,每种类型也都各有一个左括号和一个右括号,但它不是有效的。因为括号的顺序错了:[ 的闭合发生在 ( 之前,( 闭合时栈顶已经不是它自己。只看数量不看顺序,是这道题最常见的错误理解。
第三,空字符串在多数版本的定义里算是有效括号。不过力扣不同时期的题目约束会变,有些版本的 s.length 被限制为 1 <= n <= 10^4,那样就不会出现空串。我的建议是:提交前扫一眼题目最下方的约束,再决定要不要在代码里单独处理空串。这个细节在面试手写时尤其值得问一句,能够体现你对边界条件的敏感度。
1.2 为什么它能在热题100里当守门员
力扣热题100几乎是面试前人人都会过一遍的题单,第20题能排在里面,不是因为难,而是因为它考察的点非常基础且高频。
面试手写这道题时,面试官一般不是想看你能不能写对,而是想看你在“处理嵌套结构”时有没有栈的直觉。括号匹配本质上是一个嵌套结构问题,而栈天生就是处理嵌套结构的工具。一个能把这道题写得干净利落的人,至少说明他理解后进先出,理解“最近匹配”,也理解怎么用哈希表简化条件判断。
这道题在力扣上的通过率不算低,但提交量巨大。只要系统里有一道题被上万人反复提交、反复踩坑,它就一定具备教学价值。我后来刷到第32题“最长有效括号”和第921题“使括号有效的最少添加”时,发现它们全都是从第20题这个基础模型长出来的。所以如果你时间有限,与其在困难题里死磕,不如先花两小时把这道题彻底吃透,后面一系列括号题都能沾光。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 栈结构为什么是最优解:从暴力配对到线性扫描的思维转变
2.1 先想暴力法,才知道栈好在哪里
我第一次做这道题时,脑子里冒出来的暴力解法是:从左往右扫,每遇到一个左括号,就向右找它对应的右括号,找到后标记掉,继续找下一个。这种做法看着简单,实际上有一堆问题。
比如字符串 "{ [ ( ) ] }",如果只是找同类型的右括号,( 会找到 ),[ 会找到 ],{ 会找到 },看起来能过。但遇到 "([)]" 这种交叉嵌套时,单纯“找到同类右括号”就会出错。你必须要额外记录括号之间的相对位置,判断内层是否完全包在外层里面,这代码写起来就变成递归或者复杂循环了。
更现实的问题是复杂度。暴力方案最坏情况下要反复扫描字符串,时间复杂度能到 O(n²)。当 n 接近一万甚至十万时,超时几乎是必然的。力扣的简单题一般不会真的卡死你,但你要是在面试现场给出一个 O(n²) 的解法,面试官大概率会追问“能不能优化”。这时候如果你能直接说出来“用栈,O(n)”,已经算赢了一半。
2.2 栈的本质:只关心“最近的未匹配项”
栈这个东西,生活里到处都是。食堂里叠餐盘,后放的先拿走;浏览器里点返回,退回的是最近一次访问的页面;编辑器里按撤销,撤销的是最近一次操作。这些场景的共同点,是“后进先出”。
括号匹配恰好也是如此。想一想:字符串从左往右扫的时候,最内层的右括号,一定匹配的是最近遇到的、还没有闭合的左括号。比如 "{([])",遇到第一个右括号 ) 时,它要匹配的左括号是刚入栈的 [,而不是更早的 {。这就是“最近匹配”的思想,和栈的弹栈顺序完全一致。
算法流程因此变得非常清晰:
- 初始化一个空栈。
- 从左到右扫描字符串的每个字符。
- 如果是左括号(
(、[、{),直接压入栈。 - 如果是右括号(
)、]、}),检查栈顶的左括号是否与它匹配。 - 匹配则弹出栈顶,继续扫描;不匹配则直接返回
false。 - 全部扫完后,栈为空返回
true,不为空说明有左括号没闭合,返回false。
你发现没有,这个流程里我们根本不关心字符串里到底有多少个左括号,也不会去数数量。每个右括号只和当前栈顶比较一次,全程线性扫描,时间复杂度 O(n),空间复杂度最坏 O(n)。
2.3 用哈希表把配对关系收拢起来
括号只有三类,你完全可以写三个 if 判断,但那样代码会很啰嗦。更通用的做法是用哈希表存配对关系。这里有一个小设计值得多说一句:键用右括号,值用左括号。
python复制pairs = {
')': '(',
']': '[',
'}': '{',
}
为什么不是反过来?因为扫描时遇到右括号,我们想知道的是“栈顶的左括号是不是我的另一半”。用右括号做键,查表一次就能拿到期望的左括号,然后直接和 stack[-1] 比较。如果反过来用左括号做键,遇到右括号时还得遍历所有键值对去找,代码会多一层循环,效率也差。
哈希表查表是 O(1),所以整体时间复杂度保持 O(n)。这个“右括号做键”的习惯,在后面刷类似括号题目时会一直用到,建议一开始就养成。
3. Python和Go两版实现:从伪代码到可提交的完整代码
3.1 Python版本:最简单直白的写法
Python 写这道题非常顺手,列表自带的 append 和 pop 就是天然的栈操作。我最常用的写法如下:
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。这不是必要的优化,但能省掉后面几乎所有无效遍历,属于一眼就能看出来的剪枝。
pairs 字典负责保存配对关系。ch in pairs 这个判断天然区分了当前字符是左括号还是右括号:如果是右括号,它一定在字典的键里;如果是左括号,它不在。这种方式比 if ch == '(' or ch == '[' or ch == '{' 清晰很多,也方便以后扩展更多括号类型。
栈顶比较前必须先判空。not stack 放在前面,是因为如果栈是空的,说明当前右括号没有可匹配的左括号,比如输入就是 ")",这时候应该直接返回 false。如果不判空就取 stack[-1],Python 会直接抛 IndexError。
最后 return not stack 替代了 return True if len(stack) == 0 else False,代码更简洁。这个写法在刷题圈很常见,读起来也很自然。
3.2 两个小优化:快速失败和减少空间
除了奇数长度剪枝,还有一个隐含的快速失败机制。当字符串第一个字符就是右括号时,循环第一次就会进入 ch in pairs 分支,然后发现 not stack 为真,直接返回 false。不需要把整个字符串看完。
空间方面,Python 的 stack 列表最坏情况下会存下所有左括号,空间复杂度 O(n)。有人会问能不能用 collections.deque,其实没必要。我们要的只是栈顶的添加和弹出,Python 列表的 append 和 pop 都是从尾部操作,均摊时间复杂度 O(1),足够用。deque 的优势在两端操作,这里用不上。
还有一个常被忽略的点:当配对关系很固定时,也可以不用字典,直接写 if ch == ')' and stack[-1] != '(' 这种判断。但字典的好处是把“配对关系”和“匹配逻辑”解耦了,后面如果要支持 '<' 和 '>',只需要在字典里加一项,逻辑代码完全不用动。我在第20题之后的很多栈题里,都复用了这个模式。
3.3 Go版本:用切片模拟栈的注意事项
Go 语言没有内置的栈结构,最自然的做法是用切片模拟。切片尾部追加和截断,分别对应入栈和出栈。
go复制func isValid(s string) bool {
n := len(s)
if n%2 == 1 {
return false
}
pairs := map[byte]byte{
')': '(',
']': '[',
'}': '{',
}
stack := make([]byte, 0, n/2)
for i := 0; i < n; i++ {
ch := s[i]
if expected, ok := pairs[ch]; ok {
if len(stack) == 0 || stack[len(stack)-1] != expected {
return false
}
stack = stack[:len(stack)-1]
} else {
stack = append(stack, ch)
}
}
return len(stack) == 0
}
这里我用了 map[byte]byte 而不是 map[rune]rune。因为题目里的字符串只包含 ASCII 括号字符,s[i] 取出来就是 byte,用 byte 做键和值省去了类型转换。如果你在处理中文或其他 Unicode 字符,才需要改用 rune,但本题不需要。
创建切片时预分配容量 n/2 是一个小技巧。因为有效字符串里左括号最多占一半,预分配一半容量可以避免切片频繁扩容。虽然这道题的 n 最大也就一万,扩容消耗不大,但这个习惯在性能敏感的题目里是值得注意的。
Go 版本里出栈操作是 stack = stack[:len(stack)-1],这和 Python 的 pop() 效果一样,都是把切片长度减一。切片的底层数组还在,只是长度缩短,之后新的入栈元素会覆盖旧位置,不需要担心内存泄漏。
3.4 两个版本的表现对比
| 项目 | Python 版本 | Go 版本 |
|---|---|---|
| 时间复杂度 | O(n) | O(n) |
| 空间复杂度 | O(n) | O(n) |
| 栈实现方式 | 列表 append/pop | 切片 append/截断 |
| 配对关系 | 字典右括号做键 | map[byte]byte |
| 适用场景 | 快速刷题、面试讲思路 | 工程落地、性能敏感场景 |
从算法角度讲,两个版本没有任何本质区别。但从工程角度讲,Go 版本更能体现“用切片管理栈”的思路,代码也更贴近底层。我个人的习惯是:先用 Python 把思路跑通,再用 Go 写一遍加深理解。两种语言对栈的表达方式不同,写一遍相当于从两个角度复习了同一种数据结构。
4. 这些坑我全踩过:边界条件、异常输入和提交失败的复盘
4.1 只判数量,不判顺序
我第一次写这道题时,思路走偏到了“统计每个括号出现次数,然后看左右是否相等”。这个思路在 "()"、"()[]{}" 上都能过,但遇到 "([)]" 就失败了。"([)]" 里每种括号的左右数量都是相等的,可顺序完全错误。
所以一定要记住:数量相等是必要条件,不是充分条件。括号匹配必须满足“正确顺序”,也就是后出现的左括号要先闭合。这个“后进先出”的顺序约束,是栈存在的根本原因。
4.2 遇到右括号时,栈已经是空的
这种错误在初次提交时非常常见。比如输入 ")" 或 "() )" 这种字符串,扫描到右括号时,栈里要么就是空的,要么已经弹完了。
错误写法是这样的:
python复制for ch in s:
if ch in pairs:
if stack[-1] != pairs[ch]: # 栈为空的瞬间直接报错
return False
stack.pop()
我在本地测试时输入 ")",控制台直接抛出 IndexError: pop from empty list。你可能会想,这不是正好说明字符串无效吗?但在 LeetCode 上,运行时报错不等于返回 false,提交照样是错的。正确做法是每次访问栈顶前先检查 not stack,如果栈已经是空的,直接返回 false。
这个判空顺序我至今都建议初学者把“先判空”写在“取栈顶”前面,养成肌肉记忆。等你刷到后面更多栈题,会发现“取栈顶前判空”是几乎所有栈题通用的防御习惯。
4.3 遍历完字符串,忘了检查栈是否为空
这个坑和上一个正好相反。有的代码在处理完所有字符后直接 return True,导致 "(((" 这种只有左括号的字符串被判为有效。
我的第一版代码就犯过这个错。当时觉得只要遇到右括号时能匹配上就万事大吉,完全没考虑最后一个左括号可能永远等不到配对。实际上,一个有效的括号字符串在遍历结束后,栈必须回到空状态。任何残留在栈里的左括号,都意味着某个括号没有正常闭合。
return not stack 这行代码,就是对这个条件的直接表达。如果你在面试时写的是 return True,面试官大概率会追问:“那 ((( 算有效吗?”别问我是怎么知道的。
4.4 建议覆盖这几类测试用例
一个负责任的刷题习惯,是在提交前用一组覆盖边界的用例自测。我自己常用的测试集是这样的:
- 空字符串:按题目定义,通常返回
true - 单个左括号:
"(",返回false - 单个右括号:
")",返回false - 基本匹配:
"()"、"[]"、"{}" - 混合匹配:
"()[]{}" - 嵌套匹配:
"{[()]}" - 交叉错序:
"([)]",返回false - 前缀正确但结尾残渣:
"()(",返回false - 后缀右括号多出来:
"()" )这种,返回false - 大型字符串:一万个
"("加一万个"),用于检查是否有栈溢出或超时
把这些用例在本地跑一遍再提交,能省掉至少一两次无谓的提交记录。
4.5 调试小技巧:把栈内容打印出来
如果你在本地运行时有哪组用例想不通,可以在循环里临时加一行 print(stack),看看每个字符处理后栈变成了什么。比如输入 "([)]":
text复制字符 '(' -> stack: ['(']
字符 '[' -> stack: ['(', '[']
字符 ')' -> 栈顶是 '[',不匹配 ')',返回 false
这一眼就能看出问题:) 应该匹配栈顶的 [,但 [ 还没闭合,( 反而先遇到了右括号。用肉眼观察栈的变化,比在纸上画十遍都管用。当然,提交前记得把这行打印删掉,不然输出内容会干扰判题。
5. 从第20题延伸出去:一道栈题如何变成一类栈题
5.1 那些“亲儿子”变种题
括号匹配从来不是一道孤立题。力扣上围绕括号的题目有一整条线,第20题是源头。
- 第22题“括号生成”:给出
n代表生成括号的对数,生成所有可能且有效的括号组合。这题把栈的思维和回溯结合起来,是20题之后非常自然的进阶。 - 第32题“最长有效括号”:给定一个字符串,找出最长有效括号子串的长度。难度直接跳到困难,解法里既有栈也有动态规划。
- 第921题“使括号有效的最少添加”:每次可以添加左括号或右括号,问最少需要添加几次才能让字符串变有效。这题用贪心或栈都能做,难度不大,适合验证你20题到底学透没有。
- 第1541题“平衡括号字符串的最少插入次数”:和921题类似,但要求更复杂,需要同时处理成对的括号插入。
这些题的核心,都没有离开“最近的未匹配项”这个概念。第20题的方法论,是它们共同的地基。
5.2 栈在真实项目里的高频场景
有人会问,我刷了这题,工作里真能用上吗?答案是能。
JSON 和 XML 的解析器在检查括号、标签闭合时,内部就是类似栈的结构。你在编辑器里写代码时看到括号高亮和错误提示,实时匹配逻辑多半也是栈思想。编译器的词法分析阶段要检查函数调用、条件判断的括号是否配对,用的还是栈。甚至浏览器的历史记录、表单输入的撤销重做,底层都能看到栈的影子。
我平时写代码时未必会显式地声明一个 stack,但一旦看到“嵌套”“配对”“最近闭合”这些关键词,就会本能地想到用栈来组织数据。第20题训练出的就是这种条件反射。
5.3 沉淀一个通用模板给以后的自己
刷题真正有价值的,是总结出可以复用的模板。以第20题为例,我沉淀的模板长这样:
python复制def isValidWithPairs(s: str, pairs: dict) -> bool:
left_set = set(pairs.values()) # 所有左括号
stack = []
for ch in s:
if ch in left_set:
stack.append(ch)
elif ch in pairs:
if not stack or stack.pop() != pairs[ch]:
return False
else:
# 出现未知字符时按题目要求决定如何处理
continue
return not stack
以后遇到自定义配对字符的题目,只需要传入不同的 pairs 字典,逻辑部分完全不用改。打好这样一个一个小模板,再遇到新题时就不是从零开始,而是根据差异点做局部调整。
最后说点个人体会。这道题我前后刷了三遍,第一遍对着题解抄,第二遍合上答案默写,第三遍用Go重新实现,才真正理解“栈顶就是最近未匹配项”这句话。之后再做其他栈题,我脑子里不再是“用什么数据结构”,而是“当前需要记住哪些还没解决的事”。如果你现在正准备刷力扣,我建议不要急着去挑战难题,先在这道简单题上多花点时间,把空栈判断、顺序匹配、遍历结束栈归零这几个细节都弄明白。能在调试里踩过一两次空栈的坑,栈这个数据结构就真正长在你身上了。
