回溯算法的思想
回溯从本质上来说就是穷举法,为了解决一些单纯用嵌套循环解决不了的或者很难解决的题。比如,从0~n中找出长度为k的所有组合。(组合是不在意顺序的,即{1,2}={2,1})
如果k是2,则只需要两层循环就可以列出所有的可能了。但是如果k是50呢,难道要嵌套50层循环吗。如果是100呢,100000呢。显然是不现实的。
所以,我们引入了回溯的概念,内部实现就是在一层循环里嵌套一个递归来代替多层循环。
循环用来控制一层内的遍历,递归控制深度的遍历
回溯的代码模板
void backtracking(参数) { if (终止条件) { 存放结果; return; } for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) { 处理节点; backtracking(路径,选择列表); // 递归 回溯,撤销处理结果 } }
组合问题
根据回溯类问题的代码模板来写
用res来装最终结果,path装每次递归到递归边界得出的值。这两个要定义为全局变量,放在参数中会让整个代码的可读性变差
List<List<Integer>> res; List<Integer> path;
-
返回值和参数:
返回值一般都是void,参数包括n和k还有一个index。因为递归需要一个起始位置,所以需要一个index来记录,每次的深度加一就用index来记录。
public void backtracking(int n, int k, int index){...}
-
递归边界
当path的长度等于k的时候,递归结束,将path加入到res结果集中。
if(path.size() == k) { res.add(new ArrayList<>(path));//此处要new一个ArrayList放入结果集中,如果直接放path,放的是path的地址,后面的操作会让结果集中的数值发生变化 return ; }
-
递归体
用循环遍历一层,用递归遍历深度,用index控制递归起始位置
for(int i = index; i <= n; i++) { path.add(i); backtracking(n,k,i+1); path.remove(path.size() - 1); }
代码
class Solution { List<List<Integer>> res = new ArrayList<>(); List<Integer> path = new ArrayList<>(); public List<List<Integer>> combine(int n, int k) { backtracking(n,k,1); return res; } public void backtracking(int n, int k, int index){ //递归边界 if (path.size() == k) { res.add(new ArrayList<>(path)); return; } //循环控制一层内的遍历 for (int i = index; i <= n; i++) { //当前节点进入路径内 path.add(i); //递归控制深度遍历 backtracking(n,k,i+1); //回溯,让path回到进入本次递归前的状态,移除最后一个元素 path.remove(path.size()-1); } } }
剪枝优化
其实每次训话并不用都遍历到n采停止,比如上例中第一层的取4就没有必要,因为k为2,但是取4最后的path中只能有一个数,可以直接忽略不考虑。则我们只需在循环的结束控制语句做剪枝即可。
-
已经选择的元素个数:path.size();
-
还需要的元素个数为: k - path.size();
-
在集合n中至多要从该起始位置 : n - (k - path.size()) + 1,开始遍历
将n改为n - (k - path.size()) + 1即可
for (int i = index; i <= n - (k - path.size()) + 1; i++) { //当前节点进入路径内 path.add(i); //递归控制深度遍历 backtracking(n,k,i+1); //回溯,让path回到进入本次递归前的状态,移除最后一个元素 path.remove(path.size()-1); }
类似题目
本题其实就是组合问题的一种,只不过这种应用题在具体实现细节上可能回有些不知道怎么写的地方。
class Solution { List<String> res = new ArrayList<>(); StringBuilder path = new StringBuilder(); String[] letterMap = {//用来做数字到字母的映射 "", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz" }; public List<String> letterCombinations(String digits) { if(digits == null || digits.length() == 0) {//如果不判断可能会出现输入为"",输出为[""],但是输出应该是[] return res; } backtracking(digits,0); return res; } //index用来表明现在递归到digits中的哪个位置了 public void backtracking(String digits, int index) { if (path.length() == digits.length()) { res.add(new String(path)); return ; } int num = digits.charAt(index) - '0';//要把ASCII码转化为实际值的int数值 for (int i = 0; i <letterMap[num].length(); i++) { path.append(letterMap[num].charAt(i)); backtracking(digits, ++index); index--; path.deleteCharAt(path.length()-1); } } }
本题和39. 组合总和的区别就在于数据集中是有重复数字的,但是结果集中的结果是不能重复的,所以本题繁琐的地方就是怎么样才能去重。
例如:candidates = [10,1,2,7,6,1,5], target = 8
。如果结果集合不去重,得出的结果是
[[1,1,6],[1,2,5],[1,7],[1,2,5],[1,7],[2,6]]
,但是题目要求的结果应该是
[[1,1,6],[1,2,5],[1,7],[2,6]]
可以发现多出了`[1,2,5]`和`[1,7]`两个重复的集合。
那我们把candidates = [10,1,2,7,6,1,5]
中所有重复的元素去除,只留一个变成candidates = [10,2,7,6,1,5]
可以吗?
显然是不可以的,因为这样回把结果[1,1,6]
给排除掉。所以我们不能对数据集有什么改动。
那我们是应该对整个树的树枝去重还是树层去重呢?
应该是树层去重,树枝去重的话,会把[1,1,6]
中的两个1给去掉,从而丢掉一个path结果。
去重的一般步骤:
-
先对数据集排序
-
对
candidates[i] == candidates[i-1]
的数据,要跳过本次循环,不做操作。 -
避免树枝上的去重:定义
used
数组,让used[i-1]=false
才进行去重,used[i-1]=true
的,即已经被选进path中的,就不做去重
代码
class Solution { //需要避免结果集中有重复元素,需要对树层去重 //先排序,再去重 List<List<Integer>> res = new ArrayList<>(); List<Integer> path = new ArrayList<>(); boolean[] used; int sum = 0; public List<List<Integer>> combinationSum2(int[] candidates, int target) { used = new boolean[candidates.length]; Arrays.sort(candidates); backtracking(candidates,target,0); return res; } public void backtracking(int[] candidates, int target, int startIndex) { //剪枝 if(sum > target) { return; } if (sum == target) { res.add(new ArrayList<>(path)); return; } for (int i = startIndex; i < candidates.length; i++) { if (i > 0 && candidates[i] == candidates[i-1] && used[i-1] == false) continue; path.add(candidates[i]); sum+=candidates[i]; used[i] = true; backtracking(candidates, target, i+1); used[i] = false; sum-=candidates[i]; path.remove(path.size()-1); } } }
但其实,也可以不用used
数组,直接利用starIndex
避免树枝去重也可以
代码
class Solution { //需要避免结果集中有重复元素,需要对树层去重 //先排序,再去重 List<List<Integer>> res = new ArrayList<>(); List<Integer> path = new ArrayList<>(); int sum = 0; public List<List<Integer>> combinationSum2(int[] candidates, int target) { Arrays.sort(candidates); backtracking(candidates,target,0); return res; } public void backtracking(int[] candidates, int target, int startIndex) { //剪枝 if(sum > target) { return; } if (sum == target) { res.add(new ArrayList<>(path)); return; } for (int i = startIndex; i < candidates.length; i++) { if (i > startIndex && candidates[i] == candidates[i-1]) continue; path.add(candidates[i]); sum+=candidates[i]; backtracking(candidates, target, i+1); sum-=candidates[i]; path.remove(path.size()-1); } } }