1. 为什么学完条件判断,必须马上碰循环这块硬骨头
先直接说结论:循环是让程序从"做一件事"变成"做一万件事"的分水岭。你前面的变量、数据类型、运算符,包括刚学完的条件判断,本质上都是在指挥程序"单次执行"。哪怕 if 再复杂,程序跑一遍也就走完一条路。可真实场景里,几乎没有哪个需求是"只干一次"就能收工的:批量改文件名、轮询接口状态、累加报表里的每一行、遍历数组找最大最小值……这些活儿全靠循环撑起来。
我当年学这部分的时候,教材用的也是这种章节号——"3.6 控制"下面细分了顺序、选择、循环。当时只觉得"控制"这个词很抽象,后来写多了才明白:程序运行的控制权,其实就掌握在顺序(按行走)、条件(挑路走)和循环(绕圈走)这三板斧手里。而循环之所以放最后讲,是因为它的心智模型和前面完全不同:前面是"顺流而下",循环是"带着状态反复回到同一条河"。观念不转过来,代码一出错就是死循环或者少跑几轮这种非常隐蔽的逻辑病。
这篇笔记不是照抄教材定义,而是站在"我踩过坑、回头补课"的角度,把 3.6.7 循环这一节拆开揉碎。适合正学到控制流章节的初学者,也适合那种"看教材能看懂、自己写就卡壳"的复习型选手。我会把循环的三要素、几种循环语句的选择逻辑、循环控制语句的使用边界,以及最让人头疼的嵌套和死循环问题,全部用代码和实际场景过一遍。看完你应该能回答三个问题:什么时候用 while,什么时候用 for,break 和 continue 到底怎么选。
先说个从小白到老手都会反复踩的底层误区:很多人以为循环的关键字是核心,其实循环的条件才是灵魂。关键字只是形式,条件写错了,程序要么不干活,要么干起活来停不住。这个观点整篇都会反复提到。
2. 循环的本质就一句话:三要素驱动的重复执行
2.1 拆穿循环的"底层套路":初始化、条件判断、迭代更新
任何循环,不管披着什么语法外衣,内里永远是一个三角结构在转。
- 初始化(起点):循环变量第一次取值是多少。比如
i = 0,或者total = 0。 - 条件判断(闸门):每次进入循环体之前都要问一次"现在这状态还能继续吗?"满足才进,不满足就出去。
- 迭代更新(推进):每次执行完循环体,改变循环变量的值,好让条件判断有机会变成假。比如
i++或者total += item。
我用一个最直白的场景说明:你手上有 10 个快递要逐个确认签收。初始化就是你从第 1 个开始;条件判断是"手上的快递还有没有剩余";迭代更新是你签收完一个,把待办数量减 1。三步缺了哪一步,活儿都干不利索。缺初始化,你根本不知道从哪儿开始;缺条件,你会无限签收下去;缺更新,你永远卡在第 1 个快递上原地打转。
对应到代码里,最常见的就是这种模式:
i = 0 # 1. 初始化 while i < 10: # 2. 条件判断 print(i) # 循环体 i += 1 # 3. 迭代更新这里 i 从 0 开始,每次加 1,直到 i = 10 时不满足i < 10,循环结束。流程上等价于:一问一答、干活、往前挪一步,再问再答。
2.2 一个反直觉的真相:循环体里改了条件变量会怎样
上面这个三角结构看着人畜无害,实际写起代码来,新手最爱犯的一个错就是把迭代更新藏到循环体中间甚至条件判断里。比如下面这段:
i = 0 while i < 10: i += 1 if i == 5: i += 2 # 中途额外改了循环变量 print(i)你预期的输出可能是 0 到 9,实际输出是:1、2、3、4、5、8、9、10。因为 i 在 i == 5 的时候被额外加了 2,直接跳过了 6 和 7,而且最终多打印了个 10。程序没报错,逻辑也没死循环,但结果就是不对。这种问题最难排查,因为语法全对,纯粹是"循环变量被劫持"了。
我个人的建议是:循环变量只在迭代更新的位置动它,循环体内部不要轻易修改。如果确实需要根据某些条件提前跳过或加速,优先考虑 break 和 continue,而不是直接动循环变量。关于这两个关键字,后面专门有一节细聊。
3. while、for、do-while:三种循环各自的脾气和使用边界
3.1 用哪个循环,取决于"你知不知道要循环多少次"
教材里会把循环分成好几种,很多初学者背了语法却分不清应用场景,最后全都用 while 硬扛。其实选择逻辑特别简单:循环次数事先能否确定。
- 如果事先明确知道要执行几轮,比如遍历一个长度为 n 的数组,首选 for。
- 如果只知道结束条件,不知道具体轮数,比如不断读取用户输入直到输入"退出",首选 while。
- 如果无论如何都要先执行一次循环体,再判断是否继续,那就需要 do-while(或者用 while True 加 break 模拟)。
用 Python 举例,for 的典型形态是配合 range 或者直接遍历容器:
# 明确知道跑 100 次 for i in range(100): print(f"第 {i} 次") # 明确要遍历列表里的每一项 names = ["A同学", "B同学", "C同学"] for name in names: print(name)while 的典型场景则是"条件驱动":
# 不知道要输入多少次,直到输入 stop cmd = "" while cmd != "stop": cmd = input("请输入命令(输入 stop 退出):") print("你输入的是:", cmd)3.2 三种语言实现同一个场景,看完就知道共性
为了把"用哪个循环"这个问题讲透,我用三种编程风格写同一个需求:求 1 加到 100 的和。你会发现底层三要素完全一致,只是语法长相不同。
Python 风格:
total = 0 for i in range(1, 101): total += i print(total) # 5050C 风格:
int total = 0; for (int i = 1; i <= 100; i++) { total += i; } printf("%d\n", total);JavaScript 风格:
let total = 0; for (let i = 1; i <= 100; i++) { total += i; } console.log(total);看到没?初始化(i = 1)、条件(i <= 100)、更新(i++)永远整整齐齐排在那里。你只要抓住这个共性,换任何语言都只是换皮不换骨。
3.3 什么时候该用 while True + break 这种"野路子"
有同学问:如果循环条件比较复杂,能不能直接在 while 后面写一长串判断?能,但不推荐。条件一旦复杂,读代码的人要停下来算半天,而且很容易漏掉边界。
更稳妥的做法是"主循环用 while True,出口用 break 显式控制"。这个模式在实战中非常常见,比如菜单程序:
while True: choice = input("请选择:1.查询 2.退出:") if choice == "2": print("已退出") break # 其他业务处理这么写的理由很朴素:你把"什么情况下退出"这个最关键的判断,用一行 break 明明白白写在循环体里,任何人一看就懂。相比在 while 条件里堆逻辑,这种写法把复杂度摊平了。很多资深开发者写服务端的主循环就是这么干的。
4. 循环控制的两个出口:break 和 continue 千万别用反
4.1 用真实业务场景拆解 break 和 continue 的区别
break 的意思是“彻底跳出整个循环”,continue 的意思是“结束当前这一轮,直接进入下一轮”。一字之差,效果差出十万八千里。
我拿查成绩的场景举例:假设你有一个学生成绩列表,你要做两件事。
第一件事:找到第一个及格的学生就停
scores = [55, 62, 48, 90, 73] for score in scores: if score >= 60: print("第一个及格分数是:", score) break # 找到就立刻退出,后面 90、73 不再看第二件事:跳过不及格的,只打印及格的
scores = [55, 62, 48, 90, 73] for score in scores: if score < 60: continue # 不及格,本轮直接结束,不打印 print("及格分数:", score)这两个代码长得像,结果完全不同。break 是中断器,continue 是过滤器。写错的最常见后果是:该提前结束的继续傻跑,白白浪费资源;该过滤掉的继续执行后续代码,输出一堆脏数据。
4.2 break 只跳一层,这个坑让嵌套循环结果错得离谱
如果循环被嵌套成两层甚至三层,break 的行为需要特别小心:break 只能跳出离它最近的那一层循环。很多人以为 break 会把所有循环全停掉,这是天大的误会。
看一个实际例子:找出二维矩阵里第一个小于 0 的元素
matrix = [ [1, 2, 3], [4, -5, 6], [7, 8, 9] ] for row in matrix: for item in row: if item < 0: print("找到负数:", item) break # 只跳出内层循环,外层还会继续走这个 break 一执行,内层循环结束,但外层循环会接着处理下一行 [7, 8, 9]。如果你只想"找第一个就停",这种写法就会在后面多一些无用输出。解决办法也不复杂,用一个标志变量把外层也拦住:
found = False for row in matrix: if found: break for item in row: if item < 0: print("找到负数:", item) found = True break或者用 Python 的 for-else 结构,循环没被 break 打断才进入 else 分支,这个技巧很冷门但很好用。无论如何,记住一条经验:嵌套循环里写 break,先问自己一句——我到底想跳出哪一层?
4.3 continue 遇到 while 时的暗坑:小心迭代更新被跳过
continue 在 for 循环里用得很顺手,但放到 while 循环里会埋一个非常隐蔽的雷。看下面这段代码,你猜会发生什么:
i = 0 while i < 10: if i % 2 == 0: continue # 偶数就跳过本轮 print(i) i += 1这段代码一旦运行,程序会卡死。为什么?因为当 i 为偶数时,continue 直接把后续的i += 1也跳过了,i 一直保持偶数值不变,条件i < 10永远为真,死循环了。
这类 bug 的可怕之处在于:它既不报错,也不闪退,程序就像睡着了一样,只有 CPU 风扇在转。排查起来也很费劲,因为看起来"逻辑很顺畅"。我的建议是:while 循环里尽量把迭代更新放在循环体最前面,或者干脆避免在 continue 之后再更新变量。更保险的做法是用 for 循环替代 while,因为 for 的迭代更新由循环机制本身管理,不容易被 continue 跳过。
5. 嵌套循环的实战套路:从冒泡排序到遍历表格
5.1 为什么嵌套循环理解难度陡增
如果一个循环解决的是"一维问题",嵌套循环解决的就是"二维问题"。比如你面前有一列快递(一维数组),一个循环就能遍历完;但如果是 10 排货架、每排 10 个柜子(二维结构),就要两层循环:外层管排数,内层管柜子号。
嵌套循环的难点不在语法,在于你要时刻想清楚:当前这层循环在遍历什么维度。一旦维度混了,结果就全乱了。举个最经典的例子——打印乘法表:
for i in range(1, 10): # 外层:控制行 for j in range(1, i + 1): # 内层:控制列,且列数随行数变化 print(f"{j}x{i}={i * j}", end=" ") print() # 换行外层 i 从 1 到 9,内层 j 从 1 到 i。内层范围依赖外层的当前值,这就让两层之间产生了"联动"。理解了这个联动,你才算真正会写嵌套循环。
5.2 一个排序的例子,把嵌套循环从根上吃透
不纸上谈兵,拿最基础的冒泡排序来实操。冒泡排序之所以非常适合学习嵌套循环,是因为它的内层循环完全是受外层控制的。排序的思路是:每一轮把最大的元素"冒泡"到末尾,然后下一轮不再管它。
nums = [5, 2, 9, 1, 7] n = len(nums) for i in range(n - 1): # 外层:总共需要多少轮 for j in range(n - 1 - i): # 内层:每轮比较的次数越来越少 if nums[j] > nums[j + 1]: nums[j], nums[j + 1] = nums[j + 1], nums[j] print(nums) # [1, 2, 5, 7, 9]外层 i 控制"还剩多少元素没排好",内层 j 控制"当前轮需要相邻比较的范围"。i = 0 时,内层要比较 n-1 次;i = 1 时,因为最大的已经到末尾,只需比较 n-2 次。这就是为什么内层范围是n - 1 - i。
你去观察整个排序过程,会明显感受到"外层定大局、内层做细节"的分工。以后无论你写矩阵转置、遍历二维列表、还是生成坐标对,嵌套循环的心智模型都是这样:外层定维度,内层做穷举。
5.3 嵌套循环的性能代价:先学会算执行次数
学嵌套循环还有一个不得不提的话题——执行次数。很多程序员写代码时对性能没概念,直到数据量大起来才崩。假设外层跑 n 次、内层跑 m 次,总执行次数大约就是 n × m。如果 n 和 m 都是 10000,那就是 1 亿次循环体执行,这个量级在纯解释型语言里基本可以感受到明显卡顿。
一个实用的经验是:能不嵌套就不嵌套,能缩小内层范围就缩小范围。比如上面冒泡排序的内层范围就是n - 1 - i,比每次固定跑n - 1少了一半的无效比较。别小看这个优化,数据量一上来,运行时间的差距是数量级的。
6. 循环学习中最容易翻车的四个场景与排查方法
6.1 死循环:程序不报错,但就是停不下来
死循环的成因非常简单:循环条件永远为真。最常见的三个原因,我列一个排查清单:
| 原因 | 典型代码 | 排查思路 |
|---|---|---|
| 忘记写迭代更新 | while i < 10:但循环体里没有i += 1 | 检查循环变量是否随每次执行发生变化 |
| 迭代更新被 continue 跳过 | while 里 continue 出现在 i += 1 之前 | 把更新语句放到 continue 前面 |
| 条件方向写反 | while i > 0但 i 初始就是 0 | 手推一遍初始值和更新方向 |
死循环在开发环境里还好,顶多卡死重启;在线上环境一旦发生,服务直接失去响应,是非常严重的事故。学循环的第一天,就该学会"如何快速发现死循环":观察程序是否在某个位置长时间不输出、CPU 占用率是否飙高。必要时在循环体最前面加一个临时打印,看它有没有在迭代。
6.2 差一错误:循环次数和预期总是差 1
差一错误(off-by-one)是循环领域最经典的逻辑 bug。我写个最简单的例子:想打印 5 次"你好",写成for i in range(1, 5),跑出来只有 4 次。因为 range 是左闭右开区间,包含 1 但不包含 5。
类似的还有遍历数组时,数组长度为 n,下标是 0 到 n-1。如果你在循环里用< n还是<= n没想清楚,要么漏掉最后一个元素,要么下标越界报错。
我的个人习惯是:在写循环之前,先用最简单的小样本(比如 n=3)手动推演一遍。不要嫌麻烦,这个习惯能挡掉一半以上的循环 bug。比如你想遍历长度为 5 的列表,就先把下标 0、1、2、3、4 在草稿纸上过一遍,确认循环条件写成i < 5而不是i <= 5。
6.3 修改被遍历的列表:迭代器和原始数据互相干扰
这是进阶型坑,但学习笔记里必须提。循环遍历一个列表的同时,如果又在循环体里对这个列表进行增删操作,结果会变得非常诡异。很多初学 Python 的人写过这样的代码:
nums = [1, 2, 3, 4, 5] for num in nums: if num % 2 == 0: nums.remove(num) # 边遍历边删这段代码的结果是 [1, 3, 5] 吗?不是。实际跑出来很可能是 [1, 3, 4, 5]——甚至在不同 Python 版本里行为还不完全一致。原因在于遍历过程中,列表的索引和内容一直在变,迭代器完全被打乱了。
解决思路有几个:一是遍历副本,删原列表;二是用列表推导式直接生成新列表;三是记录需要删除的下标,循环结束后统一处理。我推荐第二种,代码最简洁:
nums = [1, 2, 3, 4, 5] nums = [num for num in nums if num % 2 != 0]6.4 循环里新建容器却放在循环外面:数据串了的惨案
最后这个坑很隐蔽:循环体里需要独立累积的数据,结果容器在循环外面初始化,导致每一轮的数据都堆在一起,互相覆盖。看个实际场景:
groups = [] for i in range(3): temp = [] # 应该在循环内初始化 for j in range(3): temp.append(i * 3 + j) groups.append(temp)如果把temp = []提到循环外面,每次 append 的其实是同一个列表对象的引用,最后 groups 里全是同一个列表的内容。这种 bug 在初学阶段非常容易遇到,而且排查难度不小,因为单看打印结果会觉得"数据都对,只是重复了"。
经验教训就一条:每一轮循环需要独立的数据容器,初始化代码一定放在循环体内部。想清楚"这个变量是只需要一份,还是每轮各要一份"。前者放外面,后者放里面。
7. 一个完整案例:用循环实现月度账单统计
7.1 问题描述:拿到一堆流水,要按天汇总成报表
理论讲了不少,最后用一个贴近实际的完整案例串起来。假设你拿到一个月的消费流水清单,格式是"日期, 金额"的文本行。需求是:按日期分组,计算每天的总支出,同时统计哪些天支出超过 500 元。
这个任务天然适合循环:外层按天遍历,内层对同一天的流水累加。放到真实开发里,这就是一个简化版的报表引擎。
先构造流水数据:
records = [ ("05-01", 120), ("05-01", 350), ("05-02", 80), ("05-02", 230), ("05-02", 310), ("05-03", 600), ("05-03", 45), ("05-04", 520) ]7.2 第一次实现:用嵌套循环和条件判断完成统计
使用嵌套循环实现,外层遍历日期,内层遍历所有流水,遇到匹配日期就累加。这样可以保证每一轮独立统计,逻辑层次非常清晰。
dates = [] for record in records: if record[0] not in dates: dates.append(record[0]) for date in dates: total = 0 for record in records: if record[0] == date: total += record[1] if total > 500: print(f"{date} 总支出 {total} 元,超预算") else: print(f"{date} 总支出 {total} 元")这里用到了两层循环和一个 if 判断。输出结果里,"05-03" 和 "05-04" 会标记为超预算,因为它们的日总额都超过了 500 元。这个写法很好懂,缺点是每次都要从第一条记录遍历到最后一务,流水一多就低效。
7.3 第二次优化:用字典配合单层循环提升效率
用嵌套循环能解决问题,但不够优雅。第二次优化用字典来聚合,只跑一次循环,效率高很多。这个思路在工作中非常常见:用散列结构替代内层循环。
daily_total = {} for date, amount in records: if date in daily_total: daily_total[date] += amount else: daily_total[date] = amount for date, total in daily_total.items(): if total > 500: print(f"{date} 总支出 {total} 元,超预算") else: print(f"{date} 总支出 {total} 元")这个版本的循环次数从"日期数 × 流水数"降到了"流水数",代码也更短,语义更清晰。如果你熟悉 Python 的字典操作,还可以用defaultdict或者dict.setdefault进一步精简。不过核心思想没变:能用单层循环加映射解决的,就别用嵌套循环硬凑。
7.4 复盘这个案例:循环思维的三步训练法
这个案例完整走下来,其实是一个很好的循环思维训练。拆开看是三个步骤:
- 拆数据维度。流水数据有两个维度:日期和金额。先明确你要处理的是"按日期聚合",所以外层循环必定是日期列表。
- 选循环结构。日期的数量事先不确定,因此外层用遍历式循环;金额的累加需要反复执行,因此内层是累加操作。整个流程是"分组-累加-判断"。
- 优化循环层数。第一版嵌套循环是对的但不够精。第二版引入了映射结构,把内层循环消掉了。
这套三步法既适合学习,也适合工作。真的遇到数据量大的时候,先从最直觉的写法开始,保证正确,然后再想着把内层循环换成字典或集合。千万不要一上来就追求最优解,那是经验堆出来的,不是推理推出来的。
8. 写在最后的经验之谈
循环这块内容,教材上篇幅不多,但它值得你拿出几倍的时间去反复试错。我见过不少同学前面学得飞快,到循环这里就卡住了,原因不是笨,而是循环的调试手段和前面的代码完全不是一个套路——它需要你在脑子里"代跑"程序的每一轮状态变化。这个能力只能靠练。
我的建议是给自己安排三组练习:第一组是各种循环的基本写法,目标是闭着眼睛写出 for、while、break、continue 的组合;第二组是纯逻辑题,比如冒泡排序、九九乘法表、求最大公约数,目标是训练嵌套循环和条件判断的配合;第三组是自己写一个小工具,比如上面的账单统计那样,从需求到代码完整走一遍。完成这三组,循环就算真正过门槛了。
最后分享一个常用的小技巧:调试循环的时候,别只在代码末尾打印一次结果,要打印每一轮的关键变量值,比如"第 i 轮、当前 total 是多少、条件判断结果是什么"。这一步对定位死循环、差一错误、变量被意外修改,效果立竿见影。等确认无问题了,再把这些调试打印删掉。这个小习惯帮我省下的排查时间,绝对可以按小时计算。