数据结构(C++)笔记:05.树和二叉树

文章目录

  • 5.1 树的逻辑结构
    • 5.1.1 树的定义和基本术语
    • 5.1.2 树的抽象数据类型定义
    • 5.1.3 树的遍历操作
  • 5.2 树的存储结构
    • 5.2.1 双亲表示法
    • 5.2.2 孩子表示法
    • 5.2.3 双亲孩子表示法
    • 5.2.4 孩子兄弟表示法
  • 5.3 二叉树的逻辑结构
    • 5.3.1 二叉树的定义
    • 5.3.2 二叉树的基本性质
    • 5.3.3 二叉树的抽象数据类型定义
    • 5.3.4 二叉树的遍历操作
  • 5.4 二叉树的存储结构及实现
    • 5.4.1 顺序存储结构
    • 5.4.2 二叉链表
    • 5.4.3 三叉链表
    • 5.4.4 线索链表
  • 5.5 树、森林与二叉树的转换
  • 5.6 应用举例
    • 5.6.1 二叉树的应用举例——哈夫曼树及哈夫曼编码
    • 5.6.2 树的应用举例——八枚硬币问题
  • 参考书目:

5.1 树的逻辑结构

5.1.1 树的定义和基本术语

  1. 树的定义
    在树中常常将数据元素称为结点。
    树是n(n≥0)个结点的有限集合。当n=0时,称为空树;任意一棵非空树满足以下条件:
    ⑴ 有且仅有一个特定的称为根的结点;
    n>0时根结点是唯一的,不可能存在多个根结点,别和现实中的大树混在一起,现实中的树有很多根须,那是真实的树,数据结构中的树是只能有一个根结点。
    ⑵ 当n>1时,除根结点之外的其余结点被分成m(m>0)个互不相交的有限集合 T 1 , T 2 , … , T m T_1,T_2,…,T_m T1T2Tm,其中每个集合又是一棵树,并称为这个根结点的子树。
  2. 树的基本术语
    结点的度、树的度
    某结点所拥有的子树的个数称为该结点的度;
    树中各结点度的最大值称为该树的度。
    叶子结点、分支结点
    度为0的结点称为叶子结点,也称为终端结点;
    度不为0的结点称为分支结点,也称为非终端结点。
    孩子结点、双亲结点、兄弟结点
    某结点的子树的根结点称为该结点的孩子结点;反之,该结点称为其孩子结点的双亲结点;
    具有同一个双亲的孩子结点互称为兄弟结点。
    路径、路径长度
    如果树的结点序列 n 1 , n 2 , … , n k n_1, n_2, …, n_k n1,n2,,nk满足如下关系:结点 n i n_i ni是结点 n i + 1 n_{i+1} ni+1的双亲(1≤i<k),则把 n 1 , n 2 , … , n k n_1, n_2, …, n_k n1,n2,,nk称为一条由 n 1 n_1 n1 n k n_k nk的路径;路径上经过的边的个数称为路径长度。
    祖先、子孙
    如果从结点x到结点y有一条路径,那么x就称为y的祖先,而y称为x的子孙。
    结点的层数、树的深度(高度)
    规定根结点的层数为1,对其余任何结点,若某结点在第k层,则其孩子结点在第k+1层;
    树中所有结点的最大层数称为树的深度,也称为树的高度。
    层序编号
    将树中结点按照从上层到下层、同层从左到右的次序依次给他们编以从1开始的连续自然数。
    有序树、无序树
    如果一棵树中结点的各子树从左到右是有次序的,即若交换了结点各子树的相对位置,则构成不同的树,称这棵树为有序树;反之,称为无序树。除特殊说明,在数据结构中讨论的树一般都是有序树。
    森林
    m(m≥0)棵互不相交的树的集合构成森林。
线性结构 树结构
·第一个数据元素:无前驱 ·根结点:无双亲,唯一
·最后一个数据元素:无后继 ·叶结点:无孩子,可以多个
·中间元素:一个前驱一个后继 ·中间结点:一个双亲多个孩子

5.1.2 树的抽象数据类型定义

树的抽象数据类型的定义。

ADT Tree
Data
  树是由一个根结点和若干棵子树构成,树中结点具有相同数据类型及层次关系
  Operation
  InitTree
   前置条件:树不存在
 输入:无
     功能:初始化一棵树 
     输出:无
     后置条件:构造一个空树
  DestroyTree
   前置条件:树已存在
 输入:无
     功能:销毁一棵树
     输出:无
     后置条件:释放该树占用的存储空间
  Root 
   前置条件:树已存在
 输入:无
     功能:求树的根结点
     输出:树的根结点的信息
     后置条件:树保持不变
  Parent
   前置条件:树已存在
 输入:结点x
     功能:求结点x的双亲
     输出:结点x的双亲的信息 
    后置条件:树保持不变
  Depth
   前置条件:树已存在
 输入:无
     功能:求树的深度
     输出:树的深度 
    后置条件:树保持不变
  PreOrder 
   前置条件:树已存在
 输入:无
     功能:前序遍历树
     输出:树的前序遍历序列
    后置条件:树保持不变
  PostOrder 
   前置条件:树已存在
 输入:无
     功能:后序遍历树
     输出:树的后序遍历序列
    后置条件:树保持不变
  LeverOrder 
   前置条件:树已存在
 输入:无
     功能:层序遍历树
     输出:树的层序遍历序列
    后置条件:树保持不变
endADT

5.1.3 树的遍历操作

  1. 前序遍历
    树的前序遍历操作定义为:
    若树为空,则空操作返回;否则
    ⑴ 访问根结点;
    ⑵ 按照从左到右的顺序前序遍历根结点的每一棵子树。
  2. 后序遍历
    树的后序遍历操作定义为:
    若树为空,则空操作返回;否则
    ⑴ 按照从左到右的顺序后序遍历根结点的每一棵子树;
    ⑵ 访问根结点。
  3. 层序遍历
    树的层序遍历也称为树的广度遍历,其操作定义为从树的第一层(即根结点)开始,自上而下逐层遍历,在同一层中,按从左到右的顺序对结点逐个访问。

