大话数据结构学习笔记(六)——树

树(Tree)是n(n≥0)个结点的有限集。n=0时称为空树

在任意一棵非空树中:

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

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

1 树的定义

如图所示:

树的定义其实就是我们在讲解栈时提到的递归的方法。也就是在树的定义之中还用到了树的概念, 这是一种比较新的定义方法。下图的子树T[1]和子树T[2]就是根结点A的子树。当然,DGHI组成的树又是B为根结点的子树,EJ组成的树是以C为根结点的子树。

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

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

1.1 结点分类

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

1.2 结点间的关系

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

1.3 树的其他相关概念

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

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

森林(Forest)是m(m≥0)棵互不相交的树的集合。对树中每个结点而言,其子树的集合即为森林。对于第一张图中的树而言, 第二张图中的两棵子树其实就可以理解为森林。

对比线性表与树的结构,它们有很大的不同,如下表所示:

线性结构 树结构
第一个数据元素:无前驱 根结点:无双亲,唯一
最后一个数据元素:无后继 叶结点:无孩子,可以多个
中间元素:一个前驱一个后继 中间结点:一个双亲多个孩子

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非空树c与T不相交,操作结果为插入c为树
	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;
// 结点结构
typedef struct PTNode
{
    // 结点数据
    TElemType data;
    // 双亲位置
    int parent;
} PTNode;

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

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

下标 data parent
0 A -1
1 B 0
2 C 0
3 D 1
4 E 2
5 F 2
6 G 3
7 H 3
8 I 3
9 J 4

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

对其进行改进,我们增加一个结点最左边孩子的域,不妨叫它长子域,这样就可以很容易得到结点的孩子。如果没有孩子的结点,这个长子域就设置为-1,如下表所示。

下标 data parent firstchild
0 A -1 1
1 B 0 3
2 C 0 4
3 D 1 6
4 E 2 9
5 F 2 -1
6 G 3 -1
7 H 3 -1
8 I 3 -1
9 J 4 -1

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

下标 data parent rightsib
0 A -1 -1
1 B 0 2
2 C 0 -1
3 D 1 -1
4 E 2 5
5 F 2 -1
6 G 3 7
7 H 3 8
8 I 3 -1
9 J 4 -1

3.2 孩子表示法

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

3.2.1 方案一

一种是指针域的个数就等于树的度,复习一下,树的度是树各个结点度的最大值。其结构如下图所示。

其中data是数据域。child[1]child[d]是指针域,用来指向该结点的孩子结点。

对于上图的树来说,树的度是3,所以我们的指针域的个数是3,这种方法实现如下图所示。

这种方法对于树中各结点的度相差很大时,显然是很浪费空间的,因为有很多的结点,它的指针域都是空的。不过如果树的各结点度相差很小时,那就意味着开辟的空间被充分利用了,这时存储结构的缺点反而变成了优点。

3.2.2 方案二

既然很多指针域都可能为空,为什么不按需分配空间呢。

第二种方案每个结点指针域的个数等于该结点的度,我们专门取一个位置来存储结点指针域的个数,其结构如下图所示。

其中data为数据域,degree为度域,也就是存储该结点的孩子结点的个数,child[1]child[d]为指针域,指向该结点的各个孩子的结点。

对于上图的树来说, 这种方法实现下图所示。

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

那么是否有更好的方法,既可以减少空指针的浪费又能使结点结构相同呢?

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

我们把这种方法称为双亲孩子表示法,应该算是孩子表示法的改进。

3.3 孩子兄弟表示法

对于树这样的层级结构来说,只研究结点的兄弟是不行的,我们观察后发现,任意一棵树,它的结点的第一个孩子如果存在就是唯一的,它的右兄弟如果存在也是唯一的。因此,我们设置两个指针,分别指向该结点的第一个孩子和此结点的右兄弟。结点结构如下图所示:

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

结构定义代码如下:

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

对于上图的树来说, 这种方法实现下图所示。

这种表示法,给查找某个结点的某个孩子带来了方便,只需要通过fistchild找到此结点的长子,然后再通过长子结点的rightsib找到它的二弟,接着一直下去,直到找到具体的孩子。当然,如果想找某个结点的双亲,这个表示法也是有缺陷的,那怎么办呢?

如果真的有必要, 完全可以再增加一个parent指针域来解决快速查找双亲的问题,这里就不再细谈了。
其实这个表示法的最大好处是它把一棵复杂的树变成了一棵二叉树。我们把上图变变形就成了下图这个样子。

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

