回溯经典题目:黑板上排列组合你舍得解开吗?

187 阅读10分钟

回忆往前挪十年,那一年的数学正好学到排列组合,那一天最火的电影是《那些年一起追过的女孩》。课间广播台播放的是《那些年一起追过的女孩》主题曲,其中有一句:黑板上排列组合你舍得解开吗?巧的是黑板上的题目确实也是排列组合。其中一个同学说:哎,现在黑板上不正好是排列组合吗?但是没人理他,不过这个场景我永远的记住了。

扯远了。

在leetcode上有一堆排列、组合、子集的问题,所有这些排列组合的题目的解法都是同一个算法框架——回溯。看完这篇文章,下面这些排列组合的题目就全部可以AC了。

  • 全排列:https://leetcode-cn.com/problems/permutations/
  • 全排列2:https://leetcode-cn.com/problems/permutations-ii/
  • 字符串的排列:https://leetcode-cn.com/problems/zi-fu-chuan-de-pai-lie-lcof/
  • 组合:https://leetcode-cn.com/problems/combinations/
  • 组合总和:https://leetcode-cn.com/problems/combination-sum/
  • 组合总和2:https://leetcode-cn.com/problems/combination-sum-ii/
  • 组合总和3:https://leetcode-cn.com/problems/combination-sum-iii/
  • 组合总和4:https://leetcode-cn.com/problems/combination-sum-iv/
  • 子集:https://leetcode-cn.com/problems/subsets/
  • 子集2:https://leetcode-cn.com/problems/subsets-ii/

0.回溯框架前情提要

回溯说起来可能比较高大上。但是如果说深度优先遍历,你肯定会说,这个我知道这个我知道,我我我我(见卡姆表情包)。

对,回溯只是一种变了形的深度优先遍历,深度优先遍历在遍历的时候直接无差别对各种情况开枝散叶,直到所有情况都遍历完成。而回溯是更有章法一点的深度优先遍历,他使用同一个列表存储选择路径,当完成选择之后还会把这个选取去掉换成另一个选择,使用这样方式放所有可能性遍历完成。

总结一下,他们的区别主要就两个。

  1. 深度优先遍历是直接对各种情况开枝散叶遍历,回溯是通过选择和回退选择遍历所有情况。
  2. 深度优先遍历使用的是不同列表,回溯使用的是同一个列表。

下面有9道题目,咱们先用第一道题全排列讲一下普通深度优先遍历和回溯的区别,并引出回溯框架。后面就调几道套用回溯框架来展示结果了。

1.全排列

题意

Given an array nums of distinct integers, return all the possible permutations. You can return the answer in any order.

  • permutations 美[ˌpɜːrmjʊ'teɪʃn] 英[pɜːmjʊ'teɪʃ(ə)n] n. 变化组合 / 排列 / 置换 / <英>挑选

翻译:给定一个不同数字的列表,返回所有可能的排列。你可以以任何顺序返回。

示例:

输入: [1,2,3]

输出: [ [1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1] ]

思路推演

这种题目我们在上中学的时候就会在演算本上画出来,第一位选了1之后,我们第二位可以选择2或者3,第二位选择了2之后,第三位只能选择3,第二位选择了3之后,第三位只能选择2。

根据上面的思路我们可以写出深度优先遍历的代码,一次选择一个数字通过递归往深处选择,直到选择的数量等于三为止,还需要考虑不能有重复的数字。

我们看一下代码是这样写的:

class Solution {
    public List<List<Integer>> permute(int[] nums) {
        //1.使用空列表开始深度优先遍历
        dfs(nums, new ArrayList<>());
        return res;
    }

    List<List<Integer>> res = new ArrayList<>();
    private void dfs(int[] nums, List<Integer> list) {
        //2.终止条件:如果列表选择的数字超过了数组数量,则终止选择。
        if(list.size() >= nums.length) {
            res.add(list);
            return;
        }
        //3.遍历所有可能的选择
        for(int i = 0; i < nums.length; i++) {
            //4.过滤条件:如果之前的选择包含了这个数字则不选择了。
            if(!list.contains(nums[i])) {
                //5.下面三行代码则是做出当前的选择,继续往深处遍历,直到终止条件为止。
                List<Integer> curr = new ArrayList<>(list);
                curr.add(nums[i]);
                dfs(nums, curr);
            }
        }
    }
}

这样一个熟悉的深度优先遍历就完成了。

看完代码我们可以思考,为什么不直接使用深度优先遍历解题而进化出一个回溯的概念呢?

