LeetCode 22:括号生成|回溯dfs、剪枝与 char[] 覆盖代替撤销

一、题目

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

例如:

复制代码
n = 3

答案:

复制代码
[
  "((()))",
  "(()())",
  "(())()",
  "()(())",
  "()()()"
]

每个答案一定包含:

复制代码
n 个 (
n 个 )

所以最终字符串长度一定是:

复制代码
2n

二、一开始最容易想到什么?

这道题看起来像是在:

复制代码
每个位置选择 (
或者 )

因为最终字符串长度为:

复制代码
2n

所以理论上可以枚举:

复制代码
2^(2n)

种字符串。

但是绝大多数都是非法的,例如:

复制代码
)))
())(
())())

其实这些字符串在构造一半的时候就已经能判断:

后面无论怎么补都不可能合法。

因此这道题真正适合的是:

复制代码
回溯 + 剪枝

三、整道题真正只需要维护两个变量

定义:

复制代码
left

表示:

当前已经使用了多少个左括号 (

定义:

复制代码
right

表示:

当前已经使用了多少个右括号 )

例如当前构造:

复制代码
"(()

那么:

复制代码
left = 2
right = 1

当前已经填写字符数量:

复制代码
left + right = 3

所以只要知道:

复制代码
left
right

就已经知道整个递归状态。


四、我最终采用的 char\[\] 版本

代码:

java 复制代码
class Solution {

    public List<String> generateParenthesis(int n) {

        List<String> ans = new ArrayList<>();

        // 每个答案长度一定是 2n
        char[] path = new char[n * 2];

        dfs(0, 0, n, path, ans);

        return ans;
    }

    private void dfs(
        int left,
        int right,
        int n,
        char[] path,
        List<String> ans
    ) {

        // n 个左括号和 n 个右括号全部使用完
        if (left == n && right == n) {
            ans.add(new String(path));
            return;
        }

        // 左括号还有剩余
        if (left < n) {
            path[left + right] = '(';
            dfs(left + 1, right, n, path, ans);
        }

        // 只有存在未匹配左括号时,才能放右括号
        if (right < left) {
            path[left + right] = ')';
            dfs(left, right + 1, n, path, ans);
        }
    }
}

整道题最核心的就是三个判断:

复制代码
if (left == n && right == n)

if (left < n)

if (right < left)

下面逐个理解。


五、第一个判断:什么时候结束?

复制代码
if (left == n && right == n) {
    ans.add(new String(path));
    return;
}

题目要求:

复制代码
n 个左括号
+
n 个右括号

所以当:

复制代码
left == n
right == n

意味着已经生成完整字符串。

例如:

复制代码
n = 3

left = 3
right = 3

此时:

复制代码
left + right = 6 = 2n

数组已经全部填写完成。

所以:

复制代码
new String(path)

把:

复制代码
char[]

转换成:

复制代码
String

然后加入结果集合。


六、为什么有些写法只判断 right == n?

有一种写法:

复制代码
if (right == n) {
    ans.add(new String(path));
    return;
}

这也是正确的。

为什么?

因为整个递归过程始终保证:

复制代码
right <= left <= n

如果:

复制代码
right == n

根据:

复制代码
right <= left

可以推出:

复制代码
left >= n

而我们又始终保证:

复制代码
left <= n

所以只能:

复制代码
left == n

因此:

复制代码
right == n

实际上已经隐含:

复制代码
left == n

不过为了面试时更好理解,我更推荐:

复制代码
if (left == n && right == n)

它表达最直接:

左右括号都用完了。


七、第二个判断:为什么 left < n 才能放左括号?

代码:

复制代码
if (left < n) {
    path[left + right] = '(';
    dfs(left + 1, right, n, path, ans);
}

因为题目只允许:

复制代码
n 个 (

例如:

复制代码
n = 3

那么左括号最多:

复制代码
(((

如果:

复制代码
left = 3

已经用完三个左括号,就不能再继续放:

复制代码
(

所以:

复制代码
if (left < n)

其实是在问:

左括号还有库存吗?

可以记:

复制代码
left < n
→ 左括号没用完
→ 可以放 (

八、第三个判断:为什么 right < left 才能放右括号?

这是整道题最重要的剪枝条件。

代码:

复制代码
if (right < left) {
    path[left + right] = ')';
    dfs(left, right + 1, n, path, ans);
}

右括号和左括号不一样。

左括号只需要考虑:

复制代码
有没有用完

但是右括号还需要考虑:

复制代码
当前有没有左括号可以匹配

例如当前:

复制代码
"(("

那么:

复制代码
left = 2
right = 0

已经存在两个尚未匹配的左括号。

所以当然可以放:

复制代码
)

变成:

复制代码
"(()

但是如果当前:

复制代码
"()"

那么:

复制代码
left = 1
right = 1

此时所有左括号都已经匹配。

如果再放:

复制代码
)

就会得到:

复制代码
"())"

明显非法。

所以必须满足:

复制代码
right < left

才能继续放右括号。


九、可以把 right < left 理解成"还有几个括号没关"

这个理解特别直观。

计算:

复制代码
left - right

表示:

当前还有多少个左括号没有被右括号匹配。

例如:

复制代码
"(("

对应:

复制代码
left = 2
right = 0

left - right = 2

说明还有:

复制代码
2 个左括号没关

可以放:

复制代码
)

例如:

复制代码
"(()"

对应:

复制代码
left = 2
right = 1

left - right = 1

还有一个没关。

仍然可以放:

复制代码
)

如果:

复制代码
"()"

那么:

复制代码
left = 1
right = 1

left - right = 0

说明当前没有未匹配左括号。

所以不能再放:

复制代码
)

因此:

复制代码
right < left

本质上就是:

复制代码
left - right > 0

即:

还有尚未闭合的左括号。


十、为什么不能只判断 right < n?

例如写:

复制代码
if (right < n) {
    ...
}

虽然能保证右括号最多只有 n 个,但无法保证合法。

例如:

复制代码
n = 3

left = 1
right = 1

如果只判断:

复制代码
right < n
1 < 3

成立。

于是会继续放:

复制代码
)

得到:

复制代码
())

这已经非法。

所以右括号不能只看:

复制代码
还有没有库存

而是必须看:

复制代码
有没有左括号可以匹配

因此:

复制代码
right < left

才是真正正确的判断。


十一、为什么是两个独立 if,而不是 if...else if?

代码:

复制代码
if (left < n) {
    ...
}

if (right < left) {
    ...
}

不能随便写成:

复制代码
if (left < n) {
    ...
} else if (right < left) {
    ...
}

因为这两个选择有时候可以同时成立。

例如:

复制代码
n = 3

path = "("

left = 1
right = 0

现在:

复制代码
left < n
1 < 3
true

所以可以放:

复制代码
(

得到:

复制代码
((

同时:

复制代码
right < left
0 < 1
true

所以也可以放:

复制代码
)

得到:

复制代码
()

这里必须产生两个不同的递归分支:

复制代码
         "("
        /   \
      "(("  "()"

如果写成:

复制代码
if ... else if

第二个分支就没了。

因此:

回溯中的多个选择往往不是互斥关系,每个合法选择都要分别尝试。


十二、为什么 path 的位置是 left + right?

代码:

复制代码
path[left + right] = '(';

或者:

复制代码
path[left + right] = ')';

为什么数组下标是:

复制代码
left + right

因为:

复制代码
left

表示已经放了多少个左括号。

复制代码
right

表示已经放了多少个右括号。

所以:

复制代码
left + right

就是:

当前已经填了多少个字符。

例如:

复制代码
left = 2
right = 1

说明已经填写:

复制代码
3 个字符

数组已经占用了:

复制代码
path[0]
path[1]
path[2]

那么下一格自然就是:

复制代码
path[3]

也就是:

复制代码
path[left + right]

十三、用 n = 2 跑完整棵搜索树

开始:

复制代码
left = 0
right = 0

path = ""

此时:

复制代码
left < n

成立,所以只能:

复制代码
(

因为:

复制代码
right < left
0 < 0

不成立。

于是:

复制代码
        ""
         |
        "("

来到:

复制代码
left = 1
right = 0

现在两个条件都成立:

复制代码
left < n
1 < 2

可以放:

复制代码
(

同时:

复制代码
right < left
0 < 1

也可以放:

复制代码
)

于是:

复制代码
             "("
            /   \
          "(("  "()"

先走左边。


当前:

复制代码
"(("

left = 2
right = 0

左括号已经用完:

复制代码
left < n
false

只能放:

复制代码
)

得到:

复制代码
"(()

然后再放一个:

复制代码
)

得到:

复制代码
"(())"

第一个答案。


然后递归返回,走另一个分支:

复制代码
"()"

此时:

复制代码
left = 1
right = 1

不能放:

复制代码
)

