循环语句大概是Python学习路上第一道真正的分水岭。前面学变量、学判断、学基本数据类型,写出来的代码都是从第一行顺着往下走,一眼能看完,心里有底;到了for和while,程序开始自己转圈,转几次、什么时候停、循环变量每一步变成什么值,全得先在脑子里跑一遍。不少人是这样的状态:书上的循环例子逐行看得懂,一合上书让自己写,就卡在“我该用for还是while”“range里到底写几”这种地方。这不是语法没记住,而是缺少一种“在脑子里单步执行”的手感,而手感这东西,只能靠一道道小练习喂出来。
下面这些练习,都是我在带人入门和自己回头复习时反复用过的。它们看着简单,但没有一道是纯粹的“抄一遍就完事”,每一道都埋着一个具体的边界问题:range的右开区间、累加变量的初始值、break到底跳出了几层、continue之后计数器有没有被跳过、遍历列表时能不能顺手删元素。题目按难度递进,每道都给出代码、推导过程和我实际踩过的坑。适合刚学完循环语法想找题练手的人,也适合已经写了半年Python、但某些细节一直靠“试出来”而不是“想明白”的人。
1. 循环练习真正要练的是三样东西
1.1 写得出、看得懂、改得动,是三档不同的能力
很多人把“会循环”当成一个开关,要么会要么不会。实际带人的经验是,这门手艺至少分三档。第一档是写得出:给一个明确的需求,能照着语法把for i in range(...)搭起来,哪怕要多试几次。第二档是看得懂:别人写的嵌套循环、带break和continue的循环,能一行行在脑子里走完,说清楚第3次迭代时各变量是多少。第三档是改得动:需求变了,比如从“打印1到100”变成“打印1到100里所有能被3整除但不能被5整除的数”,能立刻判断该在原有结构上改哪里,而不是推倒重来。
大部分人卡在第一档到第二档之间,原因是练习方式不对:只看不写,或者只写“标准答案型”的题。标准答案型题目有个特点,代码写出来和书上几乎一样,脑子全程不需要做判断。真正能提升手感的是带边界的题:差一个等号就错、少一次迭代就漏、多一层缩进就崩的那种。做完之后你会被迫去想“为什么是i+1不是i”,这个“想”的过程才是练习的价值所在。
1.2 先练for再练while,顺序别搞反
我见过不少人一上来就练while,理由是“while更自由”。自由恰恰是问题。while把“什么时候停”这个责任完全交给人,条件写错就是死循环,循环变量忘了推进也是死循环,而初学者的调试能力往往还不足以快速定位死循环的原因,结果就是对着一个卡住的终端发呆,学习热情直接被浇灭。
for相反,迭代次数和迭代源是绑定在一起的,range(1, 10)就是9次,不会多也不会少,出错的空间被压缩到“range参数写对没有”这一件事上。所以我的建议很明确:先用for把“循环体被执行N次”这件事练到不需要思考,再回头练while。等你能凭直觉写出for i in range(len(data))并且清楚它为什么等价于遍历索引,while的那些坑才有能力接住。
提示:判断自己是否真的掌握
for,有个简单的自测方法——不看资料,说出range(2, 11, 3)会产出哪几个数。答案是2、5、8。如果你需要默念一遍才能确认,说明右开区间这个概念还没变成肌肉记忆。
2. 用三道热身题把range的边界感砸实
2.1 打印九九乘法表的左下三角:range右边界最容易错的地方
九九乘法表是个老掉牙的题目,但它的价值在于内层循环的右边界是动态的,这正好是range最容易出错的地方。要求是打印左下三角,也就是第1行只有1列,第2行有2列,一直到第9行有9列。
for i in range(1, 10): for j in range(1, i + 1): print(f"{j}x{i}={i * j}", end="\t") print()关键在于range(1, i + 1)这个+1。因为range是左闭右开,range(1, i)只能取到i-1,第i行就少打印一列。初学者在这里的典型错误是写成range(1, i),然后盯着输出看了半天,发现每行都比预期少一格,却又说不出少在哪。更隐蔽的错误是外层写成range(1, 9),结果整个表少了第9行,因为9被右开区间吃掉了。
我给这道题加过一个变体:把输出改成右上三角,也就是第1行9列、第9行1列。这时候内层循环要写成range(i, 10),空格用"\t" * (i - 1)补齐。同一个range,边界从“跟着外层变大”翻转成“跟着外层变小”,做完这一正一反,右开区间的直觉基本就建立了。
2.2 累加与阶乘:初始值为什么必须是0和1
求1到100的和,代码短得不能再短:
total = 0 for n in range(1, 101): total += n print(total) # 5050但这里有个值得停下来想一秒的问题:total为什么初始化为0?因为加法有单位元0,任何数加0不变。如果初始化成total = 1,结果会多1。同样地,求阶乘n!的累乘变量必须初始化为1而不是0,因为乘法单位元是1,初始化为0的话,0 * 任何数永远是0,循环跑完结果还是0。
这个道理说起来像废话,但我在实际代码里见过不少次“累加变量没初始化就被+=”的错误,Python会直接抛NameError,反而好查;真正难查的是语义错了但语法没错的情况,比如统计商品总价时把累加变量初始化成了第一个商品的价格,循环里又从头加了一遍,第一个商品被算了两次。练习时我建议养成一个习惯:写累加之前,先问自己“如果循环一次都不执行,结果应该是多少”,这个值就是初始值。这个习惯比记住“0和1”更有用。
2.3 反向遍历:负数步长和空range的静默失败
range的第三个参数可以是负数,range(10, 0, -1)会产出10到1共10个数。这道练习的价值在于让你体验“range可以什么都不产出”——比如range(10, 0, 1),起点大于终点但步长为正,结果是空的,循环体一次都不执行,也不会报错。
for i in range(10, 0, -1): print(i, end=" ") # 输出:10 9 8 7 6 5 4 3 2 1我习惯在练习里塞一个“倒着打印列表但不修改原列表”的小任务:给一个列表,从最后一个元素打印到第一个。写法有两种,for i in range(len(data) - 1, -1, -1)或者for item in reversed(data)。前者的边界最容易写错:终点必须是-1而不是0,否则第一个元素打印不到,因为右开区间会把它排除掉。这种“差一个”的错误不会报错,只会静默地少一行输出,只能靠对照预期输出来发现。练的时候一定要养成先写下预期输出再运行的习惯,否则你根本不知道自己有没有错。
3. while的重点从来不是语法,而是谁负责推进循环变量
3.1 猜数字:循环条件写在哪比怎么写更重要
while的结构是“条件为真就继续”,但真实需求往往不是“某个变量满足条件”,而是“用户还没猜对”。这两种描述对应两种完全不同的写法。
import random target = random.randint(1, 100) count = 0 guess = 0 while guess != target: guess = int(input("猜一个1到100的数:")) count += 1 if guess > target: print("大了") elif guess < target: print("小了") print(f"猜对了,总共用了{count}次")这里我把guess初始化成0,因为random.randint(1, 100)保证目标值不可能是0,循环一定能进得去。但仔细想,这个初始化是有讲究的:如果目标值可能是0,这个条件就永远为假,循环直接跳过。while的前置状态必须让条件为真,这是很多人第一次写while时想不到的点。更稳妥的写法是while True配合break,把“什么时候退出”这件事放在循环体内部判断,前置状态就完全不需要操心了。
3.2 while True加break:看起来偷懒,其实是最稳的写法
接着上面的例子,改写成:
import random target = random.randint(1, 100) count = 0 while True: guess = int(input("猜一个1到100的数:")) count += 1 if guess == target: print(f"猜对了,用了{count}次") break print("大了" if guess > target else "小了")这么写的逻辑更贴近人的思维:先做事,做完判断要不要停。而while guess != target的写法要求你在进循环之前先想清楚初始状态,多了一个需要维护的变量。我在实际项目里处理“读文件直到读完”“接收消息直到收到结束标志”这类场景时,几乎都用while True加break,因为退出条件往往要在循环体中间才能判断出来,硬塞到while条件里会让代码变形。
注意:
while True不等于死循环,前提是循环体里存在一条能走到break的路径。写完一定要问自己:所有分支走一遍,有没有哪条路径永远碰不到break?输入类型错误、抛出异常这类情况也要考虑,int(input())遇到非数字会直接抛异常退出,反而是安全的。
3.3 用整除代替浮点:除到1为止的边界处理
这道题我管它叫“数字收敛”:给一个正整数,偶数就除以2,奇数就乘3加1,问多少步之后变成1。写起来是这样:
n = 27 steps = 0 while n > 1: if n % 2 == 0: n //= 2 else: n = 3 * n + 1 steps += 1 print(steps) # 111这里有两个坑。第一是除法必须用//而不是/。用/的话n会变成浮点数,n % 2 == 0的判断虽然还能用,但数值一大就会出现精度问题,而且n > 1在浮点下可能出现1.0000000000000002这种值,循环停不下来。第二是循环条件是n > 1而不是n != 1,因为如果哪一步意外跳过了1,!= 1会让循环永远继续,而> 1至少能保证单调收敛的方向。这个区别在正常数学下看不出,但在调试阶段能救命。
4. break、continue、else:三个被严重低估的关键字
4.1 for-else的语义,比if-else陌生得多
for和while可以带else子句,这个else在循环自然结束时执行,如果中途被break打断就不执行。判断质数是它最经典的用法:
def is_prime(n): if n < 2: return False for i in range(2, int(n ** 0.5) + 1): if n % i == 0: return False return True这是用return提前退出的写法。换成for-else:
n = 97 for i in range(2, int(n ** 0.5) + 1): if n % i == 0: print(f"{n}不是质数") break else: print(f"{n}是质数")else块只在“循环把所有i试完都没break”的情况下执行,语义正好就是“找不到因子”。但我必须提醒:for-else的可读性对不熟悉的人很不友好,团队协作时我更倾向于用显式的标志变量或者函数封装。练习时用它来理解语义没问题,写进生产代码前先看看同事会不会在review里问你“这个else是挂在哪个if上”。
还有一点很多人没注意到:如果循环体内有continue,else照样会执行,因为continue不是break。而如果循环一次都没执行(比如range(2, 2)是空的),else也会执行。这两条组合起来,用for-else判断质数时,n = 1这种小于2的情况必须单独拦掉,否则会被误判成质数。
4.2 continue在while里的陷阱:计数器被跳过
continue的作用是跳过本次循环剩下的语句,直接进入下一次。在for里它很安全,因为迭代由range或迭代器接管,你不推进它也会推进。但在while里,推进循环变量的语句如果放在continue之后,就永远不会执行:
i = 0 while i < 10: if i % 2 == 0: continue print(i) i += 1这段代码会卡在i = 0上死循环,因为0 % 2 == 0成立,continue直接跳到下一轮,i += 1永远轮不到。修法有两个:把i += 1提到循环体最前面,或者用for改写。我更推荐后者:
for i in range(10): if i % 2 == 0: continue print(i, end=" ") # 输出:1 3 5 7 9for天然免疫这个问题,这也是我前面说“先练for”的原因之一。凡是能用for表达的循环,就不要用while硬写,这不是风格问题,是错误率问题。
4.3 嵌套循环里的break只跳一层
这个是新手最容易误解的:break只跳出当前这一层循环,外层的循环不受影响。
for i in range(3): for j in range(3): if j == 1: break print(i, j) # 输出:(0, 0) (1, 0) (2, 0)外层i照样从0跑到2,内层每次都在j == 1时被掐断。如果需求是“一旦找到就整体退出”,需要用标志变量或者干脆写成函数用return:
def find_first(matrix, target): for row in matrix: for value in row: if value == target: return True return Falsereturn天然穿透所有层,这是函数封装最实在的好处之一。我在实际代码里看到过用for-else嵌套来模拟多层跳出的写法,能跑,但读的人要在脑子里绕三圈,不值得。
5. 嵌套循环:从打印图形到处理二维数据
5.1 星号金字塔:空格数和星号数的推导过程
打印图形是练嵌套循环最直接的方式,因为输出对错一眼就能看出来。以5行金字塔为例:
rows = 5 for i in range(1, rows + 1): spaces = rows - i stars = 2 * i - 1 print(" " * spaces + "*" * stars)关键在于先把两个数量关系推出来,而不是试。第i行(从1开始)的星号数是2i - 1,这是奇数序列1、3、5、7、9。空格数要让金字塔居中,最后一行空格为0,往上每行多一个,所以是rows - i。推导清楚之后代码就是直接翻译,一行都不用试。
如果不想推公式,还有一种思路是找“中心列”:固定宽度2 * rows - 1,每一行的星号分布在中心两侧i - 1的范围内。这种写法更通用,画其他形状也套得上。两种思路我都让人练过,前一种训练数学直觉,后一种训练通用性,各有各的用处。
5.2 二维列表遍历:索引和外层变量什么时候需要对上
处理表格数据时,双层for几乎是标配。给一个矩阵,要求打印每个元素及其坐标:
matrix = [ [1, 2, 3], [4, 5, 6], [7, 8, 9], ] for i, row in enumerate(matrix): for j, value in enumerate(row): print(f"({i},{j})={value}", end=" ") print()这里用enumerate而不是range(len(...)),原因是可读性。range(len(data))这种写法在需要同时修改元素时才必要,比如matrix[i][j] *= 2;如果只是读,enumerate或者直接for row in matrix更清爽。我见过不少人习惯了for i in range(len(x)),写什么都带个索引,结果代码里全是data[i],读起来很累。
有一个隐性坑:如果内层循环要修改元素,必须用索引访问,for value in row拿到的是元素的副本(对不可变对象而言),改value不会影响原列表。这一点在练二维数组的时候要专门验证一次,否则很容易写出“循环跑完了但数据没变”的代码。
5.3 冒泡排序:把嵌套循环练成肌肉记忆
冒泡排序是嵌套循环的经典练习,它的价值不在于排序本身(实际项目里都用sorted或.sort()),而在于两层循环的边界是相互依赖的:
data = [5, 2, 9, 1, 7, 3] n = len(data) for i in range(n - 1): swapped = False for j in range(n - 1 - i): if data[j] > data[j + 1]: data[j], data[j + 1] = data[j + 1], data[j] swapped = True if not swapped: break print(data) # [1, 2, 3, 5, 7, 9]内层的上界是n - 1 - i,因为每跑完一轮,最大的元素就被推到了末尾,下一轮不需要再比较它。外层是n - 1轮,因为n个元素排好n - 1个,最后一个自然就位。swapped标志是提前退出的优化,如果某一轮一次交换都没发生,说明已经有序,直接跳出。
我在练这道题时犯过一个错:把swapped = False写在两层循环之间而不是外层循环内部,结果第一轮之后swapped永远是True,提前退出逻辑完全失效。这类错误不看代码是发现不了的,只能靠在循环里打一行print看每轮的中间状态来定位。这也是我接下来要讲的调试方法。
6. 循环里最容易踩的五个坑,我基本都踩过
6.1 边遍历边删除:列表长度在你脚下变化
nums = [1, 2, 2, 3, 2, 4] for n in nums: if n == 2: nums.remove(n) print(nums) # [1, 2, 3, 4]结果里还剩一个2,原因是遍历用的是内部索引,删掉一个元素之后后面的元素整体前移,迭代器跳过了一个。这个坑的隐蔽之处在于它不报错,只是结果不对。正确做法有三种:列表推导[n for n in nums if n != 2]、倒序索引删除for i in range(len(nums) - 1, -1, -1)、或者while循环配索引手动控制。列表推导最干净,我基本只用这一种。
同样的坑在字典上也存在:遍历字典的同时增删键会抛RuntimeError: dictionary changed size during iteration,这个至少会报错,比列表好查。
6.2 循环变量在循环外还有值
Python的for循环变量在循环结束后仍然存在,值是最后一次迭代的值;如果循环一次都没执行,变量根本不存在。
for i in range(5): pass print(i) # 4,不报错 for j in range(0): pass print(j) # NameError这个行为和C、Java不一样,从那些语言转过来的人容易在这里翻车:以为循环结束变量就失效了,结果在循环外引用到了脏值。我在代码里从不在循环外使用循环变量,即使要用也会换个名字重新赋值,避免这种隐式依赖。
6.3 浮点数累加的精度偏差
total = 0 for _ in range(10): total += 0.1 print(total) # 0.99999999999999990.1在二进制浮点里不能精确表示,累加十次自然会偏。这不是Python的问题,所有用IEEE 754浮点的语言都这样。涉及金额时我一定用decimal.Decimal或者干脆以“分”为单位用整数计算。练习阶段知道这个现象就够了,真正踩到是在做账单、对账这类功能的时候。
6.4 range的参数必须是整数
n = input("请输入行数:") for i in range(n): # TypeError: 'str' object cannot be interpreted as an integer print("*" * i)input返回字符串,range只接受整数。改成range(int(n))就行。这里顺带一提,int()遇到非数字输入会抛ValueError,稍微正式一点的练习里应该加try-except,或者至少用.isdigit()先判断一下。
6.5 缩进层级错一行,语义完全变了
total = 0 for i in range(1, 6): total += i print(total)和
total = 0 for i in range(1, 6): total += i print(total)前者每次迭代都打印一次,输出1、3、6、10、15;后者只打印最终结果15。Python靠缩进划分代码块,这类错误在语法上完全合法,只能靠看输出发现。我建议一开始就用支持缩进参考线的编辑器,能少很多这类低级错误。
7. 练习怎么越做越有效:三个提高效率的做法
7.1 给每道题加一条约束条件
光把题做出来,收益是有限的。同样一道“求列表最大值”的题,加一条约束“不能用内置的max”,你就得自己写for加比较;再加一条“列表可能是空的”,你就得考虑初始值和边界;再加一条“列表里可能全是负数”,你就得反思初始值能不能设成0。一条约束就是一层新的思考,比换十道同难度的题管用。
我给自己定过一个规则:每道题做完,至少加一条约束重做一遍。比如九九乘法表加“只能用一层循环”,你就得用字符串拼接配合format;冒泡排序加“不能改动原列表”,就得先copy一份。这个过程会把原来“背下来”的解法重新拆开,理解层次立刻不一样。
7.2 循环调试三板斧:打印、打印、再打印
循环出错时,最有效的调试手段永远是在循环体内打印中间变量。不要指望用调试器一步步入,初学阶段对调试器不熟,反而更慢。我的习惯是三步:
第一步,在循环体第一行打印循环变量的当前值,确认迭代次数对不对。第二步,在每个if分支里打印走了哪个分支,确认条件判断有没有写反。第三步,在循环结束后打印最终结果,和手算的预期值对比。三步下来,九成的问题都能定位。
for i in range(1, 6): print(f"[调试] i={i}") total += i print(f"[调试] total={total}")调试信息记得加统一前缀,方便定位,也方便调完一次性删掉。用print调试不丢人,我到现在写复杂循环时也这么干。
7.3 把练习改成小工具,让代码有去处
纯练习的代码写完就扔,记忆留存率其实不高。真正记得牢的,是那些被反复用到的代码。所以我建议把练习往“小工具”方向改:猜数字改成能选择难度区间的版本;质数判断改成能输出某个范围内所有质数的版本;冒泡排序改成能按字典某个键排序的版本。改完之后放进一个自己的脚本文件里,下次要用直接翻出来。
我自己有个叫practice.py的文件,里面攒了几十个小函数,从is_prime到“统计一段文本里每个单词出现的次数”,都是练习时写的。写项目时遇到类似需求,经常能直接搬过去改两行就用。这种“练习有去处”的感觉,比任何学习计划都更能让人坚持下去。
最后分享一个我自己一直在用的检验方法:任何一段循环代码写完,先不运行,拿张纸把前三次迭代和最后一次迭代的变量值写出来,再运行对答案。对得上,说明思路清楚;对不上,说明某个边界你其实没想明白。这个方法比起直接运行然后看结果猜原因,效率高得多,尤其是嵌套循环,手推一遍之后对代码的理解会完全不一样。