文章目录
前言
本专栏文章为《LeetCode 热题 100》的刷题题解,相关内容如有侵权,立即删除。
一、题目
1、原题链接
2、题目描述
二、个人思路整理
1、思路分析
核心思路是回溯+剪枝
如果暴力枚举所有可能的括号组合(长度为 2 n 2n 2n,每个位置选 ( 或 )),会有 2 2 n 2^{2n} 22n 种可能,效率极低。
有效的括号序列必须满足两个关键约束:
- 左括号总数不能超过 n n n。
- 在任意前缀中,右括号数量必须小于等于左括号数量(即已放的右括号不能多于左括号,否则一定无法配对)。
因此,从空字符串开始逐步添加字符:
- 只要 已放置的左括号数 < n n n,就可以放
(。 - 只要 已放置的右括号数 < 已放置的左括号数,就可以放
)。 - 当左、右括号数都达到 n n n(即字符串长度达到 2 n 2n 2n)时,即得到一个合法的括号组合。
2、解题代码
cpp
class Solution {
private:
void backtack(int n, int left, int right, string& current, vector<string>& res) {
// 递归终止条件:当放置的字符总长度达到 2 * n 时,说明生成了一个完整的有效组合
if (current.size() == 2 * n) {
res.push_back(current);
return;
}
// 剪枝策略 1:只要左括号尚未用完,就可以尝试放入 '('
if (left < n) {
current.push_back('('); // 做选择
backtack(n, left + 1, right, current, res); // 递归进入下一层
current.pop_back(); // 撤销选择(回溯)
}
// 剪枝策略 2:只有当已放置的右括号少于左括号时,放入 ')' 才能保证合法配对
if (right < left) {
current.push_back(')'); // 做选择
backtack(n, left, right + 1, current, res); // 递归进入下一层
current.pop_back(); // 撤销选择(回溯)
}
}
public:
vector<string> generateParenthesis(int n) {
vector<string> res;
string current;
// 初始时已使用的左、右括号数均为 0
backtack(n, 0, 0, current, res);
return res;
}
};
复杂度分析
- 时间复杂度: O ( 4 n n ) O(\frac{4^n}{\sqrt{n}}) O(n 4n)。合法组合的总数对应第 n n n 个卡特兰数 C n = 1 n + 1 ( 2 n n ) C_n = \frac{1}{n+1}\binom{2n}{n} Cn=n+11(n2n),渐近复杂度为 O ( 4 n n n ) O(\frac{4^n}{n\sqrt{n}}) O(nn 4n),生成每个合法串需要 O ( n ) O(n) O(n) 时间。
- 空间复杂度: O ( n ) O(n) O(n)。递归调用栈的最大深度为 2 n 2n 2n(不计返回值占用的空间)。
三、知识风暴
回溯算法(Backtracking) 是本题的核心思想。它通过"做选择 → 递归 → 撤销选择"的框架,在解空间树上深度优先搜索所有可行解。理解回溯的决策树模型与剪枝思想,对掌握本题至关重要。
算法核心思想:
- 决策树建模 :本题可以抽象为一棵多叉决策树,树的深度为 2 n 2n 2n,每一层对应一个待放置的括号位置,每个节点代表"当前已构造的括号前缀"。从根到叶子节点的每一条路径,就是一个完整的括号序列。
- 回溯本质 :深度优先遍历决策树,在进入下一层前"做选择"(放入
(或)),从当前层返回时"撤销选择"(current.pop_back()),从而保证每条分支都能在共享的状态上独立探索,互不干扰。 - 结束标记区分 :本题收集的是决策树的叶子节点,即当
current.size() == 2 * n时收集当前current。与子集问题"收集所有节点"不同,本题必须等左右括号都恰好用完时才收集。
常见对比:回溯 vs 暴力枚举
- 回溯(Backtracking):通过递归 + 状态撤销,在解空间树上剪枝,避免重复计算中间状态。适合排列、组合、子集等"决策型"问题。
- 暴力枚举(多重循环) :若括号长度固定,可用多层
for循环直接枚举所有组合;但长度为 2 n 2n 2n 时循环层数随 n n n 变化,代码难以扩展。 - 共同点:两者最终都能生成全部组合。区别在于回溯能显式控制每一步的选择与撤销,且代码与输入长度无关,而多重循环则受限于固定层数。
"状态撤销"标记思想:
- 核心思想 :递归进入下一层前修改共享状态(如将
(或)拼入current),返回后立即恢复原状(current.pop_back())。这样同一层的不同分支之间不会相互污染。 - 与本题的联系 :本题用
current.push_back('(')做选择、current.pop_back()撤销选择,本质就是"做选择 → 递归 → 撤销选择"的体现。 - 注意事项:撤销操作必须与选择操作严格对称。若只做选择而不撤销,会导致后续分支看到被污染的状态,从而漏解或产生错误结果。
使用要点:
- 递归终止条件 :当
current.size() == 2 * n时,说明一条完整括号序列已经构建完毕,将其加入结果集并返回。 - 单层遍历逻辑 :每层最多有两个分支------若
left < n则放入(;若right < left则放入)。递归调用backtack(n, left + 1, right, current, res)或backtack(n, left, right + 1, current, res),返回后撤销选择。 - 剪枝的对称性 :
current.push_back(...)与current.pop_back()必须成对出现,确保恢复现场;同时利用"右括号数不能超过左括号数"这一约束提前剪枝,避免生成非法序列。 - 结果集存储 :
res.push_back(current)时,容器会拷贝一份当前状态,因此后续修改current不会影响已保存的结果。
算法变体与扩展:
- 子集(LeetCode 78):收集决策树的所有节点,而非仅叶子节点,与本题"只收集叶子节点"形成对比。
- 组合总和(LeetCode 39) :在决策树上做"选或不选"的剪枝,与本题"每层按约束选
(或)"的框架类似。 - 全排列(LeetCode 46):排列问题收集的是决策树的叶子节点,且每个元素必须全部选用,与本题"每层放一个括号"的框架类似。
- 单词搜索(LeetCode 79):在网格中 DFS 匹配单词,用访问标记避免重复走格,是回溯与图遍历的结合。
相关 LeetCode 例题:
- 78. 子集(回溯 + 收集所有节点)
- 39. 组合总和(回溯 + 组合剪枝)
- 40. 组合总和 II(回溯 + 去重剪枝)
- 46. 全排列(回溯 + 排列收集)
- 79. 单词搜索(回溯 + 网格 DFS)
