目录
1、什么是回溯算法?
回溯算法(backtracking algorithm)是一种通过穷举来解决问题 的方法,它的核心思想是从一个初始状态出发,暴力搜索所有可能的解决方案,当遇到正确的解则将其记录,直到找到解或者尝试了所有可能的选择都无法找到解为止。
回溯算法通常采用"深度优先搜索"来遍历解空间。在"二叉树"章节中,我们提到前序、中序和后序遍历都属于深度优先搜索。
回溯算法可以理解为是一个试错算法 。也就是在尝试在分步解决问题的时候,当发现某些不能满足问题答案的步骤时,可以取消之前的计算,再次尝试其它的步骤来寻找问题的答案。
接下来,我们利用前序遍历构造一个回溯问题,逐步了解回溯算法的工作原理。
例题1:给定一棵二叉树,搜索并记录所有值为 7 的节点,请返回节点列表。
对于此题,我们前序遍历这棵树,并判断当前节点的值是否为 7 若是,则将该节点的值加入结果列表 res
之中。相关过程实现如图和以下代码所示:
python
def pre_order(root: TreeNode):
"""前序遍历:例题一"""
if root is None:
return
if root.val == 7:
# 记录解
res.append(root)
pre_order(root.left)
pre_order(root.right)
在前序遍历中搜索节点
2、尝试与回退
之所以称之为回溯算法,是因为该算法在搜索解空间时会采用"尝试"与"回退"的策略。当算法在搜索过程中遇到某个状态无法继续前进或无法得到满足条件的解时,它会撤销上一步的选择,退回到之前的状态,并尝试其他可能的选择。
对于例题1,访问每个节点都代表一次"尝试",而越过叶节点或返回父节点的 return
则表示"回退"。
值得说明的是,回退并不仅仅包括函数返回。为解释这一点,我们对例题一稍作拓展。
例题2: 在二叉树中搜索所有值为 7 的节点,请返回根节点到这些节点的路径。
在例题1代码的基础上,我们需要借助一个列表 path
记录访问过的节点路径。当访问到值为 7 的节点时,则复制 path
并添加进结果列表 res
。遍历完成后,res
中保存的就是所有的解。代码如下所示:
python
def pre_order(root: TreeNode):
"""前序遍历:例题二"""
if root is None:
return
# 尝试
path.append(root)
if root.val == 7:
# 记录解
res.append(list(path))
pre_order(root.left)
pre_order(root.right)
# 回退
path.pop()
在每次"尝试"中,我们通过将当前节点添加进 path
来记录路径;而在"回退"前,我们需要将该节点从 path
中弹出,以恢复本次尝试之前的状态。
观察下图所示的过程,我们可以将尝试和回退理解为"前进"与"撤销",两个操作互为逆向。
3、剪枝
复杂的回溯问题通常包含一个或多个约束条件,约束条件通常可用于"剪枝"。
例题3: 在二叉树中搜索所有值为 7 的节点,请返回根节点到这些节点的路径,并要求路径中不包含值为 3 的节点。
为了满足以上约束条件,我们需要添加剪枝操作:在搜索过程中,若遇到值为 3 的节点,则提前返回,不再继续搜索。代码如下所示:
python
def pre_order(root: TreeNode):
"""前序遍历:例题三"""
# 剪枝
if root is None or root.val == 3:
return
# 尝试
path.append(root)
if root.val == 7:
# 记录解
res.append(list(path))
pre_order(root.left)
pre_order(root.right)
# 回退
path.pop()
"剪枝"是一个非常形象的名词。如下图所示,在搜索过程中,我们"剪掉"了不满足约束条件的搜索分支,避免许多无意义的尝试,从而提高了搜索效率。
根据约束条件剪枝
4、框架代码
接下来,我们尝试将回溯的"尝试、回退、剪枝"的主体框架提炼出来,提升代码的通用性。
在以下框架代码中,state
表示问题的当前状态,choices
表示当前状态下可以做出的选择:
python
def backtrack(state: State, choices: list[choice], res: list[state]):
"""回溯算法框架"""
# 判断是否为解
if is_solution(state):
# 记录解
record_solution(state, res)
# 不再继续搜索
return
# 遍历所有选择
for choice in choices:
# 剪枝:判断选择是否合法
if is_valid(state, choice):
# 尝试:做出选择,更新状态
make_choice(state, choice)
backtrack(state, choices, res)
# 回退:撤销选择,恢复到之前的状态
undo_choice(state, choice)
接下来,我们基于框架代码来解决例题三。状态 state
为节点遍历路径,选择 choices
为当前节点的左子节点和右子节点,结果 res
是路径列表:
python
def is_solution(state: list[TreeNode]) -> bool:
"""判断当前状态是否为解"""
return state and state[-1].val == 7
def record_solution(state: list[TreeNode], res: list[list[TreeNode]]):
"""记录解"""
res.append(list(state))
def is_valid(state: list[TreeNode], choice: TreeNode) -> bool:
"""判断在当前状态下,该选择是否合法"""
return choice is not None and choice.val != 3
def make_choice(state: list[TreeNode], choice: TreeNode):
"""更新状态"""
state.append(choice)
def undo_choice(state: list[TreeNode], choice: TreeNode):
"""恢复状态"""
state.pop()
def backtrack(
state: list[TreeNode], choices: list[TreeNode], res: list[list[TreeNode]]
):
"""回溯算法:例题三"""
# 检查是否为解
if is_solution(state):
# 记录解
record_solution(state, res)
# 遍历所有选择
for choice in choices:
# 剪枝:检查选择是否合法
if is_valid(state, choice):
# 尝试:做出选择,更新状态
make_choice(state, choice)
# 进行下一轮选择
backtrack(state, [choice.left, choice.right], res)
# 回退:撤销选择,恢复到之前的状态
undo_choice(state, choice)
根据题意,我们在找到值为 7 的节点后应该继续搜索,因此需要将记录解之后的 return
语句删除 。如下图对比了保留或删除 return
语句的搜索过程:
保留与删除 return 的搜索过程对比图
相比基于前序遍历的代码实现,基于回溯算法框架的代码实现虽然显得啰唆,但通用性更好。实际上,许多回溯问题可以在该框架下解决 。我们只需根据具体问题来定义 state
和 choices
,并实现框架中的各个方法即可。
5、常用术语
为了更清晰地分析算法问题,我们总结一下回溯算法中常用术语的含义,并对照例题三给出对应示例,如表 所示。
|------------------|---------------------------------------|----------------------------------------|
| 名词 | 定义 | 在例题3中的体现 |
| 解(solution) | 解是满足问题特定条件的答案,可能有一个或多个 | 根节点到节点 7 的满足约束条件的所有路径 |
| 约束条件(constraint) | 约束条件是问题中限制解的可行性的条件,通常用于剪枝 | 路径中不包含节点 3 |
| 状态(state) | 状态表示问题在某一时刻的情况,包括已经做出的选择 | 当前已访问的节点路径,即 path
节点列表 |
| 尝试(attempt) | 尝试是根据可用选择来探索解空间的过程,包括做出选择,更新状态,检查是否为解 | 递归访问左(右)子节点,将节点添加进 path
,判断节点的值是否为 7 |
| 回退(backtracking) | 回退指遇到不满足约束条件的状态时,撤销前面做出的选择,回到上一个状态 | 当越过叶节点、结束节点访问、遇到值为 3 的节点时终止搜索,函数返回 |
| 剪枝(pruning) | 剪枝是根据问题特性和约束条件避免无意义的搜索路径的方法,可提高搜索效率 | 当遇到值为 3 的节点时,则不再继续搜索 |
[常见的回溯算法术语]
6、优点与局限性
回溯算法本质上是一种深度优先搜索算法,它尝试所有可能的解决方案直到找到满足条件的解。这种方法的优点在于能够找到所有可能的解决方案,而且在合理的剪枝操作下,具有很高的效率。
然而,在处理大规模或者复杂问题时,回溯算法的运行效率可能难以接受。
- 时间:回溯算法通常需要遍历状态空间的所有可能,时间复杂度可以达到指数阶或阶乘阶。
- 空间:在递归调用中需要保存当前的状态(例如路径、用于剪枝的辅助变量等),当深度很大时,空间需求可能会变得很大。
即便如此,回溯算法仍然是某些搜索问题和约束满足问题的最佳解决方案 。对于这些问题,由于无法预测哪些选择可生成有效的解,因此我们必须对所有可能的选择进行遍历。在这种情况下,关键是如何优化效率,常见的效率优化方法有两种。
- 剪枝:避免搜索那些肯定不会产生解的路径,从而节省时间和空间。
- 启发式搜索:在搜索过程中引入一些策略或者估计值,从而优先搜索最有可能产生有效解的路径。
7、回溯算法经典例题
回溯算法可用于解决许多搜索问题、约束满足问题和组合优化问题。
给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。
示例 1:
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
示例 2:
输入:nums = [0,1]
输出:[[0,1],[1,0]]
示例 3:
输入:nums = [1]
输出:[[1]]
提示:
1 <= nums.length <= 6
-10 <= nums[i] <= 10
nums 中的所有整数 互不相同
(1)解题思路1:
暴力求解,分别把不同数字组合做一个排列。
输入:[1,2,3] 如果我们从1开始考虑可能的结果组合
每个条件组合都可以发现是排列组合的筛选,检查条件是当前值不能出现在序列中。
如果完全展开,条件也同样适用。暴力解发就是n层循环的嵌套。其中n=len(nums)
python
result = []
nums = [1,2,3]
for i in nums:
seq = [i]
for j in nums:
if j not in seq:
seq.append(j)
for k in nums:
if k not in seq:
seq.append(k)
result.append(seq[:])
seq.pop()
seq.pop()
print(result)
[[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]
(2)解题思路2:
虽然暴力求解可以得到结果,但嵌套循环的层数与数组长度一致,动态的数组的嵌套遍历是不能解决的。所以回溯方法适用于递归实现。
python
result = []
nums = [1,2]
def permutation(nums,seq):
if len(seq) == len(nums):
result.append(seq[:])
return
for i in range(len(nums)):
if nums[i] in seq:
continue
seq.append(nums[i])
permutation(nums,seq)
seq.pop()
permutation(nums, [])
print(result)
[[1, 2], [2, 1]]
👆上面代码中每进入一层迭代,temp充当着保存上一层遍历元素的存储任务。当递归方法结束后,对应的当前层的存储的组合也要弹出重新。
当组合的长度等于数组长度时,就可以进行结果保存了。
python
if len(seq) == len(nums):
result.append(seq[:])
(3)回溯算法归纳
根据上面算法的图解,可以很容易的发现回溯算法实现和树遍历的深度优先搜索算法思路是一致的。
只是根据条件选择搜索的节点。
所以说回溯算法是暴力穷举算法。由此也就决定了它的特点:算法的时间复杂度高O(n!)