可以看到在注释5那里我们每一次做出选择都需要创建一个新的列表进入下一层,这样对空间的消耗不是很好。

知道了普通深度优先遍历的缺点,现在我们引申出回溯的概念,在注释5那里我们略作改动,使用同一个列表先做选择,等选择完成之后再回退该选择,达到只使用一个列表的目的。见如下代码,只有注释2和注释5存在改动。

class Solution {
    public List<List<Integer>> permute(int[] nums) {
        //1.使用空列表开始深度优先遍历
        dfs(nums, new ArrayList<>());
        return res;
    }

    List<List<Integer>> res = new ArrayList<>();
    private void dfs(int[] nums, List<Integer> list) {
        //2.终止条件:如果列表选择的数字超过了数组数量,则终止选择。
        if(list.size() >= nums.length) {
            res.add(new ArrayList<>(list));
            return;
        }
        //3.遍历所有可能的选择
        for(int i = 0; i < nums.length; i++) {
            //4.过滤条件:如果之前的选择包含了这个数字则不选择了。
            if(list.contains(nums[i])) {
                continue;
            }
            //5.做出当前的选择
            list.add(nums[i]);
            //6.进入下一层计算
            dfs(nums, list);
            //7.下一层所有选择都计算完之后,之后回退该选择。
            list.remove(list.size() - 1);
        }
    }
}

上述代码就是最基本的回溯框架代码了,关键点在于2终止条件,3遍历所有可能,4过滤条件也就是剪枝,剪去一些没有必要的操作降低耗时,5做出选择,6进入下一层遍历,7在这一层回退该选择做出其他选择。

好的,回溯分类下最最经典的题目全排列就打完收工了。

2. 组合总和3

做完了最经典的全排列,我们选一个组合的题目组合总和3。

题意

Find all valid combinations of k numbers that sum up to n such that the following conditions are true:

Only numbers 1 through 9 are used. Each number is used at most once. Return a list of all possible valid combinations. The list must not contain the same combination twice, and the combinations may be returned in any order.

  • combinations 组合
  • conditions 情况

翻译:寻找总和为n的k个数字的所有有效组合,并需要满足下列条件:

  1. 只能使用1-9的数字。
  2. 每个数字最多只能被使用1次。
  3. 返回所有有可能的组合。
  4. 不能返回相同的组合两次,一个组合可以返回任意排序。

思路推演

这次直接把上面的框架拿过来,直接对号入座就好了。

  • 终止条件:数字个数等于或者大于k了,总和等于或者大于n了。
  • 需要遍历的所有可能:1-9。
  • 过滤条件:因为每个数字最多遍历一次,所以新一次的遍历要在之前的下标之后开始遍历。
  • 选择与回退:首先是列表的选择与回退。其次因为这里需要计算总和,所以需要实时计算总和和回退总和,避免每次当前选择的路径计算总和浪费时间复杂度。

然后把上面说的这些套到框架上,代码如下,具体见注释。

class Solution {
    public List<List<Integer>> combinationSum3(int k, int n) {
        dfs(k, n, new ArrayList<>(), 1);
        return res;
    }

    int sum = 0;
    List<List<Integer>> res = new ArrayList<>();
    private void dfs(int k, int n, List<Integer> path, int start) {
        // 终止条件:数字个数等于或者大于k了,总和等于或者大于n了。
        if(sum == n && path.size() == k && !res.contains(path)) {
            res.add(new ArrayList<>(path));
            return;
        }
        if(path.size() >= k || sum > n) {
            return;
        }
        //需要遍历的所有可能:1-9。
        //过滤条件:因为每个数字最多遍历一次,所以新一次的遍历要在之前的下标之后开始遍历。
        for(int i = start; i < 10; i++) {
            //选择与回退:首先是列表的选择与回退。其次因为这里需要计算总和,所以需要实时计算总和和回退总和,避免每次当前选择的路径计算总和浪费时间复杂度。
            path.add(i);
            sum += i;
            dfs(k, n, path, i + 1);
            path.remove(path.size() - 1);
            sum -= i;
        }
    }
}

打完收工~

总结

把模板中的几个问题(终止条件,所有可能性,过滤条件,选择与回退)思考清楚,往代码里一套基本问题就解决了。

最开始陈列的十道题目,按照这个套路刷下来,几分钟一道不成问题,就不一一分析了。(主要是懒)

如果喜欢请关注——理解并背诵君(公众号同名)