寒假积分赛(一)这场打完,我盯着屏幕上的排名愣了一会儿:五道题里,两道签到级别的题我各挂了三次,罚时直接把我从中游压到了下游;一道 BFS 我把方向数组的dy写成了dx,样例过了提交就 WA;还有两道题我连题目想问什么都没读明白。这种感觉打过训练赛的人都懂,就是那种"题不难,但我就是没做出来"的憋屈。第二天早上八点,我坐下来开始补题,一直补到第三天晚上才把这五道题全部拿下,顺便把两处知识盲区补上了。补题这件事,从来不是"把题解抄一遍"这么简单,它是把一场比赛暴露出来的所有漏洞,一条一条缝回自己的知识树里。下面这份记录,我会把这场积分赛的赛制逻辑、题单分层方法、A 到 G 七道题的真实思路和代码、以及那几天踩过的坑,按我复盘的顺序完整拆开讲。适合刚学完语法、想进算法竞赛门的新手,也适合已经打过几场、卡在"会做但过不了"这个阶段的同学。
1. 积分赛补题的底层逻辑:为什么"补"比"打"更重要
我第一次参加积分赛的时候,想法特别朴素:打就完了,打完看排名。结果连打三场,排名没什么变化,因为每场我犯的错都是同一批——读入没处理好、数组开小、边界没判、复杂度算错。真正让我水平往上走的,是第四场之后开始认真补题那段时间。所以这一节我想先把"为什么要补"这件事说透,不然补题很容易变成机械抄题解。
1.1 积分赛制和普通训练赛到底差在哪
积分赛和普通训练赛最大的区别在计分方式。普通训练赛往往只看通过题数,积分赛则通常按题号给固定分值,或者按通过人数动态给分——越少人过的题分值越高,再加上每次错误提交 20 分钟罚时。这套规则会直接改变你的比赛策略。
我这场(一)的分值分布大概是这样的:
| 题号 | 考察方向 | 分值 | 场上通过人数 | 我的结果 |
|---|---|---|---|---|
| A | 签到、输入输出 | 100 | 全场几乎全过 | AC,罚时 3 次 |
| B | 贪心、排序 | 200 | 约七成 | AC,罚时 1 次 |
| C | 前缀和、二分 | 300 | 约四成 | 场上来不及 |
| D | BFS、网格图 | 300 | 约三成 | WA 两次后放弃 |
| E | 线性 DP、背包 | 400 | 约两成 | 没思路 |
| F | 并查集 | 400 | 约一成半 | 没思路 |
| G | 数论、筛法 | 500 | 个位数 | 没读题 |
这张表是我赛后从榜单上抄下来的,它说明一件很重要的事:分值和通过人数高度相关,而通过人数又和知识点难度高度相关。A 到 C 加起来 600 分,全是基础题;D 到 G 加起来 1600 分,全是进阶题。而我在基础题上因为罚时丢了大概 80 分钟,等于说我把本该用来啃 C 题的时间,全还给 A 题了。
提示:赛后抄一份"分值 + 通过人数 + 我的结果"的表,比只记"我过了几题"有用十倍。它能直接告诉你,你丢的分是丢在能力上还是丢在纪律上。
1.2 补题的三问:知识、思路、还是代码
补题时我最怕的就是"看一遍题解,哦原来是这样,然后关掉"。这种补法一周后就忘干净了。我后来固定用三个问题来分类:
第一问,是知识点不会吗?比如 E 题我看到"背包"两个字就知道要 DP,但我当时根本没系统学过 01 背包的状态定义,这属于知识空白,必须去找资料系统补。
第二问,是知识点会但想不到吗?C 题我会前缀和,也会二分,但我场上没把"正整数数组的前缀和单调递增"这个性质和"最短子段"联系起来。这属于思路缺失,补法是归类加模板化。
第三问,是思路对但写挂了吗?A 题和 D 题都属于这一类。A 题我思路完全没错,就是多组数据的结束条件写错;D 题我思路也对,方向数组写反了。这属于工程能力问题,补法是练调试、练对拍。
这三种问题的补法完全不同。知识空白要花两三小时系统学,思路缺失要花二十分钟整理成一句话并背下来,代码写挂要花半小时写对拍脚本。把这三类混在一起补,就是我前三场原地踏步的原因。
1.3 我给自己定的补题优先级
有个反直觉的结论:先补黄色题,最后补红色题。黄色题指的是"场上有思路但没出"的题,红色题是"完全没思路"的题。很多人喜欢从最难的开始补,觉得收获大,结果两小时过去还在看题解,挫败感拉满,第二天就不想补了。
我这场黄色题有两道(C 和 D),红色题三道(E、F、G)。我的实际安排是:第一天把 C、D 补完,顺便重写一遍 A、B 确认自己真的会;第二天补 E 和 F 的知识点,晚上做 G;第三天默写所有代码。这个节奏下来,每天都有"我搞定了"的正反馈,比死磕一道 500 分题舒服得多。
2. 赛前赛后的准备动作:环境、题单与分层
补题能不能高效,很多时候不取决于你聪不聪明,而取决于你的环境和流程有没有搭好。我见过太多人补题时把时间花在"编译器又报奇怪的错""不知道哪个版本的 C++ 支持这个语法"上,真正思考算法的时间反而不到一半。这一节说说我的准备动作。
2.1 本地环境:三行命令打天下
我的本地环境很简单,就是一个终端加一个编辑器,不依赖任何复杂配置。编译命令固定成这一条:
g++ -std=c++17 -O2 -Wall -Wextra -Wshadow a.cpp -o a && ./a这里面每个参数都有用。-std=c++17 是因为我习惯用结构化绑定和auto [x, y],很多竞赛环境已经支持;-O2 是模拟评测机的优化级别,能提前暴露一些因为没优化而超时的写法;-Wall -Wextra -Wshadow 是把警告全打开,尤其是 -Wshadow(变量遮蔽),它能抓到"局部变量把全局变量盖住"这种极其隐蔽的 bug,我在 D 题上就吃过这个亏;后面的&& ./a是省一次回车,看着小,一天能省几十次操作。
代码模板我固定成下面这样,比赛和补题都用同一份:
#include <bits/stdc++.h> using namespace std; int main() { // ios::sync_with_stdio(false); // cin.tie(nullptr); int T; if (scanf("%d", &T) != 1) return 0; while (T--) { // solve } return 0; }注意:
bits/stdc++.h是 GCC 特有的,标准 C++ 里没有这个头文件。如果你本地是 Clang 或者 MSVC,编译会直接失败。我本地是 GCC,比赛环境也是 GCC,所以用得很放心,但你要是换环境,老老实实把<iostream><vector><algorithm>这些写上。
2.2 三色标记法:把题单切成三块
补题前我会先把题单摊开,用三种颜色标一遍。这个方法是从别人那里学来的,但用久了我发现它的真正价值在于强迫你承认自己哪些题是真不会。
| 颜色 | 判断标准 | 补题动作 | 时间预算 |
|---|---|---|---|
| 绿色 | 场上一次 AC | 赛后重写一遍,不看旧代码 | 15 分钟 |
| 黄色 | 有思路,WA/TLE 或没写完 | 先自己调,调不出看题解,再默写 | 1 小时 |
| 红色 | 完全没思路,或读不懂题 | 限时 1.5 小时,超时看题解,隔天默写 | 2 小时 |
对绿色题我的要求是"重写一遍"而不是"看一眼"。原因是:场上一次 AC 很有可能只是运气好,数据弱、边界没测到。重写一遍时我会故意把边界条件都试一遍,比如 n=1、n=0、全相同元素、最大值数据,能过才算真的会。
红色题的"限时 1.5 小时"这条规矩我执行得很死。早期我总觉得"再想十分钟就出来了",结果一道题卡一整个下午,题解也没心情看了。后来发现,限时到了就看题解,然后关掉题解自己重写一遍,学习效率比干耗高三倍以上。
2.3 复盘记录:一行字救回一道题
我的复盘记录是这么写的,一段一条,绝不写废话:
D题 | 考察:BFS网格最短路 | 卡点:方向数组 dy 写成了 dx,样例只有一格所以没暴露 关键结论:dx/dy 成对出现,写完立刻打印一次四个方向的目标坐标 复用模板:grid_bfs.cpp关键是"关键结论"这一行。它必须是一句可以直接执行的、下次能救命的话,而不是"要注意方向数组"这种空话。我现在的记录本里全是这种句子,比如"二分查找左闭右开时,hi = mid而不是hi = mid - 1""多组数据的 vis 数组必须在每组开头清空,别偷懒用 memset 整个数组"。
3. 前半段题目拆解:签到、贪心、前缀和
A、B、C 这三道题是整场的分水岭。A、B 属于必须拿满分的题,C 是"努力一下能拿"的题。我这场就是 A、B 拿了但罚时太重,C 没时间做。下面逐题拆。
3.1 A 题签到:真正的考点是输入输出
A 题的题意很朴素:多组数据,每组第一行一个整数 n,第二行 n 个整数,输出这组数里最大值和最小值的差。看完题我第一反应是"这也太水了",然后自信满满提交,WA。改了一次,WA。第三次,还是 WA。三次罚时 60 分钟就这么没了。
问题出在两个地方。第一,多组数据没有给组数,需要读到文件结束,我下意识用了for (int i = 0; i < T; ++i),T 读出来是 0,循环一次都没进。第二,n=1 时最大值和最小值是同一个数,差为 0,这个没问题;但如果数据范围到了 10 的 9 次方级别,n 个数的和虽然用不到,但差值还在 int 范围内,我保险起见开了 long long。
正确的写法是这样:
#include <bits/stdc++.h> using namespace std; int main() { int n; while (scanf("%d", &n) == 1) { // 读到 EOF 就停 long long x, mx = LLONG_MIN, mn = LLONG_MAX; for (int i = 0; i < n; ++i) { scanf("%lld", &x); if (x > mx) mx = x; if (x < mn) mn = x; } printf("%lld\n", mx - mn); } return 0; }while (scanf(...) == 1)这个写法比while (~scanf(...))更保险,因为返回值是成功读入的变量个数,语义清晰。用cin >> n的话,写法是while (cin >> n),也能自动处理 EOF,但一定要关掉同步(ios::sync_with_stdio(false)),否则大数据量会慢。
实操心得:签到题的罚时,几乎全部来自"多组数据的输入格式"和"边界数据"这两件事。赛后我把"多组数据三种结束条件"整理成了一张小便签贴在显示器边上:读到 EOF、给定组数 T、读到 0 0 结束。贴上去之后,我再没在签到题上挂过。
3.2 B 题贪心:为什么按结束时间排序一定选得最多
B 题是经典的区间调度:给 n 个活动的开始和结束时间,问最多能参加几个活动。我场上知道是贪心,但排序关键字犹豫了一下,最后凭直觉按开始时间排,结果只过了部分数据。
正确的贪心策略是按结束时间从小到大排序,然后依次扫描,能接上就选。为什么这样对?我用自己的话解释一遍,这也正是补题时应该想清楚的地方。
假设最优解选了 k 个活动,其中第一个是 X,而贪心选的是结束最早的 Y。因为 Y 的结束时间不晚于 X,所以把 X 换成 Y 之后,剩下的活动仍然都能接上,解的大小不会变差。这样一步步替换下去,贪心解一定不劣于最优解。这个论证方式叫交换论证,是贪心题最常用的证明套路,值得记下来。
按开始时间排序为什么错?举个反例就够了:活动一是 [1, 100],活动二是 [2, 3],活动三是 [4, 5]。按开始时间排,第一个选 [1, 100],后面全接不上,答案是 1;正确答案是 2。
代码:
#include <bits/stdc++.h> using namespace std; struct Node { int l, r; }; int main() { int n; scanf("%d", &n); vector<Node> a(n); for (int i = 0; i < n; ++i) scanf("%d%d", &a[i].l, &a[i].r); sort(a.begin(), a.end(), [](const Node& x, const Node& y) { if (x.r != y.r) return x.r < y.r; // 结束时间优先 return x.l < y.l; // 结束时间相同时,开始早的优先 }); int cnt = 0, last = INT_MIN; for (const auto& e : a) { if (e.l >= last) { // 端点相接算不算冲突,看题目要求 ++cnt; last = e.r; } } printf("%d\n", cnt); return 0; }这里有个坑我必须说:e.l >= last还是e.l > last,完全取决于题目对"时间点相接"的定义。有的题说"一个活动结束的瞬间另一个可以开始",那就用>=;有的题说"必须间隔",那就要加上间隔。我这场是前一种,但我在心里过了一遍才敢下笔。
3.3 C 题前缀和加二分:把平方复杂度压到线性对数
C 题是我场上没做完但赛后半小时就补出来的一道。题意是:给一个长度为 n 的正整数数组和一个整数 S,求和不小于S 的最短连续子段的长度,不存在输出 0。n 到了十万级别。
看到"连续子段的和",第一反应是前缀和:令pre[i] = a[1] + a[2] + ... + a[i],那么子段 [i, j] 的和就是pre[j] - pre[i-1]。如果直接双重循环枚举 i 和 j,复杂度是 O(n²),十万的数据量铁定超时。
关键性质在这里:数组全是正整数,所以前缀和是严格单调递增的。这意味着一件事——固定左端点 i,随着右端点 j 增大,子段和只会变大。那么"最小的 j 使得子段和 ≥ S"就可以二分查找了。整体复杂度 O(n log n),稳稳过。
#include <bits/stdc++.h> using namespace std; int main() { int n; long long S; scanf("%d%lld", &n, &S); vector<long long> pre(n + 1, 0); for (int i = 1; i <= n; ++i) { long long x; scanf("%lld", &x); pre[i] = pre[i - 1] + x; } int ans = n + 1; for (int i = 1; i <= n; ++i) { int lo = i, hi = n, pos = -1; while (lo <= hi) { int mid = lo + (hi - lo) / 2; if (pre[mid] - pre[i - 1] >= S) { pos = mid; hi = mid - 1; } else lo = mid + 1; } if (pos != -1) ans = min(ans, pos - i + 1); } printf("%d\n", ans == n + 1 ? 0 : ans); return 0; }提示:
mid = lo + (hi - lo) / 2这种写法比(lo + hi) / 2安全,因为当 lo 和 hi 都接近 int 上限时,lo + hi会溢出变成负数,然后就是死循环或者数组越界。这个细节我在一次 RE 之后才真正记住。
其实这题还有更优的写法:因为左右端点都只会往右移,用**双指针(尺取法)**可以做到 O(n)。思路是右指针一直往右扩,当子段和 ≥ S 时更新答案,然后右移左指针缩小子段,直到和小于 S 再继续扩右指针。两种写法我都默写过一遍,对比起来:
| 方案 | 时间复杂度 | 适用条件 | 代码难度 |
|---|---|---|---|
| 前缀和 + 二分 | O(n log n) | 要求前缀和单调(元素全正) | 中 |
| 双指针尺取 | O(n) | 同样要求单调性 | 中偏高,边界容易错 |
我场上的话会先写二分,因为二分更套路化,不容易写挂;时间充裕再优化成尺取。这种"先拿分再优化"的顺序,在积分赛里尤其重要。
4. 中段题目拆解:搜索与动态规划
D 和 E 是这场从中游往上走的分界线。D 题是搜索,思路直观但细节多;E 题是 DP,思路本身就是门槛。这两道我花了整整一天。
4.1 D 题 BFS:一个方向数组写错,样例就能过
D 题是 n 乘 m 的网格,0表示能走,1表示障碍,问从左上角走到右下角的最少步数,走不到输出 -1。典型的无权图最短路,直接 BFS。
我场上写了大概十分钟,样例一次过,提交 WA。改了一版,还是 WA。当时以为是 vis 标记的问题,折腾到比赛结束都没看出来。赛后补题时我把代码打印出来逐行对照,才发现方向数组写成了这样:
int dx[4] = {-1, 1, 0, 0}; int dy[4] = {-1, 1, 0, 0}; // 错误!应该是 {0, 0, -1, 1}后果是 BFS 只能沿着对角线方向走,上下左右四个方向全废了。样例恰好是个只有一格的网格,起点就是终点,所以直接输出 0,什么问题都看不出来。这就是为什么我现在的记录本上写着"样例太小的题,必须自己造一组三乘三以上的数据"。
正确代码:
#include <bits/stdc++.h> using namespace std; int main() { int n, m; scanf("%d%d", &n, &m); vector<string> g(n); for (int i = 0; i < n; ++i) cin >> g[i]; if (g[0][0] == '1') { puts("-1"); return 0; } vector<vector<int>> dist(n, vector<int>(m, -1)); queue<pair<int,int>> q; dist[0][0] = 0; q.push({0, 0}); int dx[4] = {-1, 1, 0, 0}; int dy[4] = {0, 0, -1, 1}; while (!q.empty()) { auto [x, y] = q.front(); q.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] == '1' || dist[nx][ny] != -1) continue; dist[nx][ny] = dist[x][y] + 1; q.push({nx, ny}); } } printf("%d\n", dist[n - 1][m - 1]); return 0; }这段代码里有三个值得单独拎出来的细节。
第一,入队时就标记 dist,而不是出队时标记。如果在出队时才标记访问,同一个格子可能被多次入队,队列会膨胀到不能接受的程度,时间复杂度从 O(nm) 退化。这一点很多人踩过。
第二,dist 数组直接兼任 vis 数组,初始化为 -1,既能表示"没访问过",又能顺带存距离,省一个数组。这是我从模板里固定下来的写法。
第三,BFS 求出的最短路只在边权全为 1 时成立。如果题目里有"传送门"这种一步顶三步的设定,BFS 就不成立了,得换 Dijkstra 或者 0-1 BFS。这个边界意识很重要,我在后来的题目里吃过亏。
4.2 E 题 DP:状态定义比转移方程值钱得多
E 题是 01 背包的裸题:n 个物品,每个有重量和价值,背包容量 V,每个物品最多拿一次,求最大价值。我场上看到就懵了,因为我连"状态"是什么都没概念。
补题时我看完资料,最大的收获不是那行转移方程,而是为什么状态要这么定义。我们定义dp[j]表示"容量为 j 的背包能装下的最大价值"。这个定义里藏着一个取舍:我们只关心容量,不关心具体装了哪些物品,因为物品的价值只和总重量有关,和装了谁无关。如果题目要求"输出具体选了哪些物品",这个状态就不够了,得开两维甚至记录方案。
有了状态,转移就好推了:对第 i 个物品,要么不拿,dp[j]不变;要么拿,dp[j] = dp[j - w] + v。取两者较大值。
#include <bits/stdc++.h> using namespace std; int main() { int n, V; scanf("%d%d", &n, &V); vector<int> dp(V + 1, 0); for (int i = 0; i < n; ++i) { int w, v; scanf("%d%d", &w, &v); for (int j = V; j >= w; --j) // 关键:倒序枚举 dp[j] = max(dp[j], dp[j - w] + v); } printf("%d\n", dp[V]); return 0; }注意事项:一维写法里 j 必须从大到小枚举,这不是习惯问题,是正确性问题。因为
dp[j]依赖的是"上一轮还没考虑当前物品的 dp[j - w]"。如果 j 从小到大枚举,dp[j - w]已经被本轮更新过了,等于当前物品被拿了两次,答案就变成完全背包了。我为了记住这一点,专门写过一个错误版本跑数据,看着它输出一个大得离谱的数字,从此再没写错过。
二维写法的思路更直观,dp[i][j]表示前 i 个物品、容量 j 的最大价值,转移时从 i-1 层拿数据,不存在覆盖问题:
for (int i = 1; i <= n; ++i) for (int j = 0; j <= V; ++j) { dp[i][j] = dp[i - 1][j]; if (j >= w[i]) dp[i][j] = max(dp[i][j], dp[i - 1][j - w[i]] + v[i]); }我建议新手先写二维版本,写熟之后再压缩成一维,理解成本会低很多。
5. 后半段题目拆解:并查集与数论
F 和 G 是这场分值最高的两道题。F 题的通过人数有一成半,说明它是"有套路就能做"的题;G 题只有个位数通过,属于真正的分水岭。
5.1 F 题并查集:路径压缩和按秩合并
F 题是这样:n 个人,给 m 对"认识关系",认识具有传递性——A 认识 B,B 认识 C,那么 A 和 C 也算在一个圈子里。问一共有几个互不相通的圈子,以及某些人是否在同一个圈子里。这就是并查集的典型应用。
并查集的思路其实特别生活化:每个人都有个"组长",如果两个人的组长是同一个人,他们就在一个组。合并两个组的时候,让一个组长认另一个组长当上级。最怕的是形成一条长链,比如 1 的上级是 2,2 的上级是 3,一直排到 n,那查一次要找 n 步。
两个优化解决这个问题。路径压缩:查询的时候顺手把路上所有节点的上级直接改到根节点上,下次再查就是一步。按秩合并:合并时把节点少的树挂到节点多的树上,防止树长歪。两个优化一起用,单次操作的均摊复杂度接近常数,可以当成 O(1) 看。
struct DSU { vector<int> fa, sz; DSU(int n) : fa(n + 1), sz(n + 1, 1) { iota(fa.begin(), fa.end(), 0); } int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); // 路径压缩 } void unite(int a, int b) { a = find(a); b = find(b); if (a == b) return; if (sz[a] < sz[b]) swap(a, b); // 小的挂到大的下面 fa[b] = a; sz[a] += sz[b]; } };统计圈子个数很简单:把所有节点遍历一遍,find(i) == i的节点就是一棵树的根,根的个数就是圈子数。
int cnt = 0; for (int i = 1; i <= n; ++i) if (find(i) == i) ++cnt; printf("%d\n", cnt);实操心得:并查集有一个很隐蔽的坑——初始化必须把每个节点的父节点设成自己,同时 size 设成 1。我见过有人只写了
fa[i] = i却忘了 size,按秩合并时把空树挂上去,很快就会出现"某个节点的 size 是 0"导致合并方向全错。另外,路径压缩和按秩合并其实只用一个也能保证效率,但两个一起用真的能省不少时间。
5.2 G 题数论:线性筛和质因数分解
G 题我场上压根没读,赛后看完发现是两个小问拼起来的:第一问给定上界 n,求 n 以内的质数个数;第二问给一个数,输出它的质因数分解形式。第一问 n 到了 10 的 7 次方,第二问的数到了 10 的 12 次方。
第一问的关键在于筛法。最朴素的埃氏筛复杂度是 O(n log log n),10 的 7 次方大概能过,但比较勉强。更稳的是线性筛(欧拉筛),每个合数只被它最小的质因子筛掉一次,复杂度严格 O(n)。
const int MAXN = 1e7 + 5; vector<int> primes; bool notp[MAXN]; void sieve(int n) { for (int i = 2; i <= n; ++i) { if (!notp[i]) primes.push_back(i); for (int p : primes) { if (1LL * i * p > n) break; // 防止溢出,用 long long 比较 notp[i * p] = true; if (i % p == 0) break; // 保证每个合数只被最小质因子筛一次 } } }这行if (i % p == 0) break;是线性筛的灵魂。它的意思是:当 p 已经能整除 i 时,说明 p 是 i 的最小质因子,那么 i 乘上更大的质数得到的合数,一定已经被更小的质因子筛过了,没必要再筛。我第一遍看的时候不理解,手动画了 n=20 的过程才算清楚。
1LL * i * p > n这个写法也要注意,i 和 p 都是 int,乘起来可能溢出,加个1LL *强制转成 long long 再比较,能避免 10 的 7 次方附近出问题。
第二问的分解用试除法就够:
vector<pair<long long,int>> factor(long long n) { vector<pair<long long,int>> res; for (long long p = 2; p * p <= n; ++p) { if (n % p) continue; int c = 0; while (n % p == 0) { n /= p; ++c; } res.push_back({p, c}); } if (n > 1) res.push_back({n, 1}); // 剩下的大质因子 return res; }复杂度是 O(根号 n),对一个 10 的 12 次方的数来说,循环最多跑到 10 的 6 次方次,完全够用。注意循环条件p * p <= n里的 n 是在变化的,这正好能把复杂度再压下去一点。还有最后那个if (n > 1)千万别漏,否则像 2 乘一个大质数这样的输入会少输出一个因子。
提示:
p * p <= n在 p 接近 long long 上限时会溢出。虽然这题用不到,但写法上更稳的是p <= n / p。我在整理模板时把这条统一改了。
6. 常见问题与排查技巧实录
补题那三天,我在调试上花的时间大概占了四成。这一节把当时遇到的错误和后来的排查方法整理出来,这部分内容在题解里基本看不到,但实战价值最高。
6.1 错误类型定位顺序:从 RE 到 WA
评测结果就那么几种,但每种背后指向的问题范围差别很大。我的排查顺序是这样的:
| 结果 | 常见原因 | 第一件事做什么 |
|---|---|---|
| RE | 数组越界、除零、栈溢出、空指针 | 把数组开大 10 倍试试,再检查递归深度 |
| TLE | 复杂度估错、常数太大、死循环 | 打印循环次数,确认是否真的进了死循环 |
| WA | 边界没判、溢出、题意理解错 | 造小数据对拍,看第一个错在哪 |
| MLE | 数组开太大、递归爆栈 | 把不需要 long long 的改成 int |
| PE | 行末空格、最后一行换行 | 逐字节对比输出格式 |
RE 我踩过最经典的一次是数组开小了。题目说 n 最大 10 的 5 次方,我开了 100005,看着够。但题目是多组数据,且没给组数,我为了省事把数组开在全局只清空一次——结果是累积的,后面几组直接越界。数组开在全局时,多组数据必须每次重新初始化,这是硬规矩。
TLE 最常见的不是算法错,而是复杂度估错。比如我看到"n 个数,每次查询区间和"就写了个双重循环,n 是 10 的 5 次方、查询 10 的 5 次方,那就是 10 的 10 次方次运算,铁定超时。这种情况必须上前缀和或者树状数组。养成习惯:写完代码先在心里算一遍最坏情况的运算次数,超过 10 的 8 次方就要警惕。
WA 的排查我觉得对拍是最有效的。下一节说。
6.2 对拍:让程序自己找自己的错
对拍的核心思路是:写一个肯定正确但很慢的暴力程序,写一个随机数据生成器,然后跑几百组数据,比对我的程序和暴力程序的输出。这招在 D 题上救了我——我造了 100 组 5 乘 5 的随机网格,第 3 组就挂了,一下子定位到方向数组。
三个文件,一个脚本:
// gen.cpp 生成随机数据 #include <bits/stdc++.h> using namespace std; int main() { srand(time(0) ^ (unsigned long long)(new char)); int T = 20; printf("%d\n", T); while (T--) { mt19937 rng(chrono::steady_clock::now().time_since_epoch().count()); int n = rng() % 5 + 1, m = rng() % 5 + 1; printf("%d %d\n", n, m); for (int i = 0; i < n; ++i) { for (int j = 0; j < m; ++j) putchar(rng() % 3 == 0 ? '1' : '0'); putchar('\n'); } } return 0; }脚本:
#!/bin/bash g++ gen.cpp -O2 -o gen g++ std.cpp -O2 -o std # 暴力程序 g++ my.cpp -O2 -o my # 我的程序 for ((i = 1; i <= 500; ++i)); do ./gen > in.txt ./std < in.txt > out1.txt ./my < in.txt > out2.txt if ! diff -q out1.txt out2.txt > /dev/null; then echo "第 $i 组数据 WA" cat in.txt break fi done注意事项:生成器的随机范围一定要覆盖边界。我一开始只随机生成了 3 乘 3 的网格,跑了两百组全过,还以为程序没问题了;把范围改成 1 到 5 之后,立刻暴露出起始点就是障碍的情况没处理。随机数据的价值不在于"多",而在于"能碰上边界"。
6.3 高频踩坑速查表
下面这张表是我从这场和之前几场比赛中攒下来的,基本每次补题都会翻一遍:
| 症状 | 最可能的原因 | 处理办法 |
|---|---|---|
| 样例过提交 WA | 样例太小,边界没覆盖 | 自己造 n=1、全相同、最大值数据 |
| 多组数据第一组对后面全错 | 全局数组没清空 | 每组开头重置,别偷懒 memset 整个数组 |
| 答案偶尔偏小 | int 溢出 | 涉及乘法和求和一律 long long |
| 大数据超时,小数据正常 | 复杂度不够,或输入输出慢 | 换算法,或关同步 / 用 scanf |
| 死循环 | 二分边界写错,或循环变量没更新 | 打印循环变量,看是否卡在同一个值 |
| 答案差 1 | 区间开闭、下标从 0 还是 1 开始 | 统一成"下标从 1 开始、区间左闭右闭" |
| 浮点数比较失败 | 精度误差 | 改成整数运算,或用 eps 比较 |
这个表里我最想强调的是"答案差 1"这一类。它几乎全部来自下标约定不统一。我后来强制自己所有涉及区间、前缀和的代码,数组下标一律从 1 开始,区间一律左闭右闭,前缀和数组开 n+1。统一之后,这类错误基本绝迹。
7. 补题之后:模板沉淀与下一场目标
补完题不等于结束。我见过太多人补完就把代码扔了,下次遇到同类型的题从零开始。真正拉开差距的是补题之后的动作——把这道题抽象成一个可复用的模块,再给自己定一个可量化的下一场目标。
7.1 模板整理的正确姿势
我的模板目录是按知识点分的,每个文件里放一个可独立编译的小程序,关键是在文件顶部加一段注释,写清楚三件事:适用条件、复杂度、以及"什么时候不要用它"。
第三个尤其重要。比如我的binary_search_shortest_segment.cpp文件里写着:
适用:正整数数组,求和不小于 S 的最短连续子段 复杂度:O(n log n) 不要用:数组含负数时,前缀和不单调,二分失效,必须换单调队列或双指针失效后重推再比如grid_bfs.cpp:
适用:无权网格图最短路,四方向或八方向 复杂度:O(nm) 不要用:边权不为 1,或有权重转移时,需要 0-1 BFS 或 Dijkstra这样写的价值在于,下次遇到新题时,我能快速判断"这题能不能套模板",而不是"先套上去再说"。前者是效率,后者是灾难。
另外,每个模板我都要求自己默写过一遍。不是复制粘贴,是关掉所有参考、从空文件开始敲。默写的时候暴露的问题最多,比如我总忘iota的头文件、总把while (lo <= hi)写成while (lo < hi)。这些问题在默写时暴露一次,赛场上就能少挂一次。
7.2 下一场的可量化目标
补完这场之后,我给自己下一场定的目标是这样三条:
第一条,签到题(100 到 200 分)必须一次过,罚时为 0。这条是纪律,不是能力,只要输入输出模板背熟、边界自己造一组数据测过,就能做到。
第二条,中等题(300 分档)至少拿下一道。这场 C 题没时间做,是因为我在 A 题上浪费了 60 分钟。把签到题的罚时压下来,中等题的时间自然就有了。
第三条,比赛结束后 48 小时内必须把黄色题全部补完。我给这条定了执行细节:赛后当晚只做一件事,把题单按三色标好,写下每道题的卡点;第二天花两小时补黄色题;第三天补红色题,超时看题解,看完默写。
实操心得:目标一定要能被打勾。我早期给自己定过"下次打得更好"这种目标,结果毫无约束力。换成"签到题零罚时"之后,我在比赛时会主动在提交前多测一组边界数据,这个动作看起来只花二十秒,但它救回来的可能是整整一小时。
后来那场积分赛(二),我签到题零罚时,C 题在剩余 70 分钟时拿下,最终排名比这场往前了二十多位。真正起作用的就是这几条看起来特别琐碎的规矩。
8. 我从这场补题里学到的最实在的东西
如果只能留下一句话,我会留这句:比赛暴露的是症状,补题才是在治根。A 题挂三次不是运气差,是输入输出模板没背熟;D 题方向数组写错不是粗心,是没养成"写完立刻打印验证"的习惯;E 题没思路不是脑子笨,是知识点压根没学过。这三种问题的解法完全不同,但它们的共同点是——只要你不去补,下一场还会原样出现。
我现在补题的流程已经固定下来了:抄一张分值通过人数表,三色标记题单,先补黄色再补红色,每道题写下"关键结论"那一行,红色题限时 1.5 小时,补完默写模板。这套流程不新鲜,但执行下来是真的管用。另一个我觉得被低估的动作是造数据。很多人补题时只在样例上跑一遍就心安了,但样例往往小得可怜——D 题那组"起点即终点"的样例就是最好的反例。宁可多花五分钟手写一组三乘三以上的数据,也不要在提交之后盯着 WA 发呆。
最后分享一个小技巧,是我在补 E 题时摸索出来的:当你看不懂一道 DP 题的状态定义时,先把二维版本写出来,把dp[i][j]的表格手工填前几行,看着数字找规律,你往往能自己"重新发现"那个状态定义。这比盯着题解硬啃快得多,而且理解得牢。至于后续还能怎么扩展——我打算把筛法、并查集、最短路这几个模板再各自写一道变式题的题解,特别是带权并查集和 0-1 BFS,这两块目前还是我的薄弱环节,下一场大概率还会考到。