数据结构(3):树(下)之二叉树与森林及其应用

一.树与森林


树既可以采取顺序存储结构,也可以采取链式存储结构,无论采用何种存储方式,都要求能唯一地反映出树中各节点之间的逻辑关系。

(一)树的存储结构

  1. 双亲表示法

    • 采用一组连续空间来存储每个节点,同时在每个节点中增设一个伪指针,其中树根节点的伪指针域为-1。

    • 结点结构

      data parent
    • #define MAX_TREE_SIZE 100
      typedef struct{
          ElemType data;
          int parent;
      }PTNode;
      
      typedef struct{
          PTNode nodes[MAX_TREE_SIZE];//双亲数组表示
          int n;//节点数目
      }PTree;
      
    • 评价:
      <优>利用了树中每个节点(除了根节点)均只有唯一的双亲,可以很快地找到每个节点的双亲节点
      <缺>求树中每个节点的孩子时需要遍历整个结构

  2. 孩子表示法

    • 将每个节点的孩子节点都用链表的形式连接起来作为一个线性结构,则N个节点就有N个孩子链表,注:叶子节点的孩子链表为空表

    • 结点结构

      data HeadNode
    • #define MAX_TREE_SIZE 100
      typedef struct{
          ElemType data;
          struct PTNode *next;
      }PTNode;
      
      typedef struct{
          PTNode nodes[MAX_TREE_SIZE];//双亲数组表示
          int n;//节点数目
      }PTree;
      
    • 评价:
      <优>对于寻找每个节点的孩子节点十分方便
      <缺>寻找节点的双亲则需要依次遍历N个孩子链表

  3. 孩子兄弟表示法

    • 孩子兄弟表示法本质上就是树的二叉链表表示法,每个节点划分成三个部分:节点值、指向节点第一个孩子节点的指针和指向节点下一个兄弟节点的指针。

    • 结点结构

      firstchild data firstsibling
    • typedef struct CSNode{
          ElemType data;
          struct CSNode *firstchild,*nextsibling;
      }CSNode,*CSTree;
      
      
    • 评价:
      <优>存储结构很灵活,可以方便地实现树与二叉树的转换,查找节点的孩子节点
      <缺>求节点的双亲节点依然较为麻烦,但是可以在节点中再增加一个parent域指向其父节点。

(二)树、森林与二叉树的转换

对比二叉树的存储结构和树的兄弟孩子节点的存储结构描述,会发现二者的存储结构是一致的,数据域+两个指针域,只是对于指针域的解释并不相同。

可以用同一存储结构的不同解释将一棵树转换为二叉树。

  1. 树/森林→二叉树

    • 树→二叉树:

      按照左孩子右兄弟的方法依次转换就好了

    • 森林→二叉树:

      先将森林中的每一棵树转换成二叉树,然后将第一棵树的右子树连接第二棵树,第二棵树的右子树连接第三棵树,依次类推。

  2. 二叉树→树/森林

    • 语言描述无能,着一段就直接参考书上的吧,如果理解了左孩子右兄弟的话,可以直接将二叉树重构成树,而森林的重构,就要先把一棵二叉树的每一个子树的右子树拆解下来,得到多个没有右子树的二叉树,再按照上述进行转换即可。

在这里插入图片描述

(三)树和森林的遍历

树和森林的遍历就是以某种方式访问树和森林中的每一个结点,且仅访问一次。

  1. 树的遍历

    1. 先根遍历:若树非空,先访问树的根节点,然后再按照从左到右的顺序遍历根节点的每一棵子树。
      树的先根遍历等价于将树转换陈二叉树再使用先序遍历。
    2. 后根遍历:若树非空,则按照从左到右的顺序遍历根节点的每一棵子树,之后再访问根节点
      树的后根遍历等同于将树转换为二叉树后再使用中序遍历
    3. 层次遍历:与二叉树的层次遍历思想基本相同。
  2. 森林的遍历

    树和森林的遍历顺序和规则的描述都可以基于递归

    1. 先序遍历:若森林非空:
      • 访问森林中第一棵树的根节点
      • 先序遍历第一棵树中根节点的子树森林
      • 先序遍历除去第一棵树之后剩余的树构成的森林
    2. 中序遍历:若森林非空:
      • 中序遍历森林中第一棵树的根节点的子树森林
      • 访问第一棵树的根节点
      • 中序遍历除去第一棵树之后剩余的树构成的森林

