05树
5.1 树
树是n(n≥0)个节点的有限集合。当n=0时称为空树。任意非空树应满足以下条件
- 树有且仅有一个根节点
- 根以外的节点构成互不相交的有限集合,且每个集合也是一棵树,称为根节点的子树
树除了根节点没有前驱外,其余节点有且仅有一个前驱
树是一个递归定义的数据结构
5.1.1 基本术语
-
两个节点之间的路径:路径只能从上往下走,即从祖先到子孙的方向
-
路径长度:从A节点到B节点所经过的边数
-
有序树:逻辑上,树的节点各子树从左至右是有次序的,不允许交换
-
无序树:逻辑上,树的节点各子树从左至右是无次序的,允许交换
-
森林:m个(m≥0)棵互补相交的树的集合。当m=0时称为空森林
-
m叉树:每个节点最多只能有m个孩子的树,可为空树
-
节点间关系
- 祖先、子孙节点:从根节点A出发,到节点B的唯一路径上的所有节点称为B的祖先。而这条路径上,位于下方的所有节点均为上方节点的子孙
- 双亲节点/父节点:除了根节点以外,其余节点的唯一前驱节点就是该节点的双亲节点/父节点
- 孩子节点:任意节点的后继节点就是该节点的孩子节点(子树),孩子节点可以是空树
- 兄弟节点:除了根节点以外,同属于同一个父节点的节点之间互称为兄弟
- 堂兄弟节点:除了根节点以外,父节点同处再同一层的节点之间称为堂兄弟
-
节点、树属性
- 节点深度/层次:从上往下数的层数,题目没有说明,默认从1开始
- 节点高度:从下往上数
- 树的高度/深度:整棵树一共多少层
- 节点的度:节点有几个孩子
- 树的度:各个节点度的最大值
5.1.2 树的性质
树结点数=树总度数+1
m叉树和度为m的树是不一样的。m叉树要求每个节点度小于m即可,可以是空树;而度为m的树一定是非空树,且要求至少有一个节点的度为3,因此至少有m+1个节点
度为m的树第i层最多有\(m^{i-1}\)个节点,i≥1
同理,m叉树第i层也最多有\(m^{i-1}\)个节点
第1层(0层),仅有一个根节点
第2层,由于度为m,满树至多有m个节点
第3层,由于度为m,满树至多有\(m^2\)个节点
高度为h的m叉树,至多\(\frac{m^h-1}{m-1}\)个节点,至少有h+m-1个节点。
具有n个节点的m叉树的最小高度为\(⌈log_m{(n(m-1)+1)}⌉\)
节点最多的情况,h层的满m叉树节点数=\(1+m+m^2+m^3+...+m^{h-1}\)
节点最少的情况,包含根节点,某层包含m个节点,其余层(剩下h-2层)仅有一个节点,共计m+1+h-2=m+h-1个节点
高度最小的情况和第一种类似,也是满m叉树,因此有不等式$$\frac{m{h-1}-1}{m-1}<n≤\frac{mh-1}{m-1}$$ $$h-1<log_m{n(m+1)+1}≤h$$ 即当\(h_{min}=⌈log_m{(n(m-1)+1)}⌉\),结果向上取整即为最小高度
5.2 二叉树
二叉树是n个(n≥0)节点的有限集合,满足如下条件
- 或为空二叉树,即n=0
- 或为一个由根和两个互不相交的左、右子树组成,左右子树分别是一棵二叉树
每个节点至多有两棵子树
二叉树是一个有序树,左右子树不能互换
5.2.1 二叉树性质
- 非空二叉树中度为0、1和2的节点个数分别为\(n_0,n_1,n_2\),则有\(n_0\)=\(n_2\)+1(叶子节点比分支节点多一个),则该树总节点数n=\(n_0\)+\(n_1\)+\(n_2\)=\(n_1\)+\(2n_2\)+1,即
树的节点数=总度数+1 - 二叉树第i层至多有\(2^{i-1}\)个节点(i≥1)
m叉树第i层至多有\(m^{i-1}\)个节点(i≥1)
- 高度为h的二叉树至多有\(2^i\)-1个节点,即满二叉树
高度为h的m叉树至多有\(\frac{m^h-1}{m-1}\)个节点
扩展:
- 对于n个节点的二叉树,共有\(\frac{1}{n+1}C_{2n}^{n}\)种不同形态
- 由于中序、前序/后续/层序可以唯一确定一棵二叉树,假设通过中序、前序来确定一棵二叉树
- 中序序列为出栈/入栈次序,前序序列为入栈/出栈序列,那么根据栈的性质,n个不同元素进栈时,出栈元素不同排列的个数为\[\frac{1}{n+1}C_{2n}^{n} = \frac{(2n)!}{(n+1)!n!} \]
5.2.2 二叉树存储结构
二叉树顺序存储采用数组存储,按照从上至下、从左至右的完全二叉树序号顺序依次存储,若该二叉树不为完全二叉树,则数组中只存放对应编号的节点,其余置空不填入
判断i节点是否有左孩子:\(2i≤n\)
判断i节点是否有右孩子:\(2i+1≤n\)
判断i节点是否为分支/叶子节点:\(i≤⌊n/2⌋\)(向下取整)
i节点父节点序号:\(⌊i/2⌋\)(向下取整)
因此高度为h的二叉树,就算只有h个节点的单分支二叉树,也至少需要占用\(2^h\)-1格存储单元
顺序存储一般只适合完全二叉树
#define MAXSIZE 100
struct TreeNode{
Elemtype value;//节点中数据元素
bool isEmpty;//节点是否为空
};
struct TreeNode t[MAXSIZE];
二叉树链式存储采用链表,对于空节点,指针域直接指向NULL。对于n个节点的二叉树,共有2n个指针,其中包括有n+1个空指针
找到指定节点p的左/右孩子仅需访问该节点的左右指针即可,但是访问该节点的父节点只能从根开始遍历寻找
特定使用场景下,为了方便找到父节点,可采用三叉链表,在原有左右指针域的前提下,再添加一个父节点指针
typedef struct BiTNode{
Elemtype data;//数据域
struct BiTNode * lchild, *rchild;//左、右孩子指针
}BiTNode,*BiTree;
5.2.3 二叉树的遍历
若二叉树高度为h,则前、中、后序遍历,空间复杂度O(h+1)=O(h),且每个非空节点均会被路过3次(访问依旧是1次,由于递归过程会重复回到原有堆栈)
前序深度优先遍历(根左右,NLR)
一棵完全二叉树ABCDEFG,前序遍历为ABDECFG
算法思路:
- 若二叉树为空,直接返回
- 若二叉树非空
- 先访问根节点
- 再先序访问根节点左子树
- 再先序访问根节点右子树