4 二叉树的定义

现在我们来做个游戏,我在纸上已经写好了一个100以内的正整数数字,请大家想办法猜出我写的是哪一个?注意你们猜的数字不能超过7个,我的回答只会告诉你是“大了”或“小了”。
这个游戏在一些电视节目中,猜测一些商品的定价时常会使用。我看到过有些人是一点一点的数字累加的,比如5、10、15、20这样猜,这样的猜数策略太低级了,显然是没有学过数据结构和算法的人才做得出的事。
其实这是一个很经典的折半查找算法。如果我们用下图(下三层省略) 的办法,就一定能在7次以内,猜出结果来。

过程如下表所示:

被猜数字 第一次 第二次 第三次 第四次 第五次 第六次 第七次
39 50 25 37 43 40 38 39
82 50 75 88 82
99 50 75 88 96 98 99
1 50 25 12 6 3 2 1

4.1 二叉树特点

二叉树的特点有:

  • 每个结点最多有两棵子树,所以二叉树中不存在度大于2的结点。注意不是只有两棵,而是最多有。没有子树或者有一棵子树都是可以的。

  • 左子树和右子树是有顺序的,次序不能任意颠倒。就像人有双手、双脚,但显然左手、左脚和右手、右脚是不一样的,右手戴做手套,右脚穿左鞋都会极其别扭和难受。

  • 即使树中某结点只有一棵子树,也要区分它是左子树还是右子树。如下图中,树1和树2是同一棵树,但它们却是不同的二叉树。就好像你一不小心,摔伤了手,伤的是左手还是右手,对你的生活影响度是完全不同的。

二叉树具有五种基本形态

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

那如果是有三个结点的树,有几种形态?如果是有三个结点的二叉树,考虑一下,又有几种形态?

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

4.2 特殊二叉树

4.2.1 斜树

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

顾名思义,斜树一定要是斜的,但是往哪斜还是有讲究。所有的结点都只有左子树的二叉树叫左斜树。所有结点都是只有右子树的二叉树叫右斜树。这两者统称为斜树。上图中的树2就是左斜树,树5就是右斜树。斜树有很明显的特点,就是每一层都只有一个结点,结点的个数与二叉树的深度相同。

4.2.2 满二叉树

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

下图就是一棵满二叉树,从样子上看就感觉它很完美。

单是每个结点都存在左右子树,不能算是满二叉树,还必须要所有的叶子都在同一层上,这就做到了整棵树的平衡。

因此,满二叉树的特点有:

  1. 叶子只能出现在最下一层。出现在其它层就不可能达到平衡。
  2. 非叶子结点的度一定是2。否则就是“缺胳膊少腿”了。
  3. 在同样深度的二叉树中,满二叉树的结点个数最多,叶子数也最多。

4.2.3 完全二叉树

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

如下图所示。

这是一种有些理解难度的特殊二叉树。
首先从字面上要区分,“完全”和“满”的差异,满二叉树一定是一棵完全二叉树,但完全二叉树不一定是满的。
其次,完全二叉树的所有结点与同样深度的满二叉树,它们按层序编号相同的结点,是一一对应的。 这里有个关键词是按层序编号,像下图中的树1,因为5结点没有左子树,却有右子树,那就使得按层序编号的第10个编号空档了。同样道理,下图中的树2,由于3结点没有子树,所以使得6、编号的位置空档了。下图中的树3又是因为5编号下没有子树造成第10和第11位置空档。只有上图中的树,尽管它不是满二叉树,但是编号是连续的,所以它是完全二叉树。

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

  1. 叶子结点只能出现在最下两层。
  2. 最下层的叶子一定集中在左部连续位置。
  3. 倒数第二层,若有叶子结点,一定都在右部连续位置。
  4. 如果结点度为1,则该结点只有左孩子,即不存在只有右子树的情况。
  5. 同样结点数的二叉树,完全二叉树的深度最小。

从上面的例子,也给了我们一个判断某二叉树是否是完全二叉树的办法,那就是看着树的示意图,心中默默给每个结点按照满二叉树的结构逐层顺序编号,如果编号出现空档,就说明不是完全二叉树,否则就是。

5 二叉树的性质

5.1 二叉树性质1

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

观察一下上面的满二叉树图。

第一层是根结点,只有一个,所以2(1-1)=20=1。

第二层有两个,2(2-1)=21=2。

