☰
括号匹配详解:栈解法与算法应用全景解析
2026/10/5 2:52:11 网站建设 项目流程

先说句大实话:括号匹配这道题,在力扣上标号 20,难度标着"简单",但我见过太多人栽在它手里。为什么?因为它在算法和数据结构里扮演的角色太特殊了——它几乎是所有"栈"考题的源头。你后面遇到的中缀表达式求值、HTML 标签闭合校验、编译器语法检查、IDE 自动缩进,本质都是括号匹配的变形。这篇文章我想从暴力枚举解法一路聊到栈解法,再带上最长有效括号、括号生成这几道经典变体,把我踩过的坑、琢磨出来的套路全部摊开讲一遍。不管是刚接触数据结构与算法的新手,还是准备算法工程师面试的老手,都能从里面捞到点东西。

1. 括号匹配在算法题库里的真实地位

1.1 题目长什么样,约束有多严格

LeetCode 20 的原题描述不长:给一个只包含 '('、')'、'['、']'、'{'、'}' 的字符串,判断字符串是否有效。有效定义有三条:左括号必须用同类型的右括号闭合;左括号必须以正确的顺序闭合;每个右括号都有一个对应的同类型左括号。就这三条,没了。

这三句话里藏着两个关键信息。第一,"同类型"意味着 '[' 不能拿 ')' 来闭合,'(' 也不能被 '}' 关掉。第二,"正确的顺序"意味着括号之间可以嵌套,但交叉不允许。什么叫交叉?([)]这种就是交叉,'[' 先出现,')' 后出现,两个括号虽然都各自找到了同类配对,但闭合顺序乱了。很多新手在这里翻车,会觉得([)]应该合法,因为它"成对出现"了。实际上它非法,这就是顺序约束在起作用。

LeetCode 对这题的约束非常宽松:字符串长度最大到 10^4,字符种类限定为六种括号字符。这个约束决定了我们不需要考虑大写字母、数字、空格混进来的情况。但真正面试或者比赛的时候,题目往往会改:有的版本要求处理嵌套优先级,有的版本要求括号之间可以夹普通字符,有的版本甚至要求匹配带通配符的括号。所以我说这题是"起点",不是终点,后面我会逐个展开。

1.2 为什么说它是"数据结构第一应用题"

我刷题这些年,一个很深的感受是:数据结构面试题的难度排序,几乎总是从括号匹配开始的。原因不复杂——栈这个结构,它的核心特征就是"后进先出",而括号的闭合规则恰好是"最后出现的左括号最先被匹配"。这是天然的一一对应。与其背一堆栈的定义,不如直接拿括号匹配来感受什么叫后进先出。

更重要的是,括号匹配把"状态"这件事讲明白了。程序的执行需要记忆"当前有哪些括号还没闭合",这个记忆状态不断发展变化。用数组当然也能记录,但栈的 push/pop 语义更精准:左括号出现就压栈(记一笔账),右括号出现就弹栈(销一笔账)。这种"记账-销账"的模型,在后续很多算法里都会反复出现,比如深度优先搜索的递归回溯、HTML 解析器的标签栈、计算器的运算符栈。把这些串起来看,括号匹配就不是一道小题,而是一个理解算法思维入口的集合点。

2. 暴力解法:先把问题看透再谈优化

2.1 暴力枚举的思路

先别急着上栈。我见过很多新手拿到题第一反应就是"用栈",但你要问他为什么用栈,他答不上来。我建议大家在理解正解之前,先走一遍暴力解的思路,哪怕它慢得离谱,这个过程能让你真正看清问题的结构。

暴力解法的朴素想法是:不断找到一对相邻且匹配的括号,把它们消掉,然后重复这个过程。举例来说,字符串是(([])),第一轮扫描找到[],删掉变成(());第二轮找到(),删掉变成();第三轮找到(),删掉变成空串。如果最后能消成空串,说明字符串合法;如果消到某一步再也找不到可消的内层括号,说明非法。

这个思路在算法上对应"重复消除相邻匹配对",最坏情况下每轮都要重新扫描,每删一对就 O(n) 时间,总共要删 O(n) 对,所以整体复杂度 O(n²)。写成代码大概长这样:

def is_valid_bruteforce(s: str) -> bool: pairs = {"()", "[]", "{}"} while s: changed = False for i in range(len(s) - 1): if s[i:i+2] in pairs: s = s[:i] + s[i+2:] changed = True break if not changed: return False return True

注意这个写法里每次删除都用切片重建字符串,实际开销比 O(n²) 还要高一些。但这不重要,暴力解的意义不在于跑得快,而在于它把"嵌套必须从内层开始闭合"这个直觉翻译成了可执行的逻辑。你一旦理解了为什么反复消内层括号最后能消干净,就自然能理解栈为什么能把"内层"这件事用 O(1) 的时间维护出来——栈顶永远是当前最内层的未闭合括号。

2.2 暴力的致命瓶颈

暴力解最大的问题,是它没有利用"扫描一次"这个信息。字符串里的括号顺序是有方向性的:左括号先出现,右括号后出现。暴力法每次都从头扫到尾找最内层,实际上是在反复做无用功。比如(((((())))))这种深度嵌套,每轮只消掉最里面一层,外层完全不受影响,下一轮又得从头遍历,等于把同一段字符串看了 n 遍。

第二个问题是它无法处理需要区分同类型闭合场景的变体。当括号种类增多、或者要求判断最大匹配长度时,这种"删掉配对字符"的策略就失灵了,因为你删掉之后不知道原本的位置信息。这也是为什么我在实际刷题时,暴力解只用来帮助理解、用来写对数器验证正解,绝不作为提交方案。

提示:暴力解真正有价值的地方,是它可以拿来做"验算器"。写完栈解法之后,用随机生成的括号串同时跑暴力和栈解法,比对结果是否一致。这个做法在刷算法题时极其好用,后面我会再展开。

3. 栈解法:LIFO 结构天然为括号而生

3.1 为什么是栈,而不是队列或者数组直接计数

先把最常见的错误想法讲清楚:如果字符串里只有一种括号,比如只有(和),那根本不需要栈,一个计数器就够了。遇到(加一,遇到)减一,过程中计数器不能为负,最后计数器归零就合法。这个思路空间复杂度 O(1),很优雅。

但题目给了三种括号,情况就变了。一个计数器无法回答"当前这个右括号应该匹配哪种左括号"的问题。你只能知道有多少左括号还没闭合,却不知道最近那个没闭合的左括号长什么样。这个状态不是单一数字,而是一个"序列"——而栈恰好是保存这种序列的最佳结构。用生活类比,这就像你手里有一叠购物小票,只记了数量,却不知道最上面那张是买菜的还是买电器的。要核对退货单,你必须能立刻看到最上面的那张小票——这就是栈:后放入的小票总是在最上面。

