📖 第63课:括号生成

4 阅读14分钟

想系统提升编程能力、查看更完整的学习路线,欢迎访问 AI Compass:github.com/tingaicompa… 仓库持续更新刷题题解、Python 基础和 AI 实战内容,适合想高效进阶的你。

📖 第63课:括号生成

模块:回溯算法 | 难度:Medium ⭐⭐⭐ LeetCode 链接:leetcode.cn/problems/ge… 前置知识:第59课(全排列) 预计学习时间:25分钟


🎯 题目描述

给定一个整数 n,生成所有合法的括号组合。也就是说,有 n 对括号,要生成所有可能的、括号正确配对的字符串。

示例:

输入:n = 3
输出:["((()))","(()())","(())()","()(())","()()()"]
解释:有 3 对括号,所有合法的组合方式

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

约束条件:

  • 1 <= n <= 8
  • 必须保证括号的有效性:左括号数量始终 >= 右括号数量
  • 每个结果字符串长度为 2*n

🧪 边界用例(面试必考)

用例类型输入期望输出考察点
最小输入n=1["()"]基本功能
中等规模n=2["(())","()()"]递归正确性
典型输入n=35种组合完整性
上界n=81430种组合性能边界(卡塔兰数)

💡 思路引导

生活化比喻

想象你在排队进出一个礼堂:

🚪 规则:每个人进去时拿一张票(左括号),出来时交还一张票(右括号)。礼堂容量有限(n对括号),关键约束是:任何时刻,出来的人不能比进去的人多(否则就没票可交了!)

🐌 笨办法:生成所有 2n 个位置的排列(如2^(2n)种),然后逐一检查是否合法。太慢!

🚀 聪明办法:边生成边检查!每次只在"合法"的时候添加括号:

  • 左括号还有剩余 → 可以加"("
  • 右括号数量 < 左括号数量 → 可以加")" 这样生成的每一个字符串都是合法的,不需要事后过滤!

关键洞察

**核心约束:**在构造过程中,任何时刻"已使用的右括号数"必须 ≤ "已使用的左括号数",这样才能保证括号合法配对。


🧠 解题思维链

这一节模拟你在面试中"从零开始思考"的过程。

Step 1:理解题目 → 锁定输入输出

  • 输入:整数 n (1~8),表示有 n 对括号
  • 输出:返回所有合法的括号字符串列表
  • 限制:字符串长度固定为 2n,必须保证括号配对合法

Step 2:先想笨办法(暴力法)

生成所有长度为 2n 的字符串(每个位置可以是"("或")",共 2^(2n) 种),然后逐个检查是否合法。

  • 时间复杂度:O(2^(2n) * n) — 生成+验证
  • 瓶颈在哪:生成了大量不合法的字符串,浪费了计算

Step 3:瓶颈分析 → 优化方向

笨办法的问题是"先生成所有可能,再过滤"。能不能边生成边剪枝,只构造合法的字符串?

  • 核心问题:如何保证生成过程中不产生非法字符串
  • 优化思路:约束回溯 — 每次添加括号时检查约束条件,不满足就不添加

Step 4:选择武器

  • 选用:回溯算法 + 约束剪枝
  • 理由:回溯天然适合"生成所有可能的组合",加上约束条件可以提前剪枝,避免生成无效字符串

🔑 模式识别提示:当题目要求"生成所有满足约束条件的组合",优先考虑"约束回溯"


🔑 解法一:暴力生成+验证(朴素法)

思路

先生成所有可能的括号序列,再逐一检查是否合法。用回溯生成所有 2^(2n) 种可能,然后用栈验证括号配对。

图解过程

以 n=2 为例,生成所有长度为 4 的括号串:

决策树(部分):
                      ""
            /                  \
          "("                  ")"  <- 不合法起点
      /         \
    "(("       "()"
   /   \       /   \
 "(((" "(()" "()(" "())"
  ...   ...   ...   ...

生成所有 2^4=16 种,再检查:
- "(())" ✓ 合法
- "()()" ✓ 合法
- "())(" ✗ 不合法
- "))((" ✗ 不合法
...

Python代码

from typing import List


def generateParenthesis_brute(n: int) -> List[str]:
    """
    解法一:暴力生成+验证
    思路:生成所有可能的括号序列,再检查合法性
    """
    def is_valid(s: str) -> bool:
        """用栈验证括号是否合法"""
        balance = 0
        for ch in s:
            if ch == '(':
                balance += 1
            else:
                balance -= 1
            if balance < 0:  # 右括号多了
                return False
        return balance == 0  # 最终必须配对完

    def backtrack(path: str):
        """生成所有长度为 2n 的括号串"""
        if len(path) == 2 * n:
            if is_valid(path):
                result.append(path)
            return
        backtrack(path + '(')  # 试左括号
        backtrack(path + ')')  # 试右括号

    result = []
    backtrack('')
    return result


