本篇目录
扫描二维码关注公众号,回复: 14727566 查看本文章
前言
本篇梳理数据结构中 “树” 这一部分的概念、遍历、应用(各种算法)等。
一、概念
【1】树
1.树是一种逻辑结构,也是一种分层结构。根节点没有父节点,叶子结点没有子结点。
2.有序树:树的左右子树不能互换。
3.树的高度:树的层数。
4.树的结点数 = 树的边数 + 1 。(每一个结点都有一条边连接父结点,除了根节点。)
5.树的路径长度 = 树到每个结点的路径总和。
6.树的度:树中结点的最大度数
【2】二叉树
1.二叉树:树的度≤2 。
2.满二叉树:叶子结点都在最下边一层,其余结点度为2 。
3.完全二叉树:该树所有结点的编号与对应满二叉树的结点编号一致,但比满二叉树少了最后几个结点。
4.二叉排序树:任意结点的关键字,大于左子树所有结点的关键字,小于右子树所有结点的关键字。
5.平衡二叉树:任意结点,左右子树深度只差≤1 。
6.二叉树的性质: n0 = n2 + 1 ,即叶子结点数 = 度为2的结点数 + 1 。
二、树和森林
1.树的存储结构
1.双亲表示法(顺序存储)
2.孩子表示法(结点以数组顺序存储,每个结点有子结点单链表)
3.孩子兄弟表示法(二叉树表示法,以二叉链表作为存储结构)
2.树、森林与二叉树的转换
树的左子结点转为二叉树的左子结点,树的右边第一个兄弟(最近的)转为二叉树的右子结点。
3.树的应用:并查集
以树来存储集合,支持集合的合并等操作。
二、二叉树的存储结构
1.顺序存储
使用数组来存储二叉树,适用于存储满二叉树与完全二叉树。最好从下标1开始,这样,数组下边就能反应结点之间的逻辑关系,T[i] 结点的子结点是T[2i] 和 T[2i+1] 。如果不是完全二叉树或满二叉树,为了反映这种逻辑关系,就要添加一些空结点(该角标对应的值为0),但这样就会造成空间浪费。
2.链式存储
二叉树一般都采用链式存储(使用二叉链表)。一个结点的结构包含 左指针域、数据域、右指针域,左右指针域分别指向该结点的左右子结点。如果某结点没有左子结点或右子结点,那么对应的指针域就为空。在线索二叉树中,这些空的指针域被用来指向该结点的前驱结点和后继节点(前驱与后继的判断是依据二叉树的遍历策略决定的)。
性质:在含有n个结点的二叉链表中,含有n+1个空链域。(每个结点有2个指针域,所以整个表共有2n个指针域,而作为子节点被指向的一共有n-1个。)
三、二叉树的遍历
1.先序、中序、后序的递归遍历
遍历就是访问树中所有结点,首先来考虑递归算法,即递归地将每个结点看作根节点,访问自身、左子结点、右子结点。对于每个节点来说,自身、左子结点、右子结点最终都要被访问,只是访问的顺序可以有不同,这种访问顺序的不同就产生了先序遍历(根左右)、中序遍历(左根右)、后序遍历(左右根)三种遍历方式,但是其本质原理都是相同的。下面通过代码来演示。
//先序遍历、中序遍历、后序遍历 的递归算法
void VisitTree(BiTree T){
if(T!=NULL){ //若树非空
visit(T); //1.先序遍历
VistiTree(T->lchild); //访问左子结点
visit(T); //2.中序遍历
VisitTree(T->rchild); //访问右子结点
visit(T); //3.后序遍历
}
}
实际上访问结点自身的代码( visit(T) )只需要出现一次,如果出现在位置1,那么就是先序遍历,如果出现在位置2,那么就是中序遍历,如果出现在位置3,那么就是后序遍历。由此可见,这三种遍历方式的递归算法,仅仅只是在访问顺序上有所不同。
时空复杂度
显然,无论是先序、中序、后序,它们只是访问顺序不同(visit(T); 这句代码的位置不同),时空复杂度是一致的。因为要访问n个结点,每个结点都递归调用一次,就需要一块栈空间。所以,递归算法的时间复杂度为O(n),空间复杂度为O(n) 。
2.层序遍历
在 《【考研·数据结构】 图 小结》 中总结过,树是一种特殊的图,二叉树的先序遍历就相当于图的深度优先搜索(DFS)。树是一种分层结构,因此还可以按层进行层序遍历,这相当于图的广度优先搜索(BFS)。层序遍历的原理就是优先将所有能找到的结点都先加入队列中暂存,之后再逐个从队列中取出元素进行访问。这种策略与 图 那部分举的吃火锅需要一个盘子的策略是相同的。
//二叉树的层序遍历
void LevelOrder(BiTree T){
InitQueue(Q); //辅助队列
BiTree p; //定义一个二叉树结点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); //若右子结点存在,则将其入队
}
}
3.先序、中序、后序非递归遍历
除了递归算法外,先序、中序、后序也可以有非递归算法,主要通过栈来辅助实现。
1.先序遍历的非递归算法
思路:从根结点开始,一路向左(深度优先),每遇到一个结点就访问并入栈(入栈是因为之后还要访问这个结点的右子结点),然后访问左子结点,直到访问到底部(左指针域为空的结点) ,说明左子树这一支访问完毕。之后出栈一个结点,这个结点能在栈中说明它自身已经被访问过,并且左子树已经访问完毕,于是现在访问它的右子结点。等到最右下方的结点被访问完毕,那么此时栈空,树也遍历完成。
//先序遍历 非递归算法
void PreOrder(BiTree T){
InitStack(S); //辅助工作栈
BiTree p = T; //p是遍历指针,初始指向根节点
while(p || !isEmpty(S)){
if(p){ //如果当前指针不空,就一路向左
visit(p); //先访问结点自身,然后去找左子结点
Push(S,p); //将自身节点入栈,因为待左子树访问完毕,还要去访问右子树
p = p->lchild; //去访问左子结点
}
else{ //若p指向NULL,则p结点的父节点一定没有左子结点
Pop(S,p); //将栈顶元素出栈,因为以后用不到这个结点了
p = p ->rchild; //转向右子树
}
}
}
2.中序遍历的非递归算法
先序与中序的算法本质上是相同的,区别就在于访问某结点自身的时间,是在初次遇到时就访问然后入栈,还是在访问完左子树并将该结点出栈后再访问。对于这种区别,我在代码中做了标注。
//中序遍历 非递归算法
void PreOrder(BiTree T){
InitStack(S); //辅助工作栈
BiTree p = T; //p是遍历指针,初始指向根节点
while(p || !isEmpty(S)){
if(p){ //如果当前指针不空,就一路向左
//visit(p); //*********这句代码如果写在这里,就是先序遍历******************
Push(S,p); //将自身节点入栈
p = p->lchild; //去访问左子结点
}
else{
Pop(S,p); //将栈顶元素出栈,
visit(p); //**********在这里访问自身结点,那么就是中序遍历***********
p = p ->rchild; //转向右子树
}
}
}
3.后序遍历的非递归算法
后序遍历比先序和中序复杂一些,因为要使用一个辅助指针(记录最近访问的结点),来判断当遍历工作指针指向NULL时,是从左子树还是右子树返回的。如果是从左子树返回的(即r≠右子结点)且右子树不空,那么就去访问右子树,这里要注意将右子结点压入栈;如果是从右子树返回的,那么就出栈,访问一下结点自身,然后将工作指针p指向NULL(这是因为,已经访问完的结点,就可以当做空结点,直接向上返回,从而不会被再次访问),就可以了。
//后序遍历的非递归算法
void PostOrder(BiTree T){
InitStack(S); //辅助工作栈
BiTree p = T; //遍历指针,初始时指向根结点
BiTree r = NULL; //辅助指针,指向最近访问过的结点
while(p || !isEmpty(S)){
if(p){ //若当前指针不空,就先去访问左子树
push(S,p);
p = p->lchild;
}
else{ //若当前指针为空,则可能从左子树返回,或从右子树返回
GetTop(S,p); //获取栈顶元素(不出栈)
if(p->rchild && p->rchild!=r){ //若右子树存在且未被访问过
p = p->rchild; //去访问右子树
push(S,p); //将右子结点压入栈
p = p->rchild; //将右子结点作为根节点开始新一轮访问
}
else{
pop(S,p); //左右子树都已访问完,现在弹出结点并访问
visit(p);
r=p; //记录访问状态
p = NULL; //已访问完的结点,就视作空结点,继续向上返回
}
}
}
}
4.根据遍历序列确定二叉树
(中序、先序)(中序、后序)(中序、层序)这三种组合中的每一种,都可以根据序列都可以确定一棵二叉树。
4.线索二叉树
1.概念
二叉链表仅能方便地找到一个结点的子结点,但是不能体现在遍历中的前驱和后继结点。而二叉链表中又有不少空链域,于是想到对结点结构加以改造,形成线索二叉树。
线索二叉树的结点结构是:[ lchild ltag data rtag rchild ],其中ltag 与 rtag 为0时表示对应指针域指向子结点,为1时表示对应指针域指向当前结点的前驱/后继结点(这时的指针即为线索)。这就是使用线索链表存储的线索二叉树。
2.线索二叉树的构造
二叉树的线索化,即通过一次遍历,将二叉树对应链表中的空指针指向结点的前驱/后继结点。有时也可以添加一个头结点,头结点的左指针域指向根结点。
3.线索二叉树的遍历
首先找到第一个要访问的结点,然后判断 ltag,若有后继结点的线索,就直接去访问后继结点,若没有,就按照正常逻辑找到后继结点。
四、二叉树的应用
1.二叉排序树
遵循左小右大原则。插入的一定是叶节点。
2.平衡二叉树
任意结点的左右子树高度差≤1 。
【1】插入新节点
插入新节点导致不平衡,之后应当进行调整,以保持平衡二叉树的性质。
1.LL插入,则右转
2.RR插入,则左转
3.LR插入,则先左转后右转
4.RL插入,则先右转后左转
【2】查找效率
平衡二叉树的平均查找效率(决定于树的高度)为O(log n) 。
3.哈夫曼树
1.某结点的带权路径长度 = 从根节点到该结点的路径长度(边数) * 该结点的权值 。
2.树的带权路径长度WPL = 该树所有叶节点的带权路径长度 。
3.WPL最小的树即为哈夫曼树
构造哈夫曼树的方法:所有结点看作一棵孤立的树,取权值最小的两个结点,新建一个结点作为它俩的父结点,并将这个父结点的权值设置为两个子结点的权值之和。之后不断重复这个过程,直到所有结点形成一棵树。这就是哈夫曼树。
4.哈夫曼编码
前缀编码:没有一个编码是另一个编码的前缀。以每个字符作为一个独立结点,以字符出现的频率作为权值,构造哈夫曼树(指向左子树和右子树的边分别用0和1来标记,也可以倒过来)。则从根节点到每个叶节点的路径即为这个字符的编码。最终WPL可视为二进制编码的长度。
4.B树(多路平衡查找树)
1.B树
m阶B数,是一棵最大允许度为m的树,用于查找。从上向下,不断细化查找范围,比如某结点中有(2,9)两个关键字,那么就对应有三棵子树,三棵子树中的关键字取值范围分别为(0,2)、(2,9)、(9,x)。(这个x的意思是,这个值还要受其它结点的约束。)也就是说,在一棵m 阶的B树中,一个结点最多能有(m-1)个关键字,这样它就有 m 棵子树。
B树的根结点要么是终端结点,要么至少有两棵子树(要是只有一颗子树,那就没有“不断细化查找范围”的意义了啊)。非根结点、非叶结点的结点,子树的数量[ ┌m/2┐-1 ,m ] 。
B树的所有叶结点都在同一层上,也就是B树所有结点的平衡因子为0 。
2.B树插入
插入一定是在叶结点中,若插入后导致关键字数量溢出,就要进行结点分裂。分裂方法是:左归左,右归新,中间顶上去。
3.B树删除
(1)若删除后叶结点的关键字数量符合要求,则直接删除即可。
(2)若删除后叶结点关键字数量低于要求,但兄弟结点有富余,则调整方法为:近兄上位,父下台。
(3)若删除后叶结点关键字数量低于要求,且兄弟结点也无富余,则调整方法为:近兄不上位,父落近兄家。
5.B+树
B+树是一种变形的B树。
对比 B树与B+树
B树 | B+树 | |
结点内关键字数量与子树数量的关系 | n个关键字对应n+1棵子树 | n个关键字对应n棵子树 |
非根非叶结点中关键字个数范围(理解为子树数量要对m过半) | [┌m/2┐-1, m-1 ] | [┌m/2┐, m ] |
根结点内关键字个数范围(理解为至少有2棵子树,最多不超过m棵子树) | [1,m-1 ] | [1,m] |
查找路径 | 所有结点包含的关键字不重复,要是找到叶结点还没成功,那就是查找失败了 | 叶结点包含了所有的关键字,非叶结点仅起索引作用,因此无论查找成功与否,每次查找都是一条从根到叶的路径。 |
查找方式 | 包含两个操作:(在磁盘上)在B树中找结点并读入内存;(在内存中)在结点内用顺序查找或折半查找来找关键字 | 叶结点形成升序链,既可以实现升序的顺序查找,又可以实现从根结点到叶结点的查找。 |
共同点 | 1.每个结点的平衡因子为0 2.根结点的子树数量为[2,m] 3.所有非叶非根结点的子树数量为[┌m/2┐, m ] 。 |
对于B树来说,可以在任何一个结点查找成功,要是找到叶结点还没成功,那就是查找失败了。对于B+树来说,非叶结点仅仅是索引,叶结点也包含关键字信息,因此无论查找成功与否,每次查找都是一条从根结点到叶结点的路径。
总结
本文总结了数据结构中 “树” 这一部分的概念、遍历、应用(各种算法)等。