
如果你刷过一阵算法题一定遇见过这些名字全排列、八皇后、组合总和、解数独。乍一看它们各不相同底层却共用同一套思想——回溯算法。我第一次接触回溯时以为它只是 DFS 加个撤销结果连着碰了几道 LeetCode 中等题都被自己绕晕后来把模板吃透、画了大量递归树才真正开窍。这篇文章就按我的学习路径把回溯算法从原理、模板到经典实战和面试避坑完整讲一遍。适合正在刷算法题的同学们也是算法工程师面试准备中绕不开的核心考点。1. 回溯算法是什么从暴力枚举到有章可循的搜索1.1 暴力枚举为什么常常行不通最原始的做法很好理解把问题所有可能的情况全部列出来再逐个检查。比如求[1,2,3]的全排列暴力枚举会生成 6 种排列但如果集合变成 10 个元素排列数是 362880030 个元素时结果已经是天文数字。暴力枚举的局限性在于它把每个候选都当成完全平等的个体去生成和检查完全忽略问题本身自带的约束条件。以八皇后问题为例如果用暴力枚举第一步就得生成所有皇后摆放布局再去判断是否互不攻击。棋盘上 8 个皇后分布在 64 个格子里可能的位置组合规模巨大连现代计算机也吃不消。回溯算法没有神奇到绕过所有可能性它只是换了一种策略一边生成布局一边即时判断当前位置是否合法。一旦发现某个皇后放下去必然导致冲突这条分支就提前结束不再往下生成。很多无效分支根本不会被展开这才是回溯相比暴力枚举的真正优势。所以第一个认知要纠正回溯不是暴力枚举的替代品而是给暴力枚举配上约束和撤销机制后的升级版本。理解这一点后面看复杂度分析时就不会觉得回溯是万能药。1.2 把问题想象成决策树我刷题时最喜欢做的事是把搜索过程画成一棵决策树。以全排列[1,2,3]为例根节点是空路径第一层可以选 1、2、3第二层在剩余数字中选一个第三层收尾。整棵树的叶子节点就是 6 个排列。回溯算法本质上就是在这棵决策树上做深度优先遍历走到一个节点先判断有没有合法选择有就沿着某个分支走到底不行就退回上一层尝试其他分支。带着这个视角看其他题目会通透很多。组合问题对应一棵“选或不选”的树排列问题对应“每层从剩余元素里选一个”的树子集问题类似组合但每个位置只有两种状态八皇后则每层代表一行决定当前皇后该放在哪一列。几乎所有回溯题都可以转化为决策树上的搜索区别只在于树的形状、层数和分支规则不同。所以当我遇到一道新题第一件事不是写代码而是先在草稿纸上画递归树。画出树之后递归函数长什么样、终止条件是什么、需要维护哪些状态基本一眼就能看出来。这一步对初学者特别重要很多时候你觉得回溯难不是因为不会写模板而是因为脑子里没有那棵树。1.3 回溯和深度优先搜索的关系“回溯就是 DFS”这种说法我认为只说对了一半。深度优先搜索强调的是遍历图或树的顺序从一个顶点出发一条路走到底再回头。回溯则在 DFS 基础上多了两个关键动作状态标记和状态恢复。比如搜索迷宫时走过的格子要留下标记防止同一路线反复走如果当前路径失败回退时要擦掉标记否则其他分支的探索会被旧的标记干扰。这个“擦掉标记”的动作就是恢复现场也是回溯算法名字的真正由来。每个递归分支不是独立新世界而是共用同一份状态数据。当前分支把某个变量改了如果不恢复下一个分支看到的就不是初始状态而是被上一个分支污染过的状态。所以更准确地说回溯是带状态恢复的深度优先搜索并且经常配合剪枝来用。如果面试官问“回溯和普通 DFS 有什么区别”把状态恢复和剪枝这两点讲清楚基本就能过关。后面我还会专门展开恢复现场的细节这是回溯最容易出错的地方。2. 回溯算法的标准模板我每天都会默写的骨架2.1 最通用的递归框架我自己默写了无数遍的模板长这样以 Python 为例def backtrack(路径, 选择列表): if 满足结束条件: 记录结果 return for 选择 in 选择列表: if 不满足约束条件: continue # 剪枝 做选择 backtrack(更新后的路径, 更新后的选择列表) 撤销选择这个模板有四个关键位置缺一不可。第一终止条件判断当前路径是不是一个完整解是就加入结果集第二约束判断进入每个分支前把非法选择过滤掉这是剪枝的基础第三递归调用带着更新后的状态往下一层走第四撤销选择递归返回后恢复现场让 for 循环的下一个选择可以基于干净状态继续尝试。我刚开始练回溯时总是漏掉最后一步。写出来的代码看起来没问题但运行结果各种串状态。后来我把“做选择、递归、撤销选择”当成一个不可拆分的整体写完代码第一件事就是检查这三句有没有配齐。这个习惯帮我避免了很多莫名其妙的 bug。如果换成 C代码结构几乎一模一样只是要小心参数引用传递void backtrack(vectorint path, vectorint nums, vectorbool used, vectorvectorint res) { if (path.size() nums.size()) { res.push_back(path); return; } for (int i 0; i nums.size(); i) { if (used[i]) continue; used[i] true; path.push_back(nums[i]); backtrack(path, nums, used, res); path.pop_back(); used[i] false; } }语言只是外壳骨架才是灵魂。熟练背下这套模板能省掉大量重复书写的时间把精力放在剪枝和状态设计上。2.2 剪枝不是所有选择都值得走模板里的continue是最基础的剪枝但剪枝的威力远不止过滤一个 used 标记。拿组合总和问题来说假设候选数组是[2,3,6,7]目标是 7。只要先把候选排序当某一步当前数字已经大于剩余目标时后面的数字只会更大这一层剩下的循环就不用再跑直接 break。这一步看着简单但遇到大 target 时能把整个搜索树砍掉一大半。剪枝的本质是提前判断“即使继续沿着这条分支走下去也一定不可能得到合法解”从而避免无效递归。判断依据需要针对具体题目设计我常用的有几种剩余空间或剩余次数不够用当前选择已经违反硬性约束后面候选数字无论怎么组合都满足不了目标。能写出这几类剪枝说明你对题目的约束条件理解已经很到位。生活里也有类似的场景。逛超市买预算有限的零食看一眼价格就知道超预算的商品根本不需要拿起来再放回去。搜索树只有几层时多拿几次不累但一旦节点数达到百万级少走一层递归带来的提升都是质变。所以我会建议每写完一个回溯题都回头想想是否还能再加剪枝。能加剪枝的地方往往就是面试官想听你讲清楚的亮点。2.3 状态重置回溯名字的真正由来恢复现场是回溯最容易错也最核心的地方。举个例子在字母矩阵里搜索单词每匹配一个字符就递归上下左右但搜索时必须记住哪些格子已经用过了。如果某条路径失败递归返回时要立刻把当前格子重置为“未访问”否则另一条路径想借道时会被旧标记挡住。没重置的后果就是搜索空间越来越小甚至直接漏掉正确答案。为什么必须撤销因为路径和状态变量在递归过程中是共享的。递归不是每次创建新宇宙而是一层层在同一份数据上做修改。当前层返回时必须把数据恢复成进入该层之前的样子兄弟分支才能看到干净的局面。如果不恢复你看到的“选择列表”其实混着已经失败路径留下的痕迹。我自己的习惯是所有在递归前修改过的标记一定要在递归后对应恢复。最好是“改了什么就记得什么递归回来原样还原”比如数组、集合、计数哈希表、状态数组。恢复现场做得干净回溯题就成功了一半。很多看着很玄的 bug最后排查一圈发现就是漏了一次状态重置。3. 三道经典题目一次弄清回溯的套路3.1 全排列先学会做选择全排列是回溯的入门经典理解了它后面的题目本质上只是换皮。给定一个不含重复数字的数组nums [1,2,3]要求返回所有排列。代码实现如下class Solution: def permute(self, nums: List[int]) - List[List[int]]: res [] path [] used [False] * len(nums) def dfs(): if len(path) len(nums): res.append(path[:]) return for i in range(len(nums)): if used[i]: continue used[i] True path.append(nums[i]) dfs() path.pop() used[i] False dfs() return res这里有两个容易踩的坑。第一个坑res.append(path[:])为什么不能直接写res.append(path)因为path是一个引用递归结束后path会不断被修改。如果直接把引用放进结果集最终结果里所有排列都会变成空列表。path[:]相当于做了一次拷贝把当前快照存下来。这个坑十个人里有八个栽过。第二个坑如果nums里含有重复元素比如[1,1,2]上面的代码会生成重复排列。解决办法是先排序然后在同一层循环里跳过值相同且前一个相同值已经用过的元素。核心判断是i 0 and nums[i] nums[i-1] and not used[i-1]。想清楚“同层去重”和“不同层不去重”的区别需要你对递归树每一层的 used 状态有清晰理解。全排列的时间复杂度是 O(n!)空间复杂度是递归深度 O(n)。虽然 n 稍微大一点就非常恐怖但面试官想要的往往不是高效解法而是你能不能清晰地用回溯讲出搜索过程。3.2 八皇后用约束让暴力搜索变得优雅八皇后问题要求在一个 n×n 的棋盘上放置 n 个皇后使得它们两两不在同一行、同一列、同一对角线上。经典解法就是逐行放置皇后每行只能放一个然后判断列和对角线是否冲突。先看如何表示冲突状态。列冲突用一个数组cols记录哪些列已经被占用。对角线冲突需要两组数组主对角线满足row - col是一个固定值副对角线满足row col是一个固定值。因为差值可能是负数统一加上n - 1平移到非负区间方便用数组索引。def solveNQueens(n): res [] board [[.] * n for _ in range(n)] cols [False] * n diag1 [False] * (2 * n - 1) # row - col n - 1 diag2 [False] * (2 * n - 1) # row col def backtrack(row): if row n: res.append([.join(r) for r in 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 board[row][col] Q cols[col] diag1[d1] diag2[d2] True backtrack(row 1) board[row][col] . cols[col] diag1[d1] diag2[d2] False backtrack(0) return res这个解法的核心在于每一层递归只做一件事决定当前行的皇后放在哪一列。如果列、主对角线或副对角线已经被占用直接剪枝。如果不冲突就放下皇后并更新三个标记数组然后进入下一行。递归返回后一定要恢复所有标记否则后面的行会被当前残留状态污染。八皇后问题能很好地说明回溯的“约束”有多重要。暴力枚举所有棋盘布局再判断成本极高而回溯是边放边判断一旦某行找不到合法列就立刻返回不会继续往下尝试。这样剪枝之后即使是 n8实际搜索节点数也远小于所有布局数。我第一次跑出全部 92 个解时才真正体会到什么叫“优雅的暴力”。3.3 组合总和剪枝让性能起飞组合总和是一道非常能体现剪枝价值的题目。给定一个无重复元素的候选数组candidates和一个目标数target找出所有可以使数字和等于 target 的组合每个数字可以被重复使用。解法是先排序然后递归选择当前数或跳过当前数。示例代码def combinationSum(candidates, target): candidates.sort() res [] def backtrack(start, remain, path): if remain 0: res.append(path[:]) return for i in range(start, len(candidates)): if candidates[i] remain: break path.append(candidates[i]) backtrack(i, remain - candidates[i], path) path.pop() backtrack(0, target, []) return res这里有两个关键设计。第一个是start参数它保证在递归中只能选择当前位置和更后面的元素从而避免出现重复组合。比如[2,2,3]和[2,3,2]本质是同一组结果start限制了选择方向这类重复就不会发生。第二个是排序加 break 的剪枝逻辑。因为数组已经升序排列当candidates[i] remain时后面的所有元素都大于 remain再加入任何元素都会超过目标值这一层循环完全可以提前结束。如果不排序就只能用continue试完所有元素效率会差很多。这里 break 和 continue 的区别就是剪枝彻底不彻底的区别。组合总和这类题在面试中出现频率很高原因就是它同时考察了三个能力递归模板是否熟练、去重逻辑是否清晰、剪枝优化是否到位。把这道题吃透组合、子集、排列这三类题目基本能形成体系后面再遇到变种也不会慌。4. 回溯算法的复杂度与选型判断4.1 怎么估算回溯的复杂度回溯问题的复杂度往往不是固定值而是取决于剪枝效果。理论最坏情况要看决策树的节点数乘以单次状态更新的代价。比如全排列第一层有 n 个分支第二层有 n-1 个分支最终节点数接近 n!所以复杂度是 O(n!)。子集问题每个元素只有选和不选两种状态节点数是 2^n所以复杂度 O(2^n)。组合总和则更复杂最坏情况下每层分支有很多个但排序加剪枝后实际会低很多。我有个快速估算套路先看递归深度也就是决策层数再看每一层的平均分支数量。如果深度为 d平均分支数为 b未剪枝的复杂度大致是 O(b^d)再加上每次状态更新的常数代价。空间复杂度主要看递归深度乘以路径存储的大小通常也是 O(d)。面试被问复杂度时先说最坏情况再补一句“具体表现依赖剪枝效果”。比如全排列答案数量基本固定剪枝空间不大组合总和则能通过排序break 大幅压缩。这样回答既诚实又展现了你对剪枝的思考。4.2 回溯和动态规划的边界很多题目回溯能做但动态规划效率更高。怎么判断用哪种我总结了一个很实用的标准如果题目要求输出所有具体方案比如排列、组合、路径那基本是回溯的领域如果只求最值或方案数并且存在重叠子问题优先考虑动态规划如果路径状态很难提取成转移方程只能递归枚举那就用回溯。拿爬楼梯问题举例求到达第 n 级台阶的方法数dp[i] dp[i-1] dp[i-2]一行就解决了。用回溯也可以数出所有走法但同一层状态会被反复计算指数级复杂度完全是浪费。组合总和求方案列表时情况则相反因为每个组合都依赖具体路径子问题状态复杂状态转移不好写回溯加剪枝反而更自然。这里还要提一个中间状态记忆化搜索。很多回溯算法会处理重叠子问题如果把递归中间结果缓存下来就能把指数级搜索大幅加速。我刷题时经常先把普通回溯写出来再分析哪些状态会被重复计算然后用字典缓存。这样过渡到动态规划会顺滑很多也是工程中常见的“搜索缓存”套路。4.3 工程里的回溯数独、路径规划与更多别以为回溯只出现在面试题里。数独求解器是教科书式的回溯案例迷宫寻路用 DFS回溯找路径游戏 AI 里的决策树搜索、约束满足问题比如排课、资源分配也在用类似思想。我在实际项目里写过一个小型排班工具每天有多个员工可选每个班次有时段、技能和工时约束目标是为每个人排出合法轮次。回溯在这里天然适配先按天放人放不下就回退换人再配合工时上限剪枝搜索空间降到可接受范围。工程应用中不会追求把所有组合都列出来而是结合启发式规则和成本估算。比如数独求解可以用最小剩余值启发式也就是优先填可能性最少的格子大幅度减少分支。这种思路和算法竞赛里的剪枝是一脉相承的。所以我建议大家不要只把回溯当作刷题工具它是一种通用搜索思维遇到需要“尝试-失败-回头再试”的现实问题时都能第一时间想到。5. 刷题和面试避坑实录5.1 那些年我踩过的回溯的坑我把常见的回溯错误整理成一个表格每条都是我或者身边朋友真实踩过的坑错误症状对策递归后没有撤销选择答案缺少、重复或完全错乱把撤销动作放进模板当成递归的固定后缀res.append(path)而不是path[:]结果集里全是空列表结果保存时一定拷贝当前路径快照重复元素没去重全排列、子集出现多个重复结果先排序再在同一层跳过相同值剪枝条件写反输出结果缺失或者死循环先用最小示例手动模拟一遍分支条件修改全局标记没恢复后续分支状态错乱进入递归前改了多少返回后原样还原终止条件漏写递归不断加深直到栈溢出每层递归入口先检查结束条件其中“递归后没有撤销”是最常见也最隐蔽的。原因通常不是不知道要撤销而是写代码时手一快就漏了。我的建议是刷题阶段不要追求速度写完代码后逐行检查做选择和撤销是否对称。也可以借助调试器在递归入口打印当前 path就能看见状态是否串了。还有一个很容易被忽略的坑剪枝写得太激进。有些同学为了让性能好看加了很复杂的剪枝条件结果误伤了合法分支。我的经验是剪枝条件一定要“安全”严格基于事实上不可能得到解而不要依赖直觉。拿不准的时候先不加剪枝跑一次确认结果正确后再加上剪枝再跑一次看结果是否一致。5.2 面试现场怎么讲回溯算法面试官问你一道回溯题千万不要一上来就写代码。先讲思路我一般会按五个步骤组织答案定义递归函数当前做到第几层路径里已经有什么选择终止条件什么时候可以将当前路径记为合法解分支策略本轮可选元素有哪些剪枝策略哪些选择可以直接跳过为什么安全状态恢复哪些标记需要在递归返回后还原。比如遇到全排列我会说“递归函数表示当前已经拼到第几位终止条件是路径长度等于数组长度每层遍历所有数字用 used 数组过滤已选数字递归完成后擦除 used 和 path 里的记录。” 这样面试官能快速抓住你的思维方式。复杂度分析也要主动讲。先给最坏情况比如全排列 O(n!)再说明为什么剪枝常数不大如果题目里有排序剪枝就把优化后的表现也说出来。最后代码写完不要立刻结束主动走一个小的输入示例比如[1,2,3]的前两步递归既给面试官留好印象又能让自己发现隐藏的边界 bug。5.3 一套值得反复练习的题目清单如果你想系统刷回溯我建议按下面的顺序来入门LeetCode 17 电话号码的字母组合、46 全排列、78 子集进阶90 子集 II、47 全排列 II、39 组合总和、40 组合总和 II经典51 八皇后、37 解数独图搜索型79 单词搜索我的习惯是每道题先默写模板再画递归树最后用最小输入手工模拟一遍。像 47 和 40 这种去重变种尤其要在纸上把“同层去重”和“不同层不去重”的差异画清楚。刷完这十道你会发现自己看很多 hard 题都不再恐惧因为代码结构实在太像了。对我个人而言刷到后面最深的感触是回溯刷的不是代码量而是对搜索空间的敏感度。遇到一道题先画递归树再问自己三个问题决策有几层每层有哪些选择哪些选择可以直接砍掉想清楚这三件事代码基本顺水推舟。如果你刚开始学别急着跳过恢复现场把每一步递归的进出栈都写在纸上过一遍。这份笨功夫之后看很多难题都会豁然开朗。希望这篇文章能让你少走一点我曾经走过的弯路。