CS 61A  /  作业解析
LAB 01

Lab 1:函数与控制ok 13 项通过

第一次真正写函数:return 与 print 的分野、if/elif 的控制流、while 循环,以及用 // 和 % 把一个整数拆成一位一位的数字。

对应讲次:Lecture 1 函数、Lecture 2 名称 / 控制 官方题面:cs61a.org/lab/lab01 代码:labs/lab01/lab01.py 本地评分:python3 ok --local → 13 个测试用例全部通过

0. 这份作业在练什么

Lab 0 只是让你把 Python 装上、把 ok 跑起来。Lab 1 是第一份需要你真的动脑子的作业。它总共十道题,分成三类,练的是三件互相独立、但都必须形成肌肉记忆的东西。

第一类是两组 WWPD(What Would Python Display,Python 会显示什么) 概念题(Q1、Q2)。它们不要你写代码,只要你在脑子里当一次 Python 解释器,说出交互式提示符下每一行会打印什么。这类题看起来像脑筋急转弯,实际上是这门课最重要的基本功:你必须清楚地区分「一个函数返回了什么值」和「一个函数在屏幕上打印了什么」。绝大部分初学者的 bug——函数明明「输出对了」但 ok 却判错——根源都在这里。

第二类是 Debugging Quiz(Q3),一组关于读 traceback(回溯信息)、写 doctest、用 print 调试的选择题。它不给分('points': 0),但它教你的东西会跟着你走完整个学期。

第三类才是写代码:falling、divisible_by_k、double_eights 三道必做题,加上 digit、middle、sum_digits 三道选做题。这六道题背后其实只有两个核心手法:

本次要点
  • return 立即终止函数调用;print 不终止。print 把东西写到屏幕上,然后函数继续往下跑。一个函数如果跑到函数体末尾都没执行 return,它返回 None,而 None 在交互式提示符下不显示。
  • 交互式提示符会显示表达式的值(用 repr 形式,字符串带引号);print 显示的是内容(不带引号)。同一行 'hello' 和 hello 的区别就是这个。
  • if / elif / else 是一条链,只会执行第一个为真的分支;而两个连着写的独立 if 会各自判断各自的。这个区别是 Q2 全部四道小题的考点。
  • 累积型 while 循环的四件套:循环前初始化累加器和计数器 → 循环条件 → 循环体里更新累加器 → 循环体最后更新计数器 → 循环后 return。falling、divisible_by_k、sum_digits 都是这个模板。
  • 用 n % 10 取出最右边一位数字,用 n // 10 把最右边一位砍掉。反复做这两件事,就能从右往左遍历一个整数的每一位。double_eights、sum_digits、digit 全靠它。

做之前该掌握什么

你需要知道:怎么用 def 定义一个函数;参数(parameter)和实参(argument)的区别;= 是赋值不是相等,== 才是相等判断;三个除法相关运算符的差别。最后这一条题面里专门复习了,这里再用一张表钉死:

表达式名称结果结果类型
25 / 4真除法 True Division6.25永远是 float
4 / 2真除法2.0float(注意不是 2)
25 // 4地板除 Floor Division6两边都是 int 时为 int
1 // 5地板除0int,把真除法的结果向下取整
25 % 4取模 Modulo(求余)1int
5 / 0、5 // 0、5 % 0—ZeroDivisionError

最常用的一个组合技:判断 x 能否被 y 整除,写 x % y == 0。判断偶数就是 x % 2 == 0。Q5 直接用到它。

注意

4 / 2 的值是 2.0 而不是 2。只要用了 /,哪怕除得尽,结果也是浮点数。如果 doctest 期望 2 而你的函数返回 2.0,ok 会判你错——它比较的是显示出来的文本。这一点在后面几道题里非常关键:凡是「答案应该是整数」的地方,一律用 //,不要用 /。

验证状态

本仓库中 labs/lab01/ 下的代码跑 python3 ok --local,结果是 13 个测试用例全部通过,包括两组 WWPD、Debugging Quiz 以及六道编程题。本页贴出的每一段解答代码,都与 labs/lab01/lab01.py 中通过评分的真实文件逐字一致。

1. WWPD:Return and Print

用 python3 ok -q return-and-print -u 进入这道题。-u 是 unlock(解锁)的意思:ok 会一行一行地把交互式会话念给你听,每到一个空位就停下来问你「这里会显示什么」。答对了才继续。这套题在本仓库中已经解锁完毕,答案存在 labs/lab01/tests/return-and-print.py 里。

题目要什么

给你两个函数定义,然后问你三行交互式命令各自会显示什么:

>>> def welcome():
...     print('Go')
...     return 'hello'

>>> def cal():
...     print('Bears')
...     return 'world'

>>> welcome()
______

>>> print(welcome(), cal())
______

「显示什么」这个说法要精确化:交互式提示符(>>>)下,你敲一个表达式,Python 做两件事——① 执行它,执行过程中任何 print 调用都会立刻把文字打到屏幕上;② 执行完得到一个值,如果这个值不是 None,Python 再把这个值的 repr 形式打到屏幕上。这两件事的输出会按发生顺序混在一起,这就是这道题全部的难点。

怎么想到的

第一次看到这种题,很容易凭直觉答「welcome() 显示 hello」,然后被判错。错在哪?错在你把两条输出通道当成了一条。

正确的做法是:把每一行拆成「执行期打印了什么」和「执行完剩下什么值」两栏,分别填,最后按时间顺序拼起来。

先看 welcome()。调用 welcome,函数体从上往下执行:

1 执行 print('Go')。print 是个内建函数,它把 'Go' 的内容(不带引号)写到屏幕上,换行。屏幕上现在有一行 Go。顺便一提:print 自己的返回值是 None,但这一行是个表达式语句,返回值被丢掉了。
2 执行 return 'hello'。函数调用立即结束,调用表达式 welcome() 的值是字符串 'hello'。
3 回到交互式提示符。它拿到值 'hello',不是 None,于是显示它的 repr——字符串的 repr 带引号,所以屏幕上出现 'hello'。

所以答案是两行:

Go
'hello'

注意第一行 Go 没有引号(是 print 打的),第二行 'hello' 有引号(是提示符回显的值)。这个引号的有无,就是区分「打印」和「返回」的最直接证据,务必看清。

再看 print(welcome(), cal())。这一行难在求值顺序。一个调用表达式 print(A, B) 的求值规则是:先求算子 print,再从左到右求每个算子数(operand),全部求完之后,才把这些值传给 print 去调用。所以:

1 求 print 这个名字 → 得到内建的 print 函数。此时屏幕上什么都没有。
2 求第一个算子数 welcome()。这会真的调用 welcome:它打印 Go(屏幕第 1 行),返回 'hello'。
3 求第二个算子数 cal()。它打印 Bears(屏幕第 2 行),返回 'world'。
4 现在两个算子数的值都有了:'hello' 和 'world'。调用 print('hello', 'world')。print 把多个参数用一个空格连起来,不带引号地写到屏幕上(屏幕第 3 行:hello world)。
5 print 的返回值是 None。整个表达式的值是 None,交互式提示符不显示 None。所以没有第 4 行。

答案是三行:

Go
Bears
hello world

为什么是这个顺序

这里有两个必须钉死的点。

第一,为什么 Go 和 Bears 在前,hello world 在后? 因为参数必须先求值完毕,才能调用外层的 print。welcome() 内部的 print('Go') 发生在「求参数」阶段,而外层 print 的输出发生在「调用」阶段。求参数一定早于调用。

第二,为什么最后一行是 hello world 而不是 'hello' 'world'? 因为这一行是 print 打的。print 显示的是 str 形式(内容本身),而提示符回显用的是 repr 形式(源代码形式,字符串要带引号)。同一个字符串对象,经过两条不同通道显示出来长得不一样。

核心结论

交互式提示符下一行命令的输出 = 执行过程中所有 print 的输出(按发生先后) + 最终值的 repr(除非值是 None)。写 WWPD 题时,永远按这两栏分别推。

return 'hello'print('hello')
函数是否立即结束是,后面的语句一律不执行否,继续往下执行
屏幕上出现什么什么都没有(除非在提示符下直接调用)立刻出现 hello
字符串带引号吗提示符回显时带:'hello'不带:hello
调用表达式的值'hello',可以赋给变量、参与运算None(print 的返回值)
能不能被 ok 拿去比较能,ok 检查返回值只能比较输出文本

验证

把这段真的在终端里跑一遍,你会看到与 labs/lab01/tests/return-and-print.py 中记录的答案完全一致的输出。那个文件里的 'code' 字段就是标准答案(已解锁为明文,并通过了哈希校验):

>>> welcome()
Go
'hello'
>>> print(welcome(), cal())
Go
Bears
hello world
常见误区

误区一:以为 welcome() 只显示一行 hello。 忽略了函数体里的 print('Go'),也忽略了回显要加引号。

误区二:以为 print(welcome(), cal()) 最后还会显示一行 None。 不会。交互式提示符对 None 这个值特殊处理:直接不显示。这也是为什么当你写了个只 print 不 return 的函数,在提示符下调用它时看不到任何返回值——它返回的正是 None。你可以自己验证:>>> print(print(1)) 会显示 1 然后 None,因为这时 None 是被外层 print 显式打印的,不是回显。

