一、理论基础
其实在讲解二叉树的时候,就给大家介绍过回溯,这次正式开启回溯算法,大家可以先看视频,对回溯算法有一个整体的了解。题目链接/文章讲解:代码随想录
1. 什么是回溯
有回溯就有递归,回溯函数就是递归函数。
2. 使用原因及解决的问题
(1)使用原因:纯暴力,但有些问题必须采用回溯,因为for循环嵌套搜索失效。
(2)解决问题:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 棋盘问题:N皇后,解数独等
注意区别:组合是不强调元素顺序的,排列是强调元素顺序。
3. 如何理解回溯法
回溯法可以抽象为一个树形结构(N叉树),因为回溯就是一个递归的过程;回溯抽象为N叉树,横向for循环,纵向递归。
4. 回溯模板
(1)确定递归函数的参数和返回值:一般来说,回溯法没有返回值,参数较多,一开始无法直接写出参数,所以在写的过程中添加参数即可。
(2)确定终止条件:在终止条件中收集结果(if 语句)。
(3)单层递归逻辑:for循环的参数用于处理集合每一个元素,也就是for循环遍历集合的每一个参数,然后在循环中处理节点,然后进入递归过程,最后回溯(撤销处理节点的情况)。
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
二、77. 组合
对着 在 回溯算法理论基础 给出的 代码模板,来做本题组合问题,大家就会发现 写回溯算法套路。在回溯算法解决实际问题的过程中,大家会有各种疑问,先看视频介绍,基本可以解决大家的疑惑。
本题关于剪枝操作是大家要理解的重点,因为后面很多回溯算法解决的题目,都是这个剪枝套路。
题目链接/文章讲解:代码随想录
1. 代码实现
(1)定义全局变量一维数组path和二维数组result。
(2)确定递归函数的参数和返回值:传入的参数n表示数组的大小,k表示组合的大小,startindex表示本次搜索的起始位置。
(3)确定终止条件:抵达叶子节点,也就是path的大小等于k,然后使用result数组收集这个结果,然后返回即可。
(4)单层递归逻辑:每个节点实际上都是一个for循环,for循环中起始位置是startindex去遍历剩余元素。先把第i个元素放进path,然后进入递归,传入n、k、i+1,这样从第i+1个元素开始搜索;最后进行回溯过程,将path清空。
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)->List[List[int]]:
if len(path) == k:
result.append(path[:])
return
for i in range(startindex, n+1):
path.append(i)
self.backTracking(n, k, i+1, path, result)
path.pop()
2. 代码易错点
(1). 代码里为什么要写成result.append(path[:])而不是写result.append(path):
在这段代码中,result.append(path[:])
的使用是为了确保添加到result
列表中的每个组合都是path
列表的一个独立副本,而不是对path
的引用。这是因为path
在递归过程中会被修改(通过path.append(i)
和path.pop()
),如果我们直接添加path
本身到result
中,那么当path
在后续的递归中被修改时,已经添加到result
中的列表也会受到影响,因为它们指向的是同一个对象。
详细解释如下:
-
列表是可变的:在Python中,列表是可变的数据类型。这意味着如果你将一个列表赋值给另一个变量,或者将一个列表作为元素添加到另一个列表中,这两个变量实际上引用的是同一个列表对象。因此,通过一个变量对列表所做的修改会影响到另一个变量。
-
递归中的状态变化:在回溯算法中,
path
列表用于存储当前的组合状态。随着递归的进行,path
会被不断地修改(添加元素和移除元素)。 -
避免共享状态:当你找到一个满足条件的组合(即
len(path) == k
)时,你需要将这个组合保存下来。如果此时你使用result.append(path)
,你实际上是在向result
中添加了一个对当前path
状态的引用。由于path
在后续的递归中会被修改,这个引用所指向的列表内容也会改变。 -
创建副本:为了避免这个问题,
path[:]
创建了一个path
的浅拷贝。这意味着你得到了一个新的列表,它与path
有相同的元素,但它们是两个不同的对象。因此,当path
在后续的递归中被修改时,这个新列表的内容不会受到影响。
所以,result.append(path[:])
确保了每个添加到result
中的组合都是独立的,不会受到后续递归中path
修改的影响。这是回溯算法中一个常见的技巧,用于正确处理可变数据结构在递归过程中的状态变化。
(2). 为什么使用path.pop()
而不是path = []
来重置path
列表
在回溯算法中,使用path.pop()
而不是path = []
来重置path
列表的原因在于它们对列表对象本身的影响不同。
path.pop()
:path.pop()
方法会从path
列表的末尾移除一个元素,并返回这个被移除的元素。- 在回溯算法中,这通常用于撤销上一次的选择,以便尝试其他可能的选择。
- 当递归调用返回时,我们需要回到上一次的状态,以便继续搜索。使用
path.pop()
可以保留path
列表之前的所有元素,只移除最后添加的那个元素。
path = []
:path = []
会创建一个新的空列表,并将变量path
指向这个新列表。- 这意味着原来的
path
列表(以及可能包含的对它的引用)将被丢弃,所有之前添加到path
中的元素都将丢失。 - 在回溯算法中,如果我们使用
path = []
来重置列表,我们将无法撤销上一次的选择,因为我们已经丢弃了包含之前所有选择的原始列表。
在回溯过程中,我们需要能够回溯到之前的每一步,以便尝试所有可能的选择。为了实现这一点,我们必须保留每一步的状态,并在需要时能够撤销选择。path.pop()
允许我们这样做,因为它只移除最后添加的元素,而不影响列表中的其他元素。
因此,在回溯算法中,我们通常使用path.pop()
(或类似的撤销操作)来回到上一步的状态,而不是创建一个全新的列表并丢弃之前的状态。这样做可以确保我们能够正确地遍历所有可能的解,并且每个解都是基于之前步骤的有效选择构建的。
3. 组合问题的剪枝操作
所以,可以剪枝的地方就在递归中每一层的for循环所选择的起始位置。
如果for循环选择的起始位置之后的元素个数 已经不足 我们需要的元素个数了,那么就没有必要搜索了。
class Solution:
def backTracking(self, n, k, startindex, path, result)->List[List[int]]:
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()
def combine(self, n: int, k: int) -> List[List[int]]:
result = []
path = []
self.backTracking(n, k, 1, path, result)
return result
三、216.组合总和III
如果把 组合问题理解了,本题就容易一些了。题目链接/文章讲解:代码随想录
视频讲解:和组合问题有啥区别?回溯算法如何剪枝?| LeetCode:216.组合总和III_哔哩哔哩_bilibili
1. 代码实现
(1)首先定义变量result(存放结果集)和path(收集组合)。
(2)确定递归函数的参数和返回值:参数targetSum相当于题目中相加为n;k是要求的组合数,用于控制树的深度;sum是当前组合数的加和,用于与targetSum进行比较;startindex用于控制当前递归层取数的起始位置,初始化为1开始。
(3)确定终止条件:当path的长度等于要求的k,那么终止递归;然后判断组合的和等于targetSum,那么它是满足目标值的组合,放进结果集。
(4)单层递归逻辑:for循环,从startindex开始(相当于从1开始,因为题目说明了数字从1-9),到9结束:遍历i,就把i加入进sum中,同时集合path收集i,然后继续递归,传入targetsum、k、sum、i+1。然后进行回溯,sum-i,将path弹出。
(5)剪枝操作(本题有组合个数和组合总和均有要求,因此可以从这两个角度剪枝):如果sum>targetsum,可以直接return,没有必要再递归了;此外,从元素角度进行考虑可以剪枝,如果横向元素个数不足k,那么继续向后遍历也没有意义,无法凑成元素个数为k的组合。
class Solution:
def backTracking(self, targetSum, sum, k, startindex, path, result):
if targetSum < sum: return
if len(path) == k:
if sum == targetSum:
result.append(path[:])
return
for i in range(startindex, 9-(k-len(path))+2):
sum += i
path.append(i)
self.backTracking(targetSum, sum, k, i+1, path, result)
sum -= i
path.pop()
def combinationSum3(self, k: int, n: int) -> List[List[int]]:
result = []
path = []
self.backTracking( n, 0, k, 1, path, result)
return result
四、17.电话号码的字母组合
本题大家刚开始做会有点难度,先自己思考20min,没思路就直接看题解。题目链接/文章讲解:代码随想录
1. 代码实现
(1)定义字符串string,用于收获单个结果;定义string类型的数组result,用于收集结果。
(2)确定递归的返回值和参数:参数digits是字符串类型,参数index表明当前递归到哪一个数字了。
(3)确定终止条件:index指向空才能结束递归,因为如果指向最后一个数字就终止的话,最后一个数字没有得到下面的递归处理,导致结果集不完整。在叶子节点收获结果:将path放入结果集result,最后return。
(4)单层递归逻辑:由于digit是一个字符串,所以需要将里面的字符单独取出来,将字符数字转变为真正的数字。然后根据这个数字,在map里面找数字对应的字符串。for循环在字符串里获取字符,将字符放进自定义的字符串里,继续递归,传入digits和index+1(在另一个字符串里进行搜索);最后进行回溯string.pop()。
class Solution:
def __init__(self):
self.lettermap=[
"abc", # 2
"def", # 3
"ghi", # 4
"jkl", # 5
"mno", # 6
"pqrs", # 7
"tuv", # 8
"wxyz" # 9
]
self.result = []
self.s = ''
def backTracking(self, digits, index):
if index == len(digits):
return self.result.append(self.s)
digit = int(digits[index])
letter = self.lettermap[digit-2]
for i in range(len(letter)):
self.s += letter[i]
self.backTracking(digits, index+1)
self.s = self.s[:-1]
def letterCombinations(self, digits: str) -> List[str]:
if len(digits) == 0:
return self.result
self.backTracking(digits,0)
return self.result
2. 代码易错点
不可以对字符串(str)对象使用 .append()
方法,因为字符串并没有这个方法。.append()
方法是用于列表(list)的,用于在列表末尾添加一个新的元素。字符串可以用join()或直接相加减。