第三层有四个,2(3-1)=22=4。

第四层有八个,2(4-1)=23=8。

通过数据归纳法的论证,可以很容易得出在二叉树的第i层上至多有2^(i-1) (i≥1)个结点的结论。

5.2 二叉树性质2

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

深度为k意思就是有k层的二叉树,我们先来看看简单的。
如果有一层,至多1=2^1-1个结点。
如果有二层,至多1+2=3=2^2-1个结点。
如果有三层,至多1+2+4=7=2^3-1个结点。
如果有四层,至多1+2+4+8=15=2^4-1个结点。
通过数据归纳法的论证,可以得出,如果有k层,此二叉树至多有2^k-1个结点。

5.3 二叉树性质3

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

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

比如下图的例子,结点总数为10,它是由A、B、C、D等度为2结点,F、G、H、I、J等度为0的叶子结点和E这个度为1的结点组成。总和为4+1+5=10。

5.4 二叉树性质4

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

由满二叉树的定义我们可以知道,深度为k的满二叉树的结点数n一定是2^k-1。 因为这是最多的结点个数。那么对于n=2^k-1倒推得到满二叉树的深度为k=log2(n+1),比如结点数为15的满二叉树,深度为4。

完全二叉树的结点数一定少于等于同样深度的满二叉树的结点数2^k-1,但一定多于2^(k-1)-1。即满足2^(k-1)-1<n≤2^k-1。由于结点数n是整数,n≤2^k-1意味着n<2^kn>2^(k-1)-1,意味着n≥2^(k-1),所以2^(k-1)≤n<2^k,不等式两边取对数,得到k-1≤log2n<k,而k作为深度也是整数,因此k=|log2n|+1

5.5 二叉树性质5

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

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

我们以下图为例,来理解这个性质。这是一棵完全二叉树,其深度为4,结点总数为10。

第一条是很显然的,i=1时就是根结点。i>1时,比如结点7,他的双亲就是结点9。

第二条,比如结点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.1 二叉树的顺序存储结构

前面我们已经谈到了树的存储结构,并且谈到顺序存储对树这种一对多的关系结构实现起来是比较困难的。但是二叉树是一种特殊的树,由于它的特殊性,使得用顺序存储结构也可以实现。
二叉树的顺序存储结构就是用一维数组存储二叉树中的结点,并且结点的存储位置,也就是数组的下标要能体现结点之间的逻辑关系,比如双亲与孩子的关系,左右兄弟的关系等。
先来看看完全二叉树的顺序存储,一棵完全二叉树如下图所示。

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

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

当然对于一般的二叉树,尽管层序编号不能反映逻辑关系,但是可以将其按完全二叉树编号,只不过,把不存在的结点设置为"∧"而已。如下图,注意浅色结点表示不存在。

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

6.2 二叉链表

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

其中data是数据域,lchildrchild都是指针域,分别存放指向左孩子和右孩子的指针。

以下是我们的二叉链表的结点结构定义代码。

// 二叉树的二叉链表结点结构定义
// 结点结构
typedef struct BiTNode
{
    // 结点数据
    TElemType data;
    // 左右孩子指针
    struct BiTNode *lchild, *rchild;
} BiTNode, *BiTree;

结构示意图如下图所示。

就如同树的存储结构中讨论的一样,如果有需要,还可以再增加一个指向其双亲的指针域,那样就称之为三叉链表。

7 遍历二叉树

7.1 二叉树遍历原理

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

这里有两个关键词: 访问次序
访问其实是要根据实际的需要来确定具体做什么,比如对每个结点进行相关计算,输出打印等,它算作是一个抽象操作。在这里我们可以简单地假定访问就是输出结点的数据信息。
二叉树的遍历次序不同于线性结构,最多也就是从头至尾、循环、双向等简单的遍历方式。树的结点之间不存在唯一的前驱和后继关系,在访问一个结点后,下一个被访问的结点面临着不同的选择。就像你人生的道路上,高考填志愿要面临哪个城市、哪所大学、具体专业等选择,由于选择方式的不同,遍历的次序就完全不同了。

7.2 二叉树的遍历方法

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

7.2.1 前序遍历

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

7.2.2 中序遍历

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

7.2.3 后序遍历

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

7.2.4 层序遍历

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

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

对于下图的这样一棵二叉树,假设这树已经用二叉链表结构存储在内存当中了。

7.3 前序遍历算法

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

