【大话数据结构】第六章-树

六、树

1.树的定义

1.1 定义

树(Tree)是 n(n≥0)个结点的有限集。n=0时称为空树。在任意一棵非空树中:(1) 有且仅有一个特定的称为根(Root)的结点;(2) 当n>1时,其余结点可分为m(m>0)个互不相交的有限集T1、T2、······、Tm,其中每一个集合本身又是一棵树,并且称为根的子树(SubTree),如图。

在这里插入图片描述

1.2 结点分类

树的结点包含一个数据元素及若干指向其子树的分支。结点拥有的子树数称为结点的度(Degree)。度为0的结点称为叶结点(Leaf)或终端结点;度不为0的结点称为非终端结点或分支结点。除根结点之外,分支结点也称为内部结点。树的度是树内各结点的度的最大值。如图,因为这棵树结点的度的最大值是结点D的度,为3,所以树的度也为3。

在这里插入图片描述

1.3 结点间关系

结点的子树的根称为该结点的孩子(Child),相应地,该结点称为孩子的双亲(对于结点来说其父母同体,唯一的一个,所以只能把它称为双亲)。同一个双亲的孩子之间互称兄弟(Sibling)。结点的祖先是从根到该结点所经分支上的所有结点。所以对于H来说,D、B、A都是它的祖先。反之,以某结点为根的子树中的任一结点都称为该结点的子孙。B的子孙有D、G、H、I,如图。

在这里插入图片描述

1.4 树的其他相关概念

结点的层次(Level)从根开始定义起,根为第一层,根的孩子为第二层。若某结点在第L层,则其子树的根就在第L+1层。其双亲在同一层的结点互为堂兄弟。显然下图中的D、E、F是堂兄弟,而G、H、I与J也是堂兄弟。树中结点的最大层次称为树的深度(Depth)或高度,下图的树的深度为4。

在这里插入图片描述

如果将树中结点的各子树看成从左至右是有次序的,不能互换的,则称该树为有序树,否则称为无序树

==森林(Forest)是m(m≥0)棵互不相交的树的集合。==对树中每个结点而言,其子树的集合即为森林。

1.5 线性表和树的结构的对比

线性结构:

  • 第一个数据元素:无前驱
  • 最后一个数据元素:无后继
  • 中间元素:一个前驱一个后继

树结构:

  • 根结点:无双亲,唯一
  • 叶结点:无孩子,可以多个
  • 中间结点:一个双亲多个孩子

2.树的抽象数据类型

ADT 树(tree)
Data
    树是由一个根结点和若干棵子树构成。树中结点具有相同数据类型及层次关系。
Operation
    InitTree(*T): 构造空树T
    DestroyTree(*T): 销毁树T
    CreateTree(*T, definition): 按definition中给出树的定义来构造树
    ClearTree(*T): 若树T存在,则将树T清为空树
    TreeEmpty(T): 若T为空树,返回true,否则返回false
    TreeDepth(T): 返回T的深度
    Root(T): 返回T的根结点
    Value(T, cur_e): cur_e是树T中一个结点,返回此结点的值
    Assign(T, cur_e, value): 给树T的结点cur_e赋值为value
    Parent(T, cur_e): 若cur_e是树T的非根结点,则返回它的双亲,否则返回空
    LeftChild(T, cur_e): 若cur_e是树T的非叶结点,则返回它的最左孩子,否则返回空
    RightSibling(T, cur_e): 若cur_e有右兄弟,则返回它的右兄弟,否则返回空
    InsertChild(*T, *p, i, c): 其中p指向树T的某个结点,i为所指结点p的度加上1,非空树c与T不相交,操作结果为插入c为树T中p指结点的第i棵子树
    DeleteChild(*T, *p, i): 其中p指向树T的某个结点,i为所指结点p的度,操作结果为删除T中p所指结点的第i棵子树
endADT

3.树的存储结构

树中某个结点的孩子可以有多个,这就意味着,无论按何种顺序将树中所有结点存储到数组中,结点的存储位置都无法直接反映逻辑关系。所以,简单的顺序存储结构是不能满足树的实现要求的。

