LeetCode笔记(一)回溯

简介

递归

递归:一个过程或函数在其定义或说明中有直接或间接调用自身的一种方法,它通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解。

在LeetCode刷题过程中,遇到很多运用递归解法的题目,递归代码较难理解,但是可以使代码逻辑简洁。

回溯

回溯是一种算法思想,从问题的某一种可能出发,搜索从这种情况出发所能达到的所有可能,当这一条路走到” 尽头 “的时候, 再倒回出发点,从另一个可能出发,继续搜索。这种不断” 回溯 “寻找解的方法,称作“回溯法”。

参考《五大常用算法之四:回溯法》,可以了解到回溯算法的设计步骤为:

  1. 针对所给问题,确定问题的解空间:首先应明确定义问题的解空间,问题的解空间应至少包含问题的一个(最优)解。
  2. 确定结点的扩展搜索规则
  3. 以深度优先方式搜索解空间,并在搜索过程中用剪枝函数避免无效搜索。

剪枝

在回溯过程中,可能会抛弃其中一些不符合要求的“路径”,这一过程称之为剪枝处理。
由上述步骤可知,回溯算法可看做是从一个根节点不断向下探查的过程,其各个结果很像一棵树上的不同路径,所以常采用深度优先遍历,与递归方式结合起来解题,下面是LeetCode中一些典型的回溯算法入门题目的总结。

经典题目

LeetCode46 全排列

题目:46.全排列

分析:回溯算法与深度优先关系密切,深度优先每个节点访问一次,回溯算法可“回头”多次访问统一节点,并且可以针对一些无效数据进行剪枝处理。在此题中,无需剪枝,每次选定给定序列中的一个,可以形成一个树结构,对于一个数组,其全排列就是从根节点到每个叶子节点的路径,则使用DFS解决,在每个节点访问后回溯至父节点,进行其他路径的访问。

例如,求数组[1,2,3]的全排列时,访问过程如下:
图片来源:LeetCode中liweiwei1419题解
用path来记录走过的路径,可以看出,每次访问一个节点向下继续走时,都需要将此节点值加入路径,并且在“回头”时,需要删除上一次所走的路径,以便选择另一条路径。

当path长度等于数组长度时,一条完整的路径就形成了,即为数组的一个排列,要将其加入最终的结果数组中,题目中说明了所给序列无重复数字,所以无需考虑重复问题。当所有路径都加入到结果数组中,全排列求解完毕。

class Solution {
    public List<List<Integer>> permute(int[] nums) {
        List<List<Integer>> result = new ArrayList<>();
        int len = nums.length;
        if(len == 0) {
            return result;
        }
        boolean []used = new boolean[len];
        for(int i = 0; i < len; i++) {  // 初始化used数组
            used[i] = false;
        }
        List<Integer> path = new ArrayList<>(); // 记录路径
        dfs(nums,len,0,path,used,result);
        return result;
    }

    void dfs(int []nums,int len,int depth,List<Integer> path,boolean[] used,List<List<Integer>> result) {
        if(depth == len) {  // 访问深度等于数组长度,则一个排列生成
            result.add(new ArrayList<>(path));
            return;
        }
        for(int i = 0; i < len; i++) {
            if(!used[i]) {
                path.add(nums[i]);
                used[i] = true;  // 已访问标志
                dfs(nums,len,depth + 1,path,used,result); // 向下继续访问打印路径
                used[i] = false; // 回溯
                path.remove(path.size() - 1);
            }
        }
    }
}

LeetCode47 全排列II

题目:47.全排列II

分析:题中要求不能重复,且给定数组中有重复元素,则比起全排列需要多一步的剪枝,即每一层的不能同时出现两个相同的元素,然后回溯求排列即可。

如何剪枝?

图片来源:LeetCode中liweiwei1419题解
此题的剪枝处理,就是避免数组中的相同元素出现在同一层,例如[1,1,2],在第一步选择了1,继续向下探查后形成排列(1,1,2),在“回头”时,不能再次选择1,否则会造成(1,1,2)排列的重复。要实现这种效果,在数组进行求排列前,要先对数组排序,只有排序后的数组,才能使得相同的元素聚集,在回溯判断时,能够很容易的判断,在代码上体现为nums[i] == nums[i - 1]的判断。

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

    void dfs(int start, List<Integer> path, int[] nums, boolean[] used) {
        if (start == nums.length) {
            result.add(new ArrayList<>(path));
            return;
        }
        for (int i = 0; i < nums.length; i++) {
            if (!used[i]) {
                if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) { // 剪枝,跳过重复的值
                    continue;
                }
                path.add(nums[i]);
                used[i] = true;
                dfs(start + 1, path, nums, used);
                used[i] = false;
                path.remove(path.size() - 1); // 回溯
            } else {
                continue;
            }
        }
    }
}

LeetCode39 组合总数

题目:39.组合总数

分析:所求为多个数字和为target的元组,可用target减去数组中每一个元素值,直至结果为0或为负数,因为同一个元素可重复,所以在每次减法后要回溯至上一状态再减下一个值,对于最终为负数的分支,进行剪枝处理,对于最终为0的分支,加入result集合中。