5.2 树的存储结构

5.2.1 双亲表示法

它实质上是一个静态链表,每个数组元素的结构为:
在这里插入图片描述
其中,data:数据域,存储结点的数据信息
parent:指针域即游标,存储父结点的地址(数组)下标
下图所示树的双亲表示法为:
数据结构(C++)笔记:05.树和二叉树_第1张图片
这样的存储结构,我们可以根据结点的parent指针很容易找到它的双亲结点,所用的时间复杂度为O(1),直到parent为一1时,表示找到了树结点的根。可如果我们要知道结点的孩子是什么,对不起,请遍历整个结构才行。
这真是麻烦,能不能改进一下呢?
当然可以。我们增加一个结点最左边孩子的域,不妨叫它长子域,这样就可以很容易得到结点的孩子。如果没有孩子的结点,这个长子域就设置为-1,如下表所示。
数据结构(C++)笔记:05.树和二叉树_第2张图片
对于有0个或1个孩子结点来说,这样的结构是解决了要找结点孩子的问题了。
甚至是有2个孩子,知道了长子是谁,另一个当然就是次子了。
另外一个问题场景,我们很关注各兄弟之间的关系,双亲表示法无法体现这样的关系,那我们怎么办?嗯,可以增加一个右兄弟域来体现兄弟关系,也就是说,每一个结点如果它存在右兄弟,则记录下右兄弟的下标。同样的,如果右兄弟不存在,则赋值为-1,如下表所示。
数据结构(C++)笔记:05.树和二叉树_第3张图片

5.2.2 孩子表示法

树的孩子表示法是一种基于链表的存储方法,主要有两种形式:
⑴ 多重链表表示法
由于树中每个结点都可能有多个孩子,因此,链表中的每个结点包括一个数据域和多个指针域,每个指针域指向该结点的一个孩子结点。
其缺点是:
这里的方案一对应C++数据结构(第二版)书上的方案二
方案一
一种是指针域的个数就等于树的度,复习一下,树的度是树各个结点度的最大值。其结构如下表所示。

data child1 child2 child3 childd

其中data是数据域。child1到childd 是指针域,用来指向该结点的孩子结点。
对于上面的树来说,树的度是3,所以我们的指针域的个数是3,这种方法实现如下图所示:
数据结构(C++)笔记:05.树和二叉树_第4张图片
这种方法对于树中各结点的度相差很大时,显然是很浪费空间的,因为有很多的结点,它的指针域都是空的。不过如果树的各结点度相差很小时,那就意味着开辟的空间被充分利用了,这时存储结构的缺点反而变成了优点。
既然很多指针域都可能为空,为什么不按需分配空间呢。于是我们有了第二种第二种方案每个结点指针域的个数等于该结点的度,我们专门取一个位置来存储结点指针域的个数,其结构如下表所示。

data degree child1 child2 childd

其中data为数据域,degree为度域,也就是存储该结点的孩子结点的个数,chid1到chilkd为指针域,指向该结点的各个孩子的结点。
对于上面的树来说,这种方法实现如下图所示:
数据结构(C++)笔记:05.树和二叉树_第5张图片
这种方法克服了浪费空间的缺点,对空间利用率是很高了,但是由于各个结点的链表是不相同的结构,加上要维护结点的度的数值,在运算上就会带来时间上的损耗。
能否有更好的方法,既可以减少空指针的浪费又能使结点结构相同。
仔细观察,我们为了要遍历整棵树,把每个结点放到一个顺序存储结构的数组中是合理的,但每个结点的孩子有多少是不确定的,所以我们再对每个结点的孩子建立一个单链表体现它们的关系。
⑵ 孩子链表表示法
结点结构:
在这里插入图片描述
其中,child存储某结点的孩子结点在表头数组中的下标
next存储指向某结点的下一个孩子结点的指针
data存储某结点的数据信息
firstchild存储指向孩子链表的头指针

struct CTNode     //孩子结点
{
  int child;
  CTNode *next;
};
template <class T>
struct CBNode    //表头结点
{
  T data;
  CTNode *firstchild;  //指向孩子链表的头指针
};

5.2.3 双亲孩子表示法

双亲孩子表示法是将双亲表示法和孩子链表表示法相结合的存储方法。

5.2.4 孩子兄弟表示法

树的孩子兄弟表示法又称为二叉链表表示法。
·结点结构:
在这里插入图片描述
其中,data:数据域,存储该结点的数据信息
firstchild:指针域,存储该结点的第一个孩子结点的存储地址
rightsib:指针域,存储该结点的右兄弟结点的存储地址
数据结构(C++)笔记:05.树和二叉树_第6张图片
这种表示法,给查找某个结点的某个孩子带来了方便,只需要通过fistchild 找到此结点的长子,然后再通过长子结点的rightsib找到它的二弟,接着一直下去,直到找到具体的孩子。当然,如果想找某个结点的双亲,这个表示法也是有缺陷的,那怎么办呢?
如果真的有必要,完全可以再增加一个parent 指针域来解决快速查找双亲的问题,这里就不再细谈了。

5.3 二叉树的逻辑结构

5.3.1 二叉树的定义

