LeetCode 22. 括号生成:深度优先搜索 (DFS)、递归

1. 题目大意

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

示例 1:

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

示例 2:

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

2. 直观理解:存钱 vs 花钱

要生成有效的括号,我们可以把 ( 看作存钱,把 ) 看作花钱

假设 n=3,意味着你总共有 3 枚金币的额度。要完成这个游戏,必须遵守两条极其简单的规则:

  1. 总额度限制
    你最多只能存 n 次钱。只要存入次数没达到 n,你随时可以存(加左括号)。
  2. 余额限制
    想花钱,前提是你账户里得有钱。也就是说,当前存入的次数必须大于当前花出的次数,你才能花钱(加右括号)。

游戏目标:把这 n 枚金币全部存进去,再全部花完,中间不能透支。


3. 算法核心:二叉树与剪枝

如果我们把每一步的选择画出来,它就像一棵二叉树。每一个节点,我们都面临两个选择:是放 ( 还是放 )

但是,为了保证结果“有效”,我们不能盲目地生成所有组合(那是 2^2n 种,太多且包含非法结果),我们需要在树生长的时候进行剪枝

我们需要记录三个状态:

  • s:当前拼接的字符串。
  • left:已经放了多少个左括号。
  • right:已经放了多少个右括号。

决策逻辑:

  1. 左分支(加左括号):只要 left < n,这个分支就是通的。
  2. 右分支(加右括号):只有 right < left,这个分支才是通的。

4. 给不懂递归的你:外包与分身术

如果你觉得递归难以理解,请把 backtrack 函数想象成“把任务外包给分身”。

假设你是 0号员工(大老板),你的任务是填满 $2n$ 个格子。
你很懒,你只做当前这一步的决定,剩下的工作你复制一个和你一模一样的分身(下级员工)去干。

场景模拟(n=2):

  • 你(0号):手里拿着空纸条 ""。你决定写个 (,纸条变成 (。然后你变出 1号员工,把纸条交给他,让他去处理剩下的。
  • 1号员工:拿着 (。他发现还可以写 (,于是写上,变成 ((。他变出 2号员工 交接工作。
  • 2号员工:拿着 ((。他发现不能写 ( 了(额度用完了),只能写 )。纸条变成 (()。交接给 3号员工
  • ...
  • 4号员工:拿着 (())。他发现长度够了,把结果扔进信箱,然后下班(消失)

什么是回溯(Backtracking)?
当 4号员工 消失后,时空回到了 3号员工 刚才做决定的那一刻。3号员工会想:“刚才那条路走完了,我还有别的选择吗?没有了,我也下班。”
...
一直回到 1号员工。1号员工会想:“刚才我选择了加左括号 ((,那条路已经被分身跑通了。但我其实当时还可以选择加右括号 () 啊!”
于是,1号员工会立刻派出另一个分身,去尝试 () 这条新路。

这就是递归:一大群分身在帮你把所有迷宫的路都试一遍,撞墙了就回头,走出去了就记录。


5. 代码实现

Python 版本

Python 的代码最为简洁,因为字符串是不可变的,在递归传递 s + "(" 时,自动创建了新对象,相当于天然自带了“回溯撤销”的功能。

class Solution:
    def generateParenthesis(self, n: int) -> List[str]:
        res = []
        
        # s: 当前的字符串
        # left: 左括号用了几个
        # right: 右括号用了几个
        def backtrack(s, left, right):
            # 【终止条件】
            # 存了n次,也花了n次,总长度达到2n,任务完成
            if len(s) == 2 * n:
                res.append(s)
                return
            
            # 【选择 1】尝试加左括号 (存钱)
            if left < n:
                backtrack(s + "(", left + 1, right)
            
            # 【选择 2】尝试加右括号 (花钱)
            # 核心逻辑:只有余额(left) > 支出(right) 才能花
            if right < left:
                backtrack(s + ")", left, right + 1)
        
        # 启动递归
        backtrack("", 0, 0)
        return res

Java 版本

import java.util.ArrayList;
import java.util.List;

class Solution {
    public List<String> generateParenthesis(int n) {
        List<String> res = new ArrayList<>();
        backtrack(res, "", 0, 0, n); // 初始传入空字符串
        return res;
    }

    private void backtrack(List<String> res, String cur, int left, int right, int max) {
        // 终止条件
        if (cur.length() == max * 2) {
            res.add(cur);
            return;
        }

        // 尝试加左括号
        if (left < max) {
            // 【关键点】直接传入 cur + "("
            // 这会创建一个新的字符串对象传递给下一层
            // 本层的 cur 并没有改变,所以不需要撤销
            backtrack(res, cur + "(", left + 1, right, max);
        }

        // 尝试加右括号
        if (right < left) {
            // 同理,直接传入 cur + ")"
            backtrack(res, cur + ")", left, right + 1, max);
        }
    }
}

总结

解决“括号生成”问题的关键在于不要被“有效组合”这个词吓倒。记住那两个简单的规则:

  1. 左括号没满就能加。
  2. 右括号比左括号少就能加。

剩下的,就交给递归函数(你的分身们)去跑完所有可能的分支吧!