news 2026/10/15 7:20:47

回溯算法详解:从LeetCode 77组合问题吃透递归剪枝

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
回溯算法详解:从LeetCode 77组合问题吃透递归剪枝

聊回溯算法,绕不开的就是组合问题。LeetCode第77题,“组合”,基本是每个刷题人接触回溯的第一站。题目本身不复杂:给定两个整数n和k,返回范围[1, n]中所有可能的k个数的组合。但就是这道看似简单的问题,把回溯算法的核心骨架全包含了——递归树、startIndex、路径收集、撤销选择、剪枝优化。我当年刷这道题,第一版代码几分钟就AC了,但真正理解为什么必须传startIndex、为什么剪枝条件是那个公式,反而是连续踩了几天坑、手画了无数张递归树之后才做到的。这篇文章把我自己的理解过程、调试方法和避坑经验全整理出来,希望能帮你把这道题彻底吃透,而不是停留在“背模板能跑”的阶段。

1. 题目本身在问什么:组合背后的“无序”逻辑

1.1 一句人话读懂题目

题目输入两个整数n和k,要求返回从1到n这n个整数中,任意选取k个数所组成的全部组合。以最经典的样例来说,n=4,k=2,就是从{1, 2, 3, 4}里任选两个数,最终结果应该是:

[ [1,2], [1,3], [1,4], [2,3], [2,4], [3,4] ]

注意这里输出的顺序其实无所谓,LeetCode对答案顺序没有要求,只要集合内容正确就可以。一共6个组合,这个数字也完全吻合数学里的组合数公式:C(4,2) = 4! / (2! * 2!) = 6。

我之前见到有人在评论区问:“为什么结果里没有[2,1]?没有[4,3]?”这个问题恰好就是组合和排列的分水岭。组合不关心元素的先后顺序,[1,2]和[2,1]在数学定义上是同一个组合。这一点如果没想清楚,后面代码里的startIndex设计就完全看不懂。

1.2 组合和排列的根本区别:无序

排列和组合是高中数学的老话题,但刷题时很多人反而把它丢了。排列强调顺序,A(4,2) = 4! / 2! = 12种,比组合多了整整一倍。组合则把顺序相同的归并成一种。说白了,组合是“选出来就行”,排列是“选出来还要排好队”。

我习惯用一个生活化类比去理解:一个房间里四个人,每两个人握一次手,一共握几次?答案是6次。A和B握手,你绝不会把它和B与A握手算成两次,因为握手这个动作本身没有“谁先伸手”的先后属性。组合就是在数“握手次数”,排列则是在数“A先伸手、B后伸手”这种带方向的关系。

这个“无序”属性直接决定了代码设计。在回溯遍历过程中,如果每次都从头开始挑选,就会出现[1,2]和[2,1]同时被收集的情况。所以代码里必须有一种机制保证“每次选的数都比上一次大”或者“每次只从当前元素后面的区间里选”,这就是后面要讲的startIndex。

1.3 组合、排列、子集三兄弟

刷题刷到中期,很多人会被组合、排列、子集这三类题搞晕,因为它们的回溯模板长得非常像。我在这里做一个对照表,基本能把这几个问题彻底分开:

问题类型是否考虑顺序是否必须取满k个典型题目核心区别
组合否是LeetCode-77选k个,但[1,2]和[2,1]算同一个
排列是是LeetCode-46选k个,且[1,2]和[2,1]算两个
子集否否LeetCode-78每个元素可选可不选,长度从0到n都要
分割否否LeetCode-131按连续区间切分,本质也是组合思路

组合是子集的“限量版”,子集是所有可能长度的组合的并集。而组合和排列的区别就是多了一个used数组(或者说是“是否用过这个数”的状态)来保证顺序被考虑。理解了这一层关系,选模板就有了方向。

2. 回溯算法核心套路:从递归树到模板

2.1 回溯的底层逻辑:尝试、撤销、再尝试

