《大话数据结构》读书笔记-树

写在前面:本文仅供个人学习使用。《大话数据结构》通俗易懂,适合整体做笔记输出,构建体系。并且文中很多图片来源于该书,如有侵权,请联系删除。

6.2 树的定义

之前我们一直在谈论的是一对一的线性结构,可现实中,还有很多一对多的情况需要处理,所以我们需要研究这种一对多的数据结构–“树”,考虑它的各种特性,来解决我们在编程中遇到的问题。

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

在这里插入图片描述

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

在这里插入图片描述

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

1 n>0时根结点是唯一的,不可能存在多个根结点,别和现实中的大树混在一起,现实中的树有很多根须,那是真实的树,数据结构中的树是只能有一个根结点。

2 m>0时,子树的个数没有限制,但它们一定是互不相交的。像图6-2-3中的两个结构就不符合树的定义,因为 它们都有相交的子树。

在这里插入图片描述

6.2.1 结点分类

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

在这里插入图片描述

6.2.2 结点间关系

结点的子树的根称为该结点的孩子(Child),相应地,该结点称为孩子的双亲(Parent)。同一个双亲的孩子之间互称兄弟(Sibling)。 结点的祖先是从根到该结点所经分支上的所有结点。

在这里插入图片描述
对于H来说,D,B,A都是它的祖先。反之,以某结点为根的子树中的任一结点都称为该结点的子孙。B的子孙有D,G,H,I。

6.2.3 树的其他相关概念

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

在这里插入图片描述

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

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

对比线性表和树的结构,它们有很大的不同。

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

6.3 树的抽象数据类型

相对于线性结构,树的操作就完全不同了,这里我们给出一些基本和常用的操作。

ADT  树(Tree)

Data
	树是由一个根结点和若干颗子树构成的。树中结点具有相同数据类型及层次关系
