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

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

Python实战循环图案打印交互式菜单

引言

这是第三章「流程控制」的第 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) 回答「这里放星号还是空格」。这一步可以先用「让每个格子说话」的小程序把网格显形:

# 行-列建模:先让每个格子自己说话
for i in range(3):
    print(" ".join(f"{i},{j}" for j in range(4)))
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。先看乘法写法,再看等价的内层循环写法:

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)
******
******
******

******
******
******

两种写法输出完全相同;第二种把「行 = 字符序列」的本质摊开了:内层循环逐列把星号拼进 line。但第二章字符串篇已经讲过,不可变字符串反复拼接会不断产生中间对象,所以工程上「一次乘法构造整行」才是矩形乃至所有实心图案的标准答案。变量名用 _ 表示「循环次数无关紧要」,这是第 4 篇的惯用法;矩形是五种图案里唯一「行的内容与行号无关」的,所以外层循环用 _ 占位,内层才真正消费列号 j。

直角三角形:星数 = 行号 + 1

左对齐的直角三角形(直角在左下)是最直接的递增形状:第 i 行有 i + 1 个星号:

n = 5
for i in range(n):
    print("*" * (i + 1))
*
**
***
****
*****

公式 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 个,左侧放这些空格即可:

n = 5
for i in range(n):
    spaces = n - 1 - i        # 左侧空格数
    stars = 2 * i + 1         # 本行星号数
    print(" " * spaces + "*" * stars)
    *
   ***
  *****
 *******
*********

边界行自检: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) 一步算出距离,一行代码写完整幅图:

n = 5
for i in range(n):
    d = min(i, n - 1 - i)     # 本行距中心行的距离
    print(" " * (n - 1 - d) + "*" * (2 * d + 1))
    *
   ***
  *****
   ***
    *

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 列)的规则是:第一行与最后一行整行星号,其余行只有首尾两个星号:

rows, cols = 4, 10
for i in range(rows):
    if i == 0 or i == rows - 1:
        print("*" * cols)              # 顶边与底边
    else:
        print("*" + " " * (cols - 2) + "*")   # 两侧壁
**********
*        *
*        *
**********

空心三角形(腰长 n)则要求逐字符判断:每行有 2i + 1 个位置,左壁(j = 0)、右壁(j = 2i)、底边(i = n - 1)放星号,其余放空格。字符不再连续,字符串乘法让位给内层循环:

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)
     *
    * *
   *   *
  *     *
 *       *
***********

注意底边 i == n - 1 是整行星号,这一行恰好覆盖了 j == 2i 的右壁条件,逻辑顺序无冲突。三种边界判断合在一个 or 里,对应第 2 章比较与逻辑运算的短路求值语义:j == 0 为真时不再看后面两个比较。

交互式菜单:while True 主循环

图案打印是「内容」,交互式菜单是「外壳」——命令行工具的标准结构。外壳由四个要素组成:主循环(main loop,while True 让程序不死)、提示与读取(input 拿用户命令)、分发(dispatch,if/elif 把命令字符串映射到对应动作,第 1 篇分支结构的用武之地)、退出通道(break,第 5 篇循环控制三件套之一)。一个最小菜单只有十几行:

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("未知命令")
命令(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 实测):

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。一段完整会话(真实运行):

===== 图案工坊 =====

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 会让每行多一个空格,顶端看起来整体右移一列:

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))
错误公式 (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 外面(顶格),菜单会「只响应一次命令就退出」:

cmd = "1"
while True:
    if cmd == "1":
        print("执行命令")
    break               # 错误缩进:无条件退出,循环形同虚设
print("菜单只运行了一次就退出")
执行命令
菜单只运行了一次就退出

第 5 篇强调过:break 的位置必须与它要守卫的条件严格对齐,逐级对齐缩进是最快的排查手段。

陷阱四:isdigit 与 int 对空白的容忍度不同。 int(" 3") 能解析(int 会跳过首尾空白),但 " 3".isdigit() 是 False(空格不是数字字符),"".isdigit() 也是 False。所以正确的输入管线是「先 strip 去空白 → 再 isdigit 判数字 → 最后 int 转换」,顺序不可调换,且空输入天然被 isdigit 拦下:

print(" 3".isdigit())   # False:带空格的"3"不是纯数字串
print(int(" 3"))        # 3:int 容忍首尾空白
print("".isdigit())     # False:空串不是数字
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,菜单的分发结构是否有变化?