CS 61A  /  作业解析
LAB 09

Lab 9:Scheme 与 Scheme 列表ok 17 项通过

换一门语言重做一遍你已经会的事:高阶函数与链表递归。语法全变了,求值规则一点没变。

对应讲次:Lecture 18(Scheme)、Lecture 19 官方题面:cs61a.org/lab/lab09 代码:labs/lab09/lab09.scm 本仓库 python3 ok --local:17 test cases passed,全部题目通过

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.emptynil(打印成 ())Q4、Q6
对链表递归:处理头 + 递归处理尾一模一样,cons 头,递归 cdrQ6
本次要点
  • 括号即调用。在 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 时返回 -1
  • num1 = num2 时返回 0
  • num1 > 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)) ...),多套一层括号,直接崩。

怎么想到的

拦住人的从来不是逻辑,是「比较怎么写」和「分支怎么写」。逐个拆。

1 比较运算符也是过程。Python 写 num1 < num2,运算符夹在中间(中缀,infix)。 Scheme 一律前缀(prefix):(< num1 num2)。< 不是什么特殊语法, 它就是一个绑定到内建过程的符号(symbol),跟 quotient 没有区别。 你在解释器里单独敲 <,它会打印 #[<],证明它是个值。
2 相等用 = 不是 ==。Scheme 的赋值靠 define, 所以单个 = 没被占用,直接拿来当数值相等判断。写 == 会报「未绑定的符号」。
3 选分支结构。这里有两个方案。方案 A 是嵌套 if:Scheme 的 if 只有两条腿((if 谓词 真值表达式 假值表达式)),没有 elif, 所以三路分支必须把第三种情况塞进「假」那条腿里,写成嵌套: (if A -1 (if B 0 1))。方案 B 是 cond,它天生支持任意多个「谓词-结果」对, 从上往下逐个试。三路以上的分支写 cond 更平、更好读,所以选 B。 题目里的 Challenge 就是让你两种都写一遍,体会 cond 只是嵌套 if 的语法糖。
4 不用写 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#felse1

三行输出 -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 里怎么「返回一个函数」?

1 先确认过程是值。Scheme 是函数式语言,过程和数字一样是一等公民(first-class): 能绑定到名字、能当参数传、能被返回。测试里 (composed multiply-by-two add-one) 直接把两个过程名当参数传进去,就是在依赖这条性质。
2 造匿名过程用 lambda。Python 的 lambda x: f(g(x)) 在 Scheme 里写作 (lambda (x) (f (g x)))。三处差异:形参要用括号括成一个列表 (因为可以有多个形参),冒号没了,函数体是表达式。
3 不需要先 define 一个内部过程再返回它。 Python 版里写 def h(x) 再 return h,是因为 Python 的 lambda 只能放一个表达式、写起来别扭。Scheme 里过程体本来就是表达式, 直接把 lambda 表达式当过程体,它的值就是返回值。一行搞定。
4 顺序怎么排?「返回 f 作用在 g of x 上的结果」——从里往外读: 先算 (g x),再把它整个作为 f 的参数,即 (f (g x))。 这里的嵌套结构和 Python 的 f(g(x)) 是同构的,只是括号从函数名后面挪到了前面。
5 为什么闭包(closure)能工作?返回的 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 可能有用。顺着这条线走。

