数据结构-6.树

6 树

6.1什么是树?

​ 我们在第一章“诸论”部分谈到,数据结构的其中一种重要的结构就是树,一种一对多的结构,而我们在第四章“栈与队列”中也提及,在“中缀转后缀”中,我们运用了树的中序和后序遍历,当然我们并没有深入展开,到本章节,我们将进一步地、系统地去介绍树,以及相关操作。

树(Tree )是n (n≥0)个结点的有限集。n=0时称为空树。在任意一棵非空树中:

(1)有且仅有一个特定的称为根(Root)的结点;

(2)当n>1 时,其余结点可分为m ( m>0)个互不相交的有限集T1、T2、……、Tm,其中每一个集合本身又是一棵树,并且称为根的子树( SubTree ),如图所示。

在这里插入图片描述

​ 树的定义其实就是我们在讲解栈时提到的递归的方法。也就是在树的定义之中还用到了树的概念,这是一种比较新的定义方法。图6-2-2的子树T1和子树T2就是根结点A的子树。当然,D、G、H、Ⅰ组成的树又是B为结点的子树,E、」组成的树是C为结点的子树。

在这里插入图片描述

对于树的定义还需要强调两点:

  1. n>0 时根结点是唯一的,不可能存在多个根结点,别和现实中的大树混在一起,现实中的树有很多根须,那是真实的树,数据结构中的树是只能有一个根结点。
  2. m>0时,子树的个数没有限制,但它们一定是互不相交的。像图6-2-3中的两个结构就不符合树的定义,因为它们都有相交的子树。在这里插入图片描述

6.2树的相关概念

6.2.1结点分类

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

在这里插入图片描述

6.2.2结点间关系

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

在这里插入图片描述

6.2.3树的其他相关概念

树的层次

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

在这里插入图片描述

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

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

在这里插入图片描述

​ 对比线性表和树的结构,它们有很大的不同:
在这里插入图片描述

6.3树的存储结构

​ 提到存储结构,在我们之前的几章,我们都会分为顺序存储和链式存储这两种存储结构。

​ 对于顺序存储,用一段连续的存储单元一次存取线性表中的数据,其实就是数组。这对于线性表来说是很自然也是很方便的一种存储模式,但是对于树这种一对多的结构呢?

​ 树的逻辑关系很简单,谁是根结点,谁是双亲,谁是孩子,某个结点的孩子可以有很多个,因此,无论以何种顺序存储在数组中,都无法直观地反应其逻辑关系。所以,简单的顺序存储无法满足树的要求。

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

6.3.1双亲表示法

​ 对于树而言,除了根结点外,其余的每一个结点不一定有孩子,但是一定有双亲。

​ 我们假设以一组连续空间存储树的结点,同时在每个结点中,**附设一个指示器指示其双亲结点到链表中的位置。**也就是说,每个结点除了知道自己是谁以外,还知道它的双亲在哪里。它的结点结构如下。

在这里插入图片描述

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

​ 我们给出结构代码以及相关操作。

#include <iostream>
using namespace std;

#define maxsize 100

//节点结构
typedef struct PTNode
{
    int data;//数据域
    int parent;//指针域    
}PTNode;

//树结构
typedef struct{
    PTNode nodes[maxsize];//结点数组
    int r,n;//r为根的位置,n为结点数
}PTree;

//创建树
void createPTree(PTree &T)
{
    int i;
    cout<<"请输入树的节点个数: ";
    cin>>T.n;
    cout<<"请输入树的根节点: ";
    cin>>T.r;
    //输入结点值
    for(i=0;i<T.n;i++)
    {
        cout<<"请输入第"<<i+1<<"个节点的值: ";
        cin>>T.nodes[i].data;
        T.nodes[i].parent = -1;
    }
    //输入结点的父节点下标
    for(i=0;i<T.n;i++)
    {
        cout<<"请输入第"<<i+1<<"个节点的父节点: ";
        cin>>T.nodes[i].parent;
    }
}

//遍历树
void traversePTree(PTree &T)
{
    int i;
    for(int i = 0;i<T.n;i++)
    {
        cout<<T.nodes[i].data<<" ";
    }
    cout<<endl;
}

​ 有了这样的结构定义,我们就可以来实现双亲表示法了。由于根结点是没有双亲的,所以我们约定根结点的位置域设置为-1,这也就意味着,我们所有的结点都存有它双亲的位置。如图6-4-1中的树结构和表6-4-2中的树双亲表示所示。

在这里插入图片描述

​ 表 6-4-2

下标dataparent
0A-1
1B0
2C0
3D1
4E2
5F2
6G3
7H3
8I3
9J4

​ 对于上述的表示方法,这种用数组来表示指针的结构,显然是一个静态链表,因此,可以按照静态链表的思路去理解。

​ 这样的存储结构,我们可以根据结点的parent指针很容易找到它的双亲结点,所用的时间复杂度为O(1),直到parent为-1时,表示找到了树结点的根。可如果我们要知道结点的孩子是什么,对不起,请遍历整个结构才行。

​ 当然可以。我们增加一个结点最左边孩子的域,不妨叫它长子域,这样就可以很容易得到结点的孩子。如果没有孩子的结点,这个长子域就设置为一1,如表6-4-3所示。

​ 表6-4-3

下标dataparentfirstchild
0A-1-1
1B03
2C04
3D16
4E29
5F2-1
6G3-1
7H3-1
8I3-1
9J4-1

​ 对于有О个或1个孩子结点来说,这样的结构是解决了要找结点孩子的问题了甚至是有2个孩子,知道了长子是谁,另一个当然就是次子了。﹒
​ 另外一个问题场景,我们很关注各兄弟之间的关系,双亲表示法无法体现这样的关系,那我们怎么办?
嗯,可以增加一个右兄弟域来体现兄弟关系,也就是说,每个结点如果它存在右兄弟,则记录下右兄弟的下标。同样的,如果右兄弟不存在,则赋值为一1,如表6-4-4所示。

​ 表6-4-4

