leetcode-回溯总结
回溯算法能解决如下问题:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 棋盘问题:N皇后,解数独等等
如何理解回溯法
回溯法解决的问题都可以抽象为树形结构,是的,所有回溯法的问题都可以抽象为树形结构!
因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度,都构成的树的深度。
递归就要有终止条件,所以必然是一颗高度有限的树(N叉树)。
回溯法模板
回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度。
如图:
从图中看出for循环可以理解是横向遍历,backtracking(递归)就是纵向遍历,这样就把这棵树全遍历完了,一般来说,搜索叶子节点就是找的其中一个结果了。
回溯算法模板框架如下:
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
求组合问题
组合是不强调元素顺序的,排列是强调元素顺序。
例如:{1, 2} 和 {2, 1} 在组合上,就是一个集合,因为不强调顺序,而要是排列的话,{1, 2} 和 {2, 1} 就是两个集合了。
剪枝优化
在一些题目中是结果是对个数有要求的,比如:给定两个整数 n 和 k,返回 1 ... n 中所有可能的 k 个数的组合。在这种情况下:如果for循环选择的起始位置之后的元素个数 已经不足 我们需要的元素个数了,那么就没有必要搜索了。
优化之后的for循环是:
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) // i为本次搜索的起始位置
还有一些题目对元素总和有限制,比如:找出所有相加之和为 n 的 k 个数的组合。组合中只允许含有 1 - 9 的正整数,并且每种组合中不存在重复的数字。在这种情况下:已选元素总和如果已经大于n(题中要求的和)了,那么往后遍历就没有意义了,直接剪掉。
在求和问题中,排序之后加剪枝是常见的套路!
对于组合问题,什么时候需要startIndex呢?
- 如果是一个集合来求组合的话,就需要startIndex,例如:回溯算法:求组合问题! (opens new window),回溯算法:求组合总和! (opens new window)。
- 如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex,例如:回溯算法:电话号码的字母组合
- 排列问题一般不需要startIndex
去重
拿40.组合总和2举例:
给定一个数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的每个数字在每个组合中只能使用一次。
题解代码如下:
private List<List<Integer> > res;
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
res = new LinkedList<>();
Arrays.sort(candidates);
LinkedList<Integer> track = new LinkedList<>();
// 这里的used数组used[i]表示在已经选择的一条路径里(纵向的路径),candidates[i]是否被选择了
boolean[] used = new boolean[candidates.length];
backTrack(candidates,target,0,0,track,used);
return res;
}
private void backTrack(int[] candidates,int target,int sum,int startIndex,LinkedList<Integer> track,boolean[] used) {
if (sum == target) {
res.add(new LinkedList<>(track));
return;
}
for (int i = startIndex;i<candidates.length;i++) {
// 假设现在排序后的candidates为[1,1,2,5,6,7,10]
// 出现重复节点,同层的第一个节点已经被访问过,所以直接跳过,这里的used[i-1]为false说明在上一层里的选择节点(纵向的路径)里没有选择candidates[i-1]
// 但是在上一层肯定已经对candidates[i-1]做了选择了(第一个1),然后又遍历了i+1之后的节点(2)作为下一层的选择节点,(1,2),所以此时used[i-1]为false,如果再选择candidates[i](第二个1)
// 就会重复在后边继续选择下一层可选择的节点(2),这样就会出现重复(1,2)
// 而此时如果used[i-1]==true时,说明上一层节点选择了第一个1,此时是可以选择第二个1的,这样就会有[1,1,6]这样的选择结果
if (i > 0 && candidates[i] == candidates[i-1] && used[i-1] == false) {
continue;
}
if (sum + candidates[i] > target) {
break;
}
track.add(candidates[i]);
// 置used[i]为true,表示在已选择的路径下包含了candidates[i]节点
used[i] = true;
backTrack(candidates,target,sum+candidates[i],i+1,track,used);
used[i] = false;
track.removeLast();
}
}
如代码注释那样:
假设现在排序后的candidates为[1,1,2,5,6,7,10]
这里的used[i-1]
为false说明在上一层里的选择节点(纵向的路径)里没有选择candidates[i-1]
但是在上一层肯定已经对candidates[i-1]做了选择了(第一个1),然后又遍历了i+1之后的节点(2)作为下一层的选择节点,(1,2),所以此时used[i-1]为false,如果再选择candidates[i] (第二个1),就会重复在后边继续选择下一层可选择的节点(2),这样就会出现重复(1,2),而此时如果used[i-1]==true时,说明上一层节点选择了第一个1,此时是可以选择第二个1的,这样就会有[1,1,6]这样的选择结果,这样是符合的
切割问题
比如:分割回文子串:
给你一个字符串
s
,请你将s
分割成一些子串,使每个子串都是 回文串 。返回s
所有可能的分割方案。
代码题解如下:
List<List<String> > res;
public List<List<String>> partition(String s) {
res = new LinkedList<>();
LinkedList<String> track = new LinkedList<>();
backTrack(s,0,track);
return res;
}
// 这里的startIndex代表着分割位置,1...3代表着索引位置从1到3区间的字符串,这里的不停往后分割也就类似于往下一层的遍历树,每一层的节点就是一个分割区间,所以可以用回溯
private void backTrack(String s,int startIndex,LinkedList<String> track) {
//如果起始位置大于s的大小,说明找到了一组分割方案
if (startIndex >= s.length()) {
res.add(new LinkedList<>(track));
return;
}
for (int i = startIndex;i < s.length();i++) {
// 这里要先判断分割的字符串满不满足回文串,如果不满足就不能继续下一层遍历,满足才可以进行后边的分割也就是下一层遍历
if (isPalindrome(s,startIndex,i)) {
track.add(s.substring(startIndex,i+1));
backTrack(s,i+1,track);
track.removeLast();
}
}
}
// 利用双指针判断是否是回文串
private boolean isPalindrome(String s,int start,int end) {
for (int i = start,j = end;i<j;i++,j--) {
if (s.charAt(i) != s.charAt(j)) {
return false;
}
}
return true;
}
如代码注释那样:
startIndex代表着分割位置,1...3
代表着索引位置从1到3区间的字符串,这里的不停往后分割也就类似于往下一层的遍历树,每一层的节点就是一个分割区间,所以可以用回溯
在每一层的遍历里,要先判断分割的字符串满不满足回文串,如果不满足就不能继续下一层遍历,满足才可以进行后边的分割也就是下一层遍历,判断回文子串利用双指针来判断
求子集问题
在树形结构中子集问题是要收集所有节点的结果,而组合问题是收集叶子节点的结果。
最基础的就是每一次递归入口,都把track放入到res列表里
比较难得是491-求递增子序列:
给你一个整数数组 nums ,找出并返回所有该数组中不同的递增子序列,递增子序列中 至少有两个元素 。你可以按 任意顺序 返回答案。
数组中可能含有重复元素,如出现两个整数相等,也可以视作递增序列的一种特殊情况。
这里的去重方法和之前的不一样,这里是要判断同一层是否重复访问了已访问的节点,需要在每一次递归的时候重新初始化一个used数组(因为数字范围是-100到100,所以数组长度初始化为201),访问之后把对应的used位置上的值置为1,
代码题解如下:
List<List<Integer> > res;
public List<List<Integer>> findSubsequences(int[] nums) {
res = new LinkedList<>();
// 这里获取原数组的递增子序列,所以不能排序改变结构
LinkedList<Integer> track = new LinkedList<>();
backTrack(nums,0,track);
return res;
}
private void backTrack(int[] nums,int startIndex,LinkedList<Integer> track) {
if (track.size() >= 2) {
res.add(new LinkedList<>(track));
}
// 记录同层之间的节点是否已访问过,若已访问过就不能将该节点添加到下一层遍历中去;题目说数值范围[-100, 100]
int[] used = new int[201];
for (int i = startIndex;i<nums.length;i++) {
// 如果同层之间nums[i] 已经访问过了,就不能再添加该节点了,因为是同层之间的判断,所以每一次递归都要初始化该数组
if (used[nums[i]+100] == 1) {
continue;
}
// 这里要保证track里的序列是有序递增的
if (track.size() == 0 || (track.size() > 0 && track.get(track.size()-1) <= nums[i])) {
track.add(nums[i]);
used[nums[i]+100] = 1;
backTrack(nums,i+1,track);
track.removeLast();
}
}
}
排列问题
排列问题的不同:
- 每层都是从0开始搜索而不是startIndex
- 需要used数组记录path里都放了哪些元素了