回溯算法的本质是深度优先搜索(DFS)加上状态回退。你可以把它想象成走迷宫:每次走到岔路口,先挑一条路往里走,走到死胡同就退回到岔路口,换一条路再试。这个“退回来”的动作,在代码里就是撤销上一次的选择。

具体到组合问题,回溯的过程就是:从候选数字里先选一个,然后在剩下的数字里继续选,直到选满k个就记录答案,然后回退一步,换一个数字重新选。这样一层一层递归下去,最终所有组合都会被枚举出来。

代码层面,回溯算法有三个核心要素:路径(path)、选择列表(可选范围)、终止条件。在组合问题里,路径就是当前已经选中的数字集合;选择列表就是startIndex到n之间的数字;终止条件就是path里已经收集了k个数字。这三个要素各有各的注意点,后面逐一说。

2.2 为什么必须有一个startIndex

如果没有startIndex,组合问题会直接变成一个排列问题。比如第一层选了数字2,第二层如果还是从1开始遍历,就会把数字1也选进来,产生组合[2,1]。但之前第一层选数字1的时候,第二层已经产生过[1,2]了,于是[2,1]就成了一个重复结果。

解决办法是让递归的搜索起点往后挪。每一层的搜索起点,都是上一层所选数字的下一个位置。也就是说,如果上一层在for循环里取了数字i,那么下一层递归就只能从i+1开始选。这个i+1就是防止重复组合出现的关键,也是回溯模板里最常见的参数设计。

我见过很多人一开始不理解startIndex和i+1的关系,直接把递归写成backtracking(n, k, startIndex),结果出来的答案全是重复组合。这里要咬死一个原则:每一层递归的可选区间必须是一个不断收窄的窗口,绝不能每次递归都从头开始扫。

2.3 终止条件:什么时候收集结果

回溯问题通常关注两类节点:叶子节点和中间节点。组合问题只关注叶子节点,也就是path.size()等于k的那一层递归。当满足这个条件时,把path里的内容保存到最终结果result里,然后return,不再往下递归。

这里面有一个非常隐蔽的坑:保存path的时候必须新建一个拷贝。如果用result.add(path),由于path是同一个对象,后面递归回退时执行removeLast()会把这个对象里的元素弹出去,最终result里存的所有“结果”都会变成空列表或同一个最终状态的重复。正确写法是result.add(new ArrayList<>(path)),相当于给当前组合拍了一张快照。

2.4 横向遍历与纵向递归

理解回溯树结构是吃透这道题的关键。整棵树的根节点是“还没选任何数”,每一层递归代表一个可选的数字分支。for循环负责横向遍历,也就是在当前可选范围内依次尝试每个数字;递归负责纵向深入,也就是在选定一个数字后,继续去下一层选接下来的数字。两者结合,就形成了完整的深度优先搜索。

以n=4、k=2为例,根节点的选择列表是1、2、3、4。先选1,递归进入第二层,第二层可选范围只剩2、3、4。在第二层中,for循环依次尝试2、3、4,每尝试一个就收集一个组合[1,2]、[1,3]、[1,4]。等第二层全部遍历完,回到根节点,for循环继续尝试2作为第一个数,第二层可选范围只剩3、4,依次收集[2,3]、[2,4]。这样整个递归树遍历完毕,6个组合全部收集完成。

我强烈建议你第一次学这个题的时候,拿出纸笔,把n=4、k=2的递归树完整画一遍,每个节点标清楚当前path内容和下一层的起始位置。画完之后再回来看代码,很多疑惑会瞬间消失。

3. 代码实现:一份能直接AC的模板

3.1 朴素回溯的完整代码

先给出最朴素的、不做任何剪枝的Java版本,这是回溯算法最原始的面貌,也是后面所有优化的基础。

class Solution { List<List<Integer>> result = new ArrayList<>(); LinkedList<Integer> path = new LinkedList<>(); public List<List<Integer>> combine(int n, int k) { backtracking(n, k, 1); return result; } private void backtracking(int n, int k, int startIndex) { if (path.size() == k) { result.add(new ArrayList<>(path)); return; } for (int i = startIndex; i <= n; i++) { path.add(i); backtracking(n, k, i + 1); path.removeLast(); } } }

