循环语句是C语言里最容易被低估的一块内容。很多人学完for、while、do...while之后,觉得无非就是"重复执行一段代码",考试能写个九九乘法表、冒泡排序就算过关了。但真正到了工程代码里,循环写得好不好,直接决定了程序是跑得稳还是三天两头出问题。我见过太多因为循环边界写错导致数组越界、因为死循环把单片机看门狗喂挂、因为循环里反复调用函数把性能拖垮的案例。这篇内容就把C语言循环语句从语法到实战、从常见坑到优化技巧,完整地捋一遍,适合刚入门C语言的朋友,也适合写了几年代码但想回头把基础打扎实的开发者。
1. 三种循环语句到底该怎么选
C语言提供了三种循环结构:for、while、do...while。语法书上会把它们并列介绍,但实际写代码的时候,选择哪一种是有讲究的,不是随便挑一个能跑就行。
1.1 for循环:计数场景的首选
for循环的结构是for(初始化; 条件判断; 循环变量更新)。它最大的特点是"三要素"集中在一行里,读代码的人一眼就能看出这个循环要跑多少次、从哪开始、到哪结束。
for (int i = 0; i < 10; i++) { printf("%d\n", i); }这段代码的意图非常清晰:从0开始,每次加1,跑到9为止。当循环次数在写代码时就能确定,或者有一个明确的计数器变量时,for循环是最自然的选择。
我个人的经验是,遍历数组、字符串这类"已知长度"的场景,一律用for。比如:
int arr[5] = {1, 2, 3, 4, 5}; int sum = 0; for (int i = 0; i < 5; i++) { sum += arr[i]; }这里用for的好处是,循环变量i的作用域被限制在循环内部(C99标准之后支持在for里声明变量),出了循环就访问不到,避免了变量污染。
1.2 while循环:条件驱动的场景
while循环的结构是while(条件),它只关心"条件成立就继续跑",不关心跑多少次。适合那种"不知道要循环几次,只知道什么时候该停"的场景。
int n; scanf("%d", &n); while (n > 0) { printf("%d ", n % 10); n /= 10; }这段代码逐位输出一个整数的各位数字,循环次数取决于输入的数字有几位,事先根本不知道。这种场景用while就比for自然得多。
while还有一个典型用法是处理输入流,比如读取文件直到文件结束:
int ch; while ((ch = getchar()) != EOF) { putchar(ch); }这里把赋值和判断合并在一行里,是C语言里非常经典的写法。注意括号的位置,(ch = getchar())外面的括号不能省,因为!=的优先级高于=,不写括号会变成ch = (getchar() != EOF),逻辑完全错了。这个坑我当年踩过,编译器不一定报错,但行为完全不对。
1.3 do...while:至少执行一次的场景
do...while和while的区别只有一个:do...while先执行循环体,再判断条件。也就是说,不管条件成不成立,循环体至少跑一次。
int choice; do { printf("1. 开始游戏\n"); printf("2. 退出\n"); printf("请选择: "); scanf("%d", &choice); } while (choice != 2);菜单类的交互最适合do...while。因为菜单至少要显示一次,用户才能做出选择。如果用while,就得在循环外面先写一遍菜单,循环里面再写一遍,代码重复。
1.4 三者的等价性与选择原则
理论上,三种循环可以互相转换。任何for循环都能改写成while,任何while都能改写成do...while(加个标志变量)。但"能改"不代表"该改"。
我的选择原则很简单:
| 场景特征 | 推荐循环 | 理由 |
|---|---|---|
| 循环次数已知或可计算 | for | 三要素集中,意图清晰 |
| 循环次数未知,靠条件终止 | while | 结构简洁,条件即终止条件 |
| 循环体至少执行一次 | do...while | 语义天然匹配 |
| 遍历数组/字符串 | for | 计数器天然存在 |
| 读取输入直到结束 | while | 终止条件明确 |
选对了循环类型,代码的可读性会提升一个档次。反过来,用while写计数循环,读者得自己数循环变量在哪更新,容易看漏。
2. 循环控制:break和continue的正确打开方式
break和continue是循环里的两个"跳转开关",但它们的语义完全不同,用错了会让逻辑变得难以追踪。
2.1 break:彻底跳出当前循环
break的作用是立即终止当前所在的循环,程序继续执行循环后面的代码。
for (int i = 0; i < 100; i++) { if (arr[i] == target) { printf("找到了,下标是%d\n", i); break; } }这段代码在数组里查找目标值,找到就跳出,不再继续遍历。break在这里的作用是"提前结束,避免无谓的循环"。
需要注意的是,break只能跳出一层循环。如果有多层嵌套,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); } }这段代码里,内层循环每次跑到j==1就break,外层循环照常跑完。输出结果是每个i只打印j=0那一行。
如果想一次跳出多层循环,常见做法是用一个标志变量:
int found = 0; for (int i = 0; i < 3 && !found; i++) { for (int j = 0; j < 3; j++) { if (arr[i][j] == target) { found = 1; break; } } }或者用goto(虽然很多人不推荐,但在跳出多层循环这个场景下,goto反而是最清晰的):
for (int i = 0; i < 3; i++) { for (int j = 0; j < 3; j++) { if (arr[i][j] == target) { goto found; } } } found: printf("找到了\n");goto在C语言里不是洪水猛兽,关键看用在哪。跳出多层循环是它少数几个"正当用途"之一。
2.2 continue:跳过本次,继续下一次
continue的作用是跳过循环体中剩余的语句,直接进入下一次循环的条件判断。
for (int i = 0; i < 10; i++) { if (i % 2 == 0) continue; // 跳过偶数 printf("%d ", i); // 只打印奇数 }continue和break的区别,用一句话概括:break是"我不玩了",continue是"这次不算,下次再来"。
2.3 在while里用continue的陷阱
在for循环里用continue很安全,因为for的循环变量更新是在continue之后自动执行的。但在while里用continue,如果更新语句写在循环体末尾,continue会跳过它,导致死循环:
int i = 0; while (i < 10) { if (i == 5) continue; // 危险!i永远不会增加 printf("%d ", i); i++; // continue跳过了这一行 }这段代码在i==5时会陷入死循环,因为continue跳过了i++,i永远停在5。正确的写法是把更新放在continue之前,或者干脆改用for循环。
提示:在while循环里使用continue时,务必确认循环变量的更新语句在continue之前执行,否则极易造成死循环。
3. 循环嵌套:从九九乘法表到冒泡排序
循环嵌套是循环语句最强大的用法,也是初学者最容易写晕的地方。嵌套的本质是"外层跑一次,内层跑一整轮"。
3.1 嵌套循环的执行逻辑
for (int i = 1; i <= 3; i++) { for (int j = 1; j <= 3; j++) { printf("(%d,%d) ", i, j); } printf("\n"); }输出结果是:
(1,1) (1,2) (1,3) (2,1) (2,2) (2,3) (3,1) (3,2) (3,3)外层i每取一个值,内层j就从1跑到3。总的执行次数是3×3=9次。理解这一点,嵌套循环就不难了。
3.2 经典案例:九九乘法表
for (int i = 1; i <= 9; i++) { for (int j = 1; j <= i; j++) { printf("%d*%d=%-2d ", j, i, i * j); } printf("\n"); }这里内层循环的条件是j <= i,而不是固定的9。这样每一行打印的列数随行数增加,形成三角形。%-2d表示左对齐、占2个字符宽度,让输出对齐。
3.3 经典案例:冒泡排序
冒泡排序是循环嵌套的教科书级案例:
void bubble_sort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; } } } }外层控制"排序的轮数",内层控制"每轮比较的次数"。注意内层条件是j < n - 1 - i,因为每跑完一轮,最大的元素就"冒"到了末尾,下一轮就不用再比较它了。
这个- i是冒泡排序的优化点。如果不减i,程序也能跑对,但会多做很多无用的比较。写循环的时候,能少跑一次就少跑一次,这是基本功。
3.4 嵌套循环的性能意识
嵌套循环的执行次数是各层循环次数的乘积。两层各100次的循环,总共要跑10000次。三层就是100万次。所以在嵌套循环里,任何一点小开销都会被放大。
比如在内层循环里调用strlen:
// 不推荐 for (int i = 0; i < strlen(str); i++) { for (int j = 0; j < strlen(str); j++) { // ... } }strlen是O(n)的操作,放在循环条件里,每次判断都要重新算一遍,整体复杂度直接上升一个量级。正确做法是提前算好:
int len = strlen(str); for (int i = 0; i < len; i++) { for (int j = 0; j < len; j++) { // ... } }这个优化在数据量小的时候看不出差别,但数据量一大,差距就是几秒和几分钟的区别。
4. 循环里那些让人抓狂的坑
循环语句的语法很简单,但实际写代码时,坑都藏在细节里。下面这几个是我和身边朋友踩过最多的。
4.1 边界错误:多一次还是少一次
循环边界是off-by-one错误的重灾区。看这段代码:
int arr[5] = {1, 2, 3, 4, 5}; for (int i = 0; i <= 5; i++) { // 错误!i会取到5 printf("%d ", arr[i]); }数组下标是0到4,但循环条件写成了i <= 5,i会取到5,访问arr[5]就是越界。这种错误在C语言里不会报错,但会读到垃圾数据,甚至导致程序崩溃。
正确的写法是i < 5。记住一个原则:遍历长度为n的数组,循环变量从0到n-1,条件是i < n。
4.2 死循环:程序卡死的元凶
死循环的常见原因有三种:
第一种是循环变量没有更新:
int i = 0; while (i < 10) { printf("%d ", i); // 忘了写 i++; }第二种是条件永远成立:
for (int i = 0; i >= 0; i++) { // i是int,溢出后变成负数,但在这之前会跑很久 // ... }第三种是浮点数比较:
for (float f = 0.0; f != 1.0; f += 0.1) { // 危险! // ... }浮点数在计算机里是近似存储的,0.1累加10次不一定精确等于1.0。用浮点数做循环条件,很容易陷入死循环或者少跑一次。正确做法是用整数做循环变量,或者用范围判断:
for (int i = 0; i < 10; i++) { float f = i * 0.1f; // ... }4.3 循环里修改循环变量
在for循环体内修改循环变量,是另一个容易出问题的地方:
for (int i = 0; i < 10; i++) { if (i == 5) i = 8; // 手动跳过 printf("%d ", i); }这段代码能跑,但逻辑变得难以预测。除非有非常明确的理由,否则不要在循环体内修改循环变量。如果确实需要跳过某些值,用continue更清晰。
4.4 scanf在循环里的输入问题
用scanf在循环里读输入时,如果输入格式不匹配,scanf会返回0并且不消耗输入缓冲区,导致无限循环:
int n; while (scanf("%d", &n) != 1) { printf("输入错误,请重新输入\n"); // 死循环!错误的输入还在缓冲区里 }正确的做法是清空缓冲区:
int n; while (scanf("%d", &n) != 1) { printf("输入错误,请重新输入\n"); while (getchar() != '\n'); // 清空这一行 }这个坑在写交互式程序时特别常见,尤其是让用户输入数字但用户输入了字母的时候。
5. 循环的性能优化:从能跑到跑得快
循环是程序里执行最频繁的代码块,优化循环往往能带来最明显的性能提升。下面几个技巧,都是实际项目中验证过有效的。
5.1 把不变的计算提到循环外
循环里如果有些计算每次结果都一样,就应该提到循环外面:
// 优化前 for (int i = 0; i < n; i++) { result[i] = data[i] * 3.14159 * radius * radius; } // 优化后 double area_factor = 3.14159 * radius * radius; for (int i = 0; i < n; i++) { result[i] = data[i] * area_factor; }编译器有时候能自动做这个优化(叫做循环不变量外提),但不能指望它。自己写清楚,既快又易读。
5.2 减少循环内的函数调用
函数调用有开销,放在循环里会被放大:
// 优化前 for (int i = 0; i < strlen(str); i++) { // ... } // 优化后 int len = strlen(str); for (int i = 0; i < len; i++) { // ... }对于简单的函数,编译器可能会内联,但strlen这种涉及内存访问的,编译器一般不会自动优化。手动提出来是最稳妥的。
5.3 循环展开
循环展开是一种用代码空间换执行时间的技巧,把多次循环合并成一次:
// 普通循环 for (int i = 0; i < 100; i++) { sum += arr[i]; } // 展开后 for (int i = 0; i < 100; i += 4) { sum += arr[i]; sum += arr[i + 1]; sum += arr[i + 2]; sum += arr[i + 3]; }展开后循环次数减少到25次,减少了循环控制的开销。不过现代编译器在开优化的情况下会自动做循环展开,手动展开反而可能干扰编译器的优化。所以这个技巧了解即可,实际项目中除非有明确的性能瓶颈,否则不建议手动展开。
5.4 缓存友好的访问顺序
多维数组的访问顺序对性能影响巨大。C语言的数组是按行存储的,所以按行访问比按列访问快得多:
#define N 1000 int arr[N][N]; // 快:按行访问,内存连续 for (int i = 0; i < N; i++) { for (int j = 0; j < N; j++) { arr[i][j] = 0; } } // 慢:按列访问,缓存命中率低 for (int j = 0; j < N; j++) { for (int i = 0; i < N; i++) { arr[i][j] = 0; } }这两段代码逻辑上完全等价,但在N=1000时,性能差距可能达到几倍。原因就是CPU缓存:按行访问时,内存是连续的,一次缓存加载能覆盖多个元素;按列访问时,每次跳跃N个元素,缓存基本命中不了。
这个知识点在写图像处理、矩阵运算这类代码时特别重要。
6. 循环在实际项目中的典型应用
光讲语法和技巧还不够,循环真正的价值体现在具体场景里。下面几个是我在实际项目中反复用到的循环模式。
6.1 字符串处理
C语言没有String类,字符串就是字符数组,处理字符串几乎离不开循环。比如字符串逆序:
void reverse_string(char *str) { int len = strlen(str); for (int i = 0; i < len / 2; i++) { char temp = str[i]; str[i] = str[len - 1 - i]; str[len - 1 - i] = temp; } }这里循环只跑到len/2,因为每次交换处理两个字符,跑一半就够了。如果跑到len,就会把已经交换过的又换回去。
再比如统计字符串中各类字符的个数:
void count_chars(const char *str, int *letters, int *digits, int *others) { *letters = *digits = *others = 0; for (int i = 0; str[i] != '\0'; i++) { if ((str[i] >= 'a' && str[i] <= 'z') || (str[i] >= 'A' && str[i] <= 'Z')) { (*letters)++; } else if (str[i] >= '0' && str[i] <= '9') { (*digits)++; } else { (*others)++; } } }注意循环条件是str[i] != '\0',这是遍历C字符串的标准写法。不要用strlen,因为strlen本身也要遍历一遍,等于跑了两遍。
6.2 链表遍历
链表是C语言里最常用的数据结构之一,遍历链表的标准写法是:
typedef struct Node { int data; struct Node *next; } Node; void print_list(Node *head) { Node *p = head; while (p != NULL) { printf("%d ", p->data); p = p->next; } }链表遍历用while比for自然,因为链表没有下标,循环的推进靠指针移动。循环条件是p != NULL,也就是"还没到链表末尾"。
在链表里删除节点时,循环要特别小心:
Node* remove_value(Node *head, int value) { Node *dummy = (Node*)malloc(sizeof(Node)); dummy->next = head; Node *p = dummy; while (p->next != NULL) { if (p->next->data == value) { Node *temp = p->next; p->next = p->next->next; free(temp); } else { p = p->next; } } Node *new_head = dummy->next; free(dummy); return new_head; }这里用了一个dummy节点(哑节点)来简化头节点的处理。循环条件是p->next != NULL而不是p != NULL,因为我们要检查的是p的下一个节点。删除节点后不移动p,因为新的p->next还没检查过。
6.3 文件读写
读取文件内容通常用循环逐行或逐字符处理:
FILE *fp = fopen("data.txt", "r"); if (fp == NULL) { perror("打开文件失败"); return; } char line[256]; while (fgets(line, sizeof(line), fp) != NULL) { // 处理每一行 printf("%s", line); } fclose(fp);fgets每次读一行,读到文件末尾返回NULL,循环自然结束。注意fgets会把换行符也读进来,如果不需要换行符,可以手动去掉:
line[strcspn(line, "\n")] = '\0';6.4 单片机里的循环
在单片机开发中,循环的写法又有不同。单片机资源有限,主循环通常是一个无限循环:
int main(void) { init(); while (1) { // 主循环 task1(); task2(); task3(); } }这个while(1)是嵌入式程序的标配,程序永远不会退出。在单片机里写循环,要特别注意:
- 循环里不要用浮点数,很多低端单片机没有硬件浮点单元,浮点运算靠软件模拟,非常慢
- 循环里不要用动态内存分配,单片机内存有限,malloc/free容易产生碎片
- 循环里不要有阻塞操作,否则会影响其他任务的响应
如果需要在主循环里做定时任务,通常用计数器:
while (1) { if (++counter >= 1000) { counter = 0; do_something(); } // 其他任务 }这种写法比delay函数好,因为delay会阻塞整个循环,而计数器方式不会。
7. 循环代码的调试与验证
循环写完了不代表就对了,尤其是复杂的嵌套循环,必须经过验证。下面几个方法是我常用的。
7.1 打印中间变量
最原始但最有效的方法,就是在循环里打印关键变量:
for (int i = 0; i < n; i++) { printf("i=%d, arr[i]=%d\n", i, arr[i]); // 调试用 // ... }通过观察输出,能快速定位是循环次数不对,还是循环体逻辑不对。调试完记得删掉这些打印,否则会影响性能。
7.2 边界值测试
循环最容易出错的地方是边界,所以测试时要专门测边界:
- 循环次数为0时,程序是否正确?
- 循环次数为1时,程序是否正确?
- 循环次数为最大值时,程序是否正确?
- 数组长度为0或1时,循环是否正确处理?
比如写一个求数组最大值的函数:
int find_max(int arr[], int n) { if (n <= 0) return -1; // 处理空数组 int max = arr[0]; for (int i = 1; i < n; i++) { if (arr[i] > max) max = arr[i]; } return max; }注意循环从i=1开始,因为max已经初始化为arr[0]了。如果从i=0开始,多比较一次,虽然结果一样,但没必要。
7.3 用断言检查循环不变量
循环不变量是循环理论里的概念,简单说就是"每次循环开始和结束时都成立的条件"。用assert检查循环不变量,能及早发现问题:
#include <assert.h> for (int i = 0; i < n; i++) { assert(i >= 0 && i < n); // 循环不变量 // ... }assert在debug版本里生效,release版本里会被优化掉,不影响性能。
7.4 性能分析
如果怀疑循环是性能瓶颈,可以用性能分析工具。Linux下用perf,Windows下用Visual Studio的性能分析器。不过对于大多数场景,手动计时就够了:
#include <time.h> clock_t start = clock(); for (int i = 0; i < 1000000; i++) { // 被测代码 } clock_t end = clock(); printf("耗时: %f秒\n", (double)(end - start) / CLOCKS_PER_SEC);这个方法的精度取决于CLOCKS_PER_SEC,一般在毫秒级。对于更精细的分析,需要用高精度计时器。
8. 从循环语句延伸出去的编程思维
循环语句表面上是语法,背后其实是一种思维方式:把重复的事情交给机器,人只负责描述规则。这种思维在编程里无处不在。
8.1 循环与递归的取舍
很多用循环能解决的问题,用递归也能解决。比如计算阶乘:
// 循环版 int factorial_iter(int n) { int result = 1; for (int i = 2; i <= n; i++) { result *= i; } return result; } // 递归版 int factorial_rec(int n) { if (n <= 1) return 1; return n * factorial_rec(n - 1); }循环版效率更高,因为不需要函数调用的开销。递归版代码更简洁,但n大了会栈溢出。一般来说,能用循环就用循环,除非问题本身是递归定义的(比如树的遍历)。
8.2 循环与迭代器
在C++里,循环被抽象成了迭代器。C语言虽然没有迭代器,但可以用函数指针模拟:
void for_each(int *arr, int n, void (*func)(int)) { for (int i = 0; i < n; i++) { func(arr[i]); } }这种"把操作作为参数传进去"的思路,就是函数式编程的雏形。理解了循环,再学高阶函数、map、filter这些概念,会容易很多。
8.3 循环与算法复杂度
循环的嵌套层数直接决定了算法的时间复杂度。一层循环是O(n),两层是O(n²),三层是O(n³)。写代码的时候,看到三层以上的嵌套循环,就要想想有没有更好的算法。
比如查找两个数组的共同元素,暴力解法是两层循环:
for (int i = 0; i < n; i++) { for (int j = 0; j < m; j++) { if (a[i] == b[j]) { // 找到共同元素 } } }复杂度是O(n×m)。如果先把一个数组排序,再用二分查找,复杂度能降到O(n log m)。如果用一个哈希表,能降到O(n+m)。循环写起来简单,但性能不一定好,这是每个C程序员都要有的意识。
循环语句是C语言里最基础也最考验功力的部分。语法半小时就能学完,但写出高效、正确、易读的循环代码,需要大量的实践和踩坑。我在实际项目里最深的体会是:写循环之前先想清楚三件事——循环几次、什么时候停、每次做什么。这三件事想明白了,代码基本不会出大问题。另外,边界条件永远是最容易出错的地方,写完循环后专门测一下边界,能省下大量调试时间。