1、算法基础
1、定义
简单的说,回溯算法就是一种结合递归的暴力搜索方法
2、适用问题
3、算法思想
伪代码:
回溯函数遍历过程
for(选择:本层集合中元素(苏中节点的数量就是集合的大小)){
处理几点;
backtracking(路径,选择列表); //递归
回溯,撤销处理结果
}
回溯算法模板框架 ```java void backtracking(参数){ if(终止条件){ 存放条件; return; }
for(选择:本层集合中元素(苏中节点的数量就是集合的大小)){ 处理几点; backtracking(路径,选择列表); //递归 回溯,撤销处理结果 } }
<a name="lzasm"></a>
# 2、组合问题
> 首先画一个树形来模拟组合问题
> 确定需要有一个递增的起始参数
> 三部曲
```java
ArrayList<List<Integer>> res = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> combine(int n, int k) {
//从 start=1 开始
trackCombine(n,k,1);
return res;
}
//确定参数:前两个和原函数一致,start表示for循环中递归调用的起始位置
void trackCombine(int n, int k,int start) {
//终止条件
if (path.size() == k) {
res.add(new ArrayList<>(path)); //此处path是全局变量,所以需要重新创建
return;
}
//for循环
for (int i = start; i <= n; i++){
path.add(i); //数字加入数组
trackCombine(n, k, ++start);
path.removeLast(); //回溯,返回上一级
}
}
3、组和总和
套用模板
原始
class Solution {
ArrayList<List<Integer>> resSum3 = new ArrayList<>();
LinkedList<Integer> paths = new LinkedList();
public List<List<Integer>> combinationSum3(int k, int n) {
trackSum3(k, n, 1);
return resSum3;
}
void trackSum3(int k, int n, int start) {
int sum = 0;
if (paths.size() == k) {
for (int i = 0; i < paths.size(); i++) {
sum += paths.get(i);
}
if (sum==n) resSum3.add(new ArrayList<>(paths));
}
for (int i = start; i < 10; i++) {
paths.add(i);
trackSum3(k, n, ++start);
paths.removeLast();
}
}
}
将求和带入到参数中
class Solution {
ArrayList<List<Integer>> resSum3 = new ArrayList<>();
LinkedList<Integer> paths = new LinkedList();
public List<List<Integer>> combinationSum3(int k, int n) {
trackSum3(k, n, 1,0);
return resSum3;
}
void trackSum3(int k, int n, int start,int sum) {
//将sum带入 时间复杂度会增高
if (paths.size() == k) {
if (sum==n) resSum3.add(new ArrayList<>(paths));
}
for (int i = start; i < 10; i++) {
sum += i;
paths.add(i);
trackSum3(k, n, ++start, sum);
sum -= i;
paths.removeLast();
}
}
}
剪枝
class Solution {
ArrayList<List<Integer>> resSum3 = new ArrayList<>();
LinkedList<Integer> paths = new LinkedList();
public List<List<Integer>> combinationSum3(int k, int n) {
trackSum3(k, n, 1,0);
return resSum3;
}
void trackSum3(int k, int n, int start,int sum) {
//初步剪枝,如果求和过程中大于目标则返回
if (sum > n) {
return;
}
if (paths.size() == k) {
if (sum==n) resSum3.add(new ArrayList<>(paths));
return;
}
//二次剪枝,如果构不成k个数,则返回
for (int i = start; i <= 9 - (k - paths.size()) + 1; i++) {
sum += i;
paths.add(i);
trackSum3(k, n, i+1, sum);
sum -= i;
paths.removeLast();
}
}
}
4、电话号码的字母组和
class Solution {
ArrayList<String> res = new ArrayList<>();
public List<String> letterCombinations(String digits) {
if (digits.isEmpty()) return res;
String[] strMap = new String[]{ "","","abc","def","ghi","jkl","mno","pqrs","tuv", "wxyz"};
trackLetter(digits, strMap, 0);
return res;
}
StringBuffer sb = new StringBuffer();
//此处的num不是记录开始位置,而是为了记录结果集中当前处理的下标
void trackLetter(String digits, String[] strMap, int num) {
if (num== digits.length()) {
res.add(sb.toString());
return;
}
String str = strMap[digits.charAt(num)-'0'];
//遍历str
for (int i = 0; i < str.length(); i++) {
sb.append(str.charAt(i));
trackLetter(digits, strMap, num+1);
sb.deleteCharAt(sb.length() - 1);
}
}
}
5、组和总和||
此题的难点,主要是数字可以重复使用 对于此,有以下方式应对:
- 将数组排序,从小到大,保证数字递加时不会提前跳出循环
- 在回溯模板中,for循环增加 如果sum大于target时,跳出循环
- for 中的下标 i ,作为下次循环的起始值
ArrayList<List<Integer>> ressum = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> combinationSum(int[] candidates, int target) {
if (candidates.length == 0) {
return ressum;
}
//数组先行排序
Arrays.sort(candidates);
trackSum(candidates, target, 0,0);
return ressum;
}
void trackSum(int[] cand, int target,int sum, int start) {
System.out.println("sum:"+sum);
if (sum == target){
ressum.add(new ArrayList<>(path));
return;
}
for (int i = start; i < cand.length; i++) {
//由于这种相加的方式,是可以重复的添加,因此需要添加此条件跳出循环
if (sum + cand[i] > target) break;
System.out.println(i);
path.add(cand[i]);
//将i 作为下次开始,保证数字的重复使用
trackSum(cand, target, sum + cand[i], i);
path.removeLast();
}
}
6、分割回文串
- 回溯函数的参数:
- 字符串,索引值
- 回溯函数的逻辑:
- 如果子字符串为回文字符串,则将结果加到path中
- 如果不是回文,则跳过本次for循环
- 递归调用下一个索引起点(索引++)
- path移除末尾元素进行回溯
- 终止条件
- 如果索引值大于等于s 的长度,则返回
class Solution {
ArrayList<List<String >> resP = new ArrayList<>();
LinkedList<String> pathp = new LinkedList<>();
public List<List<String>> partition(String s) {
if (s.length()==0) return resP;
tracePart(s, 0);
return resP;
}
void tracePart(String s, int index) {
// System.out.println("index: "+index);
if (index >= s.length()) {
resP.add(new ArrayList<>(pathp));
return;
}
for (int i = index; i < s.length(); i++) {
if (partFlag(s, index, i)) {
//此处的i+1|只是为了截取字符串
pathp.add(s.substring(index, i+1));
} else {
//当不是回文时,跳过本次循环
continue;
}
//当遍历字符是回文时,在本次的基础上索引直接加1(不需要再减回来),遍历下一个字符
tracePart(s,i+1);
//路径变量进行回溯
pathp.removeLast();
}
}
boolean partFlag(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;
}
}
7、复原IP地址
复原IP地址本质上还是数字组和问题,只不过不能改变数字的顺序,变的只是切割的位置,而且每一个 被分割的子字符串都要符合IP地址的条件。 按照以上思路,我们可以预先定义一个路径集和结果集。 在回溯模板中,参数设置为字符串和开始索引 终止条件就是路径集的大小为 4 ,并且如果开始索引的大小和s的长度一致时,将路径集通过join的方法加入到结果集中。 for循环中i = start,由于字串大小限制最大为3,所以可以增加条件 i<start+3 与 i<s.length() 如果子字符串不符合ip条件。直接跳出循环否则进行回溯 在isValid函数中,s.length()!=1 对单“0”的优化
class Solution {
ArrayList<String> resIP = new ArrayList<>();
LinkedList<String> pathIP = new LinkedList<>();
public List<String> restoreIpAddresses(String s) {
if (s.length() == 0) {
return resIP;
}
trackIP(s, 0);
return resIP;
}
void trackIP(String s, int start) {
//如果存入的数字总共有四个
if (pathIP.size() == 4) {
//且构成的数字和原字符串一致
if (start == s.length()) {
//数组用join方法加标点
resIP.add(String.join(".", pathIP));
}
return;
}
//因为IP是由三个数字组成,且不能超过字符串长度限制
for (int i = start; i < start + 3 && i < s.length(); i++) {
//System.out.println("start:" + start);
// System.out.println("i:" + i);
String sub = s.substring(start, i+1);
//如果不符合要求,可以直接跳出递归
if (!isValid(sub)) break;
//将字串加入路径中
pathIP.add(sub);
trackIP(s, i + 1);
//回溯
pathIP.removeLast();
}
}
boolean isValid(String s) {
//s.length()!=1 对单“0”的优化
if (s.length()!=1&&s.charAt(0)=='0' || Integer.valueOf(s)>255) {
return false;
}
return true;
}
}
8、子集问题
与组和、排列不同,如果将这些都抽象成一棵树的话,组和和分割是收集树的叶子节点,而子集是收集树的所有节点。 又因为子集都是无序的,所以回溯时,for循环要从start开始,并且每一层递归的结果都需要收集。 终止条件是当前长度等于s.length
class Solution {
ArrayList<List<Integer>> resSub = new ArrayList<>();
LinkedList<Integer> pathSub = new LinkedList<>();
public List<List<Integer>> subsets(int[] nums) {
if (nums.length==0) return resSub;
trackSub(nums, 0);
return resSub;
}
void trackSub(int[] nums,int start) {
resSub.add(new ArrayList<>(pathSub));
if (start==nums.length) return;
for (int i = start; i < nums.length; i++) {
pathSub.add(nums[i]);
trackSub(nums, i + 1);
pathSub.removeLast();
}
}
}
9、子集问题2
思想和 8 类似,还是收集回溯树的路径信息,主要有以下两点变动
- 有序数组无序且重复,因此需要先对数组进行排序
- 为了防止子集重复问题,在for中如果相邻两个数重复则直接跳过
ArrayList<List<Integer>> resSub = new ArrayList<>();
LinkedList<Integer> pathSub = new LinkedList<>();
void trackSub(int[] nums,int start) {
resSub.add(new ArrayList<>(pathSub));
if (start==nums.length) {
System.out.println("changdu ");
return;
}
for (int i = start; i < nums.length; i++) {
//相邻两个数字重复则跳过
if (i>start && nums[i]==nums[i-1]) continue;
pathSub.add(nums[i]);
trackSub(nums, i + 1);
pathSub.removeLast();
}
}
public List<List<Integer>> subsetsWithDup(int[] nums) {
if (nums.length==0) return resSub;
//将数组排序
Arrays.sort(nums);
trackSub(nums, 0);
return resSub;
}
10、递增子序列
与子集问题类似,都是收集抽象树上的路径,但是这个数组不是组和问题,所以不能直接排序,因此需要利用一个hash,将数字的使用情况收集起来
利用hashmap ```java class Solution { ArrayList
- > resSeq = new ArrayList<>();
LinkedList
pathSeq = new LinkedList<>(); public List - > findSubsequences(int[] nums) {
if (nums.length==0) return resSeq;
trackSeq(nums,0);
return resSeq;
}
void trackSeq(int[] nums, int start) {
if (pathSeq.size()>1) resSeq.add(new ArrayList<>(pathSeq));
if (start== nums.length) return;
//录本层元素是否重复使⽤,新的⼀层uset都会重新定义(清空)
HashMap<Integer, Integer> map = new HashMap<>();
for (int i = start; i < nums.length; i++) {
if (!pathSeq.isEmpty()&&nums[i] < pathSeq.getLast()){ continue;}
if (map.getOrDefault(nums[i],0)>0) continue;
map.put(nums[i], map.getOrDefault(nums[i], 0) + 1);
pathSeq.add(nums[i]);
trackSeq(nums, i + 1);
pathSeq.removeLast();
}
}
}
- 利用hashset
```java
class Solution {
//存放结果
List<List<Integer>> result = new ArrayList<>();
//暂存结果
List<Integer> path = new ArrayList<>();
public List<List<Integer>> findSubsequences(int[] nums) {
backTrack(nums, 0);
return result;
}
private void backTrack(int[] nums, int startIndex) {
if(path.size() > 1){
result.add(new ArrayList<>(path));//注意这⾥不要加return,因为要取树上的所有节点
}
HashSet<Integer> uset = new HashSet<>();//录本层元素是否重复使⽤,新的⼀层uset都会重新定义(清空)
for (int i = startIndex; i < nums.length; i++) {
//!path.isEmpty()&&nums[i]<path.get(path.size()-1)) : 保证子序列是递增的
//!uset.add(nums[i]) :保证在同一层不会重复使用相同数字
if ((!path.isEmpty()&&nums[i]<path.get(path.size()-1))||!uset.add(nums[i])) {
continue;
}
path.add(nums[i]);
backTrack(nums, i + 1);
path.remove(path.size() - 1);
}
}
}
11、全排列
全排可以想象成一种暴力排列的方式,因此不需要有前后顺序,所以for循环中的索引每次都从0 开始,参数也无需有索引开始的取值,但由于每次都从0 开始,所以需要判断每层的递归树中使用次数
辅助数组
class Solution {
ArrayList<List<Integer>> resPer = new ArrayList<>();
LinkedList<Integer> pathPer = new LinkedList<>();
boolean[] visited;
public List<List<Integer>> permute(int[] nums) {
visited = new boolean[nums.length];
tackPermute(nums);
return resPer;
}
void tackPermute(int[] nums) {
if (pathPer.size() == nums.length) {
resPer.add(new ArrayList<>(pathPer));
return;
}
for (int j = 0; j < nums.length; j++) {
if (visited[j]) {
continue;
}
visited[j] = true;
pathPer.add(nums[j]);
tackPermute(nums);
pathPer.removeLast();
visited[j] = false;
}
}
}
直接判断path中是否含有这个数字
class Solution {
ArrayList<List<Integer>> resPer = new ArrayList<>();
LinkedList<Integer> pathPer = new LinkedList<>();
public List<List<Integer>> permute(int[] nums) {
tackPermute(nums);
return resPer;
}
void tackPermute(int[] nums) {
if (pathPer.size() == nums.length) {
resPer.add(new ArrayList<>(pathPer));
return;
}
for (int j = 0; j < nums.length; j++) {
if (pathPer.contains(nums[j])) {
continue;
}
pathPer.add(nums[j]);
tackPermute(nums);
pathPer.removeLast();
}
}
}
12、全排列2
大致思路:由于题目中的数组可以重复,但是结果不能有重复的排列,主要思路和组合问题2 类似,都是先将数组排序,然后在for 循环中控制数字添加到路径中。 具体:经分析,此题应是树的层级去重,因此在前后判断的同时还需加一个控制位,来对递归层数相同的排列去重,否则直接横向、纵向都会被去重。具体参考下图:
public class Day_02 {
ArrayList<List<Integer>> resPer = new ArrayList<>();
LinkedList<Integer> pathPer = new LinkedList<>();
boolean[] uset;
public List<List<Integer>> permuteUnique(int[] nums) {
uset = new boolean[nums.length];
Arrays.sort(nums);
trackPer(nums);
return resPer;
}
void trackPer(int[] nums) {
if (nums.length == pathPer.size()) {
resPer.add(new ArrayList<>(pathPer));
}
for (int i = 0; i < nums.length; i++) {
if (uset[i]) continue;
//通过uset[i - 1] 进行横向去重
//当条件为true ,也能AC,但是由于这样是纵向去重的,所以复杂度会更高
if (i != 0 && nums[i] == nums[i - 1] && uset[i - 1]==false) {
continue;
}
//去重后对标记位赋值,可以视为加锁
uset[i] = true;
pathPer.add(nums[i]);
trackPer(nums);
pathPer.removeLast();
uset[i] = false;}}}
【同层剪枝 VS 非同层剪枝】 相信你现在已经知道如下条件的作用了,很多人对最后一个子条件比较疑惑。即采用!visited[i - 1] 或 visited[i - 1],结果竟然是一样的,不光都是对的,而且输出也是一摸一样。
if(visited[i] || (i > 0 && nums[i - 1] == nums[i] && !visited[i - 1])) continue;
实际上如果你愿意付出点时间用纸笔跟踪一下递归过程,你会发现 !visited[i - 1] 实现了 「同层剪枝」,而 visited[i - 1] 实现「非同层剪枝」。 【同层剪枝】 当选取到nums[i],并满足 i > 0 && nums[i - 1] == nums[i] 时,若 !visited[i - 1] = true,说明以nums[i - 1]为某一层元素的选择已穷尽,以至于在回溯的时候置 visited[i - 1] = false)。于是后续会根据这个条件跳过同层相等元素。 【非同层剪枝】 最后一个子条件若采用 visited[i - 1],当选取到nums[i],并满足 i > 0 && nums[i - 1] == nums[i] 时,若 visited[i - 1] = true,表明当前是在nums[i - 1]的子树中选择nums[i],根据这个条件,在子树中遇到nums[i],总是不选取(continue),那么该子树总是无法提供有效排列(因为缺少nums[i]),于是对该子树的搜索都是无效的。之后回溯到nums[i - 1]所在层后,由于撤销为 visited[i - 1] = false,不再满足visited[i - 1] = true,于是不会跳过,可以正常选取到包含nums[i - 1]和nums[i]的排列。 通过上述说明,采用!visited[i - 1]的「同层剪枝」效率更高,因为「非同层剪枝」对nums[i - 1]的子树(存在nums[i] == nums[i - 1])的搜索是无效的。 另外我们也可以看到,无论哪一种,输出有效排列的顺序是一致的。二者的差别可理解为,非同层剪枝比同层剪枝多做了无效子树搜索动作。
13、N皇后问题
N皇后主要难点在于这个是二维的问题,打破这个障碍,思路就变得通顺了,无非就是一行代表递归的层数,n+1代表递归层数+1,然后在访问到点时,校验这个点是否符合N皇后的规则
class Solution {
List<List<String>> res = new ArrayList<>();
public List<List<String>> solveNQueens(int n) {
char[][] chessboard = new char[n][n];
for (char[] c : chessboard) {
Arrays.fill(c, '.');
}
backTrack(n, 0, chessboard);
return res;
}
public void backTrack(int n, int row, char[][] chessboard) {
if (row == n) {
res.add(Array2List(chessboard));
return;
}
for (int col = 0;col < n; ++col) {
if (isValid (row, col, n, chessboard)) {
chessboard[row][col] = 'Q';
backTrack(n, row+1, chessboard);
chessboard[row][col] = '.';
}
}
}
public List Array2List(char[][] chessboard) {
List<String> list = new ArrayList<>();
for (char[] c : chessboard) {
list.add(String.copyValueOf(c));
}
return list;
}
public boolean isValid(int row, int col, int n, char[][] chessboard) {
// 检查列
for (int i=0; i<row; ++i) { // 相当于剪枝
if (chessboard[i][col] == 'Q') {
return false;
}
}
// 检查45度对角线
for (int i=row-1, j=col-1; i>=0 && j>=0; i--, j--) {
if (chessboard[i][j] == 'Q') {
return false;
}
}
// 检查135度对角线
for (int i=row-1, j=col+1; i>=0 && j<=n-1; i--, j++) {
if (chessboard[i][j] == 'Q') {
return false;
}
}
return true;
}
}