前言
本专题主讲回溯。
回溯算法个人总结
参考了很多网上的教程,首先是该算法的代码模板总结如下:
代码模板
//回溯算法框架
List<Value> result;
void backtrack(路径,选择列表){
if(满足结束条件){
result.add(路径);
return;
}
for(选择 : 选择列表){
做选择;
backtrack(路径,选择列表);
撤销选择;
}
}
这样的伪代码看着肯定是很难理解的,待会儿我们会用一个例题来进行解释。在这之前我们得明白一个东西就是该算法的适用场景是什么。
算法适用场景
怎么理解回溯算法呢?卡尔大佬的总结已经说的很清楚了,中心思想就是:
回溯法解决的问题都可以抽象为树形结构,因为回溯法解决的都是在集合中递归查找子集的问题,集合的大小就构成了树的宽度,递归的深度就构成了树的深度。
即:看成一颗N叉树来遍历解决。
看到了LeetCode大佬的一个解释,有点东西,贴这儿了:
用一个例题来解释:
44. 全排列(回溯算法示范例题解析)
题目描述:
先给出AC代码,再来分析其思路和过程。
代码如下:
class Solution {
//创建最终结果集
List<List<Integer>> res = new ArrayList<>();
//创建单一结果集
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> permute(int[] nums) {
//调用递归函数,一般叫backtrack(回溯)
backtrack(nums);
//经过回溯处理res中已经拿到了全部的答案
//直接返回结果
return res;
}
//递归函数
public void backtrack(int[] nums){
//判断递归终止条件
//在本题中,终止条件明显是当path单一结果集合的长度
//与我们要求的全排列集合长度一样时,就可添加进最终结果集合res
//然后终止函数即可
if(nums.length == path.size()){
res.add(new ArrayList<>(path));
return;
}
//for循环在nums母集合中不断重复穷举各个情况(横向搜索)
for(int i=0; i<nums.length; i++){
//如果path集合中已经包含了当前数字,那么我们就跳过添加它
//以避免结果中出现重复的数字
if(path.contains(nums[i])){
continue;
}
//否则我们将其添加到路径集合中
path.add(nums[i]);
//递归向深处搜索
backtrack(nums);
//回溯时删除之前存入的值
path.removeLast();
}
}
}
思路和过程分析:
怎么样,是不是看起来和上面给的代码框架简直是大同小异?
我们来分析这段代码的过程,分析过程采用了B站一个UP主的视频讲解,下面的图中有其公众号水印。
之前已经说过我们会将排列组合问题看成是N叉树的问题,所以我们会想到如下图形(以题目中的第一个例子[1,2,3]为例):
可以看出,从这棵树的根节点一直到其中一个叶子节点所经过的路径其实就是我们题目所要求的一个满足条件的结果。
我们先把1固定下来,那么接下来我们就去固定第二位,如果固定为2那么第三位就该固定为3,这就是一个符合条件的结果。我们反复进行穷举就可以拿到所有的结果集合。
只要我们遍历到某一个叶子节点时,我们就记录下来这条从根节点到该叶子节点的路径即可。
我们来模拟这个过程(下列左图是该大佬的代码,都差不多):
一开始track(也就是单一结果集合,它用来收集这条路径上的所有数字)变量是个空列表,然后我们一步一步的往后遍历:
直到遍历到某一叶子节点,然后就会触发我们的BaseCase,即:
//判断递归终止条件
//在本题中,终止条件明显是当path单一结果集合的长度
//与我们要求的全排列集合长度一样时,就可添加进最终结果集合res
//然后终止函数即可
if(nums.length == path.size()){
res.add(new ArrayList<>(path));
return;
}
说明满足条件,我们就将track集合添加到最终的结果集合res中。
添加完之后return,现在backtrack就往回走了:
往回走这个动作就是回溯,因为我们会穷举所有可能的结果集合,所以我们回溯时会将3删除,对应代码中的部分:
//for循环在nums母集合中不断重复穷举各个情况(横向搜索)
for(int i=0; i<nums.length; i++){
//如果path集合中已经包含了当前数字,那么我们就跳过添加它
//以避免结果中出现重复的数字
if(path.contains(nums[i])){
continue;
}
//否则我们将其添加到路径集合中
path.add(nums[i]);
//递归向深处搜索
backtrack(nums);
//回溯时删除之前存入的值
path.removeLast();
}
删除了3还不够,track还会继续回溯,删除2:
然后不断重复这个过程,就会穷尽所有的情况,并从中拿到我们想要的结果集合。
一定得自己去debug一波这个过程,才会有更加清晰的认识,才会对递归和for循环结合的方式有更加深刻的认知。
在这个题中因为存在的几个递归函数中第一个递归函数中的for循环的作用其实就是控制我们的第一位数字,就像上面说的假如我们控制第一位为1,那么剩余两位数字就会在剩下几个递归函数的for循环中穷举,当第一个递归函数中的for循环来到nums的第二个值时,就意味着此时我们将第一位固定为2,然后在剩下的几个递归函数的for循环中进行穷举第二位和第三位的数字。
又因为每调用一个递归函数时for循环都是从下标为0开始遍历数组的,所以我们会重复取每一个值,这样就可以拿到这三个数字的所有排列情况。
77. 组合
题目描述:
思路分析:
我们画图分析,结合着下面的代码来看。
我们以题目给的一个例子为例,当n=4,k=2的情况:
模板中的for循环一般用来遍历母集合,因为我们求的子集合都是来自该集合的,然后递归则有点类似于DFS深度优先搜索,即往深处递归搜索。当我们第一次调用递归函数时,画图分析如下:
如上图所示,与代码中相对应的部分如下:
//然后对集合进行选择,筛选出我们需要的结果
//本题范围是[1,n],所以我们像下面这样写
for(int i=index; i<=n; i++){
//否则就是符合条件的,我们将其加入到单一结果集中
path.add(i);
//递归处理
backtrack(n,k,i+1);
//回溯处理,当返回上一函数时,我们就可以将用过的组合删掉最后一个
path.removeLast();
}
在path单一结果集合中加入了第一个值1后,根据代码可知然后我们就会调用第二个递归函数,此时第一个for循环内还剩2,3,4没有遍历,但因为调用了第二个递归函数所以第一个递归函数就停滞了(因为第二个递归函数还没有结束),因为我们传入的是i+1这个值。
注意这个点很重要,这里不能传index+1必须得想清楚嗷,不一样的,因为当本次循环中的递归函数结束后for循环中i++继续循环时,又会再次调用一个递归函数,此时index值是不会变的,依然是之前的index的值,那么这次递归函数中所要遍历的集合就会产生重复了,那肯定就是错的。所以必须要传i+1来控制,因为 i 在循环中是一直变化的,可以帮助我们去遍历其它的可能性。
然后就成了下面这个样子:
然后现在在第二个递归函数中的for循环中触发了第三个递归函数,在第三个递归函数触发了递归函数终止条件了,对应的代码如下:
if(path.size() == k){
//添加结果集(一般用ArrayList,大佬说的,效率会高些)
res.add(new ArrayList<>(path));
//终止递归
return;
}
因为path.size()k了,所以第二个递归函数结束,此时会返回第二个递归函数中的for循环,此时 i2 ,然后下一条语句就是删除队列尾部的第一个值,此时path单一结果集中只剩1了,这里就是回溯的一个过程(图中for(1)表示第一个递归函数的for循环,for(2)表示第二个函数的for循环):
因为是for循环嘛,所以第二个函数会继续循环,i++,此时i=3(因为原来是2),所以根据代码我们又会添加3进入单一结果集合path中,如图:
此时path中含有1,3数字,此时又会进入一个递归函数,因为又触发了终止条件所以又返回又删除,整个求解的过程就是不断反复这个过程,递归与for循环结合起来就是上面这个样子,for循环不断在纵向穷尽可能性,而递归则是不断往深处穷尽可能性,最后完整的过程就跟卡尔代码随想录中的图一样:
看起来是不是就跟一棵N叉树一样?这个过程其实自己去思考一下就能想明白的,加油伙计们!
代码如下:
class Solution {
//回溯问题我们一般需要定义两个集合
List<List<Integer>> res = new ArrayList<>();//最终的结果集合
LinkedList<Integer> path = new LinkedList<>();//单一结果集合
public List<List<Integer>> combine(int n, int k) {
//因为我们求的是在范围[1,n]之间的有序组合
//那么我们需要用一个变量来控制下标,该下标应该从1开始
//调用递归函数
backtrack(n,k,1);
//返回res结果集
return res;
}
//递归函数
public void backtrack(int n,int k,int index){
//终止条件,即看成遍历到N叉树的叶子节点即结束
//这个要依题目来解决
//本题的叶子节点就可以抽象为当path单一结果集合长度为k时
//就可以将其加入到res最终结果集合中了,然后终止递归
if(path.size() == k){
//添加结果集(一般用ArrayList,大佬说的,效率会高些)
res.add(new ArrayList<>(path));
//终止递归
return;
}
//然后对集合进行选择,筛选出我们需要的结果
//本题范围是[1,n],所以我们像下面这样写
for(int i=index; i<=n; i++){
//否则就是符合条件的,我们将其加入到单一结果集中
path.add(i);
//递归处理
backtrack(n,k,i+1);
//回溯处理,当返回上一函数时,我们就可以将用过的组合删掉最后一个
path.removeLast();
}
}
}
216. 组合总和 |||
题目描述:
思路分析:
用模板就可以解决,唯一不同的就是对于叶子节点的判断条件,即递归终止的判断条件。
代码如下:
class Solution {
//组合问题一样的解法
List<List<Integer>> res = new ArrayList<>();//最终结果集
LinkedList<Integer> path = new LinkedList<>();//单一结果集(路径集合)
public List<List<Integer>> combinationSum3(int k, int n) {
//调用递归函数
//如果是不允许重复的情况,我们一般会用一个index下标来解决
backtrack(k,n,1);
//返回最终结果集
return res;
}
//递归函数
public void backtrack(int k,int n,int index){
//找出单一结果集可以加入到最终结果集的递归终止条件
//本题中是path集合长度为k的数相加能得到n
if(path.size() == k){//是否有k个数
//循环求path中的值是否为n
int sum = 0;
for(int i=0; i<k; i++){
sum = sum + path.get(i);
}
if(sum == n) {
res.add(new ArrayList<>(path));
}
//终止递归
return;
}
//本题的母集合为[1,9]
for(int i=index; i<=9; i++){
path.add(i);
backtrack(k,n,i+1);
path.removeLast();
}
}
}
17. 电话号码的字母组合
题目描述:
思路分析:
分析题目可以得到纵向递归的深度长为digits的长度,宽度则为一个数字对应字符串的长度,然后巧用一个index变量来指向下一个字符串,这样可使得字符串与字符串之间进行组合匹配。
用一个变量index可以巧妙的解决很多组合问题,需要细细品味。
代码如下:
class Solution {
//技巧一:数字字母映射关系用一个数组来映射
//也不一定要用Hash,哈希处理起来很麻烦
String[] map = {
"",//0
"",//1
"abc",//2
"def",//3
"ghi",//4
"jkl",//5
"mno",//6
"pqrs",//7
"tuv",//8
"wxyz"//9
};
List<String> res = new ArrayList<>();//结果集合
LinkedList<Character> path = new LinkedList<>();//单一结果集合
public List<String> letterCombinations(String digits) {
//判空
if(digits.length() == 0) return res;
//调用递归函数
backtrack(digits,0);
//返回结果
return res;
}
//递归函数
public void backtrack(String digits,int index){
//判断递归终止条件
//输入几个数字就有集合就为多长
//比如"23"即结果长度都为2
if(path.size() == digits.length()){
//加入至结果集(一般用StringBuilder)
StringBuilder sb = new StringBuilder();
for(int i=0; i<path.size(); i++){
sb.append(path.get(i));
}
res.add(sb.toString());
//终止返回
return;
}
//技巧二:将index指向的数字转化为int
//用字符数字减去字符0就可以得到整形型数字
int digit = digits.charAt(index) - '0';
String letters = map[digit]; //拿到对应数字的字符串,如2就是拿到abc
//循环处理了
for(int i=0; i<letters.length(); i++){
path.add(letters.charAt(i));
//技巧三:用index来进行下一次循环找第二个字符串
backtrack(digits,index+1);
path.removeLast();
}
}
}
39. 组合总和
题目描述:
思路分析:
直接看卡尔大佬的B站视频就行。
代码如下:
class Solution {
//创建结果集合
List<List<Integer>> res = new ArrayList<>();
//单一结果集合
LinkedList<Integer> path = new LinkedList<>();
//创建累加和计数器,也声明为全局变量
int sum = 0;
public List<List<Integer>> combinationSum(int[] candidates, int target){
//调用递归函数
//组合问题一般都会有需要一个index来辅助遍历
//如果是一个集合来求组合的话,就需要index
//如果是多个集合取组合,各个集合之间相互不影响,那么就不用index
backtrack(candidates,target,0);
//返回结果
return res;
}
//递归函数
public void backtrack(int[] candidates, int target,int index){
//判断递归终止条件
if(sum == target){
res.add(new ArrayList<>(path));
return;
}else if(sum > target) {//大于情况也可以终止了
return;
}
//遍历数组
for(int i=index; i<candidates.length; i++){
sum += candidates[i];
path.add(candidates[i]);
backtrack(candidates,target,i);//不用i+1,表示可重复选取
sum -= candidates[i];//回溯删除
path.removeLast();//回溯删除
}
}
}
40. 组合总和 ||
题目描述:
思路分析:
这个和母题的区别就是,
1、本题candidates 中的每个数字在每个组合中只能使用一次。
2、本题数组candidates的元素是有重复的,而39.组合总和是无重复元素的数组candidates。
这里对去重的解释我就直接贴卡尔大佬的分析了:
什么意思?举个例子,以candidates[1,1,2],target=3为例,(方便起见,这里已经对candidates排过序了)。
强调:树层去重,一定要先对数组进行排序!!!
从图中其实显而易见,如果是在树层上重复了的话,给出的答案就会出现[1,2],[1,2],因为母集合是[1,1,2],按照我们之前的说法其实就是在第一层for循环时不能允许遇见重复的值,否则最后返回的结果中一定会出现重复的,而树枝上即纵向上的集合中有重复是没事的,即元素同一个组合内怎么重复都没事,但组合与组合之间不能有重复。
所以现在有一个好理解的方法是,我们可以用一个布尔型的used数组来记录树层横向上是否有重复值,如果有那么我们就跳过加入结果集的操作以避免出现重复集合,否则就加入结果集合。
结合上面的图其实自己人脑debug一下其实就很好明白为什么这么写了。
代码如下:
class Solution {
//创建最终结果集合
List<List<Integer>> res = new ArrayList<>();
//创建单一结果集合
LinkedList<Integer> path = new LinkedList<>();
//创建结果累加器
int sum = 0;
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
//对树层去重之前必须先排序
Arrays.sort(candidates);
//用一个布尔型的used数组来标志是否重复了
boolean[] used = new boolean[candidates.length];
//调用递归函数
backtrace(candidates,target,0,used);
//返回结果
return res;
}
//递归函数
public void backtrace(int[] candidates, int target,int index,boolean[] used){
//递归终止条件
//如果sum与target相等了,返回
if(sum == target){
res.add(new ArrayList<>(path));
return;
}else if(sum > target){//sum大于了target的话,继续下去也没有意义,返回
return;
}
//for循环遍历母数组
for(int i=index; i<candidates.length; i++){
//used[i-1] == true,说明同一树枝candindates[i-1]使用过
//used[i-1] == false,说明同一树层candindates[i-1]使用过
if(i > 0 && candidates[i] == candidates[i-1] && used[i-1] == false){
continue;
}
sum += candidates[i];
path.add(candidates[i]);
used[i] = true;
backtrace(candidates,target,i+1,used);//i+1可以不重复的取值
used[i] = false;
sum -= candidates[i]; //回溯
path.removeLast();//回溯
}
}
}
131. 分割回文串(难)
题目描述:
思路分析:
看随想录中的解释叭…我也还不是很明白。
代码如下:
class Solution {
//最终结果集
List<List<String>> res = new ArrayList<>();
//单一结果集
LinkedList<String> path = new LinkedList<>();
public List<List<String>> partition(String s) {
//调用递归函数
backtrack(s,0);
//返回结果集
return res;
}
//递归函数
public void backtrack(String s,int index){
//递归终止条件
//如果起始位置已经大于等于s的大小,说明已经找到一组分割方案了
//s在不断的递归当中其实宽度越来越小
if(index >= s.length()){
res.add(new ArrayList<>(path));//添加结果集
return;//终止函数
}
for(int i=index; i<s.length(); i++){
//因为定义了起始位置index,那么[index,i]就是我们要截取的子串
if(isPalindrome(s,index,i)){//如果是回文串
//那么拿到[index,i]在s中的子串添加进结果集中
String str = s.substring(index,i+1);
path.add(str);
}else{
continue;
}
//起始位置后移,保证不重复
backtrack(s,i + 1);
path.removeLast();
}
}
//判断是否是回文子串
public boolean isPalindrome(String s,int left,int right){
while(left <= right){
if(s.charAt(left) != s.charAt(right)){
return false;
}
left++;
right--;
}
return true;
}
}
93. 复原IP地址(难,有字符串处理技巧)
题目描述:
思路分析:
都在代码中了,我也是看着卡尔大佬的题解写的,不是特别明白…
有个字符串处理技巧,就是substring方法:
当传入一个参数时,表示截取从索引位置开始到结束;
当传入两个参数时,表示截取从第一个索引位置开始到第二个索引位置为止,包含第一个索引位置不包含第二个索引位置;
这个技巧在下面的代码中有所体现。
代码如下:
class Solution {
//分割问题也是组合问题
//创建最终结果集合
List<String> res = new ArrayList<>();
public List<String> restoreIpAddresses(String s) {
//调用递归函数
backtrack(s,0,0);
//返回结果集
return res;
}
//递归函数
//startIndex是字符串搜索的起始位置,pointNum是添加的逗号数量
public void backtrack(String s,int startIndex,int pointNum){
//递归终止条件
//当有三个点了之后,说明已经有了一段需要判断的字符串了,分割已经结束
if(pointNum == 3){
//前面三个字符串都是判断过是满足要求的了
//现在我们就只需要判断一下第四段是否满足情况就可以返回了
if(isValid(s,startIndex,s.length()-1)){
res.add(s);
}
//不论是否满足情况,都应该终止函数返回了
return;
}
//横向穷尽遍历
for(int i=startIndex; i<s.length(); i++){
//判断[startIndex,i]这个区间内的子串是否合法
if(isValid(s,startIndex,i)){
//在str的后面插入一个逗点
s = s.substring(0,i+1)+'.'+s.substring(i+1);
pointNum++;
//插入逗点之后下一个子串的起始位置为i+2(因为有个逗点占了一位)
backtrack(s,i+2,pointNum);
pointNum--;//回溯
s = s.substring(0,i+1)+s.substring(i+2);//回溯删除逗点
}else break;
}
}
//每进行一次切割,都判断其是否有效
//left是s串的起始位置,right是s串的结束位置
//判断字符串在左闭右闭区间[left,right]所组成的数字是否合法
public boolean isValid(String s,int left,int right){
//因为字符串的长度每次会增加一位,但是分割线要后移两位,
//这就存在越界的问题。正常的递归中,有for作判断
//而递归终止开始回溯的时候,因为逗点要删除就导致字符串长度有所减小
//此时就可能产生越界,这个时候就是靠这个if来做越界的判断
if(left > right) return false;
//0开头的数字不合法,但如果只有一个数字为0的话是合法的
if(s.charAt(left) == '0' && left != right) return false;
int num = 0;//累加器,用来判断传进来的字符串是否大于255
for(int i=left; i<=right; i++){
//非法数字不合法
if(s.charAt(i) > '9' || s.charAt(i) < '0') return false;
num = num * 10 + (s.charAt(i) - '0');
//如果大于了255,非法返回false
if(num > 255) return false;
}
//上面的情况都不是,就是有效的
return true;
}
}
78. 子集
题目描述:
思路分析:
救命…这终于是一个我不用看题解就会做的题目了,我们只需要知道一个数学知识就是:
一个长度为n的数列的子集个数为2*n个就可以了,这是我们终止递归的条件。
然后就是一直穷举就行了,真的是最简单的了这个专题中,好幸福…
离奇代码如下(这是我自己歪打正着写对的,可以击败100%):
class Solution {
//创建最终结果集
List<List<Integer>> res = new ArrayList<>();
//创建单一结果集
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> subsets(int[] nums) {
//调用递归函数
backtrack(nums,0);
//返回最终结果集合
return res;
}
//递归函数
public void backtrack(int[] nums,int index){
//递归终止条件
//一个数学问题,一个长度为n的数列的子集总共为2*n个
//所以当所有情况都遍历完之后就可以终止递归函数了
//这是一个美妙的巧合...我本来打算写2的nums.length的次方的
//结果写错了歪打正着代码竟然可以过...
if(path.size() == 2 * nums.length){
return;//终止递归函数
}
//不需要什么条件,所有的情况都需要添加进最终结果集
//包括一开始path什么都没有,为空集的情况
res.add(new ArrayList<>(path));
//for循环进行穷举
for(int i=index; i<nums.length; i++){
path.add(nums[i]);
backtrack(nums,i+1);
path.removeLast();
}
}
}
下面这是正常写法:
class Solution {
//创建最终结果集
List<List<Integer>> res = new ArrayList<>();
//创建单一结果集
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> subsets(int[] nums) {
//调用递归函数
backtrack(nums,0);
//返回最终结果集合
return res;
}
//递归函数
public void backtrack(int[] nums,int index){
//不需要什么条件,所有的情况都需要添加进最终结果集
//包括一开始path什么都没有,为空集的情况
res.add(new ArrayList<>(path));
//递归终止条件
//当index>=了nums的长度时,就可以返回了,否则就要越界了
if(index >= nums.length){
return;//终止递归函数
}
//for循环进行穷举
for(int i=index; i<nums.length; i++){
path.add(nums[i]);
backtrack(nums,i+1);
path.removeLast();
}
}
}
90. 子集 ||
题目描述:
思路分析:
就是多一个在树层上去重的步骤,去重之前说过,这里就不说了。
代码如下:
class Solution {
//创建最终结果集
List<List<Integer>> res = new ArrayList<>();
//创建单一结果集
LinkedList<Integer> path = new LinkedList<>();
//布尔型数组用来标志是否重复
boolean[] used;
public List<List<Integer>> subsetsWithDup(int[] nums) {
//去重之前一定要先排序
Arrays.sort(nums);
//初始化used数组
used = new boolean[nums.length];
//调用递归函数
backtrack(nums,0);
//返回最终结果集合
return res;
}
//递归函数
public void backtrack(int[] nums,int index){
//一进来就加入最终结果集,因为空集也要加入
res.add(new ArrayList<>(path));
//递归终止条件
if(index >= nums.length){//下标位置都大于等于了nums长度了,就是极限情况
return;
}
//for循环进行穷举
for(int i=index; i<nums.length; i++){
//如果发现重复了就跳过加入集合的步骤
if(i>0 && nums[i] == nums[i - 1] && used[i - 1] == false){
continue;
}
path.add(nums[i]);
used[i] = true;//已经加入过,设置为true
backtrack(nums,i+1);
used[i] = false;//回溯,树枝上的重复不算重复,树层上重复才算重复
path.removeLast();
}
}
}
491. 递增子序列
题目描述:
思路分析:
每一个递归函数中的for循环其实都代表一个树层的遍历,即会遍历从i起始位置到数组结束位置的所有可能性,并逐一添加。在理解这一点的基础上配合代码随想录中的图解,其实很好明白了。这题搬不能使用之前的去重逻辑,因为不能排序(题目要求返回升序序列),所以我们选择用数组哈希的方式进行去重。
代码如下:
class Solution {
//经典组合问题,记得要去重
//最终结果集
List<List<Integer>> res = new ArrayList<>();
//单一结果集
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> findSubsequences(int[] nums) {
//调用递归函数
backtrack(nums,0);
//返回最终结果集
return res;
}
//递归函数
public void backtrack(int[] nums,int index){
//只要path集合中有两个元素以上,我们就添加进最终结果集
//怎么知道其已经升序了呢?我们在for循环中做了比较之后才加入集合的
if(path.size() > 1) res.add(new ArrayList<>(path));
//这里使用数组来进行去重操作,因为题目说了数值范围[-100,100]
int[] used = new int[201];
for(int i=index; i<nums.length; i++){
//如果path不为空并且path中的最后一个值大于nums[i](因为要按升序返回集合)
//或者是当前树层中nums[i] == 1即已经出现过
//那么我们就跳过加入path集合的步骤
if(!path.isEmpty() && nums[i] < path.get(path.size() - 1) || (used[nums[i] + 100] == 1)) continue;
//否则加入集合
path.add(nums[i]);
//用nums[i]+100是为了表示负数
used[nums[i] + 100] = 1; //记录这个数据在本层已经出现过了,不能再用了
backtrack(nums,i+1);
path.removeLast();
}
}
}
47. 全排列 ||
题目描述:
思路分析:
大题思路和之前做的都是差不多的。
代码如下:
class Solution {
//排列组合问题
//最终结果集合
LinkedList<List<Integer>> res = new LinkedList<>();
//单一结果集
LinkedList<Integer> path = new LinkedList<>();
//为了穷尽所有排列集合而不被因为数字重复就跳过一个可能答案
//我们选择用一个数组哈希来表示数字是否重复了
boolean[] used = null;
public List<List<Integer>> permuteUnique(int[] nums) {
//先排序
Arrays.sort(nums);
used = new boolean[nums.length];
//调用递归函数
backtrack(nums);
//返回最终结果集
return res;
}
//递归函数
public void backtrack(int[] nums){
//递归终止条件
//和母题一样的,只要path集合长度与nums长度相同即可返回结果了
if(path.size() == nums.length){
res.add(new ArrayList<>(path));
return;
}
//for循环穷尽遍历
for(int i=0; i<nums.length; i++){
//如果集合中已经出现过这个元素且它已经之前出现过了
//我们就跳过入值的操作
if(i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false){
continue;
}
if(used[i] == false){
path.add(nums[i]);
used[i] = true;
backtrack(nums);
used[i] = false;
path.removeLast();
}
}
}
}
上述代码中used[i - 1] == true也是对的,如果要对树层中前一位去重,就用used[i - 1] == false,如果要对树枝前一位去重用used[i - 1] == true。
332. 重新安排行程(困难)
题目描述:
思路分析:
困难题目…反正我自己要求没那么高,试着理解叭…以后万一遇见这种题目也算能多个思路,还能干想一下(估计真出了这种也做不出来…- _ - ||)。
代码如下:
class Solution {
private Deque<String> res;
private Map<String, Map<String, Integer>> map;
private boolean backTracking(int ticketNum){
if(res.size() == ticketNum + 1){
return true;
}
String last = res.getLast();
if(map.containsKey(last)){//防止出现null
for(Map.Entry<String, Integer> target : map.get(last).entrySet()){
int count = target.getValue();
if(count > 0){
res.add(target.getKey());
target.setValue(count - 1);
if(backTracking(ticketNum)) return true;
res.removeLast();
target.setValue(count);
}
}
}
return false;
}
public List<String> findItinerary(List<List<String>> tickets) {
map = new HashMap<String, Map<String, Integer>>();
res = new LinkedList<>();
for(List<String> t : tickets){
Map<String, Integer> temp;
if(map.containsKey(t.get(0))){
temp = map.get(t.get(0));
temp.put(t.get(1), temp.getOrDefault(t.get(1), 0) + 1);
}else{
temp = new TreeMap<>();//升序Map
temp.put(t.get(1), 1);
}
map.put(t.get(0), temp);
}
res.add("JFK");
backTracking(tickets.size());
return new ArrayList<>(res);
}
}
51. 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){
//Array2List是我们自己写的函数,用于转化为题目需要的形式
res.add(Array2List(chessboard));
return;
}
//for循环穷尽遍历,也就是遍历列数
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){
//String的静态方法copyValueOf可以直接使字符数组直接转化为字符串
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;
}
}
//上面情况都不是,返回true,当前位置为合法位置
return true;
}
}
37. 解数独(困难)
题目描述:
思路分析:
看卡尔大佬的题解叭,这题太难了,我选择复制粘贴放弃! (-_-||)…
代码如下:
class Solution {
public void solveSudoku(char[][] board) {
solveSudokuHelper(board);
}
private boolean solveSudokuHelper(char[][] board){
//「一个for循环遍历棋盘的行,一个for循环遍历棋盘的列,
// 一行一列确定下来之后,递归遍历这个位置放9个数字的可能性!」
for (int i = 0; i < 9; i++){ // 遍历行
for (int j = 0; j < 9; j++){ // 遍历列
if (board[i][j] != '.'){ // 跳过原始数字
continue;
}
for (char k = '1'; k <= '9'; k++){ // (i, j) 这个位置放k是否合适
if (isValidSudoku(i, j, k, board)){
board[i][j] = k;
if (solveSudokuHelper(board)){ // 如果找到合适一组立刻返回
return true;
}
board[i][j] = '.';
}
}
// 9个数都试完了,都不行,那么就返回false
return false;
// 因为如果一行一列确定下来了,这里尝试了9个数都不行,说明这个棋盘找不到解决数独问题的解!
// 那么会直接返回, 「这也就是为什么没有终止条件也不会永远填不满棋盘而无限递归下去!」
}
}
// 遍历完没有返回false,说明找到了合适棋盘位置了
return true;
}
/**
* 判断棋盘是否合法有如下三个维度:
* 同行是否重复
* 同列是否重复
* 9宫格里是否重复
*/
private boolean isValidSudoku(int row, int col, char val, char[][] board){
// 同行是否重复
for (int i = 0; i < 9; i++){
if (board[row][i] == val){
return false;
}
}
// 同列是否重复
for (int j = 0; j < 9; j++){
if (board[j][col] == val){
return false;
}
}
// 9宫格里是否重复
int startRow = (row / 3) * 3;
int startCol = (col / 3) * 3;
for (int i = startRow; i < startRow + 3; i++){
for (int j = startCol; j < startCol + 3; j++){
if (board[i][j] == val){
return false;
}
}
}
return true;
}
}
总结
回溯算法结束啦…真难呐艹。