下标dataparentfirstsib
0A-1-1
1B02
2C0-1
3D1-1
4E25
5F2-1
6G37
7H38
8I3-1
9J4-1

​ 但如果结点的孩子很多,超过了2个。我们又关注结点的双亲、又关注结点的孩子、还关注结点的兄弟,而且对时间遍历要求还比较高,那么我们还可以把此结构扩展为有双亲域、长子域、再有右兄弟域。存储结构的设计是一个非常灵活的过程。一个存储结构设计得是否合理,取决于基于该存储结构的运算是否适合、是否方便,时间复杂度好不好等。注意也不是越多越好,有需要时再设计相应的结构。就像再好听的音乐,不停反复听上千遍也会腻味,再好看的电影,一段时间反复看上百遍,也会无趣。

6.3.2孩子表示法

​ 换一种完全不同的考虑方法。由于树中每个结点可能有多棵子树,可以考虑用多重链表,即每个结点有多个指针域,其中每个指针指向一棵子树的根结点,我们把这种方法叫做多重链表表示法。 不过,树的每个结点的度,也就是它的孩子个数是不同的。所以可以设计两种方案来解决。

方案一

​ 指针域的个数就等于树的度。什么是树的度?树的度就是树各个结点度的最大值。

在这里插入图片描述

​ 其中data是数据域。child1 到 chid是指针域,用来指向该结点的孩子结点。

​ 那么对于图6-4-1的树来说,树的度为3,因此指针域的个数为3,表示的结构如下图所示。

在这里插入图片描述

​ 很明显,这种方法造成了空间的浪费。由于各个结点的度的大小不同,因此会存在很多的空指针域。但是如果树的度相差很小的话,意味着开辟的空间被充分利用了,那么反而成了一种优点。

​ 所以,我们为什么不按需分配空间呢?因此,我们设计了第二种方案。

方案二

​ 第二种方案每个结点指针域的个数等于该结点的度,我们专门取一个位置来存储结点指针域的个数。

在这里插入图片描述

​ 其中 data为数据域,degree为度域,也就是存储该结点的孩子结点的个数,child1到childd为指针域,指向该结点的各个孩子的结点。

​ 那么对于图6-4-1的树来说,表示的结构如下图所示。

在这里插入图片描述

​ 这种方法克服了浪费空间的缺点,对空间利用率是很高了,但是由于各个结点的链表是不相同的结构,加上要维护结点的度的数值,在运算上就会带来时间上的损耗。

​ 能否有更好的方法,既可以减少空指针的浪费又能使结点结构相同。

​ 仔细观察,我们为了要遍历整棵树,把每个结点放到一个顺序存储结构的数组中是合理的,但每个结点的孩子有多少是不确定的,所以我们再对每个结点的孩子建立一个单链表体现它们的关系。
​ 这就是我们要讲的孩子表示法。具体办法是,把每个结点的孩子结点排列起来,以单链表作存储结构,则n个结点有n个孩子链表,如果是叶子结点则此单链表为空。然后n个头指针又组成一个线性表,采用顺序存储结构,存放进一个一维数组中。

在这里插入图片描述

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

在这里插入图片描述

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

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

在这里插入图片描述

​ 其中data是数据域,存储某结点的数据信息。firstchild是头指针域,存储该结点的孩子链表的头指针。
​ 以下是我们的孩子表示法的结构定义代码。

#define maxsize 100

//孩子结点
typedef struct CTNode
{
    int child;//孩子结点下标
    struct CTNode *next;//指针域
}*ChildPtr;

//表头结构
typedef struct
{
    int data;//数据域,结点值
    ChildPtr firstchild;//指针域
}*CTBox;

typedef struct 
{
    CTBox node[maxsize];//结点数组
    int r,n; //根的位置和结点数
}CTree;

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

6.3.3孩子兄弟表示法

​ 刚才我们分别从双亲的角度和从孩子的角度研究树的存储结构,如果我们从树结点的兄弟的角度又会如何呢?当然,对于树这样的层级结构来说,只研究结点的兄弟是不行的,我们观察后发现,任意一棵树,它的结点的第一个孩子如果存在就是唯一的,它的右兄弟如果存在也是唯一的。因此,我们设置两个指针,分别指向该结点的第一个孩子和此结点的右兄弟

在这里插入图片描述

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

​ 结构代码定义如下。

typedef struct CSNode
{
    int data;
    struct CSNode *firstchild;
    struct CSNode *rightsib;
}CSNode,*CSTree;

​ 那么对于图6-4-1的树来说,这种方法实现的示意图如下。

在这里插入图片描述

​ 这个表示法最大的好处就是让树更像树,把一棵复杂的树变为了一棵二叉树。

在这里插入图片描述

6.4二叉树的定义

什么是二叉树呢

​ 我们在栈中进行“中序转后序”的过程时,曾提到过二叉树,但是没有进行深入展开。诚如彼时所言,所谓的“二叉树”,实际就是有两个“树杈”的树。

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

​ 在定义中我们可以看到,二叉树中又用到了二叉树的定义,这就是显然的递归,同我们开篇所讲的树的定义运用了递归是一样的。

在这里插入图片描述

​ 如上图即为一个二叉树。

在这里插入图片描述

​ 而此图因为结点D有三个子树,所以明显不为一个二叉树。

6.4.1二叉树特点
  • 每个结点最多有两棵子树,所以二叉树中不存在度大于2的结点。 注意不是只有两棵子树,而是最多有。没有子树或者有一棵子树都是可以的。
  • 左子树和右子树是有顺序的,次序不能任意颠倒。 就像人是双手、双脚,但显然左手、左脚和右手、右脚是不一样的,右手戴左手套、右脚穿左鞋都会极其别扭和难受。
  • 即使树中某结点只有一棵子树,也要区分它是左子树还是右子树。 图6-5-3中,树1和树②是同一棵树,但它们却是不同的二叉树。就好像你一不小心,摔伤了手,伤的是左手还是右手,对你的生活影响度是完全不同的。

在这里插入图片描述

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

1.空二叉树

