ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

括号生成算法详解:回溯剪枝与卡特兰数递推思路

括号生成算法详解:回溯剪枝与卡特兰数递推思路 但凡刷过一阵子算法题一定绕不开“括号生成”这道题。题目本身很简洁给出一个整数 n要求返回所有由 n 对括号组成的合法组合。比如 n 2 时答案是[(()), ()()]而)(这种不合法。这道题在很多面试中出现频率非常高而且它表面上是字符串排列问题内核其实是一道标准的回溯算法题考察的是递归、剪枝、状态还原这些基本功。更妙的是它还有不止一种解法除了回溯还能用闭包数递推、动态规划、深度优先搜索等思路去解。这篇文章我会从题面拆解开始把“合法括号”的定义讲透然后一步步推导出回溯解法再把闭包数递推这种思路也完整过一遍最后分享一些我在实际写代码过程中踩过的坑以及在面试场景里这道题能延伸出哪些加分考点。无论你是刚开始刷题的新手还是已经有点基础、想查漏补缺这篇都能给你一些不一样的东西。1. 题面拆解先搞懂括号“合法”到底是什么意思1.1 题面还原与边界条件“括号生成”这道题的最经典版本是 LeetCode 22 题题目给定整数 nn 代表括号对的数量要求返回所有由 n 对括号组成的、合法的括号组合。这里的“合法”是一个很具体的概念不是什么“看起来顺眼”的问题而是必须满足严格的语法规则每个左括号都必须在它后面有一个对应的右括号而且任何时刻左括号的数量不能少于右括号的数量。先明确边界条件。n 0 时结果是空字符串集合[]因为 0 对括号自然只有一种组合也就是空串。n 1 时结果只有[()]。n 2 时结果是[(()), ()()]。n 3 时结果是[((())), (()()), (())(), ()(()), ()()()]。这里有个容易忽略的点n 的取值范围一般在 1 到 8 或者 1 到 10题目如果没特别说明通常不会给很大。因为结果数量增长非常快n 4 时是 14 个组合n 5 时是 42 个n 6 时是 132 个n 10 时已经膨胀到 16796 个。输出所有组合本身就意味着复杂度不可能低于结果数量级所以拿到题先估算一下结果规模心里就有底了。1.2 合法括号串的三个直觉条件判断一个括号字符串是否合法本质上只需要盯住三个条件。第一个条件是最终左括号总数等于右括号总数都是 n 个。第二个条件是遍历过程中任意前缀里右括号数量不能超过左括号数量否则就会出现类似())这种提前闭合出错的情况。第三个条件是字符串只包含左右两种括号这个比较 trivial但写校验函数时也不要漏掉。这三个条件其实是括号语法的核心。你可以把左括号想象成“入栈”右括号想象成“出栈”任何时刻栈都不能为空的时候执行出栈也就是右括号数量永远不能超过左括号数量。这种类比在解释回溯法的剪枝条件时特别有用下面会反复用到。1.3 结果规模与卡特兰数生成合法括号组合的数量是有数学公式的它是卡特兰数C_n (1 / (n1)) * C(2n, n)。比如 n 3卡特兰数是(1/4) * C(6, 3) (1/4) * 20 5正好对应上面列的 5 个组合。卡特兰数在组合数学里非常常见除了括号匹配还出现在二叉树形态计数、出栈序列数量、多边形三角剖分等场景。知道卡特兰数有什么实际意义它能帮你快速判断算法是否高效。如果一道题的结果数量本身是卡特兰数量级那最优算法的时间复杂度也至少是这个数量级不可能做到多项式甚至线性。所以回溯法虽然看起来是暴力枚举但因为剪枝充分实际上生成的节点数和最终结果数量是同阶的已经很接近“最优”了。2. 核心思路从暴力枚举到回溯剪枝的思考过程2.1 如果完全不考虑合法性直接生成很多新手第一次拿到这道题第一反应是先列出所有可能的括号排列再逐个判断是否合法。这个思路本身没错方向是对的但效率很低而且代码写起来也算不上优雅。所谓“所有可能的括号排列”就是在长度为 2n 的字符串的每个位置上要么放左括号要么放右括号总共 2^(2n) 种情况。当 n 10 时这个值是 2^20 1048576看起来还好但 n 15 时就到了 2^30超过 10 亿这就完全不可接受了。而 n 15 时合法结果数是多少卡特兰数 C_15 ≈ 9694845比 2^30 小了两个数量级。所以暴力枚举再校验浪费的地方在于大量根本不可能是合法组合的中间状态被完整生成然后才被淘汰。比如))((这种明显不合法的情况理论上在生成到第二个字符的时候就可以断定没救了但暴力法还是会把它一直生成完再判断。2.2 回溯法的核心思想边走边剪枝回溯法和暴力枚举最大区别在于回溯法在每一步生成时就检查当前状态还有没有可能走向合法结果如果不可能立刻放弃这条路径回到上一个分支点换条路走。这就是“剪枝”。以括号生成为例递归每往字符串里添加一个字符时需要维护两个状态已经用了多少个左括号left、已经用了多少个右括号right。基于这两个状态有两个剪枝条件也是这道题最核心的约束左括号数量不能超过 n。如果left n才能继续添加左括号。右括号数量不能超过左括号数量。如果right left才能继续添加右括号。这两个条件合在一起保证了生成的任何中间前缀都不会出现右括号多于左括号的情况同时也保证了最终左括号总数恰好是 n 个。当字符串长度达到 2n 时右括号数量一定也等于 n因为right永远小于等于left而left已经达到 n总长度又是 2n自然right只能等于 n。这种状态设计非常漂亮把合法性判断完全内化到了生成过程里。2.3 回溯法的递归树长什么样想清楚递归树的样子对理解回溯法帮助很大。以 n 2 为例从空字符串开始第一步只能添加左括号得到(。这时left1, right0。第二步有两个选择可以添加左括号变成((也可以添加右括号变成()。沿着((分支继续此时left2, right0由于 left 已经达到 n只能添加右括号变成(()这时right1还不是 2n继续添加右括号得到(())终止。沿着()分支left1, right1两个选择都开放可以再添加左括号得到()(也可以添加右括号得到())但后者会立刻因为right left不成立此时 left1, right1right left 为假而被剪掉所以只能走()(最后变成()()。从这个例子可以直观看到回溯法其实是在一棵二叉树上做深度优先搜索但是两个分支不是对等的。左括号分支受left n约束右括号分支受right left约束。理解了这棵递归树的生长规律代码就呼之欲出了。2.4 回溯法代码实现回溯法代码非常简洁核心就是一个递归函数加上两个 if 分支。我用 Python 写一个最经典的版本def generate_parenthesis(n): result [] def backtrack(cur, left, right): # 当前字符串长度达到 2n说明左右括号都用完了 if len(cur) 2 * n: result.append(.join(cur)) return # 只要左括号还没用完就尝试放左括号 if left n: cur.append(() backtrack(cur, left 1, right) cur.pop() # 只有右括号数量小于左括号时放右括号才可能合法 if right left: cur.append()) backtrack(cur, left, right 1) cur.pop() backtrack([], 0, 0) return result这里有几个细节需要注意。第一个细节是递归参数cur用的是列表而不是字符串。Python 字符串是不可变对象每次拼接都会产生新字符串开销大列表可以原地追加和弹出配合cur.pop()实现状态还原效率高很多。当然如果只是为了简单也可以用字符串传参但大量字符串拼接在实际测试中会比列表慢不少。第二个细节是cur.pop()的位置。每次递归返回后必须把刚刚添加的字符移除这样才能让上一个状态继续尝试其他分支。这就是“回溯”这个词的含义往前走一步探索回来时退一步保持状态一致。如果忘了 pop会出现((变成(( )和(( (并列结果严重混乱一长串错误答案。第三个细节是终止条件。我写的是len(cur) 2 * n也可以写成left n and right n。两者等价但前者少判断一次在递归深度较小时差异不大。实际面试中写left n and right n更直观也方便解释逻辑。2.5 复杂度推导为什么回溯法足够快回到复杂度。回溯法的时间复杂度严格来说是多少这个问题面试经常问值得认真推导一下。回溯法生成的递归树中每个节点对应一个合法的“前缀”也就是任意时刻 right 不超过 left 且 left 不超过 n 的字符串前缀。最终结果集合中的每个完整合法括号串长度是 2n所以每个结果的生成路径长度为 2n。而递归树中每个叶子节点对应一个结果所以叶子节点数量等于卡塔兰数 C_n。总的节点数大约是叶子节点数的常量倍数所以时间复杂度是 O(C_n * n)C_n 是第 n 个卡特兰数。C_n 的渐近表达式是4^n / (n^(3/2) * sqrt(pi))所以也可以说时间复杂度是 O(4^n / sqrt(n)) 这个量级。空间复杂度主要来自递归深度 O(n)以及存储结果所需的 O(C_n * n)因为每个结果长度为 2n加上列表本身的存索引开销可以简化为 O(C_n * n)。面试中能把这个复杂度推导说清楚比单纯背答案要好很多。尤其是“为什么回溯法不是指数级但也不是多项式级”这个问题答案是它属于“输出敏感”的算法复杂度由输出规模决定。这类表达在面试官眼里很加分。3. 进阶思路闭包数与递推生成法3.1 换一个角度看待括号组合回溯法虽然好写但它不是唯一的正解。这道题还有一类非常巧妙的思路叫“闭包数法”或者“递推生成法”。它的核心洞察是任何一个合法括号串都可以拆成(A)B的形式其中 A 和 B 分别都是合法括号串。为什么这个拆分一定成立因为一个合法括号串的第一个字符必然是左括号这个左括号一定会匹配一个右括号。假设它匹配的右括号把字符串分成了两部分左括号和这个右括号之间的部分是 A右括号之后的部分是 B。由于匹配规则A 本身必须是一个合法括号串B 也必须是合法括号串。反之任意两个合法括号串 A 和 B把它们拼成(A)B得到的也一定是一个合法括号串。这个性质非常强因为它把“构造合法括号串”的问题转化成了“枚举子问题并拼接”的问题。3.2 递推公式推导设 f(n) 表示由 n 对括号组成的全部合法括号串集合。那么根据上面的拆分n 对括号组成的合法括号串首尾被最外层的左右括号包住中间有 k 对括号组成的合法括号串 A右边有 n-1-k 对括号组成的合法括号串 B其中 k 的范围是 0 到 n-1。所以递推公式是f(n) { ( A ) B | k 0 .. n-1, A in f(k), B in f(n-1-k) }这里 k 表示被最外层括号包围的那部分有多少对括号。注意 A 和 B 的括号对数量加起来正好是 n-1因为最外层那一对括号已经用掉了一对。这个拆分的物理意义非常清晰而且天然不重不漏。比如 n 2 时k 0A 为空B 是 f(1) [()]得到() ()()()k 1A 是 f(1) [()]B 为空得到( () )(())。于是结果就是[()(), (())]和预期一致。3.3 递归实现闭包数法基于这个公式可以直接写递归def generate_parenthesis(n): if n 0: return [] result [] for k in range(n): for left in generate_parenthesis(k): for right in generate_parenthesis(n - 1 - k): result.append(( left ) right) return result这段代码非常简短甚至可以不用显式回溯因为每层递归的字符串都是新生成的对象不存在状态共享的问题。不过它的一个缺点是没有记忆化的情况下同一个 f(k) 会被反复计算很多次效率受影响。可以加一个缓存用lru_cache或者手动字典存一下from functools import lru_cache lru_cache(None) def generate_parenthesis(n): if n 0: return [] result [] for k in range(n): for left in generate_parenthesis(k): for right in generate_parenthesis(n - 1 - k): result.append(( left ) right) return result加上lru_cache之后这个解法的时间复杂度和回溯法基本接近而且代码更贴近组合数学的直觉在面试中作为第二种解法展示非常出彩。3.4 回溯法和闭包数法怎么选这两种解法各有适用场景。回溯法更通用它代表的是“搜索 剪枝”这一类解题范式适合大部分排列、组合、子集类问题。只要你把状态定义清楚、剪枝条件设计正确就能套用。闭包数法则更像“找规律”它依赖于括号结构本身的递归性质代码通常很短但对数学直觉要求更高。如果面试时时间充裕我建议先说回溯法把剪枝条件解释清楚然后再补充闭包数法作为优化思路。如果面试官直接问“有没有别的解法”闭包数法就是一个很好的加分项。日常练习中两种解法都要能写出来因为它们在代码风格和思维路径上差异很大都能锻炼不同的能力。4. 常见错误与排查技巧实际写代码时最容易踩的坑4.1 常见错误速查表我在带新手刷题和平时自己写代码时发现括号生成这道题的错误高度集中。列一个速查表方便直接对照排查错误类型错误现象原因分析解决方案忘了回溯撤销结果中出现大量重复/异常字符串cur.pop()被遗漏或位置错误每次递归返回后立即撤销选择剪枝条件写反出现)(这种不合法组合右括号分支条件被写成right n改为right left终止条件错误结果长度不对或永不终止用left n作为唯一终止条件用len(cur) 2*n或双条件判断起点选择错误n0 输出空列表而非[]边界条件没处理n0 时返回[]字符串拼接过慢n 稍微大一点就卡顿用字符串传参导致大量拷贝用列表 join到最终结果时才拼接其中“剪枝条件写反”是最经典的错误。很多初学者会把右括号的添加条件写成right n意思是想“右括号也最多用 n 个”但这忽略了右括号不能超过左括号这个合法性约束。结果就是生成出类似())(这样的结果左括号和右括号数量虽然是平衡的但前缀随时可能变成右括号多于左括号。提示写右括号分支时就记一句话右括号永远是“跟随”左括号出现的它只能出现在已经有左括号垫底的地方所以条件是right left不是right n。4.2 三个调试技巧调试递归类题目最实用的技巧是用一个 depth 参数或者缩进打印来观察递归过程。比如可以在 backtrack 函数开头加上print( * depth fbacktrack: cur{.join(cur)}, left{left}, right{right})这样就能清楚看到每一步进入哪个分支、何时剪枝、何时返回。尤其对于 n2、n3 这种规模打印出来的递归树一目了然很快能定位到逻辑错误。第二个技巧是写一个独立的校验函数随机生成一些结果后用校验函数验证。这个校验函数很简单遍历字符串维护一个 balance 计数器遇到左括号加一遇到右括号减一如果过程中 balance 小于 0 直接判定不合法最后 balance 必须等于 0。用这个函数跑一遍生成结果能自动发现很多肉眼看不出来的问题。第三个技巧是专门测试边界值。n0、n1、n2 这种极小规模的输入是排查错误最快的路径。如果 n2 的结果都不对就不要急着看 n5 的情况先把小规模跑通。4.3 面试中的延伸考点括号生成这道题在面试中很少只问“写个解法”面试官通常还有几个追问方向。最常见的是让你写一个函数判断给定字符串是否为合法括号串这个就是上面提到的校验函数。第二个方向是让你分析如果括号种类不止一种比如同时有(、[、{三种括号怎么做合法性判断。这个就要用到栈结构遇到左括号入栈遇到右括号弹栈并检查是否匹配本质上和括号生成的剪枝逻辑相通但写法完全不同。第三个方向是问结果数量与 n 的关系也就是卡特兰数。如果你能顺口说出卡特兰数的通项公式并且指出 n 较大时可以用组合数预计算复杂度面试官会明显满意。第四个方向是生成所有合法括号组合的变体比如要求逆序列输出或者在每个组合上额外附加权重考察你对递归改写的掌握程度。这些延伸方向并不意味着需要提前准备很多冷门知识。核心还是把基础解法吃透理解了合法性判断和剪枝的本质变体大都能在几分钟内想出来。5. 进一步优化记忆化搜索与迭代实现5.1 记忆化搜索的效率验证前面提到闭包数法可以用lru_cache优化。这个优化在 n 较小时看不出太大差别但当 n 到 8、9 时性能差距非常明显因为不加缓存时同一个子问题会被重复求解无数次。用一个简单的计数来看n 6 时闭包数法不缓存、直接递归调用 generate_parenthesis(k) 的次数会膨胀到指数级别。加上缓存后每个 k 只算一次总调用次数基本等于 f(0) 到 f(n) 的集合大小之和。这个优化思路和动态规划记忆化搜索一致在面试中可以用“自顶向下记忆化”来描述。5.2 用迭代方式生成括号组合还有一类很取巧的迭代解法让我觉得非常有意思。它的思路是从 f(1) [()] 开始每次在已有组合的基础上插入新的括号对然后去重一直迭代到 f(n)。def generate_parenthesis(n): result {} for _ in range(n): new_result set() for s in result: for i in range(len(s) 1): new_result.add(s[:i] () s[i:]) result new_result return list(result)这个写法代码很短利用集合去重保证组合不重复理解起来也很直观。它的缺点是时间复杂度不是最优因为插入位置会产生大量重复主要靠去重来兜底。它还可以顺便得出一个重要结论任意合法括号串都可以从空串开始通过反复在任意位置插入()得到。这个结论在组合数学里也有意义但作为算法题解法来讨论还是优先推荐回溯法和闭包数法。5.3 如果 n 很大怎么办只要题目的要求是“返回所有组合”n 就不可能给得太大因为输出本身会爆炸。但当 n 大到一定范围可以考虑只计算组合数量而不展开所有字符串这时直接用卡特兰数公式通过组合数取模快速计算。如果题目改成“随机生成一个合法的括号串”那就不能穷举了可以用贪心策略维护剩余左括号数 l 和剩余右括号数 r每次选择左括号的概率为l / (l r)但前提是 r 不能大于 l。这种随机生成方法在模拟和测试场景里很常见逻辑也更简单本质上还是利用了括号合法性的两个约束。6. 实操总结从题目到代码的完整复盘6.1 一题多解的知识网络括号生成这道题虽然代码量不大但它串联起来的知识点非常多。从暴力枚举到回溯剪枝从闭包数递推到动态规划思想从卡特兰数到栈结构合法性判断一道题几乎覆盖了算法面试中递归、搜索、组合数学、复杂度分析等多个核心领域。我建议刷题时不要只满足于写出一种解法。拿到题目先思考几种可能的思路哪怕其中一种效率不高也可以写出来对比一下。这种“一题多解”的训练方式比盲目追求题量有效得多。尤其是像括号生成这种题目回溯和闭包数两种解法背后的思维差异非常大能帮你建立更全面的解题视角。6.2 我的个人心得为什么这道题值得反复写每次让我推荐必刷的回溯算法题目括号生成一定是前三名。它的状态定义清晰、剪枝条件直观、代码量适中几乎没有多余的边界条件干扰是非常理想的教学案例。我自己在带新人时也经常用这道题作为递归入门的第二道题第一道通常是全排列第二道就是括号生成。全排列教会你“怎么枚举所有情况”括号生成则进一步教你“怎么在枚举过程中加约束条件”。这两道题吃透了后面对子集、组合总和、N 皇后这类问题思路都会顺畅很多。括号生成还有一个好处是结果可以用肉眼验证不像有些题目需要复杂对拍调试起来特别方便。最后分享一个我实际写代码时的习惯每次写完回溯类题目我会刻意用n 3和n 4跑一遍手动对比输出结果。n 3的结果只有 5 个一眼就能看完n 4的结果是 14 个已经需要列出来仔细核对。这两组数据能覆盖绝大多数边界情况跑通它们代码基本就稳了。这个小习惯让我在面试和不熟悉的环境里“翻车”的概率低了很多也推荐给你。
返回列表