算法学习(9):LeetCode刷题之回溯算法

前言

算法的实现依赖于深度优先搜索DFS,DFS算法尽可能深的搜索某一条路径,直到到达边界,而回溯算法在DFS算法的基础上强调了回退操作,即回溯法采用尝试的思想,分步解决问题,当发现现有的分步结果不能得到正确的结果,它将取消上一步的选择,再通过其他可能的分步继续尝试。

回溯算法,其实还是一棵树的遍历过程。整个回溯过程涉及到3个方面:
1、路径:即已经做出的选择。
2、选择列表:即当前可以做的选择。
3、结束条件:已经符合条件,无法继续选择。

回溯算法通常用于求解一个问题的所有解,如排列、组合、子集等问题。算法模板如下:

result = []
void dfs(路径, 选择列表):
    if 满足结束条件:
        result.add(路径)
        return

    for 选择 in 选择列表:
        做选择
        dfs(路径, 选择列表)
        撤销选择

根据上面这个模板,我们可以写出一道题目:给你一个数组nums,输出数组中所有元素可以组成的排列,其中nums中的元素可以重复选择。

class Solution {
    private List<List<Integer>> res = new ArrayList<>();
    public List<List<Integer>> permute(int[] nums) {
        List<Integer> path = new ArrayList<>();
        dfs(nums, path);
        return res;
    }

	// nums就是选择列表,path就是路径
    private void dfs(int[] nums, List<Integer> path) {
    	// 结束条件
        if (path.size() == nums.length) {
            res.add(new ArrayList<>(path)); // java是值传递,需要重新构造List对象
            return;
        }
		// 选择
        for (int i = 0; i < nums.length; i++) {
        	 // 先选择当前元素加入到路径
	         path.add(nums[i]);
	         System.out.println("递归:" + path); // 输出看过程
	         // 递归
	         dfs(nums, path);
			 // 递归后撤销选择,即将最后一个加进来的元素删除
	         path.remove(path.size() - 1);
	         System.out.println("回溯:" + path); // 输出看过程
        }
    }
}

得到的结果是:

[[1,1,1],[1,1,2],[1,1,3],[1,2,1],[1,2,2],[1,2,3],[1,3,1],[1,3,2],[1,3,3],[2,1,1],[2,1,2],[2,1,3],[2,2,1],[2,2,2],[2,2,3],[2,3,1],[2,3,2],[2,3,3],[3,1,1],[3,1,2],[3,1,3],[3,2,1],[3,2,2],[3,2,3],[3,3,1],[3,3,2],[3,3,3]]

控制台打印信息:

递归:[1]
递归:[1, 1]
递归:[1, 1, 1]
回溯:[1, 1]
递归:[1, 1, 2]
回溯:[1, 1]
递归:[1, 1, 3]
回溯:[1, 1]
回溯:[1]
递归:[1, 2]
递归:[1, 2, 1]
回溯:[1, 2]
递归:[1, 2, 2]
回溯:[1, 2]
递归:[1, 2, 3]
回溯:[1, 2]
回溯:[1]
递归:[1, 3]
递归:[1, 3, 1]
回溯:[1, 3]
递归:[1, 3, 2]
回溯:[1, 3]
递归:[1, 3, 3]
回溯:[1, 3]
回溯:[1]
回溯:[]
递归:[2]
递归:[2, 1]
递归:[2, 1, 1]
回溯:[2, 1]
递归:[2, 1, 2]
回溯:[2, 1]
递归:[2, 1, 3]
回溯:[2, 1]
回溯:[2]
递归:[2, 2]
递归:[2, 2, 1]
回溯:[2, 2]
递归:[2, 2, 2]
回溯:[2, 2]
递归:[2, 2, 3]
回溯:[2, 2]
回溯:[2]
递归:[2, 3]
递归:[2, 3, 1]
回溯:[2, 3]
递归:[2, 3, 2]
回溯:[2, 3]
递归:[2, 3, 3]
回溯:[2, 3]
回溯:[2]
回溯:[]
递归:[3]
递归:[3, 1]
递归:[3, 1, 1]
回溯:[3, 1]
递归:[3, 1, 2]
回溯:[3, 1]
递归:[3, 1, 3]
回溯:[3, 1]
回溯:[3]
递归:[3, 2]
递归:[3, 2, 1]
回溯:[3, 2]
递归:[3, 2, 2]
回溯:[3, 2]
递归:[3, 2, 3]
回溯:[3, 2]
回溯:[3]
递归:[3, 3]
递归:[3, 3, 1]
回溯:[3, 3]
递归:[3, 3, 2]
回溯:[3, 3]
递归:[3, 3, 3]
回溯:[3, 3]
回溯:[3]
回溯:[]

