回溯法其实就是暴力查找,想效率高点就配上剪枝;回溯法解决的问题都可以抽象成树形结构。
回溯法,一般可以解决如下几种问题:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 棋盘问题:N皇后,解数独等等
另外,组合是不强调元素顺序的,排列是强调元素顺序。
例如:{1, 2} 和 {2, 1} 在组合上,就是一个集合,因为不强调顺序,而要是排列的话,{1, 2} 和 {2, 1} 就是两个集合了。组合无序,排列有序
回溯三部曲:
- 回溯函数模板返回值以及参数
- 回溯函数终止条件
- 回溯搜索的遍历过程
回溯算法模板框架如下:
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
77. 组合
组合问题:要理解横向遍历——for循环,纵向遍历——递归
组合问题不同于全排列,需要去掉一些重复的组合,此时需要用到startIndex指针来表示,每一次集合收缩后的起始值。
将问题抽象成树形结构
每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围。
图中可以发现n相当于树的宽度,k相当于树的深度。
图中每次搜索到了叶子节点,我们就找到了一个结果。相当于只需要把达到叶子节点的结果收集起来,就可以求得 n个数中k个数的组合集合。
开始回溯:
- 明确返回值和参数:
设置两个全局变量保存单次结果,和结果集;故无返回值。参数:n,k,startIndex
- 明确终止条件:
叶子节点:单次结果的大小==k的大小,保存此时的结果到结果集
- 明确单次回溯逻辑:
for循环进行横向遍历,循环中嵌套递归进行纵向遍历,递归结束后回溯
剪枝优化:
对于每一次的for循环,如果收缩后的集合大小比k小,则不应该继续递归下去,进行剪枝。
接下来看一下优化过程如下:
-
已经选择的元素个数:path.size();
-
还需要的元素个数为: k - path.size();
-
在集合n中至多要从该起始位置 : n - (k - path.size()) + 1,开始遍历
例如:n = 4,k = 3
目前已经选取的元素为0(path.size为0),n - (k - 0) + 1 即 4 - ( 3 - 0) + 1 = 2。
截止到从2开始搜索都是合理的,可以是组合[2, 3, 4]。对应第二层
目前已经选取的元素为1(path.size为1),n - (k - 1) + 1 即 4 - ( 3 - 1) + 1 = 3。
截止到从3开始的搜索都是合理的,可以是组合[1, 3, 4]或[2, 3, 4]。 对应第三层
目前已经选取的元素为2(path.size为2),n - (k - 2) + 1 即 4 - ( 3 - 2) + 1 = 4。
截止到从4开始的搜索都是合理的,可以是组合[1, 2, 4]或[1, 3, 4]或是[2, 3, 4]。 对应第四层
但是再往后的for循环就没必要了
216.组合总和III
是77.组合题的变型,比较常规。
需要注意的地方:有两处剪枝操作。
17.电话号码的字母组合
思路不难想。问题在于字符串下标的获取,数值与字符串的对应
需要注意的有三点:
1、java字符串相关的函数方法不熟悉,总是出现细节上的问题。
2、回溯中的横向循环和纵向递归的起始条件
通过设置index指针来遍历digits中的单个字符(纵向递归)
for循环起始值与之前题目不同,起始值为0,每次for循环的字符串不同,不需要考虑重复问题。有点类似于全排列。(横向循环)
3、回溯的终止条件
39. 组合总和
思路不难想,自己想出来代码也能一刷写出来,问题在于剪枝优化。
重点看一下如何剪枝优化。
先对总集合排序。
Arrays.sort(candidates); // 先进行排序
排序后,如果下一层的sum(就是本层的 sum + candidates[i])已经大于target,就可以结束本轮for循环的遍历。
for (int i = idx; i < candidates.length; i++) {
// 如果 sum + candidates[i] > target 就终止遍历
if (sum + candidates[i] > target) break;
path.add(candidates[i]);
backtracking(res, path, candidates, target, sum + candidates[i], i);
path.remove(path.size() - 1); // 回溯,移除路径 path 最后一个元素
}
40.组合总和II
与上一题的区别是:
1、本题candidates中的每个数字在每个组合中只能使用一次。
2、本题数组candidates的元素是有重复的,而39.组合总和 (opens new window)是无重复元素的数组candidates
集合(数组candidates)有重复元素,但结果不能有重复的组合。
所以要去重。去重,就是使用过的元素不能重复选取
组合问题可以抽象为树形结构,那么“使用过”在这个树形结构上是有两个维度的。
一个维度是同一树枝上使用过,一个维度是同一树层上使用过。
没有理解这两个层面上的“使用过” 是造成大家没有彻底理解去重的根本原因。
题目中元素在同一个组合内是可以重复的,怎么重复都没事,但两个组合不能相同。
所以我们要去重的是同一树层上的“使用过”,同一树枝上的都是一个组合里的元素,不用去重。
代码实现:
没有使用used数组,先排序后根据startIndex指针实现了for循环去重。
分割
切割问题类似组合问题。
例如对于字符串abcdef:
- 组合问题:选取一个a之后,在bcdef中再去选取第二个,选取b之后在cdef中再选取第三个.....。
- 切割问题:切割一个a之后,在bcdef中再去切割第二段,切割b之后在cdef中再切割第三段.....。
分割问题同样可以抽象成一个树形结构:
131.分割回文串
优化:没看。优化放在了如何判断回文字符串上。
93.复原IP地址
思路有些复杂。
1、明确参数和返回值
参数:字符串,startIndex,pointNum(记录逗号的个数) 无返回值
2、明确终止条件
首先pointNum==3
然后判断剩余部分是否合法。如果合法,将结果存入结果集;最后return
3、明确单次回溯逻辑
经典的 for (int i = startIndex; i < s.size(); i++)
字符串截取[startIndex,i]的字符,判断是否合法。
如果合法,添加逗号且pointNum+1,递归。回溯,删去逗号且pointNum-1。
递归时:startIndex+2,因为有逗号的存在
如果不合法,break跳出循环。(这里需要注意一下,为什么不是continue)
判断是否合法有4种情况:
1、区间不合法 2、不能有非数字 3、开头不能是0但单独一个数可以为0 4、不能大于225