树与二叉树
知识框架
树和图应当是较为复杂的数据结构,在树结构中,一般我们使用较多的都是二叉树
二叉树分为根结点左孩子右孩子三部分,我们根据遍历顺序不同可以分出先序中序后序三种遍历方式。
同时对树的遍历还有广度优先,深度优先两种遍历方式
树的应用范围较为广泛,本章中我们将学习到并查集和哈夫曼树两种应用。
树的基本概念
树的定义
树是n个结点的有限集,当n=0时树为空树,在任意一颗非空树中都满足:
1.有且只有一个特定的点称为根结点
2.当n>0时,其余结点的集合也可以看作一颗树,我们将其子集称为该树的子树。
除了根结点无前驱,其余树都有且仅有一个前驱,而所有结点都可以有多个或0个后继。
基本术语
1)树的最上端结点称为根结点,某结点的子结点被称为孩子结点,同一结点的多个孩子称为兄弟结点,该结点是其孩子结点的父结点。
2)结点的孩子个数称为结点的度。树的度是其最大结点的度。
3)度大于0的结点(有孩子的结点)称为分支结点,没有孩子的结点称为叶子结点。
4)树的高度即树的层次,以根结点为第一层,每个孩子为一层
5)若每个子结点从左到右是有序的称为有序树,否则称为无序树
6)从结点A到结点B所经历的边数称为AB之间的路长
7)森林是n个不相交的树的集合,森林和树很相似,本质上森林就是由多个树为子树,加上一个根节点构成的树
二叉树的概念
二叉树是指一种树结构,其中每个结点最多只有两个孩子结点,两个孩子结点被区分为左子树和右子树
特殊的二叉树
1)完全二叉树,即每个非叶子结点都有两个孩子的树。
2)满二叉树,满二叉树是指高度为h,结点数为2^h-1,即为前h-1层都满结点的树。
3)二叉排序树,即左子树上所有结点的值都小于其根节点的值,右子树上的所有结点的值都大于其根结点的值的树,其中任意子集作为子树都是一颗二叉排序树
4)平衡二叉树,树上任意结点的左子树和右子树的深度之差都不超过1
二叉树的存储
对于二叉树的存储,我们一般不会采取顺序存储,而是链式存储
顺序存储的二叉树就是按照层次遍历来顺序存储其所有结点,并且其中空孩子结点也需要一个0的存储单位来进行存储。甚至空孩子的子结点也要一个0的存储单位来进行存储,直到叶子结点及其兄弟结点全部存储完毕。
链式存储是主要存储方式,一般我们把空间划分为三个域:左孩子指针域Lchild,值域data,右孩子指针域Rchild
链式存储结构如下
typedef struct Btree{
typedef data;
Btree* Lchild;
Btree* Rchild;
};
二叉树的遍历
常见的遍历方式有先序中序后序三种遍历法
先序遍历
先序遍历指的是:根左右的遍历顺序,即按照根节点左孩子右孩子的顺序进行访问。
递归算法,注意当孩子为空并不是不访问,而是访问了空结点之后再进行其他操作,虽然看起来和没访问一样。
void PreOrder(Btree T){
visit(T);
PreOrder(T->Lchild);
PreOrder(T->Rchild);
}
中序遍历和后序遍历也一样,只需调动这三个语句的排列顺序即可
如果用非递归的方式也可以实现三种遍历法,这里以中序为例
由于非递归的方法实现的遍历只能在本函数中完成全部遍历过程,因此是非常不方便的,其中各种操作也必须在本函数中实现,我们只能借助栈来帮助我们确定访问顺序。将要访问的结点依次入栈。
void MidOrder(Btree T){
Stack S;InitStack(S);Btree p=T;//用临时树p来表示当前访问的子树
while(p!=NULL ||!isEmpty(S)){ //当子树和栈任意一个非空时继续
if(p!=NULL){//先确定访问顺序存储到栈S中
push(S,p);//由于访问顺序是左根右,因此先将根入栈待会访问
p=p->Lchild;}
else{ //p=NULL说明无结点可访问,该遍历了
pop(S,p);//将栈顶元素出栈赋值给p
visit(p);//栈里的元素实质上就是中序存储的
p=p->Rchild;
}
}
}
层次遍历
层次遍历就是自上而下,自左向右的遍历方式
要实现这样的访问方式,我们需要队列来作为辅助,我们在每次访问一个根节点的时候,都要把其子结点入队,由于队列是先进先出的,由于我们入队的顺序是从左至右,因此遍历下一层孩子的时候也是由左至右的顺序
void LevelOrder(Btree T){
Queue Q;Btree p;
InitQueue(Q);
EnQueue(Q,T);//初始化时将根节点入队
while(Q!=NULL){
DeQueue(Q,p);//队头元素出队到p
visit(p);
if(p->Lchild !=NULL)
EnQueue(Q,p->Lchild);
if(p->Rchild !=NULL)
EnQueue(Q,p->Rchild);
}
}
虽然二叉树不同的遍历顺序对应的结构是唯一的,但是遍历序列若想反向得出二叉树可能有多种不同的二叉树,需要注意。
线索二叉树
如果我们用序列来表示二叉树的话,不同的序列方式:先序中序和后序,对于同一颗二叉树可以得到不同的序列。然而,在传统的链表结构中,我们无法表示出子树在序列之间的关系。由此就产生了线索二叉树,线索二叉树的特点就是用其左右子树表达了在序列中的结点关系。例如若先序表达根节点为A,左孩子B右孩子C,则序列结果为ABC,则A称为B的前驱结点,C为B的后继结点,A无前驱,C无后继。
在线索二叉树结构中,我们增加了两个数据域ltag和rtag,当tag=0时代表其对应指针指向的是孩子结点,而当ltag=1时代表对应左指针指向的是前驱结点,而rtag=1代表右指针指向的是后继结点。
typedef struct ThreadNode{
typedef data;
int ltag;
int rtag;
ThreadNode *lchild,*rchild;
};
我们用InThread函数来代表对两个结点构建线索化,当某个指针为非空说明对应孩子节点存在,因此当且仅当某指针为空时我们才能将其线索化。
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会成为中序排序序列的最后一个节点,而p结点会成为pre位置后的NULL结点,pre则为p的前驱节点,最后还需要我们把NULL作为pre的后继结点线索化。若想其他顺序线索化,只需改变上述函数的左根右顺序即可。
void CreateThread(ThreadTree T){
ThreadTree pre=NULL;
if(T!=NULL){
InThread(T,pre);
pre->rchild=NULL;//线索化使得pre成为最后一个元素,还要使得其后继指向空
pre.rtag=1;
}
}
哈夫曼树和哈夫曼编码(最优二叉树)
哈夫曼树的要求是对于给出的所有带权值点,我们需要找出一个二叉树结构使得其WPL值得到最小,并且每个带权结点都是叶子结点,且每一个左子树的权值都应当小于其右子树。
其中WPL= ( ∑ i = 1 n w i l i ) \displaystyle \left( \sum_{i=1}^n w_i l_i \right) (i=1∑nwili)
我们要知道,如果想要WPL最小,那么权值越大离根节点就应当越近,使得权值*路长尽可能小,而权值越小的自然也越远
因此一颗哈夫曼树的构建我们可以每次选取出其中权值最小的两个结点组成一颗新的子树,例如7524这四个结点,我们选取24组成一颗子树,其权值和为6,其中权值小的为左子树,权值大的为右子树。
随后在756中我们选取了5和6作为子树,那么5为左子树,6为右子树,其总和为11
最后选择7和11,7为左子树,11为右子树,直到只剩下一个结点时,此时构建出的就是哈夫曼树。
而哈夫曼编码则是基于哈夫曼树的基础上,我们定下一个规则:
所有的叶子结点都代表着一个字符
如果往左子树就标记一个0,往右子树就标记一个1(当然可以不同)
一般我们以字符出现的频率作为权值,频率越高权值越大,离根节点也越近
哈夫曼编码的好处是,使用了前缀编码,如果我们从左至右进行解码是不会产生任何歧义的,比如b是101,那么就不会有任何一个字符编码的前缀会是101。
//注意,c++是没有array.length这个函数的,本质上是未实现的伪函数
//若用其他结构来存储也是可以实现的,并且其他结构比如栈队列可以求长度
//哈夫曼树结点结构
typedef struct HuffmanTree{
int weight;//结点权重
HuffmanTree *lchild,rchild;
int parent, left, right;//之所以设置父节点的int值,是为了判断某节点是否已经构成子树
//这样待会选取时就不需要删除原有树,只需跳过parent!=0的结点即可
};
void haffman(HuffmanTree T[],int n,HuffmanTree *p){//p作为最终的根节点返回
while(n!=1){ //n为T数组内结点的数量
findmin(T,T.length);// 这个函数来寻找其中权值最小的两个结点,并将他们结合为新的子树
n=n-1;
}
for(int i=1;i<=T.length;i++){
if(T[i].parent==0){ //T[0]不使用,之所以不用,是因为我们将parent
p=T[i]; //的初始值设为了0,这就意味着若使用了则
break;} //对应的父母是T[0],如果为-1就无所谓了
}
return 0;
}
void findmin(HuffmanTree T[],int length){
int min1=0,min2=0;
int num1=0,num2=0;
for(int i=1;i<=length;i++){
if(T[i].parent==0 && min1<T[i].weight){
min1=T[i].weight;
num1=i;
}
for(int j=1;j<=length;j++){
if(T[j].parent==0 && T[j].weight!=min1 && min2<T[j].weight){
min2=T[j].weight;
num2=j;
}
HuffmanTree p;
p->lchild=T[num1];p.left=num1;
p->rchild=T[num2];p.right=num2;
p.weight=min1+min2;
p.parent=0;
T[num1].parent=length+1;T[num2].parent=length+1;
T[length+1]=p;//注意创建静态数组的时候分配足够的空间
}