在刷算法题的过程中,「组合问题」几乎是每个人绕不开的一道坎,而 LeetCode 77:组合,正是理解回溯思想的最佳切入口。
这篇笔记会从题意理解开始,逐步拆解回溯的核心思想,最后结合代码,把每一行的意义讲清楚。
如果你之前对回溯一知半解,这篇文章就是为你准备的。
一、题目到底在干嘛?
题目描述:
给定两个整数 n 和 k,返回范围 [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 皇后(进阶)