ARTICLE DETAIL

资讯详情

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

回溯算法全攻略:从模板到剪枝,彻底搞定排列组合子集与N皇后

回溯算法全攻略:从模板到剪枝,彻底搞定排列组合子集与N皇后 回溯算法总结从模板到实战一次性理顺排列、组合、子集与棋盘问题刷题刷到回溯这个章节的时候很多人都会经历一个共同的阶段看题解觉得“哦原来如此”合上题解自己写就卡壳尤其是遇到去重、剪枝、结束条件这几个环节总有种“差一层窗户纸”的感觉。这篇文章打算把回溯算法一次性讲透——从最核心的递归模板开始到组合、排列、子集、棋盘、切割这几大经典题型的套路拆解再到剪枝优化、去重逻辑、复杂度分析的深层原理最后附上我实际调试中踩过的坑和解法思路。如果你正在备战面试或者刚学到递归回溯这篇文章应该能帮你把零散的知识点串成一条线。之所以说“串成一条线”是因为回溯算法本质上并不是一个独立的算法范式而是带剪枝的深度优先搜索DFS它解决的问题都有一个共同特征需要在多个决策阶段做选择且选择之间有约束关系最终要找满足约束的全部解或最优解。这类问题如果暴力枚举状态空间往往是指数级甚至阶乘级的回溯通过“做选择-递归-撤销选择”的循环配合约束条件剪枝能够在合理时间内搜索完整个解空间。1. 回溯算法的本质与解题框架1.1 回溯到底在做什么先看一个最朴素的问题给定一个不重复元素的数组nums [1,2,3]找出它的所有子集。大家都知道答案是 8 个但为什么是 8 个因为每个元素有“选/不选”两种状态3 个元素就是 2^3 8 种组合。回溯要做的事情就是把这棵隐式的决策树完整遍历一遍。核心思路可以用一句话概括在每一步决策点枚举当前所有可选的操作选择一个操作后进入下一层当到达终点或触发剪枝条件时撤销刚才的选择即回溯回到上一个决策点继续尝试其他选项。这里“撤销选择”是整个算法的灵魂。很多人写回溯写不明白就是漏了这一步——如果你在递归进入下一层之前把一个元素加入了路径但在递归返回后没有把它移除那么路径就会越积越长最终结果全错。1.2 万能回溯模板回溯算法的代码结构高度统一我习惯把它固定成下面这个模板——不管题目是组合、排列、子集还是棋盘问题套这个框架再按题目改条件就行def backtrack(路径, 选择列表, 其他参数): if 满足结束条件: 记录结果 return for 选择 in 选择列表: # 剪枝 if 不满足约束条件: continue # 做选择 路径.append(选择) 更新状态 # 进入下一层决策 backtrack(路径, 新的选择列表, 其他参数) # 撤销选择 路径.pop() 恢复状态这个模板有四个关键位置需要根据题目灵活调整结束条件的判断什么时候把当前路径加入结果集。选择列表的生成每一层可选的范围是什么组合和排列在这里有本质区别。剪枝条件的设置提前排除明显不可能的分支这是回溯效率的分水岭。状态更新的方式有些题需要维护额外状态比如棋盘上的攻击标记、切割问题的起始位置。1.3 为什么回溯适合这类问题换一个角度看回溯其实是在解空间树上做深度优先遍历而解空间树的规模决定了算法的时间复杂度。例如子集问题的解空间树有 2^n 个叶子节点全排列问题是 n! 个叶子节点——这样的规模决定了我们不可能用动态规划或贪心去“一下子”求解只能老老实实遍历。但遍历不等于傻遍历。剪枝的意义在于提前终止那些不可能产生有效解的路径。比如在组合总和问题里如果当前路径的和已经超过了 target那么后面无论怎么加都是超的直接 return 即可这就避免了对整棵子树的无效遍历。可以理解为回溯本身是一个“穷举”算法但好的剪枝能让它在穷举的基础上快很多倍。2. 五大经典题型套路拆解与代码详解2.1 组合问题顺序无关怎么避免重复组合组合问题的代表是“从 n 个元素中选 k 个”例如 LeetCode 77 题。它的核心特点是组合与顺序无关[1,2]和[2,1]是同一个组合。这个问题要想不产生重复组合最常用的手段是在递归中传入起始索引 start。具体来说第一层选了 1 之后第二层就只能从 2 开始选选了 2 之后第三层只能从 3 开始选。这样天然保证了一个组合内元素的相对顺序是严格递增的也就不会出现[2,1]这种重复。模板代码def combine(n: int, k: int): result [] path [] def backtrack(start: int): # 结束条件路径长度达到 k if len(path) k: result.append(path[:]) return # 剪枝如果剩余可选元素不够凑满 k 个直接结束 for i in range(start, n 1): # 关键剪枝i 到 n 的元素个数必须大于等于还需要选的个数 if (n - i 1) (k - len(path)): break path.append(i) backtrack(i 1) path.pop() backtrack(1) return result这段代码里最值得注意的就是那个剪枝判断(n - i 1) (k - len(path))。它的含义是“当前位置 i 到末尾 n 还剩多少个元素可供选择”如果连这些全部选上都凑不够 k 个那从 i 开始的所有分支都不可能凑够 k 个了直接 break 就行。这个剪枝能大幅减少无意义的递归层数尤其是在 n 远大于 k 的场景下。2.2 排列问题顺序敏感如何利用 visited 数组排列问题的代表是“给定一个数组输出所有全排列”例如 LeetCode 46 和 47。排列和组合最大的区别在于排列中 [1,2] 和 [2,1] 是两个不同的结果所以每一层的选择列表都要从整个数组的第一个元素开始遍历而不是从 start 开始。这就引出了排列问题必备的 visited 数组或布尔数组。它的作用是标记某个元素是否已经在当前路径中——如果已经用过就跳过。因为排列不能重复使用同一个元素。def permute(nums: List[int]): result [] path [] visited [False] * len(nums) def backtrack(): if len(path) len(nums): result.append(path[:]) return for i in range(len(nums)): if visited[i]: continue visited[i] True path.append(nums[i]) backtrack() path.pop() visited[i] False backtrack() return result这里有一个容易被忽视的细节visited数组的恢复必须紧跟path.pop()一样放在递归返回之后顺序不能反。因为递归返回说明“以这个元素开头的所有路径都已经探索完毕”此时必须把它释放出来才能让后续的循环继续使用它。如果忘记恢复visited那后面的元素就永远无法被选中——这就是典型的“状态污染”问题。2.3 子集问题每个节点都是答案子集问题和组合问题的代码几乎一样唯一的区别是组合问题只在叶子节点收集结果而子集问题在路径的每一步都收集结果。换句话说子集问题的结束条件不是“路径长度达到某个值”而是“当前路径本身就是一个有效子集”。def subsets(nums: List[int]): result [] path [] def backtrack(start: int): # 每一步的 path 都是一个子集直接收集 result.append(path[:]) for i in range(start, len(nums)): path.append(nums[i]) backtrack(i 1) path.pop() backtrack(0) return result这段代码看起来比组合还简单但它背后的执行过程值得仔细品味。以[1,2,3]为例递归的执行过程是第一层收集[]然后依次选 1、2、3。选了 1 之后进入第二层收集[1]然后从 2 开始选。再选了 2 进入第三层收集[1,2]然后从 3 开始选。选了 3 进入第四层收集[1,2,3]递归结束。整个过程中 result 依次收集了 8 个结果顺序是[]、[1]、[1,2]、[1,2,3]、[1,3]、[2]、[2,3]、[3]这正是深度优先遍历决策树的自然顺序。子集问题的关键认知是不需要等到“叶子”才收集答案每个递归入口的路径状态都值得记录这也是它和组合问题的本质区别所在。2.4 棋盘问题N皇后与状态标记棋盘类的代表是 N 皇后问题在 n×n 的棋盘上放置 n 个皇后使得它们彼此之间不能互相攻击不在同一行、同一列、同一对角线。这是一个非常经典的“约束满足”问题也是回溯在二维空间上的最佳例证。N 皇后的决策过程是逐行放置皇后每一列尝试一次每次尝试都检查这个位置是否和已经放置的皇后冲突。如果冲突就跳过不冲突就放置并进入下一行。def solveNQueens(n: int): result [] # cols 记录列占用diag1 记录主对角线行列diag2 记录副对角线行-列 cols [False] * n diag1 [False] * (2 * n - 1) diag2 [False] * (2 * n - 1) board [. * n for _ in range(n)] def backtrack(row: int): if row n: result.append(board[:]) return for col in range(n): d1 row col d2 row - col n - 1 if cols[col] or diag1[d1] or diag2[d2]: continue # 放置皇后 cols[col] diag1[d1] diag2[d2] True board[row] board[row][:col] Q board[row][col1:] backtrack(row 1) # 撤销放置 cols[col] diag1[d1] diag2[d2] False board[row] . * n backtrack(0) return result这个题里有三个状态数组分别对应三种冲突检查cols列冲突同一列只能有一个皇后。diag1主对角线坐标满足行列恒等例如 (0,0) 和 (1,1) 在同一主对角线。diag2副对角线坐标满足行-列恒等因为差可能为负所以加n-1偏移到数组索引范围。棋盘问题的特点在于状态不仅包括路径数组还包括额外的“冲突标记”。这些标记的作用是让每一层的冲突检查从 O(n) 降为 O(1)这是棋盘类回溯题性能的关键。如果每次放皇后都去遍历棋盘上已有的皇后逐个检查复杂度会显著上升n 稍微大一点比如 n10就会明显卡顿。2.5 切割/分割问题字符串子串的“选择列表”切割问题稍微抽象一点代表题目是“分割回文串”和“复原 IP 地址”。这类问题的特点是选择列表不是从数组中选元素而是从当前位置切一刀取出一个子串。本质上还是组合——每个切割点就是一次选择选完 i 之后下次从 i1 开始继续切。以“分割回文串”为例def partition(s: str): result [] path [] def backtrack(start: int): if start len(s): result.append(path[:]) return for end in range(start 1, len(s) 1): sub s[start:end] if sub sub[::-1]: # 判断回文 path.append(sub) backtrack(end) path.pop() backtrack(0) return result切割问题的关键认知是start表示当前切割的起始位置end表示切割结束位置不含。每一层从 start 开始枚举所有可能的 end取子串s[start:end]判断是否满足题目条件回文、IP 段合法性等满足就递归处理剩余部分。这里有一个和组合问题相同的“起始索引”技巧递归下一层时传入end而不是start 1因为切割是从 end 之后才开始的。如果传错了就会导致子串重复使用产生错误结果。3. 剪枝优化从能跑到跑快的核心手段3.1 剪枝的两种类型剪枝分为“可行剪枝”和“最优剪枝”两大类。组合、排列、子集这类求全部解的题主要用可行剪枝——提前排除那些不可能满足约束的分支。而像一些求最优解的搜索问题比如某些搜索优化问题还会用到最优剪枝——如果当前路径的代价已经大于已知最优解就直接放弃。可行剪枝最常见的场景有三个剩余元素数量不足组合问题里剩余可选元素个数小于还需要选的个数。路径已经不符合约束组合总和问题里当前和已经超过 target。重复选择的排除排序后跳过相同元素防止重复解这个在下一节详细讲。3.2 剪枝的量化收益剪枝到底能省多少时间我拿组合总和 216 题实测过一个对比。题目的约束是“找出所有和为 n 的 k 个数的组合”范围是 1 到 9。不做剪枝的情况下需要遍历 C(9,k) 个组合做了“当前和超过 target 直接终止”的剪枝后很多分支在第一层甚至第二层就被截断了。虽然理论上最坏复杂度不变但实际运行时间可以从十几毫秒降到几毫秒。另一个经典例子是 N 皇后问题。不做冲突剪枝的话n8 时解空间是 8^8 种放置方式约 1600 万种加上列、对角线的 O(1) 冲突检查后实际遍历的节点数只有约 2 万个。差距是 800 倍。这就是为什么我一直强调剪枝不是锦上添花而是回溯可用性的基础。3.3 剪枝的几个常见误区只加一个剪枝条件是不够的。很多时候需要多个剪枝逻辑叠加才能达到理想效果。比如组合总和问题既要判断当前和是否超过 target也要判断剩余元素数量是否足够。剪枝条件写错位置。剪枝判断必须放在“做选择之前”而不是递归返回之后。放在后面就完全失去了剪枝的意义。剪枝条件太严格导致漏解。比如在组合问题中有些人为了追求效率把“剩余元素不足”误写成“剩余元素等于 0 才能继续”结果把正确答案也剪掉了。剪枝的前提是你剪掉的这个分支在约束条件下一定不可能产生有效解否则就是错解。4. 去重问题的深层逻辑为什么排序是前提4.1 同层去重 vs 同枝去重含重复元素的题目是一个大坑代表是 LeetCode 40组合总和 II和 47全排列 II。这类题目要求结果中不能包含重复的组合或排列例如nums [1,1,2]的全排列[1,1,2]和另一个[1,1,2]不能出现两次。去重有两种维度同一层去重和同一树枝去重。理解这两者的区别是去重问题的关键。同层去重在同一层循环中如果前面已经用过某个值后面的相同值就不能再用了。因为以“相同的起始元素”开头的分支会产生重复结果。同枝去重路径中不能重复使用同一个元素普通排列里就是 visited 数组在做的事。很多人混淆了这两者。在“含重复元素”的题中我们需要的是同层去重——即同一个位置不能两次放入相同的值。实现方式是在循环里判断if i start and nums[i] nums[i-1]: continue但前提是数组必须先排序。4.2 为什么必须排序才能去重排序后相同的元素会相邻排列。那么当我们在循环中遇到nums[i] nums[i-1]时就说明之前已经处理过以nums[i-1]为这一层起始的完整搜索再处理nums[i]只会是重复路径直接跳过。如果不排序相同的元素可能分散在数组各个位置i start and nums[i] nums[i-1]这个判断就不成立去重也就失效了。所以“排序 跳过相邻重复元素”是含重复题型的固定前置步骤。以下是含重复元素排列的完整模板LeetCode 47def permuteUnique(nums: List[int]): nums.sort() result [] path [] used [False] * len(nums) def backtrack(): if len(path) len(nums): result.append(path[:]) return for i in range(len(nums)): # 同一个元素在路径中只能出现一次同枝去重 if used[i]: continue # 相同数值的元素在同一层只能选第一个同层去重 if i 0 and nums[i] nums[i-1] and not used[i-1]: continue used[i] True path.append(nums[i]) backtrack() path.pop() used[i] False backtrack() return result注意这里的条件写的是not used[i-1]这个细节特别关键。它表示只有当相同值的前一个元素没有被使用时才跳过当前元素。这是什么意思呢如果前一个相同元素nums[i-1]已经被用在了当前路径中说明当前路径是“允许”包含这个重复值的比如路径里需要同时包含两个 1此时当前元素可以正常使用但如果前一个元素不在路径里说明这层循环里我们已经处理过以相同的值开头的分支了当前这个就是重复的必须跳过。这个used[i-1]的判断是去重问题所有细节中最高频的出错点我建议对此多写几个测试用例验证比如[1,1,2]和[1,1,1]跑一遍体会一下逻辑。5. 时间与空间复杂度心里要有数5.1 为什么回溯的复杂度是指数级回溯的时间复杂度取决于解空间树的节点数。以子集为例每个元素有选/不选两个分支因此整棵解空间树有 2^n 个叶子所有节点大致有 2^{n1} 个所以时间复杂度是 O(2^n)。全排列的解空间树第一层有 n 个分支第二层有 n-1 个第三层有 n-2 个…… 所以叶子节点数是 n!总节点数约为 n × n!。这个复杂度是“理论最坏值”。实际运行中剪枝、约束条件会大幅减少遍历的节点数但复杂度分析时仍然要按最坏情况算——因为这决定了题目在 n 值较大时是否还能通过。5.2 一张表看懂常考回溯题复杂度题型时间复杂度空间复杂度说明子集无重复O(2^n)O(n)每个元素选/不选组合C(n,k)O(C(n,k) × k)O(k)组合数×每组合的长度全排列无重复O(n!)O(n)每层分支递减全排列有重复O(n! )O(n)加上排序 O(n log n)N 皇后O(n!)O(n²)每行尝试 n 列但很快被剪复原 IP 地址O(3^4 × 4)O(4)每段最多 3 位最多 4 段空间复杂度方面递归调用栈深度决定了核心空间。子集、组合、排列的递归深度最多为 n所以空间是 O(n)N 皇后除了 O(n) 的栈深还要存储棋盘本身 O(n²)如果不存储棋盘而用标记数组空间可降到 O(n)。5.3 实际运行时间感受我在本地实测过几个典型数据给你一个直观参考Python 3 环境子集n20结果数量 2^20 ≈ 100 万运行约 0.3 秒。全排列n10结果数量 10! 362 万运行约 1 秒。N 皇后n12共有 14200 个解运行约 3 秒。这个数据告诉我们一个规律当 n 在 20 左右时回溯勉强能跑n 超过 20指数级膨胀会让运行时间变得不可接受。所以面试中如果看到n 20左右的约束范围基本可以确定出题人想考的就是回溯/DFS。6. 调试技巧与常见错误实录6.1 剪枝判断不是剪死经验很重要这里想说写回溯最常见的错误是什么我见过最多的是三种类型忘记撤销、剪枝剪太狠、结果收集时机错误。下面是具体的表现和对应解法忘记撤销选择这是新手最容易犯的错误。表现为结果列表里出现越来越长的路径或者结果数量远多于正确值。原因是递归返回后没有执行path.pop()或没有恢复状态变量。调试方法在递归入口打印当前 path观察递归返回后 path 是否正确回到上一层的状态。只要你对每个分支做一次打印问题基本一眼就能看出来。剪枝条件写错导致漏解表现是结果数量比预期少或者缺少某些特定解。原因往往是剪枝条件的“和边界判断错了或者把“同层去重”错误实现成了“同枝去重”。调试方法先用小规模测试用例比如 n3打印所有结果和正确答案逐一比对。确认漏了哪个解后在这个解对应的分支上加打印看是哪一步被误剪了。结束条件判断错误有些题是“到达末尾才收集”有些是“每一步都收集”。比如子集问题和组合问题的结束条件完全不同。如果在子集问题里加了if len(path) k的条件那只会收集固定长度的子集显然错误。调试方法仔细读题确认答案集合到底包括哪些状态。组合、排列是收集完整路径子集是收集所有路径前缀分割问题是收集完整分割方案。6.2 推荐的调试套路一小步调试法我发现一个特别有效的回溯调试方法——“一小步调试法”。具体做法是先拿一个极小规模的输入跑一遍比如 n3 或者 k2。在回溯函数的入口处打印当前层数、start/索引位置、当前 path。手动模拟一遍递归过程和打印结果对比。这个方法比我用 IDE 断点逐个检查要快得多因为回溯的递归层级不深一般不超过 n 层打印出来的调用顺序就是决策树的遍历顺序把树形结构和代码对应起来错误自然藏不住。6.3 语言相关的坑Python 里的浅拷贝问题result.append(path)是错的必须写成result.append(path[:])或list(path)。原因是 path 是可变对象后续 pop 操作会修改它最终 result 里存的其实是同一份 list 的引用。Java/C 的引用传递类似问题也存在Java 里直接res.add(path)后继续修改 path结果同样是错误的必须new ArrayList(path)。递归函数的作用域在 Python 中如果path和result是外层函数定义的局部变量在嵌套函数里修改它们不需要nonlocal声明因为修改的是对象内容而不是重新绑定但如果要把path重新赋值为新列表则需要声明。这是一个容易踩的边界。7. 完整实战从题目描述到代码实现的完整过程理论和模板讲了不少我们来完整分析一道经典题目从需求理解、方案选型、代码编写到测试验证走一遍全流程。题目LeetCode 39组合总和。给定一个无重复元素的数组candidates和一个目标数target找出 candidates 中所有可以使数字和为 target 的组合。candidates 中的数字可以无限制重复被选取。第一步判断题目类型“找出所有可能组合”这句话明确指出这是一个求全部解的问题且组合与顺序无关。解法大概率是回溯。第二步设计决策状态路径 path当前已经选取的数字列表。当前和 current_sum需要实时维护用于剪枝。起始索引 start保证组合不重复的关键。因为数字可重复使用同一层递归传入的 start 不能是 i1 而是 i。第三步确定结束条件当 current_sum 等于 target 时收集结果并返回当 current_sum 大于 target 时直接返回可行剪枝。第四步剪枝优化这里有一个可以提前做的优化先对 candidates 排序。排序不是去重需要题目说无重复元素而是为了剪枝——如果从某个数字开始current_sum candidates[i] 已经大于 target那么后面的数字只会更大全部超 target可以直接 break 结束整层循环。第五步完整代码def combinationSum(candidates: List[int], target: int): candidates.sort() result [] path [] def backtrack(start: int, current_sum: int): if current_sum target: result.append(path[:]) return for i in range(start, len(candidates)): # 剪枝当前和加上 candidates[i] 已经超过 target # 由于 candidates 已排序后面只会更大直接 break if current_sum candidates[i] target: break path.append(candidates[i]) # 注意因为数字可以重复使用下一次递归的 start 是 i 而不是 i1 backtrack(i, current_sum candidates[i]) path.pop() backtrack(0, 0) return result第六步测试与验证用candidates [2,3,6,7], target 7手动推演选择 2 后进入下一层start 仍为 0继续尝试 2、3、6、7。路径[2,2,2]时 current_sum 6再加 2 变成 8 超 target剪枝 break。路径[2,2,3]时 current_sum 7收集结果。路径[2,3,2]不会出现因为第一层选了 2 后第二层的 start0第二次选了 2 后第三层的 start0第三次选了 3——过程是[2,2,3]不会产生[2,3,2]。这就是 start 控制顺序带来的去重效果。最终结果[[2,2,3],[7]]与标准答案一致。8. 写在最后回溯的下一步怎么走回溯掌握了之后你会发现它和很多其他算法有内在联系。比如树上的 DFS 本质上就是回溯的变体二叉树的路径问题就是“树 回溯”图的所有路径搜索也是回溯记忆化搜索递归 缓存则可以看作回溯的一种优化升级——当子问题重叠时用备忘录避免重复计算。我个人在实际刷题中的体会是回溯算法其实是最容易“背模板上岗”的算法之一但真正拉开差距的恰恰是那些模板之外的东西——剪枝的敏感度、去重的边界处理、状态恢复的严谨性。这些东西没有捷径只能靠一个题目一个题目地磨。磨的过程中你会逐渐建立一种“递归直觉”看到题目就能画出决策树画出决策树就能写出回溯写好回溯就能自然想到剪枝在哪里加。如果你刚开始接触回溯我建议把这一章里提到的五类题型各找两三道题练一遍每道题都按“画决策树 → 套模板 → 写剪枝 → 手动推演小数据”的流程走。走完十几道题回溯基本就内化成肌肉记忆了。到那个时候你再看回溯题就再也不是“背模板”的感觉而是真的能理解每一步在做什么、为什么这样做了。
返回列表