LeetCode 77、216、17、39、40 一文解决组合问题

152 阅读3分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第16天,点击查看活动详情

之前刷题都致力于完成题目本身,并没有进行总结,从本文开始将会致力于总结题目本身用到的思想,本文从回溯问题开始。

题目,本文包含以下几个相同类型的问题:

  1. 给定两个整数nk,返回范围[1,n]中所有可能的k个数的组合。
  2. 找出所有相加之和为nk个数的组合,且满足:
    • 只能使用数字1-9。
    • 每个数字最多使用一次。
  3. 给定一个只包含数字2-9的字符串,返回其所有可能的字母组合。
  4. 给定一个无重复的数组和一个目标target,要求找出数组中数字之和为target的不同组合,数组中元素可以使用多次,但需要保证组合不同。
  5. 给定一个集合和一个目标数,找出集合中所有可以使数字和为target的组合。集合中的每个数字只能使用一次,并且解集中不能包含重复的组合。

解题思路

回溯法介绍

在解决本题之前,首先介绍一下回溯。

回溯,即递归。其本质为穷举,通过穷举的方式找出所有的可能,最后选出我们需要答案。因此回溯法本身效率不高,只能通过剪枝的方法提高问题。

回溯法一般可以解决以下几种问题:

  • 组合问题:N个数里面按一定规则找出k个数的集合
  • 切割问题:一个字符串按一定规则有几种切割方式
  • 子集问题:一个N个数的集合里有多少符合条件的子集
  • 排列问题:N个数按一定规则全排列,有几种排列方式
  • 棋盘问题:N皇后,解数独等等

通常来说,回溯法是有模板的,模板如下:

void backtracking(参数) {
    if (终止条件) {
        存放结果;
        return;
    }

    for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
        处理节点;
        backtracking(路径,选择列表); // 递归
        回溯,撤销处理结果
    }
}

LeetCode 77 组合

下面回到本题,本题就是从组合开始,组合和排列的区别为组合不要求有序,而排列是有序的。那么根据上面的思路很容易可以得到下面的代码:

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

public List<List<Integer>> combine(int n, int k) {
    ArrayList<Integer> temp = new ArrayList<>();
    backtrace(n, 1, k, temp);
    return list;
}

public void backtrace(int n, int cur, int k, ArrayList<Integer> temp){
    if(temp.size()==k) {
        list.add(new ArrayList<>(temp));
        return;
    }
    for(int i=cur;i<=n;i++){
        temp.add(i);
        backtrace(n, i+1, k, temp);
        temp.remove(temp.size()-1);
    }
}

本题也是可以优化的,可以发现每个集合的元素必须等于k,那如果从遍历开始到最终结束的集合大小都不可能为k,那之后的回溯显然不需要进行,即当前已经收集temp.size()个元素,还需k-temp.size()个,那上界就应该为n- (k - temp.size()) + 1,最终快了8ms。

此处为什么加1可以模拟一下,假设起始为1,并且此时temp.size()为0,k为2, n为4,那正确应该是从3开始搜索都是合理的,而此处4-(2-0)+1=3

LeetCode 216 组合总和

下面看力扣216题的组合总和问题,本题的思路和上面相同,甚至套路都一模一样,可得代码如下:

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

public List<List<Integer>> combinationSum3(int k, int n) {
    backtrace(k, n, new ArrayList<>(), 1);
    return resCom;
}

public void backtrace(int k, int n, ArrayList<Integer> temp, int cur){
    if(n == 0 && temp.size() == k){
        resCom.add(new ArrayList<>(temp));
        return;
    }

    for(int i=cur;i<=9;i++){
        temp.add(i);
        backtrace(k, n-i, temp, i+1);
        temp.remove(temp.size()-1);
    }
}

本题和上面的题目一样也可以进行剪枝,当n<0时,则此时可以直接停止,除此之外,因为已经限制了结果集合中的元素,因此可以和上面的一样限定最终有效起始索引为9-(k-temp.size())+1

