第五章树
5.1树的基本概念
5.1.1树的定义和基本术语
1,基本概念
树:从树根生长,逐级分支
空树——结点数为0的树
非空树的特性:
有且仅有一个根节点
没有后继的结点称为“叶子结点”(或终端结点)
有后继的结点称为“分支结点”(或非终端结点)
除了根节点外,任何一个结点都有且仅有一个前驱
每个结点可以有0个或多个后继。
2,基本术语
1,结点之间的关系
父节点(双亲结点)、孩子结点
祖先结点、子孙结点
兄弟结点、堂兄弟结点
结点之间的路径——只能从上往下
路径长度——路径上经过多少条边
2,结点、树的属性
结点的层次(深度)——从上往下数
结点的高度——从下往上数
树的高度(深度)——总共多少层
结点的度——有几个孩子(分支)
树的度——各结点的度的最大值
3,有序树VS无序树
有序树——逻辑上看,树中结点的各子树从左至右是有次序的,不能互换
无序树——逻辑上看,树中结点的各子树从左至右是无次序的,可以互换
4,森林
森林是m(m≥0)棵互不相交的树的集合
5.1.2树的性质
考点1
结点数=总度数+1
结点的度——结点有几个孩子(分支)
考点2
树的度——各结点的度的最大值
m叉树——每个结点最多只能有m个孩子的树
度为m的树 | m叉树 |
---|---|
任意节点的度<=m(最多m个孩子) | 任意节点的度<=m(最多m个孩子) |
至少有一个节点度=m(有m个孩子) | 允许所有节点的度都<m |
一定是非空树,至少有m+1个节点 | 可以是非空树 |
考点3
度为m的树第i至多有m^(i-1)个结点(i>=1)
m叉树第i层至多有m^(i-1)个结点(i>=1)
考点4
高度为h的m叉树至多有个(m^h-1)/(m-1)结点。(性质2的等比数列求和)
考点5
高度为h的m叉树至少有h个结点。
高度为h、度为m的树至少有h+m-1个结点。
考点6
具有n个结点的m叉树的最小高度为| logm(n(m-1)+1)]
高度最小的情况——所有结点都有m个孩子。
5.2二叉树的概念
5.2.1二叉树的定义和基本术语
1,基本概念
二叉树是n(n≥0)个结点的有限集合:
①或者为空二叉树,即n=0
②或者由一个根结点和两个互不相交的被称为根的左子树和右子树组成。左子树和右子树又分别是一棵二叉树
特点:
①每个结点至多只有两棵子树
②左右子树不能颠倒(二叉树是有序树)
二叉树的五种状态:
-
空二叉树
-
只有左子树
-
只有右子树
-
只有根节点
-
左右子树都有
2,特殊二叉树
1,满二叉树
一棵高度为h,且含有(2^h)-1个结点的二叉树
特点:
①只有最后一层有叶子结点
②不存在度为1的结点
③按层序从1开始编号,结点i的左孩子为2i,右孩子为2i+1;结点i的父节点为[i/2](如果有的话)
2,完全二叉树
当且仅当其每个结点都与高度为h的满二叉树中编号为1~n的结点一一对应时,称为完全二叉树。
特点:
①只有最后两层可能有叶子结点
②最多只有一个度为1的结点
③同满二叉树的第三个特点
④is[n/2]为分支结点,i>ln/2]为叶子结点
3,二叉排序树
左子树上所有结点的关键字均小于根结点的关键字;
右子树上所有结点的关键字均大于根结点的关键字。
左子树和右子树又各是一棵二叉排序树。
4,平衡二叉树
树上任一结点的左子树和右子树的深度之差不超过1。
平衡二叉树能有更高的搜索效率。
5.2.2二叉树的性质
1,二叉树
常见考点1:
设非空二叉树中度为0、1和2的结点个数分别为no、n1和n2,则no=n2+1(叶子结点比二分支结点多一个)
假设树中结点总数为n,则
①n=no+n1+n2
②n=n1+2n2+1
常见考点2:
二叉树第i层至多有2^(i-1)个结点(i>=1)
m叉树第i层至多有m^(i-1)个结点(i>=1)
常见考点3:
高度为h的二叉树至多有(2^h)-1个结点(满二叉树)
高度为h的m叉树至多有个(m^h-1)/(m-1)结点
2,完全二叉树
常见考点1:
具有n个(n>0)结点的完全二叉树的高度h为(log2(n+1))或(log2n)+1
高为h的满二叉树共有2^h-1个
结点高为h-1的满二叉树共有2^(h-1)-1个结点
常见考点2:
对于完全二叉树,可以由的结点数n推出度为0、1和2的结点个数为no、n1和n2
完全二叉树最多只有一个度为1的结点,即n1= 0或1
n0=n2+1——》n0+n2一定是偶数
若完全二叉树有2k个(偶数)个结点,则必有n1=1,no=k,n2=k-1
若完全二叉树有2k-1个(奇数)个结点,则必有n1=0,no=k,n2=k-1
5.2.3二叉树的存储结构
1,顺序存储
//定义
#define MaxSize 100
struct TreeNode{
ElemType vlaue;//节点中的数据元素
bool isEmpty;//节点是否为空
};
struct TreeNode t[MaxSize];
//初始化时给所有节点标记为空,可以让第一个位置空缺,保证数组下标和节点编号一致
for(int i = 0; i < MaxSize; i++)
{
t[i].isEmpty = true;
}
2,链式存储
//定义(二叉链表)
struct ElemTyped{
int value;
}
typedef struct BitNode{
ElemType data; //数据域
struct BiNode *lchild, *rchlid;//左右孩子指针
}BiTNode, *BiTree;
//创建一棵二叉树
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;//作为根节点的左孩子
//三叉链表定义
typedef struct BiTNode{
ElemType data;
struct BiTNode *lchild, *rchild;
struct BiTNode *parent; //父节点指针
}BiTNode, *BiTree;
5.3二叉树的遍历和线索二叉树
5.3.1二叉树的先中后序遍历
遍历:按照某种次序把所有结点都访问一遍
层次遍历:基于树的层次特性确定的次序规则
先/中/后序遍历:基于树的递归特性确定的次序规则
1,三种方法
//定义二叉树
typedef struct BiTNode{
ElemType data;
struct BiTNode *lchild, rchild;
}BiTNode, *BiTree;
1,先序遍历
根左右(NLR)
先序遍历(PreOrder)的操作过程如下:
1.若二叉树为空,则什么也不做;
2.若二叉树非空:
①访问根结点;
②先序遍历左子树;
③先序遍历右子树。
//先序遍历
void PreOrder(BiTree T){
if(T != NULL)
{
visit(T); //访问根节点
ProOrder(T->lchild);//递归遍历左子树
ProOrder(T->rchild);//递归遍历右子树
}
}
2,中序遍历
左根右(LNR)
中序遍历(InOrder)的操作过程如下:
1.若二叉树为空,则什么也不做;
2.若二叉树非空:
①中序遍历左子树;
②访问根结点;
③中序遍历右子树。
//中序遍历
void PreOrder(BiTree T){
if(T != NULL)
{
ProOrder(T->lchild);//递归遍历左子树
visit(T); //访问根节点
ProOrder(T->rchild);//递归遍历右子树
}
}
3,后序遍历
左右根(LRN)
后序遍历(InOrder)的操作过程如下:
1.若二叉树为空,则什么也不做;
2.若二叉树非空:
①后序遍历左子树;
②后序遍历右子树;
③访问根结点。
//后序遍历
void PreOrder(BiTree T){
if(T != NULL)
{
ProOrder(T->lchild);//递归遍历左子树
ProOrder(T->rchild);//递归遍历右子树
visit(T); //访问根节点
}
}
2,遍历算数表达式树
先序遍历得前缀表达式
中序遍历得中缀表达式(没有括号)
后序遍历得后缀表达式
3,考点:求遍历序列
分支节点逐层展开法
5.3.2二叉树的层次遍历
算法思想:
①初始化一个辅助队列
②根结点入队
③若队列非空,则队头结点出队,访问该结点,并将其左、右孩子插入队尾(如果有的话)
④重复③直至队列为空
//二叉树的节点(链式存储)
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;
}
//层序遍历
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);//右孩子入队
}
}
5.3.3由遍历序列构造二叉树
结论:若只给出一棵二叉树的前/中/后/层序遍历序列中的一种,不能唯一确定一棵二叉树。
而如果给出下列组合中的一种组合,就可以确定
1,前序+中序遍历
前序遍历:根结点、前序遍历左子树、前序遍历右子树
中序遍历:中序遍历左子树、根结点、中序遍历右子树
2,后序+中序遍历
后序遍历:前序遍历左子树、前序遍历右子树、根结点
中序遍历:中序遍历左子树、根结点、中序遍历右子树
3,层序+中序遍历
根节点,左根,右根……
中序遍历:中序遍历左子树、根结点、中序遍历右子树
4,补充
Key:找到树的根节点,并根据中序序列划分左右子树,再找到左右子树根节点
前序、后序、层序序列的两两组合无法唯一确定一科二叉树
5.3.4线索二叉树的概念
1,作用
指向前驱、后继的指针称为“线索”。
方便从一个指定结点出发,找到其前驱、后继;方便遍历
2,存储结构
在普通二叉树结点的基础上,增加两个标志位Itag 和rtag
Itag1时,表示lchild指向前驱;Itag0时,表示lchild指向左孩子
rtag1时,表示rchild指向后继;rtag0时,表示rchild指向右孩子
//线索二叉树节点
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild, *rchild;
int ltag,rtag;//左右线索标志
}ThreadNode, *ThreadTree;
//tag==0,表示指针指向孩子
//tag==1,表示指针是线索
3,三种线索二叉树
中序线索二叉树——以中序遍历序列为依据进行“线索化"
先序线索二叉树——以先序遍历序列为依据进行“线索化"
后序线索二叉树——以后序遍历序列为依据进行“线索化"
4,几个概念
线索——指向前驱/后继的指针称为线索
中序前驱/中序后继;先序前驱/先序后继;后序前驱/后序后继
5,手算画出线索二叉树
①确定线索二叉树类型——中序、先序、or后序?
②按照对应遍历规则,确定各个结点的访问顺序,并写上编号
③将n+1个空链域连上前驱、后继
5.3.5二叉树的线索化
1,中序线索化
//线索二叉树节点
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild, *rchild;
int ltag,rtag;//左、右线索标志
}ThreadNode, * ThreadTree;
//全局变量pre,指向当前访问节点的前驱
ThreadNode *pre = NULL;
//中序遍历二叉树,一边遍历一边线索化
void InThread(ThreadTree T)
{
if(T != NULL)
{
InThread(T->lchild);//中序遍历左子树
visit(T);//访问根节点
InThread(T->rchild);//中序遍历右子树
}
}
void vist(ThreadNode *q){
if(q->lchild == NULL){//左子树为空,建立前驱线索
q->lchild = pre;
q->tag = 1;
}
if(pre != NULL && pre->rchild == NULL){
pre->rchild = q;//建立前驱节点的后继线索
pre->tag = 1;
}
pre = q;
}
//中序线索化二叉树
void CreatInThread(ThreadTree T)
{
pre = NULL;//pre初始为NULL
if(T != NULL)//非空二叉树才能线索化
{
InThread(T);//中序线索化二叉树
if(pre->rchild == NULL)
pre->rtag = 1;//处理遍历的最后一个节点
}
}
2,先序线索化
3,后序线索化
4,核心
中序/先序/后序遍历算法的改造,当访问一个结点时,连接该结点与前驱结点的线索信息
用一个指针pre记录当前访问结点的前驱结点
5,易错点
最后一个结点的rchild、rtag的处理
先序线索化中,注意处理爱滴魔力转圈圈问题,当ltag==0时,才能对左子树先序线索化
5.3.6线索二叉树找前驱/后继
1,中序线索二叉树
1,后继
在中序线索二叉树中找到指定结点*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))
vist(p);
}
2,前驱
在中序线索二叉树中找到指定结点*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);
}
2,先序线索二叉树
1,后继
①若p有左孩子,则先序后继为左孩子③若p没有左孩子,则先序后继为右孩子
2,前驱
①如果能找到p的父节点,且p是左孩子,则p的父节点为其前驱
②如果能找到p的父节点,且p是右孩子,其左兄弟为空,则p的父节点即为其前驱
③如果能找到p的父节点,且p是右孩子,其左兄弟非空,则p的前驱为其左兄弟子树最后一个被先序遍历的结点
④如果p是根节点,则p没有先序前驱
3,后序线索二叉树
1,后继
①如果能找到p的父节点,且p是右孩子,p的父节点即为其后继
②如果能找到p的父节点,且p是左孩子,其右兄弟为空,p的父节点即为其后继
③如果能找到p的父节点,且p是左孩子,其右兄弟非空,则p的后继为其右兄弟子树中第一个被后序遍历的结点
④如果p是根节点,则p没有后序后继
2,前驱
①若p有右孩子,则后序前驱为右孩子
②若p没有右孩子,则后序前驱为左孩子
5.4树、森林
5.4.1树的存储结构
1,双亲表示法
顺序存储结点数据,结点中保存父节点在数组中的下标
优点:找父节点方便;缺点:找孩子不方便。
2,孩子表示法
顺序存储结点数据,结点中保存孩子链表头指针(顺序+链式存储)
优点:找孩子方便;缺点:找父节点不方便
3,孩子兄弟表示法
用二叉链表存储树——左孩子右兄弟
孩子兄弟表示法存储的树,从存储视角来看形态上和二叉树类似
考点:树与二叉树的相互转换。本质就是用孩子兄弟表示法存储树
//树的存储——孩子兄弟表示法
typedef struct CSNode{
ElemType data;
struct CSNode *firstchild, *nextsibling;//第一个孩子和右兄弟指针
}
3,森林与二叉树的转换
本质:用二叉链表存储森林——左孩子右兄弟
森林中各个树的根节点之间视为兄弟关系
5.4.2树、森林的遍历
1,树的遍历
1,先根遍历
若树非空,先访问根结点再依次对每棵子树进行先根遍历。
树的先根遍历序列与这棵树相应二叉树的先序序列相同。
2,后根遍历
若树非空,先依次对每棵子树进行后根遍历,最后再访问根结点。
树的后根遍历序列与这棵树相应二叉树的中序序列相同。
3,层序遍历
3)层次遍历(用队列实现)
①若树非空,则根节点入队
②若队列非空,队头元素出队并访问,同时将该元素的孩子依次入队
③重复②直到队列为空
2,森林的遍历
森林。森林是m(m≥0)棵互不相交的树的集合。每棵树去掉根节点后,其各个子树又组成森林。
1,先序遍历
若森林为非空,则按如下规则进行遍历:
访问森林中第一棵树的根结点。
先序遍历第一棵树中根结点的子树森林。
先序遍历除去第一棵树之后剩余的树构成的森林。
(效果等同于依次对各个子树的先根遍历)
2,中序遍历
若森林为非空,则按如下规则进行遍历:
中序遍历森林中第一棵树的根结点的子树森林。
访问第一棵树的根结点。
中序遍历除去第一棵树之后剩余的树构成的森林。
(效果等同于依次对各个树进行后根遍历)
5.5树与二叉树的应用
5.5.1哈夫曼树
1,概念
结点的权:有某种现实含义的数值(如:表示结点的重要性等)。
结点的带权路径长度:从树的根到该结点的路径长度(经过的边数)与该结点上权值的乘积。
树的带权路径长度:树中所有叶结点的带权路径长度之和(WPL,Weighted Path Length)。
在含有n个带权叶结点的二叉树中,其中带权路径长度(WPL)最小的二叉树称为哈夫曼树,也称最优二叉树。
2,构造哈夫曼树
给定n个权值分别为w1,w2,…wn的结点,构造哈夫曼树的算法描述如下:
1)将这n个结点分别作为n棵仅含一个结点的二叉树,构成森林F。
2)构造一个新结点,从F中选取两棵根结点权值最小的树作为新结点的左、右子树,并且将新
结点的权值置为左、右子树上根结点的权值之和。
3)从F中删除刚才选出的两棵树,同时将新得到的树加入F中。
4)重复步骤2)和3),直至F中只剩下一棵树为止。
特点:
1)每个初始结点最终都成为叶结点,且权值越小的结点到根结点的路径长度越大
2)哈夫曼树的结点总数为2n-1
3)哈夫曼树中不存在度为1的结点。
4)哈夫曼树并不唯一,但WPL必然相同且为最优
3,哈夫曼编码
将字符频次作为字符结点权值,构造哈夫曼树,即可得哈夫曼编码,可用于数据压缩
前缀编码——没有一个编码是另一个编码的前缀
固定长度编码——每个字符用相等长度的二进制位表示
可变长度编码——允许对不同字符用不等长的二进制位表示
5.5.2并查集
1,三要素
逻辑结构
逻辑结构——元素之间为“集合”关系
基本操作
初始化——初始化并查集,将所有数组元素初始化为-1
Find(S[].x)—“查”,找到元素x所属集合
Union(S[],root1,root2)——“并”,将两个集合合并为一个集合
存储结构
顺序存储,每一个集合组织成一棵树,采用“双亲表示法”
2,优化
优化思路:在每次union操作构建树的时候,尽可能让树不长高高
①用根节点的绝对值表示一棵树(集合)的结点总数
②Union操作合并两棵树时,小树并入大树
5.5.3并查集的终极优化
压缩路径——Find 操作,先找到根节点.再将查找路径上所有结点都挂到根结点下
每次Find 操作,先找根,再“压缩路径”,可使树的高度不超过o(a(n))。
a(n)是一个增长很缓慢的函数,对于常见的n值,通常a(n)<4, 因此优化后并查集的Find、Union操作时间开销都很低。