- 整个过程相当于绕着树外围跑一圈(直到跑回根节点)
中序深度优先遍历(左根右,LNR)
一棵完全二叉树ABCDEFG,中序遍历为DBEAFCG
算法思路:
- 若二叉树为空,直接返回
- 若二叉树非空
- 再中序访问根节点左子树
- 先访问根节点
- 再中序访问根节点右子树

- 整个过程相当于将二叉树每个节点,垂直方向投影下来(可以理解为每个节点从最左边开始垂直掉到地上),然后从左往右的排列
后序深度优先遍历(左右根,LRN)
一棵完全二叉树ABCDEFG,后序遍历为DEBFGCA
算法思路:
- 若二叉树为空,直接返回
- 若二叉树非空
- 先后序访问根节点左子树
- 再后序访问根节点右子树
- 再访问根节点
后序遍历可用于求树的深度:先分别求出根左子树深度\(h_1\)、根右子树深度\(h_2\),取较大的深度值max{\(h_1\),\(h_2\)}+1

- 整个过程相当于围着树的外围绕一圈,将最边缘的节点逐个剪下来的过程(也就是葡萄要一个一个掉下来,不能一口气掉超过1个这样)
层序广度优先遍历
一棵完全二叉树ABCDEFG,层序遍历为ABCDEFG
算法思路:
- 若二叉树为空,直接返回
- 若二叉树非空
- 初始化一个辅助队列(链式队列,且数据域保存节点指针),根节点入队
- 若队列非空,则对头节点出队,访问该节点,并将其左、右子节点插入队尾中(如果有左、右子的话)
- 重复上一过程,直至队列为空

