文章目录
一、树的基本概念
1.1树的定义
和我们在大自然中看到的树很像啊,就是一个树根,向长出很多分支,
然后每个分支又会长出很多分支。
数据结构中的树在逻辑上也呈现这样的特性:
从一个被称为根结点的结点出发,然后依次长出各个分支,
每个分支上又可以连接一个新的结点,这些分支我们称为边
上图中橙色结点,他们还有下一级分支,所以这种结点又称为分支结点
上图中绿色结点,他们下面已经没有分支了,所以我们称之为叶子结点
树中的各个结点,可以用于存放我们的数据元素(整数、字符、字符串等等)
树还有一种特殊的形态,就是空树,就是没有任何一个结点
我们在讲线性表时,也提过两个概念:前驱和后继,树中也有前驱和后继的说法,比如下图中A是C的前驱,C是A的后继
可以发现,在非空树中,只有根结点是没有前驱的,另外,只有叶子结点是没有后继的
注意!除了根结点外,任何一个结点有且仅有一个前驱。下面两个数据结构都不是树。
上面两种数据结构不能叫作树,但是我们可以把它称为“图”,这个我们后面会进行介绍
1.2树的基本用语
1.2.1结点之间的关系描述
举个例子,我们现在有一个家谱,爷爷生了三个孩子
现在请大家结合上图,来看下面几个问题:
祖先结点:从一个结点A出发,一直往上走,直到根节点为止,这条路径上经过的所有结点,都是结点A的祖先结点。
比如,我们从图中的“你”结点出发,到“爷爷”(根结点),经过“父亲”和“爷爷”,这里的“父亲”和“爷爷”结点就是祖先结点。
子孙结点:从一个结点出发,它的下面这些分支长出的结点,都是他的子孙结点。
对于下图中的“爷爷”结点,下面的结点都是他的子孙
双亲结点(父节点):一个结点的直接前驱就是它的父节点。
比如图中的“你”结点,父节点就是“父亲”这个结点呗。
兄弟结点:说明白点就是一个爹生的几个结点,他们之间就是兄弟关系
比如这里父亲、二叔、三叔就是兄弟关系,也就说二叔三叔是父亲的兄弟结点。
堂兄弟结点:在现实生活中,你叔叔的孩子不就是你的堂兄弟嘛。
比如,我们这里的“你”结点,和“你”在一层的GHIJ就是“你”结点的堂兄弟结点。
ps:有些教材认为只要在同一层就是堂兄弟结点,也就说上图,那个F也是“你”结点的堂兄弟结点,但按逻辑来说一般是兄弟结点,毕竟他和你是一个爹生的。而考试中为了避免歧义一般不会考堂兄弟结点,大家知道一下就好。
两个结点之间的路径:这个应该挺直观的,比如"爷爷"结点到“你”结点的路径如下:
需要注意的是,当我们在树中描述两个结点之间的路径时,我们所谓的路径是单向的,只能从上往下,或者说树里面这些边,其实是有向的边
路径长度:就是一个结点到另一个结点经过几条边
比如"爷爷"结点到“你”结点,路径长度为2
1.2.2结点、树的属性描述
结点的层次:所谓结点的层次,又可以称为结点的深度,
比如A这个结点在第一层,然后BCD在第二层
结点的高度:结点的高度,就是从下往上数,
比如KLM高度为1,
再比如EFGHIJ高度为2
树的高度:也可以说树的深度,其实就是指它总共有多少层,比如下图这棵树的高度就是4
结点的度:大白话就是这个结点有几个孩子。
比如B结点有两个孩子,度就是2
比如C结点只有一个孩子,度就是1
比如D结点有三个孩子,读就是3
还有就是,图中绿色结点没有分支了,所以这几个结点的度为0
需要注意,度大于0的结点,也就是我们之前说的分支结点,也可以叫作非叶子结点或者非终端结点
树的度:这个就是看各个结点度的最大值
比如下面这个树,树的度就是3
1.2.3有序树、无序树
有序树,所谓有序树就是各个子树,从左到右是有次序的,不能互换
如上图的家谱图,通常记载家谱时按照这些孩子的出生顺序从左到右排,如果结点次序交换就会导致结点的含义发生错误。
无序树,所谓有序树就是各个子树,从左到右是无次序的,可以互换
如上图,仅仅对国家行政区做一个划分,这些子树谁排前面其实都无所谓的。
1.2.4森林
森林:森林是m棵互不相交的树的集合。
如下图,我们现在下面三个互不相交的树就可以组成一个森林
但,如果给上图这三棵树连上同一个根结点,森林就变成了一棵树
森林和树之间的相互转换,也是我们考研中一个比较重要的考点,我们后面会详细介绍
1.2.5小结
1.3树的性质
考点1:结点数=总度数+1
结点的度数,就是某个结点有几个孩子(几个分支)。
每个分支下面都会连一个孩子,类似天线宝宝
但是需要注意的是,不是每个结点头上都有天线的,根节点是没有天线的。
考点2:度为m的树与m叉树的区别
所谓树的度,就是这个树里面各个结点度的最大值。
比如有一棵度为3的树,就意味着这棵树里面至少有一个结点度为3
因为是至少有一个结点度为3,就是说一个至少一个结点有3个孩子,那空树就不可能了。
而m叉树则是每个结点最多只能有m个孩子的子树
比如有一棵3叉树,我们只是规定了每个结点最多3个孩子,就算这棵树所有结点的孩子数量都小于3依旧是可以的。
另外,m叉空树也是可以的
考点3:度为m的树第i层最多有m(i-1)个结点(i>=1)
举个例子,假设度为3的树
第一层根结点肯定只有一个
第二层,因为度为3,所以根结点往下走最多3个
第三层,因为度为3,而第二层有3个结点,所以第三层最多9个
后面以此类推可以知道,第i层有m(i-1)个结点
到这里,我们还可以有一个推论,因为m叉树也规定了每个结点最多m个孩子,所以m叉树第i层也是最多m^(i-1)个结点
考点4:高度为h的m二叉树最多有(mh-1)/(m-1)个结点
在考点3中我们已经算出每层最多多少个结点,那么高度h的m二叉树就是把每层的加起来(一共h层),其实就是一个等比数列求和
等比数列求和公式:
代入到我们这里,第一层是1,倍数是m,共h层
sn=1(1-mh)/(1-m)= (m h-1)/(m-1)
考点5:高度为h的m叉树至少有h个结点
高度为h、度为m的树至少有h+m-1个结点
因为我们这里指的是高度h的m叉树,对于m叉树我们只规定每个结点孩子最多多少,
你达不到最多也没关系。所以我们只要满足高度为h即可,也就是最少结点为h
比如这里高度为4的3叉树,最少结点为4
如果换一个问法,对于高度为h,度为m的树至少多少个结点?
因为是度为m的树,所以至少保证有一个结点度为m,所以至少有h+m-1个结点
比如这里高度为4,度为3的树,至少4+3-1=6个结点
考点6:具有n个结点的m叉树的最小高度为
对于n个的结点m叉树,高度最小。其实就是要m叉树的每个结点有最多的孩子。
这样的话,这个树是扁平的、胖胖的那种,而不是垂直的,瘦高的那种。
现假设这个n个的结点m叉树,最小高度为h,
我们知道,高度为h的m叉树最多有(mh-1)/(m-1)个结点,
然后以此类推得到高度为h-1的m叉树最多有多少个结点
然后n是在这两个区间内的
二、二叉树的概念
2.1二叉树的定义和基本术语
二叉树是n(n>=0)个结点的有限集合:
对于二叉树只有两种情况:
1.为空二叉树,即n=0
2.为一个根结点和两个互不相交的被称为根的左子树和右子树组成,
左子树和右子树又分别是一棵二叉树
常考概念1:满二叉树
所谓满二叉树就是除了最下面一层的叶子结点,其他的所有分支结点都长了两个分支
满二叉树第一层1个结点,第二层2个结点,第三层22个结点…第h层是2h-1个结点
到第h层一共,1+2+4+…2h-1=1*(1-2h)/(1-2)=2h-1
对于满二叉树来说,结点的度要么为2要么为0
另外,如果如果我们把满二叉树的各个结点安装从上到下,从左到右的顺序从1开始编号。我们可以发现现在给一个编号i的结点,该结点的左孩子编号一定是2i,且右孩子编号是2i+1
比如,i=6,6号结点的左孩子为12(也就是2i),6号结点的右孩子13(2i+1)
而如果给一个编号i的结点,要找它的父节点,我们就让i/2,然后向下取整就可以。
比如i=6,6的父节点就是3(i/2)
再比如,i=7,7的父节点也是3(7/2=3.5,向下取整是3)
常考概念2:完全二叉树
当且仅当每个结点都与高度为h的满二叉树中编号为1~n的结点一一对应,称为完全二叉树
按大白话说就是,最后一层可能是不满的,但是要保证前面的结点必须是连续不能断的
满二叉树一定是完全二叉树,完全二叉树未必是满二叉树
对于完全二叉树,它最多只有一个度为1的结点,比如图中的6号结点
另外,由于完全二叉树结点的编号是可以和满二叉树对应上的,所以
现在给一个编号i的结点,该结点的左孩子编号一定是2i,且右孩子编号是2i+1
给一个编号i的结点,要找它的父节点,我们就让i/2,然后向下取整就可以。
这个性质和满二叉树是一样的。
最后,如果一个完全二叉树有n个结点,那么当结点的编号i<=n/2时,这些结点就是分支结点,如下图:
当结点的编号i>=n/2时,这些结点就是叶子结点,如下图:
注意,对于完全二叉树而说,结点是从上到下,从左到右,如果一个结点只有一个孩子,那肯定是左孩子,所以下图这种树就不是完全二叉树
常考概念3:二叉排序树
二叉排序树的两个重要特性:
1.左子树的所有结点的关键字都要比根节点的关键字小。
2.右子树的所有结点的关键字都要比根节点的关键字大
而左子树和右子树自身也是一棵二叉排序树,也就是说你单独把一棵子树拿出来看,它也是二叉排序树。
基于上述特性,如果想在二叉搜索树上找一个关键字就会变得很简单了。
比如你想找关键字为60的结点,我们从根节点出发,
发现根节点是19<60,所以60的结点肯定在19的右子树上。
发现50<60,所以60在50结点的右子树
发现66>60,所以60在66的左子树上
最后我们就成功找到了60结点。
如果想往二叉排序树里面插入一个结点怎么做呢?
现在有如下的二叉树,我们现在想插一个68的结点进去
从根节点出发,发现68是要比19大的,所以68应该是在19的右子树里面
68又比50大,所以68应该在50的右子树里面
68还是比66大,所以68应该在66的右子树里面
到了70这里,68<70,那68应该在70的左子树里面啊,而70是叶子结点(没有左右孩子了),所以就把68当作70的左孩子即可。
常考概念4:平衡二叉树
树上任一结点的左子树和右子树的深度之差不超过1
我一般叫这种树为胖树,因为比较胖,所以身体比较平衡
再来看一个非平衡二叉树
非平衡二叉树相对就是一棵瘦树
可以发现,上面介绍的平衡二叉树和非平衡二叉树都是二叉搜索树,相对而言,平衡二叉树的搜索效率是要比非平衡二叉树高的
举个例子,如果要找70这个节点,左边的平衡二叉树需要走两步
而右边的非平衡二叉树需要走6步
2.2二叉树的性质
2.2.1二叉树常考性质
二叉树常考性质1:
设非空二叉树中度为0、1、2的结点个数分别为n0,n1,n2,
则n0=n2+1
如何证明呢?
我们假设二叉树总结点个数为n
那么毫无疑问的,n=n0+n1+n2,因为二叉树就是度为0、1、2三种结点嘛,你把三个结点个数加起来肯定就是总数啊。
而树的结点个数应该是总度数+1,因为对于任何一种树来说,除了根节点,每个结点头上都有一个分支(联想天线宝宝)
所谓的总度数,其实就是这些分支的总数量,根节点头上没有分支
所以会有树的结点数=总度数+1
对于二叉树来说,度为1的结点,刚才我们说了有n1个,度为2的结点有n2个,每个度为2的结点会贡献2个度,所以总度数=n1+2n2
所以会有树的结点数=总度数+1=n1+2n2+1
我们把两个式子整合一下
二叉树常考性质2:
二叉树第i层至多有2i-1个结点(i>=1)
m叉树第i层至多有mi-1个结点(i>=1)
二叉树常考性质3:
高度为h的二叉树至多有2h-1个结点(满二叉树)
高度为h的m叉树至多有(mh-1)/(m-1)个结点
2.2.2完全二叉树常考性质
完全二叉树常考性质1:
第一个式子推导
比如下面这个高度为4完全二叉树,结点个数为12,结点个数介于高度为3的满二叉树(7个结点)和高度为4的满二叉树(15个结点)之间
那么推广开来,高度为h的完全二叉树,它的结点个数肯定是介于高度h-1的满二叉树和高度h的满二叉树之间,也就是2h-1<n<=2h-1
然后我们做一些简单的变化,然后取对数得到下图的式子
需要注意的是,这里不等式右边是可以取等号的,毕竟你高度为h的满二叉树也是高度为h的完全二叉树
第二个式子推导
高度为h-1的满二叉树共有2h-1-1个结点,那么你高度为h的完全二叉树至少得比高度为h-1的满二叉树多一个结点,如下图
也就是高度为h的完全二叉树至少得是2h-1个结点
而我们前面讲过,高度为h的完全二叉树至多2h-1-1个结点,
也就是说2h-1<=n<=2h
也就是说2h-1<=n<2h+1
然后两边取对
关于h到底是向上取整还是向下取整,你就看log2(),括号里面的是n还是n+1,n+1比较大就向上取整 ⌈ ⌉ ,n比较小就向下取整⌊ ⌋然后+1
完全二叉树常考性质2:
对于完全二叉树,可以由结点数n,推出度为0、1和2的结点个数n0、n1和n2
我们之前介绍过,完全二叉树最多只有一个度为1的结点,如下图
也就是说,对于完全二叉树来说,n1的值只能是0或者1
又因为n0=n2+1,所以n0+n2=2n2+1一定是一个奇数
那么我们就可以有如下结论
2.3二叉树的存储结构
2.3.1顺序存储
先来看一个完全二叉树,如下图
#define MaxSize 100
struct TreeNode{
ElemType value;//结点中数据
bool isEmpty;//结点是否为空
};
TreeNode t[MaxSize];
我们要把这个二叉树的各个结点在内存上连续的、顺序的存放,所以我们可以定义一个数组。
数组内每个元素TreeNode就是对应树里面的一个结点,然后每个结点里面包含一个ElemType就是你实际要存的那个数据元素,并且我们在每个结点定义中还加了一个bool型变量,来判断这个结点是否为空节点。
在初始化这个数组时,我们需要把所有的这些元素的isEmpty设为true
也就说,刚开始这些结点都是空的,里面没有数据。
for(int i=0;i<MaxSize;i++)
{
t[i].isEmpty=true;
}
接下来,我们可以按照从上自下,从左自右的顺序,也就是一层层的来依次把这些数据放到数组里面,如下图
细心的同学会发现,我们这里t[0]的位置是保持了空缺,因为我们在给这个结点编号的时候,是从1开始编号的,我们这里为了保持一致,也是从数组1号下标开始用。
ps:你要想从t[0]位置开始存储也可以
由于数组长度是有限的,它是一个静态数组,所以这个数组里面能包含的结点数量也会有一个上限。
接下来,如果我们希望能够用我们这种顺序存储的方案,能够反映各个结点的前驱与后继的关系,也就是说我们至少要实现下图的几种基本操作
由于我们这里是完全二叉树,根据完全二叉树性质,我们知道i号结点的左孩子编号2i,右孩子节点编号2i+1
比如这里的5号左孩子是10号,右孩子11号
对于i号结点的父节点,我们就除2再向下取整
⌊
\lfloor
⌊ i/2
⌋
\rfloor
⌋
相当于是给你一个左右孩子找父亲嘛,
你左孩子是2i,右孩子是2i+1,两个除2向下取整就是i
如果要计算i所在层次的话,就是 ⌈ \lceil ⌈ l o g 2 ( n + 1 ) log_{2}(n+1) log2(n+1) ⌉ \rceil ⌉ 或者 ⌊ \lfloor ⌊ l o g 2 n log_{2}n log2n ⌋ \rfloor ⌋ +1
这些知识点在之前的小节都讲解过,可以自行翻看。
总之,我们可以把一个完全二叉树像这样一层一层的把这些结点放到数组中,然后可以利用数组下标来反映这些结点之间的逻辑关系
那么问题来了
如何判断i号结点是否有左孩子,因为左孩子编号是2i嘛,那么2i<=n肯定是有左孩子的。
然后右孩子的判断原理也是类似的,就是判断2i+1<=n
另外,可以用i是否大于n/2来判断该结点是否是叶子结点。
上面我们谈谈的是完全二叉树的存储,但如果给的树不是完全二叉树怎么办?
我们依旧按照刚才的思路,也就是一层一层的给这些结点编号1-8
可以发现,此时这些结点之间的编号已经无法再反映出结点之间的逻辑关系了,所以一个普通二叉树是不能按照完全二叉树的方法存储的。
为了解决上述问题,我们可以把这个普通的二叉树,让它的编号和完全二叉树一一对应,如下图:
图中透明的结点是实际不存在的,按照这样的编号,把结点放到对应位置
那么就可以像完全二叉树那样,通过结点之间的编号来确定结点之间是否存在某种逻辑关系。
但是采用这种方案,我们要判断一个结点是否拥有左孩子,我们就不能像之前说的那样,用树的结点编号和结点的总数n来比较判断了。
所以,对于一个非完全二叉树来说,我们要判断一个结点是否有左孩子或者右孩子,我们就只能通过我们刚开始创建的那个字段isEmpty进行判断,如果是true就是空结点,否则就不是。
举个例子,现在你要判断5号结点是否有左孩子,而左孩子是2i,
那么我们就看2*5=10号位置的isEmpty即可,
我们发现10号位置的isEmpty是true,也就是空结点,那么5号结点就不存在左孩子
显而易见,采用这种顺序存储的方式来存储一棵二叉树,那么必然有大量的空间是被浪费的。
最坏的情况是,如果一棵高度h的二叉树所有结点都只有右分支,那么至少要有2h-1个结点,如下图
所以,我们这里得出结论:二叉树的顺序存储结构只适合存储完全二叉树
2.3.2链式存储
对于左边这棵二叉树来说,如果用链式存储实现,那么就是右图这样。
一棵树的每个结点都有一个data域来存放实际的数据元素,然后还有两个指针,两个指针分别指向该结点的左右孩子。如果一个结点没有左(右)孩子,那么就把左(右)指针设置为null即可。
由于每个结点都有2个指针域,因此,如果一个二叉树如果有n个结点,那么总共有2n个指针,2n个指针域。
除了根节点外,其他每个结点头上都有一个指针,也就是说总共有n-1个结点头上会连一个指针。因此,这2n个指针域中应该是有2n-(n-1)=n+1个指针指向null。
注1:这些空指针域是可以利用起来,来构造所谓的线索二叉树,这个我们后面详细介绍。
注2:由于每个结点有这样的两个左右指针,所以我们会把这样的实现方式称为二叉链表。
struct ElemType{
int value;
};
typedef struct BiTNode{
ElemType data;
struct BiTNode *lchild,*rchid;
}BiTNode,*BiTree;
//定义一棵空树
BiTree root =NULL;
//插入根节点
root=(BiTree)malloc(sizeof(BiTNode));
root->data={1};
root->lchild=NULL;
root->rchild=NULL;
//插入新结点
BiTNode* p=(BiTNode*)malloc(sizeof(BiTNode));
p->data={2};
p->lchild=NULL;
p->rchild=NULL;
root->child=p;//作为根节点的左孩子
我们这里假设每个数据元素ElemType里面只包含一个int型变量,刚开始我们只需要声明一个指向根节点的指针,将其初始化为null,也就意味着此时它是一棵空树。
接下来我们用malloc函数申请一个根节点的空间,然后根节点里面我们给它存入数字1。并且让根节点的左右指针指向NULL。
再往下,我们用同样的方式malloc一个新结点,这个结点里面存2,然后我们把根节点的左孩子指针指向当前结点p,这样p就成了根节点的左孩子。
类似的方法就可以插入其他结点。。。这样就完成了一棵二叉树的构建。
问题来了,如果给出一个结点p,想找到这个结点左(右)孩子,直接顺着它的左(右)指针往下找就行。但是如果要找这个结点的父节点,就只能从树的根节点开始遍历,看哪个结点的左(右)孩子是p。
显然,如果一个二叉树比较庞大,给一个结点要找它的父节点就非常耗时了,所以,如果在你的应用场景中经常需要找父节点,我们就可以在二叉树的结构体再加一个指针,指向父节点。由于总共三个指针,这样的实现方式,又称为三叉链表。
//二叉树的结点(链式存储)
typedef struct BiTNode{
ElemType data;//数据域
struct BiTNode *lchild,*rchild//左右孩子指针
struct BiTNode *parent;//父节点指针
}BiTNode,*BiTree;
考研中一般是考不带父节点的情况,如何遍历在下小节进行详细探讨
2.3.3小结
三、二叉树的遍历和线索二叉树
3.1二叉树的先中后序遍历
先序遍历(根-左-右)先访问根节点,再访问左子树,最后访问右子树
中序遍历(左-根-右)先访问左子树,再访问根节点,最后访问右子树
后序遍历(左-右-根)先访问左子树,再访问右子树,最后访问根节点
例一、
例二、
例三、
例四、
注:这里算术表达式的“分析树”前中后序遍历出的结果就对应我们之前讲的栈的应用那块的前中后缀表达式,只不过这里中缀表达式需要加对应的界限符(也就是括号)
代码实现:
typedef struct BiTNode{
ElemType data;
struct BiTNode *lchild,*rchild;
}BiTNode,*BiTree
先序遍历代码:
//先序遍历
void PreOrder(BiTree T){
if(T!=NULL){
visit(T);//访问根节点
PreOrder(T->lchild);//递归遍历左子树
PreOrder(T->rchild);//递归遍历右子树
}
}
中序遍历代码:
//中序遍历
void PreOrder(BiTree T){
if(T!=NULL){
PreOrder(T->lchild);//递归遍历左子树
visit(T);//访问根节点
PreOrder(T->rchild);//递归遍历右子树
}
}
后序遍历代码:
//后序遍历
void PreOrder(BiTree T){
if(T!=NULL){
PreOrder(T->lchild);//递归遍历左子树
PreOrder(T->rchild);//递归遍历右子树
visit(T);//访问根节点
}
}
求一棵树的深度(应用题)
一棵树分为根节点、左子树和右子树,要求一棵树的深度/高度,应该先递归求出左子树高度,再求右子树高度,接下来选择左子树和右子树中更高的那个+1(加一是因为还要算上根节点的高度)即可
int treeDepth(BiTree T){
if(T==NULL){
return 0;
}
else{
int l=treeDepth(T->lchild);
int r=treeDepth(T->rchild);
//树的深度=Max(左子树深度,右子树深度)+1
return l>r?l+1:r+1;
}
}
3.2二叉树的层次遍历
也称为层序遍历,也就是一层一层的遍历二叉树的各个结点。
如上图的二叉树,如果我们用层序遍历的话,最终得到的遍历序列应该是ABCDEFGHIJKL
算法思想:
1.初始化一个辅助队列
2.根结点入队
3.若队列非空,则队头结点出队,访问该结点,并将其左右孩子入队尾(如果有左右孩子的话)
演示流程如下:
根结点A入队
队头结点出队,将该结点的左右孩子入队
队头结点出队,将该结点的左右孩子入队
队头结点出队,将该结点的左右孩子入队
后面都是一样的,以此类推…
代码实现:
//层序遍历
void LevelOrder(BiTree){
LinkQueue Q;
InitQueue(Q);//初始化辅助队列
BiTree p;
EnQueue(Q,T);//将根结点入队
while(!IsEmpty(Q)){//队列不为空则循环
DeQueue(Q,p);//队头结点出队
visit(p);//访问出队结点
if(p->lchild!=NULL)
EnQueue(Q,p->lchild);//左孩子入队
if(p->rchild!=NULL)
EnQueue(Q,p->rchild);//右孩子入队
}
}
3.3由遍历序列构造二叉树
是否给出一个二叉树的前/中/后序就能确定唯一的二叉树呢?
答案是否定的,如下图
如上三张图可见,如果只给出一棵二叉树的前/中/后序遍历序列的一种,是无法唯一确定一棵二叉树的
但是,如果给下面三种遍历序列其中两种,就可以根据遍历序列得到一棵唯一确定的二叉树
ps:一定要有中序遍历序列才可推出二叉树
3.3.1前序+中序遍历序列
前序序列中最先出现的结点肯定是根节点,所以我们可以确定根节点在中序遍历当中的位置。而由于中序序列根节点左边出现的结点是左子树,右边出现的结点是右子树
举个例子,现在给出前序遍历序列和中序遍历序列
构造的二叉树就如下图:
再看一个例子:
3.3.2后序+中序遍历序列
这个原理和前面一样,由于后序序列最后一个结点一定是根结点,所以我们可以定位到中序序列中根节点位置,然后中序序列中根节点左边是左子树,右边是右子树。
举个例子:
3.3.3层序+中序遍历序列
对于二叉树进行层序遍历时,第一个被访问的肯定是根节点,如图
然后被访问的,应该是左子树的根结点,然后是右子树的根节点
所以,思路就是层序遍历序列第一个确定根节点,然后定位到中序遍历序列的根节点,从而划分出左子树和右子树,然后再对左右子树进行下一层的划分,如下图所示
举个例子:
再看一个例子:
3.3.4小结
3.4线索二叉树的概念
3.4.1引子
假设我们现在给如下的二叉树,并给出它的中序遍历序列:
我们知道,树这种数据结构是一种非线性关系,但是由一棵树得到的遍历序列却是线性关系。
啥意思?比如这里的中序遍历序列DGBEAFC,,那么你确定了D就确定了中序遍历序列D前面的是G,后面的是B。
也就是和线性表一样,树的遍历序列中,除了第一个元素没有前驱,最后一个元素没有后继,其他元素都有一个前驱和后继。
注意,这里我说的是遍历序列的前驱和后继,不是树这个结构本身的前驱和后继(树结点本身只有一个前驱和多个后继),这里讨论的是遍历序列中的前驱和后继。
那么现在我提出两个问题:
问题1:任给二叉树某个结点的指针,能否遍历整个二叉树呢?
我们知道如果要对一个二叉树进行遍历,我们必须得从根结点出发才行。
但是在某些场景中,我们只能知道某一个结点的指针,比如下图中的G结点(非根结点指针)。
如果只给G结点,我们显然是不能由一个G遍历整个树的,因为给一个G指针,我们只能知道G的左右孩子,而不能知道G结点的双亲结点。
但是我们回忆一下线性表,给一个线性表其中任一个元素的指针,你肯定可以找到这个元素的后续所有元素的。假设下面的中序遍历序列是一个线性表,给指向E的指针,我们肯定可以找到E后面的所有元素的。但对于二叉树来说是做不到这样的事情的。
问题2:任给二叉树某个结点的指针,能否找到这个结点在中序遍历序列中的前驱呢?
显然,如果只给我们指向F结点的指针,我们肯定没法找到F在中序遍历序列中的前驱,也就是A结点。
因为你给指向F结点的指针,只能找到F的左右孩子,你不可能在树里面往上找啊。
解决方案:从根节点出发,重新进行一次中序遍历,指针q记录当前访问的结点,指针pre记录上一个被访问的结点,当q=p时,pre为前驱
我们在中序遍历代码中,当访问一个结点时,会对这个结点进行visit()操作,
那么当前正在visit的结点就是我们的q,中序遍历的过程中,d结点是第一个被visit的结点,而D这个结点在中序遍历序列中是没有前驱的,所以pre指向null
对比一下,q和p,并没有指向同一个结点,所以我们需要继续往下visit,而在下一个结点被visit()之前,我们让pre指向q
然后q指向下一个被visit()结点,此时pre指针指向的结点就是q指针指向结点的中序遍历序列的前驱结点。
所以用这样的思路,我们可以不断的让q指向后一个被访问的结点,然后pre指针也跟着依次往后移,如下动图所示:
当q和p指向了同一个结点,此时pre指向的结点就是q指向结点在中序遍历序列中的前驱结点。
当然,如果给定一个结点的之前p,要找到p所指结点在中序遍历序列中的后继,那我们就让q和pre再往后走一次就行了
总结一下这种算法的思路:
所以,在一棵普通的二叉树中,我们给定一个结点找它在中序遍历序列的前驱和后继非常不方便,所以就有了下面要介绍的线索二叉树。
3.4.2中序线索二叉树
我们这里用灰色字体给各个结点标上了它在中序遍历序列中被访问的序号,如上图所示。
现在,我们将这棵二叉树进行改造,让它可以更方便的查找序列中的前驱和后继或者进行遍历操作。
我们之前说过,对于一个有n个结点的二叉树来说,它有n+1个空链域,不用白不用,我们就用这些空链域来记录前驱和后继的信息。
关于有n个结点的二叉树,有n+1个空链域的证明:
n个结点有2n个指针,也就是2n个指针域。
除了根结点以外,每个结点头上都有一个指针,也就是说总共有n-1个结点头上会连一个指针。
那么剩下的2n-(n-1)=n+1个指针就是空的了。
我们用这些空链域来指向各个结点的前驱和后继。
举个例子,G这个结点是第二个被访问到的,它在中序遍历序列的前驱是D结点,那么我们让G左孩子指针指向D,这样就可以通过G的左孩子指针找到它的中序遍历序列的前驱D了。
然后后继也是类似的,我们让G的右孩子指针指向G在中序遍历序列中的后继B
再来看D这个结点,D是中序遍历中第一个被访问的结点,它没有前驱,就让它的左孩子指向null,
继续看E结点,E在中序遍历序列中前驱是B,后继是A,那么我们就让E的左孩子指针指向B,右孩子指针指向A
继续看F结点,F在中序遍历序列中前驱是A,后继是C,那么我们就让F的左孩子指针指向A,右孩子指针指向C
最后看C结点,C在中序遍历序列中前驱是F,没有后继。需要注意,这里C左孩子指针是已经指向F了,你就不用改了(只改空链域)。C的右孩子指针指向null。
所以,按照中序遍历的序列对这棵二叉树进行所谓的线索化后,得到就是上图的二叉树。
好,那么我们完成一个二叉树的线索化之后,怎么找到它的前驱和后继呢?
举个例子:如何找到G在中序遍历序列的后继结点?
我们看G结点,因为它的后继线索指向B,那么B就是G的后继结点呗。
而给定一个结点找到它的后继是很方便的话,那就意味着从一个结点出发,往后遍历这件事也是可以的。
ps:可能会有同学说,你现在G结点的右孩子指针正好指向它的后继,如果对于一个右孩子指针就是指向它的右孩子的结点,比如A结点,你怎么找后继?
——这个问题我们后面会进行解决,我们当前只要知道,我们把一棵二叉树线索化之后,我们找一个结点的前驱后继变得更方便了,当前只要知道这个就行了。
3.4.3线索二叉树的存储结构
那么线索二叉树怎么存储呢?和普通二叉树有什么区别?
在普通二叉树中,我们只定义了数据域data、左孩子指针lchild,rchild
//二叉树的结点(链式存储)
typedef struct BiTNode{
ElemType data;
struct BiTNode *lchild,*rchild;
}BiTNode,*BiTree
但是我们前面说了,线索二叉树中我们的左孩子指针和右孩子指针它有可能指向的不是它的左右孩子,而是它的遍历序列,那么我们这里就额外设立两个标志位ltag和rtag
//线索二叉树结点
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild,*rchild;
int ltag,rtag;//左右线索标志
}ThreadNode,*ThreadTree;
ltag0,表示指针指向左孩子,ltag1,表示指针指向线索
rtag0,表示指针指向右孩子,rtag1,表示指针指向线索
如上图,我们用实线表示左右孩子指针,黄色虚线表示前驱线索,紫色虚线表示后继线索。
注:我们之前说过,二叉树可以称为二叉链表。这里的线索二叉树又可以称为线索链表。
所以上图的二叉树,从存储的角度来看,就是下图这样:
3.4.3先序线索二叉树
原理和中序线索二叉树类似,对于先序线索二叉树来说,我们先把它的先序遍历序列写出来,然后根据这个遍历序列把二叉树线索化
从存储视角看,它就是下面这个样子:
3.4.4后序线索二叉树
原理和之前一样,只不过是根据后序遍历序列来确定各个结点的前驱和后继的关系。
从存储视角看,它就是下面这个样子:
3.4.5小结
3.5二叉树的线索化
3.5.1引子
当我们给定一个结点的指针p时,怎么找到这个结点在中序遍历当中的前驱呢?
比如下图的树,给你F结点的指针p,怎么通过p找到A结点?
在上小节已经和大家说明了,就是对这个二叉树再重新进行一次中序遍历,当我们访问一个结点时,我们用另一个指针pre指向当前访问的结点的前驱结点
而找一个结点的中序前驱,其实代码的核心逻辑和中序遍历是一样的
//中序遍历
void InOrder(BiTree T){
if(T!=NULL){
InOrder(T->lchild);//递归遍历左子树
visit(T);
InOrder(T->rchild);//递归遍历右子树
}
}
在中序遍历的时候,我们是先中序遍历左子树,然后visit访问根节点,最后再中序遍历右子树。
我们可以在visit函数中实现对q和pre的处理
//辅助全局变量,用于查找结点p的前驱
BiTNode* p//p指向目标结点
BiTNode* pre=NULL;//指向当前访问结点的前驱
BiTNode* final=NULL;//用于记录最终结果
//中序遍历
void InOrder(BiTree T){
if(T!=NULL){
InOrder(T->lchild);//递归遍历左子树
visit(T);
InOrder(T->rchild);//递归遍历右子树
}
}
//访问结点q
void visit(BiTNode* q)
{
if(q==p){//当前访问结点正好是p结点
final=pre;//找到p的前驱
}
else
{
pre=q;//pre指向当前访问结点
}
}
现有如下一棵树,p指向F结点,来看一下上述代码运行流程:
我们从A这个根节点出发,进行中序遍历。按照中序遍历规则,第一个被visit的结点应该是D结点
执行visit()代码,发现当前q指向结点和p指向结点并不一致,所以,pre=q
接下来,按照中序遍历的规则,第二个被visit的结点应该是G结点
由于q和p指向的不是同一个结点,因此,pre=q
接下来,按照中序遍历的规则,第三个被visit的结点应该是B结点
由于q和p指向的不是同一个结点,因此,pre=q
后续过程是一样的…
总之,每当一个结点被visit时,我们都要判断这个结点是不是我们要找的结点:如果不是,就让pre指向q指向的结点
当第一个结点被visit的时候,发现当前访问结点和我们p指针指向的结点是一样的。
也就是说,当前pre指针指向的结点就是p的前驱结点。我们用全局变量final记录我们找到的这个前驱结点。
这个地方,我们从中序遍历出发,过渡到这个找前驱的算法,就是为了让大家更好的理解该算法和中序遍历之间的关系
回到该小节要探讨的主要问题,怎么对二叉树进行线索化?
当我们要对二叉树的某个结点进行线索化时,其实就是要把这个结点的左孩子指针连上它的前驱,或者右孩子指针连上它的后继。
刚才这种算法的思想,我们可以迁移到二叉树的线索化中,请看下面三个小结。
3.5.2中序线索化
//全局变量pre,指向当前访问结点的前驱
ThreadNode* pre=NULL;
//线索二叉树结点
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild,*rchild;
int ltag,rtag;//左右线索标志
}ThreadNode,*ThreadTree;
//中序线索二叉树T
void CreateInThread(ThreadTree T){
pre=NULL;//pre初始化为NULL
if(T!=NULL){//非空二叉树才能线索化
InThread(T);//中序线索化二叉树
if(pre->rchild==NULL)//这里的if判断你不加也没事,因为最后一个结点的右孩子指针肯定是NULL
pre->rtag=1;//处理遍历的最后一个结点
}
}
void visit(ThreadNode* q){
if(q->lchild==NULL){//左孩子为空,建立前驱线索
q->lchild=pre;
q->ltag=1;
}
if(pre!=NULL&&pre->rchild==NULL){
pre->rchild=q;
pre->rchild=1;
}
pre=q;
}
//中序遍历二叉树,一边遍历一边线索化
void InThread(ThreadTree T){//其实就是中序遍历的函数换个名字
if(T!=NULL){
InThread(T->lchild);//中序遍历左子树
visit(T);//访问根节点
InThread(T->rchild);//中序遍历右子树
}
}
初步建成的线索二叉树结构如下,
ltag和rtag默认是0,表示这些指针此时指向的是它的左右孩子,也就是没有被线索化
老规矩,来看一下上述代码的运行流程:
按照中序遍历的规则,第一个被visit的结点应该是D这个结点
而D结点没有前驱,所以我们在全局变量中一开始让pre指向NULL
而由图可得,当前这个图中q指向的结点D是没有左孩子的,我们应该把它的左孩子指针线索化,也就是让q->lchild指向pre,即q->lchild=pre。然后,我们还需要修改这个结点的ltag=1,表示左孩子是线索。
然后,让pre指向当前q指向的结点
然后访问下一个结点,就是G这个结点
同样的,由于G结点的左孩子指针是空的,我们让G的左指针线索化即q->lchild=pre,然后把对应的tag值设为1。然后pre指向q所指。
接下来访问下一个结点B,B结点的左右孩子指针都是非空的
那我们就看一下它的前驱,也就是pre所指结点G,发现G的右孩子指针是空的,所以把G结点的右孩子指针线索化,指向它的后继(也就是当前q所指的B)。然后不要忘记,线索化之后把rtag设为1.
后面的过程不再赘述,大家自己看下面的动图
当我们访问完最后一个结点C时,我们还是会让pre指向q的
而在C结点之后,就不会再有任何结点被访问。
但是现在存在一个问题,就是最后一个结点的右孩子指针本来就是空的,我们应该线索化。
按照上述代码逻辑,没有后续就点就没法将C的右孩子指针线索化了。所以我们这里采取的pre指针是全局变量,我们可以在别的函数中对pre指针进行处理,让pre的右孩子指向NULL,然后rtag设置为1
下面是一个完整的中序线索化处理代码
3.5.3先序线索化
原理和中序线索化类似,先序线索化本质就是一个先序遍历,只不过一边遍历一边对这些结点进行线索化,线索化在visit函数中进行
//全局变量pre,指向当前访问结点的前驱
ThreadNode* pre=NULL;
//先序遍历二叉树,一边遍历一边线索化
void PreThread(ThreadTree){
if(T!=NULL){
visit(T);//先处理根结点
PreThread(T->lchild);//访问左子树
PreThread(T->rchild);//访问右子树
}
}
void visit(ThreadNode *q){
if(q->lchild==NULL){//左子树为空,建立前驱线索
q->lchild=pre;
q->ltag=1;
}
if(pre!=NULL&&pre->rchild==NULL){//建立前驱结点的后继线索
pre->rchild=q;
pre->rtag=1;
}
pre=q;
}
来看一下上述代码运行流程:
刚开始pre指向NULL
然后第一个被visit的结点是A结点,由于A的左右孩子不为空,所以不需要进行处理,只需要让pre指向当前的这个结点q就可以了,也就是pre指向
接下来,q访问下一个结点B,然后以此类推…具体过程不再分析,这和中序遍历是一样的,只不过我们访问各个结点的顺序发生了改变罢了。
现在我们来看一下先序线索化中存在的一个问题:
现在假设我们已经访问到正在visit的第三个结点,那么pre应该指向第二个节点,如下图
按照visit函数的处理逻辑,应该让第三个结点的左孩子指针线索化,也就是指向pre
然后,让pre指针指向当前访问的这个结点
这是我们在vist第三个结点发生的事情,我们回到代码,我们在visit完第三个结点之后,我们接下来是要处理第三个结点的左孩子的
但是刚才我们已经把它的左孩子指针指向了B这个结点,所以接下来如果要访问它的左子树,就会导致q结点再次回到B,然后对B结点和D结点的无限循环访问就开始了。
所以这样处理是有点问题的,我们把这个问题称为先序线索化“爱的魔力转圈圈”问题
如何处理呢?我们把左孩子指针线索化之后,这不是还有一个tag变量吗?我们就是通过tag变量来判断他的左孩子指针指向的到底是不是他真正的左孩子。
所以我们这里把PreThread()函数稍微改一下,加一个判断条件
只有ltag==0时,我们才会对它的左指针指向的树进行线索化
void PreThread(ThreadTree T){
if(T!=NULL){
visit(T);//先处理根节点
if(T->ltag==0)
{
PreThread(T->lchild);
}
PreThread(T->rchild);
}
}
上面就是先序线索化比较容易错的点,因为我们左孩子指针一旦被线索化之后,它所指向的结点就是当前访问结点的前驱结点。如果我们把前驱线索当作左孩子去访问,就会一直绕圈死循环了。
下面是先序线索化的完整代码
3.5.4后序线索化
最后来看后续线索化,和前面唯一的区别就是,我们是先处理左子树、然后是右子树、最后是根节点。因为后续遍历的顺序就是左右根。
后序线索化中,不会出现先序线索化里面的转圈问题,因为我们在访问一个结点q时,我们肯定是已经访问完q的左孩子右孩子了,所以,我们访问完这个结点后,不可能再回头去访问他的左孩子所指向的那棵子树
具体代码如下
//全局变量pre,指向当前访问结点的前驱
ThreadNode* pre
//后序线索化二叉树T
void CreatePostThread(ThreadTree T){
pre=NULL;
if(T!=NULL){//
PostThread(T);//后续线索化二叉树
}
}
void visit(Thread* q){
if(q->lchild==NULL){//左子树为空,建立前驱线索
q->lchild=pre;
q->ltag=1;
}
if(pre!=NULL&&pre->rchild=NULL){
pre->rchild=q;//建立前驱结点的后继线索
pre->rtag=1;
}
pre=q;
}
//后续遍历二叉树,一边遍历,一边线索化
void PostThread(ThreadTree T){
if(T!=NULL){
PostThread(T->lchild);//后序遍历左子树
PostThread(T->rchild);//后续遍历右子树
visit(T);//访问根节点
}
}
3.5.5小结
3.6在线索二叉树中找前驱后继
上小节我们介绍了如何将二叉树线索化,而我们建立线索的初衷就是更方便的从一个结点开始,找到它的前驱或后继。
该小结我们就会探讨,当我们有了线索二叉树后,怎么找到它的前驱和后继
3.6.1中序线索二叉树找后继
比如下面有一棵二叉树已经被中序线索化了
我们现在要求找出指定结点*p的中序后继next
那么有如下2种情况
情况1:p->rtag= =1,则next=p->rchild
也就是该结点右孩子指针已经被线索化了,也就是rtag==1,那么它的右孩子指针就是指向它的中序后继的,所以next就是p的右孩子指针指向的结点。
情况2:如果p指向结点的右指针并没有被线索化,怎么找到它的后继呢?
如下图,这里p指向的是B结点
B结点是有右孩子的,所以p->rtag==0
既然是找中序后继,也就是说我们要按照中序遍历来看一个结点它后面被访问的结点。而中序遍历的访问顺序是"左根右",现在已经知道了指定结点p肯定是有右孩子
所以访问p结点之后,应该是要继续中序遍历P的右子树
而按照中序遍历规则,P的右子树当中第一个被访问的应该是右子树最左边的结点
代码如下:
//找到以P为根的子树,第一个被中序遍历的结点
ThreadNode *Firstnode(ThreadNode* p){
//循环找到最左下的结点(并不一定)
while(p->ltag==0){
p=p->lchild;
}
return p;
}
//在中序线索二叉树中找到结点p的后继结点
ThreadNode *Nextnode(ThreadNode* p){
//右子树中最左下的结点
if(p->rtag==0){
return Firstnode(p->rchild);
}
else{
return p->rchild;//rtag==1直接返回后继线索
}
}
3.6.2中序线索二叉树找前驱
现有如下中序线索二叉树
我们现在要求找出指定结点*p的中序前驱pre
那么有如下2种情况:
情况1:若p->ltag==1,则pre=p->lchild
如果P所指向的结点的左孩子已经被线索化,那么它的左指针指向的结点就是它的前驱。
情况2:若p->ltag= =0
ltag==0,说明肯定是有左孩子的,比如B结点
B结点是有左孩子的,那么按照中序线索化规则,B的前驱应该是左子树最右边的一个结点。
代码如下:
//找到以p为根的子树中,最后一个被中序遍历的结点
ThreadNode *Lastnode(ThreadNode *p){
//循环找到最右下结点(不一定是叶结点)
while(p->rtag==0){
p=p->rchild;
}
return p;
}
//在中序线索二叉树中找到结点p的前驱结点
ThreadNode *Prenode(ThreadNode *p){
//左子树最右下结点
if(p->ltag==0){
return Lastnode(p->lchild);
}
else{
return p->lchild;//ltag==1直接返回前驱线索
}
}
3.6.3先序线索二叉树找后继
在先序线索二叉树中找到结点*p的先序后继next
有以下两个情况:
情况1:p->rtag= =1,则next=p->rchild
这个很好理解,你rtag==1就说明右孩子已经被线索化了,也就是右孩子指针指向的结点就当前结点先序序列的后继
情况2:p->rtag==0
这种情况就说明p结点肯定有右孩子,但是p结点有没有左孩子是还不确定的。
情况2.1:p既有左孩子也有右孩子
情况2.2:p没有左孩子,有右孩子
3.6.4先序线索二叉树找前驱
在先序线索二叉树中找到结点*p的先序前驱pre
情况1:p->ltag==1,则next=p->lchild
p->ltag=1就说明p指向结点的左孩子结点就是所求
情况2:p->ltag==0
当ltag=0,说明p肯定是有左孩子的
根据先序遍历规则“根左右”,则P的左子树和右子树的所有结点都只能是P的后继,不可能是P的前驱。所以,不可能从P的左右子树找到P的前驱的。
我们的线索二叉树只有指向孩子结点的指针,不可能往回找,所以这种情况下我们是找不到p的先序前驱的,除非你给一个根节点,从头开始重新遍历。
当然了,如果你是三叉链表,有指向父节点的指针,情况又不一样了:
3.6.5后序线索二叉树找前驱
在后序线索二叉树中找到结点*p的后序前驱pre
情况1:pre->ltag==1,则pre=p->lchild
情况2:pre->ltag==0
ltag=0说明它肯定是有左孩子的,但是P所指结点有没有右孩子,我们不知道
情况2.1:p既有左孩子也有右孩子
情况2.2:p只有左孩子
3.6.6后序线索二叉树找后继
在后序线索二叉树中找到结点*p的后序前驱next
情况1:p->rtag==1,则next=p->rchild
情况2:p->rtag==0
这种情况说明p结点肯定是有右孩子的,按照后序规则“左右根”,P结点的左子树和右子树所有结点只能是p的前驱,不可能是它后继的。
所以,没法在它的左右子树中找到它的后续后继,除非你进行完整的后续遍历。
当然了,如果你是三叉链表,有指向父节点的指针,情况又不一样了:
3.6.7小结
找前驱对ltag分类讨论,找后继对rlag分类讨论
四、树、森林
4.1树的存储结构
4.1.1树的逻辑结构回顾
而对于一棵树来说,它每个分支结点的子树数量是不确定的,你只靠数组下标是没法反应逻辑关系的。
如何解决这个问题呢?在树当中,除了根节点之外,其他任何结点有且仅有一个双亲(父节点),我们可以在数组中记录每个结点的父节点下标
如下图:
比如B的双亲是A,A下标是0,那么我们可以记录B的parent=0
4.1.2双亲表示法(顺序存储)
4.1.3孩子表示法(顺序+链式存储)
4.1.4孩子兄弟表示法(链式存储)
4.2树、森林和二叉树的转换
4.2.1树 转 二叉树
4.2.2森林 转 二叉树
4.2.3二叉树 转 树
4.2.4二叉树 转 森林
4.2.5小结
4.3树和森林的遍历
4.3.1树的遍历
这个和二叉树遍历基本一致
4.3.1.1树的先根遍历
4.3.1.2树的后根遍历
ps:后根和先根又称深度遍历
4.3.1.3树层次遍历
动态示意图如下
4.3.2森林的遍历
4.3.2.1森林的先序遍历
4.3.2.2森林的中序遍历
五、树和二叉树的应用
5.1哈夫曼树
5.1.1带权路径长度
结点的权:有某种含义的数值
结点的权,就是我们可以给树里面的各个结点附上一个权值,可以用这个数值来表示某种含义,比如根据数字大小规定重要性
结点的带权路径长度:从树的根到该结点的路径长度(经过的边数)与该结点上权值的乘积
举个例子:下图中从根结点到权值为3的结点,带权路径长度=3*3=9
树的带权路径长度:树中所有的叶结点的带权路径长度之和
注意,树的带权路径长度只算叶子结点
下面是四个计算树的带权路径长度的例子
5.1.2哈夫曼树的定义
我们从上小节知道了WPL,也就是树的带权路径长度是怎么计算的。
而在有n个带权叶结点的二叉树中,其中带权路径长度最小的二叉树就是哈夫曼树,也称为最优二叉树
5.1.3哈夫曼树的构造
那给定几个叶子结点,我们如何构造包含这几个叶子结点的哈夫曼树呢?
比如我们现在给权值1,2,2,3,7的结点,构造哈夫曼树的过程如下
每次从集合中选两个权值最小的结点,让它们成为兄弟,那么上面这个集合中,我们是选择权值最小的a结点和c结点。在a和c结合称为一棵新树后,我们把这两个结点的权值之和作为新树的根节点的权值。
ps:哈夫曼树构造过程中,你选的两个权值最小的结点合并时,哪个放左边,哪个放右边都是无所谓的。(比如上图我选择a作为左孩子,你也可以选择c作为左孩子,这个无所谓的)
然后继续从集合中选取两个根节点权值最小的结点的两棵树,让它们成为兄弟。我们这里选择e和刚才生成的树(你也可以选择e和b先结合),让它们两个变成兄弟之后,我们需要把这两个结点的权值之和变成新根结点的权值。
后面以此类推,最后结果如下图
(1)根据上述过程,我们知道,最初的这些初始结点最终都会成为叶子结点,并且权值越小的结点到根节点的路径长度越长。
(2)另外,由于一共n个结点,我们每次是让两个树进行结合,那么一共是要合并n-1次。而每次合并都会导致增加一个分支结点,所以哈夫曼树的结点总数应该是2n-1
(3)哈夫曼树中不存在度为1的结点
(4)哈夫曼树不唯一,但是任何一种形状的哈夫曼树,它的带权路径长度肯定都是相同且最优的,也就是WPL相同且是最小值
5.1.4哈夫曼编码
我们在电视剧中应该都看过上面这种发电报的场景,发电报的时候就需要用到哈夫曼编码,而哈夫曼编码的构造其实就是我们前面介绍的哈夫曼树的原理。
发电报的时候有些信号是较长的,有些信号是较短的。其实也就是对应我们二进制的1和0,而接受电报的人就是需要把这些0和1翻译成相对于的信息就是了。
那么给定一个字符集如何设计一个优秀的编码方案?举个例子:
现在我们有两个哥们,小渣老渣,他们两个弄清楚发电报的原理之后,决定在考试上传答案。其实也就是利用二进制的0和1来表示不同选项。
他们决定如果小渣咳嗽表示二进制的0,打嗝表示二进制的1。
而选择题只有ABCD四个选项,相应的ASCII编码如下,而ASCII码,每个字符都是8比特位,那么这种编码就叫作固定长度编码。
显然这种方式效率非常低,你传一个答案你要咳嗽打嗝共8次,100个答案就是800次,这也太不正常了,监考必然发现。
我们发现,ABCD一共也就4个字符,我们其实只需要用2个二进制位就可以区分这四种状态了。这种编码方式每个字符对应的二进制长度都是相同的,也属于固定长度编码。
采用这种方案,我们可以计算出小渣最终需要给老渣发200bit的二进制就可以把所有答案传递过去了。
这种编码方案,我们也可以把它映射为树的表示形式
我们规定,从根节点出发,往左走表示二进制0,往右走表示二进制1。
我们刚才计算最终发送的二进制码的长度,其实就是计算了上图右边树的带权路径长度。
接下来要思考的问题就是,还有没有比上述方案更优秀的编码方案呢?
也就是让他们之间传递的二进制比特信息尽可能的少,也就是尽可能的追求我们最终构造的编码树,它的带权路径长度尽可能的小呗。这不就是前面说的,哈夫曼树的构造吗?
那么我们就来实现这棵编码树,现在我们有这样四个带权结点
权值最小的显然是D和B,所以我们刚开始让D和B进行一个结合,新得到的结点为D、B权值之和,也就是2+8=10
然后就是把这棵树和A再进行一个结合
最后还剩下一个权值最大的C,将其结合
这样就用这ABCD这几个带权结点构造了一棵哈夫曼树。
从根节点出发,我们把向左的路径看成二进制的0,向右的路径看成二进制的1
这样就得到了四个字母的编码方案了
这里得到的WPL就表示了小渣把100题答案传给老渣,只需要咳嗽打嗝130次就行了。
这里的编码方式,各个字符所对应的二进制长度不同,这样的编码方式就叫作可变长度编码。
可能有同学问:你不就是想用四个二进制串来区分四个字符吗?我如果用1来表示A又如何呢?画成树就是下图右边这样。
需要注意,如果用1表示A,对于A这个结点,它是变成了非叶子结点。
假设采取A=1这种编码方式,现在小渣要给老渣传CAAABD,那么编码应该是0111111110
现在老渣接收到信息,第一个0肯定是C,但是后面111,他可能会翻译成B,再后面111也翻译成B,最后三个110翻译成D
你看,本来小渣要传的答案是CAAABD,现在由于A从10变成了1,那么接收方就可能曲解CAAABD本身的意思变成CBBD了。这就是对二进制码的解码出现错误,产生歧义了。
而如果用最先介绍的编码方式就不会出现歧义,如下图
所以,所有的这些字符集中的字符,对应到编码树里面,只能当做叶子结点,不能当做某个分支结点。
换个角度说,左边这种编码方式,我们可以称为“前缀编码”,所谓前缀编码就是,其中的任何一个字符的编码都不是另一个编码的前缀