循环是C语言里第一个真正让人“开窍”的东西。我见过太多人卡在循环这一关:语法背得滚瓜烂熟,一到自己写就不知道从哪下手,或者写出来跑一遍结果不对,盯着屏幕半天找不出问题。其实循环本身不难,难的是把“重复执行”这个抽象概念翻译成具体的代码逻辑。这篇内容就是围绕C语言循环练习题展开的,我会把常见的循环题目类型、每类题目的解题思路、代码实现、以及我在教学和实际编码中踩过的坑,全部拆开揉碎讲清楚。不管你是刚学完for和while语法的新手,还是想找一批练习题巩固基础的进阶者,都能从这里找到可以直接上手的内容。
1. 循环练习题到底在练什么
1.1 循环的本质:把重复劳动交给机器
很多人做循环练习题的时候,容易陷入一个误区——把它当成数学题来解。比如打印九九乘法表,有人会去推导每一行和列的关系,然后试图用一个公式直接算出来。这个方向不能说错,但偏离了循环练习的核心目的。
循环的本质是控制重复。你需要练的是三件事:第一,识别出哪些操作在重复;第二,找到重复的起点和终点;第三,确定每次重复之间什么在变、什么不变。这三件事想清楚了,代码自然就出来了。
拿一个最简单的例子来说:计算1到100的和。重复的操作是“加法”,起点是1,终点是100,每次变化的是加数本身。用for循环写就是:
int sum = 0; for (int i = 1; i <= 100; i++) { sum += i; }这段代码谁都写得出来。但如果换成“计算1到100之间所有偶数的和”,变化的是什么?是步长。i每次不再加1,而是加2。或者你保留i++,但在循环体里加一个判断。两种写法都对,但前者更简洁,后者更通用。这就是循环练习题要训练的判断力——在多种可行方案中选择最合适的那一种。
1.2 循环练习题的四大类型
我把常见的C语言循环练习题分成四类,每类的训练目标不同:
| 类型 | 典型题目 | 训练目标 |
|---|---|---|
| 累加累乘型 | 求1到n的和、阶乘、斐波那契数列 | 循环变量的初始化和更新 |
| 图形打印型 | 打印三角形、菱形、九九乘法表 | 嵌套循环的层次控制 |
| 数值判断型 | 素数判断、水仙花数、完数 | 循环与条件判断的配合 |
| 字符串处理型 | 字符串逆序、统计字符个数 | 循环与数组/指针的结合 |
这四类题目覆盖了循环的绝大多数应用场景。你不需要刷几百道题,每类认真做透五六道,循环就算过关了。
1.3 新手最容易踩的三个坑
在展开具体题目之前,先把最常见的三个坑说清楚,后面看例题的时候你会更有感觉。
第一个坑:循环变量在循环体外被修改。比如你在for循环里又写了一个i++,或者在循环体里调用了会改变i的函数。这种bug很隐蔽,因为语法上完全合法,但逻辑上循环次数就乱了。
第二个坑:边界条件搞错。到底是i < n还是i <= n?差一个等号,结果可能完全不同。我的习惯是:如果循环变量代表“第几次”,用1起始配<=;如果代表数组下标,用0起始配<。
第三个坑:嵌套循环的变量混用。外层用i,内层也用i,编译器不会报错,但逻辑全乱。嵌套循环的变量名一定要区分开,i、j、k是约定俗成的选择。
提示:写完循环后,用n=1、n=2这样的小值手动跑一遍,比盯着代码看十分钟都管用。
2. 累加累乘型题目精讲
2.1 从1加到n:最基础但最容易写错
这道题几乎是所有C语言教材的第一个循环例题。看起来简单,但我见过至少三种典型错误写法。
错误写法一:
int sum = 0; for (int i = 1; i <= n; i++); { sum += i; }注意for后面那个分号。它让循环体变成了空语句,下面的花括号块只执行一次。这个错误编译器不会报错,但结果永远是sum等于n+1(因为i在循环结束后等于n+1)。
错误写法二:
int sum; for (int i = 1; i <= n; i++) { sum += i; }sum没有初始化。在C语言里,局部变量的初始值是随机的,sum可能从任意值开始累加。这个错误的结果不可预测,有时候碰巧对,有时候错得离谱。
错误写法三:
int sum = 0; for (int i = 1; i < n; i++) { sum += i; }边界写成了i < n,少加了最后一项。如果n=100,实际只加到99。
正确的写法前面已经给过了。我想强调的是,累加型题目的标准模板是:初始化累加器为0,循环变量从起点到终点,循环体里做累加。这个模板可以套用到求和、计数、求平均值等所有类似场景。
2.2 阶乘计算:注意数据溢出
阶乘的循环结构和求和几乎一样,只是把加法换成乘法,累加器初始值从0换成1。
long long factorial(int n) { long long result = 1; for (int i = 2; i <= n; i++) { result *= i; } return result; }这里有两个细节值得说。第一,循环从2开始而不是从1开始,因为乘1没有意义,少一次循环少一次开销。第二,返回值用long long而不是int,因为13的阶乘就已经超过int的范围了(int最大约21亿,13!约62亿)。
实际测试一下:12! = 479001600,在int范围内;13! = 6227020800,溢出了。如果你用int接收,会得到一个负数或者错误的值。这种溢出问题在累乘型题目里非常常见,养成用long long的习惯能省很多调试时间。
2.3 斐波那契数列:循环替代递归的经典案例
斐波那契数列的定义是:前两项为1,从第三项开始每项等于前两项之和。用递归写很直观但效率极低,用循环写效率高得多。
long long fib(int n) { if (n <= 2) return 1; long long a = 1, b = 1, c; for (int i = 3; i <= n; i++) { c = a + b; a = b; b = c; } return b; }这段代码的核心是三个变量的滚动更新。a和b始终保存相邻的两项,每次循环计算出下一项c,然后把b赋给a、c赋给b。循环结束时b就是第n项。
我特意用long long是因为斐波那契数列增长极快,第50项就已经超过int范围了。另外注意n<=2的边界处理,如果不处理,n=1或n=2时循环不执行,返回的b是初始值1,恰好正确,但逻辑上不够清晰。
实操心得:滚动更新的思路在动态规划、滑动窗口等算法里反复出现,把这个模式练熟,后面学更复杂的内容会轻松很多。
3. 图形打印型题目精讲
3.1 打印直角三角形:理解嵌套循环的执行顺序
打印图形的题目是训练嵌套循环最好的方式。先看最简单的直角三角形:
* ** *** **** *****代码:
for (int i = 1; i <= 5; i++) { for (int j = 1; j <= i; j++) { printf("*"); } printf("\n"); }外层循环控制行数,内层循环控制每行打印几个星号。关键在于内层循环的终止条件是j <= i,也就是说第i行打印i个星号。
新手常犯的错误是把内层写成j <= 5,结果打印出一个5x5的方阵。还有人把printf("\n")写在内层循环里,导致每个星号后面都换行。记住:换行是行级别的操作,必须放在内层循环外面、外层循环里面。
3.2 打印等腰三角形:空格的处理是关键
等腰三角形比直角三角形多了一个空格的控制:
* *** ***** ******* *********代码:
int n = 5; for (int i = 1; i <= n; i++) { for (int j = 1; j <= n - i; j++) { printf(" "); } for (int k = 1; k <= 2 * i - 1; k++) { printf("*"); } printf("\n"); }这里有两个内层循环,第一个打印空格,第二个打印星号。空格数的规律是n - i,星号数的规律是2 * i - 1。这两个公式不是背下来的,是观察出来的:第1行4个空格1个星,第2行3个空格3个星,第3行2个空格5个星……每行空格递减1,星号递增2。
我建议你在纸上画出n=5的图形,然后逐行数空格和星号的数量,自己推导公式。推导一遍比看十遍代码印象深得多。
3.3 打印九九乘法表:格式控制的实战
九九乘法表是图形打印和数值计算的结合:
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的意思是左对齐占2个字符宽度,这样每列的乘积对齐,看起来整齐。
如果你想让乘法表呈下三角排列(第一行9个,最后一行1个),把内层循环改成j <= 10 - i即可。这种变体练习能帮你理解循环边界和图形形状之间的对应关系。
注意:printf里的格式控制符要和参数类型匹配。用%d打印long long会出错,应该用%lld。这个坑在图形打印题里不常见,但在数值计算题里经常遇到。
4. 数值判断型题目精讲
4.1 素数判断:循环提前退出的典型应用
判断一个数n是否为素数,最朴素的做法是检查2到n-1之间有没有能整除n的数:
int isPrime(int n) { if (n < 2) return 0; for (int i = 2; i < n; i++) { if (n % i == 0) return 0; } return 1; }这段代码里return 0就是提前退出循环。一旦找到一个因子,就不需要继续检查了,直接返回“不是素数”。这种“找到答案就退出”的模式在循环里非常常见。
优化版本只需要检查到sqrt(n):
#include <math.h> int isPrime(int n) { if (n < 2) return 0; for (int i = 2; i <= sqrt(n); i++) { if (n % i == 0) return 0; } return 1; }为什么是sqrt(n)?因为如果n有一个大于sqrt(n)的因子,那必然有一个小于sqrt(n)的对应因子。比如100的因子对:(2,50)、(4,25)、(5,20)、(10,10)。检查到10就够了,后面的因子对在前面已经出现过了。
这个优化把时间复杂度从O(n)降到O(sqrt(n)),对于n=1000000这样的输入,循环次数从一百万降到一千,差距非常明显。
4.2 水仙花数:拆位运算与循环枚举
水仙花数是指一个三位数,其各位数字的立方和等于该数本身。比如153 = 1³ + 5³ + 3³。
for (int n = 100; n <= 999; n++) { int a = n / 100; int b = (n / 10) % 10; int c = n % 10; if (a*a*a + b*b*b + c*c*c == n) { printf("%d ", n); } }这道题练的是两件事:用循环枚举所有可能的值,用整除和取余拆出各位数字。拆位的公式值得记一下:百位是n/100,十位是(n/10)%10,个位是n%10。对于四位数,千位是n/1000,百位是(n/100)%10,以此类推。
如果你想练得更深入,可以扩展到四叶玫瑰数(四位数,各位四次方和等于本身)或者自幂数(n位数,各位n次方和等于本身)。自幂数的求解需要嵌套循环,外层枚举位数,内层枚举该位数的所有数,是一个很好的综合练习。
4.3 完数:循环嵌套与因子求和
完数是指一个数恰好等于它的所有真因子之和。比如6 = 1 + 2 + 3。
for (int n = 2; n <= 1000; n++) { int sum = 0; for (int i = 1; i < n; i++) { if (n % i == 0) { sum += i; } } if (sum == n) { printf("%d ", n); } }外层循环枚举候选数,内层循环找因子并累加。这道题的结构和素数判断很像,区别在于素数判断找到因子就退出,完数判断需要找完所有因子。
优化思路和素数判断一样:因子只需要找到sqrt(n),但要注意完全平方数的情况。比如36的因子有1、2、3、4、6、9、12、18,其中6是sqrt(36),只能算一次。这个细节处理起来稍微麻烦一点,但对于初学者来说,先把朴素版本写对更重要。
5. 字符串处理型题目精讲
5.1 字符串逆序:双指针与循环的结合
字符串逆序是循环和数组结合的经典题目。给定一个字符串,把它原地反转。
#include <string.h> void reverseString(char *s) { int len = strlen(s); for (int i = 0; i < len / 2; i++) { char temp = s[i]; s[i] = s[len - 1 - i]; s[len - 1 - i] = temp; } }循环只走到len/2,因为每次交换涉及两个字符,走一半就够了。如果走完整长度,相当于交换两次,又变回原样了。
这里用strlen获取长度,需要包含string.h。如果你不想用库函数,可以自己写一个循环来算长度:
int len = 0; while (s[len] != '\0') { len++; }这个while循环利用了C语言字符串以'\0'结尾的特性。循环条件是当前字符不是结束符,每次循环len加1。循环结束时len就是字符串长度。
5.2 统计字符类型:循环遍历与条件分支
统计一个字符串中大写字母、小写字母、数字和其他字符的个数:
#include <stdio.h> int main() { char str[100]; fgets(str, sizeof(str), stdin); int upper = 0, lower = 0, digit = 0, other = 0; for (int i = 0; str[i] != '\0'; i++) { if (str[i] >= 'A' && str[i] <= 'Z') { upper++; } else if (str[i] >= 'a' && str[i] <= 'z') { lower++; } else if (str[i] >= '0' && str[i] <= '9') { digit++; } else { other++; } } printf("大写:%d 小写:%d 数字:%d 其他:%d\n", upper, lower, digit, other); return 0; }这里用fgets而不是scanf,因为scanf遇到空格就停止读取了,而fgets可以读入整行。fgets会把换行符也读进来,所以统计结果里“其他”会多一个。如果不想统计换行符,可以在循环里加一个判断跳过'\n'。
字符比较用的是ASCII码。'A'到'Z'是65到90,'a'到'z'是97到122,'0'到'9'是48到57。用字符字面量比较比用数字更直观,也不容易出错。
5.3 字符串匹配:嵌套循环的字符串版本
判断一个字符串是否包含另一个子串:
int contains(char *str, char *sub) { int strLen = strlen(str); int subLen = strlen(sub); for (int i = 0; i <= strLen - subLen; i++) { int j; for (j = 0; j < subLen; j++) { if (str[i + j] != sub[j]) { break; } } if (j == subLen) { return 1; } } return 0; }外层循环遍历主串的每个可能的起始位置,内层循环逐字符比较。如果内层循环正常结束(j等于subLen),说明找到了匹配。如果中途break,说明当前位置不匹配,继续尝试下一个位置。
外层循环的终止条件是i <= strLen - subLen,因为如果剩余字符数少于子串长度,不可能匹配。这个边界条件很容易写错,写成i < strLen的话,当i接近末尾时访问str[i+j]会越界。
实操心得:字符串处理题目里,越界访问是最常见的bug。养成检查循环边界的习惯,特别是涉及i+j这种复合下标的时候。
6. 循环控制进阶技巧
6.1 break和continue的正确使用场景
break用于立即退出当前循环,continue用于跳过本次循环的剩余部分、直接进入下一次迭代。
// break示例:找到第一个能被7整除的数 for (int i = 1; i <= 100; i++) { if (i % 7 == 0) { printf("找到了:%d\n", i); break; } } // continue示例:打印1到20之间所有不能被3整除的数 for (int i = 1; i <= 20; i++) { if (i % 3 == 0) { continue; } printf("%d ", i); }break在嵌套循环里只退出最内层循环。如果你想一次退出所有层,有两种做法:一是用goto(不推荐,但确实有效),二是设置一个标志变量,每层循环都检查这个标志。
int found = 0; for (int i = 0; i < 10 && !found; i++) { for (int j = 0; j < 10; j++) { if (someCondition) { found = 1; break; } } }这种标志变量的写法比goto更清晰,在实际工程中更常用。
6.2 循环嵌套的执行次数计算
嵌套循环的总执行次数是各层循环次数的乘积。比如:
for (int i = 0; i < n; i++) { for (int j = 0; j < m; j++) { // 循环体执行n*m次 } }如果内层循环的边界依赖外层变量:
for (int i = 0; i < n; i++) { for (int j = 0; j < i; j++) { // 循环体执行0+1+2+...+(n-1) = n(n-1)/2次 } }这个求和公式在分析算法复杂度时经常用到。比如冒泡排序的比较次数就是n(n-1)/2,选择排序也是。理解嵌套循环的执行次数,是分析算法效率的基础。
6.3 do-while循环的适用场景
do-while和while的区别是:do-while至少执行一次循环体,然后再判断条件。
int input; do { printf("请输入一个正数:"); scanf("%d", &input); } while (input <= 0);这个场景用do-while很自然:先让用户输入,然后检查输入是否合法,不合法就重新输入。如果用while,需要先把input初始化成一个非法值,代码会稍微别扭一点。
do-while在菜单程序里也很常用:
int choice; do { printf("1. 添加 2. 删除 3. 退出\n"); scanf("%d", &choice); switch (choice) { case 1: /* 添加逻辑 */ break; case 2: /* 删除逻辑 */ break; } } while (choice != 3);菜单至少显示一次,用户选择退出才结束循环,do-while的语义正好匹配。
7. 常见问题与排查技巧实录
7.1 死循环的排查方法
死循环是循环题目里最让人头疼的问题。程序卡住不动,CPU占用飙升,但就是不出结果。
排查死循环的第一步是检查循环变量是否在变化。比如:
int i = 0; while (i < 10) { printf("%d ", i); // 忘记写i++了 }这种错误很直观。更隐蔽的是循环变量在循环体里被意外修改:
for (int i = 0; i < 10; i++) { i = i * 2; // i的变化被干扰了 }或者循环条件永远为真:
int i = 0; while (i >= 0) { // i是int,永远可能为负,但这里i递增,最终会溢出变成负数 i++; }排查方法:在循环体里加一句printf打印循环变量的值,跑几次就能看出问题。或者用调试器设断点,单步跟踪。
7.2 循环结果差一的排查
“差一错误”(off-by-one error)是循环里第二常见的问题。结果总是差一个数,或者多循环了一次、少循环了一次。
| 现象 | 可能原因 | 修正方法 |
|---|---|---|
| 结果少最后一项 | 循环条件用了<而不是<= | 检查边界,确认是否包含终点 |
| 结果多一项 | 循环条件用了<=而不是< | 同上,反向检查 |
| 数组越界 | 循环变量从0到n(含n) | 数组下标应该是0到n-1 |
| 累加结果偏大 | 累加器初始值不是0 | 检查累加器初始化 |
我的经验是:用n=1和n=2手动验证。如果n=1时结果正确,n=2时也正确,那n=100大概率也正确。如果n=1就错了,那肯定是边界问题。
7.3 嵌套循环变量混用的排查
嵌套循环里内外层用了同一个变量名,编译器不会报错,但逻辑完全错误:
for (int i = 0; i < 5; i++) { for (int i = 0; i < 3; i++) { // 内层也用了i printf("*"); } printf("\n"); }这段代码的实际效果是:外层i从0开始,进入内层循环,内层i从0到2,打印3个星号,内层结束后i=3。回到外层,i++变成4,再次进入内层,i被重置为0……最终打印5行星号,但外层的i被内层覆盖了,逻辑上完全混乱。
避免方法:嵌套循环的变量名用i、j、k依次命名,形成肌肉记忆。如果层数超过3层,考虑把内层逻辑抽成函数。
7.4 常见编译警告与循环相关的问题
编译器的一些警告和循环直接相关,不要忽略它们:
- warning: suggest parentheses around assignment used as truth value:你可能在while条件里写了赋值(=)而不是比较(==)。
- warning: comparison between signed and unsigned integer expressions:循环变量类型和比较值类型不一致,可能导致意外的循环次数。
- warning: variable 'i' set but not used:循环变量声明了但没用到,可能是写错了变量名。
把编译器的警告级别调到最高(gcc用-Wall -Wextra),能提前发现很多循环相关的潜在问题。
提示:如果你用的是VS Code配置C语言环境,在tasks.json的args里加上"-Wall"和"-Wextra",编译时就能看到所有警告。
8. 从练习题到实际应用的过渡
8.1 循环在算法题中的典型模式
刷算法题的时候,循环很少单独出现,通常和数组、字符串、递归结合。但底层逻辑还是那几样:遍历、累加、查找、排序。
以冒泡排序为例:
void bubbleSort(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; } } } }外层循环控制排序的轮数,内层循环控制每轮的比较次数。内层边界是n-1-i,因为每轮结束后最大的元素已经“冒”到末尾了,不需要再比较。
这个结构和前面打印三角形的嵌套循环本质相同,只是循环体里的操作从打印变成了比较和交换。你把图形打印练熟了,再看排序代码就不会觉得陌生。
8.2 循环在嵌入式开发中的实际应用
如果你学C语言是为了做单片机开发,循环的使用场景会更具体。比如按键消抖:
int keyPressed() { if (KEY_PIN == 0) { for (int i = 0; i < 1000; i++); // 延时消抖 if (KEY_PIN == 0) { return 1; } } return 0; }这个空循环就是用来延时的。在单片机里,每条指令的执行时间固定,空循环的次数乘以指令周期就是延时时间。这种用法在PC程序里很少见,但在嵌入式里非常普遍。
另一个常见场景是PWM调光:
for (int duty = 0; duty <= 100; duty++) { for (int i = 0; i < 100; i++) { if (i < duty) { LED_ON(); } else { LED_OFF(); } } }外层循环控制占空比从0到100,内层循环在一个周期内控制LED的亮灭时间。这种嵌套循环在单片机编程里随处可见。
8.3 循环性能优化的基本思路
当循环次数很大时,循环体的效率就很重要了。几个基本的优化原则:
减少循环体内的重复计算。比如:
// 优化前 for (int i = 0; i < strlen(s); i++) { ... } // 优化后 int len = strlen(s); for (int i = 0; i < len; i++) { ... }strlen是O(n)的操作,放在循环条件里每次迭代都要重新计算,整体变成O(n²)。提前算好存到变量里,就变成O(n)了。
把不依赖循环变量的计算提到循环外。比如:
// 优化前 for (int i = 0; i < n; i++) { result += i * base_value * 2; } // 优化后 int factor = base_value * 2; for (int i = 0; i < n; i++) { result += i * factor; }循环展开。对于循环体很小、循环次数很大的情况,每次迭代手动处理多个元素,减少循环控制的开销:
// 优化前 for (int i = 0; i < n; i++) { sum += arr[i]; } // 优化后(每次处理4个) int i; for (i = 0; i + 3 < n; i += 4) { sum += arr[i] + arr[i+1] + arr[i+2] + arr[i+3]; } for (; i < n; i++) { sum += arr[i]; }循环展开在现代编译器里通常会自动做,但理解这个思路对分析性能瓶颈有帮助。
8.4 循环与递归的转换
有些题目既可以用循环写,也可以用递归写。理解两者的转换关系,能加深对循环的理解。
以阶乘为例,递归版本:
long long factorial_recursive(int n) { if (n <= 1) return 1; return n * factorial_recursive(n - 1); }循环版本前面已经给过了。递归的终止条件对应循环的初始条件,递归的递推关系对应循环的迭代更新。递归代码更简洁,但每次调用都有函数栈开销;循环代码稍长,但效率更高。
在实际工程中,能用循环解决的问题尽量用循环,因为递归深度过大会导致栈溢出。但在某些数据结构(如树、图)的遍历中,递归的表达力更强,这时候用递归更合适。
我在实际教学中发现,循环练习题做不出来的同学,往往不是语法问题,而是没有把“重复”这件事想清楚。拿到题目先别急着写代码,用自然语言描述一下:第一步做什么,第二步做什么,什么时候停。把这个流程写出来,再翻译成C语言,难度会降低很多。另外,手动模拟循环的执行过程非常重要,拿一张纸,把每次循环变量的值写下来,跑三五轮,规律自然就出来了。这个笨办法比盯着代码看有效得多。