2.只有一个根结点

3.根结点只有左子树

4.根结点只有右子树

5.根结点既有左子树又有右子树

​ 如果二叉树有三个结点,则会有几种形态呢?

在这里插入图片描述

6.4.2特殊二叉树
1.斜树

​ 顾名思义,都是斜下来的。因为是二叉树,所以可以分为左斜树(对于左子树)和右斜树(对于右子树)。

所有的结点都只有左子树的二叉树叫左斜树。所有结点都是只有右子树的二叉树叫右斜树。这两者统称为斜树。图6-5-4中的树⒉就是左斜树,树5就是右斜树。斜树有很明显的特点,就是每一层都只有一个结点,结点的个数与二叉树的深度相同。

​ 有人会想,这也能叫树呀,与我们的线性表结构不是一样吗。对的,其实线性表结构就可以理解为是树的一种极其特殊的表现形式。

2.满二叉树

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

在这里插入图片描述

​ 单是每个结点都存在左右子树,不能算是满二叉树,还必须要所有的叶子都在同一层上,这就做到了整棵树的平衡。因此,满二叉树的特点有:
(1) 叶子只能出现在最下一层。出现在其他层就不可能达成平衡。

(2) 非叶子结点的度一定是2。否则就是“缺胳膊少腿”了。

(3) 在同样深度的二叉树中,满二叉树的结点个数最多,叶子数最多。

3.完全二叉树

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

在这里插入图片描述

什么意思呢?

​ 根结点就是1,然后每一层横着,从左往右依次数,2,3,4,5…

满二叉树一定是一棵完全二叉树,但完全二叉树不一定是满二叉树。

从这里我也可以得出一些完全二叉树的特点:

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

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

(3)倒数二层,若有叶子结点,一定都在右部连续位置。

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

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

6.5二叉树的性质

6.5.1二叉树的性质1

在二叉树的第i层,至多有$2^{i-1} $个结点 (i≥1)。

6.5.2二叉树的性质2

深度为k的二叉树至多有 2 k − 1 2^{k}-1 2k1个结点 (k≥1)。

6.5.3二叉树的性质3

对于任何一颗二叉树T,如果其终端结点数为 n 0 n_{0} n0,度为2的结点数为 n 2 n_{2} n2,则 n 0 = n 2 + 1 n_{0} = n_{2}+1 n0=n2+1

​ 终端结点数其实就是叶子结点数,而一棵二叉树,除了叶子结点外,剩下的就是度为1或2的结点数了,我们设 n 1 n_{1} n1为度是1的结点数。则树T结点总数 n = n 0 + n 1 + n 2 n=n_{0}+n_{1}+n_{2} n=n0+n1+n2
​ 比如图6-6-1的例子,结点总数为10,它是由A、B、C、D等度为2结点,F、G、H、I、J等度为0的叶子结点和E这个度为1的结点组成。总和为4+1+5=10。

在这里插入图片描述

6.5.4二叉树的性质4

具有n个结点的完全二叉树的深度为 ⌊ log ⁡ 2 n ⌋ + 1 \left\lfloor\log _{2} n\right\rfloor+1 log2n+1(其中, ⌊ X ⌋ \left\lfloor X \right\rfloor X表示向下取整,即表示不大于X的最大整数)。

6.5.5二叉树的性质5

​ 如果对于一颗有n个结点的完全二叉树的结点按层次编号,即完全二叉树的判定编号方法,对任一结点i(1≤i≤n)都有:

1.如果 i = 1 i = 1 i=1,则结点i是二叉树的根,无双亲;若 i > 1 i > 1 i>1,则其双亲结点为 ⌊ i / 2 ⌋ \left\lfloor i/2 \right\rfloor i/2

2.如果 2 i > n 2i>n 2i>n,则结点i无左孩子(结点主为叶子结点);否则其左孩子是结点 2 i 2i 2i

3.如果 2 i + 1 > n 2i+1>n 2i+1>n,则结点i无右孩子;否则其右孩子是结点 2 i + 1 2i+1 2i+1

​ 我们以下图为例,这是一个完全二叉树,度为4,结点总数为10。

在这里插入图片描述

​ 对于第一条来说是很显然的,i=1时就是根结点。i>1时,比如结点7,它的双亲就是 ⌊ 7 / 2 ⌋ = 3 \left\lfloor 7/2 \right\rfloor=3 7/2=3,结点9,它的双亲就是 ⌊ 9 / 2 ⌋ = 4 \left\lfloor 9/2 \right\rfloor=4 9/2=4
​ 第二条,比如结点6,因为2×6=12超过了结点总数10,所以结点6无左孩子它是叶子结点。同样,而结点5,因为2×5=10正好是结点总数10,所以它的左孩子是结点10。
​ 第三条,比如结点5,因为2×5+1=11,大于结点总数10,所以它无右孩子。而结点3,因为2×3+1=7小于10,所以它的右孩子是结点7。

6.6二叉树的存储结构

6.6.1二叉树顺序存储结构

​ 在之前介绍树的存储结构中时,我们谈到了对于树来说,顺序存储很难体现这种一对多的关系。但是二叉树是一种特殊的树,由于它的特殊性,使得用顺序存储也可以实现。

​ 二叉树的顺序存储结构就是用一维数组存储二叉树中的结点,并且结点的存储位置,也就是数组的下标要能体现结点之间的逻辑关系,比如双亲与孩子的关系,左右兄弟的关系等。

​ 对于下图的二叉树,使用顺序存储。

在这里插入图片描述

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

在这里插入图片描述

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

​ 对于一般的二叉树,如含有空结点的二叉树,将对应的下标设置为空即可。

在这里插入图片描述

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

在这里插入图片描述

6.6.2二叉链表

​ 既然顺序存储适用性不强,我们就要考虑链式存储结构。二叉树每个结点最多有两个孩子,所以为它设计一个数据域和两个指针域 是比较自然的想法,我们称这样的链表叫做二叉链表。

在这里插入图片描述

