回溯法详解:从原理到经典问题实战
回溯法(Backtracking)是一种系统搜索问题解的方法,核心思想是:从初始状态出发,按照一定规则逐步尝试构造解;当发现当前选择无法得到有效解时,就撤销上一步甚至多步的选择,换一条路径继续尝试。
它本质上是 深度优先搜索 + 剪枝 + 状态重置,常用来解决“搜索所有解”“搜索任意一个解”“在约束条件下求最优解”的问题。
一、回溯法的基本概念
1. 解空间
一个问题所有可能的解构成的集合,称为该问题的解空间。
例如:
- 求集合
{1,2,3}的全排列,解空间由3! = 6个排列组成; - 求
n个元素的子集,解空间大小为2^n; n皇后问题,解空间大小为n!左右。
回溯法通常将解空间组织成一棵树,称为解空间树,然后在树上进行深度优先搜索。
2. 两类典型解空间树
- 子集树:每个元素有“选”或“不选”两种可能,例如子集问题、0/1背包问题。
如果问题规模为n,子集树最多有2^n个叶子节点。 - 排列树:每个位置从剩余元素中选择一个,例如全排列、旅行商问题、N皇后问题。
如果问题规模为n,排列树最多有n!个叶子节点。
3. 剪枝函数
回溯法不是完全暴力搜索,它会在搜索过程中使用剪枝函数去掉不可能产生解的子树。
剪枝函数分为两类:
- 约束函数:剪掉不满足约束条件的子树。
例如:N皇后中,当前放置位置会与已有皇后冲突,就不继续搜索。 - 限界函数:剪掉不可能得到最优解的子树。
例如:0/1背包中,即使把剩余所有物品都装进去,总价值也无法超过当前最优值,就剪掉。
二、回溯法的核心三要素
使用回溯法时,通常围绕三个量展开:
- 路径:已经做出的选择。
- 选择列表:当前还可以做出的选择。
- 结束条件:到达解空间树底部,或者已经找到一个可行解,无法再继续选择。
这三个要素对应回溯算法的递归函数结构。
三、回溯法通用模板
回溯法可以用递归实现,伪代码非常统一:
1 | result = [] # 保存所有可行解 |
Python 风格的通用模板:
1 | def backtrack(path, choices): |
要点:
- 递归前“做选择”,递归后“撤销选择”。
- 保存结果时要复制当前路径,例如
path.copy()或path[:]。 - 剪枝条件可以放在进入递归前,以减少不必要的递归。
四、经典问题详解
1. 全排列问题
问题:给定一个不含重复数字的数组 nums,返回其所有可能的全排列。
决策思路:
- 每次从剩余数字中选择一个加入当前排列。
- 用一个
used数组记录哪些数字已经被选过。 - 当排列长度等于原数组长度时,找到一个完整排列。
1 | def permute(nums): |
如果数组中有重复元素,需要先排序,然后跳过同一层中重复的分支:
1 | if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]: |
这样可以保证相同数字在同一层只被使用一次,避免重复排列。
2. 组合问题
问题:给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。
决策思路:
- 为避免
[1,2]和[2,1]这种重复,使用start索引,保证选择顺序递增。 - 每次从
start开始向后选择,下一次递归从i + 1开始。
1 | def combine(n, k): |
剪枝条件:
1 | i <= n - (k - len(path)) + 1 |
含义是:还需要选 k - len(path) 个数,当前最多只能选到 n - (k - len(path)) + 1,否则剩余数量不够。
3. 子集问题
问题:给定一个整数数组 nums,返回其所有子集。
决策思路:
- 每个元素有“选”和“不选”两种状态。
- 也可以看成组合问题,长度为 0 到
n的所有组合。
1 | def subsets(nums): |
如果数组中有重复元素,可以排序后:
1 | if i > start and nums[i] == nums[i - 1]: |
这样就能跳过同一层的重复元素。
4. N皇后问题
问题:在 n × n 的棋盘上放置 n 个皇后,使它们彼此不能攻击。皇后可以攻击同一行、同一列以及两条对角线上的棋子。
决策思路:
- 逐行放置皇后。
- 每一行中尝试所有列,检查是否与已放置的皇后冲突。
- 用集合记录已经占用的列和对角线。
对角线表示方法:
- 主对角线:
row - col为定值; - 副对角线:
row + col为定值。
1 | def solveNQueens(n): |
时间复杂度大约为 O(n!),但剪枝后实际运行时间会远小于这个上界。
5. 括号生成
问题:生成 n 对有效括号的所有组合。
约束条件:
- 左括号数量不能超过
n; - 右括号数量不能超过左括号数量。
1 | def generateParenthesis(n): |
6. 单词搜索
问题:给定一个 m × n 的字符网格和一个单词,判断该单词是否存在于网格中。单词必须按照字母顺序通过相邻单元格组成,同一单元格不能重复使用。
1 | def exist(board, word): |
7. 分割回文串
问题:给定字符串 s,将其分割成一些子串,使每个子串都是回文串,返回所有可能的分割方案。
1 | def partition(s): |
8. 数独求解
数独是回溯法的典型应用:在一个 9 × 9 的格子中填入数字 1~9,要求每行、每列、每个 3 × 3 宫内数字不重复。
基本步骤:
- 找到一个空格;
- 尝试填入
1~9; - 如果某个数字合法,则填进去,递归处理下一个空格;
- 如果后续无解,则撤销当前填入,尝试下一个数字。
1 | def solveSudoku(board): |
五、剪枝与优化技巧
剪枝是回溯法的效率关键。常用的剪枝策略有:
1. 可行性剪枝
当前选择已经无法满足约束条件时,立即停止搜索该分支。
例如:
- N皇后中列和对角线冲突;
- 数独中数字重复;
- 括号生成中右括号多于左括号。
2. 最优性剪枝
在求最优解的问题中,如果当前部分解已经不可能优于已有最优解,则剪掉。
例如:
- 0/1背包中,即使剩余物品全选也不能超过当前最优价值;
- 旅行商问题中,当前路径长度已经大于最短路径。
3. 顺序优化
在搜索前对候选集合进行排序,优先选择限制条件多的分支,往往能减少搜索量。
例如:
- 数独中优先填可填数字最少的空格;
- 组合问题中先排序再去重。
4. 去重剪枝
对于有重复元素的问题,排序后在同一层递归中跳过相同元素:
1 | if i > start and nums[i] == nums[i - 1]: |
在排列问题中使用 used 数组去重:
1 | if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]: |
5. 对称性剪枝
利用问题的对称性减少搜索空间。
例如 N皇后问题,第一行皇后只需要放在一半列上,然后通过对称得到其余解。
六、复杂度分析
回溯法的时间复杂度通常较高,一般与解空间大小有关。
1. 子集树
如果每个元素有两种选择,则解空间大小为 O(2^n)。
例如子集问题、0/1背包问题。
时间复杂度:
1 | O(2^n) |
2. 排列树
如果每个位置有递减的选择数量,则解空间大小为 O(n!)。
例如全排列、N皇后、旅行商问题。
时间复杂度:
1 | O(n!) |
3. 空间复杂度
主要取决于递归深度:
1 | O(n) |
其中 n 是问题规模。如果路径需要存储,空间可能为 O(n) 或更深。
由于剪枝的存在,实际运行时间可能远小于理论最坏复杂度,但最坏情况下仍然是指数级。
七、回溯法与其他算法的关系
1. 回溯法与深度优先搜索(DFS)
回溯法通常是基于 DFS 实现的。
区别在于:
- DFS 强调遍历所有节点;
- 回溯强调“状态重置”和“剪枝”,在搜索过程中不断尝试、撤销。
可以认为回溯法 = DFS + 剪枝 + 状态恢复。
2. 回溯法与动态规划(DP)
- 回溯法适合搜索所有解或判断是否存在可行解;
- 动态规划适合求最优解,并且问题具有重叠子问题和最优子结构;
- 回溯法可以通过记忆化搜索的方式避免重复计算,接近动态规划。
例如“单词拆分”可以用回溯,也可以用动态规划。回溯+记忆化后复杂度可以接近 DP。
3. 回溯法与分支限界法
- 回溯法是深度优先搜索,沿着一条路径一直走到底,不行再退回;
- 分支限界法是广度优先或最小耗费优先搜索,常用于求最优解;
- 分支限界法通常维护一个队列或优先队列,扩展节点后按某种策略选择下一个节点。
4. 回溯法与贪心算法
贪心算法每步做出局部最优选择,不回溯;
回溯法会尝试多种选择,并在不合适时撤销,因此更通用但更慢。
八、常见错误与注意事项
1. 忘记撤销选择
1 | path.append(x) |
会导致不同分支之间状态污染。
2. 保存结果时没有复制路径
1 | result.append(path) |
这样后续修改 path 时,已保存的结果也会变化。应使用:
1 | result.append(path[:]) |
或:
1 | result.append(list(path)) |
3. 修改全局状态后未恢复
例如使用了 visited、used、棋盘、集合等,递归返回后必须恢复到递归前的状态。
4. 剪枝条件写错
比如组合问题中 start 的更新、排列去重的条件,写错会导致漏解或重复解。
5. 递归条件不明确
一定要清楚:
- 什么时候结束?
- 什么时候递归?
- 递归前后状态如何变化?
九、总结
回溯法的核心可以总结为四步:
- 定义解空间:明确解的结构,确定搜索树;
- 确定递归参数:路径、选择列表、当前状态;
- 编写递归函数:
- 判断是否达到结束条件;
- 遍历所有选择;
- 做选择 → 递归 → 撤销选择;
- 加入剪枝:尽早排除不可能的分支。
回溯法适合解决的问题通常具有以下特征:
- 需要搜索所有可行解,或任意一个可行解;
- 解空间可以用树表示;
- 没有多项式时间的直接算法;
- 可以通过约束条件大幅剪枝。
掌握回溯法的核心模板后,遇到全排列、组合、子集、N皇后、数独、迷宫、括号生成、分割回文串等问题,都可以比较顺利地解决。
希望这篇博客能帮你透彻理解回溯法,并在实际编码中灵活运用。如果对某个问题有疑问,欢迎进一步交流!