二叉树是n(n≥0)个结点的有限集合,该集合或者为空集(称为空二叉树),或者由一个根结点和两棵互不相交的、分别称为根结点的左子树和右子树的二叉树组成。
二叉树的特点有:
■每个结点最多有两棵子树,所以二叉树中不存在度大于2的结点。注意不是只有两棵子树,而是最多有。没有子树或者有一棵子树都是可以的。
■左子树和右子树是有顺序的,次序不能任意颠倒。就像人是双手、双脚,但显然左手、左脚和右手、右脚是不一样的,右手戴左手套、右脚穿左鞋都会极其别扭和难受。
■即使树中某结点只有一棵子树,也要区分它是左子树还是右子树。下图中,树1和树2是同一棵树,但它们却是不同的二叉树。就好像你一不小心,摔伤了手,伤的是左手还是右手,对你的生活影响度是完全不同的。
数据结构(C++)笔记:05.树和二叉树_第7张图片
二叉树具有五种基本形态:⑴ 空二叉树;⑵ 只有一个根结点;⑶ 根结点只有左子树;⑷ 根结点只有右子树;⑸ 根结点既有左子树又有右子树。
应该说这五种形态还是比较好理解的,那我现在问大家,如果是有三个结点的树,有几种形态?如果是有三个结点的二叉树,考虑一下,又有几种形态?
若只从形态上考虑,三个结点的树只有两种情况,那就是下图中有两层的树1和有三层的后四种的任意一种,但对于二叉树来说,由于要区分左右,所以就演变成五种形态,树2、树3、树4和树5分别代表不同的二叉树。
数据结构(C++)笔记:05.树和二叉树_第8张图片
·几种特殊的二叉树:
⑴ 斜树
所有结点都只有左子树的二叉树称为左斜树;所有结点都只有右子树的二叉树称为右斜树;左斜树和右斜树统称为斜树。
上图中的树2就是左斜树,树5就是右斜树。斜树有很明显的特点,就是每一层都只有一个结点,结点的个数与二叉树的深度相同。
有人会想,这也能叫树呀,与我们的线性表结构不是一样吗。对的,其实线性表结构就可以理解为是树的一种极其特殊的表现形式。
⑵ 满二叉树
在一棵二叉树中,如果所有分支结点都存在左子树和右子树,并且所有叶子都在同一层上,这样的二叉树称为满二叉树。
单是每个结点都存在左右子树,不能算是满二叉树,还必须要所有的叶子都在同一层上,这就做到了整棵树的平衡。因此,满二叉树的特点有:
(a)叶子只能出现在最下一层。出现在其他层就不可能达成平衡。
(b)非叶子结点的度一定是2。否则就是“缺胳膊少腿”了。
(c)在同样深度的二叉树中,满二叉树的结点个数最多,叶子数最多。
⑶ 完全二叉树
对一棵具有n个结点的二叉树按层序编号,如果编号为i(1≤i≤n)的结点与同样深度的满二叉树中编号为i的结点在二叉树中的位置完全相同,则这棵二叉树称为完全二叉树。
这是一种有些理解难度的特殊二叉树。
首先从字面上要区分,“完全”和“满”的差异,满二叉树一定是一棵完全二叉树,但完全二叉树不一定是满的。
从这里我也可以得出一些完全二叉树的特点:
(a)叶子结点只能出现在最下两层。
(b)最下层的叶子一定集中在左部连续位置。
(c)倒数二层,若有叶子结点,一定都在右部连续位置。
(d)如果结点度为1,则该结点只有左孩子,即不存在只有右子树的情况。
(e)同样结点数的二叉树,完全二叉树的深度最小。

5.3.2 二叉树的基本性质

