枚举及其优化学习手册
配套题目(按"怎么优化"分类,共 12 道)
编号 题目 来源 枚举什么 怎么优化 0 费解的开关 洛谷 P10449 第一行的按键方案 ( 2 5 2^5 25) 其余各行被唯一确定,逐行递推 A Lucky PIN AtCoder sumitb2019 D 答案本身 ( 10 3 10^3 103)而非下标三元组 改变枚举对象 + 贪心判定 B 插入排序 洛谷 P7910(CSP-J 2021) 只枚举被修改的那一个数 局部更新名次,询问 O ( 1 ) O(1) O(1) C 魔法阵 洛谷 P2119(NOIP 2016 普及组) 间隔 t t t + 一组位置 计数数组/前缀和把内层降 O ( 1 ) O(1) O(1) E 杨辉三角形 洛谷 P8749(蓝桥杯 2021 省 B) 列号 k k k(只有约 16 个) 二分行号 + 组合数剪枝 F 统计子矩阵 洛谷 P8783(蓝桥杯 2022 省 B) 上下边界 双指针(用单调性) G 入阵曲 洛谷 P3941 上下边界 前缀和取模 + 桶计数(同余类) H 机器人塔 洛谷 P8644(蓝桥杯 2016 国 B) 最底下一行的排布 ( 2 h 2^h 2h, h ≤ 21 h\le21 h≤21) 上层由规则唯一确定,逐层递推 I 红蓝牌 / Karte 洛谷 P11475(COCI 2024/2025 #3) 一侧子集( 2 min ( N , M ) 2^{\min(N,M)} 2min(N,M)) 另一侧排序取前 r r r 大 J 区间操作 (opt) 洛谷 P10401(XSOI-R1) 只枚举被询问到的左端点 前缀和 + 离线排序 + 同 l l l 复用 L 子集枚举专题(我补充) 补充经典 二进制掩码 位运算 / 折半 / 容斥 D 取模 洛谷 P8807(蓝桥杯 2022 国 C) 不是枚举题(数论 + 抽屉原理) O ( 1 ) O(1) O(1) 判定
目录
- [第一章 枚举算法简介](#第一章 枚举算法简介)
- [第二章 枚举对象的选择](#第二章 枚举对象的选择)
- [第三章 枚举的优化](#第三章 枚举的优化)
- [第四章 子集枚举](#第四章 子集枚举)
- [第五章 识别手册与易错点](#第五章 识别手册与易错点)
- [第六章 例题分析](#第六章 例题分析)
- [第七章 训练建议](#第七章 训练建议)
- [第八章 反例与对照:什么时候不该枚举](#第八章 反例与对照:什么时候不该枚举)
- [例题 0 费解的开关](#例题 0 费解的开关)
- [例题 A Lucky PIN](#例题 A Lucky PIN)
- [例题 B 插入排序](#例题 B 插入排序)
- [例题 C 魔法阵](#例题 C 魔法阵)
- [例题 D 取模](#例题 D 取模)
- [例题 E 杨辉三角形](#例题 E 杨辉三角形)
- [例题 F 统计子矩阵](#例题 F 统计子矩阵)
- [例题 G 入阵曲](#例题 G 入阵曲)
- [例题 H 机器人塔](#例题 H 机器人塔)
- [例题 I 红蓝牌 / Karte](#例题 I 红蓝牌 / Karte)
- [例题 J 区间操作 (opt)](#例题 J 区间操作 (opt))
- [例题 L 子集枚举四模型(补充)](#例题 L 子集枚举四模型(补充))
第一章 枚举算法简介
1.1 枚举是"暴力",但暴力不等于笨
枚举(穷举)是最朴素的算法思想:
把所有可能的答案 / 所有可能的中间状态一个不漏地列出来,逐个检查,取最优或计数。
它的优点极其重要:
- 正确性天然:只要"枚举范围覆盖了全部可能"且"检查条件写对",答案就不会错;
- 是验证其他算法的工具:任何复杂算法都可以先用枚举写一个暴力版本,用来对拍;
- 是许多正解的前半段 :大量"正解"其实是"枚举 + 优化"------枚举部分不变,只是把检查/统计从 O ( n ) O(n) O(n) 降到 O ( 1 ) O(1) O(1)。
所以学习枚举有两个层次:
| 层次 | 目标 | 训练方法 |
|---|---|---|
| 第一层 | 能写出不重不漏的暴力 | 先想清楚"枚举的集合是什么",再写循环 |
| 第二层 | 能算清复杂度 并换枚举对象 / 优化检查 | 每写一个暴力,都问"哪些循环是浪费的" |
1.2 枚举的三要素
任何枚举方案都由三件事决定:
- 枚举对象:你循环的是什么?(下标?值?方案?状态?子集?)
- 枚举范围:每个变量的上下界(决定"漏没漏");
- 判定/统计方式:每个枚举出来的对象,你怎么判断它是否合法、以及它对答案贡献多少。
写代码的顺序永远是:先写清楚这三件事(可以在纸上),再动手。
1.3 复杂度就是一切:先看数据范围
考场上最有效的习惯是:先看数据范围,反推可以承受的复杂度。
| n n n(或值域) | 可以承受的复杂度 | 常见枚举形态 |
|---|---|---|
| n ≤ 10 n\le10 n≤10 | O ( n ! ) O(n!) O(n!)、 O ( 2 n ⋅ n 2 ) O(2^n\cdot n^2) O(2n⋅n2) | 全排列、子集 |
| n ≤ 20 n\le20 n≤20 | O ( 2 n ) O(2^n) O(2n)、 O ( 2 n ⋅ n ) O(2^n\cdot n) O(2n⋅n) | 子集枚举(状压 DP 的雏形) |
| n ≤ 100 n\le100 n≤100 | O ( n 4 ) O(n^4) O(n4) | 四重循环 |
| n ≤ 500 n\le500 n≤500 | O ( n 3 ) O(n^3) O(n3) | 三重循环 |
| n ≤ 5000 n\le5000 n≤5000 | O ( n 2 ) O(n^2) O(n2) | 双重循环 |
| n ≤ 2 × 10 5 n\le2\times10^5 n≤2×105 | O ( n log n ) O(n\log n) O(nlogn)、 O ( n n ) O(n\sqrt n) O(nn ) | 枚举 + 哈希/前缀和/双指针 |
| n ≤ 10 6 n\le10^6 n≤106 | O ( n log n ) O(n\log n) O(nlogn)、 O ( n ) O(n) O(n) | 枚举 + O ( 1 ) O(1) O(1) 查询 |
口诀 :算一算 10 8 10^8 108。每秒大约能跑 10 8 10^8 108 次简单运算 ,把 "循环层数 × 每层规模" 估成 10 8 10^8 108 以内就基本安全(常数大的算法要再打个折)。
例: n = 5000 n=5000 n=5000 的双重循环是 2.5 × 10 7 2.5\times10^7 2.5×107,安全;三重循环 1.25 × 10 11 1.25\times10^{11} 1.25×1011,必爆------必须想办法砍掉一层,这就是第三章的全部内容。
1.4 "不重不漏"是枚举的生命线
写暴力最容易犯的两个错:
- 重复 :同一个方案被算多次(例如把 ( i , j ) (i,j) (i,j) 和 ( j , i ) (j,i) (j,i) 都算了一遍,而题目只要无序对);
- 遗漏 :边界没算到(例如上界写成
<n而答案可能是 n n n;或者"空集"这种退化情况没枚举)。
三个自检问题(写完暴力先问自己):
- 我的循环真的覆盖了所有合法方案吗?把最小、最大、退化(空/全选/相等)的情况代进去试一遍;
- 有没有重复计数 ?需要去重吗(排序去重 / 强制 i < j i<j i<j / 规定字典序最小者代表整个方案)?
- 计数类题目结果需要取模 吗?答案会不会超
int?
第二章 枚举对象的选择
这是本手册最重要的一章。同一道题,换一个枚举对象,复杂度可能从 O ( n 3 ) O(n^3) O(n3) 掉到 O ( 10 3 ) O(10^3) O(103)。 例题 A(Lucky PIN)就是最经典的示范。
2.1 三层法:拿到题先问三个问题
- 我枚举的到底是什么? 写下来("我在枚举下标三元组"或"我在枚举密码本身")。
- 有没有别的等价对象,规模更小? 常见替换方向:
- 下标三元组 → \to → 值本身 (例题 A: O ( n 3 ) → O ( 10 3 ) O(n^3)\to O(10^3) O(n3)→O(103));
- 枚举方案 → \to → 枚举方案的某个关键参数 (例题 C:枚举间隔 d d d);
- 枚举"每个元素选不选" → \to → 枚举互补的部分(更小的一边);
- 枚举 O ( n 2 ) O(n^2) O(n2) 对 → \to → 枚举其中一个 + 查询另一个(例题 F/G)。
- 这个对象的"规模"和"数据范围"哪个更小? 通常选规模更小的那个(值域小就枚举值域, n n n 小就枚举 n n n)。
2.2 例题 A 的启示:枚举"答案"而不是"位置"
题目:给长度 N N N 的数字串 S S S,求"用 S S S 的三个不同位置拼成的三位数"有多少个不同的值。
朴素做法 :枚举 i < j < k i<j<k i<j<k, O ( N 3 ) O(N^3) O(N3), N ≤ 3 × 10 4 N\le3\times10^4 N≤3×104 时约 4.5 × 10 12 4.5\times10^{12} 4.5×1012,必爆。
换对象 :答案只可能是 000 ∼ 999 000\sim999 000∼999 这 1000 1000 1000 个数!所以
枚举密码本身( 1000 1000 1000 种),判断"它能不能被拼出来"。
判断方法:对密码 d 1 d 2 d 3 d_1d_2d_3 d1d2d3,在 S S S 中贪心地依次找 d 1 , d 2 , d 3 d_1,d_2,d_3 d1,d2,d3(每次都从上一个位置之后找),三个都能找到就说明可行。
复杂度 O ( 10 3 ⋅ N ) O(10^3\cdot N) O(103⋅N)。从"枚举位置"换成"枚举答案"是枚举优化的第一招,也是最常考的一招。
2.3 降低枚举维数:把一维交给预处理
很多题的枚举对象本身没错,只是"维数太高"。降维的三种典型手段:
- 前缀和/二维前缀和:把"枚举一段区间"变成"枚举两个端点"甚至"枚举一个端点"(例题 F、G);
- 哈希 / 桶计数 :把"再枚举一个对象去配对"变成 O ( 1 ) O(1) O(1) 查表(例题 G、L);
- 单调性 / 双指针 :当"随左端点右移,右端点也单调右移"时,内层循环的规模从 O ( n ) O(n) O(n) 摊还到 O ( 1 ) O(1) O(1)(例题 F)。
判断标准 :若内层循环的代码是"检查某个条件",就问"这个条件能不能用预处理的结果 O ( 1 ) O(1) O(1) 回答";若是"找一个满足条件的配对",就问"能不能用哈希/双指针"。
2.4 枚举"关键参数":把结构写出来
以例题 C(魔法阵)为例:题目要求找四个数 a < b < c < d a<b<c<d a<b<c<d 满足
b − a = 2 ( d − c ) b-a=2(d-c) b−a=2(d−c) 且 b − a < c − b 3 b-a<\dfrac{c-b}{3} b−a<3c−b。
- 直接枚举四个变量是 O ( n 4 ) O(n^4) O(n4);
- 观察到式子里的核心量是间隔 d − c d-c d−c。设 t = d − c t=d-c t=d−c,则 b − a = 2 t b-a=2t b−a=2t,且 c − b > 3 ⋅ 2 t = 6 t c-b>3\cdot2t=6t c−b>3⋅2t=6t;
- 于是枚举 t t t ( O ( n ) O(n) O(n)),再枚举两个"成对的"位置,用前缀和/计数 把剩余部分 O ( 1 ) O(1) O(1) 算出来,整体做到 O ( n 2 ) O(n^2) O(n2) 级别。
这一类题的通用步骤:
- 把所有等式/不等式写成"参数"形式(谁等于谁的几倍);
- 找出出现次数最多的那个量作为枚举对象;
- 剩下的量用"它决定了一个区间/一类对象"来聚合,用前缀和或计数数组处理。
2.5 例题 0:费解的开关------"枚举自由变量 + 递推"
5 × 5 5\times5 5×5 的灯阵,按下一盏灯会连带翻转它上下左右相邻 的灯,问能否在 6 6 6 步内把所有灯变亮。
枚举对象的选择 :直接枚举 25 25 25 个位置"按/不按",共 2 25 ≈ 3.3 × 10 7 2^{25}\approx3.3\times10^7 225≈3.3×107 种,太大。但注意
第 1 行的按键方案一旦确定,第 2 行就被唯一确定------因为第 1 行里还关着的灯,只能靠它正下方的按钮来翻亮;同理第 3 行由第 2 行确定......直到第 5 行。
于是:
- 枚举第 1 行的按键方案 (只有 2 5 = 32 2^5=32 25=32 种,这是"自由变量");
- 其余各行被唯一确定(每一行只负责"修好"上一行);
- 最后检查第 5 行是否也全亮(它已经没有"下一行"可以帮忙了)。
这就是"枚举 + 递推"的范式:把解空间里"真正自由的部分"枚举掉,其余部分用约束推出来。例题 H(机器人塔)是它的另一个形态(自由变量是最底下一行)。
2.6 减少枚举规模的三种思路
| 思路 | 做法 | 例子 |
|---|---|---|
| 只枚举一半 | 折半枚举(meet in the middle): 2 40 → 2 20 2^{40}\to2^{20} 240→220 | 大价值背包、四数之和 |
| 只枚举一边 | 排序后枚举一边,另一边二分/贪心 | 例题 I(红蓝牌) |
| 只枚举小的一边 | 值域小就枚举值域, n n n 小就枚举 n n n | 例题 A、D |
第三章 枚举的优化
枚举的优化有两个方向:减少枚举对象的数量(剪枝)与降低每次检查的代价(预处理)。 前者让循环变少,后者让循环体变轻。
3.1 方向一:剪枝------让枚举对象本身变少
- 可行性剪枝 :枚举过程中发现不可能成为答案就提前退出。
例:枚举子集求最大和时,若当前和已经是全局最大,剩余部分不用再枚举。 - 最优性剪枝:搜索类问题里用"当前解 + 乐观估计"与已有最优解比较(分支限界)。
- 上下界剪枝 :循环变量的取值范围由约束直接算出,而不是"从 1 到 n 都试一遍"。
例:求 a + b = N a+b=N a+b=N 的解,枚举 a a a 时只枚举到 N / 2 N/2 N/2(利用对称性),并可直接算出 b = N − a b=N-a b=N−a,省掉一层循环。 - 对称性去重 : ( i , j ) (i,j) (i,j) 与 ( j , i ) (j,i) (j,i) 等价时强制 i < j i<j i<j。
- 利用约束方程消元 : a + b + c = N a+b+c=N a+b+c=N 时只需枚举 a , b a,b a,b, c c c 由方程算出------这是"降一层循环"最常用的手段。
经验:先写朴素暴力,然后盯着最内层循环问:"这个变量真的需要循环吗?它是不是被其他变量唯一确定了?"
3.2 方向二:预处理------让检查变 O ( 1 ) O(1) O(1)
| 预处理手段 | 适用 | 效果 |
|---|---|---|
| 一维前缀和 | 区间和 | O ( n ) → O ( 1 ) O(n)\to O(1) O(n)→O(1) |
| 二维前缀和 | 子矩阵和 | O ( n m ) → O ( 1 ) O(nm)\to O(1) O(nm)→O(1) |
| 差分 | 区间加、单点查 | O ( n ) → O ( 1 ) O(n)\to O(1) O(n)→O(1) 每次修改 |
| ST 表 / 线段树 | 区间最值 | O ( n ) → O ( 1 ) O(n)\to O(1) O(n)→O(1)(静态,ST 表) |
| 哈希表 / 桶 | "是否存在配对"、"同余类计数" | O ( n ) → O ( 1 ) O(n)\to O(1) O(n)→O(1) |
| 排序 + 二分 | "找第一个满足条件的" | O ( n ) → O ( log n ) O(n)\to O(\log n) O(n)→O(logn) |
| 单调队列 / 双指针 | 窗口最值与窗口计数 | 内层 O ( n ) → O(n)\to O(n)→ 摊还 O ( 1 ) O(1) O(1) |
核心用法:前缀和 + 计数数组 = 把"枚举两个端点"降成"枚举一个端点 + O ( 1 ) O(1) O(1) 查表"。
例题 G(入阵曲)是这条路的代表:求"子矩阵和是 K K K 的倍数"的个数。
- 二维前缀和后仍需枚举 O ( n 2 m 2 ) O(n^2m^2) O(n2m2) 个矩形;
- 但"和是 K K K 的倍数" ⟺ \iff ⟺ 两个前缀和对 K K K 同余;
- 于是固定上下边界后,只需扫一遍列,用桶统计每类余数出现了几次 ,答案加上 ( c 2 ) \binom{c}{2} (2c) 的累加即可。
复杂度 O ( n 2 m ) O(n^2m) O(n2m)(把 n , m n,m n,m 中较小的作为"行"枚举,可再优化为 O ( min ( n , m ) 2 max ( n , m ) ) O(\min(n,m)^2\max(n,m)) O(min(n,m)2max(n,m)))。
3.3 利用单调性把内层循环"摊还"掉
当出现下面的结构时,可以把内层循环从 O ( n ) O(n) O(n) 降到摊还 O ( 1 ) O(1) O(1):
枚举左端点 l l l 时,满足条件的右端点 r r r 随 l l l 增大而单调不减。
理由是 r r r 只会向右移动 n n n 次,总移动次数 O ( n ) O(n) O(n),所以双指针整体是 O ( n ) O(n) O(n)。
例题 F(统计子矩阵)就是这样:给定 K K K,统计元素和 ≤ K \le K ≤K 的子矩阵个数。
- 元素非负 ⇒ \Rightarrow ⇒ 固定上边界 u u u、下边界 d d d 后,"列上的区间和"关于左右端点单调;
- 于是用双指针扫一遍列, O ( m ) O(m) O(m) 统计;
- 总复杂度 O ( n 2 m ) O(n^2m) O(n2m)( n , m ≤ 500 n,m\le500 n,m≤500 时约 1.25 × 10 8 1.25\times10^8 1.25×108 次轻量操作,注意常数)。
3.4 枚举的顺序也很重要
- 先排序再枚举:能把"是否存在配对"变成"双指针"或"前缀和 + 二分",也是贪心的前提(例题 I);
- 按值域枚举 vs 按下标枚举 :值域小(如 ≤ 10 3 \le10^3 ≤103、 ≤ 10 6 \le10^6 ≤106)优先按值域(例题 A、D);
- 从大到小 or 从小到大:涉及"找第一个满足条件的"时,顺序决定了能否用单调性。
3.5 枚举 + 数据结构 = 大多数"正解"
把上面几节合起来,本手册所有例题几乎都是下面这条主线的一个实例:
确定枚举对象 → 想清楚复杂度 → 用预处理/单调性/哈希把内层消掉 → 得到正解
| 题目 | 枚举对象 | 内层怎么消 |
|---|---|---|
| A Lucky PIN | 答案( 10 3 10^3 103) | 贪心扫一遍 S S S |
| B 插入排序 | 被修改的那一个数 | 直接推导名次变化 |
| C 魔法阵 | 间隔 t t t + 一个位置 | 前缀和/计数数组 |
| E 杨辉三角形 | 位置参数(列号 k k k) | 二分 n n n + 组合数剪枝 |
| F 统计子矩阵 | 上下边界 | 双指针 |
| G 入阵曲 | 上下边界 | 前缀和取模 + 桶 |
| H 机器人塔 | 最底下一行的排布( 2 h 2^h 2h) | 其余各层递推唯一确定 |
| I 红蓝牌 | 一侧子集 | 另一侧排序取前 r r r 大 |
| J 区间操作 | 被问到的左端点 | 前缀和 + 离线 + 复用 |
| D 取模 | (不枚举) | 数论等价刻画 |
第四章 子集枚举
4.1 用二进制表示集合
n n n 个元素的集合有 2 n 2^n 2n 个子集。把第 i i i 个元素"选/不选"记为 1 / 0 1/0 1/0,则每个子集对应一个 n n n 位二进制数(位掩码):
mask = 0b1011 -> 选中了第 0、1、3 号元素
基础操作(务必背熟):
| 目的 | 写法 |
|---|---|
| 判断第 i i i 位是否为 1 | mask >> i & 1 或 mask & (1<<i) |
| 把第 i i i 位设为 1 | `mask |
| 把第 i i i 位设为 0 | mask &= ~(1<<i) |
| 取反(在 n n n 位内) | mask ^ ((1<<n)-1) |
| 子集元素个数 | __builtin_popcount(mask) |
| 最低位的 1 | mask & (-mask) |
| 消掉最低位的 1 | mask &= mask - 1 |
4.2 枚举所有子集: O ( 2 n ) O(2^n) O(2n)
cpp
for (int mask = 0; mask < (1 << n); ++mask) {
// 处理子集 mask
}
n ≤ 20 n\le20 n≤20 时 2 20 ≈ 10 6 2^{20}\approx10^6 220≈106,非常轻松; n ≤ 24 n\le24 n≤24 时约 1.7 × 10 7 1.7\times10^7 1.7×107,也可以; n ≥ 27 n\ge27 n≥27 就要换方法(折半枚举)。
4.3 枚举一个集合的所有子集: O ( 3 n ) O(3^n) O(3n)
在很多"分组/划分"类题目里,需要枚举 m a s k mask mask 的所有子集:
cpp
for (int sub = mask; ; sub = (sub - 1) & mask) {
// sub 取遍 mask 的所有子集(含 0 与 mask 本身)
if (sub == 0) break;
}
这段循环的均摊复杂度是 O ( 3 n ) O(3^n) O(3n)(每个元素有三种状态:不在 m a s k mask mask 中 / 在 m a s k mask mask 但不在 s u b sub sub 中 / 在 s u b sub sub 中)。这是子集 DP(如"划分成若干组"、"最小斯坦纳树")的基础。
4.4 枚举固定大小的子集:Gosper's Hack
需要枚举"所有大小为 k k k 的子集"时,逐个 popcount 过滤太慢,可以用 Gosper's Hack 直接跳到下一个:
cpp
// 枚举所有 popcount == k 的 n 位掩码(k>=1)
int mask = (1 << k) - 1;
while (mask < (1 << n)) {
// 处理 mask
int c = mask & -mask;
int r = mask + c;
mask = (((r ^ mask) >> 2) / c) | r;
}
4.5 折半枚举(Meet in the Middle)
当 n n n 到 40 40 40 左右、 2 n 2^n 2n 不可行时:把元素分成两半,各自枚举 2 n / 2 2^{n/2} 2n/2 个子集并存下它们的贡献 (排序/哈希),然后在两半之间配对。
典型: n = 40 n=40 n=40 的子集和问题------两半各 2 20 ≈ 10 6 2^{20}\approx10^6 220≈106,排序后双指针/二分配对,总复杂度 O ( 2 n / 2 log 2 n / 2 ) O(2^{n/2}\log 2^{n/2}) O(2n/2log2n/2)。
4.6 补充例题 L:用子集枚举解决"选或不选"类问题
模型 1(枚举 + 判断) :有 n n n 个开关,每个开关控制若干盏灯,问是否存在方案让所有灯都灭。
👉 枚举 2 n 2^n 2n 个开关组合,逐位模拟, O ( 2 n ⋅ m ) O(2^n\cdot m) O(2n⋅m)。 n ≤ 20 n\le20 n≤20 可行。
模型 2(枚举 + 计数去重) :统计"由给定数字集合能拼出的不同数"的个数。
👉 枚举子集,把选出的数字排序后放进 set 去重。
模型 3(枚举 + 递推,呼应例题 0):状态空间巨大但"自由变量"很少时,只枚举自由变量(如费解的开关的第一行),其余部分递推。
模型 4(枚举 + 容斥) :求"至少/恰好"的计数时,枚举子集做容斥:
∣ ⋃ A i ∣ = ∑ ∅ ≠ S ( − 1 ) ∣ S ∣ + 1 ∣ ⋂ i ∈ S A i ∣ \Big|\bigcup A_i\Big|=\sum_{\emptyset\ne S}(-1)^{|S|+1}\Big|\bigcap_{i\in S}A_i\Big| ⋃Ai =∅=S∑(−1)∣S∣+1 i∈S⋂Ai
用 mask 枚举 S S S,每个 S S S 判断正负号(popcount 的奇偶)。
第五章 识别手册与易错点
5.1 看见这些,先想枚举(以及枚举什么)
| 题目信号 | 枚举对象 | 优化方向 |
|---|---|---|
| "有多少种不同的......"且取值范围小 | 答案本身(值域枚举) | 判断可行性用贪心/检查 |
| "统计满足条件的子矩阵/子区间个数" | 上下(左右)边界 | 二维前缀和 + 双指针/桶 |
| "子数组和是 K K K 的倍数 / 满足同余" | 前缀和的余数 | 桶计数 |
| 出现"四个数满足某些等式" | 间隔/比例参数 | 前缀和 + 计数数组 |
| n ≤ 20 n\le20 n≤20 | 子集(位掩码) | O ( 2 n ) O(2^n) O(2n)、 O ( 3 n ) O(3^n) O(3n)、折半 |
| n ≤ 40 n\le40 n≤40 | 折半后的子集 | meet in the middle |
| 有"每次修改后询问排名/相对大小" | 只枚举被修改的元素 | 直接推导名次变化 |
| 有"取模"、"某个不变量" | 取模后的状态 | 状态数有限 ⇒ \Rightarrow ⇒ 直接枚举状态 |
| 图形/规则生成的"塔、三角形、棋盘" | 自由变量(其余由规则决定) | 递推 |
| 两个数组配对求最优 | 排序后的配对位置 | 贪心(排序 + 依次配对) |
5.2 复杂度速查:枚举几层能过?
| 循环层数 | 典型规模 | 能否通过 |
|---|---|---|
| 4 层 | n ≤ 100 n\le100 n≤100 | 可以( 10 8 10^8 108 以内) |
| 3 层 | n ≤ 500 n\le500 n≤500 | 可以 |
| 2 层 | n ≤ 5000 n\le5000 n≤5000 | 可以 |
| 2 层 + 内层 O ( 1 ) O(1) O(1) 查询 | n , m ≤ 2 × 10 5 n,m\le2\times10^5 n,m≤2×105 | 可以(约 4 × 10 10 4\times10^{10} 4×1010 不行!需要 O ( n log n ) O(n\log n) O(nlogn)) |
| 2 n 2^n 2n | n ≤ 20 n\le20 n≤20(勉强 24) | 可以 |
| 3 n 3^n 3n | n ≤ 13 n\le13 n≤13 左右 | 可以 |
注意最后一行的陷阱: n = 2 × 10 5 n=2\times10^5 n=2×105 的双重 循环是 4 × 10 10 4\times10^{10} 4×1010,不行 !只有"一重枚举 + O ( 1 ) O(1) O(1) 查询"或"一重枚举 + 双指针"才行。
5.3 易错点清单
枚举范围类
- 上界写成
<n但答案可能取到 n n n(长度、下标、数量都容易犯); - 忘记空集/空操作/长度为 0 这些退化情况;
- 有重复元素时忘记去重(要"不同的值"却按"不同的下标"计数)。
重复计数类
- 无序对按有序枚举(应强制 i < j i<j i<j),或反之;
- 子集枚举时把"同一个集合的不同枚举顺序"算成多个方案;
- 容斥时符号写错( ∣ S ∣ |S| ∣S∣ 的奇偶)。
溢出类
- 计数答案超
int( ( 5000 2 ) ≈ 1.2 × 10 7 \binom{5000}{2}\approx1.2\times10^7 (25000)≈1.2×107 还在int内,但 ( 2 × 10 5 2 ) ≈ 2 × 10 10 \binom{2\times10^5}{2}\approx2\times10^{10} (22×105)≈2×1010 就超了); - 乘法先加后乘要
1LL:(long long)a * b; - 需要取模的题忘记取模,或取模后比较大小(比较不能取模!)。
优化类
- 双指针写错单调性方向(右指针回退会导致复杂度退化到 O ( n 2 ) O(n^2) O(n2));
- 前缀和下标从 0 还是 1 开始混用(
pre[r]-pre[l-1]与pre[r+1]-pre[l]); - 取模后的桶计数:余数范围是 0 , K − 1 0,K-1 0,K−1,数组要开 K K K 大小; K K K 很大时用哈希表;
- 位运算优先级:
mask & 1 == 1是错的(==优先级高于&),要写(mask & 1) == 1; 1 << n在 n ≥ 31 n\ge31 n≥31 时溢出(用1LL << n)。
第六章 例题分析
每题统一结构:题意 → 枚举对象的选择 → 优化 → 复杂度 → 易错点 。
标 ★ 的是这道题的"灵光点"。
第八章 反例与对照:什么时候不该枚举
本章专门收录"看起来该枚举、其实不该"的题型------判断"不该枚举"和"会枚举"同样重要。
8.1 反例一:P8807 取模(例题 D)
- 假象 :题目问"是否存在两个不同的 x , y ≤ m x,y\le m x,y≤m 使 n m o d x = n m o d y n\bmod x=n\bmod y nmodx=nmody",第一反应是"枚举 x x x 算余数、用桶判重", O ( m ) O(m) O(m)。
- 现实 : m ≤ 10 9 m\le10^9 m≤109,且 T ≤ 10 5 T\le10^5 T≤105,枚举必爆。
- 正解 : O ( 1 ) O(1) O(1) 判定------无解 ⟺ lcm ( 1.. m ) ∣ ( n + 1 ) \iff\operatorname{lcm}(1..m)\mid(n+1) ⟺lcm(1..m)∣(n+1) ,且 m ≥ 23 m\ge23 m≥23 时必然有解。
- 教训 :枚举的前提是"对象规模可承受" 。规模是 10 9 10^9 109 时,要找等价刻画、有限状态(抽屉原理)或阈值,而不是继续优化枚举。
8.2 反例二:P11475 红蓝牌(例题 I)
- 假象 :"选若干红牌和若干蓝牌" ⟹ 枚举两侧子集, 2 N + M 2^{N+M} 2N+M。
- 现实 : N , M ≤ 21 N,M\le21 N,M≤21 ⟹ 2 42 2^{42} 242 不可行。
- 正解 :固定一侧后另一侧的最优选择可以直接算 (贡献可分离 ⟹ 排序取前 r r r 大),于是只枚举一侧: 2 min ( N , M ) = 2 21 2^{\min(N,M)}=2^{21} 2min(N,M)=221。
- 教训 :"两侧都枚举"几乎总是可以砍成"枚举一侧 + 另一侧贪心/查询"。
8.3 反例三:P10401 区间操作(例题 J)
- 假象 :"所有区间和的异或" ⟹ 按二进制位拆开统计( ⊕ \oplus ⊕ 常与按位思路配套)。
- 现实 :按位拆解是错的 ------区间和 S k − S l − 1 S_k-S_{l-1} Sk−Sl−1 的第 b b b 位受低位借位影响,不能独立统计。反例: a a a 全为 1 时 ( 1 , 4 ) (1,4) (1,4) 与 ( 2 , 5 ) (2,5) (2,5) 的项完全相同(真值都是 4),按位思路给出 4 和 0。
- 正解 :前缀和 + 离线只枚举被问到的左端点 , O ( n 2 + q log q ) O(n^2+q\log q) O(n2+qlogq)。
- 教训 :"看起来能按位独立"必须验证(自己造小数据对拍),否则会被"漂亮的假证"骗到。
8.4 反例四:什么时候"枚举"就是最优解
| 情形 | 为什么枚举是对的 |
|---|---|
| 答案值域很小(例题 A: 10 3 10^3 103 种) | 枚举答案 + O ( N ) O(N) O(N) 判定,常数极小、代码极短 |
| 子集规模 n ≤ 20 n\le20 n≤20(例题 L) | 2 n 2^n 2n 只有 10 6 10^6 106,比任何"聪明算法"都稳 |
| 需要"枚举 + 递推"(例题 0、H) | 自由变量少,其余由约束唯一确定 |
| 数据范围小( n ≤ 100 n\le100 n≤100)且时限宽松 | 写暴力最省时间,把时间留给其他题 |
8.5 一张决策表:枚举 / 换对象 / 不枚举
数据范围先算一遍
├─ 暴力复杂度 × 最坏数据 ≤ 10^8?
│ ├─ 是 → 直接写暴力(注意不重不漏),别浪费时间想优化
│ └─ 否 → 继续
├─ 答案/状态的取值范围很小(≤10^6)?
│ ├─ 是 → 换枚举对象:枚举答案/状态 + O(1)~O(N) 判定(例题 A、D)
│ └─ 否 → 继续
├─ 内层循环在做"求和/求最值/找配对"?
│ ├─ 是 → 前缀和 / ST 表 / 哈希 / 双指针 / 二分(例题 C、F、G、J)
│ └─ 否 → 继续
├─ 枚举对象是"两侧各选一些"?
│ ├─ 是 → 只枚举一侧 + 另一侧贪心/查询(例题 I)
│ └─ 否 → 继续
└─ 规模是 10^9 级的连续量(不是组合对象)?
└─ 是 → 这题多半不是枚举题:找等价刻画 / 数学性质 / 数据结构(例题 D)
第七章 训练建议
7.1 训练顺序
| 阶段 | 题目 | 目标 |
|---|---|---|
| 1 | A Lucky PIN、B 插入排序 | 学会"换枚举对象"和"只枚举被改动的部分" |
| 2 | 0 费解的开关、H 机器人塔 | 学会"枚举自由变量 + 递推" |
| 3 | L 子集枚举专题 | 熟练位运算与容斥 |
| 4 | G 入阵曲、F 统计子矩阵 | 学会"前缀和 + 桶/双指针" |
| 5 | C 魔法阵 | 学会"枚举关键参数 + 前缀和" |
| 6 | E 杨辉三角形、I 红蓝牌、J 区间操作 | 综合运用 |
7.2 训练方法
- 先写暴力,再优化 :每道题都先写出"不重不漏"的 O ( 爆 ) O(\text{爆}) O(爆) 暴力,并用它验证优化后的代码(对拍 1000 组随机数据)。
- 写复杂度预算 :动手前在纸上写"我的枚举是 O ( ? ) O(?) O(?), n n n 最大是 ? ? ?,乘积是 ? ? ?",超了就换对象。
- 背模板:二维前缀和、双指针、桶计数、位运算子集枚举------这四段代码要能默写。
- 整理"换对象"清单:把自己做过的题按"枚举对象"分类(枚举答案/枚举参数/枚举边界/枚举子集/枚举配对),考试时按清单搜索。
7.3 下一步
- 搜索:枚举 + DFS/BFS + 剪枝(迭代加深、A*、IDA*);
- 状压 DP :子集枚举的进阶( O ( 3 n ) O(3^n) O(3n)、 O ( 2 n ⋅ n ) O(2^n\cdot n) O(2n⋅n)、轮廓线 DP);
- 折半枚举 与随机化枚举(随机打乱 + 贪心多次);
- 枚举 + 数据结构:把本手册的"桶/前缀和"换成线段树、树状数组、平衡树。