☰
LeetCode 20 有效的括号:用栈理解括号匹配与最近匹配
2026/10/5 3:06:49 网站建设 项目流程

如果只能选一道题来理解"栈"这种数据结构,我会选 LeetCode 第 20 题——有效的括号。这道题没有复杂的数学推导,也不需要精巧的二分优化,但它把栈的核心语义展示得淋漓尽致。作为一个刷过几百道题、也在面试现场看过别人写这道题的过来人,我可以很明确地说:这道题值得你反复做三遍。它不仅仅是一个入门级的热身题,更是一把打开栈这一数据结构大门的钥匙。无论你是刚接触算法的新手,还是准备面试的求职者,甚至是写过多年业务代码但想补一补基本功的老开发,都能从这道题里收获一些东西。

括号匹配这个场景我们在写代码时其实经常遇到——编译器的语法检查、编辑器的自动补全、表达式求值里的括号优先级处理,底层都有一套类似"判定括号是否合法"的机制。它能帮你快速理解什么叫"最近匹配",什么叫"后进先出",以及为什么要用栈而不是用简单的计数打天下。这篇文章我会从题目本身出发,把思路拆解、代码实现、边界陷阱和常见错误一次讲透。

1. 一道经典题背后的核心思想

1.1 题目到底在考什么

先还原一下题目原貌:给定一个只包含'('、')'、'['、']'、'{'、'}'六种字符的字符串,判断字符串中的括号是否都是有效闭合的。有效闭合的定义包括两点:左括号必须用相同类型的右括号闭合,并且闭合顺序要正确。空字符串可视为有效。

这个题在面试里出现的频率高得吓人。我统计过自己参与过的技术面试,候选人第一轮手撕代码碰到的题目里,这道题至少占了两成左右。它的定位很有意思:说难不难,但很能反映基本功。有些人上来就写错了思路,有些人写对了但边界条件处理得稀烂,还有些人根本不知道 Java 里应该用ArrayDeque而不是Stack——这些细节往往比 AC 本身更能让面试官看清一个人的水平。

它到底在考什么?说穿了就三个东西:第一,你认不认得栈这个数据结构;第二,你能不能把现实问题抽象成栈的入栈、出栈操作;第三,你的代码能不能处理干净各种边界条件。这三点对应的是数据结构基础、抽象建模能力和代码严谨性,面试官想要的就是这三样。

顺便说一句,这道题也是很多刷题网站和课程安排里的"栈专题"第一题。它就像栈类题目里的"Hello World",你要是能把这道题吃透,后面再去碰单调栈、表达式求值、函数调用栈相关的题,都会顺畅很多。

1.2 括号匹配的本质:最近匹配原则

为什么括号匹配能和栈扯上关系?关键在于括号天然有一个性质:一个右括号要和它左侧最近的那个左括号配对,而不是随便找一个左括号配对。

举个例子,看字符串"([])"。外层左括号'('最先出现,但匹配它的右括号')'反而最后才出现;内层'['次出现,对应的']'却更早出现。这种"越早出现的左括号,越晚被匹配"的规律,恰恰就是后进先出(LIFO)的语义:栈顶永远是最后压入的元素,也就永远是最新、最近的"待匹配项"。

生活里的类比也很好理解:你往桌上一叠盘子,最后放上去的那个盘子,总是你最先要取下来的那个。括号匹配里的嵌套结构,本质上就是这样一叠"待匹配的左括号"。每当遇到一个右括号,你只能从这叠盘子的顶部取一个左括号来配对,不能跳过去取底部的。一旦取了底部那个,上面的顺序就乱套了。

这个"最近匹配"原则是整个题目的灵魂。理解了它,你不仅能写出正确答案,还能跟面试官解释清楚:为什么这道题不能用简单的数量统计来做。这个点后面我会单独展开讲。

2. 为什么栈是这道题的答案

2.1 计数法的致命缺陷

我知道很多人第一眼看到这道题的反应是:"统计一下左右括号的数量,看它们相不相等不就行了?"这个思路在最简单的用例下确实能蒙混过关。比如"()",左括号 1 个,右括号 1 个,相等,通过。又比如"[]{}",三种括号各一对,数量也平衡,看起来也通过了。

但稍微给一点复杂的结构,这个方案立刻露馅。经典反例是"([)]"。这个字符串里,左括号有两个('('和'['),右括号也有两个(')'和']'),数量上完全相等。如果你只做数量统计,会判定它是合法的。但它真的是合法的吗?不是。因为'('应该匹配')','['应该匹配']',而这个字符串里两个右括号把两个左括号"交叉"了:'('的左括号在[和]的外面,可它的右括号')'却落在]的里面。这种交叉嵌套不合任何语言的语法规则。

