LeetCode 20 有效的括号:栈匹配算法详解与面试避坑指南
写这道题之前我先说一句有点反常识的话LeetCode 20“有效的括号”虽然被标成 Easy但它在面试里出现的频率比绝大多数 Medium 都高。我见过太多次有人一上来就写“左右括号计数对比”写到最后才发现根本处理不了([)]这种交叉嵌套也见过有人用栈写对了却在空栈判断上漏了一行导致}这个用例直接挂掉。所以这篇文章我打算把这道题彻底拆开从核心思路、代码写法、边界条件、常见翻车点到面试扩展一路讲完争取让新手看完能一次写对让刷过一遍的人也能从中挖出点新东西。这道题本身很朴素给定一个只包含(、)、{、}、[、]的字符串s判断字符串是否有效。有效字符串需要满足左括号必须用相同类型的右括号闭合且左括号必须以正确的顺序闭合。它适合所有准备算法面试的人尤其适合刚入门栈这个数据结构的读者。搞清楚这道题不只是会做一道题而是理解了栈这种“后进先出”结构到底解决的是哪一类问题。1. 题目分析与核心思路为什么答案非栈不可1.1 先看输入输出理解三种错误形态先把题目翻译成人话。给定一个字符串里面只有六种字符成对出现的括号。你要判断三件事类型对不对(必须配)[必须配]{必须配}。顺序对不对([)]虽然四种括号都齐了但]跑到)前面属于交叉嵌套无效。数量对不对(()这种左括号多一个或者())这种右括号多一个都无效。对应到错误形态其实就是三大类错误形态例子说明类型不匹配(]左右括号数量对得上但类型不是一对顺序错误([)]括号数量、类型都对但闭合顺序错乱数量不对(()或())左右括号无法一一配对绝大多数错误解法都是只盯住其中一类没办法同时覆盖这三类。而栈之所以是标准答案是因为它天然把“最近出现的左括号”放在了栈顶正好对应“最后出现的左括号最先被匹配”这个规则。这和嵌套结构、历史回退、函数调用栈的思考方式完全一致。1.2 栈匹配的完整逻辑链条先想一个问题当你从左往右扫描字符串时遇到一个右括号你需要匹配的是哪个左括号答案不是“字符串里出现的第一个左括号”而是“最近一个还没被匹配的左括号”。举个例子{[]}扫描到]时最近未匹配的左括号是[而不是{所以]应该和[配对配完再轮到}和{配对。这个“最近未匹配”的需求直接指向栈的 LIFO 特性。操作也非常简单遇到左括号就压入栈。遇到右括号就从栈顶弹出一个左括号检查类型是否匹配。如果栈顶和当前右括号不匹配直接判定无效。如果扫描过程中遇到右括号但栈是空的说明右括号多了直接判定无效。扫描结束后如果栈里还有左括号说明左括号多了也判定无效。整个过程只需要一趟扫描每个字符最多入栈一次、出栈一次时间复杂度 O(n)空间复杂度最坏情况下 O(n)。1.3 为什么计数器、字符串替换这类思路不行很多新手第一反应是我用三个计数器分别数三种括号不就行了左右数量相等就有效。这个思路死得最惨的地方就是([)]。三个计数器的结果(1 个)1 个[1 个]1 个数量完全对得上于是判定有效。但它是无效字符串因为]在闭合自己的时候[还没轮到闭合(夹在中间顺序彻底错乱。计数器只能解决数量问题无法解决顺序问题。还有人会想用字符串替换不断把()、[]、{}替换成空串最后剩下空串就有效。这思路其实能通过但每轮替换都要扫描整个字符串最多需要 O(n) 轮最坏时间复杂度 O(n^2)。面试官大概率会追问能不能优化到 O(n)到时候还是绕回到栈。这类“暴力思路”适合用来活跃脑回路不适合当最终答案。2. 核心实现栈加哈希表的写法与两个关键分支2.1 经典写法Python、C、JavaScript 三版对照理解了思路代码其实非常简短。这里我直接给三份工程上最常用的实现先看 Pythondef isValid(s: str) - bool: if len(s) % 2 1: return False stack [] mapping {): (, ]: [, }: {} for ch in s: if ch in mapping: # 栈为空说明右括号多了栈顶不匹配说明类型不对 if not stack or stack[-1] ! mapping[ch]: return False stack.pop() else: stack.append(ch) return not stack再看 C 版本class Solution { public: bool isValid(string s) { if (s.size() % 2 1) return false; stackchar st; unordered_mapchar, char mapping { {), (}, {], [}, {}, {} }; for (char ch : s) { if (mapping.count(ch)) { if (st.empty() || st.top() ! mapping[ch]) { return false; } st.pop(); } else { st.push(ch); } } return st.empty(); } };还有 JavaScript 版本用数组模拟栈var isValid function(s) { if (s.length % 2 1) return false; const stack []; const mapping {): (, ]: [, }: {}; for (const ch of s) { if (ch in mapping) { // 这里必须用 stack.length 0 判断栈空 if (stack.length 0 || stack[stack.length - 1] ! mapping[ch]) { return false; } stack.pop(); } else { stack.push(ch); } } return stack.length 0; };三个版本的逻辑完全一致差别只在语言特性。核心点就一个哈希表的 key 是右括号value 是与之匹配的左括号。这样每次遇到右括号直接查表得到“期望的栈顶”和当前栈顶比较即可不需要写一堆if/else判断六种字符组合。2.2 决定成败的两个分支判断这段代码别看短有两个分支写得稍有偏差整个逻辑就对不上。第一个分支if ch in mapping。这里的mapping只存了右括号所以这个判断等价于“当前字符是不是右括号”。如果是右括号走匹配逻辑如果不是右括号按左括号处理压栈。用查表代替写elif ch ) or ch ] ...代码干净得多。新手最容易犯的错是反着写mapping 里 key 存左括号value 存右括号然后判断条件写得绕来绕去最后自己都晕了。第二个分支出栈前的空栈判断。栈为空却遇到右括号说明这个右括号没有对应的左括号比如测试用例}、())。这时候直接返回false不能再往下取栈顶了——很多语言里对空栈取栈顶会直接崩溃或者报错。C 里对空stack调top()是未定义行为Python 里对空列表取[-1]会抛IndexError所以这个判断必须有而且必须放在前面。这两处写对了基本的分就拿到手了。2.3 性能优化不用哈希表匹配还能再快一点哈希表本身是 O(1) 查询但实际工程里哈希运算还是有一点开销的。这道题流量大、递归深、调用频繁时可以再用一个更“抠”的优化把查表改成if/else或者switch或者用一个长度为 128 的数组当哈希表因为字符的 ASCII 码范围是固定的。优化版 Python 思路def isValid(s: str) - bool: if len(s) % 2: return False stack [] for ch in s: if ch ( or ch [ or ch {: stack.append(ch) elif ch ): if not stack or stack.pop() ! (: return False elif ch ]: if not stack or stack.pop() ! [: return False elif ch }: if not stack or stack.pop() ! {: return False return not stack这里stack.pop()直接在比较的同时弹出栈顶少了一行代码逻辑也更紧凑。实测在 LeetCode 上的运行时这种写法通常比查表的版本快一点点。不过说实话这道题的差距在毫秒级别面试时写哈希表版本完全没问题反而更容易讲清楚思路。追求极致的优化通常是在需要高频调用的基础库或竞赛环境里才真正有意义。3. 完整实操过程手推状态变化与测试用例验证3.1 用手动推演看清栈的每一步变化光看代码还是不够我建议你准备一张纸一支笔跟着我手推一遍([)]这个经典反例初始状态栈为空。读入(是左括号入栈。栈变为[ ( ]。读入[是左括号入栈。栈变为[ (, [ ]。读入)是右括号查表得到期望的左括号是(。栈顶是[不等于(返回false。整个过程到第三步就结束了。你不需要继续看]能不能匹配上因为)在闭合自己的时候[夹在中间还没被闭合顺序已经错了。这时候就算后面所有括号都成对出现这个字符串也是无效的。再推一遍正确案例{[]}读入{入栈。栈{读入[入栈。栈{ [读入]栈顶是[匹配弹出。栈{读入}栈顶是{匹配弹出。栈空字符串扫描结束栈为空返回true。可以看出来栈顶永远代表“当前最内层未闭合的左括号”这个状态跟随扫描过程不断更新。手动推演两三遍你会非常直观地理解为什么栈和括号匹配是天生一对。3.2 覆盖全面的测试用例与验证结果拿去 LeetCode 提交前我强烈建议你先在本地把这组用例跑一遍输入预期结果说明true空字符串没有括号需要匹配()true最简单的一对括号()[]{}true三种括号并列(]false类型不匹配([)]false交叉嵌套顺序错误{[]}true正确的嵌套(((false左括号多栈不为空)))false右括号多中途栈空(()false左右数量不一致)(false顺序颠倒右括号先出现特别提醒)(这个用例很多人会漏。它左右括号数量相同但顺序是反的。用计数器会误判成有效用栈第一步扫描到)时栈是空的直接返回false这就是栈方案在“顺序检查”上的天然优势。我在本地跑完所有用例后会把代码原样提交到 LeetCode重点看提交通过率。这道题 Easy 标签下通过率不算特别高很大一部分人不是不会而是没想到应该返回true、(((应该返回false这类边界。3.3 复杂度分析为什么说它是最优解时间上每个字符最多入栈一次、出栈一次整体是线性扫描 O(n)。空间上最坏情况是字符串全部由左括号组成比如(((((((((((所有字符都要入栈空间 O(n)。平均情况远小于 n。可能有朋友会问能不能做到 O(1) 空间如果括号种类只有一种比如只包含(和)那确实可以用一个计数器遇到左括号加一遇到右括号减一任何时刻计数小于 0 或最终计数不为 0就判定无效。但题目给了三种括号单计数器无法同时记录三种类型和它们之间的嵌套关系所以 O(n) 空间已经是这个题目约束下的最优空间复杂度之一。这也就是为什么面试官通常不会追问能不能压到 O(1)他更关心的是你能不能讲清楚为什么不能。4. 常见错误、排查思路与面试扩展4.1 新手最容易踩的四个代码坑这道题代码短但错误率不低我见过的高频翻车点集中在四个地方。第一用计数器而不是栈。前面已经详细讲过计数器解决不了([)]。这里就不再重复。第二忘记了最后检查栈是否为空。常见错误写法是这样的for ch in s: ... if 匹配: stack.pop() else: stack.append(ch) return True # 错误没有检查栈是否为空如果输入是(((循环能正常走完代码会返回true。正确做法是return not stack或者return len(stack) 0。第三对空栈调用top()或stack[-1]。输入}时第一步就遇到右括号此时栈里什么也没有。如果不先判断not stackC 里st.top()是未定义行为Python 里直接IndexError整个程序崩溃。这个判断必须写在“取出栈顶之前”。第四把映射表方向写反。有人喜欢存{ (: ) }然后遇到右括号时反过来遍历找 key代码又长又容易出错。建议统一按“右括号为 key左括号为 value”来写遇到右括号直接取期望的左括号一行查表就完成了。4.2 线上问题排查别再靠猜了学会打印状态如果你写完代码在本地调不通我建议不要死死盯着代码逐行看直接加打印把每一轮的“当前字符”“栈内容”“期望栈顶”打出来。比如用 Python 调试时def isValid(s: str) - bool: stack [] mapping {): (, ]: [, }: {} for i, ch in enumerate(s): if ch in mapping: expected mapping[ch] actual stack[-1] if stack else None print(fi{i}, ch{ch}, expected{expected}, actual{actual}) if not stack or stack[-1] ! expected: return False stack.pop() else: stack.append(ch) print(fi{i}, ch{ch}, push, stack{stack}) return not stack这种日志打出来你马上能看到问题出在哪一步。比如([)]运行到第三步日志会清楚显示expected(actual[于是返回 false。人眼对“事后的状态”很迟钝但对“过程中的变化”很敏感打印法是排查这种状态机类逻辑最快的工具。我自己的调试习惯是先用一个真实的复杂用例跑一遍比如({[]})再故意用一个错误用例跑一遍对比两次状态变化差异一眼就能看出来。4.3 从一道题看一类题面试追问与扩展题面试官可不会只问这一道。他很可能换个包装来考同一个模型。这里列几个和括号匹配强相关的变体难度从 Easy 到 Hard 都有只含一种括号的有效性判断可以用计数器这题就是栈方案退化成计数器的特例。LeetCode 22 括号生成要求生成所有有效括号组合本质上是在穷举过程中维护“左括号数量必须不小于右括号数量”这个约束。LeetCode 32 最长有效括号Hard 题要求返回最长有效子串长度。需要结合栈存下标、以及动态规划两套思路。LeetCode 394 字符串解码3[a2[c]]这种嵌套结构也是用栈来处理嵌套的括号层。LeetCode 71 简化路径路径中的..和.可以用栈来做回退和过滤同样是“状态维护 回退”的模型。如果你打算刷 LeetCode 热门 100 题你会发现栈相关的题目往往就是这个套路用栈存“现场”用状态判断决定是弹出还是压入。20 题是这里面最简单、最适合拿来建立栈直觉的入口。4.4 面试中额外加分的小细节代码写对只是及格线面试想拿高分一定要主动讲出几个“别人不一定会提”的点。第一个点是提前剪枝。我看到很多解法没有len(s) % 2 1这行。实际上有效括号字符串的长度一定是偶数遇到奇数长度直接返回 false可以免去一次不必要的遍历。这个细节在代码里只占一行却体现了对问题本质的理解面试官很吃这一套。第二个点是讲清楚“右括号优先匹配栈顶”的数学含义。栈顶永远是当前未闭合最内层的左括号匹配它才能保证“顺序正确”。如果你能顺手举个反例[({})]和[(])对比面试官马上知道你理解了嵌套的本质而不是背代码。第三个点是举场景。括号匹配不只是刷题模型编译器检查代码括号、编辑器自动补全、HTML 标签配对、表达式求值中的括号优先级都在用同一个数据结构。能讲出这些场景说明你具备把算法模型映射到真实问题的能力这在系统设计轮里价值更高。5. 刷题指南一道简单题背后的能力模型5.1 为什么简单题反而值得反复刷LeetCode 20 在热门 100 题里排得很靠前被无数人标记为“通过”又“遗忘”。我个人的看法是简单题的价值不在于“能 AC”而在于“能不能用最短的时间写出最干净的题解”。你可以做个自测不看任何资料计时 5 分钟手写这道题的代码。写完后检查三点第一有没有处理奇数长度第二空栈遇到右括号判断是否放在取栈顶之前第三循环结束有没有返回not stack。三个点都对了说明这道题的边界意识已经内化。任何一个点漏了都不是“粗心”而是对状态机的边界理解还不够。我自己刷题有个习惯简单题追求“一次提交通过”。LeetCode 周赛里经常出现简单题很多人不是不会做而是写了半天在调试白白浪费赛时。把 20 这类题练到 5 分钟内零调试你在周赛 430 那类场景下会明显更从容。5.2 和热门题单、周赛的结合建议如果你在看 LeetCode 热门 100 题或者刷题指南我会建议你按这个顺序来先做 20 题理解栈的入场和出场接着做 155 最小栈每个元素存当前栈内最小值、232 用栈实现队列双栈模拟再做 394 字符串解码、32 最长有效括号。这个递进路径完全围绕“栈 状态维护”展开吃透这五道题你在栈这个主题上的能力基本能覆盖大部分面试题。LeetCode 周赛里的字符串题很多也是这个模型的变体只是加了一层“根据条件动态决定入栈/出栈”的规则。比如带优先级运算符的表达式求值、带通配符的路径化简核心都是先想清楚栈里能存什么、什么时候弹出、什么时候报错。5.3 一个晚上就能完成的刻意练习流程如果你想高效地把这道题吃透我建议按下面这个流程走一遍大约一个半小时15 分钟关掉题解自己手写暴力思路和栈思路各一遍尽量写出能跑的完整代码。15 分钟打开题解区挑三个思路不同的题解看一遍重点看别人是怎么处理边界条件的选一个你认为最优的写法。30 分钟刷三四个变体题。至少把 22、71、394 或 32 做一遍如果时间不够优先 394 字符串解码因为它最贴近实际解析场景。15 分钟不看代码重新手写 20 题计时并检查。这套流程下来你对栈的理解会比“一天刷十道简单题”扎实得多。刷题最忌讳的是“看了就以为自己会了、AC 了就再也不看”简单题尤其要警惕这种幻觉。把一道简单题的边界、变体和业界场景都讲清楚比闷头刷二十道重复题有价值得多。最后再分享一个小技巧写栈相关的题永远在脑子里问自己三个问题——“栈里存的是什么”“什么情况入栈”“什么情况出栈”每次这时我都能发现一个坑看似简单无脑但算得上是我做这类题最常用也最有效的自查清单了。我建议你以后遇到任何括号、嵌套、撤销、回退、路径化简类的问题都先默念一遍这三个问题能少走很多弯路。