为什么队列不行?队列是先进先出,意味着最早出现的左括号会被最先匹配。但括号规则恰好相反:最后一个出现的左括号,必须先遇到它的右括号。({})里{虽然在中间出现,却必须先闭合;如果把{排队等最后处理,顺序就全乱了。数组当然也能模拟,但数组的插入删除在中间位置是 O(n),只有栈的 push/pop 两端操作是 O(1),所以栈是这个场景下时间复杂度和代码简洁度的双重最优解。

3.2 核心代码与逐行拆解

标准解法用哈希表存右括号对应的左括号,然后一次遍历:

def isValid(s: str) -> bool: 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

逐行拆开来说。pairs这个映射表的键是右括号,值是它对应的左括号,这样设计是为了在遇到右括号时能 O(1) 查出"我该匹配谁"。stack只存左括号,这是很多人容易写错的地方——把右括号也丢进去,后面匹配逻辑就乱套了。

遍历时有个关键分支:if ch in pairs,判断 ch 是不是右括号。Python 里 in 操作对字典是查键,O(1)。如果 ch 是右括号,先看栈空不空。栈空说明当前右括号没有可匹配的左括号,直接判非法,比如")("的第一个字符。栈不空再看栈顶元素,如果栈顶不等于pairs[ch],说明类型不匹配,比如"(]",也直接判非法。两个检查都过了,pop()把配对完成的左括号销掉。

如果 ch 是左括号,直接append(ch)。这里不需要任何额外校验,因为题目已经保证只有六种字符。最后一行return not stack是很多新手会漏掉的关键:如果所有右括号都匹配成功了,但栈里还剩着没闭合的左括号,比如"(()",那照样非法,必须把栈清空才算通过。

3.3 跟着一个例子走一遍

拿"([{}])"来模拟一次:

  • 遇到(,入栈,栈为["("]
  • 遇到[,入栈,栈为["(", "["]
  • 遇到{,入栈,栈为["(", "[", "{"]
  • 遇到},栈顶是{,匹配,弹出,栈为["(", "["]
  • 遇到],栈顶是[,匹配,弹出,栈为["("]
  • 遇到),栈顶是(,匹配,弹出,栈为[]
  • 循环结束,栈为空,返回 True

再拿非法的"([)]"走一遍:

  • 遇到(,入栈,栈["("]
  • 遇到[,入栈,栈["(", "["]
  • 遇到),栈顶是[,不等于pairs[')']也就是(,返回 False

这就是顺序约束的直接体现:[还没闭合,)就来了,破坏了嵌套的先后关系。整个过程清晰地展示了为什么栈能一次遍历解决这个问题——每个字符最多入栈一次、出栈一次,时间复杂度 O(n),空间复杂度最坏 O(n),也就是全是左括号的时候。

4. 三道经典变体:从入门到进阶

4.1 变体一:只验证合法性,但要求空间 O(1)

LeetCode 20 本身用栈是标准答案,但面试官经常追加一个问题:能不能把空间降到 O(1)?如果所有括号类型不限,答案是不能,因为你必须记住历史上出现过的括号类型,最坏情况下要存 O(n) 个信息。但如果题目改成只有一种括号,上面说的计数器法就是 O(1) 空间:

def isValidSingleType(s: str) -> bool: count = 0 for ch in s: if ch == '(': count += 1 else: count -= 1 if count < 0: return False return count == 0

这个变体在面试里出现的频率很高,它的意义在于考察你是否理解"栈存的本质是信息,信息量决定了空间复杂度下限"。如果括号只有一种,未闭合括号的数量是一个标量,一个变量就够;如果括号有多种,未闭合括号的类型序列是向量,必须用栈或者等价的数据结构存。这个推论在系统设计里同样成立——你设计的解析器需要保存多少上下文,决定了它的内存模型。

4.2 变体二:LeetCode 32 最长有效括号

这道题是 20 题的升级版,难度直接跳到困难。题目给一个只含左右括号的字符串,要求找出最长的有效括号子串长度,注意是连续的。栈解法依然可用,但栈里存的不能再是括号字符,而是下标:

def longestValidParentheses(s: str) -> int: stack = [-1] # 哨兵下标,方便计算长度 max_len = 0 for i, ch in enumerate(s): if ch == '(': stack.append(i) else: stack.pop() if not stack: stack.append(i) # 右括号匹配失败,当前位置成为新的基准 else: max_len = max(max_len, i - stack[-1]) return max_len

这个写法的关键在于哨兵。栈底始终保留着"最后一个未匹配位置"的索引,遇到右括号时先 pop,如果 pop 之后栈空了,说明这个右括号本身是多余的,它把栈底的基准位置搞丢了,于是把当前下标 i 压进去当新的基准。如果 pop 之后栈非空,那么栈顶就是当前右括号前一个未匹配的左括号下标,从它到 i 之间的子串一定所有括号都匹配上了,长度就是i - stack[-1]。

用"()(()"走一遍验证:i=0 左括号入栈,i=1 右括号 pop 后栈底只剩 -1,长度算出来 2;i=2 左括号入栈;i=3 左括号入栈;i=4 右括号 pop 后栈里还有 i=2 那个左括号,计算长度 4-2=2。结果最长是 2,符合预期,因为后面那个()跟前面的()中间被(断开,长度加不上去。这个例子特别适合体会"为什么 pop 后栈空要重新入栈"——断点信息就是靠这个机制保留下来的。

除了栈,这题还有动态规划解法,用 dp[i] 表示以第 i 个字符结尾的最长有效括号长度。转移方程分两种情况,s[i] 是)且 s[i-1] 是(时,dp[i] = dp[i-2] + 2;s[i-1] 也是)时,还要回头看 s[i-dp[i-1]-1] 是不是(。我个人实践下来的建议是先把下标栈法吃透——它逻辑连贯、不容易写错,DP 的转移方程一旦下标错一位就是隐蔽 bug,排查成本很高。

4.3 变体三:LeetCode 22 括号生成,回溯加剪枝

跟前面验证类题目不同,生成类题目要求你输出所有合法括号组合。比如 n=3,要生成"((()))", "(()())", "(())()", "()(())", "()()()"这五种。这类题的核心是回溯法,而"剪枝算法"在这里体现得淋漓尽致——搜索空间是 2 的 2n 次方,不剪枝根本跑不完。

def generateParenthesis(n: int) -> list[str]: res = [] def backtrack(left: int, right: int, path: str): if len(path) == 2 * n: res.append(path) return if left < n: backtrack(left + 1, right, path + '(') if right < left: backtrack(left, right + 1, path + ')') backtrack(0, 0, "") return res

两个剪枝条件非常关键:加左括号的前提是left < n,避免生成出超过 n 个左括号;加右括号的前提是right < left,意思是当前已用的右括号不能超过左括号,否则就会出现右括号超前匹配的情况,比如")("这种非法前缀,提前把它掐掉。这两个条件合起来,保证了搜索路径上的任何前缀都满足括号合法性,最终所有叶子节点一定是完整合法串。

我用这段代码实测过 n=10 的情况,生成 16796 种组合,秒级返回,说明剪枝条件把无效分支砍得很干净。面试考这道题时,考官通常还想让你分析一下结果数量——n 对括号的合法组合总数等于第 n 个卡特兰数,公式是 C(2n, n) / (n+1)。这个结论背下来不算本事,能现场从"每步必须满足 right <= left 且总数限制"推导出来才算真正理解。

5. 常见 Bug 与排查技巧实录

5.1 高频翻车点排行

我在给同事做 code review 的时候,发现括号匹配这道题翻车的点非常集中,排个序:

第一条,忘判栈空直接 pop。C++ 和 Java 里对空栈 pop 直接抛异常;Python 里stack[-1]对空列表取最后一位会 IndexError。我见过太多人写if stack[-1] != pairs[ch]然后忘了前面的 not stack 判断,字符串一上来就是右括号直接崩。记住一条铁律:任何取栈顶之前,先问自己栈空不空。

第二条,最后忘了检查栈是否为空。输入"(()",三个字符处理完,代码全程没报错,结果返回 True。这种 bug 最阴,因为小规模的测试用例"()"能过,"()()"能过,就是"(()"之类不对称的用例才能发现。解决办法是写完代码默念三遍:循环结束之后return not stack。

第三条,类型映射写反。把映射表写成{'{': '}'}之类,然后匹配时拿左括号跟右括号比,比较方向完全拧了。解决方案是把表统一成"右括号映射到左括号",这样遇到右括号时只需要一次查表,思路最顺。

第四条,把非括号字符也塞进栈里。有些题目变种会混入字母,比如"(a)",正确做法是跳过普通字符,只在括号上做判断。如果直接把'a'入栈,后续所有右括号都会匹配失败,而且你还不好排查,因为报错位置离真正的问题点隔了好几步。

第五条,混淆"合法前缀"和"整体合法"的判定时机。计数器法里,count < 0直接返回 False 是必须的,因为一旦右括号比左括号多,无论后面怎么补都不可能合法;但反过来,count > 0不能中途返回 False,因为后面可能有右括号来补。这个不对称性经常让新手写错判断时机,要么该返回没返回,要么不该返回提前返回。

5.2 如何用一组测试用例覆盖全部边界

实战经验告诉我,调试这类问题不要靠肉眼硬看,直接用一组精心设计的用例跑。我常用的最小测试集是这些:

用例预期结果覆盖点
""True空串边界
"()"True最小合法
"(("False栈未清空
"))"False空栈弹栈
"(]"False类型不匹配
"([)]"False顺序交叉
"([])"True多层嵌套
"((()))"True深嵌套
"()()"True并列结构
")("False开头结尾反向

这十个用例过完,20 题的核心逻辑基本没有死角。如果你用暴力解做验算器,可以用随机字符串生成器,括号种类限定六种,长度从 0 到 10 随机,暴力解和栈解法各跑一遍比对。这个方法在面试前突击的时候特别管用,我每次整理算法模板都会顺手写一个几十行的对数器,比刷十道同类题更能发现问题。

注意:写对数器的时候,暴力解和正解必须用完全独立的思路实现,不能是同一个逻辑的两份拷贝,否则 bug 会同时存在于两套代码里,测试就失去意义了。

6. 面试与工程里的延伸用法

6.1 面试官深挖的三个方向

算法工程师面试里,括号匹配经常作为"热身题"出现,但热身不代表可以掉以轻心。我总结下来,面试官在你这道题答完之后,通常有三层追问:

第一层是复杂度。O(n) 时间、最坏 O(n) 空间,要能脱口而出。如果被问到能否优化空间,要能接住"单种括号可用计数器 O(1)"这个变体。这一层答不上来,前面代码写得再漂亮也会扣分,因为复杂度的推导才是算法能力的体现。

第二层是思路迁移。面试官会问:如果给你一段 XML 或者 HTML,你怎么判断标签是否闭合?答案是把<tag>当左括号压栈,</tag>当右括号,栈顶标签必须和结束标签同名。这时候你能指出"标签名比括号多一层信息,所以栈里要存字符串而不只是字符",面试官就会觉得你是真的理解了解析原理,而不是背了一道题。

第三层是极端情况设计。比如字符串长度 10 的 5 次方,全是左括号,栈会不会爆?在 Python 里 list 动态扩容没什么问题,但在嵌入式或者内存受限环境里就要考虑预分配容量。再比如要求支持通配符*可以当左括号、右括号或空字符,那就是 LeetCode 678,解法变成双向计数,从左边扫一遍、从右边扫一遍,两边计数都能成立才合法。这一题如果能在 20 题后主动提出来,往往能成为面试的加分项,因为它证明你能把单一知识点推广到更复杂的约束条件。

6.2 真实工程里哪些地方离不开它

括号匹配不只是面试题,工程里到处都是它的影子。编译器前端做语法分析时,词法分析器会把代码切成 token,语法分析器就要用栈处理嵌套的块结构,花括号、圆括号、方括号的匹配就是最基础的一步。JSON 解析器处理嵌套对象时,遇到{或[压栈,遇到}或]弹栈,跟括号匹配几乎同构,只是多了键值对的上下文状态。

前端开发每天用的 IDE 括号高亮、自动补全括号,底层也是括号匹配的实时版:编辑器维护一个当前位置的括号状态,每次输入一个括号就增量更新,高亮时标记出未闭合的那一对。我记得以前调过一个代码编辑器插件,问题出在括号跨行匹配时没有维护行号信息,定位到根因后发现就是少存了一个"行号下标"维度——跟 LeetCode 32 里栈存下标而不是字符是同一套思路。

更远的还有表达式求值。中缀表达式转后缀表达式用的调度场算法,里面那个运算符栈其实就是在做"带优先级的括号匹配":左括号无条件入栈,右括号把栈里直到左括号为止的运算符全部弹出。如果你把括号匹配的栈操作练熟了,调度场算法学起来会快得多,因为它的骨架就是括号匹配加一层优先级比较。这也是为什么我一直跟身边的人说,括号匹配这道题值得反复写、反复讲,它看起来简单,实际是很多复杂系统的最小原型。

我个人在刷到"找下一个身高更高的小朋友"这种单调栈题目时,就明显感觉到括号匹配打下的底子有多重要。单调栈的核心也是"用栈维护一个单调序列,遇到破坏单调性的元素就弹栈",和括号匹配的"遇到右括号就弹栈匹配"在思维模式上一模一样,区别只是维护的语义不同。把这些并列起来看,栈就是处理"用历史状态推断当前结论"这类算法问题的主心骨。

最后分享一个小技巧:我在实际写括号匹配相关代码时,习惯先在注释里写清楚两个不变量——"栈里只存未闭合的左括号"和"任何时刻右括号数量不能超过左括号"。把不变量写出来,再动手写循环,翻车概率能降一半。这个方法对任何用栈的题目都通用,你可以试试。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询