以给定数组为[2, 3, 6, 7], 目标值target = 7为例来分析:
以target = 7作为递归树的根节点,每次在数组中选择一个数,用target减去,这就是递归树中的一条分支,然后回溯,再选择另一个数减去,继续向下创立分支,若减后所得的数字小于0,这个分支不符合条件,结束递归,剪枝,若等于0,该组合是一个正确的解集,加入结果数组。
图片来源:LeetCode中liweiwei1419题解

需要注意的是,题目要求一个数可以在一个解集中重复出现,但是不同解集不能相同,这就要求同一条分支在选择要减去的数字时,可以重复选择,而不同路径所选择的数字与其他同一层的路径不能相同,即在编写dfs中的for循环时,不能与以上两题相同(for (int i = 0; i < nums.length; i++)),而应该为for(int i = start; i < len; i++),每次从传入的起点开始查找,避免与之前的i重复访问数组的同一位置。

class Solution {
    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        List<List<Integer>> result = new ArrayList<>();
        int len = candidates.length;
        if(target <= 0 && len == 0) {
            return result;
        }
        List<Integer> path = new ArrayList<>(); // 记录路径
        dfs(candidates,0,len,target,path,result);
        return result;
    }

    void dfs(int[] candidates,int start,int len,int target,List<Integer> path,List<List<Integer>> result) {
        if(target < 0) { // 剪枝
            return;
        }
        if(target == 0) { // 符合条件,加入结果列表
            result.add(new ArrayList<>(path));
            return;
        }
        for(int i = start; i < len; i++) { // i从start开始,而不是0
            path.add(candidates[i]);
            dfs(candidates, i, len, target - candidates[i], path, result); 
            // 重点说明:同一分支可重复出现,所以下一轮搜索还是从i开始,但是从此分支结束后,i会自增1,所以同一层的其他分支一定不会再访问i之前的元素了
            path.remove(path.size()-1); // 回溯
        }
    }
}

LeetCode40 组合总数II

题目:40.组合总数II
分析:与题39不同,此题每个数仅能使用一次,即相同的数字在递归树的同一层不能同时出现,所以先对candidates数组进行排序,在剪枝时要比较candidates[i]与candidates[i+1],相等的直接return剪去;其次在递归时,由于不可重复,直接向下一层探查,例如当前为第i层,那么递归条件是i+1而并非i。

如何剪枝?

此题的剪枝与全排列II几乎一样,也是要求同一元素在同一层不能同时出现,同样的也是先对所给数组排序,使相等的元素集中起来便于判断,在利用candidates[i] == candidates[i+1]来判断是否可用candidates[i+1],若相等,则跳过避免重复,否则可使用candidates[i+1]形成组合。其余递归结束和剪枝条件与39题相同。

class Solution {
    List<List<Integer>> result = new ArrayList<>();
    List<Integer> path = new ArrayList<>();
    public List<List<Integer>> combinationSum2(int[] candidates, int target) {
        if(candidates.length == 0) {
            return result;
        }
        Arrays.sort(candidates);
        dfs(0,candidates,target);
        return result;
    }
    void dfs(int start,int []candidates,int target) {
        if(target < 0) {
            return; // 剪枝
        }
        if(target == 0) {
            result.add(new ArrayList<>(path));
            return;
        }
        for(int i = start; i < candidates.length; i++) {
            if(i > start && candidates[i] == candidates[i-1]) {
                continue; // 回溯时同一层遇到相同的值,直接跳过
            }
            path.add(candidates[i]);
            dfs(i + 1,candidates,target - candidates[i]); // 不可重复使用,所以直接向i+1层探查
            path.remove(path.size() - 1); // 回溯
        }
    }
}

总结

以上四道题目,区别主要在于dfs中for循环遍历的位置和同层或不同分支重复元素的剪枝,在代码上的变化就是起始点的改变、初始数据的排序与相邻元素的比较。

全排列的两道题目,起始点选择都是0,是因为它们无需限制下一次选择的起点,每次只需继续往下走就可以。
对于第一个全排列,题目已定元素不重复,所以无需考虑排列重复的问题;对全排列II,数组内有重复,就要避免出现不同的分支得出相同排列的情况,所以对原始数组排序,用比较相邻元素的方法进行同层的剪枝。

组合总数的两道题则不同。
首先是39题,它限定了同一层元素不能重复而同一分支可以重复,这就意味着每次递归的起始点是同一层,而不是直接向下走;其次40题,在改变起始点的基础上增加了约束了同分支元素也只能使用一次,即不仅要进行同层的剪枝,还要进行垂直方向上的剪枝,这就需要在递归时设置直接向下访问(垂直剪枝保证同一个元素不会多次使用),并且要排序原始数组以比较相邻元素(同层剪枝保证不会出现重复解集

可以看出,

  • 46.全排列:最基础的回溯题目,因为题目保证无重复元素,所以无需任何剪枝处理,可作为一个模板使用;
  • 47.全排列II:有重复元素,需考虑同层剪枝避免出现重复的解集;
  • 39.组合总数:考虑了同一个元素多次使用时递归起始点的改变,题目保证无重复元素,所以无需同层剪枝;
  • 40.组合总数II:既要垂直剪枝保证同一个元素不会多次使用,又要同层剪枝保证不会出现重复解集(同层剪枝方法与47题相同——先排序后比较相邻元素)。

笔记参考了很多大佬的题解和思想,汇总了最近刷到的一些比较基础的回溯算法题目,总结不同题目之间的差异,作为日后复习的参考。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值