ARTICLE DETAIL

资讯详情

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

有效的括号:栈数据结构与算法边界处理全解析

有效的括号:栈数据结构与算法边界处理全解析 最近做技术面试我常在二面里加一道最基础的算法题有效的括号。题目本身短到没有阅读障碍——给定一个只包含(、)、{、}、[、]的字符串判断它是否有效。所谓有效就是每个左括号都要有一个同类型的右括号并且按正确的顺序闭合。很多候选人看到这题都会松一口气觉得这算什么难题三分钟就能写完。但统计下来现场一次跑对的不到一半。不是大家不会写栈而是这道题把字符串处理的边界、数据结构的选择、代码组织的细节全揉在了一起任何一个环节想当然就会翻车。如果你刚开始刷题这道题是最好的栈入门练习如果你在准备面试它是考察边界处理能力的试金石就算你已经写了多年业务代码它也值得重新看一遍——因为括号匹配本质上是很多解析器JSON 解析、HTML 标签校验、表达式求值的最简模型搞懂这一题后面一大类问题都能套同一套思路。这篇文章就把我从这道题里悟到的东西完整写出来包括正确解法、错误解法、边界条件以及怎么把它延伸到更难的题。1. 从面试翻车现场说起为什么这道入门题总在翻车1.1 题目到底在考什么不只是长得配不配很多人把有效的括号理解成左右括号数量相等就行这是第一个误区。LeetCode 第 20 题的原题规则其实包含两条核心约束类型匹配(只能由)闭合[只能由]闭合{只能由}闭合。跨类型闭合是非法行为比如(]。顺序正确括号必须按后出现的左括号先闭合的顺序消除不能交叉嵌套。比如([)]乍一看四种括号数量都成对但它非法因为[还没闭合时内层的(就被)闭合了紧接着]又来关闭[这就是交叉嵌套。这两条规则听起来简单但真正落地成代码时你会发现顺序正确这半句才是题眼。很多错误实现都能过数量相等、类型匹配的简单用例唯独过不了([)]这种交叉嵌套的用例。1.2 三个经典错误理解计数、正则、只记当前左括号我面试时见到的错误解法基本可以归为下面三类你可以对照一下自己是不是也这么想过。错误一只统计括号数量。有的候选人写一个count变量遇到左括号加一遇到右括号减一最后判断count 0。这种思路只在只有一种括号时成立比如只判断小括号()。一旦引入三种类型计数就完全失效第一([)]能统计出左右数量相等但实际非法第二)(这种右括号开头的字符串计数也能归零但显然无效。计数法的根本问题是它丢掉了顺序信息只剩下总量。错误二试图用正则或字符串替换一劳永逸。也见过候选人写replace((), ).replace([], ).replace({}, )的思路先说结论这个思路的正确性其实没问题但性能极差后面我会专门分析。还有人想写一条正则直接匹配所有有效括号串这在理论上是做不到的因为有效括号串不是正则语言它需要递归嵌套能力正则的有限状态机表达能力不够。错误三用一个变量记录当前最近的那个左括号。比如用lastLeft记录最近遇到的左括号然后期待它被匹配。这在嵌套一层时能跑通但遇到并列结构或者跨层闭合就会出错。核心原因是一个变量只能记住一个状态而括号匹配的过程中未闭合的左括号是一摞必须能回溯到上一层。比如[()]处理到)时最近未闭合的是(没问题可处理到]时最近未闭合已经变回[了单变量如果没正确更新马上就会误判。这三个错误看似无关实际上指向同一个本质括号匹配问题的状态是栈式的不是计数式的。你需要的容器必须支持两层操作记录最近状态以及完成匹配后弹出、恢复到上一层。带着这个结论我们来看标准解法。2. 栈解法拆解后进先出为什么恰好匹配括号2.1 洗盘子模型未闭合的括号就是一摞盘子理解栈解法不需要背任何概念。你想象自己在厨房里叠盘子每放上一个左括号就相当于往桌上放一个新盘子每遇到一个右括号必须先从最上面那个盘子开始收。后放上去的盘子一定先收走这就是后进先出。括号匹配的问题结构恰好和叠盘子一模一样最新出现的左括号必须先被闭合然后它下面的左括号才有机会被闭合。如果顺序反了比如最上面放着(你却来了一个]那就说明这串括号交叉嵌套了非法。所以用栈来解这个题不是恰好能用而是问题模型天然就是栈。很多数据结构题看起来难其实是没找到合适的模型一旦你意识到状态是按逆序释放的栈自然就浮出水面。2.2 Java 实现一个让代码更简洁的小技巧先给最常用的 Java 版本。这里我用了一个小技巧遇到左括号时不把左括号本身入栈而是把它的配对右括号入栈。这样遇到右括号时只需要判断栈顶字符是否和当前字符相等避免写一长串if-else或者建映射表。public boolean isValid(String s) { DequeCharacter stack new ArrayDeque(); for (char c : s.toCharArray()) { if (c () { stack.push()); } else if (c [) { stack.push(]); } else if (c {) { stack.push(}); } else { // c 是右括号栈为空说明右括号多了栈顶不是同类型说明交叉 if (stack.isEmpty() || stack.pop() ! c) { return false; } } } // 所有左括号都被匹配栈应该为空 return stack.isEmpty(); }注意这里我用的是DequeCharacter而不是StackCharacter。Stack是 Java 早期遗留的同步类性能差而且不太推荐ArrayDeque是双端队列用来当栈用是标准做法。push头部入栈、pop头部出栈正好符合栈的语义。2.3 匹配型写法与对比Python、JavaScript 也来一份如果你更习惯用哈希表来做映射也可以把左括号和右括号的对应关系显式写出来。下面这个 Python 版本就是这种思路def isValid(s: str) - bool: pairs {): (, ]: [, }: {} stack [] for ch in s: if ch in ([{: # 左括号直接入栈 stack.append(ch) else: # 右括号弹出栈顶并比对 if not stack or stack[-1] ! pairs[ch]: return False stack.pop() return not stackJavaScript 版本也一样只是映射换成一个普通对象const isValid (s) { const map { ): (, ]: [, }: { }; const stack []; for (const ch of s) { if (ch ( || ch [ || ch {) { stack.push(ch); } else { if (!stack.length || stack.pop() ! map[ch]) return false; if (!stack.length ch ! undefined) return false; // 防止空栈实际不需要 } } return !stack.length; };两种写法的核心逻辑一致左括号入栈右括号与栈顶比对。我个人更推荐 2.2 节入栈配对右括号的写法因为它在遇到右括号时不需要查表代码更短逻辑也更直白栈里存放的其实是当前缺失的右括号集合新来的右括号必须正好是缺失的那个。2.4 手动模拟一遍从入栈到出栈的全过程拿一个典型合法串{[()]}来走一遍步骤当前字符动作栈内容底部到顶部1{入栈}}2[入栈]} ]3(入栈)} ] )4)弹出)相等} ]5]弹出]相等}6}弹出}相等空遍历结束栈为空返回true。再看非法串([)]步骤当前字符动作栈内容1(入栈))2[入栈]) ]3)弹出]与)不匹配直接返回false到这里你就能直观感受到为什么栈能检测交叉嵌套)需要的是栈顶的)但栈顶存的是]说明当前的闭合顺序错了。这种一个字符就足以否决整串的提前返回也是这个算法高效的原因之一。复杂度方面整个字符串只遍历一遍每次入栈出栈都是 O(1)所以时间 O(n)最坏情况下全是左括号栈里存 n 个字符空间 O(n)。这个复杂度没什么可优化的空间已经是最优级别。3. 非栈解法实验哪些路能走通哪些看着通却走不通3.1 计数法什么时候有效什么时候必然失效先说清楚一个容易误伤的点如果题目改成只包含小括号(和)那计数法是成立的而且非常简洁def isValidSingleType(s): count 0 for ch in s: if ch (: count 1 else: count - 1 if count 0: return False return count 0这里甚至不需要栈因为只有一种括号时顺序正确等价于任何前缀中左括号数量都不少于右括号数量且总量相等。你还额外处理了右括号先出现的情况count 0提前返回。但三种括号同时出现时数量信息就再也无法表达类型顺序了。给你一个具体例子( [ ) ]去掉空格写成([)]四种括号总数量各自成对计数法会返回true实际答案却是false。这是因为计数法把所有括号混成了一个数而栈解法的核心恰恰在于区分不同类型在栈里的相对位置。你可以这样记忆计数法保留的只有一个数字栈保留的却是一串有序状态后者信息量远大于前者。3.2 字符串替换法思路正确但性能翻车前面提到有人想用连续删除()、[]、{}的方式判断代码长这样def isValidByReplace(s): while () in s or [] in s or {} in s: s s.replace((), ).replace([], ).replace({}, ) return s 这个方法的正确性是有保障的因为有效括号串的任意相邻匹配对都满足某种消除关系反复消除后最终会变成空串。但性能是硬伤每次replace都要扫描一遍全串最坏情况下比如字符串是(((((...)))))这种深度嵌套每次循环只能消掉最内层的一对括号需要 O(n) 轮扫描每轮 O(n)整体 O(n^2)。当字符串长度到 10^4 级别时这个解法会明显超时。有意思的是这个方法在思路上和栈解法是相通的栈解法本质上就是一遍扫描过程中动态消除匹配对而替换法是反复扫描中静态消除。所以它不是不能用而是不够优雅、不够快。如果你的目标只是AC 这道题替换法能跑过大多数弱用例但对性能敏感的场景比如解析器里的括号校验绝对不能这么写。3.3 为什么所有变体最后都绕回栈我还见过一些看起来聪明的写法用数组模拟栈、用快慢指针、扫描时维护最大深度、用递归消除……归根结底它们都逃不开同一个约束必须保存所有未闭合的左括号并且按逆序访问。数组模拟栈本质就是栈只是没用现成的栈类递归消除本质是系统隐式维护了调用栈维护最大深度只是记录了一个数字遇到三种括号时依旧会失效。所以刷这道题重要的不是背一个标准解法而是理解凡是涉及最近未配对状态需要回溯的问题栈往往是那个躲不开的答案。理解了这一点你再看后面那些延伸题目就会觉得它们全是同一个套路的变体。4. 边界条件地狱判空、前缀、后缀与类型混用4.1 空字符串和奇数长度送分还是送命题目的边界约定是空字符串被视为有效。这个规则很关键如果你在代码里没有显式处理依赖于for循环自动跳过最后return stack.isEmpty()会返回true那没问题。但如果你的实现先判断if (s null || s.length() 0) return false;空串就直接被判成无效了。所以面试时一定要先问清楚空串到底算有效还是无效LeetCode 上算有效但真实业务里未必。另外一个实用的小优化如果s.length()是奇数可以直接返回false。因为每个有效括号串都必须由成对的左右括号组成长度必然是偶数。这个剪枝不改变复杂度但能在最坏情况下省掉一半的扫描开销算是边界处理上的加分项。4.2 右括号开头栈空的第一个考验字符串)(或是]]这类右括号开头的输入是初学者最容易运行时崩溃的地方。处理第一个字符)时栈是空的如果代码写的是if (stack.pop() ! c) { return false; }那么pop在空栈上会抛出EmptyStackException或NoSuchElementException而不是安全地返回一个错误结果。正确做法永远是先判断栈是否为空if (stack.isEmpty() || stack.pop() ! c) { return false; }这个isEmpty()判断不仅防异常也承载了语义栈为空时还来一个右括号说明右括号多了整个串不可能是有效的直接返回 false 是唯一正确的选择。4.3 遍历结束但栈不空最容易被漏掉的一行((这种字符串遍历完所有字符后栈里还剩两个)没有匹配答案应该为false。但有些候选人写着写着最后一行直接return true;理由是反正遍历过程没出错。这就是典型的遗漏后缀检查。正确的最后一行必须是判断栈是否为空return stack.isEmpty();。在 Java 的for (char c : s.toCharArray())循环下如果你用了 2.2 节的技巧那么栈为空和所有括号完全闭合是严格等价的这一行就是语义本身。千万别图省事直接返回true(()这种在遍历中完全不会触发异常、却在结尾露馅的例子正是这道题最高频的扣分点。4.4 混用嵌套与字符比较的细节很多人在面试时会把Character对象和char原始类型搞混。如果栈声明为DequeCharacterpop()返回的是Character对象和当前字符cchar原始类型比较时Java 会自动拆箱直接用!没问题。但如果你存的是String就比较麻烦String的相等必须用equals()不能。所以我的建议是这道题用char就够了不需要引入String。嵌套深度方面不用考虑栈溢出吗正常来说ArrayDeque是基于数组的深度太大时可能扩容但不会像递归那样爆调用栈。字符串长度一般不超过几十万栈深度再大也能撑住。真遇到极端长串也可以考虑用数组加手动指针模拟栈省掉扩容开销但一般面试场景不需要这么卷。4.5 一个隐藏很深的细节提前返回 vs 完整遍历有人喜欢把所有逻辑走完、最后统一判断有人偏好遇到非法就立刻return false。两种风格都可以但提前返回有一个附带好处比如([)]这种串在处理到第 3 个字符时就已经能确定非法了不需要再看后面字符。省下的时间虽然不多但代码语义更清晰一旦匹配失败结果就已经定了后面不可能再翻盘。这也是我在 2.2 节代码里直接用return false的原因。5. 从一道题吃透一类题括号题的延伸套路5.1 最长有效括号括号匹配的区间版本LeetCode 第 32 题最长有效括号是这道题最经典的升级版。它不再是判断是否有效而是要在给定串里找到最长的连续有效子串长度。这时依然用栈但栈里存的不是括号字符而是下标。核心思路先把-1入栈作为哨兵下标。遍历时遇到左括号就 push 下标遇到右括号先 pop如果栈空就把当前下标 push 进去否则用当前下标 - 栈顶下标得到当前有效区间的长度不断更新最大值。因为栈里存了下标你不仅能判断合法性还能算出合法区间的跨度。这个技巧在匹配并统计长度类问题里非常通用比如字符串里连续配对的ab、分隔符区间统计等都能复用。5.2 括号生成有效性检查变成回溯剪枝条件LeetCode 第 22 题括号生成要求生成所有合法括号组合。解法是回溯但它的合法性剪枝条件和本道题一脉相承生成过程中任意前缀的左括号数量必须不小于右括号数量。这其实就是有效括号定义中的右括号不能先于左括号出现这个条件的动态版本。你看这道题和生成题完全是从同一个性质出发的两个方向一个在判断给定串是否满足性质一个在构造满足性质的所有串。如果你在做题时能把这两题放在一起对比就会发现栈解法中那个isEmpty()检查对应到生成问题里就是剩余右括号不能超过剩余左括号。底层逻辑是一模一样的。5.3 HTML 标签校验括号匹配的现实世界应用很多人刷完这题觉得它只存在于 OJ 里其实括号匹配在真实工程里到处都有。最典型的就是 HTML/XML 标签校验开始标签相当于左括号结束标签相当于右括号栈里存的是标签名而不是字符。遇到div入栈div遇到/div弹出栈顶比对是否相等不相等就是标签闭合错误。这几乎是把有效的括号原封不动搬到了解析器里只不过左右括号换成了带名字的标签。类似的还有 JSON 解析器里的花括号匹配、编译器词法分析阶段的括号配对检查、编辑器里高亮未闭合括号的功能。你平时用的 IDE 能正确提示你第几行少了一个}底层靠的就是一个小小栈。所以别小看这道题它是很多解析功能的最小可运行原型。5.4 思维迁移任何成对结构都能用这套框架把括号题抽象一下你会发现一个通用模式有一个元素开启一个作用域另一个元素关闭这个作用域并且作用域可以嵌套。这个模式的应用范围远超括号函数调用栈进入函数入栈返回时出栈文件夹路径打开子目录入栈回到上级目录出栈撤销操作的层级管理每步操作入栈撤销时弹出。一旦你掌握了栈记录未闭合状态、匹配时弹出并回溯这套思考方式面对很多看似新颖的题目其实都能迅速意识到哦这不就是个带花式的括号匹配吗。写到这里我想起自己刚开始刷题那会儿第一次做有效的括号也踩过isEmpty()漏写的坑也写过计数法然后对着([)]一脸懵。后来慢慢总结出一个习惯每次写代码前先把所有极端输入列出来——空串、单字符、右括号开头、左括号结尾、交叉嵌套——然后问自己我的解法在每种输入下会走什么路径。这个方法帮我避免了一大半的算法题翻车。如果你现在正准备面试或者刚开始刷题不妨也试试不要急着写代码先想清楚状态模型再动手。有效的括号只是起点但它教给你的东西可以延伸到很多更难的题上去。
返回列表