整个回溯的过程形成了下面这样一颗树,下面的leetcode题目都是以这个回溯过程为基础的,只不过是根据题意加入了一些剪枝的操作。
在这里插入图片描述

正文

1、LeetCode No.46 全排列

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

示例 1:
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

这道题根据输入示例可以画出下面这颗决策树,站在图中的每个节点上,都将面临选择。结合上面说的3个概念,如果你首先选择了1,那么[1]就是路径,记录了你已经做过的选择,[2、3]就是选择列表,表示你当前可以做的选择,结束条件就是遍历到了树的底层。

这道题目相当于在前言中的算法模板上进行剪枝,怎么剪枝的呢?就是上一层已经选择过了一个数后,下一层这个数就不能再选了。比如上一层已经选择过了2,下一层就不能再选2了,只能在1、3中选。如果已经选择了2、1,最后一层就只能选择3了。那么要怎么控制呢?答案是增加一个boolean数组,记录回溯过程中每个元素的遍历情况,如果当前元素被遍历到,就将boolean数组对应位置置为true,下一层递归访问的时候,遇到该位置是true,就不会访问该位置的元素了,递归完了之后,再把boolean数组该位置置为false,供之后的遍历用到。
在这里插入图片描述
解题代码:

class Solution {
    private List<List<Integer>> res = new ArrayList<>();
    public List<List<Integer>> permute(int[] nums) {
        List<Integer> path = new ArrayList<>();
        // boolean数组记录nums数组每个位置被访问过的情况
        boolean[] used = new boolean[nums.length];
        dfs(nums, path, used);
        return res;
    }

    private void dfs(int[] nums, List<Integer> path, boolean[] used) {
    	// 终止条件是path长度和nums长度一样
        if (path.size() == nums.length) {
            res.add(new ArrayList<>(path));
            return;
        }
		// 求全排列递归需要遍历整个数组,
        for (int i = 0; i < nums.length; i++) {
        	// 每次递归需要剪枝,即判断元素是否访问过
            if (!used[i]) {
                path.add(nums[i]); // 路径加入元素
                used[i] = true; // 将元素标记为已访问
                //System.out.println("递归:" + path); // 看递归过程
                dfs(nums, path, used); // 递归

                path.remove(path.size() - 1); // 回溯过程需要取消当前的选择
                //System.out.println("回溯:" + path); // 看回溯过程
                used[i] = false; // 将元素标记解除
            }
        }
    }
}

2、LeetCode No.47 全排列II

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

示例 1:
输入:nums = [1,1,2]
输出:
[[1,1,2],
 [1,2,1],
 [2,1,1]]

这道题跟上一题的不同之处在于,给定的数组nums里面有重复数字,而结果集中又不让有重复。整体代码框架不变,在上一题的基础上继续剪枝,怎么剪呢?回溯过程中每一层循环的时候,遇到重复的数字就跳过。需要在for循环中加入以下判断:

if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == true) {
    // System.out.println("skip");
    continue;
}

在这里插入图片描述
整体的代码如下:

class Solution {
    private List<List<Integer>> res = new ArrayList<>();
    public List<List<Integer>> permuteUnique(int[] nums) {
        if (nums.length == 0) {
            return res;
        }
        Arrays.sort(nums); // 去重需要对列表进行排序
        List<Integer> path = new ArrayList<>();
        boolean[] used = new boolean[nums.length];
        dfs(nums, path, used);
        return res;
    }

