☰
有效的括号:栈数据结构与算法边界处理全解析
2026/10/6 8:34:42 网站建设 项目流程

最近做技术面试,我常在二面里加一道最基础的算法题:有效的括号。题目本身短到没有阅读障碍——给定一个只包含'('、')'、'{'、'}'、'['、']'的字符串,判断它是否有效。所谓"有效",就是每个左括号都要有一个同类型的右括号,并且按正确的顺序闭合。很多候选人看到这题都会松一口气,觉得这算什么难题,三分钟就能写完。但统计下来,现场一次跑对的不到一半。不是大家不会写栈,而是这道题把字符串处理的边界、数据结构的选择、代码组织的细节全揉在了一起,任何一个环节想当然,就会翻车。

如果你刚开始刷题,这道题是最好的栈入门练习;如果你在准备面试,它是考察边界处理能力的试金石;就算你已经写了多年业务代码,它也值得重新看一遍——因为括号匹配本质上是很多解析器(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) { Deque<Character> 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(); }

注意这里我用的是Deque<Character>而不是Stack<Character>。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 stack

JavaScript 版本也一样,只是映射换成一个普通对象:

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原始类型搞混。如果栈声明为Deque<Character>,pop()返回的是Character对象,和当前字符c(char原始类型)比较时,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()漏写的坑,也写过计数法然后对着"([)]"一脸懵。后来慢慢总结出一个习惯:每次写代码前,先把所有极端输入列出来——空串、单字符、右括号开头、左括号结尾、交叉嵌套——然后问自己,我的解法在每种输入下会走什么路径。这个方法帮我避免了一大半的算法题翻车。如果你现在正准备面试,或者刚开始刷题,不妨也试试:不要急着写代码,先想清楚状态模型,再动手。有效的括号只是起点,但它教给你的东西,可以延伸到很多更难的题上去。

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

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

立即咨询