# ✅ 测试
print(generateParenthesis_brute(2))  # 期望输出:["(())","()()"]
print(generateParenthesis_brute(3))  # 期望输出:5种组合

复杂度分析

  • 时间复杂度:O(2^(2n) * n) — 生成 2^(2n) 个字符串,每个验证需要 O(n)
    • 具体地说:如果 n=3,需要生成 2^6=64 个字符串,每个检查需要 6 次操作,共约 384 次操作(实际只有 5 个合法)
  • 空间复杂度:O(n) — 递归栈深度

优缺点

  • ✅ 思路直接,易于理解
  • ❌ 生成大量无效字符串,效率极低(n=8 时要生成 65536 个字符串!)

🏆 解法二:约束回溯(最优解)

优化思路

不生成无效字符串!在构造过程中实时检查约束条件:

  1. 左括号数量 < n → 可以加"("
  2. 右括号数量 < 左括号数量 → 可以加")"

这样每次都只走"合法路径",直接生成答案,无需验证。

💡 关键想法:用两个计数器 left 和 right,分别记录已使用的左括号和右括号数量。约束条件 right < left 保证了任何时刻"已配对的右括号不会超过左括号",即括号始终合法。

图解过程

以 n=2 为例,约束回溯只走合法路径:

                     ""(left=0,right=0)
                      |
                    "("(1,0)
                /              \
            "((") (2,0)      "()") (1,1)
              |                 |
           "(()"(2,1)        "()("(2,1)
              |                 |
           "(())"(2,2)✓      "()()"(2,2)✓

剪枝说明:
- 从""开始,只能加"("(right=0 无法加")")
- 从"("可以加"("或")",两条路都合法
- 从"(("只能加")"(left已达上限2)
- 从"()"可以加"("(right<left)

关键:每个决策点都遵循约束,所以叶子节点一定是合法串!

再看一个 n=3 的完整图解:

n=3 时,部分决策树:

                          ""
                          |
                        "("
                    /          \
                 "((     "     "()
               /    \           /    \
            "((("  "(()   "()("  "()(
           /       /  \    ...    ...
        "((()  "(()"( ...
        ...     ...

最终生成5个合法串:
1. "((()))" - 3层嵌套
2. "(()())" - 2层嵌套+1个并列
3. "(())()" - 1个嵌套+1个独立
4. "()(())" - 1个独立+1个嵌套
5. "()()()" - 3个并列

Python代码

def generateParenthesis(n: int) -> List[str]:
    """
    🏆 解法二:约束回溯(最优解)
    思路:边生成边约束,只添加合法的括号
    """
    result = []

    def backtrack(path: str, left: int, right: int):
        """
        path: 当前构造的字符串
        left: 已使用的左括号数量
        right: 已使用的右括号数量
        """
        # 终止条件:字符串长度达到 2n
        if len(path) == 2 * n:
            result.append(path)
            return

        # 决策1:加左括号(只要还有剩余)
        if left < n:
            backtrack(path + '(', left + 1, right)

        # 决策2:加右括号(只有 right < left 时才合法)
        if right < left:
            backtrack(path + ')', left, right + 1)

    backtrack('', 0, 0)
    return result


# ✅ 测试
print(generateParenthesis(1))  # 期望输出:["()"]
print(generateParenthesis(2))  # 期望输出:["(())","()()"]
print(generateParenthesis(3))  # 期望输出:["((()))","(()())","(())()","()(())","()()()"]

复杂度分析

  • 时间复杂度:O(4^n / sqrt(n)) — 这是第 n 个卡塔兰数 C_n 的渐近复杂度
    • 具体地说:n=3 时只生成 5 个字符串(而非 64 个),n=8 时生成 1430 个(而非 65536 个)
    • 精确值:C_n = (2n)! / ((n+1)! * n!),增长速度远低于 2^(2n)
  • 空间复杂度:O(n) — 递归栈深度为 2n

为什么是最优解

  • ✅ 时间复杂度已达理论最优:必须生成所有合法组合,无法更快
  • ✅ 无需额外验证:构造过程保证了合法性
  • ✅ 代码简洁:核心只有 2 个 if 判断

🐍 Pythonic 写法

利用生成器节省内存(适合只需要逐个处理结果的场景):

def generateParenthesis_generator(n: int):
    """生成器版本:按需生成,不占用额外列表空间"""
    def backtrack(path: str, left: int, right: int):
        if len(path) == 2 * n:
            yield path
            return
        if left < n:
            yield from backtrack(path + '(', left + 1, right)
        if right < left:
            yield from backtrack(path + ')', left, right + 1)

    return list(backtrack('', 0, 0))


# 使用生成器逐个处理
for parentheses in backtrack('', 0, 0, n=2):
    print(parentheses)  # 逐个输出,不占用列表空间

⚠️ 面试建议:先写清晰版本展示思路,再提生成器版本展示 Python 功底。面试官更看重你的约束回溯思路,而非语法糖。


📊 解法对比

维度解法一:暴力生成+验证🏆 解法二:约束回溯(最优)
时间复杂度O(2^(2n) * n)O(4^n / sqrt(n)) ← 最优
空间复杂度O(n)O(n)
代码难度中等简单
面试推荐⭐⭐⭐ ← 首选
适用场景仅适合 n≤3通用,n≤8 完全胜任

面试建议:

  1. 先口述暴力法思路(30秒),说明"生成所有可能再验证"
  2. 立即优化到🏆约束回溯,强调两个关键约束:
    • left < n → 可以加左括号
    • right < left → 可以加右括号(这是保证合法性的核心!)
  3. 手动演示 n=2 的递归树,展示剪枝效果
  4. 强调时间复杂度从指数级 2^(2n) 降到卡塔兰数级别

🎤 面试现场

模拟面试中的完整对话流程,帮你练习"边想边说"。

面试官:请你生成所有合法的 n 对括号组合。

:(审题30秒)好的,这道题要求生成所有合法的括号字符串。合法的意思是括号必须正确配对,不能出现")("这样的情况。

我的第一个想法是用回溯生成所有可能的字符串,然后再验证是否合法,但这样会生成很多无效字符串。

更好的方法是约束回溯:在构造过程中就保证合法性。核心思路是:

  • 用两个计数器 left 和 right,分别记录已使用的左右括号数
  • 只有 left < n 时才能加"("
  • 只有 right < left 时才能加")"(这保证了右括号不会超过左括号)

这样每次都只走合法路径,生成的都是答案,时间复杂度是卡塔兰数级别 O(4^n / sqrt(n))。

面试官:很好,请写一下代码。

:(边写边说)

def generateParenthesis(n: int) -> List[str]:
    result = []

    def backtrack(path, left, right):
        # 终止条件:凑够 2n 个字符
        if len(path) == 2 * n:
            result.append(path)
            return

        # 决策1:加左括号
        if left < n:
            backtrack(path + '(', left + 1, right)

        # 决策2:加右括号(关键约束!)
        if right < left:
            backtrack(path + ')', left, right + 1)

    backtrack('', 0, 0)
    return result

面试官:测试一下?

:用 n=2 测试:

  • 初始 ('', 0, 0)
  • 只能加"(",变成 ('(', 1, 0)
  • 可以加"("或")",分两路:
    • 路径1:('((', 2, 0) → 只能加")" → ('(()', 2, 1)('(())', 2, 2)
    • 路径2:('()', 1, 1) → 只能加"(" → ('()(', 2, 1)('()()', 2, 2)
  • 结果:["(())","()()"] 正确!

边界情况 n=1:["()"] 正确!

高频追问

追问应答策略
"为什么 right < left 能保证合法?"核心原理:任何时刻,已使用的右括号不超过左括号,意味着"每个右括号都能找到前面的左括号配对"。比如"(()":前两个"("可以配对后面两个")",但如果是"())",第3个")"就找不到配对的"("了。
"时间复杂度为什么是卡塔兰数?"卡塔兰数是组合数学中的经典数列,第 n 个卡塔兰数 C_n 表示 n 对括号的合法组合数。公式是 C_n = (2n)! / ((n+1)! * n!)。渐近复杂度约为 O(4^n / (n * sqrt(n)))。
"能不能用迭代代替递归?"可以用栈模拟递归,但代码会复杂很多。递归版本更清晰,且 n≤8 时递归深度只有 16,不会栈溢出,面试中推荐递归。
"如果要求返回第 k 个组合呢?"可以在回溯中加计数器,找到第 k 个就返回。或者用数学方法:卡塔兰数有递推公式,可以直接跳到第 k 个。

🎓 知识点总结

Python技巧卡片 🐍

# 技巧1:字符串拼接在回溯中的使用
path + '('  # 创建新字符串,不影响原 path(自动回溯)
# vs
path.append('('); ...; path.pop()  # 列表需要手动回溯

# 技巧2:多返回值简化参数
def backtrack(left, right):  # 只传计数器
    path = '(' * left + ')' * right  # 临时构造字符串
    # 缺点:每次都重新构造,效率低

# 技巧3:yield from 递归生成器
yield from backtrack(...)  # 递归生成所有结果

💡 底层原理(选读)

卡塔兰数是组合数学中的重要数列,出现在很多问题中:

  1. 定义:C_0=1, C_{n+1} = Σ(C_i * C_{n-i}), i=0~n
  2. 通项公式:C_n = (2n)! / ((n+1)! * n!) = C(2n, n) / (n+1)
  3. 前几项:1, 1, 2, 5, 14, 42, 132, 429, 1430...
  4. 应用场景:
    • n 对括号的合法组合数
    • n+1 个叶子的二叉树形态数
    • n×n 方格从左下到右上不穿过对角线的路径数
    • n 个元素的出栈序列数

为什么括号问题是卡塔兰数? 把"("看作 +1,把")"看作 -1,合法括号序列等价于:

  • 前缀和始终 ≥0(right≤left)
  • 总和为 0(配对完整) 这正是从 (0,0) 到 (n,n) 的单调路径数,即 C_n!

算法模式卡片 📐

  • 模式名称:约束回溯(Backtracking with Constraints)
  • 适用条件:需要生成满足特定约束的所有组合/排列
  • 识别关键词:"生成所有"+"满足条件"+"括号/路径/配对"
  • 模板代码:
def constrained_backtrack(params):
    result = []

    def backtrack(state, counters):
        # 终止条件
        if is_complete(state):
            result.append(state)
            return

        # 尝试所有合法决策
        for choice in get_valid_choices(counters):
            # 做选择(不需要撤销,因为传递的是新状态)
            new_state = state + choice
            new_counters = update_counters(counters, choice)
            backtrack(new_state, new_counters)

    backtrack(initial_state, initial_counters)
    return result

易错点 ⚠️

  1. 约束条件弄反:写成 if right < n and left < right → 错!应该是 right < left

    • 原因:right < left 保证"已用右括号不超过左括号",而 left < right 会导致"((("这样的串无法生成
  2. 忘记终止条件:只检查 left == n,不检查 right == n → 可能提前返回

    • 正确做法:检查 len(path) == 2*nleft == n and right == n
  3. 字符串拼接理解错误:以为 path + '(' 会修改原 path → 不会!Python 字符串不可变,自动实现回溯效果


🏗️ 工程实战(选读)

这个算法思想在真实项目中的应用,让你知道"学了有什么用"。

  • 场景1:语法解析器:编译器在解析代码时,需要检查括号/大括号/中括号的配对,用的就是"实时计数"思想
  • 场景2:前端组件嵌套:React/Vue 中检查组件标签是否正确闭合 <div>...</div>,原理相同
  • 场景3:数学表达式生成:AI 系统生成合法的数学公式(如 LaTeX),需要保证括号配对
  • 场景4:网络协议验证:检查 JSON/XML 的大括号/标签配对,都用"左右计数器"方法

🏋️ 举一反三

完成本课后,试试这些同类题目来巩固知识:

题目难度相关知识点提示
LeetCode 20. 有效的括号Easy栈验证括号用栈检查括号配对,是本题的"验证"部分
LeetCode 32. 最长有效括号HardDP/栈找最长的合法子串,可以用"左右计数"思想
LeetCode 301. 删除无效括号HardBFS/回溯删除最少字符使括号合法,类似"修复"版本
LeetCode 241. 为运算表达式设计优先级Medium分治回溯给表达式加括号改变运算顺序,本质是括号插入

📝 课后小测

试试这道变体题,不要看答案,自己先想5分钟!

题目:给定 n 对括号,生成所有合法的括号组合,但要求输出按字典序排序。(例如 n=2 时,输出 ["(())","()()"] 而非乱序)

💡 提示(实在想不出来再点开)

回溯的递归顺序天然保证字典序!因为我们先递归 path + '(',再递归 path + ')',所以生成顺序就是字典序。

✅ 参考答案
def generateParenthesis_sorted(n: int) -> List[str]:
    """
    生成按字典序排序的括号组合
    核心:回溯的递归顺序天然保证字典序
    """
    result = []

    def backtrack(path: str, left: int, right: int):
        if len(path) == 2 * n:
            result.append(path)
            return

        # 先递归'(',再递归')' → 保证字典序
        if left < n:
            backtrack(path + '(', left + 1, right)
        if right < left:
            backtrack(path + ')', left, right + 1)

    backtrack('', 0, 0)
    return result  # 无需额外排序!


# 测试
print(generateParenthesis_sorted(3))
# 输出:['((()))', '(()())', '(())()', '()(())', '()()()']
# 已经是字典序!

解释:回溯算法的递归顺序决定了生成顺序。因为我们总是"优先尝试左括号",所以生成的第一个结果一定是 "(((...)))"(全嵌套),最后一个结果是 "()()...()"(全并列)。这正好是字典序!

扩展:如果要逆字典序,只需交换两个 if 的顺序,先递归 ')' 再递归 '(' 即可。


如果这篇内容对你有帮助,推荐收藏 AI Compass:github.com/tingaicompa… 更多系统化题解、编程基础和 AI 学习资料都在这里,后续复习和拓展会更省时间。