「回溯算法」问题选讲-2(认识「剪枝」)(自己的草稿,内容不严谨,与之前有重复,不用看)

这一节我们来谈「剪枝」这个话题。在上一节我们分析了,「回溯算法」的时间复杂度一般是指数级别的,这个算法的时间复杂度随着数据的增加增长是很快的。

由于回溯算法的时间复杂度很高,因此在遍历的时候,如果能够提前知道这一条分支不能搜索到满意的结果,这一分支就可以跳过,这一步操作就是在一棵树上剪去一个枝叶,被人们很形象地称之为剪枝

回溯算法会大量应用「剪枝」技巧达到以加快搜索速度。这里有几点提示:

1、有时,需要做一些预处理工作(例如排序)才能达到剪枝的目的。虽然预处理工作虽然也消耗时间,但和剪枝能够节约的时间相比是微不足道的。因此,能预处理的话,就尽量预处理;

2、正是因为回溯问题本身时间复杂度就很高,所以能用空间换时间就尽量使用空间。

对于一些特定的问题也必须通过「剪枝」才能搜索到精准的答案。

下面我们来看一下,「力扣」第 47 号问题,全排列问题的第 2 道问题,这道题与第 46 号问题的区别在于:输入数组有重复的元素,因此搜索出来的结果也一定会有重复的列表。

如何删除这些重复的列表呢?一种想法是按照第 46 题的解法,直接在搜索结果当中进行删除,但是我们只要具体操作一下就会发现这样的操作:是不方便实现的。连放进哈希表里去重都是不方便实现的。

  • 列表元素的去重一个比较容易想到的方案是:对列表元素进行排序然后再逐个比对;
  • 而另一种更具操作性的方案是:我们先对输入数组进行排序,在搜索的过程当中我们就能够发现能够产生重复问题的分支,我们来看下这个问题的树形结构。

我们先按照第 46 题的做法,把树形图画全,然后去发现产生重复分支的原因:在搜索起点第 21 进行遍历的时候,我们会发现由于第 11 已经取消了选择,那么接下来在搜索的过程当中,第 11 还会再次出现,因此在这一个分子所产生的结果就肯定会是重复的。

于是我们就需要捕捉到,即将要产生重复分支的这个瞬间,然后将它跳过。跳过的语句经常是 continue 或者是 break 这样的循环控制的语句。

对于这道问题,就是「当搜索起点去它前一个分支的搜索起点一样,并且前一个分支刚刚好被取消了选择的时候」,在这里「刚刚好被取消了选择」大家对比一下这个分支,搜索起点 1 和上一轮的搜索起点 1 是一样的,但是上一轮的 1 是刚刚被选择了。

依然是从「深度优先遍历」的角度去考虑这个「回溯算法」。

下面我们来看一下代码,我们可以在「力扣」第 46 题的基础上稍作修改。

  • 首先对输入数组进行排序,这是剪枝的前提;
  • 然后我们需要加上这一段代码:
if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) {
		continue;
}

Java 代码:

import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Deque;
import java.util.List;

public class Solution {

    public List<List<Integer>> permuteUnique(int[] nums) {
        int len = nums.length;
        List<List<Integer>> res = new ArrayList<>();
        if (len == 0) {
            return res;
        }

        // 排序(升序或者降序都可以),排序是剪枝的前提
        Arrays.sort(nums);

        boolean[] used = new boolean[len];
        // 使用 Deque 是 Java 官方 Stack 类的建议
        Deque<Integer> path = new ArrayDeque<>(len);
        dfs(nums, len, 0, used, path, res);
        return res;
    }

    private void dfs(int[] nums, int len, int depth, boolean[] used, Deque<Integer> path, List<List<Integer>> res) {
        if (depth == len) {
            res.add(new ArrayList<>(path));
            return;
        }

        for (int i = 0; i < len; ++i) {
            if (used[i]) {
                continue;
            }

            // 剪枝条件:i > 0 是为了保证 nums[i - 1] 有意义
            // 写 !used[i - 1] 是因为 nums[i - 1] 在深度优先遍历的过程中刚刚被撤销选择
            if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) {
                continue;
            }

            path.addLast(nums[i]);
            used[i] = true;

            dfs(nums, len, depth + 1, used, path, res);
            // 回溯部分的代码,和 dfs 之前的代码是对称的
            used[i] = false;
            path.removeLast();
        }
    }

    public static void main(String[] args) {
        Solution solution = new Solution();
        int[] nums = {1, 1, 2};
        List<List<Integer>> res = solution.permuteUnique(nums);
        System.out.println(res);
    }
}

这就是这一节的内容,大家也发现,其实我们在讲解的时候,一直都在和大家用图形说话,的确,回溯算法的问题很多时候就是和图形相关的,并且很多时候回溯算法就是在一个树形问题上进行遍历。

因此,做出回溯算法的第 1 步,是:

把题目抽象成为树形问题,然后思考程序如何在这个树形问题上进行遍历,在适当的时候,需要删除一些枝叶,以加快搜索结果。

在草稿纸上画出树形图是非常关键的一步。

画出图形,就能够帮助我们更加深刻地理解题目的条件,然后用一两个具体的例子代入,就能够发现剪枝的条件。

其实不管是多么高深的算法,其实都是人写的,程序只是帮助人们实现算法的思想。因此首先我们先去想人是怎么做这个问题的时候,往往就能打开思路。

下面提供几个相关的练习,这些练习都是「力扣」上非常经典的使用「回溯」算法解决的问题。

练习

1、「力扣」第 39 题、第 40 题:组合问题;

2、「力扣」第 78 题、第 90 题:子集问题;

3、「力扣」第 77 题:组合问题。

做这些问题的前提,依然是画出这些问题的「树形结构」,以方便我们打开思路。

下一节,我们来看一些一种特殊的回溯问题,这些问题看起来没有回溯,但实际上是回溯的问题。这就是字符串上的回溯问题。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值