2019独角兽企业重金招聘Python工程师标准>>>
6.1 开场白
2010年一部电影创造了奇迹,它是全球第一部票房到达27亿美元。总票房历史排名第一的影片,那就是詹姆斯.卡梅隆执导的电影《阿凡达》(Avatar)。
电影里提到了一棵高达900英尺(约274米)的参天巨树,是那个潘多拉星球的那威人的家园,让人印象非常深刻,可惜那只是导演的梦想,地球上不存在这样的物种。
无论多高多大的树,那也是从小到大。由根到叶、一点点成长起来的。俗话说十年树木、百年树人,可一棵大树又何止是十年这样容易--哈哈,说到哪里去了,我们现在不是在上生物课,而是要讲一种新的数据结构--树。
6.2 树的定义
之前我们一直在谈的是一对一的线性结构,可现实中,还有很多一对多的情况需要处理,所以我们需要研究这种一对多的数据结构--"树",考虑它的各种特性,来解决我们在编程中碰到的相关问题。
树的定义其实就是我们在讲解栈时提到的递归的方法。也就是在树的定义之中还用到了树的概念,这是一种比较新的定义方法。图6-2-2的子树T1和子树T2就是根结点A的子树。当然,D、G、H、I组成的树又是B为结点的子树,E、J组成的树是C为结点的子树。
对于树的定义还需要强调两点:
- n>0时根结点是唯一的,不可能存在多个根结点,别和现实中的大树混在一起,现实中的树有很多根须,那是真实的树,数据结构中的树只能有一个根结点。
- m>0时,子树的个数没有限制,但它们一定是互不相交的。像图6-2-3中的两个结构就不符合树的定义。因为它们都有相交的子树。
6.2.1 结点分类
树的结点包含一个数据元素及若干个指向其子树的分支。**结点拥有的子树数称为结点的度(Degree)。度为0的结点称为叶结点(Leaf)或终端结点;度不为0的结点称为非终端结点或分支结点。除根结点之外,分支结点也称为内部结点。树的度是树内各结点的度的最大值。**如图6-2-4所示,因为这棵树结点的度的最大值是结点D的度,为3,所以树的度也为3。
6.2.2 结点间关系
结点的子树的根称为该结点的孩子(Child)。相应地,该结点称为孩子的双亲(Parent)。嗯,为什么不是父或母,叫双亲呢?呵呵,对于结点来说其父母同体,唯一的一个,所以只能把它称为双亲了。同一个双亲的孩子之间互称兄弟(Sibling)。结点的祖先是从根到该结点所经分支上的所有结点。所以对于H来说,D、B、A都是它的祖先。反之,以某结点为跟的子树的任一结点都称为该结点的子孙。B的子孙有D、G、H、I,如图6-2-5所示。
6.2.3 树的其他相关概念
结点的层次(Level)从根开始定义起,根为第一层,根的孩子为第二层。若某结点在第l层,则其子树的根就在第l+1层。其双亲在同一层的结点互为堂兄弟。显然图6-2-6中的D、E、F是堂兄弟,而G、H、I、J也是。树中结点的最大层次称为树的深度(Depth)或高度,当前树的深度为4。
如果将树中结点的各子树看成从左到右是有次序的,不能互换的,则称该树为有序树,否则称为无序树。
森林(Forest)是m(m>=0)棵互不相交的树的集合。对树中每个结点而言,其子树的集合即为森林。对于图6-2-1中的树而言,图6-2-2中的两棵子树其实就可以理解为森林。
对比线性表与树的结构,它们有很大的不同,如图6-2-7所示。
6.3 树的抽象数据类型
相对于线性结构,树的操作就完全不同了,这里我们给出一些基本和常用操作。
6.4 树的存储结构
说到存储结构,就会想到我们前面章节讲过的顺序存储和链式存储两种结构。
先来看看顺序存储结构,用一段地址连续的存储单元依次存储线性表的数据元素。这对于线性表来说是很自然的,对于树这样一对多的结构呢?
树中某个结点的孩子可以有多个,这就意味着,无论按何种顺序将树中所有结点存储到数组中,结点的存储位置都无法直接反映逻辑关系,你想想看,数据元素挨个的存储,谁是谁的双亲,谁是谁的孩子呢?简单的顺序存储结构是不能满足树的实现要求的。
不过充分利用顺序存储和链式存储结构的特点,完全可以实现对树的存储结构的表示。我们这里要介绍三种不同的表示法:双亲表示法、孩子表示法、孩子兄弟表示法。
6.4.1 双亲表示法
我们人可能因为种种原因,没有孩子,但无论是谁都不可能从石头蹦出来的,孙悟空显然不能算是人,所以是人一定会有父母。树这种结构也不例外,除了根结点外,其余每个结点,它不一定有孩子,但是一定有且仅有一个双亲。
我们假设以一组连续空间存储树的结点,同时在每个结点中,附设一个指示器指示其双亲结点到链表中的位置。也就是说,每个结点除了知道自己是谁以外,还知道它的双亲在哪里。它的结点结构为表6-4-1所示。
其中data是数据域,存储结点的数据信息。而parent是指针域,存储该结点的双亲在数组中的下标。
有了这样的结构定义,我们就可以来实现双亲表示法了。由于根结点是没有双亲的,所以我们约定根结点的位置域设置为-1,这也就意味着,我们所有的结点都存有它双亲的位置。如图6-4-1中的树结构和表6-4-2中的树双亲表示所示。
这样的存储结构,我们可以根据结点的parent指针很容易找到它的双亲结点,所用的时间复杂度为O(1),直到parent为-1时,表示找到了树结点的根。可如果我们要知道结点的孩子是什么,对不起,请遍历整个结构才行。
这真是麻烦,能不能改进一下呢?
当然可以。我们增加一个结点最左边孩子的域,不妨叫它长子域,这样就可以很容易得到结点的孩子。如果没有孩子的结点,这个长子域就设置为-1,如表6-4-3所示。
对于有0个或1个孩子结点来说,这样的结构是解决了要找结点孩子的问题了。甚至是有2个孩子,知道了长子是谁,另一个当然就是次子了。
另外一个问题场景,我们很关注各兄弟之间的关系,双亲表示法无法体现这样的关系,那我们怎么办?嗯,可以增加一个右兄弟域来体现兄弟关系,也就是说,每一个结点如果它存在右兄弟,则记录下右兄弟的下标。同样的,如果右兄弟不存在,则赋值为-1,如表6-4-4所示。
但如果结点的孩子很多,超过了2个。我们又关注结点的双亲、又关注结点的孩子、还关注结点的兄弟,而且对时间遍历要求还比较高,那么我们还可以把次结构扩展为有双亲域、长子域、再有右兄弟域。存储结构的设计是一个非常灵活的过程。一个存储结构设计得是否合理,取决于基于该存储结构的运算是否适合,是否方便,时间复杂度好不好等。注意也不是越多越好,有需要时再设计相应的结构。就像再好听的音乐,不停 反复听上千遍也会腻味,再好看的电影,一段时间反复看上百遍,也会无趣,你们说是吧。
6.4.2 孩子表示法
换一种完全不同的考虑方法。由于树中每个结点可能有多棵子树,可以考虑用多重链表,即每个结点有多个指针域,其中每个指针指向一棵子树的根结点,我们把这种方法叫做多重链表表示法。不过,树的每个结点的度,也就是它的孩子个数是不同的。所以可以设计两种方案来解决。
- 方案一
一种是指针域的个数就等于树的度,复习一下,树的度是树各个结点度的最大值。其结构如表6-4-5所示。
其中data是数据域。child1到childd是指针域,用来指向该结点的孩子结点。
对于图6-4-1的树来说,树的度是3,所以我们的指针域的个数是3,这种方法实现如图6-4-2所示。
这种方法对于树中各结点的度相差很大时显然是很浪费空间的,因为有很多的结点,它的指针域都是空的。不过如果树的各结点度相差很小时,那就意味着开辟的空间被充分利用了,这时存储结构的缺点反而变成了优点。
既然很多指针域都可能为空,为什么不按需分配空间呢。于是我们有了第二种方案。
- 方案二
第二种方案每个结点指针域的个数等于该结点的度,我们专门取一个位置来存储结点指针域的个数,其结构如表6-4-6所示。
其中data为数据域,degree为度域,也就是存储该结点的孩子结点的个数,child1到childd为指针域,指向该结点的各个孩子的结点。
这种方式克服了浪费空间的缺点,对空间利用率是很高了,但是由于各个结点的链表是不相同的结构,加上要维护结点的度的数值,在运算上就会带来时间上的损耗。
能否有更好的方法,既可以减少空指针的浪费又能使结点结构相同。
仔细观察,我们为了要遍历整棵树,把每个结点放到一个顺序存储结构的数组中是合理的,但每个结点的孩子有多少是不确定的,所以我们再对每个结点的孩子建立一个单链表体现它们的关系。
这就是我们要讲的孩子表示法。具体办法是,把每个结点的孩子结点排列起来,以单链表作存储结构,则n个结点由n个孩子链表,如果是叶子结点则此单链表为空。然后n个头指针又组成一个线性表,采用顺序存储结构,存放进一个一维数组中,如图6-4-4所示。
为此,设计两种结点结构,一个是孩子链表的孩子结点,如表6-4-7所示。
其中child是数据域,用来存储某个结点在表头数组中的下标。next是指针域,用来存储指向某结点的下一个孩子结点的指针。
其中data是数据域,存储某结点的数据信息。firstchild是头指针域,存储该结点的孩子链表的头指针。
这样的结构对于我们要查找某个结点的某个孩子,或者找某个结点的兄弟,只需要查找这个结点的孩子单链表即可。对于遍历整棵树也是很方便的,对头结点的数组循环即可。
但是,这也存在着问题,我如何知道某个结点的双亲是谁呢?比较麻烦,需要整棵树遍历才行,难道就不可以把双亲表示法和孩子表示法综合一下吗?当然是可以。如图6-4-5所示。
我们把这种方法称为双亲孩子表示法,应该算是孩子表示法的改进。至于这个表示法的具体结构定义,这里就略过,留给同学们自己去设计了。
6.4.3 孩子兄弟表示法
刚才我们分别从双亲的角度和从孩子的角度研究树的存储结构,如果我们从树结点的兄弟的角度又会如何呢?当然,对于树这样的层级结构来说,只研究结点的兄弟是不行的,我们观察后发现,任意一棵树,它的结点的第一个孩子如果存在就是唯一的,它的右兄弟如果存在也是唯一的。因此,我们设置两个指针,分别指向该结点的第一个孩子和此结点的右兄弟。
其中data是数据域,firstchild为指针域,存储该结点的第一个孩子结点的存储地址,rightsib是指针域,存储该结点的右兄弟结点的存储地址。
对于图6-4-1的树来说,这种方法实现的示意图如图6-4-6所示。
这种表示法,给查找某个结点的某个孩子带来了方便,只需要通过fistchild找到此结点的长子,然后再通过长子结点的rightsib找到它的二弟,直到找到具体的孩子。当然,如果想找某个结点的双亲,这个表示法也是有缺陷的,那怎么办呢?
呵呵,对,如果真的有必要,完全可以再增加一个parant指针域来解决快速查找双亲的问题,这里就不再细谈了。
其实这个表示法的最大好处是它把一棵复杂的树变成了一棵二叉树。我们把图6-4-6变变形就成了图6-4-7这个样子。
这样就可以充分利用二叉树的特性和算法来处理这棵树了。嗯?有人问,二叉树是什么?哈哈,别急,这正是我接下来要重点讲的内容。
6.5 二叉树的定义
现在我们来做个游戏,我在纸上已经写好了一个100以内的正整数数字,请大家想办法猜出我写的是哪一个?注意你们猜的数字不能超过7个,我的回答只会告诉你是"大了"或"小了"。
这个游戏在一些电视节目中,猜测一些商品的定价时常会使用。我看到过有些人是一点一点的数字累加的,比如5,10,15,20这样猜,这样的猜数策略太低级了,显然是没有学过数据结构和算法的人才做得出的事。
其实这个一个很经典的折半查找算法。如果我们用图6-5-1(下三层省略)的办法,就一定能在7次以内,猜出结果来。
由于是100以内的正整数,所以我们先猜50(100的一半),被告知"大了",于是再猜25(50的一半),被告知"小了",再猜37(25与50的中间数),小了,于是猜43,大了,40大了,38小了,39完全正确。过程如表6-5-1所示。
我们发现,如果用这种方式进行查找,效率搞得不是一点点。对于折半查找的详细讲解,我们后面章节再说。不过对于这种在某个阶段都是两种结果的情形,比如开和关,0和1,真和假,上和下,对与错,正面与反面等,都适合用树状结构来建模,而这种树是一种很特殊的树状结构,叫做二叉树。
图6-5-2就是一棵二叉树。而图6-2-1的树,因为D结点有三个子树,所以它不是二叉树。
6.5.1 二叉树特点
二叉树的特点有:
- 每个结点最多有两棵子树,所以二叉树中不存在度大于2的结点。注意不是只有两棵树,而是最多有。没有子树或者有一棵子树都是可以的。
- 左子树和右子树是有顺序的,次序不能任意颠倒。就像人是双手、双脚,但显然左手,左脚,右脚是不一样的,右手戴左手套、右脚穿左鞋都会极其别扭和难受。
- 即使树中某结点只有一棵子树,也要区分它是左子树还是右子树。图6-5-3中,树1和树2是同一棵树,但它们却是不同的二叉树。就好像你一不小心,摔伤了手,伤的是左手还是右手,对你的生活影响度是完全不同的。
二叉树具有五种基本状态:
- 空二叉树
- 只有一个根结点
- 根节点只有左子树
- 根结点只有右子树
- 根节点既有左子树又有右子树
应该说这五种形态还是比较好理解的,那我现在问大家,如果是有三个结点的树,有几种形态?如果是有三个结点的二叉树,考虑一下,又有几种形态?
若只从形态上考虑,三个结点的树只有两种情况,那就是图6-5-4中有两层的树1和有三层的后四种的任意一种,但对于二叉树来说,由于要区分左右,所以就演变成五种形态,树2,树3,树4和树5分别代表不同的二叉树。
6.5.2 特殊二叉树
我们再来介绍一些特殊的二叉树。这些树可能暂时你不能理解它有什么用处,但先了解一下,以后会提到它们的实际用途。
- 斜树
顾名思义,斜树一定要是斜的,但是往哪斜还是有讲究。所有的结点都只有左子树的二叉树叫左斜树,所有结点都只有右子树的二叉树叫右斜树。这两者统称为斜树。图6-5-4中的树2就是左斜树,树5就是右斜树。斜树有很明显的特点,就是每一层都只有一个结点,结点的个数与二叉树的深度相同。
有人会想,这也能叫树呀,与我们的线性表结构不是一样吗。对的,其实线性表结构就可以理解为是树的一种极其特殊的表现形式。
- 满二叉树
苏东坡曾有词云:"人有悲欢离合,月有阴晴圆缺,此时古难全"。意思就是完美是理想,不完美才是人生。我们通常举的例子也都是左高右低、参差不齐的二叉树。那是否存在完美的二叉树呢?
嗯,有同学已经在空中手指比划起来。对的,完美的二叉树是存在的。
在一棵二叉树中,如果所有分支结点都存在左子树和右子树。并且所有叶子都在同一层上,这样的二叉树称为满二叉树。
单是每个结点都存在左右子树,不能算是满二叉树,还必须要所有的叶子都在同一层上,这就做到了整棵树的平衡。因此,满二叉树的特点有:
-
叶子只能出现在最下一层。出现在其他层就不可能达到平衡。
-
非叶子结点的度一定是2.否则就是"缺胳膊少腿"了。
-
在同样深度的二叉树中,满二叉树的结点个数最多,叶子数最多。
-
完全二叉树
对一棵具有n个结点的二叉树按层序编号,如果编号为i(1<=i<=n)的结点与同样深度的满二叉树中编号为i的结点在二叉树中位置完全相同,则这课二叉树称为完全二叉树,如图6-5-6所示。
这是一种有些理解难度的特殊二叉树。
首先从字面上要区分,"完全"和"满"的差异,满二叉树一定是一棵完全二叉树,但完全二叉树不一定是满的。
其次,完全二叉树的所有结点与同样深度的满二叉树,它们按层序编号相同的结点,是一一对应的。这里有个关键词是按层序编号,像图6-5-7中的树1,因为5结点没有左子树,却有右子树,那就是使得按层序编号的第10个编号空挡了。同样道理,图6-5-7中的树3又是因为5编号下没有子树造成第10和11位置空挡。只有图6-5-6中的树,尽管它不是满二叉树,但是编号是连续的,所以它是完全二叉树。
从这里我也可以得出一些完全二叉树的特点:
- 叶子结点只能出现在最下两层。
- 最下层的叶子一定集中在左部连续位置。
- 倒数二层,若有叶子结点,一定都在右部连续位置。
- 如果结点度为1,则该结点只有左孩子,即不存在只有右子树的情况。
- 同样结点数的二叉树,完全二叉树的深度最小。
从上面的例子。也给了我们一个判断某二叉树是否完全二叉树的办法,那就是看着树的示意图,心中默默给每个结点按照满二叉树的结构逐层顺序编号,如果编号出现空挡,就说明不是完全二叉树,否则就是。
6.6 二叉树的性质
二叉树有一些需要理解并记住的特性,以便于我们更好地使用它。
6.6.1 二叉树性质1
性质1:在二叉树的第i层上至多有2(i-1)个结点(i>=1)。 这个性质很好记忆,观察一下图6-5-5.
- 第一层是根结点,只有一个,所以2(1-1)=2(0)=1.
- 第二层有两个,2(2-1)=2(1)=2。
- 第三层有四个,2(3-1)=2(2)=4。
- 第四层有八个,2(4-1)=2(3)=8。
通过数据归纳法的论证,可以很容易得出在二叉树的第i层上至多有2(i-1)个结点(i>=1)的结论。
6.6.2 二叉树性质2
性质2:深度为k的二叉树至多有2(k)-1个结点(k>=1)。
注意这里一定要看清楚,是2(k)后再减去1,而不是2(k-1)。以前很多同学不能完全理解,这样去记忆,就容易把性质2与性质1给弄混淆了。
深度为k意思就是有k层的二叉树,我们先来看看简单的。
- 如果有一层,至多1=2(0)-1个结点。
- 如果有两层,至多1+2=3=2(2)-1个结点。
- 如果有三层,至多1+2+4=7=2(3)-1个结点。
- 如果有四层,至多1+2+4+8=15=2(4)-1个结点。
通过数据归纳法的论证,可以得出,如果有k层,此二叉树至多有2(k)-1个结点。
6.6.3 二叉树性质3
性质3:对任何一棵二叉树T。如果其终端结点数为n0,度为2的结点数为n2,则n0=n2+1。
终端结点数其实就是叶子结点数,而一棵二叉树,除了叶子结点外,剩下的就是度为1或2的结点数了,我们设n1为度是1的结点数。则树T结点 总数n=n0+n1+n2。
比如图6-6-1的例子,结点总数为10,它是有A、B、C、D等度为2结点,F、G、H、I、J等度为0的叶子结点和E这个度为1的结点组成。总和为4+1+5=10。
我们换个角度,再数一数它的连接线数,由于根结点只有分支出去,没有分支进入,所以分支总数为结点总数减去1.图6-6-1就是9个分支。对于A、B、C、D结点来说,它们都有两个分支线出去,而E结点只有一个分支线出去。所以总分支线为4x2+1x1=9。
用代数表达就是分支线总数=n-1=n1+2n2。因为刚才我们有等式n=n0+n1+n2,所以可推导出n0+n1+n2-1=n1+2n2。结论就是n0=n2+1。
6.6.4 二叉树性质4
性质4:具有n个结点的完全二叉树的深度为[log2n]+1([x]表示不大于x的最大整数)。
由满二叉树的定义我们可以知道,深度为k的满二叉树的结点数n一定是2(k)-1。因为这个最多的结点个数。那么对于n=2(k)-1倒推得到满二叉树的度数为k=log2(n+1),比如结点数为15的满二叉树,度为4.
完全二叉树我们前面已经提到,它是一棵具有n个结点的二叉树,若按层序编号后其编号与同样深度的满二叉树中编号结点在二叉树位置完全相同,那它就是完全二叉树。也就是说,它的叶子结点只会出现在最下面的两层。
它的结点数一定少于等于同样度数的满二叉树的结点数2(k)-1,但一定多于2(k-1)-1。即满足2(k-1)-1 性质5:如果对一棵有n个结点的完全二叉树(其深度为[log2n]+1)的结点按层序编号(从第一层到第[log2n]+1层,每层从左到右),对任一结点i(1<=i<=n)有: 我们以图6-6-2为例,来理解这个性质。这是一个完全二叉树,度为4,结点总数是10。 前面我们已经谈到了树的存储结构,并且谈到了顺序存储对树这种一对多的关系结构实现起来是比较困难的。但是二叉树是一种特殊的树,由于它的特殊性,使得用顺序存储结构也可以实现。 二叉树的顺序存储结构就是用一维数组存储二叉树中的结点,并且结点的存储位置,也就是数组的下标要能体现结点之间的逻辑关系,比如双亲与孩子的关系,左右兄弟的关系等。 先来看看完全二叉树的顺序存储,一棵完全二叉树如图6-7-1所示。 将这棵二叉树存入到数组中,相应的下标对应其同样的位置,如图6-7-2所示。 这下看出完全二叉树的优越性来了吧。由于它定义的严格,所以用顺序结构也可以表现出二叉树的结构来。 当然对于一般的二叉树,尽管层序编号不能反映逻辑关系,但是可以将其按完全二叉树编号,只不过,把不存在的结点设置为"^"而已。如图6-7-3,注意浅色结点表示不存在。 考虑一种极端的情况,一棵深度为k的右斜树,它只有k个结点,却需要分配2(k)-1个存储单元空间,这显然是对存储空间的浪费,例如图6-7-4所示。所以,顺序存储结构一般只用于完全二叉树。 既然顺序存储适用性不强,我们就要考虑链式存储结构。二叉树每个结点最多有两个孩子,所以为它设计一个数据域和两个指针域是比较自然的想法,我们称这样的链表叫做二叉链表。结点结构图如表6-7-1所示。 其中data是数据域,lchild和rchild都是指针域,分别存放指向左孩子和右孩子的指针。 就如同树的存储结构中讨论的一样,如果有需要,还可以再增加一个指向其双亲的指针域,那样就称之为二叉链表。由于与树的存储结构类似,这里就不详述了。 假设,我手头有20张100元的和2000张1元的奖券,同时洒向了空中,大家比赛看谁最终捡的最多。如果是你,你会怎么做? 相信所有同学都会说,一定先捡100元的。道理非常简单,因为捡一张100元等于1元的捡100张,效率好得不是一点点。所以可以得到这样的结论,同样是贱奖券,在有限时间内,要达到最高效率,次序非常重要。对于二叉树的遍历来讲,次序同样显得很重要。 二叉树的遍历(traversing binary tree)是指从根结点出发,按照某种次序依次访问二叉树中所有结点,使得每个结点被访问一次且仅被访问一次。 这里有两个关键词:访问和次序。 访问其实是要根据实际的需要来确定具体做什么,比如对每个结点进行相关计算,输出打印等,它算作一个抽象操作。在这里我们可以简单地假定就是输出结点的数据信息。 二叉树的遍历次序不同于线性结构,最多也就是从头到尾、循环、双向等简单的遍历方式。树的结点之间不存在唯一的前驱和后继关系,在访问一个结点后,下一个被访问的结点面临着不同的选择。就像你人生的道路上,高考填志愿要面临哪个城市、哪所大学、具体专业等选择,由于选择方式的不同,遍历的次序就完全不同了。 二叉树的遍历方式可以很多,如果我们限制了从左到右的习惯方式,那么主要就分为四种: 规则是若二叉树为空,则空操作返回,否则先访问根结点,然后前序遍历左子树,再前序遍历右子树。如图6-8-2所示,遍历的顺序为:ABDGHCEIF。 规则是若树为空,则空操作返回,否则从根结点开始(注意并不是先访问根结点),中序遍历根结点的左子树,然后是访问根结点,最后中序遍历右子树。如图6-8-3所示,遍历的顺序为:GDHBAEICF。 规则是若树为空,则空操作返回,否则从左到右先叶子后结点的方式遍历访问左右子树,最后是访问根结点。如图6-8-4所示,遍历的顺序为:GHDBIEFCA。 规则是若树为空,则空操作返回,否则从树的第一层,也就是根结点开始访问,从上而下逐层遍历,在同一层中,按从左到右的顺序对结点逐个访问。如图6-8-5所示,遍历的顺序为:ABCDEFGHI。 有同学会说,研究这么多遍历的方法干什么呢? 我们用图形的方式来表现树的结构,应该说是非常直观和容易理解,但是对于计算机来说,它只有循环、判断等方式来处理,也就是说,它只会处理线性序列,而我们刚才提到的四种遍历方法,其实都是在把树中的结点变成某种意义的线性序列,这就给程序的实现带来了好处。 另外不同的遍历提供了对结点依次处理的不同方式,可以在遍历过程中对结点进行各种处理。 二叉树的定义是用递归的方式,所以,实现遍历算法也可以采用递归,而且极其简洁明了。先来看看二叉树的前序遍历算法。代码如下: 假设我们现在有如图6-8-6这样一棵二叉树T。这树已经用二叉链表结构存储在内存当中。 那么当调用PreOrderTraverse(T)函数时,我们来看看程序是如何运行的。 调用ProOrderTraverse(T),T根结点不为null,所以执行printf,打印字母A,如图6-8-7所示。 调用PreOrderTraverse(T->lchild);访问了A结点的左孩子,不为null,执行printf显示字母B,如图6-8-8所示。 此时再次递归调用PreOrderTraverse(T->lchild);访问了B结点的左孩子,执行printf显示字母D,如图6-8-9所示。 再次递归调用调用ProOrderTraverse(T->lchild);访问了D结点的左孩子,执行printf显示字母H,如图6-8-10所示。 再次递归调用ProOrderTraverse(T-lchild);访问了H结点的左孩子,此时因为H结点无左孩子,所以T==null,返回此函数,此时递归调用ProOrderTraverse(T-rchild);访问了H结点的右孩子,printf显示字母K,如图6-8-11所示。 再次递归调用调用ProOrderTraverse(T->lchild);访问了K结点的左孩子,K结点无左孩子,返回,调用ProOrderTraverse(T->rchild);访问了K结点的右孩子,也是null,返回。于是此函数执行完毕,返回到上一级递归的函数(即打印H结点时的函数),也执行完毕,返回到打印D时的函数,调用ProOrderTraverse(T->lchild);访问了D结点的右孩子,不存在,返回到B结点,调用ProOrderTraverse(T->lchild);找到了结点E,打印字母E,如图6-8-12所示。 由于结点E没有左右孩子,返回打印结点B时的递归函数,递归执行完毕,返回到最初的PreOrderTraverse,调用PreOrderTraverse(T->rchild);访问结点A的右孩子,打印字母C,如图6-8-13所示。 之后类似前面的递归调用,依次继续打印F、I、G、J,步骤略。 综上,前序遍历这棵二叉树的结点顺序是:ABDHKECFIGJ。 那么二叉树的中序遍历算法是如何呢?哈哈,别以为很复杂,它和前序遍历算法仅仅只是代码的顺序上的差异。 换句话说,它等于是把调用左孩子的递归函数提前了,就那么简单。我们来看看当调用InOrderTraverse(T)函数时,程序是如何运行的。 调用InOrderTraverse(T),T的根节点不为nulll,于是调用InOrderTraverse(T->lchild);访问结点B。当前指针不为null,继续调用InOrderTraverse(T->lchild);访问结点D。不为null,继续调用InOrderTraverse(T->lchild);访问结点H。继续调用InOrderTraverse(T->lchild);访问结点H的左孩子,发现当前指针为null,于是返回。打印当前结点H,如图6-8-14所示。 然后调用InOrderTraverse(T->rchild);访问结点H的右孩子K,因结点K无左孩子,所以打印K,如图6-8-15所示。 调用InOrderTravere(T->rchild);访问结点B的右孩子E,因结点E无左孩子,所以打印E,如图6-8-18所示。 结点E无右孩子,返回。结点B的递归函数执行完毕,返回到了最初我们调用InOrderTraverse的地方,打印字母A,如图6-8-19所示。 综上,中序遍历这棵二叉树的结点顺序是:HKDBEAIFCGJ。 如图6-8-20所示,后序遍历是先递归左子树,由根结点A->B->D->H,结点H无左孩子,再查看结点H的右孩子K,因为结点K无左右孩子,所以打印K,返回。 最终,后序遍历的结点的顺序就是:KHDEBIFJGCA。同学们可以自己按照刚才的办法得出这个结果。 有一种题目为了考查你对二叉树遍历的掌握程度,是这样出题的。已知一棵二叉树的前序遍历序列为:ABCDEF,中序遍历序列为:CBAEDF,请问这棵二叉树的后序遍历结果是多少? 对于这样的题目,如果真的完全理解了前中后序的原理,是不难的。 三种遍历都是从根结点开始,前序遍历是先打印再递归左和右。所以前序遍历序列为ABCDEF。第一个字母是A被打印出来,就说明A是根结点的数据。再由中序遍历序列是CBAEDF,可以知道C和B是A的左子树的结点,E、D、F是A的右子树的结点,如图6-8-21所示。 然后我们看前序中的C和B,它的顺序是ABCDEF,是先打印B后打印C,所以B应该是A的左孩子,而C就只能是B的孩子,此时是左还是右孩子还不确定。再看中序序列是CBAEDF,C是在B的前面打印,这就说明C是B的左孩子,否则就是右孩子了,如图6-8-22所示。 再看前序中的E、D、F,它的顺序是ABCDEF,那就意味着D是A结点的右孩子,E和F是D的子孙,注意,他们中有一个不一定是孩子,还有可能是孙子的。再来看中序序列是CBAEDF,由于E在D的左侧,而F在右侧,所以可以确定E是D的左孩子,F是D的右孩子。因此最终得到的二叉树是图6-8-23所示。 为了避免推导中的失误,你最好在心中递归遍历,检查一下这棵树的前序和中序遍历序列是否与题目中的相同。 已经复原了二叉树,要获得它的后序遍历结果就是易如反掌,结果是CBEFDA。 但其实,如果同学们足够熟练,不用画这棵二叉树,也可以得到后序的结果,因为刚才判断了A结点是根节点,那么它在后序序列中,一定是最后一个。刚才推导出C是B的左孩子,而B是A的左孩子,那就意味着后序序列的前两位一定是CB。同样的办法也可以得到EFD这样的后序顺序,最终就自然得到CBEFDA这样的序列,不用在草稿上画树状图了。 反过来,如果我们的题目是这样:二叉树的中序序列是ABCDEFG,后序序列是BDCAFGE,求前序序列。 这次简单点,由后序的BDCAFGE,得到E是根结点,因此前序首字母是E。 于是根据中序序列分为两棵树ABCD和FG,由有序序列的BDCAFGE,知道A是E的左孩子,前序序列目前分析为EA。 再由中序序列的ABCDEFG,知道BCD是A结点的右子孙,再由后序序列的BDCAFGE知道C结点是A结点的右孩子,前序序列目前分析得到EAC。 中序序列ABCDEFG,得到B是C的左孩子,D是C的右孩子,所以前序序列目前分析结果为EACBD。 由后序序列BDCAFGE,得到G是E的右孩子,于是F就是G的孩子。如果你是在考试时做这道题目,时间就是分数、名次、学历,那么你根本不需关心F是G的左还是右孩子,前序遍历序列的最终结果就是EACBDGF。 不过细细分析,根据中序序列ABCDEFG,是可以得出F是G的左孩子。 从这里我们也得到两个二叉树遍历的性质。 但要注意了,已知前序和后序遍历,是不能确定一棵二叉树的,原因也很简单,比如前序序列是ABC,后序序列是CBA,我们可以确定A一定是根结点,但接下来,我们无法知道,哪个结点是左子树,哪个是右子树。这棵树可能有如图6-8-24所示的四种可能。 说了半天,我们如何在内存中生成一棵二叉链表的二叉树呢?树都没有,哪来遍历。所以我们还得来谈谈关于二叉树建立的问题。 如果我们要在内存中建立一个如图6-9-1左图这样的树,为了能让每个结点确认是否有左右孩子,我们对它进行了扩展,变成图6-9-1右图的样子,也就是将二叉树中每个结点的空指针引出一个虚结点,其值为一特定值,比如"#"。我们称这种处理后的二叉树为原二叉树的扩展二叉树。扩展二叉树就可以做到一个遍历序列确定一棵二叉树了。比如图6-9-1的前序遍历序列就为AB#D##C##。 有了这样的准备,我们就可以来看看如何生成一棵二叉树了。假设二叉树的结点均为一个字符,我们把刚才前序遍历序列AB#D##C##用键盘挨个输入。实现的算法如下: 其实建立二叉树,也是利用了递归的原理。只不过在原来应该是打印结点的地方,改成了生成结点,给结点赋值的操作而已。所以大家理解了前面的遍历的话,对于这段代码就不难理解了。 当然,你完全也可以用中序或后序遍历的方式实现二叉树的建立,只不过代码里生成结点和构造左右子树的代码顺序交换一下。另外,输入的字符也要做相应的更改。比如图6-9-1的扩展二叉树的中序遍历字符串就应该为#B#D#A#C#,而后序字符串应该为###DB##CA。 我们现在提倡节约型社会,一切都应该节约为本。对待我们的程序当然也不例外,能不浪费的时间或空间,都应该考虑节省。我们再来观察图6-10-1,会发现指针域并不是都充分的利用了,有许许多多的"^",也就是空指针域的存在,这实在不算好现象,应该要想办法利用起来。 首先我们要来看看这空指针又多少个呢?对于一个有n个结点的二叉链表,每个结点有指向左右孩子的两个指针域,所以一共有2n个指针域。而n个结点的二叉树一共有n-1条分支线数,也就是说,其实是存在2n-(n-1)=n+1个空指针域。比如图6-10-1有10个结点,而带有"^"空指针域为11.这些空间不存储任何事物,白白的浪费着内存的资源。 另一方面,我们在做遍历时,比如对图6-10-1做中序遍历时,得到了HDIBJEAFCG这样的字符序列,遍历过后,我们可以知道,结点I的前驱是D,后继是B,结点F的前驱是A,后继是C。也就是说,我们可以很清楚的知道任意一个结点,它的前驱和后继是哪一个。 可是这是建立在已经遍历过的基础之上的。在二叉链表上,我们只能知道每个结点指向其左右孩子结点的地址,而不知道某个结点的前驱是谁,后继是谁。要想知道,必须遍历一次。以后每次需要知道时,都必须先遍历一次。为什么不考虑在创建时就记住这些前驱和后继呢,那将是多大的时间上的节省。 综合刚才两个角度的分析后,我们可以考虑利用那些空地址,存放指向结点在某种遍历次序下的前驱和后继结点的地址。就好像GPS导航仪一样。我们开车的时候,哪怕我们对具体目的地的位置一无所知,但它每次都可以告诉我从当前位置的下一步应该走向哪里。这就是我们现在要研究的问题。我们把这种指向前驱和后继的指针称为线索,加上线索的二叉链表称为线索链表,相应的二叉树就称为线索二叉树(Threaded Binary Tree)。 请看图6-10-2,我们把这棵二叉树进行中序遍历后,将所有的空指针域中的rchild,改为指向它的后继结点。于是我们就可以通过指针知道H的后继是D(图中1),I的后继是B(图中2),J的后继是E(图中3),E的后继是A(图中4),F的后继是C(图中5),G的后继因为不存在而指向NULL(图中6)。此时共有6个空指针域被利用。 再看图6-10-3,我们将这棵二叉树的所有空指针域中的lchild,改为指向当前结点的前驱。因此H的前驱是NULL(图中1),I的前驱是D(图中2),J的前驱是B(图中3),F的前驱是A(图中4),G的前驱是C(图中5)。一共5个空指针域被利用,正好和上面的后继加起来是11个。 通过图6-10-4(空心箭头实线为前驱,虚线黑箭头为后继),就更容易看出,其实线索二叉树,等于是把一棵二叉树转变成了一个双向链表,这样对我们的插入删除结点、查找某个结点都带来了方便。所以我们对二叉树以某种次序遍历使其变为线索二叉树的过程称做是线索化。 不过好事总是多磨的,问题并没有彻底解决。我们如何知道某一结点的lchild是指向它的左孩子还是指向前驱?rchild是指向右孩子还是指向前驱?比如E结点的lchild是指向它的左孩子J,而rchild却是指向它的后继A。显然我们在决定lchild是指向左孩子还是前驱,rchild是指向右孩子还是后继上是需要一个区分标志的。因此,我们在每个结点再增设两个标志域ltag和rtag,注意ltag和rtag只是存放0或1数字的布尔型变量,其占用的内存空间要小于像lchild和rchild的指针变量,结点结构如表6-10-1所示。 其中: 线索化的实质就是将二叉链表中的空指针改为指向前驱或后继的线索。由于前驱和后继的信息只有在遍历该二叉树时才能得到,所以线索化的过程就是在遍历的过程中修改空指针的过程。 你会发现,这代码除加粗代码以外,和二叉树中序遍历的递归代码几乎完全一样。只不过将本是打印结点的功能改成了线索化的功能。 中间加粗部分代码是做了这样的一些事。 if(!p -> lchild)表示如果某结点的左指针域为空,因为其前驱结点刚刚访问过,赋值给了pre,所以可以将pre赋值给p->lchild,并修改p->LTag=Thread(也就是定义为1)以完成前驱结点的线索化。 后续就要稍稍麻烦一些。因为此时p结点的后继还没有访问到,因此只能对它的前驱结点pre的右指针rchild做判断,if(!pre->rchild)表示如果为空,则p就是pre的后继,于是pre->rchild=p,并且设置pre->RTag=Thread,完成后继结点的线索化。 完成前驱和后继的判断后,别忘了将当前的结点p赋值给pre,以便于下一次使用。 有了线索二叉树后,我们对它进行遍历时发现,其实就等于是操作一个双向链表结构。 和双向链表结构一样,在二叉树线索链表上添加一个头结点,如图6-10-6所示,并令其lchild域的指针指向二叉树的根结点(图中的1),其rchild域的指针指向中序遍历时访问的最后一个结点(图中的2)。反之,令二叉树的中序序列中的第一个结点中,lchild域指针和最后一个结点的rchild域指针均指向头结点(图中的3和4)。这样定义的好处就是我们既可以从第一个结点起顺后继进行遍历,也可以从最后一个结点起顺前驱进行遍历。 遍历的代码如下: 从这段代码也可以看出,它等于是一个链表的扫描,所以时间复杂度为O(n)。 由于它充分利用了空指针域的空间(这等于节省了空间),又保证了创建时的一次遍历就可以终生受用前驱后继的信息(这意味着节省了时间)。所以在实践问题中,如果所用的二叉树需经常遍历或查找结点时需要某种遍历序列中的前驱和后继,那么采用线索二叉链表的存储结构就是非常不错的选择。 我之前在网上看到这样一个故事,不知道是真还是假,反正是有点意思。 故事是说联合利华引进了一条香皂包装生产线,结果发现这条生产线有个缺陷:常常会有盒子里没装入香皂。总不能把空盒子卖给顾客啊,他们只好请了一个学自动化的博士设计一个方案来分拣空的香皂盒。博士组织成立了一个十几人的科研攻关小组,综合采用了机械、微电子、自动化、X射线探测等技术,花了几十万,成功解决了问题。每当生产线上有空香皂盒通过,两旁的探测器会检测到,并且驱动一只机械手把空香皂盒推走。 中国南方有个乡镇企业也买了同样的生产线,老板发现这个问题后大为光火,找了个小工说:你把这个问题搞定,不然老子炒你鱿鱼。小工很快想出了办法:他在生产线旁边放了台风扇猛吹,空皂盒自然会被吹走。 这个故事在网上引起了很大的争议,我相信大家听完后也会有不少的想法。不过我在这只是想说,有很多复杂的问题都是可以有简单办法去处理的,在于你肯不肯懂脑筋,在于你有没有创新。 我们前面已经讲过了树的定义和存储结构,对于树来说,在满足树的条件下可以是任意形状,一个结点可以有任意多个孩子,显然对树的处理要复杂得多,去研究关于树的性质和算法,真的不容易。有没有简单的办法解决对树处理的难题呢? 我们前面也讲了二叉树,尽管他也是树,但由于每个结点最多只能有左孩子和右孩子,面对的变化就少很多了。因此很多性质和算法都被研究了出来。如果所有的树都像二叉树一样方便就好了。你还别说,真是可以这样做。 在讲述的存储结构时,我们提到了树的孩子兄弟法可以将一棵树用二叉链表进行存储,所以借助二叉链表,树和二叉树可以相互进行转换。从物理结构来看,他们的二叉链表也是相同的,只是解释不太一样而已。因此,只要我们设定一定的规则,用二叉树来表示树,甚至表示森林都是可以的,森林与二叉树也可以互相进行转换。 我们分别来看看他们之间的转换如何进行。 将树转换为二叉树的步骤如下: 例如图6-11-2,一棵树经过三个步骤转换为一棵二叉树。初学者容易犯的错误就是在层次调整时,弄错了左右孩子的关系。比如图中F、G本都是树结点B的孩子,是结点E的兄弟,因此转换后,F就是二叉树结点E的右孩子,G是二叉树结点F的右孩子。 森林是由若干棵树组成的,所以完全可以理解为,森林中的每一棵树都是兄弟,可以按照兄弟的处理办法来操作。步骤如下: 二叉树转换为树是树转换为二叉树的逆过程,也就是反过来做而已。如图6-11-4所示。步骤如下: 判断一棵二叉树能够转换成一棵树还是森林,标准很简单,那就是只要看这棵二叉树的根结点有没有孩子,有就是森林,没有就是一棵树。那么如果是转换成森林,步骤如下: 最后我们再谈一谈关于树和森林的遍历问题。 树的遍历分为两种方式。 森林的遍历也分为两种方式: 可如果我们对图6-11-4的左侧二叉树进行分析就会发现,森林的前序遍历和二叉树的前序遍历结果相同,森林的后序遍历和二叉树的中序遍历结果相同。 这也就告诉我们,当以二叉链表作树的存储结构时,树的先根遍历和后根遍历完全可以借助二叉树的前序遍历和中序遍历的算法来实现。这其实也就证实,我们找到了对树和森林这种复杂问题的简单解决办法。 "喂,兄弟,最近无聊透顶了,有没有什么书可看?" "我这有《三国演义》的电子书,你要不要?" "'既生瑜,何生亮。'《三国演义》好呀,你邮件发给我!" "OK!文件1M多大小,好像大了点。我打个包,稍等.....哈哈,少了一半,压缩效果不错呀。" 这是我们生活中常见的对白。现在我们都是讲究效率的社会,什么都要求速度,在不能出错的情况下,做任何事情都讲究越快越好。在计算机和互联网技术中,文本压缩就是一个非常重要的技术。玩电脑的人几乎都会应用压缩和解压缩软件来处理文档。因为它除了可以减少文档在磁盘上的空间外,还有重要的一点,就是我们可以在网络上以压缩的形式传输大量数据,使得保存和传递都更加高效。 那么压缩而不出错是如何做到的呢?简单说,就是把我们要压缩的文本进行重新编码,以减少不必要的空间。尽管现在最新技术在编码上已经很好很强大,但这一切都来自于曾经的技术积累,我们今天就来介绍一下最基本的压缩编码方法--赫夫曼编码。 在介绍赫夫曼编码前,我们必须得介绍赫夫曼树,而介绍赫夫曼树,我们不得不提这样一个人,美国数学家赫夫曼(David Huffman),也有的翻译为哈夫曼。他在1952年发明了赫夫曼编码,为了纪念他的成就,于是就把他在编码中用到的特殊的二叉树称之为赫夫曼树,他的编码方法称为赫夫曼编码。也就是说,我们现在介绍的知识全都来自于近60年前这位伟大科学家的研究成果,而我们平时所用的压缩和解压缩技术也都是基于赫夫曼的研究之上发展而来,我们应该要记住他。 什么叫做赫夫曼树呢?我们先来看一个例子。 过去我们小学、中学一般考试都是用百分制来表示学科成绩的。这带来了一个弊端,就是很容易让学生、家长,甚至老师都以分取人,让分数代表了一切。又是想想也对,90分和95分也许就只是一道题目对错的差距,但却让两个孩子可能受到完全不同的际遇,这并不公平。于是再如今提倡素质教育的背景下,我们很多的学科,特别是小学的学科成绩都该作了优秀、良好、中等、及格和不及格这样模糊的词语,不再通报具体的分数。 不过对于老师来讲,他在对试卷评分的时候,显然不能凭感觉给优良或及格不及格等成绩,因此一般都还是按照百分制算出每个学生的成绩后,再根据统一的标准换算得出五级分制的成绩。比如下面的代码就实现了这样的转换。 图6-12-2粗略看没什么问题,可是通常都认为,一张好的考卷应该是让学生成绩大部分处于中等或良好的范围,优秀和不及格都应该较少才对。而上面这样的程序,就使得所有的成绩都需要先判断是否及格,再逐级而上得到结果。输入量很大的时候,其实算法是有效率问题的。 如果在实际的学习生活中,学生的成绩在5个等级上的分布规律如表6-12-1所示。 那么70分以上大约占总数80%的成绩都需要经过3次以上的判断才可以得出结果,这显然不合理。 有没有好一些的办法,仔细观察发现,中等成绩(70-79分之间)比例最高,其次是良好成绩,不及格的所占比例最少。我们把图6-12-2这棵二叉树重新进行分配。改成如图6-12-3的做法试试看。 从图中感觉,应该效率要高一些了,到底高多少呢。这样的二叉树又是如何设计出来的呢?我们来看看赫夫曼大叔是如何说的吧。 我们先把这两棵二叉树简化成叶子结点带权的二叉树,如图6-12-4所示。其中A表示不及格、B表示及格、C表示中等、D表示良好、E表示优秀。每个叶子的分支线上的数字就是刚才我们提到的五级分制的成绩所占比例数。 赫夫曼大叔说,从树中一个结点到另一个结点之间分分支构成两个结点之间的路径,路径上的分支数目称做路径长度。图6-12-4的二叉树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个权值(w1,w2,...wn),构造一棵有n个叶子结点的二叉树,每个叶子结点带权w(k),每个叶子的路径长度为1k,我们通常记作。则其中带权路径长度WPL最小的二叉树称做赫夫曼树。也有不少书中也称为最优二叉树,我个人觉得胃里纪念做出巨大贡献的科学家,既然用他们的名字命名,就应该要坚持用他们的名字称呼,哪怕"最优"更能体现这棵树的品质也应该只作为别名。 有了赫夫曼对带权路径长度的定义,我们来计算一下图6-12-4这两棵树的WPL值。 二叉树a的WPL=5x1+15x2+40x3+30x4+10x4=315。 注意:这里5是A结点的权,1是A结点的路径长度,其他同理。 二叉树b的WPL=5x3+15x3+40x2+30x2+10x2=220。 这样的结果意味着什么呢?如果我们现在有10000个学生的百分制成绩需要计算五级分制成绩,用二叉树a的判断方法,需要做31500次比较,而二叉树b的判断方法,只需要22000次比较,差不多少了三分之一量,在性能上提高不是一点点。 那么现在的问题就是,图6-12-4的二叉树b这样的树是如何构造出来的,这样的二叉树是不是就是最优的赫夫曼树呢?别急,赫夫曼大叔给了我们解决的办法。 此时的图6-12-8二叉树的带权路径长度WPL=40x1+30x2+15x3+10x4+5x4=205。与图6-12-4的二叉树b的WPL值220相比,还少了15。显然此时构造出来的二叉树才是最优的赫夫曼树。 不过现实总是比理想要复杂得多,图6-12-8虽然是赫夫曼树,但由于每次判断都要两次比较(如根结点就是a<80 && a>=70,两次比较才能得到y或n的结果),所以在总体性能是哪个,反而不如图6-12-3的二叉树性能高。当然这并不是我们要讨论的重点了。 通过刚才的步骤,我们可以得出构造赫夫曼树的赫夫曼算法描述。 当然,赫夫曼研究这种最优树的目的不是为了我们可以转化一下成绩。他的更大目的是为了解决当年远距离通信(主要是电报)的数据传输的最优化问题。 比如我们有一段文字内容为"BADCADFEED"要网络传输给别人,显然用二进制的数字(0和1)来表示是很自然的想法,我们现在这段文字只有六个字母ABCDEF,那么我们可以用相应的二进制数据表示,如表6-12-2所示。 这样真正传输的数据就是编码后的"001000011010000011101100100011",对方接收时可以按照3位壹分来译码。如果一篇文章很长,这样的二进制串也将非常的可怕。而且事实上,不管是英文、中文或是其他语言,字母或汉字的出现频率是不相同的,比如英语中的几个元音字母"a e i o u",中文中的"的 了 有 在"等汉字都是频率极高。 假设六个字母的频率为A 27,B 8,C 15,D 15,E 30,F 5,合起来正好是100%。那就意味着,我们完全可以重新安置赫夫曼树来规划它们。 图6-12-9左图为构造赫夫曼树的过程的权值显示。右图为将权值左分支改为0,右分支改为1后的赫夫曼树。 此时,我们对这六个字母用其从树根到叶子所经过路径的0或1来编码,可以得到如表6-12-3所示这样的定义。 我们将文字内容为"BADCADFEED"再次编码,对比可以看到结果串变小了。 也就是说,我们的数据被压缩了,节约了大约17%的存储或传输成本。随着字符的增加和多字符权重的不同,这种压缩会更加显出其优势。 当我们接收到1001010010101001000111100这样压缩过的新编码时,我们应该如何把它解码出来呢? 编码中非0即1,长短不等的话其实是很容易混淆的,所以若要设计长短不等的编码,则必须是任一字符的编码都不是另一个字符的编码的前缀,这种编码称做前缀编码。 你仔细观察就会发现,表6-12-3中的编码就不存在容易与1001,1000混淆的"10"和"100"编码。 可仅仅是这样不足以让我们去方便地解码的,因此在解码时,还是要用赫夫曼树,即发送方和接收方必须要约定好同样的赫夫曼编码规则。 当我们接收到1001010010101001000111100时,由约定好的赫夫曼树可知,1001得到第一个字母是B,接下来01意味着第二个字符是A,如图6-12-10所示,其余的也相应的可以得到,从而成功解码。 一般地,设需要编码的字符集为{d1,d2,..,dn},各个字符在电文中出现的次数或频率集合为{W1,W2,..,Wn},以d1,d2,...,dn作为叶子结点,以W1,W2,..,Wn作为相应叶子结点的权值来构造一棵赫夫曼树。规定赫夫曼树的左分支代表0,右分支代表1,则从根结点到叶子结点所经过的路径分支组成的0和1的序列便为该结点对于字符的编码,这就是赫夫曼编码。 终于到了总结的时间,这一章与前面章节相比,显得过于庞大了些,原因也就在于树的复杂性和变化丰富度是前面的线性表所不可比拟的。即使在本章之后,我们还要讲解关于树这一数据结构的相关知识,可见它的重要性。 开头我们提到了树的定义,讲到了递归在树定义中的应用。提到了如子树、结点、度、叶子、分支结点、双亲、孩子、层次、深度、森林等诸多概念,这些都是需要在理解的基础上去记忆的。 我们谈到了树的存储结构时,讲了双亲表示法、孩子表示法、孩子兄弟表示法等不同的存储结构。 并由孩子兄弟表示法引出了我们这章中最重要一种树,二叉树。 二叉树每个结点最多两棵子树,有左右之分。提到了斜树,满二叉树、完全二叉树等特殊二叉树的概念。 我们接着谈到它的各种性质,这些性质给我们研究二叉树带来了方便。 二叉树的存储结构由于其特殊性使得既可以用顺序存储结构又可以用链式存储结构表示。 遍历是二叉树最重要的一门学问,前序,中序,后序以及层序遍历都是需要熟练掌握的知识。要让自己学会计算机的运行思维去模拟递归的实现,可以加深我们对递归的理解。不过,并非二叉树遍历就一定要用到递归,只不过递归的实现比较优雅而已。这点需要明确。 二叉树的建立自然也是可以通过递归来实现。 研究中也发现,二叉链表有很多浪费的空指针可以利用,查找某个结点的前驱和后继为什么非要每次遍历才可以得到,这就引出了如何构造一棵线索二叉树的问题。线索二叉树给二叉树的结点查找和遍历带来了高效率。 树、森林看似复杂,其实它们都可以转化为简单的二叉树来处理,我们提供了树、森林与二叉树的互相转换的办法,这样就使得面对树和森林的数据结构时,编码实现成为了可能。 最后,我们提到了关于二叉树的一个应用,赫夫曼树和赫夫曼编码,对于带权路径的二叉树做了详尽地讲述,让你初步理解数据压缩的原理,并明白其是如何做到无损编码和无错解码的。6.6.5 二叉树性质5
6.7 二叉树的存储结构
6.7.1 二叉树顺序存储结构
6.7.2 二叉链表
6.8 遍历二叉树
6.8.1 二叉树遍历原理
6.8.2 二叉树遍历方法
1. 前序遍历
2. 中序遍历
3. 后序遍历
4. 层序遍历
6.8.3 前序遍历算法
6.8.4 中序遍历算法
6.8.5 后序遍历算法
6.8.6 推导遍历结果
6.9 二叉树的建立
6.10 线索二叉树
6.10。1 线索二叉树原理
6.10.2 线索二叉树结构实现
6.11 树、森林与而擦书的转换
6.11.1 树转换为二叉树
6.11.2 森林转换为二叉树
6.11.3 二叉树转换为树
6.11.4 二叉树转换为森林
6.11.5 树与森林的遍历
6.12赫夫曼树及其应用
6.12.1 赫夫曼树
6.12.2 赫夫曼树定义与原理
6.12.3 赫夫曼编码
6.13 总结回顾