聊个我最近特别上头的话题:DFS 和回溯算法。要说它简单,套路就那三行:选择、递归、撤销;可要说它不简单,前阵子在一个算法交流小组里有人问了一句“回溯为什么看起来这么暴力,却真能解决不少正经问题”,我脑子里突然闪过一个画面:程序在单一内存里一遍遍“穿越”无数条可能性分支,每当前路不通就倒回岔路口换一条,这不就是单一内存里的平行宇宙吗?
这篇文章想把 DFS 和回溯的底层逻辑彻底讲透:递归调用栈怎么就成了穿越宇宙的锚点,状态撤销为什么是回溯的命根子,剪枝又是怎么把指数级搜索从“爆炸”边缘拉回来的。适合刚学完二叉树、想弄明白递归本质的新手,也适合刷了很久题却总觉得回溯题“背模板不踏实”的朋友。
我会用可运行代码、调用栈推演和一张排查速查表,把这一整块讲得尽量通透。
1. 从一个比喻说起:单一内存下的平行宇宙
1.1 DFS 到底在做什么
深度优先搜索(Depth First Search,DFS)从数据结构角度看,本质上是一种遍历策略:一条路走到黑,走不通再回头。很像迷宫里探索的老鼠——先选一个方向走到头,撞墙了再退回上一个岔路口,换一条路继续试。它听起来蠢,但在没有任何额外信息时,它能保证完整地探索整个可达空间。
但“完整探索”是有代价的。假设每次决策会引出 b 个分支,一共要走 d 层决策,那么完整的搜索树节点数大概是 b^d 这个量级。b 是 2、d 是 10 的时候是 1024 个节点,大家都没感觉;b 是 5、d 是 20,就是 9500 亿个节点,已经不可能用“展开全部”的思路硬碰。DFS 之所以还能在这种规模下工作,靠的不是一次把树上所有节点都生成出来,而是按需生成、用完就丢,把空间开销压缩到“从根到当前叶子的路径长度”这一条线。
这也是 DFS 和动态规划、分治不太一样的地方:它不追求同时持有所有子问题的答案,而是只持有当前路径上的那份上下文。这个“上下文”就是状态的快照,而 DFS 负责在快照之间往返。
1.2 为什么叫“单一内存”
如果把搜索树完整画出来,每个节点代表一种状态,每条边是一次决策。理论上整棵树可以看作无数个“平行宇宙”的分支集合:你在第 1 步选了 A,后面的发展是一个宇宙;在第 1 步选了 B,又是另一个宇宙。这些宇宙彼此互不相交,只在分叉点共享历史。
问题来了:程序跑在一台普通机器上,内存就那么大,不可能真的把每个宇宙都实例化一遍并长期保存。如果你尝试用暴力表把整棵树存下来,很快你会发现内存先顶不住了。
DFS 的答案是:我不需要同时保存所有宇宙,我只需要保存“当前正在展开的这个宇宙”——也就是从根到当前位置的一条路径。每当这个宇宙走到底或者被剪枝,我就把最后几步的选择还原掉,把变量恢复成做出选择之前的样子,然后从同一个历史岔路口走进下一个宇宙。整片搜索森林,都在这一块小小的内存里轮流转场。
这就是“单一内存下的平行宇宙”最贴切的解释:内存里只存在一个宇宙的实体,其他宇宙只是尚未被实例化的可能性。每做一次“选择 + 递归 + 撤销”,你就从当前的宇宙,跳进另一个宇宙。
2. 底层逻辑:调用栈与状态转移
2.1 函数调用栈:递归的物理载体
递归为什么能天然契合 DFS?因为函数调用栈本身就是“从根到当前节点”的物理载体。
在绝大多数编程语言里,每一次函数调用都会在调用栈上压入一个栈帧。栈帧里保存着函数参数、局部变量以及返回地址。当你从函数 A 调用函数 B,B 的栈帧会压在 A 上面;B 返回时,它的栈帧被弹出,控制权交回 A,A 的局部变量原封不动。这正好和“从决策点进入子分支,子分支走完后再回到决策点”的过程一一对应。
把 DFS 写成递归,等价于让调用栈替我们保存了路径上每一层的历史信息。你在某一层做的选择,就是以该层局部变量为“记忆”,压进栈帧里的。当前节点越深,调用栈就越长,但这只是从根到叶的一条垂直方向上的链,不是一整棵横铺开来的树。
这也解释了为什么 DFS 的空间复杂度经常是 O(d)(d 是最大深度):因为某一瞬间调用栈里最多同时存在 d 个帧。
2.2 状态转移三步曲:前进、记录、还原
回溯的递归体里其实只有三件事:
- 前进:做出一个选择,修改共享状态(比如在 path 中加入当前数、把 used 标为 True)。
- 记录:到达目标深度或满足终止条件时,把当前状态保存到答案列表。
- 还原:撤销上一步的选择,把共享状态恢复原状,让同一层循环能尝试下一个分支。
前进和还原是成对出现的,就像“先借东西、用完归还”。如果只借不还,状态就会污染下一个分支。为什么需要共享状态?因为 DFS 在“单一内存”的限制下不想复制出每个宇宙的完整副本,所以它复用同一份 path、同一份 visited,靠“撤销”来重置记忆。这个设计把空间消耗压到最低,同时也成了回溯最容易出错的地方。
2.3 全排列:最小完整示例拆解
看一个最经典的全排列实现:
def permute(nums): res = [] path = [] used = [False] * len(nums) def backtrack(): if len(path) == len(nums): res.append(path.copy()) 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对 nums = [1, 2, 3],递归会这样推进:
递归进入: path = [1] 当前栈帧:第 0 层,i=0 递归进入: path = [1, 2] 当前栈帧:第 1 层,i=1 递归进入: path = [1, 2, 3] 到达长度 3,记录答案 返回后: path = [1, 2] 撤销 3 返回后: path = [1] 撤销 2 循环继续: path = [1, 3] 尝试另一个宇宙注意res.append(path.copy())这行。我见过太多人在这里直接写res.append(path),然后发现结果全是一样的列表。原因就是 path 是共享可变对象,后续的撤销操作会不断修改它。如果不拷贝,所有保存的“宇宙快照”其实都指向同一个还在变化中的列表,最终大家看到的都是最后那个状态。拷贝的意义是把当前宇宙的状态真正固定下来。
3. 回溯算法的骨架:选择、递归、撤销
3.1 回溯三步曲与统一模板
所有回溯问题几乎都能套同一套模板:
def backtrack(状态参数): if 满足终止条件: 记录答案 return for 每个候选分支: 剪枝判断 修改状态 # 选择 backtrack(下一步状态) # 递归 撤销修改 # 回溯这套模板不是死记硬背,而是“单一内存”约束下的必然产物。因为内存只够放一份状态,所以每进入一个分支前必须把状态改掉,返回时必须还原。如果把“修改状态”和“递归调用”拆开看,修改就是进入平行宇宙的开关,撤销就是从平行宇宙回归原点的传送门。
搞清楚了这一点,你在遇到新题时就不用再猜代码结构了:先定义状态是什么,再定义一步决策是什么,然后决定终止条件,最后确认返回时哪些变量要还原。剩下的就是把 for 循环候选集列清楚。
3.2 组合、子集与去重变式
全排列关注顺序,组合和子集则更强调“集合视角”。这类问题往往在 for 循环里传入一个 start 索引,表示下一个可选的候选集合从哪开始,避免出现 [1,2] 和 [2,1] 这种重复宇宙。
子集模板:
def subsets(nums): res = [] path = [] def backtrack(start): res.append(path.copy()) for i in range(start, len(nums)): path.append(nums[i]) backtrack(i + 1) path.pop() backtrack(0) return res组合总和一类的“可重复选择”问题,只要把backtrack(i + 1)改成backtrack(i)就能允许同一元素反复使用。这个细节很多人栽过跟头,因为差别只在一个+ 1,但语义完全不同:加 1 代表每个元素只能进入一个宇宙一次,不加 1 代表可以在同一个宇宙里多次使用。
去重则有另一套小心机。如果 nums 里有重复元素,直接回溯会生成大量重复排列或重复组合。常见解法是先排序,然后在同一层循环里跳过相同值:
def combinationSum2(candidates, target): candidates.sort() res = [] path = [] n = len(candidates) def backtrack(start, rest): if rest == 0: res.append(path.copy()) return for i in range(start, n): if i > start and candidates[i] == candidates[i - 1]: continue if rest - candidates[i] < 0: break path.append(candidates[i]) backtrack(i + 1, rest - candidates[i]) path.pop() backtrack(0, target) return resi > start这个判断是“同一层去重”,不是“全局去重”。它在宇宙的分叉点上拦住重复选择,但不会阻止同一值出现在不同深度的宇宙里。理解这一点,去重逻辑就不容易写错。
3.3 剪枝:搜索的刹车技巧
回溯配上剪枝才是完整形态。裸回溯是纯暴力,剪枝是在暴力基础上,把“明知不可能通向答案”的宇宙提前放弃。
最常见的几类:
- 可行性剪枝:N 皇后里,某位置放皇后会与已有皇后冲突,直接 continue,不需要递归进去试。
- 最优性剪枝:搜索最优解时,如果当前累计代价已经不小于已知最好代价,后续再走也只会更差,直接返回。
- 对称性剪枝:组合问题里通过 start 索引避免生成同集合不同顺序的排列,本质上是砍掉“对称宇宙”。
- 约束传播:解数独时优先处理“候选数字最少”的空格,让失败分支更早暴露。
剪枝最核心的思想就一句话:早失败,早回头。递归进入越深,将来回溯的成本越高,所以能在浅层判断出这条路不可行,就越省时间。
4. 单一内存的代价与设计权衡
4.1 DFS 与 BFS 的空间对决
BFS 和 DFS 都能遍历状态空间,区别在于“横着铺”还是“竖着钻”。
| 维度 | DFS | BFS |
|---|---|---|
| 空间复杂度 | O(d),d 为搜索深度 | O(b^d),可能铺满一整层 |
| 是否保证最短路径 | 不保证 | 保证 |
| 剪枝友好度 | 较友好,天然深度优先 | 剪枝场景复杂 |
| 主要风险 | 递归过深导致栈溢出 | 内存爆炸 |
从“单一内存”的角度看,BFS 非常不占便宜。它要把当前层所有节点都保存在内存里,宽度一大,内存立刻告急。DFS 的优势在于它始终只关注一条路径,内存占用是线的长度而不是树的宽度。这也是迷宫类问题、数独问题和很多组合搜索问题更愿意用 DFS 的原因。
但这并不代表 DFS 全面胜利。BFS 能找到最短路径,DFS 找到的第一条可行路径则完全可能是绕远路。很多场景下,你需要在 DFS 里配合迭代加深(Iterative Deepening DFS),限制深度上限,一层层放宽,用 DFS 的空间复杂度换取 BFS 的“浅层优先”特性。
4.2 状态该复制还是该共享
回溯里最大的设计决策是:状态是复制一份传给递归,还是修改共享状态然后撤销?
复制方案的代码往往更“干净”,因为递归调用时不需要担心父层状态被破坏:
def backtrack(path, used): if len(path) == n: res.append(path) return for i in range(n): if not used[i]: backtrack(path + [nums[i]], used | {i})但这种写法每层递归都会创建新列表、新集合,空间和时间开销都更大。path + [nums[i]]会生成一个新的 list,即使路径长度只有 20,也被迫复制了 20 次,更别说每层还有多个分支。
共享状态加撤销则是另一条路:只维护一份 path、一份 used,在进入分支前修改,退出分支后还原。它在内存上显然是“单一内存”哲学的极致体现,但代码必须小心,时刻记得“谁修改了状态、返回前是否恢复了”。
我的建议是:初学阶段先用复制方案理解逻辑,因为不容易错;正式做题或性能敏感场景再切换成共享 + 撤销。等你能一眼看出哪步该还原,再去追求省内存也不迟。
4.3 递归深度、栈溢出与迭代化
递归实现 DFS 有天然的深度限制。Python 默认递归上限约 1000 层,一旦超过会抛 RecursionError。深度几百层的问题还能通过调大上限勉强处理:
import sys sys.setrecursionlimit(10000)但深度上万时,递归调用栈的内存开销会变得难以控制,因为每层栈帧都带着局部变量和返回地址。这时就该考虑把递归改成显式栈的迭代 DFS。思路是手动维护一个栈,栈里保存的状态包括“当前候选层、当前选择路径、当前已尝试到第几个分支”。这样栈的内存仍然只是路径长度,但不再受语言递归深度限制。
显式栈往往比递归繁琐,因为递归版本的“返回后从哪行继续执行”由机器栈帧自动管理,而迭代版本必须自己在栈元素里存一个类似“进度指针”的东西。我的实际经验是:优先写递归,只有在递归深度可能超过语言限制时才改成迭代栈。
5. 经典实战:N 皇后与解数独的宇宙穿越
5.1 N 皇后:冲突判断与回溯落子
N 皇后问题的本质是从 n 行中每行选一列放皇后,同时保证任意两个皇后不同列、不同两条对角线。状态由三部分组成:列占用、主对角线占用、副对角线占用。
代码实现:
def solveNQueens(n): res = [] board = [['.'] * n for _ in range(n)] cols = [False] * n diag1 = [False] * (2 * n - 1) # row + col diag2 = [False] * (2 * n - 1) # row - col + n - 1 def backtrack(row): if row == n: res.append([''.join(r) for r in board]) return for col in range(n): idx1 = row + col idx2 = row - col + n - 1 if cols[col] or diag1[idx1] or diag2[idx2]: continue board[row][col] = 'Q' cols[col] = diag1[idx1] = diag2[idx2] = True backtrack(row + 1) board[row][col] = '.' cols[col] = diag1[idx1] = diag2[idx2] = False backtrack(0) return res这里每放一个皇后,就进入下一行搜索;如果某一行没有任何位置可放,for 循环全部 continue,递归自然返回,这就是最朴素的可行性剪枝。注意diag2的下标是row - col + n - 1,用来把负数偏移映射成非负数组下标。这类索引映射是回溯题里最容易写错的细节,建议在代码旁注释清楚。
N 皇后的州空间随 n 增长非常快,n=8 的解只有 92 个,但尝试过的摆放远超这个数。n=15 左右纯回溯已经肉眼可见变慢,这时可以结合“只搜索一半对称解再翻转”等技巧继续优化。
5.2 解数独:带启发式剪枝的 DFS
数独是最直观的“平行宇宙”问题——每个空格都有 9 个候选数字,填错一个位置可能整个宇宙都崩塌,必须回到之前的空格重新选。
基础版用三个集合列表维护行、列、宫格中已出现的数字,然后按顺序 DFS 填空格:
def solveSudoku(board): rows = [set() for _ in range(9)] cols = [set() for _ in range(9)] boxes = [set() for _ in range(9)] empty = [] for i in range(9): for j in range(9): v = board[i][j] if v == '.': empty.append((i, j)) else: rows[i].add(v) cols[j].add(v) boxes[i // 3 * 3 + j // 3].add(v) def dfs(k): if k == len(empty): return True i, j = empty[k] b = i // 3 * 3 + j // 3 for c in '123456789': if c not in rows[i] and c not in cols[j] and c not in boxes[b]: board[i][j] = c rows[i].add(c) cols[j].add(c) boxes[b].add(c) if dfs(k + 1): return True board[i][j] = '.' rows[i].remove(c) cols[j].remove(c) boxes[b].remove(c) return False dfs(0)这个版本每层递归固定按 empty 列表顺序填,遇到困难数独可能很慢。更好的做法是每层递归都重新挑选“候选项最少”的空格来填,也就是最小剩余值启发式(MRV)。因为候选越少,分支越少,剪枝生效得越早。数独这类“约束传播”强烈的搜索问题,启发式带来的加速经常是数量级的。
顺带一提:数独的退出条件不是“某一行填满”,而是所有空格都填完。递归返回 True 后要直接层层返回,不再继续探索其他宇宙,这和 N 皇后记录所有解的场景不同。
5.3 从 DFS 到双人博弈:极小化极大
DFS 和回溯不止用于“找解”,也用于双人博弈类问题的搜索。最简单的博弈 AI 是极小化极大:一方拼命往“对自己有利”的方向走,另一方在递归里表达为“对对手有利”。搜索树每一层交替代表双方的选择,DFS 沿着一串决策深入,评估叶子节点的局面好坏,再层层上卷。
这类思路在井字棋、五子棋、棋类残局里很常见。它们同样是单一内存下的平行宇宙——每次落子都是一个新宇宙,AI 要做的就是在内存允许的深度内,把几条最关键的宇宙线走完,选一条对己方最有利的。剪枝在这里对应着 Alpha-Beta 剪枝:当某一分支已经明显比当前已知最优解差时,就不再深入展开。
如果你把 DFS 理解为“决策树上的深度行者”,那博弈问题只是给这个行者加了一个评估函数和一个交替角色。状态空间依然可能极其庞大,但 DFS 的串行探索特性让你在有限内存里能“看”得足够远。
6. 常见问题排查与调试技巧
6.1 输出全错的元凶:状态污染
回溯题最常见的 bug 就是状态污染。症状是:打印中间过程时 path 看起来正确,但最终答案里出现了很多重复或缺失的组合。
典型原因有:
- 递归返回时忘了撤销 used、visited 或其他共享标记。
- 把
res.append(path.copy())写成了res.append(path)。 - 递归函数里直接用可变默认参数,比如
def dfs(path=[]):,导致所有调用共享同一个列表。 - 剪枝时不先复制状态就修改,导致部分分支状态错乱。
排查这类问题,最直接的办法是在关键递归入口和出口打印缩进日志:
def backtrack(): print(" " * len(path) + f"enter: path={path}, used={used}") if len(path) == len(nums): res.append(path.copy()) print(" " * len(path) + f"record: {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 print(" " * len(path) + f"backtrack: path={path}")对照日志检查:进入分支前状态是什么,退出分支后是否恢复成一样的东西。只要有一个分支退出后没有完全还原,基本就能当场抓住元凶。
6.2 死循环、递归过深与终止条件
另一个高频问题是递归不终止或栈溢出。回溯里每次递归调用必须“严格推进状态”:要么深度加一,要么候选范围缩小,要么剩余空间减少。如果递归传参写错,例如组合总和里忘记传i + 1或i,可能导致无限生成相同的状态组合,最终要么超时,要么栈溢出。
检查终止条件的技巧是:先去掉剪枝,只保留最朴素的终止判断,跑一个极小规模用例。如果小规模下能正确返回,再看剪枝是不是误杀;如果小规模直接不返回或报错,那问题基本在递归参数推进或终止条件本身。
6.3 调试技巧与避坑速查表
| 症状 | 常见原因 | 修复方向 |
|---|---|---|
| res 里所有结果都一样 | 保存了共享可变对象 | 改成path.copy() |
| 结果缺失某些分支 | 剪枝条件过强 | 先用无剪枝版本核对答案集 |
| 递归调用不结束 | 递归参数没有向终点推进 | 检查下一步状态是否缩小规模 |
| RecursionError: 超出递归限制 | 搜索深度超过语言限制 | 调大递归上限或改成显式栈 |
| 组合题出现顺序重复解 | 未使用 start 索引 | for 循环从 start 开始 |
| 含重复元素时结果重复 | 未做同一层去重 | 排序后按i > start判断跳过 |
还有一个比较隐蔽的坑:递归闭包修改变量时,Python 需要声明nonlocal,否则你在嵌套函数里给外层变量重新赋值会直接创建新局部变量,导致状态完全没改到外层那份。这种情况调试起来特别迷惑,因为打印可能显示一切正常,但实际状态根本没有被递归共享。
实践之后的个人体会
刷了很久回溯题之后,我最大的感受是:回溯是穷举的“体面版本”。它没有让搜索变聪明,只是把搜索过程组织得清晰可控——用一块有限的内存,反复进入不同的可能性分支,失败就回头,成功就记录。也正因如此,它最怕状态空间真的无限膨胀。剪枝能砍掉你明确认识到的无效分支,但砍不掉指数级的势能本身。
数据规模小、决策树清晰、需要完整枚举的场景,回溯是首选。数据规模一旦大到深度几十层、分支上百个,就该考虑建模、动态规划或约束传播,而不是继续硬搜。
最后分享一个小经验:写任何回溯,先回答三个问题——状态是什么?状态在哪个步骤变更?返回时哪些变量需要还原?把这三个问题想明白,比背一百道模板题都管用。你用同一份内存反复切入一个又一个宇宙,其实就是反复练习这三步的时机感。