回溯其实在二叉树部分就已经有用到了,其与递归是相伴相生的。有递归一定就会有回溯。
回溯其实本质也是暴力搜索,更极端一些可以理解为通过变量的更改去达到与循环同样的效果。
回溯一般解决的问题:
1、组合问题
2、字符串切割问题
3、子集问题
4、排列问题
笔记也是按照这个顺序来进行记录,以方便自己回顾。
目录
一、组合问题
组合问题算是回溯算法的敲门砖。
1.1、组合
求k个数的组合,其实也可以用两层循环去穷举,将所有组合列出来。
要使用回溯的话,就需要用递归去穷举所有结果,因此需要一个path路径来记录经过的路径,每经过一个数字,path就进行收集,最后收集叶子结点的结果。
回溯过程就去掉path的末尾元素即可,这个跟树的回溯收集路径其实是一样的。
用图解释就非常清晰
横向使用循环遍历所有数字,纵向递归收集路径。
每次纵向递归,都需要去掉一个数字,因此需要使用一个变量start来记录每次递归时,遍历集合的起始位置。
递归的终止条件就是当path的长度为k时,说明要收集了。
代码如下
class Solution {
List<List<Integer>> res = new ArrayList();
public List<List<Integer>> combine(int n, int k) {
ArrayList<Integer> path = new ArrayList();
backtracking(n, k, path, 1);
return res;
}
public void backtracking(int n, int k, ArrayList<Integer> path, int start) {
// 长度为k就收集
if (path.size() == k) {
res.add(new ArrayList(path));
return;
}
// 遍历n个数
for (int i = start; i <= n; i++) {
path.add(i); // 添加
backtracking(n, k, path, i + 1);
path.remove(path.size() - 1); // 回溯
}
}
}
path作为参数或者类变量都是可以的,目的都是收集路径。
这份代码其实可以进行优化,即对搜索的路径进行剪枝。
可以发现,当集合中剩余的元素个数已经不满足所需要的元素个数时,后面数字就没有必要再遍历下去了。
那么可以在横向遍历时再加一个判断,看看集合剩余的元素个数是否还满足条件,若已经不满足则直接退出循环。
如果放在循环里作为进入循环的条件,则要给i设定条件
// k - path.size() 为还需要几个元素
// n - (k - path.size()) + 1 是i的右边界 是后面至少有k - path.size()个数字的起始位置
i <= n - (k - path.size()) + 1
如果n=4,k=3,那么i <= 2,除了都含有1的集合,最后还剩个[2,3,4],是满足条件的。
代码如下
class Solution {
List<List<Integer>> res = new ArrayList();
public List<List<Integer>> combine(int n, int k) {
ArrayList<Integer> path = new ArrayList();
backtracking(n, k, path, 1);
return res;
}
public void backtracking(int n, int k, ArrayList<Integer> path, int start) {
if (path.size() == k) {
res.add(new ArrayList(path));
return;
}
// 剪枝优化 同一树层判断剩余的数字是否还足够 不足够则不继续迭代
// n - (k - path.size()) + 1 是i的右边界 是后面至少有k - path.size()个数字的起始位置
for (int i = start; i <= n - (k - path.size()) + 1; i++) {
path.add(i);
backtracking(n, k, path, i + 1);
path.remove(path.size() - 1);
}
}
}
1.2、组合总和问题
组合总和也是组合问题的一种,不过不是穷举所有组合,而是找和为给定目标值的所有组合。
可以分为三种类型:
1、集合元素不重复,每个元素只能取一次。
2、集合元素不重复,每个元素可以重复取。
3、集合元素有重复,每个元素只能取一次。
接下来对每一种类型都以一道例题来详细记录。
1.2.1、组合总和III
对应题目216. 组合总和 III - 力扣(LeetCode)
元素不重复,只能取一次,和LC_77组合问题那道题是一样的,那么就只有收集条件不同,即找到了和为target的路径,才收集。
所以思路很清晰了,维护一个全局sum遍历,一起回溯,如果sum == target,那么就收集路径就完事。
或者直接传target,然后判断 target == 0也是可以的。
剪枝也是一样的思路。
代码如下
class Solution {
List<List<Integer>> res = new ArrayList();
public List<List<Integer>> combinationSum3(int k, int n) {
ArrayList<Integer> path = new ArrayList();
backtracking(k, n, path, 0, 1);
return res;
}
public void backtracking(int k, int n, ArrayList<Integer> path, int sum, int start) {
if (path.size() == k) { // 满k个就判断
if (sum == n) res.add(new ArrayList(path));
return;
}
for (int i = start; i <= 9 - (k - path.size()) + 1; i++) {
path.add(i);
backtracking(k, n, path, sum + i, i + 1);
path.remove(path.size() - 1);
}
}
}
1.2.2、组合总和
这道题是可以重复选取数字,那么就要考虑怎么样搜索才能选取重复数字。
根据组合总和III的代码逻辑,每次递归传入的参数start = i + 1,即下一次递归会去掉一个元素,那么如果不去掉元素,是不是就可以重复选取了。
递归终止条件有两种情况
1、看总和是否等于target就可以了,不能用path的长度取判断,因为不确定。
2、看总和是否大于target,大于就直接返回,这也包含了剪枝在里面。
整体代码如下
class Solution {
List<List<Integer>> res = new ArrayList();
public List<List<Integer>> combinationSum(int[] candidates, int target) {
ArrayList<Integer> path = new ArrayList();
backtracking(candidates, target, 0, path, 0);
return res;
}
public void backtracking(int[] candidates, int target, int start, ArrayList<Integer> path, int sum) {
if (sum == target) {
res.add(new ArrayList(path));
return;
}else if (sum > target) return; // 剪枝 + 终止条件
for (int i = start; i < candidates.length; i++) {
path.add(candidates[i]);
// i不用加1 可以重复取
backtracking(candidates, target, i, path, sum + candidates[i]);
path.remove(path.size() - 1);
}
}
}
1.2.3、组合总和II
对应题目40. 组合总和 II - 力扣(LeetCode)
这道题是有重复数字,每个数字只能取一次,那么就要考虑去重问题,因为前一个数字的递归收集的结果会包含后面相同的数字递归收集的结果。
那么因此相同数字不需要再收集第二次。
那么也就是说在同一个循环里,或者说同一个树层,同一个数字不处理第二次。
思路就是对数组进行排序,在同一树层一旦发现当前数字与上一个数字是重复的,那么就直接跳过该数字,即不作第二次递归收集。
在同一树枝上是可以重复选取相同数字的,即纵向可重复选取,横向不能重复选取。
这也是卡哥提到的“树层去重”。
注意,对树层去重一定要先对集合进行排序!平常写代码很容易就漏了这一点。
思路是有了,那么代码如何去实现?
有两种实现树层去重的方式:
1、一种是使用布尔类型的used数组,标记使用过数字,并且与path一起回溯,那么去重的逻辑就是
// candidates[i] == candidates[i - 1]说明该元素与前一个元素相同
// used[i - 1] == false 说明前一个数字没有使用过
// candidates[i] == candidates[i - 1] && used[i - 1] == false 说明同一树层上有两个重复元素被选取,直接跳过
// candidates[i] == candidates[i - 1] && used[i - 1] == true 说明同一树枝上有两个重复元素被选取组合,符合条件
if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == false) continue;
使用used数组去重的过程如下
2、同一树枝的数字可重复选取,同一树层的数字不可重复选取,那么当遇到相同数字时,只要判断是否是同一树层的就行,若同一树层则直接跳过。
// i != start 表示不在同一树枝 即在同一树层
// candidates[i] == candidates[i - 1]表示相邻两数相同
// 都符合 就要去重 直接跳过该数字的选择
if (i > 0 && i != start && candidates[i] == candidates[i - 1]) continue;
第二种实现较为简单,但第一种实现比较容易理解。
有一个注意的点,这里是求组合不考虑结果的顺序,所以可以进行排序,如果是后面求排列,那么就无法对数组进行排序,也就不能使用这两种去重手段了,这时候就只能考虑用set直接去重或者用每一层的map做映射标记。
其他逻辑与组合总和III基本一致。
通过used数组来进行去重的代码如下
class Solution {
List<List<Integer>> res = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
// 先排序 可以在后面优化剪枝
Arrays.sort(candidates);
// used 用来判断元素是否被使用
boolean[] used = new boolean[candidates.length];
// backtracking(candidates, target, 0, 0, used);
backtracking1(candidates, target, 0, 0);
return res;
}
// 将sum作为参数 包含回溯过程
public void backtracking(int[] candidates, int target, int startIndex, int sum, boolean[] used) {
//if (sum > target) return;
if (sum == target) {
res.add(new ArrayList<>(path));
return;
}
// 同一树层选取需要去重 同一树枝组合不需要去重
for (int i = startIndex; i < candidates.length; i++) {
// 优化剪枝 如果已经大于target了 那就不用遍历后面的元素了 因为数组有序
if (sum > target) break;
// candidates[i] == candidates[i - 1]说明该元素与前一个元素相同
// used[i - 1] == false 说明当前数字没有使用过
// candidates[i] == candidates[i - 1] && used[i - 1] == false 说明同一树层上有两个重复元素被选取,直接跳过
// candidates[i] == candidates[i - 1] && used[i - 1] == true 说明同一树枝上有两个重复元素被选取组合,符合条件
if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == false) continue;
int val = candidates[i];
path.add(val);
used[i] = true; // 当前元素被取用 设定为true
backtracking(candidates, target, i + 1, sum + val, used);
path.removeLast();
used[i] = false; // 回溯
}
}
直接判断是否位于同一树层来去重的代码如下
class Solution {
List<List<Integer>> res = new ArrayList();
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
// 含有重复数字的组合
// 对数组排序后 在树层进行去重
Arrays.sort(candidates);
ArrayList<Integer> path = new ArrayList();
backtracking(candidates, target, 0, path, 0);
return res;
}
public void backtracking(int[] candidates, int target, int start, ArrayList<Integer> path, int sum) {
if (sum == target) {
res.add(new ArrayList(path));
return;
}
for (int i = start; i < candidates.length; i++) {
// 剪枝
if (sum > target) break;
// 去重逻辑
// i != start 表示不在同一树枝 即在同一树层
// candidates[i] == candidates[i - 1]表示相邻两数相同
// 都符合 就要去重 直接跳过该数字的选择
if (i > 0 && i != start && candidates[i] == candidates[i - 1]) continue;
path.add(candidates[i]);
// i不用加1 可以重复取
backtracking(candidates, target, i + 1, path, sum + candidates[i]);
path.remove(path.size() - 1);
}
}
}
1.3、多集合组合问题
对应题目17. 电话号码的字母组合 - 力扣(LeetCode)
这道题抽象出来就是一个数字一个集合,每一个集合是一个字符串。
两个字符串可以用两层循环暴力搜,n个字符串就得用n层循环,显然不实际。
那么回溯也是爆搜,考虑用回溯。
做法是,使用递归遍历多个集合,使用循环遍历单个集合。
递归遍历多个集合的做法是在每次进入循环前都获取一个新的集合。
循环遍历单个集合与单集合的回溯思路一致。
那么就可以发现,唯一的不同点就是每次进入循环前都获取一个新的集合。
可以使用map来对数字和字符串进行映射,便于后面获取。
递归停止条件:当path的长度等于数字的个数,就收集结果
代码如下
class Solution {
String[] map = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};
List<String> res = new ArrayList();
public List<String> letterCombinations(String digits) {
if ("".equals(digits) || digits == null) return res;
StringBuilder path = new StringBuilder();
backtracking(digits, 0, path);
return res;
}
public void backtracking(String digits, int start, StringBuilder path) {
if (path.length() == digits.length()) {
res.add(path.toString());
return;
}
// 每次递归获取新的字符串
// start参数相当于控制外层循环,即遍历digits
String s = map[digits.charAt(start) - '0'];
// 这层相当于内层循环 即遍历每一个字符串
for (int i = 0; i < s.length(); i++) {
path.append(s.charAt(i));
backtracking(digits, start + 1, path);
path.deleteCharAt(path.length() - 1);
}
}
}
二、切割问题
切割字符串问题,可以双层循环爆搜所有子串,获取分割方案。
用回溯的话,就一个难点,就是如何在递归中获取子串,换句话说,如何模拟切割子串的过程,这一步是最重要的。
那怎么模拟切割,获取子串?可以使用递归的参数start,每次纵向递归并进入循环时,i=start,即在同一树枝,i是一定等于start的,那么在同一树层,i >= start,以start为起点,i为终点,就可以切割获取子串。
到这里其实就会发现,每一次处理都是处理一个子串。
之前的组合问题,每一个节点都是处理一个数,而这里是处理一个子串。
但本质都是组合问题。
那么做法就是,每次循环,或者说每个节点切割子串,并判断子串是否是回文串,若是才添加入path,否则就直接跳过。
当start作为切割起点已经大于等于字符串长度时,说明可以切割到字符串末尾,那么path就是一个切割方案,收集。
代码如下
class Solution {
List<List<String>> res = new ArrayList();
public List<List<String>> partition(String s) {
ArrayList<String> path = new ArrayList();
backtracking(s, path, 0);
return res;
}
public void backtracking(String s, ArrayList<String> path, int start) {
if (start >= s.length()) { // 切割起点到了字符串末尾 收集
res.add(new ArrayList(path));
return;
}
// start为切割起点 i为切割终点 递归start+1起点后移 for循环i后移 水平切割
for (int i = start; i < s.length(); i++) {
if (!isPalindrome(s, start, i + 1)) continue; // 不是回文 直接跳过
String str = s.substring(start, i + 1); // 水平截取子字符串
path.add(str); // 是回文 直接将该子串添加入path
backtracking(s, path, i + 1);
path.remove(path.size() - 1);
}
}
public boolean isPalindrome(String s, int start, int end) {
while (start < end) {
if (s.charAt(start++) != s.charAt(end-- - 1)) return false;
}
return true;
}
}
三、子集问题
子集问题与组合、排列问题的区别就在于,子集问题是收集所有节点,而组合、排列是收集叶子结点。
3.1、子集
子集问题需要收集所有节点,因为每一个节点都是一个独立的结果。
那么对比组合问题,只需要在加入path的同时也收集path即可。
一旦start大于等于集合长度,就返回。
代码如下
class Solution {
List<List<Integer>> res = new ArrayList();
public List<List<Integer>> subsets(int[] nums) {
res.add(new ArrayList()); // 空集
ArrayList<Integer> path = new ArrayList();
backtracking(nums, path, 0);
return res;
}
public void backtracking(int[] nums, ArrayList<Integer> path, int start) {
if (start >= nums.length) return;
for (int i = start; i < nums.length; i++) {
path.add(nums[i]);
res.add(new ArrayList(path)); // 收集所有节点的结果
backtracking(nums, path, i + 1);
path.remove(path.size() - 1);
}
}
}
3.2、子集II
这道题在LC_78子集的基础上多了重复的数字,那么就涉及到去重问题。
那么子集的去重与组合问题的去重方法是一致的,可以使用排序 + used数组的方式,也可以直接判断是否是同一树层来去重。
代码如下
class Solution {
List<List<Integer>> res = new ArrayList();
public List<List<Integer>> subsetsWithDup(int[] nums) {
res.add(new ArrayList()); // 空集
ArrayList<Integer> path = new ArrayList();
Arrays.sort(nums); // 要记得一定要排序
backtracking(nums, path, 0);
return res;
}
public void backtracking(int[] nums, ArrayList<Integer> path, int start) {
if (start >= nums.length) return;
// 树层去重
for (int i = start; i < nums.length; i++) {
if (i > start && nums[i] == nums[i - 1]) continue; // 树层去重
path.add(nums[i]);
res.add(new ArrayList(path)); // 收集所有节点的结果
backtracking(nums, path, i + 1);
path.remove(path.size() - 1);
}
}
}
3.3、递增子序列
这道题也是求子集的问题,但是不同的是需要递增的子集,即递增序列。
并且有重复的数字,涉及去重问题。
但需要递增序列,所有不能对数组进行排序,意味着在组合问题使用的去重手段,这里都不能用。
那么要在树层进行去重,就只能使用set结构或者map映射来进行去重。
使用set去重就不说了,主要记录使用map来标记每层使用过的数字的去重方法。
做法就是用map记录每一层使用过的数字,一旦使用过了,就直接跳过。
map的实现结构可以使用hashmap,也可以直接使用used数组结构,因为 -100 < nums[i] < 100,
可以直接使用数值作为地址进行映射。
如何做到map只记录每一层使用过的数字?只要每一次递归都重置一次map,就可以做到一个map记录一层。
这里注意,由于map每一层都会重置,所以不需要进行回溯,即回退过程。
去重问题解决了,这道题就非常简单了,一旦当前数字小于path路径最后一个数字,或者当前数字使用过,那么就直接跳过。
由于序列长度至少要为2,那么当path的长度大于1时,才进行收集。
代码如下
class Solution {
List<List<Integer>> res = new ArrayList();
public List<List<Integer>> findSubsequences(int[] nums) {
ArrayList<Integer> path = new ArrayList();
backtracking(nums, path, 0);
return res;
}
public void backtracking(int[] nums, ArrayList<Integer> path, int start) {
// 长度大于1 就收集
// 不能return 因为要收集所有节点
if (path.size() > 1) res.add(new ArrayList(path));
// 有个坑 不能使用i > start 进行去重 因为不能排序
// used定义在这里是负责每一树层的去重,不作为参数传递,因为作为参数时是负责整棵树的去重
// 每一层进入used都会清空 即重置
int[] used = new int[201];
for (int i = start; i < nums.length; i++) {
// 两种情况应该跳过
// 一种是同一在树枝上 当前数字小于路径最后一个数字 不能放
// 一种是树层去重 发现已经用过相同的数字 那么直接跳过
if (!path.isEmpty() && nums[i] < path.get(path.size() - 1) || used[nums[i] + 100] == 1) {
continue;
}
path.add(nums[i]);
used[nums[i] + 100] = 1; // used数组不用回退 因为新的一层会重置
backtracking(nums, path, i + 1);
path.remove(path.size() - 1);
}
}
}
四、排列问题
4.1、排列
排列问题与组合问题的相同点是,都是在叶子结点收集;不同点是排列问题的路径长度一定是集合长度,而组合问题不一定。
做过组合问题以及对树形的搜索过程比较熟悉后,做排列问题就比较有思路了。
每条路径都要选取所有元素,并且每一层都不能有相同的元素。
那么想法就是每一层都遍历所有元素,但不遍历同一树枝上已经收集过的元素。
这就转化成“树枝去重问题”。
树枝去重就只能用used数组的方式,记录整棵树的数值是否重复出现,并且与path一起进行回溯。
不能使用直接判断是否是同一树枝或者同一树层的方式,因为每一层循环都是从0开始了,不再需要start参数,这也是排列问题与组合、子集问题的递归参数的不同之处。
使用used数组进行树枝去重的过程如下
代码如下,记得一定要对数组排序。
class Solution {
List<List<Integer>> res = new ArrayList();
int[] used; // used要么作为类变量 要么作为参数传递 负责整棵树的记录
public List<List<Integer>> permute(int[] nums) {
ArrayList<Integer> path = new ArrayList();
used = new int[nums.length];
backtracking(nums, path);
return res;
}
public void backtracking(int[] nums, ArrayList<Integer> path) {
if (path.size() == nums.length) {
res.add(new ArrayList(path));
return;
}
// 每次从0开始 因为要考虑顺序
for (int i = 0; i < nums.length; i++) {
// 同一树枝取过该数 则不再取
if (used[i] == 1) continue;
path.add(nums[i]);
used[i] = 1; // 记录使用的数字
backtracking(nums, path);
path.remove(path.size() - 1);
used[i] = 0; // 回溯
}
}
}
4.2、排列II
这道题比LC_46排列多了重复数字,还是涉及去重问题。
同一树枝不能重复选取,同一树层也不能重复选取。
所以是树枝去重 + 树层去重。
used数组既可以进行树枝去重,也可以进行树层去重。
树枝去重在排列里用到,树层去重在组合问题里有用到。
两种去重方式满足一种,就跳过当前数字。
代码如下
class Solution {
List<List<Integer>> res = new ArrayList();
public List<List<Integer>> permuteUnique(int[] nums) {
ArrayList<Integer> path = new ArrayList();
int[] used = new int[nums.length];
Arrays.sort(nums); // 一定要排序
backtracking(nums, path, used);
return res;
}
public void backtracking(int[] nums, ArrayList<Integer> path, int[] used) {
if (path.size() == nums.length) {
res.add(new ArrayList(path));
return;
}
for (int i = 0; i < nums.length; i++) {
// 两种跳过的情况
// 一种是同一树枝 选过的数字不能再选 used[i] == 1
// 另一种是同一树层去重 前一个数字与当前数字相同 且used[i - 1] == 0
// 前一个数字与当前数字相同 但used[i - 1] == 1 表示同一树枝选取重复数字 可以选
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == 0 || used[i] == 1) continue;
path.add(nums[i]);
used[i] = 1;
backtracking(nums, path, used);
path.remove(path.size() - 1);
used[i] = 0;
}
}
}