适用语言:C++17/20,Python 3.12。
感谢GPT5.5和奥特曼,让我能用拙略的语言编写出它。
前言
一场程序设计竞赛像一季结构紧凑的番剧。第一集给设定,第二集埋冲突,中段展开规则,最后把所有伏笔收束到一个可以判定的结局。题面里的城市、魔法、比赛、摊位、打字机、电话网络,都只是叙事外壳;真正决定 AC 的,是你能否从这些外壳中取出变量、状态、转移、约束和证明。
本书不把算法写成咒语表。模板当然重要,但模板只负责“会写”;比赛要求的是“知道什么时候写、为什么这样写、边界怎么守住”。因此每个重要专题都按同一条链条展开:
题面信号 -> 模型选择 -> 状态或数据结构 -> 正确性理由 -> 复杂度 -> 代码落点 -> 自测
如果只记住“这题是 DP”,那还没有开始解题。真正的解题从下一句开始:为什么这个问题的未来只需要这些状态?为什么少一个维度会错?为什么这个贪心不会把后路砍掉?为什么这个图的点不是原来的点,而是事件、坐标或集合?
本书的目标不是把读者训练成背板选手,也不是许诺 AK。GDCPC 和大多数 ACM 赛制一样,都非常难全题通过,除非是小学学算法学到大学、题面扫一眼就能把模型翻出来的异常个体,否则“几小时下班”更像段子,不是训练目标。本书只讨论两件事:巩固已有能力,发展下一层能力。看到长题面不慌,看到 special judge 不虚,看到小参数能立刻闻到状态压缩的味道,看到字符串前后缀能想到 Border,看到“最早发生”能想到优先队列事件图。所谓速通,不是跳过理解,也不是幻想清空整张卷,而是学完能下场做题,不至于一题都拆不开;它追求的是把理解压缩成稳定动作。
ACM 赛制是冷酷的。题目不是按步骤给分,对就是对,错一个条件就是全错;样例通过不代表证明成立,思路接近也不会折算成分数。因此本书会反复强调 checker、对拍、自测和边界。它们不是附属环节,而是把“我觉得对”推进到“可以提交”的最后一道门。
当题面开始叽里咕噜,第一反应不要是翻模板。先把呼吸压下来,像芝麻凛一个人扎营那样,把地钉一根一根打进土里:变量是什么,操作改变了什么,目标函数在比较什么,约束里哪个数小到可以枚举,哪个数大到必须预处理。你只要按这个顺序走,混乱就会退后一步。题面仍然长,但它不再是一团雾,而是一条可以走的山路。
本书分三条线。学习线负责建立算法骨架:二分、贪心、DP、图论、字符串、数论、几何、剪枝、对拍、自测。回顾线负责把 2021-2025 的 GDCPC 题目拆成可记忆的模型,不把题目当答案索引,而把它们当训练样本。展望线负责组织三套模拟卷和近年命题思路,让备考不是盲刷,而是有节奏地补缺口。
GDCPC 是本书的主样本,不是本书的知识边界。竞赛方法可以从 ACM/CPC 拓展到 OI 和 OJ:ACM 强调限时协作、一次提交必须完全正确;OI 更强调部分分、子任务和长时间推敲;OJ 日常训练则强调可重复提交和长期积累。本书以 ACM 的严格正确性为底线,同时让算法知识能迁移到 OI/OJ 的日常训练中。
技术内容以 C++17/20 与 Python 3.12 为目标。难题讲到赛题边界为止,不把一本 GDCPC 手册扩写成算法百科;简单题会适当外扩,因为签到题是手感的来源。手感不稳的人上了赛场,很容易从天界优等生一路堕落成珈百璃,开场二十分钟就烂在样例前。
写算法手册不必把每一页都写成实验报告。严谨是骨架,阅读感是呼吸;偶尔有一句旁白拐到奇怪的地方,只要不妨碍定义、证明、复杂度和代码,它反而能让人从紧绷里松一口气。
开卷:读题的秩序
比赛中真正危险的不是不会,而是混乱。不会的题可以暂时放下,混乱会污染所有题。遇到长题面,按下面六步读。
第一步,删背景。城市、餐厅、魔法学院、端午大集、打字机,这些词通常不直接进入代码。它们负责让题面像故事,不负责让程序运行。
第二步,圈对象。对象可能是点、边、字符串、提交、区间、格子、事件、货币、牌堆。对象决定数组维度,也决定你是否需要建图。
第三步,列操作。操作是“翻转一行”“选择一个提交结果”“沿边传能”“剪开多边形”“把一个位置改成某字符”。算法建模几乎都从操作开始,因为状态转移本质上就是操作的影子。
第四步,写目标。最大值、最小值、计数、期望、任意合法方案,这五类目标对应完全不同的证明方式。最大最小常见二分、DP、贪心;计数常见组合、DP、容斥;期望要找状态或尾和;任意合法方案必须写 checker。
第五步,标约束。n<=15、m<=4、字符串总长 3e6、询问 1e5、坐标 1e9,这些数字比题目标题更诚实。小参数是状态压缩的邀请函,大输入是预处理和离线的警报器。
第六步,落代码。写出第一层循环之前,必须知道状态含义和循环方向。DP 的循环方向错了,就像把登山路线反着走;Tarjan 的父边处理错了,重边会把桥判歪;构造题不写 checker,样例再好看也只是没有旁证的自信。
速通的正名
“速通”不是把所有知识压成三页纸,也不是把比赛想象成一路无伤的剧情。算法学习里的速通,意思是先把足够多的基本动作训练到能用:读到 n<=15 时知道可以想状态压缩,读到“任意合法方案”时知道要写不变量和 checker,读到“最早到达/最早触发”时知道要考虑优先队列,读到“本质不同”时知道要问等价类和去重。它像凛酱一个人露营前收拾装备,不需要把整个户外用品店搬走,但火源、帐篷、灯和路线必须在包里。
速通后的最低标准不是 AK,而是下场不失语。你应当能在开场二十分钟内给每道题写一个入口词:模拟、二分、背包、状态压缩、构造、桥、Trie、几何、期望,或者暂时未知。入口词不等于题解,却能阻止大脑在题面里空转。之后再按风险排序:能写的先写,能对拍的对拍,special judge 先写 checker,难题先留下模型句。所谓“学完能去做题”,说的正是这一层能力。
第一小时
第一小时决定整场比赛的精神秩序。开场读题时不要急着打开 IDE 写第一道看起来像签到的题。先读全题,给每题写规模、目标、入口词和风险。规模负责告诉你复杂度边界,目标负责告诉你证明类型,入口词负责告诉你从哪个算法门进去,风险负责告诉你是否容易 WA。一个队伍或一个人,只要第一小时没有乱,后面即使遇到压轴,也还有退路。
如果一题读起来很像剧情简介,先问它的对象是什么。对象可以是人、队伍、题目、点、格子、字符串、区间,也可以是一次事件。再问操作改变了什么。翻转、提交、移动、合并、选择、刷新、传能,都必须能落成变量变化。最后问目标比较什么。最大收益、最小时间、方案数、期望值、任意合法输出,对应的是完全不同的程序结构。这个过程朴素得像太平收拾小埋留下的零食袋,但没有它,房间迟早会乱到无从下脚。
第一小时还要接受一个现实:有些题暂时不会做。不会做不是失败,误判才是失败。压轴题读不懂,可以留下“疑似期望 + 组合概率”或“疑似离线图 + 桥”这样的入口句;中档题能写,就先把 AC 拿稳。ACM 赛制不会给半对的代码发温柔奖,OI/OJ 训练也不会因为你“感觉很接近”自动补上边界。让代码从接近正确走到真正正确,靠的是证明、对拍和自测。
1. 真题考点地图
地图不是题单。题单告诉你有多少题,地图告诉你在雾里先往哪里走。读 GDCPC 的年份变化,最重要的不是背“某年出了某算法”,而是看命题人怎样把同一批能力换壳:有时把数学定义压成可计算函数,有时把字符串相等关系藏进故事,有时让图的点不再是点,而是事件、状态、区间或组件。
1.1 年份的气质
2021 年偏重数学定义的压缩。二分、数论表示、数位计数、博弈、图最短路、区间数据结构和几何题共同指向一件事:题面先给定义,选手再把定义改写成 count、check、transition 或 query。这一年的训练价值在于让人学会“不被定义吓住”。定义如果能比较,就可能二分;如果能递推,就可能预处理、DP 或矩阵快速幂;如果它描述局面胜负,就进入博弈。
2022 年强调等价、字符串和递推。拼图的旋转翻转、字符串的公共前后缀、连通块压缩、矩阵快速幂,都在问同一个问题:哪些对象虽然外表不同,却应当在代码里合并?Burnside 不必一开始学成群论讲义,但要懂“本质相同”如何变成“不变方案数”;Trie、KMP 和反串 Trie 也不是工具箱摆件,而是把相等关系变成路径和数组。
2023 年适合训练长题面的剥离。基站、市场、旅游、经典问题和计算几何看似故事差异很大,真正落入代码后往往很短:区间约束、交易状态、操作顺序、特殊完全图 MST、凸多边形直径。读这一年要练一种本领:背景越热闹,模型句越要短。题面可以像京子一样把场子搅起来,解题的人要像结衣一样把它按回秩序里。
2024 年的关键词是结构。Border、DFS 序、电话 Trie、桥、差分约束、倍数图,这些题的难点不是写很多代码,而是认出结构。结构题的残酷之处在于:认出来以前,题面像墙;认出来以后,墙上其实有门。它要求你知道每个结构回答什么问题。KMP 回答前后缀相等,Trie 回答前缀包含,Tarjan 桥回答删边连通性,差分约束回答一组不等式是否存在势能。
2025 年是综合建模样本。矩阵翻转、位运算构造、排列维护、数位贪心、网格构造、背包、子序列计数、事件图、字符串周期和窄网格 DP 同时出现。它最像一场正式训练:开头要稳住实现,中段要吃下 DP 和构造,后段要处理图事件、周期和小宽度状态。列数小、题数小、网格宽度小都不是装饰,而是命题人放在门口的钥匙。
ZJ2026 的题面更接近展望样本。图上构造、区间桥、期望、区间计数、交互、前缀修改、动态矩阵、隐式图 BFS、LIS/LDS 偏序,显示出更强的离线、随机过程和隐式状态意识。它不要求本书把所有高级算法铺开,但提醒读者:省赛训练不能只停在模板层,至少要能判断一题的入口是期望、离线图、搜索剪枝还是偏序 DP。
1.2 能力层级
第一层是比赛生存能力。快速 IO、排序、二分、前缀和、哈希表、取模、long long,这些内容不够华丽,却决定你会不会在简单题上流血。BFS、Dijkstra、并查集、Fenwick 和线段树也是同一层:它们是图和动态查询的基本骨架。没有这一层,后面的 DP、字符串和构造会像还没调音就上台的乐队,声音再大也不稳。
第二层是中档题能力。前缀 DP、背包 DP、状态压缩、计数去重、KMP/Z 函数、Trie、字符串哈希、Tarjan、组合数、矩阵快速幂和期望,是省赛中最常把人拉开的位置。这一层不能只背模板,因为题目很少把“我是背包”写在脸上。它通常会先讲封榜、货币、打字机、电话网络或魔法,然后在某个小参数、互斥选择、前后缀关系里露出模型。
第三层是后半场发展能力。构造、贪心证明、special judge、计算几何、离线分治、回滚并查集、搜索剪枝和 IDA* 不一定每场都完整用上,却决定你能不能在陌生题前保持判断力。这里的学习边界要清楚:难题按比赛设计讲到可识别、可复盘、可训练即可,不把一本手册写成百科;但 checker、对拍和自测必须深入,因为它们直接关系到 ACM 赛制里“错一个条件就是全错”的现实。
1.3 从题面到入口词
读题时先给每题写入口词。n<=20 通常指向状态压缩或搜索,m<=4 指向轮廓或窄网格,q 很大指向预处理或离线,输出任意合法方案指向构造和 checker,最早触发指向优先队列事件图,前后缀和周期指向 KMP/Z/Trie,本质相同指向等价类计数,凸多边形和最远距离指向叉积与旋转卡壳。
入口词不是答案。它只是让你不在题面里原地打转。真正解题还要继续追问:这个状态为什么够用?这个枚举后剩下部分是否独立?这个贪心有没有交换论证?这个构造的不变量是什么?这个输出如何用 checker 验证?当你能连续回答这些问题,题面就从“看不懂的一整页”变成了一段可以执行的程序。
2. 赛前速通路线
原则:先保证“主线任务”可通关,再挑战隐藏关。这里的速通不是三天练成神,也不是三小时下班;它只负责把已经学过或即将学会的内容整理成可执行顺序,让人至少能读题、拆题、写出可验证的代码。主线任务是基础实现、图论、DP、字符串、构造;隐藏关是离线图、期望、几何、复杂构造。
2.1 三天速通:补出下场基线
第 1 天处理基础与实现稳定性。目标不是“看完很多东西”,而是把比赛中最容易手滑的动作练到自动化:快速 IO、排序、二分、前缀和,随后接上 BFS、Dijkstra、并查集和 Fenwick。字符串只要求先掌握 KMP 前缀函数,能手算 pi 数组,能写出 Border 链。当天题目以 2025 A/D/F/H、2024 G/I、2023 A 为主。做题时每道题只追求一件事:把题面限制落到第一层循环。
第 2 天进入中档模型。背包 DP、状态压缩和字符串去重计数要连在一起学,因为它们都在回答“历史能否被压成少数变量”。构造题检查器和 Tarjan 桥也放在这一天:前者训练输出合法性,后者训练图结构。题目以 2025 B/E/G/J、2024 C/F/H、2023 B/L 为主。不要一边写构造一边祈祷评测机温柔,输出条件要逐条验,像薇奈特在放学后收拾一屋子混乱。
第 3 天补强后半场能力。期望、组合数、矩阵快速幂、计算几何基础、搜索剪枝和小数据对拍都不要求一口气学成大师,但要建立入口意识。看到随机过程能想到尾和或状态方程,看到凸多边形能想到叉积和旋转卡壳,看到搜索能想到上界剪枝和暴力验证。练习题以 2025 C/K/L、2024 A/B/J、2023 M 为主。三天速通的结尾不是“我学完了”,而是“我知道每类题第一步该写什么”。
2.2 两周训练
两周训练不按“今天看几个算法”计算,而按“今天能不能产出可提交代码”计算。前两天只做基础代码板,C++ 和 Python 各写一份,从输入、排序、二分、前缀和、Fenwick、并查集到 Dijkstra。写完后用小样例、随机样例和极端样例跑一遍,模板不是收藏品,必须能编译、能运行、能复用。
第 3 到第 4 天攻图论。BFS 训练状态去重,Dijkstra 训练优先队列和距离松弛,并查集训练连通性,Tarjan 训练 tin/low 和父边编号。至少做 5 道真题或改题,其中必须包含一题重边图和一题隐式图。图论最怕把题面对象原封不动当图点,训练时每道题都要写一句“点是什么,边是什么”。
第 5 到第 6 天攻 DP。前缀 DP、背包、状态压缩、区间 DP 各做一到两题。每题必须写状态含义、初值、转移顺序和答案位置;少写任何一项,都视为没有完成复盘。DP 不是把数组开出来就结束,它像一份旅途存档,存多了浪费,存少了回不了家。
第 7 天做第一次阶段训练,时间压到 3 小时。目标不是做最多题,而是检查前六天的基础是否能在限时下启动。训练结束后立刻复盘第一道 WA、第一道卡住的题和第一道没敢提交的题。很多人平时会,比赛不会,差的正是这一段现场秩序。
第 8 到第 9 天攻字符串。KMP、Z、Trie、哈希、Border 要连在一起学,因为它们都在回答“相等关系如何快速判断”。每做一道字符串题,都要先写相等关系:是前缀等于后缀,还是两个位置与全串前缀相等,还是多串公共前缀,还是任意子串相等。程序不会读心,阿尼亚可以,字符串算法不行。
第 10 天攻数学。组合数、期望、矩阵快速幂和数论观察各做一题。数学题必须先打小表,再写性质,再写公式。公式未经小数据检验,不许直接提交。第 11 天专门做构造和 special judge,至少给一道构造题写 checker;第 12 天补几何和扫描线,保证叉积、平方距离、线段关系和旋转卡壳不手生。
第 13 天做 5 小时完整模拟。开场读全题,中段抢确定性题,后段选一题最有希望的难题推进。第 14 天只做错题归档:把模板、反例、checker、对拍脚本和模型句整理到个人库里。训练的终点不是“我看过”,而是下一次遇到同类题时,手能自己摸到门把手。
3. 基础代码板
3.1 C++17 基础头
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
using i128 = __int128_t;
const ll INF64 = (1LL << 62);
const int INF = 0x3f3f3f3f;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
return 0;
}
3.2 Python 基础头
import sys
from collections import deque, defaultdict
from heapq import heappush, heappop
input = sys.stdin.buffer.readline
INF = 10**30
MOD = 998244353
3.3 Fenwick Tree
C++:
struct Fenwick {
int n;
vector<long long> bit;
Fenwick(int n = 0) { init(n); }
void init(int n_) {
n = n_;
bit.assign(n + 1, 0);
}
void add(int idx, long long val) {
for (; idx <= n; idx += idx & -idx) bit[idx] += val;
}
long long sum(int idx) const {
long long res = 0;
for (; idx > 0; idx -= idx & -idx) res += bit[idx];
return res;
}
long long range_sum(int l, int r) const {
if (l > r) return 0;
return sum(r) - sum(l - 1);
}
int kth(long long k) const {
int idx = 0;
int step = 1;
while ((step << 1) <= n) step <<= 1;
for (; step; step >>= 1) {
int nxt = idx + step;
if (nxt <= n && bit[nxt] < k) {
idx = nxt;
k -= bit[nxt];
}
}
return idx + 1;
}
};
Python:
class Fenwick:
def __init__(self, n):
self.n = n
self.bit = [0] * (n + 1)
def add(self, idx, val):
while idx <= self.n:
self.bit[idx] += val
idx += idx & -idx
def sum(self, idx):
res = 0
while idx > 0:
res += self.bit[idx]
idx -= idx & -idx
return res
def range_sum(self, left, right):
if left > right:
return 0
return self.sum(right) - self.sum(left - 1)
def kth(self, k):
idx = 0
step = 1
while step * 2 <= self.n:
step *= 2
while step:
nxt = idx + step
if nxt <= self.n and self.bit[nxt] < k:
idx = nxt
k -= self.bit[nxt]
step //= 2
return idx + 1
3.4 并查集
C++:
struct DSU {
vector<int> parent, size;
DSU(int n = 0) { init(n); }
void init(int n) {
parent.resize(n + 1);
size.assign(n + 1, 1);
iota(parent.begin(), parent.end(), 0);
}
int find(int x) {
while (x != parent[x]) {
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
bool unite(int a, int b) {
a = find(a);
b = find(b);
if (a == b) return false;
if (size[a] < size[b]) swap(a, b);
parent[b] = a;
size[a] += size[b];
return true;
}
};
Python:
class DSU:
def __init__(self, n):
self.parent = list(range(n + 1))
self.size = [1] * (n + 1)
def find(self, x):
while x != self.parent[x]:
self.parent[x] = self.parent[self.parent[x]]
x = self.parent[x]
return x
def unite(self, a, b):
a = self.find(a)
b = self.find(b)
if a == b:
return False
if self.size[a] < self.size[b]:
a, b = b, a
self.parent[b] = a
self.size[a] += self.size[b]
return True
3.5 Dijkstra
C++:
vector<long long> dijkstra(int n, int s, const vector<vector<pair<int,int>>> &g) {
vector<long long> dist(n + 1, INF64);
priority_queue<pair<ll,int>, vector<pair<ll,int>>, greater<pair<ll,int>>> pq;
dist[s] = 0;
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]) {
long long nd = d + w;
if (nd < dist[v]) {
dist[v] = nd;
pq.push({nd, v});
}
}
}
return dist;
}
Python:
def dijkstra(n, s, graph):
dist = [INF] * (n + 1)
dist[s] = 0
pq = [(0, s)]
while pq:
d, u = heappop(pq)
if d != dist[u]:
continue
for v, w in graph[u]:
nd = d + w
if nd < dist[v]:
dist[v] = nd
heappush(pq, (nd, v))
return dist
3.6 Tarjan 桥
C++:
struct BridgeFinder {
int n, timer;
vector<vector<pair<int,int>>> g;
vector<int> dfn, low, is_bridge;
BridgeFinder(int n, int m) : n(n), timer(0), g(n + 1), dfn(n + 1), low(n + 1), is_bridge(m) {}
void add_edge(int id, int u, int v) {
g[u].push_back({v, id});
g[v].push_back({u, id});
}
void dfs(int u, int pe) {
dfn[u] = low[u] = ++timer;
for (auto [v, id] : g[u]) {
if (id == pe) continue;
if (!dfn[v]) {
dfs(v, id);
low[u] = min(low[u], low[v]);
if (low[v] > dfn[u]) is_bridge[id] = 1;
} else {
low[u] = min(low[u], dfn[v]);
}
}
}
vector<int> solve() {
for (int i = 1; i <= n; ++i) {
if (!dfn[i]) dfs(i, -1);
}
return is_bridge;
}
};
Python:
def find_bridges(n, edges):
graph = [[] for _ in range(n + 1)]
for idx, (u, v) in enumerate(edges):
graph[u].append((v, idx))
graph[v].append((u, idx))
dfn = [0] * (n + 1)
low = [0] * (n + 1)
is_bridge = [False] * len(edges)
timer = 0
def dfs(u, parent_edge):
nonlocal timer
timer += 1
dfn[u] = low[u] = timer
for v, idx in graph[u]:
if idx == parent_edge:
continue
if dfn[v] == 0:
dfs(v, idx)
low[u] = min(low[u], low[v])
if low[v] > dfn[u]:
is_bridge[idx] = True
else:
low[u] = min(low[u], dfn[v])
for i in range(1, n + 1):
if dfn[i] == 0:
dfs(i, -1)
return is_bridge
3.7 KMP 前缀函数
C++:
vector<int> prefix_function(const string &s) {
int n = (int)s.size();
vector<int> pi(n);
for (int i = 1; i < n; ++i) {
int j = pi[i - 1];
while (j > 0 && s[i] != s[j]) j = pi[j - 1];
if (s[i] == s[j]) ++j;
pi[i] = j;
}
return pi;
}
Python:
def prefix_function(s):
n = len(s)
pi = [0] * n
for i in range(1, n):
j = pi[i - 1]
while j and s[i] != s[j]:
j = pi[j - 1]
if s[i] == s[j]:
j += 1
pi[i] = j
return pi
3.8 组合数
C++:
const long long MOD = 998244353;
long long mod_pow(long long a, long long e) {
long long r = 1;
while (e) {
if (e & 1) r = r * a % MOD;
a = a * a % MOD;
e >>= 1;
}
return r;
}
struct Comb {
vector<long long> fact, inv_fact;
Comb(int n) : fact(n + 1), inv_fact(n + 1) {
fact[0] = 1;
for (int i = 1; i <= n; ++i) fact[i] = fact[i - 1] * i % MOD;
inv_fact[n] = mod_pow(fact[n], MOD - 2);
for (int i = n; i >= 1; --i) inv_fact[i - 1] = inv_fact[i] * i % MOD;
}
long long C(int n, int k) const {
if (k < 0 || k > n) return 0;
return fact[n] * inv_fact[k] % MOD * inv_fact[n - k] % MOD;
}
};
Python:
def build_comb(n, mod):
fact = [1] * (n + 1)
for i in range(1, n + 1):
fact[i] = fact[i - 1] * i % mod
inv_fact = [1] * (n + 1)
inv_fact[n] = pow(fact[n], mod - 2, mod)
for i in range(n, 0, -1):
inv_fact[i - 1] = inv_fact[i] * i % mod
def comb(N, K):
if K < 0 or K > N:
return 0
return fact[N] * inv_fact[K] % mod * inv_fact[N - K] % mod
return comb
4. 核心专题讲义
4.1 复杂度判断
复杂度判断是读题后的第一步。GDCPC 很多题会把故事写得很长,但输入规模通常直接暴露算法边界。读题时先找主规模 n,m,q,T,再找是否存在小参数,例如列数、颜色数、题数、bit 位数、网格宽度,最后确认多组数据下是否有 sum n、sum m 这样的总和限制。主规模决定不能做什么,小参数决定可以枚举什么,总和限制决定多测能否总线性处理。
| 规模 | 可用复杂度 | 首选模型 |
|---|---|---|
n <= 20 |
O(2^n n) |
状态压缩、搜索 |
n <= 100 |
O(n^3) |
区间 DP、Floyd、组合 DP |
n <= 5000 |
O(n^2) |
二维 DP、枚举优化 |
n <= 2e5 |
O(n log n) |
排序、树状数组、线段树、Dijkstra |
n <= 1e6 |
O(n) |
前缀和、KMP、单调栈、双指针 |
q >= 1e6 |
O(1) 或 O(log n) |
预处理、离线、二分 |
判断时按固定顺序走:先找最大输入规模,再找小参数,然后看多测是否有总和限制;若题目是 special judge,要立刻准备 checker;若坐标、乘积、方案数或路径代价可能很大,要在写数组前决定使用 long long 还是 __int128。这个顺序看似朴素,却能避免大量赛场事故。
常见误判有四种。T 很大但 sum n 有限制时,总线性算法仍可通过;单个 q 很大时,每问 O(n) 通常直接死亡,必须预处理或离线;存在小参数时,不要按主规模暴力,应优先压小参数;值域很大但实际数量很少时,应使用哈希、排序和离散化,而不是开值域数组。
真题中的规模信号很直接。2025 A 的列数小,所以枚举列翻转后让行独立求最优;2025 F 的题数 n<=15,所以每题生成选项组再合并;2025 L 的网格宽度 m<=4,这是轮廓 DP 或窄图建模的邀请;2024 J 的 n 极大,说明不能建图,只能找数论结构。
规模判断错了,后面所有努力都会变形。最常见的惨案不是算法完全不会,而是把 O(n^2) 写进 2e5,把每问线性写进百万询问,把值域数组开到 1e9。那种瞬间很像第一次站上大舞台却忘了接音箱,手指已经动了,声音没有出来,评测机只会冷静地给出 TLE 或 MLE。
4.2 二分与答案单调性
二分的适用条件只有一个核心:存在单调的判断函数 check(x)。题目可以问最小成本、最大阈值、第 k 大或最短时间,但代码里都必须落成一句话:当答案候选为 x 时,是否可行。若可行集合是一段连续区间,二分才成立;若可行性忽真忽假,二分代码写得再漂亮也只是萨塔妮亚式邪恶计划,气势很足,落点很歪。
C++ 最小可行值:
long long binary_first_true(long long lo, long long hi) {
while (lo < hi) {
long long mid = lo + (hi - lo) / 2;
if (check(mid)) hi = mid;
else lo = mid + 1;
}
return lo;
}
Python:
def binary_first_true(lo, hi):
while lo < hi:
mid = (lo + hi) // 2
if check(mid):
hi = mid
else:
lo = mid + 1
return lo
二分题的关键不在二分代码,而在 check。
check(x) 必须返回清晰的布尔含义,并且可行集合连续。单次检查的复杂度要足够低,否则二分只会把超时包装得更有层次。边界值也要提前处理,尤其是乘法、平方、坐标差和方案计数。
常见模型:
| 模型 | x 含义 |
check(x) |
|---|---|---|
| 最小最大值 | 最大代价上限 | 能否在限制内完成 |
| 第 k 大/小 | 候选答案 | 统计不小于/不大于 x 的个数 |
| 最小资源 | 资源数量 | 是否满足所有需求 |
| 时间最短 | 时间上限 | 是否能到达或完成 |
第 k 大计数框架:
def kth_largest(lo, hi, k):
while lo < hi:
mid = (lo + hi + 1) // 2
if count_ge(mid) >= k:
lo = mid
else:
hi = mid - 1
return lo
训练二分时不要只背模板。整数二分要区分求最小可行值还是最大可行值,因此 mid=(lo+hi)//2 与 mid=(lo+hi+1)//2 的方向不同;实数二分要控制迭代次数或误差。每做一道二分题,都要先写出单调性证明,再写代码。证明只有一句也可以,但必须存在。
4.3 贪心与构造
构造题必须同时交代构造过程、不变量、无解判定和输出合法性检查。不变量是构造过程中始终为真的条件,例如前缀和集合中不存在相差 s 的两项,当前图中路径边不相交,当前染色不改变固定格,当前排列局部关系满足目标条件。没有不变量的构造只是猜答案,猜对样例不代表能过评测。
构造题不是“凭感觉召唤答案”,而是给系统写一套不会崩的设定集。每一步操作必须维护设定,最后输出才有合法性。若题目允许任意合法输出,checker 就应该像放学后默默收拾残局的人,把输出数量、值域、固定条件和目标条件逐条验过去;它不评价你的构造美不美,只确认它有没有说谎。
前缀和构造检查:
def valid_no_subarray_sum(seq, s):
seen = {0}
pref = 0
for x in seq:
pref += x
if pref - s in seen:
return False
seen.add(pref)
return True
构造题的解法不是“猜输出”,而是“设计一个不会破坏条件的生成过程”。
标准证明模板:
定义不变量 I。
初始时 I 成立。
每一步操作后 I 仍成立。
循环结束时,由 I 推出答案满足题目要求。
若存在无解分支,证明任何方案都会违反必要条件。
构造题常从五个入口进入。子段和条件通常转成前缀和差,例如禁止子段和为 s,等价于禁止两个前缀和相差 s。网格局部连通只看上下左右,大网格经常能压成每列状态。图路径的不相交通常与桥、边双或流有关。位运算构造要看整体位模式,不能逐位乱造,因为表达式会对所有位同时生效。操作顺序题则要通过交换论证确定哪些操作可以提前,哪些操作必须靠后;如果操作之间有依赖,先找拓扑序、贪心优先级或交换论证。
输出合法性 checker:
def check_fixed_cells(original, answer):
n = len(original)
m = len(original[0])
for i in range(n):
for j in range(m):
if original[i][j] != 0 and original[i][j] != answer[i][j]:
return False
return True
真题中,2025 B 是位运算构造,核心是按 bit 模式分类;2025 E 是网格染色,核心是减少连通块且不改固定格;2024 F 是边不相交路径,需要先理解桥和边双;2023 H/J 是典型 special judge,输出合法性比样例一致更重要。训练构造题时,每题至少写一个 checker,不只验证样例,并且必须能用一句话说出不变量。
4.4 动态规划
DP 的核心不是数组,而是“遗忘的艺术”。状态保存已经处理过的部分中仍会影响未来的信息;初值描述空前缀、空集合或起点;转移说明如何从旧状态走向新状态;顺序保证转移来源已经计算;答案则从某个终点状态或一组合法状态中取出。少存一维,未来会找不到路;多存十维,内存会先替你宣布旅行结束。真正好的 DP 状态像末日旅途里的地图,纸面不大,却足够指明下一段路。
前缀 DP
不同子序列计数的标准去重思想:
new = 1 + total_before
add = new - last[char]
total += add
last[char] = new
带距离限制时,把 total_before 换成允许连接的最远前缀。
Python:
def count_distinct_loose_subseq(s, k, mod=998244353):
n = len(s)
total = [0] * (n + 1)
last = [0] * 26
for i, ch in enumerate(s, 1):
prev = i - k - 1
base = 1
if prev >= 0:
base = (base + total[prev]) % mod
c = ord(ch) - 97
add = (base - last[c]) % mod
total[i] = (total[i - 1] + add) % mod
last[c] = base
return total[n]
为什么要用 last[char]:
同一个字符作为结尾时,新出现的位置会覆盖旧位置能形成的同名序列。last[c] 保存上一次字符 c 已经贡献过的“新增来源”。再次遇到 c 时必须减掉,否则同一个字符序列会被重复计算。
这类 DP 的边界要提前定死。空序列是否计入答案会改变初值;取模减法必须归正;带距离限制时,能连接的前缀不是 i-1,而是 i-k-1。2025 H 松散子序列正是这一模型的代表:前缀 DP 负责累计,last 负责去重,距离限制负责把可接前缀向左推。2025 G 货币系统虽然外表不像子序列计数,但训练价值相似:把查询压成可预处理的前缀函数,而不是每次从头模拟。
背包 DP
多重选择背包:每组物品选一个或不选。
def multi_choice_min_cost(n, groups, start_state):
INF = 10**30
dp = [[INF] * (n + 1) for _ in range(n + 1)]
solved, post, cost = start_state
dp[solved][post] = cost
for opts in groups:
ndp = [row[:] for row in dp]
for i in range(n + 1):
for j in range(n + 1):
cur = dp[i][j]
if cur == INF:
continue
for di, dj, w in opts:
ni, nj = i + di, j + dj
if ni <= n and nj <= n:
ndp[ni][nj] = min(ndp[ni][nj], cur + w)
dp = ndp
return dp
多重选择背包适用于“每个对象产生若干互斥选项”的题。
建模时先把每个对象拆成选项组,再把每个选项写成状态增量和代价。每组只合并一次,答案在所有合法状态中取最优。2025 F 排名预测中,每道题的选项是“不通过”或“某次封榜提交作为首次 AC”。状态保存已通过题数、封榜后通过题数和最小罚时,最后扫描排名严格高于己方的状态,取最少封榜后通过题数。错误边界集中在罚时规则:已 AC 题的后续提交不影响罚时,同一分钟提交按输入顺序处理,“过几题”统计的是题数,不是 AC 提交次数。
状态压缩 DP
状态 mask 表示集合;转移添加一个未选元素。
def shortest_visit_all(cost):
n = len(cost)
INF = 10**30
dp = [[INF] * n for _ in range(1 << n)]
dp[1][0] = 0
for mask in range(1 << n):
for u in range(n):
cur = dp[mask][u]
if cur == INF:
continue
for v in range(n):
if (mask >> v) & 1:
continue
nxt = mask | (1 << v)
dp[nxt][v] = min(dp[nxt][v], cur + cost[u][v])
return min(dp[-1])
状态压缩题的入口是小参数。
mask 可以表示选中集合、翻转状态、轮廓连通状态,也可以和 last 一起表示“已经访问的集合与当前末尾”。2025 A 矩阵游戏中,mask 表示列翻转;固定列后,每一行只需比较翻或不翻,总复杂度为 O(2^m * n * m)。2025 L 路线选择中,宽度 m<=4 意味着截面状态很少,可以按行推进,维护经过情况、连通性或端点信息。训练状态压缩时,必须熟悉枚举 mask、枚举子集、用 lowbit 做增量计算,并且要判断 Python 是否能承受当前 2^m 级别。
区间 DP
按长度递增。
vector<vector<long long>> dp(n, vector<long long>(n));
for (int len = 1; len <= n; ++len) {
for (int l = 0; l + len - 1 < n; ++l) {
int r = l + len - 1;
for (int mid = l; mid < r; ++mid) {
dp[l][r] = max(dp[l][r], dp[l][mid] + dp[mid + 1][r]);
}
}
}
区间 DP 适合端点配对、括号结构、区间合并、链式选择。
标准转移:
dp[l][r] = max/min over mid: dp[l][mid] + dp[mid+1][r] + cost
或:
固定 l 与某个 k 配对,再合并左右区间
ZJ2026 D 的区间选择题可作为区间计数训练:端点两两不同、存在嵌套链或同向链,本质是区间偏序与组合 DP。区间 DP 的边界主要有四个:必须按长度递增,空区间是否有值要提前定义,方案数要处理取模,O(n^3) 通常只适合 n≈100 的规模。
期望 DP
期望题常用尾和公式:
E[X] = sum_{t>=0} P(X > t)
尾和公式的使用条件是 X 为非负整数随机变量,并且能够统计每个时刻仍未完成的概率。ZJ2026 C 回文串文回中,随机选择位置可以改写成随机排列所有位置;回文条件按对称位置成对分析,每一对在前 t 个位置被选中的情况决定是否已经满足。概率用组合数表达,除法用模逆元,不使用浮点数处理模意义期望。
4.5 图论
图论题不应从模板名字开始,而应从“点是什么、边是什么、边权是什么”开始。黄昏执行任务前会先确认身份、路线和目标,图论建模也是同一件事。若边无权,BFS 是第一选择;若边权只有 0 和 1,使用 0-1 BFS;若边权非负,Dijkstra 成立;若只维护连通性,使用并查集或 Tarjan;若图有依赖顺序,考虑拓扑排序;若目标是连接所有点且代价最小,进入 Kruskal;若题目要求边不相交路径、匹配或割,再考虑流模型。
边不相交路径在无向图中常与边双连通分量等价:两点存在两条边不相交路径,意味着它们之间没有必须经过的桥。构造路径时可以利用 DFS 树和返祖边,也可以在规模允许时转成流。事件图则是另一种常见隐式图:点首次触发后产生新事件,事件按时间排序,状态第一次生效后不再重复扩展。
图论建模时,点可能是原图节点、网格位置、字符串状态、时间事件、三维坐标或子集状态;边可能是输入边、一次操作、一次转移、一次传送或一次事件传播。边权决定算法:无权用 BFS,0/1 权用 0-1 BFS,非负权用 Dijkstra,有依赖用拓扑排序,只问连通性则用并查集或 Tarjan。2025 J 解谜比赛是事件图,解锁点产生带时间的能量事件,用优先队列处理;2024 F 图是边不相交路径,核心是桥和边双;2023 L 经典问题是特殊完全图 MST,不能真建完全图;ZJ2026 L 的三维障碍物传送则把可站立坐标变成状态点。
事件图框架:
def event_process(initial_events, expand):
pq = initial_events[:]
from heapq import heapify, heappop, heappush
heapify(pq)
done = set()
while pq:
time, state = heappop(pq)
if state in done:
continue
done.add(state)
for nt, ns in expand(time, state):
if ns not in done:
heappush(pq, (nt, ns))
return done
图题的边界很少温柔。重边不能随意去掉,Tarjan 在非连通图中要从每个点启动,Dijkstra 不能处理负边,隐式图必须哈希 visited。这些不是细枝末节,而是提交后 WA/RE/TLE 的常见来源。
4.6 字符串
字符串题的核心是“相等关系如何快速判断”。不要指望像阿尼亚一样读出字符串心声,程序只能靠结构。KMP 前缀函数回答 Border 和周期,Z 函数回答每个位置和全串前缀的 LCP,Trie 处理多字符串前缀和字典序选择,字符串哈希用于快速比较任意子串,Manacher 处理回文半径。
周期判定:
def min_period(s):
pi = prefix_function(s)
n = len(s)
p = n - pi[-1]
return p if n % p == 0 else n
Border 链:
def borders(s):
pi = prefix_function(s)
res = []
x = pi[-1]
while x:
res.append(x)
x = pi[x - 1]
return res
字符串题的核心是“相等关系如何快速判断”。
| 问题 | 工具 |
|---|---|
| 前缀与后缀 | KMP prefix function |
| 每个位置和全串前缀 LCP | Z function |
| 多字符串前缀 | Trie |
| 任意子串比较 | Hash / SA |
| 回文 | Manacher |
| 周期 | KMP / Z |
KMP 的两个基本用途是求 Border 链和求最小周期。2024 B 腊肠披萨的题面核心是最长公共前后缀,Border 不是“最长相同子串”,必须同时是前缀和后缀。2025 K 打字机的打印轨迹是正向再反向的折返周期,需要先转化为周期或镜像相等,再考虑每个前缀的最短模板。训练时要能手算 pi 数组,会从 pi[n-1] 得到周期,会枚举 Border 链,也要知道什么时候字符串哈希比 KMP 更适合。
4.7 数论与组合
数论与组合不靠灵感硬顶,靠结论落地。gcd(a,b)=gcd(b,a%b) 是最大公约数的计算入口;逆元存在条件是 gcd(a,mod)=1,质数模下常用 a^(mod-2);组合数通常 O(n) 预处理后 O(1) 查询;矩阵快速幂用于固定维线性递推;Burnside 用于旋转、翻转、本质不同染色这类等价类计数。
欧拉函数筛:
vector<int> phi(int n) {
vector<int> p, is(n + 1), phi(n + 1);
phi[1] = 1;
for (int i = 2; i <= n; ++i) {
if (!is[i]) {
p.push_back(i);
phi[i] = i - 1;
}
for (int prime : p) {
if (1LL * i * prime > n) break;
is[i * prime] = 1;
if (i % prime == 0) {
phi[i * prime] = phi[i] * prime;
break;
} else {
phi[i * prime] = phi[i] * (prime - 1);
}
}
}
return phi;
}
数论题的第一步是小样例。
数论题的第一步是小样例。看 gcd 最大值时,通常要观察区间长度和倍数;看到递推时,问它能否矩阵化;看到本质不同,问是否有旋转或翻转群作用;看到模数,先判断是否为质数,是否可用逆元;看到值域,判断能否筛、分块或整除分块。别像青山小姐一样只留下谜语,公式必须被小数据验证。
Burnside 最小学习量:
本质不同方案数 = 所有群作用下不变方案数的平均值
Burnside 的最小学习量足够处理旋转相同、翻转相同、环形排列和多边形边状态。赛题通常不会要求完整群论叙述,但会要求你知道“本质相同”不是直接除以操作数,而是统计每个操作下保持不变的方案。
矩阵快速幂适用:
dp[t+1] = A * dp[t]
dp[k] = A^k * dp[0]
2022 K 斐波那契中,路径长度很大,收益又与 Fibonacci 权值相关,典型入口是把递推和图邻接矩阵结合。组合数题要保证预处理上限覆盖最大 n;模意义下除法必须转逆元;mod 不是质数时不能随便套费马小定理。
4.8 计算几何
计算几何要先把对象保持在整数世界里。点和向量、叉积和点积、方向判断和线段相交是基础;凸包、旋转卡壳和扫描线则是 GDCPC 边界内最值得掌握的三个工具。
C++ 点结构:
struct Point {
long long x, y;
Point operator - (const Point &o) const { return {x - o.x, y - o.y}; }
};
long long cross(Point a, Point b) {
return a.x * b.y - a.y * b.x;
}
long long dot(Point a, Point b) {
return a.x * b.x + a.y * b.y;
}
几何题的策略是尽量离散化、排序化、单调化。
几何题的策略是尽量离散化、排序化、单调化。水平/竖直线段交点通常用扫描线加 Fenwick 或平衡树;凸多边形直径用旋转卡壳;方向比较用叉积;距离最值用平方距离避免浮点误差。2024 A 田字格是水平线段和竖直线段的结构题,入口是扫描线统计交点或关系;2023 M 计算几何是凸多边形切分,入口是凸性和旋转卡壳。坐标到 1e9 时,叉积用 long long 或 __int128,比较距离用平方,浮点输出保留足够精度。GDCPC 备考不需要系统学习半平面交和三维几何,但叉积、线段相交、凸包、旋转卡壳必须会。
4.9 剪枝
剪枝条件必须是充分条件:被剪掉的分支不可能产生合法解或更优解。
常用剪枝包括可行性剪枝、上界剪枝、下界剪枝、重复状态剪枝和对称性剪枝。可行性剪枝用于当前状态已经违反限制;上界剪枝用于当前收益加上最大可能收益仍不超过最优解;下界剪枝用于当前代价加上最小可能代价已经不优;重复状态剪枝在同状态下保留最优代价;对称性剪枝只保留等价分支中的一个代表。
剪枝是在搜索树里跳过确定没有奖励的支线。不能因为“看起来不顺眼”就剪,必须能证明该支线不会通向合法解或更优解。否则剪枝会从优化变成自毁,表面上少搜了很多,实际上把答案也一起丢进了纸箱。
0/1 搜索上界剪枝:
def branch_bound(value, weight, limit):
n = len(value)
suffix = [0] * (n + 1)
for i in range(n - 1, -1, -1):
suffix[i] = suffix[i + 1] + value[i]
best = 0
def dfs(i, w, v):
nonlocal best
if w > limit:
return
if v + suffix[i] <= best:
return
if i == n:
best = max(best, v)
return
dfs(i + 1, w + weight[i], v + value[i])
dfs(i + 1, w, v)
dfs(0, 0, 0)
return best
剪枝不是替代正解的借口。它适合小规模题、构造题、暴力对拍和搜索型模拟,也适合在推规律时缩小枚举范围。剪枝一旦进入正式解法,就必须给出充分性证明。广井前辈可以生活混乱,搜索树不行。
正确剪枝的证明格式:
当前状态 S。
定义上界/下界 B(S)。
若 B(S) 不可能优于当前答案,则 S 的所有后继都不可能优于当前答案。
因此剪掉 S 不影响最优解。
4.10 对拍
对拍是训练和赛前调试中最稳定的查错方法。它不证明算法正确,但能高效发现状态遗漏、边界错误、贪心反例和构造非法输出。
对拍由四个角色构成。brute 是暴力解,只处理小规模,逻辑尽量贴近题意定义;solve 是你准备提交的算法;gen 负责生成随机或枚举小数据;compare 负责比较结果,普通题直接比答案,构造题调用 checker。四者分工明确,调错时才不会互相污染。
不同题型的暴力写法也有章法。排列、交换和切分题可以枚举所有排列或所有操作位置;DP 与计数题可以 DFS 枚举所有方案;图论题用小图 Floyd、BFS 或枚举边集;字符串题枚举子串、子序列或位置集合;几何题把坐标压到很小,暴力枚举点、线段和切分方式。暴力解不追求快,只追求忠于定义。它像平时不起眼的记录本,赛场外却能把正解里最隐蔽的谎话翻出来。
有些题不适合直接比较输出。special judge 的输出不唯一,只能用 checker;浮点题需要误差比较;期望题的小规模验证应当精确枚举,不能用随机模拟近似;交互题则要改写成离线判定器。
Python 单文件对拍
import random
def brute(a):
n = len(a)
best = -10**30
for l in range(n):
s = 0
for r in range(l, n):
s += a[r]
best = max(best, s)
return best
def solve(a):
best = -10**30
cur = 0
for x in a:
cur = max(x, cur + x)
best = max(best, cur)
return best
def gen_case():
n = random.randint(1, 8)
return [random.randint(-5, 5) for _ in range(n)]
for seed in range(10000):
random.seed(seed)
data = gen_case()
a = brute(data)
b = solve(data)
if a != b:
print("Wrong answer")
print("seed =", seed)
print("data =", data)
print("brute =", a)
print("solve =", b)
break
else:
print("Accepted by stress test")
这段代码用最大子段和演示:暴力是 O(n^2),正解是 Kadane。对拍时只生成小 n,因为暴力只负责“可靠”,不负责“快”。
C++ 双程序对拍
常规流程:
gen.exe > input.txt
brute.exe < input.txt > brute.txt
main.exe < input.txt > main.txt
比较 brute.txt 和 main.txt
PowerShell:
for (i = 1;i -le 10000; i++) {
.\gen.exei > input.txt
.\brute.exe < input.txt > brute.txt
.\main.exe < input.txt > main.txt
fc brute.txt main.txt
if (LASTEXITCODE -ne 0) {
Write-Host "Wrong at seedi"
break
}
}
C++ 生成器:
#include <bits/stdc++.h>
using namespace std;
int main(int argc, char **argv) {
int seed = argc > 1 ? atoi(argv[1]) : 1;
mt19937 rng(seed);
int n = uniform_int_distribution<int>(1, 8)(rng);
cout << n << '\n';
for (int i = 0; i < n; ++i) {
int x = uniform_int_distribution<int>(-5, 5)(rng);
cout << x << (i + 1 == n ? '\n' : ' ');
}
return 0;
}
暴力解设计
暴力解要慢但可信。写法越接近题意越好。
子序列计数暴力:
def brute_distinct_loose_subseq(s, k):
n = len(s)
seen = set()
def dfs(pos, last, cur):
if pos == n:
if cur:
seen.add(cur)
return
dfs(pos + 1, last, cur)
if last == -1 or pos - last > k:
dfs(pos + 1, pos, cur + s[pos])
dfs(0, -1, "")
return len(seen)
对拍 2025 H 时,令 n<=12,把正解结果与该暴力比较。若小数据都过,再考虑大规模复杂度。
构造题 checker
构造题输出不唯一,不能比较输出文本。
禁止子段和 checker:
def check_no_subarray_sum(ans, a, b, n, s):
if len(ans) != n:
return False
pref = 0
seen = {0}
for x in ans:
if x != a and x != b:
return False
pref += x
if pref - s in seen:
return False
seen.add(pref)
return True
固定格网格染色 checker:
def check_grid_fixed(original, ans):
if len(original) != len(ans):
return False
for row0, row1 in zip(original, ans):
if len(row0) != len(row1):
return False
for x, y in zip(row0, row1):
if x != 0 and x != y:
return False
if y <= 0:
return False
return True
数据生成策略
生成器必须覆盖反常数据。最小规模如 n=1、空边、单点,极端值如 0、1、最大值和负数,重复结构如全相等和大量重复,单调结构如递增递减,图结构如链、环、完全图、森林和重边,字符串结构如全同、全不同、周期串和回文串,事件题还要覆盖同一时间多事件、永不触发和初始触发。随机生成不是只生成“看起来正常”的数据。很多 bug 只会在全 0、重边、同时间、负权、空集合这种反派角色登场时暴露。
失败定位
对拍失败后:
- 固定 seed。
- 打印输入。
- 确认暴力解正确。
- 缩小数据。
- 打印中间状态。
- 抽取失败模式。
- 加入回归测试。
回归测试记录:
case:
input:
expected:
bug:
fix:
4.11 提交前自测
提交前自测的目标是在 3-5 分钟内排除低级错误。它不是完整证明,而是最终安全检查。
最小自测集
任何题至少测:
- 样例。
- 最小规模。
- 最大规模的缩小版。
- 全相同。
- 严格递增/递减。
- 随机小数据。
- 题型特殊边界。
按题型扩展:
| 题型 | 必测 |
|---|---|
| 图论 | 非连通、重边、自环、链、环 |
| DP | 空状态、只有一个元素、全不可行 |
| 字符串 | 全同、全不同、周期串、无匹配 |
| 数学 | 0、1、质数、合数、模边界 |
| 构造 | checker 跑输出 |
| 几何 | 共线、重合、极大坐标 |
| 浮点 | 整数答案、小数答案、误差边界 |
C++ 自测工具
本地调试宏:
#ifdef LOCAL
#define debug(x) cerr << #x << " = " << (x) << '\n'
#else
#define debug(x) ((void)0)
#endif
边界断言:
assert(1 <= idx && idx <= n);
提交前要关闭大量 cerr、文件重定向和固定随机输出。assert 可以辅助本地定位,但不能让程序正确性依赖 assert;正式提交时,核心逻辑必须在没有断言的情况下仍然成立。不要等加艾露查岗时才发现珈百璃还在游戏里,提交前就把调试痕迹清掉。
Python 自测工具
基础读入:
import sys
input = sys.stdin.buffer.readline
递归题:
sys.setrecursionlimit(1_000_000)
大量输出:
out = []
for ans in answers:
out.append(str(ans))
sys.stdout.write("\n".join(out))
Python 提交前尤其要检查输入和对象开销。大输入使用 sys.stdin.buffer.readline,大循环避免切片和重复复制大列表,字典 key 必须可哈希,递归题必须设置安全的递归深度。若复杂度已经贴近上限,不要硬用 Python 赌评测机心情。
Special Judge 自测
流程:
生成输入
运行程序得到输出
checker(input, output)
checker 至少检查输出数量、值域、固定条件和目标条件。若程序输出无解,小数据下还要用暴力确认确实无解。special judge 题最怕“输出格式看起来像样例”,但少了某个硬条件。
常见事故表
| 事故 | 原因 | 自测 |
|---|---|---|
| 样例过,提交 WA | 漏边界 | 最小、全同、极端 |
| RE | 数组越界或递归爆栈 | assert、小最大测试 |
| TLE | 复杂度误判 | 放大数据 |
| MLE | DP 数组过大 | 估算内存 |
| PE | 输出缺行 | 检查输出数量 |
| 浮点 WA | 精度不足 | 输出 12 位以上 |
| 构造 WA | 输出不合法 | checker |
内存估算
C++:
int: 4 bytes
long long: 8 bytes
double: 8 bytes
1e6 long long ≈ 8 MB
1e7 int ≈ 40 MB
Python 对象开销大,不能按 C++ 数组估算。大规模数字列表尽量避免多份复制;必要时改用 C++。
提交前 60 秒清单
最后 60 秒只做低风险检查,不做大重构。确认多测已处理、数组已清空、下标体系统一;确认 long long、mod 和 INF 没有选错;确认 Dijkstra 没有负边,Tarjan 已处理重边;确认构造输出经过 checker;若使用 Python,再看一眼输入速度、递归深度和复杂度余量。
记忆锚点:提交前自测不是水群前的仪式感,是最后一次确认机体没有少装零件。
5. 样章:从真题到算法
本章不按“知识点标签”讲题,而按真实解题路径讲:为什么这样建模,代码写在哪里,边界如何自测。
5.1 2025 A 矩阵游戏:从行列翻转到状态压缩
矩阵游戏的题面给出的信号很集中:元素只有 0/1,操作是整行翻转和整列翻转,列数很小而行数可能很大,每个格子的贡献还可能有正有负。这四件事合在一起,几乎是在提醒你把翻转写成异或,并把列翻转压成一个 mask。
翻转在二进制里就是异或。设第 i 行是否翻转为 r_i,第 j 列是否翻转为 c_j,原值为 a_ij,最终值为:
b_ij = a_ij xor r_i xor c_j
所有列翻转状态合起来就是一个 mask。如果固定了 mask,每一行之间不再互相影响:第 i 行只剩两个选择,翻或不翻。于是问题变成:
枚举列 mask
每行独立取 max(不翻贡献, 翻转贡献)
正确性来自枚举与独立性。任意合法方案都有一个列翻转集合 mask;固定 mask 后,行翻转只影响本行,不再影响其他行;于是每行独立取最大值不会破坏别的选择。枚举全部 mask 后,最优方案必然被覆盖。
C++:
long long solve_matrix_game(int n, int m, long long A, long long B, const vector<string> &grid) {
vector<vector<long long>> w(n, vector<long long>(m));
vector<long long> row_sum(n, 0);
for (int i = 0; i < n; ++i) {
for (int j = 0; j < m; ++j) {
w[i][j] = A * (i + 1) + B * (j + 1);
row_sum[i] += w[i][j];
}
}
long long ans = LLONG_MIN;
for (int mask = 0; mask < (1 << m); ++mask) {
long long total = 0;
for (int i = 0; i < n; ++i) {
long long keep = 0;
for (int j = 0; j < m; ++j) {
int bit = grid[i][j] - '0';
bit ^= (mask >> j) & 1;
if (bit) keep += w[i][j];
}
total += max(keep, row_sum[i] - keep);
}
ans = max(ans, total);
}
return ans;
}
本题的边界很短,但都要命。权值可能为负,答案初值不能设为 0;A*i+B*j 和总和使用 64 位;如果列数不是小参数,这套枚举列状态的解法就会失效。
小规模暴力对拍:
def brute_matrix_game(n, m, A, B, grid):
ans = -10**30
for rm in range(1 << n):
for cm in range(1 << m):
cur = 0
for i in range(n):
for j in range(m):
bit = int(grid[i][j])
bit ^= (rm >> i) & 1
bit ^= (cm >> j) & 1
if bit:
cur += A * (i + 1) + B * (j + 1)
ans = max(ans, cur)
return ans
5.2 2025 F 排名预测:从封榜提交到多重选择背包
排名预测的题面同时给出规则和规模:ICPC 排名先比题数,再比罚时;封榜后提交结果未知;题数 n<=15;目标是求最少封榜后通过多少题才能排名严格更高。这里真正的小参数是题数,每一道题都可以被拆成一个互斥选项组。
一个未在封榜前 AC 的题有多个互斥未来:不过,或第 1 次封榜提交 AC,或第 2 次封榜提交 AC。每道题形成一个选项组。把所有题的选项组合起来,就是多重选择背包。
状态:
dp[solved][post] = 达到 solved 题,封榜后 AC post 题时的最小罚时
为什么状态里不需要记录每道题:每道题只处理一次;已处理题对未来的影响只剩总题数、封榜后题数和罚时。相同 (solved, post) 下,罚时越小越优。
Python 3.12:
INF = 10**30
def merge_problem_options(n, base_solved, base_penalty, groups):
dp = [[INF] * (n + 1) for _ in range(n + 1)]
dp[base_solved][0] = base_penalty
for opts in groups:
ndp = [row[:] for row in dp]
for solved in range(n + 1):
for post in range(n + 1):
cur = dp[solved][post]
if cur == INF:
continue
for add_solved, add_post, add_penalty in opts:
ns = solved + add_solved
np = post + add_post
if ns <= n and np <= n:
ndp[ns][np] = min(ndp[ns][np], cur + add_penalty)
dp = ndp
return dp
答案扫描:
def min_post_ac_to_beat(dp, our_solved, our_penalty):
n = len(dp) - 1
ans = n + 1
for solved in range(n + 1):
for post in range(n + 1):
penalty = dp[solved][post]
if penalty >= INF:
continue
if solved > our_solved or (solved == our_solved and penalty < our_penalty):
ans = min(ans, post)
return -1 if ans == n + 1 else ans
罚时题的边界必须严格尊重赛制。已 AC 题的后续提交不影响首次 AC;同一分钟提交按输入顺序处理;未 AC 题的错误提交会影响之后 AC 的罚时;“封榜后过几题”统计的是题数,不是提交次数。
这题不是“背包题长得像背包题”,而是把每题未来可能结果压成选项组。很多中档 DP 都是这样:剧情复杂,但每集处理完,只留下几个会影响结局的变量。
5.3 2025 H 松散子序列:为什么 last 去重不可少
松散子序列的题面有两个核心限制:它要求本质不同子序列,又要求相邻选中位置在原串中至少隔开 k 个位置。字符集只有小写字母,答案对模数取值。去重和距离限制必须同时进入状态,否则不是重复计数,就是把不合法的相邻位置接上。
普通子序列计数会重复。例如 aa 的非空子序列按位置有两个 "a",但本质不同只有一个 "a"。因此必须去重。
设:
total[i] = 前 i 个字符能形成的本质不同非空松散子序列数
last[c] = 上一次以字符 c 结尾时已经贡献过的来源数量
处理第 i 个字符时,它可以单独成序列,也可以接在某个合法旧序列后面。由于相邻位置要相隔超过 k,旧序列最后位置必须在 i-k-1 之前:
base = 1 + total[i-k-1]
add = base - last[c]
last[c] = base
Python:
def count_distinct_loose_subseq(s, k, mod=998244353):
n = len(s)
total = [0] * (n + 1)
last = [0] * 26
for i, ch in enumerate(s, 1):
prev = i - k - 1
base = 1
if prev >= 0:
base = (base + total[prev]) % mod
c = ord(ch) - ord('a')
add = (base - last[c]) % mod
total[i] = (total[i - 1] + add) % mod
last[c] = base
return total[n]
正确性来自“新增序列的结尾”。处理当前位置时,所有新增序列都以当前字符结尾;base 枚举全部可接来源,并包含单字符序列。同一结尾字符的旧位置已经产生过一批同名序列,新位置会覆盖这批贡献,因此用 last[c] 记录上次覆盖量,再从当前 base 中减掉即可。
暴力对拍:
def brute_distinct_loose_subseq(s, k):
n = len(s)
seen = set()
def dfs(pos, last_pos, cur):
if pos == n:
if cur:
seen.add(cur)
return
dfs(pos + 1, last_pos, cur)
if last_pos == -1 or pos - last_pos > k:
dfs(pos + 1, pos, cur + s[pos])
dfs(0, -1, "")
return len(seen)
训练时令 n<=12 随机生成字符串,对拍 1000 组。DP 题不是写完就信,存档点系统也要验档。
5.4 2025 J 解谜比赛:事件图不是普通最短路,但长得像
解谜比赛的题面有明显的事件系统味道:每个点有阈值,点解锁后沿出边延迟传能,强制刷新器会在指定时间改变阈值,最后要求每个点的最早解锁时间。这不是普通 Dijkstra,因为边不是“走过去就到达”,而是能量累计到阈值后才触发。
这不是普通 Dijkstra,因为边不是走过去就到达。点需要收到足够多能量才会触发。但它仍然有 Dijkstra 的骨架:事件按时间从小到大处理,某个点第一次解锁就是最早时间。
事件模型:
事件 = (时间, 类型, 点)
类型:解锁候选 / 能量到达
处理逻辑:
push 初始解锁候选
push 刷新器解锁候选
while pq:
取最早事件
若能量到达:累计能量,达到阈值则 push 解锁候选
若解锁候选:若未解锁,标记时间并沿出边产生能量到达
Python 骨架:
from heapq import heappush, heappop
def event_unlock(n, need, graph, forced):
got = [0] * (n + 1)
ans = [-1] * (n + 1)
pq = []
for u in range(1, n + 1):
if need[u] == 0:
heappush(pq, (0, 0, u))
for t, nodes in forced:
for u in nodes:
heappush(pq, (t, 0, u))
while pq:
time, typ, u = heappop(pq)
if typ == 1:
if ans[u] != -1:
continue
got[u] += 1
if got[u] >= need[u]:
heappush(pq, (time, 0, u))
continue
if ans[u] != -1:
continue
ans[u] = time
for v, w in graph[u]:
if ans[v] == -1:
heappush(pq, (time + w, 1, v))
return ans[1:]
这段是事件图骨架。真实题解要按题面精确处理刷新器对阈值的修改。
事件图的边界是时间顺序和一次性。点只能解锁一次;多个事件同一时间进入优先队列不破坏正确性;刷新器管控点的总数量可能很大,必须按总输入规模存储;永不解锁的点输出 -1。这类题像阿尼亚的秘密行动:每个事件都很热闹,但记录本上只认最早那一次。
5.5 构造题:checker 比样例更可靠
构造题输出不唯一。样例只是一条合法路线,不是唯一剧情线。
以禁止子段和为 s 的序列构造为例:
构造 c[i] in {a,b}
不存在 l,r 使 sum(c[l..r]) = s
转化:
sum(l..r)=s <=> prefix[r]-prefix[l-1]=s
新增前缀和 np 时,只要历史前缀和集合中存在 np-s,就产生非法子段。
checker:
def check_no_subarray_sum(ans, a, b, n, s):
if len(ans) != n:
return False
pref = 0
seen = {0}
for x in ans:
if x != a and x != b:
return False
pref += x
if pref - s in seen:
return False
seen.add(pref)
return True
若程序输出无解,小数据用暴力确认:
def brute_exists(a, b, n, s):
ans = []
def dfs(pos):
if pos == n:
return check_no_subarray_sum(ans, a, b, n, s)
for x in (a, b):
ans.append(x)
if dfs(pos + 1):
return True
ans.pop()
return False
return dfs(0)
构造题的证明和 checker 是两把刀。证明负责说服人,checker 负责殴打代码。
5.6 2024 B 腊肠披萨:前缀、后缀与 Border 的正名
腊肠披萨给了很多字符串,总长度达到百万级,核心对象是最长公共前后缀,答案是所有有序字符串对的贡献和。规模已经说明不能逐对比较,O(L^2) 会直接退场;定义则说明不能把它当普通 LCP 或最长公共子串。
这类题最容易误读。“最长公共前后缀”不是两个字符串的最长公共子串,也不是最长公共前缀。它要求一个字符串同时满足:
t 是 s_i 的前缀
t 是 s_j 的后缀
单对字符串可以用 KMP 检验。把 a 的前缀和 b 的后缀接在一起看,常用写法是:
a + "#" + b
该串的最后一个前缀函数值,就是 a 的最长前缀与 b 的后缀的匹配长度。分隔符必须不在原字符集内,否则会把两边误接起来。
def prefix_function(s: str) -> list[int]:
pi = [0] * len(s)
for i in range(1, len(s)):
j = pi[i - 1]
while j and s[i] != s[j]:
j = pi[j - 1]
if s[i] == s[j]:
j += 1
pi[i] = j
return pi
def lcps_len(a: str, b: str) -> int:
t = a + "#" + b
return prefix_function(t)[-1]
这段代码是显微镜,不是整题正解。它负责确认定义,适合做暴力对拍。真正面对总长 3e6 时,必须把“很多前缀”和“很多后缀”统一处理:前缀进入 Trie,后缀通过反串或自动机查询,贡献按最大匹配长度统计。此时你不再逐对看字符串,而是让所有字符串在一棵结构里相遇。
多串题的基本压缩是把每个前缀看成 Trie 上的节点。若某个节点深度为 d,它代表一个长度为 d 的前缀。一个字符串 s_j 的后缀若走到这个节点,说明所有经过该节点的字符串 s_i 都至少有一个长度为 d 的公共前后缀。为了得到“最长”,需要让更深节点覆盖更浅节点;这就是为什么这题不适合只统计“出现过多少次”,而要处理最大深度。
Trie 代码骨架:
struct Trie {
struct Node {
int nxt[26];
int depth;
int pass;
Node() : depth(0), pass(0) {
fill(nxt, nxt + 26, -1);
}
};
vector<Node> tr;
Trie() { tr.push_back(Node()); }
int insert_prefixes(const string &s) {
int u = 0;
for (char ch : s) {
int c = ch - 'a';
if (tr[u].nxt[c] == -1) {
tr[u].nxt[c] = (int)tr.size();
tr.push_back(Node());
tr.back().depth = tr[u].depth + 1;
}
u = tr[u].nxt[c];
tr[u].pass++;
}
return u;
}
};
真题落代码时要注意三件事。
第一,LCPS(s_i,s_i) 也可能被计入,必须看清题面是否排除 i=j。样例若把自身也计入,整串长度会贡献 C^{|s_i|};漏掉这一项,答案会像阿卡林一样悄悄消失。
第二,空串的贡献要按题面公式处理。如果贡献是 C^len,空匹配贡献 1;如果贡献是 len^C,空匹配贡献 0。样例里的空项往往就是用来抓这个边界的。
第三,模数 P 小于 2^30,乘法用 64 位安全,但累加时仍要及时取模。
用于对拍的暴力:
def brute_lcps_sum(words, c, mod):
ans = 0
for a in words:
for b in words:
d = lcps_len(a, b)
ans = (ans + pow(c, d, mod)) % mod
return ans
训练方式:先用暴力把定义钉牢,再写 Trie/自动机方向的正解。字符串题一旦定义错,后面代码越优化,错得越有速度。
5.7 2024 F 图:边不相交路径与桥
这道题给的是无向图,要求输出若干条边不相交路径,输入允许重边、不允许自环,并且是 special judge。四个条件合在一起,真正的入口就是桥、边双和输出验证。
“边不相交”这四个字的第一反应应当是桥和边双连通。若一条边是桥,任何跨过它的两点路径都必须经过它;若删去所有桥后两个点仍在同一块中,它们之间至少有两条边不相交路径。这个结论是无向图中 Menger 定理在边连通场景下的常用竞赛版本。
判断桥必须使用边编号。重边是这题给出的明确信号,也是 Tarjan 最常见的坑。若 DFS 时只写 if (v == parent) continue,两条平行边会被你合并成一条,桥判断立刻失真。
struct Edge {
int to;
int id;
};
void tarjan_bridge(int u, int parent_edge,
const vector<vector<Edge>> &g,
vector<int> &dfn,
vector<int> &low,
vector<int> &bridge,
int &timer) {
dfn[u] = low[u] = ++timer;
for (auto e : g[u]) {
int v = e.to;
if (e.id == parent_edge) continue;
if (!dfn[v]) {
tarjan_bridge(v, e.id, g, dfn, low, bridge, timer);
low[u] = min(low[u], low[v]);
if (low[v] > dfn[u]) bridge[e.id] = 1;
} else {
low[u] = min(low[u], dfn[v]);
}
}
}
删桥后的连通块可以用 DFS 染色:
void paint_component(int u, int color,
const vector<vector<Edge>> &g,
const vector<int> &bridge,
vector<int> &comp) {
comp[u] = color;
for (auto e : g[u]) {
if (bridge[e.id]) continue;
if (!comp[e.to]) paint_component(e.to, color, g, bridge, comp);
}
}
special judge 的题还要会验证输出。路径 checker 的最小标准:
def check_edge_disjoint_paths(n, edge_list, s, t, paths):
edge_id = {}
for idx, (u, v) in enumerate(edge_list):
if u > v:
u, v = v, u
edge_id.setdefault((u, v), []).append(idx)
used = set()
for path in paths:
if not path or path[0] != s or path[-1] != t:
return False
for a, b in zip(path, path[1:]):
x, y = (a, b) if a < b else (b, a)
if (x, y) not in edge_id:
return False
chosen = None
while edge_id[(x, y)]:
cand = edge_id[(x, y)].pop()
if cand not in used:
chosen = cand
break
if chosen is None:
return False
used.add(chosen)
return True
这段 checker 特意保留重边。若输入有三条 1-4,输出三条只含 1 4 的路径可以合法;边不相交指边编号不同,不是端点对不同。这里要像夏音照顾小依、虹夏照顾乐队一样细,少照顾一次,代码就会摔。
完整构造通常分两步。先在某个无桥连通块中找候选点对,再在 DFS 树和返祖边上恢复多条路径。若赛时无法立即写出完整构造,也要先写判断和 checker;special judge 题最怕“我觉得这样输出应该行”。构造题没有“应该”,只有条件逐条成立。
5.8 2023 L 经典问题:完全图不一定要建出来
经典问题看似给了一张完全图,但点数 n 可达 1e9,只有 m 条边被三元组特殊指定,普通边权是 |x-y|,目标是 MST。这样的规模已经把显式建完全图排除掉,真正的模型是一条数轴加少量特殊边。
看到完全图不要急着建边。这里真正的图不是 n(n-1)/2 条边,而是一条数轴加少量特殊边。普通边权 |x-y| 有强结构:从 x 到 y 沿着相邻点走,代价也是 y-x。因此很多普通长边不会比一串相邻边更有用。特殊边打破了这条数轴的局部结构,算法的任务就是在“数轴骨架”和“特殊捷径/障碍”之间做 Kruskal。
最朴素的思想是把所有相邻边和特殊边丢进 Kruskal。但 n 太大,不能枚举 1..n-1。压缩的关键是:只有特殊边端点附近的相邻关系可能影响选择;远离特殊端点的一整段连续普通边可以合并成一条“区间代价”。这类题的训练价值就在这里:不是见到 MST 就排序所有边,而是先问哪些边可能进入最小生成树。
用于小规模对拍的完整 Kruskal:
class DSU:
def __init__(self, n):
self.fa = list(range(n))
def find(self, x):
while self.fa[x] != x:
self.fa[x] = self.fa[self.fa[x]]
x = self.fa[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb:
return False
self.fa[rb] = ra
return True
def brute_mst_complete(n, special):
mp = {(u, v): w for u, v, w in special}
edges = []
for i in range(1, n + 1):
for j in range(i + 1, n + 1):
edges.append((mp.get((i, j), j - i), i - 1, j - 1))
edges.sort()
dsu = DSU(n)
ans = 0
for w, u, v in edges:
if dsu.union(u, v):
ans += w
return ans
这个暴力只用于 n<=60 对拍。正解写法应当围绕候选边压缩:所有特殊边必须保留,特殊端点及其相邻坐标进入压缩点,压缩点之间用坐标差表示连续普通边,没有任何特殊端点覆盖的长区间则把普通链代价直接累加。
C++ 中候选坐标的收集方式:
vector<long long> xs;
auto add_coord = [&](long long x, long long n) {
if (1 <= x && x <= n) xs.push_back(x);
};
for (auto [u, v, w] : special_edges) {
for (long long d = -1; d <= 1; ++d) {
add_coord(u + d, n);
add_coord(v + d, n);
}
}
add_coord(1, n);
add_coord(n, n);
sort(xs.begin(), xs.end());
xs.erase(unique(xs.begin(), xs.end()), xs.end());
把坐标压缩后,普通边不再是一条条原始边,而是相邻压缩坐标之间的区间连接,权值是坐标差。特殊边按压缩后的端点加入。然后运行 Kruskal。
struct E {
long long w;
int u, v;
bool operator < (const E &other) const {
return w < other.w;
}
};
这份写法的边界在“特殊边覆盖相邻点”。若 (x,x+1) 被特殊权值覆盖,普通权值 1 不再存在;候选边生成时不能把它又当成普通相邻边塞回去。样例第一组正是这种陷阱:(1,2) 和 (2,3) 被高权值覆盖,真正便宜的连接可能绕开它们。
本题的思想比代码更重要:完全图只是叙述,数轴结构才是模型。遇到 n 巨大而特殊信息很少的图题,先找“默认结构”和“异常结构”。默认结构能公式化,异常结构才进数据结构。
5.9 2023 M 计算几何:凸多边形的直径
计算几何这一题的关键信号是:顶点按逆时针给出,多边形凸,要用两点连线切成两个面积为正的小多边形,目标涉及两个小多边形直径平方和。凸性把“任意两点距离最大”压到顶点对,逆时针顺序给了旋转卡壳的基础。
几何题先保整数。直径的平方可以用坐标差平方表示,不需要开根号。凸多边形的直径一定由一对顶点取得;这个结论让问题从“多边形内部任意两点”落回到顶点对。
点结构:
struct Point {
long long x, y;
};
Point operator - (Point a, Point b) {
return {a.x - b.x, a.y - b.y};
}
long long cross(Point a, Point b) {
return a.x * b.y - a.y * b.x;
}
long long dist2(Point a, Point b) {
long long dx = a.x - b.x;
long long dy = a.y - b.y;
return dx * dx + dy * dy;
}
凸多边形直径用旋转卡壳。直觉是:当一条边沿多边形转动时,与它最远的点不会反向跳回去,只会单调前进。这个“单调”就是几何题最宝贵的秩序,像枫前辈带登山队,大家一段一段往前走,不会突然传送回山脚。
long long convex_diameter2(const vector<Point> &p) {
int n = (int)p.size();
if (n == 1) return 0;
if (n == 2) return dist2(p[0], p[1]);
int j = 1;
long long ans = 0;
for (int i = 0; i < n; ++i) {
int ni = (i + 1) % n;
while (true) {
int nj = (j + 1) % n;
long long cur = llabs(cross(p[ni] - p[i], p[j] - p[i]));
long long nxt = llabs(cross(p[ni] - p[i], p[nj] - p[i]));
if (nxt > cur) j = nj;
else break;
}
ans = max(ans, dist2(p[i], p[j]));
ans = max(ans, dist2(p[ni], p[j]));
}
return ans;
}
真题要枚举切割用的两点。若两点相邻,切不开面积为正的两个多边形;若所有点共线也不合法,但题面保证原凸多边形面积为正。枚举一条对角线后,两侧各是一段环形顶点序列,分别求直径,取平方和最小。
小规模验证可以用朴素直径:
def diameter2_bruteforce(poly):
ans = 0
for i, (x1, y1) in enumerate(poly):
for x2, y2 in poly[i + 1:]:
ans = max(ans, (x1 - x2) ** 2 + (y1 - y2) ** 2)
return ans
几何题的边界必须保守。坐标到 1e9,距离平方到 1e18,C++ 用 long long;若叉积表达式还要参与更大乘法,改用 __int128。输入可能有三点共线,旋转卡壳中 > 与 >= 的选择会影响是否跳过同面积点,但直径最大值不能漏。判断“面积为正”时用叉积或顶点数量,不要用浮点面积。Python 适合写对拍和暴力,正式大数据几何建议使用 C++。几何代码要像智乃拉咖啡一样稳,不能靠“差不多”调味。
几何不是玄学。它只是把“大小关系”藏进叉积,把“最远”藏进单调。把这两件事找出来,题面就不再像一张看不懂的地图。
6. 五年真题训练索引
本章的表格只负责定位,表格后面的讲读负责训练。使用这一章时不要把题目记成“某某题等于某算法”,而要记成一个更短的动作链:
题面限制 -> 可计算对象 -> 循环顺序/数据结构 -> 边界样例 -> 真题代码落点
如果一个变量在推导里没有进入状态,也没有进入转移,它就是“阿卡林变量”:名字还在,存在感已经没了。删掉它,模型会更清楚。
6.1 入门档
| 题目 | 核心 |
|---|---|
| 2023 A 算法竞赛 | 日期/计数模拟 |
| 2025 D 生成魔咒 | 数位贪心 |
| ZJ2026 H ucup-team7610 | 字符串格式 |
| ZJ2026 I 飞行艇 | 排序贪心 |
| 2024 G Menji 和 gcd | 数论观察 |
入门题不等于“随便写”。这类题的价值在于训练把自然语言规则拆成函数。日期题要先确定计数单位,字符串格式题要先确定合法字符和输出格式,数位贪心题要先确定每一位决策对后续是否独立。小埋在外面是完美女高中生,回家后才变成干物妹;签到题也常常是样例温顺、边界发癫,不能只看外表。
日期/计数模拟的代码应当把“是否为目标日”单独写成函数,而不是把一堆判断塞进主循环。这样做不是形式主义,而是为了在赛场上快速检查闰年、月份长度、左右端点是否包含。
def is_leap(y: int) -> bool:
return y % 400 == 0 or (y % 4 == 0 and y % 100 != 0)
def month_days(y: int, m: int) -> int:
md = [31, 28 + is_leap(y), 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
return md[m - 1]
def count_days(y1, m1, d1, y2, m2, d2, ok):
ans = 0
y, m, d = y1, m1, d1
while (y, m, d) <= (y2, m2, d2):
ans += ok(y, m, d)
d += 1
if d > month_days(y, m):
d = 1
m += 1
if m == 13:
m = 1
y += 1
return ans
排序贪心题的第一行通常不是排序,而是写出交换论证:若两个相邻对象 x,y 的顺序可以比较,且 x,y 换成 y,x 不会更优,则排序关键字成立。落代码时只保留比较器需要的信息,不要把题面所有字段都背进结构体。可靠姐姐型写法是“先把对象收拾干净,再让主循环只做一件事”。
struct Item {
long long key;
long long cost;
};
long long greedy_order(vector<Item> a) {
sort(a.begin(), a.end(), [](const Item &x, const Item &y) {
return x.key < y.key;
});
long long cur = 0, ans = 0;
for (auto &e : a) {
cur += e.cost;
ans = max(ans, cur - e.key);
}
return ans;
}
数论观察题要先枚举小数据。gcd、倍数、整除、奇偶这些词出现时,先用暴力生成 1..30 的表,比盯着题面硬想更稳。炮姐的硬币飞出去前已经确定轨道;数论题的性质通常也藏在小表里,只是需要你把它打出来。
6.2 中档档
| 题目 | 核心 |
|---|---|
| 2025 A 矩阵游戏 | 状态压缩 + 行独立 |
| 2025 F 排名预测 | 多重选择背包 |
| 2025 H 松散子序列 | 前缀 DP + 去重 |
| 2024 C DFS 序 | 树上贪心 |
| 2024 B 腊肠披萨 | Border / KMP |
| 2023 B 基站建设 | 约束转化 + 贪心/数据结构 |
| 2023 L 经典问题 | MST 建模 |
| 2022 E 黑白大陆 | 连通块/网格 |
中档题的共同点是“题面还有现实含义,但代码已经必须抽象”。训练时应按模型分类。
状态压缩题的信号是小参数。2025 A 的列数小,所以列翻转可以枚举;2025 F 的题数小,所以每题的未来结果可以合并成背包;窄网格题的宽度小,所以截面状态可以保存。这里的关键不是“看到小就二进制”,而是固定小参数后,其余部分是否独立。2025 A 固定列 mask 后,每一行像芝麻凛独自露营:互不打扰,每行只需要选翻或不翻。
for (int mask = 0; mask < (1 << m); ++mask) {
long long total = 0;
for (int i = 0; i < n; ++i) {
long long keep = 0;
for (int j = 0; j < m; ++j) {
int bit = a[i][j] ^ ((mask >> j) & 1);
if (bit) keep += w[i][j];
}
total += max(keep, row_sum[i] - keep);
}
ans = max(ans, total);
}
背包题的建模边界是“每组只能选一个”。排名预测中,一道题的封榜后结果有若干可能:不过、某次提交通过、另一时刻通过。这些可能互斥,所以它不是普通 0/1 背包,而是多重选择背包。代码中外层循环题目,内层用新表 ndp,避免同一题被选多次。
INF = 10**30
def group_knapsack(n, groups, base_solved=0, base_penalty=0):
dp = [[INF] * (n + 1) for _ in range(n + 1)]
dp[base_solved][0] = base_penalty
for opts in groups:
ndp = [[INF] * (n + 1) for _ in range(n + 1)]
for solved in range(n + 1):
for post in range(n + 1):
cur = dp[solved][post]
if cur == INF:
continue
for add_solved, add_post, add_penalty in opts:
ns, np = solved + add_solved, post + add_post
if ns <= n and np <= n:
ndp[ns][np] = min(ndp[ns][np], cur + add_penalty)
dp = ndp
return dp
字符串 Border 题的代码落点是 pi 数组。2024 B 和 2022 I 都不该用“找相同子串”的直觉处理;Border 必须同时是前缀和后缀。最长 Border 是 pi[n-1],次长 Border 是 pi[pi[n-1]-1],这条链就是题目的骨架。
def prefix_function(s: str) -> list[int]:
pi = [0] * len(s)
for i in range(1, len(s)):
j = pi[i - 1]
while j and s[i] != s[j]:
j = pi[j - 1]
if s[i] == s[j]:
j += 1
pi[i] = j
return pi
def border_chain(s: str) -> list[int]:
pi = prefix_function(s)
res = []
x = pi[-1] if s else 0
while x:
res.append(x)
x = pi[x - 1]
return res
网格连通题的边界是“颜色组件”,不是单个格子。2022 E 黑白大陆这类题先 BFS 压缩连通块,再在组件图上想操作次数。若直接对格子操作,状态规模和证明都会混乱。
from collections import deque
def components(grid):
n, m = len(grid), len(grid[0])
comp = [[-1] * m for _ in range(n)]
colors = []
cid = 0
for i in range(n):
for j in range(m):
if comp[i][j] != -1:
continue
colors.append(grid[i][j])
comp[i][j] = cid
q = deque([(i, j)])
while q:
x, y = q.popleft()
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
nx, ny = x + dx, y + dy
if 0 <= nx < n and 0 <= ny < m and comp[nx][ny] == -1 and grid[nx][ny] == grid[i][j]:
comp[nx][ny] = cid
q.append((nx, ny))
cid += 1
return comp, colors
6.3 困难档
| 题目 | 核心 |
|---|---|
| 2025 C 切牌 | 排列结构 + 动态维护 |
| 2025 J 解谜比赛 | 事件图 + 优先队列 |
| 2025 K 打字机 | 字符串周期 |
| 2025 L 路线选择 | 窄网格 DP |
| 2024 F 图 | 边不相交路径构造 |
| 2024 J 另一个计数问题 | 数论图计数 |
| 2023 M 计算几何 | 凸多边形直径 |
| ZJ2026 B 拜云台 | 区间桥 + 离线图 |
| ZJ2026 C 回文串文回 | 期望 + 组合 |
| ZJ2026 M Rounddog | LIS/LDS 偏序 |
困难题通常不是“会不会模板”,而是“能不能承认题面对象不该原样进代码”。2023 L 看起来是完全图 MST,若真建 O(n^2) 边就已经输在开场;2025 J 看起来是图最短路,但点的解锁依赖能量累计和刷新器,它更像事件系统;ZJ2026 C 看起来是回文字符串,核心却是随机排列与组合概率。题面像乃爱一样努力表现自己,但你不能被表演欲带跑。
事件图的落代码原则是:状态第一次确定时才扩展,所有未来影响进优先队列。若一个点能量增加但未达阈值,只更新累计值,不扩展出边;若刷新器把阈值改小,检查是否立刻触发。代码结构上至少要有三类容器:energy 保存当前能量,need 保存阈值,done 保存是否已经解锁。
from heapq import heappush, heappop
def unlock_times(n, need, graph, refresh_events):
energy = [0] * n
done = [False] * n
ans = [-1] * n
pq = []
def try_unlock(t, u):
if not done[u] and energy[u] >= need[u]:
heappush(pq, (t, 0, u))
for u in range(n):
try_unlock(0, u)
for t, u, new_need in refresh_events:
heappush(pq, (t, 1, (u, new_need)))
while pq:
t, typ, data = heappop(pq)
if typ == 1:
u, new_need = data
need[u] = min(need[u], new_need)
try_unlock(t, u)
continue
u = data
if done[u] or energy[u] < need[u]:
continue
done[u] = True
ans[u] = t
for v, add, delay in graph[u]:
energy[v] += add
try_unlock(t + delay, v)
return ans
上面是事件图骨架,不是对某一真题的完整替代品。完整实现要按题面处理“刷新器管控多个点”“同一时刻多个能量事件”“阈值为 0 的初始点”等细节。边界检查必须包含:初始可解锁、永远不可解锁、同一秒多事件、重边、多刷新器管同一点。
边不相交路径题的模型是桥。无向图中,若 s,t 之间存在两条边不相交路径,则它们在删去所有桥后的同一连通块中。2024 F 的难点不止判断,还要输出路径;但判断部分必须先干净,否则构造会像小依做手工一样认真但到处漏胶。
struct Edge { int to, id; };
void dfs_bridge(int u, int pe, const vector<vector<Edge>> &g,
vector<int> &tin, vector<int> &low, vector<int> &is_bridge, int &timer) {
tin[u] = low[u] = ++timer;
for (auto e : g[u]) {
if (e.id == pe) continue;
int v = e.to;
if (!tin[v]) {
dfs_bridge(v, e.id, g, tin, low, is_bridge, timer);
low[u] = min(low[u], low[v]);
if (low[v] > tin[u]) is_bridge[e.id] = 1;
} else {
low[u] = min(low[u], tin[v]);
}
}
}
这段代码的边界是重边。不能用 parent vertex 跳过父边,否则两点之间的平行边会被误判成桥;必须用边编号 id。这正是真题到代码最容易摔的地方。
期望题的落点是尾和公式或状态方程。ZJ2026 C 的随机过程可以理解为“随机排列所有位置,看到第几步回文条件满足”。如果某个对称位置对本来就相等,它不制造限制;如果不等,则至少要在这一对中改到足够位置。代码通常不会模拟随机,而是用组合数计算 P(T > t) 或 P(T <= t)。
E[T] = sum_{t>=0} P(T > t)
这个公式的边界是 T 必须是非负整数随机变量。模意义下做期望时,不使用浮点数,所有除法都转成逆元。
6.4 专项对照
| 专项 | 真题 |
|---|---|
| 构造 | 2025 B/E,2024 F/H,2023 H/J,ZJ2026 A/G |
| DP | 2025 F/H/L,2023 F/I,ZJ2026 C/D/F/M |
| 字符串 | 2025 H/K,2024 B/D,2023 E,2022 C/I |
| 图论 | 2025 J,2024 F/J,2023 L,2022 G/L,ZJ2026 B/L |
| 数学 | 2024 G/I/J,2022 A/B/K/M,2021 B/E/F |
| 几何 | 2024 A,2023 M,2021 A/I/K |
专项训练的顺序建议如下。
构造题先写不变量,再写输出,再写 checker。不变量是“输出过程中始终保持的真话”,例如前缀和不出现 forbidden difference、已染色格不改变、路径端点正确。checker 不需要懂你的构造过程,只需要懂题意。宫姐做点心会先看配方,构造题也一样:不变量就是配方。
DP 题先问三件事:阶段是什么,状态保存什么,转移从哪里来。若状态里放了过多历史,就要找等价压缩;若状态里少了关键限制,就会 WA。2025 H 的 last[c] 不是装饰,而是去掉重复子序列的唯一凭证。
字符串题先问“相等关系在哪里”。前后缀用 KMP,前缀相似用 Trie/Z,任意子串相等用 hash,回文用 Manacher。不要用肉眼比较字符串,赛场上肉眼属于不稳定外设。
图论题先重命名点和边。原题里的“题目、摊位、刷新器、电话、道路”未必就是图点;一次操作、一次传播、一次选择才可能是边。若命题人给了长设定,先像初春查情报一样把对象表列出来。
数学题先写小表,再写性质,再写公式。公式写出来后必须用暴力对拍小范围。食蜂可以心理掌握,你不行;不要相信没被小数据殴打过的公式。
几何题优先保持整数运算。叉积、点积、平方距离能解决的,不要提前上浮点。浮点数一旦进入比较,就要处理误差,这会把本来清爽的题写成泥潭。
7. 五年真题考法回顾
回顾不是给题目贴标签。标签只能告诉你“这里可能有 DP”,不能告诉你“为什么这个状态够用”。本章按年份看命题重心,更重要的是看每一年如何逼迫选手从故事中取出模型。
7.1 2021:数学定义压成可计算函数
2021 的底色是数学建模。题面往往先给一个定义,再问最大、最小、计数或查询。此时第一步不是找模板,而是把定义改写成一个函数。若函数单调,二分;若函数有递推,预处理或矩阵;若函数依赖图路径,进入最短路;若函数描述局面胜负,进入博弈状态。
| 题目 | 考法入口 | 落代码的第一步 |
|---|---|---|
| A An Easy Problem | K-th 值 | 写 count(x)=有多少 i*j>=x |
| B Byfibonacci | Fibonacci 表示 | 预处理 Fibonacci,枚举/DP 表示状态 |
| E EXcellent Number | 数位计数 | 定义 pos, tight, mod, has |
| G Good Game, GG | 博弈 | 找每个数的 Grundy/奇偶贡献 |
| H History | 图最短路 + 变权 | 先确定走第几条边时代价如何变化 |
| K Kera’s line segment | 区间数据结构 | 把线段坐标压到可维护范围 |
以 A 为例,题目问第 K 大的 i*j。不要枚举乘积。对一个候选值 x,能贡献的格点数是:
count(x) = sum_{i=1..n} max(0, m - ceil(x/i) + 1)
若 count(x) >= K,说明第 K 大至少为 x;否则小于 x。这就得到二分答案。代码核心只有两个函数:count_ge(x) 和二分框架。
long long count_ge(long long n, long long m, long long x) {
long long cnt = 0;
for (long long i = 1; i <= n; ++i) {
long long need = (x + i - 1) / i;
if (need <= m) cnt += m - need + 1;
}
return cnt;
}
long long kth_product(long long n, long long m, long long k) {
long long lo = 1, hi = n * m, ans = 1;
while (lo <= hi) {
long long mid = (lo + hi) / 2;
if (count_ge(n, m, mid) >= k) {
ans = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return ans;
}
这里的边界是乘积上界和计数上界。n,m 到 1e6 时,n*m 和 cnt 都需要 64 位。若 n 更大,还要做整除分块优化;但在这类赛题边界内,先写清楚单调性更重要。
2021 年给出的训练启示是:数学定义不是拿来膜拜的,是拿来改写的。把定义改成 check、count、transition,题就有了门。
7.2 2022:等价、字符串与递推的三条线
2022 的题目更像三条线并行推进:一条是等价类计数,一条是字符串结构,一条是递推和图过程。它要求选手理解“看起来不同的对象是否本质相同”。拼图的旋转翻转相同,字符串的前后缀相同,矩阵快速幂里的下一步状态相同,本质上都在追问同一件事:哪些信息需要保留,哪些信息可以合并。
| 题目 | 考法入口 | 落代码的第一步 |
|---|---|---|
| A 拼图 | Burnside | 写出旋转/翻转下不变的边状态数量 |
| C 魔法师 | LCP/LCS | 建 Trie 与反串 Trie |
| E 黑白大陆 | 连通块 | BFS 压缩颜色组件 |
| I Rock&String | Border | 预处理 KMP/哈希支撑子串查询 |
| K 斐波那契 | 矩阵快速幂 | 设计固定维转移矩阵 |
Burnside 不要背成一句玄学公式。它的意思是:如果若干操作会把对象看成一样,那么本质不同数量等于“每个操作下保持不变的对象数量”的平均值。
answer = (fix(g1)+fix(g2)+...+fix(gk)) / k
拼图题中,操作是多边形的旋转与翻转;边状态有空、凸、凹,同时相邻边不能同时凸或同时凹。落代码时先写一个线性/环形 DP 计算固定周期下的合法染色数,再把它代入群作用。赛题边界内不需要把群论学成专著,只要会把“旋转了看不出区别”翻译成“不变方案数”。
字符串配对题的核心是把前缀和后缀分开处理。前缀用原串 Trie,后缀用反串 Trie。若题目问两串最长公共后缀,把两个串反过来就是最长公共前缀。这个反转动作简单,却是从题面到代码的桥。
def lcp(a: str, b: str) -> int:
i = 0
while i < len(a) and i < len(b) and a[i] == b[i]:
i += 1
return i
def lcs(a: str, b: str) -> int:
return lcp(a[::-1], b[::-1])
上面仍是暴力定义。正解把所有串放进 Trie,让公共前缀通过公共祖先、计数或排序相邻关系求出。训练时先写暴力,因为暴力会告诉你定义有没有读错。
2022 年最重要的训练边界:Burnside 学到旋转翻转即可,矩阵快速幂学到固定维线性递推即可,字符串学到 Trie/KMP/哈希即可。不要把主线时间送给与赛题距离太远的高级理论。
7.3 2023:长叙事里的短模型
2023 很适合训练“读题时不被背景绑架”。基站、市场、新居、旅游、经典问题、计算几何,看起来像不同世界观,代码里却会收束成区间、交易状态、相邻贡献、MST 和凸多边形直径。岁纳京子可以负责把气氛搅热,解题时你要做船见结衣:冷静,把捣乱的设定按回模型里。
| 题目 | 考法入口 | 落代码的第一步 |
|---|---|---|
| B 基站建设 | 覆盖需求 | 把“服务范围”改写成区间约束 |
| C 市场交易 | 买卖次数 | 定义持有/不持有状态或贪心差分 |
| E 新怀质问 | 字符串选择 | Trie/排序维护 LCP |
| H 流画溢彩 | 操作顺序构造 | 写交换论证和输出 checker |
| K 独立钻石 | 小棋盘搜索 | 状态压缩 + 可行性剪枝 |
| L 经典问题 | 特殊完全图 MST | 找默认边权结构,不建完全图 |
| M 计算几何 | 凸多边形直径 | 旋转卡壳求直径 |
这一年最该记住的不是某道题的题解,而是“现实包装下必有一个短模型”。比如经典问题中,完全图只是表象,普通边权 |x-y| 让点天然排在数轴上;特殊三元组才是异常。于是建模动作是分离默认结构和异常结构。默认结构用公式或压缩边表示,异常结构进 Kruskal。
计算几何题则提醒我们:题面说“多边形内部任意两点”,但凸多边形的直径由顶点对取得。一个几何结论把无限对象压成有限枚举,这就是几何题的算法化。落代码时先写 dist2、cross、旋转卡壳,再考虑枚举切线。若一上来就浮点面积、开根号、三角函数,代码会变成雾。
2023 年的训练任务是每天选一道长题,只做三件事:删背景,写变量表,写模型句。模型句必须短,例如:
基站建设:每个需求点对应一个区间覆盖限制,目标是最小代价满足所有限制。
经典问题:MST 在数轴默认边和少量特殊边中选择,不显式建立完全图。
计算几何:枚举对角线切分,子多边形直径用旋转卡壳维护。
能写出这样的句子,才算读懂。
7.4 2024:结构题的硬度
2024 的题量不大,但每道题都在考结构。结构题的特点是:你不知道那个结构,代码无从下手;你知道那个结构,代码突然变短。Border、DFS 序、Trie、边双、差分约束、倍数连通,都是这种结构。
| 题目 | 考法入口 | 落代码的第一步 |
|---|---|---|
| A 田字格 | 横竖线段关系 | 扫描线统计结构 |
| B 腊肠披萨 | 公共前后缀 | 前缀 Trie / KMP 定义校验 |
| C DFS 序 | 子树访问顺序 | 计算子树贡献并排序儿子 |
| D 拨打电话 | 电话网络 | 建 Trie,把号码前缀变成树 |
| F 图 | 边不相交路径 | Tarjan 桥,重边用边编号 |
| I 不等式 | 差分约束 | 把 x-y<=c 改成有向边 |
| J 另一个计数问题 | 数论图 | 按倍数关系分层统计 |
差分约束是这一年很好的“翻译训练”。每个不等式都要翻译成边:
x_v <= x_u + w -> u -> v, weight w
x_v >= x_u + w -> v -> u, weight -w
若要求是否存在解,检查负环;若要求最大/最小可行值,跑最短路或最长路,具体方向由建边方式决定。不要在代码里同时混用两套方向。差分约束一旦方向乱了,调试时像黑子空间移动失败,现场会很热闹,但答案不会好看。
边不相交路径则是图结构训练。桥是“所有通路都绕不过的边”,删掉桥后,同块中的点才有绕路余地。代码第一步永远是 Tarjan,而 Tarjan 第一条边界永远是重边。parent edge id 是这一题的护身符。
2024 年的训练要求:每学一个结构,都要写出它回答的问题。
KMP/Border:回答前缀与后缀相等关系。
Trie:回答多字符串公共前缀和前缀包含关系。
Tarjan 桥:回答删边后连通性是否改变。
差分约束:回答一组线性不等式是否存在可行势能。
结构不是模板的名字,而是问题的答案形式。
7.5 2025:综合建模的年度样本
2025 是最适合做主线训练的一年。它把状态压缩、构造、背包、字符串去重、事件图、周期、窄网格都放在一张卷子里。读这一年时会有一种后半场番剧全员登场的感觉:炮姐放电,黑子瞬移,初春查情报,小埋在家里打游戏,乃爱还在说自己可爱。热闹归热闹,代码仍要按秩序写。
| 题目 | 考法入口 | 落代码的第一步 |
|---|---|---|
| A 矩阵游戏 | 小列数 | 枚举列 mask,行独立取最大 |
| B 整数生成器 | 位运算构造 | 找目标数在二进制下的闭包规律 |
| E 网格染色 | 2 行构造 | 固定已染色格,维护连通块不变量 |
| F 排名预测 | 多重选择背包 | 每题生成互斥选项组 |
| H 松散子序列 | 去重 DP | total 加 last[c] |
| J 解谜比赛 | 事件图 | 优先队列处理最早解锁 |
| K 打字机 | 折返周期 | 把指针轨迹变成周期串 |
| L 路线选择 | 窄网格 | 宽度小,状态记录截面 |
这一年的核心词是“小参数”。列数小、题数小、宽度小,都是命题人给出的钥匙。小参数不是让你随便暴力,而是让你枚举后把大部分对象解耦。矩阵游戏固定列后行独立,排名预测固定题目选项后进入背包,路线选择固定宽度后进入轮廓。建模时要反复问:
枚举这个小参数后,剩下的东西是否独立?
若不独立,还需要保留什么状态?
若状态太大,有没有等价压缩?
2025 年还强调 special judge。构造题不能只输出一个“看起来对”的答案。写 checker 的习惯要像提交样例一样自然。构造的证明负责说明不变量,checker 负责确认输出没有手滑。宫姐做点心可以紧张,checker 不能紧张。
8. GDCPC 出题思路
8.1 总体风格:故事只是门,结构才是房间
近年 GDCPC 很少直接说“给你一个 DAG 求最短路”或“给你一个字符串求 Border”。它更喜欢先摆一个生活或游戏场景:封榜、端午大集、电话网络、披萨店 Wi-Fi、魔法咒语、基站建设。题面像第一集设定,算法藏在设定之后。出题人的目标不是让你背模板,而是观察你能否完成抽象。
这种风格的稳定骨架是:
故事对象 -> 数学对象
故事动作 -> 状态转移或边
故事目标 -> 优化/计数/构造/判定
故事限制 -> 复杂度边界
如果一个题面很长,不要抱怨它长。长题面往往包含很多不进代码的信息,也包含一两个极关键的约束。读题时把它们分开:不进代码的先放一边,关键约束圈出来。比如“宽度不超过 4”比“端午大集很热闹”重要;“允许重边”比“题目叫图”重要;“输出任意方案”比样例里的那条路径重要。
8.2 高频命题入口:看到什么,该想什么
| 入口 | 题面表现 | 建模动作 | 代码落点 |
|---|---|---|---|
| 小参数 | n<=15、m<=4、列数小 |
枚举小参数,检查剩余独立性 | mask DP、轮廓 DP、背包 |
| 任意输出 | special judge、输出任意方案 | 先写不变量和 checker | 构造函数 + 验证函数 |
| 最早发生 | 解锁、传播、到达 | 事件按时间排序 | 优先队列、Dijkstra 骨架 |
| 前后缀 | 密码、周期、Border | 判断相等关系 | KMP、Z、Trie |
| 大询问 | q 大,静态数据 |
预处理或离线 | Fenwick、分块、Tarjan、排序 |
| 等价类 | 旋转、翻转、本质相同 | 统计不变方案 | Burnside、组合 DP |
| 凸性 | 凸多边形、最远距离 | 找单调性 | 叉积、旋转卡壳 |
这张表不是答案,而是读题时的路标。路标只告诉你方向,真正能不能到,要看你是否写出状态和证明。
8.3 未来高概率内容:应当押什么,不应当赌什么
高概率内容仍是五类。
第一类是构造和 special judge。网格、序列、路径、操作顺序都适合出构造题,因为它们能给选手自由度,也能考证明能力。训练时要准备两件武器:不变量和 checker。
第二类是中档 DP。前缀 DP、背包 DP、状态压缩、计数去重会继续出现。它们难度适中,能拉开差距,又不会变成纯模板。训练重点不是多背状态,而是多问“为什么这个状态够用”。
第三类是字符串结构。Border、周期、LCP、Trie 是 GDCPC 很喜欢的字符串边界。复杂自动机出现概率较低,但 KMP/Z/Trie 的熟练度必须高。
第四类是图结构。桥、边双、SCC、事件图、隐式 BFS 都有命题空间。近年的图题越来越不直接,点可能是事件、坐标、集合或压缩块。
第五类是数学观察。gcd、组合数、递推、矩阵快速幂、等价类计数很适合中后档题。数学题的训练方法不是空想,而是小表、暴力、归纳、证明。
中概率内容包括计算几何、离线数据结构、树上复杂维护。它们会出现,但不应挤占基础主线。低概率但高杀伤的内容包括重型网络流、高阶多项式、复杂字符串自动机、复杂半平面交。知道它们存在即可;备考时间有限,不能把主线练级点拿去刷隐藏 Boss。
8.4 题面包装预测:壳会变,核不会
未来题面大概率仍会沿着本地化、比赛化、游戏化、生活化包装展开。广东城市、交通通信、市场交易、课程社团、旅游路线、账号密码、网格建筑,都天然适合作为题面外壳。
但壳不会改变核。看到交通,不一定是最短路,也可能是区间覆盖;看到比赛排名,不一定是排序,也可能是背包;看到字符串账号,不一定是模拟,也可能是 Border;看到网格,不一定是 BFS,也可能是轮廓 DP 或构造。
读题时可以默念这句:
它在讲什么不重要;它允许我改变什么,要求我优化什么,限制我多大规模,才重要。
这不是冷漠,这是算法竞赛的阅读方式。抚子可以先被露营的快乐感染,凛最终还是要把帐篷搭稳。
9. 三年模拟训练方向
三年模拟不是预测题面,而是训练节奏。真正的比赛不是十道独立题,也不是奔着 AK 去的冲刺表,而是一条时间线:开场读题,中段抢分,后半场判断取舍,最后检查输出。模拟卷要训练这条时间线。它服务于巩固和发展:巩固能做题的正确率,发展对新模型的识别能力。
9.1 模拟一:基础稳定卷
目标:把可拿分题拿稳,让中档 DP 和字符串题不再拖慢节奏。这里的“拿稳”不是假设全队一路顺风,而是在 ACM 赛制下减少错误提交,把已经能做的题真正变成 AC。
| 题位 | 考点 | 训练要求 |
|---|---|---|
| A | 日期/字符串格式 | 10 分钟内完成,函数拆清楚 |
| B | 数位贪心 | 写出每一位为什么可局部决策 |
| C | 排序贪心 | 写交换论证 |
| D | Fenwick | 明确维护前缀和、频次或第 k 个 |
| E | KMP Border | 手算样例 pi 数组 |
| F | 多重选择背包 | 每组只选一个,用 ndp |
| G | 事件图 | 优先队列处理首次生效 |
| H | 构造 | 先写 checker |
| I | 数论观察 | 小表到性质 |
| J | 几何扫描 | 整数化、排序化 |
这套卷的节奏应当像小埋在外面的完美形态:动作干净,不拖泥带水。前 45 分钟优先处理 A-C;第 90 分钟前至少打开 D-F 中两题;中后段用 G/H 训练图过程和构造。若 A-C 花了一个小时,说明不是算法不会,而是实现手感不稳,已经有回家变 UMR 的危险。
模拟后必须补一份记录:
A-C 总耗时:
第一道 WA 的原因:
第一道卡住的题:
是否写了 checker:
是否有一题完成对拍:
9.2 模拟二:构造与图论卷
目标:训练 special judge、桥/边双、隐式图和 MST 建模。
| 题位 | 考点 | 训练要求 |
|---|---|---|
| A | 简单构造 | 输出格式零失误 |
| B | 前缀和构造 | checker 验证 forbidden subarray |
| C | 网格构造 | 写连通块不变量 |
| D | 树上贪心 | 子树贡献和排序理由 |
| E | 隐式图 BFS | 状态哈希,避免重复 |
| F | Tarjan 桥 | 重边用边编号 |
| G | 边不相交路径 | 路径 checker |
| H | MST 建模 | 不显式建完全图 |
| I | 离线图查询 | 标清询问维度 |
| J | 小流/匹配 | 只训练赛题范围 |
这套卷要训练“输出不唯一时如何不慌”。先写 checker,再写构造;先写判断,再写输出。构造题不必优雅,但必须可证。若一个构造需要靠样例来确认,那它还没成型。
图题的复盘重点是点和边是否重命名。比如事件图里,原题点不一定是图点;隐式 BFS 里,一个坐标加状态才是点;MST 建模里,完全图不一定真的存在于代码中。初春查情报时会先把资料分门别类,图题也要这样。
9.3 模拟三:数学与压轴卷
目标:训练后半场判断力。不是每个压轴都要现场做完,但每个压轴都要能判断它的入口在哪里。
| 题位 | 考点 | 训练要求 |
|---|---|---|
| A | 数论小观察 | 小表验证 |
| B | 组合数 | 预处理上下界正确 |
| C | 矩阵快速幂 | 状态向量维度明确 |
| D | Burnside | 写出群作用和 fix |
| E | 字符串周期 | KMP/Z 二选一 |
| F | 期望 | 尾和公式或状态方程 |
| G | LIS/LDS 偏序 | 坐标压缩和 rank 状态 |
| H | 窄网格 DP | 宽度状态合法性 |
| I | 凸多边形几何 | 旋转卡壳 |
| J | 区间图离线 | 桥/连通性查询 |
这套卷不要追求全做。压轴题存在的意义之一就是制造取舍,它训练的是后半场的清醒:哪题值得继续,哪题应该放下。不会做的题要留下“入口句”,例如:
Burnside:旋转 r 步后,边状态按 gcd(n,r) 个环重复。
期望:答案写成 sum P(T>t),概率用组合数。
轮廓 DP:按行推进,状态保存当前列的占用/连通信息。
写不出入口句,说明题没有读透;写得出入口句但不会做,说明可以赛后补。两者要分清。
9.4 模拟执行法:五小时的时间线
每套模拟建议 5 小时。不要只统计 AC 数,还要统计节奏是否崩。若训练环境是 OI 或 OJ,可以把 5 小时拆成多次专题训练,但仍要保留完整的读题、建模、实现、自测链条。
0:00-0:20 读全题,给每题写入口词
0:20-1:20 处理签到和确定性基础题
1:20-2:40 攻中档题,优先做模型清楚的
2:40-3:40 处理构造/图/DP 中最有希望的一题
3:40-4:30 冲一题难题,写出可提交或可放弃的证据
4:30-5:00 自测、修输出、提交低风险补丁
切题规则要提前写好。任意题 25 分钟没有形成模型句,切;形成模型句但 40 分钟没有代码骨架,切;代码写完样例不过且 20 分钟定位不到错,保留现场,切。赛场不是修行场,不能因为傲娇学生会式的不甘心把整场节奏拖死。OI/OJ 训练可以延长单题思考时间,但也要记录“卡住时缺的是模型、代码还是边界”,否则长时间只会变成低效熬夜。
赛后复盘按四栏:
题目:
卡点:读题 / 建模 / 代码 / 边界 / 心态
下次识别句:
补救动作:模板 / 对拍 / checker / 重写
复盘不是写忏悔录。它只负责把下一次的动作变短。
10. 训练方法与检查表
10.1 解题记录格式
每道题记录:
题目:
规模:
模型:
状态/数据结构:
复杂度:
关键证明:
错因:
可复用代码:
10.2 提交前检查
提交前检查不需要写成长篇,只要按固定顺序扫一遍。先确认输入总和限制是否读对,多测数组是否清空,下标是 0-based 还是 1-based。再确认数值范围:是否需要 long long 或 __int128,取模减法是否归正,INF 是否足够大。图题要检查重边、自环和非连通,Dijkstra 要确认没有负边,Tarjan 要确认父边用边编号。special judge 输出必须满足所有硬条件;Python 递归深度和输入速度要提前处理;浮点题输出足够精度。
10.3 日常训练标准
基础题的标准是 15 分钟内完成,不看题解,代码一次过样例。它们训练的不是智力上限,而是手感和稳定性。
中档题的标准是 60 到 90 分钟内完成,并且必须写出复杂度与证明。至少造 3 组边界样例,最好能写一个小暴力对拍。中档题是能力增长最快的区域,不要只看题解后说“我懂了”。
困难题允许读题解,但必须复现关键转化。若题目是构造或计数,补 checker 或暴力;若题目是图结构,补最小反例;若题目是几何,补整数边界。困难题的价值不在于当场 AC,而在于把那一个关键转化变成下次能识别的信号。
ACM、OI、OJ 的训练记录可以共用同一格式,但侧重点不同。ACM 记录时间线、提交前检查和队内分工;OI 记录子任务、部分分做法和正解差距;OJ 记录错因、反例和重写时间。三者最后都回到同一件事:让下一次从题面到代码的路径更短、更稳。
ACM 训练要把“错误提交”当成高成本事件。每次 WA 都要记录是读题漏条件、模型少状态、实现越界、取模错误,还是 checker 缺失。OI 训练要保留分层思维:先写暴力,拿到小规模正确性,再观察哪些子任务需要优化;这种习惯反过来能帮助 ACM 选手在难题上快速找到可验证的中间模型。OJ 日常训练最适合补模板和反例库,同一类题不要只 AC 一道就离开,而要把边界样例、对拍脚本和可复用代码一起归档。三种环境互相补强:ACM 给压力,OI 给层次,OJ 给重复。
10.4 考前保底策略
- 前 20 分钟读全题,标记签到和熟悉模型。
- 前 90 分钟优先处理低风险题。
- 中场集中攻一个 DP、一个图或一个构造。
- special judge 题优先写 checker。
- 任何题卡 40 分钟无进展,切题。
- 最后 30 分钟只做低风险修改。






























