先交代一个背景:我这些年面试候选人的时候,只要聊到算法题,回溯几乎是必问的一块。不是因为面试官都喜欢刁难人,而是回溯这个思想一旦通了,排列、组合、子集、棋盘类问题全部一通百通,这玩意儿就是算法题里的“万能暴力法”。说得更直白一点,回溯算法是一套系统的暴力搜索方法论——你不需要聪明到一眼看出最优解,你只需要把所有的可能性列出来,然后按规则挑出正确答案,剩下的交给递归。
这篇文章我不打算给你背定义,而是从“为什么要发明回溯”讲起,把它的本质、模板、剪枝、常见坑全部过一遍,最后用全排列、组合、N皇后这几个经典题目把思路串起来。不管你是刚接触算法的新手,还是刷了几百道题但总觉得回溯写不顺的老手,这篇都值得花二十分钟认真看完。
1. 回溯算法的本质:一套系统的暴力搜索方法论
1.1 先从“暴力枚举”说起
很多人一听到“暴力”两个字就皱眉,觉得这是下等方案。但你想过没有:当一个问题没有明显的数学规律可循,或者状态空间小到可以全部走完时,暴力枚举反而是最可靠的手段。回溯算法就是干这个的——它把所有的“可能性”组织成一棵决策树,然后在这棵树上做深度优先遍历,把符合条件的路径记录下来。
举个例子,你要从[1, 2, 3]里面选两个数字组成一个组合。你不确定有什么捷径,但你知道:第一个位置可以选1、2、3,第二个位置从前一个位置的下一个开始选。把所有情况列出来,一共三种:[1,2]、[1,3]、[2,3]。这就是最简单的暴力枚举。回溯算法做的是同一件事,只不过它用递归的方式,把“枚举”这个过程组织得井井有条,让你面对更复杂的约束时依然能清晰表达。
1.2 回溯、DFS、递归三者到底是什么关系
这个问题我几乎每次讲回溯都会被问到。我一般用一句话回答:递归是编程实现手段,DFS是遍历策略,回溯是在DFS基础上的“带撤销”遍历。
- 递归:函数自己调用自己。回溯核心逻辑天然是递归的,因为“做选择之后继续做下一个选择”这个动作,本质上就是在重复调用同一个过程。
- DFS(深度优先搜索):一路走到底,走不动了再回头。回溯遍历决策树时用的就是DFS策略,优先往深处探索。
- 回溯:DFS走到死路后撤销上一次的选择,尝试另一个分支。这个“撤销”是关键,正是它让算法能回到之前的某个状态重新决策。
所以你可以把回溯理解成“带后悔功能的DFS”。普通DFS访问完一个节点不会刻意把它恢复原状,而回溯算法在返回上一层之前,会主动把当前选择造成的影响还原,保证上一层看到的状态和进入时一致。这种“不污染现场”的设计,就是回溯和普通DFS最大的区别。
1.3 什么时候该想到用回溯
刷题刷多了你会发现,有些关键词出现的时候,基本就是在暗示你用回溯:
- “所有可能的组合”“所有排列”“所有子集”
- “是否存在一种方案”“一共有多少种方案”
- 数据范围很小(比如 n <= 20,因为状态数通常是阶乘或指数级别,数据一大就超时)
还有一个很经典的判断标准:如果这个问题可以拆成“一步一步做选择”,并且每步的选择会相互影响,那就大概率是回溯。比如N皇后,每一行放哪个列,会影响下一行能放的位置;比如组合总和,选了当前数之后,下一个数的范围就被限制了。这种“选择之间有连带约束”的问题,数学上很难建立递推公式,但用回溯表达起来几乎不需要思考。
提示:回溯能解决的问题,无外乎“决策树+约束条件”这两个要素。只要你能把问题转化成“每次选什么、选完有什么限制、什么时候算结束”,你就能用回溯。
2. 回溯算法的核心框架:三要素与代码模板
2.1 回溯决策树的三个关键要素
回溯算法有个经典的分析框架,来自我用过的一本很实用的算法笔记,它把任何回溯问题都抽象成三个要素:
路径(track):已经做出的选择。比如组合题里已经选出来的数字,N皇后里已经确定皇后的行。
选择列表(choices):当前这一步还能选什么。比如组合题里当前数字的后半部分,N皇后里当前行没被攻击的列。
结束条件(base case):什么时候可以停止递归、记录结果。比如组合题里路径长度达到k,N皇后里行号等于n。
这三个要素对应到代码里,就是backtrack函数的三个组成部分。你每写一道回溯题,先问自己三个问题:路径怎么存?每一步有哪些选择?满足什么条件就结束?想清楚这三件事,代码基本就出来了。
2.2 一套能通吃所有回溯题的模板
我写回溯题五年多了,说句掏心窝的话:真正常用的模板就一个,剩下的全是往里面填肉。
def backtrack(路径, 选择列表): if 满足结束条件: 结果集.append(路径的拷贝) return for 选择 in 选择列表: # 剪枝:排除不合法选择 if 选择不合法: continue # 做选择 路径.append(选择) # 进入下一层决策 backtrack(路径, 下一层的选择列表) # 撤销选择 路径.pop()这个框架的精髓就三句话:先判断结束,再遍历选择,最后撤销还原。
我第一次刷回溯题的时候,特别不理解为什么要在递归之后再执行pop。后来我自己画了决策树迭代过程才明白:递归调用结束后,控制权会回到当前层,这时候当前层需要尝试下一个选择。如果不把上一次的选择撤销掉,路径列表会越攒越多,track就会把不该有的元素带进后续分支。
2.3 为什么“撤销选择”如此重要
用全排列举个例子:nums = [1, 2, 3],你在第一层选了1,进入第二层之后,你面对的选择是[2, 3]。第二层选2,第三层选3,得到一个排列[1, 2, 3]。然后递归返回到第二层,这时候如果你不把2从路径中移出去,第二层试图选3的时候,路径是[1, 2, 3],再追加3就变成了[1, 2, 3, 3],结果彻底乱了。
撤销的本质是恢复现场。递归本身有个天然的优势——每次进入新函数,局部变量都是全新的,但路径、棋盘这些共享的可变数据,就得靠手动恢复。我见过很多人写着写着就把pop给忘了,或者把used[i] = False给漏了,结果跑出来全是[1,1,1]这样的重复项,找半天找不到问题在哪。所以我会在写模板的时候,把“做选择”和“撤销选择”两步在代码里对齐写在一起,一眼就能看出配不配对。
另一个新人必踩的坑是往结果集里存路径时忘了拷贝:
# 错误写法:结果里存的是同一个列表的引用 result.append(track) # 正确写法:拷贝一份,后续的pop不会影响已经记录的结果 result.append(track[:])这个坑太经典了。Python里列表是引用类型,你存进去的是指针。后面track.pop()一执行,之前存进result里的那份“结果”也跟着变。最后所有结果变成全空的列表,就是因为这个细节。
3. 经典题目实操:从全排列到N皇后
3.1 全排列:理解路径与选择列表的第一课
全排列是最适合入门的回溯题,因为它的需求最直白:给定一个不含重复数字的数组,返回所有可能的排列。
思路:用一个used数组记录哪些数字已经选过了,然后在每一层递归中,遍历所有未使用的数字,加入路径,递归,撤销。
def permute(nums): result = [] track = [] used = [False] * len(nums) def backtrack(): if len(track) == len(nums): result.append(track[:]) return for i in range(len(nums)): if used[i]: continue # 做选择 used[i] = True track.append(nums[i]) # 进入下一层 backtrack() # 撤销选择 track.pop() used[i] = False backtrack() return result这个实现里有个容易混淆的点:为什么用used数组而不是用start索引?因为排列对顺序敏感,每个数字在每个位置都可能出现,所以每一层都要从头遍历所有数字,只是跳过已经用过的。而组合恰恰相反——组合不关心顺序,[1, 2]和[2, 1]是同一个组合,所以组合用start索引强制下一个数只能从当前数之后选,避免产生重复组合。
理解了used和start的区别,你基本就理解了一半的排列组合问题。
3.2 组合与子集:不重复的关键在前一个位置开始
组合题长这样:给定n和k,返回[1, n]中所有可能的 k 个数的组合。
def combine(n, k): result = [] track = [] def backtrack(start): if len(track) == k: result.append(track[:]) return for i in range(start, n + 1): track.append(i) backtrack(i + 1) track.pop() backtrack(1) return result核心逻辑全在backtrack(i + 1)这一行。我选了 i 之后,下一层只能从i+1开始选,这样天然避免了[1, 2]和[2, 1]同时出现。换句话说,start参数就是控制“选择范围”的边界。
很多人学到这里会觉得组合题已经够用了,但真实面试常考的是组合总和的变体,比如“组合总和3”:要求找出所有相加之和为n的 k 个数的组合,且数字在1到9范围内。这时候除了组合模板,还要加一个累计和:
def combinationSum3(k, n): result = [] track = [] def backtrack(start, remaining): if len(track) == k: if remaining == 0: result.append(track[:]) return if remaining < 0: return for i in range(start, 10): track.append(i) backtrack(i + 1, remaining - i) track.pop() backtrack(1, n) return result这个版本多了一个remaining参数,每选一个数就减掉它,提前判断remaining < 0就不继续递归,这叫约束剪枝。它不改变结果,只是让不可能产生答案的路径尽早终止,省掉大量无效递归。
3.3 N皇后:二维棋盘上的回溯与约束检查
如果说全排列是回溯的入门题,N皇后就是回溯的进阶题。它把一维的“选数字”升级成二维的“放棋子”,并且增加了棋盘空间的合法性校验。
84年那版经典的N皇后解法我调试了很多次,核心就三块:逐行放置皇后、检查位置是否合法、不合法就跳过。
def solveNQueens(n): result = [] board = [['.'] * n for _ in range(n)] def is_valid(row, col): # 检查同一列是否有皇后 for i in range(row): if board[i][col] == 'Q': return False # 检查左上对角线 i, j = row - 1, col - 1 while i >= 0 and j >= 0: if board[i][j] == 'Q': return False i -= 1 j -= 1 # 检查右上对角线 i, j = row - 1, col + 1 while i >= 0 and j < n: if board[i][j] == 'Q': return False i -= 1 j += 1 return True def backtrack(row): if row == n: result.append([''.join(r) for r in board]) return for col in range(n): if not is_valid(row, col): continue board[row][col] = 'Q' backtrack(row + 1) board[row][col] = '.' backtrack(0) return result注意这个is_valid函数为什么只需要检查上方?因为我们是逐行往下放的,当前行之后的行还没放任何皇后,下方的冲突根本不可能存在。很多初级解法把四个方向全部检查一遍,虽然也能跑,但做了大量无用功。写回溯的时候,尽量利用“搜索顺序”减少校验范围,这和剪枝的思路本质一样。
N皇后最难理解的地方在于board的恢复。你把board[row][col]设为Q之后递归,返回时必须改回.,否则下一列尝试的时候棋盘上还留着上一列放的皇后,合法性判断就全乱了。这一点和全排列里的used[i] = False是完全相同的逻辑。
3.4 排列组合子集类题目的复杂度分析
回溯算法的时间复杂度是个绕不开的话题。全排列的空间状态数是A(n, n) = n!个排列,每个排列要拷贝一次,所以时间复杂度是O(n * n!)。组合问题要输出C(n, k)个结果,时间复杂度是O(C(n, k) * k)。N皇后更暴力,在不剪枝的情况下要遍历所有n^n种放置方案,剪枝之后实际运行远小于这个上界,但理论上界依然是O(n!)。
我常说一句话:回溯本来就是“没招的时候才用的招”。它的优势在于不用动脑子设计特殊算法,劣势就在于指数爆炸。所以写回溯题的时候,搞清楚复杂度上界是很有用的——它直接决定了你需不需要剪枝、需不需要换思路。如果n已经到 30 以上了,回溯大概率是跑不动的,得考虑动态规划或数学解法。
4. 剪枝优化:从能跑到能用的分水岭
4.1 剪枝的本质
回溯的暴力搜索会访问决策树上的每一个节点,但很多节点根本不可能通向合法答案。剪枝就是提前判断某个分支“没戏了”,直接跳过它,不再往下递归。
打个比方,你逛一个大型批发市场找一家指定的店铺。暴力做法是每一层楼每个过道都走一遍,剪枝做法是你先看楼层导览图,发现目标店铺只在三楼,那一楼二楼的过道你就不用走了。省下的时间就是剪枝带来的收益。
4.2 常用剪枝手段
按照我实战下来的经验,回溯里最高频的剪枝手段就三种:
第一种:约束剪枝。不满足题目条件的分支直接跳过。最典型的就是组合总和题里remaining < 0时直接 return,还有N皇后里is_valid不合法就 continue。这种剪枝是回溯的天然组成部分,几乎所有回溯题都有。
第二种:排序剪枝。先把数据排个序,然后利用数据的有序性提前终止循环。比如组合总和2里要求候选数字不能重复使用,你先把数组排个序,然后如果candidates[i] + current_sum > target,那后面的数更大,也一定超了,直接 break 出循环。这种剪枝在“选择和目标值相关”的问题里特别有效。
第三种:重复元素剪枝。当输入数组里有重复数字时,不处理会生成重复结果。经典场景是permuteUnique——全排列2,输入[1, 1, 2],你要保证结果没有重复排列。常规做法是排序后,在循环里加一行判断:
if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]: continue这行代码的逻辑是:如果当前数字和前一个数字相同,且前一个数字在本层递归中没被使用过,说明当前数字产生的结果已经被前一个数字覆盖过了,跳过。这个判断条件需要想一下才转得过弯,我建议你实际跑一遍再体会,比单纯记结论要记得牢。
4.3 一个带剪枝的完整例子
组合总和问题是个绝佳示范:给你一个无重复元素的数组candidates和一个目标数target,找出 candidates 中所有可以使数字和为 target 的组合。candidates 中的数字可以无限制重复使用。
def combinationSum(candidates, target): result = [] track = [] candidates.sort() def backtrack(start, remaining): if remaining == 0: result.append(track[:]) return for i in range(start, len(candidates)): # 剪枝:当前数字已经大于剩余值,后面的更大,直接跳出 if candidates[i] > remaining: break track.append(candidates[i]) backtrack(i, remaining - candidates[i]) track.pop() backtrack(0, target) return result这个例子里的剪枝有两个层次:一是candidates[i] > remaining时 break,因为数组已排序,后面的元素更大,全部超了,不需要继续遍历;二是backtrack(i, ...)而不是backtrack(i+1, ...),因为题目允许数字重复使用,所以下一层还可以从当前下标开始选。这两处都是组合总和题最容易写错的地方。
实际跑一遍你会发现,排序剪枝把运行时间砍掉了一大半。没有这个 break 之前,很多remaining已经变成负数的递归还会白白进入下一层,然后在下一次递归的入口才被拦截,白白浪费函数调用开销。
注意:剪枝一定要保证“剪掉的分支确实不可能产生合法答案”,否则就会漏解。漏解比超时更可怕——超时是你知道程序还在跑,漏解是程序正常结束但你根本没发现结果不对。我在代码审查的时候,剪枝条件永远比常规逻辑多检查两遍。
5. 常见问题与排查技巧实录
5.1 常见问题速查表
我把这些年被问到最多、自己也踩过的回溯问题整理成了一张表,方便你对照排查。
| 症状 | 根本原因 | 解决办法 |
|---|---|---|
| 结果集全是空列表 | 直接把track加入结果,没拷贝 | 改成track[:]或list(track) |
| 结果里有大量重复组合 | 组合题没用start,导致顺序不同也算不同 | 下一层递归从i+1开始 |
| 结果里有重复排列 | 输入有重复元素但没去重 | 排序之后用同层去重判断 |
| 运行时间爆炸 | 缺少剪枝,大量无效递归 | 提前判断约束条件,减少分支 |
| 本地跑结果对,提交超时 | 递归栈过深或剪枝不够 | 检查数据范围,增加启发式剪枝 |
修改board后没有恢复 | 漏写撤销逻辑 | 确认“做选择”和“撤销选择”成对出现 |
| 输出顺序和预期不一致 | 回溯本身不保证字典序 | 配合排序或按题目要求调整遍历顺序 |
5.2 排查技巧:用打印定位问题
回溯算法的 debug 天然有优势:它的搜索过程本身就是递归的,你只需要在关键位置打印track和当前进入的层数,就能把决策树的遍历过程全部可视化。
我自己调试回溯题的时候,会加一个depth参数:
def backtrack(start, depth): print(' ' * depth + f'进入第{depth}层, track={track}') # ... 原有逻辑 print(' ' * depth + f'离开第{depth}层, track={track}')打印出来的缩进天然形成一棵树,哪个分支走错了、哪个撤销没执行,一眼就能看出来。这个方法比我用 debugger 单步跟踪还快,因为回溯的递归分支实在太多,单步容易迷路,打印反而能全局观察。
另一个技巧是先跑最小规模。比如全排列先跑n=3,N皇后先跑n=4,把搜索树控制在几十个节点以内。这时候即使手工推演也能跟得上,定位问题会容易得多。直接上大规模数据调试,出错了根本不知道是哪个分支的问题。
5.3 实战心得:回溯的几个思维误区
第一个误区:觉得回溯就是“套模板”。模板只是骨架,真正要动脑的是怎么定义路径、怎么定义选择列表、怎么定义剪枝条件。同一个全排列问题,数字有无重复、数组是否有序、能不能重复使用元素,这些都是决定实现细节的关键,模板不能替你决定这些。
第二个误区:为了追求效率,引入一些看起来很聪明的优化,结果把代码搞复杂了。回溯本身已经是指数复杂度,你再优化局部也改变不了大局。我更建议:第一版先把正确性跑通,然后看数据范围决定要不要剪枝,剪枝从最简单、最明显的约束条件开始加,逐步增加复杂度。这样写出来的代码既可靠又好维护。
第三个误区:把回溯和动态规划搞混。动态规划要求问题有“最优子结构”,并且子问题存在重叠;回溯是纯粹的枚举,不存在状态复用。如果你发现某道题要求“最少步数”或“最大价值”,那大概率不是回溯的菜,而是动态规划或贪心。回溯擅长的是“列出所有解”,而不是“从所有解里求最优”。
6. 从回溯算法延伸出的思考
学会了回溯之后,你可能会发现它和正规的暴力搜索、KMP 算法里的那种“回溯”完全不是一个概念。KMP 里的next数组在失配时让指针回溯到之前的位置,那是字符串匹配里的优化手段;回溯算法则是数据结构的遍历方法论,两者只是中文翻译撞了名字。遇到这些概念的时候,别被术语搞混了。
回溯算法在现实项目里也有不少应用场景。比如游戏里的走迷宫 AI、解数独程序、编译原理里的语法分析(递归下降解析器本质上就是一种带回溯的搜索)、规则引擎里的匹配过程,都会用到回溯思想。尤其是解析器,我早期写过一个小型 JSON 解析器,遇到嵌套结构时就是靠递归+回溯来处理分支的。虽然回溯不是最高效的方案,但它极强的表达能力和实现简单性,让它依然在很多领域被广泛使用。
从算法题的角度来说,回溯之后值得延伸学习的还有:动态规划(它其实就是“带备忘录的回溯”,把重复计算的子问题缓存下来)、BFS(适合求最短路径)、图论里的搜索算法。你会发现这些算法之间都有千丝万缕的联系,学到最后脑子里会形成一张知识网,而不是一堆孤立的解题套路。
7. 写在最后的小经验
我讲回溯讲了这么多年,最大的体会是:别怕暴力,怕的是心里没数,不知道暴力会跑多久。回溯这种“系统化暴力”的优点是容易写对、容易验证,你只要把三要素想清楚,代码几乎不会出大逻辑错误。相比之下,那些精巧的算法虽然效率高,但边界条件一多,写起来反而容易翻车。
所以我的建议是:面试遇到没见过的题,先想能不能回溯,能的话就直接写,同时在时间复杂度里说明最坏情况,让面试官知道你有复杂度意识,再顺手加一两个明显的剪枝。这一套下来,代码正确性有保障,人也显得稳。至于要不要换更优的解法,等写完回溯再说——先拿到一个可运行的答案,永远比卡在“最优解”上浪费五分钟强。
最后再分享一个我个人的小技巧:刷回溯题的时候,我会刻意把每道题的“路径、选择列表、结束条件”三个要素写在注释里,然后再写代码。这个方法看似多花了几秒钟,实际上能大幅降低写错概率。你下次刷题的时候也可以试试,用这个习惯写十道回溯题之后,你会有一种“回溯肌肉记忆”成型了的感觉。