磨了大半个下午,终于把DHUOJ上标着"基础"的79、80、81三道题全部AC了。说实话,这三道题在题库里排的位置很微妙:说难吧,它们基本不涉及算法;说简单吧,我身边好几个同学在这里卡到怀疑人生,提交列表里红红一片。如果你也在刷OJ的入门阶段,或者正准备从"看得懂语法"过渡到"写得对代码",这三道题绝对值得静下心来认真走一遍。
它们刚好对应编程入门最核心的三个结构:顺序、分支、循环。把它们吃透,后面刷任何基础题都会顺很多。
1. 三道题在DHUOJ入门路线中的位置:为什么从它们开始
1.1 OJ判题到底在判什么
很多第一次接触在线评测系统(Online Judge)的同学,会对"Wrong Answer"这种东西感到愤怒:明明Visual Studio里面跑得好好的,怎么粘上去就错了?这不是OJ在找茬,而是它用一套极其严格的规则在检验你的程序输出。
OJ的判题逻辑通俗讲就是:你用代码写了一个"黑盒",评测系统往里输入一堆测试数据,然后逐字节比对你的程序输出和标准答案输出。多了空格、少了换行、大小写不一致、输出了一个多余的提示语,全部算错。这也解释了为什么很多人本地跑得好好的,一交上去就WA——因为你可能加了"请输入n:"这样的提示语句,或者输出格式里多了一个空格。
这三道基础题的作用,就是让你在最低的算法复杂度下,把"输入怎么读、输出怎么排版、边界怎么处理"这些跟语言本身无关的硬规矩练熟。
1.2 79、80、81分别卡在哪个知识点
我特意去翻了这几道题的提交记录和讨论区,再结合自己重新做的过程,整理出它们的定位:
| 题号 | 核心考察点 | 对应结构 | 新手常见折戟点 |
|---|---|---|---|
| 79 | 多组数据的读取与整型运算 | 顺序结构 | scanf返回值与EOF概念不清 |
| 80 | 条件组合与逻辑运算符 | 分支结构 | 闰年条件漏写、运算符优先级混乱 |
| 81 | 循环嵌套与累加器 | 循环结构 | 素数判断边界处理不当、超时 |
也就是说,这三道题是一组"连续技"——79题让你明白OJ的数据是怎么进来的,80题让你学会怎么根据条件分流,81题则是把前两题的能力整合在一起,用循环去解决一个需要重复判断的问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 顺序结构实战:79题多组输入的EOF读写
2.1 题目本质:读一组,算一组,清一组
79题的具体描述我记不完整了,但这类基础计算题的套路非常统一:输入包含多组测试数据,每组包含两个正整数a和b,要求对每一组输出a+b、a-b、a*b、a/b(整除)、a%b这样一串运算结果,每组输出之间用空行隔开。
难点从来不在四则运算本身,而在于"多组"二字。如果题目规定"输入第一行为一个整数T,表示有T组数据",那还好办——先用scanf读入T,再写个for循环跑T次就行。但这类题通常不告诉你有几组,而是让你一直读到文件结束。这意味着程序必须有能力感知"没有更多输入了"。
对C语言来说,这个感知能力来自scanf的返回值。scanf每成功读入一个变量,就会返回1,所以scanf("%d%d", &a, &b) != EOF的意思就是:只要还能按这个格式读进两个整数,循环就继续;一旦读到文件末尾,scanf返回EOF(即-1),循环终止。
很多新手把循环条件写成while(scanf("%d%d", &a, &b)),这样也能跑,因为成功读取时返回2,非零即真,读到末尾返回-1,也是非零,循环反而不会退出。这就是经典的"死循环读脏数据"。
2.2 一份可以直接抄的C语言实现
c复制#include <stdio.h>
int main() {
int a, b;
while (scanf("%d %d", &a, &b) != EOF) {
printf("%d\n", a + b);
printf("%d\n", a - b);
printf("%d\n", a * b);
printf("%d\n", a / b);
printf("%d\n", a % b);
printf("\n");
}
return 0;
}
我自己的习惯是在scanf那条语句外面再加一层保护,写成while (scanf("%d %d", &a, &b) == 2),因为如果输入里混了个字母,scanf会返回0或1,这样的写法更严谨。对这道题来说,!= EOF和== 2效果一样,但对后面一些更复杂的读取场景,用后者能规避掉很多奇奇怪怪的坑。
2.3 为什么OJ题偏爱"读到EOF"而不是"告诉你有几组"
从出题人的角度看,"读到文件末尾"这种模式能保证评测数据文件的纯净性——评测系统投喂给程序的数据文件里就是干干净净的一组组整数,不需要额外的第一行说明信息。从学习者的角度看,这是必须适应的第一个"工业标准":真正的竞赛题、面试编程题里,多组输入读到EOF是绝对主流。
这里还有一个隐藏知识点:printf("\n");在每组数据末尾多输出一个空行,但如果题目要求的是"每组数据间用一个空行分隔",最后一组后面多一个空行一般也能过,因为OJ比对时通常对行尾空白的处理比较宽容。我自己为了保险,经常把输出逻辑写成"第一组前面不打印空行,从第二组开始前面先打印一个空行",这样整个输出的视觉效果和标准答案完全一致。
c复制int first = 1;
while (scanf("%d %d", &a, &b) == 2) {
if (!first) {
printf("\n");
}
first = 0;
printf("%d\n", a + b);
// 其余输出省略
}
这个"首行标记法"在后面做表格类输出、图形类输出时非常常用,79题正是练习它的最佳时机。
3. 分支结构实战:80题闰年判断里的运算符优先级陷阱
3.1 闰年规则的两层逻辑
80题如果我没记错,是给定一个年份,判断它是否为闰年,输出对应结果。很多人听到闰年第一反应是"能被4整除",于是提笔就写:
c复制if (year % 4 == 0) {
printf("Yes\n");
}
这个答案交上去会WA,因为闰年的完整规则是:能被4整除但不能被100整除,或者能被400整除。也就是说,"能被4整除"只是必要条件,不是充分条件。1900年能被4整除,但它是平年;2000年能被400整除,它才是闰年。
之所以有这条规则,是因为回归年的实际长度是365.2422天,用"四年一闰"来补,会补多;所以又加了一条"逢百年不闰,除非能被400整除"来修正。这属于历法知识,但OJ题考它,本质是考逻辑表达式的组合能力。
3.2 优先级问题:&& 和 || 到底谁说了算
正确的判断条件写作:
c复制if ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)) {
printf("Yes\n");
} else {
printf("No\n");
}
注意这里括号的用法。C语言里&&的优先级高于||,也就是说如果不加括号,year % 4 == 0 && year % 100 != 0 || year % 400 == 0会被解析成(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)。幸运的是,这个解析结果恰好也是对的,所以很多人不写括号也能AC。但这属于"碰巧对了",一旦后面遇到更复杂的组合,裸奔的优先级表达式很容易翻车。
我的建议非常明确:逻辑表达式里面运算符越多,越要加括号。这不是给编译器看的,是给三天后的自己看的。你写完代码交上去AC了,两周后再回来看,脑子里想的是"当时那个判断条件到底是怎么组合的",这时候括号就是你最好的笔记。
3.3 一个更容易出错的变体:else if 的排列顺序
这道题还有种写法是先把特殊情况拎出来:
c复制if (year % 400 == 0) {
printf("Yes\n");
} else if (year % 100 == 0) {
printf("No\n");
} else if (year % 4 == 0) {
printf("Yes\n");
} else {
printf("No\n");
}
这种写法的好处是:每一步判断都是"单个条件",不需要组合逻辑,对新手极其友好。它背后的思维方式叫"分治法"——把所有情况划分成互不重叠的子集,然后逐层筛选。
我自己在给学生讲题时,非常推荐这种阶梯式写法。它虽然啰嗦一点,但每一个分支的意图都清清楚楚。更重要的是,这种结构能帮你避开一个经典错误:把能被400整除的年份先丢到"能被4整除"的分支里处理掉了,后面再也没机会走到正确分支。顺序结构的所有细节,到这里已经渗透得差不多,正好进入81题来一次综合检验。
4. 循环与算法思想:81题素数求和的边界测试
4.1 题意分析:不仅要会判,还要会累加
81题的典型描述是:输入一个正整数n,输出2到n之间所有素数之和。素数(质数)的定义是大于1且只能被1和自身整除的自然数。所以2是第一个素数,3是,4不是,5是,以此类推。
这道题考验的不再是单个知识点,而是三件事的串联:外层循环从2遍历到n、内层循环判断当前数是否为素数、累加器把每一个素数加起来。任何一环出问题,整个程序就崩了。
我们先看最简单的暴力写法:
c复制#include <stdio.h>
int main() {
int n;
while (scanf("%d", &n) == 1) {
int sum = 0;
for (int i = 2; i <= n; i++) {
int isPrime = 1;
for (int j = 2; j < i; j++) {
if (i % j == 0) {
isPrime = 0;
break;
}
}
if (isPrime) {
sum += i;
}
}
printf("%d\n", sum);
}
return 0;
}
这里有几个关键细节值得展开。
4.2 内层循环的边界为什么是 j < i 而不是 j <= i / 2
很多教材会写j <= i / 2甚至j < i,目的都是在保证覆盖所有可能因子的前提下尽量少算。判断i是不是素数,理论上只需要检查2到i-1之间有没有能整除i的数,所以j < i已经是满分答案,不会漏判。但它的效率很差:判断一个数是否是素数,要从2试到它自己之前,时间复杂度O(n)。
更进一步,j * j <= i其实更高效,也就是只需要试除到sqrt(i)为止。原因是:如果i有一个大于sqrt(i)的因子p,那么它必定对应一个小于sqrt(i)的因子i/p。换句话说,只要小于等于根号号的范围内没有因子,大于根号的范围也不可能有。这是素数判断里最核心的一条优化,也是很多算法题里"复杂度从O(n)降到O(nlogn)"的起点。
用这个优化改内层循环:
c复制for (int j = 2; j * j <= i; j++) {
// 判断逻辑不变
}
注意这里用j * j <= i而不是j <= sqrt(i),原因有两个:一是浮点数sqrt函数有精度损耗,在边界处可能出错;二是整数乘法在CPU里就是一条指令,比调用库函数快得多。这就是"能整型就整型,能免浮点就免浮点"的工程直觉,这种直觉越早建立越好。
4.3 边界测试:不只是样例过了就算过
很多同学在OJ上提交一次就AC了,以为万事大吉。但在真正的工程视角里,边界测试才是检验程序是否健壮的试金石。就拿81题来说,有四个边界值必须自己想清楚:
n = 1时,循环for (int i = 2; i <= n; i++)一次都不会执行,sum保持0,输出0。这一般是符合题意的,因为2到1之间不存在素数。n = 2时,i从2开始,i=2进入内层循环,j*j <= 2即4 <= 2为假,内层循环不执行,isPrime保持1,sum累加得2。输出2。n = 3时,2和3都是素数,sum是5。- 比较大的n,比如10000,暴力算法会不会超时?200000呢?这就是另一层考验了。
第四个问题其实是在提醒你:OJ题目里经常隐藏上限,比如n <= 1000000,这时候暴力试除法就不适合了。用数学眼光看,你需要一种更高效地找出所有素数的办法,这就是后面会接触到的埃拉托斯特尼筛法。但对于81题这种基础题,判题数据一般不会把n出得太大,暴力法通过没问题。
4.4 一个我踩过的输出坑:sum的初始化位置
最开始我写过这样的代码:
c复制int sum = 0;
while (scanf("%d", &n) == 1) {
for (int i = 2; i <= n; i++) {
...
}
printf("%d\n", sum);
}
把int sum = 0;放在while循环外面,导致第二组测试数据进来时,sum还在上一组的基础上继续累加。第一组输出是对的,第二组开始全错。这种错误比算法错误更隐蔽,因为样例数据往往只有一组,复制粘贴成多组测试时才会暴露。
正确的做法是让sum在每一次处理新组数据之前归零。这是"多组输入"题型的通用原则:所有累加器、计数器、标记变量,都要在每一轮循环开始前重置,否则上一轮的状态就是这一轮的脏数据。
这道题做完之后,建议你顺手做一个变式练习:把"求和"改成"输出2到n之间的所有素数,每5个一行"。这能把输出格式控制、计数器重置这些能力一并练到,性价比极高。
5. 从这三道题里提炼出的通用AC套路
5.1 "读-算-写"三段式结构
把79、80、81放在一起看,你会发现它们共享同一个骨架:
- 读入数据(scanf,注意多组输入到EOF)
- 逐组处理(计算、判断、循环)
- 按格式输出(printf,注意换行和空行)
这就是OJ入门题的"三段式"。任何一道基础题,第一反应都应该是把它拆成这三段,再分别填内容。不要从头到尾一气呵成地写代码,而是先搭骨架,再填血肉。
我见过太多新手一上来就开始写循环体,写到一半发现自己忘了考虑输入格式,又回头改。这种"自底向上"的写法在简单题里或许能勉强过关,但题目一旦复杂起来就完全失控。正确的姿势是"自顶向下":先写一个空壳main函数,里面放好三个注释区块,然后逐个填充。
5.2 处理多组输入的两个固定模板
这两块模板建议直接背下来,尤其是对笔试、机试场景,能省下大量无谓调试时间。
第一种,已知组数T:
c复制int T;
scanf("%d", &T);
while (T--) {
// 每组处理逻辑
}
注意这里while(T--)利用了后缀自减的特性:先判断T当前的布尔值再减1。T是3时,循环执行3次后T变成0,循环退出。简洁,但可读性稍差,我一般写成for (int cas = 1; cas <= T; cas++),顺带还能用上cas变量作为"第几组"的输出标记。
第二种,未知组数,读到EOF。对应模板就是79题里展示的方法,用while (scanf(...) == 期望读入个数)是最稳的写法。
5.3 再说说编译器和OJ的关系
还有一个很多人忽略的细节:OJ后端使用的编译器和你本地可能不一样。本地用的Visual Studio默认支持C++语法,VC编译器对某些C用法的处理也比较宽松;而OJ很多用GCC/G++,标准模式默认把一些警告当错误处理。具体来说,C语言要求变量声明尽量在代码块开头、scanf必须判断返回值,这些在GCC的严格模式下可能直接CE(Compile Error)。
所以养成两个好习惯:所有变量在函数开头声明(C89风格),代码里不要有任何无用的变量和未使用的头文件。这不仅是OJ的要求,也是代码整洁的基本素养。
5.4 升级路径:这三道题之后刷什么
79-81跑通后,你手里应该已经握住了三张牌:会处理多组输入、会写条件判断、会写循环累加。下一步的刷题路线建议按这个递进走:
- 字符串处理题:读取字符串、统计字符个数、反转字符串,把
scanf("%s")换成gets或fgets,顺便了解缓冲区溢出的概念。 - 数组与矩阵题:二维数组实现矩阵转置、螺旋矩阵输出,把循环和下标控制练熟。
- 排序题:冒泡排序、选择排序,理解O(n^2)的含义,为后面算法复杂度打地基。
不要一上来就刷动态规划,那是给自己找不痛快。基础题的顺序设计是有讲究的,79到81在知识上连成一条线,在难度上是平滑过渡,等你闭着眼睛都能写出这三种结构时,再往后推进,每道题都会学得更扎实。
提示:刷OJ最忌讳"只求AC,不求理解"。一个题AC了,可以用三种不同方法再写一遍,每次体会不同写法的优劣,这比连续刷十个同类题更有价值。
6. 写在最后:从小白到AC,差的不是智商,是套路
回头看这三道题,我最大的感触是:OJ入门阶段卡住人的,从来不是数学不好,也不是智商不够,而是对输入输出规则、代码结构、边界处理这些"潜规则"不熟悉。把这些规则变成肌肉记忆,后面的路会越走越顺。
我在实际练习中还发现一个特别有用的习惯:每AC一道题,我都会在代码注释里写一句话,总结这道题让我新学会的东西。比如79题的注释是"scanf返回值为成功读入变量个数,EOF文件结束",80题是"闰年判断先分再组,避免优先级问题",81题是"sum的初始化位置必须放在每一轮数据处理的内部"。三个月后回看这些注释,就是一部最真实的学习手册,比任何教程都有针对性。也希望你从这三道题开始,建立起属于自己的刷题笔记体系,哪怕每次只写一行,积累起来都会很可观。