不过充分利用顺序存储和链式存储结构的特点,完全可以实现对树的存储结构的表示。这里介绍三种不同的表示法:双亲表示法、孩子表示法、孩子兄弟表示法。

3.1 双亲表示法

人可能因为某些原因没有孩子,但一定有父母。树这种数据结构也一样,除了根结点外,其余每个结点,不一定有孩子,但是一定有且仅有一个双亲。

我们假设以一组连续空间存储树的结点,同时在每个结点中,附设一个指示器指示双亲结点在数组中的位置。也就是说,每个结点除了知道自己是谁以外,还知道它的双亲在哪里。结点结构如下图。
在这里插入图片描述

其中data是数据域,存储结点的数据信息。Parent是指针域,存储该结点的双亲在数组中的下标。

以下是双亲表示法的结点结构定义代码。

/* 树的双亲表示法结点结构定义 */
#define MAX_TREE_SIZE 100
typedef int TElemType;            // 树结点的数据类型,这是假定为int
typedef struct PTNode {
    
               // 结点结构
    TElemType data;               // 结点数据
    int parent;                   // 双亲位置
} PTNode;
typedef struct {
    
                      // 树结构
    PTNode nodes[MAX_TREE_SIZE];  // 结点数组
    int r,n;                      // 根的位置和结点数
} PTree;

由于根结点是没有双亲的,所以我们约定根结点的位置域设置为-1,由此所有的结点都存有它双亲的位置。如图。
在这里插入图片描述

在这里插入图片描述

这样的存储结构,我们可以根据结点的parent指针很快找到它的双亲结点,时间复杂度为O(1)。但如果要找结点的孩子,需要遍历整个结构。

我们可以改进一下,增加一个结点最左边孩子的域,不妨叫它长子域,如果没有孩子结点,这个长子域就设置为-1.
在这里插入图片描述

对于有0个或1个孩子结点来说,这样的结构是解决了要找结点孩子的问题了。甚至是有2个孩子,知道了长子是谁,另一个当然就是次子了。

另外一个问题场景,我们很关注各兄弟之间的关系,双亲表示法无法体现这样的关系。因此,我们可以增加一个右兄弟域来体现兄弟关系,也就是说,每一个结点如果它存在右兄弟,则记录下右兄弟的下标。同样的,如果右兄弟不存在,则赋值为-1。

在这里插入图片描述

但如结点的孩子很多,超过2个。我们又关注结点的双亲、又关注结点的孩子、还关注结点的兄弟,而且对时间遍历要求还比较高,那么我们还可以把次结构扩展为有双亲域、长子域、再有右兄弟域。存储结构的设计是一个非常灵活的过程。一个存储结构设计得是否合理,取决于基于该存储结构的运算是否合适、是否方便,时间复杂度好不好等。注意也不是越多越好,有需要时再设计相应的结构。

3.2 孩子表示法

孩子表示法:把每个结点的孩子结点排列起来,以单链表作存储结构,则n个结点有n个孩子链表,如果是叶子结点则此单链表为空。然后n个头指针又组成一个线性表,采用顺序存储结构,存放进一个一维数组中。如图。
在这里插入图片描述

在这里插入图片描述

为此,设计两种结点结构,一个是孩子链表的孩子结点。

在这里插入图片描述

其中child是数据域,用来存储某个结点在表头数组中的下标。next是指针域,用来存储指向某结点的下一个孩子结点的指针。

另一个是表头数组的表头结点。

在这里插入图片描述

其中data是数据域,存储某结点的数据信息。firstChild是头指针域,存储该结点的孩子链表的头指针。

以下是孩子表示法的结构定义代码。

/* 树的孩子表示法结构定义 */
#define MAX_TREE_SIZE 100
typedef int TElemType;
typedef struct CTNode {
    
                // 孩子结点
    int child;
    struct CTNode *next;
} *ChildPtr;
typedef struct {
    
                       // 表头结构
    TlemType data;
    ChildPtr firstchild;
} CTBox;
typedef struct {
    
                       // 树结构
    CTBox nodes[MAX_TREE_SIZE];    // 结点数组
    int r,n;                       // 根的位置和结点数
} CTree;

