刷到第155天,今天把 LeetCode 77「组合」这道题拿出来彻底拆一遍。这题是回溯算法当之无愧的入门母题,几乎所有讲回溯的资料都会拿它开刀。给一个 n 和一个 k,要求在 [1, n] 范围内找出所有长度为 k 的组合,比如 n = 4、k = 2,输出就是 [[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]]。看着简单,但里面藏了一整套回溯思维:怎么画递归树、怎么控制 startIndex 不产生重复、什么时候剪枝、结果集合怎么拷贝才不会出错。我见过不少刷了十几道回溯题的人,回头再写 77 还是会在细节上翻车,问题往往不是不会写,而是没把模板背后的那棵递归树想透。
这篇直接从我个人的刷题视角出发,把题目、模板、剪枝推导、变种对比、调试技巧一次性讲清楚,适合刚接触回溯的初学者,也适合刷完题总感觉"会了又没完全会"的人。
1. 先把题目和类别看清楚
1.1 组合题到底在问什么
输入 n 和 k,范围是 1 到 n,要求返回所有 k 个数的组合。注意它说的是"组合"不是"排列",这意味着 [1,2] 和 [2,1] 算同一种结果,只保留按升序出现的那个。输出顺序没有硬性要求,平台判题允许任何顺序返回,但我们的递归遍历路径天然会按字典序生成,所以这一点不用额外操心。
题目还有一个边界约束:1 <= k <= n,排除了 k 为 0 或 k 大于 n 的极端情况。n 上限是 20,这意味着即使 n 取最大值,组合数的规模也在可接受范围内,比如 C(20,10) = 184756,纯递归能跑完,不需要什么高级优化。这个约束其实是在暗示:这道题考的就是回溯的准确性,而不是性能技巧,先写对再谈剪枝。
我刚开始刷这道题时有个误区,想着要生成"所有组"合,是不是得先构造全排列再去重?这个思路完全跑偏了。排列之后再筛组合,会产生大量重复搜索,而且去重的代码比回溯本身还难写。正确的切入点是:在递归过程中强制数字只能往后选,压根不让逆序组合出现,从源头杜绝重复。
1.2 为什么回溯是这类题的标准解法
回溯在本质上就是带撤销操作的深度优先搜索。它解决的问题有一个共同特征:做决策时每一步都有多个选择,需要遍历所有选择路径,而且不同路径之间互相独立。组合、子集、排列、括号生成、八皇后、分割字符串,全都是这个模式。
用"标准的解题框架"这个词可能有点吓人,但回溯的代码结构其实非常固定,核心就是递归函数里反复做三件事:判断是否到达终点、循环尝试当前可选的元素、递归进入下一层。递归返回后必须撤销本次选择,让状态回到上一层的样子。这个撤销动作就是"回溯"二字的来源。
有人说回溯效率很低,因为它本质上是暴力遍历。这话只对了一半。回溯在处理"需要遍历所有可能组合"的问题时是无可替代的,因为这类问题本身就不存在多项式复杂度的解法。更重要的是,回溯可以通过剪枝大幅减少搜索空间,比如 77 题里跳过剩余元素不足的分支,在 n 很大的时候效果立竿见影。暴力枚举是"无脑试",回溯是"试完了还能优雅地回头",有组织、有剪枝、有迹可循。
2. 回溯的关键:看到那棵隐形的递归树
2.1 树形结构是理解回溯的入场券
所有回溯问题都能画成一棵树。树的每一层对应递归的每一层,也就是已经确定了几个元素;树的每条从根到某个节点的路径,对应一个部分选择方案;叶子节点对应完整方案。以 n = 4、k = 2 为例,递归树长这样:
第一层是选择第一个数,可选 1、2、3、4。如果选了 1,第二层就只能从比 1 大的数里选,即 2、3、4,得到 [1,2]、[1,3]、[1,4]。如果选了 2,第二层只能选 3、4。如果选了 3,第二层只能选 4。如果第一层直接选 4,第二层没有可选元素,路径作废。
从树形结构可以非常直观地看出:每一层的可选范围呈递减趋势,因为 startIndex 一直在往后移。这也解释了为什么组合里不会出现 [2,1] —— 当路径里已经有 2 时,可选列表已经只剩 3 和 4,1 根本不在选项里。
画树有什么实际作用?写代码之前把树画出来,你就能立刻知道递归边界条件应该是什么,循环从哪个数开始,剪枝条件怎么设。我常说一句话:回溯代码写不出来,99% 的情况是脑子里没有那棵树,而不是语法不会。哪怕是老手,遇到新题时我也会先在草稿纸上画三层深度,把递归树形状稳定下来再动手。
2.2 回溯模板的三种关键控制
回溯函数的写法可以浓缩成一段非常标准的结构:
void backtrack(参数) { if (终止条件) { 收集结果; return; } for (当前层可选元素) { 做选择; backtrack(下一层参数); 撤销选择; } }这段模板里有四个控制点需要仔细理解。
第一是终止条件。组合问题里,当已选元素的个数等于 k 时,当前 path 就是一个合法答案,复制一份放进 result,然后返回。注意这里是"个数等于 k"而不是"个数大于 k",因为组合不允许重复选同一个数,每层只会新增一个元素,不会出现超过 k 的情况。
第二是 for 循环的起点。这是组合问题的灵魂。横向的循环范围决定"这一层还能选哪些数"。在组合题里,起点是传入的 startIndex,也就是上一轮选的数的下一个位置,从而保证当前层不会选到已经选过的数以及它之前的数。
第三是递归调用。进入下一层时,把 i + 1 作为新的 startIndex 传下去,表示下一层从当前已选数的后面继续选,绝不回头。这就是组合不会出现重复的根本机制。
第四是撤销选择。递归返回后,要把刚才加入 path 的元素弹出。这一步不做,path 会保留上一分支的状态,后面的分支全被污染,结果里会出现"上一分支的尾巴",非常隐蔽。
2.3 startIndex 是组合去重的命门
很多初学者看到 startIndex 会问一个问题:为什么全排列问题不需要 startIndex,而组合必须有?因为排列讲究顺序,[1,2] 和 [2,1] 是两种不同答案,每一层都可以从所有剩余元素里选,用的是 used 数组或者交换法。组合不讲究顺序,只能通过强制"新选元素永远大于旧选元素"这个约束来去重,startIndex 就是实现这个约束的指针。
把这个逻辑讲得再直白一点:组合就是在一条数轴上,从左往右挑数字,挑了 2 之后只能挑 3、4、5,不能回头挑 1。这样挑出来的任何一组数字,从小到大排列,就是唯一形态。startIndex 保证了"不回头",于是所有重复的逆序组合天然不会出现。
所以说,理解了 startIndex 的移动规律,组合去重这块就过了。后面做组合总和 II 时,还需要在这基础上加一个"同一层跳过相同的数字"去重逻辑,那是另一个层次的问题,但根子仍然是 startIndex。
3. 代码实现与关键细节走读
3.1 最直接的 C++ 实现
直接给一份基础版 C++ 代码,先保证对,再谈优化:
class Solution { public: vector<vector<int>> result; vector<int> path; void backtrack(int n, int k, int startIndex) { if (path.size() == k) { result.push_back(path); return; } for (int i = startIndex; i <= n; i++) { path.push_back(i); backtrack(n, k, i + 1); path.pop_back(); } } vector<vector<int>> combine(int n, int k) { backtrack(n, k, 1); return result; } };这段代码在 LeetCode 上是能直接通过的。对照模板看,终止条件在第二行,横向遍历在 for 循环里,递归参数传的是 i + 1,撤销选择是 pop_back。四行关键代码,每一行都有明确职责。
这里有个小细节值得注意:result 是二维向量,push_back(path) 存入的是 path 的一份拷贝。C++ 的标准库容器是值语义,所以我们不需要手动拷贝,vector 会自动复制。这个行为在 Java 和 Python 里有大坑,下面单独讲。
3.2 Python 和 Java 的引用拷贝陷阱
用 Python 写这道题,很多第一轮刷题的人会写错一行代码。迷之原因是 Python 列表是引用类型,直接把 path 塞进 result,后续对 path 做 pop 操作,result 里刚存进去的东西也会跟着变。正确做法是复制一份:
class Solution: def combine(self, n: int, k: int) -> List[List[int]]: result = [] path = [] def backtrack(start_index: int) -> None: if len(path) == k: result.append(path[:]) # 必须切片拷贝 return for i in range(start_index, n + 1): path.append(i) backtrack(i + 1) path.pop() backtrack(1) return result难点集中在 result.append(path[:]) 这一行。如果直接写 result.append(path),你会发现最终输出的结果数组里全是同一个空列表或者同一个最后状态的列表,因为 path 在回溯结束时会被 pop 回空。切片语法 [:] 相当于生成一份新列表,存的是当前快照。Java 版本的写法是 result.add(new ArrayList<>(path)),本质是一回事。
这个坑在刷回溯题时几乎人人都会踩一次。我只能说踩得好,踩过之后你就对"可变对象引用"这个概念有了肌肉记忆。以后做任何递归题目,凡是把中间状态保存到结果集,第一反应都应该是:我存的是引用还是副本?
3.3 剪枝优化:一个公式解决边界问题
基础版本在 n 很小、k 很小时没问题,但 n = 20、k = 10 时,有些分支明显走不到终点,白白浪费递归。比如 path 里已有 8 个元素,此时已经选到了 15,剩下的可选元素是 16 到 20 只有 5 个,还需要 2 个才能凑满 k 等于 10。但循环还会从 16 走到 20,每一趟都进入递归,然后发现凑不满,再各递归出一堆废分支。剪枝就是提前判断"剩余元素够不够填满 path",不够就停止继续循环。
判断条件用数学表达就是:当前正在考察的元素 i 到 n 之间的元素个数,必须不小于还需要的元素个数。写成公式:
- 剩余元素个数:n - i + 1
- 还需要元素个数:k - path.size()
剪枝要求:n - i + 1 >= k - path.size()
移项得到:i <= n - (k - path.size()) + 1
所以 for 循环的终止条件从 i <= n 改成 i <= n - (k - path.size()) + 1 即可。修改后的代码:
void backtrack(int n, int k, int startIndex) { if (path.size() == k) { result.push_back(path); return; } for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) { path.push_back(i); backtrack(n, k, i + 1); path.pop_back(); } }很多教程直接丢出这个条件,告诉你背下来。但我建议你把推导过程刻在脑子里,因为后面做组合总和 III、分割问题、棋盘问题时,剪枝条件各不相同,但推导思路完全一致:先写约束不等式,再解出循环边界。公式本身不重要,推导能力才重要。
顺带说一个容易混淆的点:剪枝只减少递归次数,不影响结果正确性。加了剪枝之后,答案集合和原来一模一样,只是不合法分支不再进入。我见过有人担心剪枝会把合法组合漏掉,理解公式之后就会发现,碰到合法情况时,剩余元素数刚好等于或者超过所需数,条件自然满足,循环不会提前终止。
3.4 参数设计的一些个人习惯
关于递归函数的参数,网上各家写法略有不同。有的习惯把 n 和 k 放进递归函数,有的把它们变成类成员变量。我的习惯是:在类里用成员变量保存 n 和 k,递归函数只传 startIndex。这样可以让每一层栈帧更小,调用更清爽。尤其是后续做变种题时,参数多了以后,每一项参数都传会显著增加代码负担和出错概率。
但有一类参数必须放进递归函数:每一层都在变化的状态,比如 startIndex、当前路径和累计值。成员变量用来承载"全局不变的信息",递归参数用来承载"随层级变化的信息",这个原则几乎适用于所有回溯题。
4. 复杂度、常见坑与调试方法
4.1 把复杂度算清楚,不云里雾里
时间复杂度不能简单说 O(2^n) 或者 O(n!),组合问题的结果数量是组合数 C(n, k),结果数量本身已经是指数级的,这是问题性质决定的,不是回溯算法带来的额外开销。遍历所有组合,至少要访问 C(n, k) 个叶子节点,这一点没法再省。每找到一个叶子节点,都要生成一个长度为 k 的路径拷贝到 result 里,这一步需要 O(k) 时间。所以总时间复杂度是 O(C(n, k) * k)。
空间复杂度要分开看。递归调用栈的最大深度是 k,path 数组占 O(k),这两部分合起来是 O(k)。result 存储了所有结果,这部分空间是所有结果占用,通常不计入算法运行所需的辅助空间,但如果面试官问"包括结果集的空间",那答案是 O(C(n, k) * k)。
关于剪枝对复杂度的提升,严格来说剪枝不改变最坏情况下的复杂度量级,因为最坏情况发生在几乎所有分支都能走通时。但在中间阶段,比如 path 已经很长、剩余可选数不足时,剪枝能剪掉大量无效子树,实际运行时间差距显著。以 n = 20、k = 10 为例,剪枝版本的递归节点数大约只有未剪枝版本的十分之一甚至更少。
4.2 三个高频 bug 与排查实录
第一个高频 bug:在 Python 或 Java 里直接把 path 加入 result,没有拷贝。症状很典型:输出结果里要么全是空列表,要么全是同一个列表。我最早写 Python 回溯时就栽在这上面,浪费了半小时。排查方法也很简单,在 backtrack 入口处打印 path 和 result,立刻就能看到 result 里的内容被后续操作篡改了。
第二个高频 bug:递归调用时传参写错。常见的有两种,一种是把 backtrack(n, k, i + 1) 写成 backtrack(n, k, startIndex + 1),一种是写成 backtrack(n, k, i)。第一种会导致 startIndex 在每一层都是同一个值,递归进入死循环或者爆栈;第二种会允许重复选择同一个元素,产生类似 [1,1,2] 这样的非法组合。语法检查程序发现不了这种逻辑错误,只能靠脑内模拟递归树来排除。
第三个高频 bug:漏掉撤销选择。C++ 代码里遗漏 pop_back 之后,path 会在错误分支上持续累积,结果是答案里出现大量超长组合。这个问题用单步调试特别好发现:调试到递归返回时,观察 path 是不是回到了进入递归前的长度,如果没有,就是撤销操作没执行。
4.3 用打印变量法观察回溯过程
很多人觉得回溯难调试,因为函数在自己调用自己,断点跳来跳去让人头晕。我的经验是:不要尝试用单步调试去读每一层调用,直接在关键位置打印状态,观察"层缩进 + 当前层信息"。
推荐一个打印模板,在 backtrack 入口处输出:
for (int j = 0; j < path.size(); j++) cout << " "; cout << "path="; for (int v : path) cout << v << " "; cout << "| startIndex=" << startIndex << " | i=" << i << endl;打印结果会把递归树的结构直接印在终端上,哪一层选了哪个数、在哪一步返回、路径状态变化,一目了然。这个方法在刷复杂变种题时也适用,我至今仍然用它排查组合总和系列。
还有一个小技巧:递归函数里打印缩进用 path.size() 控制层数,非常直观。比如 n = 4、k = 2 时,你会看到第一层 path 为空、startIndex=1、i 从 1 到 4 循环;第二层 path 有 1 个数、startIndex 变成 2 或 3 或 4,i 再往后遍历。画面跟第 2 章画的递归树完全对得上。调试完记得删打印代码,别带着调试输出提交。
5. 从 77 出发:同一模板的常见变种
5.1 组合、排列、子集三者的本质差别
刷题量上来之后会越来越清楚一件事:组合、排列、子集本质上都是同一个回溯框架,区别只在于三个控制点的参数变化。
组合是"从 n 个不同元素中取 k 个,不重复,顺序无关",控制点是 startIndex 每次都取 i + 1,保证不回头也不重复。排列是"从 n 个不同元素中取 k 个排列,顺序有关",控制点是每层都从 0 开始遍历所有元素,配合 used 数组跳过已经选过的元素。子集是"取出所有可能长度的组合",控制点是不设长度终止条件,而是每层都收集当前 path。
用表格总结一下,方便对照:
| 题目 | 模板差异 | 关键控制点 |
|---|---|---|
| 组合(77) | 终止条件:路径长度等于 k | startIndex = i + 1 |
| 全排列(46) | 每层从 0 开始遍历 | used 数组跳过已选元素 |
| 子集(78) | 每一层都收集结果 | startIndex = i + 1,不设长度截止 |
| 组合总和(39) | 元素可无限重复使用 | startIndex = i(允许选自己) |
| 组合总和 II(40) | 数组含重复元素,结果不能重复 | 排序 + 同层去重,startIndex = i + 1 |
| 组合总和 III(216) | 限定 1 到 9,且目标和固定 | 77 模板 + 累计和剪枝 |
表格只是快速索引,每道题的代码细节还是有差异,但模板结构百分之九十相同,这就是练习母题的意义所在。
5.2 变种题的具体改动方向
以 216 组合总和 III 为例,要求只用 1 到 9 这几个数字,找出 k 个数的组合,且组合的和等于 n。这题就是在 77 的基础上多维护了一个 sum 变量。每次选择 i 之后,sum 加上 i,递归回来再减掉 i。同时终止条件从"长度等于 k"扩展为"长度等于 k 且 sum 等于 n",另外还可以加一个 sum 大于 n 的剪枝。
再以 39 组合总和为例,candidates 数组里的元素可以无限重复选取。此时 startIndex 就不再是 i + 1,而应该改成 i,因为允许重复选当前元素。这个改动一眼就能看出 startIndex 的语义:它限制了"本次循环从哪些位置开始遍历"。
40 组合总和的去重更有意思。candidates 里可能有重复数字,比如 [1,1,2,3],如果不做去重,会得到两组 [1,2](用的是两个不同的 1)。解决办法是先对数组排序,然后在 for 循环里判断当前数字是否跟上一个数字相同,如果相同就跳过。但这个"相同"判断必须限制在同一层递归内,不能跨层。哪个层是同一个循环?就是同一个 for 循环体内。用 used 数组标记会更好理解:当 used[i-1] 为 false 且 candidates[i] == candidates[i-1] 时,说明是同一层的重复选择,跳过。
一旦你把 77 的模板吃透,再学这些变种会特别快,因为所有改动都落在"终止条件、循环范围、递归参数、去重逻辑"这四个点上,没有跳出框架的新东西。
5.3 一道题的延伸思考:组合数量有多少
做 77 题时值得顺手想一件事:当 k = 0 或者 k = n 时,组合数量分别是什么?k = 0 在本题不出现(因为约束至少是 1),但子集问题里要注意空集是合法子集。k = n 时组合只有一个,即 [1..n] 本身,但这道题要求返回这个唯一的组合,代码依然能用:第一层选了 1,第二层 startIndex 变成 2,一路选到 n,path 长度正好为 n,收集结果然后一路返回,不再有其他分支。
这些边角情况的思考能帮助你验证自己的代码是否健壮。LeetCode 的测试样例不一定覆盖所有极端场景,提交前自己多问一句"如果 k 等于 1 会怎样,k 等于 n 会怎样"能避免很多潜在返工。
6. 一些实操心得和写在最后的建议
刷到第 155 天,我最大的体会是:回溯题目数量很多,但骨架只有一个。把 77 题吃透,后面 90% 的变种题都能用"改三个控制点"的方式做出来。遇到新题先别急着敲代码,闭眼想一想递归树长什么样,每一步可选元素是什么,递归到哪一层终止,这个思考过程比写代码本身更值钱。
关于是否要把模板背下来,我的建议是要背,但背的是"框架结构"而不是"逐字逐句"。面试或比赛时,框架可以帮助你在几分钟内搭出主体,剩下的时间全用来处理题目特定的去重和剪枝逻辑。框架熟到形成条件反射以后,写回溯题需要的脑力会大幅下降。
最后分享一个小习惯:每次刷完回溯题,在题解区或者自己的笔记里贴一遍"递归树手绘图 + 剪枝推导过程 + 代码"。这个三联笔记法我坚持了一百多天,对巩固理解非常有帮助。特别是画递归树,虽然麻烦,但当你能一眼看出某道题的递归树形状时,你真的已经学会了回溯。77 是这条路上最好的一块敲门砖。