代码随想录-暑假算法第六天(回溯篇)
理论基础
#什么是回溯法
回溯法也可以叫做回溯搜索法,它是一种搜索的方式。
在二叉树系列中,我们已经不止一次,提到了回溯,例如二叉树:以为使用了递归,其实还隐藏着回溯 (opens new window)。
回溯是递归的副产品,只要有递归就会有回溯。
所以以下讲解中,回溯函数也就是递归函数,指的都是一个函数。
#回溯法的效率
回溯法的性能如何呢,这里要和大家说清楚了,虽然回溯法很难,很不好理解,但是回溯法并不是什么高效的算法。
因为回溯的本质是穷举,穷举所有可能,然后选出我们想要的答案,如果想让回溯法高效一些,可以加一些剪枝的操作,但也改不了回溯法就是穷举的本质。
那么既然回溯法并不高效为什么还要用它呢?
因为没得选,一些问题能暴力搜出来就不错了,撑死了再剪枝一下,还没有更高效的解法。
此时大家应该好奇了,都什么问题,这么牛逼,只能暴力搜索。
#回溯法解决的问题
回溯法,一般可以解决如下几种问题:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 棋盘问题:N皇后,解数独等等
相信大家看着这些之后会发现,每个问题,都不简单!
另外,会有一些同学可能分不清什么是组合,什么是排列?
组合是不强调元素顺序的,排列是强调元素顺序。
例如:{1, 2} 和 {2, 1} 在组合上,就是一个集合,因为不强调顺序,而要是排列的话,{1, 2} 和 {2, 1} 就是两个集合了。
记住组合无序,排列有序,就可以了。
#如何理解回溯法
回溯法解决的问题都可以抽象为树形结构,是的,我指的是所有回溯法的问题都可以抽象为树形结构!
因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度就构成了树的深度。
递归就要有终止条件,所以必然是一棵高度有限的树(N叉树)。
这块可能初学者还不太理解,后面的回溯算法解决的所有题目中,我都会强调这一点并画图举相应的例子,现在有一个印象就行。
#回溯法模板
这里给出Carl总结的回溯算法模板。
在讲二叉树的递归 (opens new window)中我们说了递归三部曲,这里我再给大家列出回溯三部曲。
- 回溯函数模板返回值以及参数
在回溯算法中,我的习惯是函数起名字为backtracking,这个起名大家随意。
回溯算法中函数返回值一般为void。
再来看一下参数,因为回溯算法需要的参数可不像二叉树递归的时候那么容易一次性确定下来,所以一般是先写逻辑,然后需要什么参数,就填什么参数。
但后面的回溯题目的讲解中,为了方便大家理解,我在一开始就帮大家把参数确定下来。
回溯函数伪代码如下:
void backtracking(参数)
- 回溯函数终止条件
既然是树形结构,那么我们在讲解二叉树的递归 (opens new window)的时候,就知道遍历树形结构一定要有终止条件。
所以回溯也有要终止条件。
什么时候达到了终止条件,树中就可以看出,一般来说搜到叶子节点了,也就找到了满足条件的一条答案,把这个答案存放起来,并结束本层递归。
所以回溯函数终止条件伪代码如下:
if (终止条件) {
存放结果;
return;
}
- 回溯搜索的遍历过程
在上面我们提到了,回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度。
如图:
注意图中,我特意举例集合大小和孩子的数量是相等的!
回溯函数遍历过程伪代码如下:
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
for循环就是遍历集合区间,可以理解一个节点有多少个孩子,这个for循环就执行多少次。
backtracking这里自己调用自己,实现递归。
大家可以从图中看出for循环可以理解是横向遍历,backtracking(递归)就是纵向遍历,这样就把这棵树全遍历完了,一般来说,搜索叶子节点就是找的其中一个结果了。
分析完过程,回溯算法模板框架如下:
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
这份模板很重要,后面做回溯法的题目都靠它了!
如果从来没有学过回溯算法的录友们,看到这里会有点懵,后面开始讲解具体题目的时候就会好一些了,已经做过回溯法题目的录友,看到这里应该会感同身受了。
#总结
本篇我们讲解了,什么是回溯算法,知道了回溯和递归是相辅相成的。
接着提到了回溯法的效率,回溯法其实就是暴力查找,并不是什么高效的算法。
然后列出了回溯法可以解决几类问题,可以看出每一类问题都不简单。
最后我们讲到回溯法解决的问题都可以抽象为树形结构(N叉树),并给出了回溯法的模板。
回溯模板
分析完过程,回溯算法模板框架如下:
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
这份模板很重要,后面做回溯法的题目都靠它了!
第77题. 组合
给定两个整数 n 和 k,返回 1 … n 中所有可能的 k 个数的组合。
示例: 输入: n = 4, k = 2 输出: [ [2,4], [3,4], [2,3], [1,2], [1,3], [1,4], ]
题解
class Solution {
//全局变量
//存储全部的结果
List<List<Integer>> result = new ArrayList<>();
//存储单个结果
ArrayList<Integer> path = new ArrayList<>();
public List<List<Integer>> combine(int n, int k) {
backtracking(n, k, 1);
return result;
}
//回溯函数
//1. 递归的参数和返回值类型
public void backtracking(int n, int k, int startIndex) {
//2. 递归的终止条件
if (path.size() == k) {
result.add(new ArrayList<>(path));
return;
}
//3. 单层递归的逻辑
for (int i = startIndex; i <= n; i++) {
//3.1 节点的操作
path.add(i);
//3.2 递归
backtracking(n,k,i+1);
//3.3 回溯
path.remove(path.size() -1);
}
}
}
216.组合总和III
找出所有相加之和为 n 的 k 个数的组合。组合中只允许含有 1 - 9 的正整数,并且每种组合中不存在重复的数字。
说明:
- 所有数字都是正整数。
- 解集不能包含重复的组合。
示例 1: 输入: k = 3, n = 7 输出: [[1,2,4]]
示例 2: 输入: k = 3, n = 9 输出: [[1,2,6], [1,3,5], [2,3,4]]
题解
class Solution {
//全局变量
List<List<Integer>> result = new ArrayList<>();
List<Integer> path = new ArrayList<>();
public List<List<Integer>> combinationSum3(int k, int n) {
backtracking(k, n, 1, 0);
return result;
}
//回溯
public void backtracking(int k, int n, int startIndex, int sum) {
if (path.size() == k) {
if (sum == n) {
result.add(new ArrayList<>(path));
return;
}
return;
}
//单层递归逻辑
for (int i = startIndex; i <= 9; i++) {
//剪枝
path.add(i);
int tempsum = sum;
tempsum = tempsum+i;
backtracking(k,n,i+1,tempsum);
path.remove(path.size() -1);
}
return;
}
}
40.组合总和II
给定一个数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的每个数字在每个组合中只能使用一次。
说明: 所有数字(包括目标数)都是正整数。解集不能包含重复的组合。
- 示例 1:
- 输入: candidates = [10,1,2,7,6,1,5], target = 8,
- 所求解集为:
[
[1, 7],
[1, 2, 5],
[2, 6],
[1, 1, 6]
]
- 示例 2:
- 输入: candidates = [2,5,2,1,2], target = 5,
- 所求解集为:
[
[1,2,2],
[5]
]
题解
class Solution {
//全局变量
List<List<Integer>> result = new ArrayList<>();
List<Integer> list = new ArrayList<>();
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
//去重
//用于标记数组 去重
int[] used = new int[candidates.length];
//Arrays.fill(used,0);
//对数组进行paixu 便于后序的去重
Arrays.sort(candidates);
backtracking(candidates, target, 0, 0, used);
//返回结果
return result;
}
//回溯
public void backtracking(int[] candidates, int target, int sum, int startIndex, int[] used) {
//终止条件
if (sum > target) {
return;
}
if (sum == target) {
//收集结果
result.add(new ArrayList<>(list));
}
//单层递归逻辑
for (int i = startIndex; i < candidates.length; i++) {
//去重逻辑 数层去重
if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == 0) {
continue;
}
//1. 节点处理
list.add(candidates[i]);
sum = sum + candidates[i];
//修改used数组
used[i] = 1;
//2. 递归
backtracking(candidates,target,sum,i+1,used);
//3. 回溯
list.remove(list.size() -1);
sum = sum - candidates[i];
used[i] = 0;
}
}
}
78.子集
给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。
说明:解集不能包含重复的子集。
示例: 输入: nums = [1,2,3] 输出: [ [3], [1], [2], [1,2,3], [1,3], [2,3], [1,2], [] ]
题解
class Solution {
// 全局变量
List<List<Integer>> result = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
boolean[] used;
public List<List<Integer>> subsetsWithDup(int[] nums) {
if(nums.length==0){
result.add(path);
return result;
}
// 去重
Arrays.sort(nums);
used = new boolean[nums.length];
backtracking(nums, 0);
return result;
}
// 回溯
public void backtracking(int[] nums, int startIndex) {
//要把树层的结果也要添加
//所以在if上面进行添加
// 这里注意要new 一个新的list
result.add(new ArrayList<>(path));
if (startIndex == nums.length) {
return;
}
for (int i = startIndex; i < nums.length; i++) {
// 这道题不用去重
//if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) {
//continue;
//}
// 处理节点
path.add(nums[i]);
used[i] = true;
// 递归
backtracking(nums, i + 1);
// 回溯
path.removeLast();
used[i] = false;
}
}
}
90.子集II
给定一个可能包含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。
说明:解集不能包含重复的子集。
示例:
- 输入: [1,2,2]
- 输出: [ [2], [1], [1,2,2], [2,2], [1,2], [] ]
题解
class Solution {
// 全局变量
List<List<Integer>> result = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
boolean[] used;
public List<List<Integer>> subsetsWithDup(int[] nums) {
if(nums.length==0){
result.add(path);
return result;
}
// 去重
Arrays.sort(nums);
used = new boolean[nums.length];
backtracking(nums, 0);
return result;
}
// 回溯
public void backtracking(int[] nums, int startIndex) {
//要把树层的结果也要添加
//所以在if上面进行添加
// 这里注意要new 一个新的list
result.add(new ArrayList<>(path));
if (startIndex == nums.length) {
return;
}
for (int i = startIndex; i < nums.length; i++) {
// 去重
if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) {
continue;
}
// 处理节点
path.add(nums[i]);
used[i] = true;
// 递归
backtracking(nums, i + 1);
// 回溯
path.removeLast();
used[i] = false;
}
}
}
46.全排列
给定一个 没有重复 数字的序列,返回其所有可能的全排列。
示例:
- 输入: [1,2,3]
- 输出: [ [1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1]
题解
class Solution {
List<List<Integer>> result = new ArrayList<>();
List<Integer> path = new LinkedList<>();
boolean[] flag;
public List<List<Integer>> permute(int[] nums) {
flag = new boolean[nums.length];
//Arrays.fill(flag,false);
backtracking(nums);
return result;
}
public void backtracking(int[] nums) {
//递归的终止条件 在叶子节点中收集
if (path.size() == nums.length) {
result.add(new LinkedList<>(path));
return;
}
for (int i = 0; i < nums.length; i++) {
//标记
if(flag[i]){
//已经使用过
continue;
}
path.add(nums[i]);
flag[i] = true;
backtracking(nums);
path.remove(path.size() -1);
flag[i] = false;
}
}
}
51. N皇后
n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。
每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 ‘Q’ 和 ‘.’ 分别代表了皇后和空位。
示例 1:
- 输入:n = 4
- 输出:[[“.Q…”,“…Q”,“Q…”,“…Q.”],[“…Q.”,“Q…”,“…Q”,“.Q…”]]
- 解释:如上图所示,4 皇后问题存在两个不同的解法。
示例 2:
- 输入:n = 1
- 输出:[[“Q”]]
题解
class Solution {
//N皇后问题
//全局变量
List<List<String>> result = new LinkedList<>();
public List<List<String>> solveNQueens(int n) {
char[][] qipan = new char[n][n];
//给棋盘初始化
for (char[] chars : qipan) {
Arrays.fill(chars, '.');
}
//row代表行
backtracking(n, 0, qipan);
return result;
}
//回溯
public void backtracking(int n, int row, char[][] qipan) {
//递归的终止条件
if (row == n) {
result.add(change(qipan));
return;
}
for (int i = 0; i < n; i++) {
//i为列
//row为行
//判断是否合法
if (check(qipan, row, i, n)) {
//处理节点 即添加该位置为皇后
qipan[row][i] = 'Q';
//递归
backtracking(n, row + 1, qipan);
//回溯
qipan[row][i] = '.';
}
}
}
//二维数组棋盘转化成为 list
public List change(char[][] qipan) {
List<String> list = new ArrayList<>();
for (char[] chars : qipan) {
//把数组里面的值转成字符串
list.add(String.copyValueOf(chars));
}
return list;
}
//判断棋盘是否合法
public boolean check(char[][] qipan, int row, int col, int n) {
//行不用判断 因为我就是for(int i= 0;i<n)这样子取的 这里行已经唯一了
//判断列
for (int i = row - 1; i >= 0; i--) {
if (qipan[i][col] == 'Q') {
return false;
}
}
//判断左上角
for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) {
if (qipan[i][j] == 'Q') {
return false;
}
}
//判断右上角
for (int i = row - 1, j = col + 1; i >= 0 && j <= n - 1; i--, j++) {
if (qipan[i][j] == 'Q') {
return false;
}
}
//不满足上面的要求
return true;
}
}