1. 回溯算法简介
回溯算法(Backtracking)是一种避免不必要搜索的穷举式搜索算法. 它采用试错的思想, 在搜索过程中, 当某一步选择无法满足求解条件时, 退回一步(回溯)重新选择. 简单来说, 回溯算法的核心思想是「走不通就回退」. 它通常通过递归实现.
回溯算法的两种典型结果:
- 找到一个可能的解.
- 尝试了所有可能的方法后, 宣布无解.
全排列问题示例:
以 [1, 2, 3]
为例, 使用回溯法来生成全排列:
-
选择
1
为开头, 得到[1, X, X]
- 然后选择
2
为中间数,得到[1, 2, X]
- 最后一位只能选择
3
, 得到[1, 2 ,3]
- 回溯
- 最后一位只能选择
- 然后选择
3
为中间数,得到[1, 3, X]
- 最后一位只能选择
2
, 得到[1, 3 ,2]
- 回溯
- 最后一位只能选择
- 回溯
- 然后选择
-
选择
2
为开头, 得到[2, X, X]
- 然后选择
1
为中间数,得到[2, 1, X]
- 最后一位只能选择
3
, 得到[2, 1 ,3]
- 回溯
- 最后一位只能选择
- 然后选择
3
为中间数,得到[2, 3, X]
- 最后一位只能选择
1
, 得到[2, 3 ,1]
- 回溯
- 最后一位只能选择
- 回溯
- 然后选择
-
选择
3
为开头, 得到[3, X, X]
- 然后选择
1
为中间数,得到[3, 1, X]
- 最后一位只能选择
2
, 得到[3, 1 ,2]
- 回溯
- 最后一位只能选择
- 然后选择
2
为中间数,得到[3, 2, X]
- 最后一位只能选择
1
, 得到[3, 2 ,1]
- 回溯
- 最后一位只能选择
- 回溯
- 然后选择
-
遍历所有可能的排列
使用回溯法生成全排列的树状图如下所示:
[ ] (初始状态)
├── 1
│ ├── 2
│ │ └── 3 -> [1, 2, 3]
│ └── 3
│ └── 2 -> [1, 3, 2]
├── 2
│ ├── 1
│ │ └── 3 -> [2, 1, 3]
│ └── 3
│ └── 1 -> [2, 3, 1]
└── 3
├── 1
│ └── 2 -> [3, 1, 2]
└── 2
└── 1 -> [3, 2, 1]
- 每个分支表示选择了一个数字。
- 每当路径中的数字数量达到
3
(也就是nums
列表的长度),就表示找到了一个完整的排列(例如[1, 2, 3]
)。 - 完整的排列标记在每个路径的末端,路径构造完成后回溯到上一个选择点。
回溯算法核心步骤:
- 选择元素:从未被选中的元素中选择.
- 递归搜索:递归进入下一层, 直到满足边界条件.
- 撤销选择:回退选择, 尝试新的分支.
2. 回溯算法的代码实现
以全排列问题为例, 回溯算法代码如下:
class Solution:
def permute(self, nums: List[int]) -> List[List[int]]:
res = [] # 存放所有符合条件结果的集合
path = [] # 存放当前符合条件的结果
def backtracking(nums):
if len(path) == len(nums): # 当path长度等于nums长度时,找到一个完整排列
res.append(path[:]) # 将当前结果复制一份,放入结果集res中
return
for i in range(len(nums)): # 遍历nums中的每一个元素
if nums[i] not in path: # 检查该元素是否已在path中
path.append(nums[i]) # 将当前元素加入path
backtracking(nums) # 递归调用,继续构建下一个数字
path.pop() # 撤销选择,移除最后一个元素,进行下一轮选择
backtracking(nums)
return res
工作流程详解
初始状态
nums = [1, 2, 3]
res = []
(最终结果存放全排列)path = []
(当前路径,存放正在构建的排列)
递归执行流程
-
第一次递归:选择第一个元素
for i in range(len(nums))
: 第一次循环i = 0
,选择nums[0] = 1
。path.append(1)
:path = [1]
。- 递归调用
backtracking(nums)
,此时path = [1]
。
-
第二次递归:选择第二个元素
for i in range(len(nums))
: 由于1
已经在path
中,跳过nums[0]
,选择nums[1] = 2
。path.append(2)
:path = [1, 2]
。- 递归调用
backtracking(nums)
,此时path = [1, 2]
。
-
第三次递归:选择第三个元素
for i in range(len(nums))
: 跳过nums[0]
和nums[1]
,选择nums[2] = 3
。path.append(3)
:path = [1, 2, 3]
。- 递归调用
backtracking(nums)
,此时path = [1, 2, 3]
。
-
找到一个完整的排列
- 现在
path = [1, 2, 3]
,长度等于nums
长度,符合全排列条件。 - 将
path
的副本加入res
,即res = [[1, 2, 3]]
。 - 回溯:
path.pop()
撤销选择,path = [1, 2]
,返回上一层递归。
- 现在
-
回溯:尝试其他可能性
- 回到第二次递归中的
path = [1, 2]
,继续for i in range(len(nums))
的循环。 - 当前已经选择过
nums[2]
,因此回溯到path = [1]
,继续寻找。
- 回到第二次递归中的
-
继续递归:选择其他分支
- 选择
nums[2] = 3
作为第二个元素:path = [1, 3]
,递归后选择nums[1] = 2
:path = [1, 3, 2]
,找到第二个排列,将其加入res
:res = [[1, 2, 3], [1, 3, 2]]
。
- 选择
-
回溯到更上层,选择其他分支
- 回溯到
path = []
,选择nums[1] = 2
作为第一个元素。 - 继续递归,分别选择
nums[0] = 1
和nums[2] = 3
。 - 最终找到
path = [2, 1, 3]
和path = [2, 3, 1]
,将其加入res
:res = [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1]]
。
- 回溯到
-
最后的回溯和分支
- 回溯到
path = []
,选择nums[2] = 3
作为第一个元素。 - 继续递归选择
nums[0] = 1
和nums[1] = 2
,找到path = [3, 1, 2]
和path = [3, 2, 1]
。 - 将它们加入
res
,最终res = [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]
。
- 回溯到
总结
- 递归构建:在每次递归中,函数会尝试从
nums
中选择一个未被使用的元素,并将其添加到path
中。当path
的长度等于nums
的长度时,说明找到了一个完整的排列。 - 回溯撤销选择:一旦找到一个排列,或者尝试完所有可能性,函数会回溯,撤销之前的选择,然后尝试新的选择,直到遍历所有可能的排列。
- 最终结果:通过回溯,所有排列都会被找到并存储在
res
中,最后返回。
关键点
- 递归:每一层递归负责选择当前的位置的元素。
- 回溯:当递归完成或需要更换路径时,撤销最后一个选择,回到上一层继续探索。
- 去重:通过
if nums[i] not in path
这一条件来确保每个排列中的元素不会重复使用。
这样,程序能够正确生成 [1, 2, 3]
的所有排列。
3. 回溯算法通用模板
以下为回溯算法的一般模板:
res = [] # 存放所有符合条件结果的集合
path = [] # 存放当前符合条件的结果
def backtracking(nums):
if 遇到边界条件: # 说明找到了一组符合条件的结果
res.append(path[:]) # 将结果加入集合
return
for i in range(len(nums)): # 枚举可选元素列表
path.append(nums[i]) # 选择元素
backtracking(nums) # 递归搜索
path.pop() # 撤销选择
4. 回溯算法三步走
为了简化回溯算法的理解, 可以将其归纳为三步:
- 明确所有选择:根据决策树, 列出所有可选路径.
- 明确终止条件:找到递归的边界条件, 如达到叶子节点.
- 将决策树和终止条件翻译成代码:
- 定义回溯函数, 传入参数与全局变量.
- 编写回溯主体部分:约束条件、递归搜索、撤销选择.
- 明确递归终止条件, 处理终止时的结果.
5. 小结
回溯算法本质上是深度优先搜索(DFS), 当遇到不符合条件的节点时, 回退并探索其他可能的路径. 回溯的核心是递归和状态重置(撤销选择), 常用于解决排列组合、子集问题等. 通过掌握回溯算法的模板和决策树的思路, 可以应对各种复杂的搜索问题.
6. 补充内容:
- 回溯算法核心思想是通过递归解决问题, 熟练掌握递归和回溯状态的转换尤为重要.
- 在编写回溯代码时, 关键是理清递归的结束条件和每步的选择操作.