性质5-1 二叉树的第i层上最多有 2 i − 1 2^{i-1} 2i1个结点(i≥1)。
这个性质很好记忆,观察一下图。
第一层是根结点,只有一个,所以 2 1 − 1 = 2 0 = 1 2^{1-1}=2^0=1 211=20=1
第二层有两个, 2 2 − 1 = 2 1 = 2 2^{2-1}=2^1=2 221=21=2
第三层有四个, 2 3 − 1 = 2 2 = 4 2^{3-1}=2^2=4 231=22=4
第四层有八个, 2 4 − 1 = 2 3 = 8 2^{4-1}=2^3=8 241=23=8
数据结构(C++)笔记:05.树和二叉树_第9张图片
性质5-2 在一棵深度为k的二叉树中,最多有 2 k − 1 2^k-1 2k1个结点,最少有k个结点。
注意这里一定要看清楚,是 2 k 2^k 2k后再减去1,而不是 2 k − 1 2^{k-1} 2k1。以前很多同学不能完全理解,这样去记忆,就容易把性质2与性质1给弄混淆了。深度为k意思就是有k层的二叉树,我们先来看看简单的。
如果有一层,至多 1 = 2 0 − 1 1=2^0-1 1=201个结点。
如果有二层,至多 1 + 2 = 3 = 2 2 − 1 1+2=3=2^2-1 1+2=3=221个结点。
如果有三层,至多 1 + 2 + 4 = 7 = 2 3 − 1 1+2+4=7=2^3-1 1+2+4=7=231个结点。
如果有四层,至多 1 + 2 + 4 + 8 = 15 = 2 4 − 1 1+2+4+8=15=2^4-1 1+2+4+8=15=241个结点。
通过数据归纳法的论证,可以得出,如果有k层,此二叉树至多有 2 k − 1 2^k-1 2k1个结点。
性质5-3 在一棵二叉树中,如果叶子结点的个数为 n 0 n_0 n0,度为2的结点个数为 n 2 n_2 n2,则 n 0 = n 2 + 1 n_0=n_2+1 n0n21
比如下图的例子,结点总数为10,它是由A、B、C、D等度为2结点,F、G、H、I、J等度为0的叶子结点和E这个度为1的结点组成。总和为4+1+5=10。
数据结构(C++)笔记:05.树和二叉树_第10张图片
我们换个角度,再数一数它的连接线数,由于根结点只有分支出去,没有分支进入,所以分支线总数为结点总数减去1。上图就是9个分支。对于A、B、C、D结点来说,它们都有两个分支线出去,而E结点只有一个分支线出去。所以总分支线为4×2+1×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
·完全二叉树的两个性质。
性质5-4 具有n个结点的完全二叉树的深度为 ⌊ l o g 2 n ⌋ + 1 \left \lfloor log_2n\right \rfloor +1 log2n+1
由满二叉树的定义我们可以知道,深度为k的满二叉树的结点数n一定是 2 k − 1 2^k-1 2k1
因为这是最多的结点个数。那么对于 n = 2 k − 1 n=2^k-1 n=2k1倒推得到满二叉树的度数为 k = l o g 2 ( n + 1 ) k=log_2(n+1) k=log2(n+1),比如结点数为15的满二叉树,度为4。
完全二叉树我们前面已经提到,它是一棵具有n个结点的二叉树,若按层序编号后其编号与同样深度的满二叉树中编号结点在二叉树中位置完全相同,那它就是完全二叉树。也就是说,它的叶子结点只会出现在最下面的两层。
它的结点数一定少于等于同样度数的满二叉树的结点数 2 k − 1 2^k-1 2k1,但一定多于 2 k − 1 − 1 2^{k-1}-1 2k11。即满足 2 k − 1 − 1 < n ≤ 2 k − 1 < n 2^{k-1}-12k11<n2k1<n。由于结点数n是整数, n ≤ 2 k − 1 n≤2^k-1 n2k1意味着 n < 2 k n<2^k n<2k n > 2 k − 1 − 1 n>2^{k-1}-1 n>2k11,意味着 n ≥ 2 k − 1 n≥2^{k-1} n2k1,所以 2 k − 1 ≤ n < 2 k 2^{k-1}≤n<2^k 2k1n<2k,不等式两边取对数,得到 k − 1 ≤ l o g 2 n < k k-1≤log_2nk1log2n<k,而k作为度数也是整数,因此 k = ⌊ l o g 2 n ⌋ + 1 k=\left \lfloor log_2n\right \rfloor +1 k=log2n+1
性质5-5 对一棵具有n个结点的完全二叉树中的结点从1开始按层序编号,则对于任意的编号为i(1≤i≤n)的结点(简称为结点i),有:
⑴ 如果i>1,则结点i的双亲的编号为 ⌊ i / 2 ⌋ \left \lfloor i/2\right \rfloor i/2 ;否则结点i是根结点,无双亲;
⑵ 如果2i≤n,则结点i的左孩子的编号为2i;否则结点i无左孩子;
⑶ 如果2i+1≤n,则结点i的右孩子的编号为2i+1;否则结点i无右孩子。
我们以下图为例,来理解这个性质。这是一个完全二叉树,度为4,结点总数是10。
数据结构(C++)笔记:05.树和二叉树_第11张图片
对于第一条来说是很显然的,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。

5.3.3 二叉树的抽象数据类型定义

ADT BiTree
Data
   二叉树是由一个根结点和两棵互不相交的左右子树构成
   二叉树中的结点具有相同数据类型及层次关系
Operation
  InitBiTree
  前置条件:无
输入:无
    功能:初始化一棵二叉树 
    输出:无
    后置条件:构造一个空的二叉树
           DestroyBiTree 
  前置条件:二叉树已存在
  输入:无
    功能:销毁一棵二叉树
    输出:无
    后置条件:释放二叉树占用的存储空间 
 InsertL
  前置条件:二叉树已存在
输入:数据值x,指针parent
   功能:将数据域为x的结点插入到二叉树中,作为结点parent的左孩子。如果结点parent原来有左孩子,则将结点parent原来的左孩子作为结点x的左孩子
    输出:无
    后置条件:如果插入成功,得到一个新的二叉树 
 InsertR 
  前置条件:二叉树已存在
输入:数据值x,指针parent
功能:将数据域为x的结点插入到二叉树中作为结点parent的右孩子。如果结点parent原来有右孩子,则将结点parent原来的右孩子作为结点x的右孩子
输出:无
后置条件:如果插入成功,得到一个新的二叉树
 DeleteL 
  前置条件:二叉树已存在
输入:指针parent
功能:在二叉树中删除结点parent的左子树
输出:无
    后置条件:如果删除成功,得到一个新的二叉树
 DeleteR 
  前置条件:二叉树已存在
输入:指针parent
功能:在二叉树中删除结点parent的右子树
输出:无
    后置条件:如果删除成功,得到一个新的二叉树
 Search 
    前置条件:二叉树已存在
输入:数据值x
功能:在二叉树中查找数据元素x
输出:指向该元素结点的指针
    后置条件:二叉树不变 
PreOrder
  前置条件:二叉树已存在
输入:无
功能:前序遍历二叉树
输出:二叉树中结点的一个线性排列
后置条件:二叉树不变 
InOrder  
  前置条件:二叉树已存在
输入:无
功能:中序遍历二叉树
输出:二叉树中结点的一个线性排列
后置条件:二叉树不变 
PostOrder
  前置条件:二叉树已存在
输入:无
功能:后序遍历二叉树
输出:二叉树中结点的一个线性排列
后置条件:二叉树不变 
LeverOrder
  前置条件:二叉树已存在
输入:无
功能:层序遍历二叉树
输出:二叉树中结点的一个线性排列
    后置条件:二叉树不变 
endADT

5.3.4 二叉树的遍历操作

