删除无效的括号 图解题解
这道题到底在问什么
- 输入
- s = "()())(a)("
- 输出
- 两遍贪心给出其中一个合法结果 "()()(a)"(删掉第 4 位多余的 ) 和最后一位多余的 ();LC301 要的是把删同样多字符后所有合法结果都列出来
最优解:为什么这么做
一句话答案:把每个字符串看成搜索状态:第 d 层表示恰好删除 d 个括号。BFS 第一次出现合法字符串的层就是最少删除层;检查完整层并收集所有合法状态,再停止向下扩展,才能同时保证‘删除最少’和‘结果不漏’。
这题不是只求一个合法字符串
LeetCode 301 要求删除最少数量的无效括号,并返回这个最少删除数下的所有不同结果。比如 `()())()` 只需删除一个右括号,但有 `(())()` 和 `()()()` 两个答案,返回任意一个都不完整。
两遍贪心可以在线性时间构造一个最少删除的合法串,适合 LC1249;它无法枚举同样少删的所有不同结果,因此不能作为 LC301 的完整主解。
为什么分层 BFS 天然保证删除最少
把原字符串放在第 0 层。从某个状态删除一个左括号或右括号,就得到下一层状态。因此第 d 层中的每个字符串都恰好删除了 d 个括号。
BFS 按 0、1、2……的顺序检查删除次数。只要前面各层都没有合法串,而第 d 层第一次出现合法串,就能直接证明最少删除数是 d;更深层都会删除更多,没有继续搜索的价值。
怎样在线性时间验证一个候选
用 balance 记录当前尚未匹配的左括号数:遇到 `(` 加一,遇到 `)` 减一,字母跳过。如果扫描途中 balance 变成负数,说明当前右括号左侧没有可匹配的左括号,候选立即无效。
扫描结束时 balance 还必须等于 0。大于 0 表示仍有左括号没有右括号配对。于是一个候选的验证时间是 O(n),而且字母会原样保留。
为什么要去重,而且要检查完整个命中层
删除相邻的相同括号经常得到同一个字符串。例如从连续的两个右括号中删除任意一个,结果可能完全相同。每层使用集合保存状态,能避免重复入队、重复验证和重复输出。
在某一层找到第一个合法串时不能立刻返回。同层其他状态删除的括号数量完全相同,也可能是最优答案。正确停止方式是:检查完整个当前层,收集本层所有合法串;若集合非空,就返回它们,并且不再生成下一层。
正确性可以拆成最少性与完整性
最少性来自 BFS 层序:首次命中层之前的所有较少删除方案已经被完整验证,均不合法;首次命中层之后的状态删除更多,不可能更优。
完整性来自扩展规则与整层收集:每个状态都尝试删除每一个括号,所以所有删除 d 个括号的候选都会被覆盖;集合只合并内容相同的状态,不会丢掉不同结果;首次命中层又会被完整扫描,因此该层所有不同的合法串都会进入答案。
复杂度、边界与 DFS 替代方案
括号的删除子集最坏可达指数数量,每个候选还需 O(n) 验证或构造,所以时间复杂度最坏为 O(n·2^n),空间最坏为 O(2^n)。BFS 的价值是最少性直观,并通过按层停止避免搜索更深状态,不是把指数问题变成多项式。
原串已经合法时,第 0 层直接返回它;`)(` 这类输入最终返回空串;纯字母串原样返回。也可以先统计必须删除的左右括号数,再用 DFS 加剩余字符剪枝枚举答案,通常更省层集合空间,但正确性仍要同时说明最少和不漏。
▶ 动画逐步走查(共 38 步)——想跟着动画一帧帧对照就展开
- 3核心就一个计数器 bal:左括号 +1、右括号 -1,谁让它变负谁就是多余的。第一遍删多余的 ),第二遍反向删多余的 (。
- 4第一遍从左往右扫。bal 表示「目前左括号比右括号多出几个」,开始是 0。
- 5指针 i 走到下标 0,这一格是 '('。是左括号,bal 加一。
- 6左括号入账:bal 加一变成 1(高亮的就是它)。它在等一个右括号来配对。
- 7指针 i 走到下标 1,这一格是 ')'。是右括号,bal 减一,再看它是否合法。
- 8bal 大于 0,说明前面有左括号等着配它:bal 减一变成 0,这个右括号合法、保留。
- 9指针 i 走到下标 2,这一格是 '('。是左括号,bal 加一。
- 10左括号入账:bal 加一变成 1(高亮的就是它)。它在等一个右括号来配对。
- 11指针 i 走到下标 3,这一格是 ')'。是右括号,bal 减一,再看它是否合法。
- 12bal 大于 0,说明前面有左括号等着配它:bal 减一变成 0,这个右括号合法、保留。
- 13指针 i 走到下标 4,这一格是 ')'。是右括号,bal 减一,再看它是否合法。
- 14bal 已经是 0,没有左括号能配它——这个右括号是多余的,标红删掉(已删 1 个)。
- 15指针 i 走到下标 5,这一格是 '('。是左括号,bal 加一。
- 16左括号入账:bal 加一变成 1(高亮的就是它)。它在等一个右括号来配对。
- 17指针 i 走到下标 6,这一格是 'a'。是字母,跟括号无关,直接跳过。
- 18指针 i 走到下标 7,这一格是 ')'。是右括号,bal 减一,再看它是否合法。
- 19bal 大于 0,说明前面有左括号等着配它:bal 减一变成 0,这个右括号合法、保留。
- 20指针 i 走到下标 8,这一格是 '('。是左括号,bal 加一。
- 21左括号入账:bal 加一变成 1(高亮的就是它)。它在等一个右括号来配对。
- 22第一遍结束,所有多余的右括号都标红删了。但结尾那个落单的左括号还没处理,需要第二遍。
- 23第二遍从右往左扫。这回把角色对调:遇 ) 加一、遇 ( 减一,删掉多余的左括号。bal 重新从 0 起步。
- 24指针 i 走到下标 8,这一格是 '('。反向扫时左括号当「收口」,bal 减一,再看是否合法。
- 25bal 已经是 0,后面没有右括号能配它——这个左括号是多余的,标红删掉(已删 2 个)。
- 26指针 i 走到下标 7,这一格是 ')'。反向扫时右括号当「开口」,bal 加一。
- 27反向记账:bal 加一变成 1(高亮的就是它),等一个左括号来配。
- 28指针 i 走到下标 6,这一格是 'a'。是字母,跳过。
- 29指针 i 走到下标 5,这一格是 '('。反向扫时左括号当「收口」,bal 减一,再看是否合法。
- 30bal 大于 0,后面有右括号等着配它:bal 减一变成 0,这个左括号合法、保留。
- 31下标 4 这格第一遍已经删了,第二遍直接跳过它。
- 32指针 i 走到下标 3,这一格是 ')'。反向扫时右括号当「开口」,bal 加一。
- 33反向记账:bal 加一变成 1(高亮的就是它),等一个左括号来配。
- 34指针 i 走到下标 2,这一格是 '('。反向扫时左括号当「收口」,bal 减一,再看是否合法。
- 35bal 大于 0,后面有右括号等着配它:bal 减一变成 0,这个左括号合法、保留。
- 36指针 i 走到下标 1,这一格是 ')'。反向扫时右括号当「开口」,bal 加一。
- 37反向记账:bal 加一变成 1(高亮的就是它),等一个左括号来配。
- 38指针 i 走到下标 0,这一格是 '('。反向扫时左括号当「收口」,bal 减一,再看是否合法。
- 39bal 大于 0,后面有右括号等着配它:bal 减一变成 0,这个左括号合法、保留。
- 40两遍扫完,所有标红的格子去掉,剩下的字符拼起来就是 "()()(a)"——删得最少的合法括号串。
⚠️ 容易写错的地方
✗ 错:只从左往右扫一遍就返回
✓ 对:正反两遍各扫一次
一遍只能删多余的 ),结尾落单的 ( 删不掉,结果仍不合法
✗ 错:bal 减到负数不归零、继续往下减
✓ 对:判定多余后把 bal 拉回 0(删掉那个括号等于不计它)
不归零会让后面的合法括号被错判成多余,越删越多
✗ 错:把字母也纳入 bal 计数
✓ 对:只有括号动 bal,字母原样保留
字母不影响配对,计进去会把 bal 算错
完整代码(Python / C++ / Java)
Python
class Solution:
# ① 两遍贪心:删最少 → 得到「一个」合法串(LC1249 的完整解)
def removeInvalid(self, s):
arr = list(s)
def scan(idx, op, cl): # idx 顺序, op 开括号, cl 闭括号
bal = 0
for i in idx:
if arr[i] == op: bal += 1
elif arr[i] == cl:
if bal > 0: bal -= 1
else: arr[i] = '' # 多余,删除
scan(range(len(arr)), '(', ')') # 第一遍 删多余 )
scan(reversed(range(len(arr))), ')', '(') # 第二遍 删多余 (
return ''.join(arr)
# ② 真题 LC301:返回「所有」删最少后的合法结果(逐层 BFS)
def isValid(self, t):
bal = 0
for ch in t:
if ch == '(': bal += 1
elif ch == ')':
bal -= 1
if bal < 0: return False
return bal == 0
def removeInvalidParentheses(self, s):
level = {s}
while True:
res = [t for t in level if self.isValid(t)]
if res: return res # 这一层有解 = 删得最少
nxt = set()
for t in level:
for i, ch in enumerate(t):
if ch in '()': # 每次只删一个括号
nxt.add(t[:i] + t[i+1:])
if not nxt: return ['']
level = nxtC++
class Solution {
public:
// ① 两遍贪心:删最少 → 得到「一个」合法串(LC1249 的完整解)
string removeInvalid(string s){
auto scan=[&](bool fwd,char op,char cl){
int bal=0, n=s.size();
for(int k=0;k<n;k++){
int i = fwd ? k : n-1-k;
if(s[i]==op) bal++;
else if(s[i]==cl){ if(bal>0) bal--; else s[i]='*'; }
}
};
scan(true,'(',')'); scan(false,')','(');
string r; for(char c:s) if(c!='*') r+=c; return r;
}
// ② 真题 LC301:返回「所有」删最少后的合法结果(逐层 BFS)
bool isValid(const string& t){
int bal=0;
for(char c:t){
if(c=='(') bal++;
else if(c==')' && --bal<0) return false;
}
return bal==0;
}
vector<string> removeInvalidParentheses(string s){
unordered_set<string> level{s};
while(true){
vector<string> res;
for(auto& t:level) if(isValid(t)) res.push_back(t);
if(!res.empty()) return res; // 这一层有解 = 删得最少
unordered_set<string> nxt;
for(auto& t:level)
for(int i=0;i<(int)t.size();i++)
if(t[i]=='('||t[i]==')') // 每次只删一个括号
nxt.insert(t.substr(0,i)+t.substr(i+1));
if(nxt.empty()) return {""};
level = nxt;
}
}
};Java
class Solution {
// ① 两遍贪心:删最少 → 得到「一个」合法串(LC1249 的完整解)
String removeInvalid(String s){
char[] a = s.toCharArray();
scan(a, true, '(', ')'); // 第一遍 删多余 )
scan(a, false, ')', '('); // 第二遍 删多余 (
StringBuilder r = new StringBuilder();
for (char c : a) if (c != 0) r.append(c);
return r.toString();
}
void scan(char[] a, boolean fwd, char op, char cl){
int bal = 0, n = a.length;
for (int k = 0; k < n; k++){
int i = fwd ? k : n-1-k;
if (a[i]==op) bal++;
else if (a[i]==cl){ if(bal>0) bal--; else a[i]=0; }
}
}
// ② 真题 LC301:返回「所有」删最少后的合法结果(逐层 BFS)
boolean isValid(String t){
int bal = 0;
for (char c : t.toCharArray()){
if (c=='(') bal++;
else if (c==')' && --bal < 0) return false;
}
return bal == 0;
}
public List<String> removeInvalidParentheses(String s){
Set<String> level = new HashSet<>();
level.add(s);
while (true){
List<String> res = new ArrayList<>();
for (String t : level) if (isValid(t)) res.add(t);
if (!res.isEmpty()) return res; // 这一层有解 = 删得最少
Set<String> nxt = new HashSet<>();
for (String t : level)
for (int i = 0; i < t.length(); i++){
char c = t.charAt(i);
if (c=='('||c==')') // 每次只删一个括号
nxt.add(t.substring(0,i)+t.substring(i+1));
}
if (nxt.isEmpty()) return Collections.singletonList("");
level = nxt;
}
}
}复杂度
时间
O(n)
两遍线性扫描,每个字符各看常数次
空间
O(n)
用一个字符数组标记/收集结果,n 为串长
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 删除无效的括号 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
bal 这个计数器到底代表什么?+
正向扫时它代表「目前左括号比右括号多出几个」。bal>0 说明有左括号在等配对;遇到 ) 时若 bal>0 就配掉一个、否则这个 ) 多余。反向扫时角色对调,代表右括号比左括号多几个。
为什么这样删一定是「删得最少」的?+
每次删除都发生在「当前这个括号此刻无论如何都配不上对」的时刻——它是必删的,删它不会牵连别的括号。所有删除都必要,所以总数最少。
如果题目要返回所有删最少的合法方案呢?+
那就是本题的原始要求。做法是逐层 BFS:第 0 层只有原串,每一层把每个串各删掉一个括号得到下一层,第一次出现合法串的那一层删的个数就是最少,把该层所有合法串(去重后)一起返回。上面代码步的第 ② 段就是这个写法;两遍贪心只给出其中一个合法解,胜在 O(n)。
查看这道题的视频讲解和 AI 答疑
文字题解和动画可直接查看。开通图解算法年卡后,可以观看 删除无效的括号 的视频讲解;小欧 AI 会读取当前题的动画和解析,回答思路、代码与边界问题。全站已上线 948 份视频讲解。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。