误区三:把 print 当成「函数的输出方式」。 后面所有作业里,ok 检查的几乎都是返回值。写完函数忘记 return、只写了 print,是这门课第一个月最高频的错误。

2. WWPD:What If?

命令是 python3 ok -q if-statements -u。这组题考的是控制流:if 和 elif 到底谁挡住了谁,以及 return 在 if 里出现时函数什么时候跑到底、什么时候中途退出。题面给的 Hint 只有一句:print(不像 return)不会让函数退出。这句话是全部四道小题的钥匙。

先把 if/elif 的规则说死

初学者对 if 最大的误解,是把它当成「筛选器」,以为写在一起的几个条件会各自独立地判断。实际规则是:

核心结论

一个 if ... elif ... elif ... else 结构是一条链,最多执行其中一个分支。Python 自上而下依次求值每个条件,第一个为真的分支被执行,执行完整条链就结束了,后面的 elif/else 连条件都不会去算。

而两个紧挨着写的独立 if(第二个不是 elif)是两条独立的链。第一条走不走,完全不影响第二条要不要判断。

热身:so_big

题面里先给了一个例子(这个不在 ok 的测试里,但它是理解后面两道的台阶):

>>> def so_big(x):
...     if x > 10:
...         print('huge')
...     if x > 5:
...         return 'big'
...     if x > 0:
...         print('small')
...     print("nothin'")

注意这里是三个独立的 if,不是一条链。所以每个都要单独判断——除非中途被 return 打断。

逐步推演

so_big(7):7 > 10 假,跳过。7 > 5 真,执行 return 'big' → 函数立刻结束,后面两个 if 和最后的 print("nothin'") 一行都不会跑。提示符回显返回值。输出:'big'(带引号,一行)。

so_big(12):12 > 10 真,打印 huge(第 1 行)。print 不终止函数,继续。12 > 5 真,return 'big' 结束。回显 'big'(第 2 行)。输出两行:huge / 'big'。

so_big(1):1 > 10 假。1 > 5 假,所以没有任何 return 被执行。1 > 0 真,打印 small(第 1 行)。继续,打印 nothin'(第 2 行)。函数体跑到底,没遇到 return → 返回 None → 提示符不显示。输出两行:small / nothin'。

这三个调用一次性演示了三种情形:return 提前退出、print 之后继续、跑到底返回 None。看懂了它们,下面两道 ok 真题就没有新东西了。

ok 真题一:ab

>>> def ab(c, d):
...     if c > 5:
...         print(c)
...     elif c > 7:
...         print(d)
...     print('foo')

>>> ab(10, 20)
______

怎么想到的。 看到 ab(10, 20),很多人会同时勾选两个条件:10 > 5 真、10 > 7 也真,于是答 10、20、foo 三行。这是把 elif 当成了独立的 if。

正确推演:调用 ab(10, 20),形式参数 c 绑定 10,d 绑定 20。

1 求 c > 5 → 10 > 5 → True。执行这个分支:print(c) 打印 10。
2 整条 if/elif 链到此结束。 elif c > 7 的条件根本不会被求值,print(d) 更不会执行。这不是「10 > 7 算出来是假」,而是「压根没算」。
3 链结束后继续往下:print('foo') 打印 foo。
4 函数体到底,无 return → 返回 None → 提示符不显示。

答案:

10
foo

这道题的设计很刁:c > 7 比 c > 5 更严格,写成 elif 之后,elif 分支永远不可能被执行——因为任何满足 c > 7 的 c 必然也满足 c > 5,早就被第一个分支截走了。这是真实代码里非常常见的一类 bug:条件顺序写反,导致某个分支成为死代码。

ok 真题二:bake

>>> def bake(cake, make):
...     if cake == 0:
...         cake = cake + 1
...         print(cake)
...     if cake == 1:
...         print(make)
...     else:
...         return cake
...     return make

>>> bake(0, 29)
______
>>> bake(1, "mashed potatoes")
______

先看清结构。 这里有两条独立的链:第一条只有一个 if cake == 0(没有配对的 else);第二条是 if cake == 1 / else。最后那句 return make 在两条链之外,缩进回到函数体第一层。

关键的陷阱在于第一条链里有一句 cake = cake + 1——它修改了 cake 的绑定。所以第二条链判断 cake == 1 时用的是新值,不是传进来的原值。这就是为什么把两条链写成独立的 if 而不是 if/elif:出题人就是要让第一条链的副作用影响第二条链的走向。

逐步推演:bake(0, 29)

局部帧里 cake = 0,make = 29。

① cake == 0 → 0 == 0 → True,进入分支。

② cake = cake + 1:先求右边 0 + 1 = 1,再把 cake 重新绑定到 1。现在帧里 cake = 1。

③ print(cake) 打印 1。屏幕第 1 行:1

④ 第一条链结束。进入第二条链:cake == 1 → 用的是更新后的 cake,即 1 == 1 → True。

⑤ 执行 print(make) 打印 29。屏幕第 2 行:29。注意 29 是整数,print 打整数不带引号(本来也没有引号)。

⑥ else: return cake 被跳过(因为 if 分支已执行)。

⑦ 执行链外的 return make → 返回 29。函数结束。

⑧ 提示符拿到值 29,不是 None,回显 29。整数的 repr 就是 29。屏幕第 3 行:29

所以 bake(0, 29) 输出三行:

1
29
29

后两行都是 29,但来源完全不同:第二行是 print(make) 打的,第三行是提示符回显 return make 的返回值。这道题故意用整数 29,就是为了让你看不出引号差别、必须靠推理而不是靠眼力。

逐步推演:bake(1, "mashed potatoes")

局部帧里 cake = 1,make = 'mashed potatoes'。

① cake == 0 → 1 == 0 → False。第一条链整个跳过——cake 不会被加 1,也不会打印。

② 第二条链:cake == 1 → True。

③ print(make) 打印 mashed potatoes。屏幕第 1 行:mashed potatoes,不带引号(print 用的是 str 形式)。

④ else 跳过。

⑤ return make → 返回字符串 'mashed potatoes'。

⑥ 提示符回显它的 repr:屏幕第 2 行:'mashed potatoes',带引号。

mashed potatoes
'mashed potatoes'

这两行内容一模一样,唯一的差别就是引号——这是全课最直白的一次「print 输出 vs 值回显」对照实验。如果你能一眼说清为什么第一行没引号第二行有引号,Q1 和 Q2 的知识点就算掌握了。

顺带想一想:else 分支什么时候能走到

bake 的 else: return cake 在上面两个调用里都没被执行。什么时候会执行?当 cake 既不等于 0 也不等于 1 时,比如 bake(5, 'x'):第一条链跳过,第二条链 5 == 1 假 → 走 else → return 5,函数立即结束,最后那句 return make 永远不会被执行到。输出只有一行 5,没有任何 print 输出。(这条不在 ok 的测试里,是自己推的,你可以在 python3 -i 里验证。)

常见误区

误区一:把 elif 当独立 if。 在 ab 里答成三行 10 / 20 / foo。记住:elif 只在前面所有条件都为假时才被求值。

误区二:在 bake 里用传进来的原始 cake 判断第二条链。 答成 bake(0, 29) 输出 1 / 29 两行(以为 cake 还是 0,走 else 返回 0)。变量被重新赋值后,后续代码看到的就是新值——这就是「名字到值的绑定会随赋值语句改变」,是这门课的第二个母题。

误区三:以为 return 后面的 print 还会执行。 不会。return 一执行,控制权立刻交还给调用者,函数体剩下的所有语句全部作废。题面 Review 里那个 what_prints 例子(return 后面还写了一句 print('This course is awesome!'))就是专门给你看这个的:那句永远打不出来。

3. Debugging Quiz

命令 python3 ok -q debugging-quiz -u。七道选择题,'points': 0——不计分,但这是整个学期你会反复用到的工具箱。答案在 labs/lab01/tests/debugging-quiz.py 的每个 case 的 'answer' 字段里。下面逐题讲为什么选那个。

题 1、题 2:读 traceback

两道题共用同一段回溯信息:

Traceback (most recent call last):
  File "temp.py", line 10, in <module>
    f("hi")
  File "temp.py", line 2, in f
    return g(x + x, x)
  File "temp.py", line 5, in g
    return h(x + y * 5)
  File "temp.py", line 8, in h
    return x + 0
TypeError: must be str, not int

题 1:最近的一次函数调用是哪个? 选项是 f("hi") / g(x + x, x) / h(x + y * 5),答案是 h(x + y * 5)。

理由就写在第一行:Traceback (most recent call last)——最近的调用排在最后。这行英文很多人一扫而过,但它决定了整个 traceback 该从哪头读。这一栈从上往下是「谁调用了谁」的时间顺序:模块顶层第 10 行调用了 f("hi");f 的第 2 行调用了 g(x + x, x);g 的第 5 行调用了 h(x + y * 5);然后在 h 的第 8 行 return x + 0 处炸了。

所以三个候选调用中,h(x + y * 5) 是发生时间最晚的那一个。

直觉

读 traceback 的正确姿势:从下往上看。最后一行是错误类型和错误信息(最有用),倒数第二个 File 块是真正出错的那一行代码(第二有用),再往上是「是谁把我叫到这儿来的」调用链(用来定位是哪次调用传了坏数据)。初学者常常从上往下读,看到第一行 f("hi") 就以为问题出在 f,方向就反了。

