组合[中等]

一、题目

给定两个整数nk,返回范围[1, n]中所有可能的k个数的组合。你可以按 任何顺序 返回答案。

示例 1:

输入:n = 4, k = 2

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

示例 2:

输入:n = 1, k = 1

输出:[[1]]

1 <= n <= 20
1 <= k <= n

二、代码

【1】递归实现组合型枚举:n个当中选k个的所有方案对应的枚举是组合型枚举。在「方法一」中我们用递归来实现组合型枚举。首先我们先回忆一下如何用递归实现二进制枚举(子集枚举),假设我们需要找到一个长度为n的序列a的所有子序列,代码框架是这样的:

c 复制代码
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);
}

上面的代码中,dfs(cur,n)参数表示当前位置是cur,原序列总长度为n。原序列的每个位置在答案序列种的状态有被选中和不被选中两种,我们用temp数组存放已经被选出的数字。在进入dfs(cur,n)之前[1,cur−1]位置的状态是确定的,而[cur,n]内位置的状态是不确定的,dfs(cur,n)需要确定cur位置的状态,然后求解子问题dfs(cur+1,n)。对于cur位置,我们需要考虑a[cur]取或者不取,如果取,我们需要把a[cur]放入一个临时的答案数组中(即上面代码中的temp),再执行dfs(cur+1,n),执行结束后需要对temp进行回溯;如果不取,则直接执行dfs(cur+1,n)。在整个递归调用的过程中,cur是从小到大递增的,当cur增加到n+1的时候,记录答案并终止递归。可以看出二进制枚举的时间复杂度是O(2^n)

组合枚举的代码框架可以借鉴二进制枚举。例如我们需要在n个元素选k个,在dfs的时候需要多传入一个参数k,即dfs(cur,n,k)。在每次进入这个dfs函数时,我们都去判断当前temp的长度是否为k,如果为k,就把temp加入答案并直接返回,即:

c 复制代码
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);
}

这个时候我们可以做一个剪枝,如果当前temp的大小为s,未确定状态的区间[cur,n]的长度为t,如果s+t<k,那么即使t个都被选中,也不可能构造出一个长度为k的序列,故这种情况就没有必要继续向下递归,即我们可以在每次递归开始的时候做一次这样的判断:

c 复制代码
if (temp.size() + (n - cur + 1) < k) {
    return;
}

代码就变成了这样:

c 复制代码
vector<int> temp;
void dfs(int cur, int n) {
    // 剪枝:temp 长度加上区间 [cur, n] 的长度小于 k,不可能构造出长度为 k 的 temp
    if (temp.size() + (n - cur + 1) < k) {
        return;
    }
    // 记录合法的答案
    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);
}

至此,其实我们已经得到了一个时间复杂度为O((nk))的组合枚举,由于每次记录答案的复杂度为O(k),故这里的时间复杂度为O((nk)×k),但是我们还可以进一步优化代码。在上面这份代码中有三个if判断,其实第三处的if是可以被删除的。因为:

【1】首先,cur=n+1的时候,一定不可能出现s>ks是前文中定义的temp的大小),因为自始至终s绝不可能大于k,它等于k的时候就会被第二处if记录答案并返回;

【2】如果cur=n+1的时候s=k,它也会被第二处 if\text{if}if 记录答案并返回;

【3】如果cur=n+1的时候s<k,一定会在cur<n+1的某个位置的时候发现s+t<k,它也会被第一处if剪枝。

因此,第三处if可以删除。最终我们得到了如下的代码。

java 复制代码
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) {
        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);
    }
}

时间复杂度: O((k/n​)×k),分析见「思路」部分。
空间复杂度: O(n+k)=O(n),即递归使用栈空间的空间代价和临时数组temp的空间代价。