二叉树的遍历是指从根结点出发,按照某种次序访问二叉树中的所有结点,使得每个结点被访问一次且仅被访问一次。由于二叉树中每个结点都可能有两棵子树,因而需要寻找一条合适的搜索路径。
在这里插入图片描述

  1. 前序遍历
    前序遍历二叉树的操作定义为:
    若二叉树为空,则空操作返回;否则
    ⑴ 先访问根;
    ⑵ 然后前序遍历左子树;
    ⑶前序遍历右子树。
  2. 中序遍历
    中序遍历二叉树的操作定义为:
    若二叉树为空,则空操作返回;否则
    (1)从根结点开始(注意并不是先访问根结点)中序遍历根结点的左子树;
    (2)然后是访问根结点;
    (3)最后中序遍历右子树。
  3. 后序遍历
    后序遍历二叉树的操作定义为:
    若二叉树为空,则空操作返回;否则
    (1))先叶子后结点的方式遍历访问左子树;
    (2)先叶子后结点的方式遍历访问右子树;
    (3)访问根节点.
  4. 层序遍历
    二叉树的层序遍历,是指从二叉树的第一层(根结点)开始,从上至下逐层遍历,在同一层中,则按从左到右的顺序对结点逐个访问。
    数据结构(C++)笔记:05.树和二叉树_第12张图片
    前序ABDGHCEIF。
    中序GDHBAEICF。
    后序GHDBIEFCA。
    任意一棵二叉树的遍历序列都是唯一的。
    由二叉树的前序遍历序列和中序遍历序列,唯一确定这棵二叉树;由二叉树的后序序列和中序序列也可唯一确定一棵二叉树,但是,如果只知道二叉树的前序序列和后序序列,则不能唯一地确定一棵二叉树。

5.4 二叉树的存储结构及实现

5.4.1 顺序存储结构

具体步骤如下:
⑴ 将二叉树按完全二叉树编号。根结点的编号为1,若某结点i有左孩子,则其左孩子的编号为2i;若某结点i有右孩子,则其右孩子的编号为2i+1;
⑵ 以编号作为下标,将二叉树中的结点存储到一维数组中
这种存储结构的缺点是:
考虑一种极端的情况,一棵深度为k的右斜树,它只有k个结点,却需要分配2*-1个存储单元空间,这显然是对存储空间的浪费,例如图6-7-4所示。所以,顺序存储结构一般只用于完全二叉树。
数据结构(C++)笔记:05.树和二叉树_第13张图片

5.4.2 二叉链表

结点结构为:
在这里插入图片描述
其中,data:数据域,存储结点数据
lchild:左指针域,存储左孩子指针
rchild:右指针域,存储右孩子指针

template <class T>
struct BiNode
{
  T data;
  BiNode<T> *lchild, *rchild;
};

声明:

template <class T>
class BiTree
{
public:
  BiTree( ){root=NULL;}  //无参构造函数,初始化一棵空的二叉树
  BiTree(BiNode<T> *root); //有参构造函数,初始化一棵二叉树,其前序序列由键盘输入
  ~BiTree( );            //析构函数,释放二叉链表中各结点的存储空间
  void PreOrder(BiNode<T> *root);     //前序遍历二叉树
  void InOrder(BiNode<T> *root);      //中序遍历二叉树
  void PostOrder(BiNode<T> *root);    //后序遍历二叉树
  void LeverOrder(BiNode<T> *root);   //层序遍历二叉树
private:
  BiNode<T> *root;   //指向根结点的头指针
   void Creat(BiNode<T> *root);     //有参构造函数调用
  void Release(BiNode<T> *root);   //析构函数调用
};

1.前序遍历
·前序遍历的递归算法

template <class T>
void BiTree::PreOrder(BiNode<T> *root) 
{
	if (root ==NULL)  
		return;     //递归调用的结束条件
	else 
	{
		cout<<root->data;         //访问根结点的数据域
		PreOrder(root->lchild);    //前序递归遍历root的左子树
		PreOrder(root->rchild);    //前序递归遍历root的右子树  
	}
}

·前序遍历的非递归算法
PS:递归算法的优点是简洁,但一般效率不高,因为系统需要维护一个工作栈以保证递归函数的正确执行。因此有时候需要把递归算法改为非递归算法。递归转化为非递归的关键是如何实现由系统完成的递归工作栈,一般可仿照递归算法执行过程中工作栈的状态变化得到
关键问题:在前序遍历过某结点的整个左子树后,如何找到该结点的右子树的根指针。
解决方法:在访问完该结点后,将该结点的指针保存在栈中,以便以后能通过它找到该结点的右子树。一般地,在前序遍历中,设要遍历二叉树的根指针为root,可能有两种情况:
(1)若root!=NULL,则表明当前二叉树不空,此时,应输出根结点root的值并将root保存到栈中,继续遍历root的左子树。
(2)若root=NULL,则表明以root为根指针的二叉树遍历完毕,并且root是栈顶指针所指结点的左子树。
·若栈不空,则应根据栈顶指针所指结点找到待遍历右子树的根指针并赋予root,以继续遍历下去;
.若栈空,则表明整个二叉树遍历完毕,应结束,所以循环结束的条件是root为空并且栈也为空。

template <class T>
void BiTree::PreOrder(BiNode<T> *root) 
{
	top= -1;      //采用顺序栈,并假定不会发生上溢
	while (root!=NULL | | top!= -1)
	{
		while (root!= NULL)
		{
			cout<<root->data;
			s[++top]=root;
			root=root->lchild;  
		}
		if (top!= -1) { 
			root=s[top--];
			root=root->rchild;  
		}
	}
}
  1. 中序遍历
    ·中序遍历的递归算法:
template <class T>
void BiTree::InOrder (BiNode<T> *root)
{
	if (root==NULL) 
		return;     //递归调用的结束条件
	else 
	{
		InOrder(root->lchild);    //中序递归遍历root的左子树
		cout<<root->data;         //访问根结点的数据域
		InOrder(root->rchild);    //中序递归遍历root的右子树
	}
}

中序遍历的非递归算法:只需将前序遍历的非递归算法中的输出语句cout

