循环不严谨点说是那个让新手又爱又恨的“循环与数组”它在整个C语言学习路径里的地位很特殊。你几乎找不到任何一个稍微有点实际用途的C程序能完全绕开这两个东西。只要你想让程序处理一批数据、反复执行某段逻辑你就必然会跟for、while、do-while这三个货打交道也必然会跟那一排排方括号[]较劲。很多初学者觉得这里难不是因为语法本身记不住而是没搞明白这两者组合起来到底在解决什么问题。简单说循环解决的是“怎么重复做”的问题数组解决的是“把东西放在哪”的问题。一个管流程一个管存储两者一结合你写出来的代码才真正开始有了点“程序”的样子而不是从前到后一行行往下死撑的玩具脚本。这篇文章我不打算讲干巴巴的语法手册我会直接从实际写代码的视角出发把循环怎么写才不容易出错、数组怎么用才不会越界、组合起来怎么处理真实的数据场景一点点拆开来说。不管你是刚摸到C语言门口的新手还是已经写过一阵子但老觉得代码别扭的进阶学习者这篇内容应该都能帮你把线头捋顺。1. 循环选型别只看语法要看场景和可读性很多教材喜欢把三种循环for、while、do-while摆在一起比较然后告诉你它们的区别在于“判断条件的位置”。这话没错但太抽象了。我见过的实际操作中选错循环类型导致的代码混乱远比选错逻辑本身更常见。选型的核心判断标准就两个循环次数是否明确以及是否至少需要执行一次。1.1 计数型循环无脑选for别犟如果你拿到一个需求第一反应是“这大概要循环N次”比如从1加到100、遍历数组的每一个元素、打印N行星号那就直接选for。for的好处在于它把初始化、条件判断、步进更新这三件事全塞在了循环体的第一行里形成了一个非常紧凑的“三件套”。这样做对阅读者极其友好我扫一眼开头就知道这个循环从哪开始、到哪结束、每次怎么变。你看看这个例子int sum 0; for (int i 1; i 100; i) { sum i; }这个循环哪怕是不懂C语言的人看着开头大概也能猜出意思从1开始到100为止一个一个往上加。反观如果我用while来写同样的逻辑int sum 0; int i 1; while (i 100) { sum i; i; }注意i 1在循环外面i在循环体末尾中间隔着一大坨代码。如果这段循环体再长一点你有很大的概率会在读代码的时候忘记那个步进更新在哪儿甚至会出现循环体里有continue语句时把i给跳过去导致死循环。这个坑我后面细说。所以我的经验法则非常简单凡是能用for写清楚的循环就不要用while。这不是什么强制规范而是为了你自己的代码少出bug也为了让下一个看你代码的人少骂你一句。1.2 条件型循环选while注意更新位置那while用在哪儿当循环次数不确定循环是否结束完全取决于某个条件是否成立时示意环境用的就是while。典型的场景就是输入验证。比如你想让用户输入一个正整数如果输错了就重新输入直到正确为止int n 0; printf(请输入一个正整数); scanf(%d, n); while (n 0) { printf(输入无效请重新输入一个正整数); scanf(%d, n); }你没法提前算出来用户会输错几次所以不适合用for。用while就顺理成章条件成立就继续要输入不成立就往下走。这里必须提醒一个初学者极其容易踩的坑循环体内改变条件变量的语句别忘写也别忘了位置。上面的例子中scanf就是改变条件变量的操作它必须在循环体里面。如果你写在循环外面那程序要么死循环要么逻辑错乱。写while的时候先在脑子里跑一遍看看那个条件变量到底会不会在循环中变化。1.3 do-while的人情世故先干再说do-while的使用频率最低但它有一个不可替代的特性循环体至少执行一次。这个特性完美契合“菜单选择”这类场景。比如你写一个控制台小工具用户选择1做什么、2做什么、3退出。你总不能一上来就先问“你想干嘛”然后如果用户没回答就卡住吧。do-while就写得很自然int choice 0; do { printf(菜单1. 增加 2. 删除 3. 退出\n); printf(请选择); scanf(%d, choice); switch (choice) { case 1: /* 执行增加逻辑 */ break; case 2: /* 执行删除逻辑 */ break; case 3: printf(再见\n); break; default: printf(无效选项请重新选择。\n); } } while (choice ! 3);注意这里如果用户一上来就输入3菜单也会先显示一次然后才退出。这在逻辑上是合理的因为你总得先给用户看一次菜单吧。do-while的语义就是“先执行一轮事情再判断是否继续”非常契合这种交互流程。我见过有人非要用while(true)加手动break来模拟do-while也不是不行但读起来总得多绕一个弯。既然C语言给你提供了专门的关键字用上它代码会更直白。2. 数组本质是一块连续的内存别被方括号迷惑聊完循环咱们看数组。很多教材把数组定义为“同类型数据的集合”这话没错但你光知道这个定义写代码的时候还是会犯浑。你得先建立内存视角数组就是你在内存里一次性申请的一整块连续空间里面的每个格子的大小是一样的。这块空间的起始地址就是数组名。2.1 下标从0开始不是故意折腾你是地址计算方便这是初学者问得最多的一个问题“为什么数组下标从0开始而不是从1”用C语言的视角回答是因为数组名本身就是一个指向第一个元素的指针。如果下标从1开始那么访问第1个元素就需要在起始地址的基础上“偏移”一个单位再取指针多一道运算而下标从0开始直接拿起始地址就能定位到第一个元素偏移量正好等于下标乘以元素大小计算起来干净利落。你看这就是底层逻辑的差别。我写代码的时候脑子里其实是这么想的int arr[5]; // 申请了5个int格子的连续空间 // arr 指向第0号格子 // arr 1 指向第1号格子 // 其实 arr[i] 就是 *(arr i)所以arr[i]的i本质上是“偏移量”而不是“第几个”的自然语言序号。想通这一点你就能理解为什么最后一个元素是arr[n-1]以及为什么遍历数组的标准写法是for (int i 0; i n; i) { // 处理 arr[i] }这里i n而不是i n因为下标范围是0到n-1一旦i等于n就越界了。这句“边界条件”我后面会反复强调因为它真的是新手崩溃的重灾区。2.2 初始化方式决定数组的长度别混着写C语言里面数组初始化有两种常见方式一种是直接指定长度一种是让编译器根据初始化列表自动推断长度。int a[10]; // 只声明不初始化里面是垃圾值 int b[10] {0}; // 明确写了长度10全部初始化为0 int c[] {1, 2, 3, 4, 5}; // 不写长度编译器自动数出来是5第三种写法在你看别人代码的时候很常见简洁且不易写错大小。但要小心一旦你写成了int c[];不初始化也不给长度那就是编译错误。另外明确声明了长度但初始化列表不足时剩余元素会自动补0int d[5] {1, 2, 3}; // d[0]1, d[1]2, d[2]3, d[3]0, d[4]0这个“自动补0”的特性在实际工程里经常被用来把整个数组清零也就是int arr[100] {0};这样写。很多新人以为只有arr[0]是0其实后面99个也全是0。知道这个小知识能帮你省掉一个for循环的工作量。2.3 一维数组学会之后二维数组就是套娃二维数组int matrix[3][4]听起来好像很高级实际在内存里它依然是连续的一维空间。你可以把它理解成“3个元素每个元素又是一个长度为4的int数组”。所以matrix[1][2]访问的是什么是第1行第2列的那个元素。遍历它你自然就需要两重循环int matrix[3][4] {0}; for (int i 0; i 3; i) { for (int j 0; j 4; j) { matrix[i][j] i * 4 j; // 随便赋个值 } }这里有个新手很容易糊涂的点哪个循环是外层哪个是内层我的经验是外层循环管“行”内层循环管“列”。你可以把内层循环看成“在一条街上挨家挨户敲门”外层循环看成“从一条街走到另一条街”。写代码的人需要时刻清楚自己的行列逻辑不然数据就填错位置了。二维数组在现实里最常见的例子就是成绩表行是学生列是科目。你要统计某个学生的总分那就是固定行变量遍历列你要统计某门课的平均分那就是固定列变量遍历行。3. 循环与数组的合体从遍历到算法一步一个脚印讲完了两个基础大部分人最期待的重头戏来了。数组配合循环能做的事其实比你想的多得多而且这些能力是以后写算法、做数据处理的最底盘。3.1 遍历是基本功但你得知道遍历的核心是下标数组遍历看起来是件极其简单的事就是挨个访问每个元素。但很多人写着写着就迷糊了问题通常出在没有把“下标”当成一个真正的变量来对待。你可以在循环体里通过下标进行判断、读取、修改关键是下标决定你正在处理哪个元素。举个例子我现在想统计数组里面有多少个正数int data[] {3, -1, 5, 0, -7, 2}; int count 0; int n sizeof(data) / sizeof(data[0]); // 计算数组元素个数的小技巧 for (int i 0; i n; i) { if (data[i] 0) { count; } }注意这里我用了一个非常实用的小技巧sizeof(data) / sizeof(data[0])。sizeof(data)是整个数组占用的总字节数sizeof(data[0])是一个元素占用的字节数两者相除正好是元素个数。这样写的好处是即使数组以后改长了循环的边界条件也自动跟着变不用手改数字。3.2 求最值和查找是循环控制思维的“试金石”求数组最大值这个需求看起来简单到不像话但它考察的是一个非常核心的编程思维假设一个最佳候选然后通过逐一比较来修正它。我实战里经常看到新手这么干先默认最大值是0然后遍历比较。这在小范围数据里可能碰巧对但如果数组里全是负数你这辈子都找不到真正的最大值。正确的是先假设第一个元素就是最大值然后从第二个元素开始比int arr[] {-12, -3, -5, -8, -1}; int n sizeof(arr) / sizeof(arr[0]); int max arr[0]; // 重要不能假设是0 for (int i 1; i n; i) { if (arr[i] max) { max arr[i]; } }同理查找某个值是否存在我的习惯是使用一个flag来记录状态而不是在循环里直接打印结果。因为你可能得在确认“找到了”之后还要做别的处理。比如int target 5; int found 0; // 0表示未找到1表示已找到 for (int i 0; i n; i) { if (arr[i] target) { found 1; printf(找到了下标是%d\n, i); break; // 找到了就不需要继续循环 } } if (!found) { printf(没找到\n); }这里break的使用也非常关键。它表示“一旦达成目的立即跳出循环”避免无谓的后续循环消耗。程序写久了你会发现高效代码往往就是及时止损的代码。3.3 冒泡排序教科书式“循环套数组”的完整串联每次聊到数组排序我都会不厌其烦地建议大家亲手把冒泡排序写几遍。不是因为冒泡排序效率高恰恰相反它的时间复杂度是O(n²)在数据量大的时候效率很低。但它的教学价值非常高——它完美串联了双层循环、数组比较、交换元素、边界控制这四大基本功。冒泡排序的思想很形象每次比较相邻的两个元素如果顺序不对就交换每走完一趟最大的那个数就会像气泡一样浮到数组末尾。int a[] {64, 34, 25, 12, 22, 11, 90}; int n sizeof(a) / sizeof(a[0]); for (int i 0; i n - 1; i) { // 外层循环跑几趟 for (int j 0; j n - 1 - i; j) { // 内层循环每一趟比较到哪 if (a[j] a[j 1]) { // 相邻元素比较 int temp a[j]; a[j] a[j 1]; a[j 1] temp; } } }这里最需要解释清楚的是内层循环的边界条件j n - 1 - i。第一趟i0时需要比较n-1次因为每两个相邻元素都要比一次。第二趟i1时最大的数已经在最后了不需要再跟它比所以比较次数减1。以此类推每跑一趟末尾就多一个已经就位的元素内层循环的比较范围就会缩小。如果你把j n - 1 - i错写成j n - 1程序不会崩溃但会出现无意义的重复比较效率变低在某些特殊数据下甚至会排序错误。冒泡排序写完以后强烈建议你在纸上手动走一遍流程。拿一个5元素的数组把每一轮比较结果写下来。这个过程虽然枯燥但对“循环控制变量随轮数变化”的理解会有一個质的飞跃。3.4 二维数组的实际应用图像像素与成绩统计模型二维数组如果只停留在书上的“矩阵打印”你很难真正感受到它的必要性。我一般喜欢拿灰度图像来举例子一张黑白照片本质上就是一个二维数组数组的每个元素保存一个0到255之间像素值。你处理图像本质上就是在遍历这个二维数组做各种花式操作。假设你要做最简单的图片反色效果逻辑就是把每个像素值反转一下// 假设 img 是一个 height 行 width 列的二维数组 for (int i 0; i height; i) { for (int j 0; j width; j) { img[i][j] 255 - img[i][j]; } }这一行代码就实现了“每个格子取反”。是不是一下子就看出循环加数组在现实中的威力了同理做模糊处理、边缘检测、缩放底层都离不开对二维数组的逐像素遍历。另一个更贴近学生生活的实际场景是成绩统计。假设你有一个score[30][5]的数组30个学生每人5科成绩。要输出每个学生的总分写出来的代码就是跨列求和for (int i 0; i 30; i) { int sum 0; for (int j 0; j 5; j) { sum score[i][j]; } printf(第%d个学生的总分是%d\n, i 1, sum); }注意这里sum 0的位置极其关键。它必须放在外层循环的里面、内层循环的前面。如果放在最外面总分就会跨学生累计最后全部乱掉。这个细节每年都有许多人在上面栽跟头你务必要亲手试一次。4. 踩坑实录那些“看着没问题但运行结果不对劲”的瞬间我在实际调试中见过太多的新手崩溃现场大部分问题不是算法不懂而是语法细节或者边界条件没控制好。这里给你整理一份我经常用来“劝退”学生的避坑清单每一条都是我亲眼见过、也亲手修过的真实案例。4.1 问题速查与应急处理症状常见原因处理建议程序卡死一直不出来死循环通常是while条件永远为真或者for循环步进写错了用printf打印循环变量看它停在哪个值编译不报错但运行时候数组的值乱掉数组越界写入了破坏了相邻内存检查所有下标是否严格小于n尤其注意有没有多加循环只执行了一次或零次边界条件写反或初始化赋值放错位置在纸上手动模拟一轮循环scanf后循环跳不过去输入缓冲区残留换行符在scanf格式串里加空格或者清空缓冲区结果差了一个固定值下标从1开始了导致元素访问错位统一使用0起始的下标除非有明确偏移需求4.2 越界C语言最“宽容”的陷阱在很多高级语言里数组越界会直接抛异常。但在C语言里越界访问不一定会立刻报错它只是去读或者写了一块你不该碰的内存程序可能继续运行看起来毫无异常直到某个时刻莫名崩溃或者出现一堆乱七八糟的值。经典的越界场景是这样的int arr[5] {1, 2, 3, 4, 5}; for (int i 0; i 5; i) { // 注意是 5不是 5 printf(%d\n, arr[i]); }循环会访问arr[5]而arr[5]根本不存在。你会打印出一个莫名其妙的数字且如果随后有写出操作那更是灾难的开始。这种bug非常难查因为它不是“必现”的而是和环境内存布局相关。我的建议非常直接遍历数组时循环条件一律使用i n这个形态。如果真的需要访问最后一个元素那也是arr[n-1]绝不是arr[n]。你在提交代码之前把所有挨个检查一遍把n-1和n的关系在脑子里过一遍能拦下一大半的越界bug。4.3 循环里continue的坑小心步进被吃掉continue在循环里的作用是“跳过本次循环进入下一次迭代”。这话说起来容易实际操作中它配合while会出现一个很隐蔽的问题。看这段代码int i 0; while (i 10) { if (i 5) { continue; // 想跳过 i5 的情况 } printf(%d\n, i); i; }这个程序会在i5的时候直接跳过后面的printf和i然后回到循环开头继续判断。此时i还是5又会进入if又continue……恭喜你死循环了。而如果你用的是for循环for (int i 0; i 10; i) { if (i 5) { continue; } printf(%d\n, i); }continue跳过的只是本次循环体i是写在for头部的它照常执行。所以不会死循环。这也是为什么我在前面说“能用for就不用while”的又一个理由。如果你的while循环里打算使用continue那你必须把i写在continue之前或者干脆避免这种写法。4.4 scanf和循环的组合操作细节处理控制台输入时scanf是很多初学者的噩梦。一个经典问题是用户输入了一个非数字字符比如字母a然后你的scanf(%d, n)读取失败那个a会一直留在输入缓冲区里导致循环反复读取同一个失败的值最后死循环。这种情况下我推荐你养成一个习惯在scanf的格式串里加一个空格比如scanf( %d, n)这个空格会“吃掉”缓冲区里的空白字符包括换行符能有效减少一些常见的残留问题。如果输入还是不干净更稳妥的做法是用getchar()手动清空缓冲区到换行符为止。这个方法虽然笨但确实管用尤其是循环读入多组数据的场景。5. 从教材代码到实战手感三个能让思维升级的小项目单讲语法和细节你已经懂了七七八八但真正把循环和数组变成自己的肌肉记忆还得靠几个小项目练手。这三个项目你可以按顺序做都不长但每个都对应一种非常典型的“循环数组”实战模型。5.1 数字反转与回文判断练下标对称思维给定一个数组判断它是不是回文数第一位和最后一位相同第二位和倒数第二位相同……。这个练习能逼着你习惯“从两端向中间”的遍历方式int isPalindrome(int arr[], int n) { for (int i 0; i n / 2; i) { if (arr[i] ! arr[n - 1 - i]) { return 0; } } return 1; }注意那段arr[n - 1 - i]它是由“对称下标”推导出来的第i个位置对应的对称位置是总长度减1再减i。你把这行写明白了后面处理很多对称结构比如矩阵转置、镜像处理都会很顺手。5.2 约瑟夫环问题练循环与数组动态缩放的思维约瑟夫环问题可以算得上是“循环数组”的经典代表作。有N个人围成一圈从某个人开始报数报到第M个人就出列然后从下一个人开始重新报数问剩下的人是谁。这个问题的关键是用数组模拟“人还在不在”的状态int people[100] {0}; // 0表示在圈内1表示已出列 int n 10, m 3; int count n; // 圈内剩余人数 int index 0; // 当前报数的人的编号 while (count 1) { // 跳过已出列的人 for (int step 0; step m; ) { if (people[index] 0) { step; if (step m) { people[index] 1; // 第m个人出列 count--; break; } } index (index 1) % n; // 循环转移的关键 } } printf(最后剩下的人是%d号\n, index 1);这里面最精华的是index (index 1) % n;这一行它用取模运算实现了一个循环队列的“头尾相接”保证数组下标不会越界。理解这行代码你就理解了为什么%操作符在环形结构里这么常用。5.3 数据去重练“标记数组”和“哈希思想”的雏形给你一个数组里面可能有重复元素要求输出不重复的数值。最朴素的双层循环会互相嵌套每个元素都和前面所有元素比一次这也是个小优化空间。但更符合“循环数组”思维的是“标记数组”技巧int arr[] {1, 2, 2, 3, 4, 1, 5}; int seen[100] {0}; // 用值作为下标检测是否出现过 int n sizeof(arr) / sizeof(arr[0]); for (int i 0; i n; i) { if (seen[arr[i]] 0) { printf(%d , arr[i]); seen[arr[i]] 1; } }这段代码只用了一层循环本质上是在用“值”直接当“下标”从而快速判断一个数是否出现过。这个思想就是“哈希表”的雏形。虽然这里因为下标限制只能处理非负数和小范围数据但理解这个思路对后续学更高级数据结构相当有帮助。这三段练习做完你对“循环控制下标下标控制数据”这句话应该就有非常深刻的体感了。6. 调试技巧与代码习惯把问题扼杀在萌芽期最后一部分我特别想聊聊调试。因为光是写完一个循环不代表它真的能跑出正确结果。实际工作中很少能一遍写对更多的时间其实花在“找为什么不对”上。6.1 打桩输出最老派也最实用的调试大法很多新人遇到程序结果不对第一反应是茫然地看了半天代码然后改几个变量碰运气看能不能改对。我的建议是拿不准的时候直接在关键位置加一行printf看看中间值符不符合预期。这招叫“打桩输出”。比如你写了一个双层循环不确定内外层变量变化是否符合预期就打印for (int i 0; i n; i) { for (int j 0; j m; j) { printf(i%d, j%d\n, i, j); // 业务逻辑 } }这行输出可以帮助你确认循环遍历的顺序、边界和步长是否如你所想。如果发现i的变化和你预期的不一致那问题十有八九出在步进或条件上直接定位不需要瞎猜。打桩输出验证完逻辑后再把这行printf删掉即可。6.2 边界值测试写循环时必须默念的紧箍咒我每次写完循环都会习惯性地用几个边界值在心里跑一遍循环最开始的第一次循环的最后一次以及循环变量等于边界附近的值。这套方法虽然简单但能避免绝大多数逻辑错误。比如遍历arr[n]的循环第一次i0访问arr[0]没问题。最后一次in-1访问arr[n-1]没问题。越界点in访问arr[n]出错。以这三个点作为检查基准基本能覆盖90%以上的边界bug。你要是能把这个习惯保持下去以后写复杂算法的时候会感谢自己当年的谨慎。6.3 善用编码器插件的静态警告但别依赖它现在的集成开发环境IDE大多都有静态检查功能会给出一些警告提示比如“变量可能未初始化”“数组下标可能越界”。这些警告不是摆设它们背后都有常见的失败案例在支撑。看到警告别直接忽略点进去看看它到底在提示什么。但也不要完全依赖IDE的检查。C语言的指针和数组操作太灵活静态分析工具只能捕获一部分确定性的问题。真正靠得住的是你自己对内存和边界的理解外加上面说的调试技巧。我个人在实际操作中的体会是循环和数组这两块知识就像是学做饭时的“切菜”和“备料”环节。切菜刀法再花哨料备得不对菜就全毁了料备得再好刀法不行效率一样低得让人崩溃。单独看循环语法枯燥无味单独看数组定义云里雾里。但把它们放到一块用实际场景去练比如让程序去统计一段文本里的字母频次、去模拟一个排队叫号系统、去整理一份成绩排名你会突然发现它们俩其实是一对默契的搭档一个在前面指挥着“下一个”一个在后面端着一排数据等着被处理。写循环的时候多问问自己“这个边界值会不会越界”定义数组的时候多想想“我到底想要多少空间”剩下的就交给时间和练习吧。你手底下跑过的每一段循环代码都会成为你以后写复杂程序的底气。
阅读完成 · 觉得有帮助?