首先介绍一下回溯算法
回溯通常在递归函数中体现,本质也是一种暴力的搜索方法,但可以解决一些用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(参数);//回溯
撤销处理;//相当于回退到结点的上一层
}
}
组合问题
LeetCode 77. 组合(换顺序仍视为同一组合)
2023.12.17 二刷
从示例中可以观察到:选择的数字不能重复,比如[1,2]和[2,1]属于重复的组合,即调换数字次序不能视为新的组合。
这个问题可以抽象为下面的树形结构:
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的个数:path.size();
②还需要加入的个数:k-path.size();
③列表中剩余元素(n-i) >= 所需需要的元素个数(k - path.size())
④i最远只能从1-n中的n-(k-path.size())+1处开始搜索( i <= 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);
}
}
}
LeetCode 216. 组合总和 III
2023.12.17 二刷
之所以先做这题而不是这题的两道前置题,是因为其思想、方法和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;
}
}
}
LeetCode 39. 组合总和
2023.12.18 二刷
这题相比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
2023.12.18 二刷
这题与力扣 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);
}
}
}
LeetCode 17. 电话号码的字母组合
2023.12.18 二刷
这题主要需要解决两个问题:
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);//回退
}
}
}
分割问题
LeetCode 131. 分割回文串
2023.12.18 二刷
这题主要需要解决两点:
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)){
// substring左闭右开,取到[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;
}
}
LeetCode 93. 复原 IP 地址
2023.12.19 二刷
这题要求分割数字字符串,求出所有可能形成正确ip地址的分割方案,方法还是与前面一题的分割字符串判断是否是回文串一样,这题是分割数字字符串,判断分割出的子串是否符合ip地址格式(0-255),其递归回溯的过程如下图:
本题我们还需要一个变量pointNum,记录添加逗点的数量。
终止条件和131.分割回文串 (opens new window)情况就不同了,本题明确要求只会分成4段,所以不能用切割线切到最后作为终止条件,而是分割的段数作为终止条件。
pointNum表示逗点数量,pointNum为3说明字符串分成了4段了。然后验证一下第四段是否合法,如果合法就加入到结果集里。
判断子串是否合法,主要考虑到如下三点:
- 段位以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地址格式,加入path
if(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比较,不能超过255
if(Integer.valueOf(str)>255)return false;
return true;
}
}
子集问题
LeetCode 78. 子集
2023.12.19 二刷
子集问题和前面的组合问题、分割问题不同之处在于:子集问题需要收集抽象二叉树搜索过程中的每个结点,而组合、分割问题是收集最后的叶子结点;
同时需要注意的是求子集过程中集合的元素是不会重复的,比如求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);
}
}
}
LeetCode 90. 子集 II
2023.12.19 二刷
这题其实是力扣 78. 子集和力扣 40. 组合总和 II的结合,数组nums中有重复的元素,因此也会带来求子集的时候会有重复的子集出现的问题,因此需要去除重复子集。
- 首先对nums数组进行排序,这样方便后续遍历到一样的数字的时候可以去重;
- 递归终止条件就是走到nums.length,这和for循环退出条件一致,因此不需进行额外if判断
- 在for循环中需要注意如果连着两个nums[i]相同,需要进行去重(即跳过后面重复的数字),否则子集中会有重复问题;
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);
}
}
}
LettCode 491. 递增子序列
2023.12.19 二刷
这题和 力扣 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,这样只会记录本层回溯的元素是否使用
//新的一层会重新定义numMap
int[] 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);
}
}
}
排列问题
排列问题相比于组合问题不同在于,所求出的元素集就算相同,但只要排列顺序不一样,就是两个不同的排列。
LeetCode 46. 全排列
2023.12.21 二刷
相比于前面做过的题,这题全排列每次横向遍历都是从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,在为true
boolean[] 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;
}
}
}
LeetCode 47. 全排列 II
2023.12.21 二刷
这题数组中包含重复元素,而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]未被使用过
//若前面的元素标记为false,说明已经回溯过前面的位置,如果不跳过,继续往下层递归时,会重复使用前面一样的数字,
//又会把前面相同的元素加入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!;
空间复杂度:同理子集问题;
棋盘问题
LeetCode 51. N 皇后
2023.12.23 二刷
皇后的约束是:不能同行,不能同列,也不能同斜线(45°与135°);
其实棋盘的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[n];
Arrays.fill(c,'.');
Arrays.fill(chessBoard,c);
本意是想用c[]存储一行“.”字符,然后再用c[]填充chessBoard[][],但是因为chessBoard的每⼀项指向的都是同⼀个⼀维数组c。修改⼀个会影响其他地址的值,比如修改了chessboard[0][1],那么chessBoard[1][1],chessBoard[2][1]也会改变,因此还是要用for循环去遍历棋盘,一行行重新赋“.”;
然后就可以进行递归回溯三部曲了:
- 递归函数参数:
①因为要对棋盘进行遍历,所以棋盘chessboard需要作为参数传入;
②由于N皇后问题的要求:皇后不能同行,所以进行遍历的时候,按行进行遍历就行,一行最多只有一个位置符合要求,所以将遍历到的行数作为参数传入;
③主函数中的参数n,可传可不传,因为棋盘的行和列都是n,需要的时候直接表示即可,但是传入n可以使代码看着不是那么臃肿;
public void backTrack(int n,int row,char[][] chessBoard){
}
- 递归终止条件:
由前面的模拟二叉树可以看出,当遍历完棋盘最后一行的时候,就可以看出这个走法的路径是否符合要求了:
if(row==n){
//res是List<List<String>>类型的,无法直接加char[][]类型的棋盘
//需要将chessBoard转化成List<String>类型
res.add(charToList(chessBoard));
return;
}
- 单层递归逻辑
每层递归应该需要遍历当前棋盘行每一列,并且判断是否符合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;
//检查右边135°
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;
}
}
LeetCode 52. N 皇后 II
2024.03.08 一刷
思路:
相比于用一个二维的数组去模拟棋盘,用三个Set去记录同列、同主对角线方向、同副对角线方向的位置上
是否有棋子会更为简单,因为模拟棋盘还需要自己再写一个isValid函数去判断当前落子是否可行,而且空间占用更大;
可以发现:
同一主对角线方向,行下标-列下标值是固定的
同一副对角线方向,行下标+列下标值是固定的
所以每遍历到一行的新的位置时,只要判断一下三个set中是否已经存在对应方向的棋子,如果存在,就跳过当前位置,
不存在,则记录该位置有棋子,进行回溯。回溯后,记得需要从3个set中移除记录。
代码如下:
class Solution {
int count=0;
Set<Integer> col = new HashSet<>();
Set<Integer> mainDiagonal = new HashSet<>();
Set<Integer> subDiaginal = new HashSet<>();
public int totalNQueens(int n) {
backTrack(n,0);
return count;
}
public void backTrack(int n,int row){
if(row==n){
++count;
}
for(int j=0;j<n;j++){
if(col.contains(j)){
continue;
}
// 同一主对角线方向,行下标-列下标值是固定的
int mainDiagonalIndex = row - j;
if(mainDiagonal.contains(mainDiagonalIndex)){
continue;
}
// 同一副对角线方向,行下标+列下标值是固定的
int subDiaginalIndex = row + j;
if(subDiaginal.contains(subDiaginalIndex)){
continue;
}
col.add(j);
mainDiagonal.add(mainDiagonalIndex);
subDiaginal.add(subDiaginalIndex);
backTrack(n,row+1);
col.remove(j);
mainDiagonal.remove(mainDiagonalIndex);
subDiaginal.remove(subDiaginalIndex);
}
}
}
LeetCode 37. 解数独
2023.12.24 二刷
在N皇后问题的基础上解决这道题,思路会更加清晰。因为N皇后问题每一行只要填写一个数字,只需要用一层的for循环对行进行遍历;而数独问题,每一行都要填满数字,因此在for循环遍历每行的同时,要再加一层for循环去遍历列,考虑每一个可填数字的位置上的数字的合法性。最后在这两重循环的基础上,再加一层循环遍历数字字符‘1’-‘9’;
其抽象树形结构如下(来自代码随想录):
继续进行递归三部曲。
- 递归函数及参数
很明显这题只需要考虑9宫格即可,因此只要把9宫格传参:
private boolean solveSudokuHelper(char[][] board){
}
-
递归终止条件
正常的递归回溯需要在走到最下面的叶子结点处,记录结果并且返回上一层;但是9宫格问题不一样,题目规定只会有一个唯一解,因此能走到最下面,即全部遍历完成的就是正确答案,这时候直接返回true即可,就不需要终止条件了; -
单层递归遍历逻辑
就是两层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个数都试完了,都不行,那么就返回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宫格里是否重复
//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] = '.';
}
}
// 如果board[i][j]=1-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宫格里是否重复
//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;
}
}
其他问题
LeetCode 22. 括号生成
2023.12.25 一刷
思路:
一个「合法」括号组合的左括号数量一定等于右括号数量;
对于一个「合法」的括号字符串组合 p,必然对于任何 0 <= i < len§ 都有:子串 p[0…i] 中左括号的数量都大于或等于右括号的数量
因为从左往右算的话,肯定是左括号多,到最后左右括号数量都等与n,说明这个括号组合是合法的。
用 lNum 记录使用了多少个左括号,用 rNum 记录还使用了多少个右括号,就可以直接套用 回溯算法套路模板;
代码如下:
class Solution {
List<String> res = new ArrayList<>();
public List<String> generateParenthesis(int n) {
StringBuilder sb = new StringBuilder();
backtrack(0,0,n,sb);
return res;
}
// lNum与rNum分别表示sb里面已经添加了的左、右括号数量
public void backtrack(int lNum,int rNum,int n,StringBuilder sb){
// 右括号比左括号多、左右括号超过n都是不符合的
if(rNum>lNum||lNum>n||rNum>n)return;
// 左右括号数量均为n时可以收集答案
if(lNum==n&&rNum==n){
res.add(sb.toString());
return;
}
// 尝试添加左括号,如果非法,下一层会直接返回
sb.append("(");
backtrack(lNum+1,rNum,n,sb);
sb.deleteCharAt(sb.length()-1);
sb.append(")");
backtrack(lNum,rNum+1,n,sb);
sb.deleteCharAt(sb.length()-1);
}
}
LeetCode 79. 单词搜索
2023.12.25 一刷
代码如下:
class Solution {
public boolean exist(char[][] board, String word) {
StringBuilder sb = new StringBuilder();
boolean[][] visited = new boolean[board.length][board[0].length];
for(int i=0;i<board.length;i++){
for(int j=0;j<board[0].length;j++){
if(backtrack(board,word,i,j,0,visited))return true;
}
}
return false;
}
public boolean backtrack(char[][] board,String word,int i,int j,int k,boolean[][] visited){
// k表示的是当前比较到word中的下标的位置
// 如果已经到了word.length()还没报错,说明前面遍历到的所有字母都完全符合word
if(k==word.length())return true;
// i,j越界或已经访问过,或者当前遍历到的字母与word对应位置k不同,都是不符合情况
if(i<0||i>=board.length||j<0||j>=board[0].length||visited[i][j]||board[i][j]!=word.charAt(k))return false;
// 进行回溯前要将这个位置标记已访问过
visited[i][j]=true;
if(backtrack(board,word,i-1,j,k+1,visited)||backtrack(board,word,i+1,j,k+1,visited)||backtrack(board,word,i,j-1,k+1,visited)||backtrack(board,word,i,j+1,k+1,visited))return true;
// 回溯回来,重置为未访问
visited[i][j]=false;
return false;
}
}
LeetCode 332. 重新安排行程
2023.12.23 二刷
题目理解:
给你一沓机票,用它去飞(遍历)图中的城市(节点),机票要用光(遍历完所有的边),返回访问城市的路径,且机票不能重复用(遍历过的边要拆掉)。
题意说,用完机票所走的路径一定存在,找出一条即可。没找到用完机票的路径就是:你困在一个城市,手里有不合适的机票,用不出去。对应到图就是,到了一个点,没有邻接点能访问,但你还有边没遍历。
套用回溯模板的代码如下(没用到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 {
List<String> res = new ArrayList<>();
Map<String,Map<String,Integer>> map;
public List<String> findItinerary(List<List<String>> tickets) {
//<起点,<终点,航行剩余次数>>,
//“航行剩余次数”大于零,说明目的地还可以飞,如果如果“航行剩余次数”等于零说明目的地不能飞了
//map是一张全局表,统计所有的机票信息
map = new HashMap<String,Map<String,Integer>>();
// 将票放入全局map中。
// for循环中只有两个处理步骤
// 1.确定map的value值,也就是<终点,计数值>,程序里用temp接收
// -逻辑1:map中存在该起点,["JFK","SFO"],["JFK","ATL"]
// -逻辑2:map中不存在该起点,["JFK","ATL"],["SFO","ATL"]
// 2.将起点和<终点,计数值>分别作为map的key-value放入map中。
for(List<String> ticket : tickets){
// ticket 就是一张票["JFK","MUC"]
// 升序Map,会对传入的key进行了大小排序
Map<String,Integer> temp = new TreeMap<>();
String from = ticket.get(0),to = ticket.get(1);
// 逻辑1:map中存在该起点
// ticket.get(0) : 机票起点 ticket.get(1) : 机票终点
if(map.containsKey(from)){
// 获取机票统计信息 <终点,航行剩余次数>
temp = map.get(from);
// 更新机票统计信息,
temp.put(to,temp.getOrDefault(to,0)+1);
}else{// 逻辑2:map中不存在该起点
temp.put(to,1);
}
map.put(from,temp);
}
//先放入起点机场
res.add("JFK");
backtrack(tickets.size());
return res;
}
//注意返回值是boolean类型
//因为我们只需要找到一个行程
public boolean backtrack(int ticketNum){
if(res.size() == ticketNum+1){
return true;
}
// 找到接下来的出发点(就是上回的终点)
String from = res.getLast();
// 要先保证map中存在这个起点
if(map.containsKey(from)){
// 遍历map中该起点对应的所有<目的地,票数>键值对(找到所有可能的目的地)
for(Map.Entry<String,Integer> toAndNum:map.get(from).entrySet()){
// 只有目的地对应票数大于0,才说明这个票没被用过,还可以使用
if(toAndNum.getValue()>0){
res.add(toAndNum.getKey());
toAndNum.setValue(toAndNum.getValue()-1);
if(backtrack(ticketNum))return true;
res.removeLast();
toAndNum.setValue(toAndNum.getValue()+1);
}
}
}
return false;
}
}
回溯总结篇
carl大神的全面总结(自己全写下来工作量太大了,在这记录一下,先把后面的知识冲了)