// 二叉树的前序遍历递归算法
void PreOrderTraverse(BiTree T)
{
    if (T == NULL)
        return;
    // 显示结点数据,可以更改为其他结点操作
    printf("%c", T ->data);
    // 再先序遍历左子树
    PreOrderTraverse(T -> lchild);
    // 最后先序遍历右子树
    PreOrderTraverse(T -> rchild);
}

前序遍历上图这棵二叉树的节点顺序是:ABDHK-E-CFI-GJ。

7.4 中序遍历算法

// 二叉树的中序遍历递归算法
void InOrderTraverse(BiTree T)
{
    if (T == NULL)
        return;
    // 中序遍历左子树
    InOrderTraverse(T -> lchild);
    // 显示结点数据,可以更改为其他结点操作
    printf("%c", T ->data);
    // 最后中序遍历右子树
    InOrderTraverse(T -> rchild);
}

中序遍历上图这棵二叉树的节点顺序是:HK-DBE-A-IFCGJ。

7.5 后续遍历算法

// 二叉树的后序遍历递归算法
void PostOrderTraverse(BiTree T)
{
	if (T == NULL)
		return;
	// 先后序遍历左子树
	PostOrderTraverse(T->lchild);
	// 再后序遍历右子树
	PostOrderTraverse(T->rchild);
	// 显示结点数据,可以更改为其他对结点操作
	printf("%c", T->data);
}

后序遍历的结点的顺序就是:KHD-EB-IF-JGCA。

7.6 推导遍历结果

有一种题目为了考查你对二叉树遍历的掌握程度,是这样出题的。已知一棵二叉树的前序遍历序列为ABCDEF,中序遍历序列为CBAEDF,请问这棵二叉树的后序遍历结果是多少?
对于这样的题目,如果真的完全理解了前中后序的原理,是不难的。
三种遍历都是从根结点开始,前序遍历是先打印再递归左和右。所以前序遍历序列为ABCDEF,第一个字母是A被打印出来,就说明A是根结点的数据。再由中序遍历序列是CBAEDF,可以知道C和B是A的左子树的结点,E、D、F是A的右子树的结点,如下图所示。

然后我们看前序中的C和B,它的顺序是ABCDEF,是先打印B后打印C,所以B应该是A的左孩子, 而C就只能是B的孩子,此时是左还是右孩子还不确定。再看中序序列是CBAEDF,C是在B的前面打印,这就说明C是B的左孩子,否则就是右孩子了,如下图所示。

再看前序中的E、D、F,它的顺序是ABCDEF,那就意味着D是A结点的右孩子,E和F是D的子孙,注意,它们中有一个不一定是孩子,还有可能是孙子的。再来看中序序列是CBAEDF,由于E在D的左侧,而F在右侧,所以可以确定E是D的左孩子,F是D的右孩子。因此最终得到的二叉树如下图所示。

为了避免推导中的失误,你最好在心中递归遍历,检查一下这棵树的前序和中序遍历序列是否与题目中的相同。
已经复原了二叉树,要获得它的后序遍历结果就是易如反掌,结果是CBEFDA。

但其实,如果同学们足够熟练,不用画这棵二叉树,也可以得到后序的结果,因为刚才判断了A结点是根结点,那么它在后序序列中,一定是最后一个。刚才推导出C是B的左孩子,而B是A的左孩子,那就意味着后序序列的前两位一定是CB。同样的办法也可以得到EFD这样的后序顺序,最终就自然的得到CBEFDA这样的序列,不用在草稿上画树状图了。

反过来,如果我们的题目是这样:二叉树的中序序列是ABCDEFG,后序序列是BDCAFGE,求前序序列。
这次简单点,由后序的BDCAFGE,得到E是根结点,因此前序首字母是E。
于是根据中序序列分为两棵树ABCD和FG,由后序序列的BDCAFGE,知道A是E的左孩子, 前序序列目前分析为EA。
再由中序序列的ABCDEFG,知道BCD是A结点的右子孙,再由后序序列的BDCAFGE知道C结点是A结点的右孩子,前序序列目前分析得到EAC。
中序序列ABCDEFG,得到B是C的左孩子,D是C的右孩子,所以前序序列目前分析结果为EACBD。
由后序序列BDCAFGE,得到G是E的右孩子,于是F就是G的孩子。如果你是在考试时做这道题目, 时间就是分数、名次、学历,那么你根本不需关心F是G的左还是右孩子,前序遍历序列的最终结果就是EACBDGF。
不过细细分析,根据中序序列ABCDEFG,是可以得出F是G的左孩子。

