回溯算法理论基础
1.什么是回溯法:
回溯法也可以叫做回溯搜索法,它是一种搜索的方式。
回溯是递归的副产品,只要有递归就会有回溯。
回溯函数也就是递归函数,指的都是一个函数。
2.回溯法的效率:
虽然回溯法很难,很不好理解,但是回溯法并不是什么高效的算法。
因为回溯的本质是穷举,穷举所有可能,然后选出我们想要的答案,如果想让回溯法高效一些,可以加一些剪枝的操作,但也改不了回溯法就是穷举的本质。
那么既然回溯法并不高效为什么还要用它呢?
因为没得选,一些问题能暴力搜出来就不错了,撑死了再剪枝一下,还没有更高效的解法。
3.回溯法解决的问题:
回溯法,一般可以解决如下几种问题:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 棋盘问题:N皇后,解数独等等
什么是组合,什么是排列?
组合是不强调元素顺序的,排列是强调元素顺序。
例如:{1, 2} 和 {2, 1} 在组合上,就是一个集合,因为不强调顺序,而要是排列的话,{1, 2} 和 {2, 1} 就是两个集合了。
记住组合无序,排列有序,就可以了。
4.如何理解回溯法:
回溯法解决的问题都可以抽象为树形结构,是的,我指的是所有回溯法的问题都可以抽象为树形结构!
因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度就构成了树的深度。
递归就要有终止条件,所以必然是一棵高度有限的树(N叉树)。
5.回溯法模板:
在讲二叉树的递归中我们说了递归三部曲,这里我再给大家列出回溯三部曲
- 回溯函数模板返回值以及参数
- 回溯函数终止条件
- 回溯搜索的遍历过程
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
for循环可以理解是横向遍历,backtracking(递归)就是纵向遍历,这样就把这棵树全遍历完了,一般来说,搜索叶子节点就是找的其中一个结果了。
思路:
本题是回溯法的经典题目。
要解决 n为100,k为50的情况,暴力写法需要嵌套50层for循环,那么回溯法就用递归来解决嵌套层数的问题。
递归来做层叠嵌套(可以理解是开k层for循环),每一次的递归中嵌套一个for循环,那么递归就可以用于解决多层嵌套循环的问题了。
那么我把组合问题抽象为如下树形结构:
每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围。
图中可以发现n相当于树的宽度,k相当于树的深度。
那么如何在这个树上遍历,然后收集到我们要的结果集呢?
图中每次搜索到了叶子节点,我们就找到了一个结果。
相当于只需要把达到叶子节点的结果收集起来,就可以求得 n个数中k个数的组合集合。
剪枝优化:我们说过,回溯法虽然是暴力搜索,但也有时候可以有点剪枝优化一下的。
在遍历的过程中有如下代码:
for i in range(startIndex, n + 1):
path.append(i)
self.backtracking(n, k, i + 1, path, result)
path.pop()
这个遍历的范围是可以剪枝优化的,怎么优化呢?
来举一个例子,n = 4,k = 4的话,那么第一层for循环的时候,从元素2开始的遍历都没有意义了。 在第二层for循环,从元素3开始的遍历都没有意义了。
图中每一个节点(图中为矩形),就代表本层的一个for循环,那么每一层的for循环从第二个数开始遍历的话,都没有意义,都是无效遍历。
所以,可以剪枝的地方就在递归中每一层的for循环所选择的起始位置。
如果for循环选择的起始位置之后的元素个数已经不足我们需要的元素个数了,那么就没有必要搜索了。
接下来看一下优化过程如下:
-
已经选择的元素个数:path.size();
-
还需要的元素个数为: k - path.size();
-
在集合n中至多要从该起始位置 : n - (k - path.size()) + 1,开始遍历。(“至多”是一个数学和逻辑上常用的词汇,它表示“不超过”或“最多”的意思。在数学表达式中,至多通常与“≤”(小于或等于)符号相对应。所以,如果你看到“至多x”,你可以理解为“小于或等于x”。)
代码:
未剪枝优化
class Solution:
def combine(self, n: int, k: int) -> List[List[int]]:
result = [] # 初始化一个空列表result,用于存储所有可能的组合
path=[] # 初始化一个空列表path,用于存储当前正在构建的组合
self.backtracking(n, k, 1, path, result) # 调用backtracking方法,开始回溯搜索
return result # 返回找到的所有组合
# 定义了一个名为backtracking的递归方法,用于实际生成组合。
def backtracking(self, n, k, startIndex, path, result):
if len(path) == k: # 判断当前path列表的长度是否等于k,即是否已经找到了一个完整的组合
result.append(path[:]) # 如果path的长度等于k,则将path的当前状态(一个完整的组合)添加到result列表中。注意这里使用了path[:]来复制path列表,以避免直接引用导致后续修改影响结果。
return # 找到一个完整的组合后,递归结束,返回上一层
for i in range(startIndex, n + 1): # 从startIndex开始,遍历到n,尝试将每个数字i添加到path中
path.append(i) # 将当前数字i添加到path中
self.backtracking(n, k, i + 1, path, result) # 递归调用backtracking方法,尝试添加下一个数字。这里startIndex更新为i + 1,以确保不会重复添加相同的数字
path.pop() # 回溯,将path中的最后一个数字i移除,以便尝试其他可能性
时间复杂度:分析回溯问题的时间复杂度,有一个通用公式:路径长度×搜索树的叶子数。对于本题,它等于O(k⋅C(n,k))
空间复杂度:O(k)
剪枝优化
class Solution:
def combine(self, n: int, k: int) -> List[List[int]]:
result = []
self.backtracking(n, k, 1, [], result)
return result
def backtracking(self, n, k, startIndex, path, result):
if len(path) == k:
result.append(path[:])
return
for i in range(startIndex, n - (k - len(path)) + 2): # 优化的地方
path.append(i)
self.backtracking(n, k, i + 1, path, result)
path.pop()