NOIP 算法笔记全部章节
本章目录

第 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:计算顺序不好确定的时候。滑雪要“先算低处、再算高处”,按行列的顺序循环是错的;写成递归,顺序由程序自己解决。

把一个暴力搜索改成记忆化,分三步:

  1. 让 dfs 有返回值——“从这个状态出发,最好的结果是多少”——而不是把结果累加在全局变量里。
  2. 确认返回值只和参数有关。如果它还依赖“之前已经选了哪些”,就得把这个信息也放进参数(用一个二进制数表示,见第 3 章状压 DP)。
  3. 加一个数组 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 的方案数”为例:

  1. 前一半的所有子集的和算出来放进数组 A,后一半的放进数组 B,各 2^20 个。
  2. 把 B 排序。
  3. 对 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。

这两个问题都和知识无关。照下面五条规矩写,都能避免。

写暴力的五条规矩

  1. 用最笨的办法,照着题意直接翻译。 题目问“最短路恰好有两条的点对”,就对每个点做一次 BFS、老老实实地数条数。不要在暴力里用自己推出来、没验证过的巧办法。暴力只有一个任务:对。
  2. 每个循环都要说得出“它为什么一定会结束”。 while 循环的每一轮,必须有某个量在严格地变化。上面那段代码加一句 if (ft[uu] == uu) break; 就不会死。说不清的循环,加一个计数器,超过上限就跳出。
  3. 先想特殊情况,再写代码。 至少过一遍:n = 1;图不连通;树是一条链;所有的数都相同;答案是 0 或“无解”;数值取到最大。
  4. 样例之外,自己再造三组小数据手算。 一组最小的(n = 1 或 2),一组“无解 / 不连通”的,一组普通的。样例过了不等于对;连小数据都对不上的程序,交上去就是 0 分。
  5. 暴力写完先存一份,再去想正解。 复制成 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 循环都能说出它为什么会结束