Operation
	InitTree(*T) :构造空树
	DestroyTree(*T):销毁树T

	CreateTree(*T, definition) :按照definition中给出的树的定义来构造树

	ClearTree(*T):若树T存在,则将树T清空
	TreeEmpty(T):若T为空树,返回true,否则返回false。
	TreeDepth(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	

6.4 树的存储结构

说到存储结构,就会想到我们前面章节讲过的顺序存储和链式存储两种结构。

先来看看顺序存储结构,用一段连续的存储单元依次存储线性表的数据元素。这对于线性表来说是很自然的,对于树这样一对多的结构呢?

树中某个结点的孩子可以有多个,这就意味着,无论按何种顺序将树中所有结点存储到数组中,结点的存储位置都无法反映逻辑关系,你想想看,数据元素挨个的存储,谁是谁的双亲,谁是谁的孩子呢?简单的顺序存储结构式不能满足树的实现要求的。

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

6.4.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,这也就意味着,我们所有的结点都存有它双亲的位置。 如图6-4-1中的树结构和表6-4-2中的树双亲表示所示。

在这里插入图片描述

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

这真是麻烦,能不能改进一下呢?

当然可以。我们增加一个结点最左边孩子的域,不妨叫它长子域,这样就可以很容易得到结点的孩子。如果没有孩子的结点,这个长子域就设置为-1,如下表所示。
在这里插入图片描述

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

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

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

6.4.2 孩子表示法

换一种完全不同的考虑方法。由于树中每个结点可能有多棵子树,可以考虑用多重链表,即每个结点有多个指针域,其中每个指针指向一棵子树的根结点,我们把这种方法叫做多重链表表示法

方案一

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

在这里插入图片描述

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

对于图6-4-1的树来说,树的度是3,所以我们指针域的个数是3,这种方法实现如下图所示。(读书思考:我怎么感觉这里有问题呢?不是3个指针域吗?为什么是4个格子呢?难道最后一个另有他用?)

在这里插入图片描述

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

既然很多指针域都可能为空,为什么不按需分配空间呢。于是我们有了第二种方案。

方案二

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

在这里插入图片描述

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

对于图6-4-2的树来说,这种方法实现如图所示。

在这里插入图片描述

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

能 否 有 更 好 的 办 法 , 既 可 以 减 少 空 指 针 的 浪 费 又 能 使 结 点 结 构 相 同 。 \color{green}{能否有更好的办法,既可以减少空指针的浪费又能使结点结构相同。} 使

仔细观察,我们为了要遍历整棵树,把每个结点放到一个顺序存储结构的数组中是合理的,但每个结点的孩子有多少是不确定的,所以我们再对每个结点的孩子建立一个单链表来体现它们的关系。

这就是我们要讲的孩子表示法。具体办法是,把每个结点的孩子节点排列起来,以单链表作为存储结构,则n个结点有n个孩子链表,如果是叶子结点则此单链表为空。然后n个头指针又组成一个线性表,采用顺序存储结构,存放进一个一维数组中,如图6-4-4所示。

在这里插入图片描述

为此,设计两种结点结构,一个是孩子链表的孩子结点,如表6-4-7所示。

在这里插入图片描述
其中child是数据域,用来存储某个结点在表头数组中的下标。next是指针域,用来存储指向某结点的下一个孩子结点的指针。

另一个是表头数组的表头结点,如下表所示。

在这里插入图片描述
其中data是数据域,存储某结点的数据信息。firstchild是头指针域,存储该结点的孩子链表的头指针。

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

//树的孩子表示法结构定义

#define MAX_TREE_SEZE 1000

//孩子结点
typedef struct CTNode{
	int child;
	struct CTNode *next;
}*ChildPtr;

//表头结构
typedef struct{
	TELemType data;
	ChildPtr firstchild;
}CTBox;

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

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

但是,这也存在着问题, 我 如 何 知 道 某 个 结 点 的 双 亲 是 谁 呢 ? \color{green}{我如何知道某个结点的双亲是谁呢?} 比较麻烦,需要整棵树遍历才行,难道就不可以把双亲表示法和孩子表示法综合一下吗?当然是可以。如图6-4-5所示。

在这里插入图片描述

我们把这种方法称为双亲孩子表示法,应该算是孩子表示法的改进。至于这个表示法的具体结构定义,这里就略过,留给同学们自己去设计了。

6.4.3 孩子兄弟表示法

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

结点结构如表6-4-9所示

在这里插入图片描述

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

结构定义代码如下

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

对于图6-4-1的树来说,这种方法实现的示意图如图6-4-6所示。
在这里插入图片描述

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

呵呵,对,如果真的有必要,完全可以再增加一个parent指针域来解决快速查找双亲的问题,这里就不再细谈了。

其实这个表示法的最大好处是它把一棵复杂的树变成了一棵二叉树。我们把图6-4-6变形就成了图6-4-7的样子。

在这里插入图片描述

这样就可以充分利用二叉树的特性和算法来处理这棵树了。嗯?有人问,二叉树是什么?哈哈,别急,这正是我接下来要重点讲的内容。

6.5 二叉树的定义

现在我们来做个游戏,我在纸上写好了一个100以内的正整数数字,请大家想办法猜出我写的是哪一个?注意你们猜的数字不能超过7个,我只会告诉你是“大了”还是“小了”。

这个游戏在一些电视节目中,猜测一些商品的定价时常会使用。我看到过有些人是一点一点的数字累加的,比如5、10、15、20这样猜,这样的猜数策略太低级了,显然是没有学过数据结构和算法的人才做得出的事。

其实这是一个很经典的折半查找算法。 如果我们用图6-5-1(下三层忽略)的办法,就一定能在7次以内,猜出结果来。

在这里插入图片描述

由于是100以内的正整数,我们就先猜50(100的一半),然后再一半一半地猜。过程如表所示。
在这里插入图片描述

我们发现,如果用这种方式进行查找,效率高得不是一点点。对于折半查找的详细讲解,我们后面章节再说。不过对于这种在某个阶段都是两种结果的情形,比如开和关、0和1、真和假、上和下、对与错,正面与反面等,都适合用树状结构来建模,而这种树是一种很特殊的树状结构,叫做二叉树。

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

图6-5-2就是一棵二叉树,而图6-2-1的树,因为D结点有三个子树,所以它不是二叉树。

在这里插入图片描述

6.5.1 二叉树特点

二叉树的特点有

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

在这里插入图片描述

二叉树具有五种基本形态:
1 空二叉树
2 只有一个根结点
3根结点只有左子树
4 根结点只有右子树
5 根结点既有左子树又有右子树

应该说这五种形态还是比较好理解的,那我现在问大家, 如 果 是 有 3 个 结 点 的 树 , 有 几 种 形 态 ? 如 果 是 有 三 个 结 点 的 二 叉 树 , 考 虑 一 下 , 又 有 几 种 形 态 ? \color{green}{如果是有3个结点的树,有几种形态? 如果是有三个结点的二叉树,考虑一下,又有几种形态?} 3

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

6.5.2 特殊二叉树

我们再来介绍一些特殊的二叉树。这些树可能暂时你不能理解它有什么用处,但先了解一下,以后会提到它们的实际用途。

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

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

2 满二叉树

通常举的例子都是左高右低、参差不齐的二叉树。那是否存在完美的二叉树呢?

对的,完美的二叉树是存在的。

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

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

在这里插入图片描述

单是每个结点都存在左右子树,不能算是满二叉树,还必须要所有的叶子都在同一层上,这就做到了整棵树的平衡。因此,满二叉树的特点有:
(1)叶子结点只能出现在最下层。出现在其他层就不可能达到平衡。
(2)非叶子结点的度一定是2.否则就是“缺胳膊少腿”了。
(3)在同样深度的二叉树中,满二叉树的结点个数最多,叶子数最多

3 完全二叉树

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

这是一种理解起来有些难度的特殊二叉树。

首先从字面上要区分,“完全”和“满”的差异,满二叉树一定是一棵完全二叉树,但完全二叉树不一定是满的。

其次,完全二叉树的所有结点与同样深度的满二叉树,它们按层序编号相同的结点,是一一对应的。这里有个关键词是按层序编号,像图6-5-7中的树1,因为5结点没有左子树,却又右子树,那就使得按层序编号的第10个编号空档了。

在这里插入图片描述

同样道理,图6-5-7中的树2,由于3结点没有子树,所以使得6、7编号的位置空档了。图6-5-7中的树3又是因为5编号下面没有子树造成第10和11位置空档。只有图6-5-6中的树, 尽 管 它 不 是 满 二 叉 树 , 但 是 编 号 是 连 续 的 , 所 以 它 是 完 全 二 叉 树 \color{red}{尽管它不是满二叉树,但是编号是连续的,所以它是完全二叉树}

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

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

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

6.6 二叉树的性质

二叉树有一些需要理解并且记住的特性,以便于我们更好地使用它。

6.6.1 二叉树性质1

性 质 1 : 在 二 叉 树 的 第 i 层 上 至 多 有 2 i − 1 个 结 点 。 \color{red}{性质1: 在二叉树的第 i层上至多有2^{i-1}个结点。} 1i2i1

这个性质很好记忆,观察一下图6-5-5.

在这里插入图片描述

6.6.2 二叉树性质2

性 质 2 : 深 度 为 k 的 二 叉 树 至 多 有 2 k − 1 个 结 点 。 \color{red}{性质2:深度为k的二叉树至多有2^{k}-1个结点。} 2k2k1
深度为k意思就是有k层的二叉树,我们先来看简单的。

如果有1层,至多有 1 = 2 1 − 1 1=2^{1}-1 1=211个结点
如果有2层,至多有 3 = 1 + 2 = 2 2 − 1 3=1+2=2^{2}-1 3=1+2=221个结点
如果有3层,至多有 7 = 1 + 2 + 4 = 2 3 − 1 7=1+2+4=2^{3}-1 7=1+2+4=231个结点

如果有4层,至多有 15 = 1 + 2 + 4 + 8 = 2 4 − 1 15=1+2+4+8=2^{4}-1 15=1+2+4+8=241个结点

通过数据归纳,可以得出,如果有k层,此二叉树至多有 2 k − 1 2^{k}-1 2k1个结点。

6.6.3 二叉树性质3

性 质 3 : 对 任 何 一 棵 非 空 二 叉 树 T , 如 果 其 叶 子 结 点 数 为 n 0 , 度 为 2 的 结 点 数 为 n 2 , 则 n 0 = n 2 + 1. \color{red}{性质3:对任何一棵非空二叉树T,如果其叶子结点数为n_0,度为2的结点数为n_2,则n_0=n_2+1.} 3Tn02n2,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+5+1=10.

在这里插入图片描述

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

用代数表达就是分支线总数= n − 1 = n 1 + 2 n 2 n-1=n_1+2n_2 n1=n1+2n2,因为我们有等式 n = n 0 + n 1 + n 2 n=n_0+n_1+n_2 n=n0+n1+n2,所以可以推导出 n 0 + n 1 + n 2 − 1 = n 1 + 2 n 2 n_0+n_1+n_2-1=n_1+2n_2 n0+n1+n21=n1+2n2.结论就是 n 0 = n 2 + 1 n_0=n_2+1 n0=n2+1.

6.6.4 二叉树性质4

性 质 4 : 具 有 n 个 结 点 的 完 全 二 叉 树 的 深 度 为 ⌊ l o g 2 n ⌋ + 1. \color{red}{性质4:具有n个结点的完全二叉树的深度为\lfloor log_2n \rfloor +1 .} 4nlog2n+1.

6.6.5 二叉树性质5

性 质 5 : 如 果 对 一 棵 有 n 个 结 点 的 完 全 二 叉 树 ( 其 深 度 为 ⌊ l o g 2 n ⌋ + 1 ) 的 结 点 按 层 序 编 号 ( 从 第 1 层 到 第 ⌊ l o g 2 n ⌋ + 1 层 , 每 层 从 左 到 右 ) , 对 任 一 结 点 i 有 : 性质5:如果对一棵有n个结点的完全二叉树(其深度为\lfloor log_2n \rfloor +1)的结点按层序编号(从第1层到第\lfloor log_2n \rfloor +1层,每层从左到右),对任一结点i 有: 5nlog2n+11log2n+1i

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

我们以图6-6-2为例,来理解这个性质。这是一个完全二叉树,深度为4,节结点总数是10.

在这里插入图片描述

对于第一条来说是显然的, i=1时就是根结点。i大于1时,比如结点7,它的双亲就是 ⌊ 7 / 2 ⌋ = 3 \lfloor 7/2 \rfloor=3 7/2=3,再比如结点9,它的双亲编号为 ⌊ 9 / 2 ⌋ \lfloor 9/2 \rfloor 9/2=4.

第二条,比如结点6,因为26=12超过了结点总数10,所以结点6没有左孩子,它是叶子结点。同样,而结点5,因为25=10正好是结点总数10,所以它的左孩子是结点10.

第三条,比如结点5,因为25+1=11,大于结点总数10,所以它无右孩子。而结点3,因为23+1=7小于10,所以它的右孩子是结点7.

6.7 二叉树的存储结构

6.7.1 二叉树顺序存储结构

前面我们谈了树的存储结构,并且谈到顺序存储结构对树这种一对多的关系结构实现起来是比较困难的。但是二叉树是一种特殊的树,由于它的特殊性,使得用顺序存储结构也可以实现。

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

先来看看完全二叉树的顺序存储,一棵完全二叉树如图6-7-1所示。

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

在这里插入图片描述
这下看出来完全二叉树的优越性了吧。由于它定义的严格,所以用顺序结构也可以表现出二叉树的结构来。

当然对于一般的二叉树,尽管层序编号不能反映逻辑关系,但是可以将其按完全二叉树编号,只不过,把不存在的结点设置为“^”而已。如下图(标号为浅色表示不存在)
在这里插入图片描述

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

在这里插入图片描述

6.7.2 二叉链表

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

在这里插入图片描述

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

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

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

结构示意图如下图所示

在这里插入图片描述

就如同树的存储结构中讨论的一样,如果有需要,还可以再增加一个指向其双亲的指针域,那样就称为三叉链表。由于与树的存储结构类似,这里就不赘述了。

6.8 遍历二叉树

6.8.1 二叉树遍历原理

假设,我手头有20张100元的和2000张1元的奖券,同时洒向了空中,大家比赛看谁最终捡的最多。如果是你,你会怎么做?

相信所有的同学都会说,一定先捡100 元的。道理非常简单,因为捡1张100元的等于1元的捡100张,效率好得不是一点半点。所以可以得到这样的结论,同样是捡奖券,在有限的时间内,要达到最高的效率,次序非常重要。对于二叉树的遍历来说,次序同样显得非常重要。

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

这里有两个关键词:访问和次序。

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

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

在这里插入图片描述

6.8.2 二叉树遍历方法

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

1 前序遍历

规则是如果二叉树为空,则返回空;否则先返回根结点,然后前序遍历左子树,再前序遍历右子树。如图6-8-2所示,遍历的顺序为:ABDGHGEIF

在这里插入图片描述

2 中序遍历

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

在这里插入图片描述

3 后序遍历

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

在这里插入图片描述

注:所谓前序遍历、中序遍历、后序遍历,这里的前中后,指的是根结点的遍历时间,先序遍历就是先遍历根结点;中序遍历就是第二个遍历根结点;后序遍历指的是最后遍历根结点。

4 层序遍历

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

在这里插入图片描述

有同学会说,研究这么多遍历的方法干什么?

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

另外不同的遍历提供了对结点依次处理的不同方式,可以在遍历过程中对结点进行各种处理。

6.8.3 前序遍历算法

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

void PreOrderTraverse(BiTree T){
	 if(T==NULL)  return ;
	 printf("%c",T->data);//打印结点数据,可以更换为其他操作
	 PreOrderTraverse(T->lchild);//再先序遍历左子树
	 PreOrderTraverse(T->rchild);//再先序遍历右子树
	
}

假设我们现在有如图6-8-6这样一棵二叉树T。这树已经用二叉链表结构存储在内存中。

在这里插入图片描述
那么当调用PreOrderTraverse( T) 函数时,我们来看看程序时如何运行的。

1 调用PreOrderTraverse( T),T根结点不为NULL,所以执行printf,打印字母A。

在这里插入图片描述

2 调用 PreOrderTraverse(T->lchild);访问了A结点的左孩子,不为NULL,执行printf显示字母B,如下图
在这里插入图片描述
3 此时再次递归调用PreOrderTraverse(T->lchild); 访问了B结点的左孩子,执行printf显示字母D,如下图所示
在这里插入图片描述

4 再次递归调用PreOrderTraverse(T->lchild);访问了D结点的左孩子,执行printf显示字母H,如下图所示

在这里插入图片描述
5 再次递归调用PreOrderTraverse(T->lchild);访问了H结点的左孩子,此时因为H结点无左孩子,所示T==NULL,返回此函数,此时递归调用PreOrderTraverse(T->rchild);访问了H结点的右孩子,printf显示字母K,如图所示
在这里插入图片描述

6 再次递归调用PreOrderTraverse(T->lchild);访问K结点的左孩子,K结点无左孩子,返回,调用PreOrderTraverse(T->rchild);访问K结点的右孩子,K结点同样也没有右孩子,也是NULL,返回。于是此函数执行完毕,返回到上一级递归的函数(即打印H结点时的函数),也执行完毕,返回到打印结点D时的函数,调用PreOrderTraverse(T->rchild);访问D结点的右孩子,不存在,返回到B结点,调用PreOrderTraverse(T->rchild);找到了结点E,打印字母E,如下图

在这里插入图片描述
7 由于结点E没有左右孩子,返回打印结点B时的递归函数,递归执行完毕,返回到最初的PreOrderTraverse,调用PreOrderTraverse(T->rchild);访问A结点的右孩子,打印字母C,如图所示
在这里插入图片描述

8 之后类似前面的操作,依次继续打印F,I,G,J,步骤略。

综上,前序遍这棵二叉树的结点顺序是:ABDHKECFIGJ.

6.8.4 中序遍历算法

那么二叉树的中序遍历算法如何呢?哈哈,别以为很复杂,它和前序遍历算法仅仅只是代码的顺序上的差异。

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

换句话说,它等于把调用左孩子的递归函数提前了,就这么简单。

我们来看看当调用InOrderTraverse( T) 函数时,程序是如何运行的。

1 调用InOrderTraverse( T) ,T的根结点不为NULL,于是调用InOrderTraverse( T->lchild);访问结点B。当前指针不为NULL,继续调用InOrderTraverse( T->lchild);访问结点D。不为NULL,继续调用InOrderTraverse( T->lchild);访问结点H。继续调用InOrderTraverse( T->lchild);访问结点H的左孩子,发现当前指针为NULL,于是返回。打印当前结点H,如下图所示。

在这里插入图片描述
2 然后调用InOrderTraverse( T->rchild);访问结点H的右孩子K,因为结点K无左孩子,所以打印K,如下图所示。
在这里插入图片描述

3 因为结点K没有右孩子,所以返回。操作H结点的函数已经执行完毕,再次返回。返回到操作结点D的函数,打印字母D,如下图所示。

在这里插入图片描述

4 结点D无右孩子,此函数执行完毕,返回。返回到操作B结点的函数,打印字母B,如下图所示。
在这里插入图片描述
5 调用InOrderTraverse( T->rchild);访问结点B的右孩子E,因结点E没有左孩子,所以打印E,如下图所示。

在这里插入图片描述
6 结点E无右孩子,返回。返回到操作结点B的函数,此时该函数执行完毕,返回到最初我们调用InOrderTraverse的地方,打印字母A,如下图所示。

在这里插入图片描述
7 再调用InOrderTraverse(T->rchild);访问结点A的右孩子C,再递归访问C的左孩子F,结点F的左孩子I。因为I无左孩子,打印I,之后分别打印F,C,G,J。步骤省略。

综上,中序遍历这棵二叉树的结点顺序是HKDBEAIFCGJ.

6.8.5 后序遍历算法

那么同样的,后序遍历也就很容易想到如何写代码了。

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

1 如下图,后序遍历是先递归左子树,由根结点A→B→D→H,结点H无左孩子,再查看结点H的右孩子K,因为结点K无左右孩子,所以打印K,返回。

在这里插入图片描述

2 打印H。操作H的函数已经操作完毕。

3 H返回,D没有右孩子,所以打印D。

4 D返回,B处理右孩子----结点E,结点E没有左孩子、右孩子,所以打印E。
5 E返回,打印B。操作B 的函数已经执行完毕。

6 B返回,回到最初调用PostOrderTraverse(T)的地方,开始调用PostOrderTraverse(T->rchild); 开始遍历结点C,对C一路查看左孩子,依次遍历F→I,由于I没有左孩子、右孩子,所以打印I。

7 I返回,来到操作结点F的函数,由于F也没有右孩子,所以打印F。

8 F返回,来到操作C结点的函数,执行PostOrderTraverse(T->rchild); 来到结点G,由于结点G没有左孩子,执行PostOrderTraverse(T->rchild); 来到结点J。由于结点J没有左孩子、右孩子,打印字母J。

9 J返回,来到操作结点G的函数,打印G。

10 G返回,来到操作结点C的函数,打印C。

11,C返回,来到操作结点A的函数,打印A。

综上,后序遍历的结点顺序就是:KHDEBIFJGCA. 牢记:对于后序遍历中的每个结点,都是先左孩子,再右孩子,最后是自己。

6.8.6 推导遍历结果

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

对于这样的题目,如果真的完全理解了前中后序的原理,是不难的。

三种遍历都是从根结点开始,前序遍历是先打印再递归左和右。所以前序遍历序列为ABCDEF,第一个字母A就是根结点的数据。再由中序遍历序列是CBAEDF,可以知道C和B是A的左子树的结点,E,D,F是A的右子树的结点,如下图所示。

在这里插入图片描述

然后我们看到前序中C和B,它的顺序是ABCDEF,是先打印B后打印C,所以C是B的孩子,那么B就只能是A的左孩子。但是C是B左孩子还是右孩子是不确定的。再看中序序列是CBAEDF,C是在B前面打印,这就说明C是B的左孩子,否则就是右孩子了(这当然是不对的),如下图所示。

在这里插入图片描述

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

在这里插入图片描述

为了避免推导中的失误,你最好在心里递归遍历,检查一下这棵树的前序和中序遍历序列是否与题目中的相同。

已经复原了二叉树,要获得它的后序遍历结果简直易如反掌,结果是CBEFDA.

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

反过来,如果我们的题目是这样的:二叉树的中序序列是ABCDEFG,后序序列是BDCAFGE,求前序序列。

我的答案:EACBDGF (√)

这次简单点,由后序的BDCAFGE,得到E是根结点,因此前序首字母是E。

于是根据中序序列分为两棵树 ABCD 和FG,由后序序列 BDCA,知道A是E的左孩子,前序序列目前分析为EA.

再由中序序列的ABCD,知道BCD是A结点的右子孙,再由后序序列的BDCA知道C结点是A结点的右孩子,前序序列目前的结果是:EAC.

中序序列ABCD,得到B是C的左孩子,D是C的右孩子,所以目前前序序列为:EACBD.

由后序序列BDCAFGE,得到G是E的右孩子,于是F就是G的孩子。如果你是在考试中遇到这道题目,时间就是分数,那么你根本不许关心F是G的左孩子还是右孩子,前序序列的最终结果就是:EACBDGF

不过细细分析,根据中序序列FG,是可以得出F是G的左孩子。

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

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

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

在这里插入图片描述

6.9 二叉树的建立

说了半天,我们如何在内存中生成一棵二叉链表的二叉树呢? 树都没有,哪来遍历。所以我们还得来谈谈关于二叉树建立的问题。

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

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

//按前序输入二叉树中结点的值(一个字符)
// #表示空树,构造二叉链表表示二叉树T

void CreateBiTree(BiTree *T){
	TElemType ch;
	scanf("%c",&ch);
	if(ch=='#') *T=NULL; //T赋值称空指针NULL;复习C语言:*cp作为左值,表示cp的内容,也就是存的地址
	else{
		*T=(BiTree)malloc(sizeof(BiTNode));
		if(!*T) exit (OVERFLOW);
		(*T)->data=ch;//生成根结点
		CreateBiTree(&(*T)->lchild);//构造左子树
		CreateBiTree(&(*T)->rchild); //构造右子树
	}

}

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

当然,你完全可以用中序或后序遍历的方式实现二叉树的建立,只不过代码里生成结点和构造左右子树的代码顺序交换一下。另外,输入的字符也要做相应的更改。比如图6-9-1的扩展二叉树的中序遍历字符串就应该为#B#D#A#C#,后序遍历字符串应该为###DB##CA.

6.10 线索二叉树

6.10.1线索二叉树原理

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

在这里插入图片描述

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

另一方面,我们在做遍历时,比如对图6-10-1做中序遍历时,得到了HDIBJEAFCG这样的字符序列,遍历过后,我们可以知道,结点I的前驱是D,后继是B,结点F的前驱是A,后继是C。也就是说,我们可以很清楚地知道任意一个结点,它的前驱和后继是哪一个。

可是这是建立在已经遍历过的基础之上的。在二叉链表上,我们只能知道每个结点指向其左右孩子结点的地址,而不知道某个结点的前驱是谁,后继是谁。要想知道,必须遍历一次。以后每次需要知道时,都必须先遍历一次。为什么不考虑在创建时就记住这些前驱和后继呢,那将是多大的时间上的节省啊。

综合刚才两个角度的分析后,我们可以考虑利用那些空地址,存放指向结点在某种遍历次序下的前驱和后继结点的地址。就好像GPS导航仪一样,我们开车的时候,哪怕我们对具体目的地的位置一无所知,但它每次都可以告诉我从当前位置的下一步应该走向哪里。这就是我们现在要研究的问题。我们把这种指向前驱和后继的指针称为线索,加上线索的二叉链表称为线索链表,相应的二叉树就称为线索二叉树(Threaded Binary Tree)。

请看图6-10-2,我们把这颗二叉树进行中序遍历(HDIBJEAFCG)后,将所有的空指针域中的右孩子域rchild,改为指向它的后继结点。于是我们就可通过指针知道H的后继是D,I的后继是B,J的后继是E,E的后继是A,F的后继是C,G的后继因为不存在而指向NULL,此时共有六个空指针域被利用。
在这里插入图片描述

再来看看图6-10-3,我们将这棵二叉树的所有空指针域中左孩子域lchild,改为指向当前结点的前驱。中序遍历序列(HDIBJEAFCG)。 H的前驱是NULL,I的前驱是D,J的前驱是B,F的前驱是A,G的前驱是C。一共5个空指针域被利用,正好和上面的后继加起来是11个。

在这里插入图片描述

通过图6-·0-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.10.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的前驱
		InThread(p->rchild);//递归右子树线索化

	}

}