从这里我们也得到两个二叉树遍历的性质。

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

但要注意了,已知前序和后序遍历,是不能确定一棵二叉树的,原因也很简单,比如前序序列是ABC,后序序列是CBA。我们可以确定A一定是根结点,但接下来,我们无法知道,哪个结点是左子树,哪个是右子树。这棵树可能有如下图所示的四种可能。

8 二叉树的建立

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

有了这样的准备,我们就可以来看看如何生成一棵二叉树了。假设二叉树的结点均为一个字符,我们把刚才前序遍历序列AB#D##C##用键盘挨个输入。实现的代码如下:

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

其实建立二叉树,也是利用了递归的原理。只不过在原来应该是打印结点的地方,改成了生成结点、给结点赋值的操作而已。所以大家理解了前面的遍历的话,对于这段代码就不难理解了。

9 线索二叉树

9.1 线索二叉树原理

我们现在提倡节约型社会,一切都应该节约为本。对待我们的程序当然也不例外,能不浪费的时间或空间,都应该考虑节省。我们再来观察下图,会发现指针域并不是都充分的利用了,有许许多多的"∧",也就是空指针域的存在,这实在不是好现象,应该要想办法利用起来。

首先我们要来看看这空指针有多少个呢?对于一个有n个结点的二叉链表,每个结点有指向左右孩子的两个指针域,所以一共是2n个指针域。
n个结点的二叉树一共有n-1条分支线数,也就是说,其实是存在2n-(n-1)=n+1个空指针域。比如上图有10个结点,而带有"∧"空指针域为11。这些空间不存储任何事物,白白的浪费着内存的资源。

另一方面,我们在做遍历时,比如对上图做中序遍历时,得到了HDIBEJAFCG这样的字符序列,遍历过后,我们可以知道,结点I的前驱是D,后继是B,结点F的前驱是A,后继是C。也就是说,我们可以很清楚的知道任意一个结点,它的前驱和后继是哪一个。
可是这是建立在已经遍历过的基础之上的。在二叉链表上,我们只能知道每个结点指向其左右孩子结点的地址,而不知道某个结点的前驱是谁,后继是谁。要想知道,必须遍历一次。以后每次需要知道时,都必须先遍历一次。为什么不考虑在创建时就记住这些前驱和后继呢,那将
是多大的时间上的节省。
综合刚才两个角度的分析后,我们可以考虑利用那些空地址,存放指向结点在某种遍历次序下的前驱和后继结点的地址。就好像GPS导航仪一
样,我们开车的时候,哪怕我们对具体目的地的位置一无所知,但它每次都可以告诉我从当前位置的下一步应该走向哪里。这就是我们现在要研究的问题。我们把这种指向前驱和后继的指针称为线索,加上线索的二叉链表称为线索链表,相应的二叉树就称为线索二叉树(Threaded
Binary Tree)
请看下图,我们把这棵二叉树进行中序遍历后,将所有的空指针域中的rchild,改为指向它的后继结点。于是我们就可以通过指针知道H的后继是D(图中①),I的后继是B(图中②),J的后继是E(图中③),E的后继是A(图中④),F的后继是C(图中⑤),G的后继因为不存在而指向NULL(图中⑥)。此时共有6个空指针域被利用。

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

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

不过好事总是多磨的,问题并没有彻底解决。我们如何知道某一结点的lchild是指向它的左孩子还是指向前驱?rchild是指向右孩子还是指向后继?比如E结点的lchild是指向它的左孩子J,而rchild却是指向它的后继A。显然我们在决定lchild是指向左孩子还是前驱,rchild是指向右孩子还是后继上是需要一个区分标志的。

因此,我们在每个结点再增设两个标志域ltagrtag,注意ltagrtag只是存放0或1数字的布尔型变量,其占用的内存空间要小于像lchildrchild的指针变量。结点结构如下图所示。

其中:

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

因此对于上面的二叉链表图可以修改为下图的样子。

9.2 线索二叉树的结构实现

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

// 二叉树的二叉线索存储结构定义
// Link==0表示指向左右孩子指针
// Thread==1表示指向前驱或后继的线索
typedef enum {Link, Thread} PointerTag;
// 二叉线索存储结点结构
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;
            // 前驱右孩子指针指向后继(当前结点p)
            pre -> rchild = 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,以便于下一次使用。
