1. 为什么"括号匹配"是所有算法题里最值得先啃下来的那一道
如果你刷过 LeetCode、牛客或者任何算法题库,大概率见过第 20 题"有效的括号"。这道题被标记为"简单",但它的含金量一点都不简单——它是栈(Stack)这种数据结构的完美教学案例,也是编译器词法分析、IDE 语法高亮、数学表达式求值、HTML/XML 标签校验等一堆真实场景的地基。我在带新人做代码评审时经常说一句话:括号匹配都没吃透的人,后面写递归下降解析器、写模板引擎、写结构化文本校验工具,早晚要回来补课。
先把问题定义说清楚。经典版本是这样的:给定一个只包含( ) [ ] { }的字符串,判断括号是否有效。有效需要满足两个条件:左右括号必须配对,而且配对的顺序必须正确,也就是"最近打开的括号最先关闭"。比如()[]{}有效,([{}])有效,但(]无效、([)]无效、(()无效。
这道题适合谁?说实话,从刚学编程的大一新生,到准备大厂算法面试的求职者,再到需要用脚本处理日志、配置文件、模板语法的后端工程师,都值得把它彻底弄懂。因为你中学到的不是"怎么判断括号",而是**"怎么用线性结构去表达嵌套关系"**——这个思维方式,比题目本身值钱得多。
我第一次真正意识到这个问题分量的时候,是在写一个简易模板引擎。模板里有{{#if}}...{{/if}}这种块级标签,还有一个{{else}}需要正确挂载到最近的if上。琢磨了两天才发现,这不就是括号匹配的变体吗?{{#if}}是左括号,{{/if}}是右括号,{{else}}是中间的特殊处理。用栈一次遍历就能解决,而我当时用递归硬套,把自己绕晕了。所以这篇文章,我想把括号匹配这件事从头到尾拆开讲一遍:暴力枚举为什么不可行、栈为什么是正解、代码在工程里怎么写才不会埋坑、以及它怎么延伸到真正的生产场景。
2. 从"暴力枚举"到"栈":为什么嵌套结构必须用线性扫描加后进先出
2.1 先试想不用栈怎么做
很多初学者拿到这道题,第一反应是数个数:数一下左括号多少个,右括号多少个,如果相等就有效。这思路看起来没错,但立刻被([)]这种字符串打脸——左右括号数量完全相等,但它明显是无效的,因为[还没关闭就来了),(还没关闭就来了]。括号匹配的本质不是统计数量,而是验证嵌套顺序。
再往深一点想,如果不用栈,用暴力枚举怎么做?一个很自然的方案是:从左到右扫描,每遇到一个右括号,就往前找最近的、还没配对的左括号,看类型是否匹配。比如从右往左逆序扫,配完就删除。这种算法的瓶颈在哪里?假设一个合法字符串长这样:(((((((...)))))...))),每一层嵌套都很深,那么每处理一个右括号都可能要回溯很远。最坏情况下时间复杂度是 O(n²),因为你要反复从当前位置向前查找,而向前查找到的位置可能在之后的扫描中再次被影响。更麻烦的是,删除操作如果用数组实现,每次删除都要移动后续元素,又是一层 O(n)。这种"能跑但没法看"的代码,在面试里不会挂,但绝对会被追问有没有更优解。
还有一种常见尝试是用队列或者双指针从两端往中间逼近。左指针从左走、右指针从右走,判断字符是否互补。这个思路在只有一种括号的简单场景下成立,()、()()这种都能过。可一旦括号种类增加到三种,或者出现嵌套,左右对进的方式根本没办法确认"当前右括号匹配的是哪一个左括号"——你从右往左遇到的第一个左括号,不见得是实际应该配对的那一个。
所以问题的核心浮现出来了:括号的配对顺序和它们在字符串里的出现顺序是"反"的。最晚出现的左括号,最早遇到它的右括号;最早出现的左括号,最后才遇到关闭它的右括号。这种"先进后出"的顺序,和栈天然同构。
2.2 栈为什么是"天选之子"
用生活类比解释栈:想象一摞盘子,你每次只能从顶上拿盘子,新盘子也只能放在最上面。清理嵌套的括号时,规则完全一样——每当遇到一个左括号,就把它"压"到栈顶;每当遇到一个右括号,就看看栈顶的左括号是不是能和它配对,配对成功就"弹"出去。
栈这个结构只需要两个核心操作:
- push:把左括号压入栈顶。
- pop:把栈顶元素弹出来,与当前右括号做对比。
因为所有的配对决策都只发生在栈顶,不需要回头扫描,所以整个过程是一次线性遍历,时间复杂度 O(n),空间复杂度最坏 O(n)(如果全是左括号,栈会一直增长到字符串长度)。
我特意用了"天选之子"这个词,不是修辞夸张。你去看编译原理的教材,词法分析阶段处理嵌套注释、处理begin...end块、处理函数调用表达式,全是栈在做这件事。原因很简单:凡是"嵌套 + 闭合顺序相反"的问题,栈就是最自然的数学抽象。你后面学树的前中后序遍历非递归写法,学 Dijkstra 双栈算术表达式求值,学深度优先搜索的显式栈版本,都会反复和这个思维模式相遇。
2.3 从伪代码到手写实现
把逻辑翻译成伪代码,大致是:
初始化一个空栈 遍历字符串中的每一个字符 ch: 如果 ch 是左括号('(' 或 '[' 或 '{'): 将 ch 压入栈 否则,如果 ch 是右括号(')' 或 ']' 或 '}'): 如果栈为空,说明右括号没有对应的左括号,返回 false 取出栈顶元素 top 如果 top 和 ch 不匹配(比如 top 是 '(' 但 ch 是 ']'),返回 false 否则(匹配),把栈顶弹出,继续 遍历结束后: 如果栈为空,返回 true 否则返回 false(说明还有左括号没有被关闭)这里有一个关键细节,很多人第一次写会漏掉:扫描结束后必须检查栈是否为空。字符串((())走到最后,栈里还剩一个(,说明右括号全部用完了但左括号有剩余,必须判 false。反过来,右括号多了的情况已经在遍历中通过"栈为空"拦截了,所以只需要一次方向上的检查即可。
如果你在用 C++ 或者 Java 写,通常会有一个非常精简的技巧:把右括号作为 key,左括号作为 value,建立映射表。遇到右括号时直接拿栈顶去查表,就能避免冗长的 if-else 嵌套。下面给出三种语言的对照实现,方便不同技术栈的读者直接参考。
2.4 三种语言的工程实现对照
C++ 实现:
#include <stack> #include <unordered_map> #include <string> bool isValid(const std::string& s) { std::stack<char> st; std::unordered_map<char, char> pairs = { {')', '('}, {']', '['}, {'}', '{'} }; for (char ch : s) { if (pairs.count(ch)) { // ch 是右括号 if (st.empty() || st.top() != pairs[ch]) return false; st.pop(); } else { // ch 是左括号 st.push(ch); } } return st.empty(); }这个版本把"匹配判断"收敛到了查表,可读性很好。pairs.count(ch)这个判断用得很巧:你不是判断"ch 是不是(、[、{",而是直接判断"ch 是不是某个右括号",因为映射表中只有右括号作为 key。注意一个实现细节:std::unordered_map的operator[]在 key 不存在时会插入默认值,所以必须先count再访问,或者用find,否则会向表里塞入无效数据。
Java 实现:
import java.util.ArrayDeque; import java.util.Deque; import java.util.HashMap; import java.util.Map; public boolean isValid(String s) { Deque<Character> stack = new ArrayDeque<>(); Map<Character, Character> pairs = new HashMap<>() {{ put(')', '('); put(']', '['); put('}', '{'); }}; for (char ch : s.toCharArray()) { if (pairs.containsKey(ch)) { if (stack.isEmpty() || stack.peek() != pairs.get(ch)) return false; stack.pop(); } else { stack.push(ch); } } return stack.isEmpty(); }在 Java 里我特别想提醒一句:用ArrayDeque而不是Stack。Java 的java.util.Stack继承自Vector,线程安全是通过加锁实现的,在单线程算法题环境里性能是多余的损耗。而且Stack的 API 命名(push/pop/peek)虽然符合直觉,但它被标记为遗留类,官方文档都建议优先使用ArrayDeque作为栈。很多新手写算法时用了Stack,被面试官问一句"为什么用 Stack 不用 Deque"就懵了。
Python 实现:
def is_valid(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 stackPython 里没有原生的栈类型,用列表list模拟栈是最常见也最高效的做法。append对应 push,pop()对应 pop,stack[-1]对应 peek。因为 Python 的列表操作在尾部是高效率的(均摊 O(1)),所以性能完全不需要担心。
三种语言的共同点都是:空间换时间,用一张映射表避免了写三个 if 判断。这样做的好处不只是简洁,更重要的是当括号种类扩展时,你只需要改映射表,不需要改控制流。后面讲扩展到 HTML 标签校验时,你会看到这个扩展性的价值。
2.5 复杂度分析:什么时候看时间,什么时候看空间
做题或者面试的时候,复杂度分析要能脱口而出。这种"括号配对的字符串扫描"类算法,核心模式是:
- 每个字符恰好入栈一次,最多出栈一次。所以时间复杂度和字符串长度线性相关,是 O(n)。
- 空间复杂度取决于栈的深度。最坏情况是字符串全部由左括号组成,比如
(((((((,栈会一直增长,空间 O(n)。最好情况是栈基本为空,比如()()()()这种,最大值只有 1,但这依然属于 O(n) 量级的上界分析。面试官问复杂度时,直接按最坏情况回答 O(n) 时间和 O(n) 空间,然后补一句"实际运行中,正常情况下栈的深度远小于 n"就够了。
这里有个值得多想一步的地方:如果我们处理的不是单一字符串,而是一组测试用例,那么多次调用之间复用同一个栈容器是一个常见的优化点。因为栈的底层数组如果反复扩容、缩容,会有额外的分配开销。我在处理批量日志校验时就遇到过这个问题:几百万行日志每行都要做一次括号校验,每次都new一个栈对象,GC 压力很大。后来我改成用一个可复用的ArrayDeque,每次调用前clear(),性能提升很可观。这个优化在算法题里不值得做,但在生产环境的日志扫描工具里,直接决定了工具能不能在合理时间内跑完。
3. 从纯括号到复合校验:工程里最常见的四种变形题
3.1 变形一:包含字母和特殊字符时,只挑括号做校验
LeetCode 20 的题目是"只包含括号字符",但真实场景里字符串往往混着字母、数字、空格、甚至注释符号。比如你在配置文件里写if (a > 0 && b < 5) { doSomething(); },里面除了(、)、{、}还有一堆字母和运算符。这时算法只需要改一行判断逻辑:遇到非括号字符直接跳过,不参与任何栈操作。
def is_valid_mixed(s: str) -> bool: pairs = {')': '(', ']': '[', '}': '{'} brackets = set(pairs.keys()) | set(pairs.values()) stack = [] for ch in s: if ch not in brackets: continue if ch in pairs: if not stack or stack[-1] != pairs[ch]: return False stack.pop() else: stack.append(ch) return not stack核心区别在于多了一个brackets集合做预筛选。这个集合可以提前算好,不用每次循环都动态构造。
3.2 变形二:删除最少字符使括号有效,返回修正后的字符串
这个变形在 LeetCode 上有两道题,1249 题要求删掉最少的括号让字符串有效,还有一道是 301 题删掉最少括号并返回所有可能结果。这里只说基础的"删最少括号"版本,它能加深你对"什么情况下括号会冗余"的理解。
思路依然是栈,但栈里存的不是括号本身,而是括号在字符串中的索引。为什么存索引?因为最后要删除字符,你需要知道"该删哪些位置的括号"。
初始化一个栈 stack,存左括号的索引 初始化一个集合 remove,存需要删除的索引 遍历字符串: 如果是左括号: 把索引压入 stack 如果是右括号: 如果 stack 不为空: 弹出栈顶(说明匹配上了一个左括号) 否则: 把当前索引加入 remove(这个右括号是多余的) 遍历结束后,stack 里剩余的所有索引都是多余的左括号,加入 remove 最后,跳过 remove 中的索引,拼接出新字符串这个解法有一个有意思的地方:右括号一旦匹配上,就立刻把左括号弹出,这意味着我们总是优先删除"最孤立"的括号。举个例子,())这个字符串,左括号索引 0 入栈,索引 1 的)匹配成功弹出了 0,索引 2 的)发现栈为空,标记删除。结果得到()。如果是(()呢?索引 0、1 入栈,索引 2 的)匹配了栈顶的 1,循环结束后栈里还有索引 0,删除它,得到()。这两个例子说明算法确实能得到"最少删除"的解——每次只删一个多余的括号。
3.3 变形三:不只是(),还有<tag></tag>、BEGIN...END
把括号字符替换成成对的关键词,就是结构化文本校验最基础的形态。比如:
- XML/HTML:
<div>是左括号,</div>是右括号,自闭合的<img />可以当作处理时不入栈的原子操作。 - Markdown 的自定义块语法:
{{#if}}与{{/if}} - 协议报文:
BEGIN与END - 汇编 / 脚本语言:
if...endif、repeat...until
这类问题的算法框架和括号完全一样,唯一要改的是匹配判断的逻辑:从"字符相等"变成"字符串相等,且满足闭合规则"。因为栈里存的不再是一个 char,而是一个 string(标签名),所以空间开销会比字符版本大一些,但复杂度仍然是 O(n)。
我在写模板引擎时就是用了这个思路:
def validate_template(tokens): # tokens 是已经切分好的 token 流,比如 [('OPEN', '#if'), ('TEXT', 'hello'), ('OPEN', '#each'), ('CLOSE', '/each'), ('CLOSE', '/if')] stack = [] for kind, value in tokens: if kind == 'OPEN': stack.append(value) elif kind == 'CLOSE': if not stack or not stack[-1] == value: return False stack.pop() return not stack注意这里我没有像字符括号那样用映射表,而是直接比较stack[-1] == value。为什么?因为对于有名字的标签,左标签和右标签天然在名字上是一体的,不需要像字符那样建立)到(的对应关系。这个区别,是字符括号和标签校验在实际编码中的最大不同。
3.4 变形四:多个独立片段拼接,各自合法还不够
这个变形出现在实际开发中的频率非常高:你有一段文本,里面不止一套括号体系。比如日志里同时存在JSON结构(用{})和 SQL 语句(用()和字符串引号),一个完整的校验器必须对它们分别维护独立的栈,或者在一个栈里用状态标记当前上下文。
如果在同一个栈里混着压入{和(,问题就来了:({)}这个形式会怎样?栈顶是(,遇到}时发现不匹配,返回 false。但某些场景里,{和(属于不同层次、互不干扰,比如外层是模板语法、内层是嵌入的 JavaScript。这时混用单栈会导致误报。
解决办法有两种。第一种是维护两个栈,分别对应两套括号体系。第二种是更通用的思路:给栈里的每个元素带上类型标签,比如(STACK_JSON, '{')和(STACK_SQL, '('),匹配时除了比较字符还要比较标签是否一致。我曾经在一个配置文件解析器里就是第二种思路,因为那个配置文件里嵌套了三种语法,用类型标签区分后,一次遍历就能把所有层次结构全部校验掉,代码反而比拆开多次扫描更干净。
4. 深水区:括号匹配问题的三种进阶算法路线
4.1 动态规划路线:统计合法括号子串的最长长度
LeetCode 32 题"最长有效括号"是这个方向的经典题目。题目要求:给定一个只包含(和)的字符串,找出最长有效(格式正确且连续)括号子串的长度。
这个题表面上还是括号匹配,但重点从"判断是否合法"变成了"求最长合法连续段",单纯用栈也能做,但 DP 的解法更漂亮,也更符合面试官想考察的能力升级路径。
DP 的状态定义是:dp[i]表示以位置 i 结尾的最长有效括号子串的长度。重点看s[i]是什么:
- 如果
s[i]是(,那以它结尾不可能有合法子串,dp[i] = 0。 - 如果
s[i]是),看它前一个字符s[i-1]:- 如果
s[i-1]是(,那么形式是...(),dp[i] = dp[i-2] + 2。这里的dp[i-2]表示再往前一段的合法长度。 - 如果
s[i-1]是),那形式是...))。此时要看s[i - dp[i-1] - 1]这个位置。如果这个位置恰好是(,就能和当前的)配对,于是dp[i] = dp[i-1] + 2 + dp[i - dp[i-1] - 2]。
- 如果
这个递推公式是本题最烧脑的地方,我展开解释一下。当s[i] = ')'且s[i-1] = ')'时,dp[i-1]表示紧挨着 i-1 之前的合法括号段长度,假设为 L。这段合法括号一定紧贴着 i-1,坐标范围是从i-L到i-1。那么i-L-1这个位置如果恰好是(,它就能和s[i]配对,形成一段长度为L + 2的括号段。但这还没完,因为这一整段合法括号的左边,可能还连着一长段合法括号,比如()(()),最右边的)对应的 L 是 2,配对出的(())长度为 4,而其左边还有长度为 2 的(),所以还要加上dp[i-L-2]。
这个 DP 的时间复杂度是 O(n),空间复杂度 O(n)。如果你空间优化一下,把 dp 数组改成滚动变量,还能压到 O(1)。我第一次写这个 DP 时,在第二个分支上卡了两个小时,反复打印状态才理解。建议你也用()(())这个例子手动推一遍状态表格,比看十遍讲解都管用。
4.2 双向扫描路线:用"计数器"替代栈,把空间压到 O(1)
对于只包含一种括号(只有(和))的合法性判断,有一个更极端的优化方法:不用栈,用两个整数计数器。
从左往右扫描时,维护left和right两个计数器:
- 遇到
(就left++,遇到)就right++。 - 如果某时刻
right > left,说明右括号数量已经超过了左括号,必然非法,直接返回 false。 - 扫描结束后,如果
left == right,返回 true,否则 false。
这个算法的正确性建立在"只有一种括号"的前提下——因为不需要区分括号类型,只要数量关系一直保持"右括号不超过左括号",并且最终相等,那么嵌套顺序一定是合法的。这其实是括号匹配在单类型条件下的充要条件上的简化。
这种方法在已经扩展成"计数能覆盖"的场景里非常有用。比如我们做日志分析时,经常需要快速判断一段文本的括号是否平衡,不需要精确定位错误位置,只需要一个布尔结果。这时用计数器版本跑,内存占用可以忽略不计,速度比栈版本快不少。但注意:一旦括号种类大于一种,这个优化立刻失效,必须回到栈。
有一个极其经典的题目"判断字符串是否是有效括号串,且嵌套深度不超过 N",也可以用计数器变体处理。在遍历时维护一个"当前深度"变量,遇到(加一,遇到)减一,如果深度超过 N 或者中途变负,就返回 false。这类"深度约束"问题用栈做也可以,但计数器的写法明显更清爽。
4.3 状态机 / 编译原理路线:把上下文无关文法引入进来
括号匹配在形式语言理论中属于"上下文无关文法"的范畴。一个经典的文法可以写成:
S -> ε (空串) S -> ( S ) S这个文法定义了"所有合法括号串"的集合。从这个角度看,括号匹配问题就是一个简单的自顶向下语法分析。在实际工程里,这意味着你可以用递归下降解析器、或者对应的状态机来解决问题。
为什么我觉得这个角度值得了解?因为当你处理的不再是单个字符,而是带有优先级的表达式(比如2 + 3 * (4 - 1)),括号匹配只是整个解析流程中的一环。你真正需要的是一个能区分"括号上下文"和"运算符上下文"的完整状态机。很多人在 LeetCode 上刷完了括号匹配,但遇到"如何用栈计算表达式值"还是无从下手,就是因为没有建立"括号在文法中到底扮演什么角色"的意识。
用栈和状态机结合的方式做表达式解析,是 Dijkstra 老爷子提出的一种经典算法(常被称为双栈法):一个栈存操作数,一个栈存运算符。遇到(就把运算符压栈,遇到)就一直弹出运算符并计算,直到遇见左括号。这个过程本质上是在模拟递归下降解析,只是用了显式栈。篇幅所限这里不展开双栈法全部细节,但我强烈建议你把括号匹配学完之后,立刻去做 227 题"基本计算器 II"和 224 题"基本计算器",这两道题会把括号、栈、运算符优先级捏在一块,是检验你是否真正理解括号匹配的试金石。
4.4 区间 DP 路线:判断"最少插入次数"和"括号配对计数"
比最长有效括号再难一档的是 LeetCode 的几道区间 DP 题,比如 241 题(给表达式加括号求所有可能结果)、以及一些面试中出现的"最少添加括号使其有效"的题。
这类题目的通用框架是:设dp[i][j]表示子串 s[i:j] 达到某个目标所需的最小操作次数。比如对于"最少添加括号使字符串有效":
- 如果
s[i]和s[j]正好配对(() 、[]、{}),那么dp[i][j] = dp[i+1][j-1],因为两端已经配好了,只需处理中间。 - 否则,
dp[i][j] = min(dp[i][k] + dp[k+1][j] for k in range(i, j)),意思是在中间某个点切开,分别处理两段。
这个递归式的直觉是:合法括号串要么是"A(B)"嵌套结构,要么是"AB"并列结构。区间 DP 正是用切开的方式把并列结构枚举一遍。这类题做起来明显比单栈扫描费脑,但它们的本质仍然建立在"最短合法的括号串具有什么样的结构"这个基础上。如果说前面的解法是"用栈模拟过程",区间 DP 则是"从结构上枚举所有可能"。
我个人的学习建议是:不要一上来就刷区间 DP。先把栈解法做到闭眼能写,把最长有效括号的 DP 解法推明白,再碰区间 DP,否则很容易被状态转移绕晕,挫败感极强。
5. 实战中的边界条件与常见 Bug:我踩过的那些坑
5.1 空字符串、空白字符串、超长字符串
空字符串""应该返回 true,因为没有任何括号需要匹配。这对应代码里"栈为空且循环没执行"的情况,如果你在开头没有特判,很多实现天然就能过,因为循环体根本不执行,最后return stack.empty()自然返回 true。
但空白字符串" "呢?如果题目要求"只包含括号",那空白字符串其实不符合输入约束;如果你用的是"跳过非括号字符"的变形,那空白字符串应该返回 true,因为没有任何需要匹配的括号。
超长字符串的坑在于:如果校验函数内部每次新建栈容器,且外壳是循环调用,那频繁分配内存会拖慢整体性能。我在日志工具里实现批量校验时,加了一个可复用的栈对象,每次调用前clear(),实测在千万级别日志行上节省了大约 30% 的耗时。算法题里不需要关心这点,但凡是你在生产代码里实现"通用校验函数",最好保持这个意识。
5.2 右括号先来的特殊情况:stack.empty()判断不能省
很多第一版代码会漏掉这个判断:
if ch in pairs: # 右括号 if stack[-1] != pairs[ch]: # 漏了判空!这个写法在字符串以右括号开头时直接IndexError。更危险的是,有些语言里对空栈pop会报错,而有些(比如 C++ 的top在空栈上是未定义行为)则是静默错误,程序可能随机崩溃或者返回错误结果。所以任何"取栈顶"操作前,必须先判断栈是否为空。这不仅是 bug 修复,更是一个好习惯:当你的栈逻辑越来越复杂时,空栈保护能帮你在早期拦截大量非法状态。
5.3 遍历结束后忘记检查栈是否为空
这个坑我上面提过,但值得再强调一遍,因为它太容易犯了。只匹配()()这种所有括号都能成对消除的例子,你会觉得"扫描完就万事大吉";但遇到多出来的左括号((()),扫描结束时栈里显然还有东西,此时必须返回 false。正确性是"数量守恒 + 顺序正确"两条都在,缺一不可。如果你把return stack.empty()误写成return true,这类例子直接出错。我的习惯是在代码注释里明确写一句// 所有右括号都处理完了,但栈里可能还有没配对的左括号,防止未来的自己踩坑。
5.4 用if-else if还是switch处理括号类型
这三种写法各有优劣:
- 多个
if-else if判断ch == '('再压栈:可读性还行,但括号类型一多,代码迅速膨胀。 switch/case(Java/C# 等支持):把每种括号显式列出来,清晰,但也有一堆 case。- 映射表查表(我推荐的):代码最短,扩展最容易。
在工程里,如果括号映射关系可能由配置文件动态定义(比如某些工具允许用户自定义成对符号),那么映射表是唯一合理的选择,因为你不可能为未知的括号组合写 if-else。反之,如果只是写一个静态算法题,怎么清晰怎么来。
5.5 编码问题:全角/半角符号
真实场景里,用户输入里可能存在全角括号()、[]、{},甚至是中文引号。这种问题在算法题里不会出现,但在做输入校验工具时必须处理。我的建议是:在校验前做一次规范化,把全角括号映射成半角,再统一校验。如果不想规范化,另一个方案是把全角括号也加进映射表,把(映射到),但这样表会变大,而且你还需要考虑混合输入(左侧全角、右侧半角)要不要算匹配。大多数情况下,规范化是最省心的做法。
5.6 性能陷阱:在循环里调用正则表达式或字符串切片
有些实现喜欢用re.sub把成对的()、[]、{}全部删掉,不断重复直到字符串不变。这个思路极具诱惑力,因为代码看起来非常简洁:
def is_valid_regex(s): while '()' in s or '[]' in s or '{}' in s: s = s.replace('()', '').replace('[]', '').replace('{}', '') return s == ''这段代码是能跑对的,但性能极差。replace每次扫描整个字符串并创建新字符串,每次循环只消除最内层的一到两层嵌套,而remove操作本身是 O(n)。对于深度为 d 的嵌套,整体复杂度能到 O(n*d),也就是最坏 O(n²)。比如(((((((...))))))),每次循环只能削掉最中间的一对括号,整个字符串要被反复扫描 d 次。在密码学或安全场景里,这种算法还会被恶意构造的深度嵌套字符串打爆 CPU,所以我坚决不建议在生产代码里用正则替换法。
6. 括号匹配在生产环境中的真实应用:不止是刷题
6.1 结构化文本校验器
我维护过一个内部的配置中心系统,业务方上传的配置模板里允许使用{{ var }}插值语法,以及{{#if}}...{{/if}}、{{#each}}...{{/each}}这种块级指令。我们需要在上传时立刻校验块级语句是否配对,避免把损坏的模板发布到线上。这就是典型的标签型括号匹配,我使用了前面说的"字符串栈"方案,配合词法分析把模板切分成 token 流。
这里有一个生产环境特有的挑战:错误信息要能定位到具体行号。算法返回 true/false 是不够的,业务方需要知道"第 12 行的{{#each}}没有闭合"。做法是让栈里的每个元素不只存标签名,还存它在源文件里的行号。当校验失败时,直接取出栈顶的行号,拼接出人类可读的报错信息。这个需求在算法题里不存在,但真实工具里几乎必然存在。
6.2 代码编辑器的括号高亮与自动补全
你在 VSCode 或 IDEA 里看到的括号高亮、括号匹配提示,底层都是括号匹配算法的工程化实现。编辑器需要做到的比"判断合法"更多:
- 高亮当前光标所在位置的括号配对。一般做法是:从光标开始向右扫描,如果遇到左括号就压栈,遇到右括号弹出;根据配对情况返回另一侧的位置。这本质上是简化的单向右扫栈。
- 自动补全里,输入
(时自动生成),然后光标停在中间。这个不是匹配问题,但用到了"当前上下文"的概念——你必须知道当前括号嵌套深度,才知道补全哪个位置。 - 括号颜色的彩虹高亮(每层嵌套一种颜色),原理就是在扫描时记录每个括号的深度值,然后按深度着色。这个深度信息,恰恰是栈的 size 在每次 push/pop 时的快照。有趣吧?你刷题时的一个
stack.size(),就是编辑器渲染一整个漂亮弧线的数据来源。
6.3 数据序列化格式的解析
JSON 解析器、YAML 解析器、以及各种自定义 DSL 的解析器,都会遇到括号/分隔符嵌套的问题。JSON 里是{}表示对象、[]表示数组;YAML 虽然用缩进表示层级,括号仍然频繁出现。写一个 JSON 解析器的第一步,往往就是先实现一个"括号结构扫描器",确认整个文档的括号配平,才能在后续递归解析时不至于钻进明显非法的数据。
顺便说一个 YAML 解析中特容易出问题的地方:字符串里包含括号。比如desc: "这是一个 ( 中文括号 ) 测试",这时括号出现在字符串内部,不应该参与结构校验。所以真实解析器里括号匹配一定会在"字符串状态"和"结构状态"之间切换,这又回到了前面讲的状态机思路。如果读者以后要手写解析器,我建议先把"状态切换"这个意识刻进脑子里,它会帮你避免大量被字符串内容误导的 bug。
6.4 数学表达式求值与公式编辑器
前面提过 Dijkstra 双栈法。实际在做公式编辑器的时候,用户输入的公式会转成一棵抽象语法树(AST),括号在语法树里对应着不同的结合方式。比如a*(b+c)和a*b+c是两个不同的树,决定括号是不是必要的其实不是配对,而是运算符优先级。但在你构建 AST 之前,至少需要先确认括号是否合法,这依然是栈的工作。
我在实现一个简易公式计算器时发现一个坑:负数。-3这个输入,如果表达式的语法定义是"一元负号",那么在解析- ( 3 + 2 )时,开头的-和表达式里的-都要作为运算符处理,括号匹配的压栈规则必须和运算符优先级协同。这直接导致我曾经的解析器在处理(-3)时崩溃。后来我给括号匹配加了一个"当前是否期待一个操作数"的上下文标志,才算彻底解决。多说一句:这个坑在"只有加减乘除和括号"的简化模型里不会出现,但一旦把一元负号加进去,你会立刻感受到"上下文"在解析中的重要性。
7. 题后总结与扩展练习建议
括号匹配这道题,看起来短小,但它串联了一整条数据结构与算法的知识链:
- 数据结构上,它是最直观的栈应用,同时可以和队列、双端队列做对比练习。
- 算法设计上,它可以引出 DP(最长有效括号)、区间 DP(最少插入次数)、双向扫描、状态机设计。
- 工程理念上,它教你如何把"嵌套结构"抽象成线性结构去处理,如何用映射表提高扩展性,如何在性能和安全之间取舍。
如果你想顺着这条知识链继续往下走,我推荐按这个顺序刷题:
- LeetCode 20:有效的括号(入门,栈)
- LeetCode 22:括号生成(回溯,理解"合法括号串的结构")
- LeetCode 32:最长有效括号(DP / 栈)
- LeetCode 1249:移除无效括号(栈 + 索引存储)
- LeetCode 301:删除无效括号(BFS / 回溯,进阶)
- LeetCode 227 / 224:基本计算器(括号 + 运算符栈,实战感极强)
刷的时候不要只追求通过,每道题换着语言写一遍,试着把空间复杂度压下去,再把错误定位信息加进去,这样你收获的就不只是"会做这题",而是一整套用栈解决嵌套结构问题的能力。
最后分享一个我在实际项目中形成的小习惯:凡是实现任何"成对符号校验"的功能,我都先在测试用例里固定放上这几条边界用例——空串、单左括号、单右括号、((()))、(()、())、([)]、()[{}]、以及超长嵌套串。这套用例跑通了,基本能覆盖掉我在前文提到的所有常见 bug。这比任何花哨的测试框架都管用,值得你直接抄走。
字符串的嵌套世界,一入栈门深似海。括号匹配是你走进这个世界的第一个码头,但绝不是最后一个。希望这篇详解,能让你把码头的地基打得足够扎实,后面走多远你都会感谢今天的这一趟。