因为:

复制代码
right < left
1 < 1
false

只能放:

复制代码
(

得到:

复制代码
"()("

然后:

复制代码
"()()"

第二个答案。

所以完整搜索树:

复制代码
              ""
               |
              "("
             /   \
           "(("   "()"
            |       |
           "(()"   "()("
            |       |
          "(())"  "()()"

最终:

复制代码
["(())", "()()"]

十四、这就是"剪枝"

如果不做限制,理论上:

复制代码
""

下面可以直接产生:

复制代码
"("
")"

但:

复制代码
")"

显然已经非法。

我们的:

复制代码
if (right < left)

在:

复制代码
left = 0
right = 0

时直接判断:

复制代码
0 < 0
false

所以:

复制代码
")"

这一整棵子树都不会搜索。

同样:

复制代码
"()"

以后不会产生:

复制代码
"())"

因为又被:

复制代码
right < left

剪掉。

所以:

剪枝就是在还没有走到最终结果以前,提前判断某条路径已经不可能合法,于是直接不搜索。


十五、标准 StringBuilder 回溯写法

普通回溯经常写成:

复制代码
path.append('(');

dfs(...);

path.deleteCharAt(path.length() - 1);

完整逻辑:

复制代码
做选择
↓
递归
↓
撤销选择

这就是经典回溯三步。

例如:

复制代码
if (left < n) {

    path.append('(');

    dfs(left + 1, right, n, path, ans);

    path.deleteCharAt(path.length() - 1);
}

然后尝试:

复制代码
if (right < left) {

    path.append(')');

    dfs(left, right + 1, n, path, ans);

    path.deleteCharAt(path.length() - 1);
}

十六、char\[\] 版本为什么没有 delete?

我的最终版本使用:

复制代码
char[] path = new char[n * 2];

然后直接:

复制代码
path[left + right] = '(';

或者:

复制代码
path[left + right] = ')';

这里没有:

复制代码
delete

所以一开始容易产生一个疑问:

是不是覆盖代替了回溯?

答案是:

不是。

更准确地说:

覆盖代替的是"显式撤销操作",不是回溯本身。


十七、什么才叫回溯?

例如当前:

复制代码
path = "("
left = 1
right = 0

这里存在两个选择:

复制代码
(
)

程序会先:

复制代码
path[1] = '(';
dfs(...);

进入这一整棵子树。

搜索结束以后,递归函数返回上一层。

然后执行:

复制代码
path[1] = ')';
dfs(...);

进入另一棵子树。

也就是说仍然发生了:

复制代码
选择
↓
深入搜索
↓
返回上一层
↓
换另一个选择

这整个过程本身就是:

复制代码
回溯

所以回溯并没有消失。


十八、覆盖到底代替了什么?

例如:

复制代码
path = ['(', ?, ?, ?]

当前位置:

复制代码
left = 1
right = 0

下一个位置:

复制代码
left + right = 1

先尝试:

复制代码
path[1] = '(';

数组变成:

复制代码
['(', '(', ?, ?]

递归搜索结束以后回来。

此时:

复制代码
path[1]

仍然是:

复制代码
'('

但没关系。

因为如果接下来尝试右括号:

复制代码
path[1] = ')';

它会直接覆盖。

变成:

复制代码
['(', ')', ?, ?]

所以根本没必要先:

复制代码
把 path[1] 恢复

再填写右括号。

下一次赋值自然把旧状态覆盖掉了。

因此:

复制代码
StringBuilder:

append
↓
dfs
↓
delete

而:

复制代码
char[]:

覆盖
↓
dfs
↓
回来以后直接再次覆盖

所以:

char[] 固定位置写入,让回溯过程中"不需要显式恢复数组内容"。


十九、为什么 left 和 right 也不用恢复?

还有一个很容易忽略的问题。

比如:

复制代码
dfs(left + 1, right, ...)

为什么递归回来以后不用:

复制代码
left--;

因为 Java 中:

复制代码
int

是值传递。

假设当前:

复制代码
left = 1
right = 0

调用:

复制代码
dfs(2, 0, ...)

子函数中的:

复制代码
left = 2

只是新的局部参数。

不会修改上一层的:

复制代码
left = 1

当子递归返回以后,当前层仍然是:

复制代码
left = 1
right = 0

所以它们天然恢复。

这点非常重要。


二十、整个状态为什么能够正确恢复?

一个递归层的状态可以理解为:

复制代码
当前层:

left
right

这两个是局部基本类型变量。

子递归:

复制代码
dfs(left + 1, right, ...)

得到的是新的参数值。

返回以后:

复制代码
当前层 left/right 没发生变化

path 虽然是共享数组,但下一个分支使用同一个位置时会:

复制代码
直接覆盖

所以整个状态天然可以继续搜索其他分支。


二十一、因此这版实际上非常适合理解递归状态

StringBuilder 版本:

复制代码
修改共享对象
↓
递归
↓
必须手动恢复共享对象

char\[\] 版本:

复制代码
固定下标写入
↓
递归
↓
下一分支直接覆盖

这并不意味着:

复制代码
没有回溯

而是:

复制代码
没有显式撤销代码

这是两个完全不同的概念。


二十二、为什么这个版本对我更好理解?

StringBuilder 版本需要同时理解:

复制代码
append()
deleteCharAt()
length()

还要理解:

复制代码
为什么递归回来以后必须删除

而 char\[\] 版本只需要理解:

复制代码
path[left + right] = '(';

或者:

复制代码
path[left + right] = ')';

即:

当前第几位应该填什么。

对于括号生成这种:

复制代码
最终长度固定

的问题,char[] 很自然。


二十三、但不是所有回溯都可以这样省略撤销

这一点必须注意。

这道题之所以可以:

复制代码
直接覆盖

是因为:

复制代码
每个递归深度对应一个固定数组位置

例如:

复制代码
第 0 层写 path[0]
第 1 层写 path[1]
第 2 层写 path[2]
...

下一次走另一分支时:

复制代码
还是覆盖当前层对应的位置

但是很多回溯题使用的是:

复制代码
List<Integer> path

例如全排列:

复制代码
path.add(nums[i]);

dfs(...);

path.remove(path.size() - 1);

这种情况下如果不删除:

复制代码
path 长度会越来越长

旧状态就会真正影响新分支。

因此:

char[] 覆盖是一种特殊情况下可以省略显式撤销的优化,并不是所有回溯都不需要撤销。


二十四、三个判断条件可以总结成三个问题

每次进入:

复制代码
dfs(left, right, ...)

只需要问三个问题。

1. 完成了吗?

复制代码
if (left == n && right == n)

如果完成:

复制代码
保存答案
return

2. 左括号还有吗?

复制代码
if (left < n)

如果有:

复制代码
尝试 (

3. 有左括号可以关闭吗?

复制代码
if (right < left)

如果有:

复制代码
尝试 )

整个 DFS 就结束了。


二十五、可以理解成"库存 + 合法性"

左括号:

复制代码
只需要考虑库存

所以:

复制代码
left < n

右括号:

复制代码
既要考虑库存
又要考虑合法性

但:

复制代码
right < left

已经同时解决了这两个问题。

因为:

复制代码
right < left <= n

自然有:

复制代码
right < n

所以不需要额外再判断:

复制代码
right < n

二十六、复杂度

这道题最终答案的数量是第 n 个 Catalan 数。

因此搜索规模不是普通的:

复制代码
2^(2n)

因为大量非法分支已经被剪掉。

从面试角度,不需要强行背复杂 Catalan 数公式。

可以重点说明:

算法只枚举合法括号前缀,通过 right < left 提前剪掉不可能合法的状态。

如果要更正式一点,结果数量是 Catalan 数:

复制代码
C_n

每个答案长度为:

复制代码
2n

因此构造所有答案至少需要与最终输出规模相当的时间。

额外递归栈深度:

复制代码
O(n)

因为字符串最大长度是:

复制代码
2n

数量级仍然是 O(n)


二十七、面试怎么讲?

我会这样回答:

这道题使用回溯。用 leftright 分别表示已经使用的左右括号数量。

如果 left < n,说明左括号还有剩余,可以继续放左括号。

如果 right < left,说明当前还有未匹配的左括号,因此可以放右括号。这个条件同时起到了剪枝作用,避免生成任何右括号数量超过左括号数量的非法前缀。

left == n && right == n 时,说明生成完成,将当前结果加入答案。

我使用固定长度 2nchar[] 保存路径,由于当前写入位置始终是 left + right,不同分支返回后可以直接覆盖当前位置,所以不需要像 StringBuilder 版本一样显式删除最后一个字符,但递归搜索本身仍然属于回溯。

这段面试表达已经非常完整。


二十八、最容易写错的地方

1. 右括号条件写成 right < n

错误:

复制代码
if (right < n)

这样会生成:

复制代码
())

