1. 从NOJ前100题说起:这套题到底在练什么
如果你在西工大读工科,大概率绕不开NOJ这个平台。C程序设计这门课,很多专业大一就开,而NOJ上的前100题,基本就是配合课堂进度的一套“手速+思维”训练集。我带过几届学弟学妹刷这套题,发现一个很普遍的现象:很多人把题做完提交通过就完事了,结果期末上机考试换个壳子就不会写。问题出在——他们只把NOJ当成了“交作业的地方”,没把它当成“练基本功的道场”。
这套前100题,表面上看是100道零散的编程练习,实际上它的编排是有内在逻辑的。从最基础的输入输出、四则运算,到分支判断、循环嵌套,再到数组、字符串、函数、指针、结构体,最后收在递归和简单算法上。它几乎覆盖了C语言程序设计的全部核心语法点,而且每道题都对应一个具体的“应用场景”——比如求圆的面积、判断闰年、打印乘法口诀表、统计字符个数、矩阵转置、冒泡排序等等。这些场景不是随便编的,它们都是后续专业课(数据结构、算法、操作系统)里反复出现的基本操作单元。
所以,刷这套题的正确姿势不是“刷完”,而是“刷透”。什么叫刷透?就是每做完一道题,你能说清楚三件事:这道题考的是哪个语法点?我用的解法是不是最合理的?如果题目条件变一下,我还能不能写出来?举个例子,NOJ上有一道经典的“计算分段函数”的题,很多人用if-else堆出来就交了。但如果你多想一步:这个分段函数能不能用条件运算符写得更简洁?如果分段点从两个变成五个,我的代码结构还撑得住吗?这种思考,才是这套题真正的价值所在。
另外要提醒一点:NOJ的评测机制是黑盒测试,它只看你的输出结果对不对,不看你代码写得漂不漂亮。这就导致很多人养成了“能过就行”的坏习惯——变量名用a、b、c,缩进乱七八糟,注释一句不写。短期看没问题,但到了期末上机考或者后续做课程设计的时候,这种代码风格会让你吃大亏。我的建议是,从第一题开始就按规范来:变量名要有意义,该缩进就缩进,关键逻辑加一行注释。这个习惯一旦养成,后面受益无穷。
2. 前30题:输入输出与表达式里的隐藏考点
2.1 为什么“格式错误”比“答案错误”更让人抓狂
NOJ前30题里,绝大多数是输入输出和简单表达式计算。很多新手第一次遇到“Presentation Error”或者“Wrong Answer”的时候,第一反应是“我算的对啊”。没错,你算的是对的,但输出的格式不对。比如题目要求输出“sum = 15”,你只输出了“15”,或者多了一个空格、少了一个换行,评测机都会判错。
我见过最典型的一个坑:题目要求“输出保留两位小数”,有人用了printf("%f", x),结果输出的是6位小数,直接WA。还有人用了printf("%.2f", x),但题目要求的是“四舍五入到两位”,而C语言的%.2f默认是“四舍六入五成双”的银行家舍入规则,在某些边界值上会和题目预期不一致。这种细节,你不踩一次坑根本不会注意到。
提示:NOJ的评测机对输出格式极其敏感。提交之前,务必把题目里的“输出格式”那一行逐字读一遍,包括标点符号、空格数量、换行位置。一个实用的技巧是,把题目要求的输出样例复制到记事本里,用“显示不可见字符”功能看一眼,确认空格和换行的位置。
2.2 整数除法和浮点数除法的经典陷阱
前30题里有一类题专门考“除法”。比如“输入两个整数,输出它们的商”。很多人随手写a / b,然后发现结果不对——因为C语言里两个整数相除,结果会自动截断成整数。比如5 / 2得到的是2,不是2.5。要得到浮点结果,必须写成(double)a / b或者a * 1.0 / b。
这个知识点看起来简单,但每年都有大量的人在NOJ上栽跟头。更隐蔽的是“整数溢出”问题。比如题目要求计算“1到100的和”,你用int类型没问题。但如果题目要求计算“1到100000的和”,int就可能溢出,需要用long long。NOJ上有一道题是“计算阶乘”,输入20,输出20的阶乘。20的阶乘是2432902008176640000,这个数已经超过了int的范围(约21亿),必须用long long才能存下。很多人不知道这一点,结果输出一个负数,百思不得其解。
2.3 字符输入里的“回车陷阱”
前30题里还有一类题涉及字符输入。比如“输入一个字符,输出它的ASCII码”。很多人用scanf("%c", &c)读字符,然后紧接着用scanf("%c", &d)读下一个字符,结果发现第二个字符读进来的是回车符。这是因为第一次输入后按了回车,回车符留在了输入缓冲区里,被第二次scanf读走了。
解决这个问题有几种办法。第一种是在两次scanf之间加一个getchar()把回车吃掉。第二种是用scanf(" %c", &c),注意%c前面加一个空格,这个空格会跳过所有的空白字符(包括回车、空格、制表符)。第三种是干脆用gets()或者fgets()读一整行,然后自己解析。我个人推荐第二种,写起来最简洁,也不容易出错。
注意:
gets()函数因为不安全(不检查缓冲区长度)已经在C11标准里被移除了,NOJ的编译器可能还支持,但强烈建议不要用。用fgets()替代,虽然要多写一个参数,但安全得多。
3. 中间40题:循环、数组与字符串的硬骨头
3.1 循环边界:从“差一错误”到“死循环”
从第31题开始,NOJ的难度明显上来了,循环结构成为主角。循环里最容易犯的错误就是“差一错误”(off-by-one error)。比如题目要求“输出1到100之间所有能被3整除的数”,你写for (i = 1; i < 100; i++),结果漏掉了100。或者写for (i = 1; i <= 100; i++),但循环体里又写了个i++,导致跳过了某些数。
还有一种更隐蔽的差一错误出现在数组遍历里。C语言的数组下标从0开始,所以一个长度为n的数组,有效下标是0到n-1。很多人写for (i = 0; i <= n; i++),访问了a[n],这是越界访问。在NOJ上,越界访问可能不会立刻报错,但会读到垃圾值,导致结果莫名其妙地不对。更严重的是,如果越界写入了数据,可能会覆盖其他变量的值,造成“灵异现象”——比如你明明没改变量x,但x的值自己变了。
死循环是另一个高频问题。最常见的是while循环里忘了更新循环变量。比如“输入若干个数,求平均值,直到输入-1为止”,有人写while (x != -1) { sum += x; count++; },但忘了在循环体里再读一次x,结果x永远是第一个数,循环永远不结束。NOJ对死循环的处理是“超时”(Time Limit Exceeded),你会看到评测结果里显示TLE。
3.2 数组:从一维到二维的思维跃迁
数组是C语言里第一个真正意义上的“数据结构”。NOJ中间部分的题目,很多都在训练“用数组批量处理数据”的思维。比如“输入10个数,逆序输出”,你需要先把10个数存进数组,然后从后往前遍历输出。再比如“输入一个矩阵,求它的转置”,你需要用二维数组,然后交换a[i][j]和a[j][i]。
这里有一个很关键的思维转变:以前你处理数据是一个一个来的,现在你要把数据“存起来”,然后“批量操作”。这个转变对后续学习数据结构至关重要。我建议在做这类题的时候,刻意练习“先存后算”的模式——不管题目要不要你存,你都先存进数组,然后再想怎么算。这样做的好处是,你的代码结构会更清晰,也更容易调试。
二维数组的初始化是个容易踩坑的地方。比如int a[3][4] = {{1,2},{3,4}},你以为所有元素都初始化了,实际上只有前两行的前两个元素被初始化了,其余元素都是0。如果你想要全部初始化为0,可以写int a[3][4] = {0},这样所有元素都会是0。这个技巧在NOJ的矩阵类题目里非常实用。
3.3 字符串:C语言里最“反直觉”的部分
字符串是C语言里最让人头疼的部分,没有之一。NOJ上关于字符串的题目,错误率普遍偏高。核心原因在于:C语言没有原生的字符串类型,字符串就是一个以\0结尾的字符数组。这个\0是很多问题的根源。
比如“输入一个字符串,统计其中字母的个数”。你用scanf("%s", str)读入字符串,然后遍历。但scanf("%s")遇到空格就会停止读取,所以如果输入是“hello world”,你只能读到“hello”。要读一整行,需要用fgets(str, sizeof(str), stdin)。但fgets会把换行符也读进来,所以字符串末尾可能是\n而不是\0,你需要手动把\n替换成\0。
再比如字符串比较。很多人用if (str1 == str2)来判断两个字符串是否相等,这是错的。str1和str2是数组名,它们代表的是数组的首地址,比较的是地址而不是内容。正确的做法是用strcmp(str1, str2),返回0表示相等。这个坑几乎每个人都会踩一次。
还有一个经典问题是“字符串拼接”。有人写str1 = str1 + str2,编译器直接报错。因为数组名不是左值,不能被赋值。正确的做法是用strcat(str1, str2),但前提是str1必须有足够的空间容纳拼接后的结果。如果str1定义的是char str1[10],而str2有20个字符,strcat会导致缓冲区溢出,程序可能崩溃或者产生不可预期的结果。
提示:处理字符串时,永远要问自己三个问题:这个字符串有多长?存储它的数组够不够大?它有没有以
\0结尾?这三个问题能帮你避开80%的字符串相关bug。
4. 后30题:函数、指针与递归的进阶修炼
4.1 函数:从“一坨代码”到“模块化思维”
NOJ后30题开始要求你把代码拆成函数。比如“写一个函数判断一个数是不是素数,然后在主函数里调用它输出1到100之间的所有素数”。这种题目的价值不在于语法,而在于“模块化思维”——把一个大问题拆成若干个小问题,每个小问题用一个函数解决。
很多人写函数的时候,喜欢把所有的逻辑都塞进main里,然后复制粘贴。这样做短期看省事,但一旦题目变复杂,代码就会变得不可维护。我建议从后30题开始,强制自己“一个功能一个函数”。比如判断素数写一个isPrime,求最大公约数写一个gcd,交换两个变量写一个swap。这样做的好处是,你的main函数会变得非常简洁,一眼就能看出程序的逻辑流程。
函数还有一个容易踩的坑是“值传递”和“地址传递”的区别。C语言默认是值传递,也就是说,你把一个变量传给函数,函数里修改的是这个变量的副本,不会影响原来的变量。如果你想让函数修改原来的变量,必须传地址(也就是指针)。比如经典的swap函数:
void swap(int *a, int *b) { int temp = *a; *a = *b; *b = temp; }调用的时候要写swap(&x, &y)。如果你写成swap(x, y),编译器可能不报错(取决于参数类型),但交换不会生效。这个坑在NOJ的排序类题目里特别常见。
4.2 指针:C语言的“分水岭”
指针是C语言里最让人望而生畏的部分,也是NOJ后30题的重点。很多人学到这里就放弃了,觉得指针太抽象。但其实指针的核心概念只有一个:它存储的是地址。你只要记住“指针就是地址”,很多问题就迎刃而解了。
比如“输入两个字符串,用指针交换它们的内容”。如果你用数组来交换,需要逐个字符复制,很麻烦。但用指针就很简单:定义两个指针分别指向两个字符串,然后交换指针的值。这样两个指针就互换了指向,也就相当于交换了字符串的内容。这个技巧在NOJ的字符串排序题里非常有用。
指针和数组的关系也是必须搞清楚的。在C语言里,数组名在大多数情况下会“退化”成指向数组第一个元素的指针。所以a[i]等价于*(a + i),&a[i]等价于a + i。这个等价关系在NOJ的题目里经常用到。比如“用指针遍历数组并输出”,你可以写:
int *p; for (p = a; p < a + n; p++) { printf("%d ", *p); }这种写法比用下标更“C语言”,也更高效(虽然现代编译器优化后差别不大)。
4.3 递归:从“自己调用自己”到“分而治之”
递归是NOJ后30题的另一个重点。经典的递归题目包括“求阶乘”“求斐波那契数列”“汉诺塔”“全排列”等。递归的核心思想是“分而治之”——把一个大问题拆成一个或多个小问题,小问题的解法和大问题一样,只是规模更小。
写递归函数的关键是找到两个东西:递归基(什么时候停止)和递归式(怎么把问题变小)。比如求阶乘:
int factorial(int n) { if (n == 0 || n == 1) return 1; // 递归基 return n * factorial(n - 1); // 递归式 }递归基是n == 0 || n == 1,递归式是n * factorial(n - 1)。如果你忘了写递归基,函数会无限调用自己,直到栈溢出(Stack Overflow),程序崩溃。
递归的另一个坑是“重复计算”。比如斐波那契数列的朴素递归写法:
int fib(int n) { if (n <= 1) return n; return fib(n - 1) + fib(n - 2); }这个写法的时间复杂度是指数级的,算到fib(40)就要好几秒,算到fib(50)可能要几分钟。NOJ上如果遇到这种题,用朴素递归大概率会超时。解决办法是用“记忆化搜索”——用一个数组把已经算过的结果存起来,下次需要的时候直接取,不再重复计算。这个技巧在NOJ的递归类题目里非常实用。
5. 刷题之外:那些NOJ不会教你的实战经验
5.1 调试技巧:从“printf大法”到“断点调试”
NOJ上做题,最痛苦的事情莫过于“答案不对但不知道错在哪”。很多人只会用printf在代码里到处打印中间变量,这种方法在代码短的时候还行,代码一长就变成了“打印地狱”。我建议尽早学会用调试器。
如果你用的是Dev-C++,可以在代码里设置断点(点击行号旁边的空白区域),然后按F5进入调试模式,按F7单步执行,观察变量的值。如果你用的是VS Code,配置好C/C++调试环境后,体验会更好。调试器的好处是,你可以“暂停”程序的执行,查看当前所有变量的值,而不是靠猜。
当然,printf大法也不是完全没用。在NOJ这种在线评测环境里,你没法用调试器,只能靠printf。我的经验是,把printf语句写得有辨识度,比如printf("DEBUG: i=%d, sum=%d\n", i, sum),这样一眼就能看出是调试信息,提交之前也容易删干净。
5.2 代码风格:从“能跑就行”到“可读可维护”
前面提到过,NOJ只看结果不看代码风格。但我想强调的是,代码风格是你自己的事,不是给评测机看的。好的代码风格能让你在调试的时候更快找到问题,在复习的时候更快回忆起思路,在给别人看的时候更快传达意图。
具体来说,我建议做到以下几点:变量名用英文单词或缩写,不要用a、b、c;缩进用4个空格或者一个Tab,保持一致;每个函数前面写一行注释说明功能;复杂的逻辑块前面写一行注释说明意图;不要在一行里写太多东西,适当换行。这些习惯看起来微不足道,但坚持一个学期,你的代码质量会有质的飞跃。
5.3 从NOJ到真实项目:还差什么
NOJ的题目都是“单文件、单函数、标准输入输出”的模式。但真实的C语言项目要复杂得多:多个源文件、头文件、Makefile、版本控制、单元测试、内存管理、错误处理等等。NOJ不会教你这些,但你可以自己补。
比如,你可以尝试把NOJ上做过的题目整理成一个“题库项目”,每个题目一个.c文件,然后写一个Makefile来批量编译。再比如,你可以尝试用git来管理你的代码,每次做完一道题就提交一次,这样你就能看到自己的进步轨迹。这些额外的练习,会让你在后续的课程设计和毕业设计中游刃有余。
提示:NOJ上有一道题是“大数相加”,要求计算两个超过
long long范围的整数的和。这道题的标准解法是用字符串模拟手工加法。这个思路在真实项目里非常有用——当你遇到语言原生类型不够用的时候,就需要自己设计数据结构来解决问题。这种“造轮子”的能力,是区分普通程序员和优秀程序员的关键。
6. 关于“更新中”这件事:如何持续跟进和利用这套题
6.1 题目会变,知识点不会变
标题里写着“更新中”,说明这套题可能还在不断补充。但不管题目怎么变,C语言的核心知识点是固定的:数据类型、运算符、控制流、函数、数组、指针、结构体、文件操作。你只要把这些核心知识点吃透了,不管题目怎么变,你都能应对。
我的建议是,不要追求“刷完100题”,而是追求“每做一题都有收获”。做完一道题后,花几分钟想一想:这道题考了什么知识点?我用的方法是不是最优的?有没有更简洁的写法?如果题目条件变一下,我还能不能做出来?这种“复盘”的习惯,比刷题数量重要得多。
6.2 建立自己的“错题本”
NOJ上做错的题,不要改完就扔。建一个文档,把错题的题目描述、你的错误代码、错误原因、正确代码、知识点总结都记下来。每隔一段时间翻一翻,你会发现自己的薄弱环节在哪里。比如你可能发现自己在“字符串处理”上错得最多,那就专门找字符串的题目来练。
这个错题本还有一个好处:期末复习的时候,你不需要把100题重新做一遍,只需要看错题本就行了。我当年就是用这个方法,期末上机考试前只花了两个小时复习,就拿到了满分。
6.3 从“抄答案”到“读答案”
网上有很多NOJ的答案,很多人遇到不会的题就直接搜答案复制粘贴。这样做短期看省时间,但长期看是害了自己。我的建议是,遇到不会的题,先自己想15分钟。如果实在想不出来,再看答案。但看答案的时候,不要只看代码,要看思路——它是怎么分析问题的?用了什么数据结构?为什么这么设计?看完之后,把答案关掉,自己重新写一遍。如果能写出来,说明你真的懂了;如果写不出来,说明你只是“看懂了”,并没有“学会”。
注意:NOJ的题目每年可能会有微调,网上搜到的答案不一定完全适用。而且,直接抄答案有被查重系统标记的风险。自己写,哪怕写得丑一点,也是自己的东西。
6.4 组队刷题:一个人走得快,一群人走得远
如果你觉得一个人刷题太枯燥,可以找几个同学组队。每个人负责不同的题目,做完之后互相讲解。讲解的过程就是最好的复习——你要把思路说清楚,别人才能听懂。而且,不同的人有不同的解法,互相交流能开阔思路。比如一道排序题,你可能用冒泡排序,别人可能用选择排序,还有人可能用qsort函数。这些不同的解法,都是宝贵的学习资源。
我在带学弟学妹的时候,经常组织“NOJ专题讨论”,一次讨论一个主题,比如“字符串处理专题”“递归专题”“指针专题”。每次讨论完,大家都觉得收获很大。这种学习方式,比一个人闷头刷题效率高得多。
7. 一些具体的题目解析与代码示范
7.1 经典题:判断素数并输出
这道题在NOJ上出现在函数部分。题目要求:写一个函数isPrime,判断一个数是不是素数,然后在main里输出1到100之间的所有素数。
#include <stdio.h> #include <math.h> int isPrime(int n) { if (n <= 1) return 0; for (int i = 2; i <= sqrt(n); i++) { if (n % i == 0) return 0; } return 1; } int main() { for (int i = 1; i <= 100; i++) { if (isPrime(i)) { printf("%d ", i); } } printf("\n"); return 0; }这道题的关键点有两个:一是isPrime函数里循环的上界是sqrt(n)而不是n,这样可以把时间复杂度从O(n)降到O(sqrt(n));二是main函数里只负责调用和输出,逻辑非常清晰。很多人把判断素数的逻辑直接写在main里,代码就会显得很乱。
7.2 经典题:字符串逆序
题目要求:输入一个字符串,将其逆序输出。
#include <stdio.h> #include <string.h> int main() { char str[100]; fgets(str, sizeof(str), stdin); int len = strlen(str); // 去掉fgets读入的换行符 if (len > 0 && str[len - 1] == '\n') { str[len - 1] = '\0'; len--; } for (int i = len - 1; i >= 0; i--) { printf("%c", str[i]); } printf("\n"); return 0; }这道题的坑在于fgets会把换行符也读进来。如果你不处理,逆序输出的第一个字符就是换行符,结果就会多一个空行。处理方法是检查最后一个字符是不是\n,如果是就把它替换成\0,同时把长度减1。
7.3 经典题:递归求斐波那契数列
题目要求:用递归求斐波那契数列的第n项。
#include <stdio.h> long long memo[100] = {0}; long long fib(int n) { if (n <= 1) return n; if (memo[n] != 0) return memo[n]; memo[n] = fib(n - 1) + fib(n - 2); return memo[n]; } int main() { int n; scanf("%d", &n); printf("%lld\n", fib(n)); return 0; }这道题的关键是“记忆化搜索”。如果不加memo数组,算到fib(50)就会超时。加了memo之后,每个fib(n)只算一次,时间复杂度降到O(n)。这个技巧在NOJ的递归类题目里非常实用,建议熟练掌握。
8. 最后聊几句实在的
刷NOJ这套题,最忌讳的就是“为了刷而刷”。我见过太多人,题目通过之后连自己写了什么都不记得。这样的刷题,除了浪费时间,没有任何意义。真正有效的刷题,是每做一道题都有思考、有总结、有收获。
另外,不要被“100题”这个数字吓到。你不需要一次性刷完,也不需要按顺序刷。可以根据自己的薄弱环节,有针对性地选择题目来练。比如你觉得指针不熟,就专门找指针的题目;你觉得递归太难,就专门找递归的题目。集中突破,比漫无目的地刷题效率高得多。
还有一点很重要:不要怕犯错。NOJ的好处就是,你可以随便提交,错了就改,改了再交,没有任何成本。这种“试错”的机会,在真实项目里是没有的。所以,大胆地写,大胆地交,从错误中学习,这才是NOJ最大的价值。
我在实际带人的过程中发现,那些最终学得好的同学,往往不是最聪明的,而是最愿意“折腾”的。他们会尝试不同的解法,会去查资料搞懂每一个细节,会把自己的代码反复重构。这种“折腾”的精神,才是学好编程的关键。NOJ只是一个工具,怎么用好这个工具,取决于你自己。