template <class T>
void BiTree::PreOrder(BiNode<T> *root) 
{
	top= -1;      //采用顺序栈,并假定不会发生上溢
	while (root!=NULL | | top!= -1)
	{
		while (root!= NULL)
		{			
			s[++top]=root;
			root=root->lchild;  
		}
		if (top!= -1) { 
			root=s[top--];
			cout<<root->data;
			root=root->rchild;  
		}
	}
}
  1. 后序遍历
    ·后序遍历的递归算法:
template <class T>
void BiTree::PostOrder(BiNode<T> *root)
{ 
	if (root==NULL) 
		return;     //递归调用的结束条件
	else {
	    PostOrder(root->lchild);    //后序递归遍历root的左子树
	    PostOrder(root->rchild);    //后序递归遍历root的右子树
	    cout<<root->data;          //访问根结点的数据域
	}
}

·后序遍历的非递归算法:结点要入两次栈,出两次栈,这两种情况的含义与处理方法为:
⑴ 第一次出栈:
(1)第一次出栈:只遍历完左子树,右子树尚未遍历,则该结点不出栈,利用栈顶结点找到它的右子树,准备遍历它的右子树。
⑵ 第二次出栈:
遍历完右子树,将该结点出栈并访问之
为了区别同一个结点的两次出栈,设置标志flag,令flag=1表示第一次出栈,只遍历完左子树,该结点不能访问;flag=2表示第二次出栈,遍历完右子树,该结点可以访问。当结点指针进、出栈时,其标志flag也同时进、出栈。
为了区别同一个结点的两次出栈,设置标志flag,当结点进、出栈时,其标志flag也同时进、出栈。
因此,栈元素结点包括两个域,定义如下:

template <class T>
struct element
{
	BiNode<T> *ptr;
	int flag;//值可以为1或者2.
};

设根指针为root,则可能有以下两种情况:
⑴ 若root!=NULL,则root及标志flag(置为1)入栈,遍历其左子树;
⑵ 若root=NULL,此时若栈空,则整个遍历结束;若栈不空,则表明栈顶结点的左子树或右子树已遍历完毕。若栈顶结点的标志flag=1,则表明栈顶结点的左子树已遍历完毕,将flag修改为2,并遍历栈顶结点的右子树;若栈顶结点的标志flag=2,则表明栈顶结点的右子树也遍历完毕,输出栈顶结点。

template <class T>
void BiTree::PostOrder(BiNode<T> *root) 
{
	top= -1;     //采用顺序栈,并假定栈不会发生上溢
	while (root!=NULL | | top!= -1)
	{
		while (root!=NULL)
		{
			top++;
			s[top].ptr=root;  
			s[top].flag=1;
			root=root->lchild;  
		}
		while (top!= -1 && s[top].flag==2)
		{
			root=s[top--].ptr;
			cout<<root->data;
		}
		if (top!= -1) 
		{
			s[top].flag=2;
			root=s[top].ptr->rchild;
		}
	}
}
  1. 层序遍历
    设置一个队列存放已访问的结点,为什么?
    在进行层序遍历时,对某一层的结点访问完后,再按照它们的访问次序对各个结点的左孩子和右孩子顺序访问,这样一层一层进行,先访问的结点其左右孩子也要先访问,这与队列的操作原则比较吻合。因此,在进行层序遍历时,可设置一个队列存放已访问的结点。
    遍历从二叉树的根结点开始,首先将根指针入队,然后从队头取出一个元素,每取一个元素,执行下面的操作:
    ⑴ 访问该指针所指结点;
    ⑵ 若该指针所指结点的左、右孩子结点非空,则将其左孩子指针和右孩子指针入队。
    此过程不断进行,当队列为空时,二叉树的层次遍历结束。算法用伪代码描述如下:
    a. 队列Q初始化;
    b. 如果二叉树非空,将根指针入队;
    c. 循环直到
    c.1 q=队列Q的队头元素出队;
    c.2 访问结点q的数据域;
    c.3 若结点q存在左孩子,则将左孩子指针入队
    c.4 若结点q存在右孩子,则将右孩子指针入队
template <class T>
void BiTree::LeverOrder(BiNode<T> *root)
{
	front=rear=0;  //采用顺序队列,并假定不会发生上溢
	if (root==NULL) return;
	Q[++rear]=root;
	while (front!=rear)
	{
		q=Q[++front];
		cout<<q->data;   
		if (q->lchild!=NULL)  Q[++rear]=q->lchild;
		if (q->rchild!=NULL)  Q[++rear]=q->rchild;
	}
}
  1. 二叉树的建立(有参构造函数)
    设二叉树中的结点均为一个字符。假设扩展二叉树的前序遍历序列由键盘输入,root为指向根结点的指针,二叉链表的建立过程是:首先输入根结点,若输入的是一个“#”字符,则表明该二叉树为空树,即root=NULL;否则输入的字符应该赋给root->data,之后依次递归建立它的左子树和右子树。
template <class T>
BiTree ::BiTree(BiNode<T> *root)
{
  creat(root)}
template <class T>
void BiTree ::Creat(BiNode<T> *root)
{
    cin>>ch;
    if (ch=='# ') root=NULL;    //建立一棵空树
    else {
         root=new BiNode<T>;   //生成一个结点
         root->data=ch;
         Creat(root->lchild);   //递归建立左子树
         Creat(root->rchild);   //递归建立右子树
    }  
}
  1. 析构函数
    采用后序遍历,当访问某结点时将该结点的存储空间释放。为什么?
    二叉链表属于动态存储分配,需要在析构函数中释放二叉链表中的所有结点。在释放某结点时,该结点的左、右子树都已经被释放,所以,应采用后序遍历,当访问某结点时将该结点的存储空间释放
    具体算法如下:
template <class T>
BiTree ::~BiTree(BiNode<T> *root)
{
  Release(root);
}

