一、题目
给定一个整数 n,要求生成所有由 n 对括号组成的合法括号组合。
例如:
n = 3
答案:
[
"((()))",
"(()())",
"(())()",
"()(())",
"()()()"
]
每个答案一定包含:
n 个 (
n 个 )
所以最终字符串长度一定是:
2n
二、一开始最容易想到什么?
这道题看起来像是在:
每个位置选择 (
或者 )
因为最终字符串长度为:
2n
所以理论上可以枚举:
2^(2n)
种字符串。
但是绝大多数都是非法的,例如:
)))
())(
())())
其实这些字符串在构造一半的时候就已经能判断:
后面无论怎么补都不可能合法。
因此这道题真正适合的是:
回溯 + 剪枝
三、整道题真正只需要维护两个变量
定义:
left
表示:
当前已经使用了多少个左括号
(。
定义:
right
表示:
当前已经使用了多少个右括号
)。
例如当前构造:
"(()
那么:
left = 2
right = 1
当前已经填写字符数量:
left + right = 3
所以只要知道:
left
right
就已经知道整个递归状态。
四、我最终采用的 char\[\] 版本
代码:
java
class Solution {
public List<String> generateParenthesis(int n) {
List<String> ans = new ArrayList<>();
// 每个答案长度一定是 2n
char[] path = new char[n * 2];
dfs(0, 0, n, path, ans);
return ans;
}
private void dfs(
int left,
int right,
int n,
char[] path,
List<String> ans
) {
// n 个左括号和 n 个右括号全部使用完
if (left == n && right == n) {
ans.add(new String(path));
return;
}
// 左括号还有剩余
if (left < n) {
path[left + right] = '(';
dfs(left + 1, right, n, path, ans);
}
// 只有存在未匹配左括号时,才能放右括号
if (right < left) {
path[left + right] = ')';
dfs(left, right + 1, n, path, ans);
}
}
}
整道题最核心的就是三个判断:
if (left == n && right == n)
if (left < n)
if (right < left)
下面逐个理解。
五、第一个判断:什么时候结束?
if (left == n && right == n) {
ans.add(new String(path));
return;
}
题目要求:
n 个左括号
+
n 个右括号
所以当:
left == n
right == n
意味着已经生成完整字符串。
例如:
n = 3
left = 3
right = 3
此时:
left + right = 6 = 2n
数组已经全部填写完成。
所以:
new String(path)
把:
char[]
转换成:
String
然后加入结果集合。
六、为什么有些写法只判断 right == n?
有一种写法:
if (right == n) {
ans.add(new String(path));
return;
}
这也是正确的。
为什么?
因为整个递归过程始终保证:
right <= left <= n
如果:
right == n
根据:
right <= left
可以推出:
left >= n
而我们又始终保证:
left <= n
所以只能:
left == n
因此:
right == n
实际上已经隐含:
left == n
不过为了面试时更好理解,我更推荐:
if (left == n && right == n)
它表达最直接:
左右括号都用完了。
七、第二个判断:为什么 left < n 才能放左括号?
代码:
if (left < n) {
path[left + right] = '(';
dfs(left + 1, right, n, path, ans);
}
因为题目只允许:
n 个 (
例如:
n = 3
那么左括号最多:
(((
如果:
left = 3
已经用完三个左括号,就不能再继续放:
(
所以:
if (left < n)
其实是在问:
左括号还有库存吗?
可以记:
left < n
→ 左括号没用完
→ 可以放 (
八、第三个判断:为什么 right < left 才能放右括号?
这是整道题最重要的剪枝条件。
代码:
if (right < left) {
path[left + right] = ')';
dfs(left, right + 1, n, path, ans);
}
右括号和左括号不一样。
左括号只需要考虑:
有没有用完
但是右括号还需要考虑:
当前有没有左括号可以匹配
例如当前:
"(("
那么:
left = 2
right = 0
已经存在两个尚未匹配的左括号。
所以当然可以放:
)
变成:
"(()
但是如果当前:
"()"
那么:
left = 1
right = 1
此时所有左括号都已经匹配。
如果再放:
)
就会得到:
"())"
明显非法。
所以必须满足:
right < left
才能继续放右括号。
九、可以把 right < left 理解成"还有几个括号没关"
这个理解特别直观。
计算:
left - right
表示:
当前还有多少个左括号没有被右括号匹配。
例如:
"(("
对应:
left = 2
right = 0
left - right = 2
说明还有:
2 个左括号没关
可以放:
)
例如:
"(()"
对应:
left = 2
right = 1
left - right = 1
还有一个没关。
仍然可以放:
)
如果:
"()"
那么:
left = 1
right = 1
left - right = 0
说明当前没有未匹配左括号。
所以不能再放:
)
因此:
right < left
本质上就是:
left - right > 0
即:
还有尚未闭合的左括号。
十、为什么不能只判断 right < n?
例如写:
if (right < n) {
...
}
虽然能保证右括号最多只有 n 个,但无法保证合法。
例如:
n = 3
left = 1
right = 1
如果只判断:
right < n
1 < 3
成立。
于是会继续放:
)
得到:
())
这已经非法。
所以右括号不能只看:
还有没有库存
而是必须看:
有没有左括号可以匹配
因此:
right < left
才是真正正确的判断。
十一、为什么是两个独立 if,而不是 if...else if?
代码:
if (left < n) {
...
}
if (right < left) {
...
}
不能随便写成:
if (left < n) {
...
} else if (right < left) {
...
}
因为这两个选择有时候可以同时成立。
例如:
n = 3
path = "("
left = 1
right = 0
现在:
left < n
1 < 3
true
所以可以放:
(
得到:
((
同时:
right < left
0 < 1
true
所以也可以放:
)
得到:
()
这里必须产生两个不同的递归分支:
"("
/ \
"((" "()"
如果写成:
if ... else if
第二个分支就没了。
因此:
回溯中的多个选择往往不是互斥关系,每个合法选择都要分别尝试。
十二、为什么 path 的位置是 left + right?
代码:
path[left + right] = '(';
或者:
path[left + right] = ')';
为什么数组下标是:
left + right
?
因为:
left
表示已经放了多少个左括号。
right
表示已经放了多少个右括号。
所以:
left + right
就是:
当前已经填了多少个字符。
例如:
left = 2
right = 1
说明已经填写:
3 个字符
数组已经占用了:
path[0]
path[1]
path[2]
那么下一格自然就是:
path[3]
也就是:
path[left + right]
十三、用 n = 2 跑完整棵搜索树
开始:
left = 0
right = 0
path = ""
此时:
left < n
成立,所以只能:
(
因为:
right < left
0 < 0
不成立。
于是:
""
|
"("
来到:
left = 1
right = 0
现在两个条件都成立:
left < n
1 < 2
可以放:
(
同时:
right < left
0 < 1
也可以放:
)
于是:
"("
/ \
"((" "()"
先走左边。
当前:
"(("
left = 2
right = 0
左括号已经用完:
left < n
false
只能放:
)
得到:
"(()
然后再放一个:
)
得到:
"(())"
第一个答案。
然后递归返回,走另一个分支:
"()"
此时:
left = 1
right = 1
不能放:
)
因为:
right < left
1 < 1
false
只能放:
(
得到:
"()("
然后:
"()()"
第二个答案。
所以完整搜索树:
""
|
"("
/ \
"((" "()"
| |
"(()" "()("
| |
"(())" "()()"
最终:
["(())", "()()"]
十四、这就是"剪枝"
如果不做限制,理论上:
""
下面可以直接产生:
"("
")"
但:
")"
显然已经非法。
我们的:
if (right < left)
在:
left = 0
right = 0
时直接判断:
0 < 0
false
所以:
")"
这一整棵子树都不会搜索。
同样:
"()"
以后不会产生:
"())"
因为又被:
right < left
剪掉。
所以:
剪枝就是在还没有走到最终结果以前,提前判断某条路径已经不可能合法,于是直接不搜索。
十五、标准 StringBuilder 回溯写法
普通回溯经常写成:
path.append('(');
dfs(...);
path.deleteCharAt(path.length() - 1);
完整逻辑:
做选择
↓
递归
↓
撤销选择
这就是经典回溯三步。
例如:
if (left < n) {
path.append('(');
dfs(left + 1, right, n, path, ans);
path.deleteCharAt(path.length() - 1);
}
然后尝试:
if (right < left) {
path.append(')');
dfs(left, right + 1, n, path, ans);
path.deleteCharAt(path.length() - 1);
}
十六、char\[\] 版本为什么没有 delete?
我的最终版本使用:
char[] path = new char[n * 2];
然后直接:
path[left + right] = '(';
或者:
path[left + right] = ')';
这里没有:
delete
所以一开始容易产生一个疑问:
是不是覆盖代替了回溯?
答案是:
不是。
更准确地说:
覆盖代替的是"显式撤销操作",不是回溯本身。
十七、什么才叫回溯?
例如当前:
path = "("
left = 1
right = 0
这里存在两个选择:
(
)
程序会先:
path[1] = '(';
dfs(...);
进入这一整棵子树。
搜索结束以后,递归函数返回上一层。
然后执行:
path[1] = ')';
dfs(...);
进入另一棵子树。
也就是说仍然发生了:
选择
↓
深入搜索
↓
返回上一层
↓
换另一个选择
这整个过程本身就是:
回溯
所以回溯并没有消失。
十八、覆盖到底代替了什么?
例如:
path = ['(', ?, ?, ?]
当前位置:
left = 1
right = 0
下一个位置:
left + right = 1
先尝试:
path[1] = '(';
数组变成:
['(', '(', ?, ?]
递归搜索结束以后回来。
此时:
path[1]
仍然是:
'('
但没关系。
因为如果接下来尝试右括号:
path[1] = ')';
它会直接覆盖。
变成:
['(', ')', ?, ?]
所以根本没必要先:
把 path[1] 恢复
再填写右括号。
下一次赋值自然把旧状态覆盖掉了。
因此:
StringBuilder:
append
↓
dfs
↓
delete
而:
char[]:
覆盖
↓
dfs
↓
回来以后直接再次覆盖
所以:
char[]固定位置写入,让回溯过程中"不需要显式恢复数组内容"。
十九、为什么 left 和 right 也不用恢复?
还有一个很容易忽略的问题。
比如:
dfs(left + 1, right, ...)
为什么递归回来以后不用:
left--;
?
因为 Java 中:
int
是值传递。
假设当前:
left = 1
right = 0
调用:
dfs(2, 0, ...)
子函数中的:
left = 2
只是新的局部参数。
不会修改上一层的:
left = 1
当子递归返回以后,当前层仍然是:
left = 1
right = 0
所以它们天然恢复。
这点非常重要。
二十、整个状态为什么能够正确恢复?
一个递归层的状态可以理解为:
当前层:
left
right
这两个是局部基本类型变量。
子递归:
dfs(left + 1, right, ...)
得到的是新的参数值。
返回以后:
当前层 left/right 没发生变化
而 path 虽然是共享数组,但下一个分支使用同一个位置时会:
直接覆盖
所以整个状态天然可以继续搜索其他分支。
二十一、因此这版实际上非常适合理解递归状态
StringBuilder 版本:
修改共享对象
↓
递归
↓
必须手动恢复共享对象
char\[\] 版本:
固定下标写入
↓
递归
↓
下一分支直接覆盖
这并不意味着:
没有回溯
而是:
没有显式撤销代码
这是两个完全不同的概念。
二十二、为什么这个版本对我更好理解?
StringBuilder 版本需要同时理解:
append()
deleteCharAt()
length()
还要理解:
为什么递归回来以后必须删除
而 char\[\] 版本只需要理解:
path[left + right] = '(';
或者:
path[left + right] = ')';
即:
当前第几位应该填什么。
对于括号生成这种:
最终长度固定
的问题,char[] 很自然。
二十三、但不是所有回溯都可以这样省略撤销
这一点必须注意。
这道题之所以可以:
直接覆盖
是因为:
每个递归深度对应一个固定数组位置
例如:
第 0 层写 path[0]
第 1 层写 path[1]
第 2 层写 path[2]
...
下一次走另一分支时:
还是覆盖当前层对应的位置
但是很多回溯题使用的是:
List<Integer> path
例如全排列:
path.add(nums[i]);
dfs(...);
path.remove(path.size() - 1);
这种情况下如果不删除:
path 长度会越来越长
旧状态就会真正影响新分支。
因此:
char[]覆盖是一种特殊情况下可以省略显式撤销的优化,并不是所有回溯都不需要撤销。
二十四、三个判断条件可以总结成三个问题
每次进入:
dfs(left, right, ...)
只需要问三个问题。
1. 完成了吗?
if (left == n && right == n)
如果完成:
保存答案
return
2. 左括号还有吗?
if (left < n)
如果有:
尝试 (
3. 有左括号可以关闭吗?
if (right < left)
如果有:
尝试 )
整个 DFS 就结束了。
二十五、可以理解成"库存 + 合法性"
左括号:
只需要考虑库存
所以:
left < n
右括号:
既要考虑库存
又要考虑合法性
但:
right < left
已经同时解决了这两个问题。
因为:
right < left <= n
自然有:
right < n
所以不需要额外再判断:
right < n
二十六、复杂度
这道题最终答案的数量是第 n 个 Catalan 数。
因此搜索规模不是普通的:
2^(2n)
因为大量非法分支已经被剪掉。
从面试角度,不需要强行背复杂 Catalan 数公式。
可以重点说明:
算法只枚举合法括号前缀,通过
right < left提前剪掉不可能合法的状态。
如果要更正式一点,结果数量是 Catalan 数:
C_n
每个答案长度为:
2n
因此构造所有答案至少需要与最终输出规模相当的时间。
额外递归栈深度:
O(n)
因为字符串最大长度是:
2n
数量级仍然是 O(n)。
二十七、面试怎么讲?
我会这样回答:
这道题使用回溯。用
left和right分别表示已经使用的左右括号数量。如果
left < n,说明左括号还有剩余,可以继续放左括号。如果
right < left,说明当前还有未匹配的左括号,因此可以放右括号。这个条件同时起到了剪枝作用,避免生成任何右括号数量超过左括号数量的非法前缀。当
left == n && right == n时,说明生成完成,将当前结果加入答案。我使用固定长度
2n的char[]保存路径,由于当前写入位置始终是left + right,不同分支返回后可以直接覆盖当前位置,所以不需要像 StringBuilder 版本一样显式删除最后一个字符,但递归搜索本身仍然属于回溯。
这段面试表达已经非常完整。
二十八、最容易写错的地方
1. 右括号条件写成 right < n
错误:
if (right < n)
这样会生成:
())
正确:
if (right < left)
因为右括号必须匹配已有左括号。
2. 把两个 if 写成 if...else if
错误:
if (left < n) {
...
} else if (right < left) {
...
}
因为两个选择可能同时成立。
正确:
if (left < n) {
...
}
if (right < left) {
...
}
3. path 下标不会写
直接记:
path[left + right]
因为:
left + right
=
当前已经放了多少个字符
4. 以为没有 delete 就不是回溯
错误理解:
没有撤销代码
=
不是回溯
正确:
递归进入一个选择
↓
返回上一层
↓
搜索另一个选择
本身就是回溯。
这里只是:
char[] 直接覆盖
省掉了显式状态恢复。
5. 忘记 new String(path)
path 是:
char[]
但答案要求:
List<String>
所以需要:
ans.add(new String(path));
二十九、面试前 30 秒速记
看到:
括号生成
想到:
回溯 + 剪枝
状态:
left = 已用 (
right = 已用 )
三个判断:
left == n && right == n
→ 完成
left < n
→ 可以放 (
right < left
→ 可以放 )
当前位置:
left + right
所以:
path[left + right]
核心代码:
private void dfs(
int left,
int right,
int n,
char[] path,
List<String> ans
) {
if (left == n && right == n) {
ans.add(new String(path));
return;
}
if (left < n) {
path[left + right] = '(';
dfs(left + 1, right, n, path, ans);
}
if (right < left) {
path[left + right] = ')';
dfs(left, right + 1, n, path, ans);
}
}
记忆口诀:
左括号看库存
右括号看有没有左括号可匹配
左没满 → 放 (
右比左少 → 放 )
左右都到 n → 收答案
三十、最终总结
这道题看起来是在生成括号,实际上真正训练的是三个东西:
递归状态设计
+
合法性剪枝
+
回溯搜索
最重要的状态只有:
left
right
最重要的两个约束是:
left <= n
right <= left
因此代码自然得到:
if (left < n)
可以放:
(
以及:
if (right < left)
可以放:
)
而:
left + right
就是当前应该填写的数组位置。
最后关于 char[] 版本最容易产生的误解:
覆盖代替了回溯
这是不准确的。
正确理解应该是:
递归进入一个分支、返回上一层、再搜索另一个分支,这个过程仍然是回溯。
只是由于:
char[] 的每个递归深度对应固定位置
所以新分支可以:
直接覆盖旧字符
从而省掉:
显式撤销操作
因此最终可以记成一句话:
char\[\] 覆盖没有代替回溯,只是代替了回溯中的显式撤销。
这也是这版写法最值得理解的地方。