你会发现,模板还是中序遍历的框架,只是把显示printf替换为线索化需要的代码。

中间部分代码时做了这样的一些事。

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,以便于下一次使用。

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

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

在这里插入图片描述

遍历的代码如下

//T指向头结点,头结点左链lchild指向根结点,头结点右链rchild指向中序遍历的最后一个结点。中序遍历二叉线索链表表示的二叉树T

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;

}

在这里插入图片描述
代码中,第3行,p=T->lchild;意思就是图6-10-6中的①,让p指向根结点开始遍历。

第4~13行,while(p!=T) 其实意思就是循环直到图中的④出现,此时意味着p指向了头结点,于是与T相等(T是指向头结点的指针),结束循环,否则一直循环下去进行遍历操作。

第5~6行,while(p->LTag==Link)这个循环,就是由A→B→D→H,此时H结点的LTag不是Link(就是不等于0),所以结束此循环。

第7行,打印H。

第8~11行,while(p->RTag==Thread && p->rchild!=T) ,由于结点H的RTag= =Thread(就是等于1),且不是指向头结点,因此打印H的后继D,之后因为D的RTag是Link,因此退出循环。

第12行,p=p->rchild;意味着p指向了结点D的右孩子I。

然后就是不断循环遍历,路径参照图6-10-4,直到打印出HDIBJEAFCG , 结束遍历操作。
在这里插入图片描述

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

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

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

