在算法刷题中,组合问题是回溯算法的经典应用场景之一。LeetCode 77. 组合 要求我们从 [1, n] 的范围内,找出所有长度为 k 的数字组合,不考虑顺序且不重复。今天就来拆解这道题的解题思路,分析代码逻辑,以及避开那些容易踩坑的细节。
一、题目核心需求
给定两个整数 n 和 k,返回 [1, n] 中所有可能的 k 个数的组合。例如:
-
输入:n = 4, k = 2 → 输出:[[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]]
-
输入:n = 1, k = 1 → 输出:[[1]]
核心关键点:组合不考虑顺序([1,2] 和 [2,1] 是同一个组合),且每个数字只能使用一次。
二、解题思路:DFS回溯 + 剪枝优化
组合问题的本质是"选或不选"的决策过程------对于每个数字,我们可以选择将其加入当前组合,也可以选择不加入,直到组合长度达到 k 或者遍历完所有数字。这种决策过程非常适合用深度优先搜索(DFS)来实现,也就是我们常说的回溯法。
同时,为了避免无效递归、提升效率,我们需要加入"剪枝"逻辑:当剩余未遍历的数字数量,不足以凑够 k 个数字时,直接终止当前递归,无需继续向下搜索。
三、完整代码解析
先贴出最终可直接运行的 TypeScript 代码(兼容 JavaScript,去掉类型注解即可),再逐行拆解核心逻辑:
typescript
function combine(n: number, k: number): number[][] {
const res: number[][] = []; // 存储最终所有符合条件的组合
// DFS递归函数:探索所有可能的组合
// cur:当前遍历到的数字(从1开始)
// n:题目给定的上限
// k:需要选择的数字个数
// arr:当前正在构建的组合
const dfs = (cur: number, n: number, k: number, arr: number[]) => {
// 终止条件1:当前组合长度达到k,存入结果并返回
if (arr.length === k) {
res.push([...arr]); // 关键:push副本,避免引用传递导致结果异常
return;
}
// 剪枝逻辑:剩余数字(n - cur + 1)不足以凑够k个,提前终止
if (arr.length + (n - cur + 1) < k) {
return;
}
// 终止条件2:遍历完所有数字(cur > n),直接返回
if (cur > n) {
return;
}
// 决策1:选择当前数字cur,加入组合,继续遍历下一个数字
dfs(cur + 1, n, k, [...arr, cur]);
// 决策2:不选择当前数字cur,直接遍历下一个数字
dfs(cur + 1, n, k, arr);
}
// 从数字1开始,初始化空组合,启动DFS
dfs(1, n, k, []);
return res;
};
1. 结果数组初始化
const res: number[][] = []; ------ 用于存储所有最终符合条件的组合,是全局可见的(在DFS函数内部可直接操作)。
2. DFS递归函数核心参数
DFS函数的四个参数,分别对应回溯过程中的关键状态:
-
cur:当前正在遍历的数字,从1开始(因为范围是[1, n]),避免重复选择前面的数字; -
n:题目给定的数字上限,用于判断是否遍历结束; -
k:需要选择的数字个数,用于判断组合是否达标; -
arr:当前正在构建的组合,每一步决策都会修改这个数组。
3. 三个关键逻辑(终止+剪枝)
这部分是代码的核心,也是容易踩坑的地方,尤其注意顺序!
-
终止条件1:组合达标 ------
if (arr.length === k)当当前组合的长度等于k时,说明找到了一个符合条件的组合。这里必须用
[...arr]生成副本存入res,而不是直接push(arr)------因为数组是引用类型,直接push会导致后续递归修改arr时,res中已存储的组合也被修改,最终结果异常。 -
剪枝逻辑 ------
if (arr.length + (n - cur + 1) < k)剪枝的核心是"提前止损":当前组合已有arr.length个数字,剩余未遍历的数字个数是
n - cur + 1(从cur到n的所有数字)。如果两者之和小于k,说明即使把剩余所有数字都加入组合,也凑不够k个,此时无需继续递归,直接返回,减少无效计算。 -
终止条件2:遍历结束 ------
if (cur > n)当cur超过n时,说明所有数字都遍历完毕,没有更多数字可以选择,直接终止递归。这里要注意顺序:必须放在"组合达标"和"剪枝"之后,否则会漏掉包含n的组合(后面会详细说这个坑)。
4. 两种决策分支(回溯的核心)
DFS的核心就是"选或不选"的分支决策,这两步递归构成了回溯的完整逻辑:
-
dfs(cur + 1, n, k, [...arr, cur])------ 选择当前数字cur:将cur加入当前组合(生成新的数组副本,避免影响其他分支),然后遍历下一个数字(cur+1); -
dfs(cur + 1, n, k, arr)------ 不选择当前数字cur:直接遍历下一个数字,当前组合保持不变。
5. 启动DFS
dfs(1, n, k, []); ------ 从数字1开始遍历,初始化一个空组合,启动递归流程,最终所有符合条件的组合都会被存入res,最后返回res即可。
四、常见易错点(避坑指南)
这道题的代码看似简单,但有两个非常容易踩坑的细节,也是很多人提交失败的原因:
易错点1:直接push(arr),导致结果异常
错误写法:res.push(arr);
原因:数组是引用类型,arr在递归过程中会被不断修改(比如加入新数字、回溯时恢复),直接push(arr)相当于把arr的引用存入res,后续修改arr会同步影响res中的所有组合,最终可能出现所有组合都是空数组或重复数组的情况。
正确写法:res.push([...arr]); ------ 生成arr的浅拷贝,存入副本,避免引用传递的问题。
易错点2:终止条件顺序错误,漏掉包含n的组合
错误写法:把 if (cur > n) 放在最前面。
后果:当cur = n时,递归到下一层cur = n+1,会先触发cur > n直接返回,此时如果当前组合加入n后长度为k(比如n=4,k=2,arr=[3]),就会漏掉[3,4]这个组合,导致结果缺少包含n的情况。
正确顺序:先判断组合是否达标(arr.length === k),再剪枝,最后判断是否越界(cur > n),确保包含n的组合能被正确存入。
五、代码优化与拓展
1. 优化:减少参数传递
原代码中,n和k在DFS函数中是固定不变的,可以将其提升到外层作用域,避免每次递归都传递,简化代码:
typescript
function combine(n: number, k: number): number[][] {
const res: number[][] = [];
const dfs = (cur: number, arr: number[]) => {
if (arr.length === k) {
res.push([...arr]);
return;
}
if (arr.length + (n - cur + 1) < k) return;
if (cur > n) return;
dfs(cur + 1, [...arr, cur]);
dfs(cur + 1, arr);
};
dfs(1, []);
return res;
};
2. 拓展:理解组合与排列的区别
很多人会混淆组合和排列,这里简单区分:
-
组合:不考虑顺序,比如[1,2]和[2,1]是同一个组合,因此我们用cur+1确保只遍历后面的数字,避免重复;
-
排列:考虑顺序,比如[1,2]和[2,1]是不同的排列,此时需要用visited数组标记已使用的数字,而不是用cur+1。
六、总结
LeetCode 77. 组合的核心解法是「DFS回溯 + 剪枝优化」,关键在于:
-
用"选或不选"的分支决策,遍历所有可能的组合;
-
加入剪枝逻辑,减少无效递归,提升效率;
-
注意数组引用传递的问题,用副本存入结果;
-
正确排列终止条件的顺序,避免漏掉包含n的组合。