60 节 · 按算法应用划分 · 全部代码编译通过

算竞教程 · 合集

每节一个完整闭环:新函数拆解 → 例题(题面 / 思路 / 完整代码 / 逐行拆解)→ 易错点 → 数学补给 → 举一反三 → 自测 → 节末挑战题。

60 节(含 2 场模拟赛、10 节回顾)· 3 份题解 · 489 段代码
约 36 万中文字 · 135 个洛谷题号 · 单文件离线可用
图例 ⭐ 已学范围(30 天课程内已覆盖) · 难度 参考洛谷难度标签 · 回顾 每 4~5 节一次的 5 题综合训练 · 模拟赛 限时整套题
第一组 · 起步:让程序按你说的做
第二组 · 数组的两种用法:值域与网格
第三组 · 函数与排序:把代码变成工具
第四组 · 贪心与前缀和:局部最优通向全局最优
第五组 · 递归与搜索:把大问题拆小
第六组 · BFS、二分与容器
第七组 · 动态规划:用表代替重复计算
第八组 · 数论:整除、同余与计数
第九组 · 图论:最短路、生成树与拓扑
第十组 · 进阶数据结构
第十一组 · 字符串与倍增
第十二组 · 进阶专题
题解与模拟赛
怎么打开

直接双击 index.html 用浏览器打开即可,全部是纯静态页面,不需要联网、不需要服务器。所有题目链接会跳到洛谷题面页(需联网)。

进度会自动记在浏览器的 localStorage 里,换浏览器不共享。

怎么用这套教程
① 每节先看新函数拆解,看懂每个参数是什么;② 跟着例题的思路读代码,读完后关掉页面自己敲一遍;③ 做节末挑战题,先只看提示,卡住 20 分钟再看思路;④ 做完回顾章再往下走,不要跳章。
怎么用这个合集
① 左侧目录按 12 个主题组排列,点任意一节直接进入;
② 顶部搜索框可搜「二分答案」「线段树」「P3817」这类关键词(索引来自原目录的关键词标签);
③ 每节读完点右上角「标记已学完」,进度存在浏览器本地;
④ 侧栏底部 🌙 切深色模式;
⑤ 键盘 ← → 翻节;
⑥ 首页保留原目录的分组卡片与难度标签,可直接点进任一节。
01 输入输出与评测机制入门

01 · 输入输出与评测机制

把“题目 → 代码”这条最短链路打通:知道评测机在比什么,掌握 cin/cout 的结构与用法,并第一次建立“数据范围决定变量类型”的意识。
⭐ 已学范围入门预计 45 分钟cin/coutgetline整数范围评测机制
本节出现的新函数 / 新语法(先看懂再用)

先接受一个事实:评测机不理解你的程序,它只做一件事——把你输出的字节和标准答案的字节逐字比对。所以从第一天起,你写的每一行输出都要能回答一个问题:它会不会出现在标准答案里?

例题 · A+B Problem洛谷 P1001入门去洛谷做这题 ↗

输入两个整数 a、b(|a|,|b| ≤ 10⁹),输出它们的和。

输入: 2 3 输出: 5

这一节会出现 4 个必须彻底搞懂的东西。先把它们的“结构”拆开看清,后面所有代码都靠它们。

新函数拆解 · cin(标准输入流)
std::cin >> 变量1 >> 变量2 >> ...;
组成含义 / 格式要求
std::cin标准输入流对象,代表“键盘/文件”。程序中只有一个,直接拿来用。
>>提取运算符:把输入里的下一个“词”取出来,放进右边变量。
链式写法 cin >> a >> b 等价于 (cin>>a)>>b,读第一个数给 a,读第二个给 b。
跳过空白自动跳过空格、Tab、换行,所以数据分行写也能正确读入。
类型自动匹配给 int 读整数,给 double 读小数,给 char 读一个非空白字符。
最小可运行示例
示例
#include <iostream>          // cin / cout 都在这个头文件
using namespace std;     // 展开 std 命名空间,之后可以直接写 cin

int main() {
    int a, b;
    cin >> a >> b;       // 一次读两个整数
    cout << a + b;
    return 0;
}
注意

读入类型必须和变量类型一致:给 int 变量输入“abc”,cin 会失败并让后续读入全部失效。

cin 不会检查你有没有读够。数据比预期少时,后面的变量保持原值(局部变量是随机值)。

新函数拆解 · cout(标准输出流)
std::cout << 内容1 << 内容2 << ...;
组成含义 / 格式要求
std::cout标准输出流对象,代表“屏幕/文件”。
<<插入运算符:把右边的内容转成字符,送进输出流。
'\n'换行符,一个字符,不刷新缓冲区,速度最快。
endl换行 + 立刻刷新缓冲区。循环里大量用会慢 3~10 倍。
格式控制cout << fixed << setprecision(2) 固定两位小数,需要 #include 。
最小可运行示例
示例
cout << a << " " << b << "\n";     // 输出 a、空格、b、换行
cout << "ans = " << ans << "\n";    // 文字和数字可以混着插
cout << fixed << setprecision(3) << 1.0 / 3;   // 0.333
注意

cout 不需要写格式符(不像 printf 要 %d、%lf),类型对就行。

循环十万次以上时,用 '\n' 而不是 endl。

新函数拆解 · ios::sync_with_stdio(false) 与 cin.tie(nullptr)
ios::sync_with_stdio(false); cin.tie(nullptr);
组成含义 / 格式要求
sync_with_stdio(false)关闭 C 流(scanf/printf)与 C++ 流(cin/cout)的同步。关掉后 cin 快 3~5 倍。副作用:此后不能再混用 scanf 和 cin。
cin.tie(nullptr)解除 cin 对 cout 的绑定。默认每次 cin 前会先刷新 cout,解除后少一次刷新,更快。
放在哪必须写在 main 开头、任何输入输出之前,写一次就够。
最小可运行示例
示例
int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n; cin >> n;              // 读 10^6 个数也不会慢
    for (int i = 0; i < n; ++i) { int x; cin >> x; }
    return 0;
}
注意

只有输入量很大(≥ 10⁵ 个数)时才明显有用,但养成习惯没坏处。

关同步后不要再用 scanf/printf,否则读写顺序会乱。

新函数拆解 · getline(读入一整行)
std::getline(std::cin, s);
组成含义 / 格式要求
std::cin从哪个流读。
sstring 变量,读到的整行内容存这里(不含结尾的换行符)。
读到哪里停读到 '\n' 为止,并且把 '\n' 从流里取走丢掉。
常见搭配当输入含空格时必须用它;cin >> 只会读到一个“词”。
最小可运行示例
示例
string line;
getline(cin, line);              // "hello world" 整行读进来

int n; cin >> n;
cin.ignore();                    // ★ 先吃掉 n 后面残留的换行
getline(cin, line);              // 否则这里读到的是空串
注意

cin >> n 之后直接 getline,会读到空串——因为换行还留在缓冲区里。

解决办法:中间插一句 cin.ignore()。

例题怎么想:把题目翻译成 3 行代码

题目要做的事只有一件:读两个数,加起来,输出。

所以代码必然是三段:声明变量 → 读入 → 计算并输出。先把这三段写出来,再考虑细节。

完整代码
#include <iostream>
using namespace std;

int main() {
    int a, b;              // ① 声明:两个整数变量,初始是随机值
    cin >> a >> b;         // ② 读入:按顺序把前两个数放进 a、b
    cout << a + b << "\n"; // ③ 计算并输出,末尾换行
    return 0;
}
逐行拆解
代码片段含义与理由
#include <iostream>引入输入输出库。没有它,cin/cout 都不存在,编译报错。
using namespace std;把 std 命名空间展开,于是能写 cin 而不是 std::cin。竞赛里人人这么写。
int main()程序入口。评测系统从这里开始执行,return 的值表示是否正常结束。
int a, b;两个 32 位整数。范围 ±2.1×10⁹,本题 |a|,|b| ≤ 10⁹ 够用。
cin >> a >> b;先读第一个数给 a,再读第二个数给 b。空格、换行都会被自动跳过。
cout << a + b << "\n";先算 a+b,再把结果转成字符串插入输出流,最后换行。
return 0;告诉评测系统“正常结束”。不写多数编译器也会补,但建议写。
这一节最容易犯的 4 个错

输出提示语。写 cout << "请输入两个数:" 会直接 WA——因为这句话会被当成答案的一部分。

用 int 存超出范围的结果。本题 a+b 最大 2×10⁹,刚好卡在 int 上限(2.147×10⁹)附近;题目若改成 10¹⁸ 就必须用 long long。

忘记换行。多数题对行末换行不敏感,但有些题严格比对,养成加 "\n" 的习惯。

多打空格。行末多一个空格,严格评测(如 NOIP)会判 WA。

自己动手复现

把上面的完整代码不看页面敲一遍,提交到 P1001。

然后把输入改成 1000000000 1000000000,看输出是否为 2000000000。

如果输出是负数,说明你把变量改成了 short —— 亲手体验一次溢出。

数学补给 · 为什么 int 的上限是 2.1×10⁹

int 占 4 字节 = 32 位。其中 1 位是符号位,剩下 31 位表示大小。

最大值 = 2³¹ − 1 = 2147483647 ≈ 2.14×10⁹。最小值 = −2³¹ = −2147483648。

类型字节范围十进制位数
int4±2.14×10⁹10 位
long long8±9.22×10¹⁸19 位
unsigned long long80 ~ 1.84×10¹⁹20 位

记住“19”这个数:long long 最多表示 19 位十进制整数。超过 19 位就必须用高精度(字符串/数组模拟)。

推论:只要题目里出现“求和”“相乘”,先用草稿纸估一下最坏情况是多少,超过 2×10⁹ 立刻换 long long。

举一反三 · 想清楚这些再往下走
  1. 如果题目要求“先输出 a,再输出 b,中间用逗号分隔、行末不要空格”,cout 那句该怎么写?试着只改一行。
  2. 把 int 换成 double 读入 1 2,输出会变成什么样?为什么会多出小数?
  3. 如果输入是“10 个数,每行一个”,用 cin >> x 循环 10 次能正确读入吗?为什么?
  4. 为什么 endl 会比 "\n" 慢?慢在哪一步?
  5. 评测机是怎么比较你的输出和标准答案的?它会不会忽略多余空格?
自测:能全部回答再进下一节
  • 能不看资料写出“读两个数、输出和”的完整程序。
  • 知道 cin 与 getline 的区别,以及什么时候必须加 cin.ignore()。
  • 能说出 int 与 long long 的位数上限,并判断一道题该用哪个。
  • 知道为什么不能输出任何提示语。
挑战题 · 小鱼的游泳时间
洛谷 P1425入门去洛谷做这题 ↗

小鱼从 a 时 b 分游到 c 时 d 分,中间没有休息。求它一共游了多少小时多少分钟。

输入一行 4 个整数 a、b、c、d(0 ≤ a,c ≤ 24,0 ≤ b,d ≤ 59),保证结束时刻晚于开始时刻。

输出一行两个整数,表示共游的小时数和分钟数。

引导性提示(先只看这里)

不要“小时减小时、分钟减分钟”,那样会遇到分钟不够减的情况。

把所有时间先统一换成“从 0 点开始算的第几分钟”,相减后再拆回小时和分钟。

拆法:总分钟 ÷ 60 得到小时,总分钟 % 60 得到分钟。

想看思路了?点开完整思路

开始时刻 = a × 60 + b(分钟)。结束时刻 = c × 60 + d。

总时长 = 结束 − 开始。

输出 总时长 / 60 和 总时长 % 60。

为什么这样一定对:分钟制是一个“统一单位”,换算后相减等价于时间差,不受进位影响。

还是不会?点开题解代码与拆解
参考代码
#include <iostream>
using namespace std;

int main() {
    int a, b, c, d;
    cin >> a >> b >> c >> d;

    int start = a * 60 + b;      // 开始时刻换算成分钟
    int end   = c * 60 + d;      // 结束时刻换算成分钟
    int span  = end - start;     // 总时长(分钟)

    cout << span / 60 << " " << span % 60 << "\n";
    return 0;
}
关键片段拆解
片段作用
a * 60 + b把“a 时 b 分”压成一个整数。这一步把二维的时间变成一维的数,后面就能直接相减。
end - start统一单位后相减一定正确,不用管分钟借位。
span / 60整数除法,向下取整,正好是完整的小时数。
span % 60余数就是不足一小时的分钟数。
<< " " <<两个数之间用一个空格隔开,行末只换行不加空格。
这题最容易错的地方
  • 图省事写成 d - b 和 c - a,遇到 “1 50 → 2 20” 这种分钟不够减的情况就错。
  • 用 %02d 输出前导零——题目没要求,输出 01 会 WA。
  • 忘了最后换行。
延伸阅读 / 参考
  • 《算法竞赛入门笔记》第 2 章:输入输出与评测
  • 洛谷 P1001、P1425 题解区的高赞思路
02 循环、累加与整数溢出入门

02 · 循环、累加与整数溢出

把“算一次”升级成“算一百万次”:用 for/while 把重复劳动交给机器,用累加器收集结果,并在动手之前算清这个和会不会超过 2.1×10⁹。
⭐ 已学范围入门预计 50 分钟for/whilelong long溢出
本节出现的新函数 / 新语法(先看懂再用)

第 1 节的程序只做一次加法,这一节让它做一百万次。循环要想清楚三件事:从哪开始、什么时候停、每轮做什么;累加只想一件事:这个和最大能到多少。前者决定快不快(TLE),后者决定对不对(WA)。

先算最坏情况的上界,再写代码。

两个数先摆着:1+2+…+65536 = 2147516416,比 int 上限 2147483647 大 32769,存进 int 就是负数 -2147450880;而本节的例题要循环 183 万次。循环次数和数值范围是两件事。

新函数拆解 · for 循环
for (初始化; 条件; 步进) { 循环体 }
组成含义 / 格式要求
for两个分号隔出三段,分号不能省。
初始化进循环前执行一次,i 只在循环内有效。
条件每轮开始前检查。i <= n 配 i 从 1 起是 n 轮。
步进每轮末尾执行,这里 ++i 与 i++ 等价。
循环体只有一句也写花括号,以后加行不会错。
最小可运行示例
示例
long long sum = 0;
for (int i = 1; i <= 100; ++i) sum += i;        // 5050
for (int i = 0; i < 5; ++i) cout << i << " ";   // 0 1 2 3 4
注意

条件别写 i != n 配 i += 2:步进跨过 n 就永远退不出。

新函数拆解 · while 循环(含 break / continue)
while (条件) { 循环体 }
组成含义 / 格式要求
while每轮开始前检查条件,为真再转一圈。
何时用次数未知用它,次数已知(数 1..n)用 for。
改变量循环体里必须让条件最终变假,否则死循环 → TLE。
break跳出整个循环,适合“找到就停”。
continue跳过本轮剩余语句,for 会先做步进。
最小可运行示例
示例
double s = 0; int n = 0;
while (s <= 15) { ++n; s += 1.0 / n; }     // 退出时 n = 1835421
int x; while (cin >> x) { if (x < 0) break; cout << x << " "; }
注意

++n 和 s += 1.0 / n 换个顺序,答案就差 1。

新函数拆解 · 累加器(sum += x)
long long sum = 0; for (...) sum += x;
组成含义 / 格式要求
初值 0加法的单位元。局部变量不初始化是随机值。
类型要装得下最终结果:上界 = 项数 × 单项最大值。
计数器只加 1 的累加器,初值也是 0。
求积初值是 1,写成 0 结果永远为 0。
最小可运行示例
示例
long long sum = 0;
for (int i = 1; i <= n; ++i) sum += i;      // n = 1e9 时约 5×10^17
long long prod = 1;
for (int i = 1; i <= 20; ++i) prod *= i;    // 20! = 2432902008176640000
注意

求和 0、求积 1、求最小值 INT_MAX,都是让第一次运算不改变结果。

新函数拆解 · INT_MAX 与 LLONG_MAX
int INT_MAX; long long LLONG_MAX;
组成含义 / 格式要求
INT_MAX2147483647,求最小值的初值。
INT_MIN-2147483648,求最大值的初值。
LLONG_MAX9223372036854775807,19 位。
用法编译期常量,可直接输出。
只当边界别做运算:INT_MAX + 1 溢出成 INT_MIN。
最小可运行示例
示例
int mn = INT_MAX;
for (int i = 1; i <= n; ++i) { int x; cin >> x; if (x < mn) mn = x; }
cout << INT_MAX << " " << LLONG_MAX << "\n";   // 2147483647 9223372036854775807
注意

两个上限都是 2 的幂减一,末位一定是 7。记“19 位”比记数字实用。

新函数拆解 · printf 与 %lld
printf("%lld\n", 变量);
组成含义 / 格式要求
%lldlong long 的格式符,两个 l 表示 64 位。
%dint 的。给 long long 用只取低 32 位,输出垃圾。
%.2fdouble 保留两位小数。
\nprintf 不会自动换行。
别混用关掉 sync_with_stdio 后不要混用 printf 和 cout。
最小可运行示例
示例
long long s = 5000050000LL;
printf("%lld\n", s);        // 5000050000
printf("%d\n", (int)s);     // 705082704  ← 截断后的残值
注意

洛谷是 64 位 Linux,%d 读 long long 静默截断:不报错,只 WA。

五件东西拼起来,够做一道真题了。

例题 · 级数求和洛谷 P1035入门去洛谷做这题 ↗

已知 S_n = 1 + 1/2 + … + 1/n。n 足够大时,S_n 能超过任意给定的整数 k。

给定 k(1 ≤ k ≤ 15),求最小的 n,使 S_n > k。

输入格式:一行一个正整数 k。输出格式:一行一个正整数 n。

输入: 1 输出: 2
思路:部分和只能一项一项加

部分和没有公式,只能一次加一项:一个 double 累加器 + 一个次数未知的 while。

停的条件写 s <= k:还没超过就继续。题目要 S_n > k,等于 k 时还得再加一项。

先 ++n 再 s += 1.0 / n,让“加进去的项”和“当前的 n”始终对应;反过来写,输出得改成 n−1。

手算验证:k = 1 时 S_1 = 1 不够、S_2 = 1.5 够,答案 2;k = 2 时 S_3 ≈ 1.833、S_4 ≈ 2.083,答案 4。

完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int k;
    cin >> k;                  // 1 <= k <= 15

    double s = 0.0;            // 累加器:部分和,必须是浮点
    int n = 0;                 // 已经加了多少项

    while (s <= k) {           // 还没超过 k,就再补一项
        ++n;                   // 先定下“这一轮加第 n 项”,顺序不能反
        s += 1.0 / n;          // 写 1.0 才是浮点除法;写 1 / n 会永远停不下来
    }

    cout << n << "\n";         // 此时 s 第一次超过 k,n 就是最小解
    return 0;
}
逐行拆解
代码片段含义与理由
#include <bits/stdc++.h>万能头,顶掉 iostream、climits 等。
int k;1 ≤ k ≤ 15,int 足够。
double s = 0.0;累加器。0.0 表示“一项都没加”,也提醒这是浮点。
int n = 0;项数从 0 起步,进循环第一件事是把它变 1。
while (s <= k)s 没超过 k 就继续;退出时 n 恰是满足条件的最小值。
++n;先确定这一轮加第几项。放到下一行后面,最后 n 会多 1。
s += 1.0 / n;写 1.0 才触发浮点除法;写 1 / n 时 n ≥ 2 恒为 0,s 停在 1.0,循环退不出来 → TLE。
cout << n << "\n";输出项数并换行。
提交前先用三组数据自测

k = 1 → 2;k = 2 → 4;k = 15 → 1835421(真跑 183 万次循环)。

k = 15 只差 1,几乎一定是 ++n 与 s += ... 的顺序问题。

这道题的累加器是 double,不怕溢出。换成求和题,int 就会翻脸。判断只要一个乘法:项数 × 单项最大值,超过 2.1×10⁹ 就用 long long。

最坏情况上界类型
累加 10⁵ 个数,每个 ≤ 10⁹10¹⁴long long
累加 1..n,n = 655362147516416long long
累加 1..1005050int
两个 ≤ 10⁹ 的数相乘10¹⁸long long,且要先转
累乘 1..n,n = 136227020800long long
两处溢出的现场
int s = 0;
for (int i = 1; i <= 65536; ++i) s += i;
cout << s << "\n";             // -2147450880  ← 溢出成负数,编译器毫无提示

int a = 100000, b = 100000;
cout << a * b << "\n";         // 1410065408   ← 先按 32 位乘出残值
cout << 1LL * a * b << "\n";   // 10000000000  ← 1LL 把表达式抬成 64 位
乘法从左往右算:1LL * a * b 先让 a 变 64 位;写成 (long long)(a * b) 已经晚了。
这一节最容易犯的 5 个错(附症状)

累加器不初始化。long long s; 后直接 s += x,局部变量是随机值,小数据过、大数据 WA,且每次跑结果都不同。

用 1 / n 代替 1.0 / n:每项都是 0,累加器不涨,循环退不出 → TLE。

求和用 int:1+2+…+65536 得负数 → WA。项数 × 单项最大值 > 2.1×10⁹ 就换 long long。

long long ans = a * b; 两边都是 int,先溢出再赋值 → WA,要写 1LL * a * b。

边界差一次:i = 1; i < n 只有 n−1 轮 → 本节的题直接 WA。

自己动手复现

关掉页面,从空文件把 P1035 敲一遍,用 k = 1、2、15 三组自测,再提交。

改一改:把 double s = 0.0; 改成 int s = 0;,1.0 / n 改成 1 / n,跑 k = 1——程序不输出任何东西,它在死循环。Ctrl+C 打断,然后回答:n = 2 时 1 / 2 等于几?

再改一改:写 1+2+…+n,先用 int 输入 65536 看负数,再换 long long 看 2147516416。

数学补给 · 2³¹ − 1 与“这个循环要跑多久”

第一个数字是 2147483647。int 占 32 位:1 位符号 + 31 位数值,正数上限就是 2³¹ − 1,10 位数。所以十位以内基本装得下,十一位以上一定装不下。long long 用 63 位,上限 2⁶³ − 1,19 位。

第二个是调和级数 S_n = 1 + 1/2 + … + 1/n ≈ ln n + γ,γ ≈ 0.5772。反解 S_n = k 得 n ≈ e^(k−0.5772):k = 15 时 n ≈ e^14.4228 ≈ 1.84×10⁶,正是答案 1835421。

kn ≈ e^(k−0.5772)循环次数
122
5838.3×10¹
10123671.2×10⁴
151.84×10⁶1.8×10⁶

两个推论。① 时间 = 循环次数 × 每轮工作量,每秒约 10⁸ 次简单操作,10⁶ 安全、10⁹ 危险。② 范围 = 项数 × 单项最大值,超过 2.1×10⁹ 就换 long long。写代码前把这两个乘法各做一遍,就是所谓的“条件反射”。

double 只有 15~16 位有效数字,超过 10¹⁶ 的整数存不准,精确的整数答案永远用 long long。本例敢用 double,是因为每加一项只涨 1/(n+1) ≈ 5.4×10⁻⁷,而总舍入误差只有 10⁻⁹ 量级。

举一反三 · 想清楚这些再往下走
  1. 累加模式能套在很多题型上:统计个数、求平均分、边读边比求最大值。各写一句核心语句,并说明累加器初值分别是多少。
  2. 把条件从 S_n > k 改成 S_n ≥ k,while 里怎么改?k = 1 时答案从 2 变成几?
  3. k 的上限放大到 100,按 S_n ≈ ln n + γ 估一下 n 有多大。这时“一项一项加”还现实吗?
  4. 求积的初值为什么必须是 1?算 1! + 2! + … + n! 并用 int 保存,n 最小取到几就出错?
  5. 把 while 改写成 for (n = 1; s <= k; ++n) s += 1.0 / n;,输出写 n 还是 n−1?差一从哪来?
自测:能全部回答再进下一节
  • 能不看资料写出 for 和 while,并说清“次数已知用 for、次数未知用 while”。
  • 能说出三种初值(求和 0、求积 1、求最小值 INT_MAX)以及不初始化时的症状。
  • 拿到求和题能估出最坏上界,并决定用 int 还是 long long。
  • 能解释 1 / n 与 1.0 / n 的区别,以及前者为什么让本节例题死循环。
挑战题 · 质因数分解
洛谷 P1075普及-去洛谷做这题 ↗

已知正整数 n 是两个不同质数的乘积,求较大的那个质数。

输入格式:一行一个整数 n(2 ≤ n ≤ 2×10⁹)。输出格式:一行一个整数,即较大的质因数。

样例:输入 21,输出 7(21 = 3 × 7)。

引导性提示(先只看这里)

别真去分解质因数:n 最大 2×10⁹,从 2 试到 n 要跑十亿次,必 TLE。

若 i 整除 n,则 i 与 n/i 成对出现,这一对里总有一个不超过 √n。

两个质数中较小的那个 ≤ √n。从 2 往上试,第一个命中的约数就是它。

想看思路了?点开完整思路

设 n = p × q,p < q 且都是质数。由 p < q 得 p × p < n,即 p < √n。

从 i = 2 递增,第一个满足 n % i == 0 的 i 必是 p:若 i 是合数,它必有更小的质因子,早就被找到了。

答案就是 n / p,输出后直接 return。循环写 i * i <= n,只需试到 √n ≈ 4.5×10⁴ 次。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    long long n;
    cin >> n;

    for (long long i = 2; i * i <= n; ++i) {
        if (n % i == 0) {              // 从小到大,第一个约数就是较小的质因数
            cout << n / i << "\n";     // 另一个因子更大,正是答案
            return 0;                  // 找到即停
        }
    }
    return 0;
}
关键片段拆解
片段作用
long long n;n 最大 2×10⁹,int 装得下,用 long long 是为了后面的除法不贴边。
long long i = 2循环变量也取 64 位,否则 i * i 按 32 位算;数据到 10¹⁸ 就会溢出。
i * i <= n试到 √n 为止。写 i <= sqrt(n) 也行,但开方有浮点误差,乘法更可靠。
n % i == 0第一个命中的 i 就是较小的质数 p,n / i 就是较大的 q。
这题最容易错的地方
  • 循环条件写成 i <= n 或 i < n:对 n ≈ 2×10⁹ 要试十亿次,直接 TLE。
  • 想“先把质因数都存起来再比大小”:多写一遍试除,还可能把两个质数搞混。
  • 特判完全平方数:n 是两个不同质数之积,不可能是平方数,这行只会多一条错路。
延伸阅读 / 参考
  • 《算法竞赛入门笔记》第 3 章:循环结构与累加
  • 《算法竞赛黄金法则:提高算法和思考力的77项技巧》法则 1:先估算数据范围
  • 洛谷 P1035、P1075 题解区的高赞思路
03 多重循环与图形输出入门

03 · 多重循环与图形输出

把形状拆成“第 i 行几个空格、几个字符”的两列数,再用双重 for 机械翻译成代码;同时把嵌套循环的执行次数算清楚,学会先估量级再动手。
⭐ 已学范围入门预计 50 分钟嵌套循环图形打表
本节出现的新函数 / 新语法(先看懂再用)

循环嵌套的层数等于图形的维数。一维是“打 10 个星”,二维是“打 n 行、每行若干个”,三维(第 14 节的枚举)是“给 n×n 的每个格子再列出 n 种可能”。这一节只解决二维:外层管行,内层管列,换行属于行。

图形题不考创意,考的是:你能不能在 30 秒内,把眼睛看到的形状写成含 i 的两个表达式。

先记住这个判断标准:图形题的判题方式对新手极其友好——跑一遍,形状歪了就是公式错,形状对了答案基本就对。它把“抽象公式 → 循环边界”这条链压缩成肉眼可验证的产物,所以在入门阶段练它最划算。

例题 · 数字直角三角形洛谷 P5721入门去洛谷做这题 ↗

输入一个整数 n(1 ≤ n ≤ 13),输出一个由数字组成的直角三角形。

第 1 行有 n 个数,第 2 行有 n − 1 个,……,第 n 行有 1 个数;数字从 1 开始依次加 1,每个数固定占 2 个字符,不足两位时在左边补 0。同一行的数之间不加空格。

n ≤ 13 是因为 n = 13 时最大编号 n(n+1)/2 = 91,恰好两位数;再多一位,补零的写法就要换。

输入: 5 输出: 0102030405 06070809 101112 1314 15

这道题的图形有两层结构:行的长度递减,以及编号跨行连续。前者靠内层上界,后者靠一个定义在循环外面的计数器。先把下面两个东西的骨架看清,再谈怎么写。

新函数拆解 · 嵌套 for(双层循环)
for (int i = 1; i <= n; ++i) { // 外层:第几行 for (int j = 1; j <= m(i); ++j) { // 内层:这一行打几个 ...一次只处理一个格子... } }
组成含义 / 格式要求
外层 for控制“第几行”。i 从 1 走到 n,进入内层时 i 固定不动,整轮内层共用同一个 i。
内层 for控制“这一行的第几个”。外层每走一步,内层都从初值重新跑一整轮,不会接着上一行。
内层上界写成 i 的表达式图形题的上界几乎总是含 i 的公式:i、n-i+1、2*i-1、n-i。上界错一个符号,图形立刻歪。
变量名不能重复内层变量与外层同名会遮蔽外层变量 i,外层循环失控;g++ 直接报 warning 或 error。
总次数要会算矩形是 n×m,三角形是 Σ(n−i+1) = n(n+1)/2。写之前先估这个数,超过 10⁸ 就要警惕。
换行的位置换行语句必须与内层 for 平级:一行打完才换。它属于“行”,不属于“格子”。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int main() {
    // 3 行 5 列的矩形:外层 3 轮、内层每轮 5 个 → 共 15 个 '*'
    for (int i = 1; i <= 3; ++i) {      // i = 1,2,3
        for (int j = 1; j <= 5; ++j) {  // 每行都从 j = 1 重新开始
            putchar('*');
        }
        putchar('\n');                  // ★ 与内层 for 平级,写进内层图形就散架
    }
    return 0;
}
// 输出:
// *****
// *****
// *****
注意

最常见的 WA:把 putchar('\n') 写进内层 → 每个星号后面都换行,图形变成一条竖线。

内层上界可以写表达式(j <= n - i + 1),因为每进入一次内层都会重新计算上界。

行内计数器 j 每行都归零;跨行累加的计数器必须定义在两层循环之外——这两类变量别混。

嵌套三层以上时,先写清每层“代表什么”,否则下标含义会自己骗自己。

新函数拆解 · putchar(输出单个字符)
int putchar(int c); // 把一个字符写进标准输出,返回写出的字符
组成含义 / 格式要求
参数 c要输出的字符,用单引号写:'*'、' '、'\n'。形参是 int,实际传的是它的 ASCII 码。
返回值成功返回写出的那个字符(转成 int),失败返回 EOF。图形题里基本不用管。
'0' + d数字字符在 ASCII 里是连续的:'0' = 48,所以 '0' + 7 就是字符 '7'。d 必须在 0~9 之间。
'a' + k同理,'a' + 2 是 'c',用于循环打一串字母。
速度一次一个字节,比 cout << 字符 快 2~5 倍。10⁶ 级字符输出优先用它。
必须用单引号putchar("*") 传的是字符串指针,类型不符,编译不过。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int main() {
    putchar('*');            // *
    putchar(' ');            // 一个空格
    putchar('\n');           // 换行
    putchar('0' + 7);        // 数字 7 → 字符 '7'
    putchar('0' + 42 % 10);  // 先取个位 2,再变字符 '2'
    // putchar("*");         // ✗ 编译错误:双引号是字符串,不能当字符用
    return 0;
}
注意

putchar 一次只能打一个字符;要打整段文字用 cout 或 puts。

要打多位数,要么自己拆十位个位(本节例题),要么等后面学 printf("%02d", x)。

'0' + x 只在 x ∈ [0,9] 时是数字字符;x = 10 会打出字符 ':',这是“补零写错”最常见的翻车点。

第一步不是写代码,是把图形翻译成两列数

任何字符图形都只有三件事:第 i 行先打几个空格、再打几个什么字符、打完换行。把前两件事写成含 i 的表达式,代码就只剩机械翻译。

例题 P5721 没有空格,只有“两位数”。两位数固定占 2 个字符,所以每行字符数 = 2 × 每行个数:

行 i空格数数的个数字符数
10n2n
20n − 12(n − 1)
i0n − i + 12(n − i + 1)
n012

两列数一写出来,循环边界就定了:外层 i = 1..n,内层 j = 1..n-i+1,内层循环体只干一件事——编号加一,然后打两位。

关键认知:编号 cnt 要跨行连续,所以它定义在所有 for 之外;行内计数器 j 每行归零,跨行计数器 cnt 从不归零。分清这两类变量,多重循环就不会乱。

核心 9 行(片段:嵌在 main 内,n 已读入)
int cnt = 0;                                  // 跨行累加,必须在两层循环之外
for (int i = 1; i <= n; ++i) {                // 外层 = 第几行
    for (int j = 1; j <= n - i + 1; ++j) {    // 内层 = 这一行第几个数
        ++cnt;
        if (cnt < 10) putchar('0');           // 一位数,补前导 0
        if (cnt >= 10) putchar('0' + cnt / 10);
        putchar('0' + cnt % 10);
    }
    putchar('\n');                            // 换行属于“行”,所以在外层
}

复杂度:内层共执行 Σ(n−i+1) = n(n+1)/2 次,n ≤ 13 时是 91 次;输出字符数 182。这个规模怎么写都不会超时,所以图形题的时间全花在“公式对不对”上,而不是“快不快”上。

完整代码 · P5721 数字直角三角形
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;

    int cnt = 0;                                  // 编号跨行连续,所以放在循环外
    for (int i = 1; i <= n; ++i) {                // i:第几行
        for (int j = 1; j <= n - i + 1; ++j) {    // 第 i 行只有 n-i+1 个数
            ++cnt;                                // 先加再用:第一个数必须是 1
            if (cnt < 10) putchar('0');           // 一位数补前导 0,凑够 2 个字符
            if (cnt >= 10) putchar('0' + cnt / 10);  // 两位数才打十位
            putchar('0' + cnt % 10);              // 个位任何情况都要打
        }
        putchar('\n');                            // 换行与内层 for 平级
    }
    return 0;
}
逐行拆解
代码片段含义与理由
#include <bits/stdc++.h>万能头,cin/cout 与 putchar 都在里面,竞赛标准写法。
int cnt = 0;初值必须是 0 且写在双层循环外。写进内层 → 每行都从 1 重数,图形变成“每行 0102…”。
for (int i = 1; i <= n; ++i)外层走 n 次,一次负责一整行。i 只在这里被修改。
j <= n - i + 1内层上界随 i 变:i=1 时打 n 个,i=n 时打 1 个。“每行少一个”全靠这一处。
++cnt;先自增再输出。cnt 的初值 0 让第一个数是 1。
if (cnt < 10) putchar('0');补前导 0。这里不能写 else,因为下面还有两句要执行。
if (cnt >= 10) putchar('0' + cnt / 10);只有两位数才打十位。n ≤ 13 → cnt ≤ 91,两位一定够。
putchar('0' + cnt % 10);'0' + 数字 = 数字字符;cnt % 10 取个位,永远要打。
putchar('\n');位置与内层 for 平级:一行结束才换行。放进内层就变成每个数一行。
return 0;正常结束。评测机看到 0 才认为程序跑完了。
换了形状,只改两个数

所有基础字符图形都是“先空格、后字符”。把这两列的公式背下来,剩下的都是打字工作:

图形第 i 行空格数第 i 行字符数内层怎么写
n×n 方阵0n一个内层:1..n
左下三角0i一个内层:1..i
左上三角0n − i + 1一个内层:1..n-i+1
右下三角n − ii两个内层:1..n-i,再 1..i
等腰三角形n − i2i − 1两个内层:1..n-i,再 1..2*i-1
倒等腰i − 12(n − i) + 1两个内层:1..i-1,再 1..2*(n-i)+1

两列数相加就是这一行实际打出的字符总数——这意味着行末绝不能多打一个空格,公式里没有它。同一份代码打三种形状,只改上界:

一份代码,三种图形(n = 4 时见下方推演)
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;

    for (int i = 1; i <= n; ++i) {                 // ① 方阵:每行都 n 个
        for (int j = 1; j <= n; ++j) putchar('*');
        putchar('\n');
    }
    putchar('\n');                                 // 图形之间空一行,方便肉眼对比

    for (int i = 1; i <= n; ++i) {                 // ② 左下三角:每行 i 个
        for (int j = 1; j <= i; ++j) putchar('*');
        putchar('\n');
    }
    putchar('\n');

    for (int i = 1; i <= n; ++i) {                 // ③ 等腰:先空格,后星号
        for (int j = 1; j <= n - i; ++j) putchar(' ');      // 空格让星号居中
        for (int j = 1; j <= 2 * i - 1; ++j) putchar('*');  // 每行多 2 个
        putchar('\n');
    }
    return 0;
}

n = 4 时第 ③ 段的输出是 3 空格+1 星、2 空格+3 星、1 空格+5 星、0 空格+7 星。星号总数 1+3+5+7 = 16 = 4²,这个等式后面数学补给里会推。

内层换成一件事:打一张表

图形题的骨架可以脱离“星号”复用:把内层那一刻要输出的东西换掉,就能打任意二维表格。下面打 n×n 乘法表,每格右对齐占 4 列(取 n ≤ 99,保证 i·j ≤ 9801 放得下)。

n×n 乘法表,每格 4 列右对齐
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;

    for (int i = 1; i <= n; ++i) {                 // 外层:第 i 行
        for (int j = 1; j <= n; ++j) {             // 内层:第 j 列,共 n² 格
            int v = i * j;                         // 这一格的值
            int w = 1, t = v;                      // w = v 的十进制位数,至少 1 位
            while (t >= 10) { t /= 10; ++w; }      // 第三层循环:每除一次 10 少一位
            for (int k = 1; k <= 4 - w; ++k) putchar(' ');   // 右对齐:先补空格
            cout << v;                             // 再打数字本身
        }
        putchar('\n');                             // 换行只在外层
    }
    return 0;
}
三重循环是怎么嵌起来的
代码片段含义与理由
int w = 1, t = v;位数计数器。任何正整数至少有 1 位,所以从 1 起步。
while (t >= 10) { t /= 10; ++w; }第三层循环,次数 = 位数 − 1,由数据决定而不是由 n 决定。这是“多重循环”里最轻的一种。
for (int k = 1; k <= 4 - w; ++k) putchar(' ');右对齐的关键:先补齐 4 − w 个空格再打数。w = 4 时循环 0 次,也是对的。
cout << v;数字交给 cout,不用自己拆位输出。
putchar('\n');与内层 for 平级。写进内层 → 每个格子占一行,n² 行。

这段代码里 putchar 与 cout 混着用是安全的,因为默认没有关掉 sync_with_stdio,两套流共享同一个缓冲顺序。第 1 节那句“关了同步就别混用”说的正是这个前提——一旦写了 ios::sync_with_stdio(false),就必须全程只用一套。

总工作量 ≈ 4n²。n = 99 时约 4×10⁴,瞬间跑完;把 n 提到 10⁵ 就是 4×10¹⁰,而且输出本身有几百 GB——所以“先把量级算出来”永远排在“先写循环”前面。

图形题的固定四步

① 手画 n = 4 的目标图形,数出每行的空格数与字符数,写成含 i 的表达式。

② 估总量:总字符数(n²、n(n+1)/2、n(n+1) 这些量级)和总循环次数,确认不超时、不超输出限制。

③ 外层 for 行、内层 for 列,换行必须与内层平级。

④ 用 n = 1、n = 2 和最大数据各跑一次,重点看行末有没有多余空格、末尾有没有多余空行。

图形输出 6 个高频错误(都带具体症状)

换行写进内层。症状:WA,输出变成一列竖线(每个字符一行)。修法:把 putchar('\n') 的缩进与内层 for 对齐,它不属于格子。

内层上界忘了跟 i 变。写成 j <= n 得到方阵;写成 j <= i 得到上小下大的三角形。症状:WA,形状不对但“看着很像”,最容易自我怀疑。

行末多一个空格。公式里没有这一格,多打就是错。症状:逐字节比对的 OJ 判 WA 或提示格式错误,部分 OJ 会放过,但别赌。修法:空格只打在字符前面。

补零只补一半。写成 if (cnt < 10) putchar('0'); putchar('0' + cnt); —— 第二句仍然只打一个字符,cnt = 12 时会打出乱码。症状:n ≥ 5 就 WA,n ≤ 4 反而全对。

putchar 用了双引号。putchar("\n") 症状:CE,报 no matching function for call to 'putchar(const char [2])'。

内层上界写成常量造成 TLE。症状:n ≤ 2000 却跑 3 秒以上。三层各 2000 就是 8×10⁹ 次,必须先算 Σ 再写。

自己动手复现

把例题不看页面敲一遍,提交 P5721,输入 5,核对输出是否与题面逐字符一致。

把 n - i + 1 改成 i 再提交:图形变成上小下大,但编号依然连续——因为 cnt 从不归零。想清楚为什么编号还对,只有形状错了。

再把外层改成倒序 for (int i = n; i >= 1; --i),先预测输出再运行。预测错了,说明你还没分清“i 的走向”和“图形的走向”。

最后加第三段:n = 4 时输出金字塔,要求 1、3、5、7 个星号。把内层拆成“先打 n−i 个空格,再打 2i−1 个星”,两个 for 各自独立,不要合在一起。

数学补给 · 等差数列与三角数:先算总量,再动手打

图形里每一行都在重复同一件事,所以“总量”必然是等差求和。先记住这两张脸:

第 i 行有几个元素总元素数n = 1000 时常见图形
n(常数)n²10⁶方阵
in(n+1)/25×10⁵左下三角
2i − 1n²10⁶等腰三角形
2(n − i) + 1n²10⁶倒等腰

第二个公式的来历:Σi=1..n(2i − 1) = 1 + 3 + 5 + … + (2n − 1) = n²。前 n 个奇数之和是平方数——所以金字塔的星号总数是 n²,n = 4 时必须打出 16 个星,少了就是公式漏了一行。

还有一个常用反解:给定项数 N,求最小的 k 使 k(k+1)/2 ≥ N。即 k = ⌈(√(8N+1) − 1) / 2⌉。N = 10⁷ 时 k ≈ 4472,所以挑战题里那个 while 最多转 4472 圈,比开数组既快又省内存。

结论:写图形题前先算“总共要输出多少字符”。超过 10⁷ 个字符的图形,出题人一定不会让你全打出来,他会改成问“第 k 行第 m 个是什么”——那时你需要的就不是循环,是公式。

举一反三 · 想清楚这些再往下走
  1. 把例题内层上界 n - i + 1 换成 i,编号仍然连续。为什么?如果题目改成“每行都从 1 重新编号”,代码里该改哪一行、删哪一个变量?
  2. 输出 n 行 m 列的表,第 i 行第 j 列写 i * j,n、m ≤ 2000。这一格最大是多少?用 int 够不够?(先算 2000×2000,再想 int 上限 2.1×10⁹)
  3. 如果题目只要第 k 行、不要整张图,双重循环还该不该写?能不能用一条含 k 的公式直接算出这一行?这正是从“打表”走向“公式”的第一步。
  4. 空心三角形只打边界字符,中间打空格。怎么用 if 判断“这个位置在不在边界上”?注意:输出字符变少了,循环次数一个都没少——为什么?
  5. 同样输出 10⁶ 个字符,cout << '*' 和 putchar('*') 差在哪一步?已经写了 ios::sync_with_stdio(false) 为什么还可能 TLE?
  6. 如果在例题里再加一层循环(每个格子再枚举 k 次),总次数从 n² 变成 n³。n = 500 时是多少次?为什么算法题里“n 多一个数量级”经常等于“重写算法”?
自测:能全部回答再进下一节
  • 能在 30 秒内说出:n 行等腰三角形第 i 行有几个空格、几个星号。
  • 不看书写出 n = 5 的数字直角三角形(含补零),行末只允许有一个换行。
  • 知道 putchar('0' + d) 为什么能得到数字字符,以及 d = 10 时会打出什么。
  • 能估算双重循环总次数:n = 1000 的三角形是 5×10⁵ 次、n = 10⁵ 的三角形是 5×10⁹ 次,后者必然 TLE。
  • 能解释换行语句为什么必须与内层 for 平级,以及写错后输出具体长什么样。
  • 拿到图形题会先画 n = 4、手算总字符数,再动手写代码,而不是凭感觉嵌套。
挑战题 · Cantor 表
洛谷 P1014普及-去洛谷做这题 ↗

把全体正有理数排成下面的表,第 r 行第 c 列写的就是 r/c:
1/1 1/2 1/3 1/4 …
2/1 2/2 2/3 …
3/1 3/2 …
4/1 …

再按 Z 字形给表中每一项编号:第 1 项 1/1,第 2 项 1/2,第 3 项 2/1,第 4 项 3/1,第 5 项 2/2,第 6 项 1/3,第 7 项 1/4,……也就是说,编号沿着对角线来回往返,一条一条扫过整张表。

输入格式:一个整数 N(1 ≤ N ≤ 10⁷)。输出格式:一行,形如 a/b,中间不加空格,表示第 N 项。

引导性提示(先只看这里)

不要开二维数组。这张表是“斜着数”的,先想清楚第 N 项落在第几条斜线上。

第 k 条斜线上有几项?从第 1 条到第 k−1 条一共有几项?把这两个数写成 k 的式子。

斜线号的奇偶决定方向:奇数号从上方出发往左下走,偶数号从下方出发往右上走。先手写前 10 项并标上斜线号,规律自己会跳出来。

N 高达 10⁷,但斜线号只有约 4472,一个 while 累加就够,不需要数组也不需要 sqrt。

想看思路了?点开完整思路

第 k 条斜线(k = 1, 2, 3, …)含 k 项,且每条斜线上分子 + 分母 = k + 1。前 k 条斜线共 T(k) = k(k+1)/2 项。

于是“第 N 项在哪条斜线”等价于求最小的 k 使 T(k) ≥ N。T(k) 严格递增,从 k = 1 往后 while 累加即可;N ≤ 10⁷ 时 k ≤ 4472,循环量可以忽略。

设 before = (k−1)k/2 为前 k−1 条斜线的项数,则第 N 项在第 k 条斜线里是第 t = N − before 个(从 1 数起)。

方向:k 为奇数时该斜线从 k/1 出发、分子递减,故 a = k − t + 1、b = t;k 为偶数时从 1/k 出发、分子递增,故 a = t、b = k − t + 1。

自检:t 从 1 走到 k 时,a + b 恒为 k + 1,且 a、b 都在 [1, k] 内。用前 10 项逐个对,再提交。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    long long N;
    cin >> N;

    // 找最小的 k,使 1+2+...+k >= N:第 N 项落在第 k 条斜线上
    long long k = 1;
    while (k * (k + 1) / 2 < N) ++k;

    long long before = (k - 1) * k / 2;   // 前 k-1 条斜线一共多少项
    long long t = N - before;             // N 在第 k 条斜线里是第 t 个(从 1 数)

    long long a, b;                       // 答案 a/b
    if (k % 2 == 1) {                     // 奇数斜线:从 k/1 出发,分子递减
        a = k - t + 1;
        b = t;
    } else {                              // 偶数斜线:从 1/k 出发,分子递增
        a = t;
        b = k - t + 1;
    }

    cout << a << "/" << b << "\n";        // 输出格式是 a/b,不能带空格
    return 0;
}
关键片段拆解
片段作用
while (k * (k + 1) / 2 < N) ++k;用三角数定位斜线。循环次数 = k ≈ 4472,代价可忽略;不用 sqrt 是为了躲开浮点误差。
(k - 1) * k / 2前 k−1 条斜线的总项数。先乘后除:写成 (k-1)/2*k 会在 k−1 为奇数时丢余数。
t = N - before把全局编号 N 换成“斜线内部编号”t ∈ [1, k],后面只需管 t。
k % 2 == 1方向开关。奇数斜线在表中是往左下走,所以分子随 t 减小。
a = k - t + 1; b = t;t = 1 时得 k/1,t = k 时得 1/k,两个端点都对得上表。
cout << a << "/" << b << "\n";用字符 '/' 连接,不加空格。这是本题唯一的格式要求。
这题最容易错的地方
  • 用 sqrt 算 k 再反查,浮点误差会让 k 少 1,N 恰好是三角数时整体错位。
  • 把 t 当成从 0 数起,写出 a = k - t,t = 1 时会得到 (k−1)/1,整体偏一位。
  • 奇偶方向写反。症状:输出是正确答案的镜像,比如 N = 2 打成 2/1。用前 6 项对表即可发现。
  • 输出成 a / b(带空格)或 a÷b,格式不符直接 WA。
  • 用 int 存 k 和 k(k+1)/2:本题 N ≤ 10⁷ 时 k ≤ 4472,不会溢出;但若 N 换成 10¹⁸(k ≈ 1.4×10⁹),k(k+1) 立刻爆 int,从乘法那一步起就得用 long long。
  • 把 1/1 当成第 0 项,导致所有 t 的落点整体偏移一条斜线。
延伸阅读 / 参考
  • 《算法竞赛入门笔记》循环与字符图形一章
  • 《算法竞赛黄金法则:提高算法和思考力的77项技巧》第 1 章:先把形状写成公式
  • 洛谷 P5721 题解区关于补零与换行位置的讨论
04 一维数组与遍历入门

04 · 一维数组与遍历

把 n 个数存下来再处理:数组声明与下标边界、sizeof 与范围 for 的正确用法、求最值的初值选择,以及“先全部读入”和“边读边算”的分界线。
⭐ 已学范围入门预计 50 分钟数组遍历最值
本节出现的新函数 / 新语法(先看懂再用)

前三节都是“读一个、算一个、扔掉”。这一节解决回头访问:先把 n 个数存起来,再按编号取。10 个数能起 10 个变量名,10⁵ 个不行。数组 = 一块连续内存 + 从 0 开始的编号,编号叫下标,写 a[i]。

数组不检查下标。写错一个 1 不报错,只会悄悄改坏隔壁变量——本地能过、评测机 RE。
例题 · 陶陶摘苹果洛谷 P1046入门去洛谷做这题 ↗

陶陶家的苹果树结了 10 个苹果。她有一个 30 厘米高的板凳,手够不到就踩上去再试。已知 10 个苹果到地面的高度和陶陶把手伸直时能达到的最大高度,求她最多能摘到几个苹果(碰到就掉下来)。

输入两行:第一行 10 个 100~200 的整数(厘米),表示各苹果的高度;第二行 1 个 100~120 的整数(厘米),表示把手伸直时能达到的最大高度。

输出一行一个整数,表示能摘到的苹果数。

输入: 100 200 150 140 129 134 167 198 200 111 110 输出: 5

输入顺序是关键:10 个高度在前,手的高度在后。要判断够不够得到,必须先拿到手的高度,而它在最后。所以这 10 个数非存不可——数组存在的第一个理由。

本节只有三样新东西。

新函数拆解 · 一维数组
类型 名字[长度]; // int a[100005];
组成含义 / 格式要求
int a[100005];下标 0~100004。长度必须是编译期常量,不能是读进来的 n。
a[0] … a[长度-1]长度 10 的数组,最后一个元素是 a[9],没有 a[10]。
a[i]i 可以是变量或表达式(如 a[i*2+1]),只要结果在范围内。
全局 / 局部main 外:自动全 0、不占栈。main 里:随机值,大数组还会爆栈。
内存布局a[i] 的地址 = 起始地址 + i×4,连续存放,随机访问 O(1)。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

const int N = 100005;           // 长度写成常量,改一处就够
int a[N];                       // 全局:默认全 0,占 400KB,不放栈上

int main() {
    int n; cin >> n;
    for (int i = 0; i < n; ++i) cin >> a[i];        // 只填前 n 个格子
    for (int i = n - 1; i >= 0; --i) cout << a[i] << " ";
    cout << "\n";
    return 0;
}
注意

长度只能开大不能开小:n ≤ 10⁵ 就开 100005,多留 5 格防差一。

局部 int a[1000005]; 占 4MB 栈,栈上限常只有 1~8MB,直接 RE。挪到 main 外。

下标从 0 开始:a[i] 的地址是 base + i×4,索引 0 正好落在起始地址。

新函数拆解 · sizeof
sizeof(表达式或类型) // 占多少字节,类型是 size_t
组成含义 / 格式要求
sizeof(a)整个数组的字节数。int a[10] 得 40。
sizeof(a[0])单个元素:int 4、long long 8、char 1、double 8。
sizeof(a)/sizeof(a[0])数组长度,换元素类型不用改公式。
编译期求值是运算符不是函数,没有运行时开销。
数组退化后失效传进函数后 a 变成指针,sizeof(a) = 8,公式作废。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int main() {
    int a[10];
    cout << sizeof(a) << "\n";                 // 40 = 10 × 4
    cout << sizeof(a[0]) << "\n";              // 4
    cout << sizeof(a) / sizeof(a[0]) << "\n";  // 10,就是长度
    return 0;
}
注意

竞赛里更常见的是直接用读入的 n,别依赖 sizeof——数组传进函数后它会骗你。

sizeof 是无符号数,与 int 比较会有警告,写成 (int)(sizeof(a)/sizeof(a[0])) 最干净。

新函数拆解 · 范围 for(range-based for)
for (元素类型 变量 : 数组) { ... }
组成含义 / 格式要求
for (int x : a)从头到尾取每个元素,复制给 x。不写下标,不会写错边界。
int &x引用即别名,改 x 就是改数组。要修改元素时用它。
走多少格整个数组长度,一格不少。不能只走前 n 个,不能倒序或跳跃。
什么时候不能用需要下标(问“第几个”)、倒序、或数组开大了只读 n 个数时。
版本C++11 起,C++17 当然支持。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int main() {
    int a[5] = {3, 1, 4, 1, 5}, s = 0;
    for (int x : a) s += x;         // 副本:s = 14,a 没变
    for (int &x : a) x *= 2;        // 引用:a 变成 6 2 8 2 10
    for (int x : a) cout << x << " ";
    cout << "\n" << s << "\n";
    return 0;
}
注意

最大的坑:int a[100005]; 只读了 5 个数,for (int x : a) 会把后面 100000 个 0 也算进去,答案大出一截。

范围 for 拿不到下标。答案要输出“位置”的题,老实写普通 for。

把题目翻译成 3 步

判断规则一句话:苹果高度 ≤ 可及高度就摘得到,而可及高度 = 手伸直高度 + 30。

  1. 把 10 个高度读进 h[0..9],再读入手的高度 reach;
  2. 算出 can = reach + 30,把 30 只加一次;
  3. 扫一遍数组,h[i] <= can 就 ++ans。

第 3 步是通用骨架:答案变量归零 → 遍历 → 满足条件就更新。计数、求和、求最值、找位置,都只是换一行循环体。

完整代码(可直接提交 P1046)
#include <bits/stdc++.h>
using namespace std;

int main() {
    int h[10];
    for (int i = 0; i < 10; ++i) cin >> h[i];   // ① 先全部读入:手的高度还没出现

    int reach;
    cin >> reach;                                // ② 再读手能到达的高度
    int can = reach + 30;                        // ③ 踩上 30cm 板凳后,把 30 只加一次

    int ans = 0;                                 // ④ 计数器必须从 0 开始
    for (int i = 0; i < 10; ++i)
        if (h[i] <= can) ++ans;                  // 碰到就掉,计一个

    cout << ans << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
int h[10];10 个格子,合法下标 0~9。只有 40 字节,开在 main 里也无所谓。
for (int i = 0; i < 10; ++i) cin >> h[i];循环 10 次,第 i 次读的数放进 h[i]。cin 自动跳过空格换行,数据分几行写都行。
cin >> reach;第 11 个数正好是第二行那个高度。顺序不能反。
int can = reach + 30;题目给的高度不含板凳。先加进 can,循环里就只剩一次判断。
int ans = 0;必须显式初始化。局部变量不初始化是随机值,可能输出 1245123。
for (int i = 0; i < 10; ++i)0 到 9 正好 10 个。写成 i <= 10 就会访问 h[10],越界。
if (h[i] <= can) ++ans;等于也算,题目说碰到就掉,别写成小于。
cout << ans << "\n";只输出数字,不加提示文字。

把骨架换几行循环体,就得到四种统计。只有求最值有一个必须想清楚的细节:初值怎么选。

最值 + 求和的标准骨架
#include <bits/stdc++.h>
using namespace std;

const int N = 100005;
int a[N];

int main() {
    int n;
    cin >> n;
    for (int i = 0; i < n; ++i) cin >> a[i];     // 先全部读入

    int mx = a[0], mn = a[0];                    // 初值取第一个元素,不是 0
    long long sum = 0;                           // n 大、值大时必须 long long
    for (int i = 0; i < n; ++i) {
        if (a[i] > mx) mx = a[i];                // 严格大于才换,相等时保留先出现的
        if (a[i] < mn) mn = a[i];
        sum += a[i];
    }

    cout << mx << " " << mn << " " << sum << "\n";
    return 0;
}
初值为什么取 a[0]
代码片段含义与理由
int mx = a[0];a[0] 是真实数据,不可能比最大值大,结果无条件正确。
int mx = 0;只在全 ≥ 0 时对。输入 -5 -9 时 mx 停在 0,输出 0 —— WA。
int mx = INT_MIN;同样永远正确,需 <climits>(bits 已含),但看不出含义。
if (a[i] > mx)用 > 不用 >=:相等时不覆盖,保留“第一次出现”的信息。
sum += a[i];同一循环顺手算完,不必多扫一遍数组。
目的初始化循环体写错会怎样
求和0(用 long long)sum += a[i];用 int 溢出 → 输出负数
计数0if (条件) ++cnt;没初始化 → 随机数
最大值a[0] 或 INT_MINif (a[i] > mx) mx = a[i];初值 0 → 全负数时 WA
最小值a[0] 或 INT_MAXif (a[i] < mn) mn = a[i];初值 0 → 全正数时 WA
第一次位置-1if (a[i]==x) { pos=i; break; }初值 0 → 没找到时错答 1
什么时候必须“先全部读入”

必须存:需要回头访问。如本题(手的高度在后面)、两两比较、统计频率、按原顺序输出。

可以不存:求和、计数、求最值。只用“当前元素”和“已有答案”,内存 O(1)。n = 10⁶ 个 int 要 4MB,老题内存常只给 8~16MB。

口诀:答案里出现“前 i 个”就不用数组;出现“第几个 / 后面还要用”就必须存。

6 个高频错,都带症状

越界:int a[10] 却写 i<=10。症状:本地正常,洛谷 RE 或偶发 WA。

数组开小:n ≤ 10⁵ 却开 a[1000]。症状:RE 与随机 WA 交替,重交结果还不同——数组题最典型的指纹。

最值初值取 0:数据 -5 -9 时输出 0。症状:样例过、一交就 WA,只错在负数数据点。

局部大数组爆栈:int a[1000005]; 写在 main 里占 4MB 栈。症状:一启动就 RE,一行输出都没有。

求和用 int:n = 10⁵、每数 10⁹,总和 10¹⁴ 超上限。症状:输出负数。

范围 for 扫了没读过的尾巴:只读 5 个数却 for (int x : a)。症状:答案大出一截。

自己动手复现

关掉页面,把 P1046 的代码从头敲一遍并提交。

改一:把 10 换成先读 n(n ≤ 100),再读 n 个高度,最后读手的高度,所有循环条件跟着 n 走。

改二:输出“能摘到的苹果里最高的高度”,一个都摘不到输出 -1。想清楚 mx 初值该取什么,照抄 a[0] 会错在哪。

数学补给 · 循环不变式:为什么“边扫边更新”一定对

求最大值那个 if 凭什么正确?可以用数学归纳法证明。方法是给循环写一条循环不变式:关于“第 k 轮开始时什么成立”的断言。

第 k 轮开始时,mx = a[0..k-1] 中的最大值。
  1. 初始化:k = 1 时只比较过 a[0],mx = a[0] = max(a[0..0]),成立。
  2. 保持:设第 k 轮前成立。这一轮执行 if (a[i] > mx) mx = a[i];(i = k-1)。若 a[k-1] 更大则 mx 换成它,否则不变,两种情形下 mx = max(a[0..k-1]) 仍成立。
  3. 终止:循环结束时 k = n,mx = max(a[0..n-1]),正是所求。

三步就是全部。以后学前缀和、单调栈、DP,证明正确性用的都是同一套话,只是不变式内容不同。养成习惯:任何“维护一个答案变量”的循环,先用一句话说清不变式。

推论 1(数个数):区间 [l, r] 内整数个数 = r − l + 1。长度 n 的数组合法下标是 0..n−1,个数 = (n−1) − 0 + 1 = n,所以循环条件永远是 i < n。所有差一错误都能用这个公式当场算穿。

推论 2(别乱排序):求最值一遍遍历 O(n),排序后取端点 O(n log n)。

n一遍遍历 ≈ n 次排序 ≈ n·log₂n 次倍数
10³1 000约 10 00010×
10⁵100 000约 1.7×10⁶17×
10⁶1 000 000约 2×10⁷20×

结论:能在读入时顺手维护的信息(和、最值、计数),不要为了“先存后算看着清楚”多存一个数组、多排一次序。

举一反三 · 想清楚这些再往下走
  1. 给 10⁵ 个数要求“有多少个数大于平均数”。你至少要扫几遍?内存只够存 10³ 个数时怎么办?
  2. 数组开 100005 但只用前 n 个,求最大值时后面的 0 会捣乱吗?给出两种写法:一种靠下标循环避开,一种靠哨兵初值绕开。
  3. 要输出“最大值第一次出现的位置”和“最后一次出现的位置”,一个循环够吗?为什么求第一次能 break,求最后一次不能?
  4. 已知 10⁵ 个人的年龄(0~120),要统计每个年龄的人数。不开 10⁵ 个格子,只开 121 个,能做到吗?这时下标代表什么?(下一节内容,先想)
  5. 如果要求“输入顺序 = 输出的逆序”,而内存只给 1MB、n = 10⁶,你还能用数组吗?有没有不用数组的办法?
自测:能全部回答再进下一节
  • 能默写 const int N = 100005; int a[N];,并说出大数组为什么写在 main 外。
  • 知道长度 10 的数组合法下标是 0~9,能解释 a[10] 会发生什么、为什么可能“不报错”。
  • 能写出求最大值、最小值的循环,并说明初值为什么取 a[0] 而不是 0。
  • 能说出 sizeof(a)/sizeof(a[0]) 在什么情况下失效,以及竞赛里为什么更爱用 n。
  • 能说出范围 for 的两个限制:走完整长度、拿不到下标。
  • 面对新题能判断:必须把数据全存下来,还是边读边算就够。
  • 知道用 i < j 枚举时,同一对只被数一次。
挑战题 · 旗鼓相当的对手
洛谷 P5728普及-去洛谷做这题 ↗

有 N 名同学参加期末考试,每人有语文、数学、英语三科成绩,均为不超过 150 的自然数。

如果两名同学三科成绩两两之间的差距都不超过 5 分,且总分差距不超过 10 分,就称这两个同学是一对“旗鼓相当的对手”。求一共有多少对。

输入格式:第一行一个整数 N(2 ≤ N ≤ 1000);接下来 N 行,每行三个整数,表示一名同学的三科成绩。

输出格式:一行一个整数,表示“旗鼓相当的对手”的对数。

引导性提示(先只看这里)

先想清楚“一对”怎么数才不重不漏:i、j 都从 0 跑到 n−1 的话,每对会被数两次。

三科差和总分差是四条独立判断,缺一条答案就偏大。

N = 1000 时一共有多少对?先估这个数量级,再决定二重循环够不够。

想看思路了?点开完整思路

用平行数组存三科:ch[i]、ma[i]、en[i] 属于同一个人。

读入时把总分算好存进 tot[i],避免内层循环里重复加三次。

二重循环:外层 i 从 0 到 n−1,内层 j 从 i+1 到 n−1,每对 (i, j) 恰好检查一次。

四个条件全满足才计数:三个单科差 ≤ 5,总分差 ≤ 10,用 abs 取绝对值。

复杂度 O(n²),n = 1000 时约 5×10⁵ 次比较,1 秒内轻松跑完。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int N = 1005;                     // n ≤ 1000,多留 5 格防差一
int ch[N], ma[N], en[N], tot[N];        // 平行数组 + 总分,全局默认全 0

int main() {
    int n;
    cin >> n;
    for (int i = 0; i < n; ++i) {
        cin >> ch[i] >> ma[i] >> en[i];
        tot[i] = ch[i] + ma[i] + en[i];  // 总分只依赖本人,读入时算一次就够
    }

    int ans = 0;
    for (int i = 0; i < n; ++i)
        for (int j = i + 1; j < n; ++j) {            // j 从 i+1 起:每对只数一次
            if (abs(ch[i] - ch[j]) <= 5 &&
                abs(ma[i] - ma[j]) <= 5 &&
                abs(en[i] - en[j]) <= 5 &&
                abs(tot[i] - tot[j]) <= 10) ++ans;
        }

    cout << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
int ch[N], ma[N], en[N], tot[N];四个平行数组,同一下标 i 指向同一个人。写 main 外:默认全 0,不占栈。
tot[i] = ch[i] + ma[i] + en[i];总分读入时算一次。放进内层循环会重复算 n 遍。
for (int j = i + 1; j < n; ++j)关键一行。保证 (i,j) 与 (j,i) 只被算一次;写成 j = 0 答案必翻倍。
abs(ch[i] - ch[j]) <= 5abs 取绝对值,两个方向的差都变正。成绩 ≤ 150,int 足够。
&& 连四个条件短路求值:前一个不成立就不再判断后面的,既对又快。
这题最容易错的地方
  • 内层写成 for (j = 0; ...),答案正好是正确值的 2 倍。样例若是 0 就看不出,交上去必 WA。
  • 漏掉总分条件,或把“总分差 ≤ 10”写成“三科差之和 ≤ 10”,答案偏大。
  • 循环条件写成 i <= n,下标取到 n,越界写坏 tot 后面的内存,随机 WA。
  • 把三科读入顺序与题面对调。本题三科对称换了也能 AC,换个题就错,养成按题面顺序读的习惯。
延伸阅读 / 参考
  • 《算法竞赛入门笔记》数组与循环章节
  • 《算法竞赛试炼场:洛谷 300 题精析》入门组数组部分
  • 洛谷 P1046、P5728 题解区的高赞思路
05 回顾一:基础语法综合训练入门+

05 · 回顾一:基础语法综合训练

90 分钟 5 道真题,只考第 1~4 节的输入输出、循环、图形规律与一维数组:用一套限时流程把“会语法”练成“会做题”,并留下 6 条交卷前的检查清单。
⭐ 已学范围回顾训练入门+建议 90 分钟回顾5 题

这一节不讲新东西。5 道题、90 分钟,只考第 1~4 节学过的四件事:读入输出、循环、图形规律、一维数组。目标不是“能做出来”,而是“限时内稳定做出来”——同一道题,赛场上多花 10 分钟,就等于少做一道后面的大题。

决定分数的不是“你学过什么”,而是“读完题 30 秒内知不知道要开几个数组、循环怎么写”。

先看清这 5 道题分别考什么,再决定先做哪道:

标记题目主要考点难度建议用时
AP1909 买铅笔循环 + 向上取整 + 取最小普及-12 分钟
BP1014 Cantor 表图形规律,循环找第 k 条斜线普及-13 分钟
CP1047 校门外的树数组标记,区间覆盖普及-15 分钟
DP1980 计数问题while 拆位,逐个数字统计普及-14 分钟
EP1328 生活大爆炸版石头剪刀布数组 + 取模 + 查胜负表普及/提高-20 分钟
90 分钟怎么切

总原则:先把会做的分全部拿到,再回头啃没思路的。题目难度不是按顺序排的,别顺着往下做;规则题(B)想不出就跳过,它值得的时间只有 5 分钟。

阶段时间段(分钟)做什么
通读 5 题0 ~ 5标“会做 / 有思路 / 完全不会”,此阶段不许动笔写代码
A 买铅笔5 ~ 17读 2 + 写 6 + 测 4,必须一次写对
B Cantor 表17 ~ 305 分钟没思路就跳过,最后再回来
C 校门外的树30 ~ 45标记法,两个循环,重点全在边界
D 计数问题45 ~ 59代码极短,卡住必是拆位拆错了循环变量
E 石头剪刀布59 ~ 79抄表 6 + 写 8 + 测 6
检查79 ~ 90只查数组大小、初值、输出格式,不改算法

单题内部再切一刀:想 3 分钟(在纸上写清楚)、写 8 分钟、测 4 分钟。“想”的 3 分钟里只干四件事:抄下数据范围、抄下输出格式、手算一遍样例、写下要用的数组大小。

  • 单题上限 25 分钟:到点没 AC 就换题,回头来往往一眼看出错。
  • 每题先交“能过样例的版本”,再想优化。先有分,再追满分。
  • 最后 11 分钟只检查不改算法。赛场上最后 10 分钟改崩的例子,比改对的例子多得多。

如果整块时间更充裕,多出来的部分全给最没把握的那道题,不要平均分给 5 题——能做出来的题不需要第二遍,做不出来的题才需要。

“想 3 分钟”不是发呆。这 3 分钟里,纸上必须出现 4 行东西,写完再碰键盘:

  1. 数据范围:n、l、m 各是多少?据此写死数组大小,决定 int 还是 long long。
  2. 输出格式:几个数、用什么分隔、行末要不要空格、有没有固定的符号(比如 “/”)。
  3. 边界:n=1、l=0、全部相等、全是 0,这几种输入你的程序会不会崩或算错。
  4. 样例手算:题目给的输入自己在纸上走一遍。答案对不上,是题读错了,不是代码错了。

再看一张“条件反射表”。题面出现这些词,思路应该立刻跳出来——5 道训练题全在这张表里:

题面里的词第一反应
“至少”“装满”“够不够”向上取整:(n + p - 1) / p
“出现几次”“有多少个”计数器 cnt,初值写 0
“每棵树”“每个点” + 范围不大开数组打标记,最后扫一遍统计
“周期”“循环出现”下标取模 a[k % na]
“连续的一段”“最长”边扫边维护 cur,随时 max 更新答案
“含端点”“0 到 l”循环边界写 <=,元素个数是 l+1

表里有三个动作,最简的题也要用:取极值、向上取整、下标取模。下面三张卡不是新知识,只是把这三个写法再拆一遍——看着眼熟就快速扫过,看着陌生说明前面某节没吃透,回去补。

新函数拆解 · min / max(取极值)
min(a, b) max(a, b)
组成含义 / 格式要求
min(a, b)返回两值中较小的那个;a、b 的类型必须一致(都 int 或都 long long)。
max(a, b)返回较大的那个。
最常用形态ans = max(ans, x):边扫边更新答案,扫完 ans 就是最大值。
只能比两个三个以上要嵌套 max(max(a,b),c),或先塞进数组再循环。
初值ans 必须先有初值;求最大值常用 a[0](或一个极小值)打底。
最小可运行示例
示例
int a[5] = {3, 9, 2, 9, 5};
int mx = a[0];                       // 拿第一个数当擂主
for (int i = 1; i < 5; ++i)
    mx = max(mx, a[i]);              // 谁大谁当擂主
cout << mx << "\n";                  // 9
注意

ans 用 0 初始化去求最大值,遇到全是负数就错;最稳的初值是 a[0]。

min 与 max 的两个参数类型不同时可能编译不过,统一写 int 最省事。

新函数拆解 · 整数向上取整 (n + p - 1) / p
(n + p - 1) / p // 正整数 n 除以正整数 p 的向上取整
组成含义 / 格式要求
n一共有多少(要凑够多少)。
p每份多少(每包几支、每页几行)。
+ p - 1先补上“差一点点”的那一份,让整数除法多进 1。
/ p整数除法本身向下取整;补过之后再向下取整,结果等于原式向上取整。
前提n、p 都是正整数。n 恰好被 p 整除时不会多算:n=60、p=2 → (60+1)/2 = 30,等于 60/2。
最小可运行示例
示例
int n = 57, p = 2;
cout << n / p << "\n";               // 28  ← 只有 56 支,不够
cout << (n + p - 1) / p << "\n";     // 29  ← 58 支,够
注意

凡题面出现“至少”“够不够”“装满”“凑够”这类词,八成要向上取整。

n 接近 10⁹ 时 n+p-1 会溢出 int,改用 long long 写式子。

新函数拆解 · 取模取循环下标 a[k % na]
a[k % na] // 长度 na 的周期序列,第 k 项(k 从 0 数)
组成含义 / 格式要求
k第几项,从 0 开始编号。
na周期长度,也就是数组元素个数。
k % na余数范围 0 ~ na-1,正好全是合法下标;k 一直加,下标就在周期里绕圈。
注意如果一开始按下标从 1 数,就得写 (k-1) % na,两种编号方式不能混。
最小可运行示例
示例
int a[3] = {4, 1, 7};               // 一个周期的 4 1 7
for (int k = 0; k < 8; ++k)
    cout << a[k % 3] << " ";         // 4 1 7 4 1 7 4 1
注意

两个长度不同的周期序列要各算各的下标:a[k % na] 与 b[k % nb],不能共用一个。

k 为负时 % 会得到负数,这里 k 一定非负,安全。

策略讲完,先看一道示范。这题不难,但“边扫边维护一个状态”的套路会在本组 5 道题里反复出现。

例题 · 最长连号洛谷 P1420普及-去洛谷做这题 ↗

输入长度为 n 的正整数序列 a,求最长的连号长度。连号指:序列中一段连续的子串,其中每一项正比如前一项大 1。例如 2 3 4 5 是连号(长度 4),而 2 3 5 不是(3 到 5 断了)。

输入格式:第一行一个整数 n(1 ≤ n ≤ 10⁴);第二行 n 个整数 aᵢ(1 ≤ aᵢ ≤ 10⁹),空格分隔。

输出格式:一个整数,最长连号的长度。

输入: 8 1 5 6 2 3 4 5 6 输出: 5
思路拆解:只需要维护一个数

题目要的是“最长的一段”,不是“最长上升子序列”。差别只有一条:连号要求差恰好为 1,断了就必须重新数,不能跳过中间某个数接着数。

于是只需要维护一个状态:cur = 以当前位置结尾的连号有多长。扫描时只有两种转移:

  1. 和前一数差 1 → 接上了,cur 加 1;
  2. 否则 → 断了,cur 重置为 1(当前这个数自己算一段长度 1 的连号)。

每走一步都用 ans = max(ans, cur) 更新答案。因为“最长的那一段”一定在某一步结尾,所以扫描过程中记下 cur 的最大值就够了,不需要存第二遍。

复杂度:一次线性扫描 O(n),n ≤ 10⁴,耗时可以忽略。

把样例走一遍:cur 与 ans 怎么变

样例 a = 1 5 6 2 3 4 5 6。i 从 1 开始(要比较 a[i-1]):

ia[i]与前一数差 1?curans
01—(起点)11
15否,断11
26是22
32否,断12
43是22
54是33
65是44
76是55

答案 5(子串 2 3 4 5 6)。注意 i=3 时 cur 必须回到 1,不是回到 0:2 自己也是一段。

完整代码
#include <bits/stdc++.h>
using namespace std;

int a[10005];                    // 全局数组默认全 0;n ≤ 10⁴,10005 留余量

int main() {
    int n;
    cin >> n;
    for (int i = 0; i < n; ++i) cin >> a[i];

    int ans = 1, cur = 1;        // cur:以当前元素结尾的连号长度
    for (int i = 1; i < n; ++i) {
        if (a[i] == a[i - 1] + 1) ++cur;   // 接上了,长度加一
        else cur = 1;                      // 断了,从这个数重新数
        ans = max(ans, cur);               // 每一步都可以成为答案
    }
    cout << ans << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
int a[10005];开在 main 外面(全局):默认全 0,也不会挤爆栈。数据范围 n ≤ 10⁴,10005 留出余量,开数组前先看数据范围,这是要养成肌肉记忆的第一件事。
for (int i = 0; i < n; ++i) cin >> a[i];必须先把整个数组读进来:判断要回头看 a[i-1],边读边算做不到。
int ans = 1, cur = 1;n ≥ 1,至少一个数,答案至少是 1,所以两个数都从 1 起步。
for (int i = 1; i < n; ++i)从 1 开始,因为循环体里要用 a[i-1];从 0 开始就会访问 a[-1],越界。
a[i] == a[i - 1] + 1连号的定义就是“差恰好 1”。写成 a[i] > a[i-1] 会把上升序列误判成连号,WA。
else cur = 1;断了之后,当前元素自己是一段长度 1 的连号。写成 cur = 0 会让下一段整段少算一个。
ans = max(ans, cur);放在 if/else 之外,两条分支都要更新;写进 if 里面就会漏掉“整段从头到尾都连号”的情况。
cout << ans << "\n";只输出一个整数加换行。题目没要求输出长度以外的任何东西。
这 5 道题里最常见的 6 个丢分点

数组开小 → RE。看到 n ≤ 10⁴ 就开 10005,看到 n ≤ 200 就开 205。数组大小永远从数据范围推,不要凭感觉写 100。

循环边界差一 → WA。“0 到 l 有 l+1 个数”“区间含端点”这类句子必须逐字读,写成 j < v 就少砍一棵树。

行末多一个空格 → WA。一行输出多个数时,最后一个数后面只能换行;严格评测(NOIP 系列)会逐字节比对。

该向上取整写成整除 → WA。题面出现“至少”“够”,先想 (n+p-1)/p,样例里 n 不是 p 的倍数时立刻能暴露这个错。

变量没初值 / 复用了上一题的变量 → 答案随机。局部变量不初始化就是垃圾值,cnt、sum、ans 一律写 = 0 或 = 1。

10⁶ 次输出用 endl → TLE。endl 每次刷新缓冲区,比 "\n" 慢一个数量级;循环里只用 "\n"。

自己动手复现

关掉页面,把 P1047 用“标记法”写一遍再提交。

动手前先回答三个问题:数组要开多大?两个循环的边界各是什么?区间端点算不算?三个都答对再写。

写完把 l 改成 10、m 改成 2、两个区间改成 [1,3] 和 [2,5],纸算答案:0~10 共 11 棵,被砍的是 1,2,3,4,5,剩 6 棵。程序对不上就回头查边界。

改一改:如果 l 变成 10⁸、m 变成 10⁵,标记法为什么直接废了?先想清楚再往下节走。

卡住时按顺序做这三件事

1. 手算一组更小的数据:把 n 缩到 3、把 l 缩到 5,纸上算出答案再和程序比。第一条对不上的输入,就指出了 bug 在哪。

2. 打印中间变量:临时在循环里加一句 cout << i << " " << cur << "\n";,看它是不是按你预期的变;提交前删掉这句。

3. 重读题面最后两行:八成是边界、输出格式、多算或少算一个数,而不是算法想错了。

交卷前 60 秒,只查这 6 项

数组大小 ≥ 数据范围上限 + 5。

该用 long long 的地方都用了吗(和、积、平方)。

输出格式:几个数?空格还是换行?行末有没有多余空格?

边界跑过没有:n=1、l=0、全部相同、全是最小值。

计数变量初值写了没有。

题目给的样例,输出和标准答案逐字一样吗。

下面是 5 道训练题。先自己做,卡满 25 分钟再看提示;提示看完还没思路,再点开思路;代码一定要自己敲一遍再提交。

本次训练 · 共 5 题

做题方式

限时 100 分钟。每题先自己写 20~25 分钟;写不出来先看提示,再给自己 10 分钟;还不行才点开思路。每题都独立限时,不要一题耗死。

大题 挑战题 1 · 买铅笔
洛谷 P1909普及-去洛谷做这题 ↗

P 老师要买 n 支铅笔。商店只有 3 种包装,每种包装里的支数和价格都不同。为了公平,她决定只买同一种包装;包装不能拆开,所以可能买到的支数超过 n。求买够至少 n 支最少要花多少钱。

输入格式:第一行一个正整数 n;接下来 3 行,每行两个正整数,依次表示一种包装的支数与价格。7 个数都不超过 10000。

输出格式:一个整数,表示最少花费。

引导性提示(先只看这里)

三种包装互不影响:先分别算“只买第一种要多少”“只买第二种”“只买第三种”,再挑最小的。

买几包?拿 n = 57、2 支一包算一遍:28 包只有 56 支,不够。

“不够就得再补一包”这件事,会让结果比直接整除多 1。想想怎么用一行整数运算写出来。

想看思路了?点开完整思路

对每种包装单独结算:包数 × 价格,就是“只用这一种包装”的总花费。

包数 = n ÷ 每包支数 向上取整。整数除法的写法是 (n + p - 1) / p:先补到“刚好差一点点也进一”。

三种花费取 min,就是答案。

为什么不能“先挑单价最低的”?单价不是整数,而且凑包会浪费;n=57 时第三种包装单价更低、浪费更多,但总价仍然最低——只有结算总价才能比较。

复杂度 O(1),只看 3 组数据。int 足够:包数 ≤ 10⁴,花费 ≤ 10⁸。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;

    int best = INT_MAX;               // 擂主:int 的最大值,保证第一次一定被替换
    for (int i = 0; i < 3; ++i) {
        int p, c;
        cin >> p >> c;                // p 支一包,c 元一包
        int packs = (n + p - 1) / p;  // 向上取整:不足一包也要买一包
        best = min(best, packs * c);  // 三种包装各自结算,取最小
    }
    cout << best << "\n";
    return 0;
}
关键片段拆解
片段作用
int best = INT_MAX;INT_MAX 是 int 上限 2147483647,当“还没选过任何方案”的初值;若写 best = 0,min 永远是 0。
for (int i = 0; i < 3; ++i) { cin >> p >> c; }三种包装固定是 3 组,边读边算,不用开数组存下来。
(n + p - 1) / p整数向上取整的标准写法。n=57、p=2:(57+1)/2 = 29;直接写 n/p 得 28,少一包。
packs * c总价 = 包数 × 单价。本题最大约 10⁸,int 不会溢出。
best = min(best, packs * c);打擂台:每结算完一种包装就更新一次答案,循环结束即最优。
这题最容易错的地方
  • 写成 n / p,n 不是 p 的倍数时少买一包,WA。
  • 只按“每支单价最低”选包装,忽略凑包浪费,样例就错。
  • best 初始化为 0(或忘了初始化),min 结果恒为 0,WA。
  • 只读了第一行包装数据就输出,后两组根本没读,答案必然偏大。
大题 挑战题 2 · Cantor 表
洛谷 P1014普及-去洛谷做这题 ↗

Cantor 把有理数按斜线排成一张表,并按 Z 字形依次编号:第一项 1/1;接着 1/2、2/1;接着 3/1、2/2、1/3;接着 1/4、2/3、3/2、4/1;接着 5/1、4/2、3/3、2/4、1/5……也就是第 1 条斜线 1 个数、第 2 条 2 个数、第 3 条 3 个数,且相邻两条斜线的走向相反。

输入格式:一个整数 N(1 ≤ N ≤ 10⁷)。

输出格式:表中的第 N 项,格式为“分子/分母”,例如 1/4。

引导性提示(先只看这里)

别急着求坐标。先数一数:第 k 条斜线上有几个数?前 k 条斜线一共几个数?

1+2+…+k 这个和你已经会算。拿它跟 N 比,就能判断 N 落在第几条斜线上。

同一条斜线上的数,分子与分母的和是固定的。知道 N 是这条斜线的第几个,两个数就都定了。

想看思路了?点开完整思路

第 k 条斜线有 k 个数,前 k 条一共 k(k+1)/2 个数。所以找最小的 k 使 k(k+1)/2 ≥ N,它就在第 k 条斜线上。

第 k 条斜线之前的数是 k(k-1)/2 个,于是 N 在这条斜线上是第 pos = N − k(k-1)/2 个(从 1 数)。

同一条斜线上分子 + 分母 = k+1。k 为偶数时分子从 1 递增,分子 = pos;k 为奇数时分子从 k 递减,分子 = k+1−pos。分母由和减去分子得到。

验证:N=7。k=3 时 3×4/2=6 < 7,k=4 时 4×5/2=10 ≥ 7,所以在第 4 条斜线;pos = 7 − 4×3/2 = 1;k 是偶数,分子 1、分母 4 → 1/4。

复杂度:k 最大约 4472,循环几千次,O(√N),10⁷ 的 N 瞬间出结果。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    long long n;
    cin >> n;

    long long k = 1;
    while (k * (k + 1) / 2 < n) ++k;       // 找到 N 所在的斜线编号 k

    long long pos = n - k * (k - 1) / 2;   // 在这条斜线上是第几个(从 1 数)
    long long a, b;                        // a 分子,b 分母
    if (k % 2 == 0) {                      // 偶数斜线:分子 1,2,3,...
        a = pos;
        b = k + 1 - pos;
    } else {                               // 奇数斜线:分子 k,k-1,...
        a = k + 1 - pos;
        b = pos;
    }
    cout << a << "/" << b << "\n";
    return 0;
}
关键片段拆解
片段作用
while (k*(k+1)/2 < n) ++k;k(k+1)/2 是第 k 条斜线结束时的编号。k 从小到大试,第一次 ≥ n 就停,此时 k 就是答案所在的斜线。
k*(k-1)/2前 k−1 条斜线一共有多少个数。N 减去它,就是 N 在本斜线上的序号 pos。
k % 2 == 0偶数斜线自右上向左下,分子从 1 开始递增,所以分子直接等于 pos。
k + 1 - pos同一条斜线上 分子+分母 = k+1 恒成立;知道分子就能反推分母。
long long n, kN ≤ 10⁷ 时 int 本来就够,但 k*(k+1) 这类中间量会涨到 2×10⁷ 以上,统一用 long long 免掉以后改数据范围时的隐患。
cout << a << "/" << b << "\n";输出格式是“分子/分母”:中间一个斜杠,不是空格、不是小数。
这题最容易错的地方
  • 找到 k 之后忘了算 pos,直接把 k 或 N 输出,WA。
  • 奇偶方向写反:偶数线分子递增、奇数线分子递减。写反时样例全错。
  • 位置用 n − k(k+1)/2 算(用了线末编号),得到的是 0 或负数,WA;要用线首之前的 k(k−1)/2。
  • k*(k+1) 全程用 int,数据稍大就溢出成负数,循环直接跑飞(TLE)。
大题 挑战题 3 · 校门外的树
洛谷 P1047普及-去洛谷做这题 ↗

马路长 l 米,数轴上 0 到 l 每个整数点都种了一棵树,共 l+1 棵。有 m 个区间要建地铁,区间 [u,v] 上的树(含端点)都要移走,区间之间可能重叠。求移走后还剩多少棵树。

输入格式:第一行两个整数 l、m(1 ≤ l ≤ 10⁴,1 ≤ m ≤ 100);接下来 m 行,每行两个整数 u、v(0 ≤ u ≤ v ≤ l)。

输出格式:一行一个整数,剩余的树的数量。

引导性提示(先只看这里)

树的规模只有一万棵,区间只有一百个——这是“每棵树都看一眼”也完全能过的范围。

想办法让每棵树自己记住“我还在不在”,这样重叠区间就不需要任何特殊处理。

区间端点处的树也要移走。数一下 0 到 l 到底有几棵树。

想看思路了?点开完整思路

开一个数组 cut[0..l],cut[i] = 1 表示第 i 米处那棵树已被移走,初值全 0。

每读入一个区间 [u,v],就 for (j = u; j <= v; ++j) cut[j] = 1。

全部区间处理完后,统计 cut[j] == 0 的个数,就是剩下的树。

为什么用标记法而不是“总长度减并集长度”:并集要判断重叠,而标记法把“砍两次”自动压成“砍一次”,重叠问题根本不存在。

复杂度 O(l·m) = 10⁴ × 100 = 10⁶,稳过 1 秒。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int cut[10005];                  // cut[i] = 1 表示第 i 米处的树被移走

int main() {
    int l, m;
    cin >> l >> m;

    for (int i = 0; i < m; ++i) {
        int u, v;
        cin >> u >> v;
        for (int j = u; j <= v; ++j) cut[j] = 1;   // 端点也要砍,所以是 <=
    }

    int ans = 0;
    for (int j = 0; j <= l; ++j)                   // 0..l 一共 l+1 棵树
        if (cut[j] == 0) ++ans;

    cout << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
int cut[10005];开在全局:默认全 0,也没有栈溢出风险。l ≤ 10⁴,10005 够用且留了余量。
for (int j = u; j <= v; ++j)u 到 v 的每个整数点都有树,端点在区间内,所以是 <= 而不是 <。
cut[j] = 1;标记式覆盖:同一棵树被两个区间覆盖也只记一次,重叠区间天然被处理掉。
for (int j = 0; j <= l; ++j)树的编号是 0,1,…,l,一共 l+1 棵。写成 j < l 会漏掉末尾那棵。
if (cut[j] == 0) ++ans;统计没被标记的点,就是没被砍掉的树。
int ans = 0;计数变量必须显式初始化,局部变量不赋初值就是垃圾值。
这题最容易错的地方
  • 内层写成 j < v,端点 v 那棵树没被砍,WA。
  • 统计时写 j < l,漏掉 l(或忘了 0),答案偏小。
  • 数组开成 1005(l 可以到 10⁴),越界后可能 RE,也可能悄悄改坏别的变量。
  • 想用“总棵数减去各区间长度”硬算,重叠区间被重复减,WA。
大题 挑战题 4 · 计数问题
洛谷 P1980普及-去洛谷做这题 ↗

统计 1 到 n 的所有整数中,数字 x 一共出现了多少次。例如 1 到 11 中,数字 1 出现了 4 次:1 里有 1 个,10 里有 1 个,11 里有 2 个。

输入格式:两个整数 n、x(1 ≤ n ≤ 10⁶,0 ≤ x ≤ 9),空格分隔。

输出格式:一个整数,表示 x 出现的次数。

引导性提示(先只看这里)

统计对象是“位”,不是“数”:1 到 11 一共 22 个数字位。

怎么把一个整数的每一位拿出来?想想取末位用什么运算、去掉末位用什么运算。

x 可以是 0。检查一下你的程序会不会漏掉 10、20 这类数里的 0。

想看思路了?点开完整思路

外层枚举 i = 1 … n,内层把 i 的每一位拆出来,等于 x 就加一。

拆位三件套:t % 10 取末位,t /= 10 去掉末位,循环到 t == 0 为止。

关键:拆位一定要用副本 t = i,否则会把外层循环变量 i 改成 0,直接死循环。

复杂度 O(n log n):10⁶ 个数平均 6~7 位,约 7×10⁶ 次操作,过。

答案最大约 6×10⁶,int 够用;若 n 改成 10⁹ 就必须 long long。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, x;
    cin >> n >> x;

    int cnt = 0;
    for (int i = 1; i <= n; ++i) {
        int t = i;                     // 用副本拆位,绝不能动 i
        while (t > 0) {
            if (t % 10 == x) ++cnt;    // 末位等于 x 就计数
            t /= 10;                   // 砍掉末位:123 -> 12 -> 1 -> 0
        }
    }
    cout << cnt << "\n";
    return 0;
}
关键片段拆解
片段作用
int t = i;拆位的循环会把数字除到 0,必须用副本。直接写 while (i > 0) 会把 for 的循环变量改掉,要么死循环(TLE),要么直接跳过一大段数(WA)。
t % 10 == x取末位与 x 比较。x = 0 时同样成立,所以 10、20 里的 0 能被统计到。
t /= 10;整数除法去掉末位,是取模的逆操作。
while (t > 0)i ≥ 1,t 至少有一位,循环至少进一次。用 t > 0 而不是 do-while,避免 i = 0 时凭空多算一个 0。
++cnt每个匹配的位加一。本题答案量级 10⁶,int 足够。
for (int i = 1; ...)从 1 开始。题目统计的是 1 到 n,从 0 开始会多算一个数。
这题最容易错的地方
  • 直接对 i 做 t /= 10 一类的操作,改坏外层循环变量,TLE 或答案错误。
  • 用 do-while 拆位,i = 0 时会白算一次(0 % 10 == 0),x = 0 时答案偏大。
  • 外层从 0 开始枚举,凭空多统计一个数。
  • cnt 用 int 在本题没问题,但换成 n = 10⁹ 的版本就溢出;养成先估量级的习惯。
大题 挑战题 5 · 生活大爆炸版石头剪刀布
洛谷 P1328普及/提高-去洛谷做这题 ↗

猜拳升级版有 5 种手势:0 剪刀、1 石头、2 布、3 蜥蜴人、4 斯波克。胜负关系:剪刀胜布和蜥蜴人;石头胜剪刀和蜥蜴人;布胜石头和斯波克;蜥蜴人胜布和斯波克;斯波克胜剪刀和石头;手势相同为平局。赢者得 1 分,输者或平局得 0 分。

小 A 的出拳以长度 nA 的序列为周期循环,小 B 以长度 nB 的序列为周期循环,两人一共出 N 拳。输入格式:第一行三个整数 N、nA、nB;第二行 nA 个整数(小 A 的周期);第三行 nB 个整数(小 B 的周期)。

输出格式:一行两个整数,分别是小 A、小 B 的得分,用一个空格分隔。数据范围:0 < N, nA, nB ≤ 200。

引导性提示(先只看这里)

10 组胜负关系硬写 if 很容易漏。先把它们整理成一张表,让程序只做“查表”这一件事。

第 k 拳两个人各出什么?两个周期长度不同,得各算各的下标。

查表只能回答“A 出 i、B 出 j 时谁赢”。另一种情况怎么办?换个顺序再查一次就够了。

想看思路了?点开完整思路

把“A 出 i、B 出 j 时 A 是否赢”做成一张 5×5 的表,值为 1 表示 A 赢。

5×5 的表按行拼成一个长度 25 的一维数组:第 i 行第 j 列存在 W[i*5 + j]。这样只用一维数组,不用等后面的二维数组。

第 k 拳(k 从 0 数):x = a[k % nA],y = b[k % nB]。若 W[x*5+y] 则 A 得分;否则若 W[y*5+x] 则 B 得分;两个都不成立就是平局,两人都不加分。

为什么用表而不是 if:胜负关系有 10 组,if 分支至少 10 个,漏一个就 WA 且很难查;表只有 25 个数字,能逐行核对(每行恰好 2 个 1,全表 10 个 1)。

复杂度 O(N),N ≤ 200,几乎不耗时。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

// W[i*5+j] == 1 表示:小 A 出 i、小 B 出 j 时,A 赢
// 0 剪刀   1 石头   2 布   3 蜥蜴人   4 斯波克
const int W[25] = {
    0, 0, 1, 1, 0,        // 剪刀 胜 布、蜥蜴人
    1, 0, 0, 1, 0,        // 石头 胜 剪刀、蜥蜴人
    0, 1, 0, 0, 1,        // 布   胜 石头、斯波克
    0, 0, 1, 0, 1,        // 蜥蜴人 胜 布、斯波克
    1, 1, 0, 0, 0         // 斯波克 胜 剪刀、石头
};

int a[205], b[205];

int main() {
    int n, na, nb;
    cin >> n >> na >> nb;

    for (int i = 0; i < na; ++i) cin >> a[i];
    for (int i = 0; i < nb; ++i) cin >> b[i];

    int sa = 0, sb = 0;
    for (int k = 0; k < n; ++k) {
        int x = a[k % na];             // 周期序列:第 k 拳 = 周期的第 k%na 项
        int y = b[k % nb];
        if (W[x * 5 + y])      ++sa;   // 查表:A 赢
        else if (W[y * 5 + x]) ++sb;   // 交换顺序再查:B 赢
        // 都不成立就是平局,两人都不加分,不必写分支
    }
    cout << sa << " " << sb << "\n";
    return 0;
}
关键片段拆解
片段作用
const int W[25] = {...}5×5 的胜负表按行首尾相接压成一维:第 i 行第 j 列存在下标 i*5+j。表写成常量并加注释,抄错时一眼能看出来。
a[k % na]k 是第几拳(从 0 数),k%na 把它映射回周期内的下标,周期序列就变成了任意长序列。
W[x * 5 + y]还原成二维:行是 A 的手势,列是 B 的手势,值为 1 表示这一拳 A 赢。
else if (W[y * 5 + x])把两人手势交换再查一次,含义变成“B 出 y、A 出 x 时 B 是否赢”。不需要写第二张表。
没有 else 分支平局两人都得 0 分,正好什么都不做。少一个分支,少一个出错的地方。
int a[205], b[205];nA、nB ≤ 200,开 205 留余量;下标 0 开始,所以读入循环写 i < na。
这题最容易错的地方
  • 胜负表抄错一格,WA 且极难自查。抄完先验:每行恰好 2 个 1,全表 10 个 1。
  • 下标偏移写错(用 (k-1)%na 或 k%n+1),整场比赛全盘错位,WA。
  • 只判 A 赢、其余全算 B 赢(写成 else ++sb),平局被算成 B 得分,WA。
  • 数组开 105,nA 到 200 时越界,RE 或答案错乱。
做完之后自问
  • 能在 5 分钟内通读 5 题,排出一个“会做优先”的顺序,而不是从第 1 题往后硬做。
  • 能不看资料写出“扫描数组、维护当前状态、随时更新答案”的骨架(本节 6 段代码里有 5 段是它)。
  • 知道 (n + p - 1) / p 为什么要加 p - 1,并能用 n = 57、p = 2 手算验证。
  • 知道 a[k % na] 为什么能取到周期序列,以及 k 从 0 开始时下标范围是 0 ~ na-1。
  • 提交前会做三项检查:数组大小、计数变量初值、输出格式(行末不多空格)。
  • 卡题满 25 分钟会主动换题,回头再看时能重新读一遍题面。
延伸阅读
  • 《算法竞赛入门笔记》第 1~4 章:输入输出、循环、图形与数组
  • 洛谷 P1909、P1014、P1047、P1980、P1328 题解区的高赞思路
  • 《算法竞赛黄金法则》第 1 章:先把样例在纸上走一遍
06 桶计数:用值当下标入门

06 · 桶计数:用值当下标

把数组下标从“位置”换成“值”:边读入边完成统计、去重,扫一遍桶就是升序输出,不需要 sort。同时算清桶的代价 —— 值域 V 就是它的天花板,也是判断“该不该用桶”的唯一标准。
⭐ 已学范围入门预计 55 分钟桶计数排序离散化思想
本节出现的新函数 / 新语法(先看懂再用)

第 4 节的数组,下标是位置:a[0] 是第 0 个读进来的数,a[i] 是第 i 个。这一节换一种用法:让下标等于值本身。cnt[x] 读作“值等于 x 的元素有几个”。数组还是那个 int[],变的只是下标的含义。

拿位置当下标,回答“第几个是什么”;拿值当下标,回答“这个值有几个”。后一种用法的数组叫桶。

先看这段骨架。本节剩下的内容,都是在解释它为什么成立、什么时候不成立:

桶计数的骨架
int cnt[1001];                        // 值域 0..1000,所以要 1001 个桶

for (int i = 0; i < n; ++i) {
    int x;
    cin >> x;
    ++cnt[x];                         // 值 x 出现一次,就让第 x 号桶加一
}

for (int x = 0; x <= 1000; ++x)       // 按下标从小到大扫一遍
    for (int k = 0; k < cnt[x]; ++k)
        cout << x << " ";             // 输出自动升序,一次 sort 都没调用

这几行同时干完三件事:统计(每个值出现几次)、去重(同一个桶只算一次)、排序(下标从小到大就是值的从小到大)。代价是开 1001 个格子。

普通数组 a[i]桶 cnt[x]
下标含义第 i 个位置值 x 本身
格子内容那个位置上的数值 x 出现的次数(或“是否出现”)
大小由谁决定元素个数 n值域上界 V
读入后能做什么按原顺序重放统计、去重、升序输出、O(1) 判断存在
总复杂度O(n)O(n + V)

本节没有新的标准库函数。要拆的是数组的新用法:格子怎么开、cnt[x]++ 到底做了什么、什么时候该用 “= 1” 而不是 “++”。

新函数拆解 · 桶数组 int cnt[V + 1];
int cnt[V + 1]; // V = 值域上界,值 ∈ [0, V]
组成含义 / 格式要求
cnt数组名。它存的不是“数据”,而是“每个值出现的次数”,所以叫桶(bucket)。
V + 1桶的个数等于值域大小。值能取到 0..V 共 V+1 种,下标就必须有 0..V,所以要开 V+1 个。
int计数器用 int 够:个数最多 n 个,远小于 2.1×10⁹。只有当桶里要存“权值之和”时才换 long long。
全局 / static写在 main 外面时全部分配在静态区,系统自动清零。写在 main 里面是随机值,必须先清零。
空间代价4 × (V+1) 字节。V = 1000 是 4 KB;V = 10⁶ 是 4 MB;V = 10⁷ 是 40 MB,这已是天花板。
最小可运行示例
示例
int cnt[1005];               // 全局:值域 1..1000,全自动清零

int main() {
    int n;  cin >> n;
    for (int i = 0; i < n; ++i) {
        int x;  cin >> x;
        ++cnt[x];            // 值 x 出现一次,第 x 号桶加一
    }
    cout << cnt[7] << "\n";  // 值 7 出现了几次
    return 0;
}
注意

桶的大小由值的范围决定,和 n 毫无关系。n 只有 10,值到 10⁶,照样要开 10⁶+1 个。

桶开在 main 里又忘了清零,是“答案莫名多出一堆数”的头号原因。

值里有负数时不能直接当下标:先整体加一个偏移量搬进非负区间。

新函数拆解 · cnt[x]++
++cnt[x]; // 等价于 cnt[x] = cnt[x] + 1;
组成含义 / 格式要求
cnt[x]先算出下标 x 对应的地址(cnt + x * 4),再取那一格。x 必须落在 [0, 数组长度) 内,没有越界检查。
++自增:把那一格的值加一。单独成一句时,++cnt[x] 与 cnt[x]++ 效果完全相同。
语义“值等于 x 的元素又出现了一个”。它同时完成了定位与计数,中间没有循环、没有比较。
O(1)一次 ++ 只做“算地址 + 加一”,与桶多大、数据多少都无关。这是桶能打败排序的根本原因。
三种变体cnt[x] = 1 只记“出现过”;++cnt[x] 记“几次”;cnt[x] += w 记“权值和”;--cnt[x] 表示“消耗一个”。
最小可运行示例
示例
int cnt[10] = {0};

cnt[3]++;  cnt[3]++;  cnt[9]++;        // 值 3 出现 2 次,值 9 出现 1 次
cout << cnt[3] << " " << cnt[9];       // 2 1

int x = 3;
if (cnt[x] > 0) --cnt[x];              // “消耗”一个 3:配对、匹配类题目的常用写法
注意

别写成 cnt[x++]:那是“用 x 当下标,然后把 x 加一”,完全另一回事。

x 是 char 时会被当作 ASCII 码当下标,所以 cnt['a'] 和 cnt[97] 是同一个桶。

cnt[x]++ 不检查 x 是否合法。越界一格不会报错,只会悄悄改掉相邻内存。

新函数拆解 · 标记桶 cnt[x] = 1
cnt[x] = 1; // 只记“出现过”,丢掉了次数
组成含义 / 格式要求
cnt[x] = 1赋值而不是自增。同一个 x 读入十次,格子仍然是 1 —— 去重是赋值本身带来的。
读法if (cnt[x]) 表示 x 出现过;if (!cnt[x]) 表示没出现过。任意非 0 都算真。
与 ++ 的区别标记法丢掉了次数信息。要次数就用 ++,要存在性就用 = 1,别两个混着用。
bool 数组bool vis[N + 1]; 每格 1 字节,比 int 省 3/4。值域 10⁷ 时 int 要 40 MB,bool 只要 10 MB。
最小可运行示例
示例
int vis[1005];                    // 0 = 没出现,1 = 出现过

vis[42] = 1;                      // 标记值 42 存在
if (vis[42])  cout << "有 42\n";
if (!vis[7])  cout << "没有 7\n";
注意

需要“出现过几次”时用标记桶会丢信息;需要“存在性”时用计数桶只是白花力气。先想清楚要哪一个。

同一道题里两种桶可以同时用:本节挑战题里 have[] 判存在、used[] 判是否已计过答案,就是两个桶。

例题 · 明明的随机数洛谷 P1059普及-去洛谷做这题 ↗

明明用计算机生成了 n 个 1 到 1000 之间的随机整数,其中有重复。他要先把重复的数去掉、每种只留一个,再把这些数从小到大排好,按排好的顺序给同学编号。

输入格式:第一行一个整数 n(1 ≤ n ≤ 100);第二行 n 个用空格隔开的整数,每个都在 1 到 1000 之间。

输出格式:第一行一个整数 M,表示不相同的数的个数;第二行 M 个整数,从小到大用空格隔开。

一组小数据: 输入: 6 20 40 20 99 40 20 输出: 3 20 40 99
例题怎么想:把“排序 + 去重”换成“按下标找人”

题目要两件事:去重、升序。常规做法是读进数组 → 排序 → 相邻比较去重,三步,还得先会写排序。

换个角度:值的可能取值只有 1 到 1000,一共 1000 种。既然只有 1000 种,就为每一种准备一个格子,格子的编号直接等于值。读到一个 x,就去第 x 号格子做个记号。

cnt[20] = 1;   // 读到 20,给 20 号格子打勾
cnt[40] = 1;
cnt[20] = 1;   // 又读到一个 20:本来就是 1,再写一次还是 1 —— 去重是免费的

读完以后从 1 号格子扫到 1000 号格子,打了勾的就把编号输出。格子编号天然从小到大,所以输出天然升序 —— 排序也是免费的。

为什么能省掉排序和去重
代码片段含义与理由
for (int x = 1; x <= 1000; ++x)这里枚举的是“可能的值”,不是“已读入的数”。下标的顺序替我们做了比较。
if (cnt[x])非 0 即真:这个值出现过。0 就是没出现。
cout << x输出的是下标 x,不是 cnt[x]。桶里存的是次数,我们要的是值 —— 这是新手最容易写反的一行。
cnt[x] = 1赋值导致重复读入不改变结果,所以不需要额外的去重步骤。

代价写在明面上:多花 1001 个 int(4 KB),把 O(n log n) 的排序换成了 O(n + 1000) 的一次扫描。这就是以空间换时间的字面意思。

完整代码(P1059)
#include <bits/stdc++.h>
using namespace std;

// cnt[x] = 1 表示“值 x 出现过”,0 表示没出现。
// 值域是 1..1000,下标最多用到 1000;开 1005 是手滑余量。
// 放在 main 外面:全局数组由系统清零,省掉一次手动清零,也不占栈空间。
int cnt[1005];

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;
    for (int i = 0; i < n; ++i) {
        int x;
        cin >> x;
        cnt[x] = 1;          // 用 = 1 而不是 ++cnt[x]:只要“出现过”,不要次数
    }

    int m = 0;               // 题目要求先输出个数,所以先数一遍
    for (int x = 1; x <= 1000; ++x)
        if (cnt[x]) ++m;

    cout << m << "\n";

    bool first = true;       // 控制空格:第一个数前面不输出空格,行末就不会多出空格
    for (int x = 1; x <= 1000; ++x)
        if (cnt[x]) {
            if (!first) cout << " ";
            first = false;
            cout << x;
        }
    cout << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
int cnt[1005];桶的大小由值域决定:最大下标 1000,至少 1001 个。放全局是为了自动清零,同时避开 8 MB 的栈限制。
cnt[x] = 1;值当下标。这一步只做一次地址计算 cnt + x*4,与 n、与 V 都无关,是 O(1)。
for (int i = 0; i < n; ++i)读入阶段和普通数组一模一样,区别只是“存进第 x 格”而不是“存进第 i 格”。
for (int x = 1; x <= 1000; ++x)固定 1000 次,与 n 无关。n = 3 也要扫 1000 次 —— 这就是桶的 V 成本。
if (cnt[x]) ++m;cnt[x] 为 1 表示出现过,计数加一。非 0 即真,不用写 == 1。
bool first = true;第一个输出前不加空格,避免行首/行末多空格被严格比对判 WA。
if (!first) cout << " ";除第一个数外,每个数前面补一个空格,等价于“用空格分隔”。
return 0;正常结束。桶法不需要 sort,也就没有 cmp、没有稳定性问题。
同一个桶,两种存法:标记与计数

把 cnt[x] = 1 换成 ++cnt[x],桶里存的就是次数。这时扫一遍桶就能按升序输出全部元素,包括重复的 —— 这个算法有名字:计数排序。

计数排序:桶 + 顺序扫描
// 输入 n 个数(值 1..1000,允许重复),升序全部输出
int cnt[1005];

int main() {
    int n;  cin >> n;
    for (int i = 0; i < n; ++i) { int x; cin >> x; ++cnt[x]; }

    for (int x = 1; x <= 1000; ++x)
        for (int k = 1; k <= cnt[x]; ++k)    // 出现几次就打印几次
            cout << x << " ";
    cout << "\n";
}

计数排序不是比较排序:它从不比较两个元素的大小,所以 O(n log n) 的比较次数下界对它不成立,它只受值域 V 限制。这也是它快的原因,也是它碰不了“值域巨大”和“字符串、结构体”的原因。

需求桶里存什么写法
有多少种不同的值0/1 标记cnt[x] = 1
每个值出现几次次数++cnt[x]
升序输出全部元素次数++cnt[x] 后按次数打印
判断某个值在不在0/1 标记if (cnt[x])
值域太大时:先压缩,再开桶

桶唯一的门槛是 V。V 开不出数组,桶法就不能直接用。先记住这张判断表:

值域上界 V桶的大小(int)能不能用
10³4 KB放心用
10⁶4 MB放心用
10⁷40 MB128 MB 限制下勉强,别再开第二个大数组
10⁸400 MB别想,换方法
10⁹4 GB绝对开不出来

V 太大时有两条现在就能用的出路,还有三条以后会学的:

  • 压缩值域:分数保留一位小数、范围 0.0~100.0,就乘 10 变成 0~1000 的整数再开桶;温度 −50~50,就整体加 50 变成 0~100。先把值映射成小整数,再当下标,这就是离散化的雏形。
  • 只关心存在、查询又不多:把数读进数组,查询时从头扫一遍。单次 O(n),n ≤ 1000 时可接受。
  • 以后的三条:把值编号成 1..m 的离散化(第 13 节)、排序后二分(第 27 节)、哈希表(第 51 节)。
平移:把负数搬进合法下标
// 值域是 -50..50 的温度:整体平移 50,下标变成 0..100
int cnt[101];
for (int i = 0; i < n; ++i) {
    int t;  cin >> t;
    ++cnt[t + 50];                 // -50 -> 0, 0 -> 50, 50 -> 100
}
cout << cnt[50] << "\n";           // 温度 0 对应下标 50

压缩和离散化的共同点:先用一个单调映射把大值域换成小下标,之后所有操作都在小下标上做。区别只在离散化是先看数据再定映射,压缩是直接按公式算。

这一节最容易犯的 6 个错,附症状

桶开小了:值最大 1000 却写 int cnt[1000];,cnt[1000] 已经越界一格。症状:本地 AC、提交 RE;或答案莫名错一位(写坏了相邻变量,编译器一声不响)。

忘了清零:桶写在 main 里、又是多组数据,第二轮在第一轮结果上继续累加。症状:WA 且答案偏大,第一组数据永远是对的。

看错值域:题面写 ai ≤ 10⁹,还坚持开桶。症状:编译通过,一运行就崩或直接 MLE;若能开出的桶太小又硬要扫完 10⁹ 个下标,则是 TLE。

在 main 里开大数组:int cnt[1000005]; 要 4 MB 栈空间,栈一般只有 8 MB。症状:RE(SIGSEGV),换台机器结果还不一样。挪到全局即可。

用桶输出“原顺序”:桶只记得哪些值出现过,不记得谁先出现。症状:顺序全乱的 WA。

负数直接当下标:++cnt[-3]。症状:RE,或悄悄改坏别的内存,且只在含负数的数据上错。

自己动手复现

关掉页面,把上面 P1059 的完整代码手打一遍并提交。

再试一组数据:第一行 8,第二行 5 5 3 9 3 3 1 9。去重后应是 1 3 5 9,第一行个数应为 4。若你的第一行输出 8,说明你数的是元素个数而不是桶。

改一改:把 cnt[x] = 1 改成 ++cnt[x],输出那段改成“值 x 打印 cnt[x] 次”,同一组数据应输出 1 3 3 3 5 5 9 9 —— 一个字符之差,桶就从去重变成了计数排序。

数学补给 · 桶是一张函数表:O(n + V) 是怎么算出来的

定义 f(v) = 值 v 在输入中出现的次数。桶数组就是把 f 在 v = 0, 1, …, V 上的取值全部存下来。所以桶计数的本质是打表:输入还没来,就先为每个可能的输入准备好了答案。

时间账:读入 n 个数,每个做一次 ++cnt[x],共 n 次 O(1);最后从 0 扫到 V,共 V+1 次。总时间 O(n + V) —— 只和“值域多宽”有关,和“值多大”无关。

V 与 n 的关系总操作数结论
n = 10⁵,V = 10⁵≈ 2×10⁵比 sort 的 n log n ≈ 1.7×10⁶ 快近一个量级
n = 100,V = 10001100扫桶比数据多花 10 倍,仍可忽略
n = 10⁵,V = 10⁹≈ 10⁹必 TLE;空间 4 GB 也开不出来

空间账:桶占 4(V+1) 字节。口诀是 V ≤ 10⁷、且 V 不比 n 大太多,才用桶。V ≫ n 时,正确工具是排序或离散化,不是桶。

再往前一步就是前缀和:令 S(x) = cnt[0] + cnt[1] + … + cnt[x],S(x) 就是“值 ≤ x 的元素个数”。第 k 小的数,就是第一个满足 S(x) ≥ k 的 x。桶 + 前缀和 = 不排序也能求第 k 小,这条线索在第 18 节(前缀和)和第 13 节(离散化)会接上。

举一反三 · 想清楚这些再往下走
  1. 如果题目改成“输出每个数各出现了几次”,桶法要改哪一行?如果要“按出现次数从多到少输出”,桶还能直接用吗?
  2. 值域是 1..10⁹、n 只有 10⁵,桶开不出来。你能不能想个办法,让真正用到的下标不超过 n 个?(这通向第 13 节的离散化)
  3. 桶天然有序。如果题目要求“按输入顺序输出去重结果”,桶的哪一步会失效?你会怎么补这一刀?
  4. 计数排序和 sort 比,什么时候更快?当元素是字符串 “abc”“abd” 时,桶还能用吗?为什么?
  5. 多组数据、每组 10⁵ 个数、V = 10⁶,共 10 组。桶的清零成本是多少?会不会把 O(n+V) 拖成 O(T·V)?有什么办法少清零?
  6. 桶里存的如果是“该值第一次出现的下标”,能回答哪些新问题?试着用它求“每个值最早在哪一位出现”。
自测:能全部回答再进下一节
  • 能说清 a[i] 与 cnt[x] 的区别,并写出“读 n 个数进桶”的三行代码。
  • 看到值域上界,能立刻算出桶要开多大、占多少内存,并判断能不能用。
  • 能解释为什么桶遍历输出天然有序,且不需要额外的去重步骤。
  • 能说出值域 10⁹ 时桶为什么不可行,并至少给出两条替代路线。
  • 知道全局数组与局部数组在清零、栈空间上的差别,并能写出多组数据的清空代码。
  • 能手写计数排序,并说清它为什么不受 O(n log n) 下界约束。
挑战题 · 珠心算测验
洛谷 P2141普及-去洛谷做这题 ↗

老师随机生成一个正整数集合,集合里的数互不相同。她要求学生回答:其中有多少个数,恰好等于集合中另外两个(不同的)数之和。

输入格式:第一行一个整数 n(3 ≤ n ≤ 100);第二行 n 个用空格隔开的正整数,互不相同,每个不超过 10000。

输出格式:一行一个整数,表示满足条件的数的个数。

引导性提示(先只看这里)

先想暴力:枚举“和”,再枚举两个加数,是三层循环。能不能只枚举两个加数,让“和”自己算出来?

题目问的是有多少个数满足条件,不是有多少对。同一个和可能被好几组数凑出来。

两个加数必须是集合里不同的数:集合里只有一个 2 时,不存在 2 + 2 = 4。

想看思路了?点开完整思路

第一步把集合搬进桶:have[v] = 1 表示 v 在集合里。此后“某个值在不在”只需 O(1)。

第二步枚举两个不同的数 a[i]、a[j](规定 i < j),算出 s = a[i] + a[j]。若 have[s] 为 1,说明 s 是集合里的数、且能被两个不同的数凑出来。

第三步解决重复:{1,2,3,4,5} 里 5 = 1+4 = 2+3,会被枚举到两次,但题目只要一个 5。所以要再开一个 used[s] 桶,第一次算到时置 1,之后再遇到就跳过。

值域:每个数 ≤ 10000,两数之和 ≤ 20000,所以 have、used 都开 20005。

复杂度 O(n² + V) = 10⁴ + 2×10⁴ 级别,n ≤ 100 时瞬间出解。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int a[105];
int have[20005];      // have[v] = 1 表示值 v 在集合里
int used[20005];      // used[v] = 1 表示值 v 已经算过一次答案

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;
    for (int i = 0; i < n; ++i) {
        cin >> a[i];
        have[a[i]] = 1;                  // 存在性标记:值当下标
    }

    int ans = 0;
    for (int i = 0; i < n; ++i)
        for (int j = i + 1; j < n; ++j) {      // j 从 i+1 开始,两个加数必定不同
            int s = a[i] + a[j];
            if (s <= 20000 && have[s] && !used[s]) {
                used[s] = 1;             // 同一个和只记一次
                ++ans;
            }
        }

    cout << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
have[a[i]] = 1;值当下标,把 10000 个可能的取值压成一个可 O(1) 查询的表。用 = 1 而不是 ++,因为只问存在性。
for (int j = i + 1; ...)j 从 i+1 起,两个加数来自不同位置,天然排除了 a[i] + a[i]。
int s = a[i] + a[j];和的最大值是 10000 + 10000 = 20000,这就是桶要开 20005 的原因。
have[s]O(1) 判断 s 是不是集合里的数 —— 这是本题用桶、而不是再套一层循环的全部理由。
!used[s]同一和去重。没有它,{1,2,3,4,5} 里的 5 会被 1+4 和 2+3 各算一次。
used[s] = 1; ++ans;先标记再计数,顺序不能反:反过来每一对都会被算上。
s <= 20000防御性检查。桶只有 20005 格,数据超题面时宁可漏算,也不能越界写坏内存。
这题最容易错的地方
  • 枚举写成 i <= j,允许同一个数用两次,把 {2,4} 里的 4 判成 2 + 2,答案偏大。
  • 没有 used 桶去重,同一个和由多组数凑出时重复计数。
  • 桶只开到 10001(只按值域开),而和能到 20000,越界写坏内存 → WA 或 RE。
  • 把条件顺序写错(先标记再判断 have[s]),或把 ++ans 写进内层每一轮,答案会成倍变大。
延伸阅读 / 参考
  • 《算法竞赛入门笔记》第 4 章:数组与计数
  • 《算法竞赛进阶指南》0x05 排序与离散化(桶与计数排序部分)
  • 洛谷 P1059、P2141 题解区的高赞思路
07 二维数组与网格坐标入门

07 · 二维数组与网格坐标

把“值当下标”升级成“两个下标”:用二维数组表示网格,用方向数组遍历邻居,用状态机走出蛇形矩阵。重点是那三条铁律——行列别写反、先判边界再访问、方向的顺序决定转向。
⭐ 已学范围入门预计 55 分钟二维数组方向数组蛇形矩阵
本节出现的新函数 / 新语法(先看懂再用)

前面几节的数组只有一个下标,是一条“线”。网格题的数组有两个下标:a[i][j]。下标从 1 个变 2 个,错的种类也翻倍:行列写反、越界没判、方向数组配错,这三类占了入门阶段网格题 WA 的绝大多数。这一节把这三件事逐个钉死。

j = 1j = 2j = 3
i = 1a[1][1]a[1][2]a[1][3]
i = 2a[2][1]a[2][2]a[2][3]
i = 3a[3][1]a[3][2]a[3][3]

第一个下标 i 是行,从上往下增大;第二个下标 j 是列,从左往右增大。数学课上的点 (x, y) 是 x 向右、y 向上,和这里正好差一次转置——题目给你 (x, y) 时,先在纸上改写成 (行, 列),再动键盘。同一份代码里同时出现两种约定,是查三小时也查不出来的那种错。

网格题的铁律只有一条:先判边界,再碰数组。顺序反了,程序读到的东西就不由你决定了。
新函数拆解 · 二维数组 a[i][j]
int a[105][105]; // 105 行 105 列 a[i][j] = x; // 第 i 行第 j 列
组成含义 / 格式要求
int a[N][M];定义。第一个方括号管行(纵向),第二个管列(横向)。N、M 必须是编译期常量,不能是运行时读进来的变量。
a[i][j]访问第 i 行第 j 列。行从上往下编号增大,列从左往右编号增大。
有效范围0 ≤ i < N 且 0 ≤ j < M。C++ 不做越界检查:a[N][0] 会安静地写到数组外面的内存里。
行优先存储内存里是 a[0][0] a[0][1] … a[0][M-1] a[1][0] … 一整条连续空间。所以按行遍历(外层 i、内层 j)比反过来快,大数组上能差好几倍。
全局 vs 局部全局数组在静态区,默认全 0,不占函数栈;函数里的数组占栈。a[1000][1000] 是 4 MB,直接栈溢出 RE。
清零memset(a, 0, sizeof a) 按字节填,只对 0 和 -1 成立。要填别的值就老老实实双重循环。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

const int N = 105;
int a[N][N];                  // 全局:自动全 0,也不占栈

int main() {
    int n, m;
    cin >> n >> m;
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j)
            cin >> a[i][j];   // 第 i 行第 j 列

    a[1][2] = 7;              // 第 1 行第 2 列,不是第 2 行第 1 列
    cout << a[1][2] << "\n";  // 7
    cout << a[2][1] << "\n";  // 这是输入读进来的第 2 行第 1 列,和上面的 7 毫无关系
    return 0;
}
注意

不要写 int a[n][m](用运行时变量开数组):GCC 当扩展支持,标准 C++ 不允许,而且没法定义成全局数组。

a[i][j] 与 a[j][i] 是两个不同的格子。网格题一半的 WA 就来自把这两个写反。

新函数拆解 · 方向数组 dx[] / dy[]
const int dx[4] = {0, 1, 0, -1}; // 右 下 左 上 const int dy[4] = {1, 0, -1, 0};
组成含义 / 格式要求
dx[d]第 d 个方向的行增量:nr = r + dx[d]。
dy[d]第 d 个方向的列增量:nc = c + dy[d]。两个数组长度必须相同、下标一一对应。
4 方向顺时针写成 (0,1)(1,0)(0,-1)(-1,0),好处是 (d+1)%4 恰好等于“向右转 90°”。
8 方向{-1,-1,0,1,1,1,0,-1} 配 {0,1,1,1,0,-1,-1,-1},从上开始顺时针绕一圈,不重不漏。
标准用法for (d = 0; d < 8; ++d) { 算 ni、nj;先判边界;再访问 a[ni][nj]; }
为什么用数组把 8 段 if 压成一次循环;而且 d 可以当“朝向”存进变量,转向就是一句取模。
最小可运行示例
示例
const int dx[4] = {0, 1, 0, -1};   // 右 下 左 上
const int dy[4] = {1, 0, -1, 0};

// 站在 (r,c),看四个邻居分别是哪一格
for (int d = 0; d < 4; ++d) {
    int nr = r + dx[d];
    int nc = c + dy[d];
    if (nr < 1 || nr > n || nc < 1 || nc > m) continue;  // 先判边界
    // 到这里 (nr,nc) 一定在棋盘内,可以放心访问
}
注意

dx 与 dy 写反(把行增量塞进 dy)不会编译报错,只会让整张图静默地转 90°,极难查。

用 -1..1 双重循环凑 8 方向时,必须跳过 (0,0),否则格子会把自己也算成邻居。

第一个例子选扫雷:它把“访问邻居”和“判边界”这两件事同时用上,而且不用任何别的算法。

例题 · 扫雷游戏洛谷 P2670普及-去洛谷做这题 ↗

给出 n 行 m 列的雷区,* 表示地雷,? 表示待计算的非地雷格。对每个 ? 求出它周围 8 个方向内地雷的个数,* 原样输出。

输入:第一行 n、m(1 ≤ n, m ≤ 100),接下来 n 行每行 m 个字符。输出:n 行,* 保留,? 换成 0~8 的数字。

输入: 3 3 *?? ??? ?*? 输出: *10 221 1*1
扫雷:三步走,别边读边算

第一步,把整张图读进来。不能边读边算:算第 1 行的格子时,第 2 行的字符还没进内存。

第二步,坐标统一用 1..n、1..m。这样“越界”只剩两种:某个下标小于 1,或者大于 n。判断短,也不容易漏写。

第三步,对每个格子:自己是雷就输出 * 并跳过;否则用方向数组把 8 个邻居过一遍,数里面有几个雷。

复杂度:n·m·8 次比较,n、m ≤ 100 时约 8×10⁴ 次,随便过。

完整代码
#include <bits/stdc++.h>
using namespace std;

char g[105][105];                       // 全局:自动清零,也不吃函数栈

// 8 个方向:上、右上、右、右下、下、左下、左、左上(顺时针一圈)
const int dx[8] = {-1, -1,  0,  1, 1, 1, 0, -1};
const int dy[8] = { 0,  1,  1,  1, 0, -1, -1, -1};

int main() {
    int n, m;
    cin >> n >> m;
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j)
            cin >> g[i][j];             // char 版 cin 只取一个非空白字符,换行被自动跳过

    for (int i = 1; i <= n; ++i) {
        for (int j = 1; j <= m; ++j) {
            if (g[i][j] == '*') {       // 雷格原样输出,不用数
                cout << '*';
                continue;
            }
            int cnt = 0;
            for (int d = 0; d < 8; ++d) {
                int ni = i + dx[d], nj = j + dy[d];
                // 顺序不能反:边界不合法时,g[ni][nj] 已经是数组外的内存
                if (ni < 1 || ni > n || nj < 1 || nj > m) continue;
                if (g[ni][nj] == '*') ++cnt;
            }
            cout << cnt;                // 输出 0~8,不加空格、不加分隔符
        }
        cout << '\n';                   // 一整行输出完才换行
    }
    return 0;
}
逐行拆解
代码片段含义与理由
char g[105][105];数组开到 105 而不是 100,是给“手滑写到 n+1”留一层缓冲;char 每个 1 字节,总共约 11 KB。
const int dx[8] = {...};第 d 个方向的行增量。dx、dy 必须同序配对:dx[0]=-1 配 dy[0]=0 才是正上方。
cin >> g[i][j];g 是 char,cin 读一个非空白字符,行末换行被跳过,所以不需要 getline,也不用手动吸回车。
if (g[i][j] == '*') { ... continue; }雷格先处理掉,后面数邻居的循环就只管非雷格,少一层缩进、少一个条件。
int ni = i + dx[d], nj = j + dy[d];把“方向”翻译成“候选坐标”。内外层循环加起来就是沿着 (i,j) 绕一圈。
if (ni < 1 || ni > n || nj < 1 || nj > m) continue;四个条件一个都不能少。少判 nj 上界,就会读到下一行第一格,答案看起来“只差一点”,最难查。
if (g[ni][nj] == '*') ++cnt;累加器写在最内层,每换一个格子必须重置为 0——这就是 int cnt = 0 放在 j 循环里的原因。
cout << cnt;输出的是一位数字字符。题目不要求空格分隔,加了就是格式错误。
cout << '\n';换行写在内层循环外面,才能形成 n 行 m 列,而不是 n·m 行。
网格题最常见的 6 个错,对照症状查

WA,答案全错但每格只差一点点:边界判断写成 ni < 0 || ni >= n,却又用 1-indexed 存图,整体错位一格。用 1-indexed 就只判 < 1 和 > n,别混着来。

WA,个别格子数值随机:先访问 g[ni][nj] 再判越界。越界读到的是相邻数组的残留数据,本地能过、提交就错。

WA,输出全 0 或全 1:8 方向里忘了跳过自己。用双重循环 -1..1 时一定加 if (dx == 0 && dy == 0) continue;。

RE(运行错误):把 105×105 的 char 改成 int 再挪进 main——还好;但若开到 1000×1000 的 int 放局部,4 MB 直接爆栈。网格数组一律定义在 main 外面。

WA 在最后一列:行列写反。g[j][i] 编译完全合法,只是你读的是转置后的图。调试时先把 (i,j) 打出来看。

TLE(超时):数据一放大(比如 n = m = 3000)而不写 ios::sync_with_stdio(false); cin.tie(nullptr);,光读入就可能超时;再叠加一句“对每个格子重新扫一遍全图”的 O(n²m²) 写法,必挂。

扫雷是“站在一格看周围”。第二类网格题是“沿着一条路一直走”:从某格出发,每步换个方向。典型代表就是蛇形方阵——它看起来是打印题,本质是状态机。

例题 · 蛇形方阵洛谷 P5731入门去洛谷做这题 ↗

给出一个不大于 9 的正整数 n,输出 n×n 的蛇形方阵:从左上角填 1,顺时针方向依次填入 1 到 n²。

输出 n 行,每行 n 个数,每个数占 3 个字符宽度、右对齐、前面补空格。

输入: 4 输出: 1 2 3 4 12 13 14 5 11 16 15 6 10 9 8 7
蛇形方阵:把“填数”想成“一个小人在走”

不要想“第几圈、第几条边、这条边填几个”,那个思路的分支条件非常多。换成:有个小人从 (1,1) 出发,手里拿着 1、2、3……每走到一格就写下当前数字。

小人只会两个动作:直走和右转。规则一句话:下一步如果出界、或者那格已经写过,就右转;否则直走。

于是状态只有三个:当前位置 (r,c)、朝向 dir、当前要写的数 k。dir 取 0..3 表示右、下、左、上,右转就是 dir = (dir + 1) % 4。

注意 a[nr][nc] != 0 这一项:因为全局数组初始全 0,而写进去的数都 ≥ 1,所以“非 0”就是“写过”,省掉一个 vis 数组。

核心就这几行:先算候选,不合法就转向重算
// 站在 (r,c)、朝向 dir,问下一步去哪
int nr = r + dx[dir], nc = c + dy[dir];
if (nr < 1 || nr > n || nc < 1 || nc > n || a[nr][nc] != 0) {
    dir = (dir + 1) % 4;                    // 撞墙或撞到旧数字:右转
    nr = r + dx[dir], nc = c + dy[dir];     // 转向后用新方向再算一次
}

为什么这样一定对:每一步都保证 r、c 在界内且 a[r][c] == 0,n² 步恰好把 n² 个格子各写一次。把这句不变量记住,写错了也能自己推回来。

完整代码
#include <bits/stdc++.h>
using namespace std;

int a[15][15];                          // 全局:全 0,用 0 表示“还没写过”

int main() {
    int n;
    cin >> n;

    const int dx[4] = {0, 1, 0, -1};     // 右 下 左 上
    const int dy[4] = {1, 0, -1, 0};
    int r = 1, c = 1, dir = 0;          // 从左上角出发,朝右

    for (int k = 1; k <= n * n; ++k) {
        a[r][c] = k;                    // 每走一步就写一个数
        if (k == n * n) break;          // 最后一格写完后不再预判,免得算出界外坐标

        int nr = r + dx[dir], nc = c + dy[dir];
        // 出界 or 撞到自己写过的数字,就顺时针转 90° 再试一次
        if (nr < 1 || nr > n || nc < 1 || nc > n || a[nr][nc] != 0) {
            dir = (dir + 1) % 4;
            nr = r + dx[dir], nc = c + dy[dir];
        }
        r = nr; c = nc;                 // 只有合法才真正前进
    }

    for (int i = 1; i <= n; ++i) {
        for (int j = 1; j <= n; ++j) printf("%3d", a[i][j]);   // 题目要求 3 字符宽
        printf("\n");                   // 一行结束才换行
    }
    return 0;
}
逐行拆解
代码片段含义与理由
int a[15][15];n ≤ 9,开到 15 留余量。全局数组全 0,这个 0 有两种作用:占位的“空”和判断“写过”的标志。
int r = 1, c = 1, dir = 0;起点在左上角,初始朝右。用 1-indexed,出界判断就只剩两种形式。
const int dx[4] = {0, 1, 0, -1};顺序是右、下、左、上,加 1 就是顺时针右转。若把顺序改成右、上、左、下,就变成逆时针螺旋——输出会是另一个矩阵。
a[r][c] = k;先写再走,保证每格写且只写一次;写完的数非 0,后面的 a[nr][nc] != 0 才有意义。
if (k == n * n) break;最后一格写满就收工。少了这句,程序会继续算出界外的 nr、nc 并赋给 r、c,虽然循环马上结束不会写坏内存,但“变量已经越界却没人管”是不该留的习惯。
int nr = r + dx[dir], nc = c + dy[dir];只算候选坐标,不动 r、c。这是整段代码的关键:候选和目标分开存,判断不通过时原状态一点没被污染。
|| a[nr][nc] != 0第二个条件不能漏。只判出界的话,小人会沿着已经写过的路径一直撞,n² 步填不满 n² 个格子。
dir = (dir + 1) % 4;取模让 3 的下一个回到 0,省掉 if 分支,也保证 dir 永远是合法下标。
r = nr; c = nc;所有判断都通过后才前进,所以 r、c 永远停在“合法且空白”的格子上。
printf("%3d", a[i][j]);3 表示最小场宽 3,默认右对齐、左边补空格。这是本题的格式要求,用 cout 会 WA。
“预判”和“先走再退”的差别

先走再退:r += dx[dir]; c += dy[dir]; 发现不合法再退回来、转向、再走一次。同一句“走”出现两次,改一处漏一处。

预判:先算 nr、nc,不合法只改 dir,合法才赋值。全程 r、c 都在合法格子上,出了错只需要打印 (r, c, dir)。

同一个套路后面还会用到:网格 DFS/BFS 判“能不能进下一格”、扫描线判“能不能放下一个矩形”,都是先算候选、再判、最后提交。

蛇形方阵专属的 4 个坑

格式 WA:用 cout << a[i][j] << " "。题目要求每数占 3 字符宽,必须 printf("%3d", ...)。

给“最后一格”写特判:填满的判据只有 k == n * n。别去猜终点在哪个坐标——n 的奇偶不同,终点位置也不同,猜错就会提前跳出,矩阵里留一片 0。

只判出界、忘了判重:小人写完一圈会掉头冲进已经写过的格子,把 1、2、3 覆盖掉,同时留下几格 0。看到“外圈数字被改、内圈是 0”,就是漏了 a[nr][nc] != 0。

数组开成 a[9][9]:n 正好 9 时下标 9 越界,写坏内存。网格数组的每一维都留余量,这是肌肉记忆。

自己动手复现

关掉页面,从空文件开始重写扫雷(P2670),只允许看题目描述,20 行以内写完并提交。

改一改:把扫雷的 8 方向缩成 4 方向(只数上下左右),先猜 3×3 样例会变成什么样,再改代码验证。

再改一改:把蛇形方阵的 dx/dy 顺序改成右、上、左、下,用 n = 4 手推一次再看输出——你会亲眼看到“方向的顺序就是转向的方向”。

数学补给 · 网格里的四条换算

① 二维 ↔ 一维。m 列的格子,0 起下标的 (r, c) 对应一维编号 id = r*m + c;反过来 r = id / m,c = id % m(整数除法)。一维数组之所以能当二维用,就是因为这个双射。后面 BFS 的队列里只存一个 int,靠的就是它。

② 顺时针 90° 的坐标变换。方向 (dr, dc) 顺时针转 90° 变成 (dc, −dr)。验证:(0,1) 向右 → (1,0) 向下;(1,0) 向下 → (0,−1) 向左。蛇形矩阵的 dir = (dir+1) % 4 只是这条公式的查表版;没有表的时候,用公式现推。

③ 同一句“照 2 格”,两种距离两种形状。|dr| + |dc| ≤ 2 是菱形,含 13 格;max(|dr|, |dc|) ≤ 2 是正方形,含 25 格。写范围类题目第一件事就是判断题目说的是哪种距离。

距离名公式偏移 2 内的格数形状
曼哈顿距离|dr| + |dc|13菱形
切比雪夫距离max(|dr|, |dc|)25正方形

④ 螺旋的层号。n×n 里 0 起的格子 (r, c) 落在第 k = min(r, c, n−1−r, n−1−c) 层。第 k 层边长为 n−2k,格子数 = 4(n−2k−1)(边长 1 时取 1)。全部加起来恰好 n²——这是自检公式:算出的各层之和不是 n²,说明你的行/列范围有重叠或漏掉。

举一反三 · 想清楚这些再往下走
  1. 扫雷里把 8 方向换成 4 方向(只数上下左右),3×3 样例的输出会变成什么?先手算,再改代码验证。
  2. 为什么网格题的数组常见 105、1005 这种“多 5”的尺寸?如果坐标改成从 0 开始存,边界判断能短多少,又会多出哪些坑?
  3. 只允许上下左右走 k 步,能到达的格子构成什么形状?用 |dr| + |dc| 怎么描述?这和 BFS 里的“第几层”是什么关系?
  4. 如果 n = 2000,蛇形方阵还能先存进 a[2005][2005] 再输出吗?算一下内存。哪些题必须边生成边输出?
  5. 蛇形方阵的答案和“方向数组怎么写”有关吗?如果题目要求从中心出发、逆时针向外,代码要改哪几行?
  6. 扫雷的输入如果是“每行一个不含空格的字符串”,读法要怎么改?为什么那时候必须用 string 而不是 char?
自测:能全部回答再进下一节
  • 能在纸上画出 2×3 数组的 6 个格子,并说清 a[i][j] 里 i、j 各是哪一维。
  • 能不看书默写 4 方向和 8 方向两组 dx/dy,并指出哪一组是顺时针序、为什么。
  • 能解释为什么访问 g[ni][nj] 之前必须先判边界,不判会出现什么现象(WA 还是 RE)。
  • 能独立写出蛇形方阵,并说清 a[nr][nc] != 0 这个条件漏了会怎样。
  • 知道二维数组为什么开成全局,知道 a[1000][1000] 的 int 占 4 MB、放在 main 里会 RE。
  • 能说出 printf("%3d") 里 3 的含义,以及它和 cout << a[i][j] 的区别。
挑战题 · 【Mc生存】插火把
洛谷 P1789普及-去洛谷做这题 ↗

在 n×n 的方阵上放了 m 个火把和 k 个萤石。火把照亮自己所在的格子,以及和它曼哈顿距离不超过 2 的所有格子(形状是上下左右各延伸 2 格的菱形,共 13 格);萤石照亮以自己为中心的 5×5 正方形(共 25 格)。

超出方阵的部分忽略。没有被任何光源照到的格子会生成怪物,求这样的格子数。

输入格式:第一行三个整数 n、m、k(1 ≤ n ≤ 100,0 ≤ m, k ≤ 100);接下来 m 行每行两个整数,是火把的坐标;再接下来 k 行每行两个整数,是萤石的坐标。坐标从 1 开始,保证在方阵内。

输出格式:一行一个整数,表示会生成怪物的格子数。

引导性提示(先只看这里)

先把“照亮”变成一张布尔表:被照到就是 true。最后统一数 false 的个数,比边照边数省事得多。

别急着写两套代码:先写出“以 (x,y) 为中心,遍历 5×5 范围并标记”的循环,再想火把要在这个循环上加什么条件。

把火把的 5×5 画出来,标出哪 13 格是亮的,用两个偏移量之间的关系描述这个形状(提示:不是“两个都小”,而是“加起来小”)。

想看思路了?点开完整思路

开 bool lit[105][105],全局定义自动全 false,表示还没有光。

火把的 13 个格子可以直接列成两个常量数组——这就是“方向数组”,只不过这张表有 13 项而不是 4 项(马的 8 种走法也是同一招)。

萤石是完整的 5×5,不需要表:两重循环 dx、dy 各从 -2 到 2,正好生成 25 个偏移。

每个偏移都要先判 x + dx 是否落在 1..n 内——越界那部分“照到方阵外面”,直接丢掉。

最后双重循环数 lit 为 false 的格子数。复杂度 (m+k)×25 + n²,n ≤ 100 时约 1.5×10⁴。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

bool lit[105][105];                 // lit[i][j] = 这一格有没有光;全局自动全 false

// 火把照亮的 13 个偏移:曼哈顿距离 <= 2,也就是 (0,0) + 4 个距离 1 + 8 个距离 2
const int tx[13] = { 0, -1, 1,  0, 0, -1, 1, -1, 1, -2, 2,  0, 0};
const int ty[13] = { 0,  0, 0, -1, 1, -1, -1, 1, 1,  0, 0, -2, 2};

int main() {
    int n, m, k;
    cin >> n >> m >> k;

    for (int t = 0; t < m; ++t) {
        int x, y;
        cin >> x >> y;
        for (int d = 0; d < 13; ++d) {
            int nx = x + tx[d], ny = y + ty[d];
            if (nx < 1 || nx > n || ny < 1 || ny > n) continue;  // 照到方阵外面的部分不算
            lit[nx][ny] = true;
        }
    }

    for (int t = 0; t < k; ++t) {
        int x, y;
        cin >> x >> y;
        // 萤石是完整的 5×5:两重循环直接生成 25 个偏移,不需要表
        for (int dx = -2; dx <= 2; ++dx)
            for (int dy = -2; dy <= 2; ++dy) {
                int nx = x + dx, ny = y + dy;
                if (nx < 1 || nx > n || ny < 1 || ny > n) continue;
                lit[nx][ny] = true;
            }
    }

    int ans = 0;
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= n; ++j)
            if (!lit[i][j]) ++ans;  // 一次都没被照到
    cout << ans << '\n';
    return 0;
}
关键片段拆解
片段作用
bool lit[105][105];只关心“有光/没光”,用 bool 而不是 int,占 1 字节,也把意图写在类型上。
const int tx[13] = {...};把 13 个偏移抄成表,和方向数组一个思路,只是这张表更长。抄错一个偏移,答案就少亮或多亮一格,所以抄完要数一下:1 + 4 + 8 = 13。
for (int d = 0; d < 13; ++d)遍历表里每一个偏移。d 只是下标,没有任何几何含义,别把它当方向编号。
int nx = x + tx[d], ny = y + ty[d];候选坐标:先算出来,判完再用。
if (nx < 1 || nx > n || ny < 1 || ny > n) continue;方阵只有 1..n,越界部分等于照到墙外。四个条件都要写,少一个就会写到别的行上去。
lit[nx][ny] = true;重复点亮同一个格子无所谓,true 是幂等的,所以不用先判断“是不是已经亮过”。
for (int dx = -2; dx <= 2; ++dx)萤石不需要表:两重循环各 5 个取值,生成的正好是 25 个偏移。
if (!lit[i][j]) ++ans;最后统一统计,别在标记过程中维护答案,那样每点亮一格都要判断是不是第一次。
这题最容易错的地方
  • 把火把当成 5×5 整块:多照亮 12 格,答案偏小。25 − 13 = 12,看到答案差十几先查这里。
  • 抄偏移表时漏一格或重一格(比如 (-1,1) 和 (1,-1) 抄成同一个),亮区就少一块;抄完数一遍 1 + 4 + 8 = 13。
  • 边界只判了 nx 没判 ny,或者把 nx > n 写成 nx > m——本题 m 是火把个数,写混了编译器不报错。
  • 坐标从 0 开始存进 1-indexed 的数组,整张图错位一行一列,答案完全对不上。
  • 把 lit 定义在 main 里又不初始化:局部数组的值是随机的,答案每次都不一样。定全局,或 memset(lit, 0, sizeof lit)。
延伸阅读 / 参考
  • 《算法竞赛入门笔记》:数组与二维表格一节
  • 《算法竞赛进阶指南》0x03 节:数组与矩阵的常见操作
  • 洛谷 P2670、P5731 题解区的多种写法对比
08 字符与字符串基础入门

08 · 字符与字符串基础

把 char 当整数、把 string 当数组:掌握 ASCII 三组连续区间、getline 与 cin.ignore 的配合、cctype 工具箱,以及范围 for 中引用与非引用的差别。
⭐ 已学范围入门预计 55 分钟ASCIIgetlinecctype
本节出现的新函数 / 新语法(先看懂再用)

char 不是字母,是一个 0~127 的整数。'0' 是 48,'A' 是 65,'a' 是 97。字符题最后只落到两件事:用整数差值把字符映射成下标,用一次遍历扫完整个串。

例题 · 手机按键洛谷 P1765普及-去洛谷做这题 ↗

手机键盘的字母分布:2:abc 3:def 4:ghi 5:jkl 6:mno 7:pqrs 8:tuv 9:wxyz。在同一个键上按第 n 下,得到这个键上的第 n 个字母(7 键连按 4 下得 s);空格按 0 键一下。

给定一行只含小写字母和空格的文本(长度不超过 200),求打出它最少要按多少下键。

输入格式:一行文本。输出格式:一行一个整数。

输入: hello world 输出: 25

先记三组连续区间。区间内部编码连续,这是所有字符题的地基。

区间ASCII 码长度取序号用途
'0' ~ '9'48 ~ 5710c - '0'数字字符 → 0~9
'A' ~ 'Z'65 ~ 9026c - 'A'大写字母 → 0~25
'a' ~ 'z'97 ~ 12226c - 'a'小写字母 → 0~25
'a' - 'A'32—c ^ 32大小写互换
字符题只有两种操作:把字符变成下标,把下标变成字符。

两条推论:① 区间内第 k 个字符 = 首字符 + k,所以 c - 'a' 直接就是 0~25 的下标;② 区间之间不连续——'9'+1 是 ':','Z'+1 是 '['。判断一个字符属于哪一组,两头都要写。

新函数拆解 · getline(读一整行)
std::getline(std::cin, s);
组成含义 / 格式要求
std::cin从标准输入流读,换成 ifstream 就是读文件。
s整行内容存进来,不含结尾的 '\n'。
停在哪遇 '\n' 结束,并把它从缓冲区取走。
返回值流本身,所以能写 while (getline(cin, s))。
最小可运行示例
示例
string s;
getline(cin, s);        // 输入 hello world → 11 个字符全进来
cout << s.size();       // 11

cin >> s;               // 只读到第一个词,停在空格
getline(cin, s);        // 读到的是剩下的 " world"
注意

cin >> s 遇空格就停,含空格的整行只能用 getline。

两者混用时缓冲区残留的换行必须先吃掉,见下一张卡。

新函数拆解 · cin.ignore(清掉残留)
cin.ignore(); cin.ignore(n, '\n');
组成含义 / 格式要求
无参吃掉 1 个字符。常用法就是吃掉 cin >> n 后面那个换行。
两参最多吃掉 n 个字符,遇 '\n' 提前停('\n' 一并吃掉)。
为什么需要cin >> 读数字遇空白就停,不把空白取走。残留换行会让下一次 getline 读到空串。
最小可运行示例
示例
int n;
cin >> n;               // 输入 3,缓冲区还剩一个 '\n'
cin.ignore();           // 吃掉它
string s;
getline(cin, s);        // 现在读到的才是真正的第 2 行
注意

漏了这句的症状固定:getline 读到空串,后面所有数据全部错位。

全程只用 cin >> 的程序不需要它。

新函数拆解 · string::size / length(长度)
size_t s.size();
组成含义 / 格式要求
返回值字符个数。结尾的 '\0' 由 string 内部维护,不计入。
类型size_t,无符号整数,和 int 混用会出坑。
等价写法s.length() 完全一样;判断空串用 s.empty()。
最小可运行示例
示例
string s = "abc";
cout << s.size();       // 3

// ★ 经典坑:s 为空时 s.size() - 1 回绕成 1.8e19,当下标用直接 RE
int n = (int)s.size();                    // 先转成有符号
for (int i = n - 1; i >= 0; --i) cout << s[i];   // 倒序输出
注意

s 为空时 s.size() - 1 等于 18446744073709551615。

把 size 存进 int 后,所有下标运算都是带符号的,倒序循环才不会变死循环。

新函数拆解 · 范围 for(新语法)
for (char c : s) { } for (char &c : s) { }
组成含义 / 格式要求
char c副本。循环体里改 c 不影响 s,适合只读统计。
char &c引用,绑定在 s 的元素上。给 c 赋值就是改 s。
const char &c只读又不拷贝。char 拷贝代价极小,结构体才必须这么写。
数组也能用for (int &x : a) cin >> x; 一次读完数组。
最小可运行示例
示例
string s = "Hello, World";
int letters = 0;
for (char c : s) if (isalpha(c)) ++letters;   // 只读:c 是副本

for (char &c : s) c = toupper(c);             // 要改:加 & ,c 是 s 里的字符
cout << s;                                    // HELLO, WORLD

for (char c : s) c = tolower(c);              // ✗ 改副本,s 一个字符都没变
注意

判断标准只有一条:要不要改原串,要改就加 &。

循环体里不要 push_back 或删除元素,长度一变遍历就失效。

新函数拆解 · isdigit(是不是数字字符)
int isdigit(int ch);
组成含义 / 格式要求
参数字符的整数编码,直接把 char 传进去。
返回是 '0'~'9' 返回非 0(真),否则 0。
只认什么只认这 10 个字符,不认 '+', '-', '.', 空格。
最小可运行示例
示例
cout << isdigit('7');    // 非 0
cout << isdigit(' ');    // 0
cout << '7' - '0';       // 7:字符转数值靠减法
注意

返回值是 int,当布尔用,别赋给 char;等价写法是两头都判断的区间比较。

新函数拆解 · isalpha(是不是字母)
int isalpha(int ch);
组成含义 / 格式要求
返回是 'A'~'Z' 或 'a'~'z' 返回非 0,否则 0。
同族isalnum、isspace、islower、isupper、ispunct 用法相同。
为什么用它自己写区间判断容易漏边界,标准库不会错。
最小可运行示例
示例
string s = "Luogu 2024";
int letters = 0, digits = 0, other = 0;
for (char c : s) {
    if (isalpha(c)) ++letters;
    else if (isdigit(c)) ++digits;
    else ++other;
}
// 5 4 1
注意

别写成 c >= 'A' && c <= 'z':'[' '\' ']' '^' '_' '`' 这 6 个会被误判成字母。

新函数拆解 · tolower(转小写)
int tolower(int ch);
组成含义 / 格式要求
行为大写字母返回对应小写;其他字符原样返回。
返回类型int。写 c = (char)tolower(c); 最稳。
不改原值它只返回新字符,tolower(c); 单独一句等于什么都没做。
最小可运行示例
示例
cout << (char)tolower('Q');       // q

string s = "Luogu";
for (char &r : s) r = tolower(r);  // 加 & 才改到原串
cout << s;                        // luogu

char d = 'Q';
tolower(d);                       // ✗ d 仍是 'Q'
注意

最常见的错:for (char c : s) c = tolower(c); 改的是副本,原串一点没变。

新函数拆解 · toupper(转大写)
int toupper(int ch);
组成含义 / 格式要求
行为小写字母返回对应大写;其他字符原样返回。
与 tolower 对称都是“输入一个编码,输出一个编码”,不就地修改。
典型场景把整行统一成一种大小写,之后只按一种形式比较——所有“不区分大小写”题的第一步。
最小可运行示例
示例
string a = "Hello", b = "WORLD";
for (char &c : a) c = toupper(c);   // HELLO
for (char &c : b) c = tolower(c);   // world
cout << a << " " << b;              // HELLO world
注意

统一大小写只做一次就够,不要在每次比较里都调;对数字和空格调用无副作用。

例题怎么想:把“按几下”做成一张 26 元表

题目只问总按键数,与字符顺序无关——每个字符的贡献互不影响,一次累加即可。难点只剩“字符 → 次数”这个映射:键盘是死的,把 a~z 各自的次数按字母顺序写成数组,用 c - 'a' 当下标,一次数组访问就查完,比 8 个 if 又快又不容易写错。

  • ① 用 getline 读整行(含空格,cin >> 只能读到第一个词)。
  • ② 准备 int press[26],press[i] 是第 i 个字母的按键次数。
  • ③ 遍历:空格贡献 1,字母贡献 press[c - 'a'];④ 输出累加值。
完整代码:26 元查表
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    string s;
    getline(cin, s);            // 一行含空格,只能整行读

    // press[i]:打出第 i 个字母(a 记 0)要按几下
    // a b c | d e f | g h i | j k l | m n o | p q r s | t u v | w x y z
    int press[26] = {1,2,3, 1,2,3, 1,2,3, 1,2,3, 1,2,3,
                     1,2,3,4, 1,2,3, 1,2,3,4};

    int ans = 0;
    for (char c : s) {              // 只读,不加 & ,c 是副本
        if (c == ' ') ans += 1;     // 空格按 0 键一下
        else ans += press[c - 'a']; // ASCII 连续 ⇒ 下标 = c - 'a'
    }

    cout << ans << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
getline(cin, s);本题用 getline 的唯一理由:一行里含空格。用 cin >> s 只会拿到第一个词。
int press[26]26 个数对应 a~z。手写表出错时错误集中在表里,一眼能查;比一堆 if 快。
{1,2,3, 1,2,3, ...}每 3 个一组,pqrs 和 wxyz 是 4 个一组——这两组有 4 个字母,所以表里出现 4。
for (char c : s)只读遍历,不加引用。c 是副本,改不动 s,而本题也不需要改。
if (c == ' ')空格必须单独处理:它落在 'a' 左侧,直接算 c - 'a' 会得到 -65 这类负下标。
press[c - 'a']三组连续性最直接的用法:'a'-'a'=0,'z'-'a'=25,字母序号就是下标。
cout << ans << "\n";只输出数字,不加任何提示语。
另一种建表:8 个字符串

也可以把键盘存成 string key[10] = {"", "", "abc", "def", ...},每个字符去 8 组里找自己在第几位。它不用手抄 26 个数字,代价是每字符最多 32 次比较——n ≤ 106 时也只有 3×10⁷ 次,照样过。所以选哪个只看你更不容易写错。

这一节最常见的 5 个错(附症状)

WA,答案总是偏小:用 cin >> s 读含空格的整行,只拿到第一个词,s.size() 永远等于词长。

WA,读到空串:cin >> n 后直接 getline,读到残留换行。补 cin.ignore();。

RE 或答案是个天文数字:大写字母、空格代进 press[c - 'a'],下标为负,访问到数组外。

TLE / RE:for (int i = s.size() - 1; i >= 0; --i)。s 为空时 size()-1 回绕成 1.8×10¹⁹,i 转 int 后变负,条件恒真,越界读到崩。先把 size 存进 int。

WA,字符串没变:for (char c : s) c = tolower(c); 改的是副本,加 &。

自己动手复现

关掉本页,把上面的完整代码从头敲一遍,提交 P1765,AC 了再往下。

改一改 ①:输入换成含大写的 Hello World,先猜答案再运行;然后加一行 for (char &c : s) c = tolower(c);,看结果变不变。

改一改 ②:题目改成“输出 2~9 每个数字键各被按了多少次”,用 int cnt[10] 边算边统计。

改一改 ③:先读整数 n,再读 n 行文本求和。故意删掉 cin.ignore() 跑一次,亲眼看它读空。

数学补给 · 为什么大小写只差 32

把三组区间写成二进制,规律立刻出来:

字符十进制二进制区间长度
'0'48011 0000—
'9'57011 100110 个
'A'65100 0001—
'Z'90101 101026 个
'a'97110 0001—
'z'122111 101026 个

'A' 与 'a' 只差第 5 位(权重 32):100 0001 对 110 0001。'Z' 与 'z' 同样只差这一位,所以字母在编码表里的相对位置完全一致,这才有 'a' - 'A' = 32。

  • tolower(c):标准库,最清楚,推荐。
  • c | 32:把第 5 位置 1。大写变小写,小写不变,但它不检查字符是不是字母。
  • c & 32:看第 5 位。'a' 得 32,'A' 得 0——但 '0' = 011 0000 也得 32,会被误判成小写。
  • c + 32:只对大写成立,小写字母会跑出区间。

数字字符同理:'0' = 48 是区间起点,c - '0' 直接得 0~9;反过来写 d + '0',别写 d + 48——前者与编码表无关,后者只在 ASCII 上成立。

循环移位是同一套想法:(c - 'a' + k) % 26 + 'a' 把整数轴折成长度 26 的环,'z' 后一位自动回到 'a'。k 可能为负时要写 ((c - 'a' + k) % 26 + 26) % 26 + 'a',因为 C++ 的 % 对负数取余仍是负的。

举一反三 · 想清楚这些再往下走
  1. 除了 26 元 int 表,还能用 8 个字符串查表。两种写法的比较次数、抄错风险、扩展到 100 个字符时的代价各是什么?
  2. 为什么 getline 要把 '\n' 取走却不把它放进 string?如果它放进去了,本节两道题分别会出什么错?
  3. 把长度不超过 18 的数字字符串变成 long long,循环怎么写?若长度到 10⁵ 位还能用这个办法吗——这是第 9 节要解决的问题。
  4. c & 32 对 'A'、'0'、'@' 分别返回什么?为什么这三个结果说明用位运算判断大小写之前必须先 isalpha?
  5. 统计单词时把“左右是空格”改成“左右不是字母”,文章里出现逗号、句号、换行时哪种写法更稳?各举一个反例。
  6. 同样是 O(n) 扫描,为什么字符题常常边读边算就够,而不必先存整个串?什么情况下必须先存下来?
自测:能全部回答再进下一节
  • 能默写三组 ASCII 区间的端点与长度(10 / 26 / 26),并说出 'a' - 'A' 等于多少。
  • 能写出“先读整数 n,再读一整行文本”的正确代码,并解释 cin.ignore() 为什么不能省。
  • 能说出 for (char c : s) 与 for (char &c : s) 的区别,各举一个该用的场景。
  • 能解释 s 为空时 s.size() - 1 为什么会出事,并给出两种安全写法。
  • 能用 isalpha / isdigit / isspace 写出“一行里字母、数字、其他各多少个”。
  • 能不看资料写出 P1765 的完整代码并一次 AC。
挑战题 · 统计单词数
洛谷 P1308普及-去洛谷做这题 ↗

给定一个单词和一篇文章,统计它作为独立单词出现了多少次,以及第一次出现的位置。匹配不区分大小写;若它只是一个更长单词的一部分(to 出现在 today 里),不算。

输入格式:第 1 行一个只含字母的字符串,表示单词(长度不超过 10);第 2 行一个只含字母和空格的字符串,表示文章(长度不超过 106)。

输出格式:一行。出现过则输出两个整数——出现次数与第一次出现的位置(文章第一个字符的位置记为 0);从未出现则输出 -1。

引导性提示(先只看这里)

大小写不影响匹配。先把两行统一成同一种大小写,再讨论比较;想想这一步要不要改到原串上。

一次“算作出现”要同时满足两件事:从这个位置起的若干字符完全相同,并且前后都不是单词的字母。

位置从 0 开始数:文章第一个字符是位置 0,不是 1。

想看思路了?点开完整思路

用 getline 读两行:第 2 行含空格,cin >> 只能拿到第一个词。

两串都转小写,之后只比较一种形式。

枚举起点 i(0 ≤ i ≤ n-m),逐字符比较 text[i..i+m-1] 与 word[0..m-1]。

全同后再查边界:左边是 i == 0 或 text[i-1] == ' ',右边是 i+m == n 或 text[i+m] == ' '。

两边都满足才计数,第一次满足时记下 i。复杂度 O(n·m) ≤ 10⁷。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    string word, text;
    getline(cin, word);          // 第 1 行:要查的单词
    getline(cin, text);          // 第 2 行:文章,含空格

    // 统一转小写。必须用引用,否则改的是副本,原串一点没动
    for (char &c : word) c = tolower(c);
    for (char &c : text) c = tolower(c);

    int n = (int)text.size(), m = (int)word.size();
    int cnt = 0, first = -1;     // first = -1 表示没出现,0 是合法位置

    for (int i = 0; i + m <= n; ++i) {       // i 是起点,保证 i+m-1 < n
        bool same = true;
        for (int j = 0; j < m; ++j)          // 手写比较,省一次子串拷贝
            if (text[i + j] != word[j]) { same = false; break; }
        if (!same) continue;

        // 完整单词:左边是开头或空格,右边是结尾或空格
        // || 的先后不能换:i == 0 时不能去访问 text[-1]
        bool leftOK  = (i == 0)     || (text[i - 1] == ' ');
        bool rightOK = (i + m == n) || (text[i + m] == ' ');

        if (leftOK && rightOK) {
            ++cnt;
            if (first == -1) first = i;      // 只记第一次
        }
    }

    if (cnt == 0) cout << -1 << "\n";
    else          cout << cnt << " " << first << "\n";
    return 0;
}
关键片段拆解
片段作用
getline(cin, word);第一行没有空格也用 getline,省掉“cin >> 之后要不要 ignore”的判断。
for (char &c : word) c = tolower(c);引用绑定到原串元素才改得动;漏掉 & 就全场 0 次。
i + m <= n起点上限。写成 i < n 会越界访问 text[i+m-1]。
text[i + j] != word[j]逐字符比较,不同就立刻 break。
(i == 0) || (text[i - 1] == ' ')短路求值:i 为 0 时右边不执行,不会访问 text[-1]。调换顺序就在第一个单词上越界。
(i + m == n) || (text[i + m] == ' ')右边界。漏掉它,to 会把 today 开头的 to 也算一次。
first == -1用 -1 表示“没出现”,因为位置 0 是合法的。
这题最容易错的地方
  • 用 cin >> text 读文章,只拿到第一个词,答案几乎是 0。
  • 转小写忘写 &,word 仍是大写,任何输入都匹配不上,输出 -1。
  • 先写 text[i-1] 再判 i == 0,下标 -1 越界读,拿到随机值。
  • 只判左边界不判右边界:文章 “today to” 会把 today 开头的 to 也算一次。
  • 位置从 1 数起,答案整体差 1。
延伸阅读 / 参考
  • 《算法竞赛入门笔记》第 3 章:字符与字符串
  • 《算法竞赛黄金法则:提高算法和思考力的77项技巧》:把字符当整数看
  • 洛谷 P1765、P1308 题解区的高赞思路
09 字符串进阶:数字、进制与格式普及-

09 · 字符串进阶:数字、进制与格式

把字符串当“数字的另一种写法”:用 to_string / stoi / stoll 在两个方向搬运,用秦九韶与除基取余做任意进制互转,用 find / substr / reverse 切段,并且记住 19 位这条硬红线。
⭐ 已学范围普及-预计 60 分钟to_string进制转换find/substr
本节出现的新函数 / 新语法(先看懂再用)
  • to_string —— 数字转字符串,位数直接看 size()
  • stoi —— 字符串转 int,可指定进制与停止位置
  • stoll —— 字符串转 long long,19 位是硬上限
  • reverse —— 原地反转,返回 void,别往 cout 里塞
  • find —— 找子串/字符,返回下标或 npos
  • substr —— 切段:起点 + 长度,或只给起点取到结尾
  • sprintf —— 格式化到字符数组,唯一能做前导零的函数

上一节把字符串当字符数组用:下标、遍历、字符分类。这一节换个看法——把字符串当成数字的另一种写法。同一个值 255,10 进制写作 255,2 进制写作 11111111,16 进制写作 FF。三串字符,一个数。

  • 数字 → 字符串:to_string、sprintf。拼输出、算位数、补前导零。
  • 字符串 → 数字:stoi、stoll,或者自己写秦九韶循环。
  • 定位与切段:find 找分隔符,substr 切开,reverse 就地翻转。
判断标准只有一条:这个数能保证不超过 19 位吗?能,就用 long long 中转;不能,就全程 string,一个字符都别进数字类型。
新函数拆解 · to_string
string to_string(int x); string to_string(long long x); string to_string(double x);
组成含义 / 格式要求
参数任意算术类型,编译器按实参自动挑重载。
返回值string。位数就是 .size(),第 i 位字符就是 s[i]。
double 版本固定 6 位小数,宽度和位数都管不了——要格式就别用它。
效率比 sprintf 慢 2~3 倍,循环 10⁶ 次以上要掂量。
最小可运行示例
示例
int a = 42;
string s1 = to_string(a);                     // "42",长度 2
string s2 = to_string(123456789012345678LL);  // 18 位
cout << s1 + "!";                             // 42!:+ 是拼接
cout << s2.size();                            // 18:位数 = 长度
注意

to_string(1.0) 得到 "1.000000",不是 "1"。

两个结果只能 + 拼接,s1 - s2 编译不过。

新函数拆解 · stoi
int stoi(const string& s, size_t* pos = nullptr, int base = 10);
组成含义 / 格式要求
s要解析的串,允许前导空格与正负号。
pos可选。传 &p 就把“停在哪个下标”写进 p。
base可选,2~36。写 16 就能直接读 "7f" 得 127。
停在哪遇到不属于该进制的字符就停,不报错:"123abc" 得 123。
抛异常超出 int 范围时抛 std::out_of_range,进程终止,评测显示 RE。
最小可运行示例
示例
string s = "  -12345abc";
size_t pos;
int x = stoi(s, &pos);                 // -12345,pos = 8(停在 'a')
int y = stoi("7f", nullptr, 16);       // 127
int z = stoi("11111111", nullptr, 2);  // 255
注意

只认 int 范围。11 位的 "99999999999" 就抛异常。

怕异常就自己写 x = x * 10 + (c - '0'),绝不会炸。

新函数拆解 · stoll
long long stoll(const string& s, size_t* pos = nullptr, int base = 10);
组成含义 / 格式要求
三个参数含义与 stoi 一字不差,只是返回值换成 long long。
上限9223372036854775807(19 位)。值一旦超过它就抛 out_of_range,所以 19 位也不能乱填。
怎么选≤ 9 位用 stoi,10~18 位用 stoll,19 位对照上限看,≥ 20 位不转数字。
最小可运行示例
示例
long long a = stoll("9223372036854775807");   // 19 位,正好 LLONG_MAX
// stoll("9223372036854775808")  → 抛 out_of_range
string big = "12345678901234567890";          // 20 位,只能留在 string 里
注意

20 位的 18446744073709551615 连 unsigned long long 都装不下。

题目给出 20 位以上的数字,等于明示这道题考字符串。

新函数拆解 · reverse
reverse(s.begin(), s.end()); // 需要 <algorithm>
组成含义 / 格式要求
区间左闭右开 [first, last)。s.end() 是末字符的下一个位置。
返回值void。原地修改,不产生新串。
数组也能用reverse(a, a + n) 反转 int 数组。
复杂度O(n),每个元素交换一次。
最小可运行示例
示例
string s = "hello";
reverse(s.begin(), s.end());   // s 变成 "olleh"
cout << s;                     // 先反转,再输出

int a[5] = {1, 2, 3, 4, 5};
reverse(a, a + 5);             // 5 4 3 2 1
注意

cout << reverse(...) 是编译错误(no match for operator<<),不是 WA。

只给一个迭代器参数也编译不过——它要首尾两个。

新函数拆解 · find
s.find(要找的东西, 起点 = 0); // 返回 size_t
组成含义 / 格式要求
参数子串或单个 char:s.find('.')、s.find("to")。
起点从哪个下标开始找;想接着找就传 上次位置 + 1。
返回值首次出现的下标;找不到返回 string::npos,不是 -1。
npossize_t 的最大值 2⁶⁴−1。判断写 != string::npos,别写 < 0。
最小可运行示例
示例
string t = " to be or not to be ", w = " to ";
int cnt = 0;
size_t p = t.find(w);
while (p != string::npos) {
    ++cnt;
    p = t.find(w, p + 1);      // 从下一位继续,否则原地死循环
}
cout << cnt;                   // 2
注意

漏掉 +1:find 每次返回同一个下标,死循环,评测 TLE。

把结果存进 int 再和 npos 比,npos 截断成 -1,看似能过;换成 < 0 的判断就永远为假。

新函数拆解 · substr
string substr(size_t pos = 0, size_t len = npos) const;
组成含义 / 格式要求
pos起点下标。pos == size() 合法,得空串。
len取几个字符;省略就取到结尾,写大了自动截断,不报错。
pos 越界pos > size() 抛 out_of_range → RE。
最常用取 [l, r) 这一段写 s.substr(l, r - l)。
最小可运行示例
示例
string s = "123.456";
size_t p = s.find('.');        // 3
string a = s.substr(0, p);     // "123"
string b = s.substr(p + 1);    // "456",取到结尾
string c = s.substr(2, 5 - 2); // "3.4"
注意

s.substr(p + 1) 在 p 是末字符时返回空串,合法。

第一个参数是起点,不是长度——这是最常见的错法。

新函数拆解 · sprintf
int sprintf(char buf[], const char* fmt, ...); // <cstdio>
组成含义 / 格式要求
buf字符数组,大小自己保证;写越界就是踩内存,表现为 RE 或莫名 WA。
fmt%d 整数、%lld 长整型、%x 小写十六进制、%X 大写、%02d 补两位零、%s 字符串。
转 stringchar 数组可以直接赋给 string:string s = buf;
最小可运行示例
示例
char buf[64];
sprintf(buf, "%03d", 7);              // "007",前导零
sprintf(buf, "%X", 255);              // "FF"
sprintf(buf, "%lld", 10000000000LL);  // long long 必须 %lld
string s = buf;
注意

用 %d 打印 long long 是未定义行为:出乱码,且往往只在部分数据上错。

放 20 位数字,char buf[32] 够;做拼接按 len + 16 估。

例题 · 进制转换洛谷 P1143普及-去洛谷做这题 ↗

输入三行:第一行整数 n(2 ≤ n ≤ 16),表示原数的进制;第二行一个 n 进制数,n > 10 时用大写 A~F 表示 10~15,且它的十进制值不超过 10⁹;第三行整数 m(2 ≤ m ≤ 16),表示目标进制。

输出这个数在 m 进制下的写法,m > 10 时同样使用大写 A~F。

输入: 16 FF 2 输出: 11111111
思路:先落到十进制,再从十进制长出来

n 进制与 m 进制之间没有逐位对应关系(2↔4/8/16 是例外),十进制是唯一的中转站。中转只需要两个操作:

  • 读成十进制:秦九韶 dec = dec * n + 当前位的值,从左到右扫一遍,k 位只做 k 次乘加。
  • 写成 m 进制:除基取余,dec % m 是当前最低位,dec /= m 砍掉它,最后整体倒序。
  • 字符 ↔ 数值:不做 if-else,造一张数码表 "0123456789ABCDEF",下标即数值,digit.find(c) 就是反向查表。

先手算再写码。FF(16 进制):0×16+15 = 15,15×16+15 = 255。255 转 2 进制:连除 2 得 8 个余数全是 1,商归零停下,倒着读就是 8 个 1。

再用小数字看清“为什么必须倒序”:6 转 2 进制,6 % 2 = 0(这是最低位),3 % 2 = 1,1 % 2 = 1,商为 0 停。余数按 0、1、1 的顺序出来,可答案是 110——余数天生从低位到高位,所以最后那一次 reverse 不是装饰。

完整代码:n 进制 → m 进制
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, m;
    string s;
    cin >> n >> s >> m;                  // 三行输入;cin 按“词”读,换行自动跳过

    string digit = "0123456789ABCDEF";   // 数码表:下标即数值,够用到 16 进制

    // ① n 进制 -> 十进制,秦九韶
    long long dec = 0;
    for (int i = 0; i < (int)s.size(); ++i) {
        int d = (int)digit.find(s[i]);   // 'F' 在表里的下标是 15
        dec = dec * n + d;               // 旧值升一档,再加新的一位
    }

    // ② 十进制 -> m 进制,除基取余
    string res;
    if (dec == 0) res = "0";             // 特判:否则 while 一次不进,输出空行
    while (dec > 0) {
        res += digit[(int)(dec % m)];    // 余数就是当前最低位
        dec /= m;                        // 砍掉最低位,下一位升上来
    }
    reverse(res.begin(), res.end());     // 低位先产生,倒过来才是正常写法

    cout << res << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
cin >> n >> s >> m;三行被当成三个“词”。s 是 string,遇空白就停,正好读完整个 n 进制数。
string digit = "0123456789ABCDEF";把“数值→字符”做成一张表:digit[15] 就是 'F'。比 if-else 短、比 switch 快;代价是表只有 16 个字符,扩到 36 进制得换表。
int d = (int)digit.find(s[i]);反向查表:'F' 在表里的下标是 15,即它的数值。find 返回 size_t,赋给 int 要显式转换。
dec = dec * n + d;秦九韶。乘 n 是把已读部分整体左移一位,加 d 是补上新的最低位。第 i 位最终被乘了 n^(k−1−i) 次,正好等于位权。
if (dec == 0) res = "0";输入 0 时循环一次不进,res 保持空串,会输出一个空行。这是本题唯一的数据边界。
res += digit[(int)(dec % m)];余数 r = dec mod m 一定是当前最低位,digit[r] 把数值翻回字符,追加到尾部。
dec /= m;整除砍掉刚取走的最低位,剩下的是「原数除以 m 向下取整」,规模严格变小,循环必停。
reverse(res.begin(), res.end());余数从低位到高位产生,答案要从高位写起,所以整体倒序。忘掉这句,6 转 2 进制会输出 011。
切段:find + substr + reverse 三件套

小数、分数、日期、坐标的本质都一样:一个串被一个分隔符切成两段。find 负责定位,substr 负责切开,reverse 各管自己翻转。

切段 + 反转
string s = "123.456";
size_t p = s.find('.');       // 3;找不到时是 string::npos
string a = s.substr(0, p);    // "123",从 0 取 p 个
string b = s.substr(p + 1);   // "456",从 p+1 取到结尾
reverse(a.begin(), a.end());  // "321"
reverse(b.begin(), b.end());  // "654"
cout << a << "." << b;        // 321.654

顺带记住两条容易被忽略的规则:string 比较用的是字典序("9" < "10" 为假,而数值上 9 < 10),要按数值比就先比长度、再比字典序;to_string 造不出前导零,固定宽度只能靠 sprintf。

还有一个顺序问题:一个串上通常只有一种分隔符,所以按 % → . → / 依次试,命中一个就停。写成几个独立的 if(而不是 else if),同一串会被输出两遍——这是下一题最隐蔽的错法。

这一套能解的五类题
  • 回文:复制一份再 reverse,与原串比较;或双指针首尾对扫。
  • 位数 / 数位统计:to_string(x).size() 得位数,遍历字符串得每个数位。
  • 按数值排序:先比长度、再比字典序;数字位数超过 19 时这是唯一可行的比法。
  • 带分隔符的数据:日期 2024-03-15、分数 700/27、坐标 3.5 —— find 定位,substr 切段。
  • 任意进制输入输出:秦九韶进来,除基取余出去;密码题、颜色题、编号题都是它的变体。
19 位红线:什么时候不许用数字类型

long long 最大 9223372036854775807,19 位。超过它一步,“string → 数字 → string”这条链就整条断掉——20 位的输入必然如此,19 位的也得先和上限比一比。P1143 只保证十进制值 ≤ 10⁹(10 位),所以 long long 很富余;而本节挑战题把长度给到 20 位,同一个算法必须换成纯 string 版本。

但不进数字类型 ≠ 不能算:只要最终要的是余数、位数、某一位这类只依赖低位的信息,秦九韶就能只保留这部分状态往下跑,中间值永远不超范围。所以看到“输入是一个很长的数字串”,第一个问题永远是:我到底需要它的哪一部分信息?

位数超 19:只保留需要的那部分
string s = "12345678901234567890";       // 20 位
// long long x = stoll(s);                   // 抛 out_of_range → RE

int r = 0;                                   // 求这个数 mod 7
for (int i = 0; i < (int)s.size(); ++i)
    r = (r * 10 + (s[i] - '0')) % 7;         // 中间值最大 6*10+9 = 69
cout << r;                                   // 1
怎么验证你的进制转换是对的

进制题的答案没法靠“看起来像”判断,交之前用三个办法自检,每个都不到一分钟:

  • 回环验证:把输出的 m 进制结果再当输入转回 n 进制,应当逐字符还原原串。转两次还一样,基本就对了。
  • 对拍小数据:取 n = 10、m = 2,把 0~100 逐个跑一遍,与计算器或 Excel 的 DEC2BIN 对照。
  • 边界四连:0、1、最大数码(n−1)、n 本身(应写成 10)。这四组能打掉九成的 WA。

这三招不只对进制题有用:凡是“有明确逆运算”的题(进制互转、加解密、编解码)都能用回环验证。

进制数码一位能表示典型场合
20 12位运算、状压 DP、集合的子集
100~910所有读入输出、日常计算
160~9 A~F16内存地址 0x7ffd、颜色 #FF8800、哈希
360~9 A~Z36短链接、编号压缩:位数能省一半
看到“数字 / 进制”的题,先回答三问

最大可能多少位?≤ 9 位 int,≤ 18 位 long long,19 位踮脚,≥ 20 位一律 string。

需要整体数值吗?要就转数字;只要某位、某段、某个余数就全程字符串。

输出有固定宽度、前导零、大小写要求吗?有就用 sprintf 或数码表。

输出格式最常见的三种要求

十六进制大小写:数码表写成 "0123456789ABCDEF" 或全小写,一行切换。

固定宽度补零:sprintf(buf, "%05d", x);to_string 做不到这件事。

去掉多余前导零:从下标 0 起跳过 '0',但至少保留一位,否则 0 会变成空串。

这一节最容易踩的 7 个坑

把 20 位数喂给 stoll:抛 std::out_of_range,进程直接结束,评测显示 RE,连输出都看不到。

字符忘了减 '0':写成 dec = dec * n + s[i],'7' 的 ASCII 是 55,答案偏到天边。

除基取余忘了 reverse:6 转 2 进制应输出 110,你会输出 011,带前导零,WA。

用 s.find(c) < 0 判断没找到:size_t 无符号,条件永远为假。一律写 != string::npos。

拿 find 的返回值直接当 substr 起点:找不到时 npos + 1 是个天文数字,pos > size() 抛异常 → RE。

在循环里写 res = to_string(i) + res:每轮都要整体搬移整串,10⁵ 轮就是 O(n²),本地跑得动、交上去 TLE。正确做法是往后追加、最后 reverse 一次。

dec == 0 不特判:输入 0 时 res 是空串,输出一个空行 → WA。

自己动手复现

关掉页面,把 P1143 的代码从零敲一遍提交。第一组数据用 16 / FF / 2,输出应为 11111111。

再打两个边界:2 / 1011 / 10 应得 11;10 / 0 / 16 应得 0(专打特判)。

改一改:把数码表换成全小写 "0123456789abcdef",输出立刻变小写十六进制;但输入也得跟着变小写——同一张表既要用 find 查字符、又要用下标取字符,大小写必须一致。用 16 / ff / 2 验证。

再改一改:把表扩成 36 个字符 "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ",代码一行都不用动,输入 36 / Z / 10 就得到 35——这就是表比 if-else 划算的地方。

最后一改:把秦九韶换成“倒着扫、第 i 位重算 n^i”的直白写法,用 18 位输入数一数两种写法各做多少次乘法。

数学补给 · 位权、秦九韶与位数公式

设 n 进制数从高到低是 dk−1, …, d0,它的值就是各位乘自己位权的和:V = dk−1·nk−1 + … + d1·n + d0。

秦九韶把它改写成嵌套乘法:V = ((…(dk−1·n + dk−2)·n + dk−3)…·n + d0。把括号一层层拆开,每一项的系数与 n 的次数都和上式相同,两者等价。

差别只在代价:直白写法每位都要重算一次 ni,乘法 0+1+…+(k−1) = k(k−1)/2 次,k = 18 时是 153 次;秦九韶一次乘加一位,18 次。

除基取余的正确性来自带余除法:V = q·m + r,0 ≤ r < m,所以 r 一定是 m 进制写法里的最低位,q 是“砍掉最低位”后的数。对 q 重复同样的推理,位就从低到高被逐位剥出,因此最后必须倒序。

位数估算:k = ⌊logn V⌋ + 1。十进制 19 位对应 2⁶³−1 = 9223372036854775807;同一个值写成二进制要 63 位,写成十六进制只要 16 位——基数越大,位数越少,36 进制因此能把编号压到一半。

顺手把“19”推导一遍:2¹⁰ = 1024 ≈ 10³,于是 2⁶³ = (2¹⁰)⁶·2³ ≈ 10¹⁸×8 = 8×10¹⁸,精确值是 9.223×10¹⁸,刚好落在 10¹⁹ 以下——所以 long long 装得下 19 位、装不下 20 位;unsigned long long 是 2⁶⁴ ≈ 1.84×10¹⁹,正好 20 位,再多一位也装不下。

最后解释那个例外:2、4、8、16 之间可以直接换,不必经过十进制,因为 16 = 2⁴、8 = 2³——一个十六进制位恰好对应 4 个二进制位。所以 FF 逐位查表就得到 1111 1111。十六进制在底层代码里流行,正因为它对人可读(8 位一组)对机器整齐(4 位一组)。

举一反三 · 想清楚这些再往下走
  1. P1143 保证“十进制值不超过 10⁹”,所以 long long 中转很安全。若把这条保证删掉、改成“位数不超过 10⁵”,你的秦九韶还成立吗?除基取余会卡在哪一步?
  2. 判断一个 20 位正整数能否被 3 整除,可以全程用字符串。提示:秦九韶不必保留整个数,只保留那部分有用信息就够——推广到 7、13 呢?
  3. 给定 10⁵ 个长度不超过 20 的数字串,要求按数值排序。直接用 sort 比字符串为什么错?先比长度、再比字典序为什么对?有前导零时这个方案会怎样?
  4. 洛谷 P1601(高精度加法)为什么不能写成 cout << a + b?把理由压成一句话。
  5. 十六进制在真实世界里至少出现三处:内存地址、颜色 #RRGGBB、哈希。把 "#FF8800" 拆成三个 0~255 的整数,用本节的哪几个函数组合最省事?
  6. 判断回文串可以写 s == string(s.rbegin(), s.rend()),也可以双指针首尾对比。两者时间、空间差多少?长度 10⁷ 时哪个会 MLE?
自测:能全部回答再进下一节
  • 能默写 to_string / stoi / stoll 的签名,并说出各自的位数上限。
  • 能手算 0x3F:秦九韶每一步的值写在纸上(3、63),并说出它是二进制的几位。
  • 知道 reverse 为什么不能直接接在 cout 后面,也说得清它改的是谁。
  • 能解释 find 返回 npos 时为什么不能写成 < 0。
  • 看到“20 位数字”四个字,第一反应是 string 而不是 long long。
  • 能说清除基取余为什么必须倒序输出,并举一个不倒序就错的例子(6 → 110)。
挑战题 · 数字反转(升级版)
洛谷 P1553普及-去洛谷做这题 ↗

给定一个不含负号的实数 s,把它的各个数位反转。s 可能是整数、小数、分数或百分数。

整数:所有数位对调。小数:整数部分、小数部分各自反转,小数点不动。分数:分子、分母各自反转,分数线不动,不约分。百分数:只反转数字部分,% 保留。

反转后的最高位不能是 0(0 本身除外);小数部分末尾的 0 要去掉,若只剩 0 则保留一个 0。

输入格式:一行,一个实数 s,各段长度最多 20 位。输出格式:一行,s 的反转数。样例:600.084 得 6.48,700/27 得 7/72。

引导性提示(先只看这里)

四种情况互斥,判别靠串里有没有 %、.、/ 这三个字符,不需要猜数据类型。

整段串里已经有全部信息。先想清楚:能不能把某一段转成数字再反转?数据范围允许吗?

真正要动脑的不是 reverse,而是删零:哪一端是“无意义的 0”,四种情况各不相同。

想看思路了?点开完整思路

① 依次 find('%')、find('.')、find('/'),命中哪个就是哪一类,都没命中就是整数。

② 用 substr 在分隔符两侧切开,左右两段各自 reverse。

③ 左段(整数部分、分子、百分数的数字)去掉反转后开头连续的 0,至少留一位;右段若是小数部分,去掉反转后末尾连续的 0,也至少留一位;若是分母,按整数规则去掉开头的 0。

④ 拼回去:a + "%"、a + "." + b、a + "/" + b,整数直接输出 a。全程在 string 里流动,一个字符都不进 long long。

为什么小数部分要删末尾的 0:600.084 的小数段 "084" 反转得 "480",末尾那个 0 由原数的前导 0 变来,是无意义的 0,答案要求 6.48 而不是 6.480。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    string s;
    cin >> s;                       // 整行只有一个串,不含空格

    size_t p = s.find('%');
    if (p != string::npos) {                    // 百分数:只动数字部分
        string a = s.substr(0, p);
        reverse(a.begin(), a.end());
        int i = 0;
        while (i + 1 < (int)a.size() && a[i] == '0') ++i;  // 去前导零,至少留一位
        cout << a.substr(i) << "%\n";
    } else if ((p = s.find('.')) != string::npos) {        // 小数:两段各管自己
        string a = s.substr(0, p), b = s.substr(p + 1);
        reverse(a.begin(), a.end());
        reverse(b.begin(), b.end());
        int i = 0;
        while (i + 1 < (int)a.size() && a[i] == '0') ++i;  // 整数段去前导零
        int e = (int)b.size() - 1;
        while (e > 0 && b[e] == '0') --e;                  // 小数段去末尾零
        cout << a.substr(i) << "." << b.substr(0, e + 1) << "\n";
    } else if ((p = s.find('/')) != string::npos) {        // 分数:分子分母都按整数
        string a = s.substr(0, p), b = s.substr(p + 1);
        reverse(a.begin(), a.end());
        reverse(b.begin(), b.end());
        int i = 0, j = 0;
        while (i + 1 < (int)a.size() && a[i] == '0') ++i;
        while (j + 1 < (int)b.size() && b[j] == '0') ++j;
        cout << a.substr(i) << "/" << b.substr(j) << "\n";
    } else {                                               // 整数
        reverse(s.begin(), s.end());
        int i = 0;
        while (i + 1 < (int)s.size() && s[i] == '0') ++i;
        cout << s.substr(i) << "\n";
    }
    return 0;
}
关键片段拆解
片段作用
size_t p = s.find('%');先单独算一次,后面三个分支复用 p。用 size_t 接,别用 int。
(p = s.find('.')) != string::npos赋值表达式的值就是 p,少写一行,也保证 p 只在这个分支里有意义。
s.substr(0, p) / s.substr(p + 1)分隔符左边取 [0, p),右边从 p+1 取到结尾,两段长度互不影响。
while (i + 1 < (int)a.size() && a[i] == '0') ++i;去前导零,但下标最多走到最后一位:保证 "0" 这类串不会被删成空串。
int e = (int)b.size() - 1; while (e > 0 && b[e] == '0') --e;小数段规则相反:删末尾的 0,同样留最后一位。e 是最后一个保留字符的下标。
b.substr(0, e + 1)取 [0, e],长度是 e+1 而不是 e。差一错就在这里。
a.substr(i)只给起点,取到结尾——删完前导零剩下的正是答案。
i + 1 < (int)a.size()size() 是 size_t(无符号),与 int 比较会有警告,显式转 int 更保险。
这题最容易错的地方
  • 用 stoll 读整数部分:输入能到 20 位;即使是 19 位的 9223372036854775808,也已超过 LLONG_MAX,直接抛 out_of_range,评测 RE。
  • 删零删成空串:"0" 反转后还是 "0",无条件删前导零会输出空行 → WA。循环条件里必须有 i + 1 < size()。
  • 小数段照抄整数规则去前导零:600.084 会输出 6.480 而不是 6.48。小数段要删的是末尾的 0。
  • 拼回分隔符时手抖:cout << a << b 少了小数点,输出 648,只差一个字符,判 WA。
  • 四个分支用四个独立 if 而不是 else if:同一串可能被输出两次,答案多出一行。
延伸阅读 / 参考
  • 《算法竞赛入门笔记》字符串与进制转换一章
  • 《算法竞赛试炼场:洛谷300题精析》P1143、P1553 的多解对比
  • 《算法竞赛进阶指南》0x10 基本算法:位运算与进位制
  • cppreference:std::stoi / std::stoll / std::to_string
10 回顾二:数组与字符串综合入门+

10 · 回顾二:数组与字符串综合

第 6~9 节三件事的合并验收:用桶统计次数、用二维数组处理网格、用字符串处理一行文字。5 道黄题限时训练,逐题给出完整思路与可提交代码。
⭐ 已学范围回顾训练入门+建议 90 分钟回顾5 题

第 6~9 节只教了三件事:用值当下标的桶、用位置当下标的二维数组、把字符当数据的字符串。这一节不讲新算法,只做一件事——把这三件事放进 5 道黄题里,逼你在 30 秒内认出该用哪一件。

节奏建议:90 分钟 5 题。先花 3 分钟通读全部题面,把“一定会做”“有思路”“完全不会”分成三堆;再按从易到难的顺序单题限时 18 分钟。每写完一题立刻提交,不要攒到最后一起交——早一步看到 WA,就早一步知道自己错在哪个下标上。

回顾课的目标不是“五题都 AC”,而是看到任何一道新题,都答得出两个问题:下标表示什么?要开多大?
题型题目里的信号词下标表示什么固定写法
桶计数出现次数 / 第几个 / 是否出现过 / 去重 / 数条数值本身:字母、数字、和cnt[x]++ 累加,再从 0 扫到值域上界
二维数组n 行 m 列 / 上下左右 / 八个方向 / 网格 / 方阵行号与列号坐标从 1 开始,数组多开 5 格,方向用 (dx,dy) 常量数组
字符串一行文字 / 含空格 / 不区分大小写 / 子串 / 每个字符字符在串中的位置 i整行 getline,逐字符处理,时刻盯住左右边界

本节没有真正的新函数,但有 4 件工具会在 5 道题里反复出现。先花 10 分钟看完这 4 张卡,比写到一半再回来查要快得多。

新函数拆解 · getline 与 cin.ignore(整行读入)
getline(std::cin, s); std::cin.ignore();
组成含义 / 格式要求
getline(cin, s)读一整行到 string s,读到 '\n' 为止,并把 '\n' 从流里取走。空格、Tab 原样保留在 s 里。
cin >> s只读一个“词”,遇到空白就停。含空格的句子用它只能拿到第一个词。
cin.ignore()丢掉缓冲区里的一个字符,通常用来吃掉上一次 cin >> 留下的 '\n'。
cin.get()读一个字符并返回它;不想保存返回值时和 ignore 等价。
使用顺序先 cin >> 读入,再用 getline,中间必须插一句 cin.ignore();两次 getline 连用则不需要。
最小可运行示例
示例
string s;
int n;
cin >> n;              // 输入 "3\nhello world"
cin.ignore();          // 吃掉 3 后面那个 '\n'
getline(cin, s);       // s = "hello world"

// 两行都是整行时,直接两次 getline,不需要 ignore
string w, t;
getline(cin, w);       // 第一行
getline(cin, t);       // 第二行
注意

漏了 ignore() 的症状很固定:getline 读到空串,后面所有数据整体错位一行,样例必然全错。

含空格的输入(P1308、P1765)必须整行读;用 cin 只会得到第一个词,答案稳定偏小。

新函数拆解 · tolower / toupper(大小写统一)
int tolower(int c); int toupper(int c);
组成含义 / 格式要求
tolower(c)大写字母返回对应小写,其它字符原样返回。返回 int,但直接赋给 char 也能编译。
toupper(c)小写字母返回对应大写,其它字符原样返回。
参数传的是“字符的整数值”,不是下标。写成 tolower(s[i] - 'a') 是错的。
等价写法判断小写可以直接写 s[i] >= 'a' && s[i] <= 'z',不依赖 cctype,也不会踩 char 符号性的坑。
最小可运行示例
示例
string s = "Hello World";
for (int i = 0; i < (int)s.size(); ++i)
    s[i] = tolower(s[i]);       // "hello world"

for (int i = 0; i < (int)s.size(); ++i)
    if (s[i] >= 'a' && s[i] <= 'z') { /* 是小写字母 */ }
注意

tolower('A') 的结果就是整数 97,和 'a' 相等,类型不是 char 也没关系。

统一转小写之后,比较时就不用再管大小写——P1308 的标准做法就是先全转小写。

新函数拆解 · string::find 与 substr(查找、截取)
s.find(t) s.substr(pos, len)
组成含义 / 格式要求
s.size()字符个数。和 int 比较时先写成 (int)s.size(),否则 -1 会被当成巨大的正数。
s.find(t)从前往后找子串 t,返回第一次出现的下标;找不到返回 string::npos。
s.find(t, pos)从下标 pos 开始找。循环里要靠它不断往后推进,才能找全所有出现位置。
s.substr(pos, len)从 pos 开始取 len 个字符;len 超过末尾时自动截到末尾,不会越界报错。
s[i]下标访问单个字符,i 从 0 到 (int)s.size()-1。
最小可运行示例
示例
string s = "abcabc";
int p = s.find("bc");          // p = 1
if (p != (int)string::npos) { /* 找到了 */ }

string t = s.substr(1, 2);     // t = "bc"
int len = (int)s.size();       // 6
注意

find 返回的是下标,不是“第几个”。第一个字符的位置是 0——P1308 要的正是这个 0 起算的位置。

string::npos 被赋给 int 时会变成 -1,所以 (int)s.find(t) == -1 也是常见且正确的判空写法。

新函数拆解 · memset 与 fill(批量赋值)
memset(a, 0, sizeof(a)); fill(a, a + n, 0);
组成含义 / 格式要求
memset(a, 0, sizeof(a))把 a 的每个字节设成 0。只对 0 和 -1 安全,填 1 会得到 0x01010101 而不是 1。
sizeof(a)a 必须是真数组。函数参数里的数组名是指针,sizeof 得到的是 8,不是长度。
fill(begin, end, val)按元素赋值,可以填任意值,区间左闭右开。
全局数组开在 main 外面的数组默认全是 0,连 memset 都不用写,这是竞赛里最省事的做法。
最小可运行示例
示例
int cnt[26] = {0};        // 局部数组:必须显式清零
int g[105][105];          // 全局数组:默认全 0

int b[10];
fill(b, b + 10, 7);       // 10 个元素全变成 7
注意

多组数据忘了清空,第二组会带着第一组的残留,症状是“第一组对、后面全错”。

把大数组开在 main 里(栈上)会栈溢出 RE;元素数超过 10⁵ 就一律开全局。

例题 · 扫雷游戏洛谷 P2670普及-去洛谷做这题 ↗

给出 n 行 m 列的雷区,字符 * 表示地雷格,? 表示非地雷格。要求把每个非地雷格换成它周围八个方向上地雷格的个数,地雷格原样输出 *。“周围”指上、下、左、右、左上、右上、左下、右下八个直接相邻的格子。

输入格式:第一行两个整数 n、m(1 ≤ n,m ≤ 100)。接下来 n 行,每行 m 个字符。输出格式:n 行,每行 m 个字符,地雷格为 *,非地雷格为周围地雷个数。

输入: 3 3 *?? ??? ?*? 输出: *10 221 1*1
例题怎么想:一道题同时用上二维数组和字符

题目只要求一件事:对每个非雷格,数它八个方向上有几个雷。所以数据结构必然是字符二维数组——输入里是 * 和 ?,不是数字。

动手前先把三个决定定下来,这三个决定就是这道题的全部难点:

  • 下标从 1 开始存。这样 x-1、y-1 这类偏移不会变成负数,越界判断只写一个条件就够。
  • 数组开到 105×105。n ≤ 100,坐标最大 100,再加 1 格方向偏移也只到 101,105 的余量保证永远不越界,所以代码里可以完全不写边界判断。
  • 八个方向写成两个长度 8 的常量数组,用 for 循环扫一遍。写 8 个 if 也能过,但题目一改(比如改成 4 方向)就要改 8 处。

剩下的是纯粹的循环:对每个格子,是雷就输出 *,不是雷就用 8 个方向数邻居、输出数字;每行结束补一个换行。

完整代码
#include <bits/stdc++.h>
using namespace std;

char g[105][105];                 // 全局数组:默认 '\0',越界位置不会误判成雷

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j)
            cin >> g[i][j];       // cin 读 char 会自动跳过空格和换行,网格逐字符读不会错位

    int dx[8] = {-1, -1, -1, 0, 0, 1, 1, 1};   // 八个方向成对定义
    int dy[8] = {-1, 0, 1, -1, 1, -1, 0, 1};

    for (int i = 1; i <= n; ++i) {
        for (int j = 1; j <= m; ++j) {
            if (g[i][j] == '*') { cout << '*'; continue; }   // 是雷,原样输出,不数邻居
            int c = 0;
            for (int k = 0; k < 8; ++k) {
                int x = i + dx[k], y = j + dy[k];
                if (g[x][y] == '*') ++c;                     // 只需判内容,不需判界
            }
            cout << c;
        }
        cout << '\n';              // 换行放在内层循环外,一行一个
    }
    return 0;
}
逐行拆解
代码片段含义与理由
char g[105][105];字符网格必须用 char。用 int 存、再 cin >> 读 '*',cin 会立刻失败,后面全是 0。
cin >> g[i][j];提取运算符读 char 时跳过所有空白,所以网格里没空格也能一行一行读下来。
int dx[8] = {-1,-1,-1,0,0,1,1,1};八个方向的横坐标增量,和 dy 一一配对。顺序无所谓,成对就行。
if (g[i][j] == '*') { cout << '*'; continue; }雷格不输出数字,continue 直接跳到下一格,省掉一次 8 方向扫描。
for (int k = 0; k < 8; ++k)把八个方向写成循环,比 8 个 if 短,也不容易漏方向。
if (g[x][y] == '*') ++c;只判内容不判界:105 已覆盖 100 加 1 个偏移的所有下标,不会读到数组外面。
cout << '\n';换行在列循环之外,保证每行恰好 m 个字符加一个换行。
这 5 个坑会直接 WA 或 RE,都是高频

字符网格写成 int g[105][105],再用 cin >> g[i][j]:读到 * 时 cin 失败,之后所有格子保持初值 0,答案全错(WA),而且看起来“代码没毛病”。

方向数组里混进了 (0,0):中心格被算成自己的邻居,每个数字都偏大 1。

数组只开 char g[101][101]:n = 100 时 i+1 = 101 越界,轻则 WA,重则 RE。多开 5 格几乎不花内存。

行列读反:cin >> n >> m 之后条件写成 j <= n,读入整体错位,样例第一行就挂。

字符串题里用 s.substr(i, m) == w 做比较:每次复制 m 个字符,长度 10⁶ 时慢一个量级,容易 TLE;逐字符比较才是 O(1) 额外空间。

自己动手复现

关掉这一页,把上面的程序从头敲一遍,提交 P2670,先确认能看到 AC。

改一改:把八个方向换成四个方向(上下左右),样例输出会从 *10 / 221 / 1*1 变成 *10 / 110 / 1*1。先自己推一遍再跑,对不上就说明方向数组和你想的不一样。

再改一改:把雷的字符从 * 换成 #,只改输入读入和比较这两处——体会“字符只是数据”。

下面 5 道题就是本次训练。每题先只看题面和提示,自己写完再展开思路对照;拦不住的题先记下卡在哪一步,回头重看本节对应那一行。

题号题目考的是哪一节要多想一步的地方建议用时
AP1125 笨小猴桶计数(第 6 节)minn 只在出现过的字母里取18 分钟
BP1789 插火把二维数组(第 7 节)火把缺四个角,萤石是整块 5×520 分钟
CP1980 计数问题数位拆解(第 9 节)x = 0 时不能凭空补前导零15 分钟
DP1308 统计单词数字符串(第 8、9 节)匹配前要确认左右都不是字母22 分钟
EP1765 手机字符映射(第 8 节)空格算一次键,且必须整行读入15 分钟
每题提交前的 30 秒检查

数组大小:最大下标再加 5,够不够?

初值:局部数组写 = {0} 了吗?全局数组默认 0。

输出:行末有多余空格吗?两个数之间是一个空格还是别的?不存在的情况输出的是 −1 还是 0?

读入:含空格的整行用 getline 了吗?getline 前需要 ignore 吗?

本次训练 · 共 5 题

做题方式

限时 100 分钟。每题先自己写 20~25 分钟;写不出来先看提示,再给自己 10 分钟;还不行才点开思路。每题都独立限时,不要一题耗死。

大题 挑战题 1 · 笨小猴
洛谷 P1125普及-去洛谷做这题 ↗

给出一个只含小写字母的单词(长度小于 100)。设 maxn 为出现次数最多的字母的出现次数,minn 为出现次数最少的字母的出现次数(只统计出现过的字母)。若 maxn − minn 是质数,就称这个单词是 Lucky Word。

输入格式:一行,一个只含小写字母的字符串。输出格式:两行。第一行是 Lucky Word 或 No Answer;第二行是一个整数,是 Lucky Word 时输出 maxn − minn,否则输出 0。

样例:输入 error → 输出 Lucky Word 和 2(r 出现 3 次,最少 1 次,3−1=2 是质数);输入 olympic → 输出 No Answer 和 0(每个字母都只出现 1 次,差为 0)。

引导性提示(先只看这里)

先数每个字母出现了几次:26 个格子,下标就是字母本身。

minn 不是 0:没出现过的字母不参与比较,否则答案永远是 No Answer。

质数只用试除到 √d,但别忘了 0 和 1 都不是质数。

想看思路了?点开完整思路

cnt[26],cnt[s[i] - 'a']++ 数出每个字母的出现次数。

扫一遍 26 个桶,跳过 cnt == 0 的,同时维护 mx 和 mn。

d = mx − mn。d < 2 直接判死;否则试除 2 到 √d,有因子就不是质数。

为什么这么写就对:桶把“谁出现最多”变成一次线性遍历;试除是判质数的标准 O(√d) 做法,d ≤ 100。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    string s;
    cin >> s;                        // 单词只含小写字母、无空格,cin 一次读完

    int cnt[26] = {0};               // 局部数组必须清零,否则是随机值
    for (int i = 0; i < (int)s.size(); ++i)
        ++cnt[s[i] - 'a'];           // 字母 → 0~25 的下标,桶 +1

    int mx = 0, mn = 1000;           // mn 初值要大于任何可能的出现次数
    for (int i = 0; i < 26; ++i) {
        if (cnt[i] == 0) continue;   // 没出现过的字母不参与 min
        mx = max(mx, cnt[i]);
        mn = min(mn, cnt[i]);
    }

    int d = mx - mn;
    bool prime = (d >= 2);           // 0 和 1 都不是质数,先一刀切死
    for (int i = 2; i * i <= d; ++i) // 试除到 √d 就够
        if (d % i == 0) { prime = false; break; }

    if (prime) cout << "Lucky Word\n" << d << "\n";
    else       cout << "No Answer\n0\n";   // 不是 Lucky Word 时第二行必须是 0
    return 0;
}
关键片段拆解
片段作用
cnt[s[i] - 'a']++把字符映射成 0~25 的下标:'a' → 0,'z' → 25,桶长正好 26。
if (cnt[i] == 0) continue;漏了这句 mn 恒为 0,d 变成 mx,几乎全部输出 No Answer。
int d = mx - mn;题目要的就是这个差。
bool prime = (d >= 2);先把 d < 2 全部判死,后面循环才能从 2 开始、不用特判。
for (int i = 2; i * i <= d; ++i)只需试到 √d:若 d 有大于 √d 的因子,必有一个小于 √d 的搭档。
这题最容易错的地方
  • 把没出现的字母算进 minn,导致 mn = 0。
  • 把 1 当成质数:单词里只有一种字母时 d = 0,只有两种且次数相差 1 时 d = 1,都会被误判成 Lucky。
  • 输出字符串写错:必须是 Lucky Word(首字母大写、中间一个空格),No Answer 同理。
  • 两个数字之间用了 cout << ' ',但这里根本不是一行两个数——是两行,别按套路写。
大题 挑战题 2 · 插火把
洛谷 P1789普及-去洛谷做这题 ↗

在 n×n 的方阵里放 m 个火把和 k 个萤石。火把照亮自己所在格,以及与自己横纵偏移的绝对值之和不超过 2 的格子(一共 13 格,是十字形:5×5 去掉 4 个角、再去掉四条边上与角相邻的 8 格);萤石照亮以自己为中心的完整 5×5 正方形(25 格)。没有光又没有东西的格子会生成怪物,求这样的格子数。

输入格式:第一行三个整数 n、m、k;接下来 m 行每行两个整数,是火把坐标;再接下来 k 行每行两个整数,是萤石坐标。坐标从 1 开始,保证 1 ≤ n ≤ 100,1 ≤ m ≤ 25,0 ≤ k ≤ 5。输出格式:一行一个整数,表示会生成怪物的格子数。

样例:输入 5 1 0 加一行 3 3(5×5 方阵,只有 1 个火把在正中)→ 输出 12(火把照亮 13 格,25 − 13 = 12)。

引导性提示(先只看这里)

不要算面积、不要算重叠——直接开一个标记数组,照到就置 1,最后数还剩几个 0。

火光和萤石的区别只有一个:火把要跳过 |dx| + |dy| 超过 2 的那 12 个偏移。

输入是两段:m 个火把全部读完,才开始读萤石。

想看思路了?点开完整思路

a[105][105] 全 0,a[i][j] == 1 表示格子 (i,j) 已经被照亮。

火把:dx、dy 各从 −2 枚举到 2(25 个偏移),|dx| + |dy| > 2 的跳过(正好 12 个),剩下的在界内就置 1。

萤石:同样 25 个偏移,一个都不跳,在界内就置 1。

最后数 a[i][j] == 0 的个数。复杂度 O((m+k)×25 + n²):最坏 25×25 + 100² ≈ 1.1×10⁴ 次操作,瞬间完成。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int a[105][105];                      // 全局数组:默认全 0,不用手动清零

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m, k, x, y;
    cin >> n >> m >> k;

    for (int t = 0; t < m; ++t) {     // 先把 m 个火把全部读完
        cin >> x >> y;
        for (int dx = -2; dx <= 2; ++dx)
            for (int dy = -2; dy <= 2; ++dy) {
                if (abs(dx) + abs(dy) > 2) continue;   // 十字形:这 12 个偏移照不到
                int nx = x + dx, ny = y + dy;
                if (nx < 1 || nx > n || ny < 1 || ny > n) continue;  // 出界的偏移直接丢
                a[nx][ny] = 1;
            }
    }

    for (int t = 0; t < k; ++t) {     // 再读 k 个萤石
        cin >> x >> y;
        for (int dx = -2; dx <= 2; ++dx)
            for (int dy = -2; dy <= 2; ++dy) {
                int nx = x + dx, ny = y + dy;
                if (nx < 1 || nx > n || ny < 1 || ny > n) continue;
                a[nx][ny] = 1;            // 萤石是整块 5×5,不判距离
            }
    }

    int ans = 0;
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= n; ++j)
            if (a[i][j] == 0) ++ans;      // 还是 0 的格子就是没被照亮
    cout << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
int a[105][105];开在全局:默认全 0,而且不受栈空间限制(8 MB 栈放不下大二维数组)。
abs(dx) + abs(dy) > 2曼哈顿距离。等于 3 的有 8 个、等于 4 的有 4 个,共 12 个偏移被剔除,剩下的正好是 13 格十字。
nx < 1 || nx > n把越界统一处理成 continue,比给数组多开两圈更直观,坐标从 1 开始时尤其好写。
先读 m 个火把,再读 k 个萤石输入是分两段给的。边读火把边读萤石会把坐标错位,答案全错。
if (a[i][j] == 0) ++ans;统计必须扫满 1..n 的整块方阵,只扫一部分就会漏。
这题最容易错的地方
  • 把火把也当成整块 5×5,照亮范围变大,答案偏小。
  • 两段输入顺序读反(先读萤石)→ 坐标全部错位,样例就挂。
  • 数组只开 100×100:坐标 100、偏移 +2 时 a[102][102] 越界,轻则 WA,重则 RE。
  • 忘记 n = 1 的边界:此时火把只照亮 1 格,答案应为 0;越界判断写错就会输出 1 或负数。
大题 挑战题 3 · 计数问题
洛谷 P1980普及-去洛谷做这题 ↗

试计算在区间 1 到 n 的所有整数中,数字 x(0 ≤ x ≤ 9)一共出现了多少次。例如在 1 到 11 中,数字 1 在 1、10、11 里共出现了 4 次。

输入格式:一行两个整数 n、x(1 ≤ n ≤ 10⁶,0 ≤ x ≤ 9)。输出格式:一行一个整数,表示出现次数。

样例:输入 11 1 → 输出 4(1、10、11 里的 1 各算一次,11 里有两个 1)。

引导性提示(先只看这里)

不要找公式:n ≤ 10⁶ 时,把每个数拆成十进制位逐位比较就足够快。

拆一个数:反复取 % 10 拿到末位,再 / 10 丢掉末位,直到这个数变成 0。

x = 0 时要小心:数字 5 只有一位,不能因为“位数不够”就给它补前导零。

想看思路了?点开完整思路

从 1 枚举到 n,每拿到一个数就把它复制到临时变量 t。

循环:if (t % 10 == x) ++ans; t /= 10; 直到 t == 0。

因为 t == 0 就停,数字 5 只会拆出 5 这一位,不会凭空多出前导零,x = 0 也自然正确。

总操作数约 n × 位数 ≈ 6×10⁶,一秒内轻松跑完。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, x;
    cin >> n >> x;

    long long ans = 0;                  // 答案类变量一律 long long,零成本
    for (int i = 1; i <= n; ++i) {
        int t = i;                      // 复制一份再拆,不要拆坏循环变量
        while (t > 0) {
            if (t % 10 == x) ++ans;     // 末位是不是 x
            t /= 10;                    // 丢掉末位
        }
    }
    cout << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
int t = i;拆位会破坏数字,所以先复制。直接拆 i 会让 i 归零,for 里的 i++ 只把它加到 1,于是无限循环。
while (t > 0)t 归零就停,这样数字 5 只贡献一位,x = 0 时不会多算前导零。
t % 10取十进制末位;t % 10 == x 就说明这一位等于 x。
t /= 10整数除法,末位被丢掉,下一位变成末位。
long long ansn = 10⁶ 时答案不超过 6×10⁶,int 装得下;但用 long long 可以免掉一类低级错误。
这题最容易错的地方
  • 把 while 写成 while (t >= 0) → 死循环,TLE。
  • 循环区间写成 0 到 n,多算了数字 0 本身(x = 0 时答案正好多 1)。
  • 用 to_string 再补零对齐位数 → x = 0 时把前导零也算进去,答案偏大。
  • 把 x 声明成 char:cin 读到的是字符 '1'(值 49),和 t % 10 的整数比较永远不相等,答案恒为 0。
大题 挑战题 4 · 统计单词数
洛谷 P1308普及-去洛谷做这题 ↗

给一个单词和一篇文章(一行),求这个单词在文章里出现了多少次,以及第一次出现时首字母在文章中的位置(从 0 开始计)。匹配不区分大小写,但必须是完整单词:to 不能匹配 today 里的 to。

输入格式:第一行一个单词(长度不超过 10);第二行一篇文章(长度不超过 10⁶,可能含空格,空格占一个字母位)。输出格式:若出现过,输出两个整数——出现次数、第一次出现时首字母的位置(从 0 开始计);若没出现过,输出 −1。

样例:输入单词 To、文章 to be or not to be is a question → 输出 2 0(位置从 0 起算,文章开头就是 to)。

引导性提示(先只看这里)

第一行不会有空格,第二行会——所以第二行必须整行读。

先把单词和文章都转成小写,后面就只比较小写字母。

“完整单词”的判据是两条:左邻不是字母,右邻也不是字母,缺一不可。

想看思路了?点开完整思路

两次 getline 读入单词 w 和文章 s,彻底避开 cin 与换行残留的坑。

逐字符 tolower,两边都变成小写。

枚举起点 i(0 ≤ i ≤ n−m):若 s[i−1] 是字母,说明 i 落在某个单词中间,跳过;若 s[i+m] 是字母,说明只是某个单词的前缀,也跳过。

通过了边界检查再逐字符比较,全等就 ans++,第一次成功时记下 i。

最后按 ans 是否为 0 分别输出。复杂度 O(n·m),m ≤ 10 时约 10⁷ 次比较,稳过。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    string w, s;
    getline(cin, w);                 // 第一行:单词
    getline(cin, s);                 // 第二行:文章,可能含空格,必须整行读

    for (int i = 0; i < (int)w.size(); ++i) w[i] = tolower(w[i]);   // 统一小写
    for (int i = 0; i < (int)s.size(); ++i) s[i] = tolower(s[i]);

    int n = (int)s.size(), m = (int)w.size();
    int ans = 0, first = -1;         // first = -1 表示还没找到过
    for (int i = 0; i + m <= n; ++i) {
        if (i > 0 && s[i - 1] >= 'a' && s[i - 1] <= 'z') continue;   // 左边还是字母 → 不在词首
        if (i + m < n && s[i + m] >= 'a' && s[i + m] <= 'z') continue; // 右边还是字母 → 只是前缀

        int k = 0;
        while (k < m && s[i + k] == w[k]) ++k;
        if (k == m) {
            ++ans;
            if (first < 0) first = i;    // 只记第一次,后面不再覆盖
        }
    }

    if (ans == 0) cout << -1 << "\n";
    else          cout << ans << " " << first << "\n";
    return 0;
}
关键片段拆解
片段作用
getline(cin, w); getline(cin, s);两行都整行读。第二行含空格,用 cin >> 只会读到第一个词。
s[i] = tolower(s[i]);统一小写之后,比较时不必再反复转换,边界判断也只需要看 'a'~'z'。
s[i - 1] >= 'a' && s[i - 1] <= 'z'左边界检查:前面还有字母,说明 i 落在某个单词内部。
s[i + m] >= 'a' && s[i + m] <= 'z'右边界检查:后面还有字母,说明匹配到的只是某个单词的前缀。
if (first < 0) first = i;位置要求的是“第一次”,后续匹配成功不能再改。
if (ans == 0) cout << -1题目要求不存在时输出 −1,不是 0 也不是 “0 0”。
这题最容易错的地方
  • 用 cin >> 读文章,只拿到第一个词,答案稳定偏小。
  • 只做左边界检查:to 会匹配到 today、together 里的 to,答案偏大。
  • 位置输出成 i + 1(从 1 开始计),全部偏大 1。
  • 忘了 ans == 0 时输出 −1,样例能过但测试点 1 就 WA。
大题 挑战题 5 · 手机
洛谷 P1765普及-去洛谷做这题 ↗

手机键盘上,数字键 2~9 依次对应 abc、def、ghi、jkl、mno、pqrs、tuv、wxyz。同一个键上的字母按顺序排列:按一次得第一个字母,按两次得第二个,依此类推(所以 s 在 7 键上要按 4 次)。空格按一次键。

输入格式:一行文字,只含小写字母和空格,长度不超过 200。输出格式:一行一个整数,表示打完这句话至少按了多少次键。

样例:输入 i have a dream → 输出 23(i 3 次 + 3 个空格 + h 2 + a 1 + v 3 + e 2 + a 1 + d 1 + r 3 + e 2 + a 1 + m 1 = 23)。

引导性提示(先只看这里)

把“字母 → 按几下”直接做成一张 26 项的常量表,最快也最不容易错。

空格不在 26 个字母里,要单独判断,算 1 次。

这一行含空格,cin >> 只能读到第一个词。

想看思路了?点开完整思路

key[26] = {1,2,3, 1,2,3, ...},下标 s[i] - 'a' 直接查表得到按键次数。

getline 读整行;遇到空格 ans += 1;遇到小写字母 ans += key[s[i] - 'a']。

为什么手写表比推公式好:pqrs 和 wxyz 是 4 个一组,前面都是 3 个一组,公式要分情况,表不用。

复杂度 O(n),n ≤ 200,怎么读都不会慢。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    // key[i] = 打完第 i 个小写字母要按几下:a→1, b→2, ... z→4
    int key[26] = {1, 2, 3,  1, 2, 3,  1, 2, 3,  1, 2, 3,  1, 2, 3,
                   1, 2, 3, 4,  1, 2, 3,  1, 2, 3, 4};

    string s;
    getline(cin, s);                 // 含空格,必须整行读

    int ans = 0;
    for (int i = 0; i < (int)s.size(); ++i) {
        if (s[i] == ' ') ++ans;      // 空格在 0 键上,按一次
        else if (s[i] >= 'a' && s[i] <= 'z') ans += key[s[i] - 'a'];
    }
    cout << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
int key[26] = {...}常量表:下标 0~25 对应 a~z,值就是“按几下”。查表 O(1),也不会算错分组。
getline(cin, s);整行读入,空格才会进到字符串里。
if (s[i] == ' ') ++ans;空格也要按一次键,这是最容易漏的一项。
ans += key[s[i] - 'a'];字母 → 下标 → 次数,一次查表搞定。
else if (s[i] >= 'a' && s[i] <= 'z')加 else:空格分支已经处理过,这里只需保证下标落在 0~25,不会越界。
这题最容易错的地方
  • 用 cin >> s,只统计到第一个词。
  • 忘了空格算 1 次:样例 "i have a dream" 有 3 个空格,答案会少 3。
  • 按键表写错(把 pqrs 当成 3 个一组),遇到 s 就少算 1。
  • 答案用 char 累加,超过 127 后变成奇怪的字符,输出乱码。
做完之后自问
  • 能在 30 秒内说出:这道题的下标表示什么、值域多大、数组该开多少。
  • 不看笔记写出“26 个桶 + 只统计出现过的字母”的 max/min 扫法。
  • 看到八方向网格,能立刻写出 dx、dy 两个常量数组,并说清为什么坐标从 1 开始。
  • 知道含空格的一行必须 getline,并能解释为什么 getline 前有时要先 cin.ignore()。
  • 做完一题能自己造 3~5 个元素的小数据手算验证,而不是反复提交碰运气。
延伸阅读
  • 《算法竞赛入门笔记》第 3~4 章:数组、字符与字符串
  • 《算法竞赛试炼场:洛谷 300 题精析》桶计数与网格模拟部分
  • 洛谷 P2670、P1125、P1789、P1980、P1308、P1765 题解区的高赞思路
11 函数:参数传递与返回值入门

11 · 函数:参数传递与返回值

把重复逻辑收进函数:分清值传递与引用传递、知道数组传参为什么必须另传长度、搞清全局与局部变量的生存期与爆栈,并第一次见递归函数的出口与递推。
⭐ 已学范围入门预计 55 分钟函数引用传递作用域
本节出现的新函数 / 新语法(先看懂再用)

到现在为止,你所有代码都挤在 main 里。同一段逻辑出现第二次,就得整段复制——改要改两处,错就两处一起错。函数是把一段逻辑装进一个名字里:参数决定喂它什么,返回值决定它交回什么,函数名替你记住“这段在干什么”。

例题 · 距离函数洛谷 P5735入门去洛谷做这题 ↗

给出平面直角坐标系上不在同一条直线上的三个点 A、B、C 的坐标,求三角形 ABC 的周长。

输入三行,每行两个实数,表示一个点的横、纵坐标(允许是小数)。

输出一行一个实数,为三角形周长,保留两位小数。

输入: 0 0 0 3 4 0 输出: 12.00

三条边公式一模一样,只有坐标不同。不用函数,sqrt(dx*dx+dy*dy) 要抄三遍。先看 5 张语法卡。

新函数拆解 · 函数定义与调用
返回类型 函数名(形参表) { ... return 值; } 函数名(实参表); // 调用
组成含义 / 格式要求
返回类型结果是什么类型:算距离 double,判质数 bool,只做事不出结果 void。
形参表占位符,每个参数各自声明,类型写全:(double x1, double y1)。
return 值;把结果交回调用处,并立刻结束函数。
调用实参按位置一一抄进形参,然后跳进函数体。
最小可运行示例
示例
double area(double w, double h);   // 前置声明:定义在 main 之后就靠它

int main() {
    cout << area(3, 4) << "\n";    // 实参 3、4 抄给形参 w、h → 12
    return 0;
}

double area(double w, double h) {  // 定义:真正实现
    return w * h;
}
注意

示例省略 #include <bits/stdc++.h> 与 using namespace std;,所有卡片同理。

定义在 main 之后必须先声明,否则报 area was not declared in this scope。函数最多 return 一个值,要带出两个结果只能靠引用参数。

新函数拆解 · 值传递(形参是副本)
void f(int x) // x 是实参的一份拷贝
组成含义 / 格式要求
形参 int x调用时把实参的值拷一份进来,两个变量互不相干。
改 x改的是副本。函数返回后副本销毁,实参一个字节都没变。
代价传大对象要整体复制:10⁵ 个 int 的数组按值传进去,等于多拷 400 KB。
最小可运行示例
示例
void addOne(int x) { x += 1; }

int main() {
    int a = 0;
    addOne(a);
    cout << a << "\n";             // 0,不是 1
}
注意

想改实参只有两条路:引用参数,或把新值 return 出来再赋回 a = addOne(a);。

int、double、char 这类小类型,值传递最省事。

新函数拆解 · 引用参数 &(形参是别名)
void f(int &x) // x 是实参的另一个名字,不是副本
组成含义 / 格式要求
int &x& 写在类型后、变量名前。x 与实参是同一块内存的两个名字。
读 / 写 x读写 x 就是读写实参,函数返回后实参真的变了。
实参要求必须是能取地址的变量。传 f(3)、f(a + b) 直接编译错误。
最小可运行示例
示例
void addOne(int &x) { x += 1; }   // 只多了一个 &

int main() {
    int a = 0;
    addOne(a);
    cout << a << "\n";             // 1
}
注意

引用参数是“函数返回多个结果”的标准做法:void getMinMax(int a[], int n, int &mn, int &mx),调用后两个变量都被写好。

所以 mySwap(int a, int b) 什么也没干,mySwap(int &a, int &b) 才对。

新函数拆解 · const 引用 const &(只读 + 不复制)
int countChar(const string &s, char c);
组成含义 / 格式要求
const承诺函数内不修改参数。写 s[0] = 'x' 直接编译错误。
&不复制,直接引用原对象,省掉拷贝开销。
用在哪参数是大对象(string、结构体、数组)且只读时,一律 const T &。
最小可运行示例
示例
int countChar(const string &s, char c) {
    int cnt = 0;
    for (int i = 0; i < (int)s.size(); ++i)
        if (s[i] == c) ++cnt;      // 只读,符合 const 的承诺
    return cnt;
}
// countChar("banana", 'a') == 3
注意

小类型不要加 &:const int &x 比 int x 还慢一点。

新函数拆解 · 递归函数雏形
long long fact(int n) { if (n <= 1) return 1; // 出口 return 1LL * n * fact(n - 1); // 递推 }
组成含义 / 格式要求
出口最小规模直接给答案。没有出口就是无限递归,必然 RE。
递推把规模 n 化成规模 n−1 的同类问题,规模必须严格变小。
栈帧每层调用都留一份自己的局部变量,返回时才释放。深度 × 每帧大小 = 实际占用。
1LL *强制按 long long 相乘。20! = 2432902008176640000,早就超 int。
最小可运行示例
示例
long long fact(int n) {
    if (n <= 1) return 1;
    return 1LL * n * fact(n - 1);
}
// fact(5) = 120, fact(20) = 2432902008176640000
注意

本节的立场:先会读递归,再决定用不用。递归深度一般别超过 10⁵ 层。

例题怎么想:一条边的公式,调用三次

题目只有一件事:三条边相加。而“求一条边”要做三次。

所以把“求一条边”抽成只依赖 4 个形参的纯函数,再在 main 里调用三次。这种函数最好验算——随手喂两组坐标就能判断对不对,不用管程序的其他部分。

完整代码
#include <bits/stdc++.h>
using namespace std;

// 只依赖 4 个形参,不碰任何全局状态,好测、可复用
double dist(double x1, double y1, double x2, double y2) {
    double dx = x1 - x2;
    double dy = y1 - y2;
    return sqrt(dx * dx + dy * dy);       // 勾股定理
}

int main() {
    double ax, ay, bx, by, cx, cy;
    cin >> ax >> ay >> bx >> by >> cx >> cy;

    // 三条边同一个公式,只有坐标不同 —— 这正是函数的用武之地
    double ans = dist(ax, ay, bx, by)
               + dist(bx, by, cx, cy)
               + dist(cx, cy, ax, ay);

    cout << fixed << setprecision(2) << ans << "\n";   // fixed 才表示"小数位数"
    return 0;
}
逐行拆解
代码片段含义与理由
double dist(double x1, ..., double y2)返回 double:距离带小数,不能收成 int。4 个形参的顺序必须和调用时完全一致。
double dx = x1 - x2;dx、dy 是 dist 的局部变量,出了函数就销毁,和 main 里的同名变量互不干扰。
return sqrt(dx * dx + dy * dy);sqrt 的参数与返回值都是 double,与返回类型一致,不做隐式转换。
cin >> ax >> ... >> cy;六个实数一次读完。cin 自动跳过换行,所以输入分三行也正确。
dist(a...) + dist(b...) + dist(c...)三次独立调用:每次把新实参抄进形参,第一次的局部变量早已销毁,互不影响。
cout << fixed << setprecision(2) << ans;fixed 表示“小数点后几位”。只写 setprecision(2) 是“总共两位有效数字”,会输出 12 而不是 12.00。
数组传参:长度必须自己扛过去

数组名传进函数时只传了首地址,长度在这条路上丢了。所以形参里必须再给一个 n,而且不能在函数里用 sizeof 反算长度——那时 a 已经是指针,sizeof(a) 恒为 8。

数组 + 长度,缺一不可
int sumOf(const int a[], int n) {      // const int a[] 等价于 const int *a
    int s = 0;
    for (int i = 0; i < n; ++i) s += a[i];
    return s;
}
// 调用 sumOf(x, 5);在 main 里 sizeof(x)/sizeof(x[0]) 才等于 5

二维数组同理,但第二维必须写出:void f(int a[][105], int n, int m)。

作用域与爆栈:变量放在哪,决定它能活多久
写在哪叫什么初值活到什么时候内存区
函数或 {} 内局部变量随机值(垃圾)出了这层花括号就销毁栈,约 1~8 MB
所有函数之外全局变量自动为 0整个程序运行期间静态区,可用几百 MB

结论很硬:int a[1000005] 占 4 MB,写在 main 里会栈溢出(本地可能还能跑,评测机 RE);写到所有函数外面就没事,还自动清零。数组长度超过 10⁵ 就放全局。

但全局变量谁都能改,出了问题极难定位。所以只有“很大的数组”和“全程序共享的表”才放全局,能用参数传的一律用参数。

这一节最容易犯的 5 个错

想让函数改实参却忘写 &。程序能跑、输出还是原值,WA。典型是 mySwap(int a, int b):函数里换得很认真,main 里一点没变。

在函数里写 sizeof(a) / sizeof(a[0]) 算长度。a 已退化为指针,结果恒为 2,循环只跑 2 次,WA。

把大数组写在 main 里。int a[1000005] 超过栈容量,本地正常、评测机 RE。

递归忘写出口,或出口永远不成立。症状是 RE(栈溢出)或 TLE,不是编译错误,第一次遇到很难想到。

函数定义写在 main 后面又没声明。报 xxx was not declared in this scope,提示指向调用行,容易误以为自己名字打错了。

自己动手复现

不看本页,把 P5735 敲一遍并提交。

做三组对照:① 把 dist 的形参与 main 里的变量全改成 int,输入 0 0 / 0 3 / 4 0,答案仍是 12.00(sqrt 会把 int 转成 double);② 输入改成 0.5 0 / 0 3 / 4 0,看 cin >> int 只吃下 0、把 .5 留在流里造成后续读入全部失效;③ 各写一个 addOne(int x) 和 addOneRef(int &x),调用后打印 a,对比 0 与 1;④ 在 main 里开 int big[1000005] 并赋值,看程序是否直接崩。

改一改:把三次 dist 收成 double perimeter(double ax, double ay, double bx, double by, double cx, double cy),让 main 只剩两行。再想一步:要改成“返回最长的一条边”,需要几个引用参数?

数学补给 · 递归式、等比数列与栈的真实上限

把递归写成方程,就能在上机前判断够不够快。设 T(n) 是规模 n 的耗时:

T(n) = T(n−1) + c:每层只做常数工作,展开得 T(n) = cn = O(n)。阶乘递归、数组递归求和都属于这档。

T(n) = 2T(n−1) + c:第 k 层有 2ᵏ 个调用,总数 2⁰+2¹+…+2ⁿ = 2ⁿ⁺¹ − 1 = O(2ⁿ)。朴素递归求斐波那契就是这样:f(40) 约 10⁸ 次调用,f(45) 约 2×10⁹ 次,必然 TLE。

等比求和(q ≠ 1):a₁ + a₁q + … + a₁q^(m−1) = a₁(qᵐ − 1)/(q − 1)。上面那个 2ⁿ⁺¹ − 1 就是 q = 2 的特例。

处理 f(n) = a·f(n−1) + b 的通法是换元。以节末挑战题为例,逆向递推 f(d) = 2f(d+1) + 2,两边同加 2 得 f(d) + 2 = 2(f(d+1) + 2)。令 g(d) = f(d) + 2 就是纯等比,由 g(n) = 3 倒推 g(1) = 3·2ⁿ⁻¹,所以 f(1) = 3·2ⁿ⁻¹ − 2:n = 10 时 3×512 − 2 = 1534,与程序输出一致。

栈的硬上限:Windows 默认 1 MB,Linux 默认 8 MB;一层栈帧通常 32~96 字节。于是递归深度上限约 1 万层到 10 万层——读到“n ≤ 10⁶ 还要递归”,就该改循环或手写栈了。

举一反三 · 想清楚这些再往下走
  1. 声明里可以只写类型不写形参名(double dist(double, double);),定义里必须写名字。为什么声明允许省略?省略了以后函数体里还能用这些参数吗?
  2. 要让一个函数同时返回“最小值”和“它第一次出现的下标”,引用参数要几个?改成返回 pair 行不行?没找到时返回什么?
  3. 为什么 mySwap(int a, int b) 什么都没干,而标准库的 swap(a, b) 有效?它的签名属于哪种传参?
  4. 把 sumOf(const int a[], int n) 去掉 const,函数体里多了哪些可能?这会引入什么风险?
  5. 一个只做一次加法的函数被调用 10⁵ 次,总开销大约是多少?和把这 10⁵ 次加法直接写在 main 里差多少?
  6. 三个点共线时,“三角形周长”还有意义吗?题目为什么特意保证“不在同一条直线上”?
自测:能全部回答再进下一节
  • 能用自己的话说清值传递与引用传递的区别,并各写一个 5 行以内的例子。
  • 知道为什么 int sum(int a[]) 里 sizeof(a) 不等于数组字节数,以及正确做法。
  • 能把 main 里的三段重复逻辑抽成一个带返回值的函数,调用处只改三行。
  • 知道局部 int a[1000000] 为什么 RE,改成全局为什么就好了。
  • 能写出阶乘的递归版和循环版,并说出两个版本各自的代价。
挑战题 · 猴子吃桃
洛谷 P5743普及-去洛谷做这题 ↗

猴子第一天摘下若干桃子,当即吃了一半,还不过瘾,又多吃了一个。第二天早上又将剩下的吃掉一半,又多吃一个。以后每天早上都吃了前一天剩下的一半零一个。

到第 n 天早上想再吃时,发现只剩下一个桃子了。求第一天共摘了多少个桃子。

输入一个正整数 n(1 ≤ n ≤ 30)。输出一个整数,表示第一天摘的桃子数。

引导性提示(先只看这里)

别从第一天往后推——你不知道第一天有多少个。反过来,从第 n 天的 1 个往前倒推。

设第 d 天早上有 f(d) 个。吃掉一半再多吃一个后剩下的是 f(d+1),把它写成 f(d) 的表达式。

写出反向公式后,既可以用一层循环从 n 倒着算到 1,也可以写成一个递归函数。

想看思路了?点开完整思路

第 d 天有 f(d) 个,吃掉一半再多吃一个,剩下 f(d) − f(d)/2 − 1 = f(d)/2 − 1,这个量就是 f(d+1)。

所以 f(d+1) = f(d)/2 − 1,反解得 f(d) = (f(d+1) + 1) × 2。

已知 f(n) = 1,用上式从 d = n−1 倒推到 d = 1,答案就是 f(1)。

为什么对:每步都是等价代数变形,没丢解;f(d) 必为偶数,倒推结果一定是整数。

写成函数 int peach(int d):d == n 返回 1,否则返回 (peach(d+1)+1)*2 —— 这就是本节讲的递归雏形。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;

    // 从第 n 天早上的 1 个,倒推回第 1 天
    int cnt = 1;
    for (int d = n - 1; d >= 1; --d)
        cnt = (cnt + 1) * 2;      // f(d) = (f(d+1) + 1) * 2

    cout << cnt << "\n";
    return 0;
}
关键片段拆解
片段作用
int cnt = 1;初始值是第 n 天早上的桃子数,即已知的 1。cnt 表示“当前这一天的桃子数”。
for (int d = n - 1; d >= 1; --d)从第 n−1 天倒推到第 1 天,共 n−1 步。n = 1 时一步不跑,直接输出 1,正合题意。
cnt = (cnt + 1) * 2;把 f(d) = (f(d+1)+1)×2 翻成代码:先加 1 再乘 2,顺序不能反。
cout << cnt << "\n";此时 cnt 就是 f(1),第一天摘的桃子数。
这题最容易错的地方
  • 公式写反成 cnt = cnt * 2 + 1。这是另一条递推,不是“剩一半再多吃一个”的逆运算。
  • 循环写成 for (int d = n; d >= 1; --d),多推一步,答案变大。
  • 只看到 n ≤ 30 就用 int 存。n = 30 时答案是 3×2²⁹ − 2 ≈ 1.6×10⁹,已在 int 上限附近;题目一放宽就会溢出——看到“每天翻倍”就该想到 long long。
  • 把“第 n 天早上剩 1 个”读成“第 n 天吃完剩 1 个”,多算一天。读题要盯住“第几天早上”。
延伸阅读 / 参考
  • 《算法竞赛入门笔记》第 4 章:函数、作用域与递归
  • 《算法竞赛黄金法则》:把重复的代码变成可调用的工具
  • 洛谷 P5735、P5743 题解区的高赞写法
12 结构体与多关键字排序普及-

12 · 结构体与多关键字排序

用 struct 把多字段对象打包成可整体搬动的类型,用 cmp 把“总分 → 语文 → 学号”写成严格弱序,并搞清 sort 的左闭右开、greater、stable_sort 与 lambda 的分工。
⭐ 已学范围普及-预计 60 分钟structsortcmp 严格弱序
本节出现的新函数 / 新语法(先看懂再用)

前 11 节里,一个数组只装一种东西:全是成绩,或全是坐标。可真实题目里,一个学生同时有学号、语文、数学、英语、总分,而且要求按总分排、总分相同看语文、还相同看学号。裸数组排不动这种题——学号一个数组、总分一个数组,一排序就对不上号。结构体把多个字段打包成一个整体,排序时整包一起搬,字段之间的对应关系才不会散。

例题 · 奖学金洛谷 P1093普及-去洛谷做这题 ↗

输入第一行一个整数 n,表示学生人数(n 不超过 300,且至少有 5 人)。

接下来 n 行,每行三个整数(0~100),依次是语文、数学、英语成绩。学生按输入顺序编号为 1 到 n。

按总分从高到低排序;总分相同按语文从高到低;语文也相同按学号从小到大。

输出前 5 名,每行两个整数:学号、总分。

输入: 6 90 67 80 87 66 91 78 89 91 88 99 77 67 89 64 78 89 98 输出: 6 265 4 264 3 258 2 244 1 237

排序规则有三层。字段要一起搬、比较要按多个键——这是 struct 加 cmp 的典型场景。先看 6 张语法卡。

新函数拆解 · struct:把多个字段打包成一个类型
struct 名字 { 类型 字段1; 类型 字段2; }; // ← 这个分号不能漏
组成含义 / 格式要求
struct声明一个新类型。字段就是普通变量声明,可以是 int、double、string,甚至数组。
名字习惯首字母大写:Stu、Node。用法和 int 一样:Stu a;、Stu s[305];。
结尾 ;必须有。漏了会让后面几十行一起报 expected ';',错误行号离真正的坑很远。
最小可运行示例
示例
struct Stu {
    int id;
    int sum;
};                             // 分号不能漏

int main() {
    Stu a;                     // 像 int 一样定义变量
    a.id = 1;  a.sum = 265;
    Stu s[3];                  // s[0]、s[1]、s[2] 各是一个学生
    s[1].sum = 244;
    cout << a.id << " " << s[1].sum << "\n";
}
注意

示例省略 #include <bits/stdc++.h> 与 using namespace std;,每张卡片都照此补。

局部结构体变量不会自动初始化:Stu a; 之后 a.sum 是随机值,读之前必须先写。

新函数拆解 · sort:区间左闭右开
sort(首指针, 尾后指针); // 默认升序 sort(首指针, 尾后指针, 比较器); // 自定义顺序
组成含义 / 格式要求
首指针第一个元素的地址:a。
尾后指针最后一个元素下一个位置的地址。排 a[0..n−1] 就写 a + n。
比较器省略时按 < 升序;结构体数组必须自己传。
复杂度O(n log n)。n = 10⁶ 约 2×10⁷ 次比较,1 秒内跑完。
最小可运行示例
示例
int main() {
    int a[6] = {5, 2, 8, 1, 9, 3};
    sort(a, a + 6);                  // 排 a[0..5] → 1 2 3 5 8 9
    sort(a, a + 3);                  // 只排前 3 个
    sort(a, a + 6, greater<int>());  // 降序 → 9 8 5 3 2 1

    int b[6] = {5, 2, 8, 1, 9, 3};
    sort(b + 1, b + 6);              // 1 下标:排 b[1..5],b[0] 不动
}
注意

sort(a, a + n - 1) 只排了 n−1 个,最后一个不参与——最常见的差一位。

1 下标数组写成 sort(a + 1, a + n + 1),末尾是 + n + 1,别抄成 + n。

新函数拆解 · cmp:自定义比较器与严格弱序
bool cmp(const T &a, const T &b) { return a.键 < b.键; // 返回 true ⇒ a 排在 b 前面 }
组成含义 / 格式要求
返回值bool,只回答“a 该不该排在 b 前面”,不是返回差值,也不是返回“a 更小”。
const T &按引用传,避免每个元素被整包复制;const 保证比较时不改数据。
运算符只写严格不等 < 或 >,绝不能写 <=、>=。
严格弱序cmp(a,a) 必须为 false;cmp(a,b) 与 cmp(b,a) 不能同时为 true;必须可传递。
最小可运行示例
示例
bool cmpBig(int a, int b) { return a > b; }   // 降序:大的在前
bool bad(int a, int b) { return a >= b; }     // cmp(a,a) 竟返回 true

int main() {
    int a[5] = {3, 1, 4, 1, 5};
    sort(a, a + 5, cmpBig);                   // 5 4 3 1 1
    sort(a, a + 5, bad);                      // 可能直接 RE
}
注意

std::sort 的分区循环靠这三条假设决定指针往哪走。用 >= 时“等于 pivot 也算小”的指针停不下来,会一路越过数组末尾写内存,症状是 RE 而不是编译错误。

新函数拆解 · greater:标准库自带的降序比较器
sort(a, a + n, greater<类型>());
组成含义 / 格式要求
<类型>尖括号里写元素类型,必须和数组元素类型一致。
末尾 ()这对圆括号是“构造一个对象”。漏了就语法错误:greater<int> 只是类型,类型不能当值传。
等价greater<int>() 等价于 [](int x, int y) { return x > y; }。
最小可运行示例
示例
int main() {
    int a[5] = {3, 1, 4, 1, 5};
    sort(a, a + 5, greater<int>());       // 5 4 3 1 1
}
注意

同族的 less<int>() 就是 <,也正是 sort 省略比较器时的默认行为。

新函数拆解 · stable_sort:稳定排序
stable_sort(首, 尾后, 比较器); // 认为“相等”的元素保持原有先后
组成含义 / 格式要求
稳定比较器认为等价的两个元素,排序后仍按原来的顺序排列;sort 不保证这一点。
用法多关键字:从最次要的键排到最重要的键,每趟都用 stable_sort。
代价多花 O(n) 内存,常数略大;n ≤ 10⁵ 完全够用。
最小可运行示例
示例
// 假设已有例题里的 Stu s[] 与 n:目标 总分降序 → 语文降序 → 学号升序
sort(s, s + n, [](const Stu &a, const Stu &b) { return a.id < b.id; });
stable_sort(s, s + n, [](const Stu &a, const Stu &b) { return a.ch > b.ch; });
stable_sort(s, s + n, [](const Stu &a, const Stu &b) { return a.sum > b.sum; });
注意

顺序必须从次要到主要。反过来先排主键,后面几趟会把主键重新打乱。

第一趟按唯一键(如学号)排,用 sort 也不会错,因为根本没有并列。

新函数拆解 · lambda:就地写一个匿名比较器
sort(a, a + n, [](const T &x, const T &y) { return 条件; });
组成含义 / 格式要求
[]捕获列表:空表示不用外部变量;[&] 表示按引用用外部变量。
(参数)和普通函数形参一样,写 const T & 最省。
位置就地写在 sort 第三个参数处,不必在全局另起一个函数;整个 lambda 后用 ) 和 ; 收尾。
最小可运行示例
示例
struct Stu { int id, sum; };

int main() {
    Stu s[3] = {{1, 240}, {2, 265}, {3, 240}};
    sort(s, s + 3, [](const Stu &x, const Stu &y) {
        if (x.sum != y.sum) return x.sum > y.sum;   // 总分降序
        return x.id < y.id;                         // 学号升序兜底
    });
}
注意

lambda 里不能修改未被捕获的外部变量,编译会直接报错。

只在排序里用一次的比较器,用 lambda 比在全局起名字更清楚。

例题怎么想:三个优先级,写成三层 if

排序规则有三层:总分 → 语文 → 学号。写成三层 if,第一个不同的关键字就定胜负,后面的不看。

最容易忽略的是最后一层 return a.id < b.id;。它不是“顺便加一句”,而是把平局彻底消掉的兜底:学号唯一,任意两人都有确定先后。少了它,总分语文都相同的人会互相“不小于”,严格弱序被破坏。

完整代码
#include <bits/stdc++.h>
using namespace std;

struct Stu {
    int id;            // 原始下标:输入顺序就是学号,排序后靠它认人
    int ch, ma, en;    // 语文、数学、英语
    int sum;           // 总分:输入时就算好,别在 cmp 里反复加
};

// 返回 true 表示 a 应排在 b 前面
bool cmp(const Stu &a, const Stu &b) {
    if (a.sum != b.sum) return a.sum > b.sum;   // 第一关键字:总分降序
    if (a.ch  != b.ch ) return a.ch  > b.ch;    // 第二关键字:语文降序
    return a.id < b.id;                         // 第三关键字:学号升序
}

int main() {
    int n;
    cin >> n;
    Stu s[305];
    for (int i = 0; i < n; ++i) {
        s[i].id = i + 1;                        // 先记下原始下标,排完序就找不回来了
        cin >> s[i].ch >> s[i].ma >> s[i].en;
        s[i].sum = s[i].ch + s[i].ma + s[i].en;
    }

    sort(s, s + n, cmp);                        // 左闭右开:覆盖 s[0] ~ s[n-1]

    for (int i = 0; i < 5 && i < n; ++i)
        cout << s[i].id << " " << s[i].sum << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
struct Stu { int id; int ch, ma, en; int sum; };5 个字段打成一个包。排序搬动的是整个 Stu,学号和总分的对应关系不会散。
bool cmp(const Stu &a, const Stu &b)const 引用:n = 300 时省下数百次结构体拷贝,也防止比较过程改坏数据。
if (a.sum != b.sum) return a.sum > b.sum;第一层:总分不同就按总分降序定胜负,后两行不执行。先判“不同”再比较,不能反。
if (a.ch != b.ch) return a.ch > b.ch;走到这里说明总分相同,改用语文继续分。
return a.id < b.id;最后一层必须给出“不平局”的判据。学号唯一,正好当兜底。
s[i].id = i + 1;在排序之前把原始下标存进结构体。排完序 s[k] 是第 k 名,只有 id 能告诉你他是谁。
s[i].sum = ...;总分在输入时算好。放进 cmp 里每次现算,一次排序要多做 O(n log n) 次加法。
sort(s, s + n, cmp);左闭右开 [s+0, s+n),正好覆盖 s[0]~s[n−1]。写成 s+n−1 会漏掉最后一个。
同一个排序逻辑的两种载体

A 与 C 结果完全一样。逻辑复杂、想单独验算就用独立 cmp;关键字多到写不清优先级就逐层 stable_sort,每层只关心一个键。lambda 只是 A 的就地缩写,见上一张卡片。

两种写法
// A:独立 cmp 函数(本页用法)—— 定义见上面例题
sort(s, s + n, cmp);

// C:逐层 stable_sort —— 从最次要的键排到最重要的键
sort(s, s + n, [](const Stu &a, const Stu &b) { return a.id < b.id; });          // 最次要
stable_sort(s, s + n, [](const Stu &a, const Stu &b) { return a.ch > b.ch; });   // 次要
stable_sort(s, s + n, [](const Stu &a, const Stu &b) { return a.sum > b.sum; }); // 主要

C 的代价是排序 k 遍、常数乘 k;好处是每层表达式只有一两个字符,几乎不可能写错优先级。

这一节最容易犯的 5 个错

cmp 里用 >= 或 <=。cmp(a,a) 返回 true,严格弱序被破坏,洛谷上的典型症状是 RE(Segmentation fault,常挂在中段测试点),而不是提示你写错了。改成 > 立刻通过。

漏写结构体定义结尾的 };,编译器在下一个函数处报 expected ';' before ...,报错行号和真正的坑差几十行。

sort(s, s + n - 1, cmp) 漏排最后一个元素:最大(或最小)的那个人永远上不去,表现为“答案总是差一名”的 WA。

greater<int> 少写末尾的圆括号。sort(a, a+n, greater<int>) 编译错误:类型不是表达式。

只写了总分一个关键字。测试点里专门有“总分相同、语文不同”的构造,单关键字版能过大部分点,剩下的 WA 很不明显——因为前几名常常恰好没并列。

自己动手复现

不看本页,把 P1093 敲一遍并提交。

再做三组破坏性实验:① 把 cmp 里两个 > 全改成 <,看输出是否整体倒过来;② 把最后一层改成 return false;,构造三个总分都是 240 的学生,看输出顺序是否还受规则约束(它可能恰好没变——这正是未定义行为的可怕之处);③ 故意写成 return a.sum >= b.sum;,多交几次观察是否偶发 RE。

改一改:把 cmp 换成等价的 lambda;再换成方案 C 的三趟 stable_sort,确认两种写法的输出逐字节相同。

数学补给 · 全序、偏序与严格弱序

排序器要的数学条件很明确:给定任意两个元素,它得能判断“谁先”。

全序:任意两个元素都可比且无并列。按 总分+语文+学号 排完,任意两人次序唯一——这才是全序,结果确定、可复现。偏序只保证可比的那部分有次序,允许并列。

严格弱序是 std::sort 的全部要求,翻成 cmp 就是三条公理:

  • 非自反:cmp(a, a) 恒为 false —— 自己不能排在自己前面。
  • 非对称:cmp(a, b) 为 true 时 cmp(b, a) 必须为 false。
  • 传递性:cmp(a, b) 与 cmp(b, c) 为 true,则 cmp(a, c) 必须为 true。

写 >= 违反的是第一条。sort 分区里有一句“只要还小就继续前进”,而“等于 pivot 也算小”会让它永远停不下来,指针冲出数组末尾——所以症状是 RE 而不是 WA。

字典序:把 k 个关键字看成 k 元组 (k₁, k₂, …, k_k),从第一个分量开始比,遇到第一个不同分量就定胜负。本节的 cmp 就是字典序的手写展开;sort 排 pair、string 用的也是它。它一定是严格弱序,因为元组比较本身是全序;唯一例外是元组完全相等,此时 cmp 返回 false,正好满足非自反——这就是最后一层要留一个唯一键的原因。

稳定排序与基数排序:对 k 个关键字,从最次要的键开始做 k 趟稳定排序,结果等价于一次多关键字排序。归纳证明:第 i 趟结束时序列按“前 i 个次要键”有序;第 i+1 趟稳定,相等元素保留上一趟次序,次要键信息被完整带过去。这就是 LSD 基数排序的正确性来源。

举一反三 · 想清楚这些再往下走
  1. 两个人总分、语文都相同时,cmp 最后写 a.id < b.id; 与写 return false; 有什么区别?后者破坏了哪条公理?
  2. 只按总分排序、不加学号兜底时,两个 250 分的同学谁在前?这个答案由什么决定?为什么不能指望它?
  3. 字段有 6 个关键字时,你写 6 层 if 还是逐层 stable_sort?两种写法各要多少次比较、几趟排序?
  4. 把 struct 换成 pair<int,int>,两关键字排序怎么写?三关键字呢?什么时候该退回 struct?
  5. “结构体里存原始下标”这件事还能用在哪?(输出原名次、离散化、排序后映射回原数组、按原顺序输出答案)
  6. 若题目要求“总分相同的人按输入顺序输出”,用 sort+下标 与 stable_sort 哪个更短?哪个更不容易写错?
自测:能全部回答再进下一节
  • 能默写 struct 定义 + 结构体数组读入,且不漏结尾的分号。
  • 能解释为什么 cmp(a, a) 必须返回 false,以及违反后为什么是 RE 而不是编译错误。
  • 能写出三关键字的 cmp,并说清每层“先判不同再比较”的意义。
  • 知道 sort(a, a + n) 覆盖哪些元素,写成 a + n - 1 会怎样。
  • 知道 greater<int>() 末尾那对括号为什么不能省。
挑战题 · 生日
洛谷 P1104普及-去洛谷做这题 ↗

有 n 个人,每人给出姓名和出生年、月、日,请按年龄从大到小输出他们的姓名。

如果两个人的生日完全相同,则后输入的那个人排在前面。

输入第一行一个整数 n(n ≤ 100),接下来 n 行,每行一个字符串和三个整数:姓名、年、月、日。输出 n 行,每行一个姓名。

引导性提示(先只看这里)

年龄大就是出生早。先把“年龄从大到小”翻译成“日期从小到大”,比较方向就不会写反。

年、月、日是分层比较:年份不同时,月和日根本不用看。

“生日相同的后输入排前面”说明光有日期不够——把输入顺序也变成一个能比较的数字存起来。

想看思路了?点开完整思路

结构体存 name、y、m、d、idx,idx 按输入顺序编号。

cmp:y 小的在前;y 相同比 m;m 相同比 d;三者全相同则 idx 大的在前。

idx 唯一,所以任意两人都有确定次序,cmp 永远不会对两个不同元素同时返回 false,严格弱序成立,sort 安全。

按排序后的顺序逐个输出 name。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

struct Per {
    string name;
    int y, m, d, idx;       // idx:输入顺序,用来打破生日相同的平局
};

bool cmp(const Per &a, const Per &b) {
    if (a.y != b.y) return a.y < b.y;    // 出生早 = 年龄大
    if (a.m != b.m) return a.m < b.m;
    if (a.d != b.d) return a.d < b.d;
    return a.idx > b.idx;                // 生日相同:后输入(idx 大)的排前面
}

int main() {
    int n;
    cin >> n;
    Per p[105];
    for (int i = 0; i < n; ++i) {
        cin >> p[i].name >> p[i].y >> p[i].m >> p[i].d;
        p[i].idx = i;                    // 先记下原始顺序,排完序就找不回来了
    }

    sort(p, p + n, cmp);                 // 左闭右开

    for (int i = 0; i < n; ++i) cout << p[i].name << "\n";
    return 0;
}
关键片段拆解
片段作用
struct Per { string name; int y, m, d, idx; };姓名和日期放在一起,排序时整包搬,姓名不会和日期错位。
if (a.y != b.y) return a.y < b.y;年份小 = 出生早 = 年龄大,所以是 <。顺着“年龄大”写成 > 就整体反了。
if (a.m != b.m) return a.m < b.m;同年才比月;月小 = 同一年里出生更早。
return a.idx > b.idx;唯一键兜底:生日完全相同时,后输入的 idx 更大,要排前面,所以用 >。
sort(p, p + n, cmp);覆盖 p[0..n−1]。cmp 对任意两个不同元素都有确定结论,不会出现“互相都不小于”。
这题最容易错的地方
  • 年份比较写成 a.y > b.y(顺着“年龄大”的方向想),输出整个反了。
  • 忘了 idx 这一层,收尾写 return false;。生日相同的两人次序不受控,且严格弱序被破坏,可能 RE。
  • 用 scanf("%s", s.name) 读 string——string 不是字符数组,会崩。要么 cin >>,要么把字段改成 char name[25]。
  • 结构体数组开成 Per p[100] 而 n 正好 100,越界写坏别的变量,症状时好时坏。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》0x05:排序与离散化
  • 《算法竞赛试炼场:洛谷300题精析》排序章
  • 洛谷 P1093、P1104 题解区的高赞写法
13 排序进阶:去重、离散化与第 k 大普及-

13 · 排序进阶:去重、离散化与第 k 大

排序只是开始。这一节把排序之后最常用的三个动作练成肌肉记忆:sort + unique + erase 三连去重、离散化把 10⁹ 的值域压成 10⁵ 个下标、nth_element 与 partial_sort 只拿你需要的那部分信息。
⭐ 已学范围普及-预计 60 分钟unique离散化nth_element
本节出现的新函数 / 新语法(先看懂再用)

第 12 节用 sort 把数据排成了一条线。真实题目很少停在“排好”这一步:它们紧接着问三件事——有哪些不同的值、这些值能不能当数组下标、第 k 个是谁。这一节用 5 个函数把这三问一次解决,每问都只要两三行。

例题 · 明明的随机数洛谷 P1059普及-去洛谷做这题 ↗

输入 N 个 1 到 1000 之间的整数(N ≤ 100),重复的数字只保留一个,把剩下的数从小到大排序,再输出不同数的个数 M 和这 M 个数。

输入: 10 20 40 32 67 40 20 89 300 400 15 输出: 8 15 20 32 40 67 89 300 400

同一道题有三条路。注意第三行代码量最少,而且它的代价只跟 N 有关,和值域无关——这是后面所有“大值域”题目的分水岭。

做法核心代码复杂度什么时候不够用
桶计数(第 6 节)cnt[x] = 1,再扫 1~1000O(N + 值域)值域 10⁹ 时开不出数组
手写去重排序后相邻比较,自己往前搬O(N log N)多写十几行,越界、漏搬都可能
sort + unique + erase三行O(N log N)几乎没有

这 5 个函数的参数几乎都是“左闭右开区间”。last 指向最后一个元素的下一个位置,永远不能取 *last。先把这句话刻进脑子,后面的解释才读得顺。

新函数拆解 · unique(相邻去重)
iterator unique(iterator first, iterator last);
组成含义 / 格式要求
first, last区间 [first, last),左闭右开。last 是尾后位置。
作用把相邻的重复元素合并:每组相等元素只保留第一个,其余依次往前挤。
返回值指向“去重后区间”的下一个位置,也就是新的逻辑尾。原区间长度不变。
前提只合并相邻元素。所以必须先 sort;未排序时 {1, 2, 1} 一个都去不掉。
比较规则两参数版本用 == 判断相邻元素是否相同,与升序降序无关。三参数版本 unique(first, last, pred) 把 pred(前一个, 后一个) 为真的相邻对当成重复——传 greater<int>() 会把严格递减的序列压成一个元素。
最小可运行示例
示例
int a[] = {1, 1, 2, 2, 2, 3, 3};
int* p = unique(a, a + 7);          // 前 3 个位置被写成 1 2 3
cout << (p - a) << "\n";            // 3:不重复元素的个数
// p 之后是残留值(标准不保证内容),长度(size)并没有变
注意

unique 不删元素、不改 size,只是把不重复的搬到前面,尾部留着旧值。

真正删掉要接 erase:v.erase(unique(v.begin(), v.end()), v.end())。

裸数组只能“逻辑删除”:记下 m = unique(a, a + n) - a,循环到 m 为止。

新函数拆解 · lower_bound(第一个 ≥ x 的位置)
iterator lower_bound(iterator first, iterator last, const T& x);
组成含义 / 格式要求
first, last有序区间。必须按与排序相同的比较规则有序。
x要找的值,类型和元素一致(int 就传 int)。
返回值第一个 ≥ x 的元素位置;所有元素都 < x 时返回 last。
下标写法lower_bound(a, a + n, x) - a 得到 0-based 下标;不存在时正好等于 n。
复杂度二分,O(log n) 次比较。n = 10⁵ 时最多 17 次。
典型用途查排名、判存在性、离散化编号。
最小可运行示例
示例
int a[] = {1, 3, 5, 7, 9};                       // 必须有序
cout << lower_bound(a, a + 5, 5) - a << "\n";    // 2:第一个 ≥ 5 的位置
cout << lower_bound(a, a + 5, 6) - a << "\n";    // 3:第一个 ≥ 6 的位置(a[3] = 7)
cout << lower_bound(a, a + 5, 100) - a << "\n";  // 5:没有 ≥ 100 的,返回尾后
注意

区间无序时不会报错,只会安静地给出错误答案。

vector 要传 v.begin(), v.end(),写 lower_bound(v, ...) 编译不过。

想找“最后一个 ≤ x 的位置”,写 upper_bound(...) - 1。

新函数拆解 · upper_bound(第一个 > x 的位置)
iterator upper_bound(iterator first, iterator last, const T& x);
组成含义 / 格式要求
first, last同上,必须有序。
返回值第一个 > x 的元素位置;没有则返回 last。
与 lower_bound 的差所有等于 x 的元素都落在 [lower, upper) 里,所以个数 = upper − lower。
x 比所有元素都大返回 last。减掉首地址得到 n,含义是“x 会排在最后”。
常见坑相等时 lower 在左、upper 在右:对 {3, 3, 3} 问 3,lower 给第一个 3,upper 给 3 后面那个位置。
最小可运行示例
示例
int a[] = {1, 3, 3, 3, 5};
cout << upper_bound(a, a + 5, 3) - a << "\n";                        // 4:第一个 > 3
cout << upper_bound(a, a + 5, 3) - lower_bound(a, a + 5, 3) << "\n"; // 3:3 出现了 3 次
cout << *lower_bound(a, a + 5, 2) << "\n";                           // 3:第一个 ≥ 2 的值
注意

统计某个值出现几次,用 upper − lower,不要手写循环。

判存在:it != last && *it == x,两步都要判。

新函数拆解 · nth_element(只让第 k 个就位)
void nth_element(iterator first, iterator nth, iterator last);
组成含义 / 格式要求
first, last整个区间,左闭右开。
nth希望“就位”的位置指针,不是个数、也不是下标值。第 k 小(0-based)写 first + k。
执行后*nth 就是第 (nth − first + 1) 小的元素;它左边都 ≤ 它,右边都 ≥ 它。
不保证左右两侧内部无序。要整体有序还得 sort。
复杂度平均 O(n) 次比较,最坏 O(n²)(竞赛数据里极少触发)。
最小可运行示例
示例
int a[] = {9, 1, 8, 2, 7, 3};
nth_element(a, a + 2, a + 6);      // 把“第 3 小”放到下标 2 上
cout << a[2] << "\n";              // 3
// 此时 a[0]、a[1] 都 ≤ 3,a[3..5] 都 ≥ 3,但它们内部顺序不定
注意

last 是尾后位置:6 个元素写 a + 6,写成 a + 5 会漏掉最后一个元素。

求第 k 大(k 从 1 开始):nth 写 a + (n - k)。

nth_element 会打乱数组,需要原始顺序就先拷一份。

新函数拆解 · partial_sort(只排前 k 个)
void partial_sort(iterator first, iterator middle, iterator last);
组成含义 / 格式要求
first, middle要真正排好的前半段 [first, middle),长度 k = middle − first。
last候选终点。[first, last) 里的元素都可能进前 k,后面的不管。
执行后[first, middle) 就是全局最小的 k 个,且已升序;[middle, last) 顺序不定。
复杂度O(n log k),比全排序的 O(n log n) 省;k 接近 n 时没有优势。
求前 k 大第 4 个参数传 greater<int>()。
最小可运行示例
示例
int a[] = {9, 1, 8, 2, 7, 3};
partial_sort(a, a + 3, a + 6);                 // 只要前 3 小
for (int i = 0; i < 3; ++i) cout << a[i] << " ";   // 1 2 3
注意

k = n 时等价于 sort,但常数更大,没必要。

只要答案不需要有序,nth_element 更快(O(n) vs O(n log k))。

三连的顺序不能换:sort 让重复相邻,unique 才能合并;unique 只挪不删,erase 才真正缩短长度。少任何一步,答案要么错,要么长度对不上。
例题怎么想:把“去重 + 排序”翻译成三行

题目要两样东西:不同数的个数 M、这 M 个数升序。第 12 节已经会排序了,剩下的问题只是“排序之后怎么去掉重复”。

为什么不能直接开桶:本题值域只有 1000,cnt[1001] 确实能过。但题目里“随机数”三个字是干扰项——真正决定方法的是值域。值域一旦变成 10⁹,桶就死了,而 sort 路线照样跑,因为它只花 O(N log N)。这就是先估数据范围、再选方法。

排序之后,相等的数一定挨在一起。于是“去重”退化成“把相邻的相同元素压成一个”,这正是 unique 干的事;再把尾部残留截掉,就得到答案。

完整代码:sort → unique → erase
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;
    vector<int> a(n);
    for (int i = 0; i < n; ++i) cin >> a[i];

    sort(a.begin(), a.end());                      // ① 排序:让相等的数挨在一起
    a.erase(unique(a.begin(), a.end()), a.end());   // ② 合并 + ③ 真正截断

    cout << a.size() << "\n";                      // 此时 size 就是不同数的个数
    for (int i = 0; i < (int)a.size(); ++i) {
        if (i) cout << " ";                        // 只在非首个元素前打空格
        cout << a[i];
    }
    cout << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
#include <bits/stdc++.h>一个头文件带上整套 STL:sort、unique、vector、lower_bound 全在里面。比赛不用逐个 include,省事也不影响速度。
vector<int> a(n);长度 n 的动态数组,下标 0..n−1。选它而不是裸数组,是因为 erase 只有容器才有。
sort(a.begin(), a.end());升序排列,把相等的数排到一起。这是 unique 生效的前提,不是可选项。
unique(a.begin(), a.end())返回值指向“新逻辑尾”:它把不重复的元素依次写到前面,返回写到的下一个位置。注意容器长度没有变。
a.erase(first, last)删掉 [first, last)。first 就是 unique 的返回值,last 是 a.end(),于是尾部残留被真正删掉,size 变小。两个实参来自两个不同的地方,别写反。
cout << a.size()去重后 size 恰好等于不同数的个数 M,不用另外统计。
if (i) cout << " "第一个数前面不加空格,最后一个数后面也不加。行末多一个空格在严格评测下会 WA。
(int)a.size()size() 返回无符号的 size_t,和 int 的 i 比较会触发符号警告;强转一次干净利落。
离散化:10⁹ 的值域,10⁵ 个下标

场景:n ≤ 10⁵ 个数,每个在 0..10⁹ 之间。你想“用值当下标”(桶计数、并查集、树状数组),但 10⁹ 个格子开不出来。可是你真正关心的只是大小关系,不是数值本身。

做法:把所有出现过的值排序、去重,得到一张“值域字典”;再把每个数换成它在字典里的名次(1..m)。值域从 10⁹ 压到 m ≤ n,而所有比较结果不变。这就是离散化。

标准三步
vector<int> xs(a.begin(), a.end());                 // ① 拷一份,原数组要留着被改写
sort(xs.begin(), xs.end());
xs.erase(unique(xs.begin(), xs.end()), xs.end());   // ② 排序 + 去重,xs 成为“值域字典”
int m = (int)xs.size();                             // 不同值的个数

for (int i = 0; i < n; ++i)
    a[i] = (int)(lower_bound(xs.begin(), xs.end(), a[i]) - xs.begin()) + 1;  // ③ 换成 1..m 的编号
三步各管什么
代码片段含义与理由
vector<int> xs(a.begin(), a.end())拷贝原始值。a 马上要被覆盖成编号,没有副本就再也查不到原值。
sort + unique + erase和例题完全一样的三连,把 xs 变成升序、无重复的字典。
int m = (int)xs.size()不同值的个数,也是编号的最大值。之后开桶数组用 m + 1 个格子。
lower_bound(...) - xs.begin() + 1二分查出 a[i] 在字典里的位置,加 1 变成 1..m 的编号。
a[i] = ...原地改写。之后所有“谁比谁小”的判断都改用编号,结论不变。
步骤内容
原数组 a(n = 5)5 1000000000 −7 42 1000000000
xs 排序去重(m = 4)−7 5 42 1000000000
a 被改写成编号2 4 1 3 4

三条铁律:① 必须用副本,原数组要留着被改写;② 编号从 1 开始,桶和树状数组都从 1 开始;③ 之后所有比较都改成比较编号——因为编号保序,答案一个字都不会变。

第 k 大:不排全序也能知道答案

只要一个数(第 k 大、中位数、前 90% 分位数),全排序就是浪费:你花了 O(n log n) 把所有数的顺序都定下来,却只用其中一条信息。

nth_element 只做一件事:把第 k 个位置上的数放对,同时保证它左边都 ≤ 它、右边都 ≥ 它。平均 O(n),比排序快一个 log。

求第 k 大
// 求 n 个数里的第 k 大(k 从 1 开始)
int n = 7, k = 3;
int a[] = {5, 9, 1, 7, 3, 8, 2};

nth_element(a, a + (n - k), a + n);   // 第 k 大 = 第 (n-k+1) 小 → 0-based 下标 n-k
cout << a[n - k] << "\n";             // 7
需求该调谁复杂度代价
整体有序sort(a, a + n)O(n log n)最贵,但信息最全
最小的前 k 个且有序partial_sort(a, a + k, a + n)O(n log k)后面一段顺序不定
第 k 小 / 中位数nth_element(a, a + k, a + n)平均 O(n)两侧无序
某个值的个数 / 排名upper_bound − lower_boundO(log n)要求已排序

选型的顺序:先问“我到底要哪条信息”。只要一条,就别付全排序的钱。

这一节最容易犯的 7 个错

unique 之后不 erase:a.size() 还是 n,输出会多出尾部残留值 → WA。vector 上必须写 a.erase(unique(a.begin(), a.end()), a.end())。

没排序就 unique:{1, 2, 1} 一个都去不掉,因为 unique 只合并相邻元素 → WA。看到“去重结果里还有重复”,先检查 sort 那一行在不在。

对降序数组用默认 lower_bound:sort(a, a + n, greater<int>()) 之后,lower_bound(a, a + n, x) 不再有“第一个 ≥ x”的含义,返回的位置没有任何保证 → WA。比较器必须与排序一致,或者换成 upper_bound 配 greater。

第 k 大的下标写成 a + k:k 从 1 开始时正确写法是 a + (n - k)。写反时 k 与 n−k 张冠李戴,小数据可能碰巧对,大数据必错 → WA。

nth_element 的尾指针写成 a + n - 1:区间少一个元素,最大值永远进不了统计,样例能过、大数据偶发错 → WA。

对每个查询都重新 sort 一遍:n = 10⁵、查询 10⁵ 次,每次 O(n log n) 就是 1.7×10⁶ 次比较,总共 ≈ 1.7×10¹¹ 次操作 → TLE。正确做法是排一次序、后面全部改用 lower_bound,或者先把答案预处理出来。

离散化后混用 m 和 n:a 的长度是 n,字典的长度是 m。写 for (int i = 0; i < m; ++i) 去遍历 a 会漏掉后 n−m 个数 → WA;反过来拿 n 当字典长度去 lower_bound 则越界 → RE。

自己动手复现

关掉页面,把 P1059 的代码敲一遍再提交。

交之前先在 unique 前后各打印一次 a.size(),确认它从 10 变成 8。这一步能让你彻底记住:unique 不改长度,erase 才改。

改一改:把 sort(a.begin(), a.end()) 换成 sort(a.begin(), a.end(), greater<int>()),其余不动。输出变成降序——unique 照样工作(它只判断是否相等),错的是题目要求升序。这同时说明:lower_bound 的比较器必须和 sort 一致,而 unique 只要求“相等”判断正确。

数学补给 · 离散化是保序单射,第 k 大是第 n−k+1 小

把“值换成名次”的映射记作 f。设不同值升序为 v1, v2, …, vm,定义 f(vi) = i。它有两个性质:

  1. 单射:不同的值名次不同。值域从 10⁹ 压到 m ≤ n ≤ 10⁵,一个格子一个下标,数组开得出来了。
  2. 保序:x < y 当且仅当 f(x) < f(y)。

只要题目里所有判断都只用到“谁比谁小”,把每个数换成 f(x) 之后答案完全不变:排序结果、去重结果、第 k 小是谁、逆序对个数都一样。这就是离散化合法的全部理由。

反过来,如果题目要输出具体的和、具体的差、具体的乘积,就不能离散化——数值本身有意义,名次没有。先判断题目要的是“相对大小”还是“绝对数值”,再决定要不要离散化。

第 k 大的下标:升序数组 a[0..n−1] 里,第 1 大是 a[n−1],第 2 大是 a[n−2],于是第 k 大是 a[n−k]。换一种说法:第 k 大 = 第 (n−k+1) 小。nth_element 要的是 0-based 下标,所以 nth 指针写 a + (n−k)。

n全排序比较次数 n·log₂nnth_element 平均比较次数
10⁴1.3×10⁵10⁴
10⁵1.7×10⁶10⁵
10⁶2.0×10⁷10⁶
10⁷2.3×10⁸10⁷

评测机 1 秒大约做 10⁸ 次比较。最后一行的全排序已经踩在超时线上,nth_element 却只用不到 1/20 的比较次数。复杂度里的 log 不是装饰:它决定了你下一秒是 AC 还是 TLE。

举一反三 · 想清楚这些再往下走
  1. 题目改成“输出每个不同值以及它出现的次数”,sort + unique 还够用吗?不够的话,缺的那一步该用哪个函数补?
  2. n = 10⁶,只要中位数。sort 和 nth_element 各约需多少次比较?什么情况下你仍然会选 sort?
  3. 值域 0..10⁹,但要问“第 k 小的数出现了几次”。离散化之后数组开多大?为什么不是 n + 1?
  4. 把 {10, 9, 8, 7, 6} 用默认 lower_bound 查 8,返回的是哪个下标?亲手跑一遍,再说出两种修法。
  5. 离散化能不能用来做“区间内不同数的个数”?先说清楚离散化会丢掉什么信息,再判断。
自测:能全部回答再进下一节
  • 能默写 sort + unique + erase 三连,并说清 erase 的两个实参分别来自哪里。
  • 能说出 unique 之后不 erase 会发生什么(长度不变、尾部残留),以及它为什么会造成 WA。
  • 在 a = {1, 3, 3, 3, 5} 上能直接报出 lower_bound(a, a+5, 3) - a 与 upper_bound(a, a+5, 3) - a 的值。
  • 能用 nth_element 求第 k 大,并解释 nth 为什么是 a + (n − k) 而不是 a + k。
  • 能独立完成一次离散化,并说出“必须用副本”“编号从 1 开始”两条理由。
  • 能说出 partial_sort 与 sort 的复杂度差别,以及 k 多大时用 partial_sort 就不划算了。
挑战题 · 第 k 小整数
洛谷 P1138普及-去洛谷做这题 ↗

给出 n 个正整数(n ≤ 10000),相同的数只算一个,求其中第 k 小的整数(k ≤ 4000)。所有正整数都小于 30000。

输入格式:第一行两个整数 n 和 k;第二行起是 n 个正整数,用空格隔开。

输出格式:一行一个整数,即第 k 小的数;如果不同的数不足 k 个,输出 NO RESULT。

引导性提示(先只看这里)

“相同的数只算一个”就是要先去重。去重的结果还必须有序,才谈得上第几小。

排好序、去完重以后,第 k 小就是下标 k−1 的元素。注意 k 从 1 开始,数组下标从 0 开始。

先数一数还剩几个数。剩下 m 个而 m < k 时,题目要的不是 0、不是空行,而是一个固定的单词。

想看思路了?点开完整思路

读入 n、k 和 n 个数。

sort 升序 → unique 合并 → erase 截断,得到 m 个互不相同的数(本节练的三连)。

若 m < k,输出 NO RESULT;否则输出 a[k − 1]。

复杂度 O(n log n):n = 10⁴ 时约 1.3×10⁵ 次比较,离 1 秒的预算差着三个数量级。

为什么正确:去重后数组严格递增,第 i 个元素就是第 i 小的不同值,所以第 k 小落在下标 k−1。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, k;
    cin >> n >> k;
    vector<int> a(n);
    for (int i = 0; i < n; ++i) cin >> a[i];

    sort(a.begin(), a.end());                      // ① 先让相等的数挨在一起
    a.erase(unique(a.begin(), a.end()), a.end());   // ② 合并 + 真正截断

    int m = (int)a.size();                         // 不同数的个数
    if (m < k) {                                   // ③ 不够 k 个,题目要求固定单词
        cout << "NO RESULT\n";
        return 0;
    }
    cout << a[k - 1] << "\n";                      // ④ 第 k 小 = 下标 k-1
    return 0;
}
关键片段拆解
片段作用
sort(a.begin(), a.end())把重复的挤到一起。unique 只认相邻相等,这一步不能省。
unique + eraseunique 返回新逻辑尾,erase 从那里删到末尾。执行完 a 里恰好是全部不同的数,且升序。
int m = (int)a.size()去重后的个数。别再用 n —— n 是原始个数,含重复。
m < k不同数只有 m 个,第 k 小不存在。这时必须输出 NO RESULT,输出 0 或什么都不输出都是 WA。
a[k - 1]k 从 1 开始数,下标从 0 开始,所以第 k 小在下标 k−1。
"NO RESULT\n"判题逐字比对,大小写、空格、下划线都必须和题面完全一致。
这题最容易错的地方
  • 写成 a[k]:那是第 k+1 小,并且 k = m 时直接越界 → WA 或 RE。
  • 忘记判 m < k 就访问 a[k−1]:越界读到一个随机值,表现为偶发 WA、或 RE。
  • 只 unique 不 erase:m 用 a.size() 得到的是 n,m < k 的判断失效,还会把尾部残留的重复值当成“第 k 小”输出 → WA。
  • 输出单词拼错(No Result、NO_RESULT、No!!!)→ WA;这题最容易丢的分不在算法上。
延伸阅读 / 参考
  • 《算法竞赛入门笔记》第 5 章:排序与离散化
  • 《算法竞赛进阶指南》0x05 排序
  • 洛谷 P1059、P1138 题解区的高赞写法
14 枚举与暴力:把搜索空间数清楚普及-

14 · 枚举与暴力:把搜索空间数清楚

暴力有前提:方案数 × 单方案代价 ≤ 10⁸。这一节先学会在纸上数方案,再学三个把方案数变小的工具——换枚举对象(207 次代替 10⁹ 次)、二进制枚举子集、折半搜索;顺带吃透 popcount、位运算与 next_permutation。
⭐ 已学范围普及-预计 60 分钟枚举复杂度估算二进制枚举
本节出现的新函数 / 新语法(先看懂再用)

暴力不是“随便套几层循环”。它有明确的前提:方案总数 × 每个方案的代价 ≤ 10⁸ 左右。写代码之前先把这两个数算出来——算得出来,你就知道该不该动手;算不出来,说明你还没想清楚要枚举什么。这一节练的就是这个动作,外加两个把方案数变小的工具:换枚举对象、只枚举一半。

例题 · 三连击洛谷 P1008普及-去洛谷做这题 ↗

把 1, 2, …, 9 这 9 个数字分成 3 组,每组 3 个,分别拼成 3 个三位数,要求这 3 个三位数之比为 1 : 2 : 3。求所有方案。

输出: 192 384 576 219 438 657 273 546 819 327 654 981

先别写代码,先数一下:这道题到底有多少种可能?同样一道题,三种数法给出三种命运。

枚举什么方案数每个方案要做什么总操作数
a、b、c 各自从 100 试到 999900³ = 7.3×10⁸拆 9 位查重≈ 6×10⁹,必然 TLE
1~9 的全排列9! = 362 880切 3 段 + 查重≈ 3×10⁶,能过
只枚举 a,令 b = 2a、c = 3a207拆 9 位查重≈ 2×10³,眨眼
什么时候可以放心暴力

方案数 ≤ 10⁷ 且每个方案 O(1):直接写,不用想优化。

值域小(≤ 10⁷):能开数组,就先用数组把信息存下来,再扫一遍。

答案唯一且可以“找到就退出”:平均代价远小于最坏代价,也敢写。

反面信号:n ≥ 25 还想枚举子集、n ≥ 12 还想枚举排列、n ≥ 5000 还写 O(n²)。见到这三个数,先停下来算。

这一节的三个工具分别负责“数得更少”“表示得更紧”“生成得更快”。

新函数拆解 · __builtin_popcount(数 1 的个数)
int __builtin_popcount(unsigned int x);
组成含义 / 格式要求
x待统计的整数。按无符号位模式解释,所以传负数会得到它的补码里 1 的个数。
返回值x 的二进制表示中 1 的个数,范围 0..32。
复杂度编译成一条 popcnt 指令或常数级查表,O(1)。比手写循环快一个量级。
64 位版本__builtin_popcountll(x)。对 long long 用 32 位版本只会统计低 32 位。
典型用途二进制枚举里筛“恰好选 k 个”的子集、算汉明距离、判 2 的幂(popcount == 1)。
最小可运行示例
示例
for (int mask = 0; mask < 8; ++mask)
    cout << mask << " -> " << __builtin_popcount(mask) << "\n";
// 0->0  1->1  2->1  3->2  4->1  5->2  6->2  7->3
注意

名字以两个下划线开头,是 GCC 内建函数,不是标准库函数;比赛用的编译器都有。

它只吃整数,不吃 bitset<N>(那个用 .count())。

新函数拆解 · 位运算:把一个 int 当 32 个开关
(mask >> i) & 1  mask | (1 << i)  mask & ~(1 << i)  mask ^ (1 << i)  1 << n
组成含义 / 格式要求
(mask >> i) & 1取第 i 位。位编号从 0 开始(最低位)。结果只能是 0 或 1,可直接当条件。
mask | (1 << i)把第 i 位置 1。按位或不动其它位。
mask & ~(1 << i)把第 i 位清 0。~ 之后只有第 i 位是 0,其余全 1。
mask ^ (1 << i)翻转第 i 位:0 变 1,1 变 0。
1 << n2ⁿ。n ≥ 31 时 int 装不下(1<<31 是负数),要写 1LL << n。
mask & (mask - 1)清掉最低位的 1。配合 while 可以只扫 mask 里为 1 的那些位。
最小可运行示例
示例
int mask = 0b1011;                  // 1011 = 11:第 0、1、3 位是 1
cout << ((mask >> 1) & 1) << "\n";  // 1:取第 1 位
mask |= (1 << 2);                   // 置位  → 1111
mask &= ~(1 << 0);                  // 清位  → 1110
cout << __builtin_popcount(mask) << "\n";   // 3
注意

位编号从 0 开始:第 0 位的权值是 1,第 i 位的权值是 2ⁱ。

& 和 && 不是一回事:mask && 3 是逻辑与,结果是 0/1,写成它就会变成“全选”或“全不选”。

循环上界写成 1 << 31 会让条件永真,直接死循环 → TLE。

新函数拆解 · next_permutation(下一个字典序排列)
bool next_permutation(iterator first, iterator last);
组成含义 / 格式要求
first, last要重排的区间,左闭右开。
前提区间当前必须升序,否则只能拿到“从当前排列往后”的那部分,前面的全丢。函数不会帮你排序。
返回值还有下一个排列 → 返回 true 并原地改好;已经是最后一个排列 → 返回 false,同时把区间重置为升序(即最小的那个排列)。
个数n 个互不相同的元素共有 n! 个排列,do-while 正好执行 n! 次。
写法必须 do-while,不能 while:第一个排列也要输出。
最小可运行示例
示例
int a[] = {1, 2, 3};                 // 升序 = 字典序最小的排列
do {
    for (int i = 0; i < 3; ++i) cout << a[i] << " ";
    cout << "\n";
} while (next_permutation(a, a + 3));  // 还有下一个就继续
// 输出 6 行:1 2 3 / 1 3 2 / 2 1 3 / 2 3 1 / 3 1 2 / 3 2 1
注意

元素重复时按“不重复的排列”输出,总数少于 n!。

求上一个排列用 prev_permutation,初值要写成降序。

上面为了短,每行末尾多打了一个空格;真要提交时改成 if (i) cout << " "; 的写法。

枚举的三件事:枚举对象决定方案数的量级,表示方法决定一个方案要存多少信息,生成顺序决定你能不能边生成边剪掉不可能的分支。三者改一个,复杂度就可能差几个数量级。
例题怎么想:先砍掉一个数量级,再动手

最省事的写法是三层的循环 a, b, c 各 100..999,再拆位检查——7.3×10⁸ 个三元组,每个还要拆 9 位,总计 6×10⁹ 次操作,1 秒的题必死。

关键观察:题目给了比例。只要 a 定了,b = 2a、c = 3a 就定了。枚举对象从“三个数”换成“一个数”,方案数从 10⁹ 掉到 10²。这就是“数清楚”的第一层收益:不用算法,只换视角。

a 的范围也不是 100..999:a 自己得是三位数、不含 0、数字互不相同,最小就是 123;c = 3a 也得是三位数(3a ≤ 987),所以 a ≤ 329。能算出边界就不要硬写 999。

剩下的活是判重:三个三位数一共 9 个数字,要求恰好是 1..9 各一次。用 cnt[10] 数每个数字出现的次数,出现 0 或某个数字第二次出现就否决,最后检查 1..9 是否都恰好一次。

完整代码:只枚举 a,共 207 次
#include <bits/stdc++.h>
using namespace std;

int main() {
    for (int a = 123; a <= 329; ++a) {          // 3a ≤ 987 ⇒ a ≤ 329
        int cnt[10] = {0};                      // cnt[d]:数字 d 出现了几次
        int p[3] = {a, 2 * a, 3 * a};           // 由比例生成,不用三层循环
        bool ok = true;

        for (int t = 0; t < 3 && ok; ++t) {
            int x = p[t];                       // 拆 x 的三位,x 是副本,不动 p[t]
            for (int j = 0; j < 3; ++j) {
                int d = x % 10;
                x /= 10;
                if (d == 0 || ++cnt[d] > 1) {   // 出现 0 或重复,立刻否决
                    ok = false;
                    break;
                }
            }
        }
        if (!ok) continue;

        bool all = true;                        // 1~9 是否每个恰好出现一次
        for (int d = 1; d <= 9; ++d)
            if (cnt[d] != 1) { all = false; break; }
        if (all) cout << a << " " << 2 * a << " " << 3 * a << "\n";
    }
    return 0;
}
逐行拆解
代码片段含义与理由
a <= 3293a 必须还是三位数,987 / 3 = 329;下界 123 保证 a 本身是三位数。区间 [123, 329] 共 207 个数,这就是全部方案数。
int cnt[10] = {0}数字只可能是 0..9,用值当下标,10 个格子够。初始化写成 {0} 是关键,否则数组里是随机值。
int p[3] = {a, 2 * a, 3 * a}由比例直接算出另外两个数。这一步把三层循环压成一层,是整个解法的核心。
int d = x % 10; x /= 10;取末位、砍掉末位,三次拆完一个三位数。x 是副本,所以循环结束后 p[t] 不变。
d == 0 || ++cnt[d] > 1左边先短路:出现 0 直接否决,不碰 cnt[0]。右边先自增再比较:第一次出现得 1 不触发,第二次得 2 触发。一句代码同时完成“范围检查”和“判重”。
cnt[d] != 19 个数字两两不同且都在 1..9 的充要条件:每个数字恰好出现一次。
cout << a << " " << 2 * a ...a 从小到大枚举,输出天然按第一个数升序,不需要额外排序。
二进制枚举:一个 int 就是一个子集

n 个元素“选或不选”,一共 2ⁿ 种结果。把每种结果编码成一个整数:第 i 位是 1 表示选第 i 个元素。于是 for (int mask = 0; mask < (1 << n); ++mask) 这一层循环就把所有子集走完了。

它比 n 层嵌套循环短得多,也比递归早 7 节就能用(第 21 节才讲递归)。代价是不能剪枝:2ⁿ 个方案一个不落。

子集枚举模板
// 演示:n ≤ 20,统计元素和恰好等于 S 的子集个数(不含空集)
int n = 5, S = 10;
int a[5] = {1, 2, 3, 4, 5};
int ans = 0;

for (int mask = 0; mask < (1 << n); ++mask) {   // 2⁵ = 32 个 mask,一个 mask 一个子集
    int sum = 0;
    for (int i = 0; i < n; ++i)
        if ((mask >> i) & 1) sum += a[i];       // 第 i 位是 1 ⇒ 选 a[i]
    if (mask != 0 && sum == S) ++ans;           // 空集的和是 0,本题不要
}
cout << ans << "\n";                            // 3
逐行拆解
代码片段含义与理由
1 << n左移 n 位就是 2ⁿ。n = 20 时是 1048576,mask 从 0 到 2ⁿ−1,正好一个子集一个编号。
mask < (1 << n)上界写 1 << n 且不带等号。写成 <= 会多枚举一个不存在的子集。
(mask >> i) & 1把第 i 位移到最低位再与 1 相与,结果是 0 或 1,可以直接当条件用。
sum += a[i]这一位是 1,说明子集包含 a[i]。注意位编号对应的是下标 i,不是 a[i] 的值。
mask != 0mask = 0 表示一个都不选。很多题不允许空集,这一句必须写。
n2ⁿ内层 O(n) 时的总操作数结论
101 024≈ 10⁴随便过
1665 536≈ 10⁶过
201 048 576≈ 2×10⁷过
2533 554 432≈ 8×10⁸超时,改折半
301 073 741 824≈ 3×10¹⁰不可能

把这张表记住比记住代码有用:看到 n ≤ 20 就想二进制枚举,看到 n ≥ 25 就换方法。如果题目还要求“恰好选 k 个”,用 __builtin_popcount(mask) == k 筛掉大部分方案——C(n, k) / 2ⁿ 的比例可以从 C(20, 10) / 2²⁰ ≈ 18% 手算出来。

折半搜索:2⁴⁰ 太大,就做两次 2²⁰

n = 40 时 2⁴⁰ ≈ 1.1×10¹²,一秒跑不完。但把数组切成两半,每半 20 个,各枚举 2²⁰ ≈ 1.05×10⁶ 个子集和;再让“左半取 s、右半取 S − s”配对就行。

配对靠排序 + 二分,正好用上第 13 节的 lower_bound 与 upper_bound。这就是折半搜索(meet in the middle):把一次 2ⁿ 的枚举,换成两次 2^(n/2) 的枚举加一次二分。

折半:枚举两半 + 排序一半 + 另一半二分
int h = n / 2;                                 // 切成 20 + 20
vector<long long> L, R;

for (int mask = 0; mask < (1 << h); ++mask) {  // 左半:2²⁰ 个子集和
    long long s = 0;
    for (int i = 0; i < h; ++i)
        if ((mask >> i) & 1) s += a[i];
    L.push_back(s);
}
for (int mask = 0; mask < (1 << (n - h)); ++mask) {   // 右半:2²⁰ 个子集和
    long long s = 0;
    for (int i = 0; i < n - h; ++i)
        if ((mask >> i) & 1) s += a[h + i];           // 原数组下标是 h + i
    R.push_back(s);
}

sort(R.begin(), R.end());                      // 只排一半

long long ans = 0;
for (long long s : L)                          // 左半每个和,去右半里数 S−s 出现几次
    ans += upper_bound(R.begin(), R.end(), S - s)
         - lower_bound(R.begin(), R.end(), S - s);
关键几行
代码片段含义与理由
int h = n / 2把 40 个数切成 20 + 20。两半不必等长,差 1 无所谓。
1 << h2²⁰ ≈ 1.05×10⁶,比 2⁴⁰ 小了六个数量级。折半的全部收益就在这个指数上。
a[h + i]右半第 i 个元素在原数组里是 a[h+i]。下标错位是折半搜索最常见的 WA。
sort(R.begin(), R.end())只排右半。左半保持无序,靠二分去查。
upper_bound - lower_bound相减 = 等于 S−s 的元素个数,也就是能和 s 配成 S 的方案数。
n直接枚举 2ⁿ折半后的枚举量排序 + 二分总量级
201.0×10⁶2.1×10³≈ 2×10⁴10⁴
301.07×10⁹6.6×10⁴≈ 1×10⁶10⁶
401.1×10¹²2.1×10⁶≈ 4×10⁷10⁷

代价是内存:两个数组各存约 10⁶ 个 long long,各 8 MB,还扛得住。再大就得换个思路。记住这个手势:指数太大就砍一半——枚举一半、排序一半、另一半去查。

这一节最容易犯的 7 个错

循环上界写 1 << 31:它是 int 的负数(−2147483648),条件永真 → 死循环 TLE。n ≥ 31 时必须写 1LL << n,循环变量也要 long long。

next_permutation 之前忘了排序:初值是 {3, 2, 1} 时函数第一次就返回 false,6 个排列只输出 1 个 → WA。

用 while 代替 do-while:第一个排列被跳过。n = 1 时程序一行都不输出 → WA(样例就挂)。

__builtin_popcount 用错宽度:对 long long 用它只统计低 32 位,1LL << 40 会得到 0 → WA。64 位要用 __builtin_popcountll。

把“第 i 个元素”和“值 i”搞混:写 if (mask & (1 << a[i])) 是把 a[i] 当位号,值一过 30 就移出有效位,答案全错 → WA。位号永远是下标。

把 2ⁿ 个方案的结果全存下来:n = 25 时是 3.3×10⁷ 个 int,光数组就要 130 MB → MLE,很多平台紧接着就是 RE。边枚举边统计,别存。

没估量级就开写:n = 30 直接枚举子集,2³⁰ ≈ 1.07×10⁹ 个方案,每个还要 O(n),实际 3×10¹⁰ 次操作 → 必然 TLE。先数一遍就不会有这一条。

自己动手复现

关掉页面,把 P1008 的代码敲一遍并提交,确认输出正好 4 行。

先把输出换成计数器,打印候选数应是 207;再把 a <= 329 改成 a <= 987,候选数变成 865,答案仍是那 4 行——说明缩小枚举范围不改变正确性,只改变耗时。

改一改:用 next_permutation 枚举 1~9 的全排列(9! = 362 880 种),每 3 位切成一段验证 1 : 2 : 3,用 clock() 量一下两种写法的耗时。你会看到 207 次和 3.6×10⁵ 次的差距——这就是“数清楚”的收益。

数学补给 · 2ⁿ、n! 与 10⁸:把方案数折成时间

评测机 1 秒大约做 10⁸ 次简单操作(比较、加法、赋值)。所以判断能不能暴力,只需要算一个乘积:方案数 × 每个方案的代价,和 10⁸ 比大小。这一步在纸上做,比在评测机上做便宜得多。

n2ⁿ(子集)n!(排列)能不能暴力
101 0243 628 800都行
1532 7681.3×10¹²子集行,排列不行
201 048 5762.4×10¹⁸子集行(还要看每方案代价)
2533 554 432—勉强,2ⁿ·n 已到 8×10⁸
301 073 741 824—不行

折半的账:2ⁿ = 2^(n/2) × 2^(n/2)。两半各枚举 2^(n/2),再花 O(2^(n/2) · n/2) 排序、O(2^(n/2) · n/2) 配对,总量从 2ⁿ 降到约 n·2^(n/2)。n = 40 时:1.1×10¹² → 4×10⁷,快约 2.7 万倍。指数上的 n 变成 n/2,比任何常数优化都值钱。

组合恒等式:对固定的 n,C(n, 0) + C(n, 1) + … + C(n, n) = 2ⁿ。也就是说“按子集大小分类枚举”和二进制枚举覆盖的是同一批对象,只是访问顺序不同。二进制枚举省掉了 k 层循环的写法成本,代价是不能提前剪枝——这正是第 23 节 DFS 要补上的东西。

排列的增长:10! = 3.6×10⁶,11! = 4.0×10⁷,12! = 4.8×10⁸,13! = 6.2×10⁹。所以 next_permutation 全排列的实用上限大约在 n = 11;n = 12 就算每轮只做 O(1) 也已经踩线。

举一反三 · 想清楚这些再往下走
  1. n = 25,要统计“元素和等于 S 的子集个数”。直接枚举和折半各要多少次基本操作?分别能不能过?
  2. 一道题的暴力是 O(n²)。n = 5000 与 n = 10⁵ 各是多少次操作?写出你判断“够不够”的过程。
  3. 用 next_permutation 枚举 n = 12 的全排列,光生成就要 4.8×10⁸ 次。为什么即使每轮只做 O(1) 也不该这么写?
  4. 二进制枚举里,怎样只处理“恰好选了 k 个”的子集?被筛掉的比例是多少?用 C(n, k) / 2ⁿ 说话。
  5. “枚举 a 再用比例推出 b、c”比“枚举 a、b、c”快约 350 万倍。省掉的到底是哪一层因子?换成别的题,你能找到同样的比例关系吗?
自测:能全部回答再进下一节
  • 能说出 1 秒大约能做多少次简单操作,并据此判断 2²⁰、2³⁰、9! 能不能暴力。
  • 能默写二进制枚举框架,并说清 mask 的第 i 位对应数组里的哪个元素。
  • 能解释 next_permutation 为什么要求区间先升序,以及为什么必须用 do-while。
  • 能说出 __builtin_popcount 的两个坑(64 位版本、位编号从 0 开始)。
  • 能描述折半搜索的两步:数组怎么切、切完以后在另一半里找什么。
  • 能对着 P1008 说出 a 的上界为什么是 329,以及这个上界把方案数从多少降到了多少。
挑战题 · 全排列问题
洛谷 P1706普及-去洛谷做这题 ↗

按字典序输出 1 到 n 的所有不重复排列,即 n 的全排列,同一个序列中不允许出现重复的数字。

输入格式:一个整数 n(1 ≤ n ≤ 9)。

输出格式:共 n! 行,每行一个由 1~n 组成的序列,每个数字保留 5 个场宽。

引导性提示(先只看这里)

n = 9 时共有 9! = 362 880 行。递归生成当然可以,但标准库里已经有一个“按字典序吐出下一个排列”的函数。

这个函数要求区间一开始是升序,否则你只能从当前排列往后走,前面的全丢了。

场宽用 setw(5)。它只对紧随其后的那一个输出生效,所以要写在循环里。

想看思路了?点开完整思路

把数组 a 初始化成 1, 2, …, n——这是字典序最小的排列。

用 do-while 反复调用 next_permutation:它把 a 改成字典序的下一个排列,最后一个排列的下一次调用返回 false。

每轮输出 a 的 n 个数,每个用 setw(5) 补齐到 5 个字符,行末只换行。

为什么输出天然是字典序:next_permutation 的定义就是“下一个字典序”,从最小排列出发一路调用,顺序必然正确。

复杂度 O(n · n!):n = 9 时约 3.3×10⁶ 次输出,远在 10⁸ 之内。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;

    vector<int> a(n);
    for (int i = 0; i < n; ++i) a[i] = i + 1;   // 字典序最小的排列

    do {
        for (int i = 0; i < n; ++i)
            cout << setw(5) << a[i];            // 每个数字占 5 个场宽
        cout << "\n";
    } while (next_permutation(a.begin(), a.end()));   // 还有下一个就继续

    return 0;
}
关键片段拆解
片段作用
a[i] = i + 1初始化为 1..n 的升序,即字典序最小的排列。不从最小处出发,前面的排列就全都拿不到。
do {先输出再判断。写成 while 会跳过第一个排列;n = 1 时程序将一行都不输出。
setw(5)设置“下一次输出”的最小宽度为 5,右对齐、左边补空格。它只作用于紧跟其后的一个值。
next_permutation(a.begin(), a.end())把区间改成下一个字典序排列,还有下一个则返回 true;已经是最后一个时返回 false,并把区间重置为升序。
cout << "\n"每行末尾只换行。场宽已经补齐,不要再手动加空格,否则行末多出尾随空格。
这题最容易错的地方
  • 忘了把初值写成升序,导致输出的排列不完整(例如初值是 3 2 1 时只会输出 1 行)。
  • 用 while 而不是 do-while,少输出第一行 1 2 … n。
  • 用 cout << a[i] << ' ' 代替场宽,格式不符直接 WA。
  • 把 setw 写在循环外面,或误以为它会一直生效——它只影响下一个输出。
  • 想先把所有排列存进数组再输出:9! 行 × 9 个数会撑爆内存,边生成边输出才对。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》0x02 枚举、模拟与递推
  • 《算法竞赛黄金法则》第 3 章:先数后写
  • 洛谷 P1008、P1706 题解区的高赞思路
15 回顾三:函数、结构体与排序普及-

15 · 回顾三:函数、结构体与排序

把第 11~14 节的四件工具装回同一把刀:函数打包逻辑,结构体打包数据,sort + cmp 按你的规则排队,枚举把搜索空间数清楚。5 道题,90 分钟,做完再进贪心。
⭐ 已学范围回顾训练普及-建议 90 分钟回顾5 题

第 11~14 节给的是四件工具:函数打包逻辑,结构体打包数据,sort + cmp 按你定的规则排队,枚举把搜索空间数清楚再暴力。比赛里它们几乎同时出现:先给数据建模(结构体),再定比较规则(cmp),交给 sort,最后按规则取出答案。这一节不讲新算法,只把这四件工具磨到不会写错。

排序题 90% 的分数不在 sort 那一行,而在 cmp 的三行判断里。
节内容一句话判据
11函数:参数传递与返回值想改调用者的变量,参数写成 类型 &;只读不写,写 const 类型 &
12结构体与多关键字排序排序规则一句话说不完,就写 cmp
13去重、离散化与第 k 大全部有序用 sort;只要一个位置用 nth_element;值域 10⁹ 而下标只有 10⁵,先离散化
14枚举与暴力先估搜索空间有多大,再决定写几重循环
例题 · 奖学金洛谷 P1093普及-去洛谷做这题 ↗

n 名学生,每人有语文、数学、英语三科成绩(0~100),总分 = 三科之和。

排序规则:总分高的在前;总分相同,语文高的在前;总分和语文都相同,学号小的在前。

输出:按上述规则排好的前 5 名,每行「学号 总分」。

输入格式:第一行 n(n ≤ 300);接下来 n 行,第 i 行是学号为 i 的学生的语文、数学、英语成绩。

输出格式:共 5 行,每行两个整数,学号与总分。

输入: 6 90 67 80 87 66 91 78 89 91 88 99 77 67 89 64 78 89 98 输出: 6 265 4 264 3 258 2 244 1 237

这题只输出学号和总分,排序却要用到语文。所以结构体必须同时存 id、ch、ma、en、sum:排序要用什么,结构体就存什么。下面先把四件工具的写法逐个拆开,再拼成完整代码。

新函数拆解 · sort(三参数版)
sort(v.begin(), v.end(), cmp);
组成含义 / 格式要求
v.begin(), v.end()左闭右开区间 [first, last),排的是 first 到 last-1。
cmp比较函数:返回 true 表示「a 应该排在 b 前面」。
返回值void。排的是原区间本身,不产生新数组。
复杂度O(n log n) 次比较。n = 10⁶ 时约 2×10⁷ 次,1 秒内能过。
两参数版sort(v.begin(), v.end()) 按 < 升序;greater<int>() 降序。
最小可运行示例
示例
vector<int> v = {3, 1, 4, 1, 5};

sort(v.begin(), v.end());                  // 1 1 3 4 5   升序
sort(v.begin(), v.end(), greater<int>());  // 5 4 3 1 1   降序

int a[5] = {3, 1, 4, 1, 5};
sort(a, a + 5);        // 数组用「首指针, 尾后指针」
sort(a + 1, a + 4);    // 只排 a[1]..a[3],a[0] 与 a[4] 不动
注意

数组写 sort(a, a + n);写成 sort(a, a + n - 1) 就漏掉最后一个元素。

下标从 1 开始时是 sort(a + 1, a + n + 1),尾后指针要多加 1。

新函数拆解 · cmp 比较函数(结构体 + const 引用)
bool cmp(const Stu &a, const Stu &b)
组成含义 / 格式要求
bool返回 a 是否该排在 b 前面:true 排前面,false 不排前面。
const Stu &a引用传参不拷贝;结构体里有 string 时,按值传参每次比较都要复制一份。
第一个 if第一关键字:不相等就定胜负,相等才往下走。
最后一个 return兜底关键字,必须让任意两个不同元素分出胜负。
严格弱序三条铁律:cmp(a,a) 必须为 false;cmp(a,b) 与 cmp(b,a) 不能同时为 true;大小关系可传递。
最小可运行示例
示例
struct Stu { int id, sum, ch; };

bool cmp(const Stu &a, const Stu &b) {
    if (a.sum != b.sum) return a.sum > b.sum;  // 总分降序
    if (a.ch  != b.ch ) return a.ch  > b.ch;   // 语文降序
    return a.id < b.id;                        // 学号升序:兜底,必有胜负
}
注意

写成 a.sum >= b.sum 破坏严格弱序:sort 可能越界读写,本地小数据正常、评测大点 RE 或乱序 WA。

cmp 里只做比较,不要改元素、不要输出调试信息——sort 调用它多少次是不确定的。

新函数拆解 · unique(去重)
v.erase(unique(v.begin(), v.end()), v.end());
组成含义 / 格式要求
前提区间已经有序(至少相同元素相邻),否则只去掉相邻的重复。
作用把不重复元素依次搬到区间前部,返回去重后区间的尾后指针。
长度不变unique 不删元素,容器 size 仍是原值,多余旧值留在后面。
erase 收尾删掉 [新尾, 原尾) 这一段,size 才等于答案。
只要个数int k = unique(a, a + n) - a; 不删元素,直接拿到去重后的长度。
最小可运行示例
示例
vector<int> v = {1, 1, 2, 2, 2, 5};
sort(v.begin(), v.end());                      // 前提:先有序

v.erase(unique(v.begin(), v.end()), v.end());
// v = {1, 2, 5}, v.size() == 3

int a[6] = {1, 1, 2, 2, 2, 5};
int k = unique(a, a + 6) - a;                  // k == 3,前 3 个是答案
注意

只写 unique 不写 erase,再按原长度遍历就会输出重复值。

没排序就 unique,只合并相邻重复:1 2 1 一个也去不掉。

新函数拆解 · nth_element(求第 k 小)
nth_element(a, a + k, a + n);
组成含义 / 格式要求
a + k第 k 小(k 从 0 开始)该放的位置。执行完 a[k] 就是答案。
两侧保证左边元素都不大于 a[k],右边都不小于它。
两侧无序不要假设 a[0..k-1] 有序,它只是「都比 a[k] 小」。
复杂度平均 O(n),比 sort 的 O(n log n) 快。n = 5×10⁶ 时差距是 0.2 秒和 1 秒的区别。
第 k 大换下标:k 从 1 起时,第 k 大 = 第 n-k 小。
最小可运行示例
示例
int a[8] = {7, 3, 9, 1, 5, 2, 8, 4};
nth_element(a, a + 2, a + 8);   // a[2] == 3,即第 2 小(0 起)
// 此时 a[0..1] 都 ≤ 3,a[3..7] 都 ≥ 3,但两侧都没排好序
注意

k ≥ n 是未定义行为,本地可能不报错,评测机给出乱答案。

要的是「排序后的前 k 个」而不是「第 k 个」时,必须用 sort。

第 11 节回头看一眼:函数的三句话

参数:写 int f(int x) 是拷贝,改 x 不影响调用者;写 void f(int &x) 才真正改到外面的变量。

返回:bool isPrime(int x) 让调用处读起来就是一句话;需要返回多项时,返回一个结构体比用全局变量干净。

作用域:函数里定义的变量出了函数就消失;算法题里的大数组一律放全局,主函数里开 10⁶ 个 int 会爆栈。

例题怎么想:先定关键字,再写 cmp,最后才是 sort

关键字有三层,顺序不能换:总分降序 → 语文降序 → 学号升序。前一层分不出胜负才看后一层。

第三层「学号升序」不是为了好看:题目保证学号互不相同,这一层让任意两人一定分出先后,cmp 才是严格弱序,sort 的行为才可预测。这正是 P1093 里「必须严格按规则排序」的含义。

总分在读入时就算好存进结构体。放进 cmp 里现算的话,每次比较多做 3 次加法,n log n 次比较就是几百万次多余运算。

排序只需要一次 sort,之后按下标取前 5 个就行——不要再写任何比较。

完整代码 · 结构体 + 三关键字 cmp
#include <bits/stdc++.h>
using namespace std;

struct Stu {
    int id;            // 学号:第 i 行数据就是学号 i
    int ch, ma, en;    // 语文、数学、英语
    int sum;           // 总分,读入时一次算好
};

// 返回 true 表示 a 应该排在 b 前面
bool cmp(const Stu &a, const Stu &b) {
    if (a.sum != b.sum) return a.sum > b.sum;   // ① 总分高者在前
    if (a.ch  != b.ch ) return a.ch  > b.ch;    // ② 总分相同,语文高者在前
    return a.id < b.id;                         // ③ 再相同,学号小者在前(兜底)
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;
    vector<Stu> v(n);
    for (int i = 0; i < n; ++i) {
        v[i].id = i + 1;                         // 输入顺序就是从 1 开始的学号
        cin >> v[i].ch >> v[i].ma >> v[i].en;
        v[i].sum = v[i].ch + v[i].ma + v[i].en;  // 只在读入时加一次
    }

    sort(v.begin(), v.end(), cmp);

    for (int i = 0; i < 5 && i < n; ++i)         // n 小于 5 时也不会越界
        cout << v[i].id << " " << v[i].sum << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
#include <bits/stdc++.h>竞赛万能头,一次包含 vector、algorithm(sort、unique、nth_element)、iostream。
struct Stu { ... };把一个人的 5 项信息打包成一个类型。sort 交换的是整个结构体,id 与 sum 跟着 ch/ma/en 一起移动,不会错位。
bool cmp(const Stu &a, const Stu &b)const 引用:不拷贝、不改原对象。本题结构体只有 int,按值传也不慢,但换成带 string 的结构体就会明显变慢。
if (a.sum != b.sum) return a.sum > b.sum;第一关键字降序。先判「不相等」再比大小,后两层只在真正相等时才执行。
if (a.ch != b.ch) return a.ch > b.ch;第二关键字:只有总分相同时才走得到这一行,所以不需要再判总分。
return a.id < b.id;兜底。学号互不相同,任意两元素在这里一定分出胜负,cmp 满足严格弱序。
v[i].id = i + 1;第 i 行数据(i 从 0 起)对应学号 i+1。写错这一行,输出顺序对、号码全错,是本题最常见的 WA。
v[i].sum = ...;读入即算。放在 cmp 里算也能过,但每次比较都重算三遍加法,属于白费。
sort(v.begin(), v.end(), cmp);按 cmp 排整个 vector。n ≤ 300,排序耗时可以忽略,真正的成本在读入。
i < 5 && i < n题目要求输出前 5 名;加 i < n 保证 n 小于 5 时不越界。
排序要求cmp 写法注意
升序return a < b;等价于不写 cmp
降序return a > b;不要写成 a >= b
多关键字if (a.x != b.x) return a.x > b.x;
return a.y < b.y;
最后一层必须能分出胜负
拼起来更大return a.s + b.s > b.s + a.s;字符串拼接比较,不是比长度
按比值排序return (long long)a.p * b.w > (long long)b.p * a.w;交叉相乘代替除法,避免浮点
这一章最容易踩的 7 个坑(都带症状)

cmp 里写 >= 或 <=:严格弱序被破坏,sort 内部可能走到区间外。症状是本地小数据全对、交上去 RE 或随机 WA,最难查。

cmp 忘了兜底 return:函数走到末尾没有返回值,是未定义行为。症状是同一份代码两次运行结果不同。

关键字的顺序写反:先比语文再比总分,样例可能刚好过,遇到总分高但语文低的就 WA。

把 sort(a, a + n) 写成 sort(a, a + n - 1):最后一个元素没参与排序。症状是输出里总有一个数在原位不动。

unique 后忘记 erase:后续按原长度遍历,输出里出现旧值。症状是答案行数比预期多。

用 double 算 m * 1.5 再截断:浮点存不准时可能少算 1 名。症状是分数线差 1 分、人数差 1 人,样例还看不出来。

n = 10⁵ 还自己写冒泡或选择排序:约 5×10⁹ 次比较,必定 TLE;sort 只要 1.7×10⁶ 次,差距是 3000 倍。

第 13 节还有两件工具在这里补一句:离散化 = 排序 + 去重 + 把每个值换成它的名次,把 10⁹ 的值域压成 10⁵ 以内的下标;第 k 大 = 第 n−k 小,只是一次下标换算。两者都建立在「先排好序」之上,所以本章的动作顺序永远是:建模 → 排序 → 查表。

自己动手复现

关掉页面,把上面 P1093 的完整代码手打一遍并提交。

改一改①:把「输出前 5 名」改成「输出全部 n 名」,只动 for 循环的边界。

改一改②:把第三关键字从「学号升序」改成「英语降序 + 学号升序」,用同一组数据看结果变没变。

改一改③:把兜底那行改成 return false;,输入 5 个总分完全相同的学生,观察输出顺序是否还是学号升序——这就是严格弱序被破坏的现场。

题编号主要考什么最容易错在哪
AP1059sort + unique 的去重三行普及-
BP1104结构体的多关键字排序普及-
CP1068排序 + 分数线与并列人数普及-
DP1923nth_element + 快读 + 全局数组普及/提高-
EP1217枚举 + 两个判定函数偶数位回文数被 11 整除;i*i 溢出
这次训练怎么用 90 分钟

0~10 分钟:只读题,不写代码。把 5 题按「能立刻写」→「有思路」→「没思路」排个序。

10~30 分钟:P1059。最短的一道,用来找手感;写完只检查一件事:有没有 erase。

30~60 分钟:P1104 与 P1068。两道都是结构体排序,重点在关键字的顺序和兜底。

60~85 分钟:P1923 或 P1217 选一道做透。前者考 nth_element 与快读,后者考枚举 + 两个判定函数。

85~90 分钟:不写新代码,只核对数组大小、i*i 溢出、0/1 起点、行末空格。

提交前的 30 秒检查清单(排序题专用)

cmp 的三个分支都用 != 判「不相等」了吗?最后一层是不是一定分出胜负?

数组区间是 [0, n) 还是 [1, n]?尾后指针有没有差一位?

结构体的字段数量够不够?排序要用到的信息是否都在结构体里?

答案需要 long long 吗?总分、平方、乘积先按最坏情况估一遍。

输出格式:行末有没有多余空格?换行够不够?

本次训练 · 共 5 题

做题方式

限时 100 分钟。每题先自己写 20~25 分钟;写不出来先看提示,再给自己 10 分钟;还不行才点开思路。每题都独立限时,不要一题耗死。

大题 挑战题 1 · 明明的随机数
洛谷 P1059普及-去洛谷做这题 ↗

明明生成了 n 个 1 到 1000 之间的随机整数,其中重复的数只保留一个,剩下的数从小到大排好序。

输入格式:第一行一个正整数 n(1 ≤ n ≤ 100);第二行 n 个用空格隔开的整数,每个在 1 到 1000 之间。

输出格式:第一行一个整数 M,表示不同的随机数个数;第二行 M 个用空格隔开的整数,为从小到大排好序的不相同的随机数。

引导性提示(先只看这里)

值域只有 1000,第 6 节的桶计数也能做;但这道题要求用「排序 + 去重」,把第 13 节的三行写熟。

去重前必须先有序,否则只能去掉相邻的重复。

unique 只搬元素、不改长度,想一想怎么把后面多余的部分真正删掉。

想看思路了?点开完整思路

sort:把相同数字挤到一起,为 unique 创造条件。

unique:把每段连续重复的第一个留在原地,返回去重后区间的新末尾。

erase:删掉 [新末尾, 原末尾) 这段垃圾,size() 才等于真实答案。

先输出 size,再按下标输出。复杂度 O(n log n + n),n ≤ 100 瞬间过。

同理可推广:若值域是 10⁹ 而 n 只有 10⁵,排序去重后配合二分就是离散化。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;
    vector<int> a(n);
    for (int i = 0; i < n; ++i) cin >> a[i];

    sort(a.begin(), a.end());                      // 先让相同元素相邻
    a.erase(unique(a.begin(), a.end()), a.end());   // 再去重并真正缩短

    cout << a.size() << "\n";
    int m = (int)a.size();                          // size() 是无符号数,先转 int
    for (int i = 0; i < m; ++i)
        cout << a[i] << (i + 1 == m ? '\n' : ' ');  // 行末只换行,不加空格
    return 0;
}
关键片段拆解
片段作用
sort(a.begin(), a.end());unique 只合并相邻重复,所以必须先把相同值排到一起。
unique(a.begin(), a.end())把不重复的元素依次搬到前面,返回去重后区间的新末尾;此时容器长度没变。
a.erase(..., a.end());把 [新末尾, 原末尾) 这一段删掉,size() 才等于答案。
int m = (int)a.size();size() 返回无符号数,直接与 int 比较会出现符号/无符号警告。
(i + 1 == m ? '\n' : ' ')最后一个数后面只换行,避免严格评测判行末空格错。
这题最容易错的地方
  • 只写 unique 不写 erase,再按原长度遍历,输出里混进旧值 → WA。
  • 忘了 sort 就 unique,只去掉相邻重复,输出 1 2 1 这种错答案。
  • 第二行行末多打一个空格,个别评测机会判格式错。
  • 把去重后的长度写成 n(原长度),第一行就错。
大题 挑战题 2 · 生日
洛谷 P1104普及-去洛谷做这题 ↗

给出 n 个同学的姓名与出生年、月、日,按年龄从大到小输出姓名,一行一个。

年龄大的排在前面,也就是出生日期早的排在前面。

如果两个同学生日完全相同,输入的靠后的同学先输出。

输入格式:第一行一个整数 n;接下来 n 行,每行一个不含空格的姓名和三个整数,表示出生年、月、日。

输出格式:共 n 行,每行一个姓名。

引导性提示(先只看这里)

比较两个生日,先比年、再比月、再比日,这个顺序就是 cmp 里 if 的顺序。

「输入靠后先输出」说明结构体里除了生日,还得额外记一个输入下标。

如果 cmp 只写两层,生日相同的两个人谁先谁后就不确定,答案会错。

想看思路了?点开完整思路

结构体存 name、y、m、d、id,id 是读入顺序(从 0 开始)。

cmp 四层:年小在前 → 月小在前 → 日小在前 → id 大在前。

前三层用「出生早 = 年龄大」;第四层用「id 大 = 输入靠后」。

id 互不相同,所以第四层保证任意两人一定分出胜负,严格弱序成立。

sort 后按顺序输出姓名。交换的是整个结构体,姓名跟着生日一起走,不会错配。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

struct Node {
    string name;
    int y, m, d, id;     // id:读入顺序,从 0 开始
};

bool cmp(const Node &a, const Node &b) {
    if (a.y != b.y) return a.y < b.y;   // 出生早的年龄大
    if (a.m != b.m) return a.m < b.m;
    if (a.d != b.d) return a.d < b.d;
    return a.id > b.id;                 // 生日相同:输入靠后的先输出
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;
    vector<Node> v(n);
    for (int i = 0; i < n; ++i) {
        cin >> v[i].name >> v[i].y >> v[i].m >> v[i].d;
        v[i].id = i;
    }

    sort(v.begin(), v.end(), cmp);

    for (const auto &x : v) cout << x.name << "\n";
    return 0;
}
关键片段拆解
片段作用
struct Node { string name; ... };把一个人的姓名与生日打包。sort 交换的是整个结构体,姓名不会和生日错配。
bool cmp(const Node &a, const Node &b)const 引用:结构体里有 string,按值传参会每次比较都复制字符串。
if (a.y != b.y) return a.y < b.y;年份小 = 出生早 = 年龄大 = 排前面。
return a.id > b.id;生日完全相同时,id 大的(输入靠后)排前面。
sort(v.begin(), v.end(), cmp);整体排序,O(n log n);n 不大,主要成本在读入。
for (const auto &x : v)范围 for + 常引用,不拷贝结构体;只读时加 const 更安全。
这题最容易错的地方
  • cmp 只写到年份就 return,生日相同的两人顺序随机 → WA。
  • 把 id 的兜底方向写反(写成 a.id < b.id),同生日的人顺序颠倒。
  • 把出生年份当「越大越老」,方向反了,整份输出倒序。
  • cmp 里用 >=,严格弱序被破坏,可能 RE。
大题 挑战题 3 · 分数线划定
洛谷 P1068普及-去洛谷做这题 ↗

n 名选手报名,计划录取 m 名志愿者。面试分数线定为排名第 ⌊m × 1.5⌋ 名选手的分数(成绩高者排前,同分报名号小者排前)。

所有成绩不低于面试分数线的选手都能进入面试。

输入格式:第一行两个整数 n、m;接下来 n 行,每行两个整数,表示报名号与笔试成绩。

输出格式:第一行输出面试分数线与实际进入面试的人数;接下来每行一个进入面试的选手的报名号和成绩,按成绩从高到低输出,成绩相同按报名号从小到大输出。

引导性提示(先只看这里)

先按「成绩降序、报名号升序」排好,讨论第几名才有意义。

⌊1.5m⌋ 用整数算:m * 3 / 2,不要用 double。

分数线之上可能有并列,实际人数要重新数一遍,不能直接输出 ⌊1.5m⌋。

想看思路了?点开完整思路

读入 + 排序:cmp 两个关键字,成绩降序、报名号升序。

need = ⌊1.5m⌋,再用 min(n, need) 夹一下——m 接近 n 时 1.5m 会超过总人数。

分数线 line = v[need-1].sc:下标从 0 开始,第 need 名就是 v[need-1]。

实际人数 cnt = 从前往后数第一个「成绩 < line」的位置,这自动把并列的人全算进来。

先输出 line 与 cnt,再输出前 cnt 个人的报名号与成绩。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

struct P {
    int id, sc;
};

bool cmp(const P &a, const P &b) {
    if (a.sc != b.sc) return a.sc > b.sc;   // 成绩高的在前
    return a.id < b.id;                     // 同分报名号小的在前
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;
    vector<P> v(n);
    for (int i = 0; i < n; ++i) cin >> v[i].id >> v[i].sc;

    sort(v.begin(), v.end(), cmp);

    int need = m * 3 / 2;            // ⌊1.5m⌋,整数运算没有精度问题
    if (need > n) need = n;          // 1.5m 可能超过总人数
    int line = v[need - 1].sc;       // 第 need 名的分数就是分数线

    int cnt = 0;
    while (cnt < n && v[cnt].sc >= line) ++cnt;   // 并列的全进,重新数

    cout << line << " " << cnt << "\n";
    for (int i = 0; i < cnt; ++i)
        cout << v[i].id << " " << v[i].sc << "\n";
    return 0;
}
关键片段拆解
片段作用
int need = m * 3 / 2;整数除法向下取整,正好等于 ⌊1.5m⌋;(int)(1.5*m) 可能因浮点误差少 1。
if (need > n) need = n;m 接近 n 时 1.5m 超过总人数,不夹住就数组越界(RE)。
int line = v[need - 1].sc;下标 0 起,第 need 名是 v[need-1];写成 v[need] 分数线差一名。
while (cnt < n && v[cnt].sc >= line)「不低于」用 >=;并列的人全部进入,人数不一定等于 need。
cout << line << " " << cnt << "\n";先分数线和人数,同行用空格分隔。
这题最容易错的地方
  • 分数线取成 v[need] ,整份输出全错。
  • 实际人数直接输出 need,忽略并列 → 人数与名单条数不一致,WA。
  • cmp 写成 a.sc >= b.sc,严格弱序被破坏,可能 RE。
  • 忘了把 need 夹到 n,n 较小时数组越界,表现为 RE 而不是 WA。
大题 挑战题 4 · 求第 k 小的数
洛谷 P1923普及/提高-去洛谷做这题 ↗

给出 n 个整数,求其中第 k 小的数,最小的数算第 0 小。

输入格式:第一行两个整数 n、k(n < 5×10⁶,0 ≤ k < n);接下来 n 个整数,可能跨多行。

输出格式:一行一个整数,即第 k 小的数。

引导性提示(先只看这里)

只求一个位置,不需要把全部数据排好。想一想第 13 节为此准备的函数。

题目的 k 从 0 开始:k = 0 就是最小值。

n 到 5×10⁶,cin 读入偏慢,试试用 getchar 自己写一个读入函数。

想看思路了?点开完整思路

nth_element(a, a+k, a+n) 执行完后 a[k] 就是第 k 小(0 起),平均 O(n)。

它的原理是快速选择:按基准把区间分成两半,只继续处理包含 k 的那一半,所以是 O(n) 而不是 O(n log n)。

左侧元素都不大于 a[k],右侧都不小于 a[k],但两侧都没有排好序。

5×10⁶ 个 int 约 20MB,必须放全局数组:栈空间只有几 MB,放 main 里直接栈溢出 RE。

手写快读逐字符解析,比 cin 快 2~3 倍,也顺手解决「数据跨行」的问题。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 5000000;
int a[MAXN + 5];          // 全局数组:静态区,不受栈大小限制

int readInt() {           // 手写快读,返回一个 int
    int x = 0, f = 1;
    char c = getchar();
    while (c < '0' || c > '9') {      // 跳过空格、换行等
        if (c == '-') f = -1;
        c = getchar();
    }
    while (c >= '0' && c <= '9') {
        x = x * 10 + (c - '0');
        c = getchar();
    }
    return x * f;
}

int main() {
    int n = readInt(), k = readInt();
    for (int i = 0; i < n; ++i) a[i] = readInt();

    nth_element(a, a + k, a + n);     // 执行后 a[k] 就是第 k 小
    printf("%d\n", a[k]);
    return 0;
}
关键片段拆解
片段作用
int a[MAXN + 5];全局数组在静态区;5×10⁶ 个 int 约 20MB,放 main 里会栈溢出。
int readInt()返回 int 的函数:把「读一个整数」这件事打包,主函数只剩三行调用。
while (c < '0' || c > '9')跳过所有非数字字符,所以数据跨行、多空格都能正确读。
x = x * 10 + (c - '0');逐位拼数:每读一位就把已有结果左移一位再加上新数字。
nth_element(a, a + k, a + n);第 k 小放到 a[k],左边不大于它、右边不小于它,两侧无序。
printf("%d\n", a[k]);直接输出 a[k];不必再 sort,排了也只会更慢。
这题最容易错的地方
  • 把 k 当成 1 起,输出 a[k-1] → 全 WA。
  • 把数组开在 main 里 → 栈溢出,本地小数据正常、大数据 RE。
  • 用 sort 交:5×10⁶ 规模下 nlogn 约 1.1×10⁸ 次比较,容易 TLE。
  • 数组少开一个 0(开成 5×10⁵)→ 越界,小数据看不出来。
大题 挑战题 5 · 回文质数
洛谷 P1217普及-去洛谷做这题 ↗

回文数是正着读与反着读都一样的数,151 既是回文数又是质数,称为回文质数。

求区间 [a, b] 内所有回文质数,每行输出一个,从小到大。

输入格式:一行两个整数 a、b(5 ≤ a < b ≤ 10⁸)。

输出格式:若干行,每行一个回文质数。

引导性提示(先只看这里)

先写两个小函数:判质数、判回文。主函数只负责枚举和调用,逻辑立刻变短。

判质数只需试除到 √x,不必试到 x。

10⁸ 全枚举太慢。注意偶数位数的回文数有一个固定因数,想想是几。

想看思路了?点开完整思路

偶数位回文数一定能被 11 整除:以 abba 为例,1001a + 110b = 11(91a + 10b)。

所以除 11 本身以外,偶数位回文数全是合数。答案都落在 1~7 位数里。

把 b 截断到 9999999,枚举量从 10⁸ 降到 10⁷。

枚举 i ∈ [a, b]:先判回文(几次除法,极快),是回文再判质数(试除到 √i)。

只有回文才做质数判定,实际质数判定约 1.1 万次,总耗时约 0.1 秒。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

bool isPrime(int x) {
    if (x < 2) return false;                        // 0、1 不是质数
    for (int i = 2; (long long)i * i <= x; ++i)     // 只试到 √x
        if (x % i == 0) return false;
    return true;
}

bool isPal(int x) {
    int t = x, r = 0;
    while (t > 0) { r = r * 10 + t % 10; t /= 10; } // 把 x 倒过来
    return r == x;                                  // 用临时变量,不改原数
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int a, b;
    cin >> a >> b;
    if (b > 9999999) b = 9999999;   // 8 位及以上回文数都是 11 的倍数

    for (int i = a; i <= b; ++i)
        if (isPal(i) && isPrime(i)) cout << i << "\n";
    return 0;
}
关键片段拆解
片段作用
if (b > 9999999) b = 9999999;偶数位回文数都能被 11 整除,截断后不会漏解,枚举量少一个数量级。
bool isPrime(int x)判定函数:返回 bool,调用处 if (isPrime(i)) 读起来就是一句话。
(long long)i * i <= xi*i 在 i 接近 10⁵ 时会接近 10¹⁰,用 int 直接溢出成负数 → 死循环。
int t = x, r = 0;用临时变量倒序,保持 x 不变;若直接改 x,后面的质数判定就拿到错的值。
if (isPal(i) && isPrime(i))短路求值:先判回文(便宜),只有回文才做质数判定。
这题最容易错的地方
  • 把 1 当质数(别的题 a 可能从 1 开始)。
  • 循环条件写成 i < x 而不是 i*i <= x,10⁷ 级别直接 TLE。
  • 不截断 b,枚举到 10⁸ → TLE。
  • isPal 里直接改 x,导致 isPrime 拿到的不是原数 → 全错。
做完之后自问
  • 不看资料能写出 P1093 的完整代码,并说清三行 cmp 各自在比什么。
  • 能手写 sort 的两参数版与三参数版,知道数组和 vector 的区间边界分别怎么写。
  • 能说出严格弱序的三条要求,并举一个「用 >= 会 RE」的例子。
  • 能不查资料写出 sort + unique + erase 的去重三行,并解释为什么必须先排序。
  • 能说清 nth_element 之后 a[k] 两侧是什么性质、不是什么性质。
  • P1068 里 ⌊1.5m⌋ 为什么用 m * 3 / 2 而不是 m * 1.5,能讲清浮点误差怎么变成 WA。
  • P1217 里为什么能把 b 截断到 9999999,能自己举一个 4 位回文数验证它是 11 的倍数。
延伸阅读
  • 《算法竞赛入门笔记》:函数、结构体与排序相关章节
  • 《算法竞赛进阶指南》第 0x05 节:排序(cmp 与严格弱序)
  • 洛谷 P1093、P1104、P1068 题解区关于多关键字排序的讨论
  • 《算法竞赛黄金法则》:先把比较规则写清楚,再动手排序
16 贪心入门:交换论证与排序贪心普及-

16 · 贪心入门:交换论证与排序贪心

把“能不能贪”变成一张可检验的清单:三信号三反信号;再用交换论证证明“接水问题按接水时间升序”唯一最优,并把贡献法 Σ T_i × (n − i) 写成三行代码。
⭐ 已学范围普及-预计 60 分钟贪心交换论证排序
本节出现的新函数 / 新语法(先看懂再用)

贪心只有一句话:每一步都选当前看起来最好的,并且永不回头。难的不是“选最好的”,而是判断“这样选会不会毁掉全局”。本节把它变成一张 6 行的检查表,再用交换论证把“排序贪心”钉死——改一行 cmp 就换了一个算法,所以那一行必须有证明撑着。

贪心不是灵感,是“先猜结论,再交换证明”。没有证明之前,你的贪心只是一个等着被 hack 的猜测。

先看判断清单。它不能保证贪心一定对,但能让你在 5 分钟内决定:继续想贪心,还是立刻换成 DP / 搜索。

判定信号 / 反信号它意味着什么本节的例子
可以贪局部选择可量化每一步的收益或代价都能写成确定的数字接水:某人的等待时间 = 他前面所有人接水时间之和
可以贪交换不变差交换两个相邻决策,答案不会更好 → 存在一个最优解,第一步就选了局部最优T 大的排前面,交换后总等待减少 T_a − T_b
可以贪剩余问题同构选完之后,剩下的还是同一道题,只是规模变小接完一个人,剩下 n − 1 人排同一支队
不能贪挤占未来资源当前最优吃掉的资源,无法用当前收益定价0/1 背包按性价比拿,剩下的空间装不下下一件
不能贪需要反悔已经丢掉的选项后面可能想拿回来合并果子要“撤销”贪心选择,得靠堆(下一节)
不能贪n ≤ 4 就有反例手算小数据就能推翻它面值 1、3、4 凑 6:贪心 3 枚,最优 2 枚

上表里的两个反例值得记一辈子,因为它们把“贪心为什么会错”说透了。硬币:面值 1、3、4,凑 6。贪心先拿最大的 4,剩 2 只能 1 + 1,共 3 枚;最优是 3 + 3,两枚。0/1 背包:容量 20。A 件重 10 值 60(性价比 6),B 件重 20 值 100(性价比 5)。按性价比先拿 A,剩 10 装不下 B,得 60;最优是只拿 B,得 100。两者的共同结构:局部最优消耗了一份“未来资源”,而这份资源的价格没有被算进当前收益。

接水问题为什么没有这个毛病?每个人只占 1 个位置,位置总数固定为 n,谁占都不改变“后面还有几个人要等”。资源不被消耗,只是被排序,所以排序就能解决。这一条判据后面会反复出现:如果“选择”只是在重排一个定长的序列,贪心往往成立;如果“选择”会减少未来可用的资源,贪心往往不成立。

本节没有新库函数,但有一个必须吃透的东西:cmp。在排序贪心里,cmp 就是算法本身。

新函数拆解 · cmp(自定义比较函数)—— 贪心策略写在哪一行
bool cmp(const T &a, const T &b); // 返回 true 表示 a 应排在 b 前面
组成含义 / 格式要求
返回类型 bool只回答一个是非题:a 该不该排在 b 前面。它表达的是“优先级”,不是“大小”。
参数 const T &按引用传,避免结构体拷贝;const 表示比较过程不修改数据。写值传递也能过,但慢一倍。
严格弱序cmp(a, a) 必须返回 false,且 a、b 不能同时返回 true。写成 <= 会破坏这一点,std::sort 可能越界写内存,症状是 RE 或随机 WA。
平手怎么办主键相等时必须给确定的次键(如编号)。否则相同数据的输出顺序不稳定,本机过、交上去 WA。
和贪心的关系贪心的“选谁”在代码里就是这一行不等式。改贪心策略 = 改这一行,然后重新证明。
方向记法想让谁先出现,就 return 谁的键 更小。想反过来,就把 < 换成 >——“总等待最小”和“总等待最大”只差这一个符号。
最小可运行示例
示例
struct Node { int t, id; } a[1005];

bool cmp(const Node &x, const Node &y) {
    if (x.t != y.t) return x.t < y.t;   // 主键:接水时间短的优先
    return x.id < y.id;                 // 次键:时间相同按编号,输出稳定
}

sort(a + 1, a + n + 1, cmp);            // 排序只做一件事:把 cmp 认为"优先"的放前面
注意

cmp 返回 true 的意思是“a 排在 b 前面”,不是“a 小于 b”。在贪心里这两句话经常方向相反,比如“重的优先”就该写 a.w > b.w。

比较函数里不要写自增、输入输出这类有副作用的代码:std::sort 会调用它 O(n log n) 次。

现在用一道最经典的题把上面的东西落地:排队接水。它的搜索空间大到不可能枚举,但它的结构简单到只需要一次排序。

例题 · 排队接水洛谷 P1223普及-去洛谷做这题 ↗

有 n 个人在一个水龙头前排队接水,第 i 个人接水需要 T_i 个单位时间。一个人的等待时间等于排在他前面的所有人接水时间之和,不包括他自己接水的时间。

请给出一种排队顺序,使 n 个人的平均等待时间最小;若两人接水时间相同,编号更小的人排在前面。

输入格式:第一行一个整数 n;第二行 n 个整数 T_1 … T_n。

输出格式:第一行输出排队顺序(每人的原始编号,空格分隔),第二行为最小的平均等待时间,保留两位小数。

数据范围:1 ≤ n ≤ 1000,1 ≤ T_i ≤ 10⁶,T_i 可能重复。

输入: 10 56 12 1 99 1000 234 33 55 99 812 输出: 3 2 7 8 1 4 9 6 10 5 291.90
三步:数清搜索空间 → 改写成贡献 → 交换论证定顺序

第 1 步:数清搜索空间。n 个人的顺序有 n! 种,n = 11 时已经 4×10⁷,n = 1000 完全不可能枚举。必须找到一个只依赖“谁在谁前面”的量,而不是去搜索顺序。

第 2 步:把“等待”改写成“贡献”。看排在位置 i(从 1 数)的那个人:他接水用掉 T,他后面还有 n − i 个人,每个人都要白白等这 T。所以这一个人对总等待时间的贡献是 T × (n − i),总等待 = Σ T_i × (n − i)。

这一步做完,题目已经不是“排队题”了:n 是常数,系数 (n−1, n−2, …, 1, 0) 是一列从大到小排好的数,只剩一个问题——怎么把 T 这一列数配到这列系数上,让乘积和最小。

第 3 步:交换论证。假设相邻两人 a 在前、b 在后,且 T_a > T_b。只算这两个人的贡献:交换前 = (n−i)·T_a + (n−i−1)·T_b,交换后 = (n−i)·T_b + (n−i−1)·T_a,相减 = T_a − T_b > 0。也就是说:只要出现“大的在前、小的在后”的相邻对,交换一定让答案变小。任何非升序排列都至少含一个这样的相邻对,而升序排列一个都没有,所以升序就是最优。

证明完毕,代码只剩两件事:排序,然后按贡献法累加。注意“最优”只对等待时间的数值成立,T 相同时顺序可以互换,所以题面才要求“时间相同则编号小的在前”——这正是 cmp 的次键。

贪心部分只有 3 行
sort(a + 1, a + n + 1, cmp);                              // 贪心的全部内容:按 T 升序
long long sum = 0;
for (int i = 1; i <= n; ++i) sum += 1LL * a[i].t * (n - i);   // 贡献法

下面是完整程序。n 只有 1000,但用 sort 已经是习惯:同样的写法换到 n = 10⁶ 也不用改。

完整可编译代码(C++17)
#include <bits/stdc++.h>
using namespace std;

const int N = 1005;
int n;
struct Node {
    int t;      // 接水用时
    int id;     // 原始编号:排序会打乱位置,编号必须跟着走
} a[N];

// 贪心策略:接水时间短的先上
bool cmp(const Node &x, const Node &y) {
    if (x.t != y.t) return x.t < y.t;   // 主键:时间升序
    return x.id < y.id;                 // 次键:时间相同,编号小的在前
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    for (int i = 1; i <= n; ++i) {
        cin >> a[i].t;
        a[i].id = i;                    // 记住它原本是第几个人
    }
    sort(a + 1, a + n + 1, cmp);        // O(n log n),n = 1000 时约 1 万次比较

    long long sum = 0;                  // 总等待时间可达 5*10^11,int 装不下
    for (int i = 1; i <= n; ++i) {
        sum += 1LL * a[i].t * (n - i);  // 第 i 位的人被后面 n-i 人各等一次
        if (i > 1) cout << ' ';         // 分隔符:第一个数前面不打空格
        cout << a[i].id;
    }
    cout << '\n';
    cout << fixed << setprecision(2) << 1.0 * sum / n << '\n';
    return 0;
}
逐行拆解
代码片段含义与理由
struct Node { int t; int id; } a[N];把“接水时间”和“原始编号”绑在一起。排序会打乱数组下标,编号必须跟着时间一起搬,否则最后无法输出“原来第几个人”。
bool cmp(const Node &x, const Node &y)只读比较,参数用 const 引用。sort 会调用 O(n log n) 次,值传递每次多拷 8 字节。
if (x.t != y.t) return x.t < y.t;主键。这一行就是整个贪心算法,改一个符号就变成“求最大等待时间”。
return x.id < y.id;次键。P1223 题面明写“时间相同则编号小的在前”,不写这一行会在重复数据上 WA。
sort(a + 1, a + n + 1, cmp);对 a[1..n] 排序。sort 的区间左闭右开,所以右端点写 a + n + 1,不是 a + n。
long long sum = 0;n = 1000、T = 10⁶ 时总等待约 T × n(n−1)/2 ≈ 5×10¹¹,超过 int 的 2.1×10⁹。凡是“求和”先估量级,这是第 2 节就立的规矩。
sum += 1LL * a[i].t * (n - i);贡献法一行。先乘 1LL 把运算提升到 64 位,避免 int × int 先溢出再赋给 long long。
if (i > 1) cout << ' ';把空格放在数的前面而不是后面,行末就不会多出空格,严格比对的评测也不会挂。
cout << fixed << setprecision(2) << 1.0 * sum / n;题目要两位小数。先乘 1.0 变成浮点除法,否则整数除法把 291.90 截成 291。
落地模板:排序贪心的四步

① 找“顺序决定代价”的那一项。本题是等待时间:它由“前面有几个人、各用多久”决定。如果代价和顺序无关,这题不需要贪心,直接算。

② 把总代价写成 Σ(值 × 系数),系数只跟位置有关。本题 Σ T_i × (n − i)。写不出这个式子,说明还没看懂题目。

③ 用交换论证确定排序键。设相邻两项为 a、b,算出“交换前 − 交换后”的差,看它在什么条件下 > 0。差恒正的那一边就是正确答案。

④ 排序 + 一遍扫描。到这步已经没有思维含量,剩下的全是细节:编号、次键、long long、小数位。

四步里 ①② 是数学,③ 是证明,④ 才是代码。学生最常见的失败方式是从 ④ 开始写,然后对着 WA 猜排序键——这等于用提交记录做实验,非常浪费生命。

这一节的 6 个坑,每个都有具体症状

总等待时间用 int 存:n = 1000、T = 10⁶ 时和可达 5×10¹¹,溢出成负数 → WA,而且小样例全过,只有大数据错。

系数方向写反(写成 Σ T_i × i):等价于降序排序 → WA,样例都过不了,是最好发现的一种错。

cmp 写成 return x.t <= y.t;:违反严格弱序,std::sort 可能越界写内存 → RE(段错误)或答案随机。平手必须用次键区分,绝不能用 <=。

忘了记原始编号,直接输出排序后的位置:数值对、顺序错 → WA。

平均时间写成 sum / n:整数除法丢掉小数,291.90 变 291 → WA。必须 1.0 * sum / n 或 (double)sum / n。

行末多一个空格:部分严格比对(NOIP 系)会判 WA。用 if (i > 1) cout << ' '; 控制。

自己动手复现

关掉这一页,把上面那份程序从零敲一遍,交到 P1223,确认输出正好是 291.90。

改一改①:把贡献系数 (n − i) 换成 (n − i + 1),也就是“等待时间包含自己接水的时间”(完成时间之和)。先预测答案会不会变、变成多少,再跑一遍验证。

改一改②:把所有 T_i 都加 1,答案会多多少?提示:多出来的部分是 Σ 1 × (n − i) = n(n−1)/2,与顺序无关。自己算一遍 n = 10 时是 45,再乘到平均值上验证。

数学补给 · 排序不等式:为什么“大配小”一定最小

设 a₁ ≤ a₂ ≤ … ≤ a_n,b₁ ≤ b₂ ≤ … ≤ b_n,σ 是 1..n 的任意排列。排序不等式说:

Σ a_i · b_{n+1−i} ≤ Σ a_i · b_{σ(i)} ≤ Σ a_i · b_i。左边是“逆序和”(大配小),最小;右边是“同序和”(大配大),最大。

证明只用一次相邻交换。取两个位置,现在配的是 (a_j, b_k) 与 (a_k, b_j),其中 j < k。交换成 (a_j, b_j)、(a_k, b_k) 后,两者之差 = (a_j b_j + a_k b_k) − (a_j b_k + a_k b_j) = (a_k − a_j)(b_k − b_j) ≥ 0。所以“小 a 配大 b”的组合一旦存在,交换成同序一定不会变小——反过来读,逆序和最小。

回看接水问题:总等待 = Σ T_i × (n − i)。系数 (n−1, n−2, …, 0) 是一列已经降序排好的数,T 是另一列。要让乘积和最小,就必须让两列的排序方向相反:T 升序 ↔ 系数降序。这就是“按 T 升序排队”的全部数学。

用样例验一遍:T 升序为 1, 12, 33, 55, 56, 99, 99, 234, 812, 1000,系数 9, 8, …, 0。乘积和 = 9 + 96 + 231 + 330 + 280 + 396 + 297 + 468 + 812 + 0 = 2919,平均 291.90。

推论:凡是“总代价 = Σ(某个值 × 某个由位置决定的系数)”的题,答案几乎都是排序。同模型的还有“n 个任务,第 i 个耗时 t_i,求最小总完成时间”——同一个式子,同一份证明。看到这种结构,先写贡献式,再套排序不等式。

举一反三 · 想清楚这些再往下走
  1. 如果每个人接水前还有一段与顺序无关的准备时间 s_i(准备时水龙头也被占着),排序键应该改成 T_i 还是 T_i + s_i?用贡献式 Σ (T_i + s_i)(n − i) 检验你的直觉。
  2. 如果改成两个水龙头(两台机器),让所有人接完的总时间最短,按 T 升序贪心还对吗?先试 4 个任务、2 台机器,看能不能造出反例。这说明“多加一个资源”为什么会把问题变难。
  3. 如果目标是让总等待时间最大,代码要改哪一行?交换论证里的差值会变成什么符号?
  4. 如果目标是让最后一个人尽早接完水(总时长的最短化),任何顺序的结果都一样吗?为什么?这个现象对应判断清单里的哪一条?
  5. 硬币面值是 1、5、10、25 时,贪心为什么一定对?面值换成 1、3、4 就错——请说清“贪心正确”依赖的到底是题目的什么性质,而不是题目的名字。
  6. 给定一道新题,你怎么在 2 分钟内判断该不该贪?把你实际会问自己的 3 个问题写下来。
自测:能全部回答再进下一节
  • 能背出三信号三反信号,并对一道新题给出“能贪 / 不能贪”的判断和理由。
  • 能写出交换论证的差值 T_a − T_b,并解释为什么它 > 0 就说明升序最优。
  • 能不看题解推出 P1223 的贡献式 Σ T_i × (n − i),并手算样例得到 291.90。
  • 知道 cmp 必须满足严格弱序,能说出违反时会出现什么症状(RE / 随机 WA)。
  • 能在读完题后 30 秒内估出答案量级,并决定用 int 还是 long long。
挑战题 · 删数问题
洛谷 P1106普及-去洛谷做这题 ↗

给定一个不超过 250 位的正整数 n 和一个整数 k,删去其中任意 k 个数字,剩下的数字按原左右次序拼成一个新的非负整数。要求剩下的数最小。

输入格式:第一行一个正整数 n(不超过 250 位,必须按字符串读入);第二行一个整数 k(1 ≤ k < n 的位数)。

输出格式:一行,剩下最小的数。前导零不输出;若结果全为 0,输出 0。

引导性提示(先只看这里)

先只想“只删 1 位”怎么做:从最高位开始看,删哪一位能让结果变小?

删 1 位时,从前往后找第一个比后一位大的数字删掉它;如果整个串单调不降,就删最后一位。

删 k 位就是把这个动作重复 k 次。别忘最后要处理前导零。

想看思路了?点开完整思路

关键观察(交换论证)。设相邻两位 a、b 满足 a > b,前缀相同,删 a 得到的数是 …b…,删 b 得到的数是 …a…,第一个不同的就是这一位,而 b < a,所以删 a 一定更小。于是“删第一个下降点”是唯一正确的单步操作。

若整个串单调不降,删任何非末位都会让那一位被更大的数顶替(变大),只有删末位不会。

重复 k 次即可。每轮 O(n),总 O(k·n) ≤ 250 × 250 = 62500 次操作,远低于时限。

最后去掉前导零,但至少保留一位数字:n = 100、k = 1 时结果是 0,不能输出空行。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    string s;
    int k;
    cin >> s >> k;

    // 删 k 次,每次删掉"第一个比后一位大的数字"
    for (int step = 0; step < k; ++step) {
        int i = 0;
        // 找第一个下降点 s[i] > s[i+1];找不到时 i 会停在最后一位
        while (i + 1 < (int)s.size() && s[i] <= s[i + 1]) ++i;
        s.erase(s.begin() + i);     // 删掉它(单调不降时删的就是末位)
    }

    // 去前导零,但至少留一位
    int p = 0;
    while (p + 1 < (int)s.size() && s[p] == '0') ++p;

    cout << s.substr(p) << '\n';
    return 0;
}
关键片段拆解
片段作用
while (i + 1 < (int)s.size() && s[i] <= s[i + 1]) ++i;从前往后扫,停在第一个下降点。加 (int) 是防止 size() 的无符号数与 -1 比较时出错。找不到下降点时 i 停在末位。
s.erase(s.begin() + i);删第 i 位。前缀不变、后面整体左移一位,正好实现“删掉一个数字”。
for (int step = 0; step < k; ++step)删 k 次而不是一次。每轮都要重新从前往后找,因为删除后下降点的位置可能往前移。
while (p + 1 < (int)s.size() && s[p] == '0') ++p;去前导零。p + 1 < size 保证至少留一位,避免 100 删 1 位后输出空字符串。
cout << s.substr(p);从第一个非零位输出到尾。substr 是第 9 节学过的,这里正好用上。
这题最容易错的地方
  • 用 int / long long 读 n:250 位必然溢出,必须读成 string,否则连样例都过不了。
  • 只删一次就输出:忘了 k 可能大于 1,答案会明显偏大。
  • 忘记处理前导零:输入 1000、k = 1 时得到 000,正确输出是 0。
  • 删除后没有重新从前往后扫,用了旧的下标位置,结果少了或多了数字。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》第 0x07 节:贪心(邻项交换)
  • 《算法竞赛入门笔记》贪心章:排序不等式与排队问题
  • 洛谷 P1223、P1106 题解区的高赞证明
17 贪心进阶:区间问题与堆普及/提高-

17 · 贪心进阶:区间问题与堆

区间问题的四种排序键与两个证明;把“每步取当前最优”从排序升级到堆:用 priority_queue 与 greater 解决合并果子,用大根堆 + 反悔解决建筑抢修。
⭐ 已学范围普及/提高-预计 65 分钟区间调度优先队列哈夫曼
本节出现的新函数 / 新语法(先看懂再用)

上一节的贪心按键值排序,一次 sort 就结束。这一节有两个新形状:①贪心的对象是一段区间,排序键从“值”换成“端点”;②贪心要用到“当前最小”,而集合在过程中不断变化,sort 不够了,需要堆。

区间问题的排序键只有那么几个:左端点、右端点、长度、性价比。选错键的代价是整个算法错,所以要逐个排除。
问题排序键每一步怎么决定复杂度
区间调度(最多不重叠区间)右端点 r 升序当前 l ≥ 上一个 r 就选它O(n log n)
区间选点(最少点打中所有区间)右端点 r 升序当前 l > 最后一个点,就在 r 处补一个点O(n log n)
区间覆盖(最少区间盖满 [1, T])左端点 l 升序在能接上的区间里挑 r 最大的O(n log n)
分数背包(可切割)性价比 v/m 降序装得下就整堆拿,装不下就切一块O(n log n)
合并果子(哈夫曼)不排序,每次取最小的两个合并后把新堆放回集合O(n log n)

前四行还是“排一次序、扫一遍”。最后一行不一样:合并出的新堆要重新参与比较,集合是动态的,排一次序就废了。凡是“每步都要取当前最小/最大”的贪心,就用堆。

新函数拆解 · priority_queue(优先队列 / 堆)
priority_queue<int> q; // 默认大根堆 q.push(x); q.top(); q.pop(); q.size(); q.empty();
组成含义 / 格式要求
priority_queue<T>声明一个存放 T 的堆。默认是大根堆:top() 是最大值。
q.push(x)插入一个元素,O(log n)。堆自己维护顺序,不用再 sort。
q.top()读堆顶(当前最大/最小元素),O(1)。只读不删。
q.pop()删除堆顶,O(log n)。它不返回被删的值,想取值必须先 top()。
q.size() / q.empty()元素个数 / 是否为空。调用 top()、pop() 前必须保证非空。
最小可运行示例
示例
priority_queue<int> q;                 // 大根堆
for (int x : {3, 1, 4, 1, 5}) q.push(x);

while (!q.empty()) {
    cout << q.top() << ' ';            // 5 4 3 1 1
    q.pop();                           // 想取值就先 top() 再 pop()
}
注意

cout << q.pop() 直接编译错误:pop() 的返回类型是 void。

堆里元素的类型必须可比较:int、pair 可以,struct 必须重载 operator<。

新函数拆解 · greater<T>:把默认大根堆翻成小根堆
priority_queue<int, vector<int>, greater<int>> q; // 小根堆,top() 是最小值
组成含义 / 格式要求
三个模板参数①元素类型 int ②底层容器 vector<int> ③比较器 greater<int>。三个都要写,少一个就编译报错。
greater<int>来自 <functional>(bits 里已包含)。它表示“大的排在后面”,于是小的浮到堆顶。
为什么不能只写两个第二个参数位置固定是容器,把比较器塞进去会报一大串模板错误,错误信息往往指向 <bits/stl_queue.h>。
等价写法把 -x 存进大根堆再取负;只适合 long long(int 的 -x 会溢出)。
最小可运行示例
示例
priority_queue<int, vector<int>, greater<int>> q;   // 小根堆
for (int x : {3, 1, 4, 1, 5}) q.push(x);
while (!q.empty()) { cout << q.top() << ' '; q.pop(); }   // 1 1 3 4 5
注意

口诀:默认大根堆,加 greater 变小根堆。自定义优先级时别硬塞 greater,用 operator< 更清楚。

新函数拆解 · 结构体重载 operator<(决定谁在堆顶)
struct Node { int w; bool operator<(const Node &o) const { return w > o.w; } };
组成含义 / 格式要求
为什么写在结构体里priority_queue 用 operator< 比较元素。写在结构体里,堆就能直接拿来用。
const Node &o按引用传,避免拷贝;必须 const,因为堆内部用常量引用比较。
末尾的 const表示比较不修改自身。漏写会编译失败,报错位置在模板深处,很难读。
return w > o.woperator< 返回 true 表示 this 的优先级更低(排在 o 后面)。写 w > o.w,于是 w 小的反而在堆顶。
与 sort 的 cmp 方向相反cmp 回答“a 是否在前”,operator< 回答“this 是否在后”。同一个不等式,两处要反过来写,这是本节最高频的错误。
最小可运行示例
示例
struct Task {
    int w;
    // 想让 w 小的在堆顶 -> 写 w > o.w(与 sort 的 cmp 反着写)
    bool operator<(const Task &o) const { return w > o.w; }
};

priority_queue<Task> q;
q.push({3}); q.push({1}); q.push({4});
cout << q.top().w;                // 1
注意

口诀:cmp 写“谁在前”,operator< 写“谁在后”。堆顶永远是“比较意义下最大”的元素。

例题 · 凌乱的 yyy / 线段覆盖洛谷 P1803普及-去洛谷做这题 ↗

有 n 场比赛,第 i 场从 a_i 时刻开始、b_i 时刻结束。同一时刻只能参加一场,但一场可以在结束的同一时刻立刻开始另一场。求最多能参加多少场。

输入格式:第一行一个整数 n;接下来 n 行,每行两个整数 a_i、b_i。

输出格式:一行一个整数,表示最多参加的场数。

数据范围:1 ≤ n ≤ 10⁶,0 ≤ a_i < b_i ≤ 10⁶。

输入: 3 0 2 2 4 1 3 输出: 2
三个候选排序键,先排除两个错的

候选 ①:按开始时间 a_i 升序。反例:三场 [0,100]、[1,2]、[3,4]。先选 [0,100],剩下两场都被它盖住,只能 1 场;最优是 [1,2] 和 [3,4],2 场。错因:开始早不代表占得少。

候选 ②:按区间长度 b_i − a_i 升序。反例:[0,6]、[6,10]、[0,10]。最短的 [6,10] 先选,另两场都接不上,只能 1 场;最优是 [0,6] + [6,10] 共 2 场。错因:短区间可能卡在中间,把两边都切断。

候选 ③:按结束时间 b_i 升序。这个对。设排序后第一个区间是 I₁ = [l₁, r₁],r₁ 是所有右端点里最小的。任取一个最优解,它的第一场是 J = [l_J, r_J],必有 r_J ≥ r₁。把 J 换成 I₁:最优解的第二场 K 满足 l_K ≥ r_J ≥ r₁,所以 I₁ 与 K 不冲突,场数不变。于是存在一个最优解,第一场就是 I₁——贪心的第一步是安全的。删掉与 I₁ 冲突的区间,剩下问题同构,归纳即可。

落点是一句话:越早结束,剩下的时间越多,所以它不吃未来的资源——命中上一节的“交换不变差”。

贪心主体的 4 行
sort(a + 1, a + n + 1, cmp);        // cmp:右端点升序
int ans = 0, last = 0;
for (int i = 1; i <= n; ++i)
    if (a[i].l >= last) { ++ans; last = a[i].r; }   // 接得上就选,选了才更新

n 到 10⁶,数组必须开全局(放静态区),并且关掉 cin 同步。

完整可编译代码(C++17)
#include <bits/stdc++.h>
using namespace std;

const int N = 1000005;
int n;
struct Seg { int l, r; } a[N];

// 唯一的贪心决策:按右端点升序
bool cmp(const Seg &x, const Seg &y) {
    if (x.r != y.r) return x.r < y.r;   // 主键:越早结束越优先
    return x.l < y.l;                   // 次键:保证严格弱序
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    for (int i = 1; i <= n; ++i) cin >> a[i].l >> a[i].r;
    sort(a + 1, a + n + 1, cmp);        // O(n log n),n = 10^6 也能过

    int ans = 0, last = 0;              // last = 上一场的结束时刻;时刻都 >= 0,初值 0 安全
    for (int i = 1; i <= n; ++i) {
        if (a[i].l >= last) {           // 接得上(等号不能少:t 结束、t 开始是允许的)
            ++ans;
            last = a[i].r;              // 只有真的选了才更新,这是贪心的"落子"
        }
    }
    cout << ans << '\n';
    return 0;
}
逐行拆解
代码片段含义与理由
struct Seg { int l, r; } a[N];把一场比赛存成区间。n 到 10⁶,数组开全局:局部数组放在栈上会直接爆栈(RE)。
if (x.r != y.r) return x.r < y.r;整个算法只有这一行在做决策。换成 x.l < y.l,得到的就是候选 ① 的错误算法。
int ans = 0, last = 0;last 是“已选区间中最晚的结束时刻”,初值 0 因为题目保证时刻非负,等价于“第一场随时能开始”。
if (a[i].l >= last)能接上就选。等号是关键:t 时刻结束的那场与 t 时刻开始的下一场不冲突。
last = a[i].r;选了才更新。更新的是 r 而不是 max(last, r):按 r 升序后 a[i].r 一定 ≥ last。
cout << ans;答案是场数,不是总时长、不是总覆盖长度。别把 ++ans 写成 ans += a[i].r - a[i].l。
同一套排序,换一个判断:区间选点

经典模型:给定 n 个闭区间,选最少的点,使每个区间至少包含一个点。排序键一模一样——右端点升序。

维护最后一个放下的点 last(初值 −∞)。按 r 升序扫描,若 last 没落在当前区间 [l, r] 里就必须再放一个点;放哪里?放右端点 r,它最靠右,能顺带覆盖更多后面的区间。判断条件可以简化成一行 l > last:按 r 升序保证 last ≤ r,所以只要 l ≤ last,就必有 last ∈ [l, r]。

核心循环(与上一题只差 2 行)
// 接在"按右端点排序"之后,替代上一题的判断
int cnt = 0;
long long last = -1;                // 还没有放点;坐标 >= 0,-1 相当于 -inf
for (int i = 1; i <= n; ++i) {
    if (a[i].l > last) {            // 上一个点没打中这个区间
        ++cnt;
        last = a[i].r;              // 在右端点补一个点,覆盖尽量多的后续区间
    }
}
// cnt 就是最少点数

两题并排看:调度题问“能不能接上”(l ≥ 上一场的 r),选点题问“有没有被点到”(l > 最后一个点)。“越靠右越不吃未来资源”这一句同时支撑两个算法。

换一个排序键:分数背包按性价比

n 堆物品(重量 m_i、价值 v_i)和容量 T,每堆可以只取一部分,价值按比例算,求最大价值。这是“能切割”的背包,贪心成立;0/1 背包不能切,贪心不成立(上一节的反例库)。

排序键:性价比 v_i / m_i 降序。容量可看成 T 个 1 单位的格子,每个格子都该填“每单位最贵”的东西,所以贵的先拿满。能整堆拿就整堆拿,装不下就把剩余容量按比例切一块然后结束。

分数背包:把排序键从端点换成比值
#include <bits/stdc++.h>
using namespace std;

const int N = 105;
int n;
long long cap;                          // 剩余容量
struct Item { long long m, v; } a[N];

// 性价比 v/m 降序:交叉相乘代替除法,避开浮点误差
bool cmpRatio(const Item &x, const Item &y) {
    return x.v * y.m > y.v * x.m;       // v, m <= 10^6 时乘积 <= 10^12,long long 安全
}

int main() {
    cin >> n >> cap;
    for (int i = 1; i <= n; ++i) cin >> a[i].m >> a[i].v;
    sort(a + 1, a + n + 1, cmpRatio);

    double ans = 0;
    for (int i = 1; i <= n && cap > 0; ++i) {
        if (a[i].m <= cap) {            // 整堆装得下,全拿
            cap -= a[i].m;
            ans += a[i].v;
        } else {                        // 只剩部分空间,按比例切一块
            ans += 1.0 * a[i].v * cap / a[i].m;
            cap = 0;
        }
    }
    cout << fixed << setprecision(2) << ans << '\n';
    return 0;
}

不要写 x.v / x.m > y.v / y.m:double 比较有误差,可能把相等判成不等,而且 m = 0 时会除零。

例题 · 合并果子洛谷 P1090普及/提高-去洛谷做这题 ↗

有 n 堆果子,第 i 堆有 a_i 个。每次可以把两堆合并成一堆,消耗的体力等于两堆果子数之和。经过 n − 1 次合并后只剩一堆,求消耗的体力总和的最小值。

输入格式:第一行一个整数 n;第二行 n 个整数 a_1 … a_n。

输出格式:一行一个整数,即最小体力消耗。

数据范围:1 ≤ n ≤ 10⁴,1 ≤ a_i ≤ 2×10⁴,答案保证小于 2³¹。

输入: 3 1 2 9 输出: 15
为什么要每次取最小的两堆

先看清代价的结构:一堆果子被合并 d 次,它的数量就被算进总代价 d 次。所以重的应该少被合并(离根浅),轻的应该多被合并(离根深)。把每次合并画成一棵二叉树,总代价 = Σ a_i × 深度_i。

交换论证:最优树里最轻的两堆一定在最深层且互为兄弟。若最轻的叶子不在最深层,把它与最深的叶子交换,小权值下移、大权值上移,WPL 只会变小,与最优性矛盾。所以先合并最轻的两堆不损失最优性,剩下问题同构,归纳即可。

实现上有个关键点:合并出的新堆必须重新参与比较。排一次序就固定的做法是错的(1、2、9 合并出 3 之后,3 比 9 小,下一轮该先碰它)。集合动态变化,所以用堆:每次 top 两次、pop 两次、push 一次,n − 1 轮,总 O(n log n)。这是本课程第一次出现“贪心 + 数据结构”。

完整可编译代码(C++17)
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;
    // 三个模板参数:元素类型、底层容器、比较器。greater 表示小的优先 -> 小根堆
    priority_queue<int, vector<int>, greater<int>> q;
    for (int i = 0; i < n; ++i) {
        int w;
        cin >> w;
        q.push(w);
    }

    long long cost = 0;                 // 本题保证答案 < 2^31,但用 long long 不亏
    while (q.size() > 1) {              // 合并 n-1 次,剩 1 堆时结束
        int x = q.top(); q.pop();       // 当前最轻的两堆
        int y = q.top(); q.pop();
        cost += x + y;                  // 这次合并消耗的体力
        q.push(x + y);                  // 新堆放回集合,参与后面的合并
    }
    cout << cost << '\n';
    return 0;
}
逐行拆解
代码片段含义与理由
priority_queue<int, vector<int>, greater<int>> q;小根堆:top() 始终是最轻的一堆。用默认大根堆会先合并最大的两堆,答案偏大。
while (q.size() > 1)合并 n − 1 次,剩 1 堆时停。写成 while (!q.empty()) 会在最后一轮第二次 pop 时取空堆。
int x = q.top(); q.pop(); int y = q.top(); q.pop();连续取两个最小值。必须先 top 再 pop,两句不能合并(pop 没有返回值)。
cost += x + y;这次合并的体力消耗就是两堆之和,也是这棵子树的根权值。
q.push(x + y);新堆必须放回去。漏了这一行,下一轮会比较到已经不存在的旧值,答案偏离。
这一节的 5 个坑,每个都有具体症状

在空堆上 q.top() 或 q.pop():未定义行为,通常 RE(段错误)。合并果子的循环条件必须是 q.size() > 1。

合并果子错用大根堆(漏写 greater):每轮合并最大的两堆,样例 3 / 1 2 9 会输出 23 而不是 15 → WA。这种错最容易藏在“样例过了”的错觉后面,一定要用这组数据验证。

operator< 写成 return w < o.w; 以为是小根堆:堆顶其实是最大值 → WA,而且结构体题通常没有小样例能暴露它。

cmp 或 operator< 里用 <=、>=:破坏严格弱序,std::sort 可能越界写内存 → RE;开 -fsanitize 会报 invalid comparator。

区间调度按左端点或区间长度排序,用 [0,100]、[1,2]、[3,4] 或 [0,6]、[6,10]、[0,10] 一测就 WA;n = 10⁶ 时不关 cin 同步会 TLE,把 10⁶ 的数组开在 main 里会 RE(爆栈)。

自己动手复现

关掉页面,把 P1803 的代码从零敲一遍并提交,确认样例输出 2。

改一改①:把 a[i].l >= last 的等号去掉,用数据 2 / 1 2 / 2 3 试。正确答案是 2(第一场 2 时刻结束,第二场 2 时刻开始,可以接着打),改完变成 1。

改一改②:合并果子把 greater<int> 删掉变成大根堆,跑 3 / 1 2 9,看答案从 15 变成 23。亲手体会一次“堆的方向决定贪心方向”。

数学补给 · 合并果子的代价 = 哈夫曼树的带权路径长度

把每次合并画成一个新结点,两个孩子是参与合并的两堆。n 堆最终合并成一棵树:叶子是原始堆,内部结点是合并出来的新堆,共 n − 1 个内部结点。某堆果子被合并 d 次,它的数量就在 d 个内部结点的权值里各出现一次,所以

总代价 = Σ(内部结点权值)= Σ a_i × d_i,d_i 是叶子的深度。这个量叫 带权路径长度 WPL,也就是哈夫曼编码里的平均码长项。例:a = 1, 2, 3, 4,合并 1+2 = 3、再合并 3+3 = 6、最后 6+4 = 10,总代价 19;检验深度:1、2 深度 3,3 深度 2,4 深度 1,1×3 + 2×3 + 3×2 + 4×1 = 19。

为什么“取最小的两堆”最优?最轻的两堆在最优树里一定是最深的一对兄弟;否则把它们与最深的叶子交换,小权值下移、大权值上移,WPL 严格变小,与最优性矛盾。

规模:n 堆合并 n − 1 次,每次堆操作 O(log n),总 O(n log n)。树深最坏可到 n − 1,代价不能按 log n 估。P6033(n 到 10⁷)必须用 long long 并换 O(n) 做法。

举一反三 · 想清楚这些再往下走
  1. 如果每个区间带一个价值,要求“选出互不重叠的区间使总价值最大”,按右端点贪心还成立吗?先造一个反例(提示:一个很长但价值极高的区间),再想正确解法要什么工具(第 27、31 节)。
  2. 合并果子如果改成“每次只能合并相邻的两堆”,每次取最小的相邻对还对吗?先手算 4 组数据找反例,再看第 33 节的区间 DP——这是“加一个限制,贪心就退化成 DP”的典型。
  3. 合并果子如果每次可以合并任意 3 堆,会多出什么麻烦?提示:每次减少 2 堆,要保证最后正好剩 1 堆,可能需要补几个 0 堆。补几个?
  4. n = 10⁶ 个区间、时刻范围也只有 10⁶,除了 sort 还能怎么排?(提示:第 6 节的桶计数,按右端点扔进桶里,O(n + V)。)
  5. 区间选点如果要求“每个区间至少包含 2 个点”,还能贪心吗?先试 [1,2] 这种短区间,看会不会放不下两个点。
自测:能全部回答再进下一节
  • 能说出区间调度的三个候选排序键,并各给一个反例或一句证明。
  • 能写出小根堆的完整声明,并解释三个模板参数分别是什么。
  • 知道 operator< 与 sort 的 cmp 方向相反,能现场写出“w 最小的在堆顶”的版本。
  • 能在 O(1) 时间里判断“区间调度”和“区间选点”的判断条件差在哪里(>= 与 >)。
  • 能不看书写出合并果子的堆循环,并解释为什么新堆要 push 回去。
  • 看到“每步取当前最优 + 集合动态变化”,能立刻想到用堆而不是 sort。
挑战题 · 建筑抢修
洛谷 P4053普及/提高-去洛谷做这题 ↗

有 N 个受损建筑,只有一个修理工。修第 i 个建筑需要 T1_i 秒,如果它在 T2_i 秒之内还没有修完就会报废。修理工一次只能修一个,求最多能抢修多少个建筑。

输入格式:第一行一个整数 N;接下来 N 行,每行两个整数 T1_i、T2_i。

输出格式:一行一个整数 S,表示最多能抢修的建筑数。

数据范围:1 ≤ N < 150000,1 ≤ T1_i < T2_i < 2³¹。

引导性提示(先只看这里)

报废时间早的建筑必须优先处理,否则一定来不及。先按 T2 升序排序,再想“排好序之后怎么决定修不修”。

如果某一刻发现总时间已经超过当前建筑的报废时间,说明前面选多了——该从已选的里面去掉一个,去掉哪个最划算?

去掉“最费时”的那个能让总时间降得最多,而建筑个数不变。用一个大根堆维护已选建筑的修复时间。

想看思路了?点开完整思路

排序。按报废时间 T2 升序。若存在一种方案能修 k 个,把它们按 T2 升序排列也不会违反任何报废时间。

依次处理每个建筑,维护:已选集合 S、总修复时间 sum、一个存 S 中所有 T1 的大根堆。

若 sum + T1_i ≤ T2_i:直接加入 S。它在 S 里最后修,前面已选建筑的完成时间不变,所以只要它自己赶得上,整个 S 仍然合法。

若 sum + T1_i > T2_i:加不进第 |S| + 1 个,最多只能保持 |S| 个。此时如果堆顶(S 里最费时的)> T1_i,就用当前建筑替换它:个数不变、sum 变小;而且当前建筑 T2 最大、排在最后,替换后每个前缀的完成时间都不会变大,合法性保住。若堆顶 ≤ T1_i,替换只会让 sum 更大,什么也不做。

最后答案就是堆的大小。复杂度 O(N log N),N = 1.5×10⁵ 轻松过。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int N = 150005;
int n;
struct Build { int t1, t2; } a[N];

// 按报废时间升序:越早报废越先考虑
bool cmp(const Build &x, const Build &y) {
    if (x.t2 != y.t2) return x.t2 < y.t2;
    return x.t1 < y.t1;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    for (int i = 1; i <= n; ++i) cin >> a[i].t1 >> a[i].t2;
    sort(a + 1, a + n + 1, cmp);

    priority_queue<int> q;      // 大根堆:已选建筑的修复时间,堆顶最费时
    long long sum = 0;          // 已选建筑的总耗时,最大约 1.5e5 * 2^31,必须 long long

    for (int i = 1; i <= n; ++i) {
        if (sum + a[i].t1 <= a[i].t2) {
            sum += a[i].t1;             // 赶得上,直接修
            q.push(a[i].t1);
        } else if (!q.empty() && q.top() > a[i].t1) {
            // 赶不上:用当前建筑换掉已选里最费时的那个,数量不变、总时间变小
            sum -= (long long)q.top() - a[i].t1;
            q.pop();
            q.push(a[i].t1);
        }
        // 否则当前建筑直接放弃:换了只会更慢
    }

    cout << q.size() << '\n';       // 堆里剩几个就是最多能修几个
    return 0;
}
关键片段拆解
片段作用
sort(a + 1, a + n + 1, cmp);按报废时间排序。这一步定下了“修的顺序就是处理顺序”,后面所有判断都基于这个顺序。
priority_queue<int> q;这里要的是大根堆:每次想知道“已选里最费时的是谁”,所以用默认的 priority_queue,不要加 greater。
if (sum + a[i].t1 <= a[i].t2)当前建筑排在这一批的最后修,只要它的完成时刻不超过自己的报废时间,前面已选的完成时刻都没变,整批仍合法。
sum -= (long long)q.top() - a[i].t1;先减后加写成一句。括号里显式转 long long,避免 int 减法在极端数据下出问题。
cout << q.size();答案不是 sum、不是 cnt,而是集合大小。q.size() 的类型是 size_t,直接输出没问题。
这题最容易错的地方
  • 用 int 存 sum:N = 1.5×10⁵、T1 接近 2³¹ 时总和远超 int → 溢出成负数,判断全乱,WA 一片。
  • 把替换条件写成 q.top() < a[i].t1(方向反了):换成更费时的建筑,sum 变大,答案偏小 → WA。
  • 忘了先判空就 q.top():第一栋建筑赶不上且堆为空时 RE。
  • 按 T1 或按 T2/T1 排序:必须按 T2 升序,其他键都能构造反例。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》第 0x07 节:贪心(区间问题与哈夫曼树)
  • 《算法竞赛试炼场:洛谷 300 题精析》贪心章:线段覆盖与合并果子
  • 洛谷 P1803、P1090、P4053 题解区的高赞证明
18 前缀和与差分普及-

18 · 前缀和与差分

两个互逆的算子:前缀和把区间查询降到 O(1),差分把区间修改降到 O(1)。一维练熟以后平移到二维,掌握容斥的四个符号和差分的四个角。
⭐ 已学范围普及-预计 60 分钟前缀和差分二维容斥
本节出现的新函数 / 新语法(先看懂再用)

两个问题,看着相反,其实是同一件事。

  • 问题 A:给 n 个整数,q 次询问“第 l 个到第 r 个的和”。直接 for 累加是 O(nq),n = q = 10⁵ 时约 5×10⁹ 次加法,TLE。
  • 问题 B:给 n 个整数,q 次操作“把第 l 个到第 r 个每个加 z”,最后输出每个数。直接 for 一遍是 O(nq),同样 TLE。

A 的答案是前缀和:一次 O(n) 预处理,之后每次询问 O(1)。B 的答案是差分:每次修改 O(1),最后一次 O(n) 还原。两者互为逆运算——前缀和把“一段区间”换成“两个前缀相减”,差分把“一次区间修改”换成“两次单点修改”。

“多次询问区间”往前缀和想,“多次修改区间、最后统一输出”往差分想。别混:前缀和单次修改是 O(n),差分单次询问是 O(n)。
新函数拆解 · pre 数组(一维前缀和)
pre[0] = 0; pre[i] = pre[i-1] + a[i]; // i = 1..n
组成含义 / 格式要求
pre[i]前 i 个数的和:pre[i] = a[1]+a[2]+…+a[i]。它存的是累计量,不是 a 本身。
pre[0] = 0哨兵,它就是“前 0 个数的和”。有了它,l = 1 时 pre[l-1] 仍然是一个有定义的值,不用给 l = 1 写特判。
下标从 1 开始a[1..n] 放数据,pre[0..n] 放答案。这样 pre[r] - pre[l-1] 对 l = 1 也成立,不用特判。
sum(l, r)pre[r] - pre[l-1]。减法把 a[1..l-1] 削掉,剩下的正好是 a[l..r]。
顺序只能 i 从小到大递推,pre[i] 依赖 pre[i-1]。
量级n 个数的和最大 n × max|a|。n = 2×10⁵、a ≤ 10⁹ 时是 2×10¹⁴,必须 long long。
最小可运行示例
示例
const int MAXN = 200005;                 // 按题目上限开
int n; long long a[MAXN], pre[MAXN];
cin >> n;
for (int i = 1; i <= n; ++i) cin >> a[i];
for (int i = 1; i <= n; ++i) pre[i] = pre[i - 1] + a[i];   // O(n) 建表

int q; cin >> q;
while (q--) {
    int l, r; cin >> l >> r;
    cout << pre[r] - pre[l - 1] << "\n";   // O(1) 查询
}
注意

pre 里任何一项算错,它后面全部错。调试时先打印整张 pre 和手算值比对。

pre 的元素类型要按“和”的量级定,不能照抄 a 的类型。

新函数拆解 · d 数组(一维差分)
d[1] = a[1]; d[i] = a[i] - a[i-1]; // i = 2..n
组成含义 / 格式要求
d[i]相邻两项之差,记录 a 的变化量:d[i] = a[i] - a[i-1]。
区间加 zd[l] += z; d[r+1] -= z; 两次单点修改,顶替一次 O(len) 的循环。
为什么d[l] 变大 → 前缀和还原时从 l 起每一项都多 z;d[r+1] 减回去 → 从 r+1 起恢复原状。
还原a[i] = a[i-1] + d[i],等价于对 d 做一遍前缀和。这一步必须做,不做就没有答案。
数组大小要写 d[r+1],而 r 可以等于 n,所以 d 至少开 n+2 个。开 n 个一定越界。
复杂度单次修改 O(1),最后还原 O(n)。q 次修改总代价 O(n+q),而不是 O(nq)。
最小可运行示例
示例
// 目标:把 a[l..r] 每个数都加 z
d[l]     += z;
d[r + 1] -= z;          // r = n 时这句话写的是 d[n+1],所以要开 n+2

// 撤销差分,把 d 还原成 a
int cur = 0;
for (int i = 1; i <= n; ++i) {
    cur += d[i];        // cur 就是还原后的 a[i]
}
注意

差分数组本身不是答案,它只是“改动记录”。输出前一定要前缀和还原。

d[r+1] 越界不会立刻报错,会改到隔壁变量上,症状是“某一小段数据莫名其妙不对”。

做法预处理一次区间和 / 一次区间加n = q = 10⁵ 时
朴素循环无O(n)10¹⁰ 次,TLE
前缀和O(n) 建表O(1) 查询10⁵ 次,AC
差分O(n) 还原O(1) 修改10⁵ 次,AC
例题 · 语文成绩洛谷 P2367普及-去洛谷做这题 ↗

有 n 个学生,给出初始成绩 a₁…aₙ。接下来 p 次操作,每次把第 x 个到第 y 个学生的成绩每人加 z 分。所有操作结束后,输出全班的最低分。

输入:第一行 n、p(n ≤ 5×10⁶,p ≤ n);第二行 n 个整数表示初始成绩(≤ 100);接下来 p 行,每行 x、y、z(1 ≤ x ≤ y ≤ n,z ≤ 100),表示给第 x 到第 y 个学生每人加 z 分。

输出:一行一个整数,即最终的最低分。

输入: 3 2 1 1 1 1 2 1 2 3 1 输出: 2
例题怎么想:把 p 次区间加法换成 2p 次单点修改

朴素做法:每次操作 for 一遍 [x, y]。p 和 n 都能到 5×10⁶,最坏 2.5×10¹³ 次加法,必然 TLE。

换成差分。整个程序只有三段:建差分 → 2p 次单点修改 → 一遍前缀和还原并取最小。中间那 p 次操作每次只碰两个格子,与区间长度无关,这是它能从 O(np) 降到 O(n+p) 的全部原因。

两个细节。① z 的符号不用管:z 为负时 d[x] += z 就是在做减法,算法一字不改。② 没必要把还原后的成绩再存一个数组:边还原边取 min,空间从 3 个数组降到 2 个。

内存算一下:n = 5×10⁶,两个 int 数组各 5×10⁶ × 4B = 20MB,合计 40MB,全局数组放静态区,不会爆栈。

完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 5000005;      // 题目上限 5×10⁶,再多留几个位给 d[n+1]
int a[MAXN], d[MAXN];          // a 存初始成绩,d 存差分

int main() {
    ios::sync_with_stdio(false);   // 5×10⁶ 个整数,必须提速
    cin.tie(nullptr);

    int n, p;
    cin >> n >> p;
    for (int i = 1; i <= n; ++i) cin >> a[i];

    for (int i = 1; i <= n; ++i)
        d[i] = a[i] - a[i - 1];    // a[0] 是全局数组,默认 0,所以 d[1] = a[1] 自动成立

    while (p--) {
        int x, y, z;
        cin >> x >> y >> z;
        d[x]     += z;             // 从 x 开始,每个还原值都比原来多 z
        d[y + 1] -= z;             // 从 y+1 开始把这层 z 扣掉,恢复原样
    }

    int cur = 0, mn = INT_MAX;
    for (int i = 1; i <= n; ++i) {
        cur += d[i];               // 前缀和还原:cur 就是最终的 a[i]
        if (cur < mn) mn = cur;    // 边还原边统计最低分,省下第三个数组
    }
    cout << mn << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
const int MAXN = 5000005;比 5×10⁶ 多留 5 个位置。y = n 时那句 d[y+1] 会写到下标 n+1,数组必须容得下。
int a[MAXN], d[MAXN];全局数组默认全 0:a[0] = 0 让 d[1] 的公式不用特判,超出 n 的 d 值也是 0。另外 40MB 放静态区,放栈上会直接栈溢出。
ios::sync_with_stdio(false);5×10⁶ 次 cin 不关同步可能吃掉接近 1 秒,很可能 TLE。
d[i] = a[i] - a[i - 1];差分定义式。i = 1 时 d[1] = a[1] - a[0] = a[1],靠 a[0] = 0 自动正确。
d[x] += z;让第 x 个位置开始“多出一层 z”。
d[y + 1] -= z;在 y 之后立刻削掉这层 z。两句合起来,只影响 [x, y]。
cur += d[i];前缀和还原。d 里只有 O(p) 个非零值,cur 把它们的贡献一路累加下去。
if (cur < mn) mn = cur;最低分就在还原过程中统计,不再多开数组存最终成绩。
cout << mn << "\n";题目只要一个整数加换行。

把数轴换成 n 行 m 列的网格,两类问题照抄:多次询问子矩阵和用二维前缀和;多次矩形加、最后输出整张表用二维差分。区别只有一个:一维里是“两次端点”,二维里是“四个角”。

新函数拆解 · pre2 数组(二维前缀和)
pre[i][j] = pre[i-1][j] + pre[i][j-1] - pre[i-1][j-1] + a[i][j];
组成含义 / 格式要求
pre[i][j]左上角 (1,1)、右下角 (i,j) 这个矩形里所有数之和。
+ pre[i-1][j]上面那一大块:前 i-1 行、前 j 列。
+ pre[i][j-1]左边那一大块:前 i 行、前 j-1 列。
- pre[i-1][j-1]左上角那块被加了两次,减回一次——这就是容斥。
+ a[i][j]最后加上自己这一格。建表的四个符号是 + + − +。
查询左上 (x1,y1) 到右下 (x2,y2):pre[x2][y2] − pre[x1-1][y2] − pre[x2][y1-1] + pre[x1-1][y1-1],符号是 + − − +。
数组大小开 (n+1) × (m+1),下标从 1 开始,第 0 行、第 0 列全 0,边界不用特判。
最小可运行示例
示例
// 建表:i、j 都必须从小到大(依赖上方和左方)
for (int i = 1; i <= n; ++i)
  for (int j = 1; j <= m; ++j)
    pre[i][j] = pre[i-1][j] + pre[i][j-1] - pre[i-1][j-1] + a[i][j];

// 查询子矩阵和
long long s = pre[x2][y2] - pre[x1-1][y2] - pre[x2][y1-1] + pre[x1-1][y1-1];
注意

数一下就知道对不对:把 a = [[1,2],[3,4]] 代进去,pre[2][2] = 3 + 4 - 1 + 4 = 10 = 1+2+3+4。

n、m 都是 10³、每格 10⁹ 时,子矩阵和能到 10¹⁵,pre 必须是 long long。

新函数拆解 · d2 数组(二维差分)
d[x1][y1] += z; d[x2+1][y1] -= z; d[x1][y2+1] -= z; d[x2+1][y2+1] += z;
组成含义 / 格式要求
四个角左上、左下、右上、右下各改一次,一次“矩形加 z”就是 O(1)。
符号规律对角同号:左上、右下 +z;右上、左下 −z。
为什么还原后,一个角上的 +z 会向右下扩散成整块 +z。矩形内的格子只吃到左上角那一份 z;下边界外的被 (x2+1,y1) 的 −z 抵掉,右边界外的被 (x1,y2+1) 的 −z 抵掉,两者都超出的再由右下角的 +z 补平。
还原对 d 做一次二维前缀和:d[i][j] += d[i-1][j] + d[i][j-1] - d[i-1][j-1]。还原后 d[i][j] 才是真值。
数组大小要写 x2+1、y2+1,它们可以等于 n+1、m+1,所以开 (n+2) × (m+2)。
注意还原是“就地覆盖”:还原过的 d 不再是差分,别拿它再做修改。
最小可运行示例
示例
// 矩形 (x1,y1)-(x2,y2) 每个格子加 z
d[x1][y1]         += z;
d[x2 + 1][y1]     -= z;
d[x1][y2 + 1]     -= z;
d[x2 + 1][y2 + 1] += z;

// 还原:做完这一步 d[i][j] 才是格子 (i,j) 的真实值
for (int i = 1; i <= n; ++i)
  for (int j = 1; j <= m; ++j)
    d[i][j] += d[i-1][j] + d[i][j-1] - d[i-1][j-1];
注意

四个角写成“上面两个减、下面两个加”也能算,但符号必须与位置对应;对角的两个永远同号。

m 个矩形是 4m 次修改,加上 O(nm) 还原,总复杂度 O(nm + m),可以从 10³×10³ 过。

场景公式符号
建二维前缀和pre[i][j] = pre[i-1][j] + pre[i][j-1] − pre[i-1][j-1] + a[i][j]+ + − +
查询子矩阵和pre[x2][y2] − pre[x1-1][y2] − pre[x2][y1-1] + pre[x1-1][y1-1]+ − − +
二维差分修改左上、右下 +z;右上、左下 −z+ − − +
二维差分还原d[i][j] += d[i-1][j] + d[i][j-1] − d[i-1][j-1]+ + −
差分是“记账”,不是“结账”。账记完(改完 d)以后,必须做一次前缀和把它翻译回真实值,这一步在二维里尤其容易忘。
差分的 6 种典型错法(含症状)

RE / 随机 WA:d 只开了 n 个,而 d[r+1] 在 r = n 时写到下标 n+1。开 n+2。

WA 一整片:忘了还原,直接把 d 输出了。d 里大多是 0 和很小的数,和答案差得离谱。

WA 一半:d[r+1] -= z 写成 d[r] -= z。症状是每个区间的右端点那一格没被加上,短区间自查不出来,长区间一错错一整段。

WA 全负:建差分时写成 d[i] = a[i] - a[i+1],符号反了,还原后整条数组变号。

溢出:和用 int 存。本题最坏 100 + 5×10⁶ × 100 = 5×10⁸,int 刚好够;把 z 上限看成 10⁹ 就必然爆。

TLE:n = 5×10⁶ 却没写 ios::sync_with_stdio(false)。

二维专属的 3 个坑

符号记反:子矩阵查询最后一项写成减号。症状:答案偏小,且小的正好是 (x1-1)×(y1-1) 那块的和。

只开 n × m:二维差分要写 d[x2+1][...]、d[...][y2+1],x2 = n 时下标是 n+1。开 (n+2) × (m+2)。

还原顺序错:i 或 j 从大到小、或者行列写反(把 x 当列用)。前者用到的是旧值,后者输出整个转置,样例第一行就对不上。

自己动手复现

关掉页面,从空文件把 P2367 敲一遍并提交。先把 ios::sync_with_stdio(false) 注释掉测一次,感受 5×10⁶ 个整数的读入差距。

改一改 ①:删掉数组 a,读入时直接写进 d,然后 for (int i = n; i >= 1; --i) d[i] -= d[i-1]; 原地差分。内存从 40MB 降到 20MB,答案必须一模一样。

改一改 ②:把 d 改成 int d[n+1](正好 n+1 个),输入一组 y = n 的数据,例如 3 1 / 1 1 1 / 1 3 5,看它是否给出错误结果——亲手见一次越界。

改一改 ③:题目改成“输出操作后的总分”,只需把取 min 改成累加 cur,其余不动。

数学补给 · 前缀和与差分互为逆运算,容斥是它的二维版

记 S 为前缀和算子:(Sa)[i] = a[1] + a[2] + … + a[i]。记 D 为差分算子:(Da)[i] = a[i] − a[i−1],约定 a[0] = 0。

那么 D(Sa) = a,S(Da) = a。二者互为逆运算。写成矩阵(n = 3)看得最清楚:

S 是下三角全 1,D 是对角 1、下对角 −1
S = [1 0 0]      D = [ 1  0 0]        D * S = I
    [1 1 0]          [-1  1 0]
    [1 1 1]          [ 0 -1 1]

两个技巧就是同一句话的两种读法:

  • 求区间和 = (Sa)[r] − (Sa)[l−1]:把“长度 len 的区间”换成两次查表。
  • 做区间加 = 在 d 的 l 处 +z、r+1 处 −z:把“len 次单点修改”换成两次端点修改。因为 D 把“一段阶跃”映成“两个脉冲”。

数字验一遍。a = [1, 2, 3],pre = [0, 1, 3, 6],区间 [2, 3] 的和 = pre[3] − pre[1] = 6 − 1 = 5 = 2 + 3。✓

d = [1, 1, 1]。给 [2, 3] 加 4:d[2] += 4 → d = [1, 5, 1];d[4] −= 4 → d 的第 4 位变成 −4。还原 cur 依次为 1、6、7(再往下累加会得到 3,那是数组外的位置,不用管)→ a = [1, 6, 7] = [1, 2, 3] + [0, 4, 4]。✓

二维的容斥来自 |A ∪ B| = |A| + |B| − |A ∩ B|。建表时“上”和“左”两块都把左上角算了一遍,所以要减一次;查询时要去掉上方、左方两块,而它们的交集(左上角)被减了两次,所以要加回一次。二维差分的四个角是同一件事的另一种用法:一个角的 +z 还原后扩散成右下角整块 +z,四个角叠加就把“矩形”圈了出来。

最后一条推论:凡是“可减”的运算,前缀和都能用。加法可以(减法撤销),异或可以(自己撤销自己);max、min 没有逆运算,所以区间最大值不能用前缀和,要做 ST 表。

举一反三 · 想清楚这些再往下走
  1. 区间查询换成“区间最大值”,前缀和还能用吗?从“有没有逆运算”这个角度回答,并说出正确做法需要预处理什么。
  2. 给 n 个数,问有多少个子区间的和能被 7 整除。把前缀和对 7 取模后,答案为什么等于“相同余数的前缀两两配对”?写出计数式子。
  3. 差分再差分一次得到二阶差分。如果要“把 [l, r] 加上首项 p、公差 q 的等差数列”,二阶差分要改几个位置?分别是哪几个?
  4. 给一个 n×m 矩阵,求元素和最大的 h×w 子矩形。二维前缀和能把每块的和算成 O(1),那总复杂度是多少?还有更快的吗?
  5. 如果操作是“把 [l, r] 全部赋值为 z”(不是加 z),差分还够用吗?需要额外记录什么信息?
自测:能全部回答再进下一节
  • 能在 1 分钟内写出 pre 的建表与查询,并说清 pre[0] = 0 到底防的是什么。
  • 能解释 d[r+1] -= z 里的 r+1 从哪来,以及数组为什么必须开 n+2。
  • 能默写二维前缀和的建表式与子矩阵查询式,并指出每个符号抵消了哪一块。
  • 能默写二维差分的四个角,并说出“对角同号”的理由。
  • 看到“n、q ≤ 10⁵,多次区间修改,最后一次输出”,能在 10 秒内判断该用差分,而不是线段树。
挑战题 · 地毯
洛谷 P3397普及-去洛谷做这题 ↗

在 n×n 的格子上铺了 m 块矩形地毯,给出每块地毯的左上角 (x₁, y₁) 和右下角 (x₂, y₂)。求每个格子被多少块地毯覆盖。

输入:第一行两个正整数 n、m(n, m ≤ 1000);接下来 m 行,每行四个整数 x₁、y₁、x₂、y₂(1 ≤ x₁ ≤ x₂ ≤ n,1 ≤ y₁ ≤ y₂ ≤ n)。

输出:n 行,每行 n 个整数,第 i 行第 j 个数表示格子 (i, j) 被覆盖的次数,用空格分隔。

引导性提示(先只看这里)

一块地毯的效果是“矩形内每格 +1”,先想清楚这等价于在四个位置上各做一次单点修改。

所有修改都记下来之后,一次二维前缀和就能把它们“推”到每个格子上;顺序必须从左上到右下。

矩形的右下角是 (x₂, y₂),所以会用到 x₂+1 和 y₂+1——数组要留出这一行一列。

想看思路了?点开完整思路

把每块地毯看成一次“矩形 +1”。用二维差分 d:对矩形 (x1,y1)-(x2,y2) 执行 d[x1][y1]++、d[x2+1][y1]--、d[x1][y2+1]--、d[x2+1][y2+1]++。

m 块地毯就是 4m 次 O(1) 修改,总代价 O(m)。

最后对 d 做一次二维前缀和(就地覆盖),d[i][j] 直接变成格子 (i,j) 的覆盖数。

为什么四个角能圈出矩形:还原时一个角上的 +1 会向右下扩散成“从这个角到右下角”的整块 +1。矩形内的格子只吃到左上角那一份 +1;跑到下边界之外的格子被 (x2+1,y1) 的 −1 抵掉,跑到右边界之外的被 (x1,y2+1) 的 −1 抵掉,两者都超出的再被右下角的 +1 补平,全是 0。

复杂度 O(n² + m),n = m = 1000 时约 10⁶ 次操作,远小于 10⁸。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1005;         // n ≤ 1000,还要用下标 n+1,所以多开几个
int d[MAXN][MAXN];

int main() {
    ios::sync_with_stdio(false);   // n² = 10⁶ 次输出,必须提速
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;
    while (m--) {
        int x1, y1, x2, y2;
        cin >> x1 >> y1 >> x2 >> y2;
        d[x1][y1]         += 1;    // 左上角:从这里开始算被覆盖
        d[x2 + 1][y1]     -= 1;    // 下边界之外一行:撤掉
        d[x1][y2 + 1]     -= 1;    // 右边界之外一列:撤掉
        d[x2 + 1][y2 + 1] += 1;    // 右下角外侧被撤了两次,补回一次
    }

    for (int i = 1; i <= n; ++i) {
        for (int j = 1; j <= n; ++j) {
            d[i][j] += d[i - 1][j] + d[i][j - 1] - d[i - 1][j - 1];  // 二维前缀和还原
            cout << d[i][j] << (j == n ? '\n' : ' ');
        }
    }
    return 0;
}
关键片段拆解
片段作用
d[x1][y1] += 1;左上角 +1:还原后,这个 +1 会向整块右下区域扩散。
d[x2 + 1][y1] -= 1;矩形下边界外面第一行开始,把扩散出来的覆盖撤掉。
d[x1][y2 + 1] -= 1;右边界外面第一列开始,同样撤掉。
d[x2 + 1][y2 + 1] += 1;右下角外侧被上面两句各撤了一次,共撤两次,所以要补回一次。
d[i][j] += d[i-1][j] + d[i][j-1] - d[i-1][j-1];二维前缀和还原,就地覆盖;此时 d[i][j] 就是答案,不用另开数组。
(j == n ? '\n' : ' ')行末只换行不加空格,避免被严格评测判 WA。
MAXN = 1005x2 = n 时会写 d[n+1][...],下标要能到 1001,开 1005 稳妥。
这题最容易错的地方
  • 数组只开到 1000 或 1001:x2 = n 时 d[n+1][y1] 越界,表现是随机 WA 或 RE。开 1005。
  • 四个角位置写错,例如把 d[x2+1][y1] 写成 d[x1+1][y1],结果是每块矩形都少算一条边。
  • 忘记还原直接输出 d:只有四个角非零,样例第一行就对不上。
  • 还原时内层循环写成 m 而不是 n,或者把 i、j 当成 (y, x) 用:整张表转置,全错。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》前缀和与差分部分:把区间操作换成端点操作
  • 《算法竞赛入门笔记》数组预处理章节
  • 洛谷 P2367、P3397 题解区的高赞推导
19 双指针与滑动窗口普及-

19 · 双指针与滑动窗口

两种指针形态,一条共同前提:单调性。两个指针只前进不回头,总移动 O(n)。学会用 while 的循环不变式描述窗口,用摊还分析解释复杂度。
⭐ 已学范围普及-预计 60 分钟双指针滑动窗口单调性
本节出现的新函数 / 新语法(先看懂再用)

在 n = 2×10⁵ 的数组里找两个数,使它们的和等于 x。双重循环 O(n²) = 4×10¹⁰ 次比较,TLE。

如果数组是有序的,事情立刻不一样:l 指最左、r 指最右,看 a[l] + a[r] 与 x 的大小关系。和太大,说明 a[r] 配谁都太大,r 左移;和太小,说明 a[l] 配谁都太小,l 右移。每一步都能确定地丢掉一整排候选,而不是只丢一个。

  • 两端收缩(对撞指针):l 从左往右、r 从右往左,两者相向而行,直到 l ≥ r。前提是数组有序。
  • 同向双指针(滑动窗口):l、r 都只往右走,中间夹着的 [l, r] 叫窗口。r 负责扩张,l 负责收缩。
双指针不是“写了两个变量”,而是一句承诺:右指针往前走时,左指针的目标位置只会往前走,不会回头。有这句承诺,总移动次数才是 O(n);没有,就是 O(n²)。
新函数拆解 · while 循环(次数不定)
while (条件) { 循环体 }
组成含义 / 格式要求
执行方式每轮开始前重新求值,为真就执行循环体,再回去求值。所以循环体必须改变条件里的量,否则死循环。
和 for 的分工for 用于“次数已知”(i 从 1 到 n);while 用于“次数由数据决定”(一直缩到窗口合法为止)。双指针的骨架通常是外层 for、内层 while。
循环不变式进循环前先写下一句恒成立的事实,例如“当前窗口 [l, r] 内的和 ≥ S”。循环体每轮都在维护它,退出时它连同否定条件一起给出结论。
动笔前问三句① 进入循环时什么成立?② 循环体让什么改变?③ 退出时什么成立、能推出什么?
最小可运行示例
示例
int best = n + 1;                  // 当前最优长度,n+1 表示“还没有”
// 收缩:只要窗口还覆盖全部 m 种,就试着把 l 往右推
while (have == m) {
    // 此刻不变式成立:[l, r] 覆盖了全部 m 种
    if (r - l + 1 < best) best = r - l + 1;
    if (--cnt[a[l]] == 0) --have;   // 移出 a[l],可能弄丢一种
    ++l;                            // 让不变式可能被打破
}
// 退出时 have < m:[l, r] 已经不合法,正好可以继续右移 r
注意

while 的条件和循环体必须配套:上面靠 --cnt、--have、++l 让 have 变小,条件迟早为假。

内层 while 的总执行次数不超过 n,因为每次执行 l 都加 1,而 l 最大到 n+1。这就是它 O(n) 的全部理由。

新函数拆解 · 滑动窗口骨架(l、r 同向)
int l = 1; for (int r = 1; r <= n; ++r) { 进窗口; while (不合法) { 出窗口; ++l; } 用窗口更新答案; }
组成含义 / 格式要求
r(右边界)归外层 for 管,每轮只 +1,绝不停留、绝不回退。负责扩张。
l(左边界)只在 while 里 ++,绝不减小。负责收缩。
进窗口把 a[r] 的影响加进维护量:sum += a[r]、cnt[a[r]]++。
出窗口把 a[l] 的影响减掉:sum -= a[l]、cnt[a[l]]--,然后 ++l。
不合法“要最短”的题:不合法 = 条件不再满足;“求最长”的题:不合法 = 超过限制。骨架完全一样,只换布尔表达式。
更新答案在 while 收缩前或收缩后那一瞬,[l, r] 是“以 r 结尾、不能再短”的窗口,从它更新答案。
对称性维护量必须“加什么减什么”:进窗口加的量,出窗口一个都不能漏,否则误差会一直累积。
最小可运行示例
示例
int l = 1; long long sum = 0;
int ans = n + 1;                       // n+1 表示“还没有答案”
for (int r = 1; r <= n; ++r) {
    sum += a[r];                       // 扩张
    while (sum >= S && l <= r) {       // 合法就缩短
        ans = min(ans, r - l + 1);
        sum -= a[l];                   // 收缩
        ++l;
    }
}
注意

l <= r 必须写在条件里:窗口缩空后 sum 变 0,若不检查 l,l 会跑到 r 右边继续减。

l 和 r 一定要定义在 for 外面。写进 for 里每轮重置,指针退化成 O(n²)。

先看两端收缩。问题:给一个升序数组 a[1..n] 和 x,判断是否存在 i ≠ j 使 a[i] + a[j] = x。

两端收缩
int l = 1, r = n;
bool ok = false;
while (l < r) {
    int s = a[l] + a[r];
    if (s == x) { ok = true; break; }
    else if (s < x) ++l;      // a[l] 配谁都太小,这一整排候选全部作废
    else            --r;      // a[r] 配谁都太大,这一整列候选全部作废
}

为什么不会漏解?设当前 a[l] + a[r] < x。对任意 j ≤ r,因为数组升序,a[l] + a[j] ≤ a[l] + a[r] < x,所以 a[l] 和任何右端点都凑不出 x——l 可以永久丢弃。反过来 a[l] + a[r] > x 时,a[r] 配任何左端点都太大,r 可以永久丢弃。每一次移动都丢掉一整排,总共丢 n 次就结束。

所以两端收缩的真正前提是单调性:丢掉的东西以后不可能再有用。数组没排序时这个论证不成立,必须先花 O(n log n) 排序。

例题 · A-B 数对洛谷 P1102普及/提高-去洛谷做这题 ↗

给 n 个正整数 a₁…aₙ 和一个正整数 C,统计有多少个有序对 (i, j) 满足 aᵢ − aⱼ = C。数值相同但位置不同的对算作不同的对。

输入:第一行两个正整数 N、C(1 ≤ N ≤ 2×10⁵,1 ≤ C < 2³⁰);第二行 N 个整数,0 ≤ aᵢ < 2³⁰。

输出:一行一个整数,即满足条件的数对个数。

输入: 4 1 1 1 2 3 输出: 3
例题怎么想:把“找 A”变成“找一段相等值”

直接双重枚举是 O(n²) = 4×10¹⁰,TLE。题目里 A 和 B 都是数组元素,条件只有一个:A − B = C。

先排序。固定 B = a[i],则需要的 A = a[i] + C。由于相同值的元素排在一起,“等于 a[i] + C 的那些位置”在数组里是连续的一段。问题变成:对每个 i,求出这一段有多长,全部加起来。

关键观察:a[i] 升序,所以 a[i] + C 也升序。“第一个 ≥ a[i]+C 的位置”和“第一个 > a[i]+C 的位置”都只会往右走。两个指针 lo、hi 定义在循环外,各自单向前进,总移动不超过 2n。

复杂度:排序 O(n log n) + 双指针 O(n)。答案上界约 n²/4 = 10¹⁰(一半元素是 v、一半是 v+C 时取到),超出 int,必须 long long。

为什么不会重复、不会漏:i 遍历了每一个位置作为 B,窗口按值精确框住了所有可能的 A,一个 (位置 j, 位置 i) 对只会在 i 取到那个位置时被数一次。

完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 200005;
int a[MAXN];

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, c;
    cin >> n >> c;
    for (int i = 1; i <= n; ++i) cin >> a[i];

    sort(a + 1, a + n + 1);        // 有了单调性,指针才有意义

    long long ans = 0;             // 最多约 2×10¹⁰,int 装不下
    int lo = 1, hi = 1;            // 窗口 [lo, hi) 装的是所有等于 a[i]+c 的位置
    for (int i = 1; i <= n; ++i) {
        long long t = (long long)a[i] + c;
        while (lo <= n && a[lo] < t) ++lo;      // lo 停在第一个 ≥ t 的位置
        while (hi <= n && a[hi] <= t) ++hi;     // hi 停在第一个 > t 的位置
        ans += hi - lo;                         // 窗口长度 = 值为 t 的元素个数
    }
    cout << ans << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
sort(a + 1, a + n + 1);把“找某个值”变成“找连续段”,同时让 a[i]+c 随 i 单调。没有这一步,指针一步都走不了。
long long ansn = 2×10⁵ 时最坏约 n²/4 = 10¹⁰ 对(一半是 v、一半是 v+C),远超 int 的 2.1×10⁹,用 int 直接 WA。
int lo = 1, hi = 1;两个指针都从 1 开始,且定义在 for 外面——“只增不减”是它们能 O(n) 的前提。写进 for 里就退化成 O(n²)。
long long t = (long long)a[i] + c;a[i] 可以接近 2³⁰,加 c 有溢出 int 的风险,先转 long long 再加。
while (lo <= n && a[lo] < t) ++lo;lo 的含义是“第一个 ≥ t 的下标”。因为 a 升序、t 随 i 不减,lo 只需右移,不必回退。
while (hi <= n && a[hi] <= t) ++hi;hi 是“第一个 > t 的下标”。lo 与 hi 之间恰好是所有等于 t 的元素。
ans += hi - lo;区间长度就是“以 a[i] 为 B 的合法 A 的个数”,直接累加,不用逐个枚举。
cout << ans << "\n";一个整数加换行,不输出任何多余字符。

再看同向双指针。问题:给 n 个正整数和目标 S,求最短的连续段,使段内元素和 ≥ S。

滑动窗口
int l = 1, ans = n + 1;          // ans = n+1 表示“不存在”
long long sum = 0;
for (int r = 1; r <= n; ++r) {
    sum += a[r];                  // 扩张:把 a[r] 纳入窗口
    while (sum >= S && l <= r) {  // 合法就试着去掉左端
        ans = min(ans, r - l + 1);
        sum -= a[l];
        ++l;
    }
}

为什么能这么缩:所有数都是正数,所以固定 r 时 sum 随 l 增大而减小,l 越小越容易满足 sum ≥ S。于是对每个 r 只存在一个“最小的可行 l”,而且 r 增大时这个 l 只增不减——这正是双指针要求的那条单调性。反过来,如果允许负数,sum 不再随 l 单调,l 一旦右移可能永远回不到合法状态,答案就漏了。

窗口里要维护的东西不一定只有和。更常见的是计数:cnt[v] 表示值 v 在窗口里出现了几次。进窗口 ++cnt[a[r]],出窗口 --cnt[a[l]]。如果要判断“窗口是否包含全部 m 种”,再维护一个 have = 窗口里的种类数:cnt 从 0 变 1 时 have++,从 1 变 0 时 have--。这样判断只要 O(1);每轮扫一遍 cnt 数组去数种类,会把 O(n) 退化成 O(nm)。

能不能用双指针需要满足的性质不满足时的症状
是r 右移后,最优 l 只增不减(单调性)——
否窗口内有负数,区间和不再随 l 单调窗口提前收缩,漏掉解,WA 一片
否维护量不能 O(1) 增减(如窗口最大值)要改用单调队列,否则每次 O(len)
否丢掉的部分以后可能重新有用“和恰好为 S 的子数组个数”这类题不能随便丢 l
双指针的 6 种典型死法(含症状)

TLE(死循环):while 条件里的量在循环体里没变。例如 while (sum >= S) { ans = min(ans, ...); } 忘了 sum -= a[l]; ++l;。

WA(漏解):收缩过头,把“刚好合法”的窗口也缩没了。先更新答案再收缩,或把条件写成严格不等号。

WA(重复计数):把 lo、hi 声明在 for 里面,每轮重置回 1。指针退化,既慢又可能把同一对算多次。

WA(溢出):ans 用 int。P1102 最坏约 n²/4 = 10¹⁰ 对,int 只能装 2.1×10⁹。

RE / WA(越界):出窗口时访问 a[l] 却没保证 l <= r,窗口缩空后 l 跑到 r 右边继续减。

TLE(假 O(n)):每轮都扫一遍 cnt 数组判断“是否覆盖 m 种”。n = 10⁶、m = 2×10³ 时就是 2×10⁹ 次。

自己动手复现

关掉页面,从空文件把 P1102 的代码敲一遍并提交。再自己出一组数据:3 1 / 1 1 1,手算答案为 0,如果输出不是 0,回到 while 的两个条件里找原因。

改一改 ①:把 C 换成 0,输入 3 0 / 1 1 1。正确答案是 6(三个位置两两配对),程序却会输出 9——多出来的 3 对是“自己和自己”。想清楚该在哪里排除它们。

改一改 ②:在 while 前加一句 cerr << lo << ' ' << hi << '\n';,数一数两个指针总共走了多少步,验证它 ≤ 2n。

改一改 ③:把“最短区间和 ≥ S”改成“最长区间和 ≤ S”,while 条件从 sum >= S 换成 sum > S,看看答案怎么变,并想清楚为什么这次要“超了才缩”。

改一改 ④:给数组里加一个负数(例如 a = {4, -3, 7, 1}、S = 5),逐个 r 打印窗口,找出算法给出错误答案的那一步。

数学补给 · 为什么双指针是 O(n):摊还分析

把内层 while 的执行次数记为 c。每次执行循环体都伴随一次 ++l,而 l 从 1 开始、最大到 n+1,所以整个程序的 c 不超过 n。外层 for 走 n 轮,总操作数 ≤ n + n = 2n,即 O(n)。

注意:单次 while 可能执行很多轮(最坏 n 轮)。我们不算单次最坏,只算总和——这就是摊还分析:把一次昂贵操作的开销,摊到之前让它变昂贵的那 n 次便宜操作上。

写法指针总移动次数复杂度
l 只增不减(正确)l ≤ n,r ≤ nO(n)
每轮把 l 重置为 1约 n²/2O(n²)
两端收缩(l 增、r 减)l 增 ≤ n,r 减 ≤ nO(n)

换个说法:找一个只增不减、上限为 n 的计数器(这里是 l),每轮循环至少让它 +1。计数器最多 +n 次,所以循环最多跑 n 轮。写 while 之前先找到这个计数器,复杂度立刻就知道。

同样地,P1102 里 lo 和 hi 各自最多前进 n 步,合计 2n 步;外层 for 也是 n 轮,所以总复杂度 O(n log n) 完全由排序决定,双指针本身是 O(n)。

反过来说:只要你的代码里出现“指针回退”“每轮重扫一遍窗口”这类动作,摊还论证立刻失效,复杂度按最坏情况算就是 O(n²)。这是判断自己写的是不是真双指针的最快办法。

举一反三 · 想清楚这些再往下走
  1. P1102 如果把条件放宽成 0 ≤ C,两个指针还会正常工作吗?会数出“同一个位置和自己配对”吗?该怎么修?
  2. 求“和恰好为 S 的子数组个数”,数组里有负数时滑窗为什么一定错?应该换成什么做法(提示:前缀和 + 什么)?
  3. 三数之和 a + b + c = 0:在两端收缩外面再套一层枚举,复杂度是多少?为什么不是 O(n³)?
  4. 如果窗口里要维护的是最大值而不是和,cnt 和 sum 都不够用了,需要什么结构?新元素进来时会“淘汰”哪些旧元素?
  5. 两个有序数组求中位数。用双指针归并能做到 O(n+m),能不能做到 O(log(n+m))?提示:把“取前 k 小”写成二分。
自测:能全部回答再进下一节
  • 能写出两端收缩的循环,并说清“和太小就 l++”为什么不会漏掉任何解。
  • 能默写滑动窗口骨架,并指出进窗口、出窗口、更新答案分别在哪一行。
  • 能说出双指针成立依赖的那条单调性,并举一个不满足它的反例(如含负数的区间和)。
  • 能用“l 每次执行 while 都 +1,最多到 n+1”论证内层 while 总次数 ≤ n。
  • 拿到一道新题,能判断该用 sort + 双指针、滑动窗口,还是前缀和 + 哈希。
挑战题 · 逛画展
洛谷 P1638普及/提高-去洛谷做这题 ↗

博览馆按顺序展出 n 幅画,第 i 幅画出自第 aᵢ 位画家(1 ≤ aᵢ ≤ m)。买票时指定区间 [x, y],票价是区间长度 y − x + 1,能看到的画就是这段区间里的全部画。

要求选一个区间,使它包含全部 m 位画家的画,并且票价最小;若有多组解,输出 x 最小的那组。数据保证有解。

输入:第一行 n、m(n ≤ 10⁶,m ≤ 2×10³);第二行 n 个整数 a₁…aₙ。输出:一行两个整数 x、y。

引导性提示(先只看这里)

固定右端点 r,如果 [l, r] 已经覆盖全部画家,那么再往右扩只会更长;这时该试着把 l 往右推,而不是继续扩。

不要每次扫一遍计数字段去数“覆盖了几种”:用一个变量记录“已经至少出现过一次的画家数”,进窗口和出窗口时顺手改它。

答案要求 x 最小:长度相同时不要更新,让先扫描到的那一组留下。

想看思路了?点开完整思路

双指针:r 从 1 到 n 右移,每次把 a[r] 加进窗口。cnt[v] 记录画家 v 在窗口里的画数,have 记录窗口里出现过的画家种数。

cnt[a[r]] 从 0 变成 1 时,说明这位画家第一次进窗口,have 加 1。

只要 have == m,[l, r] 就合法:用 r − l + 1 去更新最优区间,然后把 a[l] 移出窗口(cnt 减 1,减到 0 则 have 减 1),l 右移,继续尝试更短的窗口。

正确性:对每个 r,收缩前的 [l, r] 正是“以 r 结尾的最短合法区间”,全局最优区间必然在某个 r 处被这组候选覆盖到。

x 最小:长度相同时只在严格更短时更新。相同长度的两个窗口,r 越小则 x 越小,而 r 是从左到右扫的,第一次遇到该长度时就已经保留下来了。

复杂度:r 走 n 步,l 总共也只走 n 步,O(n);cnt 数组 O(m) 空间。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1000005;      // n ≤ 10⁶,多留几位
const int MAXM = 2005;         // 画家编号 ≤ m ≤ 2×10³,开 2005 让下标 2000 安全
int a[MAXN], cnt[MAXM];

int main() {
    ios::sync_with_stdio(false);   // 10⁶ 个整数,必须提速
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;
    for (int i = 1; i <= n; ++i) cin >> a[i];

    int l = 1, have = 0;           // have:窗口里出现过的画家种数
    int bl = 1, br = n;            // 当前最优区间,初值取整个数组(题目保证有解)
    for (int r = 1; r <= n; ++r) {
        if (cnt[a[r]]++ == 0) ++have;      // 先判断再自增:这位画家第一次进窗口
        while (have == m) {                // 覆盖全部画家,开始缩短
            if (r - l + 1 < br - bl + 1) { bl = l; br = r; }   // 严格更短才更新 → 保证 x 最小
            if (--cnt[a[l]] == 0) --have;  // 先自减再判断:窗口里再没有这位画家的画
            ++l;                           // 左边界右移,窗口变短
        }
    }
    cout << bl << " " << br << "\n";
    return 0;
}
关键片段拆解
片段作用
if (cnt[a[r]]++ == 0) ++have;先拿旧值判断再自增:旧值 0 说明窗口里还没有这位画家,种类数 +1。
while (have == m)只有合法窗口才值得缩短;不合法时只能让 r 继续右移。
if (r - l + 1 < br - bl + 1)严格小于才更新。长度相同时保留先扫到的(它的 x 更小),这是题目要求的 tie-break。
if (--cnt[a[l]] == 0) --have;先自减再判断:减到 0 说明这位画家彻底离开窗口,种类数 −1。
++l;l 只前进不后退,这是总复杂度 O(n) 的唯一来源。
bl = 1, br = n;用整个数组作初值。因为保证有解,它一定会在某次 while 里被真正的合法区间替换。
MAXN / MAXM10⁶ 与 2000 的数组都放全局静态区,别写在 main 里(栈只有几 MB)。
这题最容易错的地方
  • 更新答案的位置放错:写在 while 外面,此时窗口已经不合法,长度偏小,答案必错。
  • 用扫一遍 cnt 数组的方式数种类,每次 O(m):n = 10⁶、m = 2×10³ 时 2×10⁹ 次,TLE。必须维护 have。
  • 长度相同时用 <= 更新,x 变大,输出不是 x 最小的解,WA。
  • 边界:m = 1 时答案应是 1 1;MAXM 开到 2000 而 m 恰为 2000 时 cnt[2000] 越界。开 2005 并把 m = 1 单独测一遍。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》双指针扫描(尺取法)部分
  • 《算法竞赛试炼场:洛谷300题精析》双指针专题
  • 洛谷 P1102、P1638 题解区的高赞推导
20 回顾四:贪心与前缀和普及-

20 · 回顾四:贪心与前缀和

把第 16~19 节压成四句判据:排序贪心看交换论证,区间问题看右端点,区间查询看前缀和、区间修改看差分,连续段最值看双指针。5 道黄题逐条验收,每题都给可提交代码与易错清单。
⭐ 已学范围回顾训练普及-建议 90 分钟回顾5 题

第 16~19 节讲的是四件事:贪心决定“选什么”,排序把选择的标准写成一行 cmp,前缀和 / 差分把“反复算一段区间”换成两三个端点的操作,双指针把“枚举所有区间”压成一条只会右移的窗口。四件工具共享同一个前提:后面要用的信息,能压成一个数。压不成,就只剩 DP 和搜索。

四件工具是四个“压缩包”:贪心压成一个排序键,前缀和压成一个累计量,差分压成两个端点,双指针压成一个窗口边界。
节内容一句话判据判据不成立时
16贪心入门:交换论证与排序贪心总代价能写成 Σ(值 × 位置系数),交换相邻两项不变差按性价比拿 0/1 背包,答案错
17贪心进阶:区间问题与堆区间调度按右端点升序;每次要取最小的两个就上堆按左端点排,选中的区间少一半
18前缀和与差分多次查询区间和用 pre;多次区间加、最后统一输出用 d混着用,复杂度降不下来
19双指针与滑动窗口右端点右移后,左端点只会右移、不回头窗口没有单调性,边缩边漏解

回顾章不讲新知识。下面先看一道能同时走通“前缀和”和“贪心”两条路的题,再用 5 道黄题把四件工具各打一遍——每题都给完整题面、提示、思路、代码和易错点。

例题 · 最大子段和洛谷 P1115普及/提高-去洛谷做这题 ↗

给出一个长度为 n 的序列 a,选出其中连续且非空的一段,使这段的和最大,输出这个最大和。

输入格式:第一行一个整数 n(1 ≤ n ≤ 2×10⁵);第二行 n 个整数 a_i(|a_i| ≤ 10⁴)。

输出格式:一行一个整数,即最大子段和。

输入: 7 2 -4 3 -1 2 -4 3 输出: 4

为什么拿它当例题:它表面是前缀和题,本质是贪心题。会写 pre 表的人很多,能说清“为什么只需要维护一个最小值”的人不多——后者才是第 18 节真正想留下的东西。而且它不到 20 行,改一个初值就能造出三种 WA,是最好的验错材料。

新函数拆解 · pre 与 minpre(前缀和 + 历史最小值)
pre[i] = pre[i-1] + a[i]; ans = max(ans, pre[i] - minpre); // minpre = min(pre[0..i-1]) minpre = min(minpre, pre[i]);
组成含义 / 格式要求
pre[i]前 i 个数的和,是累计量而不是原数组。区间和靠两个前缀相减得到。
pre[r] - pre[l-1]区间 [l, r] 的和。一次减法顶替一次长度为 len 的循环。
minpre已经扫过的所有前缀里的最小值(含 pre[0] = 0)。固定右端点 r 时,减数越小答案越大。
更新顺序先用旧的 minpre 算答案,再把 pre[i] 并进 minpre。顺序反了就等于允许空区间。
ans 初值必须取负无穷(LLONG_MIN)。子段非空,全负数时答案是“最大的那个负数”,不是 0。
量级n = 2×10⁵、|a| = 10⁴ 时 |pre| 可达 2×10⁹,已经贴住 int 上限,用 long long。
最小可运行示例
示例
long long pre = 0, minpre = 0, ans = LLONG_MIN;   // minpre 的初值 0 就是 pre[0]
for (int i = 1; i <= n; ++i) {
    pre += a[i];                    // pre 此刻等于 pre[i]
    ans = max(ans, pre - minpre);   // ① 先算答案,保证区间非空
    minpre = min(minpre, pre);      // ② 再把 pre[i] 并进历史最小值
}
// a = {2, -4, 3, -1, 2, -4, 3} → ans = 4,来自 3 + (-1) + 2
注意

把 ①② 换序:先 minpre = min(minpre, pre) 再算答案,减数可能取到 pre[i] 本身,等于允许空段。症状是全负数数据输出 0。

存了 pre 数组却每次从 1 到 r 找最小值,复杂度退回 O(n²)——minpre 的意义就是“顺手记下过去的最好值”。

新函数拆解 · d 数组(差分)与“正部之和”
d[i] = a[i] - a[i-1]; ans += max(0, a[i] - a[i-1]); // 只累加上升量
组成含义 / 格式要求
d[i]相邻两项的变化量。区间加 z 只需要 d[l] += z、d[r+1] -= z 两次单点修改。
还原对 d 求一遍前缀和才得到真实值。d 本身不是答案,它只是记账。
正部之和把一段序列“削平”(每次让一个连续区间减 1)的最少次数 = d[1] + Σ max(0, d[i] − d[i−1]),也就是所有上升量之和。
为什么每次操作最多消掉一个单位的高度差,所以“上升量之和”是下界;按“每逢上升就新开几次操作”的策略恰好取到这个下界,于是它也是上界。
数组大小要写 d[r+1],而 r 可以等于 n,所以开 n+2。
流式写法只求和时不用存数组:读一个数、与上一个比较,O(1) 空间。
最小可运行示例
示例
// 铺设道路:n 段深度 d[i],每次把一段连续区间每段减 1,求最少次数
int prev = 0;   long long ans = 0;      // prev 相当于 d[0] = 0
for (int i = 1; i <= n; ++i) {
    int d; cin >> d;
    if (d > prev) ans += d - prev;      // 只累加“上升”的部分
    prev = d;
}
// 4 3 2 5 3 5 → 4 + 3 + 2 = 9
注意

算成 Σ|d[i] − d[i−1]| 会把下降量也算进去,答案偏大。

忘掉 prev 的初值 0,第一段就少了 d[1] 次操作。

新函数拆解 · priority_queue 小根堆
priority_queue<int, vector<int>, greater<int>> pq;
组成含义 / 格式要求
三个模板参数元素类型、底层容器 vector、比较器 greater<int>(越小越优先)。少写后两个就是大根堆,行为正好相反。
pq.push(x)O(log n) 插入一个元素。
pq.top()O(1) 取出堆顶,即当前最小值。只读不删。
pq.pop()O(log n) 删除堆顶,没有返回值:必须 top 之后再 pop。
pq.size() / empty()元素个数 / 是否为空。size() 返回无符号数,与 int 比较时先转 int。
用在哪每次决策都要“取当前最小的一两个”——合并果子、哈夫曼编码、多路归并、以及第 17 节里“已经选过、但还能反悔”的贪心。
最小可运行示例
示例
priority_queue<int, vector<int>, greater<int>> pq;   // 小根堆
pq.push(5); pq.push(1); pq.push(3);

while (pq.size() >= 2) {
    int a = pq.top(); pq.pop();     // 最小
    int b = pq.top(); pq.pop();     // 次小
    pq.push(a + b);                 // 合并后的新堆放回去
}
注意

写成 priority_queue<int> 就是大根堆:合并果子会每次合并最大的两堆,答案偏大。

top 之后忘了 pop,循环里永远取到同一个值——不是死循环就是答案错。

新函数拆解 · sort + cmp:把贪心策略写成一行不等式
bool cmp(const Seg &a, const Seg &b); // true 表示 a 排在 b 前面
组成含义 / 格式要求
返回值 bool只回答“a 该不该排在 b 前面”,表达的是优先级,不是“大小”。
const Seg &引用传参,不拷贝也不修改。值传递每次比较多拷一份结构体。
主键区间调度按右端点升序,不是左端点。改这一个符号就是另一个算法,必须有证明撑着。
次键(兜底)主键相等时补一条次键,输出顺序才固定可复现。不补不算违反严格弱序(平局都返回 false 是合法的“等价”),只是结果依赖 sort 的内部顺序。
严格弱序cmp(a, a) 必须为 false;cmp(a,b) 与 cmp(b,a) 不能同时为 true。写成 <= 就破坏了它。
最小可运行示例
示例
struct Seg { int l, r; };

bool cmp(const Seg &x, const Seg &y) {
    if (x.r != y.r) return x.r < y.r;   // 主键:结束越早越优先
    return x.l < y.l;                   // 兜底:结束相同,开始早的在前
}

sort(v.begin(), v.end(), cmp);
int cnt = 0, last = -1;                 // last:上一场结束的时刻
for (auto &s : v) if (s.l >= last) { ++cnt; last = s.r; }
// 0 2 / 2 4 / 1 3 → 2
注意

把 cmp 写成 x.r <= y.r(平局也返回 true)才真的破坏严格弱序:sort 可能越界写内存,症状是 RE 或随机 WA。

cmp 里不要改数据、不要输出:sort 会调用它 O(n log n) 次。

例题怎么想:三条路,一条比一条快

路 1:暴力。枚举左右端点 l ≤ r,内层求和,O(n³);前缀和预处理后变 O(n²)。n = 2×10⁵ 时约 2×10¹⁰ 次加法,必然 TLE。

路 2:前缀和改写。区间和 = pre[r] − pre[l−1],于是答案 = max over r of ( pre[r] − min{ pre[j] : 0 ≤ j < r } )。这一步把“枚举两个端点”换成了“枚举一个端点 + 一个历史最小值”。

路 3:贪心维护。固定 r 时减数越小答案越大;而“r 之前的最小前缀”只由已经读过的数决定,与后面读什么无关——它不消耗未来资源、没有后效性,所以一遍扫描就够:O(n) 时间、O(1) 空间。

骨架只有 5 行
long long pre = 0, minpre = 0, ans = LLONG_MIN;
for (int i = 1; i <= n; ++i) {
    pre += x;                       // 边读边累加,连数组都不用存
    ans = max(ans, pre - minpre);
    minpre = min(minpre, pre);
}

两个初值决定 AC 还是 WA。① ans = LLONG_MIN:数组全负时答案是“最大的那个负数”,写成 0 会输出 0(空子段,非法)。② minpre = 0 代表 pre[0],让“从第一个数开始”的子段能被取到;再配合“先算答案、后更新 minpre”,就永远不会取到空区间。

完整代码 · O(n) 时间、O(1) 空间
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;

    ll pre = 0;            // 当前前缀和 pre[i]
    ll minpre = 0;         // 已经扫过的前缀里的最小值,初值 0 就是 pre[0]
    ll ans = LLONG_MIN;    // 子段必须非空,所以用负无穷而不是 0

    for (int i = 1; i <= n; ++i) {
        ll x;
        cin >> x;
        pre += x;                        // 读一个数就累加,省掉数组
        ans = max(ans, pre - minpre);    // ① 先算答案:右端点是 i,左端点 ≤ i
        minpre = min(minpre, pre);       // ② 再更新历史最小值,供后面的右端点使用
    }

    cout << ans << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
ios::sync_with_stdio(false);2×10⁵ 个数谈不上瓶颈,但这是所有竞赛代码的默认动作,别在这种地方养成例外。
typedef long long ll;下面几个变量的量级都能到 2×10⁹,统一用 ll,读代码时不必再逐个判断类型。
ll pre = 0;pre 就是 pre[i],只是不存整张表。第 18 节的 pre 数组在这里退化成一个变量。
ll minpre = 0;0 代表 pre[0](前 0 个数之和)。有了它,l = 1 的情况不用特判。
ll ans = LLONG_MIN;−9.22×10¹⁸,比任何可能的答案都小。换成 0 会在全负数据上错。
pre += x;累加。此时 pre 已经不是“前 i−1 个”,而是“前 i 个”,后面所有判断都用新值。
ans = max(ans, pre - minpre);以 i 结尾的最大子段和 = pre[i] − (i 之前最小的前缀)。此刻 minpre 还是旧值,所以减出来的一定非空。
minpre = min(minpre, pre);把当前前缀并入历史最小值。这一句放在答案更新之后,是本题唯一的顺序要求。
cout << ans << "\n";只要一个整数加换行。不要输出提示语,也不要在行末留空格。
题目里的信号该用哪件工具判据复杂度
顺序决定总代价(谁先谁后)排序贪心交换相邻两项,答案不变差O(n log n)
尽量多选互不重叠的区间区间调度按右端点升序,能放就放O(n log n)
每次都要取当前最小的两个小根堆需要“反悔”的选择交给堆O(n log n)
多次询问区间和前缀和运算可逆(有减法)O(n) 建表,O(1) 查
多次区间加,最后统一输出差分一次区间加 = 两个端点改O(1) 改,O(n) 还原
最长 / 最短的满足条件的连续段双指针、滑动窗口右端点右移后左端点不回头O(n)
第 17 节回头看一眼:区间调度为什么按右端点

两场活动能先后参加,条件是“前一场结束 ≤ 后一场开始”。每选一场,代价是这段时间被占用。

结束得越早,留给后面的时间越多——这正是“局部选择不消耗未来资源”。交换论证:设最优解第一场是 x,结束最早的是 e,因为 e 的结束 ≤ x 的结束,把 x 换成 e 后后面只会更宽松,所以存在含 e 的最优解。

反例记一下:按开始时间排序,遇到 (0, 100)、(1, 2)、(3, 4) 会先拿 (0, 100),只得 1 场,正确是 2 场。

代码只有三行:按 (右端点升序、左端点升序) 排序,last = -1,if (l >= last) { ++cnt; last = r; }。

这一章最容易踩的 7 个坑(每个都带症状)

最大子段和把 ans 初始化成 0:数组全负时输出 0。症状是全负数数据 WA、正数数据全过。

先更新 minpre 再算答案:等于允许空区间,全负数据同样输出 0。顺序错和初值错,症状一模一样,所以这两句要一起背。

前缀和 / 区间和用 int:n = 2×10⁵、|a| = 10⁴ 时 |pre| 可达 2×10⁹,只差 7% 就撞上 int 上限,再累加或再乘一次就溢出成负数 → WA。

差分数组只开 n 个:d[r+1] 在 r = n 时写到下标 n+1 → RE 或随机 WA。开 n+2。

区间调度按左端点排序:遇到“开始早、特别长”的区间就把后面全占掉。症状是答案偏小,样例却常常能过。

小根堆写成 priority_queue<int>:本该每次取最短的两段,变成取最长的两段,答案偏大 → WA。

双指针 while 里忘记移动下标,或把窗口条件写成“不满足才缩”:前者死循环 TLE,后者窗口收不紧、答案偏大。

自己动手复现

关掉页面,把上面 P1115 的完整代码从零敲一遍,提交后确认样例输出 4。

改一改①:把 ans = LLONG_MIN 改成 ans = 0,输入 5 / -3 -1 -4 -2 -5,看它输出什么——亲手见一次“空子段”的错。

改一改②:把 max 与 min 全反过来(ans = min(...)、minpre 换 maxpre、初值换 LLONG_MAX),求最小子段和。用例题样例 2 -4 3 -1 2 -4 3 应当得到 −4,验证对称性。

改一改③:额外输出最大子段的左右端点。提示:minpre 更新时顺手记下它所在的位置,答案被更新时记下当前的 i;想一想为什么“位置”不能事后补算。

这次训练怎么用 90 分钟

0~10 分钟:只读 5 道题,各写一个关键词(排序?前缀和?窗口?堆?),再按“能立刻写 → 有思路 → 没思路”排序。

10~25 分钟:A 题 P1094。最短的一道,练双指针手感,重点盯 l == r 这个边界。

25~45 分钟:B 题 P2240 与 C 题 P1334。一个考排序键(单价),一个考堆(把“锯”倒着看成“合并”),两题都不超过 35 行。

45~75 分钟:D 题 P5019。先手算 4 3 2 5 3 5 得 9,再把“只累加上升量”写成三行。

75~90 分钟:E 题 P1147。写不动就只核对循环边界、全局变量、行末空格,不写新代码。

题编号主要考什么最容易错在哪
AP1094排序 + 双指针的配对贪心普及-
BP2240排序贪心:按单价降序取满普及-
CP1334小根堆:把“锯”倒看成“合并”写成大根堆;用 int 存累计代价
DP5019差分的正部之和普及/提高-
EP1147双指针维护连续段的和循环写成 l <= r 输出单数段;找到一段就 break
提交前的 30 秒检查清单(本章专用)

答案要 long long 吗?凡是“求和”“乘积”,先按最坏情况估一遍量级。

数组开够了吗?差分要 n+2,计数数组要值域+5,10⁶ 级数组必须放全局(静态区)。

排序键是主键吗?区间题问自己一句:按左端点还是右端点?兜底次键写了吗?

三个初值对不对?minpre = 0、ans = LLONG_MIN、sum = 3, l = 1, r = 2(P1147 的初始窗口)——初值各错一次,就是三次 WA。

输出格式:行末有没有多余空格?多测题有没有清空数组和计数器?

本次训练 · 共 5 题

做题方式

限时 100 分钟。每题先自己写 20~25 分钟;写不出来先看提示,再给自己 10 分钟;还不行才点开思路。每题都独立限时,不要一题耗死。

大题 挑战题 1 · 纪念品分组
洛谷 P1094普及-去洛谷做这题 ↗

把 n 件纪念品分成若干组,每组最多两件,且组内价格之和不能超过 w。求最少分成几组。

输入格式:第一行一个整数 w(80 ≤ w ≤ 200),表示每组价格之和的上限;第二行一个整数 n(1 ≤ n ≤ 30000);接下来 n 行,每行一个整数 p_i(5 ≤ p_i ≤ w),表示一件纪念品的价格。

输出格式:一行一个整数,即最少的分组数目。

引导性提示(先只看这里)

先排序。最贵的那件最难找到搭档,应该让它第一个被安排掉。

最贵的能配上一件时,配最便宜的那件,还是配一件“刚好装得下”的?想想哪一件更难被安排。

把最便宜的放左端、最贵的放右端,两个下标往中间走,每次至少有一件被安排掉。

想看思路了?点开完整思路

贪心策略:排序后让最贵的 p[r] 先决定命运——若能配上最便宜的 p[l](p[l] + p[r] ≤ w),就配成一组;否则 p[r] 单独一组。

交换论证:设最优解里 p[r] 与 x 配对。若 x 不是最便宜的 p[l],因为 p[l] ≤ x,把 x 换成 p[l],和不会超限、组数不变。若最优解里 p[r] 单独一组,而 p[l] 与 y 配对,把这两组合并成 (p[r], p[l]) 和 (y) ,组数同样不变。所以“能配就配最便宜的”一定存在最优解。

双指针实现:l 从左、r 从右。每轮至少出局一件(配成一组出局两件),共 O(n) 轮;总复杂度是排序的 O(n log n)。

只剩一件(l == r)时不能自己配自己,直接单独一组,这也是最容易漏的边界。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int w, n;
    cin >> w >> n;
    vector<int> p(n);
    for (int i = 0; i < n; ++i) cin >> p[i];
    sort(p.begin(), p.end());          // 排序后两端才是“最便宜 / 最贵”

    int l = 0, r = n - 1, cnt = 0;
    while (l <= r) {
        if (l == r) { ++cnt; break; }               // 只剩一件:单独一组
        if (p[l] + p[r] <= w) { ++l; --r; }         // 能配就配,两件一起出局
        else { --r; }                              // 配不上,最贵的单独一组
        ++cnt;
    }

    cout << cnt << "\n";
    return 0;
}
关键片段拆解
片段作用
sort(p.begin(), p.end());贪心的两个决策对象就在两端:最便宜的、最贵的。排序是全部代价。
while (l <= r)两个下标相遇即结束。每轮至少出局一件,所以最多 n 轮。
if (l == r) { ++cnt; break; }区间只剩一件时不能自己和自己配对,直接单独一组;漏掉这一句要么死循环要么少算一组。
if (p[l] + p[r] <= w)能配就配。连最便宜的都配不上最贵的,说明最贵的和谁都配不上。
else { --r; }配不上时最贵的只能单独一组,右端点左移一件。
++cnt;两个分支都消耗一组,统一在分支外计数,避免两处各写一次。
这题最容易错的地方
  • 用静态数组却照着 w ≤ 200 开成 p[205]:n 最多 30000,越界 → RE。用 vector 或开 30005。
  • 配不成时忘记 --r,循环不动 → TLE。
  • 把 <= w 写成 < w:和恰好等于上限的组合被拆成两组,答案偏大。
  • 以为“每组最多两件”就是两两配对:n 为奇数时最后一件必须单独一组。
大题 挑战题 2 · 部分背包问题
洛谷 P2240普及-去洛谷做这题 ↗

有 n 种物品和一个容量为 t 的背包。第 i 种物品重 m_i、总价值 v_i。与 0/1 背包不同,这里的物品可以分割:允许只取其中一部分,按比例拿到对应的价值。求背包能装下的最大总价值。

输入格式:第一行两个整数 n、t(1 ≤ n ≤ 100,1 ≤ t ≤ 1000);接下来 n 行,每行两个整数 m_i、v_i(1 ≤ m_i, v_i ≤ 100)。

输出格式:一行一个实数,表示最大总价值,保留两位小数。

引导性提示(先只看这里)

先把“该先装谁”变成一个能算的量:每单位重量值多少钱。

如果用一个单价低的东西挤掉了一部分单价高的,交换之后总价值会变多还是变少?

排序后从前往后拿;遇到装不下的那一种,能拿多少拿多少,然后立刻停。

想看思路了?点开完整思路

贪心策略:按单价 v_i / m_i 从大到小排序,依次尽量取满;遇到装不下的那一种,只取剩余容量的那一份,然后结束。

交换论证:设最优解里取了一部分单价较低的物品 B,而单价较高的物品 A 没有取完。把 B 的一份重量换成 A 的一份重量:总重量不变,总价值不减。所以一定存在一个“按单价降序取满”的最优解。

为什么这题能贪、0/1 背包不能:物品可以切开,“取多少”是连续的,交换时总能换出任意小的量;0/1 背包只能整件拿,交换会破坏可行性。这正好落在第 16 节那张判据表的“是否消耗未来资源”上。

实现要点:排序比较用交叉相乘 a.v * b.m > b.v * a.m,不写除法;答案用 double,最后一种物品按重量比例折算。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

struct Item { int m, v; };

bool cmp(const Item &a, const Item &b) {
    // 单价降序:a.v/a.m > b.v/b.m,交叉相乘,避开浮点误差
    return 1LL * a.v * b.m > 1LL * b.v * a.m;
}

int main() {
    int n, t;
    cin >> n >> t;
    vector<Item> a(n);
    for (int i = 0; i < n; ++i) cin >> a[i].m >> a[i].v;
    sort(a.begin(), a.end(), cmp);

    double ans = 0;
    for (int i = 0; i < n; ++i) {
        if (t >= a[i].m) {                  // 装得下就整件拿
            t -= a[i].m;
            ans += a[i].v;
        } else {                            // 只能拿剩余容量的那一份
            ans += 1.0 * a[i].v * t / a[i].m;
            t = 0;
            break;                          // 背包已满,后面的单价更低,不用再看
        }
    }

    printf("%.2f\n", ans);
    return 0;
}
关键片段拆解
片段作用
bool cmp(const Item &a, const Item &b)只比较单价,不改数据。返回 true 表示 a 的单价更高、应排在前面。
1LL * a.v * b.m > 1LL * b.v * a.m交叉相乘代替除法:两边都是整数,比较精确;写成 a.v/a.m 会把 2/3 和 3/5 都算成 0。
if (t >= a[i].m)装得下就整件拿——这就是“单价最高优先”的直接体现。
ans += 1.0 * a[i].v * t / a[i].m;只装一部分:按重量比例折算价值。先乘 1.0 转浮点,否则整数除法把小数截掉。
break;容量已满,继续循环不会再加东西,反而可能让 t 变成负数。
printf("%.2f\n", ans);两位小数。一次性格式化输出用它最省事,不受 cout 流状态影响。
这题最容易错的地方
  • cmp 里写 a.v / a.m > b.v / b.m:整数除法把 2/3 与 3/5 都算成 0,排序结果乱。
  • 把最后一种物品也整件累加:剩余容量装不下整件,答案偏大 → WA。
  • 把本题当 0/1 背包去写 DP:物品本可分割,按整件拿会把答案算小 → WA。
  • 忘记保留两位小数,或用 cout 的默认精度输出 240 而不是 240.00 → WA。
大题 挑战题 3 · 瑞瑞的木板
洛谷 P1334普及-去洛谷做这题 ↗

瑞瑞要把一根长木板锯成 n 段,第 i 段长 L_i。木板的初始长度就是这 n 段之和。每次锯开一块木板,代价等于这块木板锯之前的长度,锯完得到两块更短的木板。求锯成这 n 段所需的最小总代价。

输入格式:第一行一个整数 n(1 ≤ n ≤ 20000);接下来 n 行,每行一个整数 L_i(1 ≤ L_i ≤ 50000)。

输出格式:一行一个整数,即最小总代价。

引导性提示(先只看这里)

把过程倒过来看:锯是“一块变两块”,倒过来就是“两块并成一块”,代价同样是并完的长度。

倒过来之后,它和 17 节的合并果子是同一道题:每次合并最短的两段。

合并出来的新段还要继续参与合并,所以它必须放回容器里。

想看思路了?点开完整思路

反向思考:锯一次,一块长 S 的木板的代价是 S,得到长 a、b 的两块,且 S = a + b;倒过来看,就是“把 a、b 两块并成 S,代价 S = a + b”。两种过程的总代价一一对应。

于是问题等价于:每次合并最短的两段,直到只剩一段,求最小总代价——这就是哈夫曼(合并果子)。第 17 节已经证过:若某次合并的不是最短的两段,换成最短的两段,本次代价变小,而且新段更短,对后续只会更有利。

复杂度:n − 1 次合并,每次两次 pop 一次 push,O(n log n);n = 2×10⁴ 时不到 3×10⁵ 次堆操作。

量级:每段 5×10⁴、n = 2×10⁴ 时累计代价可达 10¹⁰ 量级,堆和答案都用 long long。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;

    // 小根堆:每次都要取最短的两段
    priority_queue<long long, vector<long long>, greater<long long>> pq;
    for (int i = 0; i < n; ++i) {
        long long x;
        cin >> x;
        pq.push(x);
    }

    long long ans = 0;
    while (pq.size() >= 2) {
        long long a = pq.top(); pq.pop();   // 最短的一段
        long long b = pq.top(); pq.pop();   // 次短的一段
        ans += a + b;                       // 合并代价 = 拼接后的长度
        pq.push(a + b);                     // 新段放回去,继续参与合并
    }

    cout << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
priority_queue<long long, vector<long long>, greater<long long>> pq;堆里存段长,用 long long;比较器换成 greater 才是小根堆,否则每次取到的是最长的一段。
while (pq.size() >= 2)n 段要合并 n − 1 次,循环次数正好对上;n = 1 时一次都不做,答案 0。
long long a = pq.top(); pq.pop();先取再删,两句不能合成一句:pop() 没有返回值。
ans += a + b;这次“合并”的代价等于新段长度,也就是原来那次“锯”的代价。
pq.push(a + b);新段必须回堆,否则下一次取到的还是旧值,答案偏小。
这题最容易错的地方
  • 把代价理解成“每段最终长度之和”:那样答案是 sum(L) = 22,与样例的 44 对不上 → WA。
  • 用 int 存堆和答案:累计代价可到 10¹⁰,溢出成负数 → WA。
  • 忘记把合并后的新段 push 回堆:n > 2 时结果必然偏小。
  • 写成大根堆(少写 greater):每次合并最长的两段,答案偏大 → WA。
大题 挑战题 4 · 铺设道路
洛谷 P5019普及/提高-去洛谷做这题 ↗

一条道路分 n 段,第 i 段下陷深度为 d_i。每次操作可以选一段连续区间 [l, r],把区间内每段的深度都减 1。求把整条道路填平(所有 d_i 变为 0)的最少操作次数。

输入格式:第一行一个整数 n(1 ≤ n ≤ 100000);第二行 n 个整数 d_i(0 ≤ d_i ≤ 10000)。

输出格式:一行一个整数,即最少操作次数。

引导性提示(先只看这里)

先想 n = 1:答案是 d_1。再想 n = 2、d = 4 3:答案是多少?和 4 4 比差在哪?

从第 i−1 段走到第 i 段,如果变深了,多出来的那一截必须靠新开的操作去削。

把所有“上升量”加起来:d = 4 3 2 5 3 5 时答案是 9 吗?

想看思路了?点开完整思路

换个说法(差分):令 d_0 = 0,答案 = Σ max(0, d_i − d_{i−1}),即所有上升量之和。

下界:每次操作只让“某一段连续区间”各减 1,因此一个上升沿有多高,就至少需要那么多次操作去削平它;把所有上升沿加起来,就是任何方案都逃不掉的下界。

可达:从左往右扫,遇到 d_i > d_{i−1} 就新开 d_i − d_{i−1} 次操作,并让这些操作与前面仍有“余量”的操作并行推进——每个位置都能恰好被削到 0。下界可达,即答案。

实现:连数组都不用开:读一个数与上一个比较,只累加上升量,O(n) 时间、O(1) 空间。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;

    long long ans = 0;   // 答案上限 10^5 × 10^4 = 10^9,long long 更稳
    int prev = 0;        // 相当于 d[0] = 0

    for (int i = 1; i <= n; ++i) {
        int d;
        cin >> d;
        if (d > prev) ans += d - prev;   // 只累加“上升”的部分
        prev = d;
    }

    cout << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
int prev = 0;pre 数组的 pre[0] 在这里换成了 prev 的初值。少了它,第一段就漏掉 d_1 次操作。
if (d > prev) ans += d - prev;上升量 = 新开操作数。下降时一句都不加:那部分由前面开好的操作顺手削掉。
prev = d;处理完立刻前移。注意是赋值,不是累加。
long long ans本题 int 也够(10⁹),但同一份代码换到 d ≤ 10⁹ 就溢出,习惯用 long long。
cout << ans << "\n";一个整数加换行,不要输出多余内容。
这题最容易错的地方
  • 算成 Σ|d_i − d_{i−1}|:把下降量也算进去,答案偏大。症状是样例 4 3 2 5 3 5 输出 13 而不是 9。
  • prev 初值漏写(或写成第一段的值):第一段贡献丢失 → WA。
  • 把“每次必须选一段连续区间”看成“每次可以任意挑若干段”:那样答案会变成 max(d),和本题差一个数量级。
  • 先存数组再做差分再前缀和:结果一样,但多开 10⁵ 的空间,纯属绕路。
大题 挑战题 5 · 连续自然数和
洛谷 P1147普及-去洛谷做这题 ↗

给定一个自然数 M,求出所有至少包含两个数的连续自然数段,使段内所有数之和恰好等于 M。按首数从小到大输出每一段的首数和尾数。

输入格式:一行一个整数 M(10 ≤ M ≤ 2×10⁶)。

输出格式:若干行,每行两个整数,依次为这一段的首数与尾数,按首数升序排列。

引导性提示(先只看这里)

固定首数 l,段和随尾数 r 增大而增大——所以对每个 l 至多只有一个 r 能用。

更省事的写法:维护一个连续段 [l, r] 的和。和太小就让右端右移,和太大就让左端右移。

题目要求每段至少两个数,所以 l == r 时即使和恰好是 M 也不能输出。

想看思路了?点开完整思路

双指针:维护窗口 [l, r] 的整数和 sum,初始 l = 1、r = 2、sum = 1 + 2 = 3。

sum < M:右端右移一格,sum 加上新的 r。sum > M:左端右移一格,sum 减去旧的 l。sum == M:输出 l、r,再把左端右移,继续找下一段。

为什么 O(M):l 与 r 都只增不减,各自最多走到 M,总移动次数 O(M);M = 2×10⁶ 时不到 10⁷ 次加减,几十毫秒就完。

为什么不会漏:固定 l 时 sum 随 r 严格递增,一旦 sum ≥ M 就没有更长的段适合这个 l,只能右移 l——这就是“窗口单调”的全部依据。

边界:循环写 while (l < r)。l == r 时窗口只剩一个数,题目不允许;而且此时任何以 l 开头的更长的段之和都已超过 M。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int M;
    cin >> M;

    int l = 1, r = 2;
    long long sum = 3;              // 当前窗口 [l, r] 内所有数之和

    while (l < r) {                 // l == r 表示只剩一个数,题目要求至少两个数
        if (sum == M) {
            cout << l << " " << r << "\n";
            sum -= l;
            ++l;                    // 这一段找完,左端右移继续找下一段
        } else if (sum < M) {
            ++r;                    // 先让 r 前进
            sum += r;               // 再把新的 r 纳入窗口
        } else {
            sum -= l;               // 和太大,把左端移出窗口
            ++l;
        }
    }

    return 0;
}
关键片段拆解
片段作用
int l = 1, r = 2; long long sum = 3;最短的合法段是 1 + 2,从它开始。sum 用 long long,换更大的 M 也不用改。
while (l < r)l == r 时窗口只有一个数,不合法,直接结束循环。
if (sum == M)找到一段,输出首尾两个数。这里不能 break——还有别的段。
sum -= l; ++l;“相等”和“过大”两种分支都要收缩左端,写的动作完全一样。
++r; sum += r;顺序不能反:先把 r 加 1,再把新的 r 加进 sum;反过来会加进旧的 r,整条和都错位。
cout << l << " " << r << "\n";行内一个空格、行末只换行。
这题最容易错的地方
  • 循环写成 while (l <= r):M = 10 时会多输出一行 10 10(单数段)→ WA。
  • 找到一段就 break:M = 10000 有 4 段,只输出第一段 → WA。
  • 把 ++r; sum += r; 写成 sum += r; ++r;:加进去的是旧的 r,和永远对不上。
  • 用 int 存更大的 M 的和(同类题 M 可到 10⁹):溢出成负数,窗口判断全部失效 → WA。
做完之后自问
  • 能在 1 分钟内写出 pre 的建表与查询,以及“维护最小前缀求最大子段和”的那五行。
  • 能说清 minpre 的更新为什么必须放在计算答案之后,并举出写反时出错的那组数据。
  • 能默写差分的一次区间加(两个端点),并说出“削平次数 = 上升量之和”的下界与可达性各是什么。
  • 能解释区间调度为什么按右端点而不按左端点排序,并自己造一个反例。
  • 能手写小根堆的完整声明,并说清 top 与 pop 的分工(为什么 pop 没有返回值)。
  • 能用一句话说出滑动窗口为什么是 O(n):l 与 r 各自只向右移动,总计不超过 2n 步。
  • 拿到一道新题,能在 30 秒内决定往这四件工具里的哪一件上想,并说出判据。
延伸阅读
  • 《算法竞赛进阶指南》第 0x07 节:贪心(邻项交换、区间调度)
  • 《算法竞赛试炼场:洛谷 300 题精析》贪心与前缀和部分
  • 洛谷 P1115、P1094、P2240、P1334、P5019、P1147 题解区的高赞证明
  • 《算法竞赛黄金法则》:先把判据写下来,再动手写代码
21 递归:出口、递推与栈普及-

21 · 递归:出口、递推与栈

把问题照原样拆成同类的小问题:写清出口、保证参数变小、数清递归树的深度与节点数,并知道什么时候必须改成循环。
⭐ 已学范围普及-预计 60 分钟递归递归树爆栈
本节出现的新函数 / 新语法(先看懂再用)

把一个规模为 n 的问题,交给规模更小的同类问题去解决——这就是递归。它能停下来,只因为存在一个不需要再拆的小情形。

先看两个错法,它们分别代表递归的两类死法。

两个错法
int f(int n) { return f(n - 1) + 1; }          // ① 没有出口

int g(int n) {                              // ② 有出口,但同一件事算了又算
    if (n <= 2) return 1;
    return g(n - 1) + g(n - 2);
}

① 的调用链 f(n) → f(n−1) → f(n−2) → …永不停,栈一直往下压,直到用完栈空间,程序收到 SIGSEGV(Windows 上是 0xC00000FD),评测判 RE。② 有出口,但 g(n−1) 展开时已经把 g(n−2)、g(n−3) 都算过一遍,外面又为 g(n−2) 把这一整批下标重新算一遍。调用次数是 φⁿ 级——g(40) 约 3×10⁸ 次,g(50) 到 4×10¹⁰ 次,判 TLE。小数据它跑得飞快,这才是最坑的地方。这个“重复计算”的毛病下一节用记忆化治。

写递归只问三句话:① 最小的情形,答案是多少?② n 的答案能不能用更小的答案拼出来?③ 每次调用,参数是不是真的变小了?三句话答不上任何一句,代码一定是错的。

这一节有三张卡要先看懂:递归函数长什么样、调用一次在内存里留下什么、assert 怎么帮你定位错误。

新函数拆解 · 递归函数
返回类型 f(参数) { if (出口条件) return 基础值; // ① 出口 ... // ② 把问题变小 return f(更小的参数); // ③ 自己调自己 }
组成含义 / 格式要求
出口(base case)不再递归、直接给答案的最小情形。必须存在,且必须能到达。
递推(recursive step)用 f(更小的参数) 表示 f(参数)。这个等式就是数学上的递推关系,照抄即可。
参数必须变小n−1、n/2、左半区间……只要保证每次调用都向出口靠近。参数不变就是无限递归。
返回类型递归函数首先是函数,返回值能被上一层接住、参与运算。要算数就用 int/long long,只做事就写 void。
调用深度从 main 到最底层出口的调用层数。它由“参数从 n 减到出口要走几步”决定,与调用总次数无关。
返回值累加多层结果靠 return 一层层往上传;想往下传就加一个参数(例如当前深度)。
最小可运行示例
示例
long long fact(int n) {              // 求 n!
    if (n <= 1) return 1;            // ① 出口:0! = 1! = 1
    return (long long)n * fact(n - 1);   // ② ③ n! = n × (n-1)!
}

int main() {
    cout << fact(5) << "\n";         // 120
    return 0;
}
注意

fact 的调用链是 fact(5)→fact(4)→…→fact(1),深度 5。语句 return n * fact(n-1)里,“乘 n”这一步要等下层返回后才执行,所以它发生在“回来的路上”。

递归不是“循环的替代品”,它是“把问题定义照抄成代码”。数学定义里有 f(n)=n·f(n−1),代码里就有一行一样的。

新函数拆解 · 栈帧(每次调用在内存里留下的一格)
调用 f → 压入一帧:形参、局部变量、返回地址 f 返回 → 弹出这一帧,返回值交给调用者
组成含义 / 格式要求
一帧装什么这个函数自己的形参、局部变量,以及“返回后从哪一行继续执行”的地址。
一帧多大通常 32~64 字节。函数里定义了局部数组会猛涨:写一句 int t[1000];,每帧立刻多 4KB。
栈有多小全局数组、new/malloc 在堆上,能用几百 MB;函数调用和局部变量在栈上,Linux 常见 8MB,Windows 默认 1MB。
算深度8MB ÷ 48B ≈ 1.7×10⁵ 层。所以递归深度几千是安全的,几十万就要出事,一百万一定 RE。
爆栈症状Windows 弹 0xC00000FD,Linux 收 SIGSEGV,洛谷上都显示 RE;也可能是“输出到一半突然停住”。
与调用次数的区别调用 10⁶ 次、深度 10 的递归完全安全;调用 10⁶ 次、深度 10⁶ 的递归必炸。
最小可运行示例
示例
long long fact(int n) {
    if (n <= 1) return 1;
    return (long long)n * fact(n - 1);   // 每层一帧
}

// fact(100000);   深度 10⁵,约 5MB,8MB 栈刚好够
// fact(1000000);  深度 10⁶,约 50MB,必 RE
注意

把 const int MAXN = 1e6; 的数组写进函数里,是最常见的爆栈原因——递归还没开始,一帧就已经 4MB。大数组一律开在全局。

深度到 10⁵ 时,先想能不能改迭代;实在要递归,就把栈调大(ulimit -s 或编译选项),但那只是把上限往后推。

新函数拆解 · assert(断言)
assert(条件表达式);
组成含义 / 格式要求
头文件<cassert>,bits/stdc++.h 里已经包含,不用另写。
语义条件为真 → 什么都不做;条件为假 → 打印表达式、文件名、行号,然后 abort() 结束程序。
用在哪在递归入口写 assert(k >= 0),把“这个参数不该是负数”这个假设变成一条会喊的检查。
怎么关掉编译时定义 NDEBUG(g++ -DNDEBUG)后所有 assert 消失,提交时零开销。
别写副作用assert 里的表达式在 NDEBUG 下根本不执行,所以 assert(f(x)++) 这种写法是陷阱。
别当输入校验输入不合法时应该输出答案,而不是让程序崩在断言上。assert 是调试工具,不是容错机制。
最小可运行示例
示例
long long fact(int n) {
    assert(n >= 0 && n <= 20);   // 21! 就超出 long long 了
    if (n <= 1) return 1;
    return (long long)n * fact(n - 1);
}

// fact(-1) → Assertion `n >= 0 && n <= 20' failed. 程序立刻停下
注意

assert 最大的价值是“把隐含前提写出来”。递归里最常见的隐含前提是“下标不越界”“深度没超限”,这两条写成断言,调试时间能从一小时缩到一分钟。

本地加 assert、提交前不用删——NDEBUG 一开它们就没了。

递归迭代(循环)
代码形状照抄数学定义,n 的方案里出现 n−1手工维护一个“当前状态”变量
空间深度 × 每帧字节,受栈大小硬限制O(1),与规模无关
速度每层一次函数调用,慢 2~5 倍最快
适合结构本身嵌套:树、分治、括号、嵌套表达式一条链、顺序一眼可见:累加、递推
主要风险爆栈(RE)、重复计算(TLE)下标写错、边界写错(WA)
例题 · 赦免战俘洛谷 P5461普及-去洛谷做这题 ↗

战俘排成 2ⁿ × 2ⁿ 的方阵(0 ≤ n ≤ 10),格子按 (行, 列) 编号,从 1 开始。

赦免规则:对任意一个 2ᵏ × 2ᵏ(k ≥ 1)的方块,把它等分成四个 2ᵏ⁻¹ × 2ᵏ⁻¹ 的小方块,左上角那一块的人全部赦免(这一块不再细分),另外三块各自继续按同一条规则处理。

输出最终方阵:被赦免的格子输出 0,没被赦免的输出 1。共 2ⁿ 行,每行 2ⁿ 个数,用空格分隔。

输入: 3 输出: 0 0 0 0 0 0 0 1 0 0 0 0 0 0 1 1 0 0 0 0 0 1 0 1 0 0 0 0 1 1 1 1 0 0 0 1 0 0 0 1 0 0 1 1 0 0 1 1 0 1 0 1 0 1 0 1 1 1 1 1 1 1 1 1
例题怎么想:把“一整块”当作一个参数

规则本身就是递归的:“2ᵏ 的方块”要按同一条规则处理四个 2ᵏ⁻¹ 的方块。所以函数签名直接写 solve(x, y, k),含义是处理左上角在 (x, y)、边长 2ᵏ 的方块。这一句话必须能独立说清,后面每次调用都只信这句话,不再往上想。

三要素对齐:出口是 k == 0(边长 1,没得再分);递推是先把自己左上角那半块涂成 0,然后对另外三块各调一次 solve(..., k - 1);规模是 k 每次减 1,一定到 0。

递归树长这样(n = 3 时):

递归树:三叉,深度 n
solve(1,1,3)                        涂 4×4,然后 3 个子调用
├─ solve(1,5,2)   右上        涂 2×2
│  ├─ solve(1,7,1)            涂 1 格
│  ├─ solve(3,5,1)
│  └─ solve(3,7,1)
├─ solve(5,1,2)   左下
│  ├─ solve(5,3,1)
│  ├─ solve(7,1,1)
│  └─ solve(7,3,1)
└─ solve(5,5,2)   右下
   ├─ solve(5,7,1)
   ├─ solve(7,5,1)
   └─ solve(7,7,1)

拿到递归树就问三个量:深度= n(决定栈深度);节点数= 1+3+9+…+3ⁿ(决定调用次数);每个节点自己干多少活= 涂 4ᵏ⁻¹ 格。这三样乘起来就是复杂度,下面数学补给里会算到精确值 Θ(4ⁿ)。

还有一个顺序上的自由:三块互不重叠,谁先谁后都行。这一点很重要——它说明递归版和迭代版必然给出同一张图,后面“递归转迭代”就靠它。

完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1 << 10;         // 2^10 = 1024,n ≤ 10 时的最大边长
int a[MAXN + 5][MAXN + 5];       // 必须放全局:1024² 个 int = 4MB,放栈上直接炸
int N;                            // 边长 2^n

// 处理左上角 (x,y)、边长 2^k 的方块
void solve(int x, int y, int k) {
    assert(k >= 0);               // 调试用:k 不该变成负数
    if (k == 0) return;           // 出口:边长 1,没有可分的
    int h = 1 << (k - 1);         // 半边长 2^(k-1)
    for (int i = x; i < x + h; ++i)
        for (int j = y; j < y + h; ++j)
            a[i][j] = 0;          // 左上角那半块整体赦免

    solve(x + h, y,     k - 1);   // 右上块:行下移 h
    solve(x,     y + h, k - 1);   // 左下块:列右移 h
    solve(x + h, y + h, k - 1);   // 右下块:行、列都移 h
}

int main() {
    ios::sync_with_stdio(false);  // 最多 10⁶ 个数要输出,必须提速
    cin.tie(nullptr);

    int n; cin >> n;
    N = 1 << n;
    for (int i = 1; i <= N; ++i)
        for (int j = 1; j <= N; ++j)
            a[i][j] = 1;          // 先假设谁都没被赦免

    solve(1, 1, n);               // 只负责把该是 0 的格子涂成 0

    for (int i = 1; i <= N; ++i)
        for (int j = 1; j <= N; ++j)
            cout << a[i][j] << (j == N ? '\n' : ' ');
    return 0;
}
逐行拆解
代码片段含义与理由
const int MAXN = 1 << 10;1024。1 << k 就是 2 的 k 次方,比 pow(2, k) 快且没有浮点误差。
int a[MAXN + 5][MAXN + 5];4MB 出头。写在 main 里会放栈上,栈只有 1~8MB,n = 10 时必 RE——这是本节第一号坑。
void solve(int x, int y, int k)签名本身就是问题的定义:哪一块、多大。三个参数缺一个都描述不清。
assert(k >= 0);把“k 不会变负”写下来。真出 bug 时它会立刻停下并报行号,比输出一堆错图更好查。
if (k == 0) return;出口放最前面,且判断的是“最小的规模”。漏了它,h = 1<<(-1) 就是未定义行为。
int h = 1 << (k - 1);半边长。k = 1 时 h = 1,此时涂的就是 1 格,正好对上 n = 1 的图。
for (i = x; i < x + h; ++i)只扫左上角 h×h。注意是 < x + h,写成 <= 会多涂一行。
a[i][j] = 0;递归函数只做“涂 0”一件事。0 和 1 谁表示赦免不重要,重要的是入口统一:先全 1,再涂 0。
solve(x + h, y, k - 1);右上块。三块的顺序可以随便换,因为它们的格子互不重叠——这是“递归树能拆开算”的依据。
a[i][j] = 1;初始化。全局数组默认是 0,不写这一句,整张图全是 0,样例第一行就对不上。
(j == N ? '\n' : ' ')行末只换行不加空格。10⁶ 个数,格式错一处就是整片 WA。

同一件事换一种遍历顺序:递归是“先钻到底再回来”(深度优先),迭代可以“按块的大小从大到小”一层一层涂(宽度优先)。两种顺序结果相同,因为不同层的涂色位置互不重叠:被递归跳过的方块整块已经在上一层变成 0,再涂一遍也还是 0。

递归转迭代
// 迭代版:把递归改成按层扫描,不写函数调用
for (int k = n; k >= 1; --k) {          // 块的大小从 2^n 降到 2
    int len = 1 << k, h = len >> 1;
    for (int x = 1; x + len - 1 <= N; x += len)
        for (int y = 1; y + len - 1 <= N; y += len)
            for (int i = x; i < x + h; ++i)
                for (int j = y; j < y + h; ++j)
                    a[i][j] = 0;
}

代价对比:递归版一共涂 4ⁿ − 3ⁿ = 989527 格(n = 10),迭代版把一些已经是 0 的格子又涂了一遍,共 2621440 次赋值,是前者的 2.65 倍。这点差别只是常数,不改变结论——换来的是彻底摆脱栈深度的限制,代价几乎为零。

这一节的 6 个典型错法(附症状)

RE,且只在 n 大的时候:把 int a[1029][1029] 写进了 main。4MB 局部数组直接超出栈限额。放到全局(函数外面)即可。

WA,整张图几乎全是 0:涂色循环写成 i <= x + h 或 j <= y + h。每块多涂一行一列,多出来的那行一列又会被下一层继续“传染”,n = 3 时最终输出全 0。边界条件只差一个等号,症状却比想象中惨烈得多。

RE / 无限递归:三句递归调用里有一句参数没变(比如写成 solve(x, y, k)),或者出口写成 if (k == 0) return; 却把参数改成 k - 1 之外的东西。本地表现为程序卡死后崩,评测显示 RE。

WA 全 0:忘了初始化 a[i][j] = 1。全局数组默认全 0,递归只负责涂 0,于是整张图全 0。

WA,输出尺寸不对:把 N = 1 << n 写成 N = n。n = 3 时输出 3×3 而不是 8×8。

TLE:10⁶ 个数用 endl 换行,或者没写 ios::sync_with_stdio(false)。

递归专属的三个坑(和本题无关,但每次写递归都会遇到)

RE(爆栈):递归深度 10⁶ 却每帧还带一个局部数组。int t[1000] 让每帧多 4KB,25 层就吃掉 100KB,几千层就炸。

TLE(重复计算):f(n) = f(n-1) + f(n-2) 直接递归,n = 40 时调用约 3×10⁸ 次。调用次数是 φⁿ 级别,不是 n 级别——这种“看着像 O(n)”的错觉是本组最大的坑。

WA(返回值类型太小):递归结果一路 return 上来,中间任何一层用了 int,结果就在那一层被截断。阶乘 21! 就超 int,用 long long。

自己动手复现

关掉页面,从空文件把 P5461 敲一遍并提交,n = 10 也要在 1 秒内出结果。

改一改 ①:把 int a[MAXN+5][MAXN+5]; 挪进 main,原样提交,亲眼看一次 RE,再把 MAXN 改成 1 << 8 让它“能跑”——这就是随机 WA 的来源。

改一改 ②:把 assert(k >= 0) 改成 assert(k > 0),输入 3,看断言在哪一行触发、打印了什么。这一分钟能让你以后一眼认出断言失败。

改一改 ③:把 solve 整段删掉,换成上面的迭代版,答案必须和样例逐字相同。

改一改 ④:加一句统计,输出最终有多少个 1。n = 1、2、3、4 分别得到 3、9、27、81,猜出它等于 3ⁿ 再往下看数学补给。

数学补给 · 递归树:节点数是时间,深度是空间

把每次函数调用画成一个点,它调用的子调用画成孩子,得到一棵递归树。树上三个量分别对应三个问题:

量对应什么赦免战俘里是多少
深度 h最大栈帧数,决定会不会爆栈n(最多 10)
节点总数函数调用总次数(3ⁿ⁺¹ − 1) / 2,n = 10 时 88573
第 k 层节点内的工作量每次调用自己做的循环4ᵏ⁻¹ 次赋值

节点数用等比数列求和:1 + 3 + 9 + … + 3ⁿ = (3ⁿ⁺¹ − 1) / 2。n = 10 代入得 (177147 − 1)/2 = 88573 次调用。

总工作量要按层加权。第 k 层有 3ⁿ⁻ᵏ 个节点,每个节点涂 (2ᵏ⁻¹)² = 4ᵏ⁻¹ 格,所以

总工作量
        n
T(n) =  Σ  3^(n-k) · 4^(k-1)                 // 等比数列,公比 4/3
       k=1

     =  3^(n-1) · [1 + 4/3 + (4/3)^2 + … + (4/3)^(n-1)]

     =  3^(n-1) · ((4/3)^n − 1) / (4/3 − 1)

     =  4^n − 3^n

n = 10 时 4¹⁰ − 3¹⁰ = 1048576 − 59049 = 989527。它比格子总数 4¹⁰ = 1048576 还小,正好说明每个格子只被涂一次——这是“复杂度恰好等于输出规模”的好算法的标志。

同一个式子也能用递归方程写:T(k) = 3T(k−1) + 4ᵏ⁻¹,T(0) = 0。猜 T(k) = 4ᵏ − 3ᵏ 代进去验证:左边 4ᵏ − 3ᵏ,右边 3(4ᵏ⁻¹ − 3ᵏ⁻¹) + 4ᵏ⁻¹ = 3·4ᵏ⁻¹ + 4ᵏ⁻¹ − 3ᵏ = 4ᵏ − 3ᵏ,相等。所以 T(k) = Θ(4ᵏ) = Θ(N²),N = 2ⁿ。

还有一个更快的写法:把 4ᵏ 提出来,令 U(k) = T(k)/4ᵏ,则 U(k) = (3/4)U(k−1) + 1/4,解得 U(k) = 1 − (3/4)ᵏ。这个“先归一化再解”的手法,是处理“子问题不缩半、而是减一”的递推式的通用招。

换成 N 的语言,递推式就是 T(N) = 3T(N/2) + Θ(N²)。这正是主定理的形式 T(N) = aT(N/b) + O(Nᵈ):a = 3、b = 2、d = 2,比较 log₂3 ≈ 1.585 与 d = 2,因为 d 更大,所以 T(N) = Θ(Nᵈ) = Θ(N²)。第十一节里会系统讲主定理,现在只要会用“比大小”这一步。

最后是空间:调用 88573 次,但栈深度只有 n = 10,每帧约 48 字节,总共不到 1KB。这就是为什么本题用递归毫无风险,而 fact(10⁶) 会立刻 RE——调用次数和栈深度是两个数,永远分开算。

顺带一个可验证的结论:最终的 1 的个数恰好是 3ⁿ。n = 3 数一遍是 27,正是 3³。道理是递归树的每个叶子(k = 0 的节点)对应一个从没被涂过的格子,而叶子有 3ⁿ 个。

举一反三 · 想清楚这些再往下走
  1. 把规则改成“左上、右上两块都赦免,只对下面两块继续分”。此时每个节点涂 2·4ᵏ⁻¹ 格,递归式变成 T(k) = 2T(k−1) + 2·4ᵏ⁻¹。用归一化的办法解出 T(k),它还是 Θ(4ᵏ) 吗?最终 1 的个数还是 3ⁿ 吗?
  2. 斐波那契的朴素递归 f(n) = f(n−1) + f(n−2) 画出 n = 5 的递归树,数一数 f(1) 被算了几次、f(2) 被算了几次。为什么越靠下的项被算的次数越多?这个观察是下一节记忆化的出发点。
  3. 求 n 个数全排列的递归函数有 n! 个叶子,但深度只有 n。什么情况下“调用次数大”是安全的、什么情况下“深度大”才危险?举出两个极端例子。
  4. 二分查找的递归版空间复杂度是多少?它的递归调用出现在函数的最后一句(尾递归),编译器在什么条件下能把它优化成循环?
  5. 汉诺塔 n 层的移动次数是 2ⁿ − 1,递归树的节点数也是 2ⁿ − 1 吗?把它和赦免战俘的树比一比,谁的树更“宽”、谁的更“深”?
自测:能全部回答再进下一节
  • 能说出递归三要素,并指出“参数没有变小”会导致 RE 还是 WA。
  • 能不看代码画出 solve(1,1,2) 的完整递归树,数出调用次数和叶子数。
  • 能解释为什么 1024×1024 的二维数组必须开在全局,放进 main 会怎样。
  • 能估算:每帧 48 字节、深度 10⁶ 的递归需要多少栈空间,在 8MB 栈上会不会 RE。
  • 能把阶乘、赦免战俘这样的递归改写成循环,并说清改写的依据是什么。
挑战题 · 幂次方
洛谷 P1010普及-去洛谷做这题 ↗

任何一个正整数都可以拆成若干个 2 的幂之和。例如 137 = 2⁷ + 2³ + 2⁰。

现在约定写法:2⁰ 写成 2(0),2¹ 写成 2,2ᵏ(k ≥ 2)写成 2( k 的表示 ),其中指数 k 也用同一套规则表示。多个幂之间用 + 连接,指数从大到小排列。例如 7 = 2² + 2 + 2⁰ 写成 2(2)+2+2(0),137 写成 2(2(2)+2+2(0))+2(2+2(0))+2(0)。

输入:一个正整数 n(1 ≤ n ≤ 20000)。输出:一行,即 n 的表示,中间不加任何空格。

引导性提示(先只看这里)

先只做一层:把一个数拆成 2 的幂之和,指数从大到小排列。20000 < 2¹⁵,所以指数最大是 14。

指数本身也要用同一套规则表示——这就是递归:同一个函数,参数从 n 变成指数 i,规模严格变小。

0 次幂和 1 次幂是两种特例,不能套 2(...) 的格式;另外想清楚 + 只应该出现在什么位置。

想看思路了?点开完整思路

把 n 看成二进制位:第 i 位是 1,就说明 n 的式子里要出现 2ⁱ。指数从 14 扫到 0,天然满足“从大到小”。

定义 print(n):输出 n 的表示(不带外层括号)。对每一位 i,若第 i 位为 1:先按需输出 +;再分三种情况输出这一项。

三种情况:i = 0 输出 2(0);i = 1 输出 2;i ≥ 2 输出 2(,递归调用 print(i),再输出 )。

为什么一定停:递归只在 i ≥ 2 时发生,而指数每次严格小于上一层,最多 14 层就降到 0 或 1,而这两种情况不再递归。所以既不需要显式出口,也不会爆栈。

为什么对:n 的每个二进制 1 位恰好贡献一个片段,片段之间用 + 连着;每个片段的指数表示由同一个函数、更小的参数给出。不重不漏。

总代价 = 递归树节点数 × 每个节点的 15 次循环。n = 20000 = 2¹⁴+2¹¹+2¹⁰+2⁹+2⁵,二进制只有 5 位是 1,树小到可以忽略,微秒级出答案。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

// 输出 n 的表示,不含外层括号
void print(int n) {
    bool first = true;                 // 是不是第一个片段
    for (int i = 14; i >= 0; --i) {    // 20000 < 2^15,指数最大 14
        if ((n >> i) & 1) {            // 第 i 位是 1,说明要写 2^i
            if (!first) cout << "+";   // 加号只出现在片段之间
            first = false;

            if (i == 0)      cout << "2(0)";              // 特例:0 次幂
            else if (i == 1) cout << "2";                 // 特例:1 次幂
            else { cout << "2("; print(i); cout << ")"; }  // 一般情况:指数递归展开
        }
    }
}

int main() {
    int n; cin >> n;
    print(n);
    cout << "\n";
    return 0;
}
关键片段拆解
片段作用
for (int i = 14; i >= 0; --i)从最高位往低扫,输出顺序天然符合“指数从大到小”。14 是因为 2¹⁴ = 16384 ≤ 20000 < 32768 = 2¹⁵。
if ((n >> i) & 1)取 n 二进制第 i 位。等价于 (n / (1 << i)) % 2,但位运算更短也不会溢出。
if (!first) cout << "+";加号只能加在两个片段之间。第一个片段前面加、或者末尾多一个,都是 WA。
if (i == 0) cout << "2(0)";0 次幂是约定出来的特例。若套一般格式,会输出 2()。
else if (i == 1) cout << "2";1 次幂直接写 2,不写 2(1)。这条最容易被忘,样例第一段就会对不上。
print(i);指数递归展开。只有当 i ≥ 2 才会走到这里,而 i 严格小于 14,所以最多 14 层,一定停。
bool first = true;用“是不是第一个”这一位代替“最后一个不加加号”的判断,省掉回头看的问题。
这题最容易错的地方
  • 漏掉 i == 1 的特例,输出 2(1)。
  • 漏掉 i == 0 的特例,对 0 递归,什么也不输出,得到空的 2()。
  • 加号加了两次:既用 first 判断,又在循环外补了一句,导致开头或结尾多一个 +。
  • 把循环写成 for (int i = 0; i <= 14; ++i),输出顺序变成从小到大,与“指数从大到小”的约定冲突。
延伸阅读 / 参考
  • 《算法竞赛入门笔记》递归与分治章节:递归三要素与递归树的三个量
  • 《算法竞赛进阶指南》递归与递推:搜索树规模与栈空间估算
  • 洛谷 P5461、P1010 题解区的高赞思路
22 递推与记忆化搜索普及-

22 · 递推与记忆化搜索

把“从上往下问”的递归换成“从下往上填”的表:定状态、列转移、判方向,再用记忆化、滚动数组和卡特兰数把空间与常见模型一次收干净。
⭐ 已学范围普及-预计 60 分钟递推记忆化卡特兰数
本节出现的新函数 / 新语法(先看懂再用)

上一节留了一个尾巴:把 f(n) = f(n-1) + f(n-2) 直接写成递归,求 f(40) 要调用约 3×10⁸ 次。明明只有 40 个状态,为什么算了上亿次?因为 f(1) 被底下每一条分支各算了一遍——这叫重复计算,不是算法慢。

两种治法的方向刚好相反:

  • 记忆化搜索:还是递归,从上往下问,但每算出一个答案就记进表里;第二次问同一个状态,直接查表返回。
  • 递推:干脆从最小的开始往后填,先算 f[1]、f[2],再拿它们拼出 f[3]……一张表填到底,中间不问任何人。
递归是“从上往下问”,递推是“从下往上填”。记忆化就是给前者配一本备忘录——同一个问题只回答一次。

四张卡:递推的三要素、记忆化的两种写法、滚动数组、卡特兰数。前两张是本节的主干。

新函数拆解 · 递推三要素:状态、转移、方向
f[边界] = 初值; // ① 边界 for (int i = 起点; i <= n; ++i) { // ③ 方向:保证用到的状态已经算好 f[i] = g(f[i-1], f[i-2], ...); // ② 转移 } cout << f[n]; // 答案在哪一格
组成含义 / 格式要求
状态 f[i]一句话说得清 f[i] 是什么。说不清就是没设计好,后面的方程一定是错的。例:f[i] = 走到第 i 级台阶的走法数。
转移方程不重不漏地列出 f[i] 的所有来源,再相加或取最值。来源的下标必须更小(或同下标但已算好)。
边界最小下标那几个答案,必须手算给出。漏了边界,f[1] 会去读 f[0]、f[-1] 这些垃圾值。
循环方向只看转移式里最大的下标。依赖 f[<i] 就 i 从小到大;依赖 f[>i] 就倒序;依赖“上一行同一列”就要调整嵌套顺序。写反了不会报错,只会算错,因为用到的还是 0。
答案是哪个是 f[n],还是 max(f[1..n]),还是 f[n][m]?先写下来,再写输出。
量级先估 f[n] 最大能到多少,再决定 int / long long / 高精度。递推值的增长经常远超直觉。
最小可运行示例
示例
long long f[50];                    // f[i] = 走到第 i 级台阶的走法数
f[1] = 1; f[2] = 2;                 // 边界:1 级只有 1 种走法,2 级有 2 种
for (int i = 3; i <= n; ++i)
    f[i] = f[i-1] + f[i-2];         // 最后一步要么跨 1 级,要么跨 2 级
cout << f[n] << "\n";
注意

为什么 i 必须从小到大:f[i] 要读 f[i-1]、f[i-2],它们必须先算好。倒着写,读到的是数组初值 0,答案会整片变 0。

为什么边界给了两个:转移式同时用到前两项。用到几项就补几个边界,这是硬性对齐关系。

新函数拆解 · memo 数组:判断“算过没有”的两种写法
// 写法 A:哨兵值判空 long long f(int n) { if (memo[n]) return memo[n]; // 非哨兵值 = 已经算过 ... return memo[n] = 结果; // 存表 + 返回,一句搞定 } // 写法 B:另开一个 vis 数组专门记账 long long f(int n) { if (vis[n]) return memo[n]; // vis 为真才敢用 memo vis[n] = true; ... return memo[n] = 结果; }
组成含义 / 格式要求
写法 A 的前提任何合法答案都不等于哨兵值。答案恒正时,0 是最省事的哨兵(全局数组默认就是 0)。
写法 B语义最干净:vis[i] 为真就表示 memo[i] 有效,与答案的取值完全无关。代价是多一个 bool 数组,每格 1 字节。
写法 C(哨兵 −1)memset(memo, -1, sizeof memo) 之后判 memo[n] != -1。前提是答案不会是 −1。适合答案可能为 0 的情况。
memset 的硬限制按字节填充,只能填 0 和 −1(全 0 字节是 0,全 1 字节是 −1)。memset(memo, 0x3f, ...) 对 int 得到 0x3f3f3f3f,对 long long 得到 0x3f3f3f3f3f3f3f3f,两者数值天差地别。
return memo[n] = 结果一句做两件事。拆成两行时最容易漏掉存表那句——那时结果仍然正确,但每次都要重算,程序退化成普通递归,从 AC 变 TLE。
为什么快每个状态只算一次。状态数 S 乘单次转移代价 T,总复杂度 O(S·T),与递归树的形状无关。
最小可运行示例
示例
long long memo[100];                   // 全局数组默认全 0

long long fib(int n) {
    if (n <= 2) return 1;              // 出口
    if (memo[n]) return memo[n];       // 算过 → 查表,O(1)
    return memo[n] = fib(n-1) + fib(n-2);   // 没算过 → 算完存表
}

// 调用次数从 3×10⁸ 降到 80 次以内(每个状态只真正算一次),深度仍然只有 40
注意

记忆化不改变递归的深度,只减少次数。所以它治得了 TLE,治不了爆栈。

状态必须是“参数决定”的。函数里如果有全局变量随调用改变,那 memo 的语义就崩了——同一个参数可能对应不同的答案,这时不能记忆化。

新函数拆解 · 滚动数组(把 O(n) 的空间压成 O(1))
// f[i] 只用前两项 → 两个变量就够 long long a = f0, b = f1; for (int i = 2; i <= n; ++i) { long long c = a + b; // 先算出新值 a = b; b = c; // 再整体往后挪一格 } // 循环结束时 b 就是 f[n]
组成含义 / 格式要求
什么时候能用f[i] 只依赖固定常数个“最近”的项,不依赖更早的任意项。依赖几项就要留几格。
取模下标写法f[i % K],K = 需要同时记住的项数。依赖前 3 项写 i % 3,是通用写法,K 大时比手写变量清爽。
顺序必须先读旧值、再覆盖。a = b; b = a + b; 这种写法是错的,第二句里的 a 已经被改写过了。
收益空间 O(n) → O(1)。n = 10⁸ 时这一步决定程序是 MLE 还是 AC。
代价历史值全丢,之后没法回答“f[k] 是多少(k < n)”。要输出整张表就不能滚动。
最小可运行示例
示例
// 斐波那契取模:f[1]=1, f[2]=1
long long a = 1, b = 1;
for (int i = 3; i <= n; ++i) { long long c = (a + b) % MOD; a = b; b = c; }
cout << b << "\n";      // b 就是 f[n]

// 依赖前 3 项时的通用写法
long long f[3] = {0, 0, 1};
for (int i = 3; i <= n; ++i) f[i % 3] = f[(i-1) % 3] + f[(i-2) % 3] + f[(i-3) % 3];
注意

f[i % K] 的 K 不能小于依赖的项数:依赖前 2 项就取 K = 2,依赖前 3 项就取 K = 3。之所以读到的是正确的旧值,是因为赋值右边先整体求值——(i-2) % 2 与 i % 2 是同一格,读的仍是覆盖前的内容。K 取小了一定错:依赖 3 项却写 i % 2,有两项会被折叠到同一格上。

滚动数组只是省空间,不改变时间复杂度——该算的 n 步一步都少不了。

新函数拆解 · 卡特兰数
f[0] = 1; f[n] = f[0]·f[n-1] + f[1]·f[n-2] + … + f[n-1]·f[0];
组成含义 / 格式要求
前几项1, 1, 2, 5, 14, 42, 132, 429, 1430, 4862, 16796 …(记到 5、14、42 就够用了)。
通项Cₙ = C(2n, n) / (n+1)。要取模时必须用逆元,n 大时不如用一阶递推。
一阶递推Cₙ = Cₙ₋₁ · (4n − 2) / (n + 1)。有除法,取模要用逆元;不取模时要保证整除。
什么时候会撞见n 个元素的出栈序列数、n 对括号的合法匹配数、n 个结点的二叉树形态数、凸 n+2 边形的三角剖分数、网格上不越过对角线的路径数。
增长有多快C₁₈ = 477638700 还装得进 int,C₁₉ = 1767263190,C₂₀ = 6564120420 就已经超出 int。n 一旦到 30 以上,只能靠 long long 或高精度。
复杂度按定义二重循环是 O(n²),n ≤ 3000 都能过;n 到 10⁵ 必须换成一阶递推 O(n)。
最小可运行示例
示例
long long f[20];                 // 卡特兰数
f[0] = 1;                        // 边界:空序列只有 1 种
for (int i = 1; i < 10; ++i)
    for (int k = 0; k <= i - 1; ++k)
        f[i] += f[k] * f[i - 1 - k];
// f[0..9] = 1 1 2 5 14 42 132 429 1430 4862
注意

卡特兰数的转移是“卷积”:把所有可能的切分点 k 枚举一遍,两边方案数相乘再相加。所以它的循环方向必须是 i 从小到大,且内层枚举 k。

看到“1, 2, 5, 14, 42”这串数就条件反射:这是卡特兰数,别再从头推一遍。

写法时间空间什么时候用
朴素递归指数级(φⁿ)O(深度)只在 n ≤ 25 时用来对拍
记忆化搜索O(状态数 × 转移)O(状态数) + O(深度)依赖顺序不好写、或只用到一部分状态
递推O(状态数 × 转移)O(状态数)依赖顺序清楚时,首选,没有函数调用开销
递推 + 前缀和 / 滚动可能更低O(1) ~ O(状态数)转移是“求和一段”或“只用前几项”
例题 · 数的计算洛谷 P1028普及-去洛谷做这题 ↗

给出一个正整数 n(n ≤ 1000)。按下面的规则构造数,问一共能得到多少个不同的数(包括 n 本身):

① 不作任何处理;

② 在它的左边加上一个自然数,但这个新加的数不能超过原数的一半(“原数”指当前最左边那个数);

③ 加上之后,继续对新的数按同样的规则处理,直到不能再加为止。

输入:一个正整数 n。输出:一行一个整数,表示能得到的数的个数。

输入: 6 输出: 6
例题怎么想:先手算 n = 6,再抽象成 f[n]

先把 n = 6 的所有结果列干净:6;左边加 1、2、3 得 16、26、36(不能加 4,因为 4 > 6/2 = 3);再从 26 得 126,从 36 得 136,从 16 得不出新的(1/2 = 0)。一共 6 个:6、16、26、36、126、136。

把这次枚举翻译成计数:从 6 出发有 1 个(它自己),加上“左边先接 1”之后的 1 个、“先接 2”之后的 2 个、“先接 3”之后的 2 个,1 + 1 + 2 + 2 = 6。

关键的一步是认出后面那几个“几个”其实是同一个函数在更小的数上的值:定下左边第一个数是 i 之后,接下来能接出的东西只由 i 决定,与原来的 6 无关。于是定义 f[n] = 从 n 出发能得到的数的个数(含 n 自己)。

转移方程:f[n] = 1 + f[1] + f[2] + … + f[⌊n/2⌋]。那个 1 是“左边什么都不加”。边界 f[1] = 1:从 1 出发,1 的一半是 0,加不了任何数。

循环方向:f[n] 只用到 f[1..⌊n/2⌋],下标全都比 n 小,所以 n 从小到大填。n = 1 时求和范围是空的,所以 n = 1 必须作为边界单独给出。

朴素做法是双重循环,O(n²),n = 1000 时约 25 万次加法,能过。但还能再省一步:记 s[m] = f[1] + … + f[m],则 f[n] = 1 + s[⌊n/2⌋],每算完一项立刻把 s 往后推一格,总复杂度降到 O(n)——这就是前缀和的思路。

顺便说清朴素递归为什么慢:f(1000) 要问 f(1..500),每个又问自己的一半……而整个过程只有 1000 个不同的状态。要么存表(记忆化),要么从下往上填(递推),二选一。

完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1005;
long long f[MAXN];      // f[n] = 从 n 出发能得到的数的个数
long long s[MAXN];      // s[m] = f[1] + f[2] + … + f[m]

int main() {
    int n;
    cin >> n;

    f[1] = 1;           // 边界:从 1 出发只有它自己
    s[1] = 1;           // 前缀和同步初始化

    for (int i = 2; i <= n; ++i) {
        f[i] = 1 + s[i / 2];        // 1 是"什么都不加",s[i/2] 是"左边先接一个数"的全部方案
        s[i] = s[i - 1] + f[i];     // 算完立刻入表,供后面的下标查
    }

    cout << f[n] << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
const int MAXN = 1005;n ≤ 1000,多留 5 格防手滑。
long long f[MAXN];f[1000] = 1981471878,离 int 上限 2147483647 只剩 8%。本题 int 刚好够,但犯不着为省 4 字节去赌。
long long s[MAXN];s 的量级比 f 大得多:s[1000] = 264830889563 ≈ 2.6×10¹¹,必须 long long。本题只读 s[i/2](最大下标 500,s[500] = 1981471877 刚好没超 int),所以写 int 居然也能 AC——这是运气,不是设计。
f[1] = 1; s[1] = 1;边界。n = 1 时下面的循环一次都不进,所以这两句不能省,否则输出 0。
for (int i = 2; i <= n; ++i)从小到大。f[i] 读 s[i/2],而下标 i/2 一定小于 i,已经算好。
f[i] = 1 + s[i / 2];那个 1 是“左边什么都不加”。s[i/2] 把“左边先接 1、接 2、……、接 ⌊i/2⌋”这 ⌊i/2⌋ 种情况一次性加完,不用再套一层循环。
s[i] = s[i - 1] + f[i];前缀和向后推一格。漏了这一句,s 永远停在旧值,答案会偏小一大截,而且 n 小的时候还看不出来。
cout << f[n] << "\n";答案在 f[n] 这一格,不是 s[n]。

同一个问题换个写法,把“从下往上填”换成“从上往下问 + 备忘录”,对照着看:

记忆化写法(对照)
long long memo[MAXN];

long long f(int n) {                  // 从 n 出发的方案数
    if (n == 1) return 1;             // 出口
    if (memo[n]) return memo[n];      // 算过 → 查表(答案恒正,0 可以当"没算过")
    long long res = 1;                // "什么都不加"这一种
    for (int i = 1; i <= n / 2; ++i)
        res += f(i);                  // 递归问出来,每个状态只问一次
    return memo[n] = res;             // 存表 + 返回
}
// main 里只需 cin >> n; cout << f(n);

两版都能过,但复杂度并不相同,这一点必须说清:递推 + 前缀和是 O(n);记忆化是 O(n²)。记忆化省掉的是“同一状态的重复展开”,省不掉每一层循环里的 ⌊n/2⌋ 次求和——Σ ⌊i/2⌋ 仍是 n²/4 量级,n = 1000 时约 25 万次。

想让记忆化也降到 O(n),就得把 S(n) = f(1)+…+f(n) 也一起记忆化——那其实已经把递推又写了一遍。所以:能一眼看出依赖顺序时,写递推;依赖顺序说不清时(比如网格上只能向右向下走),才用记忆化换来写法上的自由。

递推与记忆化的 6 个典型错法(附症状)

WA,n ≥ 4 时答案恒为 1:忘了 s[i] = s[i-1] + f[i]。s 除了 s[1] 全是 0,于是 f[i] = 1 + s[i/2] 在 i/2 ≥ 2 之后恒等于 1。样例 n = 6 直接输出 1。

WA,n = 1 时输出 0:边界没写或写在下标 0 上。本题 n 可以等于 1,循环不执行,只能靠初始化。

WA,输出负数:用 int 存前缀和 s。s[507] 起就溢出,s[1000] = 2.6×10¹¹,转成 int 是 -1457082789。本题恰好只读到 s[500],所以错得不明显;一旦答案改成累加型,屏幕上立刻出负数。看到负号先查类型,别先怀疑公式。

TLE,而小数据全对:记忆化里 return memo[n] = res 写成 return res,忘了存表。程序仍然正确,但退化回朴素递归——这类“答案对但超时”的 bug 最难找。

WA,且错得莫名其妙:循环方向写反,for (int i = n; i >= 2; --i)。此时 f[i] 读到的是还没算出来的 s[i/2] = 0,于是 f[n] = 1,与 6 差得远。

RE / 随机 WA:数组开成 f[1000] 而下标要取到 1000。开 1005,或者干脆用 0-index 的写法把 +5 省掉。

记忆化专属的三个坑

哨兵选错:某个合法答案恰好是 0,而你用 0 当“没算过”。结果是这个状态被反复重算,答案还是对的,只是慢——症状是 TLE 而不自知。改法:换 −1 当哨兵,或改用 vis 数组。

memset 填错:memset(memo, 0x3f, sizeof memo) 对 long long 得到的是 0x3f3f3f3f3f3f3f3f,判 != 0x3f3f3f3f 永远成立,等于没记忆化。判等式的两边必须是同一个值。

把全局变量也当成状态的输入:函数依赖一个会被改动的全局变量,那 memo 里存的就是过期答案。记忆化的前提是“参数相同 ⇒ 答案相同”。

自己动手复现

关掉页面,从空文件把 P1028 敲一遍并提交。n = 1 也要输出 1。

改一改 ①:删掉 s 数组,改成 f[i] = 1; for (int k = 1; k <= i/2; ++k) f[i] += f[k];。答案必须一模一样,但内层加法总次数从约 1000 涨到约 25 万(n²/4)——这就是“前缀和把重复求和存起来”省下的东西。

改一改 ②:把两个数组都改成 int,输入 1000。答案居然还是对的,再输入 1000 打印 s[1000],你会看到负数——亲手见一次“刚好没溢出的运气”。

改一改 ③:把主循环改成 for (int i = n; i >= 2; --i),输入 6,答案是 1 而不是 6。这就是“方向错”的样子。

改一改 ④:用上面的记忆化版本跑 n = 1000,和递推版比运行时间;再把 return memo[n] = res 改成 return res,感受退化的代价。

数学补给 · 递推式的解:特征方程、组合通项、量级估算

线性递推可以机械地解出来。以 f[n] = f[n-1] + f[n-2] 为例,假设解是幂函数 f[n] = rⁿ,代入得 rⁿ = rⁿ⁻¹ + rⁿ⁻²,两边同除 rⁿ⁻²,得到特征方程

斐波那契的特征方程
r² = r + 1   即   r² − r − 1 = 0
r₁ = (1 + √5)/2 ≈ 1.618      r₂ = (1 − √5)/2 ≈ −0.618

通解是 f[n] = A·r₁ⁿ + B·r₂ⁿ。用 f[1] = f[2] = 1 定出 A = 1/√5、B = −1/√5,于是

闭式解
f[n] = ( r₁ⁿ − r₂ⁿ ) / √5        // 比内公式

它的用处不是让你去算 φⁿ,而是估量级:|r₂| < 1,所以 f[n] ≈ r₁ⁿ/√5,每项是前一项的 1.618 倍。查一下就知道 f[46] = 1836311903 还能装进 int,f[47] = 2971215073 就超了——这就是“数组该用 long long 还是 int”的第一个定量判据。

再看一个不是线性的例子:卡特兰数 f[n] = Σ f[k]·f[n-1-k]。它带卷积,没有特征方程,通项要从组合数来:Cₙ = (2n)! / (n!(n+1)!)。用斯特林公式 n! ≈ √(2πn)·(n/e)ⁿ 化简得

卡特兰数的增长阶
C_n  ≈  4ⁿ / ( n^(3/2) · √π )

n = 20 时:4²⁰ = 1.0995×10¹²,20^1.5 = 89.44,√π = 1.7725
          1.0995×10¹² / 158.5 ≈ 6.9×10⁹
实际 C₂₀ = 6564120420                       误差 6%

所以 C₁₈ = 477638700 还装得进 int,C₁₉ = 1767263190 也勉强,C₂₀ = 6564120420 就爆了。4ⁿ/n^1.5 这个形状说明它比 2ⁿ 快、比 4ⁿ 慢——凡是“括号匹配”“出栈序列”类的计数,答案都会迅速吃掉整型范围。

最后一条,也是本节最实用的一条:递推的复杂度是“状态数 × 单次转移代价”,而答案的大小是另一回事。

nf[n](P1028)说明
1014手算就能验证
1009828还不到一万
500263384282.6×10⁷,int 没问题
100019814718781.98×10⁹,离 int 上限只剩 8%

时间上只用 1000 步,值上却离 int 上限只差 8%。而 s[1000] = 264830889563 已经是 2.6×10¹¹。同一个程序里两个数组可以有完全不同的量级——这就是为什么“每个数组单独估量级”是必须养成的习惯,而不是“整个程序统一用 long long 就完事”。

把这两件事合起来看:状态数决定时间,答案值和中间量决定类型。两个都要在动手写代码之前估一遍。

举一反三 · 想清楚这些再往下走
  1. 上台阶(每步只能跨 1 级或 2 级)和“用 1×2 骨牌铺满 2×n 的棋盘”,答案为什么都是斐波那契?把两个问题的“最后一个动作”写出来对比,你会发现转移方程是同一句话。
  2. 如果题目要求输出 f[1] 到 f[n] 的每一个值,滚动数组还能用吗?为什么?需要改回什么写法?
  3. 卡特兰数按定义算是 O(n²)。若 n = 10⁵ 且要求对 10⁹+7 取模,O(n²) 会 TLE。用一阶递推 Cₙ = Cₙ₋₁·(4n−2)/(n+1) 怎么把它降到 O(n)?那个除法在取模下要怎么处理?
  4. 什么样的递推不能用滚动数组?(提示:想想 f[i] 依赖 f[0..i-1] 全部,或者依赖“上一行整行”的情况。)
  5. 记忆化搜索和递推算的是同一张表,什么情况下只能写记忆化?举一个“状态的依赖顺序不是下标从小到大”的例子。
自测:能全部回答再进下一节
  • 能说出递推三要素,并为一道新题写出“状态”那一句话,让同学看完就知道 f[i] 该存什么。
  • 能默写记忆化的两种写法,并说清各自的哨兵前提和代价。
  • 知道 memset 只能填 0 和 −1,并能解释 0x3f3f3f3f 为什么只对 int 成立。
  • 能判断一个递推能不能用滚动数组,并写出 2 格版本;也能说出滚动之后丢掉了什么。
  • 能背出卡特兰数的前 6 项,并说出它的三个实际出现场景。
挑战题 · 栈
洛谷 P1044普及-去洛谷做这题 ↗

一个栈的入栈序列固定为 1, 2, …, n。每个数入栈后,可以在任意时刻出栈(只要栈不空)。问最终能得到多少种不同的出栈序列。

输入:一个整数 n(1 ≤ n ≤ 18)。输出:一行一个整数,表示可能的出栈序列总数。

引导性提示(先只看这里)

先手算 n = 1、2、3、4 的答案:1、2、5、14。看看这串数是怎么长起来的。

盯着第一个入栈的元素 1:它入栈之后、出栈之前,栈里还能做多少次“进去再出来”?这些操作只涉及某 k 个元素。

把“元素 1 出栈之前”和“元素 1 出栈之后”当成两个规模更小的同类问题,方案数相乘,再对所有可能的分界点求和。

想看思路了?点开完整思路

设 f[n] = n 个元素按 1..n 入栈时能得到的出栈序列数。

看清元素 1 的地位:它第一个入栈,所以它出栈时,栈里在它上面的那些元素(共 k 个,k = 0..n−1)必须已经全部出栈。这 k 个元素从入栈到出栈的全部过程,本身就是一个规模为 k 的同类问题,方案数 f[k]。

元素 1 出栈之后,剩下 n−1−k 个元素还没进栈,它们此后的进出栈又是一个规模为 n−1−k 的同类问题,方案数 f[n−1−k]。

两个阶段互相独立(前半段的每一步都不影响后半段的合法性),所以用乘法原理得 f[k]·f[n−1−k];k 可以取 0 到 n−1 中任意一个,用加法原理求和:f[n] = Σ f[k]·f[n−1−k]。

边界 f[0] = 1:0 个元素只有一种方案(什么都不做)。验证 n = 3:f[3] = f[0]f[2] + f[1]f[1] + f[2]f[0] = 2 + 1 + 2 = 5,与样例一致。

这就是卡特兰数。复杂度 O(n²),n = 18 时不到 200 次乘法。答案 f[18] = 477638700,int 装得下,但乘法 f[k]·f[i−1−k] 的增长不好估,直接用 long long。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

long long f[25];        // f[n] = n 个元素的出栈序列数,n ≤ 18

int main() {
    int n;
    cin >> n;

    f[0] = 1;                            // 边界:0 个元素只有 1 种方案(空)
    for (int i = 1; i <= n; ++i) {       // 从小到大:f[i] 依赖 f[0..i-1]
        for (int k = 0; k <= i - 1; ++k)
            f[i] += f[k] * f[i - 1 - k]; // 枚举分界:1 出栈前处理了 k 个
    }

    cout << f[n] << "\n";
    return 0;
}
关键片段拆解
片段作用
long long f[25];n ≤ 18,开 25 富余。全局数组默认全 0,正好当递推的初始值。
f[0] = 1;边界。空栈也算一种合法方案,是整条递推的地基——写成 0,后面全是 0。
for (int i = 1; i <= n; ++i)外层从小到大。f[i] 只用到 f[0..i−1],都已经算好。
for (int k = 0; k <= i - 1; ++k)枚举“元素 1 出栈之前处理了几个元素”。k 最大取 i−1,因为元素 1 自己不在这 k 个里。
f[k] * f[i - 1 - k]前半段和后半段是两个互相独立的子问题,方案数相乘。注意右边是 i−1−k 而不是 i−k:减掉元素 1 自己。
f[i] += ...把全部分界点的结果加起来。这个“枚举切分点再相乘求和”的形状,是识别卡特兰数的标志。
这题最容易错的地方
  • 边界写成 f[0] = 0 或干脆不写,整张表全是 0。
  • 内层 k 从 1 开始,漏掉 k = 0 那一项 f[0]·f[i-1],答案会偏小。
  • 第二项写成 f[i-k],忘了减掉元素 1 自己:k = 0 时读到的是正在累加的 f[i] 本身,结果全错。
  • 外层 i 倒着从 n 算到 1,用到的 f[0..i−1] 还是 0,答案变成 0。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》递推与记忆化搜索:状态依赖顺序决定写法
  • 《算法竞赛入门笔记》动态规划的雏形:从记忆化到递推
  • 洛谷 P1028、P1044 题解区的高赞推导
23 DFS 枚举:子集、组合与排列普及-

23 · DFS 枚举:子集、组合与排列

把“枚举所有方案”写成递归:一副骨架四块内容,一套回溯三行;再用 vis、start、“选或不选”三种手段分别处理排列、组合、子集的不重不漏,并学会先估方案数再写代码。
⭐ 已学范围普及-预计 65 分钟DFS回溯字典序
本节出现的新函数 / 新语法(先看懂再用)

第 14 节用 for 和二进制数过搜索空间,第 21 节把递归拆成“出口 + 递推”。DFS 枚举 = 递归 + 枚举:把“从一堆东西里挑若干个”摊成 n 层决策,每层选一个,选完往下走,走完退回来换下一个。这个“退回来换下一个”的动作叫回溯,代码里只有一行。

DFS 枚举不保证快,只保证不重不漏。三种题型的所有技巧都在处理“不重”:排列用 vis,组合用 start,子集用“选或不选”。

先把骨架拆成四块。任何一道 DFS 枚举题,都能填进这张表里,填不进去就说明还没想清状态。

骨架部件它回答的问题全排列(n! 种)组合(C(n,k) 种)
层(step)现在在做第几层决策正在填第 step 个位置已经选了 step 个数
候选集这一层允许选哪些1..n 里所有没被用过的数start..n
出口什么状态算一个完整方案step > nstep > k
还原离开这一层要撤销什么vis[i] = 0不用撤销:start 天生递增

本节没有新关键字,只有一种必须背下来的固定顺序。把它当一个不可拆的整体记住,比理解之后再写要快。

新函数拆解 · 回溯三行(标记 → 递进 → 还原)
vis[i] = 1; dfs(step + 1); vis[i] = 0;
组成含义 / 格式要求
① 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。

新函数拆解 · start 参数(组合的去重手段)
void dfs(int step, int start)
组成含义 / 格式要求
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 节完全背包的枚举就长这样。

新函数拆解 · printf 定宽输出
printf("%5d", x);
组成含义 / 格式要求
%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、回溯三行和定宽输出,而它的方案数刚好大到能让你第一次感受到“搜索空间”这个词的重量。

例题 · 全排列问题洛谷 P1706普及-去洛谷做这题 ↗

输入一个整数 n,按字典序输出 1 到 n 的所有全排列,每个排列占一行。

输出时每个数字占 5 个字符宽度,右对齐;同一行内数字之间不额外加空格。

数据范围:1 ≤ n ≤ 9,方案数为 n!,最大 362880。

输入: 3 输出: 1 2 3 1 3 2 2 1 3 2 3 1 3 1 2 3 2 1
把“输出所有排列”翻译成“填 n 个空格”

换一种说法。一个排列就是把 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——本题的瓶颈是输出,不是搜索。

完整可编译代码(C++17)
#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 行,第一行是空集
组合:start 递增
#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。还原的对象永远是“你刚才改动的那份状态”,把它找出来,回溯就不会写错。

这一节的 6 个坑,每个都有具体症状

出口写成 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; 再跑,答案不会变——亲手验证“答案数组不需要还原”。

数学补给 · 方案数估算:n! 和 2ⁿ 的失控点

DFS 枚举的时间 = 搜索树节点数 × 每个节点的工作量。节点数是可以提前算出来的,这就是“能不能做”的唯一判据。

排列:A(n,k) = n!/(n−k)! 组合:C(n,k) = n!/(k!(n−k)!) 子集:2ⁿ = Σ C(n,k)

nn!2ⁿC(n, n/2)能不能枚举
9362880512126轻松
11399168002048462n! 已到 4×10⁷,勉强
124790016004096924n! 超 10⁸,只能枚举组合/子集
202.4×10¹⁸1048576184756只能枚举子集
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⁴ 都不会爆栈。下一节的网格搜索才会遇到“深度 = 状态数”导致的爆栈。

举一反三 · 想清楚这些再往下走
  1. n ≤ 9 时求“字典序第 k 个排列”,能不能不枚举全部 362880 个?提示:第一位定下之后,后面还有 (n−1)! 种,用这个数就能一位一位地定位 k。
  2. 如果题目改成“输出 1..n 的所有排列,但每个排列末尾要跟一个空格”,改一行 printf 能做到吗?如果改成“每个数字占 5 格、且两数之间固定一个空格”,宽度又该填多少?
  3. 子集枚举有“选/不选”递归版和第 14 节的二进制版,两者都是 2ⁿ。分别在什么情况下必须用递归版?(提示:想想“中途判断一下这个分支还有没有救”这件事在二进制版里好不好写。)
  4. 如果候选集合每层都在变,例如“第 i 位只能填比前一位更大的数”,start 参数该怎么传?这样生成的方案数是多少(用组合数表示)?
  5. 什么时候可以不用 vis?给你两个例子:网格里走过的格子、数组里两个位置的交换。它们各自用什么代替 vis?
  6. C(30, 15) ≈ 1.55×10⁸,直接 DFS 会超时。如果只需要“第 k 小的方案”,怎么利用 C(n,k) 的数值跳过整片子树?这个思路叫什么?(第 34 节数位 DP 会正式用到。)
自测:能全部回答再进下一节
  • 能默写回溯三行,并说出哪一行漏了会 WA、哪一行写错会 TLE。
  • 能说清子集 / 组合 / 排列三者“去重手段”的差别,并各写 15 行以内的完整代码。
  • 给定 n,能在 30 秒内估出 DFS 叶子数,并判断会不会 TLE、输出量会不会爆。
  • 知道 P1706 为什么不需要排序就是字典序输出。
  • 能解释为什么 a[step] 不用还原而 vis[i] 必须还原。
  • 能手算 n = 4 时排列的搜索树节点数(1+4+12+24+24 = 65)并与程序计数器对照。
挑战题 · 选数
洛谷 P1036普及-去洛谷做这题 ↗

给出 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,但把这个判断写上,函数才能通用。
这题最容易错的地方
  • 方案数按“排列”算:忘了 i+1 的递增约束,同一组数被重复计数 k! 次 → 答案偏大 k! 倍。
  • 判素数时循环写到 d < v:最坏 9.5×10⁷ 次取模,乘上 18 万次判断必然 TLE;写到 √v 才行。
  • 把 break 写成 continue:只是白跑,答案仍对但慢;反过来把剪枝条件写反(> 写成 <)会漏方案 → WA。
  • sum 用 int 是够的(≤ 9.5×10⁷),但如果把上界估成 20 × 10⁷ 并继续放大,就要换 long long,先估再用。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》第 0x21 节:树与图的遍历(DFS 框架)
  • 《算法竞赛入门笔记》搜索章:全排列与组合的递归写法
  • 洛谷 P1706、P1036、P1157 题解区的高赞实现
24 DFS 与回溯:网格搜索与剪枝普及/提高-

24 · DFS 与回溯:网格搜索与剪枝

把 DFS 搬到网格上:方向数组走四个方向,标记决定撤不撤销——连通块不撤销、路径计数必须撤销;再用三个 bool 数组把八皇后的冲突检测做成 O(1),并分清可行性、最优性、顺序三类剪枝。
⭐ 已学范围普及/提高-预计 65 分钟网格 DFS剪枝八皇后
本节出现的新函数 / 新语法(先看懂再用)

上一节在“1..n 的数字”上做决策,候选集是下标。这一节把决策搬到网格上:每层的候选集变成上下左右四格。网格搜索会逼出一个上一节没遇到过的问题——同一个格子允不允许被两条路径共用。这一个问题的答案决定了标记要不要撤销,而它是一行代码的差别、两种完全不同的算法。

网格 DFS 的错几乎都出在“标记”上:标不标、什么时候标、撤不撤销。三个问题答错一个,40 行里就有 1 行是错的。
新函数拆解 · 方向数组 dx[] / dy[]
int dx[4] = {-1, 1, 0, 0}; int dy[4] = {0, 0, -1, 1};
组成含义 / 格式要求
(dx[k], dy[k])第 k 个方向的行增量与列增量。四个方向捆成一组,用一层 for 代替四段重复的 if。
为什么这么配上 (−1, 0)、下 (1, 0)、左 (0, −1)、右 (0, 1)。行号 i 变大是往下走,列号 j 变大是往右走。
八方向把对角也加进来共 8 组:dx[8]={-1,-1,-1,0,0,1,1,1},dy[8]={-1,0,1,-1,1,-1,0,1}。dx 和 dy 必须一一对应,漏配一组不会报错,只会少算格子。
nx、ny 必须是新变量int nx = x + dx[k] 每次都现算。直接改 x 本身,下一次循环的起点就错了,症状是走出一条斜线或者原地打转。
边界先判if (nx < 1 || nx > n || ny < 1 || ny > m) continue; 四个条件缺一不可。必须先判越界再访问 g[nx][ny],顺序反了就是下标 −1,直接 RE。
下标从几开始从 1 开始,边界就是 1 和 n;从 0 开始,边界就是 0 和 n−1。两种风格不能混,混了必然越界。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int n = 3, m = 4;                          // 3 行 4 列
int dx[4] = {-1, 1, 0, 0};                 // 上 下 左 右
int dy[4] = {0, 0, -1, 1};

int main() {
    int x = 1, y = 1;                      // 站在左上角
    for (int k = 0; k < 4; ++k) {
        int nx = x + dx[k], ny = y + dy[k];
        if (nx < 1 || nx > n || ny < 1 || ny > m) continue;   // 越界的两个方向被跳过
        printf("(%d,%d) ", nx, ny);        // 输出 (2,1) (1,2)
    }
    return 0;
}
注意

方向数组的顺序只在“按字典序输出路径”时才要紧;只计数的话随便排。

先把四个方向抄在草稿纸上再写代码,比写完对着 WA 猜方向快得多。

新函数拆解 · 对角线编号 i + j 与 i − j + n
d1[i + j] // 左下—右上这一族 d2[i - j + n] // 左上—右下这一族
组成含义 / 格式要求
i + j同一条“左下—右上”斜线上,所有格子的 i + j 相同,取值范围 2..2n。
i − j同一条“左上—右下”斜线上,所有格子的 i − j 相同,取值范围 −(n−1)..(n−1)。是负数,不能直接当下标。
+ n把 i − j 整体右移 n,落到 1..2n−1,全部合法。数组开 2n+1 格就够(n ≤ 13 时 27 格)。
为什么是 O(1)两个 bool 数组记住“这条斜线上有没有棋子”。判冲突从“扫一遍棋盘”变成两次内存访问,整整省掉一个 n 的因子。
标记与撤销必须用同一个下标一处写 d2[i-j]、另一处写 d2[i-j+n],标记和还原对不上,棋子会凭空消失或凭空冲突,症状是答案偏大且不稳定。
行不用管dfs 每层固定一行、一行只放一个,行冲突天生不存在。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int n = 4;

int main() {
    for (int i = 1; i <= n; ++i) {          // 主对角线编号 i - j + n
        for (int j = 1; j <= n; ++j) printf("%2d", i - j + n);
        printf("\n");
    }
    printf("\n");
    for (int i = 1; i <= n; ++i) {          // 副对角线编号 i + j
        for (int j = 1; j <= n; ++j) printf("%2d", i + j);
        printf("\n");
    }
    return 0;
}
/* 第一张:4 3 2 1 / 5 4 3 2 / 6 5 4 3 / 7 6 5 4
   第二张:2 3 4 5 / 3 4 5 6 / 4 5 6 7 / 5 6 7 8
   每一条斜线上的数字都相同 —— 这就是“一条斜线一个下标” */
注意

两族斜线用两个不同的数组,编号区间重叠不会冲突。

n 变大时数组要跟着变大:n = 13 时 i + j 最大 26、i − j + n 最大 25,开 30 格稳。

网格上的 DFS 只有两种命运,先分清它们,再动手写。下面这张表是全节最该记住的东西。

对比项连通块:问“有几块区域”路径计数:问“有几条路”
格子的身份只属于它所在的那一块,是唯一的在一条路径上最多出现一次,但可以属于很多条路
标记之后撤销吗不撤销:标上就是“已归档”必须撤销:下一条路还要踩这一格
漏写撤销的后果本来就不该写,写了反而错方案数偏小,常常只剩 1
多写撤销的后果整块被反复重算,方案数虚高、指数爆炸 → TLE本来就要写,没有副作用
典型题数水洼、最大连通块迷宫路径数、八皇后

差别只有一行,但判断依据很清楚:问“块”就把标记留在原地,问“路”就必须把标记还回去。因为块一旦确定就不再改变,路则是一条一条试出来的。

连通块版:只标记,绝不还原(4 连通;改成 8 连通只需换方向数组)
#include <bits/stdc++.h>
using namespace std;

int n, m, ans;
int g[105][105];                          // 0 空格,1 未访问的格子,2 已归入某块
int dx[4] = {-1, 1, 0, 0};
int dy[4] = {0, 0, -1, 1};

void dfs(int x, int y) {
    g[x][y] = 2;                          // 标记“已访问”:直接改原图,省一个 vis 数组
    for (int k = 0; k < 4; ++k) {
        int nx = x + dx[k], ny = y + dy[k];
        if (nx < 1 || nx > n || ny < 1 || ny > m) continue;
        if (g[nx][ny] != 1) continue;     // 不是未访问的格子就不走
        dfs(nx, ny);
    }
    /* 这里没有 g[x][y] = 1 —— 连通块不撤销 */
}

int main() {
    cin >> n >> m;
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j) cin >> g[i][j];

    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j)
            if (g[i][j] == 1) { ++ans; dfs(i, j); }   // 每从一个没访问过的格子出发,就多一块
    cout << ans << "\n";
    return 0;
}
例题 · 迷宫洛谷 P1605普及-去洛谷做这题 ↗

有一个 N×M 的方格迷宫,其中有 T 处障碍不能通过。给定起点和终点坐标,求从起点走到终点、每个方格最多经过一次的移动方案数。每步只能上下左右走一格。

输入格式:第一行三个整数 N、M、T;第二行四个整数 SX、SY、FX、FY,为起点与终点坐标;接下来 T 行,每行两个整数,为一个障碍的坐标。保证起点上没有障碍。

输出格式:一行一个整数,表示方案数。

数据范围:1 ≤ N, M ≤ 5,0 ≤ T ≤ 10。

输入: 2 2 1 1 1 2 2 1 2 输出: 1
一条方案 = 一条不重复的路径,走到终点就 +1

模型。题目问“有几条路”,一条路就是一串不重复的格子。所以答案 = 所有“从起点到终点、且中途不重复经过任何格子”的路径条数。这个量没法算,只能搜。

状态。当前站在 (x, y),加上“哪些格子已经踩过”。后者直接写进 g 数组:踩过的格子临时改成 1(不可走),离开时改回 0。这样“已经踩过”这件事就变成了“这一格现在不能走”,判定和走一步合并成同一件事,省掉一个 vis 数组。

出口。走到 (fx, fy) 就 ++ans 然后 return。必须 return:如果到了终点还继续走,绕一圈再回到终点就会把同一条前缀数成多个方案。

回溯三行。本题是路径计数,所以第三行还原不能省:g[nx][ny] = 1; dfs(nx, ny); g[nx][ny] = 0;。这正是上一节那三行,只是占位的对象从 vis[i] 换成了网格里的一个格子。

两个容易漏的细节。① 起点也要先标记成 1,否则路径可能“绕回起点”被重复计算;② 障碍在读入时就设成 1,于是“是障碍”和“已经踩过”用同一个判断就能一起处理。

复杂度为什么不能用 4^(NM) 估。4²⁵ ≈ 1.1×10¹⁵,照这个上界早就该放弃了。但“每格最多一次”把分支死死压住:越走可选方向越少,多数分支三五步就撞墙。搜索题的复杂度要按实际能走通的路径数估,而不是按每层分支数硬乘。

完整可编译代码(C++17)
#include <bits/stdc++.h>
using namespace std;

int n, m, t, sx, sy, fx, fy, ans;
int g[10][10];              // 1 = 障碍或本路径已走过(两者都等于“不能走”)
int dx[4] = {-1, 1, 0, 0};
int dy[4] = {0, 0, -1, 1};

void dfs(int x, int y) {
    if (x == fx && y == fy) {           // 到终点:这条路径成立,计数后立刻返回
        ++ans;
        return;                         // 不返回就会从这里继续走,把一条路径数成多条
    }
    for (int k = 0; k < 4; ++k) {
        int nx = x + dx[k], ny = y + dy[k];
        if (nx < 1 || nx > n || ny < 1 || ny > m) continue;   // 先判越界
        if (g[nx][ny]) continue;                             // 障碍 / 本路径已走过
        g[nx][ny] = 1;                  // ① 占位:这一格在本路径上用掉了
        dfs(nx, ny);                    // ② 递进
        g[nx][ny] = 0;                  // ③ 还原:别的路径还要用这一格
    }
}

int main() {
    cin >> n >> m >> t >> sx >> sy >> fx >> fy;
    for (int i = 1; i <= t; ++i) {
        int x, y; cin >> x >> y;
        g[x][y] = 1;                    // 障碍也记成 1,省掉单独的判断
    }
    g[sx][sy] = 1;                      // 起点算“已走过”,否则路径可以绕回起点
    dfs(sx, sy);
    cout << ans << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
int g[10][10];N, M ≤ 5,开 10 格有足够余量。注意这里 g 承担了两个含义:障碍(永久为 1)与已走过(临时为 1)。两者都表示“现在不能走”,所以能用同一个判断合并。
if (x == fx && y == fy) { ++ans; return; }出口放在函数最前面,进函数就判。放在循环后面也能过,但会多走一些无效分支。
return;到了终点必须马上返回。少了这一句,程序会从终点继续往外走,把同一条路径的前缀反复计数 → 答案偏大。
if (nx < 1 || nx > n || ny < 1 || ny > m) continue;越界判断必须在取 g[nx][ny] 之前。C++ 不检查下标,写反了读到的是一块随机内存,有时不崩、只是答案莫名其妙。
if (g[nx][ny]) continue;一个判断同时挡掉障碍和已走过的格子。若把障碍单独存一个数组,这里就要写两个判断,还容易漏一个。
g[nx][ny] = 1; dfs(nx, ny); g[nx][ny] = 0;回溯三行。第三行是本节的核心区别:路径计数必须还原,连通块必须不还原。
g[sx][sy] = 1;起点也算踩过。不写这一句时,某些数据会出现“走到邻居再走回起点”的路径,答案偏大。
ans 用 int5×5 网格上路径数最多几万条,int 远远够。但换到 30×30 就要先估上界再决定,别默认 int。

搜索慢,唯一的原因是分支太多。剪枝就是在“往下走之前”判一句,把整棵子树砍掉——价码是 O(1),收益可能是指数级。三类剪枝必须能一眼分清。

类型砍的条件典型写法砍掉了什么
可行性剪枝当前状态已经不可能走到任何合法解if (越界 || 已走过 || 是障碍) continue;整棵子树
最优性剪枝求最优值时,当前代价已不可能优于已知最优if (step >= best) return;剩下的所有分支
顺序剪枝先试更可能出解的分支,让 best 尽早变小把候选排序,从收益大的开始试间接砍掉大量分支

三类的共同点:判断都在进入下一层之前,代价 O(1),省下的是一整棵子树。所以剪枝永远比在叶子上优化常数划算——叶子数量本来就是指数的。

八皇后是“可行性剪枝做到极致”的例子:每行放一个棋子,行冲突自动消失;剩下的列与两条斜线,各用一个 bool 数组记着有没有被占,判一次冲突只要三次内存访问。8 皇后裸枚举是 8⁸ = 16777216 种摆法,每种还要 O(n²) 检查;加上行列剪枝,实际只访问 2057 个节点。

例题 · 八皇后 Checker Challenge洛谷 P1219普及/提高-去洛谷做这题 ↗

在 n×n 的棋盘上放 n 个棋子,要求每行、每列、每条对角线上至多有一个棋子(两条主对角线的所有平行线都算)。

输入格式:一行一个整数 n(6 ≤ n ≤ 13)。

输出格式:前三行输出前三个解,每个解给出第 1 行到第 n 行棋子所在的列号,数字之间用一个空格隔开;第四行输出解的总数。

输入: 6 输出: 2 4 6 1 3 5 3 6 2 5 1 4 4 1 5 2 6 3 4
按行放子 + O(1) 冲突检测

抓住“每行一个”。n 个棋子放 n 行,每行恰好一个(否则某行空着就必然有另一行挤两个)。于是 dfs 每层负责一行,层数就是行号,行冲突自然不存在——这就是“选对决策顺序”省掉一个维度的例子。

只剩三种冲突。同列、同一条左上—右下斜线、同一条左下—右上斜线。前者的判据是 j 相同,后两者的判据是 i − j 相同、i + j 相同(见前面的「对角线编号」函数卡)。三个 bool 数组一存,判断就是 O(1)。

回溯三行,只是标记三处。col[j] = d1[i+j] = d2[i-j+n] = 1; 链式赋值一次标三个,递归,再链式赋 0 撤三个。必须用同一个下标表达式标和撤,写错一处就前功尽弃。

出口两个动作。row 走到 n+1 时说明 n 行都放好了:先 ++ans,再判断 ans <= 3 决定要不要输出。注意先加再判,否则前三个解整体错位一个。

为什么这就是剪枝。一行 if (col[j] || d1[...] || d2[...]) continue; 就把“已经打起来”的整棵子树挡在门外。n = 13 时解有 73712 个,节点数在 10⁷ 量级,1 秒能跑完。

完整可编译代码(C++17)
#include <bits/stdc++.h>
using namespace std;

int n, ans;
int pos[15];                    // pos[i]:第 i 行的棋子放在第几列
bool col[30];                   // 列被占
bool d1[40];                    // i + j 这一族斜线被占
bool d2[40];                    // i - j + n 这一族斜线被占

void dfs(int row) {             // 正在决定第 row 行
    if (row > n) {              // n 行都放好了,得到一个解
        ++ans;                  // 先计数
        if (ans <= 3) {         // 只要前三个解
            for (int i = 1; i <= n; ++i)
                printf("%d%c", pos[i], i == n ? '\n' : ' ');
        }
        return;
    }
    for (int j = 1; j <= n; ++j) {
        // 可行性剪枝:列、两条斜线任意一处冲突,整棵子树都不必进
        if (col[j] || d1[row + j] || d2[row - j + n]) continue;
        pos[row] = j;
        col[j] = d1[row + j] = d2[row - j + n] = 1;     // ① 占三个位置
        dfs(row + 1);                                   // ② 递进
        col[j] = d1[row + j] = d2[row - j + n] = 0;     // ③ 撤这三个
    }
}

int main() {
    cin >> n;
    dfs(1);                    // 从第 1 行开始放
    cout << ans << "\n";       // 第四行:方案总数
    return 0;
}
逐行拆解
代码片段含义与理由
int pos[15];n ≤ 13,开 15 格。pos 存答案,和上一节的 a[step] 一样不需要还原:每一行都会被下一次尝试覆盖。
bool col[30]; bool d1[40]; bool d2[40];col 用 j(1..13);d1 用 row+j(2..26);d2 用 row−j+n(1..25)。开小了就是越界写,症状是答案随机、n 小的时候又碰巧全对。
if (row > n)出口在第 n+1 层,表示第 n 行已经放完。写成 row == n 会漏掉最后一行 → 答案恒为 0。
++ans; if (ans <= 3)先加后判。顺序反了会把“第 zero 个解”当成第一个输出,前三个解整体偏移一个。
if (col[j] || d1[row + j] || d2[row - j + n]) continue;一行写完三重冲突判断。三个条件用 || 短路求值,前一个为真就不再算后面,所以最常冲突的 col 放最前面。
col[j] = d1[row + j] = d2[row - j + n] = 1;赋值表达式从右往左算、返回值是被赋的那个值,所以一句能标三处。撤的时候下标必须一模一样,这是最容易手误的地方。
printf("%d%c", pos[i], i == n ? '\n' : ' ');行内用空格、行末用换行一步到位,不会出现行末多余空格。
这一节的 6 个坑,每个都有具体症状

迷宫忘记 g[nx][ny] = 0:第一条路把格子全占了,后面的路走不通,答案常常是 1 → WA。

迷宫没标记起点:某些数据下会出现“走到邻居再走回起点”的路径,答案偏大 → WA,而且很难复现。

连通块里多写了还原:整块被反复重算,答案虚高且规模指数膨胀 → TLE 或 WA。

先取 g[nx][ny] 再判越界:下标 −1 或 m+1,读到垃圾甚至写到别处 → RE(本机常常不崩,交上去段错误)。

八皇后对角线数组开小(比如 bool d1[15]):n = 13 时 row+j 到 26,越界写 → RE 或答案随机。

最优性剪枝写成 if (step > best) return; 或反成 <:前者少砍一些只是变慢,后者会把正确答案也砍掉 → WA。剪枝的边界符号写完要手算一个小例子验一遍。

自己动手复现

关掉页面,把迷宫那 30 行从零敲一遍,交到 P1605,确认样例输出是 1。

改一改①:把 g[nx][ny] = 0; 注释掉再跑同一组样例,先预测答案再执行——预测是 1,这说明“撤销”是路径计数的命根子。

改一改②:把 4 个方向换成 8 个方向(含对角),答案会变大还是变小?为什么变大的幅度远超直觉?

改一改③:把八皇后那三行还原整段注释掉,n = 6 时 ans 会变成 1 还是 4?跑一遍验证你的推理,顺便观察程序有没有崩。

数学补给 · 斜线编号与搜索树规模

斜线为什么能用一个整数编号。在方格上沿 (1,1) 方向走一步,i 和 j 同时加 1,所以 i − j 不变;沿 (1,−1) 方向走一步,i 加 1、j 减 1,所以 i + j 不变。反过来,i − j = c 是一族斜率为 +1 的直线,i + j = c 是一族斜率为 −1 的直线,每条直线在 n×n 棋盘上切出的格子正好就是一条“斜线”。于是“判两条斜线是否相同”= “判两个整数是否相等”。

取值范围:i − j ∈ [1−n, n−1],加 n 后 ∈ [1, 2n−1];i + j ∈ [2, 2n]。两族用两个数组,编号区间重叠也没有关系。这就是 n ≤ 13 时 bool d1[40] 足够的原因。

搜索树到底多大。8 皇后按行放、带列与斜线剪枝时,每一层还能站住的节点数如下:

层(已放子数)012345678
节点数184214034456855031292

合计 1 + 8 + 42 + 140 + 344 + 568 + 550 + 312 + 92 = 2057 个节点;而裸枚举是 8⁸ = 16777216 种摆法。剪枝把节点数压掉约 8000 倍,靠的不是“更快地排除”,而是整棵子树根本不进去。

更一般地:若每层平均能站住的分支数是 b,深度 n,节点数约 bⁿ。8 皇后这一列的平均 b 约 2.5,2.5⁸ ≈ 1526,与 2057 同一量级。剪枝的本质就是把 b 变小,b 在指数上,所以一点点改进都很值钱。

n678910111213
解的个数4409235272426801420073712

P1219 的 n 从 6 到 13,解的个数最多 73712,int 装得下;最后一次提交若把 ans <= 3 写漏,n = 13 时会输出七万多行,直接格式错。

举一反三 · 想清楚这些再往下走
  1. 迷宫若要求“输出字典序最小的那条路径”,方向数组的顺序该怎么排?为什么顺序会影响答案,而只计数时顺序完全无所谓?
  2. 连通块题若改成“最大的一块有多少格”,代码要加哪两个变量?为什么这时候“不撤销”反而让复杂度更低?
  3. 八皇后只要求输出一个解时(n = 13),还能加什么顺序剪枝或对称性剪枝?提示:棋盘左右对称,第一个解的第一行棋子可以限定在左半边。
  4. 如果迷宫要求“下一步必须转 90°,不能连续直走”,dfs 需要多带什么参数?状态数变成几维?
  5. 最优性剪枝里的 best 初值设成 0 还是设成一个足够大的数?两种分别在求最小值和求最大值时出什么错?
  6. 网格 DFS 深度等于格子数:10⁴ 还能扛,10⁶ 呢?为什么这种时候必须换成第 26 节的 BFS?(提示:dfs 的栈帧要一直留着,而 BFS 的队列里只放“当前层”。)
自测:能全部回答再进下一节
  • 能说清连通块与路径计数在“撤销”上的区别,并各写一段 20 行以内的代码。
  • 能默写 4 方向与 8 方向的方向数组,并说出 dx 与 dy 必须一一对应的原因。
  • 能推出 i + j 与 i − j + n 是两条斜线的编号,并算出 n = 13 时数组该开多大。
  • 能背出三类剪枝的名字、条件和典型写法,并指出八皇后用的是哪一类。
  • 知道为什么八皇后要按行放子,以及这样省掉了哪一维冲突判断。
  • 能手算 8 皇后的搜索节点数 2057,并解释它为什么远小于 8⁸。
挑战题 · Lake Counting S
洛谷 P1596普及-去洛谷做这题 ↗

一块 N×M 的田地,每个格子要么是积水 'W',要么是干地 '.'。八个方向相连的一群 'W' 算一个水坑(斜着相连也算)。求一共有多少个水坑。

输入格式:第一行两个整数 N、M(1 ≤ N, M ≤ 100);接下来 N 行,每行 M 个字符,只含 'W' 和 '.'。

输出格式:一行一个整数,表示水坑个数。

引导性提示(先只看这里)

从每一个还没被数过的 'W' 出发,把和它连通的 'W' 全部标记掉;每标完一片,答案就加 1。

连通包括 8 个方向,方向数组要写 8 组,而且 dx 与 dy 必须一一对应,错一组就少算一片。

已经数过的格子不需要还原——它属于当前这片水坑,之后不该再作为起点被数第二次。

想看思路了?点开完整思路

模型。把每个 'W' 看成一个点,八方向相邻的两点连一条边,题目问的就是这个图里有几个连通块。

做法。扫整个棋盘,遇到一个没访问过的 'W',就把它当作新水坑的入口:答案 +1,然后 dfs 把和它八方向连通的 'W' 全部改成 '.'(标记 = 直接改图,省掉 vis 数组)。

为什么不撤销。一个格子只属于一个水坑。dfs 把它改成 '.' 之后,外层扫描不会再把这片水坑的格子当成新入口,所以不会重复计数;如果撤销,同一片水坑会被反复数,答案虚高而且指数级变慢。

复杂度。每个格子最多被访问一次,每个格子查 8 个方向,总时间 O(8NM) = 8×10⁴,1 秒绰绰有余。递归深度最坏 10⁴(整块田都是水),栈完全够用。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int n, m, ans = 0;
char g[105][105];
int dx[8] = {-1, -1, -1, 0, 0, 1, 1, 1};    // 八方向:dx 与 dy 必须一一对应
int dy[8] = {-1, 0, 1, -1, 1, -1, 0, 1};

void dfs(int x, int y) {
    g[x][y] = '.';                          // 标记:直接改图,这一格以后不会再当入口
    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 (g[nx][ny] != 'W') continue;     // 只走水
        dfs(nx, ny);
    }
    // 这里没有 g[x][y] = 'W' —— 连通块不撤销
}

int main() {
    cin >> n >> m;
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j) cin >> g[i][j];   // cin >> char 会跳过空白,逐字符读正确

    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j)
            if (g[i][j] == 'W') { ++ans; dfs(i, j); }  // 每发现一个新入口,就多一个水坑

    cout << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
char g[105][105];N, M ≤ 100,开 105 留余量。用 char 存,'W' 和 '.' 直接可比,不需要额外的 vis 数组。
cin >> g[i][j];cin >> char 会自动跳过空格和换行,所以逐字符读入是安全的;换成 scanf("%c", &g[i][j]) 就会把行末换行也读进来,整张图错位。
if (g[i][j] == 'W') { ++ans; dfs(i, j); }外层扫描每遇到一个还是水的格子,就说明发现了一片新水坑:先计数,再让 dfs 把整片吃掉。
g[x][y] = '.';标记和“删除”是同一个动作。放在函数开头而不是循环里,是为了让入口格子也立刻被标记。
if (g[nx][ny] != 'W') continue;一个判断同时挡掉 '.' 和已访问过的 'W'(它们都已经被改成 '.' 了)。
// 没有 g[x][y] = 'W'整段代码唯一省略的那一行,正是连通块与路径计数的分界线。
这题最容易错的地方
  • 只写 4 个方向:斜向相连的水坑被拆成好几片,答案偏大 → WA。
  • 把标记写在 for 循环之后(先遍历再标记):同一格被重复进入,递归互相调用 → RE(爆栈)。
  • 用 scanf("%c") 读图且不处理换行:整张图整体错位,答案离谱。
  • 多写了还原 g[x][y] = 'W';:一片水坑被反复重算,小数据答案偏大、大数据直接 TLE。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》第 0x22 节:深度优先搜索与剪枝
  • 《算法竞赛试炼场》搜索专题:迷宫路径与八皇后
  • 洛谷 P1605、P1219、P1596 题解区的高赞实现
25 回顾五:递归与 DFS普及/提高-

25 · 回顾五:递归与 DFS

把第 21~24 节收成一副骨架:出口、状态、标记、回溯、剪枝。以八皇后为例把五个动作全走一遍,再用 5 道题分别练递归读串、网格选数、网格路径计数、搜索剪枝和带回溯的全局最优。
⭐ 已学范围回顾训练普及/提高-建议 90 分钟回顾5 题

第 21~24 节只教了一件东西:把一个大问题拆成「第一步 + 剩下的同样问题」,拆到不能再拆(出口),再把答案一层层抛回来。四节课是同一副骨架换了四种状态:递归算数、递推打表、枚举组合排列、在网格上走路。这一节不讲新算法,只练四个动作——出口写对、状态写全、现场恢复、不该走的枝不走了。

DFS 的分数 90% 不在 for 循环里,而在三个地方:出口、标记数组的下标、回溯那一行。
节内容一句话判据
21递归:出口、递推与栈函数体第一句是 if (到出口) { 记答案; return; },之后才是「拆小 + 合并」
22递推与记忆化搜索同一个子问题被算第二次,就该把它存进表里
23DFS 枚举:子集、组合与排列题目问「有哪些选法 / 怎么排」,就画一棵每个结点开 n 个儿子的树
24DFS 与回溯:网格搜索与剪枝走一步改状态,退一步还原状态;注定不行的路当场 continue
例题 · 八皇后洛谷 P1219普及/提高-去洛谷做这题 ↗

在 n × n 的棋盘上放 n 个皇后,要求每行、每列、每条对角线(两条主对角线方向上的所有平行线)上至多有一个皇后。

一个解用序列 a1 a2 … an 表示,ai 是第 i 行皇后所在的列号。按字典序输出前 3 个解,每行 n 个数;最后一行输出解的总数。

输入格式:一行一个整数 n(6 ≤ n ≤ 13)。

输出格式:前三行为前三个解,同一行的数字用一个空格隔开;第四行只有一个整数,表示解的总数。

输入: 6 输出: 2 4 6 1 3 5 3 6 2 5 1 4 4 1 5 2 6 3 4

第 24 节用这题讲过回溯,这一节不换题——闭卷把它重写一遍,看能不能一次写对。它把四节的东西一次用全:递归(出口 + 自己调自己)、回溯(放完要还原)、剪枝(三条线被占就不走)、枚举(列 1~n 依次试)。而且它同时要两样输出——前三个解和总数——正好逼你把「出口」和「计数」两件事分开写清楚。

新函数拆解 · 递归函数(DFS 骨架)
void dfs(int 状态参数, ...)
组成含义 / 格式要求
返回类型只做枚举和计数时用 void,答案写进全局变量;只求「能不能到」时用 bool。
参数只放「随分支变化」的量:第几行、第几步、当前和。不随分支变的(n、棋盘大小)一律放全局。
出口函数体第一句永远是 if (到出口) { 记录/输出; return; }。没有出口就是无限递归,结果 RE(爆栈)。
分支for 枚举这一步的所有选择,每个选择先「改状态」,再递归,再「还原状态」。
回溯递归返回后把状态改回去。漏了这一步,后面的分支会看到被污染的状态,答案偏少且难查。
深度上限默认栈约 8MB,一帧 48~64 字节,递归到 10⁵ 层就可能爆栈。更深的搜索要改手写栈或 BFS。
最小可运行示例
示例
int n, ans = 0;

void dfs(int step) {
    if (step > n) { ++ans; return; }      // 出口:n 步都走完了,得到一种方案
    for (int i = 1; i <= n; ++i) {         // 枚举这一步的所有选择
        // 改状态(本题没有需要改的数组)
        dfs(step + 1);                     // 递归到下一步
        // 还原状态
    }
}
注意

出口里的 return 不能省,否则走完一步还会接着往下枚举,同一方案被重复计数。

递归函数的返回值只用来「传答案」或「报告成败」,不要既返回值又改全局量,那样必然乱。

新函数拆解 · 全局标记数组
bool vis[N]; // 写在 main 外面
组成含义 / 格式要求
为什么放全局全局变量在静态区:自动清零(false),而且不占栈空间。在 main 里开 10⁶ 个 int(4MB)就可能爆栈。
两种写法vis[x] = true 记录访问过;网格题里也可以直接把地图 g[x][y] 改成 '.',省一个数组。
成套出现mark(x); dfs(...); unmark(x); 三句一定连在一起,中间不要插别的逻辑。
数组大小按最大可能下标开,不是按元素个数:列号最大 n,就开 n+2;对角线下标最大 2n,就开 2n+2。
下标要能当索引负数不能做下标。row - col 最小是 1-n,加 n 平移成 1 ~ 2n-1 就能用了。
最小可运行示例
示例
int n;
bool col[30], d1[30], d2[30];      // 列、↘ 方向、↙ 方向

// 试图把第 row 行的皇后放在第 c 列
if (col[c] || d1[row + c] || d2[row - c + n]) continue;   // 三条线有任一条被占,就不能放
col[c] = d1[row + c] = d2[row - c + n] = true;            // 占住三条线
dfs(row + 1);
col[c] = d1[row + c] = d2[row - c + n] = false;           // 回溯:三处一起还原
注意

连续赋值 a = b = c = true 是右结合,三处都会被赋 true,可以放心连写。

还原时漏掉一条线,那个位置后面就永远被占着,解会少一大截——这是八皇后最典型的错法。

新函数拆解 · memset(批量初始化)
memset(数组名, 值, sizeof 数组名);
组成含义 / 格式要求
按字节填memset 把内存按字节填成同一个值:填 0 得到全 0,填 -1 得到全 -1(每字节 0xFF),填 1 得到 16843009。
只有 0 / -1 能用想把 int 数组全设成 5、把 double 设成 0.3,memset 都做不到,只能用 fill 或循环。
sizeof对数组名写 sizeof f。写成 sizeof(int) 只清 4 个字节;数组传进函数后已退化成指针,此时 sizeof 只有 8。
替代品fill(f, f + N, 值) 可以填任意值,速度略慢,但语义清楚。
什么时候需要记忆化搜索里「没算过」要用 -1 标记时;多组数据之间清空状态数组时。
最小可运行示例
示例
long long f[40][40];              // 全局:本来就全是 0,不用清

int dp[1005];
memset(dp, -1, sizeof dp);       // 全部变成 -1,用作「还没算过」的标记
// memset(dp, 1, sizeof dp);      // 错误:得到 16843009,不是 1
注意

用 0 当「没算过」的标记时,必须保证真实答案是 0 的情况不存在;否则要换成 -1。

memset 只能处理 POD 类型(int、char、double、结构体),vector、string 不要 memset。

新函数拆解 · 方向数组
const int dx[4] = {-1, 1, 0, 0}; const int dy[4] = {0, 0, -1, 1};
组成含义 / 格式要求
两个下标数组dx[k]、dy[k] 是第 k 个方向的横纵增量,配成一对才表示一个方向。
4 连通上下左右:(-1,0) (1,0) (0,-1) (0,1)。顺序只影响输出顺序,不影响答案。
8 连通再加四条斜线 (-1,-1) (-1,1) (1,-1) (1,1),一共 8 组,两个数组要一一对应。
越界判断在前nx < 1 || nx > n || ny < 1 || ny > m 必须写在访问 g[nx][ny] 之前。
const方向表是常量,加 const 让编译器直接内联,也防手滑改掉。
最小可运行示例
示例
const int dx[8] = {-1, -1, -1, 0, 0, 1, 1, 1};
const int dy[8] = {-1,  0,  1, -1, 1, -1, 0, 1};

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 (g[nx][ny] == 'W') dfs(nx, ny);
}
注意

8 个方向手写 8 个 if 极易把 dy 抄错;写成表以后,改方向只改表,循环体不动。

方向表用不用哨兵边界(四周留一圈 '.')是风格问题;留了就不用判界,但数组要开大两圈。

例题怎么想:状态 → 出口 → 分支 → 剪枝,四步定完

① 状态:dfs(row) 表示「第 1 ~ row-1 行的皇后都已放好,现在给第 row 行找位置」。棋盘大小 n 全程不变,放全局,不进参数。

② 出口:row > n 时 n 行都放好了,计数 +1,前 3 个解顺便输出。

③ 分支:第 row 行可以放在 1 ~ n 任意一列,所以 for (int c = 1; c <= n; ++c)。

④ 剪枝:这一列、这条 ↘ 线、这条 ↙ 线上已经有皇后,就 continue,不进递归。三个标记数组就是剪枝的全部实现。

为什么按行枚举就够:一行只放一个皇后,「行」是天生的层数,所以不必再判行冲突。把二维问题压成一维,是所有棋盘类 DFS 的第一步。

为什么结果天然是字典序:外层行号递增、内层列号递增,先到达出口的解一定比后到达的字典序小。所以「前三个解」就是「前三次到达出口」,不需要额外排序。

为什么这里不需要记忆化:每条从根到出口的路径都是一个不同的解,子问题之间没有重复计算,存表只会添乱。第 22 节的记忆化只在「同一个局面被不同路径反复走到」时才划算,八皇后不是这种情况。

规模感受:n = 8 有 92 个解,n = 12 有 14200 个,n = 13 有 73712 个。剪枝后搜索树只剩这些分支及其附近的死路,n ≤ 13 一秒内跑完;把剪枝去掉是 13¹³ ≈ 3×10¹⁴ 条路,永远跑不完。

完整代码 · 八皇后(递归 + 回溯 + 三条对角线剪枝)
#include <bits/stdc++.h>
using namespace std;

int n;
int total = 0;            // 解的总数:全局,dfs 的每个分支都要改它
int pos[15];              // pos[r] = 第 r 行皇后所在的列,输出解时按行读
bool col[30];             // col[c]:第 c 列是否被占
bool d1[30], d2[30];      // 两条对角线方向:row+col 相同 / row-col 相同

// 前 row-1 行已放好,现在给第 row 行找一个列
void dfs(int row) {
    if (row > n) {                                   // 出口:n 行都放好了
        ++total;
        if (total <= 3) {                            // 只要字典序前三个解
            for (int r = 1; r <= n; ++r)
                cout << pos[r] << (r == n ? '\n' : ' ');
        }
        return;                                      // 必须 return,否则还会接着找位置
    }
    for (int c = 1; c <= n; ++c) {                   // 列从小到大:字典序由此而来
        if (col[c] || d1[row + c] || d2[row - c + n])
            continue;                                // 剪枝:三条线任一被占就不放
        col[c] = d1[row + c] = d2[row - c + n] = true;   // 占住三条线
        pos[row] = c;
        dfs(row + 1);                                // 交给下一行
        col[c] = d1[row + c] = d2[row - c + n] = false;  // 回溯:三处一起还原
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cin >> n;
    dfs(1);                                          // 从第 1 行开始放
    cout << total << "\n";                           // 最后一行:解的总数
    return 0;
}
逐行拆解
代码片段含义与理由
#include <bits/stdc++.h>竞赛万能头,一次包含 iostream、algorithm 等全部标准头。
int total = 0;解的总数。全局变量在静态区自动清零,dfs 的任意一层都能直接改它。
int pos[15];记录每一行的列号。dfs 内部必须保留,否则出口处拼不出这个解。n ≤ 13,开 15 够用。
bool col[30];列标记。列下标只到 13,开 30 是留余量——数组开小了是 RE,不是 WA。
bool d1[30], d2[30];两条对角线方向。row+col 相同的格在同一条 ↘ 线上,编号 2 ~ 2n;row-col 相同的在同一条 ↙ 线上,编号 1-n ~ n-1。
void dfs(int row)状态只有一个 row。层数本身就是状态,不必额外传「已经放了几行」。
if (row > n)出口。第 n 行放完后 row 变成 n+1,写成 row == n 就永远漏掉最后一行。
++total;先加再判断,所以前三次到达出口都会被输出。
if (total <= 3)写成 total < 3 只输出两个解,这是本题最常见的 WA。
for (int c = 1; c <= n; ++c)列从小到大枚举。改成 c = n; c >= 1; --c 输出的就是字典序最大的三个解。
if (col[c] || d1[row + c] || d2[row - c + n])三条线任一被占就跳过。短路求值,前面的判断为真时后面不执行,也顺带避免了无效下标。
col[c] = d1[...] = d2[...] = true;连等号右结合,三处一起标记,等价于三句赋值。
dfs(row + 1);放下这一行,交给下一行。这里不需要「成功/失败」的返回值,因为要穷举全部解。
col[c] = d1[...] = d2[...] = false;回溯:把三处一起还原。漏一处,后面的分支就以为那条线还被占着,解会变少。
cout << total << "\n";总数在主函数里输出一次,不要在出口里输出——出口会被执行 total 次。
这一章最容易踩的 6 个坑(每个都写清了症状)

出口写成 row == n:最后一行永远放不上。症状是答案偏小甚至为 0,样例一跑就不对。

出口忘了 return:计数完还继续给第 n+1 行找位置。症状是答案大出几倍,n 稍大直接 TLE。

对角线下标不偏移:row - col 出现负数就当成下标用。症状是本地样例正常、评测 RE 或随机 WA,因为负下标属于越界访问,行为不确定。

标记数组按「大小」而不是「最大下标」开:n = 13 时 row + col 能到 26,开 bool d1[15] 就是越界。症状是小数据全对、n 一变大立刻 RE。

回溯漏还原:只写了 col[c] = false,忘了 d1、d2。症状是答案偏少,n = 8 时总数从 92 掉到几十。

输出格式:把总数写在每个解后面,或行末多打一个空格。症状是样例看着对、提交 WA。

自己动手复现

关掉页面,把上面 P1219 的代码手打一遍,输入 6,核对是不是三行解加一行 4。

改一改①:把 if (total <= 3) 改成 if (total < 3),看是不是只剩两个解。

改一改②:把三个标记数组都开成 [15],输入 13——这是越界的现场。

改一改③:删掉回溯里还原 d2 的那一句,输入 8,看 92 变成多少。

改一改④:把列循环改成从 n 到 1 倒着枚举,再输入 6,观察输出的三个解——字典序原来只由枚举顺序决定。

第 21~24 节各留一句话,以后写搜索前按这个顺序检查

21 递归:先写出口,再写递归调用。函数里第一句永远是出口判断。

22 记忆化:同一个状态算第二次就是浪费。递归 + 一张 f 表,「算过就取、算完就存」。

23 枚举:子集是「选 / 不选」,组合是「从 start 往后选」,排列是「vis 标记 + 全枚举」。三种树不同,出口相同。

24 网格:方向数组 + 越界判断 + 标记 + 回溯,四件缺一件就是 RE 或死循环。

下面 5 道题按「递归 → 网格选数 → 网格路径 → 剪枝 → 回溯」排:前两题把模板写熟,第三题多带一个状态,第四题专门练剪枝,最后一题回到字符串。每题都先自己想 20 分钟,卡住了再看提示。

题编号主要考什么最容易错在哪
AP1928递归下降:读一段,读到配对括号为止读到 ] 忘了 return;重复次数读的位置不对
BP1123网格 DFS:选 / 不选 + 8 邻域检查只查 4 邻域;多组数据忘了清空 used 与 best
CP7995网格 DFS + 状态:走格子并数转向次数第一步也算转向;dir 的编码两处不一致
DP10483搜索 + 剪枝:分组使组数最少不排序导致剪枝失效 TLE;回溯漏还原载重
EP1019回溯 + 全局最优:每一步都更新答案重叠长度取了最大而不是最小
这 90 分钟怎么用

0~8 分钟:只读题,按「能立刻写 → 有思路 → 没思路」排序。这五题代码都不长,难在想清楚状态。

8~22 分钟:A(P1928)。先想清楚递归函数返回什么、在哪里停,再动手写。

22~38 分钟:B(P1123)。骨架就是第 23 节的「选 / 不选」,多一步邻域检查。

38~55 分钟:C(P7995)。注意「转向次数」是状态的一部分,不能只传坐标。

55~72 分钟:D(P10483)。先写出不剪枝的版本对拍小数据,再加两条剪枝。

72~90 分钟:E(P1019)。重点在重叠长度的预处理;DFS 本身只是带回溯的模板。

提交前的 30 秒检查清单(搜索题专用)

每个 dfs 的第一行是不是出口?出口里有没有 return?

每一处「改状态」,返回后有没有配一句「还原状态」?数量对上没有?

标记数组的实际最大下标是多少?开的大小是不是比它大?

最坏递归深度是多少?超过 10⁵ 就要考虑爆栈,或改用 BFS。

有没有同一个状态被反复计算?有就加记忆化,或者用剪枝不再往下搜。

多组数据的题:答案、标记数组、累加量,每组开始前都重置了吗?

本次训练 · 共 5 题

做题方式

限时 100 分钟。每题先自己写 20~25 分钟;写不出来先看提示,再给自己 10 分钟;还不行才点开思路。每题都独立限时,不要一题耗死。

大题 挑战题 1 · 外星密码
洛谷 P1928普及/提高-去洛谷做这题 ↗

外星人把连续的若干个相同子串 X 压缩成 [DX] 的形式,其中 D 是 1 到 99 的整数。例如 CBCBCBCB 可以压成 [4CB],也可以压成 [2[2CB]];后者叫二重压缩,方括号还可以继续嵌套。

例如 AC[3FUN] 展开是 ACFUNFUNFUN,[2[2A]] 展开是 AAAA。

给定一个压缩串,输出解压后的字符串。

输入格式:一行一个字符串,只含数字、大写字母和方括号,保证格式合法;解压后的长度不超过 20000,压缩最多十重。

输出格式:一行,解压后的字符串。

引导性提示(先只看这里)

这个串的结构天生是递归的:一个方括号里可以再套方括号,结构完全一样。

把「从当前位置一直读到配对的方括号为止」写成一个函数,让它自己调用自己。

读到左括号时,先读出重复次数,再递归读出里面那一整段,最后把那段重复拼接上去。

想看思路了?点开完整思路

递归函数 readStr() 的语义是:从当前读位置开始,读一段内容,读到配对的 ] 或输入末尾为止,返回这段内容。

循环读字符,三种情况:普通字母 → 直接接在结果后面;[ → 读整数 D,递归读出子串 t,把 t 重复 D 次接上;] → 本层读完,return。

出口有两个:读到 ](这一层结束)和读到输入末尾(最外层结束)。

为什么这样一定对:左括号和右括号严格配对,递归进入一层就消耗掉一个左括号,返回时刚好消耗掉配对的右括号,流的位置自然停在括号后面,外层接着往下读。

复杂度:每个字符只被读一次,拼接总量等于输出长度,总时间 O(输出长度)。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

// 从当前读位置读一段,读到配对的 ']' 或输入结束为止
string readStr() {
    string s;
    char c;
    while (cin >> c) {                  // 一次读一个字符
        if (c == '[') {                 // 遇到左括号:后面紧跟重复次数
            int k = 0;
            cin >> k;                   // 读整数,正好停在内容第一个字符上
            string t = readStr();       // 递归读括号里的内容
            while (k--) s += t;         // 重复 k 次(k-- 是先用后减)
        } else if (c == ']') {          // 出口:这一层结束
            return s;
        } else {
            s += c;                     // 普通字母:直接接上
        }
    }
    return s;                           // 最外层读到输入结束也要返回值
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout << readStr() << "\n";
    return 0;
}
关键片段拆解
片段作用
while (cin >> c)一次读一个字符;cin 在读不到东西时转成 false,循环自动结束。
if (c == '[')左括号表示「这里要重复」,它后面紧跟的是次数。
cin >> k读整数会自动跳过空白,并读到第一个非数字字符为止,正好停在内容的第一个字符上。
string t = readStr();递归读内容:这一层不知道里面有多长,交给同名函数读到配对的右括号。
while (k--) s += t;k-- 先取值再减,循环体恰好执行 k 次;k 是局部变量,不影响别的层。
} else if (c == ']') { return s; }出口。把本层攒到的内容交回上一层,同时右括号已经被消耗掉。
return s;最外层读到输入结束后,循环退出,同样要返回结果。
这题最容易错的地方
  • ] 分支忘记 return,函数会继续往下读,把外层的内容也吞进来,输出整体错乱。
  • 读入顺序反了:先 cin >> k 再判断 c == '[',遇到字母时 cin 会失败,之后所有读入失效,输出空串。
  • 用 getline 读整行再手动递归,忘了前面 cin >> n 残留的换行。
  • 输出用 endl 逐行刷新,输出很长时明显变慢;本题只需拼好一次输出。
大题 挑战题 2 · 取数游戏
洛谷 P1123普及/提高-去洛谷做这题 ↗

给定一个 N × M 的由非负整数构成的矩阵,从中取出若干个数,要求取出的任意两个数不相邻(一个格子在另一个格子的 8 个相邻格中,就认为这两个数相邻),求取出的数之和的最大值。

输入格式:第一行一个正整数 T(T ≤ 20),表示数据组数。每组数据的第一行两个正整数 N、M(1 ≤ N, M ≤ 6);接下来 N 行,每行 M 个不超过 10⁵ 的非负整数。

输出格式:共 T 行,每行一个非负整数,表示该组数据的最大和。

引导性提示(先只看这里)

每个格子只有两种命运:取或不取。按行序一个个决定,就得到一棵二叉搜索树。

决定「取」之前要检查它和已经决定过的格子是否相邻。按行序处理时,只需要看左、左上、上、右上四个方向。

所有格子都决定完才能更新答案;走不下去时,用「剩下的格子全取也超不过当前最优」把这一支砍掉。

想看思路了?点开完整思路

状态:dfs(x, y, sum) 表示正在决定第 x 行第 y 列,已取数字之和是 sum。

分支:不取 → 直接走到下一个格子;取 → 先检查 4 个已处理方向的邻格,没冲突才标记并累加。

出口:走完最后一行时用 sum 更新 best。

剪枝:预处理 suf[x][y] = 从 (x,y) 按行序到末尾的所有数字之和。若 sum + suf[x][y] <= best,后面的格子全取也不可能更好,直接 return。

为什么只查 4 个方向:行序遍历时,右、左下、下、右下都还没决定,不可能与当前格冲突。

规模:6×6 网格的独立集约 5.6×10⁶ 个,比 2³⁶ = 6.9×10¹⁰ 小得多;再加后缀和剪枝,T ≤ 20 也很快。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int T, n, m;
int a[8][8];
bool used[8][8];
int suf[8][8];              // suf[x][y]:从 (x,y) 按行序到末尾的所有数字之和
int best;

// 按行序处理时,只需要检查已经决定过的 4 个方向
const int dx[4] = {0, -1, -1, -1};
const int dy[4] = {-1, -1, 0, 1};

bool canTake(int x, int y) {
    for (int k = 0; k < 4; ++k) {
        int nx = x + dx[k], ny = y + dy[k];
        if (nx < 1 || nx > n || ny < 1 || ny > m) continue;
        if (used[nx][ny]) return false;          // 和已选的格子相邻,不能取
    }
    return true;
}

void dfs(int x, int y, int sum) {
    if (x > n) {                                 // 出口:所有格子都决定完了
        best = max(best, sum);
        return;
    }
    if (sum + suf[x][y] <= best) return;         // 剪枝:后面全取也超不过当前最优

    int nx = x, ny = y + 1;                      // 下一个格子(到行末就换行)
    if (ny > m) { nx = x + 1; ny = 1; }

    dfs(nx, ny, sum);                            // 分支一:不取当前格
    if (canTake(x, y)) {                         // 分支二:取当前格
        used[x][y] = true;
        dfs(nx, ny, sum + a[x][y]);
        used[x][y] = false;                      // 回溯:还回去
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cin >> T;
    while (T--) {
        cin >> n >> m;
        for (int i = 1; i <= n; ++i)
            for (int j = 1; j <= m; ++j) {
                cin >> a[i][j];
                used[i][j] = false;              // 多组数据:每组都要清空
            }
        memset(suf, 0, sizeof suf);              // 边界 suf[n+1][1] 必须是 0,多组数据要还原
        for (int i = n; i >= 1; --i)             // 从右下往左上递推后缀和
            for (int j = m; j >= 1; --j) {
                int nxt = (j == m) ? suf[i + 1][1] : suf[i][j + 1];
                suf[i][j] = a[i][j] + nxt;
            }
        best = 0;
        dfs(1, 1, 0);
        cout << best << "\n";
    }
    return 0;
}
关键片段拆解
片段作用
int suf[8][8];后缀和表:把「剩下所有格子的数字之和」预先算好,剪枝时 O(1) 查。
for (int i = n; i >= 1; --i)从右下往左上递推:每个位置 = 自己的值 + 下一个位置的后缀和。i = n、j = m 时会读到边界 suf[n+1][1],所以上面先 memset 成 0。
if (sum + suf[x][y] <= best) return;所有数字非负,所以「后面全取」是这个分支能达到的上界;上界都不如已知最优就砍掉。
int nx = x, ny = y + 1; if (ny > m) { nx = x + 1; ny = 1; }把二维格子压成一条顺序,递归时只剩一个「下一个」。
dfs(nx, ny, sum);分支一:不取,什么都不改,直接往后走。
if (canTake(x, y))分支二:只有和已决定的 4 个邻格都不冲突才取。
used[x][y] = false;回溯:离开这一支前必须还回去,否则后面所有分支都以为这格被占了。
used[i][j] = false;多组数据:上一组的标记必须在这里清掉,否则第二组开始全错。
这题最容易错的地方
  • 邻域检查写错:只查上下左右 4 个方向(漏斜对角),或者「取」的分支干脆不检查 —— 两种都会取出相邻的数,答案偏大。
  • 回溯漏写 used[x][y] = false → 后面的分支全被挡住,答案偏小。
  • 多组数据忘了清空 used 或 best → 从第二组开始答案全错。
  • 剪枝里把 suf[x][y] 写成 suf[x][y + 1],把当前格也算进「已取」→ 漏掉最优解。
大题 挑战题 3 · Walking Home B
洛谷 P7995普及/提高-去洛谷做这题 ↗

农场是一个 N × N 的方阵(2 ≤ N ≤ 50),草地在左上角 (1,1),牛棚在右下角 (N,N)。

Bessie 只向下或向右走,有干草捆的格子(H)不能通过。她希望改变行走方向至多 K 次(1 ≤ K ≤ 3)。

求不同的路线数量。两条路线只要经过的格子集合不同就算不同。

输入格式:第一行一个整数 T(1 ≤ T ≤ 50),表示子测试用例数。每个用例的第一行是 N、K;接下来 N 行,每行 N 个字符,. 表示空格子,H 表示干草捆。保证起点和终点不是干草捆。

输出格式:共 T 行,第 i 行是第 i 个用例的路线数。

引导性提示(先只看这里)

只往右下走,所以一条路线最多 2N-2 步,可以完整枚举。

递归时多带两个信息:上一步的方向(下 / 右),以及已经转了几次方向。

换方向才 +1,沿原方向继续走不 +1;转向预算超了就不要走这一格。

想看思路了?点开完整思路

状态:dfs(x, y, turns, dir) 表示站在 (x,y),已经转了 turns 次,最后一步的方向是 dir(0 下、1 右)。

分支:向下或向右,各判一次「这一格能不能走」和「换方向后 turns 是否超 K」。

出口:走到 (N,N) 就 ++ans,然后立刻 return。

起点的第一步不算转向,所以先单独迈出第一步(向下或向右),再进入递归。

为什么不会重复计数:只往右下走时,一条路线的方向序列唯一确定这条路线,反过来也唯一,所以枚举方向序列等于枚举路线。

复杂度:K = 3 时合法路线只有 O(N³) 级别,N ≤ 50 时不过十几万条,T ≤ 50 也跑得完。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int T, n, K;
char g[55][55];
long long ans;

// x,y:当前位置;turns:已经转向几次;dir:0 表示上一步向下,1 表示向右
void dfs(int x, int y, int turns, int dir) {
    if (x == n && y == n) { ++ans; return; }      // 出口:到牛棚
    if (x + 1 <= n && g[x + 1][y] == '.') {       // 向下走一格
        int t = (dir == 1) ? 1 : 0;               // 原来向右,现在向下 = 转一次
        if (turns + t <= K) dfs(x + 1, y, turns + t, 0);
    }
    if (y + 1 <= n && g[x][y + 1] == '.') {       // 向右走一格
        int t = (dir == 0) ? 1 : 0;
        if (turns + t <= K) dfs(x, y + 1, turns + t, 1);
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cin >> T;
    while (T--) {
        cin >> n >> K;
        for (int i = 1; i <= n; ++i)
            for (int j = 1; j <= n; ++j) cin >> g[i][j];

        ans = 0;
        if (g[2][1] == '.') dfs(2, 1, 0, 0);      // 第一步向下:前面没有方向,不算转向
        if (g[1][2] == '.') dfs(1, 2, 0, 1);      // 第一步向右:同样不算转向
        cout << ans << "\n";
    }
    return 0;
}
关键片段拆解
片段作用
void dfs(int x, int y, int turns, int dir)四个参数都是随分支变化的状态:位置、已转次数、上一步方向。
if (x == n && y == n) { ++ans; return; }出口。走到牛棚就完成一条路线,必须立刻 return,否则还会继续往下试探。
int t = (dir == 1) ? 1 : 0;方向变了才算一次转向;沿原方向继续走 t = 0。
if (turns + t <= K)转向预算超了就不走这一格,等于整棵子树被剪掉。
if (g[x + 1][y] == '.')干草捆不能通过;x + 1 <= n 顺便把越界挡掉。
dfs(2, 1, 0, 0)起点单独迈第一步:第一步前面没有方向,所以不计转向,这正是本题最容易错的地方。
ans = 0;多组数据,每组开始前必须清零。
这题最容易错的地方
  • 把第一步也算成一次转向 → K = 1 时答案几乎全错(少了所有只转一次的路线)。
  • 方向编码两处不一致(一处判断 dir == 0、另一处传了 2)→ 转向次数算错。
  • 出口忘记 return,或者把「走到最后一行 / 最后一列」当成到达终点 → 重复计数。
  • 多组数据忘了把 ans 置 0 → 答案一路累加,越往后越大。
大题 挑战题 4 · 小猫爬山
洛谷 P10483普及/提高-去洛谷做这题 ↗

N 只小猫要坐索道下山(N ≤ 18)。第 i 只小猫重 Ci,每辆缆车的最大承重是 W,一辆车上小猫的重量之和不能超过 W。保证每只猫的重量都不超过 W。

每租一辆缆车要付 1 美元,求把所有小猫都运下山最少要付多少美元(也就是最少几辆车)。

输入格式:第一行两个用空格隔开的整数 N、W(1 ≤ Ci, W ≤ 10⁸);接下来 N 行,每行一个整数 Ci。

输出格式:一个整数,表示最少缆车数。

引导性提示(先只看这里)

把猫一只只往车上放:放进某一辆已经开过的车,或者新开一辆,这就是一棵搜索树。

直接枚举到 3¹⁸ 级别会超时,必须剪枝:当前车数已经不优于已知答案,就没必要再往下搜。

先放大猫。大猫可选的车少,越早把「车不够用」的分支砍掉,剪枝越有效。

想看思路了?点开完整思路

状态:dfs(i, cnt) 表示正在安排第 i 只猫,目前已经租了 cnt 辆车;car[j] 记第 j 辆车的当前载重。

分支:放进某辆还装得下的车;或者新开一辆车。

出口:i > N 时用 cnt 更新答案。

剪枝一(最优性):cnt >= ans 时直接 return——已经租的车不比已知答案少,后面再完美也不可能更优。

剪枝二(对称性):如果这只猫放进去之前那辆车是空的,说明前面几辆都装不下它;此时再试其它空车完全是重复枚举,直接 break。

排序:把 C 从大到小排。这是本题能过的关键,不是可有可无的优化。

复杂度:3^18 ≈ 3.9×10⁸ 太大,但排序 + 两条剪枝之后实际搜索量很小,N = 18 稳定通过。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int n, W;
int c[25];
int car[25];              // car[j]:第 j 辆车当前的载重
int ans;

void dfs(int i, int cnt) {              // 安排第 i 只猫,已用 cnt 辆车
    if (cnt >= ans) return;             // 剪枝一:车数已不优于答案,放弃这一支
    if (i > n) {                        // 出口:全部安排完
        ans = cnt;                      // 能到这里的 cnt 一定比 ans 小
        return;
    }
    for (int j = 1; j <= cnt; ++j) {
        if (car[j] + c[i] <= W) {
            car[j] += c[i];
            dfs(i + 1, cnt);
            car[j] -= c[i];             // 回溯:把猫从车上拿下来
            if (car[j] == 0) break;     // 剪枝二:空车彼此等价,不再重复试
        }
    }
    car[cnt + 1] = c[i];                // 新开一辆车
    dfs(i + 1, cnt + 1);
    car[cnt + 1] = 0;                   // 回溯
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cin >> n >> W;
    for (int i = 1; i <= n; ++i) cin >> c[i];
    sort(c + 1, c + n + 1, greater<int>());   // 大猫先放,剪枝才狠
    ans = n;                                  // 上界:一只猫一辆车
    dfs(1, 0);
    cout << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
int car[25];记录每辆车当前的载重。搜索时只增不减,回溯时还原。
if (cnt >= ans) return;剪枝一。已经租了 cnt 辆,车数不可能再变小,这一支直接放弃。
if (i > n) { ans = cnt; return; }出口。能走到这里说明 cnt < ans,所以直接赋值就是更新最优。
if (car[j] + c[i] <= W)塞得进第 j 辆车才塞;塞不下就换一辆,都不行就新开。
car[j] -= c[i];回溯:把猫拿下来,否则下一支会看到被撑满的车,答案偏大。
if (car[j] == 0) break;剪枝二。放进去之前是空车,说明前面几辆都装不下它;再试别的空车是重复枚举。
sort(c + 1, c + n + 1, greater<int>());大猫先放。大猫可选的车少,尽早触发剪枝一。
ans = n;上界:一只猫一辆车一定有解,所以答案不会超过 n。
这题最容易错的地方
  • 忘记排序(或从小到大排)→ 剪枝一几乎不触发,N = 18 时 TLE。
  • 回溯漏写:car[j] -= c[i] 或 car[cnt + 1] = 0 少一句 → 载重越算越大,答案偏大。
  • ans 初始化成 0 或 INT_MAX:0 会让所有分支被剪光(输出 0),INT_MAX 会让剪枝一失效(TLE)。
  • 剪枝二的 break 写成 continue → 后面几辆空车被重复枚举,最坏情况退化成指数级。
大题 挑战题 5 · 单词接龙
洛谷 P1019普及/提高-去洛谷做这题 ↗

给定一组单词和一个开头字母,要求接出以该字母开头的最长的「龙」。每个单词最多在龙中出现两次。

两个单词相连时,重合部分合并成一部分(例如 beast 和 astonish 可以接成 beastonish);相邻两部分不能存在包含关系,例如 at 与 atide 之间不能相连。

输入格式:第一行一个整数 n(n ≤ 20)表示单词数;接下来 n 行每行一个单词;最后一行为一个单字符,表示龙开头的字母。保证以该字母开头的龙一定存在。

输出格式:一行一个整数,表示以此字母开头的最长的龙的长度。

引导性提示(先只看这里)

两个单词能不能接、接上以后长度增加多少,只跟这两个词有关,可以先把所有配对算好存起来。

重叠部分越短,龙就越长——同一个配对有多种合法重叠时,要取最短的那个。

「每个单词最多用两次」是全局状态,每用一个要记下来,退出这一支时要还回去。

想看思路了?点开完整思路

预处理:ov[i][j] = 单词 i 的后缀与单词 j 的前缀的最短非空重叠长度,0 表示接不上。对每对单词从小到大枚举 k(1 ≤ k < min(leni, lenj)),第一个满足「i 的最后 k 个字符 == j 的前 k 个字符」的 k 就是答案。

为什么要最小的 k:接上以后长度增加 len_j - k,k 越小增加越多。

状态:dfs(last, len) 表示当前龙以第 last 个单词结尾,总长度是 len。

每进入一个状态先 ans = max(ans, len)——龙可以随时停下,最长值不一定出现在最后一步。

分支:枚举下一个单词 j,跳过「已用两次」和「接不上」的。

回溯:used[j]++ 后递归,返回后 used[j]--。

起点:枚举所有首字母等于给定字母的单词,把它记为用了一次,从它开始搜。

本题是上古 NOIP 题,数据很弱,朴素回溯就能过;它的难度评级只对应「能通过原始数据」的做法。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int n, ans = 0;
string w[25];
int used[25];              // 每个单词已经用了几次
int ov[25][25];            // ov[i][j]:i 后面接 j 的最短重叠长度,0 表示接不上

// a 的后缀与 b 的前缀的最短非空重叠长度
int overlap(const string &a, const string &b) {
    int la = a.size(), lb = b.size();
    for (int k = 1; k < la && k < lb; ++k) {     // 重叠必须小于任一整词
        bool ok = true;
        for (int t = 0; t < k; ++t)
            if (a[la - k + t] != b[t]) { ok = false; break; }
        if (ok) return k;                        // 第一个合法的 k 就是最短的
    }
    return 0;
}

void dfs(int last, int len) {
    ans = max(ans, len);                         // 每一步都可能是最长
    for (int j = 0; j < n; ++j) {
        if (used[j] >= 2 || ov[last][j] == 0) continue;   // 用满两次 / 接不上
        used[j]++;
        dfs(j, len + (int)w[j].size() - ov[last][j]);     // 重叠部分只算一次
        used[j]--;                                        // 回溯:还回去
    }
}

int main() {
    cin >> n;
    for (int i = 0; i < n; ++i) cin >> w[i];
    char st;
    cin >> st;

    for (int i = 0; i < n; ++i)
        for (int j = 0; j < n; ++j)
            ov[i][j] = overlap(w[i], w[j]);

    for (int i = 0; i < n; ++i)
        if (w[i][0] == st) {                     // 只能从这个字母开头的词起头
            used[i]++;                           // 开头词本身也算用了一次
            dfs(i, (int)w[i].size());
            used[i]--;
        }
    cout << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
int ov[25][25];预处理表:把「两词怎么接」从每次递归里挪到外面,只算一遍。
for (int k = 1; k < la && k < lb; ++k)重叠长度从 1 试到 min(la, lb)-1:等于整词长度是不允许的(那就是包含关系)。
if (a[la - k + t] != b[t])比较 a 的后 k 个字符与 b 的前 k 个字符。la - k 是这段后缀的起点。
if (ok) return k;k 从小到大枚举,第一个合法的就是最短重叠,也就是能加得最多的接法。
ans = max(ans, len);放在函数开头:这条链随时可以停,最长值可能出现在中间某一步。
if (used[j] >= 2 || ov[last][j] == 0) continue;两个跳过条件:次数用满,或者根本接不上。
dfs(j, len + (int)w[j].size() - ov[last][j]);增加的长度是新词长度减去重叠部分,重叠只算一次。
used[j]--;回溯。漏了这一句,每种词用满两次之后搜索树就断了,答案偏小。
if (w[i][0] == st)起点必须是给定字母开头的单词;这个字母本身不计入长度。
这题最容易错的地方
  • 重叠长度取错:取最大而不是最小 → 龙变短;允许重叠等于整词长度又违反了「不能包含」的规则。
  • used[j]-- 漏写 → 搜索提前被掐断,答案偏小。
  • 起点忘了给 used[i]++,第一个词被多用了第三次。
  • 只在「接不下新词」时才更新 ans:最长的链如果还能继续接,这个长度就取不到。
做完之后自问
  • 不看资料能写出 P1219 的完整代码,并说清 col、d1、d2 三个下标分别表示什么。
  • 能解释为什么按行枚举、列从小到大,输出就一定是字典序。
  • 能说出记忆化搜索的两个前提(有重复子问题、状态能用数组下标表示),并写出「算过就取、算完就存」那两行。
  • 能说清 P10483 的两条剪枝各自砍掉的是什么:一条靠已知最优答案,一条靠「空车彼此等价」。
  • 能手写 4 连通和 8 连通的方向数组,并说清越界判断为什么必须写在访问之前。
  • 能说出递归到多少层就可能爆栈,以及哪两种情况下必须放弃递归写法。
延伸阅读
  • 《算法竞赛入门笔记》:递归、深度优先搜索与回溯相关章节
  • 《算法竞赛进阶指南》第 0x21 节:树与图的遍历(DFS 与递归树)
  • 《算法竞赛试炼场:洛谷300题精析》:P1219、P1123、P10483 三题的剪枝对比
  • 《算法竞赛黄金法则》:先写出口再写递归;把状态写成一句话
  • 洛谷 P1219 题解区关于对角线编号与字典序的讨论
26 BFS:一层一层地扩散普及/提高-

26 · BFS:一层一层地扩散

从“能不能走到”升级到“最少几步”:用队列把距离相同的点攒成一批处理,一次遍历拿到无权图上的所有最短路。掌握三条铁律(队列、入队即标记、dis 初值 −1),再把它推到多源与任意状态上。
普及/提高-预计 65 分钟BFS最短路多源 BFS
本节出现的新函数 / 新语法(先看懂再用)

第 24 节的 DFS 回答的是“能不能走到”。这一节回答“最少几步”。两个问法只差一个字,算法完全不同:DFS 一次沿一条路走到黑,要比较“最少”就得把所有路径长度都试一遍,棋盘上路径数是指数级的;BFS 换一种走法,把距离相同的点攒成一批一起处理,谁先被算出来,谁的答案就是最短的。

BFS 只做一件事:把距离为 k 的点全部处理完,再处理距离为 k+1 的点。队列的先进先出,就是这个顺序的物理实现。
DFSBFS
数据结构栈(递归调用)队列 queue
访问顺序一条路走到底,退回来再换按距离一圈一圈往外扩
第一次到某点不一定是最短路一定是最短路(边权全为 1 时)
擅长连通性、枚举所有方案、回溯无权最短路、最少步数、扩散类问题
复杂度O(V + E)O(V + E)
  • 队列:先进先出。近的点排在前面,这就是“分层”。
  • 入队即标记:q.push(v) 的下一句(甚至同一句)就写 dis[v] = dis[u] + 1。等出队才标记,同一个点会被好几个前驱各塞一次。
  • dis 初值 −1:−1 同时表示“没到过”。初值用 0 的话,源点(距离 0)和没访问的点分不清。

这一节要新增三样东西:一个容器、一个打包工具、一行批量初始化。先把结构看清楚。

新函数拆解 · queue(队列)
queue<类型> q; q.push(v); q.front(); q.pop(); q.empty();
组成含义 / 格式要求
queue<int> q;声明一个放 int 的队列。尖括号里可以是任何类型:pair、结构体、string。
q.push(v)把 v 放到队尾。返回 void。
q.front()只看队首,不删除。要用的值必须先取出来存进变量。
q.pop()删除队首。它不返回被删的元素,所以 front 和 pop 必须成对写,顺序不能反。
q.empty()队列空返回 true。取 front 之前必须确认它不空。
q.size()当前元素个数。
没有 clear、不能下标queue 不支持 q.clear(),也不支持 q[i]。多测要清空就重新声明一个,或者 while (!q.empty()) q.pop();。
最小可运行示例
示例
queue<int> q;
q.push(1); q.push(2); q.push(3);      // 从队尾进

while (!q.empty()) {
    int u = q.front();                // ① 先取队首
    q.pop();                          // ② 再删掉它
    cout << u << " ";                 // 输出 1 2 3:先进先出
}
注意

int u = q.pop(); 编译不过:pop() 的返回类型是 void。

空队列上取 front / pop 是未定义行为,本地可能返回垃圾,线上直接 RE。

新函数拆解 · pair 与 make_pair(把两个值绑成一个)
pair<int,int> p = make_pair(x, y); p.first; p.second;
组成含义 / 格式要求
pair<A,B>把两个值(类型可以不同)打包成一个整体。BFS 里用它同时存行和列。
make_pair(x, y)按 x、y 的类型自动造一个 pair,不用手写尖括号里的类型。
p.first / p.second取第一个、第二个分量。它们是成员变量,不是函数,不加括号。
{x, y}C++11 起可以直接用花括号构造,q.push({nx, ny}) 与 make_pair 等价。
自带比较pair 按字典序比较:先比 first,相等再比 second。所以能直接放进 set / map / sort。
嵌套写法queue<pair<int,int>> 在 C++11 之前要写成 > > 中间留空格,现在直接写即可。
最小可运行示例
示例
pair<int,int> s = make_pair(3, 5);
cout << s.first << " " << s.second << "\n";     // 3 5

queue<pair<int,int>> q;                 // 存坐标的队列
q.push(make_pair(1, 2));
q.push({3, 4});                         // C++11 写法,等价

pair<int,int> cur = q.front();
int x = cur.first, y = cur.second;      // 拆开用,比每次写 cur.first 清楚
注意

pair 没有下标,p[0] 编译不过。

pair 的两个 int 各存什么全靠你自己的约定。在注释里写死“first 是行、second 是列”,能省一半调试时间。

新函数拆解 · memset(把数组的每个字节设成同一个值)
memset(dis, -1, sizeof(dis)); // 每一项变成 -1
组成含义 / 格式要求
首地址数组名 dis 会自动退化成指针。只对真正的数组有效;如果传的是函数参数(指针),sizeof 会变成 8 字节,只填了开头一点。
第二个参数要填的字节,不是 int 值。它逐字节覆盖,所以只有 0、−1 这类“四个字节全一样”的数值才填得出想要的 int。
sizeof(dis)总字节数。数组必须在这一层作用域可见,否则算错。
为什么 −1 可以−1 的补码是 32 个 1,拆成 4 个字节是 FF FF FF FF,逐字节填 FF 拼回来仍然是 −1。
为什么 1 不行填 1 得到 0x01010101 = 16843009,不是 1。这是 memset 最经典的坑。
替代写法int dis[N][M] = {}; 只能全 0。要全 −1 又不想 memset,就另开一个 vis 数组记“到过没有”。
最小可运行示例
示例
int dis[405][405];
memset(dis, -1, sizeof(dis));     // 全变成 -1:没到过
memset(dis, 0,  sizeof(dis));     // 全变成 0
// memset(dis, 1, sizeof(dis));   // ✗ 每一项会变成 16843009
注意

memset(dis, 0x3f, sizeof dis) 得到 1061109567,常当“无穷大”:它小于 INT_MAX,两个相加也不溢出。

多测时 memset 必须放在每组数据的开头,不能放在读入之后。

例题 · 马的遍历洛谷 P1443普及/提高-去洛谷做这题 ↗

有一个 n × m 的棋盘,某个点 (x, y) 上有一个马。马走“日”字,共有 8 个方向。求马到达棋盘上每个点最少要走几步,不能到达输出 −1。

输入:一行四个整数 n、m、x、y(1 ≤ x ≤ n ≤ 400,1 ≤ y ≤ m ≤ 400)。

输出:n 行,每行 m 个整数,第 i 行第 j 个数表示马到 (i, j) 的最少步数。2022 年 8 月之后本题取消了场宽要求,用空格分隔即可。

输入: 3 3 1 1 输出: 0 3 2 3 -1 1 2 1 4
例题怎么想:把棋盘拆成“状态 + 转移”

先回答两个问题,代码就是照抄模板。状态是什么?格子 (i, j),一共 n × m ≤ 160000 个。从状态能到哪些状态?8 个日字方向,每跳一步代价都是 1。

每步代价都是 1 → 无权最短路 → BFS。源点 (x, y) 距离 0,答案就是每个状态的距离。

DFS 为什么不行:DFS 没有“第一次访问就是最短”这个性质,要拿到最少步数必须把所有路径都走完再取最小,而棋盘上的路径数量是指数级的。这不是剪枝能救的。

三段骨架
memset(dis, -1, sizeof(dis));        // ① 所有格子标记为“没到过”
q.push(make_pair(sx, sy));
dis[sx][sy] = 0;                     // ② 源点入队,同一时刻标记

while (!q.empty()) {                 // ③ 一层一层往外扩
    pair<int,int> cur = q.front(); q.pop();
    for (int k = 0; k < 8; ++k) {
        int nx = cur.first + dx[k], ny = cur.second + dy[k];
        // 出界 → 跳过;到过 → 跳过;否则 dis + 1 后入队
    }
}

两个必须想清楚的地方:

  • 为什么 dis[nx][ny] = dis[x][y] + 1 就够,不用取 min?因为第一次访问 (nx, ny) 时,扩展它的 (x, y) 一定属于队列里距离最小的那一批,而任何更短的路径都得经过一个距离更小的邻点,那个邻点会先出队、先把 (nx, ny) 写掉。
  • 为什么“没到过”用 −1 而不是 0?源点的距离正好是 0。用 0 当“没到过”,源点就和所有未访问格子混在一起,判重条件 dis != -1 立刻失效。
完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 405;                // n, m ≤ 400,多留几个位置给边界
int dis[MAXN][MAXN];                 // dis[i][j]:最少步数,-1 表示没到过

const int dx[8] = {1, -1, 2, -2, 1, -1, 2, -2};   // 8 个日字方向,dx[k] 与 dy[k] 配对使用
const int dy[8] = {2, 2, -1, -1, -2, -2, 1, 1};

int main() {
    ios::sync_with_stdio(false);     // 要输出 16 万个数,关同步更稳
    cin.tie(nullptr);

    int n, m, sx, sy;
    cin >> n >> m >> sx >> sy;

    memset(dis, -1, sizeof(dis));    // 全局数组默认是 0,必须显式置 -1

    queue<pair<int, int>> q;
    q.push(make_pair(sx, sy));
    dis[sx][sy] = 0;                 // ★ 入队的同一时刻标记,不等出队

    while (!q.empty()) {
        pair<int, int> cur = q.front();
        q.pop();                     // front 取值、pop 删除,两句不能合并
        int x = cur.first, y = cur.second;

        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 (dis[nx][ny] != -1) continue;                     // 到过,且步数不会更少
            dis[nx][ny] = dis[x][y] + 1;                         // 第一次到 = 最短
            q.push(make_pair(nx, ny));
        }
    }

    for (int i = 1; i <= n; ++i) {
        for (int j = 1; j <= m; ++j) {
            cout << dis[i][j] << (j == m ? '\n' : ' ');
        }
    }
    return 0;
}
逐行拆解
代码片段含义与理由
const int MAXN = 405;下标用到 1..400,开 400 会在 n = 400 时越界一格。开 405 是习惯性的安全余量。
const int dx[8] = {1, -1, 2, -2, 1, -1, 2, -2};方向数组。马有 8 个方向,不是 4 个:除了 (1,2)、(2,1)、(-1,-2)、(-2,-1),还有 (1,-2)、(2,-1)、(-1,2)、(-2,1)。
memset(dis, -1, sizeof(dis));−1 的 32 位全是 1,逐字节填 FF 拼回来还是 −1,所以这句能一次刷完整张表。
queue<pair<int, int>> q;队列元素是 pair,一次带两个整数,省掉两个并行的队列。
q.push(make_pair(sx, sy)); dis[sx][sy] = 0;入队和标记紧挨着写,中间不插别的语句。判重完全靠这个标记;一旦漏写,源点会被反复入队,整张表跟着错位(每个格子少 1)。
pair<int, int> cur = q.front(); q.pop();先取值再删除。写成 q.pop(); cur = q.front(); 就丢了一个元素。
if (nx < 1 || nx > n || ny < 1 || ny > m) continue;棋盘下标从 1 开始,所以下界是 1,不是 0。写成 < 0 会读到 dis[0][*]。
if (dis[nx][ny] != -1) continue;判重。BFS 里“已经到过”等价于“最短距离已经确定”,后面任何一次到达都不会更优。
dis[nx][ny] = dis[x][y] + 1;转移。因为队列按距离非降出队,这一句写下的就是最小值。
cout << dis[i][j] << (j == m ? '\n' : ' ');行末只换行、不加空格。本题对场宽不敏感,但养成按格式输出的习惯。

把“一个源点”改成“很多个源点”,代码只改一处:初始时把所有源点都 push 进队列、dis 都设 0,之后的过程一个字不动。

为什么这样是对的?想象棋盘外有一个虚拟的超级源点 S,它到每个真源点都有一条权为 1 的边。从 S 出发做 BFS,第一层恰好是全部真源点,第二层是“离某个源点 1 步”的格子……把 S 那一层去掉,剩下的就是多源 BFS。所以“每个格子到最近源点的距离”一次 BFS 就能算完,不需要对每个源点各跑一遍。

多源 BFS:只改初始化
// k 个源点,一次 BFS 求出每个格子到最近源点的距离
memset(dis, -1, sizeof(dis));
while (k--) {
    int x, y;
    cin >> x >> y;
    dis[x][y] = 0;
    q.push(make_pair(x, y));      // ★ 所有源点一起入队,不是只 push 第一个
}
// 下面的 while (!q.empty()) 与单源 BFS 完全一样
怎么判断该用多源

题目问的是“到最近的某个东西的距离”,而不是“到指定的那个东西的距离”。

典型场景:每个格子离最近的火源要烧多久、离最近的出口多远、多个起点的队伍同时出发什么时候会合。

如果对每个源点各跑一次 BFS,复杂度是 k × nm,k 一多就 TLE——这正是多源 BFS 存在的理由。

BFS 的三条铁律里,没有一条要求状态必须是格子。只要满足两件事就能用 BFS:状态数量有限、任意两个状态之间的转移代价都是 1。

比如电梯问题:状态是楼层号,转移只有“上 K[u] 层”和“下 K[u] 层”两条。把坐标 + 8 个方向换成楼层 + 2 个方向,模板一个字都不用改。

同一套骨架,换个状态
queue<int> q;                       // 状态只是一个整数,队列就存 int
q.push(A);
dis[A] = 0;

while (!q.empty()) {
    int u = q.front(); q.pop();
    int nxt[2] = { u + K[u], u - K[u] };        // 两条出边
    for (int t = 0; t < 2; ++t) {
        int v = nxt[t];
        if (v < 1 || v > N) continue;           // 出界:这个按钮失灵
        if (dis[v] != -1) continue;             // 已经用更少的步数到过
        dis[v] = dis[u] + 1;
        q.push(v);
    }
}

这就是 BFS 模板的价值:写熟一遍,之后每道题只填两件事——状态是什么、从状态能到哪些状态。其余部分(队列、标记、距离 +1)永远相同。

题目状态转移
马的遍历(行, 列)8 个日字方向
迷宫最短路(行, 列)上下左右 4 个方向
奇怪的电梯楼层 1..Nu ± K[u],越界即无效
倒水问题(A 杯水量, B 杯水量)倒满 / 倒空 / 互相倒,共 6 种
单词接龙当前单词改一个字母,要求在词典里
BFS 的 7 种典型错法(含症状)

WA:除源点外全是 −1。—— dis 没置 −1(全局数组默认 0),判重条件 dis != -1 对所有格子都成立,第一次扩展就全被跳过。

WA:答案偏大。—— 在出队时才标记。同一个格子被多个前驱各 push 一次,队列膨胀;如果 dis 又在 push 时被无条件覆盖,后写入的较大距离会盖掉先写入的最小值。标记要和 push 同时发生。

RE(内存吃光)或 TLE 死循环。—— 删掉判重那一行。棋盘有环,A→B→A 会无限入队,几秒内队列吃掉几十 MB 然后 RE。

RE:数组越界。—— 棋盘是 1-based 却写成 nx < 0,或者根本不判越界。数组开 405 时 n = 400 读到 dis[401][*] 不会立刻崩,症状是随机 WA。

WA:大片格子变成 −1。—— 方向数组只写了 4 个。检查 dx/dy 是否成对出现 8 组,并且每组都用了正确的正负号(dx[k] 和 dy[k] 必须同属一个“日”)。

TLE:多源题写成了单源循环。—— 对每个源点各跑一次 BFS,复杂度 k × nm。多源 BFS 只跑一次。

WA:每个格子都比正确答案少 1,源点变成 −1。—— push 了源点却忘了写 dis[sx][sy] = 0,于是第一层算出的是 −1 + 1 = 0,整张表跟着错位。

多源与状态抽象的 3 个坑

只 push 了第一个源点,后面的都当成普通点:答案只对第一个源点正确。

多源时忘了先 memset(dis, -1, ...):dis 全是 0,判重把所有格子都当成“已到过”,BFS 一步都扩不出去,输出全 0。

状态维度估少了。若状态是 (行, 列, 朝向),dis 必须开三维;只开二维会让不同朝向互相覆盖,答案偏小。

自己动手复现

关掉页面,从空文件把 P1443 敲一遍并提交。

改一改 ①:把 if (dis[nx][ny] != -1) continue; 整行删掉,用 n = m = 400 的数据跑。程序再也不会停:队列一直涨,内存很快耗光(评测机上表现为 RE 或 TLE)——亲眼看一下没有标记的 BFS 怎么在环里绕圈。

改一改 ②:把 memset(dis, -1, sizeof(dis)) 改成 memset(dis, 0, sizeof(dis)),输入 3 3 1 1,输出会变成 9 个 0。想清楚:为什么除了源点之外也全是 0?

改一改 ③:把方向数组砍成 4 个(dx[] = {1,1,2,2} 配 dy[] = {2,-2,1,-1}),再跑样例,看哪些格子从数字变回了 −1,想清楚为什么它们到不了。

改一改 ④:输出前统计 dis == -1 的格子个数,顺便输出最大步数。

数学补给 · 队列里永远只有两种距离

BFS 的正确性可以压缩成两条断言,每条两三行就能证完。

断言 1:任意时刻,队列中元素的 dis 值单调不降,且最多只有两种取值:t 和 t+1。

证明:初始时队列里只有源点,dis = 0,成立。设当前队首 dis = t。出队后,我们把所有新点按 dis = t+1 压到队尾。原来队里的元素 dis ∈ {t, t+1},新压入的是 t+1,所以队里仍是非降的,取值仍是至多两种。∎

断言 2:点 v 第一次被写入的 dis 值,就是 v 到源点的最短距离 d(v)。

证明(对 d 归纳):d = 0 时只有源点,显然。设所有 d < k 的点都写对了,考察 d = k 的点 v。取最短路上紧挨着 v 的前驱 u,有 d(u) = k−1,由归纳假设 dis(u) 已正确写成 k−1。u 出队时会尝试扩展 v:若 v 还没被写过,就写入 k。若 v 在此之前已被某个点 w 写过,则 w 出队早于 u,由断言 1(队列非降)得 dis(w) ≤ k−1,所以写入值 ≤ k;另一方面,任何能到达 v 的点 x 都有 d(x) ≥ k−1(否则 v 的最短距离还能更小),所以写入值 ≥ k。两边夹住,dis(v) = k。∎

推论(复杂度):每个状态最多入队一次、出队一次,每条转移边只被检查一次,所以是 O(V + E)。本题 V = n·m ≤ 160000,E = 8V,约 1.28×10⁶ 次检查。

边界在哪:断言 1 的归纳只用到“每次新增的距离恰好是 t+1”。若某条边权是 2,队列里就会出现 t+2,取值超过两种,“最近的一批先出队”不再成立,第一次到达也不再是最短。边权不全为 1 时要用 Dijkstra(第 42 节),只有 0 和 1 两种权时用 0-1 BFS(第 28 节)。

顺带一个字节运算:memset(dis, -1, ...) 之所以成立,是因为 −1 的补码是 32 个 1,4 个字节完全相同;0x3f3f3f3f = 1061109567 也满足“四字节相同”,而且它小于 2³⁰,两个相加不超过 int 上限,所以常被当无穷大用。

举一反三 · 想清楚这些再往下走
  1. 把“马走日”换成“只能上下左右走”:没有障碍时,BFS 算出来的答案正好等于曼哈顿距离 |Δx| + |Δy|。那么这题还需要 BFS 吗?如果棋盘上加了若干不能通过的格子,直接套公式还成立吗?
  2. 如果棋盘上有障碍格不能通过,BFS 只需要改哪两行代码?如果障碍是“可以炸开一次”的墙,状态要怎么加一维,dis 数组的维度会变成什么?
  3. 要求“从起点到终点的不同最短路径条数”,应该在 BFS 的哪一步累加?为什么不能简单地把邻居的 dis 相加?
  4. 如果边权只有 1 和 2 两种,BFS 还成立吗?上面断言 1 的归纳坏在哪一步?如果只有 0 和 1 两种呢,能不能用一个双端队列救回来?
  5. 多源 BFS 与“从超级源点出发的 BFS”为什么等价?如果各个源点的初始距离不是 0 而是各不相同的值,还能一次 BFS 算完吗?
  6. 怎么在 BFS 里还原出一条具体的最短路径,而不只是它的长度?需要额外记录什么,从哪个点开始倒推?
自测:能全部回答再进下一节
  • 能不看资料默写 BFS 骨架:建队列、源点入队并标记、while 非空、出队、扩邻点、判重入队。
  • 能说清为什么标记必须在入队时做,而不是出队时做——并给出一个具体的错误结果。
  • 能解释 dis 初始化为 −1 的两个作用,并说清用 0 初始化会得到什么输出。
  • 能写出马的 8 个方向,并解释为什么不是 4 个。
  • 拿到一道新题,能在 1 分钟内指出它的“状态”和“转移”,并判断代价是否全为 1。
  • 知道多源 BFS 只改初始化那一段,能写出这段代码。
挑战题 · 奇怪的电梯
洛谷 P1135普及/提高-去洛谷做这题 ↗

大楼每一层都能停电梯,第 i 层上写着一个数字 Ki。电梯只有“上”和“下”两个有效按钮,按一次就上升或下降 Ki 层;如果目标楼层不在 1..N 范围内,这个按钮失灵。

现在从 A 层出发,求到 B 层最少要按几次按钮。

输入:第一行三个正整数 N、A、B(1 ≤ N ≤ 200,1 ≤ A, B ≤ N);第二行 N 个非负整数 Ki(0 ≤ Ki ≤ N)。

输出:一行一个整数,表示最少按键次数;若无法到达则输出 −1。

引导性提示(先只看这里)

把“楼层”当成点:从第 i 层能到 i + Ki 和 i − Ki 两层(只要还在 1..N 内),所以每个点最多两条出边。

每按一次按钮代价都是 1,问的是“最少按几次”,这就是一张无权图上的最短路。

从 A 出发 BFS,BFS 结束后 dis[B] 就是答案;它仍然是 −1 就说明到不了。别忘了一种情况:A 等于 B。

想看思路了?点开完整思路

状态:楼层编号 1..N,共 N ≤ 200 个。源点是 A,dis[A] = 0。

转移:u → u + K[u]、u → u − K[u],落在 [1, N] 之外的一律丢弃(按钮失灵)。

边权全为 1 → BFS。BFS 的分层性质保证 dis 就是最少按键次数。

为什么 A == B 不用特判:初始就写了 dis[A] = 0,循环结束输出 dis[B] 自然是 0。

为什么 Ki = 0 不会死循环:此时两个方向都是 v = u,而 dis[u] 已经被标记,两句话都被 continue 挡掉。但如果把判重删掉,就会原地无限入队。

复杂度:每个楼层最多入队一次,2N 次转移,O(N)。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int N, A, B;
int K[205], dis[205];           // 全局数组默认全 0

int main() {
    cin >> N >> A >> B;
    for (int i = 1; i <= N; ++i) cin >> K[i];

    memset(dis, -1, sizeof(dis));   // -1:这一层还没到过
    queue<int> q;
    q.push(A);
    dis[A] = 0;                     // 起点:0 次按键

    while (!q.empty()) {
        int u = q.front();
        q.pop();
        int nxt[2] = { u + K[u], u - K[u] };     // 上 K[u] 层 / 下 K[u] 层
        for (int t = 0; t < 2; ++t) {
            int v = nxt[t];
            if (v < 1 || v > N) continue;        // 出了大楼:按钮失灵
            if (dis[v] != -1) continue;          // 已经有更少的按键次数了
            dis[v] = dis[u] + 1;
            q.push(v);
        }
    }
    cout << dis[B] << "\n";
    return 0;
}
关键片段拆解
片段作用
memset(dis, -1, sizeof(dis));先把所有楼层当“没到过”。全局数组默认是 0,不置 −1 的话第 1 层以外都会被判成已访问。
q.push(A); dis[A] = 0;源点入队并立即标记,与 P1443 一模一样。
int nxt[2] = { u + K[u], u - K[u] };把两条出边先算出来,避免在循环里写两遍同样的判断逻辑。
if (v < 1 || v > N) continue;边界。楼层是 1..N,越界说明按钮失灵,不是走到楼外。
if (dis[v] != -1) continue;判重。注意 K[u] = 0 时 v = u,这一句正好挡住自己到自己。
dis[v] = dis[u] + 1;一次按键,距离 +1。
cout << dis[B] << "\n";A == B 时输出 0,到不了时 dis[B] 还是 −1,两种情况都自动正确。
这题最容易错的地方
  • 下标只开到 K[200]、dis[200]:i = 200 时越界,改成 205。
  • 忘了判 1..N 边界,读到 dis[0] 或 dis[201],输出一个不存在的层数。
  • 把 dis 用 0 初始化:源点以外的层全被判成已访问,答案几乎恒为 −1。
  • Ki = 0 时删掉判重,程序原地无限入队,表现为 RE 或死循环。
延伸阅读 / 参考
  • 《算法竞赛入门笔记》搜索章节:BFS 的分层思想
  • 《算法竞赛进阶指南》广搜一章:状态设计与判重
  • 洛谷 P1443、P1135 题解区的图论视角讲解
27 二分查找与二分答案普及/提高-

27 · 二分查找与二分答案

把“找”变成“排除”:数组有序时用 lower_bound / upper_bound 一次砍掉一半,答案的可行性单调时把最优化问题转成判定问题再二分。核心是三件事——语义、mid 取整、check 与区间。
普及/提高-预计 70 分钟二分单调性check 函数
本节出现的新函数 / 新语法(先看懂再用)

两个看起来很不一样的问题,答案是同一个。

  • 问题 A:给 n 个单调不减的数,m 次询问“x 第一次出现在哪个位置”。线性扫一遍是 O(nm),n = 10⁶、m = 10⁵ 时 10¹¹ 次比较,TLE。
  • 问题 B:求“锯片最高能设到多少米,砍下的木材仍不少于 M”。从 0 逐个高度试是 O(N × maxH),10⁶ × 4×10⁵ = 4×10¹¹ 次,TLE。

A 的答案是二分查找:有序 ⇒ 每次比较就能排除一半。B 的答案是二分答案:把“求最优”换成“判定可行”,可行的取值是一段连续的区间,而“是否可行”沿大小方向单调 ⇒ 也能每次砍掉一半。两者都是 O(log) 次判断,log₂10⁶ ≈ 20,log₂4×10⁵ ≈ 19。

二分的本质不是“找”,是“排除”:每一次判断都要让你能一次性丢掉一半候选。丢掉一半的前提是——候选是有序的。
例题 · 【深基13.例1】查找洛谷 P2249普及-去洛谷做这题 ↗

给出 n 个不超过 10⁹ 的单调不减非负整数 a₁…aₙ,然后进行 m 次询问。每次询问给出一个整数 q,输出 q 在序列中第一次出现的编号(从 1 开始),没有则输出 −1。

输入:第一行两个整数 n、m(1 ≤ n ≤ 10⁶,1 ≤ m ≤ 10⁵);第二行 n 个整数 aᵢ;第三行 m 个整数,表示每次询问的 q。

输出:一行 m 个整数,用空格隔开。

输入: 11 3 1 3 3 3 5 7 9 11 13 15 17 1 3 6 输出: 1 2 -1

这道题手写二分只要 8 行,但标准库已经把“第一个不小于 x 的位置”这件事做成了函数。先把它彻底看清:两个函数只差一个符号,却对应完全不同的语义。

新函数拆解 · lower_bound(第一个 ≥ x 的位置)
lower_bound(首指针, 尾后指针, x) → 迭代器
组成含义 / 格式要求
首指针 a + 1搜索区间的左端,包含。
尾后指针 a + n + 1右端,不包含。它指向最后一个元素的下一个位置,所以盖住 a[1]..a[n] 要写 a + n + 1,不是 a + n。
x要找的数。要求 [首, 尾) 这段已按 < 排好序(单调不减即可,允许重复)。
返回值指向第一个“≥ x”的元素的迭代器;若区间里所有元素都 < x,返回尾后指针 a+n+1。
转成下标减去首地址就是 1-based 下标:int p = lower_bound(a + 1, a + n + 1, x) - a;,p 的取值是 1..n+1。
判存在两件事都要查:p <= n && a[p] == x。只查前者会把“第一个比 x 大的数”当成 x。
复杂度O(log n),大约 ⌈log₂n⌉ 次比较,内部只用 operator<,不要求元素互不相同。
最小可运行示例
示例
int a[7] = {0, 1, 2, 2, 2, 5, 7};        // a[1..6] 是数据,a[0] 空着
int p = lower_bound(a + 1, a + 7, 2) - a;   // 第一个 >= 2  ->  p = 2
int q = lower_bound(a + 1, a + 7, 3) - a;   // 第一个 >= 3  ->  q = 5(a[5] = 5)
int r = lower_bound(a + 1, a + 7, 9) - a;   // 全都 < 9     ->  r = 7(尾后)
// r == 7 已经超出 a[6],所以 a[r] 是越界读!必须先判 r <= 6
注意

lower_bound(a, a + n, x) 漏掉了 a[n](右端不含)。这是最高频的错。

它不保证找到的是 x,只保证是第一个 ≥ x 的。想确认相等,必须自己再比一次。

新函数拆解 · upper_bound(第一个 > x 的位置)
upper_bound(首指针, 尾后指针, x) → 迭代器
组成含义 / 格式要求
和 lower_bound 的唯一区别把“≥”换成“>”。其余参数、返回值规则、复杂度完全相同。
位置关系两个函数把数组切成三段:[1, lo) 全都 < x,[lo, up) 全都 == x,[up, n] 全都 > x。
数个数up - lo 就是 x 出现的次数,x 不存在时结果是 0,不用额外判断。
判存在lo < up 等价于“x 在数组里”。这是最干净的写法。
x 比所有元素都大两者都返回 n+1。
x 比所有元素都小两者都返回 1。
最小可运行示例
示例
int lo = lower_bound(a + 1, a + n + 1, x) - a;   // 第一个 >= x
int up = upper_bound(a + 1, a + n + 1, x) - a;   // 第一个 >  x

int cnt = up - lo;            // x 出现的次数
bool has = (lo < up);         // x 是否出现
int le  = up - 1;             // 最后一个 <= x 的位置
注意

upper_bound 返回的是“最后一个 x 的后一位”,不是最后一个 x 本身,减 1 才是。

求“最后一个 ≤ x 的位置”用 upper_bound(...) - 1,比 lower_bound 加判断更省事。

例题怎么想:把“找 x”拆成“找位置 + 验证”

题目要的是第一次出现的位置,重复元素不算新问题——这正是 lower_bound 的定义。

所以整道题只有一句话:p = lower_bound(a+1, a+n+1, q) - a。剩下的全是判断:

  • p == n + 1:所有元素都小于 q,数组里没有 q,输出 −1。
  • a[p] != q:p 指向的是第一个比 q 大的数,说明 q 不在数组里,输出 −1。
  • 否则输出 p。两句可以合成一句 p <= n && a[p] == q,靠 && 的短路避免 p = n + 1 时读 a[p]。

为什么必须再判一次相等?因为 lower_bound 找的是“第一个不小于 q 的位置”,它对“q 存不存在”一无所知。测试点里一定有“数组里有 5、没有 6,问 6”这种数据。

复杂度 O(m log n):10⁵ × 20 = 2×10⁶ 次比较,输入量 10⁶ 个数,关同步后 1 秒内轻松过。

完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1000005;        // n ≤ 10⁶,1-based 用到 a[n]
int a[MAXN];

int main() {
    ios::sync_with_stdio(false); // 10⁶ 个读入,不关同步很容易 TLE
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;
    for (int i = 1; i <= n; ++i) cin >> a[i];

    for (int i = 1; i <= m; ++i) {
        int q;
        cin >> q;
        // 区间 [a+1, a+n+1):正好盖住 a[1]..a[n],右端是尾后指针
        int p = lower_bound(a + 1, a + n + 1, q) - a;   // 第一个 >= q 的下标

        if (i > 1) cout << ' ';                       // 行末不留多余空格
        if (p <= n && a[p] == q) cout << p;           // 找到:p 就是第一次出现的位置
        else                     cout << -1;          // 全比 q 小,或没有等于 q 的数
    }
    cout << '\n';
    return 0;
}
逐行拆解
代码片段含义与理由
const int MAXN = 1000005;下标用到 1..10⁶,开 1000000 会在 i = 10⁶ 时越界。多留几个位置。
lower_bound(a + 1, a + n + 1, q)a+1 是第一个元素,a+n+1 是尾后位置。写 a+n 就少了最后一个元素。
- a指针相减得到“相隔几个元素”,正好是 1-based 下标。这是 C++ 指针减法的语义,不是魔法。
p <= n先判越界。p 可能等于 n+1,此时 a[p] 是数组末尾之后一格,读它就是越界。
a[p] == qlower_bound 只保证 ≥ q。要确认相等必须自己比。这一句和上一句靠 && 短路连起来才安全。
if (i > 1) cout << ' ';第一个数前面不加空格,最后一个数后面不留空格。严格评测会卡行末空格。
ios::sync_with_stdio(false);10⁶ 次读入,关同步之后 cin 快 3~5 倍;不关的话这题很容易卡在输入上。

标准库好用,但比赛里经常需要变形:找“最后一个满足条件的”“最小的可行值”“答案在 long long 范围”。这些都得自己写二分。手写二分只有两种正确姿势,先把它们刻下来。

新函数拆解 · 手写二分(两种写法与 mid 取整铁律)
写法 A:while (lo <= hi) 闭区间 + ans 记录 写法 B:while (lo < hi) 半开区间 + 哨兵
组成含义 / 格式要求
写法 A:lo <= hi闭区间。区间里每个下标都可能是答案;lo == hi 时那个位置还没被检查,所以要用 <=。
写法 A:ans = midmid 满足条件,先记下来,再去左半边找“更靠前”的满足者。
写法 B:hi = n + 1半开区间 [lo, hi)。n+1 是“不存在”的哨兵,让答案域多一个合法取值,省掉 ans 变量。
写法 B:hi = midmid 本身可能就是答案,必须保留它,只把右端缩到 mid。
两者共同的:lo = mid + 1mid 一定不满足条件,整个左边(含 mid)都能排除,所以是 +1 不是 mid。
铁律 1更新式里出现 hi = mid(右边不 −1),mid 必须向下取整 (lo+hi)>>1。
铁律 2更新式里出现 lo = mid(左边不 +1),mid 必须向上取整 (lo+hi+1)>>1。
铁律 3两种写法不能混。混用是二分死循环的头号来源,而且它不报错,只是永远不结束。
最小可运行示例
示例
int a[9] = {0, 1, 2, 2, 2, 5, 7, 7, 9};    // a[1..8]

// 写法 B:找第一个 >= 2 的下标
int lo = 1, hi = 8 + 1;                       // 半开区间 [1, 9]
while (lo < hi) {
    int mid = (lo + hi) >> 1;                 // 向下取整
    if (a[mid] >= 2) hi = mid;                // mid 可能是答案,留着(不 -1)
    else             lo = mid + 1;            // mid 不可能是答案,扔掉
}
cout << lo << "\n";                           // 2

// 写法 A:同样的目标,另一种记录方式
int l = 1, r = 8, ans = -1;
while (l <= r) {
    int mid = (l + r) >> 1;
    if (a[mid] >= 2) { ans = mid; r = mid - 1; }
    else               l = mid + 1;
}
cout << ans << "\n";                          // 2
注意

写法 B 里 mid 永远小于 hi,所以 a[mid] 读不到哨兵 a[n+1],哨兵是安全的。

lo + hi 可能溢出 int:hi 到 2×10⁹ 时两数相加变负数,mid 变负 → 数组越界 RE。改写成 lo + (hi - lo) / 2。

更新式mid 必须循环条件写错的症状
lo = mid + 1; hi = mid;向下 (lo+hi)>>1lo < hi向上取整时 hi = mid 不缩小 → 死循环 TLE
lo = mid; hi = mid - 1;向上 (lo+hi+1)>>1lo < hi向下取整时 lo = mid 不缩小 → 死循环 TLE
lo = mid + 1; hi = mid - 1;两种都对lo <= hi无(最稳的一套)
记法:谁不 ±1,mid 就朝另一边取整。hi = mid 配下取整,lo = mid 配上取整。记不住就统一用 lo = mid + 1 / hi = mid - 1 / while (lo <= hi),再加一个 ans 变量。

二分查找是在数组上二分;二分答案是在答案的取值上二分。两者的代码骨架一模一样,区别只在于比较的对象:前者比 a[mid] 与 x,后者比 check(mid) 与 true。

  • 答案可枚举:答案是一个整数,且有明确的下界 lo 和上界 hi(或实数区间)。
  • 判定函数 check(v):只回答“取 v 行不行”,不回答“v 是不是最优”。它必须与题目最优值无关,复杂度 O(n) 或更低。
  • 单调性:v 从小往大,check 的结果必须形如“真真…真假假…假”(或反过来)。只有这样才能用“比较结果”排除一半候选。
  • 收尾:解是“最后一个可行的”还是“第一个可行的”,决定 lo / hi / ans 谁被保留、最后返回谁。
例题 · EKO / 砍树洛谷 P1873普及/提高-去洛谷做这题 ↗

有 N 棵树,第 i 棵高 hᵢ 米。把锯片升到整数高度 H,所有高于 H 的部分被锯下,于是得到 Σ max(0, hᵢ − H) 米木材。求最大的整数 H,使得到的木材至少 M 米。

输入:第一行 N、M(1 ≤ N ≤ 10⁶,1 ≤ M ≤ 2×10⁹);第二行 N 个整数表示树高(≤ 4×10⁵),保证所有树高之和大于 M。

输出:一行一个整数,即锯片能设置的最大高度。

输入: 4 20 20 15 10 17 输出: 10 (H = 10 时得到 10+5+0+7 = 22 ≥ 20;H = 11 时只有 19)
例题怎么想:先把 check 写出来,再去二分

不要一上来就写二分。先把题目里的“求最值”翻译成一句判断:锯片设成 H 时,木头够不够 M?这一个函数写完,二分部分就是模板。

check 函数:O(n),与最优值无关
bool ok(int H) {
    long long sum = 0;
    for (int i = 1; i <= n; ++i)
        if (a[i] > H) sum += a[i] - H;   // 只有比锯片高的树贡献木材
    return sum >= M;
}

然后看单调方向:H 越大,每棵树贡献的非负差值越小,总和越小。所以“够 M”这个性质是前半段为真、后半段为假——H 从 0 增大时,先一直可行,某一米开始不可行,而且再也不会变回可行。

题目要最大的可行 H,也就是那个“真区间的右端点”。二分时 mid 可行就说明答案可能是它或者更大,于是 ans = mid 记下来、lo = mid + 1 往右试;mid 不可行就说明 mid 和它右边全废,hi = mid − 1。

复杂度:值域 ≤ 4×10⁵,二分开销 ⌈log₂(4×10⁵)⌉ = 19 次;每次 check 扫 n = 10⁶ 个数,总共 1.9×10⁷ 次加法。朴素枚举 H 是 4×10¹¹ 次,差 4 个数量级。

完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1000005;
int a[MAXN];
int n;
long long M;

// check(H):锯片升到 H 米,能不能拿到至少 M 米木材
bool ok(int H) {
    long long sum = 0;
    for (int i = 1; i <= n; ++i) {
        if (a[i] > H) sum += a[i] - H;   // 高过锯片的部分才被锯下
        if (sum >= M) return true;       // 已经够了,后面不用看
    }
    return sum >= M;
}

int main() {
    ios::sync_with_stdio(false);   // 10⁶ 个读入,而且要被扫 19 遍
    cin.tie(nullptr);

    cin >> n >> M;
    int mx = 0;
    for (int i = 1; i <= n; ++i) {
        cin >> a[i];
        if (a[i] > mx) mx = a[i];
    }

    int lo = 0, hi = mx, ans = 0;      // 答案一定在 [0, mx] 内
    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;  // 与 (lo+hi)/2 等价,但不会溢出
        if (ok(mid)) { ans = mid; lo = mid + 1; }   // 可行:记下来,再试着升高
        else         { hi = mid - 1; }              // 不可行:只能降
    }
    cout << ans << "\n";
    return 0;
}
二分答案骨架拆解
代码片段含义与理由
bool ok(int H)判定函数独立成函数。这样二分部分与题目彻底分开,换题只改 ok。
if (a[i] > H) sum += a[i] - H;锯片以上的部分才被锯下。写成 a[i] - H 会把负贡献也算进去。
if (sum >= M) return true;早退。10⁶ × 4×10⁵ 最坏是 4×10¹¹,虽然 long long 装得下,但提前跳出能让 check 平均只扫一小段。
long long sum4×10¹¹ 超过 int 上限 2.1×10⁹。用 int 会溢出成负数,于是 check 永远返回 false,答案恒为 0。
int lo = 0, hi = mx;答案域的下界取 0(再低没有意义),上界取最高的树(再高一根木头都锯不到)。下界必须 ≤ 真答案,上界必须 ≥ 真答案,否则二分找不到。
int mid = lo + (hi - lo) / 2;等价于 (lo+hi)/2,但前者不会溢出。hi 到 2×10⁹ 时 lo+hi 会爆 int。
if (ok(mid)) { ans = mid; lo = mid + 1; }mid 可行不代表它是最大,只代表它是“可行集合里的一个”,所以先记下,再往右找更大的。
else { hi = mid - 1; }mid 不可行,它和它右边全部不可行(单调性),一刀砍掉。
cout << ans;循环结束时 ans 是最后一个可行的 mid,也就是最大的可行 H。
题目问法check(v) 为真的含义真的时候怎么走最后返回
最大可行值(砍树、木材加工)取 v 可行ans = mid; lo = mid + 1ans
最小可行值(数列分段、跳石头)取 v 可行ans = mid; hi = mid - 1ans
第一个 ≥ x 的位置a[mid] ≥ xans = mid; hi = mid - 1ans
最后一个 ≤ x 的位置a[mid] ≤ xans = mid; lo = mid + 1ans
二分查找的 6 种典型错法(含症状)

WA:偶尔少算一个。—— 区间右端写成 a + n,漏掉 a[n]。做“找第一个 ≥ x”时有时看不出问题(后面还有 a[p] == q 兜着),一改成计数 up - lo 就立刻错。凡是 n 个元素都要写 a + n + 1。

RE:随机崩或答案乱。—— 直接解引用返回值。所有元素都 < x 时它等于尾后指针,读它就是越过数组末尾。先判 p <= n。

WA:输出一个位置,正确答案是 −1。—— 只判了 p <= n,没判 a[p] == q。数组里有比 q 大的数时就会漏判。

TLE:程序不结束。—— 手写二分混用了 lo = mid 和 hi = mid - 1,或者 hi = mid 配了向上取整。区间长度不再缩小,循环永远跑。

WA:结果毫无规律。—— 忘了数组必须有序。二分在无序数组上不报错,只是每次都往错的方向排除。

TLE。—— n = 10⁶、m = 10⁵ 却没写 ios::sync_with_stdio(false)。

二分答案的 5 种典型错法(含症状)

WA:答案永远是 0 或上界。—— check 的方向写反了。验证方法:手算 check(lo) 与 check(hi),确认一个真一个假;如果两个同真假,说明区间或方向错了。

WA:答案恒为 0,且怎么改区间都没用。—— check 里用 int 累加,4×10¹¹ 溢出成负数,于是永远“不够”。这一类 bug 只在最大数据上出现,小样例全过。

WA:总是差 1。—— 返回 lo / hi / ans 选错,或 ans 忘了在可行分支里更新。自查:把区间缩到长度 1,手推一遍 mid、赋值、返回,看是否正好落在答案上。

TLE:卡死不结束。—— while (lo < hi) 里写了 lo = mid 却没配上取整。

RE:数组越界。—— mid 用 (lo + hi) / 2 且 hi 接近 2×10⁹,溢出成负数后拿去当下标。

自己动手复现

关掉页面,从空文件把 P2249 敲一遍并提交。

改一改 ①:把程序改成输出“q 在序列里出现了几次”——up - lo,其中 up 用 upper_bound。题目立刻变成一道统计题。

改一改 ②:在 ① 的基础上把两处 a + n + 1 都改成 a + n,输入 3 1 / 1 2 2 / 2:正确答案是 2,改完输出 1——最后一个元素根本没进搜索区间。

改一改 ③:把 lower_bound 换成手写的半开二分(FN 卡里的写法 B),答案必须完全一致。

改一改 ④:P1873 里把 lo + (hi - lo) / 2 改成 (lo + hi) / 2,再把 hi 的上界改成 2×10⁹ 跑一次,观察是否进入死循环或数组越界。

数学补给 · 二分为什么只要 log:判定树与不变式

n 个有序元素,每次比较把候选砍成一半:n → n/2 → n/4 → … → 1。需要 k 次使 n/2ᵏ ≤ 1,即 k ≥ log₂n。最坏比较次数精确地是 ⌈log₂(n+1)⌉。

n1010³10⁶10⁹
二分比较次数4102030
线性扫描次数10100010⁶10⁹

这就解释了为什么 n = 10⁶、m = 10⁵ 的查找题必须用二分:10⁵ × 20 = 2×10⁶ 次比较,而线性做法是 10¹¹ 次。

不变式(半开写法的正确性):循环开始时,真答案一定落在 [lo, hi] 这一段里(把 n+1 当作“不存在”的合法答案)。

  • 若 a[mid] >= x:mid 满足条件,答案 ≤ mid,所以 hi = mid,答案仍在 [lo, mid] 内。
  • 否则 mid 一定不是答案,答案 > mid,所以 lo = mid + 1,答案仍在 [mid+1, hi] 内。
  • 又因为 mid = ⌊(lo+hi)/2⌋ < hi(只要 lo < hi),每次迭代区间长度都严格减小。长度有限且严格递减 ⇒ 必然终止;终止时 lo == hi,而答案还在这个只含一个元素的区间里 ⇒ lo 就是答案。∎

这段证明顺便解释了铁律:如果把 mid 改成上取整,hi = mid 在 hi = lo + 1 时会令 mid = hi,区间长度不减,证明的第三步就失效了——程序表现为死循环。

二分答案的总代价 = O(log(hi − lo) × cost(check))。P1873 是 19 × 10⁶ ≈ 2×10⁷,P1182 是 44 × 10⁵ ≈ 4×10⁶,都在 1 秒的余量内。这也提醒你:check 必须是 O(n) 级别的,如果 check 里再套一层 sort 或二分,复杂度会乘上 log,很可能就过不去了。

单调性是硬前提。形如“真真…真假假…假”的判定才可二分。如果判据是“总和恰好等于 M”,可行集合是散点,二分直接失效,只能枚举或换思路。拿到题先问自己:答案变大时,可行性是一直真然后一直假,还是会来回跳?

实数域:二分实数时不写 while (r - l > eps),因为浮点误差可能让它永远不结束;固定循环 100 次,区间长度缩到 2⁻¹⁰⁰,精度早就够了。

举一反三 · 想清楚这些再往下走
  1. “第一个 > x 的位置”和“最后一个 ≤ x 的位置”差几?能不能用其中一个算出另一个?如果有 10⁵ 次询问、每次都要答这两个数,总复杂度是多少?
  2. 数组是“先严格递增、再严格递减”(单峰),还能二分吗?应该拿 a[mid] 和 a[mid+1] 比,还是和 x 比?如果是“先减后增”,要改哪一处?
  3. 求一个无序数组的第 k 小数,能不能二分答案?check(mid) 要做什么,复杂度是多少?它和 nth_element(第 13 节)比,谁更快?
  4. 二分答案的 check 如果是 O(n log n),总复杂度变成多少?能不能先把数据排序一次,让 check 降到 O(n)?
  5. 如果答案不是整数,而是一个实数(比如“最大的平均收益”),二分要不要改?为什么通常固定循环 100 次而不是写 while (r − l > 1e−8)?
  6. 数组要支持动态插入、删除,还要能“找第一个 ≥ x”。二分还成立吗?需要换成什么结构?
自测:能全部回答再进下一节
  • 能不看资料写出 lower_bound / upper_bound 调用的四个参数,并说清右端为什么是 n+1。
  • 能说清 lower_bound 与 upper_bound 的语义差别,并用它们算出“x 出现的次数”。
  • 能默写两种手写二分,并说出各自的 mid 取整方向;能解释写错为什么是死循环而不是 WA。
  • 能把一道“求最大/最小值”的题翻译成 check(v) + 单调性两句话,并写对 lo、hi 的初值。
  • 知道二分答案什么时候会失效(check 不单调),能举一个反例。
  • 能在 10 秒内判断一道题该用二分查找还是二分答案。
挑战题 · 数列分段 Section II
洛谷 P1182普及/提高-去洛谷做这题 ↗

给出一个长度为 N 的正整数数列 A₁…AN,要把它分成 M 段(M ≤ N),每段必须连续。设每段内数字之和为 Si,求 max(Si) 的最小值。

输入:第一行两个正整数 N、M;第二行 N 个空格隔开的非负整数 Ai(1 ≤ N ≤ 10⁵,M ≤ N,Ai < 10⁸,答案不超过 10⁹)。

输出:一行一个正整数,即每段和最大值的最小值。

引导性提示(先只看这里)

题目问的是“最大值最小”,这是二分答案最典型的信号。先把“求最小”换成判断:给定一个上限 v,能不能分成不超过 M 段,使每段和都不超过 v?

判定一个 v 是否可行,从左往右贪心即可:能塞进当前段就塞,塞不下就另起一段。这样得到的段数是所有分法里最少的。

v 的取值下界不能随便设:某个 Ai 单独一段都超过 v 时,无论怎么分都不行,所以下界至少是 max(Ai)。

想看思路了?点开完整思路

二分答案 x = “每段和的上限”。问题变成:最小的可行 x 是多少。

check(x):从左往右扫,cur 记当前段的和。若 cur + Ai ≤ x 就并进去;否则开新段(cnt++,cur = Ai)。最后 cnt ≤ M 就可行。

为什么贪心是最优的:任何合法分法的第一段右端点都不会超过贪心的第一段右端点(贪心尽量往右塞),归纳下去,贪心的段数 ≤ 任何分法的段数。所以“贪心段数 ≤ M”与“存在一种分法不超过 M 段”等价——这就是第 16 节的交换论证。

单调性:x 越大越容易分,check 形如“假假…假真真…真”。要求最小的可行值,所以真的时候 hi = mid(保留 mid),假的时候 lo = mid + 1。

下界 lo = max(Aᵢ)(否则单个元素都装不下),上界 hi = ΣAᵢ(全部塞进一段,一定可行)。答案是 lo 收敛到的位置,O(N log Σ) ≈ 10⁵ × 44 ≈ 4.4×10⁶。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int n, m;
long long a[MAXN];

// check(limit):每段和都不超过 limit 时,最少要分成几段?不超过 m 段就可行
bool ok(long long limit) {
    int cnt = 1;              // 至少一段
    long long cur = 0;        // 当前这一段已经装了多少
    for (int i = 1; i <= n; ++i) {
        if (cur + a[i] <= limit) {
            cur += a[i];      // 塞得下就塞:段数越少越容易满足 "<= m"
        } else {
            ++cnt;            // 塞不下,a[i] 自己开一段
            cur = a[i];       // 注意不是 0,a[i] 已经在这一段里了
            if (cnt > m) return false;   // 段数只会增不会减,提前剪枝
        }
    }
    return cnt <= m;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> m;
    long long lo = 0, hi = 0;
    for (int i = 1; i <= n; ++i) {
        cin >> a[i];
        hi += a[i];              // 上界:全部放进一段,一定可行
        lo = max(lo, a[i]);      // 下界:至少要装得下最大的那个数
    }

    while (lo < hi) {            // 半开写法:答案域 [lo, hi]
        long long mid = lo + (hi - lo) / 2;
        if (ok(mid)) hi = mid;        // 可行:mid 可能就是答案,保留
        else         lo = mid + 1;    // 不可行:mid 及更小全部排除
    }
    cout << lo << "\n";          // 循环结束时 lo == hi == 最小的可行上限
    return 0;
}
关键片段拆解
片段作用
int cnt = 1; long long cur = 0;段数从 1 开始:n ≥ 1 时至少有一段。cur 是当前段已装的和。
if (cur + a[i] <= limit) cur += a[i];能塞就塞。这一步的贪心性质是后面所有论证的基础。
else { ++cnt; cur = a[i]; }塞不下才开新段,并把 a[i] 放进新段(重置成 a[i],不是 0)。
if (cnt > m) return false;段数只增不减,一旦超过 m 立刻返回,省掉后半段扫描。
lo = max(lo, a[i]);下界的正确来源。设成 0 或 1 不会错但会多跑几轮;设成平均值会漏解。
while (lo < hi) ... hi = mid;半开写法。mid 保留在区间里,所以 mid 必须向下取整,否则 lo+1 == hi 时死循环。
long long mid / a[] / curmid 最大到 ΣAᵢ ≈ 10¹³,cur 也可能很大,一律 long long。
cout << lo;第一个可行的上限就是答案;此时 lo == hi,返回谁都可以。
这题最容易错的地方
  • cnt 初值写成 0:最后比真实段数少 1,check 变宽松,答案偏小(有时差 1,有时差很多)。
  • 开新段时 cur 重置成 0:漏掉 a[i],check 认为可行,答案偏小。
  • 段内累加用 int:limit 可以到 ΣAᵢ ≈ 10¹³,cur 溢出成负数,check 结果全乱。
  • 下界设得比真答案大(例如写成 ΣAᵢ/M 的向上取整):二分只在 [lo, hi] 里找,真答案根本不在区间内,输出必然偏大、甚至是个不可行的值。
  • 把 hi = mid 写成 hi = mid - 1 又保留半开区间:漏掉答案,输出比真值小 1。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》二分一章:整数二分的两种写法与二分答案
  • 《算法竞赛黄金法则》关于“把最优化转成判定”的技巧条目
  • 洛谷 P2249、P1873、P1182 题解区的高赞推导
28 双端队列与优先队列普及/提高-

28 · 双端队列与优先队列

把 BFS 的 queue 拆成两种新工具:deque 用两端插入把 0-1 BFS 做到 O(V+E),priority_queue 用堆顶取最值解决多路归并与 Top-K。重点在“队头最小”的不变式与比较器方向。
普及/提高-预计 60 分钟dequepriority_queue0-1 BFS
本节出现的新函数 / 新语法(先看懂再用)

第 26 节的 BFS 只有一条规矩:所有点从队尾进、从队头出。这条规矩之所以对,是因为每条边的长度都等于 1 —— 先被发现的点一定不比后发现的远。可一旦边权出现 0,这个前提就断了:一个点是绕了三步才发现的,距离却可能和第一步就发现的点一样。“第几层”不再等于“多远”,所以要换容器。

本节的另一半内容方向刚好相反:有些问题根本不关心先后顺序,只关心“当前最小的是谁”。这时用 priority_queue(二叉堆),每次取最值 O(log n),付出的代价是彻底放弃顺序。

三种容器,三种规矩:queue 守顺序,deque 守“距离只差 0 或 1”,priority_queue 守“堆顶最值”。选错了不是慢一点,是答案错。
容器元素怎么进出能拿到什么单次代价用在哪种题上
queue<int>队尾进、队头出最旧的元素O(1)边权全为 1 的 BFS、按层扩散
deque<int>两端都能进、两端都能出最旧的和最新的O(1)0-1 BFS、滑动窗口最值(第 19 节)
priority_queue<int>随便进、只能从堆顶出当前最大/最小O(log n)每步取当前最优、多路归并、Top-K

表里只有 deque 那一行是新的容器,另外两个前面都见过。但“见过”和“能说清它为什么能两端 O(1) 插入”是两件事 —— deque 的内存是分段的:一段一段申请,用一张表记录各段的首地址,头插就往前一段挂,尾插就往后一段挂,所以不像 vector 那样要把整块数据搬家。代价是元素不保证连续,&dq[0] + n 这种指针算术在 deque 上是危险的。

新函数拆解 · deque:双端队列的两端操作
deque<int> dq; dq.push_back(x); dq.push_front(x); dq.pop_back(); dq.pop_front(); dq.front(); dq.back();
组成含义 / 格式要求
deque<int>头文件 <deque>(bits 已包含)。带下标的双端队列,两端插入删除都是 O(1)。
push_back(x) / push_front(x)在队尾 / 队头插入一个元素。这是 deque 与 vector 的实质区别:vector 的 push_front 是不存在的,insert(begin(), x) 是 O(n)。
pop_back() / pop_front()删除队尾 / 队头元素,O(1)。返回 void,不返回值;要取值必须先 back() / front()。
front() / back()读队头 / 队尾,O(1),不删除。容器为空时调用是未定义行为,通常表现为 RE。
empty() / size()判空 / 元素个数。pop 之前先判空,是本节所有代码的固定动作。
dq[i]支持下标访问,O(1)。但一旦用下标,说明你只需要随机访问,那就该用 vector 了。
最小可运行示例
示例
deque<int> dq;
dq.push_back(1);     // [1]
dq.push_back(2);     // [1, 2]
dq.push_front(0);    // [0, 1, 2]      只有 deque 能这么干
cout << dq.front() << dq.back();        // 02
dq.pop_front();      // [1, 2]
dq.pop_back();       // [1]

deque<int> d2;
// d2.front();       // 空容器上取 front:未定义行为,现场 RE
if (!d2.empty()) d2.pop_front();        // 正确写法:先判空
注意

deque 没有 reserve(),也不能用 memcpy / 指针遍历 —— 内存是分段的。

0-1 BFS 里只有边权为 0 才允许 push_front。把边权为 1 的也插到队头,队列的距离序列不再递增,同一个点会被反复松弛,答案虽然还对,但最坏退化成 SPFA 量级 → TLE。

新函数拆解 · priority_queue 的三参数模板
priority_queue<T, Container, Compare> q;
组成含义 / 格式要求
T(第 1 个参数)元素类型。可以是 int、long long、pair<int,int>,或自己写的 struct。
Container(第 2 个)底层容器,默认 vector<T>。要求随机访问 + push_back + pop_back,所以只能是 vector 或 deque,写 list 会编译失败。平时照抄 vector<T>。
Compare(第 3 个)比较器,回答“谁排在后面”。默认 less<T>,于是 top() 是最大值;写 greater<T> 则 top() 是最小值(第 17 节用过)。
三个参数必须写全想换比较器,前两个也得一起写出来。只写 priority_queue<int, greater<int>> 会得到一屏指向 <bits/stl_queue.h> 的模板报错 —— 因为第二个位置固定是容器。
自定义比较器写函数对象:struct Cmp { bool operator()(const Node &x, const Node &y) const { return x.w > y.w; } },然后 priority_queue<Node, vector<Node>, Cmp> q; 不能直接传函数名,除非用 decltype 取它的类型。
Compare 的语义Cmp(a, b) == true 表示 a 的优先级更低、排在 b 后面。所以想要小根堆,就要让“大的”优先级低 —— 传 greater,或写 x.w > y.w。
最小可运行示例
示例
// 写法一:小根堆
priority_queue<int, vector<int>, greater<int>> small;

// 写法二:pair 默认按 first 比,first 相同再比 second
priority_queue<pair<int, int>> p;              // 默认大根堆
p.push({2, 5}); p.push({2, 3}); p.push({1, 9});
cout << p.top().first << ' ' << p.top().second; // 2 5(first 同,second 大的在前)

// 写法三:自定义比较器,按 w 小的优先
struct Node { int w, id; };
struct Cmp { bool operator()(const Node &x, const Node &y) const { return x.w > y.w; } };
priority_queue<Node, vector<Node>, Cmp> q;
注意

priority_queue 不能遍历、不能删除任意元素、不能修改已入堆元素的值(改了也不会重排)。要“改值”就再 push 一份新的,取出时和记录里的最新值比一下,旧的那份就跳过。

pair 的默认比较是字典序:{dist, id} 放进小根堆,距离小的先出,距离相同时 id 小的先出。如果题目对同距离的顺序敏感,就别依赖这个次序。

例题 · 最小函数值洛谷 P2085普及/提高-去洛谷做这题 ↗

有 n 个函数 F_i(x) = a_i·x² + b_i·x + c_i,定义域是正整数 x = 1, 2, 3, …。把所有函数在所有正整数上取到的值合在一起,求其中最小的 m 个,按升序输出(重复的值各算一个)。

输入格式:第一行两个整数 n、m;接下来 n 行,每行三个正整数 a_i、b_i、c_i。

输出格式:m 行,每行一个整数,依次为第 1 小、第 2 小……第 m 小的函数值。

数据范围:1 ≤ n, m ≤ 10⁴,a_i 恒为正(保证每个函数在正整数上单调递增)。

把无穷多个值砍成 n 个有序序列

第一步:每个函数自己是有序的。a_i > 0,抛物线开口向上,对称轴在 x = −b_i/(2a_i) < 0,所以整数部分 x ≥ 1 整段都落在增区间上:F_i(1) ≤ F_i(2) ≤ F_i(3) ≤ …。这一步把“无穷多个值”变成了“一条递增序列”。

第二步:问题变成 n 路归并。每个函数贡献一条递增序列,全局前 m 小就是这 n 条序列归并后的前 m 项。这和第 17 节合并果子是同一个结构:每一步在 n 个候选里取最小。

第三步:候选只需要 n 个。对第 i 个函数,只把“它当前还没输出的最小项”放进堆。堆顶就是全体候选的最小值,输出它,然后让这个函数往前走一格(x 加 1)再放回去。其他函数的候选没变,不用动。

为什么不会漏。堆里始终装着每条序列“尚未输出部分”的首元素。全局最小必然等于某条序列尚无输出的首元素,所以它一定在堆里 —— 取堆顶就是取全局最小。

为什么只推进 m 次。输出 m 个数就结束;每个函数最多被推进 m 次,堆的大小恒为 n,总复杂度 O((n + m) log n)。n = m = 10⁴ 时是 2×10⁴ 次堆操作、每次约 14 次比较,总共几十万次比较,可以忽略不计。

每次输出的六个动作
// 核心只有三步:取堆顶 -> 输出 -> 同一函数推进一格
Node cur = pq.top();
pq.pop();
cout << cur.val << '\n';
int nx = cur.x + 1;                   // 只推进这一个函数,别的候选不动
long long nv = f[cur.id].a * nx * nx + f[cur.id].b * nx + f[cur.id].c;
pq.push({nv, cur.id, nx});

函数值最大能到 a_i·x² 量级,x 可以到 10⁴,所以乘积必须用 long long 存,这一点在代码里靠 long long a 让整条表达式自动提升。

完整可编译代码(C++17)
#include <bits/stdc++.h>
using namespace std;

struct Func { long long a, b, c; };   // 系数只存一份,堆结点里不重复存

struct Node {
    long long val;                    // 这个函数当前能给出的最小值
    int id, x;                        // 来自第 id 个函数,自变量取 x
    // 小根堆:val 大的"排在后面"。方向和 sort 的 cmp 相反(第 17 节)
    bool operator<(const Node &o) const { return val > o.val; }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;
    vector<Func> f(n);
    priority_queue<Node> pq;          // 大小恒为 n:每个函数只放一个候选

    for (int i = 0; i < n; ++i) {
        cin >> f[i].a >> f[i].b >> f[i].c;
        long long v = f[i].a + f[i].b + f[i].c;   // F_i(1) = a+b+c,a 是 long long 自动提升
        pq.push({v, i, 1});
    }

    for (int t = 0; t < m; ++t) {
        Node cur = pq.top();
        pq.pop();                     // pop 不返回值,所以先把堆顶存下来
        cout << cur.val << '\n';

        // 只让同一个函数往前走一格:其他函数的候选本来就在堆里,不需要重放
        const Func &g = f[cur.id];
        int nx = cur.x + 1;
        long long nv = g.a * nx * nx + g.b * nx + g.c;
        pq.push({nv, cur.id, nx});
    }
    return 0;
}
逐行拆解
代码片段含义与理由
struct Func { long long a, b, c; };系数单独存在 vector 里。堆里每个结点只带 id,重算时回表取系数,结点小、拷贝便宜。
bool operator<(const Node &o) const { return val > o.val; }整个算法的方向由这一行决定。写成 val < o.val 就是大根堆,每次弹出当前最大值,答案整片错。末尾的 const 不能漏,漏了报错指向模板深处。
long long v = f[i].a + f[i].b + f[i].c;初始候选是 x = 1 的值。因为 a 是 long long,加法的每一步都按 64 位算,不会溢出。
priority_queue<Node> pq;三个模板参数这里只写了第一个,因为另外两个用默认值(vector<Node> 和 less<Node>),而 less<Node> 会去调我们刚重载的 operator<。
Node cur = pq.top(); pq.pop();先复制再删。pq.pop() 返回 void,直接写 cur = pq.pop() 编译不过。
cout << cur.val << '\n';输出的是值,不是下标、也不是 x。这里最容易在改代码时手滑写成 cur.id。
const Func &g = f[cur.id];引用而不是拷贝,避免每次迭代复制三个 long long;const 只是表明不修改。
pq.push({nv, cur.id, nx});把同一个函数的下一个值放回去。写成别的 id,那条序列就会断层,答案偏大。
pq 的大小恒为 n初始 n 个候选,每轮一出一进,所以堆永远是 n 个元素。O(m log n) 的前提就靠这一点。
同一个堆,换个方向:Top-K 只留 k 个

问题:n 个数里找出最大的 k 个(k 远小于 n),n 可以到 10⁶。排序是 O(n log n),而只留 k 个候选可以做到 O(n log k)。

做法:维护一个大小为 k 的小根堆。堆顶是“当前第 k 大”,也就是候选里最差的那个。新来的数只有比堆顶大,才可能挤进前 k,于是弹掉堆顶、把新数放进去;否则直接扔掉。

为什么这样对:扫描到任意位置时,堆里恰好是“已扫描部分最大的 min(k, 已扫描个数) 个”。归纳一步:新数 x 若 ≤ 堆顶,则堆里已有 k 个数 ≥ 堆顶 ≥ x,x 挤不进前 k(最多与第 k 名并列);若 x > 堆顶,堆顶是 k 个里最差的,用 x 换掉它,剩下的仍然是最大的 k 个。

Top-K 的核心代码
// n 个数里最大的 k 个,按降序输出
priority_queue<int, vector<int>, greater<int>> q;   // 小根堆,堆顶 = 第 k 大
for (int i = 0; i < n; ++i) {
    int x;
    cin >> x;
    if ((int)q.size() < k) q.push(x);               // 前 k 个直接进
    else if (x > q.top()) {                         // 只有更大才值得换
        q.pop();
        q.push(x);
    }
}
vector<int> ans;
while (!q.empty()) { ans.push_back(q.top()); q.pop(); }   // 弹出是升序
reverse(ans.begin(), ans.end());                   // 翻成降序

两个必须记住的细节:①堆的大小没到 k 时不能读 q.top()(空堆 RE),所以先判 size;②求“最小的 k 个”时方向完全反过来 —— 用大根堆,堆顶是第 k 小,条件是 x < 堆顶。堆顶永远是“当前候选里最差的那个”,被替换的永远是它。

代价对比:n = 10⁶、k = 10 时,log k ≈ 3.3,总比较约 3.3×10⁶ 次;排序要 2×10⁷ 次,差一个量级。k 接近 n 时两者差不多,直接 sort 更省事。

0-1 BFS:只有两种边权时,deque 比堆便宜

模型:n × m 的网格,每格有权 0 或 1。从左上角走到右下角,每步上下左右,进入一格的代价等于该格的权值,求最小总代价。

普通 BFS 不行。BFS 的正确性依赖“先发现的更近”,而这里边权有 0:同一个点可能被一条长路先发现(层数多),却被一条短路后到达(总代价小)。层数和距离脱钩了。

Dijkstra 行,但贵。nm 个点各有一个 dist,每次取最小要堆,复杂度 O(nm log(nm))。可是这里边权只有 0 和 1,dist 的最小值只有两种可能:当前值 d,或 d + 1。两种值只需要两个“桶”,而 deque 恰好能当两个桶用 —— 这就是 0-1 BFS。

规矩只有两条:从队头取点 u 扩展;若走到 v 的边权是 0,把 v push_front,若是 1,把 v push_back。

为什么队头一定是最小的。设当前 dq 里的距离序列非递减,且只含 d 与 d + 1 两种值。从队头取出 dist = d 的点 u:边权 0 的新点距离也是 d,插到队头不破坏非递减;边权 1 的新点距离是 d + 1,插到队尾也不破坏非递减。两条规矩都是为了保住这个不变式。于是每次取出的都是当前最小,和 Dijkstra 一样,只是“堆”退化成了两个桶。

0-1 BFS 的完整骨架
#include <bits/stdc++.h>
using namespace std;

const int N = 1005;
const int INF = 0x3f3f3f3f;
int n, m;
int w[N][N], d[N][N];
int dx[4] = {-1, 1, 0, 0}, dy[4] = {0, 0, -1, 1};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> m;
    for (int i = 0; i < n; ++i)
        for (int j = 0; j < m; ++j) {
            cin >> w[i][j];
            d[i][j] = INF;
        }

    deque<int> dq;                       // 存编号 x*m+y,省一个 pair 的拷贝
    d[0][0] = 0;
    dq.push_front(0);                    // 源点距离最小,从队头进
    while (!dq.empty()) {
        int id = dq.front();
        dq.pop_front();
        int x = id / m, y = id % m;      // 注意除的是列数 m,不是行数 n
        for (int k = 0; k < 4; ++k) {
            int nx = x + dx[k], ny = y + dy[k];
            if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue;
            int nd = d[x][y] + w[nx][ny];
            if (nd >= d[nx][ny]) continue;   // 不比已知更优,就不必再进队
            d[nx][ny] = nd;
            if (w[nx][ny] == 0) dq.push_front(nx * m + ny);   // 0 边:插队头
            else                dq.push_back(nx * m + ny);    // 1 边:排队尾
        }
    }
    cout << d[n - 1][m - 1] << '\n';
    return 0;
}
0-1 BFS 的四个要点
代码片段含义与理由
deque<int> dq; dq.push_front(0);源点必须从队头进:它的距离 0 是全图最小的。用 push_back 也碰巧对,但会破坏“队头最小”的阅读直觉。
int x = id / m, y = id % m;编号是按行铺开的,所以模和除都用列数 m。用成 n 时只有方阵能过,非方阵立刻 WA —— 这类错极难自查。
if (nd >= d[nx][ny]) continue;这里没有 visited,而且不能有:同一个点可能入队两次 —— 先被一条 1 边松弛到 d + 1,再被一条 0 边改小到 d(新的一份插到队头,会先出队)。所以判断条件必须是 nd < d[nx][ny]。这一行也是复杂度的保证:每个点的 dist 最多被改小一次,即最多入队两次,总代价 O(V + E)。
w[nx][ny] == 0 ? push_front : push_back整个算法只有这一行在区分两种边。判断的对象是进入 v 的这条边的权值,不是 u 的权值,也不是 dist 的奇偶。写错就退化成 SPFA。
cout << d[n-1][m-1]终点代价含终点自己的权值(因为代价计在“进入”上)。若题面把代价定义在出发格,就要改成 d[n-1][m-1] + w[0][0] —— 读题时确认一次。
这一节的 7 个坑,每个都有具体症状

空 deque 上调用 front() / back() / pop_front():未定义行为,几乎必然 RE。0-1 BFS 里忘记 push 源点,或者把 while 写成 do-while,第一轮就访问空容器。

0-1 BFS 把 0 边的 push_front 写成 push_back:距离序列不再非递减,同一个点被反复松弛,答案仍然正确但会超时(TLE)。这种错在随机小数据上完全看不出来,必须用长链数据压。

0-1 BFS 用 int x = id / n:只有 n == m 时才对,矩形网格一测就 WA。牢记“行优先编号,除和模都用列数”。

priority_queue 自定义比较器方向写反(想要小根堆却写 return val < o.val;):只有一个函数时看不出问题,n 个函数立刻 WA。方向口诀:cmp 写“谁在前”,operator< 写“谁在后”。

Top-K 用默认大根堆还写 if (x > q.top()):堆顶是候选里最大的,新数几乎永远比不过它,结果输出的是前 k 个输入数 → WA(数据 5 1 9 3 7 2、k = 3 时会输出 1 5 9,正确答案 9 7 5)。

用 int 存函数值或前缀乘积:P2085 里 a·x² 可到 10¹¹,溢出成负数后堆顶跑到最小端,输出一整片负数 → WA。凡是“平方”“乘积”“累加”,先估一遍量级。

想修改已经 push 进堆的元素(比如把某个点的 dist 改小后直接改结构体字段):堆内部顺序不会重排,之后 top() 拿到的是错的值 → WA。正确做法是 push 一份新值,取出时判旧值并跳过。

自己动手复现

关掉页面,把 P2085 的代码从零敲一遍并提交。

自测数据:2 4 / 1 1 1 / 1 1 2,两个函数的值是 3,7,13,21 和 4,8,14,22,答案应为 3 4 7 8。手算一遍再对拍。

改一改①:把 operator< 里的 val > o.val 改成 val < o.val(变成大根堆),同一组数据,输出从 3 4 7 8 变成 4 8 14 22 —— 四个数全取自第二个函数,第一个函数一次都没出队。只改一个字符,却是本节最高频的错。

改一改②:Top-K 代码把 greater<int> 删掉变成大根堆,跑 n = 6、k = 3、数据 5 1 9 3 7 2。正确答案是 9 7 5,改完得到 1 5 9 —— 亲手确认一次“堆顶是谁决定了一切”。

数学补给 · log 到底省了多少:堆的高度、Top-K 与两个桶

堆是一棵完全二叉树:n 个结点时高度 h = ⌊log₂ n⌋ + 1。一次 push 或 pop 沿着一条从根到叶的路径上下移动,最多比较 h 次,所以是 O(log n)。堆的所有复杂度都来自这一条路径。

n⌊log₂ n⌋堆高 hn·log₂n 次比较n·log₂k(k = 10)次比较
10³91010⁴3.3×10³
10⁵16171.7×10⁶3.3×10⁵
10⁶19202.0×10⁷3.3×10⁶
10⁸26272.7×10⁹3.3×10⁸

速记:2¹⁰ = 1024 ≈ 10³,所以每 10 次二分大约跨越 3 个十进制位。log₂(10⁶) = 19.9,log₂(10⁹) = 29.9 —— 十亿也就 30 层。竞赛里 n ≤ 10⁶ 时,log n 的常数按 20 估,堆操作 10⁶ 次约 2×10⁷ 次比较,在 1 秒内很宽裕。

Top-K 的收益就来自把 log n 换成 log k:n = 10⁶、k = 10 时是 3.3×10⁶ 对 2.0×10⁷,差 6 倍;k = 10³ 时是 10⁷ 对 2×10⁷,只差 2 倍 —— k 越大越不值得用堆,这是选择排序还是堆的量化依据。

0-1 BFS 省掉的则是整个 log。Dijkstra 每次要取 dist 最小的点,所以需要堆;而边权只有 0 和 1 时,队列中的 dist 只可能是 d 与 d + 1 两个值,“取最小”退化成“看队头属于哪个桶”,O(log V) 变成 O(1),总复杂度从 O(E log V) 降到 O(V + E)。

这类“值域很小的最短路”有统一名字:桶排序式的最短路(Dial 算法)。边权是 0…W 时开 W + 1 个桶循环使用,复杂度 O(E + W·V);W = 1 就是本节的双端队列版本。

举一反三 · 想清楚这些再往下走
  1. 如果边权只有 0、1、2 三种,deque 还够用吗?想想需要几个“桶”,以及为什么不能简单地用两个 deque 循环装(提示:取最小的时候要同时看两个桶的头)。
  2. P2085 如果 m 到 10⁹ 而 n 只有 10,堆还合适吗?提示:二分答案 mid,数一数每个函数有几个 x 满足 F_i(x) ≤ mid —— 解一元二次不等式,O(n log C) 就能定位第 m 小。
  3. Top-K 要改成“只求第 k 大、不要排序”时,还能更快吗?回忆第 13 节的 nth_element 与快速选择,为什么它的期望复杂度是 O(n) 而不是 O(n log k)?
  4. 把 0-1 BFS 的 deque 直接换成 priority_queue,答案还对吗?复杂度变成多少?在什么形状的数据上这两种写法几乎没差别?
  5. P2085 如果 a_i 允许为负,单调性就没了。这时该怎么办?提示:抛物线顶点在 x₀ = −b/(2a),从顶点往两边各是一条单调序列,相当于每个函数贡献两条序列。
  6. 为什么 0-1 BFS 的队列里元素距离“最多只差 1”?如果图里有 −1 权边,这个性质会怎么坏掉?再想想为什么负权图需要 Bellman-Ford 而不是 Dijkstra。
  7. 用 deque 求滑动窗口最大值(第 19 节单调队列)时,为什么被淘汰的元素从队尾弹,而不是队头?把“队头”和“队尾”的角色说清楚,你就真的懂 deque 了。
自测:能全部回答再进下一节
  • 能不看资料写出 deque 的四个两端操作,并说出它与 vector 的实质差别(以及它不能做什么)。
  • 能写出 priority_queue 的三参数模板,并解释三个参数各自为什么不能省。
  • 能说清 0-1 BFS 中 push_front 与 push_back 分别对应哪种权值,并给出“队头最小”的证明。
  • 能证明 P2085 中每个函数只推进 m 次,并算出总复杂度 O((n + m) log n)。
  • 能用一句话说明 Top-K 为什么用小根堆、堆顶代表什么、被替换的为什么是堆顶。
  • 知道 priority_queue 不能修改已入堆的值,要用“重新 push 一份 + 取出时跳过旧值”的办法。
挑战题 · 序列合并
洛谷 P1631普及/提高-去洛谷做这题 ↗

有两个长度都是 N 的序列 A、B。在 A 中取一个数、在 B 中取一个数相加,一共能得到 N² 个和。求这些和里最小的 N 个,按升序输出。

输入格式:第一行一个整数 N;第二行 N 个整数 A_i;第三行 N 个整数 B_i。

输出格式:一行 N 个整数,为最小的 N 个和,从小到大排列,用空格隔开。

数据范围:1 ≤ N ≤ 10⁵,1 ≤ A_i, B_i ≤ 10⁹。

引导性提示(先只看这里)

N² 个和存都存不下(N = 10⁵ 时是 10¹⁰ 个),必须利用“有序”。先把 A、B 都升序排序,再观察结构。

固定 A_i,序列 A_i + B_1, A_i + B_2, …, A_i + B_N 是递增的(因为 B 已排序)。于是 N² 个和 = N 个递增序列的并,问题变成多路归并。

要求全局第 1 小,只需在这 N 个序列的首元素里取最小 —— 用一个堆维护这 N 个候选,取出之后只把被取走的那条序列往前推一格。

想看思路了?点开完整思路

排序。把 A、B 都升序排。设排好后 A_1 ≤ … ≤ A_N,B_1 ≤ … ≤ B_N。

转化。对每个 i,S_i = (A_i + B_1, A_i + B_2, …, A_i + B_N) 是一条非递减序列。N² 个和正是这 N 条序列的全部元素。求最小的 N 个 = N 路归并取前 N 项。

候选。每个序列只把首元素 A_i + B_1 放进堆:堆结点存 (和, i, j),i 是行号,j 是 B 的下标。堆里初始有 N 个元素。

循环 N 次。取堆顶输出;若 j < N,把同一行的下一个 (A_i + B_{j+1}, i, j+1) 放回堆。一出一进,堆大小恒为 N。

正确性。堆里始终是每条序列“尚未输出部分”的首元素,而序列内部非递减,所以全局最小必定是某个序列的首元素 —— 它一定在堆里。取出后只推进该序列,其余序列的首元素不变,不变式保持。

为什么只推进一格不会漏。S_i 的第 j+1 项只有在第 j 项被取走之后,才可能成为全局最小(它 ≥ 第 j 项,而第 j 项当时就在堆里且更小)。

复杂度。排序 O(N log N),归并 N 次、每次 O(log N),总共 O(N log N),N = 10⁵ 轻松过。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int N = 100005;
int n;
long long a[N], b[N];

struct Node {
    long long val;          // 和:A_i + B_j
    int i, j;               // 来自 A 的第 i 个、B 的第 j 个(1-indexed)
    // 小根堆:val 大的排在后面(与 sort 的 cmp 反着写)
    bool operator<(const Node &o) const { return val > o.val; }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    for (int i = 1; i <= n; ++i) cin >> a[i];
    for (int i = 1; i <= n; ++i) cin >> b[i];
    sort(a + 1, a + n + 1);
    sort(b + 1, b + n + 1);      // 两个都要排,只排一个序列就不单调了

    priority_queue<Node> pq;
    for (int i = 1; i <= n; ++i)
        pq.push({a[i] + b[1], i, 1});    // 每条序列只放首元素,共 N 个候选

    for (int t = 1; t <= n; ++t) {
        Node cur = pq.top();
        pq.pop();                        // pop 不返回值,先存堆顶
        cout << cur.val << (t == n ? '\n' : ' ');

        if (cur.j < n) {                 // 该行还有下一个,放回去
            int nj = cur.j + 1;
            pq.push({a[cur.i] + b[nj], cur.i, nj});
        }
    }
    return 0;
}
关键片段拆解
片段作用
sort(a + 1, a + n + 1); sort(b + 1, b + n + 1);两个序列都必须升序。只排 A 的话,固定 A_i 得到的 A_i + B_j 就不再单调,“堆里放首元素”的前提直接崩了。
pq.push({a[i] + b[1], i, 1});初始候选是每条序列的最小项,共 N 个。不要把所有 N² 个和都塞进去 —— 内存和时间都不允许。
bool operator<(const Node &o) const { return val > o.val; }取最小 → 小根堆 → operator< 里写 val > o.val。方向反了就是每次输出当前最大,答案整片错。
cout << cur.val << (t == n ? '\n' : ' ');行末不能多空格,用一个条件表达式控制分隔符。写成先输出再判空也等价,但别两边都加空格。
if (cur.j < n)边界:j = N 时该行已经取完,不能再 push。不加这个判断会访问 b[N+1],读到 0 或脏数据 → WA。
pq.push({a[cur.i] + b[nj], cur.i, nj});只推进 cur 所在的这一行,i 必须沿用 cur.i。写成别的行号会让某条序列断层、另一条重复,答案错。
long long valA_i、B_i 到 10⁹,两个相加到 2×10⁹,已经贴着 int 上限;必须 long long。
这题最容易错的地方
  • 只排序一个序列:固定 A_i 后 A_i + B_j 不单调,堆里放的“首元素”不是最小,WA。
  • 把 N² 个和全部算出来再排序:N = 10⁵ 时是 10¹⁰ 个 long long,内存直接爆(MLE / RE)。
  • 弹出后忘记 push 下一项:堆会提前变空,循环还没走完就对空堆 top() → RE;只忘了 j 加 1(一直 push 同一个 j):输出一堆重复值 → WA。
  • 用 int 存和:2×10⁹ 溢出成负数后堆序全乱 → WA。
  • 行末多输出一个空格:本机看不出,严格比对时会 WA。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》第 0x26 节:广搜的变形(双端队列 BFS)
  • 《算法竞赛试炼场:洛谷 300 题精析》搜索章:0-1 BFS 与多路归并
  • 洛谷 P2085、P1631 题解区的高赞思路
29 栈与队列的应用普及/提高-

29 · 栈与队列的应用

把 LIFO/FIFO 从“系统自动帮你维护”变成“自己调度”:后缀表达式求值(栈顶即最近的两个操作数)、括号匹配(栈顶即最近未匹配的左括号)、递归改显式栈、队列模拟约瑟夫环。
普及/提高-预计 60 分钟栈括号匹配后缀表达式
本节出现的新函数 / 新语法(先看懂再用)

栈和队列不是两个新容器,是两种顺序约定:栈规定“最后进来的第一个出去”(LIFO),队列规定“最先进来的第一个出去”(FIFO)。前面几节其实一直在用它们 ——BFS 用队列按层扩散,递归用系统栈保存现场 —— 但都是自动的。这一节把它们拿到手里,用一行 st.push(x) 自己决定什么时候存、什么时候取。

具体做三件事:①后缀表达式求值,看清“栈顶就是最近的两个操作数”;②括号匹配,看清“最近未匹配的左括号就是栈顶”;③把递归改写成显式栈,把队列当成一个循环的圈。

栈的语义只有一句:最近的东西最相关。凡是“最近的还没处理完”能刻画问题的地方,都能用栈。
容器从哪进 / 从哪出看一眼拿到谁典型场景
stack<int>都从栈顶最近压入的括号匹配、后缀表达式、递归改迭代、单调栈
queue<int>队尾进、队头出最早进来的BFS 按层扩散、约瑟夫环、任务排队
deque<int>两端都能进出最早或最近的0-1 BFS、滑动窗口最值(第 28 节)

三种容器的接口几乎一样(push / pop / empty / size),区别只在 pop() 删的是哪一个,以及取元素用的是 top() 还是 front()。把这三个名字对错位置,是本节第一个坎。

新函数拆解 · stack:压栈、弹栈、看栈顶
stack<int> st; st.push(x); st.top(); st.pop(); st.empty(); st.size();
组成含义 / 格式要求
stack<int>头文件 <stack>(bits 已包含)。只有“栈顶”一个出入口;底层默认用 deque 装,所以不用预先开大小。
st.push(x)压栈,O(1)。x 成为新的栈顶,原来的栈顶被压在它下面。
st.top()读栈顶,O(1),不删除。返回引用,可以赋值(st.top() += 3 合法)。栈空时调用是未定义行为,通常 RE。
st.pop()弹栈,O(1),删除栈顶,返回 void。想用被删的值,必须先 top() 存下来。
st.empty() / st.size()判空 / 元素个数。size() 的返回类型是 size_t(无符号),空栈时算 size() - 1 会得到 18446744073709551615。
没有迭代器、没有 clear()stack 故意不给你遍历和下标 —— 只能看栈顶,这就是它的语义。清空只能循环 pop,或者 st = stack<int>(); 重新赋值。
最小可运行示例
示例
stack<int> st;
st.push(1);                         // 栈底 → [1]
st.push(2);                         //        [1, 2]
st.push(3);                         //        [1, 2, 3] ← 栈顶
cout << st.top();                   // 3
st.pop();                           // 弹出 3,剩下 [1, 2]
cout << st.top() << st.size();      // 22

// 清空的标准写法
while (!st.empty()) st.pop();
注意

int x = st.pop(); 编译不过:pop() 返回 void。这是初学者最高频的一行错。

top() 返回的是引用,char c = st.top(); st.pop(); 的顺序不能反 —— 先 pop 再 top 拿到的是下一个元素。

新函数拆解 · queue 的三个操作(对照 stack 记,第 26 节已用过)
queue<int> q; q.push(x); q.front(); q.back(); q.pop(); q.empty(); q.size();
组成含义 / 格式要求
queue<int>头文件 <queue>。队尾进、队头出;先进先出,所以最早进来的最先被处理。
q.push(x)入队,O(1)。只能从队尾进。
q.front()读队头,O(1),不删除。相当于 stack 的 top(),但拿到的是最旧的元素。
q.back()读队尾,O(1)。拿到的是最新入队的元素。
q.pop()删除队头,O(1),同样返回 void。
与 stack 的对照取元素:stack 用 top(),queue 用 front()。pop() 删的都是“下一个该被处理的”,但 stack 处理最新的,queue 处理最旧的 —— 这一句就是 LIFO 与 FIFO 的全部差别。
最小可运行示例
示例
queue<int> q;
for (int i = 1; i <= 5; ++i) q.push(i);   // [1, 2, 3, 4, 5]
cout << q.front() << q.back();            // 15

// 把队头搬到队尾:整圈轮转一格
q.push(q.front());
q.pop();
cout << q.front();                        // 2
注意

第 26 节已经用 queue 写过 BFS,这里只做对照:把 top 和 front、把“最新”和“最旧”彻底分清。

queue 不能随机访问,也不能倒着走。需要两端都能动时用 deque(第 28 节)。

把队头搬到队尾只要两行:q.push(q.front()); q.pop(); —— 记住这个动作,约瑟夫环就是它。

例题 · 后缀表达式洛谷 P1449普及-去洛谷做这题 ↗

后缀表达式(逆波兰式)把运算符写在两个操作数之后。例如 3.5.2.-*7.+@ 对应中缀写法 3×(5−2)+7,值为 16。

输入一行后缀表达式:操作数是十进制非负整数,每个数后面跟一个 '.' 表示这个数结束;运算符只有 +、−、×、÷;整行以 '@' 结尾。

输出格式:一行一个整数,即表达式的值。

题目保证表达式合法;除法按 C++ 的整数除法计算(向零取整)。

输入: 3.5.2.-*7.+@ 输出: 16
为什么后缀表达式一遍就能算完

中缀表达式难算,是因为读到 + 的时候你还不知道后面有没有 ×:优先级和括号都要“往后看”。后缀表达式把这些决定提前做完了,读到运算符时,它要的两个操作数一定刚好是最近的两个。

“最近的”就是栈顶。于是算法只有三行:数字入栈;运算符弹出两个算一次再压回去;扫完栈里剩一个数。

顺序是关键。先弹出的是右操作数 b,后弹出的是左操作数 a,计算的是 a op b。对 + 和 × 无所谓,对 − 和 ÷ 写反就得到相反数或倒数 —— 这是本节最高频的 WA。

为什么这样是对的。用归纳看不变量:扫过某个前缀之后,栈里从底到顶正好是“这个前缀算出的、还没被合并的中间结果”,并且它们的相对顺序与表达式剩余部分一致。读到一个数:它是新的中间结果,压栈,不变量成立。读到一个运算符:它作用在最近的两个中间结果上,弹两个压一个,正好是它们的合并,不变量仍成立。扫完整个表达式只剩一个中间结果,就是答案。

复杂度 O(n):每个字符进栈一次或做一次常数运算。长度 10⁵ 的表达式,几百微秒的事。

四个分支,一个栈
// 骨架:一个栈包打天下(st 是 stack<long long>,num 是正在攒的数)
while (cin >> c) {
    if (c >= '0' && c <= '9') {
        num = num * 10 + (c - '0');        // 多位数:一位一位攒
    } else if (c == '.') {
        st.push(num); num = 0;             // '.' 是数字结束符,不是小数点
    } else if (c == '@') {
        break;                             // 表达式结束
    } else {                               // + - * /
        long long b = st.top(); st.pop();  // 先弹出的是右操作数
        long long a = st.top(); st.pop();  // 后弹出的是左操作数
        st.push(c == '+' ? a + b : c == '-' ? a - b : c == '*' ? a * b : a / b);
    }
}
完整可编译代码(C++17)
#include <bits/stdc++.h>
using namespace std;

int main() {
    stack<long long> st;          // 中间结果可能很大,用 long long 不用 int
    long long num = 0;            // 正在读的那个数
    char c;
    while (cin >> c) {            // 逐字符读,cin >> char 会自动跳过空白
        if (c >= '0' && c <= '9') {
            num = num * 10 + (c - '0');      // 多位数:每来一位,前面的整体左移一位
        } else if (c == '.') {
            st.push(num);         // '.' 是这个数的结束符
            num = 0;              // 清零,准备读下一个数
        } else if (c == '@') {
            break;                // 表达式结束
        } else {                  // + - * /
            long long b = st.top(); st.pop();   // 先弹出的是右操作数
            long long a = st.top(); st.pop();   // 后弹出的是左操作数
            long long r = 0;
            if (c == '+')      r = a + b;
            else if (c == '-') r = a - b;       // 不能写成 b - a
            else if (c == '*') r = a * b;
            else               r = a / b;       // 同上,顺序错了结果就反了
            st.push(r);           // 结果必须压回栈,后面的运算符要用它
        }
    }
    cout << st.top() << '\n';     // 扫完栈里恰好剩一个数
    return 0;
}
逐行拆解
代码片段含义与理由
stack<long long> st;栈里存的是“中间结果”,不是字符。用 long long 是因为 7×8×9×… 这类连乘很快超出 int,本题数据小,但换个数据就爆。
while (cin >> c)逐字符读。cin >> char 会自动跳过空格和换行,所以表达式里有没有空格都不影响。另一种写法是 getline 整行再遍历,那样要自己处理 '\r'。
num = num * 10 + (c - '0');多位数拼装。342 是 3→34→342:每次先把已有的数乘 10 进位,再加上新的一位。(c - '0') 把字符 '7' 转成整数 7,靠的是 ASCII 码连续。
else if (c == '.')本题的 '.' 是数字结束符,不是小数点。看到它就说明前面攒的 num 是一个完整的操作数。
long long b = st.top(); st.pop();先弹出的是右操作数。后缀表达式 3.5.- 的含义是 3 − 5,栈里是 [3, 5],先弹出 5 才是被减数那一侧的 b。
long long a = st.top(); st.pop();后弹出的是左操作数。两个 pop 的先后顺序就是本题唯一需要动脑的地方。
r = a - b; r = a / b;减法和除法不满足交换律,写成 b − a 后 5.3.- 会输出 −2 而不是 2。加法和乘法写反了看不出来,所以这个错常被样例放过。
st.push(r);结果必须压回栈顶,它是后续运算的操作数。漏了这一行,下一个运算符弹到的是更早的数据,整片错。
cout << st.top()合法表达式扫完后,所有中间结果都合并成了一个。若栈里还剩两个,说明表达式不合法或漏算了。
同一个栈,换个问题:括号匹配

给一行字符串(可能含其他字符,忽略),判断其中 ()、[]、{} 三种括号是否配对且嵌套正确。

栈顶就是“最近的、还没被匹配的左括号”。遇到左括号压栈;遇到右括号,它必须和栈顶配对 ——因为括号的配对是后开的先闭,这正是 LIFO。扫描结束时栈必须为空,否则有左括号没闭上。

括号匹配:栈的三个出口
#include <bits/stdc++.h>
using namespace std;

bool check(const string &s) {
    stack<char> st;
    for (char c : s) {
        if (c == '(' || c == '[' || c == '{') {
            st.push(c);                       // 左括号:记住它,等待配对
        } else if (c == ')' || c == ']' || c == '}') {
            if (st.empty()) return false;     // 右括号多了:没有左括号可以配
            char t = st.top();
            if ((c == ')' && t != '(') ||
                (c == ']' && t != '[') ||
                (c == '}' && t != '{')) return false;   // 类型不对,嵌套关系被破坏
            st.pop();                          // 配对成功,弹出这一个
        }
    }
    return st.empty();                        // 左括号多了时这里返回 false
}

int main() {
    string s;
    getline(cin, s);
    cout << (check(s) ? "YES" : "NO") << '\n';
    return 0;
}

三个返回点对应三种失败:①栈空却来了右括号(右括号多);②类型不匹配(嵌套错);③扫完栈非空(左括号多)。漏掉任何一个,都会把非法串判成合法。

只有一种括号时,其实一个计数器就够了:遇 ( 加一、遇 ) 减一,中途不能为负、最后必须为 0。这是同一个不变式的退化版本。一旦有第二种括号,计数器就不够了 —— 它记不住“该闭的是哪一种”,例如 ([)] 的计数完全合法,但嵌套是错的。

第三个变体:出栈顺序合法吗

按 1, 2, …, n 的顺序压栈,任意时刻都可以弹栈。给定一个出栈序列,问它是否合法。判断规则只有一条:按入栈顺序逐个压栈,每压一个就检查栈顶是不是出栈序列当前要的元素,是就一直弹;压完全部之后若栈为空,就合法。

为什么这一条规则够:出栈序列的第一个元素是 k,意味着 1…k 都已经压进来过(不压进去弹不出来),而 1…k−1 还压在 k 下面。此时“能弹就弹”不会损失任何选择 —— 早弹掉一个已经可以弹的元素,只是把它从栈顶移走,不改变剩下元素之间的压弹关系。

8 行判出栈序列(3 1 2 判 false,2 1 3 判 true)
// 入栈序列固定为 1..n,判断 out[1..n] 是不是合法的出栈序列
bool ok(int n, const vector<int> &out) {
    stack<int> st;
    int p = 1;                              // 下一个还没压进去的数
    for (int i = 1; i <= n; ++i) {
        // 栈顶不是 out[i],就只能继续压,直到压出它或压完
        while (p <= n && (st.empty() || st.top() != out[i])) st.push(p++);
        if (st.empty() || st.top() != out[i]) return false;   // 压完了还是没有
        st.pop();                           // 栈顶正好是 out[i],弹掉它
    }
    return true;
}
把递归搬到堆上:用栈模拟递归

第 21 节说过:递归靠系统栈,每层压入参数、局部变量和返回地址,深度到 10⁶ 就爆栈 RE。既然栈是显式的概念,就可以自己开一个栈放到堆上,把递归改写成循环 —— 求连通块这种“只关心访问过哪些点、不关心访问顺序”的问题,改完和递归完全等价。

显式栈版 flood fill
// 网格连通块:从 (sx, sy) 出发,把能走到的 '.' 全部标记
const int N = 1005;
int n, m;
char g[N][N];
bool vis[N][N];
int dx[4] = {-1, 1, 0, 0}, dy[4] = {0, 0, -1, 1};

void floodStack(int sx, int sy) {
    stack<pair<int, int>> st;
    st.push({sx, sy});
    vis[sx][sy] = true;               // ★ 入栈的同时就标记
    while (!st.empty()) {
        auto [x, y] = st.top();       // C++17 结构化绑定
        st.pop();
        for (int k = 0; k < 4; ++k) {
            int nx = x + dx[k], ny = y + dy[k];
            if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue;
            if (g[nx][ny] != '.' || vis[nx][ny]) continue;
            vis[nx][ny] = true;       // ★ 这里也一样:push 之前标记
            st.push({nx, ny});
        }
    }
}

标记时机是唯一容易错的地方。递归版是“进函数就标记”,显式栈必须在 push 之前标记。如果等 pop 的时候才标记,同一个点会被它的多个邻居各压一次,在 10⁶ 的网格上栈会膨胀到千万级 → MLE 或 TLE。

两者的差别只在访问顺序:递归深度优先地一条路走到黑,显式栈的弹出顺序受入栈顺序影响,遍历次序不同但访问集合相同。所以连通性、可达性这类问题可以随意替换;要求“按字典序输出第一条路径”时不能随便换。

把队列当成一个圈:约瑟夫问题

n 个人围成圈编号 1…n,从 1 号开始报数,报到 m 的人出列,下一个人重新从 1 报,求出列顺序。

用队列,队头就是“当前该报数的人”。报 1 到 m−1 的人安全,把他从队头搬到队尾(相当于绕了一圈);轮到第 m 个人就出队并输出。“搬队头到队尾”这一个动作,就把线性队列变成了环。

队列模拟约瑟夫环
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, m;
    cin >> n >> m;
    queue<int> q;
    for (int i = 1; i <= n; ++i) q.push(i);

    while (!q.empty()) {
        // 前 m-1 个人安全:逐个搬到队尾,报数指针自然前移
        for (int k = 1; k < m; ++k) {
            q.push(q.front());
            q.pop();
        }
        cout << q.front() << ' ';      // 第 m 个人出列
        q.pop();
    }
    cout << '\n';
    return 0;
}

n = 10⁴、m = 10⁴ 时最坏要搬 10⁸ 次,勉强能过;n 到 10⁵ 就必须换算法。一个立刻见效的常数优化:当前还剩 s 个人时,搬 s 次就回到原点,所以只搬 (m - 1) % s 次。彻底的做法是把 m 从一开始就模掉,见下面的数学补给。

这一节的 8 个坑,每个都有具体症状

空栈上调用 top() 或 pop():未定义行为,几乎必然 RE。后缀表达式里运算符比操作数多一个就会触发;括号匹配里 )abc 这种以右括号开头的串,不先判 empty() 就是 RE。

后缀表达式两个操作数顺序搞反(a - b 写成 b - a):样例 3.5.2.-*7.+@ 会输出 −2 而不是 16,属于“样例就能查出来”的错;但很多人只测了全是 + 的数据 → WA。

运算符算完忘记 st.push(r):下一个运算符弹出的是更早的数据或空栈 → WA 或 RE。

把 '.' 当成小数点,用 stod 读它:本题的 '.' 是数字结束符,3.5. 是“数 3、数 5”两个操作数,不是小数三点五 → WA。

括号匹配只用计数法(遇左加一、遇右减一):([)] 会被判成合法 → WA。计数法只对单一类型括号成立。

括号匹配最后忘了 return st.empty():(() 被判成合法 → WA。

约瑟夫轮转次数差一:for (int k = 1; k < m; ++k) 转 m−1 次是对的,写成 k <= m 会多转一个人,n = 5、m = 2 时第一个出列的人从 2 变成 3,整串输出全错 → WA。

显式栈 DFS 在 pop 之后才标记 visited:同一点被多个邻居重复压栈,10⁶ 网格直接 MLE / TLE。

自己动手复现

关掉页面,把 P1449 的代码从零敲一遍并提交,确认样例输出 16。

改一改①:把 r = a - b; 改成 r = b - a;,用表达式 5.3.-@ 测,正确结果是 2,改完变成 −2。亲手确认一次“先弹出的是右操作数”。

改一改②:括号匹配把最后的 return st.empty() 改成 return true,用 (() 测,答案从 NO 变成 YES —— 一个左括号没闭上就被放过了。

改一改③:约瑟夫把 for (int k = 1; k < m; ++k) 改成 k <= m,跑 n = 5、m = 2:正确顺序是 2 4 1 5 3,改完第一个数就变成 3。

数学补给 · 从 O(nm) 到 O(n):约瑟夫的递推公式

队列模拟每次出列要搬 m 次,n 个人总共 O(nm)。n = m = 10⁵ 时是 10¹⁰ 次操作,必然超时。但这个环有一个只看“人数”的递推结构。

把编号改成 0 起:n 个人编号 0…n−1,报到 m 的人出列(下标是 (m−1) mod n)。出列后剩下 n−1 人,从原来编号 m mod n 的那个人开始重新报 1。把这 n−1 个人按新的起点重新编号 0…n−2,则新编号 k 对应原编号 (k + m) mod n。

于是得到递推:J(1) = 0,J(n) = (J(n−1) + m) mod n,其中 J 是胜者在新编号下的下标。答案就是 J(n) + 1(换回 1 起)。因为每次只在“少一个人”的同一个问题上加一步,复杂度 O(n)、空间 O(1)。

nJ(n)(0 起,m = 2)场上还剩的人(1 起)
101
201
323
401
523

用模拟验证 n = 5、m = 2:出列顺序 2 4 1 5 3,最后剩 3 ✓,与递推 J(5) = 2 → 3 一致。手算一遍就能确认公式没有差一:J 用的是 0 起,输出要加 1。

注意这个公式只能求最后的胜者。要输出完整的出列顺序(如 P1996),递推帮不上忙,只能模拟,或者用树状数组/线段树在 O(n log n) 内定位每次出列的人 —— 那是后面的内容。

顺手记一串数:合法括号序列的个数是卡特兰数 Catalan(n) = C(2n, n)/(n+1)(第 22 节算过)。n = 3 时是 5 种:((()))、(()())、(())()、()(())、()()()。同一串数还在数别的东西 ——n 个数进出栈的合法出栈序列也正好是 Catalan(n) 个:n = 3 时 5 个(312 不合法),n = 4 时 14 个,穷举一遍就能验证。括号匹配的栈深度最大值,就等于序列的最大嵌套层数。

举一反三 · 想清楚这些再往下走
  1. 后缀表达式如果出现一元负号(比如 -3.4.+@),现在的代码会怎样?要支持它,需要在读数字时额外维护什么状态?
  2. 中缀表达式转后缀(调度场算法)需要两个栈:一个存数字、一个存运算符。为什么运算符栈的弹出规则是“栈顶优先级 ≥ 当前优先级就弹”,而不是“只看栈顶是否相同”?
  3. 括号匹配如果要求输出“最长的合法括号子串长度”,栈还够用吗?提示:栈里存下标而不是字符,未匹配的位置就是分割点。
  4. 约瑟夫问题用队列模拟是 O(nm)。如果 n = 10⁵、m = 10⁵,而只需要最后的胜者,用数学补给的递推能到多快?如果既要胜者又要中途某个时刻的剩余人数呢?
  5. 递归改显式栈时,哪些问题可以随便换、哪些不行?以“求连通块大小”和“输出字典序最小的路径”为例各说一句。
  6. 单调栈为什么能在线性时间里求出“每个元素右边第一个比它大的数”?它保证的栈内不变式是什么?先想想:为什么被弹出元素的那个“右边第一个更大”就是当前这个新元素。
  7. 用两个栈能不能实现一个队列?反过来,用两个队列能不能实现一个栈?各自需要几次搬移,均摊复杂度是多少?
自测:能全部回答再进下一节
  • 能不看资料写出 stack 的四个操作,并说出 pop() 不返回值意味着什么。
  • 能说清 stack 的 top() 与 queue 的 front() 分别指向哪个元素,pop() 删的又是哪个。
  • 能独立写出后缀表达式求值,并解释为什么先弹出的是右操作数。
  • 能默写括号匹配的三个失败出口,并说明为什么三种括号时计数法会失效。
  • 知道显式栈 DFS 必须在 push 之前标记 visited,并能说出写错后的症状。
  • 能用队列写出约瑟夫模拟,并说出轮转次数为什么是 m−1 而不是 m。
挑战题 · 表达式求值
洛谷 P1981普及/提高-去洛谷做这题 ↗

给定一个只含加法和乘法、不含括号的表达式,运算符按通常的优先级计算(先乘后加),求表达式的值,输出它除以 10000 的余数。

输入格式:一行字符串,为所求表达式。数字是非负整数、不含前导零,只出现 '+' 和 '*'。

输出格式:一行一个整数,表示表达式的值 mod 10000。

数据范围:表达式长度在 10⁵ 量级,单个数字可能有多位。

引导性提示(先只看这里)

从左到右直接算是不行的:1+1*3 从左结合会得 6,正确是 4。必须先算乘法。

整个表达式一定能唯一拆成若干个“加项”之和,每个加项是若干数字的连乘。想办法用一个栈保存“已经定型的加项”,再想乘法该作用在谁身上。

读到 '+' 就结算当前项、开一个新项;读到 '*' 就把当前的数直接乘到栈顶那一项上。结尾别忘了还有最后一个数没有结算。

想看思路了?点开完整思路

结构。任何只含 + 和 * 的表达式都能写成 a₁ + a₂ + … + a_k,其中每个 aᵢ 是若干数的连乘(先乘后加,加法是最后一步)。

用一个栈存加项。变量 num 表示正在读的数,op 表示上一个读到的运算符(初始设为 '+',这样第一个数会以一个加项的形式入栈)。

读到运算符 c 时:先按上一个运算符 op 结算 num —— op 是 '+' 就把 num 作为新加项 push;op 是 '*' 就把它乘进栈顶那一项(乘法优先级高,它和栈顶属于同一个加项)。然后把 op 换成 c、num 清零。先结算、再换状态,顺序不能反。

结尾:字符串末尾没有运算符,循环里不会结算最后一个 num,必须再按 op 结算一次 ——漏掉这一步会少加最后一项。

取模。只要后四位,所以每次结算都 mod 10000;加法和乘法对取模是同余的,边算边模与最后取模结果相同,但避免了天文数字溢出。

正确性。栈顶始终是“正在构造的那个加项”,遇到 '+' 就封口并开新的,遇到 '*' 就往里乘。这正是“先乘后加”的栈化,等价于中缀转后缀后再求值。复杂度 O(长度)。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MOD = 10000;

int main() {
    string s;
    getline(cin, s);

    stack<long long> st;      // 存已经定型的加项,栈顶 = 正在构造的那一项
    long long num = 0;        // 正在读的数(只保留后四位)
    char op = '+';            // 上一个运算符;初值 '+' 让第一个数以加项身份入栈

    for (size_t i = 0; i < s.size(); ++i) {
        char c = s[i];
        if (c >= '0' && c <= '9') {
            num = (num * 10 + (c - '0')) % MOD;   // 只保留后四位,等价于最后取模
        } else if (c == '+' || c == '*') {
            // 先按上一个运算符结算 num,再把 op 换成当前字符
            if (op == '+') st.push(num);          // 新的加项
            else st.top() = st.top() * num % MOD; // 上一个运算符是乘:并进栈顶那一项
            op = c;
            num = 0;
        }
        // 其他字符(空格、'\r')直接忽略:不让它们被当成运算符
    }

    // 结尾的最后一个数还没有结算
    if (op == '+') st.push(num);
    else st.top() = st.top() * num % MOD;

    long long ans = 0;
    while (!st.empty()) {                     // 把所有加项加起来
        ans = (ans + st.top()) % MOD;
        st.pop();
    }
    cout << ans % MOD << '\n';
    return 0;
}
关键片段拆解
片段作用
char op = '+';虚拟一个“上一个运算符是加号”,于是第一个数会被当成一个新加项 push。少了这个初值,第一个数要么丢失、要么要单独写一段特判。
num = (num * 10 + (c - '0')) % MOD;多位数拼装 + 随时取模。因为只要后四位,(a × 10 + d) mod 10000 = ((a mod 10000) × 10 + d) mod 10000,所以可以边读边模,不必担心数字有几百位。
if (op == '+') st.push(num);上一个运算符是加号,说明 num 是一个独立的加项,压栈。
else st.top() = st.top() * num % MOD;上一个运算符是乘号,说明 num 属于栈顶那一项,直接乘进去。top() 返回引用,所以可以这样赋值;换成 long long t = st.top(); 再 push 就错了 ——那会多出一个加项。
op = c; num = 0;结算完才更新状态,顺序不能反。先清零再结算,num 就永远是 0。
else if (c == '+' || c == '*')只认这两个字符。用 else 兜底会把行末的 '\r' 或空格当成运算符,在某些评测环境下直接算错。
if (op == '+') st.push(num); // 循环之外字符串末尾没有运算符触发结算,最后一个 num 必须手工处理。这是本题最容易漏的一块,样例 1+1*3+4 漏了会输出 4 而不是 8。
ans = (ans + st.top()) % MOD;最后求和也要取模。栈里每项都小于 10000、项数最多 10⁵ 量级,总和不超过 10⁹,int 也勉强装得下;但写成 long long 并且边加边模是零成本的保险。
这题最容易错的地方
  • 从左到右直接算:1+1*3 得 6(正确 4),必须先乘后加。
  • 漏掉结尾的最后一个数(不结算栈外的 num):1+1*3+4 输出 4 而不是 8 → WA。
  • 用 else 兜底处理运算符,把行末的 '\r' 或空格当成了运算符:轻则多出一次结算,重则 num 被错误压栈 → WA。
  • 把整条表达式的值全算出来最后才取模:数值迅速超出 long long,溢出成负数 → WA。
  • 遇到 '*' 时新开一项(push 而不是乘到栈顶):2*3*4 会变成 2+3+4 = 9,正确是 24 → WA。
延伸阅读 / 参考
  • 《算法竞赛入门笔记》第 7 章:栈与队列
  • 《算法竞赛进阶指南》第 0x10 节:栈(表达式求值与括号序列)
  • 洛谷 P1449、P1981 题解区的高赞思路
30 回顾六:BFS、二分与容器普及/提高-

30 · 回顾六:BFS、二分与容器

把第 26~29 节压成一套动作:BFS 求无权最短路,二分答案处理单调性,queue / stack / priority_queue 决定「下一次取谁」。先抄一遍 P1443 的完整模板,再用 5 道洛谷黄题限时训练,每题都带完整思路与参考代码。
回顾训练普及/提高-建议 95 分钟回顾5 题

第 26~29 节给的东西可以压成三句话:BFS 按层扩散,第一次访问就是最短路;答案有单调性时,把「求最优」换成「判可行」再二分;容器只决定一件事——下一次该取谁(queue 取最早、stack 取最晚、priority_queue 取最小)。这一节不教新算法,只用一道完整例题把 BFS 模板从头抄到尾,再把二分、堆、队列的骨架各拆一遍,最后用 5 道洛谷黄题限时训练。

BFS 的答案在第一次访问时定下;二分的答案在最后一次收缩时定下;容器的答案在弹出顺序里定下。
节内容看到这些字就上它
26BFS:一层一层地扩散「最少几步」「最短时间」「同时从多个点出发」,且每一步代价相同
27二分查找与二分答案「最大值最小」「最小值最大」「第 k 个满足…」,且答案随判定单调
28双端队列与优先队列「每次取当前最小 / 最大」;边权只有 0 和 1 的最短路
29栈与队列的应用「括号」「后缀表达式」「出栈顺序」;「最近一段时间内到达的人」
例题 · 马的遍历洛谷 P1443普及/提高-去洛谷做这题 ↗

有一个 n × m 的棋盘(1 ≤ n, m ≤ 400),在 (x, y) 处有一个马。

马走日字:一步可以从 (a, b) 走到 (a±1, b±2) 或 (a±2, b±1),共 8 个落点。

求马到达棋盘上每个格子最少要走几步,到达不了的输出 −1。

输入格式:一行四个整数 n、m、x、y。

输出格式:n 行,每行 m 个整数,第 i 行第 j 个数表示马到 (i, j) 的最少步数。相邻整数用空格或合理场宽分隔(旧题解常用的 printf("%-5d") 仍判对)。

输入: 3 3 1 1 输出: 0 3 2 3 -1 1 2 1 4

题意换个说法:把 n × m 个格子看成图的点,8 种马步看成 8 条边,每条边长都是 1,题目问的就是单源最短路。边权全相同时不需要 Dijkstra,BFS 按层扩展,某个格子第一次被访问到时的层数,就是它的最短路长度。

先看清这 6 个东西的结构。它们的名字和参数写错一个字符,整道题就从 WA 开始。

新函数拆解 · queue(队列)
queue<int> q; q.push(x); q.front(); q.pop(); q.empty();
组成含义 / 格式要求
queue<int>先进先出(FIFO)容器。BFS 靠它保证「先被发现的点先扩展」,于是步数单调不减。
q.push(x)放到队尾。BFS 里就是「发现一个新点,排队等处理」。
q.front()读队头元素,只读不删。
q.pop()删掉队头,返回 void,不能写 int u = q.pop();。
q.empty()空返回 true。循环固定写 while (!q.empty())。
q.size()元素个数,返回无符号数,和 int 比较时留意类型。
最小可运行示例
示例
queue<int> q;
q.push(1); q.push(2); q.push(3);   // 队列内:1 2 3

int u = q.front();                 // u = 1,元素还在队里
q.pop();                           // 队列内:2 3

cout << q.front() << " " << q.size() << "\n";   // 2 2
注意

front() 取值和 pop() 删元素是两步。少写一步 = 同一个点被处理两次。

对空队列调用 front()/pop() 是未定义行为:本地可能不崩,评测机上就是 RE。

新函数拆解 · pair 与结构化绑定(C++17)
queue<pair<int,int>> q; q.push({x, y}); auto [x, y] = q.front();
组成含义 / 格式要求
pair<int,int>把两个 int 打包成一个类型。网格题用它存 (行, 列),就不必开两个队列、两个数组。
q.push({x, y})花括号直接构造一个 pair 压入队列。
auto [x, y] = ...结构化绑定:一次把 first、second 拆成两个变量。C++17 起可用。
老写法int x = q.front().first, y = q.front().second;,机子只支持 C++14 时改这样。
嵌套尖括号queue<pair<int,int>> 结尾两个 > 之间留空格,C++11 之前会当成右移。
最小可运行示例
示例
queue<pair<int,int>> q;
q.push({2, 5});
q.push({3, 1});

auto [x, y] = q.front();      // x = 2, y = 5(拷贝,改它不影响队列)
q.pop();

int a = q.front().first;      // 不用结构化绑定的等价写法
int b = q.front().second;
注意

auto [x, y] 拿到的是副本;想改队列里的元素要用引用,而 queue 本来就不给改队头。

坐标顺序全程统一:都用 (行, 列)。方向数组那里如果换成 (列, 行),答案会整体转置。

新函数拆解 · lower_bound / upper_bound(有序区间二分)
int p = lower_bound(a + 1, a + n + 1, x) - a;
组成含义 / 格式要求
区间左闭右开 [first, last),必须已按 < 升序排好。
lower_bound返回第一个大于等于 x 的位置。
upper_bound返回第一个严格大于 x 的位置。
换成下标数组写 - a,vector 写 - v.begin(),否则得到的是迭代器。
找不到返回 last。判断不存在要写 it != last,不能只看 *it == x。
复杂度O(log n) 次比较。n = 10⁶ 时约 20 次,比扫一遍快 5 万倍。
最小可运行示例
示例
int a[6] = {0, 1, 3, 3, 3, 7};              // a[1..5] 有序,下标从 1 起
int p = lower_bound(a + 1, a + 6, 3) - a;   // p = 2,第一个 3
int q = upper_bound(a + 1, a + 6, 3) - a;   // q = 5,第一个大于 3 的位置
int c = q - p;                              // c = 3,即 3 的个数
注意

没排序就 lower_bound,结果毫无意义:在 1 2 1 里查 1,返回的是中间那个。

手写二分有 while (l <= r) 与 while (l < r) 两套模板,混着写必死循环;用库函数没有这个风险。

新函数拆解 · 二分答案的固定骨架(check + 收缩)
while (l < r) { int mid = ...; if (check(mid)) ...; else ...; }
组成含义 / 格式要求
check(x)返回「答案不超过 x(或 x 可行)」。必须单调:x 变大时结果只从 false 变 true,不能反复。
l、r区间永远是「答案所在的范围」。l、r 的初值要真的把答案包住,宁可放宽一格。
mid 防溢出l + (r - l) / 2,别写 (l + r) / 2:两者都接近 2×10⁹ 时会溢出成负数。
求最小值下取整 mid;check 为真 r = mid,否则 l = mid + 1。
求最大值上取整 l + (r - l + 1) / 2;check 为真 l = mid,否则 r = mid - 1。
复杂度O(log 值域 × check)。值域 10⁹ 时约 30 次 check,10⁶ 个数也就 3×10⁷ 次运算。
最小可运行示例
示例
// 求最小的可行值:check 形如 false false ... false true true ...
int l = 0, r = 1000000000;
while (l < r) {
    int mid = l + (r - l) / 2;
    if (check(mid)) r = mid;        // mid 可行,答案不超过 mid
    else            l = mid + 1;    // mid 不可行,答案更大
}
cout << l << "\n";                  // 收敛时 l == r,就是答案
注意

「r = mid 配下取整」「l = mid 配上取整」这两对必须成套。配错了就是死循环 TLE。

check 写错方向(把 ≥ 写成 >、把「不超过」写成「不少于」)时,二分照样跑完,只是答案是错的——这类 WA 最难查。

新函数拆解 · priority_queue(优先队列 / 堆)
priority_queue<int, vector<int>, greater<int>> pq;
组成含义 / 格式要求
priority_queue<int>默认大根堆:top() 是当前最大值。
三个参数元素类型、底层容器、比较方式。greater<int> 表示小的优先,即小根堆。
pq.push(x)插入,O(log n)。
pq.top()读堆顶(最值),O(1),只读不删。
pq.pop()删堆顶,O(log n),同样不返回值。
底层完全二叉树压在数组里;建堆 O(n),单次操作 O(log n)。
最小可运行示例
示例
priority_queue<int, vector<int>, greater<int>> pq;   // 小根堆

for (int x : {5, 1, 4, 2}) pq.push(x);
while (!pq.empty()) {
    cout << pq.top() << " ";   // 1 2 4 5
    pq.pop();
}
注意

写成 priority_queue<int> 就是大根堆,取出的顺序整个反过来——合并果子这类题会直接 WA。

堆里存结构体要自己写比较规则;比较规则写反、或漏了兜底字段,是堆题的第一大 WA 来源。

新函数拆解 · stack(栈)
stack<int> st; st.push(x); st.top(); st.pop(); st.empty();
组成含义 / 格式要求
stack<int>后进先出(LIFO)容器。
st.push(x)压栈,放到栈顶。
st.top()读栈顶,O(1),只读不删。
st.pop()弹出栈顶,返回 void。
用之前必须判空对空栈 top()/pop() 是未定义行为,代码里永远「先判空、再 top」。
最小可运行示例
示例
stack<char> st;
string s = "(()";
for (char c : s) {
    if (c == '(') st.push(c);
    else if (!st.empty()) st.pop();   // 先判空
}
cout << (st.empty() ? "匹配" : "不匹配") << "\n";
注意

括号匹配的判据是「扫完栈为空」;中途遇到右括号而栈已空,同样直接判不匹配。

把 stack 换成 queue 就是 BFS,换成 deque 就是 0-1 BFS——三个容器只差一两个函数名。

容器怎么选:只看「下一次该取谁」

取最早进来的 → queue。BFS、按时间滑动的窗口(海港)。

取最后进来的 → stack。括号匹配、后缀表达式、非递归 DFS。

取当前最小 / 最大 → priority_queue。合并果子、Dijkstra、贪心选最优。

两头都要取 → deque。0-1 BFS(边权只有 0 和 1):权 0 塞队头、权 1 塞队尾,一次 BFS 就得到最短路。

例题怎么想:把「最少几步」翻译成「最短路」

题目只问步数不问路径,所以每个格子只记一个数:从起点走到它最少几步。一个数组就够。

BFS 的三要素:状态 (行, 列);转移 8 个马步;代价 全部为 1。三者齐了,BFS 就是标准答案。

为什么第一次访问一定最短:队列里的步数单调不减(先 0,再一层 1,再一层 2…)。某个格子第一次被碰到时,是由步数最小的那一层扩展出来的;任何更长的路径都要经过步数更大的层,排在队列更后面。所以「第一次」= 「最短」。

距离数组 d 一物两用:既记答案,又当 visited。初值 −1 同时表示「没访问过」和题目要的「到不了」。

起点必须置 0。漏了这一行,全表答案整体多 1,样例从 0 3 2 变成 1 4 3,一眼可见。

入队时就写距离、就标记,不要等出队。写在一起,判重和赋值共用一次判断,也堵死了「同一点被多次入队」的路。

完整代码 · 网格 BFS 模板
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 405;
int n, m, sx, sy;
int d[MAXN][MAXN];      // d[i][j]:起点到 (i,j) 的最少步数;-1 = 没访问到

// 马的 8 种走法,(行增量, 列增量)
const int dx[8] = {-2, -1, 1, 2, 2, 1, -1, -2};
const int dy[8] = {1, 2, 2, 1, -1, -2, -2, -1};

int main() {
    scanf("%d%d%d%d", &n, &m, &sx, &sy);

    memset(d, -1, sizeof d);        // 全设 -1:既是「未访问」,也是要输出的「到不了」
    queue<pair<int,int>> q;         // 队列里放待扩展的格子
    d[sx][sy] = 0;                  // 起点 0 步,先定值再入队
    q.push({sx, sy});

    while (!q.empty()) {
        auto [x, y] = q.front();    // 先取值
        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 (d[nx][ny] != -1) continue;                       // 访问过:BFS 里不必再看
            d[nx][ny] = d[x][y] + 1;    // 层数 = 父亲层数 + 1
            q.push({nx, ny});
        }
    }

    for (int i = 1; i <= n; ++i) {
        for (int j = 1; j <= m; ++j) printf("%-5d", d[i][j]);    // 负号 = 左对齐,5 = 场宽
        puts("");                                                // 每行结束换行
    }
    return 0;
}
逐行拆解
代码片段含义与理由
const int dx[8] = {-2, -1, 1, 2, 2, 1, -1, -2};方向数组:把 8 种走法写成两张常量表,循环里只做一次加法。手写 8 个 if 不仅长,还极容易漏一个方向——症状是「某些格子莫名其妙变成 −1」。
memset(d, -1, sizeof d);按字节填 0xFF,32 位 int 的 0xFF 正好是 −1。必须在 BFS 之前:它同时是判重标记,晚一步初始化就等于没初始化。
d[sx][sy] = 0;起点 0 步。写在 push 之前,保证「在队列里」的点一定已经有确定的 d 值。
auto [x, y] = q.front(); q.pop();先取值再出队。反过来写(先 pop 再 front)拿到的是下一个格子,答案整体错乱,而且这类错很难靠读代码看出来。
if (nx < 1 || nx > n || ny < 1 || ny > m) continue;棋盘下标 1 起,边界必须是 1 与 n(而不是 0 与 n−1)。写成 0 起会让第一行第一列永远出界,输出一大片 −1。
if (d[nx][ny] != -1) continue;BFS 的灵魂。第一次访问即最优,后来的路只会更长;不跳过就会反复入队,队列大小指数级膨胀 → TLE。
d[nx][ny] = d[x][y] + 1;距离在入队前写好。放在出队时再算也能过,但那样每个点要额外存层数,判重和赋值就成了两处逻辑,容易不同步。
printf("%-5d", d[i][j]);负号是左对齐,5 是场宽。本题现在也接受空格分隔,但场宽写法最稳,且行内对齐一眼能看出矩阵形状。
puts("");每行末尾换行。漏掉它,整个 n×m 矩阵会挤成一行,直接 WA。
看到这些话第一反应复杂度
最少几步 / 最短时间,每步代价相同BFS,距离数组兼作 visitedO(状态数)
最大值最小 / 最小值最大二分答案 + checkO(log 值域 × check)
每次取当前最小 / 最大priority_queue(注意小根堆三参数)O(n log n)
括号 / 后缀式 / 出栈顺序stack 模拟O(n)
按时间滑动的区间queue 存元素 + cnt 计数,队头过期就弹出O(总元素数)
这一章最容易踩的 7 个坑(都带症状)

BFS 把「标记已访问」写在出队时而不是入队时:同一个点被多个父亲反复推进队列。症状是小数据全对、大数据 TLE 或 MLE,队列长度能到状态数的几倍。

距离数组初值用 0 而不是 −1:全局数组默认就是 0,「到不了」的格子被当成「0 步到达」。症状是输出一片 0,样例第一眼就能看出来。

方向数组与棋盘下标起点不匹配(0 起 vs 1 起):症状是第一行或第一列全是 −1,而且小样例往往只差一两个格子,极易被忽略。

二分求最大值时 mid 用了下取整 (l + r) / 2:l 与 r 相邻且 check 为真时 l 不动。症状是死循环 TLE,最后几个点全挂。

check 里累加用 int:砍树 n = 10⁶、树高 4×10⁵,总和到 4×10¹¹,溢出成负数后 check 永远为真。症状是输出二分上界而不是答案。

二分的上界开小了:H 只开到 10⁵,比它高的答案全被截断。症状是WA 但样例照过,因为样例的树矮——上界一定要按题目给的最大值开。

priority_queue<int> 当小根堆用:取出的永远是大值。症状是合并果子样例就是错答案(1 2 9 输出 23 而不是 15),因为先合并了 9 和 2。

自己动手复现

关掉页面,把上面 P1443 的完整代码手打一遍并提交。

改一改①:只保留 dx/dy 的前 4 组(这 4 组都只往右跳),跑 3 3 1 1,数一数输出里有多少个 −1——方向少一半,可达区域立刻被切掉一大块。

改一改②:把 if (d[nx][ny] != -1) continue; 删掉,用 400×400 的数据跑,观察队列长度飞涨、程序迟迟不结束(判重是 BFS 复杂度的唯一保证)。

改一改③:把 memset(d, -1, sizeof d) 改成 memset(d, 0, sizeof d),再跑一次 3 3 1 1:输出变成整片 0。因为 −1 同时是答案和判重标记,初值一改,BFS 一步都走不出去。

题编号主要考什么最容易错在哪
AP1141BFS 连通块 + 预处理答案普及/提高-
BP1873二分答案:找最后一个可行值普及/提高-
CP1182二分答案:找第一个可行值下界取 1 而不是 max(aᵢ)
DP1090小根堆的反复弹压普及/提高-
EP2058queue 维护 24 小时滑动窗口边界写成严格小于;计数加减顺序写反
这次训练怎么用 95 分钟

0~10 分钟:只读题,不写代码。按「能立刻写」→「有思路」→「没思路」给 5 题排个序。

10~30 分钟:A(01迷宫)。重点不是 BFS 模板,而是「每个连通块只算一次」那句优化。

30~55 分钟:B(砍树)。二分答案的第一个方向:找最后一个可行值,mid 要上取整。

55~75 分钟:C(数列分段)。第二个方向:找第一个可行值,下界必须取 max(aᵢ)。

75~85 分钟:D(合并果子)。把小根堆的三个模板参数写熟,循环条件写 size() > 1。

85~95 分钟:E(海港)。只写队列 + 计数数组,不写复杂结构;最后核对边界与计数顺序。

提交前的 30 秒检查清单

BFS:距离数组初值 −1、起点 0、入队即标记,三件事都做了吗?

二分:l、r 的初值真的把答案包住了吗?mid 的取整方向与收缩方向配套吗?

check 里的累加变量是 long long 吗?最大值估一遍:个数 × 单值。

小根堆是不是写全了 <int, vector<int>, greater<int>>?

多组数据 / 多组询问:全局数组、栈、队列都清空了吗?

输出:行末有没有多余空格?每组询问是否各自换行?

本次训练 · 共 5 题

做题方式

限时 100 分钟。每题先自己写 20~25 分钟;写不出来先看提示,再给自己 10 分钟;还不行才点开思路。每题都独立限时,不要一题耗死。

大题 挑战题 1 · 01迷宫
洛谷 P1141普及/提高-去洛谷做这题 ↗

有一个 n × n 的迷宫,每格是 0 或 1。站在 0 上只能走到相邻 4 格中的 1,站在 1 上只能走到相邻 4 格中的 0。

给出 m 个询问 (i, j),每次求从这一格出发能走到的格子数(包含自身)。

输入格式:第一行 n、m(1 ≤ n ≤ 1000,1 ≤ m ≤ 10⁵);接下来 n 行每行 n 个 0/1 字符、字符间没有空格;再接下来 m 行,每行两个整数 i、j。

输出格式:m 行,每行一个整数,表示对应询问的答案。

引导性提示(先只看这里)

照题面直译:每个询问从 (i,j) 做一次 BFS。先估一下最坏要做多少次、每次多大。

如果两个格子互相可达,它们的答案必然相同。想一想「互相可达」把整张棋盘分成了什么。

既然同块同答案,能不能每块只算一次,之后每个询问只查表?

想看思路了?点开完整思路

把格子看成点,相邻且数字不同就连一条边。「数字不同」是对称的,所以这是一张无向图。

询问「从 (i,j) 能走到多少格」等价于求 (i,j) 所在连通块的大小。

同一连通块里任意两点可达集合相同,块内一点也走不到块外,所以块内所有格子的答案都等于块大小。

于是每个块只 BFS 一次:数出块大小,再把大小写回块内所有格子。之后每个询问 O(1) 查表。

实现上让 ans 数组兼作访问标记:0 = 没处理,−1 = 已入队、大小待定,正数 = 块大小。

复杂度 O(n² + m):每格只入队一次,询问只查表。若每个询问单独 BFS,最坏 10⁵ × 10⁶ = 10¹¹,必 TLE。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1005;
int n, m;
char g[MAXN][MAXN];            // g[i][j]:'0' 或 '1',行列从 1 开始
int ans[MAXN][MAXN];           // 0 = 未处理,-1 = 已入队待定,正数 = 块大小
int cells[MAXN * MAXN];        // 当前块内的格子,压成 i * MAXN + j
int dx[4] = {1, -1, 0, 0};
int dy[4] = {0, 0, 1, -1};

// 把 (sx, sy) 所在的整个连通块算完,并把块大小写回块内每一格
void fill_block(int sx, int sy) {
    queue<pair<int,int>> q;
    q.push({sx, sy});
    ans[sx][sy] = -1;          // 入队就标记,不能等出队

    int cnt = 0;
    while (!q.empty()) {
        auto [x, y] = q.front(); q.pop();
        cells[cnt++] = x * MAXN + y;       // 记下块内格子,等会儿统一回填
        for (int d = 0; d < 4; ++d) {
            int nx = x + dx[d], ny = y + dy[d];
            if (nx < 1 || nx > n || ny < 1 || ny > n) continue;
            if (ans[nx][ny] != 0) continue;            // 已属于某个块
            if (g[nx][ny] == g[x][y]) continue;        // 只能走到数字不同的格
            ans[nx][ny] = -1;
            q.push({nx, ny});
        }
    }
    for (int k = 0; k < cnt; ++k)                  // 同块同答案
        ans[cells[k] / MAXN][cells[k] % MAXN] = cnt;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> m;
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= n; ++j) cin >> g[i][j];   // 读 char 会自动跳过换行

    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= n; ++j)
            if (ans[i][j] == 0) fill_block(i, j);      // 每块只算一次

    while (m--) {
        int i, j;
        cin >> i >> j;
        cout << ans[i][j] << "\n";                     // 询问退化成查表
    }
    return 0;
}
关键片段拆解
片段作用
int ans[MAXN][MAXN];同时当答案表和访问标记。省掉一个 vis 数组,也省掉一次「两块表不同步」的 bug。
ans[sx][sy] = -1;入队立刻标记。等出队再标记的话,同一块里的格子会被多个父亲重复压入,块大小就数大了。
cells[cnt++] = x * MAXN + y;BFS 时还不知道块最终多大,只能先记下来;把坐标压成一个 int,一个数组就够。
if (g[nx][ny] == g[x][y]) continue;这就是「边」的定义:只能走到数字不同的相邻格。写反成 != 时 continue,整题崩。
ans[cells[k] / MAXN][cells[k] % MAXN] = cnt;回填:同一块所有格子的答案都等于块大小。
while (m--)询问阶段只查表,O(1)。这正是把 10¹¹ 降到 10⁶ 的地方。
这题最容易错的地方
  • 对每个询问单独 BFS:m = 10⁵、n = 1000 时约 10¹¹ 次操作 → TLE。
  • 入队时不标记、只在出队时标记 → 同一格被重复压入,块大小数大 → WA。
  • cells 数组按 10⁵ 开:n = 1000 时一整块能有 10⁶ 个格子,越界 → RE。
  • 询问下标 1 起、数组开成 0 起,混用后答案整体错位一行一列。
大题 挑战题 2 · 砍树
洛谷 P1873普及/提高-去洛谷做这题 ↗

有 N 棵树,第 i 棵高 hᵢ。设定锯片高度 H 后,每棵高于 H 的树被锯掉 hᵢ − H 米,得到木材总量 Σ max(0, hᵢ − H)。

求最大的整数 H,使木材总量至少为 M。

输入格式:第一行两个整数 N、M(1 ≤ N ≤ 10⁶,1 ≤ M ≤ 2×10⁹);第二行 N 个整数表示每棵树的高度(≤ 4×10⁵),保证所有树高之和 > M。

输出格式:一个整数,表示锯片能升到的最大高度 H。

引导性提示(先只看这里)

枚举 H 从 0 到最高的树,每次扫一遍 N 棵树:这样是多少次操作?N = 10⁶ 时能过吗?

H 增大时木材总量怎么变?把这句话翻译成一个单调的判定函数。

二分的上界不用猜——最高的那棵树就是自然上界。

想看思路了?点开完整思路

check(H):Σ max(0, hᵢ − H) ≥ M。H 越大能锯到的越少,check 单调(真…真假…假)。

要的是最后一个 check 为真的 H,于是用「上取整 mid + 可行则 l = mid」那一套。

上界取 mx = max(hᵢ)(再高就一棵也锯不到),下界 0(题目保证总和 > M,H = 0 一定可行)。

check 里的 sum 必须 long long:10⁶ 棵 4×10⁵ 的树,总量 4×10¹¹,int 直接溢出。

复杂度 O(N log mx) ≈ 10⁶ × 19 ≈ 2×10⁷ 次运算,1 秒内稳过。

边界提醒:答案可能是 0(树都很矮),也可能是 mx,所以 l、r 的初值必须覆盖这两端。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1000005;
int n, h[MAXN];
long long need;              // M 到 2×10⁹,用 int 比较会溢出

// 锯片高度为 H 时,得到的木材是否够 M 米
bool check(int H) {
    long long sum = 0;
    for (int i = 1; i <= n; ++i) {
        if (h[i] > H) {
            sum += h[i] - H;
            if (sum >= need) return true;   // 够了就提前退出
        }
    }
    return false;
}

int main() {
    scanf("%d%lld", &n, &need);

    int mx = 0;
    for (int i = 1; i <= n; ++i) {
        scanf("%d", &h[i]);
        if (h[i] > mx) mx = h[i];
    }

    int l = 0, r = mx;                      // 答案一定在 [0, 最高的树] 内
    while (l < r) {
        int mid = l + (r - l + 1) / 2;      // 上取整:求最大值必须这么写
        if (check(mid)) l = mid;            // mid 可行,答案不小于 mid
        else            r = mid - 1;        // mid 不可行,答案更小
    }
    printf("%d\n", l);
    return 0;
}
关键片段拆解
片段作用
bool check(int H)把「H 行不行」封装成函数,主函数的二分只剩四行,也方便单独手测 check。
long long sum = 0;木材总量最大 4×10¹¹。用 int 溢出后变负,check 永远为真,输出上界而不是答案。
if (h[i] > H) sum += h[i] - H;严格高于 H 才锯。恰好等于 H 的树贡献 0,写 >= 结果一样,但按题意写更好读。
if (sum >= need) return true;提前退出。木材够了就没必要继续扫后半段,n = 10⁶ 时这一句能省掉不少循环。
int mid = l + (r - l + 1) / 2;上取整。若用下取整,l 与 r 相邻且 check 为真时 l 永远不动 → 死循环。
if (check(mid)) l = mid; else r = mid - 1;可行就往上走,不可行就退一格。循环结束时 l == r,就是最后一个可行值。
这题最容易错的地方
  • 上界写成 10⁵ 或 n:树高可以到 4×10⁵,比上界高的答案被截断 → WA。
  • check 里 sum 用 int:总量 4×10¹¹ 溢出 → check 恒真 → WA(输出上界)。
  • mid 用下取整又配 l = mid:l = r − 1 时死循环 → TLE。
  • scanf 用 %d 读 M:M 到 2×10⁹ 时溢出 → 比较全错。
大题 挑战题 3 · 数列分段 Section II
洛谷 P1182普及/提高-去洛谷做这题 ↗

给定一个长度为 N 的数列 A,把它分成 M 段连续的子段,要求每段和的最大值尽量小,输出这个最小值。

输入格式:第一行两个正整数 N、M(1 ≤ N ≤ 10⁵,M ≤ N);第二行 N 个空格隔开的非负整数 Aᵢ(Aᵢ < 10⁸)。

输出格式:一个整数,即每段和最大值的最小可能值(答案不超过 10⁹)。

引导性提示(先只看这里)

「最大值最小」是二分答案的固定信号。先回答一个问题:如果限定每段和 ≤ X,最少要几段?

段数与 X 之间是单调的:X 越大,需要的段数越少。这就是能二分的理由。

二分的下界不能随便取 1。想一想:一个数本身就超过 X 时,你的 check 会怎么数段?

想看思路了?点开完整思路

check(X):从左往右贪心累加,能装就装,装不下就开新段,数出最少段数 cnt。

cnt ≤ M 说明 X 可行——段数不够可以把某段再切开,段数只会更多,而上限仍是 X。

段数随 X 单调不增,所以「最小可行 X」可以二分,用「下取整 mid + 可行则 r = mid」那一套。

下界必须取 max(Aᵢ):否则 X 比某个数还小时,check 会把「单个元素已超限」的段也当成一段,二分会输出一个装不下的假答案。例:A = 10 1 1、M = 3,正确答案 10,下界取 1 会输出 2。

上界取总和 ΣAᵢ(只分一段),10⁵ × 10⁸ = 10¹³,必须 long long。

复杂度 O(N log ΣA) ≈ 10⁵ × 44,稳过。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int n, m, a[MAXN];

// 每段和不超过 X 时,最少要分成几段
bool check(long long X) {
    int cnt = 1;                 // 至少一段
    long long sum = 0;           // 当前段的和
    for (int i = 1; i <= n; ++i) {
        if (sum + a[i] <= X) {
            sum += a[i];         // 能装就装:段数越少越容易满足 cnt <= m
        } else {
            ++cnt;               // 装不下,开新段
            sum = a[i];
        }
    }
    return cnt <= m;
}

int main() {
    scanf("%d%d", &n, &m);

    long long l = 0, r = 0;
    for (int i = 1; i <= n; ++i) {
        scanf("%d", &a[i]);
        if (a[i] > l) l = a[i];  // 下界:最大的那个数
        r += a[i];               // 上界:全部装进一段
    }

    while (l < r) {
        long long mid = l + (r - l) / 2;
        if (check(mid)) r = mid;        // X 够大,试试更小
        else            l = mid + 1;    // X 太小,段数超了
    }
    printf("%lld\n", l);
    return 0;
}
关键片段拆解
片段作用
int cnt = 1; long long sum = 0;段数从 1 开始(哪怕只有一个数也是一段)。把 cnt 写成 0 会让答案差 1。
if (sum + a[i] <= X)能塞就塞。贪心为什么对:段越少越容易满足 cnt ≤ m,而「尽量往右扩」不会让后面更难分。
else { ++cnt; sum = a[i]; }装不下就开新段,并把当前数放进去。别写 sum = 0 再 += a[i],多一步就多一次写错的机会。
return cnt <= m;段数不超过 M 就算可行——多出来的段可以把某段切开,上限仍然是 X。
if (a[i] > l) l = a[i];关键一行。下界取 1 会在 X < max(aᵢ) 时把超限的段也数进去,二分会给出假答案。
if (check(mid)) r = mid; else l = mid + 1;求最小值:可行就收缩右边界。配下取整的 mid,l = r 时收敛,不会死循环。
这题最容易错的地方
  • 下界写成 0 或 1:X < max(aᵢ) 时 check 仍会算出一个段数,于是输出比 max(aᵢ) 还小的假答案 → WA。
  • 段数和 sum 用 int:总和可到 10¹³,直接溢出 → 二分区间错乱。
  • cnt 初值写 0:所有数装得下时正确答案是 1,你会输出 0。
  • 上界只写 max(aᵢ):那只够分一段,M ≥ 2 时真实答案超过上界,二分会卡在 max(aᵢ) → WA。
大题 挑战题 4 · 合并果子
洛谷 P1090普及/提高-去洛谷做这题 ↗

有 n 堆果子,第 i 堆 aᵢ 个。每次可以把两堆合并成一堆,消耗的体力等于两堆数量之和,合并 n − 1 次后只剩一堆。

求总共消耗的最小体力。

输入格式:第一行一个整数 n(1 ≤ n ≤ 10⁴);第二行 n 个整数 aᵢ(1 ≤ aᵢ ≤ 2×10⁴)。数据保证答案小于 2³¹。

输出格式:一个整数,最小的体力耗费值。

引导性提示(先只看这里)

先手算 n = 3、1 2 9:先合并 1 和 2 得 3、再合并 3 和 9 得 12,总 15。换成先合并 2 和 9 呢?

每一堆被合并后还会被后面的合并反复计入。哪一堆应该合并得最早?

每次都要取「当前最小的两堆」,n = 10⁴ 时用哪个容器最快?

想看思路了?点开完整思路

贪心:每次取当前最小的两堆合并。

论证:把合并过程画成一棵二叉树,叶子是原堆。某堆被计入答案的次数等于它在树中的深度,深度大的位置应该放小重量——所以最小的两堆适合做最深的兄弟。

交换论证:若最深的两个兄弟不是当前最小的两堆,把最小的两堆换到那两个位置上,总和不会变大。所以第一步合并最小的两堆一定不亏,合并出的新堆继续参与同样的问题。

实现:小根堆,重复 n − 1 次「弹两个、累加、压回」。每次两个 pop 一个 push,共 O(n log n)。

n = 1 时不进入循环,答案 0——循环条件写 size() > 1 自动处理。

答案上限是「总和 × 合并次数」量级,用 long long 一劳永逸(题目保证 < 2³¹,但别省这个类型)。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    scanf("%d", &n);

    // 小根堆:top() 是当前最小的那一堆
    priority_queue<int, vector<int>, greater<int>> pq;
    for (int i = 0; i < n; ++i) {
        int x;
        scanf("%d", &x);
        pq.push(x);
    }

    long long ans = 0;
    while (pq.size() > 1) {
        int a = pq.top(); pq.pop();      // 最小
        int b = pq.top(); pq.pop();      // 次小
        ans += a + b;                    // 本次合并的代价
        pq.push(a + b);                  // 新堆放回去,继续参与
    }
    printf("%lld\n", ans);
    return 0;
}
关键片段拆解
片段作用
priority_queue<int, vector<int>, greater<int>>三个参数缺一不可:元素类型、底层容器、比较方式。greater 表示小的优先。
int a = pq.top(); pq.pop();取值和出堆是两步。只取值不出堆,下一轮还会拿到同一堆。
ans += a + b;合并代价累加。先合并的堆会被后面的合并反复计入,所以小的要早合并。
pq.push(a + b);新堆必须回到堆里,否则下一轮没有堆可取,堆空后 top() 直接 RE。
while (pq.size() > 1)n 堆并成一堆要 n − 1 次;写成 > 0 会在只剩一堆时再想弹两个,越界。
这题最容易错的地方
  • 用 priority_queue<int>(大根堆):每次合并最大的两堆,1 2 9 会输出 23 → WA。
  • 忘记 pq.push(a + b):第二轮堆已空,top() 对空堆 → RE。
  • 只 pop 一次却取两次 top:把同一堆算了两次,答案偏小 → WA。
  • 用 vector + 每次合并后重排:10⁴ 次合并 × 每次 10⁴ 量级,总比较次数约 10⁹ → TLE。
大题 挑战题 5 · 海港
洛谷 P2058普及/提高-去洛谷做这题 ↗

有 n 艘船依次到港,第 i 艘船在 tᵢ 秒到达,船上有 kᵢ 名乘客,国籍分别是 xᵢ,₁ … xᵢ,kᵢ。tᵢ 递增。

对每艘船,统计「到它到达为止的 24 小时(86400 秒)内」到港的所有乘客来自多少个不同的国家,即统计满足 tᵢ − 86400 < tⱼ ≤ tᵢ 的船上所有乘客的不同国籍数。

输入格式:第一行 n(1 ≤ n ≤ 10⁵,Σkᵢ ≤ 3×10⁵);接下来 n 行,每行先是 tᵢ、kᵢ,再是 kᵢ 个国籍编号(≤ 10⁵)。

输出格式:n 行,每行一个整数,表示第 i 艘船到达后的统计结果。

引导性提示(先只看这里)

窗口随时间往后滑,过期的人要从统计里去掉。哪种容器适合「先进先出、按时间排队」?

如果记录「每个国家在窗口里有几个人」,一个国家从 0 变 1 时答案加 1,从 1 变 0 时答案减 1。

队头什么时候该弹?注意题面是 tᵢ − 86400 < tⱼ,边界的等号要小心。

想看思路了?点开完整思路

把所有乘客按到达顺序排进一个队列,每个元素存 (到达时刻, 国籍)。船与人的层级被压平。

维护 cnt[c] = 窗口内国籍 c 的人数,以及 ans = 窗口内不同国籍数。

一艘船到达:先把它的 kᵢ 个乘客逐个入队并更新 cnt(从 0 到 1 时 ans++)。

再把队头所有「到达时刻 ≤ tᵢ − 86400」的乘客弹出,弹出时 cnt−−,减到 0 时 ans−−。

为什么用 queue:时间递增,过期的永远在队头,且不会有人「重新进来」。每个乘客进队一次、出队一次。

复杂度 O(Σkᵢ) = 3×10⁵ 次操作。若每艘船都把窗口内的乘客重扫一遍,是 O(n·Σkᵢ),必 TLE。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int cnt[MAXN];                 // cnt[c]:窗口内国籍 c 的乘客数

int main() {
    int n;
    scanf("%d", &n);

    queue<pair<int,int>> q;    // (到达时刻, 国籍),按时间排队
    int ans = 0;               // 窗口内不同国籍数

    for (int i = 0; i < n; ++i) {
        int t, k;
        scanf("%d%d", &t, &k);
        for (int j = 0; j < k; ++j) {
            int c;
            scanf("%d", &c);
            q.push({t, c});            // 同一艘船的人分享同一个到达时刻
            if (cnt[c] == 0) ++ans;    // 这个国家在窗口里从无到有
            ++cnt[c];
        }
        // 队伍左端:超过 24 小时的人出队
        while (!q.empty() && q.front().first <= t - 86400) {
            int c = q.front().second;
            q.pop();
            --cnt[c];
            if (cnt[c] == 0) --ans;    // 这个国家在窗口里彻底消失
        }
        printf("%d\n", ans);
    }
    return 0;
}
关键片段拆解
片段作用
queue<pair<int,int>> q;队列里放「每个乘客」,不是「每艘船」:出队时按人扣减,计数才对得上。
if (cnt[c] == 0) ++ans; ++cnt[c];先判「从无到有」再加人数。顺序反了(先 ++cnt 再判 0)就永远不成立,ans 一直是 0。
while (!q.empty() && q.front().first <= t - 86400)窗口左边界:t − 86400。用 ≤ 正好把「恰好 86400 秒前」的人排除,符合题面的严格小于。
--cnt[c]; if (cnt[c] == 0) --ans;出队一个人:人数减一;减到 0 说明这个国家整个离开窗口,国家数减一。
printf("%d\n", ans);每艘船处理完立刻输出当前窗口答案——题目要的是每艘船一个结果,不是最后一次性输出。
这题最容易错的地方
  • 计数顺序写反(先 ++cnt 再判是否为 0)→ ans 永远不加 → 全输出 0。
  • 弹出条件写成 < t - 86400:恰好 86400 秒前到的人被留在窗口里 → 边界数据 WA。
  • 用 bool 数组标记「这个国家在窗口里出现过」:同一国家有多人时,走掉一个就以为整个国家消失了 → 答案偏小。必须用计数。
  • cnt 只开到 1000:国籍编号最大 10⁵ → 越界,表现为乱答案或 RE。
做完之后自问
  • 不看资料能写出网格 BFS 的完整模板:方向数组、距离数组、入队即标记三件套。
  • 能说清「BFS 第一次访问即最短」的理由,并举一个 DFS 求最短路会错的例子。
  • 能手写二分答案的两套收缩方式,并说清哪一套必须配上取整的 mid。
  • 能解释 check 单调性是什么意思,以及为什么「段数 ≤ m」等价于「每段和 ≤ X 可行」。
  • 能说出 priority_queue<int, vector<int>, greater<int>> 三个参数各是什么。
  • 能说清 P2058 里为什么弹出条件是 q.front().first <= t - 86400 而不是 <。
  • 能说出 0-1 BFS 为什么必须用 deque:权 0 的边塞队头、权 1 的边塞队尾。
延伸阅读
  • 《算法竞赛入门笔记》:BFS、二分查找与 STL 容器相关章节
  • 《算法竞赛进阶指南》第 0x25 节:广度优先搜索;第 0x04 节:二分
  • 《算法竞赛试炼场:洛谷300题精析》:P1443、P1141、P1873、P1182、P1090、P2058 的题解
  • 《算法竞赛黄金法则》:先证明单调性,再动手二分
  • 想再练一道纯栈模拟,加做 P4387 验证栈序列 / P1449 后缀表达式(不计入本次限时)
31 动态规划入门:线性 DP 四步法普及/提高-

31 · 动态规划入门:线性 DP 四步法

把 DP 拆成四个必须回答的问题:状态、转移、边界、答案。用数字三角形和 LIS 建立“多阶段推进”与“以 i 结尾”两种模型,并第一次用滚动数组把二维表压成一维,为背包的倒序埋下伏笔。
普及/提高-预计 70 分钟DP 四步法状态设计线性 DP
本节出现的新函数 / 新语法(先看懂再用)

DP 不是一种算法,是一种把“重复计算”换成“查表”的写法。数字三角形 n = 1000 层,从顶到底的路径有 2⁹⁹⁹ 条,暴搜到宇宙热寂也跑不完;但互不相同的子问题只有 n(n+1)/2 ≈ 5×10⁵ 个。同一个子问题被重复算了天文数字次,DP 的全部工作就是让每个子问题只算一次、把结果存进数组。

写 DP 之前先在草稿纸上回答四个问题:状态是什么、怎么转移、边界在哪、答案是哪个格子。四个问题答完,代码不到 10 行;答不出来,抄模板必错。
  • ① 状态:用 dp[...] 的下标把子问题编码出来,数组的值就是这个子问题的答案。判断状态定得好不好,只看一条——能不能用规模更小的状态把当前状态算出来。
  • ② 转移:dp[当前] = 若干 dp[更小规模] 的 max / min / 和。本质是分类讨论“最后一步从哪来”,把所有来源列全,一个不漏。
  • ③ 边界:最小的、不需要转移就能直接写出来的状态,通常是 dp[0] 或第一行。边界写错,整张表都是垃圾。
  • ④ 答案:最终输出哪个格子?dp[n]?dp[n][n]?还是 max(dp[n][1..n])?至少三分之一的 WA 出在这一步。

无后效性是 DP 能不能用的唯一门槛:一个状态一旦确定,之后怎么走与“怎么走到这里”无关。数字三角形里 dp[i][j] 只记“走到 (i,j) 的最大和”,不需要知道具体走了哪条路——满足。如果题目改成“路径上不许出现两个相邻的相同数字”,dp[i][j] 立刻不够用,因为还得记住上一格是谁。

判断方法是一句话:要算出下一个状态,我还需要知道历史里的什么?答案是“什么也不需要”,直接 DP;答案是“需要知道某段历史”,就在下标里加一维把那点历史记下来。下一节背包多出来的那一维(选没选过)、后面状压 DP 多出来的那一维(哪些点走过),都是这个思路。

新函数拆解 · dp 数组:状态定义模板
int dp[MAXN][MAXN]; // dp[阶段][位置] = 这个子问题的最优值
组成含义 / 格式要求
dp数组名。规模大(10⁶ 以上)或二维的,一律开在 main 外面,走静态区。
第一维:阶段处理到第几行、前几个元素、走了几步、容量用了多少。挑一个只会增大的量当阶段,遍历顺序就天然合法。
第二维:位置当前停在哪一列、下标多少、还剩多少容量。转移时在这一维上左右移动。
值该子问题的答案:最大和 / 最小代价 / 方案数。值的类型决定会不会溢出,最值题一般 int 够,求和与计数先估上界。
初值最省事的做法:整表填 0 或整表填 −INF,再单独写边界。绝不能让“没算过”的格子参与 max。
注释每写一个 dp 数组,先写一行注释把含义写全。注释写不出来,说明状态还没想好,这时候敲代码只是在延长错误。
最小可运行示例
示例
// 反例:状态里没有“以 i 结尾”这个信息
int dp[1005];   // dp[i]:前 i 个数的 LIS 长度(错)
// a = {5, 1, 2, 3}
// 前 2 个数 {5,1} 的 LIS 是 1,可 a[2]=1 后面还能接上 2、3
// dp[i] 与 dp[i-1] 之间根本没有可用的转移式:a[i] 可能不在最优解里

// 正例:把“最后一个元素是谁”钉进下标
int f[1005];    // f[i]:以 a[i] 结尾的 LIS 长度(对)
// f[i] = 1 + max{ f[j] : j < i 且 a[j] < a[i] }
注意

状态里必须装下“转移需要的那点信息”。LIS 需要知道“能接在谁后面”,所以下标里必须有 i。

同一道题可以有好几种状态定义,选那个让转移式最短的(本节末的三大模型就是三种常见选法)。

新函数拆解 · fill / memset:把 dp 表刷成同一种初值
fill(dp, dp + n + 1, NEG); memset(dp, 0, sizeof dp);
组成含义 / 格式要求
fill(begin, end, v)把左闭右开区间 [begin, end) 逐元素赋成 v。能填任意 int / long long 值,最通用。
memset(p, c, n)把从 p 开始的 n 个字节都填成 c。只有“每个字节相同”的值才有效:0、−1、0x3f。
memset(dp, 0x3f, sizeof dp)每字节 0x3f,拼成 int 就是 0x3f3f3f3f = 1061109567,常当正无穷。
sizeof dp只有当 dp 还是数组时才是整张表的字节数。dp 当参数传进函数后退化成指针,sizeof 只剩 8。
为什么不用 INT_MAXINT_MAX 加上任何正数都会溢出(未定义行为)。0x3f3f3f3f 加几百万也安全。
最小可运行示例
示例
const int INF =  0x3f3f3f3f;      //  1061109567
const int NEG = -0x3f3f3f3f;      // -1061109567

int dp[1005];

memset(dp, 0, sizeof dp);          // 全 0:求最大值且不要求“恰好”时用
fill(dp, dp + n + 1, NEG);         // 全 −INF:求最大值且要求“恰好凑出”时用
fill(dp, dp + n + 1, 0);           // 只清前 n+1 个:多测时用它,省掉清整张 10⁶ 的表
注意

多测题每个测试点都要重新刷表,漏刷一次后面全错,且错得莫名其妙。

long long 数组不要用 memset(dp, 0x3f, ...):得到的 0x3f3f3f3f3f3f3f3f ≈ 4.6×10¹⁸ 本身能用,但两个这样的数相加就溢出;老实用 fill 填 1e18 或 4e18。

例题 · 数字三角形洛谷 P1216普及-去洛谷做这题 ↗

给出一个 n 层的数字三角形,第 i 层有 i 个整数。从顶层唯一的那个数出发,每一步可以走到下一层的正下方或右下方(第 i 层第 j 列可以走到第 i+1 层第 j 列或第 j+1 列)。求一条从顶到底的路径,使路径上所有数字之和最大,输出这个最大和。

输入:第一行一个整数 n(1 ≤ n ≤ 1000);接下来 n 行,第 i 行 i 个非负整数(每个不超过 100)。

输出:一行一个整数,即最大路径和。

输入: 5 7 3 8 8 1 0 2 7 4 4 4 5 2 6 5 输出: 30
例题怎么想:先在草稿纸上把四步填完

暴力:从顶走到底,每层 2 个选择,总共 2ⁿ⁻¹ 条路径。n = 1000 时是 2⁹⁹⁹,死了。

① 状态:dp[i][j] = 从顶走到第 i 层第 j 列的所有路径中,能取得的最大路径和。

② 转移:走到 (i,j) 的最后一步只可能来自正上方 (i−1,j) 或左上方 (i−1,j−1),两步各自取最优再比一次:dp[i][j] = a[i][j] + max(dp[i-1][j-1], dp[i-1][j])。

③ 边界:dp[1][1] = a[1][1]。另外第 i 层只有 1..i 列存在:j = 1 时没有左上格子,j = i 时没有正上格子。这两类情况必须单独写,否则会读到根本不存在的格子。

④ 答案:底行的最大值 max(dp[n][1..n])。不是 dp[n][n]——走到最右下角不一定是最大和。

无后效性:dp[i][j] 只引用第 i−1 行。第 i 行以后怎么走完全不影响它,所以按行推进一定正确。

复杂度:状态数 n(n+1)/2 ≈ 5×10⁵,每个状态两次比较,O(n²)。n = 1000 时约 10⁶ 次基本操作,1 毫秒量级。

完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1005;         // n ≤ 1000,开 1005 让下标 1000 安全
int a[MAXN][MAXN];             // 原始数字三角形
int dp[MAXN][MAXN];            // dp[i][j]:从顶走到 (i,j) 的最大路径和

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= i; ++j)
            cin >> a[i][j];

    // ③ 边界:起点没有“上面”,它的路径和就是它自己
    dp[1][1] = a[1][1];

    // ② 转移:按行推进。第 i 行只依赖第 i-1 行,所以“按行的顺序”天然就是合法的计算顺序
    for (int i = 2; i <= n; ++i)
        for (int j = 1; j <= i; ++j) {
            if (j == 1)                       // 左边缘:没有左上格子,只能从正上方来
                dp[i][j] = a[i][j] + dp[i - 1][j];
            else if (j == i)                  // 右边缘:没有正上格子,只能从左上方来
                dp[i][j] = a[i][j] + dp[i - 1][j - 1];
            else                              // 中间:两个前驱取优
                dp[i][j] = a[i][j] + max(dp[i - 1][j - 1], dp[i - 1][j]);
        }

    // ④ 答案:底行最大值,不是 dp[n][n]
    int ans = dp[n][1];
    for (int j = 2; j <= n; ++j) ans = max(ans, dp[n][j]);

    cout << ans << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
const int MAXN = 1005;n 最大 1000,数组要开到 1000 以上。开成 1000 时 a[1000] 越界,本地可能不报错、评测机 RE。
int a[MAXN][MAXN]; int dp[MAXN][MAXN];两个 1005×1005 的 int 约 8 MB,必须放全局。写进 main 里是栈内存,通常只有 1~8 MB,直接段错误。
dp[1][1] = a[1][1];边界,整题的地基。不写这行,dp[1][1] 是 0,后面全部错,而且答案看起来还挺“合理”,最难查。
for (int i = 2; i <= n; ++i)阶段推进。i 从 2 开始,因为第 1 行已被边界处理完。
j == 1 → dp[i-1][j]分类讨论第一支:最左列不存在左上格子 a[i-1][0]。如果统一写 max(dp[i-1][j-1], dp[i-1][j]),就会用到 dp[*][0] 这个不存在的格子。
j == i → dp[i-1][j-1]分类讨论第二支:最右列不存在正上格子 a[i-1][i]。dp[i-1][i] 是 0(没赋过值),一旦把它放进 max,答案会被悄悄改小。
max(dp[i-1][j-1], dp[i-1][j])这就是“决策”:两条来路,选收益大的那条。所有 DP 的转移式都是这个形状——把可达的来源列全,取最优。
int ans = dp[n][1];答案从底行第一个格子起步,再往右扫。用 0 起步在本题能过(数字非负),但换一题允许负数时就是错的,养成用合法格子起步的习惯。
cout << ans << "\n";只输出一个整数加换行。
新函数拆解 · 滚动数组:把二维 dp 压成一维
dp[j] = a[j] + max(dp[j-1], dp[j]); // j 从 i 递减到 1
组成含义 / 格式要求
为什么要压第 i 行只用到第 i−1 行,再往前的行永远不再被访问。二维表里 dp[0..i-2][*] 全是死数据。
压成什么dp[j] 只保留“上一行第 j 列”的结果。算第 i 行时,它表示第 i−1 行;算完后它变成第 i 行。
j 的顺序必须倒序(j 从 i 递减到 1)。倒序时 dp[j-1] 还没被这一轮改写,读到的是上一行的值,正确。
正序会怎样正序时 dp[j-1] 已经是本行的新值,相当于“从同一行左边走过来”,路径非法,答案偏大。
内存变化8 MB → 4 KB。空间换时间不一定赚,但时间换空间在这里是白赚。
最小可运行示例
示例
int a[1005], dp[1005];        // dp 只需要 1005 个 int

for (int i = 1; i <= n; ++i) {
    for (int j = 1; j <= i; ++j) cin >> a[j];   // 先把这一行读进来
    for (int j = i; j >= 1; --j)                // 再倒序更新
        dp[j] = a[j] + max(dp[j - 1], dp[j]);
}
int ans = dp[1];
for (int j = 2; j <= n; ++j) ans = max(ans, dp[j]);
注意

倒序的理由和下一节 0/1 背包完全一样:一维数组里“上一层”和“本层”共用同一块空间,只有让被依赖的格子还没被写,读到的才是旧值。

这一节先用例题体会一次,下一节会把它变成背包题的生死线。

第二个模型:以 i 结尾。问题:给定 a[1..n](n ≤ 1000),求最长严格上升子序列的长度。子序列可以不连续,但要保持原顺序。

LIS:以 i 结尾
int a[MAXN], f[MAXN];          // f[i]:以 a[i] 结尾的最长严格上升子序列长度

int ans = 1;
for (int i = 1; i <= n; ++i) {
    f[i] = 1;                                       // 边界:只有 a[i] 自己,长度 1
    for (int j = 1; j < i; ++j)                     // 枚举“紧挨在 i 前面的那个是谁”
        if (a[j] < a[i]) f[i] = max(f[i], f[j] + 1);
    ans = max(ans, f[i]);
}

为什么状态必须写成“以 i 结尾”?因为转移时需要知道“新元素能接在谁后面”,这一点只有 i 能提供。如果把状态定义成“前 i 个数的 LIS 长度”,dp[i] 和 dp[i−1] 之间就没有可用的转移式——a[i] 可能根本不在最优解里,dp[i] 甚至可能等于 dp[i−1],推不下去。

LIS 逐行拆解
代码片段含义与理由
f[i] = 1;边界。任何单个元素自成一串,长度 1。不写这句 f 全是 0,答案会变成 1。
for (int j = 1; j < i; ++j)枚举前驱。每个状态扫一遍历史,这是 O(n²) 的全部来源。
if (a[j] < a[i])“严格上升”就写 <。换成 <= 是最长不下降,换成 > 是最长下降。一个字决定答案。
f[i] = max(f[i], f[j] + 1);把 j 接到 i 前面,长度加 1;和“不接”比一次,取优。
ans = max(ans, f[i]);答案不是 f[n]。n 号元素可能很小,最优串也许在第 3 个位置就结束了。
模型状态长什么样转移从哪来典型题答案在哪
以 i 结尾dp[i],把“最后一个元素”钉进下标所有满足条件的 j < iLIS、最大子段和、合唱队形max(dp[1..n])
多阶段推进dp[i][j],一层一层往前走上一层相邻的若干格数字三角形、方格取数、过河卒底行的最值
分类讨论dp[i][0/1/2],把“当前处于哪种状态”写进下标同层不同状态之间互转股票买卖、打家劫舍、粉刷栅栏末状态的某种组合
线性 DP 的 6 种典型死法(含症状)

WA:LIS 把答案写成 f[n]。测试点里只要有一个“最后一个元素很小”的数据就挂。

WA:边界没写。dp 初值全 0,LIS 输出 1 或 0,数字三角形输出 0。症状是“样例都对、提交全 WA”。

WA:数字三角形只从 (i−1,j−1) 转移,漏掉正上方;或者忘了处理 j = 1、j = i 两个边缘,把 0 当成了合法前驱。

RE:MAXN 写成 1000 而 n = 1000,访问 a[1000] 越界;二维数组写在 main 里,1005×1005×4 B ≈ 4 MB 直接爆栈。

RE:一维滚动写成正序,虽然多数情况只错答案不崩,但只要 n 稍大、数组只开到 n,就会读到 dp[-1]。

TLE:把 O(n²) 的 LIS 交到 n = 10⁵ 的题上(例如 P1020 导弹拦截)。10¹⁰ 次比较,必挂;需要 O(n log n),本节不展开。

自己动手复现

关掉页面,从空文件把 P1216 敲一遍并提交。再输入 1 / 5,确认输出 5 而不是 0。

改一改 ①:把 max 换成 min,求最小路径和。此时 int ans = dp[n][1] 还能写成 0 吗?为什么不能?

改一改 ②:照 FN 里的滚动数组版改一遍,只留 int dp[1005],用样例数据对比两次输出是否都是 30。再把内层 j 改成从 1 到 i 的正序,看答案变成多少——那就是“同一行内横着走”的后果。

改一改 ③:把 LIS 的 a[j] < a[i] 改成 a[j] <= a[i],输入 4 / 1 2 2 3,答案从 3 变成 4。想清楚“严格”和“不下降”差在哪。

数学补给 · DP 为什么一定对,复杂度怎么估

正确性:对“状态规模”做数学归纳。① 基例:边界状态直接赋值,显然正确。② 归纳假设:所有规模小于当前的状态都已算对。③ 归纳步:当前状态引用的来源全都规模更小(数字三角形第 i 行只引用第 i−1 行),由假设它们都对,而转移式把所有可能的来源分类列尽、并在每一类里取了最优,所以当前状态也一定对。

无后效性 = 状态图是一个 DAG。把每个状态画成一个点,每条转移画成一条有向边。上面第 ③ 步要求“边只从规模小的点指向规模大的点”,这保证了图上无环。有向无环图必有拓扑序,按拓扑序递推就是合法的计算顺序——这就是“循环怎么写”的全部依据。

反过来:如果状态之间出现环(dp[i] 依赖 dp[i+1],而 dp[i+1] 又依赖 dp[i]),就没有天然的递推序,只能改用记忆化搜索,或者把环上的方程解出来。所以“能不能按某个顺序把表填完”是 DP 可解性的判据。

题目状态数单次转移总复杂度暴搜复杂度
数字三角形 n = 1000n(n+1)/2 ≈ 5×10⁵O(1)O(n²)2ⁿ⁻¹
LIS n = 1000nO(n)O(n²)2ⁿ
0/1 背包(下一节)M×T = 10⁵O(1)O(MT)2^M

记住这个乘法:总复杂度 = 状态数 × 单次转移代价。设计状态时先把两个因子估出来。n = 1000 的 O(n²) 是 10⁶,随便过;n = 10⁵ 的 O(n²) 是 10¹⁰,必挂——这时要么压状态数,要么把转移降到 O(1) 或 O(log n)。

还有一条不写在书上的经验:状态数通常控制在 10⁷ 以内。因为每个状态至少要读写一次,10⁸ 次内存访问已经是 1 秒的极限,而 DP 的循环体往往不止一次访问。

举一反三 · 想清楚这些再往下走
  1. 数字三角形如果允许负数,int ans = dp[n][1] 还能用,但写成 0 起步就错了。为什么边界和答案初值要分开考虑?
  2. 最大子段和(P1115):状态怎么设?为什么 dp[i] = max(a[i], dp[i-1] + a[i]) 就够了,答案为什么是 max(dp[i])?
  3. 把 LIS 条件里的 < 换成 <=、>、>=,分别求的是什么?如果要求“最长上升子序列的个数”,状态要加什么?
  4. 如果题目要求输出这条最大和路径本身而不只是和,需要多存什么?为什么存“从哪来”比存“整条路径”省空间?
  5. 数字三角形的滚动数组为什么必须倒序 j?这个倒序和下一节 0/1 背包的倒序,是同一个原因吗?
  6. 如果题目改成“可以从底边任意位置出发、走到顶”,状态定义和答案位置要怎么改?代码要改几行?
自测:能全部回答再进下一节
  • 能默写四步法的四步,并指出每一步对应代码里的哪一行。
  • 能解释为什么 LIS 的状态必须写成“以 i 结尾”,而“前 i 个数”不行。
  • 拿到一道新题,能判断它是否满足无后效性;不满足时,知道该往状态里加什么。
  • 能估出状态数和单次转移代价,并据此判断 O(n²) 会不会超时。
  • 能说出滚动数组能用的条件,以及为什么内层必须倒序。
挑战题 · 合唱队形
洛谷 P1091普及/提高-去洛谷做这题 ↗

n 位同学站成一排,音乐老师要请其中若干位出列,使剩下的同学排成“合唱队形”。

合唱队形指:剩下的 k 位同学从左到右编号 1..k,身高为 T₁…T_k,存在一个位置 i 使得 T₁ < T₂ < … < T_i 且 T_i > T_{i+1} > … > T_k。也就是先严格上升、再严格下降。

输入:第一行一个整数 n(2 ≤ n ≤ 100);第二行 n 个整数,表示从左到右每位同学的身高(130 ≤ T ≤ 230)。输出:一行一个整数,表示最少需要几位同学出列。

引导性提示(先只看这里)

不要枚举“谁出列”,那是指数级;反过来想“最多能留下几位”。

留下的队形有一个最高点,一旦这个最高点定下来,左边只能一路升,右边只能一路降,两半互相独立。

左边那段“以最高点结尾、严格上升”,右边那段“以最高点开头、严格下降”,各算一次 LIS 就行。

想看思路了?点开完整思路

枚举最高点 i(1 ≤ i ≤ n)。

左半边:以 i 结尾的最长严格上升子序列长度 f[i],用标准 LIS 正着算一遍。

右半边:以 i 开头的最长严格下降子序列长度 g[i]。做法是把 i 从 n 往 1 倒着扫,对 j > i 且 T[j] < T[i] 的做 g[i] = max(g[i], g[j] + 1)——本质就是在反方向上求一次 LIS。

以 i 为峰能留下的最多人数是 f[i] + g[i] − 1(i 被两段各数了一次,减掉重复)。

答案 = n − max(f[i] + g[i] − 1)。复杂度 O(n²),n ≤ 100 时约 10⁴ 次,瞬间出解。

为什么对:任何一个合法队形都有唯一的最高点 i,它左半边是 i 的上升前缀、右半边是 i 的下降后缀,长度不会超过 f[i] 与 g[i];反过来取到 f[i]、g[i] 的两段拼起来一定合法。所以枚举 i 不重不漏。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 105;
int t[MAXN];        // 身高
int f[MAXN];        // f[i]:以 i 结尾的最长严格上升子序列长度
int g[MAXN];        // g[i]:以 i 开头的最长严格下降子序列长度

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;
    for (int i = 1; i <= n; ++i) cin >> t[i];

    // 正着推:f[i] = 1 + max{ f[j] : j < i 且 t[j] < t[i] }
    for (int i = 1; i <= n; ++i) {
        f[i] = 1;                                  // 边界:自成一串
        for (int j = 1; j < i; ++j)
            if (t[j] < t[i]) f[i] = max(f[i], f[j] + 1);
    }

    // 倒着推:g[i] = 1 + max{ g[j] : j > i 且 t[j] < t[i] }
    // 必须 i 从 n 递减,才能保证用到 g[j] 时它已经算好
    for (int i = n; i >= 1; --i) {
        g[i] = 1;
        for (int j = n; j > i; --j)
            if (t[j] < t[i]) g[i] = max(g[i], g[j] + 1);
    }

    int keep = 0;
    for (int i = 1; i <= n; ++i)
        keep = max(keep, f[i] + g[i] - 1);         // i 被两段都数了一次,减 1

    cout << n - keep << "\n";
    return 0;
}
关键片段拆解
片段作用
for (int i = 1; i <= n; ++i)正推阶段。i 从小到大,f[j] (j < i) 一定已经算好——遍历顺序就是拓扑序。
f[i] = 1;边界:任何一个人自己都能当一段上升序列,长度 1。漏写这句 f 全 0。
for (int i = n; i >= 1; --i)倒推阶段。g 的定义是“以 i 开头往下走”,依赖 j > i,所以 i 必须从 n 往 1。写成从 1 到 n,g[j] 还没值,全错。
if (t[j] < t[i])严格下降:站在 i 看后面,只有比 i 矮的人才能接在 i 后面。等号不能加,题目要求严格。
f[i] + g[i] - 1减 1 是因为最高点 i 在 f 和 g 里各出现一次。忘了减,留下的人数虚高 1,输出答案偏小 1。
n - keep题目问“最少出列几位”,等于总人数减最多留下的人数。别直接输出 keep。
这题最容易错的地方
  • g 数组用 i 从 1 到 n 推:用到的 g[j] 还没算出来,全是 1,下降段全被算成 1。样例恰好还能输出 4(因为 max f[i] 就是 4),但输入 5 / 1 2 3 2 1 时正确输出 0,它输出 2。
  • 忘记 f[i] + g[i] − 1 里的 −1,输出比正确答案小 1;有的数据能过,纯属运气。
  • 把 f 和 g 都算成“整个序列的 LIS 长度”,两段长度对不上同一个峰,拼出来的队形不合法。
  • 最高点写成 for (int i = 2; i < n; ++i)、跳过端点:输入 5 / 5 4 3 2 1 正确输出 0,跳端点后输出 1。标准解法是 1..n 全枚举。
  • 输出 keep 而不是 n − keep。读题时把最后一句话圈出来再写输出。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》动态规划入门:状态设计与无后效性
  • 《算法竞赛黄金法则:提高算法和思考力的77项技巧》第 5 章:用表代替重复计算
  • 洛谷 P1216、P1091 题解区的高赞推导
32 背包问题:0/1 与完全背包普及/提高-

32 · 背包问题:0/1 与完全背包

一个符号决定一件物品能不能重复取:0/1 倒序,完全正序。由此派生出恰好装满的 −INF、方案计数的 dp[0]=1、多重背包的二进制拆分。背包是 DP 里唯一“模板即答案”的一类,但也最容易抄错方向。
普及/提高-预计 75 分钟背包倒序/正序方案计数
本节出现的新函数 / 新语法(先看懂再用)

背包是 DP 里最像“套路”的一类:有 n 件物品,每件有体积 w 和价值 v,背包容量 V,在总体积不超过 V 的前提下让总价值最大。P1048 采药就是它:T = 1000 的时间当容量,M = 100 株草药当物品。

0/1 背包和完全背包的代码只差一个不等号的方向,但差的方向后面是两件完全不同的事:dp[j] 被这一层更新之后,还允不允许这一层再拿它去更新别人。把这句话想通,背包就没有难点了。

  • 0/1 背包:每件物品只能取 0 次或 1 次。内层容量倒序。
  • 完全背包:每件物品可以取任意多次。内层容量正序。
  • 多重背包:第 i 件物品最多取 c_i 次。把 c_i 二进制拆成若干组,转成 0/1 背包。
  • 方案计数:把 max 换成加法,dp 的含义从“最大价值”变成“方案数”。
倒序 = 我只看上一层的旧值,所以每件最多用一次;正序 = 我允许用刚被这一层更新的新值,所以可以无限接下去。整节背包,核心就这一句。
新函数拆解 · 一维 dp 数组:把物品维度滚动掉
int dp[MAXT]; // dp[j] = 容量上限为 j 时的最大价值
组成含义 / 格式要求
dp[j] 的含义在“已经考虑过前 i 件物品”的前提下,容量上限为 j 时能取得的最大价值。i 这一维被滚动掉了,所以在任意时刻 dp 只代表当前这一层。
下标范围0..V。V 是容量上限(采药题里是总时间 T),不是物品个数。数组开 V+5,不是 n+5。
下标 0容量 0 装不下任何有体积的物品,价值是 0;换成计数题,dp[0] 是 1(什么都不选也是一种方案)。
为什么能压成一维二维写法里第 i 行只由第 i−1 行算出。算完第 i 行,第 i−1 行再没人要——那就让两行共用同一块空间。
值的类型最大价值一般 int 够(100 株草药 × 100 价值 = 10⁴);方案数要用 long long,完全背包的超大容量题也要看清范围。
典型规模M × V = 100 × 1000 = 10⁵ 个状态,每个 O(1) 转移,总运算 10⁵ 次——暴搜是 2¹⁰⁰。
最小可运行示例
示例
// 二维写法:想清楚用这个
for (int i = 1; i <= M; ++i)
    for (int j = 0; j <= T; ++j) {
        dp2[i][j] = dp2[i - 1][j];                                  // 不选第 i 件
        if (j >= t[i])
            dp2[i][j] = max(dp2[i][j], dp2[i - 1][j - t[i]] + v[i]); // 选第 i 件,从上一层转移
    }

// 一维写法:提交用这个
for (int i = 1; i <= M; ++i)
    for (int j = T; j >= t[i]; --j)                                 // 倒序!
        dp[j] = max(dp[j], dp[j - t[i]] + v[i]);
注意

一维写法里 j 只循环到 t[i] 就停,是因为 j < t[i] 时 j − t[i] 是负数下标,会越界;而这一步在二维写法里对应的正是“这件物品装不下,只能不选”。

一维数组装不下历史层,所以它到底代表哪一层,完全由你的循环方向决定。这就是下一张卡片的全部内容。

新函数拆解 · 遍历方向:倒序(0/1)与正序(完全)
for (int j = V; j >= w; --j) // 0/1 背包 for (int j = w; j <= V; ++j) // 完全背包
组成含义 / 格式要求
倒序更新 dp[j] 时,dp[j-w] 还没被本轮碰过,读到的仍是上一层的值——每件物品最多用一次。
正序更新 dp[j] 时,dp[j-w] 已被本轮改写成本层的值——同一件物品可以再接一次,等效于无限件。
统一记忆0/1 是倒的,完全是正的。题面差一个“能不能重复取”,代码差一个符号。
起点是 wj 从 w 开始,保证 j−w ≥ 0。写 j = 0 会让下标变负,RE 或读到垃圾。
循环嵌套顺序物品循环必须在外层,容量在内层。反过来(容量在外、物品在内)在 0/1 背包上会重复选物品,必错。
判断方法写完之后问自己:dp[j-w] 此刻是旧值还是新值?答对,方向就对了。
最小可运行示例
示例
// 0/1 背包:每件最多一次
for (int i = 1; i <= M; ++i)
    for (int j = T; j >= t[i]; --j)
        dp[j] = max(dp[j], dp[j - t[i]] + v[i]);

// 完全背包:每件无限次
for (int i = 1; i <= M; ++i)
    for (int j = t[i]; j <= T; ++j)
        dp[j] = max(dp[j], dp[j - t[i]] + v[i]);
注意

把倒序写成正序,程序不会报错、样例有时还能过,但答案是“每件可以无限取”的结果,通常偏大。

这个方向规律对“加法计数”同样成立:0/1 计数倒序,允许重复计数正序。

新函数拆解 · INF / NEG 初始化:把“不可达”标出来
const int NEG = -0x3f3f3f3f; dp[0] = 0; for (int j = 1; j <= V; ++j) dp[j] = NEG;
组成含义 / 格式要求
NEG 取 −0x3f3f3f3f≈ −1.06×10⁹。它加上 int 范围内的任何正数都不会溢出,而 INT_MIN 加一次就溢出成很大的正数(未定义行为)。
memset 只能按字节填memset(dp, 0x3f, sizeof dp) 把每字节填 0x3f,拼成 int 得到 0x3f3f3f3f = 1061109567;负无穷可以用 memset(dp, 0xbf, ...) 得到 −1077952577,但 fill 更清楚。
fill 更通用fill(dp, dp + V + 1, NEG) 能填任意值。
恰好装满dp[0] = 0,其余 NEG。只有“真能凑出 j”的状态才会被转移改写,凑不出的永远是 NEG。
不要求装满整表填 0。任何容量都是合法起点——允许留空,所以答案不会因为“装不满”而无解。
方案计数dp[0] = 1,其余 0。花 0 元什么都不选,是唯一的 1 种方案。
最小可运行示例
示例
const int NEG = -0x3f3f3f3f;

// 恰好装满的 0/1 背包
dp[0] = 0;                                  // 唯一合法的起点
for (int j = 1; j <= V; ++j) dp[j] = NEG;    // 其余一律“不可达”

for (int i = 1; i <= n; ++i)
    for (int j = V; j >= w[i]; --j)
        if (dp[j - w[i]] != NEG)             // 不可达的来源不许参与转移
            dp[j] = max(dp[j], dp[j - w[i]] + v[i]);

if (dp[V] < 0) cout << "impossible\n";       // 用 < 0 判,不要用 == NEG
else           cout << dp[V] << "\n";
注意

不写 if (dp[j-w] != NEG) 其实也能过很多题(NEG 加个正数还是负数,取 max 时会被自己的旧值压住),但一旦转移里有减号、或者价值可以为负,就必须显式判断。

判断无解用 dp[V] < 0。因为 NEG 可能被更新成一个“不那么负”的负数,用 == NEG 会漏判。

例题 · 采药洛谷 P1048普及-去洛谷做这题 ↗

辰辰是个天资聪颖的孩子,他的梦想是成为世界上最伟大的医师。他的师父给他出了一道难题:山洞里有 M 株不同的草药,采每一株都需要一些时间,每一株也有它自身的价值。给定总时间 T,求在规定时间内能采到的草药的最大总价值。

输入:第一行两个整数 T(1 ≤ T ≤ 1000)和 M(1 ≤ M ≤ 100),用空格隔开,T 表示总共能够用来采药的时间,M 表示山洞里的草药数目;接下来 M 行,每行两个整数,依次表示采第 i 株草药所需的时间和这株草药的价值(均不超过 100)。

输出:一行一个整数,表示在规定时间内能采到的草药的最大总价值。

输入: 70 3 71 100 69 1 1 2 输出: 3
例题怎么想:把“时间”当成容量

每株草药只有两种命运:采,或者不采。M ≤ 100 时枚举全部 2¹⁰⁰ 种组合,死了。

① 状态:dp[i][j] = 只考虑前 i 株草药、总时间不超过 j 时,能采到的最大价值。

② 转移:对第 i 株草药分两类讨论。不采:dp[i][j] = dp[i−1][j]。采(前提 j ≥ t[i]):dp[i][j] = dp[i−1][j − t[i]] + v[i]。两者取 max。

③ 边界:dp[0][j] = 0——一株草药都不考虑,价值是 0。本题不要求“恰好用满时间”,所以整表可以全填 0。

④ 答案:dp[M][T],前 M 株、容量 T 的最优值。

压成一维后,第 i 层只由第 i−1 层算出,于是写作 dp[j] = max(dp[j], dp[j-t[i]] + v[i]),并且 j 必须倒序——否则 dp[j−t[i]] 已经是本层的新值,等于把同一株草药采了两次。

复杂度:M × T = 100 × 1000 = 10⁵ 次转移,每步 O(1)。

完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXT = 1005, MAXM = 105;
int t[MAXM], v[MAXM];          // t:采这株草药要花的时间;v:它的价值
int dp[MAXT];                  // dp[j]:时间上限为 j 时能采到的最大价值

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int T, M;
    cin >> T >> M;
    for (int i = 1; i <= M; ++i) cin >> t[i] >> v[i];

    // 物品在外层、容量在内层:这是 0/1 背包唯一正确的嵌套顺序
    for (int i = 1; i <= M; ++i)
        for (int j = T; j >= t[i]; --j)                 // 倒序:j-t[i] 还是上一层,草药只采一次
            dp[j] = max(dp[j], dp[j - t[i]] + v[i]);

    cout << dp[T] << "\n";                              // 容量 T 时的最优值就是答案
    return 0;
}
逐行拆解
代码片段含义与理由
int dp[MAXT];一维数组,只有容量这一维。物品维度靠“外层循环一轮一轮覆盖”来滚动。
for (int i = 1; i <= M; ++i)外层是物品。每跑完一轮,dp 数组的含义就从“前 i−1 株”变成“前 i 株”。
for (int j = T; j >= t[i]; --j)内层倒序,这是 0/1 背包的核心。j 从 T 往 t[i] 走,被依赖的 dp[j-t[i]] 下标更小,还没被这一轮碰过,读到的是旧值。
j >= t[i]j < t[i] 时这株草药根本装不下,dp[j-t[i]] 还会越界成负下标,所以循环到 t[i] 就停。
dp[j - t[i]] + v[i]“采第 i 株”的收益:把它占掉的时间腾出来(容量降到 j-t[i]),价值加上 v[i]。
max(dp[j], ...)采和不采二选一。dp[j] 在等号左边出现,就是“不采”的那一支。
cout << dp[T];答案是容量上限 T 处,不是 dp[M]。dp 数组里没有物品那一维了,最后只剩容量。

把“为什么倒序”钉死。设只有一件物品:w = 2、v = 3、V = 6,dp 全 0。看两种方向跑出来的表。

j正序 dp[j](每件可无限次)倒序 dp[j](每件最多一次)
000
100
23(dp[0] + 3)3(dp[0] + 3)
33(dp[1] + 3)3(dp[1] + 3)
46(dp[2] + 3,dp[2] 已含一件)3(dp[2] 还是 0)
563
69(dp[4] + 3,三件)3(dp[4] 还是 0)

正序时 j = 4 读到的 dp[2] 已经是 3(本轮刚算出来的),于是 3 + 3 = 6——同一件物品被用了两次。倒序时 j = 4 读到的 dp[2] 还是 0,只能得 3。

一句话总结:一维数组同时扮演“上一层”和“本层”两个角色,方向决定你读到的是哪一个。倒序读到旧层(0/1),正序读到新层(完全背包)。

所以完全背包不需要新写法,只要把方向反过来:正序时 dp[j−w] 刚被本层更新过,允许“在这件物品已经用过的基础上再用一次”,正好等于无限件。

完全背包
// 完全背包:每件物品可以取任意多次
for (int i = 1; i <= M; ++i)
    for (int j = t[i]; j <= T; ++j)          // 正序:允许接着用这件物品
        dp[j] = max(dp[j], dp[j - t[i]] + v[i]);

模板题是 P1616 疯狂的采药:每株草药可以无限采,其余读入格式与 P1048 一模一样,改动只有内层方向一处。它的 T 比 P1048 大得多,所以还要多算一步账:dp 数组的内存 = T × 4 字节。T = 10⁷ 时就是 40 MB,开两个数组直接 MLE。

另外注意:完全背包的 dp 初值仍然全填 0。因为“一件都不取、容量随便剩多少”本身就是合法的 0 价值方案,不需要 −INF 那一套。

恰好装满是背包的第二种语义:不是“不超过 V”,而是“必须正好凑到 V”。状态转移一字不改,改的是初值。

为什么初值能改变语义:dp[j] 的值来自“从某个更小的 j 转移过来”,一路追溯下去终点一定是 dp[0]。如果 dp[0] = 0 而其余全填 −INF,那么只有“真能由若干件物品体积相加得到 j”的状态才会被更新成非负值,其余全是 −INF。最后 dp[V] 若仍小于 0,说明凑不出来。

恰好装满
const int NEG = -0x3f3f3f3f;
int dp[MAXT];

dp[0] = 0;                                  // 容量 0 天然装满,价值 0
for (int j = 1; j <= V; ++j) dp[j] = NEG;    // 其余容量都是“还没做到”

for (int i = 1; i <= n; ++i)
    for (int j = V; j >= w[i]; --j)          // 0/1:倒序
        if (dp[j - w[i]] != NEG)             // 来源不可达就不能转移
            dp[j] = max(dp[j], dp[j - w[i]] + v[i]);

if (dp[V] < 0) cout << "impossible\n";       // 装不满
else           cout << dp[V] << "\n";
−INF 初值最坑的一点

把 NEG 写成 INT_MIN:NEG + v[i] 直接溢出(未定义行为),得到一个很大的正数,“不可达”瞬间变成“最优解”,输出荒谬的大数。用 −0x3f3f3f3f。

用 dp[V] == NEG 判断无解:某个不可达状态可能被转移改成另一个负数,判不出来。用 < 0。

方案计数是第三种语义:把 max 换成加法,dp 的含义从“最优值”变成“方案数”。此时初值又变了:dp[0] = 1,其余 0。

dp[0] = 1 的理由:什么都不选、恰好用掉 0 的容量,这也算一种方案。写 0 的话,所有加法都会乘上 0,整张表恒等于 0。这和“恰好装满写 −INF”是同一件事的两面——不可达的状态必须贡献 0(加法)或者被排除(取 max)。

方案计数
// 0/1 方案计数:n 个数里选若干,使和恰好等于 m,问方案数
long long dp[10005];
dp[0] = 1;                                  // 和为 0 的方案:一个都不选

for (int i = 1; i <= n; ++i)
    for (int j = m; j >= a[i]; --j)          // 倒序:每个数只能用一次
        dp[j] += dp[j - a[i]];               // 加法原理:不合这个数 + 合这个数

cout << dp[m] << "\n";
新函数拆解 · 二进制拆分:把“最多 c 件”拆成 log c 个 0/1 物品
for (int k = 1; k <= rest; k <<= 1) { 用 k 件做一次 0/1; rest -= k; }
组成含义 / 格式要求
拆什么第 i 种物品最多取 c 件。不拆就是 c 次 0/1,拆完只要 ⌊log₂c⌋+1 次。
怎么拆取 1, 2, 4, 8, … 件各打包成一件“新物品”,体积 k·w、价值 k·v;剩下的零头单独成一组。
为什么能表示所有数量1, 2, 4, …, 2ᵏ⁻¹ 能拼出 0..2ᵏ−1 中任意整数(二进制);再加零头 r,就能拼出 0..c 全部整数,一个不漏。
每组的更新打包后的物体积是 k·w,所以内层写 j 从 V 倒到 k·w,跟普通 0/1 完全一样。
复杂度O(V · Σ⌊log₂c_i⌋)。c = 10⁵ 时从 10⁵ 组降到 17 组。
最小可运行示例
示例
// 第 i 种物品:体积 w、价值 v、最多 c 件
int rest = c;
for (int k = 1; k <= rest; k <<= 1) {
    // 把 k 件打包成一件新物品,然后当 0/1 处理
    for (int j = V; j >= k * w; --j)
        dp[j] = max(dp[j], dp[j - k * w] + k * v);
    rest -= k;                       // 这 k 件已经被打包掉了
}
if (rest > 0) {                      // 零头单独成一组
    for (int j = V; j >= rest * w; --j)
        dp[j] = max(dp[j], dp[j - rest * w] + rest * v);
}
注意

注意 rest 是独立变量。直接改 c 会污染循环条件,for 的边界每轮都在变,容易写错。

二进制拆分只对“同种物品完全相同”成立。如果同种物品各有各的体积价值,只能一件一件转 0/1。

类型每件取几次内层方向初始化输出
0/1 背包0 或 1 次倒序 j = V → w全 0(不要求装满)dp[V]
完全背包无限次正序 j = w → V全 0dp[V]
多重背包最多 c 次拆成 log c 组,每组倒序全 0dp[V]
恰好装满同原题同原题dp[0]=0,其余 −INFdp[V] < 0 则无解
方案计数0 或 1 次倒序dp[0]=1,其余 0dp[m]
背包的 7 种典型死法(含症状)

WA(答案偏大):0/1 背包写成正序。拿 P1048 的样例跑一遍:正确答案 3,正序输出 140(第 3 株草药 t=1、v=2 被重复采了 70 次)。

WA(该无解却说有解):恰好装满忘了写 −INF,整表 0 起步。容量 4、物品体积 3 价值 5,3 凑不出 4,正确答案是“无解”,程序却输出 5——它把“装了 3、空 1”当成了合法方案。

WA(全 0):方案计数忘了 dp[0] = 1。所有转移都是 0 + 0,输出恒为 0。

WA / RE(溢出):NEG 用 INT_MIN,加法溢出成很大的正数,“不可达”被当成“最优”,症状是输出一个几亿的奇怪数字。

RE(越界):内层写成 for (int j = V; j >= 0; --j) 却仍访问 dp[j−w],j < w 时下标为负。循环起点必须是 w。

MLE:V 到 10⁷ 时一个 dp 数组就要 4×10⁷ 字节(约 40 MB),再顺手开个二维就爆内存了。开数组前先算 V × 4 字节。

WA(多测):多组数据的题忘了每组重新初始化 dp,第二组直接继承第一组的答案,越跑越离谱。

自己动手复现

关掉页面,从空文件把 P1048 敲一遍并提交。

改一改 ①:把内层方向改成 for (int j = t[i]; j <= T; ++j),用样例 70 3 / 71 100 / 69 1 / 1 2 再跑一次,输出从 3 变成 140。这 140 是 70 × 2,说明第 3 株草药被采了 70 次——把方向和作用对上号。

改一改 ②:把它改成“恰好用完 T 个时间”。初始化改为 dp[0]=0、其余 −INF,样例仍输出 3(69 + 1 正好 70);再把 T 改成 50,此时应输出无解——71 和 69 都超过 50,只剩 1,凑不出 50。

改一改 ③:把第 3 株草药的数目限制成最多 5 株,用二进制拆分重写。检查拆出来的组数:5 = 1 + 2 + 2,三组,而不是五组。

数学补给 · 两条证明:一维化为什么等价,二进制拆分为什么成立

一、倒序 = 上一层,正序 = 本层。记二维状态 F(i, j) 表示“只考虑前 i 件物品、容量为 j”的最优值,它的递推是

F(i, j) = max( F(i−1, j), F(i−1, j−w) + v )

一维数组 d[j] 想模拟 F(i, ·)。更新 d[j] 的那一刻,d[j−w] 里到底是什么?

若 j 从大到小:j−w < j,还没被这一轮写过,所以 d[j−w] = F(i−1, j−w),代入得 d[j] = max(F(i−1, j), F(i−1, j−w) + v) = F(i, j),与二维完全等价。

若 j 从小到大:d[j−w] 这一轮已经写过了,它等于 F(i, j−w),代入得 d[j] = max( F(i−1, j), F(i, j−w) + v )。这不是错式,它正好是完全背包的递推——第 i 件物品可以取多次。所以“正序”不是 bug,是另一个问题的正确答案。

二、二进制拆分。设第 i 种物品有 c 件,取整数 k 使 2ᵏ − 1 ≤ c < 2ᵏ⁺¹ − 1,令零头 r = c − (2ᵏ − 1),则由下界得 r ≥ 0、由上界得 r < 2ᵏ。拆出的组是 1, 2, 4, …, 2ᵏ⁻¹, r,共 k+1 组,总和恰好是 c。

要证 0..c 中每个整数都能写成这些组的某个子集和。① 若 x < 2ᵏ:用 1, 2, …, 2ᵏ⁻¹ 表示(这正是 x 的二进制分解),不需要 r。② 若 x ≥ 2ᵏ:取 r,剩下 x − r。由 x ≤ c 得 x − r ≤ c − r = 2ᵏ − 1;又 x ≥ 2ᵏ > r 得 x − r > 0,所以 x − r 落在 [0, 2ᵏ−1],仍可由前 k 组表示。两类合起来覆盖全部 x,证毕。

组数是 k+1 = O(log c):c = 10⁵ 时是 17 组,c = 10⁶ 时是 20 组。这就是二进制拆分把 O(V·Σc) 压成 O(V·Σlog c) 的全部来源。

举一反三 · 想清楚这些再往下走
  1. P1048 里如果每株草药能采无限次(即 P1616 疯狂的采药),代码只需要改哪一处?为什么改完就自动对了,不需要追加任何“用了几次”的信息?
  2. 方案计数题如果把倒序改成正序,求到的是什么?(提示:允许重复点同一道菜时,1+2 和 2+1 会被算成一种还是两种?)
  3. “恰好装满”用 −INF、“不超过”用 0,为什么初值就能定义语义?把 dp[j] 的值一路追溯下去,终点是哪个格子?
  4. 多重背包的 c_i 很小时(比如都 ≤ 3),二进制拆分和“一件一件拆成 c 件”,哪个更快?分界点大概在哪?
  5. 如果还要求输出选了哪些物品,一维 dp 还能不能反向追溯?二维 dp 要怎么走?
  6. 两件完全相同的物品,在“方案计数”里算一种还是两种方案?这由题面哪句话决定,代码要不要改?
自测:能全部回答再进下一节
  • 能不看书写出 0/1 背包,并说清内层为什么必须倒序、写成正序会得到什么。
  • 能把 0/1 背包改成完全背包,并解释“正序 = 允许重复取”的机制。
  • 能说出“恰好装满”和“不超过”在初值上的区别,以及怎么判无解。
  • 能写出方案计数,并说出 dp[0] 为什么是 1 而不是 0。
  • 能把多重背包用二进制拆分转成 0/1,并手算 c = 5、c = 8 时的拆分组。
  • 拿到一道背包题,能先判断它属于哪一类、初值怎么给、内层什么方向。
挑战题 · 小A点菜
洛谷 P1164普及-去洛谷做这题 ↗

饭店里有 n 种菜,第 i 种菜卖 aᵢ 元,每种菜只有一份。小 A 身上有 m 元钱,他想把身上的钱恰好花完。

问一共有多少种点菜的方案。两种方案不同,当且仅当点的菜的集合不同。

输入:第一行两个整数 n、m(1 ≤ n ≤ 100,1 ≤ m ≤ 10⁴);第二行 n 个整数 a₁…aₙ(1 ≤ aᵢ ≤ 1000)。输出:一行一个整数,表示方案数。

引导性提示(先只看这里)

不要枚举“点哪几道菜”,那是指数级。把“钱数”当成背包容量,一道菜就是一件体积为 aᵢ 的物品。

初值先想一个问题:一道菜都不点,恰好花掉 0 元,这算不算一种方案?算的话 dp[0] 该写几?

这是“恰好花完”的计数题,不是“花不超过 m”的计数题。答案只取 dp[m] 一个格子。

想看思路了?点开完整思路

状态:dp[j] = 用当前考虑过的这些菜,恰好花掉 j 元的方案数。

转移:对第 i 道菜,只有“点”和“不点”两种。不点的方案数已经记在 dp[j] 里;点的方案数等于“花掉 j − aᵢ 元”的方案数,所以 dp[j] += dp[j − aᵢ]。

边界:dp[0] = 1——什么都不点、恰好花 0 元,这是唯一的 1 种方案;其余 dp[j] 初值 0。

方向:内层 j 从 m 倒序到 aᵢ。倒序保证 dp[j − aᵢ] 还没被第 i 道菜更新过,每道菜最多点一次。写成正序就变成“每道菜可以点任意多份”,答案会明显偏大。

答案:dp[m]。注意不是 sum(dp[0..m])——那求的是“花不超过 m 元”的总方案数。

复杂度:n × m = 100 × 10⁴ = 10⁶ 次加法,很快。空间 O(m)。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXM = 10005;        // m ≤ 10⁴,开 10005
int a[105];
long long dp[MAXM];            // dp[j]:恰好花掉 j 元的方案数

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;
    for (int i = 1; i <= n; ++i) cin >> a[i];

    dp[0] = 1;                            // 花 0 元的唯一方案:什么都不点

    for (int i = 1; i <= n; ++i)          // 逐道菜考虑
        for (int j = m; j >= a[i]; --j)   // 倒序:保证每道菜最多点一次
            dp[j] += dp[j - a[i]];        // 不点这道菜 + 点这道菜

    cout << dp[m] << "\n";                // 恰好花完 m 元的方案数
    return 0;
}
关键片段拆解
片段作用
long long dp[MAXM];计数题的值增长很快,用 long long 一步到位。最大值 2³¹ 附近时 int 会溢出成负数。
dp[0] = 1;计数题的灵魂。写 0 的话所有加法都是 0,输出恒为 0——这是最常见的死法。
for (int i = 1; i <= n; ++i)外层是菜。每跑完一轮,dp 的含义从“前 i−1 道菜”变成“前 i 道菜”。
for (int j = m; j >= a[i]; --j)倒序,0/1 的标记。正序会让 dp[j−a[i]] 含“已点过第 i 道菜”的情况,同一道菜被点多次,方案数虚高。
dp[j] += dp[j - a[i]];加法原理:凑出 j 元的方案 = 不含这道菜的方案 + 含这道菜的方案。含这道菜的每一种方案,去掉它之后都是“凑出 j−a[i]”的方案,一一对应。
cout << dp[m];只输出恰好花完 m 元。写成 ∑dp[0..m] 就变成“花不超过 m 元”,答案偏大。
这题最容易错的地方
  • dp[0] 写成 0:整张表恒为 0,样例输出 0。
  • 内层写成正序 j = a[i] → m:变成完全背包,同一道菜可以重复点,答案比正确答案大。
  • 输出 sum(dp[0..m]) 或 dp[m] 之外的格子:题目要求恰好花完 m 元。
  • 数组规模按 m 开,不是按 n 开:n ≤ 100 而 m 可以到 10⁴,dp 只开 105 就必 RE。
  • 用 int 存 dp:方案数很大时会溢出成负数,输出一个负数。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》背包问题:0/1、完全、多重
  • 《算法竞赛试炼场:洛谷300题精析》背包专题
  • 洛谷 P1048、P1164 题解区的高赞推导
33 区间 DP 与树形 DP提高-/提高

33 · 区间 DP 与树形 DP

两种“没有下标顺序”的 DP:区间 DP 按长度从短到长,树形 DP 靠 DFS 后序自底向上。把石子合并的三层循环、破环成链,和“选/不选”这一维的树形状态设计一次讲透。
提高-/提高预计 70 分钟区间 DP树形 DP自底向上
本节出现的新函数 / 新语法(先看懂再用)

第 31 节的线性 DP 从左往右扫,第 32 节的背包按容量递增,顺序都由下标决定。这一节的两个结构没有现成的下标顺序可用:区间要从短往长算,树要等儿子算完再算父亲。规矩其实是同一条——递推顺序必须和状态依赖关系一致:依赖谁,就先算谁。

区间 DP 的循环顺序不是风格问题,是正确性问题。len 一旦挪到内层,程序不报错、不崩,答案直接变成错的。
区间 DP树形 DP
状态f[l][r]:把第 l..r 个元素处理完的最优值f[u][…]:以 u 为根的子树内部的最优值
依赖两个更短的区间 [l,k]、[k+1,r]所有儿子 v 的 f[v][…]
顺序区间长度 len 从 2 到 nDFS 后序:先递归儿子,回到 u 再合并
复杂度状态 O(n²) × 转移 O(n) = O(n³)每点算一次、每条边过一次 = O(n)
典型题石子合并、能量项链、最长回文子序列没有上司的舞会、最大子树和、树形背包

先看清三块骨头:区间 DP 的三层循环、树的存法、树的遍历顺序。前两块是写法,第三块是这一节真正的思想。

新函数拆解 · 区间 DP 的三层循环
for (int len = 2; len <= n; ++len) for (int l = 1, r = len; r <= n; ++l, ++r) for (int k = l; k < r; ++k) f[l][r] = min(f[l][r], f[l][k] + f[k + 1][r] + cost(l, r));
组成含义 / 格式要求
len区间长度。必须是最外层:长度更小的区间先算完,长区间才拿得到子区间的值。
l, r左右端点,r 由 l + len − 1 推出。用一个变量加长度反推,别再单独写一层 r 的循环。
k分割点,范围 l ≤ k < r。它把 [l,r] 切成 [l,k] 与 [k+1,r] 两段,对应“最后一次操作”。
len 从 2 开始长度 1 的区间不用合并,直接 f[i][i] = 0。从 1 开始只是白跑一层,不算错。
f[l][k] 与 f[k+1][r]两者长度都严格小于 len,走到外层 len 时它们一定已经算好——这就是 len 必须在最外面的唯一理由。
cost(l, r)把两段并成一段的额外代价,最常见是区间和 s[r] − s[l−1],用前缀和 O(1) 取。
答案在哪线性题答 f[1][n];环形题先把数组复制成 2n,答所有长度 n 的窗口 f[l][l+n−1] 里的最优值。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int n = 5, a[105], s[105];
int f[105][105];                 // f[l][r]:把 a[l..r] 合并成一堆的最小代价

int main() {
    for (int i = 1; i <= n; ++i) { a[i] = i; s[i] = s[i - 1] + a[i]; }
    memset(f, 0x3f, sizeof f);
    for (int i = 1; i <= n; ++i) f[i][i] = 0;        // 长度 1:本来就是一堆,不需要合并

    for (int len = 2; len <= n; ++len)               // ① 先短后长
        for (int l = 1, r = len; r <= n; ++l, ++r)   // ② 再扫左端点
            for (int k = l; k < r; ++k)              // ③ 最后枚举断点
                f[l][r] = min(f[l][r], f[l][k] + f[k + 1][r] + s[r] - s[l - 1]);

    cout << f[1][n] << "\n";                         // 1..5 合并的最小代价
    return 0;
}
注意

把外层 len 换成“枚举 l,再枚举 r”,仍然是 r − l 递增;写成 l 从小到大、r 从 l 到 n 就用到未算的 f[k+1][r] → WA,而样例常常刚好能过,最难查。

求 min 的 f 必须初始化成大值(0x3f3f3f3f),求 max 初始化成 0 或 −INF。忘初始化 = 白送 WA。

n 到 1000 时 O(n³) = 1.7×10⁸ 且常数不小,先看数据范围,别硬刚。

新函数拆解 · vector 邻接表(树的存法)
vector<int> g[N]; g[u].push_back(v); for (int v : g[u]) { ... }
组成含义 / 格式要求
vector<int> g[N]开 N 个 vector,第 u 个装 u 的所有邻居。读法:一个数组,每个元素是一条不定长表。
g[u].push_back(v)在 u 的表尾追加 v,O(1) 摊还。树有 n−1 条边,无向存两次 → 共 2(n−1) 次追加。
g[u].size()u 的邻居个数。树里就是度数:根的儿子数 = 度数,其它节点 = 度数 − 1。
for (int v : g[u])范围 for,依次取出邻居。等价于 for (int i = 0; i < (int)g[u].size(); ++i) { int v = g[u][i]; }。
有向还是无向有向树(父→子)只 push 一次;无向树要 push 两次:g[u].push_back(v); g[v].push_back(u);
为什么不用邻接矩阵n = 6000 的矩阵是 36 MB,遍历一个点还要扫 n 格;邻接表遍历一次只花 O(度数)。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

const int N = 6005;
vector<int> g[N];                 // 全局:默认全是空表

int main() {
    // 无向树:1-2, 1-3, 3-4,每条边两个方向都要存
    g[1].push_back(2); g[2].push_back(1);
    g[1].push_back(3); g[3].push_back(1);
    g[3].push_back(4); g[4].push_back(3);

    for (int v : g[1]) cout << v << ' ';    // 2 3
    cout << "\n" << g[1].size() << "\n";    // 2
    return 0;
}
注意

vector<int> g[N] 放全局才默认全空,且不占栈。放函数里是 n 个局部 vector,n 大时容易爆栈。

范围 for 里写 int v 是拷贝;要改表本身得写 int &v。本节都只读。

边数记牢:树的边是 n−1 条,无向存两次,所以每个 vector 的总长度和是 2(n−1)。

新函数拆解 · 树的 DFS(自底向上的顺序)
void dfs(int u, int fa) { for (int v : g[u]) { if (v == fa) continue; // 不走回头路 dfs(v, u); // ① 先把儿子的答案算完 ... // ② 再用 f[v] 更新 f[u] } }
组成含义 / 格式要求
u当前节点。dfs 的每一层只负责一棵子树,不关心兄弟。
fau 的父亲。树存成无向图后,邻居里有一个是“来的路”,必须跳过,否则两点之间来回横跳。
if (v == fa) continue;这一行就是“不走回头路”。删掉它 → 无限递归 → 爆栈 RE,连输出都没有。
dfs(v, u)传的是 u,不是 fa:v 的父亲是 u。写成 dfs(v, fa) 会让 v 继续往上爬。
①②的顺序所有合并儿子的语句必须写在 dfs(v, u) 之后。写在前面,f[v] 还是初始值,答案偏小且不报错。
叶子节点叶子没有任何邻居能通过 if,for 循环直接结束,函数返回——不需要单独写递归边界。
根怎么选无向树任选一个当根(常写 1);有向树(父→子)要先找入度为 0 的点。
深度递归深度 = 树高,最坏是一条链、深度 n。n ≤ 2×10⁴ 时安全,到 10⁵ 要考虑手写栈。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

const int N = 100005;
vector<int> g[N];
int sz[N], dep[N];

void dfs(int u, int fa) {
    sz[u] = 1;                        // 自己算一个点
    dep[u] = dep[fa] + 1;             // 深度可以顺着参数/父亲递推
    for (int v : g[u]) {
        if (v == fa) continue;        // 父亲方向是来的路
        dfs(v, u);                    // ★ 先递归:等 sz[v] 算完
        sz[u] += sz[v];               // ★ 再合并
    }
}

int main() {
    g[1].push_back(2); g[2].push_back(1);
    g[1].push_back(3); g[3].push_back(1);
    g[3].push_back(4); g[4].push_back(3);
    dep[0] = 0;
    dfs(1, 0);                        // 0 是虚点,编号从 1 起,绝不会撞上
    cout << sz[1] << " " << sz[3] << " " << dep[4] << "\n";   // 4 2 3
}
注意

fa 初值写 0、编号从 1 开始,0 一定不等于任何邻居——无向树靠这一手省掉 vis 数组。

把两个 ★ 交换顺序:sz[u] += sz[v] 时 sz[v] 还是 0,答案直接少一大截。这是树形 DP 第一大坑。

根取哪个不影响 sz[1] 之外的信息量,但答案若是“某个点”,要在 dfs 里顺手更新全局量。

第一道题是本节的模板:一条链上的合并,加上一个环的处理。先把它彻底做透,第二个例题才有参照物。

例题 · 石子合并洛谷 P1880普及/提高-去洛谷做这题 ↗

在圆形操场四周摆了 N 堆石子,现要将石子有次序地合并成一堆。规定每次只能选相邻的两堆合并成新的一堆,并把新堆的石子数记为该次合并的得分。

求将 N 堆合并成 1 堆的最小得分与最大得分。

数据范围:1 ≤ N ≤ 100,0 ≤ aᵢ ≤ 20。

输入格式:第一行 N;第二行 N 个整数,第 i 个表示第 i 堆的石子数。
输出格式:两行,第一行最小得分,第二行最大得分。

输入: 4 4 5 9 4 输出: 43 54
把“合并顺序”换成“最后一步”

顺序不是状态,最后一步才是。不管前面怎么合,整个区间 [l,r] 合并成一堆的最后一步,一定是由两堆并成的:一堆来自 [l,k],一堆来自 [k+1,r]。所以只要知道“这两段各自合成一堆的最小代价”,就能拼出整段的最小代价:

f[l][r] = min(f[l][k] + f[k+1][r]) + sum(l..r),k 取遍 l..r−1。末尾加的区间和是这次合并的得分:新堆的重量就是两段之和。

为什么这一步加法是对的。最后一步的得分固定为 sum(l,r),与切法无关;前面两段内部的合并互不干扰,各自独立取最优不会冲突。这就是最优子结构,也是这道题能用 DP 的唯一理由。

环形怎么办。环上有 n 种“从哪断开”的选择。暴力枚举断点各做一次 DP 是 O(n⁴)。标准做法:把数组复制一份接在后面,得到长度 2n 的链;环上任何一种断法,都对应这条链上一个长度为 n 的连续窗口。于是所有断法的答案就是 f[l][l+n−1](1 ≤ l ≤ n)里的最优值。

规模。状态 2n × 2n = 4×10⁴,转移 n = 100,总共约 2×10⁶ 次加法取最值,一瞬间。最小和最大用同一个转移,只是 min 换成 max,所以两张表一起算,代码只多两行。

完整可编译代码(C++17)
#include <bits/stdc++.h>
using namespace std;

const int INF = 0x3f3f3f3f;
int n, a[205], s[205];              // a 开两倍:破环成链
int f[205][205], g[205][205];      // f 最小得分,g 最大得分

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    for (int i = 1; i <= n; ++i) { cin >> a[i]; a[i + n] = a[i]; }   // 复制一份接在后面
    for (int i = 1; i <= 2 * n; ++i) s[i] = s[i - 1] + a[i];          // 前缀和:O(1) 取区间和

    memset(f, 0x3f, sizeof f);     // 求最小:初值要足够大
    memset(g, 0, sizeof g);        // 求最大:得分非负,初值 0 就行
    for (int i = 1; i <= 2 * n; ++i) f[i][i] = g[i][i] = 0;   // 单堆不用合并,得分 0

    for (int len = 2; len <= n; ++len)                 // ① 长度从小到大
        for (int l = 1; l + len - 1 <= 2 * n; ++l) {   // ② 左端点
            int r = l + len - 1;
            for (int k = l; k < r; ++k) {              // ③ 断点:左段 [l,k],右段 [k+1,r]
                int add = s[r] - s[l - 1];             // 本次合并的得分 = 两段总重
                f[l][r] = min(f[l][r], f[l][k] + f[k + 1][r] + add);
                g[l][r] = max(g[l][r], g[l][k] + g[k + 1][r] + add);
            }
        }

    int mn = INF, mx = 0;
    for (int l = 1; l <= n; ++l) {                     // 枚举环的断点 = 长度 n 的每个窗口
        mn = min(mn, f[l][l + n - 1]);
        mx = max(mx, g[l][l + n - 1]);
    }
    cout << mn << "\n" << mx << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
a[i + n] = a[i]破环成链。原环上相邻的两堆,在复制后的链上仍然相邻;而任何一个长度为 n 的窗口都是环的一种“断开方式”。
s[i] = s[i - 1] + a[i]前缀和。区间和 s[r]−s[l−1] 是 O(1),否则在 k 循环里再求和就多一层 O(n),直接 O(n⁴)。
memset(f, 0x3f, sizeof f)0x3f3f3f3f ≈ 1.06×10⁹。每个字节都填 0x3f,int 四字节就得到这个数,是最常见的 INF 写法。
for (int len = 2; len <= n; ++len)长度上限是 n 而不是 2n:只有长度 n 的窗口才对应一个完整的环,更长的区间没有意义。
l + len - 1 <= 2 * n右端点不能越出复制后的数组。真正要用的最长区间是 [l, l+n−1],l ≤ n 时右端点最远到 2n−1,所以开 2n 格刚好够。
int add = s[r] - s[l - 1]最后一个合并动作的得分。它与 k 无关,所以在 k 循环外算一次就够,省一点常数。
f[l][k] + f[k + 1][r] + add两段内部各自的最优代价 + 这一次合并的代价。k 取遍所有切法,min 就是最优断点。
for (int l = 1; l <= n; ++l)环的断点等价于窗口起点。l 从 1 到 n 就把 n 种断法全试了一遍。

第二道题把“区间”换成“子树”。注意这一次没有任何“区间长度”可以枚举,顺序完全由递归调用保证。

例题 · 没有上司的舞会洛谷 P1352普及/提高-去洛谷做这题 ↗

某大学有 n 个职员,编号 1..n,他们的从属关系构成一棵以校长为根的树,父节点是子节点的直接上司。宴会每邀请一个职员都会增加快乐指数 rᵢ,但如果某职员的直接上司来了,这个职员就不肯来。

求邀请哪些职员能使快乐指数之和最大。

数据范围:1 ≤ n ≤ 6×10³,−128 ≤ rᵢ ≤ 127(可以为负)。

输入格式:第一行 n;接下来 n 行每行一个整数 rᵢ;再接下来 n−1 行,每行两个整数 l、k,表示 k 是 l 的直接上司。
输出格式:一行一个整数,最大快乐指数。

输入: 7 1 1 1 1 1 1 1 1 3 2 3 6 4 7 4 4 5 3 5 输出: 5
状态就是“这个人来不来”

约束只发生在直接父子之间。一个职员来不来,只取决于他父亲来不来;爷爷来不来与他无关。所以处理完 u 的子树后,外界只需要知道一件事:u 来了没有。这就是状态的第二维,只有 0 和 1 两个取值。

f[u][0]:u 不来时,u 的子树能贡献的最大快乐值。f[u][1]:u 来时,u 的子树能贡献的最大快乐值。

转移看着父子关系写。u 不来 → 每个儿子 v 来不来都行,取 max(f[v][0], f[v][1]);u 来了 → 儿子一定不能来,只能加 f[v][0]。两句话就是全部转移。

顺序。f[u] 依赖所有 f[v],而 f[v] 依赖 v 的子树——只能用 DFS 后序:先递归每个儿子,回到 u 再累加。这里没有“长度为 1、2、3”可枚举,顺序完全由递归结构给出。

两个边界。叶子没有儿子,求和为空:f[leaf][0] = 0,f[leaf][1] = r[leaf]。根要从入度为 0 的点里找——输入只告诉你“谁是上司”,不会直接告诉你谁是校长。

要注意 r 可以是负数。f[u][1] 直接以 r[u] 起头,不能“负的就不要”,因为选了 u 就是选了 u 这个前提。本题规定关系一定是一棵树,所以不需要判环。

完整可编译代码(C++17)
#include <bits/stdc++.h>
using namespace std;

int n, r[6005];
vector<int> g[6005];        // g[u]:u 的所有直接下属(有向,父 → 子)
int f[6005][2];             // f[u][0] u 不来;f[u][1] u 来
bool hasFa[6005];           // 用来找根:没有上司的人就是根

void dfs(int u) {
    f[u][1] = r[u];         // u 来:先拿到自己的快乐值
    f[u][0] = 0;            // u 不来:自己贡献 0
    for (int v : g[u]) {    // 有向树,边只从父指向子,不会走回头路
        dfs(v);             // ★ 先把儿子的两个值算出来
        f[u][0] += max(f[v][0], f[v][1]);   // u 不来 → 儿子来不来都行
        f[u][1] += f[v][0];                 // u 来了 → 儿子一定不能来
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    for (int i = 1; i <= n; ++i) cin >> r[i];
    for (int i = 1; i < n; ++i) {
        int l, k;                  // k 是 l 的直接上司
        cin >> l >> k;
        g[k].push_back(l);         // 加边 k → l
        hasFa[l] = true;           // l 有上司,不可能是根
    }

    int root = 1;
    for (int i = 1; i <= n; ++i)
        if (!hasFa[i]) { root = i; break; }   // 入度为 0 的点

    dfs(root);
    cout << max(f[root][0], f[root][1]) << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
void dfs(int u)只有一个参数:本题边是有向的父→子,从 u 出发只会往下走,不需要 fa 判回头。无向树才要 fa。
f[u][1] = r[u]; f[u][0] = 0;初始化写成“自己这一份”。r[u] 可能是负数,照样加进去,不能跳过。
for (int v : g[u])遍历所有直接下属。递归调用写在累加之前,是因为累加要用算好的 f[v]。
f[u][0] += max(f[v][0], f[v][1])u 不来时,儿子 v 没有任何限制,两种都合法,取大的。
f[u][1] += f[v][0]u 来了,v 一定不准来,所以只能加 v 不来的那一份。写成 max 是本题最典型的错。
hasFa[l] = true读边时顺手记下“谁有上司”,省掉一次入度统计。
if (!hasFa[i]) { root = i; break; }唯一没有上司的人就是校长。从别的点开始 dfs 会漏掉根之上的部分 → 答案偏小。
max(f[root][0], f[root][1])根来与不来都是合法方案,取大的。
这两个模板还能套在哪

认区间 DP 的信号:答案能由“最后一次操作把序列切成两半”描述;n 大概在 100~500;中间可以插一个断点 k。石子合并、能量项链(环形,同样复制 2n)、最长回文子序列(f[l][r] 看两端是否相等)、括号匹配类,都是这个骨架。

认树形 DP 的信号:答案在树上,子树之间互相独立,约束只在父子之间。这时一定有第二维表示“父/子选没选”,比如选或不选、来或不来、装多少件。没有这一维,父子约束就没法表达。

环形问题的两套写法:①复制成 2n,答案取窗口;②强行分类讨论“第一堆和最后一堆是否合并”,对区间 DP 也能用,但要加一维状态。前者代码短、后者有时更快,初学一律用前者。

树形 DP 的通用写法:先 dfs 儿子,回来再合并,最后更新全局答案。换题时通常只改三处:第二维的含义、初始化(f[u][…] 的起点)、以及儿子那一步怎么并进父亲。

这一节的 6 个坑,按症状记

区间 DP 写成 for (l) for (r = l; r <= n; ++r) for (k):用到还没算的 f[k+1][r] → WA,样例经常照样过,因为小数据正好被前几轮覆盖。

求最小却没给 f 初始化(全 0)→ 输出 0 或明显偏小的数 → WA。0x3f3f3f3f 是最稳的 INF。

环形题忘了复制成 2n,只在 1..n 上做:只算了一个断点 → WA,n 小的时候甚至可能蒙对。

树 DFS 漏了 if (v == fa) continue;:两个点互相调用 → 无限递归 → RE(爆栈),表现为进程直接退出、无任何输出。

合并儿子的语句写在 dfs(v, u) 之前:f[v] 还是初始值 → WA,答案恒定偏小,且能跑完不崩。

数组只按 n 开:n = 100 却开 f[105][105],复制成 2n 后访问 f[200][…] → 越界 → RE 或随机 WA。习惯按“题目上界 × 2 再加几格”开。

自己动手复现

关掉页面,把 P1880 的完整代码从零敲一遍,交上去确认输出 43 和 54。

改一改①:把三层循环改成 l 从 1 到 2n、r 从 l 到 2n、k 在最里层(也就是不再按长度递增),再跑样例。第一行会输出 1061109567——那正是 INF 本身,说明 f 压根没算出来;程序却不报任何错。这就是“顺序即正确性”。

改一改②:拿 P1352 的代码,在 dfs(u) 开头加一句 printf("进入 %d\n", u),在函数末尾加一句 printf("离开 %d\n", u),观察“离开”总是先于父亲——后序就是这么来的。

改一改③:把 P1352 里 f[u][1] += f[v][0] 改成 += max(f[v][0], f[v][1]),样例输出会从 5 变成 7,多出来的正是不该来的上级。

数学补给 · 区间 DP 的转移次数到底是多少

状态数是 n²,但真跑起来不是 n³ 次,而是少得多。数一数三层循环总共进多少次最内层。

固定长度 len,区间有 n − len + 1 个,每个区间的断点有 len − 1 个。总次数:

T(n) = Σlen=2..n (n − len + 1)(len − 1) = C(n+1, 3) = n(n−1)(n+1)/6

代入验证:n = 3 时 C(4,3) = 4;手算 (2)(1) + (1)(2) = 4 ✓。n = 4 时 C(5,3) = 10;手算 (3)(1)+(2)(2)+(1)(3) = 10 ✓。

nC(n+1,3)1 秒内可行?说明
100166650轻松石子合并的实际量级
3004499500很轻松P1775 弱化版的数据
50020833250可以常数要小,别在 k 循环里做加法以外的操作
1000166666500勉强1.7×10⁸,要压常数
20001.33×10⁹不行必须换四边形不等式或决策单调性优化

所以“区间 DP 能过 n ≤ 500 左右”是个可以直接背的数字。环形题因为数组翻倍、len 只到 n,转移次数约是线性版的两倍,n = 100 时仍是 3×10⁵ 量级。

树形 DP 那边则是另一套账:每个节点进一次 dfs,每次把每条出边看一遍,全部加起来 2(n−1) 次循环体。树的 DP 复杂度永远是 O(n)(或 O(n·状态数)),跟深度无关,跟形状也无关。

举一反三 · 想清楚这些再往下走
  1. 把石子合并的得分规则改成“两堆石子数之差”,区间 DP 还成立吗?“最后一步的代价固定为 sum(l,r)”这个性质还在不在?
  2. 如果每次允许合并相邻的 3 堆,len 的起点、k 的含义、cost 各要怎么改?复杂度变了吗?
  3. 树形 DP 里“父亲来了儿子就不能来”是硬约束。若改成“父亲来了,儿子的快乐值减半”,状态还需要加维度吗?
  4. 求“树上一条路径的最大点权和(点权可负)”能不能复用 f[u][0/1]?第二维的含义要换成什么?
  5. P1880 只要求得分。如果要求输出最优的合并顺序,你要多开一维数组,还是只记断点?空间差多少?
  6. 环形问题的“复制成 2n”和“讨论首尾是否合并”两种写法,分别在什么时候更省时间和空间?
自测:能全部回答再进下一节
  • 能默写区间 DP 的三层循环,并说清 len 为什么必须最外层。
  • 能手算 n = 4 的转移次数 10 次,并说出 n = 1000 时为什么撑不住。
  • 能独立写出破环成链的 5 行代码,并解释答案为什么要取长度 n 的窗口。
  • 能说清树形 DP 里 fa 参数的两个作用(防回头、传父亲),以及为什么本题不需要它。
  • 能指出“先合并后递归”会错成什么样,以及为什么它不报错。
  • 给定一道新题,能在 1 分钟内判断它该用区间 DP、树形 DP,还是线性 DP。
挑战题 · 最大子树和
洛谷 P1122普及/提高-去洛谷做这题 ↗

一株花上共连有 n 朵花,n−1 条枝干把它们连成一棵树。每朵花有一个“美丽指数”,可能是负数。

一次“修剪”指删掉一条枝干,于是整株花分成两株,扔掉其中一株。可以进行任意次修剪(也可以不剪),使最后剩下的那一株(也可能只剩一朵花)上所有花的美丽指数之和最大,求这个最大值。

输入格式:第一行一个整数 n(1 ≤ n ≤ 16000);第二行 n 个整数,第 i 个表示第 i 朵花的美丽指数(|aᵢ| ≤ 10⁹);接下来 n−1 行,每行两个整数 a、b,表示 a 与 b 之间有枝干相连。
输出格式:一行一个整数,表示最大美丽指数之和。

数据保证答案的绝对值不超过 2147483647。

引导性提示(先只看这里)

修剪之后剩下的东西一定是树上“连通的一块”,而且它可以看成某棵子树的一部分。

自己定一个根。对每个点问一句:如果剩下的这一块必须包含它,并且只能往它的子树里取,最多能拿多少?

子树里那些“拖后腿”的部分可以整块剪掉——修剪这个操作正好允许你这么做。

想看思路了?点开完整思路

定根。任取 1 号为根,把无向树变成有向的父子关系。

状态。f[u] = 以 u 为根、必须包含 u、且只在 u 的子树内取点的连通块的最大权值和。

转移。f[u] = a[u] + Σv 是 u 的儿子 max(0, f[v])。儿子那棵子树如果整体是负贡献,就连同这条边一起剪掉(这正是一次合法的修剪);是正的才接上来。

答案。剩下的那一块总有一个“最靠上的点” u,它就是那一块在根意义下的最高点,所以它一定被某个 f[u] 算到过。取所有 f[u] 的最大值即可。答案允许只留一朵花,所以初始答案不能是 0 —— 必须从 -∞ 起,否则全是负数时会被 0 顶掉。

复杂度。每个点算一次,每条边过一次 → O(n)。n = 16000 时瞬间跑完。

类型。单点权值到 10⁹,n 到 16000,一块的权值和最大可达 1.6×10¹³,必须用 long long。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int N = 16005;
int n;
long long w[N], f[N], ans = LLONG_MIN;   // f[u]:必须含 u 的连通块最大权值和
vector<int> g[N];

void dfs(int u, int fa) {
    f[u] = w[u];                         // 自己这一份先算上
    for (int v : g[u]) {
        if (v == fa) continue;           // 父亲方向是来的路,跳过
        dfs(v, u);                       // ★ 先递归:等 f[v] 算完
        if (f[v] > 0) f[u] += f[v];      // ★ 负贡献的子树整块剪掉(就是一次修剪)
    }
    ans = max(ans, f[u]);                // 每个点都可能是那一块的最高点
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    for (int i = 1; i <= n; ++i) cin >> w[i];
    for (int i = 1; i < n; ++i) {
        int a, b;
        cin >> a >> b;
        g[a].push_back(b);
        g[b].push_back(a);               // 无向树,两个方向都要存
    }

    dfs(1, 0);                           // 0 是虚点,编号从 1 起
    cout << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
long long w[N], f[N], ans = LLONG_MIN权值到 10⁹、点数到 16000,一块的和可达 1.6×10¹³,int 装不下。ans 用 −∞ 起头,保证全是负数时也能取到“最大的那个负数”。
vector<int> g[N]1.6 万个 vector,总边数 3.2 万条,内存不到 1 MB。
dfs(int u, int fa)无向树必须有 fa:否则 u→v→u 来回走,直接爆栈。
f[u] = w[u]初始化就是“只留自己这一朵”,这是一切连通块的起点。
if (f[v] > 0) f[u] += f[v]正贡献接上,负贡献剪掉。这一行同时完成了“取或不取这棵子树”的决策,所以不需要再套一层 0/1 背包式的循环。
ans = max(ans, f[u])答案不是 f[1]:只留一朵花、或最高的那朵在中途时,最高点未必是根。
g[a].push_back(b); g[b].push_back(a);边是双向的。只存一次会有很多点根本走不到 → 答案偏小,且能跑完不报错。
这题最容易错的地方
  • f、w、ans 用 int:n = 16000 且权值都是 10⁹ 时和到 1.6×10¹³,溢出后变成负数 → 大测试点全挂(WA)。
  • ans 初始化为 0:所有花都难看(全负)时正确答案是最大的那个负数,0 会把它顶掉 → WA。
  • 漏写 if (v == fa) continue;:无向树会两点之间来回递归 → RE(爆栈)。
  • 无向边只 push 一次:树被当成有向图,一部分子树永远访问不到 → WA,且不崩不报错。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》区间 DP 与树形 DP 两节(环形处理、子树合并)
  • 《算法竞赛试炼场:洛谷300题精析》DP 提高篇
  • 洛谷 P1880、P1352、P1122 题解区的高赞实现
34 状态压缩 DP 与数位 DP提高

34 · 状态压缩 DP 与数位 DP

两种“变形”的 DP:状压把集合压进一个 int 的低 n 位,按 S 递增枚举就自带拓扑序;数位 DP 把数字当字符串逐位填,用 pos / limit / lead 三个参数把 10¹⁸ 的区间压成一千次转移。
提高预计 75 分钟状压 DP数位 DP位运算
本节出现的新函数 / 新语法(先看懂再用)

前面几节的 DP 状态都是一个下标或一对下标。这一节的两种 DP 都换了“状态的形状”:状压把“用过哪些东西”压进一个 int 的二进制位;数位 DP把数字当成字符串,从高位往低位一位一位填。记忆化的套路完全一样,只是“状态长什么样”变了。

状压 DP 的复杂度只看两个数:状态数 2ⁿ × 状态里那“最后一个是谁”,以及每个状态往外转几次。n ≤ 15 是舒适区,n = 20 是天花板。
状态压缩 DP数位 DP
状态一个 int 的低 n 位表示“选过哪些元素”填到第几位、上一位是什么、有没有顶到上界
转移往集合里加 / 删一个元素,或把集合拆成两个子集这一位填 0..9,往低位递推
顺序S 从小到大(新状态一定更大)记忆化 dfs,从高位往低位
规模2ⁿ × n,n ≤ 20位数 × 附加状态 × 10,一般不到 10⁴
典型题TSP、关灯、棋盘放置区间内满足数位条件的计数

这一节的全部代码都建立在位运算上。三个工具先拆开:位运算本身、数 1 的个数、枚举子集。

新函数拆解 · 位运算六件套(把 int 当集合用)
(x >> k) & 1 取第 k 位 x | (1 << k) 第 k 位置 1(加入集合) x & ~(1 << k) 第 k 位清 0(移出集合) x ^ (1 << k) 第 k 位翻转 x & (x - 1) 去掉最低位的 1 x & (-x) 取出最低位的 1(lowbit)
组成含义 / 格式要求
(x >> k) & 1取第 k 位(k 从 0 数),结果一定是 0 或 1。直接写 x & (1<<k) 得到的是 0 或 2ᵏ,当布尔用还行,拿去做下标就会越界。
x | (1 << k)置 1:把 k 加入集合。本来就在也不会出错,所以可以无脑用。
x & ~(1 << k)清 0:把 k 移出集合。不能写 x − (1<<k),那只在该位本来是 1 时才对。
x ^ (1 << k)翻转:0↔1。只适合“按一次就取反”的效果,别拿去当删除用。
x & (x − 1)去掉最低位的 1。可以用来数 1 的个数,也能判 2 的幂:x && !(x & (x−1))。
x & (−x)lowbit:单独取出最低位的 1,树状数组靠它定位。
优先级x & 1 == 0 是错的:== 先算,等价于 x & (1==0) = 0,条件恒假。必须写 (x & 1) == 0。
1 << k 的类型字面量 1 是 int,k ≥ 31 时溢出(未定义行为)。数组下标用不到那么大,但做哈希或乘法时要写 1LL << k。
表示范围n = 15 时掩码最大 2¹⁵−1 = 32767,int 绰绰有余;n ≥ 31 就得换 unsigned 或 bitset。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int main() {
    int S = 0b0101;                     // 集合 {0, 2}
    S |= 1 << 3;                        // 加入 3      → 0b1101
    S &= ~(1 << 0);                     // 移出 0      → 0b1100
    printf("%d\n", (S >> 2) & 1);       // 第 2 位:1
    printf("%d\n", S & (S - 1));        // 去掉最低位的 1 → 0b1000 = 8
    for (int T = S; T; T &= T - 1)      // 逐位遍历所有 1
        printf("%d ", __builtin_ctz(T));// 输出 2 3
    return 0;
}
注意

掩码的位是从 0 数的:第 0 位对应第 1 个元素。所有“差一位”的 bug 都从这里来,写完先打印一次 2ⁱ 对照。

用 unsigned 做位运算可以避开符号位的麻烦;本节 n ≤ 15,int 不会有问题。

新函数拆解 · __builtin_popcount(数 1 的个数)
int __builtin_popcount(unsigned int x); int __builtin_popcountll(unsigned long long x); int __builtin_ctz(unsigned int x); // 最低位 1 的下标
组成含义 / 格式要求
__builtin_popcount(S)返回 S 的二进制里 1 的个数,也就是集合大小。状压里常用来按集合大小分层,或判断 S 是否只有一个元素。
参数是 unsigned传负数会按 2³² 补码解释,结果大得离谱。只在非负掩码上用。
__builtin_popcountll数 64 位掩码用这个,多两个字母 l 表示 long long。传 int 进 popcountll 也没问题。
__builtin_ctz(S)count trailing zeros:末尾 0 的个数,正好是最低位 1 的下标。S = 0 时未定义,调用前必须保证 S ≠ 0。
__builtin_clz(S)前缀 0 的个数,配合 31 可以反推最高位 1 的位置。同样要求 S ≠ 0。
复杂度有 popcnt 指令时是 1 条指令;没有时是几次位运算。与位数无关,放进最内层循环也不怕。
手写等价pc[S] = pc[S >> 1] + (S & 1),先算小的 S,就能 O(1) 算出 0..2ⁿ−1 的全部 popcount。
可移植性这是 GCC 扩展,洛谷用 GCC 可以直接用;换 MSVC 要写 __popcnt,比赛前先确认编译器。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int main() {
    for (int S = 0; S < 8; ++S)
        printf("%d -> %d\n", S, __builtin_popcount(S));   // 0..3 个 1
    printf("%d\n", __builtin_popcountll(1LL << 40));      // 1(64 位里只有一个 1)
    printf("%d\n", __builtin_ctz(12));                    // 2(12 = 0b1100)

    int pc[8];                                            // 手写版:一遍递推
    pc[0] = 0;
    for (int S = 1; S < 8; ++S) pc[S] = pc[S >> 1] + (S & 1);
    printf("%d\n", pc[7]);                                // 3
    return 0;
}
注意

popcount 只回答“有几个”,不回答“是哪些”。要取元素本身,用 __builtin_ctz 或 lowbit 循环。

别在循环条件里反复调用 __builtin_popcount 去判断集合大小,先在状态设计上分层更快。

新函数拆解 · 枚举子集(submask 遍历)
for (int sub = mask; sub; sub = (sub - 1) & mask) { /* 处理 sub */ }
组成含义 / 格式要求
sub依次取出 mask 的每一个非空子集,按数值从大到小。
sub = (sub − 1) & mask核心一句。sub−1 把最低位的 1 变 0、更低位全变 1;再 & mask 把不属于 mask 的位清掉,得到逻辑上的“下一个更小的子集”。
循环条件写 sub不要写 sub >= 0:sub 变 0 时循环自动结束,所以循环体里永远见不到空集。
要空集怎么办循环外单独处理 sub = 0,或写成 for (int sub = mask;; sub = (sub−1) & mask) { ...; if (!sub) break; }。
总次数对所有 mask 求和是 Σ 2^popcount(mask) = 3ⁿ。n = 15 时 1435 万次,可以;n = 20 时 3.5×10⁹,不行。
典型用途子集 DP:把当前集合拆成 sub 与 mask^sub 两半各自最优再合并,例如集合划分、最小覆盖。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int main() {
    int mask = 0b1011;                        // 集合 {0, 1, 3}
    for (int sub = mask; sub; sub = (sub - 1) & mask)
        printf("%d(%d) ", sub, __builtin_popcount(sub));
    // 输出:11(3) 10(2) 9(2) 8(1) 3(2) 2(1) 1(1)
    printf("\n");
    return 0;
}
注意

sub = (sub−1) & mask 里的 mask 是母集,不是 sub。写成 (sub−1) & sub 就是普通的“去掉最低位 1”,只得到单条链,漏掉绝大多数子集。

这个循环的状态数只有 3ⁿ 而不是 4ⁿ,是因为每个元素只有“在 mask 外 / 在 sub 里 / 在 mask 但不在 sub 里”三种身份。MATH 里有推导。

第一道题是状压 DP 的本体:n 个东西、每个只能取一次、要排一个最优顺序。先想清楚“暴力为什么必死”,再想“哪一部分信息其实没用”。

例题 · 吃奶酪洛谷 P1433普及/提高-去洛谷做这题 ↗

房间里放着 n 块奶酪,一只小老鼠要把它们都吃掉,问至少要跑多少距离?老鼠一开始在 (0,0) 点处。

数据范围:1 ≤ n ≤ 15,|xᵢ|,|yᵢ| ≤ 200(实数,小数点后最多 3 位)。

输入格式:第一行整数 n;第 2 到第 n+1 行,每行两个实数 xᵢ、yᵢ,表示第 i 块奶酪的坐标。
输出格式:一行一个实数,最少距离,保留 2 位小数。

输入: 4 1 1 1 -1 -1 1 -1 -1 输出: 7.41
状态里必须记“吃了哪些”和“最后一口在哪”

先算暴力。奶酪的吃法有 n! 种,n = 15 时约 1.3×10¹²。每条路都要算 15 段距离,直接死刑。

再看信息量。走到中途时,“接下来怎么走最省”只取决于两件事:还有哪些没吃(集合),以及现在站在哪(最后吃的那块)。至于之前是什么顺序吃的,与未来完全无关——这就是可以合并的状态。

集合怎么存。n ≤ 15,用一个 int 的低 n 位表示:第 i 位为 1 表示第 i 块已经吃过。全部吃完是 full = (1<<n) − 1,状态总数 2¹⁵ = 32768。

状态与转移。f[S][i]:已吃集合是 S、最后一口是 i 的最小路程。从 (S,i) 出发,挑一块没吃的 j,转移 f[S | 1<<j][j] = min(…, f[S][i] + d(i,j))。

为什么不用管顺序。新状态 S | (1<<j) 的数值一定大于 S(多了一个 1),所以按 S 从小到大枚举,每个状态在被用到之前一定已经算完。这就是状压 DP 不需要拓扑排序的原因:二进制数值本身就是拓扑序。

规模。状态 2¹⁵ × 15 = 491520,每状态转移 15 次 → 7.4×10⁶ 次加法。开方只在预处理两两距离时做,所以整个程序在一瞬间跑完。

完整可编译代码(C++17)
#include <bits/stdc++.h>
using namespace std;

int n;
double x[16], y[16];
double d[16][16];                  // d[i][j]:编号 i 与 j 的直线距离
double f[1 << 15][15];             // f[S][i]:已吃集合 S、最后一口在 i 的最小路程

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    x[0] = y[0] = 0;                               // 0 号点就是原点,当作起点
    for (int i = 1; i <= n; ++i) cin >> x[i] >> y[i];

    for (int i = 0; i <= n; ++i)                   // 两两距离只跟坐标有关,先算成表
        for (int j = 0; j <= n; ++j) {
            double dx = x[i] - x[j], dy = y[i] - y[j];
            d[i][j] = sqrt(dx * dx + dy * dy);     // 开方只做 (n+1)² 次,别放进转移里
        }

    int full = (1 << n) - 1;                       // 全部吃完的集合
    for (int S = 0; S <= full; ++S)
        for (int i = 0; i < n; ++i) f[S][i] = 1e18;   // 求最小,INF 要足够大

    for (int i = 0; i < n; ++i)
        f[1 << i][i] = d[0][i + 1];                // 第一口:从原点直接过去(注意 +1 的下标错位)

    for (int S = 1; S <= full; ++S)                // S 递增天然就是拓扑序
        for (int i = 0; i < n; ++i) {
            if (!(S >> i & 1)) continue;           // i 不在集合里,这个状态无意义
            for (int j = 0; j < n; ++j) {
                if (S >> j & 1) continue;          // j 已经吃过,不能重复吃
                int T = S | (1 << j);              // 新集合:把 j 加进去
                double nd = f[S][i] + d[i + 1][j + 1];
                if (nd < f[T][j]) f[T][j] = nd;    // 松弛
            }
        }

    double ans = 1e18;
    for (int i = 0; i < n; ++i) ans = min(ans, f[full][i]);
    printf("%.2f\n", ans);
    return 0;
}
逐行拆解
代码片段含义与理由
x[0] = y[0] = 0把起点也放进坐标数组的第 0 号位置,于是“奶酪编号”统一变成“坐标编号 −1”,转移时只需要 +1,不必到处写特判。
d[i][j] = sqrt(dx*dx + dy*dy)两两距离是定值。放进转移里算会多做 7×10⁶ 次开方,白白慢十倍。
double f[1 << 15][15]2¹⁵ × 15 × 8 字节 ≈ 3.9 MB。放全局(静态区),别放 main 里,否则可能爆栈。
f[S][i] = 1e18求最小值必须初始化为极大的数。1e18 大于任何合法答案,又不会和别的数相加后溢出成负数。
f[1 << i][i] = d[0][i + 1]只吃一块的初始状态。i 是 0 基的奶酪下标,坐标数组是 1 基,这个 +1 是全题最容易错的地方。
if (!(S >> i & 1)) continue;S 里没有 i,f[S][i] 这个状态根本没有定义,跳过。
int T = S | (1 << j);加入 j。因为 T > S,外层 S 从小到大枚举时,T 一定在 S 之后才被用到。
S >> j & 1等价于 (S >> j) & 1:>> 的优先级高于 &,所以不用加括号也是对的;但为了可读性建议加。
printf("%.2f\n", ans)题目要求 2 位小数。最多累加 15 次,double 的误差远小于 0.005,不需要 long double。

第二类问题换了个问法:不问“最优”,问“有多少个”。n 可以到 10¹⁸,但位数只有 19 位——所以不枚举数,枚举“填法”。

新函数拆解 · 数位 DP 的三参数(pos / limit / lead)
long long dfs(int pos, bool limit, bool lead /* , 附加状态 */) { if (pos == 0) return 1; if (!limit && !lead && f[pos][...] != -1) return f[pos][...]; int up = limit ? a[pos] : 9; long long res = 0; for (int d = 0; d <= up; ++d) res += dfs(pos - 1, limit && d == up, lead && d == 0); if (!limit && !lead) f[pos][...] = res; return res; }
组成含义 / 格式要求
pos还剩几位没填。从最高位往低位填,pos == 0 表示所有位都填完,返回 1(这一条填法算一个合法数)。
limit是否贴着上界。为真时这一位最多填到 a[pos];为假时 0..9 全放开。
lead前缀是否全为 0(还没开始真正的数字)。为真时不检查“上一位”,也不把前导零算进数字里。
int up = limit ? a[pos] : 9;limit 唯一的作用点。这一行决定了整棵搜索树的宽度。
limit && d == up只有本来就顶格、且这一位也顶到上界,下一位才继续顶格。写反或漏写 → 统计到超过 n 的数。
lead && d == 0一直填 0 才是前导零。一旦填了非零数字,lead 永久变 false,之后再出现的 0 就是真正的 0。
!limit && !lead 才记忆只有“自由身”的状态才与具体上界无关,可以共用答案。带 limit 的状态依赖 n 的某几位,一共只有 O(位数) 个,不记也不亏。
f 用 −1 做哨兵用 0 分不清“没算过”和“答案是 0”。所以记忆化数组要 memset 成 −1。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int a[15];              // n 的各位,低位在下标 1
long long f[15];        // f[pos]:还剩 pos 位、不顶格不限前导零时的填法数

long long dfs(int pos, bool limit, bool lead) {
    if (pos == 0) return 1;                                   // 填完了,一条填法
    if (!limit && !lead && f[pos] != -1) return f[pos];        // 自由身才查表
    int up = limit ? a[pos] : 9;                               // 顶格只能到上界
    long long res = 0;
    for (int d = 0; d <= up; ++d)
        res += dfs(pos - 1, limit && (d == up), lead && (d == 0));
    if (!limit && !lead) f[pos] = res;                         // 自由身才写表
    return res;
}

int main() {
    int n = 123, len = 0;
    for (int t = n; t; t /= 10) a[++len] = t % 10;              // 拆位,低位在前
    memset(f, -1, sizeof f);
    cout << dfs(len, true, true) << "\n";                       // 124 = n + 1,因为 0 也被数进去了
    return 0;
}
注意

这个骨架不限于“数数”,把出口和“这一位能不能填 d”的判断换掉,就是不同的题。

lead 不是可有可无的装饰:没有它,首位会被拿去和“上一位 = 0”比较,正确答案会差一大截。

例题 · windy 数洛谷 P2657提高去洛谷做这题 ↗

不含前导零、且相邻两个数字之差至少为 2 的正整数称为 windy 数。例如 1、13、24 是,而 12、10 不是。

给定 a 和 b,求区间 [a, b] 内 windy 数的个数。

数据范围:1 ≤ a ≤ b ≤ 2×10⁹。

输入格式:一行两个整数 a、b。
输出格式:一行一个整数,表示答案。

输入: 1 10 输出: 9
把区间拆成两个前缀,再一位一位填

区间转前缀。记 solve(n) 为 [0, n] 内 windy 数的个数,答案就是 solve(b) − solve(a−1)。把“区间”变成“前缀”,是把上界从两个降到一个,这一步是所有数位 DP 的固定开场。

逐位确定。把 n 的各位拆出来,从最高位往低位填。填到某一位时只有两种处境:前缀和 n 一模一样(limit = true,这一位最多填到 n 的对应位),或者前缀已经比 n 小了(limit = false,这一位 0..9 随便填)。

为什么还要 lead。windy 数不允许前导零。更实际的问题是:首位没有“上一位”,没法算 |d − prev|。lead = true 表示前缀全 0、真正的数字还没开始,这时跳过相邻差检查,也可以继续填 0。

状态与记忆化。附加状态只有 prev(上一位数字),于是 f[pos][prev] 表示“还剩 pos 位、上一位是 prev、且不顶格不限前导零”的合法填法数。带 limit 或 lead 的状态不记忆——它们依赖具体前缀,数量只有 O(位数) 个。

规模。状态 10 × 10 = 100 个,每个转 10 次 → 一千次出头。b 到 2×10⁹(10 位)也能瞬间跑完,这正是数位 DP 存在的意义:复杂度只看位数,不看数值大小。

0 的处理。上面这套会顺带把“0”数进去(全填 0 也算一条填法)。因为 a ≥ 1,solve(b) 和 solve(a−1) 各含 0 一次,相减时正好抵消,不需要额外特判。

完整可编译代码(C++17)
#include <bits/stdc++.h>
using namespace std;

int a[15];                 // n 的各位,a[1] 是个位
long long f[15][10];       // f[pos][prev]:还剩 pos 位、上一位是 prev 的自由填法数

// pos:还剩几位;prev:上一位数字;limit:是否贴住上界;lead:前缀是否全 0
long long dfs(int pos, int prev, bool limit, bool lead) {
    if (pos == 0) return 1;                                   // 每一位都填完了
    if (!limit && !lead && f[pos][prev] != -1) return f[pos][prev];
    int up = limit ? a[pos] : 9;                               // 顶格时最多到上界那一位
    long long res = 0;
    for (int d = 0; d <= up; ++d) {
        if (!lead && abs(d - prev) < 2) continue;              // 不是首位,且与上一位差不足 2
        res += dfs(pos - 1, d, limit && (d == up), lead && (d == 0));
    }
    if (!limit && !lead) f[pos][prev] = res;                   // 只有自由身才写表
    return res;
}

long long solve(int n) {
    if (n < 0) return 0;
    int len = 0;
    while (n) { a[++len] = n % 10; n /= 10; }
    return dfs(len, 0, true, true);       // n = 0 时 len = 0,dfs 立刻返回 1,正好把 0 数上
}

int main() {
    int A, B;
    cin >> A >> B;
    memset(f, -1, sizeof f);
    cout << solve(B) - solve(A - 1) << "\n";   // 0 在两边各出现一次,相减自动抵消
    return 0;
}
逐行拆解
代码片段含义与理由
while (n) { a[++len] = n % 10; n /= 10; }取模得到最低位,所以 a[1] 是个位、a[len] 是最高位。dfs 从 len 往 1 走,就是“从高位往低位填”。
dfs(len, 0, true, true)四个初值各有含义:还剩 len 位、上一位随便给 0(反正 lead 为真、不用它)、贴着上界、还是前导零。
if (pos == 0) return 1;所有位填完,累计出一个合法数。返回 1 表示“这里有一种填法”,父亲把它累加进 res。
if (!limit && !lead && f[pos][prev] != -1)查表条件必须和写表条件完全一致。只查不写是纯暴力,只写不查会污染答案,两者都慢或错。
int up = limit ? a[pos] : 9;顶格时这一位最多填 a[pos];一旦不顶格,后面的位再大也不会超过 n,所以全放开。
if (!lead && abs(d - prev) < 2) continue;windy 的定义。lead 为真时跳过,因为前面还没有真正的数字可比。
limit && (d == up)两个条件缺一不可:本来就得顶格,且这一位刚好填到上限,下一位才继续受限制。
if (!limit && !lead) f[pos][prev] = res;只在自由身时写表。把顶格状态也写进去,后面自由状态会查到错值 → WA,而且样例可能照样过。
两个模板的变形清单

状压的三处可换零件:①集合里放什么(吃过哪些 / 每列放到第几行 / 哪些点已连通);②转移动作(加一个元素、换一个元素、把集合拆成两半);③答案取哪个状态(全部吃完 / 某个终点 / 所有状态的极值)。

数位 DP 的三处可换零件:①附加状态(上一位是什么、数字和、是否出现过某数字、是否已有连续 3 个相同);②出口返回什么(个数 1、和、还是要返回一对值);③非法填法在哪里剪掉(循环里 continue 最快)。

带“前导零”和“上界”的题一定有两个坑:lead 传错会让首位被误判;limit 记忆化会让答案被一个“贴着上界”的错值污染。写完先拿 solve(9)、solve(10) 这类小值手动对一遍。

什么时候用枚举子集:当转移需要“把当前集合分成两块、各自最优再合并”时。如果题目只是“一个一个往里加元素”,那就是普通的 2ⁿ·n 状压,用不上 3ⁿ 的那层循环。

这一节的 6 个坑,按症状记

掩码位记错一位(把第 i 个元素写成 1<<(i+1)):WA 或答案少一半;n = 15 时还可能访问 1<<15 这一个越界状态 → RE。写完先打印每个单元素掩码对照。

状压 f 没初始化成 INF(全局数组默认 0):min 永远得到 0,输出 0.00 → WA。

把 1<<n 当成常量写死在数组里:n 变了就少一半空间;按 n 的最大值开(如 1<<15),不要按输入。

数位 DP 把带 limit 的状态也写进 f:之后自由状态查到这个被上界限制过的错值 → WA,且小数据往往查不出来。

lead 处理错(该跳过时去比较,或忘记 lead 直接算):前者答案偏小,后者把首位当 0 比较,连 [1,10] 这种样例都会错。

把 1<<k 用在 k ≥ 31 的地方,或给 __builtin_popcount 传负数:前者未定义行为,后者按补码数出一堆 1 → 拿去做数组下标直接 RE。

自己动手复现

关掉页面,把 P1433 的完整代码从零敲一遍,n = 4 时确认输出 7.41。

改一改①:把 f[S][i] 的第二维去掉,只留 f[S],然后跑样例。它会输出一个偏小的错值——亲手体验“最后一口在哪”为什么非记不可。

改一改②:把外层循环换成“按 __builtin_popcount(S) 从小到大”枚举(外层 k = 1..n,内层 S,popcount 不等于 k 就 continue),答案不变。这说明真正的依赖关系是“每次只多一个元素”,数值递增只是实现它的最省事写法。

改一改③:在 P2657 的 dfs 开头加一句 ++cnt,输出 cnt 和“状态总数 × 10”对照,亲眼看到它只有几千而不是 2×10⁹。

数学补给 · 2ⁿ 与 3ⁿ:状压的两条复杂度线

状压 DP 的费用永远是“状态数 × 每个状态的转移数”。状态数 = 2ⁿ ×(状态里那几维),转移数通常是 n,所以总量是 2ⁿ·n² 或 2ⁿ·n。先把这张表记住:

n2ⁿ2ⁿ·n3ⁿ判决
1010241.0×10⁴59049随便做
15327684.9×10⁵1.4×10⁷2ⁿ 轻松;3ⁿ 也还行
182621444.7×10⁶3.9×10⁸2ⁿ 稳;3ⁿ 超时
2010485762.1×10⁷3.5×10⁹2ⁿ·n² 已 4×10⁸,要压常数
24167772164×10⁸2.8×10¹¹内存和时间都爆

3ⁿ 是怎么来的。枚举子集的总次数是 Σmask 2popcount(mask)。按 popcount 分组:有 C(n,k) 个 mask 含 k 个 1,每个枚举 2ᵏ 个子集,于是

Σk C(n,k)·2ᵏ = (1 + 2)ⁿ = 3ⁿ(二项式定理,取 x = 1、y = 2)

这也解释了 3ⁿ 比 4ⁿ 便宜在哪:所有 (sub, mask) 对是 4ⁿ 种,加上 sub ⊆ mask 这条约束后只剩 3ⁿ 种——每个元素只有三种身份:不在 mask 里、在 mask 里但不在 sub 里、在 sub 里。

另一个常用恒等式。所有子集的元素个数之和 ΣS |S| = n·2n−1:每个元素恰好出现在一半的子集里。所以“对所有子集求大小之和”不需要枚举,直接套公式。

数位 DP 的费用则是另一个量级。状态数 = 位数 × 附加状态,转移固定 10 次。以 windy 为例:10 位 × 10 种 prev = 100 个状态,总转移约 10³ 次。b 从 10⁹ 涨到 10¹⁸ 只是位数从 10 变成 19,费用翻一倍都不到——这就是“复杂度只看位数”的含义。

举一反三 · 想清楚这些再往下走
  1. n = 20 时 f[2²⁰][20] 要 168 MB,已经危险。如果题目只问最短总长度、不问路径,最后一维能不能砍掉?为什么不能轻易砍?
  2. 如果题目改成“必须按给定顺序吃奶酪”,还需要状压吗?状态能简化成什么?
  3. 若奶酪之间只能沿网格走曼哈顿距离,状态要改吗?这题的状态设计里,哪一部分依赖“距离的定义”?
  4. 求 [a,b] 中“各位数字之和 mod 3 = 0”的数的个数:附加状态加什么?记忆化数组开多大?为什么不能只记 pos?
  5. 求 [a,b] 中“含数字 4 或含子串 62”的数的个数,用补集怎么算?为什么直接算比补集麻烦?
  6. 上一节的石子合并 n = 100,能不能用状压做?把状态写成“哪些堆已经合并”需要多少位?这说明选题时要先估什么?
  7. 把 __builtin_popcount 换成手写递推 pc[S] = pc[S>>1] + (S&1),在 2ⁿ 全部预处理时哪个更快?为什么?
自测:能全部回答再进下一节
  • 能说出 2¹⁵ 等于多少,并估出 f[1<<15][15] 的 double 数组占多少内存。
  • 能解释为什么状压 DP 按 S 递增枚举就是合法顺序,不需要额外排序或拓扑。
  • 能默写“加入元素、移出元素、判断元素在不在”三个位运算,并说清 x & 1 == 0 错在哪。
  • 能手写 popcount 的 O(1) 递推版,并解释它凭什么只查一次表。
  • 能说清 limit 和 lead 各自防的是什么错,写漏一个会发生什么。
  • 能解释为什么“顶格”和“前导零”的状态不能写进记忆化表。
  • 能手算 windy 数在 [1,10] 的答案是 9,并指出 10 不合法是因为 |1−0| = 1 < 2。
挑战题 · 关灯问题 II
洛谷 P2622普及/提高-去洛谷做这题 ↗

现有 n 盏灯和 m 个按钮。按下第 i 个按钮会同时作用在所有灯上,对第 j 盏灯的效果 aᵢⱼ 是三种之一:为 1 时,若这盏灯亮着就关掉,否则不动;为 −1 时,若这盏灯灭着就点亮,否则不动;为 0 时不动。

开始时所有灯都是亮的。求最少按几次按钮能让所有灯都灭掉;做不到输出 −1。

输入格式:第一行两个整数 n、m;接下来 m 行,每行 n 个整数,第 i 行第 j 个数是 aᵢⱼ。
输出格式:一行一个整数,表示最少次数,或 −1。

数据范围:1 ≤ n ≤ 10,1 ≤ m ≤ 100。

引导性提示(先只看这里)

“所有灯的开关情况”一共只有 2ⁿ 种,n ≤ 10,可以用一个 0..1023 的整数把它表示出来。

每个按钮的效果可以在一开始就翻译成两个掩码:哪些灯会被强制关掉、哪些会被强制点亮。之后按一次就是一次位运算。

每次按按钮的代价都是 1,要求最少次数——这是无权图上的最短路。

想看思路了?点开完整思路

状态。用 int S 表示当前灯的情况,第 j 位为 1 表示第 j 盏灯亮着。初始 S = (1<<n) − 1,目标 S = 0。

把按钮翻译成掩码。对第 i 个按钮预处理两个整数:clr[i] 记下所有 aᵢⱼ = 1 的位(这些灯按完一定是灭的),st[i] 记下所有 aᵢⱼ = −1 的位(这些灯按完一定是亮的)。aᵢⱼ = 0 的位谁也不记,保持不变。

转移就是一句位运算。T = (S & ~clr[i]) | st[i]:先强制清掉该灭的位,再强制置上该亮的位。为什么“强制”是对的:a = 1 时无论灯原来亮还是灭,结果都是灭;a = −1 时无论原来如何,结果都是亮。

算法。每条边权为 1,用 BFS 从 full 出发,dist 数组记最少按几次。dist 初始化为 −1,没被访问到的点自然是 −1,正好对上“无解输出 −1”。

复杂度。状态 2ⁿ ≤ 1024 个,每个状态试 m ≤ 100 个按钮 → 10⁵ 次位运算,可以忽略不计。

为什么不能贪心。按下按钮可能先把某些灯点亮,暂时变差但后面更省;BFS 把所有状态都试过,才敢说“最少”。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int n, m;
int clrMask[105], setMask[105];   // 每个按钮翻译成的两个掩码
int dist[1 << 10];                // dist[S]:从“全亮”走到 S 最少按几次

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> m;
    for (int i = 1; i <= m; ++i)
        for (int j = 0; j < n; ++j) {
            int a;
            cin >> a;
            if (a == 1) clrMask[i] |= 1 << j;         // 按完这盏一定是灭 → 记住“要清掉”
            else if (a == -1) setMask[i] |= 1 << j;   // 按完这盏一定是亮 → 记住“要置上”
            // a == 0:这一位两边都不记,按完保持不变
        }

    int full = (1 << n) - 1;          // 初始:所有灯都亮
    memset(dist, -1, sizeof dist);    // −1 同时表示“没访问过”,省掉 vis 数组
    queue<int> q;
    dist[full] = 0;
    q.push(full);

    while (!q.empty()) {
        int S = q.front();
        q.pop();
        if (S == 0) break;            // 已经全灭,不用再扩展
        for (int i = 1; i <= m; ++i) {
            int T = (S & ~clrMask[i]) | setMask[i];   // 一次位运算完成“该灭的灭、该亮的亮”
            if (dist[T] == -1) {                      // 每个状态只入队一次
                dist[T] = dist[S] + 1;
                q.push(T);
            }
        }
    }

    cout << dist[0] << "\n";          // 走不到就是 −1,正好是题目要的输出
    return 0;
}
关键片段拆解
片段作用
clrMask[i] |= 1 << j把“按第 i 个按钮会让第 j 盏灯灭”记成一个掩码。预处理的好处是运行时只做位运算,不再判分支。
(S & ~clrMask[i]) | setMask[i]整个转移只有这一句。先 & ~clr 强制清位,再 | set 强制置位,顺序不能反——反了会被后一步覆盖。
memset(dist, -1, sizeof dist)int 数组按字节填 0xFF 得到 −1。用 0 初始化就分不清“没访问过”和“距离是 0”,会重复入队到 TLE。
int full = (1 << n) - 1n 盏灯全亮。起点是它、终点是 0,写成反向也行,但要记得输出换成 dist[full]。
if (S == 0) break;到达终点就能收工。BFS 第一次弹出 0 时,dist[0] 已经是答案。
cout << dist[0] << "\n";无解时 dist[0] 一直是 −1,输出 −1 与题目要求完全一致,不需要额外判断。
这题最容易错的地方
  • 把 a = 1 当成“翻转”(写 T ^= 1 << j):原题是“亮着才关”,本来就灭的灯被翻成亮,状态全错 → WA。
  • 两个掩码的作用顺序写反成 (S | setMask) & ~clrMask:该亮的位被清掉 → WA。
  • dist 用 0 初始化而不是 −1:无法区分未访问,队列里反复入队 → TLE,或者直接输出 0。
  • 数组只开 1 << 9:n = 10 时最大状态是 1023,正好越界 → RE。按 n 的上界开。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》状态压缩 DP 一节(TSP 与子集 DP)
  • 《算法竞赛黄金法则:提高算法和思考力的77项技巧》关于“把状态编码成整数”的章节
  • 洛谷 P1433、P2657、P2622 题解区的高赞实现
35 模拟赛一:120 分钟 4 题普及/提高-

35 · 模拟赛一:120 分钟 4 题

用一场 120 分钟的限时模拟赛收束第 1~34 节:贪心加堆、双指针、二分答案、记忆化搜索各一题,配时间分配表、四张必背模板卡、丢分症状清单与赛后复盘方法。
模拟赛普及/提高-限时 120 分钟模拟赛4 题

这一节不讲新知识。4 道题、120 分钟,考第 1~34 节里复用率最高的四件事:贪心 + 堆(第 17 节)、双指针(第 19 节)、二分答案(第 27 节)、记忆化搜索(第 22 节)。题目都是洛谷真题,代码只用你已经学过的东西写。本场满分 400 分,每题 100 分;除 A 题外都有部分分可拿。

模拟赛暴露的第一件事,永远不是“我不会什么”,而是“我把 35 分钟花在了一道只能拿 30 分的题上”。

先看清单:四道题分别压在哪几节上。卡住时不要顺着题号往下问,直接回来查对应的节。

题号题目对应节一句话考点建议用时
AP1091 合唱队形第 30、31 节正反两遍 LIS,再合并22 分钟
BP1638 逛画展第 19 节窗口覆盖全部种类 → 双指针26 分钟
CP1182 数列分段第 27 节最小化最大值 → 二分答案32 分钟
DP1434 滑雪第 22 节网格上求最长下降路 → 记忆化24 分钟

四道题的难度都是普及/提高-,没有难题,但每题都有一个“想不到就 0 分、想到就 5 分钟”的关键点。这场的真正对手是时间。

120 分钟怎么切

总原则:先把手速题的分全额拿到,再回头啃需要想的题。题号不代表难度顺序,但本场特意把最省时间的 A 放在最前面,你必须在前 28 分钟内交掉它——A 题交得晚,后面三道题的心态就全乱了。

时间段做什么关键动作
0 ~ 8通读 4 题 + 排序在纸上给每道题标:会做 / 有思路 / 完全不会。这 8 分钟不许写代码。
8 ~ 28A 合并果子(20)难度最低,必须一次写对。测样例 + 自造 1 组再交。
28 ~ 54B 逛画展(26)双指针边界是本场最容易错的地方,写完先手算一遍样例。
54 ~ 86C 数列分段(32)二分答案:区间、check、防溢出三件事都要想全再动手。
86 ~ 110D 滑雪(24)记忆化:只多两行,但漏了就是 TLE。到点就停。
110 ~ 120检查只查数组大小、long long、输出格式,不改算法。
  • 单题上限 = 建议用时,到点没 AC 就换题,剩下时间再回来。
  • 每题先交“能过样例的版本”,再想优化。有分才谈满分。
  • A 题 20 分钟内没交,砍掉 A 先做 B——不要在一道 100 分里赌上另外 300 分。
  • 最后 10 分钟只做检查;事实上从第 105 分钟起就该停止改算法——赛场上最后 15 分钟改崩的次数,远多于改对的次数。

每题内部再切一刀:想 4 分钟、写 12 分钟、测 4 分钟。“想”的这 4 分钟不许碰键盘,纸上必须出现 4 行东西:

  1. 数组开多大:从数据范围推,上限 + 5。B 题 N ≤ 10⁶ 就开 1000005。
  2. 要不要 long long:和、积、平方、乘法,出现就估量级。C 题的 sum 到 10¹³。
  3. 循环边界:含不含端点,写 < 还是 <=,窗口是 [l, r] 还是 [l, r)。
  4. 输出格式:几个数、什么分隔、行末有没有多余空格。
为什么“想”的 4 分钟不许碰键盘

键盘会骗你:能编译通过的代码看起来总是对的。而纸上那 4 行一旦写错,编译是发现不了的。

本场四道题的丢分点全部在这 4 行里:A 的堆类型、B 的 while、C 的 lo 下界、D 的 dp 初值。

养成习惯后,这 4 分钟会缩短到 60 秒——但绝不能省。

开赛前先花 10 分钟做一道不计分的热身题:把“读题 → 写 → 测”的流程跑热,顺便确认编译环境、快读模板、以及“多关键字排序”这个你已经用过十几次的动作没生疏。

例题 · 奖学金洛谷 P1093普及-去洛谷做这题 ↗

某小学最近得到一笔赞助,打算拿出其中一部分为前 5 名学生发奖学金。期末,每个学生都有语文、数学、英语 3 门课的成绩。先按总分从高到低排序,如果两个同学总分相同,再按语文成绩从高到低排序,如果语文成绩也相同,规定学号小的同学排在前面。这样,每个学生的排序是唯一确定的。

输入格式:第一行一个整数 n(n ≤ 300);接下来 n 行,每行三个 0~100 的整数,依次是学号为 1、2、…、n 的学生的语文、数学、英语成绩。

输出格式:共 5 行,每行两个整数,依次为前 5 名学生的学号和总分;若 n < 5,则输出全部学生。

输入: 6 90 67 80 87 66 91 78 89 91 88 99 77 67 89 64 78 89 98 输出: 6 265 4 264 3 258 2 244 1 237
热身题的 4 分钟怎么想

这题就是第 12 节的原样:结构体 + 多关键字排序。分值全在 cmp 上,思路一分不值。

  1. 排序键有三个:总分降序 → 语文降序 → 学号升序。cmp 就写三行,一行一个键。
  2. 数学、英语只在读入时用一次(拿来算总分),不必存进结构体。
  3. 输出只要前 5 名:循环上界写 min(n, 5),n < 5 时不会越界。
  4. cmp 的最后一行不需要再判“全部相等”:学号唯一,两个元素不可能在三个键上全相同。

如果这题你写了超过 12 分钟,说明第 12 节的 cmp 还没变成肌肉记忆,那 B、C、D 三道题里凡是要排序的地方都会拖慢你。热身的意义就在这里。

热身题完整代码
#include <bits/stdc++.h>
using namespace std;

struct Stu { int id, ch, sum; };   // 只存比较要用的字段:学号、语文、总分

bool cmp(const Stu &a, const Stu &b) {
    if (a.sum != b.sum) return a.sum > b.sum;   // 第一关键字:总分降序
    if (a.ch  != b.ch ) return a.ch  > b.ch;    // 第二关键字:语文降序
    return a.id < b.id;                         // 第三关键字:学号升序(学号唯一,不必再判相等)
}

Stu s[305];                        // n ≤ 300,305 留余量;全局数组默认全 0

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;
    for (int i = 1; i <= n; ++i) {
        int c, m, e;
        cin >> c >> m >> e;
        s[i] = {i, c, c + m + e};  // 总分当场算掉,后面不再碰数学、英语
    }

    sort(s + 1, s + n + 1, cmp);   // 下标从 1 开始,区间是左闭右开,右端写 n+1
    for (int i = 1; i <= min(n, 5); ++i)
        cout << s[i].id << " " << s[i].sum << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
struct Stu { int id, ch, sum; };只留比较时用得到的字段。数学、英语读完就没用了,存进结构体只会让拷贝变慢。
if (a.sum != b.sum) return a.sum > b.sum;第一关键字。不能写成 >=:相等时也返回 true,会违反严格弱序,sort 可能越界崩溃(第 12 节踩过这个坑)。
if (a.ch != b.ch) return a.ch > b.ch;前面的 if 已经拦掉了总分相等的情况,走到这里才轮到语文。顺序不能反。
return a.id < b.id;最后一键用“唯一字段”收尾:学号互不相同,所以永远走不到“三个键全相等”,cmp 天然满足严格弱序。
sort(s + 1, s + n + 1, cmp);按 1 开始的下标排序,区间左闭右开,右端点是 n+1 而不是 n。写 n 会漏掉最后一个学生。
s[i] = {i, c, c + m + e};聚合初始化,按结构体成员的声明顺序填。总分在这里算掉,比每次比较时重算快得多。
i <= min(n, 5)题目只要前 5 名。写死 i <= 5 而 n = 3 时,会输出两行没初始化的垃圾数据。
cout << s[i].id << " " << s[i].sum << "\n";学号 + 一个空格 + 总分 + 换行。行末只有换行,没有多余空格。

热身完,看四张模板卡。它们不是新知识,是把本节四道题各自要用的骨架再默写一遍——赛场上默写不出来的模板,等于没学过。先能一次默写正确,再开始计时。

新函数拆解 · priority_queue 小根堆:取最小两个、合并后放回
priority_queue<int, vector<int>, greater<int>> pq;
组成含义 / 格式要求
priority_queue<int, vector<int>, greater<int>>三个参数:元素类型、底层容器、比较器。少写一个都编译不过,报错会指向 <bits/stl_queue.h>。
greater<int>让“大的”优先级更低,于是 top() 变成最小值。写 greater 才是小根堆,这与 sort 里 cmp 的方向刚好相反。
pq.top()读堆顶,O(1),不删除。空堆上调用是未定义行为,多半直接 RE。
pq.pop()删堆顶,O(log n),返回 void。必须先 top() 存下来再 pop(),否则这个值就丢了。
while (pq.size() > 1)合并类问题的循环条件:每合并一次少一堆,剩一堆自然结束。n = 1 时一次都不进。
ans += a + b; pq.push(a + b);三件事缺一不可:累加花费、把新堆放回、让堆里始终是“当前所有堆”。
最小可运行示例
示例
priority_queue<int, vector<int>, greater<int>> pq;
for (int x : {1, 2, 9}) pq.push(x);

long long ans = 0;
while (pq.size() > 1) {
    int a = pq.top(); pq.pop();      // 最小的
    int b = pq.top(); pq.pop();      // 次小的
    ans += a + b;                    // 这次合并的代价
    pq.push(a + b);                  // 新堆放回去,参与后面的合并
}
cout << ans;                         // 15
注意

写 int a = pq.pop(); 是编译错误——pop 不返回值。

只 pop 不 push:堆越来越空,答案偏小;若循环条件还成立就会读空堆,RE。

新函数拆解 · 双指针滑窗:右端点扩张、左端点收缩
for (int r = 1; r <= n; ++r) { 把 a[r] 加进窗口; while (窗口满足条件) { 更新答案; 把 a[l] 移出窗口; ++l; } }
组成含义 / 格式要求
r右端点,只增不减。每轮右移一格,把 a[r] 加进窗口的统计量。
把 a[r] 加进窗口维护统计量:计数数组 cnt[]、不同种类数 have、当前区间和 sum。加进来时同步更新它们。
while (窗口满足条件)必须是 while,不能是 if:缩一次可能还满足,要一直缩到刚好不满足为止。
更新答案写在“移出 a[l]”之前。此刻的 [l, r] 是“以 r 结尾的最短合法窗口”,所有长度更长的以 r 结尾的窗口都不可能更优。
移出 a[l]; ++l左端点右移,同时撤销 a[l] 对统计量的影响。窗口始终是闭区间 [l, r],长度 r − l + 1。
为什么是 O(n)l 和 r 都只往右走,各走不超过 n 步,总操作数 2n。
最小可运行示例
示例
int l = 1, have = 0, bestLen = INT_MAX, bestL = 0, bestR = 0;
for (int r = 1; r <= n; ++r) {
    if (++cnt[a[r]] == 1) ++have;              // 有新种类进来
    while (have == m) {                        // 已覆盖全部 m 种
        if (r - l + 1 < bestLen) {             // 严格更短才更新
            bestLen = r - l + 1; bestL = l; bestR = r;
        }
        if (--cnt[a[l]] == 0) --have;          // 撤销左端点
        ++l;
    }
}
注意

写成 if:只缩一次就跳出,最短解被漏掉。样例往往恰好只需要缩一次,本地看不出来。

先 ++l 再更新答案:记录到的是已经变短的窗口,长度算错,甚至把最短的那个漏掉。

新函数拆解 · 二分答案:把“求最小”变成“判定可行”
long long lo = L, hi = R; while (lo < hi) { long long mid = lo + (hi - lo) / 2; if (check(mid)) hi = mid; else lo = mid + 1; } // 循环结束时 lo == hi == 答案
组成含义 / 格式要求
lo, hi答案的取值区间,必须保证答案落在里面:下界取“一定可行”的最小值,上界取“一定可行”的值。
check(mid)判定 mid 行不行,必须是 O(n) 以内的函数,且关于 mid 单调:mid 可行则更大的都可行。
if (check(mid)) hi = mid;mid 可行 → 答案 ≤ mid,上界收到 mid。不能写 mid - 1,答案可能正好是 mid。
else lo = mid + 1;mid 不可行 → 答案严格大于 mid,下界推到 mid + 1。
while (lo < hi)区间缩到长度 1 时停。此时 lo == hi,就是最小可行值。
lo + (hi - lo) / 2防溢出写法。本题 hi 到 10¹³,习惯成自然。
最小可运行示例
示例
long long lo = mx, hi = sum;        // 下界:最大的单个数;上界:全部放一段
while (lo < hi) {
    long long mid = lo + (hi - lo) / 2;
    if (check(mid)) hi = mid;       // mid 可行,往小里收
    else            lo = mid + 1;   // mid 不可行,往大里推
}
cout << lo << "\n";
注意

写成 lo = mid:当 hi == lo + 1 时 mid 恒等于 lo,区间再也不缩小,死循环 → TLE。

check 的方向弄反(可行时写 lo = mid + 1),求出来的就是“最大可行值”,本题直接 WA。

新函数拆解 · 记忆化搜索:算过就存,下次直接取
int dfs(int x, int y) { if (dp[x][y]) return dp[x][y]; // 查表 ... 递归 ... return dp[x][y] = best; // 存表并返回 }
组成含义 / 格式要求
dp[x][y]“从 (x, y) 出发的答案”。初值 0 表示还没算过,所以真实答案必须 ≥ 1,否则 0 与“没算”无法区分。
if (dp[x][y]) return dp[x][y];记忆化的全部价值就在这一行。没有它,同一个格子会被重复搜索,退化成指数级。
int best = 1;递归的“底”:没有更优的邻居时,答案至少是 1(自己这一格也算长度)。
return dp[x][y] = best;赋值表达式的值就是赋进去的值,一行完成“存表 + 返回”。
递归能停的前提状态转移必须无环:高度严格递减、下标严格递增之类。有环就得换成按顺序递推。
最小可运行示例
示例
int dp[105][105], h[105][105];
int r, c;
const int dx[4] = {1, -1, 0, 0}, dy[4] = {0, 0, 1, -1};

int dfs(int x, int y) {
    if (dp[x][y]) return dp[x][y];        // 算过:O(1) 直接给
    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 dp[x][y] = best;               // 存下来,下次不再搜
}
注意

忘了存表(写成 return best;):每次调用都重新搜,指数级 → TLE。

把 < 写成 <=:等高相邻的格子互相调用,无限递归,栈爆 → RE。

本场最常丢的分,每种都写出症状

数组开小 → RE。B 题的 N ≤ 10⁶,数组只开 100005,交上去前 3 个点过、第 4 个点崩。数组大小永远从数据范围推:上限 + 5。

int 存和 → WA 成负数。C 题的 Σaᵢ 到 10¹³,用 int 会溢出成负数,二分区间直接乱掉,输出一个负的答案。看到“求和”“乘积”先估量级。

双指针 while 写成 if → WA。样例恰好只需缩一次,本地点过,交上去第 4 个点错。自查方法:拿一段连续 3 个相同画家的数据,看长度算对没有。

二分写成 lo = mid → TLE。hi = lo + 1 时 mid 恒等于 lo,区间不再缩小,程序空转 1 秒、2 秒、直到超时。

记忆化漏了存表 → TLE。D 题不加 dp[x][y] 就是纯 DFS,10⁴ 个格子的搜索树大到不可能跑完。

行末多一个空格 → WA。热身题那种“学号 总分”的输出,最后一个数后面直接换行;严格评测会逐字节比对。

局部变量没初值。int cnt; 不赋值就是垃圾值,写 ++cnt 之后答案随机,本地跑十次可能十次都一样,交上去就错。一切计数变量写 = 0。

自己动手复现

关掉页面,开一个空文件,从 #include <bits/stdc++.h> 开始,给 A 题(合并果子)掐 20 分钟写完并提交。

计时前先在纸上写三行:堆的类型、循环条件、ans 的类型。三行写对再动手。

改一改:把 aᵢ 的上限从 20000 改成 10⁹、n 从 10⁴ 改成 10⁵。这时总花费最大约多少?int 还够吗?把 ans 改成 long long 再交一遍。

再改一改:如果题目问的是“合并成 k 堆的最小花费”,本节的做法还够用吗?先把你的猜测写下来,第 49 节的对顶堆会回答它。

思路卡住时的三级降级

一级:写暴力。B 题可以 O(n²) 枚举左右端点(小数据点够用),先交一版有分的,再想 O(n) 的窗口。有分的代码才叫代码。

二级:换实现。C 题想不出二分,就从小到大枚举 k,每个 k 用 O(n) 判断——小数据点能得分。

三级:只写输入输出。哪怕答案是猜的,也把格式写对,保留“万一”的分数。

绝不做的事:在一道题上耗超过 35 分钟。本场四题分值相同,耗死一题等于放弃三题。

下面是正式的四道题。先掐表自己做,卡满单题用时再看提示;提示看完还没思路,再点开思路;四题全做完之后再点开代码对照。

题目 · 共 4 题

题号题目难度建议用时
A合唱队形普及/提高-20 分钟
B逛画展普及/提高-26 分钟
C数列分段 Section II普及/提高-32 分钟
D滑雪普及/提高-24 分钟
比赛规则(自己执行)
  • 先花 8 分钟通读全部题目,按“会做 → 有思路 → 完全不会”排序。
  • 单题最多投入 35 分钟,超过就换题。
  • 每题先写暴力拿部分分,再想正解。
  • 最后 15 分钟不再改算法,只按清单检查数组大小、long long、多测清空、输出格式。
大题 挑战题 · 合唱队形
洛谷 P1091普及/提高-去洛谷做这题 ↗

n 位同学排成一排,第 i 位同学的身高是 aᵢ。要从中选出 k 位同学,让他们按身高先递增、再递减排成一列(可以是纯递增或纯递减),且保持原来的相对顺序不变。

求最少需要让多少位同学出列。

输入格式:第一行一个整数 n(1 ≤ n ≤ 100);第二行 n 个整数 aᵢ(130 ≤ aᵢ ≤ 230),空格分隔。

输出格式:一个整数,表示最少出列人数。

样例输入
8
186 186 150 200 160 130 197 200

样例输出
4
样例解释:留下 4 人(150 200 160 130 或 186 200 197 200 等),出列 8 − 4 = 4 人。

引导性提示(先只看这里)

“先增后减”的形状,能不能拆成“从某个位置往左看递增”和“从同一个位置往右看递增”两件事?

固定中间那个最高的人 i,左边能留下的最多人数是多少?右边呢?这两件事相互独立吗?

同一件事(以某点结尾的最长递增子序列)要算两次:一次从左往右,一次从右往左。

想看思路了?点开完整思路

设留下的人里最高的那位站在位置 i。那么:

· i 左边的人必须构成以 i 结尾的严格递增序列,最多能留 up[i] 人(含 i 自己);

· i 右边的人必须构成从 i 开始的严格递减序列;把数组倒过来看,就是以 i 结尾的严格递增序列,最多能留 down[i] 人(含 i 自己)。

两段共享 i 这个点,所以以 i 为峰能留下 up[i] + down[i] − 1 人。枚举所有 i 取最大值 best,答案就是 n − best。

up 的求法就是 LIS 的 O(n²) 写法:up[i] = 1 + max{ up[j] : j < i 且 a[j] < a[i] }。down 可以用同一段代码算两次:先把数组反转,跑一遍 up,再反转回来。

为什么“只看 i 左边”就够:因为左右两侧的人选互相独立——左边的人只受左半边约束,右边只受右半边约束,唯一的交集是峰 i 本身,所以直接相加再减 1。

复杂度 O(n²)。n ≤ 100 时只有 10⁴ 次比较,随便过。

注意“严格”二字:身高相同的两人不能同时出现在递增段里,所以比较必须写 a[j] < a[i],不能写 <=。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int N = 105;
int n, a[N], up[N], down[N];

// 求以每个位置结尾的最长严格上升子序列长度,存进 f[]
void lis(int f[]) {
    for (int i = 1; i <= n; ++i) {
        f[i] = 1;                          // 至少能只留自己
        for (int j = 1; j < i; ++j)
            if (a[j] < a[i])               // 严格递增:不能取等
                f[i] = max(f[i], f[j] + 1);
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    for (int i = 1; i <= n; ++i) cin >> a[i];

    lis(up);                               // 从左往右:以 i 结尾的最长递增

    reverse(a + 1, a + n + 1);             // 倒过来
    lis(down);                             // 现在还叫 down,含义是"以 i 开头的递减"
    reverse(down + 1, down + n + 1);       // 把 down 的下标对回去,与原来的 i 对齐
    reverse(a + 1, a + n + 1);             // a 恢复原样(其实后面不用了,但别留坑)

    int best = 0;
    for (int i = 1; i <= n; ++i)
        best = max(best, up[i] + down[i] - 1);   // 峰 i 被两边都算了一次,减 1

    cout << n - best << "\n";              // 问的是出列人数
    return 0;
}
关键片段拆解
片段作用
f[i] = 1;LIS 的初值。以 i 结尾的序列至少包含自己,长度 1。漏了这一句长度会偏小。
if (a[j] < a[i])严格递增。写成 <= 会把身高相同的人接进来,样例中两个 186 会同时被留,答案偏小。
lis(up);第一次:从左往右,up[i] 是“以 i 结尾的最长递增段”。
reverse(a + 1, a + n + 1);把数组倒过来,于是“从 i 往右递减”变成“倒序数组里以对应位置结尾的递增”。
reverse(down + 1, down + n + 1);关键一步。down 是在倒序数组上算的,下标是反的,必须翻回来才能和 up[i] 对齐。
up[i] + down[i] - 1峰 i 在 up 里算了一次、在 down 里又算了一次,所以减 1。
n - bestbest 是“最多能留几人”,题目问的是“最少出列几人”,所以要 n 减 best。
这题最容易错的地方
  • 忘记把 down 数组的下标翻回来,up[i] + down[i] 对不上号,答案随机错。
  • 比较写成了 a[j] <= a[i]:相同身高的人被连成一段,样例中 186、186 会同时留下,答案偏小。
  • 直接输出 best(留下人数)而不是 n − best(出列人数)。
  • down 的方向搞反:写成“从左往右看 i 右边”却没有反转数组,逻辑上不成立。
  • 数组开成 101 而 n 可达 100、又用 1 基下标,需要至少 105 才安全。
↑ 第 A 题 · 合唱队形
大题 挑战题 · 逛画展
洛谷 P1638普及/提高-去洛谷做这题 ↗

博览馆展出 M 位画家的作品,共 N 幅画排成一行,第 i 幅由画家 pᵢ 所画。买门票时必须说明两个数字 a、b,表示只看第 a 幅到第 b 幅(含两端)之间的所有画,票价是段内画的幅数。要保证看完全部 M 位画家的作品,求总票价最小的区间。

输入格式:第一行两个整数 N、M;第二行 N 个整数,表示每幅画的画家编号(1 ≤ M ≤ 2000,N ≤ 10⁶,保证有解)。

输出格式:两个整数 a、b,表示所选区间。若有多组解,输出 a 最小的一组;若仍有多个,输出 b 最小的一组。

样例输入
12 5
2 5 3 1 3 2 4 1 1 5 4 3

样例输出
2 7
样例解释:区间 [2, 7] 里的画是 5 3 1 3 2 4,5 位画家全部出现,长度 6,是最短的。

引导性提示(先只看这里)

“一段连续的画”这个形状,说明答案是一个区间。枚举两个端点有 N² 个,N = 10⁶ 时不可能。

想一件事:把右端点往右挪,“覆盖全部 M 位”只会更容易满足;把左端点往右挪,只会更难满足。

于是对于一个固定的右端点,满足条件的区间里,最短的那一个左端点位置是唯一确定的——它就在“再往右挪一步就不满足”的那一格。

想看思路了?点开完整思路

条件具有单调性:区间变长只会更容易覆盖全部 M 种,变短只会更难。这就是双指针的入场券。

维护窗口 [l, r]、计数数组 cnt[画家] 和 have = 窗口内出现的不同画家数。

r 从 1 扫到 N,每步把 a[r] 加入窗口;只要 have == M,就尝试收缩 l:先用当前长度更新答案,再把 a[l] 移出、l 右移。

正确性:对固定的 r,while 循环退出时 [l, r] 是“以 r 结尾的最短合法窗口”。任何以 r 结尾的合法窗口都包含它、更长,所以不可能更优——只检查这一个就够,不漏解。

字典序 tie-break:长度相同取 a 最小。r 是从小到大扫的,而长度相同的两个区间,右端点更小的那个左端点也更小;配合“只在严格更短时更新答案”,第一个被记下的最短解就是 a 最小的。

复杂度 O(N):l、r 各走不超过 N 步。N = 10⁶ 时约 2×10⁶ 次操作,稳过。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int a[1000005];        // N ≤ 10⁶,上限 +5,少开就是 RE
int cnt[2005];         // M ≤ 2000:cnt[x] = 窗口内画家 x 的画有几幅

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;
    for (int i = 1; i <= n; ++i) cin >> a[i];

    int l = 1, have = 0;                       // have:窗口内不同画家的种数
    int bestLen = INT_MAX, bestL = 0, bestR = 0;

    for (int r = 1; r <= n; ++r) {
        if (++cnt[a[r]] == 1) ++have;          // 加进来后计数为 1:这是新画家
        while (have == m) {                    // 已覆盖全部画家,才值得缩短
            if (r - l + 1 < bestLen) {         // 严格小于:同长度保留先出现的(a 更小)
                bestLen = r - l + 1;
                bestL = l; bestR = r;
            }
            if (--cnt[a[l]] == 0) --have;      // 移出左端点;减到 0 说明这个画家没了
            ++l;
        }
    }

    cout << bestL << " " << bestR << "\n";
    return 0;
}
关键片段拆解
片段作用
if (++cnt[a[r]] == 1) ++have;先把计数加一并判断:加完恰好等于 1,说明这个画家是第一次进入窗口,种类数才加一。写 >= 2 会把重复画也算成新种类。
while (have == m)用 while 而不是 if:缩一次之后可能仍然覆盖全部画家,要一直缩到刚好不满足。
if (r - l + 1 < bestLen)严格小于。长度相同时不更新,保留先被扫到的那个——它的左端点更小。
bestL = l; bestR = r;此刻 [l, r] 就是最短合法窗口,两个端点一起存。
if (--cnt[a[l]] == 0) --have;减完变 0 才说明这一种画家彻底离开窗口,种类数减一。先移出再改 have 的顺序不能反。
int bestLen = INT_MAX初值必须比任何可能的长度都大。写 0 会导致永不更新,输出 0 0。
a[1000005]N ≤ 10⁶,开 1e6 + 5。开成 1e5 时 N 大的数据点直接越界,RE 或答案错乱。
这题最容易错的地方
  • while 写成 if:只缩一次,漏掉更短解,WA;样例恰好只需缩一次,本地完全看不出来。
  • 先 ++l 再更新答案:记录到的是已经变短的窗口,长度算错,甚至把最短的那个区间漏掉。
  • bestLen 初始化为 0 或 n:0 让任何长度都不小于它,永不更新,输出 0 0。
  • cnt 数组只开 105,M 到 2000 时严重越界,RE 或把别的全局变量写坏。
  • 把“新画家”判断写成 if (cnt[a[r]] == 0) 但顺序放在自增之后,逻辑反掉,have 永远到不了 m。
  • 输出顺序写成 r l,以及忘记行末只换行。
↑ 第 B 题 · 逛画展
大题 挑战题 · 数列分段 Section II
洛谷 P1182普及/提高-去洛谷做这题 ↗

给出一个长度为 N 的正整数数列 A。要把它划分成 M(M ≤ N)段,每段必须连续且不能为空,设每段数字之和的最大值为 k。求 k 的最小可能值。

输入格式:第一行两个正整数 N、M(1 ≤ N ≤ 10⁵,1 ≤ M ≤ N);第二行 N 个正整数 Aᵢ(Aᵢ ≤ 10⁸)。

输出格式:一个正整数,即 k 的最小值。

样例输入
5 3
4 2 4 5 1

样例输出
6
样例解释:分成 4 2 | 4 | 5 1,三段和分别是 6、4、6,最大值 6;k = 5 时怎么分都至少要 5 段,不满足 M = 3。

引导性提示(先只看这里)

别急着猜“平均每段多少”。先问自己:如果别人告诉你一个 k,你能不能在 O(N) 内判断它行不行?

判断只有一种做法:从左往右尽量往当前段里塞,塞不下就另起一段。这样得到的段数是最少的。

k 越小越难分(段数越多),k 越大越好分(段数越少)。这个单调性就是二分的全部依据。k 最小可能取多少?最大可能取多少?

想看思路了?点开完整思路

答案有明显单调性:若某个 k 可行,那么所有更大的 k 也可行。所以可以二分 k,把“求最小可行 k”转成 N 次“判定 k 行不行”。

check(k):贪心地扫一遍,cur 表示当前段的和,装不下(cur + Aᵢ > k)就新开一段。得到的段数 cnt 就是“每段和 ≤ k”时能划分出的最少段数。

为什么 cnt ≤ M 就等于可行:题目要求恰好 M 段。如果 cnt 小于 M,因为 M ≤ N,总可以把某些段再切开(切开只会让每段和更小),补到恰好 M 段。所以判定条件是 cnt ≤ M。

贪心为什么给出最少段数:对任意可行划分,前 i 段覆盖到的位置不会超过贪心的前 i 段;归纳一次就得到贪心段数最少。

边界:lo = max(Aᵢ),因为 k 连最大的一个数都装不下时必然不可行;hi = ΣAᵢ,全部放一段一定可行。

类型:ΣAᵢ ≤ 10⁵ × 10⁸ = 10¹³,远超 int,全程 long long。复杂度 O(N log ΣA) ≈ 10⁵ × 44。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int n, m;
long long a[100005];

// check(k):每段和都不超过 k 时,最少要分成几段
bool check(long long k) {
    long long cur = 0;
    int cnt = 1;                       // 至少一段
    for (int i = 1; i <= n; ++i) {
        if (cur + a[i] > k) {          // 当前段装不下 -> 另起一段
            ++cnt;
            cur = a[i];
        } else {
            cur += a[i];               // 能塞就塞:段数才会最少
        }
    }
    return cnt <= m;                   // 段数不超过 m 即可:多出来的段可以继续拆
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> m;
    long long sum = 0, mx = 0;
    for (int i = 1; i <= n; ++i) {
        cin >> a[i];
        sum += a[i];
        mx = max(mx, a[i]);
    }

    long long lo = mx, hi = sum;       // 下界:最大的单个数;上界:全部放一段
    while (lo < hi) {
        long long mid = lo + (hi - lo) / 2;
        if (check(mid)) hi = mid;      // mid 可行 -> 答案不超过 mid
        else lo = mid + 1;             // mid 不可行 -> 答案必然更大
    }
    cout << lo << "\n";
    return 0;
}
关键片段拆解
片段作用
long long a[100005], sumAᵢ ≤ 10⁸、N ≤ 10⁵,ΣAᵢ 到 10¹³。用 int 会在二分区间就溢出成负数,整个搜索跑飞。
int cnt = 1;段数从 1 开始:循环里只统计“新开了几段”,最后一段不算新开。写成 0 会少算一段。
if (cur + a[i] > k)贪心的核心:能塞就塞,塞不下才开新段。用 > 不用 >=,等于 k 是合法的。
return cnt <= m;判定条件是“不超过 m 段”。写成 cnt == m 会漏掉“可以继续拆段”的情况,WA。
long long lo = mxk 小于最大值时,那个数自己单独成段都超,必然不可行,直接砍掉这一段搜索区间。
long long mid = lo + (hi - lo) / 2防溢出的中点写法。hi 到 10¹³ 时 lo + hi 也在 long long 内,但这个写法是习惯,换更大的数据也不用改。
if (check(mid)) hi = mid; else lo = mid + 1;找最小可行值:可行就往小收(mid 本身要保留,所以是 hi = mid 不是 mid - 1),不可行就推到 mid + 1。
cout << lo << "\n";循环退出时 lo == hi,就是最小可行的 k。
这题最容易错的地方
  • check 里写成 cnt < m 或 cnt == m:前者漏掉恰好等于 m 的情况,后者漏掉可拆的情况,两种都 WA。
  • lo 从 0 或 1 开始:k 比某个 Aᵢ 还小时 check 仍可能返回 true(因为没判单个元素),得出错误答案。
  • 用 int 存 sum:10¹³ 溢出成负数,lo、hi 关系错乱,表现为 TLE 或输出负数。
  • 写成 lo = mid:hi == lo + 1 时 mid 恒等于 lo,区间不再缩小,死循环 → TLE。
  • 写成 hi = mid - 1 却仍返回 lo:答案正好是 mid 时被跳过,WA。
  • check 里 cur 没在开新段时重置为 a[i],而是直接清零,导致每个数都单独成段,段数虚高。
↑ 第 C 题 · 数列分段 Section II
大题 挑战题 · 滑雪
洛谷 P1434普及/提高-去洛谷做这题 ↗

给出一个 R 行 C 列的高度矩阵。从任意一格出发,每步可以走到上下左右相邻的一格,但要求目标格的高度严格小于当前格。求一条滑坡最多能经过多少个格子。

输入格式:第一行两个整数 R、C(1 ≤ R, C ≤ 100);接下来 R 行,每行 C 个整数表示高度。

输出格式:一个整数,最长滑坡经过的格子数。

样例输入
5 5
1 2 3 4 5
16 17 18 19 6
15 24 25 20 7
14 23 22 21 8
13 12 11 10 9

样例输出
25
样例解释:从 25 一路滑到 1,恰好经过全部 25 个格子。

引导性提示(先只看这里)

高度严格递减,所以同一个格子不可能被经过两次——所有“走法”构成一张有向无环图。

从 (i, j) 出发的最长长度,只由它四周更低的格子决定:等于 1 加上那些格子答案的最大值。这是一个可以递归定义的式子。

如果每个格子都朴素地往下搜,同一个格子会被重复算几十上百次。怎样让它一辈子只算一次?

想看思路了?点开完整思路

状态:f[i][j] = 从 (i, j) 出发能经过的最多格子数(含自己)。

转移:f[i][j] = 1 + max{ f[nx][ny] },其中 (nx, ny) 与 (i, j) 相邻、在界内、且高度更低;四周没有更低的格子时 f[i][j] = 1。

因为高度严格递减,转移关系不可能成环,可以放心递归。

记忆化:f 的初值 0 表示“还没算过”。进入 dfs 先查表,算完再存表。每个格子只会真正计算一次,之后都是 O(1) 返回。

答案 = 所有 f[i][j] 的最大值,因为起点任意。

复杂度:状态 R×C ≤ 10⁴ 个,每个状态看 4 个方向,总 O(RC) = 4×10⁴ 次操作。不加记忆化则是每个格子都搜一遍,最坏路径长度 10⁴,总量级不可接受。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int r, c;
int h[105][105];                     // 高度
int dp[105][105];                    // dp[i][j]:从 (i,j) 出发的最长滑坡长度;0 表示还没算
const int dx[4] = {1, -1, 0, 0};
const int dy[4] = {0, 0, 1, -1};

int dfs(int x, int y) {
    if (dp[x][y]) return dp[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 dp[x][y] = best;          // 先存表再返回,下次 O(1)
}

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;
}
关键片段拆解
片段作用
if (dp[x][y]) return dp[x][y];记忆化的全部价值。dp 初值 0 表示未计算,答案至少是 1,两者不会混淆。
int best = 1;递归的底:没有更低的邻居时,滑坡就是这一格自己,长度 1。
if (nx < 1 || nx > r || ny < 1 || ny > c) continue;四个方向都要判界。少判一个方向就是越界读,表现为 WA 或 RE。
if (h[nx][ny] < h[x][y])严格小于。写成 <= 时,两个等高格子会互相调用,无限递归 → 栈爆 RE。
best = max(best, dfs(nx, ny) + 1);取四个方向里最长的。+1 是把自己这一格加上。
return dp[x][y] = best;赋值表达式的值就是赋进去的值,一行完成“存表 + 返回”。写成 return best; 则记忆化失效。
int h[105][105], dp[105][105]R、C ≤ 100,开 105 留余量。用 1 开始的下标,配合 x < 1 判界。
ans = max(ans, dfs(i, j));起点可以是任意格子,所以全部枚举一遍取最大。不需要额外判断“最深的那个”。
这题最容易错的地方
  • 忘了 if (dp[x][y]) return dp[x][y];:每个格子重复搜索,最坏 4^10⁴ 量级,TLE 或直接栈爆。
  • 写成 return best; 而没有存表:编译能过、样例能过,大数据全是重复计算 → TLE。
  • 把 < 写成 <=:等高相邻的格子互相递归,无限循环 → RE(爆栈)。
  • dp 初值写成 1 而不是 0:分不清“没算过”和“答案是 1”,记忆化彻底失效,退化成纯 DFS。
  • 多组测试数据时忘了清空 dp/h:上一组的结果污染下一组,WA。本题单测,但习惯要先建立。
  • 输出的是格子数而不是步数。本题要求经过的格子数,起点那一格也算,所以 best 从 1 开始。
↑ 第 D 题 · 滑雪
赛后必做
  • 能在 8 分钟内读完四道题,并排出“先做哪两道”的顺序,而不是从 A 顺着往后硬做。
  • A 题 20 分钟内一次提交通过,没有因为 pop() 用法、忘写 push 回堆而重写第二遍。
  • 能说出双指针为什么用 while 而不是 if,并举出一组“只缩一次就会错”的输入。
  • 能默写二分答案的四行框架,并说清 lo 为什么取 max(Aᵢ)、为什么不能写 lo = mid。
  • 能说清记忆化搜索里“dp 初值 0”与“答案至少 1”为什么不会冲突,以及漏掉存表会退化成什么复杂度。
  • 交卷前会按顺序检查:数组大小、long long、计数变量初值、行末是否多空格。
  • 赛后当天把没做出来的题重写一遍并限时 30 分钟——不重写等于没考。
36 质数与整除:筛法与分解普及-

36 · 质数与整除:筛法与分解

把整除问题拆成三个动作:判一个数是否质数、一次筛出一整段的质数、把一个数拆成质因数之积。掌握埃氏筛的两个优化、线性筛的最小质因子、分解循环的 √n 上界,以及 R 很大而区间很小时的区间筛。
普及-预计 60 分钟埃氏筛线性筛质因数分解
本节出现的新函数 / 新语法(先看懂再用)

正整数只有两类:除 1 和自身外没有别的因子的叫质数,有的叫合数。这个划分之所以重要,是因为唯一分解定理:任何大于 1 的整数都能写成若干质数之积,写法唯一。所以数论题的第一步几乎总是“把这个数拆成质数”,而拆之前必须先知道哪些数是质数。

判一个数是不是质数,试到 √n 就够;找一整段的质数,用筛;分解一个数,同样只试到 √n,最后剩下的那部分不用再试。
要做的事数据规模用什么复杂度
判单个 n 是否质数n ≤ 10^12试除到 √n≤ 10^6 次除法
求 2..n 的全部质数n ≤ 10^7埃氏筛O(n log log n)
同上,还要最小质因子n ≤ 10^7线性筛O(n)
分解一个 nn ≤ 10^12试除到 √n≤ 10^6 次除法
[L, R] 内的质数R ≤ 10^12,R−L ≤ 10^6区间筛O(√R + (R−L)·log log R)

先证明第一条结论,它决定了后面所有循环的边界。若 n = a·b 且 a ≤ b,则 a·a ≤ a·b = n,所以 a ≤ √n。也就是说:n 只要有真因子,就一定有一个不超过 √n。于是“试完 2..√n 都没有因子”与“n 是质数”完全等价。n = 10^12 时这是 10^6 次除法对 10^12 次除法——六个数量级的差距,就在这一个不等号上。

这一节的两个基础工具都很短,但每一行都有理由。先把结构看清楚,之后所有代码都是它们的变体。

新函数拆解 · 试除法判素
bool isPrime(long long n);
组成含义 / 格式要求
n要判断的数。用 long long:n 到 10^12 时 int 装不下。
if (n < 2) return false;0 和 1 不是质数。这是最常漏的一句,漏了会让答案多算一个 1。
n % 2 == 0单独处理 2,之后就能每次跳过所有偶数,试除次数直接减半。
i <= n / i循环条件,等价于 i·i ≤ n。写成 i < n 会慢 √n 倍;写成 i·i ≤ n 在 i 用 int 时会溢出。
i += 2只试奇数。3、5、7、9、…,其中 9、15 是合数,多试几次但省掉了全部偶数。
return false找到一个真因子就立刻退出,不必再试。
最小可运行示例
示例
bool isPrime(long long n) {
    if (n < 2) return false;                       // 0、1 不是质数
    if (n % 2 == 0) return n == 2;                // 唯一的偶质数是 2
    for (long long i = 3; i <= n / i; i += 2) {   // 只试奇数,且防止 i*i 溢出
        if (n % i == 0) return false;
    }
    return true;
}
注意

i <= n / i 与 i * i <= n 等价,但前者永不溢出。这是数论里最该背下来的写法之一。

n 到 2×10^9 时最多试 22360 个奇数,一次判素不到一毫秒;但要判 10^5 个数就是 2.2×10^9 次,必须换筛法。

新函数拆解 · 埃氏筛(Sieve of Eratosthenes)
vector<char> isp(n + 1, 1); 然后双重循环把合数标 0
组成含义 / 格式要求
isp[k]1 表示 k 还没被划掉、是质数;0 表示合数。用 char 不用 int:n = 10^7 时 10 MB 对 40 MB。
isp[0] = isp[1] = 0;筛前手动把 0 和 1 划掉。全局数组默认全 0,所以用 vector 构造时给初值 1 更省事。
i 只到 √n(优化一)合数 k 一定有一个 ≤ √k 的质因子,所以所有合数都会被不超过 √n 的质数划掉。i 超过 √n 后,内层循环一次也不会执行。
j 从 i·i 起(优化二)i 的 2i、3i、…、(i−1)i 已经分别被 2、3、…、i−1 划掉了,从 i·i 起才第一次需要自己动手。
j += i步长就是 i,把 i 的所有倍数依次标 0。
复杂度O(n log log n)。n = 10^7 约 0.06 秒,n = 10^8 约 0.8 秒,n = 10^9 就得换策略了。
最小可运行示例
示例
int n;  cin >> n;
vector<char> isp(n + 1, 1);              // 先假设 2..n 全是质数
isp[0] = isp[1] = 0;                     // 0、1 不是质数
for (int i = 2; i <= n / i; ++i) {       // 优化一:外层只到 √n
    if (!isp[i]) continue;               // i 是合数,它的倍数早被更小的质数划过了
    for (int j = i * i; j <= n; j += i)  // 优化二:从 i*i 起
        isp[j] = 0;
}
// 此时 isp[k] == 1 的 k 就是质数
注意

内层写成 j = 2 * i 答案仍然正确,只是多做一倍无用功。两个优化作用不同:优化一决定外层跑几轮,优化二决定内层从哪起步,谁也替代不了谁。

重复执行筛法时上一轮的表会残留,多测要重新 fill 一遍,或者把筛法写成函数、每次重新构造 vector。

例题 · 质数口袋洛谷 P5723普及-去洛谷做这题 ↗

小 A 有一个“质数口袋”,他从 2 开始依次检查每个自然数,是质数就装进口袋。口袋的负荷是里面所有质数之和,负荷不允许超过 L。

求口袋最多能装下多少个质数,以及此时口袋的负荷。

输入:一个正整数 L(1 ≤ L ≤ 105)。

输出:两行,第一行为最多能装的质数个数,第二行为此时的负荷。

输入: 100 输出: 9 100
例题怎么想:先筛表,再从最小的开始装

题目要“装得尽量多”。所有质数都是正整数,所以装 2 一定比装 3 划算:要数量最多,就只能从最小的质数开始一个个往后装,装到再加一个就超重为止。这是唯一的最优策略,不需要搜索也不需要贪心证明。

剩下的问题是“2..L 里哪些是质数”。L ≤ 105,逐个试除要 105 × 316 ≈ 3×107 次,勉强能过;但要检查的是一整段连续区间,这正是筛法的场合——一次 O(L log log L) 把整张表标完。

两段拼起来:筛 2..L → 从 2 扫到 L,是质数就试着装。扫到第一个“装进去就超重”的质数时立刻停,因为它后面更大的质数只会更超重。

两段骨架
筛表:
  isp[0] = isp[1] = 0;  其余 0..L 全置 1
  i 从 2 到 L/i:若 isp[i],把 i*i, i*i+i, i*i+2i, ... 标 0

装口袋:
  sum = 0, cnt = 0
  i 从 2 到 L:
      若 isp[i] 为 0,跳过
      若 sum + i > L,break
      否则 sum += i, cnt += 1

两处容易写错的地方:用 break 还是 continue——超重必须 break,因为后面每个质数都更大,continue 会白扫到底;sum + i 的比较——用 sum + i > L 而不是 sum >= L,后者会把正好装满的最后一个质数丢掉。

完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;         // L ≤ 10^5,多留几个位置
bool isp[MAXN];                  // isp[i] = true 表示 i 是质数

int main() {
    int L;
    cin >> L;

    // ---------- ① 埃氏筛:标出 2..L 的所有质数 ----------
    fill(isp, isp + L + 1, true);        // 只填 0..L,别填满 MAXN
    isp[0] = isp[1] = false;             // 0 和 1 不是质数,漏了答案会多算一个

    for (int i = 2; i <= L / i; ++i) {   // 外层只到 √L:更大的 i 划不动任何新数
        if (!isp[i]) continue;           // i 自己是合数,跳过
        for (int j = i * i; j <= L; j += i) {
            isp[j] = false;              // 从 i*i 起划,2i..(i-1)i 已被更小的质数划掉
        }
    }

    // ---------- ② 从小到大装 ----------
    int cnt = 0, sum = 0;
    for (int i = 2; i <= L; ++i) {
        if (!isp[i]) continue;           // 不是质数
        if (sum + i > L) break;          // 再加就超重;后面的质数更大,一定也超
        sum += i;
        ++cnt;
    }

    cout << cnt << "\n" << sum << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
const int MAXN = 100005;L 最大 10^5,下标要能取到 10^5,所以开 100005 而不是 100000。
bool isp[MAXN];全局数组默认全 false。所以下面必须先 fill 成 true——“全局数组默认是质数”是错觉。
fill(isp, isp + L + 1, true);只填 [0, L]。填满整个数组也不会错,但白刷 10 万个位置;写 L+1 表达的正是“只关心 0..L”。
isp[0] = isp[1] = false;0 和 1 不是质数。漏这句,1 会被当成质数装进口袋,cnt 多 1、sum 多 1。
for (int i = 2; i <= L / i; ++i)外层只到 √L。理由就是开头那个不等式:任何合数都有不超过 √n 的质因子。
if (!isp[i]) continue;i 已被更小的质数划掉,它是合数,它的倍数必然也被划过了,跳过。
for (int j = i * i; j <= L; j += i)两个优化同时出现:从 i*i 起、以 i 为步长。
if (sum + i > L) break;超重就停。用 break 不用 continue:后面的质数更大,只会更超重。
sum += i; ++cnt;装进去,两个计数器同步更新。
cout << cnt << "\n" << sum << "\n";两行输出,顺序是“数量、负荷”,题目规定的顺序。

埃氏筛有个小浪费:一个合数会被它的每个质因子各划一次,比如 30 会被 2、3、5 各划一遍。线性筛把它修掉——让每个合数只被它的最小质因子划一次,代价是多用一个数组 primes 存已找到的质数,顺手还能免费得到每个数的最小质因子表 lpf。

线性筛(欧拉筛)
const int MAXN = 1000000 + 5;
vector<int> primes;          // 已找到的质数,从小到大
int lpf[MAXN];               // lpf[k]:k 的最小质因子
bool isp[MAXN];              // 是否是质数

void eulerSieve(int n) {
    primes.clear();                          // 多测:上一轮攒下的质数必须清掉
    fill(isp, isp + n + 1, true);
    isp[0] = isp[1] = false;

    for (int i = 2; i <= n; ++i) {
        if (isp[i]) {
            primes.push_back(i);
            lpf[i] = i;                        // 质数的最小质因子是它自己
        }
        for (int p : primes) {
            if ((long long)p * i > n) break;   // 越界,后面的 p 只会更大
            isp[p * i] = false;
            lpf[p * i] = p;                    // p 是 p*i 的最小质因子
            if (i % p == 0) break;             // ★ 关键的一句,见下表
        }
    }
}
线性筛为什么是 O(n)
代码片段含义与理由
for (int p : primes)拿已知的质数从小到大去乘 i。乘出来的 p·i 是合数,顺手标掉。
if ((long long)p * i > n) break;p·i 越界就跳出。p 递增,后面的只会更大,没有继续的必要。
lpf[p * i] = p;能执行到这一句,说明 p 不大于 i 的最小质因子,因此 p 一定是 p·i 的最小质因子。
if (i % p == 0) break;★ i 已含质因子 p。再乘更大的 p′,p′·i 的最小质因子仍是 p,应该由 p 去标,不在这里标——否则同一个合数被标两次,退化回埃氏筛。
每个合数只被标一次任一合数 k 只会在 i = k / lpf[k]、p = lpf[k] 这一种情况下被标,所以内层总执行次数 ≤ n。
副产物 lpf有了 lpf,分解质因数从 O(√n) 降到 O(log n):反复除以 lpf 即可。后面算约数个数、欧拉函数,第一步都是这张表。

分解质因数就是试除法的直接应用,只有一个新要点:除干净之后,剩下的那部分不用再试。因为每除一次 n 就变小,循环条件 p ≤ n/p 会自动收紧;而循环结束时若 n 仍大于 1,这个 n 一定是质数(否则它会有个不超过 √n 的因子,早就被除走了)。

分解质因数(含输出格式)
#include <bits/stdc++.h>
using namespace std;

int main() {
    long long n;
    cin >> n;
    cout << n << " = ";

    bool first = true;
    for (long long p = 2; p <= n / p; ++p) {   // 上界跟着 n 一起缩小
        if (n % p != 0) continue;              // p 不是因子
        int c = 0;
        while (n % p == 0) { n /= p; ++c; }    // 除干净,c 就是指数
        if (!first) cout << " * ";
        cout << p << "^" << c;
        first = false;
    }
    if (n > 1) {                               // 剩下的一定是质数
        if (!first) cout << " * ";
        cout << n << "^1";
    }
    cout << "\n";
    return 0;
}
分解的三处关键
代码片段含义与理由
for (long long p = 2; p <= n / p; ++p)上界不是 √原数,而是 √当前 n。每除掉一个因子,n 变小,上界随之变小,循环会更早结束——这是复杂度能接受的根本原因。
while (n % p == 0) { n /= p; ++c; }一次除干净。换成 if 只除一次,n 就没被除透:360 会拆成 2^1 * 3^1 * 4^1 * 15^1,合数 4 和 15 都被当成了质因子。
if (n > 1) 输出 n^1循环结束后 n 若大于 1,它必是质数。例 n = 14:p = 2 除完 n 变成 7,此时条件 3 ≤ 7/3 = 2 已不成立,循环立即结束,剩下的 7 只能在这里补输出。
p <= n / p写成 p * p <= n,当 n 到 10^18 时 p 接近 10^9,p*p 溢出成负数,循环变成死循环,表现为 TLE。
bool first只控制分隔符,保证行末不多一个 “ * ”。格式题里多一个字符就是 WA。

最后一招:R 可以很大(1012),但区间长度 R−L 很小(106)。筛法筛不了 1012 个位置,可我们只需要 [L, R] 这一小段。做法是:先筛出 √R ≤ 106 以内的质数,再用这些质数去划 [L, R] 里的倍数,下标整体平移 L,用一个长度 R−L+1 的数组装下。

区间筛:把 [L, R] 平移成 [0, R−L]
// 统计 [L, R] 内的质数个数,要求 L ≥ 1,R ≤ 10^12,R - L ≤ 10^6
long long countPrimes(long long L, long long R) {
    int lim = (int)sqrt((long double)R) + 1;        // 只需 √R 以内的质数
    vector<char> small(lim + 1, 1);
    small[0] = small[1] = 0;
    for (int i = 2; i <= lim / i; ++i)
        if (small[i])
            for (int j = i * i; j <= lim; j += i) small[j] = 0;

    vector<char> seg(R - L + 1, 1);                 // seg[k] 对应数字 L + k
    if (L == 1) seg[0] = 0;                         // 1 不是质数

    for (int i = 2; i <= lim; ++i) {
        if (!small[i]) continue;
        long long p = i;
        // 从 p*p 与 “不小于 L 的最小 p 的倍数” 中取较大者
        long long start = max(p * p, (L + p - 1) / p * p);
        for (long long j = start; j <= R; j += p) seg[j - L] = 0;
    }

    long long cnt = 0;
    for (long long k = L; k <= R; ++k) cnt += seg[k - L];
    return cnt;
}
区间筛的三个细节
代码片段含义与理由
int lim = sqrt((long double)R) + 1;sqrt 返回浮点数,可能比真值差一点点,加 1 保证上界够用。多筛一格没有代价。
(L + p - 1) / p * p向上取整到 p 的倍数:不小于 L 的最小 p 的倍数。整数除法自动下取整,所以先加 p−1。
max(p * p, ...)★ 不能只写后面那半。若 L ≤ p,向上取整会得到 p 自己,于是质数 p 被自己划掉,答案少算。取 max 保证 p 本身不被划。
seg[j - L] = 0;下标平移。所有真实数字都减去 L,落到 [0, R−L] 这个小区间里。
if (L == 1) seg[0] = 0;区间左端可能是 1,单独处理。L ≥ 2 时这一行不会误伤。
这一节的 7 种典型错法(含症状)

WA,答案多 1。—— 筛完忘了 isp[1] = false。1 被当成质数先装进去,白占 1 点负荷,后面的数整体错位(L = 100 时第二行变成 78,而不是 100)。

TLE,卡在 n 接近 231 的数据点上。—— 循环写成 i * i <= n 且 i 是 int。i 到 46341 时 i·i = 2147488281 > INT_MAX,溢出成负数,条件永远成立,循环停不下来。改用 i <= n / i。

WA,表里全是 true、cnt 大得离谱。—— 把 bool isp[] 换成 int isp[] 后还用 memset(isp, 1, sizeof(isp))。逐字节填 1 得到 16843009,不是 1(第 26 节的坑在这里复发)。

RE,越界写。—— 数组开 MAXN = 1000000 却让 n = 106,isp[1000000] 已经越界一格,读到数组外的内存,症状是随机 WA 或 RE。开 1000005。

WA,代码看着全对但答案是错的。—— 分解质因数里把 while (n % p == 0) 写成 if,同一个质因子只除一次,n 没除透:360 会拆成 2^1 * 3^1 * 4^1 * 15^1,合数被当成了质因子。

TLE,n 是大质数时跑不出来。—— 分解循环写成 p < n,对 n = 999999937 要试 109 次。必须是 p <= n / p。

WA,区间筛少算质数。—— 起手写成 start = (L + p - 1) / p * p,漏了 max(p * p, ...),于是区间内的质数 p 自己被自己划掉。症状是答案比正确值少一点,且 L 越小区越多。

自己动手复现

关掉页面,从空文件把 P5723 敲一遍并提交到洛谷。

改一改 ①:把外层换成 for (int i = 2; i <= L; ++i),输入 100000,用 time ./a.out 计时,和原版比一下——两行代码差多少倍?

改一改 ②:把内层的 j = i * i 改成 j = 2 * i,重跑样例与 100000。答案变了吗?为什么变不了(想清楚“多做的功”和“错的功”的区别)?

改一改 ③:把 bool isp[MAXN] 改成 int isp[MAXN],把 fill(...) 换成 memset(isp, 1, sizeof(isp)),打印 isp[4] 的值。看清楚它是不是 1,再解释为什么筛完表里还有合数被当成质数。

改一改 ④:写一份分解质因数的程序,输入 999999937(109 以内最大的质数),先把循环写成 p < n 计时,再改成 p <= n / p 计时。

数学补给 · 为什么筛法比试除快:调和级数与素数计数

判 n 以内每个数是不是质数,逐个试除是 O(n√n):n = 106 时 √n = 103,总工作量 109,必然超时。筛法把这件事压到 O(n log log n),差在哪?

筛法的工作量:质数 p 会划掉 n/p 个倍数,总工作量 = Σp ≤ n n/p = n · Σp ≤ n 1/p。而质数倒数和满足 Σp ≤ n 1/p ≈ ln ln n + 0.2615(梅滕斯定理)。

nln ln nn log log n 的常数因子
10^52.442.4
10^72.782.8
10^93.033.0
10^123.323.3

这张表的意思是:n 从 105 涨到 1012(一千万倍),常数因子只从 2.4 涨到 3.3。所以埃氏筛几乎就是线性的,重活全在 n 那个乘法里。

素数的个数:π(n) ≈ n / ln n,实际值比估计略大。

nπ(n) 实际值n / ln n
10^595928686
10^67849872382
10^7664579620421
10^857614555428681

用途很直接:开数组估大小。n = 108 时质数只有 5.76×106 个,primes 数组开 6×106 就够(int 约 23 MB),不用开 108。按 n/ln n 再乘 1.1 估,永远够用。

唯一分解定理的推论:若 n = p1e1p2e2…pkek,则

  • 约数个数 d(n) = (e1+1)(e2+1)…(ek+1);
  • 约数之和 σ(n) = Π (piei+1 − 1) / (pi − 1);
  • 欧拉函数 φ(n) = n · Π (1 − 1/pi)。

这说明分解质因数不是一道孤立的题,而是数论题的通用预处理。后面凡是题目里出现“约数”“真因子”“能整除的数有几个”,想到的第一步都该是这个循环。

举一反三 · 想清楚这些再往下走
  1. 如果有 105 个不超过 1012 的数要判素,筛法筛不到 1012,逐个试除又要 105×106 次,你会怎么折中?(提示:试除时只除以质数,能省多少?)
  2. 有了 lpf 表以后,怎么把一个数 n 在 O(log n) 内分解完?写出循环,并说明为什么它比试除到 √n 快。
  3. 怎么用筛法一次求出 1..n 每个数的约数个数,而不是逐个分解?状态该怎么设计?
  4. 如果一个合数被它的两个质因子各划了一次,答案会错吗?会错的是哪个量?这解释了线性筛存在的理由吗?
  5. 区间筛的时间花在两段:筛 √R 内的质数、划 [L, R] 的倍数。R = 1012、R−L = 106 时哪段更贵?如果改成 R−L = 102 呢?
  6. 为什么 1 既不算质数也不算合数?如果把 1 当质数,唯一分解定理会怎么破?举个具体的反例。
  7. n = 1018 时还能筛吗?不能的话,判断一个 1018 的数是否质数,有什么别的思路?
自测:能全部回答再进下一节
  • 能不看资料写出 i <= n / i 版本的试除法判素,并说清为什么不能用 i * i <= n 配 int。
  • 能默写埃氏筛,并分别说出两个优化各自省了什么,以及删掉其中一个会发生什么。
  • 能解释线性筛里 if (i % p == 0) break; 的作用,说清删掉它之后哪个结果会错。
  • 能写出分解质因数的循环,并说明 if (n > 1) 那一句为什么必要。
  • 知道 107、108 以内各有多少个质数,能据此估算 primes 数组该开多大。
  • 拿到一道题,能根据 n 的范围在 10 秒内选对:试除、埃氏筛、线性筛还是区间筛。
挑战题 · 质因数分解
洛谷 P1075普及-去洛谷做这题 ↗

已知正整数 n 恰好是两个不同质数的乘积,求这两个质数中较大的那个。

输入:一行一个正整数 n(6 ≤ n ≤ 2×109),保证 n 是两个不同质数之积。

输出:一行一个正整数,即较大的那个质数。

引导性提示(先只看这里)

两个质数 p < q 满足 p·q = n,那么 p 一定不超过 √n。所以从 2 往上试,第一个能整除 n 的数就是 p。

试到什么时候停?想清楚 p 和 √n 的大小关系,循环的上界就出来了。

找到 p 之后不用再找了——q 就是 n/p,它一定是质数,因为 n 只有这两个质因子。

想看思路了?点开完整思路

设 n = p·q,p < q 都是质数。由 p < q 得 p·p < p·q = n,所以 p < √n。

从 i = 2 开始往上试,第一个满足 n % i == 0 的 i 就是 p:比 p 小的数都不整除 n(n 只有 p、q 两个质因子);而 p 本身是质数,不会漏。

答案 = n / p。因为 q = n/p,且 q > p。

循环上界用 i <= n / i(等价 i·i ≤ n)。写成 i·i <= n 而 i 是 int 时,n 接近 231 会溢出;写成 i < n 则会 TLE。

复杂度:最坏情况 p 接近 √n ≈ 44721,循环 4 万多轮,远小于 1 秒。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    long long n;
    cin >> n;

    // n = p * q 且 p < q 都为质数,则 p < √n,从 2 往上找第一个因子即是 p
    for (long long i = 2; i <= n / i; ++i) {
        if (n % i == 0) {              // 第一个因子就是较小的质数 p
            cout << n / i << "\n";     // 较大的是 q = n / p
            return 0;                  // 找到即结束,不必再试
        }
    }
    return 0;
}
关键片段拆解
片段作用
long long n;n ≤ 2×109,int 装得下,但乘法和上界比较容易出事,统一用 long long 更稳。
for (long long i = 2; i <= n / i; ++i)上界是 √n。因为 p < q 推出 p < √n,p 一定落在这个范围内;写成 n/i 而不是 i*i,永远不会溢出。
if (n % i == 0)找到因子。第一个找到的 i 必然是最小的质因子 p——比它小的数都不能整除 n。
cout << n / i << "\n";输出较大的那个。注意不是输出 i:题目要的是大的。
return 0;写完答案立刻退出,避免继续循环。
这题最容易错的地方
  • 把 n / i 写成 i:输出的是较小的质数,样例 21 会输出 3 而不是 7。
  • 循环里改动了 n(比如 n /= i):后面就要用原来的 n 算 n/i,改了就错。
  • i 用 int 且条件写 i * i <= n:n 取 231−1 时 i·i 溢出成负数,循环停不下来。
  • 把上界写成 i < n:本题因为找到 p 就 return,还能侥幸过关;但换成“判断 n 是否质数”,循环就是 2×109 次,TLE。上界必须写 √n。
  • 忘了 return:虽然本题能找到因子,但换成“n 本身是质数”的版本就没有输出,养成循环兜底的习惯。
延伸阅读 / 参考
  • 《算法竞赛入门笔记》数论章节:质数判定与筛法
  • 《算法竞赛进阶指南》0x30 数学知识:质数与约数
  • 《算法竞赛试炼场:洛谷300题精析》P5723、P1075 两题的精析
  • 《算法竞赛黄金法则:提高算法和思考力的77项技巧》第 22 条:先估规模再选算法
37 最大公约数、同余与快速幂普及/提高-

37 · 最大公约数、同余与快速幂

数论应用的三件套:辗转相除求 gcd 与 lcm、扩展欧几里得解同余方程与逆元、二进制拆分求 a^b mod m。每一件都附一条防溢出规矩:lcm 先除后乘、快速幂每步取模、乘积超范围上 __int128。
普及/提高-预计 65 分钟gcd扩展欧几里得快速幂
本节出现的新函数 / 新语法(先看懂再用)

上一节把数拆成质数,这一节处理“两个数的公共结构”和“大指数”。三件事:两个数的最大公约数与最小公倍数、同余方程与逆元、ab mod m。第三件是第二件的工具,第二件是第一件的推广——三者用同一套代码互相支撑。

gcd(a, b) = gcd(b, a mod b),递归到 b = 0 就得到答案;ab mod m 只要把 b 拆成二进制,31 轮乘法就能算完。
要做的事数据规模用什么复杂度
两个数的 gcda, b ≤ 10^18辗转相除O(log min(a,b)),≤ 95 步
两个数的 lcm结果 ≤ 9.2×10^18a / gcd * b,先除后乘同上
a·x ≡ 1 (mod m) 的逆元m ≤ 10^9扩展欧几里得 / 费马小定理O(log m)
a^b mod mb ≤ 10^18快速幂O(log b)
两个 10^18 的数相乘再取模mod ≤ 10^18__int128O(1)

先把这几个工具拆开看清。它们都很短,但每一个都有一处“不这么写就错”的细节,这些细节正是这一节真正的考点。

新函数拆解 · std::gcd(C++17)
int g = std::gcd(a, b); long long g = std::gcd(x, y);
组成含义 / 格式要求
头文件<numeric>。<bits/stdc++.h> 已经包含,不用额外写。
两个参数类型必须相同gcd(3, 4LL) 编译不过(模板推导冲突)。竞赛里统一用 long long 最省事。
返回值非负。gcd(−12, 18) = 6,它内部按绝对值算。
gcd(x, 0) = |x|辗转相除的终止条件正好是 b = 0,所以 0 是合法参数,不用特判。
C++17 之前只能手写辗转相除,或用 GNU 扩展 __gcd(a, b)(非标准,MSVC 没有)。
手写版四行:while (b) { t = a % b; a = b; b = t; } return a; 逻辑与递归版完全一样。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int main() {
    cout << gcd(12, 18) << "\n";        // 6
    cout << gcd(0, 7)   << "\n";        // 7:gcd(0, x) = |x|
    cout << gcd(-12, 18)<< "\n";        // 6:取绝对值

    long long a = 1000000000000LL, b = 8;
    cout << gcd(a, b) << "\n";          // 4:两个都是 long long

    // 手写辗转相除(不依赖 C++17)
    long long x = a, y = b;
    while (y) { long long t = x % y; x = y; y = t; }
    cout << x << "\n";                  // 4
    return 0;
}
注意

gcd(3, 4LL) 这类“一个 int 一个 long long”的写法直接编译失败(no matching function)。出错时先看参数的声明类型。

手写版若 a、b 可能为负数,先各自取绝对值:C++ 的 % 对负数返回负余数,不处理会死循环。

新函数拆解 · lcm:先除后乘
long long lcm(long long a, long long b) { return a / gcd(a, b) * b; }
组成含义 / 格式要求
a * b / gcd错。a = b = 4×10^9 时 a·b = 1.6×10^19,超出 long long 上限 9.22×10^18,先溢出再除,结果毫无意义。
a / gcd * b对。gcd 一定整除 a,先除不丢精度;中间值最大就是最终答案,不会比 lcm 更大。
为什么顺序有效公因子被提前消掉了。两个数相乘只做一次,谁先谁后不影响结果,只影响中间值的大小。
std::lcm 也存在C++17 提供,同样先除后乘;但竞赛里更常手写——模意义下的 lcm 要乘逆元,标准库帮不上。
返回值lcm(a, b) = a / gcd(a, b) * b 对正整数恒成立;带负数时先取绝对值。
装不下的情况lcm 本身超过 9.2×10^18 时,任何 long long 写法都没救,要么题目保证范围,要么上 __int128。
最小可运行示例
示例
long long lcm(long long a, long long b) {
    return a / gcd(a, b) * b;      // 先除后乘
}

// 反例:a = b = 4e9
//   a * b / gcd = 1.6e19 / 4e9   → 1.6e19 已超出 long long,溢出成负数
//   a / gcd * b = 1e9 * 4e9      → 4e18,正确
注意

写乘法前先估一次中间值。这是数论题里最常见、也最难查的一类 WA:答案小的时候全对,大测试点突然变成负数。

a ≥ b 时先算 a / gcd 得到的商更小,习惯上把大的放前面。

新函数拆解 · __int128(128 位整数)
__int128 x = (__int128)a * b % m;
组成含义 / 格式要求
范围约 ±1.7×10^38。两个 10^18 的数相乘得 10^36,装进去还剩两位数量级的余量。
不是标准类型GCC / Clang 支持(洛谷、Codeforces、NOI 系列评测机都是 GCC),MSVC 不支持。别名 __int128_t。
不能直接读入输出cin、cout、scanf、printf 全不认识它。读入先读进 long long 再赋值;输出要手写按位打印。
转换要写在乘法之前(__int128)a * b 正确:a 先被提升,b 自动跟着提升。(__int128)(a * b) 错误:乘法已经在 long long 里溢出,再转也救不回来。
速度加减与 long long 接近,乘除取模慢 3~10 倍。10^8 次循环里慎用。
典型用法把一次可能溢出的乘法“抬”到 128 位算完,取模后降回 long long。
最小可运行示例
示例
// ① 防溢出的乘法取模:a、b < m ≤ 10^18
long long mulMod(long long a, long long b, long long m) {
    return (long long)((__int128)a * b % m);   // 中间 10^36,long long 装不下
}

// ② 打印 __int128:cout 不认识它,只能自己按位输出
void print128(__int128 x) {
    if (x < 0) { putchar('-'); x = -x; }
    if (x > 9) print128(x / 10);
    putchar('0' + (int)(x % 10));
}
注意

(long long)(...) 这一步不能省:表达式类型是 __int128,赋给 long long 变量时虽会自动截断,但显式写出来更清楚,也避免 cout 直接输出它。

本节题目 k ≤ 2^31,(2^31)² = 4.6×10^18 还在 long long 内,其实不用 128 位;但模板写成 __int128 版本可以无脑复用,不必每次重新估范围。

新函数拆解 · 快速幂(二进制拆分指数)
long long qpow(long long a, long long b, long long mod);
组成含义 / 格式要求
a底数。进门先 a %= mod,否则第一次乘法就可能用到大数。
b指数。必须用 long long:2^31 = 2147483648 已经超过 int 上限 2147483647。
mod模数。mod = 1 时任何数都同余于 0,结果必须是 0。
r = 1 % mod累乘器初值。直接写 1 在 mod = 1 且 b = 0 时会返回 1,答案应为 0;写 1 % mod 一劳永逸。
b & 1取 b 的最低位。为 1 表示当前这份 a^(2^k) 要乘进答案。
a = a * a % moda 依次变成 a2、a4、a8……每轮平方一次。
b >>= 1右移一位,处理下一个二进制位。循环次数 = b 的二进制位数 ≤ 63。
最小可运行示例
示例
long long qpow(long long a, long long b, long long mod) {
    long long r = 1 % mod;              // 处理 mod = 1 的退化情况
    a %= mod;
    while (b) {
        if (b & 1) r = r * a % mod;     // 这一位是 1:把 a^(2^k) 乘进来
        a = a * a % mod;                // a 平方,准备下一位
        b >>= 1;
    }
    return r;
}

// qpow(2, 10, 9)   = 1024 % 9 = 7
// qpow(2, 0, 1000) = 1
// qpow(7, 5, 1)    = 0
注意

每一步都要取模。写成 r = r * a 最后统一取模,b ≥ 40 就溢出,症状是中等数据突然错、大数据变成负数。

mod ≤ 2^31 时 r·a 不超过 4.7×1018,long long 够用;mod 到 10^18 就必须把两次乘法换成 __int128。

例题 · 【模板】快速幂洛谷 P1226普及/提高-去洛谷做这题 ↗

输入三个整数 b、p、k(1 ≤ b, p, k ≤ 231),求 bp mod k 的值。

输出格式严格为 b^p mod k=s,其中 s 是结果,等号两边没有空格。

注意:p 最大 231,用 int 读入会失败。

输入: 2 10 9 输出: 2^10 mod 9=7
例题怎么想:把指数拆成二进制

先算朴素做法要多久:循环 p 次做一次乘法,p ≤ 231 = 21 亿,就算每纳秒一次乘法也要 2 秒以上,必然 TLE。所以必须减少乘法次数,而不是优化每一步。

关键观察:p 的二进制最多 31 位,p 就是若干个 2 的幂之和。于是 b^p 等于对应的那些 b^(2^k) 相乘——只要手里有 b^1、b^2、b^4、…、b^(2^30) 这 31 个数,按 p 的二进制位挑出来相乘即可。

这 31 个数不用分别算:后一个是前一个的平方。(b^2)^2 = b^4,(b^4)^2 = b^8,一路平方下去,正好 31 次。

取模可以随时插入:因为 (x·y) mod m = ((x mod m)·(y mod m)) mod m。每一次乘法后立刻取模,中间值就永远不超过 m² ≤ 4.6×1018,long long 装得下。

三步循环
r = 1, a = b % k
当 p > 0:
    若 p 的最低位是 1:r = r * a % k     ← 这一位对应的幂要乘进来
    a = a * a % k                        ← 幂次翻倍:a, a², a⁴, ...
    p >>= 1                              ← 右移,看下一位
输出 r

两处细节必须写在代码里,否则有测试点过不了:p 用 long long 读(231 超 int);输出用原始的 b、p、k(不是在函数里取过模的副本)。

完整代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

// 计算 a^b mod m
ll qpow(ll a, ll b, ll m) {
    ll r = 1 % m;                          // m = 1 时答案恒为 0,1 % 1 = 0
    a %= m;                                // 底数先进模,后面的乘法才安全
    while (b) {
        if (b & 1) r = (__int128)r * a % m; // 这一位是 1,把当前的 a^(2^k) 乘进答案
        a = (__int128)a * a % m;            // 平方,得到下一份的幂
        b >>= 1;                            // 处理下一个二进制位
    }
    return r;
}

int main() {
    ll b, p, k;
    cin >> b >> p >> k;                     // p 可达 2^31,必须 long long
    cout << b << "^" << p << " mod " << k << "=" << qpow(b, p, k) << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
ll r = 1 % m;累乘器初值。写 1 的话,m = 1 且 b = 0 时返回 1,而正确答案是 0。
a %= m;底数先取模。否则 b 的最低位就是 1 时,第一轮 r * a 直接用到大数,乘完再取模已经溢出。
while (b)b 每轮右移一位,循环次数等于 b 的二进制位数,p ≤ 2^31 时最多 31 轮。
if (b & 1)取最低位。这一位是 1 表示 a^(2^k) 属于乘积,要乘进 r;是 0 就跳过。
(__int128)r * a % m抬起一轮再取模。m 到 10^18 时 r·a 会溢出 long long,这样写一劳永逸。
a = (__int128)a * a % m;平方。注意这里用的是取模后的 a,与 r 无关,两行互不干扰。
b >>= 1;右移一位。写成 b /= 2 结果一样,移位更贴合“二进制位”的语义。
cin >> b >> p >> k;三个变量都是 long long:p 最大 2^31 = 2147483648,int 上限只有 2147483647,用它读会失败。
cout << b << "^" ...输出的是原始 b、p、k,不是函数里取过模的副本。格式里 ^ 和 = 两边都没有空格。

快速幂解决“指数很大怎么算”。数论的另一半问题是“除法怎么取模”。加减乘都可以边算边取模,只有除法不行:(a / b) mod m 既不等于 (a mod m) / (b mod m),很多时候压根不是整数。要让“除以 b”变成“乘以 b 的逆元”,就得解同余方程 b·x ≡ 1 (mod m)。

运算能否直接取模正确写法
(a + b) mod m能(a mod m + b mod m) mod m
(a − b) mod m能((a mod m − b mod m) + m) mod m,先加 m 防负
(a × b) mod m能快速幂每步取模的依据;中间乘积超范围就上 __int128
(a ÷ b) mod m不能乘以 b 的逆元 b−1,前提是 gcd(b, m) = 1

求逆元要先会解 a·x + b·y = gcd(a, b)。裴蜀定理保证这个方程一定有整数解,而把辗转相除的每一步“回代”就能把解构造出来——这就是扩展欧几里得。

扩展欧几里得(递归回代版)
// 求 a*x + b*y = gcd(a,b) 的一组整数解,返回 gcd(a,b)
// x、y 用引用传递,返回值通过参数带出(第 11 节学过)
long long exgcd(long long a, long long b, long long &x, long long &y) {
    if (b == 0) {                    // 出口:a*1 + 0*0 = a
        x = 1; y = 0;
        return a;
    }
    long long x1, y1;
    long long g = exgcd(b, a % b, x1, y1);   // 先让规模变小,再回代
    x = y1;                          // a 的系数
    y = x1 - (a / b) * y1;           // b 的系数
    return g;
}
回代是怎么来的
代码片段含义与理由
if (b == 0) { x = 1; y = 0; return a; }出口。此时 a 就是 gcd(a, b),取 x = 1、y = 0 满足 a·1 + 0·0 = a。
exgcd(b, a % b, x1, y1)规模从 (a, b) 变成 (b, a mod b),和辗转相除走的是同一条路,所以递归深度也同阶。
b·x1 + (a mod b)·y1 = g递归返回时我们手里有这一条等式。
a mod b = a − (a / b)·b把上式里的 a mod b 展开:b·x1 + (a − (a/b)·b)·y1 = a·y1 + b·(x1 − (a/b)·y1)。
x = y1;对照展开后的式子,a 的系数是 y1,所以新的 x 就是 y1。
y = x1 - (a / b) * y1;b 的系数是 x1 − (a/b)·y1,这就是新的 y。
返回 g顺手拿到 gcd,省得再调一次,也是判断“方程有没有解”的依据。

拿到 exgcd 之后,逆元就是一行的事:a·x + m·y = 1 里的 x 就是 a 模 m 的逆元。另一种求法是把上一节的快速幂接上来——当 m 是质数时,费马小定理直接给答案。

两种求逆元
// ① 扩展欧几里得:任意模数,只要 gcd(a, m) == 1 就有逆元
long long invExgcd(long long a, long long m) {
    long long x, y;
    long long g = exgcd(a, m, x, y);     // a*x + m*y = gcd(a, m)
    if (g != 1) return -1;               // 不互质,逆元不存在
    return (x % m + m) % m;              // 归一化到 [0, m)
}

// ② 费马小定理:只在 m 是质数时可用(1e9+7、998244353 这类)
long long invFermat(long long a, long long m) {
    return qpow(a, m - 2, m);            // a^(m-2) ≡ a^(-1) (mod m)
}
逆元的三处关键
代码片段含义与理由
a·x + m·y = gcd(a, m)裴蜀定理:a·x + m·y 能取到的最小正数恰是 gcd(a, m)。所以方程的右边必须能被 gcd 整除。
if (g != 1) return -1;要让右边等于 1,只有 gcd = 1。这就是“逆元存在 ⟺ 互质”的来源。
(x % m + m) % mC++ 的 % 结果符号跟着被除数走,负数取模仍是负数。先 %m 把它拉进 (−m, m),加 m 变正,再 %m 收进 [0, m)。
qpow(a, m - 2, m)费马小定理:m 为质数且 m ∤ a 时 a^(m−1) ≡ 1,两边同除 a 得 a^(m−2) ≡ a^(−1)。
两种做法的取舍费马版是 log m 次乘法,常数小、代码短;exgcd 版任意模数都能用。m 是 1e9+7 时竞赛里普遍用费马版。
这一节的 8 种典型错法(含症状)

WA,答案恒为 0。—— 快速幂的累乘器写成 long long r = 0;。任何数乘 0 都是 0,样例就能看出来。

WA,小数据全对、大数据错误或为负。—— 每步忘了取模,中间值 40 次乘法后溢出。凡是“取模题答案忽正忽负”,先查这一步。

TLE 或输出随机值。—— 指数 p 用 int 读 2^31。cin 解析失败后 p 保持未初始化值,循环次数完全随机,表现为 TLE 或答案离谱。指数、模数一律 long long。

WA,输出负数。—— 逆元直接写 x % m。样例 3 10 恰好得到正数 7 看不出来,换 a = 2、b = 3 会输出 −1(正确答案 2)。必须 (x % m + m) % m。

WA,答案比正确值小且是负数。—— lcm 写成 a * b / gcd(a, b),a、b 到 4×10^9 时乘积溢出。

RE(除零)或无限递归。—— 手写 gcd 的出口写成 if (b == 1) return 1;。该停的时候 b 已经变成 0,下一轮还要算 a % b,除零 RE。必须是 if (b == 0)。

CE(编译失败)。—— gcd(3, 4LL) 类型不一致;或者用 cin >> x 读 __int128。前者加一个 LL 后缀统一类型,后者只能先读 long long 再赋值。

WA,本应输出“无解”却输出了一个数。—— 同余方程 a·x ≡ b (mod m) 在 gcd(a, m) ∤ b 时无解,但 exgcd 照样会返回 a·x + m·y = g 的一组解,直接拿来用就是错的。先判 b % g != 0 再谈求解。

自己动手复现

关掉页面,从空文件把 P1226 敲一遍并提交到洛谷。

改一改 ①:把 while 循环换成 for (ll i = 0; i < p; ++i) r = r * b % k;,输入 2 1000000000 998244353,用 time 计时——亲眼看一次 10 亿次乘法要多久。

改一改 ②:把 if (b & 1) 改成 if (b & 2),输入 2 10 1000,看答案从 24 变成多少。想清楚为什么只“少乘了某些位”。

改一改 ③:把 lcm 写成 a * b / gcd(a, b),输入 a = b = 4000000000,打印中间值 a*b——观察它变成负数,再换回 a / gcd * b 对比。

改一改 ④:用 exgcd 版求逆元,把 (x % m + m) % m 改成 x % m,输入 a = 2、m = 3,输出会变成 −1。这就是“最小正整数解”必须归一化的原因。

数学补给 · 同余、裴蜀与逆元

同余的定义只有一句:a ≡ b (mod m) 表示 m 整除 a − b。它把整数按“除以 m 的余数”分成 m 类,而所有运算都在这 m 个类上进行——这就是“模 m 剩余系”。

为什么加减乘可以随便取模:若 a ≡ a′、b ≡ b′ (mod m),则 a ± b ≡ a′ ± b′、a·b ≡ a′·b′。所以每一步乘完立刻取模,结果不变。这也解释了快速幂为什么能边乘边模。

除法为什么不行:2 ≡ 8 (mod 6),两边同除 2 得 1 与 4,而 1 ≢ 4 (mod 6)。取模把“差 m 的倍数”这个信息扔掉了,而除法恰好要用到它。补救办法是乘逆元。

裴蜀定理:对任意整数 a、b(不全为 0),存在整数 x、y 使 a·x + b·y = gcd(a, b),而且 gcd(a, b) 是所有形如 a·x + b·y 的正数中最小的那个。

  • 推论 1:a·x ≡ 1 (mod m) 有解 ⟺ gcd(a, m) = 1。(右边 1 必须能被 gcd 整除)
  • 推论 2:a·x ≡ b (mod m) 有解 ⟺ gcd(a, m) | b。有解时在 [0, m) 内恰好有 gcd(a, m) 个解。

通解结构:若 (x0, y0) 是 a·x + b·y = g 的一组解,则全部解为 x = x0 + k·(b/g)、y = y0 − k·(a/g),k 取遍整数。所以对 b 取模后逆元唯一——这就是 (x % m + m) % m 一定对的理由。

费马小定理:p 为质数且 p ∤ a,则 ap−1 ≡ 1 (mod p),于是 a−1 ≡ ap−2。模 109+7 求逆元时 p − 2 = 1000000005,一次快速幂 30 轮乘法搞定。(更一般的是欧拉定理 aφ(m) ≡ 1,条件只有 gcd(a, m) = 1。)

量级感受:gcd 的递归次数不超过 5·log10(min(a, b))(拉梅定理),a、b ≤ 2×109 时不超过 50 层,绝无爆栈风险;快速幂的轮数等于指数的二进制位数,b ≤ 1018 时 60 轮。两个算法在 1018 的量级上都只要几十步——这就是“对数级”的含义。

举一反三 · 想清楚这些再往下走
  1. 如果要算 ab mod m,但 b 是一个 1000 位的十进制字符串(远超 long long),该怎么办?(提示:指数能不能也“取模”?取模的对象是什么?)
  2. gcd(a, b) = gcd(b, a mod b) 为什么成立?请用“a、b 的公因子集合 = b、a mod b 的公因子集合”这个角度写出证明。
  3. gcd(a, b) · lcm(a, b) = a · b 对两个数成立。三个数的时候还成立吗?lcm(a, b, c) 该怎么算才不会溢出?
  4. 扩展欧几里得求出的一组解不唯一。如果要求 x 为最小正数,怎么在 O(1) 内调整(不能暴力试 k)?
  5. 若 gcd(a, m) = d > 1,方程 a·x ≡ b (mod m) 有解的条件是什么?有解时不同的解有几个?怎么把它化归成互质的情形?
  6. 快速幂的模板能不能直接用来算矩阵的幂?需要满足什么条件(结合律?单位元?)才能照抄?
  7. 为什么 r 和 a 每一步都要取模,而 b 不需要?如果 b 也取模会发生什么?
自测:能全部回答再进下一节
  • 能默写辗转相除(递归与迭代两种),并说清出口为什么是 b == 0 而不是 b == 1。
  • 知道 std::gcd 的两个参数类型必须一致,能说出 gcd(0, x)、gcd(−12, 18) 各等于什么。
  • 能写出 lcm 的正确写法,并解释先除后乘省下的是哪一次溢出。
  • 能默写快速幂,说清 r 的初值为什么是 1 % mod、b 为什么要用 long long。
  • 能写出 exgcd 并解释回代公式 y = x1 − (a/b)·y1 是怎么从 a mod b 展开得到的。
  • 知道逆元存在的充要条件,能用 exgcd 和费马小定理各写一遍求逆元,并处理负数归一化。
  • 拿到一道乘法可能溢出的题,能判断该用 long long 还是 __int128,并说清 (__int128)a * b 与 (__int128)(a * b) 的区别。
挑战题 · 同余方程
洛谷 P1082普及/提高-去洛谷做这题 ↗

求关于 x 的同余方程 a·x ≡ 1 (mod b) 的最小正整数解。

输入:一行两个正整数 a、b(2 ≤ a, b ≤ 2×109),用一个空格隔开。

输出:一行一个正整数 x0,即最小正整数解。输入数据保证一定有解。

引导性提示(先只看这里)

把“a·x 除以 b 余 1”翻译成等式:a·x − 1 是 b 的倍数,也就是存在整数 y 使 a·x + b·y = 1。

求 a·x + b·y = gcd(a, b) 的一组解是扩展欧几里得的标准任务。先想清楚:本题里 gcd(a, b) 必须等于几,方程才有解?

解出来的 x 可能为负、也可能大于 b。怎么把它挪进 1 到 b−1 之间?注意 C++ 里负数取模的结果。

想看思路了?点开完整思路

a·x ≡ 1 (mod b) 等价于 b | (a·x − 1),即存在整数 y 使 a·x − 1 = b·y,也就是 a·x + b·(−y) = 1。令 y′ = −y,问题变成解 a·x + b·y′ = 1。

由裴蜀定理,a·x + b·y 能取到的最小正数恰是 gcd(a, b)。题目保证有解,所以 gcd(a, b) 只能是 1。

调用 exgcd(a, b, x, y),返回时 x 满足 a·x + b·y = gcd(a, b) = 1,它就是一组解。

通解是 x + k·b:因为 a·(x + k·b) = a·x + k·a·b ≡ a·x (mod b)。所以把所有解对 b 取模,落进 [0, b) 的那个就是唯一答案。

C++ 的 % 对负数返回负余数,必须写成 (x % b + b) % b。又因为 b ≥ 2 且 a·x ≡ 1 ≠ 0,所以结果不会等于 0,落在 1..b−1,正是最小正整数解。

复杂度:与辗转相除同阶,b ≤ 2×109 时递归不超过 50 层,O(log b)。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

// 求 a*x + b*y = gcd(a,b) 的一组整数解,返回 gcd(a,b)
ll exgcd(ll a, ll b, ll &x, ll &y) {
    if (b == 0) {                       // 出口:a*1 + 0*0 = a
        x = 1; y = 0;
        return a;
    }
    ll x1, y1;
    ll g = exgcd(b, a % b, x1, y1);     // 规模先变小
    x = y1;                             // 回代:a 的系数
    y = x1 - (a / b) * y1;              // 回代:b 的系数
    return g;
}

int main() {
    ll a, b, x, y;
    cin >> a >> b;

    exgcd(a, b, x, y);                  // 得到 a*x + b*y = gcd(a,b) = 1
    x = (x % b + b) % b;                // 归一化到 [0, b):先 % 再 +b 再 %
    cout << x << "\n";
    return 0;
}
关键片段拆解
片段作用
ll exgcd(ll a, ll b, ll &x, ll &y)x、y 用引用传递,答案靠它们带出;返回值是 gcd。a、b 都是 long long,因为上限 2×10^9 已经贴着 int 的边缘。
if (b == 0) { x = 1; y = 0; return a; }递归出口,也就是辗转相除的最后一步:此时 gcd = a,取 x = 1、y = 0 使 a·1 + 0·0 = a 成立。
exgcd(b, a % b, x1, y1)把 (a, b) 换成 (b, a mod b)。这是整个算法唯一“变小”的地方,也是它必然终止的原因。
x = y1; y = x1 - (a / b) * y1;回代。由 b·x1 + (a − (a/b)·b)·y1 = g 直接对照系数得到,不能写反。
x = (x % b + b) % b;★ 归一化。exgcd 给的解可能为负(例如 a = 2、b = 3 时 x = −1),负余数仍是负数,必须加 b 再取模才能落进 [0, b)。
cout << xb ≥ 2 且 a·x ≡ 1 (mod b),所以 x 不会是 0;归一化后的值就是最小正整数解。
这题最容易错的地方
  • 输出 x 而不归一化:样例 3 10 恰好是正数 7,看不出问题;换 a = 2、b = 3 会输出 −1(正确答案 2),WA。
  • 把 x % b 当成“取最小正数”:C++ 的余数跟随被除数符号,负数取模还是负数。
  • 回代公式写错:把 x = y1; y = x1 - (a/b)*y1; 里的 x1、y1 对调,样例 3 10 立刻输出 1 而不是 7。这两行必须从 a mod b 的展开式自己推一遍。
  • 把递归参数换成 exgcd(b, a % b, y, x)(另一种紧凑写法)却照抄上面两行回代公式——两种写法必须成套使用,混用会得到错误的符号。
  • 参数用 int:a、b 到 2×109,中间 a / b、乘法都贴着 int 上限,统一 long long 更安全。
延伸阅读 / 参考
  • 《算法竞赛入门笔记》数论章节:gcd、同余与快速幂
  • 《算法竞赛进阶指南》0x33 同余:扩展欧几里得与逆元
  • 《算法竞赛试炼场:洛谷300题精析》P1226、P1082 两题的精析
  • 《算法竞赛黄金法则:提高算法和思考力的77项技巧》第 31 条:把乘法搬到大类型里算
38 组合计数与容斥原理提高-

38 · 组合计数与容斥原理

把“数方案”拆成乘法与加法两个动作,用杨辉三角把组合数算到模数下,再用容斥把“至少一个”化成“总数 − 一个都没有”,顺手拿下错排。
提高-预计 70 分钟组合数容斥卢卡斯
本节出现的新函数 / 新语法(先看懂再用)

计数题只做两件事。乘法原理:一件事分几步做完,各步的选择互不影响,总方案数是各步之积。加法原理:把全部方案切成互不重叠的几类,总方案数是各类之和。排列、组合、容斥、错排,全是这两条的推论。

先问“分几步”,再问“分几类”,最后问“取模取在哪一步”。三个问题答完,代码就定型了。

从 n 个不同元素里取 m 个排成一排:第一个位置有 n 种选法,第二个 n−1 种,……,第 m 个 n−m+1 种。乘法原理直接给出 A(n,m) = n(n−1)…(n−m+1) = n!/(n−m)!。如果不关心顺序,同一组 m 个元素被数了 m! 次(它们内部的 m! 种排法都算同一种选法),于是 C(n,m) = A(n,m)/m! = n!/(m!(n−m)!)。

“先按有序数、再除掉重复计数”这个动作会反复出现:圆排列除以 n(每种圈法被它的 n 个起点各数了一次),含重复元素的排列除以各元素个数的阶乘。记住这句,比背五个公式有用。

名称公式什么时候用小例子
排列 A(n,m)n!/(n−m)!选出 m 个还要排顺序(座次、名次)5 人排 3 个位置 = 60
组合 C(n,m)n!/(m!(n−m)!)只选出 m 个,不管顺序5 人选 3 人 = 10
可重排列nᵐ每个位置独立有 n 种选法4 位数字密码 = 10000
可重组合(隔板)C(n+m−1, m)m 个相同物品分给 n 人,允许空手3 个球分给 2 人 = 4
圆排列(n−1)!围成一圈,旋转后相同算一种5 人围桌 = 24

第三个动作是取模,它和公式对错完全无关。看这组数:

数值十进制位数存得下吗
C(30,15) = 1551175209 位int 可以
C(34,17) = 233360622010 位int 不行,long long 可以
C(62,31) = 46542835325526108818 位long long 刚好
C(100,50) ≈ 1.01×10²⁹30 位long long 不行
C(2000,1000)约 600 位必须取模

2000! 有 5736 位。所以“这题要不要取模”在读完数据范围的那一刻就定了,不存在“先算出来再取模”这种选项。本节只讲两套组合数求法中最基础的一套,它只有一个加法,因此对模数没有任何要求。

新函数拆解 · 组合数递推表 C[i][j](杨辉三角)
C[i][0] = C[i][i] = 1 % MOD; C[i][j] = (C[i-1][j-1] + C[i-1][j]) % MOD; // 1 <= j <= i-1
组成含义 / 格式要求
C[i][j]从 i 个不同元素里选 j 个的方案数,表里存的是 mod MOD 之后的值。i 是行,j 是列。
两个边界C[i][0] = 1(一个都不选只有一种方案),C[i][i] = 1(全选)。这两格不能靠递推得到,必须手写。写成 1 % MOD 是为了让 MOD = 1 时也对。
递推式的来源盯住第 i 个元素:选它,则还要从剩下 i−1 个里选 j−1 个;不选它,则从 i−1 个里选 j 个。两类互不重叠,加法原理。这就是帕斯卡恒等式 C(i,j) = C(i−1,j−1) + C(i−1,j)。
j 的范围内层只跑 1 到 i−1。j = 0 和 j = i 由边界给出;j > i 的格子保持 0,语义上是“非法状态”,不能当成“C = 0,所以是 MOD 的倍数”。
取模写在哪只写在赋值那一句末尾,括号要包住整个加法。写成 C[i-1][j-1] + C[i-1][j] % MOD 是错的:只对第二项取了模,第一项还是原值。
MOD 不要求是质数整个式子只有加法,没有除法。k = 12、k = 100003 都能直接用。这也是本题能用它的原因:题目给的 k 由输入决定,不保证是质数。
复杂度时间 O(N²),空间 O(N²)。N = 2000 时约 2×10⁶ 次加法(半个三角),int 表 16 MB。
最小可运行示例
示例
const int NMAX = 2005;
int C[NMAX][NMAX];                  // C[i][j] = C(i,j) % MOD

void build(int n) {                 // 预处理第 0..n 行
    for (int i = 0; i <= n; ++i) {
        C[i][0] = C[i][i] = 1 % MOD;        // 边界:选 0 个、选全部
        for (int j = 1; j < i; ++j)
            C[i][j] = (C[i-1][j-1] + C[i-1][j]) % MOD;
    }
}
// build(5) 之后
// 第 4 行:1 4 6 4 1
// 第 5 行:1 5 10 10 5 1
注意

为什么取模不破坏递推:C[i][j] mod MOD 只由两个已经取过模的值相加得到,加法和取模可交换。这是它能被用在“答案 mod k”类题目上的根本原因。

另一套写法是阶乘 + 逆元:O(N) 预处理,O(1) 查 C(n,m),但要求 MOD 是质数且 n < MOD。那套写法的除法要用逆元,第 39 节讲。

新函数拆解 · 子集枚举与 __builtin_popcount(容斥的实现手法)
for (int S = 1; S < (1 << m); ++S) { // 枚举 m 个集合的所有非空组合 int bits = __builtin_popcount(S); // S 里有几个集合 int sign = (bits & 1) ? +1 : -1; // 奇数个:加;偶数个:减 if (sign > 0) ans += ...; else ans -= ...; }
组成含义 / 格式要求
S 的含义一个 m 位二进制数。第 k 位为 1 表示“选上第 k 个集合”。S 从 1 开始,正好跳过空集。
1 << m2 的 m 次方,即子集总数。m 到 20 时是 10⁶ 量级,能枚举;m 到 10³ 就绝不能枚举子集。
__builtin_popcountGCC 内建函数,返回参数二进制里 1 的个数,编译成一条 popcnt 指令。参数是 int 时用它,long long 要用 __builtin_popcountll。
符号容斥公式里“k 个集合的交”前面是 (−1)^(k+1):k 奇数取加号,偶数取减号。所以看 bits 的奇偶。
为什么一一对应m 个集合的并集展开后恰好有 2^m − 1 项,与 2^m − 1 个非空子集一一对应。这是容斥能用二进制枚举实现的全部理由。
最小可运行示例
示例
// 求 1..n 中与 m 互质的数的个数:对 m 的质因子容斥
// k = m 的质因子个数,p[0..k-1] = 这些质因子,n = 上界
int cnt = 0;                                  // cnt = "不互质"的个数
for (int S = 1; S < (1 << k); ++S) {
    long long prod = 1; int bits = 0;
    for (int i = 0; i < k; ++i)
        if (S >> i & 1) { prod *= p[i]; ++bits; }  // p[] 是 m 的质因子
    cnt += (bits & 1) ? n / prod : -(n / prod);    // 奇数个质因子:加;偶数个:减
}
cout << n - cnt << "\n";                           // 补集:总数减“不互质”
注意

n / prod 是“1..n 中能被 prod 整除的数的个数”。把“集合大小”化成一次整除,是容斥题里最关键的观察——否则交集根本算不出来。

prod 必须开 long long:几个质因子相乘很容易超过 int(例如 2·3·5·7·11·13·17·19 = 9699690,再加一个 23 就到 2.2×10⁸)。

组合数的两套求法,别用错场合

递推(本节):O(N²) 时间、O(N²) 空间,只要加法,模数任意。适合 n ≤ 5000、或者模数不是质数。

阶乘 + 逆元(第 39 节):O(N) 预处理、O(1) 查询,要求模数是质数且 n < 模数。适合 n ≤ 10⁶。

卢卡斯定理:n、m 到 10¹⁸ 而模数是小质数(如 100003)时用 C(n,m) ≡ C(n%p, m%p)·C(n/p, m/p) (mod p),递归拆到 n < p 为止。本节不展开,见到“n、m 到 10¹⁸ 且模数很小”要先想到它。

例题 · 组合数问题洛谷 P2822普及/提高-去洛谷做这题 ↗

给定 k 与 t 组询问。对每组 (n, m),统计有多少对 (i, j) 满足 0 ≤ i ≤ n、0 ≤ j ≤ min(i, m) 且 k 整除 C(i, j)。

输入:第一行 t 和 k;接下来 t 行每行两个整数 n、m(0 ≤ n, m ≤ 2×10³,1 ≤ t ≤ 10⁴,k 在本题目测点中为 2~21 之间的整数)。

输出:t 行,每行一个整数,为对应询问的答案。

输入: 1 2 3 3 输出: 1
例题怎么想:把“整除判定”变成“表里的 0”

直接把每个 (i, j) 的真值算出来是不行的:2000! 有 5736 位。但题目只问 k 是否整除 C(i,j),也就是问 C(i,j) mod k 是否等于 0。同余关系对加法、乘法封闭,所以整张表都可以只存模值。

杨辉三角的递推式只有加法:C(i,j) = C(i−1,j−1) + C(i−1,j)。取模后递推依然成立,而且不需要 k 是质数——这一条决定了本题只能用这套写法,不能用阶乘除逆元。

于是第一步:O(n²) 把 C(i,j) mod k 的整张表算出来。n ≤ 2000,只有 4×10⁶ 个格子,很快。

第二步是回答 t ≤ 10⁴ 次询问。每次询问要数的是区域 i ≤ n, j ≤ min(i, m) 里有多少个 0。裸着数就是 O(n·m) 一次,乘 10⁴ 次必然超时。要 O(1) 回答,只剩下二维前缀和。

定义 ok[i][j] = 1 当且仅当 j ≤ i 且 C(i,j) mod k = 0,前缀和 pre[i][j] = ok[i][j] + pre[i−1][j] + pre[i][j−1] − pre[i−1][j−1]。

这里藏着本题唯一的思维难点:j > i 的格子要算成 0,不是算成 1。数学上 C(i,j) = 0(j > i 时),0 是任何数的倍数;但题目统计范围是 0 ≤ j ≤ min(i,m),这些格子根本不在范围内。如果它们被记成 1,答案会明显偏大。用 j <= i 把它挡在外面。

挡好之后,pre[n][m] 就恰好等于答案:因为所有 j > i 的格子 ok 都是 0,二维前缀和自动只累加了合法区域,连 min 都不用写。

最后两个实现细节:① 表的边长要按 max(n, m) 开(本题固定开到 2005),否则 pre[n][m] 那格根本没被算过,读到 0;② 边界写 1 % k,k = 1 时 1 mod 1 = 0,写成字面量 1 就违反了“表里存模值”的约定。

完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 2005;
int C[MAXN][MAXN];        // C[i][j] = C(i,j) mod k
int pre[MAXN][MAXN];      // pre[i][j] = 左上角矩形内 ok 的和
int qn[10005], qm[10005];

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int t; long long k;                 // k 最大到 10⁹ 也可能,用 long long 读不吃亏
    cin >> t >> k;

    int maxn = 0;
    for (int i = 0; i < t; ++i) {
        cin >> qn[i] >> qm[i];
        maxn = max(maxn, max(qn[i], qm[i]));   // m 可能比 n 大,上界要按两者取
    }

    for (int i = 0; i <= maxn; ++i) {
        C[i][0] = C[i][i] = 1 % k;      // 边界。写成 1,k = 1 时就会错
        for (int j = 1; j < i; ++j)
            C[i][j] = (C[i - 1][j - 1] + C[i - 1][j]) % k;   // 只有加法,k 不必是质数
    }

    for (int i = 0; i <= maxn; ++i)
        for (int j = 0; j <= maxn; ++j) {
            // j > i 时 C(i,j) 名义上是 0,但不在统计范围内,必须记 0 而不是 1
            int ok = (j <= i && C[i][j] == 0) ? 1 : 0;
            int v = ok;
            if (i) v += pre[i - 1][j];
            if (j) v += pre[i][j - 1];
            if (i && j) v -= pre[i - 1][j - 1];   // 容斥去重:左上角被加了两次
            pre[i][j] = v;
        }

    for (int i = 0; i < t; ++i)
        cout << pre[qn[i]][qm[i]] << "\n";   // j>i 的格子全是 0,所以不用再写 min
    return 0;
}
逐行拆解
代码片段含义与理由
const int MAXN = 2005;n, m ≤ 2000,多留 5 格防手滑。
int C[MAXN][MAXN];2005² × 4 B ≈ 16 MB。存的是 mod k 之后的值;真值有 600 位,存不下也不需要。
int pre[MAXN][MAXN];再 16 MB,合计 32 MB。前缀和最大到 4×10⁶,int 足够。
int t; long long k;k 的输入范围题目没写死,读 long long 是免费的保险。
maxn = max(maxn, max(qn[i], qm[i]));★ 只按 n 取上界时,m 更大的询问会读到从未算过的格子(值是 0),答案偏小;数组恰好按 n 开小时就是真越界。
C[i][0] = C[i][i] = 1 % k;边界必须手写,而且必须是“模过”的 1。k = 1 时它应为 0——本题 k ≥ 2,但这个习惯要一次养成。
for (int j = 1; j < i; ++j)只跑内部。j = 0 与 j = i 上一句已经给了,重复算会覆盖成同一个值,纯属浪费。
C[i][j] = (a + b) % k取模的括号包住整个加法。少一个括号只对第二项取模,答案会随 n 增大而越来越偏小。
int ok = (j <= i && C[i][j] == 0) ? 1 : 0;★ 本题最容易错的一句。C 表里 j > i 的位置从没写过、值是 0,所以少了 j ≤ i 它们就全被当成“k 的倍数”,样例直接错成 7。
if (i && j) v -= pre[i - 1][j - 1];二维前缀和的容斥:多算了两块,要减掉左上角一次。下标为 0 时跳过,避免访问 pre[-1][*]。
cout << pre[qn[i]][qm[i]];答案直接就是这个前缀和,不需要再减去什么——非法格子已经被 ok 定义排除了。

容斥解决的是“至少满足一个”。把“满足条件 i”记成集合 Aᵢ,则

容斥原理
|A₁ ∪ A₂ ∪ … ∪ A_m| =
    Σ|Aᵢ|  −  Σ|Aᵢ∩Aⱼ|  +  Σ|Aᵢ∩Aⱼ∩Aₖ|  −  …  +  (−1)^(m+1)|A₁∩…∩A_m|
    (第一个求和枚举 1 个集合,第二个枚举 2 个,共 2^m − 1 项)

它为什么对:设某个元素恰好属于 r 个集合(r ≥ 1)。右边把它数了 C(r,1) − C(r,2) + C(r,3) − … 次,而这个交错和等于 1 − (1−1)^r = 1。不属于任何集合的元素被数 0 次。所以每个该被数的元素恰好被数一次。证明用的是二项式定理,见本节数学补给。

例:1..1000 中不能被 2、3、5 整除的数有多少个?令 A₂、A₃、A₅ 分别是被 2、3、5 整除的数。

容斥的一次完整计算
1000 − (500 + 333 + 200) + (166 + 100 + 66) − 33 = 266
  ↑总数   ↑一个集合           ↑两个集合的交        ↑三个集合
          500=|A₂|  333=|A₃|  200=|A₅|
          166=|A₂∩A₃|(÷6)  100=|A₂∩A₅|(÷10)  66=|A₃∩A₅|(÷15)  33=|A₂∩A₃∩A₅|(÷30)

注意每一步都只用到 ⌊n/d⌋:“1..n 中能被 d 整除的数的个数”。容斥题能不能做,几乎全看交集的集合大小能不能 O(1) 算出来。

容斥的常见外壳把什么当作集合的并交集大小怎么算
1..n 中与 m 互质“能被 m 的某个质因子整除”⌊n/prod⌋,prod 是若干质因子之积
全装错(错排)“第 i 封信装对了”的并集,再用 n! 减去它(n−k)!:固定 k 封信,其余随便放
恰好 k 个位置满足“第 i 个位置满足”的并集,共 C(n,k) 种选法C(n,k)·D(n−k):先定下 k 个,其余错排

错排是容斥最经典的应用。n 封信全部装错:令 Aᵢ = “第 i 封信装进了第 i 个信封”,要的是 |A₁∪…∪Aₙ| 的补集。|Aᵢ| = (n−1)!,|Aᵢ∩Aⱼ| = (n−2)!,k 个集合的交 = (n−k)!,而 k 个集合有 C(n,k) 种选法,于是

错排的容斥式
D(n) = n! − C(n,1)(n−1)! + C(n,2)(n−2)! − … + (−1)ⁿ·C(n,n)·0!
     = n! · ( 1 − 1/1! + 1/2! − 1/3! + … + (−1)ⁿ/n! )

这个和式里都是除法,不好直接算。把它化简成只含加减乘的递推:

错排的递推式
D(1) = 0,  D(2) = 1
D(n) = (n − 1) · ( D(n−1) + D(n−2) )        // n ≥ 3

证明:第 n 封信一定不在第 n 个信封里,设它进了第 k 个信封(k 有 n−1 种选法)。此时看第 k 封信,只有两种可能:① 它进了第 n 个信封——两封信互换,剩下 n−2 封要错排,方案数 D(n−2);② 它没进第 n 个信封——把“第 n 个信封”改名成“第 k 个信封”,剩下的正好是 n−1 封信的错排,方案数 D(n−1)。两类互不重叠,相加后乘 n−1。

前几项:0, 1, 2, 9, 44, 265, 1854, 14833, 133496, 1334961。手算 D(3) = 2 可以验证:只有 (2,3,1) 和 (3,1,2) 两种。D(13) = 2290792932 已经超 int,D(20) = 895014631192902121 还在 long long 内,D(21) = 18795307255050944540 就超了。

例题 · 信封问题洛谷 P1595普及-去洛谷做这题 ↗

某人写了 n 封信,有 n 个信封,求所有信都装错信封的方案数。

输入:一个整数 n(1 ≤ n ≤ 20)。输出:一行一个整数,表示方案数。

输入: 3 输出: 2
例题怎么想:认出它是错排,然后选递推

题意就是错排 D(n):n 个元素的排列中,没有一个元素落在自己的位置上。n ≤ 20。

有两条路。第一条是容斥和式 D(n) = n!(1 − 1/1! + 1/2! − …),要算阶乘、要做除法,还容易溢出。第二条是递推 D(n) = (n−1)(D(n−1) + D(n−2)),只有加法和乘法。选第二条。

边界:D(1) = 0(1 封信只有“装对”这一种放法,全错是不可能的),D(2) = 1(两封互换)。这两个值必须手写,因为递推式要减到 n−2。

方向:D(n) 依赖 D(n−1)、D(n−2),下标都比 n 小,所以 n 从小到大。

类型:D(20) ≈ 8.95×10¹⁷,18 位,long long 刚好。中间量 (n−1)·(D(n−1)+D(n−2)) 最大也才 8.95×10¹⁷,不溢出。

复杂度 O(n),n = 20 时 18 次乘法。这种“答案本身就要 18 位”的题,long long 不是保险而是必需品。

完整代码
#include <bits/stdc++.h>
using namespace std;

long long D[25];        // D[n] = n 封信全部装错的方案数,n ≤ 20

int main() {
    int n;
    cin >> n;

    D[1] = 0;           // 1 封信只有"装对"一种放法,全错 0 种
    D[2] = 1;           // 2 封信只有互换这一种放法,且两封都错
    for (int i = 3; i <= n; ++i)
        D[i] = (long long)(i - 1) * (D[i - 1] + D[i - 2]);   // 第 i 封有 i-1 个"错"位置可选

    cout << D[n] << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
long long D[25];n ≤ 20,开 25 富余。全局数组默认全 0,但 D[1]、D[2] 仍然必须显式写,因为它们的值不是 0 和 0。
D[1] = 0;边界一。n = 1 时下面的循环一次都不进,答案直接取这一格。
D[2] = 1;边界二。递推式用到前两项,所以边界必须给两项——这是硬性对齐关系:用到几项就补几个边界。
for (int i = 3; i <= n; ++i)从小到大。D[i] 读 D[i−1]、D[i−2],都算好了。
(i - 1) * (D[i-1] + D[i-2])第 i 封信有 i−1 个“不能选”的信封可选,每种选法下方案数是两类之和。注意是乘不是加——乘法原理。
(long long)(i - 1)i 是 int,(i−1) 与 long long 相乘时本来就会自动提升。这个强转是写给读者看的:提醒这里会产出 18 位的数。
cout << D[n] << "\n";答案是 D[n] 这一格。n = 1、2 时循环不执行,靠的就是上面两个边界。
取模与溢出:4 个能直接复现的症状

WA,答案偏小,样例就对不上:% k 只写了最后一项,例如 C[i][j] = C[i-1][j-1] + C[i-1][j] % k;。这样每个格子存的是两个模值之和,可能落在 [0, 2k−2];恰好等于 k 的那些格子不会被判成“是 k 的倍数”。凡是 + 或 * 出现在取模左边,括号就要包住整个表达式。

WA,输出负数:容斥里做了减法,最后没 (x % MOD + MOD) % MOD。C++ 的 % 对负数返回负数,和数学上的同余不是一回事。

WA 或 RE,n 到十几就开始错:用 int 存 C(n,m) 的真值。C(34,17) = 2333606220 已经超 int,C(62,31) 是 18 位,C(100,50) 是 30 位。看到“组合数”先估位数。

答案对但 TLE:每次询问都重新数一遍区域,复杂度 O(t·n·m) = 10⁴×2000×2000 ≈ 4×10¹⁰。预处理 + 二维前缀和才能把询问压到 O(1)。

P2822 特有的三个坑

WA,答案偏大,样例就会错:把 j > i 的格子算进了“C = 0,是 k 的倍数”。C 表里 j > i 的位置从没被写过,值就是 0,C[i][j] == 0 恒成立。少了 j <= i 这个条件,样例 1 2 / 3 3 会输出 7,而正确答案是 1。修法:ok = (j <= i && C[i][j] == 0)。

WA(上界取错):maxn 只按 n 取,而某个询问的 m 比 n 大,pre[n][m] 就没被算过。本题数组开得够大,不会真的越界,但读到的是 0:输入 1 2 / 3 5 会输出 0,正确答案是 1。上界必须取 max(maxn, max(qn[i], qm[i]))。数组若恰好按 n 开,就是真越界 RE。

k = 1 时全错:边界写成 C[i][0] = 1,而表里该存的是 1 mod 1 = 0。此后每个格子的“是否为 0”判断全反:输入 1 1 / 3 3 时正确答案是 10(1 整除一切),你的程序会输出 3。写成 1 % k 一次解决。

自己动手复现

关掉页面,从空文件把 P2822 敲一遍并提交,样例 1 2 / 3 3 必须输出 1。

改一改 ①:把 ok 那句里的 j <= i && 删掉,只留 C[i][j] == 0。样例立刻从 1 变成 7;再试 1 2 加 5 1,答案从 2 变成 3。这两组对比把“非法格子”到底是什么,说得清清楚楚。

改一改 ②:把 maxn 那行改成只取 max(maxn, qn[i]),然后输入 1 2 加 3 5。正确答案是 1,你会得到 0——因为 pre[3][5] 根本没被算过。

改一改 ③:把 1 % k 改成 1,输入 1 1 加 3 3。正确答案是 10(1 整除一切,所有格子都算),你的程序会输出 3。

改一改 ④:把前缀和整段删掉,直接对每个询问双重循环数一遍。自己造 t = 10⁴、n = m = 2000 的数据,看它要跑几秒——这就是“能过与不能过”的差别。

数学补给 · 二项式定理:所有组合恒等式的源头

杨辉三角第 i 行就是 (1+x)^i 展开后的系数。把 (1+x)ⁿ 看成 n 个括号相乘,每个括号里挑 x 或挑 1,挑出 x 的那 k 个括号有 C(n,k) 种选法,于是

二项式定理(a=1, b=x)
(1+x)^n = C(n,0) + C(n,1)x + C(n,2)x² + … + C(n,n)xⁿ

一般形式是 (a+b)ⁿ = Σ C(n,k)·a^(n−k)·b^k。代不同的值,就能一次拿到一批恒等式:

代什么得到用途
x = 1C(n,0)+C(n,1)+…+C(n,n) = 2ⁿ子集总数;杨辉三角每行之和
x = −1C(n,0)−C(n,1)+C(n,2)−… = 0大小子集一样多;容斥的证明
a=1,b=x 后求导再代 x=1Σ k·C(n,k) = n·2^(n−1)加权和,下一节算期望会用到
n = 5 展开1 + 5x + 10x² + 10x³ + 5x⁴ + x⁵与杨辉三角第 5 行完全一致

容斥公式的正确性就是 x = −1 的那一行。设某个元素恰好属于 r 个集合(r ≥ 1),它被右边数了

每个元素被数几次
C(r,1) − C(r,2) + C(r,3) − … + (−1)^(r+1)·C(r,r)
= 1 − [ C(r,0) − C(r,1) + C(r,2) − … + (−1)^r·C(r,r) ]
= 1 − (1−1)^r = 1

中间那个中括号整个就是 (1−1)^r = 0(r ≥ 1)。所以并集的每个元素恰好被数一次,不在并集里的元素被数 0 次。容斥公式不是“记下来的”,是二项式定理的一个特例。

再看错排和 e 的关系。把容斥式两边除以 n!:

错排概率收敛到 1/e
D(n)/n! = 1 − 1/1! + 1/2! − 1/3! + … + (−1)^n/n!
n = 10:D(10)/10! = 1334961/3628800 = 0.3678794643
1/e                     = 0.3678794412      差 2.3×10⁻⁸

所以 n ≥ 10 以后,n 封信全装错的概率就稳定在 36.79%,与 n 几乎无关。这也是那道“n 封信随机装入 n 个信封,期望有多少封装对”答案是 1 的原因:每封信装对的概率是 1/n,n 封加起来恒等于 1(线性性,下一节细讲)。

最后一条工程结论:组合数的增长由 n 决定,而可用的类型只由位数决定。C(2000,1000) 约 10⁶⁰⁰,任何固定宽度的整数都装不下,取模是唯一出路;而 C(62,31) 恰好 18 位,是 long long 的极限——这两个数字值得记住,它们划出了“能直接算”与“必须取模”的分界。

举一反三 · 想清楚这些再往下走
  1. P2822 如果改成“统计 k 不整除 C(i,j) 的对数”,前缀和要改哪几行?能不能不新建数组就得到答案?
  2. k = 2 时,“C(n,m) 是奇数”有 O(1) 判据:(n & m) == m(卢卡斯定理 mod 2)。用它能不能把询问做到 O(1)?既然能,题目为什么还要你写 O(n²)?
  3. 错排的容斥式里有 1/k!,如果题目要求把 D(n) 对 10⁹+7 取模,这个除法要怎么处理?为什么递推式没有这个问题?
  4. “恰好 k 个人坐对”的方案数是 C(n,k)·D(n−k)。这个式子用的是哪两条原理?为什么不用容斥也能得到?
  5. 容斥的项数是 2^m。什么情况下 m 会到 10³ 以上?那时该换什么思路(提示:想想“只有很少几个集合会被选中”的场合)?
  6. 为什么 A(2000,3) 可以直接算(约 8×10⁹,long long 够),而 C(2000,1000) 必须取模?给出一个“能不取模就先不取模”的实际好处。
自测:能全部回答再进下一节
  • 能默写 C[i][j] 的两条边界和递推式,并说清为什么它不需要逆元、不要求模数是质数。
  • 能说出 C(30,15)、C(34,17)、C(100,50) 的量级,并据此决定用 int、long long 还是取模。
  • 能写出容斥公式,并用“每个元素被数一次”独立验证它,而不是背下来。
  • 能自己推出 D(n) = (n−1)(D(n−1)+D(n−2)) 的两种情况划分,并说清 D(1)、D(2) 为什么是 0 和 1。
  • 能说清 P2822 里 j > i 的格子为什么不能计入,以及二维前缀和为什么自动排除了它们。
  • 知道负数取模要先 +MOD 再 %MOD,并说得出一个会踩到这个坑的场景。
挑战题 · 越狱
洛谷 P3197普及/提高-去洛谷做这题 ↗

监狱有 n 个房间,每个房间关押一个犯人,共有 m 种宗教,每个犯人信仰其中一种。如果相邻两个房间的犯人信仰相同,就可能发生越狱。求有多少种状态可能发生越狱,答案对 100003 取模。

输入:一行两个整数 m 和 n,分别是宗教数与房间数(1 ≤ m ≤ 10⁸,1 ≤ n ≤ 10¹²)。

输出:一行一个整数,表示答案 mod 100003。

引导性提示(先只看这里)

直接数“至少有一对相邻相同”很麻烦:n−1 个位置都可能相同,而且这些事件互相重叠。

反过来数“一对相邻相同的都没有”。第 1 个房间有 m 种选法,第 2 个房间呢?第 3 个呢?

总数减去它。两个幂的指数最大到 10¹²,要用快速幂,别写循环。

想看思路了?点开完整思路

总状态数:每个房间独立选一种宗教,mⁿ 种。

无越狱的状态:第 1 个房间 m 种;此后每个房间都不能与左边那个相同,各 m−1 种。乘法原理得 m·(m−1)^(n−1)。

答案 = (mⁿ − m·(m−1)^(n−1)) mod 100003。这就是容斥里最简的一种:“至少一个”= 总数 − “一个都没有”。

n = 1 时公式给 m − m·(m−1)⁰ = 0,符合题意(只有一个房间,谈不上相邻)。

实现要点:m 可能 ≥ 100003,先把 m 对 MOD 取模再算幂;指数 n ≤ 10¹² 必须 long long;相减后可能为负,要 (x % MOD + MOD) % MOD。

复杂度 O(log n),每个幂约 40 次乘法。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const long long MOD = 100003;

long long qpow(long long a, long long b) {   // a^b mod MOD
    long long res = 1 % MOD;
    a %= MOD;
    while (b > 0) {
        if (b & 1) res = res * a % MOD;
        a = a * a % MOD;
        b >>= 1;
    }
    return res;
}

int main() {
    long long m, n;
    cin >> m >> n;              // 先读宗教数 m,再读房间数 n
    m %= MOD;                   // 底数先压进模数,避免中间乘积溢出

    long long total = qpow(m, n);                              // 全部状态 m^n
    long long bad   = m * qpow((m - 1 + MOD) % MOD, n - 1) % MOD;  // 一个相邻相同都没有
    cout << (total - bad + MOD) % MOD << "\n";                 // 减法后补 MOD 再取模
    return 0;
}
关键片段拆解
片段作用
cin >> m >> n;题目输入顺序是“宗教数 m 房间数 n”。写反了样例都过不去,是本题最常见的 0 分原因。
m %= MOD;m 最大 10⁸,先取模。取模之后 m−1 可能是 −1,所以下面要 (m−1+MOD)%MOD。
m * qpow((m - 1 + MOD) % MOD, n - 1)无越狱方案数 m·(m−1)^(n−1)。注意这里的 m 已经取过模,与 total 里的底数保持一致。
(total - bad + MOD) % MOD两个模值相减可能为负,加 MOD 保证落在 [0, MOD)。少了这一步,答案会变成负数。
long long nn ≤ 10¹²,int 装不下。qpow 的第二个参数和 b >>= 1 都依赖它是 long long。
这题最容易错的地方
  • 把 m 和 n 读反。题目输入的顺序是“宗教数 m、房间数 n”,不是按“房间数 n 个”的习惯先读 n。
  • 指数用 int:n ≤ 10¹²,读进 int 直接溢出,答案随机。
  • 忘了 +MOD:真值非负,但两个模值谁大谁小并不固定。m = 4、n = 10 时 total = 48546、bad = 78732,直接相减得 −30186,答案错。
  • 不先 m %= MOD:m ≤ 10⁸ 时 a·a = 10¹⁶ 还在 long long 内,看似没事,但好习惯是底数一律先取模。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》0x36 组合计数:帕斯卡恒等式与容斥
  • 《算法竞赛入门笔记》容斥原理:每个元素被数一次
  • 《算法竞赛黄金法则》第 41 条:先数补集,再数正面
  • 洛谷 P2822、P1595 题解区的高赞推导
39 概率与期望入门提高-

39 · 概率与期望入门

从“数情况”走到“算期望”:先把总分拆成 0/1 指示变量之和,用线性性逐项算概率;拆不动时改成倒推 DP;最后分清 double 与除法取模两条路。
提高-预计 65 分钟概率期望线性性
本节出现的新函数 / 新语法(先看懂再用)

概率的第一性原理仍然是计数:把所有等可能的情况数清,分子是符合条件的那部分。期望在此之上多一步——把每个结果乘上它的概率,再全部相加。竞赛里这两件事都不用“模拟”,只用一个公式:期望的线性性。

别去算整体的分布,去算每一个最小单位的贡献。它通常只依赖常数个数据,于是 2ⁿ 种情况被压成 n 个乘法。

三个公式打底。P(Ā) = 1 − P(A):补集,恒成立,是“至少一个”类问题的首选。P(A∪B) = P(A) + P(B) − P(A∩B):一般并,恒成立。P(A∩B) = P(A)·P(B):只在 A、B 独立时成立。

“独立”的意思是:一个发生与否,不改变另一个的概率。两道题的正确答案分别是什么,是独立的;同一道题的两个不同选项,不独立。判断独立性不能靠感觉,要看两个事件的信息是否互相影响。

运算公式前提典型场景
补集P(Ā) = 1 − P(A)永远成立至少中一次、全部装错
互斥相加P(A∪B) = P(A) + P(B)A、B 不能同时发生掷出 1 或 2
一般并P(A∪B) = P(A)+P(B)−P(A∩B)永远成立两张牌至少一张是红桃
独立相乘P(A∩B) = P(A)·P(B)A、B 独立两次射击都命中
条件概率P(A|B) = P(A∩B)/P(B)P(B) > 0已知第一次是正面

两个可以立刻心算的例子:掷两枚硬币至少一个正面,1 − (1/2)(1/2) = 3/4;掷两枚骰子和为 7,只有 (1,6)(2,5)(3,4)(4,3)(5,2)(6,1) 六种,6/36 = 1/6。所有“和为几”的问题都是数点对,不要背公式。

新函数拆解 · double 存概率:除法、精度与输出
double p = 1.0 * cnt / tot; // 先变成浮点再除 printf("%.3f\n", ans); // 保留 3 位小数
组成含义 / 格式要求
1.0 * ...把整数强行转成浮点再除。写成 cnt / tot 是整数除法,7/6 得到 1,1/6 得到 0,概率题会整片变成 0 和 1。
double 的精度53 位尾数,约 15~16 位十进制有效数字。累加 10⁷ 项的相对误差约 10⁻⁹,输出 3 位小数安全。
为什么不用 floatfloat 只有 24 位尾数,约 7 位有效数字。累加 10⁷ 项之后误差到 10⁻² 量级,答案会错在最后两位。
判等不要写 a == b,写 fabs(a - b) < 1e-9。二进制无法精确表示 0.1,所以 0.1 + 0.2 != 0.3。
输出格式printf("%.3f", x) 与 cout << fixed << setprecision(3) << x 等价,都是四舍五入到 3 位。题目写“保留三位”就输出三位,多一位少一位都 WA。
什么时候不用它题目要求“答案 mod 998244353”时,浮点毫无用处,要换成逆元(见第三张卡)。
最小可运行示例
示例
double p = 1.0 * 3 / 6;          // 0.5;写成 3 / 6 得到 0
double q = 7.0 / 6;

printf("%.3f\n", q);             // 1.167
printf("%.6f\n", 1.0 / 3);       // 0.333333

if (fabs(p - 0.5) < 1e-9) puts("equal");   // 浮点判等要带容差
注意

分子分母都是整数,但一旦做除法,就必须保证至少一边是浮点。最省事的写法是分子写 1.0 * ...。

double 能精确表示 0.5、0.25、0.125,不能精确表示 0.1、1/3、1/7。所以“理论上是 0.5”的答案可能算成 0.49999999999999994——不影响三位小数,但会破坏判等。

新函数拆解 · 期望的线性性
E[X + Y] = E[X] + E[Y] // 恒成立,不需要独立 E[a·X + b] = a·E[X] + b
组成含义 / 格式要求
X、Y 是什么同一个随机试验上的两个量。可以是任意两个量,甚至互相依赖。
为什么不需要独立证明只用到“联合概率求和”和分配律,独立二字根本没进推导(见本节数学补给)。
最强的用法把总分拆成一堆 0/1 指示变量之和:X = X₁ + X₂ + … + Xₙ,Xᵢ 只标记“第 i 件事发生了”。于是 E[X] = Σ P(第 i 件事发生),整体期望变成 n 个局部概率相加。
不能乱用乘法没有线性性:一般 E[XY] ≠ E[X]·E[Y](只有独立时才相等);E[X²] ≠ (E[X])²。
常见误解“期望”不是“最可能的值”。一枚均匀骰子的期望是 3.5,而 3.5 永远不会掷出来。
最小可运行示例
示例
// n 个人随机坐 n 个座位,期望有多少人坐对?
// 第 i 个人坐对的概率是 1/n,n 个人相加 → 答案恒为 1,与 n 无关
double ans = 0;
for (int i = 1; i <= n; ++i) ans += 1.0 / n;      // ans = 1

// 掷一枚均匀骰子:E = Σ k·(1/6) = 21/6 = 3.5
double e = 0;
for (int k = 1; k <= 6; ++k) e += k * (1.0 / 6);
注意

指示变量是期望题的第一武器:把“做对几题”“出现几次”写成 0/1 之和,整体期望自动拆成 n 个局部概率。

局部概率通常只依赖常数个数据(例如相邻两道题的选项数),于是整题从 O(2ⁿ) 降到 O(n)。

新函数拆解 · 除法取模:逆元与费马小定理
long long inv(long long a) { return qpow(a, MOD - 2); } // MOD 是质数 ans = p % MOD * inv(q) % MOD; // p/q mod MOD
组成含义 / 格式要求
为什么不能直接除(a/b) mod MOD ≠ (a mod MOD) / (b mod MOD)。例如 1/2 mod 7:直接除得 0,正确答案是 4,因为 2×4 = 8 ≡ 1 (mod 7)。
逆元的定义a·a⁻¹ ≡ 1 (mod MOD)。把“除以 a”换成“乘以 a⁻¹”,除法就消失了。
费马小定理MOD 是质数且 a 不是 MOD 的倍数时,a^(MOD−1) ≡ 1,于是 a⁻¹ ≡ a^(MOD−2)。一次快速幂,O(log MOD)。
两个前提① MOD 必须是质数(10⁹+7、998244353 都行);② 分母 q 不能是 MOD 的倍数,否则逆元不存在。
常用值mod 10⁹+7 下:1/2 = 500000004,1/3 = 333333336,1/6 = 166666668,1/7 = 142857144。
最小可运行示例
示例
const long long MOD = 1000000007;

long long qpow(long long a, long long b) {
    long long res = 1 % MOD; a %= MOD;
    while (b) { if (b & 1) res = res * a % MOD; a = a * a % MOD; b >>= 1; }
    return res;
}

// 掷一次骰子出现 6 的概率是 1/6,写成模意义下的数:
cout << 1 * qpow(6, MOD - 2) % MOD << "\n";       // 166666668
// 验算:6 × 166666668 = 1000000008 = MOD + 1 ≡ 1
注意

什么时候用哪套:要求输出实数 → double;要求输出“答案 mod 998244353” → 逆元。后者越来越常见,因为它彻底没有精度问题。

多个分数相加时,逐项换成逆元再加,不要先把分母乘起来——分母的乘积极容易变成 MOD 的倍数,那时逆元就不存在了。

例题 · 单选错位洛谷 P1297提高+/省选-去洛谷做这题 ↗

n 道单选题,第 i 道题有 aᵢ 个选项,每个选项成为正确答案的概率相等。gx 全部做对了,但把第 i 题的答案填到了第 i+1 题的位置上(第 n 题的答案填到第 1 题)。求他期望做对几道题。

输入:一行 5 个整数 n, A, B, C, a₁(2 ≤ n ≤ 10⁷,0 ≤ A, B, C ≤ 10⁸)。因为 n 很大,a 由规则生成:q₁ = a₁,qᵢ = (qᵢ₋₁·A + B) mod 100000001(i ≥ 2),最后 aᵢ = qᵢ mod C + 1。

输出:一个实数,期望做对的题数,保留三位小数。

样例说明(n = 3,a = {2, 3, 1}): 6 种正确答案等概率,对应做对 3、1、1、1、1、0 题 期望 = 7/6 ≈ 1.167,输出 1.167
例题怎么想:把“做对几题”拆成 n 个 0/1

先处理输入。这不是“读 n 个数”,而是读 5 个参数再按规则生成 a。规则里有个陷阱:qᵢ 用的是还没 mod C 的 qᵢ₋₁ 迭代,两步必须分开——先把整串 q 生成完,再统一做 aᵢ = qᵢ mod C + 1。混在一起写,从第 2 项起就全错。

概率部分:设 Xᵢ = 1 表示“抄到第 i+1 题位置上的那个答案,恰好等于第 i+1 题的正确答案”。第 n 题的答案填在第 1 题的位置,所以 Xₙ 看的是第 1 题。做对的题数 X = X₁ + X₂ + … + Xₙ,每个 Xᵢ 只取 0 或 1。

由线性性,E[X] = Σ E[Xᵢ] = Σ P(Xᵢ = 1)。这一步把题目之间的所有关联切断了:不需要知道 2ⁿ 种全局情况,也不需要管 X₁ 与 X₂ 是不是独立,每题单独算概率就行。

算 P(Xᵢ = 1)。第 i+1 个位置上写的是“第 i 题的正确答案”,它在 1..aᵢ 上均匀,概率 1/aᵢ;第 i+1 题的正确答案在 1..a_{i+1} 上均匀,概率 1/a_{i+1},而且两者独立。于是

P = Σ_{v=1}^{min(aᵢ,aᵢ₊₁)} (1/aᵢ)(1/aᵢ₊₁) = min/(aᵢ·aᵢ₊₁) = 1/max(aᵢ, aᵢ₊₁)。一个“最小值除以乘积”,化简后就是一个“最大值取倒数”。

所以答案 = Σ_{i=1}^{n} 1/max(aᵢ, aᵢ₊₁),其中 a_{n+1} = a₁。用样例 a = {2,3,1} 验算:1/3 + 1/3 + 1/2 = 7/6 ≈ 1.167,与题面一致。

实现:n ≤ 10⁷,开数组要 40 MB;但每一项只依赖相邻两个 a,边生成边累加即可,额外空间 O(1)。注意第 n 题的答案要填到第 1 题,所以循环结束后还差一项,别漏。

精度:n 项相加,每项 ≤ 1,和 ≤ 10⁷。double 的相对误差 10⁻¹⁶,累加 n 项的绝对误差约 10⁻⁹ 量级,保留三位小数完全安全。

完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, A, B, C;
    long long a1;
    cin >> n >> A >> B >> C >> a1;

    // 生成规则分两步:先按递推算出未取模的 q,再统一做 q mod C + 1。
    // 这里用流式写法:raw 保存"未 mod C"的值,first / prev 保存真正用到的 a。
    long long raw = a1;
    int first = (int)(raw % C) + 1;       // 第 1 题的选项数
    int prev  = first;
    double ans = 0;

    for (int i = 2; i <= n; ++i) {
        raw = (raw * A + B) % 100000001;  // raw·A 最大约 10¹⁶,long long 才装得下
        int cur = (int)(raw % C) + 1;
        ans += 1.0 / max(prev, cur);      // E[X_{i-1}] = 1 / max(a_{i-1}, a_i)
        prev = cur;
    }
    ans += 1.0 / max(prev, first);        // 第 n 题的答案填在第 1 题,补上最后一项

    printf("%.3f\n", ans);
    return 0;
}
逐行拆解
代码片段含义与理由
cin >> n >> A >> B >> C >> a1;5 个参数。a1 要 long long:先不取模,它可能到 10⁸,而 raw·A 会到 10¹⁶。
long long raw = a1;raw 是“还没做 mod C + 1”的那串 q。它和真正参与计算的 a 必须分开存,否则从第 2 项起全错。
int first = (int)(raw % C) + 1;a₁ = q₁ mod C + 1。first 留着给最后一项用(第 n 题的答案填到第 1 题)。
raw = (raw * A + B) % 100000001;递推。先乘后加再取模,中间值最大约 10⁸×10⁸ = 10¹⁶,只有 long long 装得下,int 会直接溢出成随机数。
ans += 1.0 / max(prev, cur);★ 全题唯一的公式。1.0 不能省:写成 1 / max(...) 是整数除法,大部分项直接变成 0,答案会小得离谱。
ans += 1.0 / max(prev, first);★ 循环只处理了 i = 2..n,也就是前 n−1 项。第 n 题的答案填在第 1 题,这一项要单独补,漏了答案会偏小。
printf("%.3f\n", ans);题目要求三位小数。用 cout 也行:cout << fixed << setprecision(3) << ans。
double ans10⁷ 项相加,用 float 会丢精度,必须 double。

上面那题能拆,是因为每一小份的概率可以独立算出来。很多题拆不动:下一步去哪由当前状态决定,同一道题里的概率互相纠缠。这时改用 DP,设 dp[s] = 从状态 s 出发到结束的期望代价,终点 dp[终点] = 0。

关键在方向。dp[s] 的值依赖“从 s 一步能到达的那些状态”,所以必须先算离终点近的状态,也就是倒着推。这和上一节“只看 dp 依赖谁”是同一条规矩:依赖更大的下标,就从大到小扫。

为什么不能正推?正推时每个状态要乘上“到达它的概率”,而那个概率本身还得再递推一层;倒推时每个状态只算一次,而且 dp[终点] 是已知的 0,整条链从末端固定下来。

倒推模板 + 一个骰子问题

模板只有五行(示意,真实的边枚举那一行要换成你自己的循环)。看清楚那个“1”是什么:它是走一步本身的代价,不是概率。

倒推模板(示意)
dp[终点] = 0;                                  // 边界:到终点不用再走
for (状态 s,按"离终点从近到远"的顺序) {        // 倒序!通常就是逆拓扑序
    dp[s] = 0;
    for (每个一步可达的 t)                          // 换成你的边枚举
        dp[s] += p(s -> t) * (cost(s -> t) + dp[t]);   // 全期望公式
}
// 答案 = dp[起点]

例:一枚均匀骰子,每次掷出 1~6 中的一个。从位置 0 出发,走到 ≥ n 就停,求步数期望。

设 dp[i] = 从位置 i 出发到结束的期望步数。i ≥ n 时已经结束,dp[i] = 0。i < n 时掷出 k(概率 1/6),花费 1 步,到达 min(i+k, n)。于是

转移方程
dp[i] = 1 + (1/6) · Σ_{k=1..6} dp[ min(i + k, n) ]

这个方程“自己包含自己”,但 dp[i] 只依赖 i+1..i+6,全是更大的下标,所以从 n−1 倒着扫一遍就能一次算完,不需要解方程。

骰子的完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;

    vector<double> dp(n + 7, 0.0);        // dp[n..n+6] = 0:已经到达或越过终点
    for (int i = n - 1; i >= 0; --i) {    // 倒着扫:dp[i] 只依赖更大的下标
        double s = 0;
        for (int k = 1; k <= 6; ++k) s += dp[min(i + k, n)];
        dp[i] = 1 + s / 6;                // 那个 1 是"这一步"本身的代价
    }

    printf("%.6f\n", dp[0]);
    return 0;
}
逐行拆解
代码片段含义与理由
vector<double> dp(n + 7, 0.0);下标要取到 n,多开 6 格防越界;初值 0 恰好就是“已经结束”的期望。
for (int i = n - 1; i >= 0; --i)★ 方向。写成 i 从小到大,dp[i] 读到的是还没算的 0,整张表变成 1。
dp[min(i + k, n)]掷出 k 后位置是 i+k;一旦 ≥ n 就结束了,所以折到 n 这一格(它的期望是 0)。写成 dp[i+k] 会越界读垃圾。
dp[i] = 1 + s / 6;全期望公式:E = Σ p·(代价 + 后续期望)。6 种情况概率相等,所以是除以 6。
printf("%.6f\n", dp[0]);dp[0] 是起点的期望。n = 1 时输出 1.000000(掷什么都够一步);n = 6 时是 2.161394。
题目要求用什么存关键操作主要风险
输出实数,保留 k 位double1.0 * 强制浮点;printf 用 %.3f 这类整数除法、累加误差
答案 mod 质数long long乘逆元代替除法分母是 MOD 的倍数、忘取模
输出分数 p/q 的最简形式long long + gcd算完再约分中间量溢出
概率与期望的 5 个典型错法(附症状)

WA,答案全是 0 或 1:cnt / tot 是整数除法。7/6 得 1,1/6 得 0。凡是概率出现,分子上先写 1.0 *。

WA,只错最后一位 0.001:输出位数不对,或用 float 累加了 10⁷ 项。float 只有 7 位有效数字。

WA,答案恰好是正确值的 1/6 或 6 倍:倒推公式里的“这一步代价 1”漏了,或者把 s / 6 写成了 s * 6。那个 1 是步数不是概率,量和概率混在一起乘除最容易被忽略。

WA,且小数据看起来全对:输入生成顺序错。必须先按递推把未取模的 q 全部算出来,再统一 mod C + 1。两步混写,从第 2 项起就全错;更糟的是生成的 a 仍然落在 [1, C] 里,肉眼查不出异常。

RE 或输出 nan:除以 0。概率题的 0/0 多来自“不可达状态也参与了平均”,要先把不可达的分支剔掉。

倒推 DP 的三个专属坑

WA,答案偏小且接近 1:循环方向写反(从小到大)。dp[i] 读到的是还没算的 0。

看起来一样,其实靠运气:dp[min(i+k, n)] 写成 dp[i+k]。本题多开了 6 格、它们又都是 0,答案居然不变;一旦数组只开到 n,就是越界读垃圾。min 这一句是在保证语义,不是保险。

栈溢出 / 死循环:图里有环却写记忆化搜索。倒推的前提是状态依赖有拓扑序;有环时要么列方程解,要么迭代到收敛(高斯消元或不断代入)。

自己动手复现

关掉页面,把 P1297 敲一遍并提交。n 到 10⁷,记得 ios::sync_with_stdio(false)。

改一改 ①:把 1.0 / max(prev, cur) 改成 1.0 / min(prev, cur),用手算的 a = {2,3,1} 对比:正确 7/6 ≈ 1.167,改后是 2.000。

改一改 ②:把生成写成“先生成到 a 里再 mod C”,但把 raw % C + 1 提前到递推之前(即用取过模的值去迭代)。答案会变,而生成的每个 a 仍在 [1, C] 内——肉眼查不出异常,这就是最危险的一类错。

改一改 ③:把 double ans 换成 float ans,自己造 n = 10⁷ 的数据,看最后三位小数是否还稳。

改一改 ④:把骰子程序的循环改成 for (int i = 0; i < n; ++i)(正序),n = 6 时输出 1.000000,而正确值是 2.161394。

数学补给 · 线性性为什么恒成立;几何分布;误差怎么算

先证线性性。设 X、Y 定义在同一试验上,联合分布为 P(X=x, Y=y)。期望的定义是“值 × 概率”求和,于是

线性性的证明
E[X+Y] = Σ_x Σ_y (x+y)·P(X=x, Y=y)
       = Σ_x x·Σ_y P(X=x, Y=y)  +  Σ_y y·Σ_x P(X=x, Y=y)
       = Σ_x x·P(X=x)  +  Σ_y y·P(Y=y)
       = E[X] + E[Y]

中间那步只用了“边缘概率 = 联合概率求和”和分配律,独立二字从未出现。这就是它可以无条件使用的原因。而 E[XY] = E[X]E[Y] 需要把 P(X=x,Y=y) 拆成 P(X=x)P(Y=y),那一步只有独立时才成立。

再看几何分布:每次成功概率 p,独立重复,求首次成功所需次数的期望。设所求为 E。第一次就成功(概率 p)花 1 次;失败(概率 1−p)花 1 次,然后一切重来,还要 E 次。于是

几何分布的期望
E = 1 + (1 − p)·E      →      p·E = 1      →      E = 1/p

用级数验证:E = Σ_{k≥1} k(1−p)^(k−1)·p = p/(1−(1−p))² = 1/p。p = 1/6 时 E = 6:等一个 6 平均要掷 6 次。这个“E = 1 + (1−p)E”的自指写法,正是倒推 DP 的雏形。

方差的三条常用性质:Var(X) = E[X²] − (E[X])²,Var(aX+b) = a²·Var(X),独立时 Var(X+Y) = Var(X) + Var(Y)。注意 E[X²] ≠ (E[X])²——这就是为什么 OSU! 那类题必须单独维护“平方的期望”和“立方的期望”。

最后算一笔精度的账。double 有 53 位尾数,机器精度 ε ≈ 2.2×10⁻¹⁶。累加 n 项的相对误差最坏约 n·ε:n = 10⁷ 时是 2.2×10⁻⁹,n = 10⁹ 时是 2.2×10⁻⁷。

n(累加项数)相对误差 ≈ n·ε和 ≈ n 时的绝对误差3 位小数安全吗
10³2.2×10⁻¹³2.2×10⁻¹⁰安全
10⁵2.2×10⁻¹¹2.2×10⁻⁶安全
10⁷2.2×10⁻⁹0.022和是 10⁷ 量级时危险
10⁹2.2×10⁻⁷220完全不能用 double 直接加

结论:误差要看绝对量级,不能只看项数。P1297 的和不超过 10⁷ 而每项 ≤ 1,实际误差远小于上界,所以 double 稳过。但如果你在累加 10⁹ 个 10⁹ 量级的数,就必须换 long double、Kahan 求和或干脆取模。

取模那一侧没有误差问题,只有“逆元存不存在”的问题:mod 10⁹+7 下 1/2 = 500000004、1/3 = 333333336、1/6 = 166666668、1/7 = 142857144。验算只有一个办法:把逆元乘回分母,结果必须是 1。

举一反三 · 想清楚这些再往下走
  1. 为什么“做对题数”能拆成 n 个概率相加,而“做对题数的平方”不能?把 (X₁+…+Xₙ)² 展开,看看多出了哪些交叉项,它们在什么条件下才能拆开。
  2. 如果 P1297 改成“第 i 题的答案抄到第 i+2 题的位置”,答案会变成什么?为什么公式里还是“取最大值再取倒数”?
  3. 掷骰子直到连续两次都是 6 才停,期望掷几次?状态只有“当前连续 6 的个数 0 / 1 / 2”三种,试着倒推列方程。
  4. 图上有环时为什么不能倒推?举一个环上最简单的例子(两个点互相走),说明“列方程”和“不断代入”分别怎么做。
  5. 什么情况下必须用逆元而不能用 double?如果分母恰好是 MOD 的倍数,逆元不存在,这时题目通常保证了什么?
  6. n 个人随机坐 n 个座位,“恰好 k 个人坐对”的方案数是 C(n,k)·D(n−k)。这个式子里哪一部分是计数、哪一部分是概率?
自测:能全部回答再进下一节
  • 能写出 P(Ā)、P(A∪B)、以及独立时的 P(A∩B),并说清每个公式的前提。
  • 能解释为什么 E[X+Y] = E[X]+E[Y] 不需要独立,而 E[XY] = E[X]E[Y] 需要。
  • 能独立推出 P1297 的 1/max(aᵢ, aᵢ₊₁),并指出哪一步用了独立性。
  • 能默写倒推 DP 的五行模板,并解释为什么方向必须反过来。
  • 知道 1/6 mod 10⁹+7 = 166666668,并能用“乘回分母等于 1”验算。
  • 能判断一道题该用 double 还是逆元,并说出各自的失效条件。
挑战题 · OSU!
洛谷 P1654提高-去洛谷做这题 ↗

一共 n 次操作,每次成功记 1、失败记 0,得到一个 01 串。串中每一段极长的连续 1,若长度为 X,就贡献 X³ 分(不同段分别计分)。给出每次操作的成功率,求最终得分的期望。

输入:第一行一个整数 n(n ≤ 10⁵),接下来 n 行每行一个 [0,1] 之间的实数 pᵢ,表示第 i 次操作的成功率。

输出:一个实数,四舍五入保留 1 位小数。

引导性提示(先只看这里)

不要指望“得分的期望 = 长度的期望的立方”。E[X³] ≠ (E[X])³。

把总分拆成“每一步新增的分数”。如果第 i 步成功,且它左边已经连了 L 个 1,这一步让分数增加多少?

把 (L+1)³ 减 L³ 展开,你会发现只需要维护“L 的期望”和“L² 的期望”两个量。

想看思路了?点开完整思路

只看第 i 位。设它左边紧挨着的连续 1 的长度为 L(第 i 位是 0 时 L 取 0)。若第 i 位成功,这一段从长度 L 变成 L+1,分数增加 (L+1)³ − L³ = 3L² + 3L + 1;若失败,增加 0。把每一步的增量加起来,正好等于最终的 X³ 之和(每一段内部逐项累加,首尾相接)。

于是总分的期望 = Σ E[第 i 步的增量],这一步用线性性,不需要任何独立性假设。

而 E[第 i 步的增量] = pᵢ · (3·E[L²] + 3·E[L] + 1)。这里又用了一次线性性:把 3L² + 3L + 1 的期望拆成三项期望。注意 (L+1)³ 展开里有 L·L 这种项,所以 E[L²] 必须单独维护——它就是“为什么不能只存 E[L]”的全部原因。

维护两个量:e1 = E[L]、e2 = E[L²]。第 i 位之后,若成功则 L 变成 L+1,否则 L 变成 0。于是 e1 = p(e1+1),e2 = p(e2 + 2e1 + 1)。

★ 更新顺序:先用旧的 e1、e2 算出这一步的期望增量,再用旧的 e1 更新 e2,最后更新 e1。顺序错一步,e2 里就会混进新的 e1。

复杂度 O(n),n = 10⁵。答案最大到 n³ = 10¹⁵,用 double(它对 10¹⁵ 的 ulp 约 0.125,本题数据允许);保险起见也可以开 long double。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    scanf("%d", &n);

    double e1 = 0, e2 = 0;       // e1 = E[L],e2 = E[L²],L 是"以当前位置结尾的连续 1 长度"
    double ans = 0;

    for (int i = 1; i <= n; ++i) {
        double p;
        scanf("%lf", &p);

        ans += p * (3 * e2 + 3 * e1 + 1);   // 先用旧的 e1、e2 算这一步的期望增量
        e2 = p * (e2 + 2 * e1 + 1);         // 再用旧的 e1 更新 e2(顺序不能换)
        e1 = p * (e1 + 1);                  // 最后更新 e1
    }

    printf("%.1f\n", ans);
    return 0;
}
关键片段拆解
片段作用
double e1 = 0, e2 = 0;初始时左边没有 1,L = 0,所以 E[L] = E[L²] = 0。
ans += p * (3*e2 + 3*e1 + 1);★ 核心公式。(L+1)³ − L³ = 3L² + 3L + 1,再乘上“这一步成功”的概率 p。失败时增量为 0,所以只乘 p,不用乘 (1−p)。
e2 = p * (e2 + 2*e1 + 1);若成功,L 变成 L+1,于是 L² 变成 L²+2L+1,取期望得 e2+2e1+1;若失败 L 归零。所以在 p 概率下取这个值。
e1 = p * (e1 + 1);同样的道理,成功时长度加一,失败时归零。
更新顺序e2 的更新式里要用旧的 e1。先更新 e1 再更新 e2,就把新的 e1 混进去了,答案会偏大。习惯写法:算 ans → 更新 e2 → 更新 e1。
printf("%.1f\n", ans);题目要求保留 1 位小数。n = 3、三次都是 0.5 时,输出 6.0。
这题最容易错的地方
  • 把 E[X³] 当成 (E[X])³,或者以为“总分的期望 = 期望长度的立方”。三次方不满足线性性。
  • 只维护 e1 不维护 e2。展开 (L+1)³ 时会出现 3L²,缺了这一项答案会明显偏小。
  • 更新顺序反了:先更新 e1 再用它更新 e2,等于用了 L+1 去算 L²,答案偏大。
  • 把“每一段的 X³”理解成“整串 1 的个数的立方”。分段计分是题目的关键设定,样例里 101 得 2 分就是证据。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》0x38 概率与数学期望:期望的线性性
  • 《算法竞赛试炼场》期望 DP:倒推与全期望公式
  • 《算法竞赛黄金法则》第 47 条:把变量拆成指示变量之和
  • 洛谷 P1297、P1654 题解区的高赞推导
40 回顾七:DP 与数论提高-

40 · 回顾七:DP 与数论

把 31~39 节压成一张判据表:DP 用三句话问出状态,数论用两类边界(取模与端点)自查。以 P1091 合唱队形走完“枚举最高点 + 两遍 LIS”的完整推导,再用 5 道题覆盖背包、区间 DP、筛法、gcd 与组合计数。
回顾训练提高-建议 95 分钟回顾5 题

第 31~39 节是九节:DP 四类(线性、背包、区间/树形、状压/数位)、一场模拟赛,数论四块(质数、gcd 与同余、组合计数、期望)。它们训练的是同一个动作——先把问题压成一张表或一个式子,再动键盘。DP 的表是 dp 数组,数论的式子要取模;错法也一样:不是编译错误,而是答案差 1、差一个模数、差一次初始化。

DP 的分不在转移方程里,在状态定义和初始化里;数论的分不在推导里,在边界和取模里。

先看九节的判据表。复习不是重读,是遮住右边一列,自己先说一遍,说不出来才回去补那一节。

节内容一句话判据(读完题先问自己)
31线性 DP 四步法答案能写成“前 i 个里选、末尾状态是 j”吗?能就设 f[i](或 f[i][j]),从“最后一步”倒推
320/1 与完全背包每种物品只能选一次 → 容量倒序;能选无限次 → 容量正序。记反这条,样例常能过、大数据全错
33区间 DP 与树形 DP答案由“最后一次把区间切成两半”决定 → 枚举断点 k;环形就复制一倍,答案取遍所有长度 n 的区间
34状压 DP 与数位 DP状态要记“哪些已经选过” → 用二进制整数当数组下标;问“1~n 里某数字出现几次” → 按位拆
35模拟赛一(120 分钟 4 题)先暴力拿分再想正解;最后 15 分钟只查数组大小、long long、多测清空
36质数与整除判一堆素数且上界 ≤ 10⁷ → 埃氏筛;单个数分解 → 试除到 √n,剩下的 > 1 必是素数
37gcd、同余与快速幂P·Q = gcd(P,Q)·lcm(P,Q);ab mod p 按二进制拆,每乘一次就取模
38组合计数与容斥C(n,m) 用杨辉三角或阶乘逆元;“至少一个”一律转成“总数 − 一个都不满足”
39概率与期望E(X+Y) = E(X) + E(Y) 恒成立,不需要独立;但每走一步要乘概率,漏乘就是 WA

这一节 95 分钟:前 25 分钟跟着一道题走完 DP 的完整思考链,后 70 分钟做 5 道题,每题 14 分钟。顺序建议就按表里的顺序:背包 → 区间 DP → 筛法 → gcd → 组合计数,前面的题给后面的题热身。

例题 · 合唱队形洛谷 P1091普及/提高-去洛谷做这题 ↗

n 位同学站成一排,第 i 位身高 Ti。音乐老师要请其中的 n − k 位同学出列,使剩下的 k 位同学排成合唱队形:存在一个人,他左边身高严格递增、右边身高严格递减。

输入格式:第一行一个整数 n(2 ≤ n ≤ 100);第二行 n 个整数 Ti(130 ≤ Ti ≤ 230)。

输出格式:一行一个整数,表示最少需要几位同学出列。

输入: 8 186 186 150 200 160 130 197 200 输出: 4
例题怎么想:把“出列最少”换成“留下最多”

题目问“出列最少”,等价于“留下最多”。设最多留下 k 人,答案就是 n − k,于是整题变成求最长的合唱队形。

合唱队形有一个唯一的最高点 i:i 左边严格递增,i 右边严格递减。枚举这个 i,左边的最长上升子序列长度 f[i] 与右边的最长下降子序列长度 g[i] 分别算出来,拼起来是 f[i] + g[i] − 1(i 被算了两次)。

于是问题拆成两个同一个模子的小问题:以 i 结尾的最长上升子序列。f[i] = 1 + max{ f[j] : j < i 且 a[j] < a[i] },没有合法 j 时 f[i] = 1。

右边不用另写一套:把数组从右往左扫,判据仍然是 a[j] < a[i],得到的 g[i] 就是“以 i 开头的最长下降子序列”。方向变了,不等式没变——这是本题最省脑子的地方。

同一个模子扫两遍
for (int i = 1; i <= n; ++i) {              // f:从左往右扫
    f[i] = 1;                                // 只留自己也算一种队形
    for (int j = 1; j < i; ++j)
        if (a[j] < a[i]) f[i] = max(f[i], f[j] + 1);
}
for (int i = n; i >= 1; --i) {              // g:从右往左扫,判据不变
    g[i] = 1;
    for (int j = n; j > i; --j)
        if (a[j] < a[i]) g[i] = max(g[i], g[j] + 1);
}
完整代码:两遍 LIS,O(n²)
#include <bits/stdc++.h>
using namespace std;

int n, a[105];
int f[105], g[105];        // f[i]:以 i 结尾的最长上升子序列长度
                           // g[i]:以 i 开头的最长下降子序列长度

int main() {
    cin >> n;
    for (int i = 1; i <= n; ++i) cin >> a[i];

    for (int i = 1; i <= n; ++i) {
        f[i] = 1;                          // 自己单独站,长度是 1
        for (int j = 1; j < i; ++j)
            if (a[j] < a[i]) f[i] = max(f[i], f[j] + 1);   // 把 a[i] 接在 a[j] 后面
    }

    for (int i = n; i >= 1; --i) {         // 倒着扫:右边的人先算好
        g[i] = 1;
        for (int j = n; j > i; --j)
            if (a[j] < a[i]) g[i] = max(g[i], g[j] + 1);
    }

    int best = 0;
    for (int i = 1; i <= n; ++i)
        best = max(best, f[i] + g[i] - 1); // i 在 f、g 里各算了一次,减掉一次

    cout << n - best << "\n";              // 题目问的是出列人数
    return 0;
}
逐行拆解
代码片段含义与理由
#include <bits/stdc++.h>一个头文件包含全部标准库。竞赛里省的不是这几个字符,是忘记 include 的那几分钟。
int f[105], g[105];n ≤ 100,开 105。开成 100 而循环写到 i = 100 就越界——习惯上多开 5 个格子。
f[i] = 1;初始化不能省。LIS 的答案至少是 1,漏了这一句 f[i] 从 0 起,best 偏小、输出偏大。
if (a[j] < a[i])严格递增用 <。本题写了“严格”,用 <= 会把 186 186 也算成递增,答案变小。
f[i] = max(f[i], f[j] + 1);枚举“i 的前一个是谁”,取最长的那个接上。f[i] 只表示以 i 结尾,中途不能当答案。
for (int i = n; i >= 1; --i)倒序扫、j 也从 n 往 i 走,算的是“i 往右能下降多长”。依赖的 g[j] 已经算好。
best = max(best, f[i] + g[i] - 1);i 是最高点,左右拼起来。漏减 1 就把最高点算了两遍,答案偏大 1。
cout << n - best << "\n";做一次“留下最多 → 出列最少”的转换。这一步写在纸上,别只在脑子里转。

上面这道题没有新语法,但接下来的 5 道题会反复用到下面两个东西。先看清它们的格式,再往下做。

新函数拆解 · __gcd / std::gcd(最大公约数)
int __gcd(int a, int b); // C++17 也可以写 std::gcd(a, b)
组成含义 / 格式要求
a, b两个整数。类型必须一致:__gcd(3, 4LL) 在老版本 g++ 上编译失败。
返回值最大公约数,正整数。gcd(a, 0) = a,所以“一边是 0”这种边界不用特判。
复杂度欧几里得算法(辗转相除),O(log min(a, b))。10¹⁸ 的数也只要几十步。
lcm 怎么算lcm(a, b) = a / gcd(a, b) * b。先除后乘,先乘就溢出。
需要 includebits/stdc++.h 已经包含,using namespace std 后可直接用。
最小可运行示例
示例
int a = 12, b = 18;
cout << __gcd(a, b) << "\n";             // 6
cout << a / __gcd(a, b) * b << "\n";     // 36:先除后乘,不溢出

long long g = __gcd(6LL, 15LL);          // 3,参数同为 long long
注意

不要写 a * b / gcd(a, b) 求 lcm:a = b = 10⁹ 时 a*b 已经溢出,得到的是垃圾值,而且不会报错。

gcd 为 1 是两个数互质的充要条件,数论题里这句判据出现频率极高。

新函数拆解 · memset(dp, -1, sizeof dp)
memset(数组名, 填充值, sizeof 数组名);
组成含义 / 格式要求
第 1 个参数数组名(首地址)。二维数组直接写 dp,不要写 dp[0]。
第 2 个参数只有“四个字节都相同”的值才安全:0、-1、0x3f(INF 常用)。它按字节填充,写 1 会得到 0x01010101 = 16843009。
第 3 个参数sizeof dp。写成 sizeof(dp) 等价;写成 n 是经典错误——只清了前 n 个字节。
为什么用 -1int 的 -1 正好是“没算过”的标记:合法答案可以是 0,用 0 当初值就分不清“算过且为 0”和“没算”。
要填别的值用 fill(f, f + n, 值),或者写循环。fill 不要求值是字节可重复的。
最小可运行示例
示例
int dp[15][15];
memset(dp, -1, sizeof dp);              // 全部 -1,当“没算过”

long long f[1005];
fill(f, f + 1005, 1LL << 60);          // 求最小值时的 INF

if (dp[x][y] != -1) return dp[x][y];    // 记忆化搜索的标准判据
注意

多测题每个 case 都要重新 memset,漏一次就把上一组的答案带进这一组。

memset 只对 POD 数组安全;vector 用 assign 或 fill。

DP 四类:三句话问出状态

回顾章的 DP 不该再背模板,而应该能用三句话把状态问出来。问完这三句,转移方程几乎是照着写:

  1. 答案的“最后一步”是什么?最后一步选不选第 i 个物品(背包)、最后一步把区间切成哪两半(区间)、最后一步填哪一位(数位)。
  2. 做完最后一步,剩下的是什么?剩下的必须是同一类问题的更小规模,这就是“无后效性”。
  3. 要记住哪些信息才能接上最后一步?记住的信息就是状态。记多了 MLE,记少了算错。
类型状态写法转移骨架本节对应的题
线性 DPf[i]:前 i 个 / 以 i 结尾f[i] = max(f[j]) + 1(j < i)例题 P1091
背包f[j]:容量 j 的最大价值0/1 倒序 j;完全背包正序 j挑战 1 P1048
区间 DPf[i][j]:区间 [i,j] 的答案枚举断点 k,f[i][k] + f[k+1][j] + 代价挑战 2 P1063
状压 / 数位f[S] 或 f[pos][cnt]枚举这一位填什么,把新状态累加进去下方 30 行的数位统计

表里第四行本节没有配题,但它和第 38 节的组合计数共用一个动作:按位统计贡献。看到“1 到 n 中数字 d 出现了多少次”,先想“固定某一位是 d,这一位贡献几次”,再想“高位有哪些限制”。限制想得清,就是 O(log n) 的数学;想不清,就退回数位 DP 的记忆化框架,别在考场上硬推。下一块把这条路走完。

第 34 节没配题:三十行补完数位统计

数位题的框架只有一句话:把数字一位一位填,记录“这一位能不能随便填”。如果限制只跟某一位有关,不必写记忆化搜索,直接数每一位的贡献更快、更短。

以“1 到 x 中数字 d 出现了几次”为例。固定一个位权 p(1、10、100…),把 x 拆成高位 high、当前位 cur、低位 low。这一位填 d 的取法分三类:

当前位与 d 的关系这一位的贡献理由
cur > d(high + 1) × p高位取 0~high 都能填 d,低位 p 种随便填
cur == dhigh × p + low + 1高位取 0~high−1 时低位随便填;高位取 high 时低位只能 0~low
cur < dhigh × p高位最多取到 high−1,低位随便填

d = 0 要单独处理:最高位不可能是 0,所以 cur == 0 时贡献是 (high − 1) × p + low + 1,其余情况是 high × p;high == 0 说明已经数到最高位,直接退出。这一条漏了,前导零会被当成真的 0 计进去,答案偏大。

P2602 数字计数的 O(log n) 写法
#include <bits/stdc++.h>
using namespace std;

long long countDigit(long long x, int d) {      // 统计 1..x 中数字 d 出现的次数
    long long res = 0;
    for (long long p = 1; p <= x; p *= 10) {    // p 是当前处理的位权
        long long high = x / (p * 10);
        int cur = (int)(x / p % 10);
        long long low = x % p;

        if (d == 0) {                           // 0 不能出现在最高位,单独算
            if (high == 0) break;
            if (cur == 0) res += (high - 1) * p + low + 1;
            else          res += high * p;
        } else {
            if (cur > d)       res += (high + 1) * p;
            else if (cur == d) res += high * p + low + 1;
            else               res += high * p;
        }
    }
    return res;
}

int main() {
    long long a, b;
    cin >> a >> b;                              // 输出 [a,b] 中 0~9 各出现几次
    for (int d = 0; d <= 9; ++d)
        cout << countDigit(b, d) - countDigit(a - 1, d) << (d == 9 ? "\n" : " ");
    return 0;
}

输入 1 99,输出 9 20 20 20 20 20 20 20 20 20 20:0 出现 9 次(10、20、…、90),1~9 各 20 次(个位 10 次 + 十位 10 次)。这份输出就是自测数据;数位 DP 版和这一版都过,才算真懂。

数论那四块的知识点都是单行公式,考前扫一遍比读十页推导有用:

考点必背式子用错会怎样
gcd / lcmgcd(a,b) · lcm(a,b) = a · b直接写 a*b/g 求 lcm,10⁹ 就溢出成负数
快速幂ab = (ab/2)² · ab mod 2,每步 % p循环 b 次,b = 10⁹ 时 TLE
组合数C(n,m) = C(n−1,m) + C(n−1,m−1)用阶乘硬算,n = 2000 就溢出
容斥|A∪B∪C| = Σ|A| − Σ|A∩B| + |A∩B∩C|符号不交替,答案偏大
期望E(X+Y) = E(X) + E(Y)以为必须独立才能相加,白推一小时
DP 的 4 个高频失分点(按症状找病因)

WA,答案恰好差 1:初始化漏了。LIS 的 f[i] 忘了赋 1、背包忘了 f[0] = 0、区间 DP 忘了 f[i][i] = 0。

WA,小数据对、大数据偏大:0/1 背包把容量写成正序。正序时同一件物品被反复选,样例常常看不出来。

RE(段错误):区间 DP 断环复制后数组只开了 n,实际要访问到 2n;或第二维写成了 n 而不是 2n。

TLE:区间 DP 写成了 O(n⁴)(枚举 i、j、k 之外又套一层扫描),或者把 10⁷ 的筛法写成对每个数试除到 √n。

数论的 4 个高频失分点

WA,只有大样例错:乘法没开 long long。a、b 都是 10⁹ 时 a*b 已经溢出,取模后是负数。写法固定成 1LL * a * b % p。

WA,答案差一个:区间端点。求 [a,b] 用 solve(b) − solve(a−1),写成 solve(b) − solve(a) 会少算 a 本身。

WA,n = 1 或全是 0 时错:筛法边界。i 从 2 开始、comp[1] 当非素数;分解时试除到 i*i ≤ n,结束后若 n > 1,它本身就是一个素数因子。

RE / MLE:筛到 10⁸ 还开 int 数组(400 MB)。布尔筛用 bool,10⁸ 只要 100 MB;能筛到 10⁷ 就别筛到 10⁸。

自己动手复现

关掉这一页,把 P1091 的代码手敲一遍提交。卡住的那一行,就是你没真懂的地方。

然后把 a[j] < a[i] 改成 a[j] <= a[i],用样例再交一次。答案从 4 变成了几?想清楚为什么是变小而不是变大。

最后把 f、g 两遍扫描塞进同一个 for:第 t 次迭代用 i = t 算 f,再用 i = n − t + 1 算 g。为什么这样两个方向都能满足?

从暴力到正解:固定五步
  1. 写暴力:先写 O(2ⁿ) 或 O(n³) 的枚举,确保小样例过。暴力是正解的对照组,也是部分分的保险。
  2. 数状态:暴力里哪些参数在变?把它们写成数组下标。参数超过两个,先想能不能砍掉一个。
  3. 定转移:从“最后一步”倒推,写出 f[...] 由哪些更小的 f[...] 拼成。写完先手算一组小数据。
  4. 定顺序:转移依赖的下标必须先算。背包靠倒序保证“只用一次”,区间 DP 靠长度从小到大保证子区间已算。
  5. 对拍:暴力 + 正解 + 随机数据,跑 100 组。这一步能抓住九成的初始化与边界错误。

数论题的版本是:先枚举(O(n) 或 O(√n))→ 找等价式(gcd·lcm = P·Q、C(n,m) 的递推)→ 算复杂度(能不能过 10⁷)→ 查 long long 与取模。四步都在纸上完成,键盘只负责翻译。

本次训练 · 共 5 题

做题方式

限时 100 分钟。每题先自己写 20~25 分钟;写不出来先看提示,再给自己 10 分钟;还不行才点开思路。每题都独立限时,不要一题耗死。

大题 挑战题 1 · 采药
洛谷 P1048普及-去洛谷做这题 ↗

山洞里有 M 株草药,采第 i 株需要 ti 的时间,能得到 vi 的价值。总时间只有 T,每株草药最多采一次,求能采到的最大总价值。

输入格式:第一行两个整数 T、M(1 ≤ T ≤ 1000,1 ≤ M ≤ 100);接下来 M 行,每行两个整数 ti、vi(1 ≤ ti ≤ T,1 ≤ vi ≤ 100)。

输出格式:一行一个整数,表示最大总价值。

引导性提示(先只看这里)

每株草药只有“采”和“不采”两种选择,而且每株最多一次——这是哪种背包?

把“时间”当容量,“价值”当物品价值,dp 数组只要一维就够。

一维数组的关键是循环方向:想清楚更新 f[j] 时,f[j − t] 是本轮的还是上一轮的。

想看思路了?点开完整思路

设 f[j] = 用不超过 j 的时间能拿到的最大价值,初值全 0(什么都不采,价值 0)。

对每株草药 i:不采它,f[j] 不变;采它,f[j] = f[j − t[i]] + v[i]。两者取大。

容量必须倒序枚举。正序时 f[j − t[i]] 已经被第 i 株更新过,等于允许同一株采两次,答案偏大。

答案就是 f[T],复杂度 O(M·T) = 10⁵,时间与空间都宽松。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int T, M;
int t[105], v[105];
int f[1005];                        // f[j]:时间不超过 j 时的最大价值

int main() {
    cin >> T >> M;
    for (int i = 1; i <= M; ++i) cin >> t[i] >> v[i];

    for (int i = 1; i <= M; ++i)
        for (int j = T; j >= t[i]; --j)          // 倒序:保证每株草药只用一次
            f[j] = max(f[j], f[j - t[i]] + v[i]);

    cout << f[T] << "\n";
    return 0;
}
关键片段拆解
片段作用
int f[1005];T ≤ 1000,开 1005。一维数组是“滚动掉物品维”的结果:第 i 轮覆盖第 i−1 轮。
for (int j = T; j >= t[i]; --j)倒序是 0/1 背包的全部秘密。j 从大到小,f[j − t[i]] 还没被本轮改过。
f[j] = max(f[j], f[j - t[i]] + v[i]);不采(f[j])与采(f[j−t[i]] + v[i])取大。
cout << f[T];f[T] 是“时间不超过 T”的最优值,不必再扫一遍 max。
这题最容易错的地方
  • 容量写成正序 → 变成完全背包,样本能过、大数据偏大。
  • 把 t[i] 和 v[i] 读反(题目先时间后价值)。
  • f 数组按物品数开成 105,越界或 RE。
大题 挑战题 2 · 能量项链
洛谷 P1063提高-去洛谷做这题 ↗

项链由 n 颗珠子串成一圈。第 i 颗珠子有头标记 ai、尾标记 ai+1(第 n 颗的尾标记是 a1)。相邻两颗可以合并:若前一颗是 (a, b)、后一颗是 (b, c),合并后得到 (a, c),同时释放 a × b × c 点能量。

求把整圈合并成一颗珠子,最多能释放多少能量。

输入格式:第一行一个整数 n(4 ≤ n ≤ 100);第二行 n 个正整数 ai(1 ≤ ai ≤ 10⁴)。

输出格式:一行一个整数,表示最大总能量。

引导性提示(先只看这里)

合并只能在相邻的两颗之间发生,而且最后一次合并一定把“环上的某一段”切成两半。

把环拉直的做法是把数组复制一份接在后面,然后只看长度为 n 的区间。

区间 DP 的枚举顺序是:长度从小到大 → 区间起点 → 断点。想清楚为什么长度必须在最外层。

想看思路了?点开完整思路

断环成链:把 a 复制一份,a[i + n] = a[i]。于是任何长度为 n 的连续区间都对应环上的一种合并顺序。

设 f[i][j] 表示把第 i..j 颗珠子合并成一颗的最大能量。最后一次合并把区间分成 [i,k] 与 [k+1,j],合并后的左块是 (a[i], a[k+1]),右块是 (a[k+1], a[j+1]),释放 a[i] × a[k+1] × a[j+1]。

转移:枚举断点 k,f[i][j] = max( f[i][k] + f[k+1][j] + a[i]·a[k+1]·a[j+1] )。

答案 = max f[i][i+n−1](i = 1…n),因为环有 n 种断法。复杂度 O(n³) = 10⁶。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int n, a[205];
long long f[205][205];      // f[i][j]:把第 i..j 颗珠子合成一颗的最大能量

int main() {
    cin >> n;
    for (int i = 1; i <= n; ++i) { cin >> a[i]; a[i + n] = a[i]; }  // 断环成链

    for (int len = 2; len <= n; ++len)                  // 长度从小到大,保证小区间先算
        for (int i = 1; i + len - 1 < 2 * n; ++i) {     // 右端点 j <= 2n-1:能量要用 a[j+1],j = 2n 就越界
            int j = i + len - 1;
            for (int k = i; k < j; ++k)                 // 最后一次合并的断点
                f[i][j] = max(f[i][j],
                              f[i][k] + f[k + 1][j] + 1LL * a[i] * a[k + 1] * a[j + 1]);
        }

    long long ans = 0;
    for (int i = 1; i <= n; ++i) ans = max(ans, f[i][i + n - 1]);   // n 种断法
    cout << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
a[i + n] = a[i];环形问题第一步:复制一份接在后面,任何长度 n 的区间都对应环上的一种合并顺序。
long long f[205][205];一次合并最多释放 10¹²(三个 10⁴ 相乘),n−1 次累加可到 10¹⁴,int 装不下。
for (int len = 2; len <= n; ++len)按长度从小到大:算长度 len 时,所有更短的子区间已经算好。
for (int k = i; k < j; ++k)枚举最后一次合并的分界:左边合成 (a[i], a[k+1]),右边合成 (a[k+1], a[j+1])。
1LL * a[i] * a[k + 1] * a[j + 1]合并释放的能量。三个 10⁴ 相乘是 10¹²,int 直接溢出。
ans = max(ans, f[i][i + n - 1]);长度为 n 的区间有 n 个起点,正好对应环的 n 种断法,取最大。
这题最容易错的地方
  • 数组只开 n 而不是 2n,复制后越界,RE。
  • 循环写成 i + len - 1 <= 2 * n,右端点取到 2n 时 a[j+1] 读到空白值,答案偏小且不报错。
  • len 从 1 开始:长度为 1 不需要合并,硬算会读到 f[i][i−1] 这种空白区域。
  • 能量用 int 存,ai = 10⁴ 时乘积 10¹² 溢出成负数。
  • 答案只取 f[1][n],漏掉“从别处断开”的更优解。
大题 挑战题 3 · 回文质数
洛谷 P1217普及-去洛谷做这题 ↗

回文质数是指既是质数、又是回文数的整数(回文数指正着读反着读一样)。给定 a、b,按从小到大输出 [a, b] 内所有回文质数,每行一个。

输入格式:一行两个整数 a、b(5 ≤ a ≤ b ≤ 10⁸)。

输出格式:每行一个回文质数,按升序输出。

引导性提示(先只看这里)

要输出区间内所有满足条件的数,意味着你得先有一个“谁是质数”的表,而不是对每个数单独判。

回文数有一个性质:偶数位的回文数一定能被 11 整除。想一想这对 10⁸ 的范围意味着什么。

标记倍数时从 i × i 开始——更小的倍数已经被更小的质数标记过了。

想看思路了?点开完整思路

先筛出 b 以内的所有质数(埃氏筛),再对每个质数判回文。

关键剪枝:偶数位的回文数一定能被 11 整除,所以 8 位数的回文数全都不是质数;b ≤ 10⁸ 时 9 位数只有 100000000,它不是回文。于是 b 可以直接截到 9999999,1 亿的筛法变成 1 千万,时间空间都省一个量级。

筛法:comp[i] 表示 i 被标记为合数;i 未标记就是素数,把 i²、i²+i、… 全部标记。内层循环的起点必须是 i²,否则 10⁷ 的筛法会退化成 O(n log n) 且常数很大。

判回文用整数翻转:把 x 的十进制位翻过来,和原数比较,空间 O(1),比转字符串快。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int N = 10000005;
bool comp[N];                       // comp[i] = true 表示 i 是合数

bool pal(int x) {                   // 判回文:翻过来看是否相等
    int t = x, r = 0;
    while (t) { r = r * 10 + t % 10; t /= 10; }
    return r == x;
}

int main() {
    int a, b;
    cin >> a >> b;
    b = min(b, 9999999);            // 8 位回文数必被 11 整除,截掉它们不丢答案

    for (int i = 2; i <= b; ++i) {
        if (!comp[i]) {             // i 没被标记 → i 是素数
            if (i >= a && pal(i)) cout << i << "\n";
            for (long long j = 1LL * i * i; j <= b; j += i)
                comp[j] = true;     // 从 i*i 开始标记,更小的倍数早被筛过
        }
    }
    return 0;
}
关键片段拆解
片段作用
b = min(b, 9999999);偶数位回文数必被 11 整除,8 位数的回文数都不是质数,所以截到 9999999 不丢答案。
if (!comp[i])埃氏筛的核心:从小到大,没被标记过的就是素数。
for (long long j = 1LL * i * i; ...)j 必须是 long long:i 接近 10⁷ 时 i*i 溢出 int,变成负数后死循环。
if (i >= a && pal(i))先筛出素数再判回文,比“枚举回文再判素数”省事:素数表已经给全了。
r = r * 10 + t % 10;整数翻转。回文判据是 r == x,不需要字符串,也没有前导零问题。
这题最容易错的地方
  • 写成 for (int j = i * i; ...),i > 46340 后 i*i 溢出,RE 或死循环。
  • 筛到 10⁸ 还用 int 数组,400 MB 直接 MLE;bool 只要 100 MB,能筛到 10⁷ 就别筛 10⁸。
  • 忘记 b 可能大于 10⁷,不做截断,时间翻几倍。
  • 把 1 当成素数输出。i 从 2 开始就不会有这个问题。
大题 挑战题 4 · 最大公约数和最小公倍数问题
洛谷 P1029普及-去洛谷做这题 ↗

已知两个正整数 P、Q 的最大公约数是 x0,最小公倍数是 y0。求满足条件的 (P, Q) 有多少对,其中 (P, Q) 与 (Q, P) 算两对。

输入格式:一行两个正整数 x0、y0。

输出格式:一行一个整数,表示满足条件的对数;不存在时输出 0。

引导性提示(先只看这里)

先用一个等式把 P、Q 的关系压缩成两个数之间的关系,再想枚举什么。

P 和 Q 都是 x0 的倍数,把它们写成 x0·i 和 x0·j,代进等式看看。

枚举 n 的因子只需要到 √n,再检查这两个因子是否互质。

想看思路了?点开完整思路

由 gcd(P,Q) · lcm(P,Q) = P · Q 得 P·Q = x0·y0。x0 不整除 y0 时无解。

令 P = x0·i、Q = x0·j,代入 P·Q = x0·y0 得 i·j = y0/x0 = n。

gcd(P,Q) = x0·gcd(i,j),要求等于 x0,所以 gcd(i,j) = 1。于是问题变成:枚举 n 的因子对 (i, j),数其中互质的有几对。

枚举到 √n 即可:每找到一个因子 i 就得到一对 (i, n/i),互质则答案加 2((i,j) 和 (j,i));i == n/i 时只加 1,避免重复。复杂度 O(√n)。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    long long x, y;
    cin >> x >> y;

    if (y % x) { cout << 0 << "\n"; return 0; }   // x 不整除 y,无解
    long long n = y / x, ans = 0;

    for (long long i = 1; i * i <= n; ++i) {
        if (n % i) continue;                     // i 不是 n 的因子
        long long j = n / i;
        if (__gcd(i, j) == 1)                    // i、j 互质,还原出的 P、Q 的 gcd 才恰好是 x
            ans += (i == j) ? 1 : 2;             // 有序对:(i,j) 与 (j,i) 各算一次
    }

    cout << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
if (y % x)P、Q 都是 x 的倍数,所以 x 必须整除 y;不整除时答案直接是 0。
n = y / x;把 x 从两个数里约掉:i、j 满足 i·j = n 且 gcd(i,j) = 1。枚举范围从 y 降到 √n。
for (i = 1; i * i <= n; ++i)枚举因子只到 √n,n 再大也只要几万次。i 用 long long 防 i*i 溢出。
__gcd(i, j) == 1互质是“gcd(P,Q) 恰好为 x”的充要条件。不互质说明还有公因子,gcd 会更大。
ans += (i == j) ? 1 : 2;(i,j) 与 (j,i) 是两个不同的有序解,各算一次;i == j 时只有一个解,只算一次。
这题最容易错的地方
  • 漏了 i == j 的特判,把 (i,i) 算成两对。
  • 用 int 存 P·Q:x·y 一超过 2×10⁹ 就溢出,直接 long long 省心。
  • 从 1 枚举到 y 逐个检查 gcd,O(y) 超时。
  • 忘记 gcd·lcm = P·Q 这条关系,直接两重循环。
大题 挑战题 5 · 组合数问题
洛谷 P2822普及/提高-去洛谷做这题 ↗

组合数 C(i,j) 表示从 i 个不同物品中选出 j 个的方案数,规定 j > i 时 C(i,j) = 0。给定 k 和 t 组询问,每组给出 n、m,求有多少对 (i, j) 满足 0 ≤ i ≤ n、0 ≤ j ≤ min(i, m),且 C(i,j) 是 k 的倍数。

输入格式:第一行两个整数 t、k(1 ≤ t ≤ 10⁴,2 ≤ k ≤ 10⁹);接下来 t 行,每行两个整数 n、m(0 ≤ n, m ≤ 2000)。

输出格式:t 行,每行一个整数,表示该组询问的答案。

引导性提示(先只看这里)

C(i,j) 可以用杨辉三角 O(n²) 全算出来,但真值有几百位,存不下——你只需要知道它是不是 k 的倍数。

每个询问问的是一块区域里有多少个满足条件的位置,位置总数是 n² 级别,逐组统计一定超时。

把每个位置标成 0/1 之后,询问就变成了区域求和。第 18 节学过一个工具,能让区域求和做到 O(1)。

想看思路了?点开完整思路

杨辉三角只保留模 k 的余数:C[i][j] = (C[i−1][j−1] + C[i−1][j]) % k。余数为 0 就是 k 的倍数。

把“C[i][j] 是 k 的倍数”记成 0/1 矩阵 bad[i][j],并把 j > i 的格子强制置 0。这样矩形 [0..n] × [0..min(n,m)] 的和就恰好是答案——多出来的 j > i 格子贡献为 0。

对 bad 做二维前缀和:s[i][j] = bad[i][j] + s[i−1][j] + s[i][j−1] − s[i−1][j−1]。每次询问 O(1)。

预处理 O(N²) ≈ 4×10⁶,询问 O(1),总复杂度由预处理决定,完全够用。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int N = 2005;
int t, k;
int C[N][N];        // 杨辉三角模 k 的余数
int s[N][N];        // bad 的二维前缀和

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> t >> k;

    for (int i = 0; i < N; ++i) {
        C[i][0] = C[i][i] = 1 % k;              // 两条边界:C(i,0) = C(i,i) = 1
        for (int j = 1; j < i; ++j)
            C[i][j] = (C[i - 1][j - 1] + C[i - 1][j]) % k;   // 只要余数
    }

    for (int i = 0; i < N; ++i)
        for (int j = 0; j < N; ++j) {
            int bad = (j <= i && C[i][j] == 0) ? 1 : 0;      // j > i 不是合法组合数
            s[i][j] = bad;
            if (i) s[i][j] += s[i - 1][j];
            if (j) s[i][j] += s[i][j - 1];
            if (i && j) s[i][j] -= s[i - 1][j - 1];          // 二维容斥
        }

    while (t--) {
        int n, m;
        cin >> n >> m;
        if (m > n) m = n;                       // 题目要求 j <= min(i, m)
        cout << s[n][m] << "\n";
    }
    return 0;
}
关键片段拆解
片段作用
C[i][0] = C[i][i] = 1 % k;杨辉三角的两条边界都是 1。写 1 % k 是防御写法:k 整除 1 时也不会错。
C[i][j] = (C[i-1][j-1] + C[i-1][j]) % k;只要余数:C(2000,1000) 有 600 位,存不下;判断倍数只看余数。
int bad = (j <= i && C[i][j] == 0);j > i 的格子不是合法组合数,必须置 0,否则前缀和会把空白区算进去。
s[i][j] = bad + s[i-1][j] + s[i][j-1] - s[i-1][j-1];二维前缀和:以 (i,j) 为右下角的矩形里有多少个 bad。
if (m > n) m = n;要求 j ≤ min(i, m)。m 超过 n 时直接截到 n,否则会查到无意义的区域。
cout << s[n][m];查询 O(1)。预处理 4×10⁶ 次,1 万组询问也不会超时。
这题最容易错的地方
  • 用 int 存真实组合数,n = 2000 就溢出,答案全错。
  • 忘了把 j > i 的格子置 0,前缀和把下三角以外算进去,答案偏大。
  • 每组询问都重新递推杨辉三角,O(t·n²) 超时。
  • 数组只开 2000 而 n 能取到 2000:下标 2000 越界,必须开 2005。
做完之后自问
  • 说到“0/1 背包”,能立刻答出容量必须倒序,并说出正序错在哪一步。
  • 能不看代码写出区间 DP 的三层循环顺序(长度 → 起点 → 断点),并解释为什么长度要在最外层。
  • 看到“1~n 中数字 d 出现几次”,能说出按位统计与数位 DP 两种做法,并判断哪个更快。
  • 能背出 gcd 与 lcm 的关系式,并说明为什么算 lcm 要先除后乘。
  • 能用一句话说清“总数 − 一个都不满足”这个容斥转化,并举一个 C(n,m) 之外的例子。
  • 能说出期望的线性性为什么不需要独立性,并给出“E(XY) = E(X)E(Y) 需要独立”的反例。
延伸阅读
  • 《算法竞赛入门笔记》:动态规划四步法、数论基础两章
  • 《算法竞赛进阶指南》:数学知识(质数、约数、同余)与动态规划(背包、区间 DP)两章
  • 《算法竞赛试炼场:洛谷300题精析》:P1048、P1063、P2822 三题的多解对比
  • 《算法竞赛黄金法则:提高算法和思考力的77项技巧》:先写暴力再压缩状态;把边界写成清单
  • 洛谷 P1091、P1063 题解区关于“两遍 LIS”与“断环成链”的讨论
41 图的存储与遍历普及/提高-

41 · 图的存储与遍历

把“邻居现算”的习惯换成显式建图:用 vector 邻接表存下 n 个点、m 条边(无向边加两次),再用 DFS/BFS 一次扫完。顺带解决连通块计数与二分图染色,并说清递归深度这个隐形炸弹。
普及/提高-预计 65 分钟邻接表DFS/BFS 图连通块
本节出现的新函数 / 新语法(先看懂再用)

第 24、26 节的 DFS 和 BFS 都在网格上跑:邻居不用存,四个方向数组现算一个出来就行。一旦题目直接给出 n 个点、m 条边,邻居就必须自己记下来。这一节只干两件事:边记在哪,记下来之后怎么走。

图有两种给法。隐式:点是格子,边由“能不能走”决定,邻居现算 —— 迷宫、马的遍历都属于这一类。显式:题目给一张边表,第 i 行 u v w 就是一条边。隐式图不需要存,显式图必须先存。本节讨论后者。

存图只有两个判据:n 大不大,以及你要不要 O(1) 查一条边。选错了不是慢一点,是存不下。
存法空间查 u→v枚举 u 的邻点什么时候用
邻接矩阵 g[u][v]O(n²)O(1)O(n)n ≤ 3000、要频繁查边、Floyd(第 42 节)
邻接表 vectorO(n + m),无向图 O(n + 2m)O(deg(u))O(deg(u))绝大多数题,m 到 10⁶ 也存得下
边集数组 (u,v,w)O(m)O(m)O(m)只按边处理,如 Kruskal(第 43 节)

算一次空间账就明白为什么必须用邻接表:n = 10⁵ 的邻接矩阵是 10¹⁰ 个 int,40 GB,而同样的图用邻接表只要 n 个 vector 对象(每个 24 字节,共 2.4 MB)加 2m 个 int(m = 10⁶ 时 8 MB)。反过来,n = 500 的小图用矩阵只有 1 MB,还能 O(1) 查边,这时邻接表反而自找麻烦。

存无向图时每条边要加两次。无向边 (u, v) 在内存里是两条有向弧:g[u].push_back(v) 和 g[v].push_back(u)。于是 m 条无向边写进表里是 2m 条记录,遍历所有邻点看到的总数也是 2m。三个后果:

  • 边集数组、链式前向星要开 2m 个位置。开 m 会越界,症状是 RE 或悄悄写坏旁边的数组。
  • DFS/BFS 的复杂度写 O(n + m),无向图里那个 m 实际是 2m —— 常数 2 不影响量级,但要心里有数。
  • 所有点的度数之和等于 2m(每条边给两个端点各贡献 1),所以“度数为奇数”的点一定是偶数个。

本节新增两个东西:一个能装下整张表的容器,一个能一次带走“终点 + 边权”的类型。后面所有图论题的建图部分,都从这两个里面选一个。

新函数拆解 · vector 的 vector(邻接表)
vector<vector<int>> g(n + 1); g[u].push_back(v); g[u].size(); for (int v : g[u]) ...
组成含义 / 格式要求
vector<vector<int>>外层每个元素本身是一个 vector<int>。外层长度 = 点数,内层长度 = 该点的出度。整张表就是“每个点一条 list”。
g(n + 1)构造时给定外层大小。n + 1 是为了让下标 1..n 直接用;写 g(n) 再访问 g[n] 就越界。外层是运行期才知道的大小时,也可以先声明全局 vector<vector<int>> g;,再在 main 里 g.resize(n + 1);。
g[u].push_back(v)在 u 的邻点表末尾追加一个 v。有向图加一次;无向图 u、v 各加一次。
g[u].size()u 的出度(无向图里是度数)。返回 size_t,与 int 混比会有符号警告,要倒序循环记得写 (int)g[u].size() - 1。
g[u][i]第 i 个邻点,i 从 0 到 size() − 1。越界不报错,只会读到随机值。
g[u].clear()只清掉 u 这一条表,外层长度不变。多测时清整张表用 g.assign(n + 1, vector<int>());。
for (int v : g[u])范围 for,遍历 u 的全部邻点,比写下标短且不会写错边界。
最小可运行示例
示例
int n = 4;
vector<vector<int>> g(n + 1);          // 下标 0..4,0 号空着不用

g[1].push_back(2);                     // 有向边 1→2
g[1].push_back(3);                     // 有向边 1→3
g[3].push_back(1);                     // 无向边要两个方向各加一次

cout << g[1].size() << "\n";           // 2:点 1 有两条出边
for (int v : g[1]) cout << v << " ";   // 2 3

vector<vector<int>> h;                 // 空的:一个点都没有,此时访问 h[1] 是越界
h.assign(n + 1, vector<int>());        // 变成 n+1 个空表,多测时用它清空
注意

两层尖括号在 C++11 之后可以直接连写 >>,不用写成 > >。

邻接表的内存是分段的:每个 g[i] 各自申请一块。所以不能对它 memcpy,也不能用 &g[u][0] 算指针偏移。

点数固定且不大时,竞赛里更常见的写法是 vector<int> g[N]; ——定长数组,每个元素是 vector,等价且不用 resize,但不能按输入动态开大小。

新函数拆解 · struct Edge(把一条边打包)
struct Edge { int to, w; }; vector<Edge> g[N]; g[u].push_back({v, w});
组成含义 / 格式要求
struct Edge自定义类型:把“一条弧去哪、代价多少”捆成一个整体。用一个名字代替两个平行数组,以后要加“边编号”“反向边下标”也只改这一处。
int to这条弧的终点。无向图里同一对点会出现两次,方向相反。
int w边权。无权图可以整条删掉,只留 to —— 那就是 vector<int> 的邻接表。
vector<Edge> g[N]外层是定长数组,每个元素存“从 u 出发的所有弧”。和 vector<vector<Edge>> 完全等价,只是不用 resize。
push_back({v, w})花括号按成员的声明顺序填,不是按名字。等价于 push_back(Edge{v, w})。
e.to / e.w成员变量,不是函数,不加括号。范围 for 用 for (auto &e : g[u]),写引用是为了不复制结构体。
无向图的加边两次 push:g[u].push_back({v, w}); g[v].push_back({u, w});。权值相同,to 与起点互换。
最小可运行示例
示例
struct Edge { int to, w; };          // to: 终点, w: 边权

vector<Edge> g[105];                   // g[u] = 从 u 出发的所有弧

g[1].push_back({2, 7});                // 有向边 1→2,权 7
g[1].push_back({3, 2});                // 有向边 1→3,权 2

for (auto &e : g[1])                   // 引用遍历,不复制
    cout << e.to << " " << e.w << "\n";     // 2 7 / 3 2

cout << g[1][0].to << "\n";            // 2:像二维数组一样取第 0 条弧的终点
注意

{2, 7} 按声明顺序填。把 to、w 的声明顺序调换,{2, 7} 的含义就跟着变,而编译器一个字都不会提醒 —— 这类错只能靠自觉。

自己写了构造函数就不能再用花括号按位置初始化,除非保留一个全参数的构造函数。

无权图别硬套 Edge:多存一个用不上的 w,还多一次写错的机会。

建图前先回答三个问题

有向还是无向?无向图每条边加两次,边集数组要开 2m。

要不要边权?不要就用 vector<int>,要就用 vector<Edge>。

下标从 1 还是从 0?从 1 开始就开 n + 1 个位置,别让 g[n] 越界。

例题 · 查找文献洛谷 P5318普及/提高-去洛谷做这题 ↗

洛谷博客里一共有 n 篇文章(编号 1 到 n)和 m 条参考文献引用关系。文献 X → Y 表示文章 X 有参考文献 Y。小 K 已经打开了编号为 1 的文章,他看一篇文章就一定会看它引用的全部文献(看过的不会再看)。

请对这张图分别做 DFS 和 BFS,输出遍历结果。如果有多个文献可以参阅,先看编号较小的那篇(因此你可能需要先排序)。

输入格式:第一行两个整数 n、m(1 ≤ n ≤ 10⁵,1 ≤ m ≤ 10⁶);接下来 m 行,每行两个整数 X、Y。不保证 1 号文章没有被其他文章引用。

输出格式:共两行。第一行为 DFS 遍历结果,第二行为 BFS 遍历结果,同一行内用空格分隔。

输入: 8 9 1 2 1 3 2 4 2 5 3 6 3 7 4 8 5 8 6 7 输出: 1 2 4 8 5 3 6 7 1 2 3 4 5 6 7 8
例题怎么想:四步,第三步最容易被忽略

① 建图。有向图,m 条弧,g[X].push_back(Y) 一次就够。写成无向会凭空多出一半的边,可达的点变多,输出整片错。

② 排序。题目要“先看编号小的”。DFS 和 BFS 的访问顺序完全由邻接表里的存储顺序决定,不排序得到的是输入顺序决定的序列 —— 样例都过不了。排序总代价 Σ deg(u)·log deg(u) ≤ m log m。

③ 两个序列要分两次遍历,visited 必须重置。一次 DFS 跑完 vis 全是 true,接着跑 BFS 只会输出一个点。这是本题最高频的错误,且不排序、不清零这两件事在样例上都会暴露,所以样例一定要手推一遍。

④ 复杂度。建图 O(m),排序 O(m log m),两次遍历各 O(n + m)。m = 10⁶ 时总操作约 2×10⁷ 次,1 秒内。

四步骨架
// 骨架:建图 → 排序 → DFS → 清空 → BFS
for (int i = 0; i < m; ++i) { cin >> x >> y; g[x].push_back(y); }   // 有向,只加一次
for (int u = 1; u <= n; ++u) sort(g[u].begin(), g[u].end());        // 让"最小的邻点"排在最前

dfs(1);  cout << "\n";                     // 先序输出整条 DFS 序列
memset(vis, 0, sizeof(vis));               // ★ 不清零,下一行只会输出一个 1
q.push(1); vis[1] = true;                  // BFS:入队即标记
while (!q.empty()) { /* 出队、输出、把未访问的邻点压进队 */ }

DFS 用递归写最省事:递归调用栈本身就是“走到黑再退回”的那条路径。BFS 用 queue,第 26 节已经写熟了,这里只换掉“邻居怎么来”——从方向数组换成邻接表。另外记住一条:DFS 可以递归也可以显式栈,BFS 只能队列,因为它们对“下一个访问谁”的要求不同。

完整代码(C++17)
#include <bits/stdc++.h>
using namespace std;

const int N = 100005;
int n, m;
vector<vector<int>> g;            // g[u] = u 引用的所有文献
bool vis[N];                      // 访问标记:两个序列共用,用前都要清

void dfs(int u) {
    vis[u] = true;
    cout << u << " ";             // 先序:一进点就输出,这就是"DFS 序列"
    for (int v : g[u])            // g[u] 已升序,天然满足"先看编号小的"
        if (!vis[v]) dfs(v);      // 有环时必须判重,否则无限递归
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> m;
    g.resize(n + 1);              // n+1 条空表,下标 1..n 可用
    for (int i = 0; i < m; ++i) {
        int x, y;
        cin >> x >> y;
        g[x].push_back(y);        // 有向边 X→Y,只加一个方向
    }
    for (int u = 1; u <= n; ++u)  // 每个邻接表单独升序
        sort(g[u].begin(), g[u].end());

    dfs(1);                       // 从 1 号文章出发
    cout << "\n";

    memset(vis, 0, sizeof(vis));  // ★ 第二轮必须清空
    queue<int> q;
    q.push(1);
    vis[1] = true;                // 入队即标记,不能等出队
    while (!q.empty()) {
        int u = q.front();
        q.pop();                  // front 取值、pop 删除,两句不能合并
        cout << u << " ";
        for (int v : g[u])
            if (!vis[v]) {        // 判重同时保证"每个点只进队一次"
                vis[v] = true;
                q.push(v);
            }
    }
    cout << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
const int N = 100005;n 到 10⁵,开 100005 留 5 个余量。正好开 100000 时,u = 100000 会踩到 vis[100000] 之外的一格。
vector<vector<int>> g;定义成全局是为了 dfs 能直接看见它,省一个“传引用”的参数。外层大小要到读入 n 之后才知道,所以先声明、再 resize。
g.resize(n + 1);变成 n + 1 条空表。多测时这一句同时就是“清空整张图” —— resize 到相同大小不会清内容,所以多测要么先 g.clear() 再 resize,要么直接 assign。
g[x].push_back(y);一条有向弧。P5318 是“X 引用 Y”,单向;加成双向会把可达点放大,输出多出一堆不该出现的编号。
sort(g[u].begin(), g[u].end());题目要“先看编号小的”。排的是每个点自己的邻点表,不是把 1..n 排序。总代价 O(m log m),m = 10⁶ 时约 2×10⁷ 次比较。
vis[u] = true; cout << u << " ";先序输出:进点即打印。DFS 的“先/中/后”三种序只差这一句的位置,图没有中序,绝大多数题要的是先序。
for (int v : g[u]) if (!vis[v]) dfs(v);范围 for 直接遍历邻点,已访问的必须跳过。忘了判重,有环时立刻无限递归,表现为 RE(栈溢出)。
dfs(1);源点是 1 号文章。题目只要求“他能看到的”,别的点即使能走到 1 也不输出。
memset(vis, 0, sizeof(vis));★ 整节最关键的一行。不清零,vis 全为 true,BFS 只输出 1 就结束,第二个序列直接废掉。
q.push(1); vis[1] = true;BFS 入队即标记。等出队才标记的话,同一个点会被多个前驱各 push 一次,序列里出现重复编号。
int u = q.front(); q.pop();先取值再删除。int u = q.pop() 编译不过:pop() 返回 void。
cout << "\n";两个序列各占一行,行末不留空格。DFS 的循环里输出的是“编号 + 空格”,所以最后一个空格之后才换行。
同一套 DFS,换个外层:连通块计数

无向图里,能互相到达的点构成一个连通块。数连通块的做法只有一行新代码:外层扫一遍所有点,遇到没访问过的,就说明发现了一个新块,从这里跑一次 DFS 把整块点亮。

连通块计数:只加一个外层循环
int blocks = 0;
for (int i = 1; i <= n; ++i)
    if (!vis[i]) {          // 这个点没被任何已发现的块扫到 → 新块
        ++blocks;
        dfs(i);             // 一次 DFS 把整个块标记完
    }
cout << blocks << "\n";

为什么总复杂度还是 O(n + m)。每次 dfs(i) 只在 vis[i] 为假时启动,而它会把进入的每个点立即标记,所以每个点恰好被进入一次;每个点的邻点表在它自己那次进入时被遍历一次,无向边被两端各看一次,合计 2m 次。外层循环不是乘上去的,因为大多数点会被 continue 掉。

这个外层循环是图论里最常用的一段代码。它其实在回答三个问题:图连不连通(blocks == 1)、有几个独立的部分(blocks 的值)、每个点属于哪个部分(把 ++blocks 换成 block[i] = blocks 在 dfs 里赋值)。有向图里对应的概念叫强连通分量,本节不展开。

再顺手记一个数:若图有 n 个点、m 条边、k 个连通块,则 k ≥ n − m。因为每个连通块至少要有一棵生成树,树边共 n − k 条,而 m 不小于树边数。这个不等式能用来粗查“边数是不是少得可疑”。

再进一步:二分图判定的雏形

二分图:能把点集分成两类,使每条边的两个端点分属不同类。等价说法是图中没有奇环(长度为奇数的环)。判定只要给每个点染一种颜色,要求一条边的两端颜色不同。

染色规则只有一句:color[v] = 3 - color[u](颜色取 1 和 2)。若某个邻点已经染过、且颜色与 u 相同,就出现了同色边,说明有奇环,直接返回 false。

二分图染色:BFS 版
int color[N];                     // 0 = 未染色, 1 / 2 = 两种颜色

bool isBipartite() {
    for (int s = 1; s <= n; ++s) {            // ★ 每个连通块都要单独起一次
        if (color[s]) continue;
        color[s] = 1;                          // 块内第一个点随便染
        queue<int> q; q.push(s);
        while (!q.empty()) {
            int u = q.front(); q.pop();
            for (int v : g[u]) {
                if (!color[v]) {               // 没染过:强制染成反色
                    color[v] = 3 - color[u];
                    q.push(v);
                } else if (color[v] == color[u]) {
                    return false;              // 同色相邻 → 奇环 → 不是二分图
                }
            }
        }
    }
    return true;
}

为什么这样一定对。一条边两端必须异色,所以一个连通块里只要定下任意一个点的颜色,其余所有点的颜色都被唯一确定(沿路径一路翻色)。两种整体配色只差“把 1 和 2 对调”,对判定没有影响。因此“染色过程中出现同色边”当且仅当存在奇环。

三个必须注意的地方:①必须对每个连通块各起一次 BFS —— 只从 1 号点染,别的块全是 0,永远判成二分图;②这只适用于无向图,有向图的“二分”是另一个概念;③自环 (u, u) 会让 color[v] == color[u] 当场成立,立刻判 false,这正好符合“自环是长度 1 的奇环”。复杂度同样是 O(n + m)。

把递归改成显式栈:深度 10⁵ 也不怕

递归 DFS 的深度等于最长的一条链。本题 n 能到 10⁵,如果数据是一条 10⁵ 长的链,递归深度就是 10⁵,每层几十字节,合计几 MB —— 在栈限制 8 MB 的评测机上会 RE。洛谷的栈空间通常够用,但你必须知道这个风险:数组没越界却 RE,先怀疑递归深度。

解法是把“调用栈”自己用一个 vector 实现。顺序能完全一致,关键在逆序压栈:把 u 的邻点从大到小压进去,编号最小的那个就落在栈顶,正好等于递归时“先走最小的那条边”。

显式栈 DFS:与递归版输出完全相同的序列
void dfs_iter(int s) {
    vector<int> st;                        // 自己当栈用:back() 看栈顶,push_back 压栈
    st.push_back(s);
    while (!st.empty()) {
        int u = st.back();
        st.pop_back();
        if (vis[u]) continue;              // 同一个点可能被多个前驱压进来
        vis[u] = true;
        cout << u << " ";                  // 出栈即输出,位置等价于递归里的"刚进函数"
        // 逆序压栈:让编号最小的邻点留在栈顶
        for (int i = (int)g[u].size() - 1; i >= 0; --i)
            if (!vis[g[u][i]]) st.push_back(g[u][i]);
    }
}

代价是栈里最多压 m 条记录(一个点可能被多个前驱重复压栈),m = 10⁶ 时 4 MB,可以接受。顺便记住这个对应关系:递归 = 系统栈,显式栈 = 自己管;DFS 用栈,BFS 用队列。把 queue 换成 stack,BFS 就变成了“深度优先但顺序不对”的东西,这也说明两种容器的区别不只是写法。

本节的 8 个坑,每个都有具体症状

无向图只加了一次边(漏了 g[v].push_back(u)):从 v 出发回到 u 的路消失,连通块数偏大、可达点偏少 → WA。样例常是树,容易蒙过去。

两个序列共用 vis 却不重置:第二个序列只剩源点 → WA,且错得非常整齐(一行只有一个数)。

BFS 在出队时才标记:同一个点被多个前驱各压一次,序列里出现重复编号 → WA。

忘记 sort 邻接表:输出的是输入顺序决定的序列,与“先看编号小的”不符 → WA。这种错样例就能查出来,属于最好查的一类,务必先手推样例。

数组开 vector<int> g[100000] 而 n 正好是 10⁵:下标 g[100000] 越界 → RE 或随机 WA。习惯写 N + 5。

有环图删掉 if (!vis[v]):递归无出口,几秒内爆栈 → RE(报错信息通常是 Stack overflow / Segmentation fault)。

链状数据上递归 DFS,深度 10⁵ → RE,而且数组完全没越界。改显式栈即可。

邻接矩阵开在函数里:n = 5000 时 5000² × 4 字节 = 100 MB,直接爆栈 → RE。大数组一律放全局或静态区(全局数组在 BSS 段,不受 8 MB 栈限制)。

自己动手复现

关掉页面,从空文件把 P5318 敲一遍并提交。

用样例里的 8 个点、9 条边手推两个序列,再和程序输出逐位比对:DFS 应为 1 2 4 8 5 3 6 7,BFS 应为 1 2 3 4 5 6 7 8。

改一改①:在 g[x].push_back(y) 后面补一句 g[y].push_back(x)(当成无向图),同一组数据重跑 —— 两个序列居然一模一样。因为新增的反向边全部指向已经访问过的点。这就是“样例查不出方向错误”的原因:改一条边为 7 1(或任何指向未访问点的边)再看,顺序立刻变。

改一改②:删掉 sort 那一行。样例的两个序列都变,说明顺序由邻接表的存储顺序决定。

改一改③:删掉 memset(vis, 0, sizeof(vis))。BFS 那一行只剩一个 1 ——这是本节最高频的错,亲眼见一次比读十遍有用。

改一改④:把 vis 换成 int block[N],先补成无向图,再按连通块给每个点标号,输出块数和每个点所属的块号,看看 1 号文章到底能不能到全部 8 篇。

数学补给 · 度、握手引理与两种存法的空间账

无向图里,点 u 的度 deg(u) 是与它相连的边数。有向图分成出度(从 u 出发的边数)和入度。邻接表里 g[u].size() 在有向图中就是出度,这一点在读题时经常要用。

握手引理:Σu deg(u) = 2m。理由只有一句:每条边 u–v 给 deg(u) 贡献 1、给 deg(v) 贡献 1,被数了两次。有向图里同样的道理给出 Σ out(u) = Σ in(u) = m。

推论(很实用):度数为奇数的点一定有偶数个。这就是欧拉路径判定里“奇点只有 0 个或 2 个”的来源,也是很多构造题的突破口 —— 如果一个构造要求“每个点的度数都是奇数”,那么点数必须是偶数,否则无解。

空间账。设 int 占 4 字节,一个空的 vector 对象占 24 字节:

nm邻接矩阵 n² × 4邻接表(无向)n × 24 + 2m × 4
10³10⁵4 MB24 KB + 0.8 MB ≈ 0.8 MB
10⁴10⁵400 MB(存不下)0.24 MB + 0.8 MB ≈ 1 MB
10⁵10⁶40 GB(存不下)2.4 MB + 8 MB ≈ 10 MB

表里能读出一条分界线:n ≤ 3000 时矩阵还能用(36 MB,放全局),n ≥ 10⁴ 时矩阵必死(400 MB 起)。所以“n ≤ 500 的 Floyd 题”用矩阵,“n 到 10⁵ 的遍历题”用邻接表,不是风格问题,是能不能存下的问题。

遍历的代价。DFS 每个点进一次、每条邻点表扫一次;无向图里邻点表的总长度是 2m,所以是 O(n + 2m) = O(n + m)。递归深度不超过 n,BFS 队列长度也不超过 n。本题 n = 10⁵、m = 10⁶,两次遍历共约 2×(10⁵ + 2×10⁶) ≈ 4×10⁶ 次操作,加上排序的 2×10⁷ 次比较 —— 瓶颈在排序,不在遍历,这是很多图论题的真实情况。

最后记一个二分图的计数结论:n 个点的二分图最多有 ⌊n²/4⌋ 条边(Mantel 定理),取到等号的是两部各 n/2 个点的完全二分图。所以看到“二分图 + 边数接近 n²/4”就该警觉:这图是稠密的,邻接矩阵可能比邻接表更合适。

举一反三 · 想清楚这些再往下走
  1. 如果题目要输出“每个点在第几层被 BFS 扫到”,BFS 代码改哪两行?为什么层号就是无权图上的最短距离?把这个 dis 数组和第 26 节的 dis 对比一下,看是不是同一个东西。
  2. P5318 里 m 到 10⁶,排序花了 O(m log m)。能不能降到 O(n + m)?提示:把 m 条边当记录做两趟稳定的计数排序(先按终点 v,再按起点 u),第二趟排完就是“按 u 分组、组内按 v 升序”,这就是基数排序(第 6 节的桶计数推广)。
  3. 无向图删掉一条边会让连通块数 +1,这样的边叫什么?用一次 DFS 找出所有这样的边,需要额外记什么?(提示:记“进入每个点的时刻”和“不走父边能回到的最早时刻”。)
  4. 有 10⁵ 次询问“a 能否到达 b”,图不变。每次都跑 DFS 是 10⁵ × (n + m),肯定超时。能预处理出什么,让每次询问 O(1)?(先想无向图,再想有向图难在哪。)
  5. 二分图判定里,为什么只检查“邻点与自己同色”就够,不用检查更远的点?如果图里有奇环,染色一定在奇环的哪一步失败?画一个五元环推一遍。
  6. 重边(同一个 u→v 出现多次)对 DFS/BFS 的遍历序列有影响吗?对连通块数呢?对“从 1 到 n 的路径条数”呢?三种情况分别给结论。
  7. 把本节所有代码里的 vector<vector<int>> 换成邻接矩阵,哪些题会 TLE 或 MLE?换成边集数组呢?分别写出 n = 10⁵、m = 10⁶ 时的估算。
自测:能全部回答再进下一节
  • 能不看资料写出“读 n、m,建有向图,排序邻接表,跑 DFS 和 BFS”的完整程序。
  • 能说清无向图为什么要加两次边,以及 2m 这个数字会出现在哪些地方(内存、复杂度、度数和)。
  • 能默写连通块计数的外层循环,并解释为什么总复杂度仍是 O(n + m) 而不是 O(n(n + m))。
  • 能说出二分图判定的染色规则,以及为什么必须对每个连通块各起一次遍历。
  • 知道递归 DFS 的深度风险和显式栈的写法,能说出“逆序压栈”是为了什么。
  • 拿到一道新图论题,能在 30 秒内决定用邻接矩阵还是邻接表,并说出理由。
挑战题 · 图的遍历
洛谷 P3916普及/提高-去洛谷做这题 ↗

给出 N 个点、M 条边的有向图。对每个点 v,令 A(v) 表示从点 v 出发能到达的编号最大的点。请求出 A(1), A(2), …, A(N)。

输入格式:第一行两个整数 N、M(1 ≤ N, M ≤ 10⁵);接下来 M 行,每行两个整数 U、V,表示一条边 U → V。点用 1..N 编号。

输出格式:一行 N 个整数,依次为 A(1) 到 A(N),用空格分隔。

引导性提示(先只看这里)

对每个点各跑一次 DFS 是 O(N(N + M)),N = 10⁵ 时是 10¹⁰ 量级,必然超时。换个方向想。

“从 v 出发能到 w”和“在反图里从 w 出发能到 v”是同一句话。反图就是把每条边掉个头。

先处理编号大的点:在反图上从 w 出发,一次能扫到的所有点,它们的答案至少是 w。从 N 递减到 1 走一遍,每个点只需要被扫到一次。

想看思路了?点开完整思路

建反图:原边 u → v 在反图里存成 v → u,即 rg[v].push_back(u)。这样“反图上从 w 出发能到达 x”就等价于“原图上 x 能到达 w”。

ans[] 全部先置 0,vis[] 全部先置 false。

for w = N down to 1:若 vis[w] 为假,就把 ans[w] 记成 w,然后从 w 出发在反图上做一次 BFS/DFS,把遇到的每个未访问点 x 都标成 vis[x] = true; ans[x] = w;。

正确性:处理到 w 时,所有编号大于 w 的点都已经处理完,它们能到达的点全被标记过。若 x 此刻还没被标记,说明原图上 x 到不了任何编号大于 w 的点;而反图上从 w 能扫到 x,说明原图上 x 能到 w。两者合起来,x 能到达的最大编号正好是 w。

若 w 已标记就整块跳过:此时原图上 w 能到某个编号更大的点,w 的答案已经定了;能到达 w 的点当然也能到达那个更大的点,答案同样已经定了,不必再扫。

复杂度:每个点在反图上最多被扫到一次,每条反边最多被看一次,O(N + M),N = M = 10⁵ 时总操作不到 2×10⁵ 次。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int N = 100005;
int n, m;
int ans[N];                        // ans[v]:从 v 出发能到达的最大编号
bool vis[N];
vector<vector<int>> rg;            // 反图:rg[v] 存"能一步到达 v 的点"

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> m;
    rg.resize(n + 1);
    for (int i = 0; i < m; ++i) {
        int u, v;
        cin >> u >> v;
        rg[v].push_back(u);        // 原边 u→v,反图里是 v→u
    }

    queue<int> q;
    for (int w = n; w >= 1; --w) {         // 从大到小,先定大答案
        if (vis[w]) continue;              // 已被更大的点扫过,答案已定
        vis[w] = true;
        ans[w] = w;                        // w 能到自己,且到不了更大的点
        q.push(w);
        while (!q.empty()) {               // 反图上从 w 扩散
            int u = q.front();
            q.pop();
            for (int x : rg[u])
                if (!vis[x]) {             // 每个点只标一次 → O(N+M)
                    vis[x] = true;
                    ans[x] = w;
                    q.push(x);
                }
        }
    }

    for (int i = 1; i <= n; ++i)
        cout << ans[i] << (i == n ? '\n' : ' ');
    return 0;
}
关键片段拆解
片段作用
rg[v].push_back(u);反着存。这是全题的关键一步:把“从 v 出发能到谁”变成“谁一步能到 v”,于是从一个大声号的点出发,就能一次扫出“能到达它”的所有点。
for (int w = n; w >= 1; --w)从大到小枚举。只有“先处理大的”,第一次被标记才一定是最大值。写成从小到大答案全错,而且错得很有规律:每个点都得到最小的那个可达点。
if (vis[w]) continue;w 已被更大的点扫过,说明原图上 w 能到达编号更大的点,w 的答案已经定了;能到达 w 的点答案也早就定了,整块不用再扫。这一步保证总复杂度是 O(N + M)。
ans[w] = w;w 至少能到自己,所以 A(w) ≥ w;而所有比 w 大的点都没扫到过 w,说明 w 到不了它们,所以 A(w) 正好等于 w。
q.push(w);用队列做 BFS。这里用 DFS 也对(顺序不影响标记结果),但 BFS 不会递归,天然避开 10⁵ 长链的爆栈问题。
for (int x : rg[u]) if (!vis[x])把“能一步到 u”的点全摊开,它们都能沿原图 u 继续走到 w。判重让每个点只被标记一次。
ans[x] = w;给新点打上答案。注意写的是 w(这一轮出发的大点),不是 u、也不是 x。
(i == n ? '\n' : ' ')行末只换行、不多空格。N 个数一行时这个细节直接决定 WA 与否。
这题最容易错的地方
  • 忘记建反图,直接在原图上从 w 出发扩散:方向反了,扫到的是“w 能到达的点”,答案整片错。
  • 从 1 开始从小到大枚举:先被标记的是小的可达点,而题目要最大值 → WA。
  • 用递归 DFS 且数据是一条 10⁵ 长的链:栈溢出 → RE,数组却没越界。改成队列或显式栈。
  • 数组只开 100000(而 N 能到 10⁵):ans[100000]、vis[100000] 越界,→ RE 或随机 WA,习惯写 N + 5。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》第 0x21 节:树与图的遍历
  • 《算法竞赛入门笔记》图论章:邻接表、连通块与染色
  • 洛谷 P5318、P3916 题解区的图论视角讲解
42 最短路:Dijkstra 与 Floyd提高-

42 · 最短路:Dijkstra 与 Floyd

把 BFS 的队列换成按 dist 排序的小根堆:Dijkstra 靠“每次取 dist 最小的点”做到 O((n + m) log n),Floyd 用三重循环一次算出所有点对。重点是懒删除、INF 的选法,以及 k 循环为什么必须最外层。
提高-预计 75 分钟DijkstraFloyd堆优化
本节出现的新函数 / 新语法(先看懂再用)

第 26 节已经会用 BFS 求最短路了,但它有一条隐形前提:每条边的代价都相等。有了这条前提,“先到”才等于“最近”。题目一旦给出边权 3、7、1 这样的数字,这个前提就碎了 —— 走两步的路径可能比走一步的代价还小。

于是要换判据:不比“第几层”,比“当前累计代价谁最小”。把 BFS 的队列换成按 dist 排序的容器,就得到 Dijkstra;把“一次算一个源点”改成“一次算所有点对”,就得到 Floyd。本节就这两个。

Dijkstra 的全部正确性压在一句话上:每次取出当前 dist 最小的点,它的 dist 已经不会再变小了。边权非负是这句话成立的唯一理由。
算法解决什么复杂度对边权的要求什么时候用
BFS(第 26 节)单源O(n + m)全部相等(通常为 1)无权图、最少步数
Dijkstra(堆优化)单源O((n + m) log n)非负绝大多数单源题,m 到 10⁶
Floyd全源(所有点对)O(n³)允许负边,不能有负环n ≤ 300~500,或要任意两点距离
Bellman-Ford / SPFA单源 + 判负环O(nm)允许负边有负权边时(本节只提一句)

先记三个数字,它决定了你该选谁。① n = 10⁵、m = 2×10⁵ 时,一次堆优化 Dijkstra 约 (n + m)·log m ≈ 3×10⁵ × 18 ≈ 5×10⁶ 次操作,稳过。② n = 500 时 Floyd 是 1.25×10⁸ 次内层操作,约 0.3 秒,能过。③ n = 1000 时 Floyd 是 10⁹,必超时 —— 而同样规模下跑 1000 次 Dijkstra 是 10⁹ 级,也不行。所以“n 小用 Floyd”里的“小”是 500,不是 5000。

本节的代码里只多了一个东西:一个能按“距离最小”自动排序的队列。它由 priority_queue 加 pair 加 greater 拼成,三样都要看准。

新函数拆解 · priority_queue 存 pair(堆里放 (距离, 点号))
priority_queue<pair<int,int>, vector<pair<int,int>>, greater<pair<int,int>>> pq; pq.push({d, u});
组成含义 / 格式要求
pair<int,int>把“距离”和“点号”打成一个元素。Dijkstra 的堆里一个元素就是一条“到 u 的距离是 d”的记录,不是点本身 —— 同一个 u 可以有多条记录。
第一个参数(元素类型)必须是 pair,因为堆里要同时带走距离和点号。只放点号的话,取出来还得回表查 dist,而 priority_queue 不支持按 dist 排序。
第二个参数 vector<pair<int,int>>底层容器。想换比较器就必须把它写出来 —— 这是 priority_queue 模板最反直觉的地方:三个参数得写全。
第三个参数 greater<...>比较器。greater 让“更小的”排在堆顶,于是 top() 是距离最小的记录,这正是“小根堆”。
pq.push({d, u})花括号直接构造 pair,等价于 push(make_pair(d, u))。
pq.top()取堆顶,返回 const 引用,不能直接改。要拆开用结构化绑定:auto [d, u] = pq.top();(C++17)。
pq.pop()删掉堆顶,返回 void。所以 top 和 pop 必须成对写,顺序不能反。
不能遍历、不能删任意元素只有 top / push / pop。Dijkstra 靠“重复入堆 + 取出时判旧值”绕开“修改已入堆元素”这个做不到的操作。
最小可运行示例
示例
// 小根堆:(距离, 点号),距离小的先出
priority_queue<pair<int,int>, vector<pair<int,int>>, greater<pair<int,int>>> pq;

pq.push({5, 3});                     // 到 3 号点距离 5
pq.push({2, 7});                     // 到 7 号点距离 2
pq.push({2, 4});                     // 距离同为 2,点号小的在前

while (!pq.empty()) {
    auto [d, u] = pq.top();          // C++17 结构化绑定,拆成两个变量
    pq.pop();
    cout << u << "(" << d << ") ";   // 输出 4(2) 7(2) 3(5)
}
注意

元素类型和比较器必须匹配。距离用 long long 时整个写法都要改成 pair<long long,int> 配 greater<pair<long long,int>>,漏改一个就是满屏模板报错。

同一个点会在堆里出现多次(每次 dist 被改小就 push 一份)。这不是 bug,是“懒删除”的代价,取出时用 if (d != dist[u]) continue; 过滤即可。

新函数拆解 · greater 对 pair 的比较(字典序,first 优先)
greater<pair<A,B>> // 等价于 a > b,按字典序:先比 first,first 相等再比 second
组成含义 / 格式要求
字典序比较pair 的 > 是先比 first,first 相等才比 second。greater 把它倒过来用,于是“更小的”排在堆顶。
所以 first 必须是距离堆顶 = 距离最小的记录。写成 {u, d} 就变成“点号最小的先出堆”,距离完全没参与比较,“取最小即确定”的依据当场消失。
first 相同时比 second距离相同的两条记录按点号升序出堆。这只影响同距离点之间的先后,不影响答案。
为什么不能写 greater<int>元素是 pair,比较器必须能比较 pair。greater<int> 和元素类型对不上,报错会指到模板深处。
等价写法一:负数priority_queue<pair<int,int>> pq; 配 push({-d, u}),用负号把大根堆当小根堆用。少写两个模板参数,但每个 d 前都要加负号,漏一次就是灾难。
等价写法二:自定义结构体元素是自己写的 struct 时没有现成的 greater,要重载 operator< 或写函数对象(第 28 节),方向与 sort 的 cmp 相反。
最小可运行示例
示例
pair<int,int> a = {3, 9}, b = {3, 2}, c = {4, 1};
cout << (a > b) << " " << (a > c) << "\n";   // 1 0:first 相等时看 second;3 < 4 所以 a < c

// 所以小根堆里 {3,2} 先于 {3,9},两者都先于 {4,1}
priority_queue<pair<int,int>, vector<pair<int,int>>, greater<pair<int,int>>> pq;
pq.push({9, 3});        // ✗ 把点号写在了 first:堆按 9 排序,距离 3 根本没参与
pq.push({3, 9});        // ✓ first = 距离 3,second = 点号 9
// 错写法里 {9,3} 和 {3,9} 比的是两个 first(9 与 3),也就是两个"点号"
注意

greater 在 <functional> 里,bits/stdc++.h 已包含。

记住一句话:堆里第一个字段永远是排序依据。Dijkstra 里它必须是距离。

例题 · 【模板】单源最短路径(标准版)洛谷 P4779普及/提高-去洛谷做这题 ↗

给定一个 n 个点、m 条有向边的带非负权图,请计算从 s 出发到每个点的最短距离。数据保证从 s 出发能到达任意点。

输入格式:第一行三个正整数 n、m、s;第二行起 m 行,每行三个非负整数 u、v、w,表示从 u 到 v 有一条权值为 w 的有向边。

输出格式:一行 n 个空格分隔的非负整数,第 i 个数表示 s 到 i 的距离。

数据范围:1 ≤ n ≤ 10⁵,1 ≤ m ≤ 2×10⁵,s = 1,1 ≤ u, v ≤ n,0 ≤ w ≤ 10⁹,0 ≤ Σw ≤ 10⁹。

输入: 4 6 1 1 2 2 2 3 2 2 4 1 1 3 5 3 4 3 1 4 4 输出: 0 2 4 3
例题怎么想:BFS 换成堆,只改一处

① dist 的定义和 BFS 一样。dist[v] 表示“当前已知的 s 到 v 最短距离”。dist[s] = 0,其余设成一个极大的数 INF。注意是极大,不是 0 —— 全 0 等于宣布“所有点都已经到达且距离为 0”,后面一条边都松弛不动。

② 重复做一件事:取出未确定的点里 dist 最小的那个 u,用它松弛所有出边。“松弛”就是三角不等式:if (dist[u] + w < dist[v]) dist[v] = dist[u] + w;。BFS 里这一步是 dist[v] = dist[u] + 1,因为不用比也知道更优。

③ 为什么取最小就能“确定”。假设弹出的 u 的 dist 还不是真正的最短距离 d(u)。在 s→u 的真实最短路上,从 s 开始找第一个还没被确定的点 y。y 之前的点都已确定并松弛过,所以 dist[y] = d(y)。因为边权非负,最短路越往后越长,所以 d(y) ≤ d(u);又因为 u 是当前未确定点里 dist 最小的,所以 dist[u] ≤ dist[y]。串起来:d(y) ≤ d(u) < dist[u] ≤ dist[y] = d(y),矛盾。∎ “边权非负”只用在这一步,这是全节最值得记住的一句话。

④ 用堆代替线性扫描。朴素写法每次在 n 个点里找最小是 O(n²);把 (dist, u) 丢进小根堆,top() 就是最小,代价 O(log n)。

⑤ 懒删除。priority_queue 不能改已入堆的值,也不能删任意元素,所以 dist 变小时就再 push 一份新的,旧记录留在堆里。取出时判断 if (d != dist[u]) continue; —— 对不上说明这条是过期记录,扔掉。

⑥ 复杂度。每条边最多触发一次 push,所以最多 push m + 1 次;堆大小 ≤ m + 1;每次 push/pop 是 O(log m)。总复杂度 O((n + m) log m),写成 O((n + m) log n) 也一样。

骨架:六步
// 骨架:初始化 → 源点入堆 → 弹堆顶 → 懒删除 → 松弛 → 新值入堆
vector<long long> dist(n + 1, INF);
dist[s] = 0;
pq.push({0, s});
while (!pq.empty()) {
    auto [d, u] = pq.top(); pq.pop();
    if (d != dist[u]) continue;                 // 过期记录,扔掉
    for (auto &e : g[u]) {                      // 只扫 u 的出边,每条边一生只被扫一次
        int v = e.first, w = e.second;
        if (dist[u] + w < dist[v]) {            // 三角不等式
            dist[v] = dist[u] + w;
            pq.push({dist[v], v});              // 新值入堆,旧值不管
        }
    }
}

把骨架补全就得到下面的程序。两个细节值得先看:邻接表存的是 pair<int,int>(终点 + 边权),所以不用 struct Edge;距离用 long long,虽然本题 Σw ≤ 10⁹ 用 int 也够,但换一道题(边权 × 点数)就会爆,统一 long long 省心。

完整代码(C++17)
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m, s;
    cin >> n >> m >> s;

    // 邻接表:g[u] 里每一项是 (终点, 边权)
    vector<vector<pair<int,int>>> g(n + 1);
    for (int i = 0; i < m; ++i) {
        int u, v, w;
        cin >> u >> v >> w;
        g[u].push_back({v, w});        // 有向边,只加一次
    }

    const ll INF = (ll)4e18;           // 4e18 + 4e18 仍在 long long 内
    vector<ll> dist(n + 1, INF);
    dist[s] = 0;                       // 源点到自己是 0

    // 小根堆:first 是距离,堆顶永远是"当前距离最小的记录"
    priority_queue<pair<ll,int>, vector<pair<ll,int>>, greater<pair<ll,int>>> pq;
    pq.push({0, s});

    while (!pq.empty()) {
        auto [d, u] = pq.top();        // C++17:把 pair 拆成 d 和 u
        pq.pop();
        if (d != dist[u]) continue;    // 懒删除:这是 u 的旧记录

        for (auto &e : g[u]) {         // 引用遍历,不复制 pair
            int v = e.first, w = e.second;
            if (dist[u] + w < dist[v]) {          // 绕 u 更快
                dist[v] = dist[u] + w;
                pq.push({dist[v], v});            // 旧记录留在堆里,靠上面那行过滤
            }
        }
    }

    for (int i = 1; i <= n; ++i)
        cout << dist[i] << (i == n ? '\n' : ' ');
    return 0;
}
逐行拆解
代码片段含义与理由
vector<vector<pair<int,int>>> g(n + 1);邻接表,每项是 (终点, 边权)。这里用 pair 而不是 struct Edge,因为只有两个字段、没有别的语义。构造函数给了 n + 1,下标 1..n 直接用。
const ll INF = (ll)4e18;INF 要满足两件事:比任何可能的最短路都大,且 INF + w 不溢出。4e18 加 1e9 远小于 long long 上限 9.22×10¹⁸,所以 dist[u] + w 不会变成负数。
vector<ll> dist(n + 1, INF);全部先当不可达。这一行的关键在于不是 0:初值给 0,所有点都“已到达且距离 0”,任何 dist[u] + w < dist[v] 都不成立,程序输出一行 0。
dist[s] = 0;源点到自己是 0。别漏,漏了源点也是 INF,堆里第一条记录就是 (INF, s),后面全乱。
priority_queue<pair<ll,int>, vector<pair<ll,int>>, greater<pair<ll,int>>> pq;三段拼出来的小根堆。first 是距离,所以 top() 给的是“当前最小距离的记录”。把 greater 换成默认的 less,就变成每次取最大,答案全错。
pq.push({0, s});只把源点入堆,不是把所有点都塞进去。堆里只该有“距离被更新过”的记录。
auto [d, u] = pq.top(); pq.pop();先取出再删除。结构化绑定等价于 ll d = pq.top().first; int u = pq.top().second;,但只调一次 top()。
if (d != dist[u]) continue;懒删除。u 的距离可能被改小过好几次,堆里躺着好几份 (d, u),只有 d == dist[u] 的那份是最新的。删掉这行答案仍正确,但过期记录也会去扫一遍出边,度数大的点被改多次时可能 TLE。
for (auto &e : g[u])引用遍历,不复制 pair。e.first 是终点 v,e.second 是边权 w。写成 auto e 也能跑,只是多一次拷贝。
if (dist[u] + w < dist[v])松弛的三角不等式。dist[u] 此刻已是最优值,所以只需检查“绕过 u 这一条边”是否有改善 —— 这正是“每个点只处理一次”的依据。
pq.push({dist[v], v});新距离入堆。注意 push 的是 dist[v] 而不是 d + w(两者相等,但前者可读性更好,也不怕以后改写法)。
(i == n ? '\n' : ' ')n 个数一行,行末只换行。多一个空格在某些严格比对的题上会 WA。
Floyd:三重循环,一次算出所有点对

Dijkstra 解决“一个源点到所有点”。如果要所有点对的距离,跑 n 次 Dijkstra 是 O(n(m + n) log n),n = 500 时约 8.5×10⁸,太慢;而 Floyd 是 O(n³) = 1.25×10⁸,反而更快。稠密图上,Floyd 常常是更优的选择。

思路是动态规划。定义 fk(i, j) = 只允许把编号 1..k 的点当作中转点时,i 到 j 的最短距离。从 k−1 推到 k 只有两种选择:不用 k,还是 fk−1(i, j);用 k,就是 fk−1(i, k) + fk−1(k, j)。于是

fk(i, j) = min( fk−1(i, j), fk−1(i, k) + fk−1(k, j) )。

k 这一维可以滚掉:算 fk 时 fk−1(i, k) 与 fk−1(k, j) 的值和 fk 的那两个值相同(最短路经过 k 本身不会变得更短,除非有负环),所以直接就地覆盖成二维数组即可。这就是为什么 k 循环必须写最外层 —— 它是 DP 的阶段维。

Floyd 骨架
#include <bits/stdc++.h>
using namespace std;

const int N = 305;
const long long INF = (long long)4e18;
long long f[N][N];

int main() {
    int n, m;                      // n ≤ 300
    cin >> n >> m;
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= n; ++j)
            f[i][j] = (i == j ? 0 : INF);          // ★ 自己到自己是 0
    for (int i = 0; i < m; ++i) {
        int u, v, w; cin >> u >> v >> w;
        f[u][v] = min(f[u][v], (long long)w);      // 重边取最小
    }
    for (int k = 1; k <= n; ++k)                   // ★ 阶段:必须最外层
        for (int i = 1; i <= n; ++i) {
            if (f[i][k] == INF) continue;          // 剪枝:i 到不了 k,这一层白算
            for (int j = 1; j <= n; ++j)
                if (f[i][k] + f[k][j] < f[i][j])
                    f[i][j] = f[i][k] + f[k][j];
        }
    // 到这里 f[i][j] 就是 i 到 j 的最短路
}

k 放里层会错,给一个具体反例。4 个点、3 条边 1→3、3→4、4→2,权都是 1,正确答案 d(1, 2) = 3。若写成 for i for j for k:算 (i = 1, j = 2) 时需要用 f[1][4],而 f[1][4](要等 j = 4 才算)此刻还是 INF,于是 f[1][2] 永远停在 INF。这类错误的表现是“一部分点对正确、一部分是 INF 或偏大”,极难靠小样例发现。

Floyd 还白送三件东西:①允许负权边(只要没有负环);②判负环 —— 跑完看 f[i][i] < 0,说明有负环(正常应该等于 0);③传递闭包 —— 把 min 换成 or、+ 换成 and,得到的就是“i 能否走到 j”,三重循环一个字不用改。

负权边为什么不能 Dijkstra

给一个三点反例,能一次看清问题:边 1→2 权 2,1→3 权 5,3→2 权 −4。真实答案是 d(2) = 1(绕 3 号点走)。Dijkstra 的执行顺序是:dist = [0, 2, 5],先弹出 2 号点(dist = 2 最小),把它当最终值;再弹出 3 号点,松弛 3→2 得到 1 —— 但 2 号点已经处理完了,答案停在 2。这一段描述的是带“弹出即确定”标记的经典实现(弹出后 vis[u] = true 并不再更新 u),本节代码没有这个标记,结论见第三段。

坏在哪一步?回到前面的证明:那句 d(y) ≤ d(u) 用的是“最短路越往后越长”,而这条性质只在边权非负时成立。有负边时,最短路可能越走越短,“先取出的最小点”就不再是确定的。

还要说清楚一个细节,很多资料含糊过去:本节这种“懒删除、不加 vis”的写法,在负权图上最终仍会给出正确答案,因为每次距离变小都会重新入堆,等价于堆版的 Bellman-Ford —— 但复杂度从 O((n + m) log n) 退化成 O(nm) 量级,SPFA 的卡法能把它卡死。而只要你为了提速加上“弹出即确定”(if (vis[u]) continue; vis[u] = true;),负权图上就是彻底的 WA。

所以结论不变,只是理由更精确:Dijkstra 的复杂度保证依赖非负权,正确性在带“确定”标记的实现里也依赖它。有负权边时用 Bellman-Ford / SPFA,并且用它顺手判负环。

最后一个容易忽视的推论:无向图里只要出现一条负权边,就一定有负环(这条边来回走一趟,总权变成负数,可以无限刷小),此时最短路根本不存在。所以看到“无向图 + 负边”要么题目保证没有,要么答案是 −∞,不要指望任何最短路算法给出有限值。

本节的 8 个坑,每个都有具体症状

dist 用默认值 0 初始化(全局数组、或 vector<ll> dist(n + 1)):所有点都“已到达且距离 0”,一条边也松弛不动,输出一整行 0 → WA。这是本节第一大坑。

INF 取得太小(如 1e9):真实最短路超过 1e9 时会被当成“不可达”,输出 1000000000 → WA。INF 取 INT_MAX:INF + w 溢出成负数,负数被当成更短的路 → WA。

删掉 if (d != dist[u]) continue;:答案仍对,但过期记录也会去扫一遍所有出边,高度数点被反复更新时工作量成倍上涨 → TLE。

pair 写成 {u, d}(点号在前):堆顶变成“点号最小的记录”,距离没参与比较,复杂度退化到 SPFA 级别,构造数据 → TLE;若你还加了“弹出即确定”的 vis,则直接 WA。

忘记写 greater(用默认大根堆):每次取当前最大距离的点,和“取最小即确定”完全相反。小数据可能因为反复入堆而碰巧收敛,一旦加 vis 就 WA —— 这种“样例能过”的隐患最危险。

Floyd 的 k 循环写进 i、j 里层:部分点对停在 INF 或偏大 → WA,而且小样例常常是过的。

Floyd 忘记 f[i][i] = 0:经过自己的转移全部失效(INF + x 还是 INF),答案偏大 → WA;反过来,用 0 初始化整个矩阵会得到“任意两点距离都是 0” → WA。

用 int 存 dist:n = 10³、w = 10⁴ 时单程才 10⁷,看着够;但 n、w 各放大十倍就是 10⁹ 出头,再加一条边就溢出成负数 → WA。统一 long long,代价只是一点内存。

自己动手复现

关掉页面,从空文件把 P4779 敲一遍并提交。

自测数据就用样例:4 6 1 加 6 条边,答案应为 0 2 4 3。手推一遍 Dijkstra 的出堆顺序(1 → 2 → 4 → 3),再和程序打印的对照。

改一改①:把 dist 的初值从 INF 改成 0,重跑样例。输出会变成一行 0 ——亲眼确认“默认 0 等于宣布所有点都可达”。

改一改②:删掉 if (d != dist[u]) continue;,在 while 里加一个计数器统计出堆次数。同一组数据对比加不加这一行时堆里到底弹了多少条过期记录。

改一改③:把 greater<...> 整段删掉(变成默认大根堆),重跑样例。答案可能仍然正确 —— 想清楚为什么(提示:每次改进都会重新入堆),以及为什么一旦加上“弹出即确定”的 vis 就会错。

改一改④:把 Floyd 代码里的 k 循环挪到 i、j 里面,用 4 个点、3 条边 1 3 / 3 4 / 4 2(权都是 1)测 d(1, 2)。正确答案 3,错写法给 INF。

数学补给 · 无穷大怎么选、复杂度怎么估、k 为什么必须在外层

先解决最容易出错的小事:INF 要同时满足三条。① 比任何可能的最短路都大;② INF + w 不溢出;③ INF + w 仍要远大于任何真实路径,这样“不可达”的点不会因为加了一次边权就变成“可达”。

若最短路长度的上界是 L = (n − 1)·wmax,取 INF > L 就够了。P4779 里 Σw ≤ 10⁹,所以 L ≤ 10⁹,INF = 10⁹ + 1 都行;但习惯上写 4e18。

选法数值两倍是否溢出适用
0x3f3f3f3f(int)10611095672122219134 < 2³¹−1,安全int 版最短路,最常用
INT_MAX(int)2147483647溢出成负数,危险不要用
4e18(long long)4×10¹⁸8×10¹⁸ < 9.22×10¹⁸,安全long long 版
1e18(long long)10¹⁸2×10¹⁸,安全也常用,但要确认 L < 10¹⁸

再看复杂度怎么估,这是选算法的唯一依据。记 (n + m) log m 里的 log 按 18 算(m = 2×10⁵):

nm一次 Dijkstran 次 Dijkstra(全源)Floyd n³
50010⁵1.7×10⁶8.5×10⁸1.25×10⁸
10³10⁴1.4×10⁵1.4×10⁸10⁹
10⁵2×10⁵5.4×10⁶5.4×10¹¹10¹⁵

表里最值得盯的是第一行:n = 500 的稠密图上,Floyd 比“跑 500 次 Dijkstra”快 7 倍。原因很朴素:Floyd 的内层只有一次加法、一次比较、一次赋值,而 Dijkstra 每次出堆都要做 log 级别的比较,常数大得多。所以“Dijkstra 一定比 Floyd 快”是错的 —— 看 n、m 的比例。

最后解释 k 循环的位置。Floyd 是一个 DP:阶段 k 表示“允许用编号 ≤ k 的点当中转”。转移只从 k−1 层来,所以必须按层推进、层层算完。k 放里层,等于在用“还没算好的中间结果”做转移,某些点对就会停留在旧值上。

换个角度也说得通:最短路满足三角不等式 d(i, j) ≤ d(i, k) + d(k, j)。松弛就是把违反它的估计值改小。Floyd 的第 k 轮做完时,所有“只允许 1..k 当中间点”的三角不等式都成立;k 从 1 跑到 n,所有可能的中转点都被检查过一遍,留下的就是最短路。

顺带两条能直接用的结论:① 跑完看 f[i][i] < 0 就是在判负环;② 把 (min, +) 换成 (or, and) 就得到传递闭包 —— 同一个三重循环骨架,两种语义。这也是“矩阵乘法式”算法的开端:Floyd 本质上是 (min, +) 半环上的矩阵乘方。

举一反三 · 想清楚这些再往下走
  1. 如果图里有 0 权边,Dijkstra 还对吗?回到证明里看“d(y) ≤ d(u)”这一步 —— 它能扛住 0 权边,为什么扛不住负权边?只差一个等号还是不等号?
  2. 要求“从 s 到每个点的最短路径条数”,该在 Dijkstra 的哪一步累加?为什么必须在“松弛成功”和“距离相等”两种情况分开处理,且相等时不能重新入堆?
  3. 如果每条边的权只有 1 和 2 两种,能不能不用堆,把它做成 O(n + m)?提示:把每条权为 2 的边中间插一个虚拟点,拆成两条权为 1 的边(拆点),再用 BFS。
  4. Dijkstra 里什么时候可以提前退出?如果只关心“s 到 t 的距离”,弹出 t 的那一刻能不能停?换成“只关心 s 到某 k 个点的距离”呢?
  5. 把 Floyd 的 (min, +) 换成 (max, min) 会得到什么?(提示:最大瓶颈路,答案是“路径上最小边权的最大值”。)为什么同一套循环换成 (max, +) 求最长路就不行了?
  6. 如果边权是浮点数,Dijkstra 还成立吗?哪些地方要改(d != dist[u] 这一句首当其冲)?
  7. 有 10⁵ 个点和 10⁵ 条边,但所有边的权都是非负整数且总和不超过 10⁶,能不能做得比 Dijkstra 更快?提示:桶排序式的 Dijkstra(Dial 算法),第 28 节的 0-1 BFS 是它在 W = 1 时的特例。
自测:能全部回答再进下一节
  • 能不看资料默写堆优化 Dijkstra:dist 初始化、堆、弹出、懒删除、松弛、入堆。
  • 能说清为什么 first 必须是距离,并解释“取最小即确定”的证明中哪一步用到了非负权。
  • 能写出 0x3f3f3f3f 与 4e18 两种 INF,并说明为什么 INT_MAX 不行。
  • 能默写 Floyd 三重循环,并给出一个“k 放里层就错”的具体图。
  • 能举出三点反例说明负权边为什么让 Dijkstra 失效,并知道这种情况该换什么算法。
  • 拿到一道题,能根据 n、m 的大小在 Dijkstra、Floyd、n 次 Dijkstra 之间做出选择并说出理由。
挑战题 · 邮递员送信
洛谷 P1629普及/提高-去洛谷做这题 ↗

邮局在 1 号点,要送 n − 1 件东西到 2..n 号点。城市里所有道路都是单行道,共 m 条。邮递员每次只能带一件东西,送完必须返回邮局。

求送完这 n − 1 件东西并最终回到邮局所需的最少总时间。

输入格式:第一行两个整数 n、m;接下来 m 行,每行三个整数 u、v、w,表示从 u 到 v 有一条耗时 w 的单行道。

输出格式:一行一个整数,表示最少总时间。

数据范围:1 ≤ n ≤ 10³,1 ≤ m ≤ 10⁵,1 ≤ w ≤ 10⁴,保证任意两点互相可达。

引导性提示(先只看这里)

每次出门的路线是 1 → i,回程是 i → 1,n − 1 次出差互不影响。所以总时间就是 Σ(d(1, i) + d(i, 1)),不要想成“一条大回路”。

d(1, i) 跑一次 Dijkstra 就全有了。d(i, 1) 要对 n − 1 个 i 各跑一次吗?想想上一节的反向建图。

把所有边方向掉个头,在反图上从 1 跑一次 Dijkstra —— 反图上 1 到 i 的最短路,就是原图上 i 到 1 的最短路。

注意答案的最大量级:单程最坏约 10³ × 10⁴ = 10⁷,累加 10³ 次就是 10¹⁰,必须 long long。

想看思路了?点开完整思路

拆开算。送第 i 件东西的过程是“1 → i,再 i → 1”,与其他件无关,所以总时间 = Σi=2..n ( d(1, i) + d(i, 1) )。两次 Dijkstra 就能得到全部 2(n − 1) 个值。

第一遍:正图。以 1 为源点跑 Dijkstra,得到 distA[i] = d(1, i)。

第二遍:反图。建一张把所有边反向的图,同样以 1 为源点跑 Dijkstra。反图上一条 1 → x1 → … → i 的路径,在原图上正好是 i → … → x1 → 1 的路径,长度相同。所以 distB[i] = d(i, 1)。

为什么不能只跑一遍。正图给出的是“从 1 出发到各点”,而“从各点回到 1”在有向图里是完全不同的问题(单行道,去得回不得)。对每个点各跑一次是 O(n m log n) = 10³ × 10⁵ 量级,会超时;反图把它压回一次。

复杂度。两遍堆优化 Dijkstra,O((n + m) log n),n = 10³、m = 10⁵,约 2×10⁵ × 17 ≈ 3.4×10⁶ 次操作。

为什么不用 Floyd。n = 10³ 时 10⁹ 次内层操作,超时;而且我们只需要 1 到各点、各点到 1 这两批值,Floyd 算的 10⁶ 个点对里绝大多数用不上。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const int N = 1005;
const ll INF = (ll)4e18;

int n, m;
vector<vector<pair<int,int>>> g, rg;   // g: 正图, rg: 反图
ll distA[N], distB[N];

// 从 s 出发的单源最短路,结果写进 d[1..n]
void dijkstra(vector<vector<pair<int,int>>> &gr, int s, ll *d) {
    for (int i = 1; i <= n; ++i) d[i] = INF;   // 全部先当不可达
    d[s] = 0;
    priority_queue<pair<ll,int>, vector<pair<ll,int>>, greater<pair<ll,int>>> pq;
    pq.push({0, s});
    while (!pq.empty()) {
        auto [du, u] = pq.top();
        pq.pop();
        if (du != d[u]) continue;              // 懒删除:过期记录
        for (auto &e : gr[u]) {
            int v = e.first, w = e.second;
            if (du + w < d[v]) {
                d[v] = du + w;
                pq.push({d[v], v});
            }
        }
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> m;
    g.resize(n + 1);
    rg.resize(n + 1);
    for (int i = 0; i < m; ++i) {
        int u, v, w;
        cin >> u >> v >> w;
        g[u].push_back({v, w});        // 正图:u → v
        rg[v].push_back({u, w});       // 反图:v → u,方向必须掉头
    }

    dijkstra(g, 1, distA);             // d(1, i)
    dijkstra(rg, 1, distB);            // 反图上从 1 出发 = 原图上 i 到 1

    ll ans = 0;
    for (int i = 2; i <= n; ++i)       // 1 号点是邮局自己,不算
        ans += distA[i] + distB[i];
    cout << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
rg[v].push_back({u, w});全题的转折点。原边 u → v 在反图里存成 v → u,权值不变。这样“反图上 1 能到 i”就等价于“原图上 i 能到 1”,一次 Dijkstra 顶掉 n − 1 次。
void dijkstra(vector<vector<pair<int,int>>> &gr, int s, ll *d)同一个函数跑两张图:传引用避免复制整张图,数组用指针传入 ll *d,传 distA 这种全局数组时会自动退化成指针。
ans += distA[i] + distB[i];第 i 件东西的总耗时是“去 + 回”。i 从 2 开始,因为 1 号点是邮局,没有东西要送给自己。
ll ans = 0;最大量级:单程最坏 (n − 1) × w = 999 × 10⁴ ≈ 10⁷,累加 999 次约 10¹⁰ —— 超过 int 上限 2.1×10⁹,必须 long long。这是本题最容易漏的一处。
for (int i = 1; i <= n; ++i) d[i] = INF;每次调用先重置。这里没写 0 而是 INF:写 0 就等于宣布“所有点距离 0”,一条边都松弛不动。
if (du != d[u]) continue;懒删除。u 的距离每被改进一次就会 push 一份记录,堆里可能躺着好几份,只有 du == d[u] 的那份是最新的。
if (du + w < d[v])松弛用本次弹出的 du 而不是 d[u]。通过懒删除后两者相等,但用 du 少一次数组访问,也更清楚。
g.resize(n + 1); rg.resize(n + 1);开 n + 1 条空表,下标 1..n 直接用。两个 vector 分别在 main 里 resize,因为点数要到读入之后才知道。
cout << ans << "\n";只有一个整数,末尾换行。不要顺手输出“最少时间是:”这类提示语 —— 那会直接 WA。
这题最容易错的地方
  • 把“回程”当成“去程”:只跑一遍正图,用 distA[i] 乘 2 当答案。有向图里两个方向的距离一般不同,必须建反图。
  • 反图加边时又写成 rg[u].push_back({v, w}):反图变成了正图的复制品,第二遍 Dijkstra 算的还是 d(1, i),答案偏小。
  • 答案用 int:单程最坏 10⁷,累加 10³ 次约 10¹⁰,溢出成负数 → WA。累加与 dist 统一 long long。
  • 把单行道当无向边、每条边加两次:反向边凭空出现,去程和回程的距离都会变小 → WA。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》第 0x61 节:最短路
  • 《算法竞赛试炼场:洛谷 300 题精析》最短路章:堆优化与懒删除
  • 洛谷 P4779、P1629 题解区的高赞思路
43 最小生成树与并查集提高-

43 · 最小生成树与并查集

一张图里挑 n−1 条边把所有点连起来,还要最省。Kruskal 的两块拼图:并查集负责判断“会不会成环”,排序贪心负责“最省”。顺带把 MST 的唯一性与瓶颈路两个理论问题一次讲清。
提高-预计 70 分钟Kruskal并查集瓶颈路
本节出现的新函数 / 新语法(先看懂再用)

给定一张 n 个点、m 条带权边的连通图,从中选 n−1 条边把所有点连起来,使边权和最小——这就是最小生成树(MST)。先记住一个事实:n 个点、n−1 条边、连通,这三条同时成立时,图一定是树——任意两点之间路径唯一,没有环。于是“选一棵树”只剩一个自由度:到底选哪 n−1 条边。

Kruskal 的正文只有三句话:把边按权值升序排;从小到大逐条看;两端还没连通就要它,已经连通就扔掉。“连通”这个词在图上很难判,但对一张不断合并的图来说,它恰好是并查集的全部工作。

Kruskal 的正确性只靠一条性质:当前还未处理过的最轻的那条边,只要它跨了两个连通块,要它一定不吃亏。
KruskalPrim(堆优化)
套路边按权值升序,能加就加,成环就跳从一点出发,每次把离生成树最近的点拉进来
依赖边排序 + 并查集判环邻接表 + 优先队列取最近点
复杂度O(m log m)O((n + m) log n)
稀疏图 m ≈ n快,只跟边数有关差不多,但堆操作多
稠密图 m ≈ n²要排 n² 条边,偏慢更快;n 小时用 O(n²) 的邻接矩阵版
附带产物边权从小到大的合并过程,能直接回答瓶颈路每个点被拉进来时的距离,是它到已选点集的最小边
本节为什么用 Kruskal★ 判环用并查集,只有 5 行,且不用建邻接表第 42 节已学,思路是 Dijkstra 的孪生兄弟

先拆三块骨头:find、merge、Kruskal 主循环。前两个是并查集(判“在不在一块”),第三个是贪心框架。这三块加起来不到 20 行,但每一行都有一个专属的错法。

新函数拆解 · 并查集 find(路径压缩)
int fa[N]; int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }
组成含义 / 格式要求
fa[x]x 的父指针。fa[x] == x 时 x 就是所在集合的代表(根)。初始化 fa[i] = i,每个点自成一个集合。
fa[x] == x ? x :递归出口:自己就是根,返回自己。整个函数只有这一个终止条件。
fa[x] = find(fa[x])先递归找到根,再把 x 直接挂到根下面——这就是路径压缩。赋值号不能省,省掉就只剩递归、没有压缩。
路径压缩的效果链 a→b→c→root:第一次 find(a) 沿链走到底,返回时顺手把 a、b、c 全改成指向 root;之后再问就是 O(1)。
返回值根节点的编号。它只用来判“是不是同一集合”,不代表任何顺序或大小。
为什么递归不会爆栈没压缩前树高最多 log₂n(按大小合并保证),n = 5000 时不过 13 层;压缩后更浅。
初始化必写for (int i = 1; i <= n; ++i) fa[i] = i; 漏写时 fa 全是 0,所有点被认成同一集合 → 一条边都加不进去 → WA。
三目写法比 if/else 短,能直接写成 return 表达式,编译器容易内联。竞赛里几乎只写这一种。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int fa[10];

int find(int x) {                              // 找根 + 顺手压缩
    return fa[x] == x ? x : fa[x] = find(fa[x]);
}

int main() {
    for (int i = 1; i <= 5; ++i) fa[i] = i;    // 初始化:各自是根
    fa[3] = 2; fa[2] = 1;                      // 手工造一条链 3 → 2 → 1

    cout << find(3) << "\n";                   // 1:根是 1
    cout << fa[3] << "\n";                     // 1:压缩后 3 直接指向 1
    cout << fa[2] << "\n";                     // 1:2 也被压平了
    return 0;
}
注意

压缩发生在返回的路上:先递归到根,再一层层把 fa[x] 改成根。所以第一次调用最慢,之后全是 O(1)。

fa 数组是全局的,多组数据时必须重新 for (i) fa[i] = i,不然上一组的关系会被继承下来。

新函数拆解 · 并查集 merge(按大小合并)
int sz[N]; void merge(int x, int y) { x = find(x); y = find(y); if (x == y) return; if (sz[x] < sz[y]) swap(x, y); fa[y] = x; sz[x] += sz[y]; }
组成含义 / 格式要求
x = find(x); y = find(y);先把两个点换成各自的根。传进来的是普通点,直接 fa[y] = x 会把一条路径接乱,之后全错。
if (x == y) return;已在同一集合,合并是空操作。漏写不崩,但 size 会重复累加、判环失效(见易错清单)。
if (sz[x] < sz[y]) swap(x, y);保证 x 是大的那块。把小树挂到大树底下,树高最多涨 1。这就是按大小合并。
fa[y] = x;把 y 的根接到 x 的根下面。整个集合的结构只靠这一行改动。
sz[x] += sz[y];只维护新根 x 的 size。y 已经不是根了,它的 size 再没人读,不用清。
返回值void。想知道“这次合并是否真的发生”,判 find(u) == find(v) 更好——Kruskal 正是靠它跳过成环的边。
按秩还是按大小维护 size 比维护 rank(树高)简单,效果一样:都给 O(log n) 上界,配上路径压缩就都是 O(α(n))。本节用 size。
复杂度单次 merge = 两次 find + 常数。α(n) ≤ 4,见本节数学补给。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int fa[10], sz[10];

int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }

void merge(int x, int y) {
    x = find(x); y = find(y);
    if (x == y) return;                     // 已经在一起
    if (sz[x] < sz[y]) swap(x, y);          // 小的挂到大的下面
    fa[y] = x;
    sz[x] += sz[y];
}

int main() {
    for (int i = 1; i <= 5; ++i) { fa[i] = i; sz[i] = 1; }

    merge(1, 2);                            // {1,2}
    merge(3, 4);                            // {3,4}
    merge(2, 3);                            // 合成 {1,2,3,4}

    cout << find(4) << " " << sz[find(1)] << "\n";   // 1 4
    return 0;
}
注意

merge 内部一定会重新 find,所以调用时传原始点即可,别在外面再 find 一次(不报错,但容易忘)。

按大小合并防的是最坏情况:不加这一步,父指针的深度没有任何上界——数据可以把它串成一条长度为 n 的链,单次 find 从 O(1) 退化成 O(n)。

新函数拆解 · Kruskal 主循环(排序 + 贪心加边)
struct Edge { int u, v, w; } e[M]; sort(e + 1, e + m + 1, [](const Edge &a, const Edge &b) { return a.w < b.w; }); int cnt = 0; long long ans = 0; for (int i = 1; i <= m && cnt < n - 1; ++i) { if (find(e[i].u) == find(e[i].v)) continue; merge(e[i].u, e[i].v); ans += e[i].w; ++cnt; }
组成含义 / 格式要求
struct Edge一条边存三个 int:u、v 是无向的两个端点,w 是权值。用结构体而不是三个平行数组,排序时整条边一起搬。
sort 的第三参数lambda,按 w 升序。必须写 < 不能写 <=——cmp 违反严格弱序会让 GCC 的 sort 越界访问,直接 RE。
e + 1, e + m + 11-indexed 的数组要用这两根指针。写成 e, e+m 会漏掉第 m 条边(代价是偶尔少一条最重边,答案偏小)。
cnt < n − 1树只有 n−1 条边,凑够就停。它既是提前退出,也是最后判连通的依据。
find(u) == find(v)两端已连通 → 加这条边会成环 → 跳过。整个算法唯一做判断的地方。
merge(u, v)两端不在同一块,这条边把两个连通块缝起来了。并查集记下这次合并,后续判环就靠它。
ans += e[i].w只有真加进树的边才累加权值。必须写在 continue 之后。
结束条件cnt == n−1 说明成功;cnt < n−1 说明图不连通,此时 ans 只是“最小生成森林”的权值,不是答案。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

const int N = 10, M = 20;
struct Edge { int u, v, w; } e[M];
int fa[N], sz[N];

int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }
void merge(int x, int y) {
    x = find(x); y = find(y);
    if (x == y) return;
    if (sz[x] < sz[y]) swap(x, y);
    fa[y] = x; sz[x] += sz[y];
}

int main() {
    int n = 4, m = 5;
    int raw[6][3] = {{0,0,0}, {1,2,2}, {1,3,2}, {1,4,3}, {2,3,4}, {3,4,3}};
    for (int i = 1; i <= m; ++i) { e[i].u = raw[i][0]; e[i].v = raw[i][1]; e[i].w = raw[i][2]; }

    sort(e + 1, e + m + 1, [](const Edge &a, const Edge &b) { return a.w < b.w; });
    for (int i = 1; i <= n; ++i) { fa[i] = i; sz[i] = 1; }

    long long ans = 0; int cnt = 0;
    for (int i = 1; i <= m && cnt < n - 1; ++i) {
        if (find(e[i].u) == find(e[i].v)) continue;   // 成环,扔掉
        merge(e[i].u, e[i].v);
        ans += e[i].w; ++cnt;
    }
    cout << cnt << " " << ans << "\n";   // 3 7:加了 3 条边,权和 7
    return 0;
}
注意

排序是最贵的一步:m = 2×10⁵ 时约 3.5×10⁶ 次比较,比并查集那几十万次操作重得多。想优化,先优化排序。

cnt 一定要在真加边时才 ++。写在 for 里或写在 continue 之前,会让“边数”算成 m,判连通永远成立 → 输出一个偏小的错答案。

例题 · 【模板】最小生成树洛谷 P3366普及/提高-去洛谷做这题 ↗

给出一个包含 N 个结点的无向图,求它的最小生成树。

输入格式:第一行两个整数 N、M,表示 N 个结点、M 条无向边。
接下来 M 行,每行三个整数 Xᵢ、Yᵢ、Zᵢ,表示有一条长度为 Zᵢ 的边连接结点 Xᵢ 与 Yᵢ。

输出格式:如果图连通,输出一个整数,表示最小生成树各边长度之和;如果图不连通,输出 orz。

数据范围:1 ≤ N ≤ 5000,1 ≤ M ≤ 2×10⁵,1 ≤ Zᵢ ≤ 10⁴。

输入: 4 5 1 2 2 1 3 2 1 4 3 2 3 4 3 4 3 输出: 7
把“选边”拆成三个问题

问题一:什么样的边集是树?n 个点、n−1 条边、连通。前两条用计数就能保证,第三条只能靠“加边时不成环”来保证——从 n 个孤立点开始,每加一条两端不在同一块的边,连通块数量恰好减 1。加满 n−1 条,块数变成 1。

问题二:怎么保证最小?用贪心:把边按权值升序,能加就加。正确性靠下面的割性质。

割性质(cut property)。把点集任意分成两块,取所有“一端在这块、一端在另一块”的边里最轻的一条 e,则存在一棵最小生成树包含 e。
证明:设 T 是不含 e 的最小生成树。T 中从 e 的一个端点到另一个端点有唯一路径,这条路径必然在某处跨出该块,设为边 f。则 T − f + e 仍是生成树,且 w(e) ≤ w(f),权值不增,故它也是最小生成树并含 e。∎

问题三:Kruskal 选的那条边,凭什么是“跨割最轻”的?设当前考察 eₖ,它的一端 u 在连通块 C、另一端不在。若有一条更轻的边 f 跨 (C, 外部),它一定在 eₖ 之前被处理过:f 若被加入,C 早已跨出去;f 若被跳过,说明 f 两端当时已连通,那另一端的点今天也该在 C 里。两种都矛盾。所以 eₖ 就是跨这个割最轻的边,由割性质它可以进 MST。

复杂度。排序 O(m log m) = 2×10⁵ × 17.6 ≈ 3.5×10⁶ 次比较;加边部分每条边至多两次 find,共 4×10⁵ 次、每次不超过 4 步。总计 10⁶ 量级,实测 20~40 ms。

不连通怎么办。加不满 n−1 条边。此时 ans 是“最小生成森林”的权值,题目要的是 orz,所以最后必须用 cnt 判一次,而不是直接输出 ans。

完整可编译代码(C++17)
#include <bits/stdc++.h>
using namespace std;

const int N = 5005, M = 200005;      // 点数与边数差 40 倍,千万别共用一个上界

struct Edge { int u, v, w; } e[M];
int fa[N], sz[N];

int find(int x) {                    // 路径压缩:找到根,顺便把路上的点挂到根上
    return fa[x] == x ? x : fa[x] = find(fa[x]);
}

void merge(int x, int y) {
    x = find(x); y = find(y);
    if (x == y) return;              // 已同集合:不需要改任何东西
    if (sz[x] < sz[y]) swap(x, y);   // 小树挂大树,保证树高只涨 1
    fa[y] = x;
    sz[x] += sz[y];
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;
    for (int i = 1; i <= m; ++i) cin >> e[i].u >> e[i].v >> e[i].w;

    sort(e + 1, e + m + 1, [](const Edge &a, const Edge &b) { return a.w < b.w; });

    for (int i = 1; i <= n; ++i) { fa[i] = i; sz[i] = 1; }   // 并查集初始化

    long long ans = 0;
    int cnt = 0;                                   // 已加入树的边数
    for (int i = 1; i <= m && cnt < n - 1; ++i) {  // 凑够 n-1 条就停
        if (find(e[i].u) == find(e[i].v)) continue;  // 两端已连通,加了会成环
        merge(e[i].u, e[i].v);
        ans += e[i].w;
        ++cnt;
    }

    if (cnt < n - 1) cout << "orz\n";              // 图不连通
    else cout << ans << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
const int N = 5005, M = 200005;两个上界分开写。n ≤ 5000、m ≤ 2×10⁵,共用一个 5005 会读入越界 → RE。
struct Edge { int u, v, w; } e[M];一条边三个 int。用结构体排序时整条边一起搬;用三个平行数组,sort 就没法直接排。
return fa[x] == x ? x : fa[x] = find(fa[x]);递归出口是 fa[x] == x;赋值号把压缩写进 return 里。少写等号左边那半,就退化成朴素查找。
if (sz[x] < sz[y]) swap(x, y);只比 size、不比值。这一行让最坏树高变成 log₂n,是“不压缩也不至于太慢”的保险。
sort(e + 1, e + m + 1, ...)按权值升序,这是全部贪心内容。cmp 用 < 而不是 <=,否则严格弱序被破坏 → 可能 RE。
for (int i = 1; i <= n; ++i) { fa[i] = i; sz[i] = 1; }初始化必须放在加边之前。写在读入之前也错——n 还没读进来。
long long ans = 0;本题 5000 个点、边权 10⁴,上界 5×10⁷,int 够;但求和一律 long long 是习惯,改题面时不用重想。
for (int i = 1; i <= m && cnt < n - 1; ++i)循环条件带 cnt:n−1 条边已经构成生成树,再看的边只可能更重。
if (find(e[i].u) == find(e[i].v)) continue;判环。这是并查集在本算法里唯一的用途,也是 Kruskal 比 Prim 好写的全部原因。
ans += e[i].w; ++cnt;两条都要在 continue 之后。顺序反了(cnt 先加)会让计数多 1,判连通永远成立。
if (cnt < n - 1) cout << "orz\n";不连通时 ans 是森林的权值,直接输出它 → WA。输出 orz 才是题目要求的。
两个必须知道的理论问题

一、最小生成树唯一吗?不一定。所有边权互不相同时,MST 唯一;有相等边权时可能有多棵。反例:三个点两两相连、三条边权都是 1,删掉任意一条都是一棵 MST,共 3 棵,但它们的权值和都是 2。

所以做题时要分清题目问的是哪一个:问“最小代价”永远有唯一答案(Kruskal 直接输出);问“方案数 / 是否唯一”才需要额外处理,那属于生成树计数,超出本节范围。

为什么边权互不相同时一定唯一?假设有两棵不同的 MST,取它们的对称差,其中必有一条权值最小的边 e 属于 T₁ 不属于 T₂。把 e 加进 T₂ 形成环,环上必有一条不属于 T₁ 的边 f;由最小性 w(f) ≥ w(e),又因边权全不同只能 w(f) > w(e),于是 T₂ − f + e 更轻,与 T₂ 最小矛盾。∎

二、瓶颈路。定义 d(u,v) = 所有 u→v 路径中“最大边权”的最小值——走这条路,最宽的那道坎最窄。定理:对任意一棵 MST,树上 u→v 路径的最大边权就等于 d(u,v)。

这条定理的用途很直接:把“最小化最大值”的连通问题全部交给 MST。比如“用最小的最大边长把所有点连通”,答案就是 Kruskal 最后加进去的那条边的权值,连和都不用求——这正是 P2330 一类的题。

单次询问的做法:先求 MST,再从 u 出发 DFS 到 v,沿途记最大边权,O(n)。多组询问就要用倍增在树上跳(第 53 节),或把 Kruskal 的合并过程建成一棵重构树。本节只需记住结论。

这一节的 6 个坑,按症状记

cmp 写成 return a.w <= b.w;:违反严格弱序,GCC 的 sort 在数据稍大时越界访问 → RE,而且报错位置在 sort 里面,看起来跟你的代码无关,最难查。

漏写 if (find(u) == find(v)) continue;:每条边都被 merge、都计入 cnt,边数超过 n−1、ans 多加权值 → WA,并且程序不崩、不超时,只是答案偏大。

并查集初始化漏写或写错位置:fa 全是 0,所有点被判成同一集合 → 一条边都加不进去,cnt 恒为 0 → WA(输出 orz,或对 n = 1 才偶然正确)。

find 里省掉 fa[x] = ,同时 merge 又不按大小合并:父指针退化成一条长链,每次 find 走 O(n),2×10⁵ 条边 × 5000 步 = 10⁹ 次操作 → TLE。两个优化至少要留一个。

边数组按 n 开:n ≤ 5000 但 m ≤ 2×10⁵,只开 e[5005] 读入就写穿 → RE;开小了还能跑完时更可怕,表现为随机 WA。

不连通时直接输出 ans:题目要 orz,你输出了森林权值 → WA。养成“最后判 cnt == n−1”的习惯。

自己动手复现

关掉页面,把 P3366 的 45 行从零敲一遍,用样例验证答案是 7。

改一改①:把 cmp 的 < 改成 <=,交一遍。小数据可能过,用 n = 5000、m = 2×10⁵ 的随机数据看 RE。

改一改②:删掉 if (find(...) == find(...)) continue;,只用样例跑一遍。正确答案 7,你会得到 11——多出来的是那条成环的边 4。

改一改③:把 merge 的两行换成 fa[find(x)] = find(y);(不按大小),同时把 find 里的 fa[x] = 删掉。用链式数据 (1,2)、(2,3)、…、(4999,5000) 再补 19 万条 (1,i) 边,观察 TLE;把两处改回来,同样的数据 0.02 s 跑完。

数学补给 · 并查集为什么可以当成 O(1) 用

并查集的均摊复杂度既不是 log 也不是常数,而是一个增长极慢的函数:反阿克曼函数 α(n)。

阿克曼函数的前几项:A(1) = 2,A(2) = 4,A(3) = 16,A(4) = 65536,A(5) = 2⁶⁵⁵³⁶ ≈ 10¹⁹⁷²⁹。递推规则是 A(k+1) = 2A(k)——每一层把上一层的值整个塞进指数。

α(n) 就是“取多少个 A 才盖得住 n”:α(n) = min{ k : A(k) ≥ n }。

n 的上界α(n)直观
42课上演示用的规模
163再大一点也只多一步
655364n = 5000 的 Kruskal 落在这一档
2⁶⁵⁵³⁶ ≈ 10¹⁹⁷²⁹5宇宙原子数约 10⁸⁰,离这档上界还差得远

结论:只要 n 小于 10¹⁹⁷²⁹,单次 find / merge 的均摊代价不超过 5 步,可以直接写成 O(1)。注意前提是路径压缩与按大小合并同时开:只开一个,上界是 O(log n);两个都不开,是 O(n)。

那 Kruskal 的时间花在哪?排序。m = 2×10⁵ 时 log₂m ≈ 17.6,比较次数约 m·log₂m = 3.5×10⁶;并查集部分只有约 2m = 4×10⁵ 次操作。排序占了九成以上。

这解释了两条实践结论:① 优化 Kruskal 先看排序,别抠 find 的常数;② 边权范围很小时(0/1,或 ≤ 10⁴ 的整数),把 sort 换成计数排序 / 桶排序,总复杂度压到 O(m + W),2×10⁵ 条边几乎是线性的。

举一反三 · 想清楚这些再往下走
  1. 把所有权值取负再求“最大生成树”,Kruskal 要改几处?最大生成树能回答“删掉哪些边最省”这类问题吗?
  2. 如果图里有自环和重边,Kruskal 会怎样?自环有可能被加进树里吗?重边会让 cnt 出错吗?
  3. 如何判断某一条给定的边 e 是否“属于某一棵最小生成树”?又怎么判断它是否“属于所有最小生成树”?提示:想想 e 与 u、v 在 MST 上路径最大边的关系。
  4. 题目问“最早加入第几条边时整张图连通”,用二分答案 + 并查集怎么做?为什么直接跑一遍 Kruskal、看第 n−1 条边是第几轻的,就更快?
  5. 除了判环,并查集还能维护集合大小、集合内最大边权。把 Kruskal 的每次合并建成一棵新树(合并点作为父亲),为什么就能 O(log n) 回答任意两点的瓶颈路?
  6. n = 10⁶ 但 m 只有 10⁵ 时,Kruskal 和 Prim 哪个更合适?这时内存瓶颈在哪?
自测:能全部回答再进下一节
  • 能默写 find 的一行路径压缩,并说清 fa[x] = 省掉之后会发生什么。
  • 能说清 merge 里“先 find、再判 x == y、最后按大小挂”这三步各防的是哪个 bug。
  • 能说出 cnt 的两个用途(提前退出、判连通),以及漏写判环那一行时答案会怎么错。
  • 能举出“边权相同导致 MST 不唯一”的例子,并说明为什么权值和仍然唯一。
  • 能说清为什么 MST 上 u→v 路径的最大边权就是 u、v 之间的瓶颈值。
  • 给定 n ≤ 5000、m ≤ 2×10⁵,能估出 Kruskal 大约是 10⁶ 量级操作,并判断不会 TLE。
挑战题 · 无线通讯网
洛谷 P1991提高-去洛谷做这题 ↗

国防部计划用无线网络连接若干个边防哨所。每台哨所都配一台无线电收发器,收发器的通信距离为 d:两个哨所之间的距离不超过 d 时才能用无线电直接通信。

现在有 S 台哨所可以额外加装卫星电话,卫星电话之间的距离不受任何限制(装了卫星电话的两台哨所永远可以通信)。

给出 S、P 和 P 个哨所的坐标,求能连通所有哨所的最小的 d。

输入格式:第一行两个整数 S、P(1 ≤ S ≤ 100,S ≤ P ≤ 500)。
接下来 P 行,每行两个整数 xᵢ、yᵢ(0 ≤ xᵢ, yᵢ ≤ 10000),表示第 i 个哨所的坐标。

输出格式:一行一个实数,表示 d 的最小值,保留两位小数。

引导性提示(先只看这里)

卫星电话的作用不是“传得更远”,而是“允许整张网络断成 S 块”——块内必须靠无线电连起来。

先假想 d 已经确定:只保留长度 ≤ d 的边,你能算出图被分成几块吗?这个判定和 Kruskal 有什么关系?

把边按长度从小到大加,每加一条能连通两块的边,块数就减 1。数一数块数减到 S 时你加到第几条边。

想看思路了?点开完整思路

把卫星电话翻译成连通块数。S 台卫星电话各自装在一个连通块里,块与块之间靠卫星通信,块内部必须靠无线电连通。所以最终恰好允许剩下 S 个连通块。分成更少块就浪费了电话,分成更多块则有一块没有电话、无法与外界通信。

问题变成:把 P 个点分成 S 块,使每块内部连通所需的无线电距离尽可能小,并对所有块取最大值。这正是“最小化最大边”的瓶颈问题,交给 MST。

Kruskal 恰好是一个不断减少连通块的过程。初始 P 块,每加入一条不成环的边,块数减 1。块数从 P 减到 S 需要 P − S 次合并,第 P − S 条被加入的边的长度就是答案——因为它正是“让块数刚好降到 S”的那道坎。

为什么不是第 P − S + 1 条?第 P − S 条加入后块数已经是 S,此时可以收手了;再加一条就把块数压到 S − 1,等于浪费了一台卫星电话,而那条边只会更长。

边界:S ≥ P 时每台哨所都能装卫星电话,不需要任何无线电,d = 0。不特判会让“凑够 P − S 条”永远不成立,ans 被更新到最大边。

复杂度。边数 P(P−1)/2 ≤ 124750,排序约 1.2×10⁵ × 17 ≈ 2×10⁶ 次比较,并查集十余万次操作,几十毫秒。

等价写法。也可以二分 d,判定“只用长度 ≤ d 的边能否把图连成 ≤ S 块”,复杂度 O(P² log),能过但多一个 log,而 Kruskal 一次就做完了。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXP = 505, MAXE = MAXP * MAXP;   // 完全图最多 P(P-1)/2 条边

struct Edge { int u, v; double w; } e[MAXE];
int fa[MAXP], sz[MAXP];
double x[MAXP], y[MAXP];

int find(int a) { return fa[a] == a ? a : fa[a] = find(fa[a]); }

bool merge(int a, int b) {                  // 返回 true 表示这次真的合并了两块
    a = find(a); b = find(b);
    if (a == b) return false;               // 已连通,这条边会成环
    if (sz[a] < sz[b]) swap(a, b);
    fa[b] = a; sz[a] += sz[b];
    return true;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int S, P;
    cin >> S >> P;
    for (int i = 1; i <= P; ++i) cin >> x[i] >> y[i];

    if (S >= P) {                           // 每台都装卫星电话,不需要无线电
        cout << fixed << setprecision(2) << 0.0 << "\n";
        return 0;
    }

    int m = 0;
    for (int i = 1; i <= P; ++i)            // 建完全图:任意两点都可能用无线电通信
        for (int j = i + 1; j <= P; ++j) {  // 从 i+1 开始,避免自环与重复边
            double dx = x[i] - x[j], dy = y[i] - y[j];
            e[++m] = {i, j, sqrt(dx * dx + dy * dy)};
        }

    sort(e + 1, e + m + 1, [](const Edge &a, const Edge &b) { return a.w < b.w; });

    for (int i = 1; i <= P; ++i) { fa[i] = i; sz[i] = 1; }

    int need = P - S;                       // 从 P 块压到 S 块,需要 P-S 次合并
    int got = 0;
    double ans = 0;
    for (int i = 1; i <= m; ++i) {
        if (!merge(e[i].u, e[i].v)) continue;   // 成环,跳过
        ans = e[i].w;                       // 记下这次合并用的边长
        if (++got == need) break;           // 凑够 P-S 条就停
    }

    cout << fixed << setprecision(2) << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
const int MAXE = MAXP * MAXP;完全图有 P(P−1)/2 条边,P ≤ 500 时是 124750。开 P² 更宽松,也不用算半天。
bool merge(...)把 merge 改成返回 bool:返回 true 表示“这次真的合并了两块”,Kruskal 的计数就靠它。返回值比在外面再判一次 find 更省一次查找。
S >= P每台哨所一台卫星电话,d 可以取 0。不特判的话 need ≤ 0,++got == need 永远不成立,循环会一路加到最后一条边,ans 变成最大边长 → WA。
for (int j = i + 1; ...)只连 i < j 的点对。写成 j = 1 会多出一倍的重复边和 P 条自环(距离 0,会排在最前面,被 merge 判成同集合跳过,不致命但白排一遍)。
sqrt(dx * dx + dy * dy)欧氏距离。坐标差最大 10⁴,平方和最大 2×10⁸,double 有 53 位有效位,精度绰绰有余。
sort 按 w 升序Kruskal 的核心。用 double 排序时 cmp 仍写 <,别写 <=。
int need = P - S;P 个点最终要剩 S 个连通块,每次成功合并让块数减 1,所以需要 P − S 条边。
ans = e[i].w;每次真合并都覆盖一次,最后一次覆盖的正是第 need 条边的长度。
if (++got == need) break;凑够就停。后面的边更长,不可能让答案更小。
fixed << setprecision(2)固定两位小数。用 cout 而不是 printf,避免和关闭同步的 cin 混用出问题。
这题最容易错的地方
  • 忘了 S ≥ P 的特判:need ≤ 0 时 break 永远不触发,ans 变成最大边长 → WA。
  • 距离用 int 存或让 sqrt 结果被截断成整数:排序顺序错,答案偏差可达 0.5 以上 → WA。
  • 只统计到 P − S − 1 条(下标差一):答案取到了第二大的那条,比正确答案小 → WA。
  • 把 ++got == need 写成 got++ == need:先比较后自增,会多加一条边 → 答案偏大。
  • 输出不带两位小数,或写成 cout << ans:评测要求 212.13 这样的格式 → WA。
  • 二分答案的写法里把“连通块数 ≤ S”错写成“< S”:答案偏大 → WA。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》最小生成树与并查集两节(割性质、瓶颈路)
  • 《算法竞赛入门笔记》图论篇:并查集的两种优化与均摊复杂度
  • 洛谷 P3366、P2330、P1991 题解区的高赞实现
44 拓扑排序与 DAG 上的 DP提高-

44 · 拓扑排序与 DAG 上的 DP

先把一张有依赖的有向图排成一个所有依赖都成立的顺序(Kahn + 入度表),再在这条顺序上做 DP。判环、最长路、方案数、字典序最小——四种问法共用同一副骨架,只改两三行。
提高-预计 65 分钟拓扑排序DAG 最长路
本节出现的新函数 / 新语法(先看懂再用)

有些事必须按顺序做:先和面才能擀皮,先擀皮才能包饺子。把每件事看成一个点,把“A 必须在 B 之前完成”看成一条有向边 A→B,整张图就必须无环——有环意味着“A 在 B 前”和“B 在 A 前”同时成立,这要求本身自相矛盾。

无环的有向图叫 DAG。把 DAG 的所有点排成一列,使每条边都从前指向后,这个排列就叫拓扑序。拓扑排序本身不是目的,它的价值是:把 DAG 变成一条可以从左往右扫的序列——扫到某个点时,它的所有前驱都已经处理完了。于是“必须先算谁”这个依赖问题被彻底消掉,剩下的事就是在一条链上做 DP。

拓扑序的全部意义就一句话:它给不出唯一答案,但它给出一个所有依赖都成立的顺序。有了这个顺序,DAG 上的 DP 就是线性 DP。
问法在拓扑序上做什么复杂度
有没有环数出队点数 cnt 是否等于 nO(n + m)
最早完成时间 / 最长路f[v] = max(f[v], f[u] + w)O(n + m)
路径条数 / 方案数f[v] += f[u],源点置 1O(n + m)
字典序最小的拓扑序把 queue 换成小根堆O((n + m) log n)
DAG 上最短路(可有负权)f[v] = min(f[v], f[u] + w)O(n + m)

三块骨头:入度表 + Kahn 算法(求出拓扑序、顺带判环)、小根堆(把拓扑序压成字典序最小)、拓扑序上的 DP(在出队时把信息往后推)。第三块是这一节真正的重点,前两块是把图变成序列的工具。

新函数拆解 · 入度表与 Kahn 算法
int in[N]; // in[v]:还有多少条边指向 v queue<int> q; for (int i = 1; i <= n; ++i) if (in[i] == 0) q.push(i); // 没有前驱的点先入队 int cnt = 0; while (!q.empty()) { int u = q.front(); q.pop(); ++cnt; // 出队顺序就是一个拓扑序 for (int v : g[u]) if (--in[v] == 0) q.push(v); } if (cnt < n) /* 有环 */;
组成含义 / 格式要求
in[v]v 的入度 = 有多少条边指向 v = 还有多少个前驱没处理。每读一条边 u→v 就 ++in[v]。
in[i] == 0 的点没有任何前驱,第一步就能做。它们是拓扑序的“第一层”,必须全部入队——漏一个就少一串点。
queue存“前驱都已完成、但还没输出”的点。用 queue、stack、priority_queue 都合法,区别只在得到哪一个拓扑序。
--in[v] == 0u 出队意味着 u 已完成,v 少了一个未完成的前驱。减到 0 说明 v 的前驱全齐了,可以入队。
必须先减再判写成 in[v] == 0(忘了自减)→ v 永远不入队,算法只输出第一层;写成 in[v]-- > 0 → 入队时机全部推迟一层。两种都不报错,只是答案错。
cnt出队的点数。cnt == n 说明所有点都被排进了序列;cnt < n 说明剩下的点互相牵制,它们一定落在某个环上。
为什么 cnt < n 就是有环环上每个点的入度里至少有一条来自环内,永远不会减到 0;反之无环时总能不断找到入度为 0 的点(证明见数学补给)。
复杂度每个点入队出队各一次,每条边被看一次:O(n + m)。m = 5×10⁵ 也就几毫秒。
自环u→u 让 in[u] ≥ 1,u 永远进不了队列,最终 cnt < n → 判成有环。这个结论是对的。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

const int N = 10;
vector<int> g[N];                  // g[u]:u 的所有后继
int in[N];

void add(int u, int v) { g[u].push_back(v); ++in[v]; }

int main() {
    add(1, 2); add(1, 3); add(2, 4); add(3, 4); add(4, 5);

    queue<int> q;
    for (int i = 1; i <= 5; ++i)
        if (in[i] == 0) q.push(i);       // 只有 1 号没有前驱

    int cnt = 0;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        cout << u << ' ';                // 出队即输出,天然是一个拓扑序
        ++cnt;
        for (int v : g[u])
            if (--in[v] == 0) q.push(v);
    }
    cout << "\n" << cnt << "\n";         // 1 2 3 4 5 / 5
    return 0;
}
注意

拓扑序一般不唯一:本例 2 和 3 的顺序可以互换,queue 的先来先服务决定了你拿到哪一个。

要保存拓扑序就开一个 ord[],在 ++cnt 的地方写 ord[cnt] = u。后面做 DP 时直接扫 ord 也行,边扫边推也行。

新函数拆解 · 小根堆拓扑序(字典序最小)
priority_queue<int, vector<int>, greater<int>> pq; for (int i = 1; i <= n; ++i) if (in[i] == 0) pq.push(i); while (!pq.empty()) { int u = pq.top(); pq.pop(); // 每次取当前可选的最小点 for (int v : g[u]) if (--in[v] == 0) pq.push(v); }
组成含义 / 格式要求
priority_queue<int, vector<int>, greater<int>>小根堆。三个模板参数依次是元素类型、底层容器、比较器。
greater<int>比较器:greater(a,b) 表示 a 的优先级比 b 低,所以堆顶是最小的那个。默认的 less<int> 堆顶是最大值。
为什么换成它就能字典序最小queue 是先进先出,谁先入队谁先出(跟编号无关);小根堆每次弹“当前所有可选项里编号最小”的那个。
正确性(交换论证)设当前可选取集合是 S,其中最小的是 x。任何一个合法拓扑序的这一位若不是 x,就把它挪到这里并把 x 提前:x 此刻没有未完成的前驱,前移不会违反任何约束,其余点相对顺序不变,字典序只会变小。所以每次取最小不吃亏。
复杂度n 次入堆、n 次出堆,每次 O(log n);每条边看一次。总计 O((n + m) log n),n = 10⁵ 也轻松。
和 sort 的 cmp 方向相反sort 的 cmp 返回 true 表示 a 排在 b 前面;堆的比较器返回 true 表示 a 的优先级更低。写反了就变成大根堆。
换自定义类型要按多个关键字排时,写一个重载了 operator() 的结构体当第三个参数;元素是 int 时无需定义结构体。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

const int N = 10;
vector<int> g[N];
int in[N];

void add(int u, int v) { g[u].push_back(v); ++in[v]; }

int main() {
    add(1, 3); add(1, 2);            // 注意:3 先入队
    add(2, 4); add(3, 4); add(4, 5);

    priority_queue<int, vector<int>, greater<int>> pq;
    for (int i = 1; i <= 5; ++i)
        if (in[i] == 0) pq.push(i);

    while (!pq.empty()) {
        int u = pq.top(); pq.pop();
        cout << u << ' ';
        for (int v : g[u])
            if (--in[v] == 0) pq.push(v);
    }
    cout << "\n";                    // 1 2 3 4 5(用 queue 会得到 1 3 2 4 5)
    return 0;
}
注意

“字典序最小”指整个序列按位比较最小,不是“编号 1 尽量靠前、再让 2 尽量靠前”。这两个目标不是一回事,遇到“让 1 最早、再让 2 最早”的问法,小根堆正着贪心不一定对,要另想办法。

priority_queue 不支持遍历和删除任意元素,需要时改用 multiset 或手写堆。

新函数拆解 · 拓扑序上的 DP(最长路 / 方案数)
// 前提:图是 DAG,in[] 已统计好,q 里是全部入度为 0 的点 while (!q.empty()) { int u = q.front(); q.pop(); for (int v : g[u]) { f[v] = max(f[v], f[u] + w); // 最长路:取更好的 //f[v] = (f[v] + f[u]) % MOD; // 方案数:加法原理 if (--in[v] == 0) q.push(v); } }
组成含义 / 格式要求
为什么 DAG 上能 DP拓扑序保证所有能到达 v 的点都排在 v 之前,算出队到 v 时,它需要的所有 f 值都已经定稿。这就是“无后效性”的具体形态。
和 Dijkstra 的区别DAG 上不需要优先队列:拓扑序本身就是正确的松弛顺序,O(n + m),而且负权也照样正确。
更新时机必须在 --in[v] == 0 判定的前面。那条边无论 v 是否刚好可以入队,都要贡献一次。写在 if 里面就漏了。
最长路取 maxf[v] 表示“到 v 为止的最优值”,每来一个前驱就试着用它更新一次。初值按题意定:−∞、0,或“自己的耗时”。
方案数用加法f[v] = Σ f[p](对所有边 p→v)。源点置 1,其余置 0;每次加法后立刻 % MOD,别攒到最后。
多个源点入度为 0 的点全都是源点。判题时要按题意决定“全部当起点”还是“只认某一个源点,其余置无效值”。
出边时加自己没有边权、只有点权时,写成 f[v] = max(f[v], f[u] + t[v]),把“自己的耗时”放在出边这一步加,源点只需初值 f[i] = t[i]。
反向图递推方向反了(要的是“从 v 出发”的值)就把图反向存一遍再拓扑排序,不要试图倒着扫拓扑序——倒序扫只对“所有前驱值已知”有效。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

const int N = 10;
vector<int> g[N];
int in[N], t[N] = {0, 5, 3, 2, 4, 1}, f[N];

void add(int u, int v) { g[u].push_back(v); ++in[v]; }

int main() {
    add(1, 2); add(1, 3); add(2, 4); add(3, 4); add(4, 5);

    queue<int> q;
    for (int i = 1; i <= 5; ++i) {
        f[i] = t[i];                       // 初值:只做自己
        if (in[i] == 0) q.push(i);
    }

    int ans = 0;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        ans = max(ans, f[u]);
        for (int v : g[u]) {
            f[v] = max(f[v], f[u] + t[v]); // v 要等所有前驱,取最晚的那个
            if (--in[v] == 0) q.push(v);
        }
    }
    cout << ans << "\n";                   // 13:串行是 15,并行省了 2
    return 0;
}
注意

f 的初值决定一切:最长路用 −∞ 或“自己的耗时”,方案数用 0/1。写错初值不会崩,只会安静地输出错数。

把更新写进 if (--in[v] == 0) 的大括号里是本节最高频的错法:只有“最后一个前驱”的那次更新生效,f[v] 变成“最后一个前驱的答案”而不是“所有前驱的最大值”。

例题 · 杂务洛谷 P1113普及/提高-去洛谷做这题 ↗

有 n 项杂务要完成,每项杂务需要一定的时间。杂务之间有先后关系:某些杂务必须在另一些杂务完成之后才能开始。

不违反先后关系的多个杂务可以同时进行,人手不限。求完成全部杂务所需的最短时间。

输入格式:第一行一个整数 n(n ≤ 10000)。
接下来 n 行,每行描述一项杂务:第一个数是杂务编号 i,第二个数是完成它需要的时间 lenᵢ,后面跟着若干整数,是这项杂务的全部前置杂务编号,以 0 结束。

输出格式:一行一个整数,表示完成所有杂务所需的最短时间。

数据保证先后关系不构成环,且每个杂务的时间是不超过 10⁴ 的正整数。

把“最短时间”翻译成“最晚的前驱”

先看一遍题目在问什么。问的是“全部完成的最短时间”,而人手不限,所以限制时间的只有依赖关系:一项杂务不能早于它所有前置杂务的最晚完成时间开始。

状态。令 f[u] 表示杂务 u 的最早完成时刻(从 0 时刻开始计时)。它只可能由两件事决定:自己的耗时 len[u],以及所有前置杂务里完成得最晚的那一个。

f[u] = len[u] + max{ f[p] : p 是 u 的前置杂务 };没有前置时 f[u] = len[u]。是 max 不是 sum:前置可以并行,u 只需要等最慢的那个,其它人早就做完了。

顺序。f[u] 依赖所有前驱,所以必须按拓扑序算。Kahn 的出队顺序恰好满足“前驱先出”:u 能入队,说明它的入度已经被减到 0,也就是所有前驱都已经出队、f 值定稿。这就是“无后效性”落到代码上的样子。

答案。是所有 f[u] 的最大值,不是 f[n]。可以并行的图里,最后完成的不一定是编号最大的那项杂务。样例分叉成两支、两支又汇合,就是专门用来打这个坑的。

转移怎么写最省事。把初值设成 f[i] = len[i](“只做自己”),然后在处理边 u→v 时写 f[v] = max(f[v], f[u] + len[v])。因为 len[v] 是常数,它可以从 max 里提出来:max(f[u] + len[v]) = len[v] + max(f[u]),正好是我们要的式子。

复杂度。每个点出队一次、每条边看一次,O(n + m)。n ≤ 10⁴ 时不到 0.1 ms,瓶颈在读入。

完整可编译代码(C++17)
#include <bits/stdc++.h>
using namespace std;

const int N = 10005;

vector<int> g[N];          // g[p] 存 p 的所有后继:p 做完才能做它们
int in[N];                 // 入度 = 还有多少个前置杂务没完成
int len[N];                // 每项杂务自己的耗时
int f[N];                  // f[u]:u 的最早完成时刻

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;

    for (int i = 1; i <= n; ++i) {
        int id, t, p;
        cin >> id >> t;                  // 用 id 而不是 i:编号以行首为准更稳
        len[id] = t;
        while (cin >> p, p) {            // 读到 0 为止
            g[p].push_back(id);          // p 是 id 的前置 → 边 p → id
            ++in[id];                    // id 多了一个前置
        }
    }

    queue<int> q;
    for (int i = 1; i <= n; ++i) {
        f[i] = len[i];                   // 初值:没有前置时,做完自己就完事
        if (in[i] == 0) q.push(i);       // 没有前置的杂务立刻可以开工
    }

    int ans = 0;
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        ans = max(ans, f[u]);            // 每个点都可能是“最后完成的那个”
        for (int v : g[u]) {
            f[v] = max(f[v], f[u] + len[v]);   // v 要等所有前置,取最晚的
            if (--in[v] == 0) q.push(v);       // 前置全齐,v 可以开工了
        }
    }

    cout << ans << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
vector<int> g[N]; int in[N];邻接表存后继,入度数组单独一个。存后继而不是存前驱,是因为 DP 的方向是“从已完成推到未完成”。
cin >> id >> t;读编号和时间。用编号当下标而不是循环变量 i:题目每行第一个数就是编号,直接用它,行号与编号是否一致就不用关心了。
len[id] = t;时间跟着编号存。这样后面出边时可以直接用 len[v],不必再翻回来找。
while (cin >> p, p)逗号表达式:先执行 cin >> p,再拿 p 当循环条件。读到 0 就停,读入失败也停。
g[p].push_back(id); ++in[id];边的方向由“谁必须先完成”决定:前置 p 在后继 id 之前。存反了整个 DP 会倒着算,答案错。
f[i] = len[i];初值的含义是“假设没有任何前置”。有了它,转移里就不用再判“有没有前置”。
if (in[i] == 0) q.push(i);入度为 0 = 没有前置 = 第 0 时刻就能开工。它们全部入队,不是只入一个。
ans = max(ans, f[u]);答案在这里取最大,而不是取 f[n]。这是本题最容易错的一行。
f[v] = max(f[v], f[u] + len[v]);v 必须等完所有前驱才能开始,所以对每个前驱 u 算一次“等完 u 才开工”的完成时刻,取最大。
if (--in[v] == 0) q.push(v);这一行和上一行是并列关系,不是包含关系:无论 v 是否入队,上面那句都要执行。
while (!q.empty())队列空了但还有点没出队,说明有环。本题保证无环,所以省掉了 cnt 判环;通用模板要留。
同一套骨架,换一个问法只改两三行

判环。加一个 cnt,出队时 ++,结束时 if (cnt < n) 就是有环。三行代码,不用额外的数组。

最长路。把 max 留着,把“自己的耗时”换成边权即可。若边权存在结构体里,就写 f[v] = max(f[v], f[u] + e.w)。

方案数。f[v] = (f[v] + f[u]) % MOD,源点 f = 1。注意重边要小心:两条 u→v 的重边代表两条不同的路径,会被加两次,这是对的;但如果题目把重边当成一条,就得先去重。

字典序最小。queue 换小根堆。反过来要字典序最大就换回 priority_queue<int>(默认大根堆)。

DAG 上最短路。把 max 换成 min,初值换成 INF。负权边也照样对,这是它比 Dijkstra 强的地方——Dijkstra 的正确性依赖“边权非负”,拓扑序不依赖。

判环的第二种写法:DFS 三色。白 = 没访问,灰 = 在递归栈里,黑 = 已结束。搜到一个灰点就说明回到了当前路径上,即找到环。优点是能顺手把环上的点输出;缺点是要递归,n = 10⁵ 的链可能爆栈。Kahn 不递归、还免费送你拓扑序,判环首选它。

这一节的 6 个坑,按症状记

把 if (--in[v] == 0) 写成 if (in[v] == 0):入度从不减少,只有第一层能入队 → cnt 极小、答案只有第一层 → WA,而且输出看着“很合理”,最难查。

初始入队写成 for (i = 0; i < n; ++i):编号是 1..n 的话,这样只扫了 0..n−1,漏掉 n 号点、又凭空多算一个不存在的 0 号点 → WA。下标从 1 开始就必须写 i = 1; i <= n。

边存反了(该 g[v].push_back(u) 却写成 g[u].push_back(v)):拓扑序整个倒过来 → WA。症状是“样例能过、稍大的数据全挂”,因为对称的图恰好对。

答案取 f[n] 而不是所有 f 的最大值:编号最大的杂务未必最后完成 → WA。

把 DP 更新写进 if (--in[v] == 0) 的括号里:只有最后一个前驱的那次更新生效 → WA,答案恒定偏小,且程序完全正常。

用邻接矩阵存边:n = 5000 时 25×10⁶ 个 int = 100 MB,加上别的开销直接 MLE;遍历一个点还要扫 n 格,m 小的时候反而更慢 → TLE。邻接表是唯一选择。

自己动手复现

关掉页面,把 P1113 的 40 行从零敲一遍。用这组数据验证:1 5 0 / 2 3 1 0 / 3 2 1 0 / 4 4 2 3 0 / 5 1 4 0,答案应该是 13。

改一改①:换一组数据 1 5 0 / 2 3 1 0 / 3 9 1 0 / 4 2 2 0,正确答案是 14。把 ans = max(ans, f[u]) 删掉,循环后改成 ans = f[n],重跑得到 10——f[n] 只是“编号最大的那项杂务”,不是全局最晚。

改一改②:回到第一组数据,把 if (--in[v] == 0) 改成 if (in[v] == 0)。程序不崩,但只有 1 号点出队,ans 变成 5。这就是“忘了自减”的症状。

改一改③:把 queue 换成 stack(front 改 top),出队顺序会变(合并点被提前处理),但 ans 仍然是 13——只要顺序合法,最长路的值与拓扑序的选取无关。

改一改④:把第一行改成 1 5 4 0(让 1 号依赖 4 号),图里就有环了。补上 cnt 判环,确认程序报“有环”,而不是输出一个偏小的数或死循环。

数学补给 · 无环、拓扑序、最长路:三件同一件事

引理一:有向图有拓扑序 ⟺ 无环。

(⇒)若有环 x₁→x₂→…→xₖ→x₁,拓扑序要求 x₁ 在 x₂ 前、x₂ 在 x₃ 前……、xₖ 在 x₁ 前,串起来得到“x₁ 在 x₁ 之前”,矛盾。

(⇐)反设图无环但每个点入度都 ≥ 1。任取一点,沿入边往回走:每一步都有入边,走 n 步必然重复经过某个点,重复的那一段就是环,矛盾。所以无环图必存在入度为 0 的点。删掉它(连同它的出边),剩下的图仍无环,归纳即得拓扑序。这段证明就是 Kahn 算法本身——它每一步都在执行“找出入度为 0 的点”。

引理二:拓扑序一般不唯一。个数可以差得极远:

图(n 个点)拓扑序个数原因
链 1→2→…→n1每对相邻点都被约束定死
无边(n 个孤立点)n!任意排列都合法;n = 10 时是 3 628 800
1→3、2→321 和 2 谁先都行
完全 DAG(i→j 对所有 i<j)1任意两点都有约束,只剩自然序

两个可以直接用的推论:① n ≥ 21 时 n! 已超出 long long(20! ≈ 2.43×10¹⁸,21! ≈ 5.11×10¹⁹),所以“数拓扑序个数”绝不能靠枚举,要用状压 DP(第 34 节),n ≤ 20 时才做得了。② 判断“能否确定唯一顺序”,就是看每一轮入度为 0 的点是否恰好只有一个。

引理三:DAG 上的最长路 = 无限并行下的最短完工时间。这条把抽象的最长路和现实的时间挂上了钩。

取本节的例子:5 项杂务,耗时分别是 5、3、2、4、1,边为 1→2、1→3、2→4、3→4、4→5。f₁ = 5,f₂ = 8,f₃ = 7,f₄ = max(8, 7) + 4 = 12,f₅ = 13。

串行做完的总耗时是 5+3+2+4+1 = 15;如果人多到可以无限并行,总耗时是最长路 13——省下来的 2 正是 3 号杂务与 2 号杂务并行掉的那一段。最长路就是这条依赖链上的“关键路径”,它一秒都省不掉。

反过来也要警惕:一旦限制“同一时刻最多 k 个人干活”,问题立刻从 O(n+m) 变成 NP-hard 的调度问题,赛题不会这么出。看到“k 台机器 / k 个人 / k 条流水线”这类字眼,先想清楚它是不是改变了问题类型。

举一反三 · 想清楚这些再往下走
  1. 把 queue 换成 stack,得到的序列还是拓扑序吗?为什么?它有没有可能是字典序最小的那个?
  2. 题目要求“让 1 号尽量早完成,然后在满足这一点的前提下让 2 号尽量早完成……”——这和“序列字典序最小”是同一件事吗?造一个 3 个点的反例。
  3. DAG 上求最短路,为什么不建反图也能做?如果是有环图且没有负权,为什么必须用 Dijkstra?Dijkstra 到底用到了“边权非负”的哪一步?
  4. 如果要求“最长路的条数”(不是路径条数,而是所有最长路里有几条),状态要加什么?两条最长路在中间合并后再分开,会被重复计数吗?
  5. 一个 DAG 再加一条边 u→v,怎么 O(n+m) 判断是否出现环?提示:先想“u 能不能从 v 走到”。
  6. 如果图的规模是 n = 10⁵、m = 10⁶,Kahn 的队列里最多同时存多少个点?什么形状的图会让它最大?
自测:能全部回答再进下一节
  • 能默写 Kahn 的完整框架,并说清 cnt < n 为什么等价于有环。
  • 能说清 --in[v] == 0 里“先减再判”的顺序为什么不能反,反了会错成什么样。
  • 能解释 P1113 的转移为什么是 max 而不是 sum,答案为什么不是 f[n]。
  • 能把最长路模板改成方案数模板,并说出初值与取模位置分别要改哪里。
  • 能说清小根堆拓扑序为什么给出字典序最小,以及它和“编号小的点尽量靠前”的区别。
  • 能说清 DAG 上最短路为什么不需要堆、为什么负权也成立。
挑战题 · 最大食物链计数
洛谷 P4017普及/提高-去洛谷做这题 ↗

生态系统中有 n 种生物,它们构成一张食物网:若生物 a 被生物 b 吃,则有一条从 a 指向 b 的边。

一条“最大食物链”指从生产者(入度为 0 的生物)出发,到最高级消费者(出度为 0 的生物)结束的一条有向路径。

求这张食物网中最大食物链的条数,答案对 80112002 取模。

输入格式:第一行两个整数 n、m(1 ≤ n ≤ 5000,1 ≤ m ≤ 500000)。
接下来 m 行,每行两个整数 a、b,表示 a 被 b 吃(即存在边 a→b)。

输出格式:一行一个整数,表示最大食物链的条数 mod 80112002。

引导性提示(先只看这里)

链条数 = 从所有入度为 0 的点出发、到所有出度为 0 的点结束的路径总数。起点和终点都可能有很多个。

对每个点记录“有多少条路径能到达它”。一条边 u→v 会把 u 的数目整个加到 v 上——这是加法原理。

拓扑序保证你算到 v 时,所有能到 v 的点都已经算完了;最后把所有出度为 0 的点的数目加起来。

想看思路了?点开完整思路

状态。f[u] = 从任意生产者出发、走到 u 的路径条数。

初值。每个入度为 0 的点(生产者)自己算一条“长度为 0 的前缀”,f = 1。注意是所有生产者,不是某一个。

转移。由加法原理,到达 v 的路径 = 所有前驱 u 的路径各自拼上边 u→v。所以对每条边 u→v 做一次 f[v] += f[u]。拓扑序保证算 f[v] 时每个 f[u] 都已经定稿。

答案。最大食物链必须以最高级消费者(出度为 0)结束,所以把所有出度为 0 的点的 f 加起来,边加边取模。

为什么不能直接 DFS 数路径。路径数可能是指数级,DFS 会重复走同一批子路径;记忆化能救,但拓扑 DP 更直接:每个点的 f 只算一次,O(n+m)。

规模。n ≤ 5000、m ≤ 5×10⁵,邻接表存 5×10⁵ 条边约 2 MB;拓扑排序 + 一次 DP,总操作不到 10⁶。

取模。MOD = 80112002,不是常见的 10⁹+7。每次加法后立刻取模,别攒到最后,否则 int 溢出。

边界。只有一个点、没有边时(若数据允许),它是生产者也是消费者,按 out == 0 统计会把它算成一条链;本题数据不出现这种孤立生物,换个题面时要留意。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int N = 5005, MOD = 80112002;

vector<int> g[N];       // 邻接表:m 最大 5×10^5,绝不能开矩阵
int in[N], out[N];      // 入度用于拓扑排序,出度用于找终点
int f[N];               // f[u]:到达 u 的路径条数

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;

    for (int i = 1; i <= m; ++i) {
        int a, b;
        cin >> a >> b;                 // a 被 b 吃
        g[a].push_back(b);             // 边 a → b
        ++in[b];                       // b 多了一个前驱
        ++out[a];                      // a 有一个后继,不再是最高级消费者
    }

    queue<int> q;
    for (int i = 1; i <= n; ++i)
        if (in[i] == 0) {              // 所有生产者都是起点
            f[i] = 1;
            q.push(i);
        }

    while (!q.empty()) {
        int u = q.front();
        q.pop();
        for (int v : g[u]) {
            f[v] = (f[v] + f[u]) % MOD;    // 加法原理,边加边取模
            if (--in[v] == 0) q.push(v);
        }
    }

    int ans = 0;
    for (int i = 1; i <= n; ++i)
        if (out[i] == 0)                   // 出度为 0:最高级消费者
            ans = (ans + f[i]) % MOD;

    cout << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
++in[b]; ++out[a];读一条边同时统计入度和出度。入度给拓扑排序用,出度给最后找终点用,两个都不能少。
if (in[i] == 0) { f[i] = 1; q.push(i); }所有生产者都是起点,各自算一条“空前缀”。只把某一个点当起点是本题最常见的错。
f[v] = (f[v] + f[u]) % MOD;到达 v 的路径 = 每个前驱 u 的路径拼上 u→v,直接相加。写成 max 就变成求最长路,全错。
if (--in[v] == 0) q.push(v);前驱全部处理完才能轮到 v,这样 f[v] 在入队时已经收集完所有前驱的贡献。
if (out[i] == 0) ans += f[i]终点可能有很多个,必须全部累加。只加“编号最大的那个点”或只加 f[n] 都会漏。
const int MOD = 80112002;题目指定的模数,不是 1e9+7。抄错模数 = 全篇 WA。
vector<int> g[N];m ≤ 5×10⁵,邻接表存 2 MB 左右;换成 5000×5000 的 int 矩阵就是 100 MB,直接 MLE。
这题最容易错的地方
  • 只把 1 号点当起点:生产者有很多个,漏掉的起点的所有链条全部丢 → WA。
  • 答案只取 f[n]:最高级消费者有很多个,而且编号最大的那一个 f 可能为 0 → WA。
  • 模数写成 1000000007 或 998244353:与题目要求不符 → WA。
  • 把所有加法攒到最后才取模:f 会超过 int 范围变成负数 → WA(而且样例很小,照样能过)。
  • 用邻接矩阵存 n = 5000、m = 5×10⁵:内存 100 MB 起,再加重边开销 → MLE。
  • 把边存成 b→a:拓扑序方向反了,f 全为 0,答案恒为 0 → WA。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》拓扑排序与 DAG 上的 DP(关键路径、方案计数)
  • 《算法竞赛黄金法则》第 4 部分:把偏序关系变成线性顺序
  • 洛谷 P1113、P4017、P1347 题解区的高赞实现
45 回顾八:图论提高-

45 · 回顾八:图论

把第 41~44 节压成一套动作:邻接表建图、DFS/BFS 遍历、堆优化 Dijkstra、Kruskal 并查集、拓扑排序上的 DP。先用 P5318 从空文件写一遍,再用 5 道洛谷黄题限时 95 分钟训练,每题带完整思路与参考代码。
回顾训练提高-建议 95 分钟回顾5 题

第 41~44 节只教了四件事:怎么存一张图(邻接表)、怎么在图上走(DFS / BFS 与连通块)、怎么求最短路与最小生成树(Dijkstra / Floyd / Kruskal)、怎么把有向无环图排成一条线(拓扑排序 + DAG 上的 DP)。这一节不引入新算法:先用 P5318 把「建图 → 排序 → DFS → BFS」从空文件写到 AC,再把五张模板各拆一遍,最后 5 道洛谷黄题限时 95 分钟。

图论题的代码很少超过 70 行。错的地方永远只有四件事:边的方向、判重的时机、下标的起点、答案的类型。
节内容看到这些字就上它复杂度
41图的存储与遍历「连通块」「能不能到达」「遍历序」「哪些点互相可达」O(n+m)
42最短路:Dijkstra 与 Floyd「最短距离」「最少花费」;边权非负 → 堆优化 Dijkstra,n ≤ 400 要全源 → FloydO(m log n) / O(n³)
43最小生成树与并查集「连通所有点花费最少」「最早何时全部连通」「两点是否连通」O(m log m)
44拓扑排序与 DAG 上的 DP「先修」「不能有环」「最长链」「方案数」O(n+m)

P5318 是这四节的最小交集:一张有向图、两种遍历、一个排序要求。它不难,但把「存图 + 判重 + 输出格式」三件事一次考完,是回顾章最合适的起手题。

例题 · 查找文献洛谷 P5318普及/提高-去洛谷做这题 ↗

洛谷博客里一共有 n 篇文章(1 ≤ n ≤ 10⁵),编号 1 到 n;另有 m 条参考文献引用关系(1 ≤ m ≤ 10⁶)。若文章 X 的参考文献里有 Y,就记一条有向边 X → Y。

小 K 已经打开了 1 号文章。看完一篇就会去看它的全部参考文献(看过的不会再看)。要求分别用 DFS 和 BFS 把这个过程走一遍,输出访问到的文章编号。

同一篇文章的多篇参考文献,按编号从小到大依次看。

输入格式:第一行两个整数 n、m;接下来 m 行,每行两个整数 X、Y,表示 X 引用 Y。

输出格式:两行。第一行为 DFS 序,第二行为 BFS 序,编号之间用一个空格隔开。只输出从 1 号出发能到达的文章。

输入: 8 9 1 2 1 3 1 4 2 5 2 6 3 7 4 7 4 8 7 8 输出: 1 2 5 6 3 7 8 4 1 2 3 4 5 6 7 8

把题目翻译成图:文章是点,引用是有向边,P5318 问的就是从 1 号点出发的 DFS 序与 BFS 序。两处细节决定了这题能不能一次过:邻接表必须先排序(否则顺序取决于输入),以及 DFS 不能用朴素递归(n 到 10⁵,最坏深度也是 10⁵)。

下面五张卡不是新知识,是把本节要用的骨架各默写一遍。赛场上默写不出来的模板,等于没学过:先能一次写对,再谈限时。

新函数拆解 · 邻接表(vector 数组)
vector<int> g[MAXN]; g[u].push_back(v); for (int v : g[u]) { ... }
组成含义 / 格式要求
vector<int> g[MAXN]开 MAXN 个 vector,g[u] 里放 u 的全部出点。空间 O(n+m);1e5 个空 vector 约 2.4 MB,m = 10⁶ 条边再占 4 MB。
g[u].push_back(v)有向边只加一次;无向边加两次(g[u]→v 与 g[v]→u)。漏加一次的症状:连通块数变多、无向图最短路算不出来。
带权边改成 vector<pair<int,int>> g[MAXN],存 (终点, 权值)。邻接矩阵在 n = 10⁵ 时要 4×10¹⁰ 字节,开不出来。
(int)g[u].size()size() 返回无符号数。for (size_t i = size - 1; i >= 0; --i) 在空表上会变成 2⁶⁴−1 而死循环;本行先强转 int 再减一,空表时就是 −1,循环直接跳过。
for (int v : g[u])遍历出边,O(deg(u))。整张图扫一遍是 O(n+m),不是 O(n²)。
要不要排序题目要求「小编号优先」时必须对每个 g[u] 排序——邻接表里的顺序就是读入顺序。
最小可运行示例
示例
vector<int> g[100005];

g[1].push_back(2);
g[1].push_back(3);            // 无向图还要写 g[2].push_back(1) 之类
g[2].push_back(3);

for (int v : g[1]) cout << v << " ";    // 2 3,按入表顺序,不自动有序
sort(g[1].begin(), g[1].end());         // 题目要求小编号优先时必须自己排
注意

无向图只加一条边:从 u 能到 v,从 v 回不到 u,连通块数直接翻倍。

1e5 个点用 int a[1005][1005] 这种矩阵存:n 一大就爆内存,稀疏图一律邻接表。

新函数拆解 · DFS / BFS 遍历骨架
void dfs(int u) { vis[u] = 1; for (int v : g[u]) if (!vis[v]) dfs(v); }
组成含义 / 格式要求
vis 数组判重的唯一依据。DFS 进点即标记,BFS 入队即标记;晚一步同一个点就会被处理两次。
递归 DFS代码最短。但深度等于路径长度:10⁵ 的链每帧按 48 字节算是 4.8 MB,离 8 MB 的栈上限只差一点,边界数据容易 RE。
显式栈st.push_back(u) 弹出访问、st.pop_back() 模拟递归。压孩子必须倒序,弹出顺序才和递归版一致。
queue BFS层序扩展。边权全为 1 时,第一次访问就是最少步数。
两遍之间memset(vis, 0, sizeof vis)。忘了清空,第二遍所有点都被判成「已访问」,第二行只剩起点一个 1。
复杂度邻接表 O(n+m);同样的遍历换成邻接矩阵就是 O(n²) = 10¹⁰。
最小可运行示例
示例
bool vis[MAXN];

void dfs(int u) {                     // 递归版:深度大时可能爆栈
    vis[u] = 1;
    cout << u << " ";
    for (int v : g[u]) if (!vis[v]) dfs(v);
}

void bfs(int s) {                     // 队列版:层序
    queue<int> q;
    q.push(s); vis[s] = 1;            // 入队即标记
    while (!q.empty()) {
        int u = q.front(); q.pop();
        cout << u << " ";
        for (int v : g[u]) if (!vis[v]) { vis[v] = 1; q.push(v); }
    }
}
注意

BFS 改成「出队时标记」:同一个点被多个父亲反复入队,队列长度到 O(m),大数据 TLE。

显式栈正序压孩子:遍历顺序与递归不一致,题目要求固定顺序时直接 WA。

新函数拆解 · 堆优化 Dijkstra
priority_queue<pair<long long,int>, vector<pair<long long,int>>, greater<pair<long long,int>>> pq;
组成含义 / 格式要求
pair 的第一维必须是距离。pair 按字典序比较,第一维是距离时堆顶才是「距离最小的点」。两位写反、取用时又把 first 当距离,读到的其实是编号,输出一列毫无意义的数。
greater<...>小根堆,堆顶是当前距离最小的点。写成默认的大根堆:只要懒删除那行还在,答案仍然对(顺序错了会不断修正),但同一个点被反复松弛,常数大几倍;若又用 used 数组「弹出即定案」,就会把错的距离冻死 → WA。
dist 初始化fill(dist, dist + n + 1, INF);INF 取 LLONG_MAX / 2,保证 d + w 不会溢出成负数。
懒删除if (d > dist[u]) continue; 堆里同一个点可能有多份历史版本,取出的 d 比记录值大就丢弃。
松弛条件if (dist[v] > d + w)。相等时不更新,能少一次入堆。
适用前提边权全部非负。有负权必须换 Bellman-Ford / SPFA,Dijkstra 会给错答案。
最小可运行示例
示例
const long long INF = LLONG_MAX / 2;
long long dist[MAXN];
fill(dist, dist + n + 1, INF);
dist[s] = 0;

priority_queue<pair<long long,int>, vector<pair<long long,int>>,
               greater<pair<long long,int>>> pq;
pq.push({0, s});
while (!pq.empty()) {
    auto [d, u] = pq.top(); pq.pop();
    if (d > dist[u]) continue;                 // 过期版本,丢掉
    for (auto [v, w] : g[u])
        if (dist[v] > d + w) { dist[v] = d + w; pq.push({dist[v], v}); }
}
注意

n ≤ 400 又要「任意两点间距离」时别写这个:Floyd 三重循环 O(n³) = 6.4×10⁷,三行就够。

dist 用 int 且 INF 取 0x7fffffff:d + w 溢出成负数,把不可达覆盖成负距离。

新函数拆解 · 并查集(路径压缩 + 合并)
int find(int x) { while (fa[x] != x) { fa[x] = fa[fa[x]]; x = fa[x]; } return x; } fa[find(a)] = find(b);
组成含义 / 格式要求
fa[i] = i初始化,每个点自成一个集合。漏了这一步:fa 全 0,所有 find 都返回 0,连通图也会被判成「全部连通」,最后输出 orz。
find 路径压缩把路径上的点直接挂到根,之后查询接近 O(1)。写成迭代是为了不受递归深度影响。
fa[find(a)] = find(b)合并:把 a 的根挂到 b 的根下。只把两个根连起来,写成 fa[a] = b 会把 a 底下的点全从原集合里拆走。
判断连通find(a) == find(b)。
计数Kruskal 里用 cnt 数已选边数,选满 n−1 就结束;最后 cnt < n−1 即图不连通。
复杂度带路径压缩后单次接近 O(1)(反阿克曼函数),m = 2×10⁵ 时并查集部分耗时可忽略。
最小可运行示例
示例
int fa[MAXN];
for (int i = 1; i <= n; ++i) fa[i] = i;      // 必须初始化

int a = find(u), b = find(v);
if (a != b) { fa[a] = b; ++cnt; }            // 不在同一集合才合并
注意

fa[u] = v 不取根:u 下面挂着的点全部脱离原集合,连通性判断随机出错,Kruskal 会加入成环的边,答案偏小。

只做路径压缩、不做按大小合并也能过;但两者都加上只是两行代码的事。

新函数拆解 · 拓扑排序(入度 + 队列)
for (每个入度为 0 的点 u) q.push(u); while (!q.empty()) { int u = q.front(); q.pop(); for (int v : g[u]) if (--indeg[v] == 0) q.push(v); }
组成含义 / 格式要求
indeg 数组读边时 ++indeg[v](边 u → v)。写成出度就完全不是拓扑排序。
初始入队入度为 0 的点没有前驱,可以直接处理,全部先入队。
--indeg[v] == 0每处理一条边删掉一条入度,降到 0 才入队——保证 v 的所有前驱都处理完了。
判环数出队次数 cnt,cnt == n 才无环;cnt < n 说明剩下的点互相成环,拓扑序不存在。
DAG 上的 DP按出队顺序更新 dp,就是 DAG 上正确的递推顺序:处理 u 时它的所有前驱都已算完。
复杂度O(n+m)。每个点入队一次、每条边被删一次。
最小可运行示例
示例
queue<int> q;
int cnt = 0;
for (int i = 1; i <= n; ++i) if (indeg[i] == 0) q.push(i);
while (!q.empty()) {
    int u = q.front(); q.pop();
    ++cnt;
    for (int v : g[u])
        if (--indeg[v] == 0) q.push(v);       // 前驱全部处理完才入队
}
if (cnt < n) puts("有环,拓扑序不存在");
注意

写成 if (indeg[v] - 1 == 0):入度永远不减,队列第一次就空了,只输出一个点。

cnt 在入队时加:重边会让同一个点被数多次,判环失效。

例题怎么想:三件事定死,代码就写完了

① 存图。m 到 10⁶,邻接矩阵要 n² = 10¹⁰ 个格子,开不出来。用 vector<int> g[MAXN]:每条边只存一次,空间 O(n+m)。

② 定顺序。题目要求「参考文献按编号从小到大看」,而邻接表里的顺序就是读入顺序。所以遍历之前必须 for (i = 1..n) sort(g[i])。不排序时,DFS 序完全取决于输入顺序——样例恰好是升序给的,本地根本看不出错。

③ 判重。vis 数组。DFS 进点即标记,BFS 入队即标记。标记晚一步,同一个点会被处理两次,输出里就出现重复编号。

DFS 还要提前决定一件事:n 到 10⁵,递归深度最坏也是 10⁵,逼近 8 MB 的栈上限。所以这里用显式栈:弹出时访问,然后把出点倒序压栈——倒序是为了让编号最小的出点留在栈顶,弹出顺序才与递归版一致。这一行写反,遍历序就变了。

BFS 用 queue,入队即标记、出队即输出,得到的自然是「先近后远,同层按编号升序」。两遍遍历之间 memset(vis, 0, sizeof vis)。

复杂度:排序 Σ deg(u)·log deg(u) ≤ O(m log m),遍历 O(n+m),读入 O(m)。m = 10⁶ 这一级读入本身就是瓶颈:用 scanf 最稳,用 cin 必须关同步。

完整代码 · 邻接表 + 显式栈 DFS + BFS
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int n, m;
vector<int> g[MAXN];          // g[u]:u 引用的所有文献
bool vis[MAXN];               // 判重:每个点最多真正访问一次

// 按顺序输出,编号之间一个空格,行末只有换行
void printOrder(const vector<int> &ord) {
    for (int i = 0; i < (int)ord.size(); ++i) {
        if (i) putchar(' ');
        printf("%d", ord[i]);
    }
    putchar('\n');
}

int main() {
    scanf("%d%d", &n, &m);
    for (int i = 0; i < m; ++i) {
        int x, y;
        scanf("%d%d", &x, &y);
        g[x].push_back(y);              // 有向边 X -> Y,只加一条
    }
    // 题目要求小编号优先;邻接表里是读入顺序,必须自己排
    for (int i = 1; i <= n; ++i) sort(g[i].begin(), g[i].end());

    // ---------------- DFS:显式栈,避免 1e5 深度爆栈 ----------------
    vector<int> ord, st;
    st.push_back(1);
    while (!st.empty()) {
        int u = st.back(); st.pop_back();
        if (vis[u]) continue;           // 可能被多个点重复压栈,只处理第一次
        vis[u] = 1;
        ord.push_back(u);
        // 倒序压栈:编号最小的出点最后压,弹出时最先访问,与递归 DFS 一致
        for (int i = (int)g[u].size() - 1; i >= 0; --i) st.push_back(g[u][i]);
    }
    printOrder(ord);

    // ---------------- BFS:队列,入队即标记 ----------------
    memset(vis, 0, sizeof vis);         // 两遍遍历之间必须清空
    ord.clear();
    queue<int> q;
    q.push(1); vis[1] = 1;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        ord.push_back(u);
        for (int v : g[u]) {
            if (vis[v]) continue;
            vis[v] = 1;                 // 入队立刻标记,否则同一点会重复进队
            q.push(v);
        }
    }
    printOrder(ord);
    return 0;
}
逐行拆解
代码片段含义与理由
vector<int> g[MAXN];邻接表。m ≤ 10⁶ 条边、n ≤ 10⁵ 个点,空间 10⁶ 个 int 加 10⁵ 个 vector 头,总共 6 MB 以内。
for (int i = 1; i <= n; ++i) sort(g[i].begin(), g[i].end());题目要求「小编号优先」。样例的边恰好是按升序给的,所以删掉这一行本地看不出变化;把输入第 2、3 行对调后再删,DFS 序立刻变成 1 3 7 8 2 5 6 4 —— 顺序完全由输入决定。
if (vis[u]) continue;显式栈里同一个点可能被压进栈多次(每个前驱各压一次)。跳过已访问的,保证一个点只输出一次。
for (int i = (int)g[u].size() - 1; i >= 0; --i) st.push_back(g[u][i]);倒序压栈是这题唯一的技巧。正序压栈时栈顶是编号最大的出点,遍历序与递归版不一致 → WA。另外 size() 是无符号数,先强转 int 再减一:空表时 i = −1,循环直接跳过。
memset(vis, 0, sizeof vis);两遍遍历之间清空。忘了这一行,BFS 时所有出点都被判成「已访问」,第二行只剩一个 1。
q.push(1); vis[1] = 1;起点入队的同时标记。先 push 后标记也不会错,但两件事写在一起,就不会出现「进了队却没标记」。
if (vis[v]) continue; vis[v] = 1; q.push(v);BFS 的核心:标记发生在入队时。改成出队时标记,同一个点会被多个父亲重复入队,队列长度从 n 涨到 O(m)。
printOrder(ord)把遍历序先存进 vector 再统一输出。这样能干净地控制「数字之间一个空格、行末只换行」,不用在每个 printf 里判断是不是第一个。
scanf("%d%d", &x, &y)m = 10⁶ 时读入本身就是瓶颈。scanf 在这个量级比 cin 稳;若坚持用 cin,务必关同步。
题目里出现第一反应最容易写错的地方
连通块个数 / 能否互相到达DFS、BFS 或并查集,O(n+m)无向图只加了一条边
无权图最少几步BFS,距离数组兼作 visited在出队时标记
非负边权的最短路堆优化 Dijkstra忘了 greater 或 pair 顺序写反
n ≤ 400 的全源最短路Floyd 三重循环中间点 k 没放最外层
连通所有点的最小花费Kruskal:排序 + 并查集fa 没初始化、合并不取根
有依赖关系的顺序 / 最长链拓扑排序,再按出队顺序 DPindeg 记成出度、忘判环
这一章最常丢的 8 种分,每种都写症状

无向图只加一条边 → WA / 答案偏小。典型症状:n 个点本该 1 个连通块,程序数出 3 个;或者「任意两点可达」的题里有大量点输出 −1。检查 g[u].push_back(v) 有没有配一条 g[v].push_back(u)。

邻接表没排序就遍历 → WA。P5318 这类要求「小编号优先」的题,DFS 序会随输入顺序变。样例数据往往恰好是升序给的,本地全过、交上去第 3 个点就错。

BFS 在出队时判重 → TLE。小数据全对,大数据队列长度炸到 O(m),10⁶ 条边时内存和时间一起崩。判重必须放在入队那一刻。

Dijkstra 的堆方向写反 → TLE 或 WA。用默认大根堆、只靠懒删除时答案仍然是对的(顺序错了但会不断修正),代价是同一个点被反复松弛,P4779 的菊花数据能把它卡成 TLE;一旦再配上 used 数组「弹出即定案」,错的距离就会被冻死,大片数据 WA。三件事一起记:小根堆 greater、懒删除、别加 used。

INF 取成 0x7fffffff → WA。不可达点的 dist 是 2³¹−1,只要在它上面加一次正权就溢出成负数,「不可达」被改写成一条负距离,之后还会继续被松弛,输出里会出现 −2000000000 这种数。写 LLONG_MAX / 2 最稳。

并查集忘了 fa[i] = i → 连通图也输出 orz。fa 全局默认全 0,所有 find 都返回 0,每条边都被判成「成环」而跳过,cnt 始终是 0。

合并时写 fa[u] = v(不取根)→ WA 且答案偏小。树被拆断,连通性判断随机出错,Kruskal 会加入成环的边,最终答案比正确值小,但边数还是 n−1,很难自查。

答案用 int → 输出负数。P1629 的答案到 10¹⁰,P4017 的方案数是指数级(必须边算边模)。凡是「求和」「相乘」「方案数」,先估量级再定类型。

自己动手复现

关掉页面,用 P5318 的样例从空文件敲一遍,提交到洛谷。

改一改①:把输入第 2、3 行对调(先 1 3 再 1 2),排序那行还在时输出不变;把排序那行注释掉,DFS 序变成 1 3 7 8 2 5 6 4,BFS 序变成 1 3 2 4 7 5 6 8 —— 这就是「没排序」的现场。

改一改②:把显式栈换成递归 DFS,再自己造一条 n = 10⁵ 的链(第 i 行输入 i i+1),盯着程序看它是正常结束还是 RE。8 MB 栈 ÷ 每帧几十字节 ≈ 10⁵ 帧,正好卡在边界上——这就是本节用显式栈的理由。

改一改③:把 BFS 的 vis[v] = 1 从入队移到出队,用 m = 10⁶ 的随机图跑一遍,看时间与队列长度怎么变。

题号编号主要考什么最容易错在哪
AP3916反向建图 + BFS:编号大的先覆盖正着建图,语义整个反了
BP4779堆优化 Dijkstra 模板大根堆 + used 数组;pair 两位写反
CP1629正反两张图各跑一次 Dijkstra只跑一次;ans 用 int
DP3366Kruskal + 并查集fa 没初始化;合并不取根
EP4017拓扑排序 + 路径计数普及/提高-
这 95 分钟怎么用

0~10 分钟:只读题不写代码。按「能立刻写」→「有思路」→「没思路」给 5 题排序,写下每题要用的模板。

10~25 分钟:A(图的遍历)。重点不是 BFS,而是「为什么从大到小扫、为什么建反图」这两问。

25~45 分钟:B(单源最短路)。模板题,要求一次写对:三个模板参数、懒删除、INF 的写法。

45~65 分钟:C(邮递员送信)。想清楚「回来」为什么等于反图上「出去」,这一题就只剩打模板。

65~80 分钟:D(最小生成树)。排序 + 并查集,重点是 fa 初始化和 orz 的判据。

80~90 分钟:E(最大食物链计数)。拓扑排序 + DP,重点是「链的起点/终点」怎么在度上体现。

90~95 分钟:只检查,不改算法。按下面的清单过一遍,每检查一项就打一个勾。

提交前的 30 秒检查清单

边的方向:有向图只加一条,无向图加两条,反图是否也建了?

vis:两遍遍历之间清空了吗?BFS 的标记在入队处吗?

最短路:dist 用 fill 初始化了吗?INF 够大且加法不溢出吗?小根堆写全了吗?

并查集:fa[i] = i 写了吗?合并的两个参数是 find 的返回值吗?

拓扑:indeg 记的是入度吗?环判了吗?

类型:求和 / 方案数是不是 long long 或边算边模?

输出:行末有没有多余空格?每个答案是否各自换行?

本次训练 · 共 5 题

做题方式

限时 100 分钟。每题先自己写 20~25 分钟;写不出来先看提示,再给自己 10 分钟;还不行才点开思路。每题都独立限时,不要一题耗死。

大题 挑战题 1 · 图的遍历
洛谷 P3916普及/提高-去洛谷做这题 ↗

给出 N 个点、M 条边的有向图。对每个点 v,记 A(v) 为从 v 出发沿有向边走能到达的所有点(包括 v 自己)中编号最大的那个。求 A(1), A(2), …, A(N)。

输入格式:第一行两个整数 N、M(1 ≤ N, M ≤ 10⁵);接下来 M 行,每行两个整数 Uᵢ、Vᵢ,表示一条 Uᵢ → Vᵢ 的有向边。点用 1…N 编号。

输出格式:一行 N 个整数,依次为 A(1)…A(N),相邻两个之间一个空格。

样例输入
4 3
1 2
2 4
4 3

样例输出
4 4 3 4
样例解释:1 能到 2、4、3,最大编号 4;2 能到 4、3,A(2) = 4;3 只能到自己,A(3) = 3;4 能到 3,A(4) = 4。

引导性提示(先只看这里)

先估暴力:每个点各跑一次遍历,最坏 N × (N+M) = 2×10¹⁰,1 秒不可能。所以必须让多个点共用同一次遍历。

如果 x 能走到 y,那么 A(x) ≥ A(y)。换句话说,答案大的点会「覆盖」答案小的点。什么样的扫描顺序能让最大的那个先说话?

把每条边的方向倒过来。原图上「谁能走到 i」,在反图上就是「从 i 能走到谁」——从 i 出发一次 BFS,就能把所有答案至少是 i 的点一次标记完。

想看思路了?点开完整思路

暴力 O(N(N+M)) 必 TLE。突破口是:A(v) 一定是 v 能到达的点里编号最大的那个,而「能到达」在反图上就是「被到达」。

把每条边反向:原图 u → v 存成反图 v → u。这样从 i 在反图上能走到的每个 x,都满足「原图上 x 能走到 i」,于是 A(x) ≥ i。

从 N 往 1 枚举 i。若 ans[i] 还没被赋值,就从 i 在反图上 BFS,把沿途所有未赋值的点都标成 i。

正确性:设 x 被标成 i。① 反图上 i 到 x 有路 → 原图 x 到 i 有路 → A(x) ≥ i;② 任何 j > i 都在更早的轮次处理过,若 x 能到 j,反图上 j 就能到 x,x 早被标成 ≥ j 的值,矛盾。所以 A(x) 恰好是 i。

复杂度:每个点只被赋值一次、入队一次,每条反边最多扫一次,总 O(N+M),N = M = 10⁵ 时约 2×10⁵ 次操作。

细节:i 必须先令 ans[i] = i 再入队,否则没有出边的点会输出 0。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int n, m;
vector<int> rg[MAXN];        // 反图:原边 u -> v 在这里存成 v -> u
int ans[MAXN];               // ans[v]:v 能到达的最大编号;0 表示还没定

int main() {
    scanf("%d%d", &n, &m);
    for (int i = 0; i < m; ++i) {
        int u, v;
        scanf("%d%d", &u, &v);
        rg[v].push_back(u);             // 唯一改动:反向建边
    }

    for (int i = n; i >= 1; --i) {      // 从大到小扫:先被覆盖的答案就是最大的
        if (ans[i]) continue;           // 更大的点已经把它定过了
        ans[i] = i;                     // 自己一定能到达自己
        queue<int> q;
        q.push(i);
        while (!q.empty()) {
            int u = q.front(); q.pop();
            for (int v : rg[u]) {
                if (ans[v]) continue;
                ans[v] = i;             // 反图 i 能到 v,等价于原图 v 能到 i
                q.push(v);
            }
        }
    }

    for (int i = 1; i <= n; ++i) {
        if (i > 1) putchar(' ');
        printf("%d", ans[i]);
    }
    putchar('\n');
    return 0;
}
关键片段拆解
片段作用
rg[v].push_back(u)反向建图的全部代价就是这一行:把 push 的下标换一下,后面的代码一行都不用改。
for (int i = n; i >= 1; --i)从大到小枚举。顺序反过来就没有「先被覆盖即最大」的性质,样例会输出 1 2 3 3 而不是 4 4 3 4。
if (ans[i]) continue;已经确定过的点不必再 BFS:它的答案在更早(更大)的那一轮就定下了。
ans[i] = i;自己一定能到达自己。漏了这一行,没有入边的孤立点答案是 0。
if (ans[v]) continue; ans[v] = i;只处理没赋过值的点。第一次覆盖它的 i 就是最大的可达编号,以后再被更小的 i 碰到也不用改。
if (i > 1) putchar(' ');行首不加空格,行末只换行。严格比对时多余空格会 WA。
这题最容易错的地方
  • 正着建图、从 i 出发遍历:算出来的是「i 能到达谁」,语义反了,整题全错。
  • 从 1 到 n 枚举 i:小的先覆盖,答案偏小,样例里 A(1) 会变成 1。
  • 忘了 ans[i] = i:题目里存在没有任何边的点,输出 0,而正确答案是它自己。
  • 每个点单独 BFS 且不判 ans:复杂度退化成 O(N(N+M)) → TLE。
大题 挑战题 2 · 【模板】单源最短路径(标准版)
洛谷 P4779普及/提高-去洛谷做这题 ↗

给定 n 个点、m 条有向边的带非负权图,求起点 s 到每个点的最短距离。数据保证 s 能到达所有点。

输入格式:第一行三个正整数 n、m、s(1 ≤ n ≤ 10⁵,1 ≤ m ≤ 2×10⁵,s = 1);接下来 m 行,每行三个非负整数 uᵢ、vᵢ、wᵢ,表示一条 uᵢ → vᵢ、权值为 wᵢ 的有向边(0 ≤ wᵢ ≤ 10⁹,0 ≤ Σwᵢ ≤ 10⁹)。

输出格式:一行 n 个空格分隔的非负整数,第 i 个表示 s 到 i 的最短距离。

样例输入
4 6 1
1 2 2
2 3 2
2 4 1
1 3 5
3 4 3
1 4 4

样例输出
0 2 4 3

引导性提示(先只看这里)

边权非负,所以某个点的最短路一旦被确定,就不会被更短的路径推翻。能不能利用这一点,规定一个「确定的顺序」,每轮只扩展一个点?

朴素做法每轮扫一遍数组找 dist 最小的未确定点,O(n²) = 10¹⁰。谁能在 O(log n) 内一直给你最小值?

同一个点可能被松弛多次,堆里会留着它的好几份历史版本。取出一个距离比当前 dist 还大的元素时,想清楚该拿它怎么办。

想看思路了?点开完整思路

Dijkstra 把点分成「最短路已确定」和「未确定」两组。每轮从未确定集合里取 dist 最小的点 u,把 dist[u] 定为最终答案,再用 u 松弛它的所有出边。

为什么取最小就一定定案:任何到 u 的更短路径必然先经过某个未确定点 x,而 dist[x] ≥ dist[u];边权非负,绕过去只会更长。这与「u 是最小」矛盾。

朴素实现每轮 O(n) 找最小,总 O(n²),n = 10⁵ 时 10¹⁰,必 TLE。

把 (dist, 编号) 塞进小根堆,堆顶就是当前最小:每个点松弛成功就入堆一次,取出时若 d > dist[u] 说明是过期版本,直接丢弃。

复杂度 O((n+m) log n)。m = 2×10⁵ 时堆操作不超过 4×10⁵ 次,每次 log(4×10⁵) ≈ 19,总共约 8×10⁶ 次比较,1 秒内稳过。

类型:Σwᵢ ≤ 10⁹ 意味着最短路也在 10⁹ 量级,int 勉强够;但 d + w 在 INF 上做加法时用 int 容易溢出,一律 long long,不必再算边界。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
typedef long long ll;
typedef pair<ll, int> pli;              // (距离, 编号)

int n, m, s;
vector<pair<int,int>> g[MAXN];          // (终点, 边权)
ll dist[MAXN];

int main() {
    scanf("%d%d%d", &n, &m, &s);
    for (int i = 0; i < m; ++i) {
        int u, v, w;
        scanf("%d%d%d", &u, &v, &w);
        g[u].push_back({v, w});         // 有向边,只加一次
    }

    const ll INF = LLONG_MAX / 2;       // 足够大且加 w 不会溢出
    fill(dist, dist + n + 1, INF);
    dist[s] = 0;

    priority_queue<pli, vector<pli>, greater<pli>> pq;   // 小根堆
    pq.push({0, s});
    while (!pq.empty()) {
        pli cur = pq.top(); pq.pop();
        ll d = cur.first;
        int u = cur.second;
        if (d > dist[u]) continue;      // 过期版本:u 后来被更短的路径更新过
        for (auto &e : g[u]) {
            int v = e.first, w = e.second;
            if (dist[v] > d + w) {      // 松弛成功才入堆
                dist[v] = d + w;
                pq.push({dist[v], v});
            }
        }
    }

    for (int i = 1; i <= n; ++i) {
        if (i > 1) putchar(' ');
        printf("%lld", dist[i]);
    }
    putchar('\n');
    return 0;
}
关键片段拆解
片段作用
typedef pair<ll,int> pli堆里存 (距离, 编号)。pair 按字典序比较,距离是第一关键字,所以堆顶永远是「距离最小的点」。
greater<pli>小根堆。写成默认的 priority_queue<pli> 就是大根堆:只靠懒删除时它仍然能算出正确答案(顺序错了,但会一直修正),代价是同一个点被反复松弛;一旦再配「弹出即定案」的 used 数组就必错。
if (d > dist[u]) continue;懒删除。不写这一句结果也对,但同一个点会被反复松弛,堆元素从 O(m) 变几倍,常数明显变大。
if (dist[v] > d + w)松弛条件。相等时不入堆,省一次操作,结果不变。
fill(dist, dist + n + 1, INF)区间是 [dist, dist+n+1),下标 1..n 全部覆盖。INF 取 LLONG_MAX/2,保证 d+w 不会溢出成负数。
dist[s] = 0; pq.push({0, s});起点先定 0 再入堆。顺序反了,第一次取出的会是 INF。
这题最容易错的地方
  • 又用 used 数组「弹出即定案」、又用大根堆:过早点定案,错的距离被冻死,随机小图上错误率极高。本节代码不带 used,靠的是懒删除 + 小根堆。
  • INF 取 0x7fffffff 又用 int:在不可达点的 dist 上加一次边权就溢出成负数,「不可达」被写成负距离。
  • 忘了 d > dist[u] 的过滤:结果仍然正确,但堆里每个历史版本都会重扫一遍出边,m = 2×10⁵ 时明显变慢。
  • 以为 Dijkstra 能处理负权:见到「负权」二字必须换 Bellman-Ford / SPFA。
大题 挑战题 3 · 邮递员送信
洛谷 P1629普及/提高-去洛谷做这题 ↗

邮局在 1 号点,要给 2…n 号点各送一样东西。城里所有道路都是单行的,共 m 条。邮递员每次只能带一样东西,送完一件必须返回邮局,最后也要回到邮局。求送完所有东西的最少总时间。

输入格式:第一行两个整数 n、m(1 ≤ n ≤ 10³,1 ≤ m ≤ 10⁵);接下来 m 行,每行三个整数 u、v、w,表示 u 到 v 有一条耗时 w 的单向道路(1 ≤ w ≤ 10⁴)。输入保证任意两点互相可达。

输出格式:一行一个整数,表示最少总时间。

样例输入
5 10
2 3 5
1 5 5
3 5 6
1 2 8
1 3 8
5 3 4
4 1 8
4 5 3
3 5 6
5 4 2

样例输出
83

引导性提示(先只看这里)

「送出去」和「回到邮局」是两段完全不同的路:去 i 是 1 → i,回来是 i → 1。单向图里这两件事不对称,不能用同一个距离。

1 → i 全部点只要跑一次 Dijkstra。可是 i → 1 有 n−1 个不同的起点,难道要跑 n−1 次?先数一下 n 和 m 的量级。

把每条边掉个头。在反图上从 1 出发的最短路,正好就是原图上「从某个点回到 1」的最短路——路径的每条边反向,长度不变。

想看思路了?点开完整思路

总时间 = Σ( 1→i 的最短路 + i→1 的最短路 ),i = 2…n。两项分别算。

第一项:以 1 为源,在原图上跑一次 Dijkstra。

第二项:i → 1 的起点各不相同,但把每条边反向之后,「i → 1」就变成了「1 → i」。所以建一张反图,再从 1 跑一次 Dijkstra。

为什么等价:原图上一条 i → a → b → 1 的路径,把每条边反向就得到反图上 1 → b → a → i 的路径,边权逐一对应,长度完全一样,且这是双射。

复杂度:两次 Dijkstra,O((n+m) log n)。n = 10³、m = 10⁵ 时约 10⁵×10 次操作。若对每个 i 单独跑一次,是 10³ × 10⁵ = 10⁸ 量级以上,1 秒很难过。

类型:单条最短路 ≤ (n−1)×10⁴ ≈ 10⁷,int 够;但 n−1 条加起来到 10¹⁰,累加答案时必须 long long。样例:正向和 28,反向和 55,总共 83。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1005;
const int INF = 0x3f3f3f3f;         // 1.06e9,比任何真实最短路都大,加 w 也不溢出
int n, m;
vector<pair<int,int>> g[MAXN], rg[MAXN];   // (终点, 权值);g 正图,rg 反图
int d1[MAXN], d2[MAXN];

// 从 1 出发,在 gr 这张图上跑 Dijkstra,结果写进 d
void dijkstra(vector<pair<int,int>> gr[], int d[]) {
    fill(d, d + n + 1, INF);
    d[1] = 0;
    priority_queue<pair<int,int>, vector<pair<int,int>>, greater<pair<int,int>>> pq;
    pq.push({0, 1});
    while (!pq.empty()) {
        auto [du, u] = pq.top(); pq.pop();
        if (du > d[u]) continue;                 // 过期版本
        for (auto [v, w] : gr[u])
            if (d[v] > du + w) {
                d[v] = du + w;
                pq.push({d[v], v});
            }
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cin >> n >> m;
    for (int i = 0; i < m; ++i) {
        int u, v, w;
        cin >> u >> v >> w;
        g[u].push_back({v, w});                  // 送出去:u -> v
        rg[v].push_back({u, w});                 // 反图:用来算「从 i 回 1」
    }

    dijkstra(g, d1);                             // d1[i] = 1 -> i
    dijkstra(rg, d2);                            // d2[i] = i -> 1

    long long ans = 0;                           // 10³ 条 10⁷ 的路,必须 long long
    for (int i = 2; i <= n; ++i) ans += (long long)d1[i] + d2[i];
    cout << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
vector<pair<int,int>> g[MAXN], rg[MAXN]正图、反图各一份。n ≤ 10³、m ≤ 10⁵,两份邻接表合计 2×10⁵ 条边,约 1.6 MB。
rg[v].push_back({u, w})全题唯一的新代码。原边 u→v 在反图里记成 v→u,权值不变。
dijkstra(g, d1)第一次:1 到每个点的最短路,即送出去的时间。
dijkstra(rg, d2)第二次:反图上 1 到每个点,等于原图上每个点回到 1,即返回的时间。
fill(d, d + n + 1, INF)每次调用前整体重置。函数里用的是全局 n,区间 [d, d+n+1)。
ans += (long long)d1[i] + d2[i]两项相加后累加。先转 long long 再相加,避免两数相加时溢出。
auto [du, u] = pq.top()结构化绑定,一次取出距离和编号。顺序必须与 push 时一致(距离在前)。
这题最容易错的地方
  • 只跑正向:返回的路程被忽略或按 0 算,答案只有 28 而不是 83。
  • 反图建成了正图(写成 rg[u].push_back({v,w})):两次都算「送出去」,答案变成 2×28 = 56,样例一眼可见,但很值得警惕。
  • 对每个 i 单独跑一次 Dijkstra:10³ 次 × 10⁵ 条边 → TLE。
  • ans 用 int:10¹⁰ 溢出成负数,输出一个 −1.4×10⁹ 之类的数。
大题 挑战题 4 · 【模板】最小生成树
洛谷 P3366普及/提高-去洛谷做这题 ↗

给出 N 个点、M 条边的无向图,求它的最小生成树(选出 N−1 条边把所有点连成一片,且边权和最小)。如果图不连通,输出 orz。

输入格式:第一行两个整数 N、M(1 ≤ N ≤ 5000,1 ≤ M ≤ 2×10⁵);接下来 M 行,每行三个整数 Xᵢ、Yᵢ、Zᵢ(1 ≤ Zᵢ ≤ 10⁴),表示一条连接 Xᵢ、Yᵢ、长度为 Zᵢ 的无向边。

输出格式:连通则输出一个整数表示最小生成树的边权之和;不连通输出 orz(三个小写字母)。

样例输入
4 5
1 2 2
1 3 2
1 4 3
2 3 4
3 4 3

样例输出
7
样例解释:选 1-2(2)、1-3(2)、3-4(3),总权 2+2+3 = 7。

引导性提示(先只看这里)

n 个点的树恰好 n−1 条边。要在 m 条边里挑 n−1 条,使总权最小且不出环。先想:按什么顺序挑边最不亏?

把边按长度从小到大看。一条边什么时候必须选?什么情况下它一定不能选?「不能选」的判据,说到底和「这两个点现在是否已经连通」有关。

什么结构能在接近 O(1) 的时间里回答「两点是否连通」,并且支持把两个集合合并成一个?

想看思路了?点开完整思路

Kruskal:所有边按权值升序排序,依次尝试加入。若两端已经在同一连通块里就跳过(加入会成环),否则选中这条边并把两个连通块合并。选满 n−1 条即结束。

正确性(割性质):设 e 是当前剩下的边里权最小的,且两端分属不同连通块。任何一棵生成树都必须有某条边跨过这个割;把那条边换成 e,总权不会变大。所以 e 一定可以出现在某棵最小生成树中,删去这个子问题后归纳成立。

并查集负责「是否连通」与「合并」:路径压缩后单次接近 O(1)。

复杂度由排序决定:O(m log m)。m = 2×10⁵ 时约 3.6×10⁶ 次比较,并查集部分几乎不耗时。

判不连通:最后选中的边数 cnt < n−1,说明有的点始终连不进来,输出 orz。

边界:n = 1 时不需要任何边,cnt = 0 = n−1,输出 0;题目数据里 n ≥ 1。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 5005, MAXM = 200005;
int n, m;
struct Edge { int u, v, w; } e[MAXM];
int fa[MAXN];

int find(int x) {                       // 路径压缩,迭代写法
    while (fa[x] != x) {
        fa[x] = fa[fa[x]];
        x = fa[x];
    }
    return x;
}

int main() {
    scanf("%d%d", &n, &m);
    for (int i = 0; i < m; ++i) scanf("%d%d%d", &e[i].u, &e[i].v, &e[i].w);

    sort(e, e + m, [](const Edge &a, const Edge &b) { return a.w < b.w; });

    for (int i = 1; i <= n; ++i) fa[i] = i;   // 每个点自成一个集合,不能省

    int cnt = 0;
    long long sum = 0;
    for (int i = 0; i < m && cnt < n - 1; ++i) {
        int a = find(e[i].u), b = find(e[i].v);
        if (a == b) continue;             // 已连通:加入就成环
        fa[a] = b;                        // 合并两个根
        sum += e[i].w;
        ++cnt;
    }

    if (cnt == n - 1) printf("%lld\n", sum);
    else puts("orz");                     // 不连通,按题目要求输出
    return 0;
}
关键片段拆解
片段作用
struct Edge { int u, v, w; } e[MAXM]把一条边打包。排序时 u、v 跟着一起搬,不用开三个平行数组。
sort(e, e + m, [](const Edge &a, const Edge &b) { return a.w < b.w; })只按权值升序。权值相同的边谁先谁后不影响最终结果——Kruskal 对同权边的顺序不敏感。
for (int i = 1; i <= n; ++i) fa[i] = i;初始化。漏了这行,fa 全是 0,find 全部返回 0,每条边都被判成成环,最后输出 orz。
if (a == b) continue;Kruskal 唯一的「拒绝」条件:两端已连通。这是保证不出环的全部机制。
fa[a] = b;合并两个根。写成 fa[e[i].u] = e[i].v 会把树拆断,连通性判断随机出错,答案偏小。
cnt < n - 1选满 n−1 条边就够,后面的边不必再看。写在循环条件里,顺便防止无谓扫描。
puts("orz")不连通的输出。三个小写字母,不要写成 ORZ、orz!、0 或 −1。
这题最容易错的地方
  • 忘了 fa[i] = i:连通图也输出 orz(所有 find 都返回 0,每条边都被跳过)。
  • 合并时写 fa[e[i].u] = e[i].v:树被拆断,可能加入成环的边,答案偏小。
  • 不连通时输出 0 或 −1:题目要求 orz,写错就是 WA。
  • 按 (u, v) 排序或忘了排序:Kruskal 必须按权值升序,顺序错则答案错。
大题 挑战题 5 · 最大食物链计数
洛谷 P4017普及/提高-去洛谷做这题 ↗

给出一个食物网:n 个物种、m 条「吃与被吃」的关系。一条最大食物链,从不会捕食其他生物的生产者出发,到不会被其他生物捕食的消费者结束,链上每一步都是捕食关系。求这样的食物链条数,对 80112002 取模。

输入格式:第一行两个正整数 n、m(1 ≤ n ≤ 5×10³,1 ≤ m ≤ 5×10⁵);接下来 m 行,每行两个正整数 A、B,表示 B 吃 A(A 被 B 捕食)。数据保证不会出现环。

输出格式:一行一个整数,食物链条数模 80112002 的结果。

样例输入
5 7
1 2
1 3
2 3
3 5
2 5
4 5
3 4

样例输出
5
样例解释:5 条链分别是 1→2→3→5、1→2→5、1→3→5、1→2→3→4→5、1→3→4→5。1 是唯一的生产者(没有生物吃它),5 是唯一的顶级消费者(它不吃别的)。

引导性提示(先只看这里)

先把方向定死:B 吃 A,就把「A 被 B 吃」画成 A → B。那么一条食物链就是这张有向图里的一段路径。生产者和顶级消费者,在这个方向上分别有什么特征?

直接枚举所有路径会重复、会漏。换一个量:以 u 结尾的链有几条?把它记成 dp[u],所有链都能按「最后一步从哪来」被唯一分解。

要让 dp 的递推一次算准,处理某个点时它所有的前驱必须都算完了。什么顺序能保证这件事?而这张图恰好没有环。

想看思路了?点开完整思路

建图:B 吃 A,就加有向边 A → B。于是「生产者」= 入度为 0 的点(它不吃任何别的生物),「顶级消费者」= 出度为 0 的点(没有生物吃它)。食物链 = 从入度 0 走到出度 0 的一条路径。

状态:dp[u] = 以 u 结尾的食物链条数。初值:入度为 0 的点 dp = 1,它自己就是一条链的开头。

转移:按拓扑序处理,每遇到边 u → v,就 dp[v] += dp[u],随时对 80112002 取模。

答案:Σ dp[u],只累加出度为 0 的点——链必须在顶级消费者结束,中途的点不能计入。

为什么拓扑序就够:处理到 u 时,所有能到达 u 的点都已经把贡献加进了 dp[u],不会漏;u 只会被处理一次,不会重。

复杂度 O(n+m)。m = 5×10⁵、时限 1 秒,读入必须用 scanf;dp 的加法在 int 内完成(模数 8×10⁷,两数相加不超过 1.7×10⁸)。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 5005;
const int MOD = 80112002;
int n, m;
vector<int> g[MAXN];
int indeg[MAXN], outdeg[MAXN];      // 入度 = 它吃几种;出度 = 几种吃它
int dp[MAXN];                       // dp[u]:以 u 结尾的食物链条数

int main() {
    scanf("%d%d", &n, &m);
    for (int i = 0; i < m; ++i) {
        int a, b;
        scanf("%d%d", &a, &b);      // B 吃 A
        g[a].push_back(b);          // 链的方向:被吃者 -> 捕食者
        ++indeg[b];
        ++outdeg[a];
    }

    queue<int> q;
    for (int i = 1; i <= n; ++i)
        if (indeg[i] == 0) {        // 生产者:链的起点
            dp[i] = 1;
            q.push(i);
        }

    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int v : g[u]) {
            dp[v] = (dp[v] + dp[u]) % MOD;   // 所有以 u 结尾的链都能延长到 v
            if (--indeg[v] == 0) q.push(v);  // v 的全部前驱都处理完了
        }
    }

    int ans = 0;
    for (int i = 1; i <= n; ++i)
        if (outdeg[i] == 0)                  // 顶级消费者:链的终点
            ans = (ans + dp[i]) % MOD;
    printf("%d\n", ans);
    return 0;
}
关键片段拆解
片段作用
g[a].push_back(b)边的方向就是链的方向:被吃 → 吃。三处方向必须统一(建图、indeg、outdeg)。全反过来的话链首尾互换是一一对应,总数恰好不变;但混用(建图 A→B、入度却记在 A 上)就必错。
dp[i] = 1每个入度为 0 的点自成一链的开头,方案数 1。
dp[v] = (dp[v] + dp[u]) % MOD按拓扑序做加法:以 u 结尾的每一条链,都能接一步到 v,所以 v 的方案数加上 u 的方案数。边算边模,避免溢出。
if (--indeg[v] == 0) q.push(v)入度降到 0 才入队,保证 v 的所有前驱都已处理完,dp[v] 一次算定,不会漏加。
if (outdeg[i] == 0) ans += dp[i]只有出度为 0 的点才是链的终点。把所有点的 dp 都加上,答案会大很多。
const int MOD = 80112002模数不是 1e9+7。抄错一个数字就是 0 分。
这题最容易错的地方
  • 答案把每个点的 dp 都累加:中途点也被当成终点,样例输出 11 而不是 5。(1+1+2+2+5 = 11;正确只算出度为 0 的点,即 dp[5] = 5。)
  • 起点判据写成出度为 0(或终点写成入度为 0):dp 从一个没有出边的点开始算,队列立刻空掉,样例输出 1。
  • 不取模、只在最后取模:方案数指数级增长,long long 也会溢出。
  • 用 cin 读 5×10⁵ 条边又没关同步:本题时限 1 秒,很容易 TLE。
做完之后自问
  • 不看资料能写出邻接表建图 + DFS/BFS 两套模板,并说清「BFS 为什么入队即标记」。
  • 能解释 P5318 为什么必须先排序,并说出不排序时 DFS 序会变成什么。
  • 能默写堆优化 Dijkstra 的十来行,并说清 greater、懒删除、INF 三处各自防的是什么错。
  • 能说清「反图」为什么把「i 回到 1」变成「1 到 i」,并指出本节哪两题用了它。
  • 能写出 Kruskal 的排序 + 并查集骨架,并说清「两端已连通就跳过」为什么不会漏掉最优解。
  • 能说出拓扑排序判环的判据,以及为什么按出队顺序 DP 不会漏算。
  • 能在 20 分钟内做完 P3366,并自查 fa 初始化、orz 输出、答案类型三处。
延伸阅读
  • 《算法竞赛入门笔记》:图论、最短路与最小生成树相关章节
  • 《算法竞赛进阶指南》:树与图的遍历、最短路、最小生成树三章
  • 《算法竞赛试炼场:洛谷300题精析》:P5318、P3916、P4779、P1629、P3366、P4017 的题解
  • 《算法竞赛黄金法则:提高算法和思考力的77项技巧》:「把问题反过来想」与「先排序再贪心」
  • 想再练一道并查集:P1111 修复公路;想再练一道拓扑 DP:P1113 杂务(不计入本次限时)
46 树状数组提高-

46 · 树状数组

用 lowbit 把前缀和切成 log n 段,让“改一个点”和“查一段和”都变成 O(log n)。一份 12 行的代码,换三种下标含义,就能做单点改区间和、区间改单点查、逆序对,再顺手学会在树状数组上二分。
提高-预计 65 分钟树状数组逆序对区间修改
本节出现的新函数 / 新语法(先看懂再用)

第 18 节的前缀和有个硬伤:改一个数,后面所有 s[i] 全部作废,重建一次 O(n)。而比赛中“单点修改 + 区间询问”的组合出现得极频繁——数据在变,问题却还是那一个。这一节解决的就是这个矛盾:让改一个点和查一段和都只要 O(log n),代价是 12 行代码。

树状数组的全部秘密在一句话里:t[i] 管辖的区间长度恰好是 lowbit(i)。看不懂这句话,就只是在背模板。
操作朴素数组前缀和树状数组
单点修改 a[i] += vO(1)O(n) 重建O(log n)
前缀和 a[1..i]O(n)O(1)O(log n)
区间和 a[l..r]O(n)O(1)O(log n)
区间加 + 单点查O(n)O(n)O(log n)(差分)
区间最大值O(n)不支持不支持
代码量3 行5 行12 行

前缀和的思路是“把 a[1..i] 压成一个数”,一旦中间某个值变了,这个压缩就废了。树状数组换了一种压法:按 i 的二进制位来压,每个二进制位对应一段。于是改一个点只会动到 O(log n) 段,求一个前缀和也只需要拼 O(log n) 段。以 n = 8 为例:t[1]=a[1],t[2]=a[1..2],t[3]=a[3],t[4]=a[1..4],t[6]=a[5..6],t[8]=a[1..8]。那么 sum(6) = t[6] + t[4] = a[5..6] + a[1..4],两段拼完,一步没多。

先看清两块骨头:lowbit(怎么算管辖长度)和三个函数的骨架(怎么跳下标)。这两块想通了,后面四种用法只是换一下“下标代表什么”。

新函数拆解 · lowbit(取最低位的 1)
int lowbit(int x) { return x & -x; }
组成含义 / 格式要求
x & -x把 x 与它的相反数按位与。结果是 x 的二进制里最低位那个 1所代表的值。
-x 从哪来补码:-x = ~x + 1,先把 x 每一位取反,再 +1,于是最低位的 1 被完整保留下来。
三个例子lowbit(6) = lowbit(110₂) = 10₂ = 2;lowbit(8) = 8;lowbit(7) = 1。
x 必须 ≥ 1x = 0 时 x & -x = 0,i += lowbit(i) 就变成原地踏步 → 死循环 TLE。
在树状数组里的两个用途① t[i] 的管辖长度 = lowbit(i);② i += lowbit(i) 跳到“下一个管着我的节点”,i -= lowbit(i) 跳到“左边还没累加的那一段的右端”。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int main() {
    for (int x = 1; x <= 10; ++x)
        cout << x << " -> " << (x & -x) << "\n";
    // 1->1  2->2  3->1  4->4  5->1  6->2  7->1  8->8  9->1  10->2
    return 0;
}
注意

规律:2 的幂返回自己,奇数返回 1,其余返回“最低位 1 的权值”。

别自己造写法(比如 x - (x & (x - 1)) 虽然也对),标准写法就这一个,读别人代码也认这个。

新函数拆解 · 树状数组(add / sum 两个函数)
void add(int i, int v); int sum(int i);
组成含义 / 格式要求
int t[N]树状数组本体,放全局(自动清零、不占栈)。t[i] 存 a[i−lowbit(i)+1 .. i] 的和。
add(i, v)执行 a[i] += v。从 i 出发,每次 i += lowbit(i),把 v 加到每一个“管着 i”的节点上。
sum(i)返回 a[1]+...+a[i]。从 i 出发,每次 i -= lowbit(i),把沿途的 t[i] 全部累加。
区间和sum(r) − sum(l−1),和前缀和一模一样:两个前缀相减。
下标从 1 开始lowbit(0) = 0 会让循环停不下来。题目是 1-based 就直接用;0-based 要整体 +1。
复杂度两个函数都是 O(log n):下标每走一步,二进制里至少少一个 1 或多进一位。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int n = 8, t[10];                                 // t[i] 管 lowbit(i) 个位置
int a[9] = {0, 3, 1, 4, 1, 5, 9, 2, 6};

int lowbit(int x) { return x & -x; }

void add(int i, int v) {
    for (; i <= n; i += lowbit(i)) t[i] += v;      // 所有管着 i 的节点都要改
}

int sum(int i) {
    int s = 0;
    for (; i > 0; i -= lowbit(i)) s += t[i];       // 一路往左上跳,把大段拼起来
    return s;
}

int main() {
    for (int i = 1; i <= n; ++i) add(i, a[i]);     // 建树:n 次单点加
    cout << sum(5) << "\n";                        // 3+1+4+1+5 = 14
    cout << sum(7) - sum(2) << "\n";               // a[3..7] = 4+1+5+9+2 = 21
    add(3, 10);                                    // a[3] 从 4 变成 14
    cout << sum(5) << "\n";                        // 24
    return 0;
}
注意

t 的意义是“和”,所以 add 里是 +=;如果维护的是“出现次数”,add(i, 1) 就够。

建树也可以 O(n):先 t[i] = a[i],再让 i 从 1 到 n 执行 t[i + lowbit(i)] += t[i]。n 大时省一个 log,但 n 次 add 在 5×10⁵ 下也只要 10⁷ 次加法。

add 和 sum 的循环条件完全不同:add 往上(i 变大),sum 往左上(i 变小)。写反了必然错。

先拿洛谷的模板题把它跑起来。这道题把“单点修改”和“区间求和”放在一起,正好对上树状数组的两个函数。

例题 · 【模板】树状数组 1洛谷 P3374普及/提高-去洛谷做这题 ↗

给定一个长度为 n 的数列,需要进行 m 次操作,操作有两种:

1 x k:把第 x 个数加上 k;2 x y:输出区间 [x, y] 内每个数的和。

数据范围:1 ≤ n, m ≤ 5×10⁵,数列中的数与 k 的绝对值不超过 10⁵。

输入格式:第一行两个整数 n、m;第二行 n 个整数;接下来 m 行,每行三个整数,表示一次操作。
输出格式:对每个操作 2,输出一行一个整数。

输入: 5 5 1 5 4 2 3 1 1 3 2 2 5 1 3 -1 1 4 2 2 1 4 输出: 14 16
先算清楚暴力为什么会死

最朴素的写法:数组存原值。修改 O(1),查询把 [x,y] 扫一遍 O(n)。最坏情况 m 次全是查询,n = m = 5×10⁵ 时是 2.5×10¹¹ 次加法,几十秒起步,直接 TLE。

换成前缀和:查询 O(1),但每次修改都要把 s[x..n] 重算一遍,还是 O(n),一样死。两种做法的毛病是同一个——它们把“一段”压成了“一个数”,改一点就得整体重来。

树状数组的做法:不再压成一个数,而是压成 log n 个数。sum(5) 只拼 t[5] + t[4](因为 5 = 101₂,去掉最低位 1 得 4,再去掉得 0);add(5, v) 只改 t[5]、t[6]、t[8]、t[16]…(5 → 6 → 8 → 16,每次进位)。两头都只碰 log n 个位置,总复杂度 O(m log n) ≈ 5×10⁵ × 19 ≈ 10⁷,稳过。

为什么这样切一定能覆盖完整?因为二进制分解是唯一的:i 减去 lowbit(i) 相当于删掉最低位的 1,反复做就得到 i 的二进制各位,而每一位恰好对应一段连续区间,这些区间首尾相接、正好拼成 [1, i],既不漏也不重。

建树别忘:代码里用 n 次 add 建树,是 O(n log n);n = 5×10⁵ 时约 10⁷ 次加法,可以接受。要更快就线性建树(见上一张函数卡的注意)。

数值:本题答案在 int 内,但把 t 和 sum 写成 long long 也只占 4 MB(比 int 多 2 MB),以后遇到大数值不用再回来改一遍。

完整可编译代码(C++17)
#include <bits/stdc++.h>
using namespace std;

const int N = 500005;
int n, m;
long long t[N];                       // t[i] 管 a[i-lowbit(i)+1 .. i] 这一段

int lowbit(int x) { return x & -x; }

void add(int i, long long v) {
    for (; i <= n; i += lowbit(i))    // 往上跳:把所有"管着 i"的节点都改掉
        t[i] += v;
}

long long sum(int i) {
    long long s = 0;
    for (; i > 0; i -= lowbit(i))     // 往左上跳:把几个大段拼成前缀
        s += t[i];
    return s;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> m;
    for (int i = 1; i <= n; ++i) {    // 建树:读一个数就当成一次单点加
        int x;
        cin >> x;
        add(i, x);
    }

    while (m--) {
        int op, x, y;
        cin >> op >> x >> y;
        if (op == 1) add(x, y);                    // 第 x 个数加 y
        else cout << sum(y) - sum(x - 1) << "\n";  // 区间和 = 两个前缀之差
    }
    return 0;
}
逐行拆解
代码片段含义与理由
long long t[N]全局数组自动清零。开 5×10⁵ 个 long long 是 4 MB,任何题都不会超内存。
for (; i <= n; i += lowbit(i))add 的循环。i 从待改位置往上爬,爬到的每一个 i 都有一段包含原位置,所以都要改。
i += lowbit(i) 走一遍 55 → 6 → 8 → 16 → … 每次把最低位的 1 往上进一位,最多 log₂n 步就超过 n。
for (; i > 0; i -= lowbit(i))sum 的循环。i 每次删掉最低位的 1,把 t[i] 这一段拿走,剩下的交给更小的 i。
i -= lowbit(i) 走一遍 66 → 4 → 0。对应 a[5..6] + a[1..4],正好是 a[1..6]。
sum(y) - sum(x - 1)区间和转成两个前缀和相减。为什么是 x−1 不是 x:sum(x) 包含 a[x],而 a[x] 在答案里。
add(i, x) 建树读入 a[i] 时直接在 i 位置做一次单点加,树就建好了,不需要额外的 build 函数。
while (m--)循环 m 次,m 归零后自动结束。注意 m 被用掉了,后面不能再当数据范围的界。

第二种用法只改一件事:让树状数组去管差分数组。于是“区间加”变成两次单点加,“单点查”变成一个前缀和——正好是 add 和 sum。

例题 · 【模板】树状数组 2洛谷 P3368普及/提高-去洛谷做这题 ↗

给定一个长度为 n 的数列,需要进行 m 次操作,操作有两种:

1 x y k:把区间 [x, y] 内每个数加上 k;2 x:输出第 x 个数的值。

数据范围:1 ≤ n, m ≤ 5×10⁵,所有输入与答案都在 64 位整数范围内。

输入格式:第一行两个整数 n、m;第二行 n 个整数,表示初始数列;接下来 m 行,每行为上述两种操作之一。
输出格式:对每个操作 2,输出一行一个整数。

输入: 5 5 1 5 4 2 3 1 2 4 2 2 3 1 1 5 -1 1 3 5 7 2 4 输出: 6 10
把“区间加”翻译成“两次单点加”

差分数组的定义:d[1] = a[1],d[i] = a[i] − a[i−1]。它的好处是 a[x] = d[1] + d[2] + … + d[x]——原数组的值就是差分数组的前缀和。

于是区间加 [l, r] 全部加 k,在差分里只动两个位置:d[l] += k(从此往后都多了 k),d[r+1] −= k(从 r+1 起把多出来的还回去)。中间那些 d 值全不变。

这样一来,两个操作正好对上树状数组的两个函数:区间加 = 两次 add,单点查 = 一次 sum。都是 O(log n)。

边界:r = n 时 d[n+1] 根本不会被任何前缀和用到,而 add(n+1, −k) 的循环条件 i <= n 立刻不成立,什么也不做。所以那句越界判断可以不写——但你要知道它为什么安全。

建树:把 d[i] = a[i] − a[i−1] 逐个 add 进去,就完成了初值。

数值:差分值带负号,k 也可能很大,t、add 的参数、sum 的返回值统一用 long long。

完整可编译代码(C++17)
#include <bits/stdc++.h>
using namespace std;

const int N = 500005;
int n, m;
long long a[N], t[N];                 // t 维护的是差分数组 d

int lowbit(int x) { return x & -x; }

void add(int i, long long v) {
    for (; i <= n; i += lowbit(i)) t[i] += v;
}

long long sum(int i) {
    long long s = 0;
    for (; i > 0; i -= lowbit(i)) s += t[i];
    return s;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> m;
    for (int i = 1; i <= n; ++i) {
        cin >> a[i];
        add(i, a[i] - a[i - 1]);      // 差分 d[i] 直接进树状数组
    }

    while (m--) {
        int op;
        cin >> op;
        if (op == 1) {
            int l, r;
            long long k;
            cin >> l >> r >> k;
            add(l, k);                // 从 l 起,后面都多 k
            add(r + 1, -k);           // 从 r+1 起还回去(r=n 时这行自动空转)
        } else {
            int x;
            cin >> x;
            cout << sum(x) << "\n";   // 原数组第 x 个值 = 差分的前缀和
        }
    }
    return 0;
}
逐行拆解
代码片段含义与理由
add(i, a[i] - a[i - 1])读入时就地差分。a[0] 默认为 0,所以 a[1] − a[0] = a[1],正好是 d[1] 的定义。
add(l, k)差分数组第 l 位加 k,含义是“a[l..n] 都加 k”。
add(r + 1, -k)第 r+1 位减 k,把上面多加的那部分收回去,于是实际生效的只有 [l, r]。
r = n 时为什么安全add 的循环条件是 i <= n,传入 n+1 时条件一开始就不成立,函数直接返回,不会碰 t[n+1]。
sum(x)d[1..x] 的和 = a[x]。这就是“单点查询”,一次前缀和搞定。
long long a[N]a[i] 在修改后可能很大,虽然本题只用来算初值,统一用 long long 免掉类型转换的坑。

第三种用法最反直觉:让树状数组的下标不是位置,而是数值本身。这时 t 存的是“某个值出现了几次”,前缀和 sum(v) 就是“值 ≤ v 的元素有多少个”。一旦有了这个能力,逆序对就变成了一行加法。

逆序对:从左往右扫,问“前面有多少个比我大”

逆序对是满足 i < j 且 a[i] > a[j] 的有序对。换个说法:把每个数当成主角,数一数它前面有多少个数比它大,全部加起来,就是答案。

扫到第 i 个数时,前面有 i−1 个数。其中 ≤ a[i] 的有 sum(a[i]) 个(因为 sum(v) 数的是“值 ≤ v 的出现次数”),剩下的自然全都严格大于 a[i],就是这一位的贡献:(i − 1) − sum(a[i])。

所以每读一个数,做两件事:先查 sum(a[i]) 算贡献,再改 add(a[i], 1) 把“这个值出现过一次”记进去。查询和修改各 O(log n),总共 O(n log n)。

顺序不能反:先 add 再 sum,会把自己算进“前面”,答案偏大。

下标要求:树状数组的下标必须是 1..n 的整数,所以这个写法只在 1 ≤ a[i] ≤ n 时能直接用。值域到 10⁹ 时必须先离散化(把数值换成名次),节末的挑战题就是这件事。

答案规模:最坏情况下所有数两两构成逆序对,共 n(n−1)/2 个。n = 5×10⁵ 时约 1.25×10¹¹,int 装不下,必须是 long long。

逆序对(值域 ≤ n 的版本)
#include <bits/stdc++.h>
using namespace std;

const int N = 500005;
int n, a[N], t[N];                    // t 的下标是"值",不是"位置"

int lowbit(int x) { return x & -x; }

void add(int i, int v) {
    for (; i <= n; i += lowbit(i)) t[i] += v;
}

int sum(int i) {
    int s = 0;
    for (; i > 0; i -= lowbit(i)) s += t[i];
    return s;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    for (int i = 1; i <= n; ++i) cin >> a[i];   // 保证 1 <= a[i] <= n

    long long ans = 0;
    for (int i = 1; i <= n; ++i) {
        ans += (i - 1) - sum(a[i]);   // 前面 i-1 个里,≤ a[i] 的有 sum(a[i]) 个
        add(a[i], 1);                 // 再把 a[i] 记进去:这个值多出现了一次
    }
    cout << ans << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
int n, a[N], t[N]这里的 n 同时是“元素个数”和“值域上界”,所以 t 的下标范围刚好够。
ans += (i - 1) - sum(a[i])i−1 是前面已有的元素个数,sum(a[i]) 是其中 ≤ a[i] 的个数,相减就是严格大于的个数。
为什么用 sum(a[i]) 不是 sum(a[i] - 1)sum(a[i]) 把“等于 a[i]”的也扣掉了,剩下来的才是严格大于,正好符合逆序对的定义。
add(a[i], 1)把“值 a[i] 出现了一次”写进树状数组。维护的是计数,所以加 1。
先 sum 后 add自己此刻还没进树,不会被 sum 数到。顺序颠倒会让每个数至少多算一次。
long long ans答案上限 n(n−1)/2 ≈ 1.25×10¹¹,远超 int 的 2.1×10⁹。

第四种用法把树状数组当成一棵“二叉搜索树”:在 O(log n) 内找到最小的 p 使 sum(p) ≥ k。答案如果靠二分,就是 O(log²n),而树状数组自己就能二分。

原理:树状数组的节点正好构成一棵“按二进制分段”的树。从 p = 0 出发,从大到小试 step = 2²⁰, 2¹⁹, …, 1:如果 t[p + step] < k,说明前 p+step 个数的和还不够 k,答案在右边,就跳过去,并把 k 减去这一段的和;否则停在原地。走完 log n 步,p 就是“最后一个前缀和 < k 的位置”,答案是 p+1。

树状数组上二分(前提:所有数非负,前缀和单调)
#include <bits/stdc++.h>
using namespace std;

int n = 8, t[10];                       // 用 t 记"每个位置出现了几次"

int lowbit(int x) { return x & -x; }
void add(int i, int v) { for (; i <= n; i += lowbit(i)) t[i] += v; }

int kth(int k) {                        // 最小的 p,使 sum(p) >= k
    int p = 0;
    for (int step = 1 << 20; step; step >>= 1)      // 起点取一个不小于 n 的 2 的幂
        if (p + step <= n && t[p + step] < k) {     // 这一段还不够,就整段吃掉
            p += step;
            k -= t[p];                              // t[p] 正是刚吃掉那一段的和
        }
    return p + 1;
}

int main() {
    add(2, 1); add(5, 2); add(7, 1);                // 位置 2 一个、位置 5 两个、位置 7 一个
    for (int k = 1; k <= 4; ++k) cout << kth(k) << " ";   // 2 5 5 7
    cout << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
step 从 2²⁰ 开始必须从“不小于 n 的 2 的幂”开始:整段跳跃靠的就是二进制拼位,起点换成 n(n 未必是 2 的幂)就拼不回去了。
p + step <= n越界保护。step 一开始远大于 n,这个判断会把它挡掉。
t[p + step] < kt[p+step] 存的是 (p+step−lowbit, p+step] 这一段的和。它 < k 意味着答案不在这段左边。
k -= t[p]把刚跨过的这一段和从 k 里扣掉,问题变成“在剩下的后缀里找第 k 个”。
return p + 1循环结束时 p 是最后一个 sum(p) < k 的位置,所以第一个够 k 的位置是 p+1。
所有数必须非负前缀和单调才有“第一个 ≥ k 的位置”这种说法。有负数时这个二分直接失效,不是慢,是错。
四种用法,其实是同一份代码

树状数组唯一不变的是骨架:lowbit、add 往上跳、sum 往左上跳。变的是下标代表什么,以及t 里存什么。四种用法对照如下。

用法下标代表t 存什么查询是什么
单点改 + 区间和位置一段位置的和前缀和相减
区间加 + 单点查位置差分数组的一段和前缀和 = 原值
逆序对 / 计数数值(名次)该值出现的次数前缀和 = 值 ≤ v 的个数
上二分位置一段位置的和不用 sum,直接跳

什么时候别用树状数组。它只能维护可差分的运算:加法(有相反数)、异或(自己是自己的逆)、模意义下的乘法(有逆元)。区间最值不行——max(1..r) 没法“减掉” max(1..l−1) 还原出 max(l..r),因为求最大值没有逆运算。要区间最值就得上线段树,下一节。

逆序对还有一个等价身份:冒泡排序的交换次数、插入排序的元素移动次数、把序列排成升序所需的最少相邻交换次数,三者都等于逆序对数。看到“相邻交换”四个字要想得起这一行。

反过来扫也一样。求“每个数右边比它小的个数”,从右往左扫:每步贡献是 已插入个数 − sum(a[i] − 1),再把 a[i] 记进树里。

这一节的 6 个坑,按症状记

下标从 0 开始:lowbit(0) = 0,add 里 i += 0 永远不动 → TLE,表现为程序卡死、一个字符都不输出。原题 1-based 就直接用,0-based 记得整体 +1。

差分区间加漏了 add(r + 1, -k),或者把 +k 和 −k 写反 → WA。症状很典型:前几个数对,从 l 之后全部偏大(或偏小),而且差值恰好是 k 的倍数。

逆序对的 ans 用 int:n = 5×10⁵ 时答案可达 1.25×10¹¹,溢出后变成负数 → WA,而且小数据完全正常,只有大测试点挂。

把 ans += (i-1) - sum(a[i]) 写成 ans += i - sum(a[i]):多算了自己 → WA,答案恰比如正确答案大 n。

用树状数组求区间最大值:sum(r) - sum(l-1) 那套逻辑对 max 不成立,代码能编译能跑,样例甚至可能过 → WA。这类“逻辑不成立”的错最难查,因为不报错。

多组数据忘记清空 t:第 2 组开始答案全错 → WA。只要 t 是全局数组,多测就在每组开头 memset(t, 0, sizeof t)。

自己动手复现

关掉页面,把 P3374 的 30 行从零敲一遍(不看书),提交确认样例输出 14 和 16。

改一改①:把 add 的循环改成 for (; i > 0; i -= lowbit(i)),先预测再跑。你会发现 add(1, x) 只改了很少几个位置,sum(5) 立刻不对——上行下行的方向不能反。

改一改②:在 add 里加一句 cout << i << ' ';,调用 add(5, 1),看到 5 6 8 16,这就是“进位”的形状。

改一改③:在 P3368 里删掉 add(r + 1, -k),用样例跑一遍,观察第 2 个输出的变化——一半的区间被多加了,这正是“忘记减回去”。

数学补给 · lowbit 与 log n 到底从哪来

把 n = 8 时每个节点的管辖范围列出来,规律一眼可见:

i二进制lowbit(i)t[i] 管辖下一个管 i 的节点 i+lowbit(i)
111a[1..1]2
2102a[1..2]4
3111a[3..3]4
41004a[1..4]8
51011a[5..5]6
61102a[5..6]8
71111a[7..7]8
810008a[1..8]16

为什么 x & -x 能取出最低位的 1。补码下 -x = ~x + 1:取反把最低位 1 变成 0、把它右边的 0 全变成 1,再 +1 让右边的 1 全部进位回 0,只有最低位那个 1 被完整地还原。所以 x & -x 只留下它,其余全 0。

sum 为什么是 O(log n)。每步 i -= lowbit(i) 至少消掉一个二进制 1。2¹⁸ = 262144 ≤ n < 2¹⁹ = 524288 时,i 最多 19 位,所以最多 19 步。n = 5×10⁵ 落在这一档,一次 sum 最多 19 次加法。

add 为什么也是 O(log n)。i += lowbit(i) 是把最低位的 1 往前推一位,下标最多涨到 2 倍,19 步之内必定超过 n。一次 add 也是最多 19 次加法。

两边合起来:一次操作 ≤ 19 次加法,5×10⁵ 次操作 ≈ 10⁷ 次,实际运行 0.1 秒量级。这就是为什么树状数组的常数比线段树小得多——它连一次递归都没有。

不能做最值的根源是“没有逆运算”。sum 能相减是因为加法有相反数;max 若要“相减”,等于要从 max(l..r) 和 max(1..l−1) 反推出 max(1..r),信息不足。所以区间最值必须让每个节点存“自己那一段的最值”,查询时把 O(log n) 段的答案合并——这正是线段树。

举一反三 · 想清楚这些再往下走
  1. 把维护的信息从“和”换成“异或”,add 和 sum 各改哪几处?为什么异或可以而 max 不行?(提示:异或的“逆运算”是谁?)
  2. 逆序对那一题,如果要输出“每个位置 i 左边比它大的个数”而不是总和,代码要改哪一行?空间要不要多加一个数组?
  3. 二维版:n × m 的网格,支持单点加和子矩阵求和。t 该开成几维?add 和 sum 各有几层循环?复杂度是多少?
  4. 树状数组上二分的 step 为什么必须从“≥ n 的 2 的幂”开始?如果从 n 开始,第一个被尝试的位置是 n,会漏掉或重复哪一段?
  5. 区间加 并且 区间求和,能不能只用树状数组做?需要几个?(提示:把 sum(l..r) 用差分的前缀和展开成两个可维护的量)
  6. 如果一道题有 5×10⁵ 次单点修改,但只有 10 次区间查询,你还会用树状数组吗?用什么更划算?反过来呢?
自测:能全部回答再进下一节
  • 能默写 lowbit、add、sum 三个函数,并说出每个的复杂度。
  • 能说出 n = 8 时 t[6] 管辖哪一段,以及 add(6, v) 会改到哪些位置。
  • 能解释区间加为什么只改 l 和 r+1 两处,并说明 r = n 时为什么不写越界判断也安全。
  • 能用树状数组求逆序对,并说清 (i-1) - sum(a[i]) 里每一项的含义。
  • 能说出树状数组不能维护区间最值的原因,以及该换成什么结构。
  • 能手写树状数组上二分的 6 行,并解释 k -= t[p] 在做什么。
挑战题 · 逆序对
洛谷 P1908普及/提高-去洛谷做这题 ↗

给定一个长度为 n 的正整数序列。若 i < j 且 aᵢ > aⱼ,则称 (i, j) 是一个逆序对。求序列中逆序对的个数。

输入格式:第一行一个整数 n;第二行 n 个整数,表示序列。
输出格式:一行一个整数,表示逆序对个数。

数据范围:1 ≤ n ≤ 5×10⁵,1 ≤ aᵢ ≤ 10⁹。答案最大约 n(n−1)/2 ≈ 1.25×10¹¹,需用 64 位整数。

引导性提示(先只看这里)

值到 10⁹,下标放不下;但 n 只有 5×10⁵——能当下标的只有“排名”,不是数值本身。

从左往右扫。读到第 i 个数时,只需要回答一句:前面已经出现过的数里,有多少个严格比它大?

把“某个排名出现了几次”存进树状数组,前缀和就是“不超过某排名的元素个数”。

想看思路了?点开完整思路

离散化。把 a 拷贝一份排序去重,再用 lower_bound 把每个 a[i] 换成它在去重数组里的名次 1..tot。大小关系不变,而值域被压到 n 以内,可以当下标。O(n log n)。

树状数组的含义换成计数。下标是名次,t 里存“这个名次出现了几次”。于是 sum(rk) = 已经出现过的、名次 ≤ rk 的元素个数。

逐位统计。扫到第 i 个数时,前面已经有 i−1 个;其中 ≤ a[i] 的有 sum(rk) 个,剩下 (i−1) − sum(rk) 个都严格大于它,每一个都构成逆序对,累加。

然后把它记进去。add(rk, 1)。先查后改,不能颠倒。

复杂度与数值。离散化 O(n log n),统计 O(n log n),n = 5×10⁵ 时约 2×10⁷ 次操作。ans 用 long long:最坏 1.25×10¹¹。相同元素不会互相构成逆序对,所以查的是 sum(rk)(含等号)。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int N = 500005;
int n, a[N], b[N], t[N];              // t 的下标是"名次"

int lowbit(int x) { return x & -x; }

void add(int i) {                     // 名次 i 的出现次数 +1
    for (; i <= n; i += lowbit(i)) ++t[i];
}

int sum(int i) {                      // 名次 <= i 的元素出现了几个
    int s = 0;
    for (; i > 0; i -= lowbit(i)) s += t[i];
    return s;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    for (int i = 1; i <= n; ++i) { cin >> a[i]; b[i] = a[i]; }

    sort(b + 1, b + n + 1);                          // 离散化:排序
    int tot = unique(b + 1, b + n + 1) - b - 1;      // 去重后值域缩到 1..tot

    long long ans = 0;
    for (int i = 1; i <= n; ++i) {
        int rk = lower_bound(b + 1, b + tot + 1, a[i]) - b;   // a[i] 的名次
        ans += (i - 1) - sum(rk);    // 前面 ≤ 它的有 sum(rk) 个,其余都严格大于它
        add(rk);                     // 再把它的名次记进去
    }
    cout << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
b[i] = a[i]; sort(b + 1, b + n + 1)拷贝一份排序,原数组 a 不能动,后面还要按原顺序扫。
unique(b + 1, b + n + 1) - b - 1去重后返回到新末尾的迭代器,减去首地址 b 再 −1 就是不同值的个数 tot。重复值共用一个名次,正好符合逆序对“严格大于”的语义。
lower_bound(b + 1, b + tot + 1, a[i]) - b找 a[i] 在去重数组里的位置,得到 1..tot 的名次。注意右端是 b + tot + 1,区间左闭右开。
ans += (i - 1) - sum(rk)把“前面有多少个比我大”算出来。sum(rk) 数的是名次 ≤ rk 的,含相等元素。
add(rk)计数型树状数组:只 +1,不传 v。先查后改,自己不会被算进 sum。
i <= n 的循环上界名次只到 tot ≤ n,用 n 当上界完全安全,t 不会越界。
这题最容易错的地方
  • 离散化时忘记去重,直接用 lower_bound 在未去重的数组里查:名次不唯一,重复元素的计数会错 → WA。
  • ans 用 int:答案到 10¹¹,溢出成负数 → WA,小数据全对,大数据全挂。
  • 把 sum(rk) 改成 sum(rk - 1):相等的元素被算成“比它大”,有重复值时答案偏大。
  • 先 add(rk) 再统计:自己也被数进去,每个数贡献少 1 或不稳定 → WA。
  • 数组只开到 10⁵(看 n 是 5×10⁵ 却没改):越界 → RE 或随机 WA。三个数组都要 5×10⁵ 以上。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》0x40 数据结构进阶:树状数组
  • 《算法竞赛入门笔记》树状数组与逆序对一节
  • 洛谷 P3374、P3368、P1908 题解区的高赞实现
47 线段树提高

47 · 线段树

把区间二分到底,每个节点维护自己那一段的信息:build 自底向上,query 命中整段就返回,区间修改靠懒标记停在整段上、要进儿子时才 pushdown。一份代码,换掉 pushup 和合并方式就能做和、最值、最大子段和。
提高预计 80 分钟线段树懒标记区间合并
本节出现的新函数 / 新语法(先看懂再用)

第 46 节的树状数组很快,但它只吃“能相减”的信息:和、异或、计数。一旦题目要区间加并且区间求和,或者要区间最值,那套 12 行就不够了。这一节写一个通用得多的结构:把区间二分到底,每个节点维护自己那一段的整体信息。四十行代码,覆盖比赛里大半的区间问题。

线段树的所有细节都能由一句话推出来:每个节点负责一个区间,节点上的值由两个儿子合并而来。哪个函数不会写,就回到这句话,问自己“我要的是哪一段、它的儿子是谁”。
问题树状数组线段树
单点改 + 前缀和O(log n),12 行O(log n),40 行
区间加 + 单点查O(log n),差分O(log n),懒标记
区间加 + 区间和要两个树状数组推式子直接做,只改 pushup
区间最值 / gcd不支持O(log n)
区间最大子段和不支持O(log n),多维护 3 个量
常数极小,没有递归约 3~5 倍
数组空间n4n

先把区间怎么分钉死。这是线段树唯一不随题目变化的部分,也是 90% 的错位 bug 的来源。根节点管 [1, n]。对一个管 [l, r] 的节点,令 mid = (l + r) / 2(向下取整),把它劈成左儿子 [l, mid] 与右儿子 [mid+1, r],两段长度差不超过 1。一直劈到 l == r,那就是叶子,管一个位置。

n = 5 时的区间划分与节点编号
                      [1,5]                    节点编号 p = 1
                    /       \
              [1,3]           [4,5]                 p = 2, 3
             /     \         /     \
         [1,2]   [3,3]   [4,4]   [5,5]              p = 4, 5, 6, 7
         /   \
      [1,1] [2,2]                                   p = 8, 9

  左儿子 2p,右儿子 2p+1:堆式编号,不用存指针,也不用存区间(l、r 现场推)

编号带来一个副作用:会跳号。n = 5 时最大编号是 9,n = 6 时直接跳到 13。所以数组必须开 4n——按 2n 开会在 n = 6 这种小数据上就踩出去。这个 4 是怎么来的,数学补给里算清楚。

一个询问区间 [ql, qr] 与当前节点 [l, r] 的关系只有三种:完全包含(整段都要,直接返回,不再往下)、完全不相交(根本不会递归进来)、部分相交(两边各自递归)。query 里那几个 if,全部只是在区分这三件事。

接下来四张卡对应线段树的四块骨头:建树、合并、查询、懒标记。前两张是“从下往上”,第三张是“从上往下”,第四张解决“整段修改怎么不往下走”。

新函数拆解 · build(递归建树)
void build(int p, int l, int r);
组成含义 / 格式要求
p节点编号。根固定是 1,左儿子 2p,右儿子 2p+1。
l, r这个节点负责的闭区间。它们跟着递归参数走,不存进数组,省掉 8n 的空间。
if (l == r)叶子:这一段只有一个位置,把 a[l] 直接抄进 s[p],然后 return。
mid = (l + r) >> 1从中间劈开。(l+r)/2 向下取整,所以左段比右段多一个或一样长,两段都不可能为空。
两次递归先建左子树再建右子树,顺序无所谓,反正都在 pushup 之前。
pushup(p)两个儿子都有值了,用它们推出 s[p]。必须写在两次递归之后,写前面就用到 0。
只调一次 build(1, 1, n)每个节点访问一次,总复杂度 O(n),不是 O(n log n)。
数组开 4nn 个位置的树有 2n−1 个真实节点,但堆式编号会跳号,4n 才一定够(数学补给给证明)。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

const int N = 100005;
long long a[N], s[N << 2];             // s[p]:节点 p 管辖区间内所有数的和

void pushup(int p) { s[p] = s[p << 1] + s[p << 1 | 1]; }

void build(int p, int l, int r) {
    if (l == r) { s[p] = a[l]; return; }         // 叶子:直接抄原数
    int mid = (l + r) >> 1;
    build(p << 1, l, mid);                       // 左子树 [l, mid]
    build(p << 1 | 1, mid + 1, r);               // 右子树 [mid+1, r]
    pushup(p);                                   // 儿子都好了,才能推自己
}

int main() {
    int n = 5;
    long long b[6] = {0, 1, 5, 4, 2, 3};
    for (int i = 1; i <= n; ++i) a[i] = b[i];
    build(1, 1, n);
    cout << s[1] << "\n";                        // 1+5+4+2+3 = 15
    cout << s[2] << " " << s[3] << "\n";         // a[1..3]=10, a[4..5]=5
    return 0;
}
注意

build 只在“初始有值”时需要。很多时候(比如从空数列开始追加)可以直接把 s 全清 0,跳过 build。

递归深度是 ⌈log₂n⌉+1,n = 10⁵ 时 18 层,离爆栈差得远。

新函数拆解 · pushup(用两个儿子更新自己)
void pushup(int p) { s[p] = s[p << 1] + s[p << 1 | 1]; }
组成含义 / 格式要求
p << 1左儿子 = 2p。位运算比乘法快,竞赛习惯写法。
p << 1 | 1右儿子 = 2p+1。| 的优先级低于 <<,所以它等于 (p<<1)|1,正确;写成 p << 1 + 1 会被算成 p << 2,直接跑去看错的节点。
s[p] = ...只写“合并”这一步。求和用 +,求最值用 max,求 gcd 用 __gcd。这是整份代码里唯一随题目变化的一行。
调用时机build 的最后一行、update 的最后一行。query 只读不写,不调用。
为什么不需要 l、r合并只用到两个儿子的值,用不上区间信息,所以只有一个参数。
叶子不调用叶子在 build / modify 里直接赋值就 return 了,pushup 只作用于有儿子的节点。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

long long s[100];                        // 假设树已建好,2 是左儿子、3 是右儿子

void pushup(int p) { s[p] = s[p << 1] + s[p << 1 | 1]; }

int main() {
    s[2] = 10; s[3] = 5;                 // 左儿子 10,右儿子 5
    pushup(1);
    cout << s[1] << "\n";                // 15
    // 换成区间最大值,只改这一句:mx[p] = max(mx[p << 1], mx[p << 1 | 1]);
    return 0;
}
注意

pushup 是“儿子 → 自己”的方向,pushdown 是“自己 → 儿子”。两个搞反,答案要么偏小要么停在上一次的状态。

维护和的时候不需要考虑 tag:apply 已经把 s[p] 改对了,pushup 照样成立。

新函数拆解 · query(区间查询)
long long query(int p, int l, int r, int ql, int qr);
组成含义 / 格式要求
ql, qr询问区间,全程不变,递归时不用减,只在判断里用。
ql <= l && r <= qr当前节点整段被包住 → 直接返回 s[p],不再往下。这是 O(log n) 的唯一来源。
if (ql <= mid)左儿子 [l, mid] 与询问区间有交集才进。判断是 ql <= mid,不是 qr <= mid。
if (qr > mid)右儿子同理。这两个 if 互相独立,因为询问区间可以同时伸进两边;写成 else 就会漏掉跨中线的那一半。
res 怎么合并求和用 res += ;求最值用 res = max(res, ...),且 res 要初始化成 −∞。
为什么不会死循环每次递归区间长度至少减半,走到 l == r 时一定被“完全包含”拦住并返回。
复杂度每层最多 2 个节点是“部分相交”,一共 log n 层,所以访问 O(log n) 个节点。
和 tag 的关系query 只读不写,所以不调 pushup;但如果树上有 tag,进入儿子前必须 pushdown。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int n = 5;
long long a[6] = {0, 1, 5, 4, 2, 3}, s[24];

void build(int p, int l, int r) {
    if (l == r) { s[p] = a[l]; return; }
    int mid = (l + r) >> 1;
    build(p << 1, l, mid); build(p << 1 | 1, mid + 1, r);
    s[p] = s[p << 1] + s[p << 1 | 1];
}

long long query(int p, int l, int r, int ql, int qr) {
    if (ql <= l && r <= qr) return s[p];          // 整段被包住
    int mid = (l + r) >> 1;
    long long res = 0;
    if (ql <= mid) res += query(p << 1, l, mid, ql, qr);          // 左半边有交集
    if (qr > mid)  res += query(p << 1 | 1, mid + 1, r, ql, qr);  // 右半边有交集
    return res;
}

int main() {
    build(1, 1, n);
    cout << query(1, 1, n, 2, 4) << "\n";         // 5+4+2 = 11
    cout << query(1, 1, n, 1, 5) << "\n";         // 15
    return 0;
}
注意

线段树查询的区间不会像树状数组那样“相减”,因为每个节点只认自己那一段,不存在前缀和的概念。

只有两个 if 都落空才是 bug:那种情况意味着进入了一个根本不相交的节点,说明上层的 if 写错了。

新函数拆解 · 懒标记 tag 与 pushdown(整段修改怎么不往下走)
void apply(int p, int l, int r, long long v); void pushdown(int p, int l, int r);
组成含义 / 格式要求
tag[p]节点 p 那一段每个数还欠着的加量。有了它,区间加可以停在“整段被覆盖”的节点上,不改到叶子。
apply(p, l, r, v)整段加 v:s[p] += v * (r - l + 1)(和增加 v 乘元素个数),同时 tag[p] += v。这两句必须一起做。
pushdown(p, l, r)把 tag[p] 分给两个儿子(对儿子各调一次 apply),然后把 tag[p] = 0。
什么时候调只要准备往儿子走,就必须先 pushdown:update 的递归前、query 的递归前,一处都不能漏。
if (!tag[p]) return;没有欠账立刻返回。这一行决定了常数大小,不能省。
为什么只推一层就够懒标记是“按需结算”:只结算真正走过的那条路径,一条路径 O(log n) 层,总复杂度不变。
为什么 s[p] 总是对的apply 同时改了 s[p] 和 tag[p],所以节点自己的和永远准确;错的只是“儿子还没结账”。
有乘法标记时像 P3373 那种“区间乘 + 区间加”,pushdown 必须先乘后加,顺序反了全错。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

long long s[100], tag[100];              // 设 1 号管 [1,5],2、3 号是它的儿子

void apply(int p, int l, int r, long long v) {
    s[p] += v * (r - l + 1);             // 和增加 v 乘元素个数
    tag[p] += v;                         // 欠账记上
}

void pushdown(int p, int l, int r) {
    if (!tag[p]) return;                 // 没欠账,走人
    int mid = (l + r) >> 1;
    apply(p << 1, l, mid, tag[p]);       // 左儿子 [l, mid] 结账
    apply(p << 1 | 1, mid + 1, r, tag[p]);   // 右儿子 [mid+1, r] 结账
    tag[p] = 0;                          // 账分完了,自己清零
}

int main() {
    s[1] = 15; s[2] = 10; s[3] = 5;      // 和分别是 15、10、5
    apply(1, 1, 5, 2);                   // [1,5] 整段加 2
    cout << s[1] << " " << s[2] << "\n"; // 25 10:儿子还没结账
    pushdown(1, 1, 5);
    cout << s[2] << " " << s[3] << "\n"; // 16 9:左段 3 个数各加 2,右段 2 个数各加 2
    return 0;
}
注意

pushup 与 pushdown 是一对反向操作:前者“儿子 → 自己”,后者“自己 → 儿子”。写反了不报错,答案会停在旧值上。

apply 里 v * (r - l + 1) 的乘号不能漏:漏了就是把“整段”当成“一个数”,n 越大错得越离谱,而样例常常只有很小的数,容易看不出来。

模板题把上面四块骨头一次串起来:区间加、区间求和。这是线段树最常见的形态,其他变体基本只是换 pushup 那一行和 query 的合并方式。

例题 · 【模板】线段树 1洛谷 P3372普及/提高-去洛谷做这题 ↗

给定一个长度为 n 的数列,需要进行 m 次操作,操作有两种:

1 x y k:把区间 [x, y] 内每个数加上 k;2 x y:输出区间 [x, y] 内每个数的和。

数据范围:1 ≤ n, m ≤ 10⁵,保证任意时刻数列中的数与答案在 64 位整数范围内。

输入格式:第一行两个整数 n、m;第二行 n 个整数;接下来 m 行,每行为上述两种操作之一。
输出格式:对每个操作 2,输出一行一个整数。

输入: 5 5 1 5 4 2 3 2 2 4 1 2 3 2 2 3 4 1 1 5 1 2 1 4 输出: 11 8 20
三件事:区间划分、懒标记、结算时机

① 朴素写法为什么死。区间加如果老老实实改到每个叶子,一次操作 O(n),m = 10⁵ 时 10¹⁰ 次加法。树状数组呢?区间加 + 区间和要用两个树状数组推公式 Σdᵢ·(x−i+1),容易推错,而且换个询问(最值)就完全失效。

② 懒标记是“记账”。当某个节点的区间整段被修改范围包住时,就没有必要往下走了:把这笔加量记在 tag[p] 上,同时把 s[p] 改对。一次区间加只会停在 O(log n) 个这样的节点上,剩下的祖先用 pushup 重新算一遍。

③ 什么时候必须结算。只有一件事会让“欠账”变成错误:要往儿子走。update 递归前、query 递归前,都必须 pushdown,把父亲的 tag 分给两个儿子。如果只在 update 里 pushdown 而 query 忘了——这题的样例就会从 20 变成 16(见下面的复现)。

④ 区间查询怎么动手。询问 [2,4] 从根 [1,5] 开始:整段没被包住,切两半;左边 [1,3] 与 [2,4] 相交,进去继续切;右边 [4,5] 也相交,进去。最后整段被命中的节点是 [2,2]、[3,3]、[4,4],三块拼起来正好是 [2,4]。任何区间都能被切成 O(log n) 个这样的整段,这就是它快的原因。

⑤ 规模估算。n = 10⁵ 时树高 18 层。实测:单点查询访问 17 个节点,随机区间平均 43 个、最多 56 个。m = 10⁵ 次操作约 4×10⁶ 到 6×10⁶ 次节点访问,1 秒内轻松完成。

⑥ 数值。用 long long:n = 10⁵、单个数到 10⁹ 时,一整个区间的和能到 10¹⁴,int 的上限 2.1×10⁹ 连零头都不够。s、tag、加法参数、返回值全部 long long,一个都别漏。

完整可编译代码(C++17)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 100005;
int n, m;
ll a[N], s[N << 2], tag[N << 2];       // s:区间和;tag:还没下推的加量

void pushup(int p) { s[p] = s[p << 1] + s[p << 1 | 1]; }

void build(int p, int l, int r) {
    if (l == r) { s[p] = a[l]; return; }        // 叶子
    int mid = (l + r) >> 1;
    build(p << 1, l, mid);
    build(p << 1 | 1, mid + 1, r);
    pushup(p);
}

void apply(int p, int l, int r, ll v) {         // 给整段 [l,r] 加 v
    s[p] += v * (r - l + 1);                    // 和增加 v 乘元素个数
    tag[p] += v;                                // 记账,等以后要用儿子时再结
}

void pushdown(int p, int l, int r) {            // 把欠账分给两个儿子
    if (!tag[p]) return;                        // 没欠账,省掉一切
    int mid = (l + r) >> 1;
    apply(p << 1, l, mid, tag[p]);
    apply(p << 1 | 1, mid + 1, r, tag[p]);
    tag[p] = 0;                                 // 账已分完,自己清零
}

void update(int p, int l, int r, int ql, int qr, ll v) {
    if (ql <= l && r <= qr) { apply(p, l, r, v); return; }   // 整段被包住就停手
    pushdown(p, l, r);                          // 要进儿子了,先结账
    int mid = (l + r) >> 1;
    if (ql <= mid) update(p << 1, l, mid, ql, qr, v);
    if (qr > mid)  update(p << 1 | 1, mid + 1, r, ql, qr, v);
    pushup(p);                                  // 儿子变了,自己重算
}

ll query(int p, int l, int r, int ql, int qr) {
    if (ql <= l && r <= qr) return s[p];        // 整段都要,直接返回
    pushdown(p, l, r);                          // 漏掉这一行,样例就会错
    int mid = (l + r) >> 1;
    ll res = 0;
    if (ql <= mid) res += query(p << 1, l, mid, ql, qr);
    if (qr > mid)  res += query(p << 1 | 1, mid + 1, r, ql, qr);
    return res;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> m;
    for (int i = 1; i <= n; ++i) cin >> a[i];
    build(1, 1, n);

    while (m--) {
        int op, x, y;
        ll k;
        cin >> op >> x >> y;
        if (op == 1) { cin >> k; update(1, 1, n, x, y, k); }
        else cout << query(1, 1, n, x, y) << "\n";
    }
    return 0;
}
逐行拆解
代码片段含义与理由
s[N << 2]N 左移 2 位 = N×4,就是 4n。按 2n 开在 n = 6 时就已越界(最大编号 13 > 12)。
if (l == r) { s[p] = a[l]; return; }唯一的递归出口。p 与 l、r 一起才唯一确定一个节点,缺一个都不对。
apply 里的 v * (r - l + 1)整段加 v,和增加 v 乘元素个数。漏掉乘号,n 越大错越多,小样例看不出来。
tag[p] += v为什么是 += 而不是 =:同一个节点可能被多次整段加,欠账要累加。
if (!tag[p]) return;pushdown 的第一行。绝大多数调用时 tag 是 0,这一行省掉大部分无谓操作。
pushdown 在 update 里的位置必须在两次递归之前。写在之后,儿子已经算完了才结账,等于没结。
if (ql <= mid) / if (qr > mid)两个独立判断。询问区间跨中线时两边都要进,写成 else if 直接漏一半。
pushup(p) 在 update 末尾递归回来,儿子们的 s 都变了,必须重算 s[p]。漏掉它,祖先全部停在旧值。
query 里的 pushdown最容易漏的一行:只在 update 写了 pushdown,查询时儿子少算了父亲那笔账 → WA。

模板题只用了“和”。真实的题目往往是别的信息,这时要改的其实只有三处:pushup 的合并方式、query 里 res 的合并方式与初值、tag 的含义与 apply 的写法。下面换一个最常见的:区间最大值,配单点修改。

换成区间最大值:单点修改 + 区间查询(不需要 tag)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 100005;
const ll NEG = -(1LL << 60);           // 最值查询的中性元:比任何答案都小
ll mx[N << 2];

void pushup(int p) { mx[p] = max(mx[p << 1], mx[p << 1 | 1]); }   // 只换这一句

void modify(int p, int l, int r, int pos, ll v) {                 // 单点赋值
    if (l == r) { mx[p] = v; return; }
    int mid = (l + r) >> 1;
    if (pos <= mid) modify(p << 1, l, mid, pos, v);
    else            modify(p << 1 | 1, mid + 1, r, pos, v);
    pushup(p);                        // 儿子变了,自己跟着变
}

ll queryMax(int p, int l, int r, int ql, int qr) {
    if (ql <= l && r <= qr) return mx[p];
    int mid = (l + r) >> 1;
    ll res = NEG;
    if (ql <= mid) res = max(res, queryMax(p << 1, l, mid, ql, qr));
    if (qr > mid)  res = max(res, queryMax(p << 1 | 1, mid + 1, r, ql, qr));
    return res;
}

int main() {
    int n = 6;
    ll a[7] = {0, 3, 1, 4, 1, 5, 9};
    for (int i = 1; i <= n; ++i) modify(1, 1, n, i, a[i]);         // 逐个位置建树
    cout << queryMax(1, 1, n, 2, 4) << "\n";                       // max(1,4,1) = 4
    modify(1, 1, n, 3, 8);                                        // a[3] 改成 8
    cout << queryMax(1, 1, n, 2, 4) << "\n";                       // max(1,8,1) = 8
    return 0;
}

注意这份代码里没有 tag、没有 pushdown。因为修改是单点的,一路走到叶子,不存在“整段还没结算”的情况。单点修改的题,懒标记整套都可以删掉。反过来,只要支持区间修改,就必须有 tag;而 tag 的含义决定了 apply 怎么写:区间加是 s[p] += v*(len)、区间赋值是 s[p] = v*(len),区间取反是 s[p] = len − s[p]。

什么时候该写线段树

三个信号同时出现就该写它:① 有修改也有询问,且修改和询问都作用在区间上;② 信息可以“合并”(和、最值、gcd、最大子段和、出现次数);③ n、m 到 10⁵,暴力撑不住。

常见变体只要换三处:

题目要什么pushup 写法query 的合并tag 含义
区间加 + 区间和s = L + Rres += ...加量 v
区间加 + 区间最值mx = max(L, R)res = max(...)加量 v
区间赋值 + 区间和s = L + Rres += ...赋值成 v
区间取反 + 区间和s = L + Rres += ...翻转次数
区间最大子段和sum / pre / suf / best 四量合并四量合并加量 v(best 也加 len·v)

还有几个名字要认得。把下标当成“值域”建的树叫权值线段树,能在 O(log n) 找第 k 小;只建用到的那几个节点叫动态开点;两棵树按节点合并叫线段树合并。这三样都是本节代码加一两句话就变形出来的,不必重新学。

常数提示。递归线段树比树状数组慢 3~5 倍。n、m 都是 10⁵ 时完全够用;到了 10⁶ 或时限 0.5 秒,才需要考虑非递归(zkw)写法。

这一节的 7 个坑,按症状记

数组按 n 或 2n 开:n = 6 时最大编号已是 13,n = 10⁵ 时是 262141 → RE(越界写坏别的变量) 或随机 WA。一律开 4n,别算。

只在 update 里 pushdown,query 里忘了 → WA。症状:先加后查的数据全错,而这题样例最后一个答案会从 20 变成 16。

update 里把 if (qr > mid) 写成 else:询问区间跨中线时只进了一边 → WA,小数据可能蒙对。

apply 里写成 s[p] += v,漏了乘 (r−l+1):把“整段”当成“一个数” → WA,区间越长错得越多。

把 p << 1 | 1 写成 p << 1 + 1:因为 + 优先级高于 <<,它等于 p<<2,跑到完全不相干的节点 → WA 或 RE。

s 用 int:区间和到 10¹⁴ → 溢出成负数 → WA,样例全过(数太小)。

多组数据不清 tag 和 s → 第二组开始全错 → WA。memset 两个数组,或者把 4n 个位置清 0。

自己动手复现

关掉页面,把 P3372 的六个函数(pushup / build / apply / pushdown / update / query)默写一遍,提交确认样例输出 11、8、20。

改一改①:删掉 query 里那一行 pushdown(p, l, r),再跑样例。前两个答案仍是 11 和 8,第三个会变成 16(正确是 20)——少掉的 4 正是节点 1 欠下的账。

改一改②:把 s 改成 int,自己造一组“n = 10、每个数 10⁹、全区间求和”的数据,看输出为什么是负数。

改一改③:把 query 里第二个 if (qr > mid) 改成 else,查询 [2, 4] 会返回 5(正确是 11):跨中线的右半边 [3,3]、[4,4] 全被跳过了。

数学补给 · 4n 是怎么来的,O(log n) 又是怎么来的

先数节点。n 个叶子,每个内部节点恰好有两个儿子,所以内部节点 n−1 个,整棵树 2n−1 个节点。如果按“节点个数”开数组,2n 就够。

但堆式编号会跳号。编号是 2p、2p+1,当 n 不是 2 的幂时最后一层不满,空出来的位置仍然占着编号。实测最大编号:

n最大编号2n4n
61312(不够)24
102520(不够)40
100253200(不够)400
100000262141200000(不够)400000

上界证明。设 2^(k−1) < n ≤ 2^k,则树高最多 k+1 层,编号最大不超过 2^(k+1)−1。又因为 n > 2^(k−1),所以 2^(k+1) = 4·2^(k−1) < 4n。即最大编号一定小于 4n,这就是所有人写 N << 2 的原因。

再算高度。每递归一层,区间长度至少减半,所以层数 = ⌈log₂n⌉ + 1。n = 10⁵ 时 ⌈log₂10⁵⌉ = 17,共 18 层。递归深度 18,爆栈是不可能的事。

最后算查询为什么是 O(log n)。关键观察:同一层里,最多只有 2 个节点是“部分相交”的。因为部分相交意味着节点区间里有询问区间的端点 ql 或 qr;而同一层的节点区间互不相交,所以含 ql 的最多一个,含 qr 的最多一个,合起来最多两个。每层 2 个、共 log n 层,于是访问节点数不超过 4⌈log₂n⌉。

实测对得上:n = 10⁵ 时单点查询访问 17 个节点(正好等于高度),随机区间平均 43 个、最坏 56 个,而理论上界 4×17 = 68。m = 10⁵ 次操作因此只有几百万次节点访问,1 秒的时限绰绰有余。

举一反三 · 想清楚这些再往下走
  1. 把区间和换成区间异或和,tag 还能记成“加 v”吗?如果操作是“区间取反”,apply 该怎么写?
  2. 如果同时有“区间加”和“区间赋值”两种标记,一个 tag 够不够?pushdown 里的顺序该怎么定?(提示:赋值会把之前的加量全部作废)
  3. 求区间最大子段和时,一个节点要维护几个量?为什么只存 best 不够,还必须存 sum、前缀最大值、后缀最大值?
  4. n = 10⁶ 时 4n 个 long long 要多少内存?如果内存限制 256 MB,还能开几棵树?这时该怎么压空间?
  5. 单点修改的题为什么可以把 tag 和 pushdown 整套删掉?如果保留它们,会多出多少无用的操作?
  6. 树状数组和线段树都能做“单点改 + 区间和”。给你一道题,你用哪两条依据决定选谁?
自测:能全部回答再进下一节
  • 能默写 build / pushup / update / query / pushdown 五个函数,并说出每个的递归出口。
  • 能画出 n = 5 的线段树,写出每个节点管辖的区间和编号。
  • 能解释为什么数组要开 4n,并说出 n = 6 时最大编号是 13。
  • 能说清 pushdown 必须在哪两个地方调用,以及漏掉 query 里的那次会错成什么样。
  • 能解释“每层最多 2 个部分相交节点”,从而说明区间查询是 O(log n)。
  • 能把区间和改成区间最大值,指出要改的三处。
挑战题 · 最大数
洛谷 P1198普及/提高-去洛谷做这题 ↗

维护一个数列,初始为空,需要支持 m 次操作:

A n:把 n 加上 t 之后对固定常数 D 取模(t 是最近一次询问的答案,若还没问过则 t = 0),把结果追加到数列末尾。Q L:询问当前数列中最后 L 个数的最大值,输出它,并把它作为新的 t。

输入格式:第一行两个整数 m、D;接下来 m 行,每行一个字符(A 或 Q)和一个整数。
输出格式:对每个 Q 操作,输出一行一个整数。

数据范围:1 ≤ m ≤ 2×10⁵,1 ≤ D ≤ 2×10⁹,保证 L 不超过当前数列长度;每次给出的整数在 64 位整数范围内,可能为负。

引导性提示(先只看这里)

插入只会发生在末尾,位置一旦确定就再也不会变——所以“最后 L 个”永远是数组里连续的一段。

既然最多插入 m 次,那位置最多只有 m 个。能不能先开一棵长度为 m 的空树,边插边改?

这题只需要两个操作:单点赋值、区间最大值。想想为什么一个 tag 都不需要。

想看思路了?点开完整思路

离线看出上界。共 m 次操作,每次最多插入一个数,所以数列长度永远 ≤ m。于是可以直接建一棵管 [1, m] 的线段树,初始全是 0,等着被填。

在线维护。用 tail 记当前长度。A n:++tail,把 ((n + t) % D + D) % D 单点赋值到 tail 位置;Q L:查询 [tail − L + 1, tail] 的最大值,更新 t 并输出。

为什么正确。插入位置的顺序就是数列顺序;已经插入的部分之后不再改动,而询问的区间一定落在 [1, tail] 内,全是已插入的有效值,树里的 0 初值永远不会被问到。

为什么不需要 tag。修改是单点的,一路递归到叶子,中间没有任何“整段还没结算”的状态。把 pushdown 和 tag 全删掉,代码更短也更快。

易错的数值细节。n + t 可能超过 int(D 到 2×10⁹),全部用 long long;取模后可能为负,要 ((n + t) % D + D) % D 才能落回 [0, D)。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 200005;
int cap;                              // 树的长度 = 操作总数(最多插入这么多次)
ll D, mx[N << 2];                     // 插入值都落在 [0, D),所以 0 是安全的初值

void modify(int p, int l, int r, int pos, ll v) {        // 单点赋值
    if (l == r) { mx[p] = v; return; }
    int mid = (l + r) >> 1;
    if (pos <= mid) modify(p << 1, l, mid, pos, v);
    else            modify(p << 1 | 1, mid + 1, r, pos, v);
    mx[p] = max(mx[p << 1], mx[p << 1 | 1]);             // 儿子变了,自己跟着变
}

ll queryMax(int p, int l, int r, int ql, int qr) {
    if (ql <= l && r <= qr) return mx[p];                // 整段被包住
    int mid = (l + r) >> 1;
    ll res = 0;                                          // 非负数,用 0 当"没有"
    if (ql <= mid) res = max(res, queryMax(p << 1, l, mid, ql, qr));
    if (qr > mid)  res = max(res, queryMax(p << 1 | 1, mid + 1, r, ql, qr));
    return res;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int m;
    cin >> m >> D;
    cap = m;                                         // 最多插 m 次,树不用更长
    int tail = 0;                                    // 当前数列长度
    ll t = 0;

    while (m--) {
        char op;
        ll n;
        cin >> op >> n;
        if (op == 'A') {
            ll v = ((n + t) % D + D) % D;             // 先加 t,再取模,负数也落进 [0,D)
            modify(1, 1, cap, ++tail, v);             // 追加到最后一个位置
        } else {
            int L = (int)n;
            t = queryMax(1, 1, cap, tail - L + 1, tail);   // 最后 L 个
            cout << t << "\n";
        }
    }
    return 0;
}
关键片段拆解
片段作用
cap = m把操作总数存下来当树的长度。后面 while (m--) 会把 m 用掉,所以必须提前保存。
mx[N << 2]N = 2×10⁵,4N = 8×10⁵ 个 long long,约 6.4 MB,安全。
((n + t) % D + D) % DC++ 的 % 对负数取模结果仍为负,加一个 D 再取模才能保证落在 [0, D)。
modify(1, 1, cap, ++tail, v)先 ++tail 再传,参数求值顺序在这里无歧义(只有一个自增)。
mx[p] = max(mx[p << 1], mx[p << 1 | 1])pushup 直接写在 modify 末尾,省一个函数调用。换信息只改这一句。
queryMax(1, 1, cap, tail - L + 1, tail)末尾 L 个 = 区间 [tail−L+1, tail]。保证 L ≤ tail,所以不会越界。
ll res = 0所有插入值都 ≥ 0,用 0 当“没有”是安全的;若允许负数,必须用 −∞ 起头。
这题最容易错的地方
  • 漏掉 cap = m,直接拿被 while (m--) 消耗掉的 m 当右端点 → 树的范围变成 0,行为完全错乱。
  • 取模写成 (n + t) % D:n 可能为负,结果也为负,插进去的值破坏“非负”前提 → WA。
  • Q 操作只 cout 忘了把答案写回 t:之后的每个 A 都少加了上一次的答案 → 从第二个 A 起全错。
  • 把查询区间写成 [tail − L, tail]:多算了前一个数,答案偏大。
  • 数组开成 mx[N] 而不是 mx[N << 2]:堆式编号最大到 4m 附近 → 越界 → RE。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》0x43 线段树(懒标记、标记下传顺序)
  • 《算法竞赛试炼场:洛谷300题精析》线段树专题
  • 洛谷 P3372、P1198 题解区的高赞实现
48 单调栈与单调队列提高-

48 · 单调栈与单调队列

一句话概括这一节:把不可能成为答案的元素当场删掉。四个用法串起来——单调栈求下一个更大元素、左右边界定柱状图最大矩形、单调队列求滑动窗口最值、单调队列把 DP 的 O(nR) 转移压成 O(1)。
提高-预计 65 分钟单调栈滑动窗口最值贡献法
本节出现的新函数 / 新语法(先看懂再用)

单调栈和单调队列解决同一类问题:在滑动窗口里随时拿到最值。手法只有一句——把“永远不可能成为答案”的元素当场删掉。删得干净,每个元素只进一次、出一次,均摊 O(n)。

单调栈维护“还没等到答案的下标”;单调队列维护“当前窗口里还可能成为最值的下标”。两者都只做两件事:入队前弹掉比自己差的,入队后弹掉过期的。
任务结构弹出条件均摊代价
右边第一个更大元素单调栈,栈内值不增栈顶值 < 当前值就弹O(n)
柱状图中最大矩形单调栈,左右各跑一遍栈顶值 ≥ 当前值就弹O(n)
长度 k 的窗口最值单调队列,队内值单调队尾更差就弹 + 队头过期就弹O(n)
f[i] = a[i] + max f[j]单调队列,窗口 [i−R, i−L]队尾 f 更小就弹 + 队头出窗O(n)

四段代码是同一份骨架:while 弹队尾 → push 当前 → while 弹队头 → 用队头更新答案。真正的差别只有两处:弹队尾用 < 还是 ≤,以及队头什么时候算过期。这两处写错,样例照样过,随机数据一片 WA。

例题 · 【模板】单调栈洛谷 P5788普及/提高-去洛谷做这题 ↗

给出项数为 n 的整数数列 a1, a2, …, an。定义 f(i) 为数列中第 i 个元素之后第一个大于 ai 的元素的下标;若不存在这样的元素,则 f(i) = 0。

输入:第一行一个整数 n(1 ≤ n ≤ 3×106);第二行 n 个整数 a1..n(1 ≤ ai ≤ 109)。

输出:一行 n 个整数 f(1), f(2), …, f(n),相邻两数之间用一个空格隔开。

输入: 5 1 4 2 3 5 输出: 2 5 4 5 0

写代码前先把两个工具拆开。它们都很短,但每一条格式要求背后都对应着一类 WA。

新函数拆解 · deque(双端队列)
deque<int> q; q.push_back(x); q.pop_back(); q.push_front(x); q.pop_front(); q.front(); q.back(); q.empty(); q.size();
组成含义 / 格式要求
<deque><bits/stdc++.h> 已包含。声明写成 deque<int> q; 或 deque<pair<int,int>> q;。
push_back / pop_back队尾进、队尾出。单调队列用它们维护“值单调”这一侧。
push_front / pop_front队头进出。这是它和 stack 的唯一区别:两端都能动,所以能弹掉过期元素。
front() / back()O(1) 取两端。单调队列的队头永远是当前窗口的最值下标。
必须存下标存值就无法回答“这个元素还在不在窗口里”。窗口滑动时只靠下标判断过期。
q[i] 随机访问deque 支持 O(1) 下标访问,但单调队列只用两端,用 q[i] 遍历反而慢。
底层结构分段连续存储,每段定长;两端增删都是 O(1),比 vector 多一层指针,常数略大。
什么时候用手写数组窗口长度和值域都固定时,用 int q[N], head, tail 更快(无函数调用、无内存分配)。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int main() {
    int a[] = {5, 3, 8, 1};        // 下标 0..3
    deque<int> q;                  // 只存下标,值从队头到队尾单调递增

    for (int i = 0; i < 4; ++i) {
        while (!q.empty() && a[q.back()] >= a[i]) q.pop_back();  // 队尾更差的永久退休
        q.push_back(i);
        cout << a[q.front()] << " ";   // 队头就是当前最小值:5 3 3 1
    }
    cout << "\n";
    return 0;
}
注意

在空的 deque 上调用 front() / back() 是未定义行为,通常读到垃圾值。每一处 front() 之前都要想清楚“它会不会是空的”。

deque 虽然支持 q[i],但它的迭代器比 vector 重;频繁按下标遍历时用 vector。

本节所有题目都可以用 deque 实现,熟练后再换成手写数组压常数。

新函数拆解 · 数组模拟栈:stk[++top]
int stk[N], top = 0; stk[++top] = i; // 入栈 int t = stk[top]; // 取栈顶 --top; // 出栈 if (top == 0) { /* 空 */ }
组成含义 / 格式要求
top = 0 表示空栈元素存在 stk[1..top],top 同时就是栈的大小。stk[0] 永远空着,可以当值为 0 的哨兵。
stk[++top] = i先加后写:第一个元素落在 stk[1]。写成 stk[top++] 会让下标从 0 开始,后续判空要小心。
a[stk[top]]栈里存下标,随时能取回原值、原位置。存值就丢了位置信息,答案要求下标时直接报废。
--top出栈只减指针,O(1),不动任何内存。
比 std::stack 快 2~3 倍std::stack 的 top()、push()、pop() 是三次函数调用,且无法按下标回溯;3×106 的题手写数组明显更快。
栈必须是全局或 staticn = 3×106 时栈数组 12 MB,开在 main 里会让栈帧过大直接爆栈;开成全局变量(在 BSS 段)最稳。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 3000005;
int stk[MAXN], top = 0;              // 全局:3e6 个 int 进静态区,不占调用栈

int main() {
    int a[] = {0, 5, 3, 8, 1};       // 1 基下标,a[0] 空着不用
    int n = 4;
    for (int i = 1; i <= n; ++i) {
        while (top > 0 && a[stk[top]] < a[i]) --top;   // 弹掉挡不住当前元素的
        stk[++top] = i;                                // 自己进栈
        cout << "i=" << i << "  栈内值:";
        for (int k = 1; k <= top; ++k) cout << a[stk[k]] << " ";
        cout << "\n";
    }
    return 0;
}

/* 输出(栈内值始终不增):
i=1  栈内值:5
i=2  栈内值:5 3
i=3  栈内值:8
i=4  栈内值:8 1            */
注意

判空必须写 top > 0。少了它,空栈时读 stk[0](值恒为 0)可能侥幸不崩,但逻辑已经错了——这类“不崩的错”最难查。

数组大小按题目上限 +5 开,n ≤ 3×106 就开 3000005。开小一个数量级只会得到 RE,不会给你提示。

例题怎么想:被弹掉的元素再也用不上

朴素做法:对每个 i 向后扫,找到第一个更大的位置。n = 3×106 时最坏 9×1012 次比较,按每秒 108 次算要二十多个小时。瓶颈不是“找得慢”,而是重复扫描。

关键是一条支配关系:若 i < j 且 ai ≤ aj,那么对任何 k < i,j 都比 i 更近、且不小于 ai,所以 i 永远不可能是 k 的答案——i 可以从候选集合里删掉。

按这条规则删下去,剩下的候选永远是一串“下标递增、值不增”的位置。新元素 ai 一到,值比它小的候选全部当场确定答案 i(i 确实是它们右边第一个更大的位置),然后 i 自己进候选集合。

“只在一端增删、还要保持单调”——这就是栈。新元素从栈顶开始弹,弹干净再压入,栈里值自然不增。

核心 4 行
for (int i = 1; i <= n; ++i) {
    while (top > 0 && a[stk[top]] < a[i]) { ans[stk[top]] = i; --top; }
    stk[++top] = i;
}

为什么总复杂度是 O(n):每个下标进栈恰好 1 次、出栈至多 1 次,while 里的比较总次数 ≤ 2n。单次 while 最坏能弹 n 次,但那是把别人先前存下的“入栈次数”一次花掉;摊到全部 n 个元素上就是常数。

两处细节决定对错:弹的条件用 < 还是 ≤——题目要“严格大于”,值相等的候选必须留着,所以用 <;栈里存下标而不是值——答案要位置,且重复值无法区分。

完整代码
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 3000005;

int n, a[MAXN], ans[MAXN];
int stk[MAXN], top = 0;          // 手写栈:stk[1..top] 存下标,top = 0 是空

inline int readInt() {           // 3×10^6 个数,逐字符读比 cin 快 3 倍以上
    int x = 0; char c = getchar();
    while (c < '0' || c > '9') c = getchar();
    while (c >= '0' && c <= '9') { x = x * 10 + (c - '0'); c = getchar(); }
    return x;
}

int main() {
    n = readInt();
    for (int i = 1; i <= n; ++i) a[i] = readInt();

    for (int i = 1; i <= n; ++i) {
        // 栈里每个下标都还没等到答案,它们的值从栈底到栈顶单调不增
        while (top > 0 && a[stk[top]] < a[i]) {
            ans[stk[top]] = i;   // i 是它右边第一个更大的位置,答案当场定下
            --top;               // 它再也不会被用到,出栈
        }
        stk[++top] = i;          // 自己进栈,继续等右边
    }
    // 结束时栈里剩下的下标右边没有更大的数,ans 保持 0

    for (int i = 1; i <= n; ++i) printf("%d ", ans[i]);
    printf("\n");
    return 0;
}
逐行拆解
代码片段含义与理由
const int MAXN = 3000005;题给 n ≤ 3×106,多开 5 个位置防越界。开成 1e6 只会得到 RE。
int stk[MAXN], top = 0;全局数组放在静态区,不占 8 MB 的调用栈;top = 0 表示空栈,元素从 stk[1] 开始存。
while (c < '0' || c > '9') c = getchar();跳过空格与换行。本题 ai ≥ 1,可以不管负号。
x = x * 10 + (c - '0');逐位累加。手写快读的本质就是自己解析数字,绕开 cin 的格式检查。
while (top > 0 && a[stk[top]] < a[i])弹的边界是“严格小于”:等于 a[i] 的候选不能弹,因为等于不算“大于”。前半句 top > 0 是判空,少了它会读 stk[0]。
ans[stk[top]] = i;栈顶那个下标等的就是现在。注意先记答案再 --top,顺序反了就记错了人。
stk[++top] = i;当前元素必定进栈:它自己也要等右边更大的数。先 ++ 后赋值,保证元素落在 stk[1..top]。
printf("%d ", ans[i]);每个答案后面跟一个空格。题目不严格卡行末空格,但 3×106 次 cout 会慢一截。

第一件事是把“下一个更大”换成“左右第一个更矮”,问题就从模板题变成一道真正的模型题:柱状图中最大的矩形。

n 根宽 1、高 hi 的柱子并排。结论是:最大矩形总可以“往上抬”到顶住某根柱子的顶端,所以它一定是以某个 hi 为高的矩形。于是枚举 i,求以 hi 为高时能伸多宽——答案是左右两边各自“第一个比 hi 矮”的位置之间。

设 Li、Ri 为左右第一个比 hi 矮的下标(没有就是 0 与 n+1),则面积 = hi × (Ri − Li − 1)。求 L、R 就是把上一题的不等号反过来,各跑一遍单调栈。

柱状图最大矩形(两次单调栈)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int main() {
    int n;
    cin >> n;
    vector<int> h(n + 2, 0);                 // h[0] 与 h[n+1] 是高度 0 的哨兵
    for (int i = 1; i <= n; ++i) cin >> h[i];

    vector<int> L(n + 2), R(n + 2), stk;
    stk.reserve(n + 2);

    // 左边界:i 左边第一个比 h[i] 矮的位置
    for (int i = 1; i <= n; ++i) {
        while (!stk.empty() && h[stk.back()] >= h[i]) stk.pop_back();
        L[i] = stk.empty() ? 0 : stk.back();
        stk.push_back(i);
    }
    stk.clear();
    // 右边界:i 右边第一个比 h[i] 矮的位置
    for (int i = n; i >= 1; --i) {
        while (!stk.empty() && h[stk.back()] >= h[i]) stk.pop_back();
        R[i] = stk.empty() ? n + 1 : stk.back();
        stk.push_back(i);
    }

    ll best = 0;
    for (int i = 1; i <= n; ++i)
        best = max(best, (ll)h[i] * (R[i] - L[i] - 1));
    cout << best << "\n";
    return 0;
}
为什么边界这么求
代码片段含义与理由
vector<int> h(n + 2, 0);首尾各留一个高度 0 的哨兵。任何真实柱子都比它高,于是“左边没有更矮的”这种情况由 0 号位兜住,代码里少一个分支。
h[stk.back()] >= h[i]弹掉所有“不比 h[i] 高”的柱子并保留等号:等高的柱子可以被同一个矩形一起覆盖,让最靠左的那根算全宽度就够了,最大值不会漏。
L[i] = stk.empty() ? 0 : stk.back();弹干净后栈顶就是左边第一个更矮的位置;栈空说明左边全是高个子,左边界顶到 0。
for (int i = n; i >= 1; --i)右边界完全对称,只是方向反过来。两遍共用一个栈,中间必须 stk.clear(),否则上一遍的残留会污染结果。
(ll)h[i] * (R[i] - L[i] - 1)高度到 109、宽度到 105,乘积到 1014,必须 long long。这是本题最隐蔽的 WA。
R[i] - L[i] - 1减 1 是因为 L、R 都是“更矮”的位置,不参与矩形。写成 R−L 会把矩形撑到矮柱子上,答案偏大。
一遍扫完的写法(省掉一个数组)

把 h[0] = h[n+1] = 0 当哨兵,令 i 从 0 扫到 n+1:只要 h[栈顶] > h[i] 就弹出栈顶 p,此时栈顶(弹完后的新栈顶)就是 p 的左边界,i 是右边界,直接算面积 h[p] × (i − 栈顶 − 1)。

每个柱子只在被弹出时结算一次,所以只用一个栈、一个变量就能出答案,常数比两遍扫描小一半。

二维版本是“每行当一次柱状图”:先对每一行求出“以这一行为底、向上连续 1 的个数”,再对每行跑一次上面的算法,总复杂度 O(nm)。洛谷上的玉蟾宫(P4147)就是这个模型。

单调队列 = 单调栈 + 队头能出。窗口 [i−k+1, i] 右移一格,左边出去一个、右边进来一个。队尾负责“进”时弹掉更差的,队头负责“出”时弹掉过期的。

长度 k 的窗口最小值
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, k;
    cin >> n >> k;
    vector<int> a(n);
    for (int i = 0; i < n; ++i) cin >> a[i];

    deque<int> q;                    // 存下标;对应值从队头到队尾单调递增
    for (int i = 0; i < n; ++i) {
        while (!q.empty() && a[q.back()] >= a[i]) q.pop_back();  // ① 队尾更差的永久退休
        q.push_back(i);
        while (q.front() <= i - k) q.pop_front();                // ② 滑出窗口的从队头退休
        if (i >= k - 1) cout << a[q.front()] << " ";             // ③ 窗口满 k 个才有答案
    }
    cout << "\n";
    return 0;
}
三步各管一件事
代码片段含义与理由
a[q.back()] >= a[i]① 队尾的值不小于新元素,说明队尾既不更小、又比新元素先过期,永远轮不到它当最小值,直接弹。等号也弹:留下最新的那个,窗口右移时它活得更久。
q.push_back(i);弹干净后当前元素一定入队。它可能是后面若干个窗口的答案。
q.front() <= i - k② 窗口左端是 i−k+1,下标 ≤ i−k 的都已出窗。写成 < 就少弹一轮,过期元素会霸占队头,答案偏小。
if (i >= k - 1)③ 前 k−1 个位置窗口还没满,不能输出。0 基下标下这是唯一的正确边界,写成 i > k−1 会少一个数。
cout << a[q.front()]队头是整个窗口的最小值,因为队内值单调递增且队头没有过期。
求最大值把 ① 的 >= 改成 <=,其余一个字都不动。两类最值只差一个符号。

复杂度:每个下标入队 1 次、出队至多 1 次,总操作 ≤ 2n,与窗口长度 k 无关。用 multiset 也能维护窗口最值,但每次 O(log k),n = 106 时慢 20 倍。

单调队列真正的价值在优化 DP。看一个典型转移:fi = ai + max{ fj : i−R ≤ j ≤ i−L }。题意:一条走廊有 n 个格子,从 0 出发,每次必须向前跳 L 到 R 格,落到格子 i 得 ai 分(可以是负数),求跳到 n 的最大得分。

暴力枚举 j 是 O(nR)。但 fj 的取值区间 [i−R, i−L] 随 i 单调右移、长度固定,正是“滑动窗口最值”。把它换成单调队列,转移降到 O(1),总复杂度 O(n)。

单调队列优化 DP
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll NEG = LLONG_MIN / 4;            // "不可达"标记,加 a[i] 也不会溢出

int main() {
    int n, L, R;
    cin >> n >> L >> R;
    vector<ll> a(n + 1), f(n + 1, NEG);
    for (int i = 1; i <= n; ++i) cin >> a[i];

    f[0] = 0;                            // 起点,得分为 0
    deque<int> q;                        // 存下标 j,f 值从队头到队尾单调递减
    // 不要把 0 提前塞进队列:j = 0 只有在 i == L 那一轮才合法(一步正好跳 L 格)

    for (int i = 1; i <= n; ++i) {
        // ① j = i - L 从这一步起才允许用来跳到 i,进队
        if (i - L >= 0 && f[i - L] != NEG) {
            while (!q.empty() && f[q.back()] <= f[i - L]) q.pop_back();
            q.push_back(i - L);
        }
        // ② j < i - R 跳得太远,够不到 i,出队
        while (!q.empty() && q.front() < i - R) q.pop_front();
        // ③ 队头就是 [i-R, i-L] 里 f 最大的那个
        if (!q.empty()) f[i] = f[q.front()] + a[i];
    }
    cout << (f[n] == NEG ? -1 : f[n]) << "\n";
    return 0;
}
转移式与三段代码的对应
代码片段含义与理由
f[i] = f[q.front()] + a[i]原来要对 j 扫一遍 max,现在队头 O(1) 给出最大值。转移从 O(R) 降到 O(1)。
if (i - L >= 0 && f[i - L] != NEG)入队时机是本题的关键:j 只有在 j ≤ i−L 时才允许跳到 i,所以每轮新加入的候选恰好是 i−L 一个;起点 0 会在 i = L 那一轮自动进队。
f[q.back()] <= f[i - L]队尾的 f 值不更大就弹:它既不更优、又比新来的先过期,永远用不上。
q.front() < i - R过期条件用 <:j = i−R 这一步跳 R 格,正好合法,不能弹。
f[i - L] != NEG不可达状态不能入队。否则 NEG 加几次之后会变成一个大负数,最后判 f[n] == NEG 失效,输出一个天文数字。
const ll NEG = LLONG_MIN / 4用“足够小的数”当不可达标记。直接用 LLONG_MIN,加上 a[i] 会下溢成正数。
这一节的 8 种典型错法(含症状)

WA,答案整体错位一格。—— 单调栈里存了值而不是下标。一旦出现重复值,或者题目要求输出位置,就彻底失效。

WA,只在有重复值的数据上错。—— 求“第一个严格大于”却写成 while (a[stk[top]] <= a[i])。相等的候选被提前弹掉,答案会记成一个“不大于它”的位置。样例通常没有重复值,看不出来。

WA,窗口答案偶尔偏小。—— 滑动窗口忘了弹队头,或者过期条件写成 q.front() < i - k。过期的元素还霸着队头,后面几个窗口全部被一个“幽灵”污染。用 n=2, k=1, a=3 5 一试就知道。

WA,第一行少输出一个数。—— 输出条件写成 i > k - 1,应该是 i >= k - 1。

TLE,明明复杂度是 O(n)。—— 3×106 的输入用 cin 却没关同步;或者把“弹队尾”写成了每次扫一遍窗口找最小值(那就退回 O(nk))。

RE(数组越界)。—— MAXN 开成 106 而 n 到 3×106;或者空队列时调用 q.front()(deque 空时取值是未定义行为,多数时候读到垃圾值)。

WA,第二组数据开始全错。—— 多测没有清空栈 / 队列 / top 指针。上组数据的残留让第一组之后的答案全歪。

WA,大测试点变成负数。—— 柱状图面积写成 h[i] * (R[i] - L[i] - 1) 而没转 long long,109 乘 105 直接溢出成负数。

自己动手复现

关掉页面,从空文件把 P5788 的完整代码敲一遍并提交到洛谷。

改一改 ①:输入 4 1 4 4 5,正确答案是 2 4 4 0。把弹栈条件 < 改成 <=,答案变成 2 3 4 0——亲手看清“严格大于”只差这一个等号。

改一改 ②:把栈里存的下标改成存值(stk[++top] = a[i]),再用它当答案数组的下标——ans[stk[top]] 立刻越界(RE)。答案要的是位置,所以只能存下标。

改一改 ③:滑动窗口输入 2 1 / 3 5,正确输出 3 5;把 q.front() <= i - k 改成 q.front() < i - k,输出变成 3 3。

改一改 ④:DP 的入队下标从 i - L 改成 i - L - 1,允许的步长整体变成 L+1 … R+1——最小的那一步 L 被禁掉了。用 L=2, R=3 的数据跑一下,答案可能变大、可能变小、也可能直接变成 -1。

数学补给 · 均摊、笛卡尔树与贡献法

均摊 O(n) 怎么证。用会计法:给元素 i 进栈时预存 2 次操作额度,一次用于将来出栈、一次用于本次比较。while 循环每弹一个元素就消耗掉它进栈时存下的额度,所以 n 次扫描中 while 体执行总次数 ≤ n。单次 while 最坏 O(n),但 n 次合计 O(n)——这就是“均摊”与“最坏”的区别。

滑动窗口同理。n 个下标各入队 1 次、出队至多 1 次,总代价 ≤ 2n,与 k 无关。对比 multiset 的 O(n log k):k = 106 时 log k ≈ 20,等于凭空多 20 倍常数。

笛卡尔树。把 Li、Ri(左右第一个更矮)当作父子关系,单调栈恰好建出一棵二叉树:节点 i 的子树区间正好是 (Li, Ri),子树大小 = Ri − Li − 1。于是“以 hi 为高的最大矩形” = hi × 子树大小——柱状图问题就是笛卡尔树上的“高度 × 子树大小”。等高柱子怎么挂都不影响面积。

贡献法(本节 tag 里的第三件事)。要求“所有子数组的最小值之和”,必须让每个子数组只归属一个最小值,否则等高元素会被重复计数。规则:Li 取左边第一个严格小于 hi 的位置,Ri 取右边第一个小于或等于 hi 的位置——于是每个子数组归给它最靠右的最小值。

按这个规则,以 hi 为最小值的子数组个数 = (i − Li) × (Ri − i):左端点可选 Li+1 … i 共 i − Li 种,右端点可选 i … Ri−1 共 Ri − i 种。答案 = Σ hi × (i − Li) × (Ri − i)。注意这里是“左小于、右小于等于”的不对称写法;柱状图求面积时两边都用 ≥ 也对,因为面积与“归给谁”无关。

量级感受。n = 3×106 时,O(n²) 是 9×1012 次比较,1 GHz 也要两小时;O(n) 是 3×106 次。同一道题,两个复杂度之间差的是“能不能交”。

式子含义典型用途
Ri − Li − 1以 hi 为高的矩形宽度柱状图最大矩形
(i − Li) × (Ri − i)以 hi 为最小值的子数组个数所有子数组最小值之和
Σ (i − Li)(Ri − i)应等于子数组总数 n(n+1)/2贡献法写完后的自检
举一反三 · 想清楚这些再往下走
  1. 把“第一个更大”改成“第一个不小于”(允许相等),弹栈条件该怎么变?举一组数据说明两种写法输出不同。
  2. “左边第一个更大”和“右边第一个更大”能不能一遍扫完同时求出来?需要几个栈、各自的方向是什么?
  3. 柱状图结论里“最大矩形一定以某根柱子的高度为高”这句话为什么成立?如果矩形上方悬空,能不能往上抬?抬的过程中宽度会变吗?
  4. 所有子数组最小值之和可以用 L、R 做贡献法。那“所有子数组最大值之和”呢?L、R 该怎么定义?
  5. 滑动窗口求最值时,如果 k 接近 n,单调队列还是 O(n) 吗?它省下的到底是哪部分工作?
  6. 把 dpi = min{ dpj + (sumi − sumj)² } 的转移也交给单调队列,为什么不行?这类式子需要什么额外条件(决策单调性 / 凸性)才能用队列维护?
  7. 窗口里如果要同时维护“最大值”和“最大值出现的次数”,单调队列还够用吗?为什么?
自测:能全部回答再进下一节
  • 能默写求“右边第一个更大元素”的 4 行核心代码,并说清被弹出的元素为什么再也用不上。
  • 能说清为什么总复杂度是均摊 O(n),而不是“最坏 O(n²)”。
  • 能写出柱状图最大矩形的 L/R 数组,并说清弹栈写成 ≥ 和写成 > 都得到正确答案的原因。
  • 能默写滑动窗口最小值的三步(弹尾、弹头、输出),并指出每一步对应哪类错误。
  • 知道 deque 里为什么必须存下标,以及什么时候该换手写数组。
  • 能把 fi = ai + max{fj} 的转移改成单调队列版本,并说清入队时机为什么是 i−L。
  • 拿到一道“区间最值 + 单调移动”的题,能判断该用单调队列还是 ST 表 / 线段树,理由是什么。
挑战题 · 发射站
洛谷 P1901普及/提高-去洛谷做这题 ↗

某地有 N 个能量发射站排成一行,第 i 个发射站的高度是 hi、能量值是 vi,所有 hi 互不相同。每个发射站同时向左右两边发射能量,能量只被两边最近的、且比它高的发射站接收;如果那边不存在更高的发射站,这份能量就损失掉。

一个发射站收到的能量,是所有能到达它的发射站发出的能量之和。求收到的能量最多的那个发射站收到了多少。

输入:第一行一个整数 N(1 ≤ N ≤ 106);接下来 N 行,每行两个整数 hi、vi(1 ≤ hi, vi ≤ 109),数据保证所有 hi 互不相同。

输出:一行一个整数,表示最大的接收能量。

引导性提示(先只看这里)

每个发射站只关心两件事:左边第一个比它高的、右边第一个比它高的。不用管能量会不会传到第三个人。

“左边第一个比它高的”,和本节例题“右边第一个比它大的”,是同一个模板把方向反过来。

从左往右跑一遍、从右往左跑一遍,两次都往一个累加数组里加。接收的能量总和可能到 1015。

想看思路了?点开完整思路

把任务拆成两半:对每个 i,找到 左边第一个 h 比自己大的位置 p,让 rec[p] += vi;再找右边第一个比自己大的位置 q,让 rec[q] += vi。两遍互不干扰。

求“左边第一个更大”用单调栈:从左往右扫,维护一个高度递减的下标栈。遇到 i 时,把栈里所有高度小于 hi 的弹掉——它们挡不住 i,而且已经被 i 截住了,以后不可能再成为任何人的“最近的更高者”。

弹干净之后,栈顶就是左边第一个比 hi 高的位置,把 vi 记在它头上。

为什么栈顶一定对:设 j 是 i 左边第一个比 i 高的位置。若 j 被弹出,只能是被某个 k(j < k < i)以 hk > hj > hi 弹掉的,那 k 比 j 更靠右且也比 i 高,与 j 的“第一个”矛盾。所以 j 一定还在栈里,且栈里没有比它更靠右的合法候选,它就是栈顶。

右边同理,从右往左再跑一遍同一个模板,栈清空后重新用。

复杂度 O(N):每个下标进栈一次、出栈至多一次,两遍合计 4N 次操作,N = 106 轻松通过。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 1000005;

int n, h[MAXN], v[MAXN];
int stk[MAXN], top = 0;             // 单调栈:存下标,高度从栈底到栈顶递减
ll rec[MAXN];                       // rec[i] = 第 i 个发射站收到的总能量

int main() {
    scanf("%d", &n);
    for (int i = 1; i <= n; ++i) scanf("%d%d", &h[i], &v[i]);

    // 第一遍:每个站把能量交给左边最近的更高者
    for (int i = 1; i <= n; ++i) {
        while (top > 0 && h[stk[top]] < h[i]) --top;   // 更矮的挡不住我,弹掉
        if (top > 0) rec[stk[top]] += v[i];            // 栈顶就是左边第一个更高的
        stk[++top] = i;                                // 自己进栈
    }

    // 第二遍:反方向再来一次,处理右边
    top = 0;
    for (int i = n; i >= 1; --i) {
        while (top > 0 && h[stk[top]] < h[i]) --top;
        if (top > 0) rec[stk[top]] += v[i];
        stk[++top] = i;
    }

    ll best = 0;
    for (int i = 1; i <= n; ++i) best = max(best, rec[i]);   // 注意用 ll 接
    printf("%lld\n", best);
    return 0;
}
关键片段拆解
片段作用
while (top > 0 && h[stk[top]] < h[i]) --top;高度严格小于就弹。本题保证高度互不相同,所以不会遇到“相等该不该弹”的问题;如果高度可能相等,题意“比它高”要求不弹等高的那个。
if (top > 0) rec[stk[top]] += v[i];弹干净后栈顶是左边第一个更高的站。注意是 rec += v[i]:能量记在接收方头上。
stk[++top] = i;自己入栈。能量已经交出去了,入栈是为了将来接收右边那些比它矮的站交来的能量。
top = 0; 第二遍循环同一个模板反着跑。清空栈这一步最容易漏,漏了会把上一遍的残留混进来。
ll rec[MAXN];一个站最多收 106 份、每份 109,总和 1015,int 直接爆。
printf("%lld\n", best);long long 必须配 %lld。用 %d 打印只会输出低 32 位,等于随机数。
这题最容易错的地方
  • 只从左往右跑一遍,忘了右边的能量:样例往往看不出来(最大值的站可能恰好在中间),大测试点掉一半。
  • 能量总和用 int 存:106 × 109 = 1015,比 int 上限高五个数量级。
  • 栈数组开 105:N 到 106,直接 RE;开 1000005 才安全。
  • 把“交给最近更高者”写成“交给最近的一个”:漏了 if (top > 0) 判空,或者忘了弹栈,结果全错。
  • 两遍之间忘了 top = 0,第二遍直接在上一遍的栈上继续操作。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》0x11:单调栈与单调队列
  • 《算法竞赛入门笔记》:栈、队列与滑动窗口最值
  • 《算法竞赛试炼场:洛谷300题精析》P5788、P1886 两题的精析
  • 《算法竞赛黄金法则:提高算法和思考力的77项技巧》:先删无用元素,再谈优化
49 堆与对顶堆、启发式合并提高

49 · 堆与对顶堆、启发式合并

堆的第二层用法:两块堆拼成对顶堆,把「动态第 k 小」压到 O(log n);多块堆拼起来时,用启发式合并(小并大)把总代价从 O(n²) 压到 O(n log² n);再把同一笔账搬到树上(DSU on tree,O(n log n)),最后用左偏树把合并本身做到 O(log n)。
提高预计 65 分钟对顶堆中位数DSU on tree
本节出现的新函数 / 新语法(先看懂再用)

堆只有三条命令:push、pop、top,每次 O(log n)。第 17 节的贪心题里就用它挑最大 / 最小,这一节把它当积木:两块拼成对顶堆求中位数,多块拼起来时讨论怎么合并最省,最后看一眼支持 O(log n) 合并的左偏树。

对顶堆的不变式只有一句:大根堆的元素个数 = 小根堆的元素个数,或比它多 1。于是中位数永远是大根堆的堆顶。
任务做法单次代价总代价
动态中位数:插入 + 查询对顶堆(大根堆 + 小根堆)O(log n)n 次操作 O(n log n)
合并两个 priority_queue没有 merge,只能 pop 出来再 pushO(m log(n+m))—
N 个集合反复两两合并启发式合并:小的并进大的搬一个元素 O(log n)O(n log² n)
同上,改用可并堆左偏树 mergeO(log n)O(n log n)
子树信息合并DSU on tree:重儿子保留—O(n log n)
例题 · 中位数洛谷 P1168普及/提高-去洛谷做这题 ↗

给出一个长度为 N 的非负整数序列 A1..N。对于所有 1 ≤ k ≤ (N+1)/2,输出 A1, A3, …, A2k−1 的中位数——也就是前 1、3、5、… 个数的中位数。

输入:第一行一个整数 N(N ≤ 105);第二行 N 个整数 Ai(0 ≤ Ai ≤ 109)。

输出:共 (N+1)/2 行,每行一个整数,表示对应的中位数。

输入: 7 1 3 5 7 9 11 6 输出: 1 3 5 6

例题里真正要维护的是“第 k 小”。先看两个这一节才会用到的成员函数,后面的合并与对顶堆都靠它们。

新函数拆解 · priority_queue::emplace
pq.emplace(构造参数...); // 等价于 pq.push(元素类型(构造参数...))
组成含义 / 格式要求
emplace(args...)把参数直接转给元素类型的构造函数,在容器内部原地构造,省掉“先造临时对象、再拷贝/移动进去”这两步。
push 需要先有对象push(make_pair(x, y)) 或 push({x, y}):先造一个临时 pair,再搬进去。
和 pair 搭配最顺手priority_queue<pair<int,int>> 里写 emplace(x, y),直接构造出 pair<int,int>(x, y),比较时先比 first 再比 second。
参数必须能造出元素priority_queue<pair<int,int>> 里写 emplace(1) 编译失败——pair 没有只接受一个 int 的构造函数。参数个数 / 类型不对时,报错信息埋在 construct 的模板里,读不懂先数参数个数。
没有返回值返回 void,别写 x = pq.emplace(...)。
相等元素的顺序不确定堆只保证堆顶是极值;值相等的元素谁先出堆由内部结构决定,别依赖它。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int main() {
    priority_queue<pair<int, int>> pq;    // 先比 first,first 相同再比 second

    pq.emplace(3, 7);                     // 原地构造 pair(3, 7)
    pq.push(make_pair(4, 1));             // 老写法:先造对象再搬进去
    pq.push({5, 0});                      // 花括号也行

    cout << pq.top().first << " " << pq.top().second << "\n";   // 5 0
    cout << pq.size() << "\n";                                  // 3
    return 0;
}
注意

emplace 的参数个数、类型必须能匹配元素类型的某个构造函数,否则编译期报错——这是它比 push 唯一“难一点”的地方。

速度差距在常数级(省一次拷贝),106 次插入大约省几十毫秒;写起来更短才是主要收益。

新函数拆解 · priority_queue::swap
a.swap(b); // 交换两个同类型堆的全部内容,O(1) swap(a, b); // 同上,std::swap 走移动,也是 O(1) 级
组成含义 / 格式要求
O(1)只交换底层容器(vector/deque)的指针,一个元素都不搬。106 个元素的堆互换也是常数时间。
两个堆必须是同一类型priority_queue<int>(大根堆)和 priority_queue<int, vector<int>, greater<int>>(小根堆)是不同类型,swap 直接编译失败。
用途 1:启发式合并if (a.size() < b.size()) a.swap(b); 保证永远把小的往大的里搬。
用途 2:O(1) 清空造一个空堆再 swap 进去,比 while (!q.empty()) q.pop() 的 O(n log n) 快得多。
用 std::swap 也一样对容器来说 std::swap 内部就是三次移动构造,同样不搬元素。成员 swap 语义更直白。
swap 之后比较器不变比较器是类型的一部分,跟着类型走,不会因为交换对象而改变。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int main() {
    priority_queue<int> a, b;
    a.push(1); a.push(2);          // a = {1, 2},堆顶 2
    b.push(9);                     // b = {9}

    a.swap(b);                     // O(1):现在 a = {9},b = {1, 2}
    cout << a.top() << "\n";       // 9

    priority_queue<int> empty;
    a.swap(empty);                 // 也是 O(1) 清空

    // 大根堆和小根堆是两个类型,下面这行编译不过:
    // priority_queue<int> big;
    // priority_queue<int, vector<int>, greater<int>> small;
    // big.swap(small);            // error: no matching function for call to swap
    return 0;
}
注意

swap 只能交换“同一个模板实例”的对象。要对顶堆那样一大一小两个堆,只能老实 push/pop 搬元素。

多测题里用一个空堆 swap 是最干净的重置方式,比重新声明局部变量更省事(尤其是全局堆)。

例题怎么想:把有序数组从中间掰成两半

中位数的定义:i 个数从小到大排好后第 (i+1)/2 个(i 为奇数时唯一)。朴素做法是每来一个数就 sort 一遍,N = 105 时约 1010 次比较,必然 TLE。

换个角度:不需要全序,只需要中间那一个。把已读入的数分成两半——小的那一半放大根堆 big(堆顶是这半边的最大值),大的那一半放小根堆 small(堆顶是这半边的最小值)。两个堆顶正好卡在有序数组的中间。

只要维持一条不变式:big 的大小 = small 的大小,或比它多 1,那么 big 里恰好是最小的 (i+1)/2 个数,堆顶就是第 (i+1)/2 小——也就是中位数。

每次插入做两件事:① 看新数和 big 堆顶的大小关系,决定进哪边;② 若不变式被破坏,把某一边的堆顶挪到另一边。一次插入最多挪一个元素,所以单次 O(log n)。

三步维护
// ① 按大小关系归类
if (big.empty() || x <= big.top()) big.push(x); else small.push(x);

// ② 按数量关系拉平:big 的大小只能是 small 或 small + 1
if ((int)big.size() > (int)small.size() + 1) { small.push(big.top()); big.pop(); }
else if (big.size() < small.size())          { big.push(small.top()); small.pop(); }

// ③ 第奇数个数读完后,堆顶就是中位数
if (i & 1) printf("%d\n", big.top());

为什么不会连续挪多个:插入前不变式成立,插入后两边大小差最多变成 2,挪一次就恢复。这也是“对顶堆”能 O(log n) 的原因——它从不重排,只搬运一个堆顶。

完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    scanf("%d", &n);

    priority_queue<int> big;                                 // 大根堆:存较小的一半
    priority_queue<int, vector<int>, greater<int>> small;     // 小根堆:存较大的一半

    for (int i = 1; i <= n; ++i) {
        int x; scanf("%d", &x);

        // ① 先按大小关系决定去左边还是右边
        if (big.empty() || x <= big.top()) big.push(x);
        else small.push(x);

        // ② 再按数量关系拉平:big 的大小只能是 small 或 small + 1
        if ((int)big.size() > (int)small.size() + 1) {
            small.push(big.top()); big.pop();
        } else if (big.size() < small.size()) {
            big.push(small.top()); small.pop();
        }

        // ③ 第奇数个数读完后总数为奇数,big 比 small 多一个,堆顶即中位数
        if (i & 1) printf("%d\n", big.top());
    }
    return 0;
}
逐行拆解
代码片段含义与理由
priority_queue<int> big;默认是大根堆:堆顶是最大值。它存“较小的一半”,所以堆顶是这半边的最大值,正是中位数。
priority_queue<int, vector<int>, greater<int>> small;小根堆要写全三个模板参数:元素类型、底层容器、比较器。greater<int> 表示“小的优先”,堆顶变成最小值。
x <= big.top()比中位数小就归左半边。用 ≤ 还是 < 都不影响正确性,关键是与后面平衡条件配套、前后一致。
(int)big.size() > (int)small.size() + 1big 最多比 small 多 1 个。多到 2 个说明左边太重,把堆顶(左半边最大的)挪到右边。size() 是无符号数,要和 −1 之类的式子混算必须先转 int,否则空堆时会绕成巨大正数。
big.size() < small.size()左边比右边少,把右边最小的挪过来。只可能差 1,所以一次就够。
if (i & 1)题目只问前 1、3、5… 个数的中位数。总数为奇数时 big 恰好 (i+1)/2 个,堆顶就是第 (i+1)/2 小。
printf("%d\n", big.top())每行一个。用 \n 而不是 endl:N = 105 时能省下十万次强制刷新。

priority_queue 没有 merge,也没有迭代器,两个堆的内部数组不能直接拼(拼完不是堆)。想合并只有一条路:把一个堆的元素 pop 出来、push 进另一个。

单次合并的代价是 O(m log(n+m)),m 是较小堆的大小。只合一次的话这样足够。真正的麻烦是“N 个集合反复两两合并”——这时合并顺序决定复杂度。

启发式合并:小并大
// 把 b 的全部元素搬进 a,并保证搬的是较小的那一堆
void mergeInto(priority_queue<int>& a, priority_queue<int>& b) {
    if (a.size() < b.size()) a.swap(b);      // ★ O(1):只交换两个底层容器
    while (!b.empty()) {                     // 现在 b 一定是小的
        a.push(b.top());
        b.pop();
    }
}
三处关键
代码片段含义与理由
a.swap(b)O(1) 交换整个容器,等价于“让 a 指向大的那堆”。没有它,就得手动判断方向、写两个分支。
while (!b.empty()) { a.push(b.top()); b.pop(); }只能这样搬:priority_queue 不暴露内部数组,也没有 merge 接口。
为什么总代价 O(n log² n)元素 x 每被搬一次,它所在集合的大小至少翻倍(因为它是从小的那边搬进大的)。所以 x 一生最多被搬 log₂ n 次;每次 push 是 O(log n)。合计 n log n 次 push,每次 log n。
为什么不直接拼底层数组即使能拿到两段有序数据,拼起来也不是堆结构,还得花 O(n) 重建;而 push 已经有 O(log n) 的增量维护。
大根堆不能 swap 进小根堆两个类型不同,编译期就报错。想让对顶堆的两边互换,只能一个个 pop/push。

同一个“小并大”的思想搬到树上,就是 DSU on tree(树上启发式合并)。它解决的问题很固定:对每个点 u,回答一个只与 u 的子树有关的问题,而这个问题支持“加点、删点、随时取答案”这三种操作。

做法四步:① 求子树大小与重儿子;② 先递归所有轻儿子,算完把它们的贡献清空;③ 再递归重儿子,算完保留它的贡献;④ 把轻儿子的子树暴力加回来,加上自己,此时的答案就是 u 的答案;若 u 是轻儿子,用完再清空。

复杂度 O(n log n):一个点被“暴力加入”的次数,等于它到根路径上轻边的条数;走一条轻边意味着父的子树大小至少是子的两倍,所以轻边不超过 log₂ n 条。每次加入是 O(1)(桶计数),合计 n log₂ n。

DSU on tree:求每个子树中出现次数最多的颜色编号之和(n ≤ 10⁵,颜色编号 1..n)
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;

int n, c[MAXN];                     // c[u] = 点 u 的颜色
vector<int> g[MAXN];
int sz[MAXN], son[MAXN];            // 子树大小、重儿子
int cnt[MAXN], mx;                  // cnt[颜色] 出现次数;mx = 当前最大出现次数
long long sum, ans[MAXN];           // sum = 出现次数等于 mx 的颜色编号之和

void dfs1(int u, int p) {           // 第一遍:子树大小与重儿子
    sz[u] = 1;
    for (int v : g[u]) if (v != p) {
        dfs1(v, u);
        sz[u] += sz[v];
        if (sz[v] > sz[son[u]]) son[u] = v;   // sz[0] = 0,所以 son 初值 0 也能比
    }
}

void add(int u, int p, int d) {     // 把 u 子树的颜色整体加进 / 移出桶
    cnt[c[u]] += d;
    if (d > 0) {                    // 只加的时候维护答案,清空时不用管
        if (cnt[c[u]] > mx) { mx = cnt[c[u]]; sum = c[u]; }
        else if (cnt[c[u]] == mx) sum += c[u];
    }
    for (int v : g[u]) if (v != p) add(v, u, d);
}

void dfs2(int u, int p, bool keep) {
    for (int v : g[u]) if (v != p && v != son[u]) dfs2(v, u, false);   // 轻儿子:算完清空
    if (son[u]) dfs2(son[u], u, true);                                 // 重儿子:算完保留
    for (int v : g[u]) if (v != p && v != son[u]) add(v, u, 1);        // 轻儿子暴力补回来

    cnt[c[u]]++;                                                       // 加上自己
    if (cnt[c[u]] > mx) { mx = cnt[c[u]]; sum = c[u]; }
    else if (cnt[c[u]] == mx) sum += c[u];
    ans[u] = sum;

    if (!keep) { add(u, p, -1); mx = 0; sum = 0; }                     // 用完清空并复位
}

int main() {
    ios::sync_with_stdio(false); cin.tie(nullptr);
    cin >> n;
    for (int i = 1; i <= n; ++i) cin >> c[i];
    for (int i = 1; i < n; ++i) {
        int u, v; cin >> u >> v;
        g[u].push_back(v); g[v].push_back(u);
    }
    dfs1(1, 0);
    dfs2(1, 0, false);
    for (int i = 1; i <= n; ++i) cout << ans[i] << " ";
    cout << "\n";
    return 0;
}
四步与四个数组
代码片段含义与理由
dfs1 求 sz 与 son重儿子 = 子树最大的儿子。所有“保留”都发生在重儿子上,所以它必须先算出来。
dfs2(v, u, false) 轻儿子第三个参数 keep 表示“算完要不要保留贡献”。轻儿子一律 false,算完就清空。
dfs2(son[u], u, true) 重儿子保留。重儿子那一大坨不用重新加,省下的正是 O(n log n) 里最贵的部分。
add(v, u, 1) 把轻儿子加回来此时桶里已有重儿子的全部信息,只补轻儿子,代价 = 轻子树大小之和。
cnt[c[u]]++ 与自己这一句别漏掉 u 自己。它不属于任何儿子,要单独加一次。
if (!keep) { add(u, p, -1); mx = 0; sum = 0; }清空整棵 u 子树,并把 mx / sum 复位。漏了复位,下一棵子树会沿用上一棵的最大值,答案全错。
DSU on tree 的适用判定

问题只与「子树」有关,且不强制在线(可以先把所有答案算完再输出)。

信息支持「加一个点 / 删一个点 / 取答案」三种操作,且加删都是 O(1) 级(桶计数、出现次数、颜色和这类)。

如果信息不支持删除(比如最大值且要求精确回滚),就换线段树合并,或按 DFS 序整体离线处理。

递归深度可能到 105(树退化成链)。若 RE,先确认是不是爆栈,再考虑把递归改成手工栈。

启发式合并的 O(n log² n) 里,多出来的那个 log 是“每个元素都要 push 一次”。如果能直接合并两个堆、每次只走 O(log n) 步,总代价就降到 O(n log n)。这就是可并堆:左偏树是最常用的实现。

左偏树用 ls[]、rs[] 两个数组表示二叉树,另外记一个 dist。它只保证一件事:每个点的左子树 dist ≥ 右子树 dist。于是从根一路往右走的“右路径”很短,merge 只沿右路径递归,自然 O(log n)——而右路径长度不超过 log₂(n+1),这一点在数学补给里证。

新函数拆解 · 左偏树 merge(可并堆)
int merge(int x, int y); // 合并两棵左偏树,返回新根;0 表示空树
组成含义 / 格式要求
x, y两棵左偏树的根。要求它们各自已经是合法的大根堆 + 左偏结构。
if (!x || !y) return x + y;有一边为空就返回另一边。只有一个非零时 x + y 正好等于那个非零值。
if (val[x] < val[y]) swap(x, y);让 x 当根。大根堆用 <,小根堆把符号改成 >。
rs[x] = merge(rs[x], y);只往右子树递归——这是 O(log n) 的来源,也是它区别于普通堆的关键。
if (dist[ls[x]] < dist[rs[x]]) swap(ls[x], rs[x]);维持左偏:左子树的 dist 不能小于右子树。
dist[x] = dist[rs[x]] + 1;dist 定义成“到最近空节点的边数”,空节点 dist = 0,所以叶子 dist = 1。
dist[0] = 0,其余点 dist 初值必须是 1下标 0 是空节点,dist 恒为 0(全局数组默认值正好对);但每个真实节点单独成堆时 必须手动置 1。忘了这一步,叶子会被算成 dist = 0,左偏性质失效,复杂度退化成 O(n)。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int ls[MAXN], rs[MAXN], dist[MAXN], val[MAXN];

// 合并两棵左偏树(大根堆),返回新根
int merge(int x, int y) {
    if (!x || !y) return x + y;
    if (val[x] < val[y]) swap(x, y);                     // x 当根
    rs[x] = merge(rs[x], y);                             // 只递归右子树
    if (dist[ls[x]] < dist[rs[x]]) swap(ls[x], rs[x]);   // 维持左偏
    dist[x] = dist[rs[x]] + 1;
    return x;
}

// 删除堆顶:合并根的左右子树即可,O(log n)
int pop(int x) { return merge(ls[x], rs[x]); }

int main() {
    int n; cin >> n;
    for (int i = 1; i <= n; ++i) { cin >> val[i]; dist[i] = 1; }  // ★ 每个点单独成堆:dist = 1
    int root = 0;
    for (int i = 1; i <= n; ++i) root = merge(root, i);           // 全部并成一个大根堆
    while (root) { cout << val[root] << " "; root = pop(root); }  // 降序输出
    return 0;
}
注意

左偏树没有「按下标访问」「随机删除」这些能力,它存在的唯一理由是 merge 便宜。不要拿它当通用容器用。

dist 只在 merge 里维护。如果手动改结构(比如直接修改 ls/rs),dist 会失效,复杂度立刻退化。

想做模板去洛谷 P3377【模板】左偏树(可并堆),那里还要求判断两个元素是否已在同一堆里,需要并查集配合。

三者的取舍:只合一次——直接 pop/push 搬,代码最短;反复合并但不要求最优——启发式合并,用 STL 就够;合并次数多、常数敏感——左偏树。选哪个看合并次数和常数,不看理论复杂度好不好看。

这一节的 8 种典型错法(含症状)

WA,中位数整体偏小。—— 平衡漏了一个方向:只写了“big 太多就往 small 挪”,没写“big 太少就从 small 补”。插入一个很大的数后 big 会比 small 少 1 个,之后每个奇数位置输出的都是第 (i−1)/2 小,比正确答案小一位。

TLE。—— 每读一个数就 sort 一遍。N = 105 时是 1010 量级次比较,对顶堆只有 105 次 O(log n)。

RE / 答案随机。—— 在空的 priority_queue 上调用 top()。std::priority_queue 对空堆取 top 是未定义行为,常见表现是读到垃圾值或 0。每次 top() 前都要保证非空。

CE。—— 想让大根堆和小根堆互换内容而写 big.swap(small):两者模板参数不同、类型不同,编译期就报错。

WA,DSU on tree 从第二棵子树开始全错。—— 清空时忘了 mx = 0; sum = 0;,下一棵子树沿用上一棵的最大出现次数。

RE,DSU on tree 在链状数据上崩。—— 递归深度 105 爆栈。开大栈或改成手工栈。

TLE,启发式合并反而更慢。—— 忘了 if (a.size() < b.size()) a.swap(b);,每次都把大堆往小堆里塞,一个元素可能被搬 O(n) 次,总代价退化成 O(n² log n)。

TLE,左偏树和普通堆一样慢。—— merge 里写成 ls[x] = merge(ls[x], y),左偏性质被破坏,右路径可能长达 O(n),单次合并退化成 O(n)。

自己动手复现

关掉页面,从空文件把 P1168 的代码敲一遍并提交到洛谷。

改一改 ①:把「左边比右边少就挪」的那段 else if (big.size() < small.size()) 整段删掉,输入 3 1 5 3。正确输出是 1 / 3,删掉后第二个数会输出 1——亲手看到不变式被破坏的后果。

改一改 ②:把 if (i & 1) 改成 if (!(i & 1)),输入 4 1 2 3 4,看看偶数位置输出的是哪两个数的哪一个。再想:要输出「下中位数」和「上中位数」,平衡条件该怎么改?

改一改 ③:造一个只有 1 个元素的小根堆和一个 105 元素的大根堆,先用 a.swap(b) 计时,再改成把 105 个元素逐个 push 计时——两者的差距就是“搬元素”和“换指针”的差距。

改一改 ④:在 DSU on tree 的代码里只删掉 mx = 0; sum = 0; 这两句(保留清空),造一棵有多个轻儿子的树,观察 ans[] 从哪个点开始变错。

数学补给 · 势能分析:合并的账怎么算

堆有多高。n 个元素的完全二叉树高度是 ⌊log₂ n⌋ + 1。n = 106 时 20 层,push / pop 只走一条“叶子到根”的路径,交换次数 ≤ 20。这就是 O(log n) 的具体数字——所以堆操作在 106 规模上几乎免费,真正的代价在搬元素的次数。

中位数的位置。i 个数从小到大排好,中间那个是第 ⌈i/2⌉ 个;i 为奇数时它等于 (i+1)/2。对顶堆让 big 恰好装最小的 (i+1)/2 个,于是 big 的堆顶就是第 (i+1)/2 小。这条“位置 = 大小”的对应关系,就是对顶堆全部正确性的来源。

启发式合并的势能账。设元素 x 当前所在集合大小为 s。x 被搬动,只可能是因为它属于较小的那一半;搬完后新集合大小 ≥ 2s。于是 s 每被搬一次就翻倍,x 一生最多被搬 ⌊log₂ n⌋ 次。总搬动次数 ≤ n log₂ n,每次 push 是 O(log n),合计 O(n log² n)。

为什么左偏树能省掉一个 log。它不搬元素,只改指针:merge 沿两棵树的右路径走,每步 O(1),总 O(log n) 次比较。合并 n 次就是 O(n log n)。这就是“可并堆”三个字的全部价值。

右路径为什么不超过 log₂(n+1)。dist(x) 定义成“x 到最近空节点的边数”,空节点 dist = 0,叶子 dist = 1。左偏性质保证 dist(ls) ≥ dist(rs),且 dist(x) = dist(rs) + 1。归纳可得:dist(x) = d 的子树至少有 2d − 1 个节点。所以 d ≤ log₂(n+1)。而右路径的边数恰好是 dist(root) − 1,于是 merge 的递归深度 ≤ log₂(n+1)。

DSU on tree 的账。一个点被“暴力加入”的次数 = 它到根路径上的轻边条数。轻边的定义是“父的 sz ≥ 2 × 子的 sz”(否则它就是重儿子),所以每走一条轻边,所在子树大小至少减半,轻边不超过 log₂ n 条。每次加入 O(1),合计 O(n log n)。

结构单次操作总代价什么时候用
priority_queueO(log n)—只要极值,不合并
对顶堆O(log n)O(n log n)动态中位数、动态第 k 小
启发式合并搬一个 O(log n)O(n log² n)合并次数多,能接受一个 log
左偏树merge O(log n)O(n log n)合并次数多且常数敏感
DSU on tree加一个点 O(1)O(n log n)子树统计、可离线
举一反三 · 想清楚这些再往下走
  1. 对顶堆的平衡条件改成「big 的大小 = small 的大小,或比它少 1」,输出会变成什么?要同时支持「下中位数」和「上中位数」,条件该怎么写?
  2. 要动态维护中位数,还要能删除任意一个已知元素,对顶堆还够用吗?需要补什么结构?
  3. 把启发式合并里的 priority_queue 换成 set,同样的题还能做吗?代价从 O(n log² n) 变成了什么?
  4. DSU on tree 里如果不用重儿子、对所有儿子一视同仁地保留,复杂度会变成多少?举一棵树说明。
  5. 左偏树怎么删除堆顶?怎么插入一个新元素?为什么它做不到 priority_queue 的「随机访问」?
  6. 如果问题是「给一段区间,问它的中位数」,多组询问,对顶堆还能用吗?你会想到什么结构?
  7. 为什么 priority_queue 的 swap 是 O(1),而「把 b 的全部元素 push 进 a」是 O(m log(n+m))?差在哪一步?
自测:能全部回答再进下一节
  • 能默写对顶堆的归类与平衡两段代码,并说清「奇数个时答案是大根堆堆顶」的理由。
  • 能说出 emplace 与 push 的区别,并用 emplace 往 priority_queue<pair<int,int>> 里塞元素。
  • 能写出 a.swap(b) 版的启发式合并,并解释它为什么必须是 O(1)。
  • 能独立推导启发式合并的 O(n log² n):搬动次数 ≤ log₂ n,每次 push 又是 log。
  • 能复述 DSU on tree 的四步,并说清复杂度为什么是 O(n log n) 而不是 O(n log² n)。
  • 能写出左偏树 merge 的五行,并说清 dist 的定义与右路径 ≤ log₂(n+1) 的理由。
  • 知道 priority_queue 没有 merge,也知道大根堆和小根堆为什么不能 swap。
挑战题 · 黑匣子
洛谷 P1801普及/提高-去洛谷做这题 ↗

Black Box 是一种原始的数据库。它能存一个整数数组,还有一个特殊的变量 i(初始为 0)。它支持两种操作:

ADD(x):把 x 放进数组;GET:先把 i 加 1,然后输出数组中第 i 小的数。

给定一个操作序列:先做 M 次 ADD,每次 ADD 之后可能紧跟若干次 GET。现在按顺序给出这 M 个被 ADD 的数,以及每次 GET 发生在第几次 ADD 之后,要求按顺序输出每次 GET 的结果。

输入:第一行两个整数 M、N(1 ≤ N ≤ M ≤ 2×105),分别是 ADD 的次数与 GET 的次数。第二行 M 个整数,依次是每次 ADD 的数(|x| ≤ 2×109)。第三行 N 个整数 u1 ≤ u2 ≤ … ≤ uN(1 ≤ uj ≤ M),表示第 j 次 GET 紧跟在第 uj 次 ADD 之后。数据保证每次 GET 时数组里至少有 i 个数。

输出:共 N 行,每行一个整数,依次是每次 GET 的答案。

引导性提示(先只看这里)

GET 要的是「第 i 小」,而 i 每次只加 1。这和例题里「前 i 个数的中位数」几乎是同一个问题。

对顶堆的不变式可以推广:让大根堆里恰好放 i 个元素,它就是最小的 i 个,堆顶自然是第 i 小。

ADD 时先随便塞进某一边并保持堆的性质;等到 GET 再按 i 调整两边的数量。x 可能为负,读入用 int,答案也用 int。

想看思路了?点开完整思路

把例题的不变式换掉:例题固定「big 的大小 = small 或 small + 1」,本题改成「big 的大小恰好等于当前 GET 序号 i」。这样 big 里就是最小的 i 个数,堆顶即第 i 小。

ADD 阶段:x 比 big 堆顶小就进 big,否则进 small。此时 big 的大小可能大于或小于 i,先不管。

GET 阶段:i 自增 1 之后调整——big 太小就从 small 的堆顶补,太大就把自己的堆顶送过去,直到 size(big) == i,输出 big.top()。

为什么调整是 O(log n):ADD 只做一次 push,GET 的调整次数等于 |size(big) − i|,而每次 GET 只让 i 加 1,累计的调整总量是 O(M),摊到每次操作是 O(log n)。

细节:uj 可以相等(同一次 ADD 后连续 GET),所以判断条件用 u[q] ≤ i 而不是 == i,并用 while 把这次 ADD 后面的所有 GET 都处理完。

边界:只有 i 不超过已 ADD 个数时才会去 small 里取元素,所以 small 不会空——前提是 u 的含义没读错。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int m, n;
    scanf("%d%d", &m, &n);

    vector<int> a(m + 1), u(n + 1);
    for (int i = 1; i <= m; ++i) scanf("%d", &a[i]);
    for (int j = 1; j <= n; ++j) scanf("%d", &u[j]);

    priority_queue<int> big;                                  // 存最小的 i 个数
    priority_queue<int, vector<int>, greater<int>> small;      // 存剩下的
    int q = 1;                                                 // 下一次要处理的 GET 编号

    for (int i = 1; i <= m; ++i) {
        // ADD a[i]
        if (big.empty() || a[i] <= big.top()) big.push(a[i]);
        else small.push(a[i]);

        // 第 i 次 ADD 之后可能有若干次 GET(u 可能重复)
        while (q <= n && u[q] <= i) {
            while ((int)big.size() < q) { big.push(small.top()); small.pop(); }  // 补到 q 个
            while ((int)big.size() > q) { small.push(big.top()); big.pop(); }    // 多的送回右边
            printf("%d\n", big.top());     // 第 q 小
            ++q;
        }
    }
    return 0;
}
关键片段拆解
片段作用
if (big.empty() || a[i] <= big.top())归类:比当前「第 i 小」还小的进左边。这里用 ≤ 是为了让相等的元素也进 big,保持 big 里存的东西“够小”。
while ((int)big.size() < q)big 不够 q 个,就从右边最小的(small 堆顶)补进来。补一次 size 加 1,最多补到 q。
while ((int)big.size() > q)big 超过 q 个,把左边最大的(big 堆顶)送到右边。这两个 while 合起来保证 size(big) 最终恰好等于 q。
u[q] <= iu 可能重复(同一次 ADD 后连续 GET),所以用 while + ≤ 一次处理干净。写成 == i 且用 if 会漏掉后面的 GET。
printf("%d\n", big.top())size(big) = q 时,big 就是最小的 q 个数,堆顶是第 q 小。
vector<int> a(m + 1), u(n + 1)1 基下标,和 uj 的定义直接对应,省掉减一的换算。
这题最容易错的地方
  • GET 的调整只写了「补」没写「送」:x 特别小时一堆元素挤进 big,size(big) 会超过 q,big.top() 变成比第 q 小更小的数。
  • 把 u[q] <= i 写成 u[q] == i 并用 if 判断:u 有重复值时漏掉 GET,输出行数不够。
  • 在 small 为空时调用 small.top():只有 i 超过已 ADD 个数时才可能发生,也就是把 u 的定义读错(比如当成「第几次 GET 之后」)。
  • x 用 int 读、答案用 int 输出是对的(|x| ≤ 2×109),但若题目把范围放宽到 1018 就必须换 long long。
  • 读入格式:u 在第三行,别和 a 混着读。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》0x14:堆与可并堆、0x16:树上启发式合并
  • 《算法竞赛入门笔记》:优先队列与对顶堆
  • 《算法竞赛试炼场:洛谷300题精析》P1168、P1801 两题的精析
  • 《算法竞赛黄金法则:提高算法和思考力的77项技巧》:把合并的代价摊到“集合大小翻倍”上
50 模拟赛二:150 分钟 5 题提高-

50 · 模拟赛二:150 分钟 5 题

用一场 150 分钟的限时模拟赛收束第 1~49 节:单调队列、正反两遍 LIS、二分答案、树状数组加离散化、断环成链的区间 DP 各一题,配时间分配表、四张必背模板卡、丢分症状清单与赛后复盘方法。
模拟赛提高-限时 150 分钟模拟赛5 题

这一节不讲新知识。5 道题、150 分钟,考第 1~49 节里复用率最高的五件事:单调队列(第 48 节)、正反两遍 LIS(第 31 节)、二分答案加贪心判定(第 27、16 节)、树状数组加离散化(第 46、13 节)、断环成链的区间 DP(第 33、18 节)。五道题都是洛谷真题,代码只用你已经学过的东西写。满分 500 分,每题 100 分。

模拟赛一考的是“会不会”,模拟赛二考的是“取舍”:五道题里一定有一道你写不完,先决定放弃哪道,比先决定做哪道更值钱。

先看清单。卡住时不要顺着题号往下问,直接翻回对应的节重看那一段。

题号题目对应节一句话考点建议用时
AP1886 滑动窗口第 28、48 节队尾弹逆序、队头出窗口22 分钟
BP1091 合唱队形第 31 节正反两遍 LIS 再合并23 分钟
CP2678 跳石头第 27、16 节二分最短距离 + 贪心数移走几块28 分钟
DP1908 逆序对第 46、13 节离散化后上树状数组30 分钟
EP1880 石子合并第 33、18 节断环成链 + 区间 DP32 分钟

难度上 A~D 是普及/提高-,E 是提高。本场的分水岭在 C 和 E:C 卡在“check 怎么写”,E 卡在“环怎么处理”。这两处都是想通就五分钟、想不通就零分的地方,所以下面每道题的提示都从“先想暴力”开始,而不是从“先想正解”开始。

150 分钟怎么切

总原则:前 55 分钟只做 A、B,并且必须一次过。这两题都是“想到就能写对”的题,一旦返工,后面三道题的时间就是借来的。

时间段做什么关键动作
0 ~ 8通读 5 题在纸上给每题标:会做 / 有思路 / 完全不会。这 8 分钟不写一行代码。
8 ~ 30A 滑动窗口(22)先写最小值那一遍,测完样例,再复制一份把比较方向反一下。
30 ~ 53B 合唱队形(23)两遍 LIS 都不难,难在最后的 n − max(f+g−1) 别算成 max。
53 ~ 81C 跳石头(28)先写 check,再套二分。check 写完用小数据手算两遍再编译。
81 ~ 111D 逆序对(30)离散化那三行写完先输出 sz 验一遍,再写树状数组。
111 ~ 143E 石子合并(32)断环成链 + 长度递增。到点没 AC 就交线性版本保分。
143 ~ 150检查只查数组大小、long long、输出格式,不改算法。
  • 单题硬上限 = 建议用时 × 1.5。C 题 42 分钟还没 AC 就换题,否则 D、E 连读题的时间都没有。
  • 每题先交“能过样例 + 手造边界”的版本,再想优化。有分才谈满分。
  • A 题写不完是致命的:它只有十几行。写不完说明没热身,立刻停手重读一遍题面。
  • 最后 7 分钟只做检查。赛场上最后 15 分钟改崩的次数,远多于改对的次数。

每题内部再切一刀:想 5 分钟、写 15 分钟、测 5 分钟。想的那 5 分钟不许碰键盘,纸上必须出现 4 行东西:

  1. 数组开多大:从数据范围推,上限 + 5。A 题 n ≤ 10⁶ 开 1000005;D 题 n ≤ 5×10⁵ 开 500005;E 题 2n ≤ 200 开 205。
  2. 要不要 long long:D 题 n = 5×10⁵ 且完全逆序时答案 ≈ n²/2 = 1.25×10¹¹,早已越过 int;E 题的得分要累加 n 次。
  3. 边界:A 题窗口是 [i−k+1, i] 而不是 [i−k, i];C 题二分的区间是 [1, L] 且中点要上取整;E 题区间长度只枚举到 n,不到 2n。
  4. 输出格式:A 题两行、行末只有换行;E 题先最小后最大,各占一行。
为什么这 4 行比代码重要

键盘会骗你:能编译通过的代码看起来总是对的。而这 4 行一旦写错,编译器一声不响。

本场五道题的丢分点几乎全在这 4 行里:A 的窗口左端点、B 的 n−best、C 的上取整、D 的 long long、E 的 2n 上界。

熟练之后这 5 分钟会压缩到 60 秒,但绝不能省。

开赛前 10 分钟做一道不计分的热身题,把“读题 → 排序 → 双指针 → 输出”这条最常用的链路跑热。它顺手复习了本场 C 题 check 函数要用的贪心:能塞就塞,塞不下就另起。

例题 · 纪念品分组洛谷 P1094普及-去洛谷做这题 ↗

元旦快到了,班长要把买来的纪念品按价格分组发给同学。每组最多只能放两件纪念品,并且每组纪念品的价格之和不能超过一个给定的上限 w。求最少需要分成几组。

输入格式:第一行一个整数 w(每组价格之和的上限);第二行一个整数 n(纪念品件数,n ≤ 30000);第三行 n 个正整数,表示每件纪念品的价格(均不超过 w)。

输出格式:一个整数,即最少的分组数。

输入: 100 9 90 20 20 30 50 60 70 80 90 输出: 6
热身题的 5 分钟怎么想

每组最多两件,所以答案的形状只有两种:一件一组,或一大一小配成一组。配得越多,组数越少。

  1. 排序后用双指针:l 指最小的、r 指最大的。
  2. 如果 p[l] + p[r] ≤ w,最便宜的能配最贵的,那就配——这一步不会让答案变差。
  3. 配不上就让 p[r] 自己一组(它太贵,配谁都超限),r 左移。
  4. l == r 时只剩一件,单独一组,结束。

复杂度 O(n log n),瓶颈在排序;双指针部分只有 O(n) 次比较。n = 30000 时几乎瞬间出答案。

热身题完整代码
#include <bits/stdc++.h>
using namespace std;

int w, n, p[30005];

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> w >> n;
    for (int i = 1; i <= n; ++i) cin >> p[i];
    sort(p + 1, p + n + 1);            // 双指针的前提:有序

    int l = 1, r = n, ans = 0;
    while (l <= r) {
        if (l == r) { ++ans; break; }         // 只剩一件,单独一组
        if (p[l] + p[r] <= w) { ++l; --r; }   // 最便宜的配最贵的
        else --r;                             // 配不上:最贵的自己一组
        ++ans;
    }
    cout << ans << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
sort(p + 1, p + n + 1);双指针的正确性依赖有序:只有排好序才能说“最贵的配谁都超”。区间左闭右开,右端写 n+1。
int l = 1, r = n, ans = 0;ans 必须初始化为 0。局部变量不赋值就是垃圾值,累加后答案随机。
while (l <= r)区间还没空就继续。不是 l < r:l == r 时还剩一件,要单独成组。
if (l == r) { ++ans; break; }先处理只剩一件的情况,否则后面 p[l] + p[r] 会把同一件算两遍。
if (p[l] + p[r] <= w)用 ≤ 不用 <:等于上限也是合法的。这里写错,边界数据全错。
++l; --r;配成一组后两个指针都动。只动一个的话,p[l] 会被重复使用。
else --r;最贵的配不上最小的,那它配谁都不行,只能自己一组。l 不动,留给下一轮配更便宜的大件。
cout << ans << "\n";只输出一个整数加换行。题目没要求任何提示语。

热身完,看四张模板卡。它们不是新知识,是把本场五道题各自要用的骨架再默写一遍——赛场上默写不出来的模板,等于没学过。先能一次默写正确,再开始计时。

新函数拆解 · 单调队列:队尾弹逆序、队头出窗口
int h = 1, t = 0; // 手写双端队列,h > t 表示空 for (int i = 1; i <= n; ++i) { while (h <= t && a[q[t]] >= a[i]) --t; // ① 队尾活不下来 q[++t] = i; // ② 新下标入队 if (q[h] < i - k + 1) ++h; // ③ 队头滑出窗口 [i-k+1, i] if (i >= k) ans = a[q[h]]; // ④ 此时队头就是答案 }
组成含义 / 格式要求
q[++t] = i队列里存下标,不存值。存下标才能判断“是否滑出窗口”,也能反查 a[q[h]]。
while (h <= t && a[q[t]] >= a[i]) --t求最小值用 >=;求最大值把 >= 换成 <=,其余不动。方向写反,队头就不是最值。
h <= t空队列判据:h = t + 1 即空。写成 h < t 会把“只剩一个元素”误判成空队列。
if (q[h] < i - k + 1) ++h窗口左端点是 i − k + 1。每步最多过期一个下标,所以用 if 就够,不必 while。
if (i >= k)前 k − 1 步窗口还没填满,不能输出。
总复杂度 O(n)每个下标进队一次、出队一次,while 的总执行次数不超过 n。暴力是 O(nk),最坏 10¹²。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int a[6] = {0, 5, 3, 8, 1, 9};   // 1 开始存,n = 5
int q[10];

int main() {
    int n = 5, k = 3, h = 1, t = 0;
    for (int i = 1; i <= n; ++i) {
        while (h <= t && a[q[t]] >= a[i]) --t;
        q[++t] = i;
        if (q[h] < i - k + 1) ++h;
        if (i >= k) cout << a[q[h]] << " ";   // 输出 3 1 1
    }
    return 0;
}
注意

队列里存下标、不存值——这是这一节唯一需要背死的一句话。

求最大值时,队尾弹出条件和答案取值都要反过来;只改一半是这类题最常见的错法。

新函数拆解 · 树状数组:单点加、前缀和查询
int lowbit(int x) { return x & -x; } void add(int i, int v) { for (; i <= sz; i += lowbit(i)) t[i] += v; } int sum(int i) { int s = 0; for (; i > 0; i -= lowbit(i)) s += t[i]; return s; }
组成含义 / 格式要求
lowbit(x) = x & -x取 x 最低位的 1 所代表的权值:lowbit(6) = 2,lowbit(8) = 8,lowbit(0) = 0。
add(i, v)把 i 这条链上的结点都加 v:i, i+lowbit(i), i+lowbit(i+lowbit(i)) …,直到超过 sz。O(log n)。
sum(i)前缀和:t[i] + t[i−lowbit(i)] + … 直到下标 0。O(log n)。区间和 = sum(r) − sum(l−1)。
下标必须 ≥ 1lowbit(0) = 0 会让 add 的循环变量原地不动,死循环 → TLE。
sz值的上界(本题是离散化后不同值的个数)。add 的终止条件是 i <= sz,不是 i <= n。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int t[20], sz = 8;
int lowbit(int x) { return x & -x; }
void add(int i, int v) { for (; i <= sz; i += lowbit(i)) t[i] += v; }
int sum(int i) { int s = 0; for (; i > 0; i -= lowbit(i)) s += t[i]; return s; }

int main() {
    add(3, 1); add(3, 1); add(6, 1);   // 排名 3 出现两次,排名 6 出现一次
    cout << sum(2) << "\n";            // 0:排名 ≤ 2 的没有
    cout << sum(3) - sum(2) << "\n";   // 2:排名恰为 3 的个数
    cout << sum(6) << "\n";            // 3:排名 ≤ 6 的总个数
    return 0;
}
注意

sum 与 add 的循环方向相反:一个 i -= lowbit(i),一个 i += lowbit(i)。写反就是死循环或答案偏小。

求逆序对时查 sum(r−1) 而不是 sum(r),否则相等元素会被算成逆序对。

本题每次只“加 1”,所以 D 题代码里把 add 简化成 void add(int i) { ++t[i]; },省掉了加数参数。

新函数拆解 · 离散化:把 10⁹ 的值压成 5×10⁵ 的排名
sort(b + 1, b + n + 1); sz = unique(b + 1, b + n + 1) - (b + 1); int r = lower_bound(b + 1, b + sz + 1, a[i]) - b;
组成含义 / 格式要求
sort(b + 1, b + n + 1)先把原数组复制一份到 b,排序。a 本身不动,后面还要按下标扫。
unique(b+1, b+n+1)把相邻的重复元素搬到末尾,返回“去重后区间的尾后指针”。它不删除元素,只是搬家。
- (b + 1)去重后的元素个数 sz。写成 - b 会多 1,是所有离散化 bug 里最常见的一个。
lower_bound(b+1, b+sz+1, x) - b第一个 ≥ x 的位置,即 x 的排名(从 1 开始)。右端点是 sz+1,不是 n+1。
相等元素拿到同一个排名这正是需要的:逆序对要求严格大于,用 r−1 查询天然排除相等的情况。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int a[6] = {0, 1000000000, -5, 7, 7, 233};
int b[6];

int main() {
    int n = 5;
    for (int i = 1; i <= n; ++i) b[i] = a[i];
    sort(b + 1, b + n + 1);
    int sz = unique(b + 1, b + n + 1) - (b + 1);   // 4 个不同值
    for (int i = 1; i <= n; ++i)
        cout << lower_bound(b + 1, b + sz + 1, a[i]) - b << " ";
    // 输出 4 1 2 2 3
    return 0;
}
注意

if (x < 1 || x > sz) 说明离散化写错了:出现这种情况几乎都是 sz 的表达式少减了一次。

离散化不改变答案的正确性,它只是把“值域”换成“排名”,让桶和树状数组开得下。

新函数拆解 · 断环成链:把环拆成 2n 的链再区间 DP
for (int i = 1; i <= n; ++i) { cin >> a[i]; a[i + n] = a[i]; } for (int len = 2; len <= n; ++len) for (int i = 1; i + len - 1 <= 2 * n; ++i) { int j = i + len - 1; for (int k = i; k < j; ++k) f[i][j] = min(f[i][j], f[i][k] + f[k + 1][j] + pre[j] - pre[i - 1]); } // 答案 = 对 i ∈ [1, n] 取 f[i][i + n - 1] 的最优值
组成含义 / 格式要求
a[i + n] = a[i]把数组复制一份接在后面。环上任意一种“从哪断开”的走法,都对应 2n 链上一个长度为 n 的连续区间。
for (len = 2; len <= n; ++len)按区间长度从小到大算。区间 DP 的方向是“先短后长”,长度 1 的区间是初值。
i + len - 1 <= 2 * n右端点不能越过 2n。只写 i <= n 是不够的:短区间的右端点完全可以超过 n。
for (k = i; k < j; ++k)枚举最后一次合并的分界:左半 a[i..k]、右半 a[k+1..j]。k 不能取到 j,否则右半为空。
+ pre[j] - pre[i - 1]本次得分 = 这一段的总和,用前缀和一次算出。写两层求和会把 O(n³) 变成 O(n⁴)。
答案取 n 个区间环有 n 种断法,对应 n 个长度为 n 的区间,答案在这 n 个里取最优。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

long long pre[10], f[10][10];

int main() {
    int a[4] = {0, 4, 5, 9};                 // 三堆:4 5 9
    for (int i = 1; i <= 3; ++i) pre[i] = pre[i - 1] + a[i];
    for (int len = 2; len <= 3; ++len)
        for (int i = 1; i + len - 1 <= 3; ++i) {
            int j = i + len - 1;
            f[i][j] = LLONG_MAX;
            for (int k = i; k < j; ++k)
                f[i][j] = min(f[i][j], f[i][k] + f[k + 1][j] + pre[j] - pre[i - 1]);
        }
    cout << f[1][3] << "\n";                 // 27:先 4+5=9,再 9+9=18
    return 0;
}
注意

长度只枚举到 n,不要到 2n:长度大于 n 的区间在环上没有意义,还会把数组写越界。

求最小值的那张表初值要是 INF(memset 0x3f),求最大值的要是 0。两张表不能共用初值。

五道题写完,把下面这张单子对着自己的提交记录看一遍。每一条都写清了症状,症状能对上,就不用再猜了。

本场最容易丢的分,每条都写明症状

单调队列存值不存下标 → WA。A 题 q[] 里存 a[i] 的值,就没法判断队头是否滑出窗口,只能靠“值相等”猜,一遇到重复数字全错。

h <= t 写成 h < t → WA 或 RE。只剩一个元素时被判成空队列,q[h] 读到的是过期下标,输出一个根本不在窗口里的数。

窗口左端点写成 i−k → WA。窗口是 [i−k+1, i],差这一格会让每个窗口多留一个过期元素,最小值普遍偏小、最大值普遍偏大。

LIS 的比较写成 ≤ → WA。B 题要求严格递增,写 a[j] <= a[i] 时,两个同身高的同学会被串进同一条上升链。样例前两个人身高都是 186,答案会小 1。

B 题把 n − best 写成 best → WA。题目问的是“出列几人”,best 却是“留下几人”。样例里 n = 8、best = 4,两种写法都能过样例——这就是为什么必须自己再造一组数据:n = 5、身高 1 2 1 2 1 时 best = 3 而答案是 2,一眼就露。

二分中点用了下取整 → TLE。C 题求“最大可行值”,mid 必须上取整;写成 (lo+hi)/2 配 lo = mid,当 hi = lo+1 时 mid 恒等于 lo,区间不再缩小,程序空转到超时。

check 漏了最后一跳 → WA。C 题只判石头与石头之间、忘了 L − last,样例能过,错在“最后一段太短”的数据上。

逆序对答案用 int → WA 成负数。D 题 n = 5×10⁵ 全逆序时答案 ≈ 1.25×10¹¹,int 上限才 2.1×10⁹,溢出后输出一个负数。

树状数组下标从 0 开始 → TLE。lowbit(0) = 0,add(0) 里 i += 0 原地踏步,死循环。

E 题 f 初值设成 0 → WA。求最小值的表如果不 memset 成 INF,min 永远取到 0,输出 0;而求最大值的表初值又必须是 0,两张表的初值不能共用。

E 题 i + len − 1 越界 → RE。数组只开到 205 而循环边界写错,越界写的可能是另一个数组,表现为“样例全过、一交就崩”。

自己动手复现

关掉页面,开一个空文件,从 #include <bits/stdc++.h> 开始,给 A 题(滑动窗口)掐 22 分钟写完并提交。

计时前先在纸上写三行:队列里存什么、队尾弹出的条件、队头过期的条件。三行写对再动手。

改一改:把窗口长度改成“每一步都不同”(第 i 步的窗口是 [i − kᵢ + 1, i]),单调队列还成立吗?成立,只是过期判断要跟着 kᵢ 走。

再改一改:如果要求每个窗口的中位数,单调队列还够用吗?把答案写下来,第 49 节的对顶堆会回答这道题。

思路卡住时的三级降级

一级:写暴力。A 题每个窗口重扫一遍是 O(nk),小数据点能拿分;C 题从小到大枚举答案 d,每个 d 用 O(n) 判断,也是能过的暴力。有分的代码才叫代码。

二级:换实现。D 题想不出树状数组,就用归并排序统计逆序对——O(n log n) 一样能过,第 46 节的思路本来就是从归并里长出来的。

三级:只写输入输出。哪怕答案是猜的,也要把格式写对,保留“万一”的分数。

绝不做的事:在一道题上耗超过 45 分钟。本场五题分值相同,耗死一题等于放弃四题。

下面是正式的五道题。先掐表自己做,卡满单题用时再看提示;提示看完还没思路,再点开思路;五题全做完之后再点开代码对照——顺序反了,这场模拟赛就白考了。

题目 · 共 5 题

题号题目难度建议用时
A滑动窗口 /【模板】单调队列普及/提高-22 分钟
B合唱队形普及/提高-23 分钟
C跳石头普及/提高-28 分钟
D逆序对普及/提高-30 分钟
E石子合并普及/提高-32 分钟
比赛规则(自己执行)
  • 先花 8 分钟通读全部题目,按“会做 → 有思路 → 完全不会”排序。
  • 单题最多投入 35 分钟,超过就换题。
  • 每题先写暴力拿部分分,再想正解。
  • 最后 15 分钟不再改算法,只按清单检查数组大小、long long、多测清空、输出格式。
大题 挑战题 · 滑动窗口 /【模板】单调队列
洛谷 P1886普及/提高-去洛谷做这题 ↗

有一个长为 n 的序列 a,以及一个大小为 k 的窗口。窗口从序列最左端开始,每次向右滑动一个单位,直到右端到达序列末尾。对每一个位置上的窗口,求出窗口内元素的最小值和最大值。

输入格式:第一行两个正整数 n、k(1 ≤ k ≤ n ≤ 10⁶)。第二行 n 个整数,表示序列 a(绝对值不超过 10⁹)。

输出格式:共两行。第一行为每个窗口的最小值,第二行为每个窗口的最大值,同一行的数字之间用一个空格隔开。

样例输入
8 3
1 3 -1 -3 5 3 6 7

样例输出
-1 -3 -3 -3 3 3
3 3 5 5 6 7

引导性提示(先只看这里)

先估一下暴力:n 和 k 都可能到 10⁶,每个窗口重扫一遍是 O(nk),最坏 10¹²,稳 TLE。

换角度看:窗口右移一格,只进来一个新数、只出去一个旧数。哪些数从此再也不可能成为最小值?

如果新来的数比当前候选里最后一个还小,那个候选在过期之前都不可能成为答案——因为新数既更小、又活得更久。

想看思路了?点开完整思路

维护一个下标队列 q,保证 q 里的下标递增、对应的 a 值也递增。此时队头 a[q[h]] 就是当前窗口的最小值。

每步三件事:① 新元素入队前,把队尾所有“值 ≥ 新元素”的下标弹掉——它们既不如新元素小、又比新元素先过期,永远没机会了;② 把 i 放进队尾;③ 检查队头是否还在窗口 [i−k+1, i] 里,不在就弹掉。

正确性:队列里值单调递增,所以队头是整个队列的最小值;队头一定在窗口内(过期就弹了)。被弹掉的下标要么值更大且更靠左,要么已经过期,都不可能成为任何后续窗口的答案。

复杂度:每个下标最多进队一次、出队一次,while 的总执行次数不超过 n,整体 O(n)。

最大值完全同构:把队尾弹出条件从 a[q[t]] ≥ a[i] 改成 a[q[t]] ≤ a[i],别的一个字都不改。

输出细节:前 k−1 步窗口没填满,不输出;行末只换行,不加多余空格。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int n, k;
int a[1000005];
int q[1000005];                  // 手写双端队列,存的是下标

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> k;
    for (int i = 1; i <= n; ++i) cin >> a[i];

    // ---- 最小值:队列里的下标对应的 a 值严格递增,队头即窗口最小值 ----
    int h = 1, t = 0;                        // h > t 表示队列为空
    for (int i = 1; i <= n; ++i) {
        while (h <= t && a[q[t]] >= a[i]) --t;   // 队尾比新来的大:它这辈子都比不过新来的
        q[++t] = i;
        if (q[h] < i - k + 1) ++h;               // 队头已经滑出窗口左端
        if (i >= k) cout << a[q[h]] << (i == n ? '\n' : ' ');
    }

    // ---- 最大值:同一个骨架,只把比较方向反过来 ----
    h = 1; t = 0;
    for (int i = 1; i <= n; ++i) {
        while (h <= t && a[q[t]] <= a[i]) --t;
        q[++t] = i;
        if (q[h] < i - k + 1) ++h;
        if (i >= k) cout << a[q[h]] << (i == n ? '\n' : ' ');
    }
    return 0;
}
关键片段拆解
片段作用
int q[1000005];手写队列,不比 STL 的 deque 慢,还省掉了迭代器和内存分配。n ≤ 10⁶,开 1000005。
int h = 1, t = 0;队头指针 h、队尾指针 t,初始 h > t 表示空队列。用 1 开始,方便和 a 的下标对齐。
while (h <= t && a[q[t]] >= a[i]) --t;弹掉队尾所有“值比新元素大”的下标。它们更靠左(先过期)、又更大(更差),被新元素彻底支配。求最大值时把 >= 改成 <=。
q[++t] = i;存下标,不存值。存了值就无法判断队头有没有滑出窗口。
if (q[h] < i - k + 1) ++h;窗口是 [i−k+1, i]。每步只可能有一个下标过期,所以用 if 就够。
if (i >= k)前 k−1 步窗口还没填满,此时输出的是“不足 k 个元素”的答案,必须跳过。
cout << a[q[h]] << (i == n ? '\n' : ' ');数字之间一个空格;最后一个窗口后面直接换行,不多输出行末空格。两行的写法完全一样。
h = 1; t = 0; 第二遍重开两个方向必须各自清空队列。不清空的话,最大值那一遍会读到最小值遗留的下标,结果全错。
这题最容易错的地方
  • 队列里存值而不是下标:无法判断过期,一遇到重复数字就 WA。
  • 空队列判据写成 h < t:只剩一个元素时被判空,q[h] 读到过期下标,输出窗口外的数。
  • 窗口左端点写成 i−k:每个窗口多留一个过期元素,最小值偏小、最大值偏大。
  • 两遍共用一个没清空的队列:第二遍的队头是上一遍的残留下标,整行答案错位。
  • n = k 时只输出一个数,却忘了换行——末尾那个字符写错,两行变一行。
  • 求最大值时只把答案取值改了、忘了改队尾弹出方向,队列失去了单调性,答案随机错。
↑ 第 A 题 · 滑动窗口 /【模板】单调队列
大题 挑战题 · 合唱队形
洛谷 P1091普及/提高-去洛谷做这题 ↗

n 位同学站成一排,音乐老师要请其中的 n−k 位同学出列,使剩下的 k 位同学排成“合唱队形”。合唱队形指存在一个“顶峰”位置 i,使身高满足 t₁ < t₂ < … < tᵢ > tᵢ₊₁ > … > t_k。求最少需要几位同学出列。

输入格式:第一行一个整数 n(2 ≤ n ≤ 100)。第二行 n 个整数,第 i 个表示第 i 位同学的身高 tᵢ(130 ≤ tᵢ ≤ 230)。

输出格式:一个整数,即最少需要出列的人数。

样例输入
8
186 186 150 200 160 130 197 220

样例输出
4
样例解释:留下 186、200、160、130 这 4 位同学即可排成合唱队形,其余 4 人出列。

引导性提示(先只看这里)

“留下的人”必须是先升后降的。形状只由一件事决定:谁是那个最高的人。

枚举顶峰 i 之后,左右两段互相独立:左边是“以 i 结尾的最长上升子序列”,右边是“以 i 开头的最长下降子序列”。

n ≤ 100,所以 O(n²) 的朴素 LIS 完全够用,不要上 O(n log n),那样只是给自己增加写错的机会。

想看思路了?点开完整思路

答案 = n − (能留下的最多人数)。所以问题变成“最多能留下几人”。

枚举顶峰 i。左边必须以 i 结尾且严格递增,长度就是 f[i] = 以 i 结尾的最长上升子序列;右边必须以 i 开头且严格递减,长度是 g[i] = 以 i 开头的最长下降子序列。

以 i 为顶峰时的最大保留人数 = f[i] + g[i] − 1,减 1 是因为顶峰自己被两段都算了一次。

f 从左往右推:f[i] = 1 + max{ f[j] : j < i 且 a[j] < a[i] },没有合法的 j 时 f[i] = 1。

g 从右往左推,转移条件同样是 a[j] < a[i]:g[i] = 1 + max{ g[j] : j > i 且 a[j] < a[i] }。

两遍都是 O(n²),总 O(n²) = 10⁴ 次操作。最后输出 n − max(f[i]+g[i]−1)。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int n, a[105];
int f[105], g[105];              // f:以 i 结尾的最长上升长度;g:以 i 开头的最长下降长度

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    for (int i = 1; i <= n; ++i) cin >> a[i];

    // 左半边:以 i 结尾的最长上升子序列
    for (int i = 1; i <= n; ++i) {
        f[i] = 1;                            // 至少包含自己
        for (int j = 1; j < i; ++j)
            if (a[j] < a[i]) f[i] = max(f[i], f[j] + 1);   // 严格递增:不能取等
    }

    // 右半边:以 i 开头的最长下降子序列,从右往左推
    for (int i = n; i >= 1; --i) {
        g[i] = 1;
        for (int j = i + 1; j <= n; ++j)
            if (a[j] < a[i]) g[i] = max(g[i], g[j] + 1);
    }

    int best = 0;                            // 能留下的最多人数
    for (int i = 1; i <= n; ++i)
        best = max(best, f[i] + g[i] - 1);   // 顶峰被算两次,减 1

    cout << n - best << "\n";                // 问的是出列人数,不是留下人数
    return 0;
}
关键片段拆解
片段作用
f[i] = 1;初始化为 1:即使前面没有比自己矮的人,自己也能单独构成长度 1 的上升段。
if (a[j] < a[i])严格小于。写成 <= 时,两个同身高的同学会被接进同一条上升链,样例前两人身高都是 186,答案会偏小。
f[i] = max(f[i], f[j] + 1);枚举 i 前面所有更矮的人,取最长的那个再接上自己。这就是 LIS 的 O(n²) 转移。
for (int i = n; i >= 1; --i)求“以 i 开头”的下降段要从右往左推:算 g[i] 时,它右边所有 g[j] 必须已经算好。
if (a[j] < a[i]) g[i] = ...“往右走必须更矮”等价于“右边的 a[j] < a[i]”,条件和左边一模一样,只是方向相反。
f[i] + g[i] - 1两段在顶峰 i 处相交,i 被算了两次,必须减 1。忘了减,答案会少 1,样例输出变成 3。
n - best题目问的是“出列几人”。这一步最容易漏,而样例里 n−best = 4 恰好等于 best,所以漏了也能过样例——必须自己造数据验。
cout << n - best << "\n";只输出一个整数加换行。
这题最容易错的地方
  • 把 f[i]+g[i]−1 写成 f[i]+g[i]:顶峰算两遍,答案少 1,样例输出 3 而不是 4。
  • 把 n − best 写成 best:样例恰好都是 4,本地完全看不出来,交上去全 WA。
  • LIS 比较写成 a[j] <= a[i]:同身高被串成一条链,只要数据里有重复身高就错。
  • g 也从左往右推:算 g[i] 时右边的 g 还是初值 0,右边段长度全变成 1。
  • 数组只开 100:n = 100 时 a[100] 越界,RE 或答案错乱。开 105。
  • 输出的是“留下人数”而不是“出列人数”,或反过来,两种都有人写错,看清题目问哪个。
↑ 第 B 题 · 合唱队形
大题 挑战题 · 跳石头
洛谷 P2678普及/提高-去洛谷做这题 ↗

比赛在一条笔直的河道中进行,河道中有 N 块岩石(不含起点与终点)。选手从起点出发,每一步跳到相邻的岩石上,直到到达终点。组委会至多可以移走 M 块岩石(起点和终点不能移走),要求移走之后,选手所有跳跃距离中的最小值尽可能大。求这个最大可能的最短跳跃距离。

输入格式:第一行三个整数 L、N、M,分别表示起点到终点的距离、岩石数、至多移走的岩石数;接下来 N 行,每行一个整数 Dᵢ,表示第 i 块岩石到起点的距离,按从小到大给出。(0 ≤ M ≤ N ≤ 50000,0 < L ≤ 10⁹)

输出格式:一个整数,即最短跳跃距离的最大值。

样例输入
25 5 2
2
11
14
17
21

样例输出
4

引导性提示(先只看这里)

“最大值最小 / 最小值最大”这类字眼一出现,先想二分答案。这里问的是“最短跳跃距离的最大值”。

固定一个 d,问:能不能只移走不超过 M 块石头,让每一跳都 ≥ d?这个判断比原问题好想得多。

判断的时候从左往右贪心:能跳到就跳,跳不到就把中间那块石头搬走。搬走一块之后,跨度会变大还是变小?

想看思路了?点开完整思路

二分答案 d:判定“最短跳跃距离 ≥ d”是否可行。d 越大越难满足,单调性成立。

check(d):last 记录上一块“保留下来的落脚点”坐标(起点是 0),从左往右扫岩石。若 a[i] − last < d,这跳太短,只能把第 i 块搬走(cnt++),last 不变;否则保留它,last = a[i]。

扫完还要检查最后一跳 L − last:若它 < d,就把最后保留的那块石头搬走(不是搬终点),这样“上一落脚点 → L”的距离只会更大,一定也 ≥ d。

为什么贪心最优:能保留就保留,保留只会让后续的距离更容易够(last 更靠右)。提前搬走一块只会让跨度更大、消耗更多次数,不可能更优。

判定条件是 cnt ≤ M:不超过 M 块就合法,题目说的是“至多移走 M 块”。

二分区间 [1, L]:答案至少 1,至多 L(一块石头都不留时就是 L)。求最大可行值,中点要上取整。

复杂度 O(N log L) ≈ 5×10⁴ × 30 = 1.5×10⁶,稳过。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int L, n, m;
int a[50005];                    // a[1..n] 是岩石坐标;起点 0 由 last 的初值表示

// 判定:能不能只移走 ≤ m 块石头,使每一跳都 ≥ d
bool check(int d) {
    int cnt = 0, last = 0;               // last:上一个保留的落脚点
    for (int i = 1; i <= n; ++i) {
        if (a[i] - last < d) ++cnt;      // 这跳太短,只能搬走第 i 块
        else last = a[i];                // 跳得到,保留它
    }
    if (L - last < d) ++cnt;             // 最后一跳不够长:搬走 last 这块(不是终点)
    return cnt <= m;                     // “至多 m 块”,不是“恰好 m 块”
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> L >> n >> m;
    for (int i = 1; i <= n; ++i) cin >> a[i];

    int lo = 1, hi = L;                  // 答案至少 1,至多 L
    while (lo < hi) {
        int mid = lo + (hi - lo + 1) / 2;    // 上取整:求最大可行值必须这么写
        if (check(mid)) lo = mid;            // 可行,试着更大
        else hi = mid - 1;                   // 不可行,必须更小
    }
    cout << lo << "\n";
    return 0;
}
关键片段拆解
片段作用
if (a[i] - last < d) ++cnt;这一跳到上一落脚点不足 d,唯一办法是把第 i 块搬走。用 < 不用 <=:距离恰好等于 d 是合法的。
else last = a[i];跳得到就落脚。贪心的全部内容就这一行:保留永远不比搬走差。
if (L - last < d) ++cnt;最后一跳也要判。这里搬走的是 last(上一块保留的石头),搬走之后“更早的落脚点 → L”只会更远,一定也 ≥ d。
return cnt <= m;题目说“至多移走 M 块”。写成 == m 会在“不用搬那么多”的数据上判不可行。
int lo = 1, hi = L;下界 1:任意两块石头位置不同,最短跳至少 1。上界 L:一块石头都不留就是 L。写 hi = 1e9 也能过,但 hi = L 更贴题意。
mid = lo + (hi - lo + 1) / 2上取整。求最大可行值时,区间只剩两个元素时 mid 必须取右边那个,否则 lo = mid 会让区间卡死 → TLE。
if (check(mid)) lo = mid;可行就把下界提到 mid(mid 本身可能正是答案,不能写 mid+1)。
else hi = mid - 1;不可行就砍掉 mid 及其右边。
这题最容易错的地方
  • 中点写成下取整 (lo+hi)/2,又配 lo = mid:hi = lo+1 时 mid 恒等于 lo,死循环 TLE。
  • check 里漏了 L − last:样例恰好不需要判最后一跳,本地全过,交上去错在“最后一段太短”的点。
  • 判定条件写成 cnt == m:题目是“至多”,能少搬的时候反而被判不可行,答案偏小。
  • 贪心写成“一跳不够 d 就搬走上一块”:last 回退会让后面的判断全乱,算出的 cnt 偏大。
  • 只测了样例就交:M = 0 时答案应是最小相邻间距(样例数据是 2),M = N 时应是 L(样例数据是 25),这两个边界各写一行测一下,30 秒的事。
  • 数组只开 50000:n = 50000 时 a[50000] 用到最后一个下标,必须开 50005。
↑ 第 C 题 · 跳石头
大题 挑战题 · 逆序对
洛谷 P1908普及/提高-去洛谷做这题 ↗

对于给定的一段正整数序列,逆序对就是序列中满足 aᵢ > aⱼ 且 i < j 的有序对 (i, j)。给定一个长度为 n 的序列,求它的逆序对数目。注意序列中可能有重复数字。

输入格式:第一行一个整数 n(n ≤ 5×10⁵)。第二行 n 个整数,表示给定的序列,每个数字不超过 10⁹。

输出格式:一个整数,即逆序对的数目。

样例输入
6
5 4 2 6 3 1

样例输出
11

引导性提示(先只看这里)

n = 5×10⁵,枚举 (i, j) 是 1.25×10¹¹ 对,稳 TLE。目标复杂度 O(n log n)。

换个计数角度:对每个 j,数一数它左边有多少个数比 aⱼ 大,全部加起来就是答案。

“左边有多少个数比它小”这种前缀计数,值域又只有 5×10⁵ 个不同值——把 10⁹ 压成排名,就能用树状数组一次 log 查出来。

想看思路了?点开完整思路

逆序对总数 = 对每个位置 i,统计“右边比 aᵢ 小的元素个数”,全部累加。

值域到 10⁹,桶开不下。先离散化:把 a 复制成 b,排序去重,用 lower_bound 把每个 aᵢ 换成排名,排名范围 1..sz,sz ≤ n ≤ 5×10⁵。

从右往左扫。树状数组 t 里记的是“某个排名已经出现过几次”。扫到 aᵢ 时,排名是 r,则 sum(r−1) 就是右边比它小的个数,直接加进答案;然后把 r 加进树状数组。

为什么用 r−1 而不是 r:逆序对要求严格大于,相等元素不能算。排名相同意味着值相等,必须排除。

正确性:每个逆序对 (i, j) 在扫描到 i 时被恰好数到一次——那时 j 已经进树状数组,且排名更小。

复杂度 O(n log n):离散化 O(n log n),每次查询和修改 O(log n),总计约 5×10⁵ × 19 × 2 次操作。

答案上界:n(n−1)/2 ≈ 1.25×10¹¹,必须 long long。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int n;
int a[500005], b[500005];        // b 是排序去重用的副本
int t[500005];                   // 树状数组:t[i] 记录“排名 i 出现了几次”
int sz;                          // 排名上界 = 不同值的个数

int lowbit(int x) { return x & -x; }

void add(int i) {
    for (; i <= sz; i += lowbit(i)) ++t[i];
}

int sum(int i) {                 // 排名 ≤ i 的元素个数
    int s = 0;
    for (; i > 0; i -= lowbit(i)) s += t[i];
    return s;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    for (int i = 1; i <= n; ++i) { cin >> a[i]; b[i] = a[i]; }

    // 离散化:排序 + 去重,把值换成排名
    sort(b + 1, b + n + 1);
    sz = unique(b + 1, b + n + 1) - (b + 1);

    long long ans = 0;                       // 逆序对最多 1.25×10¹¹,int 装不下
    for (int i = n; i >= 1; --i) {           // 从右往左,保证树状数组里只有 i 右边的数
        int r = lower_bound(b + 1, b + sz + 1, a[i]) - b;   // a[i] 的排名
        ans += sum(r - 1);                   // 右边严格小于 a[i] 的个数
        add(r);
    }

    cout << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
b[i] = a[i];复制一份再排序,a 本身不能动:后面扫的时候还要按原下标取值。
unique(b + 1, b + n + 1) - (b + 1)去重后不同值的个数。它不删除元素,只是把重复的搬到末尾,返回值是去重区间的尾后指针。
lower_bound(b + 1, b + sz + 1, a[i]) - b第一个 ≥ a[i] 的位置,就是从 1 开始的排名。右端点是 sz+1,不是 n+1。
for (int i = n; i >= 1; --i)倒着扫:扫到 i 时,树状数组里装的正是 a[i+1..n],所以 sum(r−1) 天然就是“右边比它小的个数”。
ans += sum(r - 1);用 r−1 排除相等元素。写成 sum(r) 会把值相同的一对也当成逆序对。
add(r);把这个排名计入树状数组,供更左边更小的下标查询。别忘了这一句,否则答案恒为 0。
long long ansn = 5×10⁵ 且完全逆序时答案 = n(n−1)/2 ≈ 1.25×10¹¹,int 上限 2.1×10⁹,溢出后是负数。
int lowbit(int x) { return x & -x; }x 与它的相反数按位与,留下最低位的 1。下标必须 ≥ 1,否则 lowbit(0)=0 造成死循环。
这题最容易错的地方
  • ans 用 int:溢出成负数,WA 且极难自查(样例只有 11,本地完全看不出来)。
  • 相等元素也被算成逆序对:查 sum(r) 而不是 sum(r−1),遇到重复数字就错。
  • 离散化时 sz 写成 unique(...) - b:多算 1,lower_bound 到 sz+1 时越界,RE 或排名错位。
  • 开了树状数组却忘了 add(r),答案恒为 0。
  • 从左往右扫却仍写 sum(r−1):数到的是“左边比它小的”,方向完全反了。
  • 数组只开 100005(只够 10⁵):n = 5×10⁵ 的数据直接越界,RE 或答案错乱。开 500005。
  • a 数组被 sort 之后才拿来遍历:顺序全乱,逆序对数目无意义。所以必须复制到 b 再排序。
↑ 第 D 题 · 逆序对
大题 挑战题 · 石子合并
洛谷 P1880普及/提高-去洛谷做这题 ↗

在一个圆形操场的四周摆放 N 堆石子,现要将石子有次序地合并成一堆。规定每次只能选相邻的 2 堆合并成新的一堆,并把新堆的石子数记为本次合并的得分。求把 N 堆合并成一堆的最小总得分和最大总得分。

输入格式:第一行一个整数 N(N ≤ 100);第二行 N 个整数,依次表示每堆石子的个数(正整数)。

输出格式:共两行,第一行为最小得分,第二行为最大得分。

样例输入
4
4 5 9 4

样例输出
43
54

样例解释:最小方案先合并 4+4=8(得 8 分),再 8+5=13(13 分),再 13+9=22(22 分),共 43。

引导性提示(先只看这里)

先做一条链上的版本:n 堆排成一行,每次合并相邻两堆。把“最后一步”想清楚——它一定是把某个前缀段和某个后缀段合并。

于是需要知道“把 a[i..j] 合并成一堆”的最优得分。这就是区间 DP:先算小区间,再用小区间拼出大区间。

环怎么处理?一种断法对应一种走法,有 n 种断法。有没有办法不写 n 遍,一遍扫完?

想看思路了?点开完整思路

断环成链:把数组复制一份接在后面,得到 a[1..2n]。环上任意一种“从哪断开”的合并方案,都对应 2n 链上一个长度为 n 的连续区间。答案在这 n 个区间里取最优。

状态:f[i][j] = 把 a[i..j] 合并成一堆的最小得分;g[i][j] 同理求最大。

转移:最后一次合并必然把 a[i..k] 与 a[k+1..j] 两堆合成一堆,本次得分 = 这一段总和。f[i][j] = min over k∈[i, j−1] of f[i][k] + f[k+1][j] + (pre[j] − pre[i−1])。

顺序:按区间长度 len 从小到大枚举。长度 1 的区间不需要合并,得分 0。不能用“从 i 推到 j”的方式,因为 f[i][j] 依赖的所有子区间长度都比它短。

边界:f[i][i] = 0,其余 f 初值设成 INF;g 全部初值 0(最大值的下界就是 0)。

答案:对 i = 1..n 取 f[i][i+n−1] 的最小值、g[i][i+n−1] 的最大值。

复杂度 O(n³):区间 O(n²) 个(实际是 2n 个起点 × n 种长度),每个枚举 O(n) 个分界点,n = 100 时约 2×10⁶ 次。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int n;
long long a[205], pre[205];
long long f[205][205], g[205][205];      // f:最小得分,g:最大得分

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    for (int i = 1; i <= n; ++i) { cin >> a[i]; a[i + n] = a[i]; }   // 断环成链
    for (int i = 1; i <= 2 * n; ++i) pre[i] = pre[i - 1] + a[i];     // 前缀和,O(1) 取段和

    memset(f, 0x3f, sizeof(f));              // 求最小值:初值设成极大
    for (int i = 1; i <= 2 * n; ++i) f[i][i] = 0;   // 一堆不用合并,得分 0

    for (int len = 2; len <= n; ++len)                    // 区间长度,先短后长
        for (int i = 1; i + len - 1 <= 2 * n; ++i) {      // 左端点
            int j = i + len - 1;
            long long s = pre[j] - pre[i - 1];            // 本次合并的得分
            for (int k = i; k < j; ++k) {                 // 最后一次合并的分界
                f[i][j] = min(f[i][j], f[i][k] + f[k + 1][j] + s);
                g[i][j] = max(g[i][j], g[i][k] + g[k + 1][j] + s);
            }
        }

    long long mn = LLONG_MAX, mx = 0;
    for (int i = 1; i <= n; ++i) {                        // n 种断法
        mn = min(mn, f[i][i + n - 1]);
        mx = max(mx, g[i][i + n - 1]);
    }
    cout << mn << "\n" << mx << "\n";
    return 0;
}
关键片段拆解
片段作用
a[i + n] = a[i];把数组复制一份接在后面。环上每一种断法,对应链上一个长度为 n 的连续区间。
pre[i] = pre[i - 1] + a[i];前缀和。段和 pre[j]−pre[i−1] 是 O(1),这一步把 O(n⁴) 的朴素写法压到 O(n³)。
memset(f, 0x3f, sizeof(f));把 f 的每个字节设成 0x3f,得到一个约 4.5×10¹⁸ 的大数,代表“还没算 / 不可达”。
for (int i = 1; i <= 2 * n; ++i) f[i][i] = 0;长度为 1 的区间是递归的底:一堆石子不用合并,得分 0。漏了这一行,所有答案都会带上 INF。
for (int len = 2; len <= n; ++len)区间 DP 的方向:长度从小到大。长度超过 n 的区间在环上没有意义。
i + len - 1 <= 2 * n右端点不能越过 2n。写成 i <= n 会让短区间的右端点越过边界,越界写数组。
for (int k = i; k < j; ++k)枚举最后一次合并的分界点。k 从 i 到 j−1,保证左右两段都非空。
+ s本次合并的得分 = 合并后这一堆的石子数 = 这一段的总和。三项缺一不可。
mn = min(mn, f[i][i + n - 1]);n 种断法里取最优。注意区间长度是 n,右端点是 i+n−1。
这题最容易错的地方
  • f 没有 memset 成 INF:min 永远取到初值 0,输出 0;而 g 的初值又必须是 0,两张表不能共用初值。
  • 忘了 f[i][i] = 0:长度为 1 的区间保留 INF,所有转移都得到天文数字,输出一个十几位的怪数。
  • 区间长度枚举到 2n:长度 > n 的区间在环上无意义,且 i+len−1 会超过 2n 越界。
  • 循环顺序写成“先枚举左端点再枚举长度”:算 f[i][j] 时子区间还没算好,结果全错(有时代码还能跑,最难查)。
  • k 从 i 枚举到 j(含 j):右半段为空,f[j+1][j] 是无效状态,越界读。
  • 用 int 存得分:n = 100、每堆 10⁴ 时最大得分可达 10⁸ 量级,勉强够,但数据一放大就溢出,统一 long long。
  • 输出顺序写反(先最大后最小):样例一眼能看出,但赛场上经常看漏。
↑ 第 E 题 · 石子合并
赛后必做
  • 能在 8 分钟内读完五道题并排出顺序,明确说出“这题我先放弃”的是哪一道,而不是从 A 顺着硬做。
  • A 题 22 分钟内一次提交通过,能说清为什么队列里必须存下标而不是值。
  • B 题能解释 f[i]+g[i]−1 的 −1 从哪来,并说明为什么样例里“n−best”和“best”都是 4,所以必须自己造第二组数据自测。
  • C 题能默写二分答案的四行框架,并说清为什么求最大可行值时必须上取整、check 为什么要单独判最后一跳。
  • D 题能说清离散化三步里每一步的返回值含义,以及为什么查 sum(r−1) 而不是 sum(r)。
  • E 题能说清“断环成链”为什么等价于枚举 n 个长度为 n 的区间,以及区间 DP 为什么必须按长度递增枚举。
  • 交卷前会按顺序检查:数组大小、long long、循环边界、行末是否多空格。
  • 赛后当天把没做出来的题重写一遍并限时 40 分钟——不重写等于没考。
51 字符串哈希与 Trie提高-

51 · 字符串哈希与 Trie

把字符串压成整数、把前缀压成路径:多项式哈希与 ull 自然溢出解决整串比较,前缀哈希 + 幂表解决子串比较,Trie 解决前缀计数,01-Trie 解决最大异或。附带一套防冲突的规矩:基数大于字符集、模数用双哈希、溢出必须无符号。
提高-预计 65 分钟哈希Trie冲突
本节出现的新函数 / 新语法(先看懂再用)

字符串题只有两个基本动作:比较和查找。直接比较两个长 L 的串要 L 次字符比较;一万个长 1000 的串两两比较是 5×1010 次,必然超时。这一节给两种把动作变便宜的工具:哈希把整个串压成一个整数,比较退化成一次整数比较;Trie 把公共前缀折叠成一条路径,插入和查询都只走 L 步。Trie 的一个变体把整数按二进制位建树,就能回答“和 x 异或最大的数是谁”。

哈希回答“这两个串一样吗”,Trie 回答“这个前缀出现过几次”,01-Trie 回答“和 x 异或最大的数是谁”。三个问题,一条共同的思路:把串或数变成可以随手比较、随手走几步的东西。
任务规模工具单次操作主要风险
判断两个串是否相等长 106,比较 105 次多项式哈希O(1)哈希冲突 → 判成相等
求子串 [l, r] 的指纹长 106,询问 105 次前缀哈希 + 幂表O(1)同上
插入 / 查询一个串总长 106TrieO(len)数组开小 → RE
和 x 异或最大的数n ≤ 105,值 < 23101-TrieO(31)位宽取错 → WA

先把这一节会出现的 4 样东西拆开。它们都很短,但每一件都有一个“不这么写就错”的细节,这四处细节才是真正的考点。

新函数拆解 · unsigned long long(无符号 64 位整数)
typedef unsigned long long ull; ull h = 0; for (char c : s) h = h * B + (ull)c; // 溢出即取模
组成含义 / 格式要求
取值范围0 ~ 264−1 = 18446744073709551615,20 位十进制。
自然溢出无符号整数的运算按 264 取模:溢出的高位被硬件直接丢弃,不用写 %。
为什么快取模 % 是一条除法指令,10~40 个时钟周期;乘法和丢弃高位只要 1 个。106 次哈希能差 3 倍时间。
换成 long long 会怎样有符号整数溢出是未定义行为(UB)。编译器可以假设它不会发生,在 -O2 下把比较优化掉,同一份代码本地对、交上去错。
写法h = h * B + (ull)c;先把 char 转成无符号再加,字符值 ≥ 128 时才不会被符号扩展成负数。
怎么观察它cout 直接输出十进制;ull x = 0; --x; 得到 18446744073709551615。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;

int main() {
    ull x = 0;
    --x;                              // 0 - 1 回绕到 2^64 - 1
    cout << x << "\n";                // 18446744073709551615

    string s = "abc";
    ull h = 0;
    for (char c : s) h = h * 131 + (ull)c;   // 等价于 (h * 131 + c) % 2^64
    cout << h << "\n";

    // 同一个函数对 "abc" 永远给出同一个数,这就是“指纹”
    return 0;
}
注意

减法也回绕:ull a = 1, b = 2; a - b 得到 1.8×1019,不是 −1。哈希里大量用到“减法回绕”,这正是子串哈希公式能成立的原因。

把 ull 换成 long long,h * 131 一旦超过 9.2×1018 就是 UB,g++ 可能直接按“不溢出”优化,结果无法预测。

新函数拆解 · 双哈希(两套模数各算一遍)
pair<long long, long long> H(const string& s);
组成含义 / 格式要求
为什么一个不够模 264 且基数固定时,存在确定的构造性攻击(Thue–Morse 串)。洛谷 P3370 的“拓展阅读”里整排 Hash Killer 就是干这个的。
做法对同一个串,用两组 (模数, 基数) 各算一个值,组成 pair 一起比较。
模数怎么选两个互不相同的大质数:109+7 与 109+9,或 998244353 与 109+7。
基数怎么选大于字符集(62),常用质数 131、13331。两套的基数也要不同,否则等于一个哈希算两遍。
比较方式pair 自带 operator< 和 operator==(先比 first,再比 second),可以直接 sort、unique、放 set。
代价常数 ×2,代码多三行。字符串题里这是最便宜的保险。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const ll M1 = 1000000007, M2 = 1000000009;    // 两个不同的大质数
const ll B1 = 131,        B2 = 13331;        // 两套基数也要不同

pair<ll, ll> H(const string& s) {
    ll h1 = 0, h2 = 0;
    for (unsigned char c : s) {              // 转无符号,防止负字符
        h1 = (h1 * B1 + c) % M1;
        h2 = (h2 * B2 + c) % M2;
    }
    return {h1, h2};
}

int main() {
    vector<pair<ll, ll>> v = {H("abc"), H("abc"), H("abd")};
    sort(v.begin(), v.end());
    cout << unique(v.begin(), v.end()) - v.begin() << "\n";   // 2
    return 0;
}
注意

两个模数都取 109 量级,乘积约 1018;随机数据下 n = 105 个串的期望冲突数约 5×10−9。

别用 998244353 与 109+7 配同一个基数——基数相同会让两个哈希产生关联,安全性下降。

更狠的一招是运行时随机基数:B = chrono::steady_clock::now().time_since_epoch().count() | 1,出题人无法预先构造。

新函数拆解 · Trie(字典树)的数组实现
int ch[MAXN][26]; int pass[MAXN], ended[MAXN], tot = 0;
组成含义 / 格式要求
ch[u][c]节点 u 沿字符 c 这条边走到的节点编号。0 表示这条边不存在;根固定是 0,所以节点编号从 1 开始分配。
tot已分配的节点数,也是最新节点的编号。新建节点就是 ch[u][x] = ++tot。
pass[u]有多少个串经过节点 u,也就是“以该节点对应的字符串为前缀的串数”。
ended[u]有多少个串恰好结束在 u。用来区分“是某个串的前缀”和“自己就是一个串”。
空间怎么估节点数 = 所有串的总长度 + 1。ch 占 MAXN×26×4 字节:总长 105 约 10 MB,总长 106 约 104 MB。
为什么不用指针new 一次约 100 字节开销还慢。数组连续分配,缓存友好,清空只要 memset(ch, 0, sizeof ch),多测方便。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;                 // 节点上限 = 总字符数 + 1
int ch[MAXN][26], pass[MAXN], ended[MAXN], tot = 0;

void insert(const string& s) {
    int u = 0;                           // 从根出发
    for (char c : s) {
        int x = c - 'a';
        if (!ch[u][x]) ch[u][x] = ++tot; // 边不存在才新建,这是“共享前缀”的全部秘密
        u = ch[u][x];
        ++pass[u];
    }
    ++ended[u];                          // 有一个串在这里结束
}

int node(const string& s) {              // 返回路径终点;-1 表示走不通
    int u = 0;
    for (char c : s) {
        int x = c - 'a';
        if (!ch[u][x]) return -1;
        u = ch[u][x];
    }
    return u;
}
// node(s) != -1 → 以 s 为前缀的串有 pass[node(s)] 个
// ended[node(s)] > 0 → s 本身是一个插入过的串
注意

0 号既当根又当“不存在的边”,所以判定必须是 if (!ch[u][x]),而不是 == -1(全局数组默认 0,拿 -1 判永远不成立,会把 0 当合法子节点原地打转)。

节点总数只与总字符数有关,与字符集大小无关——这正是 Trie 比“把所有前缀塞进 set”省的地方。

新函数拆解 · 01-Trie(二进制 Trie)
void insert(int x); int query(int x); // 返回与 x 异或能取到的最大值
组成含义 / 格式要求
节点含义从根往下第 b 层存的是 x 的第 b 位(b 从 30 降到 0)。一条根到叶的路径就是一个 31 位二进制数。
ch[u][2]只有 0、1 两条边。节点数上限 = 数值个数 × 31。
query 的贪心从高位到低位,尽量走与当前位相反的边;走不通才走相同的边。
为什么贪心对第 b 位是 1 贡献 2b,而低 b 位全取 1 也只有 2b−1。高位赢一次就赢过所有低位,所以从高到低逐位定。
位宽值 < 231 时从第 30 位开始;值 < 109 < 230 时从第 29 位开始也够。多枚举一位不会错,只多一层。
最小可运行示例
示例
const int MAXN = 100005;
int ch[MAXN * 31][2], tot = 0;

void insert(int x) {
    int u = 0;
    for (int b = 30; b >= 0; --b) {
        int c = (x >> b) & 1;
        if (!ch[u][c]) ch[u][c] = ++tot;
        u = ch[u][c];
    }
}

int query(int x) {                       // 与 x 异或的最大值
    int u = 0, r = 0;
    for (int b = 30; b >= 0; --b) {
        int c = (x >> b) & 1;
        if (ch[u][c ^ 1]) {              // 反边存在:这一位异或出 1
            r |= 1 << b;
            u = ch[u][c ^ 1];
        } else {                         // 只能将就,这一位异或出 0
            u = ch[u][c];
        }
    }
    return r;
}
注意

插入和查询都是 31 步固定开销,与数值大小无关。

ch 占 3.1×106×2×4 ≈ 25 MB,n = 105 时刚好;n 再大就要算清楚内存。

例题 · 【模板】字符串哈希洛谷 P3370普及-去洛谷做这题 ↗

给定 N 个字符串(第 i 个长度为 Mi),字符串内只含数字与大小写字母,大小写敏感。请求出这 N 个字符串中共有多少个不同的字符串。

输入:第一行一个整数 N;接下来 N 行,每行一个字符串。

输出:一行一个整数,表示不同字符串的个数。

数据范围:N ≤ 10000,Mmax ≤ 1500(总字符数可达 1.5×107)。

输入: 5 abc aaaa abc abcc 12345 输出: 4
例题怎么想:先把串变成整数,再去重

朴素做法:读进 N 个串,两两比较是否相同。N = 104 时约 5×107 对,每对最多比 1500 个字符,最坏 7×1010 次字符比较,TLE。

换目标:题目只问“有几个不同的串”,不问“谁和谁相同”。于是可以给每个串算一个整数指纹,把“字符串去重”降级成“整数去重”。

指纹怎么算:把串看成一个 B 进制数。h = 0; for (c : s) h = h * B + c;。长度 3 的 abc 展开是 a·B2 + b·B + c,与按位权展开一致。只要 B 大于字符集(62),不同串在不取模时必然得到不同的整数——这就是“指纹”能表示串的原因。

取模是为了把整数压进 64 位。取模会丢信息,于是不同串可能落到同一个余数上,这叫哈希冲突。模 264、随机数据、N = 104 时,期望冲突对数约 N2/(2·264) ≈ 3×10−12,可以忽略。

去重:N 个整数排序,再 unique 一遍。unique 把不重复的元素挪到前面并返回新的逻辑结尾,减去 begin 就是不同元素个数。总复杂度 O(N log N),与串长无关。

四步
读入 n
for 每个串 s:
    h = 0
    for c in s: h = h * 131 + c      # 溢出即 mod 2^64
    把 h 存进数组
排序、unique
输出 unique 之后的元素个数

两处细节:h 必须无符号(有符号溢出是 UB);unique 之前必须排序(它只消除相邻重复)。

完整代码
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;

const ull B = 131;          // 基数:大于字符集(62),且是质数

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;
    vector<ull> h;
    h.reserve(n);

    string s;
    for (int i = 0; i < n; ++i) {
        cin >> s;
        ull x = 0;
        // 每个串独立从 0 开始累;乘 131 后的溢出位被丢弃,正是 mod 2^64
        for (char c : s) x = x * B + (ull)c;
        h.push_back(x);
    }

    sort(h.begin(), h.end());              // 整数排序,比字符串比较便宜得多
    // unique 把不重复的元素挪到前面,返回新结尾;减去 begin 即个数
    cout << (int)(unique(h.begin(), h.end()) - h.begin()) << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
typedef unsigned long long ull;64 位无符号。哈希的取模靠它自然溢出,一次 % 都不写。
const ull B = 131;基数必须大于字符集大小。取 B = 1 时 ab 与 ba 哈希都是 a+b,取 B = 2 时重排也会撞。质数让各次幂分布更散。
ios::sync_with_stdio(false);总输入 1.5×107 个字符,读入本身是瓶颈之一,关同步省一半时间。
ull x = 0;写在读串的循环内,保证每个串的累加器是干净的。写在循环外会让所有串首尾相连。
for (char c : s) x = x * B + (ull)c;核心一行:先乘再加,等价于把串看成 B 进制数。(ull) 转换保证字符值 ≥ 128 时不做符号扩展。
x * B 溢出为什么不算错无符号溢出是“按 264 取模”,C++ 标准明确规定;换 long long 就是 UB。同一个式子,类型决定它是算法还是未定义行为。
h.push_back(x);N 个指纹存进 vector,长度只有 N,与串长无关。
sort(h.begin(), h.end());O(N log N),比较的是 8 字节整数。若直接 sort 字符串,比较代价是 O(串长)。
unique(h.begin(), h.end()) - h.begin()unique 返回去重后区间的新结尾。前提是已经有序,否则只消除相邻重复,答案偏大。
(int)(...)迭代器相减得到 ptrdiff_t,转 int 后输出,避免某些环境下按长整型打印。

哈希真正的威力不在“整串比较”,而在任意子串比较。要做到这一点,先算前缀哈希,再用一次减法把任意一段“抠”出来。

设 h[i] 是前 i 个字符的哈希,则 h[r] = h[l−1]·Br−l+1 + H(l..r):前 r 个字符 = 前 l−1 个字符整体左移 r−l+1 位,再接上第 l..r 个字符。移项得到子串哈希公式。

H(l..r) = h[r] − h[l−1] · Br−l+1。两段子串相同,这个值一定相同;值相同,则以极大概率两段相同——剩余的小概率就是“哈希冲突”。
子串哈希:O(1) 判断两段是否相同
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;

const ull B = 131;
const int MAXN = 1000005;
char s[MAXN];
ull h[MAXN], pw[MAXN];          // h:前缀哈希;pw:B 的幂

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> (s + 1);             // 1 基下标,s[1] 是第一个字符
    int n = strlen(s + 1);

    pw[0] = 1;
    for (int i = 1; i <= n; ++i) {
        h[i]  = h[i - 1] * B + (ull)s[i];   // 前 i 个字符的指纹
        pw[i] = pw[i - 1] * B;              // B^i
    }

    // 子串 [l, r](1 ≤ l ≤ r ≤ n)的指纹
    auto sub = [&](int l, int r) -> ull {
        return h[r] - h[l - 1] * pw[r - l + 1];
    };

    int q;
    cin >> q;
    while (q--) {
        int l1, r1, l2, r2;
        cin >> l1 >> r1 >> l2 >> r2;
        cout << (sub(l1, r1) == sub(l2, r2) ? "Yes\n" : "No\n");
    }
    return 0;
}
公式里的每一项在做什么
代码片段含义与理由
cin >> (s + 1);把字符串读到 s[1] 起始的位置,这样下标 1 就是第一个字符。括号不能省:cin >> s + 1 会被解析成 (cin >> s) + 1。
h[i] = h[i - 1] * B + (ull)s[i];递推:把前 i−1 个字符整体“左移一位”,再在末位接上 s[i]。
pw[i] = pw[i - 1] * B;幂表。子串公式里要乘 Br−l+1,现算会多一次 O(log n),查表是 O(1)。
h[r]前 r 个字符的指纹,等于 H(1..l−1) 左移 r−l+1 位后加上 H(l..r)。
h[l - 1] * pw[r - l + 1]把前缀 1..l−1 移到与 h[r] 相同的位权上,才能相减。少了这一项,减出来的是“两个不同长度的前缀”之差,毫无意义。
h[r] - h[l - 1] * pw[...]减法在 ull 里回绕,正好等价于模 264 的减法,不需要 +M 再取模。
auto sub = [&](int l, int r) -> ulllambda 捕获外部数组的引用,调用处像函数一样短。C++17 支持显式返回类型写法。

这个公式是后面所有子串题的地基:找模式串出现位置(逐个起点比指纹)、最长公共前缀(二分长度 + 判等,O(log n))、回文判断(正串和反串各一套哈希)。代价都是“预处理 O(n) + 每次询问 O(1) 或 O(log n)”。

哈希能判相等,却回答不了“有多少个串以某个前缀开头”——那是集合问题,不是相等问题。Trie 把公共前缀压成一条公共路径,走到某个节点,子树里有多少个词就是这个前缀的出现次数。

Trie:插入、前缀计数、是否存在
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;               // 节点上限 = 所有串总长 + 1
int ch[MAXN][26], pass[MAXN], ended[MAXN], tot = 0;

void insert(const string& s) {
    int u = 0;                         // 根
    for (char c : s) {
        int x = c - 'a';
        if (!ch[u][x]) ch[u][x] = ++tot;   // 没有这条边才新建:前缀只存一份
        u = ch[u][x];
        ++pass[u];                     // 经过 u 的串多了一个
    }
    ++ended[u];                        // 有一个串在这里结束
}

int node(const string& s) {            // 返回路径终点;-1 表示路断了
    int u = 0;
    for (char c : s) {
        int x = c - 'a';
        if (!ch[u][x]) return -1;
        u = ch[u][x];
    }
    return u;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, q;
    cin >> n >> q;
    string s;
    for (int i = 0; i < n; ++i) { cin >> s; insert(s); }

    while (q--) {
        cin >> s;
        int u = node(s);
        if (u == -1) { cout << "0 0\n"; continue; }
        cout << pass[u] << " " << (ended[u] > 0 ? 1 : 0) << "\n";
        // 第一个数:以 s 为前缀的串数;第二个数:s 自己是不是一个插入过的串
    }
    return 0;
}
Trie 的三处关键
代码片段含义与理由
if (!ch[u][x]) ch[u][x] = ++tot;共享前缀的全部秘密。边已存在就直接复用,不新建节点——所以 abc 和 abd 只多花 1 个节点,不是 3 个。
u = ch[u][x];沿着边走一步。循环结束时 u 就是整个串对应的节点。
++pass[u];放在“走完这一步”之后,统计的是“经过该节点的串数”。若放在走之前,统计的就变成了父节点的信息。
++ended[u];区分“是前缀”和“是单词”。ab 是 abc 的前缀,但 ab 自己可能没被插入——这时 ended[ab] = 0。
if (u == -1)走不通说明这条路径从没被插入过,前缀数一定是 0。提前返回省掉后面的遍历。
复杂度单次插入/查询 O(len),与集合里有多少串无关。这是 Trie 相对“线性扫描所有串”的本质优势。

把 Trie 的边从 26 条换成 2 条、从字符换成二进制位,就得到 01-Trie。它解决一类问题:给定一堆数,问“与 x 异或最大的数是谁”。

01-Trie:求最大异或对(n ≤ 10⁵,值 < 2³¹)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int ch[MAXN * 31][2], tot = 0;         // 每个数最多占 31 个节点

void insert(int x) {
    int u = 0;
    for (int b = 30; b >= 0; --b) {    // 从最高位开始,高位决定大小
        int c = (x >> b) & 1;
        if (!ch[u][c]) ch[u][c] = ++tot;
        u = ch[u][c];
    }
}

int query(int x) {                     // 返回与 x 异或能得到的最大值
    int u = 0, r = 0;
    for (int b = 30; b >= 0; --b) {
        int c = (x >> b) & 1;
        if (ch[u][c ^ 1]) {            // 反边存在 → 这一位异或出 1,必选
            r |= 1 << b;
            u = ch[u][c ^ 1];
        } else {
            u = ch[u][c];              // 没得选,这一位异或出 0
        }
    }
    return r;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;
    int ans = 0;
    for (int i = 1; i <= n; ++i) {
        int x;
        cin >> x;
        if (i > 1) ans = max(ans, query(x));   // 先问已有的数,答案一定来自两个不同的数
        insert(x);
    }
    cout << ans << "\n";
    return 0;
}
为什么可以一位一位贪
代码片段含义与理由
for (int b = 30; b >= 0; --b)从最高位往下。值 < 231,第 30 位是最高可能出现的位。
if (ch[u][c ^ 1])c 是 x 当前位,c^1 是相反的位。走反边意味着这一位异或结果是 1。
r |= 1 << b;这一位确定为 1,把 2b 记进答案。b ≤ 30,1 << b 不会溢出 int。
为什么高位优先第 b 位贡献 2b,低 b 位全取 1 也只有 2b−1 < 2b。高位多赢一位就大于所有低位之和,所以逐位贪心与全局最优一致。
先 query 再 insert若先插后问,可能问到自己(x xor x = 0);虽然不影响最大值,但“重复元素”的题会算错计数。顺序写对一次,以后不用想。
ans 用 int 就够两个 < 231 的数异或仍 < 231,不会超 int。若把值域改到 1018,位宽要改成 62,r 必须换 long long。
这一节的 7 种错法(都带症状)

RE / 段错误。—— Trie 的 MAXN 按“串的个数”开成 104。节点数等于总字符数,1 万个长 1500 的串要 1.5×107 个节点,insert 时写 ch[u][x] 越界。

WA,形如 ab 和 ba 被判成同一个串。—— 基数取了 B = 1 或 2,哈希退化成字符求和,任何重排都同值。B 必须大于字符集大小。

WA,本地全对、-O2 下答案诡异。—— 用 long long 做自然溢出。有符号溢出是 UB,编译器可以按“不会溢出”优化。必须 unsigned long long。

TLE。—— 用 set<string> 或 map<string,int> 存 105 个长 103 的串:每次比较 O(L),总代价 O(N·L·log N)。哈希后排序整数是 O(N log N)。

WA,只有个别测试点错。—— 单哈希被卡。长串 + 固定基数 + 模 264 有确定的 Thue–Morse 反例,换双哈希或随机基数。

RE / 死循环。—— 用 -1 表示“边不存在”,但全局数组默认 0。判定写成 if (ch[u][x] == -1) 永远不成立,于是把 0 当成合法子节点原地打转。约定:0 表示不存在,编号从 1 开始。

WA,01-Trie 答案偏小。—— 位宽只写到第 20 位而数据里有 106 以上的数;或者值域到 231 还写 1 << 31(有符号移位溢出),应写 1LL << b。

自己动手复现

关掉页面,把 P3370 从空文件敲一遍并 AC。

改一改 ①:把 B 改成 1,输入 5 / ab / ba / abc / cba / aabb,不同字符串数会从 5 变成 3。想清楚哪几对撞了。

改一改 ②:把 ull 全换成 long long,加 -O2 编译,输入 105 个随机串,对比两种写法的答案是否一致。

改一改 ③:把 sort + unique 换成 set<ull>,用 time 命令比较 105 个串的耗时差。

改一改 ④:在 01-Trie 里把位宽从 30 改成 20,输入 106 以内的数,看答案错在哪一位。

改一改 ⑤:给子串哈希那题加一组询问,让 l1、r1、l2、r2 长度不同,验证“长度不同必然不等”是否由公式自动保证。

数学补给 · 哈希为什么会有冲突:生日问题与 2^64

多项式哈希把长 L 的串映射到 H(s) = Σ si·BL−i mod M。不取模时这是 B 进制表示,B 大于字符集时不同串必得不同整数;取模把 M 以外的信息丢掉了,不同的串就可能落到同一个余数上,这就是冲突。

生日问题:把 n 个串丢进 M 个桶,期望冲突对数 ≈ n(n−1)/(2M)。要让冲突概率低于 10−6,需要 M ≳ 5×1011·n2。

模数大小n = 10⁵ 时的期望冲突数能否被构造攻击
2324.3×109≈ 1.2能
2641.8×1019≈ 2.7×10−10能(Thue–Morse)
109+7 单哈希109≈ 5能
双哈希 109+7 × 109+91018≈ 5×10−9极难

为什么模 264 特别脆:264 不是质数,奇数基数 B 的高次幂在模 264 下很快退化,于是可以用“差分”构造出等长且哈希相同的两个串。已知的构造按 Thue–Morse 串取位(第 i 位由 popcount(i) 的奇偶决定),在模 264、基数为奇数时两个串哈希完全相同。这就是 anti-hash 数据的来源——它不是运气,是构造。

双哈希为什么够:两套哈希同时撞上的概率近似为两个概率之积。两个 109 量级的模数给到 1018 的组合空间,且出题人要同时对两个不同模数构造反例,难度远超对 264。代价只是常数 ×2。

子串哈希的恒等式:h[r] = h[l−1]·Br−l+1 + H(l..r),移项即 H(l..r) = h[r] − h[l−1]·Br−l+1。这一步把任意子串的指纹变成两次查表加一次乘法,是所有“子串比较”类题目的地基。

基数怎么选:B 要大于字符集(否则不同字符会进位混叠),且最好与模数互质(取质数最省事)。131、13331 是习惯用法;更稳的是运行时随机取一个 108 附近的质数,让反例无法预先构造。

举一反三 · 想清楚这些再往下走
  1. 判断“一个串是否在另一个串中出现”,哈希和下一节的 KMP 都能做,都是 O(n+m)。什么情况下哈希会退化成 O(nm),什么情况下必须用 KMP?
  2. 如果题目要求“把 s 的某个位置改成另一个字符,然后判断两个子串是否相等”,前缀哈希要怎么改?提示:h[i] 是一个和,能不能用树状数组维护?
  3. Trie 的空间是 O(总字符数)。那它比“把所有串塞进 set”省在哪?如果字符集是 105 个汉字,ch[u][c] 还能这么开吗?替代方案是什么?
  4. 01-Trie 求“最大异或对”是 O(31n)。如果改问“异或值 ≥ k 的数对有多少个”,怎么在树上做?提示:走边时同时统计子树里的数值个数。
  5. 双哈希一定安全吗?要构造同时卡住两个模数的数据,串至少要多长?提示:两个模数乘积约 1018,而长度 L 的串有 62L 种。
  6. 把 ull 换成 uint32_t(模 232 自然溢出),n = 105 时期望冲突数约 1.2。这个数字说明什么?
  7. 为什么哈希替代不了 Trie 的“前缀计数”?前缀哈希能算出“某个前缀的指纹”,但它答不了哪个问题?
自测:能全部回答再进下一节
  • 能默写多项式哈希的那一行,并说清为什么必须用 unsigned long long 而不是 long long。
  • 能写出子串哈希公式,并解释 h[l−1]·Br−l+1 这一项在做什么、去掉会错成什么样。
  • 能说出单哈希、双哈希在 n = 105 时的冲突期望值数量级,并知道 Hash Killer 的存在。
  • 能默写 Trie 的 insert 与 node 查询,说清 0 号为什么既是根又是“不存在的边”。
  • 能默写 01-Trie 的插入与查询,并用“高位大于所有低位之和”解释贪心的正确性。
  • 拿到一道字符串题,能判断该用哈希、Trie,还是该等下一节的 KMP。
挑战题 · 于是他错误的点名开始了
洛谷 P2580普及/提高-去洛谷做这题 ↗

化学竞赛组有 n 名学生,教练手里有名单。教练会依次报出 m 个名字,你要对每个名字给出判断:

若这个名字在名单里且是第一次被点到,输出 OK;若在名单里但之前已经被点过,输出 REPEAT;若不在名单里,输出 WRONG。

输入:第一行一个整数 n;接下来 n 行每行一个名字(互不相同,只含小写字母,长度 ≤ 50);第 n+2 行一个整数 m;接下来 m 行每行一个教练报的名字(只含小写字母,长度 ≤ 50)。数据范围 n ≤ 104,m ≤ 105。

输出:对教练报的每个名字输出一行,为 OK、REPEAT 或 WRONG。

引导性提示(先只看这里)

名字总数 104、长度 ≤ 50,总字符数不超过 5×105。这个数字提示你:可以按字符建结构,不必按名字建。

查询要区分三件事:这条路径存在吗?路径的终点真的有名字结束吗?这个名字之前被点过吗?三件事需要三种不同的信息。

“前缀”和“单词”要分开:名单里只有 ab,教练报 abc,这条路径走不通;名单里只有 abc,教练报 ab,路径走得通但 ab 不结束。

想看思路了?点开完整思路

把名单建成 Trie:每个名字从根出发逐字符走,没有边就新建节点。走完整个名字时,把该节点的“结束计数”加一。

查询一个名字也是逐字符走:中途某条边不存在,说明名单里没有以它为前缀的名字,直接 WRONG。

走完整条路径后,还要看终点节点的结束计数是否为 0。为 0 说明它只是某个更长名字的前缀,自己不是名单里的名字——也是 WRONG。

为了区分 OK 和 REPEAT,再给每个节点一个 vis 标记:第一次走到这里输出 OK 并置 true,之后再走到就输出 REPEAT。

为什么用 Trie 而不是 set:set 查一次是 O(len·log n) 次字符串比较,Trie 是 O(len) 次下标访问,而且 m 到 105,差距会放大。

复杂度:插入 O(总字符数) ≤ 5×105,查询 O(m×50) ≤ 5×106,空间 O(总字符数)。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 500005;               // 节点上限 = n × 50
int ch[MAXN][26], ended[MAXN], tot = 0;
bool vis[MAXN];                        // 这个节点被点过名没有

void insert(const string& s) {
    int u = 0;
    for (char c : s) {
        int x = c - 'a';
        if (!ch[u][x]) ch[u][x] = ++tot;   // 前缀只有一份
        u = ch[u][x];
    }
    ++ended[u];                        // 这个名字真的在名单里
}

int node(const string& s) {            // 返回终点编号;-1 表示路径不存在
    int u = 0;
    for (char c : s) {
        int x = c - 'a';
        if (!ch[u][x]) return -1;
        u = ch[u][x];
    }
    return u;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;
    string s;
    for (int i = 0; i < n; ++i) { cin >> s; insert(s); }

    int m;
    cin >> m;
    for (int i = 0; i < m; ++i) {
        cin >> s;
        int u = node(s);
        // 路走不通,或者走通了但这里没有名字结束:都不是名单里的名字
        if (u == -1 || ended[u] == 0) { cout << "WRONG\n"; continue; }
        if (vis[u]) { cout << "REPEAT\n"; continue; }
        vis[u] = true;
        cout << "OK\n";
    }
    return 0;
}
关键片段拆解
片段作用
const int MAXN = 500005;n ≤ 104、每个名字长 ≤ 50,总字符数 ≤ 5×105,节点数不会超过总长 + 1。按“名字个数”开数组是这题最常见的 RE。
if (!ch[u][x]) ch[u][x] = ++tot;共享前缀。名字 abc 与 abd 只多花一个节点,不是三个。
++ended[u];★ 结束标记。没有它,“abc 在名单里,报 ab”会被判 OK,而正确答案是 WRONG。
if (!ch[u][x]) return -1;边走边判。路径断了就没有必要走下去,也顺便区分了“不存在”和“存在但不是名字”。
if (u == -1 || ended[u] == 0)两个条件缺一不可。u == -1 是路径断了;ended[u] == 0 是路径存在但只是别人的前缀。
if (vis[u])★ 用节点编号当访问标记,而不是再存一份字符串。同一个名字必然落在同一个节点上,所以节点编号就是名字的唯一 ID。
vis[u] = true;只在这一步置真:确保“第一次报”输出 OK,“第二次及以后”输出 REPEAT。
这题最容易错的地方
  • 把 MAXN 按名字个数开成 104:节点数是总字符数,直接 RE。
  • 忘了判 ended[u] == 0:名单里只有 abc 时报 ab,会错判成 OK。
  • 用 map<string,int> 记是否点过名:能过,但多了一次哈希,且意图不如“节点编号当 ID”清晰;n、m 更大时会被卡。
  • 用 ended[u]-- 表示“点过”:同一个名字第二次来时 ended[u] 已变成 0,会被判成 WRONG 而不是 REPEAT。
  • 多测不清空:本题只有一组,但换到多测题时必须把 tot、ch、ended、vis 一起清。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》0x14 哈希与 0x31 字典树
  • 《算法竞赛入门笔记》字符串章节:哈希与 Trie
  • 《算法竞赛试炼场:洛谷300题精析》P3370、P2580 两题的精析
  • 《算法竞赛黄金法则:提高算法和思考力的77项技巧》中“把比较换成指纹”的思路
  • 洛谷 P3370 题目的“拓展阅读”:Hash Killer I~Extra 系列
52 KMP 与字符串匹配提高

52 · KMP 与字符串匹配

用 fail 数组把“失配后从哪重新比”预先算好:f[i] 是前缀 s[1..i] 的最长 border,失配时沿 border 链跳,主串指针永不回退,匹配 O(n+m)。同一套下标还能直接读出最小周期 n − f[n],Z 函数则用 [l, r] 盒子在 O(n) 内给出每个后缀与前缀的 LCP。
提高预计 70 分钟KMPborderZ 函数
本节出现的新函数 / 新语法(先看懂再用)

在长串 s1 里找模式串 s2 的所有出现位置,暴力做法是:把 s2 对到每个起点上,逐字符比。最坏 O(|s1|·|s2|)——s1 = s2 = 106 个 a 时是 1012 次比较,必然超时。KMP 把“失配之后从哪一位重新开始比”预先算进一个数组,于是 s1 的指针永不回退,总时间 O(|s1| + |s2|)。

失配时不需要把 s2 挪一格重来:已经匹配上的那一段,既等于 s2 的前一段,也等于 s1 的末尾,所以只要知道“s2 的这段前缀里,最长的、同时也是后缀的前缀有多长”——这个长度就是 fail。
任务做法复杂度关键代码
s2 是否出现在 s1(暴力)每个起点逐字符比O(|s1|·|s2|)双层 for
s2 在 s1 的所有位置KMPO(|s1|+|s2|)i 不回退,j = f[j]
每个前缀的最长 border对自己做一次 KMPO(|s2|)f[i] = j
最短循环节n − f[n]O(n)一行减法
所有 border沿 f 链跳O(链长)j = f[j]
每个后缀与前缀的 LCPZ 函数O(n)盒子 [l, r]

先把这一节的 4 样新东西拆开。核心只有 5 行,但要把 5 行写对,得先知道每一行在维护什么。

新函数拆解 · 1 基下标读入:cin >> (s + 1)
cin >> (s + 1); int n = strlen(s + 1);
组成含义 / 格式要求
为什么用 1 基f[i] 的含义是“前 i 个字符”,下标天然从 1 数。用 0 基写要处处减一,border 题差一位就整题错。
cin >> (s + 1)读进以 s[1] 开头的位置,s[0] 空着不用。括号不能省:cin >> s + 1 会被解析成 (cin >> s) + 1。
strlen(s + 1)从 s[1] 数到 '\0',得到的就是串长。数组至少开 maxlen + 2(留 s[0] 与结尾 '\0')。
1e6 的串char s[1000005] 是 1 MB。全局数组在静态区,不占栈;写在 main 里是栈上 1 MB,容易爆栈。
多组数据记得清 f 数组。f 是按长度用的,只要重新算一遍 f[1..m] 就够,不必 memset 全部。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1000005;
char s[MAXN];                  // s[1] 是第一个字符,s[0] 闲置

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> (s + 1);            // 读到 s[1] 起始的位置
    int n = strlen(s + 1);     // 串长
    cout << n << " " << s[1] << " " << s[n] << "\n";
    return 0;
}
// 输入 abcde → 输出 5 a e
注意

1 基下循环写 for (int i = 1; i <= n; ++i),i 就是“第 i 个字符”,不需要在脑子里做 i−1 的换算。这是字符串算法最省心的写法。

数组开小了不一定报错:少 1 个字节时 '\0' 写到数组外,属于未定义行为,可能本地没事、交上去 RE。

新函数拆解 · fail 数组(next 数组):五行的递推
f[1] = 0; for (int i = 2, j = 0; i <= m; ++i) { while (j && s[i] != s[j + 1]) j = f[j]; if (s[i] == s[j + 1]) ++j; f[i] = j; }
组成含义 / 格式要求
f[i] 是什么s 的前 i 个字符(记为 s[1..i])的最长 border 长度。border = 既是真前缀又是真后缀的子串。
f[1] = 0单个字符没有非本身的 border。这是整条递推的起点,也是所有“回退到底”的终点。
j 的含义进入第 i 轮时 j = f[i−1],并且 s[1..j] 与 s[i−j..i−1] 已经相等。这两段相等是“可以直接接着比”的全部依据。
if (s[i] == s[j + 1])已经对齐的两段后面再接一个相同字符,border 就延长一位:f[i] = j + 1。
while (j && s[i] != s[j + 1])失配就往短里找下一个候选。候选只能是 f[j]、f[f[j]]……一路跳到 0,跳到底再比 s[1]。
j 必须写在 for 的初始化里写成 for (int i = 2; i <= m; ++i) { int j = 0; ... } 每轮归零,f 全变 0。
j 从哪来循环开始时 j = 0 = f[1],之后每轮结束 j = f[i],正好是下一轮需要的值。
最小可运行示例
示例
const int MAXN = 1000005;
int f[MAXN];

// s 从 s[1] 开始,长度 m
void buildFail(const char* s, int m) {
    f[1] = 0;                                  // 单个字符没有 border
    for (int i = 2, j = 0; i <= m; ++i) {      // j 在这里声明:跨轮保留
        while (j && s[i] != s[j + 1]) j = f[j]; // 失配:跳到次长 border
        if (s[i] == s[j + 1]) ++j;              // 匹配:border 延长 1
        f[i] = j;
    }
}

// s = "aabaaab" → f[1..7] = 0 1 0 1 2 2 3
注意

f[i] 永远不会超过 f[i−1] + 1。因为 f[i] 的 border 去掉最后一位就是 s[1..i−1] 的 border,长度必然 ≤ f[i−1]。这条性质决定了 while 里“往短跳”是唯一的出路。

多测题里 f 数组的元素会被逐个覆盖(i 从 1 到 m 全写一遍),所以不需要额外清空。

新函数拆解 · KMP 匹配:i 不回退的扫描
for (int i = 1, j = 0; i <= n; ++i) { while (j && s1[i] != s2[j + 1]) j = f[j]; if (s1[i] == s2[j + 1]) ++j; if (j == m) { /* 起点 i-m+1 */ j = f[j]; } }
组成含义 / 格式要求
i扫 s1 的指针,从左到右走一遍,永不回退。这是 O(n) 的来源。
j当前已经匹配的字符数。它同时是 s2 的下标:下一个要比的是 s2[j+1]。
while (j && ...)与建 fail 时同一行代码。KMP 匹配就是“用 s2 的 fail 信息驱动 s1 的扫描”。
if (j == m)完整匹配一次,出现位置是 i − m + 1(1 基)。
j = f[j]★ 不能写 j = 0。s2 = aa、s1 = aaa 时,位置 1 和 2 都该输出。
总复杂度i 走 n 步;j 每步最多加 1,故 while 总次数 ≤ n,整体 O(n + m)。
最小可运行示例
示例
for (int i = 1, j = 0; i <= n; ++i) {
    while (j && s1[i] != s2[j + 1]) j = f[j];   // 失配:沿 fail 后退
    if (s1[i] == s2[j + 1]) ++j;                // 这一位对上了
    if (j == m) {                               // 整串对上
        cout << i - m + 1 << "\n";              // 起点(1 基)
        j = f[j];                               // 允许重叠,继续找
    }
}
注意

s2[j + 1] 在 j = m 时会读到 s2[m+1] = '\0'——只有忘记在匹配后回退 j 才会发生。

整个算法不需要对 s1 做任何预处理,也不需要 s1 的 fail 数组。流式数据(边读边匹配)也能用。

新函数拆解 · Z 函数(另一种“与前缀的匹配长度”)
z[1] = n; for (int i = 2; i <= n; ++i) { z[i] = 0; if (i <= r) z[i] = min(z[i - l + 1], r - i + 1); while (i + z[i] <= n && t[1 + z[i]] == t[i + z[i]]) ++z[i]; if (i + z[i] - 1 > r) { l = i; r = i + z[i] - 1; } }
组成含义 / 格式要求
z[i] 是什么t[1..] 与 t[i..] 的最长公共前缀长度。z[1] = n(自己和自己)。
l, r当前“最靠右”的已知匹配段 [l, r]:t[l..r] 与 t[1..r−l+1] 相同。
i ≤ r 时先抄盒子内的部分已经对齐,所以 z[i] 至少是 min(z[i−l+1], r−i+1)。
再暴力往外扩抄完之后继续逐字符比。这一步看着像暴力,实际总代价是 O(n)。
为什么是 O(n)r 只增不减,每次 while 成功都让 r 右移,而 r ≤ n。总扩大次数 ≤ n。
更新盒子i + z[i] − 1 > r 说明右边界推远了,换上新的 l、r。
最小可运行示例
示例
const int MAXN = 2000005;
int z[MAXN];

void getZ(const char* t, int n) {          // t 从 t[1] 开始
    z[1] = n;
    int l = 1, r = 0;
    for (int i = 2; i <= n; ++i) {
        z[i] = 0;                                      // 清掉上一次运行的值
        if (i <= r) z[i] = min(z[i - l + 1], r - i + 1); // 盒子内先抄
        while (i + z[i] <= n && t[1 + z[i]] == t[i + z[i]]) ++z[i];
        if (i + z[i] - 1 > r) { l = i; r = i + z[i] - 1; }
    }
}

// t = "aaa" → z = 3 2 1
注意

min 里的 r − i + 1 是“别抄出盒子”。少了它会用到还没验证的位置,答案偏大。

Z 函数配 t = s2 + '#' + s1 就能做匹配:z[i] ≥ |s2| 的位置就是一个出现。分隔符必须在字符集之外。

例题 · 【模板】KMP洛谷 P3375普及/提高-去洛谷做这题 ↗

给出两个字符串 s1、s2。若 s1 的区间 [l, r] 子串与 s2 完全相同,则称 s2 在 s1 中出现了,出现位置为 l。求 s2 在 s1 中所有出现的位置。

另外,对 s2 的每个前缀,求出它的最长 border 长度(border 指“不是 s 本身”的、既是 s 的前缀又是 s 的后缀的子串)。

输入:第一行字符串 s1,第二行字符串 s2。

输出:先每行一个整数,按从小到大输出所有出现位置;最后一行输出 |s2| 个整数,第 i 个是 s2 长度为 i 的前缀的最长 border 长度。

数据范围:1 ≤ |s1|, |s2| ≤ 106,只含大写英文字母。

输入: ABABABC ABA 输出: 1 3 0 0 1
例题怎么想:先让 s2 认识自己

暴力匹配的浪费在哪:设 s2 已经和 s1 对上了 j 个字符,第 j+1 个失配。暴力会把 s1 的起点右移一格、j 清零重来,把刚刚确认过的 j 个字符又比一遍。n = m = 106、s1 = s2 = 全是 a 时,浪费是 1012 级别的。

关键观察:失配那一刻,我们已知 s1[i−j..i−1] = s2[1..j]。新的对齐方式相当于问:s2 的前缀 s2[1..k] 能不能同时等于 s2[1..j] 的后缀?如果能,就让 k 个字符“自动对上”,从 k+1 继续比。

“既是 s2[1..j] 的前缀、又是它的后缀”就是 border 的定义。满足条件的 k 恰好是 j、f[j]、f[f[j]]……一条链。挑最大的那个 k,最省事,也不会漏解。所以失配后的动作是 j = f[j],而不是 j = 0。

于是“跳到哪”只由 s2 自己决定,跟 s1 无关。把它预先算出来:让 s2 和自己做一次同样的匹配,得到 f[1..m]。这一步 O(m)。

正式匹配时 i 只管往前走,j 按 fail 调整,总调整次数不超过 n,整题 O(n + m)。匹配成功后写 j = f[j](此时 j = m),因为“允许重叠”,下一次出现可能从当前位置往前一点点开始。

两段几乎一样的循环
① 预处理:对 s2 做 KMP,得到 f[1..m]
     f[1] = 0; j = 0
     for i = 2..m:
         while j > 0 且 s2[i] != s2[j+1]: j = f[j]
         if s2[i] == s2[j+1]: j++
         f[i] = j

② 匹配:扫一遍 s1
     j = 0
     for i = 1..n:
         while j > 0 且 s1[i] != s2[j+1]: j = f[j]
         if s1[i] == s2[j+1]: j++
         if j == m: 输出 i-m+1;  j = f[j]

③ 输出 f[1..m]

看清楚一件事:② 和 ① 是同一段代码,只是把“自己”换成了别的串。所以 KMP 只要写对一次,另一处照抄即可。

完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1000005;
char s1[MAXN], s2[MAXN];
int f[MAXN];                       // f[i]:s2 前 i 个字符的最长 border 长度

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> (s1 + 1) >> (s2 + 1);   // 1 基下标
    int n = strlen(s1 + 1), m = strlen(s2 + 1);

    // ① 预处理:s2 匹配自己,求出 f[1..m]
    f[1] = 0;                                  // 单字符没有非本身的 border
    for (int i = 2, j = 0; i <= m; ++i) {
        while (j && s2[i] != s2[j + 1]) j = f[j];   // 失配:沿 border 链后退
        if (s2[i] == s2[j + 1]) ++j;                // 对上了:border 延长 1
        f[i] = j;
    }

    // ② 匹配:i 扫 s1,j 记录已匹配长度
    for (int i = 1, j = 0; i <= n; ++i) {
        while (j && s1[i] != s2[j + 1]) j = f[j];
        if (s1[i] == s2[j + 1]) ++j;
        if (j == m) {                          // 出现一次
            cout << i - m + 1 << "\n";
            j = f[j];                          // 允许重叠,退到最长 border 继续
        }
    }

    // ③ 输出 f[1..m],空格分隔,最后一个后换行
    for (int i = 1; i <= m; ++i) {
        cout << f[i];
        cout << (i == m ? "\n" : " ");
    }
    return 0;
}
逐行拆解
代码片段含义与理由
cin >> (s1 + 1) >> (s2 + 1);两个串都从下标 1 开始存。括号必须有,否则 >> 的优先级低于 +,意思就变了。
int n = strlen(s1 + 1), m = strlen(s2 + 1);1e6 的串求长度是 O(n),只做一次。此后所有下标都用 1..n。
f[1] = 0;递推的起点。若忘了这一句,f[1] 保留旧值,第一轮 while 就会用错数字。
for (int i = 2, j = 0; i <= m; ++i)j 写在初始化部分:它在轮与轮之间保留,正好承担“上一轮的 border 长度”。
while (j && s2[i] != s2[j + 1]) j = f[j];★ 整题的核心。一次跳过一个候选,跳的步数是均摊 O(1) 的。
if (s2[i] == s2[j + 1]) ++j;注意比的是 s2[j+1] 而不是 s2[j]:j 是“已对齐的长度”,下一个待比的下标是 j+1。
f[i] = j;把这一轮的结果存下来。下一轮直接用,不用重算。
for (int i = 1, j = 0; i <= n; ++i)匹配阶段。i 从 1 走到 n,一次也不回退。
if (j == m) { cout << i - m + 1 ...起点 = 终点 − 长度 + 1。i 是 s1 里匹配段的最后一个字符。
j = f[j];★ 写成 j = 0 会漏掉重叠的出现。f[m] 是 s2 的最长 border,也是下一次可能的重叠长度。
cout << (i == m ? "\n" : " ");最后一行 |s2| 个整数,行末换行、中间一个空格。格式错一样 WA。
fail 的递推为什么这么写

这是全节唯一需要动脑的地方。设 f[i−1] = j,含义是 s[1..j] = s[i−j..i−1]。要算 f[i],只有三种情况。

  • 情况 1:s[i] = s[j+1]。把已经对齐的两段各延长一格,得到 s[1..j+1] = s[i−j..i],于是 f[i] ≥ j+1。又 f[i] ≤ f[i−1] + 1 = j+1(理由见下),所以 f[i] = j+1。
  • 情况 2:s[i] ≠ s[j+1]。这时 f[i] 不可能等于 j+1。设 f[i] = k ≤ j。因为 s[1..k] = s[i−k+1..i],去掉最后一位得 s[1..k−1] = s[i−k+1..i−1],而 s[i−k+1..i−1] 是 s[i−j..i−1] = s[1..j] 的一段后缀,所以 k−1 是 s[1..j] 的 border,即 k−1 ≤ f[j]。于是候选 k 恰好落在 border 链上,最大的那个就是 f[j]+1。
  • 情况 3:j 退到 0 仍不匹配。此时没有任何非空 border,f[i] = 0。

“f[i] ≤ f[i−1] + 1”的理由:若 s[1..k] 是 s[1..i] 的 border,去掉最后一位,s[1..k−1] 就是 s[1..i−1] 的 border,所以 k−1 ≤ f[i−1],即 k ≤ f[i−1] + 1。这条性质说明“长度每轮最多加 1”,也说明失配时只可能往短里找。

所以 while (j && ...) j = f[j]; 不是“随便往回退一点”,而是沿着 all-border 链按从大到小枚举候选。链上的值恰好是 j, f[j], f[f[j]], …, 0,既不会漏,也不会重复。

is[1..i]这一轮发生了什么f[i]
1a起点0
2aaj=0,s[2]=s[1] → j=11
3aabj=1,s[3]≠s[2] → j=f[1]=0;s[3]≠s[1]0
4aabaj=0,s[4]=s[1] → j=11
5aabaaj=1,s[5]=s[2] → j=22
6aabaaaj=2,s[6]≠s[3] → j=f[2]=1;s[6]=s[2] → j=22
7aabaaabj=2,s[7]=s[3] → j=33

第 6 行是重点:j 从 2 跳到 1 不是“减一”,而是跳到 f[2]。如果这里写成 j−−,就退化成 O(nm)。

对照上面那张表再看这三行
代码片段含义与理由
while (j && s[i] != s[j + 1]) j = f[j];情况 2:沿 border 链找下一个候选。j 变成 f[j] 之后,s[1..j] 仍然等于 s[i−j..i−1],只是变短了,所以可以继续比 s[j+1]。
if (s[i] == s[j + 1]) ++j;情况 1:延长。注意此时若 j 曾跳到 0,比的是 s[1],也就是从最小的 border 重来。
f[i] = j;情况 3 的出口:j 已经是 0 且 s[1] ≠ s[i] 时不再进入 if,f[i] = 0。

fail 数组的第二大用途是求周期。一个串 s(长 n)的周期 p 指:对所有 i + p ≤ n 都有 s[i] = s[i+p]。等价说法是“s 有长度 n − p 的 border”。于是最小周期 = n − f[n]。

周期与 border 是一一对应的:想让周期最小,就让 border 最长,而最长 border 就是 f[n]。
周期、完整循环节、全部 border
    // 假设 s[1..n] 与 f[1..n] 已经算好
    int p = n - f[n];                       // 最小周期
    // 完整循环节:n 能整除 p 时,s 由 n/p 个相同的块拼成
    int blocks = (n % p == 0) ? n / p : 0;  // 0 表示“不完整周期”
    cout << p << " " << blocks << "\n";

    // 全部 border 长度(从长到短):沿 f 链走
    for (int k = f[n]; k > 0; k = f[k])
        cout << k << " ";
周期公式的三处用法
代码片段含义与理由
int p = n - f[n];f[n] 是 s 的最长 border。去掉“最长 border 与前缀重合的那一段”,剩下的就是最小移动量 p。
n % p == 0 ? n / p : 0整除才是“若干个完整块”。如 abababa 的 p = 2,但 7 % 2 ≠ 0,它只是“不完整周期”——末尾被截断了。
for (int k = f[n]; k > 0; k = f[k])border 链。f[k] < k 恒成立,所以循环一定终止;但链长最坏是 n−1(全是同一字符时),别在每个位置都遍历整条链。
为什么对应若 p 是周期,则 s[1..n−p] = s[p+1..n],即长度 n−p 的前缀等于同长度后缀——正是 border。反之亦然。

Z 函数是 KMP 的“兄弟”:它直接给出“每个后缀与整个串前缀的最长公共前缀”。把模式串拼在主串前面、中间加一个不在字符集里的分隔符,一次 Z 就能得到所有匹配位置。

Z 函数做匹配
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 2000005;              // |s1| + |s2| + 1 再留余量
char t[MAXN];
int z[MAXN];

void getZ(int n) {                     // t 从 t[1] 开始
    z[1] = n;
    int l = 1, r = 0;
    for (int i = 2; i <= n; ++i) {
        z[i] = 0;
        if (i <= r) z[i] = min(z[i - l + 1], r - i + 1);   // 盒子内先抄
        while (i + z[i] <= n && t[1 + z[i]] == t[i + z[i]]) ++z[i];
        if (i + z[i] - 1 > r) { l = i; r = i + z[i] - 1; }
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    string a, b;
    cin >> a >> b;                     // a 主串,b 模式串
    int n = a.size(), m = b.size();

    for (int i = 1; i <= m; ++i) t[i] = b[i - 1];
    t[m + 1] = '#';                    // 分隔符:不在字符集里,跨界的匹配立刻失败
    for (int i = 1; i <= n; ++i) t[m + 1 + i] = a[i - 1];

    int N = m + 1 + n;
    getZ(N);
    for (int i = m + 2; i <= N; ++i)
        if (z[i] >= m) cout << i - m - 1 << "\n";   // 起点 = i - (m + 1)
    return 0;
}
盒子 [l, r] 里先抄再扩
代码片段含义与理由
t = b + '#' + a分隔符保证“跨过分隔符的 LCP”不会虚增。于是 z[i] ≥ m 恰好等价于“b 在 a 的位置 i−(m+1) 出现”。
z[i] = min(z[i - l + 1], r - i + 1)盒子内 t[i−l+1..] 与 t[i..] 已经对齐,能抄 z[i−l+1] 这么多;但抄不能越过盒子右端,所以再取 r−i+1。
while (i + z[i] <= n && ...)从抄来的长度继续往外扩。边界条件 i + z[i] ≤ n 防越界。
if (i + z[i] - 1 > r)只有右端被推远才换盒子。r 单调不减,所以总扩大次数 ≤ n,整体 O(n)。
i - m - 11 基下 a 的第 pos 个字符在 t 中的下标是 m+1+pos,反过来 pos = i − (m+1)。
为什么还学 Z求“每个后缀与前缀的 LCP”Z 一步到位;求 border 链 fail 更直接。两个都写熟,按题目挑。
KMP 的 6 种错法(带症状)

WA,样例就错、fail 全为 0。—— j 声明在循环体内(for (int i = 2; ...) { int j = 0; ... }),每轮归零,f 永远算不出非零值。

RE / 越界。—— 数组按 |s1| 开。1 基下标要额外留出 s[0] 和结尾 '\0',必须开 |s1| + 2 以上;写 char s1[1000000] 装 106 个字符一定爆。

WA,漏掉重叠的出现。—— 匹配成功后写 j = 0。s2 = aa、s1 = aaa 时应输出 1 和 2 两个位置,写 0 只出 1 个。正确写法是 j = f[j]。

TLE。—— 失配时写成 --j 或 j = f[j-1] 之类的“退一格”版本,退化成 O(nm)。106 的对 a 串能跑出 1012 次比较。

WA,答案整体差 1。—— 0 基写法与 1 基写法混用。网上有 nxt[i] 表示 s[0..i] 的 border 的版本,含义与本节 f[i] 差一位,两套不能混。

WA,格式错。—— P3375 要求每个位置单独一行,最后一行才是 fail 数组;不少模板题是位置空格分隔在一行。先读输出格式再写输出。

自己动手复现

关掉页面,把 P3375 从空文件敲一遍并 AC。

改一改 ①:把 while (j && ...) j = f[j]; 换成 if (j && ...) --j;,输入 s1 为 106 个 a、s2 为 1000 个 a,用 time 看超时差多少。

改一改 ②:把 j = f[j] 改成 j = 0,输入 aaa 与 aa,位置从两个变成一个。

改一改 ③:用 n − f[n] 做周期题。输入 cabcabca 得 3;输入 abababa 得 2,且 7 % 2 ≠ 0——它是“不完整周期”。

改一改 ④:把 Z 函数的 min(z[i-l+1], r-i+1) 改成 z[i-l+1],对拍 P3375 的输出,找出第一个出错的位置。

数学补给 · 均摊 O(n) 与 border 链

两个循环形状相同:j 每轮最多 +1,while 里每次至少 −1。设 j 的总增量 Δ+、总减少量 Δ−。因为 j ≥ 0 恒成立,有 Δ− ≤ Δ+;而 ++j 每轮至多执行一次,故 Δ+ ≤ n。于是 while 的总次数 ≤ n,整个匹配 O(n)。这种“把代价记到未来的增量上”的证明叫势能分析,j 就是势能。

两个恒等式,所有 border 题都从它们出发:

  • p 是 s 的周期 ⟺ s 有长度 n − p 的 border。所以最小周期 = n − f[n]。
  • s[1..i] 的全部 border 长度恰好是 f[i]、f[f[i]]、f[f[f[i]]]……一直到 0。这条链叫 border 链。

border 链没有对数界:s = an 时链就是 n−1, n−2, …, 1,长度 n−1。所以不能靠“链短”来保证复杂度。KMP 的 O(n) 来自势能分析:整场扫描里 +1 至多 n 次,因此 −1 至多 n 次,与某一次跳多远无关。这也解释了为什么“沿链跳”本身很便宜,而在每个位置都重新遍历整条链(例如求所有前缀的全部 border)会变成 O(n2)。

完整周期与不完整周期:n % (n − f[n]) == 0 时 s 由 n/(n−f[n]) 个相同的块拼成;不整除时只是“不完整周期”,如 abababa 的最小周期是 2,但 7 不是 2 的倍数。题目问“循环节个数”还是“周期长度”,答案完全不同。

Fine–Wilf 定理:若 p、q 都是 s 的周期且 p + q − gcd(p, q) ≤ |s|,则 gcd(p, q) 也是 s 的周期。它说明“两个周期会互相挤压出更小的周期”,是很多周期结论的来源。

Z 与 fail 的分工:z[i] 回答“从 i 开始的后缀,与整串开头能对上多少位”;f[i] 回答“前 i 个字符这一段里,前缀与后缀最长能重合多少”。一个以“位置”为轴,一个以“长度”为轴。要“每个位置与前缀的匹配长度”用 Z 更直接;要“前缀的 border 链、周期”用 fail 更直接。

举一反三 · 想清楚这些再往下走
  1. f 数组只存了“最长 border”。如果要 O(1) 回答“某个前缀的全部 border”,需要额外存什么?总空间是多少?
  2. 把 s2 + '#' + s1 拼起来求一次 fail,能不能直接读出所有匹配位置?和“边扫边匹配”比,代价差在哪?
  3. 求 s 的所有循环节长度。为什么它们一定是 n − f[n] 的倍数?给出证明或反例。
  4. 给一个串 s,最少补几个字符能让它变成“某个串重复至少两次”?先用 n − f[n],再想整除条件该怎么接。
  5. 怎么用 KMP 求“每个前缀在 s 中作为子串出现了多少次”?提示:建 fail 树,从长到短把计数累加到 f[i] 上。
  6. Z 函数和 KMP 都能做匹配。举一个 Z 更顺手、一个 fail 更顺手的例子,并说明为什么。
自测:能全部回答再进下一节
  • 能用一句话说出 f[i] 的定义,并手算任意短串的 fail 数组(例如 aabaaab → 0 1 0 1 2 2 3)。
  • 能默写 fail 递推的两行核心,并解释失配时为什么是 j = f[j] 而不是 j = j−1。
  • 能证明 KMP 匹配是 O(n + m)(用势能论证),并说清 j 的增量从哪来。
  • 知道最小周期 = n − f[n],并能判断它是“完整循环节”还是“不完整周期”。
  • 能写 Z 函数,并说清盒子 [l, r] 里 z[i] 为什么可以先抄再扩、为什么要取 min。
  • 知道匹配成功后必须写 j = f[j] 才能找出重叠出现。
挑战题 · Radio Transmission 无线传输
洛谷 P4391提高-去洛谷做这题 ↗

电台把一条信息 S′ 反复拼接后循环播放:S′S′S′……。你收到的是其中一段连续的字符序列 S,长度 L 保证不小于 |S′|。求 S′ 的最小可能长度。

形式化地说:求 S 的最短子串 S′,使得 S 本身是无限串 S′S′S′…… 的一个子串。

输入:第一行一个整数 L(1 ≤ L ≤ 106);第二行恰好 L 个小写字母,即序列 S。

输出:一行一个整数,表示 S′ 的最小长度。

引导性提示(先只看这里)

“S 是 S′ 无限重复的一段”等价于:存在 p,使得对每个满足 i + p ≤ L 的 i 都有 S[i] = S[i+p]。这个 p 有个名字,叫周期。

周期的“补集”是 border:p 是周期,当且仅当 S 有长度 L − p 的 border。想让 p 最小,就要让 border 最长。

先手算样例 cabcabca:最长的、既是前缀又是后缀的真前缀有多长?把它从 L 里减掉,得到什么?再想想为什么不需要判断整除。

想看思路了?点开完整思路

记 p 为 S 的一个周期:S[i] = S[i+p] 对所有合法 i 成立。那么 S 去掉最后 p 个字符得到的前缀,与去掉最前 p 个字符得到的后缀完全相同,即 S 有长度 L−p 的 border。反过来,有长度 b 的 border 就等价于有周期 L−b。

所以“最小周期” ⟺ “最长 border”。最长 border 就是 fail 数组的最后一项 f[L]。答案 = L − f[L]。

为什么不需要整除判断:题目允许最后一段 S′ 被截断。样例 cabcabca 的 p = 3,8 不是 3 的倍数,但它确实是 abcabcabcabc 的一段子串。若要求“完整循环节个数”,才需要额外判 L % p == 0。

复杂度:只需求一次 f[1..L],O(L) 时间、O(L) 空间,L = 106 轻松通过。

边界:L = 1 时 f[1] = 0,答案 1;S 全为同一字符时 f[L] = L−1,答案 1。两端都对。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1000005;
char s[MAXN];
int f[MAXN];

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int L;
    cin >> L;
    cin >> (s + 1);                    // 1 基

    f[1] = 0;
    for (int i = 2, j = 0; i <= L; ++i) {
        while (j && s[i] != s[j + 1]) j = f[j];   // 沿 border 链后退
        if (s[i] == s[j + 1]) ++j;
        f[i] = j;
    }

    // 最小周期 = 长度 - 最长 border
    cout << L - f[L] << "\n";
    return 0;
}
关键片段拆解
片段作用
cin >> (s + 1);题目先给长度 L 再给串,读到 s[1] 起始位置,后面所有下标都是 1 基,与 f 的定义对齐。
f[1] = 0;单字符没有非本身的 border。L = 1 时答案就是 1 − 0 = 1。
for (int i = 2, j = 0; i <= L; ++i)j 写在初始化里,跨轮保留 f[i−1] 的值,这是递推能接上的关键。
while (j && s[i] != s[j + 1]) j = f[j];失配跳 border 链。整段均摊 O(L),不会退化。
cout << L - f[L] << "\n";★ 全部答案。最长 border 越长,周期越短;不判整除,因为末段允许被截断。
这题最容易错的地方
  • 加了 if (L % (L - f[L]) == 0) 才输出,样例之外的数据全错——本题允许不完整周期。
  • 数组开成 char s[1000000]:1 基下标 + 结尾 '\0' 需要 106+2 个字节,会越界 RE。
  • 把答案写成 L - f[L - 1]:f 的下标是“前几个字符”,最后一项必须是 f[L]。
  • 用 0 基读入(cin >> s)却照抄 1 基的递推,整题位移一位。
  • 把 f[i] 算成“包含本身的 border”(f[L] = L):答案会变成 0。border 必须是真子串,递推里 f[1] = 0 就是这条约束的起点。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》0x15 字符串:KMP 与最小表示法
  • 《算法竞赛入门笔记》字符串匹配章节:border 与周期
  • 《算法竞赛试炼场:洛谷300题精析》P3375、P4391 两题的精析
  • 《算法竞赛黄金法则:提高算法和思考力的77项技巧》中“用势能分析估复杂度”的思路
53 倍增与 ST 表提高

53 · 倍增与 ST 表

把“走 2^k 步”预先存下来,任意步数就能拆成二进制去拼。本节用这一招做 O(1) 的静态区间最值(ST 表),以及 O(log n) 的树上 LCA,并给出 lg 数组、st 递推、up 跳表三张必须背下来的表。
提高预计 65 分钟ST 表倍增LCA
本节出现的新函数 / 新语法(先看懂再用)

倍增只有一个动作:先把“走 2k 步之后到哪”全部算出来,再把任意步数拆成二进制去拼。正整数的二进制分解唯一,所以 105 步最多拆成 17 段,每段长度都是 2 的幂。这一节用它做两件事:O(1) 的静态区间最值(ST 表)、O(log n) 的树上 LCA。

ST 表 = 预处理所有长度是 2 的幂的区间 + 每次查询用两段这样的区间拼出目标区间。建表 O(n log n),查询 O(1),代价是只能用于 max、min、gcd 这类“重复算不影响结果”的运算。
要做的事数据规模做法复杂度
静态区间最值(ST 表)n ≤ 105,m ≤ 2×106倍增预处理 2 的幂长度区间建表 O(n log n),单次 O(1)
树上两点 LCA(倍增)n, m ≤ 5×105up[k][v] 跳 2k 级祖先预处理 O(n log n),单次 O(log n)
树上第 k 级祖先n, m ≤ 5×105把 k 按二进制拆开跳单次 O(log n)
快速幂的指数拆分(第 37 节)b ≤ 1018同一个思想,底数平方递推单次 O(log b)

先把两个工具拆开。第一个是 lg 数组——它只有四行代码,但决定了查询能不能真的 O(1);第二个是 ST 表本身的定义与递推。

新函数拆解 · lg 数组预计算(floor(log2) 查表)
lg[i] = lg[i >> 1] + 1; // 预处理,O(n) int k = lg[r - l + 1]; // 查询时 O(1) 取出
组成含义 / 格式要求
lg[i] 的含义最大的非负整数 k,使 2k ≤ i。也就是 ⌊log2 i⌋,等于 i 的二进制位数减 1。
初始化lg[1] = 0(20 = 1 ≤ 1,而 21 = 2 > 1);lg[0] 用不到,全局数组默认 0 正好无害。循环从 i = 2 开始,不要从 1 开始,否则会把 lg[1] 改写成 1。
递推式lg[i >> 1] + 1。因为 i 与 ⌊i/2⌋ 的二进制位数最多差 1,而 ⌊log2 i⌋ = ⌊log2⌊i/2⌋⌋ + 1 对 i ≥ 2 恒成立(乘 2 = 指数加 1,奇数末位被取整吃掉)。
数组大小必须开到 n + 1。开成 n 时 lg[n] 越界,症状是随机的 RE 或答案错,而且往往小样例看不出来。
为什么不用 log2log2 是浮点函数,一次调用几十纳秒,2×106 次询问就是上百毫秒;而且它不保证对 2 的幂返回恰好整数,转 int 后可能少 1,直接查错层。查表既快又准。
__lg 的替代GCC 内建 __lg(x) 直接返回 ⌊log2 x⌋,x ≥ 1。但它不是标准 C++(MSVC 没有),且实测在密集查询里略慢于查表,竞赛里一般还是预计算。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int lg2v[10];

int main() {
    lg2v[1] = 0;                                  // 2^0 = 1
    for (int i = 2; i <= 8; ++i) lg2v[i] = lg2v[i >> 1] + 1;

    for (int i = 1; i <= 8; ++i) cout << i << " -> " << lg2v[i] << "\n";
    // 1 -> 0    2 -> 1    3 -> 1    4 -> 2
    // 5 -> 2    6 -> 2    7 -> 2    8 -> 3
    return 0;
}
注意

本文把这张表记作 lg;示例与完整代码里的数组名是 lg2v,两者是同一个东西。

lg2v[i] = lg2v[i >> 1] + 1 依赖 lg2v[⌊i/2⌋] 已经算好。因为 ⌊i/2⌋ < i,从小到大循环天然满足。

查询时长度是 r - l + 1,不是 r - l。差一个 1 会让 k 取小一层,结果只覆盖半个区间(WA,答案偏小)。

新函数拆解 · ST 表:st[k][i] 的建表与查询
st[k][i] = 区间 [i, i + 2^k - 1] 的答案 st[0][i] = a[i]; st[k][i] = max(st[k-1][i], st[k-1][i + (1 << (k-1))]); ans = max(st[k][l], st[k][r - (1 << k) + 1]); // k = lg[r-l+1]
组成含义 / 格式要求
st[k][i] 的定义以 i 为左端点、长度恰好 2k 的区间的答案。k 叫“层”,i 叫“起点”,两者一起唯一确定一个区间。
第 0 层长度 20 = 1,就是原数组。建表时可以直接把读入写进 st[0][i],省掉一个 a[] 数组。
递推怎么来长度 2k 的区间从中间劈成两个长度 2k-1 的半段:[i, i+2k-1-1] 与 [i+2k-1, i+2k-1],正好是 st[k-1][i] 与 st[k-1][i + 2k-1]。所以一行 max 就能合成。
合并只要求结合律 + 幂等同一元素算两次不影响结果(max、min、gcd、按位或、按位与都满足)。求和、乘积不满足,套进来就错。
查询的两个起点k = ⌊log2 len⌋。左段从 l 起(覆盖前 2k 个),右段以 r 结尾(起点 r − 2k + 1)。两段都落在 [l, r] 内。
为什么两段一定盖满由 k 的定义 2k ≤ len < 2k+1。若两段之间有缝,则 len > 2k+1,矛盾。所以要么重叠、要么恰好相接。
空间与写法st[K][n+1] 个 int。第一维放层、第二维放起点:查询时同一个 k 连查两个起点,行内地址连续,缓存命中率更高。
最小可运行示例
示例
// n = 1e5,K = 17(2^16 = 65536 <= 1e5 < 2^17)
int st[17][100005], lg2v[100005];

lg2v[1] = 0;
for (int i = 2; i <= n; ++i) lg2v[i] = lg2v[i >> 1] + 1;

for (int k = 1; (1 << k) <= n; ++k)                       // 只要 2^k <= n 就有合法区间
    for (int i = 1; i + (1 << k) - 1 <= n; ++i)           // 右端点不能越过 n
        st[k][i] = max(st[k-1][i], st[k-1][i + (1 << (k-1))]);

int query(int l, int r) {
    int k = lg2v[r - l + 1];
    return max(st[k][l], st[k][r - (1 << k) + 1]);
}
注意

内层循环的右端点判断 i + (1 << k) - 1 <= n 不能省。省掉就是越界写,会踩坏后面的数组,症状是 RE 或者只有大数据才错。

把 max 换成 + 立刻 WA:查询时两段重叠,重叠部分会被加两次。这就是“只支持幂等运算”的实际含义。

这两块拼起来就是 ST 表模板。下面这道题把 m 堆到 2×106,正是逼你把单次查询压到 O(1) 的经典设置——线段树 O(log n) 会多出一个 17 的常数,在 3.4×107 次操作上就是过与不过的差别。

例题 · 【模板】ST 表洛谷 P3865普及/提高-去洛谷做这题 ↗

给定 n 个整数 a1…an,和 m 次询问,每次询问区间 [l, r] 内的最大值。

输入:第一行两个整数 n、m(1 ≤ n ≤ 105,1 ≤ m ≤ 2×106)。第二行 n 个整数,|ai| ≤ 109。接下来 m 行,每行 l、r(1 ≤ l ≤ r ≤ n)。

输出:m 行,每行一个整数,表示对应询问的答案。

输入: 8 3 9 3 1 7 5 2 6 4 1 6 2 8 3 3 输出: 9 7 1
例题怎么想:把“所有区间”砍到只剩 2 的幂

先估暴力:每次询问扫一遍 [l, r],最坏 O(n) 一次,总 O(nm) = 105 × 2×106 = 2×1011 次比较,按每秒 109 次算也要 200 秒。瓶颈不是每一步慢,是步数太多。

要 O(1),答案就必须“提前算好、查询时只做常数次查表”。所以问题变成:预处理哪些区间,才既装得下又够用?

所有区间有 n(n+1)/2 个,105 时约 5×109 个,装不下。于是只留长度是 2 的幂的区间:每一层 n 个,一共 17 层,1.7×106 个数,只占 6.8 MB。少算的代价由“两段拼接”补回来。

拼接的正确性:设 len = r − l + 1,k = ⌊log2 len⌋。长度 2k 的区间放得下,左起一段 [l, l+2k−1] 和右止一段 [r−2k+1, r] 都在 [l, r] 内;又因为 2k+1 > len,两段长度之和超过 len,中间不可能有缝。重叠部分被算了两次,但 max 对同一个数算两次没有影响。

层 k区间长度st[k][1..]说明
019 3 1 7 5 2 6 4原数组,一个数一段
129 3 7 7 5 6 6每两个取 max
249 7 7 7 6每四个取 max
389整个数组

走一遍询问 l = 2、r = 8:len = 7,k = ⌊log2 7⌋ = 2。左段 st[2][2] 覆盖 [2,5],值是 7;右段起点 8 − 4 + 1 = 5,st[2][5] 覆盖 [5,8],值是 6。答案 max(7, 6) = 7。区间 [5,5] 同时被两段覆盖(叠了一次),但 7 和 6 取 max 仍是 7,结果不受影响。

三步骨架
lg2v[1] = 0
for i = 2 .. n:  lg2v[i] = lg2v[i >> 1] + 1        // ① 长度 -> 层号

st[0][i] = a[i]                                     // ② 第 0 层就是原数组
for k = 1 .. while 2^k <= n:
    for i = 1 .. n - 2^k + 1:
        st[k][i] = max(st[k-1][i], st[k-1][i + 2^(k-1)])

query(l, r):
    k = lg2v[r - l + 1]                             // ③ 两段拼接
    return max(st[k][l], st[k][r - 2^k + 1])
完整代码
#include <bits/stdc++.h>
using namespace std;

const int N = 100005;
const int K = 17;                       // 2^16 = 65536 <= 1e5 < 2^17,层数最多到 16

int st[K][N];                           // st[k][i]:区间 [i, i + 2^k - 1] 的最大值
int lg2v[N];                            // lg2v[len] = floor(log2(len))

int main() {
    int n, m;
    scanf("%d %d", &n, &m);             // m 到 2e6,用 scanf/printf 比 cin/cout 稳

    for (int i = 1; i <= n; ++i) scanf("%d", &st[0][i]);   // 第 0 层直接读入,省掉 a[]

    lg2v[1] = 0;                                            // 长度 1 只需要第 0 层
    for (int i = 2; i <= n; ++i) lg2v[i] = lg2v[i >> 1] + 1;

    // 递推:长度 2^k 的区间 = 左右两个长度 2^(k-1) 的区间
    for (int k = 1; (1 << k) <= n; ++k)
        for (int i = 1; i + (1 << k) - 1 <= n; ++i)         // 右端点不越界
            st[k][i] = max(st[k - 1][i], st[k - 1][i + (1 << (k - 1))]);

    while (m--) {
        int l, r;
        scanf("%d %d", &l, &r);
        int k = lg2v[r - l + 1];                            // 2^k <= 长度 < 2^(k+1)
        // 左段以 l 开头,右段以 r 结尾;两段必然覆盖整个区间
        printf("%d\n", max(st[k][l], st[k][r - (1 << k) + 1]));
    }
    return 0;
}
逐行拆解
代码片段含义与理由
const int K = 17;层数上限。n ≤ 105 时 ⌊log2 n⌋ = 16,需要 0…16 共 17 层。少开一层就会写越界。
int st[K][N];层放第一维、起点放第二维。查询时同一个 k 连查两个起点,行内地址连续;写成 st[i][k] 会在两个不相邻的行之间来回跳。
scanf("%d %d", &n, &m);询问最多 2×106 次、整数最多 4×106 个,scanf 直读比 cin 少一层类型解析。
scanf("%d", &st[0][i])读进第 0 层。第 0 层就是原数组,这样省掉一个 a[],也省掉一次复制。
lg2v[1] = 0;长度 1 对应第 0 层。这个初值必须写,循环是从 2 开始的。
lg2v[i] = lg2v[i >> 1] + 1;i 的层号 = ⌊i/2⌋ 的层号 + 1。从小到大推,右式一定已算好。
(1 << k) <= n只要 2k ≤ n,这一层就存在合法区间。n = 105 时建到 k = 16 为止。
i + (1 << k) - 1 <= n区间右端点不能越过 n。缺这个条件的症状是越界写,小数据正常、大数据 RE。
st[k-1][i + (1 << (k-1))]右半段的左端点 = i + 2k-1。这是整个递推唯一容易写错的下标。
int k = lg2v[r - l + 1];长度换层号,一次数组访问,O(1)。写成 r − l 会取小一层,只覆盖大半个区间。
st[k][r - (1 << k) + 1]以 r 结尾、长度 2k 的那一段。+1 不能丢,丢了整段左移一格,答案可能取到区间外的数。
max(两段)重叠部分被算了两次,但 max 幂等,结果不变。换成 + 就会把重叠部分加两遍。
printf("%d\n", ...)2×106 行输出,用 "\n" 而不是 endl。endl 每次都刷新缓冲区,实测慢 3~10 倍。

ST 表是倍增在“数组上的一段”里的用法。同一个思想在树上是求 LCA:给定以 s 为根的树和两点 u、v,求它们最近的公共祖先。

朴素做法:从 u 一路往上走,把经过的点打上标记;再从 v 往上走,第一次踩到标记的点就是 LCA。单次 O(深度),5×105 次询问在一条链上直接退化成 2.5×1011 次操作。倍增把它压到 O(log n):只预存“往上跳 2k 步到哪”这 n log n 个位置。

新函数拆解 · 倍增跳表 up[k][v]
up[0][v] = fa[v]; up[k][v] = up[k-1][up[k-1][v]];
组成含义 / 格式要求
up[k][v]从 v 往上走 2k 步到达的点。k = 0 是父亲,k = 1 是爷爷,k = 2 是往上第 4 个点。
递推依据走 2k 步 = 先走 2k-1 步、再走 2k-1 步。所以两次 up[k-1] 串起来就是 up[k],一行搞定。
0 号哨兵把根的父亲设成 0,并让 up[*][0] = 0、dep[0] = 0。往上跳出树根时自动停在 0,全程不用特判。
计算顺序必须等 up[k-1][v] 的整行算完,才能算 up[k][v]。所以要么 BFS(父亲先出队),要么在 DFS 里“进儿子之前”把当前点整行算完。
深度差怎么用d = dep[u] − dep[v] 是个整数,按二进制位从低到高扫,第 k 位是 1 就 u = up[k][u],总共只有 O(log n) 跳,而且跳的步数加起来恰好是 d。
LCA 的第二步深度对齐后,从 k = K−1 往 0 试:若 up[k][u] ≠ up[k][v],说明还跳不到公共祖先之上,可以跳;循环结束时 u、v 必是 LCA 的两个不同儿子,答案是 up[0][u]。
最小可运行示例
示例
// 建表(跟 BFS 一起做,避免链状树爆栈)
void bfs(int root) {
    queue<int> q; q.push(root); dep[root] = 1;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int k = 1; k < K; ++k) up[k][u] = up[k-1][up[k-1][u]];
        for (int e = head[u]; e; e = nxt[e]) {
            int v = to[e];
            if (v == up[0][u]) continue;      // 跳过父亲
            up[0][v] = u; dep[v] = dep[u] + 1;
            q.push(v);
        }
    }
}

// up[k][3] 的含义:从 3 号点往上跳 2^k 步
// 例:路径 3 -> 5 -> 8 -> 12 -> 1 -> 0(0 是树外的哨兵)
//     up[0][3] = 5    往上第 1 步
//     up[1][3] = 8    往上第 2 步(= up[0][up[0][3]])
//     up[2][3] = 1    往上第 4 步(= up[1][up[1][3]] = up[1][8])
注意

数组开成 up[K][N]:K ≈ log2 n + 1。n = 5×105 时 K 取 20 足够。

用 BFS 建表而不是递归 DFS:链状树深度 5×105,每层栈帧几十字节,递归会爆栈(RE)。

倍增求 LCA(输入格式同 P3379:n、m、根,n−1 条边,m 次询问)
#include <bits/stdc++.h>
using namespace std;

const int N = 500005;                  // P3379 的量级:5×10^5 个点
const int K = 20;                      // 2^18 = 262144 <= 5e5 < 2^19,层数到 18,开 20 留余量

int head[N], nxt[N << 1], to[N << 1], ecnt;   // 链式前向星(第 41 节)
int up[K][N], dep[N];

void addEdge(int u, int v) {
    to[++ecnt] = v; nxt[ecnt] = head[u]; head[u] = ecnt;
}

void bfs(int root) {
    queue<int> q;
    q.push(root);
    dep[root] = 1;                     // 根的深度设 1,于是 dep[0] = 0 天然是哨兵
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int k = 1; k < K; ++k)    // 整行一次算完:2^k = 2^(k-1) + 2^(k-1)
            up[k][u] = up[k - 1][up[k - 1][u]];
        for (int e = head[u]; e; e = nxt[e]) {
            int v = to[e];
            if (v == up[0][u]) continue;         // 无向边存了两遍,跳过父亲
            up[0][v] = u;                        // 儿子的一级祖先就是 u
            dep[v] = dep[u] + 1;
            q.push(v);
        }
    }
}

int lca(int u, int v) {
    if (dep[u] < dep[v]) swap(u, v);   // 让 u 更深
    int d = dep[u] - dep[v];
    for (int k = 0; k < K; ++k)        // 深度差按二进制拆开,逐位往上跳
        if (d >> k & 1) u = up[k][u];
    if (u == v) return u;              // v 本来就是 u 的祖先
    for (int k = K - 1; k >= 0; --k)   // 从大到小试:跳完还不同就跳
        if (up[k][u] != up[k][v]) { u = up[k][u]; v = up[k][v]; }
    return up[0][u];                   // 此时 u、v 是 LCA 的两个儿子
}

int main() {
    int n, m, s;
    scanf("%d %d %d", &n, &m, &s);
    for (int i = 1; i < n; ++i) {
        int u, v; scanf("%d %d", &u, &v);
        addEdge(u, v); addEdge(v, u);
    }
    bfs(s);
    while (m--) {
        int a, b; scanf("%d %d", &a, &b);
        printf("%d\n", lca(a, b));
    }
    return 0;
}
LCA 代码的三处关键
代码片段含义与理由
dep[root] = 1;根深度设 1,则 dep[0] = 0。0 号点成了“树外”的哨兵,跳到根外面会自动停住,不用写 if。
up[k][u] = up[k-1][up[k-1][u]];内层先取 up[k-1][u],外层再取一次 up[k-1]。要求内层那一点的整行已经算好——BFS 里它一定是 u 的祖先,早就出队算完了。
if (v == up[0][u]) continue;无向边正反存了两份。漏了这一行会把父亲也当儿子压进队列,深度层层加错,最后死循环。
if (d >> k & 1) u = up[k][u];把深度差 d 拆成二进制。d 的每一位最多被用一次,跳完恰好等于 d,不会多跳一步。
if (u == v) return u;深度对齐后两点重合,说明 v 是 u 的祖先,答案就是 v。少了这句,下面会多跳一层,返回 v 的父亲。
for (int k = K - 1; k >= 0; --k)★ 必须从大到小。从大到小每次跳“跳了还不同”的最大步长,最后必然停在 LCA 下方;从小到大很可能第一步就跨过 LCA,再也回不来。
if (up[k][u] != up[k][v])判据是“跳完还不同”。相同说明这一步会落到 LCA 或 LCA 之上,不能跳。
return up[0][u];循环收敛时 u、v 深度相同且是 LCA 的两个不同儿子,父亲即答案。
这一节的 8 种典型错法(附症状)

WA,答案偏小。—— 查询写成 st[k][l] 与 st[k][l + 2^k - 1],右对齐的那一段没取,区间右半边根本没参与。第二个下标必须是 r - (1 << k) + 1。

WA,答案偏大。—— 把 max 换成 + 或 ×。两段在 len 不是 2 的幂时必然重叠,重叠部分被算了两次。

RE,或只有大数据错。—— 建表少了 i + (1 << k) - 1 <= n,st[k][i] 写到数组外面,踩坏 lg 数组或别的变量。

RE,段错误。—— K 开小一层。n = 105 时要用到第 16 层,写 K = 16 就越界;n = 5×105 的 LCA 要用到第 18 层,写 K = 18 同样越界。

TLE。—— m = 2×106 还开着 cin/cout 同步、每行用 endl。总量 4×106 次读写,光刷新缓冲区就能吃掉 1 秒以上。

WA,全负区间输出 0。—— 把答案初值写成 0 再 max 累加。区间里全是负数时 0 比任何元素都大。正确写法是直接对两个真实元素取 max,不设初值。

RE(爆栈)。—— 倍增 LCA 用递归 DFS 建表,遇到链状树(深度 5×105)栈溢出。改用 BFS。

WA,LCA 差一层。—— 第二步的循环写成 for (int k = 0; k < K; ++k)。从小到大跳会越过 LCA,返回的是 LCA 的某个祖先。

自己动手复现

关掉页面,把 P3865 从空文件敲一遍并提交。

改一改 ①:把查询里的 max 换成 +,输入上面的样例,手算 [1,6]:st[2][1] = 9(区间 1–4)、st[2][3] = 7(区间 3–6),相加得 16,而真实和是 27。亲眼看清“重叠”是怎么回事。

改一改 ②:删掉 lg 数组,查询里写 int k = (int)log2(r - l + 1);,构造长度 8、16 的询问,看会不会因为浮点误差取到 2 层而不是 3 层。

改一改 ③:把 st 声明改成 st[N][K](两个维度交换),代码逻辑不变,用 2×106 次询问计时对比——差的是缓存命中,不是算法。

改一改 ④:把 LCA 代码交到 P3379 上,再把第二步的循环方向改成从小到大,重新提交,看答案错成什么样。

数学补给 · log2、二进制分解与区间覆盖

⌊log2 n⌋ 就是 n 的二进制位数减 1。这个等式说明:倍增的层数,和整数的二进制长度是同一件事。估 K 的时候不用背表,把 n 写成二进制数位数即可。

  • n = 8 = 10002,4 位,⌊log2 n⌋ = 3,需要 0…3 共 4 层。
  • n = 105 = 110000110101000002,17 位,⌊log2 n⌋ = 16,K 至少 17。
  • n = 5×105:218 = 262144 ≤ 5×105 < 524288 = 219,故 ⌊log2 n⌋ = 18,K 至少 19。

两段必覆盖的严格论证:设 len = r − l + 1,k = ⌊log2 len⌋,于是 2k ≤ len < 2k+1。左段 L = [l, l+2k−1],右段 R = [r−2k+1, r]。两段的端点都落在 [l, r] 里,所以 L、R ⊆ [l, r]。若 L ∪ R 有缺口,则 l+2k−1 < r−2k+1,即 len = r−l+1 > 2k+1,与 k 的定义矛盾。所以 L ∪ R = [l, r],两段至多重叠 2k+1 − len 个元素。

幂等性为什么是必要条件:把运算记作 ⊕。要允许“同一元素被算两次”,必须 a ⊕ a = a(幂等);要能“劈半合并”,必须有结合律。max、min、gcd、lcm、按位或、按位与都满足这两条;加法、乘法、异或只满足结合律不满足幂等,所以 sum 的区间查询必须用前缀和(第 18 节)或线段树(第 47 节)。

空间账:ST 表存 (⌊log2 n⌋ + 1) × n 个 int。n = 105 时 17 × 105 × 4 B = 6.8 MB;lg 数组 0.4 MB。总计 7 MB 出头,洛谷常见的 125 MB 限制下非常宽裕;但 n 涨到 106 就要 20 层、80 MB,这时候要么改用分块(第 57 节),要么改用线段树把空间压回 O(n)。

LCA 的运算量:预处理 n log n ≈ 5×105 × 19 = 9.5×106 次赋值;单次询问第一步最多 19 次跳、第二步最多 20 次判断,合计不到 40 次比较,与树有多大完全无关。这就是“把 O(n) 压成 O(log n)”的实际含义:把和规模成正比的部分全部搬到预处理里。

举一反三 · 想清楚这些再往下走
  1. 如果询问换成“区间 gcd”或“区间按位或”,ST 表还能不能原样用?区间和为什么不行?请从“一个元素最多被算几次”这个角度回答。
  2. 要用 ST 表查“区间内出现次数最多的数”或者“区间第 k 小”,行不行?不行的根本原因是缺少哪条性质?
  3. 倍增还能用在哪?至少举出两个例子并说清“跳 2k 步”在那里对应什么操作(提示:快速幂的指数、树上第 k 级祖先、后缀数组的倍增构造)。
  4. 如果数组会被单点修改、同时要区间最大值,ST 表还能保持 O(1) 查询吗?为什么?这时候应该换成什么结构?
  5. 二维 ST 表求子矩阵最大值:多一维之后,预处理和查询各变成多少?空间涨到什么量级?
  6. LCA 的第二步为什么必须从大到小枚举 k?请构造一棵具体的小树,说明从小到大枚举会跳到哪里。
  7. 如果图的形状不是树而是 DAG(一个点可以有多个父亲),“LCA”还有意义吗?怎么定义才不矛盾?
自测:能全部回答再进下一节
  • 能默写 lg 数组的递推,并解释为什么 ⌊log2 i⌋ = ⌊log2⌊i/2⌋⌋ + 1。
  • 能手算 n = 105 与 n = 5×105 时需要的 K,并说明 st 数组为什么要开 K × (n+1)。
  • 能说清 ST 表查询为什么只查两段、为什么允许两段重叠、为什么 sum 不行。
  • 能看着样例的 8 个数,把第 0/1/2/3 层全部手写出来。
  • 能默写倍增 LCA 的三步:对齐深度、同时上跳、返回父亲,并说清 0 号哨兵的作用。
  • 知道倍增 LCA 为什么优先用 BFS 建表而不是递归 DFS。
  • 拿到一道“静态区间最值、m 很大”的题,能立刻判断该用 ST 表还是线段树,并说出理由。
挑战题 · Balanced Lineup G
洛谷 P2880普及/提高-去洛谷做这题 ↗

农夫约翰有 N 头奶牛,每天按固定顺序站成一排,第 i 头奶牛身高为 hi。有 Q 次询问,每次给出一个区间 [A, B],求这段区间里最高牛与最矮牛的身高差。

输入:第一行两个整数 N、Q(1 ≤ N ≤ 5×104,1 ≤ Q ≤ 2×105)。接下来 N 行,每行一个整数 hi(1 ≤ hi ≤ 106)。接下来 Q 行,每行两个整数 A、B(1 ≤ A ≤ B ≤ N)。

输出:Q 行,每行一个整数,表示 hA 到 hB 中的最大值减最小值。

引导性提示(先只看这里)

题目只要区间最值,没有任何修改操作——这类题的第一反应应该是 ST 表,而不是线段树。

一次询问要两个最值。同一套 st 数组能不能同时存最大和最小?如果不能,要开几个二维数组?

答案就是“区间最大值 − 区间最小值”。注意:局部变量的初值、数组的默认值会不会影响 min 的结果?

想看思路了?点开完整思路

把 ST 表模板复制两份:mx[k][i] 存区间 [i, i+2^k−1] 的最大值,mn[k][i] 存最小值,两者的建表方式完全一样,只是合并时一个用 max、一个用 min。

第 0 层就是原数组,mx[0][i] = mn[0][i] = h[i]。这一步是 min 表唯一能拿到真实值的地方,不能指望全局数组的 0 初值——hi ≥ 1,用 0 初始化会让所有 min 都变成 0。

每次询问按同样的 k 取出两段:hi = max(mx[k][A], mx[k][B−2^k+1]),lo = min(mn[k][A], mn[k][B−2^k+1]),输出 hi − lo。两段重叠对 max 和 min 都没有影响,所以同一个 k 可以直接用于两张表。

复杂度:预处理 O(N log N) ≈ 5×104 × 16 = 8×105,Q 次询问每次 4 次取最值,总计不到 106 次运算。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int N = 50005;
const int K = 17;                       // 2^15 = 32768 <= 5e4 < 65536,层数最多到 15

int mx[K][N], mn[K][N];
int lg2v[N];

int main() {
    int n, q;
    scanf("%d %d", &n, &q);

    lg2v[1] = 0;
    for (int i = 2; i <= n; ++i) lg2v[i] = lg2v[i >> 1] + 1;

    for (int i = 1; i <= n; ++i) {
        int x; scanf("%d", &x);
        mx[0][i] = mn[0][i] = x;        // 长度 1 的区间,最大最小都是自己
    }

    for (int k = 1; (1 << k) <= n; ++k) {
        for (int i = 1; i + (1 << k) - 1 <= n; ++i) {
            int j = i + (1 << (k - 1));             // 右半段的左端点
            mx[k][i] = max(mx[k - 1][i], mx[k - 1][j]);
            mn[k][i] = min(mn[k - 1][i], mn[k - 1][j]);
        }
    }

    while (q--) {
        int a, b;
        scanf("%d %d", &a, &b);
        int k = lg2v[b - a + 1];
        int hi = max(mx[k][a], mx[k][b - (1 << k) + 1]);
        int lo = min(mn[k][a], mn[k][b - (1 << k) + 1]);
        printf("%d\n", hi - lo);         // 身高差,不是和
    }
    return 0;
}
关键片段拆解
片段作用
int mx[K][N], mn[K][N];两张表结构完全相同,只有合并运算不同。一次建表顺手把两个都填好,比建两遍各扫一次省一半时间。
mx[0][i] = mn[0][i] = x;★ 第 0 层必须真的写入。全局数组默认是 0,如果漏了 mn[0][i],min 会在第 1 层就把 0 卷进去,答案恒等于最大值。
(1 << k) <= n层数上限由 n 决定:215 = 32768 ≤ 5×104,所以最高建到 k = 15。
int j = i + (1 << (k - 1));右半段起点。写成 i + (1 << k) 会跳过半个区间,写成 i + (1 << k) - 1 会少覆盖一个元素。
int k = lg2v[b - a + 1];长度 → 层号。两个查询都复用这个 k,因为 mx、mn 的层划分完全一致。
b - (1 << k) + 1右段的起点。与模板一致,+1 不能丢。
hi - lo题目要的是差。写成 lo - hi 会输出负数;写成 hi + lo 是另一道题。
这题最容易错的地方
  • mn 表没有写第 0 层:全局数组的 0 被卷进 min,n 以内的最小值全变 0,输出恒等于最大值,样例就能看出来。
  • K 写成 15:n 到 5×104 时需要第 15 层,数组只有 0…14,越界写 → RE 或答案随机。
  • 两张表用了不同的 k:mx 取 lg2v[len]、mn 取 lg2v[len−1],覆盖区间不同,差值没有意义。
  • 把 hi − lo 写成 lo − hi 或 hi + lo:前者三个询问全是负数,后者全错,样例一眼可见,别靠样例蒙。
延伸阅读 / 参考
  • 《算法竞赛入门笔记》倍增与 RMQ 章节
  • 《算法竞赛进阶指南》0x21 树与图的遍历:倍增求 LCA
  • 《算法竞赛试炼场:洛谷300题精析》P3865、P2880 两题的精析
  • 《算法竞赛黄金法则:提高算法和思考力的77项技巧》第 44 条:把 2 的幂当作步长的单位
54 二分图与匈牙利算法提高

54 · 二分图与匈牙利算法

先学会用 BFS 染色判定二分图,再用匈牙利算法在 O(nE) 内求最大匹配:对每个左侧点找一条增广路,找到就翻转,匹配数 +1。最后用 König 定理把匹配数直接换算成最小点覆盖与最大独立集,一行代码解决两类问题。
提高预计 65 分钟二分图匈牙利染色判定
本节出现的新函数 / 新语法(先看懂再用)

二分图是图论里结构最干净的一类:点能分成左右两堆,每条边都从左边连到右边,堆内没有边。结构干净,问题就好算——“把 n 个任务分给 m 个人,每人最多一个、每人只会其中几项”,这类分配问题一旦建成二分图,就变成了求最大匹配。

二分图 ⟺ 可以二染色使每条边两端异色 ⟺ 不含奇环。这三个说法完全等价,第一个用来建图,第二个用来写代码,第三个用来出反例。
要做的事做法复杂度本节位置
判断一个图是不是二分图BFS 染色,遇同色相邻就否定O(n + m)本节第一部分
二分图最大匹配匈牙利算法,逐个左侧点找增广路O(n·E),n = 500、E = 5×104 时 2.5×107本节第二部分
最小点覆盖(二分图)König 定理:等于最大匹配与匹配同阶本节末尾
最大独立集(二分图)总点数 − 最大匹配与匹配同阶本节末尾

先解决“怎么知道它是不是二分图”。做法就是把二分图的定义直接翻译成代码:任选一个点染成 0,它的邻居全染成 1,邻居的邻居再染回 0……如果某条边的两端被迫同色,这个图就不是二分图。

新函数拆解 · col 数组与 BFS 染色
int col[N]; // -1 未染色,0 / 1 两种颜色 col[v] = col[u] ^ 1; // 邻居染成相反色 if (col[v] == col[u]) 不是二分图;
组成含义 / 格式要求
col[v] 的取值−1 表示未染色,0 与 1 表示两种颜色。不要用 0 表示“未染色”:第一个连通块的点会被染成 0,于是它永远看起来像没染过,BFS 会反复入队。
col[u] ^ 1按位异或 1 就是 0↔1 互换。比写 1 - col[u] 少一次减法,语义也更像“取反”。
冲突判据col[v] == col[u] 且 v 已染色,说明同一条边的两端同色,绕出来的环长度是奇数,图不是二分图,立刻返回 false。
遍历所有点图不一定连通。只从 1 号点染色,其余连通块全是 −1,判定结果随机。外层必须 for (int i = 1; i <= n; ++i) if (col[i] == -1) 开一次 BFS。
初始化memset(col, -1, sizeof col)。全局数组默认是 0,不 memset 就全部“已染成 0 色”,第一次判断就全错。
复杂度每个点入队一次,每条边正反各看一次,O(n + m)。这一步比匹配本身便宜一个数量级。
最小可运行示例
示例
int col[10005];

// 对 s 所在的连通块染色,冲突返回 false
bool paint(int s) {
    queue<int> q;
    q.push(s);
    col[s] = 0;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int e = head[u]; e; e = nxt[e]) {
            int v = to[e];
            if (col[v] == -1) { col[v] = col[u] ^ 1; q.push(v); }
            else if (col[v] == col[u]) return false;   // 同色相邻
        }
    }
    return true;
}
注意

判断顺序不能反:先看“未染色”再染色,否则新染的点会被自己判成冲突。

自环(u 连 u)一定让图为非二分图——同一个点与自己相邻,两端必然同色,代码天然能判出来。

染色判定完整代码(输入 n、m 与 m 条无向边,输出 Yes / No)
#include <bits/stdc++.h>
using namespace std;

const int N = 10005;
const int M = 100005;

int head[N], nxt[M << 1], to[M << 1], ecnt;
int col[N];                             // -1 未染色,0 / 1 两种颜色

void addEdge(int u, int v) {
    to[++ecnt] = v; nxt[ecnt] = head[u]; head[u] = ecnt;
}

// 对 s 所在连通块染色;能二染色返回 true
bool paint(int s) {
    queue<int> q;
    q.push(s);
    col[s] = 0;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int e = head[u]; e; e = nxt[e]) {
            int v = to[e];
            if (col[v] == -1) {                  // 没染过:染成相反色
                col[v] = col[u] ^ 1;
                q.push(v);
            } else if (col[v] == col[u]) {       // 同一条边两端同色 → 存在奇环
                return false;
            }
        }
    }
    return true;
}

int main() {
    int n, m;
    scanf("%d %d", &n, &m);
    for (int i = 1; i <= m; ++i) {
        int u, v; scanf("%d %d", &u, &v);
        addEdge(u, v); addEdge(v, u);            // 无向边,存两份
    }

    memset(col, -1, sizeof col);                 // ★ 必须先置 -1
    for (int i = 1; i <= n; ++i) {               // 图可能不连通,每块各开一次
        if (col[i] != -1) continue;
        if (!paint(i)) { printf("No\n"); return 0; }
    }
    printf("Yes\n");
    return 0;
}
染色代码的三处关键
代码片段含义与理由
memset(col, -1, sizeof col);★ 全局数组的默认值是 0,而 0 是一种合法颜色。不清成 −1,整个图会被当成“已染成 0 色”,第一条边就报冲突。
for (int i = 1; i <= n; ++i)外层循环负责所有连通块。二分图可以是“一片森林”,只从 1 号点出发会漏掉其它块。
if (col[v] == -1)先判未染色。若先判“同色”再判“未染色”,新点会被当成冲突。
col[v] = col[u] ^ 1;异或 1 取反。整张图一旦起点颜色定了,其余所有点的颜色都被唯一确定。
else if (col[v] == col[u])已染色且相同 = 冲突。此时 u、v 到 BFS 根的路径长度奇偶相同,加上边 (u,v) 正好构成奇环。
nxt[M << 1], to[M << 1]链式前向星每条无向边存两份,这两个数组要开 2m。开成 m 会在存反向边时越界。

判定解决“结构对不对”,匹配解决“怎么配最多”。下面这道模板题把问题剥到最干净:给一个二分图和若干条允许的配对,问最多能配出几对。

例题 · 【模板】二分图最大匹配洛谷 P3386普及/提高-去洛谷做这题 ↗

给定一个二分图,左部有 n 个点、右部有 m 个点,共 e 条边(每条边从左侧某点连到右侧某点)。求这个二分图的最大匹配数。

输入:第一行三个整数 n、m、e(1 ≤ n, m ≤ 500,1 ≤ e ≤ 5×104)。接下来 e 行,每行两个整数 u、v,表示左侧点 u 与右侧点 v 之间有一条边。

输出:一行一个整数,为最大匹配数。

输入: 3 3 5 1 1 1 2 2 1 3 2 3 3 输出: 3
例题怎么想:为什么不能贪心,增广路是什么

先证明贪心不行。还是上面这 5 条边,按左侧点 1、2、3 的顺序,每人都抢第一个还空着的右点:L1 拿到 R1;L2 只剩 R1 可选,被占,放弃;L3 拿到 R2。结果只能配 2 对。

但最优是 3 对:L1–R2、L2–R1、L3–R3。贪心在 L2 这一步锁死了自己——它不知道“让 L1 换个位置”这件事是可行的。

让位就是增广。L2 想抢 R1 时,先看 R1 的主人 L1 能不能腾地方:L1 除了 R1 还能连 R2,而 R2 空着。于是走出一条路 L2 — R1 — L1 — R2,它的边依次是“未匹配、匹配、未匹配”,两端都是未匹配点。把这条路上匹配与未匹配全部翻转,得到 {L2–R1, L1–R2},匹配数从 1 变成 2,一个也没丢。

这就是 增广路:起点和终点都是未匹配点、边一未匹配一匹配交替的路。翻转一条长度为 2k+1 的增广路,匹配数恰好 +1。Berge 定理说:匹配是最大的 ⟺ 不存在增广路。所以只要对每个左侧点都尝试找一次增广路,找不到就说明它配不上,全部试完就是最大匹配。

增广 dfs 的骨架
对每个左侧点 u:
    vis[1..m] = false                    // 本轮尝试重新开始
    dfs(u):
        for u 的每个邻点 v:
            if vis[v]: continue          // 本轮试过了,跳过(否则绕圈)
            vis[v] = true
            if match[v] == 0             // v 空着:直接配
               或 dfs(match[v])          // v 有主人:让主人另找出路
                match[v] = u; return true
        return false                     // 所有出路都被堵死

复杂度:每个左侧点做一次 dfs,单次最多把所有边扫一遍,总 O(n·E)。n = 500、E = 5×104 时是 2.5×107 次,实测远不到 0.1 秒。

新函数拆解 · match 数组
int match[NR]; // match[v] = 右侧点 v 配给了谁,0 表示没人
组成含义 / 格式要求
下标含义match 用右侧点编号当下标。因为匈牙利算法的核心是“右侧点被谁占着”,左侧点只是发起方,不需要单独记录。
值 0 的含义0 号点保留作“空”。点的编号从 1 开始,于是 match[v] == 0 就能直接当判空用,省掉一次“是否已匹配”的额外数组。
初值全局数组默认全 0,天然就是“全部未匹配”,不用手动清空。但多测题目里必须 memset(match, 0, sizeof match),否则上一组数据的匹配会带进下一组。
不对称左侧点没有对应的数组。要输出方案(例如问“哪两个人一组”)时,遍历 v 输出 match[v] v 即可,反过来查“左侧 u 配给谁”才需要再造一个数组。
何时更新只在 dfs 成功的那一层写 match[v] = u。递归返回时每一层都会写一次,整条增广路上的配对关系因此被一次性翻转。
大小按右侧点数开,与左侧点数无关。把左右点数搞混是最常见的数组越界来源。
最小可运行示例
示例
int match[505];                        // 右侧最多 500 个点

// 匹配成功后:
//   match[R1] = L2   R1 现在归 L2
//   match[R2] = L1   R2 现在归 L1
// 未匹配的右侧点:match[R3] == 0

// 输出方案(左侧 u 与右侧 v 配对,v 从 1 到 m)
for (int v = 1; v <= m; ++v)
    if (match[v]) printf("%d %d\n", match[v], v);
注意

match[v] 存的是“左侧点编号”,不是布尔值。写成 bool match[] 就无法还原配对关系。

每次递归只在成功时写 match[v] = u,写完立刻 return true。所以失败的 dfs 不会留下任何改动——不需要回滚,代码里也没有回滚的语句。

新函数拆解 · dfs 增广(匈牙利算法的核心)
bool dfs(int u); // u 是左侧点,返回能否为 u 找到增广路
组成含义 / 格式要求
参数 u当前发起请求的左侧点。它可能是一个还没配上的新点,也可能是被“要地”的原配。
for 每个邻点 v按邻接表遍历 u 的所有候选右侧点。顺序不影响正确性,只影响找到的是哪一组最大匹配。
if (vis[v]) continue;本轮已经试过 v 就跳过。没有这一行,两个左侧点互相让位时会无限递归(RE:stack overflow)。
vis[v] = true;标记要放在判断之前。无论这次成功还是失败,v 在本轮都不必再试。
match[v] == 0v 空着,增广路在这里终结,直接配上。
dfs(match[v])v 被 match[v] 占着,递归去问它“你能不能让出 v”。这句是整个算法的灵魂:不是抢,而是先安排对方。
match[v] = u; return true;对方让位成功(或 v 本来就空),u 接手 v,返回 true。
return false;所有邻点都试完仍失败。此时以 u 为起点没有增广路,调用方换下一个 v。
为什么 vis 只在右侧每次进入 dfs(u) 都只可能由“u 当前配对的右侧点”触发,而 u 只配了一个右点,所以左侧点不可能重复进入。右侧点却可能被多个左侧点争抢,必须挡。
最小可运行示例
示例
bool dfs(int u) {
    for (int e = head[u]; e; e = nxt[e]) {     // 遍历 u 的所有候选
        int v = to[e];
        if (vis[v]) continue;                  // 本轮试过了
        vis[v] = true;
        if (match[v] == 0 || dfs(match[v])) {  // v 空着,或原配能让位
            match[v] = u;                      // 翻转这条路上的最后一条边
            return true;
        }
    }
    return false;
}
注意

|| 是短路求值:match[v] == 0 成立时不会进递归,所以空点那一支是 O(1)。

把 || dfs(match[v]) 删掉,算法立刻退化成贪心,答案偏小(本节样例会输出 2 而不是 3)。

完整代码
#include <bits/stdc++.h>
using namespace std;

const int NL = 505;                     // 左侧点数上限
const int NR = 505;                     // 右侧点数上限
const int ME = 100005;                  // 边数上限(题给 5×10^4,留一倍余量)

int head[NL], nxt[ME], to[ME], ecnt;
int match[NR];                          // match[v]:右侧点 v 配给的左侧点,0 表示空
bool vis[NR];                           // 本轮 dfs 中右侧点是否试过

void addEdge(int u, int v) {
    to[++ecnt] = v; nxt[ecnt] = head[u]; head[u] = ecnt;
}

bool dfs(int u) {
    for (int e = head[u]; e; e = nxt[e]) {
        int v = to[e];
        if (vis[v]) continue;                    // 本轮试过,跳过(防绕圈)
        vis[v] = true;                           // 先标记,成功失败都不再试
        if (match[v] == 0 || dfs(match[v])) {    // v 空着,或原配能另找出路
            match[v] = u;                        // 接手 v
            return true;
        }
    }
    return false;                                // 所有候选都被堵死
}

int main() {
    int n, m, e;
    scanf("%d %d %d", &n, &m, &e);
    for (int i = 1; i <= e; ++i) {
        int u, v; scanf("%d %d", &u, &v);
        addEdge(u, v);                           // 只存左侧 -> 右侧的单向边
    }

    int ans = 0;
    for (int u = 1; u <= n; ++u) {               // 每个左侧点各发起一次尝试
        memset(vis, 0, sizeof vis);              // ★ 新一轮必须重新清空
        if (dfs(u)) ++ans;                       // 找到增广路,匹配数恰好 +1
    }
    printf("%d\n", ans);
    return 0;
}
逐行拆解
代码片段含义与理由
int head[NL], nxt[ME], to[ME];三个数组按各自的上限开:head 按左侧点数,nxt/to 按边数。边只存单向,所以 nxt/to 开 e 就够,不用 <<1。
if (vis[v]) continue;★ 本轮已试过 v。缺这一行的症状是 RE(栈溢出)或 TLE,因为两个左侧点会互相“让位”给对方,永不终止。
vis[v] = true;先标记再看能不能配。放在 if 里面会漏标,同一个 v 被反复尝试。
match[v] == 0右侧点 v 还没有主人,u 直接接上——增广路长度为 1,匹配数 +1。
|| dfs(match[v])短路:上一句为真就不进来。这句让 v 的原配去别处找位置,是“贪心”与“增广”的唯一区别。
match[v] = u;接手。递归逐层返回时,这条交替路上的每条匹配边都被改写成新的配对,整体相当于一次翻转。
return false;以 u 为起点没有增广路。按 Berge 定理,这不影响全局最优,跳过即可。
memset(vis, 0, sizeof vis);★ 每个左侧点前清空。放在外层循环之外会漏掉大量增广路(答案偏小);放进 dfs 内部会每次递归都清空(指数级变慢)。
if (dfs(u)) ++ans;每成功一次匹配数恰好 +1,所以 ans 就是最终匹配数,不用最后再统计。
addEdge(u, v) 只加一次匈牙利只需要从左侧出发找路,反向边用 match 数组代替。加双向边会让 dfs 反复横跳,复杂度爆掉。

匹配算完之后,二分图上还有两个量和它死死绑定。它们不需要额外算法,只要知道定理,就是一次加减法。

量公式直觉
最大匹配 M匈牙利算法求出最多能配成多少对
最小点覆盖= M用最少的点,盖住所有边
最大独立集= 总点数 − M选最多的点,使任意两点之间没有边
最大团(二分图内)= 2(除非图没有边)二分图里堆内无边,团最多一个左点加一个右点
König 定理:最小点覆盖 = 最大匹配

点覆盖是“选一批点,使每条边至少有一个端点在选中的集合里”;最小点覆盖是其中点数最少的那个。一般图求最小点覆盖是 NP 难的,但二分图上它恰好等于最大匹配,这就是 König 定理。

构造方法(顺带说明为什么相等):从所有未匹配的左侧点出发做交替 BFS,只走“未匹配边向左→右、匹配边向右→左”,把能到达的点全标记。取“标记过的右点”加“未标记的左点”,就是一个大小等于匹配数的点覆盖。

独立集和点覆盖是互补的:删掉一个点覆盖,剩下的点两两之间不可能有边,否则那条边没被盖住。所以最大独立集 = 总点数 − 最小点覆盖 = n + m − 最大匹配。

做题时的用法:题目问“至少删几个点能让所有边消失” → 最小点覆盖 → 答案就是最大匹配;问“最多能选几个点使它们互不相邻” → 最大独立集 → 答案就是 n + m − 最大匹配。两句话都不用写新代码,只要把匹配数拿来算。

这一节的 7 种典型错法(附症状)

WA,答案偏小 1~2。—— memset(vis, 0, sizeof vis) 写在了外层循环外面,只有第一个左侧点享受到了干净的状态。vis 必须每个 u 清一次。

RE(栈溢出)或 TLE 死循环。—— dfs 里少了 if (vis[v]) continue;。输入 2 2 2 / 1 1 / 2 1 就能一秒复现:两个左侧点互相让位,永不返回。

WA,判定全错。—— 染色时用 0 表示“未染色”。第一个连通块被染成 0 之后永远像没访问过,BFS 反复入队(TLE),或者干脆把整片判成冲突。必须用 −1。

WA,只判了一个连通块。—— 外层少了 for (i = 1..n),图不连通时后面的块根本没检查,非二分图被误判成二分图。

RE,段错误。—— vis、match 误按左侧点数开:右侧点比左侧多时立刻越界。分工是 head 按左侧点数开、match 与 vis 按右侧点数开、nxt 与 to 按边数开,n ≠ m 时混用会立刻暴露。

WA,多测全错。—— 多组数据只清了图没清 match,上一组的配对残留进来,表现为第一组对、后面全错。

TLE。—— 建图时双向加边。匈牙利只从左侧出发,反向边会让 dfs 在两侧之间反复横跳,单次 dfs 从 O(E) 变成指数级。

自己动手复现

关掉页面,把 P3386 从空文件敲一遍并提交。

改一改 ①:把 match[v] == 0 || dfs(match[v]) 改成只剩 match[v] == 0,用本节的样例输入,输出从 3 变成 2——这就是纯贪心的极限。

改一改 ②:删掉 if (vis[v]) continue;,输入 2 2 2 / 1 1 / 2 1,观察程序直接崩溃(栈溢出)。恢复这一行,答案立刻变回 2。

改一改 ③:染色代码里把 col 初始化为 0、用 0 表示未染色,喂一个 4 个点 2 条边的二分图,看它是不是卡在 BFS 里出不来。

改一改 ④:在匈牙利代码末尾加一句 printf("%d\n", n + m - ans);,用样例验证最大独立集 = 6 − 3 = 3,再自己构造一个 5 个点 2 条边的图验证一遍。

数学补给 · 奇环、增广路与 König 定理

等价刻画一:能二染色 ⟺ 不含奇环。若图能二染色,任取一个环,颜色必须沿环交替,转一圈回到起点要同色,所以环长必为偶数。反过来,若图无奇环,从任一点出发 BFS,把距离为偶数的点染 0、奇数的染 1:同层的两点之间若有边,它们到根的路径长度奇偶相同,加上这条边就构成奇环,矛盾;相邻两层的点颜色本就不同。所以染色一定能完成。

等价刻画二:二分图的两个点集就是染色的两个颜色类。连通块内一旦确定一个点的颜色,其余所有点的颜色都被唯一确定,所以每个连通块恰好有两种合法染色(整体取反),这就是“取 min(黑点数, 白点数)”只需要比较两次的原因。

Berge 增广路定理:匹配 M 是最大的 ⟺ 不存在 M 增广路。一个方向容易:有增广路就翻转它,|M| 增大,M 不最大。另一个方向:若 M 不最大,取更大的 M′,则对称差 M ⊕ M′ 是一堆交替路和交替环,其中必有一条以 M′ 的边开头和结尾的路,即 M 的增广路。

复杂度账:外层至多 n 次,每次 dfs 里每个右侧点最多被标记一次,标记之后最多再扫一遍它的邻边,所以单次 O(E)。总计 O(nE)。n = 500、E = 5×104:2.5×107 次基本操作,按 108/s 估算约 0.25 秒,实测通常 0.02 秒以内——因为绝大多数 dfs 在第一两个邻点就成功返回了。

König 定理与它的推论:二分图中 最大匹配 = 最小点覆盖(记作 ν = τ)。又因为“点覆盖的补集是独立集”且两者一一对应,所以 最大独立集 = |V| − 最小点覆盖 = |V| − ν。这三个量在二分图上全部可以多项式时间求出,而在一般图上最小点覆盖是 NP 难的——二分图这个限制值钱的地方就在这里。

上界感受:匹配数永远不会超过 min(n, m),因为每个匹配边要占用一个左点和一个右点。所以样例里 3 个点最多配 3 对,答案是 3 时已经达到理论最大值,可以直接判定为最优,不必再验证。

举一反三 · 想清楚这些再往下走
  1. 棋盘上相邻的两个格子必然异色,所以“用 1×2 骨牌铺满棋盘”“骑士互相攻击”这类题都能建成二分图。请举一个你见过的题,说清左部、右部各自是什么,边代表什么。
  2. 如果图不是二分图(比如一个三角形),最大匹配还能用匈牙利算吗?它会错在哪里?这类问题叫什么名字?
  3. 怎样从 match 数组出发,真的把最小点覆盖的那批点构造出来?请描述交替 BFS 的起点、能走的边、最后的取法。
  4. 如果题目同时要求“每条边至少选一个端点”和“同一条边不能两个端点都选”,那么合法的选法实际上被限制成了什么形状?它和最小点覆盖、最大独立集分别差在哪?
  5. 匈牙利算法是 O(VE)。n 到 105 时该怎么办?Hopcroft–Karp 与 Dinic 分别快在哪一步?
  6. 二分图判定能不能用来检测“一个图里有没有奇环”?如果只想知道某条边是否属于某个奇环,该怎么改?
  7. 如果把“一人最多一个任务”改成“一人最多 k 个任务”,图还是二分图,但匹配的定义变了。这时该用什么算法?
自测:能全部回答再进下一节
  • 能说出二分图的三个等价刻画,并各举一个用途(建图 / 写代码 / 出反例)。
  • 能默写染色判定,并解释为什么 col 必须初始化为 −1、为什么外层要遍历所有点。
  • 能默写匈牙利 dfs,说清 vis 的作用域是“每个左侧点一次”,以及它为什么只开在右侧。
  • 能用自己的话讲清“让原配另找出路”为什么能让匹配数 +1。
  • 能手算本节样例的贪心结果(2)和最优结果(3),并写出那条增广路。
  • 能说出 König 定理,并用最大匹配算出最小点覆盖与最大独立集。
  • 能估计 n = 500、e = 5×104 时匈牙利算法的运算量,判断会不会 TLE。
挑战题 · 封锁阳光大学
洛谷 P1330普及/提高-去洛谷做这题 ↗

阳光大学有 n 个路口和 m 条双向道路。曹冲要在一些路口放置河蟹来封锁所有道路,规则有两条:每条道路的两个端点至少有一个放了河蟹;同一条道路的两个端点不能都放河蟹(两只河蟹会打架)。

求最少需要多少只河蟹。如果无论如何都无法满足,输出 Impossible。

输入:第一行两个整数 n、m(1 ≤ n ≤ 104,1 ≤ m ≤ 105)。接下来 m 行,每行两个整数 u、v,表示路口 u 与 v 之间有一条道路(可能有重边)。

输出:一行。最少河蟹数,或 Impossible。

引导性提示(先只看这里)

把“放”记作 1、“不放”记作 0,那么两条规则合起来,对每条边 (u, v) 意味着什么?(“至少一个 1”加“不能都是 1”等于恰好几个 1?)

于是每条边两端的标记必须不同——这个条件你有现成的算法可以检查。

连通块之间没有边,可以分开算再相加。对每个能二染色的连通块,选黑点还是选白点更省?

想看思路了?点开完整思路

对每条边 (u, v),规则要求标记恰好一个是 1、一个是 0。这就是“两端标记不同”,也就是二染色的要求。所以:图必须是二分图,否则输出 Impossible。

对每个连通块做 BFS 染色。连通块内只要起点的颜色定了,其余全被唯一确定,所以合法方案只有两种:选全部黑点,或选全部白点。

设这个连通块黑点 b 个、白点 w 个。选黑点意味着放 b 只河蟹,选白点放 w 只,取 min(b, w) 即可。为什么两种都合法?因为所有边都跨黑白色,每条边恰好被选中一个端点。

各连通块互不影响,答案相加。孤立点没有边要盖,min(1, 0) = 0,自动贡献 0,不用特判。

复杂度 O(n + m)。染色时顺便统计两种颜色的点数,一次 BFS 同时完成判定与计数。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int N = 10005;
const int M = 100005;

int head[N], nxt[M << 1], to[M << 1], ecnt;
int col[N];                             // -1 未染色,0 / 1 两种颜色
int cnt[2];                             // 当前连通块里两种颜色的点数

void addEdge(int u, int v) {
    to[++ecnt] = v; nxt[ecnt] = head[u]; head[u] = ecnt;
}

// 对 s 所在连通块染色,同时统计两种颜色各多少个;出现奇环返回 false
bool paint(int s) {
    queue<int> q;
    q.push(s);
    col[s] = 0;
    cnt[0] = cnt[1] = 0;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        ++cnt[col[u]];                       // 出队时统计,每个点只算一次
        for (int e = head[u]; e; e = nxt[e]) {
            int v = to[e];
            if (col[v] == -1) { col[v] = col[u] ^ 1; q.push(v); }
            else if (col[v] == col[u]) return false;   // 同色相邻 → 奇环
        }
    }
    return true;
}

int main() {
    int n, m;
    scanf("%d %d", &n, &m);
    for (int i = 1; i <= m; ++i) {
        int u, v; scanf("%d %d", &u, &v);
        addEdge(u, v); addEdge(v, u);         // 双向道路,存两份
    }

    memset(col, -1, sizeof col);              // ★ 未染色必须是 -1
    int ans = 0;
    for (int i = 1; i <= n; ++i) {
        if (col[i] != -1) continue;            // 已在别的连通块里染过
        if (!paint(i)) { printf("Impossible\n"); return 0; }
        ans += min(cnt[0], cnt[1]);            // 两种染色方案,取更省的那个
    }
    printf("%d\n", ans);
    return 0;
}
关键片段拆解
片段作用
int cnt[2];只开两个桶。颜色的取值恰好是 0/1,直接用 cnt[col[u]] 当下标,省掉 if-else 两分支。
cnt[0] = cnt[1] = 0;★ 每个连通块开始前清零。忘了清就把上一块的点数累加进来,答案偏大。
++cnt[col[u]];出队时统计。每个点在入队时就已染好色、且只会入队一次,所以出队计数也是恰好一次;写成入队时统计同样正确,放在出队处只是更不容易漏。
col[v] = col[u] ^ 1;邻居染相反色。这条边一旦确定,v 的颜色就没有第二种可能。
else if (col[v] == col[u]) return false;同色相邻只能来自奇环。此时两条规则无法同时满足,直接输出 Impossible 并退出。
ans += min(cnt[0], cnt[1]);选黑点还是选白点:两者都满足“每条边恰好一个端点被选”,所以取点数少的那个。
if (col[i] != -1) continue;图不连通。不跳过的话会把同一个连通块重复算两次,答案翻倍。
memset(col, -1, sizeof col);全局数组默认 0,与颜色 0 冲突。不清成 −1,从 1 号点开始的 BFS 会把自己判成已染色。
这题最容易错的地方
  • 输出写成 impossible 或 IMPOSSIBLE:字符串严格比对,大小写不同直接 WA。
  • 选颜色时用 max 或一律累加 cnt[0]:两种方案都能满足规则,但只有取小的那个是最优。
  • 只从一个点开始染色、或染色成功就中途 return:其余连通块漏掉,答案偏小甚至误判成有解。
  • cnt 忘了按连通块清零:第二个块的点数从第一个块的数字上继续加,答案偏大。
延伸阅读 / 参考
  • 《算法竞赛入门笔记》二分图与匹配章节
  • 《算法竞赛进阶指南》0x68 二分图匹配:匈牙利算法与 König 定理
  • 《算法竞赛试炼场:洛谷300题精析》P3386、P1330 两题的精析
  • 《算法竞赛黄金法则:提高算法和思考力的77项技巧》第 52 条:先找结构,再找算法
55 回顾九:数据结构与字符串提高

55 · 回顾九:数据结构与字符串

把第 46~54 节压成一张对照表:树状数组、线段树、单调栈/队列、堆、Trie、KMP、ST 表、二分图各管哪类问题。先用 P1908 逆序对把「离散化 + 树状数组」完整写一遍并换方向再数一次,再配 6 张模板速查卡(线段树、单调队列、Trie、KMP、ST 表、匈牙利),最后用 P1440、P1168、P2580、P3375、P3386 五道黄题限时训练,每题带完整思路与参考代码。
回顾训练提高建议 100 分钟回顾5 题

第 46~54 节一共给了 8 类工具,看着互不相干,其实都在做同一个替换:把「每次重扫一遍」换成「只碰 O(log n) 个位置或 O(1) 个位置」。树状数组把前缀和切成 log n 段;线段树把区间摊成二叉树,用懒标记把整段操作压成一个数;单调队列把「永远不会再成为答案」的元素直接扔掉;Trie 把公共前缀合成一条链;KMP 把「已经匹配上的那一段」记成 nxt 不重算;ST 表用倍增把区间最值压成两次查表;匈牙利把「请你换一个」写成一次 dfs。

判断标准只有一句:如果暴力里有一处「把这一段重新扫一遍」,就有一个 O(log n) 或 O(1) 的结构在等你。
节工具看到这些字就上它一次操作
46树状数组「单点改 + 前缀和」「逆序对」「值域上数个数」O(log n)
47线段树 + 懒标记「区间加 / 区间赋值」配「区间求和 / 区间最值」O(log n)
48单调栈 / 单调队列「左边第一个更大的」「窗口最值,且窗口只往右滑」O(n)
49堆 / 对顶堆「每次取最小」「动态中位数」「k 路归并取前 k 小」O(log n)
51字符串哈希 / Trie「两个子串是否相等」「前缀查询」「单词出现过没有」O(1) / O(L)
52KMP「模式串在主串里的出现位置」「最长 border」O(n + m)
53倍增 / ST 表「静态区间最值」「第 2^k 级祖先」「LCA」O(1) / O(log n)
54二分图 / 匈牙利「两两配对」「冲突分两组」「最大匹配」O(VE)

第 50 节是模拟赛,P1886 滑动窗口与 P1908 逆序对已经在里面考过一次。这一节换 P1440 和 P1168 练单调队列与堆,另外三道覆盖 Trie、KMP、二分图。先把 P1908 完整写一遍——它同时用上离散化和树状数组,是 46 节所有套路的汇合点。

例题 · 逆序对洛谷 P1908普及/提高-去洛谷做这题 ↗

给定一段正整数序列 a,若 i < j 且 a[i] > a[j],则称 (i, j) 是一个逆序对。求逆序对总数。

输入格式:第一行一个整数 n(1 ≤ n ≤ 5×10⁵);第二行 n 个整数 a[i](1 ≤ a[i] ≤ 10⁹)。

输出格式:一行一个整数,表示逆序对总数。

输入: 6 5 4 2 6 3 1 输出: 11

题意换个说法:每一对逆序对都唯一对应一个「右边比左边小」的关系。固定左边的数 a[i],它贡献的个数就是「i 右边严格小于 a[i] 的数的个数」。于是题目变成 n 次提问:在值域上问「比 x 小的数已经出现了几个」。值域开桶、单点加、前缀查询——三句话正好是树状数组。

先把三块骨头拆开:lowbit 决定管辖长度,add / query 决定怎么跳下标,离散化 把 10⁹ 的值域压到 1..m(m ≤ n)。

新函数拆解 · lowbit(取最低位的 1)
int lowbit(int x) { return x & -x; }
组成含义 / 格式要求
x & -x取 x 二进制里最低位的那个 1,连同它后面的 0,返回一个 2 的幂。
为什么成立补码下 −x = ~x + 1。取反把最低位的 1 变 0、后面的 0 全变 1,再加 1 全部进位回去,高位互相抵消,只剩那一位。
算几个lowbit(6) = 2(110 → 10);lowbit(8) = 8(1000 → 1000);lowbit(7) = 1。
在树状数组里t[x] 管的区间长度恰好是 lowbit(x),管辖范围是 (x − lowbit(x), x]。
x = 0 时lowbit(0) = 0。下标一旦从 0 开始,add 里 p += 0 永不前进 → 死循环 TLE。
最小可运行示例
示例
for (int x = 1; x <= 8; ++x)
    printf("%d ", x & -x);        // 1 2 1 4 1 2 1 8
注意

对 int 与 long long 都成立,因为补码规则一样。

树状数组的下标恒从 1 开始;题目给 0-based 就整体 +1。

新函数拆解 · 树状数组(单点加 + 前缀和)
void add(int p, int v); int query(int p);
组成含义 / 格式要求
add(p, v)a[p] += v。沿 p → p + lowbit(p) 一路上跳,改掉每个管到 p 的 t,最多 log n 步。
query(p)返回 a[1..p] 的和。沿 p → p − lowbit(p) 一路下跳,把覆盖到的 t 累加。
区间和sum(l, r) = query(r) − query(l − 1)。树状数组只会求前缀,区间一律靠相减。
循环上界add 写 p <= n;值域压缩过时写 p <= m(m 为不同值个数),写 n 也不会错,只是多跑空位。
t 不是原数组t 是「若干段区间和」的堆叠,读 t[i] 得不到 a[i]。
复杂度n = 5×10⁵ 时 log₂n ≈ 19,一次操作最多 19 次加法。
最小可运行示例
示例
int t[N], n;
void add(int p, int v) { for (; p <= n; p += p & -p) t[p] += v; }
int query(int p) { int s = 0; for (; p > 0; p -= p & -p) s += t[p]; return s; }

add(3, 5); add(5, -2);
printf("%d\n", query(5) - query(2));    // a[3..5] 的和 = 3
注意

多组数据在每组开头 memset(t, 0, sizeof t),否则第二组开始全错。

树状数组不能求区间最值:query 靠相减,max 没有逆运算。要最值就换线段树或 ST 表。

新函数拆解 · 离散化:sort + unique + lower_bound
sort(b + 1, b + n + 1); m = unique(b + 1, b + n + 1) - b - 1; rk[i] = lower_bound(b + 1, b + m + 1, a[i]) - b;
组成含义 / 格式要求
sort先排序。不排序就 unique,去重无效、名次整体错位 → WA。
unique(first, last)把相邻重复元素搬到后面,返回到「新末尾」的迭代器。它不删元素、不改容量,只是搬移。
− b − 1迭代器减首地址得到下标;因为从 b[1] 开始存,再减 1 才是不同值的个数 m。
lower_bound(...) − b在去重后的有序数组里二分,得到 a[i] 的名次 1..m。
保序性a[i] < a[j] ⇔ rk[i] < rk[j],a[i] == a[j] ⇔ rk[i] == rk[j],所以「比大小」可以整体换成名次比较。
代价O(n log n)。n = 5×10⁵ 时约 10⁷ 次比较,和后面的树状数组同阶。
最小可运行示例
示例
int a[6] = {0, 30, 10, 30, 20, 10};
int b[6]; memcpy(b, a, sizeof a);
sort(b + 1, b + 6);                      // 10 10 20 30 30
int m = unique(b + 1, b + 6) - b - 1;    // m = 3
for (int i = 1; i <= 5; ++i)
    printf("%d ", lower_bound(b + 1, b + m + 1, a[i]) - b);   // 3 1 3 2 1
注意

m 写成 n 也能过,只是树状数组多开一些用不到的位置。

原数组 a 必须留着:扫的时候按原顺序,按 b 的顺序扫就把逆序对关系弄没了。

例题怎么想:把「数对」拆成「单点查询」

朴素做法枚举所有 i < j 比较,n = 5×10⁵ 时是 1.25×10¹¹ 次比较。C++ 每秒约 10⁸ 次,要跑 20 分钟 → TLE。

换个数法:不枚举数对,只枚举右边的数 a[i],数它右边有几个比它小。所有位置的答案加起来,每对逆序对恰好被算一次。

「右边有多少个比 x 小」是一个值域上的前缀计数:开桶 c[v],出现一次就 c[v]++,查询 Σv < x c[v]。单点加 + 前缀和 = 树状数组,两种操作都是 O(log n)。

值域到 10⁹,桶开不下。但题目只关心「谁比谁大」,不关心具体值:把每个数换成它在去重后的名次,严格小、相等这些关系全部保留,值域就压到 1..m ≤ n。

从右往左扫 i = n..1:此刻桶里装的全是 a[i] 右边的数,query(rk[i] − 1) 数的是名次 ≤ rk[i]−1,也就是值严格小于 a[i] 的个数,直接累加。

先查后加:query 必须在 add 之前,否则会把自己算进去,答案恰好大 n。

对称写法是从左往右 ans += (i − 1) − query(rk[i])。两种写法答案一样,因为每一对 (i, j) 只在靠右的那个数处被统计一次;两者只差一个 i−1,记住一种即可。

完整代码:离散化 + 树状数组
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 500005;
int a[MAXN], b[MAXN], rk[MAXN], t[MAXN];
int n, m;                       // m:不同值的个数,也是树状数组的值域上界
long long ans;                  // 最多 1.25×10^11,必须 long long

int lowbit(int x) { return x & -x; }

void add(int p) {               // 在名次 p 上记一次出现
    for (; p <= m; p += lowbit(p)) ++t[p];
}
int query(int p) {              // 名次 1..p 上一共出现过多少个数
    int s = 0;
    for (; p > 0; p -= lowbit(p)) s += t[p];
    return s;
}

int main() {
    scanf("%d", &n);
    for (int i = 1; i <= n; ++i) { scanf("%d", &a[i]); b[i] = a[i]; }

    sort(b + 1, b + n + 1);                                // 离散化第一步:排序
    m = unique(b + 1, b + n + 1) - b - 1;                  // 第二步:去重,得到值域 1..m
    for (int i = 1; i <= n; ++i)
        rk[i] = lower_bound(b + 1, b + m + 1, a[i]) - b;   // 第三步:查名次

    for (int i = n; i >= 1; --i) {
        ans += query(rk[i] - 1);   // 桶里全是 i 右边的数,这里数出严格小于 a[i] 的
        add(rk[i]);                // 再把 a[i] 放进桶,留给更左边的人用
    }
    printf("%lld\n", ans);
    return 0;
}
逐行拆解
代码片段含义与理由
const int MAXN = 500005;n 的上限是 5×10⁵,多开 5 个防止 off-by-one。
long long ans;5×10⁵ 个数全逆序时对数 = n(n−1)/2 ≈ 1.25×10¹¹,int 装不下,溢出后是负数。
b[i] = a[i];复制一份用于排序,原数组一动手,逆序对关系就没了。
m = unique(b + 1, b + n + 1) - b - 1;去重后的元素个数。unique 只搬移不删除,因此必须先 sort;返回值减首地址再减 1,因为下标从 1 起。
lower_bound(b + 1, b + m + 1, a[i]) - b在去重数组里二分 a[i],得到名次 1..m。相等的元素拿到同一个名次,这正是我们要的。
for (int i = n; i >= 1; --i)倒着扫。扫描方向决定了桶里装的是「左边」还是「右边」,两种都对,混着写就错。
ans += query(rk[i] - 1);rk[i] − 1 把「相等」排除在外,数出的是严格小于 a[i] 的个数,正好是逆序对的定义。
add(rk[i]);先查后加。顺序反了会把自己算进去,答案固定多 n。
printf("%lld\n", ans);long long 必须配 %lld,用 %d 会输出高 32 位的垃圾。

逆序对还有第二种 O(n log n) 数法:归并排序。合并两段有序数组时,右段的 a[j] 每被取出一次,左段剩下的 mid − i + 1 个数都比它大,直接累加。常数比树状数组小一半,代价是会打乱原数组,也回答不了「每个位置右边有多少个更小的」这类附加问题。下一节讲分治时会再用到这段。

第二种数法:归并排序(片段,需自己补 MAXN、a、tmp)
int tmp[MAXN];
long long merge_sort(int l, int r) {      // 返回 [l, r] 内的逆序对数,顺手把 a[l..r] 排好
    if (l >= r) return 0;
    int mid = (l + r) >> 1;
    long long res = merge_sort(l, mid) + merge_sort(mid + 1, r);
    int i = l, j = mid + 1, k = l;
    while (i <= mid && j <= r) {
        if (a[i] <= a[j]) tmp[k++] = a[i++];              // 相等时先取左边,保证相等不算逆序
        else { res += mid - i + 1; tmp[k++] = a[j++]; }   // a[i..mid] 这 mid-i+1 个全都 > a[j]
    }
    while (i <= mid) tmp[k++] = a[i++];
    while (j <= r)   tmp[k++] = a[j++];
    for (int p = l; p <= r; ++p) a[p] = tmp[p];
    return res;
}

46 节的题到这里收口。下面 6 张卡片分别对应 47、48、51、52、53、54,只留最容易写错的那几行;49 的堆与对顶堆不单独给卡片,第 2 题就是它的完整代码。

新函数拆解 · 线段树:apply 与 pushdown
void apply(int p, int l, int r, long long v); void pushdown(int p, int l, int r);
组成含义 / 格式要求
tr[p] 管 [l, r]每个节点管一段。sum 是这段之和,tag 是「整段要加、但还没分给孩子」的懒标记。
数组开 4n递归划分出的节点编号最大接近 4n。开 2n 一定越界,症状是 RE 或「样例过、大测试点错」。
apply(p, l, r, v)sum[p] += v × (r − l + 1); tag[p] += v; 加的是整段,长度必须乘进去。
pushdown把 tag 分给两个孩子并清零。任何「要进孩子」的 update / query 都必须先调它。
不清零的后果标记被反复下传,同一段被加多次 → WA,答案偏大,区间越大偏得越多。
long long10⁵ 次区间加、每次 +10⁹,和可达 10¹⁴,sum 与 tag 都必须是 long long。
最小可运行示例
示例
long long sum[N << 2], tag[N << 2];

void apply(int p, int l, int r, long long v) {
    sum[p] += v * (r - l + 1);     // 整段加 v,和要乘长度
    tag[p] += v;                   // 记账,孩子以后补
}
void pushdown(int p, int l, int r) {
    if (!tag[p]) return;           // 没账可还
    int mid = (l + r) >> 1;
    apply(p << 1, l, mid, tag[p]);
    apply(p << 1 | 1, mid + 1, r, tag[p]);
    tag[p] = 0;                    // 还完必须清零,否则下次又发一遍
}
注意

query 里也要 pushdown:孩子的 sum 可能是旧值,不推标记直接读会少算。

区间赋值(不是加)时,tag 要记「赋值成几」,并用一个特殊值表示「没有标记」。

新函数拆解 · 单调队列(滑动窗口最值)
deque<int> q; // 存下标,不存值
组成含义 / 格式要求
存下标只有下标才能判断「有没有滑出窗口」。存值的话过期信息就丢了。
保单调求最小值时队列里的值单调递增:新元素 a[i] 进队前,把队尾所有 ≥ a[i] 的弹掉。
为什么能弹下标更大、值又不更大,在它过期之前永远优于被弹掉的元素;被弹掉的再也回不来。
队头过期窗口左端是 i − k + 1,队头下标 < 左端就 pop_front。
取答案最小值 = a[q.front()],O(1)。求最大值把两个比较方向反过来。
复杂度每个下标进队一次、出队一次,总 O(n);n = 2×10⁶ 也只有几十毫秒。
最小可运行示例
示例
deque<int> q;
for (int i = 1; i <= n; ++i) {
    while (!q.empty() && a[q.back()] >= a[i]) q.pop_back();  // 队尾没有存在价值了
    q.push_back(i);
    while (q.front() <= i - k) q.pop_front();                // 滑出窗口
    if (i >= k) printf("%d ", a[q.front()]);
}
注意

用 multiset 是 O(n log n),常数大 3~5 倍;每个窗口重扫一遍是 O(nk),n = 10⁶ 必 TLE。

队列里元素个数 ≤ k,手写数组队列比 deque 快一倍,2×10⁶ 的数据建议手写。

新函数拆解 · Trie(字典树)
int ch[MAXNODE][26], tot; int vis[MAXNODE]; // 0 非单词 / 1 未点过 / 2 已点过
组成含义 / 格式要求
根0 号节点是根,不代表任何字符。
ch[p][c]节点 p 走字符 c 到达的儿子编号。0 表示没有这条边,所以节点编号从 1 开始,0 专门当「空」。
tot已用节点数,新节点编号 = ++tot。
节点数上界所有串长度之和 + 1。10⁴ 个名字、每个 ≤ 50 → 5×10⁵ 个节点。
空间26 × 节点数 × 4 字节。5×10⁵ 个节点 = 52 MB,128 MB 限制下要算清楚。
结尾标记走到底还不够:必须看这个节点有没有被标成「单词结尾」。
最小可运行示例
示例
int ch[N][26], vis[N], tot;

void insert(const string &s) {
    int p = 0;
    for (char c : s) {
        int x = c - 'a';
        if (!ch[p][x]) ch[p][x] = ++tot;   // 没有就新建
        p = ch[p][x];
    }
    vis[p] = 1;                            // 只在结尾打标记
}
int query(const string &s) {
    int p = 0;
    for (char c : s) {
        int x = c - 'a';
        if (!ch[p][x]) return 0;           // 断链
        p = ch[p][x];
    }
    return vis[p];                         // 只看结尾标记
}
注意

字符集是 52 个字母就开 52,别硬套 26,越界写坏的是别的变量。

把 ch 换成 map 能省空间,但每次多一个 log,10⁵ 次查询会慢 5 倍。

新函数拆解 · KMP 的 nxt 数组
nxt[i] = p[0..i-1] 的最长 border 长度
组成含义 / 格式要求
border既是前缀又是后缀、且不等于自身的子串。
nxt[i]前缀 p[0..i-1] 的最长 border 长度,同时就是「第 i 位失配后 j 该退到哪」。
nxt[1] = 0单字符前缀没有非空真 border。
递推j = nxt[i];若 p[i] == p[j] 则 nxt[i+1] = j + 1;否则 j = nxt[j] 继续退,退到 0 为止。
匹配主串指针 i 只增不减,失配时只动 j。总复杂度 O(n + m)。
别用 findstring::find 最坏 O(nm);n = m = 10⁶ 时是 10¹² 次比较 → TLE。
最小可运行示例
示例
nxt[1] = 0;                              // 下标是「前缀长度」
for (int i = 1, j = 0; i < m; ++i) {     // 循环开始时 j 恒等于 nxt[i]
    while (j && p[i] != p[j]) j = nxt[j];    // 失配就退到更短的 border
    if (p[i] == p[j]) ++j;                   // 接得上就延长一位
    nxt[i + 1] = j;                          // 记的是长度 i+1 的前缀
}
注意

nxt 的下标是「前缀长度」,不是字符位置;写成 nxt[i] = j 会整体错一位。

n − nxt[n] 是最小循环节长度;当 n 能被 n − nxt[n] 整除时,循环次数就是 n / (n − nxt[n])。

新函数拆解 · ST 表(静态区间最值)
int st[LOG][N], lg[N];
组成含义 / 格式要求
lg[i]floor(log2 i)。预处理 lg[i] = lg[i/2] + 1,别用 log2 的浮点结果,误差一位就是错答案。
st[k][i]从 i 开始、长度 2^k 的区间的最值。
建表st[0][i] = a[i];st[k][i] = max(st[k−1][i], st[k−1][i + 2^(k−1)]),k 由小到大。
查询len = r − l + 1,k = lg[len];答案 = max(st[k][l], st[k][r − 2^k + 1]),两段允许重叠。
能用于哪些运算只限「可重复贡献」的运算:max、min、gcd、按位与/或。区间和不行,重叠部分会被算两次。
空间LOG × N 个 int。N = 10⁶、LOG = 20 → 80 MB,注意内存限制。
最小可运行示例
示例
for (int i = 2; i <= n; ++i) lg[i] = lg[i >> 1] + 1;    // lg[i] = floor(log2 i)
for (int i = 1; i <= n; ++i) st[0][i] = a[i];
for (int k = 1; (1 << k) <= n; ++k)
    for (int i = 1; i + (1 << k) - 1 <= n; ++i)
        st[k][i] = max(st[k - 1][i], st[k - 1][i + (1 << (k - 1))]);

int len = r - l + 1, k = lg[len];
int mx = max(st[k][l], st[k][r - (1 << k) + 1]);
注意

表建好后不能改:题目带修改就换线段树。

倍增求 LCA 的内存版本也是 ST 表:把「欧拉序上的深度最小值」当区间最值,思路完全一样。

新函数拆解 · 匈牙利算法(二分图最大匹配)
bool dfs(int u); int match[N], vis[N], stamp;
组成含义 / 格式要求
match[v]右部点 v 的搭档。0 表示空闲。
vis[v]本轮 dfs 是否试过 v。少了它,dfs 会在两个点之间反复走 → 栈溢出 RE。
让位v 被 match[v] 占着,就递归问 match[v] 能不能换一个;能换则 v 归 u。
时间戳vis 存轮次号,每轮 ++stamp,等价于 memset(vis) 但只花 O(1)。
答案每成功一次 dfs,匹配数 +1。最终 ans 就是最大匹配边数。
复杂度O(V·E)。V = 500、E = 5×10⁴ 时 2.5×10⁷,1 秒内可过。
最小可运行示例
示例
bool dfs(int u) {
    for (int v : g[u]) {
        if (vis[v] == stamp) continue;   // 本轮试过,跳过,否则绕圈
        vis[v] = stamp;
        if (!match[v] || dfs(match[v])) {// v 空闲,或 v 的搭档能换走
            match[v] = u;
            return true;
        }
    }
    return false;
}
注意

只从左部往右部连边,右部不需要存边,省一半空间。

n、m 到 10⁵ 时匈牙利会 TLE,要换 Dinic 或 Hopcroft-Karp。

数据结构部分:6 个具体症状

树状数组下标从 0 开始:add(0) 里 p += lowbit(0) = 0,循环永不结束 → TLE,而且一个字符都不输出。

逆序对答案用 int:n = 5×10⁵ 时最多 1.25×10¹¹,溢出成负数 → WA,小数据全对,只有大测试点挂。

线段树数组只开 2n:递归编号能到 4n,越界写坏相邻变量 → RE 或答案随机。本地跑得通、交上去挂,最难查。

pushdown 后忘记 tag[p] = 0:同一段被反复加 → WA,答案偏大,区间越大偏得越多。

单调队列忘判队头过期:输出的是早就滑出窗口的最值 → WA,前 k 个对、后面全错。

对顶堆只在插入后平衡、输出前不再检查:偶数位置的不变式被破坏,下一次 top() 取到错的 → WA。

字符串与二分图:6 个具体症状

Trie 的节点数组按 n 开(10⁴)而实际要 5×10⁵:写 ch 越界 → RE 或输出乱码。

Trie 查询不判结尾标记:把某个名字的前缀当成名字,输出 OK → WA,只在「a 与 ab」这类数据上错。

KMP 的 nxt 只开主串长度:模式串 10⁶ 时越界 → RE。nxt 的长度由模式串决定。

KMP 里 j = nxt[j] 没判 j > 0,退到 0 还继续取 nxt[0](未定义)→ 死循环 / RE。

ST 表拿去求区间和:重叠部分被算两次 → WA,答案偏大,区间越长偏得越多。

匈牙利忘写 vis[v] = stamp:同一轮反复走同一条边 → RE(栈溢出),数据小时表现为卡死。

自己动手复现

关掉页面,把 P1908 从空文件敲一遍并提交,样例必须输出 11。

把 query(rk[i] - 1) 改成 query(rk[i]) 再交:答案会变成「右边 ≤ a[i] 的个数」之和,自己造一组含相等元素的数据,算一算差多少。

再改成从 1 扫到 n 的写法 ans += (i - 1) - query(rk[i]),确认两种写法输出相同,并说清为什么。

改一改:把 a[i] 限制到 [1, 1000],删掉离散化直接开桶(t[a[i]]++),用 5×10⁵ 随机数据和原版比一次运行时间。

自己动手复现

把 P1440 的单调队列敲一遍,用 m = 1 和 m = n 各交一次,确认边界不越界。

把弹队尾的条件从 >= 改成 >,用 2×10⁶ 个全部相同的数测一次,比较队列最大长度和运行时间。

再改成「每个位置向前重扫 m 个」,同样的数据下看它跑多久——这就是 O(nm) 的手感。

赛前 10 分钟默写清单

树状数组:lowbit、add、query 三行,写完用 P3374 交一次。

线段树:apply、pushdown、update、query 四个函数,pushdown 最后一行别忘了清零。

单调队列:弹队尾 → 入队 → 弹队头 → 取答案,四步顺序固定。

Trie:insert 与 query,节点数按「总长度 + 1」估。

KMP:nxt 的六行递推,nxt[1] = 0 不能省。

ST 表:lg 预处理 + 两层循环建表 + 两段重叠查询。

匈牙利:一轮一个 stamp,dfs 里先判 vis 再让位。

下面 5 道按「数据结构 → 字符串 → 图」排列,覆盖 48、49、51、52、54 五节。每题先自己写 20 分钟;卡住先看提示,再给 10 分钟;还不行才展开思路。做完再往下走。

本次训练 · 共 5 题

做题方式

限时 100 分钟。每题先自己写 20~25 分钟;写不出来先看提示,再给自己 10 分钟;还不行才点开思路。每题都独立限时,不要一题耗死。

大题 挑战题 1 · 求 m 区间内的最小值
洛谷 P1440普及/提高-去洛谷做这题 ↗

给出一个含 n 项的数列,对每个元素求出它前面 m 个元素中的最小值。

若前面的数不足 m 个,则从第 1 个数算起;若前面一个数都没有,输出 0。

输入格式:第一行两个整数 n、m(1 ≤ m ≤ n ≤ 2×10⁶);第二行 n 个整数 a[i](0 ≤ a[i] ≤ 10⁹)。

输出格式:n 行,第 i 行是 a[1..i−1] 中最后 min(m, i−1) 个数的最小值。

引导性提示(先只看这里)

处理第 i 个数时,它关心的是一段左端和右端都随 i 增大而增大的区间——这就是滑动窗口。

如果 a[i] 比队尾那个元素还小,队尾在它过期之前都不可能再当最小值。

插入 i 之后,队列里只该留下下标 ≥ i − m + 1 的元素;下标 ≤ i − m 的从队头弹掉。

想看思路了?点开完整思路

处理第 i 个数时,窗口是 [max(1, i−m), i−1]。

队列里存下标,对应的值单调递增,队头就是当前窗口最小值。

三步固定:① 输出队头(队列空则输出 0);② 插入 i,插入前把队尾所有 ≥ a[i] 的弹掉;③ 把下标 ≤ i−m 的队头弹掉。

为什么被弹掉的不会回来:它下标更小、值又不更小,在它过期之前全面劣于新元素,之后也不会重新进入窗口。

复杂度 O(n):每个下标最多进队一次、出队一次。n = 2×10⁶ 时约 4×10⁶ 次操作。

对照:每个位置向前重扫 m 个是 O(nm),m = n 时 4×10¹² → TLE;multiset 是 O(n log n),常数大 3~5 倍。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 2000005;
int a[MAXN], q[MAXN];          // q 存下标:手写队列,比 deque 快一倍

int main() {
    int n, m;
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; ++i) scanf("%d", &a[i]);

    int head = 1, tail = 0;     // 队列区间 [head, tail],head > tail 表示空
    for (int i = 1; i <= n; ++i) {
        if (head > tail) printf("0\n");          // 只有 i = 1 会走到这里
        else printf("%d\n", a[q[head]]);         // 队头就是前 m 个数的最小值

        while (head <= tail && a[q[tail]] >= a[i]) --tail;  // 又大又早的队尾没用了
        q[++tail] = i;                           // 存下标,才能判过期

        while (head <= tail && q[head] <= i - m) ++head;    // 窗口左端是 i-m+1
    }
    return 0;
}
关键片段拆解
片段作用
int head = 1, tail = 0;用 [head, tail] 表示队列,空队列是 head = tail + 1。省掉 deque 的封装,2×10⁶ 次操作能快一倍。
if (head > tail) printf("0\n");只有 i = 1 时队列为空:前面没有数,按题意输出 0。
a[q[head]]队头下标对应的值就是答案,O(1) 取,不需要再维护别的东西。
while (head <= tail && a[q[tail]] >= a[i]) --tail;写 ≥ 而不是 >:值相等的旧元素下标更小、过期更早,留着没用。用 > 会让队列里堆一堆相等的值。
q[++tail] = i;存下标而不是值。存值就不知道这个元素是什么时候进来的,无法判断过期。
while (head <= tail && q[head] <= i - m) ++head;窗口左端是 i−m+1,所以下标 ≤ i−m 的必须走。放在插入之后,保证下一轮队头有效。
这题最容易错的地方
  • 队列里存值不存下标:无法判断队头是否过期,输出会变成整个前缀的最小值 → WA。
  • 输出放在插入之后:第 i 行会把 a[i] 自己也算进去,而题意要的是「前面」m 个数 → WA。
  • 数组只开 10⁶:n = 2×10⁶ 时越界,表现为前面几个数对、后面乱或直接 RE。
  • 用 cin/cout 读 2×10⁶ 个数又没关同步 → TLE;请用 scanf 或加两行提速。
  • 输出写成一行空格分隔:题目要 n 行,每行一个数 → WA。
大题 挑战题 2 · 中位数
洛谷 P1168普及/提高-去洛谷做这题 ↗

给出一个长度为 N 的非负整数序列 A。对每个 1 ≤ k ≤ (N+1)/2,输出 A1, A3, …, A2k−1 的中位数,也就是前 2k−1 个数的中位数。

输入格式:第一行 N(1 ≤ N ≤ 10⁵,N 为奇数);第二行 N 个整数(0 ≤ A[i] ≤ 10⁹)。

输出格式:(N+1)/2 行,每行一个整数。

引导性提示(先只看这里)

中位数只关心「比它小的有多少个」,不需要把整个前缀排序。

把数分成两堆:小的一半放一起,大的一半放一起。中位数就是小堆里最大的那个。

两堆的大小差不能超过 1,每次插入后立刻调整。哪种容器能 O(log n) 取最值?

想看思路了?点开完整思路

用一个大根堆 small 存较小的一半,一个小根堆 large 存较大的一半。

不变式:small.size() = large.size() 或 large.size() + 1,并且 small 里所有元素 ≤ large 里所有元素。

插入 x:x ≤ small.top() 就进 small,否则进 large。

调整:small 比 large 多出 2 个或更多,就把 small 的堆顶(小半部分的最大值)搬给 large;small 比 large 少,就把 large 的堆顶(大半部分的最小值)搬回 small。

长度为 2k−1 时,small 恰好 k 个、large 恰好 k−1 个,中位数 = small.top()。

复杂度 O(n log n):每个数至多进堆一次、跨堆搬一次。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    scanf("%d", &n);
    priority_queue<int> small;                                  // 大根堆:较小的一半
    priority_queue<int, vector<int>, greater<int> > large;      // 小根堆:较大的一半

    for (int i = 1; i <= n; ++i) {
        int x;
        scanf("%d", &x);
        if (small.empty() || x <= small.top()) small.push(x);
        else large.push(x);

        // 插入后立刻恢复不变式,别等到输出前才调
        if ((int)small.size() > (int)large.size() + 1) {
            large.push(small.top()); small.pop();
        } else if ((int)small.size() < (int)large.size()) {
            small.push(large.top()); large.pop();
        }

        if (i & 1) printf("%d\n", small.top());   // 长度 2k-1 时,中位数是 small 的堆顶
    }
    return 0;
}
关键片段拆解
片段作用
priority_queue<int> small;默认大根堆,堆顶是 small 里最大的,正好是「小半部分的最大值」。
priority_queue<int, vector<int>, greater<int> > large;三个参数缺一不可。greater 让堆顶变成最小值,才能 O(1) 拿到大半部分的最小值。
if (small.empty() || x <= small.top())第一个数必须进 small(空堆不能 top)。用 ≤ 而不是 <,让相等的数优先留在 small,堆大小才稳定。
if ((int)small.size() > (int)large.size() + 1)small 最多比 large 多 1 个。多出两个就把 small 的堆顶搬过去。
else if ((int)small.size() < (int)large.size())small 比 large 少就搬回来。两个分支互斥,一轮只可能触发一个。
if (i & 1) printf("%d\n", small.top());只在奇数长度输出。此时 small 有 k 个、large 有 k−1 个,第 k 小就是 small 的堆顶。
这题最容易错的地方
  • 写成 priority_queue<int> 当小根堆:取到的是最大值,整个逻辑反了 → WA。
  • 插入后不调整堆大小:small.top() 不是中位数,输出整体偏大或偏小。
  • 只在输出前调整:偶数位置的不变式被破坏,下一次取 top 直接取错 → WA。
  • 中位数位置搞错:长度 2k−1 时是第 k 小,不是第 k+1 小。
  • 用 vector + 每次 sort:O(n² log n),n = 10⁵ 必 TLE。
大题 挑战题 3 · 于是他错误的点名开始了
洛谷 P2580普及/提高-去洛谷做这题 ↗

老师手上有 n 个学生的名字,接下来要点名 m 次。

被点的名字不在名单里输出 WRONG;在名单里且是第一次被点到输出 OK;在名单里但之前已经点到过输出 REPEAT。

输入格式:第一行 n(n ≤ 10⁴);接下来 n 行每行一个名字(只含小写字母,长度 ≤ 50);然后一行 m(m ≤ 10⁵);接下来 m 行每行一个被点的名字(格式同上)。

输出格式:m 行,每行是 OK、REPEAT 或 WRONG。

引导性提示(先只看这里)

名字只含小写字母,字符集只有 26 个——这正是 Trie 的形状:每个节点 26 个儿子。

「单词结尾」必须单独标记:名字 a 和名字 ab 会共用节点 0→a,不能只看走没走到。

OK 与 REPEAT 的差别只是这个结尾节点被访问过几次,用一个 0/1/2 的状态数组就够。

想看思路了?点开完整思路

建 Trie:0 号是根,边表示字符,ch[p][c] 是节点 p 走字符 c 的儿子。

插入:沿字符往下走,没有儿子就新建,最后在结尾节点上标 1。

查询:沿字符往下走,中途没有儿子 → WRONG(断链);走到底但结尾没标记 → WRONG(它只是别人的前缀);标记为 1 → 改成 2 并输出 OK;标记为 2 → REPEAT。

节点数上界 = 所有名字长度之和 + 1 ≤ 10⁴ × 50 + 1 ≈ 5×10⁵。

空间 26 × 5×10⁵ × 4 字节 ≈ 52 MB,在 128 MB 限制内,但不能再多开一倍。

复杂度 O(总字符数) ≈ 5×10⁵ + 5×10⁶(查询串总长),比 map 的每行一次 log 快得多。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXNODE = 500005;      // 所有名字的总长度 + 1
int ch[MAXNODE][26];             // ch[p][c]:节点 p 走字符 c 的儿子,0 表示没有
int vis[MAXNODE];                // 0 不是单词;1 是单词且没点过;2 点过了
int tot;                         // 已用节点数,新节点编号 = ++tot

void insert(const string &s) {
    int p = 0;                                       // 0 号是根
    for (char c : s) {
        int x = c - 'a';
        if (!ch[p][x]) ch[p][x] = ++tot;             // 没有这条边就新建
        p = ch[p][x];
    }
    vis[p] = 1;                                      // 只在单词结尾打标记
}

int query(const string &s) {
    int p = 0;
    for (char c : s) {
        int x = c - 'a';
        if (!ch[p][x]) return 0;                     // 断链:名单里没有
        p = ch[p][x];
    }
    if (vis[p] == 0) return 0;                       // 走到底却没标记:只是别人的前缀
    if (vis[p] == 1) { vis[p] = 2; return 1; }       // 第一次点到
    return 2;                                        // 之前点过
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;
    for (int i = 0; i < n; ++i) { string s; cin >> s; insert(s); }

    int m;
    cin >> m;
    for (int i = 0; i < m; ++i) {
        string s;
        cin >> s;
        int r = query(s);
        if (r == 0) cout << "WRONG\n";
        else if (r == 1) cout << "OK\n";
        else cout << "REPEAT\n";
    }
    return 0;
}
关键片段拆解
片段作用
ch[MAXNODE][26]字符集 26 个字母,所以第二维是 26。全部初始化为 0,正好表示「没有这条边」。
if (!ch[p][x]) ch[p][x] = ++tot;新节点编号从 1 开始,0 专门留给「空」。如果从 0 开始编号,就无法区分「根」和「不存在」。
vis[p] = 1;只在单词的最后一个节点上标记:中间节点可能是别的单词的前缀,标了就会把前缀当成名字。
if (!ch[p][x]) return 0;查询中途断开,这个串一定不在名单里,直接返回,不用再走。
if (vis[p] == 0) return 0;走到位却没有标记,说明它只是某个名字的前缀(例如名单里只有 ab,来查 a)。
vis[p] = 1 → 2第一次点到就把状态改成 2,下次再来走 REPEAT 分支。三个状态刚好够用。
这题最容易错的地方
  • 节点数组按 n(10⁴)开:实际最长 5×10⁵,写 ch 越界 → RE 或输出乱码。
  • MAXNODE 开到 10⁶:26×10⁶×4 ≈ 104 MB,直接 MLE。按「总长度 + 1」估。
  • 查询只判走到底、不判结尾标记:前缀被当成名字,输出 OK → WA。
  • 用 map 每行 O(L log n):m = 10⁵、L = 50,字符比较量约 10⁸,可能 TLE。
  • 输出写成 Ok、ok、Wrong:大小写不符 → WA。
大题 挑战题 4 · 【模板】KMP字符串匹配
洛谷 P3375普及/提高-去洛谷做这题 ↗

给出两个字符串 s1 与 s2。若 s1 的区间 [l, r] 与 s2 完全相同,则称 s2 在 s1 中出现在位置 l。

求出 s2 在 s1 中所有出现的位置(每行一个,按从小到大)。

再对 s2 的每个前缀 s2[1..i] 求出它的最长 border 长度(border 指既是前缀又是后缀、且不等于自身的子串),最后输出一行 |s2| 个用空格分隔的整数。

输入格式:第一行 s1,第二行 s2(1 ≤ |s1|, |s2| ≤ 10⁶,只含大写字母)。

引导性提示(先只看这里)

暴力匹配失配时把主串指针往回退,退掉的那些字符其实已经比较过了。

记下「模式串前 i 个字符的最长 border」,失配时把 j 退到那里,主串指针一步都不回退。

border 的 border 还是 border,所以可以一路 j = nxt[j] 退下去,不会漏掉更短的可能。

想看思路了?点开完整思路

nxt[i] = p[0..i−1] 的最长 border 长度,nxt[1] = 0。

递推:已知 nxt[i],令 j = nxt[i];若 p[i] == p[j] 则 nxt[i+1] = j + 1,否则 j = nxt[j] 继续退到 0。

匹配:i 扫主串,j 是已匹配长度;s[i] == p[j] 则 ++j,否则 j = nxt[j];j == m 时记录起点。

起点换成 1-based:i 是 0-based 的结尾,起点 = i − m + 1(0-based)= i − m + 2(1-based)。

找到一次后令 j = nxt[m],才能继续找重叠的出现(如 s1 = aaa、s2 = aa 有两个位置)。

复杂度 O(n + m):i 只增不减;j 每次失配至少减 1,总减少量不超过总增加量。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1000005;
int nxt[MAXN];                  // nxt[i]:p[0..i-1] 的最长 border 长度

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    string s, p;
    cin >> s >> p;
    int n = s.size(), m = p.size();

    nxt[1] = 0;                                       // 单字符前缀没有非空真 border
    for (int i = 1, j = 0; i < m; ++i) {              // 循环开始时 j 恒等于 nxt[i]
        while (j && p[i] != p[j]) j = nxt[j];         // 失配就退到更短的 border
        if (p[i] == p[j]) ++j;                        // 接得上就延长一位
        nxt[i + 1] = j;                               // 记的是「长度 i+1 的前缀」
    }

    for (int i = 0, j = 0; i < n; ++i) {
        while (j && s[i] != p[j]) j = nxt[j];         // 主串指针 i 从不后退
        if (s[i] == p[j]) ++j;
        if (j == m) {                                 // 找到一个完整匹配
            printf("%d\n", i - m + 2);                // 换成 1-based 起点
            j = nxt[j];                               // 退一步,继续找重叠的出现
        }
    }

    for (int i = 1; i <= m; ++i) printf("%d ", nxt[i]);
    printf("\n");
    return 0;
}
关键片段拆解
片段作用
nxt[1] = 0;长度为 1 的前缀没有非空真前缀,border 必为 0。nxt 是全局数组时漏写也恰好是 0,改成局部数组就成随机值。
for (int i = 1, j = 0; i < m; ++i)i 是「正在算 nxt[i+1]」的位置,j 在循环开始时恒等于 nxt[i]。这个不变式是整个算法的骨架。
while (j && p[i] != p[j]) j = nxt[j];失配就退到更短的 border 再比。j == 0 时停下,否则会去读 nxt[0](未定义)。
if (p[i] == p[j]) ++j;能接上就延长一位。j 是 0 时比较 p[0] 与 p[i] 也是合法的,不能用 if (j && ...) 包起来。
nxt[i + 1] = j;nxt 的下标是前缀长度而不是字符位置,所以写 i+1。写成 nxt[i] 会整体错一位。
j = nxt[j];匹配成功后必须退,否则下一轮 p[j] 越界;退到 nxt[m] 才能找到重叠的出现。
printf("%d\n", i - m + 2);i 是 0-based 结尾,起点 = i−m+1(0-based)= i−m+2(1-based),少加 1 就整体偏小。
这题最容易错的地方
  • nxt 数组只开主串长度:模式串 10⁶ 时越界 → RE。nxt 的长度由模式串决定。
  • 输出位置用 0-based:全部小 1 → WA。
  • 忘了最后一行 nxt 的输出,或不用空格分隔 → WA / 格式错。
  • 匹配成功后写 j = 0:漏掉重叠出现,s1 = aaa、s2 = aa 会少输出一个位置。
  • 把 j = nxt[j] 写成 --j:最坏退化成 O(nm),n = m = 10⁶ → TLE。
大题 挑战题 5 · 【模板】二分图最大匹配
洛谷 P3386普及/提高-去洛谷做这题 ↗

给定一个二分图:左部 n 个点、右部 m 个点、e 条边。求最大匹配——最多能选出多少条边,使任意两条选中的边没有公共端点。

输入格式:第一行 n、m、e(1 ≤ n, m ≤ 500,1 ≤ e ≤ 5×10⁴);接下来 e 行每行 u、v,表示左部点 u 与右部点 v 之间有一条边。

输出格式:一行一个整数,表示最大匹配数。

引导性提示(先只看这里)

一条边被选中后,它两端的点都不能再用。题目问的是「最多能选几条」。

从左部点一个一个处理:要么直接连一个空闲的右部点,要么请占着它的左部点「换一个」。

「请别人换一个」就是递归。必须记录本轮试过哪些右部点,否则会在两个点之间绕圈。

想看思路了?点开完整思路

依次给每个左部点 u 找匹配,用 dfs 找一条增广路。

dfs(u):枚举 u 的每条边 (u, v)。若 v 本轮没试过,标记它;v 空闲则 match[v] = u 直接成功;否则递归 dfs(match[v]),若能给 match[v] 找到新位置,就让 v 归 u。

成功一次,匹配数 +1。匈牙利算法的正确性来自「不存在增广路 ⇔ 当前匹配已是最大匹配」。

vis 用时间戳:每轮 ++stamp,vis[v] == stamp 表示本轮试过,省掉每组 O(m) 的 memset。

复杂度 O(V·E):500 × 5×10⁴ = 2.5×10⁷,1 秒内可过。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 505;
vector<int> g[MAXN];             // 只存左部 → 右部的边
int match[MAXN];                 // match[v]:右部点 v 的搭档,0 表示空闲
int vis[MAXN], stamp;            // vis[v] == stamp 表示「本轮 dfs 试过 v」

bool dfs(int u) {
    for (int v : g[u]) {
        if (vis[v] == stamp) continue;      // 本轮试过就跳过,否则会绕圈
        vis[v] = stamp;
        if (!match[v] || dfs(match[v])) {   // v 空闲,或 v 的搭档能换走
            match[v] = u;                   // 让位成功,v 归 u
            return true;
        }
    }
    return false;
}

int main() {
    int n, m, e;
    scanf("%d%d%d", &n, &m, &e);
    for (int i = 0; i < e; ++i) {
        int u, v;
        scanf("%d%d", &u, &v);
        g[u].push_back(v);
    }

    int ans = 0;
    for (int u = 1; u <= n; ++u) {
        ++stamp;                            // 等价于 memset(vis),但只花 O(1)
        if (dfs(u)) ++ans;
    }
    printf("%d\n", ans);
    return 0;
}
关键片段拆解
片段作用
vector<int> g[MAXN];只存左部到右部的边。右部的邻接表用不到,省一半空间。
if (vis[v] == stamp) continue;同一轮里每个右部点只试一次。少了这一句,dfs 会在两个点之间来回走 → 栈溢出 RE。
if (!match[v] || dfs(match[v]))v 空闲就直接占;否则问 v 现在的搭档能不能换地方。|| 的短路顺序不能反,先判空再递归。
match[v] = u;让位成功之后才改 match。写在判断之前,失败时会把原来的匹配也弄丢。
++stamp;每个左部点一轮,stamp 自增相当于 memset(vis),但每次只花 O(1)。
if (dfs(u)) ++ans;每找到一条增广路,匹配数加一。ans 就是最大匹配边数。
这题最容易错的地方
  • 忘写 vis[v] = stamp:同一轮反复走同一条边 → 死循环或栈溢出 RE。
  • vis 清得比右部点少(循环写到 n 而不是 m):n < m 时右部点没清,第二轮起匹配出错 → WA。
  • 把 match[v] = u 写在判断成功之前:dfs 失败时匹配已被破坏 → WA。
  • 递归时传 v 而不是 match[v]:在右部点上递归,编号含义全乱 → WA 或 RE。
  • n、m 到 10⁵ 还用匈牙利:O(VE) 到 5×10⁹ → TLE,要换 Hopcroft-Karp 或 Dinic。
做完之后自问
  • 能不看模板写出 lowbit、add、query 三行,并说清 query 为什么是 O(log n)。
  • 能说清离散化三步各自的作用,以及漏掉 sort 会发生什么。
  • 看到「区间加 + 区间求和」能立刻指出 pushdown 要在 update 和 query 两处调用。
  • 能区分何时用单调队列、何时用堆:一个是「固定窗口 + 顺序滑动」,一个是「全局最值」。
  • 能写出 Trie 的 insert 与 query,并说出节点数上界怎么估、空间怎么算。
  • 能默写 KMP 的 nxt 递推,并解释 nxt[i] 的含义与 n − nxt[n] 的意义。
  • 能说出 ST 表为什么能求 max 却不能求区间和。
  • 能写出匈牙利的 dfs 框架,并解释 vis 数组与 stamp 时间戳各自防的是什么。
延伸阅读
  • 《算法竞赛入门笔记》:树状数组、线段树、单调队列、字符串匹配相关章节
  • 《算法竞赛进阶指南》:树状数组、线段树、字符串哈希、KMP 与 Trie 相关章节
  • 《算法竞赛试炼场:洛谷300题精析》:P1908、P1440、P1168、P2580、P3375、P3386 的精析
  • 《算法竞赛黄金法则》:先把暴力里「重扫一遍」的那句标出来,再选结构
  • 想再练一道 Trie,加做 P3879 阅读理解(不计入本次限时)
56 分治与 CDQ 思想提高

56 · 分治与 CDQ 思想

分治的三步骤说清后,先把归并排序改造成逆序对计数器,再把同一副骨架升到三维:排序管 a、归并管 b、树状数组管 c,得到 O(n log n log k) 的 CDQ 分治,顺带看清“每个答案对只在一个合并点被数一次”这条不重不漏的依据。
提高预计 70 分钟分治CDQ偏序
本节出现的新函数 / 新语法(先看懂再用)

上一节(回顾九)末尾给过一段十几行的归并排序求逆序对,说好“下一节再用到”,就是这里。那段代码看着像个小技巧,其实是分治的原型:把区间砍成两半,答案 = 左半 + 右半 + 跨中点的那一部分。前两项递归算,第三项在合并时顺手算完——分治的全部内容就是这个。

分治能成立只有一条理由:每个答案对恰好在一个合并点被数一次。想不清这一点,写出来必然重复计数或者漏算。
步骤归并排序逆序对(本节例题)CDQ 三维偏序
① 划分mid = (l + r) / 2同左先按 a 排序,再按位置切
② 递归solve(l, mid)、solve(mid+1, r)同左同左
③ 合并双指针按值归并取右边时 ans += mid − i + 1按 b 归并 + 树状数组查 c
答案对在哪—i 在左半、j 在右半左半的 (b, c) 都不超过右半
复杂度O(n log n)O(n log n)O(n log n log k)

动手前先回答三个问题,答不出就别写:① 怎么切(两半规模尽量相等);② 递归结束后两半各处于什么状态(归并排序:各自升序);③ 合并时怎么在 O(n) 或 O(n log n) 内把跨中点的贡献算完。第 ③ 问是分治的全部难点,前两问是套路。

为什么合并时只管跨中点的对:若 i、j 同在某半内部,这一对在递归到那一层时已经数过;若 i、j 被中点分开,它只可能在这一层被数到——更外层的合并只统计跨它自己中点的对,根本不会碰到它。这就是不重不漏的来源。

先看两块骨架:分治函数怎么写、合并的双指针怎么写。这两块立住了,三维偏序只是在合并里塞一棵树状数组。

新函数拆解 · 分治函数骨架 solve(l, r)
void solve(int l, int r) { if (l >= r) return; int mid = (l + r) >> 1; solve(l, mid); solve(mid + 1, r); /* 合并:只处理跨中点的贡献 */ }
组成含义 / 格式要求
l, r当前处理的闭区间。用闭区间是为了让 mid 和 mid+1 直接对应“左段右端 / 右段左端”,不用额外 +1。
if (l >= r) return;出口。长度 ≤ 1 的区间天然有序,也没有跨中点的对。写成 l == r 在 l > r 时会漏掉保护。
int mid = (l + r) >> 1;取中点,>> 1 就是整除 2。l + r 超过 2.1×10⁹ 时才需要写成 l + (r - l) / 2,本题不需要。
两个递归调用先递归、后合并。归并排序必须这个顺序:两半没排好,合并毫无意义。
合并段整个分治唯一要动脑的地方。它只能看见“跨中点”的对,其余交给递归。
递归层数log₂n 层。n = 5×10⁵ 时 19 层,栈深度 19,绝不会爆栈。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

void solve(int l, int r) {
    if (l >= r) return;
    int mid = (l + r) >> 1;
    cout << "[" << l << "," << r << "]  mid = " << mid << "\n";
    solve(l, mid);          // 左半
    solve(mid + 1, r);      // 右半
}

int main() {
    solve(1, 8);            // 先看清“切”的形状,再往里加合并
    return 0;
}
注意

跑一遍数层数:8 个元素切 3 层,第 0 层 1 段、第 1 层 2 段、第 2 层 4 段。

把 solve(1, 8) 改成 solve(1, 10),会看到 mid 向下取整,左段可能比右段长 1——所有分治都这样。

递归函数别传大对象(比如把整个 vector 按值传),每个元素会被复制 log n 次,常数直接翻倍。

新函数拆解 · 归并的双指针合并
int i = l, j = mid + 1, k = l; while (i <= mid && j <= r) tmp[k++] = (a[i] <= a[j]) ? a[i++] : a[j++];
组成含义 / 格式要求
i左段指针,从 l 出发,最远走到 mid。
j右段指针,从 mid+1 出发,最远走到 r。
k写指针,从 l 出发。它写的是 tmp 而不是 a——原地写回会覆盖还没读的数据。
a[i] <= a[j]相等时优先取左边。这叫稳定,也是逆序对统计的正确性来源(相等不算逆序对)。
tmp[k++] = ...三件事压成一行:写入、k 前移、被选中那一侧的指针前移。
收尾两个 while一侧走空后,另一侧剩下的元素本就有序,直接照抄,不需要再比较。
最后的抄回写完一段立刻抄回 a[l..r]。上层合并要求看到两半有序,这一步不能省。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int a[9] = {0, 1, 4, 7, 2, 5, 8, 9, 9};   // a[1..3] 与 a[4..8] 各自升序
int tmp[9];

int main() {
    int i = 1, j = 4, k = 1;
    while (i <= 3 && j <= 8) tmp[k++] = (a[i] <= a[j]) ? a[i++] : a[j++];
    while (i <= 3) tmp[k++] = a[i++];      // 左段剩余
    while (j <= 8) tmp[k++] = a[j++];      // 右段剩余
    for (int p = 1; p <= 8; ++p) cout << tmp[p] << " ";
    cout << "\n";                          // 1 2 4 5 7 8 9 9
    return 0;
}
注意

示例把两段有序数据直接写死了,可以单独编译运行;真实代码里这三段(主循环 + 两个收尾)要放进 msort 的合并位置。

把 <= 改成 <,排序结果一模一样,但相等元素的先后被交换——逆序对统计会在这一点上出错,症状是 WA 而不是 RE。

例题 · 逆序对洛谷 P1908普及/提高-去洛谷做这题 ↗

给定长度为 n 的正整数序列 a。若 i < j 且 aᵢ > aⱼ,则称 (i, j) 是一个逆序对,求逆序对总数。

输入格式:第一行一个整数 n;第二行 n 个整数,每个数不超过 10⁹。
输出格式:一行一个整数,表示逆序对的数目。

数据范围:1 ≤ n ≤ 5×10⁵。答案最大约 n(n−1)/2 ≈ 1.25×10¹¹,需用 64 位整数。

输入: 6 5 4 2 6 3 1 输出: 11
把“数对”拆成三份

暴力枚举每一对,n = 5×10⁵ 时是 1.25×10¹¹ 次比较,几十秒起步,必然 TLE。

拆成三份。设 solve(l, r) 给出区间内的逆序对数,则

solve(l, r) = solve(l, mid) + solve(mid+1, r) + cross(l, mid, r),cross 只数 i ≤ mid < j 且 a[i] > a[j] 的对。

让递归顺手排序。归并排序的递归本来就是“把两半排好”,于是 solve 除了返回答案,还要保证 a[l..r] 升序。这个附加状态正是 cross 能 O(n) 算出来的前提。

合并时怎么数。取右边的 a[j] 时,说明 a[i] > a[j];而 a[i..mid] 单调不降,所以这 mid − i + 1 个数全都 > a[j],一次贡献这么多对。取左边时 a[i] ≤ a[j],不构成逆序对,直接放走。

为什么不重复。每一对 (p, q) 在递归树上有唯一的最低公共层——它恰好横跨那一层的中点,于是只被那一层的合并数到一次。

等号的位置很关键。用 a[i] <= a[j] 判断取左边:相等时先放左边,相等的一对不进入答案。改一个符号就变成“非严格逆序对”,这是 WA 而不是 RE。

相对第 46 节的树状数组写法,这里省掉离散化。归并只比较大小,值到 10⁹ 也照样做;树状数组得先把值压成 1..n 的名次。代价是归并只能离线计数,改一个数就得重跑。

完整可编译代码(C++17):归并排序求逆序对
#include <bits/stdc++.h>
using namespace std;

const int N = 500005;
int n, a[N], tmp[N];
long long ans;                 // 上限 n(n-1)/2 ≈ 1.25e11,int 装不下

void msort(int l, int r) {
    if (l >= r) return;                  // 长度 1,天然有序
    int mid = (l + r) >> 1;
    msort(l, mid);                       // 先让两半各自有序
    msort(mid + 1, r);

    int i = l, j = mid + 1, k = l;
    while (i <= mid && j <= r) {
        if (a[i] <= a[j]) {
            tmp[k++] = a[i++];           // 取左边:这一对不是逆序对
        } else {
            ans += mid - i + 1;          // a[i..mid] 全都 > a[j],一次加一整段
            tmp[k++] = a[j++];
        }
    }
    while (i <= mid) tmp[k++] = a[i++];
    while (j <= r) tmp[k++] = a[j++];
    for (int p = l; p <= r; ++p) a[p] = tmp[p];   // 抄回,供上层合并使用
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    for (int i = 1; i <= n; ++i) cin >> a[i];
    msort(1, n);
    cout << ans << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
long long ans答案上限 1.25×10¹¹,用 int 会溢出成负数,且只在大测试点挂。
msort(1, n)对整段 1..n 调用,返回值放在全局 ans 里,省掉函数返回值的搬运。
if (l >= r) return;单元素区间既没有内部对,也没有跨中点的对,直接结束。
msort(l, mid); msort(mid + 1, r);两半排好序后,a[l..mid] 与 a[mid+1..r] 各自升序——这是合并能线性进行的前提。
ans += mid - i + 1此刻 a[i] > a[j],而 a[i..mid] 升序,故这一段每个数都 > a[j],共 mid−i+1 个逆序对。
i++, j++ 的分工只会有一个指针前移,所以整个 while 最多走 n 步,合并是 O(区间长度)。
for (p = l; p <= r; ++p) a[p] = tmp[p];把合并结果写回原数组。漏掉这句,上层合并看到的是乱序,答案偏小。
tmp 是全局数组不用每层新开,也避免递归里开大数组爆栈。每一层只写自己那一段 [l, r],互不干扰。

下面是正题。逆序对其实是二维偏序:数满足 i < j 且 a[i] > a[j] 的对,等价于“下标一维、值一维”。把下标换成属性 a、值换成属性 b,就是数 aᵢ ≤ aⱼ 且 bᵢ ≤ bⱼ 的对。再加第三维 c,就得到三维偏序:排序管一维,分治管一维,树状数组管一维。

维数要数的东西工具复杂度
1 维aᵢ ≤ aⱼ 的对数排序 + 双指针O(n log n)
2 维(逆序对)i < j 且 aᵢ > aⱼ归并 / 排序 + 树状数组O(n log n)
3 维aᵢ ≤ aⱼ、bᵢ ≤ bⱼ、cᵢ ≤ cⱼ排序 + CDQ + 树状数组O(n log n log k)
4 维以上再叠一维CDQ 套 CDQ / 树套树O(n log³ n) 起步

先拆开 CDQ 里唯一的新语法:三元组结构体 + 去重。看上去是存储问题,其实是正确性问题。

新函数拆解 · 三元组结构体与去重(CDQ 的前置)
struct Node { int a, b, c, cnt, id; }; bool cmp(const Node &x, const Node &y);
组成含义 / 格式要求
a, b, c三个属性。排序只按 a → b → c 的字典序,因为 CDQ 的“左半 ≤ 右半”依赖 a 单调。
cnt这个三元组出现了几次。完全相同的三元组必须合并成一个节点,否则它们在递归切分时的互相贡献会算错。
id合并后的编号,也是答案数组的下标。cdq 会反复打乱数组顺序,只有 id 能把答案找回去。
cmp 的三行依次比 a、b、c;全相等时返回 false。这样 sort 之后,相同的三元组必然相邻。
去重循环扫一遍:与上一组相同就 ++cnt,不同就开新组并记 id。因为已经排好序,一趟就够。
为什么必须去重若两个元素三个属性都相同,它们互相满足 ≤,答案里各要算对方一次。合并成 cnt 后,用 cnt 参与树状数组计数,最后再补 cnt−1,就能一次算对。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

struct Node {
    int a, b, c, cnt, id;
} q[10];

bool cmp(const Node &x, const Node &y) {
    if (x.a != y.a) return x.a < y.a;
    if (x.b != y.b) return x.b < y.b;
    return x.c < y.c;                 // 全相等返回 false,保证相同元素相邻
}

int main() {
    int raw[5][3] = {{1, 2, 3}, {1, 2, 3}, {2, 1, 1}, {1, 2, 3}, {2, 1, 1}};
    int n = 5, tot = 0;
    for (int i = 1; i <= n; ++i) {
        q[i].a = raw[i - 1][0];
        q[i].b = raw[i - 1][1];
        q[i].c = raw[i - 1][2];
    }
    sort(q + 1, q + n + 1, cmp);

    for (int i = 1; i <= n; ++i) {
        if (tot && !cmp(q[tot], q[i])) ++q[tot].cnt;      // 与上一组完全相同
        else { q[++tot] = q[i]; q[tot].cnt = 1; q[tot].id = tot; }
    }

    for (int i = 1; i <= tot; ++i)
        cout << q[i].a << " " << q[i].b << " " << q[i].c
             << "  cnt=" << q[i].cnt << "  id="p56 << q[i].id << "\n";
    // 1 2 3 cnt=3 id=1   /   2 1 1 cnt=2 id=2
    return 0;
}
注意

!cmp(q[tot], q[i]) 能判等,是因为数组已按 cmp 升序;此时“不小于”只能是“相等”。

去重后的元素个数 tot 可能远小于 n,后面的 cdq 只在 1..tot 上跑。

答案要按 id 存回,因为 cdq 的归并会把 q 的顺序改得面目全非。

例题 · 【模板】三维偏序(陌上花开)洛谷 P3810提高+/省选-去洛谷做这题 ↗

有 n 个元素,第 i 个元素有三个属性 aᵢ、bᵢ、cᵢ。设 f(i) 为满足 aⱼ ≤ aᵢ 且 bⱼ ≤ bᵢ 且 cⱼ ≤ cᵢ 且 j ≠ i 的 j 的个数。

对每个 d ∈ [0, n),求 f(i) = d 的 i 的个数。

输入格式:第一行两个整数 n、k(k 是属性最大值);接下来 n 行,每行三个整数。
输出格式:共 n 行,第 d+1 行表示 f(i) = d 的 i 的个数。

数据范围:1 ≤ n ≤ 10⁵,1 ≤ aᵢ, bᵢ, cᵢ ≤ k ≤ 2×10⁵。

三个维度,三种工具,各管一维

暴力是 O(n²) = 10¹⁰。二维可以用“排序 + 树状数组”降到 O(n log n),但三维时排完 a,剩下的 b、c 两维没法同时用一次扫描解决——这就是 CDQ 的用武之地。

第一维 a 交给排序。按 (a, b, c) 字典序排完,数组就满足 a 单调。于是任意“左半 / 右半”的切分都天然满足:左半每个元素的 a ≤ 右半每个元素的 a。

第二维 b 交给归并。合并时双指针按 b 归并:把左半元素按 b 递增塞进树状数组,遇到右半元素时,树状数组里的左半元素全都满足 b ≤ 当前元素的 b。

第三维 c 交给树状数组。进树状数组时在位置 c 上加 cnt,查询时算 sum(c),得到的就是“b、c 两维都不超过它”的左半元素个数,直接累加到答案。

答案对只在这个合并点被数一次。左半内部、右半内部的贡献在各自的递归里已经算完;跨中点的对恰好被这一层数到;更外层不会再碰它们。

去重是必须的。三个属性全相同的元素互相满足条件,但它们在排序后是同一组,不会再跨中点分开。所以合并成 cnt,进树状数组时加 cnt,最后再补上同组内另外 cnt−1 个。

每层结束要清空树状数组。第 l..mid 个元素被加过,必须原样减回去;否则外层的查询会把不属于它的元素也算进来,答案单调偏大。

复杂度。递归 log n 层,每层所有区间长度之和是 n,每个元素要么一次 add 要么一次 sum,各 O(log k) → O(n log n log k)。n = 10⁵、k = 2×10⁵ 时约 3×10⁷ 次操作。

完整可编译代码(C++17):CDQ 分治求三维偏序
#include <bits/stdc++.h>
using namespace std;

const int N = 100005;          // 元素个数上限
const int M = 200005;          // 属性值上限:树状数组下标是 c,要开到 2e5
int n, k, tot;
int bit[M];                    // 树状数组,管第三维 c
int ans[N];                    // ans[id]:比第 id 组小的元素个数
int res[N];                    // res[d]:f(i) = d 的元素个数

struct Node {
    int a, b, c, cnt, id;
} q[N], tmp[N];

bool cmp(const Node &x, const Node &y) {
    if (x.a != y.a) return x.a < y.a;
    if (x.b != y.b) return x.b < y.b;
    return x.c < y.c;
}

int lowbit(int x) { return x & -x; }
void add(int i, int v) { for (; i <= k; i += lowbit(i)) bit[i] += v; }
int sum(int i) { int s = 0; for (; i > 0; i -= lowbit(i)) s += bit[i]; return s; }

void cdq(int l, int r) {
    if (l >= r) return;
    int mid = (l + r) >> 1;
    cdq(l, mid);                       // 先把两半内部的答案算完
    cdq(mid + 1, r);                   // 两个递归结束后,两半各自按 b 有序

    // 合并:按 b 归并,只统计“左半 → 右半”的贡献
    int i = l, j = mid + 1, t = l;
    while (i <= mid && j <= r) {
        if (q[i].b <= q[j].b) {            // 左半这个 b 更小,它可以贡献给后面所有右半元素
            add(q[i].c, q[i].cnt);         // 第三维交给树状数组
            tmp[t++] = q[i++];
        } else {
            ans[q[j].id] += sum(q[j].c);   // 树里的都满足 b 更小,再查 c <= 它的有几个
            tmp[t++] = q[j++];
        }
    }
    while (i <= mid) { add(q[i].c, q[i].cnt); tmp[t++] = q[i++]; }
    while (j <= r) { ans[q[j].id] += sum(q[j].c); tmp[t++] = q[j++]; }

    for (int p = l; p <= mid; ++p) add(q[p].c, -q[p].cnt);   // 撤销本层,别污染外层
    for (int p = l; p <= r; ++p) q[p] = tmp[p];              // 按 b 有序写回
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> k;
    for (int i = 1; i <= n; ++i) cin >> q[i].a >> q[i].b >> q[i].c;
    sort(q + 1, q + n + 1, cmp);

    for (int i = 1; i <= n; ++i) {                 // 相同三元组合并成一组
        if (tot && !cmp(q[tot], q[i])) ++q[tot].cnt;
        else { q[++tot] = q[i]; q[tot].cnt = 1; q[tot].id = tot; }
    }

    cdq(1, tot);

    for (int i = 1; i <= tot; ++i)                 // 同组内另外 cnt-1 个也要算
        res[ans[q[i].id] + q[i].cnt - 1] += q[i].cnt;
    for (int d = 0; d < n; ++d) cout << res[d] << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
int bit[M]树状数组下标是第三维 c,最大 2×10⁵。只开到 N = 10⁵ 会越界写,症状是 RE 或随机 WA。
sort(q + 1, q + n + 1, cmp)先把 a 排成单调。之后任何“左半 / 右半”切分都满足左半 a ≤ 右半 a。
!cmp(q[tot], q[i])数组已升序,cmp 返回 false 只可能是相等。于是相同三元组被并成一组,cnt 记次数。
cdq(l, mid); cdq(mid + 1, r);顺序不能反:合并要求两半各自按 b 有序,而这个有序正是递归产出的。
if (q[i].b <= q[j].b)谁 b 小谁先动。左半元素进树的条件就是它的 b ≤ 当前右半元素的 b。
add(q[i].c, q[i].cnt)按 cnt 加,因为这一组背后有 cnt 个真实元素,每个都要各贡献 1。
ans[q[j].id] += sum(q[j].c)树里全是 b 不超的左半元素,sum(c) 数出其中 c 也不超的个数,正是这一对的贡献。
add(q[p].c, -q[p].cnt) 的撤销把本层的加法原样减回去。漏了这句,外层查询会混入不属于它的元素,答案单调偏大。
q[p] = tmp[p]按 b 有序写回,供上一层继续二路归并。不写回,上一层看到的是乱序,答案偏小。
res[ans[...] + cnt - 1] += cnt同组内 cnt 个元素互相都满足 ≤,所以每个的 f 值是 ans + cnt − 1;这一组共 cnt 个元素,一起加。
分治还能拿去做什么

这一节的骨架可以套在三个方向上,都是同一句话:把“枚举所有对”换成“在每个合并点批量处理跨中点的对”。

  • 归并类:数逆序对、数“左边比它大的个数”、数满足某种大小关系的对,合并时用双指针 + 一次整段计数。
  • CDQ 类:偏序计数(本题)、带修改的偏序(把修改看成时间维,变成三维)、CDQ 优化 DP。
  • 整体二分:把“对每个询问二分答案”改成“对所有询问一起二分”,每层 O(n + m),共 log 层。

CDQ 优化 DP 的形状:dp[i] 只从 j < i 且满足某个偏序条件的 j 转移。把“枚举 j”换成分治:算完左半的 dp,就用左半的所有 dp 值去批量更新右半的候选,O(n²) 直接掉到 O(n log² n)。判断能不能用的标准只有一个——转移能不能写成跨中点的偏序计数。

不能用的信号:合并时无法在 O(n) 或 O(n log n) 内算清跨中点的贡献(比如要求跨中点区间的某种最值搭配),那就得换工具,硬套分治只会得到一个更慢的暴力。

这一节的坑,按症状记

把 a[i] <= a[j] 写成 a[i] < a[j]:相等的一对被算成逆序对 → WA,而答案恰好偏大“相同元素对数”那么多;没有重复数的样例完全测不出来。

ans 用 int:上限 1.25×10¹¹,溢出成负数 → WA,小数据全对,大数据全挂。

忘记 a[p] = tmp[p] 抄回:上层合并看到的是乱序段 → WA,答案偏小,且错得很隐蔽。

CDQ 里忘记撤销树状数组(add(q[p].c, -q[p].cnt)):外层查询混入不属于它的元素 → WA,答案单调偏大,n 越大错得越离谱。

CDQ 没做相同三元组的合并:三个属性全相同的元素在递归里永远同侧,互相的贡献全丢 → WA;只要数据有重复就挂,没重复就全对。

树状数组只开到 N = 10⁵ 而属性 c 能到 2×10⁵:越界写 → RE,或者更糟——写坏别的全局变量。

自己动手复现

关掉页面,把 P1908 的 30 行从零敲一遍,提交确认样例输出 11。

改一改①:把 a[i] <= a[j] 改成 a[i] < a[j]。先用样例跑(答案仍是 11,因为没有重复数),再输入 3 换行 3 3 1,预测并验证:正确答案 2,改后变成 3。

改一改②:在合并前打印 l r mid ans,观察 ans 只在“取右边”时增长——这就是 cross 的定义在代码里的样子。

改一改③:把 CDQ 里撤销树状数组那一行注释掉,用 n = 10 的小数据跑 P3810 的样例,看输出怎么变,再想为什么。

数学补给 · 分治的 log n 是从哪一层数出来的

设一次规模为 n 的归并耗时不超过 c·n(每个元素搬进 tmp 再抄回,常数次操作)。三步骤直接给出递归式

T(n) = 2T(n/2) + c·n,T(1) = O(1)。

按层展开,每层的工作量居然完全相同:第 i 层有 2ⁱ 段,每段长 n/2ⁱ,一段的合并代价是 c·n/2ⁱ,相乘还是 c·n。层数是 log₂n + 1。

层 i区间段数 2ⁱ每段长度 n / 2ⁱ本层总工作量
01nc·n
12n/2c·n
24n/4c·n
…………
log₂nn1c·n

所以 T(n) = c·n·(log₂n + 1) = O(n log n)。n = 5×10⁵ 时 log₂n ≈ 18.9,总操作约 5×10⁵ × 19 ≈ 9.5×10⁶——比 O(n²) 的 1.25×10¹¹ 小四个数量级。

CDQ 多出来的那个 log 来自树状数组。每层所有区间长度之和恰好是 n,每个元素在每层要么 add 一次、要么 sum 一次,各 O(log k)。于是每层 O(n log k),共 log n 层:O(n log n log k)。n = 10⁵、k = 2×10⁵ 时约 10⁵ × 17 × 18 ≈ 3×10⁷。

先估答案位数再写类型。逆序对最多 n(n−1)/2,n = 5×10⁵ 时是 124999750000 ≈ 1.25×10¹¹,12 位十进制。int 上限 2.147×10⁹,差两个数量级,所以 long long 不是保险,是必须。

主定理一句话:T(n) = aT(n/b) + f(n),本题 a = b = 2、f(n) = Θ(n),结果 Θ(n log n)。注意结论的另一半——合并做多慢,分治就多慢:若合并要 Θ(n²),总复杂度就是 Θ(n²),分治毫无收益。

举一反三 · 想清楚这些再往下走
  1. 若把逆序对的 “>” 换成 “>=”(相等也算),合并里哪个符号要改?答案会多出多少?
  2. 要求输出“每个位置 i 左边比它大的个数”,而不是总数,归并里的那一行要怎么改?要不要多开一个数组?
  3. 二维偏序(aᵢ ≤ aⱼ 且 bᵢ ≤ bⱼ、j 任意)为什么“排序 + 树状数组”就够,不需要分治?三维时缺的到底是哪一步?
  4. CDQ 里把树状数组换成“合并完再双指针扫一遍 b”,能省掉一个 log 吗?代价是什么?
  5. 带修改的逆序对(单点修改 + 查全局逆序对数)能不能用 CDQ?把什么当成第三维?
  6. n = 5×10⁵ 个点、但 k(属性最大值)只有 10 的偏序问题,O(n log n log k) 里的 log k 还有意义吗?该不该换算法?
自测:能全部回答再进下一节
  • 能默写分治骨架的三段,并说出为什么必须“先递归后合并”。
  • 能解释 ans += mid - i + 1 为什么可以一次加一整段,且不会重复。
  • 能说清“不重不漏”的依据:每一对在递归树上有唯一的最低公共层。
  • 能写出 CDQ 的合并段,并指出 add / sum 各负责哪一维。
  • 能说出为什么必须撤销树状数组、为什么必须做相同三元组的合并。
  • 能估出 O(n log n log k) 在 n = 10⁵ 时的量级,并判断需要 64 位整数。
挑战题 · 寒假作业
洛谷 P2717提高-去洛谷做这题 ↗

给定长度为 n 的正整数序列 a,求有多少个连续子序列的平均值不小于给定的 k。

输入格式:第一行两个整数 n、k;接下来 n 行,每行一个整数,第 i 个数是 aᵢ。
输出格式:一行一个整数,表示答案。

数据范围:1 ≤ n ≤ 10⁵,1 ≤ aᵢ, k ≤ 10⁴。答案最大约 n(n+1)/2 ≈ 5×10⁹,需用 64 位整数。

引导性提示(先只看这里)

平均值的除法不好处理:它把整数比较变成了小数比较,也破坏了“能和前缀和相减”的性质。

试着把 k 从每个数里先减掉。子段 [i, j] 的平均值 ≥ k,等价于这段 (a − k) 的和 ≥ 0。

对 (a − k) 做前缀和 S[0..n]。子段 [i, j] 对应一对下标 (i−1, j),且 i−1 < j。问题变成数“有多少对 S[p] ≤ S[q]”。

想看思路了?点开完整思路

变形。sum(a[i..j]) ≥ k·(j−i+1) 等价于 sum(a[i..j] − k) ≥ 0。令 bᵢ = aᵢ − k,S₀ = 0、S_x = b₁+…+b_x,则子段 [i, j] 合法 ⟺ S_j ≥ S_{i−1}。

数对。于是答案是:数下标对 (p, q),0 ≤ p < q ≤ n,满足 S_p ≤ S_q。注意这里共有 n+1 个前缀和,S₀ 必须参与。

反过来数。不满足的就是 S_p > S_q 的对,即严格逆序对数 inv。总对数 C(n+1, 2),答案 = 总对数 − inv。这样可以直接套本节模板,不必在合并里额外处理“等于”。

求 inv。把这 n+1 个 long long 前缀和交给归并排序,用 a[i] <= a[j] 取左边,答案 O(n log n)。数值规模:|aᵢ − k| ≤ 10⁴,n·10⁴ = 10⁹,前缀和不会超 int,但比较与相减统一用 long long 更省心。

为什么不能把 k 直接乘进下标。常见的错法是把 S_x 写成 S_x − k·x:那等于把 k 减了两次,样例就会挂。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int N = 100005;
int n, k;
long long s[N], tmp[N], inv;          // s: 减掉 k 之后的前缀和

void msort(int l, int r) {
    if (l >= r) return;
    int mid = (l + r) >> 1;
    msort(l, mid);
    msort(mid + 1, r);

    int i = l, j = mid + 1, t = l;
    while (i <= mid && j <= r) {
        if (s[i] <= s[j]) tmp[t++] = s[i++];       // 相等不算逆序对
        else { inv += mid - i + 1; tmp[t++] = s[j++]; }
    }
    while (i <= mid) tmp[t++] = s[i++];
    while (j <= r) tmp[t++] = s[j++];
    for (int p = l; p <= r; ++p) s[p] = tmp[p];
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> k;
    s[0] = 0;                                  // 空前缀,别忘了它
    for (int i = 1; i <= n; ++i) {
        long long a;
        cin >> a;
        s[i] = s[i - 1] + (a - k);             // 把“平均值 ≥ k”化成“前缀和不降”
    }

    msort(0, n);                               // n+1 个数,下标 0..n
    long long tot = (long long)(n + 1) * n / 2;
    cout << tot - inv << "\n";
    return 0;
}
关键片段拆解
片段作用
s[i] = s[i - 1] + (a - k)把 k 摊到每个元素上:sum(a) − k·len ≥ 0 等价于 sum(a − k) ≥ 0。一次前缀和就把区间条件变成两点比较。
msort(0, n)0..n 共 n+1 个前缀和。写成 msort(1, n) 会漏掉所有“从第 1 个元素开始”的子段。
s[i] <= s[j]取等号:S_p == S_q 表示子段和为 0,平均值恰好等于 k,是合法答案,不能算成逆序对。
inv += mid - i + 1左半剩下的 i..mid 都严格大于 s[j],一次加一整段,这是 O(n log n) 的关键。
tot - inv总对数减去“S_p > S_q”的对数,剩下的正是“S_p ≤ S_q”。
(long long)(n + 1) * n / 2C(n+1, 2) 可达 5×10⁹,先转 long long 再乘,否则中间就溢出成负数。
这题最容易错的地方
  • 下标只取 1..n,漏掉 S₀:所有“从第 1 个元素开始”的子段全丢 → WA,且答案恰好少若干个。
  • 取左边时漏掉等号:S_p == S_q 的对被当成逆序对减掉 → WA,测试数据里有“平均值恰好等于 k”的子段就会挂。
  • tot 用 int 算:5×10⁹ 溢出成负数 → WA,小样例照样过。
  • 把前缀和写成 S_x − k·x:等价于把 k 减了两次,样例立刻挂——这是这道题最常见的错。
  • 忘记把 tmp 抄回 s:上层合并看到乱序段,inv 偏小 → WA。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》0x30 分治:CDQ 分治与三维偏序
  • 《算法竞赛入门笔记》归并排序与逆序对一节
  • 洛谷 P1908、P3810 题解区的高赞实现
57 根号分治与莫队入门提高+

57 · 根号分治与莫队入门

把 √n 当成一条分界线:参数小的预处理、参数大的现场算(P3396),或者按左端点分块排序、让区间指针只挪一小段(P2709 莫队)。两种技巧的复杂度都从 mB + n²/B 这类代价函数解出来,而不是背结论。
提高+预计 70 分钟根号分治莫队分块
本节出现的新函数 / 新语法(先看懂再用)

这一节的两件事看上去无关:一件是“把能整除的小模数全部预处理”,一件是“把询问排序后暴力挪指针”。它们的共同动作是往复杂度里塞一个 √n——用 √n 次廉价操作,换掉一次昂贵操作。

根号分治的本质是给暴力划一条线:线以下的预处理,线以上的现场算,两边的代价都压到 √n 量级。
做法P3396 哈希冲突(n = m = 1.5×10⁵)P2709 莫队(n = m = 10⁵)
纯暴力每次询问 O(n) → 2.3×10¹⁰,TLE每个询问 O(n) → 1×10¹⁰,TLE
全部预处理O(n²) 张表,空间放不下O(n²) 前缀表,空间放不下
根号分治 / 莫队O((n+m)√n) ≈ 1.2×10⁸O(n√m) ≈ 6.3×10⁷

形态一:按参数大小分治。以 P3396 为例。询问参数是模数 x:x 小的组合少(x ≤ T 时一共只有 Σx ≈ T²/2 张表),可以全部预存;x 大的时候池子里的数少(最多 n/x 个),现场跳着扫也不贵。取 T = √n,两种分支的代价都是 √n:查询 O(1) 或 O(√n),修改 O(√n)。

形态二:按规模分治。信这句话:一个长度为 n 的序列里,出现次数 > T 的值至多 n/T 个。于是把出现多的叫“重值”,只给重值做预处理(前缀出现次数表,代价 O(n·n/T)),出现少的“轻值”整串不超过 T 次,询问时直接枚举它的出现位置。树上同理:度数 > √m 的点至多 √m 个,这是虚树、分块图的第一步。

形态二和莫队是同一件事的两种外壳:都在用“总量有限”来给最坏情况封顶。形态一说“小的种类少”,形态二说“大的个数少”,两者相乘一定不超过 n。

先把三个语法零件拆开:阈值与两套分支、四个指针的移动、带奇偶优化的排序。

新函数拆解 · 阈值 T 与两套分支
const int T = 400; // ≈ √n if (x <= T) { /* 查预处理表 */ } else { /* 现场暴力 */ }
组成含义 / 格式要求
T 怎么定T = (int)sqrt(n) + 1,或直接写常数。n ≤ 1.5×10⁵ 时 √n ≈ 387,写 400 更好记也更快(省掉一次开方)。
表要开多大s[T+1][T+1]:第一维是模数,第二维是余数(最大 T−1)。400×400×4 字节 ≈ 640 KB,随便开。
小参数分支必须 O(1) 查表,所以预处理要覆盖所有可能出现的小参数,不能等询问来了才算。
大参数分支项数是 O(n/T),循环写成 for (i = 起点; i <= n; i += x),绝不能写成枚举 1..n 再判断 i % x == y——那是没优化。
代价交换预处理 O(nT),每次修改 O(T),每次大查询 O(n/T)。三条加起来才是要控制的量。
修改怎么维护改 a[x] 时,对所有 t ≤ T 把 s[t][x % t] 加上增量;大参数分支不用管,它每次现算。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

const int N = 150005, T = 400;
int n = 10, a[N], s[T + 5][T + 5];

int main() {
    for (int i = 1; i <= n; ++i) a[i] = i;          // 假装输入是 1 2 3 ... 10

    for (int x = 1; x <= T; ++x)                    // 建表:只给小模数建
        for (int i = 1; i <= n; ++i)
            s[x][i % x] += a[i];

    int x = 2, y = 1, ans = 0;                      // 询问:模 2 余 1 的池子和
    if (x <= T) ans = s[x][y];                      // 小模数:O(1)
    else for (int i = y ? y : x; i <= n; i += x) ans += a[i];   // 大模数:O(n/T)

    cout << ans << "\n";                            // 1+3+5+7+9 = 25
    return 0;
}
注意

y = 0 时起点是 x 而不是 0:下标从 1 开始,0 号位不存在。

两重循环是 nT ≈ 6×10⁷ 次加法,比每次询问现算 O(n) 划算得多——这就是“预处理换查询”。

T 取得太大,预处理和修改会变慢;T 取得太小,大查询变慢。最优值解方程得到,见数学补给。

新函数拆解 · 莫队的四个指针移动
while (L > q[i].l) add(--L); while (R < q[i].r) add(++R); while (L < q[i].l) del(L++); while (R > q[i].r) del(R--);
组成含义 / 格式要求
L, R 初值L = 1, R = 0,表示当前维护的区间为空。写成 L = 0 会去 add 一个不存在的 a[0]。
维护的是闭区间四个 while 走完后必然 L == l 且 R == r,此时 cur 就是这一问的答案。
四条语句的顺序只要 add / del 一一对应,顺序无所谓;习惯先扩后缩,能一眼看出区间在怎么变。
add / del 必须互为逆操作add 一个元素与 del 同一个元素的效果要正好抵消,写不对称就会永久偏移。
为什么这样能快按块排序后,同一块内的询问 L 只在 B 范围内晃、R 单调推进,所以指针总步数是 mB + n²/B,而不是 m·n。
前提:add / del 是 O(1)维护的量必须能在加入/删除一个元素时 O(1) 更新(cnt、cnt²、重复值个数)。像“区间最大值”这种删除后无法还原的量,普通莫队做不了。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int a[9] = {0, 1, 2, 2};                        // 只用 1..3 号位置,值域 1..2
int cnt[9];
long long cur = 0;                              // 维护 Σ cnt²

void add(int p) { cur += 2LL * cnt[a[p]] + 1; ++cnt[a[p]]; }
void del(int p) { cur -= 2LL * cnt[a[p]] - 1; --cnt[a[p]]; }

int main() {
    int L = 1, R = 0;                           // 空区间
    int l = 1, r = 3;                           // 目标区间 [1, 3] = {1, 2, 2}
    while (L > l) add(--L);
    while (R < r) add(++R);
    while (L < l) del(L++);
    while (R > r) del(R--);
    cout << cur << "\n";                        // 1² + 2² = 5
    return 0;
}
注意

四个 while 总共走 O(n√m) 步,每步只做 O(1) 的 add/del,所以总复杂度就是这个步数。

对每题都从空区间出发,看起来笨,实际上是“让相邻询问的区间尽量接近”,这个笨办法就是莫队的全部思想。

新函数拆解 · 奇偶排序(莫队的常数优化)
bool cmp(const Query &x, const Query &y) { int bx = x.l / B, by = y.l / B; if (bx != by) return bx < by; return (bx & 1) ? x.r > y.r : x.r < y.r; }
组成含义 / 格式要求
x.l / B左端点落在第几块。B 是块长,取 n/√m(n = m 时就是 √n)。
块号不同按块号升序,保证 L 只在块内移动,这是 mB 那一项的来源。
同一块内 r 升序块内 r 单调推进,整块只走 O(n) 步。
bx & 1 的奇偶翻转奇数块让 r 降序。这样一块结束时 r 停在高端,下一块从高端接着往下走,省掉每块之间整整一次 n 的回程,实测常数接近减半。
为什么只翻转 r翻转 l 会破坏“L 只在块内移动”的性质;r 不受块限制,可以随便调头。
必须严格弱序两个询问同块同 r 时返回 false,sort 不会崩。写成 <= 会让 sort 越界,症状是 RE。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

struct Query { int l, r, id; } q[6];
int B = 2;                                   // 块长:n/√m 的简化演示

bool cmp(const Query &x, const Query &y) {
    int bx = x.l / B, by = y.l / B;
    if (bx != by) return bx < by;             // 先按块
    return (bx & 1) ? x.r > y.r : x.r < y.r;  // 块内 r 的方向按块号奇偶翻转
}

int main() {
    int L[6] = {0, 1, 1, 3, 4, 5};
    int R[6] = {0, 5, 2, 6, 4, 8};
    for (int i = 1; i <= 5; ++i) { q[i].l = L[i]; q[i].r = R[i]; q[i].id = i; }
    sort(q + 1, q + 6, cmp);
    for (int i = 1; i <= 5; ++i) cout << q[i].l << " " << q[i].r << "\n";
    // 1 2 / 1 5 / 3 6 / 4 4 / 5 8:第 1、2 块 r 递增,第 3 块(奇数块)r 递减
    return 0;
}
注意

这是排序里唯一需要动脑的地方;写错方向不影响正确性,只影响常数,所以症状是 TLE 而不是 WA。

块长 B 与排序规则必须配套:改了 B 就要重新分析 mB + n²/B,别照抄 n = m 时的 316。

例题 · 哈希冲突洛谷 P3396提高-去洛谷做这题 ↗

B 君有一个正整数序列 value[1..n]。value[k] 会被存进 (k mod p) 这个池子,下文把模数统一记作 x。给出 m 次操作:

A x y:询问在模 x 时,y 池内所有数的总和,即所有满足 k mod x = y 的 value[k] 之和。
C x y:把 value[x] 改成 y,立即生效。

输入格式:第一行两个整数 n、m;第二行 n 个正整数;接下来 m 行,每行一个字符 cmd 和两个整数 x、y。
输出格式:每个 A 操作输出一行一个正整数。

数据范围:n, m ≤ 1.5×10⁵,1 ≤ valueᵢ ≤ 1000,修改后的值同样不超过 1000。

输入: 10 5 1 2 3 4 5 6 7 8 9 10 A 2 1 C 1 20 A 3 1 C 5 1 A 5 0 输出: 25 41 11
模数小就存,模数大就跳

暴力:每次询问扫一遍 1..n,判断 i mod x 是否为 y,O(n)。n = m = 1.5×10⁵ 时是 2.25×10¹⁰ 次取模,必然 TLE。

全部预处理:给每个 (x, y) 存一张表。x 有 n 种、y 有 x 种,合计 O(n²) —— 空间先炸。所以“存或不存”不能一刀切。

分治点:x 小的时候 (x, y) 组合少。x ≤ T 时只有 Σx ≈ T²/2 个格子,T = 387 时约 7.5×10⁴ 个,随便存;x 大的时候池子里的数少,模 x 余 y 的下标最多 n/x 个,跳着扫反而是 O(√n)。

于是:预处理所有 x ≤ T 的表 s[x][y];查询时 x ≤ T 直接查表,x > T 现场按 i += x 跳着扫。取 T = √n,两条分支的代价都是 √n 量级。

修改怎么办。改 a[x] 会影响所有小模数的表:对每个 t ≤ T 把 s[t][x % t] 加上增量 y − a[x]。大模数不需要维护,因为每次查询都是现算的。

总账。预处理 O(nT) ≈ 5.8×10⁷,m 次操作各 O(T) 或 O(n/T) ≈ 387,合计约 1.2×10⁸ 次简单加法。

数值。最大的池子和 = 1.5×10⁵ × 1000 = 1.5×10⁸,int 绰绰有余。写成 int 而不是 long long,表的内存直接减半,也在缓存里待得更久。

完整可编译代码(C++17):根号分治
#include <bits/stdc++.h>
using namespace std;

const int N = 150005, T = 400;         // T ≈ √150000 ≈ 387,取整到 400
int n, m, a[N];
int s[T + 5][T + 5];                   // s[x][y]:所有 i ≡ y (mod x) 的 a[i] 之和,只存 x ≤ T

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> m;
    for (int i = 1; i <= n; ++i) cin >> a[i];

    for (int x = 1; x <= T; ++x)                    // 建表:nT ≈ 6×10⁷ 次加法
        for (int i = 1; i <= n; ++i)
            s[x][i % x] += a[i];

    while (m--) {
        char op; int x, y;
        cin >> op >> x >> y;
        if (op == 'A') {
            if (x <= T) {
                cout << s[x][y] << "\n";            // 小模数:查表 O(1)
            } else {
                int r = 0;                          // 大模数:池内最多 n/x 个数,跳着扫
                for (int i = y ? y : x; i <= n; i += x) r += a[i];
                cout << r << "\n";
            }
        } else {
            for (int t = 1; t <= T; ++t)            // 改一个数,所有小模数的表都要动
                s[t][x % t] += y - a[x];
            a[x] = y;                               // 最后再改原数组,否则增量算错
        }
    }
    return 0;
}
逐行拆解
代码片段含义与理由
int s[T + 5][T + 5]第一维是模数 x(只到 T),第二维是余数 y(最大 T−1)。400×400 个 int 约 640 KB。
for (x = 1; x <= T; ++x)只给“小模数”建表。大模数的表不建——建了也存不下 n² 个格子。
s[x][i % x] += a[i]把每个元素放进它对应的 (模数, 余数) 格子。nT 次加法换后面每次查询 O(1)。
if (x <= T)分治的那条线。线以下查表,线以上暴力,两条分支的代价都 ≤ √n。
for (i = y ? y : x; i <= n; i += x)大模数分支的关键:直接按步长 x 跳,次数是 n/x 而不是 n。y = 0 时从 x 开始,因为下标是 1-based。
s[t][x % t] += y - a[x]用增量更新 T 张表,比“重新建表”便宜 T 倍。注意是 y − a[x],不是 y。
a[x] = y 放在最后若先改 a[x],y − a[x] 就变成 0,所有小表全部不更新 → WA。
T 写成常数 400省掉运行时开方,也让数组大小能写成编译期常量;n 变化时重新算一次即可。

现在换一个角度。前面是“按参数大小分工”,莫队是“按询问顺序分工”:把所有询问排个序,让相邻两个询问的区间尽量接近,然后暴力挪指针。单次询问是 O(n),但相邻两次之间只挪一小段,总步数被摊平到 O(n√m)。

莫队有三个硬性前提,缺一不可:① 能离线(询问的答案与顺序无关);② 维护的量在加入/删除一个元素时能 O(1) 更新;③ 区间能“增删元素”地维护,而不是只能整段重算。

例题 · 【模板】莫队 / 小 B 的询问洛谷 P2709普及/提高-去洛谷做这题 ↗

小 B 有一个长为 n 的整数序列 a,值域为 [1, k]。他一共有 m 个询问,每个询问给定区间 [l, r],求:

Σi=1..k cᵢ²,其中 cᵢ 表示数字 i 在 a[l..r] 中出现的次数。

输入格式:第一行三个整数 n、m、k;第二行 n 个整数;接下来 m 行,每行两个整数 l、r。
输出格式:输出 m 行,每行一个整数,对应一个询问的答案。

数据范围:1 ≤ n, m, k ≤ 10⁵。

增量公式 + 排序,就是莫队的全部

暴力:每个询问重扫一遍区间,O(nm) = 10¹⁰,TLE。

关键是增量公式。设当前某个值出现了 c 次,贡献是 c²。再放进一个它,贡献变成 (c+1)²,增量是 (c+1)² − c² = 2c + 1;删掉一个,减少 c² − (c−1)² = 2c − 1。两个都是 O(1),这就是莫队能成立的地基。

然后只管挪指针。维护当前区间 [L, R] 和答案 cur。要让 [L, R] 变成 [l, r],左右端点各自往里收或往外扩,每挪一格调用一次 add 或 del。全部挪完,cur 就是答案。

排序决定快慢。按 l 所在块排序,块内按 r 排序。于是同一块内 r 单调推进(整块只走 O(n) 步),而 l 只在块内晃(每个询问至多 O(B) 步)。总步数 mB + n²/B,取 B = n/√m 得 2n√m。

取 2c+1 时先算再改 cnt。cur += 2*cnt[v] + 1 用的是旧次数,写完才 ++cnt[v]。顺序颠倒就变成用新次数算增量,答案系统性偏大。

奇偶优化。块内 r 单调时,每换一块 r 都要从一段跑回另一端,白走一整趟 n。让奇数块 r 倒序,回程就变成顺路,常数接近减半。这只是常数优化,不影响正确性。

答案规模。最坏情况所有数相同,Σcᵢ² = n² = 10¹⁰,必须 long long。这是本题唯一的数值陷阱。

完整可编译代码(C++17):莫队算法
#include <bits/stdc++.h>
using namespace std;

const int N = 100005;
int n, m, k, B;
int a[N], cnt[N];                  // cnt[v]:值 v 在当前区间里出现几次
long long cur, ans[N];             // cur = Σ cnt[v]²,最大 1e10,必须 long long

struct Query { int l, r, id; } q[N];

bool cmp(const Query &x, const Query &y) {
    int bx = x.l / B, by = y.l / B;
    if (bx != by) return bx < by;                     // 先按左端点所在块
    return (bx & 1) ? x.r > y.r : x.r < y.r;          // 块内 r 单调,奇偶块方向相反
}

void add(int p) { cur += 2LL * cnt[a[p]] + 1; ++cnt[a[p]]; }   // (c+1)² − c² = 2c + 1
void del(int p) { cur -= 2LL * cnt[a[p]] - 1; --cnt[a[p]]; }   // c² − (c−1)² = 2c − 1

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> m >> k;
    for (int i = 1; i <= n; ++i) cin >> a[i];
    B = max(1, (int)(n / sqrt(m)));            // 理论最优块长 n/√m
    for (int i = 1; i <= m; ++i) { cin >> q[i].l >> q[i].r; q[i].id = i; }
    sort(q + 1, q + m + 1, cmp);

    int L = 1, R = 0;                          // 当前维护的区间为空
    for (int i = 1; i <= m; ++i) {
        while (L > q[i].l) add(--L);           // 左端点外扩
        while (R < q[i].r) add(++R);           // 右端点外扩
        while (L < q[i].l) del(L++);           // 左端点内收
        while (R > q[i].r) del(R--);           // 右端点内收
        ans[q[i].id] = cur;                    // 此刻 [L, R] == [l, r]
    }

    for (int i = 1; i <= m; ++i) cout << ans[i] << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
B = max(1, (int)(n / sqrt(m)))块长按 n/√m 取。n = m = 10⁵ 时约 316。用 max 防 m 很大时算出 0。
bx != by → 按块号升序同一块的询问连在一起处理,L 才不会跨块乱跳。
(bx & 1) ? x.r > y.r : x.r < y.r奇数块让 r 倒序,省掉块与块之间的一整趟回程。写反只是变慢,不会错。
cur += 2LL * cnt[a[p]] + 1用旧次数算增量。写 2LL 是为了让整个表达式的类型变成 long long——cur 最大 10¹⁰,int 只有 2.1×10⁹。
++cnt[a[p]] 在增量之后顺序不能换。先改 cnt 再算增量,等于用 (c+1) 代 c,每步都多算 2 → WA。
while (L > q[i].l) add(--L)先减指针再加元素。写成 add(L--) 会把旧位置算进去,还漏掉新位置。
while (L < q[i].l) del(L++)先删再减。del 完再 L++ 才对应“区间左端右移一格”。
ans[q[i].id] = cur排序打乱了询问顺序,答案必须按原编号回填,最后再按 1..m 输出。
cin >> n >> m >> kk 只是值域上界,用来给 cnt 定大小:cnt 的下标是数值本身,值域是 [1, k],所以按 k 开而不是按 n 开。
这一节的坑,按症状记

莫队按 l、r 双关键字排序(不按块):小样例全对,大数据必 TLE——L、R 各自乱跳,总步数退化回 O(nm)。

块长取 1 或取 n:B = 1 时 R 每块都要重走一趟 n,总步数 O(n²);B = n 时等于完全没排序。两种都是 TLE,而且 n 较小时看起来还挺快。

add / del 的增量写成同一个式子(都用 2c+1):只有“只扩不缩”的数据能碰对;正常数据 WA,且答案忽大忽小。

先 ++cnt 再算增量:每步系统性多算 2,答案单调偏大 → WA。

cur 用 int:最坏 Σcᵢ² = 10¹⁰,溢出成负数 → WA,小数据完全正常。

根号分治里大参数写成 for (i = 1; i <= n; ++i) if (i % x == y):等于没优化,n = m = 1.5×10⁵ 时 TLE。必须用 i += x 跳。

P3396 里先改 a[x] 再更新小表:增量 y − a[x] 变成 0,小模数分支全错 → WA,而大模数分支(现算)却是对的,查错时会被这个“一半对”误导。

cnt 按 n 开而值域是 k(本题 k 与 n 无关,k 可以大于 n):越界写 → RE 或随机 WA。数组按 值域开。

自己动手复现

关掉页面,把 P2709 的莫队从零敲一遍,提交确认能过(这道题是洛谷的莫队模板题)。

改一改①:把 cmp 里的奇偶翻转删掉(两个分支都写 x.r < y.r),在 n = m = 10⁵ 的随机数据上比较运行时间——答案不变,只是变慢。

改一改②:把 B 依次改成 1、316、100000,用同一份数据测时,对照数学补给里那张表:两头最慢,中间最快。

改一改③:把 add 里的 2LL * cnt[a[p]] + 1 改成 1,观察小数据下答案如何偏离 n 的平方和。

数学补给 · 两个复杂度是怎么解出来的

一、莫队:mB + n²/B 的最小值。块长 B 是唯一能调的量。同一块内 r 单调,整块至多走 n 步,共 n/B 块 → R 的总位移 ≤ n²/B;每个询问的 l 在同一块内差不超过 B,共 m 个 → L 的总位移 ≤ mB。

由均值不等式:mB + n²/B ≥ 2√(mB · n²/B) = 2n√m,取等号当且仅当 mB = n²/B,即 B = n/√m。n = m 时就是 B = √n。

注意 B 依赖 m,不依赖 n 单独:m 比 n 大很多时块长要变小(m ≫ n 时 B 可以到 1 附近),m ≪ n 时块长变大。所以别把 316 当常数抄。

块长 BL 总位移 ≤ mBR 总位移 ≤ n²/B合计(n = m = 10⁵)
11×10⁵1×10¹⁰约 1×10¹⁰,TLE
1001×10⁷1×10⁸1.1×10⁸
3163.2×10⁷3.2×10⁷6.3×10⁷,最优
10001×10⁸1×10⁷1.1×10⁸
1000001×10¹⁰1×10⁵约 1×10¹⁰,TLE

二、根号分治:T 取 √n。预处理 O(nT),每次操作 O(T)(修改)或 O(n/T)(大参数查询)。最坏情况每一种操作都可能拉满,所以看 max(T, n/T):两者相等时取最小,T² = n,T = √n,代价 O((n+m)√n)。

代入 n = m = 1.5×10⁵、T = 387:预处理 5.8×10⁷,m 次操作各 387 → 5.8×10⁷,合计约 1.2×10⁸ 次加法。C++ 里一次简单加法约 1~2 ns,总时间 0.2 s 量级。

三、别只看渐进次数,还要看常数。莫队的每一步都要读写数组、比较下标,实际代价是纯加法的 2~3 倍。经验值:6×10⁷ 步莫队 ≈ 0.3~0.6 s;10⁸ 次纯加法 ≈ 0.2 s;而 10⁹ 次无论怎么优化都会超 1 s。

四、阈值不一定是 √n。若修改 O(T) 很贵而查询便宜(比如修改要动 T 张表),就设“修改次数 m₁、查询次数 m₂”,对 nT + m₁T + m₂·n/T 求导,得 T = √(m₂n / (n + m₁))。先写出代价函数再解,永远比背 √n 可靠。

举一反三 · 想清楚这些再往下走
  1. 莫队必须是离线算法。如果询问强制在线(每问的输入依赖上一问的答案),莫队还能用吗?替代方案是什么?
  2. 如果 add 是 O(1) 但 del 是 O(log n)(比如维护区间内不同数的和),块长还取 n/√m 吗?代价函数该怎么写?
  3. 带修改的莫队(三维莫队)多一维“时间”,三个指针怎么排序?复杂度会从 O(n√m) 变成多少?
  4. 区间最大值能不能用普通莫队?“只加不删”的回滚莫队是怎么绕开这个限制的?
  5. 根号分治的两种形态(按参数、按出现次数)分别适合什么样的题干信号?各举一个你能想到的场景。
  6. 树上路径的颜色种数问题,怎么把“树上的路径”变成“序列上的区间”,从而直接套莫队?
自测:能全部回答再进下一节
  • 能不看资料写出莫队的排序 + 四个 while,并说清 L、R 为什么初值是 1 和 0。
  • 能推出 add 时 Σcᵢ² 的增量是 2c+1,并写出对称的 del。
  • 能解释块长为什么取 n/√m,并说出 m ≫ n 时块长会怎么变。
  • 能说清奇偶优化省的是哪一段位移,以及写反了会有什么症状。
  • 能在 P3396 里说清 T 为什么取 √n,修改时为什么要动全部 T 张小表。
  • 能说出根号分治两种形态各按什么分治,以及各自的复杂度来源。
挑战题 · 数列找不同
洛谷 P3901普及/提高-去洛谷做这题 ↗

给定长度为 N 的数列 A₁…A_N,以及 Q 个询问 (Lᵢ, Rᵢ),每次询问 A[Lᵢ..Rᵢ] 这一段是否互不相同。

输入格式:第一行两个整数 N、Q;第二行 N 个整数;接下来 Q 行,每行两个整数 Lᵢ、Rᵢ。
输出格式:每个询问输出一行,Yes 或 No。

数据范围:1 ≤ N, Q ≤ 10⁵,1 ≤ Aᵢ ≤ N,1 ≤ Lᵢ ≤ Rᵢ ≤ N。

引导性提示(先只看这里)

“互不相同”不需要知道每个数出现几次,只需要知道一件事:有没有哪个值出现了两次以上。

维护一个计数器 bad,表示“当前区间里出现次数 ≥ 2 的值有几个”。区间合法 ⟺ bad == 0。

加入一个值时 bad 什么时候加 1?删除一个值时 bad 什么时候减 1?想清楚这两个临界点(都是 1 和 2 之间),剩下全是模板。

想看思路了?点开完整思路

为什么可以直接用普通莫队。需要维护的量是“有多少个值重复了”,它在加入/删除一个元素时只可能变化 1,O(1) 更新,满足莫队的前提。

状态。cnt[v]:值 v 在当前区间的出现次数;bad:满足 cnt[v] ≥ 2 的 v 的个数。

add(p)。先 ++cnt[a[p]];若恰好变成 2,说明这个值刚刚跨过“只出现一次”的界线,++bad。次数从 2 变 3 时 bad 不变——它数的是“重复的值有几个”,不是“重复的对有几对”。

del(p)。先判断 cnt[a[p]] == 2:若成立,删掉它之后这个值不再重复,--bad;再 --cnt[a[p]]。顺序不能反,减完就分不清 1 和 2 了。

答案。bad == 0 ? Yes : No。按块排序 + 四个 while 挪指针,总移动 O(N√Q) ≈ 6×10⁷,每次 O(1)。

另解。离线按右端点排序 + 树状数组可以做到 O((N+Q) log N),更快但要另想一层;本题用莫队练手正好。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int N = 100005;
int n, m, B;
int a[N], cnt[N];
int bad;                                    // 出现次数 >= 2 的值的个数
int ans[N];

struct Query { int l, r, id; } q[N];

bool cmp(const Query &x, const Query &y) {
    int bx = x.l / B, by = y.l / B;
    if (bx != by) return bx < by;
    return (bx & 1) ? x.r > y.r : x.r < y.r;     // 奇数块 r 倒序,省一趟回程
}

void add(int p) {
    if (++cnt[a[p]] == 2) ++bad;             // 1 -> 2:这个值开始重复
}

void del(int p) {
    if (cnt[a[p]] == 2) --bad;               // 2 -> 1:删掉后不再重复
    --cnt[a[p]];
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> m;
    for (int i = 1; i <= n; ++i) cin >> a[i];
    B = max(1, (int)(n / sqrt(m)));          // 理论最优块长 n/√m
    for (int i = 1; i <= m; ++i) { cin >> q[i].l >> q[i].r; q[i].id = i; }
    sort(q + 1, q + m + 1, cmp);

    int L = 1, R = 0;
    for (int i = 1; i <= m; ++i) {
        while (L > q[i].l) add(--L);
        while (R < q[i].r) add(++R);
        while (L < q[i].l) del(L++);
        while (R > q[i].r) del(R--);
        ans[q[i].id] = (bad == 0);
    }

    for (int i = 1; i <= m; ++i) cout << (ans[i] ? "Yes" : "No") << "\n";
    return 0;
}
关键片段拆解
片段作用
if (++cnt[a[p]] == 2) ++bad;只在 1 → 2 这一刻计数。bad 记“重复的值有几个”,不是“重复的对有几对”。
if (cnt[a[p]] == 2) --bad;必须在 --cnt 之前判断:减完就再也分不清 1 和 2 了。
ans[q[i].id] = (bad == 0)区间互不相同 ⟺ 没有任何值的出现次数达到 2。存 0/1 便于最后输出。
B = max(1, (int)(n / sqrt(m)))N = Q = 10⁵ 时约 316。取 max 是为了防 B 算成 0——B = 0 会让 x.l / B 直接除零崩掉。
sort 后再回填 ans排序打乱了询问顺序,答案必须按原编号存回,否则输出顺序全错。
四个 while 的顺序先扩后缩只是习惯;真正要求的是 add/del 与指针移动方向严格对应,写错会统计到区间外的元素。
这题最容易错的地方
  • 把 bad 记成“重复对数”(每次 add 都加 cnt−1):删除时无法对称还原 → WA,而且 bad 会到 O(N²) 级别。
  • del 里先 --cnt 再判断是否等于 2:临界点永远碰不到,bad 只增不减 → 从某个询问开始全输出 No。
  • 块长取 1 或取 N:总移动退化到 O(N²) → TLE,小数据却看不出问题。
  • 答案按排序后的顺序输出而不是按 id 回填 → WA,而且错得很隐蔽:数值全对,只有顺序错。
  • cnt 数组按“最大询问长度”开而不是按值域开:Aᵢ 可以等于 N,必须开 N+1。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》0x40 分块与莫队
  • 《算法竞赛黄金法则:提高算法和思考力的 77 项技巧》关于“均摊与分块”的章节
  • 洛谷 P3396、P2709 题解区的高赞实现
58 博弈论:SG 函数与公平组合游戏提高

58 · 博弈论:SG 函数与公平组合游戏

把「谁赢」变成一个整数:必败态的递归定义给出判据的形状,Nim 的异或和是它的第一个实例,SG 函数把任意公平组合游戏压成一个 Nim,反尼姆只改一句「最后一手算输」就能推出新的判据。
提高预计 65 分钟NimSG必胜态
本节出现的新函数 / 新语法(先看懂再用)

这一类游戏可以用三句话定义清楚:两个人轮流走;每一步能走哪些只取决于当前局面(不看历史、不分身份,两人面对的选项完全相同);走不动的人输。满足这三条的叫「公平组合游戏」(Impartial Combinatorial Game,下文写 ICG)。这一节只做一件事:给每个局面算出一个整数,用它一眼判断谁赢。

为什么算得出来?因为「必败」本身是递归定义的:走不动的局面必败;能一步走到必败局的局面必胜;所有后继都必胜的局面必败。这三句话从终点往前把整个局面图推了一遍,所以每个局面的胜负唯一确定,且只依赖它的后继。

Nim 的判据是「所有堆的异或和是否为 0」;SG 定理说任何公平组合游戏的和都不是新东西,它就是一个 Nim。
局面条件结论记号
无路可走(空局)没有合法步必败P 态
存在一个后继是必败至少一条路通向 P 态必胜N 态
所有后继都是必胜怎么走都把 N 态送给对方必败P 态

先手推一个能一眼看穿的例子:一堆 n 个石子,每人每次取 1~3 个,取完者胜。n = 0 必败;n = 1、2、3 可以一次取完,必胜;n = 4 时无论取几个,都留给对方 3、2、1 个(全是必胜态),所以必败。照此推下去,胜负按 4 循环:必败 ⟺ n 是 4 的倍数。这个「先手的所有选择都把人送进必胜态,所以自己必败」的推理,就是整节的地基。

本节三块骨头:异或(Nim 判据的运算)、mex(SG 值的算法)、SG 定理(把任意 ICG 变成 Nim)。先把它们拆开看清。

新函数拆解 · 按位异或 ^
long long s = 0; for (int i = 1; i <= n; ++i) s ^= a[i]; // s == 0 表示必败
组成含义 / 格式要求
a ^ b逐位异或:同一位上两数相同得 0,不同得 1。5 ^ 3 = 101₂ ^ 011₂ = 110₂ = 6。
s ^= x把 x 异或进累积量,等价于 s = s ^ x。求全部堆的异或和只要这一个循环。
x ^ x = 0自反:任何数异或自己得 0。所以「同一个数出现两次就抵消」,这是异或能表达「配对」的根源。
x ^ 0 = x0 是单位元。空局的异或和是 0,正好对上「走不动必败」。
交换律、结合律异或和与堆的顺序无关,可以边读边算,不需要存数组。
没有进位1 + 1 = 2,但 1 ^ 1 = 0。异或就是「不进位的加法」,各二进制位互不影响,所以下文的证明可以逐位讨论。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int main() {
    cout << (5 ^ 3) << "\n";            // 6
    cout << (2 ^ 2) << "\n";            // 0:自己异或自己
    cout << (1 ^ 1 ^ 1) << "\n";        // 1:奇数个 1 留下 1

    int a[] = {1, 2, 3};
    int s = 0;
    for (int x : a) s ^= x;
    cout << s << "\n";                  // 0 → 这个局面先手必败
    return 0;
}
注意

^ 的优先级低于 ==。写 if (s ^ a[i] == 0) 会被解析成 s ^ (a[i] == 0) —— 编译通过、含义全错 → WA。想判 s ^ a[i] 是否为 0,必须打括号。

异或能做的只有「判奇偶 / 配对 / 抵消」这一类事。看到「出现两次」「抵消」「按位独立」就要想起它。

新函数拆解 · mex(最小未出现的非负整数)
int mex(vector<int> s); // 返回最小的、不在 s 里的非负整数
组成含义 / 格式要求
定义mex(S) = 最小的不属于 S 的非负整数。{0,1,3} → 2;{1,2} → 0;{} → 0;{0,1,2} → 3。
为什么需要它SG 值要回答「这个局面一步能走到哪些 SG 值」。能走到的集合是 S,最小的走不到的值就是当前局面的 SG —— 于是「能不能走到」被编码进了一个数。
实现思路排序去重,然后从 0 开始扫:遇到第一个「断档」就停,那个缺口就是答案。
容量mex 一定不超过集合大小:k 个不同元素最多占住 0..k−1,第 k 个位置必然空着。所以标记数组开「后继个数 + 1」就够。
必须去重重复元素不影响 mex,先 unique 再扫,逻辑最简单。
多组数据要清空共用一个标记数组却忘记清零,上一组的标记会污染这一组 → WA,且只在大数据上错。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int mex(vector<int> s) {
    sort(s.begin(), s.end());
    s.erase(unique(s.begin(), s.end()), s.end());   // 去重,顺序无关
    int m = 0;
    for (int x : s) {
        if (x == m) ++m;        // 0、1、2…… 连续被占住
        else break;             // 断档了,缺口就是答案
    }
    return m;
}

int main() {
    cout << mex({0, 1, 3}) << "\n";   // 2
    cout << mex({1, 2})    << "\n";   // 0
    cout << mex({0, 1, 2}) << "\n";   // 3
    return 0;
}
注意

负数不在 mex 定义域内。集合若是 {-1, 0},答案仍是 1 —— 扫的时候把负数当成不存在。

mex 是算 SG 的唯一工具。记不住 SG 的定义时,记住「能走到的那些 SG 值组成集合,取 mex」就够了。

新函数拆解 · SG 函数与 SG 定理
int sg[N]; // sg[x] = mex{ sg[y] : x 能一步走到 y } int g = sg[x1] ^ sg[x2] ^ ...; // g == 0 表示整个局面必败
组成含义 / 格式要求
怎么算SG 是 DAG 上的 DP:先算出所有后继的 sg,再取 mex。按「步数」或「数值」从小到大推。
初值走不动的局面没有后继,集合为空,mex{} = 0。
单个游戏sg(x) = 0 ⟺ x 必败。这就是「一个整数代替一整个局面」的全部意义。
SG 定理若干个互不干扰的子游戏同时进行时,整体必败 ⟺ 各子游戏 sg 值的异或和为 0。任意 ICG 的和都等价于一个 Nim。
Nim 是特例一堆 n 个石子能走到 0..n−1 全部局面,它们的 sg 正好是 0..n−1,mex = n。所以单堆的 sg 就是石子数,异或和就是 Nim 判据 —— 不是巧合,是同一个定理。
打表找周期后继的偏移量只有有限几种,sg 序列几乎总会很快进入周期。找到周期后,即使 n 到 10¹⁸ 也能 O(1) 算出来。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int sg[35];

int main() {
    for (int x = 1; x <= 30; ++x) {
        bool seen[4] = {false};                  // 后继只有 3 个,mex <= 3
        for (int k = 1; k <= 3 && k <= x; ++k)   // 可以取走 1、2、3 个
            seen[sg[x - k]] = true;
        int m = 0;
        while (seen[m]) ++m;
        sg[x] = m;
    }
    for (int x = 0; x <= 12; ++x) cout << sg[x] << " ";   // 0 1 2 3 0 1 2 3 ...
    return 0;
}
注意

seen 的下标是 SG 值,不是石子数。开多大取决于「后继个数」而不是 N。

取法变多时 seen 要跟着变大。保守写法是开 64,或者直接用 set(慢一点但不会越界)。

第一个能完整对上上面三块骨头的题就是 Nim 模板。它只有 12 行,但包含了整节的判据。

例题 · 【模板】Nim 游戏洛谷 P2197普及/提高-去洛谷做这题 ↗

甲、乙两人玩 Nim 取石子游戏:地上有 n 堆石子,每人每次可从任意一堆里取出任意多枚丢掉,可以取完,不能不取,每次只能从一堆里取。最后没石子可取的人输。甲先手。

输入格式:本题有多组测试数据。第一行一个整数 T。接下来每两行是一组数据:第一行一个整数 n,表示有 n 堆石子;第二行 n 个非负整数,表示每堆的数量。

输出格式:共 T 行。若这组数据存在先手必胜策略,输出 Yes,否则输出 No。

数据范围:1 ≤ T ≤ 10,1 ≤ n ≤ 10⁴,每堆石子数不超过 2³¹ − 1。

输入: 3 2 1 1 3 1 2 3 2 1 2 输出: No No Yes
从两堆推起,猜出判据再证明

暴力为什么不行。一个局面就是各堆数量构成的多重集,n = 10⁴ 时局面数是指数级的,不可能把状态图建出来。但每次只能让某一堆变小,这个偏序关系提示:胜负一定有一个 O(n) 的封闭判据。

先看一堆。先手一次取完,必胜。除了 n = 0,全是 N 态。

再看两堆 (a, b)。a = b 时必败:先手从一堆取走多少,后手就在另一堆取走同样多,局面永远回到「两堆相等」,最后一手一定属于后手。a ≠ b 时先手把多的那堆减到和少的相等,把必败态直接递过去。所以两堆的判据是「是否相等」。

猜。把「相等」翻译成二进制:a = b ⟺ a ^ b = 0。于是猜:所有堆异或和为 0 时先手必败。

证明只需两条,正好对应必败态定义的两个方向。

  • ① 异或和 s = 0 时,任何一步都会把 s 变成非 0。设把 aᵢ 取成 aᵢ′ < aᵢ,新异或和 = s ^ aᵢ ^ aᵢ′ = aᵢ ^ aᵢ′。若它为 0 则 aᵢ = aᵢ′,与「必须取走至少一枚」矛盾。所以所有后继都是 N 态 → 本局面必败。
  • ② 异或和 s ≠ 0 时,一定存在一步把它变成 0。设 s 的最高位是第 k 位,那么至少有一堆 aᵢ 的第 k 位是 1(否则 s 的第 k 位不可能是 1)。令 aᵢ′ = aᵢ ^ s:aᵢ 与 s 的最高位都是 k,异或后第 k 位变 0、更高位不变,所以 aᵢ′ < aᵢ,这一步合法;取完后的异或和 = s ^ aᵢ ^ aᵢ′ = s ^ s = 0 → 存在一个 P 态后继 → 本局面必胜。

闭环。空局的异或和是 0 且必败,符合判据;上面两条说明「异或和 0 的局面只能走向非 0」「非 0 的局面总能走向 0」,与必败态/必胜态的递归定义逐条对齐,所以判据对任意局面成立。

复杂度。每组 O(n),全部数据 O(Σn ≤ 10⁵),就是一个异或循环。

完整可编译代码(C++17)
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int T;
    cin >> T;
    while (T--) {
        int n;
        cin >> n;
        long long s = 0;                 // 每组开头必须清零,否则会带上上一组的数据
        for (int i = 0; i < n; ++i) {
            long long x;
            cin >> x;
            s ^= x;                      // 边读边异或,不需要存数组
        }
        cout << (s ? "Yes" : "No") << "\n";   // s 非 0 → 先手必胜
    }
    return 0;
}
逐行拆解
代码片段含义与理由
long long s = 0异或的单位元。放在 while 内部,保证每组独立计算。
long long x; cin >> x每堆不超过 2³¹−1,用 long long 读,避开 int 上限附近的所有麻烦。
s ^= x把这一堆并进异或和。异或可交换,所以读取顺序无所谓。
cout << (s ? "Yes" : "No")s 非 0 即必胜。三目运算符直接给出两种输出,不需要 if。
while (T--)跑 T 次,T 归零后结束。注意 T 被用掉了,循环里不能再拿它当数据范围。
不需要开数组答案只依赖一个异或和,10⁴ 个数读完就能丢,空间 O(1)。

Nim 判据能成立,是因为「一堆 n 个」的 SG 值恰好等于 n。换句话说,Nim 的每一堆本身就是一个「等价于 n 个石子的游戏」。一旦取法改了,这个等价关系就断,判据必须换。

把取法改掉:SG 定理怎么救场

改一个字。同样的 n 堆石子,但每次只能取走 1、3、4 枚(取 2 枚不合法)。这时「一堆 n 个 = 一个数 n」不再成立:一堆 2 个只能取 1 枚,一堆 3 个能取到 0 或 2,它们能到达的局面集合和「n 个」完全对不上,异或判据直接用就错。

正确的做法分两步。第一步,只考虑单独一堆,用 mex 把它压缩成一个 SG 值 sg[n];第二步,把所有堆的 sg 值异或起来,为 0 则必败。这就是 SG 定理,Nim 只是其中 sg[n] = n 的特例。

为什么可以先算单堆。各堆之间互不影响(每次只动一堆),所以整个游戏是「单堆游戏的直和」。SG 定理正是说:直和的 SG 值 = 各部分 SG 值的异或。

打表看形状。取法 {1,3,4} 的 sg 序列是 0, 1, 0, 1, 2, 3, 2, 0, 1, 0, 1, 2, 3, 2, 0, …,从 n = 1 起按 [1,0,1,2,3,2,0] 循环,周期 7。即 sg(n) = tab[(n−1) mod 7](n ≥ 1)。

周期的用处。有了周期,n 到 10¹⁸ 也只要一次取模。这是博弈题最常见的提速手段:先打表 200 项,人眼找周期,再验证。验证的依据很硬:每一项只依赖前面 max(取法) 项,所以只要连续 T 项(T ≥ max(取法))重复,后面的项就照抄前面的,自动循环下去。

单堆 SG 打表(取法 1、3、4)
#include <bits/stdc++.h>
using namespace std;

const int N = 60;
int sg[N];

int main() {
    int mov[] = {1, 3, 4};               // 一次只能取走 1、3、4 枚
    int cnt = 3;

    for (int x = 1; x < N; ++x) {
        bool seen[8] = {false};          // 后继最多 cnt 个,mex 不会超过 cnt
        for (int j = 0; j < cnt; ++j)
            if (mov[j] <= x) seen[sg[x - mov[j]]] = true;   // 标记所有能到达的 sg
        int m = 0;
        while (seen[m]) ++m;             // 找最小的缺口
        sg[x] = m;
    }

    for (int x = 0; x < 30; ++x) cout << sg[x] << " \n"[x == 29];
    // 输出:0 1 0 1 2 3 2 0 1 0 1 2 3 2 0 1 0 1 2 3 2 0 1 0 1 2 3 2 0 1
    return 0;
}
逐行拆解
代码片段含义与理由
mov[] = {1, 3, 4}合法的「一次取走数量」。改这一行就能换成任何取法,其余代码不动。
for (int x = 1; x < N; ++x)按 x 从小到大推。x 的后继是 x−mov[j],都更小,所以一定已经算过。
if (mov[j] <= x)x 不够取时这一步不合法,必须跳过;否则 sg[x - mov[j]] 的下标会变负 → RE。
seen[sg[x - mov[j]]] = true记下「能一步到达这些 sg 值」。注意下标是 sg 值,不是石子数。
while (seen[m]) ++m从 0 往上找第一个没被标记的数,就是 mex。
结束后的周期从 n = 1 起 7 个一组循环。用 tab[(n-1) % 7] 就能 O(1) 算任意 n。
[x == 29]把布尔值当数组下标:最后一项取 '\n',其余取空格,省掉一个 if。
反尼姆:只改一句「最后一手算输」

上面所有结论都建立在 normal play(取走最后一枚的人赢)上。改成 misère play(取走最后一枚的人输,也就是反尼姆),Nim 判据整体失效:(1,1) 从必败变必胜,(1) 从必胜变必败。原因很直白——步数不再能任意拖长,最后一步成了负担。

局面正常 Nim(最后一手赢)反尼姆(最后一手输)
所有堆都不超过 1 枚异或和 = 0 → 败堆数为奇数 → 败
存在某堆至少 2 枚异或和 = 0 → 败异或和 = 0 → 败
(1, 1)败胜
(1, 1, 1)胜败
(2, 2)败败
(1, 2)胜胜

修正后的判据(SJ 定理)只有一句:先手必胜 ⟺ (所有堆都不超过 1 枚 且 异或和 = 0)或(存在某堆 ≥ 2 枚 且 异或和 ≠ 0)。

反尼姆判据
// s = 所有堆的异或和,big = 是否存在某堆 >= 2 枚
bool antiWin(long long s, bool big) {
    if (!big) return s == 0;      // 全是 0/1:偶数个 1 先手胜,奇数个 1 先手败
    return s != 0;                // 有大堆时,与正常 Nim 完全一致
}

两类局面要分开讨论,是因为「最后一手输」只影响收尾阶段。只要场上还有一堆 ≥ 2,胜负手由异或和决定;等场上全是 1,就变成纯粹的奇偶问题,谁被迫拿走最后一枚谁输。

全零局面要按题目约定处理。反尼姆的递推把「无子可取」理解成上一手取走了最后一枚,所以函数对全零返回 true。若题目规定「初始为空则先手直接输」,就单独加一句判断——这类边界值不值得花时间争论,读题时看清约定即可。

这一节的 7 个坑,按症状记

用 sum 代替异或当判据 → WA。样例 (1,2,3) 的和是 6、异或和是 0,两种算法结论相反;小数据有可能碰巧对,大数据全错。

写成 if (s ^ a[i] == 0):== 优先级高于 ^,编译通过但含义变成 s ^ (a[i] == 0) → WA。加括号,或者先存中间变量。

把 long long s = 0; 写在 while (T--) 外面 → 第二组起异或和混进了上一组的数据 → WA。症状非常典型:第 1 组对,后面全错。

用 int 存每堆石子。题目写「不超过 2³¹−1」,那就是 int 的上限 2147483647,边界值刚好踩在线上,换一道范围稍大的题立刻溢出。统一 long long,代价只是多 4 字节。

反尼姆里用 s <= 1 代替「所有堆都不超过 1」→ WA。反例:(2,3) 的异或和是 1,但它显然不是「全是 1」的局面。必须显式扫一遍看有没有 a[i] >= 2。

输出大小写不符。本题要 Yes/No,写成 YES 或 yes 一律 WA——评测是逐字节比对,不做智能匹配。

SG 打表时 seen 开小了。取法有 5 种时 mex 可能到 5,bool seen[4] 会越界写 → RE 或莫名其妙的 WA。开「取法数 + 1」。

自己动手复现

关掉页面,把 P2197 那 20 行从零敲出来,提交确认样例输出 No / No / Yes。

改一改①:把 s ^= x 换成 s += x,只跑第 3 组 (1 2):和是 3、异或和也是 3,两种算法都给 Yes,看不出区别。再跑 (1,1):和是 2、异或和是 0,加法的答案变成 Yes,而正确是 No —— 亲手看到「判据必须是异或」。

改一改②:把 long long s = 0; 提到 while (T--) 上面再提交,观察第 2 组开始出错。这就是「多组数据没清零」的样子。

改一改③:把打表程序的 mov[] 改成 {1, 2},重新跑,你会得到 0 1 2 0 1 2 …(周期 3),正好等于 n mod 3,和「一堆取 1~2 个必败 ⟺ n 是 3 的倍数」对上。

改一改④:在打表程序里把 seen 改成 bool seen[3],mov 保持 {1,3,4},观察结果是否开始出错。越界访问是未定义行为——它可能崩,也可能悄悄给出一个错误答案,而后者更危险。

数学补给 · 为什么「最高位是 1 的那堆」一定存在

异或的核心性质是逐位独立:第 k 位的结果只取决于所有数第 k 位上有多少个 1。偶数个 → 0,奇数个 → 1。所以「异或和 = 0」等价于「每一位上 1 的个数都是偶数」。

这条性质直接给出证明里的存在性:若 s = a₁ ^ … ^ aₙ ≠ 0,取 s 最高的那个 1,设它在第 k 位。第 k 位为 1 意味着「第 k 位上是 1 的堆有奇数个」,奇数 ≥ 1,所以至少有一堆的第 k 位是 1。

再看为什么 aᵢ ^ s < aᵢ。比较两个数的大小是从高位往下比的。s 的最高位是 k,所以高于 k 的位 s 全是 0,异或后不变;第 k 位上 aᵢ 和 s 都是 1,异或得 0。于是新数在最高的「有差异的位」上比原数小,整体就小。这一步是整个证明的枢纽:它保证取走 aᵢ − (aᵢ ^ s) > 0 枚,走法是合法的。

用代数的说法:所有非负整数在「异或」下构成一个 GF(2) 上的向量空间,每个数的每个二进制位是一个坐标,异或就是逐坐标相加。「异或和为 0」= 这 n 个向量线性相关到零向量,与顺序无关,这就是为什么堆可以任意排列。

SG 定理为什么成立。设两个子游戏 G₁、G₂ 的 SG 值为 g₁、g₂,要证 SG(G₁+G₂) = g₁ ^ g₂。分两步:

  • 任何后继都拿不到 g₁ ^ g₂。一步只能动一个子游戏,若动的是 G₁,它的新 SG 值 g₁′ ≠ g₁(SG 的定义保证「能走到的值里没有自己」),由归纳假设新局面的值为 g₁′ ^ g₂ ≠ g₁ ^ g₂。
  • 比 g₁ ^ g₂ 小的每个值都拿得到。记 v = g₁ ^ g₂,设 v 的最高位是 k。若 g₁ 的第 k 位是 1,则由 SG 的定义存在 G₁ 的后继使 g₁′ = g₁ ^ v < g₁,于是新局面值 = g₁′ ^ g₂ = v;若 g₁ 第 k 位是 0,则 g₂ 的第 k 位必是 1,改去动 G₂ 即可。所以 v 的每个更小值都能到达。

两条合起来:v 的后继都 ≠ v,而 0..v−1 全都可达,所以 mex 恰为 v。多个子游戏由归纳法合并即可。Nim 就是 gᵢ = 石子数的情形。

复杂度。Nim 判定 O(n)、空间 O(1);SG 打表 O(N × 取法数),N 为最大石子数。两者都是「一次线性扫描」的量级,真正的成本在于想清楚取法与周期。

举一反三 · 想清楚这些再往下走
  1. 如果规则改成「每次可以从至多 k 堆中各取若干」,Nim 的异或判据还成立吗?(提示:一个二进制位上如果有 3 堆是 1,一次最多动 k 堆时这 3 个 1 消得干净吗?按位统计试试)
  2. 打表得到 取法 {1,3,4} 的 sg 周期是 7。那么取法 {1,2} 的周期是几?{2,3} 的周期是几?周期长度和取法集合有什么必然联系?
  3. SG 打表里 seen 的大小为什么可以只开「取法数 + 1」而不是「最大石子数 + 1」?把这个结论说清楚,你就真正理解 mex 了。
  4. 如果一个游戏会出现平局(循环走法),还能用 SG 值表示吗?为什么不能?(提示:SG 的定义依赖「局面是 DAG」这个前提)
  5. 阶梯 Nim:石子放在编号 1..n 的台阶上,每次从第 i 级取若干个放到第 i−1 级(第 0 级即地面),不能动者输。为什么它等价于「只对奇数级台阶做 Nim」?
  6. 反尼姆里 (1) 先手必败,(1,1) 先手必胜。用这两个例子解释:为什么「所有堆都不超过 1」时判据只看堆数的奇偶,而「存在大堆」时又能回到异或判据?
自测:能全部回答再进下一节
  • 能默写必败态的三条递归定义,并用它们手推出「一堆取 1~3 个,必败 ⟺ n 是 4 的倍数」。
  • 能不看书讲清 Nim 判据的两步证明,特别是「为什么 aᵢ ^ s 一定比 aᵢ 小」。
  • 能说出 if (s ^ a[i] == 0) 错在哪,并写出正确写法。
  • 能解释 SG 值为什么能代替一整个局面,以及 SG 定理里的异或从哪来。
  • 给定一套取法,能用 15 行代码打出 sg 表并人眼找出周期。
  • 能说出反尼姆判据,并解释为什么要分「所有堆 ≤ 1」和「存在大堆」两类。
挑战题 · 取火柴游戏
洛谷 P1247提高-去洛谷做这题 ↗

有 k 堆火柴,第 i 堆 nᵢ 根。两人轮流取,每次从某一堆取走若干根(可以取完整堆,不能不取,不能跨堆),谁取走最后一根谁获胜。你先手。

给定初始局面,判断先手是否必胜;若必胜,还要给出第一步该怎么取。

输入格式:第一行一个整数 k(k ≤ 5×10⁵);第二行 k 个整数 n₁ … n_k(nᵢ ≤ 10⁹)。

输出格式:先手必败时输出一行 lose;必胜时第一行输出两个整数 a、b,表示从第 b 堆取出 a 根,第二行输出取完之后的 k 个整数(空格隔开)。有多种答案时输出 b 最小的(b 相同时 a 最小)。

引导性提示(先只看这里)

胜负判据和例题完全一样,先把异或和算出来;为 0 就是 lose。

必胜时,第一步必须把局面送到「异或和为 0」。想让第 i 堆变成多少?只可能是一个唯一的值。

从第 1 堆开始往后扫,第一个「能被改小」的堆就是答案要求的 b——因为 b 要最小。别忘了输出取完之后的整副牌。

想看思路了?点开完整思路

设 s = n₁ ^ n₂ ^ … ^ n_k。s = 0 时先手必败,直接输出 lose。

s ≠ 0 时,由 Nim 的证明,一定存在堆 i 满足 nᵢ ^ s < nᵢ。把第 i 堆改成 nᵢ ^ s,新的异或和就是 s ^ nᵢ ^ (nᵢ ^ s) = 0,把必败态送给了对方。

要取多少根:取走 nᵢ − (nᵢ ^ s) 根。这个数一定为正,因为 nᵢ ^ s < nᵢ。

为什么最小的 b 就够:要让异或和归零,其余 k−1 堆不动时,第 i 堆的新值被唯一确定为 nᵢ ^ s。所以「b 固定」时取法只有一个,题目要求的「b 最小优先、a 其次」就退化成「找第一个合法的 i」。

复杂度:找 i 是 O(k),输出是 O(k),总 O(k),k = 5×10⁵ 完全够。数组要开到 5×10⁵ 以上。nᵢ ≤ 10⁹ 用 int 也装得下,但统一 long long 能省掉「这里要不要转换」的思考成本。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int N = 500005;
int k;
long long a[N];

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> k;
    long long s = 0;
    for (int i = 1; i <= k; ++i) { cin >> a[i]; s ^= a[i]; }

    if (s == 0) {                          // 异或和为 0:先手没有必胜走法
        cout << "lose\n";
        return 0;
    }

    for (int i = 1; i <= k; ++i) {
        if ((a[i] ^ s) < a[i]) {           // 第 i 堆能变到 a[i]^s,并且确实变小了
            long long take = a[i] - (a[i] ^ s);
            a[i] ^= s;                     // 改完后全局异或和变 0
            cout << take << " " << i << "\n";
            for (int j = 1; j <= k; ++j) cout << a[j] << " \n"[j == k];
            return 0;
        }
    }
    return 0;
}
关键片段拆解
片段作用
s ^= a[i]边读边算异或和。判据只需要这一个量。
if (s == 0) { cout << "lose"; return 0; }必败局没有第一步可输出,直接结束。注意不是输出 0。
(a[i] ^ s) < a[i]同时完成两件事:检查这一堆能不能改小(合法性),以及保证找到的是最小的 i。a[i] = 0 时右边是 s > 0,条件不成立,自动跳过空堆。
take = a[i] - (a[i] ^ s)取走的根数。因为 a[i]^s 严格小于 a[i],这个数一定为正。
a[i] ^= s先改再输出,否则打印的是原始局面。这一步就是把异或和清零。
cout << take << " " << i题目要的是「数量 堆号」,顺序反了就 WA。
" \n"[j == k]最后一项输出换行、其余输出空格,避免行末多一个空格。
这题最容易错的地方
  • 判据写成了 sum 或者干脆只比大小:样例恰好能过,提交全 WA。
  • 找到堆之后忘记 a[i] ^= s,把原始局面输出了 → WA,而且第一行是对的,很难发现。
  • 输出顺序写成「堆号 数量」→ WA。题目第一行要的是 a b,a 是取走的根数。
  • 数组开到 10⁵(题目 k 到 5×10⁵)→ 越界 RE。
  • 把 (a[i] ^ s) < a[i] 写成 a[i] > s:两者完全不等价,会挑到不合法的堆,改完异或和不为 0 → WA。
  • 必败时输出 Lose 或 lose (多了空格)→ WA,逐字节比对。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》0x3A 博弈论
  • 《算法竞赛黄金法则:提高算法和思考力的 77 项技巧》中的不变量与对称论证
  • 洛谷 P2197、P1247 题解区关于 SG 定理与反尼姆的推导
59 计算几何入门:向量与凸包提高

59 · 计算几何入门:向量与凸包

把图形翻译成代数:点积判角度、叉积判方向与面积,再给自定义类型重载运算符,最后用一个单调栈把 n 个点的最外圈(凸包)在 O(n log n) 内扫出来,并顺手拿到面积、点内判定与极角排序。
提高预计 70 分钟向量叉积凸包
本节出现的新函数 / 新语法(先看懂再用)

计算几何的第一道坎不是算法,而是把图形翻译成代数。这件事只需要两个式子:点积管角度,叉积管方向。把这两个写好,「有没有拐弯」「在左边还是右边」「这块面积是多少」全都变成几次乘法和减法。

出发点是:把点当成向量。点 A、B 的差 A − B 是一条有向线段;两点的「位置」本身没有意义,只有「差」才有。于是整节代码里点与向量共用一个类型,减法得到向量,向量加减还得到向量。

叉积的符号就是「左拐还是右拐」,大小就是「面积的两倍」。凸包算法只是把这一句话重复 n 次。
运算定义几何意义典型用途
加法 / 减法(ax±bx, ay±by)平移、两点连线把点搬到原点看问题
点积 dotax·bx + ay·by|a||b|cosθ夹角、垂直判定、投影长度
叉积 crossax·by − ay·bx|a||b|sinθ(有符号)转向、共线、面积
长度 lensqrt(dot(a, a))|a|周长、两点距离
cross(b−a, c−a)—三点转向 / 三角形有向面积 ×2凸包的全部判断

本节要新学三样:结构体重载运算符(让自定义类型像 int 一样写)、点积与叉积(两个式子撑起全部几何)、sgn 与 EPS(浮点比较的唯一正确姿势)。

新函数拆解 · 结构体重载运算符
struct Vec { double x, y; Vec operator-(const Vec& b) const { return {x - b.x, y - b.y}; } bool operator<(const Vec& b) const { return x != b.x ? x < b.x : y < b.y; } };
组成含义 / 格式要求
struct Vec { double x, y; }把「点」和「向量」统一成同一个类型。两点之差是向量,向量还叫点——计算几何里不区分这两个概念,共用一个类型最省事。
Vec operator-(const Vec& b) const成员函数版重载:编译器把 a - b 翻译成 a.operator-(b)。返回类型写成 Vec,所以 b - a 还能继续参与运算。
const Vec& bconst 引用传参:不拷贝,并承诺不修改 b。漏掉 const,对 const 对象调用会编译失败。
结尾的 const承诺这个函数不修改 *this。漏了它,对 const Vec 调用会报错;写了没有坏处。
return {x - b.x, y - b.y};花括号初始化,等价于 Vec{x - b.x, y - b.y},C++11 起可用。
bool operator<(const Vec& b)给 sort 用:先比 x,x 相等再比 y。凸包的第一步就是按这个顺序排序,运算符写反整份代码都错。
重载与 cmp 只能选一个sort(v.begin(), v.end()) 用 operator<,sort(v.begin(), v.end(), cmp) 用 cmp。两处定义不一致 → 凸包结果随机。
为什么一定要 operator<Andrew 算法的正确性依赖「按 x 单调」的顺序。没有这个顺序,扫描时弹出的点可能是该留的。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

struct Vec {
    double x, y;
    Vec operator-(const Vec& b) const { return {x - b.x, y - b.y}; }
    bool operator<(const Vec& b) const { return x != b.x ? x < b.x : y < b.y; }
};

int main() {
    Vec a{3, 4}, b{1, 1};
    Vec c = a - b;                       // (2, 3)
    cout << c.x << " " << c.y << "\n";

    vector<Vec> v{{2, 1}, {1, 5}, {1, 2}};
    sort(v.begin(), v.end());            // 先按 x,再按 y
    for (auto p : v) cout << "(" << p.x << "," << p.y << ") ";
    // (1,2) (1,5) (2,1)
    return 0;
}
注意

比较器里不要塞 EPS。写成 fabs(x - b.x) > EPS ? ... : ... 会破坏严格弱序,libstdc++ 的 sort 在极端数据上可能越界 → RE。要判浮点相等,用 sgn 写在算法里,别放进比较器。

二元减法是 operator-;一元负号是 operator-()(无参)。两者能共存,别写混。

新函数拆解 · 点积 dot 与叉积 cross
double dot(Vec a, Vec b) { return a.x * b.x + a.y * b.y; } double cross(Vec a, Vec b) { return a.x * b.y - a.y * b.x; }
组成含义 / 格式要求
dot 的符号> 0 锐角,= 0 垂直,< 0 钝角。判垂直就用它,因为 dot = 0 恰好等价于垂直。
dot(a, a)等于 |a|²。开根号才得到长度:sqrt(dot(a, a))。
cross 的符号> 0:b 在 a 的逆时针一侧(左转);< 0:顺时针一侧(右转);= 0:共线(同向或反向)。凸包的全部判断都是这一句。
三点转向cross(b - a, c - a) 判断「沿 a→b 走到 c,是左拐还是右拐」。左拐为正、右拐为负、共线为 0。注意两个向量都从同一个点出发。
cross 的绝对值等于以 a、b 为邻边的平行四边形面积。三角形 abc 的面积 = fabs(cross(b - a, c - a)) / 2。
反对称cross(a, b) = -cross(b, a),所以「转向」带正负号;dot(a, b) = dot(b, a),所以「夹角」没有方向。
量级坐标到 10⁶ 时叉积到 10¹²,int 必炸。整数坐标用 long long,实数坐标用 double。
最小可运行示例
示例
Vec A{0, 0}, B{2, 0}, C{1, 1};
double s = cross(B - A, C - A);              // 2*1 - 0*1 = 2 > 0,A->B->C 是左转
cout << s << "\n";                           // 2
cout << fabs(s) / 2 << "\n";                 // 1:三角形面积
cout << dot(Vec{1, 0}, Vec{0, 1}) << "\n";   // 0:垂直
cout << dot(Vec{1, 0}, Vec{1, 1}) << "\n";   // 1 > 0:锐角
注意

两个式子都是「两次乘法、一次加减」,几乎不要钱。看到角度、旋转、面积、方向,先想这两个。

cross 的顺序不能换:ax·by − ay·bx。手滑写成 ax·bx − ay·by 就是点积,程序照跑,结果全错。

新函数拆解 · sgn 与浮点比较
const double EPS = 1e-9; int sgn(double x) { return x > EPS ? 1 : (x < -EPS ? -1 : 0); }
组成含义 / 格式要求
为什么不写 x > 0double 用二进制存十进制小数,0.1 存不准。叉积是「两个乘积相减」,两个 10¹² 量级的数相减会带上量级 10⁻⁴ 的误差。直接拿 0 当分界,会把共线判成左转。
EPS 取多大跟着数量级走。坐标 ≤ 10⁶ 时叉积 ≤ 10¹²,double 的 53 位尾数只给 15~16 位有效数字,绝对误差约 10⁻⁴。所以本题用 1e-9 其实等于「几乎没有容差」——够用,因为它只影响共线点的取舍。
什么时候必须放大做「凸包顶点个数」「点是否在多边形内」这类数量敏感的题,EPS 得按坐标量级放大,否则边界点会随机判错。
永远不写 a == b浮点判等一律写 sgn(a - b) == 0。
输出题目要求几位就输出几位:printf("%.2f\n", ans)。不要手写 round,四舍五入交给 printf。
能避开就避开本题坐标最多两位小数,把输入 ×100 存成 long long 后叉积完全精确。这是计算几何最省心的一招:能用整数就绝不用浮点。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

const double EPS = 1e-9;
int sgn(double x) { return x > EPS ? 1 : (x < -EPS ? -1 : 0); }

int main() {
    printf("%d\n", sgn(1e-11));          // 0:当作零
    printf("%d\n", sgn(-3.0));           // -1
    printf("%d\n", sgn(2.0));            // 1

    double a = 0.1 + 0.2;
    printf("%d\n", a == 0.3);            // 0:不相等
    printf("%d\n", sgn(a - 0.3) == 0);   // 1:容差内相等
    return 0;
}
注意

EPS 不是越小越好,也不是越大越好。经验值:坐标 ≤ 10³ 用 1e-9;坐标到 10⁶ 时 1e-9 已经小到没有容差,该按「判的是量级还是个数」来定。

本题有个好处:判错只会让一个近乎共线的点留下或去掉,周长几乎不变。换成「凸包上有几个点」,同样的错判就直接 WA。

凸包是最基础的几何结构:包住所有点的最小凸多边形。它只有一道模板题,但里面每一行都在复用上面三个工具。

例题 · 【模板】二维凸包 / [USACO5.1] 圈奶牛 Fencing the Cows洛谷 P2742普及/提高-去洛谷做这题 ↗

农夫约翰要造一圈围栏,把奶牛喜欢吃草的所有地点都围进去,并且要最短。给定这些地点的坐标,求围栏的长度——也就是这些点的凸包周长。

输入格式:第一行一个整数 n,表示放牧点数目;第 2 到第 n+1 行,每行两个实数 xᵢ、yᵢ,表示第 i 个点的坐标。

输出格式:一行一个实数,四舍五入保留两位小数,表示围栏长度。

数据范围:3 ≤ n ≤ 10⁵,−10⁶ ≤ xᵢ, yᵢ ≤ 10⁶,坐标小数点后最多两位。

自造样例(6 个点,其中两个在矩形内部): 输入: 6 0 0 4 0 4 3 0 3 2 1 1 2 输出: 14.00
凸包 = 上下两条单调链,各扫一遍

凸包是什么。包住所有点的最小凸多边形。所有点要么在它内部,要么在它边界上。求周长就是把这个多边形的边长加起来。

先排序。把所有点按 (x, y) 字典序排好。最左和最右的点一定在凸包上。于是凸包被这两个点切成上下两条链,每条链都是从最左走到最右的单调折线。

下链怎么走。从左往右依次加点,用单调栈维护当前的下链。新点 c 加入时,检查「栈顶 b、栈顶之前一个 a」组成的三点转向 cross(b−a, c−b):如果是右转或共线(≤ 0),说明 b 被包进 a、c 里了,永远当不了凸包顶点,弹掉;反复弹到出现左转,再压入 c。

上链同理。从右往左再扫一遍,沿路同样只保留左转。两段拼起来就是完整凸包。

弹出条件为什么是 ≤ 0 而不是 < 0。取 ≤ 0 会连共线点一起弹掉,得到的是「没有多余顶点」的凸包;取 < 0 会把共线点留在链上。两者在本题等价——多点共线时那些点在同一条边上,边长的和不变。做题要看清楚考的是周长(无害)还是顶点个数(有害)。

为什么弹掉不会错。设栈顶是 b、前一个是 a、新点是 c,且 cross(b−a, c−b) ≤ 0,则 b 在 a→c 这条有向直线的右侧或线上。三角形 a、b、c 里 b 是「凹进去」的那个角,任何包含 a、c 的凸集都不会把 b 当顶点。后面加入的点都在 c 右边,b 就更没机会了。

注意 m > k。下链扫完得到 k 个点,上链的弹出条件必须是 m > k 而不是 m >= 2——否则上链会把下链刚建好的点弹光,凸包退化成两个点。这是整个算法唯一容易写错的地方。

最后一步 --m。上链一路扫到最左点,它的值和起点重复,所以数组末尾多了一个起点。去掉它,剩下的 m 个点就是凸包顶点(逆时针)。

复杂度。排序 O(n log n),两次扫描各 O(n)(每个点最多进栈一次、出栈一次)。n = 10⁵ 时总操作约 10⁶。

完整可编译代码(C++17)
#include <bits/stdc++.h>
using namespace std;

const int N = 100005;
const double EPS = 1e-9;

struct Vec {
    double x, y;
    Vec operator-(const Vec& b) const { return {x - b.x, y - b.y}; }
    bool operator<(const Vec& b) const { return x != b.x ? x < b.x : y < b.y; }
};

int n;
Vec p[N], h[N];

double cross(Vec a, Vec b) { return a.x * b.y - a.y * b.x; }
double dot(Vec a, Vec b)   { return a.x * b.x + a.y * b.y; }
double len(Vec a)          { return sqrt(dot(a, a)); }
int sgn(double x)          { return x > EPS ? 1 : (x < -EPS ? -1 : 0); }

int main() {
    scanf("%d", &n);
    for (int i = 0; i < n; ++i) scanf("%lf%lf", &p[i].x, &p[i].y);

    sort(p, p + n);                        // 凸包第一步:按 x 再按 y

    int m = 0;
    for (int i = 0; i < n; ++i) {          // 下链:从左到右,只留左转
        while (m >= 2 && sgn(cross(h[m - 1] - h[m - 2], p[i] - h[m - 1])) <= 0) --m;
        h[m++] = p[i];
    }
    int k = m;                             // 下链的点数,上链不能把它们弹掉
    for (int i = n - 2; i >= 0; --i) {     // 上链:从右到左
        while (m > k && sgn(cross(h[m - 1] - h[m - 2], p[i] - h[m - 1])) <= 0) --m;
        h[m++] = p[i];
    }
    --m;                                   // 末尾与起点重复,去掉

    double ans = 0;
    for (int i = 0; i < m; ++i)
        ans += len(h[(i + 1) % m] - h[i]); // 首尾相连,最后一条边要绕回去
    printf("%.2f\n", ans);
    return 0;
}
逐行拆解
代码片段含义与理由
struct Vec点和向量共用一个类型。后面所有 h[i] - h[j] 都是在求向量。
sort(p, p + n)依赖 operator<。排好序后「从左到右」才有意义,弹出逻辑才成立。
while (m >= 2 && ...)m ≥ 2 才够组成三点转向。不足两点时无条件压栈。
cross(h[m-1] - h[m-2], p[i] - h[m-1])三点的转向:从 h[m−2]→h[m−1] 转到 h[m−1]→p[i]。第一个向量是「上一段」,第二个是「新一段」。
sgn(...) <= 0右转或共线 → 栈顶是凹点,弹掉。注意是 ≤ 0 而不是 < 0,差一个等号决定共线点留不留。
h[m++] = p[i]当前点入栈。每个点都会被压一次,最多被弹一次,所以总操作 O(n)。
int k = m;记下下链长度。上链从这里往上长,弹出条件换成 m > k,保护下链成果。
for (int i = n - 2; i >= 0; --i)从倒数第二个点往回扫到第 0 个。n−1 已经在下链末尾,不用重复加。
--m;上链最后压进去的是最左点,与 h[0] 重复。去掉它,h[0..m−1] 才是凸包顶点。
h[(i + 1) % m]取模实现首尾相连:最后一条边从 h[m−1] 回到 h[0]。
len(h[(i+1)%m] - h[i])两点距离 = 差向量的模。整份代码只在这里出现一次根号。
凸包建好之后,能顺手做三件事

一、面积。顶点是逆时针给出的,把每个顶点当成从原点出发的向量,Σ cross(h[i], h[i+1]) 就是面积的 2 倍(鞋带公式)。这条式子和 P1183 的解法是同一个东西。

二、判断点在不在凸包里。逆时针凸包有个漂亮的等价条件:内部就是每条边的左侧交集。逐边判叉积,全部 ≥ 0 就在里面。O(m) 判一次;要更快可以按极角二分,O(log m)。

三、极角排序。Graham 扫描需要按极角排序。用 atan2 能排但慢且不准,标准做法是「先按半平面分组,组内用叉积定先后」,全部整数运算。

凸包的三个常见后续操作
double area(Vec* h, int m) {            // 顶点逆时针 → 结果为正
    double s = 0;
    for (int i = 0; i < m; ++i) s += cross(h[i], h[(i + 1) % m]);
    return s / 2;
}

bool inHull(Vec q, Vec* h, int m) {     // 点在凸包内(含边界),O(m)
    for (int i = 0; i < m; ++i)
        if (sgn(cross(h[(i + 1) % m] - h[i], q - h[i])) < 0) return false;
    return true;                        // 每条边都不在右侧 → 在里面
}

int half(Vec a) {                       // 极角排序:上半平面 0,下半平面 1
    return (a.y > 0 || (a.y == 0 && a.x > 0)) ? 0 : 1;
}
bool cmpPolar(Vec a, Vec b) {           // 极角从小到大
    if (half(a) != half(b)) return half(a) < half(b);
    return cross(a, b) > 0;             // 同一半平面内用叉积定先后
}
这三段为什么这样写
代码片段含义与理由
cross(h[i], h[(i+1)%m])两个位置向量直接做叉积 = 三角形 O、h[i]、h[i+1] 的有向面积 ×2。全部加起来,原点在外的部分正负抵消,剩下的正好是多边形面积 ×2。
return s / 2上式给的是 2 倍面积。逆时针时为正,顺时针为负,需要绝对值就加 fabs。
cross(h[(i+1)%m] - h[i], q - h[i])边向量 与 「边起点指向 q」的向量。叉积为正 = q 在这条边左侧。所有边都满足,q 就在凸包内。
< 0 就 return false只要有一条边把 q 判在右侧,q 一定在外面——凸包是凸的,方向性是整体一致的。
half(a)把平面按 x 轴正方向切成两半,先排上半、再排下半,避免 atan2 的精度与常数。
cross(a, b) > 0同一半平面内,叉积为正说明 a 的极角小于 b 的极角,a 排在前面。

注意 inHull 的 EPS。凸包边长可能很小,叉积量级跟着坐标走。用它判题之前,先按坐标范围把 EPS 调到合适大小,别照抄 1e-9。

这一节的 7 个坑,按症状记

上链的弹出条件写成 m >= 2(漏了 m > k)→ 上链会把下链的点全部弹回去,凸包退化成两点一线 → WA,而且样例有时还能过。

叉积写成 a.x * b.x - a.y * b.y(把 by 打成 bx)→ 那是点积的式子,程序照跑,结果完全不对 → WA。编译期间发现不了,只能靠手算一个三角形验证。

弹出条件写成 < 0 而不是 <= 0:共线点会留在凸包里。本题只求周长,留着不影响答案;换成「凸包面积」「凸包顶点数」就会出事。

数组开小。h 的长度会涨到 n+1(凸包最多 n 个顶点,末尾再加一个与起点重复的点),所以 p、h 都要开到 10⁵+5 以上。只开 10⁵ → 越界 RE。

在比较器里塞 EPS(fabs(x - b.x) > EPS)破坏严格弱序 → sort 越界 RE,或者顺序乱掉 WA。

用 int 读实数坐标:只读到整数部分,小数被丢掉 → WA,而样例恰好都是整数时完全看不出来。必须 double + scanf("%lf")。

所有点共线时 --m 之后 m = 2,周长 = 2 × 两端距离。看着像「算错了一倍」,其实是对的——退化凸包就是一条线段,来回各走一遍。想要别的约定就得特判。

自己动手复现

关掉页面,把 P2742 的 40 行从零敲一遍,提交确认样例输出 14.00。

改一改①:把上链的 m > k 改成 m >= 2,先预测再跑。结果是 12.00 而不是 14.00——上链把角点 (4,3) 弹掉了,凸包变成一个缺角的三角形。

改一改②:在 --m 后面加一句 cout << m << "\n";,对样例(6 个点,2 个在内部)应该看到 4。再把 <= 0 改成 < 0,喂 5 个共线的点 (0,0)(1,0)(2,0)(3,0)(4,0) 进去:m 从 2 变成 8(正着数一遍、反着再数一遍),而周长仍然是 8.00 —— 顶点数变了,答案没变。

改一改③:把 sort(p, p + n); 注释掉,用同一组数据多跑几次,观察结果乱跳——这是「顺序决定正确性」最直观的现场。

改一改④:把 scanf("%d", &n) 之后的读入改成 cin >> p[i].x,然后关掉 sync_with_stdio,对比一下 10⁵ 个点的运行时间。

数学补给 · 叉积是有向面积,鞋带公式是它的累加

把叉积写成行列式:cross(a, b) = ax·by − ay·bx = |ax ay; bx by|。行列式的绝对值等于以这两个向量为邻边的平行四边形面积,所以叉积的绝对值就是面积,而它的正负号由「b 在 a 的左边还是右边」决定。

由 a·b = |a||b|cosθ、a×b = |a||b|sinθ 两式之比可得 tanθ = cross/dot。这就是为什么「判角度」用点积、「判方向」用叉积:cos 是偶函数,分不清左右;sin 是奇函数,能分。

三点转向。取 u = b − a、v = c − a,则 cross(u, v) = |u||v|sin∠bac。sin 在 (0, π) 上为正、(π, 2π) 上为负,正好对应「c 在 a→b 左侧 / 右侧」。共线时 sin = 0。

鞋带公式怎么来。把多边形每条边 PᵢPᵢ₊₁ 和原点 O 连成三角形,有向面积 cross(Pᵢ, Pᵢ₊₁)/2 全部相加。原点在多边形外部时,有些三角形是「负」的,它们的重叠部分恰好正负抵消,总和仍然等于多边形面积。这就是为什么它对任意位置的坐标系都成立,不需要把原点搬进多边形。

叉积的双线性。cross(a, b + c) = cross(a, b) + cross(a, c),cross(ka, b) = k·cross(a, b)。鞋带公式能一项项拆开累加,靠的就是这个性质。点积同样双线性,所以内积空间那套线性代数在这里全部通用。

整数坐标的面积。坐标全是整数时,Σ(xᵢyᵢ₊₁ − xᵢ₊₁yᵢ) 是整数,所以多边形面积一定是整数或半整数。P1183 保证面积是整数,于是这个和一定是偶数,s / 2 用整数除法不会丢精度。这是「整数几何」的核心事实,也是为什么能用 long long 全程避开浮点。

精度从哪来。double 有 53 位尾数,约 15.9 位十进制有效数字。坐标 ≤ 10⁶ 时叉积 ≤ 2×10¹²,此时 1 ulp ≈ 2×10¹² / 9×10¹⁵ ≈ 2×10⁻⁴。所以在这种量级下,1e-9 的 EPS 实际上不起任何作用;真要容差就得取 10⁻³ 甚至更大,但那会让「确实很小的左转」被判成共线。想让判断可靠,正确做法是把坐标缩放到整数,而不是调 EPS。

举一反三 · 想清楚这些再往下走
  1. 如果要求输出「凸包上有几个顶点」而不是周长,代码要改哪一处?共线点应该留还是不留?两种弹法(< 0 与 <= 0)分别给出什么答案?
  2. Andrew 必须按 (x, y) 排序。Graham 扫描则要先选一个极点(一般取 y 最小、y 相同取 x 最小的点)再按极角排序。这个极点为什么一定要取「最下最左」的?换一个点当极点会错在哪?
  3. 如果所有点共线,--m 之后 m 等于几?周长输出多少?把这个值和「线段长度」比一比,解释为什么是两倍。
  4. 判断点是否在凸包内,逐边扫是 O(m)。能不能做到 O(log m)?提示:凸包顶点按极角有序,可以先二分出 q 落在哪个扇形里。
  5. 把坐标 ×100 存成 long long,叉积就完全精确。这个技巧的代价是什么?什么情况下用不了(提示:输入的小数位数没有上限时,×100 就不再精确)?
  6. 用叉积算多边形面积时,如果顶点是按顺时针给出的,结果会怎样?写一行代码让它永远为正,并说清为什么取绝对值是安全的。
自测:能全部回答再进下一节
  • 能不看资料写出 struct Vec 的 operator- 和 operator<,并说清两个 const 各约束什么。
  • 能手算 cross((2,0), (1,1)) 的符号,并说出 A(0,0)、B(2,0)、C(1,1) 这三点的转向。
  • 能讲清 Andrew 算法里 m > k 的作用,以及漏掉它会退化成什么形状。
  • 能解释为什么弹出条件是叉积 ≤ 0,以及取 ≤ 0 与 < 0 在「周长」和「顶点数」两题里的差别。
  • 能说出 --m 在删哪个点,不删会导致什么后果。
  • 能写出鞋带公式并用它算一个给定多边形的面积,说清为什么最后才除以 2。
挑战题 · 多边形的面积
洛谷 P1183普及-去洛谷做这题 ↗

给出一个没有缺口的简单多边形,它的每条边都平行于坐标轴之一。顶点按逆时针方向逐个给出,坐标都是整数,因此面积也是整数。注意可能存在连续三个顶点共线。

求这个多边形的面积。

输入格式:第一行一个整数 n,表示顶点数;接下来 n 行,每行两个整数 x、y,表示一个顶点。多边形最后由最后一个顶点连回第一个顶点封闭。

输出格式:一行一个整数,表示面积。数据范围:1 ≤ n ≤ 100,−200 ≤ x, y ≤ 200。

引导性提示(先只看这里)

顶点是逆时针给的,这个顺序本身就是信息。想想「每条边和原点组成的三角形,有向面积加起来」会得到什么。

三角形 OAB 的有向面积 = cross(A, B) / 2,其中 cross(A, B) = Ax·By − Ay·Bx。把它对每条边累加。

累加出来的和是面积的两倍,一定是偶数。所以最后再做一次除以 2,过程中不要提前除。

想看思路了?点开完整思路

这是鞋带公式(Shoelace Formula)最直接的用法:S = |Σ (xᵢ·yᵢ₊₁ − xᵢ₊₁·yᵢ)| / 2,下标 i+1 对 n 取模,保证最后一条边连回首点。

为什么对。对每条边 PᵢPᵢ₊₁,xᵢyᵢ₊₁ − xᵢ₊₁yᵢ 正好是 cross(Pᵢ, Pᵢ₊₁),等于三角形 O、Pᵢ、Pᵢ₊₁ 的有向面积的两倍。把 n 个三角形加起来,落在多边形外面的部分正负抵消,剩下的正好是多边形本身,所以总和 = 2S。这与原点在不在多边形内部无关。

方向的处理。逆时针给出时每一项的累加结果为正,直接取用即可。加一句 if (s < 0) s = -s; 更保险:万一题目给的顺序反了,也不会输出负数。

为什么最后才除以 2。过程中每一项都是整数,累加也是整数,只有最后一步可能是「半个」。本题保证面积是整数,所以总和一定是偶数,整数除法不会丢精度。若提前做除法,就会引入 .5 甚至浮点误差。

共线顶点怎么办。不用管。三个点共线时,对应的两个三角形有向面积恰好相互抵消(或其中一个为 0),总和不变。这也是为什么题目特意提示「可能存在连续三个顶点共线」——它在提醒你不要去做去重。

规模。n ≤ 100、坐标 ≤ 200,|s| ≤ 100 × 2 × 200 × 200 = 8×10⁶,int 就够;但养成习惯用 long long,坐标范围稍微大一点(10⁹)就会溢出。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;
    vector<long long> x(n), y(n);
    for (int i = 0; i < n; ++i) cin >> x[i] >> y[i];

    long long s = 0;                        // 有向面积的两倍,一定是偶数
    for (int i = 0; i < n; ++i) {
        int j = (i + 1) % n;                // 最后一条边:末点连回首点
        s += x[i] * y[j] - x[j] * y[i];     // 就是 cross(P_i, P_j)
    }
    if (s < 0) s = -s;                      // 万一是顺时针给出,取绝对值
    cout << s / 2 << "\n";                  // 整数除法,答案必为整数
    return 0;
}
关键片段拆解
片段作用
vector<long long> x(n), y(n)把所有点先全部读进来。首尾成环,必须在算之前知道全部坐标。
int j = (i + 1) % n取模实现闭环。i = n−1 时 j = 0,最后一条边自动连回起点。
x[i] * y[j] - x[j] * y[i]叉积 cross(P_i, P_j) 的展开,也就是三角形 O、Pᵢ、Pⱼ 的有向面积的两倍。顺序不能反,反了整体变号。
s += ...把所有有向面积累加。多边形外侧的三角形贡献负值,抵消后剩下 2S。
if (s < 0) s = -s;逆时针时 s 本就为正;加这一句是为了防止题目顺序与预期不符。
cout << s / 2最后一步才除。本题保证面积为整数,所以 s 必为偶数,整数除法精确。
这题最容易错的地方
  • 提前做除法:写成 s += (x[i]*y[j] - x[j]*y[i]) / 2,每项都截断,答案差一大截。
  • 累加时漏掉最后一条边(循环写成 i < n-1)→ 少了「末点连回首点」那一块,WA。
  • 把叉积写成 x[i]*x[j] - y[i]*y[j](点积的形式)→ 完全不对,但照样能跑。
  • 用 int 且坐标范围很大时溢出。本题坐标只到 200 侥幸逃过,换一题就炸。
  • 以为「共线顶点」需要去重,多写一段特判 → 反而把合法的重复顶点处理错。这题不需要任何去重。
延伸阅读 / 参考
  • 《算法竞赛进阶指南》0x50 计算几何:凸包与半平面交
  • 《算法竞赛试炼场:洛谷 300 题精析》二维凸包与旋转卡壳部分
  • 洛谷 P2742、P1183 题解区关于 Andrew 单调链的图示
60 终极综合:60 题大回顾提高

60 · 终极综合:60 题大回顾

一场 120 分钟的 5 题模拟赛,把 60 节压成 12 组:二分答案 + Dijkstra、离散化 + 树状数组、排列映射 + LIS、区间筛、KMP。每题都给完整题面、引导提示、正解代码与逐段拆解。
回顾训练提高建议 120 分钟总复习5 题

最后一节,一场 120 分钟的模拟赛:5 道题,难度都在洛谷黄题到蓝题之间。这里不出现任何新算法——60 节的内容全部在第 1~59 节讲过。这一节只做两件事:把 60 节压成一张 12 行的覆盖表,让你知道每个知识点会以什么形式出现在题面里;再用 6 段代码把它们钉住。

综合题从不考新东西。它考的是:读完题 60 秒内,你能不能说清「这题 = 第 X 节的模板 + 第 Y 节的判定 + 第 Z 节的数据结构」。

先把结论摆出来:这 5 题的正解加起来只有 6 段模板——差分(9 行)、二分答案(6 行)、堆优化 Dijkstra(20 行)、树状数组(10 行)、LIS 的二分替换(6 行)、KMP 的 nxt 递推(5 行)。剩下 54 节不是没用,而是以「判断」的形式出现:看到 n = 5×105 就知道 O(n2) 必死,看到「最大值最小」就知道要二分,看到 231 就知道中间量可能溢出 int。

组节核心工具在本套里的落点
一 · 起步1–5cin/cout、for、数组、整数范围六段代码的读入输出;B 的 5×105 数组;D 的 231 边界
二 · 数组两种用法6–10桶计数、二维数组、字符串A 的费用数组与邻接表;B 的离散化数组;D 的 106 标记数组
三 · 函数与排序11–15sort/cmp、unique、离散化B 的排序去重 + lower_bound;C 的排列映射
四 · 贪心与前缀和16–20交换论证、前缀和、差分例题的差分 + 贪心;B 的树状数组就是「动态前缀和」
五 · 递归与搜索21–25递归出口、回溯、复杂度估算B 的另一解:归并排序求逆序对(递归分治)
六 · BFS 与二分26–30BFS、二分查找、二分答案A 的 check 单调性 + 二分;C 的 LIS 二分替换
七 · DP31–35状态定义、转移、边界C 的 LIS 是 DP 的二分优化形态
八 · 数论36–40埃氏筛、gcd、快速幂、组合数D 的埃氏筛 + 区间筛
九 · 图论41–45邻接表、Dijkstra、MST、拓扑A 的建图与最短路
十 · 进阶数据结构46–50树状数组、单调队列、堆B 的 bit;A 的 priority_queue
十一 · 字符串与倍增51–55哈希、Trie、KMP、ST 表E 的前缀函数与匹配
十二 · 进阶专题56–60分治、根号分治、博弈、几何B 的归并分治;D 的筛法复杂度 √R log log R

五题覆盖不了每个角落。概率与期望(39)、根号分治与莫队(57)、博弈(58)、计算几何(59)这四块本套没有设题,考的是结论而不是代码:期望的线性性、块大小取 √n、SG 值异或、叉积判方向。考前各花 10 分钟扫一遍结论,不要在这个时候学新东西。

标记题号题目主要考点难度建议用时
AP1462通往奥格瑞玛的道路二分答案 + 堆优化 Dijkstra提高-20 分钟
BP1908逆序对普及/提高-提高-20 分钟
CP1439【模板】最长公共子序列排列映射 + LIS 二分提高-20 分钟
DP1835素数密度埃氏筛 + 区间筛提高-20 分钟
EP3375【模板】KMP 字符串匹配普及/提高-提高-25 分钟

下面四张卡不是新知识,是把这 5 题反复用到的四个动作各默写一遍。赛场上默写不出来的模板,等于没学过:先能一次写对,再谈限时。

新函数拆解 · 差分数组 d[i] = a[i] − a[i−1]
d[i] = a[i] - a[i-1]; // 求差分 a[i] = a[i-1] + d[i]; // 还原(对 d 求前缀和)
组成含义 / 格式要求
d 的定义相邻两项之差。约定 a0 = 0,于是 d1 = a1。
为什么有用「对一段区间整体加 x」在差分里只改两个位置:d[l] += x、d[r+1] -= x。区间操作从 O(长度) 降到 O(1)。
差分与前缀和互逆对 d 求一次前缀和就回到 a。所以要「区间加 + 区间查」时,靠的是树状数组维护 d 的前缀和(第 46 节)。
相邻比较a[i] − a[i−1] 就是 d[i]。凡题面问「比前一段多多少」「新开了几段」,答案往往是 Σ max(0, d[i])。
a[0] = 0 不能省第一段的差分 d1 = a1 − a0。漏掉这个约定,第一项就没有基准,例题的天数会少算。
下标统一差分可以 0 起也可以 1 起,但同一个程序里必须一致;混用是这类题最常见的 WA。
最小可运行示例
示例
int a[6] = {4, 3, 2, 5, 3, 5};
int pre = 0, ans = 0;
for (int i = 0; i < 6; ++i) {
    int d = a[i] - pre;          // 差分:比前一段多(少)多少
    if (d > 0) ans += d;         // 只累加「变深」的部分
    pre = a[i];                  // 更新基准
}
// 差分序列: 4 -1 -1 3 -2 2,求和 = 5 = 最后一段的深度
// ans = 4 + 3 + 2 = 9
注意

差分只压缩「区间整体加减」。如果还要随时查询区间和,单靠差分不行,得配树状数组或线段树。

答案可能到 109,累加统一用 long long,不要每道题都去算边界。

新函数拆解 · 二分答案:在候选集上找最小可行值
while (lo < hi) { int mid = (lo + hi) / 2; if (check(mid)) hi = mid; // 可行 → 答案不右于 mid else lo = mid + 1; // 不可行 → 答案在 mid 右边 } // 退出时 lo == hi,即最小可行下标
组成含义 / 格式要求
前提:单调性lim 可行,则更大的 lim 一定也可行。A 题里 lim 是「城市费用上界」,放宽上界只会多出能走的城市,最短路只会变短,所以可行域是一个后缀。
lo / hi 是什么候选集的下标,不是答案本身。A 题里候选集是「所有城市费用排序去重后的数组」。
mid(lo + hi) / 2 向下取整。担心 lo + hi 溢出时写 lo + (hi - lo) / 2;本题下标 ≤ 104,不必。
check 的代价每次 check 跑一遍 Dijkstra,O(m log n)。log n ≈ 14 次,总共约 14 × 5×104 × 14 ≈ 107。
退出后再判一次不可行时二分仍会停在最后一位。必须先 check 候选集的最大值,不可行就直接输出无解标记。
为什么先排序去重答案只可能是某个城市的费用。在 n 个候选值上二分比在 [0, 109] 上二分少一半次数,也避免「答案不是任何城市费用」的尴尬。
最小可运行示例
示例
// val[0..m-1] 已升序去重;check(i) 表示「用 val[i] 作上界可行」
int lo = 0, hi = m - 1;
while (lo < hi) {
    int mid = (lo + hi) / 2;
    if (check(mid)) hi = mid;
    else            lo = mid + 1;
}
// 循环结束 lo == hi,val[lo] 就是答案
注意

check 的边界(写 ≤ 还是 <)直接决定答案差一格。写完先用样例把「刚好可行」的那个值手动验一遍。

二分的是候选值集合的下标,不是值域。搞混了就会在 [0, 109] 上多跑十几次 check。

新函数拆解 · 离散化 + 树状数组 bit
sort(v.begin(), v.end()); v.erase(unique(v.begin(), v.end()), v.end()); int rk = lower_bound(v.begin(), v.end(), x) - v.begin() + 1; // 名次从 1 起 void add(int i) { for (; i <= n; i += i & -i) ++bit[i]; } int query(int i) { int s = 0; for (; i > 0; i -= i & -i) s += bit[i]; return s; }
组成含义 / 格式要求
为什么要离散化B 题 ai 到 109,而树状数组的下标必须落在 [1, n]。把「值」换成「它在所有值里排第几」,下标就能用了。
为什么可以离散化名次的大小关系与原值完全一致,所以逆序对的个数一个都不变。这是它唯一能用的理由,换成「求和」这种依赖具体数值的操作就不行了。
unique 的语义把相邻重复元素挪到末尾,返回新末尾的迭代器。它不删除元素,所以必须配 erase。
lower_bound返回第一个 ≥ x 的位置。减去 begin() 得 0 起下标,+1 才是树状数组要的 1 起下标。
为什么从 1 起i & -i 取最低位的 1。i = 0 时 i & -i = 0,add 会原地死循环——这就是名次必须 +1 的实际原因,不是「习惯」。
从右往左扫统计「右边比它小的」:query(rk − 1) 就是答案的那一份贡献,扫完加进 bit 即可。
最小可运行示例
示例
int n = 6, a[7] = {0, 5, 4, 2, 6, 3, 1};      // 下标 1 起
int bit[7] = {0};

vector<int> v(a + 1, a + n + 1);                // 复制一份
sort(v.begin(), v.end());                       // 1 2 3 4 5 6
v.erase(unique(v.begin(), v.end()), v.end());

long long ans = 0;
for (int i = n; i >= 1; --i) {                  // 从右往左
    int rk = lower_bound(v.begin(), v.end(), a[i]) - v.begin() + 1;
    ans += query(rk - 1);                       // 右边比它小的个数
    add(rk);
}
// ans = 11
注意

bit 数组开 n + 1:名次最大是 n,写成 bit[n] 会在 rk = n 时越界写。

逆序对最多 n(n−1)/2 = 1.25×1011,答案必须 long long。

新函数拆解 · 前缀函数 nxt[i](KMP 的核心)
for (int i = 2, j = 0; i <= m; ++i) { while (j && t[i] != t[j+1]) j = nxt[j]; if (t[i] == t[j+1]) ++j; nxt[i] = j; }
组成含义 / 格式要求
nxt[i] 的含义t[1..i] 的最长 border 长度:既是前缀又是后缀、且不等于本身的最长子串。
j 的含义处理 i 时 j = nxt[i−1],是「上一段已经配好的长度」。所以只需要比较 t[i] 与 t[j+1]。
失配回跳j = nxt[j]:放弃一部分已配好的前缀,退到「还可能是答案」的最长位置。这是全部技巧所在。
为什么均摊 O(m)每次成功匹配 j 加 1,每次回跳 j 至少减 1;j 总共只加 m 次,所以总共只减 m 次。
1 起下标字符串从 t + 1 开始存,nxt[1] 恒为 0(全局数组默认值正好对)。
匹配时同一套循环文本串上把 t 换成 s;j == m 时输出 i − m + 1,再 j = nxt[j] 继续找(允许重叠)。
最小可运行示例
示例
char t[] = " ABA";              // t[1]='A' t[2]='B' t[3]='A'
int nxt[4] = {0, 0, 0, 0};

for (int i = 2, j = 0; i <= 3; ++i) {
    while (j && t[i] != t[j+1]) j = nxt[j];
    if (t[i] == t[j+1]) ++j;
    nxt[i] = j;
}
// "ABA" -> nxt[1]=0  nxt[2]=0  nxt[3]=1
注意

nxt 存的是「border 长度」,不是「下一个该比的下标」。用的时候写成 t[nxt[j] + 1],差 1 就全错。

把 nxt 初始化成 −1(有些人喜欢这么写)会让 while 条件变成两层判断,本套统一用 0。

先看一道正解只有 6 行的题。它不难,但它把「综合题」的本质演示得很干净:把操作翻译成柱状图,答案就变成相邻两项的差——想清楚结构,代码自然只有一行。

例题 · 铺设道路洛谷 P5019普及/提高-去洛谷做这题 ↗

有一条长为 n 的路,第 i 段下陷了 di。每天可以选一段连续的路段 [l, r],把这段路上每一段的深度都减 1(减到 0 为止,不能减成负数)。求把所有下陷填平(全部变成 0)最少需要多少天。

输入格式:第一行一个整数 n(1 ≤ n ≤ 105);第二行 n 个整数 d1…dn(0 ≤ di ≤ 104)。

输出格式:一行一个整数,表示最少天数。

输入: 6 4 3 2 5 3 5 输出: 9
例题怎么想:从「按天切」换成「按层切」

① 把操作画出来。把 d 画成柱状图:第 i 根柱子的高度是 di。一次操作就是在某几根连续的柱子上横着切掉一层——被切的那几层在位置上必须连续、高度都 ≥ 1。

② 按高度分层。第 k 层 = 满足 di ≥ k 的所有位置。这一层被「深度不到 k」的位置切成了若干段,每段连续、且必须单独用一天切掉——因为一次操作只能覆盖连续区间,中间有空洞就跨不过去。

③ 数段数。第 k 层在位置 i 处新开一段,当且仅当 di ≥ k 而 di−1 < k(约定 d0 = 0)。固定 i,满足 di−1 < k ≤ di 的 k 恰好有 max(0, di − di−1) 个。

④ 答案。天数 = 所有层所有段的段数之和 = Σi=1..n max(0, di − di−1)。上界:按层从低到高切,第 k 层有几段就切几天,正好填平。下界:对固定的 i,考察「覆盖了 i 却没有覆盖 i−1」的操作——位置 i 一共要被覆盖 di 次,位置 i−1 一共 di−1 次,被同一次操作同时覆盖的次数不超过 di−1,所以这类操作至少 max(0, di − di−1) 次。对所有 i 求和,就是总天数的下界。

⑤ 样例走一遍。d0 = 0,序列 4 3 2 5 3 5:4−0 = 4、3−4 < 0 记 0、2−3 < 0 记 0、5−2 = 3、3−5 < 0 记 0、5−3 = 2,合计 4+3+2 = 9。

⑥ 复杂度。一次线性扫描 O(n),每段的贡献只与它的前一段有关,所以一个数组都不用开,空间 O(1)。答案上界是 Σdi ≤ 105 × 104 = 109,卡在 int 上限的一半,但累加一律写 long long。

完整代码 · 边读边算,O(n) 时间 O(1) 空间
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    scanf("%d", &n);

    long long ans = 0;              // 上界 1e9,int 勉强够;这类累加统一 long long
    int pre = 0;                    // 约定 d[0] = 0:第一段之前没有路
    for (int i = 1; i <= n; ++i) {
        int d;
        scanf("%d", &d);
        if (d > pre) ans += d - pre;  // 只有「比前一段深」的部分要新开一层
        pre = d;                      // 留给下一段比较
    }

    printf("%lld\n", ans);
    return 0;
}
逐行拆解
代码片段含义与理由
long long ans = 0;答案最多 109,int 还剩一倍余量;但「求和」类变量统一 long long,这样题目把 di 改成 109 时不用回头改类型。
int pre = 0;给 a0 补一个 0。差分的第一项是 d1 − d0 = d1,所以第一段路该要的天数一分都不少。写成 pre = d(拿自己初始化)第一段就漏了。
if (d > pre) ans += d - pre;整题唯一的核心。d ≤ pre 时这一段的高度已经被左边「顺带切掉」了,不需要新的天数;d > pre 时高出的每一层都要在 i 处新起一段。
ans += d - pre加的是差值,不是 d。写成 ans += d 会按「每段各自填平」算,样例输出变成 22。
pre = d;更新基准。写成 pre = max(pre, d) 就错了:后面要和「紧邻的前一段」比,不是和前缀最大值比;反例 5 1 5,正确答案 5 + 0 + 4 = 9,用 max 会变成 5 + 0 + 0 = 5。
for (int i = 1; i <= n; ++i)从 1 数到 n,正好对应 d1..n;pre 里存的是 di−1,循环开始时是 d0 = 0。
边读边算每段的贡献只跟它的前一段有关,所以不需要把整个数组存下来。数据范围 105 时开数组也没事,但能 O(1) 空间就不要 O(n)。
printf("%lld\n", ans)long long 必须用 %lld。写成 %d 时高 32 位被丢掉,输出一个随机数。
这 120 分钟怎么用

0~10 分钟:只读题,不写代码。给每题写三行:数据范围、要用的模板、最容易写错的一处。按「能立刻写 → 有思路 → 没思路」排序,不要按题号顺序做。

10~30:A(二分 + Dijkstra)。先写对 Dijkstra,再把二分套上去。反过来写会同时有两个未验证的部件。

30~50:B(逆序对)。离散化错一处全盘皆错;先用 n = 5、值很小的数据手算验证,再交。

50~70:D(区间筛)。重点只有两个:起点取 max(p2, ⌈L/p⌉p)、i × p 用 long long。

70~90:C(LCS)。想清「为什么排列可以映射成 LIS」再动手,这一步想不通就别写。

90~115:E(KMP)。写不出 nxt 就先在纸上把「ABA」的 nxt 算一遍,照着递推抄。

115~120:只检查,不改算法。按下面的清单过一遍,每项打勾。赛场上最后 5 分钟改崩的,比改对的多。

这 5 题最常丢的分,每种都写症状

WA,答案刚好差一格:二分的 check 边界没对齐。A 题里 check 的语义是 dist[n] ≤ b,写成 < 时 dist[n] == b 会被误判不可行;写成 ≤ b + 1 又会在超一点时误判可行。

RE / 数组越界:B 题离散化后名次从 1 开始,bit 必须开 n + 1。开成 n 时 rk = n 会写越界,症状是小数据正常、大数据第 2 个点就 RE。

WA,输出负数:B 题答案到 1.25×1011,用 int 累加直接溢出成负数。C 题的 LIS 长度 ≤ n,用 int 没问题——所以类型要按题算,不能一刀切。

TLE:A 题用 O(n2) 的朴素 Dijkstra。n = 104 时单次 108,再乘 14 次二分,必死。二分答案的总代价是「check 的代价 × log」,check 里任何 O(n2) 都会被放大 14 倍。

RE / 死循环:D 题的 i × i、p × p 用 int 算。463412 = 2147488281,比 INT_MAX(2147483647)还大 4634,溢出成负数,循环下标变负 → RE 或卡死。

WA,答案多 1:D 题忘了 1 不是素数。L = 1 的测试点会多算一个,而样例不含 L = 1,本地看不出来。

MLE:D 题按 R 的大小开标记数组。R 到 231 时开不出来;只能开 R − L + 1。

WA,KMP 全乱:E 题的 nxt 当成了「下一个比较位置」。nxt 存的是 border 长度,下一步永远比 t[nxt[j] + 1],那个 +1 丢了就全错。

格式错:E 题先输出每个出现位置(各占一行),最后一行才是 nxt 数组;写成一行里混着输出必 WA。

自己动手复现

关掉页面,把例题 P5019 从空文件敲一遍并提交。

改一改①:把 if (d > pre) ans += d - pre; 改成统计下降量 if (d < pre) ans += pre - d;,样例跑出 4(1+1+0+2+0),而正确答案是 9。亲手确认「变深」才是新开层。

改一改②:把 long long 改回 int,构造 n = 105、每段都是 104 的数据,看 ans 会不会变成负数。这一步把「类型按数据范围定」变成肌肉记忆。

改一改③:先把差分存进数组 d[i] = a[i] - a[i-1],再对 d 求前缀和还原 a,验证「差分 + 前缀和 = 原数组」,顺便确认 Σd = an。

改一改④:输入 5 1 5,正确答案是 5 + 0 + 4 = 9。把 pre 改成 pre = max(pre, d) 再跑,结果变成 5——这就是「和前缀最大值比」与「和紧邻前一项比」的区别。

题面里的词第一反应本套哪题
「最大值最小」「最小的最大值」二分答案 + 单调性 checkA(P1462)
「有多少对 i < j 且 ai > aj」逆序对:离散化 + 树状数组 / 归并B(P1908)
「两个排列的最长公共子序列」映射成位置序列,再求 LISC(P1439)
「区间内素数个数」「R 到 231」筛到 √R,再在区间上打标记D(P1835)
「一个串在另一个串里的出现位置」KMP:先 nxt 再匹配E(P3375)
「比前一段多出的部分」「新开几段」差分:Σ max(0, ai − ai−1)例题(P5019)
「左边严格递增、右边严格递减,中间一个峰」正着一遍、反着一遍,再枚举峰第 31 节的合唱队形(P1091)
交卷前 60 秒,只查这 7 项

数组大小 ≥ 数据范围上限 + 5;n、m 各开一个,别共用一个 MAXN 常量就以为够了。

long long:逆序对、最短路、所有求和;输入输出用 %lld。

离散化后名次从 1 开始了吗?树状数组的下标出现 0 了吗?

二分退出后,check 的可行性是否已验证(不可行要先输出无解标记)?

边界:n = 1、L = 1、全部相等、答案是 0,这四种输入跑过吗?

输出格式:几个数、一行还是一列、行末有没有多余空格。

样例逐字对过吗?不是「看起来一样」,是每一位都一样。

本次训练 · 共 5 题

做题方式

限时 100 分钟。每题先自己写 20~25 分钟;写不出来先看提示,再给自己 10 分钟;还不行才点开思路。每题都独立限时,不要一题耗死。

大题 挑战题 1 · 通往奥格瑞玛的道路
洛谷 P1462提高-去洛谷做这题 ↗

有 n 个城市(编号 1…n)和 m 条双向道路。穿过第 i 条道路会失去 wi 点生命值;进入第 i 个城市要交 fi 点费用,起点和终点也要交。你从 1 号城市出发到 n 号城市,初始生命值 b。

一条路径的「花费」定义为:路径上所有城市费用的最大值。要求在沿途失去生命值总和不超过 b 的前提下,让这个最大值尽量小,输出最小值;若不存在这样的路径,输出 AFK(三个大写字母)。

输入格式:第一行三个整数 n、m、b(1 ≤ n ≤ 104,1 ≤ m ≤ 5×104,1 ≤ b ≤ 109);第二行 n 个整数 f1…fn(1 ≤ fi ≤ 109);接下来 m 行,每行三个整数 u、v、w(1 ≤ w ≤ 109),表示 u、v 之间有一条双向道路。

输出格式:一行一个整数,或 AFK。

样例输入
4 4 10
5 4 3 2
1 2 3
2 4 4
1 3 2
3 4 10

样例输出
5
样例解释:1→2→4 失去 3+4 = 7 点生命值,经过的城市费用最大是 max(5,4,2) = 5;1→3→4 虽然费用更小,但要失去 2+10 = 12 点,超过 b = 10。所以答案是 5。

引导性提示(先只看这里)

这题有两个代价:生命值总和、路径上最贵的城市。它们不是同一个量,不能合成一次最短路。

先规定「只允许走费用 ≤ X 的城市」,剩下的就是一个纯粹的最短路问题。X 变大时能走的城市只多不少,最短距离只会变小——这个单调性说明了什么?

答案一定是某个城市的费用,候选只有 n 个。逐个试要跑 n 次最短路,但候选是有序的,能不能只试十几次?

想看思路了?点开完整思路

把问题拆成两层:外层是对「路径上城市费用最大值」的二分,内层是普通的单源最短路。

判定问题:给定 lim,只保留费用 ≤ lim 的城市,跑 1 到 n 的最短路,若 dist[n] ≤ b 则 lim 可行。

单调性:lim 增大时可用城市集合只增不减,最短路只可能变短,所以可行 lim 构成一个后缀——可以二分。

候选集是所有城市费用排序去重后的数组。先判最大候选是否可行,不可行直接输出 AFK;否则二分出最小的可行下标。

细节:起点 1 和终点 n 也要交费,所以 f[1] > lim 或 f[n] > lim 时直接不可行。漏了这一句,当 lim 落在 f[1] 与 f[n] 之间时会输出一个实际上走不通的答案。

复杂度:离散化 O(n log n);二分 log n ≈ 14 次,每次 Dijkstra O(m log n) ≈ 5×104 × 14,总计约 107 次操作。最长简单路径不过 104 条边,每条 109,最短路最大 1013,dist 必须 long long。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 10005;
typedef long long ll;
typedef pair<ll, int> pli;

int n, m;
ll b;
ll f[MAXN];                              // 每个城市的费用
vector<pair<int,int>> g[MAXN];           // (终点, 生命值消耗)

ll dist[MAXN];

bool check(ll lim) {                     // 只允许走费用 <= lim 的城市
    if (f[1] > lim || f[n] > lim) return false;   // 起终点也要交费
    const ll INF = (ll)4e18;
    fill(dist, dist + n + 1, INF);
    dist[1] = 0;

    priority_queue<pli, vector<pli>, greater<pli>> pq;   // 小根堆:(距离, 编号)
    pq.push({0, 1});
    while (!pq.empty()) {
        pli cur = pq.top(); pq.pop();
        ll d = cur.first;
        int u = cur.second;
        if (d > dist[u]) continue;       // 过期版本
        if (d > b) continue;             // 已经超过血量,再走只会更贵
        for (auto &e : g[u]) {
            int v = e.first, w = e.second;
            if (f[v] > lim) continue;    // 这个城市太贵,不能进
            if (dist[v] > d + w) {
                dist[v] = d + w;
                pq.push({dist[v], v});
            }
        }
    }
    return dist[n] <= b;
}

int main() {
    scanf("%d%d%lld", &n, &m, &b);
    for (int i = 1; i <= n; ++i) scanf("%lld", &f[i]);
    for (int i = 0; i < m; ++i) {
        int u, v, w;
        scanf("%d%d%d", &u, &v, &w);
        g[u].push_back({v, w});
        g[v].push_back({u, w});          // 双向道路,两条边都要存
    }

    vector<ll> val(f + 1, f + n + 1);    // 候选集:所有城市费用
    sort(val.begin(), val.end());
    val.erase(unique(val.begin(), val.end()), val.end());

    if (!check(val.back())) {            // 最宽松的上界也不行 → 无解
        puts("AFK");
        return 0;
    }

    int lo = 0, hi = (int)val.size() - 1;
    while (lo < hi) {
        int mid = (lo + hi) / 2;
        if (check(val[mid])) hi = mid;   // 可行:答案不右于 mid
        else                 lo = mid + 1;
    }
    printf("%lld\n", val[lo]);
    return 0;
}
关键片段拆解
片段作用
if (f[1] > lim || f[n] > lim) return false;起终点同样收费。少了这一句,lim 落在 f[1] 与 f[n] 之间时会把一条根本进不去的路径判成可行。
const ll INF = (ll)4e18;最短路最大 1013,INF 取 4×1018 既不溢出 long long,又远大于任何真实距离。
if (d > dist[u]) continue;懒删除:堆里同一个点可能有好几份历史版本,取出时距离对不上就丢掉。
if (d > b) continue;血量已经超了,继续扩展只会更长。剪掉它同时也保证了 d + w 不会溢出。
if (f[v] > lim) continue;★ 二分的关键:不行就整条边跳过。这一步删掉,check 就与 lim 无关了。
if (!check(val.back())) puts("AFK");先判最宽松的上界。不判的话,不可行时二分仍会收敛到最后一个下标,程序会输出一个可行的假答案。
if (check(val[mid])) hi = mid;可行就往左收(mid 本身可能是答案,所以不是 mid − 1)。
lo = mid + 1;不可行就整段右移。写成 mid 会在不可行时原地打转,死循环。
g[v].push_back({u, w});双向道路必须存两条。只存一条时大量点不可达,样例就会输出 AFK。
这题最容易错的地方
  • 忘了判 f[1]、f[n] 也要 ≤ lim:答案偏小,样例就会输出 4 而不是 5。
  • 没有先判 val.back() 是否可行,输出一个假答案而不是 AFK。
  • 用朴素 O(n2) Dijkstra:14 × 108 次操作,必 TLE。
  • dist 用 int 或 INF 取 0x3f3f3f3f 再加边权:溢出成负数,不可达的点变成负距离。
  • 输出写成 AKF / afk / -1:题目只要 AFK 三个大写字母。
  • 双向边只加了一条:样例直接输出 AFK,看着像算法错,其实是建图错。
大题 挑战题 2 · 逆序对
洛谷 P1908普及/提高-去洛谷做这题 ↗

给定一个长度为 n 的序列 a,求逆序对的个数。逆序对指满足 i < j 且 ai > aj 的数对 (i, j)。

输入格式:第一行一个整数 n(1 ≤ n ≤ 5×105);第二行 n 个整数 a1…an(1 ≤ ai ≤ 109)。

输出格式:一行一个整数,表示逆序对个数。

样例输入
6
5 4 2 6 3 1

样例输出
11
样例解释:以每个位置为右端点,左边比它大的个数依次是 0、1、2、0、3、5,合计 11。

引导性提示(先只看这里)

暴力是 O(n2):n = 5×105 时有 1.25×1011 对,1 秒绝无可能。要把「数一遍右边比它小的」这一步压到 log。

ai 到 109,当不了数组下标。但逆序对只关心大小关系,不关心具体数值——能不能只保留「排第几」?

从右往左扫,每读到一个数就查一次「已经加入的里面比它小的有几个」。什么东西能同时支持「单点加 1」和「前缀求和」,而且都是 log?

想看思路了?点开完整思路

逆序对 = Σi(i 右边比 ai 小的个数)。把统计对象从「数对」换成「每个位置的一份贡献」。

从右往左扫,维护一个集合 = 已经扫过的数(也就是 i 右边的数)。查询「比 ai 小的有几个」,就是一次前缀求和;把 ai 加入集合,就是一次单点加 1。树状数组两个操作都是 O(log n)。

下标问题:ai 到 109,树状数组装不下。做离散化——把所有值排序去重,把 ai 换成它的名次 rk(1…n)。名次之间的大小关系与原值完全一致,所以逆序对个数不变。

于是 rk − 1 就是「严格小于」的范围:query(rk − 1) 天然排除了相等的情况,题目要的正是严格大于。

复杂度 O(n log n):n = 5×105 时约 5×105 × 19 × 2 ≈ 2×107 次操作。

答案上界 n(n−1)/2 = 1.25×1011,必须 long long。另一条路是归并排序:合并两个有序段时,右段每个元素被取走前,左段剩下的元素都与它构成逆序对(第 21、56 节的递归分治)。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 500005;
int n, a[MAXN], bit[MAXN];       // bit 存的是「名次 <= i 的已出现个数」

void add(int i) {                // 名次 i 上出现了一个数
    for (; i <= n; i += i & -i) ++bit[i];
}

int query(int i) {               // 名次 <= i 的已出现个数,也就是前缀和
    int s = 0;
    for (; i > 0; i -= i & -i) s += bit[i];
    return s;
}

int main() {
    scanf("%d", &n);
    for (int i = 1; i <= n; ++i) scanf("%d", &a[i]);

    vector<int> v(a + 1, a + n + 1);          // 复制一份用来离散化
    sort(v.begin(), v.end());
    v.erase(unique(v.begin(), v.end()), v.end());

    long long ans = 0;                        // 最多 1.25e11,必须 long long
    for (int i = n; i >= 1; --i) {            // 从右往左:集合里全是「右边」的数
        int rk = lower_bound(v.begin(), v.end(), a[i]) - v.begin() + 1;
        ans += query(rk - 1);                 // 右边严格小于它的个数
        add(rk);
    }

    printf("%lld\n", ans);
    return 0;
}
关键片段拆解
片段作用
vector<int> v(a + 1, a + n + 1);把有效数据复制出来。a 是 1 起下标,所以区间是 [a+1, a+n+1)。
sort + unique + erase升序去重。unique 只是把重复元素挪到末尾,必须接 erase 才是真去重。
... - v.begin() + 1名次从 1 开始。树状数组的 i & -i 在 i = 0 时等于 0,add(0) 会原地死循环——这个 +1 不是习惯,是必需。
for (int i = n; i >= 1; --i)从右往左扫,保证 bit 里存的全是 i 右边的数。从左往右扫再查 query(rk−1),数出来的是「顺序对」。
ans += query(rk - 1);rk − 1 是「严格小于它」的名次范围,相等的情况被排除,正好对应 ai > aj。
add(rk);统计完再插入自己。顺序反了会把 ai 自己算进去,答案偏大。
long long ans最大 5×105 × 5×105 / 2 ≈ 1.25×1011,int 最多 2.1×109。
i += i & -i / i -= i & -i上跳与下跳。i = 0 时 i & -i 也是 0,add(0) 会原地打转,所以名次必须是 [1, n]。
这题最容易错的地方
  • bit 只开 n(不是 n+1):rk = n 时写越界,大数据 RE 或答案错。
  • 离散化后名次从 0 开始:add(0) 死循环,程序直接 TLE。
  • 从左往右扫却查 query(rk − 1):统计成了「左边比它小」,样例输出 4 而不是 11。
  • ans 用 int:输出负数,且小样例看不出来。
  • query(rk) 而不是 query(rk − 1):把相等的数也算进去,有重复值时答案偏大。
大题 挑战题 3 · 【模板】最长公共子序列
洛谷 P1439提高-去洛谷做这题 ↗

给出 1…n 的两个排列 P1 和 P2,求它们的最长公共子序列的长度。

输入格式:第一行一个整数 n(1 ≤ n ≤ 105);第二行 n 个整数,是 P1;第三行 n 个整数,是 P2。

输出格式:一行一个整数,表示最长公共子序列的长度。

样例输入
5
3 2 1 4 5
1 2 3 4 5

样例输出
3
样例解释:2 4 5、3 4 5、1 4 5 都是公共子序列,最长长度为 3。

引导性提示(先只看这里)

普通 LCS 的 DP 是 O(n2):1010 个状态,1 秒绝无可能。必须用上「两个都是排列」这个条件。

排列里每个数只出现一次,所以「这个数在 P1 里排第几位」是唯一确定的。把 P2 的每个数换成它的位置,会得到一串什么?

一个公共子序列在 P1 里的位置是递增的。那么这串位置里,公共子序列长什么样?

想看思路了?点开完整思路

先建映射:pos[x] = x 在 P1 中出现的位置(1…n,每个 x 恰好一个)。

把 P2 逐项换成 pos 值,得到序列 c:ci = pos[P2,i]。

关键等价:P1 与 P2 的公共子序列,与 c 的上升子序列一一对应。因为一个子序列同时是 P1 的子序列,意味着它的元素在 P1 中的位置按顺序递增——而 c 的下标顺序就是它在 P2 中的顺序,c 的值就是它在 P1 中的位置。两个顺序都对上,长度自然相等。

于是问题变成「求 c 的最长上升子序列」,用 tail 数组 + 二分做 O(n log n):tail[len] 记录「长度为 len 的上升子序列的最小结尾」,每个 ci 用 lower_bound 找到第一个 ≥ 它的位置替换掉。

为什么可以替换:结尾越小,后面能接的数越多,所以「最小结尾」这一状态足以代表全部长度为 len 的子序列。

c 是 1…n 的一个排列,元素互不相同,所以 lower_bound 与 upper_bound 结果一致;若换成有重复的序列求「不下降子序列」,就得用 upper_bound。复杂度 O(n log n)。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int N = 100005;
int pos[N], c[N], tail[N];   // tail[len]:长度为 len 的上升子序列的最小结尾

int main() {
    int n;
    scanf("%d", &n);

    for (int i = 1; i <= n; ++i) {      // 第一个排列:记下每个数在第几位
        int x;
        scanf("%d", &x);
        pos[x] = i;
    }
    for (int i = 1; i <= n; ++i) {      // 第二个排列:换成「在第一个排列里的位置」
        int y;
        scanf("%d", &y);
        c[i] = pos[y];
    }

    int len = 0;                         // 当前 LIS 长度
    for (int i = 1; i <= n; ++i) {
        // 找到第一个 tail >= c[i] 的位置;tail[1..len] 是单调不减的
        int p = (int)(lower_bound(tail + 1, tail + len + 1, c[i]) - tail);
        tail[p] = c[i];                  // 用更小的结尾替换,给后面留更多余地
        if (p == len + 1) ++len;         // 接到了末尾,长度加一
    }

    printf("%d\n", len);
    return 0;
}
关键片段拆解
片段作用
pos[x] = i;读第一个排列时就建好映射。之后每查一次「x 在 P1 的位置」都是 O(1)。
c[i] = pos[y];全题唯一的转化,写在两行里。转化完之后原题的两个排列都不再需要。
lower_bound(tail + 1, tail + len + 1, c[i])在 [1, len] 里找第一个 ≥ c[i] 的位置。区间右端是 tail + len + 1,别写成 tail + len。
tail[p] = c[i];替换而不是插入。同一个长度只保留最小结尾,状态数才是 O(n)。
if (p == len + 1) ++len;p 越过 len 说明 c[i] 比所有结尾都大,可以接在最长的后面,长度加一。
int len = 0;空序列的 LIS 长度是 0。tail 是全局数组,默认全 0,但只有 1…len 会被读到。
printf("%d\n", len);答案是长度,不是子序列本身。题目没要求输出方案。
这题最容易错的地方
  • 照抄 O(n2) 的 LCS:n = 105 时 1010 次比较,TLE。
  • 映射建反:pos 存的是 P2 的位置却拿 P1 去查——样例恰好也能得 3,只有自己造一组数据才能发现。
  • 把问题当成「求 c 的最长不下降子序列」:排列元素互不相同,这里两者等价,但换成有重复的数据就错。
  • lower_bound 的区间写成 tail + len:漏掉一个元素,答案偶尔偏小。
  • 把 tail 当答案输出:tail[len] 是结尾值,不是长度。
大题 挑战题 4 · 素数密度
洛谷 P1835提高-去洛谷做这题 ↗

给定区间 [L, R],求这个区间内素数的个数。

输入格式:一行两个正整数 L、R(1 ≤ L ≤ R ≤ 231 − 1,且 R − L ≤ 106)。

输出格式:一行一个整数,表示区间内素数的个数。

样例输入
1 10
样例输出
4
样例解释:1 到 10 之间的素数是 2、3、5、7,共 4 个;1 不是素数。

引导性提示(先只看这里)

区间长度最多 106,整段打标记完全放得下;但 L、R 本身到 231,按 R 开数组是不可能的。注意区分「区间长度」和「数值大小」。

要判断 [L, R] 里每个数是不是素数,只需要知道它有没有不超过 √R 的因子。√(231) ≈ 46341——这个范围内的素数筛得出来。

用每个小素数 p 去划掉 [L, R] 里 p 的倍数。起点从哪儿开始?如果从 L 开始,当 L ≤ p 时会把 p 自己也划掉。

想看思路了?点开完整思路

两步走:先筛出 [2, √R] 的全部素数(埃氏筛,√R ≤ 46341,数组几万个就够了);再用这些小素数去「筛掉」[L, R] 里的合数。

第二步:对每个小素数 p,把 [L, R] 中 p 的倍数标记为合数。起点取 max(p2, ⌈L/p⌉·p):从 p 开始是为了不划掉 p 本身,从 ⌈L/p⌉·p 开始是为了不划掉区间左边的数。

标记数组只需要 R − L + 1 个位置,用 x − L 当下标。这就是「区间筛」的全部内容。

为什么只筛到 √R:任何合数 x ≤ R 都有一个不超过 √x ≤ √R 的因子;这个因子对应的素数一定在筛出的表里。

边界:L = 1 时要把 1 单独标成非素数;L = R 时答案要么 1 要么 0。

溢出:p ≤ 46341,p × p 最大 463412 = 2147488281,比 INT_MAX(2147483647)还大 4634,用 int 算会变成负数,循环下标变负 → RE。所有涉及乘积的地方一律 long long。

复杂度:筛小素数 O(√R log log R),标记区间 Σ (R−L)/p = O((R−L) log log R),R = 231 时约 4.6×104 + 106 × 3 次操作。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXS = 1000005;
const int MAXP = 50005;         // sqrt(2^31 - 1) ≈ 46341
bool isp[MAXP];                 // 小素数表
bool mark[MAXS];                // mark[i] 表示 L + i 是合数

int main() {
    long long L, R;
    scanf("%lld%lld", &L, &R);

    int lim = (int)sqrt((double)R) + 1;     // 只需要筛到 sqrt(R)

    fill(isp, isp + lim + 1, true);
    isp[0] = isp[1] = false;
    for (int i = 2; i <= lim; ++i)
        if (isp[i])
            for (long long j = 1LL * i * i; j <= lim; j += i)  // i*i 用 long long 算
                isp[j] = false;

    long long len = R - L + 1;
    if (L == 1) mark[0] = true;             // 1 不是素数

    for (int i = 2; i <= lim; ++i) {
        if (!isp[i]) continue;
        long long p = i;
        // 从 p 的平方 或 第一个 >= L 的倍数 开始划;两者取大,避免划掉 p 自己
        long long st = max(p * p, (L + p - 1) / p * p);
        for (long long x = st; x <= R; x += p)
            mark[x - L] = true;
    }

    int ans = 0;
    for (long long i = 0; i < len; ++i)
        if (!mark[i]) ++ans;

    printf("%d\n", ans);
    return 0;
}
关键片段拆解
片段作用
const int MAXP = 50005;46342 个位置就够,开 50005 留余量。写 46341 会差一点点就越界。
fill(isp, isp + lim + 1, true);初始化 [0, lim] 全部为 true,右端写 lim + 1。区间少一个,isp[lim] 就是 false,最后一段素数会被漏筛。
1LL * i * i★ i 到 46341 时 i*i 超过 INT_MAX,用 int 会溢出成负数,内层循环直接跑飞。
if (L == 1) mark[0] = true;1 不是素数。样例就是 L = 1,漏了这一行输出 5。
long long p = i;后面的 p*p 与 x 都要 long long,先在这里提上来,免得写成 (long long)i*i 到处都是。
max(p * p, (L + p - 1) / p * p)两个起点取大:p*p 保证不划掉 p 自己,⌈L/p⌉p 保证落在区间内。漏掉 p*p,L ≤ p 时答案会少 1。
mark[x - L] = true;下标平移。写成 mark[x] 需要 231 个位置,直接 MLE 崩溃。
for (long long x = st; x <= R; x += p)x 用 long long,否则 x += p 可能越过 231 变成负数,死循环。
if (!mark[i]) ++ans;未被标记的就是素数。ans ≤ R − L + 1 ≤ 106 + 1,int 足够。
这题最容易错的地方
  • i × i 或 p × p 用 int:463412 = 2147488281 > INT_MAX,溢出成负数,RE 或死循环。
  • 起点只用 ⌈L/p⌉·p,漏了 max(p2, ...):L ≤ p 时把 p 自己划掉,答案偏小。
  • 忘了 1 不是素数:L = 1 的测试点答案多 1(样例就能暴露,别跳过样例)。
  • 标记数组开成 R 大小:R = 231 时是 2 GB,本地直接跑不起来,交上去 MLE。
  • 统计时用 mark[x] 而不是 mark[x − L]:越界读,答案随机。
  • 只筛到 sqrt(L) 而不是 sqrt(R):区间右半部分的合数没被划掉,答案偏大。
大题 挑战题 5 · 【模板】KMP 字符串匹配
洛谷 P3375普及/提高-去洛谷做这题 ↗

给出两个字符串 s1 和 s2。若 s1 的区间 [l, r] 子串与 s2 完全相同,则称 s2 在 s1 中出现,出现位置为 l。要求输出 s2 在 s1 中所有出现的位置。

另外定义:字符串 s 的 border 是 s 的一个不等于 s 本身的子串 t,满足 t 既是 s 的前缀又是 s 的后缀。对 s2 的每个前缀(长度为 1…|s2|),求出它最长 border 的长度。

输入格式:第一行一个字符串 s1,第二行一个字符串 s2(1 ≤ |s2| ≤ |s1| ≤ 106,只含大小写字母)。

输出格式:若干行,每行一个整数,按从小到大输出 s2 在 s1 中的出现位置;最后一行输出 |s2| 个整数,第 i 个表示 s2 长度为 i 的前缀的最长 border 长度,相邻两个用一个空格隔开。

样例输入
ABABABC
ABA

样例输出
1
3
0 0 1

样例解释:ABA 在 ABABABC 的第 1 位和第 3 位出现(允许重叠);前缀 A、AB 没有非自身的 border,ABA 的 border 是 A,长度 1。

引导性提示(先只看这里)

暴力是每个起点比 |s2| 个字符,最坏 106 × 106。要压到线性,必须让「已经匹配上的那一段」在失配后不被浪费。

失配时,已经匹配的那一段文本,既是 s2 的某个前缀,又是刚读过的文本的后缀。如果知道 s2 每个前缀「最长的、既是前缀又是后缀的长度」,失配后应该退到哪?

这个长度可以递推:nxt[i] 只依赖 nxt[i−1] 和 t[i] 本身。为什么回跳的总次数是 O(m) 而不是 O(m2)?

想看思路了?点开完整思路

定义 nxt[i] = s2[1..i] 的最长 border 长度(即最长的、既是前缀又是后缀、且不等于本身的长度)。

递推:假设 nxt[i−1] = j 已经算出。若 s2[i] == s2[j+1],则 nxt[i] = j + 1;否则令 j = nxt[j] 继续试,直到 j = 0 或字符相等。这一步的依据是:i 位置的所有可行 border 长度,必然依次是 nxt[i−1]、nxt[nxt[i−1]]、…(border 链)。

匹配:在 s1 上维护 j = 「当前已匹配的长度」。s1[i] 与 s2[j+1] 相等则 ++j,否则 j = nxt[j] 回跳。j 达到 m 时输出起点 i − m + 1,然后令 j = nxt[j] 继续(允许重叠)。

复杂度:j 每次成功匹配加 1,总共只加 O(n) 次;每次回跳 j 至少减 1,所以回跳总次数也是 O(n)。均摊下来整个匹配 O(n + m),106 的规模毫无压力。

为什么不能 j = 0 直接重来:那样已经匹配的那一段信息全丢了,退化成暴力。

输出注意:位置从 1 开始数,所以起点是 i − m + 1;最后一行才是 nxt 数组,nxt[1] = 0 也要输出。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1000005;
char s[MAXN], t[MAXN];
int nxt[MAXN];

int main() {
    scanf("%s", s + 1);              // 从下标 1 开始存:位置直接是 i - m + 1
    scanf("%s", t + 1);
    int n = (int)strlen(s + 1), m = (int)strlen(t + 1);

    // ① 求 t 的前缀函数:nxt[i] = t[1..i] 的最长 border 长度
    for (int i = 2, j = 0; i <= m; ++i) {
        while (j && t[i] != t[j + 1]) j = nxt[j];   // 失配:沿 border 链回退
        if (t[i] == t[j + 1]) ++j;                  // 接上一位,匹配长度加一
        nxt[i] = j;
    }

    // ② 在 s 上匹配:j 是「当前已经匹配的长度」
    for (int i = 1, j = 0; i <= n; ++i) {
        while (j && s[i] != t[j + 1]) j = nxt[j];
        if (s[i] == t[j + 1]) ++j;
        if (j == m) {                               // 完整出现一次
            printf("%d\n", i - m + 1);              // 起点(1 起)
            j = nxt[j];                             // 允许重叠,继续找下一处
        }
    }

    // ③ 最后一行:nxt[1..m]
    for (int i = 1; i <= m; ++i) {
        if (i > 1) putchar(' ');
        printf("%d", nxt[i]);
    }
    putchar('\n');
    return 0;
}
关键片段拆解
片段作用
scanf("%s", s + 1);从 1 起存。这样「起点」与「已匹配长度」的换算就是 i − m + 1,不用再 +1 修正。
for (int i = 2, j = 0; i <= m; ++i)从 2 开始:nxt[1] 恒为 0(单个字符没有非自身的 border)。j 的初值 0 表示「还没有任何 border」。
while (j && t[i] != t[j + 1]) j = nxt[j];★ 全题的核心。失配时放弃一部分前缀,退到下一个候选 border。while 而不是 if:一次退不够,可能连退好几次。
t[j + 1]要比较的位置是「当前匹配长度 + 1」。写成 t[j] 就整体错位一位,样例立刻错。
nxt[i] = j;把当前匹配长度记下来,它既是 border 长度,也是下一个 i 的回退起点。
if (j == m) { ... }匹配成功。先输出起点,再 j = nxt[j] 继续——写成 j = 0 会漏掉重叠的出现(样例的第 3 位就没了)。
i - m + 1起点。1 起下标下,终点是 i、长度是 m,起点自然是 i − m + 1。
if (i > 1) putchar(' ');nxt 数组用空格分隔、行末只换行。多一个空格在严格评测里可能 WA。
char s[MAXN], t[MAXN];106 的字符串用 char 数组 + scanf 最稳;string 也能过,但常数大一些。
这题最容易错的地方
  • 字符串从 0 起存,输出却用 i − m + 1:所有位置都小 1(样例输出 0、2)。
  • 匹配成功后 j = 0 而不是 j = nxt[j]:重叠出现被漏掉,样例只输出 1。
  • 把 nxt 理解成「失配后从哪一位重新比较」,写成 t[nxt[j]]:差 1,全盘皆错。
  • while 写成 if:一次回退不够时不彻底,nxt 数组会偏大,后续匹配出错。
  • 最后一行忘了输出 nxt[1] = 0,或者把 nxt 输出在位置之前。
  • 用 cin >> string 读 106 字符又不关同步:在时限边缘,用 scanf 更稳。
做完之后自问
  • 能说出 60 节里哪四块本套没有设题,并各写出一条必须记住的结论。
  • 能在 3 分钟内判断一道题该用二分答案、二分查找还是普通枚举,并说清单调性从哪来。
  • 能默写树状数组的 add / query,并说清离散化为什么不会改变逆序对个数。
  • 能说清「排列的 LCS」为什么等价于 LIS,以及 O(n2) 的 LIS 为什么必 TLE。
  • 能说清区间筛的复杂度怎么算,以及 i × p 为什么必须用 long long。
  • 能默写 KMP 的 nxt 递推与匹配循环,并解释 j = nxt[j] 为什么是均摊 O(m)。
  • 能在 120 分钟内给 5 题各写出一份能过样例的代码,且每份都做过边界检查。
  • 错题能归到具体一节:是模板没背下来,还是没认出该用哪个模板。
延伸阅读
  • 《算法竞赛进阶指南》0x21 树状数组、0x31 质数、0x34 KMP 三节的模板对照
  • 《算法竞赛试炼场:洛谷300题精析》P1462、P1908、P1439、P1835、P3375 的精析
  • 《算法竞赛入门笔记》二分答案、离散化、字符串匹配三章
  • 《算法竞赛黄金法则:提高算法和思考力的77项技巧》第 12 条:先问「答案是否单调」
题解 GDUT 2026 月赛 题解 14 题里 13 题给了完整思路与可过代码,另 1 题只给题面与结论入门 ~ 提高+

GDUT 2026 月赛 题解

14 题里 13 题给了完整思路与可过代码,另 1 题只给题面与结论
题解入门 ~ 提高+GDUT 2026 Monthly competition(2026-10-15)
跳过说明
  • L 幻想乡树状结界:题意已完整还原(n ≤ 20,要求每个关键点 v 满足 subtree(v) 的异或和为 0,构造任意一棵以 1 为根的树),上面也给出了题面、提示和关键结论,但没有代码。原因是这题的构造我还没能给出一个自己敢担保正确的搜索算法——「为每个关键点找一个互不相交、异或为 0 的集合」这步在 n ≤ 20 下的正确剪枝与回溯强度不好把握,按「宁可少写也不写错」的原则略去实现,请直接看官方题解。顺带记一个常见错误:不要假设子树在输入顺序里是连续一段——n = 4、a = [1,5,7,5]、关键点 {2} 时,树 1-2、2-4、1-3 里 subtree(2) = {2,4} 异或为 0 是合法的,而任何「子树 = 连续段」的构造都做不到([2]、[2,3]、[2,4] 的异或分别是 5、2、7)。
  • 除 L 之外的 13 题均已给出可编译、可过样例的 C++17 代码;如果你在第 14 题上有了把握,可以按上面那条「互不相交、异或为 0 的集合」的思路继续做。

本场 PDF 的中文字体没有嵌入正确的 ToUnicode 映射,直接复制题目文本只会得到乱码。好消息是这批乱码是可逆的:乱码字符的码位 C 与原始 GBK 双字节 (hi, lo) 满足 C = 94 × (hi − 0xA6) + (lo − 0xA1),反解之后 14 道题的题面全部还原,并与每题的官方样例逐条对照过。所以这份题解覆盖了全部 14 题。

覆盖情况:A ~ K、M、N 共 13 题给出完整思路与参考代码;这 13 份代码全部通过官方样例,其中 B/C/D/E/F/G/I/J/K/N 还用独立写的暴力程序对拍过随机小数据,H 用本地交互器验过 97 个隐藏值(最多用了 31 次询问,上限 32)。L 见下方跳过说明。

读题提示

原 PDF 的样例区把「标准输入 / 标准输出」并排放在一张表里,粘贴时容易把两栏混成一段。本文每题都给出了完整的样例输入与输出,可以直接照抄去本地测。

另外这份题面里所有上标都被拍平了:10^4 显示成 104、10^6 显示成 106、4 × 10^5 显示成 4 ∗ 105。读题时注意别把 10^4 当成 104。

怎么用这份题解

每题先看「引导性提示」——只有两三句话,够你判断方向对不对。

卡住 20 分钟再看折叠的「思路」,最后再对代码。

代码段的注释写的是「为什么这么写」,不是「这行在干什么」,建议对着注释读。

题目索引

题号题目核心考点难度
A迈向第 50 届世界总决赛零输入、输出固定串(签到题)入门
B是啊,吃什么贪心 + 单状态模拟(只跟踪空位数)普及-
C小猪玩音游模拟 + 排序(按题意算 Rating)普及
D课后作业构造 + 进制观察(三进制只有 0/1/2)普及/提高-
EQZKago Requiem多源最短路(关键在「只关心最大值」的化归)普及+/提高-
F猪猪的难题(简单版)离线 + 树状数组(用「最后一次出现」拆贡献)提高+
G智慧大学生猪猪比赛排序 + 线性 DP(只需考虑 2 元组与 3 元组)提高-
H取整互交交互题 + 两阶段二分(30 次卡进 32)提高
ISBR:荒野中的觉悟期望的线性性 + 模逆元提高-
J捕风的异乡人双指针 + 线段树优化 DP提高+
K野比大雄的射击训练切比雪夫距离入门
L幻想乡树状结界构造 + 异或(子树异或为 0)提高+
M教练,我想打 xcpc字符串筛选与输出入门
N不太整齐的队伍(简单版)mex 的等价转化 + 最大空档普及-

A · 迈向第 50 届世界总决赛

题意入门

本题没有任何输入。请输出下面这一行(注意下划线、大小写与结尾的感叹号):

We_are_going_to_the_50th_World_Finals!

标准输入标准输出
(空)We_are_going_to_the_50th_World_Finals!
引导性提示

题面写着「本题没有任何输入」——所以一行读入语句都不要写。

把答案串当成数据抄下来,逐字符核对:下划线到底有几个?大小写?最后一个字符是 ! 还是 。?

思路(先自己推,再看这里)

签到题。零输入,直接把字符串打印出来即可,时间、空间都是 O(1)。

唯一会挂的地方是抄错串:本题的串里有 7 个下划线(We_are_going_to_the_50th_World_Finals!),开头 We 是大写 W,结尾是半角感叹号,行末要有换行。

题解代码与拆解
参考代码
#include <cstdio>

int main() {
    // 本题零输入:一个 scanf / cin 都不要写,否则会一直等输入
    const char* ans = "We_are_going_to_the_50th_World_Finals!";
    std::puts(ans);      // puts 输出后自动补换行;用 printf 则要自己写 \n
    return 0;
}
代码拆解
片段作用
// 本题零输入:一个 scanf / cin 都不要写题目没有输入文件内容,写了读入会阻塞在等待输入上
const char* ans = "We_are_going_to_the_50th_World_Finals!"把答案串提到一个变量里,便于和自己数出来的字符逐个对照
std::puts(ans);puts 输出完自动补换行;若改用 printf 必须自己写 \n
return 0;main 正常返回 0;此时输出已经写完
易错点
  • 输出里不能有任何提示语(例如「答案是:」),评测机按字符比对。
  • 别把下划线 _ 打成空格或减号,也别把结尾的 ! 漏掉。
  • 补一句换行更保险;虽然多数评测容忍行末无换行,但没必要赌。

B · 是啊,吃什么

题意普及-

餐厅有 n 个座位,初始有 k 个空位(0 ≤ k ≤ n)。接下来依次发生 m 个事件,用一个长度为 m 的 01 串 s 描述:

  • s_i = 0:一名正在用餐的客人离开,空位数 +1;但若此时所有座位都空(空位数 = n),空位数不变。
  • s_i = 1:一名客人到达。若存在空位,他立刻入座、空位数 −1;否则他必须等位。

你拥有一种「魔法」:每施展一次,可以让一名正在用餐的客人立刻离开,从而立刻空出一个座位。魔法可以在任意时刻施展。求最少需要施展多少次,才能使每一位到达的客人都能立刻入座、无须等位。

输入:第一行 T(1 ≤ T ≤ 10^4);每组数据第一行 n, k, m(1 ≤ n, m ≤ 10^6,0 ≤ k ≤ n),第二行一个长度 m 的 01 串 s。保证 Σn + Σm ≤ 10^6。输出:每组一行一个整数。

标准输入标准输出
3
1 0 3
101
3 2 4
1110
2 2 5
00111
1
1
1
引导性提示

整个过程里真正会影响决策的状态只有一个:当前还有几个空位。别把「谁坐在哪」也记下来。

如果此刻还有空位,施法有意义吗?施法让空位 +1,而下一个到达者立刻就会把它吃掉。

试着证明「只在没有空位的那一刻施法」不会比任何方案差。

思路(先自己推,再看这里)

施法的唯一作用是让空位 +1。若当前空位 > 0,提前施法只是让空位暂时变大,而下一个 s_i = 1 马上把它吃掉——这个座位存不住,等于白施。所以最优解可以在「空位 = 0 且有人到达」时才施法。

而在空位 = 0 时,餐厅必然是满的(n 个座位全被占,且 n ≥ 1),所以一定有「正在用餐的人」可以逼走,施法合法。逼走一人 → 空位变 1 → 该客人立刻坐下 → 空位回到 0。净效果就是:答案 +1,空位数不变。

于是只需 O(m) 扫一遍串,维护空位数 empty:

s_i = 0:若 empty < n 则 ++empty(题面专门说了全空时不增)。

s_i = 1:若 empty > 0 则 --empty;否则 ++ans(施法一次,empty 仍然为 0)。

样例 1:n=1,k=0,串 101 —— 第 1 个 1 时 empty=0,施法一次;随后 0 让 empty 变 1,最后一个 1 顺利入座,答案 1。

样例 2:n=3,k=2,串 1110 —— 前两个 1 吃掉两个空位,第三个 1 时 empty=0 需施法一次,答案 1。

样例 3:n=2,k=2,串 00111 —— 前两个 0 因为全空所以空位不变(仍是 2),两个 1 坐满,第三个 1 施法一次,答案 1。

题解代码与拆解
参考代码
#include <cstdio>

// B:只需跟踪"空位数"这一个状态。
// 施法只在空位为 0 的那一刻才可能有用:让一名正在用餐的人离开,
// 空位 +1,随后到达者立刻坐下,空位又回到 0,净效果就是"多接待一人"。
// 提前施法是浪费(空位迟早会被吃掉),所以贪心按需施法即可。
int main() {
    int T;
    std::scanf("%d", &T);
    static char s[1000005];
    while (T--) {
        int n, k, m;
        std::scanf("%d %d %d", &n, &k, &m);
        std::scanf("%s", s);
        long long empty = k, magic = 0;
        for (int i = 0; i < m; ++i) {
            if (s[i] == '0') {
                if (empty < n) ++empty;      // 全员都在吃时没人能离开,空位不变
            } else {
                if (empty > 0) --empty;      // 有空位,直接入座
                else ++magic;                // 满座:逼走一人腾位,坐下后仍满座
            }
        }
        std::printf("%lld\n", magic);
    }
    return 0;
}
代码拆解
片段作用
static char s[1000005];Σm ≤ 10^6,把串放到静态数组里,避免每组数据反复分配
if (empty < n) ++empty;题面额外规定「所有座位均为空时空位不变」,所以空位封顶在 n
if (empty > 0) --empty;有空位就直接入座,不需要任何代价
else ++magic;满座才施法:逼走一名用餐者腾出座位,该客人随即坐下,空位数仍是 0
std::printf("%lld\n", magic);每组数据独立输出一行,答案用 long long 更稳
易错点
  • s_i = 0 且空位已经是 n 时,空位不能再 +1(题目专门写了这一条,写成无条件 ++empty 会错)。
  • 满座施法后不要以为「多了一个空位」:到达者立刻坐下,空位仍然回到 0,只让答案 +1。
  • 多组数据,empty 和 magic 每组都要重新初始化(初始 empty = k)。
  • Σm 到 10^6,用 cin 逐字符读会偏慢;scanf("%s") 或快读更稳。

C · 小猪玩音游

题意普及

游戏里有 N 首歌,编号 1..N,第 i 首有一个定数 d_i(恰好两位小数,1.00 ≤ d_i ≤ 20.00)。小猪共进行了 M 次游玩,每次记录包含歌曲编号 s_j 和本次准确率 a_j(a_j 是百分数本体,0.00 ≤ a_j ≤ 100.90,恰好两位小数,不带百分号)。

一次定数为 d、准确率为 a% 的游玩,其单曲评分为:

S(d, a) = 0(0 ≤ a < 97.50);S(d, a) = d + 2.15 × (a − 97.50) / (100.90 − 97.50)(97.50 ≤ a ≤ 100.90)。

计算 Rating 时:每首歌只保留它所有游玩记录中的最高单曲评分(同一首歌最多在 B30 中出现一次);把所有歌的最高分从高到低取前 30 个组成 B30;没有游玩过的歌、以及不足 30 首时多出来的位置都记为 0 分。Rating 定义为 B30 中 30 个分数的平均值,即「30 个分数之和 / 30」。

输入:第一行 N, M(1 ≤ N, M ≤ 2 × 10^5);第二行 N 个实数 d_i;接下来 M 行每行 s_j 和 a_j。保证至少有一条游玩记录。输出一个浮点数,绝对或相对误差不超过 10^-6 即算正确。

标准输入标准输出
3 6
15.90 12.00 14.00
1 97.50
1 100.90
2 99.20
2 98.18
3 97.49
1 99.20
1.0375000000
引导性提示

游玩有 M 次,可是歌只有 N 首——第一步显然是把 M 次的信息压到「每首歌一个数」上。

「同一首歌最多只能在 B30 中出现一次」这句话,等价于对每首歌做什么操作?

想清楚分母:只有 1 首歌时,Rating 是「分数/1」还是「分数/30」?

思路(先自己推,再看这里)

第一步 O(M):对每首歌维护 best[i],用 S(d[s], a) 去更新取最大值。同一首歌多次游玩只留最高分,正是「同一首歌最多出现一次」的含义。

第二步 O(N log N):把 best[1..N] 降序排序,取前 min(N, 30) 个求和,再除以 30。不足 30 首的位置按 0 分补,而补 0 不影响和,所以直接只加前 min(N,30) 项即可。

评分公式要注意 a 是「百分数本体」:a = 97.50 时给 d 分,a = 100.90 时给 d + 2.15 分,中间线性。a = 97.49 虽然只差 0.01,也是 0 分——样例的第 3 首歌正是这个陷阱。

验样例:歌 1 最高 15.90 + 2.15 = 18.05;歌 2 在 99.20% 得 12.00 + 2.15 × (99.20−97.50)/3.40 = 13.075;歌 3 的 ACC 97.49 < 97.50 得 0。B30 之和 31.125,其余 27 个位置补 0,Rating = 31.125 / 30 = 1.0375。

double 的精度远好于 1e-6 的宽容度,输出 10 位小数即可。

题解代码与拆解
参考代码
#include <cstdio>
#include <algorithm>
#include <functional>
#include <vector>

// C:每首歌只留最高单曲评分,然后取前 30 高求和除以 30。
// 关键三点:① 同一首歌的多次游玩只取 max;② 不足 30 首用 0 补齐(0 不影响和);
// ③ 评分公式里 a 是百分数本体(如 97.50),不是小数。
static double scoreOf(double d, double a) {
    if (a < 97.50) return 0.0;
    return d + 2.15 * (a - 97.50) / (100.90 - 97.50);
}

int main() {
    int N, M;
    std::scanf("%d %d", &N, &M);
    std::vector<double> d(N + 1), best(N + 1, 0.0);
    for (int i = 1; i <= N; ++i) std::scanf("%lf", &d[i]);
    for (int j = 0; j < M; ++j) {
        int s;
        double a;
        std::scanf("%d %lf", &s, &a);
        double sc = scoreOf(d[s], a);
        if (sc > best[s]) best[s] = sc;               // 每首歌只保留最高分
    }
    // best[1..N] 是各歌的最高分;从大到小排,取前 30
    std::sort(best.begin() + 1, best.end(), std::greater<double>());
    double sum = 0.0;
    for (int i = 1; i <= std::min(N, 30); ++i) sum += best[i];   // 缺的位置补 0
    std::printf("%.10f\n", sum / 30.0);               // 分母恒为 30,不是 min(N,30)
    return 0;
}
代码拆解
片段作用
static double scoreOf(double d, double a)把评分公式抽成函数,避免在两处写出不一致的表达式
if (a < 97.50) return 0.0;严格小于才给 0 分;a = 97.50 恰好拿 d 分,是常见的 off-by-one
return d + 2.15 * (a - 97.50) / (100.90 - 97.50);分母 3.40 是区间长度,2.15 是满分加成,两者都取自题面
if (sc > best[s]) best[s] = sc;同一首歌只保留最高单曲评分,绝不累加
std::sort(best.begin() + 1, best.end(), std::greater<double>());降序排序后前 30 项就是 B30
std::printf("%.10f\n", sum / 30.0);分母恒为 30,不是 min(N,30)
易错点
  • 分母恒为 30。只玩过 1 首歌且最高分 15.90 时,Rating = 15.90/30 = 0.53,不是 15.90。
  • 边界是 a < 97.50 才为 0;a = 97.50 恰好拿 d 分,写成 a <= 97.50 会错。
  • 同一首歌多次游玩取最大值,不是求和、也不是取最后一次。
  • 输入的 a 就是 97.50 这样的数,不要再除以 100;d 也不用做任何换算。

D · 课后作业

题意普及/提高-

定义:把一个正整数的各位数字相乘,对得到的积重复这一操作,直到结果变成一位数,所需步数就是这个数的「乘法持续性」;已经是一位数时持续性为 0。

本题所有运算都在三进制下进行:每一步都把当前数视为三进制数,把它各位数字相乘,结果继续写成三进制。例如 12₃ 各位数字之积为 1 × 2 = 2,所以 12₃ 的持续性为 1。

给定 x(0 ≤ x ≤ 11),请构造一个长度不超过 38 位的三进制正整数,使其乘法持续性恰好为 x,输出这个数(只含 0、1、2,不含前导零)。若不存在,输出 −1。

标准输入标准输出
222

样例说明:22₃ → 11₃ → 1₃,共 2 步,所以输出 22 的持续性为 2。

引导性提示

三进制的数字只有 0、1、2 三种。那么「各位数字相乘」的结果只可能是哪些数?

如果这个数含数字 0 且不止一位,第一步的积是什么?它已经是一位数了吗?

如果数字只含 1 和 2,积只由「2 的个数」决定。把 x 拆成 1 + (一个 2^t 的持续性)来枚举。

思路(先自己推,再看这里)

三进制数字 ∈ {0, 1, 2},所以各位数字之积只可能是 0,或者 2^t(t 为数字 2 的个数)。这把问题一下压扁了:

情形一:这个数含 0 且至少两位 → 第一步的积就是 0,而 0 本身是一位数,持续性 = 1。

情形二:数字只由 1 和 2 组成 → 积恒等于 2^t,其中 t 是数字 2 的个数,持续性 = 1 + P(2^t)(P 表示持续性)。

枚举 t = 0..38 直接算 P(2^t):P(1) = P(2) = 0,P(4) = 1(4 = 11₃ → 1),P(8) = 2(8 = 22₃ → 4 → 1),而 t ≥ 4 时 2^t 的三进制表示必然含 0,P(2^t) = 1。

所以可达的持续性只有 {0, 1, 2, 3},x ≥ 4 一律输出 −1。最小代表分别是:x=0 → 1(一位数)、x=1 → 10(1×0 = 0)、x=2 → 22(样例)、x=3 → 222(222₃ = 26 → 8 → 4 → 1,三步)。

程序里不必背表:对 t = 0..38 现场算 1 + P(2^t) 去匹配 x 就行,复杂度 O(38 log)。

题解代码与拆解
参考代码
#include <cstdio>
#include <string>

// D:三进制只有 0/1/2 三个数字,乘法持续性的取值范围被死死卡住。
//   ① 一个数若含数字 0 且至少两位 -> 积为 0,0 是一位数,持续性 = 1;
//   ② 只含 1/2 -> 积恒等于 2^(2 的个数),记 t = 2 的个数,持续性 = 1 + P(2^t)。
// 于是可达集合 = {0} U {1 + P(2^t) : t = 0..38},用暴力枚举就能确定。
// 实测:P(2^t) 在 t<=38 内只取 0/1/2,可达持续性只有 0,1,2,3,
// 故 x>=4 一律输出 -1。最小代表:1、10、22、222。

// 三进制下的乘法持续性
static int persistence(unsigned long long n) {
    int steps = 0;
    while (n > 2) {                 // 0/1/2 都是一位数,停
        unsigned long long p = 1;
        while (n > 0) { p *= n % 3; n /= 3; }
        n = p;                      // 含 0 时 p = 0,下一轮直接停
        ++steps;
    }
    return steps;
}

int main() {
    int x;
    if (std::scanf("%d", &x) != 1) return 0;
    // 一位数:持续性 0
    if (x == 0) { std::puts("1"); return 0; }
    // 枚举"2 的个数" c,构造 c 个 2 + 若干 1 保证长度 >= 2
    for (int c = 0; c <= 38; ++c) {
        int extra = (c == 0 ? 2 : (c == 1 ? 1 : 0));   // 让总长度至少 2
        int len = c + extra;
        if (len > 38) break;
        unsigned long long v = 1ULL << c;               // c <= 38,2^c 不会溢出
        if (1 + persistence(v) == x) {
            std::string ans(c, '2');
            ans.append(extra, '1');
            std::puts(ans.c_str());
            return 0;
        }
    }
    std::puts("-1");
    return 0;
}
代码拆解
片段作用
static int persistence(unsigned long long n)按定义模拟:一位数(n ≤ 2)停,否则把三进制各位相乘继续
while (n > 2) {三进制的一位数是 0/1/2,所以 n > 2 才需要再迭代一步
while (n > 0) { p *= n % 3; n /= 3; }取出三进制每一位并累乘;遇到 0 时 p 变 0,下一轮直接停
if (x == 0) { std::puts("1"); return 0; }一位数持续性为 0,输出 1(不能输出空串、也不能有前导零)
int extra = (c == 0 ? 2 : (c == 1 ? 1 : 0));保证构造出的数至少两位——「0」是一位数,持续性 0,不能拿来当 x=1 的答案
if (1 + persistence(v) == x)核心判定:全是 1/2 的数,持续性 = 1 + P(2^c)
易错点
  • 别按十进制直觉去枚举数字:38 位三进制到 1.35e18,枚举不完;必须先利用「积只能是 0 或 2^t」。
  • 构造含 0 的答案时至少要两位数字:单独的「0」持续性为 0,不是 1。
  • x = 0 要输出一位数(如 1),不能输出空串,也不能带前导零。
  • 输出的是三进制串,别把 22 理解成十进制的 22(22₃ 才是 8)。

E · QZKago Requiem

题意普及+/提高-

给定一棵 n 个点的带权树,边权 w。树上有 m 个点被指定为「影响源」(s_1..s_m 互不相同)。

为第 i 个影响源设置一个非负整数影响范围 r_i。若 dist(s_i, v) < r_i(严格小于),则称点 v 被 s_i 覆盖。要求树上每个点至少被一个影响源覆盖,在此前提下让所有 r_i 中的最大值尽可能小,输出这个最小值。

输入:第一行 n, m(1 ≤ n ≤ 4 × 10^5,1 ≤ m ≤ n);接下来 n − 1 行每行 u, v, w(1 ≤ w ≤ 10^9);最后一行 m 个互不相同的整数表示影响源位置。输出一个整数。答案可能超过 32 位整数范围。

标准输入标准输出
3 1
1 2 5
2 3 7
1
13
5 2
1 2 4
2 3 3
3 4 10
4 5 2
1 5
8
引导性提示

目标只是「最大半径」尽量小,那每一个 r_i 是不是可以各管各地定?相邻两个源的半径之间有没有耦合约束?

如果最大半径是 R,那么「点 v 被源 s 覆盖」能推出一个只关于 dist(s,v) 和 R 的不等式吗?

把「半径」换回「距离」看看:题目其实在问每个点到最近源的什么量?

思路(先自己推,再看这里)

设所有半径的最大值为 R。点 v 被源 s 覆盖要求 dist(s,v) < r_s ≤ R,而两边都是整数,所以等价于 dist(s,v) ≤ R − 1。

必要性:对每个 v 都要存在某个 s 使 dist(s,v) ≤ R − 1,即 min_s dist(s,v) ≤ R − 1,于是 R ≥ 1 + max_v min_s dist(s,v)。

充分性:把所有源的半径都设成同一个 R。此时覆盖集合就是「半径 R − 1 的球」之并;只要 R − 1 ≥ max_v min_s dist(s,v),就全覆盖了。注意不同的 r_i 之间没有任何耦合约束(没有总能量预算),所以「让所有源都用满」永远不亏。

结论:答案 = 1 + max_v min_s dist(s,v)。做法就是多源 Dijkstra:把所有源以距离 0 一起入堆,跑完取 dist 的最大值再加 1。

验样例 1:源为点 1,dist = 0, 5, 12,max = 12 → 13。验样例 2:源为 1 和 5,最近源距离 = 0, 4, 7, 2, 0,max = 7 → 8。

路径长度可达 4 × 10^5 × 10^9 = 4 × 10^14,必须 64 位整数。

题解代码与拆解
参考代码
#include <cstdio>
#include <cstring>
#include <queue>
#include <vector>

// E:答案就是 "1 + 树上每个点到最近影响源的距离" 的最大值。
// 证明(两句话):设所有半径的最大值为 R。
//   必要性:点 v 被源 s 覆盖要求 dist(s,v) < r_s <= R,即 dist 至多 R-1,
//           所以 R >= 1 + min_s dist(s,v),对每个 v 都成立。
//   充分性:把每个源的半径都取 R,覆盖集合就是半径 R-1 的球之并,正好达标。
// 于是先多源 Dijkstra 求出 dist 数组,取 max 再加 1。边权可到 1e9、路径可到 4e14,必须 64 位。

const int MAXN = 400005;
const int MAXE = 800005;

static int head[MAXN], to[MAXE], nxt[MAXE], ecnt;
static long long wt[MAXE];

static inline void addEdge(int u, int v, int w) {
    to[++ecnt] = v; wt[ecnt] = w; nxt[ecnt] = head[u]; head[u] = ecnt;
}

static char buf[1 << 24];
static int blen, bpos;

static inline int nextInt() {
    while (bpos < blen && (buf[bpos] < '0' || buf[bpos] > '9')) ++bpos;
    int x = 0;
    while (bpos < blen && buf[bpos] >= '0' && buf[bpos] <= '9') x = x * 10 + (buf[bpos++] - '0');
    return x;
}

int main() {
    blen = (int)fread(buf, 1, sizeof(buf), stdin);
    int n = nextInt(), m = nextInt();
    ecnt = 0;
    for (int i = 1; i <= n; ++i) head[i] = 0;
    for (int i = 1; i < n; ++i) {
        int u = nextInt(), v = nextInt(), w = nextInt();
        addEdge(u, v, w);
        addEdge(v, u, w);
    }

    const long long INF = (long long)4e18;
    std::vector<long long> dist(n + 1, INF);
    typedef std::pair<long long, int> PII;
    std::priority_queue<PII, std::vector<PII>, std::greater<PII> > pq;

    for (int i = 0; i < m; ++i) {
        int s = nextInt();
        dist[s] = 0;
        pq.push(PII(0, s));          // 所有源同时入堆 = 多源最短路
    }
    while (!pq.empty()) {
        PII cur = pq.top(); pq.pop();
        long long d = cur.first;
        int u = cur.second;
        if (d > dist[u]) continue;
        for (int e = head[u]; e; e = nxt[e]) {
            int v = to[e];
            if (d + wt[e] < dist[v]) {
                dist[v] = d + wt[e];
                pq.push(PII(dist[v], v));
            }
        }
    }

    long long far = 0;
    for (int i = 1; i <= n; ++i) if (dist[i] > far) far = dist[i];
    std::printf("%lld\n", far + 1);
    return 0;
}
代码拆解
片段作用
dist[s] = 0;所有源初始距离置 0 并一起入堆,堆里跑一次就是「到最近源的距离」
if (d > dist[u]) continue;堆里的过期状态直接跳过,保证每条边只做一次有效松弛
if (d + wt[e] < dist[v])松弛用 long long,边权 10^9 且路径可累加到 10^14
const long long INF = (long long)4e18;INF 要远大于最长路径 4e14;别用 1e18 再去做 d + w 之外的加法
std::printf("%lld\n", far + 1);答案是最大距离 +1:覆盖条件是严格小于,距离恰好为 far 的点盖不到
易错点
  • 覆盖条件是严格不等式 dist < r,所以答案不是最大距离,而是最大距离 + 1。
  • 每个源也要覆盖自己:dist(s,s) = 0 < r_s,所以半径至少为 1——这也是那个 +1 的来源之一。
  • 边权到 10^9、点数到 4 × 10^5,距离必须 long long;用 int 会直接溢出。n = 1 时没有边、m = 1,答案是 1。
  • 不要往「二分答案 + 树形 DP」上想:本题已经证明了答案是闭式,二分既多余又容易写错。

F · 猪猪的难题(简单版)

题意提高+

定义一个非空字符串的权值 w(T) 为其中出现过的不同小写英文字母种类数。

给定长度为 n 的小写英文字母串 S(下标从 1 开始),q 次询问,每次给 l, r,求

Σ_{l ≤ i ≤ j ≤ r} w(S_i S_{i+1} … S_j),

即区间 [l, r] 内所有非空连续子串的权值总和。

输入:第一行 T(1 ≤ T ≤ 1000);每组数据第一行 n, q(1 ≤ n, q ≤ 10^6),第二行串 S,接下来 q 行每行 l, r。保证 Σn ≤ 10^6、Σq ≤ 10^6。输出:每组数据输出一行 q 个整数,空格隔开。答案可能超过 32 位整数范围。

标准输入标准输出
1
3 3
abc
1 3
1 1
2 3
10 1 4
引导性提示

不要真的去枚举子串。换一种方式数「不同字符种类数」:它等于区间里「是某个字符最后一次出现」的位置个数。

固定右端点 r,让左端点 i 从 1 到 r 变,每个位置 p 对答案的贡献能写成一个关于 i 的简单式子吗?

把求和次序交换后,答案会变成 Σ (p − l + 1) · c_p(r) 的形状。然后想:r 增加 1 时,哪些 p 的 c 值不再变化?

思路(先自己推,再看这里)

记 next[p] 为 p 之后与 S[p] 同字符的下一个位置(没有则记 n+1)。那么 S[i..j] 里每个字符恰好在它「最后一次出现」的位置被数到一次,即 w(S[i..j]) = #{ p ∈ [i, j] : next[p] > j }。

交换求和次序并整理,可以得到一个很干净的闭式:

Ans(l, r) = Σ_{p=l..r} (p − l + 1) · c_p(r), 其中 c_p(r) = min(r, next[p] − 1) − p + 1。

c_p(r) 的含义是:p 在多少个右端点 r' ≤ r 上仍然是「最后一次出现」,也就是它对 B 数组的累计贡献。

于是 Ans = S1 − l · S2,其中 S2 = Σ c_p(r)、S1 = Σ (p + 1) · c_p(r)(p 取遍 [l, r])。用两个树状数组分别维护 c_p 和 (p+1)·c_p 即可。

关键观察:当右端点从 r−1 走到 r 时,唯一会「死掉」的位置是上一个与 S[r] 同字符的位置 pv —— 它的 next 就是 r,从此刻起 c_pv 永远定格为 r − pv,把它塞进树状数组。而仍然活着的位置恰好是 26 个字符各自的最后一次出现位置,最多 26 个,查询时直接扫一遍累加,不必进树状数组。

复杂度 O(n log n + q (26 + log n)),Σn、Σq 各 10^6 都跑得很快(实测 1.6s)。

验样例:S = abc,next = 4, 4, 4,于是 c = 3, 2, 1;S2 = 6,S1 = 2×3 + 3×2 + 4×1 = 16;Ans(1,3) = 16 − 1×6 = 10 ✓Ans(1,1) = 2 − 1×1 = 1 ✓ Ans(2,3) = 10 − 2×3 = 4 ✓

题解代码与拆解
参考代码
#include <cstdio>
#include <cstring>

// F:Ans(l,r) = sum_{l<=i<=j<=r} w(S[i..j])
// 关键变形:把"每个子串的不同字符数"换成"每个位置 p 作为某字符最后一次出现"的次数。
//   w(S[i..j]) = #{ p in [i,j] : next[p] > j },next[p] 是 p 之后同字符的下一个位置。
// 交换求和次序可得
//   Ans(l,r) = sum_{p=l..r} (p-l+1) * c_p(r),
//   c_p(r)   = min(r, next[p]-1) - p + 1  = "p 在多少个 r' <= r 里仍存活"。
// 于是答案 = S1 - l*S2,其中
//   S2 = sum c_p(r),S1 = sum (p+1)*c_p(r)(p 取遍 [l,r])。
// 当 r 前进时:只有"上一个同字符位置"会死掉,把它的 c 定死成 (r-p) 塞进树状数组;
// 而仍然存活的 p 恰好是 26 个字符各自的最后出现位置,最多 26 个,直接扫一遍即可。

const int MAXN = 1000005;

static int n, q;
static char s[MAXN];
static long long bit1[MAXN], bit2[MAXN];   // bit1 存 c_p,bit2 存 (p+1)*c_p
static int last[26];
static int qhead[MAXN], qnext[MAXN], qleft[MAXN];
static long long qans[MAXN];

static inline void bitAdd(long long* b, int i, long long v) {
    for (; i <= n; i += i & (-i)) b[i] += v;
}
static inline long long bitSum(long long* b, int i) {
    long long r = 0;
    for (; i > 0; i -= i & (-i)) r += b[i];
    return r;
}

static char obuf[1 << 25];
static int olen;

static inline void writeLL(long long v) {
    if (v == 0) { obuf[olen++] = '0'; return; }
    char t[24]; int k = 0;
    while (v > 0) { t[k++] = (char)('0' + v % 10); v /= 10; }
    while (k > 0) obuf[olen++] = t[--k];
}

int main() {
    int T;
    if (std::scanf("%d", &T) != 1) return 0;
    while (T--) {
        std::scanf("%d %d", &n, &q);
        std::scanf("%s", s + 1);
        for (int i = 0; i <= n + 1; ++i) { bit1[i] = bit2[i] = 0; qhead[i] = -1; }
        memset(last, 0, sizeof(last));
        for (int j = 0; j < q; ++j) {
            int l, r;
            std::scanf("%d %d", &l, &r);
            qleft[j] = l;
            qnext[j] = qhead[r];          // 按右端点把询问挂成链表
            qhead[r] = j;
        }
        for (int r = 1; r <= n; ++r) {
            int ch = s[r] - 'a';
            int pv = last[ch];
            if (pv) {                     // pv 的 next 就是 r,它的 c 从此定格为 r-pv
                long long v = r - pv;
                bitAdd(bit1, pv, v);
                bitAdd(bit2, pv, (long long)(pv + 1) * v);
            }
            last[ch] = r;
            for (int e = qhead[r]; e != -1; e = qnext[e]) {
                int l = qleft[e];
                long long S2 = bitSum(bit1, r) - bitSum(bit1, l - 1);
                long long S1 = bitSum(bit2, r) - bitSum(bit2, l - 1);
                for (int c = 0; c < 26; ++c) {
                    int p = last[c];      // 存活位置:各字符的最后一次出现
                    if (p >= l) {         // last[c] <= r 恒成立,只需判下界
                        long long cc = r - p + 1;
                        S2 += cc;
                        S1 += (long long)(p + 1) * cc;
                    }
                }
                qans[e] = S1 - (long long)l * S2;
            }
        }
        for (int j = 0; j < q; ++j) {
            if (j) obuf[olen++] = ' ';
            writeLL(qans[j]);
        }
        obuf[olen++] = '\n';
    }
    fwrite(obuf, 1, olen, stdout);
    return 0;
}
代码拆解
片段作用
bitAdd(bit2, pv, (long long)(pv + 1) * v);上一个同字符位置在此刻死亡,把 c 定格成 r−pv 后一次性写进两个树状数组
last[ch] = r;维护 26 个字符的最后出现位置——它们是查询时唯一还「活着」的候选
if (p >= l) {活着的位置天然 ≤ r,只需判下界 p ≥ l,省掉一次上界判断
qnext[j] = qhead[r];把询问按右端点挂成链表,一次扫描回答全部询问
qans[e] = S1 - (long long)l * S2;闭式答案:Σ(p+1)c − l·Σc,两个量都来自 BIT + 26 个活位置
writeLL(qans[j]);q 可达 10^6 且必须输出在同一行,手动缓冲区比 cout 快很多
易错点
  • c_p 里是 next[p] − 1 而不是 next[p]:p 在 r = next[p] 时才死亡,所以 r 最大只能取到 next[p] − 1。
  • 死掉的位置只能在它死亡的那一刻写入一次,否则会被重复累计;多组数据还要把 BIT、qhead、last 全清空(清空范围是 0..n)。
  • 答案量级约 10^13,而 S1 会到 10^17,必须全程 long long;BIT 用 int 会溢出。
  • 输出是「每组数据一行 q 个数」,不是每个询问一行,别多打换行。

G · 智慧大学生猪猪比赛

题意提高-

n 名评委给出原始评分 a_1..a_n。要把所有评委划分成若干小组,每人恰属一组,每组至少 2 人。

每组选定一个整数 x 作为该组所有评委的最终评分,要求组内每个 i 满足 |x − a_i| ≤ d。不同组可以选不同的 x,x 没有额外的取值范围限制。小猪的总分为所有评委最终评分之和,即一个有 k 人、最终评分为 x 的组贡献 k·x。求最大总分;若无法完成合法分组,输出 Impossible。

输入:第一行 T(1 ≤ T ≤ 10^5);每组第一行 n, d(2 ≤ n ≤ 10^6,0 ≤ d ≤ 10^9),第二行 n 个整数 a_i(0 ≤ a_i ≤ 10^9)。保证 Σn ≤ 10^6。输出:每组一行,总分或 Impossible(注意大小写)。

标准输入标准输出
3
4 2
0 1 2 3
3 0
0 1 1
3 2
2 4 6
12
Impossible
12
引导性提示

先排序。如果两个人的组「交错」在一起,把其中一个人换到另一个组,总分和可行性会不会变差?

一个组里 x 取多大最赚?它被组内最小值还是最大值卡住?

把一个 4 人大组拆成两个 2 人组,总分变大了还是变小了?推广一下能得到什么结论?

思路(先自己推,再看这里)

先排序。若两组的人交错(a_u ≤ a_v ≤ a_w,u、w 同组而 v 在别组),把 v 换进来不会让「最大值−最小值」变大,而每个组的 a_min 也不会变大——所以最优解一定可以取成连续段分组。

对一段 [l, r]:要存在整数 x 使所有 |x − a_i| ≤ d,等价于 a_r − a_l ≤ 2d。在此前提下 k·x 随 x 增大而增大,而 x 的上界是 a_l + d(a_l、d 都是整数,所以 x 也是整数)。于是该段贡献 (r − l + 1)(a_l + d)。

关键剪枝:把一段拆成更小的段后,每一小段的 a_min 都不小于原段的 a_l,所以 Σ size·(a_min + d) 只会变大;而最细的拆法只能拆到 2 元组和 3 元组(每组至少 2 人)。因此最优解只由 2 元组和 3 元组构成。

于是 dp[i] = 前缀 i 的最大总分,转移只看最后一段是 2 个还是 3 个:

dp[i] = max( dp[i−2] + 2(a_{i−1} + d) 若 a_i − a_{i−1} ≤ 2d , dp[i−3] + 3(a_{i−2} + d) 若 i ≥ 3 且 a_i − a_{i−2} ≤ 2d )。

用 −∞ 表示该前缀无法划分;dp[n] 仍是 −∞ 就输出 Impossible。复杂度 O(n log n)(排序)。

验样例 1:排序后 0 1 2 3,dp = 0, −∞, 4, 6, 12 —— 分成 {0,1} 取 x=2、{2,3} 取 x=4,总分 4 + 8 = 12。

验样例 2:d = 0 时只有原始分相同的评委能同组,原始分为 0 的评委(只有 1 人)无法进任何合法组 → Impossible。

验样例 3:三人一组,a_min = 2,x 取 2 + 2 = 4,总分 3 × 4 = 12。

题解代码与拆解
参考代码
#include <cstdio>
#include <algorithm>
#include <vector>
#include <climits>

// G:排序后最优解的分组一定是连续段(把交错的两个人互换不会变差)。
// 一段 [l,r] 里所有 a 都要满足 |x-a| <= d,故 x 最大取 a_l + d(要整数,a_l、d 都是整数),
// 该段贡献 (r-l+1)*(a_l+d),可行当且仅当 a_r - a_l <= 2d。
// 再注意一个单调事实:把一个大段拆成若干小段,每小段的 a_l 都不小于原段的 a_l,
// 所以拆开只会更赚 —— 最细的拆法只能拆到 2 元组和 3 元组。
// 于是 dp[i] 只用考虑"最后一段是 2 个还是 3 个"。
int main() {
    int T;
    std::scanf("%d", &T);
    const long long NEG = LLONG_MIN / 4;
    while (T--) {
        int n;
        long long d;
        std::scanf("%d %lld", &n, &d);
        std::vector<long long> a(n + 1);
        for (int i = 1; i <= n; ++i) std::scanf("%lld", &a[i]);
        std::sort(a.begin() + 1, a.end());

        long long lim = 2 * d;
        std::vector<long long> dp(n + 1, NEG);
        dp[0] = 0;
        for (int i = 2; i <= n; ++i) {
            if (dp[i - 2] > NEG / 2 && a[i] - a[i - 1] <= lim)      // 最后一段是 {i-1, i}
                dp[i] = std::max(dp[i], dp[i - 2] + 2 * (a[i - 1] + d));
            if (i >= 3 && dp[i - 3] > NEG / 2 && a[i] - a[i - 2] <= lim)  // 最后一段是 {i-2,i-1,i}
                dp[i] = std::max(dp[i], dp[i - 3] + 3 * (a[i - 2] + d));
        }
        if (dp[n] <= NEG / 2) std::puts("Impossible");
        else std::printf("%lld\n", dp[n]);
    }
    return 0;
}
代码拆解
片段作用
std::sort(a.begin() + 1, a.end());先排序,之后最优分组才是连续段,DP 才能只看相邻元素
long long lim = 2 * d;一段可行 ⟺ a_r − a_l ≤ 2d,阈值是 2d 不是 d
dp[i - 2] + 2 * (a[i - 1] + d)最后一段是 {i−1, i},x 取该段最小值加 d 最赚
dp[i - 3] + 3 * (a[i - 2] + d)最后一段是 {i−2, i−1, i},n 为奇数时必然用到这一支
if (dp[i - 2] > NEG / 2 && a[i] - a[i - 1] <= lim)先判可达再判可行,避免 NEG 参与加法后下溢
if (dp[n] <= NEG / 2) std::puts("Impossible");dp[n] 不可达才无解
易错点
  • x 的上界是 a_l + d(不是 a_l、也不是平均值);只有取到这个上界才最赚。
  • 可行性是 a_r − a_l ≤ 2d,不是 ≤ d。
  • dp 必须区分「不可达」,用一个足够小的负数当 −∞,并防止它参与加法后下溢成正数。
  • 总分可达 10^6 × 2 × 10^9 = 2 × 10^15 要用 long long;无解输出的是 Impossible(首字母大写)。

H · 取整互交

题意提高

本题是交互题。交互器隐藏了一个整数 n(10^9 ≤ n ≤ 10^18),在整个交互过程中不变。

每次询问,你输出一行 `? k`,其中 k 是满足 10^9 ≤ k ≤ 10^18 的整数;随后从标准输入读回一个整数,值为 ⌊n / k⌋。

你最多可以进行 32 次形如 `? k` 的询问。确定 n 之后,输出一行 `! n` 并立即结束程序。每次输出查询或最终答案后都必须输出换行并刷新输出缓冲区,否则可能得到 Idleness Limit Exceeded。

本题没有初始输入。样例展示的是 n = 10^9 时的一次交互(样例中的回答不一定足以唯一确定 n,仅示意格式):

标准输入标准输出
0
0
? 1234567890
? 1000000001
! 1000000000
引导性提示

k 最小只能是 10^9。那么第一次固定问 k = 10^9,得到的 ⌊n/10^9⌋ 能把 n 锁进多宽的区间?

在锁定的区间里,如果取 k = mid,能不能让 ⌊n/mid⌋ 变成一个「n ≥ mid 吗」的布尔判断?

数一数:宽度 10^9 的区间二分需要多少次?再加上前面那 1 次,够不够 32?

思路(先自己推,再看这里)

第一阶段(1 次询问):问 k = 10^9,拿到 a = ⌊n / 10^9⌋。由整除的定义立即有 n ∈ [a·10^9, (a+1)·10^9 − 1],区间宽度正好 10^9。

第二阶段(把 k 当比较器):在窗口内取 k = mid。因为 mid ≥ a·10^9 且 n < (a+1)·10^9 ≤ 2·mid,所以 ⌊n/mid⌋ 只可能是 0 或 1,且⌊n/mid⌋ ≥ 1 ⟺ n ≥ mid。一次询问等于一次比较。

于是对宽度 10^9 的窗口二分,需要 ⌈log2 10^9⌉ = 30 次。总计 1 + 30 = 31 次,卡进 32。

两个边界要处理:n = 10^18 时 a = 10^9,窗口上界被 10^18 截断成单点,直接输出;a ≥ 1 是因为 n ≥ 10^9,所以下界 a·10^9 一定 ≥ 10^9,k 不会越界。

样例解读:n = 10^9,a = 1,窗口 [10^9, 2·10^9 − 1]。问 1234567890 得 0 ⇒ n < 1234567890;问 1000000001 得 0 ⇒ n < 1000000001,配合 n ≥ 10^9 就锁定 n = 10^9。

题解代码与拆解
参考代码
#include <cstdio>
#include <cstdlib>
#include <algorithm>

// H:k 可以取到 1e18,而 n <= 1e18,所以询问 k 得到的 floor(n/k) 只有
//   "n >= k ? 至少 1 : 0" 这一位信息最好用。
// 但若直接对 [1e9,1e18] 二分要 60 次,超了。先花 1 次询问 k = 1e9 拿到
//   a = floor(n/1e9),就把 n 锁进长度恰好 1e9 的窗口 [a*1e9, (a+1)*1e9-1]。
// 窗口内再用 k = mid 询问:由 mid >= a*1e9 且 n < (a+1)*1e9 <= 2*mid 可知
//   floor(n/mid) >= 1  <=>  n >= mid
// 正好是一次比较,于是 30 次二分足够。总计 1 + 30 = 31 <= 32 次。

static long long ask(long long k) {
    std::printf("? %lld\n", k);
    std::fflush(stdout);
    long long q;
    if (std::scanf("%lld", &q) != 1) std::exit(0);
    return q;
}

int main() {
    const long long LO = 1000000000LL;         // 1e9
    const long long HI = 1000000000000000000LL; // 1e18
    long long a = ask(LO);                     // a = floor(n / 1e9)
    long long lo = a * LO;
    long long hi = std::min((a + 1) * LO - 1, HI);
    if (lo < LO) lo = LO;
    while (lo < hi) {
        long long mid = lo + (hi - lo + 1) / 2;   // 上取整,避免死循环
        if (ask(mid) >= 1) lo = mid;              // n >= mid
        else hi = mid - 1;
    }
    std::printf("! %lld\n", lo);
    std::fflush(stdout);
    return 0;
}
代码拆解
片段作用
long long a = ask(LO);第一阶段:k = 10^9,用一次询问把 n 压进宽度 10^9 的窗口
long long hi = std::min((a + 1) * LO - 1, HI);上界要截到 10^18,否则 mid 会超出 k 的允许范围
long long mid = lo + (hi - lo + 1) / 2;上取整,配合 lo = mid 才能收敛而不是死循环
if (ask(mid) >= 1) lo = mid;窗口内 ⌊n/mid⌋ ≥ 1 ⟺ n ≥ mid,正好一次比较
std::fflush(stdout);每次输出后必须刷新,否则交互器收不到你的询问
std::printf("! %lld\n", lo);最终答案格式,输出后立即结束
易错点
  • 每次 printf 之后都要 fflush(stdout),交互题最常见的挂法就是忘了刷新。
  • 二分必须用上取整 mid = (lo + hi + 1) / 2,否则 lo = mid 时区间不缩小、死循环。
  • 判定方向别弄反:⌊n/mid⌋ ≥ 1 说明 n ≥ mid(含等号),不是 n > mid。
  • k 必须落在 [10^9, 10^18]:窗口下界天然 ≥ 10^9,上界要 min 上 10^18;询问上限 32 次(形如 `! n` 的最终答案不计入),别把次数浪费在边界上。

I · SBR:荒野中的觉悟

题意提高-

有 n 名神枪手,第 i 名的命中率为 a_i / 100(a_i 为整数)。进行一轮射击:

  • 每名神枪手从除自己以外的 n − 1 人中,等概率随机选择一人作为目标。
  • 所有人同时射击,每人只射一发;即使本轮被击中,也会完成自己的射击。
  • 第 i 名的子弹以 a_i / 100 的概率命中所选目标,否则不击中任何人。
  • 一个人只要被至少一发子弹击中就死亡;未被任何子弹击中的人存活。
  • 各人的目标选择相互独立,各发子弹是否命中也相互独立,且与所有目标选择独立。

求这一轮结束后存活人数的期望,输出它对 998244353 取模的结果。

输入:第一行 T(1 ≤ T ≤ 10^5);每组第一行 n(2 ≤ n ≤ 2 × 10^6),第二行 n 个整数 a_i(0 ≤ a_i ≤ 100)。保证 Σn ≤ 2 × 10^6。

标准输入标准输出
1
3
100 100 100
249561089
1
2
25 80
848507701
引导性提示

直接算「存活 k 人的概率」是 O(n^2),但期望有线性性——先只算一个人存活的概率。

第 j 个人打中第 i 个人的概率是多少?(先选目标,再判命中,两个因子都要乘上。)

「第 i 人存活」要求其余每个人都打空他。这些事件关于 j 相互独立吗?乘起来之后,Σ_i 里能不能提公因式?

思路(先自己推,再看这里)

用期望的线性性:E[存活人数] = Σ_i P(第 i 人存活),问题变成 n 个独立的小概率。

j 打中 i 的概率 = (a_j / 100) × 1/(n − 1)。所以 P(i 存活) = Π_{j ≠ i} p_j,其中 p_j = 1 − a_j / (100(n − 1))。

于是 E = Σ_i Π_{j≠i} p_j = (Π_j p_j) × (Σ_i 1/p_i) —— 一次前缀积加上一次倒数和,O(n)。

p_j = 0 怎么办?p_j = 0 ⟺ a_j = 100(n − 1)。因为 a_j ≤ 100,这只可能出现在 n = 2 且 a_j = 100 时。分三种情况:两个 0 → 所有人都必死,答案 0;恰好一个 0 → 只有那个人的「他人乘积」非零,答案就是其余 p 的乘积;没有 0 → 用上面的公式。

模运算:p_j = (100(n − 1) − a_j) / (100(n − 1)),1/p_j = 100(n − 1) / (100(n − 1) − a_j)。由于 n ≤ 2 × 10^6,有 100(n − 1) ≤ 2 × 10^8 < 998244353,分母恒可逆。

验样例 2:n = 2,p_1 = 3/4、p_2 = 1/5。P(1 存活) = 1/5,P(2 存活) = 3/4,E = 19/20,取模得 848507701。

验样例 1:n = 3,p 全为 1/2,E = 3 × (1/2)^2 = 3/4,取模得 249561089。

题解代码与拆解
参考代码
#include <cstdio>
#include <cstring>

// I:求存活人数期望。用期望的线性性,逐个算每个人存活的概率。
// 子弹 j 打中 i 的概率 = (a_j/100) * 1/(n-1),所以
//   P(i 存活) = prod_{j != i} (1 - a_j / (100(n-1))) = prod_{j != i} p_j。
// 于是 E = sum_i prod_{j!=i} p_j = (prod_j p_j) * (sum_i 1/p_i)。
// p_j = 0 只会出现在 n == 2 且 a_j == 100 时(此时 100(n-1) == 100),要单独处理:
//   两个 0 -> 全员必死,答案 0;一个 0 -> 只有那个零的"别人乘积"非零。

typedef long long ll;
const ll MOD = 998244353;

static ll pw(ll b, ll e) {
    ll r = 1;
    b %= MOD;
    while (e > 0) {
        if (e & 1) r = r * b % MOD;
        b = b * b % MOD;
        e >>= 1;
    }
    return r;
}

static char buf[1 << 24];
static int blen, bpos;

static inline int nextInt() {
    while (bpos < blen && (buf[bpos] < '0' || buf[bpos] > '9')) ++bpos;
    int x = 0;
    while (bpos < blen && buf[bpos] >= '0' && buf[bpos] <= '9') x = x * 10 + (buf[bpos++] - '0');
    return x;
}

int main() {
    blen = (int)fread(buf, 1, sizeof(buf), stdin);
    int T = nextInt();
    static int cnt[101];
    static ll invMemo[101];
    while (T--) {
        int n = nextInt();
        ll Dnum = 100LL * (n - 1);          // 分母的分子部分,<= 2e8 < MOD
        ll invD = pw(Dnum % MOD, MOD - 2);
        memset(cnt, 0, sizeof(cnt));
        for (int i = 0; i < 101; ++i) invMemo[i] = -1;

        ll zeros = 0, accNum = 1;
        for (int i = 0; i < n; ++i) {
            int x = nextInt();
            ++cnt[x];
            ll num = Dnum - x;              // p = num / Dnum
            if (num == 0) ++zeros;
            else accNum = accNum * (num % MOD) % MOD;
        }
        ll prod = accNum * pw(invD, n - zeros) % MOD;   // 只对非零项除 Dnum,零项已跳过

        ll invsum = 0;
        for (int x = 0; x <= 100; ++x) {
            if (!cnt[x]) continue;
            ll num = Dnum - x;
            if (num == 0) continue;
            if (invMemo[x] < 0) invMemo[x] = pw(num % MOD, MOD - 2);
            // 1/p = Dnum / num
            ll invp = (Dnum % MOD) * invMemo[x] % MOD;
            invsum = (invsum + (ll)cnt[x] % MOD * invp) % MOD;
        }

        ll ans;
        if (zeros >= 2) ans = 0;
        else if (zeros == 1) ans = prod;            // 只有那个零的"他人乘积"非零
        else ans = prod * invsum % MOD;
        std::printf("%lld\n", ans);
    }
    return 0;
}
代码拆解
片段作用
ll Dnum = 100LL * (n - 1);命中率的分母:先除以 n−1 选目标,再乘 a/100,合起来分母是 100(n−1)
if (num == 0) ++zeros;分子为 0 说明 p_j = 0,不能求逆,单独计数
ll prod = accNum * pw(invD, n - zeros) % MOD;Π p_j = (Π 非零分子) / D^(非零项个数):只对非零项除 D,指数不是 n
ll invp = (Dnum % MOD) * invMemo[x] % MOD;1/p = D / (D − a),用逆元代替除法
if (zeros >= 2) ans = 0;两个零 → 所有人都被必中的子弹覆盖,期望 0
else if (zeros == 1) ans = prod;一个零 → 只有那个零的「其他人乘积」不为 0
ans = prod * invsum % MOD;一般情形:E = (Π p_j) × Σ (1/p_j)
易错点
  • 分母是 100(n − 1) 而不是 100:每颗子弹先在 n − 1 个目标里等概率选一个。
  • p_j 可能等于 0,绝不能直接求逆元;必须先数零的个数再分情况。
  • Π p_j 的指数是「非零项的个数」,不是 n —— 跳过零项后仍按 n 次除会算错(这里踩过坑)。
  • a 只有 101 种取值,逆元可以按下标缓存;Σn 到 2 × 10^6 必须快读。输出是模意义下的整数,不是浮点数。

J · 捕风的异乡人

题意提高+

给定长度为 n 的正整数序列 a_1..a_n,把它划分成若干连续区间,每个区间的长度都必须 ≥ k。约定 0 = p_0 ≤ p_1 ≤ … ≤ p_m = n,第 t 个区间是 [p_{t−1} + 1, p_t]。

若某个区间内所有元素之积整除 u(即 Π a_j | u),则这个区间可以完成一次有效共鸣。请选择一种划分方案,使有效区间的数量最大,输出这个最大值。

输入:第一行 n, k, u(1 ≤ k ≤ n ≤ 500000,1 ≤ u ≤ 10^18);第二行 n 个正整数 a_i(1 ≤ a_i ≤ 500000)。

标准输入标准输出
8 3 12
2 2 3 1 2 2 3 5
1
引导性提示

固定右端点 i,把左端点 s 往左推时,乘积 Πa[s..i] 会怎么变?能让它整除 u 的 s 有什么形状?

形状是一段后缀。那怎么在线维护这个后缀的左边界?想想「乘积一旦不整除就再也回不来」。

dp[i] 由 dp 的一段区间最大值转移而来,用线段树(或单调队列)把 O(n^2) 压成 O(n log n)。

思路(先自己推,再看这里)

先看可行性结构:若某个 a_i 单独就不整除 u,那么任何含 i 的区间都不可能整除 u,窗口只能从 i+1 重新开始。

否则,把左端点 s 往左移只会让乘积变大、更难整除,所以「能让 Πa[s..i] 整除 u」的 s 恰好是一段后缀 [L, i]。用双指针维护 L:每加入 a_i 就把左端往右挪,直到乘积重新整除 u。由于 L 单调不减,总复杂度 O(n)。

再叠 DP:dp[i] 表示前缀 i 的最大有效区间数。最后一段是 [j + 1, i],必须满足 i − j ≥ k,即分界点 j ≤ i − k。于是

dp[i] = max( 1 + max{ dp[j] : L − 1 ≤ j ≤ i − k } , max{ dp[j] : 0 ≤ j ≤ min(L − 2, i − k) } )。

前一项表示最后一段有效(+1),后一项表示最后一段无效(不加分,但允许存在)。两个区间最大值都用线段树查。

乘积不会溢出:因为只在 prod 整除 u 时保留,prod ≤ u ≤ 10^18;加入 a_i 前先用 prod > u / a[i] 判一下,再决定是否把左端挪走。

验样例:a = 2 2 3 1 2 2 3 5,k = 3,u = 12。L 依次为 1,1,1,1,2,3,4,9,dp = 0,−∞,−∞,1,1,0,2,2,1,输出 dp[8] = 1。唯一有效的划分是 [1,3](积 12)+ 剩下的 [4,8](积 60,无效)。

题解代码与拆解
参考代码
#include <cstdio>
#include <algorithm>
#include <climits>

// J:dp[i] = 前缀 i 能拿到的最大有效区间数。
// 对固定右端点 i,"起点 s 使 prod(a[s..i]) 整除 u" 的 s 构成一段后缀 [L, i]
// (把区间再往左延长只会让乘积变大、更难整除,所以可行集合对 s 单调),
// 用双指针维护这个 L;L = i+1 表示一个有效段都没有。
// 于是
//   dp[i] = max( 1 + max dp[L-1 .. i-k] ,  max dp[0 .. min(L-2, i-k)] )
// 前一项 = 最后一段有效,后一项 = 最后一段无效(无效段允许,只是不加分)。
// 注意长度约束:最后一段长度 >= k,所以分界点 j <= i-k。
// 两个区间最大值都落在 dp 上,线段树查询,整体 O(n log n)。

typedef long long ll;
const ll NEG = LLONG_MIN / 4;

static int n, k;
static ll u;
static ll a[500005];
static ll dp[500005];

static int segsz;
static ll seg[1 << 21];

static void segInit(int m) {
    segsz = 1;
    while (segsz < m) segsz <<= 1;
    for (int i = 0; i < 2 * segsz; ++i) seg[i] = NEG;
}
static void segUpdate(int p, ll v) {
    p += segsz;
    seg[p] = v;
    for (p >>= 1; p; p >>= 1) seg[p] = std::max(seg[p << 1], seg[p << 1 | 1]);
}
static ll segQuery(int l, int r) {          // 闭区间 [l,r];l > r 返回 NEG
    if (l > r) return NEG;
    ll res = NEG;
    for (l += segsz, r += segsz; l <= r; l >>= 1, r >>= 1) {
        if (l & 1) res = std::max(res, seg[l++]);
        if (!(r & 1)) res = std::max(res, seg[r--]);
    }
    return res;
}

int main() {
    std::scanf("%d %d %lld", &n, &k, &u);
    for (int i = 1; i <= n; ++i) std::scanf("%lld", &a[i]);

    segInit(n + 1);
    dp[0] = 0;
    segUpdate(0, 0);

    int L = 1;
    ll prod = 1;                 // prod = a[L] * ... * a[i],始终 <= u,不会乘爆
    for (int i = 1; i <= n; ++i) {
        if (u % a[i] != 0) {
            // 单个 a[i] 都不整除 u,任何有效段都不能含 i:窗口清空
            L = i + 1;
            prod = 1;
        } else {
            while (L <= i && prod > u / a[i]) { prod /= a[L]; ++L; }  // 先防溢出
            prod *= a[i];
            while (L <= i && u % prod != 0) { prod /= a[L]; ++L; }    // 再保证整除
        }

        int hi = i - k;                        // 分界点 j 的上界
        ll best = NEG;
        if (hi >= L - 1) best = std::max(best, segQuery(L - 1, hi) + 1);
        if (hi >= 0 && L - 2 >= 0) best = std::max(best, segQuery(0, std::min(L - 2, hi)));
        dp[i] = best;
        segUpdate(i, dp[i]);
    }
    std::printf("%lld\n", dp[n] < 0 ? 0LL : dp[n]);
    return 0;
}
代码拆解
片段作用
if (u % a[i] != 0) {单个元素不整除 u,任何有效段都不能含它,窗口直接清空(此时 L = i+1 让「有效后缀」为空)
while (L <= i && prod > u / a[i]) { prod /= a[L]; ++L; }先防溢出:时刻保证 prod * a[i] ≤ u,所以 prod(long long)永远不会超出 10^18
while (L <= i && u % prod != 0) { prod /= a[L]; ++L; }左端单调右移,直到乘积重新整除 u;L 推进到 i+1 时 prod = 1 必然整除,循环必然结束
int hi = i - k;长度约束:最后一段长度 ≥ k,所以分界点 j ≤ i − k
if (hi >= L - 1) best = std::max(best, segQuery(L - 1, hi) + 1);最后一段有效的转移:起点 s = j + 1 ≥ L,即 j ≥ L − 1
if (hi >= 0 && L - 2 >= 0) best = std::max(best, segQuery(0, std::min(L - 2, hi)));最后一段无效也要参与转移,只是不加分;漏掉这一支会把合法划分误判成不可达
易错点
  • 必须先判 u % a[i] != 0 再谈 u / a[i],否则 a[i] > u 时 u / a[i] = 0,逻辑全乱;乘法也要用 prod > u / a[i] 提前拦住。
  • 左端右移时可能连续遇到 a[L] = 1,prod 不变;靠 L 自身的单调推进保证循环终止,不要写成 while 里只看 prod。
  • 长度约束的分界点是 j ≤ i − k,不是 j ≤ i − k + 1(最后一段有 i − j 个元素)。
  • 无效段必须参与 DP 转移,只是不加分;只考虑有效段会得到 −∞。答案下限是 0,因为整段作为一个区间(长度 n ≥ k)永远合法。

K · 野比大雄的射击训练

题意入门

有一个 (2n − 1) × (2n − 1) 的正方形靶盘,行列均从 1 编号,中心格坐标为 (n, n)。

靶盘由中心向外划分为若干方形环:中心格得 n 分;围绕中心的第一圈共 8 个格子,得分均为 n − 1;此后每向外一圈得分减少 1,最外圈得分均为 1。当 n = 1 时靶盘只有一个格子,得 1 分。

现在有 k 支飞镖落在靶盘上,落点互不相同。给定每支飞镖的落点坐标 (x_i, y_i),求它们的总分。

输入:第一行 n, k(1 ≤ n ≤ 1000,1 ≤ k ≤ (2n − 1)^2);接下来 k 行每行 x_i, y_i(1 ≤ x_i, y_i ≤ 2n − 1),保证坐标对两两不同。输出一个整数。

标准输入标准输出
3 4
3 3
1 1
2 4
5 3
7

样例说明:n = 3 时四支飞镖分别得 3、1、2、1 分,总分 7。

引导性提示

「第 d 圈」这个几何概念,用坐标怎么写成一个式子?

先看四个角和四条边上的格子:角是 |dx| = |dy| = d,边是 max(|dx|,|dy|) = d。

是 max(|dx|, |dy|) 还是 |dx| + |dy|?拿样例里的 (1,1) 试一试就知道。

思路(先自己推,再看这里)

以中心 (n, n) 为原点看偏移 dx = x − n、dy = y − n。第 d 圈正是 max(|dx|, |dy|) = d 的等值线,也就是「切比雪夫距离」,而这一圈的得分是 n − d。

所以每一支飞镖的得分就是 n − max(|x − n|, |y − n|),逐个累加即可,O(k)。

验样例:n = 3。(3,3) → d = 0 → 3 分;(1,1) → max(2,2) = 2 → 1 分;(2,4) → max(1,1) = 1 → 2 分;(5,3) → max(2,0) = 2 → 1 分。合计 3 + 1 + 2 + 1 = 7 ✓

n = 1 时 d 只能为 0,得 1 分,公式自洽,不需要特判。

题解代码与拆解
参考代码
#include <cstdio>
#include <algorithm>

// K:(2n-1)*(2n-1) 的方阵,第 d 个环得分 n-d,其中 d 是切比雪夫距离
// d = max(|x-n|, |y-n|)。中心 (n,n) 得 n,最外环得 1。
// 逐点累加即可,注意 n<=1000、k<=4e6,总和可达 4e9,要 64 位。
int main() {
    int n, k;
    std::scanf("%d %d", &n, &k);
    long long ans = 0;
    for (int i = 0; i < k; ++i) {
        int x, y;
        std::scanf("%d %d", &x, &y);
        int d = std::max(std::abs(x - n), std::abs(y - n));
        ans += n - d;
    }
    std::printf("%lld\n", ans);
    return 0;
}
代码拆解
片段作用
int d = std::max(std::abs(x - n), std::abs(y - n));圈号就是到中心的切比雪夫距离(max 而不是和)
ans += n - d;第 d 圈得 n − d 分:中心 n 分,最外圈 d = n−1 得 1 分
long long ans = 0;k 最大 (2n−1)^2 = 4 × 10^6、每支最多 1000 分,总分到 4 × 10^9,超出 int
std::printf("%lld\n", ans);64 位输出
易错点
  • 距离用的是切比雪夫距离 max(|dx|,|dy|),不是曼哈顿距离 |dx|+|dy|;用错了 (1,1) 会算成 3−4 < 0。
  • 总分到 4 × 10^9,必须 long long;int 会溢出成负数。
  • 得分下限是 1,不是 0:最外圈(d = n−1)仍然有 1 分。
  • n = 1 时靶盘只有一格,d = 0,公式自然得到 1,别多写特判。

L · 幻想乡树状结界

说明:本题题意已完整还原,但下面只给出题面、提示与关键结论,不提供代码。原因见文末「跳过说明」——我还没能给出一个自己敢担保正确的构造算法,按「宁可少写也不写错」的原则略去实现。

题意提高+

有 n 个魔力结点,编号 1..n,第 i 个结点有权值 a_i(非负整数)。需要在结点之间添加 n − 1 条边把它连成一棵树,并以 1 号结点(博丽神社所在结点)为根。

其中有 k 个结点被标记为「关键点」。对每个关键点 v,记 subtree(v) 为以 v 为根的子树所含结点的集合(含 v 本身)。要求构造出的树对每个关键点 v 都满足

⊕_{u ∈ subtree(v)} a_u = 0(⊕ 表示按位异或)。

若存在合法方案,输出 n − 1 行、每行两个整数 x, y 表示一条无向边(多解任选其一输出);若不存在,输出 −1。特别地,n = 1 且 a_1 = 0 时合法输出为空(因为树没有边)。

数据范围:1 ≤ n ≤ 20,1 ≤ k ≤ n,0 ≤ a_i < 2^60,1 ≤ v_i ≤ n,关键点编号互不相同。

标准输入标准输出
5 2
0 5 5 5 5
2 3
1 2
2 4
1 3
3 5
3 1
1 2 4
2
-1
引导性提示

n ≤ 20 这么小,说明做法大概是指数级的(状压 / 搜索),不要去想着多项式贪心。

先把「子树异或为 0」翻译成集合语言:对每个关键点 v,都要存在一个包含 v 的集合异或为 0。多个关键点的这些集合之间是什么关系?

陷阱预警:不要假设「子树在输入顺序里是连续的一段」。样例 1 的官方输出里 subtree(2) = {2, 4} 就不是连续段。

思路(先自己推,再看这里)

记 S_v = subtree(v)。这些集合一定是「层叠(laminar)」的:任取两个关键点 u、v,S_u 与 S_v 要么不相交,要么一个包含另一个——因为子树结构本身就是这样。

对每个关键点 v 定义 B_v = S_v 去掉它内部那些「极大关键后代」的子树。于是 B_v 两两不交、都含 v、且不含别的关键点,并且 XOR(B_v) = XOR(S_v) ⊕ (内部关键子树的异或) = 0 ⊕ 0 = 0。所以「每个关键点 v 都配一个互不相交、含 v、异或为 0 的集合 B_v」是必要条件。

反过来也够用:把这些 B_v 按任意顺序排成一条链,每个 B_v 里的关键点放在该块最前面、其余点挂在它后面,再把所有没用上的点(连同 1 号点)放在链首当作前缀,就得到一棵合法的树(v ≠ 1 时 B_v 不能含 1;若 1 是关键点则还要全局异或为 0,此时 B_1 自动取剩下的全部点)。

于是问题化归为:为每个关键点 v 找一个互不相交的集合 B_v ∋ v、XOR(B_v) = 0(即给每个需求找一个异或等于 a_v 的辅助集合)。这才是真正要写搜索的部分——也正是我在这里不敢保证写对的地方。

反例(证明「子树 = 输入顺序里的连续段」这个简化是错的):n = 4、a = [1, 5, 7, 5]、关键点 {2}。树 1-2、2-4、1-3 中 subtree(2) = {2, 4},异或 5 ⊕ 5 = 0,合法;而任何让子树等于连续段的构造都做不到——{[2], [2,3], [2,4]} 的异或分别是 5、2、7,没有 0。

M · 教练,我想打 xcpc

题意入门

共有 n 条报名记录,每条包含四项:队伍名、赛站名、比赛日期、报名结果。请输出所有报名成功的队伍,每行格式为 gdut_队伍名 赛站名 比赛日期(输出顺序任意)。报名失败的记录不输出;若没有队伍报名成功,可以不输出任何内容。

约定:队伍名由大小写英文字母组成、长度 ≤ 30 且两两不同;赛站名由小写英文字母组成、长度 ≤ 10;比赛日期格式为 YYYY-MM-DD 且是合法日期;报名结果为 1(成功)或 0(失败)。n ≤ 50,每支队伍恰好出现一次。

标准输入标准输出
3
avemujica shanghai 2026-01-01 1
CRYCHIC wuhan 2026-01-10 0
mygo changchun 2026-02-01 1
gdut_avemujica shanghai 2026-01-01
gdut_mygo changchun 2026-02-01
引导性提示

每条记录只有四个字段,而决定要不要输出的只有最后一个字段。

注意前缀的形态:是 gdut_ 直接贴在队名前面,中间没有空格。

日期是字符串,原样输出即可——不要真的去解析它。

思路(先自己推,再看这里)

逐条读入四个字段,若最后一项为 1,就拼接 "gdut_" + 队名 + " " + 赛站名 + " " + 比赛日期 输出一行。

n ≤ 50,直接按输入顺序输出即可,O(n) 时间、O(1) 额外空间。多条输出拼进一个缓冲区再一次性写出更省事。

题解代码与拆解
参考代码
#include <cstdio>
#include <iostream>
#include <string>

// M:纯筛选。报名结果为 1 的记录输出,格式为 gdut_<队名> <赛站> <日期>,顺序任意。
int main() {
    int n;
    std::scanf("%d", &n);
    std::string out;
    for (int i = 0; i < n; ++i) {
        char team[64], site[32], date[32];
        int ok;
        std::scanf("%s %s %s %d", team, site, date, &ok);
        if (ok == 1) {
            out += "gdut_";
            out += team;
            out += ' ';
            out += site;
            out += ' ';
            out += date;
            out += '\n';
        }
    }
    std::fwrite(out.data(), 1, out.size(), stdout);
    return 0;
}
代码拆解
片段作用
std::scanf("%s %s %s %d", team, site, date, &ok);四个字段都是空白分隔的 token,日期不含空格,可以整体当一个字符串读
if (ok == 1) {只有报名结果为 1 的记录才输出,失败的整行跳过
out += "gdut_";前缀紧贴队名,中间不能插空格
std::fwrite(out.data(), 1, out.size(), stdout);所有成功记录先攒起来一次性输出,避免多次 I/O
易错点
  • 前缀是 gdut_ 且与队名无缝相连;写成 "gdut_ " 会多一个空格。
  • 日期 2026-01-01 直接当字符串输出,不要做日期运算、不要去掉补零。
  • 报名失败的记录一行都不输出;全部失败时输出为空,不要打印 0、空行或提示语。
  • 队伍名含大小写字母,不要统一转小写;输出顺序任意,按输入顺序最省事。

N · 不太整齐的队伍(简单版)

题意普及-

有 n 名同学站成一排,第 i 名同学有一个非负整数标签 a_i。

对连续区间 [l, r],记 S 为其中出现过的标签组成的集合,mex(S) 表示不属于 S 的最小非负整数。例如标签为 0, 1, 1, 3 时 S = {0, 1, 3},mex(S) = 2;标签为 1, 2, 3, 3 时 S = {1, 2, 3},mex(S) = 0。重复出现的标签不影响 S。

请选择一个连续区间,使其中标签集合的 mex 不超过 K,并求这个区间的最大长度。

输入:第一行 T(1 ≤ T ≤ 100);每组第一行 n, K(1 ≤ n ≤ 2 × 10^5,0 ≤ K ≤ 10^18),第二行 n 个整数 a_i(0 ≤ a_i ≤ n)。保证 Σn ≤ 2 × 10^5。输出:每组一行一个整数。

标准输入标准输出
2
8 2
0 1 2 3 0 2 3 1
5 0
1 2 0 3 1
5
2
引导性提示

把 mex(S) ≤ K 翻译成「S 里必须缺什么」——是必须缺一个,还是必须缺全部?

如果某个数 t 在整个区间里一次都没出现,mex 会是多少?

固定 t,「完全不含 t 的最长区间」等于 t 的哪些位置之间的空隙?

思路(先自己推,再看这里)

mex(S) ≤ K ⟺ 0, 1, …, K 里至少有一个数不在 S 里。(反之若 0..K 全都在 S 里,mex 至少是 K + 1 > K。)注意是「至少缺一个」,不是「全都缺」。

于是答案 = max_{t = 0..K}(区间里完全不含 t 的最长长度)。而对固定的 t,完全不含 t 的最长子段就是 t 的相邻出现位置之间的最大空档,并且要把「第一个出现位置之前」和「最后一个出现位置之后」这两段也算进去。

还有一个 O(1) 的快路:因为 a_i ≤ n,所以 K > n 时必有某个 t ∈ [0, K] 永不出现(例如 t = n + 1),答案直接是 n。这样就不需要为 10^18 级别的 K 开数组。

复杂度 O(n + K) = O(n)。

验样例 1:n = 8、K = 2、a = 0 1 2 3 0 2 3 1。t = 0 位置 {1,5},空档 max(0,3,3) = 3;t = 1 位置 {2,8},空档 max(1,5,0) = 5;t = 2 位置 {3,6},空档 max(2,2,2) = 2 → 答案 5(区间 [3,7] 缺 1)。

验样例 2:n = 5、K = 0,t = 0 位置 {3},空档 max(2,2) = 2 → 答案 2。

题解代码与拆解
参考代码
#include <cstdio>
#include <vector>
#include <algorithm>

// N:mex(S) <= K 等价于 "0..K 里至少缺一个数"(若 0..K 全出现则 mex >= K+1)。
// 于是答案 = max_{t=0..K} (区间里完全不含 t 的最长子段)
//          = max_t (t 的相邻出现位置之间的最大空档)。
// 又因 a_i <= n:K > n 时必有某个 t 永不出现,答案直接是 n。
int main() {
    int T;
    std::scanf("%d", &T);
    static int a[200005];
    while (T--) {
        int n;
        long long K;
        std::scanf("%d %lld", &n, &K);
        for (int i = 1; i <= n; ++i) std::scanf("%d", &a[i]);
        if (K > n) {                          // 0..K 里一定有值超出 a_i 范围,必不出现
            std::printf("%d\n", n);
            continue;
        }
        std::vector<std::vector<int> > pos((size_t)K + 1);
        for (int i = 1; i <= n; ++i)
            if (a[i] <= K) pos[a[i]].push_back(i);
        int best = 0;
        for (int t = 0; t <= (int)K; ++t) {
            int prev = 0;                     // 哨兵:位置 0
            for (size_t j = 0; j < pos[t].size(); ++j) {
                int p = pos[t][j];
                best = std::max(best, p - prev - 1);
                prev = p;
            }
            best = std::max(best, n - prev);  // 最后一段到 n 为止
        }
        std::printf("%d\n", best);
    }
    return 0;
}
代码拆解
片段作用
if (K > n) {a_i ≤ n,K > n 时 0..K 里必有永不出现的值,整个数组都是答案
std::vector<std::vector<int> > pos((size_t)K + 1);K ≤ n 时才建表;Σn ≤ 2 × 10^5,总空间可控
if (a[i] <= K) pos[a[i]].push_back(i);大于 K 的标签对「0..K 里缺谁」毫无影响,直接丢掉
best = std::max(best, p - prev - 1);两个相邻出现位置之间(不含端点)的长度
best = std::max(best, n - prev);最后一段要单独算:最后一个出现位置到 n 的空档
int prev = 0;用 0 当哨兵,这样第一段(位置 1 之前)也能用同一个式子算
易错点
  • K 可到 10^18,不能直接开 K + 1 大小的位置表;先判 K > n 再建表,此时答案直接是 n(不是 K)。
  • 空档要含「第一个出现位置之前」和「最后一个出现位置之后」两段,只算中间会漏。
  • 等价条件是「0..K 里至少缺一个」,不是「0..K 全部缺失」。
  • T 可达 100,每组的位置表要重新构建(不要跨组复用残留数据)。
题解 2025 广工新生赛(初赛)题解 10 题全解:从字符串贪心到树上的取值顺序入门 ~ 提高

2025 广工新生赛(初赛)题解

10 题全解:从字符串贪心到树上的取值顺序
题解入门 ~ 提高2025 年广东工业大学 ACM 新生程序设计竞赛(初赛)
跳过说明
  • B 我不是AI:只要求判断一行输入是否严格等于 Are you AI,属于读入练习(入门),略去。

这份题解的用法:先自己看题意,再看「引导性提示」,卡住 20 分钟再点开思路与代码。

难度:I 入门;E 普及-;C、G、J 普及+;A、H 提高-;D、F 提高(F 实际是省选向的模板拓展)。考场上 I、E 十分钟内该出;C、J 想清「状态里带什么」就没了;A、G、H 的关键是先把不变量写出来;D 想到运输问题就结束;F 会 Pollard-Rho 就是送分,不会就先跳过。

复盘建议

A:把「匹配删除」写成 (q, i) 的链,再想想为什么可以把长度压成 k, k-1, ..., 1。

C:老虎位置是时间的三角波,周期 2(m-1)。会写这个函数,BFS 就是模板。

D:先把战斗序列翻译成「需求 − 容量」的分配,再用霍尔条件判一眼。

J:正着看是「割点」,倒着看只是「新点必须有已加入的邻居」。换个方向,整题难度就掉下来了。

题目索引

题号题目核心考点难度
A最大子串匹配删除操作把操作序列化成「后缀链」,标准化后用 Z 函数 + 二分判定提高-
C逃出生天把老虎位置写成时间的周期函数,在 (格子, 时刻 mod 周期) 上 BFS普及+
D师出同门把战斗序列翻译成运输问题,用霍尔条件得到一个 O(n) 判定,再挑第一场提高
E稳定区间按 mod 3 的三类计数做前缀和,O(1) 回答询问普及-
F【模板】Pollard-Rho交互协议 + Pollard-Rho;查询本质是拿 popcount 逐位定位提高
G互异排列扩展n+1 ≡ 0 决定了插入位置只能是开头,剩下只需判重普及+
H互异排列生成先由总和模 n+1 定出 n 必须是奇数,再用 Walecki 序列的差分构造提高-
I2-冲突数对总数对减去同值数对,注意答案要用 long long入门
J收藏宝石合法性等价于剩余集合始终连通,倒过来按价值从小到大加点的 BFS普及+

A · 最大子串匹配删除操作

题意提高-

给定两个只含小写字母的字符串 S、T,1 ≤ |S|, |T| ≤ 2×10^5。反复执行下面两种操作之一,直到 T 变空:

  • 删除操作:删掉 T 的第一个字符,S 不变。
  • 匹配删除操作:若当前 T 恰好等于当前 S 的某个子串 S[i..j],则删掉 S 的前缀 S[1..j](S 只剩后缀 S[j+1..]),同时删掉 T 的第一个字符。

删除操作不算分,匹配删除算一次得分。求最多能做多少次匹配删除。

输入两行,分别是 S 和 T;输出一个整数。样例:S = abcb、T = aab 时答案是 2。

引导性提示

删除操作只动 T、不动 S,所以它随时都能补做:真正要决定的只有「哪些匹配做、按什么位置做」。

一次匹配删除之后,S 的可用部分变成某个后缀,T 的可用部分也变成某个后缀。于是整个过程是一条链:第 t 次匹配吃掉 T 的某个后缀,位置必须在上一次结束之后。

如果一条链做了 k 次匹配,第 t 次匹配用的后缀长度至少是多少?把这些长度加起来,k 最大能到多少?

思路(先自己推,再看这里)

先换个说法。设第 t 次匹配用的是 T 的后缀 T[q_t..|T|](长度 L_t = |T|-q_t+1),它出现在 S 的 i_t 处。那么必须 q_t 严格递增,且 i_{t+1} ≥ i_t + L_t(不重叠、从左到右)。删除操作只负责把 q 从 q_t 推到 q_{t+1},随时可做,所以一条这样的链一定对应一个合法操作序列,反之亦然。问题就是求最长链。

关键标准化:任何一条长 k 的链,都可以换成「长度恰为 k, k-1, ..., 1」的链。理由:T 的长 L 后缀以 T 的长 L' 后缀(L' 小于 L)为后缀,所以若长 L 后缀出现在 i,则长 L' 后缀出现在 i + (L - L');令 j_t = i_t + (L_t - (k-t+1)),逐项验证 j_{t+1} ≥ j_t + (k-t+1),不等号两边只差 (i_{t+1} - i_t - L_t) + L_{t+1} - (k-t) ≥ 0,成立。于是「能取 k 次」等价于「把长度 k, k-1, ..., 1 的后缀依次塞进 S」。

长度序列固定后,贪心就是最优:每个后缀取它能取到的最左出现位置。长度固定时起点最左等价于终点最左,留给后面的 S 最多,这是标准交换论证。所以判定 k 只需要从左往右扫一遍,复杂度 O(|S|)。

判定对 k 单调:长度为 k 的标准链去掉第一段就是 k-1 的标准链,所以二分答案。又因为 1+2+...+k ≤ |S|,k 上界是 632(|S| = 2×10^5),二分只要 10 次判定。

「T 的长 L 后缀在 S 中最左出现在哪」用一次 Z 函数解决:把 S、T 都反转,T 的后缀变成 T' 的前缀,在 T'+'#'+S' 上跑 Z,z 值就是 S' 某位置与 T' 的 LCP,于是 O(1) 判定某个位置能否放长度 L 的后缀。总复杂度 O(|S| + |T| + |S| log min(|T|, 632))。

题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    string S, T;
    if (!(cin >> S >> T)) return 0;
    int n = S.size(), m = T.size();

    // 反转后 T 的后缀变成 T' 的前缀:一次 Z 函数就能 O(1) 判断任意位置能否放任意长度的后缀
    string Sr(S.rbegin(), S.rend()), Tr(T.rbegin(), T.rend());
    string U = Tr + "#" + Sr;
    int len = U.size();
    vector<int> z(len, 0);
    for (int i = 1, l = 0, r = 0; i < len; i++) {
        if (i <= r) z[i] = min(r - i + 1, z[i - l]);
        while (i + z[i] < len && U[z[i]] == U[i + z[i]]) z[i]++;
        if (i + z[i] - 1 > r) { l = i; r = i + z[i] - 1; }
    }
    int base = m + 1;                        // U 中 S' 部分的起点
    auto ok = [&](int i, int L) {            // S[i..i+L-1] 是否等于 T 的长 L 后缀
        return z[base + (n - i - L)] >= L;   // 对应到 S' 里的起点是 n-i-L
    };
    auto check = [&](int k) {                // 长度 k,k-1,...,1 的标准链能否塞进 S
        int p = 0;
        for (int L = k; L >= 1; L--) {
            int i = p;                       // 每个后缀都取能取到的最左位置
            while (i + L <= n && !ok(i, L)) i++;
            if (i + L > n) return false;
            p = i + L;                       // 下一段必须从这里之后开始
        }
        return true;
    };

    int hi = min(m, (int)((sqrt(8.0 * n + 1) - 1) / 2));  // 1+2+...+k 不能超过 |S|
    int lo = 0;
    while (lo < hi) {                        // 可行性对 k 单调,二分
        int mid = (lo + hi + 1) >> 1;
        if (check(mid)) lo = mid; else hi = mid - 1;
    }
    cout << lo << '\n';
    return 0;
}
代码拆解
片段作用
string U = Tr + "#" + Sr;把两个串都反转。T 的长 L 后缀反转后是 T' 的长 L 前缀,于是「后缀出现」变成「前缀出现」,一次 Z 函数覆盖所有长度。
return z[base + (n - i - L)] >= L;S[i..i+L-1] 等于 T 的长 L 后缀,等价于反转后 S' 从 n-i-L 起与 T' 的 LCP 至少为 L。下标 n-i-L 是被反转映射出来的,少减一个 L 就整体错位。
while (i + L <= n && !ok(i, L)) i++;固定长度后最左出现就是最优:终点也最左,给后面的后缀留下最多的 S。贪心的正确性由交换论证保证。
int hi = min(m, (int)((sqrt(8.0 * n + 1) - 1) / 2));长度 k,k-1,...,1 至少占 k(k+1)/2 个字符,所以 k 不超过 sqrt(2|S|) ≈ 632;同时不能超过 |T|。缺任何一个上界都可能判出非法的大 k。
if (check(mid)) lo = mid; else hi = mid - 1;标准链去掉第一段就是 k-1 的标准链,可行性单调,所以能二分。
易错点
  • 「能匹配就立刻匹配」的贪心是错的:S = ddddddd、T = dddd 时一上来吃掉 dddd 只剩 2 次,正确答案是 3(先跳过 d,用长度 3、2、1)。症状是 WA 而不是 TLE。
  • 把 T 的后缀当成 T 的前缀:样例 acmer / medal 的正确输出是 0,若只看前缀会算出 1(a 匹配 a),属于读题错误。
  • 二分上界只取 |T| 不取 sqrt(2|S|):check 会去试明显塞不下的 k,虽然结果一般还对,但如果 check 里没做长度越界检查就会读错下标甚至越界。
  • Z 函数里 T 与 S 之间忘了加分隔符 '#',LCP 会跨过 T 的末尾继续比,导致 ok 判定偏大。

C · 逃出生天

题意普及+

一个 n 行 m 列的网格,2 ≤ n, m ≤ 100。小猪从右上角 (1, m) 出发,要到左下角 (n, 1)。每一行都有一只老虎:第 i 行的老虎初始在第 c_i 列,初始方向 d_i 是 L 或 R,保证 c_1 ≠ m(起点安全)。

每一时刻的时序是:小猪先动(可以走上、下、左、右或原地,不能出界),然后所有老虎同时动。老虎只会沿当前方向走一格;若该方向的前方出界,则先掉头再走一格。

任何时刻(任一方移动前、移动后)小猪都不能与老虎同格,走到出口的那一刻也一样。问小猪能否到达出口,输出 YES 或 NO(大小写随意)。

引导性提示

第 i 只老虎永远待在第 i 行,所以当小猪在 (x, y) 时,真正需要担心的只有第 x 行那一只。

一行里左右来回走的老虎,位置随时间变化是周期性的。试试把列号换成「相位」,看 t 次移动之后它在哪。

如果任意时刻老虎的位置都能 O(1) 算出来,那小猪的状态除了坐标还需要记录什么?

思路(先自己推,再看这里)

老虎只在它所在的行里活动,所以小猪走到 (x, y) 时只可能撞上第 x 行的老虎。这一条把「与所有老虎判重」降成「与一只老虎判重」。

单只老虎在一行内左右折返,位置是周期 2(m-1) 的三角波。取相位:初始方向 R 时相位 = c-1,初始方向 L 时相位 = 2(m-1)-(c-1);那么做完 t 次移动后,位置列号是令 x = (相位+t) mod 2(m-1),若 x ≤ m-1 则是 1+x,否则是 2m-1-x。这样任意时刻位置 O(1) 求出。注意 m = 2 时周期是 2,公式依然成立。

所有老虎的周期都是 2(m-1)(与初始位置无关),所以整个局面以 2(m-1) 为周期重复。于是 BFS 的状态是 (行, 列, t mod 2(m-1)),状态数 n·m·2(m-1) ≤ 100·100·198 ≈ 2×10^6,每个状态枚举 5 种移动。

转移要按题目时序检查两次:小猪先动,落点不能是时刻 t 的老虎格;然后老虎动,落点不能是时刻 t+1 的老虎格。两次都过了才把 (nr, nc, (t+1) mod 周期) 入队;若落点就是 (n,1) 且第二次检查通过,直接输出 YES。

为什么状态里一定要带时间?同一个格子在时刻 t 和 t + 2(m-1) 是同一局面,但在时刻 t 与 t+1 的生死判断完全不同;只用 (行,列) 做 BFS 会漏掉「必须等老虎走开」的解,也会把不同时刻混成一点。

题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, m;
    if (scanf("%d %d", &n, &m) != 2) return 0;
    vector<int> c(n);
    vector<char> d(n);
    for (int i = 0; i < n; i++) scanf("%d %c", &c[i], &d[i]);

    int P = 2 * (m - 1);                     // 老虎位置的周期
    vector<int> ph(n);
    for (int i = 0; i < n; i++) {
        int x = c[i] - 1;
        ph[i] = (d[i] == 'R') ? x : (P - x) % P;   // 相位:位置只由 (ph + t) % P 决定
    }
    auto pos = [&](int row, int t) {          // 第 row 行老虎在时刻 t 的列号(1-indexed)
        int x = (ph[row] + t) % P;
        return x <= m - 1 ? 1 + x : 2 * m - 1 - x;   // 超过 m 的部分折返
    };

    vector<char> vis((size_t)n * m * P, 0);
    auto id = [&](int r, int col, int t) { return ((size_t)r * m + col) * P + t; };
    vector<int> q;
    int st = (int)id(0, m - 1, 0);            // 起点 (1, m),0-indexed 是 (0, m-1)
    q.push_back(st);
    vis[st] = 1;
    const int dr[5] = {-1, 1, 0, 0, 0}, dc[5] = {0, 0, -1, 1, 0};
    for (size_t h = 0; h < q.size(); h++) {
        int cur = q[h];
        int t = cur % P, cell = cur / P, r = cell / m, col = cell % m;
        for (int k = 0; k < 5; k++) {
            int nr = r + dr[k], nc = col + dc[k];
            if (nr < 0 || nr >= n || nc < 0 || nc >= m) continue;
            if (nc + 1 == pos(nr, t)) continue;      // 小猪先动:落点上正站着老虎
            int t2 = (t + 1) % P;
            if (nc + 1 == pos(nr, t2)) continue;     // 老虎后动:不能被踩到
            if (nr == n - 1 && nc == 0) { puts("YES"); return 0; }
            int nid = (int)id(nr, nc, t2);
            if (!vis[nid]) { vis[nid] = 1; q.push_back(nid); }
        }
    }
    puts("NO");
    return 0;
}
代码拆解
片段作用
int P = 2 * (m - 1);一格宽的行来回一趟需要 2(m-1) 步。所有老虎共用这个周期,所以整个棋盘以它为周期,状态里带 t % P 就够。
ph[i] = (d[i] == 'R') ? x : (P - x) % P;把「位置 + 方向」压成一个相位。验证:m = 5、c = 3、方向 L 时相位 6,t = 1 得 x = 7,超过 4 折回成 2,正是「向左走一格」。
return x <= m - 1 ? 1 + x : 2 * m - 1 - x;三角波映射:前 m 个相位向右走到 m,后 m-2 个相位折回。漏掉折返分支就会让老虎瞬移到列 0。
if (nc + 1 == pos(nr, t)) continue;小猪先动,所以第一步只检查老虎当前(时刻 t)的位置。少了这一句,小猪会直接走进老虎格。
if (nc + 1 == pos(nr, t2)) continue;老虎后动,时刻 t+1 的落点再查一次。只有两次都安全才入队;出口也必须在老虎动完后仍然安全才判 YES。
int nid = (int)id(nr, nc, t2);状态三维编码:格子号 × P + 时刻余数。用 (行,列) 做状态会漏解。
易错点
  • 把周期写成 m 或者 2m:正确周期是 2(m-1),m = 2 时写错成 2m 就会把 2 步周期当成 4 步,答案随机偏。
  • 漏掉「前方出界则先掉头再走一格」里的方向反向,只做了位置回弹:位置序列一样,但下一步又会往错的方向走,从第二次折返开始全错(WA,且小样例可能骗过去)。
  • 只查老虎移动后的位置、不查小猪移动前的位置:小猪会走进老虎当前所在格,样例 1 仍输出 NO 但很多数据会错判 YES。
  • BFS 状态里不带时刻,或者 vis 数组只按 n×m 开:状态数少了 2(m-1) 倍,会漏解;反过来把 vis 开成 n×m×2(m-1) 但编码时忘了乘 P 会越界 RE。

D · 师出同门

题意提高

两国各由 n 个宗派组成。玄真国第 i 宗派有 a_i 人,幻月国第 i 宗派有 b_i 人(1 ≤ a_i, b_i ≤ 10^9,n ≤ 10^5,t ≤ 10^4 组测试,所有 n 之和 ≤ 10^5)。

每场战斗:玄真国挑一个还有人的宗派 i,幻月国挑一个还有人的宗派 j。若 i = j,双方不动手,什么也不发生;若 i ≠ j,则 a_i 与 b_j 各减 1,称为一次有效战斗。

问是否存在战斗序列,使得结束后至少一方的所有人阵亡(所有 a_i = 0 或所有 b_i = 0)。若存在,先输出 YES,再输出第一场有效战斗的 i、j(i ≠ j,多解任意);否则输出 NO。

引导性提示

非有效战斗什么也不改变,可以无限重复,所以只需考虑 i ≠ j 的战斗;它们才是真正在减人。

把「第 i 宗派要死 a_i 个人」看成 a_i 份需求,「第 j 宗派最多死 b_j 个人」看成容量 b_j,而需求不能送给自己。

什么样的配置会送不出去?先只盯住某一个 i 看:它所有的需求只能由别人的容量承担。

思路(先自己推,再看这里)

要灭掉玄真国,就得让每个 i 挨 a_i 刀,共 SA = Σa_i 场有效战斗;每场战斗同时消耗一个 b_j,所以第 j 个宗派最多被消耗 b_j 次。也就是说:把 a_i 份「需求」分给各个 j(i ≠ j),每个 j 收不超过 b_j 份。这就是一个运输问题(源 i 供应 a_i,汇 j 容量 b_j,只缺 i = j 的边),存在解等价于存在这样的分配。

有了分配表就能排出战斗顺序:任一时刻某宗派剩余人数 = 初始值减去已消耗,而总消耗不超过初值,所以随便什么顺序都不会出现「没人可派」。因此分配存在 ⟺ 战斗序列存在。

按霍尔条件(最大流最小割)判可行性:对任意源集合 X,Σ_{i∈X} a_i 不能超过 X 能连到的汇容量和。若 |X| ≥ 2,任取 i∈X 与任一 j,只要 j ≠ i 就有边,所以 X 连到全部汇,条件是 SA ≤ SB。若 X = {i},连到的汇是除 i 外的全部,条件是 a_i ≤ SB - b_i,即 a_i + b_i ≤ SB。

于是:能灭甲方 ⟺ SA ≤ SB 且 max(a_i + b_i) ≤ SB;能灭乙方 ⟺ SB ≤ SA 且 max(a_i + b_i) ≤ SA。两个都不成立就输出 NO。注意 n = 1 时 a_1 + b_1 必然大于 SB 与 SA,自动判 NO,和样例一致。

输出第一场:注意「SA ≤ SB」在双方各减 1 后仍然成立(同时减 1 不改变大小关系),所以计划不会因为这一刀而改变。设当前计划上限为 lim(灭甲取 SB、灭乙取 SA),开战后要求 max(a_k+b_k) ≤ lim 变成 ≤ lim-1;只有 i、j 两行会减 1,因此所有满足 a_k + b_k = lim 的下标 k 必须都落在 {i, j} 里。这样的下标至多 2 个:0 个则任取 i ≠ j,1 个则让它当 i,2 个则直接取 {i, j}。

题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int main() {
    int T;
    if (scanf("%d", &T) != 1) return 0;
    while (T--) {
        int n; scanf("%d", &n);
        vector<ll> a(n), b(n);
        ll SA = 0, SB = 0, M = 0;
        for (int i = 0; i < n; i++) { scanf("%lld", &a[i]); SA += a[i]; }
        for (int i = 0; i < n; i++) { scanf("%lld", &b[i]); SB += b[i]; }
        for (int i = 0; i < n; i++) M = max(M, a[i] + b[i]);

        // 灭甲方:SA <= SB 且 max(a_i+b_i) <= SB;灭乙方对称
        bool okA = (SA <= SB) && (M <= SB);
        bool okB = (SB <= SA) && (M <= SA);

        // 在上限 lim 下挑第一场:所有 a_k+b_k == lim 的 k 都必须被 {i,j} 覆盖
        auto pick = [&](ll lim, int &ii, int &jj) -> bool {
            if (n < 2) return false;
            vector<int> z;
            for (int k = 0; k < n; k++) if (a[k] + b[k] == lim) z.push_back(k);
            if (z.size() > 2) return false;              // 一刀只能照顾两行
            if (z.size() == 2) { ii = z[0]; jj = z[1]; return true; }
            if (z.size() == 1) { ii = z[0]; jj = (z[0] == 0 ? 1 : 0); return true; }
            ii = 0; jj = 1; return true;                 // 没人卡在上限,随便打
        };

        int ii = -1, jj = -1;
        bool got = false;
        if (okA) got = pick(SB, ii, jj);
        if (!got && okB) got = pick(SA, ii, jj);         // SA == SB 时两种计划的覆盖条件相同
        if (!got) { puts("NO"); continue; }
        puts("YES");
        printf("%d %d\n", ii + 1, jj + 1);               // 输出 1-indexed
    }
    return 0;
}
代码拆解
片段作用
bool okA = (SA <= SB) && (M <= SB);灭甲方的充要条件:总需求不超过总容量,且每个单独的 a_i 不能超过别人的容量和 SB - b_i。少写 M 这一项就是样例 1 的错解。
for (int i = 0; i < n; i++) M = max(M, a[i] + b[i]);把 a_i ≤ SB - b_i 写成 a_i + b_i ≤ SB,只用一个 max 就能判全部 i。
if (z.size() > 2) return false;开战后只有 i、j 两行的 a+b 会减 1,所以卡在上限的下标最多只能有两个,第三个就无法靠这一刀救回来。
if (z.size() == 2) { ii = z[0]; jj = z[1]; return true; }两个下标都卡在上限时,必须让它们分别当 i 和 j,一刀同时降两行;顺带保证 i ≠ j。
if (!got && okB) got = pick(SA, ii, jj);SA == SB 时两种计划的判定条件完全一样,先试甲方计划、不行再试乙方计划,能覆盖所有情况。
printf("%d %d\n", ii + 1, jj + 1);输入是 1-indexed 的宗派编号,内部用 0-indexed 存,输出要加 1。
易错点
  • 只判 SA ≤ SB 就输出 YES:漏了 a_i + b_i ≤ SB。样例 1(a = 2, b = 4)会错答 YES,实际只有一个宗派,永远打不出有效战斗。
  • 用 int 存总和:a_i 到 10^9、n 到 10^5,Σa 能到 10^14,必须 long long,否则 WA(溢出后大小关系全乱)。
  • 第一场输出一个已经不剩人的宗派,或者输出 i = j。本解法第一场取的两个下标初始都 ≥ 1 人,但自己写贪心时容易违反这一点,会被判「构造不合法」。
  • 多组测试忘记重置,或者 YES/NO 的大小写、换行格式不对(有解要先单独一行 YES 再一行两个数)。

E · 稳定区间

题意普及-

给定长度 n 的序列 a(1 ≤ n, q ≤ 10^5,1 ≤ a_i ≤ 10^9)和 q 个询问,每个询问给出区间 [l, r]。

问区间内是否存在一个非空子序列(下标递增即可,不要求连续)其元素之和是 3 的倍数。存在则称该区间稳定,输出 Yes,否则输出 No。

输入第一行 n、q,第二行 n 个整数,接下来 q 行每行 l r;对每个询问输出一行。

引导性提示

子序列不要求连续,所以答案只和区间里「模 3 余 0、余 1、余 2」各有多少个数有关。

只用余数 1 和 2 的数,能凑出 3 的倍数的最小组合有哪几种?把它们列全。

一个余数 0 的数自己就够;如果一个都没有,还能怎么凑?

思路(先自己推,再看这里)

只看每个数 mod 3。设区间内余 0、1、2 的个数分别是 c0、c1、c2。

c0 ≥ 1 时直接取那个数,和为 3 的倍数,成立。否则只能用余 1 和余 2 的数凑。1+1+1 = 3,2+2+2 = 6,1+2 = 3,这三种组合都成立;反过来,任何和 ≡ 0 (mod 3) 的非空组合,把 1 和 2 尽量配成对之后,剩下的全是同一种余数,必须凑够三个才能再次被 3 整除。所以判定就是三种组合之一可行。

所以答案 = (c0 ≥ 1) 或 (c1 ≥ 1 且 c2 ≥ 1) 或 (c1 ≥ 3) 或 (c2 ≥ 3)。

对三类余数各做一遍前缀和,每个询问 O(1)。预处理 O(n)、总复杂度 O(n + q),与 a_i 的大小无关(也不需要真的求和,避免 10^9 × 10^5 溢出)。

题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, q;
    if (scanf("%d %d", &n, &q) != 2) return 0;
    vector<array<int, 3>> pre(n + 1);      // pre[i][r] = 前 i 个数里余 r 的个数
    pre[0] = {0, 0, 0};
    for (int i = 1; i <= n; i++) {
        int x; scanf("%d", &x);
        pre[i] = pre[i - 1];
        pre[i][x % 3]++;
    }
    while (q--) {
        int l, r; scanf("%d %d", &l, &r);
        int c0 = pre[r][0] - pre[l - 1][0];
        int c1 = pre[r][1] - pre[l - 1][1];
        int c2 = pre[r][2] - pre[l - 1][2];
        bool ok = c0 >= 1 || (c1 >= 1 && c2 >= 1) || c1 >= 3 || c2 >= 3;
        puts(ok ? "Yes" : "No");
    }
    return 0;
}
代码拆解
片段作用
pre[i][x % 3]++;只保留余数信息:原值多大都不影响答案,这一步同时避免了区间求和溢出。
vector<array<int, 3>> pre(n + 1);三类余数各一份前缀和,任取区间相减就是 O(1);n ≤ 10^5 时总数也不超过 int 范围。
bool ok = c0 >= 1 || (c1 >= 1 && c2 >= 1) || c1 >= 3 || c2 >= 3;四类可行组合:单个 3 的倍数、一个 1 加一个 2、三个 1、三个 2。四者缺一就会漏解。
puts(ok ? "Yes" : "No");按题面要求的 Yes/No 输出;大小写不敏感但拼写不能错。
易错点
  • 只判「存在某个数是 3 的倍数」:漏掉 1+2 与三个同余数的组合,样例第二个询问就会错。
  • 只判 c1 ≥ 3 或 c2 ≥ 3:漏掉 1+2,整道题基本全错。
  • 真去算区间和再模 3:区间和能到 10^14,用 int 溢出后结论随机;本题只需要余数计数,根本不用原值。
  • 想把那组子序列真的找出来(背包、枚举):n 到 10^5、a_i 到 10^9,做不了;这题只要判存在性,而存在性只由三类余数的个数决定。

F · 【模板】Pollard-Rho

难度定位:省选 / 提高+ 的拓展题。它把「交互题怎么写」和「大整数分解」捆在一起,新生赛阶段不会做完全正常;下面给出完整的 Pollard-Rho 模板,读完能直接当黑箱用。

题意提高

这是一道交互题。输入一个整数 n(2 ≤ n ≤ 2^60 - 1),你要输出它的最大质因子 p。

你最多有 64 次尝试。每次尝试输出一行一个整数 m(0 ≤ m ≤ 2^63 - 1),交互器计算 d = p ⊕ m(按位异或),然后输出 popcount(d),即 d 的二进制里 1 的个数。当交互器输出 0 时说明 m = p,你应立即结束程序。

每次输出后必须刷新缓冲区:C++ 用 fflush(stdout) 或 std::endl。尝试次数达到 64 次也会被强制终止。

引导性提示

交互器只回给你一个数:你的猜测和答案在二进制上差了多少位。想清楚「差多少位」怎么变成某个具体 bit 的取值。

先输出 m = 0 能得到什么?再输出 m = 2^i,popcount 会怎么变?

p 最多 60 个 bit,这个信息量够用;另外别忘了 n 本身不超过 2^60,直接把 n 分解掉也能一次命中。

思路(先自己推,再看这里)

思路一(交互派):逐位确定。先输出 m = 0,得到 c = popcount(p)。之后每次输出 m = 2^i,若 p 的第 i 位是 0,则 d 比 p 多一个 1,回复 c+1;若是 1,则回复 c-1。一次查询换来一个 bit,候选集合每次都缩小一半。p 有 60 个 bit:1 次求 c、59 次定下第 0..58 位,第 59 位由 c 减去已知的 1 的个数直接算出,最后输出 p 收 0,共 1+59+1 = 61 次,小于 64。

思路二(分解派,也是代码走的路):n ≤ 2^60,直接做质因数分解就能拿到最大质因子,第一次尝试输出 m = p 就立刻收到 0,只用 1 次机会。题目叫「模板」就是在提示这条路,提示说明里也写了「只需要写一个高效率的质因数分解算法」。

分解的两个部件:Miller-Rabin 判素(用前 12 个质数当底,对 2^60 以内的数完全确定,不会误判),以及 Pollard-Rho 找因子(随机 c 与起点,用 Floyd 判圈在 O(n^{1/4}) 期望步数内找到非平凡因子)。n 不超过 2^60 时最坏是「两个 30 位质数相乘」,需要约 3×10^4 次迭代,而每次迭代是几次 128 位取模乘法,非常快。

递归分解:n 是质数就更新答案;否则找到一个因子 d,递归处理 d 与 n/d。注意每层都要判素,因为 Pollard-Rho 只能保证返回一个非平凡因子(可能还是合数)。

交互侧只需三步:读 n、分解、输出 p 并 fflush,然后读掉那行 0 再退出。千万不要在收到 0 之后继续输出,会被判协议错误。

题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
typedef __int128 lll;

mt19937_64 rng(chrono::steady_clock::now().time_since_epoch().count());

ull mulmod(ull a, ull b, ull m) { return (ull)((lll)a * b % m); }   // 防溢出

ull powmod(ull a, ull e, ull m) {
    ull r = 1 % m;
    a %= m;
    while (e) {
        if (e & 1) r = mulmod(r, a, m);
        a = mulmod(a, a, m);
        e >>= 1;
    }
    return r;
}

// 前 12 个质数作底,对 n < 3.3e24 都是确定的,2^60 内绝不会误判
bool isPrime(ull n) {
    if (n < 2) return false;
    for (ull p : {2ULL, 3ULL, 5ULL, 7ULL, 11ULL, 13ULL, 17ULL, 19ULL, 23ULL, 29ULL, 31ULL, 37ULL})
        if (n % p == 0) return n == p;
    ull d = n - 1;
    int s = 0;
    while (!(d & 1)) { d >>= 1; s++; }
    for (ull a : {2ULL, 3ULL, 5ULL, 7ULL, 11ULL, 13ULL, 17ULL, 19ULL, 23ULL, 29ULL, 31ULL, 37ULL}) {
        ull x = powmod(a, d, n);
        if (x == 1 || x == n - 1) continue;
        bool comp = true;
        for (int r = 1; r < s; r++) {
            x = mulmod(x, x, n);
            if (x == n - 1) { comp = false; break; }
        }
        if (comp) return false;
    }
    return true;
}

// Floyd 判圈版 Pollard-Rho:找到 n 的一个非平凡因子(n 为合数时)
ull pollard(ull n) {
    if (n % 2 == 0) return 2;
    while (true) {
        ull c = rng() % (n - 1) + 1;
        ull x = rng() % (n - 2) + 2, y = x, d = 1;
        auto f = [&](ull v) { return (mulmod(v, v, n) + c) % n; };
        while (d == 1) {
            x = f(x);                       // 一步一步
            y = f(f(y));                    // 两步,相遇即出环
            d = __gcd(x > y ? x - y : y - x, n);
        }
        if (d != n) return d;               // d == n 说明这一轮退化,换 c 重来
    }
}

ull best = 1;                               // 最大质因子

void factor(ull n) {
    if (n < 2) return;
    if (isPrime(n)) { best = max(best, n); return; }
    ull d = pollard(n);
    factor(d);
    factor(n / d);
}

int main() {
    ull n;
    if (scanf("%llu", &n) != 1) return 0;
    factor(n);
    printf("%llu\n", best);                 // 第一次尝试就报出 p
    fflush(stdout);                         // 交互题必须刷新
    int c = 0;
    if (scanf("%d", &c) == 1) { /* 这里收到 0,代表猜对了 */ }
    return 0;
}
代码拆解
片段作用
ull mulmod(ull a, ull b, ull m) { return (ull)((lll)a * b % m); }a×b 可能到 2^120,必须用 __int128 中转,否则溢出后分解必然出错。
for (ull p : {2, 3, 5, ..., 37}) if (n % p == 0) return n == p;先用小质数筛掉,既加快速度,也避免 Miller-Rabin 里 a % n == 0 的退化情形。
while (!(d & 1)) { d >>= 1; s++; }把 n-1 写成 d·2^s,这是 Miller-Rabin 的标准准备步骤。
if (d != n) return d;Pollard-Rho 只保证给出非平凡因子;d == n 表示这一轮把 n 自己圈住了,必须换随机 c 重来。
if (isPrime(n)) { best = max(best, n); return; }递归边界:只剩质数时更新答案。合数继续拆,所以最终 best 一定是最大质因子。
fflush(stdout);交互题的生命线。不刷新的话交互器读不到你的输出,表现是 TLE 或 Idle。
易错点
  • 输出后不刷新缓冲区:交互器收不到数字,直接判超时(表现为 TLE / Idle 而不是 WA)。
  • 用 unsigned long long 直接乘:a×b 溢出,Miller-Rabin 与 Pollard-Rho 全部失效,答案是随机的(WA)。
  • Miller-Rabin 只取 {2,3,5,7} 作底:2^60 范围内存在强伪素数,会把合数判成质数,于是 best 取到一个合数(WA)。
  • 协议与边界:输出 p 之后应读掉那行 0 就结束,别再输出;同时 factor 必须先判素再拆——n 是质数(或 n = 2)时直接 pollard 会死循环,表现是 TLE。

G · 互异排列扩展

题意普及+

给定一个 1 到 n 的排列 p(n ≤ 10^6,T ≤ 1000,所有 n 之和 ≤ 10^6)。把小 H 的数字 n+1 插入 p 的某个位置(含开头、结尾),得到长度 n+1 的排列 q。

记 S_k = (q_1 + q_2 + ... + q_k) mod (n+1),要求 S_1, S_2, ..., S_{n+1} 这 n+1 个值互不相同。

有解则输出任意一个可行的 q(一行 n+1 个整数),无解输出 -1。

引导性提示

n+1 对 n+1 取模等于 0,它对前缀和的贡献是什么?

若把 n+1 插在第 k 个位置之后(k ≥ 1),那么 S_k 和 S_{k+1} 分别等于多少?比较一下。

插入位置被唯一确定之后,剩下的检查只有一件事,想想要不要额外排除某个余数。

思路(先自己推,再看这里)

n+1 ≡ 0 (mod n+1),所以它的前缀和不会改变当前余数。若把它插在 p_1..p_k 之后(k ≥ 1),则 S_{k+1} = S_k,两个值相等,立即不合法。

因此插入位置只能是开头:q = (n+1, p_1, p_2, ..., p_n)。此时 S_1 = n+1 ≡ 0,而 k ≥ 2 时 S_k 就是 p 的前 k-1 个前缀和 mod (n+1)。

于是条件变成:p 的 n 个前缀和 mod (n+1) 两两不同,而且都不能等于 0(0 已经被 S_1 占了)。n+1 个余数正好 0..n,所以这 n 个值必须恰好是 1..n。

扫一遍 p、用桶判重(同时把 0 预标记为已用)即可,O(n)。注意多组测试要按 n+1 的大小开桶,不要每组都 memset 一个 10^6 的数组。

题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int T;
    if (scanf("%d", &T) != 1) return 0;
    while (T--) {
        int n; scanf("%d", &n);
        vector<int> p(n + 1);
        for (int i = 1; i <= n; i++) scanf("%d", &p[i]);

        int m = n + 1;                       // n+1 ≡ 0,插在中间会让相邻两个前缀和相等
        vector<char> seen(m, 0);
        seen[0] = 1;                         // S_1 = 0 已经占用了余数 0
        long long s = 0;
        bool ok = true;
        for (int i = 1; i <= n; i++) {
            s = (s + p[i]) % m;
            if (seen[s]) { ok = false; break; }
            seen[s] = 1;
        }
        if (!ok) { puts("-1"); continue; }
        printf("%d", m);
        for (int i = 1; i <= n; i++) printf(" %d", p[i]);
        puts("");
    }
    return 0;
}
代码拆解
片段作用
int m = n + 1;取模对象。后面所有余数都在 0..m-1 内。
seen[0] = 1;S_1 = n+1 ≡ 0,所以前缀和不许再出现 0。这一句同时替代了「不能等于 0」的单独判断。
s = (s + p[i]) % m;边读边累加前缀和,O(n) 完成,不需要存中间结果。
if (seen[s]) { ok = false; break; }一旦余数重复(含重复的 0)就必然无解,可以立即跳出,但仍要把本组输入读完——这里已经读完了。
vector<char> seen(m, 0);按 m 开桶,而不是每组都清空一个固定 10^6 的数组,避免 T = 1000 时 O(T·10^6) 的假 TLE。
易错点
  • 只判「前缀和两两不同」,忘了余数 0 已被 S_1 占用:例如 p 的某个前缀和正好是 n+1,判重检查通过但实际不合法(WA)。
  • 以为插在别的空位也可能成立:n+1 ≡ 0 必然造成相邻两个前缀和相等,这一步想错会导致答案全错。
  • 多组测试用 memset 清 10^6 的桶:T = 1000 时是 10^9 次写,TLE。
  • 输出 -1 之后还多输出东西,或 q 只输出了 p 忘了前面的 n+1。

H · 互异排列生成

题意提高-

接上一题:对给定的 n(n ≤ 10^6,T ≤ 1000,所有 n 之和 ≤ 10^6),判断是否存在 1..n 的排列 p,使得把 n+1 插入 p 之后能得到「前缀和模 n+1 互异」的排列 q。

若存在,输出任意一个可行的 p(一行 n 个整数);若不存在输出 -1。

引导性提示

先回忆上一题的结论:n+1 只能插在开头,所以对 p 的要求是什么?

p 的 n 个前缀和必须恰好占满 n+1 个余数里除 0 之外的全部,那么它们的总和 mod n+1 必须是多少?由此能得到 n 的奇偶限制。

构造时可以让前缀和一半取 1,2,3,...,另一半取 m-1,m-2,...,然后反推出排列的每一项(差分)。

思路(先自己推,再看这里)

由上一题:只有把 n+1 放在开头才可能合法,等价条件是 p 的 n 个前缀和 mod m(m = n+1)两两不同且都不为 0。n 个互不相同的非零余数正好占满 1..m-1,于是最后一个前缀和 = 1+2+...+n = n(n+1)/2 必须不等于 0 mod m。

若 m 是奇数,(m-1)/2 是整数,n(n+1)/2 = m(m-1)/2 ≡ 0 (mod m),矛盾。所以 m 必须偶数,即 n 必须奇数;n 为偶数时直接输出 -1。这也解释了样例里 n = 2 输出 -1。

n 为奇数(m 偶数)时给出构造。取 p = (m-1, 2, m-3, 4, m-5, 6, ..., 1),也就是奇数位依次放 m-1, m-3, ..., 1,偶数位依次放 2, 4, ..., m-2。

验证:设 p 的第 2i 项是 2i,第 2i-1 项是 m-(2i-1)。则第 2i 个前缀和 = i·m + i ≡ i,第 2i-1 个前缀和 = i·m - i ≡ m-i。于是前缀和集合是 {1,...,m/2-1} ∪ {m/2,...,m-1} = 1..m-1,全部非零、全部互异,正好满足要求。

复杂度 O(n)(每个数只输出一次),比「搜索构造」靠谱得多;而且这组 p 直接可以作为上一题的输入,两题互相对拍很合适。

题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int T;
    if (scanf("%d", &T) != 1) return 0;
    while (T--) {
        int n; scanf("%d", &n);
        if (n % 2 == 0) { puts("-1"); continue; }   // m 为奇数时总和必为 0 mod m,无解
        int m = n + 1;                              // 偶数
        // p = (m-1, 2, m-3, 4, ..., 1):差分出的前缀和覆盖 1..m-1
        vector<int> p(n);
        int odd = m - 1, even = 2;
        for (int i = 0; i < n; i++) {
            if (i % 2 == 0) { p[i] = odd; odd -= 2; }
            else { p[i] = even; even += 2; }
        }
        for (int i = 0; i < n; i++) printf("%d%c", p[i], i + 1 == n ? '\n' : ' ');
    }
    return 0;
}
代码拆解
片段作用
if (n % 2 == 0) { puts("-1"); continue; }m = n+1 为奇数时,所有元素之和 ≡ 0 (mod m),最后一个前缀和必然是 0,与「非零」矛盾,所以偶数 n 无解。
int odd = m - 1, even = 2;构造的两个指针:奇数位置从 m-1 开始往下减 2,偶数位置从 2 开始往上加 2。
if (i % 2 == 0) { p[i] = odd; odd -= 2; }生成 m-1, m-3, ..., 1。它与下一项配对后差分出的前缀和正好是 m-i 与 i 交替。
else { p[i] = even; even += 2; }生成 2, 4, ..., m-2。这样 1..n 每个数恰好出现一次,p 是合法排列。
printf("%d%c", p[i], i + 1 == n ? '\n' : ' ');按题面要求输出一行 n 个整数,行末不能多空格。
易错点
  • 以为 n 偶数也可能有解:n = 2 时三个前缀和只能落在 {0,1,2},永远撞;本质是总和必为 0。
  • 把奇数位与偶数位的取值搞反(奇数位放 2,4,...,偶数位放 m-1,m-3,...):会输出到 m 这个非法值,同时前缀和也会重复。
  • 直接输出升序 1..n:n = 5 时前三个前缀和是 1, 3, 6 ≡ 0,立刻不合法。
  • 用搜索/回溯造排列:总和到 10^6,最坏情况根本搜不出来,TLE。

I · 2-冲突数对

题意入门

给定长度 n 的数组 a(1 ≤ n ≤ 10^5,1 ≤ a_i ≤ 10^5)。

统计满足 1 ≤ i ≤ j ≤ n 且 a_i ≠ a_j 的下标对 (i, j) 的个数(i = j 会被 a_i ≠ a_j 排除,所以等价于 i 小于 j);两个数对不同当且仅当 i 或 j 不同。

输出一个整数。样例:n = 3、a = 1 1 2 时答案是 2;n = 5、a = 1 2 3 2 1 时答案是 8。

引导性提示

所有下标对一共多少个?把不满足 a_i ≠ a_j 的那些减掉就行。

同值对只和每个值出现了多少次有关。

n = 10^5 时答案大约多大?int 够吗?

思路(先自己推,再看这里)

全部下标对是 C(n,2) = n(n-1)/2 个,需要去掉 a_i = a_j 的。

对每个值 v,若出现 cnt[v] 次,则它内部贡献 C(cnt_v,2) = cnt_v(cnt_v-1)/2 个同值对。答案是 C(n,2) 减去所有值的 C(cnt_v,2)。

用桶统计 cnt,O(n + V) 一遍扫完,V 是值域 10^5。n = 10^5 时 C(n,2) ≈ 5×10^9,超出 int,必须用 long long。

题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    if (scanf("%d", &n) != 1) return 0;
    const int V = 100000;
    vector<long long> cnt(V + 1, 0);
    for (int i = 0; i < n; i++) {
        int x; scanf("%d", &x);
        cnt[x]++;
    }
    long long ans = (long long)n * (n - 1) / 2;      // 全部有序对
    for (int v = 1; v <= V; v++) ans -= cnt[v] * (cnt[v] - 1) / 2;   // 减掉同值对
    printf("%lld\n", ans);
    return 0;
}
代码拆解
片段作用
long long ans = (long long)n * (n - 1) / 2;n = 10^5 时约 5×10^9,int 会溢出,先转 long long 再乘。
vector<long long> cnt(V + 1, 0);值域只有 10^5,桶数组直接开满,省掉排序或 map 的 log。
ans -= cnt[v] * (cnt[v] - 1) / 2;同值对的个数只看出现次数:cnt 个下标任取两个,都在同一个值里,都要减掉。
printf("%lld\n", ans);long long 用 %lld 输出,用 %d 会打印垃圾值。
易错点
  • 用 int 存答案:n = 10^5 时约 5×10^9 溢出,输出负数(WA)。
  • 写成 O(n^2) 枚举所有对:n = 10^5 时 10^10 次,必定 TLE。
  • 把同值对按 (i,j)、(j,i) 数成两倍:C(cnt,2) 已经是无序对,对应到有序只是 i ≤ j 的那一半,不要重复减。
  • cnt 用 int 但 cnt*(cnt-1) 先溢出:cnt 到 1e5 时乘积到 1e10,建议 cnt 直接开 long long。

J · 收藏宝石

题意普及+

n 个宝石编号 1..n,房梁编号 0。m 条绳索把节点两两相连(无重边、无自环),保证每个宝石都能连到房梁。每个宝石有价值 a_i(1 ≤ a_i ≤ k ≤ 10^5,n, m ≤ 10^5)。

使用一个宝石的操作是:选定它,切断所有与它相连的绳索。若这次操作恰好只让一个宝石失去与房梁的连接(也就是它自己掉下来),则操作合法,该宝石被取下;它上面的绳索一并消失。

问是否存在一种取下顺序,使得每一步取下的都是「当前剩余宝石中价值最大的之一」。输出 YES 或 NO(大小写随意)。

引导性提示

先想清楚「合法」到底等价于什么:切掉 v 的所有绳索之后,除了 v 之外的宝石还能不能全部连到房梁?

把顺序倒过来看:从只剩房梁开始,每次加入一个宝石。加入时对连通性提什么要求?价值又变成什么顺序?

同价值的宝石之间可以任意排序,所以它们可能互相解锁——一组同价值的点要怎么一起处理?

思路(先自己推,再看这里)

合法性其实是连通性条件。切掉 v 的全部绳索后,v 必然与房梁断开(其它宝石不能再断),所以操作合法 ⟺ 取走 v 之后,剩下的宝石仍然全部连到房梁。于是整个问题要求:每一步之后,剩余集合(加上房梁 0)都是连通的。

把顺序反过来:从集合 {0} 开始,按逆序把宝石一个个加回去。逆序里每次加入 v 时,剩下的是已经加进去的那些,所以要求恰好是「v 在已加入集合里有邻居」——加一个和当前集合相邻的点,连通性自动保持。这样做也把「割点」这类棘手判断完全绕开了。

正序要求每一步取的都是当前最大值,反过来就是要求加入顺序的价值不降。于是:所有宝石按价值升序处理,同价值的为一组;组内可以任意排序,因此只要组内某些点已经和已加入集合相邻,就可以先加它,再由它解锁同组别的点——也就是在组内做一次 BFS 扩散。

具体做法:排序后按价值分组;处理一组时,先把所有「已经与已加入集合相邻」的组内点入队,然后 BFS:出队即标记已加入,并把它在组内的邻居入队。整组扩散结束后若还有人没被加入,说明这个价值没法整体接上,答案 NO。

注意「只能取叶子」只是树上的特例。有环时,环上的点取走后其余部分仍连通,同样合法——样例 1 是个三角形,两个点都不是叶子却都能取,套「只取叶子」会直接错。

复杂度:排序 O(n log n),BFS 每个点、每条边各走一次,总 O((n + m) log n)。

题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, m, k;
    if (scanf("%d %d %d", &n, &m, &k) != 3) return 0;
    vector<int> a(n + 1);
    for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
    vector<vector<int>> g(n + 1);
    for (int i = 0; i < m; i++) {
        int x, y; scanf("%d %d", &x, &y);
        g[x].push_back(y);
        g[y].push_back(x);
    }

    vector<int> ord(n);
    for (int i = 0; i < n; i++) ord[i] = i + 1;
    sort(ord.begin(), ord.end(), [&](int x, int y) { return a[x] < a[y]; });   // 逆序 = 价值升序

    vector<char> added(n + 1, 0), inq(n + 1, 0), ing(n + 1, 0);
    added[0] = 1;                       // 房梁一开始就在集合里
    bool ok = true;
    int i = 0;
    while (i < n && ok) {
        int j = i;
        while (j < n && a[ord[j]] == a[ord[i]]) j++;         // 同价值一组
        for (int t = i; t < j; t++) ing[ord[t]] = 1;
        vector<int> q;
        for (int t = i; t < j; t++) {
            int v = ord[t];
            for (int w : g[v]) if (added[w]) { q.push_back(v); inq[v] = 1; break; }
        }
        for (size_t h = 0; h < q.size(); h++) {              // 组内扩散
            int v = q[h];
            added[v] = 1;
            for (int w : g[v]) if (ing[w] && !added[w] && !inq[w]) { inq[w] = 1; q.push_back(w); }
        }
        for (int t = i; t < j; t++) if (!added[ord[t]]) ok = false;   // 有人接不上
        for (int t = i; t < j; t++) ing[ord[t]] = 0;
        i = j;
    }
    puts(ok ? "YES" : "NO");
    return 0;
}
代码拆解
片段作用
vector<char> added(n + 1, 0), inq(n + 1, 0), ing(n + 1, 0);added:已加回集合(含房梁 0);inq:本组内已入队;ing:本组成员标记,避免每组都清空 n 大小的数组。
added[0] = 1;房梁视为一开始就在集合里,于是「与房梁直接相连」的宝石在第一步就能加入。漏了这一句会让所有宝石都加不进来,全部输出 NO。
sort(ord.begin(), ord.end(), [&](int x, int y) { return a[x] < a[y]; });倒着看以后价值必须不降,所以按价值升序排序;同价值的点相对顺序任意。
for (int t = i; t < j; t++) ing[ord[t]] = 1;只标记当前这一组,BFS 扩散时限定组内,避免同价值以外的点被提前加入。
for (int w : g[v]) if (added[w]) { q.push_back(v); inq[v] = 1; break; }组内种子:已经和已加入集合相邻的点。一个点只要有一个已加入邻居就够了。
for (int w : g[v]) if (ing[w] && !added[w] && !inq[w]) { inq[w] = 1; q.push_back(w); }组内 BFS:加入 v 之后,同组邻居也被解锁。没有 inq 标记会重复入队,队列规模退化。
for (int t = i; t < j; t++) if (!added[ord[t]]) ok = false;整组扩散完还有人没加入,说明这一档价值出现了「接不上」的断点,答案就是 NO。
易错点
  • 套用「只有叶子能取」:样例 1 是三角形,两个宝石都不是叶子却都合法,按叶子判会输出 NO(正确答案 YES)。
  • 只做一次全局 BFS 而不按价值分组:同价值的点本来可以互相解锁,不分组的做法会把它们误判成接不上(WA)。
  • 把「恰好一个宝石失去连接」理解成「切掉 v 就行,别的宝石断不断无所谓」:那会允许砍掉整棵子树,样例 2 会错答 YES。
  • 组内扩散没有 inq 标记,同一个点被反复入队,入队次数从 O(n + m) 退化成 O(deg^2),大数据 TLE。
题解 2025 广工月赛 题解 14 题全解(跳过 3 道入门题):数论、贪心、数据结构与构造入门 ~ 提高+

2025 广工月赛 题解

14 题全解(跳过 3 道入门题):数论、贪心、数据结构与构造
题解入门 ~ 提高+2025 年广东工业大学 ACM 程序设计竞赛月赛
跳过说明
  • A %%%自动机:输出 n 个 %,入门读入输出,略去。
  • I 运动世界校园:1-(1-p)^3 的入门概率,略去。
  • L 翻转硬币:n ≤ 20 的入门题,略去。

用法:先自己看题意,再看提示,卡住 20 分钟再看思路与代码。

关键规模与做法对照

B:maxA ≤ 1081080,约数枚举总量 ≈ maxA·σ(k)/k ≈ 4.8×10⁶,不是 O(n√k)。

C:询问 ≤ n²,实测 n = 100 的最坏排列 5050 次;D:1 + n(n−1)/2 + ⌊n/3⌋,n = 100 时 4984 次。

F:n + q ≤ 2×10⁷,答案压成异或,必须 O(1) 摊还维护第 k 大;不同票数只有 O(√n) 种。

G:4×4 棋盘,状态 = 2¹⁶ × 4 × 4,直接记忆化博弈,一次跑完全部 16 组输入。

K:Erdős–Szekeres 给下界 min_B (B + ⌈n/B⌉),分块构造恰好达到。

N:只看度数 ≥ 2 的点数 D,答案 max(0, D−1),再输出任意两个分叉点。

交互题先读交互协议

C/D 每输出一行都要 fflush(stdout);C 可以输出任意序列,D 必须输出排列。

询问次数按 n² 卡,Σn² ≤ 10000,所以 n = 100 时只有 10000 次预算。

题目索引

题号题目核心考点难度
Bk-冲突数对数论参数化 gcd + 滑动窗口提高-
C序列重构(简单版)交互题:用垃圾值逐位定位普及+
D序列重构(困难版)交互 + 组合:不动点计数 → 环结构 → 定向提高+
E新田忌赛马贪心(带权调度 / 交换论证)普及+
F雪莉的预言桶维护 + 「第 k 大每次只挪一格」的指针摊还提高
G俄罗斯方块的博弈无偏博弈 + 状压记忆化搜索普及+
H我不吃水果按列预处理 + 前缀最小值(锚定矩形)普及
J逃出生天静态网格预处理 + BFS普及
K最不上升也不下降序列构造 + Erdős–Szekeres普及+
M字符消消乐计数 + 贪心(一次操作吃光一段)普及-
N缩花树 + 不变量(分叉点个数)提高+

B · k-冲突数对

题意提高-

定义 k-冲突数对:满足 x + y − gcd(x, y) = k 的数对 (x, y)。给定长度为 n、元素两两不同的数组 a。若区间 [l, r] 内不存在下标 l ≤ i, j ≤ r 使 (a_i, a_j) 是 k-冲突数对(i 与 j 可以相同),则称子数组 a[l..r] 合法。求最长合法子数组的长度,空数组也算,答案可以是 0。

输入:第一行 n, k(1 ≤ n, k ≤ 1081080);第二行 n 个整数 a_i(1 ≤ a_i ≤ 1081080,两两不同)。

输出:一个整数,最长合法长度。题面提醒本题时空限制特殊,需要较快的读入。

引导性提示

把 x、y 写成 g·u、g·v(g = gcd(x, y)),方程立刻变成 g(u+v−1) = k。于是 g 只能是 k 的约数,u+v 被 k/g 唯一确定。

u 与 v 互质,而 v = s − u,所以条件等价于 gcd(u, s) = 1——只要枚举 1 ≤ u < s,不重不漏。

拿到所有冲突数对后,问题变成「窗口内不能同时出现某一对位置」,这是滑动窗口的标准形态:约束按右端点累积。

思路(先自己推,再看这里)

参数化。设 g = gcd(x, y),x = g·u,y = g·v,gcd(u, v) = 1。则 x + y − gcd = g(u+v) − g = g(u+v−1) = k,所以 g | k,且 s := u + v = k/g + 1 固定。

反过来验证:任取 k 的约数 g、任取 1 ≤ u < s 且 gcd(u, s) = 1(因为 gcd(u, v) = gcd(u, u+v) = gcd(u, s)),令 v = s − u,则 (gu, gv) 满足方程且 gcd 恰为 g。所以枚举「约数 × u」穷尽了全部冲突数对,不重不漏。

规模估算:1081080 = 2³·3³·5·7·11·13,约数最多 256 个;对固定 g,u 只需要枚举到 ⌊maxA/g⌋(因为 g·u ≤ maxA)。总枚举量 Σ_{g|k} maxA/g = maxA·σ(k)/k ≈ 4.8×10⁶,很轻松。

把冲突对换算成位置约束:值为 x、y 的两个数在数组中的位置是 pos[x]、pos[y],记 L = min、R = max。任何同时包含 L 与 R 的窗口都不合法,也就是「右端点 ≥ R 的窗口,左端点必须 > L」。于是记 bound[R] = max(bound[R], L+1)。

滑动窗口:left[r] = max(left[r−1], bound[r]),答案取 max(r − left[r] + 1)。注意 i = j 的情况:x = y 时方程变成 2x − x = x = k,所以「值恰好等于 k」的元素与自己冲突,此时 L = R = pos[k],bound = pos+1,算出的窗口长度正好是 0——同一套式子顺带处理掉了。

复杂度 O(Σ_{g|k} maxA/g + n),空间 O(maxA + n)。

题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; long long k;
    if (scanf("%d %lld", &n, &k) != 2) return 0;
    vector<int> a(n);
    int maxA = 0;
    for (int i = 0; i < n; i++) { scanf("%d", &a[i]); maxA = max(maxA, a[i]); }
    vector<int> pos(maxA + 1, -1);
    for (int i = 0; i < n; i++) pos[a[i]] = i;
    vector<int> bound(n, 0);                 // bound[r] = 窗口右端为 r 时,左端至少要到哪
    for (long long d = 1; d * d <= k; d++) {
        if (k % d) continue;
        for (long long g : {d, k / d}) {     // g = gcd(x,y) 必须是 k 的约数
            if (g > maxA) continue;          // x = g*u >= g
            long long s = k / g + 1;         // u + v = s
            long long lim = maxA / g;        // u、v 都不超过这个上界
            long long hi = min(s - 1, lim), lo = max(1LL, s - lim);
            for (long long u = lo; u <= hi; u++) {
                if (std::gcd(u, s) != 1) continue;   // (u,v)=1 等价于 (u,s)=1
                long long v = s - u;
                int x = (int)(g * u), y = (int)(g * v);
                if (pos[x] < 0 || pos[y] < 0) continue;
                int L = min(pos[x], pos[y]), R = max(pos[x], pos[y]);
                bound[R] = max(bound[R], L + 1);     // 含 L、R 的窗口必须从 L 右边开始
            }
        }
    }
    int left = 0, ans = 0;
    for (int r = 0; r < n; r++) {
        left = max(left, bound[r]);
        ans = max(ans, r - left + 1);
    }
    printf("%d\n", ans);
    return 0;
}
代码拆解
片段作用
for (long long d = 1; d * d <= k; d++)只枚举到 √k,再配对取出 k/d 两个约数
for (long long g : {d, k / d})g 与 k/g 都要试;g > maxA 直接跳过,因为 x = g·u ≥ g
long long s = k / g + 1;由 g(u+v−1) = k 得 u+v = k/g+1,这是唯一的自由度
if (std::gcd(u, s) != 1) continue;gcd(u,v) = gcd(u, u+v) = gcd(u, s),只需查 u 与 s 互质
long long hi = min(s - 1, lim), lo = max(1LL, s - lim);同时卡住 g·u ≤ maxA 与 g·v ≤ maxA,避免越界
bound[R] = max(bound[R], L + 1);把「同时含 L、R」翻译成右端点 R 对应的左端下界
left = max(left, bound[r]);约束随右端点单调累积,左端点只增不减
ans = max(ans, r - left + 1);left 可以等于 r+1(自己和自己冲突),此时长度为 0
易错点
  • 漏掉 i = j:x = k 时 (k, k) 自己是冲突对,这样的元素不能出现在任何窗口里,样例 1 就是这个情况。
  • 只枚举 g ≤ √k 的一半约数,忘了 k/g 那一半。
  • u 的范围必须同时满足 g·u ≤ maxA 和 g·(s−u) ≤ maxA,只卡一边会数组越界。
  • a_i ≤ 1081080,用「值 → 位置」的数组直接索引;用 map 每次查 10⁶ 次会慢一个量级。

C · 序列重构(简单版)

入门交互题:把「一次询问」当成一次测量,用 0 当垃圾值屏蔽掉不需要观察的位置。

题意普及+

交互题。有一个隐藏的长度为 n 的排列 p(1..n 各出现一次),它在任何询问之前就已固定。

每次询问:输出长度 n 的任意整数序列 a(每个 a_i 只要在 32 位有符号整数范围内),交互器返回满足 a_i = p_i 的位置个数 k。最多询问 n² 次,之后输出 p。

本题是简单版:a 允许是任意序列(不要求是排列)。

多测:第一行 t(t ≤ 2500),每个测试一行 n(n ≤ 100),保证 Σn² ≤ 10000。输出答案不计入询问次数。

引导性提示

简单版允许任意序列,那就能把「除一个位置外」的所有位置都填成绝不可能等于 p_i 的值。

p 的值域恰好是 1..n,而 0 不在其中:把其余位置全填 0,它们就永不匹配。

于是那次询问的返回值只能是 0 或 1,直接就是「p_j 是不是 v」的答案。

思路(先自己推,再看这里)

目标:对每个位置 j 定出 p_j。

询问「第 j 位放 v、其余位放 0」时,返回的 k = [p_j = v](0 不属于 p 的值域,别的位置一定不匹配)。一个 0/1 的答案就把「p_j = v」这条假设判定完毕。

对 v = 1..n 依次处理:从左到右扫还没确定的位置,遇到返回 1 就把该位置定为 v,本个 v 结束。每个 v 至多花 n 次询问,总计 ≤ n²。实测 n = 100 的逆序排列用了 5050 次。

复杂度:O(n²) 次询问,每次输出 n 个整数,总输出量 O(n³) 个整数;n = 100 时 10⁶ 个,足够快。

题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
    int t;
    if (scanf("%d", &t) != 1) return 0;
    while (t--) {
        int n;
        if (scanf("%d", &n) != 1) return 0;
        vector<int> ans(n, 0), q(n, 0);
        for (int v = 1; v <= n; v++) {
            for (int j = 0; j < n; j++) {       // 把 v 摆在 j 位,其余位置放 0(非排列值,永不匹配)
                if (ans[j]) continue;
                fill(q.begin(), q.end(), 0);
                q[j] = v;
                printf("?");
                for (int i = 0; i < n; i++) printf(" %d", q[i]);
                printf("\n");
                fflush(stdout);
                int c;
                if (scanf("%d", &c) != 1) return 0;
                if (c) { ans[j] = v; break; }
            }
        }
        printf("!");
        for (int i = 0; i < n; i++) printf(" %d", ans[i]);
        printf("\n");
        fflush(stdout);
    }
    return 0;
}
代码拆解
片段作用
fill(q.begin(), q.end(), 0);全填 0:0 不在 1..n 中,这些位置必然不匹配
q[j] = v;唯一可能命中的位置
if (c) { ans[j] = v; break; }返回值只能是 0/1,为 1 就锁定 p_j = v,这个 v 不用再试
if (ans[j]) continue;已确定的位置不再浪费询问
fflush(stdout);交互题每输出一行都要刷新缓冲区
while (t--)多测:回答完要接着读下一个 n,不能提前退出
易错点
  • 忘记 fflush(stdout):交互器会一直等,表现为 TLE 或 ILE。
  • 询问虽然是「任意序列」,但仍必须输出恰好 n 个数。
  • 询问上限是 n²,别对每个 (v, j) 都无脑问一遍而不再去重(本题最坏 n² 也允许,但要保证不超)。
  • 输出答案后要处理下一个测试用例,不能在 t 循环里 return。

D · 序列重构(困难版)

本题是整场的思维题:把「询问排列」看成「测任意排列与 p 的重合数」,再用不动点计数与无向环把信息榨干。属于拓展难度,建议先做完 C 再读。

题意提高+

交互题,与简单版同样的目标:问出隐藏排列 p(1..n 各一次)。

区别:每次询问的 a 必须自身也是 1..n 的排列。交互器回答 a 与 p 在同位置取值相同的个数 k。最多询问 n² 次。

多测:t ≤ 2500,n ≤ 100,Σn² ≤ 10000。输出答案不计入询问次数。

引导性提示

因为「排列复合排列还是排列」,你可以问 a = τ(τ 是任意排列),答案就是 #{i : p_i = τ_i},也就是「τ 与 p 的重合数」。

先问恒等排列,拿到 p 的不动点个数 F。再问所有「只交换 i、j 两位」的排列:它的答案减去 F,只剩四个指示变量。

把 T(i, j) 对 j 求和,[p_i = i] 就被单独解出来了;剩下的关系 T(i, j) ≥ 1 告诉你 p 的环结构。

思路(先自己推,再看这里)

关键恒等式:问 a = τ 时答案 = #{i : p_i = τ_i}。排列集合对复合封闭,所以「任意排列与 p 的重合数」都能无噪声地测出来。

第一步:问恒等排列,得到 F = #{i : p_i = i}。

第二步:问「交换 i、j 两位」的排列(仍是排列),记 T(i, j) = 答案 − F。把重合数展开,只有四项:T(i, j) = [p_i = j] + [p_j = i] − [p_i = i] − [p_j = j],取值只可能是 −2..2。

对固定 i 把 T(i, j) 对 j ≠ i 求和:Σ[p_i = j] = 1 − [p_i = i],Σ[p_j = i] = 1 − [p_i = i],Σ[p_j = j] = F − [p_i = i],于是 A_i = 2 − F − n·[p_i = i]。每个位置是不是不动点,一次就解出来了。

第三步:对非不动点 i,T(i, j) ≥ 1 当且仅当 j 是 p_i 或 p⁻¹(i),所以把这些「相邻关系」连起来就得到 p 的每个环的无向版本(把有向环看成无向环,此时还剩朝向未知)。T(i, j) = 2 只可能出现在二元环上(p_i = j 且 p_j = i),可以直接定死。

第四步:长度 ≥ 3 的无向环还剩两个朝向。取候选 τ =「沿环走一步,环外恒等」,问一次:环外恒等两问贡献相同、环内没有不动点,所以 答案 − F 只能等于 L 或 0(L 为环长),据此定朝向。

询问数 = 1 + n(n−1)/2 + (#长度 ≥ 3 的环) ≤ 1 + n(n−1)/2 + ⌊n/3⌋ < n²。n = 100 时 4984 次,实测恰好是这个数。

题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;
int n;
int ask(const vector<int>& a) {
    printf("?");
    for (int i = 0; i < n; i++) printf(" %d", a[i]);
    printf("\n");
    fflush(stdout);
    int c;
    if (scanf("%d", &c) != 1) exit(0);
    return c;
}
int main() {
    int t;
    if (scanf("%d", &t) != 1) return 0;
    while (t--) {
        if (scanf("%d", &n) != 1) return 0;
        vector<int> id(n);
        for (int i = 0; i < n; i++) id[i] = i + 1;
        if (n == 1) { printf("! 1\n"); fflush(stdout); continue; }
        int F = ask(id);                       // 不动点个数
        vector<vector<signed char>> T(n, vector<signed char>(n, 0));
        for (int i = 0; i < n; i++)
            for (int j = i + 1; j < n; j++) {  // 交换 i,j 两个位置上的值
                vector<int> q = id;
                swap(q[i], q[j]);
                T[i][j] = T[j][i] = (signed char)(ask(q) - F);
            }
        vector<int> p(n, 0);
        vector<char> fix(n, 0), done(n, 0);
        for (int i = 0; i < n; i++) {
            int A = 0;
            for (int j = 0; j < n; j++) if (j != i) A += T[i][j];
            // A_i = 2 - F - n*[p_i == i]
            if ((2 - F - A) / n == 1) { fix[i] = 1; p[i] = i + 1; done[i] = 1; }
        }
        for (int i = 0; i < n; i++) if (!done[i])          // T=2 就是二元环
            for (int j = i + 1; j < n; j++) if (!done[j] && T[i][j] == 2) {
                p[i] = j + 1; p[j] = i + 1; done[i] = done[j] = 1; break;
            }
        vector<vector<int>> adj(n);
        for (int i = 0; i < n; i++) if (!done[i])
            for (int j = 0; j < n; j++) if (j != i && !done[j] && T[i][j] == 1) adj[i].push_back(j);
        vector<char> vis(n, 0);
        for (int s = 0; s < n; s++) {
            if (done[s] || vis[s]) continue;
            vector<int> cyc;                    // 走出一条无向环
            int cur = s, prev = -1;
            while (!vis[cur]) {
                vis[cur] = 1; cyc.push_back(cur);
                int nxt = -1;
                for (int x : adj[cur]) if (x != prev) { nxt = x; break; }
                prev = cur; cur = nxt;
                if (cur < 0) break;
            }
            int L = (int)cyc.size();
            vector<int> q = id;                 // 猜测顺时针:cyc[t] -> cyc[t+1]
            for (int i = 0; i < L; i++) q[cyc[i]] = cyc[(i + 1) % L] + 1;
            int c = ask(q);
            bool fwd = (c - F == L);            // 环外不动,故差值只能是 0 或 L
            for (int i = 0; i < L; i++) {
                int to = fwd ? cyc[(i + 1) % L] : cyc[(i - 1 + L) % L];
                p[cyc[i]] = to + 1;
            }
        }
        printf("!");
        for (int i = 0; i < n; i++) printf(" %d", p[i]);
        printf("\n");
        fflush(stdout);
    }
    return 0;
}
代码拆解
片段作用
int F = ask(id);恒等排列的答案就是 p 的不动点个数
T[i][j] = T[j][i] = (signed char)(ask(q) - F);交换两位后差值只剩四个指示变量,取值 −2..2,用 signed char 存
if ((2 - F - A) / n == 1)由 A_i = 2 − F − n·[p_i = i] 反解不动点(整除,结果只能是 0/1)
if (!done[j] && T[i][j] == 2)T = 2 当且仅当 p 在 i、j 上构成二元环,直接定死
for (int x : adj[cur]) if (x != prev)每个非不动点只与 p_i、p⁻¹(i) 相邻,沿「不是来路」的邻居走就绕出整个环
bool fwd = (c - F == L);环外两问相同、环内无不动点,所以两个朝向的答案差正好是 L
if (n == 1) { printf("! 1\n"); ... continue; }n = 1 时唯一排列已知,不能进入「交换两位」的逻辑
易错点
  • 询问必须是排列:交换两个位置、翻转整条环都保持双射,但别顺手把某位改成别的值。
  • 二元环要先处理掉,否则「每个点恰有两个邻居」的前提在二元环上失效(只会有一个)。
  • n = 1 要单独回答,否则 1 + n(n−1)/2 的推导和环遍历都会出问题。
  • 环定向只额外花 1 次询问;如果对每个环问两次,n = 100 时 4950 + 66 仍在界内但没必要。

E · 新田忌赛马

题意普及+

田忌有 n 匹马,速度 a_i;齐王有 m 匹马,速度 b_i,两组都按降序给出。

每轮各出一匹未出战的马对局:若田忌的马速度严格大于齐王的马,田忌获得 b_j(对手那匹马的速度)的赏金,否则 0。

求田忌能获得的最大赏金总额。1 ≤ n, m ≤ 5×10⁵,1 ≤ a_i, b_i ≤ 10⁹。

引导性提示

输掉不罚分,唯一目标是把某些 b_j 赢下来:等价于选一个单射,最大化「赢下的对手速度之和」。

赏金就是 b_j 本身,所以赢大的总比赢小的好——按 b 从大到小做决策。

决定要赢 b_j 时,用「能赢它的最慢的马」最省:能赢 b_j 的马,也一定能赢所有比 b_j 更小的对手。

思路(先自己推,再看这里)

这是一个「每个任务需要一台能力足够的机器」的带权调度问题:任务 j 的权值是 b_j,需要一匹 a > b_j 的马。

交换论证:按 b 降序处理。设当前最大的是 b_1。若某个最优解没有赢 b_1 而 b_1 明明能被赢下,就把「赢下集合」里权值最小的那个任务换成 b_1(马不够就挪一匹正在赢更小对手的马过来),赏金不减。归纳可知贪心最优。

实现:把 a 排序后放进 multiset,b 降序遍历,对每个 b_j 用 upper_bound(b_j) 取「第一个严格大于 b_j 的马」——这是所有能赢 b_j 的马里最慢的一匹,把更快的马留给后面(其实后面更小,但这样做天然不浪费)。找不到就放弃这个 b_j。

复杂度 O((n+m) log(n+m));常数可以更好(排序 + 并查集/双指针),5×10⁵ 用 multiset 也只要 0.1s 量级。

题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, m;
    scanf("%d %d", &n, &m);
    vector<int> a(n), b(m);
    for (int i = 0; i < n; i++) scanf("%d", &a[i]);
    for (int i = 0; i < m; i++) scanf("%d", &b[i]);
    sort(a.begin(), a.end());
    sort(b.begin(), b.end());
    multiset<int> have(a.begin(), a.end());
    long long ans = 0;
    for (int j = m - 1; j >= 0; j--) {          // 赏金从大到小处理
        auto it = have.upper_bound(b[j]);       // 能赢它的最慢的马
        if (it != have.end()) { ans += b[j]; have.erase(it); }
    }
    printf("%lld\n", ans);
    return 0;
}
代码拆解
片段作用
sort(b.begin(), b.end());齐王的马按速度(也就是赏金)升序,之后从后往前就是降序
for (int j = m - 1; j >= 0; j--)先安排赏金最大的对手,避免大赏金被小赏金的选择挤掉
auto it = have.upper_bound(b[j]);upper_bound 找到最小的严格大于 b_j 的马,省下更快的马
have.erase(it);赢下就必须消耗这匹马,一匹马只能打一场
ans += b[j];只有赢下才计赏金,输掉是 0 而不是负分
long long ans上界 5×10⁵ × 10⁹ = 5×10¹⁴,必须 64 位
易错点
  • 赏金是 b_j(对手的速度),不是差值 a_i − b_j,也不是 a_i。
  • 必须严格大于才算赢:用 upper_bound 而不是 lower_bound。
  • n 与 m 不相等:最多打 min(n, m) 场,打不赢的场次不影响答案,不用特殊处理。
  • 答案会超过 32 位,用 long long 存。

F · 雪莉的预言

2×10⁷ 的规模下不能开「票数 → 编号和」的 64 位数组,用「不同票数只有 O(√n) 种」这一点换空间是这题的核心技巧。

题意提高

m 名少女编号 0..m−1,一开始有 n 张票,随后有 q 次操作(投票或撤票,撤票时若该少女 0 票则忽略,但答案照算)。

每次操作后,把所有少女的票数降序排成 a,票数恰好等于 a_k 的少女都算「票数第 k 大」,记她们的编号和为 S_i。

输出 S_1 ⊕ S_2 ⊕ … ⊕ S_q。

输入一行 seed, k, n, m, q, b(seed ≤ 10⁹,k, n, m, q ≤ 2×10⁷,n+q ≤ 2×10⁷)。

数据由给定的 RNG 在线生成:seed = (seed·13331 + 23333) mod 1000000007;初始 n 次投票各取一次 read() % m;每次操作先 read() 得 op(op ≥ b 表示投票,否则撤票),再 read() % m 得到少女编号。

引导性提示

输出被压成了异或,说明必须在线维护每一次的答案,不能把 q 个答案存下来。

一次操作只让一个人的票数 ±1:降序数组里只有一个元素挪了一格,所以「第 k 大的值」每次最多变 1。

那就记住当前的 cur,每次操作后判断要不要 ±1,并同时维护「票数 ≥ cur 的人数」——它只在有人跨过 cur 时才变。

思路(先自己推,再看这里)

维护 cnt[v] = 票数恰为 v 的人数、sum[v] = 这些人的编号和。那么本次答案就是 sum[cur],cur 是当前第 k 大的票数。

维护 G = #{票数 ≥ cur}。一次操作后第 k 大最多移动 1:若 G − cnt[cur] ≥ k,说明真正的第 k 大在 cur 之上,做 G −= cnt[cur]、cur++;若 G < k,则 cur−−、G += cnt[cur]。每次操作最多挪一两步,而 cur 的总移动量是 O(q + 初值),整体线性。

G 的增量维护:投票 x → x+1 时只有 x+1 = cur(即 x = cur−1)才让 G 加一;撤票 x → x−1 时只有 x = cur 才让 G 减一。

空间是这题的关键。票数取值在 [0, n],直接开 sum 数组要 2×10⁷ 个 long long(160MB)+ cnt(80MB),太重。但票数是非负整数且总和 ≤ n ≤ 2×10⁷,「出现过的不同票数」至多 D 种,而 1+2+…+D ≤ n 给出 D ≤ 6326。所以用一张 32768 槽的开地址表存 (票数 → 人数, 编号和),常驻缓存,快到可以忽略。

0 票那一桶不必逐个插入:人数 = m − 被投过票的人数,编号和 = m(m−1)/2 − Σ(被投过票的编号)。初值 cur 则把表里所有 (票数, 人数) 按票数降序累加,累到 ≥ k 为止。

复杂度 O(n + q + √n) 时间,O(m + 32768) 空间;实测 n = q = 10⁷、m = 2×10⁷ 一组约 1.1s。

题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

static ll sd;
static inline ll rnd() { sd = (sd * 13331 + 23333) % 1000000007LL; return sd; }

// 出现的不同票数只有 O(sqrt(n)) 种(1+2+...+D <= 票数总和 <= n),
// 所以 (票数 -> 人数/编号和) 用一张 32768 槽的开地址散列表就够,不必开 2e7 的数组。
static const int HS = 1 << 15, HM = HS - 1;
static int hkey[HS], hcnt[HS];
static ll hsum[HS];

static inline int slotOf(int v, bool create) {
    int h = v & HM;
    while (true) {
        if (hkey[h] == v) return h;
        if (hkey[h] < 0) {
            if (!create) return -1;
            hkey[h] = v; hcnt[h] = 0; hsum[h] = 0;
            return h;
        }
        h = (h + 1) & HM;
    }
}
static inline int getCnt(int v) { int s = slotOf(v, false); return s < 0 ? 0 : hcnt[s]; }
static inline ll getSum(int v) { int s = slotOf(v, false); return s < 0 ? 0 : hsum[s]; }

int main() {
    ll k, b;
    int n, m, q;
    if (scanf("%lld %lld %d %d %d %lld", &sd, &k, &n, &m, &q, &b) != 6) return 0;
    for (int i = 0; i < HS; i++) hkey[i] = -1;

    vector<int> votes(m, 0);
    vector<int> touched;
    touched.reserve(min(n, m));
    for (int i = 0; i < n; i++) {
        int g = (int)(rnd() % m);
        if (votes[g] == 0) touched.push_back(g);
        votes[g]++;
    }
    for (int g : touched) {
        int s = slotOf(votes[g], true);
        hcnt[s]++; hsum[s] += g;
    }
    {   // 0 票的少女是最多的一批,直接算,避免 O(m) 次散列
        ll tot = (ll)m * (m - 1) / 2, positiveSum = 0;
        for (int g : touched) positiveSum += g;
        int s = slotOf(0, true);
        hcnt[s] = m - (int)touched.size();
        hsum[s] = tot - positiveSum;
    }

    // 初始的第 k 大票数
    vector<pair<int, int>> vals;
    for (int i = 0; i < HS; i++) if (hkey[i] >= 0) vals.push_back({hkey[i], hcnt[i]});
    sort(vals.begin(), vals.end(), greater<pair<int, int>>());
    int cur = 0, G = 0;
    for (auto& pv : vals) {
        G += pv.second;
        cur = pv.first;
        if (G >= k) break;
    }

    ll xorsum = 0;
    for (int i = 0; i < q; i++) {
        ll op = rnd();
        int g = (int)(rnd() % m);
        int x = votes[g];
        if (op >= b) {                       // 投票:x -> x+1
            if (x + 1 == cur) G++;           // 她跨过 cur 这条线,才影响 “>= cur” 的人数
            int s = slotOf(x, true); hcnt[s]--; hsum[s] -= g;
            s = slotOf(x + 1, true); hcnt[s]++; hsum[s] += g;
            votes[g] = x + 1;
        } else if (x > 0) {                  // 撤票:x -> x-1(0 票时忽略,但答案照算)
            if (x == cur) G--;
            int s = slotOf(x, true); hcnt[s]--; hsum[s] -= g;
            s = slotOf(x - 1, true); hcnt[s]++; hsum[s] += g;
            votes[g] = x - 1;
        }
        while (G - getCnt(cur) >= k) { G -= getCnt(cur); cur++; }   // 第 k 大上移
        while (G < k && cur > 0) { cur--; G += getCnt(cur); }       // 第 k 大下移
        xorsum ^= getSum(cur);
    }
    printf("%lld\n", xorsum);
    return 0;
}
代码拆解
片段作用
static int hkey[HS], hcnt[HS]; static ll hsum[HS];(票数 → 人数, 编号和) 的小散列表:不同票数只有 O(√n) 种,开 2×10⁷ 的数组是浪费
hcnt[s] = m - (int)touched.size(); hsum[s] = tot - positiveSum;0 票的桶用补集一次算出,省掉 O(m) 次散列
if (x + 1 == cur) G++;只有跨过 cur 这条线的人才改变「票数 ≥ cur」的人数
if (x == cur) G--;撤票时同理,跨出 cur 的人才让 G 减一
while (G - getCnt(cur) >= k) { G -= getCnt(cur); cur++; }第 k 大上移:把整个 cur 桶移出 G
while (G < k && cur > 0) { cur--; G += getCnt(cur); }第 k 大下移,并把新区间的人加进 G
xorsum ^= getSum(cur);第 k 大那一桶的编号和,直接异或进答案
if (scanf("%lld %lld %d %d %d %lld", &sd, &k, &n, &m, &q, &b) != 6) return 0;注意六个参数的顺序是 seed, k, n, m, q, b
易错点
  • 撤票时若该少女 0 票,操作被忽略,但仍然要算一次答案并异或进去。
  • sum[v] 能到 m(m−1)/2 ≈ 2×10¹⁴,异或也按 64 位做,别用 int。
  • 每次操作要读两个随机数:op 和少女编号,顺序不能反;op ≥ b 才是投票。
  • 要保证 k ≤ m,否则「第 k 大」不存在;cur 下移时记得判 cur > 0。

G · 俄罗斯方块的博弈

题意普及+

4×4 棋盘,x 个 T 形四格骨牌、y 个 L 形四格骨牌(都可旋转、翻转、平移),双方轮流放一块,必须完全在棋盘内且不与已有方块重叠,无处可放者输。Alice 先手,双方最优。0 ≤ x, y ≤ 3。

输出 Alice 或 Bob。

T 形:3 个相邻格子在一条直线上,第 4 格从正中伸出;L 形:同样的 3 连格,第 4 格从拐角(端点)伸出。

引导性提示

棋盘只有 16 格,每块占 4 格,所以一局最多放 4 块——搜索树极浅。

双方可用方块是同一堆,所以状态只需要 (已占格掩码, 剩余 T 数, 剩余 L 数),是个无偏博弈。

必胜 ⟺ 存在一步走到必败状态;无步可走即必败。直接记忆化。

思路(先自己推,再看这里)

先枚举所有摆放:把基础形状做 4 次旋转 × 2 次翻转,归一化去重,再在 4×4 内平移。T 共 24 种摆放(4 个朝向 × 每个朝向 6 个位置),L 共 48 种(8 个朝向 × 6)。

记忆化搜索 win(mask, t, l):能放下某个不冲突的 T(t > 0)或 L(l > 0),并且走完后对手是必败态,则当前必胜;否则必败。

状态上界 2¹⁶ × 4 × 4 = 2²⁰ 个,但可达状态远少于此(掩码必须是若干四格骨牌的并集),数组只需要 1MB,实测全域 16 组 (x, y) 一次跑完不到 0.1s。

全部 16 组的结果(行 = T 数 0..3,列 = L 数 0..3,B 表示 Bob 必胜):x=0 行 B A B A;x=1 行 A B B A;x=2 行 B B B A;x=3 行 B B B A。可以拿它检查自己的搜索。

题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

static vector<int> TP, LP;                 // 所有 T / L 方块的摆放掩码(4x4 -> 16 位)
static int8_t memo[1 << 16][4][4];         // 0 未知,1 必胜,2 必败

static void genShapes(const vector<pair<int, int>>& base, vector<int>& out) {
    set<vector<pair<int, int>>> canon;
    vector<pair<int, int>> cur = base;
    for (int f = 0; f < 2; f++) {
        for (int r = 0; r < 4; r++) {
            int mnr = 9, mnc = 9;
            for (auto& p : cur) { mnr = min(mnr, p.first); mnc = min(mnc, p.second); }
            vector<pair<int, int>> nrm;
            for (auto& p : cur) nrm.push_back({p.first - mnr, p.second - mnc});
            sort(nrm.begin(), nrm.end());
            canon.insert(nrm);
            vector<pair<int, int>> nx;
            for (auto& p : cur) nx.push_back({p.second, -p.first});   // 旋转 90 度
            cur = nx;
        }
        vector<pair<int, int>> nf;
        for (auto& p : cur) nf.push_back({p.first, -p.second});       // 翻转
        cur = nf;
    }
    set<int> masks;
    for (auto& sh : canon) {
        int mr = 0, mc = 0;
        for (auto& p : sh) { mr = max(mr, p.first); mc = max(mc, p.second); }
        for (int dr = 0; dr + mr < 4; dr++)
            for (int dc = 0; dc + mc < 4; dc++) {
                int m = 0;
                for (auto& p : sh) m |= 1 << ((p.first + dr) * 4 + (p.second + dc));
                masks.insert(m);
            }
    }
    out.assign(masks.begin(), masks.end());
}

static bool win(int mask, int t, int l) {
    int8_t& r = memo[mask][t][l];
    if (r) return r == 1;
    bool w = false;
    if (t > 0)
        for (int p : TP) if (!(p & mask)) { if (!win(mask | p, t - 1, l)) { w = true; break; } }
    if (!w && l > 0)
        for (int p : LP) if (!(p & mask)) { if (!win(mask | p, t, l - 1)) { w = true; break; } }
    r = w ? 1 : 2;
    return w;
}

int main() {
    genShapes({{0, 0}, {0, 1}, {0, 2}, {1, 1}}, TP);   // T 形:3 连 + 正中伸出
    genShapes({{0, 0}, {0, 1}, {0, 2}, {1, 0}}, LP);   // L 形:3 连 + 拐角伸出
    int x, y;
    scanf("%d %d", &x, &y);
    printf("%s\n", win(0, x, y) ? "Alice" : "Bob");
    return 0;
}
代码拆解
片段作用
static int8_t memo[1 << 16][4][4];2²⁰ 字节的记忆化表,0/1/2 = 未知/必胜/必败
for (int r = 0; r < 4; r++)4 次旋转;外层再翻一次,凑齐 8 种对称
canon.insert(nrm);先平移到左上角并排序,保证同一朝向只留一份
for (int dr = 0; dr + mr < 4; dr++)在 4×4 内平移,越界就停
for (int p : TP) if (!(p & mask))枚举所有与已占格不冲突的摆放(位与为 0)
if (!win(mask | p, t - 1, l)) { w = true; break; }存在一步让对手必败,当前即必胜
win(0, x, y) ? "Alice" : "Bob"空棋盘、先手视角;无子可放时返回必败,正是 Bob 赢
易错点
  • L 形必须包含镜像:L 与它的镜像不是旋转关系,只做 4 次旋转会少一半朝向。
  • 双方共用同一堆方块,别把 T、L 分给两个人各一堆。
  • (0, 0) 时先手一步都走不了,答案是 Bob,别把「无子」当成必胜。
  • 记忆化数组用 int8 即可;顺便注意掩码只用到 16 位。

H · 我不吃水果

题意普及

n×n 的 0/1 网格,1 表示该位置有水果。要求切一块不含水果的矩形,且必须包含左上角格子 (1,1)。

求这块矩形的最大面积。n ≤ 1000。

输入:n,接下来 n 行每行 n 个整数(0 无水果,1 有水果)。输出一个整数。

引导性提示

矩形必须含 (1,1),所以它一定是「左上角锚定」的:形状必然是行 1..i、列 1..j。

固定宽度 j 时,最大高度就是前 j 列「从第 1 行开始连续 0 的个数」的最小值。

对每列先求这个连续 0 的长度,再对宽度做一遍前缀最小值,一次扫描收工。

思路(先自己推,再看这里)

对每列 c 找自上而下第一颗水果的行号 first[c](没有就记 n+1),那么这一列从第 1 行起连续 0 的个数是 h_c = first[c] − 1。

宽度为 j 的锚定矩形,高度只能取 min(h_1..h_j):只要有一列在那行是 1 就不行,而这么取又显然可行。所以答案 = max_j j · min(h_1..h_j)。

前缀 min 单调不增,扫描一次即可。总复杂度 O(n²) 读入 + O(n),空间 O(n)。

注意这不是「最大全 0 子矩形」的单调栈题:锚定左上角后,宽和高的约束是「一起向右、向下延伸」,直接把二维压成一维前缀最值。

题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n;
    if (scanf("%d", &n) != 1) return 0;
    vector<int> first(n + 1, n + 1);      // 第 c 列自上而下第一颗水果的行号
    for (int r = 1; r <= n; r++)
        for (int c = 1; c <= n; c++) {
            int x;
            scanf("%d", &x);
            if (x == 1 && first[c] == n + 1) first[c] = r;
        }
    int ans = 0, h = n;                   // h = 当前宽度下能取的最大高度
    for (int c = 1; c <= n; c++) {
        h = min(h, first[c] - 1);         // 宽度扩到 c,高度只能取所有列的最小值
        ans = max(ans, c * h);
    }
    printf("%d\n", ans);
    return 0;
}
代码拆解
片段作用
if (x == 1 && first[c] == n + 1) first[c] = r;只记每列第一颗水果,后面的水果对锚定矩形没有额外影响
int ans = 0, h = n;h 从 n 开始,等价于「还没遇到水果」
h = min(h, first[c] - 1);宽度扩到第 c 列,高度只能取所有列连续 0 长度的最小值
ans = max(ans, c * h);宽 c、高 h 的锚定矩形面积
first[c] == n + 1整列全 0 时 h_c = n,正好是满高
易错点
  • 矩形必须包含 (1,1),不是求整张图的最大全 0 子矩形(那要用单调栈,答案会偏大)。
  • (1,1) 本身是 1 时答案为 0。
  • n = 1000 时输入 10⁶ 个整数,cin 不解绑同步会明显慢;用 scanf 或手写快读。

J · 逃出生天

题意普及

网格有 n+2 行 m 列。第 2..n+1 行各有一只石像:第 i 只在第 i+1 行的第 c_i 列,朝向 d_i ∈ {L, R}。

石像朝正前方一直发射激光到边界,被照到的格子与石像所在格都不能进入。第 1 行与第 n+2 行没有石像。

小猪从 (1, m) 出发,每步可上下左右移动,求能否到达 (n+2, 1)。n, m ≤ 1000,输出 YES / NO。

引导性提示

石像不会动,所以激光是静态的:第 i 行(第 i+1 行)朝 L 就封住 1..c_i,朝 R 就封住 c_i..m(含石像格)。

把封锁格先标出来,剩下就是一张普通网格上的可达性判断。

别忘了总行数是 n+2:第 1 行和第 n+2 行是干净的上下两条通道。

思路(先自己推,再看这里)

预处理:把 n+2 行 m 列拉平成一维下标,按每只石像把对应区间标为不可走(区间含石像所在列)。

从 (1, m) 做四连通 BFS,出界或撞封锁格就跳过;能弹出 (n+2, 1) 就输出 YES。

复杂度 O(nm) ≤ 10⁶,空间 O(nm)。

因为激光与石像都不随时间变化,不存在「时间」维度,不需要建分层图或做时序 BFS。

题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, m;
    if (scanf("%d %d", &n, &m) != 2) return 0;
    int R = n + 2, W = m + 1;
    vector<char> block((size_t)(R + 1) * W, 0), vis((size_t)(R + 1) * W, 0);
    auto id = [&](int r, int c) { return (size_t)r * W + c; };
    for (int i = 1; i <= n; i++) {                 // 第 i 只石像在第 i+1 行
        int c; char d;
        scanf("%d %c", &c, &d);
        int r = i + 1;
        if (d == 'L') { for (int j = 1; j <= c; j++) block[id(r, j)] = 1; }
        else          { for (int j = c; j <= m; j++) block[id(r, j)] = 1; }
    }
    vector<int> q;
    q.reserve((size_t)R * m);
    if (!block[id(1, m)]) { vis[id(1, m)] = 1; q.push_back((int)id(1, m)); }
    const int dr[4] = {-1, 1, 0, 0}, dc[4] = {0, 0, -1, 1};
    size_t head = 0;
    bool ok = false;
    while (head < q.size()) {
        int cur = q[head++];
        int r = cur / W, c = cur % W;
        if (r == R && c == 1) { ok = true; break; }
        for (int k = 0; k < 4; k++) {
            int nr = r + dr[k], nc = c + dc[k];
            if (nr < 1 || nr > R || nc < 1 || nc > m) continue;
            size_t ni = id(nr, nc);
            if (block[ni] || vis[ni]) continue;
            vis[ni] = 1;
            q.push_back((int)ni);
        }
    }
    printf("%s\n", ok ? "YES" : "NO");
    return 0;
}
代码拆解
片段作用
if (d == 'L') { for (int j = 1; j <= c; j++) block[id(r, j)] = 1; }朝左:从边界一路照到石像格,含石像格本身
else { for (int j = c; j <= m; j++) block[id(r, j)] = 1; }朝右:石像格到右边界
int R = n + 2, W = m + 1;行数比 n 多 2;W = m+1 让一维下标从 1 开始,省掉越界换算
if (!block[id(1, m)]) { ... }起点在第 1 行,一定没被封锁,但仍先判一次,逻辑集中在一处
if (r == R && c == 1) { ok = true; break; }到达左下角出口 (n+2, 1)
if (nr < 1 || nr > R || nc < 1 || nc > m) continue;四连通越界直接跳过
易错点
  • 行数是 n+2,读到的是 n 只石像,别把数组开成 n 行。
  • 激光区间要包含石像自己那一格(题面:不能与石像处在同一格子)。
  • 起点 (1, m) 与终点 (n+2, 1) 都在没有石像的行上,但仍要用同一套封锁表判断。
  • 输出大小写不敏感,但必须只输出 YES 或 NO。

K · 最不上升也不下降序列

题意普及+

构造一个 1..n 的排列,使它的 LIS 长度与 LDS 长度之和最小。n ≤ 2000,输出任意一组可行解。

样例:n = 1 输出 1;n = 3 输出 1 3 2(LIS = 2、LDS = 2,和为 4);n = 7 输出 1 4 3 2 7 6 5(和为 6)。

引导性提示

Erdős–Szekeres:任意长度为 n 的排列都满足 LIS·LDS ≥ n,所以和有一个与 √n 同阶的下界。

把排列切成若干块:块内降序、块间升序,则 LIS = 块数,LDS = 最大块长。

于是只需最小化「块数 + ⌈n/块数⌉」,n ≤ 2000 直接枚举块数。

思路(先自己推,再看这里)

构造:把 1..n 切成 B 个连续值段,段内降序输出,段间按值递增(先输出小的值段)。设第 b 段长为 s_b。

LIS = B:段内递减,递增子序列每段至多取一个;取每段最后一个元素(各段最小值)恰好递增,所以正好 B。

LDS = max s_b:段内递减给出下界;而一段里的数全都小于后面所有段的数,递减子序列不可能跨段,所以相等。

令各段长度尽量均匀(前 n mod B 段多 1),则 max s_b = ⌈n/B⌉,总和 B + ⌈n/B⌉。

下界:任意排列若 LIS = a、LDS = b,则 ab ≥ n,故 a + b ≥ min_B (B + ⌈n/B⌉)。我们枚举 B 取到这个下界,所以最优。

复杂度 O(n)(枚举 B 是 O(n),构造也是 O(n))。

题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n;
    if (scanf("%d", &n) != 1) return 0;
    int bestB = 1, best = INT_MAX;
    for (int B = 1; B <= n; B++) {          // 分成 B 块:块内降序、块间升序 -> LIS=B, LDS=最大块长
        int S = (n + B - 1) / B;
        if (B + S < best) { best = B + S; bestB = B; }
    }
    int q = n / bestB, r = n % bestB, cur = 0;
    for (int b = 0; b < bestB; b++) {
        int s = (b < r) ? q + 1 : q;         // 各块长度尽量均匀
        for (int t = s; t >= 1; t--) printf("%d ", cur + t);   // 块内从大到小
        cur += s;
    }
    printf("\n");
    return 0;
}
代码拆解
片段作用
int S = (n + B - 1) / B;B 块时最大块长不可能小于 ⌈n/B⌉(鸽巢)
if (B + S < best) { best = B + S; bestB = B; }枚举块数,最小化 LIS + LDS = B + 最大块长
int q = n / bestB, r = n % bestB;把 n 均分:r 块长 q+1,其余长 q
for (int t = s; t >= 1; t--) printf("%d ", cur + t);块内从大到小输出,保证块内递减
cur += s;值域按段递增推进,保证任意前一段的数都小于后一段
if (B + S < best)严格小于:B 和 n/B 对称,取到最优即可,不必纠结选哪一个
易错点
  • 值段必须按增序输出(先输出小值段),否则 LIS 会退化成 1,和不是最优。
  • 每段都要非空,B 取 1..n;B > n 无意义。
  • n = 1 时 B = 1,输出 1,和为 2,不需要特判。
  • 题目只要「和最小」,任意一组最优解都可,别去凑样例的具体排列。

M · 字符消消乐

题意普及-

字符串长度 n,至多进行 k 次消除。每次可以消掉一段「相邻且相同、长度至少 3」的子串,消掉后两边拼接。

游戏开始前允许把字符串任意重排。求至多 k 次消除后能得到的最短长度。n, k ≤ 10⁵。

样例:"aaccbbbba"(n=9, k=3)可以排成 "aaabbbbcc",消 "bbbb"、消 "aaa",剩 "cc",答案 2。

引导性提示

既然能任意重排,就把同种字符全部堆到一起:每种字符只剩一段。

一段长度 ≥ 3 的字符,一次操作就能整段消掉(题面允许选任意长度 ≥ 3 的连续相同子串)。

于是每次操作最多「消灭一种字符」,应该优先消灭数量最多的那些。

思路(先自己推,再看这里)

重排后第 i 种字符形成长度 c_i 的一个连续段。c_i < 3 的段消不掉;c_i ≥ 3 的段花 1 次操作就能整段清空。

不同字符的段互不干扰:清掉某段不会让别的段合并(同种字符已全部集中在同一段里),也不会产生新的可消段。所以每种字符的花费与收益是独立的:花 1 次操作,收益 c_i(当 c_i ≥ 3)。

k 次操作最大化总收益 → 取数量最大的 k 种(且数量 ≥ 3)字符,答案 = n − 这些数量之和。

复杂度 O(n + 26 log 26),即 O(n)。注意不是 c mod 3 那套:长度 5 的段也是 1 次清空,样例 1 的 "bbbb" 就是 1 次清掉 4 个。

题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, k;
    char s[100005];
    if (scanf("%d %d %s", &n, &k, s) != 3) return 0;
    int cnt[26] = {0};
    for (int i = 0; i < n; i++) cnt[s[i] - 'a']++;
    sort(cnt, cnt + 26, greater<int>());
    int rem = n;
    for (int i = 0; i < 26 && k > 0; i++)   // 一次操作能吃光一整个长度 >= 3 的段
        if (cnt[i] >= 3) { rem -= cnt[i]; k--; }
    printf("%d\n", rem);
    return 0;
}
代码拆解
片段作用
for (int i = 0; i < n; i++) cnt[s[i] - 'a']++;统计每种字符的出现次数(重排后就是段长)
sort(cnt, cnt + 26, greater<int>());按段长从大到小贪心
if (cnt[i] >= 3) { rem -= cnt[i]; k--; }长度 ≥ 3 才能消;一次操作整段消失
for (int i = 0; i < 26 && k > 0; i++)k 用完就停;不足 3 的段永远消不掉
int rem = n;从全长开始扣,最后直接输出剩余长度
易错点
  • 一次操作消掉的是「任意长度 ≥ 3 的连续相同子串」,所以长度 4、5 的段都是 1 次清空,不要写成 c % 3。
  • 数量小于 3 的字符不能消,别把它算进收益。
  • k 可能大于「可消字符的种类数」,循环要能提前结束。
  • 样例 1 里 a 只有 3 个、b 有 4 个、c 有 2 个,数错就会以为答案不同。

N · 缩花

本场的压轴:难点在读清「菊花图」的定义,以及找到「分叉点每次最多少一个」这个不变量。找到后代码只有十几行。

题意提高+

给定 n 个点的树。一次操作:先指定一个点 r 作为根,再选一个非根点 u,把 u 的所有直接儿子到 u 的边断开、改接到 u 的父亲上(u 自己仍连在父亲上)。

目标是变成菊花图。题面把菊花图定义为「不存在任何长度大于 3 的路径」,即不允许 4 个点的路径,等价于直径 ≤ 2,也就是星形。

求最少操作次数;若至少需要一次操作,还要输出最优方案的第一次操作的 r 和 u(多解任意)。

t ≤ 10⁴ 组,每组 n ≤ 10⁵,Σn ≤ 10⁵。

引导性提示

先把「菊花图」翻译清楚:没有 4 个点的路径 ⟺ 直径 ≤ 2 ⟺ 存在一个点与所有点相邻,也就是星。

只盯一个量:度数 ≥ 2 的「分叉点」个数 D。星(n ≥ 3)只有一个分叉点。

一次操作里,u 必然从分叉点变成叶子;它的父亲如果本来是叶子,就会变成分叉点。所以 D 每次最多减 1。

思路(先自己推,再看这里)

下界:设 I = {v : deg(v) ≥ 2},D = |I|。操作选的 u 必须有儿子,故 u ∈ I;操作后 u 的度数变成 1(离开 I),而 u 的父亲度数增加,最多新增 1 个分叉点。所以 |I| 每次至多减少 1;终态星形(n ≥ 3)|I| = 1,故至少 D − 1 次。

上界(构造):取 c ∈ I 作花心,把 I \ {c} 里的点按到 c 的距离从小到大依次操作,每次 r = c。轮到 u 时它的父亲已经是 c(路径中间的点的度数 ≥ 2,都在 I 里且已处理完),于是 u 的所有儿子直接挂到 c。全部做完后每个点都挨着 c,得到星,恰好 D − 1 次。

所以答案是 max(0, D − 1)。第一次操作取任意两个不同的分叉点 r、u 即可:r ∈ I 保证 u 的父节点也在 I 里,这一步才会真正让 D 减 1,后续照上面的顺序做完即可。

复杂度 O(n):只要度数,连图都不用真正建出来。

题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
    int t;
    if (scanf("%d", &t) != 1) return 0;
    while (t--) {
        int n;
        if (scanf("%d", &n) != 1) return 0;
        vector<int> deg(n + 1, 0);
        for (int i = 0; i < n - 1; i++) {
            int u, v;
            scanf("%d %d", &u, &v);
            deg[u]++; deg[v]++;
        }
        vector<int> inner;                       // 度数 >= 2 的点:已有分枝,都是操作对象
        for (int v = 1; v <= n; v++) if (deg[v] >= 2) inner.push_back(v);
        if ((int)inner.size() <= 1) { printf("0\n"); continue; }
        printf("%d\n%d %d\n", (int)inner.size() - 1, inner[0], inner[1]);
    }
    return 0;
}
代码拆解
片段作用
for (int v = 1; v <= n; v++) if (deg[v] >= 2) inner.push_back(v);分叉点集合 I:只有它们需要被「抬走儿子」
if ((int)inner.size() <= 1) { printf("0\n"); continue; }已经是星(或点数 ≤ 2)就不用操作
printf("%d\n%d %d\n", (int)inner.size() - 1, inner[0], inner[1]);答案 D−1;任取两个分叉点当第一次的 r、u
deg[u]++; deg[v]++;只需要度数,不需要建邻接表
while (t--)多测,Σn ≤ 10⁵,每组 O(n)
易错点
  • 「菊花图」按题面是「没有 4 个点的路径」,等价于星形;不要理解成「直径 ≤ 3」(那样样例 3 会答 0,与样例矛盾)。
  • n ≤ 2 时答案是 0,不能输出 D−1 = −1。
  • 输出的 r、u 必须不同,且 u 在以 r 为根时不是根;选两个分叉点天然满足,u 一定有儿子。
  • 多测不清空、或者把 Σn 当成单组 n 都会出事。