这段代码提交到LeetCode-77,可以通过所有测试用例。我当年就是先把这个版本跑通,然后才开始研究剪枝的。先跑通、再优化,这个顺序对新手特别重要,别一上来就追求最优解,反而把基本流程搞混。

3.2 逐行拆解:代码每个细节在干什么

成员变量result和path放在类里面,目的是让递归方法不用反复传递这两个引用。path用LinkedList而不是ArrayList,是因为频繁在末尾添加和删除元素时,LinkedList的removeLast操作更符合栈的使用场景,语义上也更清晰。当然你用ArrayList然后remove(path.size() - 1)也完全可以,只是LinkedList的写法更贴合“栈”的直觉。

递归方法backtracking接收三个参数:n是数字范围上限,k是目标组合长度,startIndex是当前层可选的起始位置。核心逻辑就三行:path.add(i)表示做出选择,backtracking递归调用表示进入下一层,path.removeLast()表示撤销选择。这三行构成一个不可分割的循环体,少写任何一行,程序的行为就会完全不对。

3.3 时间复杂度怎么算

组合问题的时间复杂度不是O(n^k),虽然看起来for循环嵌套递归很像n的k次方。实际总的递归次数等于所有有效组合的数量,也就是C(n, k) = n! / (k! * (n-k)!)。每次收集结果时需要把path中的k个元素拷贝到一个新列表,所以时间复杂度是O(C(n, k) * k)。你可能会看到一些博客直接写O(C(n, k)),那是忽略了拷贝成本;严格来说应该是乘以k。

空间复杂度方面,path最多存储k个元素,递归栈的深度最多也是k层,所以额外空间是O(k)。result存储所有结果,不计入辅助空间,但如果面试官要求算上返回值,那就是O(C(n, k) * k)。

这套复杂度分析思路是通用的。碰到任何回溯题,先算“一共有多少条路径”,再算“每条路径的拷贝/处理成本”,两者相乘就是典型的时间复杂度。别去套一些花里胡哨的公式,掌握这个思路以后换题目也能算。

3.4 边界条件和输入约束

这道题LeetCode给出了约束:1 <= k <= n <= 20。k的范围给得很小,说明它甚至允许用暴力枚举通过,这也是回溯能AC的前提。但我们要养成习惯,不是所有回溯题都有这么宽松的范围,组合总和类题目就可能出现n很大的情况。

边界情况考虑两点。第一,k等于n时,只有一种组合,就是[1,2,...,n]。代码处理这个过程没有问题,递归只有一条路径走到头。第二,极端情况下如果k为0(虽然题目约束不会出现),应该返回一个空列表集合,而不是null。我一般会在代码里加一个if (k <= 0) return result;的防御性判断,避免有人改参数测试时出问题。

4. 剪枝优化:从朴素回溯到高效回溯

4.1 为什么要剪枝

朴素回溯会把所有分支都走完,但有些分支根本走不到终点。举个例子,n=4、k=3,当你已经选了3个数字,在某个递归层中startIndex等于4时,剩下的可选元素只有4这一个,根本凑不满k个,但朴素代码还是会进入这个递归,进去后for循环跑一遍发现size到不了3,空手而归。

再夸张一点,n=100、k=50,如果第一层for循环里i已经取到了80,那么后面只剩20个数字可用,根本不可能凑齐50个组合数。但朴素回溯不会提前感知这一点,它会傻傻地往下递归很多层,每一层都发现凑不够数,然后大量空转。这些无效递归就是可以剪掉的分支。

4.2 剪枝条件的逐步推导

剪枝的本质是:for循环的搜索范围不需要遍历到n,只需要遍历到“还能凑够k个数”的最大起点即可。

一步步推。当前path里已有path.size()个元素,还需要k - path.size()个元素。假设for循环的当前变量是i,那么i到n之间一共有n - i + 1个可选元素。想要这条路能走到终点,就必须保证剩余元素数量不少于还需要的元素数量,也就是:

n - i + 1 >= k - path.size()

