欢迎光临
我们一直在努力

力扣热题100实战 | 第22期:括号生成——回溯算法的进阶应用

力扣热题100实战 | 第22期:括号生成——回溯算法的进阶应用

    • 前言
    • 一、题目:生成所有有效的括号组合
      • 关键点解读
    • 二、第一反应:暴力枚举(超时但思路正确)
      • 核心思想
      • 代码实现
      • 复杂度分析
      • 这段代码的问题在哪?
    • 三、核心解法:回溯算法 + 剪枝(面试标准答案)
      • 为什么需要剪枝?
      • 两个关键的剪枝条件
      • 算法步骤
      • 图解流程
      • 代码实现(String版,推荐)
      • 代码实现(StringBuilder版,更高效)
      • 代码解析
      • 复杂度分析
    • 四、两种剪枝条件的深度对比
    • 五、细节剖析:面试官真正关心的问题
      • Q1:为什么用剩余数量 `left` 和 `right`,而不是已使用数量?
      • Q2:剪枝条件 `right > left` 是怎么推导出来的?
      • Q3:String 版和 StringBuilder 版哪个更好?
      • Q4:为什么不用 StringBuffer?
      • Q5:n=0 时为什么返回 `[""]` 而不是空列表?
    • 六、面试官追问进阶版
      • 追问1:如何用动态规划实现括号生成?
      • 追问2:如何输出所有括号组合的长度?
      • 追问3:如何验证一个括号字符串是否有效(不用栈)?
      • 追问4:如果要求生成所有可能的括号组合(包括无效的),怎么改?
    • 七、实际开发:这道题到底有什么用?
      • 场景1:编译器语法检查
      • 场景2:数据库查询优化
      • 场景3:二叉树遍历的序列化
      • 场景4:数学表达式求值
      • 场景5:正则表达式匹配
    • 八、总结:从一道题到一类题
    • 附录:思考题

如果说电话号码的字母组合是回溯算法的入门,那么括号生成就是回溯算法的进阶——它不仅需要枚举所有可能,还要在枚举过程中动态维护合法性。这种“边构建边验证”的思想,正是回溯算法的精髓所在。

前言

你好,我是@礼拜天没时间。

上一期我们学习了合并两个有序链表,掌握了链表操作的基础技巧。这一期,我们回到回溯算法专题,来解一道比“电话号码的字母组合”更进一阶的题目——括号生成(LeetCode 第22题)。

这道题在力扣上被标记为“中等”,但它的难度其实不在于代码的复杂度,而在于如何在递归过程中维护括号的有效性。很多同学第一次接触这道题时,要么生成的括号数量不对,要么生成了无效的括号序列,要么代码写得冗长复杂。

今天,我希望能带你从暴力枚举的思路出发,一步步推导出带有剪枝的回溯解法,并理解回溯算法中“剪枝”这个核心概念。


一、题目:生成所有有效的括号组合

先看题目描述(LeetCode 第22题):

数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。

示例 1:

输入:n = 3
输出:["((()))","(()())","(())()","()(())","()()()"]

示例 2:

输入:n = 1
输出:["()"]

示例 3:

输入:n = 0
输出:[""]