​ 其中,data是数据域,lchild和rchild是指针域。分别存储左孩子和右孩子的指针。leftchild和rightchild的缩写。

​ 定义代码。

typedef struct Tree{
    int val;//数据域
    struct Tree *left;//左孩子
    struct Tree *right; //右孩子
}Tree,*Bittree;

​ 结构示意图如下图所示。

在这里插入图片描述

6.7遍历二叉树

6.7.1二叉树遍历原理

​ 对于“遍历”,我们并不陌生。如我们开篇诸论中所提到的最简单的数组遍历。遍历就是把所有情况都枚举一遍,或者说是把所有情况都过一遍,“经历”一遍,其重点就是做到**“不重不漏”。**

​ 凡是遍历操作,都要讲究一个先后顺序,你可以从前往后遍历,你也可以从后往前遍历,更可以从中间向两边进行遍历。那么对于二叉树呢?

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

​ 两个关键部分:访问次序

​ 访问其实是要根据实际的需要来确定具体做什么,比如对每个结点进行相关计算,输出打印等,它算作是一个抽象操作。在这里我们可以简单地假定就是输出结点的数据信息。

​ 二叉树的遍历次序不同于线性结构,最多也就是从头至尾、循环、双向等简单的遍历方式。树的结点之间不存在唯一的前驱和后继关系,在访问一个结点后,下一个被访问的结点面临着不同的选择。就像你人生的道路上,高考填志愿要面临哪个城市、哪所大学、具体专业等选择,由于选择方式的不同,遍历的次序就完全不同了。

6.7.2二叉树遍历方法

​ 前、中、后、层序遍历。

​ 关于前、中、后序遍历,我们在栈中“前缀转后缀”中也涉及过。所谓的“前 中 后”三个字是指根结点的位置。

1.前序遍历

​ 遍历顺序:**根、左、右。**左、右指左子树和右子树。

​ 规则是若二叉树为空,则空操作返回,否则先访问根结点,然后前序遍历左子树,再前序遍历右子树。如图6-8-2所示,遍历的顺序为: ABDGHCEIF。

在这里插入图片描述

​ 那么对于遍历的算法呢?

​ 二叉树的定义是运用了递归,而遍历整个二叉树的过程,实际上也是运用了递归,所以,遍历二叉树算法的代码使用递归即可实现。那么使用递归有什么好处呢?维护结点很方便,只需要针对一个结点写出相应操作,那么其余结点同样适用。

void traverse(Bittree T)
{
    if(T == NULL)//返回条件
    {
        return ;
    }
    cout<<T->val;//输出结点值
    traverse(T->left);//先遍历左子树
    traverse(T->right);//后遍历右子树
}
2.中序遍历

​ 遍历顺序:左、根、右。

​ 规则是若树为空,则空操作返回,否则从根结点开始(注意并不是先访问根结点),中序遍历根结点的左子树,然后是访问根结点,最后中序遍历右子树。如图6-8-3所示,遍历的顺序为: GDHBAEICF。

在这里插入图片描述

​ 对于中序遍历的算法,同前序遍历的大同小异,只不过是根据定义一样,改了代码的位置。

void traverse(Bittree T)
{
    if(T == NULL)//返回条件
    {
        return ;
    }
    traverse(T->left);//先遍历左子树
    cout<<T->val;//输出结点值
    traverse(T->right);//后遍历右子树
}
3.后序遍历

​ 遍历顺序:左、右、根。

​ 规则是若树为空,则空操作返回,否则从左到右先叶子后结点的方式遍历访问左右子树,最后是访问根结点。如图6-8-4所示,遍历的顺序为:GHDBIEFCA。

在这里插入图片描述

​ 同样的,后序遍历。

void traverse(Bittree T)
{
    if(T == NULL)//返回条件
    {
        return ;
    }
    traverse(T->left);//先遍历左子树
    traverse(T->right);//后遍历右子树
    cout<<T->val;//输出结点值
}
4.层序遍历

每一层从左至右依次遍历。

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

在这里插入图片描述

​ 研究这么多遍历的方法干什么呢?
​ 我们用图形的方式来表现树的结构,应该说是非常直观和容易理解,但是对于计算机来说,它只有循环、判断等方式来处理,也就是说,它只会处理线性序列,而我们刚才提到的四种遍历方法,其实都是在把树中的结点变成某种意义的线性序列,这就给程序的实现带来了好处。
​ 另外不同的遍历提供了对结点依次处理的不同方式,可以在遍历过程中对结点进行各种处理。

6.7.3推导遍历结果

​ 有一种题目为了考查你对二叉树遍历的掌握程度,是这样出题的。已知一棵二叉树的前序遍历序列为ABCDEF,中序遍历序列为CBAEDF,请问这棵二叉树的后序遍历结果是多少?

​ 遇到这种题,根据定义,动手在草稿纸上多画一画就出来了。

二叉树遍历的两个性质:

  • 已知前序遍历序列和中序遍历序列,可以唯一确定一棵二叉树。
  • 已知后序遍历序列和中序遍历序列,可以唯一确定一棵二叉树。

前序中序,确定一棵;后序中序,确定一棵。

​ 但是注意,已知前序和后序,是不能确定一棵二叉树的。 比如前序遍历是ABC,后序遍历是CBA。我们可以确定A是确定的根结点,但是对于BC,我们不知道谁是左子树,谁是右子树。如图所示。

在这里插入图片描述

6.8二叉树的建立

​ 建立一棵二叉树,首先我们知道了二叉树的定义。那么对于数据域就存入相应的数据,对于左右指针分别指向左右子树。若不存在,则指向空即可。

​ 而这个存入数据的过程,依旧用到了递归的思想。

​ 对于二叉树的建立代码,我们使用的语言为c++。

//二叉树的定义
typedef struct Tree{
    int val;//数据域
    struct Tree *left;//左子树
    struct Tree *right;//右子树
    //c++结构体新特性
    Tree() : val(0), left(nullptr), right(nullptr) {}//无参构造
    Tree(int x) : val(x), left(nullptr), right(nullptr) {}//有参构造
    Tree(int x, Tree *left, Tree *right) : val(x), left(left), right(right) {}
}Tree,*Bittree;

