在C语言里,循环几乎是每段有点实际功能的代码都绕不开的东西。不管你是要遍历数组、累加求和,还是写一个持续接收用户输入的程序,都会用到while或for。坦白讲,我自己刚接触C语言的时候,一度分不清这两个到底该用哪个,写出来的代码常常是while和for混着用,后来才发现,每次纠结“这里该用哪个”其实是在浪费精力。这篇文章就把while和for彻底讲透,从语法细节到执行流程,从踩坑记录到实战场景,一次性梳理清楚,希望你读完能形成自己的判断,而不是靠死记硬背写循环。
1. 先说清楚:while和for到底解决什么问题
1.1 没有循环的日子是怎么过的
很多人一开始学C语言,写完顺序结构的代码后,会发现一个问题:如果我要输出“你好”5次,难道要写5行printf?如果要输出100次呢?总不能复制100遍吧。这就是循环出现的根本原因——让同一段代码根据条件反复执行,避免手动复制粘贴。
顺着这个思路,你就能理解循环的本质:循环体是那件要反复做的事情,循环条件决定“什么时候停下来”。只要条件成立,循环体就一遍一遍跑,直到条件不成立为止。这个理解比死记语法更重要,因为无论是while还是for,底层逻辑都是这一句话。
1.2 while和for的分工,一句话说清
我用一句话概括:不知道要循环多少次,但知道什么时候该停,用while;知道大概要循环多少次,尤其是跟计数相关的,用for。
听起来很简单,对吧。但实际场景要比这句话复杂得多。比如你读文件,读到文件末尾才停,你不知道文件里有多少行,这是典型的while场景;你从1加到100,明确要加100次,这是典型的for场景。后面我会通过实战案例逐一演示,这里先记住这个大方向。
2. while循环:语法、执行流程与躲坑指南
2.1 while的标准写法与执行顺序
while的语法非常简单:
while (条件表达式) { // 循环体 }执行顺序是:先判断条件表达式,如果为真(非0),进入循环体执行;执行完循环体,再次回到条件表达式判断。条件为假(0)时,跳过循环体,继续往后走。
这里有个特别容易被新手忽略的点:如果条件一开始就不成立,循环体一次都不会执行。这个特性在某些场景下是优点,比如你要检测一个队列是否为空,空的话直接不处理;但在另一些场景下可能是个坑,比如你希望至少执行一次,那就得用do-while了。
写while循环时,循环体里一定要有改变条件的语句。我见过太多次初学者写了下面这样的代码:
int i = 1; while (i <= 10) { printf("%d\n", i); // 忘记写 i++ 了 }后果就是i一直等于1,条件永远成立,程序陷入死循环。这不是小问题,在真实项目里,死循环会导致程序占用CPU持续飙高,界面卡死,服务器响应异常。所以每次写while循环,先扫一眼循环体有没有改变条件变量的语句。
2.2 do-while:先干一次再判断的另类while
do-while的语法是这样的:
do { // 循环体 } while (条件表达式);注意最后那个分号,很多人第一次写都会漏掉,编译直接报错。do-while的特点是先执行一次循环体,再判断条件,所以至少会执行一次。
这个特性最典型的应用场景是用户输入校验。比如你要让用户输入一个1到100之间的数字,如果输错了就重新输入,用do-while非常顺手:
int num; do { printf("请输入1到100之间的数字:"); scanf("%d", &num); } while (num < 1 || num > 100);程序会一直要求输入,直到用户输入合法为止。用普通while也能实现,但得先把num初始化成一个非法值,代码看起来就别扭多了。
2.3 while循环里最常见的三个坑
第一个坑是条件表达式的写法。有人图省事,写成while (i == 10)却少打了个等号变成while (i = 10),这直接把赋值表达式的值10当成了条件,永远为真,死循环跑起来。C语言里赋值表达式的值就是赋进去的值,非0就是真,这点跟其他语言不太一样,初学阶段最容易在==和=上翻车。
第二个坑是循环控制变量暴露在外部。比如你写了一个while循环查找链表中的某个节点,循环结束后,用来遍历的指针变量还留在作用域里,指向那个找到的节点或最后的NULL。如果你在循环之后再误用这个变量,很容易出莫名其妙的问题。建议循环变量尽量只在循环内使用,如果循环结束后还需要用到结果信息,用一个独立变量保存。
第三个坑是条件判断里使用浮点数比较。浮点数有精度误差,直接比较while (x != 1.0)非常危险,可能永远等不到x精确等于1.0的那一刻。解决办法是定义一个很小的精度阈值,比如while (fabs(x - 1.0) > 1e-6)。这个坑在工程计算里特别常见。
3. for循环:结构更紧凑,边界更可控
3.1 for的四个组成部分和执行顺序
for循环的语法是:
for (初始化表达式; 条件表达式; 更新表达式) { // 循环体 }执行顺序很多人背不清,我拆开讲:
- 先执行初始化表达式,且只执行这一次。
- 判断条件表达式,为假就直接跳出循环。
- 条件为真,执行循环体。
- 循环体执行完,执行更新表达式。
- 回到第2步,继续判断条件。
也就是说,条件判断和更新表达式不是顺序执行的,循环体夹在中间。画个执行顺序就是:初始化 → 判断 → 循环体 → 更新 → 判断 → 循环体 → 更新 → 判断……直到判断为假。
标准写法里,初始化通常用来定义一个循环变量,比如for (int i = 0; i < 10; i++),在C99及以上版本中可以直接在初始化里声明变量,这样变量的作用域就被限制在循环内部,循环结束后访问不到,这个设计本身就是为了防止刚才说的“循环变量暴露在外部”的坑。
3.2 for循环的三种灵活变形
for的语法其实很灵活,三个表达式都可以省略,但分号不能少。
省略初始化表达式:适合循环变量已经在外部初始化好的情况。
int i = 5; for (; i < 20; i++) { // 从5开始循环到19 }省略条件表达式:相当于把条件写成了恒真,循环没有出口,必须在循环体里用break手动跳出。
for (int i = 0; ; i++) { // 需要break跳出 if (i > 100) break; }省略更新表达式:更新动作被放进了循环体里,实际上退化成类似while的写法。
for (int i = 0; i < 10; ) { // 循环体里自己更新i i++; }这三个变形我不建议在初学阶段用,因为可读性差,容易把自己绕晕。但在阅读别人代码时会经常看到,你能看懂就行。我自己写代码时基本只用标准形式,只有在特殊场景下才会省略。
3.3 for和while如何互相转换
严格来说,任何while循环都能改写成for循环,反过来也一样。转换的核心是找到对应的三个部分:初始化、条件、更新。
一个典型的while循环:
int count = 0; while (count < 10) { printf("%d\n", count); count++; }改成for:
for (int count = 0; count < 10; count++) { printf("%d\n", count); }对于“不知道次数但知道停止条件”的while场景,硬改成for也可以,但写出来的代码会不自然。比如等待用户输入特定字符的循环:
char ch; while ((ch = getchar()) != 'q') { printf("你输入了%c\n", ch); }改成for就显得别扭了。所以我的建议是:在代码里保持语义一致,该用while用while,该用for用for。for的优势是循环变量、条件、更新都集中在一行里,方便阅读,但也要求这三部分逻辑本身就很紧密、可计数。
4. break、continue与嵌套循环的使用策略
4.1 break:及时停住,别硬跑
break的作用是跳出当前一层循环。注意“当前一层”这个术语,多层嵌套时,break只跳出最内层的那一层,外层的继续执行。
举个例子,你要在一个数组里找第一个大于100的元素:
int arr[10] = {1, 50, 103, 99, 200}; for (int i = 0; i < 10; i++) { if (arr[i] > 100) { printf("找到了:%d\n", arr[i]); break; } }找到第一个就直接跳出,不用再遍历剩余部分。这个操作在性能上很有意义,尤其是当数组很大、目标出现得很早时。
使用break的一个常见疑问是:找到后怎么知道是break出来的,还是正常循环结束的?很多人会用一个标志变量,比如:
int found = 0; for (int i = 0; i < 10; i++) { if (arr[i] > 100) { found = 1; break; } } if (found) { printf("存在大于100的数\n"); } else { printf("不存在\n"); }这个写法很实用,也容易理解。至于有些人会考虑把break用在while里,场景是一样的,都是让循环提前结束。
4.2 continue:跳过本次,继续下一次
continue不一样,它不结束循环,只跳过循环体里continue后面的代码,直接进入下一轮判断。
如果你要输出1到10之间的偶数,用continue写:
for (int i = 1; i <= 10; i++) { if (i % 2 != 0) { continue; } printf("%d\n", i); }奇数直接跳过printf,偶数才打印。这个功能用if也能实现,但组合条件复杂时,continue能显著减少嵌套层级,代码更清晰。
要注意的是,在while循环里使用continue时,更新表达式如果放在continue后面,就会导致循环变量不更新,变成死循环。拿这个代码举例:
int i = 0; while (i < 10) { if (i == 5) { continue; // 坏了,i++永远执行不到 } printf("%d\n", i); i++; }一旦i等于5,程序就卡死在continue和判断之间,i永远不增长。解决方法是把i++放在continue之前,或者改用for循环,更新表达式写在for那一行,continue不会跳过它,天然避开这个问题。这也是我推荐“能用for就用for”的原因之一。
4.3 嵌套循环的性能细节
嵌套循环是二维数组遍历、排序算法、矩阵运算的必备工具,比如常见的冒泡排序就是双重循环。但嵌套循环也存在性能隐患,尤其是深层嵌套时。
先看一个简单的例子:打印九九乘法表,用双重循环,外层控制行,内层控制列:
for (int i = 1; i <= 9; i++) { for (int j = 1; j <= i; j++) { printf("%d*%d=%d\t", j, i, i * j); } printf("\n"); }这个例子里,外层循环9次,总执行次数是1+2+...+9=45次,不算多。但如果两个循环都是0到10000,总次数就是1亿,这个数量级对程序性能就有影响了。
优化嵌套循环时有一个基本思路:把最耗时的操作尽量往外提。比如在双重循环里反复计算的一个不变量,提前在循环外算好,避免内层循环每跑一次都重新算一遍。还有一个细节:内层循环尽量使用局部变量,减少内存访问的随机性,这个在嵌入式开发里影响更明显。
5. 四个实战场景,从入门到能写
5.1 数组求和与查找最大值
数组是C语言里用得最多的数据结构,而数组操作离不开循环。求一个数组所有元素的和,最直接的写法:
int arr[5] = {3, 7, 2, 9, 4}; int sum = 0; for (int i = 0; i < 5; i++) { sum += arr[i]; } printf("总和:%d\n", sum);这里有个值得养成的习惯:数组的长度用宏定义或变量来表示,而不是直接把5写死在循环里。因为以后数组改成10个元素,你只需要改一个地方。代码改成这样:
#define ARR_SIZE 5 int arr[ARR_SIZE] = {3, 7, 2, 9, 4}; int sum = 0; for (int i = 0; i < ARR_SIZE; i++) { sum += arr[i]; }查找最大值的逻辑是这样的:先假设第一个元素就是最大值,然后遍历剩下的元素,遇到更大的就更新:
int max = arr[0]; for (int i = 1; i < ARR_SIZE; i++) { if (arr[i] > max) { max = arr[i]; } }注意i从1开始,因为arr[0]已经作为初始值了,不需要再和自己比较。这种写法看起来没什么问题,但如果数组是空数组就麻烦了,访问arr[0]本身就是越界。所以实际编码时要先判断数组是否为空。
5.2 九九乘法表与打印格式控制
前面已经给出了九九乘法表的核心代码,这里说说格式控制。printf里的\t是制表符,用来对齐输出,但有时不同数字的宽度不一样,比如“11=1”和“99=81”长度差异很大,单靠\t在控制台上还能看,但输出到文件或某些界面时对齐效果就差了。
更可靠的做法是用printf的宽度说明符,比如%2d表示占两个字符宽度,右对齐:
for (int i = 1; i <= 9; i++) { for (int j = 1; j <= i; j++) { printf("%d*%d=%-2d ", j, i, i * j); } printf("\n"); }%-2d表示左对齐,占两个字符宽。这样打印出来的九九乘法表,每一列都能整齐对齐。很多初学者不在意输出格式的细节,但实际项目里,日志格式、报表输出、数据导出的对齐问题经常让人抓狂,从这些小例子里就开始养成格式控制的意识,后面会省很多事。
5.3 斐波那契数列与素数判断
斐波那契数列的规律是每一项目等于前两项之和,初始两项是0和1。用循环来生成前20项:
long long fib[20]; fib[0] = 0; fib[1] = 1; for (int i = 2; i < 20; i++) { fib[i] = fib[i - 1] + fib[i - 2]; } for (int i = 0; i < 20; i++) { printf("%lld ", fib[i]); }这个例子有两个知识点。第一,斐波那契数列增长非常快,第20项以后就超过int的范围了,所以我用long long,这种“数值可能溢出”的意识要提前建立。第二,第一段循环是生成数据,第二段循环是输出数据,两段循环分工明确,你以后阅读别人代码时,也能从这种结构快速理解逻辑。
判断一个数是不是素数,同样需要循环。素数是除1和它本身之外没有其他因数的数:
int n = 97; int is_prime = 1; for (int i = 2; i * i <= n; i++) { if (n % i == 0) { is_prime = 0; break; } } if (is_prime == 1 && n > 1) { printf("%d是素数\n", n); }这里的关键是i * i <= n。如果一个数n不是素数,那么它一定有一个小于等于根号n的因子,所以只需要检查到根号n就够了。这个优化把时间复杂度从O(n)降到了O(根号n),在大数字判断中效果非常明显。比如判断1000000附近的数是不是素数,原方法可能要做几十万次运算,优化后只需要几百次。
5.4 一个综合小项目:猜数字游戏
把循环、分支、随机数、输入输出综合到一起,写一个猜数字小游戏,是检验基础的好办法。
游戏规则是程序随机生成一个1到100的数字,玩家输入猜测,程序提示太大或太小,直到猜中为止:
#include <stdio.h> #include <stdlib.h> #include <time.h> int main() { srand(time(NULL)); int target = rand() % 100 + 1; int guess; int attempts = 0; do { printf("请输入你猜的数字:"); scanf("%d", &guess); attempts++; if (guess > target) { printf("太大了\n"); } else if (guess < target) { printf("太小了\n"); } else { printf("恭喜你猜对了!总共猜了%d次\n", attempts); break; } } while (1); return 0; }这个程序的核心是do-while(1),一个不设条件的循环,靠break跳出。这里省略条件表达式是合理的,因为退出条件分散在循环体内部的多个分支中,用while(1)+break是最自然的写法。srand(time(NULL))是随机数种子,rand() % 100 + 1生成1到100之间的随机数。
你可能会问,为什么不直接用while加条件?因为游戏要一直进行,条件写在哪里都不够简洁。这种“循环条件不好定义但知道什么时候退出”的场景,用while(1)+break反而更清晰。
6. 常见报错与排查经验实录
6.1 死循环:程序卡住不动的处理办法
死循环是循环最容易出的故障,表现形式就是程序一直运行,不输出、不结束,像卡死了一样。
排查步骤大致是这样:先在循环体开头放一个临时的printf,输出循环变量的值,观察它是否在按预期变化。如果输出停止不变,大概率是更新语句没执行到,重点检查是不是continue跳过了更新,或者更新语句在某个分支里没有覆盖。如果循环变量一直在变但程序还是没结束,说明循环条件可能永远为真,检查条件表达式里比较的数值是否有机会到达边界。
我自己遇到过最隐蔽的一个死循环,是在条件里用了无符号整数:
for (unsigned int i = 10; i >= 0; i--) { printf("%u\n", i); }这个循环永远跑不完,因为无符号整数永远大于等于0,i减到0之后,再减1会变成一个很大的正数,条件依然成立。解决方法是把有符号和无符号分开,或者改用i > 0作为条件。这个坑在日常开发里很常见,而且一旦遇到,看半天不一定能看出来。
6.2 边界条件出错,多跑一次少跑一次
循环里最常见的边界问题就是少一次、多一次或者越界。看这段代码:
int arr[3] = {1, 2, 3}; for (int i = 0; i <= 3; i++) { printf("%d\n", arr[i]); }数组只有3个元素,索引是0到2,但循环条件写成了i <= 3,i会遍历到3,arr[3]就数组越界了。C语言不像其他一些语言那样会自动检查数组越界,它可能会访问到内存中相邻位置的数据,或者直接导致程序崩溃。
边界问题的根源还是对“区间”的理解。C语言的循环习惯是“左闭右开”,也就是for (int i = 0; i < n; i++),i从0到n-1,一共n次。这个写法能避免很多边界错误,因为不需要去数n-1还是n,脑子里只要记住“小于长度”就好。
6.3 循环内的变量初始化问题
循环体里定义的变量,每次循环都会重新初始化;循环外定义的变量,会一直保留上一次循环的结果。这个区别如果不注意,会产生很隐蔽的逻辑错误。
看这个例子,我想打印每一行“当前数字和之前所有数字的和”:
int arr[4] = {1, 2, 3, 4}; for (int i = 0; i < 4; i++) { int sum = 0; sum += arr[i]; printf("%d\n", sum); }这样写每次循环sum都被重新初始化为0,输出结果是1、2、3、4,而不是累加和。如果sum定义在循环外面,输出就是1、3、6、10。两种写法的语义完全不同,你得先想清楚到底要哪种。这也是调试循环时经常要检查的一个点。
6.4 几个容易被忽略的效率细节
循环的性能优化,在数据规模小的时候看不出差别,但数据量一上去,差距就明显了。
第一个细节是减少重复计算。比如:
for (int i = 0; i < strlen(str); i++) { // 处理 }每次循环都会调用strlen计算字符串长度,如果字符串很长,这个开销相当可观。正确做法是在循环外先算好:
int len = strlen(str); for (int i = 0; i < len; i++) { // 处理 }第二个细节是尽量避免在循环体里做不必要的函数调用,把能提前算的运算挪到循环外面。第三个细节是优先使用局部变量,而不是频繁访问外部数组或结构体字段;把数组元素先赋给局部变量再计算,有时会有明显提升。
这些优化在写业务代码时不一定非要纠结,但养成习惯后,遇到性能敏感的代码就不会手足无措。
最后分享一个我自己的习惯:每次写完一个循环,都会用“边界值 + 退出条件”的方式检查一遍,比如循环变量等于0、等于n-1、等于n时,程序分别会怎么走。这样自查一遍,很多隐蔽错误在编译运行之前就能被发现。循环这个东西,多写几次、多踩几次坑,你对它的掌控感自然就上来了。