刷东华OJ基础题刷到74-76这个位置,很多人会有一个共同的感受:前面七十多道题写起来挺顺的,基本就是练练循环、数组、条件判断,到这儿突然觉得题目“变味”了。
不是题目超纲,而是这阶段的题开始把多个知识点揉在一起考。你可能上一题还在处理简单数学公式,下一题就要同时处理字符串、数组索引、边界条件,甚至还要自己琢磨输入格式。基础74-76题恰恰是东华OJ基础区里一道分水岭:刷过去,后面再看结构体、链表、递归这些章节,心态会稳很多;卡在这儿,很容易怀疑自己是不是不适合写代码。
这篇就专门聊这个阶段。我会把74-76这几题常见的出题套路、解题思路、提交时容易踩的坑都拆开讲一遍。不管你是刚把循环搞明白的新手,还是刷题进度卡住的“半卡户”,这篇都值得看完再动手写。
1. 东华OJ基础74-76题的整体定位与核心难点
1.1 为什么刷到这儿突然觉得吃力
先说说我对东华OJ基础题区的整体印象。前面大概70题,基本上是一道题对应一个考点:判断闰年、求阶乘、数组逆序、冒泡排序,只要你把对应的模板记熟了,套进去就能过。代码量普遍在二三十行以内,思路也比较直。
到了74-76这个区间,题目描述开始变长,考点也开始叠加。我举个例子,纯考察“判断回文串”的题,前面已经出过好几道了,但到中后段如果再出现回文相关题,往往会加上“忽略空格和标点”“不区分大小写”“输入可能包含多组数据”这些附加条件。表面上看还是回文判断,实际考的是字符串预处理、边界处理、多组数据循环这三件事的组合。
这种变化让很多习惯“背模板”的人很难受。因为模板解决的是单考点问题,而组合题需要你自己去拆解:这题到底要我做几步?每一步的输入输出分别是什么?中间哪些地方可能出边界问题?
1.2 这个阶段真正考察的能力:拆题
我在带学弟学妹刷题的时候,发现一个挺常见的现象:拿到一道题,读了三遍,然后就开始写代码,写到一半发现思路不对,删掉重来。这不是代码能力问题,是缺少拆题的环节。
所谓拆题,就是把一道完整的题目分成三块来看:
- 输入是什么?包括数据的类型、数量、格式,以及是否有结束条件(比如读到EOF为止)。
- 处理过程是什么?这一步是核心,决定了你要用哪些变量、哪些循环、哪些条件判断。
- 输出是什么?包括格式、精确位数、换行要求。
拿基础74-76这类中后段的题来说,80%的“不会做”其实不是不会写代码,而是没有把题目里的逻辑梳理清楚。我在这个阶段养成了一个习惯:先拿纸笔写伪代码,用自然语言把处理过程描述一遍,再转成C语言。这么做前期看起来多花几分钟,实际上能省下反复调试的一两个小时。
1.3 中后段基础题常见的三个出题方向
根据东华OJ基础题区的整体分布,74-76这个位置通常逃不开这三个方向:
- 字符串处理类:读入字符串、逐字符判断、大小写转换、子串操作。
- 数值计算与数论入门:素数判断、进制转换、最大公约数、数字拆分重组。
- 数组操作与简单逻辑:去重、排序、矩阵操作、按规则筛选。
我后面会分别把这三个方向拆开讲,每个方向都会给出可以直接“抄作业”的思路和代码框架。
提示:如果你刷到某道题发现不是这三类,先别急着怀疑,看看题目的核心数据是“字符”还是“数字”还是“一组数的排列”,大概率是这三类的变种。
2. 字符串处理题:先搞定一行数据的读入,再谈逻辑
2.1 一个必踩的坑:scanf和gets混用
字符串处理题在这个阶段频繁出现,而新手栽跟头最多的地方,不是字符串的逻辑处理,而是“数据读不进来”。
典型场景是这样的:题目先给你一个整数n,表示后面有n行字符串,然后要求你对每一行做处理。很多人的第一反应是:
int n; char str[100]; scanf("%d", &n); gets(str);然后发现:第一行字符串读出来是空的,或者直接跳过了一行。原因很简单,scanf(“%d”) 读走的是数字,回车符还残留在输入缓冲区里,紧接着的gets把这个回车符当成空串读走了。
解决方式也很简单:在scanf之后、gets之前,用一个getchar()把残留的换行符吃掉:
int n; char str[100]; scanf("%d", &n); getchar(); // 吃掉换行符 gets(str);如果你用的是fgets,也一样,需要处理这个残留换行。这个问题几乎每学期都有一堆人踩,属于这个阶段“打过一次就再也不会忘”的经典坑。
2.2 回文判断的完整拆解
假设题目是:给一行字符串(可能包含空格和标点),判断忽略空格、标点、大小写之后是否为回文。这个题目就很典型,能代表74-76阶段的字符串难度。
我的做法是分三步走。
第一步,读入整行。考虑到字符串中可能有空格,不能用scanf(“%s”)读,得用gets或fgets:
char str[1000]; gets(str);第二步,过滤掉非字母数字字符,统一转为小写或大写,存到一个新数组里:
char clean[1000]; int len = 0; for (int i = 0; str[i] != '\0'; i++) { if ((str[i] >= 'A' && str[i] <= 'Z') || (str[i] >= 'a' && str[i] <= 'z') || (str[i] >= '0' && str[i] <= '9')) { if (str[i] >= 'A' && str[i] <= 'Z') { clean[len++] = str[i] + 32; // 大写转小写 } else { clean[len++] = str[i]; } } } clean[len] = '\0';第三步,双指针判断:
int left = 0, right = len - 1; int flag = 1; while (left < right) { if (clean[left] != clean[right]) { flag = 0; break; } left++; right--; } if (flag) { printf("YES\n"); } else { printf("NO\n"); }这里要特别提醒:判断结束条件是 left < right,不是 left <= right。如果字符串长度为偶数,用 <= 会在中间两个字符错位后多判断一次,导致误判。
2.3 字符串处理题的其他常见变化
74-76阶段的字符串题,除了回文判断,还经常出这些变种:
- 统计某个字符出现的次数,注意大小写是否合并统计。
- 把字符串中的单词按顺序输出,每个单词之间用空格隔开。
- 字符串加密或解密,比如循环移位。
这类题的处理套路是一致的:先逐字符遍历,把需要的字符挑出来,再对挑出来的内容做进一步处理。不要试图“一步到位”——边遍历边输出有时确实能过,但逻辑一复杂就容易乱,还是先处理到新数组里更稳妥。
3. 数值计算与数论入门题:数学建模是核心
3.1 素数判断:别只会从2除到n-1
这个阶段如果遇到素数相关题目,首先得判断只是单纯判断一个数,还是要输出一堆素数。这两者的复杂度差别很大。
如果只是判断单个数是不是素数,用从2到sqrt(n)的循环就够了:
int isPrime(int n) { if (n < 2) { return 0; } for (int i = 2; i * i <= n; i++) { if (n % i == 0) { return 0; } } return 1; }注意两个细节:一是n小于2直接返回0,二是循环条件用 i * i <= n,不要用 i <= sqrt(n)。前者是整数运算,快;后者每次循环都要调用sqrt函数,效率低,而且涉及浮点数精度,边界容易出错。
如果是要求输出一个区间内所有素数,建议直接用埃氏筛。这个阶段接触筛法确实显得有点超前,但它的原理很好理解:从2开始,把所有2的倍数标记为合数,然后找下一个没被标记的数,再把它的倍数标记掉。
int isPrime[1000001]; void initPrime(int n) { for (int i = 2; i <= n; i++) { isPrime[i] = 1; } isPrime[0] = isPrime[1] = 0; for (int i = 2; i * i <= n; i++) { if (isPrime[i]) { for (int j = i * i; j <= n; j += i) { isPrime[j] = 0; } } } }我自己就是在这个阶段第一次接触筛法,当时觉得“这也太麻烦了吧”,但后面做数据结构的题时经常用到,那时候才发现这个思路有多重要。如果这道题的数据范围是10万以上,你还在用逐个判断的写法,几乎必然超时。
3.2 进制转换:除基取余,注意逆序输出
进制转换题在基础题中后段基本是保留曲目。最常见的是十进制转R进制,思路就一句话:除R取余,逆序排列。
举个例子,十进制13转二进制:
- 13 % 2 = 1,13 / 2 = 6
- 6 % 2 = 0,6 / 2 = 3
- 3 % 2 = 1,3 / 2 = 1
- 1 % 2 = 1,1 / 2 = 0
把余数从下往上排,得到1101。代码实现上,用一个数组存余数,最后倒着输出:
void decToR(int n, int r) { int ans[100]; int len = 0; if (n == 0) { printf("0\n"); return; } while (n > 0) { int mod = n % r; if (mod >= 10) { ans[len++] = 'A' + (mod - 10); } else { ans[len++] = '0' + mod; } n /= r; } for (int i = len - 1; i >= 0; i--) { printf("%c", ans[i]); } printf("\n"); }这里面最容易被忽略的是n等于0的情况。很多人的while循环条件一写,n=0时就压根不进入循环,输出就成了空。这种边界情况就是OJ的爱考的点。
反过来,R进制转十进制也要会。思路是从高位往低位逐位处理:result = result * r + 当前位数字。
int rToDec(char str[], int r) { int result = 0; for (int i = 0; str[i] != '\0'; i++) { int digit; if (str[i] >= '0' && str[i] <= '9') { digit = str[i] - '0'; } else { digit = str[i] - 'A' + 10; } result = result * r + digit; } return result; }3.3 数字拆分:取模和整除的配合
还有一种经常混在74-76里的题:给一个数,要求逆序输出、求各位之和、判断某位数字出现次数等。这类题核心就是取模和整除的配合。
while (n > 0) { int digit = n % 10; // 处理 digit n /= 10; }这个循环体每转一圈,就处理了当前最低位,然后砍掉最低位。如果你要的是从高位往低位处理,一种办法是先算出这个数的位数,另一种是把数字转成字符串处理。说实话,字符串在某些时候反而更简单,比如判断某位数字出现次数时,直接遍历字符串就行。
注意:如果题目要求保留前导零,比如输入是00123,那就不能用整数读入了,必须按字符串处理。这个细节经常成为WA点。
4. 数组操作与简单逻辑:先想清楚数据怎么存
4.1 数组去重:两种思路对比
数组去重题在OJ里出现频率极高,形式变化也很多:有的要求去掉重复值然后原顺序输出,有的要求统计每种值出现次数,有的要求按出现次数排序。但底层逻辑都一样:需要记录“这个值是否已经出现过”。
最直接的做法是暴力双重循环:外层遍历每个元素,内层看它是否在之前已经出现过。这个方法我一开始也在用,但写到后面发现,每次内层都要从头扫,时间复杂度是O(n^2),数据量一大就跑不动了。
更好的做法是“空间换时间”:用一个标记数组,记录每个值是否出现过。比如数据范围是1到10000,就开一个长度为10001的数组:
int seen[10001] = {0}; int result[1000]; int resultLen = 0; for (int i = 0; i < n; i++) { if (!seen[a[i]]) { seen[a[i]] = 1; result[resultLen++] = a[i]; } }这个思路的核心是:把“这个值出现过没有”这个信息存下来,而不是每次去现查。很多初学者会觉得“开这么大的数组太浪费了”,其实现代OJ内存通常是128MB甚至更多,一个10000的int数组才40KB,完全不用担心。
4.2 排序题:要搞清楚排序规则
这一类题只要涉及排序,很多人直接掏出冒泡排序或选择排序的模板开始写。但在74-76阶段,题目往往会要求“按分数排序,分数相同按名字字典序排序”“按出现次数排序,次数相同按首次出现顺序排序”之类的复合规则。
这时候要先确定一件事:交换两个元素的依据是什么?如果是双关键字排序,就要把比较的逻辑单独拎出来:
if (score[i] > score[j] || (score[i] == score[j] && strcmp(name[i], name[j]) > 0)) { // 交换 }这种写法放在冒泡里很直观。推荐先把比较条件写在纸上,再往代码里套,不容易乱。
4.3 矩阵类题目:下标的对称关系
矩阵相关的基础题也常在74-76出现,比如求主对角线元素之和、副对角线元素之和、矩阵转置。核心是要记住下标规律:
- 主对角线:i == j
- 副对角线:i + j == n - 1
如果同时要求“不包括两条对角线交点”,要判断 n 是奇数还是偶数。交点元素在主对角线和副对角线相交处,n为奇数时存在,n为偶数时不存在。这类细节题目不会明说,但样例里通常藏着答案。
另外,矩阵输入的行列顺序也要看清,是先输入行数还是先输入列数,直接影响双重循环的嵌套顺序。
5. 从“本地能跑”到“OJ能过”的实操过程
5.1 先看输出格式:Presentation Error 的根源
很多新手第一次被PE(格式错误)整懵,就是在基础题中后段开始。明明输出内容是对的,答案却判错,问题往往出在空格和换行上。
我踩过最经典的一个坑:题目要求输出一行多个数,每个数字后面跟一个空格。我写成了“先输出第一个数,再循环输出空格+数”,结果最后面多了一个空格,被判PE。
后来我养成了一个习惯:把题目给的样例输出复制到文本编辑器里,用“显示所有字符”功能看它末尾有没有空格。虽然有点笨,但很管用。对于输出格式,记住一句话:严格照着题目示例来,不要自由发挥。
5.2 多组输入与EOF:必须掌握的循环框架
基础题中后段开始频繁出现“输入包含多组测试数据,每组以...结束”的描述。处理多组输入的通用框架是:
int n; while (scanf("%d", &n) != EOF) { // 处理这一组数据 }这里有个容易忽视的坑:每处理完一组数据,该重置的变量一定要重置。比如求和变量sum、计数器cnt,要放在while循环内部初始化,不能放在外面。否则第二组数据的结果会把第一组的数据加进去。
5.3 调试三板斧:打印、注释、分段
在这个阶段,你不可能每道题都一遍过。调试能力决定了你刷题效率的上限。
我的调试流程是:
- 在代码里加临时printf,把关键中间变量打出来,比如循环里的i、数组当前元素、sum的当前值。
- 用题目给的样例去跑,逐步核对中间结果和手算结果是否一致。
- 定位到出错的分段后,把该段代码单独拎出来测,甚至写一个小的测试函数。
- 调通之后,把临时printf全部删掉再提交。
这个方法听着简单,但能解决80%的逻辑错误。不要在代码里猜,直接看变量实际值,很多时候一眼就能看出问题在哪。
5.4 边界测试清单
每次提交前,我习惯先过一遍这些边界值:
- 输入为0或1,程序是否还能正确处理?
- 输入为最大范围值,比如数组长度是1000就测1000,数据值是上限就测上限。
- 输入包含多组数据时,第一组和最后一组是否正确。
- 字符串数组是否可能在str[MAX]的最后一个下标溢出。
- 用int类型的变量去存乘法结果,是否会溢出。
这些问题在OJ上是实实在在的WA来源。一个看起来能过的代码,往往就是栽在这些边界细节上。
6. 常见问题与避坑速查
6.1 OJ提交报错类型速查
| 报错类型 | 一般原因 | 建议对策 |
|---|---|---|
| Compile Error | 语法错误、变量名冲突 | 本地编译过了再提交,选对语言 |
| Runtime Error | 数组越界、除零、栈溢出 | 检查所有下标边界,排查除以变量的运算 |
| Wrong Answer | 逻辑错误、边界未处理 | 对照样例,构造边界测试数据 |
| Time Limit Exceeded | 算法太慢 | 减少循环层数,用筛法/哈希替代暴力 |
| Presentation Error | 多余空格、缺少换行 | 把样例输出的空格和换行一个一个数清楚 |
6.2 基础题阶段的高频坑点清单
- scanf和gets混用,缓冲区残留换行,导致字符串读不进来。
- 数组开小了,越界写不报错,但在OJ上表现为WA或RE。
- int溢出,特别是做乘法和累加时,注意改用long long。
- 忘记输出换行符,导致两个输出粘在一起。
- 没有正确处理“多组输入”,每组之间的变量没有重置。
- 题目要求“忽略大小写”却直接比较原字符。
- 读题时漏看“从大到小”“从小到大”“保留两位小数”等修饰词。
6.3 卡题时的求助策略
卡题超过半小时,我建议按这个顺序做:
- 回到题目原文,一个词一个词地重新读一遍,重点关注“输入格式”和“输出格式”两节。
- 手工演算一遍题目给的样例,确认自己的输出和标准输出是否真的完全一致。
- 加printf调试,检查中间步骤。
- 如果还是不行,再考虑看别人的题解。
看题解也有讲究。不要直接复制代码,先看别人的思路,然后合上题解自己写一遍。如果看完题解直接粘贴过去,AC了也没多大意义,下次遇到同类题照样卡。
7. 最后说几点个人体会
东华OJ基础74-76题这几道,在整个刷题路径里不算难,但它卡住过很多人,也筛掉了很多人。我在帮学弟学妹改代码时,发现一个有意思的规律:这个阶段能独立刷过去的人,后面学结构体、链表、DFS这些内容的时候,普遍不太慌;而靠搜题解混过去的人,到后面往往又会回来补基础。
这个阶段最值得刻意练习的,不是某一道题的解法,而是“拿到题先想清楚再做”的习惯。我刚刷到这儿的时候,也经常拿到题就开写,写一半发现思路错了。后来强迫自己在草稿纸上写伪代码,把输入、处理、输出三块列出来,AC率确实上了一个台阶。
我自己现在帮人讲题,也是先问对方一句:你能用大白话把题目要求说清楚吗?能说清楚,基本就成功了一大半;说不清楚,代码写得再漂亮也白搭。这个建议同样送给你,刷题路上慢慢体会吧。