只要刷过一段时间的算法题,多半都会碰到“有效的括号”这一题。它看起来就是判断三种括号是否匹配,简单到很多人的第一反应是数一数左右括号数量是不是相等。可实际写起来,你会发现事情没有这么简单:([)] 这种字符串里,每个左括号都能找到对应的右括号,数量完全对得上,但它并不“有效”。
这道题在各个在线判题系统里反复出现,绝对不是没有原因。它表面上考的是字符串处理,实际考的是数据结构里一个非常经典的操作——栈。对一个刚进入刷题阶段的程序员来说,它能一次性检验三件事:对栈这种数据结构熟不熟、能不能看懂题目里的隐藏约束、写代码时有没有把边界情况考虑到。即使对已经有几年工程经验的人来说,把这道题当作每天几分钟的代码热身,也是很好的习惯。
这篇内容会围绕“每日一练:有效的括号”展开,我会把题目拆开讲清楚,然后带你从暴力思路走到栈解法,最后给出完整的代码实现、边界测试和常见错误排查。无论你是还没接触过栈的初学者,还是想快速复习相关套路的在职开发者,这篇文章都适合你花十分钟读一遍,再动手写一遍。
1. 题目拆解:先弄懂“有效”这两个字
1.1 题目到底在问什么
这类题目的标准描述一般是这样:给定一个只包含括号字符的字符串,字符范围通常限定为六种,(、)、[、]、{、},要求判断这个字符串中的括号是否有效。
判断是否有效通常看三条规则:
- 左括号必须用相同类型的右括号闭合,也就是说
(必须用)来关,不能用}去关。 - 左括号必须以正确的顺序闭合,这涉及到括号的嵌套方式。
- 每个右括号都有一个对应的左括号,而且顺序不能乱。
只看规则还不够直观,看例子会更清楚。下面几个是常见测试用例:
text复制输入: "()"
输出: true
输入: "()[]{}"
输出: true
输入: "(]"
输出: false
输入: "([)]"
输出: false
输入: "{[]}"
输出: true
() 和 ()[]{} 好理解,括号要么直接相邻,要么整段并列,都属于有效序列。(] 一眼就能看出来类型对不上。真正需要停下来想一想的是 ([)] 和 {[]}。
([)] 里四种括号都出现了,( 有 ) 对应,[ 也有 ] 对应,数量完全齐。但是它是错的,因为从左到右扫描时,( 还没关闭,[ 就先关闭了,接着 ) 才把 ( 关掉。正确写法应该是 {[]} 这种从里向外一层层闭合,还是 ([]) 这种并列相邻的情况。
1.2 常见误区:用计数器为什么会翻车
很多初学者会想,我分别统计三种左括号和右括号出现次数,最后看三个计数是不是分别相等,不也能判断吗?
这种思路对付一半的用例是可行的。像 (() 之类只有一种括号的字符串,数一数确实能发现左右数量不等。但如果括号类型变成三种,数量相同也救不了 ([)] 这种顺序错乱的输入。
更深层的原因是括号匹配本质上是一个层级嵌套问题。([)] 如果把每个左括号想成进入一个房间,那正确的“关门”顺序应该是离得最近的房间先关。计数只能统计“有没有”,完全不知道“先关哪个”和“后关哪个”。递归结构需要用栈这样的后进先出结构来模拟,计数器没有顺序信息,所以它先天不适用。
1.3 暴力解又是什么样
如果真的有人不用栈去硬做,比较自然的想法是:每次找一对相邻的空括号,比如 ()、[]、{},把它删掉之后继续找。整个过程很像消消乐。比如 {[]} 先删掉中间那对 [],字符串变成 {},再删掉剩下的 {},全删干净就说明有效;([)] 里没有任何一对相邻的空括号,直接判定无效。
这个思路叫作“重复消除法”,也确实能解决问题,缺点在于效率不好。每次搜索并删掉一对相邻括号的时间复杂度最坏会到 O(n²),因为每一轮都要重新扫描字符串。在很短的小字符串上它可能毫秒级就跑完,但等到字符串长度变成几万甚至几十万时,会明显吃亏。它可以作为面试中和别人讨论的“脑洞方案”,但实际写代码还是要用更优雅的栈来模拟同样的消除过程。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解法设计:为什么栈是这道题的主角
2.1 用生活化的例子理解栈
栈这种数据结构,你可以把它想象成叠盘子。你永远只能从最上面拿走一个盘子,也永远只能把新盘子放在最上面。这种“后进先出”的顺序,在计算机里非常常用,比如函数调用、浏览器的后退历史、编辑器里的撤销记录,背后都是栈。
括号匹配和叠盘子的逻辑高度一致。每遇到一个左括号,就相当于把一个新盘子放到栈顶;每遇到一个右括号,就应该去检查栈顶的那个盘子,也就是最近一个还没关闭的左括号,跟它是不是同一种类型。如果是,就把那个盘子拿走,继续处理后面的字符;如果不是,说明括号顺序出错了,直接返回失败。
用这个模型再回头看 ([)] :读取到 ( 时,栈里放着 ( ;读取到 [ 时,栈里变成 (、[ ;现在读取到右括号 ) ,按规则要跟栈顶的 [ 对比。左括号 [ 应该配 ],跟 ) 对不上,于是立刻判负。这个判断不需要等到字符串扫完,读到错误位置就能提前结束。
2.2 为什么不是队列,也不是哈希表
前面提到计数器解决不了问题,那有没有人问过为什么非要选栈,而不能用队列?
队列是先进先出,会把最先出现的左括号先拿出来,本质上还是在用顺序死板地对应括号。可括号匹配要求“最近出现的左括号先被关闭”,这和队列的顺序正好相反,所以队列在这道题里完全用不上。
哈希表单独用也不行,它适合做映射,比如给一个左括号快速找到它对应的右括号,但它没有“当前等待关闭的是谁”这种状态信息。实际解法里往往会把哈希表和栈结合起来用:栈负责保存“还没关闭的左括号”,哈希表负责在读取到右括号时,快速查询这个右括号对应的左括号应该长什么样。两者是各司其职。
2.3 时间复杂度能不能优化得更低
这道题无论如何都得把字符串从左到右看一遍,因为任何位置上的字符都可能影响最终结果,所以 O(n) 是没有办法突破的下限。栈解法的时间复杂度正好是 O(n)。每个字符最多入栈一次、出栈一次,整体是线性。
空间复杂度则取决于最坏情况下栈里会存多少个左括号。比如输入是 (((((((,所有字符都是左括号,它们会一直留在栈里没机会弹出,所以空间复杂度最坏是 O(n),其中 n 是字符串长度。在实际面试或笔试里,能做到 O(n) 时间、O(n) 空间就已经是这个问题的标准最优解了,不需要再强行追求常数空间,因为没有多余信息可以利用。
3. 代码实现与逐步走读
3.1 基于 Python 的完整实现
选 Python 来做标准实现,因为它语法简洁,适合快速验证思路,也方便演示。
python复制def is_valid(s: str) -> bool:
# 长度为奇数时,一定无法一一配对
if len(s) % 2 == 1:
return False
# 用右括号当键,找对应的左括号
pairs = {
')': '(',
']': '[',
'}': '{',
}
stack = []
for ch in s:
if ch in pairs:
# 当前是右括号,需要看栈顶是否匹配
if not stack or stack[-1] != pairs[ch]:
return False
stack.pop()
else:
# 当前是左括号,压栈等待匹配
stack.append(ch)
# 如果栈中还有残留左括号,说明没有被完全关闭
return not stack
这里要特别留意 pairs 字典的定义方式。我把右括号当作 key,左括号当作 value。这么设计的原因是,当扫描到的字符是右括号时,我希望能快速找到“与它配对的左括号长什么样”,然后跟栈顶比较。如果你不喜欢这种写法,也可以把 key 设为左括号,value 设为右括号,然后在判断到左括号时去构造期望的右括号,两种方向都能实现,关键是要保持前后一致,不要自己写乱。
3.2 用具体输入把栈的操作过程走一遍
只看代码可能还没有体感,手动模拟一个例子会清楚许多。选 {[]} 这个正确答案作为例子:
初始化时栈是空列表。
第一个字符是 {,它不在 pairs 里,所以会被加入栈,此时栈是 ['{']。
第二个字符是 [,同样不在 pairs 里,继续压栈,此时栈是 ['{', '[']。
第三个字符是 ],它出现在 pairs 中,所以先去查 pairs[']'] 的值,结果是 '['。再看栈顶,栈顶正好是 '[',比较成功,把栈顶弹出。此时栈是 ['{']。
第四个字符是 },同样的流程,查 pairs['}'] 得到的值是 '{',栈顶也正好是 '{',继续弹出。此时栈变成空列表。
循环结束,返回值是 not stack,结果等价于 not [],也就是 True。输入确实是有效括号。
再看一个失败的例子 ([)] 的过程。
前两个字符 ( 和 [ 都属于左括号,依次压栈,栈变成 ['(', '[']。第三个字符 ) 是右括号,查 pairs[')'] 得到的期望左括号是 '('。但当前栈顶是 '[',两者不相等,程序立刻返回 False,不需要再继续扫描后面的内容。
从模拟中可以看出来,真正的判断都是围绕栈顶进行的,这也正是栈“只关心最近一个未关闭左括号”的体现。
3.3 其他语言的移植思路
如果你平时用 JavaScript 多一些,下面这个版本在思路上完全一致:
javascript复制var isValid = function(s) {
if (s.length % 2 === 1) return false;
const map = {
')': '(',
']': '[',
'}': '{'
};
const stack = [];
for (const ch of s) {
if (map[ch]) {
// 右括号,判断栈顶
if (stack.length === 0 || stack[stack.length - 1] !== map[ch]) {
return false;
}
stack.pop();
} else {
// 左括号
stack.push(ch);
}
}
return stack.length === 0;
};
C++ 版本也不复杂,注意如果用 unordered_map<char, char> 来建立映射,并且在遍历时使用范围循环,写起来会非常接近伪代码:
cpp复制#include <unordered_map>
#include <stack>
#include <string>
bool isValid(std::string s) {
if (s.size() % 2 == 1) return false;
std::unordered_map<char, char> pairs = {
{')', '('},
{']', '['},
{'}', '{'}
};
std::stack<char> st;
for (char ch : s) {
if (pairs.count(ch)) {
if (st.empty() || st.top() != pairs[ch]) return false;
st.pop();
} else {
st.push(ch);
}
}
return st.empty();
}
语言之间区别不大,主要就是取栈顶、弹栈、判空这三个操作的 API 名称不同。
4. 边界用例与实战排错
4.1 三个最常见的翻车现场
这道题写第一版能跑通的同学不少,但能一次把所有边界情况考虑清楚的同学不多。我见过、也踩过的坑主要是下面几个。
第一个坑:读到右括号的时候忘记判断栈是否为空。假设输入是 ")(",扫描第一个字符 ) 的时候,栈还是空列表。如果你这时候直接取 stack[-1] 去和期望左括号比较,会直接抛出异常或者报“下标越界”。正确的逻辑是,只要栈是空的,就说明眼前这个右括号没有对应的左括号在等待,应该立刻返回 False。所以我在代码里写的是 if not stack or stack[-1] != pairs[ch],先判空,再从栈顶取值,两个条件用 or 连着。
第二个坑:循环结束之后忘记检查栈是否为空。如果输入是 "(()",它是三个字符,长度不是奇数,所以开头那个奇数判断拦不住它。扫描过程中,前面的 ( 和第二个 ( 都入栈了,遇到 ) 时弹出一个,最后栈里还剩一个 (。如果代码在循环结束后直接 return True,就会把一个本不该通过的字符串判成有效。正确做法是返回“栈是否为空”,而不是返回一个固定值。
第三个坑:只匹配类型,忽略了“同类型关闭”的顺序含义。实际上很多错误输入都是因为我们没有用字典去关联关闭符号,而是写了一大串 if...else,写多了很容易漏掉某种括号组合。比如把 map[ch] 配置错了,或是看反了左括号和右括号,都可能出问题。这里用字典统一管理的好处是:想要新增一种括号类型,只需要在 pairs 里面加一组键值对,不需要改动其他逻辑。
4.2 可以照抄的一份测试用例表
为了确保代码真正可靠,我建议不要只靠在线判题系统去验证,自己本地跑一小批测试用例会更安心。下面这张表是我常用的回归用例,你可以直接复制到代码里用:
| 输入 | 期望结果 | 设计原因 |
|---|---|---|
() |
true | 最基本的一层包裹 |
()[]{} |
true | 三种括号并列出现 |
(] |
false | 括号类型不匹配 |
([)] |
false | 括号交错,最经典的陷阱 |
{[]} |
true | 多层嵌套且顺序正确 |
( |
false | 只有左括号,没有右括号 |
) |
false | 栈空时遇到右括号 |
((())) |
true | 连续多层嵌套 |
"()(()){}" |
true | 并列加嵌套混合型 |
"((" |
false | 左括号数量多于右括号 |
如果手边有 Python,可以写一个简单脚本把上面这些用例都执行一遍:
python复制cases = [
("()", True),
("()[]{}", True),
("(]", False),
("([)]", False),
("{[]}", True),
("(", False),
(")", False),
("((()))", True),
("()(()){}", True),
("((", False),
]
for s, expected in cases:
result = is_valid(s)
status = "通过" if result == expected else "失败"
print(f"{s}: {result}, 期望 {expected}, {status}")
运行以后如果所有行都显示“通过”,再交到在线判题系统里,心里会踏实很多。
4.3 顺手再做一点小优化
上面实现里有一个容易忽略的小优化,就是开头对“奇数长度”的判断。左括号和右括号总是一对一出现,所以一个有效括号序列的总长度必定是偶数。如果字符串长度本身就是奇数,那它绝对不可能是有效括号,可以直接返回 False,省掉后面不必要的扫描。
另外,在循环里我使用 if ch in pairs 来判断字符是不是右括号。这种写法依赖字典的键集合 keys() 来判断,在 Python 这种高度优化的字典实现里,哈希查询只需要 O(1) 时间,非常快。如果你不习惯,也可以把判断条件反过来,先判断是不是左括号,逻辑等价,但注意分支里的代码也要相应调整。
5. 跳出这道题:延伸变体和后续训练方向
5.1 从这道题延伸出去的三个经典变体
“有效的括号”能火,不光是因为它难度适中,更是因为它能引出非常多同一家族的问题。自己做题不能只做完这一道就过去,顺手看一看后面的变体,能收获更多。
第一个常见变体是“括号的最大嵌套深度”。题目会给你一个括号字符串,要求计算最大嵌套深度。比如 (1+(2*3)+((8)/4))+1 这类带数字和运算符的字符串,你只需要按同样的栈扫描逻辑,在左括号入栈时记录栈的最大深度即可。这个变体不要求你弹出右括号时做复杂校验,只要统计深度,代码甚至比原题还简单。
第二个常见变体是“生成括号”。前面是给一个字符串判断是否有效,而这个变体是给一个数字 n,要求生成所有由 n 对括号组成的合法组合。这题对栈思路的要求降低了,对递归和回溯能力的要求提高了。你会发现凡是学习回溯,都会先拿括号生成来练手,因为它的递归树相对好画。
第三个变体是“删除无效括号”或“移除最少的括号使字符串有效”。这道题已经可以直接用栈找出哪些位置的多余括号需要删除。因为你在扫描时遇到右括号而栈为空,就说明这个右括号是多余的;等到全部扫描结束,栈中还剩着的左括号也是多余的。整个思路可以无缝迁移到真实场景,比如解析用户输入的数学表达式时,把括号纠正成合法形式。
5.2 为什么“每日一练”适合用它来保持手感
我自己的感受是,这类题的好处不在于它难,而在于它有固定的思维套路,很适合作为每天热身的小项目。你不需要花很长时间去反复想复杂的数学推导,只需要在手边打开编辑器,花几分钟时间把栈、字典、边界条件串一遍,就能让大脑重新进入算法状态。
比起动不动就做大项目,或者花两小时啃一道困难题,每天用一道经典题来保持手感反而更可持续。一个“每日一练”系列如果选太偏门、太生僻的题目,读者很难坚持,因为每道题都要重新理解背景。像“有效的括号”这种题目,背景只依赖小学就该理解的生活常识,规则也足够清晰,读者能快速进入挑战状态。用这样的小题作为每日练习入口,绝对比一上来就做“编辑距离”或“正则表达式匹配”要友好得多。
我做每日练习时还会顺便做两件事:一是在代码注释里写一行“这个解法为什么用栈”,帮助未来的自己回忆;二是把上面那张测试用例表存成一个可执行文件,之后每次改解法都跑一遍。这样做最大的好处是不会犯“今天知道了正确答案,一周后又忘了怎么写”的毛病。
5.3 复习时怎样一道题吃出三道题的效果
想榨干一道经典题的价值,可以在完成标准解法之后问自己几个问题:
如果输入里只有一种括号,那还能不能用计数法解决?答案是能。因为只有一种括号时,不存在顺序错乱的问题,左右数量相等即可。所以当你只面对 ( 和 ) 时,用计数器能把空间复杂度降到 O(1),这种优化方式在面试里可以和面试官多聊两句。
如果括号类型不是三种而是很多种,比如自定义成 < 和 >、「 和 」,你的解法能不能不修改核心逻辑就支持?用字典管理映射关系后,只需要往 pairs 里加新条目就行,这就是把规则和数据分离带来的好处。
如果这个字符串长度高达几百万,你前面的解法会内存不够吗?栈最小会存储所有出现过的左括号,最坏情况全字符串都是左括号,那么空间复杂度 O(n) 是逃不掉的。意识到这一点,说明你已经明白了这道题的复杂度边界,下次遇到更复杂的字符串解析题时,就会主动评估内存占用。
这三个问题里任何一个想明白了,都比单纯多刷七八道同类题更有效。因为你不是在背答案,而是在理解这种数据结构为什么会在括号问题上恰好合适。
最后再分享一个自己常用的实操小习惯:把“有效的括号”这类题目放进你每周的快速回看清单里,每周末抽两分钟重新写一遍。两天不写代码和两周不写代码,手感差距非常大;但如果你每周都从这道题开始热手,重新建立状态只需要很短的时间。所谓每日一练,不是每次都要挑战高难度,而是让自己始终保持在能随时开始的状态里。这样一本正经的基础题,就是你最好的启动按钮。
