回溯算法——子集、组合、排列问题

Rhythm_2019

作者:Rhythm_2019

创建时间:2021.04.04

修改时间:2021.04.05

Email:rhythm_2019@163.com

导读:本文主要讲述利用回溯算法生成数组的子集、组合和排列,三者都是在数组中不断做“选择元素”、“递归”、“取消选择”三件事,根据题目的不同要求加入一些条件约束(深度限制、判重)即可解决问题。文章最后总结了自己在写代码时所犯下的错误,通过本文自己也对回溯算法有了信的理解

通过阅读本文您可以解答下面算法题:

  1. LeetCode78. 子集
  2. LeetCode90. 子集 II
  3. LeetCode77. 组合
  4. LeetCode46. 全排列
  5. LeeCode47. 全排列 II

当然啦,最重要的是你在写回溯代码的时候的那种感觉,我在写代码的时候犯了很多错误,都放在文章的最后啦

<hr/>

我们先回顾一下什么是递归,简单来说就是自己调用自己,为什么自己调用自己就能解决问题呢?是因为问题存在重复的子问题,而且规模缩小到最小时问题的解是已知的。

不必将分治、递归、回溯华清界限,就当自己在写递归即可

下面是递归的模板

private void recursion(int level, int maxLevel, int param) {
    // Terminator
    
    // Process
    
    // Drill down
    
    // Restore
}

回溯算法其实就是再下探Drill down后对数据进行恢复,也就是Restore,从而达到不断试探但效果。在平时的算法练习中我常写递归,但是需要回溯的情况比较少(可能是我写的不多),比较常见的就是走迷宫(DFS染色)、生成子集、N皇后、数独等

子集

数组[1, 2]的子集有[]、[1]、[2]、[1, 2]四个,大家重点留意一下回溯的解法,其他解法大家看看就好

LeetCode 78 子集

给定一个数组nums,生成其所有子集,我想到的思路有:

  1. 自顶向下:已知[0, i]的所有子集,当前数字2可以选择加入他们,选择不加入他们,这样即可生成所有子集,我们把这个方法称为选择加入吧
  2. 对于任意元素都有两种选择,出现在集合里,不出现在集合里,这样就能生成所有子集,这个方法称之为选择出现吧
  3. 回溯算法,分别对数组中的元素做选自、递归、取消选择

先是第一种选择加入的思路

public List<List<Integer>> subsets(int[] nums) {
    if (nums.length == 0) {
        return new ArrayList<>();
    }
    return _subsets(nums.length - 1, nums);

}

public List<List<Integer>> _subsets(int level, int[] nums) {
    // Terminator
    if (level < 0) {
        List<List<Integer>> ans = new ArrayList<>();
        ans.add(new ArrayList<>());
        return ans;
    }
    // Process & Drill dwon
    // 获得前面的所有子集
    List<List<Integer>> subsets = _subsets(level - 1, nums);

    int size = subsets.size();
    for (int i = 0; i < size; i++) {
        List<Integer> list = new ArrayList<>(subsets.get(i));
        list.add(nums[level]);
        subsets.add(list);
    }

    return subsets;
}

第二种选择出现的思路

private List<List<Integer>> ans = new ArrayList<>();
public List<List<Integer>> subsetsWithDup(int[] nums) {
    if (nums.length == 0) {
        return ans;
    }
    Arrays.sort(nums);
    _subsets(0, nums, new ArrayList<>(), new HashSet<>());
    return ans;
}
public void _subsets(int level, int[] nums, ArrayList<Integer> list) {
    // Terminator
    if (level == nums.length) {
        ans.add(list);
        return;
    }
    // Process & Drill down
    ArrayList<Integer> list1 = (ArrayList<Integer>) list.clone();
    ArrayList<Integer> list2 = (ArrayList<Integer>) list.clone();
    
    // 出现
    list1.add(nums[level]);
    _subsets(level + 1, nums, list1, res);
    // 不出现
    _subsets(level + 1, nums, list2, res);
}

<hr/>

最后就是回溯算法,我们使用循环分别对数组中的元素做选择,调用递归函数,取消选择

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

private List<List<Integer>> subsets(int[] nums) {
    if (nums.length == 0) {
        return ans;
    }
    _subsets(0, nums, new ArrayList<>());
    return ans;
}

private void _subsets(int level, int[] nums, ArrayList<Integer> list) {
    // 每一步都要保存结果
    ans.add(new ArrayList<>(list));

    for (int i = level; i < nums.length; i++) {
        // 【选择】
        list.add(nums[i]);
        // 【递归】
        _subsets(i + 1, nums, list);
        // 【取消选择】
        list.remove(level);
    }
}

将其状态树画出来,大概是这样子的。途中一行代表递归的层级,黄色表示需要被保存的结果

子集状态树

LeetCode 90 子集 II