(四)树的应用——并查集

  1. 并查集的基本介绍

    并查集是一种简单的集合表示,支持以下三种操作

    • Union(S,R1,R2):将集合S中的字集合R1和子集合R2
    • Find(S,x):找到集合S中单元苏x所在的子集合
    • Initial(S):对集合S中的每一个元素先初始化为一个单元素集合

    通常用树的双亲表示作为并查集的存储结构,每个子集合以一棵树表示。所有表示子集合的树,构成表示全集合的森林。

  2. 并查集的结构定义

    #define SIZE 100
    int UFSet[SIZE];
    
  3. 并查集的操作实现(简略表示)

    //并查集的初始化
    void Initial(int S[]){
        for(int i = 0;i<size;i++){
            S[i] = -1;
        }
    }
    
    //Find操作
    int Find(int S[],int x){
        while(S[x]>=0)
            x = S[x];
        return x;
    }
    
    //Union操作
    void Union(int S[],int Root1,int Root2){
        S[Root2] = Root1;
    }
    

说明:如果想要系统地把并查集过一遍,推荐看橙色封面的那本经典的《算法导论》,里面对于并查集采取的存储方式,quick-find,quick-union操作讲述很详细。

并查集的实现在一些算法题中也会涉及,特别是题目有涉及到连通分量的求解还有等价关系的划分的时候,可以考虑使用并查集的思想。

(五)树与森林部分的算法代码展示

有关树与森林的一些算法设计,本质上和二叉树是一致的,且大部分都可以利用到递归的思想,在遍历的过程中进行一些操作。

只不过对树进行操作的时候,要根据具体的应用情景和题意的要求选择适当的存储结构。

  1. 求孩子兄弟链表表示法的树上有多少个叶子节点

    typedef struct node{
        ElemType data;
        struct node *fch,*nsib;
    }*Tree;
    int Leaves(Tree t){
        if(t==NULL){
            return 0;
        }
        if(t-<fch == NULL)
            return 1+Leaves(t->nsib);
        else
            return Leaves(t->fch)+Leaves(t->nsib);
    }
    

    理解:对于一棵树的二叉链表表示,只要一个节点的孩子节点为空,那么这个节点就是叶子节点;而一棵树的叶子节点数目,就是当前节点的左子树(孩子子树)和右子树(兄弟子树)的叶子节点之和。

  2. 求孩子兄弟链表表示法的树高

    int Height(CSTree bt){
        int hc,hs;
        if(bt === NULL)
            return 0;
        else{
            hc = Height(bt->first->child);
            hs = Height(bt->nextsibling);
            if(hc+1>hs)
                return hc+1;
            else 
                return hs;
        }
    }
    

    理解:求树高,依然是利用递归算法,对于一般的二叉树就是左右子树的高度最大值加上1,但是在树的二叉表示中,左子树的含义是孩子子树,所以其所在的层次要比兄弟子树低一层,注意这个细节即可。

    若利用非递归算法求解树的高度,可以采取队列的数据结构,利用层次遍历(每次遍历到当前层的最后一个节点,层数即加1)
    具体可以参考树系列的上一篇博文的 三.1.处
    https://blog.csdn.net/kodoshinichi/article/details/106969894


二.树与二叉树的应用


