LeetCode LCR 085. 括号生成
题目描述
题意分析
给定
n对括号,要求生成所有由这n对括号组成的、能够正确闭合的字符串。答案的顺序不限,但必须不重不漏。
先把「有效」这个词说精确。一个只含
(和)的串合法,当且仅当满足两条:任意前缀中(的数量不少于)的数量,且整串的(与)数量相等。前一条排除了)(这种「先关后开」,后一条排除了((这种「开而不关」。这个刻画是全题的地基——它把一个看起来需要「事后校验」的性质,转化成了可以边生成边维护的增量条件。
题目要输出所有方案而不是方案数,说明必须真正把每个串构造出来。长度为
2n的串共有 $2^{2n}$ 种填法,而合法的只有卡特兰数 $C_n$ 个(n = 8时是 1430)。$2^{16} = 65536$ 对 1430,差了 40 多倍,说明先生成后过滤是可行但浪费的,而利用上面那两条前缀性质做剪枝可以让每一步都只走向有效状态。
约束
1 ≤ n ≤ 8再次表明本题不考性能极限,而考枚举结构的设计。边界上:n = 1时唯一答案是"()";答案里不会出现空串;由于每一步的选择序列各不相同,天然不会产生重复方案,因此不需要任何去重机制。
解法:动态规划递推
核心思路
暴力做法是把
2n个位置各自填(或),得到 $2^{2n}$ 个候选串,再逐个用「计数器扫一遍」验证合法性。瓶颈在于绝大多数候选在很靠前的位置就已经注定非法了——比如首字符填),后面 $2^{2n-1}$ 个串全部白算。
关键观察是:合法性的两条判据都只依赖已经填出的前缀,因此可以在生成过程中实时判断,一旦某个前缀不可能扩展成合法串就立刻停止。具体地,设当前前缀里已用了
left个(、right个),那么下一步能填什么完全由这两个数决定:
能填
(当且仅当left < n(左括号总数不能超过n对);能填)当且仅当right < left(补上这个右括号后仍满足「前缀中左不少于右」)。这两个条件就是本解法的不变量:递归过程中任何时刻都有right ≤ left ≤ n,即当前前缀始终是某个合法串的前缀。
因为不变量保证了「走到的每个状态都还有救」,搜索树上不存在死胡同:只要一路走到
left == right == n,得到的必然是一个合法串;反过来任何合法串也必然对应一条这样的路径。于是枚举与答案一一对应,不重不漏,且不需要任何合法性校验代码——非法状态压根不会被创建出来。
状态由
(left, right)二元组完整刻画,路径上的字符串t只是把选择序列记录下来,不参与决策。
解题步骤
- 确定递归状态:
dfs(left, right, t)中left是已用左括号数,right是已用右括号数,t是当前已拼出的前缀。只用这两个计数就够了,不需要额外记录「还差多少个未闭合」,因为它恰好等于left - right。
- 递归基:
left == n && right == n说明2n个字符全部填完且左右配平,直接把t收入答案并返回。这里不需要再做任何校验,正是因为不变量已经保证了合法性。
- 尝试填左括号:
if (left < n)时递归dfs(left + 1, right, t + "(")。条件是left < n而不是left < 2n之类——题目给的是n对括号,左右各n个。
- 尝试填右括号:
if (right < left)时递归dfs(left, right + 1, t + ")")。这一条是全题最关键的剪枝:必须是right < left而不是right < n。用right < n会放行")("这类前缀,生成一堆非法串。
- 不需要显式撤销:
t + "("生成的是新字符串,当前栈帧里的t没有被修改,返回后自然还是原值。这是「传值路径」相对于「共享可变路径」的便利之处——代价是每层都产生一个新字符串对象。如果改用StringBuilder共享路径,就必须在递归后手动deleteCharAt撤销。
以
n = 2走一遍,把状态记作(left, right, t)。
根节点
(0, 0, "")。left = 0 < 2可填左;right = 0 < left = 0不成立,不能填右——首字符不可能是),这一步就砍掉了一半的候选。
进入
(1, 0, "(")。left = 1 < 2可填左,进入(2, 0, "((");right = 0 < 1也可填右,进入(1, 1, "()")。
分支
(2, 0, "(("):left = 2不小于n,不能再填左;right = 0 < 2可填右,进入(2, 1, "(()")。该状态下left已满,right = 1 < 2可填右,进入(2, 2, "(())"),命中递归基,记下第一个答案"(())"。
分支
(1, 1, "()"):left = 1 < 2可填左,进入(2, 1, "()(");right = 1 < left = 1不成立,不能填右——此处正是剪枝生效的地方,若允许会得到"())"这种前缀。继续从(2, 1, "()(")出发,left已满,right = 1 < 2可填右,进入(2, 2, "()()"),记下第二个答案"()()"。
最终答案为
["(())", "()()"],恰好是 $C_2 = 2$ 条,与卡特兰数吻合。若把right < left误写成right < n,根节点就会先产出")"开头的分支,n = 2的输出会膨胀到 6 条,多出")("、"())("之类的非法串。
代码实现
class Solution {
public List<String> generateParenthesis(int n) {
List<String> answer = new ArrayList<>();
dfs(0, 0, n, "", answer);
return answer;
}
// left / right:已使用的左、右括号数量;t:当前前缀。
private void dfs(int left, int right, int n, String t, List<String> answer) {
if (left == n && right == n) {
// 不变量保证走到这里的 t 一定合法,无需再校验。
answer.add(t);
return;
}
if (left < n) {
dfs(left + 1, right, n, t + "(", answer);
}
// 必须是 right < left,写成 right < n 会放行 ")(" 这类非法前缀。
if (right < left) {
dfs(left, right + 1, n, t + ")", answer);
}
}
}
func generateParenthesis(n int) []string {
var answer []string
// left / right:已使用的左、右括号数量;t:当前前缀。
var dfs func(left, right int, t string)
dfs = func(left, right int, t string) {
if left == n && right == n {
// 不变量保证走到这里的 t 一定合法,无需再校验。
answer = append(answer, t)
return
}
if left < n {
dfs(left+1, right, t+"(")
}
// 必须是 right < left,写成 right < n 会放行 ")(" 这类非法前缀。
if right < left {
dfs(left, right+1, t+")")
}
}
dfs(0, 0, "")
return answer
}
复杂度分析
- 时间复杂度:$O(n \times C_n)$,其中 $C_n = \binom{2n}{n} / (n+1)$ 是卡特兰数。由于剪枝保证每个被访问的状态都能扩展出至少一个答案,搜索树的叶子数恰为答案数 $C_n$,内部节点数与之同阶;每条答案在递归路径上累计做了 $O(n)$ 次字符串拼接。渐近上界可写作 $O(4^n / \sqrt{n})$。
- 空间复杂度:$O(n)$,递归深度恰为
2n,每层持有一个长度不超过2n的字符串。若把每层新建的字符串都算上,栈上同时存活的字符总量是 $O(n^2)$,改用共享StringBuilder可降回 $O(n)$。返回值按惯例不计入。
关键点总结
- 把「结果的合法性校验」改写成「过程中的增量约束」是这类构造题的核心手法:只要合法性可以由前缀判定,就能做到只生成有效状态、零校验、零去重。这个思路可以直接迁移到括号有效性、路径合法性一类问题上。
right < left而不是right < n,是本题唯一但致命的剪枝条件。面试中被问「为什么不能用right < n」时,给出")("这个反例最直接。- 搜索路径本身就区分了不同答案,所以本题天生不需要去重。判断一道搜索题要不要去重,看的是「不同的选择序列会不会产出相同结果」,而不是凭感觉加
Set。- 传值路径与共享路径的取舍:
t + "("写法简洁、无需撤销,代价是每层拷贝;StringBuilder共享路径省内存,但必须成对写append与deleteCharAt。面试时可以主动提出这个权衡。- 用卡特兰数自查:
n = 1,2,3,4的答案数分别是 1、2、5、14。写完随手对一下,多解或漏解立刻暴露。
易错点总结
- 右括号的条件写成
right < n:n = 2会输出 6 条,其中")("、")()("等根本不闭合。- 右括号的条件写成
right <= left:n = 1时会在(0,0,"")处直接填),输出含")("。- 左括号的条件写成
left < 2 * n:n = 1会尝试生成"((",左括号用超,最终既有非法串又永远凑不齐右括号。- 递归基只判
left == n:n = 2时"(("一到齐就被当成答案收走,输出里混入长度只有 2 的残缺串。- 递归基判成
t.length() == 2 * n却不检查配平:在没有right < left剪枝的前提下,n = 2会把"))(("也收进答案。- 命中答案后忘记
return:left与right都已达n,两个if都不成立,虽然不会死循环,但多写的分支容易让人误加逻辑;若同时把条件放宽成<=,就会无限递归直到栈溢出。- 改用
StringBuilder共享路径却忘了deleteCharAt撤销:n = 2时第一条答案"(())"之后,路径残留导致第二条被记成"(())()("这类超长串。- 在 Go 里用
[]byte共享缓冲并answer = append(answer, string(buf))之外还直接存buf:切片共享底层数组,先存入的结果会被后续写入覆盖成乱码。- 先枚举 $2^{2n}$ 个串再用栈校验:
n = 8时要检查 65536 个候选而答案只有 1430 个,虽然勉强能过,但面试中会被追问「怎么避免生成非法串」,答不上就等于没解决题目真正考察的点。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 22. 括号生成 | 中等 | 与本题完全同题,代码可原样提交 |
| 面试题 08.09. 括号 | 中等 | 同题的另一处收录,返回值类型略有差异,可用来练手模板复用 |
| 20. 有效的括号 | 简单 | 只判定给定串是否合法,用栈匹配三种括号,是本题剪枝条件的判定版 |
| 301. 删除无效的括号 | 困难 | 反向操作:从非法串中删最少字符使其合法,需先算删除量再搜索并去重 |
| 32. 最长有效括号 | 困难 | 求最长合法子串长度,改用栈或线性 DP,不再枚举方案 |
| 1249. 移除无效的括号 | 中等 | 只需给出任意一个合法结果,两趟扫描标记待删位置即可,无须搜索 |
| 856. 括号的分数 | 中等 | 输入已保证合法,考察的是按嵌套层次计分的栈式解析 |