把这个不等式移项,得到:

i <= n - (k - path.size()) + 1

这就是for循环的右边界。最终代码从for (int i = startIndex; i <= n; i++)变成for (int i = startIndex; i <= n - (k - path.size()) + 1; i++)。

我第一次推导这个公式的时候总觉得有点绕,后来换了一个更直观的理解方式:把n个数字想象成一排在货架上的商品,你需要再取needed个商品,那么你的手最多只能伸到第n - needed + 1个位置。再往后,剩下的商品数量就已经不够了。

4.3 剪枝后的完整代码

剪枝后的代码只改了一个地方,但效果完全不同:

class Solution { List<List<Integer>> result = new ArrayList<>(); LinkedList<Integer> path = new LinkedList<>(); public List<List<Integer>> combine(int n, int k) { backtracking(n, k, 1); return result; } private void backtracking(int n, int k, int startIndex) { if (path.size() == k) { result.add(new ArrayList<>(path)); return; } for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) { path.add(i); backtracking(n, k, i + 1); path.removeLast(); } } }

注意,剪枝条件不是去掉某个具体分支,而是直接从循环条件层面减少被尝试的起点。它不改变算法的时间复杂度量级,但是会显著降低常数因子,在n较大时减少大量无谓的递归调用。

4.4 剪枝效果实测

以n=4、k=3为例,我手动数过两种写法的递归调用次数。朴素回溯总共发生15次backtracking调用,而剪枝之后只需要10次调用,少了三分之一。当n和k的差距进一步拉大时,这种差距会越来越明显。比如n=20、k=10,剪枝前很多从11到20开头的分支都会白跑几十层,剪枝后这些分支根本不会进入。

面试时如果被问到剪枝,很多人能背出这个公式,但会被追问为什么。我建议把推导过程完整说一遍,先讲“剩余可选数 >= 还需元素数”这个不等式,再推出循环右边界。能把“为什么”讲清楚,面试官通常就会认为你是真懂回溯,而不是背模板。

5. 刷题路上的坑:我踩过的雷和调试技巧

5.1 坑1:忘记撤销路径,结果全废

刚开始练回溯时,我有一段非常经典的低级错误代码——只写了path.add(i)和backtracking,忘了path.removeLast()。结果提交之后,第一个组合[1,2]倒是正确收集了,但后续所有组合全都带着前一次递归的残留元素,越走越长,最后甚至越界。

排查这种问题最快的方法,是在每次递归入口处打印path的内容。你会清楚地看到:从[1,2]回到根节点时,path依然还是[1,2],没有缩回空列表。这时候你马上就意识到是撤销步骤丢了。修复只需要一行path.removeLast()。从此我给自己定了一条规矩:回溯三步曲(选择、递归、撤销)必须同时出现,写代码时先写好三步,再填具体逻辑。

5.2 坑2:收集结果时没有拷贝路径

这个坑更隐蔽。假设你正确写了removeLast,但收集结果时直接写result.add(path),你会发现最终result里存的全是空列表。原因是result.add(path)保存的是path这个对象的引用,之后removeLast把元素弹出去,path对象变成空列表,result里所有引用指向的同一个对象也跟着变空。

解决方案就是前面提到的new ArrayList<>(path)。这句话相当于把path的内容完整复制一份,已经复制出来的内容不再受path后续变化影响。这背后是Java引用和对象的概念:add进去的是引用,不是值拷贝。我一般会把这个问题归到“深浅拷贝”这一类别里,刷题时凡是往结果集合里放一个正在被修改的容器,都要用拷贝构造器做快照。

5.3 坑3:递归参数写错,重复组合满天飞

还有一次,我把backtracking(n, k, i + 1)错写成了backtracking(n, k, startIndex)。结果每个递归层都从同一个startIndex开始,第二层选到的数字可能比第一层还小,于是[1,2]和[2,1]同时出现在结果里,白白多出一倍重复组合。

