05、组合

厨子大约 6 分钟数据结构算法算法基地面试回溯刷题程序厨校招社招

哈喽,大家好,我今天我们来讲回溯相关的知识哦,这个题目是必会的!!!

https://leetcode.cn/problems/combinations/open in new window

题目描述

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

什么是组合?

组合的定义

组合:从 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]

详细执行过程

可视化表格

深度pathstart选择范围操作结果
1[]1[1,2,3,4]选择 1path=[1]
2[1]2[2,3,4]选择 2path=[1,2] ✓
2[1]2[2,3,4]选择 3path=[1,3] ✓
2[1]2[2,3,4]选择 4path=[1,4] ✓
1[]1[1,2,3,4]选择 2path=[2]
2[2]3[3,4]选择 3path=[2,3] ✓
2[2]3[3,4]选择 4path=[2,4] ✓
1[]1[1,2,3,4]选择 3path=[3]
2[3]4[4]选择 4path=[3,4] ✓
1[]1[1,2,3,4]选择 4path=[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(相同)

大家可以自己尝试使用一个变量记录递归调用次数,遍历结束后输出,看看各自的递归调用次数是多少次