准高二零基础学 OI 第一周:7 天,43 个文件,31 题 AC,33 条错误模式
我马上高二,会 Python,初中摸过 FPGA,高一自学了组成原理和操作系统,C++ 一行没写过,目标 NOIP 2026 省二。8 月 10 日到 17 日,7 天啃完 OI 语言关,代价是 43 个题文件、31 题 AC、33 条错误模式。
带我的教练是个 AI——opencode 命令行工具,跑 deepseek 模型,被我当 OI 教练使。我有组成原理底子,直接看汇编理解;做题则要求每题过本地 judge 再交洛谷。
这 7 天我干了什么
节奏固定:教练先讲概念,再做题,每题本地 judge 验证、洛谷提交、WA 就记一条错误模式。
Day 1 到 Day 7 依次是 IO、分支循环、数组字符串、函数递归、STL 两批加排序、综合模拟。毕业考是 NOIP2016 提高组 T1(P1563),独立 AC 算过关。做完计划任务还能开”超额池”提前刷后面的题,7 天下来 43 个题文件、31 题 AC 。
有两天很挫败:P1308 五轮 WA(下面细说),P1055 也卡了很久。但这周最大的收获全在 WA 里。
33 条错误模式全量清单
Day 2 起每次 WA 都登记一条错误模式,7 天记了 33 条。按心智模型分七组,全部列出来,当给自己做周复盘,也给读者当参考。
组 1:C++ 语法陷阱(6 条)
^是异或不是乘方(P5717)。我写边长平方直接^2(之前明明记得cpp乘方是^),死活找不到 bug。C++ 没有**,^优先级还低于+,$a \text{ XOR } 2$ 和 $a^2$ 完全不是一回事。平方就老老实实a*a。这是 Python 直觉污染。- 运算符优先级(P5711/P5710,两次中招):
<<和?:、&&混用必须加括号。cout << x ? 1 : 0实际是(cout << x) ? 1 : 0,三目的结果根本没输出。编译器的-Wparentheses警告是免费的定位器。 - 未初始化自引用(P5710):
bool a = (a % 2 == 0)——右边的a用的是自己,未初始化。-Wuninitialized警告出现必查。 - 输出分隔符(P5715):元素之间打空格,不是每个元素后打。
for循环里cout << a[i] << ' '会留下行尾空格。 - 抄写错误(P5717):三角形判定
(c+b<c)抄成对不上的比较对象。写完通读一遍条件行。 - struct 漏分号 + 成员函数漏括号(P5742):
struct Student{...}结尾没分号是编译错;s.is_excellent不带括号是函数指针,恒为真——编译能过、逻辑全错、样例碰巧对,最危险的一种。
组 2:边界与定义(5 条)
- 边界语义抠字眼(P5711):闰年是”能被 4 整除且不能被 100 整除,或能被 400 整除”,不是只有
%4。 - 临界值手动推(P5713):条件写
n<5,应为n<=5——n=5 时本地 25 分钟 < 洛谷 26 分钟,恰好是临界点。样例过了不等于边界过了。 - 下标基线对齐(P5729):数组按 0..w-1 初始化,切割坐标却是 1..w,边界层整个漏判。自测要构造”切掉坐标 w 整层”这种用例。
- 筛法漏 1(P5736):线性筛从 2 开始标记,
is_comp[1]永远是 false,输入含 1 就会把 1 当质数输出。 - “位置”默认指字符下标(P1308):词序号 ≠ 字符位置。样例里
to恰好是第一个词(词序号 0 = 字符位置 0),两种定义输出相同,只有洛谷隐藏数据能区分。样例区分不了定义时,构造”能区分定义的用例”(如no it is not找is,期望1 6)再提交。
组 3:输入输出形态(5 条)
- 输出格式读全题面(P1055):ISBN 判错时要输出完整正确的 ISBN(
0-670-82162-4),不是只补校验位(4)。本地样例只覆盖 Right 分支,错误分支的格式必须自己补测。 - 特殊值单独处理(P1055):校验位
'X'代表 10,'X' - '0'等于 40 会静默出错。读题时标注所有特殊输入值。 - 大数必须用 string(P1781):票数可达 100 位,long long 只有 19 位,读进去就溢成垃圾值。字符串比大小先比长度、同长再比字典序——
"9" > "100"是陷阱。数据范围决定存储类型。 - “独立单词”≠”空格分隔”(P1308):
cin >> word按空格分词,文章里to,会被粘成"to,"。正确做法是isalpha清洗非字母为空格再分词,”单词 = 连续字母段”。 - getline 的 CRLF 刺客(P1308):洛谷数据是
\r\n,本地是\n,getline读进来的字符串末尾带\r,比较永远失败。防御:if (!s.empty() && s.back()=='\r') s.pop_back();。这个坑我教练自己都栽过。
组 4:容器操作(4 条)
- 弹栈/出队前判空(P1739):输入
)(时空栈pop()是未定义行为 = RE。pop()无返回值,且对空容器调用是 UB。 - 解析题的结束标记最先判断(P1449):后缀表达式的
@被当运算符弹栈,弹两个数但栈里只有一个,空栈 RE。结束符是控制字符不是运算数,遍历时必须第一个处理。 - front/back 混淆(P1540):清缓存标记用
back(),但pop()弹的是front()——清除端和弹出端不一致,标记错位,样例碰巧掩盖。写队列代码时问自己:pop 前访问的端和 pop 的端一致吗? - “满了才挤”是先判满再入(P1540):
cnt >= m在入队后才判断,第 m 个词一进来就被挤掉,内存等效 m-1 容量。正确:入队前size() >= m先弹再入。
组 5:类型(1 条)
- 阶乘/累乘必用 long long(P5739):$20! \approx 2.43 \times 10^{18}$,int 上限才 $2^{31}-1 \approx 2.1 \times 10^9$。看到阶乘、排列组合、累乘,第一反应 long long。基准条件写
n <= 1防fact(0)。
组 6:循环与控制流(4 条)
- if 不带花括号只保护一行(P1739 二次 WA):
if (empty) cout<<...; return 0;的return 0不在 if 内,第一个)就无条件退出。铁律:所有 if/for/while 一律带花括号。-Wmisleading-indentation警告出现必查。 - break vs continue(线性筛):把 break 写成 continue,
i % p == 0后继续用更大的质数筛,每个合数被多次标记,操作数实测从 $1.6n$ 涨到 $3.1n$。break 是”到此为止”,continue 是”跳过本次”。 - 循环边界
<=陷阱(线性筛):j <= cnt访问到未写入的primes[cnt](初值 0),i % 0除零。我当时的认知错误是把cnt当成size()-1(最后元素下标),实际上cnt等价于vector.size()(元素个数/下一个空位)——遍历用j < cnt。 - 读字符循环:读取放循环开头(P1042 死循环):
continue跳过循环末尾的cin.get(c),c永远是'\n'永远不等于'E'。铁律:while (cin.get(c))读在开头,任何 continue/break 都不会弄丢下一次读取。
组 7:下标与取模(3 条)
- 下标运算不要莫名 -1(P1563):
((pos+step)%n) - 1在取模结果为零时变成 -1 越界。我当时的想法是”vector 从 0 开始而小人从 1 开始”,但这是混淆了编号和偏移量:编号转下标才 -1(第 k 个元素 = 下标 k-1),移动距离不 -1(数 k 个 = 走 k 步)。P1563 里只有后者。 - 大步数防负取模(P1563):
pos - step + n在 step > n 时仍为负(C++ 负数取模结果是负的,$(-3) \bmod 7 = -3$ 不是 4)。正确写法是 $((pos – step) \bmod n + n) \bmod n$,两次取模保证非负。 - 方向规则用题面例子反推(P1563):”朝内+左=顺时针”是直觉,但题面例子说 singer(朝内)左数第 3 个是 archer——0 号左数 3 到 4 号,是逆时针。写完方向判断后,拿题面例子对一步验证,1 分钟就能发现方向反了。
顺带:环形 = 模算术(P1563 最大的收获)
P1563 我一开始完全没想到取模。想到的是环形链表——然后被教练一句点醒:环形结构的数学标准表示就是模算术,时钟就是活例子。
3 点加 11 小时是 2 点($14 \bmod 12 = 2$),你从来不会觉得时钟难,因为它”转一圈回到原点”。玩具小人围成的圈和时钟是同一个数学对象:n 个位置 = 模 n,走 s 步 = 加 s(模 n)。链表是”实现环”的一种方式,取模是”计算环上位置”的方式——题目要的是后者。
pos = (pos + s) % n; // 顺时针走 s 步
pos = ((pos - s) % n + n) % n; // 逆时针走 s 步(两次 % 防负)
顺带一提,这个取模做法是 O(1) 的:一次指令直接算到位,不用一步一步走。所以它既解决”环形怎么表示”,也解决”步数大(s 可达 10^9)会不会超时”——链表逐格走早就 TLE 了。
组 8:其他(5 条)
- 变量遮蔽(线性筛):函数内
int cnt遮蔽全局cnt,填表和验证用两个变量,编译还不报错(-Wshadow能查)。函数内不声明与全局同名的变量。 - 逐字符读只过滤
\n是半个修复(P1042):cin.get逐字符读时\r是独立字符,只if (c=='\n') continue会让\r落到 else 分支被记分。铁律:用白名单写法if (c != 'W' && c != 'L') continue;——只接受合法字符,其余全跳过。 - “结束”语义按题面规则而非物理量(P1042):乒乓球一局结束 = 一方 ≥ 11 且分差 ≥ 2,不是”打了 11 个球”。循环结束后的残余状态也要输出(最后一局,含 0:0)。
- 打满不等于获胜:$11:10$ 还要继续打,领先 2 分才赢。
- 样例不全时分支没测全(P1055/P1540 通用):本地 AC 只证明”我测过的情况”正确。构造边界用例(n=5、坐标 w 整层、CRLF、全 0)自测,比提交洛谷试错快。
再顺带:补码和取模是同一个东西
然后我又想到:补码的本质就是模运算。在 32 位 int 里,$-3$ 的补码是 0xFFFFFFFD = $4294967293 = 2^{32} – 3$。你看,这不就是 $(-3) \bmod 2^{32}$ 的非负余数吗?负数在计算机里的表示,就是这个数对 $2^{32}$ 取模后的同余类。
所以:
int溢出回绕($2^{31}-1 + 1 = -2^{31}$)不是 bug,是模 $2^{32}$ 算术的正常结果- 为什么 C++ 里
(-3) % 7 = -3而不是 4?因为 C++ 取模向零截断,返回的是”余数”,不是”非负同余代表”。数学上 -3 和 4 在模 7 下是同一个东西,只是 C++ 选了个负数来表示 - 所以防负取模
((pos - step) % n + n) % n不是 hack,是在手动找回数学意义上的非负余数——那个真正代表”环上的位置”的数
环形取模、补码、溢出回绕,这三件事在数学上是同一个对象:模 $2^k$ 或模 $n$ 的同余类。想通这点,看 int 回绕就像看时钟转圈一样自然。
顺带:线性筛的思路(Day 4 自研)
Day 4 学筛质数,我要求先自己想,别看书。朴素做法是埃氏筛:每遇到一个质数 p,就把它的倍数全标记。问题在于重复标记——12 被 2 筛一次、被 3 筛一次,做了无用功,复杂度 $O(n \log \log n)$。
线性筛的突破口是个数学事实(算术基本定理):每个合数有唯一的质因数分解,所以有唯一的最小质因子。那么:
让每个合数 $x$ 只被它的最小质因子筛掉一次。每个合数只操作一次 → 总复杂度 $O(n)$。
代码的灵魂在那一行 break:
for (int i = 2; i <= n; i++) {
if (!is_comp[i]) primes[cnt++] = i;
for (int j = 0; j < cnt; j++) {
int p = primes[j];
if (i * p > n) break; // 防越界
is_comp[i * p] = 1; // 标记
if (i % p == 0) break; // ★ 灵魂:p 已经是 i 的最小质因子,再筛就重复了
}
}
那句 i % p == 0 一旦成立,就说明 p 整除 i,那么后面更大的质数 $p’$ 筛出来的 $i \cdot p’$,它的最小质因子还是 p(因为 p 已经在 i 里了)——所以 $i \cdot p’$ 应该由更小的数负责筛,我在这里继续就是重复劳动。
自研的时候我把它写成 continue,实测操作数从 $1.6n$ 涨到 $3.1n$——每个合数被标记了不止一次,线性性质就没了。break 是”到此为止”,continue 是”跳过本次”,这一字之差就是 $O(n)$ 和 $O(n \log \log n)$ 的区别。
六组心智模型
33 条错误模式背后是六组心智模型。C++ 和 Python 的差别不在语法,在心智。
- 值语义 vs 引用语义。Python 传引用,C++ 默认按值拷贝。函数参数、遍历要不要
&,debug 一半的 bug 在这。 - 没有动态大整数。int 上限约 21 亿,溢出是静默的。看到阶乘、累乘、1e9 级别直接 long long。
- 区间思想。STL 全是左闭右开 $[begin, end)$,循环边界默认
<,除非闭区间语义明确。 - 编译与运行分离 + O2。
g++ -O2会”优化”未定义行为,本地对、评测炸。 - 题面是唯一真理。
n<5和n<=5的区别,就是样例过了和 AC 的区别。 - 暴力对拍。逻辑手推不可靠时,写 Python 朴素解随机对拍,几分钟抓出所有隐藏 bug。
读题清单五问
这周三次 WA(P5713、P5729、P1055)全是一个原因:题没读完。于是定了一张清单,每题动笔前过一遍:
- 数据范围:n 最大多少?决定算法和类型。
- 输入格式:下标从 0 还是 1?多个数怎么隔开?
- 输出格式:行尾要不要空格?错误分支输出什么?
- 特殊值:’X’、-1、0、空串?单独处理。
- 边界条件:n=1、全 0、最大值时表现如何?题面保证了什么?
本地 AC 不等于题面读完,这条花了不少代价才明白。
我的训练环境
先交代硬件软件,因为这一周能这么快,环境帮了大忙。
- 系统:Fedora 43,Python 3.14,g++ 15(编译命令
g++ -O2 -Wall -Wextra -std=c++17,和 NOIP 环境一致) - 编辑器:Neovim 0.11 + lazy.nvim 插件管理,装了 treesitter(C/C++ 高亮)、clangd LSP(写代码时实时报错和补全)、nvim-cmp(补全)、telescope(文件搜索)。clangd 是最值的——变量名打错、类型不匹配,还没编译就标红,等于多了一道免费的类型检查
- 本地评测脚本 judge.sh:一条命令完成”编译 → 跑样例 → 比对 → 计时”,输出 AC/WA 和耗时,不用手动敲 g++ 和 diff
- 样例自动抓取:用 curl 带 cookie 抓洛谷题目页,解析页面里内嵌的 JSON(
"samples":[[输入,输出],...]),自动落到lang/samples/<题号>/目录。做题前数据已经备好,验证就是一条命令 - 目录结构:
lang/放语言关练习,algo/放专题,notes/放复盘,每个文件命名P题号.cpp,模板统一从tools/template.cpp复制(bits/stdc++.h + ios 关同步)
我原来以为 OI 训练就是”洛谷网页上写代码提交”,后来发现本地闭环才是主战场:样例验证、对拍、复盘全在本地做,洛谷只用来交最终答案。环境建好后,一天能多挤出一两个小时的做题时间。
三件工具,让训练效率翻倍
- 本地评测脚本:
g++ -O2 -Wall -std=c++17编译 + 样例比对 + 计时,一条命令完成”本地验证”闭环。先把样例数据抓下来(curl + cookie 解析洛谷页面里的 JSON),每题本地 judge 过了再交洛谷。 - 暴力对拍:怀疑有隐藏 bug 时,写一个 Python 朴素参考解(直接模拟题意,不做任何优化)+ 随机数据生成器,几百上千组对比。P1540 两轮 WA 人工测试全放过,对拍 2000 组抓出 77 组失败。这是”本地 AC 但洛谷 WA”的根治手段。
- 错误模式清单:每次 WA 登记一条,按心智模型分组。它不只是事后补救,更是下次写代码时提前激活的检查点——P1563 开写前我主动想到
(int)size()强转(P5727 教训),没等踩坑先预防了。教练管这个叫”正向记录”。
除了踩坑,还有两件高兴的事
第一件是上面说的主动预防。
第二件是复盘出了方法。以前学东西”懂了就完”,现在每次 WA 都问”这暴露了我哪个思维漏洞”。33 条错误模式里几乎没有一条属于智商问题,全是习惯和细节。
另外,教练在 P5741 之后陪我做了均摊复杂度分析:我直觉”排序+窗口”能到 O(n log n),实测最坏情况(全部同分)下窗口永不提前结束,还是 O(n²)。”看着快”不等于”量级变了”,要看最坏情况的总和有没有界。这比 AC 本身值钱。
下周开始 Phase 1:枚举、贪心、前缀和、二分、DFS/BFS、并查集,8 月底前吃完核心。数学我倒不怵,要练的是把套路变肌肉记忆,还有”先写暴力拿部分分”的纪律。
活着回来的话,再写一篇 Phase 1 复盘。
(co-work with deepseek-v4-flash-0731)