2.2.3 序列作为一致化的接口
在使用复合数据的时候,我们有点紧张,数据抽象是如何让我们能够设计程序却没有被
数据表示的细节所困扰,数据抽象是如何让我们保留了 试验多种表示方式的灵活性。
在这部分中,我们要介绍另一种强有力的设计原则,这种原则针对的是数据结构的使用。
设计原则是使用一致化的接口
在1.3部分中,我们看到了程序如何抽象,实现为一个高层次的程序,能够捕捉到
处理数字的程序的共同模式。在处理复合数据时,我们规范相似操作的能力,关键依赖于
我们操作我们的数据结构的风格。思考一下,例如,如下的程序,它与2.2.2部分中的
count-leaves程序是相似的,它有一个树作为参数,计算叶子为偶数的平方和。
(define (sum-odd-squares tree)
(cond ((null? tree) 0)
((not (pair? tree))
(if (odd? tree) (square tree) 0))
(else (+ (sum-odd-squares (car tree)) (sum-odd-squares (cdr tree)))
)
)
)
在表面上,这个程序与如下的程序完全不同,它组装了所有的斐波那些数的列表。
(define (even-fibs n)
(define (next k)
(if (> k n)
nil
(let ((f (fib k)))
(if (even? f)
(cons f (next (+ k 1)))
(next (+ k 1))
)
)
)
)
(next 0)
)
尽管事实是这两个程序在结构上非常不同,这两个计算在一个更抽象的描述上,
揭示出非常大的相似性,第一个程序的执行如下:
# 遍历树的叶子
# 过滤结点,选择偶数
# 对选择的数进行平方计算
# 从0开始,累加结果
第二个程序如下:
# 从0到N,遍历整数。
# 计算斐波那些数。
# 过滤它们,选择奇数
# 从一个空列表开始,使用cons,累加结果到列表。
一个信号处理的工程师,认为概念化这些过程,使用信号流,通过一系列的过程的串联,
这些过程都实现程序计划的一部分。如图2.7所示。在sum-odd-squares程序中,我们开始于
enumerator遍历器,它生成了一个信号,由一个给定的树的叶子组成。这个信号流过一个过滤器,
滤过了其它的元素,保留了奇数。这个结果信号流过了map,这是一个转换器,应用square到各个元素。
map的输出喂给了一个累加器,它使用加法操作组合了各个元素,从0开始。even-fibs的计划是相似的。
-------------- ________________ _________________ ________________
|遍历: |----->|过滤器 | ----->|映射 | |累加器 |
|树的叶子 | | 是奇数? | |平方函数 |--->|+ ,0 |
-------------- ---------------- |________________| |_______________|
-------------- _________________ _________________ ________________
|遍历: |----->| 映射 | ----->|过滤器 | |累加器 |
|正整数集 | |斐波那些数函数| |是偶数? |--->|cons ,() |
-------------- ----------------- |________________| |_______________|
图2.7 信号流计划揭示了两个程序之间的共性。sum-odd-squares程序在上,even-fibs在下。
不幸的是,如上的两个程序定义没有显式的显示出这个信号流的结构。
例如,如果我们检查sum-odd-squares程序,我们发现遍历被null? 和pair?
的测试实现了一部分,实现的另一部分是程序的树形递归结构。
相似的是,累加器也是部分被测试实现,部分被递归的累加实现。
总之,这不是能够对应到程序的信号流描述中的元素的独特的一部分。
我们的这两个程序以不同的方式分解了计算,把遍历扩散到程序中,与其它的部分混合了。
如果我们能组织我们的程序在我们的程序编写中形成信号流的结构,
这将增强结果代码的概念完整性。
* 序列操作
组织程序为了更清晰地反映信号流的结构的关键在于集中注意力于信号从一个环节
流向下一个环节。如果我们表示这些信号为列表,然后,我们能使用列表操作
来实现每个环节中的处理过程。例如,我们能实现信号流图中的映射环节,使用
2.2.1部分中的map程序:
(map square (list 1 2 3 4 5))
(1 4 9 16 25)
过滤一个序列仅选择那些满足特定条件的元素们,由如下的程序来完成:
(define (filter predicate sequence)
(cond ((null? sequence) nil)
((predicate (car sequence))
(cons (car sequence) (filter predicate (cdr sequence))))
(else (filter predicate (cdr sequence)))
)
)
例如,
(filter odd? (list 1 2 3 4 5))
(1 3 5)
累加器能被以下的程序实现:
(define (accumulate op initial sequence)
(if (null? sequence)
initial
(op (car sequence) (accumulate op initial (cdr sequence))))
)
(accumulate + 0 (list 1 2 3 4 5))
15
(accumulate * 1 (list 1 2 3 4 5))
120
(accumulate cons nil (list 1 2 3 4 5))
(1 2 3 4 5)
实现信号流图的所有的剩余部分是遍历元素的序列。对于even-fibs,
我们需要生成一个给定范围的整数的序列,我们能做的如下:
(define (enumerate-interval low high)
(if (> low high)
nil
(cons low (enumerate-interval (+ low 1) high))
)
)
(enumerate 2 7)
(2 3 4 5 6 7)
为了列举树的叶子,我们使用如下的程序:
(define (enumerate-tree tree)
(cond ((null? tree) nil)
((not (pair? tree)) (list tree))
(else (append (enumerate-tree (car tree))
(enumerate-tree (cdr tree)))))
)
(enumerate-tree (list 1 (list 2 (list 3 4)) 5))
(1 2 3 4 5)
现在我们能以信号流图的方式,重构程序sum-odd-squares 和even-fibs。
对于程序sum-odd-squares,我们能列举树的叶子的序列,过滤它得到奇数的序列
对这些元素进行平方,然后再求和。
(define (sum-odd-squares tree)
(accumulate + 0 (map square (filter odd? (enumerate-tree tree))))
)
对于even-fibs,我们列举从0到n的整数,生成这些数字中的任何一个的斐波那些数。
再过滤结果序列,仅保留偶数,再累加结果到一个列表。
(define (even-fibs n)
(accumulate cons nil (filter even? (map fib (enumerate-interval 0 n))))
)
把程序表达为序列操作的价值是它有助于我们让程序设计更加地模块化,
也就是设计为组合相对独立的程序片段而组装起程序来。通过提供一个标准组件的库,
结合以灵活的方式连接组件的一致性的接口,我们能鼓励模块化设计。
在工程设计之中,模块化组装是一个强有力的策略来控制复杂性。在真实的信号处理
应用之中,例如设计者常常以选择标准化的组件并且级联它们,来构建系统。
这些标准化组件是从过滤器和转换器的标准化库中选择出来的。相似的,序列操作
也提供了一个我们能混合与匹配的标准的程序元素的库。例如,在前N+1个斐波那些数
的平方的列表的组装程序中,我们能重用程序sum-odd-squares 和even-fibs的程序片段。
(define (list-fib-squares n)
(accumulate cons nil (map square (map fib (enumerate-interval 0 n))))
)
(list-fib-squares 10)
(0 1 1 4 9 25 64 169 441 1156 3025)
在一个序列中的奇数的连续乘积的计算中,我们能重排与使用上述程序的程序片段。
(define (product-of-squares-of-odd-elements sequence)
(accumulate * 1
(map square (filter odd? sequence)
)
)
)
(product-of-squares-of-odd-elements (list 1 2 3 4 5))
225
用序列操作我们能也规范化一致性的数据处理应用。假定我们有一个个人记录的
序列,我们要找到工资最高的程序员的工资。假定我们有一个选择子salary,它可以
返回工资记录中的工资值,一个判断式programmer?,它用来测试一条记录是不是
程序员的。然后,我们能写出如下的程序:
(define (salary-of-highest-paid-programmer records)
(accumulate max
0
(map salary
(filter programmer? records)
)
)
)
这些例子给了一个启示。它是各种各样的操作能被表示成序列的操作。
序列被实现为列表,它提供了一系列的一致性的接口,来允许我们组合处理模块。
此外,当我们统一地把结构表示成列表时,在我们的程序中,我们能把数据结构
的依赖局限于很少的几种序列操作。通过改变这点,我们能实验序列的其它表示方法。
它脱离了我们的程序的整体设计。在3.5部分中我们将探索这种能力,
当我们通用化序列处理范式时来承认无限的序列。
练习2.33
作为累加器的一些基本的基于列表的操作,在这些操作的如下的定义中,填上
缺失的表达式来完成定义。
(define (map p sequence)
(accumulate (lambda (x y) <??>) nil sequence))
(define (append seq1 seq2)
(accumulate cons <??> <??>)
)
(define (length sequence)
(accumulate <??> 0 sequence)
)
练习2.34
在一个给定的x的值处解释一个多项式,能被公式化为一个累加。
我们解释多项式 an*x^n+a(n-1)*x^(n-1)+....+a1*x+a0
使用一个知名的算法,叫做角商规则,它结构化了计算如下:
(...(an*x+a(n-1))*x+....+a1)*x+a0
换句话说,我们开始于an乘以x,加上 a(n-1),再乘以x,等等。直到
我们到达了a0.填写如下的模板,生成一个程序用角商规则来解释一个多项式。
假定多项式的系数被放在了一个序列中,从a0到an.
(define (horner-eval x coefficient-sequence)
(accumulate (lambda (this-coeff higher-terms) <??>) 0 coefficient-sequence)
)
例如计算 1+3*x+5*x^3+x^5 在x=2时,你要解释如下的表达式:
(horner-eval 2 (list 1 3 0 5 0 1))
练习2.35
作为累加器,重定义2.2.2部分中的count-leaves程序。
(define (count-leaves t)
(accumulate <??> <??> (map <??> <??>))
)
练习2.36
程序accumulate与accumulate是相似的,除了它的第三个参数是
一个序列的序列,序列的元素被假定有相同的元素的数量。
它应用一个主要的累加程序,来组合序列的所有的第一个元素,
序列的所有的第二个元素,等待。返回结果的序列。例如,如果s
是一个由四个序列组成的序列,((1 2 3) (4 5 6) (7 8 9) (10 11 12)),
那么(accumulate-n + 0 s)的值应该是序列(22 26 30)。
在accumulate-n的定义中,填入缺失的表达式:
(define (accumulate-n op init seqs)
(if (null? (car seqs))
nil
(cons (accumulate op init <??>)
(accumulate-n op init <??>))
)
)
练习2.37
假定我们表示向量v为数据的序列,矩阵m作为向量的序列,向量是矩阵的行。
例如,矩阵
_ _
| 1 2 3 4 |
| 4 5 6 6 |
| 6 7 8 9 |
- -
能被表示为序列((1 2 3 4) (4 5 6 6) (6 7 8 9)).
在这种表示法下,我们能使用序列操作来精确表达基本的矩阵和向量操作。
这些操作(它被描述在矩阵代数的任何一本书中)如下:
(dot-product v w) 点乘 返回
(matrix-*-vector m v) 矩阵乘向量
(matrix-*-matrix m n) 矩阵相乘
(transpose m) 矩阵转置
我们能定义点乘如下:
(define (dot-product v w)
(accumulate + 0 (map * v w)))
为了计算其它的矩阵操作,在如下的程序中填入缺失的表达式。
(程序accumulate-n在练习2.36中定义)
(define (matrix-*-vector m v)
(map <??> m)
)
(define (transpose mat)
(accumulate-n <??> <??> mat)
)
(define (matrix-*-matrix m n)
(let ((cols (transpose n)))
(map <??> m))
)
练习2.38
程序accumulate也因fold-right程序而知名。
因为它组合了序列的第一个元素作为结果,这个结果是所有的元素向右组合的。
也有fold-left与fold-right相似,除了组合的方向不同。
(define (fold-left op initial sequence)
(define (iter result rest)
(if (null? rest)
result
(iter (op result (car rest))
(cdr rest))
)
)
(iter initial sequence)
)
如下的表达式的值是什么?
(fold-right / 1 (list 1 2 3))
(fold-left / 1 (list 1 2 3))
(fold-right list nil (list 1 2 3))
(fold-left list nil (list 1 2 3))
给定一个属性,就是op应该满足一个条件,这个条件是它要保证
fold-right和fold-left生成任何序列的相同的值。
练习2.39
用练习2.38中的fold-leftfold-right来完成练习2.18中reverse的
如下的定义。
(define (reverse sequence)
(fold-right (lambda (x y) <??>) nil sequence)
)
(define (reverse sequence)
(fold-left (lambda (x y) <??>) nil sequence)
)
* 级联映射
我们能扩展序列范式包括许多操作,这些操作一般使用嵌套的循环来表达。
考虑这个问题,给定一个正整数n,找到所有的不相同的正整数i和j的有序的数对。
并且1<=j<i<=n, 并且j+i是素数,例如如果n等于6,那么有序的数对如下:
i | 2 3 4 4 5 6 6
j | 1 2 1 3 2 1 5
_____|______________________
i+j | 3 5 5 7 7 11 11
组织起这个计算的一个很自然的方式是生成小于等于n的所有的
正整数的有序的数对的序列,再过滤选择数对的和为素数的,然后对于任何一个
数对,生成三元式(i,j,j+i).
这是一个生成数对的序列的方式。对于i<=n,遍历整数j<i,并且对于任何一个i和j,生成
i和j的数对。用序列的操作,我们映射序列(enumerate-interval 1 n).对于这个序列中的
任何一个i,我们能映射序列(enumerate-interval 1 (- i 1)),对在后一个序列中的j,
我们能生成一个i和j的数对。这让我们有了一个对于任何一个i的数对的序列。组合所有的i的序列
生成要求的数对的序列。
(accumulate append nil
(map (lambda (i) (map (lambda (j) (list i j))
(enumerate-interval 1 (- i 1))))
(enumerate-interval 1 n)))
在这个程序中,映射与累加结合append的组合是如此的有共同性,
我们能独立出它成为一个单独的程序。
(define (flatmap proc seq)
(accumulate append nil (map proc seq)))
现在能过滤这个数对的序列,以找到和为素数的部分。过滤器的
判断式被序列中每一个元素调用。它的实际参数是一个数对,
它必须从这个数对中抽取出整数来。因此,应用在这个序列中的每
一个元素的判断式是
(define (prime-sum? pair)
(prime? (+ (car pair) (cadr pair)))
)
最后,生成结果的序列,通过映射已经被过滤的数对,通过
使用如下的程序,它组装三元式,这个三元式包括数对的二个
元素和它们的和。
(define ()
(list (car pair) (cadr pair) (+ (car pair) (cadr pair))))
组合这些步骤完成整个程序:
(define (prime-sum-pairs n)
(map make-pair-sum
(filter prime-sum?
(flatmap
(lambda (i) (map (lambda (j) (list i j))
(enumerate-interval 1 (- i 1))))
(enumerate-interval 1 n)))))
级联映射不仅对遍历区间,对序列也是很有用的。假定,我们希望生成一个集合的
所有的子集的集合。也就是在集合中对所有的项进行排序。例如,{1 2 3}的全排列是
{1 2 3},{1 3 2},{2 1 3},{2 3 1},{3 1 2},{3 2 1}。
为了生成集合的全排列,这有一个计划:对于集合中的任何一项元素,递归地生成集合S减少这一项x
的全排列的序列,再在序列中的每一项前加上x.对于集合中的每一项元素x,这完成了以x开头的
集合S的全排列的序列。组合所有的x的这些序列,就得到了S的全排列。
(define (permutations s)
(if (null? s)
(list nil)
(flatmap (lambda (x) (map (lambda (p) (cons x p))
(permutations (remove x s))))
s))
)
注意这个策略是如何把生成集合S的全排列的问题归结为比集合S有更少的元素的集合的全排列
的问题的。在递归终结的情况中,我们把问题归结为空集合。它没有任何元素。对引,
我们能生成一个空元素的集合,这个集合有一个元素,它的含义是没有元素。在全排列的程序中,
remove程序返回除了指定元素之外的所有的元素。这能被表达为一个简单的过滤器。
(define (remove item sequence)
(filter (lambda (x) (not (= x item)))
sequence)
)
练习2.40
定义一个程序unique-pairs,给定一个整数n,生成 i和j的数对的序列,
条件是 1<=j<i<=n.使用这个程序unique-pairs,来简化prime-sum-pairs的定义。
练习2.41
写一个程序来找到所有的有序的三元式,这些三元式中的i,j,k都是正整数,且不相同,
小于等于n,它们的和等于s.
练习2.42
________________________________________
| | | | | |#-# | | |
|____|____|____|____|____|____|____|____|
| | |#-# | | | | | |
|____|____|____|____|____|____|____|____|
|#-# | | | | | | | |
|____|____|____|____|____|____|____|____|
| | | | | | |#-# | |
|____|____|____|____|____|____|____|____|
| | | | |#-# | | | |
|____|____|____|____|____|____|____|____|
| | | | | | | |#-# |
|____|____|____|____|____|____|____|____|
| |#-# | | | | | | |
|____|____|____|____|____|____|____|____|
| | | |#-# | | | | |
|____|____|____|____|____|____|____|____|
图2.8 八王后问题的一种解决方案
八王后问题是问如何把八个王后放在国际象棋的棋盘上,保证没有任何两个
王后处于相互攻击的状态。(即没有两个王后处于同一行,同一列,同一斜行)
一种可能的解决方案如图2.8. 解决问题的一种方式是沿着棋盘,在每个列上放一个王后,
一旦我们已经放了k-1个王后,我们在放第k个王后时,它的位置不能和棋盘上已有的
任何一个王后处于相互攻击的状态。我们能对这个方法递归地公式化:假定我们已经生成了
在棋盘的前k-1列上放置k-1个王后的所有的可能的方式的序列。对于这些方式中的任何一种,
通过在第k列的任何一行上放一个王后,生成一个扩展的位置集合。现在过滤它这些集合,仅保留
第k列上的与其它的王后处于安全状态的王后的位置。这生成了在棋盘的前k列上放置k个王后
的所有的可能的方式的序列。通过继续这个执行过程,我们将生成不只一个解决方案,
而是问题的所有的方案。
我们实现这个方法,以程序queens,它返回在n乘以n大小的棋盘上,放置 n个王后的问题的所有的
解决方案的序列。queens有一个内部的程序,queen-cols,这个程序返回棋盘上前k列上放置的王后的
所有的解决方案的序列。
(define (queens board-size)
(define (queen-cols k)
(if (= k 0)
(list empty-board)
(filter (lambda (positions) (safe? k positions))
(flatmap (lambda (rest-of-queens)
(map (lambda (new-row)
(adjoin-position new-row k rest-of-queens))
(enumerate-interval 1 board-size)))
(queen-cols (- k 1)))))
)
(queen-cols board-size)
)
在这个程序rest-of-queens是在前k-1列中放置 k-1个王后的一种方式,new-row是在第k列中放置王后的
一个可选的行。为了棋盘的位置的集合,实现它的表示方式,来完成程序,包括程序adjoin-position和
程序empty-board. 程序adjoin-position负责把一个新的行列位置加入到位置集合中。
程序empty-board负责表示位置的一个空集合。你也必须写一个程序safe?,它负责确定对于一个位置集合来说,第k列的王后与其它的王后之间是否是安全的。(注意我们仅需要检测新的王后是否是安全的,其它的王后之间已经保证是安全的。)
练习2.43
louis reasoner在做练习2.42时,执行时间很长。他的程序queens(王后们)似乎是可以工作的,但太慢了。
(甚至在6*6时也慢得要命。) louis向eva求教,她指出他已经修改了在flatmap中的嵌套映射的顺序,把
它写成了如下的形式:
(flatmap (lambda (new-row)
(map (lambda (rest-of-queens)
(adjoin-position new-row k rest-of-queens))
(queen-cols (- k 1))))
(enumerate-interval 1 board-size))
解释下,为什么这个交换会让程序执行变慢?假定练习2.42中的程序的执行时间为T,
估计一下Louis的程序在执行八王后问题时,要花多少时间?