力扣hot100之22:括号生成
力扣hot100之22:括号生成

LeetCode 22:括号生成(Generate Parentheses)题解

Generate Parentheses 是 LeetCode Hot 100 里面一道非常经典的回溯题。

这道题和前面的“电话号码的字母组合”有点像,都是通过递归一层层构造答案;但它又比电话号码题多了一层限制: 不是随便拼接字符就行,而是必须保证最终生成的括号序列是 合法的括号组合

也正因为有了这个“合法性约束”,这道题就成了学习 回溯 + 剪枝 的非常典型的一题。很多人第一次学回溯时,往往只是会写“枚举所有情况”;而这道题真正重要的地方在于: 我们不是先把所有字符串都生成出来再筛选,而是在生成过程中就主动避免无效情况。

你给出的这份 Java 代码,就是这道题最经典的回溯写法之一。 本文就结合这段代码,系统地讲一下这道题的思路。


题目介绍

给定一个整数 n,要求你生成所有由 n 对括号组成的、并且合法的括号组合。

例如:

n = 3

那么答案是:

["((()))","(()())","(())()","()(())","()()()"]

注意,这里要求的是:

  • 一共必须有 n 个左括号 (

  • 一共必须有 n 个右括号 )

  • 并且整个括号字符串必须是合法的

所谓合法,意思是任意前缀中,左括号的数量都不能少于右括号的数量,并且最终左右括号数量相等。

比如:

  • "(()())" 是合法的

  • "())(()" 不合法

  • ")(" 不合法

  • "(((" 也不合法

所以这道题的难点并不是“怎么拼出长度为 2n 的字符串”,而是“怎么只生成合法答案”。


示例

示例 1

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

只有一对括号时,唯一合法的结果就是:

"()"

示例 2

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

两对括号时,一共只有两种合法情况。

示例 3

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

这也是这道题最经典的示例。


这道题为什么适合用回溯?

因为这道题本质上是一个“逐步构造答案”的过程。

假设我们现在要生成一个长度为 2n 的括号字符串。 那每一步其实只有两种选择:

  • 放一个左括号 "("

  • 放一个右括号 ")"

也就是说,我们可以把整个过程想成一棵决策树:

  • 每一层表示当前要放第几个字符

  • 每个结点都有最多两种选择:放左括号或放右括号

  • 当字符串长度达到 2n 时,就得到一个完整结果

这就是典型的回溯模型。

但是和普通“全排列”不同的是,这里并不是所有路径都合法。 所以我们需要在回溯过程中加上限制条件,也就是所谓的 剪枝


最朴素的想法为什么不好?

如果完全不考虑剪枝,那么我们可能会想到:

  • 一共需要生成长度为 2n 的字符串

  • 每一位都可以是 ()

  • 所以总共有 2^(2n) 种可能

然后我们再把这些字符串一个个检查,筛出合法答案。

这个方法当然理论上可行,但效率很差。 因为绝大多数生成出来的字符串都是无效的,我们做了大量没有必要的工作。

比如 n = 3 时,长度是 6,所有可能字符串有:

2^6 = 64

种,但真正合法的结果只有 5 个。

所以更好的思路是:

在构造过程中,一旦发现某条路径不可能合法,就立刻停止继续搜索。

这正是回溯的优势所在。


核心思路

我们在递归过程中维护三个信息:

  • current:当前已经构造出的括号字符串

  • open:当前已经使用了多少个左括号

  • close:当前已经使用了多少个右括号

然后根据这三个量来决定下一步能做什么。

情况 1:还能继续放左括号

只要当前使用的左括号数量还没有达到 n,就可以继续加左括号:

if (open < n)

情况 2:还能继续放右括号

右括号不能乱放。 只有当前已经放置的右括号数量 小于 左括号数量时,才能放右括号:

if (close < open)

为什么?

因为如果某一时刻右括号数量超过了左括号数量,那么当前前缀一定不合法。 而一个不合法的前缀,不可能再补救成合法答案。

