2.3.3 例子:表示集合

 2.3.3 例子:表示集合
在前面的例子中,我们构建了两种复合数据的表示法。一种数据是有理数,另一种数据是代数表达式。
这些例子中的一个我们化简表达式的选择在组装时或者是选择时。但是其它的情况是为了这些结构的表示法的选择。
当我们转到集合的表示时,表示法的选择并不明显。实际上,有一些可能用的表示法。在某些时候,
它们之间的区别很大。

形式化的讲,一个集合是一个不同的元素组成的。为了得到更精确的定义,我们能用数据抽象的方法。
也就是我们定义集合上的操作。它们是union-set,intersection-set,element-of-set?,adjoin-set.
Element-of-set?是一个判断式,确定一个给定的元素是否是集合中的一个元素。
adjoin-set程序有一个对象和一个集合作为参数,返回一个集合,这个集合包括原来的集合的元素和新添加的元素。
union-set程序计算两个集合的并集。
intersection-set程序计算两个集合的交集。
从数据抽象的观点来看,我们能自由的设计表示法 来实现这些操作,以一种一致的方法。

@ 集合作为一个无序的列表
表示集合的一种方式是用列表,并且它的元素只出现一次。空集合表示为空列表。在这种表示法中,
element-of-set?与2.3.1部分中的memq程序是相似的。它使用equal? 代替 eq?
因为集合的元素不要求一定是符号。
(define (element-of-set? x set)
     (cond ((null? set) false)
           ((equal? x (car set)) true)
           (else (element-of-set? x (cdr set)))
     )
)
使用这个程序,我们能写adjoin-set程序了。如果添加的对象已经存在于集合中,我们仅返回集合。
否则,我们要使用cons来把对象添加到表示集合的列表中。

(define (adjoin-set x set)
   (if (element-of-set? x set)
        set
       (cons x set)
   )
)

对于intersection-set程序,我们使用递归策略。如果我们知道如何生成
set2和set1的cdr结果集的交集,我们仅需要决定是否包括set1的car的部分。
而且这依赖于(car set1)是否在set2中。这是如下的结果程序:

