递归枚举:二叉树递归版 vs for 多叉版
同一个问题「从 $1 \sim n$ 里选 $m$ 个数」,有两种最常见的递归写法:
- 二叉树递归版:每个数字二选一(选 / 不选),按数字分层
- for 多叉版:每层填一个位置,按已选个数分层
两种都是 DFS、都是前序,但遍历的搜索树根本不是同一棵。
一、问题
从 $1 \sim n$ 中选出 $m$ 个数,按字典序输出所有组合。
以 $n = 3,\ m = 2$ 为例,答案只有三组:
1 | {1,2} {1,3} {2,3} |
二、写法 A:二叉树递归(选 / 不选)
2.1 核心思想
每一层对应一个数字,每个节点只有两个分支:拿它,或者不拿它。
1 | int n, m; |
调用入口:calc(1)。
分支顺序决定输出顺序:先走「选」得到字典序
{1,2} {1,3} {2,3};先走「不选」会得到逆字典序{2,3} {1,3} {1,2}。两者答案集合一样,但多数题目要求字典序,所以先写「选」。
2.2 搜索树长什么样
$n=3,\ m=2$,树高 = 3,每一层是一个数字(下图就是代码实际展开的节点,含 a.size() == m 的提前返回):
flowchart TD
A["x=1<br/>a = {}"]
A -->|选 1| B["x=2<br/>a = {1}"]
A -->|不选 1| C["x=2<br/>a = {}"]
B -->|选 2| D["x=3<br/>a = {1,2}"]
B -->|不选 2| E["x=3<br/>a = {1}"]
C -->|选 2| F["x=3<br/>a = {2}"]
C -->|不选 2| G["x=3<br/>a = {}"]
D -->|已满 m 个,直接输出| H["{1,2} ✓"]
E -->|选 3| I["{1,3} ✓"]
E -->|不选 3| J["{1} ✗ 不够 m 个"]
F -->|选 3| K["{2,3} ✓"]
F -->|不选 3| L["{2} ✗ 不够 m 个"]
G -->|选 3| M["{3} ✗ 不够 m 个"]
G -->|不选 3| N["{} ✗ 不够 m 个"]
classDef ok fill:#c8f7c5,stroke:#2e7d32,color:#1b5e20
classDef bad fill:#eeeeee,stroke:#9e9e9e,color:#757575
class H,I,K ok
class J,L,M,N bad
另外还有剪枝 a.size() + (n - x + 1) < m:剩下的数字全选也凑不满时,整棵子树直接砍掉,所以上面这棵树实际只会展开一小部分。
三个绿色叶子就是答案,并且是按字典序出现的:
| 选择路径 | 结果 |
|---|---|
| 选1,选2(已满 m 个,直接输出) | {1,2} |
| 选1,不选2,选3 | {1,3} |
| 不选1,选2,选3 | {2,3} |
2.3 特点
- 树高固定为 $n$,一个数字一层
- 每个节点永远是 2 个分支,是一棵二叉树
- 剪枝很好写:
剩余可选数量 + 已选数量 < m直接砍掉 - 适合「每个元素独立做决定」的子集类问题(01 背包的暴力、选不选某点等)
三、写法 B:for 多叉版(按位置分层)
3.1 核心思想
每一层对应「答案里的第几个位置」,节点决定这一位放谁。
1 | int n, m; |
调用入口:calc(0, 1)。
start 参数是去重的关键:它保证下一个位置只在当前数之后取,生成的组合天然有序、不重复。
3.2 搜索树长什么样
$n=3,\ m=2$,树高 = 2,每一层是答案里的一个位置:
flowchart TD
A["pos=0, start=1<br/>a = {}"]
A -->|i = 1| B["pos=1, start=2<br/>a = {1}"]
A -->|i = 2| C["pos=1, start=3<br/>a = {2}"]
A -->|i = 3| D["pos=1, start=4<br/>a = {3}"]
B -->|i = 2| E["pos=2<br/>{1,2} ✓"]
B -->|i = 3| F["pos=2<br/>{1,3} ✓"]
C -->|i = 3| G["pos=2<br/>{2,3} ✓"]
D -->|start 超过 n,循环不执行| H["死路"]
classDef ok fill:#c8f7c5,stroke:#2e7d32,color:#1b5e20
classDef bad fill:#eeeeee,stroke:#9e9e9e,color:#757575
class E,F,G ok
class H bad
pos == m 时直接输出,不再往下展开,所以叶子节点就是答案。
输出顺序就是 {1,2}、{1,3}、{2,3},天然字典序,不用额外排序。
3.3 特点
- 树高固定为 $m$,一个位置一层
- 每个节点的子节点数量不固定,是一棵多叉树
- 逻辑直接对应「凑够 $m$ 个就输出」
- 适合「定长组合」类问题;排列的话把
start = i + 1换成start = 1再加一个used[]即可
四、两者的共同点
- 都是 DFS:一路往深处走到底,再退回上一层换分支
- 都是前序遍历:先处理当前节点(把数字塞进数组),再递归往下;递归全返回后才撤销(回溯)
- 都靠回溯复原现场:
push_back和pop_back必须成对出现 - 输出顺序都由「枚举顺序」决定:for 版
i从start递增,天然字典序;二叉版把「选」分支写在前面,也是字典序
前序这一点可以这样理解:
1 | a.push_back(i); // 处理当前节点:记录「我做了这个选择」 |
五、关键区别
| 二叉分支版 | for 多叉版 | |
|---|---|---|
| 每层含义 | 第几个数字 | 第几个位置 |
| 树高 | $n$ | $m$ |
| 分支数 | 固定 2(选 / 不选) | 不固定(多叉) |
是否要 start |
不需要 | 需要,用来去重 |
| 输出顺序 | 取决于先写「选」还是先写「不选」 | 天然字典序 |
| 剪枝 | 容易写(剩余数量不足直接砍) | 只能靠 start > n 自然结束 |
| 时间复杂度 | $O(2^n)$ | $O(C_n^m)$ |
| 典型用途 | 子集类、每个元素独立决策 | 定长组合、按位置枚举 |
怎么选?
- 想「每个数单独决定拿不拿」→ 二叉版
- 想「答案由若干个位置拼成」→ for 多叉版
六、什么时候不是前序
偶尔会看到这种「先递归、再记录」的写法:
1 | void calc(int pos) { |
这是后序风格:先钻到最深处,回来路上才把自己的选择记进数组。结果数组里是倒序的、而且无法在递归前用当前选择做剪枝,组合枚举里基本没人这么写——它违背了「进入节点时状态已经完整」这个直觉。
七、小结
二叉树递归:按数字分层,选 / 不选两个分支,树高 $n$,剪枝方便。
for 多叉版:按位置分层,start控制起点,树高 $m$,天然去重。
两者都是前序 DFS + 回溯,只是搜索树不同。