第 2 章 枚举与搜索
本章定位:这一章回答“暴力分从哪来”。NOIP 每道题的数据范围表里,前面 20%~40% 的测试点 n 很小(n ≤ 10、n ≤ 20),就是留给枚举和搜索的。T3、T4 的正解现在够不着,但这几档分用本章的几个模板就能拿到,四道题加起来有 60~100 分。CSP-S 2026 的 T3 和 T4,按赛后解析的部分分设置,逐点 BFS 有 24 分、n ≤ 10 的枚举有 15 分,这 39 分一分没拿到——不是不会,是暴力写错了、或者没写。本章最重要的是最后一节:暴力程序的纪律。
2.1 从数据范围判断能不能暴力 【必会】
一句话:把 n 代进复杂度里算出运算次数,不超过 10^8 就能在 1 秒内跑完。 识别信号:每道题、每一档部分分,都先做这一步。
| 数据范围 | 能承受的复杂度 | 对应的做法 |
|---|---|---|
| n ≤ 10 | O(n!·n) | 全排列枚举(2.3) |
| n ≤ 20 | O(2^n·n) | 子集枚举(2.2)、状压 DP(第 3 章) |
| n ≤ 40 | O(2^(n/2)·n) | 折半搜索(2.7) |
| n ≤ 100 | O(n^4) | 四重循环、状态较多的 DP |
| n ≤ 300 ~ 500 | O(n^3) | 三重循环、Floyd、区间 DP |
| n ≤ 5000 | O(n^2) | 两重循环、朴素 DP |
| n ≤ 10^5 ~ 10^6 | O(n log n) | 排序、二分、堆、树状数组、线段树 |
| n ≤ 10^7 | O(n) | 扫一遍、双指针、前缀和、线性 DP |
| n ≤ 10^18 | O(log n) 或 O(1) | 公式、快速幂、找规律 |
几个要记住的数:10! ≈ 3.6×10^6,11! ≈ 4×10^7,12! ≈ 4.8×10^8;2^20 ≈ 10^6,2^25 ≈ 3.3×10^7,2^30 ≈ 10^9。
出题人就是照着这张表设部分分的。数据范围表要一档一档地读:看到“n ≤ 18”,意思就是“这一档给 2^n 的做法”;看到“n ≤ 10”,就是给全排列的。
2.2 子集枚举 【必会】
一句话:n 个元素,每个“选或不选”,一共 2^n 种选法。0 到 2^n − 1 的每个整数的二进制恰好对应一种,一个 for 循环全部枚举出来。 识别信号:n ≤ 20;题目要“选出若干个,使得……”。 复杂度:O(2^n·n)。n = 20 时约 2×10^7,很快;n = 24 时约 4×10^8,实测要 1 秒,已经到头了。
// 子集枚举(二进制)。用洛谷 P1048 采药的输入格式,演示物品数 m ≤ 20 时的暴力:
// 总时间 T,m 株草药各有采摘时间和价值,每株最多采一次,求总时间不超过 T 时的最大总价值。
// 0 到 2^m - 1 的每个整数恰好对应一个子集:二进制第 i 位是 1,表示选了第 i 株。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int T, m;
cin >> T >> m;
vector<int> t(m), v(m); // 从 0 开始编号,第 i 株对应二进制的第 i 位
for (int i = 0; i < m; i++) cin >> t[i] >> v[i];
long long best = 0;
for (int mask = 0; mask < (1 << m); mask++) { // 枚举每一个子集
long long used = 0, gain = 0;
for (int i = 0; i < m; i++)
if (mask >> i & 1) { // 第 i 位是 1:这个子集里有第 i 株
used += t[i];
gain += v[i];
}
if (used <= T) best = max(best, gain); // 合法的子集才用来更新答案
}
cout << best << '\n';
return 0;
}
- 第 i 位表示第 i 个元素,所以元素从 0 开始编号最顺手,读入时存进下标 0 到 n − 1。
- 每个子集的检查要尽量简单。检查本身是 O(n^2) 时,n = 20 就是 4×10^8,会超时,这时把上限当成 n ≤ 16 来估。
- 这道采药,第 3 章的 01 背包是正解。暴力和正解都写出来,就能互相对拍——本章和第 3 章的模板就是这样检查的。
易错点
1 << m写成1 << m - 1。减号的优先级更高,它等于1 << (m - 1),少枚举了一半。- 元素从 1 开始编号,却去看第 0 位;或者循环写成
i <= m。 - 把“合法才更新答案”的判断漏掉,非法的子集也算了进去。
练一练:洛谷 P2392 kkksc03考前临时抱佛脚;本系统 E03(n ≤ 18 的那一档)、E08、E10 的小数据档。
2.3 排列枚举 【必会】
一句话:n 个元素的所有先后顺序一共 n! 种。next_permutation 每调用一次,就把数组变成“字典序的下一个排列”。
识别信号:n ≤ 10;题目和“顺序”有关——访问顺序、操作顺序、排队顺序。
复杂度:O(n!·n)。n = 10 约 4×10^7,没有问题;n = 11 约 4×10^8,检查很简单时还能压线;n = 12 不行。
// 排列枚举(next_permutation)。用洛谷 P1171 售货员的难题的输入格式,演示 n ≤ 10 时的暴力:
// n 个村庄,d[i][j] 是从 i 到 j 的路程。从 1 号出发,每个村庄恰好去一次,最后回到 1 号,求最短总路程。
#include <bits/stdc++.h>
using namespace std;
int n, d[25][25];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++) cin >> d[i][j];
vector<int> order; // 2..n 号村庄的访问顺序;起点固定,不参与排列
for (int i = 2; i <= n; i++) order.push_back(i);
long long best = LLONG_MAX;
do { // 数组一开始是升序(最小的排列),这样才能枚举到全部 (n-1)! 种
long long len = 0;
int cur = 1;
for (size_t i = 0; i < order.size(); i++) {
len += d[cur][order[i]];
cur = order[i];
}
len += d[cur][1]; // 别忘了回到起点的那一段
best = min(best, len);
} while (next_permutation(order.begin(), order.end())); // 换成下一个排列;没有下一个了就返回 false
cout << best << '\n';
return 0;
}
- 开始之前数组必须是升序的(最小的排列),否则前面的排列会被漏掉。数组不是 1 到 n 时,先
sort。 - 固定用
do { … } while (next_permutation(…));:先处理当前这个排列,再换下一个。写成while (next_permutation(…)) { … }会漏掉第一个排列。 - 起点固定时,只排列其余的元素(模板里 1 号村庄不参与排列),少乘一个 n。
- 这道旅行商,第 3 章的状压 DP 是 n ≤ 20 的正解。
易错点
- 分不清排列的是“下标”还是“值”。习惯让
order[i]表示“第 i 个去的是谁”,再用d[cur][order[i]]去查表。 - 回到起点的那一段路忘了加。
best的初值不够大。路程可能很长时用LLONG_MAX,但之后不能再往它上面加东西(会溢出)。
练一练:洛谷 P1706 全排列问题、P1171 售货员的难题(这个暴力只能过 n 小的点);本系统 E13(n ≤ 8 的档)、E14(k ≤ 8 的档)。
2.4 DFS 回溯与剪枝 【必会】
一句话:一步一步做决定,走不通就退回上一步、换一种选择。子集、排列、组合、“每个元素从 k 种方案里选一种”,都能用同一个骨架写出来。 识别信号:n 很小;决定之间互相制约(不能冲突、总和不能超过……),用几层 for 循环写不出来。 复杂度:最坏是指数级的,剪枝决定实际能跑多大。
// DFS 回溯与剪枝。洛谷 P1219 [USACO1.5] 八皇后 Checker Challenge:
// n×n 的棋盘上放 n 个皇后,任意两个不同行、不同列、不在同一条斜线上。
// 输出字典序最小的前 3 个方案(第 i 个数是第 i 行的皇后所在的列)和方案总数。
#include <bits/stdc++.h>
using namespace std;
int n, total;
int pos[15]; // pos[r]:第 r 行的皇后放在第几列
bool col[15], diag1[30], diag2[30]; // 这一列、这条“/”斜线、这条“\”斜线上是否已经有皇后
void dfs(int r) { // 前 r - 1 行已经放好,现在决定第 r 行
if (r > n) { // n 行都放完了:找到一个方案
total++;
if (total <= 3) {
for (int i = 1; i <= n; i++) cout << pos[i] << ' ';
cout << '\n';
}
return;
}
for (int c = 1; c <= n; c++) { // 依次尝试第 r 行的每一列
if (col[c] || diag1[r + c] || diag2[r - c + n]) continue; // 剪枝:会冲突的位置直接跳过
pos[r] = c;
col[c] = diag1[r + c] = diag2[r - c + n] = true; // 占用
dfs(r + 1);
col[c] = diag1[r + c] = diag2[r - c + n] = false; // 回溯:撤销占用,再试下一列
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
dfs(1);
cout << total << '\n';
return 0;
}
通用骨架:
void dfs(int step) { // 前 step - 1 个决定已经做完
if (step > n) { 检查、更新答案; return; }
for (第 step 个决定的每一种选择) {
if (现在就能看出这种选择不行) continue; // 剪枝
做出选择(修改状态);
dfs(step + 1);
撤销选择(把状态改回去); // 回溯
}
}
三种常见的枚举都是它的特例:
- 子集:第 step 个元素选或不选,两种选择。
- 排列:第 step 个位置放哪个还没用过的元素。
- 组合(n 个里选 k 个):多带一个参数
start,只从 start 往后选,保证每种组合只出现一次(洛谷 P1036 选数)。
剪枝有两种基本的想法:可行性剪枝——已经违反了约束,后面怎么选都没用(八皇后的冲突判断);最优性剪枝——求最小值时,当前的代价已经不小于已知的最优答案,不必再往下走。
易错点
- 忘了撤销。
col[c] = true; dfs(r + 1);之后必须有col[c] = false;。漏掉它,第一条路走完之后所有的列都被占着,方案数偏小。 - 撤销和修改不对称:改了三个数组,只撤销了两个。写的时候把“占用”和“撤销”两行挨着写,对照着检查。
- 终止条件是
r == n还是r > n,取决于 r 的含义。模板里 r 是“现在要放第 r 行”,所以 r > n 才是放完了。 - 统计答案的全局变量,多组数据时忘了清零。
- 把大数组当参数按值传进 dfs,每一层都复制一份,又慢又容易爆栈。数组放全局。
练一练:洛谷 P1219 八皇后、P1036 选数、P1605 迷宫;本系统 E05(n ≤ 8 的档:每门课从几种方案里选一种)。
2.5 BFS 【必会】
一句话:从起点出发一圈一圈向外扩展。第一次到达某个位置时走的步数,就是到它的最少步数。 识别信号:“最少几步”“最短时间”,并且每一步的代价都相同。每步代价不一样时要用 Dijkstra(第 4 章)。 复杂度:O(状态数 × 每个状态能走的方向数),网格上是 O(nm)。
// BFS 求网格上的最少步数。洛谷 P1443 马的遍历:
// n×m 的棋盘,马从 (x, y) 出发,输出它到每个格子最少要走几步,到不了的格子输出 -1。
#include <bits/stdc++.h>
using namespace std;
const int N = 405;
const int dx[8] = {1, 2, 2, 1, -1, -2, -2, -1}; // 马的 8 种走法。走迷宫时换成上下左右 4 种
const int dy[8] = {2, 1, -1, -2, -2, -1, 1, 2};
int n, m, dist[N][N];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int sx, sy;
cin >> n >> m >> sx >> sy;
memset(dist, -1, sizeof(dist)); // -1 表示还没到过
queue<pair<int, int> > q;
dist[sx][sy] = 0; // 入队的同时就写上步数,它也充当“已访问”的标记
q.push(make_pair(sx, sy));
while (!q.empty()) {
int x = q.front().first, y = q.front().second;
q.pop();
for (int k = 0; k < 8; k++) {
int nx = x + dx[k], ny = y + dy[k];
if (nx < 1 || nx > n || ny < 1 || ny > m) continue; // 先查出界
if (dist[nx][ny] != -1) continue; // 再查到没到过(第一次到就是最少步数)
dist[nx][ny] = dist[x][y] + 1;
q.push(make_pair(nx, ny));
}
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) cout << left << setw(5) << dist[i][j]; // 题目要求左对齐、宽 5 格:left 是左对齐,setw(5) 是占 5 格
cout << '\n';
}
return 0;
}
- 三件东西:队列、
dist数组(同时充当“到没到过”的标记)、方向数组。 - 多个起点:一开始把所有起点都以 dist = 0 入队,得到的是每个位置“到最近的起点”的步数。
- 状态不一定是格子。倒水问题里状态是“几个杯子里各有多少水”,八数码里状态是整个棋盘。只要说得清“一步能从哪个状态变成哪些状态”,就能 BFS;到没到过,用数组(状态能编号时)或
map、set来记。 - 图上的 BFS 和网格上一样,把“几个方向”换成“邻接表里的每个邻居”。CSP-S 2026 T3 的 24 分暴力,就是从每个点各做一次 BFS,顺便数最短路的条数(第 4 章的最短路计数)。
易错点
- 出队的时候才标记。同一个格子会被它的好几个邻居重复放进队列,队列越来越大,超时或者超内存。要在入队的时候就标记。
- 边界检查写在了访问数组的后面。
if (dist[nx][ny] != -1 || nx < 1 …)是先越界访问、再检查。顺序要像模板那样:先查出界。 - 行和列弄反。n 行 m 列,第一维是行(上界 n),第二维是列(上界 m)。
- 图不连通时,有些位置永远到不了,
dist一直是 -1。后面用到dist之前要先判断。 - 用 DFS 求最少步数。DFS 第一次走到某个位置时不保证步数最少,结果要么错、要么超时。
练一练:洛谷 P1443 马的遍历、P1746 离开中山路、P1379 八数码难题。
2.6 记忆化搜索 【常考】
一句话:dfs 的返回值只由参数决定时,把算过的结果存进数组,同样的参数第二次来就直接返回。它就是用递归写的 DP。 识别信号:搜索时发现同一个状态被反复计算;不同的状态总数不大(比如 n×m 个格子)。 复杂度:O(状态数 × 每个状态的选择数)。滑雪是 O(RC)。
// 记忆化搜索。洛谷 P1434 [SHOI2002] 滑雪:
// R 行 C 列的高度表,每次只能滑到上下左右相邻且更低的格子,求最长的一条路线经过多少个格子。
#include <bits/stdc++.h>
using namespace std;
const int N = 105;
const int dx[4] = {1, -1, 0, 0}, dy[4] = {0, 0, 1, -1};
int R, C, h[N][N];
int f[N][N]; // f[x][y]:从 (x, y) 出发最多能经过几个格子。0 表示还没算过(真实答案至少是 1)
int dfs(int x, int y) {
if (f[x][y]) return f[x][y]; // 算过了就直接用。少了这一行,同一个格子会被重复计算无数次
int best = 1; // 至少包括自己这一格
for (int k = 0; k < 4; k++) {
int nx = x + dx[k], ny = y + dy[k];
if (nx < 1 || nx > R || ny < 1 || ny > C) continue;
if (h[nx][ny] < h[x][y]) best = max(best, dfs(nx, ny) + 1); // 只能往更低处滑,所以不会绕回来
}
return f[x][y] = best; // 先记下来,再返回
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> R >> C;
for (int i = 1; i <= R; i++)
for (int j = 1; j <= C; j++) cin >> h[i][j];
int ans = 0;
for (int i = 1; i <= R; i++)
for (int j = 1; j <= C; j++) ans = max(ans, dfs(i, j)); // 起点任选,每个格子都试一遍
cout << ans << '\n';
return 0;
}
什么时候用它而不用循环写的 DP:计算顺序不好确定的时候。滑雪要“先算低处、再算高处”,按行列的顺序循环是错的;写成递归,顺序由程序自己解决。
把一个暴力搜索改成记忆化,分三步:
- 让 dfs 有返回值——“从这个状态出发,最好的结果是多少”——而不是把结果累加在全局变量里。
- 确认返回值只和参数有关。如果它还依赖“之前已经选了哪些”,就得把这个信息也放进参数(用一个二进制数表示,见第 3 章状压 DP)。
- 加一个数组 f:函数开头查,结尾存。
易错点
- “没算过”的标记和真实答案撞了。答案可能是 0 时,不能用 0 表示没算过,改用 -1(
memset(f, -1, sizeof(f)))。模板里答案至少是 1,所以可以用 0。 - 状态里漏了参数。dfs 有三个参数,f 却只开了两维。
- 状态之间有环时不能直接记忆化(a 要用 b 的结果、b 又要用 a 的,会无限递归)。滑雪靠“只能往更低处滑”保证没有环。
- 多组数据忘了清空 f。
练一练:洛谷 P1434 滑雪、P1464 Function。
2.7 折半搜索 【了解】
一句话:n ≤ 40 时 2^40 跑不完,但把元素分成两半,每一半各枚举 2^20 种,再把两边的结果配对,就可以。 识别信号:n 在 30 到 40 之间,问“选若干个,总和不超过 / 恰好等于某个数”的方案数或最优值。
以“选若干个数,总和不超过 M 的方案数”为例:
- 前一半的所有子集的和算出来放进数组 A,后一半的放进数组 B,各 2^20 个。
- 把 B 排序。
- 对 A 里的每个和 x,在 B 里二分出有多少个 y 满足 x + y ≤ M,加起来。
总复杂度 O(2^(n/2)·n)。考场上遇到的机会不多,认得出“n ≤ 40”这个信号就够了。
练一练:洛谷 P4799 世界冰球锦标赛。
2.8 暴力程序的纪律 【必会】
CSP-S 2026 T3 交上去的是一份暴力,结果 25 个测试点全部超时,0 分。原因有两个。
第一,死循环。 代码里有一段“从 u 出发,不断跳到最远能到的岛,直到跳到 n 号岛”:
while (uu < n) {
uuu = uu; uu = ft[uu]; m++; // ft[uu]:从 uu 出发最远能到的岛
for (int i = uuu + 1; i <= uu; i++) dis[u][i] = m;
}
如果相邻两个岛的距离超过了 d(图不连通),ft[uu] 就是 uu 自己,uu 不再变化,循环永远不结束。第 1 个测试点的第 2 组数据就是两个互相够不着的岛。
第二,做法没有验证过。 即使不死循环,它数的也不是“最短路有几条”。四个岛排成一条线、只有相邻的能互相到达时(坐标 1 2 3 4,d = 1),每两个岛之间都只有一条最短路,答案是 0,这份代码输出 1。
这两个问题都和知识无关。照下面五条规矩写,都能避免。
写暴力的五条规矩
- 用最笨的办法,照着题意直接翻译。 题目问“最短路恰好有两条的点对”,就对每个点做一次 BFS、老老实实地数条数。不要在暴力里用自己推出来、没验证过的巧办法。暴力只有一个任务:对。
- 每个循环都要说得出“它为什么一定会结束”。 while 循环的每一轮,必须有某个量在严格地变化。上面那段代码加一句
if (ft[uu] == uu) break;就不会死。说不清的循环,加一个计数器,超过上限就跳出。 - 先想特殊情况,再写代码。 至少过一遍:n = 1;图不连通;树是一条链;所有的数都相同;答案是 0 或“无解”;数值取到最大。
- 样例之外,自己再造三组小数据手算。 一组最小的(n = 1 或 2),一组“无解 / 不连通”的,一组普通的。样例过了不等于对;连小数据都对不上的程序,交上去就是 0 分。
- 暴力写完先存一份,再去想正解。 复制成
xxx_bf.cpp。之后正解写出来了,用它对拍;正解写不出来,交它。
每道题的最低目标
每道题至少交一份暴力。空着是 0 分,一份三四十行的枚举往往有 15~30 分。
以 CSP-S 2026 T4 研发基地 n ≤ 10 的那一档为例(题意:在树上选 k 个点,选中的点最多形成两个连通块,让其余各点到最近选中点的距离的最大值尽量小)。它只需要三样东西,全是模板:枚举选哪 k 个点(子集枚举);数选中的点有几个连通块(BFS);算每个点到最近选中点的距离(多个起点的 BFS)。
暴力的常见样子
| 题目在问什么 | 小数据的暴力 |
|---|---|
| 选若干个元素,使……最优 | 子集枚举,O(2^n·n) |
| 排一个顺序,使……最优 | 全排列,O(n!·n) |
| 每个元素从 k 种方案里选一种 | DFS,O(k^n) |
| 最少几步、最短距离 | BFS;边有长度就用 Floyd 或 Dijkstra |
| 所有区间里……的最值、个数 | 两重循环枚举左右端点,里面再扫一遍,O(n^3) 或 O(n^2) |
| 树上或图上,很多次询问两点之间的…… | 每个询问都重新 DFS / BFS 一遍,O(nq) |
| 方案数 | 把所有方案搜出来,数一遍 |
对拍的具体做法见第 0 章。
本章必会清单
- 看到数据范围,说得出这一档允许什么复杂度、对应哪种暴力
- 子集枚举:
for (mask = 0; mask < (1 << n); mask++)加上mask >> i & 1 - 排列枚举:升序开始,
do { } while (next_permutation(…)) - DFS 回溯:占用、递归、撤销三行,一行不少
- 网格 BFS:入队时标记,先查出界再查访问
- 把一个有返回值的 dfs 改成记忆化搜索
- 背得出写暴力的五条规矩,并且每次都照做
- 每个 while 循环都能说出它为什么会结束