//建立二叉树
Bittree creatTree()
{
    int data;
    char temp;
    Bittree T;

    cin>>data;
    temp = getchar();

    if(data == -1){			//	输入-1 代表此节点下子树不存数据,也就是不继续递归创建
		
		return NULL;

	}else{
        Tree* T = new Tree(data);
		//Bittree T = (Bittree)malloc(sizeof(Tree));	//		分配内存空间
		//T->val = data;								//		把当前输入的数据存入当前节点指针的数据域中
		
		printf("请输入%d的左子树: ",data);		
		T->leftChild = creatTree();			//开始递归创建左子树
		printf("请输入%d的右子树: ",data);			
		T->rightChild = creatTree();		//开始到上一级节点的右边递归创建左右子树
		return T;							//返回根节点
	}
}

6.9线索二叉树

6.9.1线索二叉树原理

​ 对于6.8中建立一个二叉树时,我们可能遇到很多为空的情况。如下图。

在这里插入图片描述

​ 所以,又是我们老生常谈的问题,当空指针域过多的时候,就会出现空间浪费的情况。那么,我们能不能将这些空指针域利用起来呢?换句话说,我们是否能利用这些空指针域去指向一些结点,使得操作更快捷呢?答案是可以的。因此,这种利用空指针域的二叉树就为线索二叉树。

​ 首先我们讨论空指针域的个数。对于一个有n个结点的二叉树来说,每个结点都有两个指针域,所以有2n个指针域。而n个结点一共有n-1条分支线数,所以,其实存在 2 n − ( n − 1 ) = n + 1 2n - (n-1) = n+1 2n(n1)=n+1个空指针域。 如上图中有10个结点,而空指针域为11,浪费着内存中的资源。

​ 另一方面,我们在对图6-10-1做中序遍历时。得到HDIBJEAFCG这样的序列,从遍历的结果我们可以清楚地看出谁是谁的前驱和后继。如,I的前驱是D,后继是B。注意,我们得到的前驱和后继的关系是通过遍历的结果得出的。

​ 而我们在二叉链表中,也就是我们的二叉树中,我们只清楚一个结点的左右孩子结点的地址,并不知道某个结点的前驱和后继是谁,如果想要了解,那么必须要遍历一遍。这显然是非常不方便的。为什么不考虑在创建时就记住这些前驱和后继呢?那将节省大量的结点花销。

​ 综合以上两个角度的考虑,我们可以利用那些空地址,存放结点在某种遍历结果下的前驱和后继的结点地址。这就是我们现在所研究的问题。我们把这种指向前驱和后继的指针称为线索,加上线索的二叉链表成为线索链表,相应的二叉树就称为线索二叉树。(Threaded Binary Tree)

​ 请看图6-10-2,我们把这棵二叉树进行中序遍历后,==将所有的空指针域中的rchid,改为指向它的后继结点。==于是我们就可以通过指针知道H的后继是D(图中①),I的后继是B(图中②),J的后继是E(图中③),E的后继是A(图中④),F的后继是C(图中⑤),G的后继因为不存在而指向NULL(图中⑥)。此时共有6个空指针域被利用。

在这里插入图片描述

​ 再看图6-10-3,我们==将这棵二叉树的所有空指针域中的 lchid,改为指向当前结点的前驱。==因此H的前驱是NULL(图中①),I 的前驱是D(图中②),J 的前驱是B(图中③),F的前驱是A(图中④),G的前驱是C(图中⑤)。一共5个空指针域被利用,正好和上面的后继加起来是11个。

在这里插入图片描述

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

在这里插入图片描述

​ 但是,我们如何知道某一结点的lchild是指向它的左孩子还是前驱结点呢?rchild是指向右孩子还是指向后继?例如对于E,它的lchild是指向它的左孩子J,而rchild却是指向后继A。显然,我们需要一个区分标志,来决定和区分lchild和rchild的指向。因此,我们需要在每个结点增设两个标志域lTag和rTag,需要注意的是,lTag和rTag是布尔类型的变量,即只存放0和1.其占用内存空间小于像lchild和rchild这类的指针域。结点结构如下。

在这里插入图片描述

其中:

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

​ 因此对于图6-10-1的二叉链表图可以修改为图6-10-5的样子。

在这里插入图片描述
)

6.9.2线索二叉树结构的实现

​ 我们来回顾一下线索二叉树的构造方式:在原来二叉树的基础上,对于空指针域进行判断,并改变Tag值,结合遍历的结果,使得空指针域进行重新指向。

​ 以上的构造方式,和我们的代码一致。

  • 在原来二叉树的基础上
//线索二叉树的定义
typedef struct BiThrNode{
    int val;//结点数据
    struct BiThrNode *left;//左孩子
    struct BiThrNode *right;//右孩子
    bool LTag;//左孩子线索标识
    bool RTag;//右孩子线索标识

    BiThrNode(int x): val(x),left(nullptr),right(nullptr){}
}BiThrNode,*BiThrTree

//初始化赋值
BiThrTree creatTree()
{
    int data;
    char temp;
    BiThrTree T;

    cin>>data;
    temp = getchar();
    if(data == -1)
    {
        return NULL;
    }else{
        BiThrTree T = new BiThrNode(data);

        printf("请输入%d的左子树: ",data);		
		T->left = creatTree();			//开始递归创建左子树
		printf("请输入%d的右子树: ",data);			
		T->right = creatTree();		//开始到上一级节点的右边递归创建左右子树
		return T;							//返回根节点
    }
}

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

  • 结合遍历结果,对空指针以及Tag进行修改。
//中序遍历进行中序线索化
void InThreading(BiThrTree p)
{
    BiThrTree pre;//临时存放结点
    if(p)
    {
        InThreading(p->left);//左子树
        if(!p->left)//p的left为空
        {
            p->LTag = 1;//修改Tag值
            p->left = pre;//改变指针,指向前驱结点
        }
        if(!p->right)//p的right为空
        {
            pre->RTag = 1;//修改Tag值
            pre->RTag = p;//改变指针,指向后继结点
        }
        pre = p;//根
        InThreading(p->right);//右子树
    }
}