这样的结构对于我们要查询某个结点的某个孩子,或者找某个结点的兄弟,只需要查找这个结点的孩子单链表即可。对于遍历整棵树也是很方便的,对头结点的数组循环即可。

但是,如果想知道某个结点的双亲是谁,就需要遍历整棵树,这里我们可以改进一下,综合双亲表示法和孩子表示法,如图。

在这里插入图片描述

我们把这种方法称为双亲孩子表示法。

3.3 孩子兄弟表示法

孩子兄弟表示法:任意一棵树,它的结点的第一个孩子如果存在就是唯一的,它的右兄弟如果存在也是唯一的。因此,我们设置两个指针,分别指向该结点的第一个孩子和此结点的右兄弟

结点结构如图:

在这里插入图片描述

其中data是数据域,firstchild为指针域,存储该结点的第一个孩子结点的存储地址,rightsib是指针域,存储该结点的右兄弟结点的存储地址。

结点定义代码如下:

/* 树的孩子兄弟表示法结构定义 */
typedef struct CSNode {
    
    
    TElemType data;
    struct CSNode *firstchild, *rightsib;
} CSNode, *CSTree;

这种方法实现如下图:

在这里插入图片描述

在这里插入图片描述

这种表示法,给查找某个结点的某个孩子带来了方便,只需要通过firstchild找到此结点的长子,然后再通过长子结点的rightsib找到它的二弟,接着一直下去,直到找到具体的孩子。

但如果想找某个结点的双亲,这个表示法也是有缺陷的。不过我们可以增加一个parent指针域来解决快速查找双亲的问题,和之前一样,这里不再细讲。

其实这个表示法最大的好处是把一棵复杂的树变成了一棵二叉树。上图可以变形成下图所示。

在这里插入图片描述

这样就可以充分利用二叉树的特性和算法来处理这棵树了。下面来谈谈二叉树。

4.二叉树的定义

4.1 定义

二叉树 (Binary Tree) 是 n (n≥0) 个结点的有限集合,该集合或者为空集(称为空二叉树),或者由一个根结点和两棵互不相交的、分别称为根结点的左子树和右子树的二叉树组成。

在这里插入图片描述

4.2 二叉树的特点

二叉树的特点有:

  • 每个结点最多有两棵子树,所以二叉树中不存在度大于2的结点。
  • 左子树和右子树是有顺序的,次序不能任意颠倒。
  • 即使树中某结点只有一棵子树,也要区分它是左子树还是右子树。

二叉树具有五种基本形态:

  1. 空二叉树
  2. 只有一个根结点
  3. 根结点只有左子树
  4. 根结点只有右子树
  5. 根结点既有左子树又有右子树

区分:如果是有三个结点的树,有几种形态?如果是有三个结点的二叉树。又有几种形态?

若只从形态上考虑,三个结点的树只有两种情况,如下图,分别是有两层的树1和有三层的后四种的任意一种,但对于二叉树来说,由于要区分左右,所以就演变成五种形态,树2、树3、树4和树5分别代表不同的二叉树。

在这里插入图片描述

4.3 特殊二叉树

4.3.1 斜树

所有的结点都只有左子树的二叉树叫左斜树。所有结点都是只有右子树的二叉树叫右斜树。这两者统称为斜树。

上图的树2就是左斜树,图5就是右斜树。

4.3.2 满二叉树

在一棵二叉树中,如果所有分支结点都存在左子树和右子树,并且所有叶子都在同一层上,这样的二叉树称为满二叉树。

在这里插入图片描述

满二叉树的特点有:

  • 叶子只能出现在最下一层。出现在其他层就不可能达成平衡。
  • 非叶子结点的度一定为2。
  • 在同样深度的二叉树中,满二叉树的结点个数最多,叶子数最多。

4.3.3 完全二叉树

对一棵具有n个结点的二叉树按层序编号,如果编号为i(1≤i≤n)的结点与同样深度的满二叉树中编号为i的结点在二叉树中位置相同,则这棵二叉树称为完全二叉树,如图。

在这里插入图片描述

完全二叉树有以下几个特点:

  1. 叶子结点只能出现在最下两层。

  2. 最下层的叶子一定集中在左部连续位置。

  3. 倒数第二层,若有子节结点,一定都在右部连续位置

  4. 如果结点度为1,则该结点只有左孩子,即不存在只有右子树的情况。

  5. 同样结点数的二叉树,完全二叉树的深度最小。

