王道数据结构第五章:树与二叉树

5.1_1.树的定义和基本术语

树的基本定义(逻辑结构)

树是n(n>=0)个结点的有限集合,当n=0时,称为空树。在任意一棵非空树中应满足:

1)有且仅有一个根结点

2)当n>1时,其余结点可分为m(m>0)个互不相交的有限集合t1,t2,t3.....其中每个集合本身又是一棵树,称为根结点的子树

树的基本概念:根结点、边、分支结点、叶子结点。空树:结点数为0的树。

非空树:有且仅有一个根结点。非空树里只有根结点是没有前驱的,只有叶子结点没有后继。

               因此我们把叶子结点又叫做(终端结点),分支结点称为(非终端结点)。

结点之间的关系描述:

祖先结点:从一个结点出发,往上走到根结点为止。 子孙结点相反。

双亲结点(父结点)孩子结点   兄弟结点  堂兄弟结点

两个结点之间的路径是有方向的,只能从上往下。路径长度就是经过了几条边。

结点、树的属性描述:

结点的层次(深度)——从上往下开始数,一般认为根结点是第一层。

结点的高度——从下往上数。       树的高度(深度)——总共多少层。

结点的度——有几个孩子(分支)  树的度——各结点度的最大值。

叶子结点的度=0,非叶子结点的度>0.

有序树VS无序树

有序树:逻辑上看,树中结点的各子树从左往右是有次序的,不能互换。

无序树:逻辑上看,从左往右是无次序的,可以互换。

具体看你要用树来存什么,是否需要用结点的左右位置反应某些逻辑关系。

森林VS树

森林:森林是m(m>0)棵互不相交的树的集合。

树可以是空树,森林也可以是空森林。

5.1_2树的性质

考点1:结点数=总度数+1.加1是因为根结点没有算在里面。

考点2:度为m的树和m叉树的区别

树的度——各结点度的最大值。       m叉树——每个结点最多有m个孩子的 

                 度为m的树                          m叉树
任意结点的度<=m(最多m个孩子)任意结点的度<=m
至少一个结点的度=m允许所有结点的度都<m
一定是非空树,至少m+1个结点可以是空树

考点3:度为m的树,第i层最多有m^{i-1}个结点(i>=1)

             第一层1个根结点,第二层有m个,第三层m²个,故有此结论。

考点4:高度为h的m叉树至多有\frac{m^{h}-1}{m-1}个结点。

             等比数列求和即可。

考点5:高度为h的m叉树结点数最少有h个。

             高度为h、度为m的树至少有h+m-1个结点。

考点6:具有n个结点的m叉树最小高度为\log {_{m}}^{n(m-1)+1}.

             高度最小的情况:每个结点都有m个孩子。已知高度为h的m叉树最多有多少个结点,

             An-1<n<=An;解方程即可。

5.2_1二叉树的定义和基本术语

二叉树基本概念

二叉树是指n(n>=0)个结点的有限集合。

1)或者为空二叉树,即n=0;

2)或者由一个根结点和两个互不相交的被称为根的左子树右子树组成。左子树和右子树又分别        是一棵二叉树。

