二叉树
文章目录
二叉树的定义
二叉树(binary tree)是指树中节点的度不大于2的有序树,它是一种最简单且最重要的树。二叉树的递归定义为:二叉树是一棵空树,或者是一棵由一个根节点和两棵互不相交的,分别称作根的左子树和右子树组成的非空树;左子树和右子树又同样都是二叉树。
说一下几点需要注意的地方:
- 每个结点最多有两棵子树,也就是度不大于2
- 左子树和右子树是有定义的,不能颠倒,即使某个节点只有一颗子树,也有左右之分,例如上图的D结点和G结点,如果我把G结点掰到右边,就变成了右子树
根结点有五种基本形态:
- 空二叉树
- 只有一个根结点
- 根结点只有左子树
- 根结点只有右子树
- 根结点既有左子树又有右子树
特殊二叉树
- 满二叉树:如果一棵树只有度为0的结点和度为2的结点,并且度为0的结点在同一层,则这棵树为满二叉树。
-
完全二叉树:深度为k,有n个结点的二叉树当且仅当其每一个结点都与深度为k的满二叉树中编号从1到n的结点一一对应时,称为完全二叉树。
完全二叉树的特点是叶子结点只可能出现在层序最大的两层上,并且某个结点的左分支下子孙的最大层序与右分支下子孙的最大层序相等或大1
这句话什么意思呢?
叶子结点只可能出现在层序最大的两层上,也就是说只能出现在3、4层
由于完全二叉树的n个结点,每一个结点都与深度为k的满二叉树中编号从1到n的结点一一对应,也就是编号一定要连续,例如下面这种情况就不是完全二叉树,因为编号为10的结点空掉了。
二叉树的性质
-
二叉树的第i层上至多有 2 i − 1 2^{i-1} 2i−1(i≥1)个节点。
-
深度为h的二叉树中至多含有 2 h − 1 2^h-1 2h−1个节点
-
若在任意一棵二叉树中,有 n 0 n_0 n0个叶子节点,有 n 2 n_2 n2个度为2的节点,则必有 n 0 = n 2 + 1 n_0=n_2+1 n0=n2+1
-
具有n个节点的完全二叉树深为 l o g 2 x + 1 log_2x+1 log2x+1(其中x表示不大于n的最大整数)
-
若对一棵有n个节点的完全二叉树进行顺序编号 ( 1 ≤ i ≤ n ) (1≤i≤n) (1≤i≤n),那么,对于编号为 i ( i ≥ 1 ) i(i≥1) i(i≥1)的节点:
⑴ i = 1 i=1 i=1时,该节点为根,它无双亲节点 。
⑵ i > 1 i>1 i>1时,该节点的双亲节点的编号为i/2 。
⑶ 2 i ≤ n 2i≤n 2i≤n,则有编号为2i的左节点,否则没有左节点 。
⑷ 2 i + 1 ≤ n 2i+1≤n 2i+1≤n,则有编号为2i+1的右节点,否则没有右节点 。
这里我重点解释一下5.⑶和5.⑷
当 n = 10 n=10 n=10时,对于 i = 5 i=5 i=5时, 2 i ≤ n 2i≤n 2i≤n,所以有编号为10的左节点,但不满足 2 i + 1 ≤ n 2i+1≤n 2i+1≤n,所以没有编号为 11 11 11的右结点
二叉树的存储结构
二叉树的顺序存储结构
二叉树的顺序存储结构就是用一维数组存储二叉树中的结点,并且结点的存储位置,也就是数组的下标要能体现结点之间的逻辑关系,比如双亲与孩子的关系,左右兄弟的关系等。
下面是完全二叉树的存储结构:
下标 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|
数据 | A | B | C | D | E | F | G | H | I | J |
完全二叉树的编号在线性表中能够很好的把结点间的逻辑关系表现出来,当然,对于普通的二叉树结构,尽管层编号不能反映逻辑关系,但是也可以将其按完全二叉树来编号,把不存在的结点标为∧
下标 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|
数据 | A | B | C | D | E | F | ∧ | H | ∧ | J |
二叉树的链式存储结构
由于一个结点最多有两个孩子,那么对结构体的设计就变得简单很多,只需要包含一个数据域和两个指针域即可,我们称这样的链表为二叉链表:
lchild和rchild分别指向左孩子和右孩子
//二叉链表结构体定义
typedef struct BiTNode
{
TElemType data; //数据域
struct BiTNode *lchild,*rchild; //指针域
}BiTNode,*BiTree;
遍历二叉树
遍历 ( T r a v e r s a l ) (Traversal) (Traversal)是指沿着某条搜索路线,依次对树中每个结点均做一次且仅做一次访问。
遍历方法
-
前序遍历
先访问根结点,然后前序遍历左子树,再前序遍历右子树,遍历顺序为 A B D G E C F H I ABDGECFHI ABDGECFHI。
前序遍历算法:
void PreOrderTraverse(BiTree T)
{
if(T==NULL)
return;
printf("%c",T->data); //显示结点数据,可以更改为其他对结点的操作
PreOrderTraverse(T->lchild); //先序遍历左子树
PreOrderTraverse(T->rchild); //先序遍历右子树
}
算法原理:
调用 P r e O r d e r T r a v e r s e ( T ) PreOrderTraverse(T) PreOrderTraverse(T),根结点不为 n u l l null null,打印结点A,调用 P r e O r d e r T r a v e r s e ( T − > l c h i l d ) PreOrderTraverse(T->lchild) PreOrderTraverse(T−>lchild),访问A的左孩子,不为 n u l l null null,打印结点B,再递归调用 P r e O r d e r T r a v e r s e ( T − > l c h i l d ) PreOrderTraverse(T->lchild) PreOrderTraverse(T−>lchild),访问B的左孩子D,不为 n u l l null null,打印结点D,再次递归调用 P r e O r d e r T r a v e r s e ( T − > l c h i l d ) PreOrderTraverse(T->lchild) PreOrderTraverse(T−>lchild)访问D的左孩子结点G,不为 n u l l null null,打印结点G,G的左孩子为空,递归调用 P r e O r d e r T r a v e r s e ( T − > r c h i l d ) PreOrderTraverse(T->rchild) PreOrderTraverse(T−>rchild),访问G的右孩子,此时为 n u l l null null,返回,调用 P r e O r d e r T r a v e r s e ( T − > r c h i l d ) PreOrderTraverse(T->rchild) PreOrderTraverse(T−>rchild)访问结点D的右孩子,为 n u l l null null,返回,再递归调用 P r e O r d e r T r a v e r s e ( T − > r c h i l d ) PreOrderTraverse(T->rchild) PreOrderTraverse(T−>rchild)访问结点B的右孩子,不为空,打印结点E,由于E没有左右孩子,返回打印结点B的递归函数。递归执行完毕返回最初的 P r e O r d e r T r a v e r s e PreOrderTraverse PreOrderTraverse,继续遍历右子树,原理相同。
-
中序遍历
首先遍历左子树,然后访问根结点,最后遍历右子树,遍历顺序为 G D B E A C H F I GDBEACHFI GDBEACHFI。
中序遍历算法:
void InOrderTraverse(BiTree T)
{
if(T==NULL)
return;
InOrderTraverse(T->lchild); //中序遍历左子树
print("%c",T->data);
InOrderTraverse(T->rchild); //中序遍历右子树
}
算法原理:
首先调用 I n O r d e r T r a v e r s e ( T ) InOrderTraverse(T) InOrderTraverse(T),根结点不为 n u l l null null,于是调用 I n O r d e r T r a v e r s e ( T − > l c h i l d ) InOrderTraverse(T->lchild) InOrderTraverse(T−>lchild),访问结点A的左孩子结点B,结点B不为 n u l l null null,继续调用 I n O r d e r T r a v e r s e ( T − > l c h i l d ) InOrderTraverse(T->lchild) InOrderTraverse(T−>lchild),访问结点D,结点D不为 n u l l null null,于是继续调用 I n O r d e r T r a v e r s e ( T − > l c h i l d ) InOrderTraverse(T->lchild) InOrderTraverse(T−>lchild)访问D的左孩子G,然后访问G的左孩子,由于G没有左孩子,此时指针为 n u l l null null,打印结点G。
然后调用 I n O r d e r T r a v e r s e ( T − > r c h i l d ) InOrderTraverse(T->rchild) InOrderTraverse(T−>rchild),结点G没有右结点,指针返回,打印结点D,结点D没有左孩子,返回到B,打印结点B,再访问B的左孩子E,E没有左孩子,打印结点E,E没有右孩子,返回到B,B已经打印完毕,返回到A,打印根结点A,右子树遍历原理相同。
-
后序遍历
从左到右先叶子后结点的方式遍历访问左右子树,最后访问根结点,遍历顺序为 G D E B H I F C A GDEBHIFCA GDEBHIFCA。
后序遍历算法:
void PostOrderTraverse(BiTree T)
{
if(T==NULL)
return;
PostOrderTraverse(T->lchild); //后序遍历左子树
PostOrderTraverse(T->rchild); //后序遍历右子树
printf("%c",T->data);
}
-
层序遍历
从树的第一层开始访问,从上而下逐层遍历,在同一层中,按从左到右的顺序对结点逐个访问,遍历顺序为 A B C D E F G H I ABCDEFGHI ABCDEFGHI。
二叉树的建立
如果我们要建立一个如下图左边的二叉树,为了能在遍历时确认每个结点是否有左右孩子,我们可以用一个特定值来代表这些空的结点,例如‘#’。我们称这种处理后的二叉树为原二叉树的扩展二叉树,扩展二叉树就可以做到一个遍历序列确定一颗二叉树了,如下图扩展二叉树的前序遍历序列为AB#D##C##。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RHqOWQ8Z-1641389469701)(E:\gif动图\二叉树\10.png)]
建立二叉树的算法也用到了递归原理,思路和前面遍历算法一致,只是原来是打印结点的地方改为生成结点即可,然后我们把前序遍历的序列AB#D##C##依次用键盘输入就好。
//#表示空树,构造二叉链表表示二叉树T
void CreatrBiTree(BiTree *T)
{
TElemType ch;
scanf("%c",&ch);
if(ch == '#')
*T = NULL;
else
{
*T = (BiTree)malloc(sizeof(BiTNode));
if(!*T)
exit(OVERFLOW);
(*T)->data = ch; //生成根结点
CreateBiTree(&(*T->lchild)); //递归构造左子树
CreateBiTree(&(*T->rchild)); //递归构造右子树
}
}
线索二叉树
不难发现,刚刚我们创建的二叉树存在两个缺点:
- 空指针占据了一部分空间,且不储存任何东西,造成了空间浪费。
- 在遍历之前,我们不知道每个结点的前驱后继是谁,如果想要知道,则要遍历链表,造成时间浪费。
为了解决上述两个问题,我们可以考虑这些空地址,存放指向结点在某种遍历次序下的前驱和后继结点的地址,我们把这种指向前驱后继的指针称为线索,加上线索的二叉链表称为线索链表,相应的二叉树就称为线索二叉树(Threaded Binary Tree)。
如下图,在经过一遍中序遍历后,得到序列 H D B J E A F C G HDBJEAFCG HDBJEAFCG,现在我们利用所有空指针域中的 r c h i l d rchild rchild,改为指向它的后继结点,如H的后继指向D,D的后继指向B,B的后继指向J,…,G不存在后继, r c h i l d rchild rchild指向NULL
现在,我们将这棵二叉树的所有空指针域中的 l c h i l d lchild lchild,改为指向当前结点的前驱,如下图
我们对二叉树以某种次序遍历使其变成线索树的过程称做是线索化,但现在面临的问题是,我们如何知道一个结点的 r c h i l d rchild rchild指向的是结点的右孩子还是结点的后继? l c h i l d lchild lchild指向的是结点的左孩子还是前继,例如结点B的前继应该结点I,但实际上 l c h i l d lchild lchild指向的是左孩子D,因此要对这种情况进行区分,我们需要做一些标志,我们在每个结点在增设两个标志域 l t a g ltag ltag和 R t a g Rtag Rtag,这两个标志域只存放0和1的布尔变量,占用的内存空间要小于 l c h i l d lchild lchild和 r c h i l d rchild rchild的指针变量,结点结构如下:
l t a g ltag ltag为0时指针指向该结点左孩子,为1时指向该结点的前驱
r t a g rtag rtag为0时指针指向该结点右孩子,为1时指向该结点的后继
线索二叉树结构体实现
typedef enum {Link,Thread} PointerTag;//Link==0表示指向左右孩子指针
//Thread==1表示指向前驱或后继的线索
typedef struct BiThrNode
{
TElemtype data;
struct BiThrNode *lchild,*rchild;
PointerTag LTag;
PointerTag RTag;
}BiThrNode,*BiThrTree;
线索化的实质就是将二叉链表中的空指针改为指向前驱或后继的线索。由于前驱和后继的信息只有在遍历该 二叉树时才能得到,所以线索化的过程就是在遍历的过程中修改空指针的过程。
中序遍历线索化的递归函数代码如下:
BrTheTree pre //全局变量,始终指向刚刚访问过的结点,
void InThreading(BiThrTree p)
{
if(p)
{
InThreading(p->lchild); //递归左子树线索化
if(!p->lchild) //没有左孩子
{
p->LTag = Thread; //前驱线索
p->lchild = pre; //左孩子指针指向前驱
}
if(!pre->rchild) //前驱没有右孩子
{
pre->RTag = Thread; //后继线索
pre->rchild = p; //前驱右孩子指针指向后继,即当前的p
}
pre = p;
InThreading(p->rchild); //递归右子树线索化
}
}
这一段代码实际上是将新结点p递归插入到二叉树线索链表中,整个结构和双向链表类似,那么如果我们要遍历二叉链表,其实就等于操作一个双向链表结构。
首先,在二叉树线索链表中添加一个哨兵位头结点,并令其 l c h i l d lchild lchild域的指针指向二叉树的根结点(红色箭头),其 r c h i l d rchild rchild域指针指向中序遍历序列中最后一个节点(蓝色箭头)。然后,令中序遍历序列中第一个结点的 l c h i l d lchild lchild域指针,和左后一个结点的 r c h i l d rchild rchild域指针均指向头结点(分别为绿色箭头和黄色箭头)。这样定义的好处就是我们可以进行双向遍历。
遍历代码:
void InOrderTraverse_Thr(BiThrTree T) //T指向头结点
{
BiThrTree p;
p = T->lchild; //p指向根结点
while(p != T) //树为空或遍历结束的条件
{
while(p->LTag == Link) //LTag==0时,找到中序序列的第一个结点
p = p->lchild;
printf("%c",p->data);
while(p->RTag==Thread && p->rchild != T)
{
p = p->rchild;
print("%c",p->data);
}
p = p->rchild; //p进至右子树根
}
}
稍微解释一下上面这段代码
while(p->LTag == Link)
p = p->lchild;
printf("%c",p->data);
这个循环其实是由 A → B → D → H A→B→D→H A→B→D→H,H的 L T a g LTag LTag不为0打印结点H,也就是找到中序序列的第一个结点。
while(p->RTag==Thread && p->rchild != T)
{
p = p->rchild;
print("%c",p->data);
}
p = p->rchild; //p进至右子树根
由于结点H的 R T a g RTag RTag为1,且不指向头结点,所以打印H的后继结点D,此时D的 R T a g RTag RTag为0,不满足循环条件,执行 p = p − > r c h i l d ; p = p->rchild; p=p−>rchild;,进入到D的右孩子I
上面的操作不断循环遍历,直到 p = = T p == T p==T,二叉树线索链表打印完毕。
由于它充分利用了空指针域的空间(这等于节省了空间) ,又保证了创建时的一次遍历就可以终生受用前驱后继的信息(这意味着节省了时间)。所以在实际问题中,如果所用的二叉树需经常遍历或查找结点时需要某种遍历序列中的前驱和后继,那么采用线索二叉链表的存储结构就是非常不错的选择。
树、森林与二叉树的转换
树转换为二叉树
- 连接所有兄弟结点
- 对于每一个结点,只保留它与第一个孩子的连线,删除它与其他孩子的连线。
- 以树的根结点为轴心,将整棵树顺时针旋转一定的角度,使之结构层次分明。注意第一个孩子是二叉树结点的左孩子,兄弟转换过来的孩子是结点的右孩子。
森林转换为二叉树
- 把每棵树转换为二叉树
- 第一棵二叉树不动,从第二棵二叉树开始,依次把后一棵二叉树的根结点作为前一棵二叉树的根结点的右孩子,用线连接起来。当所有的二叉树连接起来后就得到了由森林转换来的二叉树。
Haffman树
给定N个权值作为N个叶子结点,构造一棵二叉树,若该树的带权路径长度达到最小,称这样的二叉树为最优二叉树,也称为哈夫曼树 ( H u f f m a n T r e e ) (Huffman Tree) (HuffmanTree)。哈夫曼树是带权路径长度最短的树,权值较大的结点离根较近。
光看概念比较抽象,下面以学生评分系统为例来学习哈夫曼树来
if(score<60)
printf("不及格");
else if(score<70)
printf("及格");
else if(score<80)
printf("中等");
else if(score<90)
printf("良好");
else
printf("优秀");
由于学生的成绩一般来说服从正态分布,也就是说较多的同学的成绩会集中分布在某个区间,而上面这样的程序,使得所有的成绩都需要先判断是否及格,再逐级而上得到结果,当输入量很大时,算法的效率是有问题的。
在对学生的学习成绩进行分析后,可得到以下规律
分数 | 0~59 | 60~69 | 70~79 | 80~89 | 90~100 |
---|---|---|---|---|---|
比例 | 5% | 15% | 40% | 30% | 10% |
在得到每个评级所占比例后,我们可以对算法进行重新设计,比例大的优先判断,比例小的放在后面判断,如下图
我们把前后两种方法分别简化成叶子结点带权的二叉树,如下图,其中A表示不及格、B表示及格、C表示中等、D表示良好、E表示优秀
从树中一个结点到另一个结点之间的分支构成两个结点之间的路径,路径上的分支数目称做路径长度。树的路径长度就是从树根到每一个结点的路径长度之和。
二叉树a的树路径长度=1+1+2+2+3+3+4+4=20
二叉树b的树路径长度=1+2+2+3+3+1+2+2=16
结点的带权的路径长度为从该结点到树根之间的路径长度与结点上权的乘积。树的带权路径长度为树中所有叶子结点的带权路径长度之和,带权路径长度$ WPL$ 最小的二叉树称做赫夫曼树。
二叉树a的 W P L = 5 × 1 + 15 × 2 + 40 × 3 + 30 × 4 + 10 × 4 = 315 WPL=5×1+15×2+40×3+30×4+10×4=315 WPL=5×1+15×2+40×3+30×4+10×4=315
二叉树a的 W P L = 5 × 3 + 15 × 3 + 40 × 2 + 30 × 2 + 10 × 2 = 220 WPL=5×3+15×3+40×2+30×2+10×2=220 WPL=5×3+15×3+40×2+30×2+10×2=220
那么上图中的二叉树b是如何构建出来的,这个二叉树是不是最优的哈夫曼树?
构建方法:
- 先把有权值的叶子结点按照从小到大的顺序排列成一个有序序列,即: A 5 , E 10 , B 15 , D 30 , C 40 A5,E10,B15,D30, C40 A5,E10,B15,D30,C40
- 如下图,先取权值最小的两个结点作为一个新结点 N 1 N_1 N1的两个子结点,其中权值较小的作为左孩子,较大的作为右孩子,新结点的权值就是两个叶子权值之和5+10=15。
- 将 N 1 N1 N1替换有序序列中的A与E,保持从小到大排列。即: N 1 15 , B 15 , D 30 , C 40 N_115,B15,D30,C40 N115,B15,D30,C40。
-
重复步骤2。将 N 1 N_1 N1与 B B B作为一个新结点 N 2 N_2 N2的两个孩子结点。 N 2 N_2 N2的权重15+15=30。
-
重复步骤3。将将 N 2 N2 N2替换有序序列中的 N 1 N_1 N1与 B B B,保持从小到大排列。即 N 2 30 , D 30 , C 40 N_230,D30,C40 N230,D30,C40。
重复上面的步骤,最终构建出下图中的哈夫曼树
此二叉树的带权路径长度 W P L = 40 × 1 + 30 × 2 + 15 × 3 + 10 × 4 + 5 × 4 = 205 WPL=40×1+30×2+15×3+10×4+5×4=205 WPL=40×1+30×2+15×3+10×4+5×4=205,这比上面的二叉树b的 W P L = 220 WPL=220 WPL=220更小,所以此时构造出来的二叉树才是最优的哈夫曼树。
哈夫曼编码
在传输信息时,都会将信息转换为二进制进行传输,例如一段文字内容 B A D C A D F E E D BADCADFEED BADCADFEED,假设每个字母对应的二进制为下表所示。
字母 | A | B | C | D | E | F |
---|---|---|---|---|---|---|
二进制 | 000 | 001 | 010 | 011 | 100 | 101 |
现在我们就可以将上面这段文字内容翻译成二进制 001000011010000011101100100011 001000011010000011101100100011 001000011010000011101100100011,现在仅仅只有10个字母,二进制就如此之长,那么如果字母更多,那翻译过来的二进制是不可想象的。在实际生活中,每个字母出现的概率是不同的,这时我们就可以利用哈夫曼树来重新规划他们,下面假设这几个字母出现的概率。
A | B | C | D | E | F |
---|---|---|---|---|---|
27% | 8% | 15% | 15% | 30% | 5% |
构造的哈夫曼树如下左图所示,将权值左分支改为0,右分支改为1,得到右图
我们对这些字母用其从树根到叶子所经过路径的0或1来编码,得到下表
字母 | A | B | C | D | E | F |
---|---|---|---|---|---|---|
二进制 | 01 | 1001 | 101 | 00 | 11 | 1000 |
现在 B A D C A D F E E D BADCADFEED BADCADFEED的二进制编码为 1001010010101001000111100 1001010010101001000111100 1001010010101001000111100
现在我们来对比一下前后两次编码长度:
原编码: 001000011010000011101100100011 001000011010000011101100100011 001000011010000011101100100011 (共30个字符)
新编码: 1001010010101001000111100 1001010010101001000111100 1001010010101001000111100 (共25个字符)
由于出现频率较多的字母的二进制被缩短了,所以总体上二进制比原编码短了,从而将我们需要传输的数据压缩了,这就是哈夫曼编码的优势。而接收数据的一方只要用这同一套编码规则将二进制翻译成原信息即可。
参考文献:
- 大话数据结构
- 《数据结构》C语言版(清华严蔚敏考研版)