我们前面已经讲过了树的定义和存储结构,对于树来说,在满足树的条件下可以是任意形状,一个结点可以有任意多个孩子,显然对树的处理要复杂得多,去研究关于树的性质和算法,真的不容易。有没有简单的办法解决对树处理的难题呢?

我们前面也讲过了二叉树,尽管它也是树,但由于每个结点最多只能有左孩子和右孩子,面对的变化就少很多了。因此很多性质和算法都被研究了出来。如果所有的树都像二叉树一样方便就好了。你还别说,真是可以这样做。

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

我们分别来看看它们之间的转换如何进行。

6.11.1 树转化为二叉树

将树转化为二叉树的步骤如下:
1 加线。在所有兄弟结点之间加一条连线
2 去线。对树中每个结点,只保留它与第一个孩子结点的连线,删除它与其他孩子结点之间的连线

3 层次调整。以树的根结点为轴心,将整棵树顺时针旋转一定的角度,使之结构层次分明。注意第一个孩子是二叉树结点的左孩子,兄弟转换过来的孩子是结点的右孩子。

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

在这里插入图片描述

6.11.2 森林转换为二叉树

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

1 把每棵树转换为二叉树

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

在这里插入图片描述

6.11.3 二叉树转换为树

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

在这里插入图片描述

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

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

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

