资讯详情

资讯详情

LeetCode 20 有效的括号:栈与哈希表如何优雅解决括号匹配问题

LeetCode 20题“有效的括号”是我刷题这些年见过的最能体现“数据结构选型”价值的入门题。它给你一个只含()[]{}六种字符的字符串让你判断括号是否有效。题目标着 Easy但背后的栈模型、哈希映射、边界处理几乎能串起一整片面试题。很多新手一上来就用计数器计数遇到多类型括号直接翻车也有人用栈但忘了检查栈空一跑用例就崩。这篇文章我打算把这道题拆开揉碎从题目的本质、三种主流解法到我不小心踩过的坑再到它怎么延伸到最长有效括号、括号生成、简化路径这些经典变体。无论你是刚刷 LeetCode 的新手还是准备面试想巩固基础的开发者这篇都值得看完。1. 题目解析这道题到底在考什么1.1 题目描述与本质拆解原题要求很简单输入一个只包含(、)、{、}、[、]的字符串判断字符串是否有效。有效字符串需满足两条规则左括号必须用相同类型的右括号闭合左括号必须以正确的顺序闭合。说白了就是检查括号是否“一一对应、层层嵌套”。你可以把它想象成一摞盘子先放进去的盘子在最底下后放进去的在最上面要拿只能先拿最上面的。这个“先入后出”的规则和栈Stack的特性完全一致。当字符串是{[()]}最内层的()最先闭合然后是[]最后是{}一层套一层全部匹配而当字符串是([)]时(期待的是)结果却出现了]这就违反了嵌套顺序无效。所以这道题表面上是字符串处理本质是最近匹配问题——当前遇到的右括号必须和“最近的未匹配左括号”配对。要高效地跟踪“最近的未匹配左括号”栈是天然的、也是最自然的选择。1.2 为什么括号匹配能成为经典题这道题在 LeetCode 上是“通过率很高但一遍写对率很低”的代表。我见过不少候选人思路说得很清楚代码一写就暴露问题。它经典的原因有几个数据结构选型直接考察栈的 LIFO 特性没有弯弯绕绕。哈希表配合用映射关系简化左右括号的配对判断考你对字典的熟练程度。边界条件多空字符串、奇数长度、只有左括号、只有右括号、嵌套错位每个都要处理。变体丰富从“判断是否有效”可以扩展到“找出最长有效长度”、“生成所有有效组合”、“移除最少括号使之有效”难度跨度从 Easy 到 Hard。一个简单的括号匹配能把栈、哈希表、边界思维、复杂度分析都串起来这也是它高频出现在面试题库里的根本原因。2. 解法一栈匹配法最优解2.1 核心思路LIFO 与最近匹配栈解法只需要一次遍历规则也很固定遇到左括号(、[、{入栈。遇到右括号)、]、}检查栈顶是否是对应的左括号。如果匹配弹出栈顶如果不匹配或者栈已经空了直接返回 False。遍历结束后栈必须为空才说明所有左括号都有对应的右括号。我拿一个简单的例子走一遍s {[]}。遇到{栈变成[{]。遇到[栈变成[{, []。遇到]栈顶是[匹配弹出栈变成[{]。遇到}栈顶是{匹配弹出栈变成[]。遍历结束栈为空返回 True。整个过程里栈顶永远维护着“最近一个还没闭合的左括号”完美符合 LIFO 特性。这就是为什么栈是这道题的最优数据结构。2.2 完整代码与实现细节def isValid(s: str) - bool: n len(s) if n % 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几个细节我特别说一下字典方向我用右括号作为 key左括号作为 value。这样在遇到右括号时直接查字典拿到它期待匹配的左括号再去和栈顶比较。如果你把字典反着写左括号为 key你会发现判断“当前字符是否是右括号”时还得额外判断代码会绕。提前判奇如果字符串长度是奇数那必然不可能完全配对直接返回 False。这一步虽然简单但能省掉一半输入的最坏情况遍历。栈空判断not stack这行不能省。遇到]这种以右括号开头的字符串栈是空的如果直接访问stack[-1]会报 IndexError。先判断栈空再访问栈顶是写栈相关代码的基本修养。2.3 复杂度分析和容易被忽略的剪枝时间复杂度是 O(n)每个字符最多入栈一次、出栈一次整体是线性扫描。空间复杂度是 O(n)最坏情况下字符串全是左括号如((((((栈里要存 n/2 个字符。这里有一个大多数题解不会提到的剪枝遍历过程中一旦发现栈的剩余容量已经不可能匹配完可以提前返回 False。具体来说当len(stack) n - i时说明即使剩下的字符全部是栈中左括号对应的右括号也不够把它们闭合完。for i, ch in enumerate(s): # ... 入栈出栈逻辑 ... if len(stack) n - i: return False这个剪枝不改变渐进复杂度但在极端输入比如前半段全部左括号、后半段全部右括号但类型不匹配能提前退出。实际工程里这种优化意义不大不过面试时提一句“这里还可以根据剩余字符数剪枝”会显得你思考过边界问题。3. 解法二计数法与替换法非主流的备选思路3.1 计数法单类型括号的 O(1) 空间解法如果题目把条件改成“只包含一种括号()”那么这题可以做到 O(1) 空间。思路是维护一个计数器遇到(计数加一。遇到)计数减一如果计数变成负数说明右括号多于左括号返回 False。遍历结束后计数为 0 才有效。def isValidSingle(s: str) - bool: count 0 for ch in s: if ch (: count 1 else: count - 1 if count 0: return False return count 0计数法的本质是只关注“左右括号数量是否平衡且前缀中右括号不超过左括号”。它确实能把空间压到 O(1)但一旦括号类型增多就失效了。比如([)]用三个计数器分别统计()、[]、{}的数量你会发现总数是平衡的但顺序是错的——这正好说明括号匹配不仅要数量对还要顺序对。顺序问题必须借助栈这种能记录“先后关系”的结构。所以我在实际刷题时遇到多类型括号一律栈解计数法只当成一种思维拓展。3.2 替换法思路直观但效率堪忧还有一种思路很“暴力美学”不断把字符串里的()、[]、{}替换成空串直到没有可替换的子串为止。如果最后字符串变成空串说明有效。def isValidByReplace(s: str) - bool: while () in s or [] in s or {} in s: s s.replace((), ).replace([], ).replace({}, ) return s 这个方法思路非常直观匹配的过程就是不断地“消除”成对的括号。有点像消消乐最内层先消掉外层再消掉。但性能很差最坏情况下每一轮只消掉一对字符串长度变化是 n, n-2, n-4, ...字符串查找和替换本身又要扫描整体复杂度能到 O(n²)。我最初学栈的时候反而觉得替换法更好理解“消除”这个概念。面试中如果你先说替换法再说“不过它效率不高我用栈重写一遍”展示的是你懂得比较和取舍是加分项。4. 细节打磨从能跑到跑得好4.1 边界条件一网打尽括号匹配的边界条件看着多其实都是围绕“栈空”和“栈不空”两个状态展开的。我把常见输入和预期结果整理成一张表输入字符串预期结果原因True空字符串没有不匹配的括号(False最后栈里剩一个左括号)False右括号遇到空栈()True标准匹配()[]{}True三种括号类型各自成对(]False类型不匹配([)]False类型匹配但顺序错误{[]}True嵌套匹配((()))True多层嵌套((())False奇数长度提前返回 False你在写代码时只要保证两点遇到右括号时检查栈空、遍历结束检查栈空这八成的边界情况就已经覆盖了。剩下的一半靠奇数长度剪枝一半靠字典映射。4.2 性能优化的小心思虽然 LeetCode 上这道题只要 O(n) 就能过但“能过”和“跑得快”是两回事。我试过几种优化用字节数组遍历Python 里直接for ch in s:已经很快但如果你用for i in range(len(s)): ch s[i]反而慢。字符串迭代器是 C 层实现的不要手动索引。用 switch / if-else 替代字典字典查找是散列操作虽然 O(1)但常数不小。对于只有 3 对括号的映射直接 if/elif 比较有时比字典快。不过这种优化在 Python 里收益有限在 Java/C 里稍微明显一点。避免重复调用stack[-1]访问栈顶在 Python 里是 O(1)但not stack和len(stack)也是 O(1)。真正的性能瓶颈不在这别过度优化。我的建议是先保证逻辑清晰再考虑微优化。面试场景里能两分钟写对、讲清楚复杂度比跑出最优常数重要得多。4.3 多语言实现差异同样一个思路不同语言的栈写法差异不小这里做个对比语言栈实现注意点Pythonlistappend()/pop()最自然性能好JavaArrayDeque避免用StackStack继承自Vector有同步开销已不推荐Cstd::stackchar底层默认deque足够Go[]rune切片模拟入栈append出栈s[:len(s)-1]JavaScript数组push()/pop()注意字符比较用严格等于用 Java 的ArrayDeque时有个细节Deque接口的push()/pop()是栈方法addFirst()/removeFirst()也等价别混用导致逻辑混乱。Go 里我一般用stack : make([]byte, 0, len(s)/2)预分配容量能省不少内存分配。栈这种结构在绝大多数语言里都不需要额外引入库用数组模拟就够了。如果面试官问你“为什么不用递归”你可以说“递归本质也是栈但显式栈避免了函数调用的开销和递归深度限制”。5. 常见错误与排查技巧实录5.1 常见错误速查表这题我在 LeetCode 评论区看到过大量反面案例自己也犯过其中几个。整理成速查表错误类型错误写法正确写法原因忽略栈空if stack[-1] ! pairs[ch]if not stack or stack[-1] ! pairs[ch]栈空时访问stack[-1]直接越界字典方向写反pairs {( : )}pairs {) : (}遇到右括号时需要快速找到它期待的左括号只判断类型不弹出if stack[-1] pairs[ch]: continuestack.pop()不弹出会导致栈永远非空最终返回 False漏掉末态检查return Truereturn not stack只遍历不检查栈空(((会误判为 True用计数器处理多类型用三个计数变量用栈计数无法保证顺序正确在遍历中修改字符串边遍历边 replace一次性替换或直接用栈遍历时修改字符串会导致索引错位或死循环第五个错最有代表性。很多新手看到 “()” 和 “[]” 各自成对就想到分别计数却忽略了([)]这个反例。这再次验证匹配问题的核心是顺序顺序问题必须用栈。5.2 调试经验分享我自己调试这类问题有个习惯在纸上或者注释里画出栈的状态变化。比如遇到{[()]}我边遍历边写出每步栈的内容一旦某个右括号和栈顶不匹配立刻就能定位到是入栈顺序错了还是字典映射错了。还有一个排查技巧把字典单独打印出来检查。我有一次手滑把键值对写成了{): ]}导致()都匹配不上排查了半天才发现是字典写错了。这种低级错误用一个小测试用例就能暴出来assert isValid(()) True assert isValid(()[]{}) True assert isValid((]) False assert isValid(([)]) False assert isValid({[]}) True把这几个用例固定住每次改完代码跑一遍能省太多时间。实际刷题时我也建议把测试用例保存成一个本地文件改算法的时候反复回归。6. 从面试追问到工程应用这道题的延伸价值6.1 面试官可能追问的点一道 Easy 题面试官通常不会满足于你写出正确答案。我经历过的追问层层递进如果字符串里混合了字母和其他符号怎么处理比如a(b)c。这要根据需求决定是忽略非括号字符还是直接判定无效。栈解法只需要在入栈出栈逻辑上增加一个if判断灵活度很高。如果括号种类扩展到 10 种呢栈字典依然是 O(n)只需要扩展字典。如果要求返回第一个出错的位置呢栈解法可以在遍历时记录下标出错的右括号位置直接返回如果是左括号多余返回最后栈顶括号的下标。能否用递归实现能但递归会消耗系统调用栈深度太大可能爆栈。显式栈是更好的工程方案。这些追问其实都在考察一个能力能不能把一个具体问题抽象成“栈 状态维护”的模型。凡是可以写成“序列 配对 嵌套”的问题基本都能套上这个框架。6.2 真实工程里的括号匹配别以为这题只活在面试里。我在实际开发中遇到过一个模板解析场景需要解析一段带有自定义标记的文本检查标记是否正确闭合。这本质上就是“有效的括号”只不过括号从字符变成了标记字符串。当时我的做法是扫描 token 序列遇到开始标记就入栈遇到结束标记就检查栈顶是否是匹配的开始标记不是就报错并给出行号。这个逻辑和 LeetCode 20 一模一样只是栈里存的从char变成了Token对象。类似的场景还有编译器/解释器的语法分析阶段检查小括号、中括号、大括号配对。编辑器的括号高亮和自动补全实时计算当前光标所处嵌套层级。HTML/XML 解析器里标签的配对校验本质就是带名字的括号匹配。JSON 和代码格式化工具中检查对象大括号是否闭合。所以刷这道题练的不只是“会写一个算法”而是建立一种思维模型值回票价。7. 思路迁移相关题型的举一反三7.1 最长有效括号LeetCode 32题“最长有效括号”是这道题的 Hard 进阶版。它要求找出字符串中最长的有效括号子串长度。核心思路还是栈但栈里存的是下标而不是字符。技巧是栈底始终保留一个“最后一个未匹配右括号的下标”作为参照点遇到右括号时弹出栈顶并计算长度差。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这里的-1初始哨兵配合“栈空时重新入栈”的思路很多新人第一次看会懵。但只要理解了“有效的括号”里栈的语义这个进阶题就容易接受了。7.2 括号生成LeetCode 22题“括号生成”要求生成 n 对括号的所有合法组合。它用的是回溯法但约束条件和括号匹配息息相关生成过程中任意前缀中左括号数量不能小于右括号数量。def generateParenthesis(n: int): res [] def backtrack(s, left, right): if len(s) 2 * n: res.append(s) return if left n: backtrack(s (, left 1, right) if right left: backtrack(s ), left, right 1) backtrack(, 0, 0) return res注意这里的if right left条件其实就是保证了每个前缀都是一个“有效括号序列的前缀”。这也是从“有效的括号”迁移过来的前缀合法性判断。7.3 延长线上的同类题型栈这个思路在 LeetCode 上一串题都能用上题号题目与括号匹配的关联71简化路径用栈处理路径中的目录入栈出栈856括号的分数用栈记录嵌套层级的分数累加921使括号有效的最少添加计数加栈思想统计缺失数1249移除无效的括号栈记录非法下标再统一删除394字符串解码栈存重复次数和已拼接字符串刷完“有效的括号”顺着这个列表往下走你会发现栈这个数据结构在字符串处理里的统治力。每道题都是“维护状态 配对消除”的变体。我个人刷题时的心得是不要满足于把 20 题写对要总结出“栈底哨兵”“右括号触发出栈”“字典统一映射”这些模式再迁移到其他题目。代码写多了你会发现很多 Hard 题就是 Easy 题的叠加。这道题我前前后后写过的版本不下五个从最开始的替换法到后来的栈剪枝每一步都是对“匹配模型”理解的加深。如果你正准备面试建议把它练到“闭着眼睛都能写对”的程度如果你在做工程不妨留意一下那些解析器、匹配器的代码它们底层大概率就是这段思想。刷题的价值不在背答案而在把一个简单的模型用熟、用透。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →