树和二叉树
树结构是一类重要的非线性数据结构。直观来看,树是以分支关系定义的层次结构。树结构在客观世界中广泛存在,如人类社会的族谱和各种社会组织机构都可用树来形象表示。树在计算机领域中也得到广泛应用,尤以二叉树最为常用。如在操作系统中,用树来表示文件目录的组织结构,在编译系统中,用树来表示源程序的语法结构,在数据库系统中,树结构也是信息的重要组织形式之一。
树和二叉树的定义
树的定义
树(Tree)是n(n≥0)个结点的有限集,它或为空树(n=0);或为非空树,对于非空树T:
- 有且仅有一个称之为根的结点;
- 除根结点以外的其余结点可分为 m(m>0)个互不相交的有限集 T 1 , T 2 , … , T m T_1,T_2,…,T_m T1,T2,…,Tm,其中每一个集合本身又是一棵树,并且称为根的子树(SubTree)。
如,在下图中,(a)是只有一个根结点的树;(b)是有13个结点的树,其中A是根,其余结点分成3个互不相交的子集: T 1 = { B , E , F , K , L } , T 2 = { C , G } , T 3 = { D , H , I , J , M } 。 T_1=\{B,E, F, K, L\}, T_2=\{C,G\},T_3=\{D, H, I, J, M\}。 T1={B,E,F,K,L},T2={C,G},T3={D,H,I,J,M}。 T 1 、 T 2 T_1、T_2 T1、T2和 T 3 T_3 T3都是根A的子树,且本身也是一棵树。例如 T 1 T_1 T1,其根为B,其余结点分为两个互不相交的子集: T 11 = { E , K , L } , T 12 = { F } T_{11}=\{E,K,L\},T_{12}=\{F\} T11={E,K,L},T12={F}。 T 11 T_{11} T11和 T 12 T_{12} T12都是B的子树。而 T 11 T_{11} T11中E是根,{K}和{L}是E的两棵互不相交的子树,其本身又是只有一个根结点的树。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ElWxM8zD-1630834564974)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.1snlu7sniveo.png)
显然,树的定义是递归的,即在树的定义中又用到了其自身,树是一种递归的数据结构。树作为一种逻辑结构,同时也是一种分层结构,具有以下几个性质:
- 树的根结点没有前驱,除根结点外的所有结点有且只有一个前驱。
- 树中所有结点可以有零个或多个后继。
- 树中的结点数等于所有结点的度数加1。
- 度为m的树中第i层上至多有 m i − 1 m^{i-1} mi−1个结点 ( i ≥ 1 ) (i ≥1) (i≥1)。
- 高度为h的m叉树至多有 ( m h − 1 ) / ( m − 1 ) (m^h-1)/(m-1) (mh−1)/(m−1)个结点。
- 具有n个结点的m叉树的最小高度为 [ log m ( n ( m − 1 ) + 1 ) ] [\log_m {(n(m-1)+1)}] [logm(n(m−1)+1)]。
树适合于表示具有层次结构的数据。树中的某个结点(除根结点外)最多只和上一层的一个结点(即其父结点)有直接关系,根结点没有直接上层结点,因此在n个结点的树中有n-1条边。而树中每个结点与其下一层的零个或多个结点(即其子女结点)有直接关系。
树的基本术语
名词 | 解释 |
---|---|
结点 | 树中的一个独立单元。包含一个数据元素及若干指向其子树的分支。 |
结点的度 | 结点拥有的子树数称为结点的度。如上图(b)中,A的度为3,C度为1,F度为0。 |
树的度 | 树的度是树内各结点度的最大值。 |
叶子 | 度为 0 的结点称为叶子或终端结点。 |
非终端结点 | 度不为 0 的结点称为非终端结点或分支结点。除根结点之外,非终端结点也称为内部结点。 |
双亲和孩子 | 结点的子树的根称为该结点的孩子,相应地,该结点称为孩子的双亲。如上图(b)中,B的双亲为A,B的孩子有E和F。 |
兄弟 | 同一个双亲的孩子之间互称兄弟。例如上图(b)中,H、I 和 J 互为兄弟。 |
祖先 | 从根到该结点所经分支上的所有结点。例如上图(b)中,M 的祖先为 A 、 D 和H。 |
子孙 | 以某结点为根的子树中的任一结点都称为该结点的子孙。例如上图(b)中,B 的子孙为E、K、 L 和F。 |
层次 | 结点的层次从根开始定义起,根为第一层,根的孩子为第二层。树中任一结点的层次等于其双亲结点的层次加 1。 |
堂兄弟 | 双亲在同一层的结点互为堂兄弟。例如,结点 G 与E 、 F、 H 、 I 、 J互为堂兄弟。 |
树的深度 | 树中结点的最大层次称为树的深度或高度。如上图(b)中,树的深度为4。 |
有序树和无序树 | 如果将树中结点的各子树看成从左至右是有次序的(即不能互换),则称该树为有序树,否则称为无序树。在有序树中最左边的子树的根称为第一个孩子,最右边的称为最后一个孩子。 |
路径和路径长度 | 树中两个结点之间的路径是由这两个结点之间所经过的结点序列构成的,而路径长度是路径上所经过的边的个数。由于树中的分支是有向的,即从双亲指向孩子,所以树中的路径是从上向下的,同一双亲的两个孩子之间不存在路径。 |
森林 | 森林是m (m≥0)棵互不相交的树的集合。森林的概念与树的概念十分相近,因为只要把树的根结点删去就成了森林。反之,只要给m棵独立的树加上一个结点,并把这m棵树作为该结点的子树,则森林就变成了树。 |
二叉树的定义
二叉树(Binary Tree)是n(n≥0)个结点所构成的集合,它或为空树(n=0); 或为非空树,对于非空树T:
- 有且仅有一个称之为根的结点;
- 除根结点以外的其余结点分为两个互不相交的子集 T 1 T_1 T1和 T 2 T_2 T2, 分别称为T的左子树和右子树,且 T 1 T_1 T1和 T 2 T_2 T2本身又都是二叉树。
二叉树与树一样具有递归性质,二叉树与树的区别主要有以下两点:
- 二叉树每个结点至多只有两棵子树(即二叉树中不存在度大于2 的结点);
- 二叉树的子树有左右之分,其次序不能任意颠倒。
二叉树是有序树,若将其左、右子树颠倒,则成为另一棵不同的二叉树。即使树中结点只有一棵子树,也要区分它是左子树还是右子树。二叉树的5种基本形态如下图所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SF8XiIoy-1630834649275)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.y99zwnb80u8.png)
树和二叉树的抽象数据类型定义
根据树的结构定义,加上树的一组基本操作就构成了树的抽象数据类型定义:
ADT Tree{
数据对象D:D 是具有相同特性的数据元素的集合。
数据关系R:若 D 为空集,则称为空树;其余略。
基本操作P:
InitTree(&T); //构造空树T。
DestroyTree(&T); //销毁树T。
CreateTree(&T,definition); //按definition构造树T。
ClearTree(&T); //将树T清为空树。
TreeEmpty(T); //若 T 为空树,则返回 true, 否则 false。
TreeDepth(T); //返回T的深度。
Root(T); //返回T的根。
Value(T,cur_e); //返回 cur_e 的值。
Assign(T,cur_e,value); //结点 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); //插入c为T中p指结点的第i棵子树。
DeleteChild(&T,p,i); //删除T中 p 所指结点的第i棵子树。
TraverseTree(T); //按某种次序对T的每个结点访问一次。
}ADT Tree
二叉树的抽象数据类型定义如下:
ADT BinaryTree{
数据对象D:D 是具有相同特性的数据元素的集合。
数据关系R:若 D=∮,则R=∮,称BinaryTree为空二叉树;其余略。
基本操作P:
InitBiTree(&T); //构造空二叉树T。
DestroyBiTree(&T); //销毁二叉树T。
CreateBiTree(&T,definition); //按definition构造二叉树T。
ClearBiTree(&T); //将二叉树T清为空树。
BiTreeEmpty(T); //若T为空二叉树,则返回true, 否则false。
BiTreeDepth(T); //返回T的深度。
Root(T); //返回T的根。
Value(T,e); //返回e的值。
Assign(T,&e,value); //结点e赋值为value。
Parent(T,e); //若e是T的非根结点,则返回它的双亲,否则返回“空”。
LeftChild(T,e); //返回e的左孩子。若e无左孩子,则返回“空”。
RightChild(T,e); //返回e的右孩子。若e无右孩子,则返回“空”。
LeftSibling(T, e); //返回e的左兄弟。若e是T的左孩子或无左兄弟,则返回 “空”。
RightSibling(T,e); //返回e的右兄弟。若e是T的右孩子或无右兄弟,则返回 “空”。
InsertChild(&T,p,LR,c); //根据LR为0或1,插入c为T中p所指结点的左或右子树。p所指结点的原有左或右子树则成为c的右子树。
DeleteChild(&T, p, LR); //根据LR为0或1, 删除T中p所指结点的左或右子树。
PreOrderTraverse(T); //先序遍历T, 对每个结点访问一次。
InOrderTraverse(T); //中序遍历T, 对每个结点访问一次。
PostOrderTraverse(T); //后序遍历T, 对每个结点访问一次。
LevelOrderTraverse(T); //层序遍历T, 对每个结点访问一次。
}ADT BinaryTree
二叉树的性质和存储结构
几种特殊的二叉树
-
满二叉树
一棵高度为h,且含有 2 h − 1 2^h-1 2h−1个结点的二叉树称为满二叉树,即树中的每层都含有最多的结点,如图(a)所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Y9gl1LOE-1630834564980)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.7k4ldf2nvg40.png)
可以对满二叉树的结点进行连续编号,约定编号从根结点起,自上而下,自左至右。由此可引出完全二叉树的定义。
-
完全二叉树
高度为h、有n个结点的二叉树,当且仅当其每个结点都与高度为h的满二叉树中编号为1~n的结点一一对应时,称为完全二叉树,如图(b)所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DwTpx3f0-1630834564985)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.35kfs7nqx5c0.png)
其特点如下:
- 若 i ≤ ⌊ n / 2 ⌋ i≤\lfloor {n/2}\rfloor i≤⌊n/2⌋,则结点 i 为分支结点,否则为叶子结点。
- 叶子结点只可能在层次最大的两层上出现。对于最大层次中的叶子结点,都依次排列在该层最左边的位置上。
- 若有度为1的结点,则只可能有一个,且该结点只有左孩子而无右孩子(重要特征)。
- 按层序编号后,一旦出现某结点(编号为i)为叶子结点或只有左孩子,则编号大于 i 的结点均为叶子结点。
- 若n为奇数,则每个分支结点都有左孩子和右孩子;若n为偶数,则编号最大的分支结点(编号为n/2)只有左孩子,没有右孩子,其余分支结点左、右孩子都有。
-
二叉排序树
左子树上所有结点的关键字均小于根结点的关键字;右子树上的所有结点的关键字均大于根结点的关键字;左子树和右子树又各是一棵二叉排序树。
-
平衡二叉树
树上任一结点的左子树和右子树的深度之差不超过1。
二叉树的性质
二叉树具有下列重要特性:
- 性质1:在二叉树的第i层上至多有 2 i − 1 2^{i-1} 2i−1个结点(i≥1)。
- 性质2:深度为k的二叉树至多有 2 k − 1 2^{k}-1 2k−1个结点(k≥1)。
- 性质3:对任何一棵二叉树T, 如果其终端结点数为 n 0 n_0 n0,度为2的结点数为 n 2 n_2 n2,则 n 0 = n 2 + 1 n_0=n_2+1 n0=n2+1。
- 性质4:具有n(n>0)个结点的完全二叉树的高(深)度为 ⌈ log 2 ( n + 1 ) ⌉ \lceil \log_2 (n+ 1)\rceil ⌈log2(n+1)⌉或 ⌊ log 2 n ⌋ + 1 \lfloor \log_2n \rfloor+1 ⌊log2n⌋+1。
- 性质5:对完全二叉树按从上到下、从左到右的顺序依次编号1,2,…, n,则有以下关系:
- 当i>1时,结点 i 的双亲的编号为 ⌊ i / 2 ⌋ \lfloor i/2\rfloor ⌊i/2⌋,即当 i 为偶数时,其双亲的编号为i/2,它是双亲的左孩子;当 i 为奇数时,其双亲的编号为 ( i − 1 ) / 2 (i- 1)/2 (i−1)/2,它是双亲的右孩子。
- 当2i≤n时,结点 i 的左孩子编号为2i,否则无左孩子。
- 当2i+1≤n时,结点 i 的右孩子编号为2i+1,否则无右孩子。
- 结点 i 所在层次(深度)为 ⌊ l o g 2 i ⌋ + 1 \lfloor log_2i\rfloor+ 1 ⌊log2i⌋+1。
二叉树的存储结构
类似线性表,二叉树的存储结构也可采用顺序存储和链式存储两种方式。
-
顺序存储结构
顺序存储结构使用一组地址连续的存储单元来存储数据元素,为了能够在存储结构中反映出结点之间的逻辑关系,必须将二叉树中的结点依照一定的规律安排在这组单元中。
对于完全二叉树,只要从根起按层序存储即可,依次自上而下、自左至右存储结点元素,即将完全二叉树上编号为i的结点元素存储在一维数组中下标为i-1的分量中。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xvXvk3WF-1630834564987)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.6vnftcmc0nw0.png)
对于一般二叉树,则应将其每个结点与完全二叉树上的结点相对照,存储在一维数组的相应分量中,如下图所示,图中以"0"表示不存在此结点。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iDMBBAhP-1630834564988)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.1kwybra1r4xs.png)
由此可见,这种顺序存储结构仅适用于完全二叉树。因为,在最坏的情况下,一个深度为K且只有K个结点的单支树(树中不存在度为2的结点)却需要长度为 2 k − 1 2^k-1 2k−1的一维数组。这造成了存储空间的极大浪费, 所以对于一般二叉树,更适合采取下面的链式存储结构。
-
链式存储结构
设计不同的结点结构可构成不同形式的链式存储结构。由二叉树的定义得知,二叉树的结点由一个数据元素和分别指向其左、 右子树的两个分支构成,则表示二叉树的链表中的结点至少包含3个域:数据域和左、 右指针域,如下图(b)所示。有时,为了便于找到结点的双亲,还可在结点结构中增加一个指向其双亲结点的指针域,如图 ( c ) 所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BpRJAxML-1630834564990)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.1c75cred95z4.png)
利用上图中两种结点结构所得的二叉树的存储结构分别称为二叉链表和三叉链表,如下图所示。链表的头指针指向二叉树的根结点。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-p0xpOUB6-1630834564992)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.3hq3zrrbbm00.png)
在不同的存储结构中,实现二叉树的操作方法也不同,如找结点x的双亲, 在三叉链表中很容易实现,而在二叉链表中则需从根指针出发巡查。由此,在具体应用中采用什么存储结构,除根据二叉树的形态之外还应考虑需进行何种操作。在下一节的二叉树遍历及其应用的算法均采用以下定义的二叉链表形式实现。
// ---- 二叉树的二叉链表存储表示 ---- typedef struct BiTNode{ TElemType data; //结点数据域 struct BiTNode *lchild,*rchild; //左右孩子指针 }BiTNode,*BiTree;
遍历二叉树和线索二叉树
在二叉树的一些应用中,常常要求在树中查找具有某种特征的结点,或者是对树中的全部结点逐一进行处理,这就提出了一个遍历二叉树的问题。线索二叉树是在第一次遍历时将结点的前驱、后继信息存储下来,便于再次遍历二叉树。
遍历二叉树
遍历二叉树(traversing binary tree)是指按某条搜索路径巡访树中每个结点,使得每个结点均被访问一次,而且仅被访问一次。
假如使用L、D、R分别表示遍历左子树、访问根结点和遍历右子树,且限定先左后右,则有我们常使用的4种遍历。我们使用下述表格进行说明:
名称 | 操作 |
---|---|
先(根)序遍历 (DLR) | 若二叉树不为空:(1) 访问根结点;(2) 先序遍历左子树;(3) 先序遍历右子树。 |
中(根)序遍历 (LDR) | 若二叉树不为空:(1) 中序遍历左子树;(2) 访问根结点;(3) 中序遍历右子树。 |
后(根)序遍历 (LRD) | 若二叉树不为空:(1) 后序遍历左子树;(2) 后序遍历右子树;(3) 访问根结点。 |
层次遍历(BFS) | 按照从上到下、从左至右的顺序按层次遍历。 |
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mPUTEafX-1630834564994)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.2tuwwl6hvmm0.png)
接下来,我们会围绕上图所示的二叉树来介绍二叉树的先中后序遍历,并给出相关算法描述。我们首先来看一下各种不同的遍历所得到的输出顺序。
类型 | 遍历结果 |
---|---|
先序遍历 | - + a * b - c d / e f |
中序遍历 | a + b * c - d - e / f |
后序遍历 | a b c d - * + e f / - |
层次遍历 | - + / a * e f b - c d |
从遍历结果来看,先中后序遍历的结果所对应的恰好为表达式的前缀表示(波兰式)、中缀表示和后缀表示(逆波兰式)。
我们通过下图来具体了解一下先中后序遍历算法的递归执行过程。向下的箭头表示更深一层的递归调用,向上的箭头表示从递归调用退出返回;虚线旁的三角形、圆形和方形内的字符分别表示在先序、中序和后序遍历的过程中访问结点时输出的信息。 只要沿着虚线从1出发到2结束,将沿途所见的三角形(或圆形或方形)内的字符记下,便得到遍历二叉树的先序(或中序或后序)序列。例如在下图中,沿虚线游走可以分别得到先序序列为ABDEC、中序序列为DBEAC、后序序列为DEBCA。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Hk9EAYFK-1630834564995)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.5pl9crxi5qo0.png)
下面我们给出先中后序遍历算法的递归与非递归实现,与层次遍历的代码实现。
-
先序遍历
先序遍历遵循"根左右"的思想,即先访问根结点,然后是左子树和右子树。我们使用递归可以很轻松的实现其遍历的操作。
下面给出先序遍历的递归算法实现:
void PreOrder(BiTree T) { if (T!=NULL) //若二叉树非空 { cout << T->data << " "; //访问根结点 PreOrder(T->lchild); //先序遍历左子树 PreOrder(T->rchild); //先序遍历右子树 } }
根据先序遍历访问的顺序,优先访问根结点,然后再分别访问左孩子和右孩子。即对于任一结点,其可看做是根结点,因此可以直接访问,访问完之后,若其左孩子不为空,按相同规则访问它的左子树;当访问其左子树为空时,再访问它的右子树。因此先序遍历非递归的实现步骤及代码如下:
- 访问结点P,并将结点P入栈;
- 判断结点P的左孩子是否为空;
- 若为空,则取栈顶结点并进行出栈操作,并将栈顶结点的右孩子置为当前的结点P;
- 若不为空,则将P的左孩子置为当前的结点P;
- 直到P为NULL并且栈为空,则遍历结束。
void PreOrder2(BiTree T){ stack<BiTree> s; BiTree P = T; while (P || !s.empty()) //直到P为NULL并且栈空,则遍历结束 { if(P){ //若当前结点非空 cout<<P->data<<" "; //访问当前结点 s.push(P); //将当前结点入栈 P = P->lchild; //将P的左孩子置为当前结点P }else{ //若当前结点为空 P = s.top(); s.pop(); //取栈顶元素并进行出栈操作 P = P->rchild; //将栈顶元素的右孩子置为当前结点P } } }
-
中序遍历
中序遍历遵循"左根右"的思想,即先访问左子树,然后是根结点和右子树。
下面给出中序遍历的递归算法实现:
void InOrder(BiTree T) { if (T != NULL) //若二叉树非空 { InOrder(T->lchild); //中序遍历左子树 cout << T->data << " "; //访问根结点 InOrder(T->rchild); //中序遍历右子树 } }
根据中序遍历的顺序,对于任一结点,优先访问其左孩子,而左孩子结点又可以看做一根结点,然后继续访问其左孩子结点,直到遇到左孩子结点为空的结点才进行访问输出,然后按相同的规则访问其右子树。因此中序遍历非递归的实现步骤及代码如下:
对于任一结点P:
- 若其左孩子不为空,则将P入栈并将P的左孩子置为当前的P,然后对当前结点P再进行相同的处理;
- 若其左孩子为空,则取栈顶元素并进行出栈操作,访问该栈顶结点,然后将当前的P置为栈顶结点的右孩子;
- 直到P为NULL并且栈为空,则遍历结束。
void InOrder2(BiTree T){ stack<BiTree> s; BiTree P = T; while (P || !s.empty()) //直到P为NULL并且栈空,则遍历结束 { if(P){ //若当前结点非空 s.push(P); //将当前结点入栈 P = P->lchild; //将P的左孩子置为当前结点P }else{ //若当前结点为空 P = s.top(); s.pop(); //取栈顶元素并进行出栈操作 cout<<P->data<<" "; //访问栈顶结点 P = P->rchild; //将当前的P置为栈顶结点的右孩子 } } }
-
后序遍历
后序遍历遵循"左右根"的思想,即先访问左子树,然后是右子树和根结点。
下面给出后序遍历的递归算法实现:
void PostOrder(BiTree T) { if (T != NULL) //若二叉树非空 { PostOrder(T->lchild); //后序遍历左子树 PostOrder(T->rchild); //后序遍历右子树 cout << T->data << " "; //访问根结点 } }
后序遍历的非递归实现是三种遍历方法中最难的。因为在后序遍历中,要保证左孩子和右孩子都已被访问并且左孩子在右孩子前访问才能访问根结点,这就为流程的控制带来了难题。
后序非递归遍历算法的思路分析:从根结点开始,将其入栈,然后沿其左子树一直往下搜索,直到搜索到没有左孩子的结点,但是此时不能出栈并访问,因为如果其有右子树,还需按相同的规则对其右子树进行处理。直至上述操作进行不下去,若栈顶元素想要出栈被访问,要么右子树为空,要么右子树刚被访问完(此时左子树早已访问完),这样就保证了正确的访问顺序。
void PostOrder2(BiTree T){ stack<BiTree> s; BiTree P = T; //当前结点 BiTree r = NULL; //记录最近访问的一个结点,即前一次访问的结点 while (P || !s.empty()) { if(P){ s.push(P); P = P->lchild; }else{ P = s.top(); //读取栈顶元素 if(P->rchild&&P->rchild!=r){ //若右子树存在,且未被访问过 P = P->rchild; s.push(P); P = P->lchild; }else{ //结点右子树不存在或右子树刚刚被访问 s.pop(); cout<<P->data<<" "; r = P; //记录最近被访问过的结点 P = NULL; } } } }
-
层次遍历
要进行层次遍历,需要借助一个队列。下面给出层次遍历的算法实现:
- 先将二叉树根结点入队,然后出队,访问出队结点。
- 若它有左子树,则将左子树根结点入队;
- 若它有右子树,则将右子树根结点入队。
- 然后出队,访问出队结点……如此反复,直至队列为空。
// 广度优先 void BFS(BiTree T){ queue<BiTree> q; q.push(T); //先将二叉树根结点入队 BiTree tree; while (!q.empty()) //若队列非空 { tree = q.front(); //取队头元素 cout<<tree->data<<" "; //访问出队结点 q.pop(); //出队 if(tree->lchild){ //若它有左子树,则将左子树根结点入队; q.push(tree->lchild); } if(tree->rchild){ //若它有右子树,则将右子树根结点入队。 q.push(tree->rchild); } } }
- 先将二叉树根结点入队,然后出队,访问出队结点。
无论是递归还是非递归遍历二叉树,因为每个结点被访问一次,则不论按哪一种次序进行遍历,对含 n 个结点的二叉树,其时间复杂度均为 O(n)。所需辅助空间为遍历过程中栈的最大容量,即树的深度,最坏情况下为 n, 则空间复杂度也为 O(n)。
由二叉树的先序序列和中序序列,或由其后序序列和中序序列均能唯一地确定一棵二叉树。
因为先序遍历的顺序是"根左右",由此可以确定,在先序序列中第一个结点一定是二叉树的根结点。另外,中序遍历的顺序是"左根右",根结点在中序序列中必然将中序序列分割成两个子序列,前一个子序列是根结点的左子树的中序序列,而后一个子序列是根结点的右子树的中序序列。根据这两个子序列,在先序序列中找到对应的左子序列和右子序列。在先序序列中,左子序列的第一个结点是左子树的根结点,右子序列的第一个结点是右子树的根结点。如此递归地进行下去,便能唯一地确定这棵二叉树。
同理,由二叉树的后序序列和中序序列也可以唯一地确定一棵二叉树。因为后序序列的最后一个结点就如同先序序列的第一个结点,可以将中序序列分割成两个子序列,然后采用类似的方法递归地进行划分,进而得到一棵二叉树。
例如,求先序序列(ABCDEFGHI)和中序序列(BCAEDGHFI)所确定的二叉树。
首先,由先序序列可知A为二叉树的根结点。中序序列中A之前的BC为左子树的中序序列,EDGHFI为右子树的中序序列。然后由先序序列可知B是左子树的根结点,D是右子树的根结点。以此类推,就能将剩下的结点继续分解下去,最后得到的二叉树如下图©所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nCMi0sUG-1630834564997)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.32vqvtz5nfu0.png)
二叉树遍历算法的应用
“遍历” 是二叉树各种操作的基础,假设访问结点的具体操作不仅仅局限于输出结点数据域的值,而把 "访问 " 延伸到对结点的判别、计数等其他操作,可以解决一些关于二叉树的其他实际问题。如果在遍历过程中生成结点, 这样便可建立二叉树的存储结构。
-
创建二叉树的存储结构——二叉链表
为简化问题,设二叉树中结点的元素均为一个单字符。假设按先序遍历的顺序建立二叉链表,T 为指向根结点的指针,对于给定的一个字符序列, 依次读入字符, 从根结点开始, 递归创建二叉树。
- 扫描字符序列, 读入字符ch。
- 如果ch是一个 “#” 字符, 则表明该二叉树为空树,即T为NULL; 否则执行以下操作:
- 申请一个结点空间T;
- 将ch赋给T->data;
- 递归创建T的左子树;
- 递归创建T的右子树;
void CreateBiTree(BiTree &T){ char ch; cin>>ch; if(ch=='#') T = NULL; //递归结束, 建空树 else{ T = new BiTNode; //生成根结点 T->data = ch; //根结点数据域置为 ch CreateBiTree(T->lchild); //递归创建左子树 CreateBiTree(T->rchild); //递归创建右子树 } }
如我们给出先序序列:12#46###3#5##,可建立出如下二叉链表结构。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1jkQfNx3-1630834564999)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.9s4fu8i2vj0.png)
-
复制二叉树
复制二叉树就是利用已有的一棵二叉树复制得到另外一棵与其完全相同的二叉树。根据二叉树的特点, 复制步骤如下:
- 如果是空树, 递归结束, 否则执行以下操作:
- 申请一个新结点空间,复制根结点;
- 递归复制左子树;
- 递归复制右子树。
void Copy(BiTree T,BiTree &NewT){ if(T==NULL){ NewT=NULL; return; }else{ NewT = new BiTNode; NewT->data = T->data; //复制根结点 Copy(T->lchild,NewT->lchild); //递归复制左子树 Copy(T->rchild,NewT->rchild); //递归复制右子树 } }
- 如果是空树, 递归结束, 否则执行以下操作:
-
计算二叉树的深度
- 如果是空树, 递归结束, 深度为0, 否则执行以下操作:
- 递归计算左子树的深度记为m;
- 递归计算右子树的深度记为n;
- 如果 m 大于 n, 二叉树的深度为 m+1, 否则为 n+1。
int Depth(BiTree T){ if(T==NULL) return 0; //如果是空树,深度为0,递归结束 else{ int m = Depth(T->lchild); //递归计算左子树的深度 int n = Depth(T->rchild); //递归计算右子树的深度 return m>n?(m+1):(n+1); //返回最大子树深度+1 } }
- 如果是空树, 递归结束, 深度为0, 否则执行以下操作:
-
统计二叉树中结点的个数
如果是空树,则结点个数为 0; 否则,结点个数为左子树的结点个数加上右子树的结点个数再加上 1 。
int NodeCount(BiTree T){ if(T==NULL) return 0; else return NodeCount(T->lchild)+NodeCount(T->rchild)+1; }
线索二叉树
遍历二叉树是以一定的规则将二叉树中的结点排列成一个线性序列,从而得到几种遍历序列,使得该序列中的每个结点(第一个和最后一个结点除外)都有一个直接前驱和直接后继。
传统的二叉链表存储仅能体现一种父子关系,不能直接得到结点在任一序列中的前驱或后继。我们可以利用上二叉树中所有结点的空指针,通过不同的遍历方式, 使这些空指针来存放其前驱或后继结点。 这样就可以像遍历单链表那样方便地遍历二叉树。引入线索二叉树正是为了加快查找结点前驱和后继的速度。
注: 在含n个结点的二叉树中,有n+1个空指针。
这是因为每个叶结点有2个空指针,每个度为1的结点有1个空指针,空指针总数为 2 n 0 + n 1 2n_0 + n_1 2n0+n1,又 n 0 = n 2 + 1 n_0 = n_2 + 1 n0=n2+1,所以空指针总数为 n 0 + n 1 + n 2 + 1 = n + 1 n_0+ n_1+n_2+1 =n+1 n0+n1+n2+1=n+1。
我们规定: 若无左子树,令lchild指向其前驱结点;若无右子树,令rchild指向其后继结点。如下图所示,还需增加两个标志域标识指针域是指向左(右)孩子还是指向前驱(后继)。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Uh9KRgIB-1630834565000)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.46i70h8x2da0.png)
其中,标志域的含义如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KX1mOcdV-1630834565001)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.g8skqer4qp4.png)
线索二叉树的存储结构描述如下:
typedef struct ThreadNode
{
char data; //数据元素,可以是任意其他类型,这里采用char。
int ltag=0, rtag=0; //左、右线索标志,0表示指向孩子结点,1表示指向线索。初始化为0。
struct ThreadNode *lchild, *rchild; //左、右孩子指针。
} ThreadNode, *ThreadTree;
这里提出一些相关名词概念:
名词 | 解释 |
---|---|
线索链表 | 以上面这种结点结构构成的二叉链表作为二叉树的存储结构。 |
线索 | 指向结点前驱和后继的指针。 |
线索二叉树 | 加上线索的二叉树,称之为线索二叉树 (Threaded Binary Tree)。 |
线索化 | 对二叉树以某种次序遍历使其变为线索二叉树的过程。 |
由于线索二叉树构造的实质是将二叉链表中的空指针改为指向前驱或后继的线索, 而前驱或后继的信息只有在遍历时才能得到, 因此线索化的过程即为在遍历的过程中修改空指针的过程,可用递归算法。对二叉树按照不同的遍历次序进行线索化,可以得到不同的线索二叉树,包括先序线索二叉树、中序线索二叉树和后序线索二叉树。以下我们将分别介绍三种序列实现的线索二叉树,以及代码实现。
-
中序线索二叉树
为了记下遍历过程中访问结点的先后关系,附设一个指针pre始终指向刚刚访问过的结点,而指针p指向当前访问的结点,即pre指向p的前驱。在中序遍历的过程中,检查p的左指针是否为空,若为空就将它指向pre;检查pre的右指针是否为空,若为空就将它指向p,由此记录下遍历过程中访问结点的先后关系。如下图所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3YJjQJ7g-1630834565003)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.633va6jptcs0.png)
以结点p为根的子树中序线索化:
- 如果p非空,左子树递归线索化。
- 如果p的左孩子为空,则给p加上左线索,将其ltag置为1,让p的左孩子指针指向pre(前驱);
- 如果pre的右孩子为空,则给pre加上右线索,将其rtag置为1,让pre的右孩子指针指向p(后继);
- 将pre指向刚访问过的结点p,即pre=p。
- 右子树递归线索化。
ThreadNode *pre; //中序遍历对二叉树线索化的递归算法 void InThreading(ThreadTree p) { if (p) { InThreading(p->lchild); //左子树递归线索化 if (!p->lchild) //如果左子树为空 { p->ltag = 1; p->lchild = pre; } if (pre && !pre->rchild) //此处需要判断第一次pre是否为空的情况。 { pre->rtag = 1; //给pre加上右线索 pre->rchild = p; //pre的右孩子指针指向p (后继) } pre = p; //保持pre指向p的前驱 InThreading(p->rchild); //右子树递归线索化 } } //调用此方法完成线索化 void CreateInThread(ThreadTree T) { if (T != NULL) {//完成线索化后,pre指向中序遍历的最后一个结点。所以还需要对最后一个结点进行处理。 InThreading(T); pre->rchild = NULL; pre->rtag = 1; } }
在二叉树的线索链表上也可以添加一个头结点,并令其lchild域的指针指向二叉树的根结点,其rchild域的指针指向中序遍历时访问的最后一个结点;同时,令二叉树中序序列中第一个结点的lchild域指针和最后一个结点rchild域的指针均指向头结点。这好比为二叉树建立了一个双向线索链表,既可从第一个结点起顺后继进行遍历,也可从最后一个结点起顺前驱进行遍历。如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OY9CyACp-1630834565003)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.421llmoubk00.png)
带头结点的二叉树中序线索化:
//带头结点的中序线索化二叉树,相当于双向线索链表 void InOrderThreading(ThreadTree Head,ThreadTree T){ Head->ltag = 0; Head->rtag = 1; Head->rchild = Head; //初始化时右指针指向自己 if(!T) Head->lchild = Head; //若树为空,则左指针也指向自己 else{ Head->lchild = T; //头结点的左孩子指向根 pre = Head; //pre 初值指向头结点,可完成中序线索第一个结点指向头结点的功能 InThreading(T); //中序线索化二叉树 pre->rchild = Head; //线索化完,pre指向中序遍历的最后一个结点,最后一个结点rchild域的指针指向头结点 pre->rtag = 1; //pre的右标记为1 Head->rchild = pre; //头结点的rchild域的指针指向中序遍历时访问的最后一个结点 } }
对于线索二叉树的遍历,不能再像普通二叉树的遍历一样,因为会导致死循环。线索二叉树进行遍历时,需要先找到序列中的第一个结点,然后依次找结点的后继,直至其后继为空。 所以我们需要找到序列的第一个结点,以及序列的下一个结点(以不带头结点的线索化为例)。
1)查找p指针所指结点的前驱:
- 若p->ltag为1,则p的左链指示其前驱;
- 若p->ltag为0,则说明p有左子树,结点的前驱是遍历左子树时最后访问的一个结点(左子树中最右下的结点)。
2)查找p指针所指结点的后继:
- 若p->rtag为1,则p的右链指示其后继;
- 若p->rtag为0,则说明p有右子树。右子树访问的第一个结点就是下一个结点,即右子树中最左下的结点。
由以上的想法我们给出不含头结点的中序线索二叉树的中序遍历的算法:
//求中序线索二叉树中中序序列下的第一个结点 ThreadNode* Firstnode(ThreadNode *p){ while (p->ltag==0) //最左下结点 p = p->lchild; return p; } //求中序线索二叉树中结点p在中序序列下的后继 ThreadNode* Nextnode(ThreadNode *p){ if(p->rtag==0) return Firstnode(p->rchild); else return p->rchild; //rtag==1 直接返回后继线索 } //中序线索二叉树的中序遍历 void InOrder(ThreadNode *T){ for(ThreadNode *p=Firstnode(T);p!=NULL;p=Nextnode(p)) cout<<p->data<<" "<<p->ltag<<" "<<p->rtag<<endl; }
-
先序线索二叉树
先序线索二叉树的思想类似,再先序遍历的基础上对二叉树进行线索化。构建出来的先序线索二叉树如下图所示。先序序列为ABCDF,其中将C的左链域指向前驱B,右链域指向后继D;结点D无左孩子,将左链域指向前驱C,无右孩子,将右链域指向后继F;结点F无左孩子,将左链域指向前驱D,无右孩子,也无后继故置空。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-11WIKRBD-1630834565004)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.1ne2a7ze3ev4.png)
以结点p为根的子树先序线索化:
- 如果p非空,进行如下操作。
- 如果p的左孩子为空,则给p加上左线索,将其ltag置为1,让p的左孩子指针指向pre(前驱);
- 如果pre不为空,并且pre的右孩子为空,则给pre加上右线索,将其rtag置为1,让pre的右孩子指针指向p(后继);
- 将pre指向刚访问过的结点p,即pre=p。
- 若左指针不是线索,左子树递归线索化。
- 若右指针不是线索,右子树递归线索化。
ThreadNode *pre; //先序遍历对二叉树线索化的递归算法 void PreThreading(ThreadTree p) { if (p) { if (!p->lchild) //如果左子树为空 { p->ltag = 1; //给p加上左线索 p->lchild = pre; //p的左孩子指针指向pre (前驱) } if (pre && !pre->rchild) //此处需要判断第一次pre是否为空的情况。 { pre->rtag = 1; //给pre加上右线索 pre->rchild = p; //pre的右孩子指针指向p (后继) } pre = p; //保持pre指向p的前驱 if(p->ltag==0) //若p的左指针不是线索,再进行递归 PreThreading(p->lchild); //左子树递归线索化 if(p->rtag==0) //若p的右指针不是线索,再进行递归 PreThreading(p->rchild); //右子树递归线索化 } } //调用此方法完成线索化 void CreatePreThread(ThreadTree T) { if (T != NULL) {//完成线索化后,pre指向先序遍历的最后一个结点。所以还需要对最后一个结点进行处理。 PreThreading(T); pre->rchild = NULL; pre->rtag = 1; } }
在先序线索二叉树中查找
1)查找p指针所指结点的前驱:
- 若p->ltag为1,则p的左链域指示其前驱;
- 若p->ltag为0,则说明p有左子树。此时p的前驱有两种情况:若*p是其双亲的左孩子,则其前驱为其双亲结点;否则应是其双亲的左子树上先序遍历最后访问到的结点。
2)查找p指针所指结点的后继:
- 若p->rtag为1,则p的右链指示其后继;
- 若p->rtag为0,则说明p有右子树。按先序遍历的规则可知,*p的后继必为其左子树根(若存在)或右子树根。
先序线索二叉树的先序遍历的算法实现:
//求先序线索二叉树中结点p在先序序列下的后继 ThreadNode* Nextnode(ThreadNode *p){ if(p->rtag==1){ return p->rchild; //rtag==1 直接返回后继线索 } else{ if(p->lchild){ //若存在左子树,则下一个后继为左子树根 return p->lchild; } return p->rchild; //没有左子树,下一个后继为右子树根 } } //先序线索二叉树的先序遍历 void PreOrder(ThreadNode *T){ //先序遍历的第一个结点为根结点 for(ThreadNode *p=T;p!=NULL;p=Nextnode(p)) cout<<p->data<<" "<<p->ltag<<" "<<p->rtag<<endl; }
-
后序线索二叉树
后序线索二叉树的过程:后序序列为CDBFA,结点C无左孩子,也无前驱故置空,无右孩子,将右链域指向后继D;结点D无左孩子,将左链域指向前驱C,无右孩子,将右链域指向后继B;结点F无左孩子,将左链域指向前驱B,无右孩子,将右链域指向后继A,得到的后序线索二叉树如下图所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3PHMitIA-1630834565005)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/1630597118524.44d71yvpzyi0.png)
后序线索化的代码如下:
//后序遍历对二叉树线索化的递归算法 void PostThreading(ThreadTree p) { if (p) { PostThreading(p->lchild); //左子树递归线索化 PostThreading(p->rchild); //右子树递归线索化 if (!p->lchild) //如果左子树为空 { p->ltag = 1; //给p加上左线索 p->lchild = pre; //p的左孩子指针指向pre (前驱) } if (pre && !pre->rchild) //此处需要判断第一次pre是否为空的情况。 { pre->rtag = 1; //给pre加上右线索 pre->rchild = p; //pre的右孩子指针指向p (后继) } pre = p; //保持pre指向p的前驱 } }
在后序线索二叉树中查找
1)查找p指针所指结点的前驱:
- 若p->ltag为1,则p的左链指示其前驱;
- 若p->ltag为0,当p->rtag也为0时,则p的右链指示其前驱。若->ltag为0,而p->rtag为1时,则p的左链指示其前驱。
2)查找p指针所指结点的后继情况比较复杂,分以下情况讨论:
- 若*p是二叉树的根, 则其后继为空;
- 若*p是其双亲的右孩子, 则其后继为双亲结点;
- 若 *p是其双亲的左孩子, 且 *p没有右兄弟, 则其后继为双亲结点;
- 若 *p是其双亲的左孩子,且 *p有右兄弟,则其后继为双亲的右子树上按后序遍历列出的第一个结点( 即右子树中 “最左下” 的叶结点)。
可见在后序线索二叉树上找后继时需知道结点双亲,即需采用带标志域的三叉链表作为存储结构。这里不再进行阐述。
树和森林
树的存储结构
树的存储方式有多种,既可采用顺序存储结构,又可采用链式存储结构,但无论采用何种存储方式,都要求能唯一地反映树中各结点之间的逻辑关系,这里介绍3种常用的存储结构。
-
双亲表示法
这种表示方法中,以一组连续的存储单元存储树的结点,每个结点除了数据域data外,还附设一个parent域用以指示其双亲结点的位置。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-f8wBQAHj-1630834565006)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.omon004iou8.png)
双亲表示法的存储结构描述如下:
#define MAX_TREE_SIZE 100 //树中最多结点树 typedef struct{ //树的结点定义 ElemType data; //数据元素 int parent; //双亲位置域 }PTNode; typedef struct{ //树的类型定义 PTNode nodes[MAX_TREE_SIZE]; //所有结点数组 int n; //结点数 }PTree;
该存储结构利用了每个结点(根结点除外)只有唯一双亲的性质,可以很快得到每个结点的双亲结点,但求结点的孩子时需要遍历整个结构。
-
孩子表示法
孩子表示法是将每个结点的孩子结点都用单链表链接起来形成一个线性结构,此时n个结点就有n个孩子链表(叶子结点的孩子链表为空表)。不仅如此,还可以把双亲表示法和孩子表示法结合起来,即将双亲表示和孩子链表合在一起。如下图所示(对应的树为双亲表示法的树)。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tHMCT0kl-1630834565007)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.7fejo2prudg0.png)
-
孩子兄弟法
孩子兄弟表示法又称二叉树表示法,即以二叉链表作为树的存储结构。孩子兄弟表示法使每个结点包括三部分内容:结点值、指向结点第一个孩子结点的指针,及指向结点下一个兄弟结点的指针(沿此域可以找到结点的所有兄弟结点),如下图所示(对应的树为双亲表示法的树)。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yNnrQs4d-1630834565008)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.1zb63rm4mp8g.png)
孩子兄弟表示法的存储结构描述如下:
// ---- 树的二叉链表(孩子-兄弟)存储表示 ---- typedef struct CSNode{ ElemType data; struct CSNode *firstchild,*nextsibling; //第一个孩子和右兄弟指针 }CSNode,*CSTree;
这种存储表示法比较灵活,其最大的优点是可以方便地实现树转换为二叉树的操作,易于查找结点的孩子等,但缺点是从当前结点查找其双亲结点比较麻烦。若为每个结点增设一个parent域指向其父结点,则查找结点的父结点也很方便。
森林与二叉树的转换
由于二叉树和树都可以用二叉链表作为存储结构,因此以二叉链表作为媒介可以导出树与二叉树的一个对应关系,即给定一棵树,可以找到唯一的一棵二叉树与之对应。从物理结构上看,它们的二叉链表是相同的,只是解释不同而已。
-
树转换成二叉树
从树的二叉链表表示的定义可知,任何一棵和树对应的二叉树,其根结点的右子树必空。这是因为根结点没有兄弟,所以对应的二叉树没有右子树。
树转换为二叉树的规则:每个结点左指针指向它的第一个孩子,右指针指向它在树中的相邻右兄弟,这个规则又称"左孩子右兄弟"。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TA2d8Uv9-1630834565009)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.1kc5ctqarcl.png)
-
森林转换成二叉树
将森林转换为二叉树的规则与树类似。先将森林中的每棵树转换为二叉树,由于任何一棵和树对应的二叉树的右子树必空,若把森林中第二棵树根视为第一棵树根的右兄弟,即将第二棵树对应的二叉树当作第一棵二叉树根的右子树,将第三棵树对应的二叉树当作第二棵二叉树根的右子树……以此类推,就可以将森林转换为二叉树。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iJE8lwIX-1630834565010)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.5fkza62a5eg0.png)
-
二叉树转换成森林
二叉树转换为森林的规则:若二叉树非空,则二叉树的根及其左子树为第一棵树的二叉树形式,故将根的右链断开。二叉树根的右子树又可视为一个由除第一棵树外的森林转换后的二叉树,应用同样的方法,直到最后只剩一棵没有右子树的二叉树为止,最后再将每棵二叉树依次转换成树,就得到了原森林。
树和森林的遍历
-
树的遍历
如下图中所展示的树来说明一下相关的几种树遍历。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Z5odPRDM-1630834565010)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.6lptg72tfnw0.png)
类型 描述 遍历序列 先根遍历 先访问树的根结点,然后依次先根遍历根的每棵子树。 ABEFCDG 后根遍历 先依次后根遍历每棵子树,然后访问根结点。(对应其转换成二叉树的中序遍历) EFBCGDA 层次遍历 按层序依次访问各结点。 ABCDEFG -
森林的遍历
按照森林和树相互递归的定义,可得到森林的两种遍历方法。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-P9Gyxpyu-1630834565011)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.49poy8wb9a00.png)
类型 描述 遍历序列 先序遍历森林 ① 访问森林中第一棵树的根结点。② 先序遍历第一棵树中根结点的子树森林。③ 先序遍历除去第一棵树之后剩余的树构成的森林。 ABCDEFGHIJ 中序遍历森林 ① 中序遍历森林中第一棵树的根结点的子树森林。② 访问第一棵树的根结点。③ 中序遍历除去第一棵树之后剩余的树构成的森林。 BCDAFEHJIG 当森林转换成二叉树时,其第一棵树的子树森林转换成左子树,剩余树的森林转换成右子树,可知森林的先序和中序遍历即为其对应二叉树的先序和中序遍历。
树与二叉树的应用
二叉排序树(BST)
-
二叉排序树的定义
二叉排序树(也称二叉查找树)或者是一棵空树,或者是具有下列特性的二叉树:
- 若左子树非空,则左子树上所有结点的值均小于根结点的值。
- 若右子树非空,则右子树上所有结点的值均大于根结点的值。
- 左、右子树也分别是一棵二叉排序树。
根据二叉排序树的定义,左子树结点值 < 根结点值 < 右子树结点值,所以对二叉排序树进行中序遍历,可以得到一个递增的有序序列。例如,下图所示二叉排序树的中序遍历序列为1 2 3 4 6 8。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mK741nMY-1630834565012)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.5s7ajpgrl6g0.png)
二叉排序树的在c++中的定义与普通二叉树一样:
// ---- 二叉排序树的定义 ---- typedef struct BSTNode{ int data; //这里我们使用int类型,也可以使用其它类型 BSTNode *lchild,*rchild; //定义左子树与右子树指针 }BSTNode,*BiTree;
-
二叉排序树的查找
二叉排序树的查找是从根结点开始,沿某个分支逐层向下比较的过程。若二叉排序树非空,先将给定值与根结点的关键字比较,若相等,则查找成功;若不等,如果小于根结点的关键字,则在根结点的左子树上查找,否则在根结点的右子树上查找。这显然是一个递归的过程。
二叉排序树的非递归查找算法:
BSTNode* BST_Search(BiTree T,int key){ while (T!=NULL && key!=T->data) //当树空或等于根结点值,结束循环 { if(key<T->data) T = T->lchild; //小于,在左子树查找 else T = T->rchild; //大于,在右子树查找 } return T; }
二叉排序树的递归算法实现:
BSTNode* BST_Search2(BiTree T,int key){ if(T==NULL || key == T->data) //当树空或等于根结点值,结束递归 return T; if(key<T->data) return BST_Search2(T->lchild,key); //值小于根结点,在左子树查找 else return BST_Search2(T->rchild,key); //值大于根结点,在右子树查找 }
-
二叉排序树的插入
插入结点的过程如下:
- 若原二叉排序树为空,则直接插入结点;
- 否则,若关键字k小于根结点值,则插入到左子树,若关键字k大于根结点值,则插入到右子树。
- 插入的结点一定是一个新添加的叶结点,且是查找失败时的查找路径上访问的最后一个结点的左孩子或右孩子。
如下图所示,在一个二叉排序树中依次插入结点28和结点58,虚线表示的边是其查找的路径。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-a5JL51zK-1630834565013)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.5ekcnhog66o0.png)
二叉排序树插入操作的算法描述如下:
//若插入成功返回true,插入失败返回false bool BST_Insert(BiTree &T,int key){ if(T==NULL){ //若递归到根结点为空,则说明可以插入 T=(BiTree)malloc(sizeof(BSTNode)); T->data = key; T->lchild = T->rchild = NULL; return true; }else if(key==T->data){ //若树中存在相同关键字的结点,插入失败 return false; }else if(key<T->data){ //若值小于结点,插入到其左子树 return BST_Insert(T->lchild,key); }else{ //若值大于结点,插入到其右子树 return BST_Insert(T->rchild,key); } }
-
二叉排序树的构造
从一棵空树出发,依次输入元素,将它们插入二叉排序树中的合适位置。设查找的关键字序列为 { 45 , 24 , 53 , 45 , 12 , 24 } \{45,24,53,45,12,24\} {45,24,53,45,12,24},则生成的二叉排序树如下图所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Gdt1JTs3-1630834565014)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.2wxl5i16g1w0.png)
构造二叉排序树的算法描述如下:
void Creat_BST(BiTree &T,int keys[],int n){//keys是关键字序列,n表示其长度 T = NULL; //初始时T为空树 int i = 0; while (i<n) { //依次将每个关键字插入到二叉排序树中 BST_Insert(T,keys[i]); i++; } }
-
二叉排序树的删除
在二叉排序树中删除一个结点时,不能把以该结点为根的子树上的结点都删除,必须先把被删除结点从存储二叉排序树的链表上摘下,将因删除结点而断开的二叉链表重新链接起来,同时确保二叉排序树的性质不会丢失。删除操作的实现过程按3种情况来处理:
- 若被删除结点z是叶结点,则直接删除,不会破坏二叉排序树的性质。
- 若结点z只有一棵左子树或右子树,则让z的子树成为z父结点的子树,替代z的位置。
- 若结点z有左、右两棵子树,则令z的直接后继(或直接前驱)替代z,然后从二叉排序树中删去这个直接后继(或直接前驱),这样就转换成了第一或第二种情况。
下图显示了在3种情况下分别删除结点45,78,78的过程。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fsitB2hX-1630834565015)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.1pfbjkushf5s.png)
二叉排序树的删除算法如下:
bool BST_Delete(BiTree &T,int key){ BSTNode *z = BST_Search(T,key); //通过要删除的结点的值找到该结点 if(z==NULL) //若要删除的结点不存在,删除失败 return false; if(z->lchild==NULL && z->rchild==NULL){//若被删除结点z是叶结点,则直接删除,不会破坏二叉排序树的性质。 z = NULL; free(z); }else if(z->lchild==NULL && z->rchild!=NULL){//若结点z只有一棵右子树,让右子树代替z的位置 BSTNode *p = z->rchild; z->data = p->data; z->lchild = p->lchild; z->rchild = p->rchild; free(p); }else if(z->rchild==NULL && z->lchild!=NULL){//若结点z只有一棵左子树,让左子树代替z的位置 BSTNode *p = z->lchild; z->data = p->data; z->lchild = p->lchild; z->rchild = p->rchild; free(p); }else{//结点z有左、右两棵子树,则令z的直接后继替代z //1. 先找到z的右孩子的最下面的左孩子,即为右孩子的最小的那个(是z的直接后继) BSTNode *left = z->rchild; while (left->lchild != NULL){ left = left->lchild; } //2. 将右孩子的最下面的左孩子的数据赋给node z->data = left->data; //3. 处理这个直接后继结点,因为是最后的一个左孩子肯定没有左孩子, //所以如果该结点有右孩子,就将右孩子赋予改结点。否则将该结点置为NULL if (left->rchild != NULL){ BSTNode *p = left->rchild; left->data = p->data; left->lchild = p->lchild; left->rchild = p->rchild; free(p); } else{ left = NULL; free(left); } } return true; }
-
二叉排序树的查找效率分析
从查找过程看,二叉排序树与二分查找相似。就平均时间性能而言,二叉排序树上的查找和二分查找差不多。但二分查找的判定树唯一,而二叉排序树的查找不唯一,相同的关键字其插入顺序不同可能生成不同的二叉排序树,如下图所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aJb2GFOu-1630834565015)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.31lu0k1apyk.png)
在最坏情况下,即构造二叉排序树的输入序列是有序的,则会形成一个倾斜的单支树,此时二叉排序树的性能显著变坏,树的高度也增加为元素个数n。
补充知识点:
等概率情况下,上图(a)查找成功的平均查找长度为: A S L a = ( 1 + 2 × 2 + 3 × 4 + 4 × 3 ) / 10 = 2.9 ASL_a= (1 +2×2+3×4+4×3)/10= 2.9 ASLa=(1+2×2+3×4+4×3)/10=2.9
上图(b)查找成功的平均查找长度为: A S L b = ( 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 ) / 10 = 5.5 ASL_b=(1+2+3+4+5+6+7+8+9+ 10)/10= 5.5 ASLb=(1+2+3+4+5+6+7+8+9+10)/10=5.5
类型 从查找过程分析 从维护分析 适用情况 二叉排序树 查找效率取决于树的高度。输入序列决定了判定树的形态,故判定树不唯一,查找时间复杂度最好为 O ( l o g 2 n ) O(log_2n) O(log2n),最坏为 O ( n ) O(n) O(n)。 无须移动结点,只需修改指针即可完成插入和删除操作,平均执行时间为 O ( l o g 2 n ) O(log_2n) O(log2n)。 动态查找表 二分查找 判定树唯一,查找时间复杂度为 O ( l o g 2 n ) O(log_2n) O(log2n)。 二分查找的对象是有序顺序表,若有插入和删除结点的操作,所花的代价是O(n)。 静态查找表
平衡二叉树
-
平衡二叉树的定义
为避免树的高度增长过快,降低二叉排序树的性能,规定在插入和删除二叉树结点时,要保证任意结点的左、右子树高度差的绝对值不超过1,将这样的二叉树称为平衡二叉树(Balanced Binary Tree),简称平衡树。
平衡因子:结点左子树与右子树的高度差。
平衡二叉树:平衡二叉树结点的平衡因子的值只可能是 -1、0或1。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SoVoE7ES-1630834565016)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.1bbn1ruvkgsg.png)
-
平衡二叉树的插入
二叉排序树保证平衡的基本思想如下:
- 每当在二叉排序树中插入(或删除)一个结点时,首先检查其插入路径上的结点是否因为此次操作而导致了不平衡。
- 若导致了不平衡,则先找到插入路径上离插入结点最近的平衡因子的绝对值大于1的结点A。
- 再对以A为根的子树,在保持二叉排序树特性的前提下,调整各结点的位置关系,使之重新达到平衡。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7Kqs2Odc-1630834565017)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.5yh63k08p240.png)
注意: 每次调整的对象都是最小不平衡子树,即以插入路径上离插入结点最近的平衡因子的绝对值大于1的结点作为根的子树。
平衡二叉树的插入过程的前半部分与二叉排序树相同,但在新结点插入后,若造成查找路径上的某个结点不再平衡,则需要做出相应的调整。可将调整的规律归纳为下列4种情况:
-
LL平衡旋转(右单旋转)
由于在结点 A 的左孩子的左子树插入了新结点,导致A的平衡因子由1增至2,需要一次向右的旋转操作。右旋之后,B变为根结点,A变为B的右子树,B的原右子树按照大小次序,应作为A的左子树。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ehyjjqYZ-1630834565018)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.zxtzntp0o8g.png)
-
RR平衡旋转(左单旋转)
由于在结点 A 的右孩子的右子树插入了新结点,导致A的平衡因子由-1减至-2,需要一次向左的旋转操作。左旋之后,B变为根结点,A变为B的左子树,B的原左子树按照大小次序,应作为A的右子树。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Df0Gn39v-1630834565019)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.52rjrgfp618.png)
-
LR平衡旋转(先左后右双旋转)
由于在结点 A 的左孩子的右子树插入了新结点,导致A的平衡因子由1增至2,需要进行两次旋转操作,先左旋转后右旋转。先将A结点的左孩子B的右子树的根结点C向左上旋转提升到B结点的位置,然后再把该C结点向右上旋转提升到A结点的位置。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cCvP1Pz0-1630834565019)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.1jzrretvbjuo.png)
-
RL平衡旋转(先右后左双旋转)
由于在结点 A 的右孩子的左子树插入了新结点,导致A的平衡因子由-1减至-2,需要进行两次旋转操作,先右旋转后左旋转。先将A结点的右孩子B的左子树的根结点C向右上旋转提升到B结点的位置,然后再把该C结点向左上旋转提升到A结点的位置。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eIUcsdTh-1630834565020)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.13oq8gxu888w.png)
-
平衡二叉树的查找
在平衡二叉树上进行查找的过程与二叉排序树的相同。因此,在查找过程中,与给定值进行比较的关键字个数不超过树的深度。因此平衡二叉树的平均查找长度为 O ( l o g 2 n ) O(log_2n) O(log2n)。
哈夫曼树和哈夫曼编码
-
哈夫曼树的基本概念
哈夫曼(Huffman)树又称最优树,是一类带权路径长度最短的树,在实际中有广泛的用途。
哈夫曼树的定义,涉及路径、路径长度、权等概念,下面先给出这些概念的定义,然后再介绍哈夫曼树。
名词 定义 路径 从树中一个结点到另一个结点之间的分支构成这两个结点之间的路径。 路径长度 路径上的分支数目称作路径长度。 树的路径长度 从树根到每一结点的路径长度之和。 权 赋予某个实体的一个量,是对实体的某个或某些属性的数值化描述。 结点的带权路径长度 从该结点到树根之间的路径长度与结点上权的乘积。 树的带权路径长度 树中所有叶子结点的带权路径长度之和,通常记作 W P L = ∑ k = 1 n w k l k WPL=\sum\limits_{k=1}^nw_kl_k WPL=k=1∑nwklk。 哈夫曼树 带权路径长度 WPL 最小的二叉树称做最优二叉树或哈夫曼树。 例如,下图中所示的3棵二叉树,都含4个叶子结点a、b、c 、d, 分别带权7、5、2、4,它们的带权路径长度分别为:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rjvusOMZ-1630834565021)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.21njwzbsk200.png)
其中c树的WPL最小,可以验证,它恰好为哈夫曼树。
-
哈夫曼树的构造算法
给定n个权值分别为 w 1 , w 2 , . . . , w n w_1,w_2,..., w_n w1,w2,...,wn的结点,构造哈夫曼树的算法描述如下:
- 将这n个结点分别作为n棵仅含一个结点的二叉树,构成森林F。
- 构造一个新结点,从F中选取两棵根结点权值最小的树作为新结点的左、右子树,并且将新结点的权值置为左、右子树上根结点的权值之和。
- 从F中删除刚才选出的两棵树,同时将新得到的树加入F中。
- 重复步骤2)和3),直至F中只剩下一棵树为止。
从上述构造过程中可以看出哈夫曼树具有如下特点:
- 每个初始结点最终都成为叶结点,且权值越小的结点到根结点的路径长度越大。
- 构造过程中共新建了n-1个结点(双分支结点),因此哈夫曼树的结点总数为2n-1。
- 每次构造都选择 2 棵树作为新结点的孩子,因此哈夫曼树中不存在度为1的结点。
例如,权值 { 7 , 5 , 2 , 4 } \{7,5,2,4\} {7,5,2,4}的哈夫曼树的构造过程如下图所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PfbhSqo8-1630834565022)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.7khl9uwdu9w0.png)
哈夫曼算法的实现:
- 初始化:首先动态申请 2n 个单元;然后循环 2n-1 次,从 1 号单元开始,依次将 1 至 2n-1 所有单元中的双亲、左孩子、右孩子的下标都初始化为0; 最后再循环n次,输入前n个单元中叶子结点的权值。
- 创建树:循环 n-1 次,通过 n-1 次的选择、删除与合并来创建哈夫曼树。选择是从当前森林中选择双亲为0且权值最小的两个树根结点 s1 和 s2;删除是指将结点 s1 和 s2 的双亲改为非0;合并就是将 s1 和 s2 的权值和作为一个新结点的权值依次存入到数组的第 n+1 之后的单元中,同时记录这个新结点左孩子的下标为 s1, 右孩子的下标为 s2。
//这里使用数组的方式顺序存储哈夫曼树,前n个位置存储了叶子结点,后n-1个位置存储了非叶子结点。 //---- 哈夫曼树的存储表示 ---- typedef struct{ int weight; //结点的权值 int parent,lchild,rchild; //结点的双亲、左孩子和右孩子的下标 }HTNode,*HuffmanTree; //动态分配数组存储哈夫曼树 //选择两个其双亲域为0 且权值最小的结点,并返回它们在 HT 中的序号 sl和 s2 void Select(HuffmanTree &HT,int n,int &s1,int &s2){ int min = Infinite; //找出最小值的下标 for(int i=1;i<=n;i++){ if(HT[i].parent==0 && HT[i].weight<min){ s1 = i; min = HT[i].weight; } } min = Infinite; //找出值第二小的下标 for(int i=1;i<=n;i++){ if(HT[i].parent==0 && HT[i].weight<min && i!=s1){ s2 = i; min = HT[i].weight; } } } void CreateHuffmanTree(HuffmanTree &HT,int n){ if(n<=1) return; int m = 2*n-1; //n个叶子结点的哈夫曼树共有2n-1个结点。 HT = new HTNode[m+1]; //0号单元不用,动态分配m+1个单元。 for(int i=1;i<=m;i++){ //初始化1~m号单元 HT[i].parent = 0; HT[i].lchild = 0; HT[i].rchild = 0; } for(int i=1;i<=n;i++) //输入前n个单元中叶子结点的权值 cin>>HT[i].weight; //-------- 创建哈夫曼树 -------- for(int i=n+1;i<=m;i++){ int s1,s2; //在 HT[k] (k在1到i-1之间) 中选择两个其双亲域为0且权值最小的结点,并返回它们在 HT 中的序号 sl和 s2 Select(HT,i-1,s1,s2); HT[s1].parent = HT[s2].parent = i; //将两个最小的结点的父结点设置为下标i HT[i].lchild = s1; //将HT[i]的左孩子下标设置为s1 HT[i].rchild = s2; //将HT[i]的右孩子下标设置为s2 HT[i].weight = HT[s1].weight+HT[s2].weight; //HT[i]的权值为两个孩子权值之和 } }
-
哈夫曼编码
为了引出哈夫曼编码的思想,我们先说明一些概念:
名词 解释 固定长度编码 对每个字符用相等长度的二进制位表示。 可变长度编码 允许对不同字符用不等长的二进制位表示。 前缀编码 在一个编码方案中,没有一个编码是另一个编码的前缀。 光看上面的概念不是很好理解,我们来举例说明一下这些概念。
如a,b,c,d四个字符,我们若用二进制表示,可以表示为:a->00,b->01,c->10,d->11。这些字符编码的二进制位都相等,所以我们称其为固定长度编码。
倘若在一篇文章中,a出现了10次,b出现了6次,而c和d各只出现了2次,这时候我们再使用固定长度编码就变得不太聪明了。因为a出现的次数最多,我们完全可以使其用一个更短的编码如0表示,而b出现次数第二多,我们用1表示,c使用10,d使用11。这样我们得到的编码从整体上来说,就变得更短,压缩数据的效果就越好。这便是可变长度编码。
从上面介绍的可变长度编码可以观察出一些问题,就是解码的时候有多种可能性。比如01101,我们可以解码成adab也可以解码成abcb。这种情况的出现是因为b->1是c->10,d->11的前缀,所以我们解码得到的结果不唯一,这样显然是不行的,我们需要确保编码与解码的准确性。所以在一个编码方案中,当任一个编码都不是其他任何编码的前缀时,我们称为前缀编码。
了解到上面的概念后,我们来提出哈夫曼树实现哈夫曼编码的思路:
哈夫曼编码:对一棵具有n个叶子的哈夫曼树,若对树中的每个左分支赋予0,右分支赋予1,则从根到每个叶子的路径上,各分支的赋值分别构成一个二进制串, 该二进制串就称为哈夫曼编码。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UBDpvUSN-1630834565023)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.i16t92980oo.png)
由哈夫曼树得到哈夫曼编码很简单,我们将每个字符当作一个独立的结点,其权值为它出现的频度,然后构造哈夫曼树。将每个左分支置为0,右分支置为1,从根结点到叶子结点(该字符)的路径即为该字符的哈夫曼编码。
在构造好哈夫曼树之后,求哈夫曼编码编码的主要思想是:依次以叶子为出发点,向上回溯至根结点为止。 回溯时走左分支则生成代码0,走右分支则生成代码1。
根据哈夫曼树求哈夫曼编码的算法实现:
- 由哈夫曼树的构造算法得知,前n个元素记录的为叶子结点,后n-1个元素记录的为合并出来的父结点。所以我们遍历前n个叶子结点。
- 记录叶子结点的下标,和其父结点的下标,然后回溯判断子结点是父结点的左孩子还是右孩子,左孩子输出0,右孩子输出1。
- 循环上述操作,不断更新孩子结点和父结点的下标,直到结点的父结点下标为0,表示已经到根结点,停止循环。
//在这里我们定义哈夫曼树的时候多加一个字符型的data域,用于记录结点是哪个字符。 void CreateHuffmanCode(HuffmanTree HT,int n){ int start; for(int i=1;i<=n;i++){ cout<<HT[i].data<<':'; start = i; //从叶子结点开始 int p = HT[start].parent; //p为父结点的下标 while (p!=0)//当p的下标为0时结束,哈夫曼树下标从1开始记录 { if(HT[p].lchild==start){//判断原子结点是父结点的左孩子还是右孩子 cout<<'0'<<' '; //左孩子输出0 }else{ cout<<'1'<<' '; //右孩子输出1 } start = p; //更新子结点 p = HT[p].parent; //更新父结点 } cout<<endl; } }
由上述算法得到的哈夫曼编码为倒序输出的结果,因为是从叶子结点从下往上输出的,可以使用栈来逆转顺序,也可以使用容器或数组来存储哈夫曼编码的结果,此处不在阐述。
文件的编码和译码
(1) 编码:有了字符集的哈夫曼编码表之后,对数据文件的编码过程是:依次读入文件中的字符c,在哈夫曼编码表HC中找到此字符,将字符c转换为编码表中存放的编码串。
(2) 译码:依次读入文件的二进制码,从哈夫曼树的根结点出发,读到0走左孩子,读到1走右孩子,一旦到达叶子结点将其数据输出即可。
树的应用—并查集
并查集是一种简单的集合表示,它支持以下3种操作:
- Union(S,Root1,Root2):把集合S中的子集合Root2并入子集合Root1。要求Root1和Root2互不相交,否则不执行合并。
- Find(S,x):查找集合S中单元素x所在的子集合,并返回该子集合的名字。
- Initial(S):将集合S中的每个元素都初始化为只有一个单元素的子集合。
通常使用树(森林)的双亲表示法作为并查集的存储结构,每个子集合以一棵树表示。所有表示子集合的树,构成表示全集合的森林,存放在双亲表示数组内。通常用数组元素的下标代表元素名,用根结点的下标代表子集合名,根结点的双亲结点为负数。
若设有一个全集合为 S = { 0 , 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 } S= \{0,1,2,3,4,5,6,7,8,9\} S={0,1,2,3,4,5,6,7,8,9},下面介绍使用并查集的一些操作。
状态 | 介绍 | 图片展示 |
---|---|---|
初始化 | 每个子集合的数组值为-1。 | [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1P5xn8I2-1630834565025)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.3bupii4mnag0.png) |
集合合并 | 子集合合并为3个更大的子集合后。子结点值为根结点下标,根结点值为集合元素个数的相反数。 | [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CX3rm5wY-1630834565026)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.41h0ra2mzyi0.png) |
两个子集合合并 | 将其中一个子集合根结点的双亲指针指向另一个集合的根结点。 S 1 ∪ S 2 S_1 ∪S_2 S1∪S2 | [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8J6Tjbtn-1630834565027)(https://cdn.jsdelivr.net/gh/alonscholar/image-warehouse@master/blog/数据结构/数据结构(严蔚敏)]-第五章-树和二叉树/image.c88y7n5j5gw.png) |
在采用树的双亲指针数组表示作为并查集的存储表示时,集合元素的编号从0到size-1。其中size是最大元素的个数。下面是并查集主要运算的实现。
#define SIZE 100 //定义集合元素的个数
int UFSets[SIZE]; //集合元素数组,使用双亲表示法
void Initial(int S[]){
for(int i=0;i<SIZE;i++) //每个自成单元素集合
S[i]=-1;
}
int Find(int S[],int x){
while (S[x]>0) //循环寻找x的根
{
x=S[x];
}
return x; //返回树的根
}
void Union(int S[],int Root1,int Root2){
//要求传入的为两个根结点,可以用Find方法找到根结点
if(Root1!=Root2){ //若两个根结点不同
S[Root1] += S[Root2]; //将集合Root2中的元素个数加到集合Root1上
S[Root2]=Root1; //将根Root2连接到另一根Root1下面
}
}