回溯算法理论基础
什么是回溯法
回溯法也可以叫做回溯搜索法,是一种搜索的方式
回溯是递归的副产品,只要有递归就会有回溯。
下面的讲解中,回溯函数也就是递归函数,指的都是一个函数
回溯法的效率
回溯法很难,不好理解,也不是什么高效的算法
回溯的本质是穷举,穷举所有可能,然后选出我们想要的答案
如果想让回溯法高效一些,可以加入一些剪枝操作,但也改变不了回溯法就是穷举的本质
【一些问题能暴力搜索出来就已经不错了,撑死再剪枝一下,还没有更高效的解法】
回溯法解决的问题
回溯法,一般可以解决如下几种问题:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 棋盘问题:N皇后,解数独等
如何理解回溯法
回溯解决的所有问题都可以抽象为树形结构
因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度,都构成的树的深度
递归就要有终止条件,所以必然是一棵高度有限的树(N叉树)
回溯法模板
回溯三部曲:
-
回溯函数模板返回值以及参数
回溯算法中函数返回值一般为void
参数:因为回溯算法需要的参数可不像二叉树递归的时候那么容易一次性确定下来,所以一般是先写逻辑,然后需要什么参数,就填什么参数。
回溯函数伪代码如下:
void backtracking(参数)
-
回溯函数终止条件
既然是树形结构,那么再遍历树形结构一定要有终止条件
什么时候达到了终止条件,树中就可以看出,一般来说搜到叶子节点了,也就找到了满足条件的一条答案,把这个答案存放起来,并结束本层递归。
所以回溯函数终止条件伪代码如下:
if (终止条件) { 存放结果; return; }
-
回溯搜索的遍历过程
在上面提到了,回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成树的深度
集合大小和孩子数量是相等的
回溯函数遍历过程伪代码如下:
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
for循环就是遍历集合区间,可以理解一个节点有多少个孩子,这个for循环就执行多少次。
backtracking这里自己调用自己,实现递归。
大家可以从图中看出for循环可以理解是横向遍历,backtracking(递归)就是纵向遍历,这样就把这棵树全遍历完了,一般来说,搜索叶子节点就是找的其中一个结果了。
分析完过程,回溯算法模板框架如下:
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
组合问题及优化
链接: 第77题. 组合
思路
回溯法解决的问题都可以抽象为树形结构(N叉树)
例图:
在这里插入图片描述
由图片可以直观的看出,集合的大小是树的宽度,递归的深度是树的深度。
每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围。
终止条件:图中每次搜索到了叶子节点,就找到了一个结果
回溯三部曲
- 回溯返回值及参数
从下图中红线部分可以看出,在集合[1,2,3,4]取1之后,下一层递归,就要在[2,3,4]中取数了,那么下一层递归如何知道从[2,3,4]中取数呢,靠的就是startIndex。
所以需要startIndex来记录下一层递归,搜索的起始位置。
那么整体代码如下:
List<Integer> path;
List<List<Integer>> results;
void backtracking(int n,int k,int startIndex)
- 回溯函数终止条件
什么时候到达所谓的叶子节点了呢?
path这个数组的大小如果达到k,说明我们找到了一个子集大小为k的组合了,在图中path存的就是根节点到叶子节点的路径。
此时用result二维数组,把path保存起来,并终止本层递归。
所以终止条件代码如下:
if (path.size() == k){
results.add(new ArrayList<>(path));
return;
}
-
单层搜索的过程
回溯法的搜索过程就是一个树型结构的遍历过程,在如下图中,可以看出for循环用来横向遍历,递归的过程是纵向遍历。
如此我们才遍历完图中的这棵树。
for循环每次从startIndex开始遍历,然后用path保存取到的节点i。
代码如下:
for (int i = startIndex;i <= n;i++){
path.add(i);
backtracking(n,k,i + 1);
path.remove(path.size() - 1);
}
可以看出backtracking(递归函数)通过不断调用自己一直往深处遍历,总会遇到叶子节点,遇到了叶子节点就要返回。
backtracking的下面部分就是回溯的操作了,撤销本次处理的结果。
代码
class Solution {
List<Integer> path;
List<List<Integer>> results;
void backtracking(int n,int k,int startIndex){
if (path.size() == k){
results.add(new ArrayList<>(path));
return;
}
for (int i = startIndex;i <= n;i++){
path.add(i);
backtracking(n,k,i + 1);
path.remove(path.size() - 1);
}
}
public List<List<Integer>> combine(int n, int k) {
path = new ArrayList<>();
results = new ArrayList<List<Integer>>();
backtracking(n,k,1);
return results;
}
}
疑问:为什么results.add(new ArrayList<>(path)); 要再创建一个链表,不创建就是空的呢
剪枝优化
举一个例子,n = 4,k = 4的话,那么第一层for循环的时候,从元素2开始的遍历都没有意义了。 在第二层for循环,从元素3开始的遍历都没有意义了。
图中每一个节点(图中为矩形),就代表本层的一个for循环,那么每一层的for循环从第二个数开始遍历的话,都没有意义,都是无效遍历。
所以,可以剪枝的地方就在递归中每一层的for循环所选择的起始位置。
如果for循环选择的起始位置之后的元素个数 已经不足 我们需要的元素个数了,那么就没有必要搜索了。
注意代码中i,就是for循环里选择的起始位置。
接下来看一下优化过程如下:
- 已经选择的元素个数:path.size();
- 还需要的元素个数为: k - path.size();
- 在集合n中至多要从该起始位置 : n - (k - path.size()) + 1,开始遍历
为什么有个+1呢,因为包括起始位置,我们要是一个左闭的集合。
举个例子,n = 4,k = 3, 目前已经选取的元素为0(path.size为0),n - (k - 0) + 1 即 4 - ( 3 - 0) + 1 = 2。
从2开始搜索都是合理的,可以是组合[2, 3, 4]。
优化后代码
class Solution {
List<Integer> path;
List<List<Integer>> results;
void backtracking(int n,int k,int startIndex){
if (path.size() == k){
results.add(new ArrayList<>(path));
return;
}
for (int i = startIndex;i <= n - (k - path.size()) + 1;i++){
path.add(i);
backtracking(n,k,i + 1);
path.remove(path.size() - 1);
}
}
public List<List<Integer>> combine(int n, int k) {
path = new ArrayList<>();
results = new ArrayList<List<Integer>>();
backtracking(n,k,1);
return results;
}
}
组合总和III
链接: 216.组合总和III
题目特征
-
在大小为9的集合中取k个数,使之相加为n
-
取k个数
- i <= 9 - (k - path.size()) + 1
-
不重复使用数字
class Solution {
List<Integer> path;
List<List<Integer>> results;
void backtracking(int targetSum,int k,int startIndex,int sum){
if (sum > targetSum) return;
if (path.size() == k){
if (sum == targetSum){
results.add(new ArrayList<>(path));
}
return;
}
for (int i = startIndex;i <= 9 - (k - path.size()) + 1;i++){
sum += i;
path.add(i);
backtracking(targetSum,k,i + 1,sum);
path.remove(path.size() - 1);
sum -= i;
}
}
public List<List<Integer>> combinationSum3(int k, int n) {
path = new ArrayList<>();
results = new ArrayList<List<Integer>>();
backtracking(n,k,1,0);
return results;
}
}
最好画个图理解一下,千万不要忘记sum += i 回溯后再减去sum -= i;
电话号码的字母组合
链接: 17.电话号码的字母组合
思路
思路是回溯常见思路
本题的特殊之处在于数字字符串和字母字符串的转换,用到的数据类型之间的转换比较多。
涉及到数字和字符串的转换可以直接用Stirng数组来表示
回溯三部曲:
-
返回值及参数
回溯函数的返回值一般都是空,
在本题中,digits是用户输入的数字字符串,要传进去。
我们将String数组定义在主函数而不是定义成全局变量的话,也需要传进去,而为了遍历,我们还定义了一个num,是用来指向数组字符串的索引
-
终止条件
num 等于长度时,说明需要加入results处理,就不用再往字符串里加入了。
-
单层逻辑
单层逻辑和一般的回溯大同小异,有一点区别。
遍历到宽度后,往深层遍历:加入元素,遍历,回溯删除
主要还是写代码过程中的细节问题。
代码
class Solution {
List<String> results = new ArrayList<>();
public List<String> letterCombinations(String digits) {
// return null和return一个空的列表还是有区别的
if (digits == null || digits.length() == 0) return results;
// 数组字面量赋值是大括号不是中括号,与js不同
String[] newString = {"","","abc","def","ghi","jkl","mno","pqrs","tuv","wxyz"};
backTacking(digits,newString,0);
return results;
}
StringBuilder sb = new StringBuilder();
// digits是输入的数字字符串 newString是数字对应的字母字符串 num是数字字符串下标
void backTacking(String digits,String[] newString,int num){
if (num == digits.length()){
results.add(sb.toString());
return;
}
// 先找出输入的数字字符串中每个元素对应的数字,然后从newString中取出来
// 字符串的长度是String.length() 不是size()
String str = newString[digits.charAt(num) - '0'];
for (int i = 0;i < str.length();i++){
sb.append(str.charAt(i));
backTacking(digits,newString,num + 1);
sb.deleteCharAt(sb.length() - 1);
}
}
}
组合总和
链接: 39. 组合总和
思路
本题是在自己头脑清晰的情况下独立做出来的
本题与组合总和III不同之处有两点
- 从数组中取元素相加之和为target,不限制取元素的数量
- 数组中的元素可以重复取。
回溯三部曲
-
返回值及参数
回溯返回值一般都为空,参数要主函数给出的两个参数:选择数组及目标和,其次还有一个我们使用的和sum,然后还要有一个标志startIndex;startIndex表示我们在下一层递归时开始的索引。
-
终止条件
sum > targetSum,肯定要返回
本题没有限制取元素的数量,所以终止条件不考虑。因此,只考虑sum = targetSum的情况,相等则将path加入到results中
-
单层逻辑
抽象成树的形状,int i = startIndex,因为没有数量的限制,所以i的条件只需要 < candidates.length【不是size,也不用加括号】
sum加上candidates[i] , path加入该数字,遍历,然后回溯。
代码
class Solution {
List<Integer> path = new ArrayList<>();
List<List<Integer>> results = new ArrayList<>();
void backTracking(int[] candidates,int targetSum,int sum,int startIndex){
if (sum > targetSum){
return;
}
if (sum == targetSum){
results.add(new ArrayList<>(path));
return;
}
for (int i = startIndex;i < candidates.length;i++){
sum += candidates[i];
path.add(candidates[i]);
backTracking(candidates,targetSum,sum,i);
path.remove(path.size() - 1);
sum -= candidates[i];
}
}
public List<List<Integer>> combinationSum(int[] candidates, int target) {
backTracking(candidates,target,0,0);
return results;
}
}
组合总和II
链接: 40.组合总和II
思路
本题与组合总和不同之处在于
- 每个数字在每个组合中只能使用一次
- 给出的candidates数组里面的元素有重复
我的思路:先去重,再进行常规操作
【数组如何去重】
老师讲解思路:
树层去重,树枝去重
回溯三部曲
-
返回值及参数 : 略
-
终止条件:略
-
单层搜索逻辑
要去重的是“同一树层上(相同元素)使用过了。”
如何判断:
如果
candidates[i] == candidates[i - 1]
并且used[i - 1] == false
,就说明:前一个树枝,使用了candidates[i - 1],也就是说同一树层使用过candidates[i - 1]。此时for循环里就应该做continue的操作。
如果**
used[i - 1] == true
**说明不是树层遍历(横向遍历),是树枝遍历(纵向遍历)
我在图中将used的变化用橘黄色标注上,可以看出在candidates[i] == candidates[i - 1]相同的情况下:
- used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
- used[i - 1] == false,说明同一树层candidates[i - 1]使用过
可能有的录友想,为什么 used[i - 1] == false 就是同一树层呢,因为同一树层,used[i - 1] == false 才能表示,当前取的 candidates[i] 是从 candidates[i - 1] 回溯而来的。
而 used[i - 1] == true,说明是进入下一层递归,去下一个数,所以是树枝上,如图所示:
代码
我的错误代码
class Solution {
List<Integer> path = new ArrayList<>();
List<List<Integer>> results = new ArrayList<>();
void backTracking(int[] candidates,int targetSum,int sum,int startIndex){
if (sum > targetSum){
return;
}
if (sum == targetSum){
results.add(new ArrayList<>(path));
return;
}
for (int i = startIndex;i < candidates.length;i++){
sum += candidates[i];
path.add(candidates[i]);
backTracking(candidates,targetSum,sum,i + 1);
path.remove(path.size() - 1);
sum -= candidates[i];
}
}
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
//int型数组去重操作
//定义一个Integer的集合类型【不定义的话,默认Object,后面没办法转换成int】
Set<Integer> set = new HashSet<>();
for (int i = 0;i < candidates.length;i++){
set.add(candidates[i]);
}
//括号中的内容不能少
Integer[] temp = set.toArray(new Integer[]{});
int[] newCandidates = new int[temp.length];
for (int i = 0;i < temp.length;i++){
newCandidates[i] = temp[i].intValue();
}
backTracking(newCandidates,target,0,0);
return results;
}
}
自己进行了一步去重操作,但是去重后,忽略了以下情况:
还是思考不全面
class Solution {
List<Integer> path = new ArrayList<>();
List<List<Integer>> results = new ArrayList<>();
boolean[] used;
void backTracking(int[] candidates,int targetSum,int sum,int startIndex,boolean[] used){
if (sum > targetSum){
return;
}
if (sum == targetSum){
results.add(new ArrayList<>(path));
return;
}
for (int i = startIndex;i < candidates.length;i++){
//需要排序,排序后看相邻两个元素是否相等,相等且遍历到后面的元素【体现在used[i - 1] = false】,说明之前已经遍历过了,跳过。【树层剪枝】
if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == false)
continue;
sum += candidates[i];
path.add(candidates[i]);
used[i] = true;
backTracking(candidates,targetSum,sum,i + 1,used);
used[i] = false;
path.remove(path.size() - 1);
sum -= candidates[i];
}
}
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
Arrays.sort(candidates);
used = new boolean[candidates.length];
for (int i = 0;i < candidates.length;i++){
used[i] = false;
}
backTracking(candidates,target,0,0,used);
return results;
}
}
报错修改时注意统一性,之前写错的相关代码都要改掉
总结
77.组合
-
题目特征
- 在大小为n的集合中取k个数【终止条件】
- 取k个数,for循环的控制条件
- 不重复使用数字
216.组合总和III
-
题目特征
-
在大小为9的集合中取k个数,使之相加为n
-
取k个数
- i <= 9 - (k - path.size()) + 1
-
不重复使用数字
-
39.组合总和
-
题目特征
- 从无重复元素的整数数组中取数,使之相加和为target
- 未限制取数的多少
- 可以被重复选取
40.组合总和II
-
题目特征
- 从有重复元素的整数数组中取数,使之相加和为target
- 未限制取数的多少
- 每个元素只能使用一次
- 示例:
输入: candidates = [10,1,2,7,6,1,5], target = 8,
输出:
[
[1,1,6],
[1,2,5],
[1,7],
[2,6]
]
-
本题比一般题目特殊很多,数组中有重复数字,如果按照常规的只只取一次:backtracking(candidates,targetSum,k,sum,i + 1)
- 这样会出现[1,7],[7,1]的情况
-
本题解题思路:
- 设置used数组,来记录每个元素是否使用过;
- 先在主函数中为数组排序
- if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == false)
- 在左右元素相等的情况下,如果前面的元素被使用过了,则说明是树枝遍历(纵向遍历),否则是横向遍历
- 核心代码:单层循环逻辑
17.电话号码的字母组合
- 应用题:拨开外表看本质