删除无效的括号 图解题解
这道题到底在问什么
- 输入
- s = "()())(a)("
- 输出
- "()()(a)"(删掉第 4 位多余的 ) 和最后一位多余的 ()
最优解:为什么这么做
一句话答案:LeetCode 301 删除无效的括号:两遍贪心扫描可求出一个合法解——维护平衡计数 bal,第一遍从左到右删掉每个让 bal 变负的多余右括号,第二遍反向对称地删多余左括号,删的都是必删项,时间 O(n)。官方原题要求列出全部最少删除方案时,需改用 BFS/DFS 枚举。
删除无效的括号在问什么
字符串里混着左右括号和小写字母,要求删掉最少数量的括号,让剩下的串合法——每个左括号都能配到它右边的一个右括号。字母不参与配对、原样保留。这题按删除数量最少来衡量方案好坏,所以核心是想清楚:到底哪些括号是「非删不可」的,除此之外一个都不多删。
为什么想到用平衡计数器 bal
先问一个串怎样才算合法。从左往右数,用 bal 表示「目前左括号比右括号多出几个」:遇左括号加一,遇右括号减一。合法等价于两件事同时成立——扫描全程 bal 从不变负(每个右括号出现时左边都有闲置的左括号等它),且扫完 bal 归零(左括号也都配上了)。
这个刻画直接暴露了谁是多余的:某个右括号让 bal 要变负,说明它左边的左括号已经全被前面的右括号配光了,无论后面发生什么,它都永远配不上对——这就是一个必删的括号。反过来,扫完后 bal 还大于 0,说明有左括号到最后也没等来配对,它们同样必删,只是位置在串的尾部一侧。
为什么必须正反各扫一遍
从左到右的一遍扫描只能当场揪出多余的右括号,因为右括号非法与否取决于它左边的情况,扫到它时证据已经齐了。可多余的左括号不行:左括号能不能配上,取决于它右边还有没有右括号,正向扫到它时未来还没发生,没法判它死刑。
解决办法是对称地反着来一遍:从右往左扫,把右括号当开方、左括号当闭方,bal 的含义变成「右括号比左括号多几个」,让 bal 变负的左括号就是配不上的,删掉。两遍合起来,多余的右括号和左括号都被清干净,剩下的串前缀性质与总量守恒同时满足,必然合法。
凭什么说这样删得最少
每一次删除都发生在「铁证已到手」的时刻:那个括号在当时的局面下无论如何都配不上对,任何合法方案都必须删掉它或某个等价位置的括号,删除数量因此不可能更少。而删掉它之后 bal 停在 0 继续扫,等于当它从没出现过,不牵连任何本来合法的括号——不多删一个。
实现上有个细节要盯住:判定多余的那个括号直接删掉、bal 保持原样不减,等于当它从未出现,绝不能让 bal 落到负数再继续。若放任 bal 变成负数往下走,后面本来合法的右括号会被连累着误判成多余,越删越多。
复杂度、边界与题目变体
时间 O(n):正反各一遍线性扫描,每个字符只看常数次;空间 O(n),用一个字符数组标记删除位并拼出结果。易错点除了 bal 忘归零,还有把字母也计入 bal——字母不影响配对,混进计数会把平衡算错,它们只需原样保留。
变体上,如果题目要求返回所有删除数最少的合法方案,两遍贪心就不够了:得先算出左右括号各需删几个,再用 BFS 或 DFS 枚举删哪些位置并去重校验。本题这套线性扫描的价值在于用 O(n) 拿到一个合法解,是面试里性价比极高的起手式。
▶ 动画逐步走查(共 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
def removeInvalid(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)C++
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;
}Java
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; }
}
}复杂度
时间
O(n)
两遍线性扫描,每个字符各看常数次
空间
O(n)
用一个字符数组标记/收集结果,n 为串长
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 删除无效的括号 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
bal 这个计数器到底代表什么?+
正向扫时它代表「目前左括号比右括号多出几个」。bal>0 说明有左括号在等配对;遇到 ) 时若 bal>0 就配掉一个、否则这个 ) 多余。反向扫时角色对调,代表右括号比左括号多几个。
为什么这样删一定是「删得最少」的?+
每次删除都发生在「当前这个括号此刻无论如何都配不上对」的时刻——它是必删的,删它不会牵连别的括号。所有删除都必要,所以总数最少。
如果题目要返回所有删最少的合法方案呢?+
那就得换成 BFS/DFS:先算出要删的左、右括号各几个,再枚举删哪些位置、去重后校验合法性。本题的两遍贪心只给出其中一个合法解,胜在 O(n)。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 删除无效的括号 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。