特点:二叉树每个结点最多只有两棵子树,左右子树顺序不能颠倒(二叉树是有序树

二叉树的五个状态:空二叉树 只有左子树 只有右子树 只有根节点   左右子树都有

几种特殊的二叉树

1.满二叉树:一棵高度为h,且含有2^{h}-1个结点的二叉树。

   特点:只有最后一层有叶子结点。不存在度为1的结点。

              若从上往下,从左往右,依次排序,则     编号为i的树,左子结点序号为2i,右子结点2i+1;                父结点的编号为int i/2向下取整。

2.完全二叉树。当且仅当每个结点都与高度为h的满二叉树中编号为1~n的结点一一对应时,称为       完全二叉树。

   特点:只有最后两层可能出现叶子结点。最多只有一个度为1的结点。

              一个完全二叉树总共有n个结点,i<[n/2]为分支结点,i>[n/2]为叶子结点。

             如果有个结点只有一个孩子,那必然是左孩子。

3.二叉排序树。

  左子树上所有结点的关键字均小于根结点的关键字。

  右子树上所有结点的关键字均大于根结点的关键字。

  左子树和右子树又各是一棵二叉排序树。二叉排序树可以用于排序搜索。

4.平衡二叉树

  任何结点的左子树和右子树深度之差不超过1。

   平衡二叉树有更高的搜索效率。

5.2_2二叉树的性质

二叉树的常考性质

考点1:设非空二叉树中度为0、1、2的结点个数分别为n0,n1,n2则有n0=n2+1;

             叶子结点比二分支结点多一个。

             假设二叉树中结点总数为n,则n=n0+n1+n2;且n=n1+2n2+1(结点数等于总度数加1);

             两边化简可知n0=n2+1;

考点2:二叉树第i层至多有2^{i-1}个结点(i>=1);

             m叉树第i层至多有m^{i-1}个结点(i>=1)

考点3;高度为h的二叉树至多有2^{h}-1个结点(满二叉树)

完全二叉树的常考性质

考点1:具有n个结点的完全二叉树高度为\left [ \log {_{2}}^{(n+1)}\right ]或者\left [ \log {_{2}}^{n} \right ]+1;

             第i个结点编号层次为\left [ \log {_{2}}^{n} \right ]+1或者\left [ \log {_{2}}^{(n+1)}\right ]

考点2:对于完全二叉树,可以由已知的结点数n推出度为0、1、2的结点个数为n0,n1,n2;

             完全二叉树最多只有一个度为1的结点,n1只可能为0或者1

             n0+n2=2n2+1,所以n0+n2一定是奇数。

             若n是奇数,则n1=0,n0有k+1个,n1有k个。若n是偶数则相反。

5.2_3二叉树的存储结构

二叉树的顺序存储

如果是一个完全二叉树,我们可以利用数组实现。

#define MaxSize 100
struct TreeNode{
    ElemType value;//结点中的数据元素
    bool isEmpty;//结点是否为空
};
TreeNode t[MaxSize];
//定义一个数组,按照从上至下从左往右的顺序依次存储完全二叉树的各个结点。
//初始化时所有结点标记为空
for(int i=0;i<MaxSize;i++){
    t[i].isEmpty=true;
}

可以让第一个位置空缺,保证数组下标和结点编号一至。

几个常见的基本操作:左孩子、右孩子、父结点、层次;判断是否是分支/叶子结点。

如果不是完全二叉树,我们不能按照上述方式进行,一定要把结点编号和完全二叉树的编号对应起来。

因此,顺序存储结构只适合完全二叉树。

二叉树的链式存储

typedef struct BiTNode{
   ElemType data;//数据域
   struct BiTNode *lchild,*rchild;//左右孩子指针
}BiTNode,*BiTree;
//定义一棵空树
BiTNode root=NULL;
//插入根结点
root=(BiTNode)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;//作为根结点的左孩子.

n个结点,一共2n个指针,其中n+1个是空指针。空指针可以用来构造线索二叉树。

如果需要找到指定结点p的左孩子右孩子很简单,但是如果要找到父结点很困难,需要从根结点开始遍历。

所以工作中一般会再定义一个父指针,称为三叉链表,而王道考研一般不会使用。

5.3_1二叉树的前中后序遍历

遍历:按照某种次序把所有结点都访问一遍。

二叉树的递归特性:

1)要么是个空二叉树

2)要么就是由“左孩子+右孩子+根结点”组成

先序遍历:根左右(NLR)      中序遍历:左根右(LNR)     后序遍历:左右根(LRN)

