ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

LeetCode 20 有效的括号:栈的应用与边界处理详解

LeetCode 20 有效的括号:栈的应用与边界处理详解 接触过 LeetCode 的同学基本都绕不开“有效的括号”这道题。它排在面试高频题的前排位置题号是 20难度标的是简单但实际面试里翻车的频率完全不亚于中等题。原因很简单这道题考的不只是“会不会用栈”而是你在边界处理、代码组织、异常情况上的基本功。我在国内几家互联网公司都参与过技术面试用这道题筛过不少候选人也在自己刷题和写工具库时反复蹂躏过它。今天把这道题从题意、解法、优化、调试再到面试追问完整地拆一遍力求比题解区那些只给代码的帖子更能带你吃透。1. 题目拆解与核心思路1.1 题目到底在说什么题目本身不长给定一个只包含(、)、{、}、[、]的字符串判断字符串是否有效。有效字符串需要满足左括号必须用相同类型的右括号闭合左括号必须以正确的顺序闭合。翻译成大白话就是两点类型对得上顺序对得上。()[]{}合法(]不合法([)]看起来每个左括号都有关联的右括号但交叉嵌套的顺序不对也不合法。([{}])这种层层嵌套、严格对称的结构才合法。这道题背后其实是一个经典的计算机科学问题括号匹配问题。它最早可以追溯到编译器设计里对表达式的语法检查——编译器要判断你写的表达式里括号是否配对、嵌套是否正确。你做代码编辑器时输入一个(编辑器自动高亮匹配的那个)底层用的也是同一个原理。所以别看题简单它是真实工程问题的一个缩微版本。1.2 为什么第一反应是栈很多初学者看到这道题第一反应是计数数一下左右括号的数量是否相等。这个思路对单一类型的括号来说是对的比如只有()时左括号数量等于右括号数量就有效。但题目里有三种括号还要考虑顺序计数就力不从心了。考虑([)]这个例子左括号数量和右括号数量完全相等但它显然是无效的。问题出在顺序上。这时候需要一种数据结构能记录“最近出现但还没匹配完的左括号”并且在新右括号出现时能快速判断它是否与最近的那个左括号类型匹配。这个需求恰好就是栈的典型应用场景后进先出。栈顶永远是最新压进去的左括号遇到一个右括号时只需要弹出栈顶做类型比对本身就是 O(1) 的操作。生活化类比一下想象你有一叠盘子每次放新盘子都放在最上面取盘子也从最上面拿。左括号入栈就是放盘子右括号匹配就是取盘子。如果最上面的盘子和你想要的不匹配那整个叠放顺序就是错的。这个类比虽然朴素但在面试时用来解释思路非常直观。1.3 这题考的能力模型说句实在话这道题在面试中的定位主要是考察三个维度的基本功。第一个维度是数据结构的选用。给一个需求能不能想到栈以及能不能说清楚为什么是栈而不是队列、数组、哈希表。有些候选人背了题解知道用栈问一句“为什么不能用数组双指针”就卡住了。第二个维度是边界情况的严谨性。字符串是空串怎么办只有左括号怎么办只有右括号怎么办突然出现一个非括号字符怎么办这些都是在写代码前就要在脑子里过一遍的测试用例。第三个维度是代码实现的简洁与健壮。同样是栈解法有人写二十行有人写十行有人写一堆 if有人用哈希表映射风格和可读性差距很大。面试官从这段代码能判断出你的工程习惯。2. 解法演进从暴力匹配到栈的落地2.1 暴力替换法最容易想到但最不可取拿到题目最容易想到的暴力思路是这样的如果字符串里有()、[]、{}这三种连续的合法子串就把它们替换成空字符串。不断重复这个过程直到字符串不再变化最后看字符串是否为空。比如([{}])第一轮替换{}变成([])第二轮替换[]变成()第三轮替换()变成空串最终为空有效。这个思路在直观上完全没问题而且实现起来非常简单只要一个 while 循环加字符串替换。但它的问题也很明显时间复杂度高。每轮替换都需要扫描整个字符串并且一轮往往只能消除一层的括号对于深度为 n 的嵌套需要执行 n 轮整体复杂度是 O(n^2)。在字符串很长或者面试环境要求最优解时这个方案直接出局。不过我在这里想说的是暴力法并非完全没有价值。面试时如果实在没有思路先抛一个能跑的暴力解然后再说“但这不够好我优化一下”至少证明你具备拆解问题的能力。而且暴力法对理解题目本身很有帮助——它明确了“哪些子串是合法的”为栈解法的推导提供了直觉基础。2.2 栈解法标准答案为什么这么写主流且最优的解法就是利用栈加哈希表映射。具体逻辑如下初始化一个空栈遍历字符串的每个字符。如果当前字符是左括号(、[、{就压入栈中如果当前字符是右括号)、]、}则分两种情况处理栈为空说明没有与之匹配的左括号直接返回 false栈不为空弹出栈顶元素检查栈顶左括号是否与当前右括号类型匹配不匹配则返回 false。遍历结束后如果栈为空说明所有左括号都被正确匹配返回 true否则说明有左括号没被匹配返回 false。用哈希表存配对规则是工程上的常见做法建立一个右括号到左括号的映射比如) - (这样在做类型匹配时直接查表省去一堆 if else。代码风格也更清晰。这个解法的时间复杂度是 O(n)只需遍历一次字符串空间复杂度是 O(n)最坏情况下字符串全是左括号所有字符都入栈。这里有必要强调一下很多题解会说复杂度是 O(1) 的栈空间那是只说了一个栈帧的情况实际上栈里元素个数和输入规模相关所以是 O(n)面试时空间复杂度说错了反而是减分项。2.3 计数法为什么单类型和多种类完全不同补充一个快速理解问题的角度。如果题目退化成只判断()这一种括号那么一个计数器就能搞定遇左加一遇右减一任何时刻计数器不能为负最终计数器必须为零。这也是有效括号问题最简单的版本很多入门教程会用这道题来教贪心思想。但一旦引入了多种括号计数器就不再适用因为你需要区分类型匹配的层级关系。[(])在这种计数器逻辑下左右数量相等每一种括号也是相等你算出来的结论是“有效”但它实际上是无效的。有人会想那我用三个计数器分别统计三种括号行不行答案是不行因为类型之间的交叉嵌套顺序无法通过计数器还原。这就是数据结构选型差异带来的本质区别计数器只保留“数量”信息栈保留了“顺序”信息。理解了这一点你才算真正搞懂了为什么这题要用栈。2.4 数组模拟栈面试中的加分技巧除了直接使用语言内置的 Stack 类还有一个常见的实现细节用数组来模拟栈。在 JavaScript 和 Python 里数组的push和pop天然就是栈操作所以很多人直接拿数组当栈用。但在 Java 中官方推荐的Deque接口比Stack类更规范因为Stack继承自Vector有历史包袱方法加了同步锁性能不如ArrayDeque。面试时如果写 Java用ArrayDeque会比用Stack更让面试官认可。这里还要注意一个细节不要用栈存右括号。我见过不少候选人思路是对称的遇到左括号入栈遇到右括号也入栈最后再统一处理。这个思路不是完全不行但会让逻辑变得复杂因为你必须额外记录左右括号的对应关系最终还是要回到匹配逻辑上。正确的做法是只对左括号压栈遇到右括号时主动出栈比对这样代码最干净。3. 完整实现与细节优化3.1 代码实现以 Python 和 JavaScript 为例先看一份标准的 Python 实现代码非常短但每一行都有讲究def isValid(s: str) - bool: # 右括号到左括号的映射表 mapping {): (, ]: [, }: {} stack [] for char in s: # 当前字符是右括号 if char in mapping: # 栈为空说明没有对应的左括号直接失效 # 栈顶元素不匹配也直接失效 if not stack or stack[-1] ! mapping[char]: return False stack.pop() else: # 左括号入栈 stack.append(char) # 栈为空才说明所有括号都已匹配 return not stack再看 JavaScript 版本思路一致用的是 Map 做映射var isValid function(s) { const map new Map([ [), (], [], [], [}, {] ]); const stack []; for (const ch of s) { if (map.has(ch)) { if (stack.length 0 || stack[stack.length - 1] ! map.get(ch)) { return false; } stack.pop(); } else { stack.push(ch); } } return stack.length 0; };两份代码的核心逻辑完全一致都是把“当前字符是否为右括号”作为判断分支的依据。这里的小技巧在于映射表只存右括号到左括号的映射而不是反过来。原因很简单遍历到右括号时才需要做匹配判断左括号只需要入栈。反过来存会导致每次遇到左括号时都要额外查一次表浪费操作。3.2 边界条件检查清单写这道题的时候边界条件决定成败。我在评审代码时会刻意用这些测试用例去试探候选人测试用例预期结果实际测试意图true空串按题目定义是有效的因为没有未闭合的括号(false只有左括号栈不空)false只有右括号栈为空时直接返回 false()true最基础的合法情况([])true嵌套合法([)]false类型交叉顺序错误((()))true多层嵌套深度合法((({}))false括号数量不对称()[]{}true平行结构合法其中这个用例需要特别说明。按照力扣的定义空字符串被认定为有效括号序列因为不存在任何未匹配的括号。但如果你在面试时遇到这道题最好主动和面试官确认一下空字符串的处理方式。有些面试官在口头出题时可能没想那么细你主动确认展示的是对需求边界敏感的职业习惯这一点在面试中是加分项。3.3 时空复杂度分析的正确姿势时间复杂度 O(n)这一点大部分人都能答对因为只需要一次线性扫描。但空间复杂度的回答很多人会掉进坑里。有些分析说“栈最多存储 n 个元素因此空间复杂度 O(n)”这没问题。但有经验的人还会补充一句实际上最坏情况确实是 O(n)比如输入全是左括号((((((最好情况是 O(1)比如输入就是()栈始终只有一层。面试时能说清最好与最坏的区别会显得你真的理解这个算法而不是背答案。另一个值得讲的空间优化思路是可以直接用数组的索引位置来模拟栈的操作即用一个变量top表示栈顶位置数组只做存储。这样在概念上更贴近底层也方便你控制栈容量。但对这道题来说语言内置的栈已经足够没有必要过度设计。3.4 一个容易忽视的代码风格问题现在来说一个我在 code review 中经常看到的问题if char in mapping这个判断在 Python 里每次执行的是哈希查找。但我们的字符串只包含括号字符其实可以更明确地把右括号集合写出来。不过从可维护性角度看直接查映射表反而更好因为你不需要维护两个集合。写代码时有一个原则少一个数据结构就少一份出错的可能。另外很多人在遍历结束后会写if len(stack) 0: return True else: return False其实直接return not stack就够了。这不是炫技而是让代码意图更清晰函数结束时的返回值本身就蕴含着“栈是否为空”的布尔逻辑。当然如果你是团队里风格偏保守的开发者觉得return not stack不够直观我也不反对写完整判断。但作为博主我建议你在自己的项目里尝试一下这种简洁写法习惯了就会发现代码整体清爽很多。4. 常见问题与调试技巧实录4.1 最容易踩的三个坑第一个坑是忘了处理栈非空的情况。有些代码在遇到右括号时只判断栈顶元素是否匹配没有判断栈是否为空结果碰到)这样的输入时直接报 “stack underflow” 或者空指针异常。在调试时这非常隐蔽因为你可能用()测时一切正常一旦输入变成)()程序在第一个字符就崩了。记住一个原则任何出栈操作前必须先确认栈里有元素。第二个坑是错误地只在右括号匹配失败时 return false但忘了最终检查栈是否为空。比如输入(()遍历结束时栈里还剩一个左括号如果直接 return true就得到了错误答案。这个坑比第一个更隐蔽因为它在大多数测试用例下表现正常只在所有括号都匹配但数量不对称时才暴露。我建议在代码写完时立刻用(()这个用例过一遍。第三个坑是混淆了“对称”和“匹配”的概念。比如输入({)}有人以为这像判断回文一样从两端向中间比较就能解决。实际上括号匹配是“就近匹配”不是“对称匹配”。最近出现的左括号必须最先被匹配掉这体现了栈的“后进先出”特性和回文匹配的“先进先出”正好相反。很多候选人在这上面绕不过弯建议用([)]亲手走一遍逻辑感受一下差别。4.2 调试技巧用小规模用例走查逻辑我自己在面试或教学时很喜欢用一个笨但有效的方法手动模拟栈的变化过程。拿()[{}]举例可以写成初始栈[]读到(入栈[ ( ]读到)栈顶(匹配弹出[ ]读到[入栈[ [ ]读到{入栈[ [ { ]读到}栈顶{匹配弹出[ [ ]读到]栈顶[匹配弹出[ ]结束栈为空返回 true把每一步写出来思路会非常清晰。你不需要每次调试都这么干但在面试自我介绍时可以提一句“我会用这种手算方式验证边界用例”面试官往往会有好感因为这表明你有习惯去验证自己代码的正确性而不是写完就跑。4.3 与变种题目的关联最长有效括号、括号生成、表达式求值掌握了基础版“有效的括号”之后一定要知道它在面试题体系中的位置。这个知识点最常见的三个延伸方向如下第一力扣的困难题32. 最长有效括号。它要求在一个只包含左右括号的字符串中找出最长的有效括号子串的长度。这道题依然用栈但栈里存的不是括号本身而是下标。思路是维护一个“最后一个未匹配的右括号位置”作为基准每当遇到匹配成功就计算当前长度。如果你能先把 20 题的栈写法吃透再做 32 题会轻松不少。第二22. 括号生成。数字 n 代表生成括号的对数请你生成所有可能的且有效的括号组合。这是回溯法的经典题目核心思路依然是维护“左括号数量不能超过 n”和“右括号数量不能超过左括号数量”这两个约束。你会发现约束条件的本质和“有效的括号”的判断条件一脉相承。第三表达式求值。在编译原理和很多真实项目中需要解析带括号的四则运算表达式。这时候通常是两个栈一个栈存操作数一个栈存运算符遇到右括号时触发一次子表达式求值。区别就在于遇到右括号时要弹出运算符直到遇到左括号。如果你能把这个逻辑讲清楚面试官会认为你不只是刷了题而是能把知识迁移到真实场景。4.4 面试追问环节该如何应对这道题还有一个很经典的追问如果括号类型扩展到任意多对比如还有和、«和»你的解法需要改多少答案很简单只需要在映射表里加对应的键值对即可算法主体的逻辑完全不用动。这个追问测试的就是你代码的可扩展性。如果你一开始在代码里写死了 if 判断括号类型的逻辑扩展起来就要改好几个地方这就是代码可维护性的反面教材。另一个追问是用例设计。面试官可能会问你除了题目给的示例你还会写哪些测试用例这时候你可以回答性能测试构造一个长度十万的合法嵌套串验证 O(n) 的算法能不能在极短时间出结果随机测试用一个简单的生成器随机生成长度不同、内容随机的括号串和暴力替换法的结果做交叉验证特殊输入null、空串、单字符、超长字符串确保程序不会崩这些回答本身比答案更重要它们展示了你的测试思维和工程意识。高级开发者之间的差距很多时候不在写代码的速度而在于想问题的覆盖面。用这道 20 题去训练自己“考虑边界 设计用例”的习惯收益会远比这道题本身大。5. 延伸思考从算法题到真实工程5.1 在编辑器、编译器中的应用很多人刷完题就扔了觉得“有效的括号”只是面试题和实际工作没什么关系。其实括号匹配思想的应用无处不在。最典型的是代码编辑器的括号高亮功能你在 VS Code 里输入(时编辑器会自动配对高亮对应的)光标移动时也能看到配对的另一个括号。Vim 里的%键可以在括号间跳转实现方式就是从头扫描遇到左括号入栈遇到右括号出栈深入理解这道题。凡是涉及解析的地方几乎都离不开栈。还有 JSON 解析器、HTML 标签匹配、Markdown 解析器。HTML 的标签虽然形式是div和/div本质就是带类型的括号匹配只是类型更多、规则更复杂但核心结构仍然是栈遇到开始标签入栈遇到结束标签出栈比对不对就直接报错。你在浏览器里看到“unclosed tag”的报错提示背后就是这个逻辑。5.2 如何利用这道题训练算法思维我见过太多人刷题时只看题解不看思考过程刷了几百道还是没感觉。拿这道“有效的括号”来说我想给你一个真正能提升思维的训练建议不要急着看题解先自己花十五分钟想能想到什么程度就到什么程度。我就是这样训练自己的最初我想到的是字符串替换法然后卡住了想不到栈的用法。后来看题解先看思路提示不看代码自己重新实现一遍。过两天再把这题翻出来重新做一遍逐渐形成肌肉记忆。这个过程比只看十遍题解都管用。还有一个训练技巧是复杂度敏感度。拿到题目先问自己暴力做法是什么复杂度有没有可能降到 O(n)为什么需要 O(n) 空间能不能只用 O(1) 空间对这道题来说O(1) 空间意味着不能存储 n 个左括号那在只处理一种括号时可以做到但三种括号就不行。这个“能不能”的推演过程比背答案有价值得多。5.3 压在时间复杂度和空间复杂度之间做权衡我在真实项目中写解析器时通常不会直接套用上面这种简单栈而会考虑两种优化方向。第一种是批量预处理。如果输入字符串很长并且你知道某些前缀已经匹配完毕且不影响后续状态可以周期性重置栈。这在流式处理场景特别常见比如从网络请求中分段读取 HTML每处理完一个完整段落就清空栈重新开始。第二种是双端栈。有些场景下左右括号的嵌套深度很高但总长度也很大这时候可以分配一个固定大小的数组当栈用两个指针分别从两端向中间扩展节省一半内存。这些都属于工程层面的优化出现在面试里属于超纲内容但作为经验分享写在博客里我觉得能让读者对算法和工程的关系多一层理解。不过今天这篇聚焦的还是基础版“有效的括号”先把基本功吃透再去理解这些衍生场景会更顺。这也是我一直以来的一个观点算法真正重要的不是算法本身而是你在理解过程中建立起来的结构化思维。这个思维可迁移到业务代码的可读性设计、并发模型的状态管理等方方面面。括号匹配其实就是一次很好的思维训练机会。6. 实操总结与个人体会写到这里关于“有效的括号”这道题我想最后沉淀几个最有价值的体会。第一个是这道题是检验“数据结构选型”能力的试金石。问一百个候选人为什么用栈能答出“因为要匹配最近出现的左括号后进先出”的人比例并不高。多数人只能说“题解这么写的”。差距不在于刷题量而在于是否养成了对着需求反推数据结构的习惯。下次遇到问题时建议先别急着想用什么数据结构先把自己需要什么样的操作特性列出来再倒推选用什么结构。第二个是边界条件往往决定了你是“会写代码”还是“会写正确代码”。空字符串、只有左括号、只有右括号这三个用例能不能第一时间想到基本决定你这道题能不能一次通过。我面试时甚至会故意问候选人“你觉得有没有可能一次就通过所有用例”说“会”的候选人我会跟进问“你测过哪几个边界用例”。能答上来的代码往往确实是稳的。这个细节非常小却很能说明问题。第三个是解法代码的简洁程度反映了你对问题的理解深度。同一个栈解法新手可能会写出一堆 if 嵌套老手几行就收工因为老手会把 “判断右括号” 和 “比对类型” 这两个动作合并成一个哈希查找。看代码基本就知道这个开发者的平均水平。建议你在日常写代码时刻意去消除冗余分支和重复逻辑这是从普通程序员到资深工程师的一条必经之路。最后分享一个我自己的小习惯每学一道算法题我都会写一遍暴力解法、一遍最优解法、再写一个带随机化测试的压力验证脚本然后把代码丢进自己的算法仓库。时间久了这个仓库就是我的“第二大脑”。后来跳槽面试前不需要临时抱佛脚翻翻自己写过的题很快就能进入状态。如果这篇文章能对你有帮助我会很高兴如果它让你养成了“多追问一句为什么”的习惯那就赚得更多了。
返回列表