如下图三种树就不是完全二叉树(注意浅色结点表示不存在),因为它们编号间出现了空档。

在这里插入图片描述

5.二叉树的性质

5.1 二叉树性质1

性质1:在二叉树的第i层上至多有2i-1个结点(i≥1)。

在这里插入图片描述

从上图很容易看出这一性质。

5.2 二叉树性质2

性质2:深度为k的二叉树至多有2k-1个结点(k≥1)。

等比数列求和:1+2+22+23+······+2k-1=2k-1

5.3 二叉树性质3

性质3:对任何一棵二叉树T,如果其终端结点数为n0,度为2的结点数为n2,则n0=n2+1。

终端结点数其实就是叶子结点数,而一棵二叉树,除了叶子结点外,剩下的就是度为1或2的结点数了,我们设n1为度是1的结点数。则数T结点总数n=n0+n1+n2。如图。

在这里插入图片描述

再换个角度,数一数它的连接线数,由于根结点只有分支出去,没有分支进入,所以分支线总数为结点总数减1。上图就是9个分支,10个结点。对于A、B、C、D结点来说,它们都有两个分支线出去,而E结点只有一个分支线出去。所以总分支线为4×2+1×1=9。

用代数表达就是分支线总数=n-1=n1+2n2。联立上面的n=n0+n1+n2,可以推导出n0=n2+1。

5.4 二叉树性质4

性质4:具有n个结点的完全二叉树的深度为[log2n]+1([x]表示不大于x的最大整数)。

对于完全二叉树,它的结点数一定小于等于同样深度的满二叉树的结点数2k-1,但一定多于比它少一深度的满二叉树的结点数2k-1-1。即满足2k-1-1<n≤2k-1。由于结点数n是整数,得2k-1≤n<2k,取对数得k-1≤log2n<k,而k作为深度也是整数,因此k=[log2n]+1。

5.5 二叉树性质5

性质5:如果对一棵有n个结点的完全二叉树(其深度为[log2n]+1)的结点按层序编号(从第1层到第[log2n]+1层,每层从左到右),对任一结点i(1≤i≤n)有:

  1. 如果i=1,则结点i是二叉树的根,无双亲;如果i>1,则其双亲是结点[i/2]。
  2. 如果2i>n,则结点i无左孩子(结点i为叶子结点);否则其左孩子是结点2i。
  3. 如果2i+1>n,则结点i无右孩子;否则其右孩子是结点2i+1。

结合下图进行理解:

在这里插入图片描述

6.二叉树的存储结构

6.1 二叉树顺序存储结构

先来看看完全二叉树的顺序存储。

在这里插入图片描述

将这棵二叉树存入数组中,相应的下标对应其同样的位置。

在这里插入图片描述

由于它定义的严格,用顺序结构也可以表现出二叉树的结构来。

而对于一般的二叉树,我们可以将其按完全二叉树编号,把不存在的结点设置为"^"。如图(浅色结点表示不存在)。

在这里插入图片描述

另外考虑一种极端情况,一棵深度为k的右斜树,它只有k个结点,却需要分配2k-1个存储单元空间,这显然是对存储空间的浪费。如下图,所以,顺序存储结构一般只用于完全二叉树。

在这里插入图片描述

6.2 二叉链表

二叉树每个结点最多有两个孩子,所以为它设计一个数据域和两个指针域,我们称这样的链表叫做二叉链表。结点结构图如表所示。

在这里插入图片描述

二叉链表的结点结构定义代码:

typedef struct BiTNode {
    
          // 结点结构
    TElemType data;           // 结点数据
    struct BiTNode *lchild, *rchild;     // 左右孩子指针
} BiTNode, *BiTree;

结构示意图:

在这里插入图片描述

7.遍历二叉树

7.1 二叉树遍历原理

二叉树的遍历 (traversing binary tree) 是指从根结点出发,按照某种次序依次访问二叉树中所有结点,使得每个结点被访问一次且仅被访问一次。

7.2 二叉树遍历方法