题 2:这个错误的原因是什么? 选项是「代码试图把一个字符串加到一个整数上」/「代码陷入了死循环」/「缺少 return 语句」,答案是第一个。

依据是最后一行:TypeError: must be str, not int。TypeError 是「类型不匹配」,信息直译是「必须是 str,不能是 int」。再看出错行 return x + 0:0 是 int,那么 x 必然是 str——Python 在做 str + int 时抱怨右边不是字符串。

顺着调用链还能把这个 str 的来源追出来:顶层传的是 "hi"(字符串)→ f 里 x + x 就是 "hihi"(字符串拼接,合法)→ 传给 g 之后 x + y * 5 也还是字符串(字符串乘整数是重复,也合法)→ 传给 h,h 里 x + 0 就崩了。类型错误往往在离源头很远的地方才爆炸,这正是要学会顺着调用链回溯的原因。

另外两个选项为什么排除:死循环根本不会产生 traceback(它会一直卡住,除非你 Ctrl-C,那时报的是 KeyboardInterrupt);缺少 return 也不会报错,只会让函数悄悄返回 None,通常表现为后续的 TypeError: unsupported operand type(s) for +: 'NoneType' and 'int' 之类,错误信息长得不一样。

题 3:doctest 怎么写

问「如何写一个 doctest 断言 square(2) == 4」,四个选项分别用了 doctest: (2, 4)、input:/output:、没有 >>> 的裸调用、以及带 >>> 的标准写法。答案是:

def square(x):
    '''
    >>> square(2)
    4
    '''
    return x * x

doctest 的格式是死的:写在文档字符串(docstring)里,一行以 >>> 加一个空格开头,后面跟要执行的表达式;紧接着的下一行(或几行)写期望在交互式提示符下看到的输出,一字不差。doctest 模块靠的就是 >>> 这个标记去识别测试;没有它的行只是普通说明文字,会被完全忽略——这就是第三个选项(square(2) 后面直接跟 4,但缺了 >>>)错的原因:它看起来像测试,但 doctest 根本不会执行它,你会得到「零个测试全部通过」的假安全感。

「一字不差」这四个字要重视。既然期望输出是照抄提示符的显示结果,那么前面反复强调的规则在这里全部生效:字符串带引号、None 不显示、2.0 和 2 是两回事。>>> falling(4, 0) 下面写 1,你的函数返回 1.0 就算失败。

跑 doctest 的命令题面也给了:python3 -m doctest lab01.py。全部通过时没有任何输出——静默即成功。想看细节加 -v。

题 4:什么时候该用 print 调试

三个选项:「用于永久性调试,从而对代码有长期信心」/「用于确保代码在某些点满足某些条件」/「用于查看代码运行到某处时各变量的值」。答案是第三个。

这道题在划分三种工具的职责,值得用表格钉死:

工具用途该不该留在最终代码里
print 语句临时查看某一刻变量的值,回答「跑到这里时 i 到底是多少」不该。调完就删
assert 语句确保某个条件在某处成立,不成立就立刻崩掉可以留,它是代码的一部分
doctest / 单元测试永久性地保证行为正确,每次改动后重跑该留,这才是长期信心的来源

选项一说的是测试的职责,选项二说的是 assert 的职责,都被安到了 print 头上。print 的价值恰恰在于它轻、快、用完即弃:你怀疑循环变量走错了,就在循环体里插一行 print,跑一次,看清楚,删掉。

题 5:怎么让 ok 忽略你的调试输出

答案:在输出行开头打印 'DEBUG:'。

print("DEBUG:", x)

这条规则的必要性来自 Q5 那种题:divisible_by_k 的正确性一部分体现在它打印了什么,ok 会逐行比对输出。这时你要是为了调试多 print 了一行,测试立刻失败,而且失败信息看起来像是你的逻辑错了,非常误导人。ok 的对策是:凡是以 DEBUG: 开头的输出行一律丢弃,不参与比对。

另外两个选项都是错的:「ok 只看返回值不看打印」不成立(WWPD 题和 divisible_by_k 都比对打印内容);「用 # 开头」也不行——# 在 Python 里是注释符号,跟运行时的输出内容毫无关系,ok 不认这个。

题 6:怎么开一个交互终端调查失败的测试

问:lab01 里 sum_digits 这道题测试没过,最好用哪个命令开交互终端?四个选项,答案是:

python3 ok -q sum_digits -i

-q sum_digits 指定只跑这一道题;-i(interactive)的作用是:当某个测试用例失败时,ok 会在失败的那一刻打开一个交互式提示符,而且此时环境里已经加载好了你的 lab01.py、以及那个失败用例执行到一半的所有变量。你可以当场敲 sum_digits(4224) 看它到底返回了什么、试试 4224 % 10 对不对。

对比另外三个选项:python3 ok -q sum_digits(不带 -i)只告诉你「期望 12,实际得到 8」,然后就退出了,你还得自己想办法复现;--trace 不是这个用途;python3 -i lab01.py 确实能开交互终端并加载你的函数,但它不知道哪个测试失败了、失败时的输入是什么,你得手动把测试用例再敲一遍。-i 省掉的正是这一步。

题 7:以下哪一条「不」正确

五个选项,问哪个是错的。答案是:

「代码返回一个错误答案总比崩溃好,因为它至少还能工作。」

这句话是错的,而且错得很重要。一个会崩溃的程序远好过一个默默给出错误答案的程序。 崩溃是响亮的:你立刻知道出了事,traceback 还告诉你出在哪一行。而返回错误答案是无声的:它会一路传下去,污染后续所有计算,等你发现时已经查不出源头了——这正是题 1 那个 traceback 的反面教材:那个错误好在它崩了,所以你能顺着栈追到 h 里的 x + 0。

另外四条都是对的,也都值得记:测试对写出健壮代码很重要;调试不能替代测试(调试是事后救火,测试是事前和事后的保障,两者不是一回事);把调试用的 print 留在发布代码里是坏习惯;把 assert 留在代码里则是好习惯。注意最后两条恰好呼应题 4 的那张表——print 是临时的,assert 是永久的。

核心结论

这七道题串起来其实是一套完整的工作流:写 doctest 定义正确性 → 跑 ok 发现失败 → 读 traceback 定位出错行和错误类型 → 用 ok -q 题名 -i 进现场 → 插 print("DEBUG:", ...) 看变量 → 改对后把 print 删掉。这个流程从 Lab 1 用到项目 Scheme 解释器,一次都不会变。

4. Falling Factorial falling

题目要什么

def falling(n, k):
    """Compute the falling factorial of n to depth k.

    >>> falling(6, 3)  # 6 * 5 * 4
    120
    >>> falling(4, 3)  # 4 * 3 * 2
    24
    >>> falling(4, 1)  # 4
    4
    >>> falling(4, 0)
    1
    """

翻成人话:从 n 开始往下数 k 个连续整数,把它们乘起来返回。 「往下数 k 个」的意思是这 k 个因子依次是 n、n-1、n-2、……、n-k+1。

四个 doctest 恰好把边界一路收缩给你看:

调用因子最小的因子结果
falling(6, 3)6, 5, 46 - 3 + 1 = 4120
falling(4, 3)4, 3, 24 - 3 + 1 = 224
falling(4, 1)444
falling(4, 0)(一个都没有)—1

边界情况就是最后一行:k 为 0 时返回 1。 为什么是 1 而不是 0?因为这是一个连乘。「零个数相乘」的结果按数学惯例是乘法单位元 1,正如「零个数相加」是 0。更实际的理由:只有取 1,递推关系 falling(n, k) = n * falling(n-1, k-1) 才在 k=1 时也成立(falling(4,1) = 4 * falling(3,0) = 4 * 1 = 4)。如果 k=0 返回 0,那所有结果都会变成 0。

注意这里不是普通阶乘。falling(6, 3) 不是 6!,它只乘三项就停。数学上这叫「下降阶乘幂」,记作 \(n^{\underline{k}} = n(n-1)\cdots(n-k+1)\)。

怎么想到的

看到「把一串数乘起来」,脑子里该跳出来的模板是累积循环:搞一个变量存「到目前为止的乘积」,然后让另一个变量走遍所有因子,每走一步就乘进去。这个模板的骨架是固定的:

total = 初始值
while 还有因子:
    total = total * 当前因子
    移到下一个因子
return total

初始值必须是 1(不是 0,否则乘什么都是 0)。剩下要填的只有两个空:「当前因子」用哪个变量?「还有因子」这个条件怎么写?

第一版想法(会崩的那个)。 最自然的写法是搞个计数器 i 从 0 数到 k-1,因子是 n - i:

total, i = 1, 0
while i < k:
    total = total * (n - i)
    i = i + 1
return total

这个是对的,而且 k=0 时循环一次都不进、直接返回 1,边界也自动对了。但它用了两个「新」变量 i 和常量 k,因子要写成 n - i 这种间接形式,读起来隔了一层。

第二版想法:能不能不要计数器? 观察一下:因子本身就是从 n 一路递减的。既然 n 是个局部变量(形式参数在函数帧里就是普通的局部名字,改它完全不影响调用者),那就直接拿 n 当循环变量,每轮乘完就让 n 减 1。因子直接写 n,不用算。

