回溯算法小结(leetcode回溯题集合)

回溯算法小结

回溯法定义

回溯法:采用试错的思想,它尝试分步的去解决一个问题。在分步解决问题的过程中,当它通过尝试发现现有的分步答案不能得到有效的正确的解答的时候,它将取消上一步甚至是上几步的计算,再通过其它的可能的分步解答再次尝试寻找问题的答案。回溯法通常用最简单的递归方法来实现,在反复重复上述的步骤后可能出现两种情况:

  1. 找到一个可能存在的正确的答案;
  2. 在尝试了所有可能的分步方法后宣告该问题没有答案。

上面说到回溯法在试错的过程中,会取消上一步或者几步的操作,从而开始进行其它的尝试。这时候的这个过程,在学习树的搜索时所用到的递归十分相似,也就是深度优先搜索。比如遍历树时,会按照条件遍历左结点或右节点,如果不满足,当前节点会返回到根节点,从而开始遍历另一个节点。

深度优先搜索(英语:Depth-First-Search,DFS)是一种用于遍历或搜索树或图的算法。这个算法会 尽可能深 的搜索树的分支。当结点 v 的所在边都己被探寻过,搜索将 回溯 到发现结点 v 的那条边的起始结点。这一过程一直进行到已发现从源结点可达的所有结点为止。如果还存在未被发现的结点,则选择其中一个作为源结点并重复以上过程,整个进程反复进行直到所有结点都被访问为止。

如图参考的是二叉树的深度优先搜索,这里默认的是先搜索左结点再搜索右结点。

回溯法与深度优先搜索结合

接下来就通过leetcode上的几道题来加深回溯算法的理解:

leetcode 46. 全排列

题目描述:

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

示例:

