“组合”的本质,是一场优雅的试错:手把手带你搞懂回溯与递归

“组合”的本质,是一场优雅的试错:手把手带你搞懂回溯与递归


🪄 写在开头:组合问题,不止“选几个数”这么简单

“从一堆数里选出几个数,满足某种条件”——这类问题,你肯定刷过。

从最早的:

  • 「从1到n中选k个数」
    到:
  • 「电话号码的字母组合」
  • 「求子集、排列、组合、组合总和」
    甚至到:
  • 「N皇后、数独填空、单词搜索」……

你有没有发现,它们看似不同,其实核心都在考察两点

  1. 递归的表达能力:定义子问题、收缩问题规模;
  2. 回溯的试错过程:每走一步都能“回头看”,撤销选择、换条路走。

而“组合类问题”,就是最基础、也最适合入门的模板题。

今天我就带你从一道最经典的“组合问题”入手,深挖回溯与递归背后的算法哲学,不仅会写,还要会想。


🎯 问题描述:从1到n中选k个数,输出所有组合

这题LeetCode上叫 77. 组合,题目非常直接:

给定两个整数 n 和 k,返回 1…n 中所有可能的 k 个数的组合。

举个例子:

输入:n = 4, k = 2
输出:
[
  [1,2],
  [1,3],
  [1,4],
  [2,3],
  [2,4],
  [3,4]
]

看到这种题,我们的第一反应往往是:这不就是穷举吗?全都试一遍就好了。

没错,就是要“试”,但怎么试得优雅?怎么试得不重复?怎么试得不冗余?——这就是回溯要解决的问题。


🧠 思维模型:回溯 + 递归的双剑合璧

📌 回溯是什么?

回溯,是一种“试错 + 回滚”的思想。

  • 类似一个人在迷宫中走路,每走一步都记下来;
  • 一旦发现走不通,就退回上一步,换一条路;
  • 直到找到所有可行路径或者最优解。

📌 递归是什么?

递归,是一种“把大问题变小问题”的思维方式。

  • 比如:combine(n, k) 其实是让你思考:
    • 如何从 [1…n] 中选择一个数 x;
    • 再从 [x+1…n] 中递归地选 k-1 个数;
    • 最后组合起来就是解。

所以我们需要的,是这样一套逻辑:

  1. 从某个起点 start 开始尝试加入数字;
  2. 每加入一个数字,k就减一;
  3. 当组合数量到达k,记录下来;
  4. 回退上一步,继续尝试下一个数字;
  5. 直到遍历完所有路径。

🧪 动手实践:一行一行读懂回溯代码

我们来看一个标准回溯解法的模板代码(Node.js 写法,更贴近生活逻辑):

function combine(n, k) {
  const result = []; // 最终的结果集
  const path = [];   // 当前组合路径(临时)

  function backtrack(start) {
    // 如果组合长度达到k,加入结果集
    if (path.length === k) {
      result.push([...path]); // 注意要复制
      return;
    }

    // 从当前数字开始,尝试所有可能
    for (let i = start; i <= n; i++) {
      path.push(i);         // 做出选择
      backtrack(i + 1);     // 递归下一层
      path.pop();           // 撤销选择(回溯)
    }
  }

  backtrack(1); // 从数字1开始组合
  return result;
}

🔍 逐行解读:

  • path 是一个栈,记录当前走到哪一步;
  • start 保证组合不重复、不回头;
  • path.pop() 是关键:每尝试一个选项后要“清场”,为下一轮准备
  • path.length === k 时,说明达成目标。

这个函数本质上是构造了一个“树状结构”的所有路径,只记录长度为 k 的路径。


🧰 组合问题的“剪枝”优化技巧

有时候你会发现,遍历范围可以更小。

✂️ 剪枝点:

for (let i = start; i <= n - (k - path.length) + 1; i++)
  • 这段代码是为了提前终止无意义的尝试
  • 举个例子:
    • 假设 n=4, k=3,当前已经选了2个数;
    • 此时你只剩 i=4 可选,而你还需要2个数,不够了;
    • 所以从 i 开始就没有意义,提前跳出。

这个优化虽然不会改变算法复杂度(仍然是 C(n,k) 级别),但能极大提升执行效率。


🪜 举一反三:组合与排列、子集、组合总和的关系

组合是“选出若干元素”,而排列是“选出来还要排序”,子集是“选或不选”的所有情况。

类型是否关心顺序是否可重复示例
组合[1,2], [1,3]
排列[1,2], [2,1]
子集空集、[1], [1,2]
组合总和[2,2,3] from [2,3,6,7]

本质上都是回溯法的变种,只需换一下条件判断、遍历方式、剪枝策略,就可以拓展出一整个算法体系。


🧾 写在最后:组合题不是死记模板,而是训练抽象能力

刷题不是为了记住代码,而是为了构建“问题->模型->递归->剪枝”的抽象能力。

组合类问题是最基础的回溯训练场,通过它我们可以习得:

  • 递归调用的结构化思维;
  • 回溯法的路径构建和撤销机制;
  • 参数控制的边界判断与剪枝技巧;
  • 解空间模型的“树状图”画法;

这些思想贯穿算法设计和解决复杂问题的全流程。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Echo_Wish

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值