但这样一来循环条件怎么写?n 在动,k 不动,我需要知道「n 减到多少该停」。答案是:循环开始前先把终点算出来,存在一个变量里。 最后一个要乘的因子是 n - k + 1,所以第一个不该乘的是 n - k。把它叫 stop:

关键一步

令 stop = n - k,循环条件写 while n > stop。这样循环体正好执行 k 次,n 依次取 n, n-1, ..., stop+1(也就是 n-k+1),一个不多一个不少。

stop 必须在循环之前算好并固定下来。如果你把条件写成 while n > n - k,那每轮 n 一变,右边也跟着变,条件永远是 n > n - k(当 k > 0 时恒为真)→ 死循环。这就是「把随时间变化的量在开头快照一次」的典型用法。

再检查一遍边界。 k = 0 时 stop = n - 0 = n,循环条件 n > n 为假,一次都不进,直接 return total 也就是初始值 1。边界不需要任何特判——这是好设计的标志:如果你发现自己要为边界情况写 if,通常说明主逻辑还能再改改。

代码

def falling(n, k):
    total, stop = 1, n - k
    while n > stop:
        total, n = total * n, n - 1
    return total

逐行看:

total, stop = 1, n - k——这是多重赋值(multiple assignment)。Python 先把右边所有表达式全部求值,得到 1 和 n-k 两个值,然后再一起绑定给左边两个名字。写成两行 total = 1 和 stop = n - k 完全等价,这里合并只是为了紧凑。注意 n - k 用的是此刻的 n(还没被改过),这正是我们要的快照。

while n > stop:——每轮开始前检查一次。stop 不变,n 每轮减 1,所以最多跑 k 轮,一定会终止(对 k ≥ 0 而言)。

total, n = total * n, n - 1——这一行是整段代码的心脏,也是必须用多重赋值的地方。规则重申:右边两个表达式先全部用「旧值」算完,再同时绑定给左边。

注意

如果拆成两行,顺序错了就出错:

n = n - 1          # 先把 n 减了
total = total * n  # 这里乘进去的是减完之后的 n,错!

正确的拆法是把乘法放前面:

total = total * n
n = n - 1

而多重赋值 total, n = total * n, n - 1 让你不必操心顺序——右边全用旧值。这也是为什么在这门课里交换两个变量可以直接写 a, b = b, a,不需要临时变量。

return total——注意它在 while 外面(缩进和 while 对齐)。如果误缩进到循环体里,函数第一轮就返回了,falling(6,3) 会得到 6 而不是 120。

验证

手动追踪 falling(6, 3)。进入函数时局部帧里 n = 6,k = 3。第一行执行后 total = 1,stop = 6 - 3 = 3。

时刻ntotaln > stop(stop=3)本轮做了什么
循环前616 > 3 → 真进入循环
第 1 轮后51 × 6 = 65 > 3 → 真把因子 6 乘进去,n 降到 5
第 2 轮后46 × 5 = 304 > 3 → 真把因子 5 乘进去,n 降到 4
第 3 轮后330 × 4 = 1203 > 3 → 假把因子 4 乘进去,n 降到 3,退出
return3120—返回 120 ✓

因子恰好是 6、5、4 三个,与 doctest 注释 # 6 * 5 * 4 一致,结果 120 ✓。

再用环境图看一眼第 1 轮那句多重赋值,把「先算右边、再绑左边」画清楚:

执行 total, n = total * n, n - 1  之前
┌─ falling 帧 ──────────────┐
│ parent: Global            │
│ n     → 6                 │
│ k     → 3                 │
│ total → 1                 │
│ stop  → 3                 │
└───────────────────────────┘

步骤 A:求右边(全部用旧值)
   total * n  →  1 * 6  →  6
   n - 1      →  6 - 1  →  5      ← 这里的 n 仍是 6

步骤 B:同时绑定
┌─ falling 帧 ──────────────┐
│ n     → 5     (被改)     │
│ k     → 3                 │
│ total → 6     (被改)     │
│ stop  → 3     (不变)     │
└───────────────────────────┘

再快速验一下边界 falling(4, 0):total = 1,stop = 4 - 0 = 4,条件 4 > 4 为假,循环体一次都不执行,return 1 ✓。

常见误区

误区一:把 stop 写进循环条件里现算。 while n > n - k 是死循环(k > 0 时条件恒真),程序会一直跑到你 Ctrl-C,或者因为 total 无限增大而卡死。ok 会显示 Test timed out。

误区二:total 初始化成 0。 结果永远是 0,因为 0 * 任何数 = 0。累加用 0 起步、累乘用 1 起步,别串了。

误区三:为 k == 0 写特判。 像 if k == 0: return 1 虽然不算错,但说明你没意识到主逻辑已经自动覆盖了这个情况。多余的特判越多,出 bug 的面越大。

误区四:return 缩进进 while 里。 这是初学者最高频的缩进错误,症状是「结果总是只乘了第一项」。falling(6, 3) 会返回 6。

误区五:用 print(total) 代替 return total。 屏幕上看到 120,你以为对了,但 ok 报错说期望 120 得到 None——因为函数体跑到底没有 return,返回的是 None。这就是第 1 节那个知识点的第一次实战。

5. Divisible By k divisible_by_k

题目要什么

def divisible_by_k(n, k):
    """
    >>> a = divisible_by_k(10, 2)  # 2, 4, 6, 8, and 10 are divisible by 2
    2
    4
    6
    8
    10
    >>> a
    5
    >>> b = divisible_by_k(3, 1)  # 1, 2, and 3 are divisible by 1
    1
    2
    3
    >>> b
    3
    >>> c = divisible_by_k(6, 7)  # There are no integers up to 6 that are divisible by 7
    >>> c
    0
    """

这道题要做两件事,而且缺一不可:

  1. 打印所有满足 1 ≤ i ≤ n 且 i 能被 k 整除的整数 i,从小到大,每个一行。
  2. 返回一共打印了多少个数。

这是本次作业里唯一一道同时考察 print 和 return 的题,也正因为如此,它是前两节 WWPD 知识点的最佳落地练习。ok 会同时检查两样东西:屏幕上打印出的每一行文字,以及函数的返回值。

注意 doctest 的写法很讲究:它写成 a = divisible_by_k(10, 2) 而不是直接 divisible_by_k(10, 2)。赋值语句本身没有值,所以提示符不会回显任何东西——这一行下面的 2 4 6 8 10 五行全部是函数体里 print 打出来的,跟返回值无关。返回值被存进了 a,下一行单独敲 a 才回显出 5。这样写就把两条输出通道彻底分开了,你没法蒙混过关。

边界情况在第三个例子:divisible_by_k(6, 7)。 1 到 6 里没有 7 的倍数,所以一行都不打印,返回 0。注意 doctest 里 >>> c = divisible_by_k(6, 7) 这一行下面紧接着就是 >>> c,中间是空的——doctest 用「没有输出行」来断言「什么都没打印」。如果你的函数在这种情况下多打了一个 0,测试就会失败。

再看第二个例子 divisible_by_k(3, 1):k = 1 时每个数都能被 1 整除,所以 1、2、3 全打印,返回 3。这个例子提醒你循环得从 1 开始,不是从 k 开始也不是从 0 开始——0 能被任何非零数整除(0 % 2 == 0),但题目说的是「正整数」,0 不在范围内。

怎么想到的

第一步:把「所有小于等于 n 的正整数」变成一个循环。 这是最直接的读法——题目说「小于等于 n 的正整数」,那就让 i 从 1 走到 n,一个都不漏:

i = 1
while i <= n:
    ...
    i = i + 1

条件必须是 i <= n(小于等于),不是 i < n。因为 divisible_by_k(10, 2) 的输出里包含 10 本身。这种「差一错误」(off-by-one error)是循环题最常见的 bug,写之前先拿一个 doctest 对一下端点,能省很多时间。

第二步:在循环体里筛。 「i 能被 k 整除」怎么写?题面 Review 已经给了:i % k == 0。这里必须小心一个语义方向——是 i % k 不是 k % i。i % k == 0 读作「i 除以 k 余数为 0」,即 i 是 k 的倍数。搞反了的话 divisible_by_k(10, 2) 会打出一堆奇怪的数(2 % 1 == 0、2 % 2 == 0……方向反了含义就全变了)。

第三步:计数。 题目要返回「打印了几个」。最省事的想法是:打印和计数在同一个地方发生——凡是我 print 了一个数,我就把计数器加 1。把这两句绑在同一个 if 分支里,就永远不会对不上。

关键一步

不要想着「先算出有多少个,再打印」,也不要想着用公式 n // k 直接算个数(虽然数学上确实等于 n // k)。因为题目要求你打印它们,既然已经在循环里逐个访问了,顺手 count = count + 1 是零成本的。让计数器紧贴着 print 写,是保证「返回值 = 打印行数」的最稳做法。

第四步:检查边界。 count 初始化成 0(这次是累加,不是累乘,所以起点是 0 不是 1)。divisible_by_k(6, 7) 时循环跑 6 轮,if 一次都不成立,count 保持 0,返回 0,也没打印任何东西 ✓。同样不需要特判。

一条弯路值得提。 有人会想跳着走,让 i 直接从 k 开始、每次加 k:

i = k
while i <= n:
    print(i)
    count = count + 1
    i = i + k

