1. 为什么刷历年真题一定要配一套本地流程
每年固定举办、题目质量在圈内口碑一直很好的那项年度程序设计赛事,它的历年真题是很多人算法路上绕不开的训练素材。题目风格稳、考点覆盖面广、难度梯度均匀,从签到题到压轴题都齐全,适合拿来练手。但真正决定你能不能把它刷出效果的,其实取决于一套配套的本地流程。所谓本地流程,就是从环境搭建、写代码、编译调试,到用小数据对拍验证、用大数据测性能这一整套动作。只做题不验证,等于把真题当阅读理解做,看完题解觉得“懂了”,赛场上照样写不出来或者写出来也是错的。
我见过太多这样的例子:题库里 AC 数量刷到几百,一到正式比赛就卡在第二题。问题往往不出在算法知识上,出在“写出来的代码没有经过严格验证”这个环节。平时做题有在线判题告诉你对错,那只是最终结果;你并不知道自己错在哪一组数据、为什么错,甚至不知道自己这次没出错是因为数据太弱。而历年真题恰恰是那种“看上去简单、暗坑特别多”的类型,尤其是计几、图论建模、多组数据输入这些题,边界问题非常密集。
这套流程适合谁?刚接触算法竞赛、准备开始系统刷真题的同学肯定用得上;正在准备校赛、区域赛,想把手感提上来的选手也用得上;哪怕是工作几年想捡回算法基本功的开发者,同样能用它来自学。整个过程对语言没有硬性依赖,我用 C++ 写得比较多,但换成其他语言,思路完全一样。下面我会把每一步拆开讲,包括我实际踩过的坑、参数该怎么定、脚本该怎么写,让你看完就能照着搭一套。
1.1 三种刷题姿势的差距到底在哪
把刷题行为分个类,大概能分成三种,效果差得非常远。
第一种是“看题解型”:读完题,想三五分钟没思路,直接翻题解,看完觉得有道理,抄一遍代码提交 AC,走人。这种刷法的收益极低,因为算法思想的迁移能力是在“卡住—尝试—失败—再尝试”这个过程里建立的,抄来的代码只会给你一种虚假的掌握感。
第二种是“闭门造车型”:坚决不看题解,一道题卡三天,最后要么放弃要么写了个错的自己还不知道。这种也不好,时间成本太高,而且遇到自己完全没接触过的算法方向,硬想是想不出来的,纯属浪费时间。
第三种是“闭环型”:先独立思考 30 到 60 分钟,有明确思路后动手写,写完先过样例,再过自己构造的边界数据,然后写暴力程序对拍,最后测一下大数据下的耗时。如果这时还过不了,再看题解,重点看自己卡在思路还是卡在实现。我实践下来,第三种方式的单题收益是第一种的五倍以上,而且留下的记忆特别牢固。这套流程的核心成本其实很低,环境搭一次,脚本写一次,后面每道题只多花十分钟,但这十分钟换来的东西非常值得。
1.2 在线判题给不了你的三样东西
在线判题平台是刷题的必需品,但它有三件事做不了,而这恰恰是本地环境的价值所在。
第一是过程可见。线上只告诉你 WA、TLE 还是 RE,不告诉你在第几组数据上挂的。你在本地跑,可以打印中间变量、可以单步调试、可以看自己的程序在哪一步开始偏离预期。特别是那种“样例过、交上去 WA”的题,没有本地调试能力,你只能瞎改。
第二是数据可控。线上的测试数据你拿不到,也不能自己造一组极端数据去试。本地你可以随手构造一个 n=1 的、全是相同元素的、或者刚好卡在某个阈值上的数据,看程序会不会崩。这种能力在排查边界 bug 时是决定性的。
第三是时间可控。线上编译排队、提交限流,一次反馈可能要等几十秒;本地编译一秒内完成,改一版代码马上能看到结果。把一天里等待反馈的时间省下来,多做两三道题完全没问题。而且本地跑大数据还能直接看到秒数,比线上“TLE 或 AC”的二元反馈精细得多。
1.3 一条完整的刷题闭环长什么样
我把自己的流程画成一条线,你可以对照看自己的流程缺了哪一段。
第一步是读题,反复读三遍,把输入格式、输出格式、数据范围、多组数据与否、有没有特殊约定全部抠清楚。第二步是估算复杂度,用数据范围反推应该用哪个量级的算法。第三步是写实现,同时把暴力解的思路也在心里过一遍。第四步是样例验证,官方样例必须全过。第五步是构造边界数据自测,比如最小规模、最大规模、全相同、单调递增递减。第六步是对拍,跑几百上千组小数据。第七步是性能测试,造一组最大规模数据看耗时。第八步是复盘,把这道题的新知识点和踩过的坑写进笔记。
这八步里,第三到第七步是纯技术活,也是最容易偷懒的部分。很多人做到第四步就提交了,结果是靠平台的弱数据蒙混过关,到了正式比赛原形毕露。下面几章我会把第二、三、四、五、七步的实操细节全部展开。
2. 环境搭建:把编译、运行、调试压缩到一次按键
环境搭建这件事,很多人觉得是“装个编译器就完事了”,其实差别很大。一套顺手的本地环境能把“改一版代码看结果”的周期从三十秒压到三秒,这个差距在连续调试时会非常明显。我见过有同学每次改代码都要手动敲一长串编译命令,一天下来光敲命令就废了。这一章讲清楚编译器怎么选、参数怎么加、目录怎么组织、模板怎么写。
2.1 编译器与编译参数的门道
C++ 选手基本都是 GCC 系,Windows 上一般用 MinGW 版本,Linux 和 macOS 自带或者用包管理器装。版本建议 9 以上,能支持 C++17。历年真题里大量题目的标程都是按 C++14 或 C++17 写的,用太老的编译器可能连语法都不支持。
编译参数的配置是重头戏,我给一套我自己长期用的:
g++ -std=c++17 -O2 -Wall -Wextra -Wshadow -o a a.cpp逐个拆解一下为什么这么加。-std=c++17是标准版本,避免用了新特性本地过、线上不过的问题,如果赛方明确用 C++14,本地就同步改成 c++14,标准版本一定要和评测环境一致,这是我吃过亏的地方:本地用 C++17 的结构化绑定写得很爽,赛场上编译器是 C++11,直接 CE。
-O2是优化等级。这里有个取舍:不加优化,本地跑大数据可能超时,你分不清是算法问题还是没优化;加了优化,某些未定义行为(比如数组越界、有符号溢出)可能被优化掉,导致本地不崩线上崩。我的做法是调试阶段不加 -O2,测性能时再加。你可以准备两套编译命令,一个叫dbg,一个叫run,用别名或者脚本区分。
-Wall -Wextra打开常见警告,这个太重要了。它会提示未使用变量、有符号无符号比较、可能未初始化、函数缺少返回值。上面这些警告里有相当一部分直接对应线上 WA 或 RE 的成因。-Wshadow更狠一点,会警告变量遮蔽,比如你在全局定义了n,函数里又写了int n,它能揪出来。这类 bug 找起来极其痛苦,让编译器帮你找最划算。
如果你用递归写深搜,还得注意栈空间。Linux 下默认栈一般是 8MB,深度几十万的递归会直接爆栈。可以临时用ulimit -s unlimited放开,但要注意这只影响本地,线上该爆还是爆,所以根本解决办法是把递归改成手写栈或者加记忆化剪枝。
2.2 目录与文件命名约定
目录结构看起来是小事,刷到第五十道题的时候你就知道重要性了。我用的结构是这样:
algo/ ├── tasks/ │ ├── 2023-r1-a/ │ │ ├── main.cpp │ │ ├── brute.cpp │ │ ├── gen.cpp │ │ ├── stress.sh │ │ └── notes.md │ └── 2023-r1-b/ ├── template/ │ └── tmpl.cpp └── scripts/ └── newtask.sh每个题目一个独立目录,四个文件固定:main.cpp是你的解法,brute.cpp是暴力对拍程序,gen.cpp是数据生成器,stress.sh是对拍脚本。目录名用“年份-轮次-题号”的格式,一眼能看出是哪个题,不用回忆。
这个约定的价值在于降低对拍的启动成本。如果每道题都要重新想一遍脚本怎么写、文件叫什么,对拍这件事你坚持不了三天就会放弃。固定下来之后,新建目录就是一个命令的事,脚本甚至可以直接从上一题复制。
2.3 编辑器配置:一键编译运行
编辑器用什么不重要,重要的是能不能做到“按一个键编译并运行”。我用的是轻量编辑器配外部命令的方案,也可以直接在编辑器里配构建任务。核心是三条命令:
# 编译 g++ -std=c++17 -O2 -Wall -Wextra -o a a.cpp # 运行(带输入文件) ./a < in.txt # 运行(交互式输入) ./a把前两条绑到快捷键上,改完代码按一下,编译错误直接显示在终端里,看都不用切窗口。如果你习惯用图形化 IDE,把这套命令配到它的运行配置里也一样。
还有一个细节:输出重定向。调试大数组时终端会刷屏,把输出写到文件里再用 diff 对比更方便。我平时习惯用./a < in.txt > out.txt,然后用编辑器的文件对比功能看差异。这个习惯在调格式类题目(比如要求输出空格分隔的数字)时特别有用,因为肉眼很难看出行末多了个空格。
2.4 代码模板里该放什么,不该放什么
模板的作用是省掉每题都要敲的头文件、输入输出、常用小工具。但模板也容易写成“全家桶”,什么都往里塞,结果编译变慢、变量名冲突、用不上的一堆东西干扰阅读。我的原则是只放高频且低耦合的东西。
必放的:头文件区(algorithm、vector、string、iostream 等常用几个)、using namespace std、using ll = long long、快速读入函数(格式化输出题目多的时候有用)、一个极简的主函数框架。
不放的:任何具体算法实现。并查集、线段树、最短路这些,每题现场敲,敲多了自然快,而且现场敲能根据题目特点调整,比直接套模板更不容易出错。把大段算法塞进模板的后果是比赛中手生,模板里的函数写错一个下标上界,你连从哪查都不知道。
还有一个小技巧:模板里留一段带#ifdef LOCAL的调试输出代码块,本地编译时加-DLOCAL打开,交题时自动失效,不影响性能也不影响输出。
#include <bits/stdc++.h> using namespace std; using ll = long long; const int MAXN = 1e5 + 5; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int T; cin >> T; while (T--) { solve(); } return 0; } void solve() { // 实现 }注意上面solve()写在了调用之后,这是刻意示范的常见错误类型——严格来说这里应该先声明,真实使用时别照抄。主函数里默认处理多组数据的框架,是我刷历年题的强烈推荐做法,因为很多题目输入格式带 T,如果你平时不练这个结构,赛场上临时想起来容易漏掉清空全局数组。
3. 从题目到代码:把“想清楚”翻译成写对
写完环境只是开始,真正的分水岭在于能不能把思路踏实落地。这一章讲怎么用数据范围定算法方向、哪些坑点必须提前防、调试输出该怎么写。
3.1 用数据范围反推复杂度目标
这是老手和新手最明显的能力差异之一。新手拿到题先想“这题用什么算法”,老手先看数据范围。数据范围直接告诉你可行复杂度的上限,等于给你圈定了搜索空间。
| 数据规模 n | 时间限制 1s 下的可行复杂度 | 常见对应算法方向 |
|---|---|---|
| n ≤ 10 | O(n!) | 全排列枚举 |
| n ≤ 20 | O(2^n · n) | 状压 DP、子集枚举 |
| n ≤ 40 | O(2^(n/2)) | 折半搜索 |
| n ≤ 100 | O(n^4) | 区间 DP、Floyd |
| n ≤ 500 | O(n^3) | 三元环、Floyd、区间 DP |
| n ≤ 5000 | O(n^2) | 普通 DP、暴力枚举对 |
| n ≤ 1e5 | O(n log n) | 排序、二分、树状数组、线段树、Dijkstra |
| n ≤ 1e6 | O(n) 或小常数 O(n log n) | 线性 DP、单调队列、前缀和 |
| n ≤ 1e9 | O(log n)、O(sqrt n) | 快速幂、二分数位、质因数分解 |
| n ≤ 1e18 | O(log n) | 矩阵快速幂、数位统计 |
这张表的用法是这样的:看到 n ≤ 1e5,你就要排除 O(n^2) 的直接枚举,同时也不用去想 O(n) 的什么神奇技巧,目标就锁在 O(n log n)。看到 n ≤ 20,八成是状压。有了这个约束,你在一张图或一个 dp 状态设计上纠结的时候,可以快速判断“这个设计会不会超时”,避免花半小时写了个必然 TLE 的做法。
反过来,如果一道题你想到的做法复杂度刚好卡在可行范围边缘(比如 n=1e5,你想到 O(n sqrt n)),那就要格外注意常数优化,并且本地必须实测最大数据。这就是后面性能测试存在的意义。
3.2 高频翻车点清单
下面这些是历年真题里反复出现的坑,我按出错频率排了序,刷题时挨个对照检查一遍。
第一,整数溢出。题目里说答案可能很大,你就得全程用 64 位。容易漏的是中间量:两个 1e9 的数相乘,结果存 int 就爆了,但最终答案取模后很小,看起来没问题。我的做法是模板里直接using ll = long long,涉及乘法的中间量全部用 ll,然后心里默念一句“凡是乘法先想会不会爆”。无符号数更要小心,unsigned和int混合运算结果会隐式转换,出现负数会变成巨大的正数,这种 bug 极难发现。
第二,多组数据不清空。带 T 的题目,每轮之间必须把上一轮的全局数组、计数器、邻接表清零。典型的漏网之鱼是head[]、cnt、vis[]。我的习惯是所有全局状态集中写一个init()函数,每轮开头调一次,写完立刻检查有没有遗漏某个新加的数组。
第三,数组开小。无向图存边要开 2 倍边数;线段树要开 4n;字符串拼接后的长度是原来两倍;离散化后的下标从 1 开始时数组要开 n+1。这些数字记不住没关系,养成习惯:凡是开数组的地方都多想一句“最大可能用到多少”。
第四,递归爆栈。深搜深度上万就要警惕了,尤其是带权并查集的路径压缩、树上的 DFS、Tarjan 求强连通。本地不一定崩,评测机栈空间更小就容易崩。解决办法是能迭代就迭代,或者减小递归深度。
第五,sort 比较函数不满足严格弱序。写cmp(a, b) { return a.x <= b.x; }这种带等号的比较,一旦有相等元素,某些实现下可能越界崩溃。标准写法是严格小于。这个 bug 在 Linux 和 Windows 下表现不同,经常是本地好好的、线上 RE。
第六,输出格式。行末多余空格、答案之间多空行、最后一组数据后不该换行却换了,这类格式错在历年题里很常见。本地自测时要专门用diff对着标准答案看,别用眼睛扫。
3.3 调试输出的正确姿势
调试输出写得好,能省掉一半排查时间。我的三个原则:
一是用 cerr 不用 cout。cerr 不缓冲,崩溃时最后的输出还在;而且它和 cout 分开,不会污染正常输出,方便重定向对比。
二是用条件编译包裹。所有调试代码包在#ifdef LOCAL里,交题时不用手工删一段段注释,容易漏删。
#ifdef LOCAL #define dbg(...) fprintf(stderr, __VA_ARGS__) #else #define dbg(...) #endif三是输出带上下文。别只打印一个数字,要打印“这是第几轮、哪个变量、值是多少”。比如dbg("round %d, dp[%d] = %lld\n", t, i, dp[i]);。排查时不带上下文的裸数字等于白打。
另外提一句assert。写复杂数据结构时,用assert(l <= r)、assert(0 <= idx && idx < n)把不变量断言出来,一旦违反立刻停止并打印行号。这比静默地算出一个错答案然后你花一小时反推要高效得多。当然,交题前要确认assert在关闭NDEBUG时会被保留——默认情况下加了-DDNDEBUG才会失效,不加的话线上遇到非法状态会直接 RE,这反而是好事,能暴露问题。
4. 对拍验证:四件套的完整实操
对拍这件事,是第一梯队的刷题技巧。它解决的是“我怎么知道自己对不对”这个根本问题。原理很简单:你有一个高效解法,正确性存疑;你还有一个暴力解法,虽然慢但逻辑一目了然,正确性几乎确定。让两者跑同一批小规模数据,比对输出。这一章把完整实操讲透。
4.1 对拍的原理与前提条件
对拍成立的前提有两个。第一,你的暴力解必须是显然正确的。它可以慢到 O(n^3),但逻辑必须直白到不需要证明,比如“枚举所有点对”“枚举所有子集”“直接模拟每一条指令”。如果暴力解本身就很绕,那对拍失去意义——两个错的程序对出一样的结果也很正常。
第二,数据规模必须小。对拍的价值在于可以跑成百上千组,所以单组数据要小到暴力解毫秒级完成。一般把 n 控制在 5 到 10 之间就够用,因为大部分逻辑错误在小数据上就会暴露。如果小数据全过、大数据挂,那说明问题出在复杂度或者大数组越界上,这时候对拍帮不上忙,得靠性能测试和边界构造。
必须说清楚一点:对拍跑过不代表你的代码正确。它只能证明你在生成器覆盖到的数据形态上没出错。如果你的生成器只会生成随机数据,而错误只出现在“所有元素相同”或者“恰好成一条链”这种特殊结构上,对拍是抓不到的。这一点后面会专门讲怎么补救。
4.2 暴力程序怎么写才敢信
写暴力有一个反直觉的原则:不要复用你主程序的任何函数,哪怕只是一行输入输出。从零开始敲,独立实现,宁可代码丑、跑得慢、写得笨。原因很直接——如果你从主程序复制了一个辅助函数,而那个函数本身有 bug,两份程序会一起错,对拍就白做了。
暴力程序的典型写法有几种:枚举所有候选答案取最优、模拟每一步操作、用最朴素的递归搜所有可能。比如一道求最短路的题,暴力就用 Bellman-Ford 反复松弛 n 次;一道求区间最大值的题,暴力就每个询问从 l 遍历到 r;一道计数题,暴力就枚举所有子集统计。
暴力程序的输入输出格式必须和主程序完全一致,包括多组数据的处理方式、换行、空格。这是新手最容易翻车的地方:主程序读 T 组数据,暴力只读一组,那么对拍出来的所有结果都会不一致,你以为是主程序错了,实际上是暴力读错了输入。
小技巧:暴力程序里可以加一点“检查不变量”的 assert,比如算出来的答案不会超过输入规模,这样暴力本身出问题时能立刻发现。
4.3 数据生成器的设计要点
生成器是整套对拍里技术含量最高的一环,也是决定对拍有效率的关键。
第一版生成器通常长这样:用一个随机种子,用mt19937生成随机数,用uniform_int_distribution限范围。一定要用种子做参数,保证同一组失败数据能复现,这一点是重中之重的。很多新手写的生成器用srand(time(0)),对拍挂了你却永远复现不了那组数据,只能干瞪眼。
#include <bits/stdc++.h> using namespace std; int main(int argc, char* argv[]) { int seed = argc > 1 ? atoi(argv[1]) : 0; mt19937 rng(seed); int n = rng() % 8 + 1; printf("%d\n", n); for (int i = 0; i < n; i++) { printf("%d ", (int)(rng() % 20) + 1); } printf("\n"); return 0; }第二版生成器要根据题目结构做“参数化”。比如图的题目要能控制点数、边数、有没有重边、是不是连通、边权范围;字符串题目要能控制字符集大小(全 'a' 和随机字母是两种完全不同的数据形态);序列题目要能生成单调、全等、随机、锯齿几种形态。
我一般的习惯是给生成器加一个mode参数,根据 mode 走不同分支:
| mode | 数据形态 | 用途 |
|---|---|---|
| 0 | 完全随机 | 覆盖一般情况 |
| 1 | 极小规模(n=1 或 2) | 抓边界崩溃 |
| 2 | 全部元素相同 | 抓去重、相等比较逻辑 |
| 3 | 严格单调递增 | 抓极端结构 |
| 4 | 随机但存在大量重复 | 抓哈希、计数 |
| 5 | 链状/星形结构 | 图论题抓退化情况 |
这个设计花不了多少时间,但能把对拍的命中率提高一大截。一个成熟的做法是让对拍脚本轮换 mode,每个 mode 跑几十组。
4.4 一键对拍脚本的完整实现
Linux 下用 bash,Windows 下用 bat 或者干脆装个类 Unix 环境。下面是一个我常用的 bash 版本,逐行解释。
#!/bin/bash # 用法: ./stress.sh 起始种子 结束种子 g++ -O2 -std=c++17 -o main main.cpp || exit 1 g++ -O2 -std=c++17 -o brute brute.cpp || exit 1 g++ -O2 -std=c++17 -o gen gen.cpp || exit 1 for ((i=$1; i<=$2; i++)); do ./gen $i > in.txt ./main < in.txt > mine.txt ./brute < in.txt > std.txt if ! diff -q mine.txt std.txt > /dev/null; then echo "FAIL at seed $i" cp in.txt fail_$i.txt echo "输入已保存到 fail_$i.txt" echo "--- 期望输出 ---" head -5 std.txt echo "--- 实际输出 ---" head -5 mine.txt exit 1 fi if [ $((i % 50)) -eq 0 ]; then echo "已通过 $i 组" fi done echo "全部通过: $1 ~ $2"几点说明。第一行三个编译命令,如果任何一个编译失败直接退出,避免拿旧的可执行文件跑。diff -q只比较是否相同,不打印差异,配合重定向到/dev/null保持输出干净。发现不一致时立刻保存输入文件,这是最重要的——有了这组数据,你可以在主程序里加调试输出,慢慢定位问题。最后那条进度打印是为了让你知道脚本还在跑,不是卡死了。
对于浮点输出的题目,diff直接比较会失败,因为浮点误差。这时候要换比较方式,可以用一个专门的比对程序,读两行浮点数,判断相对误差是否在 1e-6 以内。写一个小 C++ 程序大概二十行,或者用现成的脚本语言,都比手工看强。
还有一个小优化:把main和brute的编译移到循环外,只在脚本开头编译一次,否则每跑一组都编译一遍,慢得离谱。我一开始就犯过这个错,跑一百组花了五分钟,后来才发现问题。
4.5 对拍挂了之后怎么复现和定位
拿到失败数据之后,标准操作是三步。
第一步,确认是不是暴力错了。把失败输入手工跑一遍,或者肉眼看一看,判断主程序和暴力哪个更可信。有时候暴力写得太笨,比如没考虑 0 下标,结果反而它错了。
第二步,在主程序里挨个关键节点打输出,和手工推算的结果对照,找到第一个开始不对的位置。这个位置之前的所有逻辑都是对的,问题就出在那一两步。这种二分定位法比从头到尾读代码高效得多。
第三步,如果问题涉及算法结构而不是单点计算,把数据规模再缩小(比如从 n=8 缩到 n=3),手工走一遍完整流程,状态的变化一步一步写下来。规模小到能手工算清楚的时候,错误藏不住。
最后提醒一句:修完 bug 之后,一定要重跑同样的种子范围,确认原来失败的那组数据现在过了,而且没有引入新的失败。有时候改了一个地方修好了 A 组数据,却让 B 组数据挂了,这叫修修补补式调试,只有重跑才能发现。
5. 常见问题与排查实录
对拍跑通只是完成了正确性验证,实际提交时还有一堆别的问题。这一章按“症状—原因—排查手段”的格式整理,都是我自己和身边人反复踩过的。
5.1 对拍全过但提交挂掉
这是最让人心态崩的情况,但对拍全过不代表没问题,常见原因有四类。
数据覆盖不够。生成器只会造随机数据,而错误只出现在特殊结构上。补救办法就是上一节说的参数化生成器,覆盖全相同、严格单调、边界极小、链状这几种形态。
数组开小了。小规模数据下越界访问恰好落在空闲内存里,没崩也没错;大规模数据下越界踩到别的变量,结果就变了。这类问题对拍永远抓不到,只能靠养成“每个数组都想想最大需要多少”的习惯。
整数类型不对。小数据下乘积不溢出,大数据下溢出。这个也抓不到,得靠数据范围反推:只要题目里出现了 1e9 这个量级,涉及加法和乘法的地方就全换 64 位。
复杂度问题。小数据跑得飞快,大数据 TLE。这就是需要性能测试的原因。
排查顺序建议是:先本地造最大规模数据测时间,再看数组大小,再检查整数类型,最后回头看算法本身有没有遗漏的情况。
5.2 运行时错误与编译错误速查
下面这张表是我整理的常见报错对照表,遇到问题时可以快速定位方向。
| 症状 | 最可能的原因 | 快速排查手段 |
|---|---|---|
| RE 且无任何输出 | 数组越界、空指针、除零 | 开-fsanitize=address编译重跑 |
| RE 且程序中途停住 | 递归爆栈 | 减小递归深度或改迭代 |
| RE 只在个别用例 | sort比较函数非严格弱序 | 检查所有比较函数 |
| CE 提示某函数不存在 | 标准版本不匹配 | 对齐评测环境的-std |
| WA 但样例全过 | 多组数据未清空、溢出 | 手动构造多组数据测试 |
| WA 且只差小数 | 浮点精度 | 改用long double或加容差比较 |
| TLE 且算法复杂度正确 | 常数过大、memset大数组 | 用时间函数定位到具体语句 |
| TLE 且复杂度明显超 | 算法方向错了 | 回到数据范围重新推复杂度 |
这里特别说两个。-fsanitize=address是一个非常好用的工具,编译时加上它,程序运行时会自动检查越界访问、内存泄漏、使用未初始化内存。它的代价是运行变慢、内存占用变大,所以只适合在小数据上抓 bug。很多“本地不崩线上崩”的诡异问题,用它一跑就现原形,强烈建议加进你的调试工具集。
另一个是memset清空大数组。当数组是 1e7 级别,而你有 1e5 组数据时,每组都memset一遍直接 TLE。解决办法是记录修改过的位置,用完逐点复原,或者改用时间戳标记法。这类问题不实测大数据的多组数据,你根本发现不了。
5.3 性能测试与常数优化
性能测试的做法很简单:造一组最大规模数据,直接用time命令跑。
g++ -O2 -std=c++17 -o main main.cpp ./gen 1 > big.txt # 生成最大规模数据 time ./main < big.txt > /dev/null输出会给出 real、user、sys 三个时间,看 user 时间最准。一般经验是:本地耗时控制在时间限制的三分之一以内比较安全,因为评测机性能可能有波动,加上不同编译器的常数差异,留够余量才稳。
如果本地耗时贴着限制,有几个常规优化方向。输入输出方面,ios::sync_with_stdio(false)和cin.tie(nullptr)是必须的,或者直接换快速读入。数据结构方面,把vector的push_back预留空间、避免频繁扩容;用数组代替动态容器;减少无意义的拷贝,函数参数大对象用引用。算法常数方面,unordered_map在特定数据下会被卡,可以换排序加二分或者手写哈希;bitset在需要固定范围枚举时常常有奇效。
我得说一句实话:如果本地耗时超过时间限制,别急着优化常数,先回头检查算法复杂度是不是真的对。常数优化最多帮你去掉两三倍,复杂度错了是几十倍的差距,方向比努力重要。
6. 训练节奏与赛前检查
工具和流程都有了,最后讲讲怎么把它变成日常习惯。这一章不是方法论套话,是我自己实践下来觉得确实有用的几个做法。
6.1 一道题练三遍
我的习惯是同一道题至少过三遍。第一遍是正常流程,从读题到对拍全程走完,这一遍耗时最长,可能一两个小时。第二遍是隔两三天后重做,不看之前的代码,只看题目,目标是 20 分钟内写完并一次通过。第三遍是隔一周后,只看题目标题,能否在 5 分钟内说出核心思路和关键坑点。
这三遍的收益是递增的。第一遍学的是“怎么做”,第二遍练的是“写得快”,第三遍确认的是“真的记住了”。很多人刷题的效率低,是因为只做了第一遍,然后就去刷下一道了,结果两周一过全部遗忘,题库 AC 数量很多,实际能力原地踏步。
6.2 复盘笔记怎么写
每个题目目录下的notes.md只写三样东西,多了没必要。
第一是核心转化:这道题的关键一步是什么,比如“把区间和问题转化成前缀和的差”“把图的最优路径问题转化成最小生成树的路径最大值”。这一句是整个题目最值钱的地方,写清楚它,以后遇到同类题能瞬间联想到。
第二是踩过的坑:具体到自己写错的那个点,比如“多组数据忘了清空 vis 数组”“下标从 1 开始时数组开成了 n”。“写错了某个算法”这种表述太笼统,没有复盘价值。
第三是对拍抓到的失败样例:把那个fail_xxx.txt的数据形态描述一句,比如“全是相同元素时我的去重逻辑错了”。这些样例积累多了,就是你的私人题库。
6.3 赛前环境检查清单
正式比赛前一天,花十分钟把下面这张清单过一遍,能避免很多低级事故。
| 检查项 | 确认内容 |
|---|---|
| 编译器 | 版本号、-std参数与赛方一致 |
| 编译命令 | 保存成脚本或别名的都测试一遍 |
| 模板文件 | 放在能快速打开的位置,主函数结构完整 |
| 对拍脚本 | 至少准备一份,能直接改成新题 |
| 常用复杂度表 | 打印或记在纸上,方便快速定位 |
| 栈空间设置 | 如果允许,确认递归深度上限 |
| 输出格式 | 复查行末空格、空行、多测试用例分隔 |
还有一个非常实用的建议:准备一份“解题检查表”,赛场上每写完一道题,提交前对着扫一遍。内容包括:数据范围里有没有 1e9 以上的量需要 64 位、是不是多组数据、全局数组有没有清、无向图边数组有没有开两倍、输出的格式对不对。这五条能覆盖我统计里七成以上的非算法性错误。比赛时心跳加速、思路发散,靠检查表比靠记性靠谱得多。
我个人在实际操作中的体会是,前面这些流程搭起来的那一周确实有点麻烦,各种脚本调不通、生成器写得不对、对拍反而抓出一堆自己没意识到的错,挫败感挺强。但过了那个坎之后,做题的手感和正确率完全不一样了。最明显的变化是提交前心里有底,那种“希望这次能过”的赌博感少了很多,取而代之的是“这题我大概率是对的,如果挂了我能很快找到原因”的确定感。这种感觉,比多刷一百道题更值钱。