回溯算法 - 理论基础
回溯是什么
回溯是递归的副产品,只要有递归就会有回溯。所以,回溯函数也就是递归函数,指的都是一个函数。
一般而言,回溯操作会出现在递归被调用之后。
回溯本质上是穷举:穷举所有可能,选出符合要求的答案。适当的剪枝可以减少一些不必要的可能,但无法优化复杂度,因为没有改变穷举的本质。
回溯的应用
有的问题靠嵌套 for loop 是无法完成暴力搜索的,这时就需要回溯来有效地暴力解决问题,属于是无奈之举。
回溯解决的问题都可以抽象为树形结构:在集合中递归查找子集(解题时一定要这么思考!)
- 集合的大小决定了树的宽度
- 递归的深度决定了树的深度
- 递归就要有终止条件,所以必然是一棵高度有限的 N 叉树
回溯的逻辑(模版)
- 返回值以及参数
- 参数很难一开始就确定,在写的过程中逐渐添加即可
- 一般不需要返回值,因为回溯的功能通常是撤销之前的修改(处理节点)
- 终止条件
- 递归必然是有终止的,回溯时的终止条件一般是将符合条件的终止结果保存
- 遍历过程
- 当前节点的子节点数 = 当前集合的子集数
def backtrack(...):
if (终止条件):
# 保存结果
return
for subset in curr_set:
# 处理节点
# 递归:调用自己
# 回溯:撤销之前的处理结果
77. 组合
非常经典的回溯处理:如果是固定长度的子集,可以用嵌套的 for loop 完成,只是很麻烦;但是动态的长度取决于输入,这样的话只能求助于回溯。
如下图所示,集合大小 n=4 是树的宽度,需要的子集大小 k=2 是树的深度。每次从集合中选取元素,可选择的范围(子树宽度)随着之前选择的进行而收缩,调整可选择的范围;每次处理所选择的元素后进行递归(深度)。
- 终止条件: k=0,也就是不需要再向当前子集中添加元素了。
- 参数和返回值:
- 两个全局变量,一个记录当前子集,一个记录最终结果;
- 需要一个额外的函数变量,记录当前的选择范围
- 单层搜索:for loop 遍历当前的选择范围,每一个 iteration 中进行递归+回溯
class Solution:
def __init__(self):
self.curr_record = []
self.results = []
def backtrack(self, start: int, end: int, k: int):
# termination
if k == 0:
self.results.append(self.curr_record.copy())
return None
# recursion and backtrack
for i in range(start, end + 1):
self.curr_record.append(i)
self.backtrack(i + 1, end, k - 1)
self.curr_record.pop()
def combine(self, n: int, k: int) -> List[List[int]]:
self.backtrack(1, n, k)
return self.results
以上代码可以进行剪枝优化:当 for loop 中剩下的元素已经不足 k 个时,没必要继续进行 for loop 了 - 可以改为 for i in range(start, end + 1 - k):