《数据结构与算法》(十一)- 树、森林与二叉树的转换及哈夫曼树详解
前言
部分内容摘自程杰的《大话数据结构》
1. 树、森林与二叉树之间的转换
1.1 树转换为二叉树
树如下图所示。
将树转换为二叉树的步骤如下:
- 加线。在所有兄弟结点之间加一条连线,如下图所示。
- 去线。对树中每个结点,只保留它与第一个孩子结点的连线,删除它与其他孩子结点之间的连线。
- 层次调整。以树的根结点为轴心,将整棵树顺时针旋转一定的角度, 使之结构层次分明。注意第一个孩子是二叉树结点的左孩子,兄弟转换过来的孩子是结点的右孩子,如下图所示。
例如上面几幅图,一棵树经过三个步骤转换为一棵二叉树。初学者容易犯的错误就是在层次调整时,弄错了左右孩子的关系。比如图中F
、G
本都是树结点B
的孩子,是结点E
的兄弟,因此转换后,F
就是二叉树结点E
的右孩子,G
是二叉树结点F
的右孩子。
1.2. 森林转换为二叉树
森林是由若干棵树组成的,所以完全可以理解为,森林中的每一棵树都是兄弟,可以按照兄弟的处理办法来操作。步骤如下:
例如下图,将森林的三棵树转化为一棵二叉树。
转化步骤如下:
- 把每个树转换为二叉树,如下图所示。
- 第一棵二叉树不动,从第二棵二叉树开始,依次把后一棵二叉树的根结点作为前一棵二又树的根结点的右孩子,用线连接起来。当所有的二叉树连接起来后就得到了由森林转换来的二叉树,如下图所示。
1.3. 二叉树转换为树
二叉树转换为树是树转换为二叉树的逆过程,也就是反过来做而已。如下图所示。
转化为树的步骤如下:
- 加线。若某结点的左孩子结点存在,则将这个左孩子的右孩子结点、右孩子的右孩子结点、右孩子的右孩子的右孩子结······总之就是左孩子的
n
个右孩子结点都作为此结点的孩子。将该结点与这些右孩子结点用线连接起来。
- 去线。删除原二叉树中所有结点与其右孩子结点的连线。
- 层次调整。使之结构层次分明。
1.4 二叉树转换为森林
判断一棵二叉树能够转换成一棵树还是森林, 标准很简单,那就是只要看这棵二叉树的根结点有没有右孩子,有就是森林,没有就是一棵树。如下图所示:
那么如果是转换成森林,步骤如下:
- 从根结点开始,若右孩子存在,则把与右孩子结点的连线删除,再查看分离后的二叉树,若右孩子存在,则连线删除······,直到所有右孩子连线都删除为止,得到分离的二叉树。
- 再将每棵分离后的二叉树转换为树即可。
1.5 树与森林的遍历
最后我们再谈一谈关于树和森林的遍历问题。
树的遍历分为两种方式:
- 一种是先根遍历树,即先访问树的根结点,然后依次先根遍历根的每棵子树。
- 另一种是后根遍历,即先依次后根遍历每棵子树,然后再访问根结点。比如下图的树,它的先根遍历序列为 ABEFCDG,后根遍历序列为 EFBCGDA。
森林的遍历也分为两种方式:
- 前序遍历:先访问森林中第一棵树的根结点, 然后再依次先根遍历根的每棵子树,再依次用同样方式遍历除去第一棵树的剩余树构成的森林。比如下图中三棵树的森林,前序遍历序列的结果就是 ABCDEFGHJI。
- 后序遍历:先访问森林中第一棵树,后根遍历的方式遍历每棵子树,然后再访问根结点,再依次同样方式遍历除去第一棵树的剩余树构成的森林。比如上图中三棵树的森林,后序遍历序列的结果就是 BCDAFEHIG。
可如果我们对下图的二叉树进行分析就会发现,森林的前序遍历和二叉树的前序遍历结果相同,森林的后序遍历和二叉树的中序遍历结果相同。
这也就告诉我们,当以二叉链表作树的存储结构时,树的先根遍历和后根遍历完全可以借用二叉树的前序遍历和中序遍历的算法来实现。这其实也就证实,我们找到了对树和森林这种复杂问题的简单解决办法。
2. 哈夫曼树及其应用
2.1 哈夫曼树
现在我们都是讲究效率的社会,什么都要求速度,在不能出错的情况下,做任何事情都讲究越快越好。在计算机和互联网技术中,文本压缩就是一个非常重要的技术。玩电脑的人几乎都会应用压缩和解压缩软件来处理文档。因为它除了可以减少文档在磁盘上的空间外,还有重要的一点,就是我们可以在网络上以压缩的形式传输大量数据,使得保存和传递都更加高效。
那么压缩而不出错是如何做到的呢?简单说,就是把我们要压缩的文本进行重新编码,以减少不必要的空间。尽管现在最新技术在编码上已经很好很强大,但这一切都来自于曾经的技术积累,我们今天就来介绍一下最基本的压缩编码方法——赫夫曼编码。
在介绍赫夫曼编码前,我们必须得介绍赫夫曼树,而介绍赫夫曼树,我们不得不提这样一个人,美国数学家赫夫曼(David Huffman),也有的翻译为哈夫曼。他在 1952 年发明了赫夫曼编码,为了纪念他的成就,于是就把他在编码中用到的特殊的二叉树称之为赫夫曼树,他的编码方法称为赫夫曼编码。也就是说,我们现在介绍的知识全都来自于近 60 年前这位伟大科学家的研究成果,而我们平时所用的压缩和解压缩技术也都是基于赫夫曼的研究之上发展而来,我们应该要记住他。
什么叫做赫夫曼树呢?我们先来看一个例子。
过去的小学、中学-般考试都是用百分制来表示学科成绩的。这带来了-一个弊端,就是很容易让学生、家长,甚至老师自己都以分取人,让分数代表了一切。有时想想也对,90 分和 95 分也许就只是一道题目对错的差距,但却让两个孩子可能受到完全不同的待遇,这并不公平。于是在如今提倡素质教育的背景下,我们很多的学科,特别是小学的学科成绩都改作了优秀、良好、中等、及格和不及格这样模糊的词语,不再通报具体的分数。
不过对于老师来讲,他在对试卷评分的时候,显然不能凭感觉给优良或及格不及格等成绩,因此一般都还是按照百分制算出每个学生的成绩后,再根据统一的标准换算得出五级分制的成绩。比如下面的代码就实现了这样的转换。
1 | if (a<60) |
下图粗略看没什么问题,可是通常都认为,一张好的考卷应该是让学生成绩大部分处于中等或良好的范围,优秀和不及格都应该较少才对。而上面这样的程序,就使得所有的成绩都需要先判断是否及格,再逐级而上得到结果。输入量很大的时候,其实算法是有效率问题的。
如果在实际的学习生活中,学生的成绩在5个等级上的分布规律如下表所示。
分数 | 0~59 | 60~69 | 70~79 | 80~89 | 90~100 |
---|---|---|---|---|---|
所占比例 | 5% | 15% | 40% | 30% | 10% |
那么 70 分以上大约占总数 80% 的成绩都需要经过 3 次以上的判断才可以得到结果,这显然不合理。
有没有好一些的办法,仔细观察发现,中等成绩(70~79 分之间)比例最高,其次是良好成绩,不及格的所占比例最少。我们把上图的二叉树重新进行分配。改成如下图的做法试试看。
从图中感觉,应该效率要高一些了,到底高多少呢。这样的二叉树又是如何设计出来的呢?我们来看看赫夫曼是如何说的吧。
2.2 哈夫曼树的定义与原理
我们先把这两棵二叉树简化成叶子结点带权的二叉树(树结点间的边相关的数叫做权 Weigth),如下图所示。其中A
表示不及格、B
表示及格、C
表示中等、D
表示良好、E
表示优秀。每个叶子的分支线上的数字就是刚才我们提到的五级分制的成绩所占比例数。
赫夫曼说,从树中一个结点到另一个结点之间的分支构成两个结点之间的路径,路径上的分支数目称做路径长度。上图中的二叉树a
中,根结点到结点D
的路径长度就为 4,二叉树b
中根结点到结点D
的路径长度为 2。树的路径长度就是从树根到每一结点的路径长度之和。二叉树a
的树路径长度就为1+1+2+2+3+3+4+4=20
。二叉树b
的树路径长度就为1+2+3+3+2+1+2+2=16
。
如果考虑到带权的结点,结点的带权的路径长度为从该结点到树根之间的路径长度与结点上权的乘积。树的带权路径长度为树中所有叶子结点的带权路径长度之和。假设有n
个权值 {w,w,···,w},构造一棵有n
个叶子结点的二叉树,每个叶子结点带权 W,每个叶子的路径长度为 l,我们通常记作,则其中带权路径长度WPL最小的二叉树称做赫夫曼树。也有不少书中也称为最优二叉树。
有了赫夫曼对带权路径长度的定义,我们来计算一下上图中这两棵树的WPL
值。
二叉树a
的WPL=5×1+15×2+40×3+30×4+10×4=315
注意:这里 5 是 A 结点的权,1 是 A 结点的路径长度,其他同理。
二叉树b
的WPL=5×3+15×3+40×2+30×2+10×2=220
这样的结果意味着什么呢?如果我们现在有 10000 个学生的百分制成绩需要计算五级分制成绩,用二叉树a
的判断方法,需要做 31500 次比较,而二叉树b
的判断方法,只需要 22000 次比较,差不多少了三分之一一量, 在性能上提高不是一点点。
那么现在的问题就是,上图的二叉树b
这样的树是如何构造出来的,这样的二叉树是不是就是最优的赫夫曼树呢?别急,赫夫曼大叔给了我们解决的办法。
- 先把有权值的叶子结点按照从小到大的顺序排列成一个有序序列,即:
A5
,E10
,B15
,D30
,C40
。 - 取头两个最小权值的结点作为一个新节点 N 的两个子结点,注意相对较小的是左孩子,这里就是
A
为 N 的左孩子,E
为 N 的右孩子,如下图所示。新结点的权值为两个叶子权值的和5+10=15
。
- 将 N 替换
A
与E
,插入有序序列中,保持从小到大排列。即: N,B15
,D30
,C40
。 - 重复步骤 2。将 N 与
B
作为一个新节点 N 的两个子结点。如下图所示。N 的权值=15+15=30。
- 将 N 替换 N 与
B
,插入有序序列中,保持从小到大排列。即:N,D30
,C40
。 - 重复步骤 2。将 N 与
D
作为一个新节点 N 的两个子结点。如下图所示。N 的权值=30+30=60。
- 将 N 替换 N 与
D
,插入有序序列中,保持从小到大排列。即:C40
,N 。 - 重复步骤 2。将
C
与 N 作为一个新节点T
的两个子结点,如下图所示。由于T
即是根结点,完成赫夫曼树的构造。
此时的上图二叉树的带权路径长度WPL=40× 1+30×2+15×3+10×4+5×4=205
。与本章开头的二叉树b
的WPL=5×3+15×3+40×2+30×2+10×2=220
相比,还少了 15。显然此时构造出来的二叉树才是最优的赫夫曼树。
不过现实总是比理想要复杂得多,上图虽然是赫夫曼树,但由于每次判断都要两次比较(如根结点就是a<80 && a>=70
,两次比较才能得到y
或n
的结果),所以总体性能上,反而不如二叉树b
的二叉树性能高。当然这并不是我们要讨论的重点了。
通过刚才的步骤,我们可以得出构造赫夫曼树的赫夫曼算法描述。
- 根据给定的
n
个权值 {w,w,···,w} 构成n
棵二叉树的集合 F={T,T,···,T},其中每棵二叉树 T 中只有一个带权为 w 根结点,其左右子树均为空。 - 在
F
中选取两棵根结点的权值最小的树作为左右子树构造一棵新的二叉树,且置新的二叉树的根结点的权值为其左右子树上根结点的权值之和。 - 在
F
中删除这两棵树,同时将新得到的二叉树加入F
中。 - 重复 2 和 3 步骤,直到
F
只含一棵树为止。这棵树便是赫夫曼树。
2.3 哈夫曼编码
当然,赫夫曼研究这种最优树的目的不是为了我们可以转化一下成绩。 他的更大目的是为了解决当年远距离通信(主要是电报)的数据传输的最优化问题。
比如我们有一段文字内容为 “BADCADFEED” 要网络传输给别人,显然用二进制的数字( 0 和 1 )来表示是很自然的想法。我们现在这段文字只有六个字母 A、B、C、D、E、F,那么我们可以用相应的二进制数据表示,如下表所示。
字母 | A | B | C | D | E | F |
---|---|---|---|---|---|---|
二进制字符 | 000 | 001 | 010 | 011 | 100 | 101 |
这样真正传输的数据就是编码后的 “001000011010000011101100100011”,对方接收时可以按照三位一分来译码。 如果一篇文章很长,这样的进制串也将非常的可怕。而且事实上,不管是英文、中文或是其他语言,字母或汉字的出现频率是不相同的,比如英语中的几个元音字母 “a e i o u”,中文中的 “的 了 有 在" 等汉字都是频率极高。
假设六个字母的频率为 A 27,B 8,C 15,D 15,E 30,F 5,合起来正好是 100%。那就意味着,我们完全可以重新按照赫夫曼树来规划它们。
左下图为构造赫夫曼树的过程的权值显示。右下图为将权值左分支改为 0,右分支改为 1 后的赫夫曼树。
此时,我们对这六个字母用其从树根到叶子所经过路径的 0 或 1 来编码,可以得到如下表所示这样的定义。
字母 | A | B | C | D | E | F |
---|---|---|---|---|---|---|
二进制字符 | 01 | 1001 | 101 | 00 | 11 | 1000 |
我们将文字内容为 “BADCADFEED” 再次编码,对比可以看到结果串变小。
- 原编码二进制串:001000011010000011101100100011(共 30个字符)
- 新编码二进制串:1001010010101001000111100(共25个字符)
也就是说,我们的数据被压缩了,节约了大约 17% 的存储或传输成本。随着字符的增加和多字符权重的不同,这种压缩会更加显出其优势。
当我们接收到 1001010010101001000111100 这样压缩过的新编码时,我们应该如何把它解码出来呢?
编码中非 0 即 1,长短不等的话其实是很容易混淆的,所以若要设计长短不等的编码,则必须是任一字符的编码都不是另一个字符的编码的前缀, 这种编码称做前缀编码。
你仔细观察就会发现,上表中的编码就不存在容易与 1001、1000 混淆的 “10” 和 “100" 编码。
可仅仅是这样不足以让我们去方便地解码的,因此在解码时,还是要用到赫夫曼树,即发送方和接收方必须要约定好同样的赫夫曼编码规则。
当我们接收到 1001010010101001000111100 时,由约定好的赫夫曼树可知,1001 得到第一个字母是B
,接下来 01 意味着第二个字符是A
,如下图所示,其余的也相应的可以得到,从而成功解码。
一般地,设需要编码的字符集为 {d,d,···,d},各个字符在电文中出现的次数或频率集合为 {w,w,···,w},以 d,d,···,d 作为叶子结点,以 w,w,···,w 作为相应叶子结点的权值来构造一棵赫夫曼树。规定赫夫曼树的左分支代表 0,右分支代表 1,则从根结点到叶子结点所经过的路径分支组成的 0 和 1 的序列便为该结点对应字符的编码,这就是赫夫曼编码。
3. 总结
树、森林看似复杂,其实它们都可以转化为简单的二叉树来处理,我们提供了树、森林与二叉树的互相转换的办法,这样就使得面对树和森林的数据结构时,编码实现成为了可能。
最后我们提到了关于二叉树的一个应用,哈夫曼树和哈夫曼编码,对于带权路径的二叉树做了详尽地讲述,能让我们初步理解数据压缩的原理,并明白其是如何做到无损编码和无错解码的。