数据结构第五章(三)
树与二叉树的应用
一、树和森林
回顾一下:
树是一种递归定义的数据结构。
二叉树:一个分支结点最多只能有两棵子树
树:一个分支结点可以有多棵子树
1.树的存储结构
1.1 双亲表示法(顺序存储)
我们想一下,二叉树的顺序存储是怎么存储来着?是不是按照完全二叉树的结点顺序,将各节点存储到数组的对应位置,数组下标反映结点之间的逻辑关系
。
还记得吗?如果根节点从数组下标1开始存放,则:
- i的左孩子:2i
- i的有孩子:2i+1
- i的父结点: ⌊i/2⌋
但是树肯定不是这么搞的,因为根本对不上。它的一个分支结点可以有多棵子树,只依靠数组下标,无法反映结点之间的逻辑关系。
所以我们如果既要保存结点信息,又要反映关系,可以用数组存储各个结点,每个结点中保存数据元素,指向双亲结点(父结点)的“指针”
,非根结点的双亲指针=父结点在数组中的下标
口述有点干,如下图所示:
那么这就很清晰了,parent是-1的是根结点,其他我们都能很方便地找到它的父结点。
我们的代码定义更加清晰:
#define MAX_TREE_SIZE 100 //树中最多结点数
typedef struct{ //树的结点定义
int data; //数据元素
int parent; //双亲位置域
}PTNode;
typedef struct{ //树的类型定义
PTNode nodes[MAX_TREE_SIZE]; //双亲表示
int n; //结点数
}PTree;
我们在PTree里还加了个结点数,是因为这样就不用在需要结点数的时候一个一个遍历了。
当然这种“双亲表示法”还可以用来存储“森林”,森林中每棵树的根结点双亲指针都=-1即可。
但是!你有没有发现,这种办法找父亲是方便了,找孩子呢?找孩子该怎么找?只能一个一个遍历,很麻烦。所以这种方法有优有缺:
- 优点:找父结点很方便;
- 缺点:找孩子不方便,只能从头到尾遍历整个数组。
所以它适合那种找“父亲”多,找“孩子”少的应用场景,如
“并查集”
。
1.2 孩子表示法(顺序+链式存储)
这种我们是方便找孩子的,但是一个父会有多个孩子,按照顺序存储的方式总不能在它的child里面写上1,2,3这样,所以它的孩子得串起来,这就比较清晰方便且易懂。
也就是说,孩子表示法就是用数组顺序存储各个结点
,每个结点中存储数据元素、孩子链表头指针
干干的,上图:
当然还是代码定义更清晰:
#define MAX_TREE_SIZE 100 //树中最多结点数
struct CTNode{
int data; //孩子结点在数组中的位置
struct CTNode *next;//下一个孩子
};
typedef struct{
int data;
struct CTNode *firstChild; //第一个孩子
}CTBox;
typedef struct{
CTBox nodes[MAX_TREE_SIZE];
int n,r; //结点数和根的位置
}CTree;
这个是顺序存储+链式存储相结合。显然其中我们可以看到,CTNode是孩子链表的结点定义,CTBox是这个顺序存储的顺序表中的每个元素定义,CTree是这个树的定义。
在上面那个图中,结点数n=11,根的位置r=0.
当然我们也可以用孩子表示法存储“森林”,只不过需要记录多个根的位置。
不过,其实这也很显然,这种办法找孩子是方便了,但是找双亲又困难了,只能遍历。所以:
孩子表示法:
- 优点:找孩子很方便
- 缺点:找双亲不方便,只能遍历每个链表
所以它适用于“找孩子”多,找“双亲”少的应用场景,比如服务流程树(就是那种一级一级往下的,比如通话服务转1转2又转1转2转3…什么的)
1.3 孩子兄弟表示法(链式存储)
还记得二叉树的结点是什么样吗?是这样的:
//二叉树的结点(链式存储)
typedef struct BiTreeNode{
int data;
struct BiTreeNode *lchild, *rchild;
}BiTreeNode, *BiTree;
那我们树的孩子兄弟表示法,与二叉树类似,采用二叉链表实现。每个结点内保存数据元素和两个指针
,但两个指针的含义与二叉树结点不同:
//树的存储-孩子兄弟表示法
typedef struct CSNode{
int data; //数据域
//指向第一个孩子和右边的兄弟
struct CSNode *firstChild,*nextsibling; //第一个孩子和右兄弟指针
}CSNode, *CSTree;
弱弱说一句:其实它这样感觉很像是把树扭曲成二叉树存放了。。
当然它也可以存储“森林”,森林中每棵树的根结点视为平级的兄弟关系。当使用“孩子兄弟表示法”存储树或森林时,从存储视角来看
形态上与二叉树类似
。
2.树、森林和二叉树的转换
2.1 树、森林->二叉树
其实就是刚刚提到的孩子兄弟表示法,先确定一个根节点,如果是森林就把所有森林的根都当做第一树的根的兄弟来看,如果当前处理的结点在树中有孩子,就把所有孩子结点“用右指针串起来”,并在二叉树中把第一个孩子挂在当前结点的左指针下方
举个栗子:
再举个栗子:
2.2 二叉树->树、森林
就是把刚刚换成的二叉树逆向恢复,还是从根节点开始处理,依次层序遍历,如果当前处理的结点有左孩子,就把左孩子和左孩子的一整串右指针都拆下来,按顺序挂在当前结点的下方。
栗子如下:
其实这里我们就能知道,如果这个二叉树的根节点只有左子树,那就是转化成树;如果有右子树,那就是转化成森林。如下:
要注意的就是,森林中各棵树的根节点视为平级的兄弟关系
。
其实就是“左孩子右兄弟”,没啥其他的了。
3.树和森林的遍历
3.1 树的遍历
1、树的先根遍历
就是,若树非空,则先访问根节点,再依次队每棵树进行先根遍历。
因为它不再是二叉树那种只有左子树和右子树了,所以伪代码要变一下:
//树的先根遍历
void preOrder(TreeNode *R){
if(R!=NULL){
visit(R); //访问根节点
while(R还有下一个子树T){
preOrder(T); //先根遍历下一棵子树
}
}
}
其实也就是从左往右一棵子树一棵子树遍历,这就是树的先根遍历。
树的先根遍历其实和这棵树对应的二叉树的先序序列是相同的,因为它一个一个子树访问肯定是先访问左边的再访问右边的,右边的会变成右子结点,所以和左根右是一样的。
2、树的后根遍历
就是,若树非空,则先依次对每棵子树进行后根遍历,最后再访问根节点。
伪代码如下:
//树的先根遍历
void postOrder(TreeNode *R){
if(R!=NULL){
while(R还有下一个子树T){
postOrder(T); //后根遍历下一棵子树
}
visit(R); //访问根节点
}
}
其实就是左右根这样一个一个从左往右遍历子树,这就是树的后根遍历。
但是我们树化成二叉树之后就会发现树的兄弟会变成二叉树的右孩子,也就是说如果我们有一棵只有两层的树,化成二叉树就是这个根节点的左孩子只有一个,这个左孩子的右孩子有很深一串的二叉树,这个左孩子的那串右孩子就是它曾经的兄弟(怎么听起来有点残忍。。。),所以:
树的后根遍历序列就是这棵树相对应的二叉树的中序序列(左根右)。
2、树的层次遍历
还是用队列。
若树非空,则根节点入队,队列非空则队头元素出队并访问,同时将该元素的孩子依次入队,一直重复直到队列为空。
这个就相当于图的广度优先遍历,之后会讲到。插一嘴,那既然这个是广度优先遍历,深度优先遍历也有了就,就是刚刚说到的树的后根遍历,因为它确保所有子结点都被访问后再处理父节点。
3.2 森林的遍历
森林只有先序遍历和中序遍历,没有后序遍历。
1、森林的先序遍历
就是,若森林为非空,则按照如下规则进行遍历:
- 访问森林中第一棵树的根节点;
- 先序遍历第一棵树中根节点的子树森林;
- 先序遍历除去第一棵树之后剩余的树构成的森林。
其实就是依次对各个树进行先根遍历。。。没什么特别的。当然效果也等同于依次对二叉树进行先序遍历。
2、森林的中序遍历
就是,若森林非空,则按如下规则进行遍历:
- 中序遍历森林中第一棵树的根节点的子树森林;
- 访问第一棵树的根节点;
- 中序遍历除去第一棵树之后剩余的树。
效果等同于对它转换成的二叉树进行中序遍历。
也等于依次对各个树进行后根遍历,为什么?
因为将森林转换为二叉树后,中序遍历该二叉树的过程为:
- 遍历左子树(对应第一棵树的子树森林)。
- 访问根结点(对应第一棵树的根)
- 遍历右子树(对应剩余树组成的森林)。
此过程与 依次对各树进行后根遍历 完全一致,因为每棵树的后根遍历对应其转换后的二叉树的中序遍历。
小结:
树 | 森林 | 二叉树 |
---|---|---|
先根遍历 | 先序遍历 | 先序遍历 |
后根遍历 | 中序遍历 | 中序遍历 |
先序遍历所有的都是一样的。那么我们刚刚已经解释过了为什么树的后序是二叉树的中序,森林呢就得先把它先转化为二叉树,知道森林的中序遍历就是二叉树的中序遍历,完了再对应一下树的后根才是二叉树的中序,就能更方便地把森林的中序和树的后根对应起来了!
二、应用
1.哈夫曼树
1.1 带权路径长度
首先说一个概念,结点的权
是什么意思?其实就是我们的树的结点的值,这个值具有一定的意义,比如代表结点的重要性等。
那么什么是结点的带权路径长度
?就是从树的根到该结点的路径长度(经过的边数)与该结点上权值的乘积。
举个栗子,比如一棵树:
1
/ \
1 4
/ \
1 2
\
3
则3这个结点的带权路径长度就是3*3=9.
那树的带权路径长度
又是什么呢?树的带权路径长度就是树中所有
叶节点的带权路径的长度之和
(WPL,Weighted Path Length)
比如上面那个栗子,WPL=2 * 1+3 * 3+1 * 4 = 15.
1.2 哈夫曼树
现在我们已经学会了带权路径长度,那么什么是哈夫曼树?
在含有n个带权叶结点的二叉树中,其中带权路径长度(WPL)最小的二叉树称为哈夫曼树
,也称最优二叉树
。
如果带权叶结点固定,哈夫曼树是不唯一的。几棵树构造不同,但是树的带权路径长度相同都是有可能的。
所以接下来我们就要探讨如何构造一棵哈夫曼树,先看看定义:
在给定n个权值分别为w1,w2 …wn的结点,构造哈夫曼树的算法描述如下:
- 将这n个结点分别作为n棵仅含一棵结点的二叉树,构成森林F;
- 构造一个新结点,从F中选取两棵根结点权值最小的树作为新结点的左、右子树,并且将新结点的权值置为左、右子树上根结点的权值之和;
- 从F中删除刚才选出的两棵树,同时将新得到的树加入F中;
- 重复步骤2,3,直至F中只剩下一棵树为止。
举个栗子
比如我现在有几个结点,权值分别是1,2,2,3,7,现在我来构造一棵哈夫曼树:
首先把最小的1,2拿出来(哪个2都行),生成一个新的结点3,再把3放回去,树是这样的:
3
/ \
1
2
我们的结点序列就变成了2,3,3,7
然后我们再从结点序列中拿两个小的,拿2,3(哪个3都行),树就变成了这样:
5
/ \
3 2
/ \
1
2
我们的结点序列就变成了3,5,7
然后我们再再从结点序列中拿两个小的,拿3,5,树就变成了这样:
8
/ \
5 3
/ \
3 2
/ \
1
2
我们的结点序列就变成了7,8
我们再把最后结点序列中只剩的两个结点合并,我们的哈夫曼树就构成了,是这样的:
8
/ \
7
8
/ \
5 3
/ \
3 2
/ \
1
2
所以WPLmin=1 * 7 + 2 * 3 + 3 * 2 + 4 * 1 + 4 * 2 = 31
由上可知,哈夫曼树有这些特点:
- 每个初始结点最终都成为叶结点,且权值越小的结点到根节点的路径长度越大
- 哈夫曼树的结点总数为2n-1
- 哈夫曼树中不存在度为1的结点
- 哈夫曼树并不唯一,但WPL必然相同且为最优
结点总数为2n-1是因为初始n个结点合并n-1次,所以一共n+n-1就是2n-1个。
1.3 哈夫曼编码
我们在电视剧里都看过电报,点、划表示0,1,那我们的哈夫曼编码说白了其实类似,就是用一些二进制位表示需要用到的东西。
比如我们的ASCΙΙ码,其中
A:0100 0001
B:0100 0010
C:0100 0011
D:0100 0100
如果我们想用0,1表示ABCD四个字符,那是不是不用那么麻烦,2bit就够了,也就是每个字符用长度为2的二进制表示就好了:
A:00
B:01
C:10
D:11
这就是
固定长度编码
,也就是每个字符都用相等长度的二进制位表示.
则如果我们有100个题,其中80题选C,10题选A,8题选B,2题选D
那么所有答案的二进制长度就是80 * 2 + 10 * 2 + 8 * 2 + 2 * 2 = 200bit
换成树就是这样的:
WPL=80 * 2 + 10 * 2 + 8 * 2 + 2 * 2 = 200
也就是说这个200其实就是这个树的带权路径长度。那我们要想用尽可能少的bit,构造一棵带权路径长度最小的树不就可以了吗,所以我们可以构造一棵哈夫曼树,把出现的次数当成它的权值,构造如下所示:
WPL=80 * 1 + 10 * 2 + 8 * 3 + 2 * 3 = 130
把左边边当成0,右边边当成1,我们可以得到这个哈夫曼树对应的编码:
C:0
A:10
B:111
D:110
这就是就是哈夫曼编码。当然,哈夫曼树不唯一,所以哈弗曼编码也是不唯一的。
这是一个
可变长度编码
,也就是说允许对不同字符用不等长的二进制位表示的编码。
如果我们乱写,也就是说我们用这个来编码:
C:0
A:1
B:11
D:10
这样可以吗?
答案肯定是不行滴,因为如果这样的话你传过来111你就不知道是一个A一个B还是三个A了,除此之外传10你也不知道是一个D还是一个A一个C。换成树更直观,也就是会发现在通往B、D的路径上经过了A,所以你就区分不出来:
所以,像刚刚哈夫曼树构造的那种,没有一个编码是另外一个编码的前缀,称这样的编码为前缀编码
ATTENTION!!!没有一个编码是另外一个编码的前缀才称为前缀编码,也就是说哈夫曼编码就是一种前缀编码。
前缀编码解码无歧义
小结一下:由哈夫曼树得到哈夫曼编码——字符集中的每个字符作为一个叶子结点,各个字符出现的频度作为结点的权值,根据之前介绍的方法构造哈夫曼树即可。
弱弱说一句:哈夫曼树可以用于数据库压缩存储,因为哈弗曼编码通过根据字符的频率分配不同长度的码字,使得高频字符使用较短的码,从而减少整体数据量,实现压缩。这种编码方式不仅节省存储空间,还能再解码时快速恢复原始数据,哈夫曼树的结构还确保了编码的唯一性和无损性,故能用于数据库压缩存储提升存储效率和访问速度。
2.并查集
2.1 “集合”关系表示
如果我们有一个集合,应该怎么去表示?第一个想到的是不是森林,因为我们的不相交的子集其实就是森林中的一些树,因为森林本身就是互不相交的树的集合嘛。所以如果我们想表示集合,可以将同一子集中的各个元素组成一棵树,这样就能方便区分不同子集了。
所以我们用互不相交的树表示多个“集合”
那我们要怎么才能“查
”到一个元素到底属于哪一个集合?这个问题显然就是要向上找到它的根呀,只要知道它的根不就知道它属于哪个集合了吗。那同理,我们通过找根也可以判断两个元素是否属于同一个集合(因为如果属于一个集合找到的根肯定是相同的)。
那我们要怎么才能把两个集合“并
”为一个集合?第一个想到的肯定是让他们根节点相同,那其实就是直接让一棵树成为另一棵树的子树就可以了。
而我们上面有说到树的存储方式,什么来着还记得不?就是双亲表示法,孩子表示法和孩子兄弟表示法。我们在“并查集”的操作主要是找根结点,所以应该用的就是双亲表示法(顺序存储实现)。
还记得我们双亲表示法的代码结构是什么吗?
#define MAX_TREE_SIZE 100 //树中最多结点数
typedef struct{ //树的结点定义
int data; //数据元素
int parent; //双亲位置域
}PTNode;
typedef struct{ //树的类型定义
PTNode nodes[MAX_TREE_SIZE]; //双亲表示
int n; //结点数
}PTree;
看看图:
由此可得,用一个数组s[]即可表示“集合”关系。
2.2 并查集代码实现
刚刚我们知道,集合的两个基本操作其实就是——“并
”和“查
”
Find——“查”操作:确定一个指定元素所属集合
Union——“并”操作:将两个不相交的集合合并为一个
注:并查集(Disjoint Set)是逻辑结构——集合的一种具体实现,只进行“并”和“查”两种基本操作
我们先初始化一个并查集:
#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 = S[x]; //循环寻找x的根
}
return x;
}
//Union “并”操作,将两个集合合并成一个
void Union(int S[], int Root1, int Root2){
//要求Root1和Root2是不同的集合
if(Root2 == Root1){
return;
}
//将根Root2连接到另一根Root1下面
S[Root2] = Root1;
}
要注意并的时候得先判断是不是一个根的,如果是的话就没必要再并了。
额,所谓的Root1和Root2其实就是数组下标,S[i]就是它的父的数组下标,其实还是之前说的双亲表示法,没啥新花样。
那我们来看看这个“查”和“并”的时间复杂度该当如何,设结点数为n。首先是那个“查”,它最坏不就一直while,while所有的结点,所以Find的最坏时间复杂度为O(n)
。那“并”呢,啥也不用干,直接S[Root2] = Root1;,所以Union的时间复杂度为O(1)
。
2.3 并查集优化
那我们的Find为啥最坏时间复杂度为O(n)?不就是因为如果这个树一层就一个结点,它的高度h=n嘛。所以我们如果想要优化的话,就得让它的高度h没那么高。如何让它的高度没那么高?最容易想的就是,在它Union的时候,不要让它长那么高,也就是“小树合并到大树”,这样可以稍微抑制一下它变得很长~
所以我们可以
对Union操作进行优化
:在每次Union操作构建树的时候,尽可能让树不长高
- 用根节点的绝对值表示树的结点总数
- Union操作,让小树合并到大树
代码就是这个样子滴:
//Union “并”操作,小树合并到大树
void optimizeUnion(int S[], int Root1, int Root2){
//要求Root1和Root2是不同的集合
if(Root2 == Root1){
return;
}
if(S[Root2] > S[Root1]){ //Root2结点数更少
S[Root1] += S[Root2]; //累加结点总数
S[Root2] = Root1; //小树合并到大树
}else{
S[Root2] += S[Root1]; //累加结点总数
S[Root1] = Root2; //小树合并到大树
}
}
要注意的就是不要忘了更新结点总数!!这个好容易忘啊。。
这个优化后的Union构造的树高不超过⌊log~2~n⌋+1
,可以用数学归纳法来证。所以Union操作优化后,Find操作的最坏时间复杂度就变成了O(log2n)。优化后的Union操作时间复杂度还是O(1)。
3.并查集的进一步优化
现在呢,我们并查集的进一步优化就不是优化Union了,而是优化Find。但是Find是个“查”,“查”怎么优化呢?确实在“查”的过程中无法再进一步优化了,但是我们可以做到“身后事”,在“查”的时候做一些操作方便之后“查”。
那我们查之后岂不是再查这一个还要走那么多的路,这也太麻烦了。所以就开始压缩路径
,也就是Find操作先找到根节点,再将查找路径上的所有结点全部都挂到根节点下。
我们之前查是怎么查来着?上代码看的清楚些:
//Find "查"操作,找x所属集合(返回x所属根结点)
int Find(int S[], int x){
while(S[x] >= 0){
x = S[x]; //循环寻找x的根
}
return x;
}
那现在肯定不能这样。那我们要做什么?我们在找到根之后,放一边不管它,然后再用找x的根的这个x,从它开始依次向上找,先找到它的父节点存起来,然后再把它挂到根结点底下(改变S[x]的值,让S[x] = root),(先找父节点再挂是因为先挂了再找父节点就是根了,就找不到它原来的父节点了)完了再让x等于这个父节点,依次这么干。上代码:
//Find "查"操作,先找到根结点,再进行“压缩路径”
int optimizeFind(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; //返回根节点编号
}
当然我们本来要干的事情是找到那个根,别忘了初心,记得返回那个根(用root保存的)。
每次Find操作,先找根,再“压缩路径”,可使树的高度不超过O(∂(n))。∂(n)是一个增长很缓慢的函数,对于常见的n值,通常∂(n)≤4,因此优化后并查集的Find操作、Union操作时间开销都很低。
所以“压缩路径”优化Find函数后,它的最坏时间复杂度就可以变成O(∂(n))了
。
小结:
将n个独立元素通过多次Union合并为一个集合
:原来
的最坏时间复杂度为O(n2),(因为此时Find的最坏树高为n,最坏时间复杂度为O(n));优化Union
后,最坏时间复杂度为O(nlog2n),(因为此时Find的最坏树高为⌊log2n⌋+1,最坏时间复杂度为O(log2n));优化Find后
,最坏时间复杂度为O(n∂(n)),(因为此时Find的最坏树高为∂(n),最坏时间复杂度为O(∂(n)))。
总结
我们讲了树的存储方式,还有树、森林怎么遍历,还有哈夫曼编码、并查集,要注意的就是并查集的两重优化策略是怎么优化的。还有一个容易混的就是树的后根是二叉树的中序,森林的中序也是二叉树的中序,所以树的后根就是森林的中序。