所以数量相等只是必要条件,远不是充分条件。判断括号是否合法,不仅要看左右数量对得上,还要看配对顺序对得上。计数法把顺序信息完全丢掉了,这是它的致命伤。如果你在面试里提出计数法,面试官大概率会追问一句:"([)]你怎么判断?"这一问就能让你意识到问题所在。

2.2 栈的数据结构特性与匹配过程的映射

现在来看栈是怎么把"最近匹配"翻译成程序的。

算法的核心思路只有四步:

  • 遍历字符串的每个字符;
  • 如果是左括号((、[、{),把它压入栈;
  • 如果是右括号()、]、}),从栈顶取出一个左括号,检查两者是否是同一类型的一对;
  • 如果栈顶元素不是配对的左括号,或者栈里根本没有元素,直接判定不合法;遍历结束后,如果栈不为空,说明有左括号没找到配对,也不合法。

我拿"({[]})"这个合法嵌套的例子走一遍。遍历到'(',入栈,栈变成['('];遍历到'{',入栈,栈变成['(', '{'];遍历到'[',入栈,栈变成['(', '{', '['];遍历到']',是右括号,看栈顶'[',刚好配对,弹栈,栈变回['(', '{'];遍历到'}',看栈顶'{',配对,弹栈,栈变成['('];遍历到')',看栈顶'(',配对,弹栈,栈变成[]。最后栈为空,返回合法。整个过程就像按下一个按钮,逐层剥开嵌套结构。

这套逻辑把"最近匹配"直接转化成了"栈顶匹配"。为什么栈顶就是最近?因为栈顶永远是最新压入的那个左括号,也就是当前所有未匹配左括号中最新出现的那个。右括号要找的恰好就是它。这个映射关系非常自然,没有任何生搬硬套。

2.3 两种常见实现风格对比

实现上有两种主流风格。

第一种:只压左括号。遇到左括号入栈,遇到右括号做匹配判断。这种写法最直白,三种括号的配对关系可以用哈希表存起来,也可以写成switch。第二种:所有括号都压栈,遇到右括号时再把栈顶弹出来比较,如果栈顶也是右括号或者匹配不上就返回False。第二种写法其实更绕,不推荐,因为它把左括号和右括号混在同一个栈里,栈顶的判断逻辑反而变复杂了。

在面试场景里,我强烈推荐第一种写法,并且用哈希表存储配对关系。理由有两个:其一,代码里每个分支的意图非常清晰——if判断是不是右括号,是就匹配,不是就入栈,面试官扫一眼就能看明白;其二,哈希表比一长串if-else更容易维护,后续要扩展新的括号类型也方便。省下来的时间可以用来跟面试官讨论边界情况,这在面试里是加分项。

3. 完整实操:手写一套高效判定

3.1 以 Python 为例的完整实现

先说 Python 版本,这是我在 LeetCode 上反复使用的一版,代码短但五脏俱全。

def isValid(s: str) -> bool: pairs = {')': '(', ']': '[', '}': '{'} stack = [] for char in s: # 当前字符是右括号 if char in pairs: # 栈为空,或栈顶不是配对的左括号 if not stack or stack[-1] != pairs[char]: return False stack.pop() else: # 当前字符是左括号,入栈 stack.append(char) # 栈空说明全部配对成功 return not stack

逐行解释一下。pairs这个字典定义的是配对关系,注意键是右括号,值是左括号,方向别搞反了。遍历时,char in pairs这一句就是判断当前字符是不是右括号,时间复杂度是 O(1),因为字典底层是哈希表。如果是右括号,先看栈空不空,空栈说明这个右括号是个"孤儿",前面没有任何左括号等它,直接返回False;再看栈顶元素是不是它期待的那个左括号,不是也直接返回False。这两步都通过了,才执行pop。

如果是左括号,不管具体是哪种,直接append进栈。最后一行return not stack很经典:栈为空,说明所有左括号都成功配对了,返回True;栈不为空,说明至少有一个左括号被晾在栈里,返回False。这个写法用 Python 的布尔语义把判断压缩成一行,简洁又不容易漏。

3.2 以 Java 为例的完整实现

很多面试是用 Java 考的,所以 Java 版本也必须拿得出手。这里有一个很多新手不知道的坑:不要用java.util.Stack,要用ArrayDeque。

class Solution { public boolean isValid(String s) { Deque<Character> stack = new ArrayDeque<>(); Map<Character, Character> pairs = new HashMap<>() {{ put(')', '('); put(']', '['); put('}', '{'); }}; for (char c : s.toCharArray()) { if (pairs.containsKey(c)) { if (stack.isEmpty() || stack.pop() != pairs.get(c)) { return false; } } else { stack.push(c); } } return stack.isEmpty(); } }

