LeetCode第77题:组合

94 阅读7分钟

LeetCode第77题:组合

题目描述

给定两个整数 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

解题思路

这道题目是经典的组合问题,可以使用回溯算法(Backtracking)来解决。回溯算法是一种通过探索所有可能的候选解来找出所有解的算法,它通常采用深度优先搜索的策略。

方法:回溯算法

  1. 定义一个递归函数 backtrack(start, path),其中 start 表示当前可选的起始数字,path 表示当前已经选择的数字集合
  2. 在每一步中,我们考虑将数字 i(从 startn)添加到当前路径 path
  3. 添加数字 i 后,递归调用 backtrack(i+1, path),继续选择下一个数字
  4. path 的长度等于 k 时,将当前路径添加到结果集中
  5. 在递归返回后,需要将最后添加的数字从 path 中移除,以便尝试其他可能的组合

剪枝优化

为了提高效率,我们可以进行剪枝优化:

  1. 如果当前路径长度加上剩余可选数字的数量小于 k,则无法构成长度为 k 的组合,可以直接返回
  2. 在选择数字时,我们只需要考虑从 startn-(k-path.size())+1 的数字,因为后面的数字即使全部选择也无法构成长度为 k 的组合

关键点

  • 理解回溯算法的核心思想:尝试所有可能的选择,并在需要时撤销选择
  • 正确处理递归的终止条件和状态恢复
  • 使用剪枝技巧优化算法效率

算法步骤分析

步骤操作说明
1初始化创建结果集 result 和当前路径 path
2定义回溯函数backtrack(start, path) 用于生成所有可能的组合
3终止条件path 的长度等于 k 时,将当前路径添加到结果集中
4选择数字startn 的数字中选择一个添加到 path
5递归调用调用 backtrack(i+1, path) 继续选择下一个数字
6撤销选择将最后添加的数字从 path 中移除,尝试其他可能的组合
7返回结果返回所有可能的组合

算法可视化

以示例 1 为例,n = 4, k = 2,回溯过程如下:

  1. 初始状态:path = []start = 1
  2. 选择数字 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]
  3. 撤销选择 1:path = []
  4. 选择数字 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]
  5. 撤销选择 2:path = []
  6. 选择数字 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]
  7. 撤销选择 3:path = []
  8. 选择数字 4:path = [4],递归调用 backtrack(5, [4]),但 start > n,直接返回
  9. 撤销选择 4:path = []
  10. 最终结果集为 [[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++ 提交

代码亮点

  1. 回溯算法的应用:使用回溯算法解决组合问题,代码结构清晰。
  2. 剪枝优化:通过限制循环的上界,避免不必要的递归调用,提高效率。
  3. 状态恢复:在递归返回后,正确地恢复状态,确保不影响其他分支的探索。
  4. 深拷贝处理:在添加结果时,创建当前路径的副本,避免后续修改影响已添加的结果。
  5. 递归终止条件:明确定义递归的终止条件,避免无限递归。

常见错误分析

  1. 忘记创建路径副本:在将当前路径添加到结果集时,如果不创建副本,后续的修改会影响已添加的结果。
  2. 剪枝条件错误:剪枝条件的计算需要考虑当前路径长度和剩余需要选择的数字数量。
  3. 状态恢复不完全:在递归返回后,需要完全恢复状态,否则会影响其他分支的探索。
  4. 递归终止条件设置不当:如果终止条件设置不当,可能导致递归过早终止或无限递归。
  5. 循环范围设置错误:循环的起始和结束位置需要正确设置,否则可能漏掉某些组合或产生无效组合。

解法比较

解法时间复杂度空间复杂度优点缺点
回溯算法(无剪枝)O(n^k)O(k)实现简单,适用于所有组合问题效率较低,会有很多无效的递归调用
回溯算法(有剪枝)O(C(n,k) * k)O(k)效率高,避免无效递归剪枝条件的计算可能增加代码复杂度
迭代法O(C(n,k) * k)O(k)避免递归调用的开销实现复杂,不如递归直观

相关题目