目录
一、简介
二、回溯算法的应用
三、回溯算法的模板
四、回溯算法解决子集问题
(一)子集问题描述
(二)问题解决
(三)代码实现
(四)剪枝操作
五、回溯算法解决分割问题
回溯与递归是相辅相成的,有回溯的地方必然会使用到递归。回溯算法并非是一种高效的算法,而是一种暴力法。
对于某些问题来说,即便多层for循环也无法解决,也无法写出这种程序,这时候就需要用到回溯算法。排列、组合、子集、切割、部分棋盘等问题都可以通过回溯算法解决。
举个简单的例子来说明为什么要使用回溯法。
假设从包含4个元素的集合中,找出所有元素个数为2的子集,这时候我们可以通过两层for循环来解决该问题,当要求子集元素个数为3时通过3层for循环解决。那么当要求子集包含的元素个数变为k时(k为较大的值),不可能通过k层for循环来写代码,这时候就需要用到回溯算法,回溯算法其实就是通过递归来自动实现多层for循环的嵌套。
从包含n个元素的集合中,找到所有包含k个元素的子集(对应leetcode上77题)。例如从[1,2,3,4]中找到所有包含两个元素的子集,结果有[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]。
从[1,2,3,4]这个具体问题来理解回溯算法,这里我们树形结构来模拟回溯算法的搜索过程。
对于图中当箭头值取2时候,剩余元素仅为[3,4],而不包含1,这是因为在子集问题中,[1,2]和[2,1]表示的是同一个子集。
使用回溯法,要清楚三个问题。
1.函数的参数值与返回值:在子集问题中,首先函数中传入的参数有元素个数n,子集元素个数k,此外还需要传入一个参数start_idx(这个参数的意义在于从哪个位置开始递归,比如当start_idx为0时候,此时取到的值为1,那么接着递归时候就要从索引为1开始(也就是值为2的地方));此外,由于每个子集会作为一个结果,因此定义一个空列表path来存放子集(上图中[1,2]的形成,是先把1存放到列表中,然后再将2存到列表中,因此需要一个空列表),最终将所有符合条件的子集存放到一个新的列表中,因此需要另一个空列表res;由于子集问题是要将所有符合条件的子集存到一个列表中,而存放结果的操作在函数内部实现,因此不需要返回值;
2.确定终止条件:如果终止条件设置错误,则很容易变成死循环。在子集问题中,当path中元素个数等于k值时,则终止;因此终止条件为len(path)==k;
3.确定单层搜索逻辑:也就是如何通过递归以及回溯来实现多层循环的嵌套。
class Solution:
def combine(self, n: int, k: int):
path = []
res=[]
def backtracking(n, k, start_idx):
# 终止条件
if len(path) == k:
res.append(path[:])
return
# 单层逻辑
for i in range(start_idx, n):
path.append(i + 1)
#递归
backtracking(n, k, i + 1)
#回溯
path.pop()
backtracking(n, k, 0)
return res
path用来存储子集,res存储最终结果。最外层的一次for循环(即第一次for循环)表示遍历当第一个数依次取1、2、3、4时。当每次递归函数执行完成后,执行path.pop()操作(即当找到一个符合条件的子集例如为[1,2]时,终止条件成立,执行path.pop(),此时path变为[1],下一次递归path变为[1,3],当第一个数取1完成之后,path回溯两次变为空,以此类推。
对于有些情况来说,剪枝操作可以降低时间复杂度。例如对于包含4个元素的集合,找到所有包含3个元素的子集,此时当第一个元素取3时候,已经不满足条件了,而不经过剪枝的回溯算法仍然是要对该节点的分支进行搜索,这就造成很大的时间消耗。
由于代码中对每个节点所在分支进行搜索是在for循环中实现,因此只需要找到满足搜索条件的最大值即可,将n替换为n-(k-len(path))+1,简单说明一下(若当前path中存放的元素个数为0,此时计算值为4-(3-0)+1=2,由于python中for循环取不到右边值,也就是符合条件的最大索引为2-1(假设索引从0开始))。
剪枝前后时间复杂度对比如下:
待更新