子集II

我一开始以位直接加一个哈希表对重复元素进行剪枝即可,折腾了半天才发现问题

子集不排序的后果

图上浅灰色方块表示使用哈希表剪枝的元素,比如最右边灰色的1由于和最前面的1重复了,我们就不对他进行递归了。黄色表示结果,大家可以看到深灰色的两个元素重复了,这时由于包含重复元素,很自然的[1, 2][2, 1]是重复的只能保留一个

如果我对数组进行排序,问题就会得到解决

排序后的子集

排序能够将重复元素放在一起,后面的元素就不会生成和前面数字相关的子集

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

public List<List<Integer>> subsetsWithDup(int[] nums) {
    if (nums.length == 0) {
        return ans;
    }
    Arrays.sort(nums);
    _subsets(0, nums, new ArrayList<>(), new HashSet<>());
    return ans;
}

private void _subsets(int level, int[] nums, ArrayList<Integer> list, Set<Integer> visited) {
    ans.add(new ArrayList<>(list));
    for (int i = level; i < nums.length; i++) {
        if (visited.contains(nums[i])) {
            continue;
        }
        visited.add(nums[i]);
        list.add(nums[i]);
        _subsets(i + 1, nums, list, new HashSet<>());
        list.remove(level);
    }
}

这里介绍了回溯的方法,我们也可以使用选择出现现的思路,不过也是需要先排序,然后计算重复元素个数,如果就一个,按照以前的逻辑兵分两路,如果超过n个(n > 1),说明存在重复元素,正确的逻辑应该是让数字出现0 ~ n

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

public List<List<Integer>> subsetsWithDup(int[] nums) {
    if (nums.length == 0) {
        return ans;
    }
    Arrays.sort(nums);
    _subsets(0, nums, new ArrayList<>(), new HashSet<>());
    return ans;
}

private void _subsets(int level, int[] nums, ArrayList<Integer> list, Set<Integer> visited) {
    // Terminator
    if (level >= nums.length) {
        ans.add(list);
        return;
    }
    // Process & Drill down
    int count = 1, index = level + 1;
    while (index < nums.length && nums[level] == nums[index]) {
        index++;
        count++;
    }
    if (count == 1) {
        // 没有重复
        ArrayList<Integer> list1 = (ArrayList<Integer>) list.clone();
        ArrayList<Integer> list2 = (ArrayList<Integer>) list.clone();
        // 出现
        list1.add(nums[level]);
        _subsets(level + 1, nums, list1, visited);
        // 不出现
        _subsets(level + 1, nums, list2, visited);
    } else {
        // 存在重复
        ArrayList<Integer> list1 = (ArrayList<Integer>) list.clone();
        // 不出现
        _subsets(level + count, nums, (ArrayList<Integer>) list1.clone(), visited);
        for (int i = 1; i <= count; i++) {
            list1.add(nums[level]);
            // 出现i次
            _subsets(level + count, nums, (ArrayList<Integer>) list1.clone(), visited);
        }
    }
}

组合

以前看动态规划的时候一直没弄明白排列和组合的区别,那题Coin Change i/ii记忆犹新。对于一个数组c长度为n,其组合有C(n, m)个,而排列数则有A(n, m)

LeetCode 77 组合

组合

子集包含所有组合的情况,入参k其实表示的是递归层数,所以我们只需要在子集的基础上添加一个终止条件即可

private List<List<Integer>> ans = new ArrayList<>();
public List<List<Integer>> combine(int n, int k) {
    if (n < 0) {
        return ans;
    }

    _combine(0, 1, n, k, new ArrayList<>());
    return ans;
}

private void _combine(int level, int start, int n, int k, List<Integer> list) {
    if (level == k) {
        ans.add(list);
        return;
    }

    for (int i = start; i <= n - (k - list.size()) + 1; i++) {
        list.add(i);
        _combine(level + 1,  i + 1, n, k, new ArrayList<>(list));
        list.remove(level);
    }
}

大家看图

组合

其实和子集的一毛一样,最右边灰色的3被剪枝的主要是因为他凑不到2个。

数组和数字的生成方式很类似,如果数组中存在重复元素相信大家都会处理了

不那么相关的题目还有:17. 电话号码的字母组合

排列

LeetCode 46 全排列

全排列

组合是子集的一部分,但是排列就不一样了,每次选择都能选前面的元素但又不能选到自己,所以我们可以创建一个哈希表来存储选择过的元素(这里选择保存其索引)

private List<List<Integer>> ans = new ArrayList<>();
public List<List<Integer>> permute(int[] nums) {
    if (nums == null || nums.length == 0) {
        return new ArrayList<>();
    }

    _permute(0,nums, new ArrayList<>(), new HashSet<>());
    return ans;
}