6.11.4 二叉树转换为森林

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

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

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

在这里插入图片描述

6.11.5 树与森林的遍历

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

树的遍历分为两种方式。

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

2 另一种是后根遍历,即先依次后跟遍历每棵子树,然后再访问根结点。比如图6-11-4中最右侧的树,它的先根遍历序列为ABEFCDG,后根遍历序列为EFBCGDA。

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

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

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

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

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

6.12 哈夫曼树及其应用

6.12.1 哈夫曼树

在这里插入图片描述

在计算机和互联网技术中,文本压缩是一个非常重要的技术。玩电脑的人几乎都会应用压缩和解压缩软件来处理文档。因为它除了可以减少文档在磁盘上的空间外,还有重要的一点,就是我们可以在网络上以压缩的形式传输大量数据,使得保存和传递都更加高效。

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

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

什么叫做哈夫曼树呢?我们先来看一个例子。

过去我们小学、中学一般考试都是用百分制来表示学科成绩。这带来了一个弊端,就是很容易让学生、家长,甚至老师自己都以分取人,让分数代表了一切。有时想想也对,90分和95分也许就只是一道题目的对错而已,但却让两个孩子可能受到完全不同的待遇,这并不公平。于是在如今提倡素质教育的背景下,我们很多的学科,特别是小学的学科成绩都改作了优秀、良好、中等、及格和不及格这样模糊的词语,不再通报具体的分数。