先说为什么不用Stack。Stack是 Java 早期遗留的类,继承自Vector,而Vector的几乎所有方法都加了synchronized锁。在单线程算法题场景里,这个锁只有开销、没有收益。ArrayDeque是双端队列,当栈用的时候性能更好,官方文档也明确建议优先使用。面试时你能说出这个区别,本身就是技术深度的体现。

再看看这段代码里的细节。初始化哈希表时我用了双括号写法,这在面试题里无伤大雅,但要知道它每次会生成一个匿名内部类,正式项目里不推荐。更严格的写法是在构造函数里初始化。核心判断逻辑在stack.isEmpty() || stack.pop() != pairs.get(c)这一行——利用||的短路求值,如果栈为空,就不会执行后面的pop,避免了空栈异常。这在逻辑上和 Python 版本的if not stack or stack[-1] != pairs[char]完全等价。

3.3 关键分支逻辑逐行解读

我自己在指导别人写这道题时,发现最容易出问题的就是那个"匹配"分支的判断顺序。

每次遇到右括号,其实是一次"匹配请求"。这个请求有两个前置条件,缺一不可:

  • 栈里必须有元素。栈为空,说明当前右括号前面没有等待配对的左括号,比如字符串就是")"这种情况;
  • 栈顶元素必须正好是它的另一半。比如当前是')',栈顶必须是'(',而不是'['或'{'。

我见过不少只写了一半判断的代码,比如只判断栈顶元素是否匹配,却不判断栈空:

if stack[-1] != pairs[char]: # 栈空时会 IndexError return False

这种写法在遇到以右括号开头的字符串时会直接抛异常。教训就一句话:先判空,再取值。这一点在 Python 里尤其重要,因为栈空时访问stack[-1]会直接报IndexError,而不是返回一个空值让你好比较。

Java 版本由于||短路求值的存在,把判空和取值写在同一个表达式里天然安全,但我还是建议你在心里明确这一步的逻辑,而不是把它当成一个理所当然的写法。

3.4 复杂度分析

复杂度是面试的必问环节。时间上,每个字符最多入栈一次、出栈一次,所有操作都是常数级别,所以总时间复杂度是 O(n)。空间上,最坏情况是字符串全由左括号组成,比如"(((((",这时候栈里要存 n 个元素,空间复杂度是 O(n)。

这个复杂度结论本身不复杂,但我想多说一句:这道题的线性复杂度并不稀罕,在 LeetCode 32 题"最长有效括号"里,同样的输入可以玩出 O(n) 的 DP 配合栈、O(1) 的双指针计数等花样。所以这道基础题不单是为了 AC,它建立的是你对"栈解决子串匹配类问题"的直觉,后面所有变体都是在这个直觉上做加法。面试时把这段复杂度分析说得有条理,也能展示你的分析框架:最坏情况、平均情况、空间占用,一个一个来。

4. 边界情况与进阶陷阱

4.1 空串与单字符

很多题目喜欢在边界条件上埋坑,这道题也不例外。

空字符串""是合法的。虽然有个别业务场景可能要求非空,但 LeetCode 和绝大多数算法题对空串的默认判定都是true。你可以理解成:没有任何括号需要配对,自然也是"有效闭合"的。我在代码里没有对空串做特殊处理,因为return not stack直接返回True,天然正确。

单个左括号"("不合法。它走到最后一步时栈不为空,被return not stack拦下。单个右括号")"也不合法,它第一轮就会进入右括号分支,发现栈为空,直接返回False。这两个用例是笔试里最容易出错的:有人把遍历逻辑写得复杂无比,却忘了检查"遍历结束后栈是否为空"这步,导致"("被误判为合法。

我还建议你在写完代码后第一时间跑一遍这几个用例:"("、")"、"()"、"(()"、"())"。跑完这五个,边界问题基本能暴露七八成。

4.2 交叉嵌套误区

再回到那个经典的"([)]"。用我们的栈算法跑一遍:'('入栈;'['入栈;遇到')',栈顶是'[',和')'不匹配,直接返回False。整个过程甚至没走完整个字符串。

这正是栈方案的威力:交叉匹配在第一次出现"张冠李戴"时就会被拦截,根本不需要等到最后。而计数法在这个用例上会完全失明。所以我在面试考这道题时,特别喜欢把"([)]"作为追问用例抛出去,看候选人能不能顶住这一问。如果你能主动在代码里展示对这个用例的处理,并且说明为什么它是非法的,面试官对你是会有好感的。

顺便提一个变体:"([])"是合法的,"([)]"是非法的,这两个字符串长得极为相似,差的就是那一层嵌套关系。建议你把这两个用例对照着在代码上跑一跑,直观感受一下"顺序"对括号匹配到底意味着什么。

4.3 栈溢出与性能考量