private void _permute(int level, int[] nums, List<Integer> List, Set<Integer> visited) {
    if (level == nums.length) {
        ans.add(List);
        return;
    }

    for (int i = 0; i < nums.length; i++) {
        if (visited.contains(i)) {
            continue;
        }
        visited.add(i);
        List.add(nums[i]);
        _permute(level + 1, nums, new ArrayList<>(List), visited);
        List.remove(nums[i]);
        visited.remove(i);
    }
}

画一下图大概就是这样

全排列

LeetCode 47 全排列 II

全排列II

现在包含重复元素,我们可以再创建一个哈希表用于记录当前层已使用过的元素,这样重复的元素就被剪枝

private List<List<Integer>> ans = new ArrayList<>();
public List<List<Integer>> permuteUnique(int[] nums) {
    if (nums == null || nums.length == 0) {
        return new ArrayList<>();
    }
    Arrays.sort(nums);
    _permute(0,nums, new ArrayList<>(), new HashSet<>());
    return ans;
}

private void _permute(int level, int[] nums, List<Integer> List, Set<Integer> indexVisited) {
    if (level == nums.length) {
        ans.add(List);
        return;
    }

    Set<Integer> contentVisited = new HashSet<>();
    for (int i = 0; i < nums.length; i++) {
        if (indexVisited.contains(i) || contentVisited.contains(nums[i])) {
            continue;
        }
        contentVisited.add(nums[i]);
        indexVisited.add(i);

        List.add(nums[i]);
        _permute(level + 1, nums, new ArrayList<>(List), indexVisited);
        List.remove(level);

        indexVisited.remove(i);
    }
}

这里涉及两个哈希表:

  1. indexVisited:用来记录递归时走过的元素,他保证了下一次递归不再访问某元素,作用于全局,需要被一直传递的
  2. contentVisited:用来保存某一层已遍历的元素,这样就能剪掉重复元素,作用域某一层级,所以不用传递

总结

这篇文章我大概写了两天,感觉还有些地方没说清楚,我觉得要把算法思想正确的表达出来是一件不容易的事,还需要多加理解和练习。对于上面这几个题目其实套路都差不多

private void recursion() {
    // 终止条件,一般都是限定递归层数
    
    // 循环做选择
    for (int i = start; i <= n; i++) {
        // 【选择】 将结果放入数组
        // 【递归】 Drill down
        // 【取消选择】 将选择移出数组
    }
}

总结一下需要注意的几个点:

  1. 我们写for循环的时候怎么写,应该从哪里开始到哪里结束:

    如果是子集和组合,为了不重复选择自己之前选择的元素,我们应该指定开始下标,每进入一曾递归下标往后移动。对于组合如果提米规定了输出组合的长度我们可以适当剪枝。对于排列数,每次都可以选之前的元素,所以是从0开始

  2. 什么时候需要引入HashSet判重:

    不包含重复元素:子集和组合是不需要的,因为通过开始下标我们已经排除了之前选择过的元素。而排列数则需要记录递归路上选择过的索引(元素值也可以),这样就不会重复选到之前选过的元素,这个哈希表需要全局使用,需要被传递

    包含重复元素:我们需要使用HashSet对当前层的重复元素进行剪枝,这个哈希表只作用于当前层,不需要传递

    所以包含重复元素时需要使用哈希表,而排列数本来就一定需要哈希表

  3. 对于结果list的传递需不需要clome:当然需要,我们最好使用构造方法创建新的,而且要注意创建的时机,一定是创建后马上加入结果集,中间不能有任何addremove操作
  4. 最后想说的是注意【选择】和【撤销选择】的方式,我曾经是这样写的代码

    private void _combine(int level, int start, int n, int k, List<Integer> list) {
       // ...
    
        for (int i = start; i <= n - (k - list.size()) + 1; i++) {
            list.add(i);
            _combine(level + 1,  i + 1, n, k, new ArrayList<>(list));
            // 注意这里
            list.remove(new Integer(i));
        }
    }

    这样写在元素不重复的情况下是没什么问题的,但是如果存在重复元素的话递归时会删掉第一个重复的元素,所以我们直接这样写

    list.add(i);
    _combine(level + 1,  i + 1, n, k, new ArrayList<>(list));
    // 注意这里
    list.remove(level);

    这样删除元素才正确

<hr/>

我个人觉得算法挺有意思的,自己也听喜欢写题,细想一下可能是因为这些题目规模较小,但非常奇妙,你的代码让计算机变得灵活,学习成本也不会太高,所以我更喜欢算法。

之前面试遇到的一个题目,如何正确计算0.3 / 0.2 ,已经脱了很久了,后面会给大家带来告计算机的小数和高精算法的内容,还有非常神奇的卡特兰数,大家可以期待一下。

阅读 1.4k
4 声望
1 粉丝
0 条评论
4 声望
1 粉丝
文章目录
宣传栏