    void dfs(int[] nums, List<Integer> path, boolean[] used) {
        // 退出条件
        if (path.size() == nums.length) {
            res.add(new ArrayList<>(path));
        }

        for (int i = 0; i < nums.length; i++) {
            // 结果去重剪枝
            if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == true) {
                // System.out.println("skip");
                continue;
            }
            if (!used[i]) {
                path.add(nums[i]);
                used[i] = true;
                // System.out.println("递归:" + path);

                dfs(nums, path, used);

                path.remove(path.size() - 1);
                used[i] = false;
                // System.out.println("回溯:" + path);
            }
        }
    }
}

3、LeetCode No.39 组合总和

给定一个无重复元素的正整数数组 candidates 和一个正整数 target ,找出 candidates 中所有可以使数字和为目标数 target 的唯一组合。candidates 中的数字可以无限制重复被选取。如果至少一个所选数字数量不同,则两种组合是唯一的。

输入: candidates = [2,3,6,7], target = 7
输出: [[7],[2,2,3]]

这道题,同样适用回溯法遍历树,其中题目给出的nums数组中的元素可以重复选择,但是最终结果里面不能有重复子数组,那要怎么剪枝呢?就是在回溯的遍历过程中,如果遍历到第i个元素,该元素再下一层遍历的时候,就不能从0开始了,要从i开始。看下面的图示,当遍历到元素3的位置时,下一层遍历就不能再选择2了,因为前面的2已经选择过3了,如果这次3再去选2,那么就会造成最终的结果集中有2个子数组【2、3、xxx】,【3、2、xxx】,这2种子数组认为是重复的。
在这里插入图片描述
完整代码如下:

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

        // dfs方法增加一个start字段,用它来控制下一层遍历从哪个位置开始
        private void dfs(int[] candidates, int target, List<Integer> path, int start) {
            // 边界条件
            if (target < 0) {
                return;
            }
            // 命中条件
            if (target == 0) {
                // System.out.println("OK"); // 调试看过程
                res.add(new ArrayList<>(path));
            }
            // i不再从0开始了,start是我们定义的控制下一层起始位置
            for (int i = start; i < candidates.length; i++) {
                path.add(candidates[i]);
                // System.out.println("递归:" + "i:" + i + ",start:" + start + "---" + path);  // 调试看过程
                // 当前层的位置,是下一层遍历的起始位置,所以递归时,入参start要传入i。
                dfs(candidates, target - candidates[i], path, i);
                path.remove(path.size() - 1);
                // System.out.println("回溯:" + "i:" + i + ",start:" + start + "---" + path);  // 调试看过程
            }
        }
    }

4、LeetCode No.40 组合总和II

给定一个数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的每个数字在每个组合中只能使用一次。
注意:解集不能包含重复的组合。 

示例 1:

输入: candidates = [10,1,2,7,6,1,5], target = 8,
输出:
[
[1,1,6],
[1,2,5],
[1,7],
[2,6]
]

这道题有2个不能重复的限制,来看下如何剪枝。
1、首先要考虑数组中的元素不能重复选择,且最终结果中结果不重复,所以在递归过程中下一层循环的起始位置是当前层元素位置+1,比如下图中第一层遍历到了第一个1,下一层开始遍历的位置就不能从0 开始,要从下标1开始,不然同一个数就重复选择了;遍历到第二个1,下一层遍历就要从下标2开始,因为下标0对应的1跟自己重复了,下标1对应的1组成了【1、1、xxx】,这个在第一轮已经有这个结果了,重复了,要干掉。针对这种情况,可以在递归方法增加一个参数start来控制下一层循环的起始位置。
2、数组中有重复元素,但是要求最终结果中不要重复,所以要在每一轮迭代中,跳过重复元素。跳过重复元素的前提是要先对数组进行排序

完整代码如下:

class Solution {
    private List<List<Integer>> ret = new ArrayList<>();