关键点解读

  • 有效括号的定义:

    • 左括号必须以正确的顺序闭合
    • 任何一个前缀中,左括号数量 ≥ 右括号数量
    • 最终左括号总数 = 右括号总数 = n
  • 组合数量:对于 n 对括号,有效组合的数量是卡特兰数(Catalan number),公式为 C(2n, n) / (n + 1) 。例如:

    • n=1:1种
    • n=2:2种
    • n=3:5种
    • n=4:14种
  • 数据范围:题目中 n 的范围通常是 1 到 8,所以即使是指数级算法也能接受,但优秀的解法仍然是必要的 。

  • 空串处理:当 n=0 时,返回包含一个空字符串的列表。


  • 二、第一反应:暴力枚举(超时但思路正确)

    当我第一次看到这道题,我的第一反应是:生成所有可能的括号序列,然后筛选出有效的 。

    核心思想

  • 一共有 2n 个位置,每个位置可以放 ( 或 ),所以总共有 2^(2n) 种组合
  • 生成所有组合后,逐个检查是否有效
  • 检查有效性的方法:遍历字符串,维护一个计数器,遇到 ( 加1,遇到 ) 减1,过程中计数器不能为负,最后计数器必须为0
  • 代码实现

    class Solution {
    public List<String> generateParenthesis(int n) {
    List<String> result = new ArrayList<>();
    if (n == 0) {
    result.add("");
    return result;
    }

    // 生成所有可能的序列
    generateAll(new char[2 * n], 0, result);
    return result;
    }

    private void generateAll(char[] current, int pos, List<String> result) {
    if (pos == current.length) {
    // 检查是否有效
    if (isValid(current)) {
    result.add(new String(current));
    }
    return;
    }

    // 尝试放左括号
    current[pos] = '(';
    generateAll(current, pos + 1, result);

    // 尝试放右括号
    current[pos] = ')';
    generateAll(current, pos + 1, result);
    }

    private boolean isValid(char[] current) {
    int balance = 0;
    for (char c : current) {
    if (c == '(') {
    balance++;
    } else {
    balance;
    }
    if (balance < 0) {
    return false;
    }
    }
    return balance == 0;
    }
    }

    复杂度分析

    • 时间复杂度:O(2^(2n) × n) —— 需要生成 2^(2n) 种组合,每种组合需要 O(n) 时间检查有效性
    • 空间复杂度:O(2n) —— 递归栈深度和字符数组的空间

    这段代码的问题在哪?

  • 效率太低:n=3 时,需要生成 2^6=64 种组合,其中只有5种有效,有效率不到8%
  • 大量无效计算:很多序列在生成早期就能判断无效,但我们仍然要生成完整才检查
  • 不符合回溯思想:真正的回溯应该边构建边剪枝,而不是先生成再筛选

  • 三、核心解法:回溯算法 + 剪枝(面试标准答案)

    为什么需要剪枝?

    在生成括号的过程中,我们可以利用有效括号的性质,提前终止那些不可能形成有效解的递归分支 。

    两个关键的剪枝条件

  • 左括号数量不能超过 n:如果我们已经放了 n 个左括号,就不能再放左括号了
  • 右括号数量不能超过左括号数量:任何时刻,已经放置的右括号数量必须 ≤ 左括号数量,否则序列无效
  • 算法步骤

  • 定义状态:用两个变量 left 和 right 分别表示还可以使用的左括号和右括号数量

    • 初始时 left = n,right = n
    • 每放一个左括号,left – 1
    • 每放一个右括号,right – 1
  • 递归终止条件:当 left == 0 && right == 0 时,得到一个有效组合,加入结果集

  • 递归分支:

    • 如果 left > 0:可以放一个左括号
    • 如果 right > left:可以放一个右括号(因为剩余的右括号数量必须大于剩余的左括号数量,才能保证过程中不会出现右括号多于左括号的情况)
  • 图解流程

    以 n = 3 为例,递归树如下(括号内表示剩余的左右括号数) :

    root (3,3)
    / \\
    (2,3) ×(不能放右括号)
    / \\
    (1,3) (2,2)
    / \\ / \\
    (0,3) (1,2) (1,2) (2,1)
    | / \\ / \\ |
    … … … … …

    通过剪枝,我们只探索那些可能形成有效解的分支,大大减少了搜索空间。

    代码实现(String版,推荐)

    class Solution {
    public List<String> generateParenthesis(int n) {
    List<String> result = new ArrayList<>();
    if (n == 0) {
    result.add("");
    return result;
    }

    backtrack(result, "", n, n);
    return result;
    }

    /**
    * 回溯生成括号组合
    * @param result 结果集
    * @param current 当前构建的字符串
    * @param left 剩余可用的左括号数量
    * @param right 剩余可用的右括号数量
    */

    private void backtrack(List<String> result, String current, int left, int right) {
    // 终止条件:所有括号都用完了
    if (left == 0 && right == 0) {
    result.add(current);
    return;
    }

    // 剪枝条件1:剩余左括号大于0,可以放左括号
    if (left > 0) {
    backtrack(result, current + "(", left 1, right);
    }

    // 剪枝条件2:剩余右括号大于剩余左括号,才能放右括号
    // 因为当前序列中已有的左括号数 = n – left,已有的右括号数 = n – right
    // 需要保证已有的右括号数 < 已有的左括号数,即 n – right < n – left => left < right
    // 所以 right > left 是放右括号的必要条件
    if (right > left) {
    backtrack(result, current + ")", left, right 1);
    }
    }
    }

    代码实现(StringBuilder版,更高效)

    class Solution {
    public List<String> generateParenthesis(int n) {
    List<String> result = new ArrayList<>();
    if (n == 0) {
    result.add("");
    return result;
    }

    backtrack(result, new StringBuilder(), n, n);
    return result;
    }

    private void backtrack(List<String> result, StringBuilder current, int left, int right) {
    if (left == 0 && right == 0) {
    result.add(current.toString());
    return;
    }

    if (left > 0) {
    current.append('(');
    backtrack(result, current, left 1, right);
    current.deleteCharAt(current.length() 1); // 回溯撤销
    }

    if (right > left) {
    current.append(')');
    backtrack(result, current, left, right 1);
    current.deleteCharAt(current.length() 1); // 回溯撤销
    }
    }
    }

    代码解析

  • 参数设计:用 left 和 right 表示剩余的括号数量,比用已使用的数量更直观

  • 剪枝条件 right > left 的理解 :

    • 剩余右括号数量 > 剩余左括号数量,意味着已经使用的左括号数量 > 已经使用的右括号数量
    • 这是放右括号的必要条件,保证了序列的合法性
  • String vs StringBuilder:

    • String版:每次递归创建新字符串,代码简洁但性能稍差
    • StringBuilder版:复用同一个对象,需要手动回溯(deleteCharAt),性能更好但代码稍复杂
  • 回溯撤销的重要性 :在StringBuilder版本中,如果不撤销选择,下一次递归会在错误的字符串基础上继续构建,导致结果错误。

  • 复杂度分析

    • 时间复杂度:O(4^n / √n),这是第 n 个卡特兰数的渐近复杂度
    • 空间复杂度:O(n),递归栈的深度最大为 2n

    四、两种剪枝条件的深度对比

    为了帮助读者更好地理解,我将两个剪枝条件的作用和意义总结如下:

    剪枝条件代码表示意义违反后果
    左括号数量限制 left > 0 不能超过 n 个左括号 生成的括号对数量不对
    合法性限制 right > left 任何时刻右括号不能多于左括号 生成无效的括号序列

    这两个条件共同作用,确保我们只生成有效的括号组合 。


    五、细节剖析:面试官真正关心的问题

    Q1:为什么用剩余数量 left 和 right,而不是已使用数量?

    答案:两种方式都可以,但用剩余数量更直观地体现“可用资源”的概念 。当 left == 0 && right == 0 时,所有资源耗尽,得到一个解。如果用已使用数量,终止条件是 usedLeft == n && usedRight == n。

    Q2:剪枝条件 right > left 是怎么推导出来的?

    答案:假设当前剩余左括号为 left,剩余右括号为 right,那么已经使用的左括号数为 n – left,已经使用的右括号数为 n – right。为了保证序列合法,必须有 n – right < n – left,即 left < right,也就是 right > left 。

    Q3:String 版和 StringBuilder 版哪个更好?

    答案 :

    • String版:代码简洁,不易出错,适合面试快速写出
    • StringBuilder版:性能更好(减少对象创建),但需要手动回溯,容易忘记 deleteCharAt
    • 建议:面试中用 String 版即可,如果时间充裕可以补充说明优化方案

    Q4:为什么不用 StringBuffer?

    答案:StringBuffer 是线程安全的,有同步开销,在单线程场景下不如 StringBuilder 高效。本题是单线程算法,用 StringBuilder 即可。

    Q5:n=0 时为什么返回 [""] 而不是空列表?

    答案:按照题目要求,n=0 表示生成0对括号,唯一的组合是空字符串。这符合卡特兰数的定义:C(0) = 1 。


    六、面试官追问进阶版

    追问1:如何用动态规划实现括号生成?

    思路 :动态规划解法基于一个巧妙的递推关系。

    f(n) 可以表示为 "(" + f(i) + ")" + f(n-1-i),其中 i 从 0 到 n-1。

    public List<String> generateParenthesis(int n) {
    List<List<String>> dp = new ArrayList<>();
    dp.add(Collections.singletonList(""));

    for (int i = 1; i <= n; i++) {
    List<String> cur = new ArrayList<>();
    for (int j = 0; j < i; j++) {
    for (String left : dp.get(j)) {
    for (String right : dp.get(i 1 j)) {
    cur.add("(" + left + ")" + right);
    }
    }
    }
    dp.add(cur);
    }

    return dp.get(n);
    }

    复杂度:同样是卡特兰数,但空间复杂度 O(n × Catalan) 较高。

    追问2:如何输出所有括号组合的长度?

    思路:直接返回卡特兰数公式的结果。

    public int countParenthesis(int n) {
    // 卡特兰数公式:C(2n, n) / (n + 1)
    return combination(2 * n, n) / (n + 1);
    }

    private int combination(int n, int k) {
    long res = 1;
    for (int i = 1; i <= k; i++) {
    res = res * (n k + i) / i;
    }
    return (int) res;
    }

    追问3:如何验证一个括号字符串是否有效(不用栈)?

    思路:用计数器即可,左括号加1,右括号减1,过程中不能为负,最终为0 。

    public boolean isValid(String s) {
    int count = 0;
    for (char c : s.toCharArray()) {
    if (c == '(') count++;
    else count;
    if (count < 0) return false;
    }
    return count == 0;
    }

    追问4:如果要求生成所有可能的括号组合(包括无效的),怎么改?

    思路:去掉剪枝条件,只限制长度,然后过滤。

    public List<String> generateAll(int n) {
    List<String> result = new ArrayList<>();
    generateAllHelper(new char[2 * n], 0, result);
    return result;
    }

    private void generateAllHelper(char[] current, int pos, List<String> result) {
    if (pos == current.length) {
    result.add(new String(current));
    return;
    }
    current[pos] = '(';
    generateAllHelper(current, pos + 1, result);
    current[pos] = ')';
    generateAllHelper(current, pos + 1, result);
    }


    七、实际开发:这道题到底有什么用?

    很多读者会问:“括号生成,实际工作中哪用得到?”

    其实它的思想无处不在:

    场景1:编译器语法检查

    编译器的语法分析阶段,需要检查代码中的括号是否匹配。理解括号生成的原理,有助于理解编译器的工作原理 。

    场景2:数据库查询优化

    某些数据库查询优化算法中,需要枚举不同的执行计划组合,这与括号生成的思想类似。

    场景3:二叉树遍历的序列化

    二叉树的序列化表示(如用括号表示子树)与括号生成有异曲同工之妙。

    场景4:数学表达式求值

    在数学表达式求值中,括号的嵌套关系决定了运算顺序,理解括号生成有助于设计表达式解析器。

    场景5:正则表达式匹配

    正则表达式中的 *、+ 等量词,与括号的生成规则有相似之处,都是递归定义的。


    八、总结:从一道题到一类题

    回顾一下,我们从括号生成学到了什么:

    维度收获
    算法思维 暴力枚举 → 回溯剪枝,理解“边构建边验证”的思想
    剪枝技巧 左括号上限、右括号合法性,两个条件缺一不可
    代码实现 String vs StringBuilder,回溯撤销的重要性
    复杂度分析 卡特兰数、递归深度、剪枝带来的优化
    工程关联 编译器、表达式解析、数据库优化

    力扣热题100的第二十二题,不是为了难住你,而是为了告诉你:回溯算法的精髓不在于“尝试所有可能”,而在于“优雅地排除不可能”。当你掌握了剪枝的艺术,你就能在指数级的搜索空间中找到那条通往答案的捷径。

    下一期预告:《合并K个升序链表》——优先队列的经典应用


    附录:思考题

    看完这篇文章,你可以试着回答:

  • 如果要求生成所有可能的括号组合,但允许右括号先出现(无效组合),代码怎么改?
  • 如何用迭代(非递归)的方式实现括号生成?
  • 你能用这道题的思路,去解 LeetCode 301(删除无效的括号)吗?
  • 欢迎在评论区留下你的思考!

    赞(0)
    未经允许不得转载:171主机测评 » 力扣热题100实战 | 第22期:括号生成——回溯算法的进阶应用
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址