给定两个整数n
和k
,返回范围[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
的所有子序列,代码框架是这样的:
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
加入答案并直接返回,即:
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
的序列,故这种情况就没有必要继续向下递归,即我们可以在每次递归开始的时候做一次这样的判断:
if (temp.size() + (n - cur + 1) < k) {
return;
}
代码就变成了这样:
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>k
(s
是前文中定义的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
可以删除。最终我们得到了如下的代码。
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=4
,k=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 |
我们可以看出「对应的二进制数」一列包含了由k 个1 和n?k 个0 组成的所有二进制数,并且按照字典序排列。这给了我们一些启发,我们可以通过某种方法枚举,使得生成的序列是根据字典序递增的。我们可以考虑我们一个二进制数数字x ,它由k 个1 和n?k 个0 组成,如何找到它的字典序中的下一个数字next(x) ,这里分两种情况: | ||
规则一:x 的最低位为1 ,这种情况下,如果末尾由t 个连续的1 ,我们直接将倒数第t 位的1 和倒数第t+1 位的0 替换,就可以得到next(x) 。如0011→0101 ,0101→0110 ,1001→1010 ,1001111→10101111 。 | ||
规则二:x 的最低位为0 ,这种情况下,末尾有t 个连续的0 ,而这t 个连续的0 之前有m 个连续的1 ,我们可以将倒数第t+m 位置的1 和倒数第t+m+1 位的0 对换,然后把倒数第t+1 位到倒数第t+m?1 位的1 移动到最低位。如0110→1001 ,1010→1100 ,1011100→11000111 。 |
至此,我们可以写出一个朴素的程序,用一个长度为n
的0/1
数组来表示选择方案对应的二进制数,初始状态下最低的k
位全部为1
,其余位置全部为0
,然后不断通过上述方案求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 |
在朴素的方法中我们通过二进制数来构造方案,而二进制数是需要通过迭代的方法来获取next
的。考虑不通过二进制数,直接在方案上变换来得到下一个方案。假设一个方案从低到高的k
个数分别是{a0,a1,??,ak?1}
,我们可以从低位向高位找到第一个j
使得aj+1≠aj+1
?,我们知道出现在a
序列中的数字在二进制数中对应的位置一定是1
,即表示被选中,那么aj+1≠aj+1
意味着aj
和aj+1
对应的二进制位中间有0
,即这两个1
不连续。我们把aj
对应的1
向高位推送,也就对应着aj←aj+1
,而对于i∈[0,j?1]
内所有的ai
把值恢复成i+1
,即对应这j
个1
被移动到了二进制数的最低j
位。这似乎只考虑了上面的「规则二」。但是实际上 「规则一」是「规则二」在t=0
时的特殊情况,因此这么做和按照两条规则模拟是等价的。
在实现的时候,我们可以用一个数组temp
来存放a
序列,一开始我们先把1
到k
按顺序存入这个数组,他们对应的下标是0
到k?1
。为了计算的方便,我们需要在下标k
的位置放置一个哨兵n+1
(思考题:为什么是n+1
呢?)。然后对这个temp
序列按照这个规则进行变换,每次把前k
位(即除了最后一位哨兵)的元素形成的子数组加入答案。每次变换的时候,我们把第一个aj+1≠aj+1
的j
找出,使aj
自增1
,同时对i∈[0,j?1]
的aia
重新置数。如此循环,直到temp
中的所有元素为n
内最大的k
个元素。
回过头看这个思考题,它是为了我们判断退出条件服务的。我们如何判断枚举到了终止条件呢?其实不是直接通过temp
来判断的,我们会看每次找到的j
的位置,如果j=k
了,就说明[0,k?1]
内的所有的数字是比第k
位小的最后k
个数字,这个时候我们找不到任何方案的字典序比当前方案大了,结束枚举。
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
的空间代价。