有了线索二叉树后,我们对它进行遍历时发现,其实就等于是操作一个双向链表结构。

和双向链表结构一样,在二叉树线索链表上添加一个头结点,如下图所示,并令其lchild域的指针指向二叉树的根结点(图中的①),其rchild域的指针指向中序遍历时访问的最后一个结点(图中的②)。

反之,令二叉树的中序序列中的第一个结点中,lchild域指针和最后一个结点的rchild域指针均指向头结点(图中的③和④)。这样定义的好处就是我们既可以从第一个结点起顺后继进行遍历,也可以从最后一个结点起顺前驱进行遍历。

遍历的代码如下:

// T指向头结点,头结点左链lchild指向根结点,头结点右链rchild指向中序遍历的
// 最后一个结点。中序遍历二叉线索链表表示的二叉树T
Status InOrderTraverse_Thr(BiThrTree T)
{
    BiThrTree p;
    // p指向根结点
    p = T -> lchild;
    // 空树或遍历结束时,p==T
    while (p != T)
    {
        // 当LTag==0时循环到中序序列第一个结点
        while (p -> LTag == Link)
        	p = p -> lchild;
        // 显示结点数据,可以更改为其他对结点操作
        printf("%c", p -> data);
        while (p -> RTag == Thread && p -> rchild != T)
        {
            p = p->rchild;
            printf("%c", p -> data);
        }
        // p进至其右子树根
        p = p -> rchild;
    }
    return OK;
}
  1. 代码中,第4行,p=T->lchild;意思就是上图中的①,让p指向根结点开始遍历。
  2. 第5~16行,while(p!=T)其实意思就是循环直到图中的④的出现,此时意味着p指向了头结点,于是与T相等(T是指向头结点的指针),结束循环,否则一直循环下去进行遍历操作。
  3. 第7~8行,while(p->LTag==Link)这个循环,就是由A→B→D→H,此时H结点的LTag不是Link(就是不等于0),所以结束此循环。
  4. 第9行,打印H。
  5. 第10~14行,while(p->RTag==Thread&&p->rchild!=T),由于结点H的RTag==Thread(就是等于1),且不是指向头结点。因此打印H的后继D,之后因为D的RTag是Link,因此退出循环。
  6. 第15行,p=p->rchild;意味着p指向了结点D的右孩子I。7.……,就这样不断循环遍历,路径参照上面的线索链路图,直到打印出HDIBJEAFCG,结束遍历操作。

从这段代码也可以看出,它等于是一个链表的扫描,所以时间复杂度为O(n)

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

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

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

10.1 树转换为二叉树

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

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

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

10.2 森林转换为二叉树

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

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

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

10.3 二叉树转换为树

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

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

10.4 二叉树转换为森林

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

那么如果是转换成森林,步骤如下:

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

10.5 树与森林的遍历

最后我们再谈一谈关于树和森林的遍历问题。

树的遍历分为两种方式。

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

比如上上图中右下方的树,它的先根遍历序列为ABEFCDG,后根遍历序列为EFBCGDA。

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

  • 前序遍历:先访问森林中第一棵树的根结点,然后再依次先根遍历根的每棵子树,再依次用同样方式遍历除去第一棵树的剩余树构成的森林。比如上图下面三棵树的森林, 前序遍历序列的结果就是ABCDEFGHJI。
  • 后序遍历:是先访问森林中第一棵树,后根遍历的方式遍历每棵子树,然后再访问根结点,再依次同样方式遍历除去第一棵树的剩余树构成的森林。比如上图下面三棵树的森林,后序遍历序列的结果就是BCDAFEJHIG。

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

11 赫夫曼树(霍夫曼树/哈夫曼树/最优树/最优二叉树)及其应用

11.1 赫夫曼树

压缩文件而不出错是如何做到的呢?简单说,就是把我们要压缩的文本进行重新编码,以减少不必要的空间。尽管现在最新技术在编码上已经很好很强大,但这一切都来自于曾经的技术积累,我们今天就来介绍一下最基本的压缩编码方法——赫夫曼编码

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

