层次结构——小写 t 树
小写 t 树由分支和叶子组成,借助递归可以优雅地处理这类嵌套列表结构。
小写 t 树概览
我们来讨论小写 t 树的一些基本性质。我们已经知道,像 (cons (list 1 2) (list 3 4)) 这样的结构可以表示成树形结构:
小写 t 树由分支(branch)和叶子(leaf)组成。上面的树有五条分支,它们对应上图中从左向右的连线。请注意,一条分支可以通向一棵子树(subtree)——即包含在更大树中的树。在本例中,分支 ((1 2) 3 4) 包含子树 (1 2)。叶子没有从它连出的分支。上面的树有 4 个叶子:1、2、3 和 4。叶子位于树的”底部”,也称为 fringe(边缘)。
与现实世界中的树相比,计算机科学里的树往往是倒过来的!
用递归处理小写 t 树
处理树时,用递归(recursion)的方式思考通常很有帮助。举个例子,我们来写一个函数 count-leaves,统计一棵树中有多少个叶子。
我们先用平实的英语粗略描述这个函数要做什么,这称为编写伪代码(pseudocode)。在理解了 count-leaves 应有的行为之后,我们再为它编写真正的 Racket 代码。这是解决问题的好习惯。
伪代码
回顾我们如何定义 length——它统计列表中的元素个数:
- 空列表的
length是 0。 - 非空列表
x的length是 1 加上x的cdr的length。
count-leaves 的基本情况(base case)与此相同:
- 空列表的
count-leaves是 0。
但递归情况略有不同。在 length 中,我们保证列表的 car 是单个元素,所以它的长度计为 1。而对于 count-leaves,它的 car 可能包含一棵或多棵子树,其长度并不总是 1。因此,我们还需要递归地求树的 car 的 count-leaves!于是我们的递归调用是:
- 一棵树的
count-leaves等于这棵树的car的count-leaves加上其cdr的count-leaves。
最终我们会一路 car 到树的叶子,于是有第二个基本情况:
- 一个叶子的
count-leaves是1。
pair? 谓词
对一棵树调用 car 时,我们必须判断它返回的是另一棵树(一个序对),还是一个叶子(单个元素,技术上称为原子 atom)。如何检查?Racket 提供了内置谓词 pair?,测试其参数是否为 cons 的结果。例如:
(pair? (cons 1 2))返回#t。(pair? (cons 1 (cons 2 3)))返回#t。(pair? 2)返回#f。(pair? 'pear)返回#f。(pair? '())返回#f。
真正的代码
借助 pair? 和上面的伪代码,我们可以写出 count-leaves 的完整代码:
(define (count-leaves x)
(cond ((null? x) 0) ;; 树是空的吗?
((not (pair? x)) 1) ;; 树是单个元素吗?
(else (+ (count-leaves (car x)) ;; 否则,对 car 调用 count-leaves
(count-leaves (cdr x)))))) ;; 再对 x 的 cdr 调用,然后相加
示例:scale-tree
在第 4 课中,我们见过函数 scale-list,它把列表中的每个元素乘以一个给定的数值因子。现在我们要写一个类似的函数 scale-tree,它接受一个深列表和一个数值因子,把深列表中的所有元素都乘以该因子。
示例调用如下:
> (scale-tree (list 1 (list 2 (list 3 4) 5) (list 6 7)) 10)
(10 (20 (30 40) 50) (60 70))
测试理解
下面是 scale-tree 的一个未完成定义。要正确定义 scale-tree,我们需要哪些基本情况?
(define (scale-tree tree factor)
(cond ;;你的答案写在这里。
(else
(cons (scale-tree (car tree) factor)
(scale-tree (cdr tree) factor)))))
选项:
- A.
((null? tree) 0):对空树返回 0,会在某些情况下给序列末尾拼上多余的 0。你能举出会发生这种情况的例子吗? - B.
((null? tree) null):这是一个正确的基本情况,但还需要另一个才能让scale-tree正确工作。 - C.
((not (pair? tree)) (* factor tree)):这是一个正确的基本情况,但还需要另一个才能让scale-tree正确工作。 - D. A 和 C 都要:单个元素的情况处理正确了,但没有处理空树的情况。
- E. B 和 C 都要:正确!(✅)B 处理参数为空树的情况,C 检查输入的树是否为原子,并正确地将其乘以 factor。
现在,试着在解释器中用一些你自己的例子测试 scale-tree!
示例:deep-reverse
我们来处理一个结构类似的问题。这一次,我们要写一个名为 deep-reverse 的函数,反转深列表中所有元素的顺序。例如:
> (define x (list (list 1 2) (list 3 4)))
((1 2) (3 4))
> (deep-reverse x)
((4 3) (2 1))
注意,不仅 (1 2) 和 (3 4) 交换了位置,它们内部的元素也反了过来。deep-reverse 对内部不含其他列表的列表也应该有效。
测试理解
下面是 deep-reverse 的一个未完成定义。要正确定义 deep-reverse,我们需要哪些递归调用?
(define (deep-reverse d-l)
(cond ((null? d-l) null)
;;你的答案写在这里。
))
选项:
- A.
((pair? (car d-l)) (append (deep-reverse (cdr d-l)) (list (deep-reverse (car d-l))))):这是一个正确的递归调用,但我们还需要另一个才能让deep-reverse正确工作。 - B.
((pair? (car d-l)) (append (cdr d-l) (list (deep-reverse (car d-l))))):d-l 的cdr也必须被反转! - C.
(else (append (deep-reverse (cdr d-l)) (list (car d-l)))):这是一个正确的递归调用,但我们还需要另一个才能让deep-reverse正确工作。 - D.
(else (append (car d-l) (list (deep-reverse (cdr d-l))))):这个递归调用没有反转 d-l 的car和cdr。 - E. A 和 C 都要:正确!(✅)
(car d-l)是序对的情况由 A 处理,(car d-l)不是序对的情况由 C 处理。 - F. B 和 D 都要:请分别查看 B 和 D 的解析。
试着在 Racket 解释器中试试看!
要点总结
树可以包含子树,因此在解决涉及树的问题时,递归会非常有用。