声明:该笔记是根据Car卡尔大佬在B站的视频和他的网站
代码随想录加上自己的一些想法总结而出,目的是为了方便复习,自己记录的更容易看懂。
1、回溯介绍
- 什么叫回溯法?
回溯法也叫回溯搜索法,它是一种搜索的方式。回溯是递归的衍生产品,只要是递归就一定会有回溯。也就是说,回溯函数也就是递归函数,指的是一个函数。
- 回溯算法的效率
会所算法很抽象,但其实并不是什么很高深的算法。其本质上就是暴力穷举,穷举所有的可能,然后选出我们的答案,如果想提高回溯算法的效率,可以加一些剪枝的操作,但也是改变不了回溯的本质。
- 回溯法解决的问题
- 组合问题:N个数里面按一定规则找出k个数的集合
- 切割问题:一个字符串按照一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少个符合条件的子集
- 排列问题:N个数按照一定规则的去全排列,有几种排列的方式
- 棋盘问题:N皇后,解数独等问题
- 何为组合?何为排列?
组合是不强调元素的顺序,排列是强调元素的排列顺序的。
例如:{1,2}和{2,1}在组合上,就是一个集合,因为不强调顺序,二排列的话就是要强调顺序,{1,2}和{2,1}是不同的集合。
组合无序,排列有序。
-
回溯法模板
- 回溯函数模板返回值及参数,在回溯算法中,习惯函数起名为backtracking,也可以为别的。但返回值一般为void 。
void backtracking(参数)
- 回溯函数终止条件
if (终止条件) { 存放结果; return; }
- 回溯算法模板框架
void backtracking(参数) { if (终止条件) { 存放结果; return; } for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) { 处理节点; backtracking(路径,选择列表); // 递归 回溯,撤销处理结果 } }
-
如何理解回溯法
回溯法解决的问题度介意抽象成树形结构。
回溯法解决的都是在集合中递归查找子集,集合的大小就构成数的宽度,递归的深度,就构成了树的深度。
递归一定是有种植条件的,所以必然是一棵高度有限度的树(N叉树)。
参数在一开始不确定,后期需要什么就传入什么。
从树中就可以看出,一般来说搜到叶子节点了,也就找到了满足条件的一条答案,把这个答案存放起来,并结束本层递归。
2、回溯题目分类
2.1 第77题:组合
-
力扣题目链接:https://leetcode.cn/problems/combinations/
-
题目描述
- 思路
直接的解法当然是使用for循环,例如示例中k=2,就很容易想到for循环实现,也可以达到题目要求的结果:
int n = 4;
for (int i = 1 ; i <= n ; i++) {
for (int j = i+1; j <= n; j++) {
System.out.println(i+""+j);
}
}
n = 100, k = 3 那么就三层for循环,代码如下:
int n = 100;
for (int i = 1; i <= n; i++) {
for (int j = i + 1; j <= n; j++) {
for (int u = j + 1; u <= n; n++) {
System.out.println(i+""+j+u);
}
}
}
这还可以接受,可是如果n为100,k为50呢?要用50个循环??n为1000,k为200呢?
所以显然这样的方式是行不通的。
So?
就用到了回溯法,虽然回溯也是暴力搜索,但是至少能解出来,而不是for循环让人绝望。
要解决 n为100,k为50的情况,暴力写法需要嵌套50层for循环,那么回溯法就用递归来解决嵌套层数的问题
递归来做层叠嵌套(可以理解是开k层for循环),每一次的递归中嵌套一个for循环,那么递归就可以用于解决多层嵌套循环的问题了。
分析结构
- 回溯实现
import java.util.ArrayList;
import java.util.List;
public class sulation {
public List<List<Integer>> combine(int n, int k) {
List<Integer> arr = new ArrayList<Integer>();
List<List<Integer>> res = new ArrayList<>();
backstring(n,k,1,res,arr);
return res;
}
private void backstring(int n,int k,int statindex,List<List<Integer>> res,List<Integer> arr){
//终止条件
if(arr.size() == k){
res.add(new ArrayList<>(arr));
return;
}
//递归
for (int i = statindex; i <= n; i++) {
arr.add(i);
backstring(n,k,i+1,res,arr);
arr.remove(arr.size()-1);
}
}
}
- 部分解释
backstring
方法的作用是递归求解所有符合要求的组合,并将它们添加到res
列表中。
backstring
方法首先判断当前arr
列表中是否已经有k个数了。如果是,则将arr
列表添加到res
列表中,然后返回。
如果arr
列表中的元素数量还不足k个,那么就从当前位置statindex
开始,循环遍历1~n范围内的所有数。
对于每个遍历到的数i,将它添加到arr
列表中,然后递归调用backstring
方法,传递参数n、k和i+1。这里传递i+1是为了保证组合中的数字是递增的,避免重复的组合出现。
在递归调用backstring
方法之后,需要将最后添加的元素从arr
列表中删除,以便进行下一次循环遍历。
最终,backstring
方法会递归求解所有符合要求的组合,并将它们添加到res
列表中。最后,combine
方法返回res
列表,即为所有符合要求的组合。
3、组合问题剪枝操作
- 题目描述:同上。
- 思路
举个梨子,
我们假设n=4,k=4,我们画图来看,发现哪一些步骤是多余的。
第一层for循环的时候,从元素2开始遍历都没有意义了,从2开始后面不论怎么取都拿不到要求的k个数的组合。所以我们把它剪掉不需要遍历。
画×的分支是永远不可能达到题目所要求的k个数的集合的,所以直接去掉,不进行遍历。
图中的么一个节点(矩形),就代表一个for循环,就会发现每一层从第二个数开始遍历都是无效遍历!
所以,可以剪枝的地方就在每一层递归中的for循环中的起始位置。如果for循环的起始位置之后的元素个数加上已经取到的元素不足k个,则就没必要遍历了。
注意代码中i,就是for循环里选择的起始位置。
for (int i = startIndex; i <= n; i++) {
- 优化过程
- 当前已经选择的元素个数:path.size()
- 所需要的元素个数为:k - path.size()
- 列表中所剩余的元素(n-i) >= 所需要的元素个数(k - path.size())
- 在集合n中至多要从该起始位置 : i <= n - (k - path.size()) + 1,开始遍历
- 对于找边界的方法理解
path.size():已经找到的个数。
k-path.size():还需要找的个数
在[x,n]的区间上的数组长度起码是在k - path.size()才有继续搜索的可能,那么就有
n - x + 1 = k - path.size();此处的 n - x + 1 表示:在该区间上总的元素个数
解上列方程得:
最大的起始位置,也就是说大于这个区间,就找不到满足k个的元素了
x = n + 1 -(k - path.size())
为什么有个+1呢,因为包括起始位置,我们要是一个左闭的集合。
举个例子,n = 4,k = 3, 目前已经选取的元素为0(path.size为0),n - (k - 0) + 1 即 4 - ( 3 - 0) + 1 = 2。
从2开始搜索都是合理的,可以是组合[2, 3, 4]。
这里大家想不懂的话,建议也举一个例子,就知道是不是要+1了。
所以优化之后的for循环是:
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) // i为本次搜索的起始位置
- 代码实现
import java.util.ArrayList;
import java.util.List;
public class sulation {
public List<List<Integer>> combine(int n, int k) {
// 初始化容器
// 所访问的路径 元素
List<Integer> path = new ArrayList<Integer>();
// 返回的元素结果集
List<List<Integer>> result = new ArrayList<>();
backstring(n, k, 1, path, result);
return result;
}
private void backstring(int n, int k, int start, List<Integer> path, List<List<Integer>> result) {
// 终止条件
if (path.size() == k) {
result.add(new ArrayList<>(path));
return;
}
i为本次搜索的起始位置
for (int i = start; i <= n - (k - path.size()) + 1; i++) {
// 处理节点
path.add(i);
backstring(n, k, i, path, result);
回溯,撤销处理的节点
path.remove(path.size() - 1);
}
}
}
4、组合总和 III
-
题目描述:
这里我们回归一下,回溯三部曲
- 确定递归函数
和77的数组一样我们需要使用一个ArrayList来存储路径上符合存放条件的结果,二维ArrayList用来存放结果集。
回溯算法都可以用树新结构来表示,基于这一题来说,取数范围[1,9],要求求出个数为k,和为n的所有组合。
例如: k = 3, n = 9 ==> [1,2,6],[1,3,5],[2,3,4]
k = 2 , n= 4 ==>[1,3]
而且这个树的宽度,有这个取值区间决定的;而高度是有总之条件决定的。比如这一的结束层次就为k。
targetSum:目标和,也就是n;
k:题目中要求k个数的集合
sum:为已经收集的元素总和。
statIndex:为下一层for徐怒汉搜索的起始位置。
path:List集合,用于存放满足元素的元素。
result:List<List>用于返回满足条件的集合,也就是和为targetNum,元素个数为k的集合。
- 确定终止条件
k限制了数的深度,因为就取k个元素,在往下也没有意义了。所以path.size()和k相等了,就终止了。
如果此时path的收集到的元素和sum和targetSum(题目里的n)相同了,就用result收集当前的结果。
//终止条件
if(path.size() == k){
if (sum == target) {
result.add(new ArrayList<Integer>(path));
}
return;
}
- 单层搜索的过程
因为题目说了,区间只是1~9,所以这一层的for循环的终止条件小于等于9,也是最多取到9.但这样就有很多于的地方了,比如在这题中,假设k = 2 ,最大的起始位置只能是8,而不能到9,从8开始还可以组成【8,9】,9开始就取不到2个元素了。这部分取不到的可以减掉,不用处理。
这里还可以减掉的还有,比如当前元素还没有到达题目要求的k个,但是sun 已经大于targetSum,那就不必要往下遍历了,直接返回上一层递归栈,继续处理。
- 代码
private int sum = 0;
public List<List<Integer>> combinationSum3(int k, int n) {
List<Integer> path = new ArrayList<>();
List<List<Integer>> result = new ArrayList<>();
backstring(n, k, 1, path, result);
return result;
}
private void backstring(int target, int k, int start, List<Integer> path, List<List<Integer>> result) {
// 截掉部分不满足的分支
if (sum > target)
return;
// 终止条件
if (path.size() == k) {
if (sum == target) {
result.add(new ArrayList<Integer>(path));
}
return;
}
// 单层处理 和截掉部分不满足分支
for (int i = start; i <= 9 - (k - path.size()) + 1; i++) {
path.add(i);
sum += i;
backstring(target, k, i + 1, path, result);
sum -= i;
path.remove(path.size() - 1);
}
}
5、电话号码的字母组合
- 题目描述
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nWoE1mPM-1682500091244)(https://raw.githubusercontent.com/tjy17678942520/DrawingBed/master/images202304072309810.png)]
- 简单分析
从示例上来说,输入”23“,最直接的想法就是2层for循环遍历就出结果。如果输出”233“,就三层,如果n层就无法算了。这就回到了回溯问题核心问题所在。就是for循环的层数该如何确定下来。
-
解决本题需要思考的问题
- 题目输入的数字字符串,该如何映射成字母
- 两个字母就两个for循环,三个字符我就三个for循环,以此类推,然后发现代码根本写不出来。
- 输入1 * #按键等等异常情况
回溯法解决n个for循环
这里可以看出遍历的深度,就是输入“23”的长度,而叶子结点就是我们要收集的值。
-
回溯三部曲
- 确定回溯函数
首先,需要一个字符串变量来收集叶子结点的结果,然后用一个List容器将满足条件的s保存起来,定义为全局变量方便调用。也可以当做参数传递,进入递归中。
这里还需要确定的就是参数,就是int类型的index索引参数,为了记录digist中到第几个数字了,同时也用来表示树的深度。
-
确定终止条件:根据上图可知,刚好等于digist的长度。
-
确定单层遍历逻辑
首先要取index指向的数字,并找到对应的字符集(手机键盘的字符集),在for循环中回溯递归处理下一个字母。
-
注意:输入1 * #按键等等异常情况,但是要知道会有这些异常,如果是现场面试中,一定要考虑到!
-
代码(未优化效率不高,因为使用String产生了大量的字符串对象)
private String s = "";
private String[] latter = {"","","abc","def","ghi","jkl","mno","pqrs","tuv","wxyz"};
public List<String> letterCombinations(String digits) {
List<String> resoult = new ArrayList<>();
backstring(digits,0,resoult);
return resoult;
}
void backstring(String digits,int index,List<String> resoult ){
//递归返回条件
if (index == digits.length()){
if(s.length()>0) resoult.add(s);
return;
}
//单层处理逻辑
String str = latter[digits.charAt(index)-'0'];
for (int i = 0; i < str.length(); i++) {
s += str.charAt(i);
backstring(digits,index+1,resoult);
s = s.substring(0,s.length()-1);
}
}
由此可见,我们下次遇到字符串操作尽量使用可变字符串StringBuilder
- 代码(优化后)
private StringBuilder s = new StringBuilder();
private String[] latter = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};
public List<String> letterCombinations(String digits) {
List<String> resoult = new ArrayList<>();
backstring(digits, 0, resoult);
return resoult;
}
void backstring(String digits, int index, List<String> resoult) {
//递归返回条件
if (index == digits.length()) {
if (s.length() > 0) {
resoult.add(s.toString());
}
return;
}
//单层处理逻辑
String str = latter[digits.charAt(index) - '0'];
for (int i = 0; i < str.length(); i++) {
s.append(str.charAt(i));
backstring(digits, index + 1, resoult);
s.replace(s.length() - 1, s.length(), "");
}
}
从这里就可以很明显的看到,这两种结构是一样的就是换了一个类型效率就提升了那么大,看来还是得知道一些底层原理,才能写出更好的代码。
6、组合总和
- 题目描述
- 思考
回溯的题目,通常可以用树形来表示,由于题目可以出现重复元素,下一层递归还是可以冲当前位置开始取。
class Solution {
private int sum = 0;
public List<List<Integer>> combinationSum(int[] candidates, int target) {
List<Integer> path = new ArrayList<>();
List<List<Integer>> res = new ArrayList<>();
int len = candidates.length;
backstring(candidates, target,0, path, res,len);
return res;
}
private void backstring(int[] candidates, int targetSum,int index, List<Integer> path, List<List<Integer>> res,int len) {
//剪枝操作
if(sum > targetSum) {
return;
}
//返回条件
if (targetSum == sum) {
res.add(new ArrayList<>(path));
return;
}
//单层处理逻辑
for (int i = index; i < len; i++) {
sum += candidates[i];
path.add(candidates[i]);
backstring(candidates, targetSum,i, path, res,len);
sum -= candidates[i];
path.remove(path.size()-1);
}
}
}
7、组合总和II
- 题目描述
给定一个候选人编号的集合 candidates
和一个目标数 target
,找出 candidates
中所有可以使数字和为 target
的组合。
candidates
中的每个数字在每个组合中只能使用 一次 。
**注意:**解集不能包含重复的组合。
示例 1:
输入: candidates = [10,1,2,7,6,1,5], target = 8,
输出:
[
[1,1,6],
[1,2,5],
[1,7],
[2,6]
]
示例 2:
输入: candidates = [2,5,2,1,2], target = 5,
输出:
[
[1,2,2],
[5]
]
-
思路
-
candidates
中的每个数字在每个组合中只能使用 一次 。数组candidates的元素是有重复的。 -
所以就需要考虑去重,组合问题可以抽象为树形结构,那么“使用过”在这个树形结构上是有两个维度的,一个维度是同一树枝上使用过,一个维度是同一树层上使用过。
强调一下,树层去重的话,需要对数组排序!
-
- 很明显这里的结束条件和之前组合的条件一样。终止条件为
sum > target
和sum == target
。
sum > target
这个条件其实可以省略,因为在递归单层遍历的时候,会有剪枝的操作。
前面我们提到:要去重的是“同一树层上的使用过”,如何判断同一树层上元素(相同的元素)是否使用过了呢。
如果candidates[i] == candidates[i - 1]
并且 used[i - 1] == false
,就说明:前一个树枝,使用了candidates[i - 1],也就是说同一树层使用过candidates[i - 1]。
此时for循环里就应该做continue的操作。
- Java语言实现
class Solution {
public List<List<Integer>> combinationSum(int[] candidates, int target) {
List<Integer> path = new ArrayList<>();
List<List<Integer>> res = new ArrayList<>();
int sum = 0;
int len = candidates.length;
boolean[] used = new boolean[len];
Arrays.sort(candidates);
System.out.println(Arrays.toString(candidates));
backstring(candidates, target, 0, path, res, len, sum, used);
return res;
}
private void backstring(int[] candidates, int targetSum, int index, List<Integer> path, List<List<Integer>> res,
int len, int sum, boolean[] used) {
// 结束条件
if (sum == targetSum) {
res.add(new ArrayList<>(path));
return;
}
// 单层处理逻辑
for (int i = index; i < len; i++) {
//截枝操作
if (sum + candidates[i] > targetSum) {
return;
}
// 去重 在通一层上,在一个集合里面是可以重复的
if (i >= 1 && candidates[i] == candidates[i - 1] && !used[i - 1] == true) {
continue;
}
sum += candidates[i];
path.add(candidates[i]);
used[i] = true;
backstring(candidates, targetSum, i + 1, path, res, len, sum, used);
sum -= candidates[i];
path.remove(path.size() - 1);
used[i] = false;
}
}
}
8、分割回文串
- 题目描述
给你一个字符串 s
,请你将 s
分割成一些子串,使每个子串都是 回文串 。返回 s
所有可能的分割方案。
回文串 是正着读和反着读都一样的字符串。
示例 1:
输入:s = "aab"
输出:[["a","a","b"],["aa","b"]]
示例 2:
输入:s = "a"
输出:[["a"]]
提示:
-
1 <= s.length <= 16
-
s
仅由小写英文字母组成。 -
思路
- 切割问题,所谓就是不同的切割方式
- 判断回文(不难,两端向中间查询)
-
比较切割和组合问题
- 组合问题:选取第一个a后,在bcdef中选取第二个,选取b之后在cdef中选取第三个……
- 切割问题:切割第一个之后,在bcdef中切割第二段,切割之后在cdef中在切割第三段……
是不是都差不多
我们画这一份树形结构分析图
递归用来纵向遍历,for用来横向遍历,切割线切到字符串的结尾位置,说明找到了一个切割方法。
终止条件:可以看出图中只要能切割到字符串的最后一个位置,就是一个切割方法,也是结束条件。
在for (int i = startIndex; i < s.size(); i++)
循环中,我们 定义了起始位置startIndex,那么 [startIndex, i] 就是要截取的子串。
首先判断这个子串是不是回文,如果是回文,就加入在vector<string> path
中,path用来记录切割过的回文子串。
- Java整体代码
class Solution {
public List<List<String>> partition(String s) {
List<String> path = new ArrayList<>();
List<List<String>> res = new ArrayList<>();
backstring(s, 0, path, res);
return res;
}
private void backstring(String s, int index, List<String> path, List<List<String>> res) {
// 结束条件
if (index == s.length()) {
res.add(new ArrayList<>(path));
return;
}
// 单层处理逻辑
for (int i = index; i < s.length(); i++) {
if (isHuiWen(s, index, i)) {
// 获取[startIndex,i]在s中的子串
path.add(s.substring(index, i + 1));
} else {
continue;
}
backstring(s, i + 1, path, res);
path.remove(path.size() - 1);
}
}
private static boolean isHuiWen(String s, int index, int i) {
int left = index, right = i;
while (left < right) {
if (s.charAt(left) != s.charAt(right)) {
return false;
}
left++;
right--;
}
return true;
}
}
9、复原IP地址
有效 IP 地址 正好由四个整数(每个整数位于 0
到 255
之间组成,且不能含有前导 0
),整数之间用 '.'
分隔。
- 例如:
"0.1.2.201"
和"192.168.1.1"
是 有效 IP 地址,但是"0.011.255.245"
、"192.168.1.312"
和"192.168@1.1"
是 无效 IP 地址。
给定一个只包含数字的字符串 s
,用以表示一个 IP 地址,返回所有可能的有效 IP 地址,这些地址可以通过在 s
中插入 '.'
来形成。你 不能 重新排序或删除 s
中的任何数字。你可以按 任何 顺序返回答案。
示例 1:
输入:s = "25525511135"
输出:["255.255.11.135","255.255.111.35"]
示例 2:
输入:s = "0000"
输出:["0.0.0.0"]
示例 3:
输入:s = "101023"
输出:["1.0.10.23","1.0.102.3","10.1.0.23","10.10.2.3","101.0.2.3"]
遇到回溯问题,就先画出树形分析图
从图中看来只要我们对每次分割的字符串进行合法性判断,合法加上小点隔开。最后判断和法了就放入List容器中作为最终结果。
-
回溯三部曲
private void backstring(StringBuffer s, int startIndex, List<String> result, int poinSum)
startIndex 分割的位置,需要操作的字符串s,结果集result,poinSum点的数量,为3的时候结束递归。
-
递归终止条件
本题明确要求只会分成4段,所以不能用切割线切到最后作为终止条件,而是分割的段数作为终止条件。
pointNum表示逗点数量,pointNum为3说明字符串分成了4段了。
然后验证一下第四段是否合法,如果合法就加入到结果集里。
// 终止条件
if (poinSum == 3) {
if (s.lastIndexOf(".") != s.length()-1 && check_format(s.substring(startIndex,s.length())))
result.add(s.toString());
return;
}
s.lastIndexOf(“.”) != s.length()-1 确保最后一个不是点的情况。防止分割到最后一段没有数字,他也加上逗点情况。
- 截枝操作,由上图可以看出来,截取到的长度大于4都是不和法的ip段
在for循环中去掉这些
//截枝操作
if (s.substring(startIndex, i + 1).length() >= 4){
break;
}
递归调用时,下一层递归的startIndex要从i+2开始(因为需要在字符串中加入了分隔符.
),同时记录分割符的数量pointNum 要 +1。
回溯的时候,就将刚刚加入的分隔符.
删掉就可以了,pointNum也要-1。
// 单层处理逻辑
for (int i = startIndex; i < s.length(); i++) {
//截枝操作
if (s.substring(startIndex, i + 1).length() >= 4){
break;
}
// 检查是否合法
if (check_format(s.substring(startIndex, i + 1))) {
// 追加 .
s.insert(i + 1, ".");
poinSum += 1;
backstring(s, i + 2, result, poinSum);
poinSum -= 1;
//Java的操作时以开始的坐标索引和结束的位置的后一个索引
//就是点 . 的位置开始,到点的后一个位置
s.replace(i+1 , i+2, "");
}
}
- 判断字串是否合法
private boolean check_format(String str) {
//处理其他非数字
for (int i = 0; i < str.length(); i++) {
if (str.charAt(i) > '9' || str.charAt(i) < '0') {
return false;
}
}
//处理数字书否合法
if (str.startsWith("0") && str.length() > 1) {
return false;
} else {
int sum = 0;
for (int i = 0; i < str.length(); i++) {
sum = sum * 10 + str.charAt(i) - '0';
}
if (sum < 0 || sum > 255) {
return false;
}
}
return true;
}
以上还可以更改为
private boolean check_format(String str) {
//正则判断 是否是数字
String pattern = "^[0-9]*$";
boolean isMatch = Pattern.matches(pattern, str);
//是数字后才可能做合法性判断
if (isMatch){
if (str.startsWith("0") && str.length() > 1) {
return false;
} else {
int sum = 0;
for (int i = 0; i < str.length(); i++) {
sum = sum * 10 + str.charAt(i) - '0';
}
if (sum < 0 || sum > 255) {
return false;
}
}
}
return true;
}
- 回溯模板
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
- Java完整代码
class Solution {
public List<String> restoreIpAddresses(String s) {
List<String> res = new ArrayList<>();
StringBuffer temStr = new StringBuffer();
backstring(new StringBuffer(s), 0, res, 0);
return res;
}
private void backstring(StringBuffer s, int startIndex, List<String> result, int poinSum) {
// 终止条件
if (poinSum == 3) {
if (s.lastIndexOf(".") != s.length()-1 && check_format(s.substring(startIndex,s.length())))
result.add(s.toString());
return;
}
// 单层处理逻辑
for (int i = startIndex; i < s.length(); i++) {
//截掉多余的部分
if (s.substring(startIndex, i + 1).length() >= 4){
return;
}
// 检查是否合法
if (check_format(s.substring(startIndex, i + 1))) {
// 追加 .
s.insert(i + 1, ".");
poinSum += 1;
backstring(s, i + 2, result, poinSum);
poinSum -= 1;
s.replace(i+1 , i+2, "");
}
}
}
private boolean check_format(String str) {
if (str.startsWith("0") && str.length() > 1) {
return false;
} else {
int sum = 0;
for (int i = 0; i < str.length(); i++) {
sum = sum * 10 + str.charAt(i) - '0';
}
if (sum < 0 || sum > 255) {
return false;
}
}
return true;
}
}
10、子集
给你一个整数数组 nums
,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。
示例 1:
输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
示例 2:
输入:nums = [0]
输出:[[],[0]]
提示:
1 <= nums.length <= 10
-10 <= nums[i] <= 10
nums
中的所有元素 互不相同
如果把 子集问题、组合问题、分割问题都抽象为一棵树的话,那么组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点!
子集也是一种组合问题,只不过有点特殊。集合是无序的子集{1,2} 和 {2,1}是一样的。
那么在回溯算法中的for循环不能从0开始取数,而是从startIndex开始。
- 回溯三部曲
- 递归函数参数
- 全局/形参变量数组path收集元素,二维数组res存放path满足条件的集合。
- 由上图可以看的出,当stratIndex == nums.length时(即到达叶子结点时)开始返回。
- 单层搜索逻辑(for)
- 求取子集问题,不需要任何剪枝!因为子集就是要遍历整棵树。每一个节点都要收取。
- 这里可以不用写返回条件,因为每次递归的下一层就是从i+1开始的,最终for循环会停止。
- 递归函数参数
class Solution {
public List<List<Integer>> subsets(int[] nums) {
List<Integer> path = new ArrayList<>();
List<List<Integer>> result = new ArrayList<>();
backstring(nums,0,path,result);
return result;
}
![image-20230418204240026](C:/Users/23705/AppData/Roaming/Typora/typora-user-images/image-20230418204240026.png)
private void backstring(int[] nums,int startIndex,List<Integer> path, List<List<Integer>> result){
result.add(new ArrayList<>(path));
if (startIndex == nums.length){
return;
}
for (int i = startIndex; i < nums.length; i++) {
path.add(nums[i]);
backstring(nums,i+1,path,result);
path.remove(path.size()-1);
}
}
}
12、子集II
这一道题就是在之前子集的基础上,加上之前的树层去重。
去重在组合总和三那一题。
本题也可以不使用used数组来去重,因为递归的时候下一个startIndex是i+1而不是0。
如果要是全排列的话,每次要从0开始遍历,为了跳过已入栈的元素,需要使用used。
class Solution {
public List<List<Integer>> subsetsWithDup(int[] nums) {
List<Integer> path = new ArrayList<>();
List<List<Integer>> res = new ArrayList<>();
boolean[] used = new boolean[nums.length];
Arrays.fill(used,false);
Arrays.sort(nums);
backstring(nums,0,used,path,res);
return res;
}
private void backstring(int[] nums,int startindex,boolean[] used,List<Integer> path,List<List<Integer>> res){
res.add(new ArrayList<>(path));
if (startindex >= nums.length){
return;
}
for (int i = startindex; i < nums.length; i++) {
if (i >= 1 && nums[i] == nums[i-1] && !used[i-1]){
continue;
}else {
path.add(nums[i]);
used[i] = true;
backstring(nums,i+1,used,path,res);
used[i] = false;
path.remove(path.size()-1);
}
}
}
}
这里结束条件也是可以除去的。
13、递增子序列
给定一个整型数组, 你的任务是找到所有该数组的递增子序列,递增子序列的长度至少是2。
示例:
- 输入: [4, 6, 7, 7]
- 输出: [[4, 6], [4, 7], [4, 6, 7], [4, 6, 7, 7], [6, 7], [6, 7, 7], [7,7], [4,7,7]]
说明:
- 给定数组的长度不会超过15。
- 数组中的整数范围是 [-100,100]。
- 给定数组中可能包含重复数字,相等的数字应该被视为递增的一种情况。
在上一篇中子集II中我们通过排序使用标记数组去标记在同一层上是否使用。这题很明显在同一层上也需要去重,否则就会得 到重复的集合。
而本题求自增子序列,是不能对原数组进行排序的,排完序的数组都是自增子序列了。
所以不能使用之前的去重逻辑!
为了有鲜明的对比,我用[4, 7, 6, 7]这个数组来举例,抽象为树形结构如图:
本题求子序列,很明显一个元素不能重复使用,所以需要startIndex,调整下一层递归的起始位置。
本题其实类似求子集问题,也是要遍历树形结构找每一个节点,所以和回溯算法:求子集问题!一样,可以不加终止条件,startIndex每次都会加1,并不会无限递归。
但本题收集结果有所不同,题目要求递增子序列大小至少为2。
public List<List<Integer>> findSubsequences(int[] nums) {
//路径集合
List<Integer> path = new ArrayList<>();
List<List<Integer>> res = new ArrayList<>();
backstring(nums,0,path,res);
System.out.println(res);
return res;
}
private void backstring(int[] nums,int startindex,List<Integer>path,List<List<Integer>> res){
if(path.size() >= 2) res.add(new ArrayList<>(path));
Set<Integer> used = new HashSet<>();
for (int i = startindex; i < nums.length; i++) {
if (!path.isEmpty() && nums[i] < path.get(path.size()-1) || !used.add(nums[i])){
continue;
}
path.add(nums[i]);
backstring(nums,i+1,path,res);
path.remove(path.size()-1);
}
}
- 优化可以考虑使用数组做哈希桶效率会更高。
14、全排列
给定一个不含重复数字的数组 nums
,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
提示:
1 <= nums.length <= 6
-10 <= nums[i] <= 10
nums
中的所有整数 互不相同
这一题一看和之前的好像有点相似,好像每一个元素都可以取到,是否还记得前面有个startIndex,去掉这个是不是每一层上每一个元素都会取到了呢?对的没错,但是这样就会出现[1,1,1],[1,2,2],[3,3,3]等情况?那怎么办呢?那得去重啊,对就是这样的。之前我们有说过树枝去重和树层去重,这里是树枝去重,我们使用一个use数组来标记。
-
回溯三部曲
-
递归参数
首先排列是有序的,也就是说 [1,2] 和 [2,1] 是两个集合,这和之前分析的子集以及组合所不同的地方。
可以看出元素1在[1,2]中已经使用过了,但是在[2,1]中还要在使用一次1,所以处理排列问题就不用使用startIndex了。
-
-
递归终止条件
从car大哥的图中可以看出,所有结果都产生在叶子结点的地方。
当收集元素的数组path的大小达到和nums数组一样大的时候,说明找到了一个全排列,也表示到达了叶子节点。
- 单层搜索的逻辑
如元素1在[1,2]中已经使用过了,但是在[2,1]中还要再使用一次1。
而used数组,其实就是记录此时path里都有哪些元素使用了,一个排列里一个元素只能使用一次。
public List<List<Integer>> permute(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
LinkedList path = new LinkedList();
boolean[] used = new boolean[nums.length];
backtracking(nums,res,path,used);
return res;
}
private void backtracking(int[] nums, List<List<Integer>> res, LinkedList path, boolean[] used) {
if (path.size() == nums.length){
res.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < nums.length; i++) {
//已经用过的元素直接跳过
if (used[i]){
continue;
}
path.add(nums[i]);
used[i] = true;
backtracking(nums,res,path,used);
path.removeLast();
used[i] = false;
}
}
15、全排列II
这题和上一题全排列有相似的地方,但是又有点略有不同。上一题全排列我们做的是树枝去重,防止出现第一个取过了,第二次还取到它。
回溯的去重不过是两种,一种是同一层上的去重,一种是路径上的去重。
还要强调的是去重一定要对元素进行排序,这样我们才方便通过相邻的节点来判断是否重复使用了。
树枝上的我们只要判断当前元素使否使用过,使用过就跳过就行。
一般来说组合问题和排列问题都是在树形结构的叶子结点上收集结果,而子集问题就是去树上的每一节点上的结果。
class Solution {
public List<List<Integer>> permuteUnique(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
boolean[] used = new boolean[nums.length];
//树层去重必须是有序的 否者去除不干净
Arrays.sort(nums);
backtracking(nums,path,res,used);
return res;
}
private void backtracking(int[] nums,LinkedList<Integer> path,List<List<Integer>> res,boolean[] used){
if (path.size() == nums.length){
res.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < nums.length; i++) {
// 树枝去重(同一个元素不能出现多次) 树层去重
if (used[i] || i >= 1 && nums[i] == nums[i-1] && !used[i-1] ){
continue;
}
//树枝去重
// if (used[i]){
// continue;
// }
path.add(nums[i]);
used[i] = true;
backtracking(nums, path, res, used);
used[i] = false;
path.removeLast();
}
}
}
对于排列问题,树层上去重和树枝上去重,都是可以的,但是树层上去重效率更高
16、N皇后问题
盼星星盼月亮盼到终于盼来了传说中的N皇后问题。
按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。
n 皇后问题 研究的是如何将 n
个皇后放置在 n×n
的棋盘上,并且使皇后彼此之间不能相互攻击。
来我们分析一下皇后们的约束条件:
- 不在同行
- 不在同列
- 不在45°对角线上
- 不在135°对角线上
- 算法设计
使用一个 char[][] chessboard
数组存放棋盘,没有皇后的位置用’.‘来表示,初始默认全为’.',即是没有皇后。
题目要求返回数据结构 List<List<String>>
,我们需要转换,由于不好直接操作二维List,我们需要一个辅助数组。
public List<List<String>> solveNQueens(int n)
- 确定回溯参数
需要一个 List<List<String>>
变量voc,辅助棋盘数组,皇后数量n,当前所在行row。
private void backtacking(List<List<String>> voc,char[][] chessboard,int n,int row)
- 确定好参数后,分析皇后所在位置,画出树形结构。
对于一个热议一个位置的皇后,我们判断皇后的约束条件,只需要在当前皇后所在位置上往上判断,往下没有意义。因为下一层还没有放皇后(全是点)。
只要往这三个方向加上约束即可。往下对于当前来说是不必要的。
- 验证皇后是否合法
private boolean isValid(char[][] chessboard,int n,int col,int row){
//检查列
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 <= n - 1 ; i--,j++) {
if (chessboard[i][j] == 'Q'){
return false;
}
}
//检查135°
for (int i = row - 1, j = col - 1; i >= 0 && j >= 0 ; i--,j--) {
if (chessboard[i][j] == 'Q'){
return false;
}
}
return true;
}
三皇后是无解的,但是由此可以看出,只要在n个皇后放了就完成,结束递归。
用皇后们的约束条件,来回溯搜索这棵树,只要搜索到了树的叶子节点,说明就找到了皇后们的合理位置了
- 单层搜索逻辑
递归深度就是row控制棋盘的行,每一层里for循环的col控制棋盘的列,一行一列,确定了放置皇后的位置。
每次都是要从新的一行的起始位置开始搜,所以都是从0开始。
- 完整Java代码
class Solution {
public List<List<String>> solveNQueens(int n) {
List<List<String>> voc = new ArrayList<>(n);
char[][] chessboard = new char[n][n];
for (char[] c: chessboard
) {
Arrays.fill(c,'.');
}
backtacking(voc,chessboard,n,0);
System.out.println(voc);
return voc;
}
private void backtacking(List<List<String>> voc,char[][] chessboard,int n,int row){
//终止条件
if (row == n){
List<String> list = new ArrayList<>();
for (char[] c : chessboard
) {
list.add(String.copyValueOf(c));
}
voc.add(list);
return;
}
//单层处理逻辑
for (int col = 0; col < n; col++) {
if (isValid(chessboard,n,col,row)){
chessboard[row][col] = 'Q';
backtacking(voc,chessboard,n,row+1);
chessboard[row][col] = '.';
}
}
}
private boolean isValid(char[][] chessboard,int n,int col,int row){
//检查列
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 <= n - 1 ; i--,j++) {
if (chessboard[i][j] == 'Q'){
return false;
}
}
//检查135°
for (int i = row - 1, j = col - 1; i >= 0 && j >= 0 ; i--,j--) {
if (chessboard[i][j] == 'Q'){
return false;
}
}
return true;
}
}
经过一段时间的学习,进行的回溯算法总结,虽然过程带着艰辛,我感觉这种感觉很棒!加油!