您现在的位置是:首页 >技术交流 >数据结构_树与二叉树网站首页技术交流
数据结构_树与二叉树
目录
1. 树的基本概念
1.1 树的定义
树是 n ( n 0 ) 个结点的有限集。当 n=0 时,称为空树。在任意一棵非空树中应该满足:
- 1. 有且仅有一个特定的称为 根 的结点。
- 2. 当 n>1 时,除了称为 根 的结点以外的其余结点可以分为 m (m>0) 个互不相交的有限集 ,……,其中每个集合本身又是一棵树(n个结点的有限集),并且称为 根的子树。下述图中每一个方框中都是一个子树。(互不相交的有限集合)
还可以再进行切换为两个不相交的子树。
显然,树是递归的,树的定义中又用到了其自身,树是一种递归的数据结构。树作为一种逻辑结构,同时也是一种分层结构,具有以下两个特点:
- 1. 树的根结点没有前驱,除根结点外的所有结点有且只有一个前驱。
- 2. 树中所有结点可以有零个或多个后继。
树适合于表示具有层次结构的数据。树中的某个结点(除根结点外)最多只和上一层的一个结点(即其父结点)有直接关系,根结点没有直接的上层结点,因此在 n 个结点的树中有 n-1 条边。树中每一个结点与其下一层的零个或多个结点(即其子女结点)有直接关系。
1.2 基本术语
1. 考虑结点K。根到结点K的唯一路径上的任意结点,称为结点K的祖先。如结点B是结点K的祖先,而结点K是结点B的子孙。路径上最接近结点K的结点E称为K的双亲,而K为结点E的孩子。根是树中唯一没有双亲的结点。有相同双亲的结点称为兄弟,如结点K和结点L有相同的双亲E,即K和L为兄弟。
2. 树中一个结点的孩子个数称为该结点的度,树中结点的最大度数称为树的度。如结点B的度为2,结点D的度为3,树的度为3
3. 度大于0的结点称为分支结点(又称非终端结点);度为0 (没有子女结点)的结点称为叶子结点(又称终端结点)。在分支结点中,每个结点的分支数就是该结点的度。
4. 结点的深度、高度和层次:
结点的层次从树根开始定义,根结点在第一层,它的子节点为第二层,以此类推。双亲在同一层的结点互为堂兄弟,图中结点G与E F H I J 互为堂兄弟。
结点的深度是从根结点开始自顶向下逐层累加的。
结点的高度是从叶结点开始自底向上逐层累加的。
树的高度(或深度) 是树中结点的最大层数。图中树的高度为4。
5. 有序树和无序树。树中结点的各子树从左到右是有次序的,不能互换,称该树为有序树,否则称为无序树。如上图为有序树,若将子节点位置互换,则变成一颗不同的树。
6. 路径和路径长度。树中两个结点之间的路径是由这两个结点之间所经过的结点序列构成的,而路径长度是路径上所经过的边的个数。
注意:由于树中的分支是有向的,从双亲指向孩子,所以树中的路径是从上向下的,同一双亲的两个孩子之间不存在路径。
7. 森林。森林是 m ( m 0 )棵互不相交的树的集合。森林的概念和树的概念十分相近,把树的根结点删去就成了森林。反之,只要把 m 棵独立的树加上一个结点,并把这 m 棵树作为该结点的子树,则森林就变成了树。
1.3 树的性质
树具有如下最基本的性质:
- 1. 树中的结点数等于所有结点的度数之和加1;
- 2. 度为 m 的树中第 i 层上至多有个结点( i 1);
- 3. 高度为 h 的 m 叉树至多有(-1)/(m-1)个结点;
- 4. 具有 n 个结点的 m 叉树的最小高度为 [ ( n ( m - 1 ) + 1 ) ];
1. 结点数=总度数+1;
2. 树的度:各结点的度的最大值
m叉树:每个结点最多只能有 m 个孩子的树
度为 m 的树(各结点中度的最大值为m):
1. 任意结点的度m(最多有 m 个孩子)
2. 至少有一个结点度 = m(有 m 个孩子)
3. 一定是非空树,至少有 m+1 个结点
m叉树(每个结点最多只能有 m 个孩子的树):
1. 任意结点的度m(最多有 m 个孩子)
2. 允许所有结点的度都小于 m
3. 可以是空树
3. 高度为 h 的 m叉树至少有 h 个结点。
高度为 h、度为 m 的树至少有 h+m-1 个结点。
1.4 相关练习
1. 树的路径长度是从树根到每个结点的路径长度的总和。
2. 对于一棵具有 n 个结点,度为 4 的树来说,树的高度至多是 n-3;
首先明确这个一个树,度为4就是说各个结点中度的最大值为4,至少有一个结点的度=4,也可以说是有4个孩子,那么从根结点出发从上到下到达子节点,减去 度=4 中的三个度,剩下的就是树可以达到的最大高度。
3. 度为4、高度为 h 的树,至少有 h+3 个结点
首先度为4表示,树中结点的度的最大值就是4,且至少有一个结点的度是4,高度是h,那么高度至少也要是结点度为4的那个结点中减去三个度+h,原因和上题相同。
4. 假定一棵度为 3 的树中,结点数为 50,则其最小高度为 5
度为3就是说结点的度的最大值为3,并且至少有一个结点的度为3,最大高度显然是50-2=48;
要达到最小高度,那么每一层都要充足(假设每一个结点的度都为3),度为 m 的树中第 i 层上至多有个结点( i 1);
根结点一个,第二层至多有3个结点,第三层至多有3的平方----9个结点,第四层至多3的三次方----27个结点,27+9+3+1=40<50,第5层至多3的四次方----81个结点,所以最小高度为5;
5. 在一棵度为 4 的树 T 中,若有 20 个度为 4 的结点,10个度为 3 的结点,1个度为 2 的结点,10个度为 1 的结点,则树 T 的叶结点个数为 82
度为 4 表示树中结点的度的最大值为4,且至少有一个结点的度为4;
总度数=分支数-1;结点总数又等于总度数+1;总度数=分支数;
叶结点是没有度的(叶结点没有孩子,也就是没有度);所以树的总度数=20*4+10*3+1*2+10*1=122;结点总数也就是123;
除啦叶结点其余的结点数=20+10+1+10=41;所以叶结点的个数为123-41=82;
注意:
树结点与度之间的关系:
①:总结点数=+++……+
②:总分支数=1+2+……+m(度为 m 的结点引出 m 条分支)
③:总结点数=总分支数+1;
2. 二叉树的概念
2.1 二叉树的概念及其主要特性
二叉树的定义:
二叉树是另一种树形结构,其特点是每个结点至多只有两棵子树(即二叉树中不存在度大于 2 的结点);二叉树的子树有左右之分,其次序不能任意颠倒。
二叉树也是以递归的形式定义。二叉树是 n (n0) 个结点的有限集合:
①:或者为空二叉树,即 n=0 。
②:或者由一个根结点和两个互不相交的被称为根的左子树和右子树组成。左子树和右子树又分别是一棵二叉树。
二叉树是有序树,若将其左、右子树颠倒,则成为另一棵不同的二叉树。即使树中结点只有一棵子树,也要区分它是左子树还是右子树。
二叉树与度为2的有序树的区别:
①:度为2的树至少有3个结点,而二叉树可以为空。
②:度为2的有序树的孩子的左右次序是相对于另一个孩子而言的,若某个结点只有一个孩子,则这个孩子就无须区分其左右次序;而二叉树无论其孩子数是否为2,均需确定其左右次序,即二叉树的结点次序不是相对于另一个结点而言的,而是确定的。
几个特殊的二叉树:
1. 满二叉树。一棵高度为 h,且含有 -1 个结点的二叉树称为满二叉树,顾名思义,即树中每层都含有最多的结点,如下图所示;满二叉树的叶子结点都集中在二叉树的最下一层,并且除叶子结点之外的每个结点度数均为 2。
可以对满二叉树按层序编号:约定编号从根结点(根结点编号为1)起,自上而下,自左而右。这样,每个结点对应一个编号,对于编号为 i 的结点,若有双亲,则其双亲为 [i/2],若有左孩子,则左孩子为 2i;若有右孩子,则右孩子为 2i+1。(例如:编号为 4 的结点,其双亲为编号 2 ,也就是 4/2,编号 4 的左孩子为 编号8 ,也就是2*4;右孩子为 编号9,也就是2*4+1)
2. 完全二叉树。高度为 h、有 n 个结点的二叉树,当且仅当其每个结点都与高度为 h 的满二叉树中编号为 1~n 的结点一一对应时,称为完全二叉树;
完全二叉树的特点:
①:若 i [n/2],则结点 i 为分支结点,否则为叶子结点。
显然上图中 n=12,12/2=6,编号小于 6 的结点,都是分支结点;编号大于 6 的结点,都是叶子结点;
②:叶子结点只可能在层次最大的两层上出现。对于最大层次中的叶子结点,都依次排列在该层最左边的位置上。
显然上图中叶子结点出现在层次最大的两层上,二叉树是分左子树和右子树的,所以按照编号是优先排列在该层最左边的位置上的;
③:若有度为 1 的结点,则只可能有一个,且该结点只要左孩子而无右孩子(重要特征);
④:按层序编号后,一旦出现某结点(编号为 i )为叶子结点或只有左孩子,则编号大于 i 的结点均为叶子结点;
⑤:若 n 为奇数,则每个分支结点都有左孩子和右孩子;若 n 为偶数,则编号最大的分支结点(编号为 n/2)只有左孩子,没有右孩子,其余分支结点左、右孩子都有;
注:完全二叉树就是对应相同高度的满二叉树缺失最下层最右边的一些连续叶子结点。
3. 二叉排序树。左子树上所有结点的关键字均小于根结点的关键字;右子树上的所有结点的关键字均大于根结点的关键字;左子树和右子树又各是一棵二叉排序树。
4. 平衡二叉树。树上任一结点的左子树和右子树的深度之差不超过 1。
二叉树的性质:
1. 非空二叉树上的叶子结点数等于度为2的结点数加1;即 =+1;
证明:设度为0,1,2的结点个数分别为,和,那么结点总数就等于n=++;二叉树的分支数,除了根结点以外,其余结点都有一个分支进入,那么分支数就等于结点数减1;由于这些分支数都是由度等于1或者2的结点射出了,所以分支数等于+2;所以 ++ = +2 ;
拓展:若结点数量为n,则边的数量为n-1;
2. 非空二叉树上第 K 层上至多有个结点
3. 高度为h的二叉树至多有-1个结点
4. 对完全二叉树按从上到下、从左到右的顺序依次编号1,2,3……n,则有以下关系:
①:当 i>1时,结点 i 的双亲编号为[i/2],当 i 为偶数时,其双亲的编号为i/2,它是双亲的左孩子;当 i 为奇数时,其双亲的编号为(i-1)/2,它是双亲的右孩子。
②:当 2in 时,结点 i 的左孩子编号为 2i,否则无左孩子。
③:当 2i+1n 时,结点 i 的右孩子编号为 2i+1,否则无右孩子。
④:结点 i 所在层次 (深度) 为 i+1。
5. 具有 n 个结点的完全二叉树的高度为(n+1) 或 n+1。
2.2 二叉树的存储结构
2.2.1 顺序存储结构
二叉树的顺序存储是指用一组地址连续的存储单元依次自上而下、自左至右存储完全二叉树上的结点元素,即将完全二叉树上编号为 i 的结点元素存储在一维数组下标为 i-1 的分量中。
根据二叉树的性质,完全二叉树和满二叉树采用顺序存储比较合适,树中结点的序号可以唯一地反映结点之间的逻辑关系,这样既可以最大的节省存储空间,又能利用数组元素的下标值确定结点在二叉树中的位置,以及结点之间的关系。
对于一般的二叉树,为了让数组下标可以反映二叉树中结点之间的逻辑关系,只能添加一些并不存在的空结点,让其每个结点与完全二叉树上的结点相对照,再存储到一维数组的相应分量中。在最坏的情况下,一个高度为 h 且只有 h 个结点的单支树却需要占据 -1 个存储单元。如下图所示:
注意:这种存储结构建议从数组下标 1 开始存储树中的结点,若从数组下标 0 开始存储,则不满足连续存储的特性;
2.2.2 链式存储结构
由于顺序存储结构的空间利用率较低,因此二叉树一般都采用链式存储结构,用链表结点来存储二叉树中的每个结点。在二叉树中,结点结构通常包括若干数据域和若干指针域,二叉链表至少包含3个域:数据域 data、左指针域 lchild 和 右指针域 rchild;
//二叉树的链式存储结构
typedef struct BiTNode
{
ElemType data; //数据域
struct BiTNode *lchild,*rchild; //左右孩子指针
}BiTNode,*BiTree;
重要结论:在含有 n 个结点的二叉链表中,含有 n+1 个空链域;
2.3 相关练习
3. 二叉树的遍历和线索二叉树
3.1 二叉树的遍历
二叉树的遍历是指按照某条搜索路径访问树中每个结点,使得每个结点均被访问一次,而且仅被访问一次。由于二叉树是一种非线性结构,每个结点都可能有两棵子树,因而需要寻找一种规律,以便使二叉树上的结点能排列在一个线性队列上,进而便于遍历。
由二叉树的递归定义可以知道,遍历一棵二叉树便要决定对根结点N、左子树L和右子树R的访问顺序。按照先遍历左子树再遍历右子树的原则,常见的遍历次序有先序(NLR)、中序(LNR)和后序(LRN)三种遍历算法;其中 “序” 指的是根结点在何时被访问。
3.1.1 先序遍历
先序遍历(PreOrder)的操作过程如下
若二叉树为空,则什么也不做;否则,
- 1. 访问根结点;
- 2. 先序遍历左子树;
- 3. 先序遍历右子树;
void PreOrder(BiTree T)
{
if(T!=NULL)
{
visit(T); //访问根结点
PreOrder(T->lchild); //递归遍历左子树
PreOrder(T->rchild); //递归遍历右子树
}
}
3.1.2 中序遍历
中序遍历(InOrder)
若二叉树为空,则什么也不做;否则,
- 1. 中序遍历左子树;
- 2. 访问根结点;
- 3. 中序遍历右子树;
void InOrder (BiTree T)
{
if(T!=NULL)
{
InOrder(T->lchild); //递归遍历左子树
visit(T); //访问根结点
InOrder(T->rchild); //递归遍历右子树
}
}
3.1.3 后序遍历
后序遍历(PostOrder)
若二叉树为空,则什么也不做;否则,
- 1. 后序遍历左子树;
- 2. 后序遍历右子树;
- 3. 访问根结点;
void PostOrder(BiTree T)
{
if(T!=NULL)
{
PostOrder(T->lchild); //递归遍历左子树
PostOrder(T->rchild); //递归遍历右子树
visit(T); //访问根结点
}
}
总结:
三种遍历算法中,递归遍历左右子树的顺序都是固定的,只是访问根结点的顺序不同。不管采用哪种遍历算法,每个结点都访问一次且仅访问一次,所以时间复杂度都是O(n)。
在遍历算法中,递归工作栈的栈深恰好为树的深度,所以在最坏的情况下,二叉树是有 n 个结点且深度为 n 的单支树,遍历算法的空间复杂度为O(n)。
3.1.4 递归算法和非递归算法的转换
在上部分所学的先序、中序和后序三种遍历算法中,暂时抹去和递归无关的 visit 语句,这三个遍历算法完全相同,因此,从递归执行过程的角度看先序、中序和后序遍历也是完全相同的。
借助栈,我们来分析中序遍历的访问过程:
① 沿着根的左孩子依次入栈,直到左孩子为空,说明已经找到了可以输出的结点,此时栈内的元素依次为A B D。
② 栈顶元素D出栈并访问(它是中序序列的第一个结点);若其右孩子为空,继续执行 ②;若其右孩子不为空,将右子树转而执行 ①;D右孩子为空,栈顶 B 出栈并访问;B 右孩子不空,将其右孩子 E 入栈,E左孩子为空,栈顶 E 出栈并访问;E 右孩子为空,栈顶 A 出栈并访问;A右孩子不空,将其右孩子 C 入栈,C左孩子为空,栈顶 C 出栈并访问。 由此得到中序序列D B E A C;
void InOrder(BiTree T)
{
InitStack(S); //初始化栈
BiTree p=T; //p是遍历指针
while(p || !IsEmpty(S)) //栈不空或 p 不空时循环
{
if(p)
{
Push(S,p); //当前结点入栈
p=p->lchild; //左孩子不空,一直向左走
}
else //出栈,并转向出栈结点的右子树
{
Pop(S,p); //栈顶元素出栈
visit(p); //访问出栈结点
p=p->rchild; //向右子树走,p赋值为当前结点的右孩子
}
}
}
3.1.5 层次遍历
如下图为二叉树的层次遍历,按照箭头所指方向,按照 1,2,3,4 的层次顺序,对二叉树中各个结点进行访问。
要进行层次遍历,需要借助一个队列。先将二叉树根结点入队,然后出队,访问出队结点,若它有左子树,则将左子树根结点入队;若它有右子树,则将右子树根结点入队。然后出队,访问出队结点……如此反复,直到队列为空。
//二叉树的层次遍历算法
void LevelOrder(BiTree T)
{
InitQueue(Q); //初始化辅助队列
BiTree p;
EnQueue(Q,T); //将根节点入队
while(!IsEmpty(Q)) //队列不空则循环
{
DeQueue(Q,p); //队头结点出队
visit(p); //访问出队结点
if(p->lchild!=NULL)
{
EnQueue(Q,p->lchild); //左子树不空,则左子树根结点入队
}
if(p->rchild!=NULL)
{
EnQueue(Q,p->rchild); //右子树不空,则右子树根结点入队
}
}
}
注意:
遍历是二叉树各种操作的基础,可以在遍历的过程中对结点进行各种操作;比如,对于已知树求结点的双亲、求结点的孩子结点、求二叉树的深度、求二叉树的叶子结点个数、判断两棵二叉树是否相等;所有的这些操作都建立在二叉树遍历的基础之上。
3.1.6 由遍历序列构造二叉树
由二叉树的先序序列和中序序列可以唯一的确定一棵二叉树;
在先序遍历序列中,第一个结点一定是二叉树的根结点;而在中序遍历中,根结点必然将中序序列分割成两个子序列,前一个子序列是根结点的左子树的中序序列,后一个子序列是根结点的右子树的中序序列。根据这两个子序列,在先序序列中找到对应的左子序列和右子序列。在先序序列中,左子序列的第一个结点是左子树的根结点,右子序列的第一个结点是右子树的根结点。如此递归下去,便能唯一的确定这棵二叉树。
由二叉树的后序序列和中序序列也可以唯一的确定一棵二叉树。
后序序列的最后一个结点就如同先序序列的第一个结点,可以将中序序列分割成两个子序列,然后采用类似的方法递归的进行划分,进而得到一棵二叉树。
由二叉树的层序序列和中序序列也可以唯一地确定一棵二叉树;
例如,求先序序列(ABCDEFGHI)和中序序列(BCAEDGHFI)所确定的二叉树
首先,由先序序列可知 A 为二叉树的根结点。中序序列中 A 之前的BC为左子树的中序序列,EDGHFI 为右子树的中序序列。然后由先序序列可知 B 是左子树的根结点,D 是右子树的根结点。以此类推即可;
3.2 线索二叉树
3.2.1 线索二叉树的基本概念
遍历二叉树是以一定的规则将二叉树中的结点排列成一个线性序列,从而得到几种遍历序列,使得该序列中每个结点(第一个和最后一个结点除外)都有一个直接前驱和直接后继。
传统的二叉链表存储仅能体现一种父子关系,不能直接得到结点在遍历中的前驱或后继。在二叉链表中,我们知道:在含 n 个结点的二叉树中,有 n+1 个空指针。(这是因为二叉树只会存在度为0、度为1和度为2的结点;度为0的结点也就是叶子结点含有两个空指针,度为1的结点含有一个空指针,所以空指针总数等于2+,又因为度为0的结点数等于叶子结点数+1,所以=+1,所以空指针数等+++1=n+1)
由此设想利用空指针来存放指向其前驱和后继的指针;因为线索二叉树也正是为了加快查找结点前驱和后继的速度。
规定:若无左子树,令lchild指向其前驱结点;若无右子树,令rchild指向其后继结点。如下图所示,还需增加两个标志域标识指针域是指向左(右)孩子还是指向前驱(后继)。
//标识域的含义如下:
ltag=0; lchild域指示结点的左孩子
ltag=1; lchild域指示结点的前驱
rtag=0; rchild域指示结点的右孩子
rtag=1; rchild域指示结点的后继
typedef struct ThreadNode
{
ElemType data; //数据元素
struct ThreadNode *lchild,*rchild; //左、右孩子指针
int ltag,rtag; //左、右线索标志
}ThreadNode,*ThreadTree;
以这种结点结构构成的二叉链表作为二叉树的存储结构,称为线索链表。其中指向结点前驱和后继的指针称为线索。加上线索的二叉树称为线索二叉树。
3.2.2 中序线索二叉树的构造
二叉树的线索化是将二叉链表中的空指针改为指向前驱或后继的线索。而前驱和后继的信息只有在遍历时才能得到,因此线索化的实质就是遍历一次二叉树。
以中序线索二叉树建立为例。附设指针 pre 指向刚刚访问过的结点,指针 p 指向正在访问的结点,即 pre 指向 p 的前驱。在中序遍历的过程中,检查 p 的左指针是否为空,若为空就将它指向 pre;检查 pre 的右指针是否为空,若为空就将它指向 p ;
//中序遍历对二叉树线索化的递归算法
void InThread(ThreadTree &p,ThreadTree &pre)
//pre是指向刚刚访问过的结点
{
if(p!=NULL)
{
InTread(p->lchild,pre); //递归,线索化左子树
if(p->lchild==NULL) //如果左子树为空,建立前驱线索
{
p->lchild=pre;
p->ltag=1;
}
if(pre!=NULL && pre->rchild==NULL)
{
pre->rchild=p; //建立前驱结点的后继线索
pre->rtag=1;
}
pre=p; //标记当前结点成为刚刚访问过的结点
InThread(p->rchild,pre); //递归,线索化右子树
}
}
//通过中序遍历建立中序线索二叉树的过程
void CreateInThread(ThreadTree T)
{
ThreadTree pre=NULL;
if(T!=NULL) //非空二叉树,线索化
{
InTread(T,pre); //线索化二叉树
pre->rchild=NULL; //处理遍历的最后一个结点
pre->rtag=1;
}
}
3.2.3 中序线索二叉树的遍历
中序线索二叉树的结点中隐含了线索二叉树的前驱和后继信息。在对其进行遍历时,只要先找到序列中第一个结点,然后依次找结点的后继,直到其后继为空。
在中序线索二叉树中找结点后继的规律是:若其右标志为 “1” ,则右链为线索,指示其后继,否则遍历右子树中第一个访问的结点(右子树中最左下的结点)为其后继。
求中序线索二叉树中中序序列的第一个结点:
ThreadNode *Firstnode(ThreadNode *p)
{
while(p->ltag==0)//指针 p 的左域指针ltag=0;意味着存在左孩子
{
p=p->lchild; //最左下的结点 让指针 p 指向结点的左孩子
return p; //返回左孩子对应的结点 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))
//for循环起始条件是指针 p 指向中序遍历序列的第一个结点;范围是指针 p 指向的结点不为空;每循环一次,指针p就指向其后继结点
visit(p); //每循环一次,指针 p 就访问指向的结点
}
3.2.4 先序线索二叉树和后序线索二叉树
以上图 a 为例手动求先序线索二叉树的过程。
先序序列为ABCDF,然后依次判断左右结点的左右链域,如果为空则将其改造为线索。结点AB均有左右孩子;结点C没有左孩子,则将左链域指向前驱B;结点C没有右孩子,则将右链域指向后继D;结点D没有左孩子,则将左链域指向前驱C;结点D没有右孩子,则将右链域指向后继F;结点F没有左孩子,则将其左链域指向前驱D,结点F没有右孩子,也没有后继,则置空NULL。先序线索二叉树如图b;
后序序列为CDBFA。结点C没有左孩子也没有右孩子,结点C没有前驱故左链域置空,结点C的右链域指向后继D;结点D没有左孩子也没有右孩子,结点D的左链域指向前驱C,右链域指向后继B;结点F没有左孩子也没有右孩子,结点F的左链域指向前驱B,结点F的右链域指向后继A;后序线索二叉树如图c。
3.3 相关练习
后序线索树的遍历仍需要栈的支持。
这是因为先序线索树和中序线索树都可以通过线索指针依次遍历得到下一个元素;而后序线索树是最后访问根结点的,是无法单单通过线索访问整个二叉树的;
4. 树、森林
4.1 树的存储结构
树的存储结构有多种,既可以采用顺序存储结构,又可以采用链式存储结构,但是无论选择哪种存储结构,都要求必须能唯一的反映树中各结点之间的逻辑关系。
4.1.1 双亲表示法
这种存储方式采用一组连续空间来存储每个结点,同时在每个结点中增设一个伪指针,指示其双亲结点在数组中的位置。
上图中显然数组的下标是按照从上到下,从左到右的顺序进行树的遍历;所以根结点的数组下标为0,根结点的孩子的下标分别为1 2 3;伪指针中根结点指向-1,其余结点分别指向自己的双亲。
//双亲表示法的存储结构
#define MAX_TREE_SIZE 100 //树中最多的结点数
typedef struct //树的结点定义
{
Elemtype data; //数据元素
int parent; //双亲位置域
}PTNode;
typedef struct //树的类型定义
{
PTNode nodes[MAX_TREE_SIZE]; //双亲表示
int n; //结点数
}PTree;
该存储结构利用了每个结点(根结点除外)只有唯一双亲的性质,可以很快得到每个结点的双亲结点,但求结点的孩子时需要遍历整个结构。
注意:区别树的顺序存储结构与二叉树的顺序存储结构
在树的顺序存储结构中,数组下标代表结点的编号,下标中所存的内容指示了结点之间的关系。而在二叉树的顺序存储结构中,数组下标既代表了结点之间的编号,又指示了二叉树中各结点之间的关系。
二叉树也属于树,因此二叉树都可以用树的存储结构来存储,但树却不能用二叉树的存储结构来存储。
4.1.2 孩子表示法
孩子表示法是将每个结点的孩子结点都用单链表链接起来形成一个线性结构,此时 n 个结点就有 n 个孩子链表(叶子结点的孩子链表为空表)
这种存储方式寻找子女的操作非常直接,但是寻找双亲的操作相对来说就比较复杂;
此方法中,显然 R 是根结点,R 的孩子结点就是A B C三个结点,对应的数组下角标分别为1 2 3;结点 A 的孩子结点为D E,对应的数组下角标为4 5 ;依次类推;
4.1.3 孩子兄弟表示法
孩子兄弟表示法又称为二叉树表示法,即以二叉链表作为树的存储结构。
孩子兄弟表示法使每个结点包括三部分内容:结点值、指向结点第一个孩子结点的指针,及指向结点下一个兄弟结点的指针(沿此域可以找到结点的所有兄弟结点);
//孩子兄弟表示法的存储结构
typedef struct CSNode
{
ElemType data; //数据域
struct CSNode *firstchild,*nextsibling; //第一个孩子和右兄弟指针
}CSNode,*CSTree;
通过上图可以非常直观的发现:左指针指向孩子结点,右指针指向兄弟结点;
4.2 树、森林与二叉树的转换
树转换为二叉树的规则:每个结点的左指针指向它的第一个孩子,右指针指向它在树中的相邻右兄弟,这个规则称为 “左孩子右兄弟” 。
二叉树转换为森林的规则:若二叉树非空,则二叉树的根及其左子树为第一棵树的二叉树形式,故将根的右链断开。二叉树根的右子树又可视为一个由除第一棵树外的森林转换后的二叉树,应用同样的方法,直到最后只剩一棵没有右子树的二叉树为止,最后再将每棵二叉树依次转换成树,就得到了原始森林。
其实就是把二叉树按照左子树分开即可,直到只剩一棵没有右子树的二叉树即可;
4.3 树和森林的遍历
树的遍历是指用某种方式访问树中的每个结点,且仅访问一次。主要有:
1. 先根遍历。若树非空,先访问根结点,再依次遍历根结点的每棵子树,遍历子树时仍遵循先跟后子树的规则。其遍历序列与这棵树相应二叉树的先序序列相同。
2. 后根遍历。若树非空, 先依次遍历根结点的每棵子树,再访问根结点,遍历子树时仍遵循先子树后根的规则。 其遍历序列与这棵树相应二叉树的中序序列相同。
按照森林和树相互递归的定义,可得到森林的两种遍历方法。
1. 先序遍历森林。
● 访问森林中第一棵树的根结点。
● 先序遍历第一棵树中根结点的子树森林。
● 先序遍历除去第一棵树之后剩余的树构成的森林。
2. 中序遍历序列
●中序遍历森林中第一棵树的根结点的子树森林。
●访问第一棵树的根结点。
● 中序遍历除去第一棵树之后剩余的树构成的森林。
4.4 相关练习
5. 树与二叉树的应用
5.1 哈夫曼树和哈夫曼编码
5.1.1 哈夫曼树的定义
在许多应用中,树中结点常常被赋予一个表示某种意义的数值,称为该结点的权。从树的根到任意结点的路径长度(经过的边数)与该结点上权值的乘积,称为该结点的带权路径长度。树中所有叶结点的带权路径长度之和称为该树的带权路径长度,记为
式中, 是第 i 个叶结点所带的权值, 是该叶结点到根结点的路径长度。
在含有 n 个带权叶结点的二叉树中,其中带权路径长度(WPL)最小的二叉树称为哈夫曼树,也称为最优二叉树。
例如:
下图中 3 棵二叉树都有 4 个叶子结点a b c d,分别带权7 5 2 4 ,它们的路径长度分别为
a. WPL=7*2+5*2+2*2+4*2=36
b. WPL=4*2+7*3+5*3+2*1=46
c. WPL=7*1+5*2+2*3+4*3=35
通过上述的计算,c 树的WPL最小,c 树为哈夫曼树。
5.1.2 哈夫曼树的构造
给定 n 个权值分别为 ,…… 的结点,构造哈夫曼树的算法描述如下:
1. 将这 n 个结点分别作为 n 棵仅含一个结点的二叉树,构成森林F。
2. 构造一个新结点,从 F 中选取两颗根结点权值最小的树作为新结点的左、右子树,并且将新结点的权值置为左、右子树上根结点的权值之和。
3. 从 F 中删除刚才选出的两棵树,同时将新得到的树加入到 F 中。
4. 重复步骤2 和 3,直至 F 中只剩下一棵树为止。
哈夫曼树的构造定义可能显得枯燥乏味,不好理解,接下来,举个例子来介绍哈夫曼树的构造过程:
从上述的构造过程中可以看出哈夫曼树具有如下的特点:
1. 每个初始结点最终都成为叶结点,且权值越小的结点到根结点的路径长度越大。
2. 构造过程中共新建了 n-1 个结点,因此哈夫曼树的结点总数为 2n-1。
3. 每次构造都选择 2 棵树作为新结点的孩子,因此哈夫曼树中不存在度为 1 的结点。
5.1.3 哈夫曼编码
在数据通信中,若对每个字符用相等长度的二进制位表示,称这种编码方式为固定长度编码。若允许对不同字符用不等长的二进制位表示,则这种编码方式称为可变长度编码。可变长度编码比固定长度编码要好得多,其特点是对频率高的字符赋以短编码,而对频率较低的字符则赋以较长一些的编码,从而可以使字符的平均编码长度减短,起到压缩数据的效果。哈夫曼编码是一种被广泛应用而且非常有效的数据压缩编码。
若没有一个编码是另一个编码的前缀,则这样的编码为前缀编码。
例如:
设计字符 A,B 和 C 对应的编码 0,101 和 100 是前缀编码。对前缀编码的解码很简单,因为没有一个编码是其他编码的前缀。所以识别出第一个编码,将他翻译为原码,再对余下的编码文件重复同样的解码操作。
比如说码串 00101100 可被唯一的翻译为 0,0,101和100。
如果再将字符 D 的编码设计为 00,此时 0 是 00的前缀,这样的码串的前两位就无法唯一翻译。
首先,将每个出现的字符当做一个独立的结点,其权值为它出现的频度(或次数),构造出对应的哈夫曼树。显然,所有字符结点都出现在叶结点中。我们可以将字符的编码解释为从根至该字符的路径上边标记的序列,其中边标记为 0 表示 “转向左孩子” ,标记为 1 表示 “转向右孩子” 。
这棵哈夫曼树的 WPL 为
这里的 WPL 可以视为最终编码得到二进制编码的长度,共224位。倘若采用 3 位固定长度编码,则得到的二进制编码长度为 300 位,因此哈夫曼树编码共压缩了 25% 的数据。
注意:
0 和 1 究竟是表示的左子树还是右子树并没有明确的规定。左、右孩子结点的顺序是任意的,所以构造出的哈夫曼树并不唯一,但是每个哈夫曼树的带权路径长度 WPL 相同且为最优。
5.2 并查集
并查集是一种简单的集合表示,支持以下三种操作:
1. Initial(S):将集合 S 中的每个元素都初始化为只有一个单元素的子集合。
2. Union(S,Root1,Root2):把集合 S 中的子集合 Root2 并入子集合 Root1。要求 Root1 和 Root2 互不相交,否则不执行合并。
//Union “并”操作,小树合并到大树
void Union(int S[],int Root1,int Root2)
{
if(Root1==Root2)
return;
if(S[Root2]>S[Root1]) //Root2 结点数更少,这是因为根结点是负值,对应的下角标越大,整体的结点数越少
{
S[Root1]+=S[Root2]; //把少结点的树加到多结点的树上,其目的是为了保证树整体的高度不变
S[Root2]=Root1; //小树合并到大树
}
else
{
S[Root2]+=S[Root1]; //累加结点总数
S[Root1]=Root2; //小树合并到大树
}
}
优化之后:
Find时间复杂度O(log2n)
Union时间复杂度O(nlog2n)
3. Find(S,x):查找集合 S 中单元素 x 所在的子集合,并返回该子集合的根结点。
所谓的并查集其实就是实现三种操作:
并--将一个子集合并入另一个子集合
查--从一个集合中查找到一个元素,通常是从找到该元素,然后一路向上找到根结点,根据根结点判断该元素属于哪一棵树
集--并查集本质上就是一个集合
通常用树 (森林) 的双亲表示作为并查集的存储结构,每个子集合以一棵树表示。所有表示子集合的树,构成表示全集合的森林,存放在双亲表示的数组内。通常用数组元素的下标代表元素名,用根结点的下标代表子集合名,根结点的双亲结点为负数。
比方说,若设有一个全集合为 S={0,1,2,3,4,5,6,7,8,9},初始化时每个元素自成一个单元素子集合,每个元素的数组值为-1;
紧接着:将这些子集合合并为 3 个更大的子集合 ={0,6,7,8},={1,4,9},={2,3,5}
数组元素的下标代表元素名;
根结点的下标表示子集合名; 所以结点6 7 8 的并查集数组名为0; 结点4 9 的并查集数组名为1; 结点3 5 的并查集数组名为 2;
根结点的双亲结点为负数; 子集合的数组名在初始化的基础之上做和得到根结点的并查集数组名; 结点6 7 8 0初始化时值均为 -1 ;所以子集合结点0 的数组名就是 (-1)*4=-4;
最后:为了得到两个子集合的并,只需将其中一个子集合根结点的双亲指针指向另一个集合的根结点。
根据上述的规则:根结点 0 的并查集数组名为 (-1)*7=-7;
在采用树的双亲指针数组表示作为并查集的存储表示时,集合元素的编号从 0 到 Size-1。其中 Size 是最大元素的个数。
//并查集的结构定义
#define SIZE 100
int UFSets[SIZE]; //集合元素数组(双亲指针数组)
//并查集的初始化操作(S 即为并查集)
void Initial(int S[])
{
for(int i=0;i<size;i++) //每个自成单元素除外
{
S[i]=-1;
}
}
//Find操作(函数在并查集 S 中查找并返回包含元素 x 的树的根)
int Find(int S[],int x)
{
while(S[x]>=0) //循环寻找x的根
x=S[x];
return x; //根的S[]小于0
}
时间复杂度O(n)
//判断两个元素是否属于同一集合,分别找到它们的根结点,比较根结点是否相同即可。
Union操作(函数求两个不想交子集合的并集)
void Union(int S[],int Root1,int Root2)
{
//要求 Root1 与 Root2 是不同的,且表示子集合的名字
S[Root2]=Root1; //将根 Root2 连接到另一根 Root1 下面
}
//如果将两个元素所在的集合合并为一个集合,那么就需要先找到两个元素的根结点。
时间复杂度O(n方)
5.2.1 Find 查找的优化
优化就是找到所要找的元素到根结点路径上的所有元素,图中所要找的元素就是L,L到根结点A路径上的所有元素有B E,分别将路径上所有元素全部指向根结点;
//Find “查”操作优化,先找到根结点,再进行"压缩路径"
int Find(int S[],int x)
{
int root=x;
while(S[root]>=0)
root=S[root]; //循环先找到要查元素的根结点
while(x!=root) //压缩路径
{
int t=S[x]; //t指向x的父结点
S[x]=root; //x直接挂到根结点下
x=t;
}
return root; //返回根结点编号
}
压缩路径优化之后
Find时间复杂度O(a(n))
Union时间复杂度O(n a(n))