二叉树的遍历方式有很多,如果我们限制了从左到右的习惯方式,那么主要分为四种:

7.2.1 前序遍历

规则是若二叉树为空,则空操作返回,否则先访问根结点,然后前序遍历左子树,再前序遍历右子树。

(根在前,从左往右,一棵树的根永远在左子树前面,左子树永远在右子树前面 )

在这里插入图片描述

7.2.2 中序遍历

规则是若树为空,则空操作返回,否则中序遍历根结点的左子树,然后是访问根结点,最后中序遍历右子树。

(根在中,从左往右,一棵树的左子树永远在根前面,根永远在右子树前面)

在这里插入图片描述

7.2.3 后序遍历

规则是若树为空,则空操作返回,否则从左到右先叶子后结点的方式遍历访问左右子树,最后是访问根结点。

(根在后,从左往右,一棵树的左子树永远在右子树前面,右子树永远在根前面)

在这里插入图片描述

7.2.4 层序遍历

规则是若树为空,则空操作返回,否则从树的第一层,也就是根结点开始访问,从上而下逐层遍历,在同一层中,按从左到右的顺序对结点逐个访问。

在这里插入图片描述

7.3 前序遍历算法

二叉树的定义是用递归的方式,所以,实现遍历算法也可以采用递归,而且极其简洁明了。代码如下:

void PreOrderTraverse(BiTree T) {
    
    
    if (T==NULL)
        return;
    printf("%c", T->data);
    PreOrderTraverse(T->lchild);
    PreOrderTraverse(T->rchild);
}

7.4 中序遍历算法

void PreOrderTraverse(BiTree T) {
    
    
    if (T==NULL)
        return;
    PreOrderTraverse(T->lchild);
    printf("%c", T->data);
    PreOrderTraverse(T->rchild);
}

7.5 后序遍历算法

void PreOrderTraverse(BiTree T) {
    
    
    if (T==NULL)
        return;
    PreOrderTraverse(T->lchild);
    PreOrderTraverse(T->rchild);
    printf("%c", T->data);
}

8.二叉树的建立

如果我们要在内存中建立一棵树,为了能让每个结点确认是否有左右孩子,我们对它进行扩展,如下图,将二叉树中每个结点的空指针引出一个虚结点,其值为一特定值,比如"#"。我们称这种处理后的二叉树为原二叉树的扩展二叉树。扩展二叉树就可以做到一个遍历序列确定一棵二叉树了。

在这里插入图片描述

假设二叉树的结点均为一个字符,我们把刚才前序遍历序列AB#D##C##用键盘挨个输入。实现算法如下:

/* 按前序输入二叉树中结点的值(一个字符) */
/* #表示空树,构造二叉链表表示二叉树T */
void CreateBiTree(BiTree *T) {
    
    
    TElemType ch;
    scanf("%c", &ch);
    if (ch == '#')
        *T = NULL;
    else {
    
    
        *T = (BiTree)malloc(sizeof(BiTree));
        if (!*T)
            exit(OVERFLOW);
        (*T)->data = ch;
        CreateBiTree(&(*T)->lchild);
        CreateBiTree(&(*T)->rchild);
    }
}

9.线索二叉树

9.1 线索二叉树原理

在这里插入图片描述

对于如图所示的二叉树,指针域没有得到充分的利用,有许多"^",我们要想办法把它们利用起来。

在二叉链表上,我们只知道每个结点指向其左右孩子结点的地址,而不知道某个结点的前驱是谁,后继是谁。要想知道,必须遍历一次。所以我们可以考虑利用那些空地址存放指向结点在某种遍历次序下的前驱和后继结点的地址。我们把这种指向前驱和后继的指针称为线索,加上线索的二叉链表称为线索链表,相应的二叉树就称为线索二叉树(Threadeed Binary Tree)

如下图,我们把这棵二叉树进行中序遍历后,将所有的空指针域中的rchild,改为指向它的后继结点。
在这里插入图片描述

再看下图,我们将这棵二叉树的所有空指针域中的lchild,改为指向当前结点的前驱。

在这里插入图片描述

