一直觉得回溯类是算法面试里最"反直觉"的一类题,而 LeetCode 77 组合(Combinations)恰好是理解回溯算法最合适的一道题。它的题干很短,短到一眼就能看懂:给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。可就是这道入门级题目,藏着回溯算法最核心的"选择-递归-撤销"三步曲,也是很多人从"能看懂题解"到"能独立写对"的分水岭。
这篇文章我不打算只贴一份代码,而是把这道题从递归树怎么画、剪枝条件怎么推,到复杂度怎么算、面试官会怎么追问,一次讲透。内容默认以 C++ 为主,同时给出 Python 版本,适合准备算法面试的求职者,也适合正在刷 LeetCode 但卡在回溯上的朋友。
1. 这道题到底在考什么:组合与排列的本质区别
先花点时间把题目本身的边界划清楚,因为很多人在编码之前就理解偏了。
1.1 组合和排列的差别,以及出题人埋的陷阱
组合(Combination)和排列(Permutation)最核心的区别是:组合不关心顺序。比如 n=4、k=2 时,[1,2] 和 [2,1] 在组合里是同一个结果,在排列里是两个结果。这道题要求的是组合,所以所有顺序不同的重复集合必须被过滤掉。
很多初学者的第一反应是嵌套 k 层 for 循环。k=2 的时候确实可以:
cpp复制for (int i = 1; i <= n; i++) {
for (int j = i + 1; j <= n; j++) {
// 处理组合 {i, j}
}
}
但 k 是输入参数,可能是 3、可能是 5,不可能在代码里写死 k 层循环。所以必须用递归来动态控制循环层数,每一层递归就相当于一层 for 循环,这就是回溯算法的天然起点。
1.2 题目隐含的两个关键约束
再读一遍题目,范围是 [1, n] 这个连续整数区间,且每个数字只能用一次。这两点决定了整套解法的走向:
- 数字范围连续且有序:因为候选数字是 1 到 n 的连续整数,天然有序,所以只要按从小到大的顺序去选,就不会产生重复组合。
- 每个数字只能选一次:选过的数字不能被后面的递归层再次选中,这要求递归参数里必须带上"当前可以选的起始位置"。
我从牛客和 LeetCode 讨论区里见过不少提交,问题不在于代码写不出来,而在于没有抓住这两条约束,导致在"为什么参数里要有一个 startIndex"这个问题上卡住。这个 startIndex 是整道题的灵魂,后面展开说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 回溯模板的逐步拆解:从递归树到第一版实现
回溯算法说白了就是暴力枚举加"反悔"机制,但"反悔"这两个字如果没有画过递归树,理解起来永远隔一层。
2.1 递归树怎么画:把选择过程可视化
以 n=4、k=2 为例,把搜索过程画成树:
- 第一层:可以选择 1、2、3、4(作为组合的第一个数字)。
- 第二层:如果第一层选了 1,第二层只能从 2、3、4 里选;如果第一层选了 2,第二层只能从 3、4 里选。
对应的完整组合就是 {1,2}、{1,3}、{1,4}、{2,3}、{2,4}、{3,4},一共 6 个,正好是 C(4,2)=6。
注意第二层为什么不能回头选第一层已经选过的数字?因为如果回头选,就会出现 {1,2} 和 {2,1} 同时存在的情况,这就不是组合了。所以递归时传入的起始位置必须是当前下标的下一个位置,写成代码就是 dfs(i + 1)。
这个递归树其实就是一个"沿着树往深处走,走到叶子就记录结果,然后回退到上一层继续尝试下一个分支"的过程。想清楚这个,代码的主干框架基本就出来了。
2.2 第一版朴素实现:先跑通再优化
不要一上来就写剪枝,先让代码能出正确结果。回溯的模板化写法如下:
cpp复制class Solution {
public:
vector<vector<int>> combine(int n, int k) {
vector<vector<int>> result;
vector<int> path;
dfs(n, k, 1, path, result);
return result;
}
private:
void dfs(int n, int k, int start,
vector<int>& path, vector<vector<int>>& result) {
// 递归终止条件:已经凑够了 k 个数
if (path.size() == k) {
result.push_back(path);
return;
}
// 从 start 开始遍历候选数字
for (int i = start; i <= n; i++) {
path.push_back(i); // 选择
dfs(n, k, i + 1, path, result); // 递归
path.pop_back(); // 撤销
}
}
};
核心就四步:终止条件、选择、递归、撤销。这套模板背下来不算本事,真正要理解的是为什么需要一个临时变量 path 来记录当前路径,以及为什么递归完要立刻把刚加进去的元素弹出来。
2.3 为什么必须撤销:path 是所有递归分支共享的
path 不是一个"每次递归都复制一份"的局部变量,而是整个递归过程中共享的同一个容器。这么设计是为了省空间,但代价就是:如果不手动恢复现场,上一层递归会看到下一层留下的"脏数据"。
可以这样理解:你把 path 当成一个背包,进入某个分支前要放一个数字进去,递归返回后如果不把数字拿出来,下一个分支尝试放新数字的时候,背包里还残留着上一个分支的东西,路径就乱了。
我第一次带新人看这段代码时经常被问:"为什么 dfs 之后要 path.pop_back()?直接不弹不行吗?"答案很简单:不弹的话,第一层选了 1 之后,深度优先会依次探索 {1,2}、{1,3}、{1,4},如果不撤销,path 最终会变成 [1,2,3,4],甚至更早之前就触发终止条件,结果完全错乱。
所以记住一句话:有选择就有撤销,撤销是回溯的灵魂。面试官如果让你手写回溯,多半会盯着你这一步看。
3. 剪枝优化的完整推导:i <= n - (k - path.size()) + 1 是怎么来的
朴素回溯能 AC,但效率不高。LeetCode 讨论区里最常用也最经典的优化是"剪枝",也就是提前砍掉那些不可能凑够 k 个数字的分支。这个优化看起来只是把循环条件改一下,但背后的推导值得彻底弄懂。
3.1 朴素回溯浪费在哪
还是以 n=4、k=2 为例,当第一层选择了 4,进入第二层的时候,start 变成 5,但 5 大于 n,循环体根本进不去,直接返回。也就是说,这个分支白白递归了一层,却什么结果都没产生。
当 n 和 k 很大时,这种"注定没结果"的递归层数会很恐怖。比如 n=100、k=50,如果第一层选择了 99,那么第二层只能从 100 里选,但即使选了 100,加起来也才 2 个数,永远凑不够 50 个,这个子树就是完全无意义的探索。
3.2 剪枝条件的数学推导
剪枝的核心思想是:当前分支还能选到的数字个数,必须大于等于还需要的数字个数。
设当前已经选择了 path.size() 个数字,还需要选择 k - path.size() 个。假如 for 循环的变量是 i,那么从 i 到 n 一共有 n - i + 1 个数字可选。为了确保这条分支能走到底,必须满足:
code复制n - i + 1 >= k - path.size()
把这个不等式改写成 i 的范围,就是:
code复制i <= n - (k - path.size()) + 1
所以在 for 循环里,循环上界可以写成 n - (k - path.size()) + 1,而不是固定的 n。因为当 i 超过这个值的时候,即使把后面所有数字都选上,也凑不够 k 个数,继续递归没有意义。
3.3 剪枝后的完整代码
把剪枝套进模板:
cpp复制class Solution {
public:
vector<vector<int>> combine(int n, int k) {
vector<vector<int>> result;
vector<int> path;
dfs(n, k, 1, path, result);
return result;
}
private:
void dfs(int n, int k, int start,
vector<int>& path, vector<vector<int>>& result) {
if (path.size() == k) {
result.push_back(path);
return;
}
// 剪枝:i 最大只能是 n - (k - path.size()) + 1
for (int i = start; i <= n - (k - path.size()) + 1; i++) {
path.push_back(i);
dfs(n, k, i + 1, path, result);
path.pop_back();
}
}
};
很多题解里把剪枝后的循环写成 for (int i = start; i <= n - (k - path.size()) + 1; i++),第一次看到这个式子确实容易懵。但只要把"还需要几个数"和"还能选几个数"这两个量一对比,推导过程其实不超过三行。
3.4 剪枝能省多少:一个直观的对比
光说"效率提升"不够具体,拿 n=20、k=15 来说:
- 不剪枝:第一层从 1 到 20 全部尝试,其中 i=20 这一支要递归至少 6 层才能发现不够数。
- 剪枝:当 path 为空时,
k - path.size() = 15,循环上界直接变成20 - 15 + 1 = 6,也就是说第一层只需要从 1 尝试到 6。这已经省掉了 14 个"必死"分支。
LeetCode 上实际跑下来,剪枝后的运行时间通常能减少 30% 到 50% 左右,n 和 k 差距越大,剪枝效果越明显。这个优化在面试中几乎必被问到,能当场从数学上推导出来,绝对是一个加分项。
4. 复杂度分析与多语言实现对照
每道算法题都要能说清楚时间和空间复杂度,LeetCode 77 这道题也不例外。更重要的是,面试官递进式追问往往从这里开始。
4.1 时间复杂度为什么是 O(C(n,k) × k)
从结果倒推最直观:最终要生成 C(n,k) 个组合,每个组合的路径长度是 k。每次递归到叶子节点时,把 path 拷贝进 result 需要 O(k) 的时间。所以拷贝所有结果本身就需要 O(C(n,k) × k) 的时间。
那么在搜索过程中,非叶子节点消耗的时间呢?严格来说,回溯过程会访问一个递归树,树的节点数要大于叶子数。但由于每一层最多扩展 n 个分支,且递归深度最多 k,整棵树的节点数大约是 O(C(n,k) × k) 量级的(每个节点对应一条长度为 1 到 k 的路径前缀),和最终结果的数量级一致。所以总时间复杂度可以记为:
code复制O(C(n,k) × k)
当然,如果使用了剪枝,实际访问的节点数会更少,但大 O 表示法下数量级不变,因为剪枝只砍掉了"不会产生新结果的节点",而"最终结果的拷贝"这部分时间是无法避免的。
4.2 空间复杂度:递归深度与 path 数组
空间复杂度主要来自两个部分:
- 递归调用栈的深度。递归最多深入 k 层,每一层调用栈是 O(1),所以栈空间是 O(k)。
- path 数组最多同时保存 k 个元素,也是 O(k)。
result 是题目要求返回的结果,通常不计入算法的额外空间开销。所以空间复杂度就是:
code复制O(k)
这个数字要记牢,面试官问空间复杂度时,直接答 O(k) 并把理由说清楚就行。
4.3 三种主流语言的实现细节对比
同样一套逻辑,用不同语言写出来会有不同的注意点,这里把 C++、Python、Java 各写一个版本,方便不同语言背景的读者对照。
C++ 版本最大的坑是容器传参。如果 dfs(vector<int> path) 按值传参,每一层递归都会拷贝整个 path,额外开销会非常大,所以一定要用引用 vector<int>& path。还有 result.push_back(path) 是拷贝,不用担心后续修改 path 会影响 result 里已经存好的内容,这一点反而是 C++ 新手容易误解的地方。
cpp复制// C++:注意引用传递
class Solution {
public:
vector<vector<int>> combine(int n, int k) {
vector<vector<int>> result;
vector<int> path;
dfs(n, k, 1, path, result);
return result;
}
private:
void dfs(int n, int k, int start,
vector<int>& path, vector<vector<int>>& result) {
if (path.size() == k) {
result.push_back(path);
return;
}
for (int i = start; i <= n - (k - path.size()) + 1; i++) {
path.push_back(i);
dfs(n, k, i + 1, path, result);
path.pop_back();
}
}
};
Python 的写法更简洁,但需要注意 path[:] 这个细节。如果你直接写 result.append(path),后续 path 一变化,已经 append 进去的内容也会跟着变,因为 Python 里 list 是可变对象,append 只是保存了引用。必须显式拷贝一份 path[:] 才安全。
python复制# Python:注意 path[:] 拷贝
class Solution:
def combine(self, n: int, k: int) -> List[List[int]]:
result = []
path = []
def dfs(start: int) -> None:
if len(path) == k:
result.append(path[:])
return
# 剪枝
for i in range(start, n - (k - len(path)) + 2):
path.append(i)
dfs(i + 1)
path.pop()
dfs(1)
return result
Java 的写法介于两者之间,用 List<Integer> 作为 path,递归返回后必须 removeLast()。Java 的 List 也是引用类型,所以 new ArrayList<>(path) 来拷贝是必须的:
java复制// Java:注意 new ArrayList<>(path) 拷贝
class Solution {
public List<List<Integer>> combine(int n, int k) {
List<List<Integer>> result = new ArrayList<>();
dfs(n, k, 1, new ArrayList<>(), result);
return result;
}
private void dfs(int n, int k, int start,
List<Integer> path, List<List<Integer>> result) {
if (path.size() == k) {
result.add(new ArrayList<>(path));
return;
}
for (int i = start; i <= n - (k - path.size()) + 1; i++) {
path.add(i);
dfs(n, k, i + 1, path, result);
path.remove(path.size() - 1);
}
}
}
语言层面的这些小细节,往往是真正上机写代码时才暴露的问题。建议至少用两种语言各写一遍,感受一下"引用 vs 拷贝"在不同语言里的处理差异。
5. 从 77 题延伸出去:组合类问题的通用套路
LeetCode 77 是一道被很多人当成"模板题"的题目,但实际上它衍生出来的变体遍布 medium 和 hard 区间。如果能顺着 77 题把整个组合家族串起来,刷题效率会高很多。
5.1 带着 startIndex 参数的本质
为什么组合类问题都离不开一个 start 参数?因为它本质上是"从候选集合中有序地选择子集"。start 代表当前这一层递归可以从哪个下标开始选,这样就保证了:
- 不会回头选择已经选过的元素;
- 不会产生
{1,2}和{2,1}这种顺序不同但元素相同的重复组合。
理解了这一点,遇到任何"从数组里选 k 个数"的题目,第一反应都应该是:递归参数里加一个 start,循环从 start 开始。
5.2 变体一:求和类组合(39、40、216)
这是最直接的一组变体。LeetCode 216(组合总和 III)和 77 几乎完全一样,只是加了一个条件:k 个数的和必须等于 n。代码上只需要在终止条件里加一个 sum 判断:
cpp复制// 216 的核心终止条件示例
if (path.size() == k) {
if (sum == n) result.push_back(path);
return;
}
LeetCode 39(组合总和)和 40(组合总和 II)稍微复杂一些,因为数字可以重复使用(39 题)或者数组里本来就有重复元素(40 题需要去重)。但核心框架仍然是"选择-递归-撤销",区别只在于递归参数的写法:
- 如果允许重复使用同一个数字,递归时传
i而不是i + 1; - 如果不允许重复使用,传
i + 1; - 如果候选数组里有重复元素且要求结果不重复,先排序,再在同一层循环里跳过相邻的相同值。
这几个规则几乎是所有回溯求和题的通解,面试时遇到这类题可以大胆套用。
5.3 变体二:需要去重的组合(40、90)
LeetCode 40 和 90 的核心难点在于"去重"。同一个数值可能在数组中多次出现,如果直接套 77 的模板,会产生很多重复组合。
通用的去重套路是先对数组排序,然后在同一层 for 循环中,如果 i > start && candidates[i] == candidates[i-1],就跳过这一次循环。这个技巧背后的逻辑是:同一个数字在同一层递归中只能被选择一次,否则相同前缀的路径会被探索多次。
这个"跳过同一层重复元素"的写法,在排列类问题中也经常用到,属于必须掌握的题型之一。
5.4 变体三:电话号码字母组合(17)的差异
LeetCode 17 在很多人的分类里也被归为回溯题,但它的递归结构和 77 有本质差别:17 的每个数字对应一个固定的字母集合,递归时不存在"从候选集合中排除某些元素"的概念,因为每个位置能选的字母是独立的。
所以 17 的递归参数是"当前处理到第几个数字",而不是"当前可以从哪个下标开始选"。这一下就说明了 start 参数的使用场景:只有当候选集合是共享的、需要从小到大依次选择的场景,才需要 start 参数。而 17 这种"每个位置有独立候选集合"的场景,start 参数反而没有意义。
5.5 面试官常见的递进式追问
在面试中做完这道题,面试官大概率会追加几个问题,提前准备能让你表现得更从容:
- "为什么不能直接写 k 层 for 循环?"——因为 k 是动态输入,循环层数无法在编译期确定,递归可以在运行时动态控制嵌套层数。
- "剪枝条件是怎么推出来的?"——就是剩余可选元素个数 >= 还需元素个数,即
n - i + 1 >= k - path.size()。 - "如果 n 和 k 特别大,这个算法还能跑吗?"——不能,组合数本身是指数增长的,C(n,k) 当 n 很大时结果数量爆炸,任何算法都要枚举所有结果,因此时间复杂度不可能低于 C(n,k),这时只能说需要换思路,比如直接公式计算组合数量,或者用生成函数预处理。
- "能不能用迭代代替递归?"——可以用栈模拟回溯,但代码复杂度会显著提高,实际工程里通常没必要,递归写法已经足够清晰。
顺着这一组追问,其实已经能覆盖面试官对回溯类题目 80% 的考察点。
6. 我自己刷这道题时的几个体会
最后说一些题解之外、代码之外的东西。
刷题不是背模板,而是理解模板为什么长这样。我第一次接触这道题时,照抄了别人的解法,也能 AC,但过了一个月再遇到 216 题,完全想不起来怎么写。后来我重新把递归树画了一遍,把每个节点的状态变化写在纸上,才真正理解了 start 参数和撤销操作的含义。从那之后,回溯类的题目基本没有再卡过我,哪怕是 hard 难度的全排列、N 皇后、解数独,核心都是同一套思想:选择、递归、撤销。
如果你现在正被回溯搞到头大,我的建议是:不要急着刷更多题,先把 77 这道题的递归树完整画一遍,用一个小数据量(比如 n=4、k=2)手动模拟整个调用过程,每一步都写出 path 的内容和 start 的取值。花几个小时做这件事,比盲目刷十道题有用得多。
另外提一下实际比赛和面试中的一个小技巧:如果对剪枝条件推导不熟,宁可先写出朴素版本,再在面试官的提示下补充剪枝,也不要一开始就写出一个错误的上界。思路清晰、能解释每一步为什么这么写,比一次性 AC 更让面试官认可。这道题本身不难,但把它讲明白、讲透,才是真正拉开差距的地方。
