## 引言
这是第三章「流程控制」的第 8 篇,正式进入实战环节。前面七篇把零件都备齐了:分支(if/elif/else)、三种循环(while、for/range)、循环控制(break/continue)、上一篇的推导式。但单个零件和一台能运转的机器是两回事——本篇要用两个经典题目,把这些零件拧成两个完整可运行的程序:FizzBuzz 与猜数字。
选这两道题是有讲究的。FizzBuzz 是计算机行业流传最广的「最小考题」:逻辑简单到一眼能看懂,却能干净地检验你分支顺序、取模运算和代码组织的基本功。猜数字则是「交互式程序」的完整样本:它需要输入、校验、循环、随机数、状态维护和退出机制,写一遍等于把第三章的语法全部实战了一遍。同时请记住本篇与第 9 篇的分工:第 9 篇做图案打印与交互式菜单,**菜单结构不在本篇讨论范围**——本篇的猜数字只做「一个人机交互的自然会话」,包含退出选项和简单的再来一局确认,绝不铺开成菜单系统。
阅读前提:if/elif/else(第 1 篇)、while 与 for/range(第 3、4 篇)、break/continue(第 5 篇)、推导式(第 7 篇)、f-string(第 2 章)。本篇会第一次用到 try/except 的一小角(捕获输入错误),异常处理的完整体系在第 7 章《文件与异常处理》才系统展开,这里点到为止,只记住这个用法即可;也会用到一次 def 定义函数,完整内容在第 4 章《函数》,这里同样只点到为止。
## 概念与原理
### FizzBuzz 为什么值得认真写
规则只有三条:从 1 数到 n,能被 3 整除的数输出 Fizz,能被 5 整除的输出 Buzz,**同时能被 3 和 5 整除的输出 FizzBuzz**,其余情况输出数字本身。它作为面试题的妙处在于:没写过代码的人连「i % 3 == 0 表示整除」都答不顺,写过代码的则几乎不可能答错——所以它过滤的不是能力上限,而是「有没有亲手写过代码」的下限。
三条规则之间有一个天然的冲突点:15 是 3 的倍数也是 5 的倍数,两条规则同时命中,而输出只能有一个。解决方式恰好有三种,对应三种思维层次:
- **朴素分支**:把「同时整除」的判断放在最前面,用 if/elif 的短路特性保证 15 先被截获——考验的是分支顺序的理解(if/elif 依次判断,命中即止);
- **推导式(表达式思维)**:利用「字符串 × 布尔值」的技巧,让 Fizz 和 Buzz 两个标记按需拼接,15 自然拼出 FizzBuzz——这是第 7 篇推导式的直接应用;
- **函数化(数据驱动)**:把「除数 → 单词」的对应关系做成规则表,循环查表拼词——规则从代码里剥离出来,加一条新规则不用改逻辑,这也是第 4 章函数与真实工程可扩展性的桥梁。
三种解法没有优劣,它们回答的是同一个问题:**当多条规则重叠时,你的代码如何优雅地处理冲突**。
### 输入校验循环:交互程序的骨架
任何交互式程序都绕不开一个问题:用户输入不可信。用户可能输入 "abc"、空行、超范围的数字——程序必须能优雅地拒绝错误输入并重新询问,而不是崩溃。写出这种「反复询问直到合法」结构的标准模式是:
> while True: 读取输入 → 校验 → 不合法则 continue 重来 → 合法则 break 跳出
这套模式在交互程序里的地位,相当于循环在算法里的地位:**只要程序和人打交道,就必然有一个「校验循环」**。它的关键点是 try/except——把可能失败的转换(int(text))包起来,失败时捕获 ValueError 而不是让程序崩溃。int() 遇到非数字字符串(包括空字符串)都会抛 ValueError,这一异常类型就是本次唯一需要认识的异常,其余留到第 7 章。配合字符串的 .strip() 去掉首尾空白,可以顺手解决用户手滑多打空格的问题。
### 猜数字的信息论:为什么最多 7 次必中
猜数字游戏的规则:程序随机想一个 1~100 的整数,玩家每次猜一个数,程序回答「高了 / 低了 / 中了」。每一轮回答都让玩家获得信息:如果猜 50 被回答「低了」,那么 1~50 全部出局,搜索区间直接砍半。**每次把区间缩小一半,这就是二分法(binary search)的雏形**——课程 02 会正式学习它,这里只需要直觉版本。
1~100 的区间连续折半,ceiling(log2(100)) = 7 次之内必然命中(2 的 6 次方是 64,2 的 7 次方是 128,7 次足够区分 100 个数字)。对比一下:从 1 开始逐个数着猜,运气差要猜 100 次;二分策略最坏 7 次。为了让「7 次」从结论变成可感受的过程,完整版游戏会随着猜测实时收窄提示区间(如「正确值在 26~49 之间」),并且在玩家连续失手时给出二分提示。实现上,程序维护 low 与 high 两个边界变量,每次猜测后按回答更新一侧边界——这就是「状态维护」的最小示范。
另外注意随机数 API 的区间语义:random.randint(1, 100) **包含两端**,即可能生成 1 也可能生成 100;而 random.randrange(1, 100) 是排他的(不包含 100)。写游戏时方向绝对不能搞反,易错点会再强调。
## 操作与实现
### FizzBuzz 解法一:朴素分支
最直白的写法:三连判断,注意「同时整除」必须排在最前。
```python
for i in range(1, 16):
if i % 15 == 0:
print("FizzBuzz", end=" ")
elif i % 3 == 0:
print("Fizz", end=" ")
elif i % 5 == 0:
print("Buzz", end=" ")
else:
print(i, end=" ")
```
输出(1~15):
```text
1 2 Fizz 4 Buzz Fizz 7 8 Fizz Buzz 11 Fizz 13 14 FizzBuzz
```
为什么 15 的判断要放最前?if/elif 从上到下逐个尝试、命中第一个就不再往下走:如果把 `i % 3 == 0` 放前面,15 会先被「Fizz」截胡,永远到不了 FizzBuzz。也可以通过先拼字符串再判断来绕开顺序问题——那正是解法二。
### FizzBuzz 解法二:推导式
上一篇的推导式在这里大显身手:布尔值能乘字符串(True 复制一次、False 得到空串,机制见第 2 章「bool 是 int 的子类」),Fizz 与 Buzz 各自独立拼接,拼出来是空串(两个条件都没命中)时用 `or` 回退到数字字符串:
```python
fb = ["Fizz" * (i % 3 == 0) + "Buzz" * (i % 5 == 0) or str(i) for i in range(1, 16)]
print(" ".join(fb))
```
输出(1~15):
```text
1 2 Fizz 4 Buzz Fizz 7 8 Fizz Buzz 11 Fizz 13 14 FizzBuzz
```
拆解表达式:`"Fizz" * (i % 3 == 0)` 在“能被 3 整除”时为 "Fizz",否则为空串 "";两串相加后,15 得到 "FizzBuzz",普通数字得到 "";空串为假,触发 `or str(i)` 回退成数字。这一招不需要关心任何分支顺序,因为 15 的两个标记是**各算各的再加起来**。写不通顺时回到第 7 篇:推导式 = 产式 + for + 可选 if,这里没有过滤(每项都要),只有产式的技巧。
### FizzBuzz 解法三:函数化(规则表)
把「除数 → 单词」的对应做成规则表,查表拼接。这里第一次正式用到 def(函数的完整教程在第 4 章,先记住三要素:def 定义、参数传入、return 返回):
```python
def fizzbuzz(n, rules=((3, "Fizz"), (5, "Buzz"))):
out = "".join(word for d, word in rules if n % d == 0)
return out or str(n)
print(" ".join(fizzbuzz(i) for i in range(1, 16)))
rules7 = ((3, "Fizz"), (5, "Buzz"), (7, "Whizz"))
for n in (7, 21, 35, 105):
print(n, "->", fizzbuzz(n, rules7))
```
输出:
```text
1 2 Fizz 4 Buzz Fizz 7 8 Fizz Buzz 11 Fizz 13 14 FizzBuzz
7 -> Whizz
21 -> FizzWhizz
35 -> BuzzWhizz
105 -> FizzBuzzWhizz
```
相比解法二,这里把「规则」从代码里抽了出来:想加一条「7 的倍数输出 Whizz」,只需要新增一个规则元组,函数体一行不用改。`"".join(word for d, word in rules if n % d == 0)` 是对规则表做一次「取命中的词并拼起来」的生成器表达式——规则全部落空时 out 为空串,同样用 `or str(n)` 兜底。注意输出的最后一行 105:三个条件全中,拼出 FizzBuzzWhizz,再次印证「拼接」策略天然免疫顺序问题。
### 输入校验循环
现在写交互程序的骨架——反复读取输入直到合法:
```python
while True:
text = input("请输入一个 1~10 的整数:").strip()
try:
n = int(text)
except ValueError:
print(f"「{text}」不是整数,请重新输入。")
continue
if 1 <= n <= 10:
break
print(f"{n} 超出 1~10 范围,请重新输入。")
print(f"校验通过:n = {n}")
```
一次真实运行(输入依次为 abc、0、15、7):
```text
请输入一个 1~10 的整数:abc
「abc」不是整数,请重新输入。
请输入一个 1~10 的整数:0
0 超出 1~10 范围,请重新输入。
请输入一个 1~10 的整数:15
15 超出 1~10 范围,请重新输入。
请输入一个 1~10 的整数:7
校验通过:n = 7
```
逐行分析这个模式:while True 让循环「永远重来」直到 break;int(text) 可能失败,失败被 except ValueError 捕获,continue 直接回到循环开头重新询问——**错误输入不会导致程序崩溃**,这是它与裸写 `int(input())` 的本质区别;范围校验(1 <= n <= 10)是业务规则,不满足就 continue;两条路都过了才 break。这个骨架接下来会被猜数字游戏原样复用。
### 猜数字:核心版
先写最小可用版:随机一个 1~100 的整数,循环读取猜测,高了/低了/中了,统计次数。
```python
import random
secret = random.randint(1, 100)
attempts = 0
print("我心中想了一个 1~100 的整数,来猜猜看。")
while True:
text = input("你的猜测:").strip()
try:
guess = int(text)
except ValueError:
print("请输入整数。")
continue
if not (1 <= guess <= 100):
print("请输入 1~100 之间的整数。")
continue
attempts += 1
if guess == secret:
print(f"恭喜!你用了 {attempts} 次猜中。")
break
if guess < secret:
print("低了。")
else:
print("高了。")
```
一次真实运行(秘密数字每次随机生成,此局为 82):
```text
我心中想了一个 1~100 的整数,来猜猜看。
你的猜测:abc
请输入整数。
你的猜测:0
请输入 1~100 之间的整数。
你的猜测:101
请输入 1~100 之间的整数。
你的猜测:50
低了。
你的猜测:75
低了。
你的猜测:88
高了。
你的猜测:81
低了。
你的猜测:84
高了。
你的猜测:82
恭喜!你用了 6 次猜中。
```
注意几个细节:attempts 的累加放在「类型合法 + 范围合法」两个检查**之后**,因此无效输入不计数;猜中通过 break 跳出循环,否则 while True 永不结束;「高低」判断用 if/else 二选一,猜中是唯一的中断出口。int 转换同样被 try/except 保护——这是一路从上一节带过来的骨架。
### 猜数字:完整版
核心版能用,但体验粗糙:玩家不知道区间还剩多大、连猜几次失手没有指引、想退出只能 Ctrl+C。完整版一次补齐四件事:**实时区间提示**(low/high 边界随每次猜测收窄)、**二分策略提示**(第 5 次还没猜中时给出区间中点)、**退出选项**(输入 q 随时离开)、**再来一局**(猜完或退出后按 y 重开)。
```python
import random
print("猜数字游戏:秘密数字在 1~100 之间(含两端)。输入 q 退出。")
while True:
secret = random.randint(1, 100) # 每个新回合重新随机
low, high, attempts = 1, 100, 0
hinted = False
while True:
text = input(f"[{low}~{high}] 请猜一个整数:").strip()
if text.lower() in ("q", "quit", "退出"):
print(f"本局结束,答案是 {secret}。")
break
try:
guess = int(text)
except ValueError:
print("无效输入,请输入整数。")
continue
if not (1 <= guess <= 100):
print("数字必须在 1~100 之间,本次不计次数。")
continue
attempts += 1
if guess == secret:
print(f"恭喜!你用了 {attempts} 次猜中 {secret}。")
break
if guess < secret:
low = max(low, guess + 1)
print(f"低了。正确值在 {low}~{high} 之间。")
else:
high = min(high, guess - 1)
print(f"高了。正确值在 {low}~{high} 之间。")
if attempts == 5 and not hinted:
hinted = True
print(f"提示:试试二分法——区间中点 {(low + high) // 2} 能把范围缩小一半。")
again = input("再来一局?输入 y 继续,其他键退出:").strip().lower()
if again != "y":
break
print("游戏结束,再见!")
```
一次真实运行(秘密数字为 42,10 次猜测包含无效输入):
```text
猜数字游戏:秘密数字在 1~100 之间(含两端)。输入 q 退出。
[1~100] 请猜一个整数:abc
无效输入,请输入整数。
[1~100] 请猜一个整数:0
数字必须在 1~100 之间,本次不计次数。
[1~100] 请猜一个整数:101
数字必须在 1~100 之间,本次不计次数。
[1~100] 请猜一个整数:50
高了。正确值在 1~49 之间。
[1~49] 请猜一个整数:25
低了。正确值在 26~49 之间。
[26~49] 请猜一个整数:37
低了。正确值在 38~49 之间。
[38~49] 请猜一个整数:44
高了。正确值在 38~42 之间。
[38~42] 请猜一个整数:40
低了。正确值在 41~42 之间。
提示:试试二分法——区间中点 41 能把范围缩小一半。
[41~42] 请猜一个整数:41
低了。正确值在 42~42 之间。
[42~42] 请猜一个整数:42
恭喜!你用了 7 次猜中 42。
再来一局?输入 y 继续,其他键退出:y
[1~100] 请猜一个整数:q
本局结束,答案是 20。
再来一局?输入 y 继续,其他键退出:n
游戏结束,再见!
```
逐块消化这段代码。外层 while True 是「回合循环」:每回合重新抽秘密数字、重置 low/high/attempts/hinted。内层 while True 是「猜测循环」,由四种出口跳出:q 退出(打印答案后进入再来一局询问)、非法输入 continue 重试、猜中 break、区间收窄后继续。区间更新用 max/min 夹逼:猜低了只可能提高下界(low = max(low, guess + 1)),猜高了只可能降低上界(high = min(high, guess - 1)),两个边界朝中间收拢,提示区间永远不含已被排除的数字。hinted 用一个布尔开关保证二分提示只给一次。请留意一处细节:上面示范里玩家从 [1~49] 猜到 [42~42],区间被压缩到只剩一个数时,就等于「答案已经确定」——这就是二分法的终点形态,7 次猜中与理论结论完全吻合。
## 易错点与陷阱
### 陷阱一:FizzBuzz 的分支顺序
把 `i % 15 == 0` 的检查放在 `i % 3 == 0` 和 `i % 5 == 0` 之后,15、30 这类数字会被输出成 Fizz 或 Buzz 而不是 FizzBuzz。if/elif 是「命中即止」的从上到下判断,**重叠规则的判定必须放在具体规则之前**。解法二、三用拼接天然免疫这个问题,但如果改用朴素分支,顺序是硬约束——写完建议把 15 的倍数单独跑一遍验证。
### 陷阱二:randint 是闭区间
random.randint(1, 100) 包含 100,random.randrange(1, 100) 不包含 100。想表达「1~100 含两端」用 randint;想表达「1~99」或「0~99」用 randrange。两个名字只差几个字母,语义却是「含 / 不含」的区别,做概率模拟、抽奖程序时方向错了会直接产出越界数据。另外 randint(1, 99) 并不能表达「1~100」——它会把 100 永远排除在外,这是比「多一个端点」更隐蔽的错误。
### 陷阱三:裸写 int(input()) 会崩溃
`int(input())` 遇到 "abc"、空行、带小数点的 "4.5" 都会抛 ValueError 让程序当场终止。交互程序必须用 try/except ValueError 包住转换,配合 while True + continue 实现「拒绝重来」。另一个高频错误是忘记 .strip():用户输入 " 50 "(带空格)时 int 其实能处理首尾空白,但 "50\n" 或按错的全角数字 "50" 会带来难以排查的怪错误——统一在读取后立刻 strip 是良好习惯。异常体系本身是第 7 章的内容,本篇只要求会用 ValueError 这一种。
### 陷阱四:区间更新错误导致死循环或次数失真
猜数字里最常见的两个错误:一是只打印「高了/低了」却忘了更新 low/high,提示区间永远不动,玩家只能盲猜,二分提示也失去意义;二是更新方向反了——猜高了反而调低下界(low = guess + 1),区间会越缩越错,甚至把答案排除在外。另一个隐蔽问题是 attempts 的累加位置:放在范围校验之前,非法输入也会计数,次数统计就失真了。本篇把 attempts += 1 放在两道校验之后,正是为了「只统计有效猜测」。
## 小结
本篇用两道经典题目验证了第三章全部语法。FizzBuzz 的三种解法分别训练分支顺序、表达式思维与数据驱动(规则表)三种能力,其中 15 的倍数如何处理是唯一的考点。输入校验循环(while True + try/except ValueError + continue/break + strip)是人际交互程序的通用骨架,从此写任何带输入的程序都可复用。猜数字完整版把随机数、区间维护、次数统计、二分提示、退出与再来一局组装成一台完整机器,实测最坏 7 次必中的二分结论与运行示例完全一致。下一站是第 9 篇的图案打印与交互式菜单——那里的菜单结构会让「循环 + 分支」的组合再上一个台阶。
## 练习与思考题
1. 给函数化 FizzBuzz 增加规则 (7, "Whizz"),运行验证 7、21、35、105 的输出分别为 Whizz、FizzWhizz、BuzzWhizz、FizzBuzzWhizz;再心算 fizzbuzz(45) 的输出,运行核对。
2. 改进猜数字核心版:统计「猜过的数字」存进一个集合,用户重复猜同一个数时提示「这个数猜过了」且不累加次数(提示:参考第 6 章集合的 in 判断,或第 3 篇 while 的记忆技巧)。
3. 挑战题:给完整版加「7 次保护」——如果玩家运气太差超过 7 次还没猜中,打印「提示:答案其实一直在区间中点附近」,并重新给出当前区间中点(提示:统计 attempts 与 7 的关系)。
4. 思考题:把猜数字的二分策略写出来——初始区间 [1, 100],每次猜 (low + high) // 2,需要几步猜中 100?猜中 1 呢?验证两者都满足「最多 7 次」。