由此得出下图(实线空心箭头为前驱,虚线黑箭头为后继),就更容易看出,其实线索二叉树,等于是把一棵二叉树转变成了双向链表,这样对我们的插入删除结点、查找某个结点都带来了方便。所以我们对二叉树以某种次序遍历使其变为线索二叉树的过程称作是线索化

在这里插入图片描述

为了区分某个结点的lchild/rchild是指向它的左/右孩子还是指向前驱/后继,我们在每个结点再增设两个标志域ltag和rtag,主要它们只存放0或1数字的布尔型变量,其占的内存空间要小于像lchild和rchild的指针变量。

结点结构图如下表:

在这里插入图片描述

其中:

  • ltag为0时指向该结点的左孩子,为1时指向该结点的前驱。
  • rtag为0时指向该结点的右孩子,为1时指向该结点的后继。

因此之前的二叉链表图可以改为下图:

在这里插入图片描述

9.2 线索二叉树结构实现

二叉树的线索存储结构定义代码如下:

typedef enum {
    
    Link, Thread} PointerTag;  // Link==0表示指向左右孩子指针,Thread==1表示指向前驱或后继的线索
typedef struct BiThrNode {
    
                   // 二叉线索存储结点结构
	TElemType data;                      // 结点数据
    struct BiThrNode *lchild, *rchild;   // 左右孩子指针
    PointerTag LTag;
    PointerTag RTag;                     // 左右标志
} BiThrNode, *BiThrTree;

线索化的实质就是将二叉链表中的空指针改为指向前驱或后继的线索。由于前驱和后继的信息只有在遍历该二叉树时才能得到,所以线索化的过程就是在遍历的过程中修改空指针的过程

中序遍历线索化的递归函数代码如下:

BiThrTree pre;           // 全局变量,始终指向刚刚访问过的结点

void InThreading(BiThrTree p) {
    
    
    if (p) {
    
    
        InThreading(p->lchild);  // 递归左子树线索化
        if (!p->lchild) {
    
            // 没有左孩子
            p->LTag = Thread;    // 前驱线索
            p->lchild = pre;     // 左孩子指针指向前驱
        }
        if (!pre->rchild) {
    
          // 前驱没有右孩子
            pre->RTag = Thread;  // 后继线索
            pre->rchild = p;     // 前驱右孩子指针指向后继(当前结点p)
        }
        pre = p;                 // 保持pre指向p的前驱
        InThreading(p->rchild);  // 递归右子树线索化
    }
}

我们发现,除了中间代码(两个递归之间的代码)之外,和二叉树中序遍历的递归代码几乎完全一样。只不过将本是打印结点的功能改成了线索化的功能。

下面来分析一下中间代码。

if(!p->lchild)表示如果某结点的做指针域为空,因为其前驱结点刚刚访问过,且赋值给了pre,所以可以将pre赋值给p->lchild,并修改p->LTag=Thread(也就是定义为1)以完成前驱结点的线索化。

后继就要稍微麻烦一些。因为此时p结点的后继还没有访问到,因此只能对它的前驱结点pre的右指针rchild做判断,if(!pre->rchild)表示如果为空,则p就是pre的后继,于是pre->rchild=p,并且设置pre->RTag=Thread,完成后继结点的线索化。

完成前驱和后继的判断后,别忘记将当前的结点p赋值给pre,以便于下一次使用。

有了线索二叉树后,我们对它进行遍历时发现,其实就等于是操作一个双向链表结构。

和双向链表结构一样,在二叉树线索链表上添加一个头结点,如下图,头结点的前驱和后继添加如下①②③④关系。这样的好处就是我们既可以从第一个结点起顺着后继进行遍历,也可以从最后一个结点起顺着前驱进行遍历。

在这里插入图片描述

遍历代码如下:

/* T指向头结点,头结点左链lchild指向根结点,头结点右链rchild指向中序遍历的最后一个结点。*/
Status InOrderTraverse_Thr(BiThrTree T) {
    
    
    BiThrTree p;
    p = T->lchild;              // p指向根结点
    while (p != T) {
    
                // 空树或遍历结束时,p==T
        while (p->LTag==Link)   // 当LTag==0时循环到中序序列第一个结点
            p = p->lchild;
        printf("%c", p->data);  // 显示结点数据
        while (p->RTag==Thread && p->rchild!=T) {
    
    
            p = p->rchild;
            printf("%c", p->data);
        }
        p = p->rchild;          // p进至其右子树根
    }
    return OK;
}

