Lab 9:Scheme 与 Scheme 列表ok 17 项通过
换一门语言重做一遍你已经会的事:高阶函数与链表递归。语法全变了,求值规则一点没变。
0. 这份作业在练什么
这是整门课里第一份不用 Python 写的作业。你打开 lab09.scm,看到的是一堆括号,
第一反应通常是「我不会了」。但真相是:这份 lab 里六道题,考的东西你在第 5 到第 12 讲之间
全部学过——分支、高阶函数(higher-order function)、返回函数的函数、链表(linked list)递归。
换的只有写法,没换规则。
所以这份解析的重点不是教你背 Scheme 语法表,而是每道题都问同一个问题: 解释器(interpreter)拿到这个表达式之后,一步一步做了什么? 只要你能把求值过程说出口,括号写在哪就不再是问题。
做之前应该已经掌握的
| Python 里的概念 | 本次对应的 Scheme 形态 | 本次哪道题用到 |
|---|---|---|
| 调用表达式:先算算子,再算算子数,再 apply | (operator operand1 operand2 ...) 前缀写法 | 全部 |
if / elif / else 语句 | if 与 cond 特殊形式(special form) | Q1、Q3、Q6 |
lambda x: ...,函数是值 | (lambda (x) ...) | Q2、Q3、Q6 |
| 返回函数的函数、函数组合 | 同样是 lambda,只是括号更多 | Q2、Q3 |
Link(first, rest)、link.first、link.rest | (cons a b)、(car p)、(cdr p) | Q4、Q5、Q6 |
Link.empty | nil(打印成 ()) | Q4、Q6 |
| 对链表递归:处理头 + 递归处理尾 | 一模一样,cons 头,递归 cdr | Q6 |
- 括号即调用。在 Scheme 里,凡是写下一对括号,就是在说「把里面第一个东西当过程调用」。
所以
0求值成0,而(0)会报错——你在试图调用数字 0。这条规则解释了初学者九成的报错。 - 特殊形式不遵守普通求值规则。
if、cond、define、lambda、quote长得像调用表达式,但它们的子表达式不会被全部求值。这是 Q4(WWSD)的全部考点。 - 除了
#f,一切都是真值。Python 里0、''、[]都是假; Scheme 里只有#f是假,0和空列表nil都是真。 - Scheme 列表就是链表。
cons造一个 pair,第二个参数必须是列表或nil, 一串 pair 串起来就是列表。你在 Lab 7 / Lab 8 对Link写的每个递归,这里都能原样搬。
题目全写在 labs/lab09/lab09.scm 里,测试用 python3 ok -q over_or_under 这样单题跑,
或 python3 ok --local 全跑。tests/*.py 里 'setup' 那行的
(load-all ".") 意思是:把当前目录下的 .scm 文件全部加载进解释器,
然后再执行 'code' 里的那些 scm> 行。所以你只要把定义写进 lab09.scm,
测试就能看见它们。
1. Over or Under(over-or-under)
题目要什么
写一个过程 over-or-under,收两个数 num1、num2,返回:
num1 < num2时返回-1num1 = num2时返回0num1 > num2时返回1
官方测试文件 tests/over_or_under.py 里给的三个用例是:
scm> (over-or-under 5 5)
0
scm> (over-or-under 5 4)
1
scm> (over-or-under 3 5)
-1
翻译成人话:这就是三路比较(three-way comparison),Python 里你会写
def over_or_under(num1, num2):
if num1 < num2:
return -1
elif num1 == num2:
return 0
else:
return 1
边界情况其实只有一个值得留意:三个分支必须互斥且穷尽。
两个数比较只有小于、等于、大于三种可能,没有第四种,所以最后一路可以放心用 else
兜底,不必再写一次 (> num1 num2)。题目还额外提醒了一句:
在 Scheme 里 (0) 会报错,因为括号意味着「调用 0 这个过程」。这句提醒不是废话——
初学者最爱写 (cond ((< num1 num2) (-1)) ...),多套一层括号,直接崩。
怎么想到的
拦住人的从来不是逻辑,是「比较怎么写」和「分支怎么写」。逐个拆。
num1 < num2,运算符夹在中间(中缀,infix)。
Scheme 一律前缀(prefix):(< num1 num2)。< 不是什么特殊语法,
它就是一个绑定到内建过程的符号(symbol),跟 quotient 没有区别。
你在解释器里单独敲 <,它会打印 #[<],证明它是个值。= 不是 ==。Scheme 的赋值靠 define,
所以单个 = 没被占用,直接拿来当数值相等判断。写 == 会报「未绑定的符号」。if:Scheme 的 if
只有两条腿((if 谓词 真值表达式 假值表达式)),没有 elif,
所以三路分支必须把第三种情况塞进「假」那条腿里,写成嵌套:
(if A -1 (if B 0 1))。方案 B 是 cond,它天生支持任意多个「谓词-结果」对,
从上往下逐个试。三路以上的分支写 cond 更平、更好读,所以选 B。
题目里的 Challenge 就是让你两种都写一遍,体会 cond 只是嵌套 if 的语法糖。return。这一步最容易卡住 Python 用户。Scheme 里过程体是表达式,
不是语句序列;表达式算出什么,过程就返回什么。cond 本身就是一个表达式,
它的值就是命中那一支的值。所以过程体写一个 cond 就完事了。把「if 是语句」的直觉换成「if 是表达式」。Python 的 if 语句本身不产生值,
你必须用 return 把值送出去;Scheme 的 (if p a b) 本身就是 那个值,
就像 Python 的条件表达式 a if p else b。理解这一点,
你就再也不会在 Scheme 里找 return 了。
代码
(define (over-or-under num1 num2)
; cond 从上到下逐个检查条件,命中就返回对应的值
(cond ((< num1 num2) -1)
((= num1 num2) 0)
(else 1)))
逐行看:
| 片段 | 作用 | 为什么不是别的写法 |
|---|---|---|
(define (over-or-under num1 num2) ...) |
定义过程:名字 over-or-under,形参 num1、num2,其后是过程体 |
这是 (define over-or-under (lambda (num1 num2) ...)) 的简写,两者完全等价。
名字里的连字符 - 是合法字符,Scheme 惯用 kebab-case 而不是 Python 的 snake_case。 |
; 开头那行 | 注释,读到行尾结束 | Scheme 的注释是分号,不是 #。写 # 会被当成 #t/#f 的前缀而报错。 |
(cond ...) | 多路分支,整体是一个表达式 | 不能写 elif;用嵌套 if 也对,但三层括号更容易数错。 |
((< num1 num2) -1) | 一个 cond 子句:外层括号包住「谓词 + 结果」这一对 | 注意两层括号:外层是子句本身,内层 (< num1 num2) 才是调用。
少写一层变成 (< num1 num2 -1),就成了给 < 传三个参数。 |
-1、0、1 | 直接写数字字面量 | 千万别写成 (-1)。那是「调用 -1」,会报 -1 is not callable 一类的错误。 |
(else 1) | 兜底子句 | 写 ((> num1 num2) 1) 也能过测试,但那样如果三个条件因笔误没穷尽,
cond 会返回 undefined 而不是报错,更难查。else 明确表达「剩下的都归这里」。 |
验证:手工追踪 (over-or-under 3 5)
(over-or-under 3 5)
1. 这是调用表达式。先求算子 over-or-under
→ 全局帧里查到一个过程对象,形参 (num1 num2)
2. 从左到右求算子数:3 → 3,5 → 5
3. apply:新建一帧 f1,parent = 全局,绑定 num1 = 3、num2 = 5
在 f1 中求值过程体 (cond ...)
4. cond 是特殊形式,不会先算所有子表达式,而是逐句试:
子句 1:求谓词 (< num1 num2) = (< 3 5) → #t
#t 不是 #f,命中!求这一子句的结果表达式 -1 → -1
(子句 2、子句 3 根本不会被看一眼)
5. cond 的值 = -1 → 过程体的值 = -1 → 调用返回 -1
另外两个用例同理:
| 调用 | (< num1 num2) | (= num1 num2) | 命中子句 | 返回 |
|---|---|---|---|---|
(over-or-under 3 5) | #t | 不求值 | 第 1 条 | -1 |
(over-or-under 5 5) | #f | #t | 第 2 条 | 0 |
(over-or-under 5 4) | #f | #f | else | 1 |
三行输出 -1 / 0 / 1,与测试文件里写的完全一致。
Challenge:用 if 再写一遍
题目鼓励你两种都写。用嵌套 if 是这样(这段没进最终提交,是示意用的等价实现):
(define (over-or-under-if num1 num2)
(if (< num1 num2)
-1
(if (= num1 num2)
0
1)))
把它和 cond 版并排看,你会发现 cond 的第 k 条子句,
就是嵌套 if 的第 k 层的「真」分支;else 就是最内层那个「假」分支。
cond 不是新东西,它是把右倾的嵌套结构拍平成一列。
- 给字面量加括号。写
(cond ((< num1 num2) (-1)) ...), 解释器会试图调用-1,报错类似SchemeError: cannot call: -1。 记住括号 = 调用,别手滑。 - cond 子句少一层括号。写成
(cond (< num1 num2) -1 ...), 解释器会把(< num1 num2)整个当成一条子句,于是「谓词」变成了符号<(一个过程对象,非#f,恒为真),结果永远返回最后一个子表达式的值。 这类错误不报错、只给错答案,最难查。 - 写
return。(define (f x) (return x))会报 「unknown identifier: return」——Scheme 根本没有这个名字。 - 括号不平衡。这是写 Scheme 最高频的错误。养成习惯:写完一个左括号立刻补右括号,
再往中间填内容;编辑器装个括号高亮插件(题面推荐了 VS Code 的
vscode-scheme)。
2. Compose(composed)
题目要什么
写一个过程 composed,它收两个过程 f 和 g,
返回一个新过程。这个新过程收一个数 x,返回「先对 x 用 g,
再把结果交给 f」的值,也就是数学上的 \(f \circ g\)。
官方测试 tests/composed.py 的 setup 里先准备了三个东西:
scm> (define (add-one a) (+ a 1))
scm> (define (multiply-by-two a) (* a 2))
scm> (define add-then-mul (composed multiply-by-two add-one))
然后跑这些用例:
scm> (add-then-mul 2)
6
scm> ((composed add-one add-one) 2)
4
scm> ((composed multiply-by-two multiply-by-two) 2)
8
scm> ((composed add-one multiply-by-two) 2)
5
scm> ((composed (composed add-one add-one) add-one) 2)
5
scm> ((composed (composed add-one add-one) multiply-by-two) 2)
6
scm> ((composed multiply-by-two (composed add-one add-one)) 2)
8
这几行值得逐个读一遍,因为它们精确地钉死了「谁先谁后」。看第四行:
(composed add-one multiply-by-two) 作用在 2 上得 5。
如果顺序是「先 add-one 再 multiply-by-two」,结果会是 \((2+1)\times 2 = 6\);
实际是 5,说明真实顺序是先 multiply-by-two(\(2 \times 2 = 4\))再 add-one(\(4+1=5\))。
composed 的第二个参数先跑。
再看 setup 里的 add-then-mul——名字叫「先加后乘」,定义是
(composed multiply-by-two add-one),作用在 2 上得 \((2+1)\times 2 = 6\),
和测试里写的 6 吻合。名字和参数顺序是反的,这正是 \(f \circ g\) 记法容易绊人的地方:
写在左边的后执行。
还有一处边界要注意:最后三行把 composed 的返回值又当成参数传给了
composed。这说明你返回的那个东西必须是「和 add-one 平起平坐的一等过程」,
而不是什么半成品。只要老老实实返回 lambda,这一点自动成立。
怎么想到的
这题在 Python 里你早就写过(Lab 3 / HW 2 的 compose1):
def composed(f, g):
def h(x):
return f(g(x))
return h
所以真正的问题只有一个:Scheme 里怎么「返回一个函数」?
(composed multiply-by-two add-one)
直接把两个过程名当参数传进去,就是在依赖这条性质。lambda。Python 的 lambda x: f(g(x))
在 Scheme 里写作 (lambda (x) (f (g x)))。三处差异:形参要用括号括成一个列表
(因为可以有多个形参),冒号没了,函数体是表达式。define 一个内部过程再返回它。
Python 版里写 def h(x) 再 return h,是因为 Python 的 lambda
只能放一个表达式、写起来别扭。Scheme 里过程体本来就是表达式,
直接把 lambda 表达式当过程体,它的值就是返回值。一行搞定。(g x),再把它整个作为 f 的参数,即 (f (g x))。
这里的嵌套结构和 Python 的 f(g(x)) 是同构的,只是括号从函数名后面挪到了前面。lambda 离开 composed
的调用之后还得记得 f 和 g 是谁。Scheme 和 Python 一样是词法作用域(lexical scoping):
lambda 创建的过程会记住它被创建时所在的那一帧作为 parent。
那一帧正是 composed 的调用帧,里面绑着 f 和 g。所以不用做任何额外的事。「返回一个函数」在 Scheme 里比在 Python 里更自然,因为你不需要为它起名字。
把 def h(x): return ... + return h 这两步合并成一个 lambda 表达式,
是理解「函数就是值」的最好练习。
代码
(define (composed f g)
; 返回一个新过程:先把 x 交给 g,再把结果交给 f
(lambda (x) (f (g x))))
| 片段 | 作用 | 为什么不是别的写法 |
|---|---|---|
(define (composed f g) ...) | 定义两参数过程 | 形参名就叫 f、g,它们在调用时会被绑定到过程对象,不是数字。 |
(lambda (x) ...) | 创建一个单参数过程并作为返回值 | 形参必须写成列表 (x)。写成 (lambda x ...) 在很多 Scheme 方言里表示
「把所有参数收成一个列表绑给 x」,语义完全不同。 |
(g x) | 先调用 g |
它是 f 的算子数,按求值规则会在 apply f 之前算完。 |
(f (g x)) | 把内层结果交给 f |
写成 (g (f x)) 会让 ((composed add-one multiply-by-two) 2) 得 6 而不是 5,测试立刻挂。 |
特别注意括号的数量:(f (g x)) 里有两对括号,各代表一次调用。
如果写成 (f g x),意思就变成「把 g 和 x 两个参数一起传给 f」——
而 add-one 只收一个参数,会报参数个数错误。
验证:手工追踪 (add-then-mul 2)
先看定义阶段发生了什么,再看调用阶段。
(define add-then-mul (composed multiply-by-two add-one))
1. define 是特殊形式,先求最后那个子表达式:
(composed multiply-by-two add-one)
2. 求算子 composed → 过程对象,形参 (f g)
3. 求算子数:
multiply-by-two → 过程对象 P_mul
add-one → 过程对象 P_add
4. apply:新建帧 f1,parent = 全局
f1: f = P_mul
g = P_add
在 f1 中求 (lambda (x) (f (g x)))
5. lambda 是特殊形式:不执行函数体,只造一个过程对象
得到 λ1 = 过程(形参 x, 体 (f (g x)), parent = f1)
← 这一步是关键:λ1 记住了 f1,而 f1 里 f=P_mul、g=P_add
6. define 把 λ1 绑到全局帧的 add-then-mul,返回符号 add-then-mul
全局帧 (Global)
add-one → P_add (形参 a, 体 (+ a 1))
multiply-by-two → P_mul (形参 a, 体 (* a 2))
composed → P_comp (形参 f g, 体 (lambda (x) (f (g x))))
add-then-mul → λ1
f1 [调用 composed 时建立] parent: Global
f → P_mul
g → P_add
返回值: λ1
λ1 形参: x
体: (f (g x))
parent: f1 ← 闭包记住的环境
(add-then-mul 2)
1. 求算子 add-then-mul → λ1
2. 求算子数 2 → 2
3. apply:新建帧 f2,parent = λ1 的 parent = f1
f2: x = 2
在 f2 中求 (f (g x))
4. 求 (f (g x)):
a. 求算子 f:f2 里没有 f → 沿 parent 到 f1 → 找到 P_mul
b. 求算子数 (g x):
求 g:f2 没有 → f1 里找到 P_add
求 x:f2 里就有 → 2
apply P_add:新建帧 f3,parent = Global,a = 2
体 (+ a 1) → 3
(g x) = 3
c. apply P_mul:新建帧 f4,parent = Global,a = 3
体 (* a 2) → 6
5. (f (g x)) = 6 → 返回 6
输出 6,与测试文件一致。注意第 4a 步:查 f 时走的是
λ1 的 parent 链(f2 → f1 → Global),而不是「调用点所在的环境」。
这就是词法作用域。如果 Scheme 用的是动态作用域,f 就会在调用者那里找,
这题根本没法工作。
验证:嵌套用例 ((composed multiply-by-two (composed add-one add-one)) 2)
外层是一个调用表达式,算子本身是个表达式,得先算出来:
1. 求算子 (composed multiply-by-two (composed add-one add-one))
1.1 先求它自己的算子数(左到右):
multiply-by-two → P_mul
(composed add-one add-one)
→ 建帧 fA: f = P_add, g = P_add
→ 返回 λA (体 (f (g x)), parent = fA)
1.2 apply composed:建帧 fB: f = P_mul, g = λA
返回 λB (体 (f (g x)), parent = fB)
2. 求算子数 2 → 2
3. apply λB:建帧 fC,parent = fB,x = 2
求 (f (g x)):
f 在 fB 里 = P_mul
(g x):g 在 fB 里 = λA,调用 λA(2)
建帧 fD,parent = fA,x = 2
求 (f (g x)):f 在 fA = P_add, g 在 fA = P_add
(g x) = (add-one 2) = 3
(f 3) = (add-one 3) = 4
λA(2) = 4
(f 4) = (multiply-by-two 4) = 8
结果 8 ✓
算式写出来就是 \((2+1+1) \times 2 = 8\),和测试里的 8 对上。
同一段代码,f 和 g 在不同的帧里指向不同的东西——
这就是为什么闭包必须记住「创建时的帧」而不是「函数体的文本」。
- 顺序写反。写成
(lambda (x) (g (f x))),前四个测试里有三个仍然会过 (因为add-one组合add-one、multiply-by-two组合multiply-by-two顺序无所谓),只有混合的那两个会挂。这种「大部分对」的现象很误导, 一定要盯着((composed add-one multiply-by-two) 2)应得 5 这一条。 - 忘了返回过程,直接返回值。写成
(define (composed f g) (f (g x)))会报x未绑定——x这个名字只有在lambda的形参列表里才存在。 - 调用时少一层括号。
(composed add-one add-one 2)是给composed传三个参数,报参数个数不匹配。正确写法是先造出过程再调用:((composed add-one add-one) 2),两对括号。 - 把 lambda 的形参写成
(lambda x ...)。少了那对括号, 调用时x会被绑成参数列表(2)而不是2, 后面(g x)就在拿一个列表去做加法。
3. Repeat(repeat)
题目要什么
写一个过程 repeat,收一个过程 f 和一个数 n,
返回一个新过程;这个新过程收 x,返回把 f 连续作用在 x 上 n 次的结果。
scm> (define (square x) (* x x))
square
scm> ((repeat square 2) 5) ; (square (square 5))
625
scm> ((repeat square 3) 3) ; (square (square (square 3)))
6561
scm> ((repeat square 1) 7) ; (square 7)
49
tests/repeat.py 里还有第二组用例:
scm> (define (increment x) (+ x 1))
increment
scm> ((repeat increment 4) 2) ; (increment (increment (increment (increment 2))))
6
scm> ((repeat increment 10) 51)
61
翻译成人话:(repeat f n) 就是 \(f^n\)(函数的 n 次自复合)。
题面给的注释已经把语义写死了:(repeat square 2) 等于
(lambda (x) (square (square x))),不多不少。
边界情况:官方测试里 n 最小是 1。但你的实现几乎一定会碰到 n = 0——
因为递归要有 base case。n = 0 意味着「一次都不作用」,
那新过程就该原样返回 x,也就是恒等函数(identity function)。
这个观察是整题的枢纽。
怎么想到的
题面给了提示:上一题的 composed 可能有用。顺着这条线走。
(repeat f n) 返回的那个过程。观察:
\(R_1 = f\),\(R_2 = f \circ f\),\(R_3 = f \circ f \circ f\)。
一眼可见 \(R_n = f \circ R_{n-1}\),用代码写就是
(composed f (repeat f (- n 1)))。递归的形状出来了。f」,
写成 (if (= n 1) f (composed f (repeat f (- n 1))))。
这确实能过官方那五个用例。但它在 n = 0 时会无限递归:
\(0 \to -1 \to -2 \to \cdots\),永远撞不到 1,最后爆栈。
这就是我说的「先想到 X,但 X 在某种输入下会崩」。(lambda (x) x)。
验算一下 \(n = 1\):\(R_1 = f \circ R_0 = f \circ \text{id}\),
它作用在 x 上得 (f ((lambda (x) x) x)) = (f x),正确。
于是既覆盖了 \(n = 0\),也不需要为 \(n = 1\) 单独写一支。base case 挑得越靠边,
代码越短、越不容易漏情况——这条经验在 Python 递归里也一样成立
(比如列表递归拿空表当 base case,而不是单元素表)。(composed f (repeat f (- n 1)))
还是 (composed (repeat f (- n 1)) f)?对 square 这种情况两者结果相同,
看不出差别;但从定义讲,composed 的第二个参数先执行,所以
「先做 \(n-1\) 次,最外面再补一次 f」写成前者。
(因为这里复合的两边都是同一个 f 的幂,实际上两种写法数学上等价,
但保持和递归叙述一致更不容易搞混。)composed 行不行?行,直接返回 lambda 也可以:
(lambda (x) (f ((repeat f (- n 1)) x)))。但复用上一题的成果更能体现
「抽象出来的东西是拿来用的」,而且读起来就是那个数学等式本身。把「重复 n 次」翻译成「做一次,再重复 n−1 次」,然后追问「重复 0 次是什么」。
答案是恒等函数——这个在 Python 里是 lambda x: x,在数学里是 \(f^0 = \mathrm{id}\)。
很多人卡住是因为下意识觉得「0 次没意义」,其实它才是最干净的起点。
代码
(define (repeat f n)
; 重复 0 次就是恒等函数;否则先做一次 f,再把剩下 n-1 次接在外面
(if (= n 0)
(lambda (x) x)
(composed f (repeat f (- n 1)))))
| 片段 | 作用 | 为什么不是别的写法 |
|---|---|---|
(if (= n 0) ... ...) | 两路分支,够用,不必上 cond |
只有两种情况时 if 更短。注意 if 是特殊形式:
只有被选中的那一支会被求值,另一支连碰都不碰——这正是递归能停下来的原因。 |
(lambda (x) x) | 恒等函数,作为 base case 的返回值 | 返回的必须是过程,不能是 x(此处根本没有 x)也不能是 nil。
因为 repeat 的契约是「永远返回一个可调用的东西」。 |
(- n 1) | 把问题规模缩小 | 前缀写法。写 (n - 1) 会被解释成「调用过程 n」,报错。 |
(composed f (repeat f (- n 1))) | 递归调用 + 复合 | 注意 (repeat f (- n 1)) 会在 apply composed 之前被完整求值
(算子数先算完),所以递归是自底向上真的展开到 n = 0 才回代的。 |
这题有两层调用嵌套,很容易混。(repeat square 3) 这一步就已经把三层
composed 全部搭好了,返回一个完整的过程对象;后面 (... 3) 只是把 3 灌进这个
早已搭好的管道。换句话说:递归在构造期完成,运行期只是逐层调用。
理解这一点,下面的追踪就不会乱。
验证:手工追踪 ((repeat square 2) 5)
(repeat square 2)
(repeat square 2)
n = 2,(= n 0) → #f,走假分支
需要求 (composed f (repeat f (- n 1)))
先算算子数:f → P_sq;(repeat square 1)
(repeat square 1)
n = 1,(= n 0) → #f
先算算子数:f → P_sq;(repeat square 0)
(repeat square 0)
n = 0,(= n 0) → #t
返回 (lambda (x) x) → 记作 ID
★ 到底了,开始回代
(composed P_sq ID) → 建帧 g1: f = P_sq, g = ID
→ 返回 λ1,parent = g1
(composed P_sq λ1) → 建帧 g2: f = P_sq, g = λ1
→ 返回 λ2,parent = g2
(repeat square 2) 的值 = λ2
此时内存里是这样一条链:
λ2 形参 x,体 (f (g x)),parent = g2
g2: f = P_sq(square) g = λ1
λ1 形参 x,体 (f (g x)),parent = g1
g1: f = P_sq(square) g = ID
ID 形参 x,体 x,parent = Global
(λ2 5)
1. apply λ2 于 5:建帧 h1,parent = g2,x = 5
求 (f (g x)):
f → 沿 h1→g2 找到 P_sq
(g x):g → g2 里是 λ1,参数 x = 5,调用 λ1(5)
2. apply λ1 于 5:建帧 h2,parent = g1,x = 5
求 (f (g x)):
f → g1 里是 P_sq
(g x):g → g1 里是 ID,调用 ID(5)
3. apply ID 于 5:建帧 h3,x = 5,体 x → 5
ID(5) = 5
(f 5) = (square 5) = 25
λ1(5) = 25
(f 25) = (square 25) = 625
λ2(5) = 625
输出 625,等于 (square (square 5)),与测试注释一致。
可以看到 square 恰好被调用了 2 次,中间那次 ID 什么也没做——
它只是让递归有个干净的底。
再验一个:((repeat increment 4) 2)
| 展开层 | 构造出的过程 | 作用在 2 上的结果 |
|---|---|---|
| n = 0 | ID | 2 |
| n = 1 | increment ∘ ID | 3 |
| n = 2 | increment ∘ (上一行) | 4 |
| n = 3 | increment ∘ (上一行) | 5 |
| n = 4 | increment ∘ (上一行) | 6 |
得 6,与测试一致。((repeat increment 10) 51) 同理是 \(51 + 10 = 61\)。
用 increment 来验证比用 square 好,因为加法不满足「顺序无所谓」的巧合掩盖,
数字直接告诉你 f 被作用了几次。
- base case 用
n = 1返回f。能过官方测试,但(repeat f 0)会一路递减到负无穷,报maximum recursion depth exceeded。 更糟的是这个 bug 在这次 lab 里不暴露,等到项目里再咬你。 - base case 返回错类型。写成
(if (= n 0) x ...):此处作用域里没有x, 报 unknown identifier;写成(if (= n 0) nil ...):返回的不是过程, 外层composed之后调用(g x)时会报「cannot call: ()」。 - 把递归写成迭代式的错解。比如
(f (repeat f (- n 1)))—— 这是把「过程」直接喂给f,square拿到一个过程去做乘法,直接崩。 记住:repeat返回的是过程,要复合而不是直接调用。 - 忘了
composed必须先定义。lab09.scm里composed写在repeat上面。其实 Scheme 是运行时查名字,只要调用发生时composed已在全局帧里就行,但保持定义顺序自然更保险。
4. WWSD: Lists(wwsd_lists)
题目要什么
WWSD = What Would Scheme Display,「Scheme 会显示什么」。
用 python3 ok -q wwsd_lists -u 解锁,一共九问,全都围绕
cons / list / 引号(quote)三者的区别。这些题不写代码,
但它们是理解后面 Q5、Q6 的前提:你必须能一眼看出一个表达式造出来的是什么形状的数据。
先把三条规则摆清楚,后面逐问对照:
| 构造方式 | 参数会被求值吗 | 造出什么 | 例子 |
|---|---|---|---|
(cons a b) | 会,两个都求 | 一个 pair:car 是 a 的值,cdr 是 b 的值 | (cons 1 (cons 2 nil)) → (1 2) |
(list a b c) | 会,全部求 | 一个长度为 3 的列表,元素是各参数的值 | (list (+ 1 1) 3) → (2 3) |
'(a b c)(即 (quote (a b c))) | 不求值 | 字面照抄那段文本结构,符号就是符号 | '(+ 1 1) → (+ 1 1) |
另外要分清「一个列表」和「它打印出来的样子」。
Scheme 打印列表时省略掉所有 cons 和 nil:一串 pair
\(p_1 \to p_2 \to \cdots \to \text{nil}\) 打印成 (元素1 元素2 ...)。
所以看到输出 (1 2),你要能反推出内部是两个 pair 串成的链。
逐问讲
1) (cons 1 (cons 2 nil)) → (1 2)
scm> (cons 1 (cons 2 nil))
(1 2)
从里往外算。(cons 2 nil) 造一个 pair:car = 2,cdr = 空表。
这是个合法列表,打印成 (2)。再 (cons 1 那个):car = 1,cdr = (2)。
链子是 1 → 2 → nil,打印成 (1 2)。
盒指针(box-and-pointer): +---+---+ +---+---+ | 1 | ------>| 2 | / | +---+---+ +---+---+ / 表示 nil(空表),打印时不出现
这和 Python 里 Link(1, Link(2, Link.empty)) 是同一个东西。
2) (car (cons 1 (cons 2 nil))) → 1
car 取 pair 的第一格。上面那条链的第一格是 1,所以答案是 1,
不是 (1)。car 返回的是元素本身,不是只含它的列表。
对应 Python 的 link.first。
3) (cdr (cons 1 (cons 2 nil))) → (2)
cdr 取第二格,也就是那条 2 → nil 的子链。
它是个列表,所以打印成 (2) 而不是 2。
第 2、3 问放在一起就是要你看清 car 和 cdr 的返回类型不同:
一个是元素,一个是列表。这条区分在 Q6 写递归时会救你的命。
4) (list 1 2 3) → (1 2 3)
list 是普通过程,参数照常求值(1 2 3 求值成自己),
然后把它们串成一个长度为 3 的链。等价于 (cons 1 (cons 2 (cons 3 nil))),
写起来省心得多。
5) '(1 2 3) → (1 2 3)
引号版。'(1 2 3) 是 (quote (1 2 3)) 的简写:
不求值,直接把括号里那段结构当数据造出来。因为里面全是数字字面量,
求值与不求值结果一样,所以这一问和第 4 问输出相同——但机制完全不同。
下一问就会把差别暴露出来。
6) (cons 1 '(list 2 3)) → (1 list 2 3)
scm> (cons 1 '(list 2 3)) ; Recall quoting
(1 list 2 3)
1. cons 是普通过程,两个参数都要求值。
2. 求第一个参数 1 → 1
3. 求第二个参数 '(list 2 3):
quote 是特殊形式,不求值里面的东西。
于是得到一个三元素列表,元素依次是:
符号 list(不是那个内建过程,就是个 symbol)
数字 2
数字 3
这个列表打印出来是 (list 2 3)
4. cons 1 到它前面 → 四元素列表:1, list, 2, 3
5. 打印 (1 list 2 3)
如果这里写的是 (cons 1 (list 2 3))(没有引号),
那第二个参数会被调用,得到列表 (2 3),最终结果是 (1 2 3)。
一个引号,差别就这么大。
引号把代码变成数据。(list 2 3) 加了引号之后,
不再是「一次调用」,而是「一个三元素的列表,恰好第一个元素是名叫 list 的符号」。
Scheme 里代码和数据用的是同一种结构(都是列表),这个性质叫同像性(homoiconicity),
也是下一讲写 Scheme 解释器的基础。
7) (cons 1 `(list 2 3)) → (1 list 2 3)
这里用的是反引号(quasiquote,准引号),题面注释直说了
「Quasiquotes also work as quotes」。在没有 unquote(逗号)的情况下,
反引号的行为和普通引号完全一样:整段都不求值。所以答案和第 6 问一模一样。
出这一问是为了让你看到反引号并不神秘——只有当里面出现 ,expr 时它才会「开个口子」
去求值那一小段,本次作业不涉及。
8) '(cons 4 (cons (cons 6 8) ())) → (cons 4 (cons (cons 6 8) ()))
scm> '(cons 4 (cons (cons 6 8) ()))
(cons 4 (cons (cons 6 8) ()))
整个表达式被引号罩住,一个字都不求值,所以输出就是原文照抄。
这一问是全套里最容易慌的一问——你会本能地去算里面的 cons,
甚至注意到 (cons 6 8) 若真求值会报错(cdr 不是列表也不是 nil,
正如题面所说 (cons 1 2) 会 Error)。但引号意味着它永远不会被求值,
所以不会报错,它只是一段静静躺着的数据。
那这段数据到底是什么形状?它是一个三元素列表:
| 位置 | 内容 | 是什么 |
|---|---|---|
| 第 0 个 | cons | 符号 |
| 第 1 个 | 4 | 数字 |
| 第 2 个 | (cons (cons 6 8) ()) | 又是一个三元素列表(嵌套) |
注意末尾的 ():它是空列表,作为最内层列表的第三个元素出现,
打印时也就原样打印成 ()。它跟「链的末端那个不打印的 nil」不是一回事——
这里的 () 是一个元素,所以看得见。
9) (cons 1 (list (cons 3 nil) 4 5)) → (1 (3) 4 5)
没有引号,所有东西都要求值,从里往外:
1. (cons 3 nil) → 单元素列表,打印为 (3)
2. (list (那个) 4 5) → 三元素列表:
元素0 = 列表 (3) ← 元素本身是个列表!
元素1 = 4
元素2 = 5
打印为 ((3) 4 5)
3. (cons 1 上面这个) → 四元素列表:1, (3), 4, 5
打印为 (1 (3) 4 5)
+---+---+ +---+---+ +---+---+ +---+---+
| 1 | ---->| * | ---->| 4 | ---->| 5 | / |
+---+---+ +-|-+---+ +---+---+ +---+---+
|
v
+---+---+
| 3 | / |
+---+---+
关键在于分清「把元素接到链上」和「把整个列表当成一个元素」。
cons 的第一个参数总是变成一个元素——哪怕它自己是个列表。
所以 (3) 在结果里是嵌套的一层括号,而不是被摊平成 1 3 4 5。
想摊平得用 append。
| 表达式 | 结果 | 为什么 |
|---|---|---|
(cons '(1 2) '(3 4)) | ((1 2) 3 4) | 第一个参数整体成为一个元素,长度 3 |
(append '(1 2) '(3 4)) | (1 2 3 4) | 两条链首尾相接,长度 4 |
(list '(1 2) '(3 4)) | ((1 2) (3 4)) | 两个参数各自成为一个元素,长度 2 |
验证
这九问在 tests/wwsd_lists.py 里是明文存着的('locked': False),
答案就是上面写的那些。跑 python3 ok -q wwsd_lists 时,
ok 会真的把每一行喂给 Scheme 解释器,拿实际输出和文件里的期望值比对,
所以这些答案不是「据说」,是解释器亲口说的。
- 以为
cdr返回元素。第 3 问填2是最常见的错。cdr永远返回列表(或非列表的 cdr 值),把它想成 Python 的link.rest就不会错。 - 对引号里的东西继续求值。第 8 问最容易写成
(4 (6 . 8))之类的东西。 只要最外层有',里面就是纯数据,一层求值都不做。 - 把
()和「什么都没有」混淆。作为列表末端的 nil 不打印, 作为元素出现的()要打印。第 8 问结尾那个()属于后者。 - 以为
'只能加在列表前。'a也合法,得到符号a;'5得到5。引号作用于任意一个表达式。 - 忘了
(cons 1 2)在这个 61A 版 Scheme 里会 Error。 题面明说 cdr 必须是列表或 nil。别在 Q5/Q6 里手滑写出这种非列表 pair。
5. Make a List(lst)
题目要什么
题面给了一张盒指针图(box-and-pointer diagram),要你定义一个名字 lst 指向那个结构。
题面里的图我们看不到原图文件,但解锁测试把答案钉死了——
tests/make_structure.py 里第二个用例写着:
scm> lst ; type out exactly how Scheme would print the list that will be defined in this problem (see spec)
((1) 2 (3 4) 5)
所以目标非常明确:造一个四元素的列表,依次是
| 下标 | 元素 | 类型 |
|---|---|---|
| 0 | (1) | 子列表,只有一个元素 1 |
| 1 | 2 | 数字 |
| 2 | (3 4) | 子列表,两个元素 |
| 3 | 5 | 数字 |
它长这样:
lst | v +---+---+ +---+---+ +---+---+ +---+---+ | * | ---->| 2 | ---->| * | ---->| 5 | / | +-|-+---+ +---+---+ +-|-+---+ +---+---+ | | v v +---+---+ +---+---+ +---+---+ | 1 | / | | 3 | ---->| 4 | / | +---+---+ +---+---+ +---+---+
主链有四个 pair(因为顶层有四个元素);第一个和第三个 pair 的 car 指向另一条链, 而不是一个数。「元素本身是列表」正是 Q4 第 9 问练的那件事。
怎么想到的
这题的难点不在语法,在读图。给一张盒指针图写出构造代码, 核心是先回答两个问题,顺序不能反。
lst 出发,一路顺着 cdr 箭头走到 nil,经过几个盒子。这里是 4 个。
往下垂的那些箭头(car 指向的子结构)不算在长度里。
判断依据也可以直接从打印结果看:((1) 2 (3 4) 5) 最外层括号里,
按空格切开是四段。(1);
第 2 个 car 就是数字 2;第 3 个 car 指向长度 2 的链 → (3 4);第 4 个是 5。list 是最直接的工具:(list e0 e1 e2 e3)。
而元素本身是列表时,就在那个位置再嵌一层 (list ...)。
用 cons 一层层手搭也行,但括号会多一倍,容易写错。cons 和 list 搞反。
初学者常写成 (list 1 2 (list 3 4) 5),少了第一个元素外面那层——
结果是 (1 2 (3 4) 5),第一个元素成了数字 1 而不是列表 (1),
测试直接不匹配。写完一定拿打印结果对一遍括号。Scheme 打印列表时,每一层嵌套对应一对括号。反过来读答案
((1) 2 (3 4) 5):最外层一对括号 = 一个列表;里面第一段 (1)
自己带括号 = 它是个列表。「括号数 = 嵌套深度」这条对应关系,
是把打印结果翻译回构造代码的钥匙。
代码
(define lst
; 对应盒指针图:第一个元素是子列表 (1),第三个元素是子列表 (3 4)
(list (list 1) 2 (list 3 4) 5))
| 片段 | 作用 | 说明 |
|---|---|---|
(define lst ...) | 给一个值起名,不是定义过程 | 注意 lst 后面没有括号。写成 (define (lst) ...)
就变成了定义一个零参数过程,之后 lst 求值得到的是过程对象而不是列表,测试会挂。 |
外层 (list ... ... ... ...) | 造主链,四个元素 | 四个参数从左到右求值,然后串成链。 |
(list 1) | 造子列表 (1) |
等价写法 (cons 1 nil) 或 '(1)。 |
(list 3 4) | 造子列表 (3 4) |
等价写法 (cons 3 (cons 4 nil)) 或 '(3 4)。 |
Challenge:换几种方式造同一个结构
题面鼓励用不同构造器写出同一个列表。下面几种都会打印成 ((1) 2 (3 4) 5)
(这几段是示意,最终提交的是上面那版):
; 全部用 cons 手搭
(cons (cons 1 nil)
(cons 2
(cons (cons 3 (cons 4 nil))
(cons 5 nil))))
; 整体用引号一次写完
'((1) 2 (3 4) 5)
; 混合:外层 list,子表用引号
(list '(1) 2 '(3 4) 5)
三者造出的结构完全相同。list 版最好读;引号版最短,但要记住引号里的东西
不会求值——如果某个元素需要算(比如 (+ 1 1)),引号版就不能用了。
验证
解锁测试的第一个用例是一段热身,把它手工走一遍,正好复习 car/cdr 的嵌套:
scm> (define a '(1))
a
scm> a
(1)
scm> (define b (cons 2 a))
b
scm> b
(2 1)
scm> (define c (list 3 b))
c
scm> c
(3 (2 1))
scm> (car c)
3
scm> (cdr c)
((2 1))
scm> (car (car (cdr c)))
2
scm> (cdr (car (cdr c)))
(1)
a = (1) 一个 pair: car=1, cdr=nil
b = (cons 2 a) 新 pair: car=2, cdr=a → 打印 (2 1)
注意 a 没有被复制,b 的 cdr 就是 a 那个 pair
c = (list 3 b) 两个元素的列表: 元素0=3, 元素1=b
→ 打印 (3 (2 1)),b 整体成为一个元素
(car c) 主链第一个 pair 的 car = 3
(cdr c) 主链剩下部分 = 只含一个元素 b 的列表
该元素是 (2 1),所以打印 ((2 1)) ← 两层括号
(car (cdr c)) 取出那个唯一元素 → b,即 (2 1)
(car (car (cdr c))) b 的 car → 2
(cdr (car (cdr c))) b 的 cdr → a,即 (1)
最容易错的是 (cdr c) 那一问:答案是 ((2 1)) 而不是 (2 1)。
因为 c 有两个元素,去掉第一个之后剩下的是一个含单个元素的列表,
外面那层括号是「列表」的括号,里面那层是「元素恰好也是列表」的括号。
这里也顺带看到别名(aliasing):b 的 cdr 和 a 是同一个 pair,
(cdr (car (cdr c))) 拿到的就是 a 本身。
然后是本题自己的验证。测试 setup 先跑 (load-all ".") 把 lab09.scm 加载进来,
于是 lst 已被定义。再求值 lst:
求 (list (list 1) 2 (list 3 4) 5): 1. list 是普通过程,四个算子数从左到右求值: (list 1) → 链 [1|nil] 打印 (1) 2 → 2 (list 3 4) → 链 [3|·]→[4|nil] 打印 (3 4) 5 → 5 2. apply list:把这四个值串成四个 pair 的主链 3. 打印:遍历主链,逐个打印元素,元素是列表就再加一层括号 → ((1) 2 (3 4) 5) ✓
- 写成
(define (lst) ...)。那是定义过程。之后lst打印出来是#[lst]之类的过程表示,测试期望((1) 2 (3 4) 5),不匹配。 - 子列表忘了包一层。
(list 1 2 (list 3 4) 5)得到(1 2 (3 4) 5),长度还是 4 但第一个元素类型错了。 - 用
cons但把子表当成 cdr。比如(cons (list 1) (cons 2 ...))是对的,而(cons 1 (cons 2 ...))就少了那层嵌套。区别只在第一个参数是1还是(list 1)。 - 把主链末尾的 nil 忘了。纯
cons写法必须以nil收尾; 写成(cons 5 5)之类的会直接 Error,因为 cdr 不是列表。
6. Without Duplicates(without-duplicates)
题目要什么
实现 without-duplicates:收一个数字列表 lst,返回一个新列表,
包含 lst 中所有不重复的元素,且按它们第一次出现的顺序排列。
例如 (without-duplicates (list 5 4 5 4 2 2)) 得 (5 4 2)。
tests/without_duplicates.py 的全部用例:
scm> (without-duplicates (list 5 4 2))
(5 4 2)
scm> (without-duplicates (list 5 4 5 4 2 2))
(5 4 2)
scm> (without-duplicates (list 5 5 5 5 5))
(5)
scm> (without-duplicates ())
()
scm> (without-duplicates '(5 4 3 2 1))
(5 4 3 2 1)
scm> (without-duplicates '(5 4 3 2 1 1))
(5 4 3 2 1)
scm> (without-duplicates '(5 5 4 3 2 1))
(5 4 3 2 1)
scm> (without-duplicates '(12))
(12)
scm> (without-duplicates '(1 1 1 1 1 1))
(1)
把这些用例读出信息量:
| 用例 | 它在考什么 |
|---|---|
(without-duplicates ()) → () | 空表必须能处理,返回空表(不是报错、不是 #f) |
'(12) → (12) | 单元素表 |
(5 4 5 4 2 2) → (5 4 2) | 重复元素不相邻也要去掉;保留的是第一次出现的位置 |
(5 5 5 5 5) → (5) | 全同 |
(5 4 3 2 1) → 原样 | 无重复时不能打乱顺序,也不能排序 |
第三条最关键:(5 4 5 4 2 2) 里的重复是隔开的,
所以「只比较相邻两个」的做法(那是 uniq,不是去重)会失败。
第五条排除了「先排序再去相邻重复」的偷懒法——排序会把 (5 4 3 2 1) 变成 (1 2 3 4 5)。
怎么想到的
先说一条错误的路,因为大多数人都会先想到它。
「维护一个『已经见过的元素』的集合,遍历列表,没见过就加进结果。」
这在 Python 里是标准解法。但在 Scheme 里,你手上没有 set,也没有可变状态,
要实现就得再传一个累积参数,写成 (define (helper lst seen) ...),
还要自己写「seen 里是否包含 x」的查找过程。能做,但你会发现题目根本没给你写 helper 的位置,
而且提示里明确点了 filter。这说明有更短的路。
(5 4 5 4 2 2)。第一个元素 5 一定要保留(它是第一次出现)。
既然 5 已经进结果了,那后面所有的 5 都不该再出现——
那我干脆现在就把它们从剩余部分里全删掉。
剩余部分 (4 5 4 2 2) 删掉所有 5,变成 (4 4 2 2)。
然后对 (4 4 2 2) 做同样的事就行了。这就是递归。(without-duplicates lst)
= car lst 接在 (without-duplicates (删掉所有等于 car lst 的元素后的 cdr lst)) 前面。
每次递归,表至少短 1,一定会终止。filter 干的事:
(filter 谓词 列表) 保留谓词为真的元素。我们要保留「不等于 (car lst)」的,
谓词就是 (lambda (x) (not (= x (car lst))))。
题面提示里那句「用 filter 配一个 helper lambda」说的就是这个。(if (null? lst) nil ...)。
测试里 (without-duplicates ()) → () 就是直接考这一支。
不能返回 #f 或什么都不返回——后面递归回代时要 cons 到它上面,
必须是个合法列表。cons 到递归结果之前),而头永远是「剩余部分里最早出现的那个」。
所以输出顺序就是首次出现顺序。这也解释了为什么必须用 cons 而不是 append
往后接——那会把顺序反过来。把「记住过去」换成「清理未来」。前者需要额外的状态(累积参数), 后者只需要当前这一层的信息。函数式编程里这个转换非常常见: 凡是想用可变集合记录历史的地方,先想想能不能改成对剩余数据做一次过滤。
代码
(define (without-duplicates lst)
(if (null? lst)
nil
; 保留表头,再把余下部分中所有与表头相等的元素滤掉后继续递归
(cons (car lst)
(without-duplicates
(filter (lambda (x) (not (= x (car lst))))
(cdr lst))))))
| 片段 | 作用 | 为什么这样写 |
|---|---|---|
(null? lst) | 判断是不是空表 | Scheme 惯例:返回布尔值的过程名以 ? 结尾(null?、even?、pair?)。
不能写 (= lst nil),= 只用于数字。 |
nil | base case 返回空表 | 它会被打印成 ()。也是递归回代时最内层那个「接地点」。 |
(cons (car lst) ...) | 把当前头接到递归结果前面 | 顺序全靠这一步。若改成 (append ... (list (car lst))),输出会整个倒过来。 |
(filter 谓词 (cdr lst)) | 从尾部里滤掉与头相等的元素 | 第二个参数必须是 (cdr lst) 而不是 lst。
如果传 lst,头自己也会被滤掉——那倒不会死循环,但会丢掉本该保留的元素?
不,更糟:头被过滤掉后表变短,结果虽然仍收敛,却把逻辑搞乱了。
干脆记住:头已经处理完了,递归只该看尾巴。 |
(lambda (x) (not (= x (car lst)))) | 谓词:x 与头不相等时保留 | 这个 lambda 是闭包,它引用了外层的 lst。
每层递归的 lst 不同,所以每层造出来的谓词也不同——闭包在这里是必需的,
不是装饰。 |
(not (= ...)) | 取反 | Scheme 没有 !=。题面提示里明说了用 not 配 =。 |
这段代码是「链表递归」的标准骨架:base case 返回空表 → 递归情况 cons 头 + 递归处理尾。
你在 Python 里对 Link 写过无数次
Link(link.first, f(link.rest)),这里一模一样,只是名字从
Link/first/rest/Link.empty 换成了 cons/car/cdr/nil。
本题唯一的变化是:递归之前先对尾巴做了一次 filter。
验证:手工追踪 (without-duplicates (list 5 4 5 4 2 2))
第 0 层 lst = (5 4 5 4 2 2) (null? lst) → #f (car lst) = 5 (cdr lst) = (4 5 4 2 2) filter 保留 ≠5 的: (4 4 2 2) → 结果 = (cons 5 (without-duplicates (4 4 2 2))) 第 1 层 lst = (4 4 2 2) (car lst) = 4 (cdr lst) = (4 2 2) filter 保留 ≠4 的: (2 2) → 结果 = (cons 4 (without-duplicates (2 2))) 第 2 层 lst = (2 2) (car lst) = 2 (cdr lst) = (2) filter 保留 ≠2 的: () → 结果 = (cons 2 (without-duplicates ())) 第 3 层 lst = () (null? lst) → #t → 返回 nil ★ 触底
第 3 层 返回 nil 打印 () 第 2 层 (cons 2 nil) = (2) 第 1 层 (cons 4 (2)) = (4 2) 第 0 层 (cons 5 (4 2)) = (5 4 2) ✓
输出 (5 4 2),与测试一致。注意第 0 层那次 filter:
它一口气把位置 2 上的那个 5 也删掉了,虽然它跟头并不相邻。
这就是为什么这个解法能处理「隔开的重复」,而只比较相邻元素的写法不能。
再看两个边界用例
| 调用 | 递归层数 | 过程 | 结果 |
|---|---|---|---|
(without-duplicates ()) | 1 层 | (null? lst) 立即为 #t,返回 nil | () |
(without-duplicates '(12)) | 2 层 | 头 = 12,尾 = (),filter 空表还是空表,递归返回 nil,(cons 12 nil) | (12) |
(without-duplicates '(1 1 1 1 1 1)) | 2 层 | 头 = 1,filter 把尾巴里五个 1 一次性全滤掉 → (),递归返回 nil | (1) |
(without-duplicates '(5 4 3 2 1)) | 6 层 | 每层 filter 都没删掉任何东西,等价于原样重建这条链 | (5 4 3 2 1) |
第三行值得多看一眼:全同元素的表只递归两层就结束了, 因为一次 filter 就把所有重复清空。而如果用「逐个比对 seen 集合」的写法, 这里得走满六层。这个解法在重复很多时反而更快。
复杂度
设列表长 \(n\)、去重后有 \(k\) 个不同元素。递归深度是 \(k\)(每层消掉一个不同的值),
每层的 filter 要扫一遍当前剩余的表,代价 \(O(n)\)。
所以总代价是 \(\Theta(nk)\),最坏(全不相同)是 \(\Theta(n^2)\)。
这和 Python 里用 set 的 \(\Theta(n)\) 比是慢的,
但在没有哈希表的纯函数式设定下,\(\Theta(n^2)\) 是很正常的代价,
本题也没有性能要求。
不用 filter 的写法(对照)
如果不用 filter,也可以走「判断头是否在尾巴里出现过」的思路,
但那需要一个额外的 contains? 辅助过程,而且顺序会变:
如果你写「若头在尾巴里还会出现,就丢掉头」,那 (5 4 5 4 2 2) 保留的是
最后 一次出现的位置,得到 (5 4 2) 恰好也对,但 (1 2 1)
会得到 (2 1) 而不是 (1 2)。题目要的是首次出现的顺序,
所以这条路必须小心。filter 版天然正确,这也是提示推荐它的原因。
- filter 的第二个参数写成
lst。写(filter (lambda (x) (not (= x (car lst)))) lst)会把头自己也滤掉, 于是头永远进不了结果,最终输出空表或残缺表。必须是(cdr lst)。 - 用
append代替cons。append的两个参数都得是列表,(append (car lst) ...)会因为(car lst)是个数字而报错;写成(append (list (car lst)) ...)虽然能跑, 但每层都要重建一条链,白白多花时间。cons才是 \(O(1)\) 的。 - base case 返回
()的字面量写法搞混。 写nil或'()都可以;写()(不带引号)在有些实现里会被 当成空的调用表达式而报错。这份代码用nil,最稳。 - 用
equal?还是=?本题保证是数字列表, 题面明说用=。如果元素可能是符号或嵌套列表,=会报类型错, 那时才需要equal?。 - 忘了 lambda 里的
(car lst)会在每次调用谓词时重新求值。 这不是错误(lst在这一层里不会变),但你要明白它引用的是本层的lst, 靠的是闭包记住了本层的帧。递归到下一层时,那是另一个帧、另一个lst。
7. 整份作业回顾
如果只让你从这份 lab 里带走一句话:语法是表皮,求值规则是骨头。 你换到一门括号语言,写法全变了,但「先算算子、再从左到右算算子数、再 apply」 这条规则、以及「函数记住自己被创建时的环境」这条规则,一个字都没变。
| 题目 | 核心手法 | 迁移到哪里 |
|---|---|---|
Q1 over-or-under | cond 多路分支;分支表达式而非语句 |
任何三路以上的判断;理解「表达式有值、语句没有」 |
Q2 composed | 返回 lambda;闭包捕获形参 |
装饰器、柯里化、任何「工厂函数」;下一讲写解释器时的过程对象 |
Q3 repeat | 对次数递归而不是对数据递归;恒等函数当 base case | 迭代 n 次的抽象、\(f^n\);「把 base case 挑在最边界」这条通用经验 |
| Q4 WWSD | 区分求值与不求值:cons/list/quote |
理解 Scheme 代码即数据,是 Lecture 20 写解释器的前提 |
Q5 lst | 读盒指针图 → 数主链长度 → 逐元素还原 | 任何链表/嵌套结构的构造与调试;考试里画图题 |
Q6 without-duplicates | 链表递归骨架 + filter;用「清理未来」替代「记住过去」 |
所有需要去重/分组/划分的函数式处理;快排的 partition 思路 |
三条可迁移的思维方法
tests/*.py 不是作弊,是搞清楚需求——
挑那个能区分不同实现的用例(比如 Q2 的
((composed add-one multiply-by-two) 2) 得 5 而不是 6),
一眼就能确定顺序。自测
不看代码,说出 (cons 1 (list 2 3))、(list 1 (list 2 3))、'(1 (list 2 3)) 各是什么
(cons 1 (list 2 3)) → (1 2 3):(list 2 3) 求值成两元素列表,
1 接在它前面,长度 3。
(list 1 (list 2 3)) → (1 (2 3)):两个元素,第二个元素本身是列表,长度 2。
'(1 (list 2 3)) → (1 (list 2 3)):整段不求值,
第二个元素是含三个东西的列表,第一个是符号 list,长度 2。
把 (repeat f 3) 展开成一个不含 repeat 的表达式
(composed f (composed f (composed f (lambda (x) x))))。
作用在 x 上等于 (f (f (f x))),最内层那次恒等调用不改变任何东西。
如果把 Q6 里的 (cdr lst) 误写成 lst,(without-duplicates '(1 2 1)) 会输出什么
第 0 层:头 = 1,filter 在整个 (1 2 1) 上滤掉所有 1,得 (2),
结果 = (cons 1 (without-duplicates '(2)))。
第 1 层:头 = 2,filter 在 (2) 上滤掉所有 2,得 (),
结果 = (cons 2 nil) = (2)。
回代得 (1 2)——这次碰巧对了。但在
(without-duplicates '(1 1 2)) 上同样会得到 (1 2),也对。
这个写法之所以危险,是它多做了一次无用的比较(头必然等于头),
逻辑上依赖「过滤掉头之后剩下的不含头」这个巧合;
一旦谓词改成非自反的比较(比如 <),就会立刻出错。写 (cdr lst) 才是表达意图的写法。
为什么 (if (> (- x 3) 0) (/ 1 (- x 3)) (+ x 2)) 在 x = 3 时不报除零错
因为 if 是特殊形式,不遵守「先求所有算子数」的规则:
它先求谓词 (> 0 0) 得 #f,然后只求假分支 (+ x 2) 得 5。
真分支那个会除零的表达式根本没被求值。
如果 if 是普通过程,两条腿都会先算,那就必崩。这就是「特殊形式」存在的理由。