首先介绍一下回溯算法

回溯通常在递归函数中体现,本质也是一种暴力的搜索方法,但可以解决一些用for循环暴力解决不了的问题,其应用有:
1.组合问题:
例:1 2 3 4这些数中找出组合为2的组合,有12(或21也行),13,14,23,24,34这些;
2.切割问题:
例:给一个字符串,要得出指定结果,求解有几种切割方式。
3.子集问题:
例:1 2 3 4这些数的子集:1 2 3 4 12 13 14 23 24 34 123 124 234 1234。
4.排列问题(有顺序要求):如12和21是不一样的
5.棋盘问题:N皇后,解数独

回溯法可以抽象成n叉树,树的深度就是递归的过程,树的宽度就是处理的集合的大小;对于子集问题是针对每个结点收集结果,组合、切割、排列等在叶子结点收集结果,如图(图片来自代码随想录公众号)

其函数一般没有返回值,参数一般较多,可以在需要用到的时候添加;其代码一般形式如下:

void backTracking(参数){if(终止条件){收集结果;return;}for(横向遍历集合元素集){处理结点;backTracking(参数);//回溯撤销处理;//相当于回退到结点的上一层}
}

组合问题

力扣 77. 组合

这个问题可以抽象为下面的树形结构:
n相当于树的宽度,k就相当于树的深度;这棵树上的叶子结点就是我们需要求的结果集合。

针对这题的结果的特征,需要定义两个全局变量list去存放结果:

List<List<Integer>> res=new ArrayList<>();//存放最终结果
List<Integer> path= new ArrayList<>();//记录搜索过程

·回溯三部曲:
1.确定函数返回值和参数
因为定义了全局变量记录搜索过程和最终结果,因此不需要返回值(大部分回溯题不需要返回值),参数需要n和k,以及起始数startIndex,用于指明这一层递归中,要从集合的哪个位置开始遍历,从上图可以看出,在第一层递归的遍历中,可以分别从集合1 2 3 4开始继续遍历,在它们各自的下一层递归中,只能分别从2 3 4处继续遍历,以此类推;

public void backTrack(int n,int k,int startIndex){}

2.确定终止条件
需要判断什么时候到了最下面的叶子结点,由上面所述可知用path记录遍历的过程,如果到了叶子结点,那么path数组大小就是k,所以当path大小等于k时,就需要把这条path加入res中,并且返回到上一层中;需要特别注意。java中的list是引用类型,将path加入res的时候,注意加入的需要是一个新的副本,否则后续若对path进行修改,前面已经加入res的path内容也会被修改。

if(path.size()==k){res.add(new ArrayList<>(path));return;
}

3.单层搜索过程
在for循环中,从i=startIndex开始,遍历到n为止,每次将当前的i加入path,然后调用回溯函数自身,最后在这次中撤销本次加入的i;

for(int i=startIndex;i<=n;i++){path.add(i);backTrack(n,k,i+1);path.remove(path.size()-1);
}

完整代码如下:

class Solution {List<List<Integer>> res=new ArrayList<>();List<Integer> path= new ArrayList<>();public List<List<Integer>> combine(int n, int k) {backTrack(n,k,1);return res;}public void backTrack(int n,int k,int startIndex){if(path.size()==k){res.add(new ArrayList<>(path));return;}for(int i=startIndex;i<=n;i++){path.add(i);backTrack(n,k,i+1);path.remove(path.size()-1);}}
}

但是经过运行发现效率有点低,其实这个算法还可以进行剪枝优化
例如,n=4,k=4的话,在第一层for循环里面,i>=2的情况都不需要遍历了,因为最终需要4个数,如果大于等于2,最多只会有3个数,不可能符合条件,因此需要对i的限制条件进行修改。
①已经搜索过的个数:path.size();
②还需要搜索几个数:k-path.size();
③最多只能从1-n中国的n-(k-path.size())+1处开始搜索,再远了剩下的数就不够满足最终要求的k个数了。

所以i要小于等于n-(k-path.size())+1。
改进代码如下:

class Solution {List<List<Integer>> res=new ArrayList<>();List<Integer> path= new ArrayList<>();public List<List<Integer>> combine(int n, int k) {backTrack(n,k,1);return res;}public void backTrack(int n,int k,int startIndex){if(path.size()==k){res.add(new ArrayList<>(path));return;}for(int i=startIndex;i<=n-(k-path.size())+1;i++){path.add(i);backTrack(n,k,i+1);path.remove(path.size()-1);}}
}

216. 组合总和 III

之所以先做这题而不是这题的两道前置题,是因为其思想、方法和77非常相似。

在回溯函数的终止条件里,不能当path.size()==k时直接将新path加入res中,因为这题还需要符合在抽象二叉树中搜索路径的总和等于n的条件;

if(path.size()==k){if(sum==n)res.add(new ArrayList<>(path));return;
}

在对宽度进行遍历的时候(for循环里),i的限制也应该是<=9;

for(int i=startIndex;i<=9;i++)

完整代码:
里面的sum参数其实可以省略,只要在每层回溯的时候,将回溯函数参数n减去这次遍历到的值,最后终止条件判断n是否等于0即可;

class Solution {List<List<Integer>>res=new ArrayList<>();List<Integer>path=new ArrayList<>();public List<List<Integer>> combinationSum3(int k, int n) {backTrack(k,n,1,0);return res;}public void backTrack(int k,int n,int startIndex,int sum){if(path.size()==k){if(sum==n)res.add(new ArrayList<>(path));return;}for(int i=startIndex;i<=9;i++){path.add(i);sum+=i;backTrack(k,n,i+1,sum);path.remove(path.size()-1);sum-=i;}}
}

同样的,这题也有着其对应的剪枝优化空间,可以从两方面来考虑剪枝:
1.同上题一样,对for循环里的i做进一步限制,排除选取太后面的数,防止最终选的数不够k个;
2.当元素总和超过n,再往后走就没意义了,也可以剪掉;

剪枝优化完整代码如下:

class Solution {List<List<Integer>>res=new ArrayList<>();List<Integer>path=new ArrayList<>();public List<List<Integer>> combinationSum3(int k, int n) {backTrack(k,n,1,0);return res;}public void backTrack(int k,int n,int startIndex,int sum){if(sum>n)return;//剪枝if(path.size()==k){if(sum==n)res.add(new ArrayList<>(path));return;}for(int i=startIndex;i<=9-(k-path.size())+1;i++){path.add(i);sum+=i;backTrack(k,n,i+1,sum);path.remove(path.size()-1);sum-=i;}}
}

力扣 39. 组合总和

这题相比216. 组合总和 III不同之处在于它从candidates数组选取元素,选取的元素是可以重复的。与前几题代码的差别在于:
①不设置sum记录总和,而是直接在回溯传参的时候将target减去candidates[i],这样传递到下一层的target值是减过的值,回溯到本层时target值还是保持不变的;
②回溯传参时用于遍历candidates数组的索引值不+1,这样可以在递归过程中重复选相同元素;
③在剪枝优化方面,使用排序+剪枝的方法(在求和问题中比较常见),先对candidates数组排序,然后在for循环开始进行判断,减去这次的candidates[i]之后target值是否会小于0,如果小于0,直接退出for循环。因为若小于0,代表这条抽象二叉树搜索路径总和已经不符合条件了,而经过排序的candidates数组升序排列,再往后遍历值只会更大;若只是在回溯函数最开始加if(target<0)return;这样在递归时,就算在for循环时已经不符合条件,但是还是会进入递归下一层,直到判断不符合条件才会回退;因此采用排序+剪枝方法。

完整代码如下:

class Solution {List<List<Integer>>res=new ArrayList<>();List<Integer>path=new ArrayList<>();public List<List<Integer>> combinationSum(int[] candidates, int target) {Arrays.sort(candidates);//先对其排序,方便后面剪枝backTrack(candidates,target,0);return res;}public void backTrack(int[]candidates,int target,int startIndex){if(target==0){res.add(new ArrayList<>(path));return;}for(int i=startIndex;i<candidates.length;i++){//剪枝优化,路径总和超出target,终止遍历(配合主函数的排序使用)if(target-candidates[i]<0)break;path.add(candidates[i]);backTrack(candidates,target-candidates[i],i);//不用i+1(可以重复选一个数)path.remove(path.size()-1);}}
}

力扣 40. 组合总和 II

这题与力扣 39. 组合总和区别在于:
①candidates数组元素是有重复的,39题的是无重复元素的;
②candidates 中的每个数字在每个组合中只能使用一次;
③解集不能包含重复的组合;
也就是说,最后求出的组合中,是可以有重复的元素(只要这些重复元素是在candidates中本身就重复出现的),但是两个组合不能有相同。
例如: candidates = [10,1,2,7,6,1,5], target = 8,其中1重复出现了2次;
res=[[1,1,6],[1,2,5],[1,7],[2,6]],但是[7,1],[1,6,1]这样的是不可以再出现在答案中的

所以这题的重点就在于去除重复的组合!

题目要求的是组合不能相同,如下图抽象二叉树搜索过程,如果同一树层(for循环)当前遍历到的元素和前一个元素相同,那它继续往下走,还是有可能走出一条元素相同的搜索路线,如:
candidates = [10,1,2,7,6,1,5], target = 8,
第一个树层:1 1 2 5 6 7 10
res=[[1,1,6],[1,2,5],[1,7],[2,6]]
如果不跳过同一树层前后相同的元素,那么此时
res=[[1,1,6],[1,2,5],[1,7],[1,2,5],[1,7],[2,6]]
这里面这里面加粗斜体的[1,2,5]就是在遍历到同一树层的第二个1的时候,重复搜索了第一个1已经搜索过的元素。

这题相比于39的代码改动在于两点:
在对candidate数组进行排序之后,
①for循环里进行回溯传参的时候,传递的是i+1,保证不会重复选取一个元素;
②在进行抽象二叉树搜索的时候,在横向遍历过程中,如果碰到当前元素和前一个元素相同,跳过当前元素,直接继续从下一个元素继续

class Solution {List<List<Integer>>res=new ArrayList<>();List<Integer>path=new ArrayList<>();public List<List<Integer>> combinationSum2(int[] candidates, int target) {Arrays.sort(candidates);//先对其排序,方便去重backTrack(candidates,target,0);return res;}public void backTrack(int[]candidates,int target,int startIndex){if(target==0){res.add(new ArrayList<>(path));return;}for(int i=startIndex;i<candidates.length;i++){if(target-candidates[i]<0)break;//不符合target,及时退出for循环//i>startIndex,保证i-1不会小于for循环区间if(i>startIndex&&candidates[i]==candidates[i-1])continue;path.add(candidates[i]);backTrack(candidates,target-candidates[i],i+1);path.remove(path.size()-1);}}
}

力扣 17. 电话号码的字母组合


这题主要需要解决两个问题:
1.数字和字母的映射关系
2.映射之后如何求出数字所代表的所有的字母组合

对于映射关系,可以用一个String数组来存储0-9所对应的字符串,其中索引0、1没有对应的值,为空。numMap[i]就是数字映射出的字符串:

 String[] numMap={"","","abc","def","ghi","jkl","mno","pqrs","tuv","wxyz"};

不过输入的数字为字符串,所以在遍历数字字符串digits的时候,应当取出每个遍历到的数字字符,将其转为int型数字:digits.charAt(index)-'0',index为当前遍历到数字字符串digits的位置。

所以通过:numMap[digits.charAt(index)-'0']可以表示当前遍历到的数字字符所对应的按钮上的字符串,对这个字符串每个字符进行遍历:numMap[digits.charAt(index)-'0'].charAt(i).

对于字母组合问题,采用回溯法:

需要注意的是java的String因为底层声明的是一个final的数组,所以它具有不可变性,但是同样是字符串的StringBuffer和StringBuilder却是可变的;而StringBuffer是线程安全的,所以效率比线程不安全的StringBuilder更高,所以在递归回溯的时候用一个StringBuilder 类的str暂存可能的字母字符串组合。

class Solution {List<String>res=new ArrayList<>();String[] numMap={"","","abc","def","ghi","jkl","mno","pqrs","tuv","wxyz"};StringBuilder str=new StringBuilder();public List<String> letterCombinations(String digits) {if(digits.length()==0)return res;backTrack(digits,0);return res;}public void backTrack(String digits,int index){//当遍历完数字字符串digits一次,就记录一次遍历的结果,再回溯if(index==digits.length()){res.add(str.toString());return;}//numStr代表当前数字对应的字符串,使代码简洁一点String numStr=numMap[digits.charAt(index)-'0'];for(int i=0;i<numStr.length();i++){str.append(numStr.charAt(i));backTrack(digits,index+1);//处理数字字符串digits的下一个索引str.deleteCharAt(str.length()-1);//回退}}
}

分割问题

力扣 131. 分割回文串


这题主要需要解决两点:
1.如何分割字符串;
2.判断分割的字符串是否为回文串;

对于分割问题,也是类似组合问题一样,例如切割abcdef,首先切割第一段,可能是a,ab,abc,abcd,abcde,abcdef;若切割的是a,则在bcdef中切割第二段,若第二段切割的是b,则在cdef中切割第三段,以此类推……如下图:
for循环用来遍历切割线的位置,当切割线到了最后,说明找到了一个切割方案,这样的过程和组合问题的回溯方法是类似的。

与组合问题类似,需要全局变量res记录所有分割方案;全局变量path记录单个分割方案;

List<List<String>>res=new ArrayList<>();
List<String>path=new ArrayList<>();

对于这个问题使用回溯进行分割:
1.确定参数:
需要遍历字符串s,还需要记录每次递归时的遍历的起始位置startIndex;

public void backTrack(String s,int startIndex){}

2.递归函数终止条件:
当startIndex走到字符串s的末尾时,就会产生一个切割方案,此时需要将path加入res中,并且返回上一层递归;

if(startIndex==s.length()){res.add(new ArrayList<>(path));return;
}

3.单层递归的逻辑:
分割出的子串的起始位置为i=startIndex,若[startIndex,i]区间的子串不是回文串,则i++,若是回文串则截取s的该区间子串加入path中;
再进行下一层递归,传入的参数中,下一层的分割的子串的起始位置为i+1;最后回退操作,撤回加入的子串;

for(int i=startIndex;i<s.length();i++){if(isPalindrome(s,startIndex,i)){path.add(s.substring(startIndex,i+1));}else continue;backTrack(s,i+1);path.remove(path.size()-1);
}

对于判断回文串,直接使用双指针法,从子串头尾向中间遍历:

public 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;
}

最终完整代码如下:

class Solution {List<List<String>>res=new ArrayList<>();List<String>path=new ArrayList<>();public List<List<String>> partition(String s) {backTrack(s,0);return res;}public void backTrack(String s,int startIndex){if(startIndex==s.length()){res.add(new ArrayList<>(path));return;}for(int i=startIndex;i<s.length();i++){if(isPalindrome(s,startIndex,i)){path.add(s.substring(startIndex,i+1));}else continue;backTrack(s,i+1);path.remove(path.size()-1);}}public 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;}
}

力扣 93. 复原 IP 地址

这题要求分割数字字符串,求出所有可能形成正确ip地址的分割方案,方法还是与前面一题的分割字符串判断是否是回文串一样,这题是分割数字字符串,判断分割出的子串是否符合ip地址格式(0-255),其递归回溯的过程如下图:


完整代码如下:

class Solution {List<String> res=new ArrayList<>();//记录最终结果List<String> path=new ArrayList<>();//记录每次分割好的数字子串public List<String> restoreIpAddresses(String s) {if(s.length()>12)return res;//s长度超过12不可能有合法分割方案backTrack(s,0);return res;}public void backTrack(String s,int startIndex){//当path已经有4个地址,并且分割线遍历字符串s结束if(path.size()==4&&startIndex==s.length()){res.add(pathToRes(new ArrayList<>(path)));return;}for(int i=startIndex;i<s.length();i++){//剪枝,在还没添加新的子串进入path前,已经有四个合理ip了//说明数字多了,直接回溯到上层if(path.size()==4)return;String str=s.substring(startIndex,i+1);//若数字子串符合ip地址格式,加入pathif(isIpAddress(str))path.add(str);else break;//不合法,则后面的也不合法,直接退出这层横向遍历backTrack(s,i+1);path.remove(path.size()-1);}}//将分割好的数字字符子串中间用"."拼接,转化成题目要求格式public String pathToRes(List<String> path){StringBuilder sb=new StringBuilder();for(int i=0;i<path.size();i++){sb.append(path.get(i));//最后一个子串后面不用加"."if(i!=path.size()-1)sb.append(".");}return sb.toString();//最后还需要转化成String类型}//判断截取的数字子串是否合法public boolean isIpAddress(String str){//当子串第一个数字为0且子串不止一个数字的时候,非法if(str.charAt(0)=='0'&&str.length()!=1)return false;//str转数字与255比较,不能超过255if(Integer.valueOf(str)>255)return false;return true;}
}

子集问题

力扣 78. 子集


子集问题和前面的组合问题、分割问题不同之处在于:子集问题需要收集抽象二叉树搜索过程中的每个结点,而组合、分割问题是收集最后的叶子结点;

同时需要注意的是求子集过程中集合的元素是不会重复的,比如求1 2 3 的子集,则{1,2}和{2,1}是一个子集,所以for循环遍历中递归的下一层的参数应该是这层的i+1;并且for循环要从设置的startIndex开始,上一层的i+1作为下一层的startIndex;

另外,这题里的回溯递归函数不需要终止条件,因为正常的终止条件就是startIndex遍历到nums数组的末尾,但是在这题里面,for循环到最后startIndex也是到达nums.length,然后退出,在这过程中res已经把遍历过的每个path加入了;

完整代码如下:

class Solution {List<List<Integer>>res=new ArrayList<>();List<Integer>path=new ArrayList<>();public List<List<Integer>> subsets(int[] nums) {backTrack(nums,0);return res;}public void backTrack(int[] nums,int startIndex){//收集子集,要在终止条件之前,否则会漏了自己res.add(new ArrayList<>(path));//if(startIndex>=nums.length)return;//终止条件可以不加for(int i=startIndex;i<nums.length;i++){path.add(nums[i]);backTrack(nums,i+1);path.remove(path.size()-1);}}
}

力扣 90. 子集 II

这题其实是力扣 78. 子集和力扣 40. 组合总和 II的结合,数组nums中有重复的元素,因此也会带来求子集的时候会有重复的子集出现的问题,因此需要去除重复子集。

首先对nums数组进行排序,这样方便后续遍历到一样的数字的时候可以去重;
递归终止条件设置为startIndex=nums.length,代表遍历到了nums的末尾,需要回退到上一层;

class Solution {List<List<Integer>>res=new ArrayList<>();List<Integer>path=new ArrayList<>();public List<List<Integer>> subsetsWithDup(int[] nums) {Arrays.sort(nums);//注意先将数组排序,方便去重backTrack(nums,0);return res;}public void backTrack(int[] nums,int startIndex){//要在终止条件之前加入res,否则可能漏掉自身res.add(new ArrayList<>(path));//终止条件,遍历到nums末尾if(startIndex==nums.length)return;for(int i=startIndex;i<nums.length;i++){//前后数字一样的时候进行去重,直接跳过当前数字//不跳过的话可能走出一条和前一个数字一样的数字路径if(i>startIndex&&nums[i]==nums[i-1])continue;path.add(nums[i]);backTrack(nums,i+1);path.remove(path.size()-1);}}
}

力扣 491. 递增子序列

这题和 力扣 90. 子集 II很像,但是细节之处又有很多不同。

首先,注意不能改变数组元素的相对位置,即不能先对数组排序再求递增子序列,因为本题求的是在原数组元素相对位置上找递增的子序列;所以这题不能像之前一样先对数组排序再遍历进行去重;

其次,相等的数字也被视为递增的一种情况,如{1,2,2}也是递增的;

完整代码如下:

class Solution {List<List<Integer>>res=new ArrayList<>();;List<Integer>path=new ArrayList<>();public List<List<Integer>> findSubsequences(int[] nums) {backTrack(nums,0);return res;}public void backTrack(int[] nums,int startIndex){if(path.size()>1)res.add(new ArrayList<>(path));//进入for循环前设置numMap,这样只会记录本层的元素是否使用//新的一层会重新定义numMapint[] numMap=new int[201];元素大小范围在[-100,100]for(int i=startIndex;i<nums.length;i++){//在path非空前提下,当前遍历元素小于path最后一个元素,说明非递增//或这个元素在本层之前已经使用过了,这两种情况都要跳过if(!path.isEmpty()&&nums[i]<path.get(path.size()-1)||numMap[nums[i]+100]==1)continue;//标记元素已经使用过numMap[nums[i]+100]=1;path.add(nums[i]);backTrack(nums,i+1);path.remove(path.size()-1);}}
}

排列问题

排列问题相比于组合问题不同在于,所求出的元素集就算相同,但只要排列顺序不一样,就是两个不同的排列。

力扣 46. 全排列

相比于前面做过的题,这题全排列每次横向遍历都是从nums[0]开始,即i=0,判断当前遍历到的元素是否在path里,如果在的话,就跳过这个元素,达到去重目的;采用一个标记数组used,记录是否在path里,和path添加、删除元素是同步的。

完整代码如下:

class Solution {List<List<Integer>>res=new ArrayList<>();List<Integer>path=new ArrayList<>();public List<List<Integer>> permute(int[] nums) {//标记前面的元素是否已经在path中,不在为false,在为trueboolean[] used=new boolean[nums.length];backTrack(nums,used);return res;}public void backTrack(int[] nums,boolean[] used){//收集叶子结点if(path.size()==nums.length){res.add(new ArrayList<>(path));return;}for(int i=0;i<nums.length;i++){//若path已经有了该元素,跳过,去重if(used[i])continue;used[i]=true;path.add(nums[i]);backTrack(nums,used);path.remove(path.size()-1);used[i]=false;}}
}

力扣 47. 全排列 II

这题数组中包含重复元素,而46题不包含重复数字;

需要先对nums数组进行排序,这样方便下一层递归中,for循环从i=0开始重新遍历的时候,比较前后nums的元素相同的时候,根据是否访问过(uesd[i-1])进行去重;

因为是全排列,所以每次递归中,for循环需要从i=0开始遍历,这样才能保证最后叶子结点的元素数量等于nums.length;

完整代码如下:

class Solution {List<List<Integer>>res=new ArrayList<>();List<Integer>path=new ArrayList<>();boolean[] used;//标记数组public List<List<Integer>> permuteUnique(int[] nums) {used=new boolean[nums.length];Arrays.sort(nums);backTrack(nums);return res;}public void backTrack(int[] nums){//到达叶子结点if(path.size()==nums.length){res.add(new ArrayList<>(path));return;}//每次递归遍历从i=0,开始,借助used数组去重for(int i=0;i<nums.length;i++){//uesd[i-1]=true,说明同一树枝的nums[i-1]被使用过//used[i-1]=false,说明同一树层的nums[i-1]被使用过//在nums排序之后的nums[i]==nums[i-1],若同一树层的nums[i-1]未被使用过//则直接跳过当前的nums[i],否则进入下一层递归for循环,i从0开始//又会把前面相同的元素加入path,出现重复(如1 1 2)if(i>0&&nums[i]==nums[i-1]&&used[i-1]==false)continue;if(used[i]==false){used[i]=true;path.add(nums[i]);backTrack(nums);path.remove(path.size()-1);used[i]=false;}}}}

需要特别理解的是,负责去重的代码:

//树层去重
if(i>0&&nums[i]==nums[i-1]&&used[i-1]==false)continue;

以及

//树枝去重
if(i>0&&nums[i]==nums[i-1]&&used[i-1]==true)continue;

都可以起到去重的作用,这是为什么呢?

用两张图来理解:
①树层去重:
相当于在同一树层,碰到前后一样的数字时,若前面的元素标记为false,说明已经回溯过前面的位置,如果不跳过,继续往下层递归时,会重复使用前面一样的数字,导致和前一个数字的某条树枝重复;

②树枝去重:
相当于在一个树枝上,碰到上下一样的数字,前一个数字标志为使用过,若不跳过当前数字,也会出现重复读取的情况;

从图中可以看出,树层去重效率更高,因为它可以在递归一开始的地方就进行剪枝,可以更快剪去更多树枝;

子集、组合、排列问题复杂度

·子集问题:

时间复杂度O(n2^n),因为每个元素状态就是取或者不取,这一块时间复杂度就是O(2的n次方),构造出来的每一组子集都要填进res数组,需要O(n),最终需要O(n2的n次方);

空间复杂度:O(n),递归深度为n,栈空间为O(n),每一层递归所用空间为常数级别,而res和path一般是全局变量,就算放在参数传递,也只是传引用,没有新申请内存空间,最终空间复杂度为O(n);

·组合问题:

时间复杂度O(n*2^n),组合问题其实也是一种子集问题,它最坏情况也不会超过子集问题时间复杂度;

空间复杂度:O(n),同子集问题;

·排列问题:

时间复杂度:O(n!),排列的树形图可以得到,第一层结点数n,第二层每一个分支都延伸n-1个分支,再往下n-2个分支……一直到叶子结点一共就是nn-1n-2*····*1=n!;

空间复杂度:同理子集问题;

棋盘问题

力扣 51. N 皇后

皇后的约束是:不能同行,不能同列,也不能同斜线;
其实棋盘的N皇后问题也可以转换成回溯的模拟二叉树遍历过程:
矩阵的行数就是模拟二叉树的深度,矩阵的列数就是模拟二叉树的结点的宽度,只要搜索这个模拟二叉树,找到叶子 结点的时候,就找到了一个合理的皇后摆放方案。

在开始遍历棋盘之前,首先要对棋盘进行初始化,将二维字符数组赋“.”字符:

char[][] chessBoard=new char[n][n];
for(char[] c:chessBoard)Arrays.fill(c,'.');

注意:
初始化chessBoard的时候,不能这样写:

char[][] chessBoard=new char[n][n];
char[] c=new char[2n];
Arrays.fill(c,'.');
Arrays.fill(chessBoard,c);

本意是想用c[]存储一行“.”字符,然后再用c[]填充chessBoard[][],但是因为chessBoard的每⼀项指向的都是同⼀个⼀维数组c。修改⼀个会影响其他地址的值,比如修改了chessboard[0][1],那么chessBoard[1][1],chessBoard[2][1]也会改变,因此还是要用for循环去遍历棋盘,一行行重新赋“.”;

然后就可以进行递归回溯三部曲了:

  1. 递归函数参数:
    ①因为要对棋盘进行遍历,所以棋盘chessboard需要作为参数传入;
    ②由于N皇后问题的要求:皇后不能同行,所以进行遍历的时候,按行进行遍历就行,一行最多只有一个位置符合要求,所以将遍历到的行数作为参数传入;
    ③主函数中的参数n,可传可不传,因为棋盘的行和列都是n,需要的时候直接表示计即可,但是传入n可以使代码看着不是那么臃肿;
public void backTrack(int n,int row,char[][] chessBoard){}
  1. 递归终止条件:
    由前面的模拟二叉树可以看出,当遍历完棋盘最后一行的时候,就可以看出这个走法的路径是否符合要求了:
if(row==n){//res是List<List<String>>类型的,无法直接加char[][]类型的棋盘//需要将chessBoard转化成List<String>类型res.add(charToList(chessBoard));return;
}
  1. 单层递归逻辑
    每层递归应该需要遍历当前棋盘行每一列,并且判断是否符合N皇后要求,如果符合,就将皇后放上棋盘,然后继续递归,并且在本层递归的最后撤销本层对棋盘当前行的操作:
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]='.';}
}

最后,为了完成回溯算法,需要写两个函数进行辅助:
①判断当前位置是否符合N皇后要求的函数:
不用检查行数是否符合要求,因为每次for循环都是在一行内遍历,每一行选择出一个位置后就会进入下一层递归;

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;//检查右边45°for(int i=row-1,j=col+1;i>=0&&j<n;i--,j++)if(chessBoard[i][j]=='Q')return false;return true;
}

②将chessBoard二维字符数组类型的棋盘转化为List< String>类型:

//转换chessBoard为List<String>类型
public List charToList(char[][] chessBoard){List<String>list=new ArrayList<>();//用c[]遍历chessBoard的每一行for(char[] c:chessBoard)//copyValueOf可以将字符数组转成字符串list.add(String.copyValueOf(c));return list;
}

完整代码如下:

class Solution {List<List<String>>res=new ArrayList<>();public List<List<String>> solveNQueens(int n) {//chessBoard模拟棋盘,先将其初始化为全“.”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){//当行数遍历到最后一行(从0-n-1共n行),说明棋盘遍历结束if(row==n){//res是List<List<String>>类型的,无法直接加char[][]类型的棋盘//需要将chessBoard转化成List<String>类型res.add(charToList(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]='.';}}}//转换chessBoard为List<String>类型public List charToList(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;//检查右边45°for(int i=row-1,j=col+1;i>=0&&j<n;i--,j++)if(chessBoard[i][j]=='Q')return false;return true;}
}

37. 解数独

在N皇后问题的基础上解决这道题,思路会更加清晰。因为N皇后问题每一行只要填写一个数字,只需要用一层的for循环对行进行遍历;而数独问题,每一行都要填满数字,因此在for循环遍历每行的同时,要再加一层for循环去遍历列,考虑每一个可填数字的位置上的数字的合法性。最后在这两重循环的基础上,再加一层循环遍历数字字符‘1’-‘9’;

其抽象树形结构如下(来自代码随想录):

继续进行递归三部曲。

  1. 递归函数及参数
    很明显这题只需要考虑9宫格即可,因此只要把9宫格传参:
private boolean solveSudokuHelper(char[][] board){}
  1. 递归终止条件
    正常的递归回溯需要在走到最下面的叶子结点处,记录结果并且返回上一层;但是9宫格问题不一样,题目规定只会有一个唯一解,因此能走到最下面,即全部遍历完成的就是正确答案,这时候直接返回true即可,就不需要终止条件了;

  2. 单层递归遍历逻辑
    就是两层for循环遍历行和列,第三层for循环遍历0-9数字字符;

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是否合适//只有当数字符合要求,才填入9宫格if (isValidSudoku(i, j, k, board)){board[i][j] = k;if (solveSudokuHelper(board)){ // 如果找到合适一组立刻返回return true;}board[i][j] = '.';}}// 9个数都试完了,都不行,那么就返回falsereturn false;// 因为如果一行一列确定下来了,这里尝试了9个数都不行,说明这个棋盘找不到解决数独问题的解!// 那么会直接返回, 「这也就是为什么没有终止条件也不会永远填不满棋盘而无限递归下去!」}}// 遍历完没有返回false,说明找到了合适棋盘位置了return true;
}

除此2之外,还需要判断填上当前数字后是否符合数独要求:

/*** 判断棋盘是否合法有如下三个维度:*     同行是否重复*     同列是否重复*     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宫格里是否重复//9宫格行起始:0,3,6;列起始:0,3,6//因此只要除3再乘就可以获取起始行列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;
}

完整代码如下:

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是否合适//只有当数字符合要求,才填入9宫格if (isValidSudoku(i, j, k, board)){board[i][j] = k;if (solveSudokuHelper(board)){ // 如果找到合适一组立刻返回return true;}board[i][j] = '.';}}// 9个数都试完了,都不行,那么就返回falsereturn 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宫格里是否重复//9宫格行起始:0,3,6;列起始:0,3,6//因此只要除3再乘就可以获取起始行列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;}
}

其他问题

力扣 332. 重新安排行程

题目理解:
给你一沓机票,用它去飞(遍历)图中的城市(节点),机票要用光(遍历完所有的边),返回出访问城市的路径,且机票不能重复用(遍历过的边要拆掉)。

题意说,用完机票所走的路径一定存在,找出一条即可。没找到用完机票的路径就是:你困在一个城市,手里有不合适的机票,用不出去。对应到图就是,到了一个点,没有邻接点能访问,但你还有边没遍历。

套用回溯模板的代码如下(没用到map效率较低):

class Solution {//path记录路线,res存所有路线List<String> path = new ArrayList<>();List<List<String>> res = new ArrayList<>();//used数组用于标记同一树枝不能重复使用!即不能重复使用一张票boolean[] used = new boolean[301];boolean find;public List<String> findItinerary(List<List<String>> tickets) {//先按字典序从小到大排列降落地//重写sort规则tickets.sort((o1, o2) -> o1.get(1).compareTo(o2.get(1)));path.add("JFK");backTracking(tickets, "JFK");return res.get(0);}void backTracking(List<List<String>> tickets, String outset) {//算个小剪枝吧,找到一条就行if (find) {return;}//因为这些航班肯定会有一条路线是正确的//所以我们加入path的size如果等于tickets.size()+1说明我们找到路线了if (path.size() == tickets.size() + 1) {find = true;res.add(new ArrayList<>(path));return;}for (int i = 0; i < tickets.size(); i++) {//如果出发地和上一个的降落地相同 并且 同一条路线中没有重复使用一张票if(tickets.get(i).get(0).equals(outset)  && !used[i]){//标记该票已经使用过used[i]= true;path.add(tickets.get(i).get(1));//把现在的降落地加入递归函数//即把当前机票终点作为下一站的起点backTracking(tickets, tickets.get(i).get(1));//回溯! 该票标记为未使用 路线中移除该票used[i]=false;path.remove(path.size()-1);}}}
}

用了map的写法:

class Solution {private LinkedList<String> res;private Map<String, Map<String, Integer>> map;public List<String> findItinerary(List<List<String>> tickets) {//<起点,<终点,航行剩余次数>>,//“航行剩余次数”大于零,说明目的地还可以飞,如果如果“航行剩余次数”等于零说明目的地不能飞了//map是一张全局表,统计所有的机票信息map = new HashMap<String, Map<String, Integer>>();res = new LinkedList<>();// 将票放入全局map中。// for循环中只有两个处理步骤// 1.确定map的value值,也就是<终点,计数值>,程序里用temp接收//      -逻辑1:机票起点相同,["JFK","SFO"],["JFK","ATL"]  //      -逻辑2:机票起点不同,["JFK","ATL"],["SFO","ATL"]// 2.将起点和<终点,计数值>分别作为map的key-value放入map中。for(List<String> t : tickets){// t 就是一张ticket["JFK","MUC"]// 升序Map,会对传入的key进行了大小排序Map<String, Integer> temp = new TreeMap<>();// 逻辑1:机票起点相同// t.get(0) : 机票起点  t.get(1) : 机票终点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{      // 逻辑2 : 机票起点不同["JFK","ATL"],["SFO","ATL"]temp.put(t.get(1), 1);}map.put(t.get(0), temp);}//先放入起点机场res.add("JFK");//调用递归函数backTracking(tickets.size());return res;}//注意返回值是boolean类型//因为我们只需要找到一个行程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()){// target.getValue() 就是剩余航行次数,如果大于0,说明还能飞if(target.getValue() > 0){// 将航行目的地添加到结果集res.add(target.getKey());target.setValue(target.getValue() - 1);if(backTracking(ticketNum)) return true;res.removeLast();target.setValue(target.getValue() + 1);}}}return false;}
}

回溯总结篇

carl大神的全面总结(自己全写下来工作量太大了,在这记录一下,先把后面的知识冲了)

力扣刷题记录-回溯算法相关题目相关推荐

  1. 力扣刷题记录-单调栈相关题目

    单调栈是指栈里的元素保持升序或者降序. 判别是否需要使用单调栈:通常是一维数组里面,需要寻找一个元素左边或者右边第一个比自己大或者小的元素的位置,则可以考虑使用单调栈:这样的时间复杂度一般为O(n). ...

  2. 力扣刷题记录--哈希表相关题目

    当遇到需要快速判断一个元素是否出现在集合里面的时候,可以考虑哈希法,牺牲一定的空间换取查找的时间. java常用的哈希表有HashMap.HashSet以及用数组去模拟哈希,这几种方法各有优劣. 数组 ...

  3. 力扣刷题-python-回溯算法-1(回溯算法模板、题型)

    文章目录 1.回溯算法 2.回溯算法模板 3.回溯实例(77.216.17.39.40.131.93.78.90.491.46.47) 4.总结 1.回溯算法 回溯算法的本质就是穷举,最多再加上剪枝, ...

  4. 力扣刷题记录-动态规划问题总结

    百度百科里对于动态规划问题是这样解释的: 在现实生活中,有一类活动的过程,由于它的特殊性,可将过程分成若干个互相联系的阶段,在它的每一阶段都需要作出决策,从而使整个过程达到最好的活动效果.因此各个阶段 ...

  5. 力扣刷题记录_字符串(自学)

    字符串 一.字符串 1.反转字符串(力扣344) 2.反转字符串 II(力扣541) 3.替换空格(剑指 Offer 05) 4.翻转字符串里的单词(力扣151) 5.左旋转字符串(剑指 Offer ...

  6. 力扣刷题记录--位运算问题

    这里写目录标题 一.n&(n-1) 1. 求一个数的二进制表示中的1的个数 力扣 191. 位1的个数 AcWing 801. 二进制中1的个数 2. 判断一个数是否是2的方幂 二.n& ...

  7. LeetCode力扣刷题——千奇百怪的排序算法

    排序算法 一.常见的排序算法         以下是一些最基本的排序算法.虽然在 C++ 里可以通过 std::sort() 快速排序,而且刷题时很少需要自己手写排序算法,但是熟习各种排序算法可以加深 ...

  8. python力扣刷题记录——204. 计数质数

    题目: 统计所有小于非负整数 n 的质数的数量. 方法一: 暴力法 class Solution:def countPrimes(self, n: int) -> int:count = 0if ...

  9. 力扣刷题记录---快排算法

    AcWing 785. 快速排序 对快排算法思想就不描述了,针对快排递归过程中边界的取值做了总结: x为每次递归中,选取的基准数(枢轴) 如果x = q[i]或者x = q[l + r >> ...

最新文章

  1. python小白逆袭大神课程心得_Python小白逆袭大神学习心得
  2. stm32官方例程在哪找_正点原子Linux第十一章模仿STM32驱动开发格式实验
  3. UA MATH563 概率论的数学基础1 概率空间3 概率测度
  4. DL之DNN:利用DNN【784→50→100→10】算法对MNIST手写数字图片识别数据集进行预测、模型优化
  5. C#中的默认访问修饰符
  6. java cucumber_为Java + STANDARD值引入Cucumber
  7. nginx tcp转发_Nginx学习(九):负载均衡服务
  8. python教育学_跟着老男孩教育学Python开发【第三篇】:Python函数
  9. java8 Stream的实现原理 (从零开始实现一个stream流)
  10. Android的启动模式(上)
  11. NameError: name 'reload' is not defined等python版本问题解决方案
  12. IDEA 之because it is included into a circular dependency循环依赖的解决办法
  13. NotifyIcon用法
  14. python开根号_python 开根号
  15. 如何在UltraCompare中编辑文件?
  16. 【期末复习】现代管理科学基础
  17. linux 防火墙reject,CentOS 防火墙配置与REJECT导致没有生效问题
  18. Android沉浸式的两种方法
  19. IT培训班有用吗?IT培训包就业是真的吗?
  20. 【淘宝API开发系列】获取商品详情,商品评论、卖家订单接口

热门文章

  1. 4个简单有效的网页视频下载方法,超级简单好用
  2. java jdk安装失败_图文解答Java JDK9.0安装失败的原因,附带处理方法
  3. C++ string大小写转换
  4. 字节跳动校招笔试题汇总
  5. word编辑文字时光标随意跳动问题
  6. 邮箱输入注册测试用例
  7. 软件工程毕业设计课题(37)基于JAVA毕业设计JAVA核酸预约系统统毕设作品项目
  8. 【bzoj3065】: 带插入区间K小值 详解——替罪羊套函数式线段树
  9. 啃光学论文的笔记(1)
  10. 【PTA乙级】【1096 大美数 (15 分)】