什么叫做赫夫曼树呢?我们先来看一个例子。过去我们小学、中学一般考试都是用百分制来表示学科成绩的。这带来了一个弊端,就是很容易让学生、家长,甚至老师自己都以分取人,让分数代表了一切。有时想想也对,90分和95分也许就只是一道题目对错的差距,但却让两个孩子可能受到完全不同的待遇,这并不公平。于是在如今提倡素质教育的背景下,我们很多的学科,特别是小学的学科成绩都改作了优秀、良好、中等、及格和不及格这样模糊的词语,不再通报具体的分数。
不过对于老师来讲,他在对试卷评分的时候,显然不能凭感觉给优良或及格不及格等成绩,因此一般都还是按照百分制算出每个学生的成绩后,再根据统一的标准换算得出五级分制的成绩。比如下面的代码就实现了这样的转换。

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

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

如果在实际的学习生活中,学生的成绩在5个等级上的分布规律大致如下表所示。

分数 0-59 60-69 70-79 80-89 90-100
所占比例 5% 15% 40% 30% 10%

那么70分以上大约占总数80%的成绩都需要经过3次以上的判断才可以得到结果,这显然不合理。
有没有好一些的办法,仔细观察发现,中等成绩(70~79分之间)比例最高,其次是良好成绩,不及格的所占比例最少。我们把上图这棵二叉树重新进行分配。 改成如下图的做法试试看。

从图中感觉,应该效率要高一些了,到底高多少呢。这样的二叉树又是如何设计出来的呢?我们来看看赫夫曼大叔是如何说的吧。

11.2 赫夫曼树定义与原理

我们先把这两棵二叉树简化成叶子结点带权的二叉树(注:树结点间的边相关的数叫做权Weight) ,如下图所示。其中A表示不及格、B表示及格、C表示中等、D表示良好、E表示优秀。每个叶子的分支线上的数字就是刚才我们提到的五级分制的成绩所占百分比。

赫夫曼大叔说,从树中一个结点到另一个结点之间的分支构成两个结点之间的路径,路径上的分支数目称做路径长度。上图的二叉树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[n]},构造一棵有n个叶子结点的二叉树,每个叶子结点带权w[k],每个叶子的路径长度为l[k],我们通常记作,则其中带权路径长度WPL最小的二叉树称做赫夫曼树。
有不少书中也称为最优二叉树,我个人觉得为了纪念做出巨大贡献的科学家,既然用他们的名字命名,就应该要坚持用他们的名字称呼, 哪怕“最优”更能体现这棵树的品质也应该只作为别名。
有了赫夫曼对带权路径长度的定义,我们来计算一下上图这两棵树的WPL值。
二叉树a的WPL=5×1+15×2+40×3+30×4+10×4=315
注意:这里5是A结点的权,1是A结点的路径长度, 其他同理。
二叉树b的WPL=5×3+15×3+40×2+30×2+10×2=220
这样的结果意味着什么呢?如果我们现在有10000个学生的百分制成绩需要计算五级分制成绩,用二叉树a的判断方法,需要做31500次比较,而二叉树b的判断方法,只需要22000次比较,差不多少了三分之一量,在性能上提高不是一点点。
那么现在的问题就是,上图中的二叉树b这样的树是如何构造出来的,这样的二叉树是不是就是最优的赫夫曼树呢?别急,赫夫曼大叔给了我们解决的办法。

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

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

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

  4. 重复步骤2。将N[1]与B作为一个新节点N[2]的两个子结点。如下图所示。 N[2]的权值=15+15=30。

  5. 将N[2]替换N[1]与B,插入有序序列中,保持从小到大排列。即:N[2]30, D30, C40。

  6. 重复步骤2。将N[2]与D作为一个新节点N[3]的两个子结点。如下图所示。N[3]的权值=30+30=60。

  7. 将N[3]替换N[2]与D,插入有序序列中,保持从小到大排列。即:C40,N[3]60。

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

此时的上图二叉树的带权路径长度WPL=40×1+30×2+15×3+10×4+5×4=205。 与上上上上上图的二叉树b的WPL值220相比,还少了15。显然此时构造出来的二叉树才是最优的赫夫曼树。
不过现实总是比理想要复杂得多,上图虽然是赫夫曼树,但由于每次判断都要两次比较(如根结点就是a<80&&a>=70,两次比较才能得到y或n的结果),所以总体性能上,反而不如上上上上上上图的二叉树性能高。当然这并不是我们要讨论的重点了。
通过刚才的步骤, 我们可以得出构造赫夫曼树的赫夫曼算法描述。

  1. 根据给定的n个权值{w[1],w[2],...,w[n]}构成n棵二叉树的集合F={T[1],T[2],...,T[n]},其中每棵二叉树T[i]中只有一个带权为w[i]的根结点,其左右子
    树均为空。
  2. F中选取两棵根结点的权值最小的树作为左右子树构造一棵新的二叉树,且置新的二叉树的根结点的权值为其左右子树上根结点的权值之和。
  3. 在F中删除这两棵树,同时将新得到的二叉树加入F中。
  4. 重复2和3步骤,直到F只含一棵树为止。这棵树便是赫夫曼树。

