第 1 章讲的是「怎么相信一个还没写完的函数」。这一章讲的是递归真正的主场: 当你需要把所有可能性一个不漏地枚举出来的时候。
这是信息学竞赛里出现频率最高的场景之一。搞定它,DFS、回溯、剪枝、状压全都有了地基。
1一句话问题
给 n 个正整数,从中选出若干个(可以一个都不选),问有多少种选法使它们的和恰好等于 m。
输入
3 5 3 5 2
输出
2
第一行是 n = 3 个数、目标和 m = 5,第二行是那 n 个数。
答案是 2,因为 {5} 和 {3, 2} 两种选法的和都正好是 5。
2先用纸笔手算一遍
n = 3 只有 8 种选法,全列出来(√ 表示选):
| a[0]=3 | a[1]=5 | a[2]=2 | 和 | 命中 m=5? |
|---|---|---|---|---|
| 0 | ||||
| √ | 2 | |||
| √ | 5 | ★ | ||
| √ | √ | 7 | ||
| √ | 3 | |||
| √ | √ | 5 | ★ | |
| √ | √ | 8 | ||
| √ | √ | √ | 10 |
答案是 2。请注意这张表的行数:8 = 2³。每个数都有「选」和「不选」两种可能,
n 个数就是 2ⁿ 种组合。这个 2ⁿ 后面会反复出现。
3第一种写法:二进制枚举
看着上面那张表,很容易发现每一行就是三个 0/1 —— 正好是一个三位二进制数。
于是枚举 0 到 2³-1,用位运算取出每一位:
这一章不讲位运算(讲它的是第 46 章),但下面这份代码要用到两个:
1 << n就是 2ⁿ。1 << 3= 8,正好是那张表的行数。mask >> i & 1是「取出mask的第i位」,结果只会是 0 或 1 —— 在这儿它的意思就是「第i个数选了没有」。
先按这两句话读下去就行。想立刻看见它们在干什么, C++ 速查的第一组有一份点一下就能跑的最小例子。
// 子集和计数 —— 二进制枚举写法//// 输入:第一行 n m,第二行 n 个正整数// 输出:从这 n 个数里选出若干个(可以一个都不选),和恰好等于 m 的方案数//// 思路:每个数只有「选」和「不选」两种状态,n 个数就是 n 个 0/1,// 正好对应一个 n 位二进制数。于是枚举 0 ~ 2^n-1 的每个 mask,// mask 的第 i 位是 1 就表示选第 i 个数。//// 这个写法很聪明,但它有个致命局限:它把「决策过程」压扁成了一个整数。// 一旦题目要求「选到一半发现超了就别往下选了」(剪枝),// 或者「选的顺序也算不同方案」(排列),二进制枚举就没办法表达了。//// 复杂度:O(2^n * n)
#include <bits/stdc++.h>using namespace std;
int main() { int n; long long m; cin >> n >> m; vector<long long> a(n); for (int i = 0; i < n; i++) cin >> a[i];
long long count = 0; for (int mask = 0; mask < (1 << n); mask++) { long long sum = 0; for (int i = 0; i < n; i++) { if (mask >> i & 1) sum += a[i]; // 第 i 位是 1 -> 选了 a[i] } if (sum == m) count++; }
cout << count << "\n"; return 0;}点「运行 ▶」看结果
这个写法很聪明,也很短。但它有个天花板,第 5 步就会撞上。
4先确认它是对的
跑一下上面的样例,应该输出 2。再试试这两组边界:
输入
1 0 5
输出
1
m = 0:一个都不选,和就是 0 —— 这也算一种选法,所以答案是 1,不是 0。
这个边界最容易漏。
输入
4 10 1 2 3 4
输出
1
m 正好等于全部之和:只有「全选」这一种,答案 1。
确认无误。它将成为我们的标准答案。
5二进制枚举的天花板在哪
它把「一连串决策」压扁成了一个整数。压扁之后,中间过程就消失了。
具体来说,下面这三件事它都做不了:
- 剪枝。如果选到第 3 个数时和已经超过
m了,后面的数怎么选都没用, 本该立刻掐掉这一整片分支。但mask是一次性生成的,你没有「选到一半」这个时刻。 - 排列。如果「先选 3 再选 5」和「先选 5 再选 3」算两种不同方案, 一个 0/1 掩码根本表达不了顺序。
n > 63。1 << n会溢出,写不了。
第 1 条尤其致命。搜索题的分数几乎全靠剪枝,而剪枝的前提是你必须能站在决策过程的中间。
6★ 关键的一步
把「做选择」画成一棵树。
整张图的钥匙,是 dfs(i, sum) 里那两个数字 —— 先把它们认清楚:
dfs( i , sum )
| +--- 已经决定「要选」的那些数,加起来是多少
+------- 前 i 个数(a[0] .. a[i-1])已经决定完了,接下来轮到 a[i]⚠ i 是已经决定完的个数,不是「正在看第几个」。所以 dfs(0, 0) 的意思是
「一个数都还没决定,和是 0」—— 那就是根,你还没动手的那一刻;
而 dfs(2, 5) 读作「前 2 个数决定完了,选中的加起来是 5,接下来该决定 a[2]」。
树上每一层对应一个数字的决策,每个节点分出两个叉:不选 走左边,选 走右边。 从根走到叶子的一条路径,就是一种选法。
dfs(i=0, sum=0) <- 什么都还没决定
+------------+------------+ 决定 a[0]=3:左=不选,右=选
dfs(1, 0) dfs(1, 3)
+-----+-----+ +-----+-----+ 决定 a[1]=5:左=不选,右=选
dfs(2, 0) dfs(2, 5) dfs(2, 3) dfs(2, 8)
+--+--+ +--+--+ +--+--+ +--+--+ 决定 a[2]=2:左=不选,右=选
0 2 5 7 3 5 8 10 <- 叶子 = 最终的 sum
^ ^ <- 和正好 = m = 5,就这两片怎么读这张图(数据还是 a[0]=3、a[1]=5、a[2]=2,m = 5):
- 每一层只决定一个数,图里右边写着是哪个。左右的含义每一层都一样:左边不选,右边选。
- 往左走
sum不变,往右走sum加上这一层的那个数 —— 两个叉之间的区别只有这一条。 而i两边都要加 1:不管选不选,这个数总归是决定过了。 - 最后一行为什么只剩光秃秃的数字 —— 那是最终的 sum。走到
i = 3 = n, 三个数全决定完了,dfs(3, sum)一进门就撞上出口、不再分叉,所以图上把dfs(3, …)省成了 sum 本身。比如dfs(2, 5)的两个孩子,写全了是dfs(3, 5)和dfs(3, 7)。 - 底下那两个
^指的是「和正好等于m = 5」的叶子 —— 也就是第 2 步那张表里 打 ★ 的两行。数一数有 2 个,答案就是 2。
八片叶子从左往右,正好就是第 2 步那张手算表格从上往下的八行:
| 叶子 | 从根到它的路径 | 实际选中的 | 命中 m=5? |
|---|---|---|---|
0 |
左 左 左 | 什么都不选 | |
2 |
左 左 右 | a[2]=2 |
|
5 |
左 右 左 | a[1]=5 |
★ |
7 |
左 右 右 | a[1]=5、a[2]=2 |
|
3 |
右 左 左 | a[0]=3 |
|
5 |
右 左 右 | a[0]=3、a[2]=2 |
★ |
8 |
右 右 左 | a[0]=3、a[1]=5 |
|
10 |
右 右 右 | 全选 |
一共 8 个叶子 —— 和二进制枚举的 8 个 mask 一一对应:把路径上的「不选 = 0、选 = 1」
按 a[0]、a[1]、a[2] 的顺序写下来,就是 mask 的第 0、1、2 位。
两种写法在数学上完全等价。
但递归写法多了一样东西:它是一层一层往下做决策的。 于是在任何一层,你都可以中途叫停(剪枝)、可以记录当前选了什么(回溯)、 可以让下一层的选择依赖上一层(排列)。
递归不是「函数调用自己」这个语法现象,它是「在一棵决策树上做深度优先遍历」。 这句话是这一章唯一需要记住的东西。
翻译成代码,只有两行:
void dfs(int i, long long sum) {
if (i == n) { if (sum == m) count_++; return; } // 到叶子了
dfs(i + 1, sum); // 左边这一叉:不选 a[i]
dfs(i + 1, sum + a[i]); // 右边这一叉:选 a[i]
}
dfs(i, sum) 的职责(还记得第 1 章的三要素吗):
前 i 个数已经决策完了,当前和是 sum,负责数清剩下的 a[i..n-1] 有多少种选法能凑到 m。
7递归写法
// 子集和计数 —— 递归(决策树)写法//// 和 bitmask.cpp 解同一道题,答案必须一模一样。//// 核心视角:把「做选择」画成一棵二叉树。//// dfs(0, 0)// 不选 ┌──────┴──────┐ 选 a[0]// dfs(1, 0) dfs(1, a[0])// ┌──┴──┐ ┌──┴──┐// dfs(2,·) dfs(2,·) dfs(2,·) dfs(2,·)// ... 一直分叉到第 n 层 ...//// 树上每一条从根到叶子的路径 = 一种选法。// 一共 2^n 个叶子,正好对应 bitmask 里的 2^n 个 mask —— 两种写法在数学上是一回事。//// 但递归写法多了一样东西:它「一层一层地做决策」,所以在任何一层都可以中途叫停。// 这就是剪枝的入口,也是后面 DFS、回溯、搜索题全部的基础。//// dfs(i, sum) 的含义:前 i 个数已经决策完了,当前选中的和是 sum,// 问「剩下的 a[i..n-1] 还有多少种选法能把总和凑到 m」。
#include <bits/stdc++.h>using namespace std;
int n;long long m;vector<long long> a;long long count_ = 0;
void dfs(int i, long long sum) { if (i == n) { // 出口:n 个数全部决策完了 if (sum == m) count_++; // 这条路径凑出了 m,记一笔 return; } dfs(i + 1, sum); // 分支一:不选 a[i] dfs(i + 1, sum + a[i]); // 分支二:选 a[i]}
int main() { cin >> n >> m; a.resize(n); for (int i = 0; i < n; i++) cin >> a[i];
dfs(0, 0);
cout << count_ << "\n"; return 0;}点「运行 ▶」看结果
8把整棵决策树打印出来
// 子集和 —— 把决策树打印出来//// 建议用很小的数据跑(n = 3 或 4),然后对着输出,// 在纸上把这棵树画一遍。画完这一次,「递归 = 决策树」就真的懂了。//// 输出里的缩进 = 树的层数 = 当前决策到第几个数。
#include <bits/stdc++.h>using namespace std;
int n;long long m;vector<long long> a;long long count_ = 0;vector<int> chosen; // 记录当前这条路径上都选了哪些数
void indent(int d) { for (int i = 0; i < d; i++) cout << "| ";}
void dfs(int i, long long sum, const string& how) { indent(i); cout << how << " -> dfs(i=" << i << ", sum=" << sum << ")";
if (i == n) { cout << " [叶子] 选了 {"; for (size_t k = 0; k < chosen.size(); k++) { cout << (k ? "," : "") << chosen[k]; } cout << "} 和=" << sum; if (sum == m) { count_++; cout << " ★ 命中 m=" << m; } cout << "\n"; return; } cout << " 还要决策 a[" << i << "]=" << a[i] << "\n";
dfs(i + 1, sum, "不选");
chosen.push_back((int)a[i]); // 进入分支前:记下这个选择 dfs(i + 1, sum + a[i], "选 "); chosen.pop_back(); // 从分支回来后:撤销这个选择 // ↑ 这一对 push/pop 就是「回溯」。现在只是为了打印, // 到了 N 皇后、全排列那些题,它是算法本身不可缺的一步。}
int main() { cin >> n >> m; a.resize(n); for (int i = 0; i < n; i++) cin >> a[i];
cout << "目标和 m = " << m << ",数字为:"; for (int i = 0; i < n; i++) cout << " " << a[i]; cout << "\n\n";
dfs(0, 0, "根 ");
cout << "\n方案数 = " << count_ << "\n"; cout << "叶子总数 = 2^" << n << " = " << (1 << n) << "\n"; return 0;}点「运行 ▶」看结果
注意 trace.cpp 里这一对操作:
chosen.push_back(a[i]); // 进入「选」这个分支前:记下选择
dfs(i + 1, sum + a[i], "选");
chosen.pop_back(); // 从分支回来后:撤销选择
这就是回溯。现在它只是为了打印好看,但到了全排列、N 皇后,它是算法本身不可缺的一步。 记住这个「进入 → 递归 → 撤销」的三段式,第 4 章会把它变成肌肉记忆。
9单步看决策树长出来
播放一遍,盯住这几件事:
- 它总是先一头扎到最左边的叶子,才回头处理第二个叶子 —— 这就是「深度优先」。
- 蓝色高亮的那条路径 = 此刻的递归栈,和第 1 章调用栈动画里的那根柱子是同一个东西。
- 走到叶子时才判断
sum == m,命中的叶子变绿打星。 - 试着把「数字」改成 4 个(比如
3 5 2 4),看叶子数从 8 变成 16 —— 每加一个数就翻倍。
10★ 对拍验证
把「递归版」那一栏的代码整个换成你自己默写的,再点开始对拍。
// 子集和计数 —— 递归(决策树)写法//// 和 bitmask.cpp 解同一道题,答案必须一模一样。//// 核心视角:把「做选择」画成一棵二叉树。//// dfs(0, 0)// 不选 ┌──────┴──────┐ 选 a[0]// dfs(1, 0) dfs(1, a[0])// ┌──┴──┐ ┌──┴──┐// dfs(2,·) dfs(2,·) dfs(2,·) dfs(2,·)// ... 一直分叉到第 n 层 ...//// 树上每一条从根到叶子的路径 = 一种选法。// 一共 2^n 个叶子,正好对应 bitmask 里的 2^n 个 mask —— 两种写法在数学上是一回事。//// 但递归写法多了一样东西:它「一层一层地做决策」,所以在任何一层都可以中途叫停。// 这就是剪枝的入口,也是后面 DFS、回溯、搜索题全部的基础。//// dfs(i, sum) 的含义:前 i 个数已经决策完了,当前选中的和是 sum,// 问「剩下的 a[i..n-1] 还有多少种选法能把总和凑到 m」。
#include <bits/stdc++.h>using namespace std;
int n;long long m;vector<long long> a;long long count_ = 0;
void dfs(int i, long long sum) { if (i == n) { // 出口:n 个数全部决策完了 if (sum == m) count_++; // 这条路径凑出了 m,记一笔 return; } dfs(i + 1, sum); // 分支一:不选 a[i] dfs(i + 1, sum + a[i]); // 分支二:选 a[i]}
int main() { cin >> n >> m; a.resize(n); for (int i = 0; i < n; i++) cin >> a[i];
dfs(0, 0);
cout << count_ << "\n"; return 0;}常见错误,故意写一个试试:
dfs(i + 1, sum + a[i])写成dfs(i, sum + a[i])(i忘了 +1)→ 死循环- 出口写成
if (i == n - 1)→ 最后一个数没被决策到 - 两个分支写成
dfs(i+1, sum); dfs(i+1, sum);→ 永远算不到「选」的情况
11第二个例子:全排列 —— 循环真正的天花板
上面的子集和,二进制枚举勉强还能顶。但全排列不行,这里能看到循环彻底失效的地方。
输出 1..n 的所有排列。n = 3 时用三重循环还写得出来:
// 全排列 —— 循环写法(只能做 n = 3)//// 输入:n(这份代码只支持 n = 3)// 输出:1..n 的所有排列,每行一个//// 这就是不会递归时唯一能想到的办法:有几个位置,就套几重循环。// n = 3 套三重,n = 4 就得回来改成四重,n = 8 要套八重。//// 关键问题不是「累」,是「n 是输入进来的,你根本没法在写代码的时候就决定套几重循环」。// 循环的层数必须在编译期写死,而这道题要求层数在运行期才知道。// —— 这是循环真正的天花板,也是必须用递归的理由。
#include <bits/stdc++.h>using namespace std;
int main() { int n; cin >> n;
if (n != 3) { cout << "这份代码只会做 n = 3。想做 n = " << n << ",得回去把循环改成 " << n << " 重 —— 这正是问题所在。\n"; return 0; }
for (int i = 1; i <= 3; i++) { for (int j = 1; j <= 3; j++) { if (j == i) continue; // 不能和前面重复 for (int k = 1; k <= 3; k++) { if (k == i || k == j) continue; cout << i << " " << j << " " << k << "\n"; } } } return 0;}点「运行 ▶」看结果
把输入改成 4 再跑一次 —— 它做不了。
不是「循环写起来累」,而是 循环的层数必须在编译期写死,可 n 是运行时才知道的。
n = 3 套三重,n = 8 套八重 —— 但你写代码的时候根本不知道 n 是几,没法决定套几重。
这是循环在语法层面的硬限制,再怎么努力也绕不过去。
递归把「套几重」交给了递归深度,而深度由 n 决定 —— 运行时才定下来。
一个 dfs(pos) 函数里的那一重 for,就等于三重循环里的某一重;
至于要展开成几重,跑起来才知道。
(dfs(pos) 到底在干什么、那一重 for 又是从哪来的,下一步把树画出来就清楚了。)
12★ 换一棵树:这一次每层决定的是一个位置
代码等一下再看。还是老规矩,先把要枚举的东西摆出来 —— n = 3 的排列一共 6 条,
纸笔就能列全(也就是上面那份三重循环跑出来的那 6 行):
输入
3
输出
1 2 3 1 3 2 2 1 3 2 3 1 3 1 2 3 2 1
输入只有一个 n。输出 1..3 的全部排列,每行一条,按字典序。
这 6 行同样是一棵决策树的叶子。但树的形状和第 6 步那棵完全不同,这才是这一节唯一的难点。
第 6 步那棵树,每一层问的是「a[i] 选还是不选」—— 层对应一个数字。
全排列这棵树,每一层问的是「perm[pos] 这个位置填哪个数」—— 层对应一个位置。
主语从「数字」换成了「位置」:
_ _ _ <- dfs(0):三个位置都还空着
+-------------------+-------------------+ 填 perm[0]:1 / 2 / 3 三支
1 _ _ 2 _ _ 3 _ _ <- dfs(1):第 0 个位置已经定了
+---+---+ +---+---+ +---+---+ 填 perm[1]:剩下的两个数各一支
1 2 _ 1 3 _ 2 1 _ 2 3 _ 3 1 _ 3 2 _ <- dfs(2):前两个位置定了
| | | | | | 填 perm[2]:只剩一个数,没得选
1 2 3 1 3 2 2 1 3 2 3 1 3 1 2 3 2 1 <- dfs(3):填满了,打印这一行
怎么读这张图(_ 表示这个位置还空着):
- 每一层填一个位置,从左往右填。填满
n个位置走到底,就得到一条排列 —— 最底下那六行,正好是上面输出框里的六行,顺序都一样。 - 岔口不再永远是两条。根有 3 支(三个数都能填),第二层每个节点只剩 2 支
(有一个已经被上面用掉了),第三层只剩 1 支。所以叶子数是
3 × 2 × 1 = 3! = 6, 不是 2³ = 8。 - 一个岔口有几支、能填哪几个数,取决于它上面已经填了什么 —— 这是和第 6 步那棵树
最要命的区别。子集和的树,左右两支的含义每一层、每个节点都一样(左不选、右选),
和你是怎么走到这儿的毫无关系;这棵树不行:
1 _ _底下能填的是 2 和 3,2 _ _底下能填的是 1 和 3。
两棵树摆在一起看:
子集和 dfs(i, sum) |
全排列 dfs(pos) |
|
|---|---|---|
| 每一层决定什么 | 第 i 个数字选不选 |
第 pos 个位置填哪个数 |
| 每个岔口分几支 | 恒为 2 | 还没用掉的数各一支:n、n-1、… 、1 |
| 叶子有多少片 | 2ⁿ | n! |
| 两支之间差在哪 | sum 加不加 a[i] |
填进去的数字不同 |
| 分支跟走过的路有关吗 | 无关,每层都是同一个二选一 | 有关,祖先用掉的数不能再用 |
子集和里,「走过的路」被压成了 sum 一个数顺着参数传下去 —— 走过就可以忘。
全排列不行:站在 1 2 _ 这个节点上,你必须知道 1 和 2 已经被祖先用掉了,
才知道这个岔口只剩一支。可这件事光看 pos = 2 是看不出来的。
于是要有个地方记着「路上用过哪些数」,那就是 used[]:
used[v] = true 走进「填 v」这一支之前:把 v 标成用掉了
used[v] = false 从这一支回来之后:还回去,好让兄弟支能用这正是第 5 步说「一个 0/1 掩码表达不了顺序」的另一面 —— 顺序这件事,是靠路径记住的。
dfs 的参数还是老规矩,先把它认清楚(对照第 6 步那个 dfs(i, sum)):
dfs( pos )
+--- 前 pos 个位置(perm[0] .. perm[pos-1])已经填好了,接下来轮到 perm[pos]
和当时的 i 一样,pos 是已经决定完的个数:dfs(0) 是根(一个位置都没填),
dfs(3) 就是图上最底下那一行(填满了,该打印了)。两个数组的含义也对着图看:
perm[pos]:第pos个位置最终填的数 —— 就是图上1 2 _里那些写出来的数字。used[v]:数字v是不是已经被路径上的某个祖先占走了。
翻译成代码,就是对着这棵树照抄一遍:
void dfs(int pos) {
if (pos == n) { /* 打印 perm[0..n-1] */ return; } // 到叶子了:n 个位置全填满
for (int v = 1; v <= n; v++) { // 这个岔口有哪几支?把候选数字都过一遍
if (used[v]) continue; // 祖先用掉了,这一支根本不存在
used[v] = true; // 走进这一支:占用 v
perm[pos] = v;
dfs(pos + 1); // 去填下一个位置
used[v] = false; // 从这一支回来:还回去
}
}
for 加上那句 continue,合起来就是图上「这个岔口分几支」。
子集和那棵树岔口恒为 2,所以两行 dfs 写死就够了;这里岔数是变的,只能用循环把它们列出来。
v 从小到大枚举,所以输出天然是字典序。
13递归版:n 是几都行
// 全排列 —— 递归写法(任意 n)//// 输入:n// 输出:1..n 的所有排列,每行一个(字典序)//// 和 perm3.cpp 比,代码更短,而且 n 是几都行。//// 这里的决策树和「子集和」那棵不一样,读代码前先把形状认清:// · 每一层决定的是「perm[pos] 这个位置填哪个数」(不是「第 i 个数选不选」);// · 所以岔口不是固定的两支,而是「所有还没被用掉的数」各一支 ——// 根有 n 支,下一层 n-1 支,…… 叶子一共 n! 片。// · 一个岔口分几支,取决于路上的祖先已经填了什么。// 于是必须有个地方记住「路上用过哪些数」,那就是 used[]。//// 递归在这里干的事,就是「把循环的层数交给运行期决定」:// dfs(pos) 内部的那一重 for 循环 = perm3 里的某一重循环,// 而「套几重」由递归深度决定,深度由 n 决定 —— 运行时才定下来。//// 那对 used[v] = true / false 就是回溯(和 trace.cpp 里的 push_back / pop_back 同一件事):// 进入分支前占用这个数字,从分支回来后释放它,// 好让兄弟分支能重新使用。忘了释放,是初学回溯最常见的错误。
#include <bits/stdc++.h>using namespace std;
int n;vector<int> perm; // perm[pos] = 第 pos 个位置填的数vector<bool> used; // used[v] = 数字 v 是否已经被用掉
void dfs(int pos) { if (pos == n) { // 出口:n 个位置全填满了 for (int i = 0; i < n; i++) cout << perm[i] << (i == n - 1 ? '\n' : ' '); return; } for (int v = 1; v <= n; v++) { // 这个岔口有哪几支(这一重 for = perm3 里的某一重循环) if (used[v]) continue; // 祖先用掉了,这一支不存在
used[v] = true; // 占用 perm[pos] = v; dfs(pos + 1); // 去填下一个位置 used[v] = false; // 释放(回溯!) }}
int main() { cin >> n; perm.assign(n, 0); used.assign(n + 1, false); dfs(0); return 0;}点「运行 ▶」看结果
那对 used[v] 的赋值,和第 8 步 trace.cpp 里的 chosen.push_back / chosen.pop_back
是同一件事:进入分支前记下选择,从分支回来后撤销。
used[v] = true; // 进入:占用数字 v
perm[pos] = v;
dfs(pos + 1); // 递归
used[v] = false; // 撤销:释放(必须!)
区别只在于:在 trace.cpp 里它只是为了打印好看,删掉也照样算对答案;
到了这里,它是算法本身的一部分 —— 少了它,used[] 里全是走过就没还回去的幻影,
兄弟分支一个数都填不了,程序只能输出一条排列就没了。
used[v] = false 忘了写是初学回溯的头号错误。去掉那一行跑一次,
亲眼看看会发生什么,比记住这句话有用。
14自测
- 洛谷 P1706 全排列问题解析 → —— 模板题,必须一次写对
- 洛谷 P1157 组合的输出解析 → —— 从排列改成组合,只需改一个地方 —— 想清楚是哪个
- 洛谷 P1036 选数解析 → —— NOIP2002。子集和的变形,多了个质数判断
- 洛谷 P2036 PERKET解析 → —— 标准的「每个都选或不选」,和本章例题几乎同构
第 4 章 N 皇后会把「进入 → 递归 → 撤销」这个三段式练到形成肌肉记忆, 并且第一次引入剪枝 —— 也就是本章第 5 步说的、二进制枚举做不到的那件事。