不过对于老师来讲,他在对试卷评分的时候,显然不能凭感觉给优良或及格不及格,因此一般都还是按照百分制算出每个学生的成绩后,再根据统一的标准换算出五级分制的成绩。比如下面的代码就实现了这样的转换。

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个等级上的分布规律如下表所示。
在这里插入图片描述

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

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

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

6.12.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 1+1+2+2+3+3+4+4=20 1+1+2+2+3+3+4+4=20。二叉树b的路径长度就为 1 + 1 + 2 + 2 + 2 + 2 + 3 + 3 = 16 1+1+2+2+2+2+3+3=16 1+1+2+2+2+2+3+3=16.

如果考虑到带权的结点,结点的带权的路径长度为从该结点到树根之间的路径长度与结点上权的乘积树的带权路径长度为树中所有叶子结点的带权路径长度之和。假设有n个权值 { w 1 , w 2 , . . . w n } \{w_1,w_2,...w_n\} {w1,w2,...wn},构造一棵有n个叶子结点的二叉树,每个叶子结点带权 w k w_k wk,每个叶子的路径长度为 l k l_k lk,我们通常记作 Σ k w k l k \Sigma_{k}w_kl_k Σkwklk,则其中带权路径长度WPL最小的二叉树称作哈夫曼树。也有不少书中称之为最优二叉树。

