05、组合
大约 6 分钟数据结构算法算法基地面试回溯刷题程序厨校招社招
哈喽,大家好,我今天我们来讲回溯相关的知识哦,这个题目是必会的!!!
https://leetcode.cn/problems/combinations/
题目描述
给定两个整数 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 <= 201 <= k <= n
什么是组合?
组合的定义
组合:从 n 个元素中选出 k 个元素,不考虑顺序(如果不懂的话,就需要复习一下高中的知识,组合和排列)
数学记号:C(n, k) 或 (n k)
例子:
从 [1, 2, 3] 中选 2 个数
组合:[1,2], [1,3], [2,3]
共 C(3,2) = 3 种
注意:
[1,2] 和 [2,1] 是同一个组合
这是组合和排列的区别!
组合 vs 排列
组合(Combination):
- 不考虑顺序
- [1,2] 和 [2,1] 是同一个
- C(n,k)
排列(Permutation):
- 考虑顺序
- [1,2] 和 [2,1] 是不同的
- P(n,k)
例子:
从 [1,2,3] 中选 2 个
组合:[1,2], [1,3], [2,3](3 个)
排列:[1,2], [2,1], [1,3], [3,1], [2,3], [3,2](6 个)
这块不详细说明了,忘记的同学,自行复习哈
解题思路
回溯算法 ⭐⭐⭐
核心思想
问题本质:
- 从 n 个元素中选 k 个
- 是一个典型的搜索问题
- 可以用回溯(深度优先搜索)解决
回溯算法框架:
结果 = []
def backtrack(路径, 选择列表):
if 满足结束条件:
结果.add(路径)
return
for 选择 in 选择列表:
做选择
backtrack(路径, 新的选择列表)
撤销选择
应用到本题:
- 路径:当前已经选择的数字
- 选择列表:还可以选择的数字
- 结束条件:路径长度 = k
关键:避免重复
[1,2] 和 [2,1] 是同一个组合
所以每次只能选择比当前数字大的数
回溯过程图解
以 n=4, k=2 为例:以下为选择树
暂时无法在飞书文档外展示此内容
解释:
1. 从 [] 开始
2. 第一层:选择 1, 2, 3, 4
3. 第二层:
- 选了 1 后,可以选 2, 3, 4
- 选了 2 后,可以选 3, 4
- 选了 3 后,可以选 4
- 选了 4 后,没有数可选了(没有比4大的数了)
结果:
[1,2], [1,3], [1,4], [2,3], [2,4], [3,4]
详细执行过程
可视化表格
| 深度 | path | start | 选择范围 | 操作 | 结果 |
|---|---|---|---|---|---|
| 1 | [] | 1 | [1,2,3,4] | 选择 1 | path=[1] |
| 2 | [1] | 2 | [2,3,4] | 选择 2 | path=[1,2] ✓ |
| 2 | [1] | 2 | [2,3,4] | 选择 3 | path=[1,3] ✓ |
| 2 | [1] | 2 | [2,3,4] | 选择 4 | path=[1,4] ✓ |
| 1 | [] | 1 | [1,2,3,4] | 选择 2 | path=[2] |
| 2 | [2] | 3 | [3,4] | 选择 3 | path=[2,3] ✓ |
| 2 | [2] | 3 | [3,4] | 选择 4 | path=[2,4] ✓ |
| 1 | [] | 1 | [1,2,3,4] | 选择 3 | path=[3] |
| 2 | [3] | 4 | [4] | 选择 4 | path=[3,4] ✓ |
| 1 | [] | 1 | [1,2,3,4] | 选择 4 | path=[4] |
| 2 | [4] | 5 | [] | 无选择 | 回溯 |
通过上面的表格,大家应该理解了题目,但是可能还无法自己写出代码,大家可以看下面的示例代码,进一步理解
代码实现
基础版本
class Solution {
private:
vector<vector<int>> result; // 存储所有组合
vector<int> path; // 当前路径
public:
vector<vector<int>> combine(int n, int k) {
result.clear();
path.clear();
backtrack(n, k, 1);
return result;
}
private:
void backtrack(int n, int k, int start) {
// 终止条件:找到一个长度为 k 的组合
if (path.size() == k) {
result.push_back(path);
return;
}
// 遍历选择列表
for (int i = start; i <= n; i++) {
// 做选择
path.push_back(i);
// 递归:从 i+1 开始(避免重复)
backtrack(n, k, i + 1);
// 撤销选择,撤销掉重选,1,2 1,3 1,4 这样才能完成这种场景的
path.pop_back();
}
}
};
剪枝优化版本
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
public:
vector<vector<int>> combine(int n, int k) {
result.clear();
path.clear();
backtrack(n, k, 1);
return result;
}
private:
void backtrack(int n, int k, int start) {
// 终止条件
if (path.size() == k) {
result.push_back(path);
return;
}
// 剪枝优化
// 还需要选择的数字个数:k - path.size()
// 剩余可选的数字个数:n - i + 1
// 如果剩余数字不够,直接返回
// 即:n - i + 1 < k - path.size()
// 整理:i <= n - (k - path.size()) + 1
for (int i = start; i <= n - (k - path.size()) + 1; i++) {
path.push_back(i);
backtrack(n, k, i + 1); // 注意这里传的是 +1 哦
path.pop_back();
}
}
};
时间复杂度: O(C(n,k) × k) - 有 C(n,k) 个组合,每个组合复制需要 O(k)
空间复杂度: O(k) - 递归栈深度和 path 数组大小都是 O(k)
剪枝优化详解
为什么需要剪枝?
例子:n=100, k=50, 当前 path=[1, 2, ..., 49](已选 49 个)
不剪枝:
还需要 1 个数
会尝试 i = 50, 51, 52, ..., 100
但实际上:
- 选 50:可以凑够 50 个 ✓
- 选 51:也可以凑够 50 个 ✓
- ...
- 选 100:也可以凑够 50 个 ✓
这些都是有效的!但是...
如果 path=[1,2,...,48](已选 48 个)
还需要 2 个数
- 选 99:后面只有 [100],不够 2 个 ✗
- 选 100:后面没有数了,不够 2 个 ✗
所以这两个就可以剪掉啦!!!则可以提前结束循环!
剪枝效果对比
测试:n=20, k=10
不剪枝:
生成的组合数:C(20,10) = 184,756
剪枝后:
生成的组合数:C(20,10) = 184,756(相同)
大家可以自己尝试使用一个变量记录递归调用次数,遍历结束后输出,看看各自的递归调用次数是多少次





