2.3.4 例子:哈夫曼编码树

2.3.4 例子:哈夫曼编码树
这部分提供了练习,在列表结构和数据抽象的使用方面,来操纵集合和树。应用的方法是表示数据为1和0的序列。
例如,ASCII标准编码用来表示计算机中的文本,编码任何一个字符为七位的一个序列。使用7位比特的长度,
允许区分出2^7或者是128个可能的不同的字符。总之,如果我们要区分n个不同的字符,我们将需要为每个字符使用log2(n)个比特的位长。如果我们所有的消息都是由八个字符ABCDEFGH组成,我们能选择一个有三个比特的编码
为每个字符使用,例如

A 000 B 001 C 010 D 011
E 100 F 101 G 110 H 111
有了这套编码,如下的消息

BACADAEAFABBAAAGAH

能被编码成如下的54个比特位的字符串。

001000010000011000100000101000001001000000000110000111

例如ASCII的编码和上面的A到H的编码,都是固定长度的编码,因为
它们表示消息中的任何一个字符都使用相同数量的比特位。
这是与使用变长编码有一些不同的,变长码是不同的字符可能被表示为不同的数量的比特位。
例如,莫斯码对于字母中的每个字符没有使用相同数量的点和划。特别是使用最频繁的字符e,
被表示为一个点。总之,如果是我们的消息是这样的,有些字符出现得非常频繁,有些很生僻,
如果我们给频繁的字符赋予更短小的编码,我们能编码数据更高效一些(例如对于每条消息
使用更少的比特位)。对于字符从A到H,考虑如下的可选的编码方案:


A 0   C 1010  E 1100  G  1110
B 100 D 1011  F 1101  H  1111

使用这种编码,如上的相同的消息能被编码成如下的字符串

100010100101101100011010100100000111001111

这个字符串有42个比特位,所以比上述所示的固定的编码方式节省了超过20%的空间使用。

使用变长编码的困难之一是在读取一个01的序列时,知道什么时候到了字符的结尾处。
莫尔斯码解决了这个问题,是通过在任何一个字母的点和划之后,使用一个特定的分隔符的编码。
另一个解决方案是以特定的方式设计编码,这个方式是对于任何字符而言,没有一个字符的完全编码,是
另一个字符的编码的前缀。这种编码叫做前缀编码。在上面的例子中,A编码为0,B编码为100,
所以没有一个其它的字符以0或者是100为前缀。

总之,我们能得到很大的节省,如果我们使用了变长的前缀编码,利用了在消息中的字符
的使用相对频率来编码。这么做的一个特定的模式叫做哈夫曼编码方法,它的发现人是戴维哈夫曼。
一个哈夫曼编码能被表示为一个二叉树,它的叶子是被编码的字符。在树的任何一个非叶子的结点
是一个集合,这个集合包括了这个结点下的所有的叶子的字符。此外,在任何一个叶子上的字符都被
赋与了一个权值(它是相对的频率), 任何一个非叶子结点包含了一个权值,它是这个结点所有的叶子结果的权值的和。这个权值不在编码或者是解码中使用。我们将下面看到这些权值是如何辅助树的组装过程的。

                      * {A B C D E F G H} 17
                      |
           -------------------------
           |                       |
          * A 8                    *  {B C D E F G H} 9
                                   |
                    -----------------------------
                   |                            |
                   *  {B C D} 5                 * {E F G H} 4
                   |                            |
           -------------------         ----------------- 
           |                 |         |               |
           * B 3             * {C D}2  * {E F} 2       * {G H} 2
                             |         |               |
                      -----------     --------    ---------
                      |         |     |      |    |       |
                      * C 1     * D 1 * E 1  *F1  * G 1   * H 1


图2.18  一个哈夫曼编码树

图2.18显示了从A到H的如上的编码的哈夫曼树。叶子中的权值显示出树是被专门设计的,
根据消息中的A出现的相应频率是8次,B是3次,其它字母是1次。

给定一个哈夫曼树,我们能找到树中的任何字符的编码,能以树根开始,向下移动,直到包括了目标
字符的叶子结点。任何一次我们下移到左结点时,我们在编码上加0,任何一次我们下移到右结点时,
我们在编码上加1。(通过测试来知道哪个分支是字符的那个叶子结点或者是在它的集合中包括了那个字符,
来决定我们下移到哪个分支)例如,在图2.18中,从树根开始,我们到达D的叶子结点,通过到右分支,
然后是左分支,再右分支,再右分支,因此,D的编码是1011。

为了使用哈夫曼树解码一个比特序列,我们开始于树根,使用比特序列中的连续的01来确定
下移的过程中是遍历左分支或者是右分支。我们到达叶子结点的任何一次,我们已经在消息中生成
了一个新的字符,我们从树的根部重新开始,再找下一个字符。例如,假定
我们给定了如上的树和一个序列10001010,开始于根,我们移到右分支,(因为第一个比特是1),然后
下移到左分支,因为是0,然后再下移到左分支,(因为第三个也是0)。这把我们带到了叶子结点,
它的字符是B,所以解码消息的第一个字符是B。现在我们重新开始于根部,我们做一个下移到左分支,
因为它的下一个比特位是0,这个把我们带到了A的叶子上。然后我们再从根开始,结合串的剩余部分1010。
所以我们向右,向左,向右,再向左到了C的叶子结点处。因此,完整的消息是BAC。

