关注Java领域相关技术 记录有趣的事情

LeetCode-77. 组合

US-B.Ralph
US-B.Ralph
2020-09-082020-09-18

问题地址

LeetCode每日一题/2020-09-08

LeetCode77. 组合


问题描述

规则

  • 给定两个整数 nk,返回 1 … n 中所有可能的 k 个数的组合。

示例

  • 示例一:
输入: n = 4, k = 2
输出:
[
  [2,4],
  [3,4],
  [2,3],
  [1,2],
  [1,3],
  [1,4],
]

解析

解题思路

  • 使用递归思路来解题;
    • 题目的结果可以理解为:
      • 从n个里面先选1个;
      • 从n-1个里面选出剩下的k-1个;

数据操作分析

复杂度分析

  1. 时间复杂度
  2. 空间复杂度

编码实现

public class LeetCode0077_Combinations {

    List<List<Integer>> result = new ArrayList<>();

    public List<List<Integer>> combine(int n, int k) {
        if (n == 0 || k == 0 || k > n) return result;
        LinkedList<Integer> tmp = new LinkedList<>();
        dfs(n, k, tmp);
        return result;
    }

    private void dfs(int n, int k, LinkedList<Integer> list) {
        if (k == 0) {
            result.add(new ArrayList<>(list));
            return;
        }
        if (n > k) {
            dfs(n - 1, k, list);
        }
        list.addFirst(n);
        dfs(n - 1, k - 1, list);
        list.removeFirst();
    }
}


官方解法

方法一:递归实现组合型枚举

思路:

  • n 个当中选 k 个的所有方案对应的枚举是组合型枚举。在「方法一」中我们用递归来实现组合型枚举。
  • 首先我们先回忆一下如何用递归实现二进制枚举(子集枚举),假设我们需要找到一个长度为 n 的序列 a 的所有子序列,代码框架是这样的:
vector<int> temp;
void dfs(int cur, int n) {
    if (cur == n + 1) {
        // 记录答案
        // ...
        return;
    }
    // 考虑选择当前位置
    temp.push_back(cur);
    dfs(cur + 1, n, k);
    temp.pop_back();
    // 考虑不选择当前位置
    dfs(cur + 1, n, k);
}
  • 上面的代码中,{\rm dfs(cur,} n) 参数表示当前位置是 \rm cur,原序列总长度为 n。原序列的每个位置在答案序列种的状态有被选中和不被选中两种,我们用 \rm temp 数组存放已经被选出的数字。在进入 {\rm dfs(cur,} n) 之前 \rm [1, cur – 1] 位置的状态是确定的,而 [{\rm cur}, n] 内位置的状态是不确定的,{\rm dfs(cur,} n) 需要确定 \rm cur 位置的状态,然后求解子问题 {\rm dfs(cur + 1}, n)。对于 \rm cur 位置,我们需要考虑 a[{\rm cur}] 取或者不取,如果取,我们需要把 a[{\rm cur}] 放入一个临时的答案数组中(即上面代码中的 \rm temp),再执行 {\rm dfs(cur + 1}, n),执行结束后需要对 \rm temp 进行回溯;如果不取,则直接执行 {\rm dfs(cur + 1}, n)。在整个递归调用的过程中,\rm cur 是从小到大递增的,当 \rm cur 增加到 n + 1 的时候,记录答案并终止递归。可以看出二进制枚举的时间复杂度是 O(2 ^ n) 组合枚举的代码框架可以借鉴二进制枚举。例如我们需要在 n 个元素选 k 个,在 \rm dfs 的时候需要多传入一个参数 k,即 {\rm dfs(cur}, n, k)。在每次进入这个 \rm dfs 函数时,我们都去判断当前 \rm temp 的长度是否为 k,如果为 k,就把 \rm temp 加入答案并直接返回,即:
vector<int> temp;
void dfs(int cur, int n) {
    // 记录合法的答案
    if (temp.size() == k) {
        ans.push_back(temp);
        return;
    }
    // cur == n + 1 的时候结束递归
    if (cur == n + 1) {
        return;
    }
    // 考虑选择当前位置
    temp.push_back(cur);
    dfs(cur + 1, n, k);
    temp.pop_back();
    // 考虑不选择当前位置
    dfs(cur + 1, n, k);
}
  • 这个时候我们可以做一个剪枝,如果当前 \rm temp 的大小为 s,未确定状态的区间 [{\rm cur}, n] 的长度为 t,如果 s+t ks 是前文中定义的 \rm temp 的大小),因为自始至终 s 绝不可能大于 k,它等于 k 的时候就会被第二处 \rm if 记录答案并返回;
    • 如果 {\rm cur} = n + 1s = k,它也会被第二处 \rm if 记录答案并返回;
    • 如果 {\rm cur} = n + 1s< k,一定会在 {\rm cur}< n + 1 的某个位置的时候发现 s + t< k,它也会被第一处 \rm if 剪枝。
  • 因此,第三处 \rm if 可以删除。最终我们得到了如下的代码。

复杂度分析:

  1. 时间复杂度:O({n \choose k} \times k),分析见「思路」部分。
  2. 空间复杂度:O(n + k) = O(n),即递归使用栈空间的空间代价和临时数组 \rm temp 的空间代价。

编码实现

lass Solution {
    List<Integer> temp = new ArrayList<Integer>();
    List<List<Integer>> ans = new ArrayList<List<Integer>>();

    public List<List<Integer>> combine(int n, int k) {
        dfs(1, n, k);
        return ans;
    }

    public void dfs(int cur, int n, int k) {
        // 剪枝:temp 长度加上区间 [cur, n] 的长度小于 k,不可能构造出长度为 k 的 temp
        if (temp.size() + (n - cur + 1) < k) {
            return;
        }
        // 记录合法的答案
        if (temp.size() == k) {
            ans.add(new ArrayList<Integer>(temp));
            return;
        }
        // 考虑选择当前位置
        temp.add(cur);
        dfs(cur + 1, n, k);
        temp.remove(temp.size() - 1);
        // 考虑不选择当前位置
        dfs(cur + 1, n, k);
    }
}

方法二:非递归(字典序法)实现组合型枚举