正确:

复制代码
if (right < left)

因为右括号必须匹配已有左括号。


2. 把两个 if 写成 if...else if

错误:

复制代码
if (left < n) {
    ...
} else if (right < left) {
    ...
}

因为两个选择可能同时成立。

正确:

复制代码
if (left < n) {
    ...
}

if (right < left) {
    ...
}

3. path 下标不会写

直接记:

复制代码
path[left + right]

因为:

复制代码
left + right
=
当前已经放了多少个字符

4. 以为没有 delete 就不是回溯

错误理解:

复制代码
没有撤销代码
=
不是回溯

正确:

复制代码
递归进入一个选择
↓
返回上一层
↓
搜索另一个选择

本身就是回溯。

这里只是:

复制代码
char[] 直接覆盖

省掉了显式状态恢复。


5. 忘记 new String(path)

path 是:

复制代码
char[]

但答案要求:

复制代码
List<String>

所以需要:

复制代码
ans.add(new String(path));

二十九、面试前 30 秒速记

看到:

复制代码
括号生成

想到:

复制代码
回溯 + 剪枝

状态:

复制代码
left  = 已用 (
right = 已用 )

三个判断:

复制代码
left == n && right == n
→ 完成

left < n
→ 可以放 (

right < left
→ 可以放 )