分支节点逐层展开法:脑补空结点,以先序遍历为例,先从根结点出发,画一条路,如果左边还没有走到尽头,优先往左边走,走到路的尽头(空结点)就往回走,如果左边没路了,就往右边走。如果左右都没路了,就往上走。

先序遍历:

1.若二叉树为空则什么也不做。

 2.若二叉树非空:先访问根结点,再先序遍历左子树,最后先序遍历右子树。

代码:

typedef struct BiTNode{
   ElemType data;
   struct BiTNode *lchild,*rchild;
}BiTNode,*BiTree;
void Pre0rder(BiTree T){
     if(T!=NULL){
        visit(T);//访问根结点
        Pre0rder(T->lchild);//遍历左子树
        Pre0rder(T->rchild);//遍历右子树
     }
};

空间复杂度O(h+1)h为二叉树的高度。

中序遍历后序遍历可类比写出。

5.3_2二叉树的层序遍历

算法思想:

1)初始化一个辅助队列

2)根结点入队

3)若队列非空,则队头结点出队,访问该结点,并将其左右孩子插入队尾(如果有的话)。

4)重复3)直至队列为空

//二叉树的结点(链式存储)
typedef struct BiTNode{
   char data;
   struct BiTNode*lchild,*rchild;
}BiTNode,*BiTree;
//链式队列结点
typedef struct LinkNode{
   BiTNode *data;//存指针而不是结点,这样空间就会小很多
   struct LinkNode *next;
}LinkNode;
typedef struct{
   LinkNode *front,*rear;//队头队尾
}LinkQueue;
//层序遍历
void Level0rder (BiTNode 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);//右孩子入队
       }
}

5.3_3由遍历序列构造二叉树 

 由该图我们可以看出,给定一棵二叉树和遍历方法,得到的结果是一定的。但是给定遍历序列,二叉树却不唯一。

若给出一棵二叉树的前中后层序遍历中的一种,不能唯一确定一棵二叉树。

但是给出四种中的其中两种,我们就可以得到唯一的确定的二叉树。(必须有中序序列哈)

5.3_4线索二叉树的概念

以该图为例,我们能很轻松得到中序遍历序列。尽管二叉树是非线性的,但是得到的序列却有着线性表的性质,每个元素有前驱后继,二叉树中一个结点有唯一的前驱但可能有多个后继 。接下来思考两个问题:

我们从G出发,能否遍历这棵二叉树呢?显然不能,因为G只有孩子的指针,没有前驱的指针,不能完成遍历。

给定F的指针,能否找到F的前驱呢?不能,F只有它孩子的指针。

咸鱼老师是这样处理的,双指针法重新遍历二叉树,pre定义为前驱指针,q指针为当前指针,我们每次移动前,pre指针指向的都是q指针的前驱,接着先移动pre,再移动q,这样每次visit()访问,直到p=q,此时pre指针指向的就是p指针(也就是结点F)的前驱。

这种方法显而易见非常耗费时间,那我们有什么简单方法吗?

中序线索二叉树:

已知n个结点的二叉树,有n+1个空链域,可用来记录前驱后继的信息

我们把指向前驱、后继的指针称为线索。把二叉树线索化之后,找结点的前驱后继很方便,并且遍历也变得非常方便。这便是线索化的作用,目前知道就好,结点B怎么找后继我们后面探讨。

//二叉树的链式存储
typedef struct BiTNode {
	int data;
	BiTNode* lchild, * rchild;//
}BiTNode;
//线索二叉树结点
typedef struct ThreadNode {
	int data;
	ThreadNode* lchild, * rchild;
	int ltag, rtag;//左右线索标志
}ThreadNode;

tag==0,表示指针指向孩子;tag==1,表示指针指向线索。

先序线索二叉树

 原理是类似的,只是结点的指针不一样罢了。

后序线索二叉树同理,不再赘述。

 

5.3._5 二叉树的线索化 

