1. 从“重复做事”说起:while循环到底解决了什么问题
1.1 没有循环,代码会被逼成什么样
如果你刚接触C语言,可能还不太理解“循环”这个概念存在的意义。我的建议是:先别急着背语法,想象一个特别朴素的场景——让你打印1到100的整数。没有循环的时候,你只能写100条printf语句,printf("1 "); printf("2 ");……写到第20行基本就想放弃了。如果需求改成打印1到1000呢?复制粘贴勉强能撑到1000行,那如果改成打印所有奇数、倒着打印、隔两个打印一次呢?每改一次需求,前面堆的代码全要重来。
循环干的事情,本质上是把“重复执行”从“代码行数堆叠”变成“一套可重复的机制”。你只需要写一份循环体,让某个变量在每轮执行时发生变化,就能用同一个模板处理不同的数据。
这个思路可以类比成流水线:工人站在传送带前,传送带每送来一个零件就处理一次,处理完接着等下一个。只要传送带一直在运转,工人就一直重复同一套动作。在C语言里,传送带的“运转条件”就是while后面的那个条件表达式。
1.2 while循环的语法和执行流程拆解
C语言里最简单的循环结构就是while,语法非常简洁:
while (条件) { // 循环体:条件为真时反复执行的代码 }它的执行流程可以分为四步:
- 计算“条件”表达式的值。C语言里没有独立的布尔类型,规则很简单:结果为0表示假,非0表示真。
- 如果条件为真,从上到下执行整个循环体。
- 循环体执行完毕后,回到第1步,重新计算条件。
- 直到某一次条件计算结果为0,直接跳出循环,继续执行while后面的代码。
有一个初学者非常容易忽略的细节:条件是在每一轮循环开始前重新计算的,而不是“一开始判断一次就够了”。这意味着如果循环体里修改了条件中涉及的变量,下一轮判断就会受影响。这既是while灵活的地方,也是死循环的根源。
1.3 循环三要素:初始化、条件、更新
我写了这么多年C语言,总结成一个很朴素的口诀,写while循环时主动维护三样东西:初始化、条件、更新。
int i = 1; // 初始化 while (i <= 10) { // 条件 printf("%d ", i); i++; // 更新 }这个例子循环10次,打印1到10。
- 初始化:循环变量从几开始,决定了整个循环的起点。
- 条件:循环什么时候继续、什么时候停,直接决定循环次数。
- 更新:每次让循环变量变化的方向和幅度,
i++、i += 2、i--都算。
如果你只写while (i <= 10),但循环体里忘了更新i,那这个循环就是死循环:i永远是1,条件永远为真。
这里多说一句,while和for在底层是等价的。很多初学者会问:为什么既学for又学while,是不是C语言故意搞两个差不多的东西?其实for (初始化; 条件; 更新)完全可以改写成while:
int i = 1; while (i <= 10) { printf("%d ", i); i++; }等价于:
for (int i = 1; i <= 10; i++) printf("%d ", i);那什么时候用for、什么时候用while?我个人的习惯是:如果循环次数靠一个计数器就能控制得很清楚,用for;如果退出条件比较复杂,或者循环次数事先无法确定,用while。这个选型问题后面我会展开细说。
2. while和do-while的区别:一个先判断,一个先执行
2.1 两者判断时机的差异
C语言一共有三种循环结构:for、while、do-while。其中do-while经常被教材叫作“直到型循环”,因为它的执行顺序和while正好相反。
do { // 循环体 } while (条件);注意这个语法坑:do-while的while后面必须带一个分号,很多人第一次写总丢。而且即便循环体只有一条语句,也建议加上花括号,不然后续扩展逻辑时很容易出错。
两者的核心区别,我用一句话概括:
- while:先判断,再执行。条件一开始就为假的话,循环体一次都不会执行。
- do-while:先执行,再判断。不管条件真假,循环体至少执行一次。
我把关键差异整理成一张表,方便你放在手边对比:
| 对比项 | while | do-while |
|---|---|---|
| 判断时机 | 循环体执行前 | 循环体执行后 |
| 条件为假时 | 一次都不执行 | 至少执行一次 |
| 语法结构 | while(条件) { } | do { } while(条件); |
| 典型场景 | 计数器循环、未知输入次数 | 菜单选择、输入校验、必须做一次再判断 |
2.2 什么时候必须用do-while
很多教材讲完do-while就扔到一边,导致不少人不知道为什么存在这个结构。我举个最常见的场景:控制台菜单程序。
假设你要做一个命令行交互菜单,用户输入1表示继续,输入0表示退出。这个流程至少要让用户输入一次,才能判断要不要退出。如果用while实现,你得这样写:
int choice = 0; scanf("%d", &choice); while (choice != 0) { // 执行菜单功能 scanf("%d", &choice); }代码能跑,但你会发现scanf出现了两次:第一行是为了“预热”循环条件,循环体尾部又读一次,为了更新条件。如果你忘了开头的预热scanf,循环体的第一次执行就可能基于一个未定义的变量。用do-while就自然多了:
int choice; do { // 执行菜单功能 printf("输入0退出,输入1继续:"); scanf("%d", &choice); } while (choice != 0);循环体先执行,然后根据用户输入决定是否继续,输入语句只需写一次。
再比如最基础的输入校验:要求用户输入一个1到9之间的数,不合法就重新输入。这个场景“至少输入一次”是天生的:
int num; do { printf("请输入一个1-9之间的数:"); scanf("%d", &num); } while (num < 1 || num > 9);这类逻辑如果用while写,要么提前读一次,要么用一个flag变量把条件搞复杂,都不如do-while清爽。记住一句话:当“至少执行一次”本身就是需求的一部分时,优先选do-while。
2.3 同样的逻辑,两段代码跑出来的差别
教学的时候,我特别喜欢用同一个例子来展示两者差异:
int n = 0; while (n > 0) { printf("while执行了\n"); n--; } n = 0; do { printf("do-while执行了\n"); n--; } while (n > 0);n一开始就是0,n > 0为假,所以第一段代码里的printf永远不会执行。但do-while不管条件真假,先把循环体跑一遍,再判断n > 0是否成立,所以第二段代码至少会打印一次“do-while执行了”。
这个例子看着简单,但考试和面试真的很爱考。很多经典C语言课程里专门花时间讲这个对比,刷题做错的人,往往不是因为不会循环,而是没搞清楚“先执行”这三个字的含义。
3. 牛刀小试:九九乘法表、字符串逆序、鞍点问题三个经典场景
3.1 九九乘法表:嵌套while的必修课
九九乘法表几乎是每个学C语言的人都会写一遍的练习。用while实现嵌套循环,是理解“外层循环控制行、内层循环控制列”的最佳入口:
#include <stdio.h> int main() { int i = 1; while (i <= 9) { int j = 1; while (j <= i) { printf("%d*%d=%-2d ", j, i, i * j); j++; } printf("\n"); i++; } return 0; }输出结果:
1*1=1 1*2=2 2*2=4 1*3=3 2*3=6 3*3=9 1*4=4 2*4=8 3*4=12 4*4=16 ...这里有几个关键点,值得写进你的笔记:
- 内层循环变量j必须在每次外层循环开始前重新初始化为1。很多初学者把j定义在外层while外面,结果第二行开始j还在延续上一轮的值,输出完全乱掉。这个错误我在带人时见过无数次。
- 内层循环条件写成
j <= i,所以第i行刚好有i个式子,形成左下三角形。 - 用
%-2d中的负号表示左对齐,让每个式子占两列对齐,输出更整齐。
每次循环开始前,j的值从1开始,内层循环执行i次后结束,然后外层循环执行i++,进入下一行。这个过程捋顺了,嵌套循环的“外层行、内层列”的模型就建立起来了。
3.2 字符串逆序:while配合数组下标的典型用法
PTA上有一道非常经典的“字符串逆序”题,要求把输入的字符串倒过来输出。这个场景里while承担了两个职责:遍历字符数组数出长度,再用“双指针”交换字符。
#include <stdio.h> #include <string.h> int main() { char s[100]; fgets(s, sizeof(s), stdin); // 用while统计实际长度,去掉结尾换行符 int len = 0; while (s[len] != '\0' && s[len] != '\n') { len++; } s[len] = '\0'; int left = 0, right = len - 1; while (left < right) { char tmp = s[left]; s[left] = s[right]; s[right] = tmp; left++; right--; } printf("%s\n", s); return 0; }代码里的两个while各有侧重。第一个while遍历字符数组,通过判断\0找到字符串的结束位置,这是在模拟strlen的底层逻辑;第二个while用两个下标从两端往中间走,每次交换一对字符,直到left和right相遇。
大部分教材会直接用strlen,但我特意写成手动while,是想让你体会“遍历数组”的本质:C语言的数组没有自带长度信息,你必须自己用循环去读数据、判断结束条件。字符串的结束条件是\0,数组的结束条件是你自己定义的边界。数组下标从0开始,所以最后一个有效元素的下标是len-1,这就是right = len - 1的来源。
提示:实际写代码时推荐用fgets代替gets。gets不检查缓冲区大小,容易引发缓冲区溢出,在很多评测环境里也不安全。
3.3 鞍点问题:5x5矩阵中while与标志位的配合
“鞍点”是C语言基础课程里的经典题,刷题时经常碰到。原题大概是这样:输入一个5×5的矩阵,找出它的鞍点。鞍点的定义是:该元素在其所在行上是最大值,同时在其所在列上是最小值。
为什么我特别推荐用这个题来练while?因为它把二维数组遍历、嵌套循环、标志位、break这几个知识点全部串在了一起,做完一遍,你对循环控制流的理解会上一个台阶。
#include <stdio.h> #include <limits.h> int main() { int a[5][5]; int i, j; printf("请输入5x5矩阵:\n"); for (i = 0; i < 5; i++) for (j = 0; j < 5; j++) scanf("%d", &a[i][j]); int found = 0; // 全局标志位:是否找到鞍点 i = 0; while (i < 5) { // 1. 找出第i行的最大值 int max_val = INT_MIN; int max_col = 0; j = 0; while (j < 5) { if (a[i][j] > max_val) { max_val = a[i][j]; max_col = j; } j++; } // 2. 检查该元素是否也是第max_col列上的最小值 int is_min = 1; int k = 0; while (k < 5) { if (a[k][max_col] < max_val) { is_min = 0; break; } k++; } if (is_min) { printf("鞍点: a[%d][%d] = %d\n", i, max_col, max_val); found = 1; break; } i++; } if (!found) printf("不存在鞍点\n"); return 0; }几个细节值得细说:
- 我用了limits.h里的INT_MIN来初始化max_val,而不是随便写一个0。因为矩阵元素可能全是负数,如果你拿0当初始最大值,整个逻辑就错了。这是C语言题目里非常典型的“隐藏扣分点”,考的就是你对边界条件的敏感度。
- 第二步用is_min标志位记录“该列中是否所有元素都不小于这个最大值”。一旦发现有元素比它小,立刻break,没必要继续比较后面的行。
- found是整个程序的状态变量,贯穿两层判断。只要找到一个鞍点就打印并退出。如果题目要求输出全部鞍点,就要把break去掉并继续遍历;如果严格要求“行最大值唯一且列最小值唯一”,还要考虑重复最大值的情况,那就是另一个更复杂的变体了。做题前一定先看清题意。
很多人第一次做鞍点题都会卡在同一个地方:行最大值好找,但列最小值还要回到那一列再遍历一遍,这个“回马枪”的思路不容易一下转过来。这正是二维数组循环遍历的难点:按行扫描一遍,再按列扫描一遍,相当于用两种顺序访问同一个数组。多写几遍这个题,你对二维数组下标的敏感度会明显提升。
4. while循环的常见错误与调试实战
4.1 死循环现场:条件永远成立
新手最容易遇到的问题是:程序运行后窗口完全卡住,CPU风扇开始狂转,最后只能强制关掉。不用怀疑,八成是死循环。
常见的死循环诱因,我按出现频率排个序:
- 忘记在循环体里更新循环变量。
- 循环变量更新写在某个分支内部,导致某些情况下永远不更新。
- 不小心用if代替while,代码根本没有“回头重新判断”的效果。
- 赋值和相等比较写混:
while (i = 1)而不是while (i == 1)。
第4种我要单独强调,因为它的隐蔽性很强。在C语言里,while (i = 1)是合法代码,它把1赋给i,然后判断i是否为真,结果当然为真,于是死循环。编译器一般会给出警告,但很多初学者直接忽略警告。有个防手滑的小技巧:把常量写在左边,写成while (1 == i)。这样一旦你把==误写成=,编译器会直接报错,因为1不能作为赋值表达式的左值,错误在编译阶段就被拦下来了。
4.2 差一错误:循环边界怎么确认
差一错误指的是循环次数比预期多一次或少一次。比如:
// 想打印 1 到 10 int i = 1; while (i < 10) { // 错了,少打印10 printf("%d ", i); i++; }i < 10会在i等于10的时候停止,所以只打印1到9。正确写法是i <= 10。
判断循环次数有个很好用的小公式:
- 条件为
i < N时,i从0开始,循环执行N次。 - 条件为
i <= N时,i从0开始,循环执行N+1次。 - 条件为
i < N时,i从M开始,循环执行N-M次。
写数组遍历的代码时,我几乎永远用i < 长度这种形式,因为数组下标从0开始,最后一个有效下标就是长度减一。这样写你不用额外背着“+1、-1”的换算,代码也更安全。
4.3 用gdb调试while循环:一次完整的过程
排查死循环和差一错误,最高效的手段不是盯着代码看,而是用调试器。很多人学C语言时习惯全程用printf打印日志,代码简单时还行,一旦循环嵌套复杂,printf还要改来改去,非常痛苦。这里我用gdb演示一遍实际调试while循环的过程。
假设你有下面这个程序,原意是打印10、8、6、4、2,但它似乎永远停不下来:
#include <stdio.h> int main() { int i = 10; while (i > 0) { printf("%d ", i); // 这里忘记写 i -= 2; } return 0; }编译时加-g参数保留调试信息,然后启动gdb:
gcc -g -o test test.c gdb ./test在循环体结束的位置设断点,运行,单步执行:
(gdb) break 8 (gdb) run (gdb) next (gdb) print i你会发现每一次next过去,i的值都是10,printf反复输出10。这时候基本可以判断:i没有被更新。
你还可以用watch命令设置观察点,只要i的值发生变化,gdb就会自动暂停:
(gdb) watch i (gdb) continue如果i始终不变,continue之后程序会继续狂奔,不会因为watch而停下,你马上就能意识到“更新语句缺失”。这种调试方式比肉眼一遍遍扫代码可靠得多。
4.4 调试的优先级:先怀疑条件,再审循环体
结合我带人做实验的经验,while循环出问题时,排查顺序建议这样来:
- 先看条件合不合理:确定循环到底会执行0次、N次,还是无限次。
- 再看循环体里有没有更新和控制变量:用gdb打印关键变量,对比每轮变化。
- 最后检查有没有读写越界:数组越界往往不会立刻报错,但会在某次循环里把相邻变量的值覆盖掉,造成各种“灵异现象”,比如变量突然变成奇怪的值。
记住一个原则:程序不会无缘无故卡死,卡死的地方往往是条件判断和变量更新断了链。
5. 循环里的“隐藏操作”:break、continue、标志位与while(scanf)
5.1 break和continue的适用边界
break和continue不是循环必须的,但用好了能让逻辑清晰很多:
- break:立即终止当前整个循环,跳出到循环体后面继续执行。
- continue:跳过本次循环剩余的部分,直接进入下一轮条件判断。
我习惯用“破门而出”和“跳过这一下”来类比。遇到某个特殊情况需要立刻结束,用break;遇到某个不需要处理的数据,想跳到下一个,用continue。
注意一点:多层嵌套循环里的break只跳出当前最近的一层循环,而不是一次性退出所有循环。很多人想从两层循环内部直接跳出,但break做不到。这种情况一般靠标志位解决,后面马上讲。
5.2 标志位:让循环退出条件清晰可读
有些循环内部有多个退出条件,如果全堆在while(条件)里,条件表达式会变得又长又难懂。更常见的做法是用标志位:
int running = 1; while (running) { // 处理命令 if (接到退出指令) running = 0; if (发生致命错误) { running = 0; error_code = 1; } }这里的running是标志位,只用来表示“循环是否该继续”。它比把各种退出判断直接写进条件表达式清晰得多。网络编程里的消息循环、游戏主循环,基本都是这种写法:while(1)表示程序一直在跑,真正退出时通过break或标志位跳出。
5.3 while(scanf(...)) 到底是怎么回事
刷PTA或者处理不确定行数的输入时,你经常会看到这样的写法:
int n; while (scanf("%d", &n) != EOF) { // 处理每组数据 }很多初学者第一次看到直接懵了:scanf不是用来输入的吗?怎么还能当作while的条件?这里的关键是,scanf有返回值。它会返回“成功读取的变量个数”:读到了合法整数,返回1;读取失败,返回0;遇到输入结束或文件末尾,返回EOF,也就是-1。所以scanf放在while条件里,本质上是“每轮读取一次输入,读成功就处理,读到文件末尾就退出”的简写。
这个模式在读取文件时更常见:
FILE *fp = fopen("data.txt", "r"); int n; while (fscanf(fp, "%d", &n) == 1) { printf("%d\n", n); }fscanf成功读到一个整数就返回1,循环继续处理,直到读取失败或文件结束。它相当于一个隐式的“先读取、再判断、循环读取”结构,在数据行数不确定时特别实用。理解了这一点,你再看网上大量while(scanf(...))的代码就不会发怵了。
5.4 循环代码风格:写给未来自己看的代码
循环是程序里最容易越写越乱的地方。我见过有人把几十行逻辑全塞进一个循环体,缩进乱成一片,最后连自己都看不懂。结合我的实际经验,给出几条很朴素的建议:
- 循环体不要过深,嵌套超过两层就考虑抽成函数,让每一层只干一件事。
- 条件表达式里不要写带副作用的复杂运算,要一眼能看出退出逻辑。
- 循环变量命名要有含义。i、j、k做普通计数器没问题,但
row、col、index这种更清晰的命名,在复杂场景下能帮你省下大量时间。 - 复杂的退出条件,提前用变量或标志位表示,别全部堆在while后面。
代码首先是给人看的,其次才是给机器跑的。这句话放到循环上尤其适用。
6. 别小看这三件事:性能、选型和while的“手感”
6.1 循环里频繁调用的函数,可能拖慢整段程序
网上有个很经典的问题:为什么strlen、strcat这类函数用起来很方便,但有些老手建议在循环里少调用?因为这类函数计算的是“当下”的长度,如果字符串长度在循环过程中保持不变,你却每轮都调用strlen,循环的时间复杂度就从O(N)报废成了O(N²)。字符串越长,差距越明显。
类似这种写法:
int i = 0; while (i < strlen(str)) { // 处理str i++; }如果str在循环体里长度不变,完全可以把长度先存到一个变量里:
int len = strlen(str); int i = 0; while (i < len) { // 处理str i++; }这种优化不需要高深的理论,就是一句话:把循环中不变的计算移出循环。写while时多问自己一句:这个表达式真的需要每轮重算吗?
6.2 什么时候选while而不是for
选for还是while,表面上只是风格问题,但写多了会发现它直接影响代码的可读性。
标准计数器循环优先用for,因为初始化、条件、更新都集中在一行,看代码的人不用去循环体里找i++。下面这几种情况则更适合用while:
- 循环次数取决于运行时状态,事先根本不知道要循环多少次。
- 退出条件在循环体中间才产生,或者要依靠数据内容决定是否继续。
- 要一直读输入,直到EOF或某个特定值出现。
- 写游戏主循环、网络消息循环这类“一直运行直到某事件触发”的程序。
如果你发现自己在for的小括号里写了一长串复杂表达式,那多半应该改成while加标志位。这两种循环没有谁比谁高级,只有谁更适合当前场景。
6.3 我写while循环的几条个人习惯
最后分享一点实在的个人经验,不算高深理论,但确实帮我少踩了很多坑:
- 每个while循环动笔之前,先在注释里写清楚“什么时候退出”。一行注释能避免半小时的死循环排查。
- 循环变量的初始化尽量靠近循环,不要在几百行之前定义,然后在循环里突然使用,这样容易把状态绕晕。
- 用
while(1)的时候,一定保证循环体内存在break或标志位修改,并且你能明确说出退出路径。 - 刷题考试时,先确认题目要求输出所有结果还是第一个结果,这直接决定了你什么时候break。
- 嵌套循环里提前用注释标明“外层行、内层列”这类关系,回头调试真的有用。
我不太喜欢把文章写成教科书式的说教,但如果你让我用一句话总结对while循环的理解,我会说:循环不是语法,而是一种思维方式——你要学会把一个重复的过程抽象成“条件驱动”的形式,想清楚从哪里进入、什么时候退出、每轮如何变化。这三个问题想明白了,不管是while、do-while还是for,其实都是同一件事的三种写法。而这三种写法,也是大多数编程语言共通的底层逻辑——你学的是C语言,收获的却是一套迁移到任何语言都管用的思考方式。