【2】非递归(字典序法)实现组合型枚举: 这个方法理解起来比「方法一」复杂,建议读者遇到不理解的地方可以在草稿纸上举例模拟这个过程。这里的非递归版不是简单的用栈模拟递归转化为非递归:我们希望通过合适的手段,消除递归栈带来的额外空间代价。假设我们把原序列中被选中的位置记为1,不被选中的位置记为0,对于每个方案都可以构造出一个二进制数。我们让原序列从大到小排列(即{n,n−1,⋯1,0}。我们先看一看n=4k=2的例子:

原序列中被选中的数 对应的二进制数 方案
43[2][1] 0011 2,1
4[3]2[1] 0101 3,1
4[3][2]1 0110 3,2
[4]32[1] 1001 4,1
[4]3[2]1 1010 4,2
[4][3]21 1100 4,3
我们可以看出「对应的二进制数」一列包含了由k1n−k0组成的所有二进制数,并且按照字典序排列。这给了我们一些启发,我们可以通过某种方法枚举,使得生成的序列是根据字典序递增的。我们可以考虑我们一个二进制数数字x,它由k1n−k0组成,如何找到它的字典序中的下一个数字next(x),这里分两种情况:
规则一:x的最低位为1,这种情况下,如果末尾由t个连续的1,我们直接将倒数第t位的1和倒数第t+1位的0替换,就可以得到next(x)。如0011→01010101→01101001→10101001111→10101111
规则二:x的最低位为0,这种情况下,末尾有t个连续的0,而这t个连续的0之前有m个连续的1,我们可以将倒数第t+m位置的1和倒数第t+m+1位的0对换,然后把倒数第t+1位到倒数第t+m−1位的1移动到最低位。如0110→10011010→11001011100→11000111

至此,我们可以写出一个朴素的程序,用一个长度为n0/1数组来表示选择方案对应的二进制数,初始状态下最低的k位全部为1,其余位置全部为0,然后不断通过上述方案求next,就可以构造出所有的方案。

我们可以进一步优化实现,我们来看n=5k=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

在朴素的方法中我们通过二进制数来构造方案,而二进制数是需要通过迭代的方法来获取next的。考虑不通过二进制数,直接在方案上变换来得到下一个方案。假设一个方案从低到高的k个数分别是{a0,a1,⋯ ,ak−1},我们可以从低位向高位找到第一个j使得aj+1≠aj+1​,我们知道出现在a序列中的数字在二进制数中对应的位置一定是1,即表示被选中,那么aj+1≠aj+1意味着ajaj+1对应的二进制位中间有0,即这两个1不连续。我们把aj对应的1向高位推送,也就对应着aj←aj+1,而对于i∈[0,j−1]内所有的ai把值恢复成i+1,即对应这j1被移动到了二进制数的最低j位。这似乎只考虑了上面的「规则二」。但是实际上 「规则一」是「规则二」在t=0时的特殊情况,因此这么做和按照两条规则模拟是等价的。

在实现的时候,我们可以用一个数组temp来存放a序列,一开始我们先把1k按顺序存入这个数组,他们对应的下标是0k−1。为了计算的方便,我们需要在下标k的位置放置一个哨兵n+1(思考题:为什么是n+1呢?)。然后对这个temp序列按照这个规则进行变换,每次把前k位(即除了最后一位哨兵)的元素形成的子数组加入答案。每次变换的时候,我们把第一个aj+1≠aj+1j找出,使aj自增1,同时对i∈[0,j−1]aia重新置数。如此循环,直到temp中的所有元素为n内最大的k个元素。

回过头看这个思考题,它是为了我们判断退出条件服务的。我们如何判断枚举到了终止条件呢?其实不是直接通过temp来判断的,我们会看每次找到的j的位置,如果j=k了,就说明[0,k−1]内的所有的数字是比第k位小的最后k个数字,这个时候我们找不到任何方案的字典序比当前方案大了,结束枚举。

java 复制代码
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;
    }
}

时间复杂度: O((nk)×k)。外层循环的执行次数是(n/k)次,每次需要做一个O(k)的添加答案和O(k)的内层循环,故时间复杂度O((n/k)×k)
空间复杂度: O(k)。即temp的空间代价。

相关推荐
极客先躯9 分钟前
高级java每日一道面试题-2025年4月13日-微服务篇[Nacos篇]-Nacos如何处理网络分区情况下的服务可用性问题?
java·服务器·网络·微服务·nacos·高级面试
_GR10 分钟前
2025年蓝桥杯第十六届C&C++大学B组真题及代码
c语言·数据结构·c++·算法·贪心算法·蓝桥杯·动态规划
心想事“程”10 分钟前
决策树详解+面试常见问题
算法·决策树·机器学习
pwzs18 分钟前
Spring MVC 执行流程全解析:从请求到响应的七步走
java·后端·spring·spring mvc
小兵张健26 分钟前
互联网必备职场知识(4)—— 共情沟通能力
后端·产品经理·运营
拉不动的猪1 小时前
简单回顾下插槽透传
前端·javascript·面试
我该如何取个名字1 小时前
Mac配置Java的环境变量
java·开发语言·macos
kkkkatoq1 小时前
Java中的锁
java·开发语言
AskHarries1 小时前
使用 acme.sh 自动更新 SSL 证书的指南
后端
shinelord明1 小时前
【软件系统架构】事件驱动架构
数据结构·设计模式·架构·系统架构·软件工程