最近在DHUOJ上刷基础题,从第20题一路做到第30题。说实话,前面十几道都是热热身,真正让我停下来想了想的,是基础25、26、27这三道。这三道题的难度不算高,但特别适合拿来检验C语言(或者Python)基础里最核心的几块:循环、分支、输入输出格式、边界条件。很多刚接触OJ的同学会在这种题上反复吃WA,不是不会写,而是掉进了各种格式和细节的坑里。
这篇文章我不打算泛泛地讲“编程基础很重要”这种废话,就围绕三道题,把我实际做题时的思路、代码、踩过的坑、排查方法全部摊开讲。如果你正在刷DHUOJ或者任何其他OJ(比如HDUOJ、POJ、洛谷),这个系列的经验是通用的。看完之后,你至少能搞明白一件事:OJ的“基础题”到底在考什么,以及怎么稳稳地把AC拿到手。
1. OJ基础题的通用套路与DHUOJ的评测机制
1.1 做题前先把评测环境摸清楚
DHUOJ本质上就是一个Online Judge,你提交源代码,系统自动编译、运行,拿你的输出和标准答案做比对。听起来简单,但这套机制和平时在自己电脑上写代码的感觉完全不一样。本地跑通了不算数,评测机跑通了才算数。
我第一次用DHUOJ的时候,用的C语言提交,编译器选的GCC。后来我发现很多同学在OJ上连CE(编译错误)都遇到过好几回,最常见的两个原因:一是把C++的语法写进了C文件里,二是主函数写成了void main(),某些编译器严格模式下直接不给过。老老实实写int main(),最后return 0;,这个习惯从一开始就要养成。
还有一件事,提交前必须看清题目告诉你用哪个语言。DHUOJ支持C、C++、Java、Python这些,但不同题目的时限和内存限制是固定的。基础题一般不卡时间,但如果你用Python写特别复杂的循环,碰到大数据量也可能TLE。所以基础阶段我建议优先用C/C++练,对底层的循环、变量、内存理解更扎实。
另外一个新手最容易忽略的点:OJ的输入输出是“严格匹配”的。多一个空格、少一个换行,都可能让你从AC变成PE(Presentation Error,格式错误)。DHUOJ对待PE通常就是判错,不会给你放水。
1.2 读题永远比写代码更值得花时间
很多人的习惯是扫一眼题目、看完样例就开写。我自己也吃过亏。OJ的题面描述一般都很简洁,但里面藏着几个关键信息:输入范围、输出精度、多组测试还是单组测试、数据结束的标志是什么。
比如输入范围决定你用什么数据类型。题目说n最大是10^6,你开个int,循环计算的时候可能就爆了,还在那查半天WA。再比如输出要求“保留两位小数”,结果你写成%f,精度对不上,又是一个WA。这些都是可以提前规避的。
读题的时候我建议把三样东西圈出来:输入格式、输出格式、数据范围。哪怕多花五分钟,也比白白交三次WA强。
还有一个基础题里特别常见的设定:多组输入。有时候题目写“输入包含多组测试数据,每组占一行,处理到文件结束”,这时候你的代码就得写成:
while (scanf("%d", &n) != EOF) { // 处理每一组 }很多新手只会写一次scanf,结果只处理了第一组数据,评测机上后面的数据全没跑,直接WA。这种“EOF判读”的思路在OJ里几乎是必备技能,25、26、27这几道题虽然没有特别为难你,但后面一定会碰到,最好从一开始就养成习惯。
2. 第25题:递推数列的循环功底
2.1 题目场景与解题思路
DHUOJ基础25这题,我拿到手是这样的:给定一个正整数n,求一个递推数列的第n项。已知第一项是1,从第二项开始每一项等于前一项加上一个和项数有关的数。这类题目在OJ基础题里几乎是标配,考的就是循环和递推。
举个例子,假设规律是第k项等于第k-1项加2k-1。那么数列长这样:1、4、9、16、25……眼尖的同学可能已经看出来了,这其实就是n的平方。但如果题目直接告诉你求平方,那就太没意思了。它非要包装成递推,目的就是让你写循环。
我的思路很简单:从第1项开始,用一个long long变量存当前项的值,每次循环往里加增量。核心代码长这样:
#include <stdio.h> int main() { int n; scanf("%d", &n); long long ans = 1; for (int i = 2; i <= n; i++) { ans += 2LL * i - 1; } printf("%lld\n", ans); return 0; }这里面有两个点特别值得说。第一,为什么用long long不用int?因为如果n跑到10^6,第n项的值会远超int的范围。OJ特别喜欢在数据范围上挖坑,你以为int够用,结果中间计算爆了,答案自然不对。基础题的教训之一就是:凡是结果可能变大的,一律先考虑long long。
第二,为什么是2LL * i - 1而不是2 * i - 1?加上LL是为了让乘法以long long的精度进行,避免int溢出后再赋给long long。虽然在这个式子里2 * i本身不太会爆,但养成这个写法,后面处理大数的时候能少踩很多坑。
如果用的是Python版本,就不用担心int溢出,代码也更简洁:
n = int(input()) ans = 1 for i in range(2, n + 1): ans += 2 * i - 1 print(ans)2.2 易扣分的两个细节
这道题的WA大户集中在两个地方。
第一个是循环次数。有人写for (int i = 2; i < n; i++),少了一次循环,n=1的时候可能碰巧对,n=3就开始错了。这种边界条件,做题时必须自己验证一遍。建议每次写完代码,先拿题目给的样例测,再自己脑补n=1、n=2、n=最大值的几组数据。
第二个是变量初始化。有人把ans = 1写到了循环里面,结果每次循环都把答案重置了,最后输出的永远是最后一次增量。这种问题本地编译不会报错,跑起来结果莫名其妙,只能靠经验避免。我的习惯是:循环外面放“起点状态”,循环里面只做“状态转移”,这个思路在写DP的时候同样适用。
3. 第26题:打印菱形——循环嵌套的标准练习
3.1 图形类题目的通用拆法
DHUOJ基础26,经典中的经典:输入一个奇数n,输出一个由星号组成的菱形。例如n=5的时候输出:
* *** ***** *** *这种图形题考查的核心是循环嵌套和数学归纳。拿到手不要急着写,先拆。
菱形上下对称,上半部分有(n+1)/2行,下半部分有(n-1)/2行。以n=5为例,上半部分3行,下半部分2行。每一行由两部分组成:前面的空格和后面的星号。
拿上半部分第i行来说(i从1开始):空格数是(n+1)/2 - i,星号数是2*i - 1。n=5时,第1行空格2个、星号1个;第2行空格1个、星号3个;第3行空格0个、星号5个。
下半部分其实就是上半部分倒过来,从i = (n-1)/2递减到1。
思路理清了,代码就很好写:
#include <stdio.h> int main() { int n; scanf("%d", &n); int m = (n + 1) / 2; // 上半部分,m行 for (int i = 1; i <= m; i++) { for (int j = 1; j <= m - i; j++) { printf(" "); } for (int j = 1; j <= 2 * i - 1; j++) { printf("*"); } printf("\n"); } // 下半部分,m-1行 for (int i = m - 1; i >= 1; i--) { for (int j = 1; j <= m - i; j++) { printf(" "); } for (int j = 1; j <= 2 * i - 1; j++) { printf("*"); } printf("\n"); } return 0; }说句实话,这类题第一次写的时候很容易绕晕。我见过一个同学用了一个巨复杂的二维数组先把图形存下来再输出,能跑对,但完全没有必要。碰到图形题,先找行号和空格/星号的数量关系,再把公式写出来,代码自然就顺了。
3.2 输出格式的隐藏陷阱
这道题我WA了两次才过,原因说出来有点丢人:第一版代码每行末尾多了个空格。
从肉眼上看,"* "和"*"似乎没什么区别,但OJ是按字符逐字节比对的,多一个空格都不行。还有人在每行输出星号之后多打印了一个换行,导致整个菱形中间多了一行空白,也是PE。
另一个隐藏比较深的坑是“行末换行”。有些人会想:我每行末尾已经printf("\n")了,如果整个图形前面、后面再空一行,是不是也无所谓?答案是不行。OJ要求你的输出和标准答案“完全一致”。所以写上return 0;之前,建议在脑子里跑一遍:第一行有没有多余的前导空格,最后一行输出完有没有换行,有时候题目要求最后一行也有换行,有时候不要求,看题。
我也总结了一个小技巧:遇到图形题,先在草稿纸上写出n=1、n=3、n=5三种情况的正确图形,以此作为标准答案,写完代码后逐一对比。这样能把大量格式错误提前拦截住。
4. 第27题:数据统计里藏着边界意识
4.1 题目设定与常规解法
DHUOJ基础27,这次不是图形了,是一道数据统计题。题目大概是:输入一个正整数n,再输入n个0到100之间的整数,统计及格率、优秀率和不及格人数。及格线是60分,优秀线是85分。
第一眼看上去很简单,无非就是循环读入、if判断、计数器累加。但想一次性AC,还是有些细节要处理好。
我的解法如下:
#include <stdio.h> int main() { int n, score; int pass = 0, good = 0, fail = 0; scanf("%d", &n); for (int i = 0; i < n; i++) { scanf("%d", &score); if (score >= 60) { pass++; } if (score >= 85) { good++; } if (score < 60) { fail++; } } printf("%.2f%% %.2f%% %d\n", 100.0 * pass / n, 100.0 * good / n, fail); return 0; }这里最关键的是那个100.0 * pass / n。很多人写成绩统计题时会写成pass / n * 100,结果输出永远都是0.00。原因很简单:整数除以整数,结果还是整数,pass / n在pass小于n的时候直接就是0,再乘以100也是0。你必须在运算的一开始就把其中一个操作数转成浮点数,100.0 * pass这一步就是在做这个事。
4.2 边界条件与测试用例设计
这道题更值得记笔记的地方,是边界条件。
n=1的时候,只有一个分数。如果它是95分,及格率、优秀率都是100.00%,不及格人数是0。这三个条件同时成立,代码能不能正确处理?if (score >= 60)和if (score >= 85)是两条独立的if,不是else if,这样才能让一个分数同时被统计进及格和优秀。如果你用了else if,优秀人数就会被吃掉一大半,最后算出来的率全错。
再比如,n=1且这个分数是50分,及格率0.00%,优秀率0.00%,不及格人数1。这种极端数据你平时可能不会注意,但OJ的测试点里面一定有。所以每写完一道题,我习惯在本地把这些极端边界跑一遍:
- 最小值:n=1
- 分数边界:0分、59分、60分、84分、85分、100分
- 全及格、全不及格、全优秀
这一套组合测下来,基本能保证逻辑上没有漏判。
还有一个格式问题:百分号怎么打印。printf里想输出一个%,必须写成%%。我见过有人直接在字符串里写了一个%,结果编译报错或者输出乱码。printf("%.2f%%\n", rate);这个写法看起来有点怪异,但它就是标准做法。
5. 常见问题排查速查表与避坑技巧
刷OJ和调试本地程序完全是两种节奏。本地写代码,跑出结果你还能打断点、看变量;OJ上你只有一个判定结果,信息量很少。所以我整理了一张DHUOJ基础题的常见问题排查表,都是我实际踩过或看别人踩过的坑。
| 判定结果 | 常见原因 | 排查方法 |
|---|---|---|
| CE(编译错误) | 主函数返回类型不是int;用了C++语法但提交了C | 本地用gcc严格编译一次,把warning也当error看 |
| WA(答案错误) | 算法逻辑不对;数据类型溢出;边界条件漏判 | 拿极端样例自测;把int换成long long试试 |
| PE(格式错误) | 多空格、少换行、行末有多余输出 | 和题目给的输出样例逐字符比对;特别注意行末空格 |
| TLE(运行超时) | 循环次数过多;算法复杂度太高;死循环 | 检查循环退出条件;看n的范围,如果超过10^7就要优化 |
| RE(运行错误) | 数组越界;除数为0;递归栈溢出 | 检查所有下标范围;确认n不为0;避免深递归 |
针对这些排查,我再说几个亲测有效的实操习惯。
第一,别用“题解对答案”式刷题。我一开始刷OJ的时候,WA了就看别人代码,看得懂,但下次遇到还是错。后来改成“WA了先自己查半小时”,实在不行才看提示,效果完全不一样。基础题的价值不在于你AC了几道,而在于你独立排查出了几个bug。
第二,多用assert或中间打印。但OJ上不要留打印语句,否则会产生额外输出,必然PE。本地调试的时候可以随便打印,提交通道前记得注释掉。我自己常用的做法是创建一个“本地测试版”,里面加上一些调试输出,AC之后再提交干净版本。
第三,警惕浮点数输出精度。题目要求保留两位小数,就老老实实用%.2f。别自作主张改成%.3f或%g,评测系统是按固定格式比对的,多一位小数字符都不一样了。
6. 从三道基础题延伸到整个刷题习惯
6.1 刷题后必做的“复盘三件事”
三道题全部AC之后,不要急着做下一题。我会回到每一道题,问自己三个问题:
第一,我的代码在最坏数据下能不能跑完?比如n最大是10^6,我的算法是O(n)还是O(n^2)?如果是O(n^2),那评测机很可能TLE。
第二,我的代码能不能处理不规则输入?比如输入中间夹着空行,数据后面有多余空格,scanf实际上会自动跳过空白字符,但如果你是按行读字符串再手动解析,就要小心这些情况。
第三,有没有更简洁的写法?我做完26题之后,发现有人用两段循环分别处理上半部分和下半部分,也有人用一个对称的下标公式合并成一个循环。两种都能AC,但前者更容易阅读和维护。OJ不考代码风格,但你以后写的代码是要给人看的,趁着基础题练习的时候养成清晰的习惯,很划算。
整理错题也是我很推荐的做法。不需要多精美,一个备忘录就行,记录:题号、WA次数、WA的原因、最终怎么解决的。我刷完25、26、27之后翻了下记录,发现自己的WA原因高度集中在“格式化输出”和“边界值漏判”上。意识到这个规律之后,后面的题我写完就会先主动检查这两方面,AC率真的提升了很多。
6.2 给刚起步的人的一点实在建议
DHUOJ基础25、26、27这三道题,单独拿出来都不难。它们真正的价值,在于逼你把“写代码”变成“写对代码”。在OJ上,代码跑通不是终点,正确、稳定、通过所有测试点才是终点。这种思维方式越早建立,后面做算法题、参加比赛、甚至写工程项目都会受益。
如果非要说一个最重要的心得,我会选“边界意识”。写循环的时候想一下边界,写输出的时候想一下格式,写除法的时候想一下类型。很多WA都不是不会写,而是这几个地方没想清楚。把这套意识带进后面的每一道题,基础阶段就算真正过关了。最后再分享一个小习惯:每次AC一道题之后,去讨论区看看别人的代码,不用多,两三份就行,你会发现同一个问题有人用三行解决,有人用三十行解决,那种差异本身就是特别好的学习素材。