括号生成:用DFS+剪枝秒懂回溯算法的经典题
发布时间:2026/9/30 4:01:19来源:尧图网络
有一次模拟面试我拿LeetCode热题里的经典题“括号生成”给一个准备跳槽的朋友练手。题目几句话就能说清给定n生成所有由n对括号组成的有效组合。结果他在纸上划了半天第一版方案是“把所有左右括号全排列出来再用栈过滤一遍”。我当时就知道这道题对他来说是“做过了”而不是“想透了”。括号生成的真正考点从来不是“怎么判断一个括号串是否合法”而是“怎么在构造路径里保证每一步都不走歪”——这是一个深度优先搜索加剪枝的构造题。无论你是准备面试、刷LeetCode热门100题还是单纯想搞懂回溯算法这道题都是绕不过去的一块试金石。1. 为什么这道题不是“排列组合题”——先纠正两个常见思路我见过太多人拿到这道题的第一反应是“排列组合”。这个直觉不算错因为有效括号串确实可以看成左右括号的一种排列但它忽略了问题的关键括号串的合法性依赖于结构不是单纯的数量相等。顺着排列组合往下走通常会踩进两个典型的坑。1.1 错误思路A枚举所有排列再校验合法性这种做法的流程是先把n个左括号和n个右括号放进一个数组用一个类似“全排列”的写法把所有不区分同类的排列都列出来然后对每个串做一遍栈扫描或计数器扫描合法的留下。从数据规模上感受一下它有多浪费。n8时总候选数是C(16,8)12870个其中有效结果只有1430个差不多每9个候选里才有一个合法。n12时更夸张候选数是C(24,12)2704156而有效结果是卡特兰数C_12208012每13个里才有一个合法。别忘了每次校验要从头到尾扫一遍长度为2n的串这个开销再乘上去暴力法到n12左右就明显笨重了。虽然LeetCode原题n的范围不大暴力法在n8也能跑出答案但面试官出一道经典回溯题目的不是看你能不能完整跑出结果而是看你能否快速识别“这是一个带剪枝的DFS构造过程”。你交一版暴力枚举上去他大概率会追问“n再大一点怎么办”到时候你再改回溯等于把思路重来一遍非常被动。1.2 错误思路B随机插入括号对再查重还有人是反过来想的从空串开始不断在任意位置插入一对“()”插入n次后把得到的串去重。这个思路其实有数学背景每个有效括号串确实可以由某些基础串插入得到问题在于插入位置没有约束会产生大量重复。比如n3时像“()()()”这种结构你很难说清楚它是先插入哪个位置得到的。同一个最终串可以由很多不同的插入路径到达所以你不得不挂一个哈希集合去重。结果是代码又长又绕状态空间完全说不清运行效率还低。更麻烦的是去重后的集合仍然是一个“黑盒”面试官让你分析复杂度时你根本给不出干净的结论。这种方案不是完全不能跑但它把“构造”问题变成了一个“排列去重”问题等于主动放弃了DFS天然无重复的优势。1.3 正确视角每个中间状态都必须是合法前缀回到问题本质。一个括号串从左边往右扫描任意时刻右括号数量都不能超过左括号数量最终左右数量相等这个串就一定合法。反过来只要构造过程中每一步都满足“右括号数不超过左括号数”最终左右数量相等时你得到的一定是有效括号串。这句话就是整道题的核心。我们可以把判断条件从“整体合法”改成“前缀合法”。校验一个完整串需要O(n)但如果我在构造过程中就保证每一步都是合法前缀那么到最后根本不需要校验天然就是答案。这就是DFS剪枝的思路每一个递归状态代表一个中间前缀我只往前探索那些“仍有机会变成合法结果”的分支走不通的分支直接不进去。比较一下三种方式的节点规模差距非常明显方案探索状态规模是否需要二次校验是否产生重复全排列校验C(2n, n)是O(n)扫描无重复但浪费插入括号对去重指数级且路径重复否大量重复需要set回溯剪枝与卡特兰数同量级否天然无重复理解了“前缀合法”这个视角后面整个递归设计就顺理成章了。2. 回溯法背后的状态树和两个剪枝条件回溯的本质是一种结构化的深度优先遍历。对“括号生成”来说每个递归节点都可以定义为一个三元组(left, right, path)left是已经放下的左括号数right是已经放下的右括号数path是当前构造出的前缀。2.1 状态定义和递归函数怎么设计我习惯把递归出口写在最前面当len(path) 2 * n也就是left n且right n时把path加入结果集。这个出口不需要额外判断“这个串是否合法”因为我在往下走的时候已经保证了每一个前缀都合法。入口调用是dfs(0, 0, )从空串开始。每层递归只做两件事尝试放一个左括号尝试放一个右括号。这两个尝试不是无条件的而是对应两个剪枝条件。2.2 左括号分支left n管的是上限左括号可以放的条件是left n也就是当前已经使用的左括号数量还没有达到上限n。这个条件很直白总共有n对括号你不可能放下超过n个左括号。为什么这个条件放在“尝试分支”里而不是放在“生成后再校验”因为一旦left等于n后面所有再放左括号的分支都是死路没必要进去。这个剪枝把状态树的规模直接砍掉一大截。2.3 右括号分支right left管的是合法性右括号可以放的条件是right left这是整道题最精妙的一刀。它的意思是只有当已经放下的右括号数量严格小于左括号数量时才能再放一个右括号。换句话说如果当前前缀里左括号和右括号数量相等说明目前所有左括号都被匹配完了是一个“栈空”的状态。这时候你再放一个右括号前面就多出一个无处匹配的右括号这个前缀已经死了后面无论怎么补都不可能合法。所以分支条件不是“right n”而是“right left”。2.4 从状态树看所有有效路径用n3走一遍状态树会从(0,0)出发先进入(1,0)然后有两条分支左到(2,0)右到(1,1)。继续展开任何一个节点只要满足right left就还活着一旦遇到right left右分支被禁掉只能继续加左括号。最终所有到达(3,3)的路径都对应一个有效结果。如果把状态节点看成平面上的坐标点横轴是左括号数纵轴是右括号数那么整个构造过程就是从(0,0)走到(n,n)的一条路径而且路径上的每个点都落在对角线下方或刚好在对角线上。所有不越过对角线的走法总数恰好是卡特兰数。这个几何视角我后面讲复杂度还会用到建议你现在脑子里就有这么一张三角区域图。3. 代码实现细节与三个高频翻车点思路清楚了代码其实很短。但越是短的递归题越容易在小细节上翻车。我把常用写法和三个高频问题一起说清楚。3.1 先给一版能直接过LeetCode的Python写法def generateParenthesis(n: int) - list[str]: res [] def dfs(left: int, right: int, path: str) - None: if len(path) 2 * n: res.append(path) return if left n: dfs(left 1, right, path () if right left: dfs(left, right 1, path )) dfs(0, 0, ) return res这个版本我用的是字符串不可变拼接完全没有显式回溯的负担。如果你平时写Java用StringBuilder就得注意恢复现场我给一个完整可跑的版本class Solution { public ListString generateParenthesis(int n) { ListString res new ArrayList(); dfs(n, 0, 0, new StringBuilder(), res); return res; } private void dfs(int n, int left, int right, StringBuilder sb, ListString res) { if (left n right n) { res.add(sb.toString()); return; } if (left n) { sb.append((); dfs(n, left 1, right, sb, res); sb.deleteCharAt(sb.length() - 1); } if (right left) { sb.append()); dfs(n, left, right 1, sb, res); sb.deleteCharAt(sb.length() - 1); } } }Python版和Java版的核心差异就在path的类型。String是不可变对象path (会创建一个新字符串递归完不需要回滚StringBuilder是可变对象进入递归前append出来必须deleteCharAt否则会污染上一层状态。3.2 翻车点一right n和right left一字之差结果全错我知道有些人会把右分支条件写成if (right n)。表面上它也很合理最多n个右括号嘛没超就能加。但请仔细想n2的一个具体分支。从空串出发先放左括号得到“(”left1right0。这时如果走右分支得到“()”left1right1。在这个节点上right n仍然成立因为1 2。如果用right n作为条件你就可以再加一个右括号得到“())”此时left1right2。然后left还小于n再加一个左括号得到“())(”len4被当成结果收集了。这显然不是一个有效括号串。把条件改成right left之后“()”这个节点right1left1right left不成立右分支被禁掉只能走左分支生成“(())”。看到差距了吗一字之差一个会混进非法结果一个干净利落。做题时如果你发现产出了类似“())(这种串第一排查点就是这个条件。3.3 翻车点二可变对象忘了恢复现场Java版本里最容易犯的错是只append不deleteCharAt。一旦忘了恢复递归回到上一层时StringBuilder尾巴上还挂着一个刚才加进去的括号下一分支叠加上去整个前缀就乱了。我的建议是凡是递归里使用可变容器先想清楚“进入递归前改了什么出递归后必须马上还原”。顺序上也别颠倒正确写法是append、递归、deleteCharAt三步连着写。如果是C选手用vector 或string同样要注意pop_back。相比之下Python写成path (虽然每次生成新字符串有拷贝开销但代码最不容易出错新手我建议先用这种写法把逻辑跑通。3.4 翻车点三n0的边界与结果集的拷贝开销LeetCode原题n从1开始但有些变体题不保证。如果n0len(path) 2 * n这个出口在递归入口处就成立会把空串“”加进结果集返回[]。在某些题目的预期里n0应该返回[]。稳妥的做法是在入口直接特判if (n 0) return new ArrayList();。还有一个容易忽略的性能点递归每到一个结果就把path转成字符串存进结果集这一步是必要的因为path后面还会被复用。但注意别把整个path的拷贝提前到每一层递归里做那样会把时间浪费在不可能成为结果的中间前缀上。只在len(path) 2*n那一刻做一次拷贝就够了。4. 复杂度为什么是卡特兰数——面试追问的标准答案这道题在面试里几乎必被追问复杂度。如果你只说“指数级”面试官不会满意因为“指数级”太笼统。括号生成的复杂度对应的是一族非常具体的数卡特兰数。4.1 合法括号串的数量就是卡特兰数卡特兰数列的前几项是C_01C_11C_22C_35C_414C_542C_6132。n3的结果集正好是5个n4是14个跟卡特兰数完全对上。为什么是这个数列经典解释是“闭合数递推”。任何一个有效括号串第一个字符一定是左括号它一定有一个匹配的右括号。这个右括号把整个串分成了三块左括号、被它包裹的内部、以及它右侧的剩余部分。内部是一个合法括号串右侧的剩余部分也必须是一个合法括号串。如果内部有k对括号右侧就有n-1-k对k可以从0取到n-1。所以C_n Σ(C_k × C_{n-1-k})k从0到n-1。这个递推式就是卡特兰数的定义式。这个递推式同时给了我们另一种解法动态规划。这里一并写出来dp[i]表示i对括号的所有合法组合def generateParenthesis(n: int) - list[str]: dp [[] for _ in range(n 1)] dp[0] [] for i in range(1, n 1): for k in range(i): for a in dp[k]: for b in dp[i - 1 - k]: dp[i].append(( a ) b) return dp[n]dp[i]的构造就是“左括号 内部k对的组合 右括号 剩余i-1-k对的组合”。这个写法和回溯解法的时间复杂度同量级但思路不同建议一起掌握。4.2 时间复杂度O(4^n/√n)是怎么来的卡特兰数的渐近公式是C_n ≈ 4^n / (n^(3/2) √π)。回溯算法每生成一条有效路径需要往结果里加一个长度为2n的字符串时间复杂度要乘上2n这个构造开销。所以总复杂度大概是 O(2n × C_n)化简后得到 O(4^n / √n)。我见过很多人把复杂度说成O(2^(2n))那是不准确的。2^(2n)是未剪枝的全排列量级而回溯通过“left n”和“right left”两个剪枝把状态量压到了卡特兰数的量级两者差距非常大。n10时2^(2n)超过一百万卡特兰数C_10是16796差了六十多倍。4.3 空间复杂度递归栈与结果集分开算不考虑结果集的话回溯递归深度最多2n所以空间复杂度是O(n)。但结果集本身要存所有答案每个答案长度2n数量是C_n所以额外空间是O(n × C_n)。面试时通常先说“不计结果集是O(n)”再把结果集部分补上这样显得你考虑得比较完整。4.4 另一种解法动态规划按闭合数递推刚才给过DP代码这里重点说说它和回溯的关系。回溯是“从前往后构造”DP是“从小到大拼装”。dp[k]的每个结果会被反复用于构造更大的i天然带了一点记忆化的味道而回溯每次都是从空串重新走一条完整路径。两者都能过题但DP更适合作为面试官追问“有没有其他解法”时的备选答案。5. 从这一题发散出去括号类问题与刷题顺序如果你刷题刷到这份上会发现“括号生成”几乎是一整类题目的源头。最近LeetCode热榜上能看到很多跟括号相关的题目都是把同一个思想换了个场景又包装了一遍。5.1 括号主题下的一条完整刷题链路我把实际刷题时最有联动价值的几道题整理成一张表你可以按这个顺序往下刷题目核心思路和括号生成的关系20. 有效的括号栈匹配括号生成的剪枝条件就是从这题的判断逻辑来的22. 括号生成DFS 剪枝本题32. 最长有效括号栈或动态规划反过来求最长合法片段考察对称思考678. 有效的括号字符串双栈或贪心加了星号通配符合法性定义放宽301. 删除无效括号BFS/回溯 剪枝需要枚举删哪些括号是括号生成的逆向题224. 基本计算器栈处理表达式遇到括号要先递归或压栈和栈思想同源比如“基本计算器”它的难点之一就是括号带来的优先级变化处理方式和栈的压栈弹栈一脉相承“删除无效括号”则是在一堆括号里反推删掉哪些可以让整体合法正好用到“前缀合法”的判断思路。刷完“括号生成”再去做这几道你会明显感觉到思维是复用的。5.2 面试官追问时该怎么接如果面试官在“括号生成”这题上继续加问最常出现的三个追问分别是第一个是“n非常大怎么办”。注意这时候不是让你优化算法而是要你说清楚输出本身就有卡特兰数个结果这个数量是指数级的所以不可能有多项式时间算法。你能做的是尽量减少无效中间状态但最终还是要构造出所有结果。第二个是“能不能不用递归”。当然可以。你可以自己维护一个显式栈把(left, right, path)三元组手动压栈弹栈用迭代方式完成DFS或者用层序BFS每一层基于上一层的合法前缀扩展左右括号。面试中偶尔有人用BFS写这题确实也能AC但代码可读性通常不如递归。我建议你背熟递归写法然后对“显式栈迭代”有个概念就行。第三个是“如果输入不只是括号还有中括号和大括号怎么办”。这就是LeetCode 20题的超集处理思路是把配对关系做成一个映射表每当准备放右括号时检查它与栈顶左括号是否匹配。生成的逻辑会更复杂但核心还是“前缀合法”这一套。5.3 我的个人刷题习惯与建议每次带人刷这道题我都会强调一个笨办法先把n3的递归树完整画一遍再写代码。你别嫌麻烦这张图能把状态、剪枝、结果收集全部串起来画完之后你会觉得整道题的递归调用关系是“看得见”的。代码层面我有个小习惯变量名统一用left和right不要一会儿open一会儿close。这两个词对应的语义是“已使用数量”写出来以后读代码的人一眼就能明白剪枝条件为什么是left n和right left。如果你已经能默写回溯版本了我建议再做一次“一题三做”回溯法写一遍动态规划写一遍最后用显式栈模拟递归写一遍。三遍下来你收获的不只是AC而是对DFS、递推、手动栈这三个工具都有了一次很扎实的练习。之后遇到任何“列出所有满足某规则的组合”类问题你都能第一时间联想到今天画的这棵状态树。
网站建设高端定制企业官网