用土办法找到结点p的前驱:定义两个指针q和pre,q重新开始遍历,pre一开始指向空,只要q指向的不是p,我们就循环移动pre和q,直到q==p时,pre指向的就是p的前驱。

//中序遍历
void InOrder(BiTree T){
    if(T!=NULL){
        InOrder(T->lchild);
        visit(T);
        InOrder(T->rchild);
    }
}
//访问结点q
void visit(BiTNode *q){
    if(q==p){
        final=pre;
    }
    else{
        pre=q;
    }
}
//辅助全局变量
BiTNode *p;
BiTNode *pre=NULL;
BiTNode *final=NULL;

中序线索化

一开始ltag、 rtag均设置为0,便于初始化。

 现在有一个问题,当我们线索化完成之后,到达最后一个结点,结点c不应该再有右孩子的线索化,不过我们pre设置为了一个全局变量,这样我们可以在其它函数中完成对最后一个结点的线索化,让它的右孩子指针指向NULL。最后还要检查rtag是否==1.

我们看一下王道书上教材写的代码

先序线索化 

先序线索化需要作出调整,避免出现转圈现象:

如图所示,我们处理完第三个结点D之后,接下来要处理D的左孩子,可是我们已经把D的左指针指向B了,那么这样就会导致循环,“爱的魔力转圈圈”。

 为此,我们作出如下调整:当需要遍历它的左孩子时,我们进行判断,看看ltag是否==0,如果是,说明不是前驱线索可以继续遍历,如果不是,那就停止遍历左孩子。

后续线索化就简单很多,不会出现转圈的问题了。

 

 5.3.6 在线索二叉树中找前驱后继

中序线索二叉树找中序后继

现在二叉树已经线索化了,如果没有右孩子,那么右指针一定指向这个节点的后继,如果有右孩子,按照中序遍历“左根右”的顺序来看,遍历完该节点,后继一定是右孩子;若右孩子又有别的孩子,我们就往下找它的左孩子,如此递归下去,最终应该是该节点的右子树的最左下节点。

如果我们只知道中序后继,那么我们遍历这个线索二叉树,能得到它的正常中序序列。

//中序线索二叉树找中序后继
//找到以p为根的子树中,中序遍历第一个访问的结点
ThreadNode* FirstNode(ThreadNode* p) {
	//循环找到最左下结点(不一定是叶子结点)
	while (p->ltag == 0)p = p->lchild;
	return p;
}
//在中序线索二叉树中找中序后继
ThreadNode* NextNode(ThreadNode* p) {
	//右子树最左下结点
	if (p->rtag != 0) {
		//这一步很重要,找右子树最左下,我们递归调用
		return FirtNode(p->rchild);
	}
	else
		return p->rchild;
}
//对中序线索二叉树进行中序遍历(利用线索实现的非递归算法)
void Inorder(ThreadNode* Y) {
	for (ThreadNode* p = FirstNode(T); p != NULL; NextNode(p))
		visit(p);
}

中序线索二叉树找中序前驱 

如果没有左孩子,那么左指针一定指向它的前驱。如果有左孩子,中序遍历左根右,说明左子树一定在它之前遍历,故找该节点的左子树的最右下节点。

如果我们只知道二叉树前驱,那么遍历只能获得逆向的中序序列。

//中序线索二叉树找中序前驱
//找到以p为根的子树中,中序遍历最后一个访问的结点
ThreadNode* LastNode(ThreadNode* p) {
	//循环找到最右下结点(不一定是叶子结点)
	while (p->rtag == 0)p = p->rchild;
	return p;
}
//在中序线索二叉树中找中序前驱
ThreadNode* PreNode(ThreadNode* p) {
	//左子树最右下结点
	if (p->ltag != 0) {
		return LastNode(p->lchild);
	}
	else
		return p->lchild;
}
//对中序线索二叉树进行逆向中序遍历
void Inorder(ThreadNode* Y) {
	for (ThreadNode* p = LastNode(Y); p != NULL; PreNode(p))
		visit(p);
}

 先序线索二叉树找先序后继