    public List<List<Integer>> combinationSum2(int[] candidates, int target) {
        if (candidates.length == 0) {
            return ret;
        }
        // 先对数组进行排序,以便去重
        Arrays.sort(candidates);
        List<Integer> path = new ArrayList<>();
        dfs(candidates, 0, target, path);
        return ret;
    }
	// 递归函数使用start参数控制下一层的起始位置
    private void dfs(int[] candidates, int start, int target, List<Integer> path) {
        if (target < 0) {
            return;
        }
        if (target == 0) {
            ret.add(new ArrayList<>(path));
        }

        for (int i = start; i < candidates.length; i++) {
        	// 如果当前元素和前一个元素相等,则继续
            if (i > start && candidates[i] == candidates[i - 1]) {
                continue;
            }
            path.add(candidates[i]);
            // System.out.println("递归:" + path);
            dfs(candidates, i + 1, target - candidates[i], path);
            path.remove(path.size() - 1);
            // System.out.println("回溯:" + path);
        }
    }
}

5、LeetCode No.78 子集

给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。

示例 1:
输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]

这道题比上面的几道题要简单,只需要控制start的起始位置。

class Solution {
        private List<List<Integer>> ret = new ArrayList<>();
    public List<List<Integer>> subsets(int[] nums) {
        if (nums.length == 0) {
            return ret;
        }
        List<Integer> path = new ArrayList<>();
        dfs(nums, path, 0);
        return ret;
    }
	// start参数控制下层循环的起始位置
    private void dfs(int[] nums, List<Integer> path, int start) {
    	// 对于这道题来说,遇到一条path就扔进结果集
        ret.add(new ArrayList<>(path));

        for (int i = start; i < nums.length; i++) {
            path.add(nums[i]);
            //System.out.println("递归:" + path);
            dfs(nums, path, i + 1);
            path.remove(path.size() - 1);
            //System.out.println("回溯:" + path);
        }
    }
}

6、LeetCode No.90 子集II

给你一个整数数组 nums ,其中可能包含重复元素,请你返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。返回的解集中,子集可以按 任意顺序 排列。

这道题跟上题的区别只在于,数组中有重复元素,而结果集中不让包含重复。

class Solution {

        private List<List<Integer>> ret = new ArrayList<>();

    public List<List<Integer>> subsetsWithDup(int[] nums) {
        if (nums.length == 0) {
            return ret;
        }
        // 排序是为了跳过重复元素
        Arrays.sort(nums);
        List<Integer> path = new ArrayList<>();
        boolean[] used = new boolean[nums.length];
        dfs(nums, path, 0);
        return ret;

    }
	// start参数控制下层循环的起始位置
    private void dfs(int[] nums, List<Integer> path, int start) {
        if (start > nums.length) {
            return;
        }
        ret.add(new ArrayList<>(path));
        // System.out.println("OK," + start);

        for (int i = start; i < nums.length; ++i) {
        	// 跳过重复元素
            if (i > start && nums[i] == nums[i - 1]) {
                continue;
            }
            path.add(nums[i]);
            // System.out.println("递归:" + i + ", " + path);

            dfs(nums, path, i + 1);

            path.remove(path.size() - 1);
            // System.out.println("回溯:" + i + ", "+ path);
        }
    }
}

7、LeetCode No.77 组合

给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。
你可以按 任何顺序 返回答案。

示例 1:

输入:n = 4, k = 2
输出:
[示例 1:

输入:n = 4, k = 2
输出:
[
  [2,4],
  [3,4],
  [2,3],
  [1,2],
  [1,3],
  [1,4],
]

经过了上面这么多题的讲解,这道题也能够轻松写出来了。

    class Solution {
        private List<List<Integer>> ret = new ArrayList<>();
        public List<List<Integer>> combine(int n, int k) {
            if (n < 1) {
                return ret;
            }
            dfs(n, new ArrayList<>(), 1, k);
            return ret;
        }

        private void dfs(int n, List<Integer> path, int start, int k) {
            if (path.size() == k) {
                ret.add(new ArrayList<>(path));
                return;
            }
            for (int i = start; i <= n; i++) {
                path.add(i);
                dfs(n, path, i + 1, k);
                path.remove(path.size() - 1);
            }
        }
    }

总结

回溯法适用于求所有可能的解,依赖深度优先搜索算法,关键是每次做出选择并递归后,要取消做过的选择。

一般题目中会给出各种限制条件,这种就会需要剪枝,剪枝的逻辑还是需要多多理解。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值