编码 · 算法
回溯算法详解:原理、代码与示例
简短回答
回溯是一种搜索所有可能解的方法:每次做一个选择来逐步构造候选解,一旦发现它不可能通向合法解,就立即撤销最后一个选择。它是在部分解构成的树上做的深度优先搜索,遵循“选择、探索、撤销”的模式。回溯可以解决 N 皇后、数独、子集和排列等谜题与组合问题。它的最坏情况是指数级的,因此及早剪掉无效分支才使它在实践中可行。
打开正则测试回溯解决什么问题
有些问题要求给出满足一组规则的所有排布,或其中任意一种:放置 8 个皇后使它们互不攻击、填满数独、列出和为目标值的所有子集、找出把字符串拆成词典单词的所有方式。这类问题没有巧妙的公式,只能搜索。盲目尝试每一种完整组合太慢了,但大多数部分组合在做出几个选择之后就已经无效。
回溯会在每一次选择之后检查规则。一旦某个部分解违反规则,它就立刻放弃搜索树中的整个分支,退回去尝试下一个选项。
用大白话讲思路
- 如果当前的部分解已经完整,就把它记录下来。
- 否则,对每个可能的下一步选择:如果该选择是允许的,就做出它(选择)。
- 递归地继续扩展解(探索)。
- 撤销这个选择,使下一个选项从相同的状态出发(撤销)。
搜索空间是一棵树:根是空解,每一层增加一个选择,叶子是完整的候选解。回溯就是在这棵树上做深度优先搜索,而剪枝是指砍掉不可能包含解的子树。
实例:4 皇后
在 4 × 4 的棋盘上放置 4 个皇后,每行一个,要求任意两个皇后不在同一列或同一条对角线上。列编号为 0 到 3。
| 步骤 | 操作 | 目前的皇后(每行所在的列) |
|---|---|---|
| 1 | 第 0 行:把皇后放在第 0 列 | 0 |
| 2 | 第 1 行:第 0 列和第 1 列受攻击;尝试第 2 列 | 0, 2 |
| 3 | 第 2 行:每一列都受攻击。回溯:把第 1 行改放到第 3 列 | 0, 3 |
| 4 | 第 2 行:第 1 列是安全的 | 0, 3, 1 |
| 5 | 第 3 行:每一列都受攻击。回溯到第 2 行:没有其他列可用。回溯到第 1 行:没有剩余的列。回溯到第 0 行:改放到第 1 列 | 1 |
| 6 | 第 1 行:第 3 列是安全的 | 1, 3 |
| 7 | 第 2 行:第 0 列是安全的 | 1, 3, 0 |
| 8 | 第 3 行:第 2 列是安全的。找到一个解 | 1, 3, 0, 2 |
继续搜索会找到唯一的另一个解 2, 0, 3, 1,它是第一个解的镜像。
Python 实现
用集合记录已占用的列和对角线,使每次安全检查都是 O(1)。在一条对角线上 row - col 是常数,在另一条对角线上 row + col 是常数。
def solve_n_queens(n):
solutions, cols = [], []
used_cols, diag1, diag2 = set(), set(), set()
def place(row):
if row == n:
solutions.append(cols.copy()) # copy, not the live list
return
for col in range(n):
if col in used_cols or row - col in diag1 or row + col in diag2:
continue # prune: square is attacked
cols.append(col) # choose
used_cols.add(col); diag1.add(row - col); diag2.add(row + col)
place(row + 1) # explore
cols.pop() # unchoose
used_cols.remove(col); diag1.remove(row - col); diag2.remove(row + col)
place(0)
return solutions
print(solve_n_queens(4)) # [[1, 3, 0, 2], [2, 0, 3, 1]]
print(len(solve_n_queens(8))) # 92同样的模板可以生成所有子集和所有排列:
def subsets(items):
result, current = [], []
def go(i):
if i == len(items):
result.append(current.copy())
return
current.append(items[i]); go(i + 1); current.pop() # take items[i]
go(i + 1) # skip items[i]
go(0)
return result
def permutations(items):
result, current, used = [], [], [False] * len(items)
def go():
if len(current) == len(items):
result.append(current.copy())
return
for i, x in enumerate(items):
if not used[i]:
used[i] = True; current.append(x)
go()
used[i] = False; current.pop()
go()
return result
print(subsets([1, 2, 3]))
# [[1, 2, 3], [1, 2], [1, 3], [1], [2, 3], [2], [3], []]
print(permutations([1, 2, 3]))
# [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]在实际代码中,若只是求普通的子集和排列,itertools.combinations 和 itertools.permutations 更快。只有需要用问题特有的规则剪枝时,才自己写回溯。
时间与空间复杂度
| 问题 | 结果数量 | 时间 | 额外空间 |
|---|---|---|---|
| n 个元素的所有子集 | 2ⁿ | O(n × 2ⁿ) | O(n) 的递归深度,加上输出 |
| n 个元素的所有排列 | n! | O(n × n!) | O(n),加上输出 |
| N 皇后 | 视 n 而定(n = 8 时为 92) | 至多 O(n!),剪枝后少得多 | O(n) |
| 数独(9 × 9) | 规范的题目只有 1 个 | 最坏情况下与空格数呈指数关系 | O(格子数) |
回溯无法突破输出规模的下限:如果必须列出 n! 个排列,至少需要 n! 步。剪枝只能通过跳过不产生任何结果的分支来提高效率。
变体与常见错误
- 忘记撤销:递归调用之后如果没有恢复状态,之后的分支就会看到过时的选择。
- 追加了“活”的列表:
result.append(current)保存的是一个之后还会被修改的引用;应追加current.copy()。 - 输入中有重复元素:先对元素排序,并在同一深度跳过与前一个相等的元素,以避免出现重复的子集或排列。
- 找到第一个解就停止:当一个解就足够时(如数独求解器),让递归返回
True并终止循环。 - 更好的顺序:先尝试约束最多的选择(对数独来说,就是候选数最少的空格)。这通常能让搜索树大幅缩小。
何时使用及实际应用
- 约束满足问题:数独、填字游戏、地图着色、排课表和考试安排。
- SAT 求解器和 Prolog 式的逻辑引擎都建立在带强力剪枝的回溯搜索之上。
- 许多语言的正则表达式引擎(包括 Python 的
re)使用回溯;写得不好的模式可能发生指数级回溯(“灾难性回溯”)。 - 面试题:子集、全排列、组合总和、N 皇后、网格中的单词搜索、分割回文串、括号生成、电话号码的字母组合。
常见问题
回溯和暴力枚举有什么区别?
暴力枚举会构造并检查每一个完整的候选解。回溯在每次选择之后检查约束,一旦部分候选解不满足就立即放弃,因此能整批整批地跳过候选解。
回溯就是 DFS 吗?
回溯是在部分解构成的树上做的深度优先搜索,并增加了撤销每个选择和剪掉无效分支的规则。图上的 DFS 是同样的思想,只是作用于已有的节点和边。
回溯的时间复杂度是多少?
最坏情况下通常是指数级或阶乘级,例如子集为 O(2ⁿ),排列为 O(n!)。好的剪枝能减少实际工作量,但不能改变最坏情况的上界。
8 皇后问题有多少个解?
有 92 个解;如果把互为旋转或镜像的解只算一个,则有 12 个。