LeetCode 77. 组合:DFS回溯+剪枝,高效求解组合问题

在算法刷题中,组合问题是回溯算法的经典应用场景之一。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回溯 + 剪枝优化」,关键在于:

  1. 用"选或不选"的分支决策,遍历所有可能的组合;

  2. 加入剪枝逻辑,减少无效递归,提升效率;

  3. 注意数组引用传递的问题,用副本存入结果;

  4. 正确排列终止条件的顺序,避免漏掉包含n的组合。

相关推荐
格林威1 小时前
工业相机图像高速存储(C#版):内存映射文件方法,附Basler相机C#实战代码!
开发语言·人工智能·数码相机·c#·机器视觉·工业相机·堡盟相机
Nuopiane1 小时前
MyPal3(3)
java·开发语言
重生之我是Java开发战士1 小时前
【递归、搜索与回溯】二叉树中的深度优先搜索:布尔二叉树,求根节点到叶节点数字之和,二叉树剪枝,验证二叉搜索树,第K小的元素,二叉树的所有路径
算法·深度优先·剪枝
篮l球场1 小时前
矩阵置零
算法
KerwinChou_CN1 小时前
什么是流式输出,后端怎么生成,前端怎么渲染
前端
mjhcsp1 小时前
C++剪枝解析
c++·剪枝
lihihi1 小时前
P1650 [ICPC 2004 Shanghai R] 田忌赛马(同洛谷2587)
开发语言·算法·r语言
朱一头zcy1 小时前
[牛客]BC38 变种水仙花
算法
努力学算法的蒟蒻1 小时前
day105(3.6)——leetcode面试经典150
算法·leetcode·面试