(define (intersection-set set1 set2)
   (cond ((or (null? set1) (null? set2)) '())
         ((element-of-set? (car set1) set2)
           (cons (car  set1)
                 (intersection-set (cdr set1) set2)))
         (else (intersection-set (cdr set1) set2))))

在设计表示时,我们应该注意的问题之一是效率。考虑我们的集合操作需要的步骤数量。
因为它们都使用element-of-set?,这个操作的速度对整个集合操作的实现效率有很大的影响。
现在为了检查一个对象是否是一个集合的元素,element-of-set?可能不得不扫描整个集合。
(最差的情况是,对象却不在集合中)。所以集合有N个元素,element-of-set?可能执行N次。
 因此,增长为theda(N) . 使用这个操作的adjoin-set,的增长也是theda(N).对于 intersection-set操作而言
它对set1中的每个元素执行,element-of-set?操作,需要的操作步骤的数量的增长是被调用的集合的大小的乘积,或者是
对于大小为N的两个集合而言是theda(N*N). union-set的情况也是这样的。

练习2.59
对于集合的非有序的列表表示法,实现union-set操作。

练习2.60
我们指定集合表示为一个没有重复元素的列表。现在我们假定允许重复。例如,集合{1,2,3}
能被表示为列表(2 3 2 1 3 2 2)。在这个表示法之下,设计程序element-of-set?,adjoin-set,union-set,
intersection-set.与非重复表示法下的相应程序比较一下效率如何?
有什么应用程序是使用这种表示法而优先于非重复表示法?


@ 集合作为一个有序的列表
加速集合操作的一种方式是改变它的表示方法,如集合的元素以升序排列。
为了实现这个目标,我们需要一些方式来比较两个对象哪个更大。
例如,我们能比较符号,以字母序。或者是我们认为的一些方法,如为一个对象
赋值一个唯一的值,然后比较它们按照相应的数值。为了保持我们的讨论是简单的,
我们仅考虑集合元素是数字的情况。这是这了能使用大于号和小于号来比较元素。
我们将表示一个数字的集合以升序表示在列表中。我们的第一种表示允许我们
表示集合{1 3 6 10}以任意顺序。我们的新的表示仅允许我们表示为列表(1 3 6 10)。

有序的一个优势表现在element-of-set?程序中。在检查一个项的存在性时,我们不再是必须扫描整个集合了。
如果我们得到一个集合元素大于我们要找的值,那么我们知道这个值不在集合中。

扫描二维码关注公众号,回复: 2635489 查看本文章

(define (element-of-set? x set)
        (cond ((null? set) false)
              ((= x (car set)) true)
              ((< x (car set)) false)
              (else (element-of-set? x (cdr set)))
        ))

这节省了多少步骤呢? 在最差的情况下,我们找的是集合中的最大的值,它的步骤数与非有序时是一样的。
另一方面,我们在许多不同的大小的项时,我们能预期有些时候,我们能够在列表的开始处附近停下来,还有的时候,
我们仍然要检查列表的大多数。我们能预期的平均检查次数是集合中项的数量的一半。因此,要求的步骤数量是N/2。
这仍然是theda(N)的增长。平均来看,它比之前的表示法,节省了一半的步骤。

对于intersection-set来说,我们得到了更明显的加速。在无序的时候,这个操作的增长是theda(N*N).
因为我们为了set1的每一个元素,我们执行对set2的全扫描。但是在有序的表示中,我们能够使用一个更聪明的做法。
通过比较两个集合中的初始元素,如果相等,那么得到了交集中的一个元素,交集的其它部分是两个集合的CDR部分的交集。
假定x1<x2,因为x2是set2中的最小元素,我们能立即得到结论,x1不再set2中,因此x1不在交集中。因此
交集等于set2与set1的CDR部分的交集。类似的情况是,x2<x1,交集等于set1与set2的CDR部分的交集。
程序的定义如下:

(define (intersection-set set1 set2)
   (if (or (null? set1) (null? set2))
        '()
        (let ((x1 (car set1)) (x2 (car set2)))
              (cond ((= x1 x2)
                     (cons x1 (intersection-set (cdr set1) (cdr set2))))
                    ((< x1 x2)
                      (intersection-set (cdr set1) set2))
                    ((> x1 x2)
                      (intersection-set set1 (cdr set2)))
              )
         )
    )
)

为了估计这个过程需要多少步骤,注意到在任何一个步骤中,我们都把交集问题归结到了计算更小的集合的交集上。
移除了集合一,或者是集合二的一个元素,或者是两个集合都移除了一个元素。因此需要的步骤数量是它的最大值
等于集合一和集合二的元素数量之和。而不是无序表示时的两个集合的元素数量之积。
这是theda(N)的增长,而不是theda(N*N).这是深谋远虑的加速,尤其是对有巨量的元素的集合。

练习2.61
使用有序的表示,给出adjoin-set的实现。通过与element-of-set?的类似,显示出怎么利用有序的特性,
产生一个程序,这个程序在平均情况下,需要无序时,一半的操作步骤。

练习2.62
对于集合表示成有序的列表时,给出一个增长为theda(N)的 union-set的实现。


@ 集合作为一个二叉树
通过把集合元素组织成树型,我们能比有序列表的表示方式做得更好。
树的任何一个结点,都拥有集合中的一个元素。叫做在那个结点上的载荷。还有连接到其它两个结点上的连接。
也可能是一两个空结点。即连接可能有0,1,2个。左连接指向的元素都小于这个结点上的值。右连接指向的元素
都大于这个结点上的值。图2.16显示了一些树来表示集合{1,3,5,7,9,11}。以树的方式,同一个集合,
能有许多棵树表示。对于一个有效的表示方式,我们要求的仅有的事是在左子树上的所有元素值都小于该结点上的元素值,
在右子树上所有的元素值都是大于该结点上的元素值。

                7                        3                          5
               / \                      / \                        / \
              /   \                    /   \                      /   \
             3     9                  1     7                    3     9
            / \     \                      / \                  /     / \
           /   \     \                    /   \                /     /   \
          1     5     11                 5     9              1     7     11
                                                \
                                                 \
                                                  11
图2.16  表示集合{1,3,5,7,9,11}的多个二叉树

树表示的优势是假定我们要检查一个元素是否在一个集合中。我们开始比较这个值是否是树的根结点。
如果这个值小于根结点,我们知道我们仅需要搜索左子树即可。如果这个大于根结点的值,我们仅需要搜索右子树。
现在如果树是平衡的,这个平衡是指这些子树中的任何一个都有其父树的大约一半的结点。
因此,我们在搜索树时的每一个步骤的递归,搜索的规模都是减半的,我们能预期的执行步骤的数量是在扫完一个有N个结点的树时,
增长的复杂度是theda(log(N)). 对于很大的集合而言,相比之前的表示方式,这是一个很重大的加速。

使用列表,我们能表示树。任何一个结点都是有三个项的列表。在结点上的载荷,左子树,右子树。
对于空列表来说,它的左子树和右子树将显示为没有子树连接了。我们能描述这个表示方法,以如下的程序:

(define (entry tree) (car tree))
(define (left-branch tree) (cadr tree))
(define (right-branch tree) (caddr tree))
(define (make-tree entry left right)
       (list entry left right))

现在我们能写element-of-set?程序,使用如上描述的策略。

(define (element-of-set? x set)
       (cond ((null? set) false)
             ((= x (entry set)) true)
             ((< x (entry set))
                    (element-of-set? x (left-branch set)))
             ((> x (entry set))
                    (element-of-set? x (right-branch set)))
      )
)

添加一个项到集合中的实现与此是相似的。也要求有theda(log(n))步骤。为了添加一个项,
我们把x与结点的值进行比较,来决定是应该添加到右子树还是左子树。然后添加到合适的子树上,
然后我们重新组装子树。如果x等于结点的值,则返回结点。如果我们要把x添加到空树上,我们就生成一个树,
有x作为载荷,和空的左子树,和空的右子树。这是如下的程序:

(define (adjoin-set x set)
    (cond ((null? set) (make-tree x '() '()))
          ((= x (entry set)) set)
          ((< x (entry set))
              (make-tree (entry set) (adjoin-set x (left-branch set)) (right-branch set))
          )
          ((> x (entry set))
              (make-tree (entry set) (left-branch set) (adjoin-set x (right-branch set)))
         )
   )
)

上述的要求是搜索树能被执行以对数级的步骤数量,这是假定树是平衡的。
也就是每个树的左右子树有大约相同数量的元素,也就是每个子树包括
大约父树的一半的元素。但是我们如何能确定我们所组装的树是平衡的呢?即使我们
以一棵平衡的树开始,用adjoin-set程序加了元素以后,也可能导致一个不平衡的结果。
因为一个新加入的结点的位置依赖于元素如何与集合中的已有的项进行比较,我们能预期
的是如果我们加元素是随机的话,树可能在平均水平上趋向于平衡。但这是没有什么保证的。
例如,我们开始于一个空的集合,加上数字1到7以顺序。我们将得到一个高度不平衡的树。如图2.17
在这个树中,所有的左子树都是空的,这与一个简单的有序列表相比,没有任何优势。解决
这个问题的一种方式是定义一个操作来把一棵普通的树转换成一棵平衡的树,并且这棵树与原来的树有相同的元素。然后,我们能够执行在每个adjoin-set操作之后执行这个操作,来保持我们的集合处于平衡状态。
也有其它的方式来解决这个问题,它们的大部分包括设计一个新的数据结构来搜索和插入都是
操作步骤的数量在对数级。

1
 \
  \
   2
    \
     \
      3
       \
        \
         4
          \
           \
            5
             \
              \
               6
                \
                 \
                  7
图2.17 顺序地添加1到7,生成的非平衡的树

练习2.63
如下的两个程序中的任何一个都把一个二叉树转换成列表。

(define (tree->list-1 tree)
    (if (null? tree)
         '()
         (append (tree->list-1 (left-branch tree))
                 (cons (entry tree)
                       (tree->list-1 (right-branch tree))
                 )
          )
    )
)

(define (tree->list-2 tree)
    (define (copy-to-list tree result-list)
       (if  (null? tree)
            result-list
           (copy-to-list (left-branch tree)
                         (cons (entry tree)
                               (copy-to-list (right-branch tree)
                                             result-list)))
       )
    )
    (copy-to-list tree '())
)

a.这两个程序能为任何树生成相同的结果吗?如果不能,结果怎么不同?对于图2.16中
的树,这两个程序能生成什么样的列表?

b. 在把平衡的树转成列表时,这两个程序有相同的时间复杂度吗?如果不同,哪个增长得慢一点呢?

练习2.64
如下的程序list->tree转换一个有序的列表成为一个平衡的二叉树。辅助程序partial-tree以一个整数n
和至少n个元素的列表为参数,组装一个平衡的树,这个树包括列表的前n个元素。partial-tree返回的结果
是一个数对(由cons组装),它的car部分是一个组装好的树,cdr部分是一个没在树中的元素的列表。

(define (list->tree elements)
(car (partial-tree elements (length elements))))

(define (partial-tree elts n)
  (if (= n 0)
      (cons '() elts)
      (let ((left-size (quotient (- n 1) 2)))
           (let ((left-result (partial-tree elts left-size)))
                (let ((left-tree (car left-result))
                      (non-let-elts (cdr left-result))
                      (right-size (- n (+ left-size 1))))
                     (let ((this-entry (car non-left-elts))
                           (right-result (partial-tree (cdr non-left-elts)
                                                        right-size)))
                         (let ((right-tree (car right-result))
                               (remaining-elts (cdr right-result)))
                              (cons (make-tree this-entry left-tree right-tree) remaining-elts)
                          )
                     )
                 )
           )
      )
  )
)

a.写一小段话,清楚地解释你能如何让partial-tree工作的?画出程序list-tree为列表(1 3 5 7 9 11)
生成的树。

b. 程序list-tree把一个有n个元素的列表转换的需要的步骤数量的时间复杂度是什么?

练习2.65
使用练习2.63和练习2.64的结果,使用平衡的二叉树实现集合,给出union-set和intersection-set
的时间复杂度为线性的实现。

@ 集合与信息检索

为了使用列表示集合,我们检查了选项,已经看到了如何为一个数据对象的表示方法的选择
对于使用数据的程序的性能,能有一个巨大的影响。聚焦于命令的另一个原因是这里讨论的技术
一次又一次地出现在包含信息检索的应用程序中。

考虑下一个数据库包括了大量的个人记录,例如一个公司的员工文件,或者是一个会计系统的事务。
一个典型的数据管理系统花费了大量的时间来读取和修改记录中的数据,因此需要一个高效的方法
来读取记录。通过把任何一条记录的一部分标识起来,作为一个标识的键,这可以实现高效率。
一个键能是任何值,它能唯一性地标识记录。对于一个员工的文件,它可能是一个员工的员工编号。
对于一个会计系统,它可能是一个事务编号。无论键是什么,当我们定义记录作为一个数据结构,这个数据结构中
我们应该包括了一个键的选择子程序,这个程序检索与一个给定记录相关的键值。

现在我们表示数据库为记录的一个集合。为了定位一个给定键的记录,我们使用一个程序lookup,
这个程序以一个键值和一个数据库为参数,返回有那个键的记录,如果没有这样的记录,就返回假。
程序lookup被实现以几乎与element-of-set?相同的方式。例如,如果记录的集合以无序列表被实现,
我们能使用如下的程序:

(define (lookup given-key set-of-records)
 (cond ((null? set-of-records) false)
       ((equal? given-key (key (car set-of-records))) (car set-of-records))
       (else (lookup given-key (cdr set-of-records))))
)

当然了,比起无序的列表来,有更好的方式来表示大型的集合。在信息检索系统中的记录不得不能够被随机性
地读取,经典的实现是以基于树的方法,例如之前讨论过的二叉树。在设计这样的系统时,数据抽象的方法论
能有很大的帮助。设计者能使用一个简单的,很自然的表示方法,例如无序列表,来创建一个初始化的实现。
对于最终的工业级系统产品来说,这是不合适的,但是它在提供一个快速并且粗糙的数据库,并用来测试系统的其它方面时,能是很有用的。 稍后,数据表示能被修改,并且变得很复杂。如果数据库能被读取,使用抽象的选择子和组装子,在表示方法上的修改将不要求对系统的其它部分的修改。

练习2.66
实现lookup程序,针对的情况是记录的集合被组织为一个二叉树的结构,以键的数值排序。

猜你喜欢

转载自blog.csdn.net/gggwfn1982/article/details/81477130
今日推荐