这就是这道题最关键的剪枝条件。


Java 代码

下面是你给出的代码,我这里保留原本逻辑,并加上注释:

class Solution {
    List<String> combinations = new ArrayList<>();

    public List<String> generateParenthesis(int n) {
        backtrack("", 0, 0, n);
        return combinations;
    }

    public void backtrack(String current, int open, int close, int max) {
        // 如果当前字符串长度已经达到 2 * n,说明构造完成
        if (current.length() == max * 2) {
            combinations.add(current);
            return;
        }

        // 只要左括号数量还没达到上限,就可以继续放左括号
        if (open < max)
            backtrack(current + "(", open + 1, close, max);

        // 只有右括号数量小于左括号数量时,才可以放右括号
        if (close < open)
            backtrack(current + ")", open, close + 1, max);
    }
}

代码拆解

这段代码虽然不长,但它把回溯的核心结构和剪枝条件都体现得非常完整。 下面一步一步来看。


1. generateParenthesis 做了什么?

public List<String> generateParenthesis(int n) {
    backtrack("", 0, 0, n);
    return combinations;
}

这个函数只是做了两件事:

  1. 从空字符串开始递归

  2. 返回最终结果列表

一开始:

  • 当前字符串是 ""

  • 已使用左括号数 open = 0

  • 已使用右括号数 close = 0

然后通过递归不断扩展。


2. backtrack 里的参数分别表示什么?

public void backtrack(String current, int open, int close, int max)

这四个参数非常关键:

  • current:当前已经拼出来的括号字符串

  • open:当前已经放了多少个左括号

  • close:当前已经放了多少个右括号

  • max:一共需要放多少对括号,也就是 n

换句话说,这个函数表示的是:

当前已经生成了一个部分括号串 current,其中用了 open 个左括号、close 个右括号,接下来继续补全它。

只要把这个定义想明白,后面的递归逻辑就非常清楚了。


3. 为什么长度到 2 * n 就可以加入答案?

if (current.length() == max * 2) {
    combinations.add(current);
    return;
}

因为一个由 n 对括号组成的字符串,总长度一定是:

2 * n

所以当 current.length() 达到这个长度时,说明我们已经放完了全部括号。

而由于前面每一步都遵守了合法性条件,所以能走到这里的字符串一定是合法答案,可以直接加入结果集。


4. 为什么 open < max 时可以继续放左括号?

if (open < max)
    backtrack(current + "(", open + 1, close, max);

因为总共只允许放 n 个左括号。

所以只要当前已经使用的左括号数量还没达到上限,就可以继续添加一个左括号。

这一步很好理解,本质上是在控制左括号总数不能超标。


5. 为什么 close < open 时才能放右括号?

if (close < open)
    backtrack(current + ")", open, close + 1, max);

这是整道题最核心的一句。

因为括号是否合法,有一个非常重要的性质:

在任意前缀中,右括号的数量都不能超过左括号数量。

比如:

  • "()" 合法

  • "(())" 合法

  • "())" 不合法

  • ")(" 不合法

一旦某个前缀出现:

close > open

那么当前字符串一定已经不可能变成合法括号串了。

所以只有在:

close < open

的情况下,才允许继续加右括号。

这就是剪枝。

也正是因为有了这个条件,我们在搜索过程中就能主动避开大量无效路径。


手动模拟一遍

我们用最经典的例子来手动走一遍:

n = 3

初始调用:

backtrack("", 0, 0, 3)

第一步:当前是空串

  • current = ""

  • open = 0

  • close = 0

这时候:

  • 可以放左括号,因为 open < 3

  • 不能放右括号,因为 close < open 不成立

所以只能走:

"("

第二步:当前是 "("

  • open = 1

  • close = 0

这时候两种选择都可以:

  • 放左括号,变成 "(("

  • 放右括号,变成 "()"

于是递归会分成两条路。


路线一:继续放左括号

"(" 变成:

