上周帮朋友查一个 JSON 解析器的问题,现象很诡异:一段结构完全正常的嵌套数据,程序在读取到第三个层级时直接抛了异常,报错的位置却跟真正的问题完全不沾边。我盯了半小时,最后在解析器的校验模块里发现,他手写的括号配平逻辑用的是计数器——遇到左括号加一,遇到右括号减一。单看一个层级没问题,一旦遇到[()这种交叉嵌套,计数器直接瞎了。
这个小插曲其实就是标题里那个经典问题的真实缩影:用栈实现括号匹配。它在算法题库里是道入门题,但在现实工程里,它是一切结构化文本解析的底层骨架。这篇文章我不打算只给一份能跑通的代码,而是把从原理到实现、从边界到扩展的完整链路讲清楚,顺便把我踩过的、看别人踩过的坑都交代一遍。
1. 为什么括号匹配这道题,栈是唯一正解
1.1 括号嵌套结构和“最近匹配”原则
先想一个问题:当你写下一串括号((())),人工判断它是否合法,靠的是什么直觉?
你会先找最内层的那一对,确认它成对且类型相等,然后往外扩展一层。这个“从内往外”的处理顺序,天然就是后进先出(LIFO)的顺序。也就是说,最后一个出现的左括号,必然匹配第一个出现的右括号;而最先出现的左括号,要等到最后才被匹配。
这不叫巧合,这就是栈这个数据结构存在的意义之一。栈的核心操作只有两个——压入和弹出,它天然只允许你在“最近加入”的元素上做文章。所以处理括号这种互相嵌套、最近配对的场景,栈的语义和问题本身的语义完全对齐。
我见过有人试图用队列解决这个问题,处理顺序从“后进先出”变成“先进先出”,做出来的逻辑要么是错的,要么复杂度爆炸。原因很简单:你把两个语义矛盾的结构强行凑到一起,只会处处别扭。
1.2 函数调用栈和手动栈其实是一回事
理解栈结构,还有个更生活化的入口:程序自己的调用栈。
你写递归函数,A 调用 B,B 调用 C,调用完成之后,执行顺序是从 C 回到 B 再到 A。这个回溯过程,俗称backtrace 栈回溯,用的就是调用栈里保存的栈帧信息。栈帧记录了每个函数调用的返回地址、局部变量、参数等,一层层往回找栈帧的过程,就是栈回溯。
括号匹配里维护的那个手动栈,其实是同一套思想:你不断压入还在等待匹配的左括号,遇到一个右括号就弹出最近的等待者,匹配成功则继续,匹配失败则中断。你手写的栈就相当于一个极简版调用栈,只不过栈帧换成了括号字符。
理解这一点有个好处:后面你去看编译器如何做语法分析、如何做表达式求值,会发现它们全在重复同一个模式——用一个栈来维护“还没处理完的上下文”。括号匹配只是这个模式最简单、最抽象的一种呈现。
1.3 为什么不能用简单的计数替换
计数器方案看起来很美:维护一个计数,左括号加一,右括号减一,任何时刻计数不能为负,最终计数归零即合法。
实测下来,这个方案处理单一种类括号确实没问题,比如全是小括号(())、(()()),它都很稳。因为它只关心“数量是否守恒”,而单种括号的合法条件确实就是数量守恒加上前缀符号非负。
但问题出在多种括号并存的场景。看这个字符串:
[(])用计数法(假设对小括号、中括号分别计数),左中括号一个、左小括号一个,然后遇到右小括号、右中括号,数量全部归零,逻辑会判定它合法。但你写代码的时候一眼就能看出来,这种交叉嵌套是错的——[(闭合的时候应该是]收尾,结果收尾的是)。
计数法的本质缺陷是没保留顺序信息。它只知道有多少个括号没闭合,却不知道这些括号谁在最内层、谁应该先闭合。而括号嵌套恰恰是强顺序问题,这就是计数器方案必然翻车的根本原因。
所以面试时候如果有人问我“能不能用计数替代栈”,我的回答从来都是:单括号可以,多括号绝对不行。这个点也经常被拿来考察候选人有没有真正理解栈的用途,而不只是背了两道题。
2. 一步步写实现:从单类型到多类型括号
2.1 单类型括号的最小实现
先写最朴素的版本,只处理()一种括号。代码量很小,但结构完整:
def is_valid_single(s: str) -> bool: stack = [] for ch in s: if ch == '(': stack.append(ch) elif ch == ')': if not stack: return False stack.pop() return not stack这段代码里有一个容易被忽略的点:if not stack必须在pop()之前检查。如果栈为空还继续pop(),运行时会直接抛IndexError;即便你用的是某些允许负索引的语言,也会得到一个错误结果。这个检查不是优化,是正确性的必要条件。
最后一步return not stack也别写成return True。如果整个字符串遍历下来左括号多于右括号,比如"(()",栈里会残留一个左括号,此时匹配失败,返回True就是错的。这个细节我见过太多人写漏了。
2.2 多类型括号:存当前左括号,还是存预期的右括号
扩展成()[]{}三种括号,核心逻辑变成:遇到左括号压栈,遇到右括号时判断栈顶的左括号和当前右括号是否配套。
第一种写法很直白:
def is_valid_v1(s: str) -> bool: stack = [] left_set = set('([{') pairs = {')': '(', ']': '[', '}': '{'} 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 # 忽略其他字符 return not stack第二种写法略有不同,压栈的时候直接压入“它未来期望遇到的右括号”:
def is_valid_v2(s: str) -> bool: stack = [] pairs = {'(': ')', '[': ']', '{': '}'} for ch in s: if ch in pairs: stack.append(pairs[ch]) elif ch in pairs.values(): if not stack or stack.pop() != ch: return False return not stack你注意 v2 里,栈里存的不再是左括号,而是预期匹配的右括号。遇到(时压入),遇到)时弹出并比对,因为栈顶存的就是这里应该出现的字符,直接相等判断即可,不需要再查一次映射表。两版复杂度同为 O(n),但 v2 在比对阶段少了一次哈希查找,逻辑也更贴近“待匹配目标”的思考方式,我日常会推荐用 v2。
2.3 关于“其他字符”的处理策略
实际工程中,要校验的字符串往往不只有括号,还夹杂着字母、数字、空格、运算符。你需要在三个策略里选一个:忽略、报错、只提取括号再判断。
忽略最简单,就是上面两版代码里的elif分支都不命中就跳过。报错则需要额外维护一个非法字符的逻辑。只提取括号再判断适合用于纯结构分析场景。
从算法本身来说,“忽略其他字符”和“不允许其他字符”对应的是两种不同需求,没有谁对谁错。但面试时如果你不主动说明自己的策略,面试官可能会追问;工程上如果不明确输入规范,后续会出现“为什么这个字符串校验不过”的争议。我的建议是把输入约束写进注释或文档,而不是在代码里藏着。
3. 最容易踩的坑:边界条件与典型错误场景
3.1 五类必挂的测试用例
括号匹配的代码能跑通正常用例不算本事,真正拉开差距的是边界测试。这是我平时必跑的五类用例:
| 用例 | 输入 | 期望结果 | 失败原因 |
|---|---|---|---|
| 空字符串 | "" | True | 遍历不执行,栈为空,返回 True |
| 纯右括号开头 | ")(" | False | 栈为空时直接 pop 或先入为主判断错误 |
| 左括号多于右括号 | "((" | False | 遍历完栈非空,但误返回 True |
| 类型不匹配 | "(]" | False | 比对逻辑缺失或比对条件写反 |
| 交叉嵌套 | "([)]" | False | 用计数器替代栈,完全漏判 |
这五类用户外,还有一种输入是长度为奇数的字符串,比如"(()"长度是 3,如果有括号对完全闭合,括号必然成对出现,字符串长度理应是偶数。所以一个经典的快速剪枝判断是:
if len(s) % 2 == 1: return False这个优化是安全的:任何合法的括号串,长度必然是偶数。加上这个判断可以提前退出,省一次遍历。但它不是必要条件,不加也不影响正确性。
3.2 一个隐蔽的 bug:栈空时直接 pop
我第一次手写这道题时犯过这样一个错误:
elif ch in pairs: if stack.pop() != pairs[ch]: return False少了栈空检查。测试用例是"())":第一个右括号把左括号弹掉,第二个右括号再来的时候,栈已经空了,代码直接抛异常。但更隐蔽的是")("这种输入,栈为空时pop()在某些语言里返回的是一个默认值,比如 JavaScript 中数组pop()空数组返回undefined,不抛错,于是undefined != ')'成立,返回 False,结果碰巧对了。
这种“碰巧正确”最可怕。它会让你在错误的实现上误以为逻辑是对的,直到某个栈空时弹出的值和预期相等,bug 才突然炸出来。所以我现在写这类代码有个强迫症:任何一次 pop 后面必然有前置的栈空检查,不管那个语言会不会抛异常。
3.3 手写时的高频错误对照
下面这张表是我在 Code Review 里反复看到的问题——
| 错误写法 | 问题 | 修正思路 |
|---|---|---|
return True结尾 | 左括号残留时误判 | return not stack |
压栈时存左括号、比对时用!=判错 | 逻辑反了 | 明确“相等才匹配通过” |
用s[-1]访问栈顶后不 pop | 顺序错乱 | 先 pop 再校验,或者先 peek 再 pop |
| 遍历时修改原始字符串 | 无效操作 | 栈操作只影响栈本身 |
| 字典 key 和 value 搞反 | 查找结果恒为 None | 逐一用[、]、{、}验证 |
我总跟团队里的人说,这类算法题写错不可怕,可怕的是写错之后跑一两组用例就以为万事大吉。正确做法是上面那张表的用例全部跑一遍,尤其是边界用例,跑过才算完。
4. 从模板题到工程应用:变式与实战延伸
4.1 从“是否合法”到“最大嵌套深度”
很多时候我们不只是要判断括号是否合法,还想知道嵌套到底有多深,比如 JSON 解析器想要限制输入的最大深度,防止递归过深导致调用栈溢出。
计算嵌套深度其实很取巧:在遍历过程中,栈的长度就是当前的嵌套深度。遇到左括号入栈时,当前栈长就是这一层的深度;记录全程的最大值即可。
def max_depth(s: str) -> int: stack = [] max_d = 0 for ch in s: if ch in '([{': stack.append(ch) max_d = max(max_d, len(stack)) elif ch in ')]}': if not stack: raise ValueError("unbalanced brackets") stack.pop() if stack: raise ValueError("unbalanced brackets") return max_d这类需求在解析复杂嵌套结构时很有用,比如配置文件解析、模板引擎渲染。限定最大深度也是一种防御性编程——防止恶意构造的超深嵌套把栈空间打爆。
4.2 从“是否合法”到“最长有效括号长度”
一道知名变式题是 LeetCode 32:给定一个只包含(和)的字符串,找出最长有效括号子串的长度。这个题比单纯判断合法性高一个台阶,因为你需要考虑不连续的子串。
经典解法还是栈,但栈里存的是下标而不是括号字符:
def longest_valid_parentheses(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栈底先压一个-1,作用相当于一个“虚拟的左括号锚点”。遇到右括号时先弹出,如果栈空说明这个右括号是多余的,把它自己压入作为新锚点;如果栈不空,i - stack[-1]就是以当前右括号结尾的有效子串长度。这个思路的精妙之处在于把“匹配”和“长度计算”合并到了同一次遍历里。
虽然标题是“用栈实现括号匹配”,但这道变式题的解法仍然完全建立在栈语义之上,算是这个主题最值得做的进阶练习。
4.3 从括号到标签:解析器的抽象思路
括号匹配最有价值的扩展,是把“括号”抽象成任何成对出现的结构标记。比如 HTML/XML 的标签,<div>和</div>本质上就是一对“大括号”:
def is_valid_html_like(segments): stack = [] for tag in segments: if tag.startswith('</'): name = tag[2:-1] if not stack or stack.pop() != name: return False else: name = tag[1:-1] if tag.endswith('/>') else tag[1:-1] stack.append(name) return not stack再比如模板引擎里{% if %}和{% endif %}、Markdown 里的 ** 加粗标记,甚至批处理脚本里的if...end if。你一旦看穿了“成对标记 + 嵌套结构 + 最近闭合”这个三元组,就能把括号匹配的栈解法搬到非常广阔的领域。
这也是为什么我不建议把这道题当模板背——它的意义在于帮你建立一种识别结构化配对问题的能力。这种能力在写解释器、写代码分析工具的时候价值巨大。
4.4 括号生成:与回溯的交叉
括号匹配的反方向是括号生成:给定 n 对括号,生成所有合法组合。核心策略是递归放左括号和右括号,但右括号只能在“已有左括号数量大于右括号数量”时才能放:
def generate_parenthesis(n: int): res = [] def backtrack(left, right, cur): if len(cur) == 2 * n: res.append(cur) return if left < n: backtrack(left + 1, right, cur + '(') if right < left: backtrack(left, right + 1, cur + ')') backtrack(0, 0, '') return res这里你会发现,括号匹配中的“栈非空才能弹”在生成场景里变成了“右括号数量小于左括号数量才能放右括号”。一个正向校验、一个逆向生成,语义完全对称。我建议把这两个题连着练习,对栈和递归的理解会深入不少。
5. 复杂度分析与常见追问
5.1 时间和空间复杂度
括号匹配的最优解法,时间和空间复杂度都是 O(n)。
时间上,每个字符最多被处理两次(一次可能入栈,一次出栈),整体是线性扫描。空间上,最坏情况是输入全是左括号,比如"((((((((",栈中要存 n 个元素,所以空间复杂度是 O(n)。
这里有一个可以说的优化点:如果栈里只需要记录“最近未匹配的是哪种左括号”,那么用数组模拟栈和直接用系统栈递归,空间复杂度没有本质区别。但手写显式栈有个好处:你可以提前判断栈的容量,设置上限,对异常输入做保护。系统递归则容易在深嵌套时直接把调用栈打爆。
5.2 O(1) 空间能做吗?
被问到“能不能把空间降到 O(1)”,要分情况回答。
单种括号且只判断合法性,确实可以做到 O(1) 空间——用计数器就是 O(1) 空间的合法做法,前提是输入里只有这一种括号,并且你清楚计数法判断合法性的全部条件。
但多类型括号下,O(1) 空间算法是不存在的。因为它必须记住“最近的左括号是什么”,而“最近”这个信息本身是 O(n) 级别的状态量。这一步需要准确说出来,因为很多面试官想考察的就是“你是否理解空间复杂度的来源”。
5.3 栈相关的系列面试题
括号匹配只是栈应用的冰山一角。面试时常见的栈题目还包括:
| 题目 | 核心思路 |
|---|---|
| 最小栈 | 额外维护一个单调不增的辅助栈 |
| 用两个栈实现队列 | 一个入栈一个出栈,出栈空则倒灌 |
| 逆波兰表达式求值 | 数字入栈、运算符弹出两个数计算后压回 |
| 每日温度/下一个更大元素 | 单调栈维护等待匹配的下标 |
| 字符串解码 | 数字栈 + 字符串栈协同 |
这些题目表面不同,内里都在反复使用同一个工具:栈管理“尚未处理完的等待集合”。括号匹配就是理解这个抽象的第一课,把这题吃透了,后面的单调栈、双栈实现队列,理解成本都会低很多。
我的实操经验总结
这道题我在白板面试写过不下二十次,在实际项目里也修过很多次类似的 bug。如果只留三条经验,我会留:
第一,栈空检查永远是第一优先级,任何弹出操作的执行顺序里都要有它;第二,结尾一定要检查栈是否为空,这个和运行时异常的检查一样关键;第三,把测试用例刻在脑子里,尤其")("、"(]"、"([)]"这三组输入,能一次性全对,代码基本就稳了。
最后再分享一个小技巧:面试手写这道题时,可以把“栈里存什么”这个过程说出来,比如“我选择在遇到左括号时压入期望的右括号,这样匹配时直接比较”,这段话比最终代码更能体现你的工程思维,很多面试官会因为这个细节给你加分。