题目

数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。
数据范围
1 <= n <= 8
测试用例
示例1
bash
输入:n = 3
输出:["((()))","(()())","(())()","()(())","()()()"]
示例2
bash
输入:n = 1
输出:["()"]
题解1(博主题解,时间复杂度O(4nn)O(\frac{4^n}{\sqrt{n}})O(n 4n),空间复杂度ON)
java
class Solution {
// 全局变量,用于在递归过程中跟踪左括号 '(' 的数量
int leftNum = 0;
// 全局变量,用于在递归过程中跟踪右括号 ')' 的数量
int rightNum = 0;
// 用于存储所有生成的有效括号组合结果
List<String> res;
public List<String> generateParenthesis(int n) {
res = new ArrayList<>();
// 从 0 个字符开始递归,初始字符串为空
dfs(n, 0, new StringBuilder());
return res;
}
/**
* 深度优先搜索函数
* @param n 题目要求的括号对数
* @param curr 当前构建的字符串长度 (也是递归深度)
* @param sb 当前路径构建的字符串
*/
public void dfs(int n, int curr, StringBuilder sb) {
// [剪枝条件]
// 如果在任何时候,右括号数量超过了左括号,这一定是非法的(例如 "())"),直接停止该路径的搜索
if (leftNum < rightNum) {
return;
}
// [递归终止条件 / Base Case]
// 当构建的字符串长度达到 2*n 时(即填满了所有位置)
if (curr == 2 * n) {
// 只有当左括号数量等于右括号数量时,才是一个有效的解
if (leftNum == rightNum) {
res.add(sb.toString());
}
return; // 结束当前递归分支
}
// [递归逻辑]
// 循环两次,i=0 尝试添加左括号,i=1 尝试添加右括号
// 注意:这种写法虽然逻辑正确,但比通常的写法稍微低效,因为它会尝试添加超过 n 个的左括号,直到最后才被淘汰
for (int i = 0; i < 2; i++) {
if (i == 0) {
// --- 尝试添加左括号 ---
sb.append("("); // 做出选择:添加 '('
leftNum++; // 更新状态:左括号计数 +1
dfs(n, curr + 1, sb); // 进入下一层递归
// --- 回溯 (Backtracking) ---
// 撤销选择:当递归返回后,需要将状态恢复到进入递归之前,以便进行下一次循环尝试
leftNum--; // 恢复状态:左括号计数 -1
sb.delete(curr, curr + 1); // 撤销选择:删除刚才添加的 '('
}
else {
// --- 尝试添加右括号 ---
sb.append(")"); // 做出选择:添加 ')'
rightNum++; // 更新状态:右括号计数 +1
dfs(n, curr + 1, sb); // 进入下一层递归
// --- 回溯 (Backtracking) ---
rightNum--; // 恢复状态:右括号计数 -1
sb.delete(curr, curr + 1); // 撤销选择:删除刚才添加的 ')'
}
}
}
}
题解2(官解,时空同上)
java
class Solution {
public List<String> generateParenthesis(int n) {
List<String> ans = new ArrayList<String>();
backtrack(ans, new StringBuilder(), 0, 0, n);
return ans;
}
public void backtrack(List<String> ans, StringBuilder cur, int open, int close, int max) {
if (cur.length() == max * 2) {
ans.add(cur.toString());
return;
}
if (open < max) {
cur.append('(');
backtrack(ans, cur, open + 1, close, max);
cur.deleteCharAt(cur.length() - 1);
}
if (close < open) {
cur.append(')');
backtrack(ans, cur, open, close + 1, max);
cur.deleteCharAt(cur.length() - 1);
}
}
}
题解3(官解2 按括号序列的长度递归,时间同上,空间同时间)
java
class Solution {
ArrayList[] cache = new ArrayList[100];
public List<String> generate(int n) {
if (cache[n] != null) {
return cache[n];
}
ArrayList<String> ans = new ArrayList<String>();
if (n == 0) {
ans.add("");
} else {
for (int c = 0; c < n; ++c) {
for (String left: generate(c)) {
for (String right: generate(n - 1 - c)) {
ans.add("(" + left + ")" + right);
}
}
}
}
cache[n] = ans;
return ans;
}
public List<String> generateParenthesis(int n) {
return generate(n);
}
}
思路
这道题暴力搜索不在我们的考虑范围之内,我们直接选择回溯,回溯方法很简单,java存在stringbuilder与stringbuffer字符串操作也很轻松。是一个较为轻松的题,博主的回溯法与官解的回溯法大体思路一样,但是官解的细节处理要好一些,博主的方法会多进一次递归,虽然影响不大,但总归是没有官解的好。
这道题还有按照括号序列的长度递归这个方法,采用了分治的思路,由于性能不错,大家也可以学习学习,由于博主分治还不是很熟练,就不讲解这个方法的思路了,直接贴上ai讲解!!
这份官方题解的代码采用了一种完全不同的思路:分治算法(Divide and Conquer) 结合 记忆化搜索(Memoization / Dynamic Programming)。这种方法的数学核心是:任何一个合法的括号序列,都可以被拆分为 ( A ) B 的形式。
- 核心逻辑:( left ) right不同于之前的"回溯法"是一个字符一个字符地拼凑,这份代码是成块成块地组装括号。对于任何一个长度为 nnn 的合法括号序列,它一定以 ( 开头。这个起始的 ( 一定有一个与之匹配的 )。我们可以把这个序列看作:( 内部 ) 外部(\ \text{内部} \ ) \ \text{外部}( 内部 ) 外部代码中的这一行是灵魂:
java
ans.add("(" + left + ")" + right);
left (内部):是在那一对括号 里面 的合法序列。right (外部):是在那一对括号 后面 的合法序列。数量关系:总共需要 nnn 对括号。最外层已经用掉了 1 对 ( ... )。假设我们在 内部 放了 ccc 对括号。那么 外部 剩下的就必须是 n−1−cn - 1 - cn−1−c 对括号。
- 代码逐行详解A. 缓存(Memoization)
java
ArrayList[] cache = new ArrayList[100];
public List<String> generate(int n) {
if (cache[n] != null) {
return cache[n];
}
// ... 计算过程 ...
cache[n] = ans; // 计算完后存入缓存
return ans;
}
作用:这是一个"备忘录"。因为计算 generate(3) 时可能会用到 generate(2),计算 generate(4) 时也会用到 generate(2)。
避免重复计算:如果不存下来,程序会反复计算相同的 nnn,效率会非常低。有了 cache,每个 nnn 只会被计算一次。
B. 递归与分治
java
if (n == 0) {
ans.add("");
} else {
// c 代表括号对内部的括号数量
for (int c = 0; c < n; ++c) {
// 遍历内部所有可能的组合 (left)
for (String left: generate(c)) {
// 遍历外部所有可能的组合 (right)
// 外部的数量 = 总数 n - 1 (外层那对) - c (内部占用的)
for (String right: generate(n - 1 - c)) {
// 组合起来:(内部)外部
ans.add("(" + left + ")" + right);
}
}
}
}
这里的双重循环 for (String left...) 和 for (String right...) 是一个笛卡尔积。它把所有可能的"内部结构"和"外部结构"拼在一起。
- 举例演示 (n=3n=3n=3)
我们需要生成 3 对括号的所有组合。公式:( left ) right,其中 left 有 ccc 对,right 有 3−1−c3-1-c3−1−c 对。
情况 1:c=0c=0c=0 (内部 0 对,外部 2 对)
left: "" (0对)
right: generate(2) 的结果 -> ()(), (())
组合:
( + "" + ) + ()() = ()()()
( + "" + ) + (()) = ()(())
情况 2:c=1c=1c=1 (内部 1 对,外部 1 对)
left: generate(1) -> ()
right: generate(1) -> ()
组合:
( + () + ) + () = (())()
情况 3:c=2c=2c=2 (内部 2 对,外部 0 对)
left: generate(2) -> ()(), (())
right: "" (0对)
组合:
( + ()() + ) + "" = (()())
( + (()) + ) + "" = ((()))
结果:()()(), ()(()), (())(), (()()), ((())) (共5种,完全正确)
- 复杂度分析时间复杂度:依然是卡特兰数级别 O(4nn)O(\frac{4^n}{\sqrt{n}})O(n 4n)。因为无论怎么算,结果的总数没变。空间复杂度:由于使用了 cache 数组存储所有中间结果,以及大量的字符串拼接对象,空间消耗通常比回溯法要大一些,但逻辑结构更清晰(特别是对于理解动态规划而言)。总结上一份代码 (回溯法) 是在"走路",一步步试探,撞墙回头。这份代码 (分治/动态规划) 是在"搭积木"。它把大问题拆成 (左边) 和 右边 两个小问题,分别解决后再拼起来。这份代码的优势在于逻辑非常符合卡特兰数的递归定义公式:Cn=∑i=0n−1CiCn−1−iC_n = \sum_{i=0}^{n-1} C_i C_{n-1-i}Cn=i=0∑n−1CiCn−1−i如果你需要解决类似的数学构造问题,这种分治思维非常重要。