最终剪枝的回溯代码如下:

public void backtrace(int k, int n, ArrayList<Integer> temp, int cur){
    if(n<0) return;

    if(n == 0 && temp.size() == k){
        resCom.add(new ArrayList<>(temp));
        return;
    }

    for(int i=cur;i<=9-(k-temp.size())+1;i++){
        temp.add(i);
        backtrace(k, n-i, temp, i+1);
        temp.remove(temp.size()-1);
    }
}

LeetCode 17、电话号码的字母组合

本题首先需要将数字键盘上的字母使用数据结构保存下来,此处想到的是用字符数组,之后的思路和之前的思路也是一致的,此处的难点是搞清楚字符对应的位置,可得代码如下:

private List<String> resDig = new ArrayList<>();

public List<String> letterCombinations(String digits) {
    if(digits.length() == 0) return resDig;
    char[][] arr = {
        {'a', 'b', 'c'},
        {'d', 'e', 'f'},
        {'g', 'h', 'i'},
        {'j', 'k', 'l'},
        {'m', 'n', 'o'},
        {'p', 'q', 'r', 's'},
        {'t', 'u', 'v'},
        {'w', 'x', 'y', 'z'}
    };
    backtrace(arr, digits, 0, new StringBuilder());
    return resDig;
}

public void backtrace(char[][] arr, String digits, int cur, StringBuilder sb){
    if(sb.length() == digits.length()){
        resDig.add(sb.toString());
        return;
    }
    for(int i = 0; i<arr[digits.charAt(cur) - '0' - 2].length; i++){
        sb.append(arr[digits.charAt(cur) - '0' - 2][i]);
        backtrace(arr, digits, cur+1, sb);
        sb.deleteCharAt(sb.length()-1);
    }
}

LeetCode 39、组合总和

本题和216的组合总和类似,不过本题给定了具体的数组,并且数组中的数字可以重复使用,需要保证最终的结果是不同的数字组合,此处的关键点在于起始索引的移动,之前都需要+1,而此处不需要了,可得代码如下:

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

public List<List<Integer>> combinationSum(int[] candidates, int target) {
    backtrace(candidates, target, new ArrayList<>(), 0);
    return resComb;
}

public void backtrace(int[] candidates, int target, ArrayList<Integer> arr, int cur){
    if(target<0) return;
    if(target == 0){
        resComb.add(new ArrayList<>(arr));
        return;
    }
    for(int i=cur;i<candidates.length;i++){
        arr.add(candidates[i]);
        backtrace(candidates, target-candidates[i], arr, i);
        arr.remove(arr.size()-1);
    }
}

LeetCode 40、组数总和II

本题的难点在于解集中不能包含重复的组合,因为给定的集合是包含重复数字的,如果按照上面的思路,那得到的结果是包含重复组合的,即使对集合进行排序了也没有效果,一个思路是先对集合进行去重,之后对去重的数组进行回溯,但这破坏了原来的数组。另一个思路是首先对数组进行排序,之后通过判断相邻元素是否相同和元素是否已经使用来进行去重,代码如下:

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

public List<List<Integer>> combinationSum2(int[] candidates, int target) {
    Arrays.sort(candidates);
    boolean[] visited = new boolean[candidates.length];
    backtrace(candidates, target, 0, new ArrayList<>(), visited);
    return resCombin;
}

public void backtrace(int[] candidates, int target, int cur, ArrayList<Integer> temp, boolean[] visited){
    if(target<0) return;
    if(target == 0){
        resCombin.add(new ArrayList<>(temp));
        return;
    }

    for(int i=cur;i<candidates.length;i++){
        if(i>0&&candidates[i]==candidates[i-1]&&!visited[i-1]) continue;
        temp.add(candidates[i]);
        visited[i] = true;
        backtrace(candidates, target-candidates[i], i+1, temp, visited);
        temp.remove(temp.size()-1);
        visited[i] = false;
    }
}