文章目录
- 第五章 树与二叉树
- 一、树
- 二、树的常考性质
- 三、二叉树
- 四、二叉树的常考性质
- 五、完全二叉树的常考性质
- 六、二叉树的存储结构
- 七、二叉树的先/中/后序遍历
- 八、二叉树的层序遍历
- 九、由遍历序列构造二叉树
- 十、线索二叉树
- 十一、二叉树的线索化
- 十二、线索二叉树找前驱/后继
- 十三、树的存储结构
- 十四、树、森林的遍历
- 十五、二叉排序树(BST)
- 十六、平衡二叉树(AVL)
- 十七、哈夫曼树
第五章 树与二叉树
一、树
(一)树的基本概念
- 根节点
- 边
- 分支结点
- 叶子结点
- 空树——结点数为0的树
- 非空树的特性:有且仅有一个根结点
在线性表当中我们说过前驱和后继的概念。
在树里面,也有前驱和后继的概念。
- 在一个非空树当中,只有根结点是没有前驱的;只有叶子结点是没有后继的。
- 而有后继的结点叫分支结点。
- 叶子结点可以把它叫做终端节点,分支节点可以把它称为非终端结点。
- 除了根结点外,任何一个结点都有且仅有一个前驱。(否则就不是树了,而应该是图或网)
- 每个结点可以有0个或多个后继。
从数学的角度严谨的描述树的概念,如下。
(二)结点之间的关系描述
- 什么是祖先结点?
- 从某一结点出发,往根结点的方向走,这一路径上所经过的所有结点,都是它的祖先结点。
- 什么是子孙结点?
- 从某一结点出发,它的分支下的所有结点(包括分支的分支),都是它的子孙结点。
- 什么是双亲结点(父节点)?
- 一个结点的直接前驱,就是它的父节点。
- 什么是孩子结点?
- 一个结点的直接后继,就是它的孩子结点。
- 什么是兄弟节点?
- 一个节点的若干个后继结点,相互为兄弟结点。
- 什么是堂兄弟结点?
- 父节点的兄弟节点的孩子结点。(或者直接看,是同一层的、但不是兄弟节点的所有结点)
- 什么是两个结点之间的路径?
- 从一个结点走到另一个结点的路径。
- 要注意,树里面的“边”,是单向的,只能从上往下。如果需要从下往上走才能到达,则就是没有路径的。
- 什么是路径长度?
- 路径经过几条边,就是路径的长度。
(三)结点、树的属性描述
- 结点的层次(深度)
- 从上往下数,最上层的是第1层。看看该结点位于树的第几层即可。
- 默认是从第1层开始算的。但有的题目中也会从第0层开始算起,也不要觉得奇怪。
- 结点的高度
- 从下往上数,最下层的高度是1。看看该结点的高度是几即可。
- 树的高度(深度)
- 总共有多少层。
- 结点的度
- 这个结点有几个孩子(分支)
- 非叶子结点的度>0
- 叶子结点的度=0
- 树的度
- 各结点的度的最大值
(四)有序树、无序树
总之,一棵树是有序树还是无序树,具体要看你用树存什么,是否需要用结点的左右位置反映某些逻辑关系。
(五)树和森林
例如这三棵树,它们之间互不相交(结点之间没有连线),它们组成一个森林。
如果把这三棵树都连到同一个根结点上面,那么这就又变成了一棵树。
森林和树之间的相互转化问题,也是一个较重要的考点。
树可以有空树,森林也可以有空森林(有0棵树)。
二、树的常考性质
(一)结点数=总度数+1
(二)度为m的树、m叉树的区别
- 度为m的树,就意味着,这棵树里面至少有某一个结点的度达到了m。
- 任意结点的度≤m(最多m个孩子)
- 至少有一个结点的度=m
- 一定是非空树,至少有m+1个结点
- 而m叉树,我们只是规定了每个结点最多只能有m个孩子,但不代表必须有一个结点的度达到m。
- 任意结点的度≤m(最多m个孩子)
- 允许所有结点的度都<m
- 可以是空树(如,三叉空树,没问题)
(三)度为m的树第i层至多有m^(i-1)个结点(i≥1)
观察,从上到下
- 第一层:m^0=1个结点(根结点)
- 第二层:m^1=m个结点
- 第三层:m^2个结点
- ……
- 第i层:m^(i-1)个结点
同样的道理,我们也可以说m叉树的第i层至多有m^(i-1)个结点(i≥1)
(四)高度为h的m叉树至多有(m^h-1)/(m-1)个结点
我们将性质(三)中,每一层最多有几个结点,将它的前h层加起来。就得到了这个结果。
(五)高度为h的m叉树至少有h个结点
所以高度为h的m叉树,结点最少的情况,就是从根结点一直往下,每一个结点都只有一个孩子的情况。所以至少有h个结点。
对于高度为h、度为m的树,至少有h+m-1个结点。
这是因为,首先我们让它从根结点一路向下,每一个结点都只有一个孩子。但是由于度为m的树,要保证至少有一个结点有m个孩子,所以还要加上m-1。即h+m-1。
(六)具有n个结点的m叉树的最小高度
对于n个结点的m叉树,想让它的高度最小,那么就想尽可能的让每一个结点都有尽可能多的孩子,也就是会有m个孩子。这样一来,这个树就会往宽处达到最宽,高度从而就最小了。
高度最小——所有结点都有m个孩子。
结合性质(四),我们设此处这棵具有n个结点的树,的最小高度为h,那么它应该符合如下式子
m
h
−
1
−
1
m
−
1
<
n
≤
m
h
−
1
m
−
1
\frac{m^{h-1}-1}{m-1}<n≤\frac{m^h-1}{m-1}
m−1mh−1−1<n≤m−1mh−1
最左边的项,表示前h-1层最多有几个结点;最右侧的项,表示前h层最多有几个结点。
对不等式进行变形,得
m
h
−
1
<
n
(
m
−
1
)
+
1
≤
m
h
m^{h-1}<n(m-1)+1≤m^h
mh−1<n(m−1)+1≤mh
进而得到
h
−
1
<
l
o
g
m
(
n
(
m
−
1
)
+
1
)
≤
h
h-1<log_m(n(m-1)+1)≤h
h−1<logm(n(m−1)+1)≤h
那么对此对数结果向上取整,即得到h。
三、二叉树
(一)二叉树的基本概念
特点:
- 每个结点至多只有两棵子树
- 左右子树不能颠倒(二叉树是有序树)
(二)几个特殊的二叉树
1.满二叉树
一棵高度为h,且含有
2
h
−
1
2^h-1
2h−1
个结点的二叉树。
除了最下层的叶子结点以外,其他的结点,都长满了两个分支。
对于满二叉树来说,第i层有2^(i-1)个结点。
特点:
- 只有最后一层有叶子结点
- 不存在度为1的结点
- 按层序从1开始编号,结点i的左孩子为2i,右孩子为2i+1(如同上图那样的编号);结点i的父节点为i/2(向下取整)(如果有的话)。
- 这个特性是很重要的,可以通过这个特性,使得我们能够用顺序存储的方式来存储这些结点。
2.完全二叉树
当且仅当其每个结点都与高度为h的满二叉树中编号为1~n的结点一一对应时,称为完全二叉树。
可见,满二叉树是一种特殊的完全二叉树。而完全二叉树不一定是满二叉树。
那么,由于我们将最后一层编号更大的结点去除了,那么就会在倒数第二层,出现叶子结点。而且不难理解,完全二叉树只有最后两层可能有叶子结点。且最多只有一个度为1的结点。
特点:
- 只有最后两层可能有叶子结点
- 最多只有一个度为1的结点
- 【同满二叉树】按层序从1开始编号,结点i的左孩子为2i,右孩子为2i+1(如同上图那样的编号);结点i的父节点为i/2(向下取整)(如果有的话)。
- 若一个完全二叉树的结点数为n的话。则
i≤(n/2)
(向下取整)为分支结点;i>(n/2)
(向下取整)为叶子结点。(例如上图,共有12个结点,其中16为分支结点;712为叶子结点) - 如果某结点只有一个孩子,那么一定是左孩子。
3.二叉排序树
一棵二叉树或者是空二叉树,或者是具有如下性质的二叉树:
- 左子树上所有结点的关键字均小于根结点的关键字;
- 右子树上所有结点的关键字均大于根结点的关键字。
- 左子树和右子树又各是一棵二叉排序树。
二叉排序树可用于元素的排序、搜索
4.平衡二叉树
树上任一结点的左子树和右子树的深度之差不超过1。(或者说高度之差也是一样的)
上图为一个平衡二叉树,而下图不是一个平衡二叉树
可以看出,平衡二叉树能有更高的搜索效率。
其实平衡二叉树,就是希望一棵树在生长的时候,尽可能的往宽处长,而高度是在当前状态下的最小高度。这样一来,我们在向下搜索的过程中,搜索的次数也便是最少的了。
四、二叉树的常考性质
(一)叶子结点比二分支结点多一个
设非空二叉树中度为0、1和2的结点个数分别为n0、n1和n2,则n0 = n2 + 1
。(叶子结点比二分支结点多一个)。
这个结论是怎么来的呢,如下。
先假设树中结点总数为n,则
①
n
=
n
0
+
n
1
+
n
2
②
n
=
n
1
+
2
n
2
+
1
① n = n_0 + n_1 + n_2\\ ② n = n_1 + 2n_2 + 1
①n=n0+n1+n2②n=n1+2n2+1
对于式①,很好理解;对于式②,其理解为:树的结点数=总度数+1
②式-①式,即可得到
n
0
=
n
2
+
1
n_0 = n_2 + 1
n0=n2+1
(二)二叉树的第i层至多有2^(i-1)个结点(i≥1)
此处不再赘述。
(三)高度为h的二叉树至多有2^h-1个结点
此处不再赘述。
五、完全二叉树的常考性质
(一)具有n个结点的完全二叉树的高度
具有n个(n>0)结点的完全二叉树的高度h为
⌈
l
o
g
2
(
n
+
1
)
⌉
或
⌊
l
o
g
2
n
⌋
+
1
\lceil log_2(n+1)\rceil \\或\\ \lfloor log_2n\rfloor + 1
⌈log2(n+1)⌉或⌊log2n⌋+1
第一个式子,怎么得来的:
因此,可得
2
h
−
1
−
1
<
n
≤
2
h
−
1
2^{h-1}-1<n≤2^h-1
2h−1−1<n≤2h−1
于是
h
−
1
<
l
o
g
2
(
n
+
1
)
≤
h
所
以
h
=
⌈
l
o
g
2
(
n
+
1
)
⌉
h-1<log_2(n+1)≤h \\所以 \\h=\lceil log_2(n+1)\rceil
h−1<log2(n+1)≤h所以h=⌈log2(n+1)⌉
第二个式子,怎么得来的:
因此,可得
2
h
−
1
≤
n
<
2
h
即
h
−
1
≤
l
o
g
2
n
<
h
所
以
h
=
⌊
l
o
g
2
n
⌋
+
1
2^{h-1}≤n<2^h \\即\\ h-1≤log_2n<h \\所以\\ h=\lfloor log_2n\rfloor+1
2h−1≤n<2h即h−1≤log2n<h所以h=⌊log2n⌋+1
那么,若已知完全二叉树某个结点的编号为i,则其所在层次h即为如上两个结论的结果。是一样的道理。
(二)对于完全二叉树,可以由结点数n推出度为0、1和2的结点个数
对于完全二叉树,可以由结点数n推出度为0、1和2的结点个数,n0、n1和n2。
怎么做呢?这需要基于我们之前得到的几个结论。
- 完全二叉树最多只有一个度为1的结点,即
n1 = 0 或 1
n0 = n2 + 1
,即n0 + n2
一定是奇数- 则可以有这样的推论:若完全二叉树有2k(偶数)个结点,则必有
n1 = 1, n0 = k, n2 = k-1
。 - 若完全二叉树有2k-1(奇数)个结点,则必有
n1 = 0, n0 = k, n2 = k-1
。
- 则可以有这样的推论:若完全二叉树有2k(偶数)个结点,则必有
六、二叉树的存储结构
- 顺序存储
- 链式存储
(一)二叉树的顺序存储
我们要把二叉树的各个元素连续的,顺序的存放,我们可以定义一个数组。
1.完全二叉树
#define MaxSize 100
struct TreeNode {
ElemType value; //结点中的数据元素
bool isEmpty; //结点是否为空
};
TreeNode t[MaxSize];
//定义一个长度为MaxSize的数组t,按照从上至下、从左至右的顺序依次存储完全二叉树中的各个结点
在初始化这个数组的时候,我们要把所有的元素的结点先初始化为空。
for(int i=0; i<MaxSize; i++){
t[i].isEmpty = true;
}
另外,在存储的时候,可以让t[0]弃置不用,以此来保证数组下表和结点编号一致,便于操作。
几个常考的基本操作:
由于它是一个完全二叉树,在之前的小节中我们都提到过,对于i号结点
,它的左孩子是2i
,右孩子是2i + 1
,父节点是⌊i / 2⌋
,i所在的层次是⌈log₂(n+1)⌉ 或 ⌊log₂n⌋ + 1
若完全二叉树中共有n个结点,则
2.普通二叉树
刚才我们讨论的是完全二叉树的顺序存储,只需顺序地存放完全二叉树中的每个结点,然后根据下标就可判断出每个结点之间的父子、层级关系等。
而对于普通二叉树,如果我们还像完全二叉树那样按照从上到下、从左向右,对每一层各个结点进行顺序存储,那么,根据某结点的下标i,就无法反应任何结点间的逻辑关系了。
然而,我们可以把普通二叉树的结点编号与完全二叉树的对应起来。
如果像这样,并且把每个节点放在数组中对应的位置上(虚线的结点在数组中对应位置置空),那么依然可以按照结点的编号i来判断:
但是,即使其结点编号模仿了完全二叉树,但其总结点数还是无法“模仿”的,因此,对于这些操作是无法判断的:
那么此处的这三个操作怎么判断?如下:
可见,用这种方法来存放一棵普通二叉树,会有大量的空间是闲置的。最坏情况为:高度为h且只有h个结点(即有h层,且每层只有1个结点,而且每个结点均为右孩子),也至少需要2^h - 1个存储单元。
3.结论
二叉树的顺序存储结构,只适合存储完全二叉树。
思考:如果根结点是从0号开始的,那么一系列结论又是什么样的呢?
(二)二叉树的链式存储
//二叉树的结点(链式存储)
typedef struct BiTNode{
ElemType data; //数据域
struct BiTNode *lchild, *rchild; //左、右孩子指针
}BiTNode, *BiTree;
若共有n个结点,则共有2n个指针,其中n-1个指针是存放了内容的(除了头结点外,每个结点被指1次),也就是说n个结点的二叉链表共有n+1个空链域。
由于每个节点都有两个指针,我们也把这种实现叫做二叉链表。
//定义一棵空树
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->lchild = p; //作为根结点的左孩子
那么,在这种情况下,给出某结点p,若要找到它的左孩子或者右孩子的话,就非常简单。——只需检查它的左孩子指针、右孩子指针,就可以了。
但是如何找到其父节点?——就只能从根节点开始遍历寻找,看看哪一个结点的左孩子或者右孩子是指向p结点的。显然,若整个树很大,那么这一操作还是很耗时的。因此,若你的应用场景当中,经常需要找某结点的父节点的话,你可以再给结点添加一个指针域,用来存放该结点的父节点指针。
由于有三个指针,这种也叫三叉链表。
//二叉树的结点(链式存储)
typedef struct BiTNode{
ElemType data; //数据域
struct BiTNode *lchild, *rchild; //左、右孩子指针
struct BiTNode *parent; //父节点指针
}BiTNode, *BiTree;
考研当中一般不考带父节点的。那么,只有左右孩子指针的二叉链表,该怎么更高效地寻找其父节点,在下一小节我们再进一步讨论。
七、二叉树的先/中/后序遍历
(一)手算
但是对于树形结构,我们的遍历规则就会更复杂一些。
由于树这种结构呈现出了一层一层的效果,因此不难想到我们可以一层一层地访问这些结点。
这种方法就叫层次遍历。即基于树的层次特性确定的次序规则。
而此处我们说的先/中/后序遍历,是基于树的递归特性来制定的遍历规则。(根结点、左子树、右子树)
二叉树的递归特性:
- 要么是空二叉树。
- 要么是由“根结点 + 左子树 + 右子树”组成的二叉树(当然,左右子树也可能是空二叉树)。
由此,我们可以制定一种遍历规则:当我们要遍历的这个二叉树为空二叉树时,我们什么都不用做;若我们要遍历的是一个非空二叉树,我们可以根据根结点、左子树、右子树的访问次序,来制定三种访问规则,分别是先序遍历、中序遍历、后序遍历。
分支结点逐层展开法。即,如果一个结点是分支结点而不是叶子结点的话,那么你就要嵌套递归地按照特定序列遍历的规则,把它展开到下一级。
如果用二叉树存放运算数、操作符等内容,例如算术表达式a + b * (c - d) - e / f
的分析树:
通过这个例子我们知道,对某个算术表达式的分析树进行前/中/后序遍历,可以得到其前缀、中缀、后缀表达式。
(二)代码
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 InOrder(BiTree T) {
if(T!=NULL){
InOrder(T->lchild);
visit(T);
InOrder(T->rchild);
}
}
//后序遍历
void PostOrder(BiTree T) {
if(T!=NULL){
PostOrder(T->lchild);
PostOrder(T->rchild);
visit(T);
}
}
这种方法也可以正确的求出先/中/后序遍历的序列。上一小节当中的那个方法也可以,这两种方法任选其一即可。
(三)应用
求树的深度
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;
}
}
八、二叉树的层序遍历
算法思想:
①初始化一个辅助队列
②根结点入队
③若队列非空,则队头结点出队,访问该结点,并将其左、右孩子插入队尾(如果有的话)
④重复③直至队列为空
//层序遍历
void LevelOrder(BiTree T) {
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); //右孩子入队
}
}
九、由遍历序列构造二叉树
结论:若只给出一棵二叉树的 前/中/后/层 序遍历序列中的一种,不能唯一确定一棵二叉树。
怎样可以唯一确定一棵二叉树呢?
由二叉树的遍历序列构造二叉树:
- 前序 + 中序遍历序列
- 后续 + 中序遍历序列
- 层序 + 中序遍历序列
(一)前序+中序遍历序列
前序遍历:根结点、前序遍历左子树、前序遍历右子树
(二)后序+中序遍历序列
后序遍历:左子树的后序遍历、右子树的后序遍历、根结点。
(三)层序+中序遍历序列
层序遍历:根结点、左子树的根、右子树的根
以上三种,最重要的都是找到树的根结点,并根据中序序列划分左右子树。之后按照同样的逻辑找到左右子树的根结点。
(四)如果不要中序序列
例如:
这两棵不同形态的树,其
所以即使你把前序、后续、层序遍历序列都给出了,三个一起组合,依然无法确定这个二叉树的唯一准确形态。两两组合就更不可能了。
结论:前序、后序、层序遍历序列的两两组合无法唯一确定一棵二叉树。一定要有中序序列。
十、线索二叉树
(一)线索二叉树的作用
之前我们学过普通的二叉树,我们可以对它进行先序、中序、后续遍历。
如:
其中序遍历序列:D G B E A F C
这样一来,本来在树中存储的数据元素,它们是非线性的关系,但是从遍历得到的序列来说,它们属于线性关系。有前驱、后继。
问题一:
问题二:
由此可以看出,我们在一棵普通的二叉树中,我们要从某一个结点开始往后遍历,是很不方便的。同时,如果指定一个结点,要找他的前驱,或者后继,我们要从头开始重新进行一次遍历,才有可能找到他的前驱、后继。因此,如果在某些应用场景中,找前驱、后继,还有遍历,这种操作很频繁的话,用普通二叉树就有很大的缺点。因此有人提出了线索二叉树。
(二)中序线索二叉树
对于上一小节中的普通二叉树,我们将其“线索化”之后
其中,黄色箭头为前驱线索(由左孩子指针充当),紫色箭头为后继线索(由右孩子指针充当)。
指向前驱、后继的指针称为线索。
此时,再来看刚才的问题:
既然任意一个结点,找它的后继很方便的话,那是不是说,任意给出一个结点,从它开始往后遍历也是可行的?因为所谓的遍历,无非就是不断找后继的过程。
有人要问了:对于G这种,右孩子指针存储的就是它的后继结点的来说,当然很方便的能找后继了。但是如果对于B这种左右孩子都存储的是真正的孩子结点的,该怎么找它的后继呢?
总之,我们把二叉树线索化之后,找结点的前驱、后继更方便,遍历也更方便。
(三)中序线索二叉树的存储
对于一个普通二叉树来说,它的每个结点有一个左孩子指针、右孩子指针。
//二叉树的结点(链式存储)
typedef struct BiTNode {
ElemType data;
struct BiTNode *lchild, *rchild;
}BiTNode, *BiTree;
但是对于二叉树来说,它的左孩子指针、右孩子指针,指向的有可能不是它的左右孩子,而是它的前驱和后继。
为了区别这样的两种状态,我们要加两个标志位。
//线索二叉树结点
typedef struct ThreadNode {
ElemType data;
struct ThreadNode *lchild, *rchild;
int ltag, rtag; //左、右线索标志
}ThreadNode, *ThreadTree;
对于ltag、rtag,当tag0时,表示指针指向孩子;当tag1时,表示指针是“线索”。
这样的线索二叉树的链式存储,我们也可以叫线索链表。
以上是中序线索二叉树。而对于先序、后序线索二叉树来说,只不过是遍历序列的顺序变了,利用左孩子指针充当前驱线索、右孩子指针充当后继线索的思想是一样的。
十一、二叉树的线索化
我们要对一棵二叉树进行线索化,就是要从根结点开始进行中序遍历,依次找到它的每一个结点,并将其左孩子指针连上它的前驱,右孩子指针连上它的后继。
//线索二叉树结点
typedef struct ThreadNode {
ElemType data;
struct ThreadNode *lchild, *rchild;
int ltag, rtag; //左、右线索标志
};
//全局变量pre,指向当前访问结点的前驱
ThreadNode *pre = NULL;
//中序遍历二叉树,一边遍历一边线索化
void InThread(ThreadTree T) {
if(T!=NULL){
InThread(T->lchild); //中序遍历左子树
visit(T); //访问根结点
InThread(T->rchild); //中序遍历右子树
}
}
线索化的事情可以在visit()函数里面来完成,如下
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的rchild是否为NULL,如果是,则令rtag=1。
中序线索化的一个较完整的代码如下
//线索二叉树结点
typedef struct ThreadNode {
ElemType data;
struct ThreadNode *lchild, *rchild;
int ltag, rtag; //左、右线索标志
}ThreadNode, *ThreadTree;
//全局变量pre,指向当前访问结点的前驱
ThreadNode *pre = NULL;
//中序线索化二叉树T
void CreateInThread(ThreadTree T) {
pre = NULL; //pre初始为NULL
if(T!=NULL){ //非空二叉树才能线索化
InThread(T); //中序线索化二叉树
if(pre->rchild == NULL)
pre->rtag = 1; //处理遍历的最后一个结点
}
}
//中序遍历二叉树,一边遍历一边线索化
void InThread(ThreadTree T) {
if(T!=NULL){
InThread(T->lchild); //中序遍历左子树
visit(T); //访问根结点
InThread(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;
}
经过中序线索化之后,这棵树的存储结构就会呈现如下这样:
王道教材中给的代码是这样的:
//中序线索化二叉树T
void CreateInThread(ThreadTree T){
ThreadTree pre = NULL;
if(T!=NULL){ //费空二叉树,线索化
InThread(T, pre); //线索化二叉树
pre->rchild = NULL; //处理遍历的最后一个结点
pre->rtag = 1;
}
}
//中序线索化
void InThread(ThreadTree p, ThreadTree &pre) {
if(p!=NULL){
InThread(p->lchild, pre); //递归,线索化左子树
if(p->lchild == NULL) {
p->lchild = pre;
p->ltag = 1;
}
if(pre!=NULL && pre->rchild == NULL){
pre->rchild = p; //建立前驱结点的后继线索
pre->rtag = 1;
}
pre = p; //标记当前结点成为刚刚访问过的结点
InThread(p->rchild, pre); //递归,线索化右子树
}
}
此处和上面的处理思路是一样的。此处函数参数中使用的局部变量pre,和我们刚刚全局变量声明一个pre,作用是相同的。
而此处的代码,在处理最后一个结点的右孩子指针rchild指针时,与我们的处理方式有所不同。它没有判断rchild是否为NULL。为什么?
这是因为,中序遍历的访问顺序为左 根 右
。也就是我们在访问这个根结点之后,肯定还会再访问、再visit()下一个结点。所以我们可以断定,在中序遍历当中,最后一个被访问、被visit()的结点,肯定是没有右孩子的。即:中序遍历的最后一个结点右孩子指针必为空。
所以此处代码和上面判断一下为NULL再做,的代码,是一样的效果。只不过上面的代码可能更易懂一点。
※ 总结
二叉树线索化的核心,在于
- 对中序/先序/后序遍历算法的改造,使其能够边访问边线索化,即在访问一个结点时,连接该结点与前驱结点的线索信息。
- 用一个指针pre记录当前访问结点的前驱结点。
- pre设为全局变量也可以,设为局部变量然后在函数形参中调用&pre也可以。
易错点:
- 最后一个结点的lchild、rtag的处理。
- 先序线索化中,注意处理无限转圈的问题,即当ltag==0时,才能对左子树进行继续的遍历。
- 而中序、后序不会,是因为,在其访问根结点时,其左子树就已经被遍历完毕了,所以不会出现无限循环的问题。
十二、线索二叉树找前驱/后继
(一)中序线索二叉树找中序后继
怎么找中序线索二叉树中指定结点p的中序后继next?
-
若
p->rtag == 1
,则next = p->rchild
。 -
若
p->rtag == 0
,(说明这个结点肯定是有右孩子的)。next = 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直接返回后继线索
}
既然可以找到任意结点的中序后继,那么我就可以对这棵中序线索二叉树进行中序遍历了。
//对中序线索二叉树进行中序遍历(利用线索实现的非递归算法)
void InOrder(ThreadNode *T){
for(ThreadNode *p = Firstnode(T); p!=NULL; p=Nextnode(p)){
visit(p);
}
}
(二)中序线索二叉树找中序前驱
在中序线索二叉树中找指定结点p的中序前驱pre?
-
若
p->ltag == 1
,则pre == p->lchild
。 -
若
p->ltag == 0
,(说明这个结点肯定是有左孩子的)。pre = p的左子树中最右下的结点。
//找到以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直接返回前驱线索
}
有了对任意一个结点找中序前驱的操作以后,我们就可以实现对一个中序线索二叉树进行逆向中序遍历的事情。
void RevInOrder(ThreadNode *T){
for(ThreadNode *p=Lastnode(T); p!=NULL; p=Prenode(p)){
visit(p);
}
}
(三)先序线索二叉树找先序后继
怎么找先序线索二叉树中指定结点p的先序后继next?
-
若
p->rtag == 1
,则next = p->rchild
。 -
若
p->rtag == 0
,(说明p结点一定有右孩子,但是有没有左孩子还不确定)。若p有左孩子,则先序后继为左孩子;若p没有左孩子,则先序后继为右孩子。
(四)先序线索二叉树找先序前驱
在先序线索二叉树中找到指定结点p的先序前驱pre。
-
若
p->ltag == 1
,则next = p->lchild
。 -
若
p->ltag == 0
,(说明p一定有左孩子)。
(五)后序线索二叉树找后序前驱
在后序线索二叉树中找到指定结点p的后续前驱pre。
-
若
p->ltag == 1
,则pre = p->lchild
。 -
若
p->ltag == 0
(说明它一定有左孩子,但是不一定有没有右孩子)
(六)后续线索二叉树找后序后继
在后序线索二叉树中找指定结点p的后序后继next。
-
若
p->rtag == 1
,则next = p->rchild
。 -
若
p->rtag == 0
,(说明其一定有右孩子)
注意:对以上所有情况的讨论,我们只需要理解其逻辑过程,而不要去背结论!!
(七)总结
中序线索二叉树 | 先序线索二叉树 | 后序线索二叉树 | |
---|---|---|---|
找前驱 | √ | × | √ |
找后继 | √ | √ | × |
对先序线索二叉树来说,找先序前驱是不可以的;同样地,对于后续线索二叉树来说,找后序后继是不可以的。除非采用三叉链表,或者用土办法从整棵树的根结点重新进行完整的遍历来寻找其父节点。
也就是,对于先序线索二叉树来说,给你一个结点,你只能从这个结点开始向后进行先序遍历。
对于后续线索二叉树来说,给你一个结点,你只能从这个结点开始进行逆向的后序遍历。
十三、树的存储结构
之前我们着重探讨的都是二叉树。此处我们探讨普通的树。
(一)双亲表示法(顺序存储)
双亲表示法:每个结点中保存指向双亲的指针。
因为在一棵树中,除了根结点外,每个结点都有且仅有一个它的父节点。
所以,利用这一特性,我们在保存每个结点本身的数据之外,再保存一个指向它双亲的指针。
#define MAX_TREE_SIZE 100 //树中最多结点数
typedef struct { //数的结点定义
ElemType data; //数据元素
int parent; //双亲位置域
}PTNode;
typedef struct { //树的类型定义
PTNode nodes[MAX_TREE_SIZE]; //双亲表示
int n; //结点数
}PTree;
若要增加一个结点,只需要在数组的空闲位置写入一个结点即可,写入其数据值,并绑定其与双亲的关系即可。而结点在数组中存放的先后顺序是无所谓的,没必要按照树中层次的先后顺序来存储。
若要删除一个叶子结点,有两种方案。方案一,把该结点的指针域置为-1,让此位置无效。方案二:将数组中最后一个结点置于此节点上将之覆盖,方案二可以保证每个位置都是有效的存储位置。最后再将树的结点数n减一即可。(方案二是较好的,见下方查询操作中的描述可知)
思考一个问题,若删除的结点不是一个叶子结点,而是某一个分支结点,该怎么进行删除操作呢?
那此时就不能只删除这个结点在数组中存放的位置了,因为如果这样删除,就意味着以这个结点为根的整棵子树都被删除了。我们还要将这个结点的孩子结点找到,并且用一定的办法保留在原树中。
那么此时就涉及到树的查找操作了。
在这种存储结构中,给定一个结点,对于查找它的双亲节点是很简单的,只需访问其parent指针即可(优点)。但如果要找到它的孩子,我们就只能从头到尾依次遍历,然后匹配出它的所有孩子结点(缺点)。(此处也暴露出删除一个结点操作中,方案一的缺点,会导致遍历操作的时候访问很多无效的结点,导致遍历的速度更慢)。
(二)孩子表示法(顺序+链式存储)
孩子表示法:顺序存储各个结点,每个结点中保存孩子链表头指针。
struct CTNode {
int child; //孩子结点在数组中的位置
struct CTNode *next; //下一个孩子
};
typedef struct {
ElemType data;
struct CTNode *firstChild; //第一个孩子
}CTBox;
typedef struct {
CTBox nodes[MAX_TREE_SIZE];
int n, r; //结点数和根的位置
}CTree;
思考这种存储方式,在进行增/删/查时的操作。以及其优缺点。此处不再展开。
(三)※孩子兄弟表示法(链式存储)
//树的存储——孩子兄弟表示法
typedef struct CSNode {
ElemType data; //数据域
struct CSNode *firstchild, *nextsibling; //第一个孩子和右兄弟指针
}CSNode, *CSTree;
从存储的角度来看,这就是个二叉链表(每个结点有两个指针)。其实和二叉树的存储是相似的,只是变量的含义、命名有所区别。
上图,左侧为原本的树。右侧为按照此种二叉链表式的存储结构存储而得到的树的存储结构。
由此观之,一棵树可以转化为一棵二叉树。
好处:我们可以用我们熟悉的二叉树操作来处理树。
(四)树和二叉树的转化
用孩子兄弟表示法存储的树,在物理存储结构上呈现出二叉树的样子。
只不过其“左指针”指向的是其第一个孩子,而“右指针”指向的是它的右兄弟。
要会将树转化为二叉树,也要会将二叉树转化为树。
(五)森林和二叉树的转换
对于一棵树,我们已经知道了如何把它转化为一棵二叉树了。
而森林是多棵互不相交的树的集合,那么我们也可以把森林转化为二叉树。
同时,由于这几棵互不相交的树的根结点,可以看做是同级的结点,也就是兄弟节点,因此可以把这几个结点按照兄弟节点的关系用右指针联系起来。
把森林转化为二叉树、把二叉树转化为森林,都要会转化。
十四、树、森林的遍历
(一)树的先根遍历
若树非空,先访问根结点,再依次对每棵子树进行先根遍历。
//树的先根遍历
void PreOrder(TreeNode *R) {
if(R!=NULL) {
visit(R); //访问根结点
while(R还有下一个子树T){
PreOrder(T); //先根遍历下一棵子树
}
}
}
接下来,我们把这棵树转化为与之对应的二叉树:
会发现,对树的先根遍历序列,和与之对应的二叉树的先序遍历序列相同。
(二)树的后根遍历
若树非空,先依次对每棵子树进行后根遍历,最后再访问根结点。
//树的后根遍历
void PostOrder(TreeNode *R) {
if(R!=NULL) {
while(R还有下一个子树T) {
PostOrder(T); //后根遍历下一棵子树
}
visit(R); //访问根结点
}
}
树的后根遍历与这棵树对应的二叉树的中序遍历序列相同。
(三)树的层次遍历
不难发现,对于层次遍历来说,我们在探索这些结点的时候,是尽可能的横向在探索,也就是探索的范围尽可能的广,所以对树的层次遍历也叫广度优先遍历。
那么与之相对的,树的先根遍历和后根遍历,我们在探索结点的时候,是尽可能的往深处探索,所以后根遍历和先根遍历也叫深度优先遍历。
(四)森林的先序遍历
先序遍历森林:
若森林非空,则按如下规则进行遍历。
- 访问森林中第一棵树的根结点;
- 先序遍历第一棵树中根结点的子树森林;
- 先序遍历除去第一棵树之后剩余的树构成的森林。
其先序遍历步骤如下:
①B C D
②(B E F) (C G) (D H I J)
③(B (E K L) F) (C G) (D (H M) I J)
先序遍历森林,效果等同于依次对各个树进行先根遍历。
先序遍历森林,效果等同于与之对应的二叉树的先序遍历。
(五)森林的中序遍历
中序遍历森林:
若森林非空,则按如下规则进行遍历:
- 中序遍历森林中第一棵树的根结点的子树森林;
- 访问第一棵树的根结点;
- 中序遍历除去第一棵树之后剩余的树构成的森林。
对上图的森林,中序遍历步骤如下:
①B C D
②(E F B) (G C) (H I J D)
③((K L E) F B) (G C) ((M H) I J D)
中序遍历森林,效果等同于依次对各个树进行后根遍历。
中序遍历森林,效果等同于与之对应的二叉树的中序遍历。
(六)总结
树 | 森林 | 二叉树 |
---|---|---|
先根遍历 | 先序遍历 | 先序遍历 |
后根遍历 | 中序遍历 | 中序遍历 |
十五、二叉排序树(BST)
(一)二叉排序树的定义
二叉排序树,又称二叉查找树(BST,Binary Search Tree),一棵二叉树或者是空二叉树,或者是具有如下性质的二叉树:
- 左子树上所有结点的关键字均小于根结点的关键字;
- 右子树上所有结点的关键字均大于根结点的关键字。
- 左子树和右子树又各是一棵二叉排序树。
- 即:左子树结点值 < 根结点值 < 右子树结点值
- 因此,如果我们进行中序遍历,则可以得到一个递增的有序序列。(因为中序遍历的规则就是左、根、右)
(二)二叉排序树的查找
查找步骤为:
- 若树非空,目标值与根结点的值比较;
- 若相等,则查找成功。
- 若小于根结点,则在左子树上查找,否则在右子树上查找。
- 查找成功,返回结点指针;查找失败,返回NULL。
例如,对于如下二叉排序树
查找关键字为30的结点:
①指针T指向根结点19
②30>19,则T往右子树查找,T指向50
③30<50,则T往左子树查找,T指向26
④30>26,则T往右子树查找,T指向30
⑤30==30,则查找成功,返回指针T。
//二叉排序树结点
typedef struct BSTNode {
int key;
struct BSTNode *lchild, *rchild;
}BSTNode, *BSTree;
//在二叉排序树中查找值为key的结点
BSTNode *BST_Search(BSTree T, int key) {
while(T!=NULL && key!=T->key) { //若树空或等于根结点值,则结束循环
if(key < T->key) T = T->lchild; //小于,则在左子树上查找
else T = T->rchild; //大于,则在右子树上查找
}
return T;
}
上面的查找的代码实现是一种非递归的方式,也可以使用递归的方式来实现。
//在二叉排序树中查找值为key的结点(递归实现)
BSTNode *BSTSearch(BSTree T, int key) {
if(T == NULL)
return NULL; //查找失败
if(key == T->key)
return T; //查找成功
else if(key < T->key)
return BSTSearch(T->lchild, key); //在左子树中查找
else
return BSTSearch(T->rchild, key); //在右子树中查找
}
(三)二叉排序树的插入
若原二叉排序树为空,则直接插入结点;否则,若关键字k小于根结点值,则插入到左子树,若关键字k大于根节点值,则插入到右子树。
//在二叉排序树插入关键字为k的新结点(递归实现)
int BST_Insert(BSTree &T, int k) {
if(T == NULL) { //原树为空,新插入的结点为根结点
T = (BSTree)malloc(sizeof(BSTNode));
T->key = k;
T->lchild = T->rchild = NULL;
return 1; //返回1,插入成功
}
else if(k == T->key) //树中存在相同关键字的结点,插入失败
return 0;
else if(k < T->key) //插入到T的左子树
return BST_Insert(T->lchild, k);
else //插入到T的右子树
return BST_Insert(T->rchild, k);
}
显然,插入操作也可以采用非递归的方式实现,自己练习实现一下。
(四)二叉排序树的构造
//按照str[]中的关键字序列建立二叉排序树
void Create_BST(BSTree &T, int str[], int n) {
T = NULL; //初始时T为空树
int i = 0;
while(i < n) { //依次将每个关键字插入到二叉排序树中
BST_Insert(T, str[i]);
i++;
}
}
按照插入的操作,依次插入各个结点即可。
如上图,其中,例1、例2构造的二叉排序树为左图;而例3构造的二叉排序树为右图。这是给定序列的关键字前后顺序不同导致的。
比较常考的内容:给你一个关键字序列,让你来构造出一个二叉排序树。
(五)二叉排序树的删除
先搜索,找到目标节点:
- 若被删除的结点z是叶子结点,则直接删除,不会破坏二叉排序树的性质。
- 若结点z只有一棵左子树或右子树,则让z的子树成为z父节点的子树,替代z的位置。这种处理,依然可以保持排序树的特性。
- 若结点z有左、右两棵子树,则令z的直接后继(或直接前驱)替代z,然后从二叉排序树中删去这个直接后继(或直接前驱),这样就转换成了第一或第二种情况。
- 我们如果直接将结点z删除,让它空着,肯定不行,显然要有一个人来补到它的位置。
- 找谁呢?由于我们在删除之后,依然要保证二叉排序树
左子树结点值 < 根节点值 < 右子树结点值
的特性。由于对二叉排序树进行中序遍历,可以得到一个递增的有序序列。也就是我们删除之后,再次进行中序遍历,得到的仍然是一个递增的有序序列,即可保证我们的删除操作没有破坏二叉排序树的性质。 - 中序遍历——左、根、右。将根结点删除,中序遍历序列仍为一个递增的有序序列。那么就要让右子树中第一个由中序遍历被访问到的结点,来补到根结点的位置。不难得知,这个第一个被访问到的结点,就是右子树中最小的结点。即:在z的右子树中找到最左下的那个结点(该结点一定没有左子树;也就是它只有一个右子树,或者它就是叶子结点;因此便转换为了前两种情况)。
- 以上是利用它的直接后继来处理的。我们也可以用它的直接前驱来处理。
- 用直接前驱来替代被删除的结点。也就是要找到它的左子树当中最大的那个值,来替代当前被删除的这个结点。
- 显然,z的直接前驱:z的左子树中最右下的结点(该结点一定没有右子树,因此便转化成了前两种情况)。
(六)查找效率分析
查找长度——在查找运算中,需要对比关键字的次数,称为查找长度。它反映了查找操作的时间复杂度。
不论是用非递归的循环方式,还是用递归的方式,对一种二叉树的查找,是有时间复杂度的衡量的。时间复杂度和查找长度的数量级是相同的。
对查找的时间复杂度的度量,我们是使用查找成功的平均查找长度ASL(Average Search Length)。
那么显然,左边这棵树的查找效率要高一些,即使它们存储的内容是完全相同的。
由于我们在一棵树中对比关键字的次数,最多不会超过二叉树的高度h。因此查找效率最坏情况的复杂度,也是和二叉树的高度h是同等数量级的,即O(h)。因此,二叉排序树的查找效率,很大程度上取决于这棵树的高度到底是多少。
在之前讲二叉树的几个小节中我们说过,对于有n个结点的二叉树:
- (最好情况)最小高度为
⌊log₂n⌋ + 1
,平均查找长度为O(log₂n); - (最坏情况)每个节点只有一个分支,树高h = 结点数n。平均查找长度为O(n)。
刚才我们说的是查找成功时的平均查找长度。
现在我们分析一下查找失败的平均查找长度ASL(Average Search Length)(需补充失败结点再分析)。
对于上图左边的这棵树,我们先把叶子结点下的空结点给补上:
对于上图右边的这棵树,我们先把空节点给补上:
显然,也是左边这棵比较胖的树,它的查找效率要更高一些。
十六、平衡二叉树(AVL)
(一)平衡二叉树的定义
平衡二叉树(Balanced Binary Tree),简称平衡树(AVL树)——树上任一结点的左子树和右子树的高度之差不超过1。
我们定义这样一个概念:
- 结点的平衡因子 = 左子树高 - 右子树高。
如图,每个结点下方的数字即为该结点的平衡因子。
那么,显然,若一棵二叉树是平衡二叉树的话,那么它各个结点的平衡因子的值只可能是-1、0或1。(也就是绝对值不超过1)。
也就是说,如果有任一结点的平衡因子的绝对值大于1,那么这棵树肯定就不是平衡二叉树。
所以,我们可以在二叉树的各个结点的结构类型中,增加一个平衡因子的属性,如下:
//平衡二叉树结点
typedef struct AVLNode {
int key; //数据域
int balance; //平衡因子
struct AVLNode *lchild, *rchild;
}AVLNode, *AVLTree;
上一节我们说过,如果我们保证一棵树为平衡二叉树,我们就可以保证它的查找效率为O(log₂n)的数量级。
因此我们着重要研究的就是,在二叉排序树中插入新结点后,如何保持平衡?
如上图,如果我们不进行平衡化处理,那么在插入一个新结点之后,新插入结点查找路径上的所有祖先结点都可能会受到影响。
我们的解决办法是:从插入点往回找到第一个不平衡结点,调整以该结点为根的子树。
这棵子树,也就是我们所要调整的对象,将它称为最小不平衡子树。
调整之后,得到的效果,如下所示:
我们会发现,只要我们把最小不平衡子树,让它恢复平衡之后,其他的受影响的祖先节点,它们的平衡因子就都恢复原样了(恢复平衡了)。
(二)调整最小不平衡子树
1.调整最小不平衡子树(LL)
在插入之前,子树A是一棵平衡二叉树。其左右子树高度之差为1。
在向A的左孩子的左子树中插入一个结点后,即A的左子树高度增加1,那么A的左右子树高度之差就变为了2,就不平衡了。
总之,在我们假定一棵最小平衡二叉树中的某个子树的高度为H的话,其他的子树的高度一定为H。只有这样,在进行LL插入之后,A会由最小平衡二叉树的根结点,变为最小不平衡二叉树的根结点。
下面开始探讨,如何恢复平衡?
我们的目标是:
- 恢复平衡;
- 保持二叉排序树的特性。
- 也就是
左子树结点值 < 根节点值 < 右子树结点值
- 也就是
BL < B < BR < A < AR
- 也就是
对于这种情况,我们的做法是,将B右旋。具体的做法是:
也就是将B结点旋转,成为新的根结点,之后,由于BL<B,所以BL当然成为B的左孩子,但是由于B的右孩子已经挂了A了,那么BR就要去挂到A的下方,又因为BR<A,所以BR成为A的左孩子(旋转后A保证是没有左子树的)。
这样一来,就将最小不平衡子树恢复平衡,并且依然保持着二叉排序树的特性。
2.调整最小不平衡子树(RR)
这种情况我们需要做的调整,将B左旋。让B结点向左上旋转,替代A结点的位置,B结点变成新的根结点。而A结点向左下旋转,变成B的左孩子。
接下来再把剩余的AL、BL、BR看情况给挂上去,就行了。由于BR>B,所以BR肯定要挂在B的右孩子;AL<A,则AL挂在A的左孩子;BL>A,则BL挂在A的右孩子。
这就使得二叉树恢复了平衡,同时使得其保持了二叉排序树的特性。
3.代码思路
对于右旋。
实现f向右下旋转,p向右上旋转:
(其中f是p的父节点,p为左孩子,gf为f的父节点)
①f->lchild = p->rchild;
②p->rchild = f;
③gf->lchild/rchild = p。
对于左旋。
实现f向左下旋转,p向左上旋转:
①f->rchild = p->lchild;
②p->lchild = f;
③gf->lchild/rchild = p。
4.调整最小不平衡子树(LR)
实际上,新结点插入到C的左子树还是右子树,处理的操作是一样的,最终达到的效果也是一样的。如下所示。
5.调整最小不平衡子树(RL)
可以看到,无论是LR,还是RL,其思想,或者说最终效果,就是最终让C去顶替A的位置。C先去顶替B的位置,然后再去顶替A的位置。
而LR、RL中的两次旋转,其实现思路与LL、RR的一次左旋/右旋实现思路是一样的,只不过它们是先左/右旋、后右/左旋了。
再次总结一下左旋、右旋的代码实现思路:
可以看到,只有左孩子才能右上旋;只有右孩子才能左上旋。
6.为什么只需调整最小不平衡子树
至此,我们已经能够具备平衡化的操作手段了,也就能够解决一开始的那个问题了。
但是我们不禁要思考一个问题:
为什么?
是因为,插入操作导致最小不平衡子树高度+1,经过调整后,高度恢复。所以,对于它的上一级结点来说,只要我们能把这棵子树的高度恢复原状,那么它上一级的父节点的平衡因子肯定也能恢复原状。因此,再往上的各个父节点的平衡因子也均恢复至原状了。
(三)查找效率分析
若树高为h,则最坏情况下,查找一个关键字最多需要对比h次,即查找操作的时间复杂度不可能超过O(h)。
也就是,分析二叉排序树的查找效率,实际上就是分析这棵树有多高。
平衡二叉树——树上任一结点的左子树和右子树的高度之差不超过1。
假设以n(h)表示深度为h的平衡树中含有的最少结点数。
实际上,有n(h) = n(h-1) + n(h-2) + 1
。
因此,如果说某个平衡二叉树的结点数为9的话,那么它的最大高度h=4。
即,如果一棵平衡二叉树的节点总数为n的话,那么它的最大高度h(max) = O(log₂n)。
而它的最大高度又反映了它的查找时间复杂度。
因此,平衡二叉树的平均查找长度为O(log₂n)。
十七、哈夫曼树
(一)带权路径长度
结点的权:有某种现实含义的数值(如:表示结点的重要性,等)。
结点的带权路径长度:从树的根到该结点的路径长度(经过的边数)与该结点上权值的乘积。
树的带权路径长度:树中所有叶子结点的带权路径长度之和(WPL,Weighted Path Length)。
如下面这四棵树的带权路径长度WPL的计算:
至此,我们便可以对哈夫曼树进行定义。
(二)哈夫曼树的定义
在含有n个带权叶节点的二叉树中,其中带权路径长度(WPL)最小的二叉树称为哈夫曼树,也称最优二叉树。
像这个例子中,我们给出了四个叶子结点,它们的权值分别为1、3、4、5。
我们可以构造各种形态的二叉树,把这些结点依次放到不同的位置,并保证这四个为叶子结点。
那么这些不同的二叉树,其带权路径长度WPL有可能相同,也有可能不同。但是对于这四个叶子结点,无论如何改变二叉树的形态,其WPL是不可能小于25的。也就是说,25就是有可能出现的最小的树的带权路径长度。
因此,上图中间的那两棵树,就是哈夫曼树。
(三)哈夫曼树的构造
最终可以得到:
其WPL是最小的,WPL = 1*7 + 2*3 + 3*2 + 4*1 + 4*2 = 31
。
可以看到,哈夫曼树有这样一些性质:
1)每个初始结点最终都称为叶节点,且权值最小的结点到根结点的路径长度越大。
2)由于我们对n个结点每次进行两两结合,直至剩下1个结点,因此我们结合的次数为n-1次,且每结合一次,产生一个新结点。所以哈夫曼树的结点总数为2n-1。
3)哈夫曼树中不存在度为1的结点。
4)哈夫曼树并不唯一,但WPL必然相同且为最优。
对于哈夫曼树并不唯一,如上图情况,我们还可以构造一个与之不同的哈夫曼树,如下所示:
而其WPL依然是最小值31。
(四)哈夫曼编码
固定长度编码——每个字符用相等长度的二进制位表示
但是若只对A、B、C、D这四个字符来说(例如,在考试当中的选择题的答案),就没有必要用8位二进制数来区分了,而只需要用2位二进制数即可区分。
那么,假设由100个选择题,其中有80题选C,10题选A,8题选B,2题选D。
那么,所有答案的二进制长度 = 80*2 + 10*2 + 8*2 + 2*2 = 200bit
。
实际上,这种编码方案,我们也可以把它映射为树的表示形式,
即,由根结点出发,如果往左走,那么表示这是二进制0,如果往右走,表示这是二进制1。
那么,刚才我们算的所有答案的二进制长度,其实就是算了这棵树的带权路径长度。
那么接下来要思考,还有没有比这种编码方案更优秀的方案?也就是要让它们之间传递的二进制长度的这个bit信息尽可能的少。就是要尽可能追求我们最终构造的这棵编码树,它的带权路径长度尽可能的小。
那么就又回到了我们哈夫曼树的构造问题了。给出四个叶子结点以及权值,构造哈夫曼树。
WPL=80*1 + 10*2 + 2*3 + 8*3 = 130
那么这种编码方式,也就是各个字符所对应的长度不等。
可变长度编码——允许对不同字符用不等长的二进制位表示。
所以,对于一个字符集,我们若要设计一系列可变长度编码的话,所有字符对应到编码树里面,只能当做叶子结点,不能当做某一个分支结点。
换一个角度来说,这种编码方式,没有一个编码是另一个编码的前缀。则称这样的编码为前缀编码。前缀码解码无歧义,而非前缀编码在解码的时候有歧义。
由哈夫曼树得到哈夫曼编码——字符集中的每个字符作为一个叶子结点,各个字符出现的频度作为结点的权值,根据之前介绍的方法构造哈夫曼树。
左分支看成0,右分支看成1。(当然,反过来也无所谓)
同时,由于给定若干个叶子结点及其权重,构造出的哈夫曼树是不唯一的,因此,哈夫曼编码也是不唯一的。但同样的,即使是不唯一的,但是它的WPL都是相同的,是最小值。