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层最多有个结点(i>=1)
第一层1个根结点,第二层有m个,第三层m²个,故有此结论。
考点4:高度为h的m叉树至多有个结点。
等比数列求和即可。
考点5:高度为h的m叉树结点数最少有h个。
高度为h、度为m的树至少有h+m-1个结点。
考点6:具有n个结点的m叉树最小高度为.
高度最小的情况:每个结点都有m个孩子。已知高度为h的m叉树最多有多少个结点,
An-1<n<=An;解方程即可。
5.2_1二叉树的定义和基本术语
二叉树基本概念
二叉树是指n(n>=0)个结点的有限集合。
1)或者为空二叉树,即n=0;
2)或者由一个根结点和两个互不相交的被称为根的左子树和右子树组成。左子树和右子树又分别 是一棵二叉树。
特点:二叉树每个结点最多只有两棵子树,左右子树顺序不能颠倒(二叉树是有序树)
二叉树的五个状态:空二叉树 只有左子树 只有右子树 只有根节点 左右子树都有
几种特殊的二叉树
1.满二叉树:一棵高度为h,且含有个结点的二叉树。
特点:只有最后一层有叶子结点。不存在度为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层至多有个结点(i>=1);
m叉树第i层至多有个结点(i>=1)
考点3;高度为h的二叉树至多有个结点(满二叉树)
完全二叉树的常考性质
考点1:具有n个结点的完全二叉树高度为或者
+1;
第i个结点编号层次为+1或者
考点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;
}
}
我们可以通过数学归纳法证明,这种方法构造的树,高度不超过+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
}
个人感悟:树这一章前前后后写了个把月,本章知识内容太多太碎,名称叫法也有很多不同,难度不算小,需要多做一些题来巩固提高。