目录
树:树 (Tree)是 n ( n ≥ 0) 个结点的有限集。n=0 时称为空树。在任意一棵非空树中:(1) 有且仅有一个特定的称为根(Root) 的结点;(2) 当 n>1 时,其余结点可分为 m (m>0) 个互不相交的有限集
、
、......... 、
,其中每一个集合本身又是一棵树,并且称为根的子树(SubTree)。
1. 树的定义
树:树 (Tree)是 n ( n ≥ 0) 个结点的有限集。n=0 时称为空树。在任意一棵非空树中:(1) 有且仅有一个特定的称为根(Root) 的结点;(2) 当 n>1 时,其余结点可分为 m (m>0) 个互不相交的有限集、
、......... 、
,其中每一个集合本身又是一棵树,并且称为根的子树(SubTree)。如下图所示:
对于树的定义还还需要强调两点:
1. n>0(结点个数)时根结点是唯一的,不可能存在多个根结点,别和现实中的大树混在一起,现实中的树有很多根须,那是真实的树。数据结构中的树只能有一个根结点。
2. m>0(除根结点的结点数)时,子树的个数没有限制,但它们一定是互不相交的。像下图所示的两个结构就不符合树的定义,因为它们都有相交的子树。
1.1 结点分类
树的结点包含一个数据元素及若干个指向其子树的分支。结点拥有的子树数称为结点的度(Degree)。度为 0 的结点称为叶结点(Leaf)或终端结点;度不为 0 的结点称为非终端结点或分支结点。除根结点之外,分支结点也称为内部结点。树的度是树内各结点的度的最大值。如下图所示,因为这棵树的结点的度的最大值是结点 D 的度,为 3,所以树的度也为 3。
1.2 结点间关系
结点的子树的根称为该结点的孩子(Child),相应地,该结点称为孩子的双亲(Parent)。同一个双亲的孩子之间互称兄弟(Sibling)。结点的祖先是从根到该结点所经分支的所有结点。下图所示中,对于 H 来说,D、B、A 都是它的祖先。反之,以某结点为根的子树中的任一结点都称为该结点的子孙。下图所示,B 的子孙有 D、G、H、I。
1.3 树的其他相关概念
结点的层次(Level)从根开始定义起,根为第一层,根的孩子为第二层。若某结点在第L层,则其子树的根就在第 L+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 的非叶结点,否则返回它的最左孩子,否则返回空。
RightSlibling(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; //根结点的数据类型,此处定位整型
typedef struct PTNode //结点结构
{
TElemType data; //结点数据
int parent; //双亲位置
}PTNode;
typedef struct //树结构
{
PTNode nodes[MAX_TREE_SIZE]; //结点数组
int r, n; //根的位置和结点数
}PTree;
有了这样的结构定义,就可以实现双亲表示法了。由于根结点是没有双亲的,所以我们约定根结点的位置域设置为 -1 ,这也就意味着,我们所有的结点都存有它的双亲的位置。如下图所示的树结构和树双亲表示所示。
这样的存储结构,我们可以根据结点的 parent 指针很容易找到它的双亲结点,所用的时间复杂度为 O(1) ,直到 parent 为 -1 时,表示找到了树结点的根,可是要是知道结点的孩子是什么,只能遍历整个结构才可以。
此时是比较麻烦的,我们可以增加一个结点最左边孩子的域,不妨叫它长子域,这样就可以很容易得到结点的孩子。如果没有孩子的结点,这个长子域就设置为 -1 ,如下表所示:
对于有 0 个或 1 个孩子结点来说,这样的结构是解决了要找结点孩子的问题了。甚至是有 2 个孩子,知道了长子是谁,另一个当然就是次子了。
另外一个问题,我们很关注兄弟之间的关系,双亲表示法无法体现这样的关系,我们可以增加一个右兄弟域来体现兄弟关系,也就是说,每个结点如果它存在右兄弟,则记录右兄弟的下标。同样的,如果右兄弟不存在,则赋值为 -1 ,如下表所示:
但如果结点的孩子很多,超过了 2 个。我们又关注结点的双亲、又关注结点的孩子、还关注结点的兄弟,而且对时间遍历要求还比较高,那么我们还可以把此结构扩展为有双亲域、长子域、再有右兄弟域。存储结构的设计是一个非常灵活的过程。一个存储结构设计得是否合理,取决于基于该存储结构的运箅是否适合、是否方便,时间复杂度好不好等。注意也不是越多越好,有需要时再设计相应的结构。就像再好听的音乐,不停反复听上千遍也会腻味,再好看的电影,一段时间反复看上百遍,也会无趣。
3.2 孩子表示法
换一种完全不同的考虑方法。由于树中每个结点可能有多棵子树,可以考虑用多重链表表示法。即每个结点有多个指针域,其中每个指针指向一棵子树的根结点,我们把这种方法叫做多重链表表示法。不过,树的每个结点的度,也就是它的孩子个数是不同的。所以可以设计两种方案来解决。
方案一
一种是指针域的个数就等于树的度,树的度是树各个结点度的最大值。其结构如表所示:
其中 data 是数据域。child1 到 childd 是指针域,用来指向该结点的孩子结点。
对于下图左图所示的树来说,树的度为 3 ,所以我们指针域的个数是 3 ,这种方法实现如下图右图所示。
这种方法对于树中各结点的度相差很大时,显然是很浪费空间的,因为有很多的结点,它的指针域都是空的。不过如果树的各结点相差很小时,那就意味着开辟的空间被充分利用了,这时存储空间的缺点反而变成了优点。
既然很多指针域可能为空,为什么不按需分配空间呢。于是就有了第二种方案。
方案二
第二种方案每个结点指针域的个数等于该节点的度,我们专门取一个位置来存储结点指针域的个数,其结构如下图所示:
其中 data 为数据域,degree 为度域,也就是存储该结点的孩子结点的个数,child1 到 childd 为指针域,指向该结点的各个孩子的结点。
对于下图左图所示的树来说,这种方法实现如下图右图所示:
这种方法克服了浪费空间的缺点,对空间利用率是很高了,但是由于各结点的链表是不相同的结构,加上要维护结点的度的数值,在运算上就会带来时间上的损耗。
仔细观察,为了遍历整棵树,把每个结点放到一个顺序存储结构的数组中是合理的,但每个结点的孩子有多少是不确定的,所以我们再对每个结点的孩子建立一个单链表体现它们的关系。这样就可以减少空指针的浪费又能使结点结构相同。
这就是孩子表示法。具体办法是,把每个结点的孩子结点排列起来,以单链表作存储结构,则 n 个结点有 n 个孩子链表,如果是叶子结点则此单链表为空。然后 n 个头指针又组成一个线性表,采用顺序存储结构,存放一个一维数组中,如下图所示。
为此,设计两种结构组织,一个是孩子链表的孩子结点,如下图所示:
其中 child 是数据域,用来存储某个结点在表头数组中的下标。next 是指针域,用来存储指向某结点的下一个孩子结点的指针。
另一个是表头数组的表头结点,如下图所示:
其中 data 是数据域,存储某结点的数据信息。firstchild 是头指针域,存储该结点的孩子链表的头指针。
以下是我们孩子表示法的结构定义代码。
//树的孩子表示法结构定义
#define MAX_TREE_SIZE 100 //结点数组的大小
typedef int TElemType; //根结点的数据类型,此处定位整型
typedef struct CTNNode //孩子结点
{
int child;
struct CTNode *next;
}*ChildPtr;
typedef struct //表头结构
{
TElemType 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 找到此结点的长子,然后再通过长子结点的 rightsilb 找到它的二弟,接着一直下去,直到找到具体的孩子。缺点是如果想找某个结点的双亲,这个表示法也是有缺陷的。
如果有必要,完全可以再增加一个 parent 指针域来解决快速查找双亲的问题。
其实这个表示法最大的好处是它把一棵复杂的树变成了一棵二叉树。把左图变成了右图这个样子。
这样就可以利用二叉树的特性和算法来处理这棵树了。
4. 二叉树的定义
现在我们来做个游戏,我在纸上已经写好了一个 100 以内的正整数数字,请大家想办法猜出我写的是哪一个?注意你们猜的数字不能超过 7 个,我的回答只会告诉你 是“大了”或“小了”。
这个游戏在一些电视节目中,猜测一些商品的定价时常会使用。我看到过有些人是一点一点的数字累加的,比如 5、10、15、20 这样猜,这样的猜数策略太低级了,显然是没有学过数据结构和算法的人才做得出的事。
其实这是一个很经典的折半查找算法。如果用下图的办法(省略下三层)。就一定能在七次之内猜出结果。
猜测过程如下图所示:
可以看出折半查找效率很高。不过对于这种在某个阶段都是两种结果的情形,比如开和关、0 和 1 、真和假、上和下、对与错、正面与反面等,都适合用树状结构来建模,而这种树是一种很特殊的树状结构,叫做二叉树。
二叉树:二叉树(Binary Tree)是 n(n ≥ 0)个结点的有限集合,该集合或者为空集(称为空二叉树),或者由一个根结点和两棵互不相交的、分别称为根结点的左子树和右子树的二叉树组成。
下图所示就是一棵二叉树:
4.1 二叉树特点
二叉树的特点有:
■ 每个结点最多有两棵子树,所以二叉树中不存在度大于 2 的结点。注意不是只有两棵子树,而是最多有。没有子树或者有一棵子树都是可以的。
■ 左子树和右子树是有顺序的,次序不能任意颠倒。就像人是双手、双脚,但显然左手、左脚和右手、右脚是不一样的,右手带左手套、右脚穿左鞋都会及其别扭和难受。
■ 即使树中某结点只有一个子树,也要区分它是左子树还是右子树。下图所示,树 1 和树 2 是同一棵树,但是它们却是不同的二叉树。就好像你一不小心摔伤了手,伤的是左手还是右手,对于你的生活影响度是完全不同的。
二叉树具有五种基本形态:
1. 空二叉树。
2. 只有一个根结点。
3. 根结点只有左子树。
4. 根结点只有右子树
5. 根结点既有左子树又有右子树。
例如对于有三个结点的树,有几种形态?如果是有三个结点的二叉树,又有几种形态?
若只从形态上考虑,三个结点的树只有两种情况,那就是下图中有两层的树 1 和三层的后四种的任意一种,但对于二叉树来说,由于要区分左右,所以就演变成五种形态,树 2、树 3、树 4 和树 5 分别代表了不同的二叉树。
4.2 特殊二叉树
1. 斜树
所有的结点都只有左子树的二叉树叫左斜树。所有结点都是只有右子树的二叉树叫右斜树。这两者统称为斜树。下图中的树 2 就是左斜树,树 5 就是右斜树。斜树有一个很明显的的特点,就是每一层都只有一个结点,结点的个数与二叉树的深度相同。
其实线性表结构就可以理解为是树的一种极其特殊的表现形式。
2. 满二叉树
苏东坡曾有词云:“人有悲欢离合,月有阴晴圆缺,此事古难全”。意思就是完美是理想,不完美才是人生。我们通常举的例子也都是左高右低、参差不齐的二叉树。 那是否存在完美的二叉树呢?
在一棵二叉树中,如果所有分支结点都存在左子树和右子树,并且所有叶子都在同一层上,这样的二叉树称为满二叉树。也叫完美的二叉树。
下图所示就是一棵满二叉树,从样子上看就感觉它很完美。
单是每个结点都存在左右子树,不能算是满二叉树,还必须要所有的叶子都在同一层上,这就做到了整棵树的平衡。因此满二叉树的特点有:
(1)叶子只能出现在最下一层。出现在其他层就不可能达成平衡。
(2)非叶子结点的度一定是 2 。否则就是“缺胳膊少腿”了。
(3)在同样深度的二叉树中,满二叉树的结点个数最多,叶子树最多。
3. 完全二叉树
对一棵具有 n 个结点的二叉树按层序编号,如果编号为 i(1≤i≤n)的结点与同样深度的满二叉树中编号为 i 的结点在二叉树中位置完全相同,则这棵树称为完全二叉树。如下图所示:
满二叉树一定是一棵完全二叉树,但完全二叉树不一定是满的。
其次,完全二叉树的所有结点与同样深度的二叉树,它们按层编号,相同的结点,是一一对应的。这里有个关键词是按层序编号,像下图所示的树 1,因为 5 结点没有左子树,却有右子树,那就使得按层序编号的第 10 个编号空挡了。同样下图的树 2,由于 3 结点没有子树,所以使得 6、7 编号的位置空挡了。树 3 又是因为 5 编号下没有子树造成第 10 和第 11 位置空挡。只有上图中的树,尽管它不是满二叉树,但是编号是连续的,所以它是完全二叉树。
完全二叉树的特点:
(1)叶子结点只能出现在最下两层。
(2)最下层的叶子一定集中在左部连续位置。
(3)倒数二层,若有叶子结点,一定都在右部连续位置。
(4)如果结点度为 1,则该结点只有左孩子,即不存在只有右子树的情况。
(5)同样结点数的二叉树,完全二叉树的深度最小。
由上面的例子,给我们了一个判断某二叉树是否是完全二叉树的办法,那就是看着树的示意图,心中默默给每个结点按照满二叉树的结构逐层顺序编号,如果编号出现空挡,就说明不是完全二叉树,否则就是。
5. 二叉树的性质
5.1 二叉树性质 1
性质 1:在二叉树的第 i 层上至多有 个结点(i ≥ 1 )。
如下图所示,第一层是根结点,只有一个,所以 。
第二层有两个, 。
第三层有四个,。
第四层有八个,。
通过数据归纳法的论证,可以很容易得出二叉树的第 i 层上至多有 个结点(i ≥ 1)的结论。
5.2 二叉树性质 2
性质 2:深度为 k 的二叉树至多有 个结点( k ≥ 1 )。
深度为 k 意思就是有 k 层的二叉树。
如果有一层,至多 个结点。
如果有二层,至多 个结点。
如果有三层,至多 个结点。
如果有四层,至多 个结点。
通过数据归纳法的论证,就可以得出如果有 k 层,此二叉树至多有 个结点。
● 注:
深度:对于任意节点 n,n 的深度为从根到 n 的唯一路径长,根的深度为 0 ;
高度:对于任意节点 n,n 的高度为从 n 到一片树叶的最长路径长,所有树叶的高度为 0 ;
5.3 二叉树性质 3
性质 3:对任何一棵二叉树 T ,如果其终端结点数为 ,度为 2 的结点数为
,则
。
终端结点数其实就是叶子结点数,而一棵二叉树,除了叶子结点外,剩下的就是度为 1 或 2 的结点数了,我们设 为度是 1 的结点数。则树 T 结点总数
。
例如下图所示的例子,结点总数为 10 ,它是由 A、B、C、D 等度为 2 结点,F、G、H、I、J 等度为 0 的叶子结点和 E 这个度为 1 的结点组成。总和为 4+1+5=10。
由于根结点只有分支出去,没有分支进入,还可以得出:分支线总数为结点总数减去 1 。
用代数表达就是 分支线总数 。因为我们有等式
,所以可推导出
。结论就是
。
5.4 二叉树性质 4
性质 4:具有 n 个结点的完全二叉树的深度为 ( [x] 表示不大于 x 的最大整数)。
由满二叉树定义可知:深度为 k 的满二叉树的结点数 n 一定是 。这是最多的结点个数。倒推可得到满二叉树的度数为
。例如结点数为 15 的满二叉树,度为 4。
完全二叉树它的叶子结点只会出现在最下面的两层,它的结点数一定少于等于同样度数的满二叉树的结点数 ,但一定多于
。即满足
。由于结点数 n 是整数,意味着
,意味着
,所以
,不等式两边取对数,得到
,而 k 作为度数也是整数,因此
。
5.5 二叉树性质5
性质5:如果对一棵有 n 个结点的完全二叉树(其深度为 )的结点按层序编号(从第 1 层到第
层,每层从左到右),对任一结点 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,它的双亲就是 [7/2]=3 ,结点 9,它的双亲就是[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.1 二叉树的顺序存储结构
对于树这种一对多的关系结构用顺序存储结构来实现是比较困难的,但是二叉树是一种特殊的树,由于它的特殊性,使得用顺序存储结构也可以实现。
二叉树的顺序存储结构就是用一维数组存储二叉树中的结点,并且结点的存储位置,也就是数组的下标要能体现结点之间的逻辑关系,比如双亲与孩子的关系,左右兄弟的关系等。
先来看看完全二叉树的顺序存储,一棵完全二叉树如下图所示。
将这棵二叉树存入到数组中,相应的下标对应其同样的位置,如下图所示。
这下看出完全二叉树的优越性来了吧。由于它定义的严格,所以用顺序结构也可以表现出二叉树的结构来。
当然对于一般的二叉树,尽管层序编号不能反映逻辑关系,但是可以将其按完全二叉树编号,只不过,把不存在的结点设置为“ ∧ ”而已。如下图所示,注意浅色结点表示不存在。
考虑一种极端的情况,一棵深度为 k 的右斜树,它只有 k 个结点,却需要分配 个存储单元空间,这显然是对存储空间的浪费,例如下图所示。所以,顺序存储结构一般只用于完全二叉树。
6.2 二叉链表
既然顺序存储适用性不强,我们就要考虑链式存储结构。二叉树每个结点最多有两个孩子,所以为它设计一个数据域和两个指针域是比较自然的想法,这样的链表叫做二叉链表。结点结构图如下表所示:
其中 data 是数据域,lchild 和 rchild 都是指针域,分别存放指向左孩子和右孩子的指针。
以下是我们的二叉链表的结点结构定义代码:
typedef int TElemType; //根结点的数据类型,此处定位整型
//二叉树的二叉链表结点结构定义
typedef struct BiTNode //结点结构
{
TElemType data; //结点数据
struct BiTNode *lchild, *rchild; //左右孩子指针
}BiTNode,*BiTree;
结构示意图如下图所示:
就如同树的存储结构中讨论的一样,如果有需要,还可以再增加一个指向其双亲的指针域,那样就称之为三叉链表。由于与树的存储结构类似,这里就不详述了。
7. 遍历二叉树
7.1 二叉树遍历原理
举个例子来帮助大家理解:假设,我手头有 20 张 100 元的和 2000 张 1 元的奖券,同时洒向了空中,大家比赛看谁最终捡的最多。如果是你,你会怎么做?
相信所有人都会说,一定先捡 100 元的。道理非常简单,因为捡一张 100 元等于 1 元的捡 100 张,效率好得不是一点点。所以可以得到这样的结论,同样是捡奖券,在有限时间内,要达到最高效率,次序非常重要。对于二叉树的遍历来讲,次序同样显得很重要。
二叉树的遍历 ( traversing binary tree ) 是指从根结点出发,按照某种次序依次访问二叉树中所有结点,使得每个结点被访问一次且仅被访问一次。
这里有两个关键词:访问和次序。
访问其实是要根据实际的需要来确定具体做什么,比如对每个结点进行相关计算,输出打印等,它算作是一个抽象操作。在这里可以简单地假定就是输出结点的数据信息。 ‘
二叉树的遍历次序不同于线性结构,不像线性结构最多也就是从头至尾、循环、双向等简单的遍历方式。树的结点之间不存在唯一的前驱和后继关系,在访问一个结点后,下一个被访问的结点面临着不同的选择。就像你人生的道路上,高考填志愿要面临哪个城市、哪所大学、具体专业等选择,由于选择方式的不同,遍历的次序就完全不同了。
7.2 二叉树遍历方法
二叉树的遍历方式可以很多,如果我们限制了从左到右的习惯方式,那么主要就分为四种:前序遍历、中序遍历、后序遍历、层序遍历。
1. 前序遍历(根、左、右)
规则是若二叉树为空,则空操作返回,否则先访问根结点,然后前序遍历左子树,再前序遍历右子树。如下图所示,遍历的顺序为:ABDGHCEIF。
2. 中序遍历(左、根、右)
规则是若树为空,则空操作返回,否则从根结点开始(注意并不是先访问根结点),中序遍历根结点的左子树,然后是访问根结点,最后中序遍历右子树。如下图所示,遍历的顺序为:GDHBAEICF。
3. 后序遍历(左、右、根)
规则是若树为空,则空操作返回,否则从左到右先叶子后结点的方式遍历访问左右子树,最后是访问根结点。如下图所示,遍历的顺序为:GHDBIEFCA。
4. 层序遍历(逐层从左到右)
规则是若树为空,则空操作返回,否则从树的第一层,也就是根结点开始访问, 从上而下逐层遍历,在同一层中,按从左到右的顺序对结点逐个访问。如下图所示,遍历的顺序为:ABCDEFGHI。
这么多的遍历方法有什么用处呢?
我们用图形的方式来表现树的结构,应该说是非常直观和容易理解,但是对于计算机来说,它只有循环、判断等方式来处理,也就是说,它只会处理线性序列,而我们刚才提到的四种遍历方法,其实都是在把树中的结点变成某种意义的线性序列,这就给程序的实现带来了好处。
另外不同的遍历提供了对结点依次处理的不同方式,可以在遍历过程中对结点进行各种处理。
7.3 前序遍历算法
二叉树的定义是用递归的方式,所以,实现遍历算法也可以采用递归,而且极其简洁明了。先来看看二叉树的前序遍历算法。代码如下:
//二叉树的前序遍历递归算法
void PreOrderTraverse(BiTree T)
{
if (T == NULL) //判断树是否为空
{
return; //如果树为空,直接返回,不执行后续操作
}
printf("%c", T->data); //显示结点数据,可以更改为其他对结点的操作
PreOrderTraverse(T->lchild); //再先序遍历左子树
PreOrderTraverse(T->rchild); //最后先序遍历右子树
}
假设我们现在有如下图这样一棵二叉树 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 结点的右孩子,也是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 。
7.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->Ichild); 访问结点 D,不为 null,继续调用 InOrderTraverse (T->lchild); 访问结点 H。继续调用 InOrderTraverse (T->lchild); 访问结点 H 的左孩子,发现当前指针为 null,于是返回。打印当前结点 H,如下图所示:
2. 然后调用 InOrderTraverse (T->rchild); 访问结点 H 的右孩子 K ,因结点 K 无左孩子,所以打印 K,如下图所示:
3. 因为结点 K 没有右孩子,所以返回。打印结点 H 函数执行完毕,返回。打印字母 D,如下图所示:
4. 结点 D 无右孩子,此函数执行完毕,返回。打印字母 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。
7.5 后续遍历算法
那么同样的,后序遍历也就很容易想到应该如何写代码了。
//二叉树的后序遍历递归算法
void PostOrderTraverse(BiTree T)
{
if (T == NULL) //判断树是否为空
{
return; //如果树为空,直接返回,不执行后续操作
}
PostOrderTraverse(T->lchild); //先后序遍历左子树
PostOrderTraverse(T->rchild); //再后序遍历右子树
printf("%c", T->data); //显示结点数据,也可以更改为其他对结点操作
}
如下图所示,后序遍历是先递归左子树,由根结点 A→B→D→H,结点 H 无左孩子,再查看结点 H 的右孩子 K,因为结点 K 无左右孩子,所以打印 K,返回。
最终,后序遍历的结点的顺序就是:KHDEBIFJGCA。
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。
不过细细分析,根据中序序列ABCDEEG,是可以得出 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)
{
/*
OVERFLOW为math.h中的一个宏定义,其值为3。
含义为运算过程中出现了上溢,
即运算结果超出了运算变量所能存储的范围。
exit(OVERFLOW)的含义就是,退出程序,
并返回OVERFLOW的值给主调进程。其标准的使用范围为:
当程序运算出现上溢时,退出程序并报错给主调进程。
*/
exit(OVERFLOW);
}
(*T)->data = ch; //生成根结点
CreateBiTree(&(*T)->lchild); //构造左子树
CreateBiTree(&(*T)->rchild); //构造右子树
}
}
其实建立二叉树,也是利用了递归的原理。只不过在原来应该是打印结点的地方,改成了生成结点、给结点赋值的操作而已。所以大家理解了前面的遍历的话,对于这段代码就不难理解了。
当然,你完全也可以用中序或后序遍历的方式实现二叉树的建立,只不过代码里生成结点和构造左右子树的代码顺序交换一下。另外,输入的字符也要做相应的更改。比如下图的扩展二叉树的中序遍历字符串就应该为 #B#D#A#C# ,而后序字符串应该为 ###DB##CA 。
9. 线索二叉树
9.1 线索二叉树原理
编写一个程序要讲究对资源的利用率越高越好,能不浪费的时间或空间,都应该考虑节省。我们来观察下图,会发现指针域并不是都充分的利用了,有许许多多的“ ∧ ”,也就是空指针域的存在,这实在不是好现象,应该要想办法利用起来。
首先我们要来看看这空指针有多少个呢?对于一个有 n 个结点的二叉链表,每个结点有指向左右孩子的两个指针域,所以一共是 2n 个指针域。而 n 个结点的二叉树一共有 n-1 条分支线数,也就是说,其实是存在 个空指针域。 比如上图中有 10 个结点,而带有“ ∧ ”空指针域为 11 。这些空间不存储任何事物,白白的浪费着内存的资源。
另一方面,我们在做遍历时,比如对下图做中序遍历时,得到了 HDIBJEAFCG 这样的字符序列,遍历过后,我们可以知道,结点 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 结点的 Ichild 是指向它的左孩子 J,而 rchild 却是指向它的后继 A 。显然我们在决定 Ichild 是指向左孩子还是前驱,rchild 是指向右孩子还是后继上是需要一个区分标志的。因此,我们在每个结点再增设两个标志域 ltag 和 rtag ,注意 ltag 和 rtag 只是存放 0 或 1 数字的布尔型变量,其占用的内存空间要小于像 lchild 和 rchild 的指针变量。结点结构如下标所示:
其中:
■ Itag 为 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; //后继线索
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,以便于下一次使用。
有了线索二叉树后,我们对它进行遍历时发现,其实就等于是操作一个双向链表结构。
和双向链表结构一样,在二叉树线索链表上添加一个头结点,如下图所示, 并令其 lchild 域的指针指向二叉树的根结点(图中的①),其 rchild 域的指针指向中序遍历时访问的最后一个结点(图中的②)。反之,令二叉树的中序序列中的第一个结点中, lchild 域指针和最后一个结点的 rchild 域指针均指向头结点(图中的③和④)。这样定义的好处就是我们既可以从第一个结点起顺后继进行遍历,也可以从最后一个结点起顺前驱进行遍历。
遍历的代码如下:
#define OK 1
#define ERROR 0
typedef int ElemType; //也就相当于给int起别名
typedef int Status; //创建子函数返回类型
/*
T 指向头结点,头结点左链 lchild 指向根结点,头结点右键 rchild 指向
中序遍历的最后一个结点。中序遍历二叉树线索链表表示的二叉树 T
*/
Status InOrderTraverse_Thr(BiThrTree T)
{
BiThrTree p;
p = T->lchild; // p 指向根结点
while (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;
}
1. 代码中,第 13 行,p=T->lchild; 意思就是下图中的①,让 p 指向根结点开始遍历。
2. 第 14〜27 行,while (p!=T) 其实意思就是循环直到图中的④的出现,此时意味着 p 指向了头结点,于是与 T 相等(T 是指向头结点的指针),结束循环,否则一直循环下去进行遍历操作。
3. 第 16〜19 行,whild (p->LTag==Link) 这个循环,就是由 A→B→D→H,此时 H 结点的 LTag 不是 Link (就是不等于 0 ),所以结束此循环。
4. 第 20 行,打印 H。
5. 第 21〜25 行,while (p->RTag == Thread && p->rchild != T) ,由于结点 H 的 RTag==Thread (就是等于 1 ),且不是指向头结点。因此打印 H 的后继 D,之后因为 D 的 RTag 是 Link,因此退出循环。
6. 第 26 行,p=p->rchild; 意味着 p 指向了结点 D 的右孩子 I 。
7. ……,就这样不断循环遍历,路径参照下图,直到打印出 HDIBJEAFCG ,结束遍历操作。
从这段代码也可以看出,它等于是一个链表的扫描,所以时间复杂度为 O(n) 。
由于它充分利用了空指针域的空间(这等于节省了空间),又保证了创建时的一次遍历就可以终生受用前驱后继的信息(这意味着节省了时间)。所以在实际问题中,如果所用的二叉树需经常遍历或査找结点时需要某种遍历序列中的前驱和后继,那么采用线索二叉链表的存储结构就是非常不错的选择。
10. 树、森林与二叉树的转换
我先讲一个从网上看到的故事,不知是真是假。
故事是说联合利华引进了一条香皂包装生产线,结果发现这条生产线有个缺陷: 常常会有盒子里没装入香皂。总不能把空盒子卖给顾客啊,他们只好请了一个学自动化的博士设计一个方案来分拣空的香皂盒。博士组织成立了一个十几人的科研攻关小组,综合采用了机械、微电子、自动化、X射线探测等技术,花了几十万,成功解决了问题。每当生产线上有空香皂盒通过,两旁的探测器会检测到,并且驱动一只机械手把空皂盒推走。
中国南方有个乡镇企业也买了同样的生产线,老板发现这个问题后大为恼火,找了个小工来说:你把这个问题搞定,不然老子炒你鱿鱼。小工很快想出了办法:他在生产线旁边放了台风扇猛吹,空皂盒自然会被吹走。
这个故事在网上引起了很大的争议,我相信大家听完后也会有不少的想法。不过我在这只是想说,有很多复杂的问题都是可以有简单办法去处理的,在于你肯不肯动脑筋,在于你有没有创新。
树的定义和存储结构我们都了解,对于树来说,在满足树的条件下可以是任意形状,一个结点可以有任意多个孩子,显然对树的处理要复杂得多,去研究关于树的性质和算法,真的不容易。有没有简单的办法解决对树处理的难题呢?
对于二叉树,尽管它也是树,但由于每个结点最多只能有左孩子和右孩子,面对的变化就少很多了。因此很多性质和算法都被研究了出来。如果所有的树都像二叉树一样方便就好了。你还别说,真是可以这样做。
在讲树的存储结构时,提到了树的孩子兄弟法可以将一棵树用二叉链表进行存储,所以借助二叉链表,树和二叉树可以相互进行转换。从物理结构来看,它们的二叉链表也是相同的,只是解释不太一样而已。因此,只要我们设定一定的规则,用二叉树来表示树,甚至表示森林都是可以的,森林与二叉树也可以互相进行转换。
我们分别来看看它们之间的转换如何进行。
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 树与森林的遍历
树和森林的遍历问题:
树的遍历分为两种方式。
1. 一种是先根遍历树,即先访问树的根结点,然后依次先根遍历根的每棵子树。
2. 另一种是后根遍历,即先依次后根遍历每棵子树,然后再访问根结点。比如下图中最右侧的树,它的先根遍历序列为 ABEFCDG ,后根遍历序列为 EFBCGDA 。
森林的遍历也分为两种方式:
1. 前序遍历:先访问森林中第一棵树的根结点,然后再依次先根遍历根的每棵子树,再依次用同样方式遍历除去第一棵树的剩余树构成的森林。比如下图右侧三棵树的森林,前序遍历序列的结果就是 ABCDEFGHJI 。
2. 后序遍历:是先访问森林中第一棵树,后根遍历的方式遍历每棵子树,然后再访问根结点,再依次同样方式遍历除去第一棵树的剩余树构成的森林。比如下图右侧三棵树的森林,后序遍历序列的结果就是 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 个等级上的分布规律如下表所示。
那么 70 分以上大约占总数 80% 的成绩都需要经过 3 次以上的判断才可以得到结果,这显然不合理。
有没有好一些的办法,仔细观察发现,中等成绩( 70 〜 79 分之间)比例最高,其次是良好成绩,不及格的所占比例最少。我们把下图左图这棵二叉树重新进行分配。 改成下图右图的做法试试看。
从图中感觉,应该效率要高一些了,到底高多少呢。这样的二叉树又是如何设计出来的呢?我们来看看赫夫曼大叔是如何说的吧。
11.2 赫夫曼树定义与原理
我们先把这两棵二叉树简化成叶子结点带权的二叉树,如下图所示。其中 A 表示不及格、B 表示及格、C 表示中等、D 表示良好、E 表示优秀。每个叶子的分支线上的数字就是刚才我们提到的五级分制的成绩所占比例数。
赫夫曼大叔说,从树中一个结点到另一个结点之间的分支构成两个结点之间的路径,路径上的分支数目称做路径长度。上图的二叉树 a 中,根结点到结点 D 的 路径长度就为 4 ,二叉树 b 中根结点到结点 D 的路径长度为 2 。
树的路径长度就是从树根到每一结点的路径长度之和。二叉树 a 的树路径长度就为 。二叉树 b 的树路径长度就为
。
如果考虑到带权的结点,结点的带权的路径长度为从该结点到树根之间的路径长度与结点上权的乘积。树的带权路径长度为树中所有叶子结点的带权路径长度之和。 假设有 n 个权值 ,构造一棵有 n 个叶子结点的二叉树,每个叶子结点带 权
,每个叶子的路径长度为 lk ,我们通常记作,则其中带权路径长度 WPL 最小的二叉树称做赫夫曼树。也有不少书中也称为最优二叉树,我个人觉得为了纪念做出巨大贡献的科学家,既然用他们的名字命名,就应该要坚持用他们的名字称呼,哪怕 “最优”更能体现这棵树的品质也应该只作为别名。
有了赫夫曼对带权路径长度的定义,我们来计算一下下图这两棵树的 WPL 值。
二叉树 a 的
注意:这里 5 是 A 结点的权,1 是 A 结点的路径长度,其他同理。
二叉树 b 的
这样的结果意味着什么呢?如果我们现在有 10000 个学生的百分制成绩需要计算五级分制成绩,用二叉树 a 的判断方法,需要做 31500 次比较,而二叉树 b 的判断方 法,只需要 22000 次比较,差不多少了三分之一量,在性能上提高不是一点点。
那么现在的问题就是,下图的二叉树 b 这样的树是如何构造出来的,这样的二叉树是不是就是最优的赫夫曼树呢?
别急,赫夫曼大叔给了我们解决的办法。
1. 先把有权值的叶子结点按照从小到大的顺序排列成一个有序序列,即:A5, E10, B15, D30, C40。
2. 取头两个最小权值的结点作为一个新节点 的两个子结点,注意相对较小的是左孩子,这里就是 A 为
的左孩子,E 为
的右孩子,如下图所示。新结点的权值为两个叶子权值的和
。
3. 将 替换 A 与 E,插入有序序列中,保持从小到大排列。即:
15, B15, D30, C40。
4. 重复步骤 2。将 与 B 作为一个新节点
的两个子结点。如下图所示。
的权值 = 15 + 15 = 30。
5. 将 替换
与 B,插入有序序列中,保持从小到大排列。即:
30, D30, C40。
6. 重复步骤 2。将 与 D 作为一个新节点
的两个子结点。如下图所示。
的权值 = 30 + 30 = 60。
7. 将 替换
与 D,插入有序序列中,保持从小到大排列。即:C40,
60。
8. 重复步骤 2。将 C 与 作为一个新节点 T 的两个子结点,如下图右图所示。 由于 T 即是根结点,完成赫夫曼树的构造。
此时上图右图的二叉树的带权路径长度 。 与上图左图的二叉树 b 的 WPL 值 220 相比,还少了 15。显然此时构造出来的二叉树才是最优的赫夫曼树。
不过现实总是比理想要复杂得多,下图左图虽然是赫夫曼树,但由于每次判断都要两次比较(如根结点就是 a<80 && a>=70,两次比较才能得到 y 或 n 的结果),所以总体性能上,反而不如下图右图的二叉树性能高。当然这并不是我们要讨论的重点了。
通过刚才的步骤,我们可以得出构造赫夫曼树的赫夫曼算法描述。
1. 根据给定的 n 个权值
构成 n 棵二叉树的集合
, 其中每棵二叉树
中只有一个带权为
根结点,其左右子树均为空。
2. 在 F 中选取两棵根结点的权值最小的树作为左右子树构造一棵新的二叉树,且置新的二叉树的根结点的权值为其左右子树上根结点的权值之和。
3. 在 F 中删除这两棵树,同时将新得到的二叉树加入 F 中。
4. 重复 2 和 3 步骤,直到 F 只含一棵树为止。这棵树便是赫夫曼树。
11.3 赫夫曼编码
当然,赫夫曼研究这种最优树的目的不是为了我们可以转化一下成绩。他的更大目的是为了解决当年远距离通信(主要是电报)的数据传输的最优化问题。
比如我们有一段文字内容为 “ BADCADFEED ” 要网络传输给别人,显然用二进制的数字( 0 和 1 )来表示是很自然的想法。我们现在这段文字只有六个字母 ABCDEF,那么我们可以用相应的二进制数据表示,如下表所示:
这样真正传输的数据就是编码后的 “ 001000011010000011101100100011 ” ,对方接收时可以按照 3 位一分来译码。如果一篇文章很长,这样的二进制串也将非常的可怕。而且事实上,不管是英文、中文或是其他语言,字母或汉字的出现频率是不相同的,比如英语中的几个元音字母 “ a e 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 ,长短不等的话其实是很容易混淆的,所以若要设计长短不等的编码,则必须是任一字符的编码都不是另一个字符的编码的前缀,这种编码称做前缀编码。
你仔细观察就会发现,上表中的编码就不存在容易与 1001 、 1000 混淆的 “10” 和 “100” 编码。
可仅仅是这样不足以让我们去方便地解码的,因此在解码时,还是要用到赫夫曼树,即发送方和接收方必须要约定好同样的赫夫曼编码规则。
当我们接收到 1001010010101001000111100 时,由约定好的赫夫曼树可知, 1001 得到第一个字母是 B,接下来 01 意味着第二个字符是 A,如下图所示, 其余的也相应的可以得到,从而成功解码。
一般地,设需要编码的字符集为 ,各个字符在电文中出现的次数或频率集合为
,以
作为叶子结点,以
作为相应叶子结点的权值来构造一棵赫夫曼树。规定赫夫曼树的左分支代表 0 ,右分支代表 1 ,则从根结点到叶子结点所经过的路径分支组成的 0 和 1 的序列便为该结点对应字符的编码,这就是赫夫曼编码。
12. 总结
开头我们提到了树的定义,讲到了递归在树定义中的应用。提到了如子树、结点、度、叶子、分支结点、双亲、孩子、层次、深度、森林等诸多概念,这些都是需要在理解的基础上去记忆的。
我们谈到了树的存储结构时,讲了双亲表示法、孩子表示法、孩子兄弟表示法等不同的存储结构。
并由孩子兄弟表示法引出了最重要一种树,二叉树。
二叉树每个结点最多两棵子树,有左右之分。提到了斜树,满二叉树、完全二叉 树等特殊二叉树的概念。接着谈到它的各种性质,这些性质给我们研究二叉树带来了方便。
二叉树的存储结构由于其特殊性使得既可以用顺序存储结构又可以用链式存储结构表示。
遍历是二叉树最重要的一门学问,前序、中序、后序以及层序遍历都是需要熟练掌握的知识。要让自己要学会用计算机的运行思维去模拟递归的实现,可以加深我们对递归的理解。不过,并非二叉树遍历就一定要用到递归,只不过递归的实现比较优雅而已。这点需要明确。
二叉树的建立自然也是可以通过递归来实现。
研究中也发现,二叉链表有很多浪费的空指针可以利用,查找某个结点的前驱和后继为什么非要每次遍历才可以得到,这就引出了如何构造一棵线索二叉树的问题。 线索二叉树给二叉树的结点查找和遍历带来了高效率。
树、森林看似复杂,其实它们都可以转化为简单的二叉树来处理,我们提供了树、森林与二叉树的互相转换的办法,这样就使得面对树和森林的数据结构时,编码实现成为了可能。
最后,我们提到了关于二叉树的一个应用,赫夫曼树和赫夫曼编码,对于带权路径的二叉树做了详尽地讲述,让你初步理解数据压缩的原理,并明白其是如何做到无损编码和无错解码的
注:本博客是本人在学习《大话数据结构》后整理的笔记,用于自己以后的复习与回顾,博客中的照片是本人从《大话数据结构》中截取的。