LeetCode LCR 080. 组合
题目描述
题意分析
输入两个整数
n和k,要输出从1到n这n个数中选出k个数的所有组合,答案可以按任意顺序返回。约束里最关键的信号是「组合」二字:
[1, 2]和[2, 1]被视为同一个答案,只能出现一次。这与排列题的根本区别,决定了枚举方式必须能天然规避重复,而不是先枚举全部排列再去重。另一个信号是1 <= k <= n <= 20,答案数量是 $C(n, k)$,最大出现在n = 20、k = 10时约 18 万条,规模上允许把每一条答案都真正构造出来,也就是说这是一道「输出全部方案」的枚举题,不存在比枚举更快的算法——下界就摆在答案个数上。边界上要考虑:
k等于n时答案只有一条,就是全选;k等于 1 时答案有n条;数字范围从1开始而不是0,起点别写错;答案里每条组合的内部顺序题目不作要求,但按递增输出最自然也最便于自查。
解法:回溯枚举组合
核心思路
最朴素的想法是枚举
k层嵌套循环,但k是运行时才知道的变量,循环层数写不出来,所以必须把「不定层数的嵌套循环」翻译成递归——这正是回溯的由来:递归深度充当循环层数,每层负责决定路径上的第depth个数。直接递归的问题是重复。如果每层都从
1到n随便选,[1, 2]和[2, 1]会被当成两条不同路径产出,答案数从 $C(n, k)$ 膨胀到 $A(n, k)$,还得额外排序去重。瓶颈在于「同一组数字的不同排列被走了k!遍」。观察在于:既然组合与顺序无关,那就人为规定一个顺序——只允许按严格递增的次序挑选。这样一组数字就只对应唯一一条搜索路径,重复被从根上掐掉。落到代码里就是给递归带一个参数
start,表示本层允许选择的最小数字;选中num之后,下一层从num + 1开始,保证路径上的数字严格递增。由此得到第一条不变量:进入某层递归时,
path里的数字严格递增,且全部小于start;因此本层从start起往后选,路径必然继续保持严格递增。递增序列与集合一一对应,所以每个组合恰好被产出一次,不重不漏。第二个优化是剪枝上界。设当前
path已有path.size()个数,还需要need = k - path.size()个。若本层选了num,那么剩下可用的数字只有num + 1 .. n共n - num个,必须满足n - num >= need - 1,整理得num <= n - need + 1。也就是说循环上界不是n而是n - need + 1,超过它的分支无论怎么往下走都凑不满k个数,提前砍掉能省下大量必然失败的递归。这就是第二条不变量:任何被真正进入的分支,剩余可选数字个数都足够填满剩余空位,因此递归树上不存在「走到底才发现凑不齐」的死路。终止条件是
path.size() == k,此时把path的副本加入答案。之所以必须拷贝,是因为path是全程复用的同一个可变容器,回溯时会被不断增删;直接放引用的话,答案列表里塞的全是指向同一对象的指针,递归结束后path被清空,所有答案一起变空。
解题步骤
- 准备结果容器
res和路径容器path,从backtrack(1, ...)开始递归。起点是1而不是0,因为题目的数字范围是[1, n]。- 每层进入时先判断
path.size() == k。达到长度就把new ArrayList<>(path)加入res并立刻return,return不能少,否则会继续往下选出超长路径。- 计算
need = k - path.size(),即还差几个数。它是推导剪枝上界的唯一依据,写成变量比直接把表达式塞进循环条件更易读也更不易错。- 循环
num从start到n - need + 1。上界的含义是「选了num之后,num后面还剩至少need - 1个数」,保证这一支有希望凑满;若写成n,答案仍然正确,只是会多走一堆注定失败的分支。- 循环体内做「选择—递归—撤销」三步:
path.add(num)选中;backtrack(num + 1, ...)递归,起点传num + 1而不是start + 1,这是保证严格递增、避免重复的关键;path.remove(path.size() - 1)撤销,把状态恢复成进入循环体之前的样子,供下一个num使用。以
n = 4、k = 2走一遍:初始path = [],start = 1,need = 2,上界为4 - 2 + 1 = 3,所以第一层只尝试num = 1, 2, 3,num = 4被剪掉——确实,首位选 4 之后已经没有更大的数可选了。
num = 1:path = [1],递归start = 2。此层need = 1,上界为4 - 1 + 1 = 4,尝试num = 2, 3, 4,分别得到[1,2]、[1,3]、[1,4]三条答案。返回后撤销,path回到[]。
num = 2:path = [2],递归start = 3,上界仍是4,尝试num = 3, 4,得到[2,3]、[2,4]。注意这里不会再产生[2,1],因为起点已经被抬到3。撤销后path回到[]。
num = 3:path = [3],递归start = 4,尝试num = 4,得到[3,4]。撤销后path回到[]。第一层循环到
num = 3结束,共产出 6 条答案[1,2] [1,3] [1,4] [2,3] [2,4] [3,4],恰好等于 $C(4, 2) = 6$,且每条内部递增、彼此互不相同。
代码实现
class Solution {
public List<List<Integer>> combine(int n, int k) {
List<List<Integer>> res = new ArrayList<>();
backtrack(1, n, k, new ArrayList<>(), res);
return res;
}
private void backtrack(int start, int n, int k, List<Integer> path, List<List<Integer>> res) {
if (path.size() == k) {
res.add(new ArrayList<>(path));
return;
}
int need = k - path.size();
for (int num = start; num <= n - need + 1; num++) {
// 组合只向后选,避免同一组数字的不同排列。
path.add(num);
backtrack(num + 1, n, k, path, res);
path.remove(path.size() - 1);
}
}
}
func combine(n int, k int) [][]int {
res := make([][]int, 0)
path := make([]int, 0, k)
var dfs func(int)
dfs = func(start int) {
if len(path) == k {
cur := append([]int(nil), path...)
res = append(res, cur)
return
}
need := k - len(path)
for num := start; num <= n-need+1; num++ {
// 剩余数量不足的位置不再尝试。
path = append(path, num)
dfs(num + 1)
path = path[:len(path)-1]
}
}
dfs(1)
return res
}
复杂度分析
- 时间复杂度:$O(C(n, k) \cdot k)$。有了剪枝上界后,递归树上不存在注定失败的分支,每一条走到底的路径都对应一个合法组合,共 $C(n, k)$ 条;每条路径在终点处要把长度为
k的path拷贝一份,故乘以k。由于答案本身就有 $C(n, k) \cdot k$ 个整数要输出,这个量级已经触到问题下界。- 空间复杂度:$O(k)$,不计返回值。递归深度最多
k层,每层只有常数个局部变量;path的长度也不超过k。答案列表本身占 $O(C(n, k) \cdot k)$,属于必要输出,通常不计入额外空间。
关键点总结
- 组合去重的手段是「人为规定递增次序」,靠
start参数实现;递归时传num + 1而不是start + 1,这是模板里最容易写错的一处。- 剪枝上界
n - need + 1由「剩余数字数 ≥ 剩余空位数」推导而来,能背下结论,但面试里最好现场推一遍,说明它砍掉的是必然失败的分支而非合法解。- 收集答案必须深拷贝,
path是复用的可变容器,直接放引用会让所有答案指向同一个对象。- 「选择—递归—撤销」三步要严格配对,撤销漏写会让路径越积越长,多写会让状态提前失效。
- 面试视角:本题是回溯三大类(组合、排列、子集)的入口,回答时最好主动区分——组合用
start防重复,排列用used数组标记已选,子集则是在每个节点而非只在叶子处收集答案。能讲清这三者的差异,比写出单个模板更有价值。- 面试视角:常见追问是「若原数组含重复元素怎么办」,答案是先排序,再在同一层循环里跳过与前一个相同的数字(
i > start && nums[i] == nums[i-1] then continue),这就是 40 题的做法;另一个追问是「能否非递归实现」,可以提字典序法或二进制枚举,但要指出后者在n较大时不适用。
易错点总结
- 错误写法:收集答案时直接
res.add(path),不做拷贝 → 用n = 4、k = 2时,res里 6 个元素全是同一个path对象的引用,递归彻底结束后path已被回溯清空,最终输出 6 个空列表;Go 里写成res = append(res, path)同理,底层数组共享,后续append会互相覆盖。- 错误写法:递归时传
backtrack(start + 1, ...)而不是num + 1→ 用n = 4、k = 2时,第一层选num = 2后下一层仍从2开始,会产出[2, 2]这种含重复元素的非法组合。- 错误写法:递归时传
start不变 → 用n = 4、k = 2时会产出[1,1]、[1,2]、[2,1]等,既有重复元素又有同组的不同排列,答案数远超 6。- 错误写法:命中
path.size() == k后忘记return→ 用n = 4、k = 2时,[1,2]被收集后继续往下选,need变成 0,循环上界成了n + 1,num会取到5,路径长度超过k且再也不会被收集,白白浪费大量递归。- 错误写法:漏写
path.remove(path.size() - 1)→ 用n = 4、k = 2时,收完[1,2]返回后path仍是[1,2],接着又追加3,长度一路只增不减,path.size() == k再也不成立,除了第一条之外的答案全部丢失。- 错误写法:把循环上界写成
num < n→ 用n = 4、k = 2时num最大只到3,第二层选不到4,[1,4]、[2,4]、[3,4]三条答案全部丢失。- 错误写法:剪枝上界写成
n - k + 1(用k而不是need)→ 用n = 4、k = 2时每层上界都被压成3,第二层选不到4,同样丢掉三条答案;正确的上界必须随已选个数动态变化。- 错误写法:起点从
0开始递归backtrack(0, ...)→ 用n = 4、k = 2时会产出含0的组合如[0, 1],而题目要求数字取自[1, n]。- 错误写法:Go 里写
cur := path之后res = append(res, cur)→ 切片是引用语义,cur与path共用底层数组,后续回溯的append会就地改写已收集的答案,输出全乱;必须显式append([]int(nil), path...)复制一份。- 错误写法:为了「去重」先枚举全排列再用集合过滤 → 用
n = 20、k = 10时排列数是组合数的 $10!$ 倍,约 6.6 亿条,必然超时;去重应该靠搜索结构而非事后过滤。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 39. 组合总和 | 中等 | 同一元素可重复选取 |
| 40. 组合总和 II | 中等 | 含重复元素时的同层去重 |
| 216. 组合总和 III | 中等 | 个数与和的双重约束剪枝 |
| 78. 子集 | 中等 | 在每个节点而非叶子收集答案 |
| 46. 全排列 | 中等 | 用 used 标记而非 start 控制 |
| 17. 电话号码的字母组合 | 中等 | 多个候选集合的逐层展开 |