AI 技术博客
返回首页
Python 基础 · 17 分钟阅读

嵌套与组合:分支与循环配合的代码结构

## 引言 这是第三章第 6 篇,概念部分的组合收官篇——从本篇起,前 5 篇的语法零件将进入「组合运用」阶段。前 5 篇把零件都备齐了:分支(if/elif/else)、条件循环(while)、遍历(for/range)、循环控制(break/continue/else)。但真实程序几乎从不用单层零件——数据是二维的(表格、棋盘、图像像素矩阵),逻辑是多条件的(既要遍历又要过滤),控制流常常需要「在两层循环里提前退出」。本篇把这些零件**组合**起来,回答三个问题:嵌套循环的执行次数怎么算;二维数据如何用「外层行、内层列」的嵌套结构遍历;如何在多层循环里干净利落地提前退出(提前退出设计)。最后用「卫语句」(guard clause)这个工程上最重要的拍平技巧,讨论嵌套层数与可读性的平衡。 读者应先掌握:第 4 篇的 for/range/enumerate、第 5 篇的 break/continue/else(尤其是 for-else 触发规则)。本篇会频繁复用前两篇的结论——质数判断、搜索即 break、else 兜底。这是概念篇的最后一篇,第 7 篇的推导式将大量使用「双层循环压缩成一行」的写法,本篇的嵌套直觉正是它的前置。 ## 概念与原理 ### 嵌套循环的执行次数:乘法原理 「外层循环每迭代一轮,内层循环完整跑一遍」——这句话是整个嵌套结构的基石,它的执行次数可以直接推导:内层循环体每执行一次记 1,若外层跑 m 轮、每轮内层跑 n 次,则总次数 = m × n。这就是**乘法原理**。三层嵌套则是三数之积:三个各跑 10 次的循环嵌套,循环体执行 10 × 10 × 10 = 1000 次。由此得到一个必须内化的结论:**嵌套层数每加一层,执行次数通常乘一个规模因子**。`range(100)` 里套 `range(100)` 是 1 万次运算,再套一层是 100 万次——两层尚可、三层就开始感受到卡顿,这直接影响程序性能(课程 02 会把它上升为算法复杂度理论,本系列其他章也会不断回到这个直觉)。 注意一个特例:内层循环的范围依赖外层变量时(如九九乘法表内层只跑到 i),总次数不是简单的 m×n,而是 Σ(每轮内层次数),对乘法表来说是 1+2+…+9 = 45 次而不是 81 次。计算总次数时,要把「每轮内层的实际长度」逐轮相加,不能想当然地乘。 ### 二维数据的天然结构:列表的列表 表格、棋盘、像素、成绩矩阵,天然是「行 × 列」的结构,Python 里最直接的表示是**列表的列表**(list of lists):外层列表的每个元素是一行,行元素则是列值。遍历它的标准姿势是「外层循环取行、内层循环取列」: ```python matrix = [ [1, 2, 3], [4, 5, 6], ] for row in matrix: # 外层:每轮拿到一行 for v in row: # 内层:遍历这一行的每一列 print(v, end=" ") print() # 行末换行 ``` `matrix[0]` 是第一行、`matrix[0][1]` 是第 0 行第 1 列的元素——两个下标第一维是行、第二维是列,这个约定在整个数据科学体系里通用(NumPy、pandas 都是先行后列)。嵌套循环本质上就是把二维结构「展平」成访问顺序:先定行、再扫列。这也是为什么「外层行、内层列」的顺序约定俗成——想要按列处理(先列后行),就得交换两层循环的角色,在真实工程里(比如列式统计)这是最常见的嵌套用法变体。 ### 分支与循环的三种组合模式 循环体里嵌分支,有且仅有三种经典组合,理解了它们,嵌套代码的结构问题就解决了一半: - **筛选**(filter):循环里用 if 决定「这条要不要处理」,不要的 `continue` 跳过——典型的「脏数据清洗」; - **提前终止**(early exit):循环里用 if + break 决定「要不要立刻收工」,找到目标即停; - **汇总裁决**(aggregate decision):循环里用 if 更新累积变量或标志,循环结束后依据累积结果做最终判断——比如「有没有一票否决项」。 前两者已经在第 5 篇出现,本篇把它放进嵌套结构里重新审视;第三种是「flag 模式」,第 5 篇介绍的 for-else 正是它的结构性替代。组合之后,还有一个贯穿始终的工程原则:**每一步都要能回答「现在在哪一层、在干什么」**——嵌套代码出错,十有八九是层次感丢了。 ### 提前退出设计:双层循环的「连坐」问题 单层循环里 break 一次就完事;双层循环里这个问题升级了:内层 break 只能停内层,外层还会继续扫下一行——「找到目标却停不下来」的窘境。Python 没有 C 语言的 goto、也没有带标签的 break,退出双层循环的连坐手段主要有三种: 1. **函数 return**:把嵌套循环放进函数,找到目标时 `return (i, j)`——return 会退出整个函数,两层循环瞬间全部停摆。这是最干净、最推荐的做法; 2. **for-else 接力**:利用第 5 篇的规则——内层没 break 说明这行没结果,`else: continue` 让外层继续;内层 break 了则执行内层后的 `break` 退出外层。技巧性最强,但完全不引入新机制; 3. **标志变量**:内层找到时置 flag 并 break,外层检查 flag 再 break。能工作,但引入状态,可读性最差,是上一种的「退化版」。 一个实用判据:如果嵌套逻辑本身是可以独立命名的任务(「在矩阵里找负数的位置」),就把它放进函数用 return;如果只是脚本里的一段临时逻辑,用 for-else 接力。两者都在本篇实现部分给出实测。 ### 卫语句:用「提前走人」拍平缩进地狱 回到可读性。嵌套最怕的是**缩进地狱**(indentation hell):三层循环、两层 if,最深处的逻辑缩进六七八层,眼睛根本无从追踪。工程界的标准解法是**卫语句**(guard clause):把「不满足就提前退出」的检查写在前面,用 `continue`(循环内)或 `return`(函数内)直接跳过异常情况,让正常路径保持平铺。卫语句的本质是倒转思维:**先拦截例外,再处理常规**。`if not 合法: continue` 而不是把整个循环体包进 `if 合法:`——前者让每一层缩进都对应一个「还在处理正常数据」的循环层次,后者让正常逻辑越嵌越深。第 5 篇的「推进语句放 continue 之前」、本篇的「空行注释行前置拦截」,都是它的具体形态。这也是绝大多数 Python 风格指南(包括 PEP 8 精神下的社区最佳实践)推荐的结构。 ## 操作与实现 ### 九九乘法表:三角嵌套的第一个舞台 ```python for i in range(1, 10): for j in range(1, i + 1): # 内层只跑到 i:三角形 print(f"{j}×{i}={i * j}", end="\t") print() # 换一行 ``` 输出(前四行): ```text 1×1=1 1×2=2 2×2=4 1×3=3 2×3=6 3×3=9 1×4=4 2×4=8 3×4=12 4×4=16 ``` 内层范围 `range(1, i + 1)` 依赖外层变量 i,所以第 i 行只有 i 个算式,斜着看就是一条对角线。`end="\t"` 让每个算式后面跟一个制表符而不是换行,`print()` 在行末补一个换行——「一行内留空格、行末换行」的分工,是乘法表这类矩阵输出结构的定式。总执行次数是 1+2+…+9 = 45,符合乘法原理的「逐轮相加」特例。 ### 二维数据:行平均与总体统计 ```python matrix = [ [80, 85, 90], [70, 75, 72], [88, 92, 95], ] for row in matrix: row_sum = 0 # 关键:每行开始前重置累积变量 for v in row: row_sum += v print("行平均", row_sum / len(row)) ``` 输出: ```text 行平均 85.0 行平均 72.33333333333333 行平均 91.66666666666667 ``` `row_sum = 0` 写在**外层循环体内、内层循环之前**:每个新行开始统计前归零。这个位置是嵌套里最容易写错的一行,写错的结果见本节末尾的陷阱一。 再找一个全局最大值的位置——enumerate 与解包的第二次合作(第 4 篇预告过它们的高频用途): ```python matrix = [ [80, 85, 90], [70, 75, 72], [88, 92, 95], ] best = (-1, -1, -1) # (值, 行, 列) for i, row in enumerate(matrix): for j, v in enumerate(row): if v > best[0]: best = (v, i, j) print("最大值", best[0], "位置", (best[1], best[2])) ``` 输出:`最大值 95 位置 (2, 2)`。外层 enumerate 给行号、内层 enumerate 给列号,一个元组同时记录值、行、列——这正是一行「行列索引」信息在嵌套结构里的标准载体。 ### 提前退出设计一:函数 + return ```python def find_first_negative(grid): for i, row in enumerate(grid): for j, v in enumerate(row): if v < 0: return (i, j) # return 一次性退两层 return None # 全程没找到 grid = [[1, 2], [-3, 4], [5, -6]] print(find_first_negative(grid)) # (1, 0):第一处负数 print(find_first_negative([[1, 2], [3, 4]])) # None:全正 ``` return 在函数里遇到即执行:找到负数就带着 (i, j) 离开整个函数,两层循环与后续代码全部不再执行;全部扫描完没有负数才走到最后的 `return None`。这就是「把任务命名成函数、让 return 背负退出责任」的工程姿态——逻辑清晰,且函数可以在任何地方复用。 ### 提前退出设计二:for-else 接力 不想用函数时,第 5 篇的 for-else 可以完成同样的双层退出: ```python grid = [[1, 2], [-3, 4], [5, -6]] for i, row in enumerate(grid): for j, v in enumerate(row): if v < 0: print("找到负数", (i, j)) break # 内层停:本行有结论 else: continue # 内层没结论:换下一行 break # 有结论:整个外层也停 ``` 输出:`找到负数 (1, 0)`。解读这一段需要第 5 篇的全部功力:内层 break 后,内层 for 的 else 被跳过,程序走到内层循环体之后的 `break`,退出外层;内层没 break(本行全正)时 else 执行 `continue`,外层进入下一行。两个 break 一先一后,各自负责一层。把这段与 return 版本对比:return 版依赖「整段是函数」,接力版在脚本里原地生效——各有适用场景,但共同点是**都不需要标志变量**。 ### 卫语句:清洗二维数据 把「分支藏在循环体最深处」改成「例外在最前面拦截」,可读性立竿见影: ```python records = [ "# 这是配置注释", "server = 127.0.0.1", "", "port = 8080", "# 结束", ] for rec in records: if not rec: # 卫语句 1:空行直接跳过 continue if rec[0] == "#": # 卫语句 2:注释行跳过 continue print(rec) # 只剩下真正要处理的行 ``` 输出: ```text server = 127.0.0.1 port = 8080 ``` 两条卫语句各拦一类噪音,正常路径上的 print 始终只有一层缩进。注意 `if not rec` 必须放在 `rec[0]` 之前——空字符串的下标访问会抛 IndexError,卫语句的顺序本身就是「先判安全、再取数据」的防线(这就是「先拦截例外,再处理常规」的具体例证)。若把这两个 if 换成 `if rec and rec[0] != "#":` 的复合条件,逻辑等价但可读性下降,且一旦条件复杂起来(更多拦截项),复合条件会迅速失控。 ### 三层嵌套:次数与警惕 ```python count = 0 for a in range(3): for b in range(3): for c in range(3): count += 1 print(count) # 27 = 3 × 3 × 3 ``` 27 次执行与乘法原理严格一致。把它想象成 10、10、10:1000 次;100、100、100:100 万次。三层嵌套的真正门槛不是语法,而是**规模**——写三层循环前,先问一句「每层多大、总共多少次」。需要三层以上时,先考虑能否分解:把最内层的逻辑抽成函数、用更高级的数据结构(词典、集合)减少一层、或者干脆换算法(课程 02 的主角)。 ## 易错点与陷阱 ### 陷阱一:累积变量没在外层每轮重置 把「每行平均」写错地方是嵌套新手最典型的失误: ```python matrix = [[80, 85, 90], [70, 75, 72], [88, 92, 95]] row_sum = 0 # 写在外层循环之外:错误位置 for row in matrix: for v in row: row_sum += v # 三行的分数滚雪球式累加 print("行平均", row_sum / len(row)) ``` 输出(错误): ```text 行平均 85.0 行平均 157.33333333333334 行平均 249.0 ``` 第一行碰巧正确,从第二行开始 row_sum 带着上一行的余量继续累加:第二行打印的是 (255+217)/3 = 157.33,第三行直接变成全部 9 个元素之和 747 除以 3 = 249.0——典型「看起来在统计、实际上在滚雪球」。判断标准:**外层每轮迭代需要「从零开始」的变量,初始化语句必须写在外层循环体内**。 ### 陷阱二:break 只跳内层,期望的「全退」没发生 第 5 篇讲过 break 的最近一层规则,在嵌套里它造成的困惑最大:内层 break 后外层继续跑,「找到了却还在浪费时间」甚至「重复处理已找到的结果」。双层提前退出必须用本节演示的两种手段之一(函数 return 或 for-else 接力)显式连坐;靠「多写几个 break」是无效的——Python 的 break 没有标签,天然无法指定「退到第几层」。如果你的嵌套循环里出现「break 之后还要检查一个变量才知道该不该继续」,停下来想想 return 或 for-else。 ### 陷阱三:三角循环的范围写错 九九乘法表把内层写成 `range(1, 10)`(与 i 无关)会得到完整的 9×9 矩形:每个算式出现两次(j×i 与 i×j 形状不同但内容重复的交错组合)、每行 9 个算式、斜线对称被破坏。内层范围依赖外层变量时,务必问一句「这一轮内层到底该跑多长」——三角循环(j 到 i 为止)、矩形循环(j 到 n)、递减斜线(与 i 反向)是三种不同形态,`range` 的参数是它们唯一的区别。 ### 陷阱四:行列错位与非矩形矩阵 外层行内层列的约定一旦破例(比如「算每列的平均」时顺手把行列写反),结果不是报错、而是错误的统计。更隐蔽的是**非矩形数据**:各行长度不一致时,`matrix[i][j]` 在短的哪一行会越界抛 IndexError。处理行长度不齐的数据,要么先校验行长,要么用 `enumerate` 只访问当前行实际存在的下标。先检查「数据是不是矩形的」,再写嵌套遍历。 ## 小结 嵌套循环的执行次数由乘法原理决定(三角循环按逐轮相加),二维数据用「外层行、内层列」的列表的列表遍历;分支与循环的组合只有筛选、提前终止、汇总裁决三种模式,各自对应 continue、break、累积逻辑。双层提前退出是嵌套里唯二需要专门设计的点,函数 return 与 for-else 接力都能免 flag 达成;卫语句把例外前置拦截,是控制缩进深度的第一工具。三层及以上嵌套要先算规模、再考虑拆解。至此,第三章的「语法件」已全部齐备——下一站的推导式,将把「双层循环 + 条件分支」压缩进一行表达式,本篇的嵌套直觉就是它的地基。 ## 练习与思考题 1. 用嵌套循环输出 1 到 100 的所有「相乘组合」:a × b = c 中 a ≤ b 且 c ≤ 100 的全部三元组,并数出共多少个。 2. 把矩阵 `[[1, 2, 3], [4, 5, 6], [7, 8, 9]]` 转置输出(行列互换),要求只用双层循环和 print。 3. 用「先算规模」的方法估算:一个 100×100 的矩阵里找最大值(双层循环)需要约多少次比较?如果再加一层遍历 100 个矩阵,总次数是多少? 4. 思考题:把「提前退出设计二」的 for-else 接力改写为「标志变量」版本,逐行对比两者的变量数量与分支深度,说明为什么接力版更接近「用结构表达意图」。