LeetCode 77:组合(Combinations)——一篇把回溯法真正讲清楚的笔记

8 阅读3分钟

在刷算法题的过程中,「组合问题」几乎是每个人绕不开的一道坎,而 LeetCode 77:组合,正是理解回溯思想的最佳切入口。

这篇笔记会从题意理解开始,逐步拆解回溯的核心思想,最后结合代码,把每一行的意义讲清楚。

如果你之前对回溯一知半解,这篇文章就是为你准备的。


一、题目到底在干嘛?

题目描述:

给定两个整数 nk,返回范围 [1, n] 中所有可能的 k 个数的组合。

示例:

输入:n = 4, k = 2
输出:
[
  [1,2],
  [1,3],
  [1,4],
  [2,3],
  [2,4],
  [3,4]
]

注意几个关键词:

  • 从 1 到 n
  • 选 k 个
  • 组合,不是排列
  • 不要求顺序

二、为什么这道题一定要用回溯?

先想一个问题:

如果不用回溯,你打算怎么写?

  • 两层 for?三层 for?
  • k 不固定,n 也不固定
  • 层数根本写不出来

这正是回溯存在的意义:

当问题的解空间是“树形结构”,而且深度不固定时,用回溯。


三、回溯法的本质是什么?

一句话总结回溯:

在一条路上走到底,不行就退回来换一条路。

在这道题里:

  • 每一层,决定选哪个数字
  • 每走一步,把数字加入当前组合
  • 当数量够 k 个时,记录答案
  • 然后退回,尝试下一个数字

这就是「递归 + 选择 + 撤销选择」。


四、把问题抽象成一棵树

n = 4, k = 2 为例:

           []
      /     |     \
     1      2      3
   / | \    | \
 12 13 14  23 24
  • 每一层表示选择一个数字
  • 组合长度等于 k 时,就是一个答案
  • 向下是「做选择」
  • 向上是「撤销选择」

五、核心代码实现

完整代码如下:

class Solution {
    public List<List<Integer>> combine(int n, int k) {
        List<List<Integer>> result = new ArrayList<>();
        backtracking(result, n, k, new ArrayList<>(), 1);
        return result;
    }

    private void backtracking(List<List<Integer>> result,
                              int n,
                              int k,
                              List<Integer> list,
                              int index) {

        // 终止条件:选够 k 个数
        if (list.size() == k) {
            result.add(new ArrayList<>(list));
            return;
        }

        // 从 index 开始,避免重复组合
        for (int i = index; i <= n; i++) {
            list.add(i);                    // 做选择
            backtracking(result, n, k, list, i + 1);
            list.removeLast();              // 撤销选择
        }
    }
}

六、逐行拆解回溯逻辑

1. 为什么需要 index?

backtracking(..., index)

index 的作用是:

  • 保证组合是递增的
  • 避免出现 [2,1][3,1] 这种重复组合
  • 保证每个数字只向后选

这是组合题和排列题的本质区别。


2. 终止条件为什么是 list.size() == k

if (list.size() == k) {
    result.add(new ArrayList<>(list));
    return;
}
  • 组合长度达到 k

  • 当前路径已经是一个合法答案

  • 必须 new 一个 ArrayList

    • 否则后续回溯会修改原 list

这是回溯题里最容易犯错的地方之一。


3. for 循环在干什么?

for (int i = index; i <= n; i++) {

这行代码表示:

  • 在当前层,可以选择的数字范围
  • 每一层只负责「选一个数」
  • 递归进入下一层时,从 i + 1 开始

4. 为什么一定要“撤销选择”?

list.removeLast();

这是回溯的灵魂。

  • add 是向下走
  • remove 是回到岔路口
  • 不撤销,后面的结果全错

记住一句话:

回溯一定是「成对出现」:add + remove。


七、时间复杂度 & 空间复杂度

  • 时间复杂度:O(C(n, k))
  • 空间复杂度:O(k),递归栈和临时路径

这是组合问题的理论最优复杂度。


八、这道题的通用模板

以后你看到下面这些题,几乎可以直接套这个模板:

  • 组合总和
  • 子集
  • 子集 II
  • 组合总和 III
  • N 皇后(进阶)