“组合”的本质,是一场优雅的试错:手把手带你搞懂回溯与递归
🪄 写在开头:组合问题,不止“选几个数”这么简单
“从一堆数里选出几个数,满足某种条件”——这类问题,你肯定刷过。
从最早的:
- 「从1到n中选k个数」
到: - 「电话号码的字母组合」
- 「求子集、排列、组合、组合总和」
甚至到: - 「N皇后、数独填空、单词搜索」……
你有没有发现,它们看似不同,其实核心都在考察两点:
- 递归的表达能力:定义子问题、收缩问题规模;
- 回溯的试错过程:每走一步都能“回头看”,撤销选择、换条路走。
而“组合类问题”,就是最基础、也最适合入门的模板题。
今天我就带你从一道最经典的“组合问题”入手,深挖回溯与递归背后的算法哲学,不仅会写,还要会想。
🎯 问题描述:从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 个数;
- 最后组合起来就是解。
所以我们需要的,是这样一套逻辑:
- 从某个起点
start
开始尝试加入数字; - 每加入一个数字,k就减一;
- 当组合数量到达k,记录下来;
- 回退上一步,继续尝试下一个数字;
- 直到遍历完所有路径。
🧪 动手实践:一行一行读懂回溯代码
我们来看一个标准回溯解法的模板代码(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] |
本质上都是回溯法的变种,只需换一下条件判断、遍历方式、剪枝策略,就可以拓展出一整个算法体系。
🧾 写在最后:组合题不是死记模板,而是训练抽象能力
刷题不是为了记住代码,而是为了构建“问题->模型->递归->剪枝”的抽象能力。
组合类问题是最基础的回溯训练场,通过它我们可以习得:
- 递归调用的结构化思维;
- 回溯法的路径构建和撤销机制;
- 参数控制的边界判断与剪枝技巧;
- 解空间模型的“树状图”画法;
这些思想贯穿算法设计和解决复杂问题的全流程。