回溯是递归的副产品,只要有递归,就会有对应的回溯过程。
回溯实际上就是“撤销上一次递归操作”的一个过程。
回溯法是由递归+循环组成的,其中每次循环执行的次数应该是可知的。
每一次完成递归都会收集一次可能的结果,因此结果集的大小是不确定的,需要使用递归去找,我们称之为纵向搜索;
而每次循环会从待找集合中依次遍历,是一个横向搜索的过程。
模板
void backtracking(参数){
if(终止条件){
收集结果
return;
}
//单层搜索,横向遍历
for(集合){
处理节点;
//纵向遍历
backtracking();
回溯(撤销)
}
}
回溯三部曲
- 确定递归函数参数和返回值
- 确定终止条件
- 确定单层搜索(递归)的逻辑
每一个回溯算法都可以抽象为N叉树。画图可以更清晰的理解算法过程。
组合问题
lc77.
给定两个整数 n
和 k
,返回范围 [1, n]
中所有可能的 k
个数的组合。
你可以按 任何顺序 返回答案。
示例 1:
输入:n = 4, k = 2
输出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]
示例 2:
输入:n = 1, k = 1
输出:
[
[1]
]
提示:
1 <= n <= 20
1 <= k <= n
- 确定递归函数参数和返回值。 递归函数参数一般包含待遍历集合、起始搜索下标。返回值一般为
void
。本题中,待遍历集合则为[1,...,n]
,由于它是一个连续的自然数列,我们传入n即可,通过for循环就可以遍历了。同时,还需要传入组合大小k。 - 确定终止条件。注意,终止条件并不是整个搜索的终止条件,而是每一次纵向遍历的终止条件,也就是这条搜索路径的叶子结点。满足这个条件时,就表明该条路径搜索结束了,应该要判断是否需要收集结果了。本题中,如果当前组合中的数的已经达到k个,那么就应该终止。单次收集结果放入一维数组中,收集的结果应该加入二维数组中。为了方便,一般使用集合类。本题中使用
List
。 - 确定单层搜索逻辑。此过程也就是for函数的内容。一般而言,单层搜索逻辑为处理节点-递归-撤销处理(回溯)三个步骤。本题中,处理节点即为向组合中添加当前节点,撤销处理也就是移出最新添加的节点。
public static List<List<Integer>> combine(int n, int k) {
LinkedList<Integer> path = new LinkedList<>();
List<List<Integer>> result = new ArrayList<>();
backtrackingCombine(path,result,n,k,1);
return result;
}
static void backtrackingCombine(LinkedList<Integer> path,List<List<Integer>> result,int n,int k,int startIndex){
//终止条件
if(path.size()==k){
//收集结果
result.add(new LinkedList<>(path));
return;
}
for(int i = startIndex;i<=n;i++){
path.add(i);//处理结点
backtrackingCombine(path,result,n,k,i+1);//递归到下一层
path.removeLast();//回溯,撤销处理
}
}
本题中需要关注的点有:
- 初始时,startIndex从1开始;
- 收集结果时,不能直接把path加入到结果集中,因为这么做只会将path的浅拷贝加入集合中。我们此时需要进行深拷贝。
- 回溯时,注意撤销的是path的最后一个结点。由于java的ArrayList不提供直接删除最后一个结点的方式,虽然也可以使用
remove(path.size()-1)
的方式删除,并且本题中是完全合法的,但是由于remove重载了remove(Object o)
和remove(int index)
两种实现,在List集合元素是Integer
类型的情况下,也许某种情况下会出现歧义。因此选用LinkedList
。
组合剪枝
在上面的代码中,我们会发现以下情况是不必要考虑的:
- 第一次回溯时,for循环来到i = 2及之后时;
- 第二次回溯时,for循环来到i = 3及之后时;
- 第三次回溯时,for循环来到i = 4及之后时。
因为这种情况下,已选元素加上所有剩余可选元素也达不到要求的元素个数。此时我们就应该舍弃掉这种可能。这种方式称为剪枝。
我们会发现,在组合问题中,剪枝通常发生在横向遍历中,即使用for循环遍历剩余可选结果集这个过程中。因此,我们只需要在for循环的终止条件上进行改动即可。
假设需要选的元素个数为 k k k,总共有 n n n个元素,已选的元素个数为 x x x,则至少还需要 k − x k-x k−x个元素。为了后面能选上 k − x k-x k−x个元素,for循环的i最多能取到 n − ( k − x ) + 1 n-(k-x)+1 n−(k−x)+1.
- 为什么要用n减?以第一次选数为例,当你选择1时,后续还能选到2、3、4;当你选择2时,后面只能选到3、4.
- 为什么要+1?因为本题中横向搜索域是左闭的,统计选取个数的时候需要包括起始位置。看下面的图就会很清楚:
当 n = 4 , k = 4 , x = 1 n=4,k=4,x=1 n=4,k=4,x=1时,还需要选取3个元素。从4往前数3个数(包括4)就是4-2+1=2了。
代码实现也很简单,x其实也就是是path.size()。改变for循环中的循环终止条件即可。如下:
if(path.size()==k){
//收集结果
result.add(new LinkedList<>(path));
return;
}
for(int i = startIndex;i<=n-(k-path.size())+1;i++){
path.add(i);//处理结点
backtrackingCombine(path,result,n,k,i+1);//递归到下一层
path.removeLast();//回溯,撤销处理
}