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

实战:图案打印与交互式菜单

## 引言 这是第三章「流程控制」的第 9 篇。前八篇依次建立了分支结构(`if/elif/else`)、模式匹配(`match`)、`while`、`for` 与 `range`、`break`/`continue`/`else`、嵌套组合、推导式,并在第 8 篇用 FizzBuzz 家族与猜数字完成了第一次实战。本篇延续实战路线,做两件必然要做的事:其一,**图案打印**——把「在终端画图形」当作训练循环与分支的标尺,用行-列建模(row-column modeling)方法从行号推导公式,程序化地画出矩形、三角形、菱形与空心图案;其二,**交互式菜单**——用 `while True` + `input` + 分支分发搭出命令行工具的标准外壳。两种任务最后合体:图案打印作为菜单的一个功能项,与第 8 篇的猜数字并列,组成一个像样的命令行小应用。读者应先掌握:`while` 与 `for` 的写法(第 3、4 篇)、`break`/`continue` 的语义(第 5 篇)、嵌套循环的层次关系(第 6 篇)、字符串乘法与 `join`(第二章字符串篇)、真值测试与短路(第二章布尔篇)。下一篇是本篇的测验篇,本篇正是测验的重点取材对象。 ## 概念与原理:行-列建模 图案打印的难点不在语法,而在「把图形翻译成公式」。翻译的起点是认清终端的本质:**print 永远整行输出,字符只能按行产生**。控制台把每一行视为一个字符串,行与行之间没有可供回退的绘制指令,因此任何图案程序的外形都被限死为「外层循环管行,行内逻辑管这一行长什么样」。反过来说,只要把「第 i 行是什么」写成公式,整个图案程序就只剩一层循环加一行构造。 图案本质上是一张二维网格(grid):用二元组 `(i, j)` 标识位置,i 是行号(从 0 起),j 是列号。画图就是对每个 `(i, j)` 回答「这里放星号还是空格」。这一步可以先用「让每个格子说话」的小程序把网格显形: ```python # 行-列建模:先让每个格子自己说话 for i in range(3): print(" ".join(f"{i},{j}" for j in range(4))) ``` ```text 0,0 0,1 0,2 0,3 1,0 1,1 1,2 1,3 2,0 2,1 2,2 2,3 ``` 这里的 `join` 与生成器表达式来自第二章与本章第 7 篇:本行四个格子 `(i, j)` 逐个变成字符串,再用空格串成一行。程序与输出严格对应——每行 `(i, j)` 中 j 从 0 走到 3,行号 i 控制「第几行」。绝大多数教科书图案还有一个更强的性质:**一行之内只有一块连续的星号**。于是每行可以完全由两个数决定——前置空格数 `S(i)` 与星号数 `T(i)`。整幅图案的代码因此坍缩为一层循环加两个公式,先写公式再写代码,是图案打印的总方法论。 公式怎么保证正确?三步自检法:① 数清总行数 n;② 写下「行号 i 与星号数、缩进量的关系式」;③ 把边界行(i = 0 与 i = n - 1)代入公式手推,输出必须与图中两端的行吻合。三类构造工具在本章都已就位:字符串乘法 `"*" * k` 一次产生 k 个字符,是整行构造的首选;字符串拼接在循环里逐字符组装,机制透明但每行产生多次中间对象;`join` 适合把字符序列合成行。值得算一笔复杂度账:画 n 行、每行最长约 n 个字符,字符总量是 O(n²) 量级;对每行用乘法一次构造,行内便不再需要内层循环,这是「循环层数越少,代码越容易读」的直接体现。 ## 操作与实现:五种图案的公式推导 ### 矩形:先整行,再整幅 矩形是「行公式与行号无关」的特例——每行都是同样长度的星号行,`S(i) = 0`,`T(i) = m`。先看乘法写法,再看等价的内层循环写法: ```python n, m = 3, 6 for _ in range(n): # 外层循环:行 print("*" * m) # 整行一次构造 print() for i in range(n): # 等价写法:内层循环逐字符 line = "" for j in range(m): line = line + "*" print(line) ``` ```text ****** ****** ****** ****** ****** ****** ``` 两种写法输出完全相同;第二种把「行 = 字符序列」的本质摊开了:内层循环逐列把星号拼进 `line`。但第二章字符串篇已经讲过,不可变字符串反复拼接会不断产生中间对象,所以工程上「一次乘法构造整行」才是矩形乃至所有实心图案的标准答案。变量名用 `_` 表示「循环次数无关紧要」,这是第 4 篇的惯用法;矩形是五种图案里唯一「行的内容与行号无关」的,所以外层循环用 `_` 占位,内层才真正消费列号 j。 ### 直角三角形:星数 = 行号 + 1 左对齐的直角三角形(直角在左下)是最直接的递增形状:第 i 行有 `i + 1` 个星号: ```python n = 5 for i in range(n): print("*" * (i + 1)) ``` ```text * ** *** **** ***** ``` 公式 `T(i) = i + 1`;代入边界行验证:i = 0 时 1 个星号(顶端),i = 4 时 5 个星号(底边),完全吻合。把 `range(n)` 换成 `range(n, 0, -1)` 立即得到倒置版本——同一公式、不同步长,这正是第 4 篇 `range` 三参数形式的用武之地。 ### 等腰三角形:居中公式的推导 等腰三角形比直角三角形多一个维度:每行除了星号还要前置空格把它推到中间。设总行数 n,底边宽度为 `2n - 1`;第 i 行的星号数显然是 `2i + 1`(从 1 起每次加 2)。要居中,两侧的空格各占 `(2n - 1 - (2i + 1)) / 2 = n - 1 - i` 个,左侧放这些空格即可: ```python n = 5 for i in range(n): spaces = n - 1 - i # 左侧空格数 stars = 2 * i + 1 # 本行星号数 print(" " * spaces + "*" * stars) ``` ```text * *** ***** ******* ********* ``` 边界行自检:i = 0 时 `spaces = 4`、`stars = 1`,顶端一个星号居中;i = 4 时 `spaces = 0`、`stars = 9`,底边打满。这个公式是后面所有居中图案的地基,值得背下来:**空格数随行号递减、星号数随行号以 2 递增**。 ### 菱形:到中心行的距离 菱形可以看成「上半场递增、下半场递减」的等腰三角形。设中心行是第 `n // 2`(n 为奇数时恰好是中间行),记 `d = |i - 中心行|` 为本行到中心的距离,则星号数 `2d + 1`、前置空格数 `n - 1 - d`。用 `min(i, n - 1 - i)` 一步算出距离,一行代码写完整幅图: ```python n = 5 for i in range(n): d = min(i, n - 1 - i) # 本行距中心行的距离 print(" " * (n - 1 - d) + "*" * (2 * d + 1)) ``` ```text * *** ***** *** * ``` `min(a, b)` 是内建函数,返回两个数中较小者:i 从 0 走到 n - 1,`min(i, n - 1 - i)` 先等于 i(上半场距离递增)、在中心行之后等于 `n - 1 - i`(下半场距离递减),两个公式在中间行自然交棒。等效的分支写法是 `d = i if i <= n // 2 else n - 1 - i`——公式版本比分支版本短且不易错,这正是「先建模、后编码」的收益。 ### 空心图案:边界判断 空心图案在「一块连续星号」的假设上开洞:星号只出现在边界上。空心矩形(n 行 × 2n 列)的规则是:第一行与最后一行整行星号,其余行只有首尾两个星号: ```python rows, cols = 4, 10 for i in range(rows): if i == 0 or i == rows - 1: print("*" * cols) # 顶边与底边 else: print("*" + " " * (cols - 2) + "*") # 两侧壁 ``` ```text ********** * * * * ********** ``` 空心三角形(腰长 n)则要求逐字符判断:每行有 `2i + 1` 个位置,左壁(j = 0)、右壁(j = 2i)、底边(i = n - 1)放星号,其余放空格。字符不再连续,字符串乘法让位给内层循环: ```python n = 6 for i in range(n): line = "" for j in range(2 * i + 1): if j == 0 or j == 2 * i or i == n - 1: line += "*" # 左壁 / 右壁 / 底边 else: line += " " print(" " * (n - 1 - i) + line) ``` ```text * * * * * * * * * *********** ``` 注意底边 `i == n - 1` 是整行星号,这一行恰好覆盖了 `j == 2i` 的右壁条件,逻辑顺序无冲突。三种边界判断合在一个 `or` 里,对应第 2 章比较与逻辑运算的短路求值语义:`j == 0` 为真时不再看后面两个比较。 ## 交互式菜单:while True 主循环 图案打印是「内容」,交互式菜单是「外壳」——命令行工具的标准结构。外壳由四个要素组成:**主循环**(main loop,`while True` 让程序不死)、**提示与读取**(`input` 拿用户命令)、**分发**(dispatch,`if/elif` 把命令字符串映射到对应动作,第 1 篇分支结构的用武之地)、**退出通道**(`break`,第 5 篇循环控制三件套之一)。一个最小菜单只有十几行: ```python while True: cmd = input("命令(1 打印 2 帮助 0 退出):").strip() if cmd == "1": print("正在打印……") elif cmd == "2": print("帮助:输入 1 打印,输入 0 退出") elif cmd == "0": print("再见!") break else: print("未知命令") ``` ```text 命令(1 打印 2 帮助 0 退出):2 帮助:输入 1 打印,输入 0 退出 命令(1 打印 2 帮助 0 退出):1 正在打印…… 命令(1 打印 2 帮助 0 退出):9 未知命令 命令(1 打印 2 帮助 0 退出):0 再见! ``` 上面是一次真实运行(Python 3.12 实测,输入行是程序提示后我键入的内容)。三个细节值得沉淀:其一,`input` 返回的一定是字符串(第二章类型转换篇),命令比较时必须与字符串字面量比较,`if cmd == 1` 恒为假;其二,`elif` 的顺序就是分发表(dispatch table)的顺序,`0` 的退出分支必须能 break——`break` 只终止最近一层循环,这里恰好是主循环;其三,任何不在白名单(whitelist)里的输入都会被 `else` 兜住并回到循环顶部继续等待,程序不会崩、不会死锁。在循环末尾没有任何语句时,一轮结束自动回到 `while True` 的顶部——这是主循环「永远问下一个问题」的动力来源。 ## 综合演示:图案打印成为菜单的功能项 现在把两部分合体。图案打印是「功能」,猜数字(第 8 篇的实战)是另一个「功能」,菜单是「分发器」。要做到这一点,第一次需要一种新语言机制:**函数**(第四、五章会系统讲 `def` 与参数传递,这里先借最朴素的形态——无参数、定义即用)。没有函数时,每个功能都要把整段代码抄进对应的 `if` 分支,菜单每加一项就膨胀一倍;有了函数,「做什么」与「怎么做」分离,菜单只剩一行调用。这也是为什么几乎所有真实命令行工具都长成「函数库 + 主循环」两层。完整程序如下(文件 menu_demo.py,Python 3.12 实测): ```python def rect(n): for _ in range(n): print("*" * (2 * n)) def tri(n): for i in range(n): print(" " * (n - 1 - i) + "*" * (2 * i + 1)) def diamond(n): for i in range(n): d = min(i, n - 1 - i) print(" " * (n - 1 - d) + "*" * (2 * d + 1)) def hollow(n): for i in range(n): if i == 0 or i == n - 1: print("*" * (2 * n)) else: print("*" + " " * (2 * n - 2) + "*") def guess(): target = 42 tries = 0 while True: tries += 1 s = input("猜一个 1~100 的整数:").strip() if not s.isdigit(): print("只会读数字,请重猜") continue if int(s) < target: print("小了") elif int(s) > target: print("大了") else: print(f"猜对了!共用 {tries} 次") break print("===== 图案工坊 =====") while True: print("\n1. 图案打印 2. 猜数字 0. 退出") choice = input("请选择:").strip() if choice == "0": print("再见!") break if choice == "1": kind = input("图案编号(1 矩形 2 三角形 3 菱形 4 空心矩形):").strip() size = input("行数(正整数):").strip() if not size.isdigit() or int(size) < 1: print("行数必须是正整数") continue n = int(size) if kind == "1": rect(n) elif kind == "2": tri(n) elif kind == "3": diamond(n) elif kind == "4": hollow(n) else: print("没有这个图案编号") elif choice == "2": guess() else: print("无效选项(0~2)") ``` 程序共 60 行:前五个函数是「图案函数库」,其中 `guess` 把第 8 篇的猜数字原样收编;主循环是「分发器」。两处设计值得说明:其一,矩形宽度取 `2n`,让「行数 n」一个参数同时决定所有图案的规模,菜单不必再问两个数字;其二,行数校验用 `not size.isdigit() or int(size) < 1`——`isdigit()` 是字符串方法,当字符串非空且全是数字字符时返回 `True`,由于 or 短路求值,`int(size)` 只在确认是纯数字后才会执行,非数字输入永远不会触发 `ValueError`。一段完整会话(真实运行): ```text ===== 图案工坊 ===== 1. 图案打印 2. 猜数字 0. 退出 请选择:9 无效选项(0~2) 1. 图案打印 2. 猜数字 0. 退出 请选择:2 猜一个 1~100 的整数:50 大了 猜一个 1~100 的整数:30 小了 猜一个 1~100 的整数:abc 只会读数字,请重猜 猜一个 1~100 的整数:42 猜对了!共用 4 次 1. 图案打印 2. 猜数字 0. 退出 请选择:1 图案编号(1 矩形 2 三角形 3 菱形 4 空心矩形):3 行数(正整数):5 * *** ***** *** * 1. 图案打印 2. 猜数字 0. 退出 请选择:1 图案编号(1 矩形 2 三角形 3 菱形 4 空心矩形):4 行数(正整数):4 ******** * * * * ******** 1. 图案打印 2. 猜数字 0. 退出 请选择:1 图案编号(1 矩形 2 三角形 3 菱形 4 空心矩形):5 行数(正整数):3 没有这个图案编号 1. 图案打印 2. 猜数字 0. 退出 请选择:1 图案编号(1 矩形 2 三角形 3 菱形 4 空心矩形):1 行数(正整数):0 行数必须是正整数 1. 图案打印 2. 猜数字 0. 退出 请选择:1 图案编号(1 矩形 2 三角形 3 菱形 4 空心矩形):2 行数(正整数):3 * *** ***** 1. 图案打印 2. 猜数字 0. 退出 请选择:0 再见! ``` 这段会话覆盖了程序的所有分支:非法菜单项(9)、合法菜单项(2 与 1)、猜数字的偏大/偏小/非数字/猜中、菱形、空心矩形、非法图案编号(5)、非法行数(0)、三角形与退出。注意非法输入从来不会让程序崩溃,而是被白名单或守卫拦下——这是「外壳」质量的分水岭。把第 8 篇的猜数字与图案打印并排在同一菜单下,两种任务的衔接也讲清楚了:**猜数字的循环在「数值区间」上行进(大了/小了),图案打印的循环在「字符网格」上行进(行号/列号)**,它们本质是同一套控制结构的两种叙事。 ## 易错点与陷阱 **陷阱一:差一错误。** 居中公式里左侧空格数是 `n - 1 - i`,写成 `n - i` 会让每行多一个空格,顶端看起来整体右移一列: ```python print("错误公式 (n - i):") for i in range(3): print(" " * (3 - i) + "*" * (2 * i + 1)) print("正确公式 (n - 1 - i):") for i in range(3): print(" " * (3 - 1 - i) + "*" * (2 * i + 1)) ``` ```text 错误公式 (n - i): * *** ***** 正确公式 (n - 1 - i): * *** ***** ``` 差一个字符的差值在 i = 0 时最明显:正确版顶端应在最中列,错误版往右挪了一格。预防手段就是边界行自检——公式写完后,先把 i = 0 与 i = n - 1 代入手推。 **陷阱二:input 的结果是字符串,与整数比较恒为假。** `input("请选择:")` 返回 `"1"` 而非 `1`,`if choice == 1` 永远不成立,所有命令都会落到 `else` 分支——程序不报错,但菜单形同虚设。必须与字符串字面量比较(`choice == "1"`),或先 `int()` 再比较。第二种写法的风险在陷阱四。 **陷阱三:break 的缩进位置。** `break` 一旦执行就终止最近一层循环。把它误写到 `if` 外面(顶格),菜单会「只响应一次命令就退出」: ```python cmd = "1" while True: if cmd == "1": print("执行命令") break # 错误缩进:无条件退出,循环形同虚设 print("菜单只运行了一次就退出") ``` ```text 执行命令 菜单只运行了一次就退出 ``` 第 5 篇强调过:`break` 的位置必须与它要守卫的条件严格对齐,逐级对齐缩进是最快的排查手段。 **陷阱四:isdigit 与 int 对空白的容忍度不同。** `int(" 3")` 能解析(int 会跳过首尾空白),但 `" 3".isdigit()` 是 `False`(空格不是数字字符),`"".isdigit()` 也是 `False`。所以正确的输入管线是「先 strip 去空白 → 再 isdigit 判数字 → 最后 int 转换」,顺序不可调换,且空输入天然被 isdigit 拦下: ```python print(" 3".isdigit()) # False:带空格的"3"不是纯数字串 print(int(" 3")) # 3:int 容忍首尾空白 print("".isdigit()) # False:空串不是数字 ``` ```text False 3 False ``` **陷阱五:空心图案的字符数不配平。** 空心矩形中间行的 `"*" + " " * (cols - 2) + "*"`,字符总数必须是 1 + (cols - 2) + 1 = cols,与顶行 `"*" * cols` 严格相等;写成 `cols - 1` 会让右壁左移一列,图案歪掉。自检办法是打印每行的 `len` 与 cols 比对——矩形 4×10 的四行理应全部输出 `10`,这一检查在综合演示代码上逐行成立。 ## 小结 本篇完成了第三章的第二次实战闭环。行-列建模把「画图形」翻译成「写公式」:数清行数、写出行号与星号数/空格数的关系、用边界行自检,五种图案(矩形、直角三角形、等腰三角形、菱形、空心矩形与空心三角形)全部在这套方法下一行代码成形。交互式菜单给出命令行工具的标准外壳:`while True` 主循环、`input` 读取、`if/elif` 分发、`break` 退出,白名单与守卫让非法输入不再致命。两者合体后,第 8 篇的猜数字也收编为菜单功能项——图案、游戏、菜单三种「叙事」用的都是同一组控制结构,这正是本章的主题。下一篇综合测验将用选择题与编程题检验这些能力。 ## 练习与思考题 1. **倒置三角形**:写程序输出 n 行的倒等腰三角形(第 0 行的星号数为 `2n - 1`,此后每行减 2)。提示:把等腰三角形的公式反转——`stars = 2 * (n - 1 - i) + 1`,空格数仍为 `n - 1 - i`。运行并核对 i = 0 与 i = n - 1 两行。 2. **空心菱形**:在菱形公式基础上挖空:每行在星号区间 `[0, 2d]` 内,只当 `j == 0` 或 `j == 2d` 时放星号(d = 0 的顶行与底行只有单个星号),其余放空格。参考空心三角形的内层逐字符写法,先手推 n = 5 的形状再编码。这个练习是把「实心公式」与「边界判断」两种方法合用的关键一步。 3. **菜单扩容**:为「图案工坊」增加第 5 个功能项「数字金字塔」(第 i 行打印 `str(i) * i`,即 1、22、333……)。写出需要改动的最小代码集合(答案方向:菜单印刷加一行、`if/elif` 分发加一个分支、函数库加一个函数),并说明为什么三个位置都要改、漏改任何一个会发生什么。 4. **思考题**:综合演示里矩形宽度被固定为 `2n`(列数随行数变化)。如果希望矩形宽高独立(用户分别输入行数与列数),`rect` 函数与主循环各需要改几行?改动后 `n` 与 `cols` 两个参数如何传给 `rect`,菜单的分发结构是否有变化?