目录
树的定义
之前我们一直在谈一对一的线性结构,可现实中,还有很多一对多的情况需要处理,所以我们研究出了这种一对多的数据机构——树。
树是n个结点的有限集。在n=0时称为空树。
在任意一颗非空树中:
- 有且仅有一个特定的称为根的结点;
- 当n>1时,其余结点可分为m个互不相交的有限集T1、T2、.....、Tm,其中每一个集合本身又是一棵树,并且称为根的子树。
如图所示
关于树的定义还需强调两点:
- n>0时,根节点是唯一的,不可能存在多个根节点,数据结构中的树只能有一个根节点。
- m>0时,子树的个数没有限制,但它们一定是互不相交的。如下图所示就不成立。
结点分类
树的结点包含一个数据元素及若干指向其子树的分支。结点拥有的子树数称为结点的度。度为0的结点称为叶结点或终端结点;度不为0的结点称为分支结点。
除根节点之外,分支节点也被称为内部结点。树的度是树内部各结点的度的最大值。如图所示
因为这棵树结点的度的最大值是结点D的度为3,所以树的度也为3.
结点间关系
结点的子树的根被称为该结点的孩子(child),相应地,该节点被称为孩子的双亲(parent)。同一个双亲的孩子之间互称兄弟(sibling)。
结点的祖先是从根到该结点所经分支上的所有结点。以某结点为根的子树中的任一结点都称为该结点的子孙。
树的其他相关概念
结点的层次从根开始定义起,根为第一层,根的孩子为第二层。若某结点在第i层,则其子树的根就在i+1层。
其双亲在同一层的结点互为堂兄弟。如下图中的D、E、F是,而G、H、I、J也是。
树中结点最大层次称为树的深度或高度,当前树的深度为4。
若将树中结点的各子树看成从左往右是有次序的,不能互换的,则称该树为有序树,否则称为无序树。
森林是m颗互不相交的树的集合。对树中的每个结点而言,其子树的集合即为森林。
对比线性表与树的结构
树的抽象数据类型
相比于线性结构,树的操作就完全不同了。
树的存储结构
双亲表示法
我们假设以一组连续空间存储树的结点,同时在每个结点中,附设一个指示器指示其双亲结点到链表中的位置。也就是说,每个结点除了知道自己是谁以外,还知道它的双亲在哪里。如下图所示。
其中data是数据域,存储结点的信息;而parent为指针域,存储该结点的双亲在数组中的下标。
以下是我们的双亲表示法的结点结构定义代码。
有了这样结构定义,我们来试试实现双亲表示法。由于根节点无双亲,所以我们约定根节点的位置域设置为-1。
![](https://i-blog.csdnimg.cn/blog_migrate/b9047bee3ad88d4352b15c237bb63ec9.png)
这样的存储结构,我们可以根据结点的parent指针很容易找到它的双亲结点,所用时间复杂度为O(1),直到parent为-1时,表示找到了树结点的根。可是,想找到结点的孩子,需遍历整个结构才行。
那么,改进一下——我们增加一个结点是最左边孩子的域,不妨叫它长子域;若无孩子的结点,这个长子域就设置为-1。
![](https://i-blog.csdnimg.cn/blog_migrate/8def99399110a845537c609ade72a5d5.png)
对于有0个或1个孩子结点来说,这样的结构是解决了要找结点孩子的问题了。甚至是有2个孩子,知道了长子是谁,另一个当然就是次子了。
而另外一个问题,各兄弟之间的关系,无法用双亲表示法得以体现。但我们可以增加一个右兄弟域来体现兄弟关系。若存在右兄弟,则记录其下标;若不存在,则赋值为-1。
![](https://i-blog.csdnimg.cn/blog_migrate/6ae9e870317d9ec1e40d0ab954d97b88.png)
但如果结点孩子较多,超过了2个。我们又关注结点的双亲、又关注结点的孩子与兄弟,且对时间遍历要求还比较高。那我们要扩展为有双亲域、长子域、右兄弟域。
存储结构的设计是一个非常灵活的过程。一个存储结构设计得是否合理,取决于基于该存储结构的运算是否合适、是否方便,时间复杂度好不好。
孩子表示法
由于每个结点中可能有多棵子树,可以考虑用多重链表,即每个结点有多个指针域,其中每个指针指向一棵子树的根结点,我们把这种方法叫做多重链表示法。
不过,树的每个结点的度是不同的,所以有两种方案来解决。
方案一
一种是指针域个数就等于树的度——而树的度是各个结点度的最大值。
其中data是数据域,child1到childd是指针域,用来指向该结点的孩子结点。
对于图例中的树而言,树的度是3,所以我们的指针域的个数为3。
此方法对于树中各结点的度相差很大时,显然是浪费空间的,因为有很多的结点,它的指针域都是空的。不过如果树的各结点度相差很小时,那就意味着开辟空间被充分利用了。
方案二
第二种方案每个结点指针域的个数等于该结点的度,我们专门取一个位置来存储结点指针域的个数。
其中data为数据域,degree为度域,也就是该结点上孩子结点的个数,child1到childd为指针域,指向该结点各个孩子的结点。
这种办法确实克服了浪费空间的缺点,对空间的利用率是很高了,但是由于各个结点的链表是不相同的结构,加上要维护结点的度的数值,在运算上就会带来时间上的损耗。
方案三(最好的孩子表示法)
能否有更好的方法,既可以减少空指针的浪费又能使结点结构相同?
这就是我们要讲的孩子表示法,把每个结点的孩子结点排列起来,以单链表作存储结构,则n个结点有n个孩子链表,如果是叶子结点则此单链表为空。然后n个头指针又组成一个线性表,采用顺序存储表中。
为此,我们需要设计两种结点结构,一个是孩子链表的孩子结点。
其中child是数据域,用来存储某个结点在表头数组的下标。next是指针域,用来存储指向某结点的下个孩子结点的指针。
另一个是表头数组的表头结点。
其中data是数据域,存储每一个结点的信息。firstchild是头指针域,存储该结点的孩子链表的指针。
以下是结构定义代码:
回顾一下原来的知识:
此时为了避免孩子节点中的next指针,与表头结构中的next指针产生混淆,所以一个用CTnode来定义,一个用childptr来定义.。两者next指针都是同一结构体类型。
这样的结构对于找某个结点的孩子,或者找某个结点的兄弟,只要查找这个结点的孩子单链表即可,而遍历整棵树也很方便,只需头结点数组循环即可。
可是如何找到孩子结点的双亲呢?比较麻烦,需要整棵树遍历才行。
假设我们有一个树,节点A的孩子节点是B和C,那么A的孩子链表中就会包含B和C的位置。如果我们想找到B的父节点,我们就需要遍历整个树,查找哪个节点的孩子链表中包含了B的位置。在这个例子中,我们会发现A的孩子链表中包含了B的位置,所以B的父节点就是A。
可这也只是查找操作,我们并没有直接存储每个节点的父节点信息,所以不能直接调用。
孩子表示法的改进——加入双亲结点信息(双亲孩子表示法)
图示如下
结构定义如下:
孩子兄弟表示法
之前两种方法是由双亲结点和孩子结点入手,如果我们从孩子结点的兄弟入手呢?
我们观察发现,任意一棵树,它的结点的第一个孩子如果存在就是唯一的,它的右兄弟如果存在也是唯一的。因此,我们设置两个指针,分别指向该结点的第一个孩子和此结点的右兄弟。
其中data是数据域,firstchild是存储孩子结点的指针域,rightsib是存储兄弟结点的指针域。
结构定义代码如下:
图解如下:
当然,如果想找每个某个结点的双亲,这个表示法也是有缺陷的。如果有这个需要的话,完全可以增加一个parent指针域来解决。
其实,这个表示法的最大好处是把一颗复杂的树变成了一颗二叉树,如下图
二叉树的定义
对于这种在某个阶段都是两种结果的情形,比如开和关、0和1、真和假等,都适合用树状结构来建模,而这种树是一种很特殊的树状结构,叫做二叉树。
定义:二叉树是n个结点的有限集合,该集合或者为空集,或者由一个根节点和两棵互不相交的、分别称为根节点的左子树和右子树的二叉树组成。
左侧树因为D结点有3个结点,所以它不是二叉树;而右侧就是一棵二叉树。
二叉树的特点
二叉树的特点有:
- 每个结点最多有两个孩子结点,注意是最多,没有或者只有一个是可以的。
- 左子树与右子树是有顺序的,次序不能颠倒。如人的双手。
- 即使树中某结点只有一棵子树,也要区分它是左子树还是右子树。如同你摔了手,但伤的左手还是右手是完全不同的,如下图所示
二叉树的五种基本形态:
- 空二叉树
- 只有一个根结点
- 根结点只有左子树
- 根结点只有右子树
- 根结点既有左子树又有右子树
五种形态好理解,那如果这么问:三个结点的二叉树有几种形态?三个结点的树有几种形态?
只从形态上考虑,三个结点的树只有两种情况(因为树不分左右):为树1,以及后四种任意一种;而三个结点的二叉树有五种。
特殊二叉树
斜树
所有结点都只有左子树的二叉树叫做左斜树;所有结点都只有右子树的二叉树叫右斜树。二者统称为斜树。
满二叉树
在二叉树中,所有分支结点都存在左子树和右子树,并且将所有叶子都在同一层上。
单是每个结点都存在左右子树,不能算是满二叉树,还必须要所有的终端结点都在同一层上,这就做到了整棵树的平衡。因此满二叉树具有以下特点:
- 终端节点都在最后一层。
- 非终端节点的度一定是2,否则就是“缺胳膊少腿”了。
- 在同样深度的二叉树中,满二叉树的结点个数最多,终端结点最多。
完全二叉树
对一棵具有n个结点的二叉树按层序编号,若编号为i的结点顺序如下,则称为完全二叉树。
- 叶子结点只能出现在最下层和次下层。
- 最下层的叶子结点集中在树的左部。
- 倒数第二层若存在叶子结点,一定在右部连续位置。
- 如果结点度为1,则该结点只有左孩子,即没有右子树
从上面的例子,也给我们一个判断某二叉树是否是完全二叉树的办法,那就是看着树的示意图,默默逐层编号,如果编号出现空档,就说明不是。
二叉树的性质
性质1:在二叉树的第i层最多有个结点
性质2:深度为k的二叉树最多有个结点,如果有k层,此二叉树至多
个结点。
性质3:对任意一颗二叉树T,如果其终端结点数为n0,度为2的结点数为n2,则n0=n2+1。
终端结点数其实就是叶子结点数,而一棵二叉树,除了叶子结点外,剩下的就是度为1或2的结点数了,我们设n1为度是1的结点数。则树T结点总数n=n0+n1+n2。
性质4:具有n个结点的完全二叉树的深度为【]+1
由满二叉树的定义,深度为k的满二叉树的结点数n一定是。公式倒退如下:
在满二叉树中,结点数n为15的满二叉树,度k为4。而若是完全二叉树,结点数小于满二叉树结点数。
性质5:如果对一棵有n个结点的完全二叉树的结点按层序编号,其深度为+1,(从第1层到第
+1层,每层从左从右),对任一结点i有:
- 如果i=1,则结点i是二叉树的根,无双亲;如果i>1,则其双亲是结点i/2。
- 如果2i>n,则结点i无左孩子(结点i为叶子结点);否则其左孩子是结点2i。
- 如果2i+1>n,则结点i无右孩子;否则其右孩子是结点2i+1。
二叉树的存储结构
二叉树顺序存储结构
二叉树的顺序存储结构就是用一维数组存储二叉树的结点,并且结点的存储位置,也就是数组的下标要能体现结点之间的逻辑关系。
将这棵树存入到数组中,相应的下标对应其同样的位置。
此处便体现了二叉树的优越性。由于它定义的严格,所以用顺序结构也可以表现出二叉树的结构来。
对于一般的二叉树,尽管层序编号不能反映逻辑关系,但是可以将其完全二叉树编号,只不过,把不存在的的结点“^”而已。
考虑一种极端情况,一棵深度为k的右斜树,它只有k个结点,却需要分配-1个存储单元空间,这显然是对存储空间的浪费。顺序结构一般只用于完全二叉树。
二叉链表
既然顺序存储适用性不强,我们就要考虑链式存储结构。二叉树每个结点最多有两个孩子,所以可以为它设计一个数据域与两个指针域。
其中data是数据域,lchild和rchild分别存放左孩子和右孩子的指针
结构定义代码如下
结构示意图如下
就如同树的存储结构讨论的那样,如果有需要,还可以添加一个指向双亲的指针域。那样就被称为三叉链表。
遍历二叉树
二叉树的遍历是从根结点出发,按照某种次序依次访问二叉树中所有结点,使得每个结点被访问一次且仅被访问一次。
这里有两个关键词:访问和次序
访问是根据实际需求来确定做什么 ,比如对每个结点进行相关计算,输出打印等抽象操作,在这里可以假定为输出结点的数据信息。
二叉树的遍历次序不同于线性结构,不存在唯一的前驱和后继关系,在访问一个点后,下一个被访问的结点面临不同选择,由于选择方式不同,遍历次序也会不同。
二叉树遍历方法
如果限制了从左到右的习惯方式,那么主要分为四种:
1. 前序遍历
规则是若二叉树为空,则空操作返回,否则先访问根结点,然后前序遍历左子树,再前序遍历右子树。如下遍历顺序为:ABDGHCEIF
2.中序遍历
规则是若树为空,则空操作返回,否则从根结点开始(注意并不是先访问根结点),中序遍历根结点的左子树,然后是访问根结点,最后中序遍历右子树。遍历的顺序为:GDHBAEICF。
3.后序遍历
规则是若树为空,则空操作返回,否则从左到右先叶子后结点的方式遍历访问左右子树,最后是访问根结点。遍历顺序为:GHDBIEFCA
4. 层序遍历
规则是若树为空,则空操作返回,否则从树的第一层,也就是根结点开始访问,从上而下逐层遍历,在同一层中,按从左到右的顺序对结点逐个访问。遍历顺序为:ABCDEFGHI。
计算机只会处理线性序列,我们这四种遍历方法,其实都是在把树中结点变成某种意义的线性序列。另外不同的遍历提供了对结点依次处理的不同方式,可以在遍历过程中对结点进行各种处理。
前序遍历算法
二叉树的定义是用递归的方式,所以实现遍历算法也可以采用递归,而且极其简洁明了。
代码如下:
此处使用了递归算法的性质,就是当前函数中再次调用当前函数。如果说第一次调用PreOrderTraverse (BiTree T)是访问当前树的根结点。而PreOrderTraverse (T->lchild)访问的就是当前根结点的左子树。
假设我们现在有二叉树T如下,这树已经用二叉链表结构存储在内存中。
那么调用PreOrderTraverse(T)函数时,我们来看看程序如何运行的。
1. 调用PreOrderTraverse(T),T根结点不为null,所以执行printf,打印字母A, 再调用PreOrderTraverse(T->lchild);访问了A结点的左孩子,不为null,执行printf显示字母B,逐级循环至H结点。
2.再次递归调用PreOrderTraverse(T->lchild);访问了H结点的左孩子,此时H无左孩子,T==NULL,返回此函数,访问右孩子,就是K结点。
如果
H->lchild
是NULL
,那么函数会立即返回,结束的是调用H结点的左子树的操作,但是,这并不会影响到后面的PreorderTraverse(H->rchild)
的调用。只要H->rchild
不是NULL
,我们就会对右子树进行前序遍历。
3.再次递归调用PreOrderTraverse(T->lchild),访问K的左孩子,K无左孩子,返回,调用PreOrderTraverse(T->rchild),访问K的右孩子,也是null,返回。此函数执行完毕,返回上一级递归的函数(即打印的H结点时的函数),一直返回到B结点,找到了E结点。
此处有个问题:为什么K能回到H?
这与递归函数的特点有关:在这个函数中,我们首先访问根节点,然后递归地遍历左子树,最后递归地遍历右子树。
当我们开始遍历一个节点的左子树时,我们实际上是在进行一个新的递归调用,也就是说,我们暂时“离开”了当前的节点,进入了它的左子树。在这个新的递归调用中,我们会按照同样的顺序(根节点 -> 左子树 -> 右子树)遍历左子树的所有节点。
当一个节点的左子树和右子树都被遍历完后,
PreorderTraverse
函数的递归调用就会结束,我们就会“返回”到它的父节点。回到B时,此时左子树已经遍历完成,递归回溯检查每个结点是否存在右子树。这个“返回”实际上是由函数调用栈自动管理的,而函数调用栈又是由计算机的硬件和操作系统自动管理的。
4. 由于结点E无左右孩子,返回打印结点B直到A;访问结点A的右孩子,打印字母C。也是先访问左孩子F后,在访问F结点的左子树I,因为I,F无右孩子,回到C后访问右孩子树G,J。
中序遍历算法
它等于是把调用左孩子的递归函数提前了 。
1.调用InOrderTraverse(T)函数时,T的根结点不为null,于是访问结点B直到结点H。继续调用InOrderTraverse(T->lchild),发现指针为null,返回函数,并执行下一步的printf函数。
2.然后调用InOrderTraverse(T->rchild),访问H的右孩子结点K,又因结点K无左孩子,返回null,执行printf。
3.递归逐级回溯并打印到B,成功调用函数访问右孩子E后,再次回溯到B后到A。
4.此时A的左子树已经递归完毕,开始访问A的右子树C,但因为调用左子树的函数在前,也是先递归调用访问左子树的函数。但注意只是访问,并不是打印!打印要到终端结点才开始。
后序遍历算法
相当于是将打印放在遍历左右子树之后。
1.检查当前结点是否有左右子树,若有则进入访问,直到没有子树的终端结点K,执行printf。
2.回溯到有子树存在的B,访问其子树E后,确认结点E无子树后执行printf,回到结点B执行printf
二叉树的建立
如果我们要在内存中建立一个如左图这样的树,为了能让每个结点确认是否有左右孩子,我们对它进行了扩展,变成图右的样子。
也就是将二叉树中每个结点的空指针引出一个虚结点,其值为一特定值,比如“#”。我们称这种处理后的二叉树为原二叉树的扩展二叉树。扩展二叉树就可以做到一个遍历序列确定一棵二叉树了。比如图右的前序遍历序列就为AB#D##C##。
实现代码如下:
建立二叉树,也是利用了递归的原理。只不过在原来应该是打印结点的地方、改成了生成节点、给结点赋值的操作;你也可以用中序或后序遍历的方式实现二叉树的建立,只不过代码里生成结点和构造左右子树的代码顺序进行交换。
线索二叉树
此时存在一个如下图的二叉树。此时假设用中序排序为HDIBEJAFCG。
我们发现许多空指针域存在,我们想办法利用起来。
我们发现,n个结点的二叉链表,每个结点有指向左右孩子的两个指针域,所以一共是2n个指针域。而n个结点一共有n-1条分支线。那就存在2n-(n-1)=n+1个空指针域。
正如上图,10个结点,便有11个空指针域。这些空间不存储任何事物,白白浪费内存资源。
我们可在空指针中存放指向前驱和后继的指针称为线索,加上线索的二叉链表称为线索链表,相应的二叉树就称为线索二叉树。
如果将所有的空指针域中的rchild,改为指向它的后继结点,如下图
再将所有空指针域中的lchild,改为指向当前结点的前驱,如下图
这样可以看出,其实线索二叉树,等于是把一棵二叉树转变成了一个双向链表,所以我们对二叉树以某种次序遍历使其变为线索二叉树的过程叫做线索化。
另外,我们在决定lchild是指向左孩子还是前驱,rchild指向右孩子还是后继上是需要一个区分标志的。 因此,在每个节点再增设两个标志域ltag和rtag,这两个只是存放0或1数字的布尔型变量。结构如下
- ltag为0时指向该结点左孩子,为1时指向结点前驱。
- rtag为0时指向该结点右孩子,为1时指向结点后继。
因此二叉链表图可以修改为如下样子。
线索二叉树结构实现
线索化的过程就是在遍历过程中修改空指针的过程。
线索二叉树的代码实现
而中序遍历线索化的递归函数代码如下:
上面加粗部分为与中序遍历算法的添加内容,注意此时递归函数的调用,先进入二叉树的左子树。
图解程序如下:
1.左子树的线索递归
2.执行判断函数
注意看,此时先是判断当前结点是否存在左孩子,如果没有,则将左孩子指针指向前驱结点,因为D为首元素,无前驱结点,所以pre指向null。由此,pre指针才首次亮相。
后续结点的
又因为是pre为null,所以if(!pre->rchild)函数不执行。
3.当前结点变为前驱结点,进入下一个结点后,再次执行程序步骤
我们可以总结出一套规律,满足判断条件的话,该算法是先在当前结点中的lchild指针与前驱节点先链接后,再用前驱的rchild指针与当前结点相连,此时双链关系已成型。当前再将当前结点变为前驱结点,再进入下一个结点(递归到双亲结点或者进入右子树结点),在下一个结点中再次完成这些操作。
加入头指针进行遍历
加入一个头结点,如上图所示:
- 如1所示,头结点的lchild指向根结点。
- 如2所示,头结点的rchild指向末尾元素。
- 如3所示,首元素的lchild指向头结点
- 如4所示,末尾元素的rchild指向头结点
遍历代码如下
图解代码如下:
第4行,p=p->lchild,p指针指向根结点。
第5~16行,p!=T,意味着指针未回到头结点,指针一直重复循环访问lchild指针域,此刻遍历正式开始,因此当p==T时,意味着p指针与头指针重合。则循环结束。
第7~8行,当Ltag=0时,说明当前结点存在左子树,就是由A->H,因为此时H结点的Ltag=1,所以说明H无左子树,循环结束。此操作为找到首元素。
第9行,打印H结点。(显示结点数据,可以更改为其他对结点操作。)
第10行,结点的rtag=1时,说明其指针域指向后继结点,并且当前后继结点并不是头结点,则开始遍历且输出。当如果rtag=0时,如D,则退出循环。此时注意D的输出:因为打印H后,H结点满足循环,所以在循环中进入了后继结点,而此时循环中的输出为D。
第15行,如上图所示,p指向了结点D的右孩子I。
总结:它等于是一个链表的扫描,所以时间复杂度为O(n),如果所用的二叉树需要经常遍历或查找结点时需要某种遍历序列中的前驱和后继,那么采用线索二叉链表的存储结构是非常不错的选择。
树、森林与二叉树的转换
从树的存储结构,我们提到了树的孩子兄弟法可以将一棵树用二叉链表进行存储,所以借助二叉链表,树和二叉树可以进行相互转换。只要我们设定一定的规则,用二叉树来表示树,甚至表示森林都是可以的。
树转换为二叉树
将树转换为二叉树的步骤如下:
- 加线:在所有兄弟结点之间加一条连线。
- 去线:对树中每个结点,只保留它与第一个孩子结点的连线,删除它与其他孩子结点之间的连线。
- 层次调整:以根结点为轴心,将整棵树旋转一定的角度,使结构层次分明——注意,树中结点的左孩子在二叉树中为结点的左孩子,树中该结点的兄弟结点在二叉树中该结点的右孩子。
森林转换为二叉树
- 把每个树转换为二叉树。
- 第一棵二叉树不动,依次把后一棵二叉树的根结点作为前一棵二叉树的根结点的右孩子,用线连接起来。
二叉树转换为树
- 加线:将二叉树左孩子的n个右孩子结点都作为此结点的孩子都作为此结点的孩子,将该结点与这些右孩子结点用线连接起来。
- 去线:删除原二叉树所有结点与其右孩子结点的连线。
- 层次调整:使之结构分明
二叉树转换为森林
判断一棵二叉树能够转换成一棵树还是森林,只需要看这棵二叉树的根结点是否有右孩子,有就是森林,没有就是一棵树。
去线:将右孩子的连线一一去掉。
调整根结点:将每棵分离后的二叉树转换为树即可。
树与森林的遍历
树的遍历分为两种:
- 先根遍历树:先访问树的根结点,再依次根据根遍历每棵子树。
- 后根遍历:即先依次后根遍历每棵子树,然后再访问根结点。
以上,先根遍历序列为ABEFCDG,后根遍历序列为EFBCGDA。
森林的遍历也分为两种方式:
- 前序遍历:先访问第一棵树的结点,然后再依次先根遍历根的每棵子树,再依次用同样的方式遍历除去第一棵树的剩余树构成的森林。
- 后序遍历:先访问森林中第一棵树,后根遍历的方式遍历每棵子树,再访问根结点,然后用同样的方式遍历其他树
以上,前序遍历顺序为ABCDEFGHJI,后序遍历顺序为BCDAFEJHIG,我们发现,森林的前序遍历与二叉树前序遍历结果相同,森林的后序遍历与二叉树中序遍历结果相同。
这也是证实了,我们找到了对树和森林这种复杂问题的简单解决办法。
赫夫曼树及其应用
首先来了解一些概念:
- 路径长度:两结点间的分支数目称作路径长度。
- 树的路径长度:从树根到每一结点的路径长度之和。
- 权:树中结点赋给一个有某种含义的数值。
- 结点的带权路径长度:从根结点到该结点之间的路径长度与该结点的权的乘积。
- 树的带权路径长度:树中所有叶子结点的带权路径长度之和,记作WPL。
这样的结果意味着,如果有10000个同学需要计算五等分制成绩,用二叉树a的判断方法,需要做31500次,而二叉树b需要22000次。
赫夫曼树的构造:
根据给定的n个权值{w1w2…wn}构成n棵二叉树集合F={T1,T2…,Tn},其中每棵二叉树Ti中只有一个带权为wi根结点。
- 在F中选取两棵根结点的权值最小的树为左右子树构造一棵新的二叉树,且置新的二叉树根结点的权值为其左右子树上根结点之和。
- 在F中删除这两棵树,同时将新得到的二叉树加入F集合中。
- 重复2和3步骤,直到F只含一棵树为止。
赫夫曼编码
当年赫夫曼研究这种最优树的更大目的是为了解决当年远距离通信的数据传输的最优化问题。
比如我们要传输字符“BADCADFEED”,那么我们可以用相应的二进制表示。
这样真正传输的数据就是编码后的“001000011010000011101100100011” ,如果一篇文章很长,这样的二进制串也将非常的恐怖。
假设六个字母的频率如下:
合起来刚好是100%,那意味着我们完全可以重新按照赫夫曼树来规划它们。
下图是构造赫夫曼树的权值显示
而下图是将权值左分支改为0,右分支改为1.
此时,我们对六个字母用其从树根到叶子所经过路径的0或1来编码,可以得到如下定义:
我们将文字内容“BADCADFEED”再次编码,对比可以看见结果串变小了。
也就是说,我们的数据被压缩了,节约了17%的存储成本和传输成本。
那么当我们接受到这样压缩过的新编码时,我们应该如何解码呢?
首先,发送方和接收方必须要约定好同样的赫夫曼编码规则,当我们接收到10010100时,由约定好的赫夫曼树可知,1001得到第一个字母是B ,01是第二个字符是A。如下图所示
由上可知,这段代码还有个好处,就是能够精准确定一个编码代表的字符,不会因为编码杂糅而有误解的对应结果。