这个版本也是对的,而且效率更高(只跑 n//k 轮而不是 n 轮)。但它有个隐患:如果 k 是 0,i = i + k 就永远不动,死循环。而 i % k 那个版本在 k=0 时会干脆地抛 ZeroDivisionError——崩掉比死循环好(正是 Debugging Quiz 题 7 的道理)。题目保证 k 是正整数,两个版本都能过 ok,本仓库交上去的是逐个检查的那版,因为它跟题目描述「所有小于等于 n 的正整数中……」一一对应,最不容易想错。

代码

def divisible_by_k(n, k):
    count = 0
    i = 1
    while i <= n:
        if i % k == 0:
            print(i)
            count = count + 1
        i = i + 1
    return count

逐行看:

count = 0——累加器初始化。它记录「到目前为止打印了几个」,一开始是零个。

i = 1——循环变量从 1 开始,因为题目要的是正整数。

while i <= n:——包含端点 n。

if i % k == 0:——整除判断。% 的优先级高于 ==,所以它等价于 (i % k) == 0,不用加括号。

print(i) 和 count = count + 1——两句都在 if 体里,缩进相同。这个缩进是本题的正确性关键:count 必须只在打印时才加,否则返回值会变成 n(每轮都加)。

i = i + 1——在 while 体里、if 体外。缩进级别比 print 少一层。这是本题第二个缩进关键点:i 必须每轮都推进,不管这一轮有没有打印。如果误缩进到 if 里面,那么第一个不能整除的 i 就会让循环卡住不动 → 死循环。

return count——在 while 外面。函数结束时把总数交出去。

注意

这道题里同一段代码有三个不同的缩进层级,各管各的事:

语句层级执行频率
count = 0 / i = 1 / return count函数体(1 层)整个调用中各一次
if i % k == 0 / i = i + 1while 体(2 层)每轮一次,共 n 次
print(i) / count = count + 1if 体(3 层)只在整除时,共 n // k 次

Python 用缩进表达结构,缩进错一格,语义就完全不同,而且通常不会报语法错误——它会安静地跑出错误结果或者死循环。写完之后逐行核对缩进层级,是这门课必须养成的习惯。

验证

追踪 divisible_by_k(10, 2)。进入时 n = 10,k = 2,初始化后 count = 0,i = 1。

轮次轮首 ii <= 10i % 2打印轮末 count
11真1—0
22真021
33真1—1
44真042
55真1—2
66真063
77真1—3
88真084
99真1—4
1010真0105
—11假,退出——5

屏幕上依次出现 2、4、6、8、10 共五行,与 doctest 一致;return count 返回 5,被赋给 a,下一行敲 a 回显 5 ✓。

再验边界 divisible_by_k(6, 7):i 从 1 走到 6,每轮算 i % 7 得到 1、2、3、4、5、6,没有一个是 0(因为 i < 7 时 i % 7 就等于 i 本身),if 一次都不进。i 变成 7 时条件 7 <= 6 为假,退出,返回 count = 0。屏幕上一个字都没有 ✓。

常见误区

误区一:把 i = i + 1 缩进到 if 体里。 divisible_by_k(10, 2) 会在 i = 1 时卡死:1 % 2 != 0,不进 if,i 不变,下一轮还是 1……ok 报 Test timed out。这是本题最高频的错误。

误区二:把 count = count + 1 写在 if 外面。 语法完全正确,打印也完全正确,只有返回值错:divisible_by_k(10, 2) 会返回 10 而不是 5。ok 的报错会说打印部分匹配、但 a 的值是 10 不是 5。

误区三:写成 return count 在循环里,或者用 print(count) 结尾。 前者第一轮就退出返回 0;后者会在输出末尾多打一行 5,且函数返回 None,两处都对不上。

误区四:条件写成 i < n。 divisible_by_k(10, 2) 会漏掉 10,只打四行、返回 4。

误区五:把整除判断写成 k % i == 0。 方向反了。而且 i 从 1 开始时 k % 1 == 0 恒真,你会看到一个莫名其妙打出 1 的结果。

误区六:调试时随手加了 print(i, count) 忘了删。 这道题 ok 会逐行比对输出,多一行就挂。如果一定要在这里调试,用 print("DEBUG:", i, count)——Debugging Quiz 题 5 讲的那个技巧,正是为这种题准备的。

6. Double Eights double_eights

题目要什么

def double_eights(n):
    """Return true if n has two eights in a row.
    >>> double_eights(8)
    False
    >>> double_eights(88)
    True
    >>> double_eights(2882)
    True
    >>> double_eights(880088)
    True
    >>> double_eights(12345)
    False
    >>> double_eights(80808080)
    False
    """

判断一个数的十进制表示里有没有相邻的两个 8。关键词是相邻——挨着的、中间不隔任何数字。六个 doctest 每一个都在堵一条歧义:

调用期望它在堵什么
double_eights(8)False只有一个 8 不算。「两个」是硬要求
double_eights(88)True最简单的正例
double_eights(2882)True相邻的 88 出现在中间,两头有别的数字
double_eights(880088)True出现两处 88,找到一处就该返回 True
double_eights(12345)False一个 8 都没有
double_eights(80808080)False本题的杀手锏:有四个 8,但两两之间都隔着 0,一处相邻都没有

最后一个例子把所有偷懒解法一网打尽。「数一数有几个 8,≥2 就返回 True」——被 80808080 毙掉。「看看有没有 8 出现过两次以上」——同样被毙。必须真的判断「位置相邻」。

还有一个细节:返回值必须是布尔值 True / False,不是 1 / 0,也不是字符串。doctest 比对的是显示文本,1 和 True 显示出来不一样。

怎么想到的

第一个念头:能不能转成字符串? '88' in str(n) 一行就完事了。这确实能过 ok,但它绕开了这道题真正要练的东西——用算术把一个整数逐位拆开。CS 61A 这个阶段还没讲字符串方法,而且后面 sum_digits、digit、以及未来递归章节的一大堆题都建立在「% 10 取末位、// 10 去末位」这套手法上。所以正经做法是算术。

第二步:建立拆数字的基本动作。 对一个正整数 n:

操作n = 2882 时含义
n % 102取出最右边一位(个位)
n // 10288砍掉最右边一位,剩下的部分

反复做这两件事,就能从右往左把每一位都过一遍,直到 n 变成 0(这时所有位都取完了)。循环骨架:

while n > 0:
    last = n % 10   # 当前最右位
    n = n // 10     # 砍掉它
    ... 处理 last ...

这个循环一定会终止:n 每轮至少变成原来的十分之一(向下取整),有限步内必然到 0。

第三步:怎么判断「相邻」?这是本题真正的坎。

循环每轮只能看到一位数字 last。要判断相邻,我需要同时知道这一位和上一位。可上一位在上一轮就已经被处理掉了,怎么办?

方案 A(走不通的直觉):一次取两位。 用 n % 100 == 88 判断末两位是不是 88,然后 n = n // 10(注意只砍一位,不是两位,否则会跳过重叠的位置)。这个方案其实可行:

while n > 9:            # 至少还有两位
    if n % 100 == 88:
        return True
    n = n // 10
return False

它是对的,但有两个容易写错的地方:循环条件必须是 n > 9(剩一位时就没必要看了,写成 n > 0 也不会错但多跑一轮),以及每轮只能砍一位——如果写成 n = n // 100,2882 会先看 82(不是 88),然后 n 变成 28,看 28,还是不是 88,返回 False,错过了中间的 88。这个坑很深。

方案 B(本仓库采用的):用一个变量记住上一位是不是 8。 与其每轮回头取两位,不如让循环带着记忆往前走:设一个布尔变量 prev_was_eight,含义是「我刚刚看过的那一位是 8 吗」。那么每轮的判断就变成:

关键一步

「当前位是 8」并且「上一位也是 8」→ 找到相邻的两个 8 → 立刻 return True。

处理完当前位之后,无论如何都要更新记忆:prev_was_eight = (last == 8)。注意这是直接赋值,不是 if last == 8: prev_was_eight = True——因为当前位不是 8 时必须把记忆清掉。忘了清就是 80808080 会误判的原因:第一次遇到 8 之后记忆一直是 True,等到第二个 8(中间隔着 0)时就会误报。

这个「用一个变量携带上一步的信息」的模式叫状态变量,是循环编程里最基础也最重要的技巧之一。方案 B 比方案 A 好在:它每轮只关心一位,逻辑更贴近「相邻」这个概念本身,而且天然推广——如果题目改成「三个连续的 8」,方案 B 加个计数器就行,方案 A 要改成 % 1000 且循环条件也得跟着改。

第四步:初始值和边界。 prev_was_eight 一开始设 False——进入循环前我还没看过任何一位,所以「上一位是 8」当然是假的。这保证了 double_eights(8):唯一一轮里 last = 8,但 prev_was_eight 是 False,不返回 True;更新记忆后循环结束(n 变成 0),走到 return False ✓。

第五步:为什么中途 return True、末尾 return False? 因为「存在性」问题只需要找到一个证据就能下结论。一旦发现相邻的 88,答案已经确定,没必要继续扫;return True 立刻结束函数,这也是 880088 那种有多处 88 的情况不会重复计算的原因。只有把所有位都扫完一个证据都没找到,才能断言 False——所以 return False 必须在循环外面。

代码

def double_eights(n):
    prev_was_eight = False
    while n > 0:
        n, last = n // 10, n % 10
        if last == 8 and prev_was_eight:
            return True
        prev_was_eight = (last == 8)
    return False

逐行看:

prev_was_eight = False——状态变量初始化。名字起得很实在:它就读作「上一位是 8 吗」。

while n > 0:——只要还有位没处理完就继续。n 归零意味着所有位都取过了。

n, last = n // 10, n % 10——又是多重赋值,又是「右边先全部用旧值算完」。这里必须用多重赋值或者注意顺序:

注意

如果拆成两行且顺序写反:

n = n // 10     # n 已经被砍掉一位了
last = n % 10   # 取的是砍完之后的末位,错位了一格!

n = 2882 时,正确应得 last = 2、n = 288;写反则得 n = 288、last = 8——你取到的是十位而不是个位,整个循环全错位。正确的拆法是先取后砍:

last = n % 10
n = n // 10

多重赋值 n, last = n // 10, n % 10 里两个表达式用的都是同一个旧 n,天然没有这个问题——这就是为什么这门课里如此偏爱它。

if last == 8 and prev_was_eight:——两个条件用 and 连起来,必须同时成立。and 是短路(short-circuit)的:先求 last == 8,如果为假就直接得出 False,右边 prev_was_eight 根本不会被求值。在这道题里两边都没有副作用,所以短路只影响效率不影响结果;但求值顺序这件事本身是这门课的核心考点,随时提醒自己。

return True——找到了就立刻结束整个函数,连循环带函数一起退出。这里体现了 return 的「立即终止」语义在算法上的用处:提前退出(early return)。

prev_was_eight = (last == 8)——更新记忆。右边 last == 8 是一个比较表达式,它的值就是 True 或 False,直接赋给变量即可。括号不是必需的(== 优先级高于 =),加上是为了让「把一个布尔值存起来」这件事一眼可见。

这一行必须在 if 外面(与 if 同层,都在 while 体里),因为无论当前位是不是 8 都要更新。这也是唯一能让 80808080 返回 False 的写法。

return False——循环正常结束(没有中途 return True)说明扫完全部位都没找到,返回 False。它在 while 外面。

验证

先追踪正例 double_eights(2882)。初始 n = 2882,prev_was_eight = False。

轮轮首 n取出 last新 n轮首 prev_was_eightlast==8 and prev轮末 prev_was_eight
128822288False假(2 != 8,短路)False
2288828False假(左真右假)True
32882True真 → return True—

第 3 轮时当前位是 8、上一位也是 8,立刻返回 True ✓。注意最高位的 2 根本没被检查——找到答案就走人,不必扫完。

再追踪那个杀手锏反例 double_eights(80808080)。数字从右往左依次是 0, 8, 0, 8, 0, 8, 0, 8:

轮轮首 nlast新 n轮首 prev命中?轮末 prev
18080808008080808False否False
280808088808080False否(上一位是 0)True
3808080080808True否(当前位是 0)False ← 记忆被清掉
48080888080False否True
580800808True否False
6808880False否True
78008True否False
8880False否True
—0条件 0 > 0 为假,退出循环—

return False ✓。整张表最值得看的是第 3、5、7 轮那一列加粗的 False:每当遇到一个非 8 的数字,记忆就被清零。正是这个清零动作,让四个 8 无法「隔空配对」。如果把最后一行写成 if last == 8: prev_was_eight = True(只置真不清假),那么第 2 轮之后 prev_was_eight 就永远是 True,第 4 轮的 8 会误命中,函数错误地返回 True。

剩下几个 doctest 快速过一遍:double_eights(8)——一轮,last=8 但 prev=False,不命中,n 变 0 退出,返回 False ✓。double_eights(88)——第 1 轮 last=8/prev=False,记忆置 True;第 2 轮 last=8/prev=True,返回 True ✓。double_eights(880088)——从右往左是 8,8,...,第 2 轮就命中,返回 True ✓(另一处 88 压根没走到)。double_eights(12345)——没有 8,prev 全程 False,返回 False ✓。

常见误区

误区一:只置真不清假。 if last == 8: prev_was_eight = True——80808080 返回 True,错。这是本题唯一一个专门为它设计的 doctest,出题人料到你会这么写。

误区二:数 8 的个数。 count >= 2 → True,同样被 80808080(四个 8)毙掉。

误区三:取位和砍位顺序写反。 n = n // 10 写在 last = n % 10 前面,整个遍历错位一格,double_eights(88) 会返回 False(第一轮取到的是十位 8,第二轮 n 已是 0 循环就结束了)。

误区四:return False 缩进进了循环。 那么第一轮不命中就直接返回 False,2882 会错误地返回 False——因为它第一轮取到的是 2。「存在性」判断的通用结构是:循环里只 return True,循环外才 return False。反过来写(循环里 return False)是初学者第二高频的结构性错误。

误区五:用 n % 100 == 88 但每次砍两位。 n = n // 100 会错过跨边界的 88,2882 返回 False。要用这个方案,每轮只能 n = n // 10。

误区六:返回 1 / 0 或者 'True'。 doctest 比对显示文本,1 和 True 不是一回事。直接返回布尔值,或者返回一个比较表达式的值。

7. Pick a Digit digit(选做)

题目要什么

def digit(n, k):
    """Return the k-th digit from the right of n for positive integers n and k.

    >>> digit(3579, 2)
    5
    >>> digit(3579, 0)
    9
    >>> digit(3579, 10)
    0
    """
    return ____

取出 n 从右数第 k 位的数字。k 从 0 开始计:k = 0 是个位,k = 1 是十位,k = 2 是百位……如果 n 没有那么多位,返回 0。

拿 3579 对一下:

k01234 及以上
位名个位十位百位千位不存在
digit(3579, k)97530

还有一个额外的硬约束写在题干里:函数体只能有一条 return 语句("has only a single return statement as its body"),模板里写死了 return ____。也就是说不许写循环、不许写 if,必须用一个表达式一步到位。这个约束才是这道题的真正难点——它逼你从「一位一位剥」的循环思维,切换到「直接算」的表达式思维。

边界情况是 digit(3579, 10):k 超出位数时返回 0,而且是自动返回,不能靠 if 特判。

怎么想到的

先想「如果允许循环怎么做」。 很自然:把 n 砍 k 次尾巴,然后取末位。

i = 0
while i < k:
    n = n // 10
    i = i + 1
return n % 10

这个能过测试,但违反了「只有一条 return」的要求。不过它把思路点明了:取第 k 位 = 先砍掉右边 k 位,再取新的末位。 两步。

第二步:把「砍 k 次」压成一个表达式。 每砍一次是 // 10,砍 k 次就是除以 10 共 k 次。连续做 k 次整除 10,等价于一次整除 \(10^k\):

$$\underbrace{((n \mathbin{//} 10) \mathbin{//} 10)\cdots \mathbin{//} 10}_{k \text{ 次}} \;=\; n \mathbin{//} 10^{k}$$

这个等式对整数地板除是成立的(不是显然的,但确实成立:连续两次向下取整除法 (n // a) // b == n // (a*b) 对正整数 a, b 恒成立)。题目 Hint 里提到 built-in pow 函数,其实 Python 的幂运算符 ** 更顺手:10 ** k。

第三步:拼起来。 砍完之后取末位,就是再 % 10:

n // 10 ** k % 10

第四步:检查边界为什么自动对。 这是这个解法最漂亮的地方。digit(3579, 10):

逐步推演

10 ** 10 = 10000000000(一百亿)。

3579 // 10000000000 = 0——因为被除数比除数小,地板除的结果向下取整得 0。(这里必须是 //;如果用 /,得到的是 3.579e-07 这个浮点数,后面 % 10 还是这个小数,返回 3.579e-07,测试直接挂。)

0 % 10 = 0 ✓

所以「k 超出位数」这个边界不需要任何特判:位数不够时前半段自然算出 0,后半段再取 0 的末位还是 0。这正是题目敢要求「只有一条 return」的底气。

代码

def digit(n, k):
    return n // 10 ** k % 10

这一行里有三个运算符,全靠优先级和结合性正确断句,值得单独讲。

优先级运算符结合性
高**(幂)右结合
中//(地板除)、%(取模)、*、/左结合,同级
低+、-左结合

** 优先级最高,所以 10 ** k 先算,不会被误解成 (n // 10) ** k。

// 和 % 同级且左结合,所以从左往右:先做 //,再做 %。整个表达式实际断句为:

(n // (10 ** k)) % 10

这正是我们要的顺序:先砍掉右边 k 位,再取末位。如果顺序反了(先 % 10 再 // 10**k),你得到的会是 (n % 10) // 10**k——先取末位再除,k > 0 时永远是 0,全错。

直觉

把这一行读成三个动作的流水线:n → 右移 k 位(// 10 ** k)→ 只留末位(% 10)。如果你熟悉二进制的位移,这就是十进制版的 (n >> k) & 1。

虽然优先级保证了正确性,写成 (n // 10 ** k) % 10 加个括号可读性更好,也不会被扣分。本仓库提交的是无括号版本,与 labs/lab01/lab01.py 一致。

验证

逐个追踪三个 doctest:

调用10 ** kn // 10 ** k... % 10期望
digit(3579, 2)1003579 // 100 = 3535 % 10 = 55 ✓
digit(3579, 0)10 ** 0 = 13579 // 1 = 35793579 % 10 = 99 ✓
digit(3579, 10)100000000003579 // 10000000000 = 00 % 10 = 00 ✓

第二行特别值得留意:10 ** 0 = 1(任何非零数的 0 次方是 1),于是 n // 1 = n——「砍掉右边 0 位」就是「什么都不砍」,语义完全对上。这也是为什么 k 从 0 开始编号在这里比从 1 开始更自然。

再自己验一个中间情况 digit(3579, 1):10 ** 1 = 10,3579 // 10 = 357,357 % 10 = 7 → 十位是 7 ✓。

常见误区

误区一:用 / 而不是 //。 digit(3579, 2) 会算 3579 / 100 = 35.79,再 35.79 % 10 = 5.789999999999999(浮点数取模还会带上误差)。doctest 期望 5,实际显示一串小数,测试失败。凡是结果该是整数的地方一律用 //,这是本次作业反复出现的教训。

误区二:以为需要 if 处理 k 超界。 不需要,而且题目也不允许。地板除在被除数小于除数时自然给 0。

误区三:把 % 和 // 的顺序想反。 写成 n % 10 // 10 ** k,看起来只是换了个位置,实际语义全变(先取末位再右移)。digit(3579, 2) 会返回 0。

误区四:用 pow(10, k) 时写成 pow(10, k, ...) 三参数形式。 pow 的第三个参数是模数,pow(10, k, 10) 算的是 \(10^k \bmod 10\),完全不是这道题要的东西。用两参数的 pow(10, k) 或者直接 10 ** k。

8. Middle Number middle(选做)

题目要什么

def middle(a, b, c):
    """Return the number among a, b, and c that is not the smallest or largest.
    Assume a, b, and c are all different numbers.

    >>> middle(3, 5, 4)
    4
    >>> middle(30, 5, 4)
    5
    >>> middle(3, 5, 40)
    5
    >>> middle(3, 5, 40)
    5
    >>> middle(30, 5, 40)
    30
    """
    return ____

三个数里,返回既不是最小也不是最大的那个——中位数。

题目给了两个重要前提,都要吃透:

  • 「Assume a, b, and c are all different numbers」——三个数两两不同。这条免掉了所有麻烦:不用考虑 middle(5, 5, 3) 这种「最小和中间是同一个值」的情况。可以放心假设三者构成严格的大小顺序。
  • 和上一题一样,只能写一条 return 表达式("by writing a single return expression"),模板是 return ____。不许 if。

题目还贴心地给了 Hint 和两个内建函数的用法:

>>> max(1, 2, 3)
3
>>> min(-1, -2, -3)
-3

Hint 的原话是:试试把所有数加在一起,然后用 min 和 max 把你不想要的那些减掉。

怎么想到的

没有 Hint 时的第一反应:穷举比较。 三个数排序,无非六种排列,写一堆 if:

if a < b < c or c < b < a:
    return b
elif b < a < c or c < a < b:
    return a
else:
    return c

这是对的(Python 支持 a < b < c 这种链式比较,语义就是 a < b and b < c),但又长又容易漏一种情况,而且违反「只能一条 return 表达式」的要求。看到这种约束就该意识到:出题人知道有个巧妙的算术恒等式,他要你找到它。

顺着 Hint 想。 关键的观察是:三个互不相同的数,恰好可以被划分成三类——最小的一个、最大的一个、中间的一个,一个不多一个不少。也就是说:

$$a + b + c \;=\; \min(a,b,c) \;+\; \text{中间那个} \;+\; \max(a,b,c)$$

移项就直接得到答案:

$$\text{中间那个} \;=\; a + b + c \;-\; \min(a,b,c) \;-\; \max(a,b,c)$$
关键一步

「三个不同的数,其中恰有一个最小、恰有一个最大、恰有一个居中」——把这句集合论的话翻译成算术,就是求和减去两端。这是一类很有用的技巧:当你想要「除了某些之外的那一个」,而这些量满足一个守恒关系时,用总量减掉不要的部分,比逐个判断简单得多。

同样的手法在别处也见得到:数组里找唯一缺失的数(用等差数列求和减去实际和)、找唯一出现一次的数(用异或),套路是一样的。

为什么「三个数互不相同」这个前提不可省。 假设允许重复,比如 middle(5, 5, 3):min = 3,max = 5,公式给出 5+5+3-3-5 = 5。恰好也对(中间的确是 5)。再试 middle(5, 5, 5):5*3 - 5 - 5 = 5,也对。看起来还挺鲁棒?但 「中位数」的定义在有重复时本身就模糊了,题目直接排除这种输入,让你不用纠结。做题时看到「Assume ...」一定要读,它是在告诉你「这些情况你不用管」。

代码

def middle(a, b, c):
    return a + b + c - min(a, b, c) - max(a, b, c)

逐部分看:

a + b + c——三个数的总和。

min(a, b, c)——min 是内建函数(built-in function),可以接受任意多个参数,返回其中最小的。max 同理。它们不需要 import,直接用。

- min(...) - max(...)——减法左结合,所以是 ((a+b+c) - min(...)) - max(...),先减最小再减最大,顺序无所谓,减法在这里是可交换的(都是减)。

整个表达式的求值过程严格遵守这门课的求值规则:先求算子(min 这个名字查到内建函数),再从左到右求算子数(a、b、c 的值),然后调用。 min(a, b, c) 和 max(a, b, c) 是两个独立的调用表达式,各自被完整求值成一个数,然后参与外层的加减运算。

注意

min 和 max 是名字,它们在全局环境里绑定到内建函数。如果你在自己的代码里写了 max = 5,就把这个名字重新绑定到了整数 5,之后再调用 max(a, b, c) 会报 TypeError: 'int' object is not callable。这是「名字与值的绑定」这个母题的一个真实后果——不要用内建函数名当变量名。同样别用 sum、list、str、len、print。

验证

把所有 doctest 逐个代入公式:

调用a+b+cminmax结果期望
middle(3, 5, 4)123512 - 3 - 5 = 44 ✓
middle(30, 5, 4)3943039 - 4 - 30 = 55 ✓
middle(3, 5, 40)4834048 - 3 - 40 = 55 ✓
middle(30, 5, 40)7554075 - 5 - 40 = 3030 ✓

四个例子覆盖了「中间的是第三个参数」「中间的是第二个参数」「中间的是第一个参数」三种情况——公式对参数位置完全对称,所以位置根本不影响正确性。这也是它比 if 版优越的地方:if 版必须显式枚举六种排列,公式版一句话覆盖全部。

再展开一次 middle(30, 5, 40) 的完整求值,看清求值顺序:

逐步推演

a + b + c - min(a, b, c) - max(a, b, c)

① 名字查找:a → 30,b → 5,c → 40(都在 middle 的局部帧里)

② a + b → 30 + 5 → 35;35 + c → 35 + 40 → 75

③ 调用表达式 min(a, b, c):算子 min → 内建 min 函数;算子数 → 30, 5, 40;调用 → 5

④ 75 - 5 → 70

⑤ 调用表达式 max(a, b, c) → 40

⑥ 70 - 40 → 30

⑦ return 30,提示符回显 30 ✓

常见误区

误区一:把中位数和平均数搞混。 写成 (a + b + c) / 3。middle(3, 5, 4) 会得到 4.0——数值碰巧接近但类型是浮点数,而且换个输入就完全错了:middle(30, 5, 4) 得 13.0,期望 5。

误区二:写成 min(max(a, b), max(b, c)) 之类的花式组合。 有些组合确实等于中位数(比如 max(min(a,b), min(max(a,b), c))),但很难一眼验证正确性,也容易在某个排列上翻车。求和减两端的写法一看就懂,为什么正确也一句话说得清。

误区三:忘了 min/max 能吃三个参数,写成 min(min(a, b), c)。 这不算错(结果一样),只是啰嗦。Python 的 min/max 接受任意多个位置参数。

误区四:漏掉 return。 只写 a + b + c - min(...) - max(...) 一行——这是个合法的表达式语句,值被算出来然后丢掉,函数返回 None。ok 会说期望 4 得到 None。

9. Sum Digits sum_digits(选做)

题目要什么

def sum_digits(y):
    """Sum all the digits of y.

    >>> sum_digits(10) # 1 + 0 = 1
    1
    >>> sum_digits(4224) # 4 + 2 + 2 + 4 = 12
    12
    >>> sum_digits(1234567890)
    45
    >>> a = sum_digits(123) # make sure that you are using return rather than print
    >>> a
    6
    """

把一个非负整数的所有数位加起来。4224 → 4+2+2+4 → 12。

四个 doctest 里第四个最值得注意,它的注释直接把出题意图写在脸上:make sure that you are using return rather than print。它用 a = sum_digits(123) 接住返回值,再单独回显 a。如果你写的是 print(total),这个测试会显示 6 然后 a 回显出的却是空(None 不显示),立刻露馅。 这是本次作业第三次用不同方式敲打同一件事。

sum_digits(1234567890) = 1+2+3+4+5+6+7+8+9+0 = 45,用来验证多位数不会中途出错。

怎么想到的

这道题是 falling(累积循环)和 double_eights(逐位拆解)的合体,两个模板一拼就出来了。

拆解部分照抄 double_eights:y % 10 取末位、y // 10 砍末位、while y > 0 直到取完。

累积部分照抄 falling,但把乘法换成加法,所以初始值从 1 换成 0:

累乘(falling)累加(sum_digits)
初始值total = 1(乘法单位元)total = 0(加法单位元)
循环体total = total * 因子total = total + 数位
空输入时返回10

「初始值取单位元」不是死记的规则,它有明确的理由:初始值必须是「还没累积任何东西时的正确答案」。零个数相加是 0,零个数相乘是 1。这样一来空输入的边界就自动正确——sum_digits(0) 时循环一次不进,返回 0,而 0 的数位和确实是 0 ✓。(0 这个输入不在 doctest 里,但题目说「非负整数」,所以它是合法输入,值得自己验一下。)

关键一步

做完 falling 和 double_eights 再看这道题,你应该有一种「已经见过」的感觉。这不是巧合:一个循环 = 「初始化状态」+「每轮如何更新状态」+「什么时候停」+「停下来后交出什么」。三道题的差别只在于状态是什么(乘积 / 布尔记忆 / 和)以及怎么更新。把这个框架内化,以后遇到新的循环题就是在填四个空。

代码

def sum_digits(y):
    total = 0
    while y > 0:
        y, last = y // 10, y % 10
        total = total + last
    return total

逐行看:

total = 0——累加器,加法单位元起步。

while y > 0:——只要还有位没取完就继续。y 每轮至少缩小到十分之一,一定终止。注意这里直接改的是形式参数 y——这完全合法,y 在函数帧里就是个普通的局部名字,改它不影响调用者传进来的那个整数(整数是不可变对象,而且这里改的只是局部绑定)。

y, last = y // 10, y % 10——与 double_eights 那行一模一样的写法:右边两个表达式都用旧的 y,算完后同时绑定。last 拿到末位数字,y 变成砍掉末位的部分。

total = total + last——把这一位加进累加器。

return total——在循环外,把累加结果交出去。这一行是第四个 doctest 专门盯着的地方。

验证

追踪 sum_digits(4224),期望 12。初始 total = 0,y = 4224。

轮轮首 yy > 0last = y % 10新 y = y // 10轮末 total
14224真44220 + 4 = 4
2422真2424 + 2 = 6
342真246 + 2 = 8
44真408 + 4 = 12
—0假,退出——12

返回 12 ✓,与注释 # 4 + 2 + 2 + 4 = 12 一致。

注意第 4 轮:y = 4 时,4 % 10 = 4(取出唯一那位),4 // 10 = 0(砍掉之后什么都不剩)。正是 4 // 10 == 0 这个事实终结了循环——最高位被取出的同一轮里,y 就归零了。

再看 sum_digits(10):第 1 轮 last = 0、y = 1、total = 0;第 2 轮 last = 1、y = 0、total = 1;退出,返回 1 ✓。这个例子里数位 0 被正常加进去了(加 0 不改变结果),说明不需要为 0 这个数位做任何特殊处理。

最后一个 doctest 的双通道检查:

逐步推演

>>> a = sum_digits(123)

① 求右边 sum_digits(123):循环三轮,total 依次为 3、5、6,返回 6。执行过程中没有任何 print,所以屏幕上不出现任何东西。

② 把 6 绑定给全局名字 a。赋值语句本身不是表达式,提示符不回显。

③ 所以这一行的输出是空——doctest 里这一行下面确实没有期望输出行 ✓

>>> a

④ 求名字 a → 6,不是 None,回显 6 ✓

如果函数写成了 print(total):第 ① 步会在屏幕上打出 6(doctest 期望这里什么都没有 → 失败),第 ④ 步 a 是 None(不显示,期望 6 → 又失败)。一处错误两处报警,出题人的设计很到位。

常见误区

误区一:total 初始化成 1。 从 falling 那道题惯性带过来的错。sum_digits(10) 会返回 2 而不是 1——每个答案都比正确值大 1,是很典型的「起点错了」症状。

误区二:忘了更新 y。 只写 total = total + y % 10 而没有 y = y // 10,y 永远不变,条件永远为真 → 死循环,total 无限增长。ok 报 Test timed out。

误区三:用 print 而不是 return。 第四个 doctest 就是为它准备的,见上面的推演。

误区四:条件写成 while y != 0 而输入可能是负数。 题目说的是非负整数,所以 y > 0 和 y != 0 在合法输入上等价。但 y > 0 更稳:万一传进 -123,y > 0 直接返回 0,而 y != 0 会因为 Python 里 -123 // 10 == -13(向下取整,越除越负)而永远不到 0 → 死循环。循环条件写成不等式而不是不等号,是个便宜的保险。

误区五:取位和砍位顺序写反。 与 double_eights 同一个坑:y = y // 10 写在 last = y % 10 前面,会丢掉个位、并多算一次。sum_digits(4224) 得到 8(少了个位的 4,多了个 0)。

10. 整份作业回顾

Lab 1 表面上是十道零散的小题,实际上只教了四件事。把它们抽出来,比记住十个答案有用得多。

一、两条输出通道

这是 Lab 1 最核心、也是最容易被轻视的一课。它被反复考了至少五次:Q1 的 welcome/cal、Q2 的 bake(同一个 29 出现两次、同一句 mashed potatoes 一次带引号一次不带)、Q5 的 divisible_by_k(必须同时打印和返回)、Debugging Quiz 里的 DEBUG: 前缀、Q10 sum_digits 那个专门写着「make sure that you are using return rather than print」的 doctest。

问题答案
函数的「结果」是什么return 的值。没有 return 就是 None
屏幕上的文字从哪来只可能来自 print,或者提示符对非 None 值的回显
'hello' 和 hello 的区别前者是回显(repr),后者是 print(str)
为什么看不到 None提示符对 None 特殊处理:不显示
return 之后的代码永远不执行
print 之后的代码照常执行

二、循环的四件套

falling、divisible_by_k、double_eights、sum_digits 四道题,代码长得都不一样,骨架却是同一个:

1 初始化状态——状态是「到目前为止累积的结果」。取值要保证「什么都还没做时答案就是它」:累加取 0,累乘取 1,计数取 0,「见过 8 吗」取 False。
2 写循环条件——想清楚「还有活干吗」怎么表达。n > stop、i <= n、y > 0。条件里用到的量如果会变,注意是不是需要提前快照(falling 里的 stop)。
3 循环体:更新状态 + 推进——两件事都不能漏。忘了推进就死循环,忘了更新状态就白跑。多重赋值 a, b = 新a, 新b 能让你不必操心先后顺序。
4 循环外交出结果——return 的缩进必须和 while 对齐。存在性判断则是「循环里 return True,循环外 return False」。

三、整数的算术拆解

想干什么怎么写出现在
取最右边一位n % 10double_eights、sum_digits
砍掉最右边一位n // 10double_eights、sum_digits
砍掉最右边 k 位n // 10 ** kdigit
取右起第 k 位n // 10 ** k % 10digit
取最右边两位n % 100double_eights 的另一解
判断整除i % k == 0divisible_by_k
遍历完所有位的标志n 变成 0所有拆位题

贯穿始终的一条铁律:结果该是整数的地方,一律用 //,绝不用 /。 用了 / 结果就变成浮点数,doctest 比对显示文本,2 和 2.0 就是两回事。

四、让边界自动正确

这份作业里有四个边界情况,没有一个需要写 if 特判:

题目边界为什么自动对
falling(4, 0)返回 1stop = n - 0 = n,循环零次,返回初始值 1
divisible_by_k(6, 7)不打印、返回 0if 一次不成立,count 保持初始值 0
digit(3579, 10)返回 03579 // 10**10 == 0,地板除自动给 0
double_eights(8)返回 Falseprev_was_eight 初始为 False,单个 8 命中不了
核心结论

当你发现自己要为边界情况加一个 if 时,先停下来想想:能不能调整初始值或循环条件,让主逻辑自然覆盖它? 多数情况下可以,而且改完的代码更短、更容易证明正确。这个习惯在后面的递归章节回报更大——递归的 base case 选得好不好,直接决定整个函数是三行还是三十行。

迁移到哪里

题目核心手法后面会在哪里再见到
Q1 / Q2 WWPD区分「打印」与「返回」;if/elif 链的短路每一次「我的函数明明输出对了却判错」的时刻
Q3 Debugging Quiz读 traceback、写 doctest、ok -q 题名 -i整个学期每一次调试
falling累乘循环;提前快照循环终点;多重赋值递归版的 falling(后续作业)、各种累积函数
divisible_by_k「打印」与「返回」并存;三层缩进各司其职Hog 项目里既要打印又要返回状态的函数
double_eights状态变量携带上一步信息;存在性判断的提前退出树/链表的遍历、迭代器题、任何「找一个就够」的搜索
digit把循环压成一个算术表达式;运算符优先级后续所有对数位做手脚的题
middle用守恒关系「总和减两端」代替穷举比较需要「除了某些之外那一个」的场合
sum_digits累加循环 + 逐位拆解的合体它的递归版本几乎立刻就会在后面的作业里出现

最后一句

Lab 1 这十道题,六道能写出来的核心其实只有一句话:你必须能在纸上当一次 Python 解释器。 本页每道题都手动追踪了一遍循环表格或求值步骤,不是为了凑篇幅——那是这门课要求你养成的默认动作。写完代码不确定对不对时,最靠谱的办法永远不是「再改改看能不能过」,而是拿一个具体输入,把每一步的变量值老老实实写在纸上。