回溯算法听起来像是计算机竞赛里才会用到的高级玩法,但实际上,你刷 LeetCode 遇到的全排列、N 皇后、子集组合、数独求解、括号生成,本质上都是回溯。哪怕你平时不搞竞赛,只要写过需要“枚举所有可能情况再筛选”的逻辑,你其实已经在用回溯的思路了,只是自己没意识到。
这篇文章我会从零开始,把回溯算法拆开揉碎讲清楚:它到底解决什么问题,代码模板长什么样,剪枝是怎么一回事,以及我实际刷题和写业务逻辑时踩过的坑。你可以把它当成一篇能直接抄作业的实操笔记,也可以当成理解回溯底层逻辑的入门指南。无论你是刚开始学数据结构与算法的萌新,还是准备面试想系统性回顾回溯的求职者,这篇都适合你。
1. 回溯算法到底在解决什么问题——先搞懂它的定位
1.1 从暴力搜索到回溯:为什么不是把所有方案全列出来
我第一次接触回溯时,第一反应是:这不就是暴力破解吗?把所有可能性都试一遍,然后把符合条件的留下来。这个理解方向是对的,但只说对了一半。
暴力枚举确实可以把所有方案列出来,比如你要生成三个元素[1,2,3]的全排列,那就三层循环嵌套,把所有组合都跑一遍。但问题在于:当规模变大时,暴力枚举的复杂度会爆炸。4 个元素的全排列有 24 种,5 个是 120 种,10 个就是 3628800 种,涨得非常快。更麻烦的是,很多问题并不是“把所有排列生成出来”那么简单,而是带有约束条件的搜索,比如 N 皇后问题里,你需要在放置皇后的过程中实时判断“这个位置能不能放”,如果你硬生生把所有棋盘状态都枚举出来再判断,那复杂度高到没法看。
回溯算法的价值就在这里:它同样是在搜索解空间,但它是边搜索边判断,一旦发现当前路径已经不可能通向合法答案,就立刻掉头走人。这个“掉头走人”的动作,就是回溯里最核心的“撤销选择”和“往回走”。
我做个生活化类比:你在一个迷宫里找出口,暴力枚举的做法是“把所有可能的路径都走一遍,最后统计哪条能到出口”,而回溯的做法是“每到一个岔路口就选一条走,如果走着走着发现前面是死胡同,就退回到上一个岔路口,换另一条路试试”。这种“走到头发现不行再退回来”的探索方式,就是回溯。
1.2 回溯的三板斧:选择、递归、撤销
回溯算法实现起来其实非常公式化。我总结下来,核心就是三个动作反复执行:
- 选择:在当前状态下,从可选列表里挑一个元素加入路径。
- 递归:基于这个选择,进入下一层状态,继续做选择。
- 撤销:从路径中移除刚才选择的元素,恢复到选择之前的状态,尝试下一种可能。
这三个动作对应到代码上就是一套非常固定的模板:
def backtrack(路径, 选择列表): if 满足结束条件: 记录结果 return for 选择 in 选择列表: # 剪枝判断:如果这个选择不可能通向合法结果 if 选择不合法: continue # 做选择 路径.append(选择) # 递归进入下一层 backtrack(路径, 新的选择列表) # 撤销选择 路径.pop()这个模板你背下来之后,绝大多数回溯题目都能往里面套。但背模板只是第一步,真正拉开差距的是另外两件事:一是怎么构建状态树,二是怎么设计剪枝条件。这俩才是回溯的灵魂,后面我逐一展开。
2. 解空间与剪枝策略:决定回溯性能的关键在哪里
2.1 两类解空间:子集树和排列树
刷题多了你会发现,回溯题虽然千变万化,但解空间其实只有两类。
第一种叫子集树,解决的是“从 n 个元素中选若干元素”这类问题,比如全组合、子集、找出所有和为 target 的组合等。这类问题的特点是:每个元素只有“选”和“不选”两种状态,所以解空间树是一个二叉树,每一层对应一个元素的决策。
第二种叫排列树,解决的是“对 n 个元素进行全排列”这类问题,比如全排列、N 皇后、旅行商问题等。这类问题的特点是:每一步选择一个元素放到当前位置,而且已经选过的元素不能重复选,所以解空间树的每层分支数是当前未被选择的元素个数。
理解这两种树的区别至关重要,因为它直接决定了你的递归函数里有几层循环、选择和撤销的逻辑怎么设计。比如做子集问题时,循环的起点往往会传入一个startIndex来控制“当前层从哪个元素开始尝试”;而做排列问题时,就需要额外用一个visited数组来记录哪些元素已经被选过。
2.2 剪枝的三种常见类型
剪枝是回溯面试里最常被问的点。简单说,剪枝就是“提前判断这条路走不通,省得浪费递归次数”。我把它分成三类:
第一类是可行性剪枝,也是最常见的一种。当前选择明显不合法,直接跳过。比如 N 皇后里,新放的皇后和已有皇后在同一列或者对角线上,那这个位置直接放弃,不再向下递归。再比如组合总和里,当前数字已经大于剩余目标值,后面的更大数字也都不用试了,直接退出循环。
第二类是重复性剪枝,主要用于去除重复结果。处理“数组中有重复元素”的题目时,如果不剪枝,[1,2,1]和[1,1,2]会被当成两种组合输出。常用的办法是先排序,然后在同一层循环里,如果当前元素和前一个元素相同、且前一个元素没有被选过,就跳过当前分支。
第三类是最优性剪枝,在优化类问题里比较常见。比如走迷宫求最短路径时,如果当前已经走的步数超过之前找到的最优解,就可以直接返回,不用继续搜下去。这类剪枝一般会结合一个全局变量来记录当前最优值。
上面这三类剪枝,实际使用中经常是组合出现的。设计剪枝时有一个核心原则:剪枝条件必须保证安全,即你剪掉的区域一定不包含合法解,否则就会漏掉答案。这一点我在第五部分“常见问题”里会具体展开。
3. 实操过程与核心环节实现:三道经典题目带你完整走一遍
3.1 全排列:入门第一题,吃透选择与撤销
先看最经典的全排列问题。给定一个不含重复数字的数组nums,返回所有可能的全排列。
我第一次写全排列时犯过一个糊涂:以为循环遍历就能搞定,后来发现每一层递归都要知道“哪些数字已经用过了”,于是引入visited数组。完整代码如下:
def permute(nums): res = [] path = [] used = [False] * len(nums) def backtrack(): # 结束条件:路径长度等于数组长度 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]) # 递归 backtrack() # 撤销选择 path.pop() used[i] = False backtrack() return res这套代码有几个细节值得重点说。首先是res.append(path[:]),这里必须拷贝,因为后面的递归会不断修改path本身,如果你直接追加path,最终结果里全是同一个列表的引用,最后会被清空成[]。其次是used数组的恢复,递归返回之后不仅path要弹出末尾元素,used也要同步改回False,否则下一轮循环无法重新使用该元素。
如果nums里包含重复数字,需要在循环里加一个去重判断,这个我会放在 3.3 节重点讲。
3.2 N 皇后:真正的回溯实战,学会用约束条件加速
N 皇后是回溯里非常经典的一道题,它除了让你“枚举排列”,还要在每一步判断位置是否冲突。题目要求在一个n x n的棋盘上放置 n 个皇后,使得任意两个皇后不能在同一行、同一列或同一对角线上。
由于每个皇后必然独占一行,所以我们可以用一维数组queens[row] = col来表示第row行的皇后放在第col列。这样整个搜索过程就变成了:逐行放置皇后,每行尝试不同列,同时判断当前列和对角线是否安全。
核心判断逻辑可以写成:
def is_safe(row, col): # 检查已放置的每一行 for r in range(row): if queens[r] == col: # 同列冲突 return False if abs(queens[r] - col) == abs(r - row): # 对角线冲突 return False return True主递归函数:
def solve_n_queens(n): res = [] queens = [-1] * n def backtrack(row): if row == n: # 所有行都放好了,记录结果 board = [] for c in queens: line = '.' * c + 'Q' + '.' * (n - c - 1) board.append(line) res.append(board) return for col in range(n): if is_safe(row, col): queens[row] = col backtrack(row + 1) queens[row] = -1 # 撤销 backtrack(0) return res这个版本已经能正确通过测试,但我实测在 n=10 以上时会明显变慢。一个低成本的优化是把is_safe改成用三个集合来记录“已占用的列”“主对角线”和“副对角线”。
为什么能这么做?因为棋盘上每个位置(row, col)的主对角线满足row - col是一个固定值,副对角线满足row + col是一个固定值。也就是说,你不需要每次遍历之前所有行来判断冲突,只需要查一下三个集合,时间复杂度从 O(n) 降到 O(1)。代码优化后如下:
def solve_n_queens_opt(n): res = [] queens = [-1] * n col_set = set() # 记录已占用列 diag1_set = set() # row - col diag2_set = set() # row + col def backtrack(row): if row == n: board = [] for c in queens: board.append('.' * c + 'Q' + '.' * (n - c - 1)) res.append(board) return for col in range(n): if col in col_set or (row - col) in diag1_set or (row + col) in diag2_set: continue # 做选择 queens[row] = col col_set.add(col) diag1_set.add(row - col) diag2_set.add(row + col) backtrack(row + 1) # 撤销选择 queens[row] = -1 col_set.remove(col) diag1_set.remove(row - col) diag2_set.remove(row + col) backtrack(0) return res我在自己机器上分别跑 n=12,纯is_safe版本耗时大约 2 秒,改用集合版本后降到 0.3 秒左右。对于这种解空间呈指数增长的题目,常量级别的优化同样能带来很大收益。
3.3 组合总和:剪枝实战的最佳教学案例
组合总和这道题非常适合作剪枝分析的教材。题目是:给定一个无重复元素的数组candidates和一个目标数target,找出 candidates 中所有可以使数字和为 target 的组合。candidates 中的数字可以无限制重复被选取。
先说一个最常见的低级错误:上来就写全排列式的回溯,结果[2,2,3]和[3,2,2]被当成两种组合输出了。要避免这个问题,必须引入start参数,保证每次递归只能从当前位置开始往后选数字,这样组合顺序就被固定成非递减形式,重复组合自然消失。
再看剪枝。这里有一个实用技巧:先对candidates排序。排序本身不是为了别的,而是为了剪枝做铺垫——当你已经排好序后,一旦发现candidates[i]大于当前剩余需要凑的数值,后面的元素只会更大,直接break掉这个循环即可。代码写出来是:
def combination_sum(candidates, target): candidates.sort() res = [] path = [] def backtrack(start, remaining): if remaining == 0: res.append(path[:]) return for i in range(start, len(candidates)): # 剪枝:当前数字已经超过剩余值,后面的更大数字不用试了 if candidates[i] > remaining: break path.append(candidates[i]) backtrack(i, remaining - candidates[i]) # 注意不是 i+1,因为可以重复使用 path.pop() backtrack(0, target) return res这道题我想单独强调两个点:一是backtrack(i, ...)的写法,因为题目允许数字重复使用,所以递归时传入的起点仍然是i而不是i+1;二是一旦发现candidates[i] > remaining,为什么是break而不是continue—— 因为数组已经排好序,当前元素已经超了,后面更大的元素必然也超了,没有继续遍历的必要。这个细节就是“性能从能跑到飞快”的关键差别。
4. 如何判断一道题适不适合用回溯——选型方法论
4.1 回溯算法的适用边界
很多人学完回溯之后会陷入一个误区:遇到所有“枚举所有可能”的题都无脑写 DFS 回溯。但回溯不是万能的,它适用的场景有明确的共性,我总结是以下三点:
第一,问题需要搜索的是多个解,或者至少是一个解,而且这个解可以拆分成一系列逐步决策的结果。比如全排列是一步一步选数字,N 皇后是一行一行放皇后,迷宫是一步一步走格子。
第二,搜索空间是树形结构,且可以做到“部分路径验证失败就提前终止”。如果一个问题没法在构造中间状态时就判断合法性,必须等到完整解才能判断,那回溯能起到的剪枝作用就非常有限,性能也不会比纯暴力枚举好多少。
第三,问题的规模不能太大。回溯本质上是带剪枝的穷举,最坏情况下仍然是指数级复杂度。如果 n 是几十甚至上百,回溯基本跑不动,需要考虑动态规划或者贪心。
反过来,什么时候不适合用回溯?我提供一个快速判断清单:
- 如果问题求的是“最优值”并且满足“最优子结构”和“重叠子问题”,优先考虑动态规划而不是回溯。最典型的例子是背包问题:回溯能解,但 n 一超过 30 就爆炸了,而动态规划在可接受的空间内能处理上千个物品。
- 如果问题可以在每一步都通过局部策略推出全局最优解,优先考虑贪心。比如区间调度问题,排序后贪心就能解决,回溯属于杀鸡用牛刀。
- 如果问题的解空间非常巨大、且中间状态的合法判断很弱,比如某些大规模图论问题,那就该想想是不是可以用网络流、匹配算法等专用方法。
4.2 回溯、DFS、动态规划的区分
因为这三者经常被拿出来放在一起对比,我在这里明确说一下我的理解。
回溯和 DFS 的关系最紧密:回溯是建立在 DFS 之上的一种搜索策略,二者共享“递归深入”的搜索方式。不同点在于,回溯更强调“状态回退”的动作 —— 在返回到上一层时,要清理掉当前层做过的修改。DFS 本身不强制要求做状态回退,它只是简单地遍历完整个图。从代码模板来看,回溯往往包含显式的路径.pop()或used[i] = False,而普通 DFS 一般没有这一步。
回溯和动态规划的区别更本质:动态规划通常要求问题具有重叠子问题,可以通过记忆化来避免重复计算,而回溯一般不做记忆化,它会重复探索很多相同的子空间。当然,有一种“记忆化回溯”的杂交写法,就是在递归里加一个memo对状态做缓存,这在很多面试题里也是合法的优化方案。
我给你的建议是:先判断问题是否需要枚举所有排列/组合,再判断中间状态是否可以提前剪枝。如果两个条件都满足,果断用回溯。如果中间状态反复出现且决策只依赖当前状态无关,可以尝试给回溯加记忆化,可能会收到奇效。
5. 我实际踩过的坑:常见问题与排查技巧实录
5.1 六大常见错误速查表
我在学习和使用回溯的过程中踩过不少坑,其中有些坑几乎每个新手都会踩一遍。我把它们整理成一个速查表,方便你写代码前先扫一眼。
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 结果全是空列表 | 直接把path追加进结果,没有拷贝 | 使用path[:]或list(path)拷贝快照 |
| 结果出现重复排列/组合 | 没有控制选择顺序,排列子问题混用 | 组合类题目使用start参数限制起点 |
| 结果正确但速度极慢 | 剪枝条件缺失或无效 | 优先给搜索空间排序,尽快触发 break/continue |
| 递归死循环,栈溢出 | 递归传入的起点参数错误 | 检查是重复使用元素还是只能使用一次,正确传i或i+1 |
| 部分解丢失 | 剪枝条件过度,把合法分支也剪掉了 | 用简单用例验证剪枝的安全性 |
| 变量相互污染 | 用一个全局可变对象承载状态,没有在递归边界正确复制 | 进入下一层前明确选择,返回后严格撤销,必要时用局部变量 |
这六个坑里,最不起眼但最致命的是第一个。我见过不少人在 LeetCode 讨论区贴出的代码,逻辑看着全对,就是结果输出[[], [], []],基本都是犯了这个错。
5.2 调试回溯代码的实用技巧
说实话,回溯代码用断点调试很痛苦,因为递归层数深、变量变化频繁,你很难跟踪清楚每一个时刻的状态。我推荐几种更高效的调试方式。
第一个技巧是在递归函数开头打印当前路径和选择列表。虽然这会让控制台刷屏,但在小规模输入下非常直观,可以看清每一步的选择和撤销有没有正确执行。如果发现路径没有按预期弹回,那么问题基本出在撤销阶段。
第二个技巧是先用最小规模用例跑通,再逐步扩大。比如全排列先跑nums = [1],再跑nums = [1,2],最后再跑nums = [1,2,3]。这样你能精确判断是哪一层出了逻辑问题,而不是面对一个错误百出的大结果发呆。
第三个技巧是我个人非常依赖的:画解空间树。面对一道新回溯题时,我通常先在草稿纸上画出 n=3 时的状态树,把每一层的选择分支标出来,然后再对着树写代码。这样写完之后,每个循环对应哪个节点、每次递归对应哪条边、剪枝对应哪根叶子,心里一清二楚。
另外提一个细节:如果递归函数里修改了多个状态变量,比如path和visited,那么所有状态变量都必须同步撤销。我写代码时习惯把“选择”和“撤销”两段代码放在对称位置,一眼就能检查是否漏掉某一行。
5.3 剪枝无效的排查思路
如果你发现代码能跑但性能极差,通常不是回溯框架的问题,而是剪枝没有生效。这时候你该排查的方向有三个。
第一,检查剪枝变量是否在递归中正确更新。比如在组合总和里,如果remaining在递归调用时没有减掉当前添加的数字,那每次递归拿到的还是原始 target,剪枝条件永远不会满足。这种 bug 很隐蔽,因为它不影响正确性,只是让搜索空间变得巨大。
第二,检查剪枝条件是否过于保守。如果条件只剪掉极少的分支,性能自然提不上去。比如 N 皇后里,如果你只判断同列冲突,忽略对角线冲突,那剪枝力度就非常弱。有效的剪枝应该把最弱的约束也纳入条件判断。
第三,检查是否可以提前排序。组合子集类问题,排序往往能让剪枝效率翻倍。原因很简单:只有有序数组,你才能确信“当前元素超过剩余值,后面所有更大元素都不需要再试”。如果是无序数组,即使当前元素超过剩余值,你也没法确定后面是否有更小的元素能凑齐,因此剪枝逻辑就变得不可用了。
6. 当回溯遇上复杂约束——一个贴近业务的实战案例
理论本身讲得差不多,但如果不结合一个稍微复杂一点的例子,你可能会觉得回溯就只是刷题用的。我决定分享一个我实际做过的贴近真实业务的案例:生成满足条件的所有课程编排方案,属于简化版的排课表问题。
假设有 5 门课程,每门课有对应的上课时间和教室容量。需要排出所有满足以下约束的课表:
- 同一个时间段不能有两门课被安排到同一教室;
- 同一个教师不能在同一时间上两门课;
- 每个教室容量必须大于等于选课人数。
这种问题在真实项目里不可能靠贪心一步到位,因为可能有多个合法方案,业务方会要求你枚举出来供人工挑选。用回溯来求解非常合适,因为它的核心就是“逐门课填充时间槽位,每填一步检查约束,不合法就回退”。
我的代码思路如下:
def schedule_courses(courses, time_slots, classrooms): # courses: [{"id": 0, "teacher": "A", "enrollment": 30}, ...] # time_slots: ["Mon-1", "Mon-2", ...] # classrooms: [{"name": "101", "capacity": 50}, ...] res = [] schedule = [None] * len(courses) # 每门课分配 (时间, 教室) def is_conflict(course_idx, time_slot, classroom): for i in range(course_idx): if schedule[i] is None: continue t, c = schedule[i] if t == time_slot: # 同一时间不能同一教室 if c == classroom: return True # 同一时间不能同一老师 if courses[i]["teacher"] == courses[course_idx]["teacher"]: return True return False def backtrack(course_idx): if course_idx == len(courses): res.append(schedule[:]) return course = courses[course_idx] for time_slot in time_slots: for classroom in classrooms: if classroom["capacity"] < course["enrollment"]: continue if is_conflict(course_idx, time_slot, classroom["name"]): continue schedule[course_idx] = (time_slot, classroom["name"]) backtrack(course_idx + 1) schedule[course_idx] = None backtrack(0) return res这段代码的业务层面逻辑非常简单,结构上和我们前面写的模板没有本质区别,唯一多出来的就是对业务约束进行了判断。我想用这个例子说明一个道理:回溯不是刷题专属工具,当你需要“枚举方案 + 逐层剪枝”时,它就是你最快能落地的方案。
而且你会发现,真正应用时,多数时间不是花在写框架上,而是花在提炼约束条件上。只有把业务条条框框转换成清晰的is_conflict判断,回溯代码才能真正有实用价值。这也是从业者和纯算法题选手之间最常见的差距。
另外提醒一句,这个场景如果课程数涨到几十门,回溯的复杂度就扛不住了。实际项目里通常会加轮数限制(比如最多算前 200 个方案),或者改成启发式搜索。这印证了我前面说的:回溯适合中等规模、有明确剪枝条件的搜索问题,不是万能解药。
回溯算法说难也难,说简单也简单。难点在于它要求你具备很强的“状态抽象”能力,能把实际问题映射成递归状态树;简单在于它的代码框架非常固定,一旦你把模板吃透,剩下的都是往套子里填业务逻辑。
我自己的体会是,回溯这关没有太多捷径可走,但有一个很有效的训练方式:把全排列、子集、组合总和、N 皇后这四道基础题反复写到不需要思考就能默写出来,然后再去挑战数独、括号生成、分割回文串这类变种题。写多了你会发现,看到任何一道搜索题,第一反应不再是一个个函数堆上去,而是能清晰地说出“状态是什么”“选择列表是什么”“剪枝条件是什么”。到了这一步,回溯就算真正过关了。