阶段 0 · 递归思维 · 第 3 章普及组 J

递归 = 决策树:子集、组合、全排列

循环的层数必须在写代码时定死,可题目偏偏要到运行时才告诉你套几重。

需要先学:第 1 章 递归入门:函数怎么调用自己例题:子集和计数 · 全排列建议用时:120 分钟

第 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++ 速查的第一组有一份点一下就能跑的最小例子。

bitmask.cpp二进制枚举
// 子集和计数 —— 二进制枚举写法
//
// 输入:第一行 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

这个写法很聪明,也很短。但它有个天花板,第 5 步就会撞上。

4先确认它是对的

跑一下上面的样例,应该输出 2。再试试这两组边界:

输入

1 0
5

输出

1

m = 0:一个都不选,和就是 0 —— 这也算一种选法,所以答案是 1,不是 0。 这个边界最容易漏。

输入

4 10
1 2 3 4

输出

1

m 正好等于全部之和:只有「全选」这一种,答案 1。

确认无误。它将成为我们的标准答案。

5二进制枚举的天花板在哪

它把「一连串决策」压扁成了一个整数。压扁之后,中间过程就消失了。

具体来说,下面这三件事它都做不了:

  1. 剪枝。如果选到第 3 个数时和已经超过 m 了,后面的数怎么选都没用, 本该立刻掐掉这一整片分支。但 mask 是一次性生成的,你没有「选到一半」这个时刻。
  2. 排列。如果「先选 3 再选 5」和「先选 5 再选 3」算两种不同方案, 一个 0/1 掩码根本表达不了顺序。
  3. 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递归写法

rec.cpp递归版
// 子集和计数 —— 递归(决策树)写法
//
// 和 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

8把整棵决策树打印出来

trace.cpp过程演示
用小数据跑(n = 3 或 4)。跑完请对着输出,在纸上把这棵树画一遍 —— 画完这一次,这一章就真的懂了。
// 子集和 —— 把决策树打印出来
//
// 建议用很小的数据跑(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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

注意 trace.cpp 里这一对操作:

chosen.push_back(a[i]);        // 进入「选」这个分支前:记下选择
dfs(i + 1, sum + a[i], "选");
chosen.pop_back();             // 从分支回来后:撤销选择

这就是回溯。现在它只是为了打印好看,但到了全排列、N 皇后,它是算法本身不可缺的一步。 记住这个「进入 → 递归 → 撤销」的三段式,第 4 章会把它变成肌肉记忆。

9单步看决策树长出来

子集和的决策树
第 1 / 16 步
不选不选不选选选不选选选不选不选选选不选选0000255733358810i=0i=1i=2i=3
从根节点出发:dfs(i=0, sum=0)。还没做任何决策。 已找到 0 种

播放一遍,盯住这几件事:

  • 它总是先一头扎到最左边的叶子,才回头处理第二个叶子 —— 这就是「深度优先」。
  • 蓝色高亮的那条路径 = 此刻的递归栈,和第 1 章调用栈动画里的那根柱子是同一个东西。
  • 走到叶子时才判断 sum == m,命中的叶子变绿打星。
  • 试着把「数字」改成 4 个(比如 3 5 2 4),看叶子数从 8 变成 16 —— 每加一个数就翻倍。

10★ 对拍验证

★ 正确的用法

把「递归版」那一栏的代码整个换成你自己默写的,再点开始对拍。

对拍器
两种写法在数学上等价,所以答案必须完全一致。生成器会造 n ≤ 12 的随机数据 —— 注意 n 不能太大,因为两边都是 2ⁿ 的。
// 子集和计数 —— 递归(决策树)写法
//
// 和 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 时用三重循环还写得出来:

perm3.cpp三重循环(只能 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

把输入改成 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] 填进去的数字不同
分支跟走过的路有关吗 无关,每层都是同一个二选一 有关,祖先用掉的数不能再用
★ 最后那一行,就是 used[] 的全部来历

子集和里,「走过的路」被压成了 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 是几都行

perm.cpp递归版(任意 n)
把输入从 4 改成 5、6 试试。代码一个字都不用动。
// 全排列 —— 递归写法(任意 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

那对 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自测

自测清单0 / 8
配套练习
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
下一章预告

第 4 章 N 皇后会把「进入 → 递归 → 撤销」这个三段式练到形成肌肉记忆, 并且第一次引入剪枝 —— 也就是本章第 5 步说的、二进制枚举做不到的那件事。