* 生成哈夫曼编码树

给定一些符号的字母表,和它们的相应的频率,我们如何组装最好的编码呢?
(换句话说,哪个树能使用最少的比特位来编码消息?)为了实现这个目标,
哈夫曼给出一个算法,显示出结果的编码的确是对于那些消息的最好的变长
编码.这些消息中的字符的相对的使用频率匹配了被组装的编码的相应的频率.
我们没有证明这里的哈夫曼编码的优越性,但是我们将显示哈夫曼树是如何组装
起来的.

生成一个哈夫曼树的算法是很简单的.安排树的思想是为了有最小频率的字符
离树根最远.开始于叶子结点的集合,包含字符和它的频率,作为通过初始数据
来确定编码如何被组装。现在发现两个权值最小的叶子结点,合并它们来生成
一个非叶子结点,这个结点包括这两个结点,以这两个叶子结点作为这个非叶子结
点的左右两个分支。新结点的权值等于两个子结点的权值的和。从原集合中移除
这两个结点,用这个新的结点代替它们。现在继续这个过程,在每一步中,
合并两个最小权值的结点,从集合中移除它们,用新的结点来代替它们。这个新的结点
以这两个结点为左右分支。当仅剩一个结点时,执行结束了。这个结点就是整个树的根。
这里是图2.18的哈夫曼树是如何生成的:

初始时的叶子集合{(A 8) (B3 ) (C 1) (D  1) (E 1)(F 1)(G 1)(H 1)}
合并 {(A 8) (B3 ) ({C D} 2)  (E 1)(F 1)(G 1)(H 1)}
合并 {(A 8) (B3 ) ({C D} 2) ({E F} 2) (G 1)(H 1)}
合并 {(A 8) (B3 ) ({C D} 2) ({E F} 2) ({G H} 2)}
合并 {(A 8) (B3 ) ({C D} 2) ({E F G H} 4)}
合并 {(A 8) ({B C D} 5)  ({E F G H} 4)}
合并 {(A 8)  ({B C D E F G H} 9)}
最终合并 {({A B C D E F G H} 17)}

算法并不是总能指定一个唯一的树,因为有可能在任何一步最小权值的结点
不是唯一的。而且哪两个结点被合并的顺序的选择是随意的。

* 表示哈夫曼编码树

在下面的练习中,我们将工作在一个使用了哈夫曼树来编码和解码消息的系统,
生成哈夫曼树根据如上的概括的算法。我们将开始讨论树是如何被表示的。

树的叶子被表示为一个列表,这个列表包括常量字符串leaf,在叶子中的字符,
和它的权值。

