刷LeetCode Hot100刷到这道“有效的括号”,我第一反应是:“这不就是个栈的入门题吗?”但真把这个题讲透,把边界处理好,让面试官点头,还真不是写个十几行代码就完事的事情。这个题在Hot100里属于那种“看着简单,实则每一条都踩坑”的经典。这篇我就用Java代码把这道题从思路到代码,从边界到面试扩展,完整拆解一遍,顺便把我自己当时刷这题时踩过的坑、面试时被追问过的细节,都写出来。
这道题解决的是“给定一个只包含括号字符的字符串,判断括号是否有效匹配”的问题,核心考点是栈的先进后出特性和对边界条件的敏感度。适合刚开始刷算法题、备战Java后端面试、或者想系统性过Hot100的朋友参考,同时也适合那些想提升代码风格和边界意识的老手快速复盘。
1. 题目与核心考点拆解
1.1 题目原文与基本规则
LeetCode 20题,题目名字叫Valid Parentheses。给你一个字符串s,里面只包含(, ), {, }, [, ]这六种字符,让你判断这个字符串是否是“有效的括号”。
有效括号字符串要满足两条基本规则:一是左括号必须用相同类型的右括号闭合,二是左括号必须以正确的顺序闭合。这两个条件看着平平无奇,但组合起来能玩出很多花样。比如()[]{}是有效的,([)]就是无效的,因为[先被打开,却先被)闭合,顺序错了。
1.2 为什么这道题是Hot100的常青树
Hot100收录这道题,不是因为解法有多难,恰恰是因为它考查的素质非常基础且高频。第一,它考查对栈这种数据结构的掌握程度,Java开发中栈在调用栈、括号解析、表达式求值里无处不在,这道题就是把栈的特性用最直接的方式暴露出来。第二,它考查编码细节,比如栈空时不能出栈、匹配到右括号时栈里必须存在对应的左括号,这些细节一旦写错,代码在部分用例下就会崩。第三,它是许多复杂题目的前置基础,后面的“最长有效括号”“括号生成”“表达式求值”全都会用到这题的思路。
1.3 题目真正的考察点清单
我把这个题目的考察点拉了一个清单,你在自测的时候可以对照着看。
- 栈的LIFO特性是否真正理解,而不是只会背“先进后出”四个字。
- 是否符合用“相反方向”匹配的思路,即右括号要和栈顶的左括号对应,而不是两个同向的括号自己配对。
- 是否处理了“字符串长度是奇数”这个直接返回false的优化点。
- 是否处理了“遇到右括号但栈已经空了”的情况,这个是最容易被漏掉的。
- 是否处理了“遍历完整个栈还不为空”的情况,这也是一大经典漏网之鱼。
- 是否关注了Java中
Stack、Deque、ArrayDeque的性能差异和实现细节。 - 是否考虑过用
Map来存储括号映射关系,或者用switch、if-else来实现匹配。
很多人刷这题的时候,一遍就写对了,但面试时被追问“为什么不用计数器”“如果只有一种括号怎么优化”,就卡住了。所以我下面会展开讲思路的推导过程和扩展问题,不只是贴个能通过的代码。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解法思路与栈的底层逻辑
2.1 用生活场景理解为什么必须用栈
想象一个场景:你是一个仓库管理员,货物是一批一批进场和离场的。每进一个左括号,就相当于往仓库里放一个箱子,每遇到一个右括号,就要从仓库的最上面取一个箱子出来。后放进来的箱子必须先取走,这就是后进先出。如果不能从最上面取,而要从中间掏箱子,那仓库就乱套了。
括号的匹配天然就符合这种嵌套结构。()这种是最简单的情况,({[]})这种是深度嵌套的情况,嵌套就意味着最近的左括号应该最先被匹配上,最远的左括号最后被匹配。这正好和栈的后进先出完全吻合。所以遇到左括号就入栈,遇到右括号就和栈顶元素比对,配对成功就弹出栈顶,最终看栈是否为空。这就是这个题的标准解法。
2.2 为什么不能用计数器来解
很多新手会问:我能不能用三个计数器,分别记录三种括号的数量,遇到左括号加一,遇到右括号减一,最后看计数器是不是都变成0?这个方案对()[]{}这种彼此分离的括号是有效的,对({})也是有效的,因为(计数加一,)计数减一,{加一,}减一,最后归零。
但一旦遇到([)]这种交叉嵌套的字符串,计数器方案就彻底失效了。三个括号的数量都是对的:(,[各一次,),]各一次,数量上完全平衡,但实际字符串是无效的。为什么?因为[在(之后打开,却在)之后才闭合,顺序错乱。计数器只能统计“数量是否匹配”,无法记录“谁先出现、谁后出现”这个顺序信息,所以它天然解决不了这个问题。栈的价值恰恰在于记录顺序,不只是记录数量。
2.3 匹配方向的关键细节:反向映射
这里有一个特别关键的细节:入栈的时候存左括号,遇到右括号的时候去栈顶匹配,这个过程我们比较的是“右括号是否和左括号对应”。但Java代码里我们怎么判断“对应”?
一种做法是,遇到右括号时,从栈顶弹出左括号,然后判断这个左括号是不是和当前右括号是同一对,比如弹出的是(,当前是),就算匹配成功。这需要写多个判断条件。
另一种更优雅的做法是,在入栈的时候往栈里存“对应的右括号”。什么意思?遇到左括号(,我们入栈的是);遇到{,入栈};遇到[,入栈]。这样我们只需要把遇到的每个右括号和栈顶元素直接比较是否相等即可。相等就弹出继续,不相等就说明匹配失败。
这种做法最直观的好处是,比较逻辑从“两个字符是否形成一对”简化成了“两个字符是否相等”。人类看()觉得是一对,但程序需要维护那种映射关系,而如果入栈时就存好反向的右括号,匹配就变成了纯等值比较,逻辑更干净,也不太容易写错。
2.4 复杂度分析为什么是O(n)和O(n)
时间复杂度方面,每个字符最多被处理两次:入栈一次,出栈一次,所以整体时间复杂度是O(n),n就是字符串长度。空间复杂度方面,最坏情况下所有字符都是左括号,比如(((((((,那栈里会累积n/2甚至n个元素,所以空间复杂度是O(n)。如果你在面试时被问到“能不能把空间复杂度降到O(1)”,就需要分情况讨论:如果括号类型只有一种,可以只用一个计数器,空间O(1);但现在有三种括号,顺序关系没法用常量级空间记录,O(1)空间做不到,这是理论上的限制。顺着这个思路说下去,面试官会觉得你不仅会写代码,还对复杂度有真正的理解。
3. Java实现:从最稳写法到最优写法
3.1 第一版:从头到尾的完整实现
先给一个最稳、最直白的写法,这个版本我建议基础薄弱的朋友先吃透,再考虑优化。
java复制public boolean isValid(String s) {
if (s == null || s.length() == 0) {
return true;
}
if (s.length() % 2 == 1) {
return false;
}
Deque<Character> stack = new ArrayDeque<>();
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '(') {
stack.push(')');
} else if (c == '{') {
stack.push('}');
} else if (c == '[') {
stack.push(']');
} else if (stack.isEmpty() || stack.pop() != c) {
return false;
}
}
return stack.isEmpty();
}
这段代码的逻辑是:遇到左括号就压入对应的右括号,遇到右括号时,先判断栈是否为空,为空说明没有与之配对的左括号,直接返回false。不为空就弹出栈顶元素,和当前字符比较,不相等也返回false。遍历完所有字符后,最后再检查栈是否为空,防止((())这种左边多了一个括号的情况。
这里有几个容易困惑的点,我特别说明一下。stack.pop() != c会同时完成“弹出元素”和“比较”两个动作,很多初学者看这行代码有点懵:如果相等,元素已经弹出了,OK继续;如果不相等,提前返回false。其实就等同于先char top = stack.pop(); if (top != c) return false;,只是写法紧凑了一些。
3.2 为什么推荐Deque而不是Stack
Java里有个历史遗留问题,Stack类是Vector的子类,而Vector的所有方法都是同步的,在多线程场景下会带来不必要的性能开销。另外Stack类的设计也偏老,Java官方更推荐用Deque接口来实现栈,比如ArrayDeque。
ArrayDeque是基于数组实现的双端队列,把它当栈来用,操作都在数组的同一端进行,效率很高。它的push()方法是把元素放到栈顶,pop()和peek()分别是弹出栈顶和获取栈顶但不弹出。在LeetCode上,用Deque替代Stack,运行时间通常会有略微提升,而且不会收到“Stack is legacy”这类提醒。如果你在面试中主动说出“我不用Stack,因为它是同步的,性能有损耗,ArrayDeque更合适”,面试官对你的印象会好很多。
3.3 第二版:用HashMap映射来减少重复代码
上面的版本用三个if判断处理左括号,代码还算简洁。但如果你觉得多个if有点啰嗦,可以引入Map。思路是建立一个从“左括号”到“右括号”的映射,遇到左括号时,取对应的右括号入栈,遇到右括号时,弹出栈顶字符比较。
java复制public boolean isValid(String s) {
Map<Character, Character> map = new HashMap<>();
map.put('(', ')');
map.put('{', '}');
map.put('[', ']');
Deque<Character> stack = new ArrayDeque<>();
for (char c : s.toCharArray()) {
if (map.containsKey(c)) {
stack.push(map.get(c));
} else if (stack.isEmpty() || stack.pop() != c) {
return false;
}
}
return stack.isEmpty();
}
这个写法的好处是扩展性强,如果之后括号类型变多了,只需要往map里加键值对就行,不用改动主体逻辑。坏处是每次循环都要查一次HashMap,性能理论上比第一版直接字符比较要慢一些,但在LeetCode的测试数据下,这个差异其实微乎其微。我更建议面试中提到“HashMap可读性更好、可扩展性更强”,然后背第一版或第二版代码,看自己更习惯哪个。
3.4 第三版:减少入栈操作(半优化版)
还有一些人会用这样的思路:遇到左括号时把左括号本身入栈,遇到右括号时弹出栈顶,再判断弹出的左括号和当前右括号是否属于同一对。这个代码的写法如下。
java复制public boolean isValid(String s) {
Deque<Character> stack = new ArrayDeque<>();
for (char c : s.toCharArray()) {
if (c == '(' || c == '{' || c == '[') {
stack.push(c);
} else {
if (stack.isEmpty()) {
return false;
}
char top = stack.pop();
if ((c == ')' && top != '(') || (c == '}' && top != '{') || (c == ']' && top != '[')) {
return false;
}
}
}
return stack.isEmpty();
}
这个版本入栈的元素是左括号本身,出栈之后要判断“当前字符和栈顶是否形成一对”。逻辑也没有问题,但判断条件比反向入栈版本要复杂一些,三个条件写在一起,少看了会容易犯错。相比之下,我还是推荐反向入栈的方案,因为你在写代码的时候不需要反复想(配)还是)配(,只要统一把左括号对应的右括号压进去,后面的比较就是单纯的等值判断。
3.5 Java细节:charAt还是toCharArray
在这道题里有两种遍历字符串的方式,一种是for (int i = 0; i < s.length(); i++)配合s.charAt(i),另一种是for (char c : s.toCharArray())。两者的区别在于toCharArray()会额外创建一个字符数组,然后在数组上遍历,稍微多占一点内存;charAt()是直接通过索引访问字符串内部的字符数组,不产生新对象。
从性能角度看,charAt()略优,但几乎可以忽略不计;从代码简洁度看,toCharArray()的for-each写法更清爽。我个人的习惯是在LeetCode刷题时用charAt(),因为LeetCode有时候内存卡的比较严,能省一点是一点。但在工程代码里,toCharArray()的可读性更好,面试时两种写法都可以,说得出各自的优劣就行。
3.6 极端参数与复杂度再确认
这个题最核心的输入参数就是字符串长度n,范围在LeetCode上是0 <= s.length <= 10^4。空字符串直接返回true,这是题目明确规定的一个测试用例。如果字符串长度是奇数,比如长度是3、5、7,因为括号都是成对出现的,所以一个长度为奇数的括号字符串绝对不可能是有效括号,可以提前返回false,省掉后续遍历。这个优化虽然只节省一点点时间,但在很多提交记录里是加分项,因为它体现出了对问题特征的把握。
复杂度上,我们前面已经确认过是O(n)时间和O(n)空间。具体的n上限虽然只有10^4,但这个题的算法思想完全能扛住更大的规模,比如10^6甚至10^7,只要内存够就可以。
4. 测试用例与边界情况深度测试
4.1 覆盖测试用例表
我刷算法题一定会自己列测试用例,不直接提交。这道题的用例可以这样分梯度覆盖:最简单的单个括号对、嵌套的括号、交错但合法的括号、非法的交叉括号、字符串开头就是右括号、全是左括号、字符串中间有多余括号。下表是我当时自测用的,你可以直接拿去用。
| 输入 | 输出 | 说明 |
|---|---|---|
"" |
true | 空字符串视为有效,但要注意null要单独判断 |
"()" |
true | 最基本的括号对 |
"()[]{}" |
true | 三种括号彼此独立,互不干扰 |
"({[]})" |
true | 深度嵌套,最典型有效场景 |
"([)]" |
false | 数量相等但顺序错乱,最能体现栈的价值 |
"(]" |
false | 左右括号类型不匹配 |
"((())" |
false | 结束后栈还残留左括号,数量不平衡 |
"}()" |
false | 一开始就是右括号,栈是空的 |
"(){}}{" |
false | 中途栈空后遇到右括号,经典坑例 |
4.2 最容易出错的三个边界场景
第一个是"}()"这种字符串,第一个字符就是右括号。此时栈为空,如果代码没有写stack.isEmpty()判断,直接调用stack.pop(),就会抛出NoSuchElementException。这个是运行时错误,不是简单的逻辑错误,必须提前预防。
第二个是"(("这种全是左括号的字符串。遍历完成后栈不为空,如果代码最后忘了return stack.isEmpty()而是直接return true,这个用例就会误判。很多人的第一版代码都是挂在这一条上。
第三个是"()(()"这种左括号和右括号数量不平衡的字符串。前两个字符配对成功,后面又多出一个左括号,最终栈里残留一个元素,正确结果应该是false。这类用例要求你不仅在循环中做匹配,还要在最后检查栈的最终状态。
4.3 为什么空字符串返回true
LeetCode原题明确规定,空字符串被认为是有效括号字符串。这个定义其实和数学上的“空串是任何语言的一个合法字符串”是一致的。过" "这种包含空格或其他普通字符的字符串,本题字符串输入被限定为只包含括号字符,所以你不需要额外处理其他字符。如果在面试时你自己扩展了这个问题,比如字符串里允许出现字母,那遇到字母字符时需要跳过,不参与匹配,这个属于扩展讨论的范畴。
4.4 局部变量与性能优化技巧
在这个题里,可以有一个非常小的性能优化。你可以在循环体里把当前字符存成局部变量,也就是char c = s.charAt(i),然后后面多次用到这个变量,避免反复调用charAt。
另一个经常被提到的优化是:把stack初始化为new ArrayDeque<>(s.length() / 2 + 1),指定初始容量。简单解释就是,栈内最多只会存放左括号,而左括号的数量最多是字符串长度的一半(因为每个有效的括号对需要一左一右两个字符)。如果能提前确认字符串长度是偶数,那栈的容量最多是n/2,初始化时直接分配这么多,可以避免ArrayDeque在添加元素过程中频繁扩容。这个优化在n很小的时候感受不到,但在n很大、提交次数很多的时候,能减少一部分扩容开销。
4.5 别在代码里留下隐藏bug
有这样一个写法,表面看得过去,但实际有隐藏bug:
java复制if (!stack.isEmpty() && stack.pop() != c) {
return false;
}
这个写法在没有左括号可匹配时,不会返回false,而是什么都不做,继续遍历下一个字符。最终如果栈恰好为空,就会错误地返回true。比如输入")",栈为空,跳过判断,遍历结束栈为空,返回true,但")"明显是无效的。这告诉我们要特别注意使用&&和!组合时的短路逻辑,不要自己把判断条件写反了。
5. 面试追问与题目变形,叠buff时刻
5.1 面试官最常追问的四个问题
如果一个候选人能顺利把这道题写出来,面试官通常不会就此打住,还会追问一些变化和深层理解。我遇到过的追问包括下面这些。
第一,“如果字符串中只有一种括号,比如只有小括号,代码可以怎么简化?”这个就简单了,用一个计数器,遇到左括号加一,遇到右括号减一,任何时刻计数器不能为负,最后计数器为0就是有效。这就是空间复杂度O(1)的解法,正好回答前面说的“三种括号时空间复杂度降不到O(1)”。
第二,“如果括号是匹配的,但字符串里混入了其他字符,比如字母和数字,怎么处理?”实际工程场景里经常遇到这种,解析一段代码时括号里可能夹杂着变量名、数字。处理方式是当遇到非括号字符时直接跳过,不参与栈操作。如果是Java字符串解析场景,还要额外考虑转义字符,这些属于工程扩展问题。
第三,“如果要求你在匹配时输出具体的匹配位置,怎么改?”那就是要记录每个左括号在字符串中的索引,和栈配合,栈里存储的就不只是字符,而可以是一个保存字符和索引的小对象。这样在弹出并匹配成功时,就能得到两个配对括号的下标,满足后续需求。
第四,“如果括号类型很多,比如有< >甚至自定义的成对符号,用三种括号版本怎么改?”用HashMap映射的版本扩展起来最方便,因为只需要在map里继续加键值对,核心匹配逻辑一行不用改。这也是为什么我建议理解第二版写法的原因,它的扩展性有非常明显的优势。
5.2 从Hot100延伸出去的兄弟题目
这个题在LeetCode里有一连串关联题目。最直接的兄弟题是“Longest Valid Parentheses”,也就是最长有效括号,难度是Hard。它要求你找出给定字符串中最长的有效括号子串长度,核心思路仍然用栈,但栈里存的是下标,通过下标差值计算长度。做过“有效的括号”之后再去做“最长有效括号”,你的感觉不会太陌生,因为匹配的思想完全一致。
另一个关联题是“Generate Parentheses”,生成括号,通过回溯法生成所有n对括号组成的有效括号组合。这道题更像是栈思想的反向运用——从有效性判断变成了合法性构造。逻辑上有相通之处:生成时保证“右括号数量不超过左括号数量”等等。
还有一个“Remove Invalid Parentheses”,删除无效括号,这个题在栈思想基础上加上了BFS或DFS,难度会明显上升。刷题路径上建议按照“有效的括号 -> 最长有效括号 -> 括号生成”的顺序去逐步递进,每一步都能复用前面的核心思想。
5.3 工程场景:实际编码中的括号匹配
除了面试刷题,括号匹配在真实工程里也有大量应用场景。最典型的是编译器前端,词法分析和语法分析阶段需要处理各种表达式里的括号匹配,识别括号是否平衡是语法检查的基础。其次是一些IDE和编辑器的括号高亮功能,当你的光标靠近一个右括号时,编辑器能自动高亮对应的左括号,原理就是从光标位置向左用栈匹配。还有JSON解析器、配置文件解析器,比如在Java里解析一段外部传入的表达式字符串,都需要做括号合法性的前置校验。
如果你参与过规则引擎或表达式引擎的开发,应该对这种“先校验再执行”的模式很熟悉。先把括号匹配校验通过,再去做语义分析或表达式求值,可以避免很多运行时异常。所以这道Hot100题目并不是“刷完就忘”的算法玩具,而是很多真实系统的基础组件。
5.4 关于算法复杂度的一点延伸理解
时间复杂度和空间复杂度不只是回答面试题的套话,它们直接决定代码能不能扛住真实数据量。假设你的公司要做一个大文件解析器,文件里可能有几十万行代码,每行几千个字符,一个O(n^2)的括号匹配算法会导致执行时间以平方级别爆炸,等一晚上都跑不完。而栈解法是O(n),再大的文件也能在可接受时间内完成。
另外,栈解法虽然空间是O(n),但你还可以进一步优化:如果只关心括号匹配而不需要具体的嵌套深度,可以在算法指导下维护一个“虚拟栈”,也就是用变量模拟栈顶位置,避免真的创建数据结构。这就是用数组模拟栈的做法,在Java里可以用一个char[]数组和一个指针变量top来模拟。代码如下:
java复制public boolean isValid(String s) {
char[] stack = new char[s.length()];
int top = -1;
for (char c : s.toCharArray()) {
if (c == '(') stack[++top] = ')';
else if (c == '{') stack[++top] = '}';
else if (c == '[') stack[++top] = ']';
else if (top == -1 || stack[top--] != c) return false;
}
return top == -1;
}
这种写法减少了对象创建,性能更好,Java代码里用数组模拟栈也是一种很常用的手工优化。
6. 常见问题速查与避坑总结
6.1 常见运行错误对照表
这道题很多人提交失败,基本都是犯同一个类别的错误。我把典型错误、原因和修正方法整理成一张速查表。
| 错误现象 | 根本原因 | 修正方法 |
|---|---|---|
输入)报NoSuchElementException |
遇到右括号时栈为空还调用pop() |
先判断stack.isEmpty(),为空直接返回false |
输入((返回true |
遍历结束后没有检查栈是否为空 | 最终返回stack.isEmpty()而不是return true |
输入([)]返回true |
计数匹配代替了顺序匹配 | 必须用栈记录顺序 |
输入(()返回true |
栈中有残留元素没有检测 | 同第二行,结束时要查栈状态 |
输入({})返回false |
入栈存左括号,出栈时比较条件写反 | 建议用“入栈存对应右括号,出栈直接等值比较” |
输入null报NullPointerException |
没有判空 | 开头加if (s == null) return true 或按需求返回false |
6.2 刷题过程中最容易忽略的细节
刷这个题最容易忽略的细节之一是Java里字符比较的语义。stack.pop() != c这里的!=比较的是两个char的数值,因为是基本类型,所以完全没有问题。但如果哪天你把Deque<Character>改成了Deque<Byte>或者使用包装类型,再用!=比较就可能出错,因为包装类型的!=比较的是引用地址,不是数值。所以写Java算法题时,尽量避免在栈里存包装类型然后直接用!=比较。
另一个细节是字符串遍历的顺序不能乱。我们是从左往右遍历的,意味着最先遇到左括号会先入栈,后被更晚的左括号压在下面。当遇到右括号时,出栈的是最后入栈的左括号对应的右括号,这个顺序保证了“最近打开的最先闭合”的嵌套规则,千万不要用for从右往左遍历,那就变成反向了。
6.3 基于个人经验的几点心得
我刷这道题有一个很深刻的体会:一个算法题拿过来,先不要急着写代码,要先把测试用例想清楚。很多人在LeetCode上反复提交失败,不是算法思路不会,而是压根没想过"([)]"这种用例。有效的括号这个题让我养成了“先列边界用例再动笔”的习惯,这个习惯后来帮我省下了大量的调试时间。
第二个体会是:能用Deque就别用Stack。不只是这个题,在Java的任何算法题中,栈操作我都优先用ArrayDeque。一来它是官方推荐的替代方案,二来它的性能确实好。如果你想在实际生产代码里用到栈结构,这个习惯能直接迁移过去。
第三个体会是:把题做对只是第一步,把题讲清楚才是加分项。如果你去面试,写完代码之后一定要能解释“栈为什么能解决这个问题”“计数法为什么不行”“空栈出栈为什么会崩”“最终为什么要检查栈是否为空”。这四个问题都讲明白了,面试官基本不会再刁难你这个题。后来我也把这个题的讲解思路用在了辅导同学和带新人的过程里,百试百灵。
6.4 再分享一个扩展小技巧
最后分享一个实用小技巧。这个题如果是标准的三括号版本,上面讲的解法已经够用。但如果你遇到的情况是“括号对非常多,比如JSON里的字符串需要忽略转义字符里的括号”,那就要在遍历时先识别转义字符并跳过。举一个具体例子,字符串"abc\\\")"里有一个反斜杠加一个右括号,这个右括号不应该作为括号参与匹配,这时候你就不能只是无脑地判断字符是否是括号,而要维护一个“转义状态”或“是否在字符串内”的状态机。这个思路本质上给括号匹配增加了上下文,很多实际解析器就是这么干的。
如果你能把“有效的括号”从一个纯算法题提升到这个理解层面,你就不只是在刷题,而是在建立真正的工程思维。这也是我在刷Hot100这个题时最大的收获之一。
回头再看这个题,我的建议就一句话:先用最简单最稳定的解法把它跑通,然后花时间把边界场景和复杂度讲清楚,最后带着“这个解法能怎么扩展”的视角去做延伸思考。做好这三步,这道Hot100题目才算真正拿捏到位了。