思路:

  • 这里的非递归版不是简单的用栈模拟递归转化为非递归:我们希望通过合适的手段,消除递归栈带来的额外空间代价。
  • 假设我们把原序列中被选中的位置记为 1,不被选中的位置记为 0,对于每个方案都可以构造出一个二进制数。我们让原序列从大到小排列(即 { n, n – 1, \cdots 1, 0 }。我们先看一看 n = 4,k = 2 的例子:
    原序列中被选中的数 对应的二进制数 方案
    43[2][1] 00110011 2,1
    4[3]2[1] 01010101 3,1
    4[3][2]1 01100110 3,2
    [4]32[1] 10011001 4,1
    [4]3[2]1 10101010 4,2
    [4][3]21 11001100 4,3
  • 我们可以看出「对应的二进制数」一列包含了由 k1n – k0 组成的所有二进制数,并且按照字典序排列。这给了我们一些启发,我们可以通过某种方法枚举,使得生成的序列是根据字典序递增的。我们可以考虑我们一个二进制数数字 x,它由 k1n – k0 组成,如何找到它的字典序中的下一个数字 {\rm next}(x),这里分两种情况:

    • 规则一:x 的最低位为 1,这种情况下,如果末尾由 t 个连续的 1,我们直接将倒数第 t 位的 1 和倒数第 t + 1 位的 0 替换,就可以得到 {\rm next}(x)。如:
      0011 \rightarrow 0101
      0101 \rightarrow 0110
      1001 \rightarrow 1010
      1001111 \rightarrow 10101111
    • 规则二:x 的最低位为 0,这种情况下,末尾有 t 个连续的 0,而这 t 个连续的 0 之前有 m 个连续的 1,我们可以将倒数第 t + m 位置的 1 和倒数第 t + m + 1 位的 0 对换,然后把倒数第 t + 1 位到倒数第 t + m – 1 位的 1 移动到最低位。如:
      0110 \rightarrow 1001
      1010 \rightarrow 1100
      1011100 \rightarrow 1100011
  • 至此,我们可以写出一个朴素的程序,用一个长度为 n0/1 数组来表示选择方案对应的二进制数,初始状态下最低的 k 位全部为 1,其余位置全部为 0,然后不断通过上述方案求 \rm next,就可以构造出所有的方案。
  • 我们可以进一步优化实现,我们来看 n = 5,k = 3 的例子,根据上面的策略我们可以得到这张表:

    二进制数 方案
    00111 3,2,1
    01011 4,2,1
    01101 4,3,1
    01110 4,3,2
    10011 5,2,1
    10101 5,3,1
    10110 5,3,2
    11001 5,4,1
    11010 5,4,2
    11100 5,4,3
  • 在朴素的方法中我们通过二进制数来构造方案,而二进制数是需要通过迭代的方法来获取 \rm next 的。考虑不通过二进制数,直接在方案上变换来得到下一个方案。假设一个方案从低到高的 k 个数分别是 { a_0, a_1, \cdots, a_{k – 1} }, 我们可以从低位向高位找到第一个 j 使得 a_{j} + 1 \neq a_{j + 1} ,我们知道出现在 a 序列中的数字在二进制数中对应的位置一定是 1,即表示被选中,那么 a_{j} + 1 \neq a_{j + 1} 意味着 a_ja_{j + 1} 对应的二进制位中间有 0,即这两个 1 不连续。我们把 a_j 对应的 1 向高位推送,也就对应着 a_j \leftarrow a_j + 1,而对于 i \in [0, j – 1] 内所有的 a_i 把值恢复成 i + 1,即对应这 j1 被移动到了二进制数的最低 j 位。这似乎只考虑了上面的「规则二」。但是实际上「规则一」是「规则二」在 t = 0 时的特殊情况,因此这么做和按照两条规则模拟是等价的。

  • 在实现的时候,我们可以用一个数组 \rm temp 来存放 a 序列,一开始我们先把 1k 按顺序存入这个数组,他们对应的下标是 0k – 1。为了计算的方便,我们需要在下标 k 的位置放置一个哨兵 n + 1(思考题:为什么是 n + 1 呢?)。然后对这个 \rm temp 序列按照这个规则进行变换,每次把前 k 位(即除了最后一位哨兵)的元素形成的子数组加入答案。每次变换的时候,我们把第一个 a_{j} + 1 \neq a_{j + 1}j 找出,使 a_j 自增 1,同时对 i \in [0, j – 1]a_i 重新置数。如此循环,直到 \rm temp 中的所有元素为 n 内最大的 k 个元素。
  • 回过头看这个思考题,它是为了我们判断退出条件服务的。我们如何判断枚举到了终止条件呢?其实不是直接通过 \rm temp 来判断的,我们会看每次找到的 j 的位置,如果 j = k 了,就说明 [0, k – 1] 内的所有的数字是比第 k 位小的最后 k 个数字,这个时候我们找不到任何方案的字典序比当前方案大了,结束枚举。

复杂度分析:

  1. 时间复杂度,O({n \choose k} \times k)。外层循环的执行次数是 n \choose k 次,每次需要做一个 O(k) 的添加答案和 O(k) 的内层循环,故时间复杂度 O({n \choose k} \times k)
  2. 空间复杂度,O(k)。即 \rm temp 的空间代价。

编码实现

class Solution {
    List<Integer> temp = new ArrayList<Integer>();
    List<List<Integer>> ans = new ArrayList<List<Integer>>();

    public List<List<Integer>> combine(int n, int k) {
        List<Integer> temp = new ArrayList<Integer>();
        List<List<Integer>> ans = new ArrayList<List<Integer>>();
        // 初始化
        // 将 temp 中 [0, k - 1] 每个位置 i 设置为 i + 1,即 [0, k - 1] 存 [1, k]
        // 末尾加一位 n + 1 作为哨兵
        for (int i = 1; i <= k; ++i) {
            temp.add(i);
        }
        temp.add(n + 1);

        int j = 0;
        while (j < k) {
            ans.add(new ArrayList<Integer>(temp.subList(0, k)));
            j = 0;
            // 寻找第一个 temp[j] + 1 != temp[j + 1] 的位置 t
            // 我们需要把 [0, t - 1] 区间内的每个位置重置成 [1, t]
            while (j < k && temp.get(j) + 1 == temp.get(j + 1)) {
                temp.set(j, j + 1);
                ++j;
            }
            // j 是第一个 temp[j] + 1 != temp[j + 1] 的位置
            temp.set(j, temp.get(j) + 1);
        }
        return ans;
    }
}

精彩评论

跳转地址:回溯算法 + 剪枝(Java)

重点概括

  • 如果解决一个问题有多个步骤,每一个步骤有多种方法,题目又要我们找出所有的方法,可以使用回溯算法;
  • 回溯算法是在一棵树上的 深度优先遍历(因为要找所有的解,所以需要遍历);
  • 组合问题,相对于排列问题而言,不计较一个组合内元素的顺序性(即 [1, 2, 3][1, 3, 2]认为是同一个组合),因此很多时候需要按某种顺序展开搜索,这样才能做到不重不漏。

根据搜索起点画出二叉树

  • 回溯算法首先需要画出递归树,不同的树决定了不同的代码实现。下面给出了两种画树的思路。

思路

  • 既然是树形问题上的 深度优先遍历,因此首先画出树形结构。例如输入:n = 4, k = 2,我们可以发现如下递归结构:
    • 如果组合里有 1 ,那么需要在 [2, 3, 4] 里再找 1 个数;
    • 如果组合里有 2 ,那么需要在 [3, 4] 里再找 1 数。注意:这里不能再考虑 1,因为包含 1 的组合,在第 1 种情况中已经包含。
  • 依次类推(后面部分省略),以上描述体现的 递归 结构是:在以 n 结尾的候选数组里,选出若干个元素。画出递归结构如下图:

说明:

  • 叶子结点的信息体现在从根结点到叶子结点的路径上,因此需要一个表示路径的变量 path,它是一个列表,特别地,path 是一个栈;
  • 每一个结点递归地在做同样的事情,区别在于搜索起点,因此需要一个变量 start ,表示在区间 [begin, n] 里选出若干个数的组合;
  • 可能有一些分支没有必要执行,我们放在优化中介绍。
  • 友情提示:对于这一类问题,画图帮助分析是非常重要的解题方法。

复杂度分析

  1. 时间复杂度:。
  2. 空间复杂度:。

参考代码一

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

public class Solution {

    public List<List<Integer>> combine(int n, int k) {
        List<List<Integer>> res = new ArrayList<>();
        if (k <= 0 || n < k) {
            return res;
        }
        // 从 1 开始是题目的设定
        Deque<Integer> path = new ArrayDeque<>();
        dfs(n, k, 1, path, res);
        return res;
    }

    private void dfs(int n, int k, int begin, Deque<Integer> path, List<List<Integer>> res) {
        // 递归终止条件是:path 的长度等于 k
        if (path.size() == k) {
            res.add(new ArrayList<>(path));
            return;
        }

        // 遍历可能的搜索起点
        for (int i = begin; i <= n; i++) {
            // 向路径变量里添加一个数
            path.addLast(i);
            // 下一轮搜索,设置的搜索起点要加 1,因为组合数理不允许出现重复的元素
            dfs(n, k, i + 1, path, res);
            // 重点理解这里:深度优先遍历有回头的过程,因此递归之前做了什么,递归之后需要做相同操作的逆向操作
            path.removeLast();
        }
    }
}

参考代码二:增加控制台输出

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

public class Solution {

    public List<List<Integer>> combine(int n, int k) {
        List<List<Integer>> res = new ArrayList<>();
        if (k <= 0 || n < k) {
            return res;
        }
        Deque<Integer> path = new ArrayDeque<>();
        dfs(n, k, 1, path, res);
        return res;
    }

    private void dfs(int n, int k, int begin, Deque<Integer> path, List<List<Integer>> res) {
        if (path.size() == k) {
            res.add(new ArrayList<>(path));
            return;
        }
        for (int i = begin; i <= n; i++) {
            path.addLast(i);
            System.out.println("递归之前 => " + path);
            dfs(n, k, i + 1, path, res);
            path.removeLast();
            System.out.println("递归之后 => " + path);
        }
    }

    public static void main(String[] args) {
        Solution solution = new Solution();
        int n = 5;
        int k = 3;
        List<List<Integer>> res = solution.combine(n, k);
        System.out.println(res);
    }
}

优化:分析搜索起点的上界进行剪枝

思路

  • 我们上面的代码,搜索起点遍历到 n,即:递归函数中有下面的代码片段:
// 从当前搜索起点 begin 遍历到 n
for (int i = begin; i <= n; i++) {
    path.addLast(i);
    dfs(n, k, i + 1, path, res);
    path.removeLast();
}
  • 事实上,如果 n = 7, k = 4,从 5 开始搜索就已经没有意义了,这是因为:即使把 5 选上,后面的数只有 67,一共就 3 个候选数,凑不出 4 个数的组合。因此,搜索起点有上界,这个上界是多少,可以举几个例子分析。
  • 分析搜索起点的上界,其实是在深度优先遍历的过程中剪枝,剪枝可以避免不必要的遍历,剪枝剪得好,可以大幅度节约算法的执行时间。
  • 下面的图片绿色部分是剪掉的枝叶,当 n 很大的时候,能少遍历很多结点,节约了时间。
    • (温馨提示:右键,在弹出的下拉列表框中选择「在新标签页中打开图片」,可以查看大图。)
  • 容易知道:搜索起点和当前还需要选几个数有关,而当前还需要选几个数与已经选了几个数有关,即与 path 的长度相关。我们举几个例子分析:
例如:n = 6 ,k = 4。
path.size() == 1 的时候,接下来要选择 33 个数,搜索起点最大是 44,最后一个被选的组合是 [4, 5, 6];
path.size() == 2 的时候,接下来要选择 22 个数,搜索起点最大是 55,最后一个被选的组合是 [5, 6];
path.size() == 3 的时候,接下来要选择 11 个数,搜索起点最大是 66,最后一个被选的组合是 [6];

再如:n = 15 ,k = 4。
path.size() == 1 的时候,接下来要选择 33 个数,搜索起点最大是 1313,最后一个被选的是 [13, 14, 15];
path.size() == 2 的时候,接下来要选择 22 个数,搜索起点最大是 1414,最后一个被选的是 [14, 15];
path.size() == 3 的时候,接下来要选择 11 个数,搜索起点最大是 1515,最后一个被选的是 [15];
  • 可以归纳出:
    • 搜索起点的上界 + 接下来要选择的元素个数 – 1 = n
  • 其中,接下来要选择的元素个数 = k – path.size(),整理得到:
    • 搜索起点的上界 = n – (k – path.size()) + 1
  • 所以,我们的剪枝过程就是:把 i <= n 改成 i <= n – (k – path.size()) + 1 :

复杂度分析

  1. 时间复杂度:。
  2. 空间复杂度:。

参考代码三:

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

public class Solution {

    public List<List<Integer>> combine(int n, int k) {
        List<List<Integer>> res = new ArrayList<>();
        if (k <= 0 || n < k) {
            return res;
        }
        Deque<Integer> path = new ArrayDeque<>();
        dfs(n, k, 1, path, res);
        return res;
    }

    private void dfs(int n, int k, int index, Deque<Integer> path, List<List<Integer>> res) {
        if (path.size() == k) {
            res.add(new ArrayList<>(path));
            return;
        }

        // 只有这里 i <= n - (k - path.size()) + 1 与参考代码 1 不同
        for (int i = index; i <= n - (k - path.size()) + 1; i++) {
            path.addLast(i);
            dfs(n, k, i + 1, path, res);
            path.removeLast();
        }
    }
}

说明:

  • 一些边界条件比较绕的,用具体的例子分析就不容易出错,主要考察的是细心,没有太多技巧;
  • 为参考代码 3 添加 path 的打印输出语句,可以看到输出语句会更少。

按照每一个数选与不选画出二叉树

思路

  • 可以按照每一个数选与不选画出二叉树,二叉树最多 n 层。同样可以剪枝。剪枝的思路请见下图「剪枝条件 ② 的加强」。
  • 画一个表格更容易看出边界条件。
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;

public class Solution {

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

        // 为了防止底层动态数组扩容,初始化的时候传入最大长度
        Deque<Integer> path = new ArrayDeque<>(k);
        dfs(1, n, k, path, res);
        return res;
    }

    private void dfs(int begin, int n, int k, Deque<Integer> path, List<List<Integer>> res) {
        if (k == 0) {
            res.add(new ArrayList<>(path));
            return;
        }

        // 基础版本的递归终止条件:if (begin == n + 1) {
        if (begin > n - k + 1) {
            return;
        }
        // 不选当前考虑的数 begin,直接递归到下一层
        dfs(begin + 1, n, k, path, res);

        // 不选当前考虑的数 begin,递归到下一层的时候 k - 1,这里 k 表示还需要选多少个数
        path.addLast(begin);
        dfs(begin + 1, n, k - 1, path, res);
        // 深度优先遍历有回头的过程,因此需要撤销选择
        path.removeLast();
    }
}
US-B.Ralph
LeetCode数据结构与算法算法

Leave a Comment

邮箱地址不会被公开。 必填项已用*标注

3 × 2 =