5.树
树和二叉树
树的逻辑结构
定义
树形结构,即是从树根生长,逐级分支的结构
结点数为0
的树称为空树,非空树的特性是:有且仅有一个根节点,除了根节点外,任何一个结点都有且仅有一个前驱
子树:树可以分为m
个互不相交的有限集合,其中每个集合本身又是一棵树,并且称为根节点的子树
树是一种递归定义的数据结构
结点的度:即结点孩子的数量
树的度:各结点的度的最大值
森林:m
棵互不相交的树的集合
性质
结点数=总度数+1
这个较好理解,总度数就是除了根节点之外的结点数,或者说,总度数就是树的”树枝“
对于森林来说,结点数=总度数+树的数量
度为
m
的树是指结点的度的最大值,而m
叉树是指,每个结点最多只能有m
个孩子,实际上即使没有也可以度为
m
的树的第i
层至多有$m^{i-1}$个结点由3可知,把每层的结点加起来,根据等比数列求和,高度为h的m叉树最多有$\frac{m^h-1}{m-1}$个结点
高度为
h
的m
叉树至少有h
个结点,高度为h
,度为m
的树至少有h+m-1
个结点
二叉树的逻辑结构
定义
二叉树是n
个结点的有限集合,n
为0
时是空二叉树,n>0
时是由一个根节点和两个互不相交的被称为根的左子树和右子树组成
特点:每个结点至少只有两棵子树,左右子树不能颠倒
几种特殊的二叉树:
满二叉树
一颗高度为h,且含有$2^h-1$个结点的二叉树
只有最后一层有叶子结点,而且不存在度为1的结点
完全二叉树
当且仅当每个结点都与高度为
h
的满二叉树中编号为1~n
的结点一一对应时,称为完全二叉树最多只有一个度为1的结点,$i≤[n/2]$为分支结点,$i>[n/2]$为叶子结点
二叉排序树(BST)
左子树上所有结点的关键字均小于根节点的关键字
右节点上所有结点的关键字均大于根节点的关键字
左子树和右子树又均是一颗二叉排序树
平衡二叉树(AVL)
树上任一结点的左子树和右子树的深度之差不超过1
性质
设非空二叉树中度为0,1,2的结点个数分别为$n_0,n_1,n_2$,则$n_0=n_2+1$,即叶子结点比二分支结点多一个
推导过程:已知树的结点数=总度数+1 即$n=n_1+2n_2+1$,同时$n=n_0+n_1+n_2$,所以可以推出$n_0=n_2+1$
完全二叉树
- 具有
n
个结点的完全二叉树的高度h
为$log_2(n+1)$或$[log_2n]+1$ - 对于完全二叉树, 已知其最多只有一个度为1的结点,$n_1=0或1$,而二叉树有性质$n_0=n_2+1$,所以$n=n_1+2n_2+1$,如果完全二叉树有偶数个结点,那么$n_1$为1,奇数个为0
二叉树的存储结构
顺序存储
定义
1 | struct TreeNode{ |
用数组存储二叉树的结点,一定要把二叉树的结点编号与完全二叉树对应起来
在最坏的情况下,二叉树为右单枝树,也至少需要$2^h-1$个存储单元,结论是二叉树的顺序存储结构,只适合存储完全二叉树
链式存储
定义
1 | typedef struct BiTNode |
(有两个指针域的链表罢了)
n
个结点的二叉链表共有n+1
个空链域
创建二叉树
1 | void CreateBiTree(BiTree* T) // 双重指针,因为需要修改根节点的指向,就是需要修改root的值,所以应该传入指向root的地址,这样在被调用的函数中,对*T的操作等价于操作root |
二叉树的遍历
遍历:按照某种次序把所有结点都访问一遍
一共有4种遍历
- 先序遍历:对于二叉树和其子树,先访问二叉树的根节点,然后是左孩子,然后是右孩子 (根左右)
- 中序遍历:对于二叉树和其子树,先访问二叉树的左孩子,然后是根节点,然后是右孩子 (左根右)
- 后序遍历:对于二叉树和其子树,先访问二叉树的左孩子,然后是右孩子,然后是根节点 (左右根)
- 层序遍历:按照二叉树的分层,从左到右依次访问
先序,中序,后序遍历的代码实现(递归):
1 | //二叉树的先序,中序,后序遍历(递归) |
可以很明显的看出,三种遍历方式的不同之处在于,在何时对二叉树结点进行操作
对二叉树进行遍历,模拟访问路径,红色路线表示第一次访问,绿色路线表示第二次访问,紫色表示第三次访问
可以看出,第一次访问就出栈是前序遍历,第二次访问就出栈是中序遍历,第三次访问就出栈是后序遍历
1 | // 栈的顺序结构 |
至于第三次访问,则需要增加一个辅助栈来记录访问次数
1 | //非递归利用顺序栈来进行后序遍历 |
层序遍历
需要构造一个辅助队列,如队列非空,则队头结点出队,访问该结点,并将其左右孩子插入队尾,直到队列为空
1 | // 定义层次遍历中辅助队列的结构 |
根据遍历构造二叉树
根据一种遍历顺序是无法得到二叉树结构的,需要中序和其他任一遍历序列才能构造出二叉树
寻找任一二叉树结点的父节点
1 | / |
线索化二叉树
二叉树的问题:
- 建立二叉树时,
n
个结点的二叉树总共有2n
个指针域,会有n+1
个指针域被浪费 - 在创建二叉树的时候,大部分算法都是用递归实现,实现起来简单,然而系统运行起来却很吃力,很占用系统资源。当我们在建立好二叉树之后我们需要频繁的查找某个单元,就得频繁的调用遍历递归去查找,这显然并不高效
结合这两个问题,解决办法是用这些空指针域,存放一些有用的指向信息,从而尽量的将一棵二叉树线性化成双向链表,我们把这些指向信息称之为线索,也就是说线索二叉树等效成双向链表
中序遍历
先序遍历
后序遍历
结点的定义
1 | //将原来的三个域扩展到五个域 |
创建并初始化
1 | BiTree *creat() //二叉树的创建及初始化(初始化左右标记为0) |
中序化二叉树
- 利用两个指针
p
和pre
,让其在中序遍历的过程中分别指向一前一后,也就是说始终保持p是pre
的后继,pre
是p
的前驱;那么在处理到p
结点的时候,我们可以根据p节点是否有左孩子来判断,p->lchild
是否要指向前驱,若p
指向的节点没有左孩子,则直接让p->lchild=pre;
并将p->ltag
赋值为1,即可完成将结点的左孩子指向前驱结点。pre
也一个道理,若pre
所指向的节点没有右孩子,将pre->rchild=p;
并即将pre->rtag
赋值为1,即可完成该结点的右孩子指向后继节点 - 整体思路就是:通过
p
来处理左孩子指向前驱(左孩子为空的情况下),通过pre
来处理右孩子指向后继(右孩子为空的情况下)
pre
与p
指针功能相同,但定义却不相同,p
是线索化函数内的局部变量,而pre
为了保证在每一次递归序列中能够正确返回当前值,必须定义成全局变量
1 | //因为我们是以中序遍历的顺序给二叉树填上线索,所以,整个线索化的函数是套用在中序遍历的框架下的。 |
查找后继
1 | BiTree* Next(BiTree* t) //已知节点t找t的"后继"结点位置 |
查找前驱
1 | BiTree* Prior(BiTree* t)//已知节点t找t的"前驱"结点位置 |
利用线索实现中序遍历
但是需要注意的一点是,我们creat
函数中输入是以先序的次序输入端,故p
一开始指向的节点是先序的第一个节点,而不是中序的第一个结点,所以要先查找第一个节点,在用Next中的线索查找后续结点
1 | void InorderTraverse(BiTree *t)//利用线索实现中序遍历 |
在中序遍历中,左根右的顺序,任意一结点可以找到前驱和后继
在前序遍历中,根左右的顺序,有左孩子的结点是无法找到其前驱的
在后序遍历中,左右根的顺序,有右孩子的结点是无法找到其后继的
树的存储结构
双亲表示法(顺序存储)
每个结点中保存自身数据以及指向父节点的位置
定义
1 | typedef struct Tree{ |
优点:查指定结点的双亲很方便
缺点:查指定结点的孩子只能从头遍历
孩子表示法(顺序+链式)
顺序存储各个结点,每个结点中保存孩子链表头指针
定义
1 | struct CTNode{ |
优点:查指定结点的孩子很方便
缺点:查指定结点的父节点只能从头遍历
孩子兄弟表示法(链式存储)
其定义和二叉树一模一样,不过是指针域指向的对象不一样
1 | typedef struct CSNode { |
关键在于,这种表示法可以将树转化为二叉树,从而可以用熟悉的二叉树的操作来操作树
而对森林来说,则可以将几颗树的头结点相连,再用孩子兄弟表示法表示即可
树的遍历
先根遍历
如果树非空,先访问根节点,然后依次访问其子树,对树的先根遍历和对其转换二叉树的先序遍历结果是一样的
后根遍历
若树非空,先依次对每棵树进行后根遍历,最后再访问根节点,对树的后根遍历和对其转换二叉树的中序遍历结果是一样的
层序遍历
按照层次访问树,可以看做对树的广度优先遍历(BFS)
哈夫曼树
定义
当有 n
个结点(都做叶子结点且都有各自的权值)构建一棵树时,如果构建的这棵树的带权路径长度(WPL)最小,称这棵树为“最优二叉树”,有时也叫“哈夫曼树“
权值可以理解为是访问频率,而带权路径长度=权值 x 该节点到树根的路径长度
树的带权路径长度=所有叶节点的带权路径之和WPL(Weighted Path Length)
构建
选取并合并:选取取值最小的两个结点合并成一棵树(根节点为权值之和)
删除并加入:从序列中删除上述选取的两个两个最小结点,加入新合并的树,新合并的树权值为两结点之和
加入:选择两个结点,和第2步合并的结点,一共三个节点,选择其中最小的两个结点合并,组成新合并的树
继续重复上述操作即可得到哈夫曼树
哈夫曼树并不唯一,但是WPL的值肯定是唯一的
哈夫曼编码
即任一一个编码不能是其他编码的前缀,将字符集中的每个字符作为一个叶子结点,各个字符出现的频率作为结点的权值,可用于数据压缩
并查集
逻辑结构
并指合并操作,查指查找操作,集指采用集合这种逻辑结构,并查集就是可以进行合并和查找的集合
森林就将各个元素划分为若干个互不相交的子集
在森林中,查指在森林中查找某一结点属于那个集合,并指两棵树的合并
存储结构
存储结构采取双亲表示法可以方便地实现并查
用一个数组就可以表示集合关系
1 |
|
并操作给出了两棵树的根节点,合并的时间复杂度只需要O(1)
查操作的时间复杂度在O(n)
查操作的时间复杂度和树的高度有关,可以优化并的操作,让小树成为大树的子树,从而尽量不增加树的高度
优化思路:在每次Union操作构建树的时候,用根的绝对值表示树的结点总数
1 | //Union 并操作 将两个集合合并成一个 |
优化后find操作的最坏时间复杂度为$O(log_2n)$
Find操作可以继续优化,如果不考虑保持树的结构,可以将尽可能多的树挂靠在根节点之下,从而可以更快查到根节点
压缩路径可以将被查找节点到根节点路径中的所有结点都挂靠在根节点之下
1 | //Find 查操作 压缩路径 |