时间复杂度O(n)

由于它充分利用了空指针域的空间(这等于节省了空间),又保证了创建时的一次遍历就可以终生受用前驱后继信息(这意味着节省了时间)。所以在实际问题中,如果所用的二叉树需经常遍历或查找结点时需要某种遍历序列中的前驱和后继,那么采用线索二叉链表的存储结构就是非常不错的选择

10.树、森林与二叉树的转换

10.1 树转为二叉树

将树转换为二叉树的步骤如下:

  1. 加线。在所有兄弟结点之间加一条连线。
  2. 去线。对树中每个结点,只保留它与第一个孩子结点的连线,删除它与其他孩子结点之间的连线。
  3. 层次调整。以树的根结点为轴心,将整棵树顺时针旋转一定的角度,使其结构层次分明。注意第一个孩子是二叉树结点的左孩子,兄弟转过来的孩子是结点的右孩子。

在这里插入图片描述

10.2 森林转为二叉树

森林是由若干棵树组成的,所以完全可以理解为,森林中的每一棵树都是兄弟,可以按照兄弟的处理办法来操作。步骤如下:

  1. 把每个树转换为二叉树。
  2. 第一棵二叉树不动,从第二棵二叉树开始,依次把后一棵二叉树的根结点作为前一棵二叉树的根结点的右孩子,用线连接起来。当所有的二叉树连接起来后就得到了由森林转换来的二叉树。

在这里插入图片描述

10.3 二叉树转为树

二叉树转换为树是树转换为二叉树的逆过程,步骤如下:

  1. 加线。若某结点的左孩子结点存在,则将这个左孩子的右孩子结点、右孩子的右孩子结点、右孩子的右孩子的右孩子结点······作为此结点的孩子。将该结点与这些右孩子结点用线连接起来。
  2. 去线。删除原二叉树中所有结点与其右孩子的连线。
  3. 层次调整。使之结构层次分明。

在这里插入图片描述

10.4 二叉树转为森林

判断一棵二叉树能够转换成一棵树还是森林,只要看这棵二叉树的根结点有没有右孩子,有就是森林,没有就是一棵树。步骤如下:

  1. 从根结点开始,若右孩子存在,则把与右孩子结点的连线删除,再查看分离后的二叉树,若右孩子存在,则连线删除······,直到所有右孩子连线都删除为止,得到分离的二叉树。
  2. 再将每棵分离后的二叉树转换为树即可。

在这里插入图片描述

10.5 树与森林的遍历

树的遍历分为两种:

  1. 一种是先根遍历树,即先访问树的根结点,然后依次先根遍历根的每棵树。
  2. 另一种是后根遍历,即先依次后根遍历每棵子树,然后再访问根结点。

如下图这棵树,它的先根遍历序列为ABEFCDG,后根遍历序列为EFBCGDA。

在这里插入图片描述

森林的遍历也分为两种:

  1. 前序遍历:先访问森林中第一棵树的根结点,然后再依次先根遍历根的每棵子树,再依次用同样的方式遍历除去第一棵树的剩余树构成的森林。如下图步骤2中的三棵树,前序遍历序列结果为ABCDEFGHJI。

在这里插入图片描述

  1. 后序遍历:先访问森林中第一棵树,后根遍历的方式遍历每棵子树,然后再访问根结点,再依次用同样方式遍历除去第一棵树的剩余树构成的森林。如上图步骤2中的三棵树,后序遍历序列结果为BCDAFEJHIG。

可如果我们对上图的二叉树进行分析会发现,森林的前序遍历和二叉树的前序遍历结果相同,森林的后序遍历和二叉树的中序遍历结果相同。

由此可知,当以二叉链表作为树的存储结构时,树的先根遍历和后根遍历完全可以借用二叉树的前序遍历和中序遍历的算法来实现。

11.赫夫曼树及其应用

11.1 赫夫曼树的定义

首先给出两棵叶子结点带权的二叉树(注:树结点间的边相关的数叫做权 Weight),如下图。

在这里插入图片描述