(define (make-leaf symbol weight)
 (list 'leaf symbol weight))
(define (leaf? object)
 (eq? (car object) 'leaf))
(define (symbol-leaf x) (cadr x))
(define (weight-leaf x) (caddr x))

一个整体的树是一个左分支,一个右分支,一些字符的集合,和一个权值
的列表。一个字符的列表仅简单的是一些字符的列表,而不是一些更复杂
的集合表示。当我们通过合并两个结点来生成一个树时,我们得到了树的
权值,通过计算子结点的权值的和,得到了字符的列表通过计算子结点的
字符的集合的并集。因为我们的字符集合被表示为列表,我们能形成并集
通过使用我们在2.2.1部分中定义的append程序。

(define (make-code-tree)
 (list left right
      (append (symbols left) (symbols right))
      (+ (weight left) (weight right))))

如果我们以这种方式生成树,我们有如下的选择子程序:

(define (left-branch tree)
 (car tree))
(define (right-branch tree)
 (cadr tree))
(define (symbols tree)
 (if (leaf? tree)
     (list (symbol-leaf tree))
     (caddr tree)
  )
 )

(define (weight tree)
 (if (leaf? tree)
     (list (weight-leaf tree))
     (cadddr tree)
 ))

程序symbols和 weight必须做一些不同的事,依赖于它们是调用一个叶子
或者是整个树。这有一些通用化程序的简单例子(程序能处理超过一
种类型的数据)。这方面在2.4和2.5部分我们再进一步地讨论。

* 解码程序

如下的程序实现解码算法。它的参数是一个01的列表和一个哈夫曼树。

(define (decode bits tree)
   (define (decode-1 bits current-branch)
       (if (null? bits)
          '()
   (let ((next-branch (choose-branch (car bits) current-branch)))
        (if (leaf? next-branch)
            (cons (symbol-leaf next-branch)
           (decode-1 (cdr bits) tree))
            (decode-1 (cdr bits) next-branch)
        )
   )
       )
    )
    (decode-1 bits tree)
)

(define (choose-branch bit branch)
  (cond ((= bit 0) (left-branch branch))
        ((= bit 1) (right-branch branch))
 (else (erro "bad bit --choose branch" bit)))
)

程序decode-1有两个参数,余下的待解释比特串的列表和树中的当前位置。
它保持向下遍历树,根据下一个比特位是0还是1,来决定选择左或者是右分支
(这是由程序choose-branch来完成的)。开始于树的根,当到达了叶子,
它返回这个叶子结点包含的字符,作为消息中的下一个字符,
使用程序cons来把这个字符组装到解码消息的其它部分的结果之中。
注意的是,在程序choose-branch中的最后的选择项是错误检查,如果程序在输入
的数据中发现了不是01的值时,程序报错。

* 有权值的元素的集合

在我们的树的表示法中,任何一个非叶子结点都包含了一个字符的集合,
我们表示它作为一个简单的列表。然而,如上讨论的树的生成算法需要我们也能
工作在叶子和树的集合中,连续合并最小的两项。因为我们需要重复地
找到集合中的最小的两项,所以对于这种集合,使用一种有序的表示法是很方便的。

我们将表示叶子和树的集合作为一个列表,以权值的升序排列。如下的组装集合
的程序adjoin-set与练习2.61中描述的程序是相似的。然而,不在集合中的元素在添加到
集合中时,项以它们的权值被比较。

(define (adjoin-set x set)
   (cond ((null? set) (list x))
         ((< (weight x) (weight (car set))) (cons x set) )
  (else (cons (car set)
              (adjoin-set x (cdr set))
        )
  )
   )
)

如下的程序以字符与频率的数对的列表为参数,这个列表例如((A 4) (B 2) (C 1) (D 1))
并且组装一个叶子的初始化的有序集合,准备好根据哈夫曼算法进行合并:

(deine (make-leaf-set pairs)
   (if (null? pairs)
      '()
       (let ((pair (car pairs)))
            (adjoin-set (make-leaf (car pair) (cadr pair))
                 (make-leaf-set (cdr pairs))))
   )
)


练习2.67
定义一个编码树和一个样本消息

(define sample-tree
(make-code-tree (make-leaf 'A 4)
                (make-code-tree (make-leaf 'B 2)
                                (make-code-tree (make-leaf 'D 1)
                                                (make-leaf 'C 1)
                                )
                 )
))

(define sample-message '(0 1 1 0 0 1 0 1 0 1 1 1 0))

使用decode程序,来解码消息,给出结果。


练习2.68
程序encode以一个消息和一个哈夫曼树为参数,生成给定的编码的消息的比特的列表。

(define (encode message tree)
   (if (null? message)
          '()
           (append (encode-symbol (car message) tree)
                   (encode (cdr message) tree)) 
   )
)

encode-symbol是一个程序,是你必须写的,根据一个给定的哈夫曼树编码一个给定的
字符,返回比特的列表。你应该设计它,如果字符不在树中,报错。测试你的程序,通过
对练习2.67中得到的编码结果。看一看是否与原有的消息相同。

练习2.69
如下的程序以字符和频率的数对的列表为参数,并且没有字符是重复的,根据哈夫曼算法,
生成哈夫曼编码树。

(define (generate-huffman-tree pairs)
    (successive-merge (make-leaf-set pairs))
)

make-leaf-set如上面给出的程序,它把数对的列表转换成一个叶子的有序列的集合。
successive-merge是你必须写的程序,使用make-code-tree来持续合并集合中的最小权值
的元素,直到只剩下了一个元素为止,它就是期望中的哈夫曼树了。(这个程序有一点怪,
但不是真得很复杂。如果你发现你自己设计了一个复杂的程序,那么你可以几乎是一定有事做
错了。你能很好的利用的事实是我们正在使用的有序的集合的表示法)


练习2.70
如下的8字符的字母与相关的使用频率被用来设计的上世纪五十年代的摇滚歌曲的歌词的高效的编码。
(一个字母的字符不需要成为一个独立的字母)

A       2     NA    16
BOOM    1     SHA    3
GET     2     YIP    9
JOB     2     WAH    1

使用练习2.69中的generate-huffman-tree程序,生成一个相对应的哈夫曼树,
使用练习2.68中的encode程序,编码如下的消息。

Get a job

Sha na na na na na na na na

Get a job

Sha na na na na na na na na

Wah yip yip yip yip yip yip yip yip yip

Sha boom

这次编码需要多少个比特位? 对于这8字符的字母,如果我们使用定长的编码,编码这首歌
需要的最小的比特位的是什么?

定长需要34*3


练习2.71
假定,我们一个n字符的字母的哈夫曼编码树,相对的频率是1,2,4,。。。2^(n-1)
草绘一个n=5的树,对于n=10,在这样的树中,编码最频繁的需要多少位?频率最小的需要多少位?

1位,9位

练习2.72

考虑下,你在练习2.68中设计的编码程序,编码一个字符,它的时间复杂度是多少?确定在任何一个结点中
搜索字符的列表需要的步骤数量。 考虑通用性,回答这个问题是困难的。考虑一下特例的情况,
例如在练习2.71中的描述的相应的频率。给出一个时间复杂度,作为一个n的函数,衡量的是编码最常用和不常
用的字符的步骤的数量。

猜你喜欢

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