- 整个过程就是单纯的按照树的层次,从左向右进行遍历
5.2.4 由遍历序列构造二叉树
若只已知一棵二叉树的前、中、后、层序遍历序列中的一种,无法唯一确定一棵二叉树
已知一颗二叉树的中序遍历序列,只要再知道前、后、层序列任意一种即可唯一确定一棵二叉树
- 前序+中序
前序遍历,可知根节点-左子树-右子树
中序遍历,可知左子树-根节点-右子树
由于根左子树的长度是固定的,因此根据中序根节点的位置两边分割出左子树、右子树序号集合;继续递归在前序序列中找到左右子树的根,逐步可得左右子树结构 - 后序+中序
确定二叉树结构类似于前序+中序
- 层序+中序
层序遍历,可得知根节点-根左子树的根-根右子树的根
中序遍历,可知左子树-根节点-右子树
根据层序遍历得到根以及中序遍历根的位置,中序根节点的位置两边分割出左子树、右子树序号集合,遍历层序序列即可(每个子树根都是先后出现的)
5.2.5 满二叉树
- 高度为h,且含有\(2^h\)-1个节点
- 叶子节点只存在于最下面一层
- 满二叉树的度要不为0(空二叉树),要么为2
- 满二叉树一定是一颗完全二叉树,在节点序号上有着相同性质。按层序从1开始编号,则编号为i的节点,左子序号为2i,右子序号为2i+1,如果该节点有父节点,其父节点编号为向下取整\(⌊i/2⌋\)
5.2.6 完全二叉树
- 当且仅到其每个节点都与高度为h的满二叉树中编号为1~n的节点一一对应时,称为完全二叉树。完全二叉树可以是空树
- 叶子节点只会出现在最后两层
- 至多只有一个度为1的节点,且该节点非空子节点一定是左子
- 按层序从1开始编号,则编号为i的节点,左子序号为2i,右子序号为2i+1,如果该节点有父节点,其父节点编号为\(⌊i/2⌋\)
- 节点序号i≤\(⌊n/2⌋\)为分支节点,i>\(⌊n/2⌋\)为叶子节点
- 具有n个节点(n>0)的完全二叉树的高度h为向上取整\(⌈log_2{(n}\)+1)\(⌉\)或向下取整\(⌊log_2{n}⌋\)+1
如果该完全二叉树是满二叉树,则n=\(2^h\)-1,h=\(⌈log_2{(n}\)+1)\(⌉\)
如果非满二叉树,高度为h-1的满二叉树节点数为\(2^{h-1}\)-1,那么该完全二叉树节点数\(2^{h-1}\)≤n<\(2^h\)-1,则有h=\(⌊log_2{n}⌋\)+1
同理,完全二叉树上某节点序号为i,则该节点所在层次也为向上取整\(⌈log_2{(i}\)+1)\(⌉\)或向下取整\(⌊log_2{i}⌋\)+1
- 对于完全二叉树,已知节点数为n,可以直接推出\(n_0,n_1,n_2\)
节点数n=\(n_1\)+\(2n_2\)+1,由于二叉树度为1的节点至多1个,所以\(n_1\)=1或0,而\(n_0\)+\(n_2\)=\(2n_2\)+1,该结果一定为奇数
若完全二叉树节点数n为偶数2k,则说明\(n_1\)=1,继而推出\(n_0\)=k,\(n_2\)=k-1;
若完全二叉树节点数n为奇数2k-1,则说明\(n_1\)=0,继而推出\(n_0\)=k,\(n_2\)=k-1
5.2.7 二叉排序树
- 二叉排序树是一颗二叉树,或者空二叉树,并且具有如下性质
- 左子树上所有节点的值均比根节点小,右子树上所有节点的值均比根节点大
- 左子树、右子树也为二叉排序树
二叉排序树比较适合排序以及搜索
5.2.8 平衡二叉树性质
- 树上任意节点的左子树和右子树的深度之差不超过1.平衡二叉树可以是空树
一颗二叉排序树如果满足平衡二叉树的性质,因为在二分查找过程中的深度是被优化压缩,可以拥有更高的搜索效率
5.2.9 线索二叉树
已知一棵二叉树的中序序列,记该中序遍历序列中某个节点前后为该节点的中序前驱、中序后继。
找到某个非根节点p的中序前驱节点、中序后继节点
- 进行一次中序遍历,使用q指针指向当前访问的节点,pre指针指向中序前驱节点
- 当q=p时,pre就是p节点的中序前驱节点
- 当pre=q时,q就是p节点的中序后继节点
对于n个节点的二叉树,一共有n+1个空指针域,可以将该空指针域作为中序前驱、后继的指针域。同时由于根节点没有前驱,中序最后一个节点没有后继,因此还是会存在两个空指针域
二叉树线索化
//线索二叉树
typedef struct ThreadNode{
ElemType data;//数据域
struct ThreadNode *lchild,*rchild;//左右孩子指针
int ltag,rtag;//左右线索标志
} ThredNode,*ThreadTree;
//ltag==0时表示左指针指向的是左孩子,rtag==0时表示左指针指向的是右孩子
//ltag==1时表示右指针指向的是线索前驱,rtag==1时表示右指针指向的是线索后继
void visit(ThreadNode *q);
void CreatePreThread(Thread &T);
void CreateInThread(Thread &T);
void CreatePostThread(Thread &T);
void PreThread(Thread T);
void InThread(Thread T);
void PostThread(Thread T);
ThreadTree pre;///全局变量指针,用于指向当前访问节点的前驱
//访问节点并线索化
void visit(ThreadNode *q){
if(!q->lchild){//左子为空,左指针填入前驱
q->lchild=pre;
q->ltag=1;
}
if(pre && !q->rchild){//右子为空,右指针填入后继
pre->rchild=q;
pre->rtag=1;
}
pre=q;//节点后移
}
//前序线索化
void CreatePreThread(Thread &T){
pre=NULL;//初始化为NULL,根节点前驱为NULL
if(T){
PreThread(T);
if(!pre->rchild)//此处可以直接赋值pre->rchild=NULL,因为pre在执行中序遍历后一定是最后一个节点
pre->rtag=1;
}
}
void PreThread(Thread T){
if(T){
visit(T);
//访问顺序为根-左子-右子
//如果左子为叶子节点,左子的左孩子重新会指向根,导致死循环
//因此需要判断节点孩子是否是线索指针
if(!T->ltag)
PreThread(T->lchild);
PreThread(T->rchild);
}
}
//中序线索化
void CreateInThread(Thread &T){
pre=NULL;//初始化为NULL,根节点前驱为NULL
if(T){
//线索化
InThread(T);
if(!pre->rchild)
pre->rtag=1;
}
}
void InThread(Thread T){
if(T){
InThread(T->lchild);
visit(T);
InThread(T->rchild);
}
}
//后序线索化
void CreatePostThread(Thread &T){
pre=NULL;//初始化为NULL,根节点前驱为NULL
if(T){
//线索化
PostThread(T);
if(!pre->rchild)
pre->rtag=1;
}
}
void PostThread(Thread T){
if(T){
PostThread(T->lchild);
PostThread(T->rchild);
visit(T);
}
}
void main(){
ThreadTree T;
InitThreadTree(T);
//前序线索化
CreatePreThread(T);
//中序线索化
CreateInThread(T);
//后序线索化
CreatePostPostThread(T);
}
其中值得注意的是,在前序线索化过程中,前序遍历顺序为根-左子-右子。当某节点的左子为叶节点时,该节点访问后访问其左子,左子建立线索化又指向该节点,出现死循环(节点的左子为下一个访问的节点,但叶子节点的左子表示上一节点,出现矛盾),因此前序线索化中,访问左子树时需要判断是否为线索T->ltag==0
中序线索化,遍历顺序为左-根-右,在访问根之前,根的左子树已经访问完成,避免了闭环链路(先访问左子树最左端叶子节点,线索化后直接访问上级根,不存在继续访问左子造成矛盾)
后序线索化,遍历顺序为左-右-根,由于访问方向是横向的,更不会出现矛盾过程
线索二叉树找前驱/后继
-
中序线索找节点p的后继节点next
p->rtag==1,则next=p->rchildp->rtag==0,由于中序是左根右,当前节点为根,下一个节点应当是右子树的最左端节点//获取树中序遍历的第一个节点 ThreadNode* firstNode(ThreadTree p){ while(!p->ltag) p=p->lchild; return p; } //获取中序后继节点 ThreadNode* nextNode(ThreadNode * p){ if(p->ltag) return p->rlchild; return firstNode(p->rchild); } //中序遍历,由于不需要队列,因此空间复杂度O(1) void InOrder(ThreadTree T){ for(ThreadNode *p=firstNode(T);p;p=nextNode(p)) visit(p); }
-
中序线索找节点p的前驱节点pre
p->ltag==1,则pre=p->lchildp->ltag==0,由于中序是左根右,当前节点为根,上一个节点应当是左子树的最右端节点//获取树中序遍历的最后一个节点 ThreadNode* lastNode(ThreadTree p){ while(!p->rtag) p=p->rchild; return p; } //获取中序前继节点 ThreadNode* preNode(ThreadNode * p){ if(p->ltag) return p->lchild; return lastNode(p->lchild); }
-
先序线索找节点p的后继节点next
p->ltag==1,由于先序是根左右,当前节点为根,左子树已空,则next=p->rchildp->ltag==0,则next=p->lchild
-
先序线索找节点p的前驱节点pre
p->ltag==1,则pre=p->lchildp->ltag==0,说明该节点有左孩子,但是由于先序是根左右,左右子均为该节点的后继节点,因此无法通过指针域获得前驱节点,除非从根开始先序遍历- 假设使用三叉链表可找到节点的父节点q的话
- p有父节点q,p为q的左子,则
pre=q - p有父节点q,p为q的右子,但q左子为空,则
pre=q - p有父节点q,p为q的右子,且q左子不为空,则前驱节点为父节点左子树先序遍历最后一个节点
- 若p为树的根,则p没有前驱节点
-后序线索找节点p的后继节点next
-
p->rtag==1,则next=p->rchild -
p->rtag==0,由于中序是左右根,左右子均为该节点的前驱节点,因此无法通过指针域获得后继节点,除非从根开始后序遍历- 假设使用三叉链表可找到节点的父节点q的话
- p有父节点q,p为q的左子,但q右子为空,则
next=q - p有父节点q,p为q的左子,但q右子不为空,则后继节点为q右兄弟子树中后续遍历的第一个节点
- p有父节点q,p为q的右子,则
next=q - 若p为树的根,则p没有后继节点
-
后序线索找节点p的前驱节点pre
p->ltag==1,则pre=p->lchildp->ltag==0,若p没有右子,则p的前驱为左子pre=p->lchild;若p有右子,则p前驱为右子pre=p->rchild
总结,
- 对于中序线索二叉树,可以从当前节点出发,找到前驱和后继
- 对于前序线索二叉树,从当前节点出发只能找到后继;如果要找到前驱,除非从根进行前序遍历或者使用三叉链表
- 对于后序线索二叉树,从当前节点出发只能找到前驱;如果要找到后继,除非从根进行后序遍历或者使用三叉链表
5.3 树的存储结构
-
顺序存储
二叉树的顺序顺序存储通过完全二叉树的排列序号进行存储,但并不适用于所有树
- 双亲表示法
由于树节点的每个度都是不确定的,但是除了根节点外,每个节点都有且仅有一个前驱,因此可以使用数组顺序存储各个节点的父节点指针(数组序号)、数据元素,即
非根节点双亲指针=父节点在数组中的下标。同时可规定根节点双亲指针为-1双亲表示法,对于找到某节点p的父节点时间复杂度为O(1)。但是找到其孩子节点需要遍历整个数组确认其双亲指针是否为p,时间复杂度为O(n)
适用于找父节点多,找子节点少的场景该方法同样适用于存储森林,每棵树的双亲指针均为-1
#define MAXSIZE 100 typedef struct{ ElemType data;//数据域 int parent;//双亲指针 }PTNode; typedef struct{ PTNode nodes[MAXSIZE];//节点集合 int n;//节点数 }PTree;- 孩子表示法
为方便找到孩子节点,可以采用顺序存储+链式存储的方式,即顺序存放节点的数据元素、孩子节点链表
孩子表示法,对于查找某节点p的孩子节点,仅需要遍历该节点的孩子链表即可。但找到某个节点的父亲节点仍需要遍历整个数组中的所有链表节点,找到该节点,链表所在的数组下标则为父节点下标
适用于找子节点多,找父节点少的场景,如服务流程树该方法同样适用于存储森林,但需要额外记录每颗树根的下标位置
#define MAXSIZE 100 typedef struct CTNode{ int child;//孩子节点序号 struct CTNode *next;//指针域,指向下一个孩子 }; typedef struct{ ElemType data;//数据域 struct CTNode *firstChild;//孩子链表指针。指向第一个孩子 }CTBox; typedef struct{ CTBox nodes[MAXSIZE];//节点集合 int n;//节点数 int r;//根的序号 }CTree;- 孩子兄弟表示法
类似于二叉树的链表结构,每个节点包含数据元素以及两个指针域,在孩子兄弟表示法中,一个指针指向第一个孩子,另一个指针指向右边的一个兄弟
该方法同样适用于存储森林,但是在森林中,每棵树的根节点视为平级的兄弟关系。通过这种存储方式,可以把包含多棵独立的树森林转化为一颗二叉树
#define MAXSIZE 100 typedef struct CSNode{ ElemType data;//数据域 struct CSNode *firstChild,*nextBrother;//双指针域,firstChild指向第一个孩子,nextBrother指向右边的一个兄弟 }CSNode,*CSTree;
5.4 森林、树、二叉树之间的转化
森林的层序
把森林中的树看为整体,第一层为各棵树的根节点,第二层为各棵树的第二层所有节点
孩子兄弟表示法
-
树->二叉树的转化:
- 算法思路
- 先建立二叉树的根节点
- 按照树的层序次序依次处理每个节点
- 处理过程:若当前处理的节点有孩子,则将其所有孩子用右指针串联成一个链表,并在二叉树中把第一个孩子挂在当前节点的左指针下
-
森林->二叉树的转化:
- 算法思路
- 由于在森林各棵树的根节点视为兄弟平级关系,因此先将各棵树的根节点用右指针串联成一个链表
- 按照森林的层序次序依次处理每个节点(不同树的同一层节点不是兄弟节点)
- 处理过程:若当前处理的节点有孩子,则将其所有孩子用右指针串联成一个链表,并在二叉树中把第一个孩子挂在当前节点的左指针下
-
二叉树->树的转化:
- 算法思路
- 先建立树的根节点
- 从树的根节点开始,按树的层序恢复每个节点的孩子
- 恢复过程:在二叉树中,某个当前节点有左孩子,将左孩子及其右指针方向的链表拆下,按顺序挂在树的当前节点下(孩子节点)
-
二叉树->森林的转化:
- 算法思路
- 先将二叉树根节点及其右指针方向的链表拆分,按顺序作为多棵树的根节点
- 按森林的层序恢复每个节点的孩子
- 恢复过程:在二叉树中,某个当前节点有左孩子,将左孩子及其右指针方向的链表拆下,按顺序挂在树的当前节点下(孩子节点)
5.5 树、森林的遍历
-
树的遍历
- 先根遍历
- 若树非空,先访问根节点,再依次对每棵子树进行先根遍历
- 层序为ABCDEFGHIJK,先序为ABEKFCGDHIJ
树的先根遍历序列和对应二叉树的先序序列相同
void preOrder(TreeNode *R){ if(R){ visit(R); //访问根节点 //下面为伪代码 while(节点R还有下一个子树T){ PreOrder(T); //先序遍历下一棵子树 } } } - 后根遍历
- 若树非空,先依次对每棵子树进行后根遍历,最后再访问根节点
- 层序为ABCDEFGHIJK,后序为KEFBGCHIJDA
树的后根遍历序列和对应二叉树的中序序列相同
void postOrder(TreeNode *R){ if(R){ //下面为伪代码 while(节点R还有下一个子树T){ postOrder(T); //后序遍历下一棵子树 } visit(R); //访问根节点 } } - 层序遍历(需要队列实现)
- 若树非空,根节点入队
- 若队列非空,则队头元素出队,同时及将该节点的孩子节点入队
- 重复上个过程,直至队列为空
- 先根遍历
-
森林的遍历
-
先序遍历
森林的先序遍历相当于依次对每棵树进行先根遍历,也同时相当于孩子兄弟表示法的对应二叉树的先序遍历
- 若森林非空,则做如下操作:
- 访问森林第一棵树的根节点
- 先序遍历第一棵树的根节点的子树森林
- 先序遍历除去第一棵树之后剩余树构成的森林
- 若森林非空,则做如下操作:
-
中序遍历
森林的中序遍历相当于依次对每棵树进行后根遍历,也同时相当于孩子兄弟表示法的对应二叉树的中序遍历
- 若森林非空,则做如下操作:
- 中序遍历第一棵树的根节点的子树森林
- 访问森林第一棵树的根节点
- 中序遍历除去第一棵树之后剩余树构成的森林
- 若森林非空,则做如下操作:
-
5.6 哈夫曼树
基本概念
- 节点的权:标识节点重要性的数值
- 节点带权路径长度:从树根到节点的路径长度(经过的边数)与该节点权值的乘积
- 树的带权路径长度:树中所有叶节点的带权路径长度之和,WPL
哈夫曼树
含有n个带权叶节点的二叉树中,其中带权路径长度WPL最小的二叉树称为哈夫曼树,也称为最优二叉树
- 每个初始节点最终都成为了哈夫曼树的叶子节点,且权值越小的节点,路径长度越大
- 哈夫曼树节点总数为2n-1,且不存在度为1的节点
- 哈夫曼树不唯一,但是WPL必然相同且均为最优二叉树
哈夫曼树构造过程
给定n个权值分别为\(w_1,w_2,...,w_n\)的节点,算法思路如下
- 将n个节点分别作为n棵树的根节点,构成森林F
- 构造一个新节点,从F中选取权值最小的两个节点作为新节点的左右子树(左右顺序任意),并将这两个节点的权值之和赋值给新节点
- 从F中删除各个选取的两棵树,同时将新构成的树加入F
- 重复上述过程,直至森林F只剩下一颗树,该树便是哈夫曼树
5.6.1 哈夫曼树应用
基本概念
- 固定长度编码:每个字符使用相同长度额二进制表示
- 可变长度编码:允许对不同字符用不等长的二进制位表示
- 前缀编码:编码中任意一个编码均不是其他编码的前缀,该编码方式称之为前缀编码
哈夫曼编码:字符集中每个字符作为一个叶子节点,各个字符出现的频度作为节点的权值,构造哈夫曼树。由于哈夫曼树不唯一,因此哈夫曼编码也不唯一
5.7 并查集
集合:由若干个元素组成,且任意两个元素之间没有除同属这个集合以外的其他逻辑关系
由于集合内部未规定元素之间逻辑关系,作为数据结构没有意义,现将集合中各个元素划分为若干个多个互补相交的子集
集合中的各个子集,可通过互不相交的树进行表示,那么规定两种操作
- 查找:查找某个元素属于哪个集合的操作
- 由于每个子集均使用互补相交的树进行表示,每棵树均只有一个唯一的根,棵通过查找该节点所在的树的根,从而判断属于哪个子集
- 对于不同的节点是否同属于同个子集,也可以通过查找的方式确定所在树的根是否相同,确认是否同属于同个子集
- 合并:将两个原先互不相交的子集合并为一个子集的操作
- 由于每个子集均使用互补相交的树进行表示,两个子集进行合并,只需要将其中一棵树的根节点挂在另外一颗树的根节点之下作为子树,即可完成子集的合并
5.7.1 并查集的逻辑结构
对于上述结构,其实可以使用树结构的双亲表示法进行存储,同时把上述结构称之为并查集
并查集:通过一个长度为n的数组S[]表示集合中n个节点之间的关系
基础操作:
- Find,查操作:确定一个指定元素的所属集合
- 最坏时间复杂度就为O(n),集合中为一个高度为n的树,且查找叶子节点
- Union,并操作:将两个不相交的集合合并为一个集合
- 时间复杂度为O(1),只需修改一个数组元素即可
#define SIZE 20
int UFSets[SIZE]; //集合元素数组
//初始化并查集
void Initial(int S[]){
for(int i=0;i<SIZE;i++) S[i]=-1;
}
//查操作
int Find(int S[],int x){
while(S[x]>=0) x=S[x];
return x;
}
//并操作
void Union(int S[],int root1,int root2){
if(root1-root2) S[root2]=root1;
}
5.7.2 并查集优化
由于查找次数和树的结构有关,因此可以再进行union操作时尽可能不让合并后的树高度增加;数组中根节点存放的值原先为-1,并没有意义,可以利用起来。因此由如下优化思路
- 根节点的绝对值表示树的节点总数,数组中父亲指针小于0的元素均为根节点
- Union操作,让小树合并到大树上
void Union(int S[],int root1,int root2){
if(root1-root2){
if(S[root1]<S[root2]){ //root1中节点更多
S[root1]+=S[root2]; //累计节点总数
S[root2]=root1; //小树合并到大树
}
else { //root2中节点更多
S[root2]+=S[root1]; //累计节点总数
S[root1]=root2; //小树合并到大树
}
}
}
该种优化方式可以让构造的树高度不超过⌊\(log_2n\)⌋+1,Union的时间复杂度仍为O(1),但Find的最坏时间复杂度降到了O(\(log_2n\))
5.7.3 并查集的进一步优化
除了上衣方法对树的高度进行优化从而减少find的最坏时间复杂度,也可以通过压缩路径的方式进行,优化思路如下
- 先找到查找结点的根节点,再将查找路径上的所有节点挂在根节点上
- 通过压缩路径的方式,可使得树的高度不超过O(α(n))。其中α(n)是一个增长很缓慢的函数,对于n<\(10^4\),通常α(n)≤4,即时间复杂度接近O(1)
int Find(int S[],int x){
int root =x;
while(root>=0) root=S[root]; //循环找到根节点
while(x-root){
int t=S[x]; //缓存该节点的父节点指针
S[x]=root; //指针直接指向所在树的根节点
x=t; //父节点变为当前节点
}
return root
}
上述两种对并查集的优化,均通过压缩书的高度方式加快查找效率
5.8 可视化演示
https://www.cs.usfca.edu/~galles/visualization/Algorithms.html
本文来自博客园,作者:GK_Jerry,转载请注明原文链接:https://www.cnblogs.com/GKJerry/articles/18264784

浙公网安备 33010602011771号