第 14 节用 for 和二进制数过搜索空间,第 21 节把递归拆成“出口 + 递推”。DFS 枚举 = 递归 + 枚举:把“从一堆东西里挑若干个”摊成 n 层决策,每层选一个,选完往下走,走完退回来换下一个。这个“退回来换下一个”的动作叫回溯,代码里只有一行。
先把骨架拆成四块。任何一道 DFS 枚举题,都能填进这张表里,填不进去就说明还没想清状态。
| 骨架部件 | 它回答的问题 | 全排列(n! 种) | 组合(C(n,k) 种) |
|---|---|---|---|
| 层(step) | 现在在做第几层决策 | 正在填第 step 个位置 | 已经选了 step 个数 |
| 候选集 | 这一层允许选哪些 | 1..n 里所有没被用过的数 | start..n |
| 出口 | 什么状态算一个完整方案 | step > n | step > k |
| 还原 | 离开这一层要撤销什么 | vis[i] = 0 | 不用撤销:start 天生递增 |
本节没有新关键字,只有一种必须背下来的固定顺序。把它当一个不可拆的整体记住,比理解之后再写要快。
| 组成 | 含义 / 格式要求 |
|---|---|
① vis[i] = 1 | 标记:把 i 从候选集里划掉。下一层看到的 vis 就是“占位之后”的现场。 |
② dfs(step + 1) | 递进:带着新状态进入下一层。这句话返回时,它内部的所有标记都已经撤干净了。 |
③ vis[i] = 0 | 还原:撤销 ①。不写这一行,i 会一直被认为占着,后面所有分支都选不到它。 |
为什么必须还原 | 所有层共用同一份全局数组。dfs 只是“借用”它,离开时必须物归原主,否则兄弟分支看到的是上一条路径留下的脏状态。 |
顺序不能换 | 先标再递进、先递进再还原。把还原写在递归前面,等于下一层拿到的是“没占位”的状态,同一个数会被重复填进不同位置。 |
位置在循环体内 | 三行必须一起写在 for 的循环体里。写到循环外面就只还原了最后一次的 i,这是最典型的手误:代码看起来完全正常,答案少一半。 |
成对检查法 | 调不出来时先数一遍:每个 = 1 是否都配了一个 = 0,两者是否在同一个循环体、同一个下标变量上。 |
#include <bits/stdc++.h>
using namespace std;
int n = 3, a[10];
bool vis[10];
void dfs(int step) { // step:正在填第几个位置
if (step > n) { // 出口:n 个位置都填满了
for (int i = 1; i <= n; ++i) cout << a[i] << ' ';
cout << '\n';
return;
}
for (int i = 1; i <= n; ++i) { // 这一层的候选集:1..n
if (vis[i]) continue; // 已被前面某层占用的数不能再用
a[step] = i;
vis[i] = 1; // ① 占位
dfs(step + 1); // ② 递进
vis[i] = 0; // ③ 还原
}
}
int main() { dfs(1); } // 输出 6 行,正好是 3! 个排列注意 a[step] = i 不需要还原:同一层的下一次循环会直接覆盖它。要还原的是占用关系(vis),不是答案数组。
忘记还原的症状很隐蔽:不 RE、不崩,只是方案数远少于预期(常常只剩一条);如果只在某个分支上漏写,就变成“答案小一点”,最难查。
还原写在 dfs 之前,症状相反:同一个数被反复填,输出大量非法排列,n 稍大直接 TLE。
| 组成 | 含义 / 格式要求 |
|---|---|
step | 已经选了几个数,等于递归层数。step == k 时到达出口。 |
start | 这一层允许使用的最小下标。它把“下标严格递增”变成一条硬约束,于是每个集合只有一种生成顺序。 |
为什么就够去重 | 从 {1,2,3} 里选 2 个,排列会给出 (1,2) 和 (2,1) 两份;先选小的、后选大的,就只留 (1,2)。 |
传 i + 1 还是 i | 当前数用过就不能再用 → 传 i + 1;如果允许重复使用同一个数(如整数拆分)→ 传 i。 |
和 vis 的分工 | vis 管“哪些用过了”,start 管“只能往更大的方向选”。组合用 start 就够,不需要 vis。 |
出口写法 | step > k 与 step == k 都能用,但必须和传参起点对齐。两种风格混着改,是 off-by-one 的主要来源。 |
#include <bits/stdc++.h>
using namespace std;
int n = 5, k = 3, a[25];
// 从 1..n 中选 k 个,按字典序输出每一种组合
void dfs(int step, int start) { // step:已选个数;start:本层最小可选值
if (step > k) { // 选够了,输出一种组合
for (int i = 1; i <= k; ++i) printf("%3d", a[i]);
printf("\n");
return;
}
for (int i = start; i <= n; ++i) { // 只从 start 往后选
a[step] = i;
dfs(step + 1, i + 1); // ★ 下一层起点是 i+1,不是 start+1
}
}
int main() { dfs(1, 1); } // C(5,3) = 10 行写成 dfs(step + 1, start + 1) 是经典错误:那样只会生成 {(1,2,3),(2,3,4),…} 这类连续区间,方案数少一大截,而且小数据很难发现。
把 i + 1 改成 i,同一个数可以被重复选,输出就变成“可重复组合”,第 32 节完全背包的枚举就长这样。
| 组成 | 含义 / 格式要求 |
|---|---|
%5d | 十进制整数,右对齐,至少占 5 个字符位,不够的在左边补空格。 |
%-5d | 负号 = 左对齐,空格补在右边。 |
宽度小于实际位数 | 有宽度的格式符不截断。x = 123456 时 %5d 照旧输出 6 位,整行就错位了。 |
一行的分隔 | printf("%d%c", x, i == n ? '\n' : ' '); 用三元表达式让行末换行、其余打空格,省掉 if。 |
和 cout 混用 | 两者都能用,但一个程序里最好只用一种。混用时不要写 ios::sync_with_stdio(false),否则输出顺序可能乱。 |
#include <bits/stdc++.h>
using namespace std;
int main() {
for (int i = 1; i <= 3; ++i) printf("%5d", i); // " 1 2 3"
printf("\n");
printf("[%5d][%-5d]\n", 42, 42); // "[ 42][42 ]"
printf("%5d\n", 123456); // 宽度不够时不截断,输出 123456
return 0;
}P1706 要求“每个数字占 5 个字符宽度”,用 cout 要写 setw(5) 且必须每题都记得重新设置;printf 一个格式符解决,更不容易漏。
对 long long 要用 %5lld,用 %5d 会把高 32 位丢掉,输出一个随机数。
下面的例题是本节所有内容的最小检验:它同时用到 step、vis、回溯三行和定宽输出,而它的方案数刚好大到能让你第一次感受到“搜索空间”这个词的重量。
输入一个整数 n,按字典序输出 1 到 n 的所有全排列,每个排列占一行。
输出时每个数字占 5 个字符宽度,右对齐;同一行内数字之间不额外加空格。
数据范围:1 ≤ n ≤ 9,方案数为 n!,最大 362880。
换一种说法。一个排列就是把 1..n 各用一次,依次放进第 1、2、…、n 个格子。于是“输出所有排列”变成“枚举所有填法”,而填法可以一层一层决定:第 1 格填谁,第 2 格填谁……
状态记什么。只需要两样:已经填好的 a[1..step-1],以及哪些数字已经被用掉。后者可以直接扫一遍 a 得到,但每次是 O(n);用一个 vis[] 布尔数组把它压成 O(1)。这是“空间换时间”出现得最朴素的地方。
出口在哪。step 从 1 开始,填完第 n 格后进入第 n+1 层,此时 step > n,a[1..n] 就是一个完整排列,输出后 return。把出口写成 step == n 会漏掉最后一格,是本题第一大坑。
为什么不用排序就是字典序。每层循环 i 都从 1 到 n 递增。字典序比较的是“第一个不同的位置”,递归保证前面的位置先定下来、而且按从小到大尝试,于是输出的先后顺序就是字典序。
规模。节点数 = 1 + n + n(n−1) + … + n! ≈ e·n!,n = 9 时约 98.6 万个节点、362880 个叶子。每个叶子输出 9 个数 × 5 字符 ≈ 46 字节,总输出约 16 MB——本题的瓶颈是输出,不是搜索。
#include <bits/stdc++.h>
using namespace std;
int n;
int a[10]; // a[1..n]:正在拼的这一个排列
bool vis[10]; // vis[i]:数字 i 是否已被前面的位置用掉
void dfs(int step) { // step:正在填第几个位置
if (step > n) { // 出口:n 个位置全填好,a[1..n] 就是一个排列
for (int i = 1; i <= n; ++i) printf("%5d", a[i]);
printf("\n");
return; // 必须 return,否则会继续往下选
}
for (int i = 1; i <= n; ++i) { // 这一层的候选:1..n 中还没用过的
if (vis[i]) continue; // 已被占用,跳过
a[step] = i; // 填进第 step 格(不用还原,下次循环会覆盖)
vis[i] = 1; // ① 占位
dfs(step + 1); // ② 递进
vis[i] = 0; // ③ 还原,把 i 还给后面的兄弟分支
}
}
int main() {
cin >> n; // 输入只有一个整数,不必关同步
dfs(1); // 从第 1 个位置开始填
return 0;
}| 代码片段 | 含义与理由 |
|---|---|
int a[10]; bool vis[10]; | n ≤ 9,下标要用到 9,所以开 10 格。开 9 格是“差一格越界”:n = 9 时写 a[9] 正好踩在数组外面,有时不报错、有时答案随机,是最难查的一类 WA。 |
void dfs(int step) | 返回 void:答案不是“算出来返回上去”,而是“走到叶子就地输出”。搜索题的函数几乎都是 void + 全局状态。 |
if (step > n) | 出口写成 step > n(等价于 step == n+1)。因为进入第 n+1 层才说明第 n 格已经填完;写成 step == n 时,最后一格还没填就输出。 |
printf("%5d", a[i]) | 每个数字占 5 格。用 cout 需要写 setw(5) 并且每次重新设置,容易漏。 |
if (vis[i]) continue; | 判重放在候选集处,而不是在出口处回头检查。出口再判重会白白走完 n! 条路,复杂度高一个量级。 |
a[step] = i; | 只写不还原。a 是“当前答案”,同一层的下一次循环会覆盖它;把它也还原反而多此一举。 |
vis[i] = 1; dfs(step + 1); vis[i] = 0; | 回溯三行。中间这句返回时,深层所有分支都已经把标记撤干净,所以这里只负责撤自己这一层的。 |
dfs(1); | 从第 1 层进入。若从 0 开始,出口条件和 a 的下标全都要跟着改,两种风格不要混。 |
把子集、组合、排列写成同一副骨头,差别只在循环怎么写。这三段建议直接背下来,之后所有搜索题都是往里面填东西。
#include <bits/stdc++.h>
using namespace std;
int n = 3, a[25], top = 0;
void dfs(int i) {
if (i > n) { // 每个元素的去留都定了
for (int k = 1; k <= top; ++k) cout << a[k] << ' ';
cout << '\n';
return;
}
dfs(i + 1); // 分支一:不选 i
a[++top] = i; // 分支二:选 i,先占位
dfs(i + 1); // 再往下
--top; // 回来时弹出(这就是还原)
}
int main() { dfs(1); } // 2^3 = 8 行,第一行是空集#include <bits/stdc++.h>
using namespace std;
int n = 5, k = 3, a[25];
void dfs(int step, int start) {
if (step > k) {
for (int i = 1; i <= k; ++i) cout << a[i] << ' ';
cout << '\n';
return;
}
for (int i = start; i <= n; ++i) { // 起点是 start,不是 1
a[step] = i;
dfs(step + 1, i + 1); // 下一层起点右移一格
}
}
int main() { dfs(1, 1); } // C(5,3) = 10 行③ 排列就是例题:候选集是“1..n 中所有没被用过的数”,用 vis 判重。三者对照一下:子集的分支数固定为 2,不需要循环;组合的循环起点是变的;排列的循环起点固定、靠 vis 排除。
还有一条容易忽略:子集模板里 --top 就是“回溯三行”的第三行,只不过这里占位的是一个栈指针而不是 vis。还原的对象永远是“你刚才改动的那份状态”,把它找出来,回溯就不会写错。
出口写成 if (step == n):最后一格没填就输出,n = 3 时每行只有两个数 → WA,样例就挂。
忘记 vis[i] = 0:只输出一条排列就结束,或方案数严重偏少 → WA,且程序不崩、不好查。
还原写在 dfs 之前:同一个数被反复填入不同位置,输出大量非法排列,n = 9 时输出量爆炸 → TLE。
组合里把 dfs(step + 1, i + 1) 写成 start + 1:只能生成连续区间,方案数少一大截 → WA。
数组开小(bool vis[9] 却要访问 vis[9]):越界写,结果随机 → RE 或莫名其妙的 WA。习惯是“题目上界 + 1~5 格余量”。
用 endl 输出 36 万行:每行强制刷新一次缓冲区 → TLE。循环里一律用 "\n" 或 printf。
关掉这一页,把 27 行全排列程序从零敲一遍,交到 P1706,确认 n = 3 的输出和样例一字不差。
改一改①:把 for (int i = 1; i <= n; ++i) 改成从 n 递减到 1,先预测输出顺序,再跑一遍——它正好是字典序的完全倒序。
改一改②:在 dfs 开头加一个全局计数器 ++cnt,输出 n = 5 时的 cnt,和公式 1 + 5 + 20 + 60 + 120 + 120 = 326 对照。对不上就说明你的回溯没撤干净。
改一改③:把 a[step] = i; 后面补一句 a[step] = 0; 再跑,答案不会变——亲手验证“答案数组不需要还原”。
DFS 枚举的时间 = 搜索树节点数 × 每个节点的工作量。节点数是可以提前算出来的,这就是“能不能做”的唯一判据。
排列:A(n,k) = n!/(n−k)! 组合:C(n,k) = n!/(k!(n−k)!) 子集:2ⁿ = Σ C(n,k)
| n | n! | 2ⁿ | C(n, n/2) | 能不能枚举 |
|---|---|---|---|---|
| 9 | 362880 | 512 | 126 | 轻松 |
| 11 | 39916800 | 2048 | 462 | n! 已到 4×10⁷,勉强 |
| 12 | 479001600 | 4096 | 924 | n! 超 10⁸,只能枚举组合/子集 |
| 20 | 2.4×10¹⁸ | 1048576 | 184756 | 只能枚举子集 |
| 23 | — | 8388608 | — | 2ⁿ 接近 10⁷,还能做 |
| 25 | — | 33554432 | — | 子集已吃力 |
判据:一般评测机 1 秒约做 10⁸ 次简单操作,但搜索的每个节点还要输出或判断,所以节点数控制在 10⁷ 以内才稳。于是 n! 的失控点在 n = 11~12,2ⁿ 的失控点在 n = 24~25,C(n, n/2) 在 n = 30 附近(1.5×10⁸)。
输出也会失控。P1706 的 n = 9 输出约 16 MB 还行;换成 n = 12 就是 4.8×10⁸ 个排列、约 28 GB,哪怕搜索一秒跑完,输出也要跑几分钟。看到“输出所有方案”先估输出量,这一条比估复杂度更早生效。
递归深度不用担心:深度等于层数,排列是 n 层、组合是 k 层,每层栈帧几十字节,n ≤ 10⁴ 都不会爆栈。下一节的网格搜索才会遇到“深度 = 状态数”导致的爆栈。
a[step] 不用还原而 vis[i] 必须还原。给出 n 个整数 x₁…xₙ 和一个整数 k(k < n)。从这 n 个数中任选 k 个相加,会得到一系列和。求其中和为素数的方案数。例如 n = 4、k = 3,四个数为 3、7、12、19,四种组合的和分别是 22、29、38、34,只有 29 是素数。
输入格式:第一行两个整数 n、k(1 ≤ n ≤ 20,k < n);第二行 n 个整数 xᵢ(1 ≤ xᵢ ≤ 5×10⁶)。
输出格式:一行一个整数,表示和为素数的方案数。
k 是输入的,写不出 k 层 for 循环。想一个“层数由输入决定”的写法。
题面说的是“选 k 个”,3 + 7 + 12 和 7 + 3 + 12 是同一个方案,只能算一次。这个“去重”上一节有两套手段。
和是不是素数,只跟 k 个数加起来的结果有关,跟选的顺序无关——所以判断放在“选够 k 个”那一刻做一次就够。
模型。每个方案是一个 k 元下标集合 {i₁ < i₂ < … < i_k},答案是其中 sum 为素数的集合个数。
去重。用 start 参数保证下标递增:第 step 层从 start 开始枚举,下一层传 i + 1。这样 (3,7,12) 只会以 3→7→12 的顺序被生成一次,(7,3,12) 永远不会出现。
出口。step == k 时手里已经有 k 个数的和 sum,判一次素数,是就 ++ans,然后 return。为了省一个参数,也可以把和做成 dfs 的第三个参数往下传,进入下一层时直接 sum + x[i]。
判素数。只需试除到 √v:若 v 有大于 √v 的因子,那它必然还有一个小于 √v 的配对因子。本题 sum ≤ k × 5×10⁶ ≤ 9.5×10⁷,√sum < 10⁴,单次判断最多一万次取模。
再省一点。还剩 n − i + 1 个位置可选,如果不够 k − step 个了,后面的 i 全都不必试,直接 break。这是下一节要讲的“可行性剪枝”,在这里先见一次。
#include <bits/stdc++.h>
using namespace std;
int n, k, ans;
int x[25];
// 试除到 sqrt(v) 就够:大于 sqrt 的因子一定配一个小于 sqrt 的
bool isPrime(int v) {
if (v < 2) return false;
for (int d = 2; d * d <= v; ++d) // d*d 用 int 不会溢出,因为 d <= 10^4
if (v % d == 0) return false;
return true;
}
// step:已选个数;start:本层最小可选下标;sum:已选数之和
void dfs(int step, int start, int sum) {
if (step == k) { // 选够 k 个,判一次
if (isPrime(sum)) ++ans;
return;
}
for (int i = start; i <= n; ++i) {
if (n - i + 1 < k - step) break; // 剩下的数不够选了,剪枝
dfs(step + 1, i + 1, sum + x[i]); // 下标递增 → 不重
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> k;
for (int i = 1; i <= n; ++i) cin >> x[i];
dfs(0, 1, 0); // 选了 0 个,从下标 1 开始,和为 0
cout << ans << "\n";
return 0;
}| 片段 | 作用 |
|---|---|
dfs(int step, int start, int sum) | 三个参数分别是“进度、候选下界、当前和”。把 sum 顺着参数传,比用全局变量再回退更好写——少一次还原,就少一个出错的地方。 |
if (step == k) | 出口是“选够 k 个”,此时 sum 就是这 k 个数的和。注意和上一节的全排列不同:那里的出口在 n+1 层,这里的出口正好在第 k 层。 |
dfs(step + 1, i + 1, sum + x[i]) | 整个去重就靠这个 i + 1。它让下标严格递增,于是每个集合只有一种生成顺序。 |
if (n - i + 1 < k - step) break; | 位置 i..n 共 n−i+1 个,还需要 k−step 个。装不下就 break(不是 continue),因为 i 再大只会更不够。 |
for (int d = 2; d * d <= v; ++d) | 试除到 √v。v ≤ 9.5×10⁷ 时 d 不超过 9747,d*d 远在 int 范围内。写成 d <= sqrt(v) 要调用浮点开方,慢且可能因精度差一,用乘法比较更稳。 |
if (v < 2) return false; | 1 不是素数。本题 sum 至少为 k 个正整数之和,不会小于 2,但把这个判断写上,函数才能通用。 |
d < v:最坏 9.5×10⁷ 次取模,乘上 18 万次判断必然 TLE;写到 √v 才行。break 写成 continue:只是白跑,答案仍对但慢;反过来把剪枝条件写反(> 写成 <)会漏方案 → WA。int 是够的(≤ 9.5×10⁷),但如果把上界估成 20 × 10⁷ 并继续放大,就要换 long long,先估再用。