​ 有了线索二叉树后,我们对它进行遍历时发现,其实就等于是操作一个双向链表结构。
​ 和双向链表结构一样,在二叉树线索链表上添加一个头结点,如图6-10-6所示,并令其lchild域的指针指向二叉树的根结点(图中的①),其rchild域的指针指向中序遍历时访问的最后一个结点(图中的②)。反之,令二叉树的中序序列中的第一个结点中,lchild域指针和最后一个结点的rchild域指针均指向头结点(图中的③和④)。这样定义的好处就是我们既可以从第一个结点起顺后继进行遍历,也可以从最后一个结点起顺前驱进行遍历。

在这里插入图片描述

  • 遍历输出
//遍历
void Traverse(BiThrTree T)
{
    BiThrTree p;
    p = T->left;//p指向根结点
    while(p != T)
    {
        while(p->left==0)//使得p指向了最左边的叶子结点
        {
            p = p->left;
        }
        cout<<p->val;
        while(p->RTag==1 && p->right!=T)
        {
            p = p->right;
            cout<<p->val;
        }
        p = p->right;
    }
    return ;
}

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

6.10树、森林与二叉树的转换

​ 我们前面已经讲过了树的定义和存储结构,对于树来说,在满足树的条件下可以是任意形状,一个结点可以有任意多个孩子,显然对树的处理要复杂得多,去研究关于树的性质和算法,真的不容易。有没有简单的办法解决对树处理的难题呢?
​ 我们前面也讲了二叉树,尽管它也是树,但由于每个结点最多只能有左孩子和右孩子,面对的变化就少很多了。因此很多性质和算法都被研究了出来。如果所有的树都像二叉树一样方便就好了。

​ 在讲树的存储结构时,我们提到了**树的孩子兄弟法可以将一棵树用二叉链表进行存储,所以借助二叉链表,树和二叉树可以相互进行转换。**从物理结构来看,它们的二叉链表也是相同的,只是解释不太一样而已。因此,只要我们设定一定的规则,用二叉树来表示树,甚至表示森林都是可以的,森林与二叉树也可以互相进行转换。

​ 我们分别来讨论它们之间是如何转换的。

6.10.1树转换为二叉树

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

​ 例如图6-11-2,一棵树经过三个步骤转换为一棵二叉树。初学者容易犯的错误就是在层次调整时,弄错了左右孩子的关系。比如图中F、G本都是树结点B的孩子,是结点E的兄弟,因此转换后,F就是二叉树结点E的右孩子,G是二叉树结点F的右孩子。

在这里插入图片描述

6.10.2森林转换为二叉树

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

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

如下图,将森林的三棵树转化为一颗二叉树。

在这里插入图片描述

6.10.3二叉树转换为树

二叉树转换为树是树转换为二叉树的逆过程,也就是反过来做而已。步骤如下:

  1. 加线。若某结点的左孩子结点存在,则将这个左孩子的右孩子结点、右孩子的右孩子结点、右孩子的右孩子的右孩子结点……哈,反正就是左孩子的n个右孩子结点都作为此结点的孩子。将该结点与这些右孩子结点用线连接起来。

  2. 去线。删除原二叉树中所有结点与其右孩子结点的连线。

  3. 层次调整。使之结构层次分明。

在这里插入图片描述

6.10.4二叉树转换为森林

​ 判断一棵二叉树能够转换成一棵树还是森林,标准很简单,**那就是只要看这棵二叉树的根结点有没有右孩子,有就是森林,没有就是一棵树。**那么如果是转换成森林,步骤如下:

​ 1.从根结点开始,若右孩子存在,则把与右孩子结点的连线删陈,再查看分离后的二叉树,若右孩子存在,则连线删除……,直到所有右孩子连线都删除为止,得到分离的二叉树。

​ 2.再将每棵分离后的二叉树转换为树即可。

在这里插入图片描述

6.10.5树与森林的遍历

树的遍历分为两种方式:

1.先根遍历树。 即先访问树的跟结点,然后依次先根遍历根的每棵子树。

2.后根遍历树。 即先依次后根遍历每棵子树,然后再访问根结点。

森林的遍历也分为两种方式:

1.前序遍历。 先访问森林中第一棵树的根结点,然后再依次先根遍历根的每棵子树,再依次用同样方式遍历除去第一棵树的剩余树构成的森林。如图6-11-5,右侧三棵树的森林,前序遍历序列的结果是ABCDEFGHJI。

2.后序遍历。 是先访问森林中第一棵树,后根遍历的方式遍历每棵子树,然后再访问根结点,再依次同样方式遍历除去第一棵树的剩余树构成的森林。如图6-11-5的森林,后序遍历的结果为BCDAFEJHIG。

​ 可如果我们对图6-11-4的左侧二叉树进行分析就会发现,森林的前序遍历和二叉树的前序遍历结果相同,森林的后序遍历和二叉树的中序遍历结果相同。
​ 这也就告诉我们,**当以二叉链表作树的存储结构时,树的先根遍历和后根遍历完全可以借用二叉树的前序遍历和中序遍历的算法来实现。**这其实也就证实,我们找到了对树和森林这种复杂问题的简单解决办法。

6.11哈夫曼树及其应用

6.11.1 哈夫曼树

​ 在我们的日常生活中,常常需要打压缩包,或者是压缩文件来使占用的空间减小。那么,所谓的压缩,是使用了什么原理呢?或者是使用了什么算法呢,使得压缩不出错如何做到呢?简单说,就是把我们要压缩的文本进行重新编码,以减少不必要的空间。尽管现在最新技术在编码上已经很好很强大,但这一切都来自于曾经的技术积累,我们今天就来介绍一下最基本的压缩解码方法——哈夫曼编码。