当前位置:

复制代码
left + right

所以:

复制代码
path[left + right]

核心代码:

复制代码
private void dfs(
    int left,
    int right,
    int n,
    char[] path,
    List<String> ans
) {

    if (left == n && right == n) {
        ans.add(new String(path));
        return;
    }

    if (left < n) {
        path[left + right] = '(';
        dfs(left + 1, right, n, path, ans);
    }

    if (right < left) {
        path[left + right] = ')';
        dfs(left, right + 1, n, path, ans);
    }
}

记忆口诀:

复制代码
左括号看库存
右括号看有没有左括号可匹配

左没满 → 放 (
右比左少 → 放 )

左右都到 n → 收答案

三十、最终总结

这道题看起来是在生成括号,实际上真正训练的是三个东西:

复制代码
递归状态设计
+
合法性剪枝
+
回溯搜索

最重要的状态只有:

复制代码
left
right

最重要的两个约束是:

复制代码
left <= n
right <= left

因此代码自然得到:

复制代码
if (left < n)

可以放:

复制代码
(

以及:

复制代码
if (right < left)

可以放:

复制代码
)

而:

复制代码
left + right

就是当前应该填写的数组位置。

最后关于 char[] 版本最容易产生的误解:

复制代码
覆盖代替了回溯

这是不准确的。

正确理解应该是:

递归进入一个分支、返回上一层、再搜索另一个分支,这个过程仍然是回溯。

只是由于:

复制代码
char[] 的每个递归深度对应固定位置

所以新分支可以:

复制代码
直接覆盖旧字符

从而省掉:

复制代码
显式撤销操作

因此最终可以记成一句话:

char\[\] 覆盖没有代替回溯,只是代替了回溯中的显式撤销。

这也是这版写法最值得理解的地方。

相关推荐
sylviiiiiia1 小时前
leetcode hot100
python·算法·leetcode
纪伊路上盛名在2 小时前
Kabsch算法的Julia实现
开发语言·算法·julia·序列分析·蛋白质·rmsd
裕晟资质规划2 小时前
军工保密资质二级申报的四个可量化硬条件:条文位置、数值口径与西安配套企业实务要点
java·服务器·网络·数据库·算法
LuminousCPP2 小时前
数据结构-排序(一):基础排序算法横向对比|冒泡、插入、希尔、堆与双向选择,详解二分 / Knuth 增量
c语言·数据结构·笔记·算法·排序算法
Nil2082 小时前
leetcode 46全排列
算法·leetcode·职场和发展
辰烨chenye2 小时前
LeetCode Hot 100 题解 · 动态规划篇
算法·leetcode·动态规划
青山木3 小时前
Hot 100 --- 买卖股票的最佳时机
java·数据结构·算法·leetcode·贪心算法
wlkdb3 小时前
大模型/Agent 时代下的视觉算法
图像处理·算法·计算机视觉
sali-tec3 小时前
C# 基于OpenCv的视觉工作流-章107-光流追踪
图像处理·人工智能·opencv·算法·计算机视觉