1. 先聊聊第6天这个节点:刷OJ不是拼题量,是拼思考
如果你也在刷OJ,应该有这样的感觉:前五天是最上头的,见题就想AC,AC完就想看排名,恨不得一天刷二十道。到了第六天,那股冲劲儿会稍微缓下来,大脑开始问一个很实在的问题——我刷这些题,到底在刷什么?
我今天想认真回答这个问题,也把第6天的完整记录整理出来。
OJ刷题这件事,说简单很简单:打开在线评测系统,读题,写代码,提交,等一个AC或者WA。但说复杂也复杂,因为每一道题背后都藏着一套评测逻辑、一组边界数据、一种复杂度约束,以及你对自己代码的掌控力。第六天刚好是“入门期”和“平台期”之间的分界线。前五天你已经熟悉了OJ平台的基本操作,知道怎么提交代码、怎么看报错,甚至已经AC过几道入门题。但从第六天开始,你要面对的是真正的思维转换——从“把题做出来”变成“把题做对、做稳、做优”。
这篇笔记适合谁看?两类人。一类是刚接触OJ平台、正在刷入门题的新手,你可以继续读下去,看看一个普通刷题者第六天是怎么规划、怎么踩坑的。另一类是刷了几百道题但总觉得没体系的老手,我建议你重点看第4节和第5节,里面整理了不少判题状态和调试排查的实操经验。
我自己目前的刷题节奏是:每天三到四道题,不多刷,但每道题都会做一遍、改一遍、再重新写一遍。今天总共提交了九次代码,AC了其中七次,两次WA之后找到问题所在并修正。整个过程看似简单,实际有很多值得掰开揉碎讲的东西。
2. 今天的刷题清单:三道题让我改了六个版本的代码
2.1 第一道题:多组输入下的A+B,远没有你想的那么无脑
每个刷OJ的人都会从A+B开始,但很多人不知道A+B其实有七八种变形。今天我特意选了“多组输入A+B”这道题来练手。题面很简单:输入多行,每行两个整数a和b,直到文件结束;每行输出对应的a+b。
看起来就是几行代码的事,但这里面埋着一个新手特别容易忽略的问题——你怎么知道输入什么时候结束?在本地编译器里测试的时候,你可以手动按Ctrl+Z或者Ctrl+D模拟文件结束,但提交到OJ平台后,评测系统是直接把整个测试文件注入到程序的标准输入流里的,程序必须正确处理EOF(End of File,文件结束标记)。
我第一次提交的时候写的是:
#include <stdio.h> int main() { int a, b; while (scanf("%d %d", &a, &b) != EOF) { printf("%d\n", a + b); } return 0; }这个写法没问题。但如果你写的是while (1)循环加break判断,或者你用了scanf("%d %d", &a, &b) == 2来判断,那就要格外注意循环内部的退出逻辑。我见过不少人在while里少写了一个大括号,导致输出语句跑到了循环外面,只输出了最后一行结果。
这里有个很实际的经验:多组输入的题,先确定终止条件,再写循环体。终止条件有几种常见写法:
scanf的返回值等于读入的变量个数(比如两个整数就判断== 2)scanf的返回值不等于EOF(等价写法)- 使用
while(cin >> a >> b),C++里这是最简洁的
我实际测试下来,第三种在OJ平台上的稳定性是最好的,因为C++的输入流能自动处理EOF状态。至于scanf和cin的性能差异,在数据量不到十万级的入门题里基本可以忽略不计。
2.2 第二道题:数组去重与排序,样例通过不代表逻辑正确
第二道题也是一个经典入门题:“输入N个整数,去掉重复的数字,从小到大输出。”题目本身不难,但给我的“惊喜”不少。
我最初的思路很简单:先把数组排序,然后遍历一遍,跳过和前面相同的元素。代码如下:
#include <stdio.h> #include <stdlib.h> int cmp(const void *a, const void *b) { return *(int *)a - *(int *)b; } int main() { int n, arr[1005], res[1005]; while (scanf("%d", &n) != EOF) { for (int i = 0; i < n; i++) scanf("%d", &arr[i]); qsort(arr, n, sizeof(int), cmp); int cnt = 0; for (int i = 0; i < n; i++) { if (i == 0 || arr[i] != arr[i - 1]) { res[cnt++] = arr[i]; } } for (int i = 0; i < cnt; i++) { if (i) printf(" "); printf("%d", res[i]); } printf("\n"); } return 0; }样例测试的时候完全没问题,但提交上去,第一个WA就来了。排查了半天,最后发现是cmp函数的问题。*(int *)a - *(int *)b这个写法在a和b都是正数时没问题,但如果差值溢出int范围就会出错。虽然这道题的数据范围没有那么大,但养成一个坏习惯比做错一道题更可怕。
正确的写法是:
int cmp(const void *a, const void *b) { return (*(int *)a > *(int *)b) - (*(int *)a < *(int *)b); }这种写法不会溢出,也是比较安全的通用写法。改完之后我又提交了一次,AC了。但这次WA让我反思了很久:在OJ刷题时,你写的每一个函数、每一个边界条件,都可能在测试数据中被放大考验。评测系统不会因为你只错了一个极端数据就手下留情。
2.3 第三道题:一道披着递归外衣的DP入门题
今天的第三道题是一个斐波那契数列变体:输入n,输出数列第n项,n能到1000。这道题有意思的地方在于——它看起来可以用递归写,但你只要真用递归提交,大概率收到TLE(超时)或者MLE(超内存)。
我第一次就是用递归写的,信誓旦旦地提交了,结果TLE。回头一看,递归里重复计算太多了。比如计算F(5)要先算F(4)和F(3),计算F(4)又要算F(3)和F(2),这个重复计算量是指数级的。
这类题的解法很简单,就是动态规划的思路——用一个数组把结果存起来,从前往后推:
#include <stdio.h> int main() { int n; long long dp[1005]; dp[0] = 0; dp[1] = 1; for (int i = 2; i <= 1000; i++) { dp[i] = dp[i - 1] + dp[i - 2]; } while (scanf("%d", &n) != EOF) { printf("%lld\n", dp[n]); } return 0; }等等,你以为这就结束了吗?没有。这个题还有一个大坑:n等于1000的时候,斐波那契数列已经超过long long的范围了。如果你用的是Python,那无所谓,整型可以无限大;但如果你像我一样用C或C++,就得考虑高精度加法——也就是用数组模拟大数的加法过程。
最后我改成用二维数组存每一位,手工进位来做。这个过程中我深刻体会到一个道理:OJ的入门题里,最难的往往不是算法本身,而是对数据范围敏感度。题目说n不超过1000,不是随口说说的,每一个数字都有它的意义。
3. OJ判题状态码速查:AC、WA、TLE背后的真实含义
3.1 状态码并不只是“过”和“不过”
很多刚开始刷OJ的人只会看两个状态:AC(通过)和WA(答案错误)。但实际提交之后你会发现,状态码至少有七八种。我今天的九次提交里,就吃到了WA、TLE两种状态码。这里把我整理过的常见状态码列出来,方便你对照查:
| 状态码 | 全称 | 含义 | 最常见的出现原因 |
|---|---|---|---|
| AC | Accepted | 通过 | 答案正确 |
| WA | Wrong Answer | 答案错误 | 思路有漏洞/边界没处理 |
| TLE | Time Limit Exceeded | 超出时间限制 | 算法复杂度过高/死循环 |
| MLE | Memory Limit Exceeded | 超出内存限制 | 数组开太大/递归层数太深 |
| RE | Runtime Error | 运行时错误 | 数组越界/除零/栈溢出 |
| PE | Presentation Error | 输出格式错误 | 多了空格/空行/大小写问题 |
| CE | Compile Error | 编译错误 | 代码语法问题 |
这里面最容易让人混淆的是PE和WA。PE说明你输出的答案内容是对的,但格式和评测系统的期望不一致,比如行尾多了一个空格,或者两个数之间的分隔符错了。我最早刷OJ的时候,觉得PE就是小事,结果后来发现很多题对格式有严格要求,尤其是一行输出的题目,最后一个数后面不能有空格。
有的OJ平台会把PE算作WA,有的会单独标出来。这个你要在题目页面的“说明”里看清楚。我今天做第二道题的时候就遇到类似的问题,特意把最后的空格处理干净才AC。
3.2 时间复杂度的自查方法:别等TLE了才去想优化
TLE是刷OJ的人最容易遇到也最头疼的状态码之一。我的经验是,在提交之前就应该预估自己的代码在最坏情况下的耗时,而不是等评测系统告诉你超时。
怎么预估?两个参数相乘就行:数据规模n乘以你的算法复杂度。假设题目给的n最大是10的5次方(100000),你的代码是两层for循环,那操作次数大约就是100亿次。以目前OJ评测机每秒执行大约10的8次方到10的9次方次基本操作的算力来看,100亿次肯定是跑不完的。
所以拿到一道题,第一步不是写代码,而是看一眼数据范围。数据范围是出题人留给你的最大暗示:
- n小于等于1000:O(n²)算法通常可以接受
- n小于等于10的5次方:O(n log n)是安全的
- n小于等于10的6次方或更大:建议用O(n)甚至O(1)的解法,或者考虑数学公式、前缀和这类优化
今天那道斐波那契变体题,我一开始用递归写,复杂度是指数级的,n到30就卡住了,n到1000直接爆炸。后来改成打表(预处理),复杂度变成O(n),完全没问题。
4. 本地过得了,OJ上就是过不了?六步排查法
4.1 第一步:检查输入输出格式
这是最基础但最容易被忽略的一步。本地测试的时候,人眼不会在意你输出的是1 2 3还是1 2 3(末尾多一个空格),但OJ的评测器是逐字节比对的。
我自己总结了一套自查清单:
- 输出结果是否有多余的空格?
- 最后一行的末尾是否有换行?
- 多组输入时,是否每组的输出中间有正确的分隔?
- 题目要求的是“Case #1:”这种前缀还是直接输出数据?
输入方面,检查你是否正确处理了EOF、是否使用了正确的输入类型(int还是long long)。我见过很多人题目说n可以到10的9次方,还在用int,结果数据溢出导致答案错误。用long long虽然不丢人,但并非所有OJ都支持%lld,老平台可能需要你用%I64d,这一点也要留意。
4.2 第二步:构造边界测试数据
本地能过的最大原因,是你只用了一组最普通的数据。比如第二道题的样例是5 3 1 2 3 1,输出是1 2 3。这个数据很温和,既没有重复全部相同的数字,也没有只有一个数的情况。
但评测数据是残酷的,它一定包含这些边界情况:
- 输入只有一个数
- 所有输入都相同
- 输入已经排好序
- 输入是逆序
- n是最大值
- n是最小值(比如0或1)
我的习惯是,每次写完代码先手动构造这几组数据跑一遍。今天第二道题如果没有跑“所有输入都相同”的情况,也不会发现那种极端场景下数组越界的问题。
4.3 第三步到第六步:数组大小、变量初始化、算法复杂度和平台差异
数组大小是RE的主要来源之一。新手常见问题是,题目说n不超过1000,但不代表中间过程不会用到更大的数组,尤其是DP题,你可能会开dp[1005][1005],这时候如果某个维度上界估计错误,就会越界访问。
变量初始化也是大坑。OJ评测不会像本地编译器那样默认把变量清零,有些编译器行为是有差异的。你最好在声明后手动初始化每用到一个变量,尤其是循环计数器。别问我为什么强调这个——我今天第三道题第一次WA就是因为dp[0]忘了赋值,本地编译器把它默认清零了,而OJ的编译器让它保持了一个随机值。
最后是平台差异。不同OJ使用的编译器和编译选项不完全一样,同一个代码在本地没问题,到OJ上可能CE。这个我放在下一节详细说。
5. 我摸过几个OJ平台各自的脾气:注册、交题与避坑
5.1 杭电OJ:经典中的经典,适合用来打基础
杭电OJ(HDOJ)在刷题圈里是老牌平台了,题量大、题目分类清晰,而且很多公司的笔试题目都能在它的题库里找到影子。它用的编译器版本比较旧,所以你在本地用新特性写得好好的代码,到那里可能CE(编译错误)。我个人的建议是,在杭电OJ交题前,尽量用老标准的C/C++语法,比如避免使用C++11之后新加入的特性。如果你想用auto关键字或者是unordered_map,建议先查一下它支持不支持。
另外,杭电OJ的注册和登录界面比较朴素,但功能齐全。它有一个很有用的功能是查看每道题的通过率和提交次数,这个可以帮你判断题目的难度。
5.2 郑轻OJ和杭师大OJ:学校自建平台,题目更贴近课程
郑州轻工业大学OJ(郑轻OJ)和杭师大OJ这类学校自建平台,给我的感觉是更贴近大学课堂和课程设计。它们的题量不像杭电那么大,但不少题目是老师自己出的,风格上偏向“数据结构作业”和“算法课设”,对正在学数据结构的同学很有参考价值。特别是郑轻OJ的题目前面往往会标注所属课程和难度,新手拿来当练习特别合适。
不过学校自建平台有个通病——高峰期的并发能力可能不够,尤其是考试周的时候,提交以后可能要排队等判题。遇到这种时候别慌,刷新一下状态页面就好。
5.3 华为OJ(现在叫华为OD机试)和XTU OJ:偏实战,题目审题要仔细
华为OJ最核心的价值在于它贴近笔试、机试场景。它和竞赛型OJ不一样,更强调在规定时间内跑通指定功能、处理指定的业务逻辑。如果你刷题是为了找工作笔试,华为OJ的题目风格值得专门练一练。
XTU OJ(湘潭大学OJ)是我最近发现的一个比较好用的平台,题目难度梯度设计得很好,特别是它的“perfect”系列题,专门考察边界处理和数学推导。我在第6天刷的几道题目里,就有一道是从XTU OJ上找的,虽然WA了两次,但第三次AC的时候对题意的理解完全不一样了。这类平台还有一个好处:题目页面会显示输入输出样例的多个变体,审题时认真看能省下很多WA。
5.4 平台选择的个人心得
平台不用贪多。我的建议是:日常练习主用杭电OJ,数据结构课程刷题用郑轻OJ,临近笔试前加练华为OJ。每个平台的判题规则略有差异,但核心的算法功底是一样的。跑遍再多平台,该学会的还是那几件事——读题、分析、设计、验证。
6. 今天踩过的大坑合集:如果早看到这些能省两小时
6.1 比较函数的溢出陷阱
前面提到第二道题的cmp函数问题,这里必须单独拎出来再强调一次。return *(int *)a - *(int *)b;是很多教程里给的写法,看起来简洁,但有隐患。当两个数都很接近INT_MAX或者INT_MIN时,减法会溢出,导致比较结果反转,排序错乱。
安全写法是:
int cmp(const void *a, const void *b) { int x = *(const int *)a; int y = *(const int *)b; if (x > y) return 1; if (x < y) return -1; return 0; }或者是之前提到的三目表达式写法。别小看这个细节,很多WA半天查不出原因的题,最后都是栽在这里。
6.2 多组输入时没有重置标志位
第三道题我还有一次WA,原因是多组输入循环里,我用了一个flag变量控制输出状态,但没在每一轮输入前重置它。第一组数据正常运行,第二组数据就出现输出错乱。这其实是多组输入题里非常经典的错误——每一轮循环都应该是一个独立的世界,该初始化的变量一个都不能省。
6.3 输出格式的“最后一个空格”问题
这个我在前面提到过好几次,因为它太常见了。把数组元素输出成一行、用空格隔开,常规输出方式有两种:
for (int i = 0; i < cnt; i++) { if (i) printf(" "); printf("%d", res[i]); }或者:
for (int i = 0; i < cnt; i++) { printf("%d%c", res[i], i == cnt - 1 ? '\n' : ' '); }我习惯用第一种,因为它修改起来最直观,也不容易忘记最后换行。
6.4 不要轻易用递归处理大规模数据
今天的斐波那契数列题目,很多人提交TLE之后会想“那我优化一下递归”。优化空间确实有,比如记忆化递归,但它本质上还是递归调用,函数调用栈的深度和开销在那里,到了大数据量依然容易出问题。能迭代就迭代,能打表就打表,这个思路在OJ刷题里永远是推荐的。
7. 第6天结束后的几点真实体会
我不是什么算法高手,也没有在ACM竞赛拿过奖,就是一个老老实实刷OJ、每天记笔记的普通学习者。但这六天下来,我最大的感受是:OJ刷题最有价值的并不是你AC了多少道题,而是你通过WA和TLE真正理解了多少个概念。
第六天这一整天的三道题,虽然看起来都很基础,但每一道都让我对输入输出处理、数据范围判断、代码鲁棒性有了更具体的认识。以前我以为“会写代码”就是能跑通样例,现在我知道,能在边界情况下依然正确、能在内存和时间的限制下高效运行,才是OJ真正想训练你的能力。
如果你也刷到了第六天,我建议你放慢一点节奏。不要急着追题量,试着把今天做过的每一道题重新写一遍,或者换一种方法再实现一次。甚至可以把代码扔到不同的OJ平台上跑一遍,看看平台之间的差异。这个习惯我用了六天建立起来,后面慢慢感受到了它带来的好处。
最后再分享一个小技巧:在OJ平台提交前,先压缩一下代码里的调试输出。那些用来调试的printf如果不删掉,不仅可能在输出里多出奇怪的内容导致WA,还会拖慢程序运行速度。真正的调试,要么在本地完成,要么用临时的#ifdef包裹住,提交前统一去掉。
第六天结束,第七天我计划开始碰一碰原汁原味的贪心算法题,到时候再把这些笔记整理出来。如果你也在刷OJ,欢迎在评论区留下你第六天踩到的坑,看看我们是不是都有相似的经历。