​ 在介绍哈夫曼编码前,我们必须得介绍哈夫曼树,而介绍哈夫曼树,我们不得不提这样一个人,美国数学家哈夫曼(David Huffman),这里我们采用哈夫曼的翻译。他在1952年发明了哈夫曼编码,为了纪念他的成就,于是就把**他在编码中用到的特殊的二叉树称之为哈夫曼树,他的编码方法称为哈夫曼编码。**也就是说,我们现在介绍的知识全都来自于近60年前这位伟大科学家的研究成果,而我们平时所用的压缩和解压缩技术也都是基于哈夫曼的研究之上发展而来,我们应该要记住他。

​ 什么是哈夫曼树呢?我们看一个例子。

​ 在我们上学的阶段,常常使用百分制来反映学科成绩。带来的弊端不尽相同,容易造成以分取人,我们在此不做过多讨论。在当下素质教育的背景下,大多都改为了优秀、良好、中等、及格、不及格这样模糊的词语,而不是具体的分数。

​ 不过这五个等级,不能完全主观臆断,还是依靠分数进行转换,只不过是在一个给定的区间范围内,进行了一个等级赋予。于是,我们就有了以下的代码。

if(a<60)
    b = "不及格";
else if(a<70)
    b = "及格";
else if(a<80)
    b = "中等";
else if(a<90)
    b = "良好";
else
    b = "优秀";

​ 图6-12-2粗略看没什么问题,可是通常都认为,一张好的考卷应该是让学生成绩大部分处于中等或良好的范围,优秀和不及格都应该较少才对。而上面这样的程序,就使得所有的成绩都需要先判断是否及格,再逐级而上得到结果。输入量很大的时候、其实算法是有效率问题的。

在这里插入图片描述

​ 如果在实际的生活中,学生的成绩在5个等级上的分布规律如表。

分数0-5960-6970-7980-8990-100
所占比例5%15%40%30%10%

​ 那么70分以上大约占总数80%的成绩都需要经过3次以上的判断才可以得到结果,这显然不合理。

​ 有没有好一些的办法,仔细观察发现,**中等成绩(70~79 分之间)比例最高,其次是良好成绩,不及格的所占比例最少。**我们把图6-12-2这棵二叉树重新进行分配。改成如图6-12-3的做法试试看。.

在这里插入图片描述

​ 从上图可以直观的感受到,效率是有所提高的,到底高多少?这样的二叉树又是如何设计出来的?我们看哈夫曼是如何说的。

6.11.2哈夫曼树定义与原理

我们先把这两棵二叉树简化成叶子结点带权的二叉树,如图6-12-4 所示。其中A表示不及格、B表示及格、C表示中等、D表示良好、E表示优秀。每个叶子的分支线上的数字就是刚才我们提到的五级分制的成绩所占比例数。

在这里插入图片描述

​ 哈夫曼大叔说,从树中一个结点到另一个结点之间的分支构成两个结点之间的路径,**路径上的分支数目称做路径长度。**图6-12-4 的二叉树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。

​ 如果考虑到带权的结点,结点的带权的路径长度为从该结点到树根之间的路径长度与结点上权的乘积。树的带权路径长度为树中所有叶子结点的带权路径长度之和。假设有n个权值 { w 1 , w 2 , w 3 , . . . , w n } \left \{ w_{1},w_{2},w_{3},...,w_{n} \right \} {w1,w2,w3,...,wn} ,构造一棵有 n个叶子结点的二叉树,每个叶子结点带权 W i W_{i} Wi,每个叶子的路径长度为 l i l_{i} li,我们通常记作,则其中带权路径长度WPL最小的二叉树称做哈夫曼树。也有不少书中也称为最优二叉树,我个人觉得为了纪念做出巨大贡献的科学家,既然用他们的名字命名,就应该要坚持用他们的名字称呼,哪怕“ 最优”更能体现这棵树的品质也应该只作为别名。

W P L = ∑ i = 1 n W i × l i WPL = \sum_{i = 1}^{n} W_{i}×l_{i} WPL=i=1nWi×li
​ 有了哈夫曼对带权路径长度的定义,我们来计算一下图6-12-4这两棵树的WPL值。

​ 二叉树a的 W P L = 5 × 1 + 15 × 2 + 40 × 3 + 30 × 4 + 10 × 4 = 315. WPL = 5×1+15×2+40×3+30×4+10×4 = 315. WPL=5×1+15×2+40×3+30×4+10×4=315.

​ 二叉树b的 W P L = 5 × 3 + 15 × 3 + 40 × 2 + 30 × 2 + 10 × 2 = 220. WPL=5 ×3+15×3+40×2+30×2+10×2=220. WPL=5×3+15×3+40×2+30×2+10×2=220.

​ 这样的结果意味着什么呢?如果我们现在有10000个学生的百分制成绩需要计算五级分制成绩,用二叉树a的判断方法,需要做31500次比较,而二叉树b 的判断方法,只需要22000次比较,差不多少了三分之一量,在性能上提高不是一点点。

那么现在的问题就是,图6-12-4的二叉树b这样的树是如何构造出来的,这样的二叉树是不是就是最优的哈夫曼树呢?别急,哈夫曼大叔给了我们解决的办法。

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

2.取头两个最小权值的结点作为一个新节点 N 1 N_{1} N1的两个子结点,注意相对较小的是左孩子, 这里就是A为 N 1 N_{1} N1的左孩子,E为 N 1 N_{1} N1的右孩子,如图6-12-5所示。新结点的权值为两个叶子权值的和5+10=15。

3. N 1 N_{1} N1替换A与E,插入有序序列中,保持从小到大排列。即: N 1 N_{1} N1 15,B15,D30,C40。

4.重复步骤2。将 N 1 N_{1} N1与B作为一个新节点 N 2 N_{2} N2的两个子结点。如图6-12-6 所示。 N 2 N_{2} N2的权值=15+15=30。

在这里插入图片描述

5.将 N 2 N_{2} N2替换 N 1 N_{1} N1与B,插入有序序列中,保持从小到大排列。即:$N_{2}$30,D30,C40。

