初学C语言的函数和循环时,大家几乎都会碰到一类题目:判断素数,或者更进一步,输出100到200之间的所有素数。这个题目看起来很简单,但它几乎把C语言初阶最重要的一批知识点全串起来了——函数的定义与参数传递、返回值的处理、循环的边界条件、还有一点算法的小心思。我在带新人练手的时候,一直把这道题当成一块试金石。函数部分学得扎实不扎实,循环边界思考得细不细,写一遍这个题基本就看出来了。
这篇文章就围绕“C语言初阶6-函数参数、100-200之间等素数练习”展开,详细聊聊这个练习背后的设计思路、函数参数在这里起到了什么作用、素数判断的核心逻辑,以及在实际运行中会踩进去的那些坑。无论你是刚学完函数打算刷题巩固,还是自学到一半拿不准参数到底怎么传,这篇文章都应该能帮上忙。
1. 内容整体设计与思路拆解
1.1 为什么拿“100到200之间的素数”当练习题目
选这个题目不是随手的。100到200这个区间特别适合初学阶段练手,原因很实在:数字不大不小,既能验证循环的正确性,又不会因为计算量太大把程序跑得让人等得不耐烦。老掉牙的练习从2到100甚至1到1000都有,但100到200这个范围有它自己的讲究——它包含了一定数量的非素数(比如偶数、能被5整除的数、以及一些容易被误判的合数),同时又不至于范围太大导致初学者光顾着看结果而忽略了代码本身的逻辑。
素数的定义本身也简单:大于1的自然数,除了1和它自身之外,不能被其他自然数整除。这个定义直接给出了一种最朴素、最容易理解的判断方法:拿这个数依次去试除比它小的正整数,只要发现能被某个数整除,就说明它不是素数。这个过程放在程序里,就是一层循环遍历所有候选除数,一旦出现余数为0的情况就立刻做出判断。这个逻辑只要理解了,代码写出来几乎不需要背任何模板。
但这里有一个“坑”藏在定义里:到底试除到哪个数就算完?很多初学者一开始会写成“从2循环到n-1”,用for循环把每一个可能的除数都试一遍。这个写法在逻辑上没有错,结果也是对的,但显然不够聪明。在没学更复杂的算法之前,正确且高效的做题方式,是用数学上的一个小性质把循环范围压缩,那就是下面的核心优化点。
1.2 函数参数在这个练习中的核心作用
练习标题里特别提到“函数参数”,这其实是这道题真正的教学重点之一。很多初学者在第一次独立写完这个练习时,会把所有的逻辑全部塞进main函数里:先写外层循环数100到200,再写内层循环做素数判断,一堆if嵌套在里面,代码虽然能跑,但结构非常糟糕。一旦出bug,排查起来要一行一行地看,心情很快就崩了。
用函数把素数判断独立出来,是一种更合理、也更符合工程习惯的做法。写一个专门的函数,比如叫is_prime,它接收一个整数参数,在函数内部完成判断,然后把“是素数”或“不是素数”的结果通过返回值告诉调用者。这样一来main函数里的逻辑就变得非常清晰:遍历100到200的每一个数,调用is_prime检查它,如果是素数,就打印。这个思路不是凭空想出来的,它的本质是“单一职责”——让每一个函数只干一件事,把复杂问题拆分成小块的组合。这也是C语言初阶课程里后续学习模块化编程的基础。
这里需要重点理解的是函数参数的值传递机制。C语言默认采用的是按值传递:调用函数时,实参的值会被拷贝一份给形参,函数内部操作的是这个拷贝,而不是原始的变量。就拿is_prime这个函数来说,传进去的整数在函数内部被赋值给形参num,无论函数内部怎么处理num,都不会影响main函数里那个正在循环的变量。在实际练习里,这意味着你可以在函数里安全地修改形参的值,而不用担心弄乱外层循环的计数变量。很多初学者一开始不知道这一点,担心“函数里改了参数会不会把外面的循环搞坏”,其实完全不用担心。
1.3 这个练习在整个C语言学习路径中的位置
说句掏心窝的话,定位好练习题所处的位置,练习效果会好得多。C语言学完变量、分支、循环之后,下一个重点就是函数。函数这关过不了,后面学数组、指针、结构体都会很吃力。因为那些知识点本质上都依赖“函数怎么定义、参数怎么传、返回值怎么接”这三板斧。
这套练习放到这里,其目的不是让你写出一个多高效的素数判断算法,而是让你通过“把问题拆成函数”这个动作,理解程序结构是怎么设计的。100到200之间的素数数量有限,程序运行结果也就那么几十个数,对错一目了然。正因为这个问题本身足够简单,你才能把注意力集中在代码结构和函数设计上,而不是和复杂的业务逻辑搏斗。我见过不少学习者跳过了这些基础题目,直接去啃链表、二叉树,结果写出来的代码思路混乱,调试起来无从下手,最后回头补基础,效率反而更低。
函数参数这个点,在此刻学到的是最本质的东西:怎么定义形参、怎么调用、返回值怎么接。这个练习题目恰好把这三样全用上了。
2. 核心细节解析与实操要点
2.1 函数定义、声明与调用的细节
先看一个最基础的is_prime函数应该长什么样。稍微整理一下,大概这样:
#include <stdio.h> // 判断一个整数是否为素数 // 是素数返回1,不是素数返回0 int is_prime(int num) { int i; if (num < 2) { return 0; } for (i = 2; i * i <= num; i++) { if (num % i == 0) { return 0; } } return 1; } int main(void) { int n; for (n = 100; n <= 200; n++) { if (is_prime(n)) { printf("%d ", n); } } printf("\n"); return 0; }这段代码乍看很短,但里面有几个点值得一条一条拆清楚。
第一,函数定义的位置。把is_prime函数写在main函数之前,这是最简单也最不容易出错的安排。如果你习惯把函数定义写在后面,那在main函数调用它之前必须有函数声明,否则编译器报错会说“隐式声明”或者“调用未声明的函数”。这个概念在初学阶段容易被忽略,因为很多编译器只是给警告,不会直接拒绝编译。但养成好的习惯很重要:要么把函数定义放前面,要么在文件开头统一声明,免得后面写多文件项目的时候绕进怪圈。
第二,返回值设计。is_prime返回1表示是素数,返回0表示不是素数。用整数0和非0来表示逻辑真假的用法,在C语言里非常常见,因为C语言本身没有专门的布尔类型(C89标准时代)。后续如果编译器支持stdbool.h,也可以直接定义成bool类型返回true/false。但在初学阶段,用int返回0和1完全够用,代码也更直观。
第三,main函数里通过返回值来判断“是素数还是不是素数”。这里if (is_prime(n))这个写法等于在问“is_prime(n)是否为非0”。很多初学者习惯写成if (is_prime(n) == 1),这个写法也没错,不过多啰嗦了一层。当你看多了代码就会发现,直接用返回值作为条件表达式是C语言社区里非常常见的风格。当然,第一次写代码,写if (is_prime(n) == 1)更容易理解,看得明白比写得高深更重要。
2.2 素数判断的核心逻辑:试除法
素数判断最核心的方法叫“试除法”。简单说就是拿要判断的数字n,依次除以2、3、4、5……直到可能的除数范围结束。只要有一次余数是0,就说明n能被某个非1非自身的数整除,从而断定n不是素数。
那到底要试除到多大范围才能停?这就用到了前面说的数学性质:如果n有一个大于sqrt(n)的因子a,那么n一定还有一个小于等于sqrt(n)的因子b,使得a乘以b恰好等于n。换句话说,只要在2到sqrt(n)之间没有找到能整除n的数,那在sqrt(n)到n-1之间也一定找不出来。所以循环边界定到sqrt(n)就足够了。
代码里有两种等价的写法。一种是先求sqrt,然后循环,像这样:
#include <math.h> for (i = 2; i <= sqrt(num); i++) { ... }另一种更漂亮,完全不需要引入math.h,直接利用乘法:
for (i = 2; i * i <= num; i++) { ... }我强烈推荐第二种写法。它不仅仅是为了少引用一个头文件,更关键的是避免了浮点数比较带来的潜在误差。sqrt(num)返回的是double类型,拿它和int类型的i做比较时,编译器会进行类型转换。浮点数的存储和比较本身就容易引入细微误差,万一num刚好是某个整数的平方(比如121),sqrt(121)在某些环境下可能得到一个非常接近11但又不完全等于11的值,虽然多数情况下影响不大,但这种隐患完全可以用i * i <= num这种方式消除干净。
另外还有一个细节:循环变量i的类型通常定义成int,但在for循环里直接用int i声明,跟在循环外面声明,在初学阶段两种都行。翁恺老师的课上习惯先声明后使用,在C89标准下这种写法更稳妥。现代编译器大多支持C99了,在for语句里声明变量也很常见。初学阶段如果你不清楚当前环境支持什么标准,按经典写法先声明循环变量是最省心的。
2.3 边界条件与细节陷阱
这里有一个我见新人踩过次数最多的坑:忘记特殊处理小于等于1的数字。素数定义要求大于1,所以1不是素数,0和负数更不是。如果函数内不处理这些边界,直接把数字拿去做循环,那for循环从2开始试除,小于2的数字循环体根本不执行,函数直接返回1,等于把1、0、负数全都误判成素数了。所以is_prime里开头的if (num < 2) { return 0; }绝对不能省。
还有num等于2和3的情况。num等于2时,循环条件i * i <= num,i从2开始,22=4大于2,循环体不执行,直接return 1,正确。num等于3也一样,3是素数。num等于4时,i = 2,22=4不大于4,循环执行,4 % 2 == 0成立,函数返回0,正确。这些边界值单独验证一遍,能帮你debug时迅速缩小问题范围。
另一个容易忽视的点是打印格式。题目要求输出100到200之间的素数,你在printf里加了空格还是换行,都会影响结果呈现。打印时用printf("%d ", n)输出一堆以空格分隔的数,最后再补一个换行,看起来干净利落。如果要求每行输出固定个数,那就得额外加一个计数器,每打印5个或8个数就换一次行。这个属于输出格式问题,在课程练习里经常出现,我后面会单独展开说。
从头到尾把这段代码实际编译运行一遍是最重要的练习方式。光看代码觉得“懂了”的人很多,但一上手就会卡在不少小地方。把这些小细节亲手踩一遍,对函数参数和返回值的理解才会真正变成自己的东西。
3. 实操过程与核心环节实现
3.1 完整代码与运行结果
先把最基础的一版完整代码写下来,编译运行一遍看看效果。我建议初学阶段不要抄网上的代码,而是自己照着这个结构敲一遍。敲的过程里手指的记忆和眼睛的记忆是完全两回事,只有亲手敲过,才能注意到各种符号、括号、分号的位置细节。
#include <stdio.h> int is_prime(int num) { int i; if (num < 2) { return 0; } for (i = 2; i * i <= num; i++) { if (num % i == 0) { return 0; } } return 1; } int main(void) { int n; printf("100到200之间的素数有:\n"); for (n = 100; n <= 200; n++) { if (is_prime(n)) { printf("%d ", n); } } printf("\n"); return 0; }在命令行里编译运行,我习惯用gcc:
gcc prime.c -o prime ./prime输出结果应该是:
100到200之间的素数有: 101 103 107 109 113 127 131 137 139 149 151 157 163 167 173 179 181 191 193 197 199一共21个素数。如果程序跑出来和这个结果不一样,那说明逻辑或边界条件出了错。对照这个结果调试,效率很高。
3.2 分步拆解main函数中的调用流程
把main函数里的循环拆开看:for (n = 100; n <= 200; n++),n从100开始,每循环一次加1,直到200结束。注意循环结束条件用的是n <= 200,如果你写成了n < 200,那199会被漏掉,因为199在这个区间里是素数,漏了就可惜了。如果你写成n < 201,那也是对的,但很多初学者初期会绕在“到底用<还是<=”这个问题上,我建议仔细想一想循环变量最终停在哪个值上,想清楚比背写法靠谱得多。
每一次循环里调用is_prime(n),n的值——注意,是n当前的值——会被复制一份传给形参num。无论is_prime内部怎么折腾num,main函数里n的值始终保持不变。这句理解透了,函数参数的值传递机制就掌握了。
函数内部,num = 212这种场景不会出现在这个练习里,因为n的范围受外层for控制。但如果你想测试函数本身是否正确,完全可以在main里单独调用:printf("%d\n", is_prime(2)); 来验证输出是不是1。这是一种很朴素的测试方法,手工构造几个输入值,看输出是否符合预期。养成这种“小步验证”的习惯,后面写复杂代码时会省去大量排查时间。
3.3 扩展练习:打印格式调整与函数优化
原题如果满足不了你,我建议多走两步。
第一步,把输出从“一行输出全部”改成“每行输出5个”,这就需要加一个计数器。改完之后代码长这样:
#include <stdio.h> int is_prime(int num) { int i; if (num < 2) { return 0; } for (i = 2; i * i <= num; i++) { if (num % i == 0) { return 0; } } return 1; } int main(void) { int n; int count = 0; for (n = 100; n <= 200; n++) { if (is_prime(n)) { printf("%d ", n); count++; if (count % 5 == 0) { printf("\n"); } } } printf("\n"); return 0; }这里count记录已经找到的素数个数,每打印一个就让count加1,当count能被5整除时换行。这个“计数器控制排版”的思路在后续很多题目里都能复用,非常实用。
第二步,把试除循环从2到sqrt(n)进一步增强。其实对于判断单个数字,试除法已经足够,但你可以观察到一个现象:既然2的倍数都不可能是素数(除了2本身),那判断一个大于2的奇数时,完全可以跳过所有偶数除数。换句话说,循环可以从i = 3开始,每次i += 2,直接从3、5、7、9这样试除,进一步减少一半的循环次数。不过这么做有一点代价:代码的可读性稍微下降一点,而且对100到200这种小规模数字来说,性能提升几乎感觉不到。我更建议初学者先把最清晰的版本写好,优化的意识要有,但短期内不需要为了这一点点性能牺牲代码的直观性。
3.4 动手验证“非素数”情况的处理
很多人写完代码只关注输出结果里是不是包含了正确的素数,却没有刻意验证“不该输出的数没有输出”。比如100、102、104、105这些非素数有没有被错误地打印出来。由于输出结果一眼就能看出格式对不对,这个验证通常很简单。但如果你的is_prime出现边界条件bug,比如把1误判成素数,那么当函数用于其他题目时,错误就会悄悄潜伏下来。
我平时练习喜欢在main函数里额外加几行测试代码,把0、1、2、3、4、9、121这些特殊值都测一遍,确认输出符合预期,再删掉测试代码跑正式输出。这个习惯我一直保持着,它确实帮我省去了很多后面调试的麻烦。
printf("%d %d %d %d %d %d %d\n", is_prime(0), is_prime(1), is_prime(2), is_prime(3), is_prime(4), is_prime(9), is_prime(121));预期输出是0 0 1 1 0 0 0。如果是这个结果,说明边界处理正确。121不是素数,因为11乘以11等于121,如果你写循环边界的时候错写成了i < num,那121也会被正确算出0;如果你错写成了i * i < num(漏了等号),那当i等于11时,11*11=121不满足小于121,循环直接退出,函数就会错把121当成素数返回1。这个“漏掉等号”的错误非常经典,我在批改作业时见到过不止一次,大家写的时候务必留意。
4. 常见问题与排查技巧实录
4.1 编译报错与函数声明问题
新手刚写完代码,按gcc一编译,可能就会看到类似这样的报错:
warning: implicit declaration of function 'is_prime'出现这个警告的原因通常是is_prime函数定义在了main函数之后,而main函数调用它之前没有任何声明。虽然在很多编译器下这种写法只是警告而不会阻止生成可执行文件,但因为C语言里“隐式声明”行为在旧标准下可能会被当作“返回int的函数”来处理,万一函数实际返回值不是int,就会出现严重的问题。养成在调用之前声明或定义的习惯,是避免这些模糊问题的最直接手段。
解决方式有两种:一是把整个函数体挪到main函数前面;二是在main函数前加上一行函数原型声明。比如:
int is_prime(int num);这行声明告诉编译器“有一个is_prime函数,它接收一个int参数,返回int”。函数原型声明是C语言里非常基础也非常重要的概念,后面写多文件项目时经常用到。现在就开始养成习惯,不亏。
4.2 scanf残留问题并不属于本题,但值得警惕
这个题目不涉及scanf,不需要用户输入任何东西,因此“scanf残留导致输入被跳过”这种问题在这道题里不会出现。但我特别想提一下,因为在后续练习里,这类问题非常常见。很多题目会要求先读入一个n,再判断n是不是素数,或者输出n以内的素数,那时就会用到scanf。
如果你在写“输入一个数,判断是否为素数”这类题目时,发现程序好像跳过输入直接输出了结果,那很可能是scanf在读取时被之前的输入缓冲区里残留的换行符卡住了。scanf的%d不会读取换行符或空格,所以如果代码里先用了scanf("%d", &a),再接着调用getchar()想读一个字符,那个getchar读到的基本就是上一轮输入遗留的换行符。要解决这个问题,要么在scanf的格式串里带上空白字符,比如scanf("%d ", &a),要么在需要清空缓冲区时手动消耗掉多余字符。
这并不是本题的内容,但我把它写在这里,是因为“函数参数”和“输入输出”这两个主题经常一起出现。提前认清scanf的脾气,后面写交互式练习时能少走很多弯路。
4.3 自增运算符与循环变量混淆
在循环里使用了n++,这是非常标准的写法。但有些初学者会不小心把n++写成++n,然后发现运行结果和预期不太一样,但又说不清哪里不对。
简单定义n++和++n都让变量n增加1,它们的区别在于表达式整体的值:n++的值是增加前的n,++n的值是增加后的n。在for (n = 100; n <= 200; n++)这个场景里,n++和++n效果完全一样,因为循环根本不会使用“增加后返回的值”。但在某些表达式里,比如b = a++和b = ++a,结果就完全不同了。学函数参数的同时辨析这些运算符之间的差异,也是在给后面的表达式运算打基础。
我见过一个有意思的bug:有人在printf里直接写printf("%d", is_prime(n++)),本意是打印n是否为素数,结果n的值被提前自增了,整个循环都乱了套。这里的关键就是:把自增放在表达式里改变了函数调用的实参值。理解了值传递,这一类问题就很好排查了。
4.4 使用调试手段定位函数内逻辑错误
如果程序跑出的结果不对,比如输出了一堆偶数,那多半是is_prime里整除判断或者循环边界出了问题。最直接的定位手段是printf大法:在循环里临时加printf,把当前的i和num打印出来,看循环到底执行到了什么程度。
比如可以临时在is_prime里加:
printf("debug: num=%d, i=%d, i*i=%d\n", num, i, i * i);这样就能看到函数内部真实执行的情况。效果立竿见影。用完之后记得把这些调试代码删掉或者注释掉。
这个方法看起来土,但非常好用。刚开始学编程的人不要急着上调试器,熟练掌握printf大法,再慢慢接触gdb这类工具,学习曲线会平滑很多。我平时排查一个陌生代码里的问题,第一反应也常常是加打印而不是开调试器,因为打印输出的信息直观且可追溯。
另外,如果你的代码已经拆成了is_prime这种独立函数,你还可以单独写一段小测试,直接调用这个函数验证它的行为,比如传进去100、101、102分别看返回值。这比在完整程序里打日志更高效。测试驱动的好习惯,从初学阶段就可以培养。
4.5 常见问题速查表
我把这个练习里容易遇到的问题整理成一张表,方便随时对照排查。
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 100到200的偶数都被输出了 | is_prime里整除判断写反,或循环边界过大导致误判 | 检查if (num % i == 0)后是否return 0 |
| 121被输出为素数 | 循环边界漏写等号,i * i < num | 改成i * i <= num |
| 1被输出为素数 | 缺少num < 2的边界处理 | 函数开头加if (num < 2) return 0 |
| 编译警告implicit declaration | 函数定义在main后且无声明 | 把函数定义挪前或增加函数原型声明 |
| 输出格式混乱,没有换行 | printf里没有控制换行 | 使用count计数控制每行输出个数 |
| 运行结果缺199 | 循环边界写错,n < 200 | 改成n <= 200 |
5. 实操向的进阶思考与后续方向
5.1 从这道题延伸到“求n以内所有素数”
100到200之间的素数练完之后,很自然的扩展是“求n以内所有素数”。这时代码的主体逻辑基本不变,只需要把main函数里的固定范围改成由用户输入决定。这类题目涉及到scanf的读取、循环边界的动态确定、变量命名等等,综合性比固定区间练习更强。
如果用户输入一个比较大的n,比如10000,那前面is_prime的逐个试除法勉强还能跑。但如果你输入1千万甚至更大,性能问题就会非常明显。这时候,经典的“埃拉托斯特尼筛法”就要登场了。
埃拉托斯特尼筛法的核心思路是:从2开始,把所有2的倍数标记为合数,然后找下一个未标记的数(也就是3),把所有3的倍数标记为合数,依此类推。最后没被标记的数就是素数。这个算法的时间复杂度远优于逐个试除法,也是真正进入算法领域后的一道门槛。不过这是后话了,在初学函数与循环阶段,能把is_prime写对已经达到了练习目标。
说到这里我想起一件小事。有一次我带一位朋友练这道题,他坚持要求输出结果里必须包含2,原因是他觉得2也是素数,而题目区间是100到200,2根本不在区间里。这类关于区间边界的讨论看起来很较真,但恰恰是把循环边界和问题定义搞清楚的好时机。后来他代码里发生一次“漏掉199”的bug,就是他一开始没想明白循环边界到底取到哪。透过这些小细节,我发现很多初学者的问题其实不在语法,而在没有把题目要求翻译成准确的循环条件。这个能力只能靠多练。
5.2 动态数组与函数参数的综合练习
如果你想更进一步,可以试试这个题目:写一个函数,接收一个数组和数组长度,把数组里所有素数打印出来。这个练习把函数参数从单个int扩展到了数组,你马上会接触到“数组作为函数参数会退化为指针”这个概念。
void print_primes(int arr[], int len) { int i; for (i = 0; i < len; i++) { if (is_prime(arr[i])) { printf("%d ", arr[i]); } } printf("\n"); }这里的int arr[],本质上就是int *arr。这是C语言里一个重要的进阶知识点。你在main里调用这个函数时,传进去的是数组首元素的地址,而不是整个数组的拷贝。这跟前面“参数按值传递”的规则并不矛盾——传递的是指针的值,或者说地址值。想明白这一层,你对C语言里函数参数的理解就真正上了一个台阶。
5.3 与“冒泡排序”和“C语言字符串处理”等经典练习串联
这类入门题目之间,其实是可以串联起来练习的。比如写完素数判断之后,又练了冒泡排序,接下来你就可以组合出一个小程序:生成一组随机数,筛出其中的素数并排序输出。这样的组合练习不会增加太多难度,却可以让你把多个知识点融成一片,比一道一道孤立题目刷效果好得多。
C语言的字符串处理也一样,等学到fgets、字符串逆序这类题目时,你会发现前面的函数参数思维能直接迁移过去——字符串函数接收char数组参数,跟接收int数组本质上没有区别。练习是一环扣一环的,千万别指望一步登天。把“100到200之间的素数”这道题吃透,其实是给后面的堆叠知识打了一个稳定地基。
我个人带人练习时的顺序是:先独立写完is_prime和main函数的完整流程,确认输出无误;再改输出格式,加计数器控制每行输出个数;再把范围改成用户输入;最后尝试用数组存储结果并打印。这样一层一层递进,每加一层难度,都是在巩固前一层的基础。这个顺序我曾经在好几批初学者身上验证过,效果普遍好过一次性做综合大练习。
最后再分享一个我实际操作中的小偏好:练习这份代码时,不妨把写过的版本都保存一下。比如把v1的朴素版、v2的优化版、v3的带计数器排版版分别存成文件,重新打开对比一下,你能清楚看到自己每一步都改了什么、为什么改。这个习惯看起来不起眼,但对形成编程直觉非常有效。函数参数怎么传、返回值怎么接、循环边界怎么定,这些能力不是靠背,是靠一遍一遍对比和反复实现磨出来的。