(一)二叉排序树

  1. 二叉排序树的定义

    ①二叉排序树的结构定义

    二叉排序树(BST),也称为二叉查找树,二叉排序树或者是一棵空树,或者十四一棵具有下列特性的非空二叉树

    1. 若做子树非空,则左子树上所有的节点关键字值均小于根节点的关键字值
    2. 做右子树非空,则右子树上所有节点关键字值均大于根节点的关键字值
    3. 左右子树本身也分别是一棵二叉排序树

    ②二叉排序树的特点

    1. 二叉排序树也是一个递归的数据结构,在进行算法设计的时候也可以优先考虑递归算法。
    2. 因为左子树节点值<根节点值<右子树节点值,因此对二叉树进行中序遍历即可以得到一个递增的有序序列。
  2. 二叉排序树的查找

    ①二叉排序树查找的描述:即从根节点开始,沿着某一个分支逐层向下进行比较,若二叉排序树非空,将给定值与根节点关键字比较,等于则查找成功,小于则在左子树中查找,大于则在根节点的右子树中查找。

    ②二叉排序树的查找算法实现

    //非递归实现
    BSTNode *BST_Search(BiTree T,ElemType key,BSTNode *&p){
        p = NULL;
        while(T!=NULL&&key!=T->data){
            p = T;
            if(key<T->data)
                T = T->lchild;
            else
                T = T->rchild;
        }//非递归实现,但是也是利用了递归的思想,根据比较的值关系,将根节点移向左子树或右子树
        return T;
    }
    
    //递归实现
    BSTNode *BST_Search(BiTree T,ElemType key){
        if(T == NULL)
            return NULL;
        if(T->data==key)
            return T
        else if(T->data <key)
            BST_Search(T->rchild);
        else 
            BST_Search(T->lchild);
    }
    
  3. 二叉排序树的插入
    ①二叉排序树是动态集合,所以树的构造也是在查找过程中动态建立的,当树中不存在当前查找的关键字节点时,就插入节点。二叉排序树的插入也可以采用递归思想,且插入的节点一定是叶节点。

    ②算法实现

    int BST_Insert(BiTree &T,KeyType k){
        //在二叉排序树中插入一个关键字为k的节点
        if(T == NULL){
            T = (BiTree)malloc(sizeof(BSTNode));
            T->key = k;
            T-lchild = T->rchild = NULL;
            return 1;
        }
        else if(k==T->key)
            return 0;
        else if(k<T->key)
            return BST_Insert(T->lchild,k);
        else
            return BST_Insert(T->rchild,k);
    }
    
  4. 二叉排序树的构造
    ①有了前面关于二叉排序树的插入操作的实现,不难想到,二叉排序树的构造就是从一棵空树开始依次对二叉排序树进行插入操作。

    ②算法实现

    void Create_BST(BiTree &T,KeyType str[],int n){
        //根据数组str中的关键字值构造一棵以T为根节点的排序二叉树,共有n个节点
        T = NULL;
        int i  = 0;
        while(i<n){
            BST_Insert(T,str[i]);
            i++;
        }
    }
    
  5. 二叉排序树的删除
    ①二叉排序树的删除操作较为复杂,因为需要删除之后不能使二叉链表断裂,且要保证二叉排序树的性质不会丢失。

    • 若被删除的节点只是叶节点,直接删除即可
    • 若被删除的节点只有一棵子树,则把待删除节点的子树嫁接在被删除节点的父节点下即可
    • 若被删除节点具有两棵子树,则找到待删除节点的直接后继(或者直接前驱),然后将待删除节点的位置的关键字用相应的后继(前驱)的关键字替代,再把后继(前驱)删除即可,因为直接前驱和后继肯定是叶节点,所以此时就将第三种情况转化成第一种了。
  6. 二叉排序树的查找效率分析

    ①对于高度为H的二叉排序树,插入和删除操作的运行时间复杂度均为O(H),但是当二叉树形成了一个倾斜的单支树的时候,此时H与节点个数相同(输入的序列是有序的)。

    ②查找成功的平均查找长度的求解:对每一个结点,将其查找成功所需的查找次数相加再求平均即可以得到平均查找长度。其中节点的深度就是节点查找成功所需的查找次数,默认根节点得到深度为1。

    ③二叉排序树 vs. 二分查找:假设对于一个有N个节点的表而言

    比较项 二叉排序树 二分查找
    查找 O(logN)-O(N) O(logN)
    插入、删除 O(logN) O(N)
    选择 当有序表为动态表的时候
    选用二叉排序树作为逻辑结构
    当有序表为静态表的时候
    选用顺序表作为逻辑结构