"(("

再往下又可以继续放左括号,变成:

"((("

然后此时左括号已经用满,只能不断补右括号,最终得到:

"((()))"

路线二:在第二步放右括号

"(" 变成:

"()"

接下来还可以继续放左括号,再逐步扩展,会生成:

  • "()(())"

  • "()()()"

以及其他合法路径。

最终所有搜索结束后,会得到:

["((()))","(()())","(())()","()(())","()()()"]

这道题的本质是什么?

这道题本质上是在一棵搜索树中寻找所有合法路径。

每一步我们都面临两个选择:

  • 加左括号

  • 加右括号

但不是每个选择都允许。 我们必须始终维持一个约束:

  • 左括号总数不能超过 n

  • 右括号数量不能超过左括号数量

所以这道题的关键不是“暴力生成所有情况”,而是:

在搜索过程中维持合法状态,只扩展那些仍然有可能成为答案的路径。

这就是典型的回溯 + 剪枝思想。


复杂度分析

时间复杂度

这道题的准确时间复杂度不太适合简单写成 O(2^(2n)),因为我们并没有真的搜索所有长度为 2n 的字符串。

实际生成的答案数量和第 n 个卡特兰数有关。 如果按题解常见写法,可以理解为:

  • 回溯会遍历所有合法括号组合

  • 每个组合长度为 2n

因此时间复杂度可以近似理解为:

O(Cn * n)

其中 Cn 表示第 n 个卡特兰数。

如果不强调数学公式,也可以直接理解为:

时间复杂度与最终合法答案的数量成正比。

空间复杂度

递归深度最多是:

2n

因此递归栈空间为:

O(n)

如果把结果集也算进去,那么总空间还要加上所有答案本身占用的空间。


这道题真正想考什么?

这道题非常经典,它真正考察的其实有两个层面。

第一,是否理解回溯的基本结构

也就是:

  1. 定义状态

  2. 写终止条件

  3. 枚举当前层选择

  4. 递归进入下一层

这是一切回溯题的共同骨架。

第二,是否会在回溯中做剪枝

这道题最精彩的地方就在于:

我们并不是生成所有字符串再判断,而是提前利用规则:

  • open < max

  • close < open

来限制搜索范围。

所以它比“电话号码的字母组合”更进一步,因为它开始真正体现回溯的效率优势。


和“有效的括号”有什么区别?

很多人在刷题时会把这两题联想到一起:

  • LeetCode 20:有效的括号

  • LeetCode 22:括号生成

它们都和括号有关,但考点其实不同。

有效的括号

考的是:

  • 给定一个字符串,判断它是否合法

  • 重点数据结构是

括号生成

考的是:

  • 构造出所有合法的字符串

  • 重点方法是 回溯

一个是在“验证”,一个是在“生成”。

虽然都用到了括号合法性的规则,但解决问题的工具完全不同。


总结

这道题的核心思想可以概括成一句话:

通过回溯逐步构造括号串,并在构造过程中始终保证当前前缀合法。

具体做法就是:

  1. current 记录当前路径

  2. open 记录已经使用的左括号数量

  3. close 记录已经使用的右括号数量

  4. 当长度达到 2n 时,把当前结果加入答案

  5. 如果左括号还没用完,就继续放左括号

  6. 如果右括号数量小于左括号数量,就可以放右括号

这道题是一道非常标准、也非常有代表性的回溯题。 它不仅能帮助你掌握“如何写回溯”,更重要的是能让你理解:

真正高效的回溯,不只是搜索,更是在搜索过程中不断剪掉不可能的分支。

如果你把这道题真正理解透,那么后面再去做:

  • 组合总和

  • 子集

  • 全排列

  • N 皇后

这些题时,你会越来越能体会“状态、选择、递归、剪枝”这一整套回溯框架的威力。

括号生成,几乎就是回溯剪枝题里最经典的一块入门模板。

如果对您有帮助的话,能否支持一下博主?
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