先序遍历是根左右,故左孩子存在,那么后继就是左孩子;如果左孩子不存在,并且rtag==1那就看rtag指向的线索,rtag==0,那必然存在右孩子,此时右孩子是后继结点。

先序线索二叉树找先序前驱 

如果没有左孩子,那么lchild指向的线索就是前驱,如果有左孩子,那么是无法找到的。

先序遍历中,左右子树只可能是后继,根本不能是前驱,除非我们用土办法。

 此时如果我们更改二叉树的存储结构,将其加入一个父亲结点,那么可以分三种情况讨论:

如果该节点是左孩子,那么前驱是父亲结点。

如果该节点是右孩子,并且有左兄弟,那么前驱是左兄弟中先序遍历的最后一个结点。

如果该节点是右孩子,并且没有左兄弟,那么前驱是父亲结点。

如果该节点没有父亲结点,那么它也没有前驱。

 后序线索二叉树找后序后继

后序遍历为左右根,和先序找前驱同理,我们这里也找不到后序的后继,所以还是那样分情况讨论,这里不再赘述。

 我们可以用三叉链表找到父结点

后序线索二叉树找后序前驱 

左右根的顺序,如果有右孩子,那么右孩子就是后序前驱,如果没有右孩子,那么后序前驱为左孩子。

如果ltag==1,那么前驱就是线索。

 5.4.1 树的存储结构

树是递归定义的一种数据结构,逻辑结构如下:

基于此,树的存储结构我们有三种方法,孩子表示法(顺序+链式存储)、双亲表示法(顺序存储)、孩子兄弟表示法(链式存储)

双亲表示法

双亲表示法是用数组实现的,每个结点都保存指向双亲的指针,只不过是类似栈一样的top指针,parent表示它的双亲在数组中的下标是多少。 

实现了存储结构之后,我们来考虑基本操作增删改查的实现。

新增数据元素很简单,我们直接增加一个元素,parent记录一下就好了。

删除一个元素有两个方案,我们对比一下:方案一,直接将该节点的parent指针指向-2;方案二,在物理上移除这个元素,并且把后续元素都向前移动一位。哪种方案更好呢?应该是方案二。因为当我们删除一个结点时,后续操作就不会访问数组为空的部分,能提高效率。

孩子表示法

确实一图胜千言,这种方式增加删除操作和链表类似,只不过找父亲结点很麻烦,得遍历。

孩子兄弟表示法

这是最重要,也是考察最多的方法。我们在树中存储两个指针域,一个用来存储孩子,另一个用来存储右兄弟。这其实就实现了树和二叉树的转化,我们在物理结构上把树转换为了二叉树。

森林和二叉树的转换

 这样操作的本质是用二叉树来存储森林,把各个树的根结点视为兄弟关系。

5.4.2 树和森林的遍历

 树的先根遍历

所谓树的先序遍历,其实就是把这棵树转化为二叉树之后,对二叉树进行先序遍历。(也就是根左右)

树的后根遍历

所谓树的后根遍历,与对应的二叉树的中序遍历是一样的。

树的层次遍历(用队列实现) 

森林的遍历:

森林的先根遍历等同于对各个不相交的树进行先序遍历,效果又等同于对二叉树先序遍历,森林的后根遍历等同于对各个不相同的树进行中序遍历,效果又等同于对二叉树进行中序遍历。

根左右=先根,左根右=后根。

5.4.3 哈夫曼树

带权路径长度:一定要清楚是边的数目×权值,总的带权路径长度是各个叶子结点带权路径长度求和。

哈夫曼树指的是所有树中,带权路径长度最小的树。 

 我们重点学一下哈夫曼树的构造:每次取出两个权值最小的结点,当做叶子结点,并且把他们二者权值相加,添加一个一个根结点连接二者,再把他们放回循环。如此往复,直到循环体中不存在结点。

 哈夫曼编码:

固定长度编码传答案很费劲,我们有没有什么办法优化一下呢? 

 

5.5.1 并查集 

  我们已经学过很多种逻辑结构,今天我们学习“集合”这种逻辑结构,集合,将若干个元素划分为互不相交的子集。

 我们回顾一下,森林是m棵互不相交的树组成的集合,那我们是否可以用树来表示集合这种结构呢?答案是当然可以。考虑基本操作查找的实现,我们可以一路向北,找到根结点。

如何判断两个元素是否属于同一集合呢?我们只需要找到分别的根结点,看是否相同即可。

如何让一个元素成为另一个集合的元素呢?让其连接到树上即可。 

接下来我们研究并查集的存储结构。树的表示方法有孩子表示法、双亲表示法、孩子兄弟表示法三种,那么对于并查集而言,哪一种表示方法更适合呢?应该是双亲表示法。因为这样并和查两个操作都非常容易实现。

集合的两个基本操作:并和查。

Find,查找,确定一个指定元素所属的集合。

Union,并,将两个不相交的集合合并为一个。

注:并查集(DisJoint Set)只是逻辑结构——集合的一种具体实现,只进行并和查两种操作。

#define Size 13
int UFSets[Size]//集合元素数组
//初始化并查集
void Initial(int S[]) {
	for (int i = 0; i < Size; i++) {
		S[i] == -1;
	}
}
//查找操作,找x所属集合(返回x所属根结点)
int find(int S[], int x) {
	while (S[x] >= 0)//循环找x的根
		x = S[x];
	return x;//根的S[x]<0
}
//并操作,将两个集合合并为一个
void Union(int s[], int Root1, int Root2) {
	//要求Root1与Root2是不同的集合
	if (Root1 == Root2)
		return;
	//将根Root2连接到另一根Root1下面
	S[Root2] = Root1;
}

目前所写代码,合并操作时间复杂度为O(1);但是查找操作复杂度为O(n);并且与树的高度直接相关,我们有没有什么办法能降低查找的时间复杂度呢?

我们可以先考虑对合并Union操作进行优化,为了使树的高度尽可能低,我们让每次合并,都是将小树放到大树上。用结点个数的多少来区分大树小树。

//并操作,将两个集合合并为一个
void Union(int s[], int Root1, int Root2) {
	//要求Root1与Root2是不同的集合
	if (Root1 == Root2)
		return;
	if (S[Root2] < S[Root1]) {//Root1结点更少,因为这里是负数比大小
		S[Root2] += S[Root1];//累加结点总数
		S[Root1] = Root2;//小树合并到大树上
	}
	else {
		S[Root1 += S[Root2];
		S[Root2] = Root1;
	}
}

我们可以通过数学归纳法证明,这种方法构造的树,高度不超过\left [ \log {_{2}}^{n} \right ]+1

5.5.2 并查集的进一步优化

    除了对Union操作优化外,Find操作也可以进行压缩路径优化,最终可以使时间复杂度接近常数级。

  每次 Find 操作,先找根,再“压缩路径”,可使树的高度不超过0(a(n))。 a(n)是一个增长很缓慢的函数,对于常见的n值,通常a(n)<4,因此优化后并查集的Find、Union操作时间开销都很低 。

//查找操作,找x所属集合(返回x所属根结点)
int find(int S[], int x) {
	int root = x;
	while (S[root] >= 0)//循环找x的根
		root = S[root];
	while (x != root) {//压缩路径
		int t = S[x];//t指向x的父结点
		S[x] = root;//将x自己直接挂到根结点下
		x = t;//x向前进一步,成为路径上的下一个节点
	}
	return x;//根的S[x]<0
}

个人感悟:树这一章前前后后写了个把月,本章知识内容太多太碎,名称叫法也有很多不同,难度不算小,需要多做一些题来巩固提高。 

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值