void BiTree ::Release(BiNode<T> *root)
{
	if (root!=NULL) 
	{
		Release(root->lchild);   //释放左子树
		Release(root->rchild);   //释放右子树
		delete root;
	}  
}

5.4.3 三叉链表

在二叉链表存储方式下,从某结点出发可以直接访问到它的孩子结点,但要找到它的双亲,则需要从根结点开始搜索,最坏情况下,需要遍历整个二叉链表。此时,应该采用三叉链表存储。三叉链表的结点结构为:
在这里插入图片描述
其中,data、lchild和rchild三个域的含义同二叉链表的结点结构;parent域为指向该结点的双亲结点的指针。
三叉链表存储结构既便于查找孩子结点,又便于查找双亲结点。
但是,相对于二叉链表而言,它增加了空间开销如果二叉树中的结点个数相对稳定,可以采用静态链表形式表示三叉链表。

5.4.4 线索链表

为什么要建立线索链表?
我们现在提倡节约型社会,一切都应该节约为本。对待我们的程序当然也不例外,能不浪费的时间或空间,都应该考虑节省。我们再来观察下图,会发现指针域并不是都充分的利用了,有许许多多的“A”,也就是空指针域的存在,这实在不是好现象,应该要想办法利用起来。
数据结构(C++)笔记:05.树和二叉树_第14张图片
首先我们要来看看这空指针有多少个呢?对于一个有n个结点的二叉链表,每个结点有指向左右孩子的两个指针域,所以一共是2n个指针域。而n个结点的二叉树。
一共有n-1条分支线数,也就是说,其实是存在2n-(n-1)=n+1个空指针域。比如上图有10个结点,而带有“^”空指针域为11。这些空间不存储任何事物,白白的浪费着内存的资源。
另一方面,我们在做遍历时,比如对上图做中序遍历时,得到了HDIBJEAFCG这样的字符序列,遍历过后,我们可以知道,结点1的前驱是D,后继是B,结点F的前驱是A,后继是C。也就是说,我们可以很清楚的知道任意一个结点,它的前驱和后继是哪一个。
可是这是建立在已经遍历过的基础之上的。在二叉链表上,我们只能知道每个结点指向其左右孩子结点的地址,而不知道某个结点的前驱是谁,后继是谁。要想知道,必须遍历一次。以后每次需要知道时,都必须先遍历一次。为什么不考虑在创建时就记住这些前驱和后继呢,那将是多大的时间上的节省。
综合刚才两个角度的分析后,我们可以考虑利用那些空地址,存放指向结点在某种遍历次序下的前驱和后继结点的地址。就好像GPS导航仪一样,我们开车的时候,哪怕我们对具体目的地的位置一无所知,但它每次都可以告诉我从当前位置的下一步应该走向哪里。这就是我们现在要研究的问题。我们把这种指向前驱和后继的指针称为线索,加上线索的二叉链表称为线索链表,相应的二叉树就称为线索二叉树。
请看下图,我们把这棵二叉树进行中序遍历后,将所有的空指针域中的rchild,改为指向它的后继结点。于是我们就可以通过指针知道H的后继是D(图中①),I的后继是B(图中②),J的后继是E(图中③),E的后继是A(图中④),F的后继是C(图中⑤),G的后继因为不存在而指向NULL(图中⑥)。此时共有6个空指针域被利用。
数据结构(C++)笔记:05.树和二叉树_第15张图片
再看下图,我们将这棵二叉树的所有空指针域中的child,改为指向当前结点的前驱。因此H的前驱是NULL(图中①),1的前驱是D(图中②),J的前驱是B(图中③),F的前驱是A(图中④),G的前驱是C(图中⑤)。一共5个空指针域被利用,正好和上面的后继加起来是11个。
数据结构(C++)笔记:05.树和二叉树_第16张图片
通过下图(空心箭头实线为前驱,虚线黑箭头为后继),就更容易看出,其实线索二叉树,等于是把一棵二叉树转变成了一个双向链表,这样对我们的插入删除结点、查找某个结点都带来了方便。所以我们对二叉树以某种次序遍历使其变为线索二叉树的过程称做是线索化。
数据结构(C++)笔记:05.树和二叉树_第17张图片
不过好事总是多磨的,问题并没有彻底解决。我们如何知道某一结点的lchid 是指向它的左孩子还是指向前驱?rchild是指向右孩子还是指向后继?比如E结点的lchid是指向它的左孩子J,而rchild却是指向它的后继A。显然我们在决定lchild 是指向左孩子还是前驱,rchild是指向右孩子还是后继上是需要一个区分标志的。因此,我们在每个结点再增设两个标志域lag和rtag,注意ltag和rtag只是存放0或1数字的布尔型变量,其占用的内存空间要小于像lchild和rchild的指针变量。结点结构如下:
在这里插入图片描述
其中:
ltag为0时指向该结点的左孩子,为1时指向该结点的前驱。·
rtag为0时指向该结点的右孩子,为1时指向该结点的后继。
由于二叉树的遍历次序有4种,故有4种意义下的前驱和后继,相应的有4种线索链表:前序线索链表、中序线索链表、后序线索链表、层序线索链表。

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

