括号生成 图解题解
n 对括号的合法组合,不用枚举再筛——两条规矩边生成边剪枝,连废串都不会产生。
像填空题的监考老师:不等学生写完再全部批改,而是边写边看——只要当前草稿已经出现「右括号比左括号多」这种先天残疾,直接没收试卷、这条路作废。剩下的题目只让两类人往下走:左括号没用完的,还能再放左;已放右括号少于左括号的,才能放右。守住这两条规矩,树上每条枝头长出来的就一定是合法括号,不需要事后筛垃圾。
这道题到底在问什么
- 输入
- n = 3
- 输出
- ((())) (()()) (())() ()(()) ()()()
最优解:为什么这么做
一句话答案:LeetCode 22 括号生成的标准解法是回溯加剪枝:搭串时只有两条落子规则——左括号没用完(open < n)就能放「(」,存在落单的左括号(close < open)才能放「)」,凑满 2n 个字符即收进答案。时间约 O(4ⁿ/√n),即第 n 个卡塔兰数的量级,递归深度 O(n)。
括号生成这道题到底在问什么
给定 n 对括号,要求列出所有「有效」的括号组合。有效的含义是:每个右括号往前看,都能找到一个还没被配对的左括号。注意题目要的是全部方案本身而不是方案个数,所以这是一道枚举型问题——必须把每一个合法串真正构造出来,计数公式帮不上忙。
为什么不先生成所有串再过滤
最直觉的暴力是:把长度 2n 的串每一位都在「(」和「)」里二选一,生成全部 2 的 2n 次方个候选,再逐个检查合法性。n = 3 时就是 64 个候选里挑 5 个,浪费惊人,n 一大更是指数级白干。
关键观察是:一个前缀能不能长成合法串,其实当场就能判定,只取决于两个计数——已放的左括号数 open 和右括号数 close。只要 close 超过 open,或者 open 超过 n,后面无论怎么补都救不回来。既然非法在半路就能看出来,就没必要等整串搭完再验,这正是回溯剪枝的用武之地:边搭边判,非法分支根本不进。
两条落子规则为什么恰好充分又必要
回溯的每一步只问两件事:open < n 吗?是就可以再放一个「(」,因为左括号配额还没用完。close < open 吗?是就可以放一个「)」,因为前面还有落单的左括号等着被配对;若 close 已经追平 open,再放右括号就没有搭档,必然非法。
这两条规则保证了不重不漏。不漏:任何一个合法串,它的每个前缀都天然满足「右括号数不超过左括号数、左括号数不超过 n」,所以这条路径在决策树里一定走得通。不重:每一步先试「(」再试「)」,每个串对应树上唯一一条路径。于是当 len(path) == 2n 时,open 和 close 必然都等于 n,收进结果的一定是完整合法串,连最终校验都省了。
剪枝到底省掉了多少工作量
不剪枝的搜索空间是 2 的 2n 次方,剪枝后真正被展开的路径只有合法串本身加上少量当场夭折的分支。合法串的个数是第 n 个卡塔兰数 C(n) = (2n)! / ((n+1)! n!),n = 3 时是 5 个:((()))、(()())、(())()、()(())、()()()。每个串长 2n,所以总时间约 O(4ⁿ/√n)——仍是指数,但这是输出规模决定的下限,算法本身没有一点冗余。
复杂度怎么算,哪些写法会翻车
时间约 O(4ⁿ/√n),由卡塔兰数乘每串长度 2n 得出;空间 O(n),只有深度 2n 的递归栈和一条 path。最常见的翻车点是把放右括号的条件写成 close < n:这样会放出没有搭档的右括号,产出 )( 这类非法串。另一个细节是收集完必须 return,否则 path 已满还继续往下试,会越界或重复收集。
▶ 动画逐步走查(共 42 步)——想跟着动画一帧帧对照就展开
- 3记住这两条「能不能放」的判断,下面每一步都在套它。
- 4从空 path 开始。最左一定先放「(」(空串放「)」必非法)。
- 5左括号 0<3,没用完 → 放「(」。现在 path=「(」,左1右0。
- 6左括号 1<3,没用完 → 放「(」。现在 path=「((」,左2右0。
- 7左括号 2<3,没用完 → 放「(」。现在 path=「(((」,左3右0。
- 8左括号已放满 3=3 → 不能再放「(」,剪掉这条分支 ✗。当前 path=「(((」,左3右0。
- 9右括号 0<左括号 3 → 可以放「)」配对。现在 path=「((()」,左3右1。
- 10左括号已放满 3=3 → 不能再放「(」,剪掉这条分支 ✗。当前 path=「((()」,左3右1。
- 11右括号 1<左括号 3 → 可以放「)」配对。现在 path=「((())」,左3右2。
- 12左括号已放满 3=3 → 不能再放「(」,剪掉这条分支 ✗。当前 path=「((())」,左3右2。
- 13右括号 2<左括号 3 → 可以放「)」配对。现在 path=「((()))」,左3右3。
- 14path 凑满 6 个字符 → 合法串「((()))」收进 results(第 1 个)。✅
- 15右括号 0<左括号 2 → 可以放「)」配对。现在 path=「(()」,左2右1。
- 16左括号 2<3,没用完 → 放「(」。现在 path=「(()(」,左3右1。
- 17左括号已放满 3=3 → 不能再放「(」,剪掉这条分支 ✗。当前 path=「(()(」,左3右1。
- 18右括号 1<左括号 3 → 可以放「)」配对。现在 path=「(()()」,左3右2。
- 19左括号已放满 3=3 → 不能再放「(」,剪掉这条分支 ✗。当前 path=「(()()」,左3右2。
- 20右括号 2<左括号 3 → 可以放「)」配对。现在 path=「(()())」,左3右3。
- 21path 凑满 6 个字符 → 合法串「(()())」收进 results(第 2 个)。✅
- 22右括号 1<左括号 2 → 可以放「)」配对。现在 path=「(())」,左2右2。
- 23左括号 2<3,没用完 → 放「(」。现在 path=「(())(」,左3右2。
- 24左括号已放满 3=3 → 不能再放「(」,剪掉这条分支 ✗。当前 path=「(())(」,左3右2。
- 25右括号 2<左括号 3 → 可以放「)」配对。现在 path=「(())()」,左3右3。
- 26path 凑满 6 个字符 → 合法串「(())()」收进 results(第 3 个)。✅
- 27右括号 2=左括号 2,没有落单的「(」可配 → 放「)」非法,剪掉 ✗。当前 path=「(())」,左2右2。
- 28右括号 0<左括号 1 → 可以放「)」配对。现在 path=「()」,左1右1。
- 29左括号 1<3,没用完 → 放「(」。现在 path=「()(」,左2右1。
- 30左括号 2<3,没用完 → 放「(」。现在 path=「()((」,左3右1。
- 31左括号已放满 3=3 → 不能再放「(」,剪掉这条分支 ✗。当前 path=「()((」,左3右1。
- 32右括号 1<左括号 3 → 可以放「)」配对。现在 path=「()(()」,左3右2。
- 33左括号已放满 3=3 → 不能再放「(」,剪掉这条分支 ✗。当前 path=「()(()」,左3右2。
- 34右括号 2<左括号 3 → 可以放「)」配对。现在 path=「()(())」,左3右3。
- 35path 凑满 6 个字符 → 合法串「()(())」收进 results(第 4 个)。✅
- 36右括号 1<左括号 2 → 可以放「)」配对。现在 path=「()()」,左2右2。
- 37左括号 2<3,没用完 → 放「(」。现在 path=「()()(」,左3右2。
- 38左括号已放满 3=3 → 不能再放「(」,剪掉这条分支 ✗。当前 path=「()()(」,左3右2。
- 39右括号 2<左括号 3 → 可以放「)」配对。现在 path=「()()()」,左3右3。
- 40path 凑满 6 个字符 → 合法串「()()()」收进 results(第 5 个)。✅
- 41右括号 2=左括号 2,没有落单的「(」可配 → 放「)」非法,剪掉 ✗。当前 path=「()()」,左2右2。
- 42右括号 1=左括号 1,没有落单的「(」可配 → 放「)」非法,剪掉 ✗。当前 path=「()」,左1右1。
- 43右括号 0=左括号 0,没有落单的「(」可配 → 放「)」非法,剪掉 ✗。当前 path=「」,左0右0。
- 44回溯走完整棵决策树,共收集到 5 个合法串:((()))、(()())、(())()、()(())、()()()。
⚠️ 容易写错的地方
✗ 错:先枚举所有 2^(2n) 串再判合法
✓ 对:边搭边剪:不合法当场不放
非法分支根本不进,省指数级时间
✗ 错:放「)」的条件写成 close<n
✓ 对:close<open 才能放「)」
右括号要有落单的左括号才能配
✗ 错:用 open+close==2n 当收集条件
✓ 对:len(path)==2n 更直观(等价)
两者等价,但别漏了 return
完整代码(Python / C++ / Java)
Python
def generateParenthesis(n):
res = []
def bt(path, open, close):
if len(path) == 2 * n:
res.append("".join(path)); return
if open < n:
bt(path + ["("], open + 1, close)
if close < open:
bt(path + [")"], open, close + 1)
bt([], 0, 0)
return resC++
vector<string> res;
void bt(string& p, int open, int close, int n){
if((int)p.size() == 2*n){ res.push_back(p); return; }
if(open < n){ p.push_back('('); bt(p,open+1,close,n); p.pop_back(); }
if(close < open){ p.push_back(')'); bt(p,open,close+1,n); p.pop_back(); }
}
vector<string> generateParenthesis(int n){
string p; bt(p,0,0,n); return res;
}Java
List<String> res = new ArrayList<>();
void bt(StringBuilder p, int open, int close, int n){
if(p.length() == 2*n){ res.add(p.toString()); return; }
if(open < n){ p.append('('); bt(p,open+1,close,n); p.deleteCharAt(p.length()-1); }
if(close < open){ p.append(')'); bt(p,open,close+1,n); p.deleteCharAt(p.length()-1); }
}
List<String> generateParenthesis(int n){
bt(new StringBuilder(), 0, 0, n); return res;
}复杂度
时间
~O(4ⁿ/√n)
结果个数是第 n 个卡塔兰数,每个串长 2n
空间
O(n)
递归深度 2n + path
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 括号生成 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么结果数是卡塔兰数?+
合法括号序列与卡塔兰数一一对应,C(n)=(2n)!/((n+1)!n!),n=3 时为 5。
能不能不用递归?+
可以用显式栈模拟,或按「逐个字符 + 计数」的迭代回溯,但递归最直观。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 括号生成 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。