1. 回溯算法是什么?
1.1 生活中的回溯
想象你在走迷宫:
- 走到一个岔路口,选择一条路走
- 发现是死胡同,退回岔路口
- 尝试另一条路
- 直到找到出口
这就是回溯:走不通就回头,尝试其他可能。
1.2 算法的定义
回溯算法(Backtracking) :一种通过深度优先搜索 遍历解空间树,并在搜索过程中剪枝(排除不可能的分支),从而找到所有(或最优)解的算法技术。
核心特征:
- 系统性:按一定顺序遍历所有可能
- 跳跃性:发现不满足条件时,立即回溯(剪枝)
- 通用性:框架固定,适用于多种组合问题
1.3 回溯 vs 递归 vs DFS
| 概念 | 关系 | 说明 |
|---|---|---|
| 递归 | 实现方式 | 函数调用自身,是一种编程技巧 |
| DFS | 搜索策略 | 深度优先搜索,是一种遍历策略 |
| 回溯 | 算法思想 | = 递归 + DFS + 剪枝,用于求解问题 |
💡 一句话:回溯是用递归实现的 DFS,加上剪枝优化。
2. 回溯算法的核心框架
2.1 三大要素
回溯算法必须明确定义以下三个要素:
┌─────────────────────────────────────────┐
│ 路径(Path) │
│ 已经做出的选择,记录当前状态 │
├─────────────────────────────────────────┤
│ 选择列表(Choices) │
│ 当前可以做的选择 │
├─────────────────────────────────────────┤
│ 结束条件(Termination) │
│ 到达终止状态,保存结果 │
└─────────────────────────────────────────┘
2.2 通用代码框架
python
def backtrack(路径, 选择列表):
if 满足结束条件:
结果.append(路径[:]) # 注意拷贝!
return
for 选择 in 选择列表:
if 不满足约束条件: # 剪枝
continue
做选择 # 修改路径
backtrack(新路径, 新选择列表) # 递归
撤销选择 # 恢复路径(回溯)
2.3 关键理解:为什么需要"撤销选择"?
python
# 错误示范:不撤销选择
def wrong_backtrack(path, nums):
if len(path) == len(nums):
result.append(path) # 保存引用,后续会被修改!
return
for num in nums:
if num in path:
continue
path.append(num) # 做选择
wrong_backtrack(path, nums)
# 忘记撤销!path 一直增长
# 正确示范:撤销选择
def correct_backtrack(path, nums):
if len(path) == len(nums):
result.append(path[:]) # 保存拷贝
return
for num in nums:
if num in path:
continue
path.append(num) # 做选择
correct_backtrack(path, nums)
path.pop() # 撤销选择!恢复状态
🎯 核心原则 :回溯算法的状态共享 特性要求,进入递归前修改状态,递归返回后必须恢复状态,保证同一层循环中各选择之间的独立性。
3. 经典例题一:组合问题
3.1 题目描述
从 [1, 2, ..., n] 中选取 k 个数,返回所有可能的组合。
示例: n = 4, k = 2
输出: [[1,2], [1,3], [1,4], [2,3], [2,4], [3,4]]
3.2 解题思路
解空间树(n=4, k=2):
开始
/ | | \
1 2 3 4
/| | |
2 3 4 3 4
/ |
3 4 4
有效路径:[1,2] [1,3] [1,4] [2,3] [2,4] [3,4]
剪枝:[2,1] 无效(顺序保证不重复) [3,1] [3,2] 同理
关键设计:
- 避免重复 :每次从
start开始,不回头选 - 结束条件 :
len(path) == k
3.3 代码实现
python
def combine(n: int, k: int) -> list[list[int]]:
"""
从 1~n 中选 k 个数的所有组合
时间复杂度:O(C(n,k)),组合数
空间复杂度:O(k),递归栈深度
"""
result = []
path = []
def backtrack(start: int):
# 结束条件:已选够 k 个数
if len(path) == k:
result.append(path[:]) # 保存拷贝
return
# 选择列表:从 start 开始,避免重复
for i in range(start, n + 1):
path.append(i) # 做选择
backtrack(i + 1) # 递归,下次从 i+1 开始
path.pop() # 撤销选择
backtrack(1)
return result
# 测试
print(combine(4, 2))
# [[1, 2], [1, 3], [1, 4], [2, 3], [2, 4], [3, 4]]
3.4 执行过程可视化
backtrack(1):
path=[1]
├── backtrack(2):
│ path=[1,2] → 长度=2,保存 [1,2]
│ path.pop() → [1]
├── backtrack(3):
│ path=[1,3] → 保存 [1,3]
│ path.pop() → [1]
├── backtrack(4):
│ path=[1,4] → 保存 [1,4]
│ path.pop() → [1]
path.pop() → []
├── backtrack(2):
│ path=[2]
│ ├── backtrack(3):
│ │ path=[2,3] → 保存 [2,3]
│ │ path.pop() → [2]
│ ├── backtrack(4):
│ │ path=[2,4] → 保存 [2,4]
│ │ path.pop() → [2]
│ path.pop() → []
└── ... 继续
4. 经典例题二:全排列问题
4.1 题目描述
给定一个不含重复数字的数组 nums,返回其所有可能的全排列。
示例: nums = [1, 2, 3]
输出: [[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1]]
4.2 解题思路
解空间树(nums=[1,2,3]):
开始
/ | \
1 2 3
/ \ / \ / \
2 3 1 3 1 2
| | | | | |
3 2 3 1 2 1
路径:[1,2,3] [1,3,2] [2,1,3] [2,3,1] [3,1,2] [3,2,1]
关键设计:
- 避免重复 :用
used数组标记已选元素 - 结束条件 :
len(path) == len(nums)
4.3 代码实现
python
def permute(nums: list[int]) -> list[list[int]]:
"""
返回数组的所有全排列
时间复杂度:O(n!),n 个元素的全排列数
空间复杂度:O(n),递归栈 + used 数组
"""
result = []
path = []
used = [False] * len(nums) # 标记数组
def backtrack():
# 结束条件:所有元素都已使用
if len(path) == len(nums):
result.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 result
# 测试
print(permute([1, 2, 3]))
# [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]
4.4 与组合问题的区别
| 维度 | 组合问题 | 排列问题 |
|---|---|---|
| 顺序 | 不重要([1,2] = [2,1]) | 重要([1,2] ≠ [2,1]) |
| 选择范围 | 从 start 开始,不回头 |
每次都从头选,用 used 标记 |
| 去重方式 | 控制起始位置 | 使用标记数组 |
| 解的数量 | C(n,k) | n! |
5. 经典例题三:N 皇后问题
5.1 题目描述
在 n × n 的棋盘上放置 n 个皇后,使其不能互相攻击(任意两个皇后不在同一行、同一列、同一对角线)。
示例: n = 4
输出: 2 种解法
解法1: 解法2:
. Q . . . . Q .
. . . Q Q . . .
Q . . . . . . Q
. . Q . . Q . .
5.2 解题思路
关键观察:
- 每行必须且只能放一个皇后(否则同行冲突)
- 用
board[i] = j表示第i行第j列放皇后
冲突检测:
- 同列:
board[i] == board[row] - 对角线:
abs(board[i] - board[row]) == abs(i - row)
5.3 代码实现
python
def solve_n_queens(n: int) -> list[list[str]]:
"""
N 皇后问题:返回所有解法
时间复杂度:O(n!),实际因剪枝远小于此
空间复杂度:O(n),递归栈
"""
result = []
board = [-1] * n # board[i] = 第i行皇后所在的列
def is_valid(row: int, col: int) -> bool:
"""检查在 (row, col) 放皇后是否合法"""
for i in range(row):
# 同列 或 同对角线
if board[i] == col or abs(board[i] - col) == abs(i - row):
return False
return True
def backtrack(row: int):
# 结束条件:所有行都放置成功
if row == n:
# 生成棋盘表示
solution = []
for col in board:
line = "." * col + "Q" + "." * (n - col - 1)
solution.append(line)
result.append(solution)
return
# 选择列表:当前行的每一列
for col in range(n):
if not is_valid(row, col): # 剪枝:冲突位置跳过
continue
board[row] = col # 做选择
backtrack(row + 1) # 递归:处理下一行
board[row] = -1 # 撤销选择(回溯)
backtrack(0)
return result
# 测试
solutions = solve_n_queens(4)
print(f"4 皇后问题共有 {len(solutions)} 种解法:\n")
for i, sol in enumerate(solutions, 1):
print(f"解法 {i}:")
for line in sol:
print(line)
print()
输出:
4 皇后问题共有 2 种解法:
解法 1:
.Q..
...Q
Q...
..Q.
解法 2:
..Q.
Q...
...Q
.Q..
5.4 解的数量规律
| n | 解的数量 | 计算时间 |
|---|---|---|
| 1 | 1 | 瞬间 |
| 4 | 2 | 瞬间 |
| 8 | 92 | 瞬间 |
| 10 | 724 | < 1秒 |
| 12 | 14,200 | ~1秒 |
| 14 | 365,596 | ~1分钟 |
6. 经典例题四:子集问题
6.1 题目描述
给定一个不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。
示例: nums = [1, 2, 3]
输出: [[], [1], [2], [3], [1,2], [1,3], [2,3], [1,2,3]]
6.2 解题思路
关键洞察 :每个元素有两种选择------选 或不选
解空间树(nums=[1,2,3]):
开始
/ \
选1 不选1
/ \ / \
选2 不选2 选2 不选2
/ \ / \ / \ / \
选3 ...(共8个叶子节点)
路径:[] [3] [2] [2,3] [1] [1,3] [1,2] [1,2,3]
6.3 代码实现
python
def subsets(nums: list[int]) -> list[list[int]]:
"""
返回数组的所有子集(幂集)
时间复杂度:O(2^n),每个元素选/不选两种状态
空间复杂度:O(n),递归栈深度
"""
result = []
path = []
def backtrack(start: int):
# 关键:每个节点都是一个有效子集!
result.append(path[:])
# 选择列表:从 start 开始的每个元素
for i in range(start, len(nums)):
path.append(nums[i]) # 做选择:选 nums[i]
backtrack(i + 1) # 递归
path.pop() # 撤销选择:不选 nums[i]
backtrack(0)
return result
# 测试
print(subsets([1, 2, 3]))
# [[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3], [3]]
6.4 另一种思路:逐个元素选/不选
python
def subsets_binary(nums: list[int]) -> list[list[int]]:
"""
用二进制表示选/不选:000 ~ 111
第 i 位为 1 表示选 nums[i]
"""
n = len(nums)
result = []
# 枚举 0 到 2^n - 1
for mask in range(1 << n): # 1 << n = 2^n
subset = []
for i in range(n):
if mask & (1 << i): # 第 i 位是否为 1
subset.append(nums[i])
result.append(subset)
return result
# 测试
print(subsets_binary([1, 2, 3]))
7. 剪枝优化:让回溯更高效
7.1 什么是剪枝?
剪枝(Pruning) :在搜索过程中,提前判断某条路径不可能产生有效解,从而跳过该分支,减少搜索空间。
无剪枝:遍历整棵树
〇
/|\
〇 〇 〇
/|\ ...
〇 〇 〇
有剪枝:剪掉无效分支
〇
/|
〇 〇 ✂️
/|
〇 〇
7.2 剪枝策略
策略1:约束剪枝(可行性剪枝)
python
# 组合问题优化:剩余元素不够选时直接返回
def combine_optimized(n: int, k: int) -> list[list[int]]:
result = []
path = []
def backtrack(start: int):
if len(path) == k:
result.append(path[:])
return
# 剪枝:剩余元素不足以凑够 k 个
# 还需要选:need = k - len(path)
# 可选范围:[start, n],共 n - start + 1 个
# 如果 n - start + 1 < need,直接返回
need = k - len(path)
if n - start + 1 < need:
return
for i in range(start, n + 1):
path.append(i)
backtrack(i + 1)
path.pop()
backtrack(1)
return result
策略2:排序剪枝(常用于求和类问题)
python
def combination_sum(candidates: list[int], target: int) -> list[list[int]]:
"""
组合总和: candidates 中数字无限制重复选取,和为 target 的所有组合
"""
candidates.sort() # 排序,便于剪枝
result = []
path = []
def backtrack(start: int, remain: int):
if remain == 0:
result.append(path[:])
return
if remain < 0: # 剪枝:和已超过 target
return
for i in range(start, len(candidates)):
# 剪枝:当前数已大于剩余值,后面更大,直接break
if candidates[i] > remain:
break
path.append(candidates[i])
backtrack(i, remain - candidates[i]) # i 可重复选
path.pop()
backtrack(0, target)
return result
# 测试
print(combination_sum([2, 3, 6, 7], 7))
# [[2, 2, 3], [7]]
策略3:记忆化剪枝(去重)
python
def permute_unique(nums: list[int]) -> list[list[int]]:
"""
全排列 II:数组可能包含重复数字
"""
nums.sort() # 排序,让重复数字相邻
result = []
path = []
used = [False] * len(nums)
def backtrack():
if len(path) == len(nums):
result.append(path[:])
return
for i in range(len(nums)):
if used[i]:
continue
# 剪枝:跳过同一层中的重复数字
# 条件:当前数字等于前一个,且前一个未使用(说明是同层)
if i > 0 and nums[i] == nums[i-1] and not used[i-1]:
continue
used[i] = True
path.append(nums[i])
backtrack()
path.pop()
used[i] = False
backtrack()
return result
# 测试
print(permute_unique([1, 1, 2]))
# [[1, 1, 2], [1, 2, 1], [2, 1, 1]]
8. 回溯算法的复杂度分析
8.1 时间复杂度
回溯算法的时间复杂度取决于解空间树的大小:
| 问题类型 | 解空间大小 | 时间复杂度 |
|---|---|---|
| 子集问题 | 每个元素选/不选 | O(2ⁿ) |
| 组合问题 | C(n, k) | O(C(n,k)) |
| 排列问题 | n! | O(n!) |
| N 皇后 | 实际远小于 n! | 最坏 O(n!) |
8.2 空间复杂度
- 递归栈深度:通常为 O(n)
- 路径存储:通常为 O(n)
- 总空间:O(n) 或 O(n²)(保存所有解时)
8.3 优化效果对比
以组合问题 combine(20, 10) 为例:
| 版本 | 递归调用次数 | 优化效果 |
|---|---|---|
| 无剪枝基础版 | 184,756 | 基准 |
| 剪枝优化版 | 184,756 | 无(此题本身已最优) |
| 错误实现(不撤销选择) | 无限/错误 | 崩溃 |
以 combination_sum([2,3,5], 8) 为例:
| 版本 | 递归调用次数 |
|---|---|
| 无剪枝 | 大量无效分支 |
| 排序剪枝 | 显著减少 |
9. 总结与思维导图
9.1 回溯算法核心框架
python
def backtrack(路径, 选择列表):
if 满足结束条件:
result.append(路径[:])
return
for 选择 in 选择列表:
if 不满足约束: # 剪枝
continue
做选择
backtrack(新路径, 新选择列表)
撤销选择 # 关键!
9.2 问题类型速查
| 问题 | 特征 | 关键代码 |
|---|---|---|
| 组合 | 无序,不重复 | backtrack(i + 1) |
| 排列 | 有序,全排列 | used[] 标记 |
| 子集 | 所有可能 | 每个节点都收集 |
| N 皇后 | 约束复杂 | is_valid() 检查 |
| 分割 | 字符串切割 | 判断子串合法性 |
9.3 回溯算法的优缺点
| 优点 | 缺点 |
|---|---|
| 代码结构清晰,易于实现 | 时间复杂度高,指数级 |
| 能求出所有解 | 数据量大时可能超时 |
| 通用性强,框架固定 | 需要设计好剪枝策略 |
| 适合组合、排列类问题 | 空间占用随深度增加 |