从物理结构上看,树的孩子兄弟表示法和二叉树的二叉链表是相同的,只是解释不同而已。
以二叉链表作为媒介,可导出树和二叉树之间的一个对应关系。也就是说,给定一棵树,可以找到唯一的一棵二叉树与之对应。这样,对树的操作实现就可以借助二叉树存储,利用二叉树上的操作来实现。
1.树转换为二叉树
将一棵树转换为二叉树的方法是:
a.加线。在所有兄弟结点之间加一条连线。
b.去线。对树中每个结点,只保留它与第一个孩子结点的连线,删除它与其他孩子结点之间的连线。
c.层次调整。以树的根结点为轴心,将整棵树顺时针旋转一定的角度,使之结构层次分明。注意第一个孩子是二叉树结点的左孩子,兄弟转换过来的孩子是结点的右孩子。
数据结构(C++)笔记:05.树和二叉树_第18张图片
2.森林转换为二叉树
将一个森林转换为二叉树的方法是:
⑴ 将森林中的每棵树转换成二叉树;
⑵ 从第二棵二叉树开始,依次把后一棵二叉树的根结点作为前一棵二叉树根结点的右孩子,当所有二叉树连起来后,此时所得到的二叉树就是由森林转换得到的二叉树。
3.二叉树转换为树或森林
将一棵二叉树转换为树(或森林)的方法是:
a.从根结点开始,若右孩子存在,则把与右孩子结点的连线删除,再查看分离后的二叉树,若右孩子存在,则连线删除……,直到所有右孩子连线都删除为止,得到分离的二叉树。
b.再将每棵分离后的二叉树转换为树即可。

5.6 应用举例

5.6.1 二叉树的应用举例——哈夫曼树及哈夫曼编码

  1. 哈夫曼树
    ·叶子结点的权值
    叶子结点的权值是对叶子结点赋予的一个有意义的数值量。
    ·二叉树的带权路径长度
    设二叉树具有n个带权值的叶子结点,从根结点到各个叶子结点的路径长度与相应叶子结点权值的乘积之和叫做二叉树的带权路径长度(Weighted Path Length),记为:
    W P L = ∑ k = 1 n w k l k WPL=\sum_{k=1}^nw_kl_k WPL=k=1nwklk
    其中, w k w_k wk为第k个叶子结点的权值; l k l_k lk为从根结点到第k个叶子结点的路径长度。
    哈夫曼树
    给定一组具有确定权值的叶子结点,可以构造出不同的二叉树,将其中带权路径长度最小的二叉树称为哈夫曼树,也称为最优二叉树。
    哈夫曼算法的基本思想是:
    ⑴ 初始化:
    ⑵ 选取与合并:
    ⑶ 删除与加入:
    ⑷ 重复⑵、⑶两步,当集合F中只剩下一棵二叉树时,这棵二叉树便是哈夫曼树。
    哈夫曼算法的存储结构。
    设置一个数组huffTree[2n-1]保存哈夫曼树中各结点的信息,数组元素的结点结构为:
    在这里插入图片描述
    其中,weight:权值域,保存该结点的权值;
    lchild:指针域(游标),保存该结点的左孩子在数组中的下标
    rchild:指针域(游标),保存该结点的右孩子在数组中的下标
    parent:指针域(游标),保存该结点的双亲在数组中的下标
struct element
{
	int weight;
	int lchild,rchild,parent;
};

如何判定一个结点是否已加入到哈夫曼树中?
可通过parent域的值来判断,当某结点已加入到哈夫曼树中时,该结点parent域的值为其双亲结点在数组中的下标。
2. 哈夫曼编码
·等长编码:
·不等长编码:
·前缀编码:
哈夫曼树可用于构造最短的不等长编码方案。对于{A、B、C、D、E}五个字符,使用的频率分别为{35,25,15,15,10},图5-41给出了哈夫曼编码树及哈夫曼编码。
数据结构(C++)笔记:05.树和二叉树_第19张图片
对编码串"110100101"进行解码,其过程为:
对字符串编码串的解码则是将编码串从左到右逐位判别,直到确定一个字符。这可以用生成哈夫曼树的逆过程实现。从根结点开始,根据每一位的值是0还是1确定选择左分支还是右分支——一直到到达一个叶子结点,然后再从根出发,开始下一个字符的解码。
如对编码串110100101进行解码,根据图5-41所示哈夫曼编码树,从根结点开始,由于第一位是1,所以选择右分支,下一位是1,选择右分支,到达叶子结点对应的字符A:再从根结点起,下一位是0,选择左分支,下一位是1,选择右分支,到达叶子结点对应的字符C。类似地,完成全部解码为ACBD。
由于哈夫曼编码树的每个字符结点都是叶子结点,它们不可能在根结点到其他字符结点的路径上,所以一个字符的哈夫曼编码不可能是另一个字符的哈夫曼编码的前缀,从而保证了解码的唯一性。
在哈夫曼编码树中,树的带权路径长度的含义是各个字符的码长与其出现次数的乘积之和,即字符串编码的总长度。所以哈夫曼偏码是一种能使字符串的编码总长度最短的不等长编码。

5.6.2 树的应用举例——八枚硬币问题

设有八枚硬币,分别表示为a,b,c,d,e,f,g,h,其中有且仅有一枚硬币是假币,并且假币的重量与真币的重量不同,可能轻,也可能重。现要求以天平为工具,用最少的比较次数挑选出假币,并同时确定这枚假币的重量比其它真币是轻还是重。
从八枚硬币中任取六枚a,b,c,d,e,f,在天平两端各放三枚进行比较。假设a,b,c三枚放在天平的一端,d,e,f三枚放在天平的另一端,可能出现三种比较结果:
⑴ a+b+c>d+e+f
⑵ a+b+c=d+e+f
⑶ a+b+c 问题的解决是经过一系列的判断,这些判断构成了树结构,可以用判定树来描述这个判定过程。大写字母H和L分别表示假币较其它真币重或轻,边线旁边给出的是天平的状态。八枚硬币中,每一枚硬币都可能是或轻或重的假币,因此共有16种结果,反映在树中,则有16个叶子结点,从图中可看出,每种结果都需要经过三次比较才能得到。
数据结构(C++)笔记:05.树和二叉树_第20张图片

参考书目:

大话数据结构
数据结构——从概念到C++实现(第2版)(第3版)
数据结构(C++版)教师用书

你可能感兴趣的:(数据结构和算法)