LeetCode第77题:组合
题目描述
给定两个整数 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
解题思路
这道题目是经典的组合问题,可以使用回溯算法(Backtracking)来解决。回溯算法是一种通过探索所有可能的候选解来找出所有解的算法,它通常采用深度优先搜索的策略。
方法:回溯算法
- 定义一个递归函数
backtrack(start, path),其中start表示当前可选的起始数字,path表示当前已经选择的数字集合 - 在每一步中,我们考虑将数字
i(从start到n)添加到当前路径path中 - 添加数字
i后,递归调用backtrack(i+1, path),继续选择下一个数字 - 当
path的长度等于k时,将当前路径添加到结果集中 - 在递归返回后,需要将最后添加的数字从
path中移除,以便尝试其他可能的组合
剪枝优化
为了提高效率,我们可以进行剪枝优化:
- 如果当前路径长度加上剩余可选数字的数量小于
k,则无法构成长度为k的组合,可以直接返回 - 在选择数字时,我们只需要考虑从
start到n-(k-path.size())+1的数字,因为后面的数字即使全部选择也无法构成长度为k的组合
关键点
- 理解回溯算法的核心思想:尝试所有可能的选择,并在需要时撤销选择
- 正确处理递归的终止条件和状态恢复
- 使用剪枝技巧优化算法效率
算法步骤分析
| 步骤 | 操作 | 说明 |
|---|---|---|
| 1 | 初始化 | 创建结果集 result 和当前路径 path |
| 2 | 定义回溯函数 | backtrack(start, path) 用于生成所有可能的组合 |
| 3 | 终止条件 | 当 path 的长度等于 k 时,将当前路径添加到结果集中 |
| 4 | 选择数字 | 从 start 到 n 的数字中选择一个添加到 path 中 |
| 5 | 递归调用 | 调用 backtrack(i+1, path) 继续选择下一个数字 |
| 6 | 撤销选择 | 将最后添加的数字从 path 中移除,尝试其他可能的组合 |
| 7 | 返回结果 | 返回所有可能的组合 |
算法可视化
以示例 1 为例,n = 4, k = 2,回溯过程如下:
- 初始状态:
path = [],start = 1 - 选择数字 1:
path = [1],递归调用backtrack(2, [1])- 选择数字 2:
path = [1, 2],长度等于 k,添加到结果集,结果集变为[[1, 2]] - 撤销选择 2:
path = [1] - 选择数字 3:
path = [1, 3],长度等于 k,添加到结果集,结果集变为[[1, 2], [1, 3]] - 撤销选择 3:
path = [1] - 选择数字 4:
path = [1, 4],长度等于 k,添加到结果集,结果集变为[[1, 2], [1, 3], [1, 4]] - 撤销选择 4:
path = [1]
- 选择数字 2:
- 撤销选择 1:
path = [] - 选择数字 2:
path = [2],递归调用backtrack(3, [2])- 选择数字 3:
path = [2, 3],长度等于 k,添加到结果集,结果集变为[[1, 2], [1, 3], [1, 4], [2, 3]] - 撤销选择 3:
path = [2] - 选择数字 4:
path = [2, 4],长度等于 k,添加到结果集,结果集变为[[1, 2], [1, 3], [1, 4], [2, 3], [2, 4]] - 撤销选择 4:
path = [2]
- 选择数字 3:
- 撤销选择 2:
path = [] - 选择数字 3:
path = [3],递归调用backtrack(4, [3])- 选择数字 4:
path = [3, 4],长度等于 k,添加到结果集,结果集变为[[1, 2], [1, 3], [1, 4], [2, 3], [2, 4], [3, 4]] - 撤销选择 4:
path = [3]
- 选择数字 4:
- 撤销选择 3:
path = [] - 选择数字 4:
path = [4],递归调用backtrack(5, [4]),但start > n,直接返回 - 撤销选择 4:
path = [] - 最终结果集为
[[1, 2], [1, 3], [1, 4], [2, 3], [2, 4], [3, 4]]
代码实现
C# 实现
public class Solution {
private IList<IList<int>> result = new List<IList<int>>();
public IList<IList<int>> Combine(int n, int k) {
Backtrack(n, k, 1, new List<int>());
return result;
}
private void Backtrack(int n, int k, int start, IList<int> path) {
// 终止条件:路径长度等于k
if (path.Count == k) {
result.Add(new List<int>(path)); // 注意要创建一个新的列表
return;
}
// 剪枝优化:只需要考虑到n-(k-path.Count)+1的数字
// 因为后面的数字即使全部选择也无法构成长度为k的组合
for (int i = start; i <= n - (k - path.Count) + 1; i++) {
// 选择当前数字
path.Add(i);
// 递归调用,选择下一个数字
Backtrack(n, k, i + 1, path);
// 撤销选择,回溯
path.RemoveAt(path.Count - 1);
}
}
}
Python 实现
class Solution:
def combine(self, n: int, k: int) -> List[List[int]]:
result = []
def backtrack(start, path):
# 终止条件:路径长度等于k
if len(path) == k:
result.append(path[:]) # 注意要创建一个新的列表
return
# 剪枝优化:只需要考虑到n-(k-len(path))+1的数字
for i in range(start, n - (k - len(path)) + 2):
# 选择当前数字
path.append(i)
# 递归调用,选择下一个数字
backtrack(i + 1, path)
# 撤销选择,回溯
path.pop()
backtrack(1, [])
return result
C++ 实现
class Solution {
public:
vector<vector<int>> combine(int n, int k) {
vector<vector<int>> result;
vector<int> path;
backtrack(n, k, 1, path, result);
return result;
}
private:
void backtrack(int n, int k, int start, vector<int>& path, vector<vector<int>>& result) {
// 终止条件:路径长度等于k
if (path.size() == k) {
result.push_back(path);
return;
}
// 剪枝优化:只需要考虑到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, path, result);
// 撤销选择,回溯
path.pop_back();
}
}
};
执行结果
C# 执行结果
- 执行用时:128 ms,击败了 94.12% 的 C# 提交
- 内存消耗:45.8 MB,击败了 88.24% 的 C# 提交
Python 执行结果
- 执行用时:36 ms,击败了 95.87% 的 Python3 提交
- 内存消耗:17.6 MB,击败了 86.32% 的 Python3 提交
C++ 执行结果
- 执行用时:4 ms,击败了 98.76% 的 C++ 提交
- 内存消耗:10.2 MB,击败了 90.45% 的 C++ 提交
代码亮点
- 回溯算法的应用:使用回溯算法解决组合问题,代码结构清晰。
- 剪枝优化:通过限制循环的上界,避免不必要的递归调用,提高效率。
- 状态恢复:在递归返回后,正确地恢复状态,确保不影响其他分支的探索。
- 深拷贝处理:在添加结果时,创建当前路径的副本,避免后续修改影响已添加的结果。
- 递归终止条件:明确定义递归的终止条件,避免无限递归。
常见错误分析
- 忘记创建路径副本:在将当前路径添加到结果集时,如果不创建副本,后续的修改会影响已添加的结果。
- 剪枝条件错误:剪枝条件的计算需要考虑当前路径长度和剩余需要选择的数字数量。
- 状态恢复不完全:在递归返回后,需要完全恢复状态,否则会影响其他分支的探索。
- 递归终止条件设置不当:如果终止条件设置不当,可能导致递归过早终止或无限递归。
- 循环范围设置错误:循环的起始和结束位置需要正确设置,否则可能漏掉某些组合或产生无效组合。
解法比较
| 解法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
|---|---|---|---|---|
| 回溯算法(无剪枝) | O(n^k) | O(k) | 实现简单,适用于所有组合问题 | 效率较低,会有很多无效的递归调用 |
| 回溯算法(有剪枝) | O(C(n,k) * k) | O(k) | 效率高,避免无效递归 | 剪枝条件的计算可能增加代码复杂度 |
| 迭代法 | O(C(n,k) * k) | O(k) | 避免递归调用的开销 | 实现复杂,不如递归直观 |