如果你用东华oj刷过题,应该能理解“自用”这两个字的分量。刷题记录变长之后,比起网上那些零散题解,自己整理的那份笔记才是真正顺手的东西。我的自用笔记里,题号16到20这五道题占了挺重要的位置,它们正好卡在基础语法训练和初级算法入门之间:16题最大公约数与最小公倍数、17题回文串判断、18题数组去重、19题成绩排序、20题杨辉三角。这篇就把我在这五道题上的完整思路、实际代码和踩坑记录整理出来,给同样在东华oj上刷题的人一个参考,也方便自己以后回头查。
这套题适合两类人:刚把C语言语法过完、想通过在线评测验证自己写代码能力的新手,以及刷到中间阶段、总在边界条件上WA或者RE的老手。前者可以完整跟一遍我的思路,后者可以直接跳到你感兴趣的那道题看坑位。我要提前说明,东华oj的题号在不同时期注册的账号下可能有细微调整,我记录的是经典版本,真正的考点几乎每届都会轮换着出,思路通用性很强。
1. 题号16-20在刷题路线上的真实定位
1.1 为什么说这几题是“算法思维分水岭”
东华oj前面十几道题,大部分是单点语法训练:给个输入,套个循环,打印结果,题目怎么说你就怎么写。到了16题开始,事情悄悄变了。你光知道语法不够,还得先想清楚“计算过程怎么设计”。
拿第16题来说,最大公约数最直白的写法是枚举,从较小的数开始往下试。数据范围小的时候没问题,可一旦测试数据里塞进两个接近10的9次方的数,枚举就直接超时。这时候你需要换思路,用辗转相除法把复杂度降到对数级别。这种“意识到代码正确不一定能跑过、必须优化算法”的转变,对初学者来说是第一道坎。
第20题杨辉三角也是这样。第一反应可能是用组合数公式 C(n, k) 去算每一项,但你很快就发现 n 一大,阶乘直接溢出,long long 都不够用。正确做法是放弃公式,用二维数组递推,每一行的值只依赖上一行。这就是典型“算法设计比代码技巧更重要”的例子。16到20这几题不是单纯让你练手,而是逼着你从“我愿怎么写就怎么写”过渡到“先设计再动手”。
1.2 我的自用工具链:本地VS Code加GCC,再加一个踩坑记录本
我在东华oj上刷题的环境很简单:本地Visual Studio Code写代码,编译器用MinGW的GCC,提交语言选C。我不太建议直接在网页文本框里写长代码,一旦编译错误排查起来效率太低。本地写好、跑完样例之后,再把代码复制到提交框,这是最稳的流程。
调试方面,我习惯用freopen重定向输入输出,把样例内容存成文本文件,一跑就能对结果。但这里有个很重要的细节:提交前一定要把freopen注释掉。在线评测系统不认你的本地文件路径,一旦忘删,基本上就是Runtime Error,白白浪费一次提交机会。我后来想了个办法,用宏定义隔开:
// 本地调试时取消下面这行注释 // #define LOCAL_DEBUG #ifdef LOCAL_DEBUG freopen("in.txt", "r", stdin); freopen("out.txt", "w", stdout); #endif提交的时候只要不定义LOCAL_DEBUG,就完全不影响线上判题。类似的工具选型问题,我还专门在笔记里记了一条结论:不依赖集成开发环境的实时报错,先把C语言标准库的常用函数吃透,剩下的交给编译器提示。
另外我强烈建议搞一个自己的踩坑记录本。我用的就是一个Markdown文件,按题号整理易错点。比如“17题:gets会读入换行符,记得干掉”这种一句话记录,等下次复习时翻出来特别管用。人脑对WA的细节记忆是会模糊的,白纸黑字写下来才能形成自己的知识库。
2. 五道题的完整拆解:思路、代码与易错点
2.1 第16题:最大公约数与最小公倍数
题目要求很经典:输入两个正整数 a 和 b,输出它们的最大公约数和最小公倍数,多组数据输入,直到输入两个 0 时结束。这道题考的就是辗转相除法和最小公倍数的溢出问题。
先看求最大公约数。辗转相除法的核心是:gcd(a, b) = gcd(b, a % b),一直递归到余数为 0,此时另一个数就是答案。用循环写是这样:
int gcd(int a, int b) { int t; while (b != 0) { t = a % b; a = b; b = t; } return a; }这个过程中不需要你手动比较 a 和 b 谁大谁小。就算初始 a 小于 b,取一次余数后两个数自动换位,进入标准流程。
最小公倍数的公式是lcm = a / gcd * b。注意这里不能写成a * b / gcd,因为可能会溢出。举个例子,a 和 b 都接近 10 的 9 次方时,乘积是 10 的 18 次方量级,int 根本装不下,甚至 long long 也可能临界。但先除再乘,a 除以 gcd 的结果不会超过 b 的规模,再乘 b 才安全。所以我把 lcm 声明成long long,用(long long)a / g * b计算。
完整代码里还需要处理“直到输入两个 0”的终止条件:
#include <stdio.h> int gcd(int a, int b) { while (b != 0) { int t = a % b; a = b; b = t; } return a; } int main() { int a, b; while (scanf("%d %d", &a, &b) != EOF) { if (a == 0 && b == 0) break; int g = gcd(a, b); long long lcm = (long long)a / g * b; printf("%d %lld\n", g, lcm); } return 0; }这题我第一版提交WA在lcm = a * b / g上,当时样例数据比较小跑通了,但评测系统里的边界数据直接把溢出炸出来。这也是我踩过的第一个显而易见的“思路对但类型不够”的坑,从那之后只要涉及乘法我都习惯先估算数据范围。
2.2 第17题:回文串判断
第17题的题面通常是输入一个字符串,判断它正着读和反着读是否相同,输出Yes或者No。字符串长度给到1000以内,不含空格。这道题直接考察字符串处理,但陷阱藏在输入函数和双指针写法上。
思路很简单:两个指针,一个从字符串头开始,一个从末尾开始,往中间扫。一旦发现字符不相同,直接判断失败;如果两个指针相遇或者交叉,说明是回文串。代码这样写:
#include <stdio.h> #include <string.h> int main() { char s[1005]; while (scanf("%s", s) != EOF) { int left = 0; int right = strlen(s) - 1; int flag = 1; while (left < right) { if (s[left] != s[right]) { flag = 0; break; } left++; right--; } printf("%s\n", flag ? "Yes" : "No"); } return 0; }这里最大的坑在于scanf读取字符串的时候不会读入空格。题目说“不含空格”还好,但东华oj上凡是字符串题,我建议都先确认题面到底怎么表述的。有些字符串题目实际上是含空格的句子,这时候必须用gets或者fgets来读一整行,而gets在C11标准里被标为不安全,编译环境如果开了严格告警会报warning,我当时在本地怎么编译都过,提交就CE,后来换成fgets才解决。
fgets会连换行符一起读进来,所以拿到手之后需要手动去掉末尾的\n:
fgets(s, sizeof(s), stdin); s[strcspn(s, "\n")] = '\0';还有个细节是,回文串判断里“大小写是否区分”要看题面。有的题目写“忽略大小写”,那就要先把字符串里的大写字母统一转成小写再判断。我习惯写一个循环先处理,避免后面判断时每写一次比较都考虑大小写。
2.3 第18题:数组去重
第18题常见表述是:输入 n 个整数,输出去重后的个数,以及去重后的序列。排序方面有的要求升序,有的要求降序,我遇到的是升序输出。题目数据范围 n 不超过1000,但每个整数的绝对值可能到10的6次方。
看到“去重”两个字,新手第一反应往往是:每读入一个数,就和前面所有数比较,有重复就跳过。这种 O(n的平方) 的写法在小数据里没问题,但我建议走出这个舒服区:先排序再去重。排序复杂度是 O(n log n),去重一次遍历完成,整体稳定且好写。
去重的核心代码:
#include <stdio.h> #include <stdlib.h> int cmp(const void *a, const void *b) { return (*(int *)a - *(int *)b); } int main() { int n, i; int a[1005]; scanf("%d", &n); for (i = 0; i < n; i++) { scanf("%d", &a[i]); } qsort(a, n, sizeof(int), cmp); int cnt = 0; for (i = 0; i < n; i++) { if (i == 0 || a[i] != a[i - 1]) { a[cnt++] = a[i]; } } printf("%d\n", cnt); for (i = 0; i < cnt; i++) { printf("%d", a[i]); if (i < cnt - 1) printf(" "); } printf("\n"); return 0; }这段去重写法有个巧妙点:直接从原数组里把不重复的元素往前覆盖,省掉额外开一个新数组。判断条件i == 0 || a[i] != a[i - 1]保证了连续重复的第一个元素被保留,其余重复项被丢掉。
这题我也见过有人用桶排序,思路是直接开一个数组,下标作为数值,读到哪个数就把对应位置加一。这种方案效率极高,但前提是数值范围窄。如果数值跨度达到10的6次方甚至更大,桶数组消耗内存太大,评测环境不一定扛得住。所以我的结论是:常规情况下用排序去重最稳妥,既不超时也不超内存。
2.4 第19题:成绩排序
第19题是一道结构体加自定义排序的题目,题面是输入学生人数,每个学生有学号和成绩,要求按成绩从高到低排序,成绩相同的时候按学号升序排序。这道题的意义在于:东华oj给你机会练习“结构体排序”,这是后面很多实际问题的地基。
我当时的做法是用qsort配合自定义比较函数:
#include <stdio.h> #include <stdlib.h> #include <string.h> typedef struct { char id[20]; int score; } Student; int cmp(const void *a, const void *b) { Student *sa = (Student *)a; Student *sb = (Student *)b; if (sa->score != sb->score) { return sb->score - sa->score; } return strcmp(sa->id, sb->id); } int main() { int n, i; scanf("%d", &n); Student stu[1005]; for (i = 0; i < n; i++) { scanf("%s %d", stu[i].id, &stu[i].score); } qsort(stu, n, sizeof(Student), cmp); for (i = 0; i < n; i++) { printf("%s %d\n", stu[i].id, stu[i].score); } return 0; }比较函数里最容易被忽略的是返回值类型。qsort比较函数返回的是 int,当两个成绩相同时再比较学号,strcmp返回值也是 int,正好符合要求。但如果你手写冒泡排序,可能就会写出“交换判断时没处理成绩相等情况”的错误。所以这道题我推荐用qsort,把排序规则集中在比较函数里,不容易漏掉次级排序条件。
另外,成绩排序是降序,但学号是升序。初学者容易把两者搞混,写反之后排序结果看着很像,但成绩相同的几个学生的先后顺序就不对了。这类题评测数据里必定包含多组同分情况,专门用来抓这种错误。
2.5 第20题:杨辉三角
第20题考二维数组和递推,题面是输入行数 n,输出杨辉三角的前 n 行。每一行的首尾都是 1,中间的数等于它左上角和右上角的数相加,转换成数组下标就是a[i][j] = a[i - 1][j - 1] + a[i - 1][j]。
代码主体非常简单:
#include <stdio.h> int main() { int n, i, j; scanf("%d", &n); int a[105][105] = {0}; for (i = 0; i < n; i++) { a[i][0] = 1; a[i][i] = 1; for (j = 1; j < i; j++) { a[i][j] = a[i - 1][j - 1] + a[i - 1][j]; } } for (i = 0; i < n; i++) { for (j = 0; j <= i; j++) { printf("%d", a[i][j]); if (j < i) printf(" "); } printf("\n"); } return 0; }这道题的难点几乎都在输出格式。东华oj对空格的容忍度是严格区分的,行末多一个空格,大概率会收到Presentation Error。我当年就在这上面罚了好几次,最后总结出来的经验是:每个数后面要不要跟空格,取决于它是不是当前行的最后一个数。
还有一个容易翻车的点:数组初始化。二维数组没有做全局或局部初始化时,a[i][i]这个位置的值是不确定的,好在我在定义时写了= {0},把所有元素先清零,再按规则赋值。如果偷懒不初始化,第一行第一个数赋值1没问题,但后续行首行尾都靠显式赋值,中间值由上行算出来,倒是不会出错。不过为了稳妥,还是建议一律初始化。
我看到有些同学想直接用组合数 C(n, k) 计算每一个位置,理论上也成立,但 n 稍微大一点,阶乘就会炸掉。印象中杨辉三角题型会测试 n 较大时的情况,所以递推数组是更可靠的做法。这正好呼应开头说到的“分水岭”:你需要主动选择复杂度更好的算法,而不是看着公式就往里套。
3. 实操细节:输入输出和判题规则上的三个坑
3.1 scanf与换行符的纠缠
在线评测题的输入大多是基于文本的,而文本里最常见的干扰项就是换行符。scanf("%d", ...)和scanf("%s", ...)会自动跳过空白字符,所以读整数、字符串时不会遇到换行符问题。但如果你用scanf("%c", ...)读单个字符,它就老老实实把上次输入后留下的那个\n读进去了,这是初学者最容易犯的错。
我在第17题和其他字符相关题里反复吃过这个亏,后来总结出一个习惯:只要程序里既用了scanf读数字或字符串,又需要读字符,就在中间补一个getchar()把换行符吞掉。或者干脆统一用scanf加上固定格式比如scanf(" %c", &ch),注意%c前面多留一个空格,这会让scanf先跳过空白字符再读。
3.2 多组输入用EOF还是标志位
东华oj很多基础题都是多组测试数据,读取方式一般分两种:一种是读直到文件结束,也就是while (scanf(...) != EOF),适合题目说“输入多组数据,每组占一行”但没有明确结束标志的情况。另一种是读到一个特殊值结束,比如第16题的“0 0”。
这两种方式不能随意混用。如果题目给定了结束标志,你却只写了while (scanf(...) != EOF),程序会永远读下去,即使读到结束标志也不会break,导致输出多余内容。反过来也一样,题目明明是多组EOF结束,你却写了个“读到0停止”,那正常数据中的0会被当成非法输入。
我的建议是看题面时先把“输入结束条件”圈出来,再决定循环怎么写。第16题我用了while (scanf(...) != EOF)加上内部if (a == 0 && b == 0) break;,这样既保证文件读完也能正常结束,又支持标志位结束,算是一个比较通用的写法。
3.3 输出格式:空格和换行的边界
PE和WA的区别,很多刷题人没搞清楚。PE意思是你答案的内容正确,但输出格式不对,比如行末多空格、少了空行、大小写写错。WA是答案本身不对。在实际体验里,有些OJ会把格式错误直接算作WA,东华oj一般会明确给PE,但不管哪种,你都得返工。
处理格式问题的最稳办法是:每个输出值之间用什么分隔,提前想清楚。需要空格分隔的值,最后一个后面不要再加空格;需要换行的场景,最后一行末尾的换行最好保留,但也不要多打印一个空行。我的办法是先写一个包含固定输出的框架,然后用样例数据核对一遍肉眼可见的格式,再提交。虽然笨,但对这类题特别有效。
4. 常见问题与排查实录
4.1 WA不是玄学,边界值是首选怀疑对象
很多人收到WA的第一反应是“是不是评测系统有问题”,实际上大部分都是自己的代码在边界条件下出了问题。我排查WA有一套固定顺序:先看题目里的数据范围,然后拿最大值、最小值、0、1、负数这几种输入在本地试跑,对比输出是否合理。第16题的最小公倍数公式,在边界数值下会溢出;第18题的排序去重,在 n=1 时我的循环也能正常处理,因为i == 0 || a[i] != a[i - 1]用了短路判断,不会访问到a[-1]。这种边界细节,只有亲手试过才有印象。
我整理了一张速查表,遇到WA时可以对着查:
| 可能原因 | 表现特征 | 排查方法 |
|---|---|---|
| 边界值溢出 | 样例通过但大数据WA | 计算最大数据范围下的中间结果,改long long |
| 循环边界写错 | 少一行或多一行输出 | 用1和2作为最小输入试跑 |
| 比较方向写反 | 排序相邻数据互换 | 构造一组相等数据测试 |
| 输出格式不符 | 被判PE | 检查行末空格和空行数量 |
| 忽略结束条件 | 输出多余内容 | 确认题目的终止标志 |
4.2 RE:数组越界的经典案例
Runtime Error最常见的来源就是数组越界。第20题杨辉三角,如果你按题目给出的 n 上限正好开int a[105][105],并访问到a[105][105],在C语言里这就是越界访问,虽然有时候侥幸没崩,但评测系统在严格环境下会直接判RE。
我的习惯是把数组开大一点,比如 n 最大是100,我就开a[105][105]或者a[110][110],多留一点余量。不是因为代码规范要求有多严谨,而是评测环境真的会因为这个报错。类似的还有字符串数组,长度1000的字符串,数组至少开成1001,因为要留一个位置给结尾的'\0'。
4.3 CE:本地能编译,提交却编译错误
编译错误的一大来源是命名撞上了系统保留字。比如有的人在全局区写一个变量名qsort或者eof,本地GCC没报错,但提交到OJ的编译器可能对某些头文件里的函数声明有冲突,导致编译失败。还有一个常见问题是main函数返回值类型写错,标准写法必须是int main(),有人写成void main(),部分OJ编译器能通过,部分直接CE。
我的建议是写代码时尽量避开系统库函数和常见宏的名字,比如index、select、time这些词都有同名系统函数的风险。第19题里我用Student作为结构体名,是因为它不可能和标准库冲突,这种命名习惯能少踩很多雷。
4.4 分享一个WA转AC的排查实例
有一年我刷第18题,样例怎么都对,提交就是WA。当时我用的是桶排序方案,自认为把最大值当数组长度,逻辑没有漏洞。后来我打印出每次实际读入的最大值,发现题目给出的整数可以在正负之间变化,而我开的桶只覆盖了非负范围,一旦出现负数全部越界,越界读出的垃圾值自然影响判重结果。改回排序去重后一次通过。这个经历让我意识到:选算法之前,必须把题目的取值范围和符号都看清楚,尤其是“一维数组排序去重”这种题,用的常规排序方案普适性很强。
5. 自用刷题之外:最后分享几个小习惯
我在东华oj刷到16到20这个阶段时,形成了一个固定动作:每到一道题AC之后,就在笔记里写一句“核心思路是什么、当时哪一步容易错”。这个习惯后来帮我省了大量复盘时间。比如第16题我记的是“最小公倍数用a/g*b防溢出”,第17题记的是“fgets要吃换行”,第19题记的是“同分再比学号”。这些规律性的东西,比重新做一遍题更容易变成你自己的思维资产。
我还建议给本地代码命名时加上题号和目标,比如20_yanghui.c,这样以后找到某一题特别方便。碰到多次提交不过的情况,我会复制一份带_wa后缀的代码保存下来,AC之后再把两份放一起对比。有时看自己错在哪,比看正确答案还有收获。
另外,代码里凡是涉及文件重定向、调试printf,提交前扫一遍确认已注释。我给自己的流程是:写完代码,先跑样例,确认通过后,全局搜索freopen和printf("debug"),确认没有遗漏再提交。这套流程听着简单,但能稳稳妥妥地帮你把无关错误挡在第一步之外。