“奇妙的比值”“T的倍数N”“三角形”——单看这三个题目,你可能会以为这是一份数学练习卷,但它们其实是入门编程课里非常经典的“循环”基础题,编号分别是16th、17th、18th。三道题放在一起很有意思:都要求用循环语句完成,但每一道的循环用法都不一样。第16题要用循环做辗转相除,求两个数的最大公约数,然后推到最小公倍数和最简整数比;第17题要用循环遍历一个区间,把T的倍数筛出来;第18题要用两层循环配合,在终端里打印出三角形图案。换句话说,这是对for、while、循环嵌套的一次组合式训练。
如果你刚学完循环语法,写单层for循环还磕磕绊绊,或者看完答案能懂、合上书就写不出来,那这篇内容应该能帮你把逻辑理顺。我会从算法思路开始拆,给出可运行的Python和C两种代码,再讲几个只有自己踩过坑才会知道的细节。题目不复杂,但把这三道题吃透,循环这一关基本就稳了。
1. 三道题为什么都围绕“循环”展开
1.1 循环的本质:重复、变化、收集
很多初学者把循环理解成“把一段代码重复执行几遍”,这个理解不够完整。真正的循环题考的是三件事:按规律重复、在重复中让某个量发生变化、在重复中收集结果。
举个例子,打印1到10的整数,i每次都加1,这是“变化”;每打印一个数都在屏幕上留下输出,这是“收集”;整个过程重复10次,这是“重复”。第16题的辗转相除,每次循环都在算余数并交换两个数,直到余数为0,这也是一个标准的循环过程,只是它的循环次数不固定,完全由输入决定。
所以判断一个场景能不能用循环解决,就看它是否满足“同样的操作,参数在变,结果在积累”。凡是满足这三条的问题,基本都能套上for或while。
1.2 三道题的递进关系与考察点
三道题是逐步加深的。第16题是条件型循环,循环次数事先不知道,靠循环条件决定何时停下来;第17题是范围型循环,遍历一个已知的区间,在循环体里做条件筛选;第18题是嵌套型循环,外层管行、内层管列,内外层还要配合好。
我在下面这张表里把三题的考察点做了一个对照:
| 题号 | 题目 | 循环形态 | 核心考点 | 实际生活中的影子 |
|---|---|---|---|---|
| 16th | 奇妙的比值 | while循环,次数不固定 | 辗转相除、取模、变量交换 | 数据规约、求最大公约数 |
| 17th | T的倍数N | for循环遍历范围 | 取模判断、边界控制、计数累加 | 数据筛选、批量任务处理 |
| 18th | 三角形 | for嵌套循环 | 行列建模、公式推导、输出格式 | 表格遍历、图形输出、矩阵操作 |
这个递进关系是刻意的。第16题考“循环到底什么时候结束”,第17题考“循环体内部怎么根据条件取舍”,第18题考“两层循环如何协同”,三关闯完,循环的三种典型用法基本都见过了。
2. 第16题“奇妙的比值”:用循环求最简整数比
2.1 题目解析与算法思路
“奇妙的比值”听起来抽象,其实是求两个正整数的最大公约数和最小公倍数,然后输出它们的最简整数比。比如输入12和18,最大公约数是6,最小公倍数是36,最简整数比是2:3。
为什么说比值“奇妙”?因为任意两个整数只要同时除以它们的最大公约数,得到的比例就和原来的比例完全等价,但数字变小了,这个性质在化简分数时天天用。题目用“循环”作为后缀,意思是这个最大公约数必须用循环来实现,而不是直接调用现成的数学函数。
求最大公约数最经典的方法是欧几里得算法,也叫辗转相除法。它的核心规则是:gcd(m, n) = gcd(n, m % n),一直算到余数为0,最后一个非零余数就是最大公约数。这个算法用循环表达特别自然,因为循环体里反复做“求余、赋值”两个动作。
2.2 Python与C关键代码实现
先看Python版本,清晰而且贴近伪代码:
m = int(input("请输入第一个数 m: ")) n = int(input("请输入第二个数 n: ")) a, b = m, n while b != 0: r = a % b a = b b = r g = a l = m // g * n print("最大公约数 g =", g) print("最小公倍数 l =", l) print("最简整数比:{} : {}".format(m // g, n // g))这里a和b是工作变量,不能直接修改m和n,因为后面还要用原始值计算最小公倍数和比值。g = a是循环结束时保存结果,l = m // g * n用的是最大公约数 × 最小公倍数 = m × n这个性质。
再看C语言版本,很多学校的C语言课都会让把这道题写一遍:
#include <stdio.h> int main(void) { int m = 0, n = 0, a = 0, b = 0, r = 0; scanf("%d%d", &m, &n); a = m; b = n; while (b != 0) { r = a % b; a = b; b = r; } printf("gcd = %d\n", a); printf("lcm = %d\n", m / a * n); printf("ratio = %d:%d\n", m / a, n / a); return 0; }2.3 这个例题里的两个容易忽略的细节
第一个细节是m * n / a和m / a * n的区别。数学上两式一样,但在C语言里如果m和n比较大,m * n可能先溢出,结果就不对了。推荐先除后乘,也就是m / a * n,可以在多数情况下避免整数溢出。这是一个很实用的工程经验,面试里考过很多次。
第二个细节是输入的两个数大小顺序。欧几里得算法并不要求m >= n,因为如果m < n,第一轮求余就会自动得到r = m,然后两个数交换位置。你可以试试输入18 12,结果和12 18完全一致。这个特性是好心给了你写代码的方便,但也容易被忽略,导致有人专门写个判断去排序,属于多余的步骤。
3. 第17题“T的倍数N”:用循环做区间筛选
3.1 基础方案:遍历1到N逐个判断
这道题的要求是:给定一个整数N和一个正整数T,找出从1到N范围内所有T的倍数,统计个数并求和。
最直观的思路是从1循环到N,对每一个数判断能否被T整除。判断的数学工具是取模运算:i % T == 0,如果成立,说明i是T的倍数。
n = int(input("请输入范围上界 N: ")) t = int(input("请输入倍数因子 T: ")) count = 0 total = 0 for i in range(1, n + 1): if i % t == 0: print(i, end=" ") count += 1 total += i print() print("倍数的个数:", count) print("倍数的总和:", total)比如输入N=20、T=3,程序输出3 6 9 12 15 18,个数是6,总和是63。整个过程一共循环20次,每次只做一次取模和一次判断,逻辑非常直白。
这类题目是“遍历加筛选”的典型代表,也是循环里最常见的模式。以后写代码时,你会经常遇到“从一个集合里挑出符合某种条件的元素”的场景,比如从日志里筛出报错行、从用户列表里找出活跃用户,思路都是同一个:循环遍历,条件判断,收集结果。
3.2 步进方案:循环次数更少
如果你观察一下会发现,T的倍数本身是有规律的,它们构成一个等差数列:T, 2T, 3T...。所以完全不用逐个遍历,可以直接从T开始,每次加T。
n = int(input("请输入范围上界 N: ")) t = int(input("请输入倍数因子 T: ")) for i in range(t, n + 1, t): print(i, end=" ")这段代码里,range(t, n + 1, t)的第三个参数就是步长。当T很大时,比如N=100、T=99,第一种方案要循环100次,第二种方案只循环1次,效率差距非常明显。
但要注意,这两种方案在教学中各有价值。第一种方案把“倍数”这个数学判断和取模运算绑定在一起,更有利于理解倍数的定义;第二种方案更贴近数学本质,也更高效。实际写代码时选哪种?我的习惯是:如果你只需要倍数本身,用步进;如果你还需要对非倍数做别的处理,用取模判断。
3.3 分隔符处理:最后一个数不输出多余符号
这个问题很不起眼,但踩坑的人特别多。如果用print(i, end=" ")这种方式,输出的数字之间都会有空格,但最后一个数字后面也会多出一个空格。在终端看不太出来,但如果把输出结果当作数据传给别的地方,这个多余的空格就可能造成问题。
更常见的需求是用逗号分隔:3, 6, 9, 12, 15, 18,最后一个数后面不能有逗号。处理思路有两种,一种是把结果先收集成列表,再统一打印:
n = int(input("N = ")) t = int(input("T = ")) res = [] for i in range(1, n + 1): if i % t == 0: res.append(i) for idx, v in enumerate(res): if idx == len(res) - 1: print(v) else: print(v, end=", ")另一种思路是用一个布尔变量记住“是不是第一个”,只有从第二个元素开始才在输出前加逗号。这种first标记法在C语言里非常常见:
int first = 1; for (i = 1; i <= n; i++) { if (i % t == 0) { if (!first) { printf(","); } printf("%d", i); first = 0; } }这个技巧的通用性很强。前端做动态列表、脚本输出CSV、日志格式化,都会遇到“最后一个元素别加分隔符”的问题。这个知识点虽然小,但特别能体现工程感。
4. 第18题“三角形”:嵌套循环输出图形
4.1 直角三角形:从“第几行”推“几个星号”
前面两道题都只用一层循环,到了第18题开始上嵌套了。先看最简单的直角三角形,输出效果是:
* ** *** **** *****观察规律:第1行有1个星号,第2行有2个,第3行有3个。也就是说,第i行的星号数量正好等于i。这就是外层循环和内层循环之间的关系:外层循环变量负责控制行号,内层循环的循环次数由外层变量决定。
n = 5 for i in range(1, n + 1): for j in range(i): print("*", end="") print()每一行内层循环结束后,用一次print()换行。这个换行也是循环的一部分,不能省略。
C语言版本:
for (int i = 1; i <= n; i++) { for (int j = 0; j < i; j++) { printf("*"); } printf("\n"); }4.2 金字塔三角形:先推空格数,再推星号数
比直角三角形稍微难一层的是金字塔形(等腰三角形)。目标输出:
* *** ***** ******* *********这里的关键是每一行不仅有星号,还有前置空格。想要让所有行的星号在中间对齐,就要先输出空格再输出星号。
先看规律。对于n=5:
- 第1行:4个空格,1个星号
- 第2行:3个空格,3个星号
- 第3行:2个空格,5个星号
所以第i行(i从1开始)的空格数是n - i,星号数是2*i - 1。空格数为什么是n-i?因为要保证最后一行没有前置空格,而前面的行逐层缩进。星号数为什么是2*i-1?因为每行比上一行多出两个星号,首行1个,对应的通项公式就是2*i-1。
n = 5 for i in range(1, n + 1): for j in range(n - i): print(" ", end="") for k in range(2 * i - 1): print("*", end="") print()内层两个循环一个输出空格、一个输出星号,顺序不能反。因为一旦先输出星号再输出空格,图形就会整体错位。
C语言版本:
for (int i = 1; i <= n; i++) { for (int j = 0; j < n - i; j++) { printf(" "); } for (int k = 0; k < 2 * i - 1; k++) { printf("*"); } printf("\n"); }4.3 扩展图形:倒三角与菱形
三角形题目最常见的变体是倒三角形和菱形。倒三角形只需要把外层循环倒过来,从n往下数到1:
n = 5 for i in range(n, 0, -1): for j in range(n - i): print(" ", end="") for k in range(2 * i - 1): print("*", end="") print()菱形则是先输出上半部分的正金字塔,再输出下半部分的倒金字塔(去掉中间重复的一行)。这类变体刷过一遍之后,嵌套循环基本就通了。这里强调的关键不是记住某个图形的代码,而是学会“先观察规律,再写出公式,最后翻译成循环”的解题流程。
5. 循环题的常见问题与排查技巧实录
5.1 边界错误:少一个元素或多一个元素
这是循环题最高频的问题。比如打印1到N,你写的范围是for i in range(1, n),输出就少了n;或者C语言里写成i < n,同样会少最后一项。反过来,有时候又会多循环一次。
排查思路很简单:取最小的边界值N=1做手工推演。让代码在脑子里“跑一遍”:i从1开始,满足条件吗?循环结束后i的值是多少?如果输出结果和预期不符,那多半就是边界条件写错了。记住这个口诀:含端点就用<=和range(1, n+1),不含端点就用<和range(1, n)。
5.2 死循环:循环变量“卡住”了
死循环最常见的原因是循环体里没有更新循环变量。比如:
i = 1 while i <= n: print(i) # 忘了 i += 1这种错误看起来低级,但写成复杂业务逻辑后非常隐蔽。排查时可以看循环体最后几行,确认有没有修改条件里出现的变量。
死循环不止出现在入门题里。比如硬件开发中,ESP8266等设备用AT指令做串口通信时,代码里等待设备返回“OK”字符串,如果循环里没有设置超时上限,设备状态异常就会一直等下去,表现就是“卡死”。这和基础题里的死循环本质一样,只是换到了真实场景。我的经验是:所有等待型的while循环都要加一个“最大尝试次数”或“超时”出口,这是工程级代码的底线要求。
5.3 输出格式:多空格、少换行、分隔符多余
打印循环题经常栽在格式上。常见的三种问题:
第一,多了一个前置空格。如果内层循环输出空格时多算了一个,图形就会整体偏移。第二,换行不对,内层循环结束时忘了打印print(),所有星号就会挤在一行。第三,分隔符问题,我在第3.3节里讲过,尾部多出逗号或空格,用first标记法就能解决。
有一个通用的调试技巧:在循环体里临时打印出循环变量,比如print("i=", i, "j=", j),跑一遍就能看到变量是怎么变化的。用完再删掉就行,这种“打印调试法”比干瞪眼强十倍。
5.4 从三道基础题看循环在现实场景中的影子
这三道题虽然简单,但循环的思想会在后续很多地方出现。
链表遍历时,访问节点后要移动到下一个节点,典型的循环结构:while (p != NULL) { p = p->next; }。如果是循环单链表,就把结束条件换成p != head。这和辗转相除法里的while循环非常像,循环次数都由条件决定。
循环队列中,元素的移动常用公式:rear = (rear + 1) % capacity。这里的取模运算和判断“T的倍数”用的i % t == 0是同一个操作,只是用途不同。
语言层面也一样。shell脚本里的for循环写起来可能是for i in $(seq 1 5); do mkdir "dir$i"; done,JavaScript里提前结束循环用break,这些语法细节不同,但循环的逻辑骨架完全一致。理解了循环的本质,换个语言不过是换个关键字。
循环神经网络RNN则是把同一个结构在时间轴上重复执行多次,每一层接收前一层的输出作为当前输入,这和“在循环中反复使用同一个处理逻辑”的心智模型也是相通的。基础题里练的循环,往上可以一直抽象到模型设计。
我自己带学生时有个习惯:这三道题做完,一定要让他们把第16题的while循环改成for循环、把第17题的for循环改成while循环、把第18题的金字塔改成菱形。同样的逻辑用两种循环各写一遍,很多理解不到位的地方就暴露出来了。代码不光是写出来能跑,还要能解释清楚每一步为什么这么做,这才算真正过关。