【LeetCode Hot100 刷题日记 (59/100)】22. 括号生成 —— 字符串、回溯、递归、卡特兰数🧠

37 阅读5分钟

📌 题目链接:22. 括号生成 - 力扣(LeetCode)

🔍 难度:中等 | 🏷️ 标签:字符串、回溯、递归、卡特兰数

⏱️ 目标时间复杂度:O(4ⁿ / √n) (第 n 个卡特兰数的渐近上界)

💾 空间复杂度:O(n) (递归栈深度)


🧠 题目分析

本题要求生成 所有有效的括号组合,其中“有效”指的是:

  • 每个左括号 '(' 都有对应的右括号 ')'
  • 在任何前缀中,右括号的数量不能超过左括号的数量(即不能出现 ")(" 这样的非法前缀)。

输入为一个整数 n(1 ≤ n ≤ 8),表示要生成 n 对括号,即最终每个字符串长度为 2n

例如,当 n = 3 时,合法组合有 5 种,这恰好是 第 3 个卡特兰数 C₃ = 5

💡 卡特兰数(Catalan Number)小知识
第 n 个卡特兰数公式为:

Cn=1n+1(2nn)C_n = \frac{1}{n+1} \binom{2n}{n}

它广泛应用于:合法括号序列、二叉树结构计数、出栈序列、不交叉连线等问题。
在本题中,有效括号组合的总数 = Cₙ


🔁 核心算法及代码讲解 —— 回溯法(Backtracking)

虽然暴力法可以生成所有 2^(2n) 种可能再验证,但效率极低(指数爆炸)。回溯法通过剪枝提前排除非法路径,是本题最优解法

✅ 回溯的核心思想:

我们维护两个计数器:

  • open:已使用的左括号数量;
  • close:已使用的右括号数量。

在每一步决策中,遵循以下规则:

  1. open < n → 可以添加 '('(因为最多只能有 n 个左括号);
  2. close < open → 可以添加 ')'(右括号不能超过左括号,否则非法)。

这样,每一步都保证当前字符串前缀是“潜在合法”的,从而避免无效搜索。

🧩 为什么这样能覆盖所有解?

  • 所有合法序列必然以 '(' 开头;
  • 每次添加括号都满足“左 ≥ 右”且“左 ≤ n”,因此最终一定能构造出所有 Cₙ 种组合;
  • 递归到 cur.size() == 2n 时,open == close == n,自然合法,无需额外验证。

💻 C++ 代码详解(带行注释)

void backtrack(vector<string>& ans, string& cur, int open, // 已用左括号数
               int close, // 已用右括号数
               int n) {   // 总对数
    // 🎯 终止条件:字符串长度达到 2n
    if (cur.size() == n * 2) {
        ans.push_back(cur); // 当前组合合法,加入结果集
        return;
    }
    
    // ✅ 剪枝1:左括号未用完,可加 '('
    if (open < n) {
        cur.push_back('(');
        backtrack(ans, cur, open + 1, close, n); // 递归
        cur.pop_back(); // 回溯:恢复现场
    }
    
    // ✅ 剪枝2:右括号少于左括号,可加 ')'
    if (close < open) {
        cur.push_back(')');
        backtrack(ans, cur, open, close + 1, n);
        cur.pop_back(); // 回溯
    }
}

🔍 面试高频考点

  • 回溯三要素:选择(add)、递归、撤销(pop);
  • 剪枝条件设计:如何保证每一步都合法?——这是回溯优化的关键;
  • 空间复用:使用引用 string& cur 避免频繁拷贝,提升性能。

🧭 解题思路(分步拆解)

  1. 初始化:创建空结果列表 result 和当前路径字符串 current

  2. 启动回溯:从 open = 0, close = 0 开始;

  3. 递归构建

    • 若还能加左括号(open < n),就加 '(' 并递归;
    • 若右括号数量小于左括号(close < open),就加 ')' 并递归;
  4. 到达叶子节点:当字符串长度为 2n,说明已形成完整合法序列,加入结果;

  5. 回溯恢复:每次递归返回后,弹出最后添加的字符,尝试其他分支。

🌟 关键洞察
不需要检查整个字符串是否合法!通过构造过程保证合法性,这是回溯优于暴力法的根本原因。


📊 算法分析

项目分析
时间复杂度O(4ⁿ / √n) —— 即第 n 个卡特兰数的渐近上界。每个合法序列需 O(n) 时间复制到结果数组。
空间复杂度O(n) —— 递归栈最大深度为 2n,每层 O(1) 空间;结果数组不计入额外空间。
剪枝效果暴力法需遍历 2^(2n) = 4ⁿ 种可能,而回溯只遍历 Cₙ ≈ 4ⁿ / (n√n) 种,指数级优化

💬 面试官可能会问

  • “为什么不用 BFS?” → 回溯天然适合构造型问题,BFS 需要存储大量中间状态,内存开销大。
  • “能否用动态规划?” → 可以(如方法三的分治+记忆化),但回溯更直观、代码更简洁。
  • “n=8 时有多少种?” → C₈ = 1430,可快速回答体现数学素养。

💻 完整代码

✅ C++ 实现

#include <bits/stdc++.h>
using namespace std;
using ll = long long;

class Solution {
public:
    void backtrack(vector<string>& ans, string& cur, int open, int close, int n) {
        if (cur.size() == n * 2) {
            ans.push_back(cur);
            return;
        }
        if (open < n) {
            cur.push_back('(');
            backtrack(ans, cur, open + 1, close, n);
            cur.pop_back();
        }
        if (close < open) {
            cur.push_back(')');
            backtrack(ans, cur, open, close + 1, n);
            cur.pop_back();
        }
    }

    vector<string> generateParenthesis(int n) {
        vector<string> result;
        string current;
        backtrack(result, current, 0, 0, n);
        return result;
    }
};

// 测试
signed main(){
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout.tie(nullptr);

    Solution sol;
    
    // 🧪 测试用例 1
    int n1 = 3;
    auto res1 = sol.generateParenthesis(n1);
    cout << "Input: n = " << n1 << "\n";
    cout << "Output: [";
    for (int i = 0; i < res1.size(); ++i) {
        cout << """ << res1[i] << """;
        if (i != res1.size() - 1) cout << ",";
    }
    cout << "]\n\n";

    // 🧪 测试用例 2
    int n2 = 1;
    auto res2 = sol.generateParenthesis(n2);
    cout << "Input: n = " << n2 << "\n";
    cout << "Output: ["" << res2[0] << ""]\n";

    return 0;
}

✅ JavaScript 实现

/**
 * @param {number} n
 * @return {string[]}
 */
var generateParenthesis = function(n) {
    const result = [];
    
    function backtrack(cur, open, close) {
        // 终止条件
        if (cur.length === 2 * n) {
            result.push(cur);
            return;
        }
        
        // 尝试加左括号
        if (open < n) {
            backtrack(cur + '(', open + 1, close);
        }
        
        // 尝试加右括号(必须满足 close < open)
        if (close < open) {
            backtrack(cur + ')', open, close + 1);
        }
    }
    
    backtrack('', 0, 0);
    return result;
};

// 🧪 测试
console.log("Input: n = 3");
console.log("Output:", generateParenthesis(3));

console.log("Input: n = 1");
console.log("Output:", generateParenthesis(1));

💡 JS 版本使用字符串拼接(不可变),无需手动回溯,但空间开销略大;C++ 使用可变字符串 + 引用,更高效。


🌟 本期完结,下期见!🔥

👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!

💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪

📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!