1 先把关系写成等式。记 \(R_n\) 表示 (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)))。递归的形状出来了。
2 base case 选哪个?第一个念头通常是「\(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 在某种输入下会崩」。
3 把 base case 往下挪一格。让 \(n = 0\) 当 base case,返回什么? 按定义,「作用 0 次」= 什么都不做 = (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,而不是单元素表)。
4 顺序又要检查一次。该写 (composed f (repeat f (- n 1))) 还是 (composed (repeat f (- n 1)) f)?对 square 这种情况两者结果相同, 看不出差别;但从定义讲,composed 的第二个参数先执行,所以 「先做 \(n-1\) 次,最外面再补一次 f」写成前者。 (因为这里复合的两边都是同一个 f 的幂,实际上两种写法数学上等价, 但保持和递归叙述一致更不容易搞混。)
5 不用 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 = 0ID2
n = 1increment ∘ ID3
n = 2increment ∘ (上一行)4
n = 3increment ∘ (上一行)5
n = 4increment ∘ (上一行)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
12数字
2(3 4)子列表,两个元素
35数字

它长这样:

lst
 |
 v
+---+---+   +---+---+   +---+---+   +---+---+
| * | ---->| 2 | ---->| * | ---->| 5 | / |
+-|-+---+   +---+---+   +-|-+---+   +---+---+
  |                       |
  v                       v
+---+---+               +---+---+   +---+---+
| 1 | / |               | 3 | ---->| 4 | / |
+---+---+               +---+---+   +---+---+

主链有四个 pair(因为顶层有四个元素);第一个和第三个 pair 的 car 指向另一条链, 而不是一个数。「元素本身是列表」正是 Q4 第 9 问练的那件事。

怎么想到的

这题的难点不在语法,在读图。给一张盒指针图写出构造代码, 核心是先回答两个问题,顺序不能反。

1 顶层有几个元素?只数主链上的 pair 个数, 也就是从 lst 出发,一路顺着 cdr 箭头走到 nil,经过几个盒子。这里是 4 个。 往下垂的那些箭头(car 指向的子结构)不算在长度里。 判断依据也可以直接从打印结果看:((1) 2 (3 4) 5) 最外层括号里, 按空格切开是四段。
2 每个元素分别是什么?逐个看主链 pair 的 car: 第 1 个 car 指向一条长度 1 的链 → 元素是列表 (1); 第 2 个 car 就是数字 2;第 3 个 car 指向长度 2 的链 → (3 4);第 4 个是 5。
3 选构造器。「我知道有几个元素、每个元素是什么」这种情形, list 是最直接的工具:(list e0 e1 e2 e3)。 而元素本身是列表时,就在那个位置再嵌一层 (list ...)。 用 cons 一层层手搭也行,但括号会多一倍,容易写错。
4 别把 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。这说明有更短的路。

1 换个角度:不要「记住见过谁」,而是「把未来的重复提前删掉」。 考虑 (5 4 5 4 2 2)。第一个元素 5 一定要保留(它是第一次出现)。 既然 5 已经进结果了,那后面所有的 5 都不该再出现—— 那我干脆现在就把它们从剩余部分里全删掉。 剩余部分 (4 5 4 2 2) 删掉所有 5,变成 (4 4 2 2)。 然后对 (4 4 2 2) 做同样的事就行了。这就是递归。
2 写出递归关系。 (without-duplicates lst) = car lst 接在 (without-duplicates (删掉所有等于 car lst 的元素后的 cdr lst)) 前面。 每次递归,表至少短 1,一定会终止。
3 「删掉所有等于某值的元素」怎么写?这正是 filter 干的事: (filter 谓词 列表) 保留谓词为真的元素。我们要保留「不等于 (car lst)」的, 谓词就是 (lambda (x) (not (= x (car lst))))。 题面提示里那句「用 filter 配一个 helper lambda」说的就是这个。
4 base case。空表返回空表:(if (null? lst) nil ...)。 测试里 (without-duplicates ()) → () 就是直接考这一支。 不能返回 #f 或什么都不返回——后面递归回代时要 cons 到它上面, 必须是个合法列表。
5 顺序保住了吗?保住了。每层递归都是把当前的头放在最前面 (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),= 只用于数字。
nilbase 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-undercond 多路分支;分支表达式而非语句 任何三路以上的判断;理解「表达式有值、语句没有」
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 思路

三条可迁移的思维方法

1 先写等式,再写代码。Q3 里先写下 \(R_n = f \circ R_{n-1}\)、\(R_0 = \mathrm{id}\), 代码就只是把等式抄成括号。Q6 里先写下 「结果 = 头 + 去重(滤掉等于头的尾巴)」,代码同样是照抄。 卡住的时候,问题几乎总是出在等式还没想清楚,而不是括号不会写。
2 base case 尽量选最边界的那个。Q3 选 \(n=0\) 而不是 \(n=1\), Q6 选空表而不是单元素表。边界 case 通常返回某种「单位元」—— 恒等函数、空表、0、1。选得越边界,需要单独处理的特例越少。
3 用测试用例反推规格。Q2 的参数顺序、Q5 的确切结构, 题面文字都不够精确,但测试文件把答案钉死了。 读 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 是普通过程,两条腿都会先算,那就必崩。这就是「特殊形式」存在的理由。