LEETCODE 022Medium
括号生成
合法性可以在构造过程中步步维持——左括号没用完就能加,右括号少于左括号才敢加,剪枝后每条路径都通向合法解。
问题拆解
生成所有由 n 对括号组成的合法序列。暴力做法是枚举 2n 个位置上每个位置放 ( 还是 ) 的全部 2^{2n} 种组合,再逐个验证合法性——绝大多数组合在前几个字符就已经废了,比如以 ) 开头的全军覆没,验证纯属浪费。
合法括号序列有个逐前缀的刻画:任何前缀里右括号数都不能超过左括号数,且最终两者都等于 n。这个条件是每一步局部可检查的,这正是回溯最喜欢的结构——不用等串拼完才验证,摆每个字符时就能判断这条路还有没有救。
维护两个计数 open 和 close:
open < n时可以放左括号(还有存货);close < open时可以放右括号(有未配对的左括号等着它)。守住这两条,走到长度2n的每条路径都自动合法。
open/close 计数回溯
public List<String> generateParenthesis(int n) {
List<String> result = new ArrayList<>();
backtrack(result, new StringBuilder(), 0, 0, n);
return result;
}
private void backtrack(List<String> result, StringBuilder path, int open, int close, int n) {
if (path.length() == 2 * n) {
result.add(path.toString()); // 剪枝保证到这里必然合法
return;
}
if (open < n) { // 左括号还有存货
path.append('(');
backtrack(result, path, open + 1, close, n);
path.deleteCharAt(path.length() - 1); // 撤销选择
}
if (close < open) { // 有未配对的左括号
path.append(')');
backtrack(result, path, open, close + 1, n);
path.deleteCharAt(path.length() - 1);
}
}
def generateParenthesis(n: int) -> list[str]:
result = []
path = []
def backtrack(open_count: int, close_count: int) -> None:
if len(path) == 2 * n:
result.append("".join(path)) # 剪枝保证到这里必然合法
return
if open_count < n: # 左括号还有存货
path.append("(")
backtrack(open_count + 1, close_count)
path.pop() # 撤销选择
if close_count < open_count: # 有未配对的左括号
path.append(")")
backtrack(open_count, close_count + 1)
path.pop()
backtrack(0, 0)
return result
func generateParenthesis(n int) []string {
result := []string{}
path := make([]byte, 0, 2*n)
var backtrack func(open, close int)
backtrack = func(open, close int) {
if len(path) == 2*n {
result = append(result, string(path)) // 剪枝保证到这里必然合法
return
}
if open < n { // 左括号还有存货
path = append(path, '(')
backtrack(open+1, close)
path = path[:len(path)-1] // 撤销选择
}
if close < open { // 有未配对的左括号
path = append(path, ')')
backtrack(open, close+1)
path = path[:len(path)-1]
}
}
backtrack(0, 0)
return result
}
pub fn generate_parenthesis(n: i32) -> Vec<String> {
fn backtrack(result: &mut Vec<String>, path: &mut String, open: i32, close: i32, n: i32) {
if path.len() as i32 == 2 * n {
result.push(path.clone()); // 剪枝保证到这里必然合法
return;
}
if open < n { // 左括号还有存货
path.push('(');
backtrack(result, path, open + 1, close, n);
path.pop(); // 撤销选择
}
if close < open { // 有未配对的左括号
path.push(')');
backtrack(result, path, open, close + 1, n);
path.pop();
}
}
let mut result = Vec::new();
backtrack(&mut result, &mut String::new(), 0, 0, n);
result
}
两个条件里更容易写错的是第二个:close < open 而不是 close < n。后者只保证右括号总数不超标,挡不住 )( 这种前缀非法的序列——右括号能不能放,取决于此刻有没有落单的左括号,而不是配额还剩多少。第一个条件写成 open < n 则天然保证左括号不超发,两条合在一起,到达 2n 长度时必有 open == close == n,终点无需再验证。
递归返回后要撤销刚才的追加(pop / 删末字符),让 path 回到进入前的状态,兄弟分支才能在干净的前缀上继续——“做选择、递归、撤销”这三拍是回溯的节奏本身。解的个数是卡特兰数 C(n) = C(2n, n) / (n + 1),剪枝后搜索树的节点数与解数同阶,没有一步走进死路。
复杂度
| 指标 | 复杂度 | 原因 |
|---|---|---|
| 时间 | O(4^n / √n) |
第 n 个卡特兰数的量级,每个解拷贝 O(n) |
| 空间 | O(n) |
递归深度与 path 长度都是 2n(输出不计) |
可以迁移的模式
- 生成类问题优先找“逐前缀可判定”的合法条件,边构造边剪枝,而不是生成后过滤;
- 用少量计数器(这里是 open/close)概括前缀状态,比携带整个字符串判断更省;
- 回溯三拍:追加、递归、撤销,共享的 path 一定要在返回后复原。
凡是解空间是“一步步做选择”长出来的树,并且坏选择能提前识别,这套计数回溯就能直接搬过去。