【考研·数据结构】 树 小结

本篇目录

前言

一、概念

二、树和森林

二、二叉树的存储结构

1.顺序存储

2.链式存储

三、二叉树的遍历

1.先序、中序、后序的递归遍历

2.层序遍历

3.先序、中序、后序非递归遍历

4.线索二叉树

四、二叉树的应用

1.二叉排序树

2.平衡二叉树

3.哈夫曼树

4.B树(多路平衡查找树)

5.B+树

总结


前言

本篇梳理数据结构中 “树” 这一部分的概念、遍历、应用(各种算法)等。


一、概念

【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+树的异同点
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+树来说,非叶结点仅仅是索引,叶结点也包含关键字信息,因此无论查找成功与否,每次查找都是一条从根结点到叶结点的路径。


总结

本文总结了数据结构中 “树” 这一部分的概念、遍历、应用(各种算法)等。

猜你喜欢

转载自blog.csdn.net/Dr_Cheeze/article/details/128048069
今日推荐