如果你正在学循环,你大概率会在题库里撞见这三道题:素数判断、计算自然常数e、求各位数字之和,对应编号分别是第31题、第32题、第33题。它们排在一起不是巧合,而是把一个新手最容易迷糊的循环难点拆成了三个阶段:条件遍历、级数累加、数位剥离。别看这三道题单独拿出来都很基础,不少人写出的代码“能跑”,但边界一测就露馅;还有人把循环写成了死循环,或者一换成大一点的输入直接溢出。
这篇文章我就按这三道题的顺序,逐个拆解它们的循环结构,讲清楚每一步为什么这么写,再把我这么多年调试循环代码踩过的坑一并列出来。适合刚学完for和while、但面对题目还是有点发懵的同学,也适合准备期末考试想快速系统过一遍循环题的老读者。内容以C语言为例,但思路对Python、Java同样适用。
1. 三道题凑在一起,恰好覆盖循环的三种典型“变数”
很多人学循环时都会背“三要素”:初始化、条件、更新。但真到做题,卡住的地方往往是同一个问题——循环体里到底该写什么?为什么同样都是循环,素数题里变量一路加一,数字之和里变量一路变小,而计算e的题里变量几乎一直在被自己除?
1.1 循环不是在重复同一段代码
循环表面上是“把一段代码重复执行很多遍”,但真正有价值的循环,每一轮执行时数据都在变化。比如for (i = 2; i < n; i++),这段代码每一轮测试的i不同,所以判断结果也可能不同。循环体里写的不是“固定动作”,而是“对当前这个值做动作”。
理解这一点之后,三道题的本质就露出来了:
- 素数题:重复的动作是“测试n能否被i整除”,其中
i从2一路递增到某个边界。 - 计算e题:重复的动作是“算出当前这一项,并把它累加到总和里”,其中项的值在不断变小。
- 数字之和题:重复的动作是“取出当前数字的最后一位,累加起来,然后把数字缩小”,其中
n本身在逐轮被改写。
这么一拆,你就能看出三道题分别对应三种不同的“变数”:递增的整数变量、递推的浮点项、不断缩减的输入值。把“什么在变”找清楚,循环条件基本就写对了一半。
1.2 循环体里真正变化的量,才是解题钥匙
我见过不少学员拿到题目就急着敲代码,结果写完一运行,要么死循环,要么结果差一位。他们的问题不是语法不熟,而是没先回答一个问题:每一轮循环结束之后,哪个变量的值会变?怎么变?
拿数字之和来说,最容易想到的写法是:
while (n > 0) { sum += n % 10; n = n / 10; }这里的“变”体现在n身上:从1234变成123,再变成12、1,最后变成0。如果你写的时候忘记n = n / 10,循环就永远不会结束。这不是笑话,是我真的见过无数次的错误。
所以我的建议是:拿到任何一道循环题,先不要写代码,用一句话回答“每一轮我到底要对什么做操作,做完之后哪些值会变”。这三道题正好是三种完全不同的答案,做完它们,你对循环的理解会立体很多。
2. 素数(循环)第31题:试除边界的数学依据与三个隐蔽坑
素数题是循环入门里公认的“第一道坎”。题目本身一句话:输入一个整数,判断它是不是素数。但代码写出来,有人十几行,有人三十几行,效率差好几倍,还都有各自的坑。
2.1 素数的定义与第一版试除代码
素数定义是“大于1的自然数,除了1和它本身之外,不能被其他数整除”。翻译成循环,最直接的想法就是:从2开始,一直试到n-1,看有没有谁能整除n。
int isPrime(int n) { if (n < 2) { return 0; } int i; for (i = 2; i < n; i++) { if (n % i == 0) { return 0; } } return 1; }这个版本逻辑没错,但对大数来说太慢了。比如判断1000000以内的素数,最坏情况要做将近一百万次取模运算。虽然基础题不要求极致性能,但从一开始就养成优化边界的习惯,后面写算法会省很多事。
2.2 为什么边界可以砍到sqrt(n)
如果你判断n是不是素数,其实不需要试到n-1。假设n = a × b,并且a和b都大于sqrt(n),那么a × b一定大于n,矛盾。所以一旦n是合数,它的因子里至少有一个不超过sqrt(n)。
换句话说:只要在2到sqrt(n)之间找不到因子,n就是素数。于是循环条件可以改成:
int isPrime(int n) { if (n < 2) { return 0; } int i; for (i = 2; i * i <= n; i++) { if (n % i == 0) { return 0; } } return 1; }从i < n改成i * i <= n,时间一下子从O(n)降到O(sqrt(n))。这也是我认为基础题里最值得反复体会的一个优化点:它不是靠什么玄学技巧,而是用一条简单的数学性质,直接砍掉了一大半无效计算。
2.3 三个隐蔽坑:flag重置、i*i溢出、边界临界值
先看一个看起来很合理的错误写法:
int flag = 1; for (i = 2; i < n; i++) { if (n % i == 0) { flag = 0; } } return flag;如果这段代码只调用一次,问题不大。但如果你在一个循环里反复调用isPrime,flag可能还残留上一次的结果,导致判断全部错乱。正确做法是每次进入函数都重新初始化,或者像我前面的代码一样,直接用return提前退出,根本不用flag。
第二个坑是i * i <= n的溢出问题。int类型最大能表示约21亿,当i超过46340时,i * i就超过21亿了,会变成负数,循环条件立刻出错。如果你用这个写法,又恰好有人输入了一个接近int上限的大数,程序可能误判或死循环。稳妥方案有两种:一是用long long类型保存乘法结果,二是先算好int limit = (int)sqrt(n);再循环,但要记得#include <math.h>。
第三个坑是边界临界值。n = 2时,i * i <= 2不成立,循环一次都不执行,直接返回1,这是对的。n = 1或负数时,如果忘了开头的if (n < 2)检查,程序会返回1,把1也当成素数,这就是经典的边界错误。我建议每个初学者都把n = 2, 3, 4, 9, 97这几个值当成“素数题固定测试集”。
2.4 一个能直接跑的完整示例
我把判断和调用放在一起,方便你直接复现:
#include <stdio.h> int isPrime(int n) { if (n < 2) { return 0; } int i; for (i = 2; i * i <= n; i++) { if (n % i == 0) { return 0; } } return 1; } int main() { int n; printf("请输入一个整数(输入0退出):"); while (scanf("%d", &n) == 1 && n != 0) { if (isPrime(n)) { printf("%d 是素数\n", n); } else { printf("%d 不是素数\n", n); } printf("请输入一个整数(输入0退出):"); } return 0; }运行效果:输入7输出“7 是素数”,输入9输出“9 不是素数”。如果你想把这道题扩展一下,可以改成输出2到100之间的所有素数,注意每输出10个换一行,这又是一个锻炼循环和计数器的小练习。
3. 计算e(循环)第32题:级数求和如何用递推躲开阶乘溢出
计算自然常数e也是一道经典循环题。题目通常有两种问法:一种是输入项数n,计算前n项的和;另一种是不给项数,要求一直累加到某项小于某个精度为止。无论哪种,核心都是同一个级数公式。
3.1 e的级数定义与最笨的实现
自然常数e的定义是:
e = 1 + 1/1! + 1/2! + 1/3! + ... + 1/n! + ...
其中“!”是阶乘,0!约定为1。最容易想到的实现是每轮重新算一次阶乘:
double e = 0.0; for (int k = 0; k < n; k++) { double fact = 1.0; for (int j = 2; j <= k; j++) { fact *= j; } e += 1.0 / fact; }这个写法有两个明显问题。第一,内层循环重复计算阶乘,每轮都要从头乘一遍,整体复杂度是O(n^2)。第二,阶乘增长极快,13!就已经超过62亿,int根本放不下,算到后面阶乘溢出成负数,累加结果全乱。
3.2 用t_k = t_{k-1} / k 一行递推
观察相邻两项的关系。令第k项为 t_k = 1/k!,那么 t_k = t_{k-1} / k。也就是说,你根本不需要每轮重新算阶乘,只要把上一轮的结果除以k,就得到了这一轮的结果。
代码可以写成:
double e = 1.0; // 第0项:1/0! = 1 double term = 1.0; // 上一项的值,初始是第0项 int k = 1; while (k <= n) { term = term / k; e = e + term; k++; }验证一下:k=1时,term = 1/1 = 1,e = 1 + 1 = 2,对应前2项;k=2时,term = 1/2 = 0.5,e = 2 + 0.5 = 2.5,对应前3项。完全正确。
这个递推式的好处是:每次只做一次除法和一次加法,没有重复劳动;同时term的数值一直在变小,永远不会溢出。你想算多少项都行,只受double精度限制。
3.3 固定项数还是精度控制
如果老师要求“计算前n项”,上面用while(k <= n)就好。如果要求“计算e,直到新增项小于0.000001”,则改一下循环条件:
double e = 1.0; double term = 1.0; int k = 1; while (term > 1e-12) { term = term / k; e = e + term; k++; } printf("%.12f\n", e);这里有几个容易搞错的细节。第一,term的初始值是第0项1.0,所以在进入循环前,e已经包含了第0项。第二,循环内的顺序必须是先更新term再累加,如果写反了,结果会差一项。第三,精度阈值不要设得太小,double大约只有15到16位有效数字,设成1e-15以下基本是无效循环,白算很多轮。
另外,如果你直接用printf("%f", e),输出默认只有6位小数,你只能看到2.718282,既看不出精度,也容易让人误以为计算错了。建议至少输出8到10位小数,例如printf("%.10f\n", e);。
3.4 顺序错误与其他浮点坑
我曾经见过一个学员把代码写成:
e += term; term = term / k; k++;结果输出2.5之后怎么都不涨。原因就是第一轮累加了第0项,第二轮累加的却还是第0项的值,等于每一项都被推迟了一轮。虽然这个错误不致命,但会让人困惑很久。调试方法很简单:在循环体里临时加一句printf("k=%d, term=%.10f, e=%.10f\n", k, term, e);,立刻就能看出顺序对不对。
浮点计算还有一个经典陷阱:如果把k定义成int,term / k会自动转成double,没有问题;但如果有人写成1 / k,那就是整数除法,结果全是0。我建议在写级数题时,养成把“上一项”放到double变量里的习惯,比如last_term /= k的形式,既清楚又安全。
4. 数字之和(循环)第33题:拆位循环的本质是“缩减规模”
第三道题看起来比前两道简单:输入一个整数,把它的每一位数字加起来,比如1234输出10。但真正写起来,它考察的是你能不能想到“取个位,再去个位”这两个动作。
4.1 从“取个位、去个位”两个动作开始
对于一个十进制正整数n,n % 10能得到它的个位数字,n / 10则能把个位去掉。比如n=1234:
- 第一轮:n % 10 = 4,累加4,然后n = 1234 / 10 = 123。
- 第二轮:n % 10 = 3,累加3,然后n = 123 / 10 = 12。
- 第三轮:n % 10 = 2,累加2,然后n = 12 / 10 = 1。
- 第四轮:n % 10 = 1,累加1,然后n = 1 / 10 = 0。
当n变成0,循环结束。完整代码:
#include <stdio.h> int digitSum(int n) { int sum = 0; if (n < 0) { n = -n; } while (n != 0) { sum += n % 10; n = n / 10; } return sum; } int main() { int n; printf("请输入一个整数:"); scanf("%d", &n); printf("各位数字之和 = %d\n", digitSum(n)); return 0; }4.2 为什么这个场景默认用while而不是for
很多人会问:能不能用for写?能,for (; n != 0; n /= 10)也能跑。但从可读性角度,while更自然,因为循环次数不由一个固定的计数变量决定,而是取决于n这个输入值本身会被“削减”成什么样。你要处理的不是一个从1数到100的计数器,而是一个不断缩小的数据源。
这也解释了为什么这题经常和“进制转换”绑定在一起出现。你把n % 10改成n % 2,把n / 10改成n / 2,它就变成了统计二进制中1的个数,原理一模一样。可以说这一题的循环结构是后续很多位运算和数位DP问题的最小原型。
4.3 负数、零和末尾带0的数字
题目如果只说“整数”,没有说“正整数”,那负数就要小心。C语言里负数取模结果是负数,比如-123 % 10是-3,直接累加会得到负数结果。简单解法是开头判断一次,if (n < 0) n = -n;。如果你不想改n本身,也可以每次累加abs(n % 10),但那样每轮都要调用一次绝对值函数,不如统一处理一次来得干净。
还有一个容易被忽略的边界是末尾带0的数字。比如输入1000,循环过程是:取出0,加0;变成100;取出0,加0;变成10;取出0,加0;变成1;取出1,加1;变成0。最终结果是1,正确的。因为加0不影响总和,所以这类数字往往不容易测出问题,但你能在纸上推演一遍,说明你真正理解了循环的每一步变化。
如果输入是0,while循环一次都不执行,sum保持0,输出0,这也是对的。所以这道题的边界测试集建议为:0、1、1234、1000、-123、99999。
4.4 一个常见变体:各位数字的乘积
练习循环的人很快会遇到类似的题:把各位数字改成“求积”。思路几乎一样,但初始值要从0变成1。如果写成sum初始为0然后累乘,结果永远是0;而初始为1,最后返回1,又会在n=0时产生歧义。这类小变体最适合用来检验你是不是真的懂了“累加器和累乘器初始化值的差异”,建议自己动手改一遍。
5. 三道题的调试串讲:从手工推演到边界清单
做题和写bug是两回事。我见过太多人代码写完了,运行结果不对,就开始盲目改循环条件,改来改去更乱。这里分享一套我个人最常用的循环调试流程,基础题阶段足够用了。
5.1 手工推演:在纸上列出每一轮的变化
遇到循环结果不对,先别急着上IDE,拿一张纸,把每一轮的循环变量变化写下来。以计算e为例,我让你写一个推演表:
| k | 本轮term | 累加后e |
|---|---|---|
| 1 | 1.0 | 2.0 |
| 2 | 0.5 | 2.5 |
| 3 | 0.166666... | 2.666666... |
| 4 | 0.041666... | 2.708333... |
只要执行三到四轮,你马上就能发现是顺序错了、初始值错了,还是循环条件错了。这个方法在素数题和数字之和题里同样有效,只是表格内容换成i和n%i,以及n和sum。
5.2 临时printf大法:在循环体里窥探变量
如果你觉得手工推演太慢,程序里埋点输出是最高效的调试方式。比如素数判断,你在循环体里写一句:
printf("i=%d, n%%i=%d\n", i, n % i);运行之后,你能直接看到i和余数变化的全过程,哪一轮误判一目了然。注意一点:调试完记得把这些printf删掉,或者用//注释掉。有些初学者调对了之后就忘了删,结果提交作业时带着一堆调试输出,格式分直接扣没了。
5.3 教科书上不写但你必须会的边界测试清单
循环代码最常见的错误,几乎全部藏在边界值里。我把三道题的边界测试列成一张表,建议你每道题写完都过一遍:
| 题目 | 测试输入 | 期望输出 | 能暴露什么问题 |
|---|---|---|---|
| 素数 | 1 | 不是素数 | 漏掉n<2的判断 |
| 素数 | 2 | 是素数 | 循环边界写错导致特判失败 |
| 素数 | 4 | 不是素数 | 能否正确退出循环 |
| 素数 | 97 | 是素数 | 边界优化是否有效 |
| 计算e | n=1 | 2.0 | 漏掉第0项 |
| 计算e | n=2 | 2.5 | 循环顺序是否正确 |
| 数字之和 | 0 | 0 | while条件写错 |
| 数字之和 | 1234 | 10 | 基础拆位逻辑 |
| 数字之和 | 1000 | 1 | 末尾0是否处理正确 |
| 数字之和 | -123 | 6 | 负数处理 |
不要觉得这些输入“太简单”。我工作这些年,真正线上出问题的往往就是这类边界场景。能在基础题阶段养成测边界的习惯,后面写业务代码会少挨很多骂。
5.4 常见编译错误的快速自查
如果代码编译不过,先看这几类问题:math.h有没有加;循环条件里是用=还是==;循环体里有没有漏掉更新语句;变量是不是定义在使用之前;是不是在中文输入法状态下敲了全角分号或括号。尤其是全角符号,报错信息经常错位,新手容易排查很久。
6. 做完这三道题,值得继续做的三个方向
三道基础题不是终点。如果做完就扔,过两周基本忘光;如果能把它们串起来或者做点扩展,循环这一块就算是真正吃透了。我给几个亲测有效的方向。
6.1 组合题:求区间内所有“素数的数字之和”
把第31题和第33题组合:输入范围[m, n],求这个范围内每个素数的各位数字之和的总和。代码无非是先调用isPrime判断,再调用digitSum累加:
#include <stdio.h> 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; } int digitSum(int n) { if (n < 0) n = -n; int sum = 0; while (n != 0) { sum += n % 10; n /= 10; } return sum; } int main() { int m, n, total = 0; scanf("%d %d", &m, &n); for (int i = m; i <= n; i++) { if (isPrime(i)) { total += digitSum(i); } } printf("%d\n", total); return 0; }这道组合题能让你体会到“函数拆解”的好处:判断和拆位各干各的,主函数读起来像一句话。这也是我推荐初学者尽早养成写小函数的习惯的原因。
6.2 从朴素质数判断升级到埃氏筛
第31题只判断一个数,但如果要判断2到100000之间所有素数,逐个试除的效率就很一般了。埃氏筛的思想很简单:先把所有数都当成素数,然后从2开始,把每个素数的倍数全部标记为合数。代码只要十几行就能筛出很大范围,这也是很多后续算法题的原型。学有余力的人可以把它作为第31题的“进阶篇”来写。
6.3 把级数求e的套路迁移到其他数学常数
计算e的递推思路换到π上完全通用,比如π的莱布尼茨级数,或者sin(x)的泰勒展开,都是“上一项乘以一个分式得到下一项”的结构。你甚至可以把计算e的代码里term = term / k;改成其他递推式,然后观察收敛速度的变化。这样一道题就变成了一类题。
说句实在话,这三道题我当年也做了不止一遍。第一遍照着书上的答案抄,第二遍合上书自己写,第三遍是过了一周又回来重写,目的就是检验自己是不是真明白。现在回头想,真正让我受益的不是记住了“素数怎么判断”“e怎么算”,而是养成了拿到循环题就先问一句“每轮什么东西在变”的思维习惯。做项目debug到深夜的时候,最感谢的反而是这些基础题打下的底子。
最后分享一个小技巧:循环变量命名尽可能含义明确。i、k、n这种单字母不是不能用,但你心里要清楚它此刻代表谁。如果你发现一个循环里同时出现好几个含义不清的变量,先停下来给它改个名字,往往bug自己就现形了。祝刷题顺利。