题目描述

✅ 77. 组合

image-20260928215226280

题意分析

从整数 1 到 n 中选出恰好 k 个不同的数,返回所有可能的组合。组合只关心选中了哪些数,不关心选择顺序;同一组数换一个排列,仍然是同一个答案,不能重复收集。

题目保证 1 <= k <= n,结果中的每组都必须含 k 个数,每个数只能使用一次。不同组合之间可以共享数字,答案的输出顺序不限。这里求的是全部组合,而不是某个组合、组合数量或元素和。

解法:回溯枚举组合

核心思路

[!blue]

每个组合都可以唯一地按从小到大排列。因此只生成严格递增的选择路径,就能让同一个组合只出现一次,也不会遗漏任何组合。选了当前数 num 之后,后续只能从 num + 1 开始选,不需要再用集合判断重复。

递归状态由当前路径 path 和下一步允许选择的最小值 start 组成。路径保存已经选好的递增前缀;当前层依次尝试 start 及之后的数字,每选一个数,就递归决定剩余位置。递归返回后移除刚选的数,使下一个分支仍从相同前缀出发。

枚举前还可以判断剩余数量是否足够。若还需要 need = k - path.size() 个数,当前选择 num 后,还必须有至少 need - 1 个更大的数可选。可用数量是 n - num,因此要求 n - num >= need - 1,即 num <= n - need + 1。超过这个上界的分支一定凑不满,直接不枚举即可。

当路径长度达到 k,它就是一组完整答案。此时必须复制路径容器后加入结果:搜索过程还会删除和覆盖当前路径,结果需要保存这一刻的内容。Java 创建新列表,Go 将当前整数元素复制到新切片,随后结束这个分支。

任何合法组合的递增排列,都能沿着它的各个数字依次走到一条搜索路径;数量剪枝也不会排除它,因为它确实还剩足够多的数字。所以递增选择保证不重复,完整枚举和安全剪枝保证不遗漏。

解题步骤

  1. 从空路径和 start = 1 开始回溯。
  2. 若路径已有 k 个数,复制路径加入答案并返回。
  3. 计算还需选择的数量 need,枚举 num 从 start 到 n - need + 1。
  4. 将 num 加入路径,从 num + 1 递归;返回后移除路径末尾的 num。
  5. 所有分支完成后,返回收集到的全部组合。

代码实现

class Solution {
    public List<List<Integer>> combine(int n, int k) {
        List<List<Integer>> ans = new ArrayList<>();

        backtrack(1, n, k, new ArrayList<>(), ans);

        return ans;
    }

    private void backtrack(int start, int n, int k, List<Integer> path, List<List<Integer>> ans) {
        if (path.size() == k) {
            // 保存路径快照,回溯对当前列表的修改不能影响答案。
            ans.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, ans);
            path.remove(path.size() - 1);
        }
    }
}
func combine(n int, k int) [][]int {
    ans := make([][]int, 0)
    path := make([]int, 0, k)

    var dfs func(int)
    dfs = func(start int) {
        if len(path) == k {
            // 保存路径快照,回溯对当前切片的修改不能影响答案。
            ans = append(ans, append([]int(nil), path...))
            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 ans
}

复杂度分析

  • 时间复杂度:$O\left(k\binom{n}{k}\right)$。共有 $\binom{n}{k}$ 个组合,每个结果需要复制 k 个元素;剪枝后的每个搜索前缀都能延伸为答案,搜索工作也不超过所有答案路径长度的总量级。
  • 空间复杂度:不计返回结果为 $O(k)$,用于当前路径和递归栈;结果本身占 $O\left(k\binom{n}{k}\right)$。

关键点总结

[!green]

  • 固定每组的递增顺序,用搜索规则直接消除排列带来的重复。
  • start 决定下一步能选谁,need 决定当前数字最晚能选到哪里。
  • 保存答案时复制路径,尝试下一分支前撤销本次选择。

易错点总结

[!yellow]

  • 下一层传 start + 1 而非 num + 1,会允许重新选择本层已经选过的数字,破坏递增性。
  • 收集结果时直接保存可变路径,后面的回溯修改会影响已有答案。
  • 把剪枝上界固定为 n - k + 1,没有扣除已选数量,会在深层错误排除末尾数字。
  • 忘记撤销本次选择,会让后续分支带上上一分支的额外数字。
  • 每个递归前缀都加入答案,会混入不足 k 个数的选择;本题只收集长度恰好为 k 的路径。

相似题目

题目 难度 关联与区别
78. 子集 中等 原题枚举全部大小的子集,本题只收集恰好k个元素的选择。
216. 组合总和 III 中等 同样固定组合大小,再额外限制元素和,可结合剩余数量与和的边界剪枝。
39. 组合总和 中等 在选择和撤销之间枚举组合;本题只约束选择数量,该题允许同一候选被多次选取。
40. 组合总和 II 中等 在选择和撤销之间枚举组合;本题只约束选择数量,该题候选只能用一次且需去重。
90. 子集 II 中等 在选择和撤销之间枚举组合;本题只约束选择数量,该题排序后跳过同层重复值。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2025/67920981
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!