这个问题排查起来也是用打印递归树:你会看到每一层递归的可选范围没有收窄,每一层的startIndex没有随着路径的变化而移动。修复就是把参数从startIndex改成i+1,让下一层永远从当前已选数字的下一位开始扫。这个i和startIndex的区别,我在前面第2.2节已经重点说过,它是组合问题去重的命根子,连这个都写错,说明对回溯树的层次关系还没有真正掌握。

5.4 调试技巧:打印递归树快速定位问题

刷回溯题,我强烈建议写一个调试辅助方法,在递归入口打印当前层数、path内容和待选起始位置。下面是一个我在本地IDE里实际调试时常用的输出格式示例:

层数:0, path:[], 下一层起始:1 层数:1, path:[1], 下一层起始:2 层数:2, path:[1,2], 下一层起始:3 -> 收集 [1,2] 层数:2, path:[1,3], 下一层起始:4 -> 收集 [1,3] 层数:2, path:[1,4], 下一层起始:5 -> 收集 [1,4] 层数:1, path:[2], 下一层起始:3 层数:2, path:[2,3], 下一层起始:4 -> 收集 [2,3] 层数:2, path:[2,4], 下一层起始:5 -> 收集 [2,4] 层数:1, path:[3], 下一层起始:4 层数:2, path:[3,4], 下一层起始:5 -> 收集 [3,4]

观察这份输出,你能非常直观地看到:同一层path的变化从[1]变成[2]再变成[3],这是for循环横向遍历的效果;层数加深时path变长,这是递归纵向深入的效果。每一层的起始位置都严格等于上一层所选数字的下一位,这就是startIndex和i+1的意义。只要输出里出现“重复组合”或“不同层起始位置一样”的情况,问题定位就是一两分钟的事。

6. 举一反三:组合题的变体与实战场景

6.1 组合总和类问题:从“选满k个”到“和为目标值”

LeetCode-39组合总和和LeetCode-40组合总和II,是组合问题的第一层变体。它们不再要求固定选k个数,而是要求选出的数字之和等于目标值target。39题允许同一个数字无限次重复选取,核心改动只有一处:递归参数从i+1改成i,让下一层还能重新选择当前数字。而40题要求每个数字只能用一次,并且结果不能有重复组合,处理方法通常是先排序,再在for循环里加一个去重判断:如果当前数字和前一个数字相同,并且前一个数字没有被选中,就跳过。

这两道题做完,你就掌握了回溯里“去重”的两种典型套路:一种靠排序加相邻去重,一种靠used数组记录是否使用过。它们都属于组合题的标准延伸,面试频率相当高。

6.2 多集合组合:电话号码的字母组合

LeetCode-17电话号码的字母组合,也是组合思想的变体,但结构不同。77题是在同一个数字集合里选k个;17题则是从多个不同的集合里各取一个。比如数字“23”,对应‘abc’和‘def’两个字母集合,要求所有可能的组合。此时不再需要startIndex,因为每个位置可选集合都不同,不会产生顺序重复;只需要一个index记录当前处理到第几个数字,每层从对应的字母集合里选一个字母,然后递归到下一层。

我习惯把这类题叫作“多段式组合”,它更贴近实际业务中的“从每个分组里各选一项”的场景。典型应用比如配置系统里,颜色(黑、白、红)和尺寸(S、M、L)的所有搭配组合,本质就是从多个枚举集合里各取一个元素。

6.3 组合在实际场景中的典型应用

组合问题不只是面试题。我做过的项目里,遇到过几个真实场景,全部可以套用77题的模板来解。第一个是电商后台的优惠券叠加规则,从n种优惠券里选k种给用户展示,需要穷举所有组合来测试系统;第二个是权限系统,从n个角色权限中选k个分配给某个新角色,需要生成所有可能的权限组合方案;第三个是机器学习里的特征选择,从m个特征里选k个做建模,用小规模数据穷举组合来对比效果,这时候回溯就是最直接的方案。

这些场景的共同特点是:候选集合规模不大(一般不超过20),但需要拿到具体的组合列表,而不是只算数量。规模一旦超过20,回溯就会比较吃力,那时候就要考虑启发式或动态规划了。

