免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

括号生成全解析:递归回溯、剪枝与卡特兰数

括号生成全解析:递归回溯、剪枝与卡特兰数 看到“括号生成”这四个字估计很多刷题人都不会陌生。这是LeetCode上非常经典的第22题几乎可以看作是考察递归回溯能力的“分水岭题目”。题目本身很简短给定一个整数n要求返回所有由n对括号组成的合法组合比如n3时输出就是5个字符串。它看似只是枚举实际上一旦深究下去会牵扯到DFS、剪枝、卡特兰数甚至编译原理里的括号栈匹配。我当年第一次刷这道题时只会暴力枚举再逐个校验后来在面试中又被问过不少次才彻底把里面的门道吃透。这篇文章我就按自己的刷题思路来拆一拆把从暴力到剪枝回溯、从复杂度分析到工程应用都说清楚适合准备算法面试、正在补递归基础、或者单纯想巩固回溯模板的同学参考。1. 题目到底在问什么先读懂需求再谈解法1.1 括号生成的核心约束先重新用大白话描述一遍题意输入一个整数n表示有n个左括号和n个右括号请输出所有长度是2n的字符串这些字符串必须满足“括号合法”。合法是什么概念第一左右括号数量恰好都是n第二从左往右扫描这个字符串时任意一个前缀里左括号的数量都不能少于右括号的数量。第二个条件是关键很多人一开始容易忽略。它其实是在保证括号不会“提前闭合”。举个反例字符串)(虽然左右括号数量各一个但第一个字符就是右括号任何以它开头的前缀里右括号数量都多过了左括号所以它不可能合法。用生活中的例子理解左括号相当于有人进会场右括号相当于有人出会场任何时刻会场里的人数不能是负数最后人都走了人数恰好为零。当n3时合法的五个结果是这样((()))(()())(())()()(())()()()你可以自己在草稿纸上画一画看看能不能找出第六个不重复的。如果找不出来说明你已经开始理解合法性的约束了。1.2 为什么这道题值得二刷三刷很多刷题者做这道题时可能只是背了一个模板就过去了但我认为它值得反复咀嚼。首先它在面试里的出场率非常高属于递归回溯类型的常客同族的还有“全排列”“组合总和”等题但括号生成往往是面试官最先抛出来的那道因为它代码量不大却能在短时间内考察出一个人对递归过程的理解程度。其次这道题的综合性很强。表面上看只是生成括号实际浓缩了深度优先遍历、剪枝、组合计数、甚至是栈的应用。能完整讲清楚“为什么可以这样剪枝”“为什么时间复杂度是O(4^n/√n)”比单纯写对代码更能体现功底。第三它非常适合作为回溯算法的“标准练习题”。只要你吃透了这道题的递归树结构再去做其他回溯题思路会通顺很多。我到现在写回溯代码时脑子里浮现的状态图还是从这道题建立起来的。2. 从暴力枚举到剪枝思路是怎么一步步收敛的2.1 最朴素的枚举思路与问题如果第一次遇到这个题多数人的第一反应是长度为2n的字符串每个位置填左括号或者右括号那总共就有2^(2n)种情况把所有这些字符串都生成出来再逐一判断是否合法不就行了这个思路没错只是效率很低。以n3为例2^664种字符串里只有5个合法到n8时2^1665536种字符串合法的只有1430个浪费率超过95%。等n到10全量枚举是1048576种合法的只有16796个已经完全不可接受了。暴力解法里的合法性判断也有两种常见方式。一种是借助栈遇到左括号压栈遇到右括号弹栈如果栈空或者最后栈不空就非法。另一种更快的计数器方式用一个变量balance记录未匹配的左括号数量遇到左括号加1遇到右括号减1如果过程中balance变成负数说明出现了“右括号比左括号多”的前缀直接判非法循环结束后如果balance不等于0也判非法。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这段代码就是括号合法判断的核心逻辑后面生成合法括号时你会发现我们其实一直在用这个思想做预防性检查。2.2 剪枝的关键左右括号的计数关系从暴力枚举到高效回溯最关键的一步是意识到我们不需要先生成完整字符串再判断合法性而是在生成的过程中每一步都保证“当前这个位置只能选择合法的括号”。具体来说递归的过程中维护两个变量left表示已经使用的左括号数量right表示已经使用的右括号数量。这时能添加的字符只有两种如果 left n说明左括号还没用完可以添加左括号如果 right left说明当前的右括号数量确实比左括号少可以添加右括号。第二条是整个剪枝的精髓。它直接保证了任意时刻已经拼出来的前缀都是“合法前缀”因为右括号永远不会比左括号多。这样一来那些最终会变成非法的分支在生成的早期就被拦截掉了而不是等整棵树长完再做一次全局检查。这就是典型的“先判断再递归”的剪枝思想。你在做八皇后时会在放下棋子的那一刻检查是否冲突在做组合总和时会在递归前判断剩余目标值是否还允许加入当前数。括号生成的剪枝逻辑也是同一套思路把合法性校验前置让递归树只长出可能通向答案的分支。2.3 为什么回溯/DFS是题解标准答案很多初学者分不清“递归”和“回溯”这两个词。其实回溯就是带状态回退的深度优先遍历。在括号生成里我们从空字符串开始每次尝试添加一个括号沿着一个分支走到头如果发现这条路没法继续或者已经得到了一个完整结果就回到上一个位置换另一个选项继续走。用走迷宫来类比最合适你站在迷宫入口先选一条路走走到尽头发现是死路就退回上一个岔路口换另一条路再试。括号生成里的“回退”其实没有真正的死路因为剪枝已经把很多分支砍掉了但在尝试完一个分支后你仍然需要把刚刚添加的括号从路径里删掉让路径恢复到添加前的状态才能继续尝试另一个分支。这一步就叫“恢复现场”。我见过很多人在写回溯时忘记恢复现场结果得到了大量重复或者长度奇怪的字符串。因为这个过程依赖同一个可变对象path来记录当前状态如果不及时撤销操作上一次递归留下的痕迹就会污染后续的分支。所以写回溯题先想清楚三个问题递归参数是什么、终止条件是什么、递归回来之后要恢复什么。这三个问题想明白括号生成就能写出标准解。3. 完整实现与逐行拆解几种语言都能抄作业3.1 回溯法标准模板Python直接上我最近常用的Python写法def generateParenthesis(n: int): res [] path [] def backtrack(left: int, right: int): if len(path) 2 * n: res.append(.join(path)) return if left n: path.append(() backtrack(left 1, right) path.pop() if right left: path.append()) backtrack(left, right 1) path.pop() backtrack(0, 0) return res逐行拆一下。res是结果列表path是当前正在构造的括号串。backtrack有两个参数left是已经用了多少个左括号right是已经用了多少个右括号。终止条件是len(path) 2 * n也就是已经拼满2n个字符这时把path拼成字符串放进结果里。接着是两个分支。if left n这行的意思是只要左括号没用完就尝试放一个左括号然后递归递归结束之后立刻path.pop()把左括号从路径里删掉。if right left这行同理只有右括号数量还比左括号少时才允许放右括号递归结束后同样要恢复现场。为什么right left就能保证不产生非法串因为合法前缀的要求就是任意时刻右括号不超过左括号我们每一步都遵守这个规则最后的路径自然满足全局合法。3.2 Java和Go版本处理可变状态的不同方式Java版本和Python类似如果用StringBuilder也需要手动删除最后一个字符class Solution { public ListString generateParenthesis(int n) { ListString res new ArrayList(); backtrack(res, new StringBuilder(), 0, 0, n); return res; } private void backtrack(ListString res, StringBuilder sb, int left, int right, int n) { if (sb.length() 2 * n) { res.add(sb.toString()); return; } if (left n) { sb.append((); backtrack(res, sb, left 1, right, n); sb.deleteCharAt(sb.length() - 1); } if (right left) { sb.append()); backtrack(res, sb, left, right 1, n); sb.deleteCharAt(sb.length() - 1); } } }这里要特别留意sb.deleteCharAt(sb.length() - 1)它和Python里的path.pop()是同一个作用都是为了恢复现场。Go版本的写法则有点不同因为string类型是不可变的我们每次递归传递的是拼接后的新字符串所以不需要恢复现场func generateParenthesis(n int) []string { res : []string{} var backtrack func(path string, left, right int) backtrack func(path string, left, right int) { if len(path) 2*n { res append(res, path) return } if left n { backtrack(path(, left1, right) } if right left { backtrack(path), left, right1) } } backtrack(, 0, 0) return res }Go每次递归都生成一个新的path字符串老字符串停留在之前调用栈里互不干扰。这种写法代码上少写了一行pop但代价是每次拼接都会创建新字符串空间开销更大。两种风格你都可以掌握面试时根据语言特点选择即可。3.3 另一种优雅实现递归拼接法回溯法是最容易想到的解法但官方题解里还有一种很漂亮的递归思路用数学归纳法来构造把第一个左括号固定它一定有一个右括号和它配对这对括号把整个序列分成了三块左括号、中间的合法序列、右括号、后面的合法序列。于是可以得到递推式生成(k) “(” 生成(i) “)” 生成(k-1-i)其中i从0到k-1。写成Python代码是这样from functools import lru_cache def generateParenthesis(n: int): lru_cache(None) def build(k: int): if k 0: return [] res [] for i in range(k): for left in build(i): for right in build(k - 1 - i): res.append(( left ) right) return res return build(n)这里的核心思想是第一对括号内部有i对括号外部有k-1-i对括号。因为内部的序列和外部的序列都必须是合法括号串所以整个拼接结果一定合法。这个解法不依赖回溯更像经典的记忆化搜索面试时可以作为第二种方案给面试官展示。不过它的顺序和回溯法有所不同生成结果不是严格的字典序如果没有特殊要求也不用太纠结顺序。我建议至少要会用回溯法因为回溯法的状态转移更通用能迁移到更多变体题里。3.4 边界条件与初始参数怎么定写这道题时最容易出问题的不是主逻辑而是边界。先看n0。LeetCode原题输入通常是正整数但如果你把n0传进来期望的结果是什么有的定义认为空串是0对括号的唯一合法组合所以返回[]有的定义认为什么都没有返回空列表[]。面试时可以和面试官确认或者提前约定好返回空列表即可。再看初始调用。一定是从backtrack(0, 0)开始表示“还没有使用任何括号”。如果你从backtrack(1, 0)开始那就相当于默认第一个字符是左括号虽然对于合法括号串来说这个假设总是成立的但没必要反而容易让自己在写递归条件时困惑。最后是n比较大的情况。比如n10时结果数量已经达到16796全部输出没有任何问题但n15时结果是9694845个内存和输出都会变得很夸张。面试时如果遇到“n非常大”的追问通常考察的不是你怎么生成所有结果而是你会不会用卡特兰数直接求个数。4. 复杂度分析与时间复杂度粗算4.1 先说结论时间O(4^n/√n)、空间O(n)很多文章直接给出结论但对推导过程语焉不详。我这里用比较好理解的方式解释一下。括号生成的结果数量恰好是第n个卡特兰数。卡特兰数的通项公式是C(2n,n)/(n1)意思是“从2n个位置里选n个放左括号的方案数”再除以(n1)。为什么除以(n1)这个问题可以从很多角度证明最简单的理解是在全部C(2n,n)种“数量平衡”的0/1序列里恰好有1/(n1)的比例满足“任意前缀左括号不少于右括号”这个条件这一部分才是合法括号串。卡特兰数有一个著名的渐近估计C_n约等于4^n除以(n的1.5次方乘以√π)。由于每个结果最终要拼接成2n长度的字符串拼接代价是O(n)所以总时间复杂度大约是O(n * C_n)化简后就是O(4^n/√n)。空间复杂度方面递归深度最多是2n路径字符串长度最多是2n所以 O(n)。需要说明的是这个复杂度没有把结果列表本身算进去如果要求输出所有结果那结果列表占用 O(n * C_n) 的空间是不可避免的。4.2 为什么卡特兰数在这里出现卡特兰数贯穿了很多经典的组合计数问题除了括号生成还有n个节点的二叉树形态数、n个元素出栈的合法序列数、n×n棋盘上不越过对角线的单调路径数、矩阵连乘的括号化方案数等等。它们本质上都共享同一个递推结构C_0 1 C_{n1} Σ_{i0}^{n} C_i * C_{n-i}括号生成里如果我们把最后一对括号单独拎出来这对括号内部有i对括号外部有n-1-i对括号正好就对应这个递推公式。所以括号组合的数量就是卡特兰数这不是巧合而是同一个数学结构的体现。方便自查这里列一下常见的卡特兰数n卡特兰数合法括号串数量011空串111222355414145424261321327429429814301430948624862101679616796如果你写完代码后发现n3的结果数量不是5n4不是14那基本可以断定代码出了问题不用一行行调试先看数量就能定位方向。4.3 递归开销的实际感受虽然理论上时间是指数级但实际运行起来n10时程序输出16796个字符串的速度非常快毫秒级就能完成。n12时结果是208012个依然可以接受。真正让人崩溃的是n15以上这时结果接近千万就算算法没问题输出本身也会把终端打爆。我刷这道题时的习惯是在本地测试时用一个小的n先把结果数量对上表再慢慢调大而不是一上来就塞一个很大的n那样既看不出正确性也会让调试变得非常痛苦。5. 实际应用与变体不只是刷题那么简单5.1 合法括号判断与生成的双生问题和括号生成最直接相关的是另一道经典题判断一个括号字符串是否合法也就是LeetCode第20题。第20题是“给定字符串验证是否合法”第22题是“给定n生成所有合法字符串”一个负责验证一个负责构造正好是一对。判断单类型括号时用计数器就够了判断多类型括号时才需要用栈。这里给一个单类型判断函数def is_valid(s: str) - bool: count 0 for ch in s: if ch (: count 1 else: count - 1 if count 0: return False return count 0理解这个函数之后再看回溯生成代码你会发现生成过程中的right left条件其实就是把“count不能为负”这个事情前置了。一个是事后校验一个是事前拦截本质是同一件事。5.2 编辑器、编译器、表达式校验里它在哪有人可能觉得括号生成只是面试题实际工作中用不到但其实它的思想渗透在很多地方。第一类是编辑器和IDE。代码编辑器里经常要做括号高亮、括号自动补齐、光标跳跃匹配这些功能都需要判断某个位置的括号是否匹配。测试这些功能时测试工程师经常需要批量生成大量嵌套合法的括号串来验证高亮算法在深层嵌套下不会出错。写一个括号生成器就可以当作这类测试数据生成工具。第二类是编译器和语法分析。表达式解析时括号的嵌套直接影响语法树的构建。虽然语言本身不只有括号但“遇到左括号时深度加一遇到右括号时深度减一深度不能为负”这个规则是所有括号式语法的基础。你在写一个小型解释器或者模板引擎时第一个要处理的问题往往就是括号配对。第三类是生成测试用例。在给某个需要处理嵌套结构的功能写测试时比如JSON生成器、XML解析器你需要随机生成一些深层嵌套的合法结构。用括号生成的思想把每个标签或者结构单元当成一对括号就能自动组合出大量合法的嵌套样例。5.3 从单类型到多类型变体题目的扩展思路掌握了单类型括号生成之后面对变体题也会轻松很多。一种变体是要求生成多种括号比如同时用小括号、中括号、大括号要求所有符号都正确配对。这个题目比单类型复杂的地方在于右括号并不是简单地“数量小于左括号”就能放还必须是最近一个未闭合的左括号类型一致。解决方法是额外维护一个栈栈里记录未闭合括号的类型递归时检查栈顶是否匹配匹配才允许添加对应右括号。另一种变体是限制最大嵌套深度。比如要求生成的括号串里最多只能嵌套k层。这时只需在递归参数里增加一个depth放左括号时depth加一放右括号时depth减一并在放左括号前检查depth是否已经达到上限即可。核心框架和单类型括号生成几乎一样。还有一类变体是“只求个数不求具体结果”或者“求字典序第k个合法括号串”。前者直接用卡特兰数递推后者需要结合计数做剪枝跳跃属于进阶玩法但底座仍然是递归树的概念。5.4 括号深度与栈溢出的关联最后一个我想展开的细节是“括号深度”。括号生成出来的不同字符串最大嵌套深度是不一样的。比如((()))的深度是3()()()的深度是1。如果你在实现递归下降解析器时解析一个深度很大的括号表达式可能会导致函数调用栈过深甚至栈溢出。所以括号生成也可以用来做“极端输入的压力测试”。我写过一个小工具专门生成最大深度很大的括号串用来测试解析器的栈使用情况。这种实践让我觉得刷算法题并不是为了面试而面试很多思维模式真的能反哺工程。6. 常见问题与排查技巧实录6.1 为什么我生成的序列总是少几个这是最常见的错误几乎都出在右括号的剪枝条件上。很多人会把right left写成right n觉得左右括号都是n个那右括号只要没用完就能放。这样想的结果就是递归过程中会出现大量右括号个数超过左括号个数的非法前缀最后的结果要么数量不对要么混入了非法字符串。我在排查时有个土办法先把n设成3数一下结果数量如果不是5就说明剪枝条件有问题。然后打印每次递归进入时的left和right值观察是否有right left的情况出现一旦出现就能立刻定位到出错的分支。6.2 剪枝时机写错的典型表现剪枝的时机也有讲究。你可以在进入递归之前判断也可以在递归函数开头判断但要注意别把边界条件写错。常见的边界错误是把left n写成left n导致左括号多放了一个把right left写成right left导致出现右括号和左括号数量相等时还能继续放右括号直接产生非法前缀。这些错误用肉眼很难看出来最好用结果数量来校验。n3对应5个n4对应14个对不上数量就去检查边界条件。6.3 递归回溯中“恢复现场”的必要性回溯里最容易被忽略的就是递归返回之后的恢复操作。如果你用的是可变对象比如Python list、Java StringBuilder一定要在递归后把刚添加的字符删除。用打草稿来类比你在一张共享的草稿纸上写字写完一个分支之后如果不擦干净下一个分支就会接着之前的内容继续写结果自然乱成一团。path.pop()和sb.deleteCharAt(...)就是在做“擦干净”这件事。而Go版使用不可变字符串每次递归都生成新纸自然就不用擦了代价是内存稍高。如果你发现输出结果里有大量重复字符串或者出现长度不为2n的字符串第一反应就应该是检查恢复现场有没有漏掉。6.4 面试官喜欢追问的细节这道题在面试里写对代码只是及格真正拉开差距的是后面的几个追问。第一个追问通常是“时间复杂度是多少”。只回答“指数级”不够最好能说出卡特兰数和4^n/√n这个数量级。第二个追问是“能不能不用递归用非递归方式实现”。理论上可以用显式栈模拟递归但实现起来繁琐我一般会回答“可以但没有必要递归深度只有2n栈溢出的风险很低”。第三个追问是“如果n很大但只需要结果数量怎么算”。这时直接把卡特兰数递推写出来就行这是加分项。还有一个很少被问到但很考理解的点为什么这种回溯生成的括号串不会重复因为递归树的每个分支决策都是互斥的一个字符串对应一条唯一的路径只要保证分支条件写对同一时刻不会通过两条路径到达同一个结果所以天然无重复。这一点想明白了对递归理解会更深一层。我个人在实际操作中有一个小习惯新接触一道回溯题我会先在纸上画出n2或n3的递归树把每个节点的left、right值标出来再对着树写代码。这样写代码的正确率很高调试时也只需要对着树看哪一步走岔了。括号生成这道题尤其适合这个练习建议你也试试。
返回列表