还有一种写法是用递归来处理括号匹配,递归函数每次处理一个括号对,递归深度等于嵌套深度。如果测试用例里给出一个几千层嵌套的字符串,比如"("重复 5000 次再接 5000 个")",递归方案很容易触发栈溢出。在 Python 里尤其明显,默认递归深度限制大约在 1000 层,稍微大一点就直接RecursionError。

显式栈方案就不会有这个问题。因为栈是分配在堆上的动态结构,不受函数调用栈深度限制,只要内存够,几万层嵌套也能处理。这也解释了为什么算法题里遇到栈相关问题时,优先写显式栈而不是递归:稳定、可控、不依赖语言运行时设置。虽然业务代码里我们经常追求递归的简洁,但在这种"深度可能很大"的场景里,显式循环是更稳妥的选择。

5. 常见错误与调试实录

5.1 经典错误速查表

我把平时见到的各种错误集中整理成一张表,刷题时可以直接对照自检:

易错点典型输入错误后果正确做法
栈为空时直接取栈顶")"抛出索引越界或空栈异常先判空,再取栈顶
遍历结束忘记检查栈空"(()"误把未闭合括号判为合法返回前检查栈是否为空
只统计括号数量,不判断顺序"([)]"交叉括号被误判为合法用栈维护顺序信息
栈顶匹配时只比较"是左括号""(]"不同类型括号混配用哈希表做精确配对
用Stack类实现任意不必要的性能开销用ArrayDeque
误把pairs键值方向写反")("匹配逻辑反了键是右括号,值是左括号

第五行和第六行看似小问题,但实际犯的人不少,特别是从 C++ 转到 Java 的人,习惯了std::stack就顺手写了Stack。算法题虽然不卡那点性能,但这些细节能体现你对语言生态了解多少。

5.2 实用调试技巧

调试这道题,我推荐两个特别实用的方法。

第一,把栈的内容打印出来。在每次入栈和出栈之后print(stack),尤其在处理复杂嵌套用例时,肉眼看一下栈顶的变化,马上能定位是匹配逻辑错了还是弹出时机错了。我曾经在处理三四种括号混合嵌套的用例时,靠打印栈快速发现自己在遇到右括号时把pop放在了比较之前,导致栈顶已经被拿走,自然比较什么都不对。这种 bug 光靠读代码很难抓,打印一次立刻现形。

第二,准备一组"九宫格"测试用例,覆盖所有情况。我自己固定跑这九组:

1. "" → 预期 true(空串合法) 2. "()" → 预期 true(简单配对) 3. "(}" → 预期 false(类型不匹配) 4. "({})" → 预期 true(嵌套合法) 5. "([)]" → 预期 false(交叉非法) 6. "{[]}" → 预期 true(多种嵌套合法) 7. "((()))" → 预期 true(多层嵌套合法) 8. "(()" → 预期 false(左括号剩余) 9. ")(" → 预期 false(右括号开头)

任何实现如果过不了这九组,都不算真正写完。它们涵盖了空串、简单匹配、类型不匹配、嵌套合法、交叉非法、未闭合、顺序错误等所有场景。我还会顺手在本地写一个小的驱动器,把这组用例和我的isValid函数绑在一起跑,省去每次手动输入的麻烦。

5.3 几道延伸题目

学完这道题,有几道题我强烈建议立刻去刷,它们都是"有效括号"的直系后代。

第一道是 LeetCode 22,括号生成。它要求生成所有合法的括号组合,核心是回溯加左右括号数量控制,和栈的关系在于你要理解什么才算一个"合法前缀"。第二道是 LeetCode 32,最长有效括号。难度明显上一个台阶,需要动规或栈,很考验综合运用能力。第三道是 LeetCode 678,有效的括号字符串。它加入了通配符'*',可以用贪心或者双栈解决,思路极其巧妙,能把你的思维从"确定性匹配"拉到"概率性匹配"。

我的建议是先把第 20 题做透,再按 22 → 32 → 678 的顺序挑战。这几道题串下来,你对栈模型的理解会有一个质的飞跃。很多刚开始刷题的人喜欢一个专题只做一道题就走,其实最吃亏——因为同一个数据结构在不同变体里展现出的特性,才是真正需要花时间吸收的东西。

我在实际面试和刷题过程中最深的一点体会是:有效的括号这道题,代码量不到二十行,但它是理解栈的一把钥匙。无数后来让我头疼的题目——单调栈、表达式求值、函数调用栈模型、编译原理里的括号语法分析——追根溯源,都和这题背后的"最近匹配"思想相通。第一次写这道题时,我也犯过"只数左右括号"的错,被"([)]"狠狠教训过之后,才真正理解了为什么括号匹配不只是数量问题。如果你刚开始刷题,我建议你把这题做透,多跑几组边界用例,把栈的 push、pop、判空练成肌肉记忆。之后再遇到嵌套匹配类的问题,你会感谢这一道题打下的底子。

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

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

立即咨询