从树中一个结点到另一个结点之间的分支构成两个结点之间的路径,路径上的分支数目称做路径长度

二叉树a中,根结点到结点D的路径长度为4,二叉树b中,根结点D的路径长度为2.

树的路径长度就是从树根到每一结点的路径长度之和

二叉树a的树路径长度为1+1+2+2+3+3+4+4=20。二叉树b的树路径为1+2+3+3+2+1+2+2=16。

带权路径长度WPL最小的二叉树称做赫夫曼树

二叉树a的WPL=5×1+15×2+40×3+30×4+10×4=315。

二叉树b的WPL=5×3+15×3+40×2+30×2+10×2=220。

11.2 赫夫曼树的构造

  1. 先把有权值的叶子结点按照从小到大的顺序排列成一个有序序列,即:A5,E10,B15,D30,C40。

  2. 取头两个最小权值的结点作为一个新结点N1的两个子结点,注意相对较小的是左孩子,这里就是A为N1的左孩子,E为N1的右孩子,如下图。新结点的权值为两个叶子权值的和5+10=15。

在这里插入图片描述

  1. 将N1替换A与E,插入有序序列中,保持从小到大排列。即:N115,B15,D30,C40。

  2. 重复步骤2。将N1与B作为一个新结点N2的两个子节点。如下图。N2的权值=15+15=30。

在这里插入图片描述

  1. 将N2替换N1与B,插入有序序列中,保持从小到大排序。即:N230,D30,C40。

  2. 重复步骤2。将N2与D作为一个新结点N3的两个子结点。如下图。N3的权值=30+30=60。

在这里插入图片描述

  1. 将N3替换N2与D,插入有序序列中,保持从小到大排序。即:C40,N360。

  2. 重复步骤2。将C与N3作为一个新结点T的两个子结点,如下图。由于T即是根结点,完成赫夫曼树的构造。

在这里插入图片描述

11.3 赫夫曼编码

如果我们有一段文字内容“BADCADFEED”要网络传输给别人,显然用二进制的数字(0和1)来表示是很自然的想法。这段文字里的6个字母可以用相应的二进制数据表示,如下图。

在这里插入图片描述

传输编码后“001000011010000011101110100011”,对方接收时就按照3位一分来译码。但如果文章特别长,这样的二进制串也是非常可怕的。而事实上,字母或汉字出现频率是不相同的,所以我们采用赫夫曼树的方法。

假设六个字母的频率为A27,B8,C15,D15,E30,F5,合起来正好是100%,因此我们可以重新按照赫夫曼树来规划它们。

下左图为构造赫夫曼树的过程的权值显示。右图为将权值左分支改为0,右分支改为1后的赫夫曼树。

在这里插入图片描述

此时,我们对这六个字母用其从树根到叶子所经过路径的0或1来编码,可以得到如下表这样的定义。

在这里插入图片描述

我们将文字内容为“BADCADFEED”再次编码,对比可以看到结果串变小了。

  • 原编码二进制串:001000011010000011101110100011(共30个字符)
  • 新编码二进制串:1001010010101001000111100 (共25个字符)

可以看出,数据被压缩了,节约了大约17%的存储或运输成本。随着字符的增加和多字符权重的不同,这种压缩会更加显出其优势。

关于解码,编码中非0即1,长短不等的话其实是很容易混淆的,所以若要设计长短不等的编码,则必须是任一字符的编码都不是另一个字符的编码的前缀,这种编码称做前缀编码

但仅仅这样不足以让我们去方便地解码,因此在解码时,还要用到赫夫曼树,即发送方和接收方必须要约定好同样的赫夫曼编码规则。

一般地,设需要编码的字符集为{ d1,d2,···,dn },各个字符在电文中出现的次数或频率集合为{ w1,w2,···,wn },以d1,d2,···,dn作为叶子结点,以w1,w2,···,wn作为相应叶子结点的权值来构造一棵赫夫曼树。规定赫夫曼树的左分支代表0,右分支代表1,则从根结点到叶子结点所经过的路径分支组成的0和1的序列便为该结点对应字符的编码,这就是赫夫曼编码。

猜你喜欢

转载自blog.csdn.net/m0_50833438/article/details/113630427