25考研,参考了几个博主和王道
一、树的基本概念
1.树的定义
树是n(n>=0)个结点的有限集合,当n=0时,称为空树。在任意一棵非空树中应满足:
- 有且仅有一个根结点。
- 当n>1时,其余结点可分为m(m>0)个互不相交的有限集合t1,t2,t3.....其中每个集合本身又是一棵树,称为根结点的子树。
树的定义是递归 的,树本身也是一种递归的数据结构。其作为一种逻辑结构,同时也是一种分层结构。树适合表示具有层次结构的数据。有以下两个特点:
- 树的根节点没有前驱节点,除根结点外 的所有节点有且只有一个前驱。
- 树中所有节点都可以有个或多个后继
2.树的基本术语
1)节点之间的关系
父节点(双亲结点),孩子结点
祖先结点,子孙结点
兄弟结点,堂兄弟结点
结点之间的路径------------只能从上到下
路径的长度------------路径上经过多少条边
结点的深度,高度和层次
结点的层次:从树根开始定义根节点为第一层,其孩子结点为第二层
结点的深度 :就是结点所在的层次
树的高度 (深度): 树中结点的最大层数
分支结点:度大于0,每个结点的分支数就是结点的度数
叶子结点:度等于0
2)结点,树的属性
结点的层次(深度),结点的高度
结点的度:结点的分支数
树的度:树中各结点的度的最大值
!!!树的度是一个总体的,结点的度是一个部分值
3)有序树VS无序树
逻辑上看,各子树是否有序,位置是否可互换
4)森林
有m(m>=0)个互不相交的树组成森林。
3.树的性质
性质一:
结点数=总度数+1
理解:第n-1层的结点度数之和就是第n层的所有结点数量,从而得知从第1层到第n层的结点度数和实际上就是除了根节点之外的所有结点的数量
性质二:
度为m的树
- 至少有一个结点的度数为m
- 一定是非空树
m叉树
- 允许所有结点的度都<m
- 可以是空树
性质三:
度为m的树第i层至多有m^(i-1)结点
推导:等比数列推导,底数为m,次数是i-1
性质四:
高度为h的m叉树至多有(m^h-1)/(m-1)个结点
推导:等比数列求和
性质五:
①高度为h的m叉树至少有h个结点
②高度为h,度为m的树至少有 h+m-1****个结点
性质六:
具有n个结点的m叉树的最小高度 为
推导如下:高度最小则表明所有结点都有m个孩子,其中
前h-1个最多有多少结点<n<=前h层最多有多少结点
二、二叉树的概念
1.二叉树的基本概念
二叉树是n (n≥0)个结点的有限集合:
- 或者为空二叉树,即n=0。
- 或者由一个根结点和两个互不相交的被称为根的左子树 和右子树 组成。左子树和右子树又分别是 一棵二叉树。
- 二叉树是有序树。左右子树颠倒不同。
注意:!!!!!!!!!!二叉树是递归定义的数据结构
2.特殊的二叉树
1)满二叉树与完全二叉树
满二叉树 :一棵高度为h ,且含有2^n-1个结点的二叉树。
- 只有最后一层有叶子结点
- 不存在度为1 的结点,度要么为0 要么为2
- 按层序从1开始编号,结点i的左孩子为2i ,右孩子为2i+1 ;结点i的父节点为**⌊i/2⌋**。
完全二叉树:当且仅当其每个结点都与高度为h的满二叉树 中编号为1∼n的结点一一对应。
特点:
- 只有最后两层可能有叶子结点。
- 最多****只有一个度为1的结点,且其孩子只能为左孩子。
- 与满二叉树一样,结点i的左孩子为2i ,右孩子为2i+1 ;结点i的父节点为**⌊i/2⌋**。
- i ≤ [ n/2] 为分支结点,i > [n/2] 为叶子结点(n为满二叉树的结点个数)
下图中左边为满二叉树,右边为完全二叉树
2)二叉排序树
二叉排序树。一棵二叉树 或者是空二叉树,或者是具有如下性质的二叉树:
- 左子树 上所有结点的关键字均小于根结点的关键字。
- 右子树 上所有结点的关键字均大于根结点的关键字。
- 左 子树和右 子树又各是一棵二叉排序树。
二叉排序树可用于元素的排序,搜索
3)平衡二叉树
平衡二叉树。树上任一结点的左子树和右子树的深度之差不超过1。
是特殊的二叉排序树。
下图中左边是平衡二叉树,右边就不是平衡二叉树
2.二叉树的性质
①设非空二叉树 中度为0,1,2的结点个数分别为n0, n1, n2 ,则n0=n2+1。(利用二叉树结点的个数为结点的总度数+1推出)
②.二叉树 的第i层至多有2^(k-1)(i≥1)
③.高度为h的二叉树 至多有2^h-1个结点(满二叉树)(推导过程为等比数列求和)
④.完全二叉树的常考性质
- 具有n (n >0) 个结点的完全二叉树的高度h为**[log2 (n+1)]**或[log2 n]+1(推导:高层与底层之间或低满与同满之间)
- 对于完全二叉树,给定结点数n推出度为0,1,2的结点个数为n0,n1,n2(二叉树当有度为1的结点时,只有一个,且为左子树;同时结点数=总度数+1****)
3.树的存储结构
1)二叉树的顺序存储
先以一个完全二叉树为例演示顺序存储一个二叉树
要存储一个完全二叉树,可定义一个长度为MaxSize的数组t,按照从上到下,从左到右的顺序依次存储完全二叉树中的各个结点。这里可以让第一个位置空缺,保证数组下标和结点编号一致
cpp
#define MaxSize 10
struct TreeNode{
ElemType value; //结点中的数据元素
bool isEmpty; //结点是否为空
}
TreeNOde t[MaxSize] //数组t按照从上到下,从左到右的顺序依次存储完全二叉树中的各个结点
//初始化操作
for(int i=0;i<MaxSize;i++){
t[i].isEmpty=true;
}
这种存储关系要反映各个结点之间的前驱后继关系,或者说父子的关系,可以通过如下几个重要基本操作
- i的左孩子------2i
- i的右孩子------2i+1
- i的父节点------⌊i/2⌋
- i所在的层次------[log2 (n+1)]或[log2 n] + 1
非完全二叉树
依旧如同完全二叉树一样,只是有些位置为空。
2)二叉树的链式存储
cpp
//二叉树的结点(链式存储)
typedef struct BiTNode{
ElemType data; //数据域
struct BiTNode *lchild,*rchild; //左右孩子指针
}BiTNode,*BiTree;
若有n个结点,则有2n个指针域,而除了根结点每个节点头上都会连一个指针,故n个结点的二叉链表共有n+1个空链域。这些空出的空链域可以用于构造线索二叉树
cpp
struct ElemType{
int value;
};
typedef struct BiTNode{
ElemType data;
struct BiTNode *lchild,*rchild;
}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;
现在要找到指定结点的左/右孩子很简单,但是找到指定结点的父结点就只能从根开始遍历寻找。如果要频繁的查找父结点,就需要用到三叉链表
cpp
typedef struct BiTNode{
ElemType data;
struct BiTNode *lchild,*rchild;
struct BitNode *parent; //父结点指针
}BiTNode,*BiTree;
三、二叉树的遍历
1.二叉树的遍历
包含三种遍历。 前序遍历。中序遍历和后序遍历
下面是一个总体的例子:
先序遍历:A B D G E C F
中序遍历:D G B E A F C
后续遍历:G D E B F C A
1)前序遍历(NLR)
步骤:
- 先访问根节点
- 先序遍历左子树
- 先序遍历右子树
递归算法如下:
cpp
void preOrder(BigTree T) {
if (T != null) {
visit(T);
preOrder(T->lchild);
preOrder(T->rchild);
}
}
2)中序遍历(LNR)
步骤:
- 先序遍历左子树
- 先访问根节点
- 先序遍历右子树
递归算法如下:
cpp
void preOrder(BigTree T) {
if (T != null) {
preOrder(T->lchild);
visit(T);
preOrder(T->rchild);
}
}
3)后序遍历(LRN)
步骤:
- 先序遍历左子树
- 先序遍历右子树
- 先访问根节点
递归算法如下:
cpp
void preOrder(BigTree T) {
if (T != null) {
preOrder(T->lchild);
preOrder(T->rchild);
visit(T);
}
}
上述三个遍历算法中每个结点都访问一次 ,时间复杂度为O(n)。
注意:!!!!!!!!!!!!!!!三种都使用了递归调用栈,故其空间复杂度为O(H)。(h为树的高度)
应用:求树的深度
要求一棵二叉树的深度,根据二叉树的递归特性(分为根节点,左子树,右子树)可以先递归地求出左子树和右子树的高度,然后选取左子树和右子树中高度高的一边+1这样就求出了树的深度,下面的代码其实就是后序遍历的变种
cpp
int treeDepth(BiTree T){
if(T==NULL){
return 0;
}else{
int l=treeDepth(T->lchild);
int r=treeDepth(T->rchild);
//树的深度=Max(左子树深度,右子树深度)+1
return l>r?l+1:r+1;
}
}
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 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); //右孩子入队
}
}
}
上面一直在说让某一个结点入队,但是我们并不用在队列中保存整个结点的真实数据,而是只需要保存这个结点的指针就可以了。因此让结点入队的时候其实真正入队的是这个结点的指针
2.由遍历序列构造二叉树
一个二叉树的中序遍历是唯一的,但是已知中序遍历不能确定二叉树。
1)前序+中序遍历序列
前序遍历序列:A D B C E
中序遍历序列:B D C A E
2)后序+中序遍历序列
后序遍历序列:E F A H C I G B D
中序遍历序列:E A F D H C B G I
这棵二叉树还原后为
3)层序+中序序列
层序遍历序列:A B C D E
中序遍历序列:A C B E D
这颗二叉树还原后为
3.线索二叉树
其是基于遍历序列 的。正常的二叉树难以找到对应前驱和后继,所以使用线索二叉树。
利用结点的空指针域存放前驱或后继。左指针指向前驱,右指针指向后继。
1)线索二叉树的存储结构
cpp
//线索二叉树结点
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild,*rchild;
int ltag,rtag; //左右线索标志,为1标志指向前驱或后继,为0标志指向孩子结点
}ThreadNode,*ThreadTree;
依据不同的遍历方式,又可以把线索二叉树分为先序,中序,后序线索二叉树
2)中序线索二叉树
线索指向中序前继,中序后继。
从逻辑视角看
从存储视角看
3)先序线索二叉树
线索指向先序前继,先序后继。
从逻辑视角看
从存储视角看
4)后序线索二叉树
线索指向后序前继,后序后继
从逻辑视角看
从存储视角看
4.二叉树的线索化
1)中序线索化
中序线索化实际上就是一个中序遍历的过程(下面代码中InThread函数内部实际上就是一个中序遍历),只不过在中序遍历访问各个结点的过程中需要一边遍历一边处理这个结点。
完整代码如下
cpp
//全局变量pre,指向当前访问结点的前驱
ThreadNode *pre=NULL;
//线索二叉树结点
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild,*rchild;
int ltag,rtag; //左,右线索标志
}ThreadNode,*ThreadTree;
//中序遍历二叉树,一边遍历一边线索化
void InThread(ThreadTree T){
if(T!=NULL){
InThread(T->lchild); //中序遍历左子树
visit(T); //访问根结点
InThread(T->rchild); //中序遍历右子树
}
}
void visit(ThreadNode *q){
if(q->lchild==NULL){ //左子树为空,建立前驱线索
q->lchild=pre;
q->ltag=1;
}
if(pre!=NULL&&pre->rchild==NULL){
pre->rchild=q; //建立前驱结点的后继线索
pre->rtag=1;
}
pre=q;
}
//中序线索化二叉树T
void CreateInThread(ThreadTree T){
pre=NULL; //pre初始为NULL
if(T!=NULL){ //非空二叉树才能线索化
InThread(T); //中序线索化二叉树
if(pre->rchild==NULL){
pre->rtag=1; //处理遍历的最后一个结点
}
}
}
2)先序线索化
先序线索化的原理类似,只不过我们要先visit根结点再对左子树和右子树进行线索化。本质上仍是先序遍历,只不过是一边遍历一边对这些结点进行进行线索化的处理。
但是如果只是简单修改中序遍历的代码像下面一样直接用,会出现小问题re指向第二个结点。
要解决这个现象,就要在上面的代码基础上对PreThread函数稍作修改。因为在把左孩子指针线索化的同时修改了ltag变量,所以可以多一步判断,利用ltag变量判断左孩子指针是不是真的指向该结点的左孩子。
cpp
//全局变量pre,指向当前访问结点的前驱
ThreadNode *pre=NULL;
//先序线索化二叉树T
void CreatePreThread(ThreadTree T){
pre=NULL; //pre初始为NULL
if(T!=NULL){ //非空二叉树才能线索化
PreThread(T); //先序线索化二叉树
if(pre->rchild==NULL){
pre->rtag=1; //处理遍历的最后一个结点
}
}
}
//先序遍历二叉树,一边遍历一边线索化
void PreThread(ThreadTree T){
if(T!=NULL){
visit(T); //先处理根结点
if(T->ltag==0){ //lchild不是前驱线索
PreThread(T->lchild);
}
PreThread(T->rchild);
}
}
void visit(ThreadNode *q){
if(q->lchild==NULL){ //左子树为空,建立前驱线索
q->lchild=pre;
q->ltag=1;
}
if(pre!=NULL&&pre->rchild==NULL){
pre->rchild=q; //建立前驱结点的后继线索
pre->rtag=1;
}
pre=q;
}
3)后序线索化
原理类似,唯一的区别在于先处理左右子树后处理根节点,因为后序遍历的顺序就是左右根。仿造中序线索化即可,这里不再赘述
cpp
//全局变量pre,指向当前访问结点的前驱
ThreadNode *pre=NULL;
//后序线索化二叉树T
void CreatePostThread(ThreadTree T){
pre=NULL; //pre初始为NULL
if(T!=NULL){ //非空二叉树才能线索化
PostThread(T); //后序线索化二叉树
if(pre->rchild==NULL){
pre->rtag=1; //处理遍历的最后一个结点
}
}
}
//后序遍历二叉树,一边遍历一边线索化
void InThread(ThreadTree T){
if(T!=NULL){
PostThread(T->lchild); //后序遍历左子树
PostThread(T->rchild); //后序遍历右子树
visit(T); //访问根结点
}
}
void visit(ThreadNode *q){
if(q->lchild==NULL){ //左子树为空,建立前驱线索
q->lchild=pre;
q->ltag=1;
}
if(pre!=NULL&&pre->rchild==NULL){
pre->rchild=q; //建立前驱结点的后继线索
pre->rtag=1;
}
pre=q;
}
4)线索二叉树中找前驱和后继
总结
中序线索二叉树 | 先序线索二叉树 | 后序线索二叉树 | |
---|---|---|---|
找前驱 | √ | × | √ |
找后继 | √ | √ | × |
先序线索二叉树找前驱和后序线索二叉树找后继都只能用三叉链表 或者用之前讲过的土办法从根开始遍历寻找。所以在先序线索二叉树中,给你一个指定结点就可以从这个指定结点开始进行先序遍历。而对于后序线索二叉树,给你一个指定结点你只能从这个指定结点开始进行逆向后序遍历
四、树、森林
1.树的存储结构
1)双亲表示法(顺序存储)
双亲表示法:每个结点中保存指向双亲的"指针"
cpp
#defint MAX_TREE_SIZE 100 //树中最多结点数
typedef struct{ //树的结点定义
ElemType data; //数据元素
int parent; //双亲位置域
}PTNode;
typedef struct{ //树的类型定义
PTNode nodes[MAX_TREE_SIZE]; //双亲表示
int n; //结点数
}PTree
根结点固定存储在数组下标为0的位置,双亲位置域置为-1表示没有双亲。
因为保存了双亲的信息,新增数据元素无需按逻辑上的次序存储。
①增加数据元素
直接在空白位置写入这个新结点的值,并且记录它和双亲的关系即可
②删除数据元素
③删除叶子结点
有两种方法
1.直接将该元素的双亲位置域置为-1
2.把尾部的数据移上来填充这个空白(更好)
记得把结点数n进行修改
④删除非叶子结点
删除非叶子结点意味着删除了以其为根的整个子树,因此要找到这个结点的子孙结点并删除。这就涉及了从一个结点找到他的孩子结点的查询操作
采用双亲表示法找到指定结点的双亲很方便,但是找到指定结点的孩子只能从头遍历,所以查找孩子很不方便。这也暴露了删除的第一种方案存在的问题,如果没有用最后一个元素来填充这个空白的位置。在进行遍历操作的时候就要多判断一个无效的结点,这会导致遍历的速度更慢。
2)孩子表示法(顺序+链式存储)
各个结点实际的数据用结构C T B O X CTBOXCTBOX存储,而链表中的结点保存了各个孩子结点的下标。
cpp
struct CTNode{
int child; //孩子结点在数组中的位置
struct CTNode *next; //下一个孩子
}
typedef struct{
ElemType data;
struct CTNode *firstchild; //第一个孩子
}CTBox;
typedef struct{
CTBox nodes[MAX_TREE_SIZE];
int n,r; //结点数和根的位置
}
这种方式找孩子很方便,找双亲不方便
3)孩子兄弟表示法(链式存储)
- 最重要的一种方法,用纯链式存储的方式来存储一棵树
左孩子,右兄弟。
从下图可以看到每个结点中包含了一个数据域和两个指针,从存储的角度看其实就是个二叉链表(每个结点有两个链接指针)。和二叉树的结点本质是一样的,只不过变量的命名和含义有一点区别。下面会具体讲解如何把一棵普通的树转化为二叉树并用二叉链表保存
cpp
//二叉树的结点(链式存储)
typedef struct BiTNode{
ElemType data;
struct BiTNode *lchild,*rchild;
}BiTNode,*BiTree;
//树的存储------孩子兄弟表示法
typedef struct CSNode{
ElemType data; //数据域
struct CSNode *firstchild,*nextsibling; //第一个孩子和右兄弟指针
}CSNode,*CSTree;
以下图的树为例
可以把firstchild指针看作左指针,指向结点的第一个孩子,nextsibling指针看作右指针,指向结点的右兄弟。A是根节点,左指针指向它的第一个孩子结点B。B的右指针连向它的右兄弟C。同时让C的右指针连向它的右兄弟D。B的左指针指向它的第一个孩子E。E的右指针连向它的右兄弟F,左指针连向它的第一个孩子K。再看C,C的左指针连向它的第一个孩子G,右指针连向它的右兄弟D。D的左指针连向它的第一个孩子H。剩下的I,J都是H的兄弟,用右指针把它们连起来。这样就得到了用孩子兄弟表示法,或者说用二叉链表保存的一棵树。
用孩子兄弟表示法存储的树在物理上呈现出"二叉树"的样子
上面实现了树和二叉树之间的相互转化,就可以用我们熟悉的二叉树操作来处理树了。
2.森林和二叉树的转换
这个问题背后的本质也是用二叉链表来存储森林 。不同数之间的根节点相当于兄弟结点。
左孩子,右兄弟。
把森林中的树的根结点连起来作为兄弟结点
3.树和森林的遍历
1)树的先根遍历
和二叉树的先序遍历类似。若树非空,先访问根结点,再依次对每颗子树进行先根遍历
cpp
//树的先根遍历
void PreOrder(TreeNode *R){
if(R!=NULL){
visit(R); //访问根节点
while(R还有下一棵子树T){
PreOrder(T); //先根遍历下一棵子树
}
}
}
以下图的树为例
采用逐层展开法就能得到这棵树的先根遍历序列
值得注意的是如果把这棵树转化为二叉树,也就是用孩子兄弟法来存储这棵树,就会发现树的先根遍历序列与这棵树相应二叉树的先序序列相同
2)树的后根遍历
若树非空,先依次对每棵子树进行后根遍历,最后再访问根结点。
cpp
//树的后根遍历
void PostOrder(TreeNode *R){
if(R!=NULL){
while(R还有下一个子树T){
PostOrder(T); //后根遍历下一棵子树
}
visit(R); //访问根节点
}
}
以下面这棵树为例
利用逐层展开法得到这棵树的后根遍历序列
树的后根遍历序列与这棵树相应二叉树的中序序列相同
3)树的层次遍历
和二叉树的层序遍历没有什么区别,都用队列实现。实现思想如下
1.若树非空,则根节点入队
2.若队列非空,队头元素出队并访问,同时将该元素的孩子依次入队
3.重复2直到队列为空
以下面这棵树为例
按照上述思想就能得到该树的层次遍历序列
A B C D E F G H I J K
不难发现我们在探索这些结点的时候都是尽可能横向地去探索。所以对树的层次遍历也可以被称为对树的广度优先遍历。与之相对的,之前介绍的后根遍历和先根遍历在探索各个结点的时候是尽可能的往深处探索直到尽头才会返回,所以后根遍历和先根遍历也可以被称为树的深度优先遍历
森林的先序遍历
森林是m(m≥0)棵互不相交的树的集合。每棵树去掉根节点后,其各个子树又组成森林。森林这种数据结构其实是和树相互递归定义的,因此我们也可以用递归的思想来遍历森林
法一
若森林为非空,则按如下规则进行遍历:
1.访问森林中第一颗树的根结点
2.先序遍历第一棵树中根结点的子树森林
3.先序遍历除去第一棵树之后剩余的树组成的森林
以下面这个森林为例
按照上述思想得到的最终访问次序如下
从结果来看其实效果等同于 依次对各个树进行先根遍历,所以问先序遍历森林得到的序列是什么不用钻到上面那个有两层递归嵌套的递归算法里面,直接对各个树进行先根遍历最后排出完整的序列即可
法二
先转化森林为对应的二叉树,再对二叉树进行先序遍历。
森林的中序遍历
法一
若森林为非空,则按如下规则进行遍历:
1.中序遍历第一棵树中根结点的子树森林
2.访问第一棵树的根结点
3.中序遍历除去第一棵树之后剩余的树组成的森林
效果等同于依次对各个树进行后根遍历
以下面这个森林为例
按照上述思想得到的最终访问次序如下
法二
先转化森林为对应的二叉树,再对二叉树进行中序遍历。
代码实现时可以用孩子兄弟法来存储森林,将对森林的遍历操作转换成对二叉树的遍历操作
五、树与二叉树的应用
1.哈夫曼树
概念
结点的权:有某种现实含义的数值(如:表示结点的重要性等)
结点的带权路径长度 :从树的根到该结点的路径长度 (经过的边数)*该结点上权值
树的带权路径长度 :树中所有叶结点 的带权路径长度之和 (WPL)
WPL=i=∑Wi*Li
在含有n个带权叶结点的二叉树中,其中带权路径长度(WPL)最小的二叉树称为哈夫曼树,也称最优二叉树
下图中间两棵树就是哈夫曼树
哈夫曼树的构造
给定n个权值分别为w1,w2,...,wn的结点,构造哈夫曼树的算法描述如下:
1.将这n个结点分别作为n棵仅含一个结点的二叉树,构成森林F
2.构造一个新结点,从F中选取两棵根结点权值最小的树作为新结点的左,右子树,并且将新结点的权值置为左,右子树上根结点的权值之和
3.从F中删除刚才选出的两棵树,同时将新得到的树加入F中
4.重复步骤2和3,直至F中只剩下一棵树为止
哈夫曼树具有如下性质:
1.每个初始结点最终都成为叶结点,且权值越小的结点到根结点的路径长度最大
2.哈夫曼树的结点总数为2n−1
原有n个结点,结合n-1次,每次结合产生一个新的结点
3.哈夫曼树中不存在度为1的结点
4.哈夫曼树并不唯一,但WPL必然相同且为最优
哈夫曼编码
固定长度编码------每个字符用相等长度的二进制位表示
可变长度编码------允许对不同字符用不等长的二进制位表示
若没有一个编码是另一个编码的前缀,则称这样的编码为前缀编码
有哈夫曼树得到哈夫曼编码------字符集中的每个字符作为一个叶子结点,各个字符出现的频度作为结点的权值,根据之前介绍的方法构造哈夫曼树。哈夫曼树不唯一,因此哈夫曼编码不唯一。哈夫曼编码可用于数据压缩
2.并查集
同一子集中的各个元素,组织成一棵树,用互不相交的树,表示多个"集合"
如何"查"到一个元素到底属于哪一个集合? ------ 从指定元素出发,一路向北,找到根节点
如何判断两个元素是否属于同一个集合? ------ 分别查到两个元素的根,判断根节点是否相同即可
如何把两个集合"并"为一个集合? ------ 把其中一棵树的根节点指向另一棵树的根节点,成为另一棵树的子树即可
并查集(Disjoint Set)是逻辑结构 ------集合的一种具体实现,只进行 "并" 和 "查" 两种操作
1)并查集的存储结构
使用双亲表示法来表示并查集,"并"和"查"两操作实现更方便
2)并查集的基本操作
集合的两个基本操作 ------ "并" 和 "查"
Find ------ "查" 操作:确定一个指定元素所属集合
Union ------ "并" 操作:将两个不相交的集合合并为一个
并查集的代码实现
cpp
#define SIZE 13
int UFSets[SIZE]; //集合元素数组
//初始化并查集
void Initial(int S[]){
for(int i=0;i<SIZE;i++)
S[i] = -1;
}
//Find "查"操作,找x所属集合(返回x所属根结点)
int Find(int S[],int x){
while(S[x]>=0) //循环寻找x的根
x=S[x];
return x; //根的S[]小于0
}
//Union "并"操作,将两个集合合并为一个
void Union(int S[],int Root1, int Root2){
//要求Root1与Root2是不同的集合
if(Root1==Root2) return;
//将根Root2连接到另一根Root1下面
S[Root2]=Root1;
}
如果指定的两个元素不是根节点,要合并这两个元素从属的集合,需要先"查"确定两个元素各自的根节点,然后再对两个根节点进行"并"
时间复杂度分析
Union时间复杂度:O(1)
Find最坏时间复杂度:O(n)
3)Union操作的优化
优化思路:在每次Union操作构建树的时候,尽可能让树不长高
1.用根节点的绝对值表示树的结点总数
2.Union操作,结点总数小的树是小树,让小树合并到大树
cpp
//Union "并"操作,小树合并到大树
void Union(int S[],int Root1,int Root2){
if(Root1==Root2) return;
if(S[Root2]>S[Root1]) { //Root2结点数更少
S[Root1] += S[Root2]; //累加结点总数
S[Root2]=Root1; //小树合并到大树
} else {
S[Root2] += S[Root1]; //累加结点总数
S[Root1]=Root2; //小树合并到大树
}
}
Union操作优化后,该方法构造的树高不超过[log2n]+1,该结论可以用数学归纳法证明。
Union操作时间复杂度仍为O(1),Find操作最坏时间复杂度为O(log2n)
4)并查集的进一步优化
①Find操作的优化(压缩路径)
以下图为例,按照之前实现的Find操作,要找到L结点所属的集合会从L出发,沿着L ---> E ---> B ---> A的路径找到根结点A,这条路径称为查找路径。所谓压缩路径,就是缩短查找路径,将查找路径上的各个结点全部挂到根节点A下面。
cpp
//Find "查"操作优化,先找到根节点,再进行"压缩路径"
int Find(int S[],int x){
int root = x;
while(S[root]>=0) root=S[root]; //循环找到根
while(x!=root){ //压缩路径
int t=S[x]; //t指向x的父结点
S[x]=root; //x直接挂到根节点下
x=t;
}
return root; //返回根节点编号
}
每次Find操作,先找根,再"压缩路径",可使树的高度不超过O(α(n))。O(α(n))是一个增长速度比log2n还要缓慢的函数,对于常见的几万以内的n值,通常α ( n ) ≤ 4α(n)≤4,因此优化后并查集的UnionFind、Union操作时间开销都很低