(二)平衡二叉树

  1. 平衡二叉树的定义:要么是一棵空树,要么是满足以下条件的一棵树:

    1. 左子树和右子树都是平衡二叉树
    2. 左子树和右子树的高度差的绝对值不超过1
  2. 平衡因子:节点左子树和右子树的高度差为节点的平衡因子

  3. 平衡二叉树的插入

    关于变换的本质还是没有能够理解,所以每次复习到这儿都需要再花精力去记规律。

    规律的识记可以参考以下:

    ①先记住LL和RR两种基本的变换规则:都是把中间节点移到根节点的位置(所谓中间节点,就是平衡因子绝对值超过1的节点和插入的节点之间的那个节点),然后被替代了的左子树或右子树根据大小关系移到原来根节点的子树位置上。

    ②LR和RL的目标就是把问题转化成LL或RR,核心步骤就是将被插入子节点的那个节点顺次向上提。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
4. 平衡二叉树的查找

①平衡二叉树的查找过程和二叉排序树是一致的

②根据数学归纳法可证,含有n个节点的平衡二叉树的最大深度为O(logn),这也是它的平均查找长度。

假设以Nh表示深度为h的平衡树中含有的最少节点数
N 0 = 0 , N 1 = 1 , N 2 = 2 , . . . , N h = N h 1 + N h 2 + 1 N_0=0,N_1=1,N_2=2,...,N_h=N_h -_1+N_h -_2+1
在这里插入图片描述

(三)哈夫曼树

  1. 哈夫曼树的定义

    1. 节点的权:树中节点被赋予的一个表示某种意义的数值

    2. 节点的带权路径长度:从树根节点到该节点的路径长度(经过的边数)与节点上权值的乘积
      W P L = w i l i WPL=∑w_i*l_i

    3. 哈夫曼树:在含有N个带权叶节点的二叉树中,带权路径最小的二叉树,也成为最优二叉树

  2. 哈夫曼树的构造
    对于已知的N各权值分别为w1,w2,…wn的节点,通过哈夫曼算法可以构造出最优二叉树,算法描述如下:

    1. 将这N个节点分别作为N棵仅含有一个节点的二叉树
    2. 构造一个新节点,从F中选取两棵根结点权值最小的树作为新节点的左右子树,并将新节点的权值设置为左右子树权值之和
    3. 从F中删除选出的两个节点,并将生成的新的树节点加入到F中
    4. 重复上述步骤b和c,直到F中只剩下一棵树为止
  3. 哈夫曼编码

    1. 哈夫曼编码的综述:哈夫曼编码属于一种可变长度的编码,可变长度编码就是对出现频率较高的字符使用短编码,对出现频率比较低的频率可以适当使用长编码。
    2. 哈夫曼编码适用于前缀编码,所谓前缀编码就是没有一个编码是另一个编码的前缀。
    3. 哈夫曼树与哈夫曼编码:将每个出现的字符当做一个独立的节点权值就是它出现的频率,按照上述构造过程就可以得到一棵哈夫曼树。构造完毕的时候,所有字符节点均出现在叶结点上。将字符的编码解释为从根到该字符的路径上边标记的序列,边标记为0表示转向左孩子,标记为1表示转向右孩子。

三.参考文档

《王道2019年数据结构考研复习指导》

猜你喜欢

转载自blog.csdn.net/kodoshinichi/article/details/106993140