6.4 回溯与其他方案的取舍

有读者可能会问:“如果只是想知道总共有多少种组合,是不是也可以回溯?”从功能上当然可以,但没必要。数学上有现成的组合数公式,O(1)就能算出来。回溯的价值在于输出“每一种具体的组合”,而不是计数。如果只需要计数,直接套C(n, k)公式即可;如果n很大(比如n=50、k=10),即使只求组合数量,也会因为数量本身爆炸而无法枚举,那时候就要考虑用组合数公式配合取模运算等数学技巧。

对于n比较小的场景,有一种替代回溯的迭代写法是二进制枚举,用一个整数的二进制位表示某个数字是否被选中,再筛出二进制中1的个数等于k的状态。这种方法实现起来也简单,适合n不超过20的情况。但它更难扩展:一旦从“选满k个”变成“和等于target”之类的约束,二进制枚举就需要额外遍历筛选,模板复用性远不如回溯。所以我个人的习惯是:拿回溯当组合问题的主方案,把二进制枚举当作一种思维补充。

这道题刷完之后,我最大的收获不是AC了77题,而是把回溯算法的通用套路彻底理清了。现在看到任何回溯题,我都先默默画一棵树,标清楚每一层的选择列表、终止条件和解的收集方式,然后直接往上套模板。这个方法陪我解决了上百道LeetCode的搜索、组合、子集、排列类题目,几乎没有失手过。如果你正卡在回溯算法的门槛上,我强烈建议从77题开始,认真画一遍递归树,而不是只把代码跑通就换下一题。把这道题真正吃透,后面整个回溯系列都会变得顺畅很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 7:20:16

中医养生实训室建设:数字化技术与传统医学融合实践

1. 项目背景与核心价值中医养生保健实训室的建设&#xff0c;本质上是在搭建传统医学理论与现代职业教育之间的桥梁。作为在中医教育领域深耕多年的从业者&#xff0c;我见证过太多实训室从"药材陈列馆"到"智能实训中心"的蜕变过程。一个真正合格的实训室&…

作者头像 李华
网站建设 2026/10/15 7:19:59

AI专利实战指南:从技术方案到权利要求与布局策略

这两年&#xff0c;做 AI 的朋友聊天时绕不开一个词&#xff1a;AI 专利。我见过不少团队&#xff0c;模型跑得很漂亮&#xff0c;论文也发了&#xff0c;最后却因为专利布局晚了&#xff0c;在商业化和融资环节被竞争对手用专利围墙挡住。AI 专利从来不是“把论文复制粘贴成申…

作者头像 李华
网站建设 2026/10/15 7:19:55

UOS离线仓库部署实战:基于aptly的可信镜像方案

1. 项目概述&#xff1a;为什么离线仓库在UOS环境下不是“备选方案”&#xff0c;而是刚需“基于UOS的离线仓库部署”——这八个字背后&#xff0c;藏着一批真实场景里反复被卡住脖子的运维人员、信创项目交付工程师和国产化替代推进小组成员。我接触过不下二十个实际案例&…

作者头像 李华
网站建设 2026/10/15 7:19:43

Java技术栈进阶:从Spring MVC到分布式架构实战

1. 项目概述&#xff1a;Java技术栈的求职进阶之路在当今企业级开发领域&#xff0c;Java技术生态的深度和广度都在持续扩展。从传统的Spring MVC单体架构到现代的分布式微服务体系&#xff0c;技术栈的演进直接影响着开发者的职业竞争力。我经历过三次技术转型期的求职过程&am…

作者头像 李华
网站建设 2026/10/15 7:18:40

AI反例生成:数学验证的范式革命

1. 这不是科幻&#xff0c;是正在发生的数学现场“AI找出数学反例推翻论文&#xff0c;作者确认&#xff0c;453篇手稿&#xff0c;AI开始自己出题了”——这行标题刚刷出来时&#xff0c;我正坐在凌晨两点的书桌前&#xff0c;手边摊着一份刚被ArXiv撤回的预印本&#xff0c;P…

作者头像 李华