你有没有过这种感觉?回溯的模板背得滚瓜烂熟,可一碰到具体题目还是写不出来。
我当初刷这道题的时候就是这样,对着题目愣了半小时,满脑子都是 "这也能用回溯?"
题目说个明白
给你一个数字 n,代表有 n 对括号,要你生成所有合法的括号组合。
合法的标准就两个:一是左右括号总数刚好都是 n;二是任意前缀里,右括号的数量都不能超过左括号。
暴力枚举:我最开始的朴素想法
一开始我想得特别直白。总共 2n 个位置,每个位置要么放左括号,要么放右括号,把所有可能的组合都生成出来,再挨个判断合不合法不就行了?
思路很简单,写起来也顺。先写个递归把所有组合枚举出来,再写个校验函数,碰到合法的就收进结果里。
javascript
var generateParenthesis = function(n) {
const result = [];
// 递归生成所有可能的括号组合
const generateAll = (str, pos) => {
// 字符串长度到2n了,就检查是否合法
if (pos === 2 * n) {
if (isValid(str)) {
result.push(str);
}
return;
}
// 每个位置先试放左括号
generateAll(str + '(', pos + 1);
// 回溯回来再试放右括号
generateAll(str + ')', pos + 1);
}
// 校验当前字符串是不是合法括号
const isValid = (s) => {
let balance = 0;
for (let c of s) {
if (c === '(') balance++;
else balance--;
// 中途右括号比左括号多,直接不合法
if (balance < 0) return false;
}
// 最后左右括号数量要相等
return balance === 0;
}
generateAll('', 0);
return result;
};
写完跑了下 n=3,结果是对的。但我越看越觉得别扭 ------ 这也太笨了吧?
比如第一个字符就放右括号,明摆着不可能合法,可程序还是会傻乎乎把后面所有位置都填完,最后再校验失败。n 小的时候还好,n=8 的时候,光无效路径就多到离谱,纯纯的浪费性能。
回溯 + 剪枝才是正确打开方式
那能不能不等字符串生成完再判断?在生成的过程中,就把明显走不通的路直接砍掉?
想明白这点之后,思路一下就通了。说白了就两个规则,从源头把非法路径掐死:
- 只要左括号还没用完,随时可以加左括号
- 只有当前右括号数量比左括号少的时候,才能加右括号
就这么两个简单的条件,把所有不可能的路径都剪掉了,剩下的走到底就全是合法结果,连最后校验都省了。
咱们就拿 n=2 一步步走一遍,刚好对应后面的数组写法,走完你就彻底明白回溯和剪枝是怎么跑的了。
初始状态:cur 是长度为 4 的空数组,left=0,right=0,从 dfs (0,0) 开始。
- 左括号还没用完(0<2),给下标 0+0=0 的位置填 '(',然后递归走 dfs (1, 0)。
- 进入 dfs (1,0),左括号还剩名额(1<2),给下标 1+0=1 的位置填 '(',递归走 dfs (2, 0)。
- 进入 dfs (2,0),左括号已经用完了,走不了左分支。看右分支:右括号 0 个 < 左括号 2 个,符合条件,给下标 2+0=2 的位置填 ')',递归走 dfs (2, 1)。
- 进入 dfs (2,1),左括号还是满的。右括号 1 个 < 左括号 2 个,给下标 2+1=3 的位置填 ')',递归走 dfs (2, 2)。
- 进入 dfs (2,2),right 等于 n=2,触发终止条件,把当前 cur 拼成字符串
(()),塞进结果集,然后返回上一层。
到这里第一条路径就走完了,接下来就是回溯的过程。从 dfs (2,2) 退回到 dfs (2,1),右分支也执行完了,再退回到 dfs (2,0),左右分支都走完了,继续退回到 dfs (1,0)。
这时候 dfs (1,0) 的左分支已经跑完,开始跑右分支:6. 右括号 0 个 < 左括号 1 个,符合条件,给下标 1+0=1 的位置填 ')'------ 你看,这里直接就把之前这个位置的 '(' 覆盖掉了,天然就完成了 "撤销选择",不用额外写回溯撤销的代码。然后递归走 dfs (1, 1)。7. 进入 dfs (1,1),左括号还有剩(1<2),给下标 1+1=2 的位置填 '(',递归走 dfs (2, 1)。8. 进入 dfs (2,1),左括号满了,右括号 1 个 < 左括号 2 个,给下标 2+1=3 的位置填 ')',递归走 dfs (2, 2)。9. 再次触发终止条件,cur 拼成字符串 ()(),塞进结果集,然后逐层返回。
等所有递归都退完,结果集里就有了全部合法组合。全程没有任何一条非法路径,只要右括号数量要超过左括号了,这条路直接就被堵死了,连走都不会走。

优化版代码实现
最开始我写的是字符串拼接的版本,后来发现每次拼接都会生成新字符串,有点浪费性能。就改成了数组占位的写法,提前开好固定长度的数组,直接按下标填字符,效率高不少。
javascript
var generateParenthesis = function (n) {
const ans = [];
// 提前定好数组长度,总共2n个字符,不用频繁扩容
const cur = Array(n * 2);
var dfs = function (left, right) {
// 终止条件:右括号用完就结束
// 不用额外判断left------我们一直保证右括号<=左括号,右括号满了,左括号肯定也满了
if (right === n) {
ans.push(cur.join(''));
return;
}
// 左括号还有剩余,就填左括号
if (left < n) {
// left+right 刚好就是当前要填充的位置下标,不用额外维护索引变量
cur[left + right] = '(';
dfs(left + 1, right);
}
// 右括号数量 < 左括号,才能填右括号
// 这个条件我一开始写成 right < n,跑出来一堆离谱结果,踩过实坑
if (right < left) {
cur[left + right] = ')';
dfs(left, right + 1);
}
}
dfs(0, 0);
return ans;
};
复杂度说两句
暴力法时间复杂度是 O (2^(2n) * n),每个位置两种选择,最后校验还要遍历一遍字符串,效率很低。
剪枝后的回溯法,时间复杂度对应第 n 个卡特兰数,量级大概是 4^n / √n,比暴力法高效得多。不用死记这个公式,知道剪枝后砍掉了大量无效路径就行。
空间复杂度都是 O (n),主要是递归栈的深度,优化版多了一个固定长度的数组,整体开销很小。
踩坑小结
这题我踩过两个印象很深的坑。
第一个就是最开始写暴力法的时候,校验函数忘了中途判断 balance < 0,非要等遍历完再看结果。虽然结果没错,但平白多了很多无效计算。
第二个就是写剪枝版的时候,右括号的判断条件写错了。一开始想当然写成 right < n,结果跑出来一堆右括号开头的离谱组合,人都傻了。
后来才反应过来:合法括号的核心,从来不是最后总数相等,而是任何时候右括号都不能比左括号多。
这也是这道题最精髓的地方 ------ 回溯的魅力,从来不是把所有路都走一遍,而是在出发前就知道哪些路根本不用走。
其实回溯题真的没那么玄乎。核心就三个问题:能选什么?什么时候不能选?什么时候结束?
把这三个问题想清楚,大部分中等难度的回溯题都能拿下。
你刷这道题的时候有没有踩过什么有意思的坑?或者你有别的更巧妙的解法?评论区聊聊,我每条都会看的~
觉得文章有用的话,点个赞再走呀,谢谢啦。