有了哈夫曼对带权路径长度的定义,我们来计算图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=51+152+403+304+104=315,注意,这里5是A结点的权,1是A结点的路径长度,其他同理。

二叉树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=53+153+402+302+102=220

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

那么现在的问题是,图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 15 N_115 N115,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 N_230 N230,D30,C40.

6 重复步骤2.将 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 N_360 N360.
8 重复步骤2.将C与 N 3 N_3 N3作为一个新结点 T T 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=401+302+153+104+54=205,与图6-12-4的二叉树b的WPL值220相比,还少了15.显然此时构造的二叉树才是最优的哈夫曼树。

不过现实总是比理想复杂得多,图6-12-8虽然是哈夫曼树,但由于每次判断都要两次比较(如根结点就是a<80 && a≥70,两次比较才能得到y或n的结果),所以总体性能上,反而不如图6-12-3的二叉树性能高。当然这并不是我们要讨论的重点啦。

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

1 根据给定的n个权值 { w 1 , w 2 , . . . w n } \{w_1,w_2,...w_n\} {w1,w2,...wn}构成n棵二叉树的集合 F = { T 1 , T 2 , . . . , T n } F=\{T_1,T_2,...,T_n\} F={T1,T2,...,Tn},其中每棵二叉树 T i T_i Ti中只有一个带权为 w i w_i wi的根结点,其左右子树均为空。

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

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

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

6.12.3 哈夫曼编码

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

哈夫曼编码之前学过好多遍,这里便不再赘述。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值