Input: nums = [1,2,3]
Output: [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

Input: nums = [0,1]
Output: nums = [[0,1],[1,0]]

Input: nums = [1]
Output: nums = [[1]]

解题思路:

由于前面提到回溯问题可以转化为树的遍历问题而不断尝试,因此可以把该题转化为树的深度优先搜索,即递归。

我们参考示例1中所打印的答案,发现它会先打印以1为开头的全排列,即[1,2,3]和[1,3,2],然后按数组顺序,分别打印以2和3开头的全排列,因此其实可以看出它是以1为根节点,分别遍历左右子树,得到以1为开头的全排列,依次类推。下图为liweiwei所作

image-20211022143840786

该回溯的过程有几个关键点:

  1. 由于打印全排列时,当数组中间元素作为全排列的开头时,那就会不可避免的重新遍历到该元素,而由于该元素已经被使用,则应该跳过该元素的选择,进入下一个遍历。因此,这时候我们使用一个used数组来记录对应索引的数组是否被使用。
  2. 我们可以使用path变量来存储当前数字选择,但是在选择过后,由于如果已经保存了一个结果,那么就应该撤销上一次选择的数据,循环遍历选择下一个数字。比如说第一次选择了1,那么path=[1],这时候会把used[1]标记成true,表示已经使用,这时以该节点为根节点,再次进行递归,在此次递归中,由于used[1]已经标记为true,那么就会跳过该循环,此时遍历到第二个元素的数组2,加入到path=[1,2],再次以2作为根节点,进入递归,最后加入3,path=[1,2,3],循环递归结束。但是我们要注意到,在1选择2的时候,其实还可以选择3,因此我们需要撤销2的选择,使得第一次递归时,跳过2,进入到3,将3加入到path,path=[1,3]。 这个撤销的过程称之为状态重置。这部分有点难理解,读者最好拿出笔来按照我的描述过程画一画。

算法代码:

class Solution {
    LinkedList<List<Integer>> res = new LinkedList<>();  //设为全局变量,可以避免进行递归时传参数
    LinkedList<Integer> path = new LinkedList<>(); 
    public List<List<Integer>> permute(int[] nums) {
        boolean[] used = new boolean[nums.length];
        dfs(nums, used, 0);
        return res;
    }

    public void dfs(int[] nums, boolean[] used, int begin){  //这里的begin并没有什么用,因为每次都会从0开始,在后面的例题会看到它的作用。
        if(path.size() == nums.length){ 
            res.add(new LinkedList(path));  // 复制一份path,避免每次进行操作时,res里的path都会改变。
            return;
        }
        for(int i = 0; i < nums.length; i++){
            if(!used[i]){
                path.add(nums[i]);
                used[i] = true;
                dfs(nums, used, 0);
                used[i] = false;  // 要记得状态归为false,因为接下来会撤销本次选择,因此该索引的元素就会没被使用。
                path.removeLast();  // 撤销本次选择。
            }
        }
    }

}

leetcode 47. 全排列Ⅱ

题目描述:

给定一个可包含重复数字的序列 nums按任意顺序 返回所有不重复的全排列。

示例:

Input: nums = [1,2,3]
Output: [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

Input: nums = [1,1,3]
Output: nums = [[1,1,3],[1,3,1],[3,1,1]]

解题思路:

该题与上题的区别在于nums中会存在重复元素,对于递归全排列,实际上时先选择其中一个元素作为全排列的开头,然后求的剩余元素的全排列。那么当元素相同时,全排列会是怎么样的呢?拿实例中的nums= [1,1,3]举例

  1. 先写以1 开头的全排列,它们是:[1, 1, 3], [1, 3, 1],即 1 + [1, 3] 的全排列(注意:递归结构体现在这里);
  2. 再写以1开头的全排列,它们是:[1, 1, 3], [1, 3, 1],即 1+ [1, 3] 的全排列;
  3. 最后写以 3开头的全排列,它们是:[3, 1, 1], [3, 1, 1],即 3 +[1, 1] 的全排列。

我们发现这其中会有很多相同的排列,而题目中需要返回的是不重复的,因此我们需要剪枝。

首先我们可以对数组进行排序,将相同的元素都紧靠着排放。 通过观察这个例子,我们可以看出,在第二步时,由于第一个元素和第二个元素都是1,也就是说和第一步一样都是求[1,3]的全排列,这时我们就可以跳过本次循环。跳过循环的判断条件有两个,1. 它们都是1;2. 第二次循环时,第一个1没有被使用(能够选择它);写成条件为if ( i>0 && nums[i] == nums[i-1] && !used[i] )。其中i>0保证i-1在数组范围内,并且表示剪枝的是后面的重复元素。

class Solution {
    LinkedList<List<Integer>> res = new LinkedList<>();
    LinkedList<Integer> path = new LinkedList<>();
    public List<List<Integer>> permuteUnique(int[] nums) {
        boolean[] used = new boolean[nums.length];
        Arrays.sort(nums);
        dfs(nums, used, 0);
        return res;
    }

    public void dfs(int[] nums, boolean[] used, int depth){
        if(path.size() == nums.length){ 
            res.add(new LinkedList<>(path));
            return;
        }
        for(int i = 0; i < nums.length; i++){
            if(i > 0 && nums[i] == nums[i-1] && !used[i-1]) continue;
            if(!used[i]){
                path.add(nums[i]);
                used[i] = true;
                dfs(nums, used, i+1);
                used[i] = false;
                path.removeLast();
            }
        }
    }
}

接下来区分下什么时候使用begin这个变量

leetcode 39.组合总和

题目描述:

给定一个无重复元素的正整数数组 candidates 和一个正整数 target ,找出 candidates 中所有可以使数字和为目标数 target 的唯一组合。

candidates 中的数字可以无限制重复被选取。如果至少一个所选数字数量不同,则两种组合是唯一的。

对于给定的输入,保证和为 target 的唯一组合数少于 150 个。

示例:

Input: candidates = [2,3,6,7], target = 7
Output: [[7],[2,2,3]]

Input: candidates = [2,3,5], target = 8
Output: [[2,2,2,2],[2,3,3],[3,5]]

解题思路:

该题也是经典的回溯问题,需要经历不断地试错过程。前面说到回溯问题很多都可以转化为树的遍历问题,这题也不例外。这时候树的每个节点则是target的值,由于target会不断地尝试减去candidates中元素的值,因此target的值会不断改变,如果target的值变为0,那么说明前面减去的几个值可以凑成一个组合,这时候就将这个组合作为其中一个结果。

image-20211023123957182

考虑到题目说candidates中的数字可以无限制的重复提取,也就是说在这一轮首先选了2,下一轮可以继续选2。这一轮选了3, 下一轮仍然可以选3, 首先我们可能会想跟前面所提到的全排列一样,每次for循环都是从0开始,但是这题需要注意的是,组合需要是唯一的,也就是至少有一个数是不同的。从下图可以发现,先选择了一个数,他就会将该数与包括他自身在内的所有可能的结果都回溯一遍,因此我们在选择后面的数时,就不需要重新选择前面的数来尝试,因为在之前已经尝试过了,因此在递归的过程中,需要将一个变量将当前循环的开始的索引记录下来,用于下次递归循环索引的开始,就不需要每次都从0开始,造成重复的组合。

image-20211023124912981

class Solution {
    LinkedList<List<Integer>> res = new LinkedList<>();
    LinkedList<Integer> path = new LinkedList<>();
    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        if(candidates.length == 0) return res;
        Arrays.sort(candidates);
        dfs(candidates, target, 0);
        return res;
    }

    public void dfs(int[] candidates, int target, int begin){
        if(target < 0 ) return;
        if(target == 0) res.add(new LinkedList(path));
        for(int i = begin; i < candidates.length; i++){
            if(target - candidates[i] < 0) break;
            path.add(candidates[i]);
            dfs(candidates, target - candidates[i], i); //将当前的循环索引传递到下一个递归中去。
            path.removeLast();
        }
    }
}

leetcode 40.组合总和 Ⅱ

题目描述:

给定一个数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。

candidates 中的每个数字在每个组合中只能使用一次。

注意:解集不能包含重复的组合。

示例:

Input: candidates = [10,1,2,7,6,1,5], target = 8
Output: [[1,1,6],[1,2,5],[1,7],[2,6]]

Input: candidates = [2,5,2,1,2], target = 5
Output: [[1,2,2],[5]]

解题思路:

其实本题和39题主要有两个不同,一是每个数字只能使用一次,即下次递归时不能在选择这个数字,既然我们前面说了,不能选前面的数字,本题又不能选自己这个数字,那么只能选择后面的数字了,因此每次递归都需要将begin+1。二是数组中会有重复的数组。

image-20211023132533942

首先我们要知道,当选定其中一个数时,最后出来的结果也就是该数后面的数的组合有多少个。就拿例题中所说

当选定1为第一个数时,这时还剩下[2,2,2,5],那么这时候的结果也就是1+[2,2,2,5]的组合。

当选定2为第一个数时,这时还剩下[2,2,5], 那么这时候的结果就是2+ [2,2,5]的组合。

当选定第3个2为第二个数时,这时还剩下[2,5], 那么这时候的结果就是2+[2,5]的组合。

要注意,这里的[2,2,5]和[2,5]其实是包含关系的,因为题目并不是要求每个数都要用到,而是加起来等于target即可。

其实我们就可以发现,当有数相同时,第一个相同的数及其组合就会包含了后面相同的数及其组合,那么既然前面已经算了一次,后面为什么还要继续算呢,这时候就应该跳过后面相同数的循环。

其实做递归时,我们要么是递归到最后一层来判断情况,要么是在第一层就判断情况。而本题只需要在第一层,即target=5时就开始判断什么时候跳过循环,那么后面递归的过程其实是一样的。

class Solution {
    LinkedList<List<Integer>> res = new LinkedList<>();
    LinkedList<Integer> path = new LinkedList<>();
    public List<List<Integer>> combinationSum2(int[] candidates, int target) {
        Arrays.sort(candidates);  //先进行排序,使相同的数靠在一起。
        dfs(candidates, target, 0);
        return res;
    }
    
    public void dfs(int[] candidates, int target, int begin){
        if(target < 0) return;
        if(target == 0) res.add(new LinkedList(path));
        for(int i = begin; i < candidates.length; i++){
            if(target - candidates[i] < 0) break;
            if (i > begin && candidates[i] == candidates[i - 1]) continue;  //当选择后面相同的数时,跳过循环。
            path.add(candidates[i]);
            dfs(candidates, target - candidates[i], i+1);
            path.removeLast();
        }
    }
}

leetcode 77. 组合

题目描述:

给定两个整数 nk,返回范围 [1, n] 中所有可能的 k 个数的组合。

你可以按 任何顺序 返回答案。

示例:

Input: n = 4, k = 2
Output: [ [2,4],[3,4],[2,3],[1,2],[1,3],[1,4]]

Input: n = 1, k = 1
Output: [[1]]

解题思路:

本题与上面的组合题类似,只是该题没有给出一个数组,二是需要自己取[1, n]中的数。

重点:

本题需不需要begin?

需要,因为不能重复的取自身的数,因此需要begin标记当前循环到哪个数字了,然后从当前数字的下一个数字开始。

返回的条件是什么?

当path的大小等于k时,说明已经有k个数了,此时应该添加该结果并返回撤销上一次加入的数。

剪枝的方法?

由于取数只能从当前数往后面取,那么如果当前数加上后面的数都不够还需要加入的数( 即 k − p a t h . s i z e ( ) k - path.size() kpath.size() )时怎么办呢?那么就只能退出循环,也就是说从这个数开始,之后的递归都是没有办法满足题目要求的。条件为: n − i + 1 < k − p a t h . s i z e ( ) n - i + 1 < k - path.size() ni+1<kpath.size() 时 就要退出循环,这时候 i < = n − ( k − p a t h . s i z e ( ) ) + 1 i <= n - (k-path.size()) + 1 i<=n(kpath.size())+1

算法代码:

class Solution {
    LinkedList<List<Integer>> res = new LinkedList<>();
    LinkedList<Integer> path = new LinkedList<>();
    public List<List<Integer>> combine(int n, int k) {
        dfs(n,k,1);
        return res;
    }
    public void dfs(int n, int k, int begin){
        if(path.size() == k){
            res.add(new LinkedList<>(path));
            return;
        }
        for (int i = begin; i <= n - (k-path.size()) + 1; i ++){  // 剪枝,当前能加入小于path当前剩余容量时,则停止循环。即n - i + 1 < k - path.size 退出
            path.add(i);
            dfs(n,k,i+1);
            path.removeLast();
        }
    }
}

leetcode 78. 子集

题目描述:

给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。

解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。

示例:

Input: nums = [1,2,3]
Output: [[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]

Input: nums = [0]
Output: [[],[0]]

解题思路:

**思路一:**仔细观察可以发现本题与上题77其实时很类似的,因为是求一个整数数组的组合,只是几个数的组合会有限定,一开始是0个数的组合,然后是一个数的组合,两个数的组合,… ,最后是nums数组大小的组合。也就是说size在不断增大,因此可以在不同的size时调用递归回溯即可。

class Solution {
    LinkedList<List<Integer>> res = new LinkedList<>();
    LinkedList<Integer> path = new LinkedList<>();
    public List<List<Integer>> subsets(int[] nums) {
        res.add(path);
        for(int i = 1; i <= nums.length; i++){
            dfs(nums,0,i);
        }
        return res;
    }

    public void dfs(int[] nums, int begin, int size){
        if(path.size() == size){
            res.add(new LinkedList<>(path));
            return;
        }
        for(int i = begin; i < nums.length; i++){
            path.add(nums[i]);
            dfs(nums, i+1, size);
            path.removeLast();
        }
    }
}

思路二:

虽然思路一做出来了,但是发现它的时间复杂度较高,可能是在多次循环多次调用递归的原因。参考官方题解的,可以发现每进行一层循环时,我们有两种选择,一是选择了该层的数字再进入下一层,二是不选择直接进入下一层。当无法再进入下一层的时候保存该子集,也就是该层是数组的大小时停止。因此我们需要使用index来传递递归开始的索引,最后index为数组长度时保存并返回。参考liweiwie大佬的图

image-20211024122817795

class Solution {
    List<Integer> t = new ArrayList<Integer>();
    List<List<Integer>> ans = new ArrayList<List<Integer>>();

    public List<List<Integer>> subsets(int[] nums) {
        dfs(0, nums);
        return ans;
    }

    public void dfs(int cur, int[] nums) {
        if (cur == nums.length) {
            ans.add(new ArrayList<Integer>(t));
            return;
        }
        t.add(nums[cur]);  //此时选择了该层的数
        dfs(cur + 1, nums);  // 进入下一层
        t.remove(t.size() - 1); //去掉该层的数,也就是不选择该层
        dfs(cur + 1, nums);  // 进入下一层。
    }
}

思路三: 利用二进制来判断一个数是否在某个子集中

对于一个nums数组,里面的每一个元素都有着选择与不选择两个选项,这时候可以使用二进制的01来判断该数是否在集合中。0 表示不在集合中,1表示在集合中。

以下以[1, 2, 3] 为例

000 表示 3 个数都不在集合中,即当前的path = []
001 表示 1 个数在集合中,即当前的path = [1][3] 具体是哪个由代码的写法决定。
...
...
111 表示 3 个数都在集合中,即当前的path = [1, 2, 3]

代码实现:

推荐这部分大家可以用idea进行debug,会更清楚。

class Solution {
    List<Integer> t = new ArrayList<Integer>();
    List<List<Integer>> ans = new ArrayList<List<Integer>>();

    public List<List<Integer>> subsets(int[] nums) {
        int n = nums.length;
        for (int mask = 0; mask < (1 << n); ++mask) {  // (1 << n)表示1左移n位,举例n=3,左移3位后为8,表示所有子集的数量。
            t.clear();  // 将上一次的结果清空  
            for (int i = 0; i < n; ++i) {   
                if ((mask & (1 << i)) != 0) {  
                    // 举例mask = 2 时,二进制mask = 010, 此时只有010与mask进行与运算时,结果不为0,因此需要将1左移1为,即i=1,这时候取nums[i],即取保留第二位数。
                    // 再举一个mask = 101的情况,只有在001和100的时候与mask相与不为0,此时i分别为0和2,因此会分别添加nums[0] 和nums[2]作为子集。
                    t.add(nums[i]);
                }
            }
            ans.add(new ArrayList<Integer>(t));
        }
        return ans;
    }
}

回溯算法的小结就先到这里,之后如果有再做到有意思的题会继续添加。

如有错误,欢迎大家留言指出,大家一起进步

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值