有效的括号字符串 图解题解
这道题到底在问什么
- 输入
- s = "()"
- 输出
- true
- 输入
- s = "(*)"
- 输出
- true (* 当空,或当一对里的字符都行)
- 输入
- s = "(*))"
- 输出
- true (* 当 (,凑成 (()))
最优解:为什么这么做
一句话答案:LeetCode 678 有效的括号字符串:星号可当左、右括号或空串,不枚举每种选法,只跟踪未匹配左括号数的下界 low 与上界 high,扫完 low 归 0 即有效,时间 O(n)、空间 O(1)。
这串括号能不能配成有效的
输入是一串只含左括号、右括号和星号的字符。星号是百变字符,每个都能当成一个左括号、一个右括号,或当成空串什么都不填。问题是:能不能给所有星号各挑一个身份,让整串括号配得刚好——每个左括号都有右括号接上,且任何前缀里右括号都不多于左括号。配得出返回 true,否则 false。
暴力枚举每个星号会炸成什么样
星号的麻烦在于身份要扫到后面才定。一个办法是把每个星号的三种身份全枚举:k 个星号就有 3 的 k 次方种组合,每种都要完整扫一遍验证前缀合法。星号一多就指数级膨胀,几十个就跑不动,得把这堆组合合并成一次扫描。
为什么跟踪一段区间就不漏任何组合
跳出逐个枚举,改盯一个量:扫到当前位置为止,未匹配的左括号还剩几个。不同星号选法让这个数不一样,但把所有选法的取值收在一起,它们始终是一段连续区间 low 到 high。low 是最悲观的下界(星号尽量当右或空),high 是最乐观的上界(星号尽量当左)。
连续性能逐字符验:开局区间只有 0 一个值。每读一字符,遇左括号整段 +1、遇右括号整段 -1 都是平移;遇星号把区间左右各撑一格,下界 -1、上界 +1,仍连续且把原取值都包住。既然每步都保住连续,中间任何整数都对应某种真实选法,只用 low、high 两端点就代表全部、一个不漏。
扫一遍的三条更新规则和收尾判定
落成扫描:low、high 从 0 起步,逐字符从左读。读到左括号,未匹配左括号多一个,low、high 各 +1;读到右括号消耗一个,各 -1;读到星号可左可右可空,撑开区间,low 减 1、high 加 1。每读完一个做两道检查:一旦 high < 0,连最乐观的上界都救不了多出的右括号,返回 false;若 low < 0 就夹回 0,因未匹配左括号数最少是 0,不夹会让后面下界失真。扫完返回 low == 0:下界能摸到 0,就有一种选法让左右配平。
三个题面串的 low、high 各怎么变
先看 「()」:low、high 从 0、0 起步。左括号各 +1 到 1、1;右括号各 -1 回 0、0,全程没到负。扫完 low == 0,返回 true。
再看 「(*)」:起点 0、0。左括号后 1、1;星号撑开成 0、2;右括号 -1 得 -1、1,low 夹回 0 成 0、1。末尾 low == 0,返回 true。
最后 「(*))」:从 0、0 起。左括号后 1、1;星号撑开 0、2;右括号 -1 得 -1、1,low 夹回 0 成 0、1;再一个右括号 -1 得 -1、0,夹回 0、0。high 全程没负,收尾 low == 0,返回 true。
区间法最容易崩的三处
枚举每个星号的三种选法是最大的坑,串一长 3 的 k 次方级组合直接超时,区间法压成两个整数扫一遍才行。第二个坑是忘把 low 夹回非负——负的 low 会带偏后面每步下界,让本该有效的串误判成 false。第三是收尾只看 high、忘判 low:high 只是上界,能否配平取决于下界 low 摸不摸得到 0,末尾必须判 low == 0。
▶ 动画逐步走查(共 37 步)——想跟着动画一帧帧对照就展开
- 3关键直觉:所有 * 的选法下,「未匹配左括号数」始终是一段连续区间。我们不关心具体每个 * 选了啥,只盯这段区间的上下界。high 是乐观上界(* 尽量当左),low 是悲观下界(* 尽量当右)。high 掉到负说明右括号怎么都多了;结尾 low 能归 0 说明存在一种选法刚好配平。
- 4例 1(有效) 开始。蓝色光标 cur 停在第 0 个字符。我们维护一段区间 [low, high] = 「当前未匹配 ( 的可能个数」,开局是 [0,0]。下面一个字符一个字符地更新这段区间,并随时检查 high 有没有掉到负。
- 5光标到第 0 个字符,是左括号 (。左括号必然多出一个未匹配的 (,区间整段右移:low、high 各 +1。 更新前 [low,high]=[0,0],更新后先得到 [1,1]。
- 6高亮的格子并入已处理区(绿底)。检查铁律②:low=1 本就 ≥0,不用夹。 high=1≥0 没触发 false,区间安全落在 [1, 1],往下一个字符走。
- 7光标到第 1 个字符,是星号 *。星号身份不定——当 ) 时 -1,当空时不变,当 ( 时 +1,所以区间被撑大:low-1、high+1。 更新前 [low,high]=[1,1],更新后先得到 [0,2]。
- 8高亮的格子并入已处理区(绿底)。检查铁律②:low=0 本就 ≥0,不用夹。 high=2≥0 没触发 false,区间安全落在 [0, 2],往下一个字符走。
- 9光标到第 2 个字符,是左括号 (。左括号必然多出一个未匹配的 (,区间整段右移:low、high 各 +1。 更新前 [low,high]=[0,2],更新后先得到 [1,3]。
- 10高亮的格子并入已处理区(绿底)。检查铁律②:low=1 本就 ≥0,不用夹。 high=3≥0 没触发 false,区间安全落在 [1, 3],往下一个字符走。
- 11光标到第 3 个字符,是右括号 )。右括号必然消耗一个 (,区间整段左移:low、high 各 -1。 更新前 [low,high]=[1,3],更新后先得到 [0,2]。
- 12高亮的格子并入已处理区(绿底)。检查铁律②:low=0 本就 ≥0,不用夹。 high=2≥0 没触发 false,区间安全落在 [0, 2],往下一个字符走。
- 13光标到第 4 个字符,是左括号 (。左括号必然多出一个未匹配的 (,区间整段右移:low、high 各 +1。 更新前 [low,high]=[0,2],更新后先得到 [1,3]。
- 14高亮的格子并入已处理区(绿底)。检查铁律②:low=1 本就 ≥0,不用夹。 high=3≥0 没触发 false,区间安全落在 [1, 3],往下一个字符走。
- 15光标到第 5 个字符,是星号 *。星号身份不定——当 ) 时 -1,当空时不变,当 ( 时 +1,所以区间被撑大:low-1、high+1。 更新前 [low,high]=[1,3],更新后先得到 [0,4]。
- 16高亮的格子并入已处理区(绿底)。检查铁律②:low=0 本就 ≥0,不用夹。 high=4≥0 没触发 false,区间安全落在 [0, 4],往下一个字符走。
- 17光标到第 6 个字符,是右括号 )。右括号必然消耗一个 (,区间整段左移:low、high 各 -1。 更新前 [low,high]=[0,4],更新后先得到 [-1,3]。
- 18高亮的格子并入已处理区(绿底)。检查铁律②:low 算出来是 -1,但未匹配左括号数不可能为负,夹回 0。 high=3≥0 没触发 false,区间安全落在 [0, 3],往下一个字符走。
- 19光标到第 7 个字符,是右括号 )。右括号必然消耗一个 (,区间整段左移:low、high 各 -1。 更新前 [low,high]=[0,3],更新后先得到 [-1,2]。
- 20高亮的格子并入已处理区(绿底)。检查铁律②:low 算出来是 -1,但未匹配左括号数不可能为负,夹回 0。 high=2≥0 没触发 false,区间安全落在 [0, 2],往下一个字符走。
- 21光标到第 8 个字符,是左括号 (。左括号必然多出一个未匹配的 (,区间整段右移:low、high 各 +1。 更新前 [low,high]=[0,2],更新后先得到 [1,3]。
- 22高亮的格子并入已处理区(绿底)。检查铁律②:low=1 本就 ≥0,不用夹。 high=3≥0 没触发 false,区间安全落在 [1, 3],往下一个字符走。
- 23光标到第 9 个字符,是星号 *。星号身份不定——当 ) 时 -1,当空时不变,当 ( 时 +1,所以区间被撑大:low-1、high+1。 更新前 [low,high]=[1,3],更新后先得到 [0,4]。
- 24高亮的格子并入已处理区(绿底)。检查铁律②:low=0 本就 ≥0,不用夹。 high=4≥0 没触发 false,区间安全落在 [0, 4],往下一个字符走。
- 25光标到第 10 个字符,是右括号 )。右括号必然消耗一个 (,区间整段左移:low、high 各 -1。 更新前 [low,high]=[0,4],更新后先得到 [-1,3]。
- 26这个字符处理完,区间落定在 [0, 3]。注意 low 原本算出 -1,但「未匹配左括号数」最少就是 0、不存在负的,所以夹回 0。 这是最后一个字符了,接下来看结尾的 low 能不能归 0。
- 27例 1(有效) 卡死在第 10 个字符(红色):那一步 high 掉到负,乐观上界都救不了多出来的右括号,无论 * 怎么选都配不平,答案 false。
- 28例 2(无效) 开始。蓝色光标 cur 停在第 0 个字符。我们维护一段区间 [low, high] = 「当前未匹配 ( 的可能个数」,开局是 [0,0]。下面一个字符一个字符地更新这段区间,并随时检查 high 有没有掉到负。
- 29光标到第 0 个字符,是左括号 (。左括号必然多出一个未匹配的 (,区间整段右移:low、high 各 +1。 更新前 [low,high]=[0,0],更新后先得到 [1,1]。
- 30高亮的格子并入已处理区(绿底)。检查铁律②:low=1 本就 ≥0,不用夹。 high=1≥0 没触发 false,区间安全落在 [1, 1],往下一个字符走。
- 31光标到第 1 个字符,是星号 *。星号身份不定——当 ) 时 -1,当空时不变,当 ( 时 +1,所以区间被撑大:low-1、high+1。 更新前 [low,high]=[1,1],更新后先得到 [0,2]。
- 32高亮的格子并入已处理区(绿底)。检查铁律②:low=0 本就 ≥0,不用夹。 high=2≥0 没触发 false,区间安全落在 [0, 2],往下一个字符走。
- 33光标到第 2 个字符,是右括号 )。右括号必然消耗一个 (,区间整段左移:low、high 各 -1。 更新前 [low,high]=[0,2],更新后先得到 [-1,1]。
- 34高亮的格子并入已处理区(绿底)。检查铁律②:low 算出来是 -1,但未匹配左括号数不可能为负,夹回 0。 high=1≥0 没触发 false,区间安全落在 [0, 1],往下一个字符走。
- 35光标到第 3 个字符,是右括号 )。右括号必然消耗一个 (,区间整段左移:low、high 各 -1。 更新前 [low,high]=[0,1],更新后先得到 [-1,0]。
- 36高亮的格子并入已处理区(绿底)。检查铁律②:low 算出来是 -1,但未匹配左括号数不可能为负,夹回 0。 high=0≥0 没触发 false,区间安全落在 [0, 0],往下一个字符走。
- 37光标到第 4 个字符,是左括号 (。左括号必然多出一个未匹配的 (,区间整段右移:low、high 各 +1。 更新前 [low,high]=[0,0],更新后先得到 [1,1]。
- 38这个字符处理完,区间落定在 [1, 1]。 这是最后一个字符了,接下来看结尾的 low 能不能归 0。
- 39例 2(无效) 卡死在第 4 个字符(红色):那一步 high 掉到负,乐观上界都救不了多出来的右括号,无论 * 怎么选都配不平,答案 false。
⚠️ 容易写错的地方
✗ 错:挨个枚举每个 * 当 ( / ) / 空
✓ 对:维护区间 [low, high] 扫一遍
k 个星号有 3^k 种选法,指数爆炸;区间法把它压成 O(n)
✗ 错:忘了把 low 夹回 max(low,0)
✓ 对:每步 low<0 就置 0
未匹配左括号数不可能为负,不夹会让后面的判定失真
✗ 错:结尾只看 high==0 或不看 low
✓ 对:结尾必须判 low==0
high 是上界,能配平的充要条件是下界 low 能触到 0
完整代码(Python / C++ / Java)
Python
def checkValidString(s):
low = high = 0 # 未匹配左括号数的可能区间 [low, high]
for ch in s:
if ch == "(":
low += 1; high += 1
elif ch == ")":
low -= 1; high -= 1
else: # ch == "*",可当 ) / 空 / (
low -= 1; high += 1
if high < 0: # 右括号无救地多了
return False
if low < 0: # 未匹配左括号数不能为负
low = 0
return low == 0 # 能归零 → 配得平C++
bool checkValidString(string s) {
int low = 0, high = 0; // 区间 [low, high]
for (char ch : s) {
if (ch == "("[0]) { low++; high++; }
else if (ch == ")"[0]) { low--; high--; }
else { low--; high++; } // "*"
if (high < 0) return false; // 右括号多了
if (low < 0) low = 0; // 夹回非负
}
return low == 0;
}Java
public boolean checkValidString(String s) {
int low = 0, high = 0; // 区间 [low, high]
for (int i = 0; i < s.length(); i++) {
char ch = s.charAt(i);
if (ch == '(') { low++; high++; }
else if (ch == ')') { low--; high--; }
else { low--; high++; } // '*'
if (high < 0) return false; // 右括号无救地多了
if (low < 0) low = 0; // 夹回非负
}
return low == 0;
}复杂度
时间
O(n)
从左到右扫一遍字符串,每个字符只处理一次
空间
O(1)
只用 low、high 两个整数,不开额外结构
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 有效的括号字符串 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
没有星号、只有左右括号时,这套区间法退化成什么?+
退化成最经典的括号计数。没有星号,每一步 low 和 high 同增同减,两个端点永远相等,区间塌成一个确定的数,就是一个计数器 balance:读左括号加 1、读右括号减 1,中途一旦变负就 false,扫完等于 0 就 true。区间法不过是把这个计数器从一个确定值放宽成一段可能范围,好容纳星号带来的不确定。
为什么所有星号选法下的未匹配左括号数一定连成一段区间,而不是一堆零散的值?+
用归纳看:开局只有一个值,天然连续。每读一个字符,左括号和右括号把整段区间平移,各 +1 或各 -1,连续段平移后还是连续段;星号把区间往左右各撑一格,下界 -1、上界 +1,中间没有断点,仍是一整段。既然每一步都保住连续性,全程就始终是一段区间,两个端点 low、high 足以描述所有情况。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 有效的括号字符串 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。