写代码这行当干久了,你会慢慢发现,真正决定程序“智商”的往往不是那些复杂的算法,而是最基础的三件事:顺序、选择、循环。前两件事好理解,一行一行执行,遇到岔路判断一下方向。但一旦遇上“重复做某件事”,比如打印1到100、统计一个班四十个人的成绩、在十万个数字里找一个目标值,要是没有循环,代码能把人写到崩溃。我刚入门的时候,就干过把同一个输出语句复制粘贴一百遍的蠢事,后来被带我的同事狠狠教育了一顿,才老老实实把C语言里的循环控制结构啃扎实。说白了,循环控制结构就是让计算机替你干那些“重复但有规律”的活儿的钥匙,这也正是计算机最擅长的事。
这篇内容不扯虚的,直接从实际使用的角度,把C语言里的三种循环——while、do-while、for,连同break、continue这两个辅助控制语句,一个一个拆开讲清楚。每一个知识点都会配上能直接运行的代码片段,并且把我这些年用循环时踩过的坑、总结出来的调试技巧一并写出来。不管你是刚学编程的在校生,还是自学C语言想补基础的转行者,这篇文章都适合你。读完不需要你记住多少语法细节,只需要你明白:下次遇到“重复处理”的题目时,大脑里能第一时间浮现出循环的框架,并且知道该选哪一种循环来写。
1. 整体设计与思路拆解:为什么要“绕圈子”执行代码
很多初学者对循环的第一反应是“麻烦”,觉得代码一行一行写完不就行了吗,为什么要来回跳转?这里我先讲一个最朴素的应用场景。假如老师布置了一个作业,让你在屏幕上打印十次“Hello”,你不写循环,就得写十个printf语句。那打印一百次呢?一千次呢?就算你真有耐心复制一千行,万一哪一次需要改输出内容,你得手动改一千处。循环解决的就是这个“批量处理”的痛点,用一段简短的代码描述“重复执行的规律”,再由计算机自动去执行,这既是循环的魅力,也是程序效率的来源。
1.1 循环三要素:初始化、条件、更新
用我自己的理解方式,可以把任何循环都拆成三个部分。第一是初始化,给循环变量一个起点,就像跑步比赛前站到起跑线上;第二是条件判断,每次循环开始前确认“还跑不跑”,相当于比赛的终点线;第三是更新操作,跑完一圈之后把变量往终点推进一步,相当于每跑一圈数一次数。这三个要素配合好,循环才会按预期停止,否则就是死循环。用代码来对照就是:
int i = 0; // 初始化:起点 while (i < 10) // 条件:终点线 { printf("%d ", i); i = i + 1; // 更新:每跑一圈加一次数 }这段代码会打印0到9。我见过不少初学者把更新操作漏掉,结果程序进入了死循环,控制台窗口疯狂刷屏,最后只能强制关闭。这是学习循环的第一个要命的地方,也恰恰说明三要素缺一不可。
1.2 三种循环的基本语法与执行顺序
C语言提供三种循环语法,很多人把它们割裂来记,其实没必要。从执行逻辑上看,它们之间可以互相转换,区别主要在“判断时机”和“书写习惯”上。while和for都是先判断条件、后执行循环体,条件不满足时循环体一次都不执行;do-while则是先执行一次循环体、再判断条件,所以循环体至少会执行一次。
// while循环:先判断再执行 int a = 0; while (a < 3) { printf("a=%d\n", a); a++; } // do-while循环:先执行再判断 int b = 0; do { printf("b=%d\n", b); b++; } while (b < 3); // for循环:把三要素集中在头部 for (int c = 0; c < 3; c++) { printf("c=%d\n", c); }三种写法最终打印的效果一样,但do-while有一个特殊场景是其他两种比不了的:比如判断用户输入是否合法,总得先让用户输入一次,再判断要不要重新输入。这种场景用do-while写起来非常顺,而while的写法反而要在循环前额外写一遍输入代码。我平时写交互式菜单程序时,就特别偏爱do-while。
1.3 选型依据:什么时候用哪种循环
直接给你一个可以“抄作业”的判断标准,是我在带新人时反反复复讲的。如果循环次数是事先确定或者能用计数器控制的,无脑用for,比如遍历数组、打印固定行数;如果循环次数不确定,只能靠某个条件是否成立来判断的,用while,比如读取文件直到文件末尾;如果无论如何都要先执行一次循环体,再根据结果判断是否继续的,用do-while,比如菜单选择输入。
| 循环类型 | 判断时机 | 最少执行次数 | 典型场景 |
|---|---|---|---|
| while | 先判断后执行 | 0次 | 条件等待、文件读取 |
| do-while | 先执行后判断 | 1次 | 菜单输入、用户输入校验 |
| for | 先判断后执行 | 0次 | 计数遍历、固定次数循环 |
记住这张表就够了。我见过不少工作了几年的人写代码也经常while和for混用,不是说不行,但代码读起来总是不够直白。遵循这个选型标准,你的代码会清晰很多,别人接手也省心。
2. 核心细节解析与实操要点:循环边界的判断与流程跳转
语法谁都能背下来,真正拉开差距的是细节。循环控制结构里最容易出错的地方,几乎都集中在边界条件和流程控制语句的使用上。我写代码十二年,回头看看早年犯的错,一半以上都出在这些看似不起眼的小地方。
2.1 循环变量的边界:小于还是小于等于
这是初学者问得最多的问题。打印十次内容,到底写i < 10还是i <= 10?我的习惯是优先使用左闭右开区间,也就是i < 10。为什么?因为这种写法有一个明显的好处:循环次数刚好等于条件里的那个数。i < 10执行10次,i < n执行n次,一目了然。而且很多底层API和数组下标的取值范围也是左闭右开的,保持同样的习惯可以降低认知负担。
再比如从5数到10,你会看到有人写for (int i = 5; i <= 10; i++),也有人写for (int i = 5; i < 11; i++)。两种都对,但意思的清晰程度不一样。i < 11看起来会有“为什么是11”的疑惑,而i <= 10直白地告诉你“包含10”。所以在边界是孤立的数值时,用包含式更合适;在表达次数时,用左闭右开更合适。我建议你把这两种思维都记下来,根据场景选择,比死记一个规则要靠谱。
2.2 循环条件中的浮点数比较陷阱
用一个真实踩过的坑说明这个问题。当时我需要把0.1累加十次,判断结果是否为1.0,我下意识写了这样的代码:
double sum = 0.0; for (int i = 0; i < 10; i++) { sum += 0.1; } if (sum == 1.0) { printf("相等\n"); } else { printf("不相等,sum=%.20f\n", sum); }运行结果是“不相等”,打印出来的sum是0.99999999999999988898。原因并不神秘:0.1在计算机的二进制浮点数里并不是精确的0.1,累加十次之后误差就累积出来了。从那以后我给自己定了一条规则:循环条件里不要用浮点数比较,尤其是相等判断。必要的浮点累加场景,要么用整数计数循环,要么判断差值小于一个很小的误差值,比如fabs(sum - 1.0) < 1e-6。
// 推荐做法:用整数作为循环变量 double sum = 0.0; for (int i = 0; i < 10; i++) { sum += 0.1; } if (fabs(sum - 1.0) < 1e-6) { printf("近似相等\n"); }写循环条件的本质是在定义一个“边界”,而浮点数的边界是非常不可靠的。能避开就避开,不能避开就用误差区间判断。
2.3 break和continue:循环里的急刹车与跳过牌
break用于立即终止当前所在的循环,程序跳到循环外继续执行;continue则跳过本次循环的剩余语句,直接进入下一次循环的条件判断。初学者最容易混淆这两个词。我用一个生活中的例子说明:假设你在操场跑10圈,break的意思是“我不跑了,直接离场”;continue的意思是“这一圈我状态不好,不跑了,但下一圈我还接着来”。很明显,前者彻底结束,后者只是跳过一轮。
为了看清它们的区别,可以写一小段代码:
for (int i = 1; i <= 10; i++) { if (i == 5) { break; // 运行到这里直接终止循环 } printf("%d ", i); } // 输出:1 2 3 4 for (int i = 1; i <= 10; i++) { if (i == 5) { continue; // 跳过5这一轮,继续6到10 } printf("%d ", i); } // 输出:1 2 3 4 6 7 8 9 10这段代码非常经典,我建议你亲手敲一遍。还有一个容易被忽略的细节:break在switch语句里同样起到跳出作用,而且如果break用在嵌套循环里,它只跳出最内层的那一层循环,而不是一次性跳出所有循环。这个特性后面实操部分会专门讲。
3. 实操过程与核心环节实现:三个经典案例手把手拆解
掌握了语法和边界判断,现在进入动手环节。C语言循环控制结构的技能树,我认为用三个经典案例就可以点满百分之八十:累加器模式、双重循环嵌套、枚举算法。这三个案例覆盖了循环的绝大多数应用场景,代码量不大,但逻辑密度很高。
3.1 案例一:累加求和与连乘中的初始化陷阱
累加器是最基础的循环模式,比如计算1加到100的和。很多教材会直接给代码,但没有告诉你为什么累加变量sum必须初始化为0。我换个角度讲:初始化为0,是因为任何数加0都不会改变它本身,这叫“零元”;而连乘时mul必须初始化为1,是因为任何数乘以1都不会改变本身,这叫“幺元”。这两个词听着唬人,道理其实很简单。如果连乘时错误地初始化为0,结果永远只能是0。
long sum = 0; for (int i = 1; i <= 100; i++) { sum += i; } printf("1到100的和:%ld\n", sum); long mul = 1; for (int i = 1; i <= 10; i++) { mul *= i; } printf("1到10的乘积:%ld\n", mul);溢出问题也得提醒一句。1到20的乘积用int存储就会溢出,结果变成一个莫名其妙的负数。计算规模较大时,把累加变量声明为long,再大时用long long。这类问题不会直接报错,只会逻辑上出错,排查起来很隐蔽。
3.2 案例二:嵌套循环输出九九乘法表
嵌套循环是双重循环的典型应用,外循环控制行,内循环控制列。九九乘法表是理解嵌套循环的最佳练习,因为行和列之间存在明确的依赖关系:第i行有i列,正好用内层循环的边界去依赖外层循环变量。
for (int i = 1; i <= 9; i++) { for (int j = 1; j <= i; j++) { printf("%d*%d=%-2d ", j, i, j * i); } printf("\n"); }输出效果是左下三角的乘法表。这里有一个细节,%-2d表示左对齐、占2个字符宽度,用它可以让输出的表格对齐整齐,不至于数字一长一短看着乱。这个格式化输出的习惯我在实际开发里经常用,推荐你留意一下。写这个案例时,我建议你人为地出几个错:把内层循环的j <= i误写成j <= 9,观察输出从三角变成矩形;把外层从i = 1改成i = 0,观察第一行变成零乘法。这种“故意改错”的训练方法,比单纯照着敲代码有效得多。
3.3 案例三:质数判断与sqrt优化
判断一个整数是否为质数,思路是枚举所有可能的因子。一个数如果在2到它自身减1之间没有任何因子,它就是质数。这里我不想写那种从头枚举到尾的笨代码,因为完全没有必要。假设要判断一个数n,一旦发现了大于sqrt(n)的因子,必然会有一个**小于sqrt(n)**的因子与它对应。所以枚举范围只需要到sqrt(n),计算量从n次缩减到大约根号n次,效率提升非常明显。
#include <stdio.h> #include <math.h> int is_prime(int n) { if (n < 2) { return 0; } for (int i = 2; i <= sqrt(n); i++) { if (n % i == 0) { return 0; } } return 1; } int main(void) { int num; printf("请输入一个正整数:"); scanf("%d", &num); if (is_prime(num)) { printf("%d是质数\n", num); } else { printf("%d不是质数\n", num); } return 0; }注意return 0写在循环体内,一旦发现一个因子,函数立刻结束并返回“不是质数”,这其实就是一种提前退出的思想。用return来中断函数,比用break标志位要干净得多。这段代码我能背下来,每次讲goto和break的区别时也拿它举例,因为在质数判断里,中途退出完全合法,而且让代码变得非常短小清晰。
3.4 从代码数量级看循环性能差异
嵌套循环带来的性能问题是面试里高频出现的点,实际开发中也不能忽略。假设外层n次、内层m次,简单的双重循环复杂度就是O(n*m)。如果你能把内层循环做到提前退出,最坏情况和平均情况会差很多。拿质数判断加上素数计数来说,遍历1到100000之间所有数,逐个用慢办法枚举和用sqrt办法枚举,时间差距肉眼可见。我在机器上实测过,从大约5秒多降到不到0.1秒。这不是玄学,就是循环次数实打实减少了。
写循环代码时,脑子里要有一根弦:循环层数越深,总执行次数增长越快。平时能单层解决就不要叠两层,能提前退出就写提前退出。这不是让你过度优化,而是在一开始就避免写出明显会被数据规模压垮的代码。
4. 常见问题与排查技巧实录:多轮调试换来的避坑经验
代码写多了,出错是常态,关键是要有快速的排查思路。下面这些问题,每一个都是我在教学和实际项目里真实遇到过的,记录下来,希望能让你少走弯路。
4.1 死循环:忘记更新循环变量的经典现场
死循环可能是循环学习里最让人崩溃的问题,表现形式有两种:一是程序完全卡死,二是控制台窗口疯狂输出。前者一般是循环体内没有任何输出语句,CPU全在空转;后者则是一看输出就猜到了原因。
最经典的死循环写法是:
int i = 0; while (i < 10) { printf("i=%d\n", i); // 忘写 i++; }变量i永远是0,条件永远成立,循环永远出不来。排查这类问题的顺序我建议是这样:先看初始化,再看条件表达式有没有可能永远为真,最后看循环体最后一行有没有更新语句。如果代码量很大,就在循环体里临时加一个printf打印循环变量,看它是否按预期变化,这叫打点调试法。用完记得删掉这些临时输出,不然代码会显得很邋遢。
4.2 边界多跑一轮:循环方向反了或者边界开错
另一种常见错误是循环多执行一次或者少执行一次。最典型的是数组遍历,C语言数组索引从0开始,遍历长度为n的数组,条件应该是i < n,但新手很容易写成i <= n,结果越界访问,轻则读到脏数据,重则程序崩溃。
遍历数组的正规写法如下:
int arr[5] = {10, 20, 30, 40, 50}; for (int i = 0; i < 5; i++) { printf("arr[%d]=%d\n", i, arr[i]); }要格外注意,C语言并不检查数组越界,因此这类错误完全靠程序员自己把关。我现在的习惯是在每次写循环前都先默念一遍“次数是多少,下标范围是多少”,两个数字对上号了再往下写。
4.3 嵌套循环中的break和continue:到底跳出的是哪一层
前面提到break只跳出最内层的循环,这是嵌套循环里特别容易出理解偏差的地方。看下面这段代码:
for (int i = 0; i < 3; i++) { for (int j = 0; j < 3; j++) { if (j == 1) { break; } printf("i=%d j=%d\n", i, j); } printf("内层循环结束,i=%d\n", i); }输出结果里,内层的break只会让j从0跳到2,但外层循环还是正常会执行3次。如果你想在满足某个条件时彻底退出所有嵌套循环,只靠break是不够的,需要引入一个标志位,或者让函数直接return。我的建议是:在多层嵌套里需要彻底退出时,把逻辑封装成一个函数,用return退出,这样代码比用标志位更直观,也更好维护。
4.4 continue在while循环里的特殊性
continue对for循环很好用,它会跳到for语句的更新表达式那一行,然后判断条件。但放在while循环里,如果循环变量的更新写在continue之后,continue会直接跳过更新语句,从而引发死循环:
int i = 0; while (i < 10) { if (i == 5) { continue; // 跳过了下面的 i++,i永远是5 } printf("%d ", i); i++; }这个例子运行起来会卡死在i等于5那里。很多人直到程序刷屏都没反应过来是continue的问题。经验是:如果while循环里用到continue,就把循环变量的更新放在循环体的第一行,或者干脆用for循环代替,这样能省掉很多不必要的排查时间。这不是什么高深的技巧,纯粹是踩坑之后的习惯养成。
4.5 循环相关问题排查速查表
把上面几个问题整理成速查表,方便你写代码卡壳时对照。
| 问题表现 | 可能原因 | 解决办法 |
|---|---|---|
| 程序卡死、CPU占用高 | 循环变量未更新或更新被跳过 | 检查循环体末端是否有i++,continue是否跳过了更新 |
| 循环次数比预期多一次 | 边界用了i <= n而不是i < n | 明确循环次数,数组遍历统一用i < 长度 |
| 循环一次都没执行 | while/for条件初始就不成立 | 检查初始化值与条件的关系,确认是否应为do-while |
| 输出杂乱无章 | 嵌套循环中break跳错层 | 记住break只结束最内层,必要时用return提前返回 |
| 浮点累加结果不对 | 浮点数二进制精度误差累积 | 用整数做循环变量,比较时用误差范围判断 |
| 循环内声明的变量外面用不了 | 变量作用域仅限于循环体 | 需要在循环外使用的数据,在循环外先声明变量 |
这一套表是我整理给自己团队新人看的,每年都有人来问同样的问题。大部分循环问题翻来覆去都是这些原因,定位到具体类型,修起来就快多了。
我个人在实际调试中的体会是,循环语句写错了并不可怕,可怕的是对着屏幕干瞪眼,毫无章法地乱试。先把三要素列出来:初始化、条件、更新,逐一核对;再确认break和continue的作用范围;最后再检查边界是否包含端点。按这个顺序走一遍,九成以上的循环问题都能在五分钟内找到根源。写循环多一点耐心,把每个案例亲手敲出来执行一遍,比看十篇文章都管用。