11.3 赫夫曼编码

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

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

这样真正传输的数据就是编码后的"001000011010000011101100100011",对方接收时可以按照3位一分来译码。如果一篇文章很长,这样的二进制串也将非常的可怕。而且事实上,不管是英文、中文或是其他语言,字母或汉字的出现频率是不相同的,比如英语中的几个元音字母"ae i o u", 中文中的“的 了 有 在”等汉字都是频率极高。
假设六个字母的频率为A 27,B 8,C 15,D 15,E 30,F 5,合起来正好是100%。那就意味着,我们完全可以重新按照赫夫曼树来规划它们。
下图左图为构造赫夫曼树的过程的权值显示。右图为将权值左分支改为0,右分支改为1后的赫夫曼树。

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

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

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

也就是说,我们的数据被压缩了,节约了大约17%的存储或传输成本。随着字符的增加和多字符权重的不同,这种压缩会更加显出其优势。
当我们接收到1001010010101001000111100这样压缩过的新编码时,我们应该如何把它解码出来呢?
编码中非0即1,长短不等的话其实是很容易混淆的,所以若要设计长短不等的编码,则必须是任一字符的编码都不是另一个字符的编码的前缀,这种编码称做前缀编码。
你仔细观察就会发现, 上图中的编码就不存在容易与10011000混淆的“10”和“100”编码。
可仅仅是这样不足以让我们去方便地解码的,因此在解码时,还是要用到赫夫曼树,即发送方和接收方必须要约定好同样的赫夫曼编码规则。
当我们接收到1001010010101001000111100时,由约定好的赫夫曼树可知,1001得到第一个字母是B,接下来01意味着第二个字符是A, 如下图所示,其余的也相应的可以得到,从而成功解码。

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

12 总结回顾

终于到了总结的时间,这一章与前面章节相比,显得过于庞大了些,原因也就在于树的复杂性和变化丰富度是前面的线性表所不可比拟的。即使在本章之后,我们还要讲解关于树这一数据结构的相关知识,可见它的重要性。
开头我们提到了树的定义,讲到了递归在树定义中的应用。提到了如子树、结点、度、叶子、分支结点、双亲、孩子、层次、深度、森林等诸多概念,这些都是需要在理解的基础上去记忆的。
我们谈到了树的存储结构时,讲了双亲表示法、孩子表示法、孩子兄弟表示法等不同的存储结构。
并由孩子兄弟表示法引出了我们这章中最重要一种树,二叉树。
二叉树每个结点最多两棵子树,有左右之分。提到了斜树满二叉树完全二叉树等特殊二叉树的概念。
我们接着谈到它的各种性质,这些性质给我们研究二叉树带来了方便。
二叉树的存储结构由于其特殊性使得既可以用顺序存储结构又可以用链式存储结构表示。
遍历是二叉树最重要的一门学问,前序、中序、后序以及层序遍历都是需要熟练掌握的知识。 要让自己要学会用计算机的运行思维去模拟递归的实现,可以加深我们对递归的理解。不过,并非二叉树遍历就一定要用到递归,只不过递归的实现比较优雅而已。这点需要明确。
二叉树的建立自然也是可以通过递归来实现。
研究中也发现,二叉链表有很多浪费的空指针可以利用,查找某个结点的前驱和后继为什么非要每次遍历才可以得到, 这就引出了如何构造一棵线索二叉树的问题。线索二叉树给二叉树的结点查找和遍历带来了高效率。
树、森林看似复杂,其实它们都可以转化为简单的二叉树来处理,我们提供了树、森林与二叉树的互相转换的办法,这样就使得面对树和森林的数据结构时,编码实现成为了可能。
最后,我们提到了关于二叉树的一个应用,赫夫曼树和赫夫曼编码,对于带权路径的二叉树做了详尽地讲述,让你初步理解数据压缩的原理,并明白其是如何做到无损编码和无错解码的。

posted @   JapserTang  阅读(367)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!
Live2D
欢迎阅读『大话数据结构学习笔记(六)——树』
点击右上角即可分享
微信分享提示