题目
给定一个只包括
'('、
')'、
'{'、
'}'、
'['、
']' 的字符串
s,判断字符串是否有效。
有效需满足:左括号必须用
相同类型的右括号闭合,且以
正确的顺序闭合(嵌套不乱)。每个右括号都有一个对应的同类型左括号。
输入: s = "{[()]}" 输出: true
输入: s = "()[]{}" 输出: true
输入: s = "(]" 输出: false (类型不匹配)
输入: s = "([)]" 输出: false (嵌套交错)
输入: s = "(()" 输出: false (左括号没闭合)
腾讯面试 TOP 8 常客,也是"栈"这个数据结构最经典的入门题。
一、核心洞察:栈是被结构逼出来的
这题有意思的地方是:
你不需要"想到用栈"—— 括号的结构自己就把栈推到你面前了。
想一想为什么
"(]" 无效而
"{[()]}" 有效?因为每个右括号匹配的是
离它最近的、尚未闭合的同类左括号。换句话说:
最内层尚未闭合的左括号,必须最先被闭合 —— 后开先闭,这就是 LIFO。
"记下还没闭合的左括号,来一个右括号就看最近记下的那个" —— 这话翻译成数据结构就是栈。
所以解法读出来几乎是白话:
遇左括号,入栈(记下"我在等什么");遇右括号,去问栈顶"你是不是我等的那个";扫完栈必须为空。
def isValid(s: str) -> bool:
pairs = {'(': ')', '[': ']', '{': '}'}
stack = []
for ch in s:
if ch in pairs: # 左括号:入栈
stack.append(ch)
else: # 右括号:问栈顶
if not stack: # 失败模式二:没人接
return False
if pairs[stack.pop()] != ch: # 失败模式一:类型不匹配
return False
return not stack # 失败模式三:栈里还留着
九行。但九行里有
三处独立的有效性检查,漏一处就错一种输入 —— 这才是本题的考点(第二节展开)。
另一个实现取舍:栈里可以
存左括号(弹出时查映射表),也可以
直接存期待的右括号(遇左括号
push(pairs[ch]),遇右括号
pop() == ch 即可)。后者少一张弹出时的查表,面试提一句是加分项。
二、拆解:三种失败模式,三处检查
"有效"不是一句话,是
三个否定条件的合取。每条对应代码里一处检查,漏一处就错一类输入:
模式一是"配错了",模式二是"多出来了",模式三是"没配完"。
三者互相独立 —— 任何一条输入只会挂在其中一条上,但也可能三条全不触发才算有效。
两个高频追问的正例:
"(]":) 来的时候栈顶是 ( —— 数量对(一左一右),类型错。有些人以为"每个左括号都有右括号配"就行,这题还要求类型;
"([)]":数量对、类型也都有,但嵌套顺序错 —— [ 在 ( 还开着的时候就把 ) 闭了。栈恰好能查出这种交错:) 来时栈顶是 [,不配。
三、动画怎么看
动画两行:上行是字符串格子(当前字符橙环高亮),下行是栈 ——
从左往右生长,最右端就是栈顶(
top 标记跟着走)。
- push 帧:左括号飞进栈行,淡金底;
- match 帧:当前右括号变绿,栈顶那格弹出去 —— 弹出的格子画在栈顶右侧(带
popped 标记),配对关系一目了然;
- mismatch 帧:当前字符和栈顶一起变红 —— "栈顶在等
"]",来的却是 ")"";
- unmatched 帧:栈行空着,右括号无处可去;
- done 帧:有效则全串变绿、栈行清空;leftover 则栈里剩的左括号标红。
主例
"{[()]}" 八帧讲完:三次入栈、三次配对出栈、栈恰好清空。
四、完整推演表
以
"{[()]}" 为主例(6 个字符):
对照三种失败:
"(]":帧 2,) 来时栈顶 ( 等 ")" —— mismatch,即刻 false;
"())":帧 3,第二个 ) 来时栈已空 —— unmatched,即刻 false;
"(()":全部扫完栈里还剩 ( —— leftover,false。
五、变体:带优先级的括号匹配(原创题)与同族题
5.1 带优先级变体(腾讯原创题的常见形态)
定义:给三种括号定优先级,
( = 1 <
[ = 2 <
{ = 3。在"类型配对正确"之外额外要求:
内层括号的优先级不得低于外层(优先级沿嵌套方向只增不减,不允许"越级往里降")。
(方向可反:有的题定义"外层必须更高级",把比较不等号翻过来即可。
机制不变:栈里多存一个优先级,push 前和栈顶比一次。)
实现上只改两处:
def isValidWithPriority(s: str) -> bool:
pairs = {'(': ')', '[': ']', '{': '}'}
prio = {'(': 1, '[': 2, '{': 3}
stack = [] # 栈里存 (字符, 优先级)
for ch in s:
if ch in pairs:
if stack and prio[ch] < stack[-1][1]: # 内层不得低于外层
return False
stack.append((ch, prio[ch]))
else:
if not stack or pairs[stack[-1][0]] != ch:
return False
stack.pop()
return not stack
栈解法的扩展性就体现在这:
任何"额外约束"都是往栈帧里加一个字段 —— 这是面试官把 LC 20 变着花样问时想看你能不能接得住的地方。
5.2 同族题
家族口诀:
"最近未处理的先处理"就上栈;配对类栈里存"我在等什么",计数类只存余额,下标类连位置一起存。
六、Java 版
public boolean isValid(String s) {
Map<Character, Character> pairs = Map.of('(', ')', '[', ']', '{', '}');
Deque<Character> stack = new ArrayDeque<>();
for (char ch : s.toCharArray()) {
if (pairs.containsKey(ch)) {
stack.push(ch);
} else {
if (stack.isEmpty() || pairs.get(stack.pop()) != ch) {
return false; // 模式二(空栈)与模式一(不匹配)合并成一行
}
}
}
return stack.isEmpty(); // 模式三
}
Java 惯用
ArrayDeque 而不是
Stack(后者继承自
Vector,带锁、接口混杂)。注意合并写法里
stack.pop() 先于比较执行 —— 短路
|| 保证空栈时不会 pop 出异常。
七、边界情况
八、复杂度
- 时间 O(n):每个字符入栈/出栈至多一次。
- 空间 O(n):最坏全是左括号(
"(((("),栈深 n。
九、常见坑
9.1 只查配对,忘了最后"栈非空"
"(()" 每个右括号都配上了,但左括号有剩 —— 扫完必须检查
stack 为空。这是三种失败模式里最容易被漏的一种,因为它的检查在
循环外面。
9.2 遇右括号不查空栈直接 pop
"())" 的第二个
)、单独的
"]":栈已空时
pop() 在 Python 是
IndexError、Java 是异常 —— 但很多人先写了
pairs[stack.pop()] 再补空栈检查,顺序一反就是运行时崩。
空栈检查必须在 pop 之前。
9.3 用两个独立计数器代替栈
count( 和
count) 相等就有效?
"(]" 会上当吗——
( 和
] 根本不是同类,计数器连类型都不分;就算分类型三个计数器,
"([)]" 也查不出来(数量全对)。
计数器只能查"余额",查不了"顺序" —— 顺序恰恰是本题的核心。
9.4 交错配对想当然
"([)]" 数量对、类型齐,很多人直觉判 true。嵌套合法性靠的就是栈顶比较:
) 来时最内层是
[ 不是
(。拿这个例子当自测用例,能一次验完你写的栈逻辑。
9.5 空串的处理
"" 应该返回 true(没有括号需要配对)。循环不进、栈为空、
return not stack 天然给出 true —— 但如果开头写了
if not s: return False(想当然"空即无效")就错了。
9.6 Java 用了 java.util.Stack
能跑,但
Stack 继承自
Vector(同步锁 + 暴露索引接口),现代 Java 用
Deque/
ArrayDeque。面试里这算小的工程素养分。
十、面试怎么答
- 一句话定性:"最内层未闭合的必须先闭合 —— 后开先闭,栈是括号结构直接逼出来的选择。"
- 先报三种失败模式再写代码:"类型不匹配、右括号没人接、左括号没闭合 —— 三处检查一个都不能少,最后一处藏在循环外。"
- 主动给
"([)]":数量、类型都对但嵌套交错 —— 证明你理解栈顶比较查的是顺序。
- 复杂度:O(n) 时间,O(n) 空间(最坏全左括号)。
- 延伸:栈里存期待右括号的写法;带优先级变体(栈帧加字段);同族的计数器版(LC 921)和下标版(LC 32)—— 说明你 know 这道题在题谱里的位置。
结语
有效的括号是把"栈"讲清楚的最好的题:它不靠任何技巧,只靠一个观察 ——
括号的嵌套结构本身就是后进先出的。九行代码里三处检查对应三种失败模式,一条都不能少;而所有括号家族的变体(计数、下标、优先级),都是往这个栈帧里加减字段。下次遇到"最近未处理的先处理",先想想这里。