6.重复步骤⒉。将 N 2 N_{2} N2与D作为一个新节点 N 3 N_{3} N3的两个子结点。如图6-12-7所示。 N 3 N_{3} N3的权值=30+30=60。

7.将 N 3 N_{3} N3替换 N 2 N_{2} N2与D,插入有序序列中,保持从小到大排列。即:C40,$N_{3}$60。

8.重复步骤2。将C与 N 3 N_{3} N3作为一个新节点T的两个子结点,如图6-12-8所示由于T即是根结点,完成赫夫曼树的构造。

在这里插入图片描述

​ 此时的图6-12-8二叉树的带权路径长度 W P L = 40 × 1 + 30 × 2 + 15 × 3 + 10 × 4 + 5 × 4 = 205 。 WPL=40×1+30×2+15×3+10×4+5×4=205。 WPL=40×1+30×2+15×3+10×4+5×4=205与图6-12-4的二叉树b的WPL值220相比,还少了15。显然此时构造出来的二叉树才是最优的赫夫曼树。

​ 通过刚才的步骤,我们可以得出构造哈夫曼树的赫夫曼算法描述。

1.根据给定的n个权值 { w 1 , w 2 , w 3 , . . . , w n } \left \{ w_{1},w_{2},w_{3},...,w_{n} \right \} {w1,w2,w3,...,wn}构成n棵二叉树的集合 F = { T 1 , T 2 , T 3 , . . . , T n } F = \left \{ T_{1},T_{2},T_{3},...,T_{n} \right \} F={T1,T2,T3,...,Tn}其中每棵二叉树 T i T_{i} Ti中只有一个带权为 w i w_{i} wi根结点,其左右子树均为空。

2.在F中选取两棵根结点的权值最小的树作为左右子树构造一棵新的二叉树,且置新的二叉树的根结点的权值为其左右子树上根结点的权值之和。

3.在F中删除这两棵树,同时将新得到的二叉树加入F中。

4.重复2和3步骤,直到F只含一棵树为止。这棵树便是哈夫曼树。

6.11.3哈夫曼编码

​ 当然,哈夫曼研究这种最优树的目的不是为了我们可以转化一下成绩。他的更大目的是为了解决当年远距离通信(主要是电报)的数据传输的最优化问题

​ 比如我们有一段文字内容为“BADCADFEED”要网络传输给别人,显然用二进制的数字(0和1)来表示是很自然的想法。我们现在这段文字只有六个字母ABCDEF,那么我们可以用相应的二进制数据表示,如下表所示。

字母ABCDEF
二进制字符000001010011100101

​ 这样真正传输的数据就是编码后的“001000011010000011101100100011",对方接收时**可以按照3位一分来译码。**如果一篇文章很长,这样的二进制串也将非常的可怕。而且事实上,不管是英文、中文或是其他语言,字母或汉字的出现频率是不相同的,比如英语中的几个元音字母“a eiou”,中文中的“的了有在”等汉字都是频率极高。

​ 假设六个字母的频率为A 27,B 8,C 15,D 15,E 30,F 5,合起来正好是100%。那就意味着,我们完全可以重新按照哈夫曼树来规划它们。

​ 图6-12-9左图为构造哈夫曼树的过程的权值显示。右图为将权值左分支改为0,右分支改为1后的哈夫曼树。

在这里插入图片描述

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

字母ABCDEF
二进制字符01100110100111000

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

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

​ 也就是说,我们的数据被压缩了,节约了大概17%的存储和运输成本。随着字符的增多和多字符的权重不同,这种压缩会更加凸显出其优势。

​ 编码完毕了,那么如何解码呢?

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

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

6.12总结回顾

​ 总的来说,树这一章的内容非常多,但是基本的概念,就我个人认为是比较好理解的。树是一种非常重要的数据结构,可以这么说,任何数据结构都可以转换为树的这种结构。可见其重要性。

​ 同样,在算法的学习过程中,树的算法,尤其是二叉树的相关算法,我们建议是作为算法学习的开始。因为可以比较融会贯通、举一反三的去解决一些基本的数据结构的算法问题。

​ 开头我们提到了树的定义,讲到了递归在树定义中的应用。提到了如子树、结点、度、叶子、分支结点、双亲、孩子、层次、深度、森林等诸多概念,这些都是需要在理解的基础上去记忆的。

​ 我们谈到了树的存储结构时,讲了双亲表示法、孩子表示法、孩子兄弟表示法等不同的存储结构。
​ 并由孩子兄弟表示法引出了我们这章中最重要一种树,二叉树。
二叉树每个结点最多两棵子树,有左右之分。提到了斜树,满二叉树、完全二叉树等特殊二叉树的概念。

​ 我们接着谈到它的各种性质,这些性质给我们研究二叉树带来了方便。
二叉树的存储结构由于其特殊性使得既可以用顺序存储结构又可以用链式存储结构表示。
​ 遍历是二叉树最重要的一门学问,前序、中序、后序以及层序遍历都是需要熟练掌握的知识。要让自己要学会用计算机的运行思维去模拟递归的实现,可以加深我们对递归的理解。不过,并非二叉树遍历就一定要用到递归,只不过递归的实现比较优雅而已。这点需要明确。
​ 二叉树的建立自然也是可以通过递归来实现。
​ 研究中也发现,二叉链表有很多浪费的空指针可以利用,查找某个结点的前驱和后继为什么非要每次遍历才可以得到,这就引出了如何构造一棵线索二叉树的问题。线索二叉树给二叉树的结点查找和遍历带来了高效率。

​ 树、森林看似复杂,其实它们都可以转化为简单的二叉树来处理,我们提供了树、森林与二叉树的互相转换的办法, 这样就使得面对树和森林的数据结构时,编码实现成为了可能。

​ 最后,我们提到了关于二叉树的一个应用,哈夫曼树和哈夫曼编码,对于带权路径的二叉树做了详尽地讲述,让你初步理解数据压缩的原理,并明白其是如何做到无损编码和无错解码。

  • 6
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Wu丶ying

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值