数据结构-王道-树和二叉树
[top]
树和二叉树
树:是\(N(N\geq0)\)个结点的有限集合,\(N=0\)时,称为空树,这是一种特殊情况。在任意一棵非空树中应满足:
- 有且仅有一个特定的称为根的结点。
- 当\(N>1\)时,其余结点可分为\(m(m>0)\)个互不相交的有限集合\(T_1,T_2,\ldots,T_m\),其中每一个集合本身又是一棵树,并且称为根结点的子树。
显然树的定义是递归的,是一种递归的数据结构。树作为一种逻辑结构,同时也是一种分层结构,具有以下两个特点:
- 树的根结点没有前驱结点,除根结点之外的所有结点有且仅有一个前驱结点。
- 树中所有结点可以有零个或者多个后继结点。
树适合于表示具有层次结构的数据。树中的某个结点(除了根结点之外)最多之和上一层的一个结点(其父结点)有直接关系,根结点没有直接上层结点,因此在n个结点的树中最多只有n-1条边。而树中每个结点与其下一层的零个或者多个结点(即其子女结点)有直接关系。
>树具有如下最基本的性质。
1. 树中结点数等于所有节点的度数+1.
2. 度为m的树中第i层上之多有$m^{i-1}$个结点$(i\geq1)$。
3. 高度为h的m叉树至多有$\frac{m^h-1}{m-1}$个结点。
4. 具有n个结点的m叉树的最小高度为$\log_m(n(m-1)+1)$。
二叉树的概念
二叉树和度为2的有序树的区别:
- 度为2的树至少有3个结点,而二叉树则可以为空;
- 度为2的有序树的孩子结点的左右次序是相对于另一个孩子结点而言的,如果某个结点只有一个孩子结点,这个孩子结点就无需区别其左右次序,但是二叉树无论孩子数是否为2,均需要确定其左右次序,也就是说二叉树结点次数不是相对于另一个结点而言,而是确定的。
单解释一下完全二叉树:设一个高度为h,有n个结点的二叉树,当且仅当其每一个结点都与高度为h的满二叉树中编号一一对应是称为完全二叉树。
二叉树的遍历
先序遍历
void PreOrder(BitTree T)
{
if(T!=NULL)
{
printf("%d\n",T->data);
PreOrder(T->lchild);
PreOrder(T->rchild);
}
}
中序遍历
void InOrder(BitTree T)
{
if(T!=NULL)
{
InOrder(T->lchild);
printf("%d\n",T->data);
InOrder(T->rchild)
}
}
后序遍历
void PostOrder(BitTree T)
{
if(T!=NULL)
{
PostOrder(T->lchild);
PostOrder(T->rchild);
printf("%d\n",T->data);
}
}
三种遍历算法中递归遍历左子树和右子树的顺序都是固定的,只是访问根节点的顺序不同。不管采用何种遍历方法,每个结点都是访问一次,所以时间复杂度就是\(O(n)\)。
在递归遍历中,递归工作栈的深度恰巧是树的深度,所以在最坏的情况下,二叉树是有n个结点且深度为n的单支树,递归遍历算法的时间复杂度是\(O(n)\)。
@[中序遍历的非递归算法如下]
typedef struct BiTNode
{
int data;
struct BiTNode *lchild,*rchild;
}*BitTree;
typedef struct
{
char data[MaxSize];
int top;
}SqStack;
void InitStack(SqStack &S)
{
S.top=-1;
}
void InOrder2(BitTree T)
{
InitStack(S);
BitTree p=T;
while(p||IsEmpty(s))
{
if(p)
{
Push(S,p);
p=p->lchild;
}
else
{
Pop(s,p);
printf("%d\n",p->data);
p=p->rchild;
}
}
}
线索二叉树
遍历二叉树就是以一定的规则将二叉树中的结点排列为一个线性序列,从而得到二叉树中结点的各种遍历序列。其实质就是对一个非线性结构进行线性化操作,使在这个访问序列中的每一个结点(除了最后一个和第一个)都有一个直接前驱结点或者后继结点。
传统的链式储存能够体现出一种父子关系,不能直接得到结点在遍历中的前驱或者后继。通过观察,我们发现在二叉链表表示的二叉树中存在大量的空指针,若是利用这些空链域存放指向其直接的前驱或者后继的指针,则可以更加方便的运用某些二叉树的操作算法。引入线索二叉树是为了加快查找节点的前驱和后继的速度。
前面提到,在N个节点的二叉树中,有N+1个空指针。这是因为每个叶节点都有两个空指针,而每一个度为1的节点有一个空指针。总的空指针数目为\(2N_0+N_1\),又有\(N_0=N_2+1\)。意思是二倍的叶子节点加上1被的一个孩子的节点的数目。
线索二叉树的构造。
线索二叉树的存储结构描述如下:
typedef struct ThreadNode
{
int data;
struct ThreadNode *lchild,*rchild;
int ltag,rtag;
}ThreadNode,*ThreadTree;
\(ltag=0\)表示lchild指向的是结点的左孩子
\(ltag=1\)表示lchild指向的是结点的前驱
\(rtag=0\)表示rchild指向的是结点的右孩子
\(rtag=1\)表示rchild指向的是结点的后继
这种结点结构构成的二叉链表作为二叉树的存储结构,叫做线索链表,其中指向结点前驱和后继的指针,称为线索。加上线索的二叉树称为线索二叉树。对二叉树进行以某种次序遍历使其变为线索二叉树的过程叫做线索化。
@[线索化二叉树的构造]
对二叉树的线索化,实质上就是遍历一次二叉树,只是在遍历的过程中检查当前节点的左右指针是否为空,若为空,将他们改为指向前驱节点或者后继节点的线索。
@[P109]
度为2的有序树不是是二叉树:二叉树中如果某个节点只有一个孩子节点,那么这个孩子节点的左右次数是确定的,但是在有序树中如果某个节点只有一个孩子节点则这个节点无需区分其左右次序,所以度为2的树不是二叉树。
完全二叉树的节点数目和高度的关系是\([\log_2N]+1\)。
完全二叉树的节点排列是从左到右从上到下,所以如果一个节点没有左孩子,则它必定是叶节点。
二叉排序树 后面补上。
设层次遍历的结果为A,B,C。
则先序遍历的结果为A,B,C。
则中序遍历的结果为B,A,C。
则后续遍历的结果为B,C,A。
二叉树的中序遍历的最后一个节点一定是从根开始沿右子女指针链走到最低的结点。
树的存储结构
> 双亲表示法
这种存储方式采用一组连续空间来存储每个节点,同时在每个节点中增设一个尾指针指示双亲结点在数组中的位置。根节点下标为0,其伪指针域为-1.
并查集
并查集是一种简单的集合表示,它支持一下三种操作:
- $Union(S,root1,root2); $把集合S中的子集合Root2,并入Root1中。要求Root1和Root2互不相交,否则不执行合并。
- \(Find(S,x);\)查找集合S中单元素x所在的子集合,并返回该子集合的名字。
- \(Initial(S);\)将集合S中每一个元素都初始化为只有一个单元素的子集合。
通常用树(森林)的双琴表示作为并查集的存储结构,每个子集合以一棵树表示。所有表示子集合的树,构成表示全集合的森林,存放在双亲表示数组中。
并查集的结构定义如下:
#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=S[x];
return x;
}
void Union(int S[],int Root1,int Root1)
{
S[Root1]=Root2;
}
森林
由于二叉树和树都可以用二叉链表作为存储结构,则以二叉链表作为媒介可以导出树与二叉树的一个对应关系,即给定一棵树,可以找出唯一的一棵二叉树与之对应。从物理结构上看,树的孩子兄弟表示法和二叉树的二叉链表表示法相同,即每个节点共有两个指针,分别指向结点的第一个孩子节点和结点的下一个兄弟节点,而二叉链表可以使用双指针。因此,就可以用同意存储结构的不同解释将一棵树转换为二叉树。
树转换为二叉树的规则:每个节点的左指针指向她的第一个孩子节点,右指针指向它在书中的相邻兄弟结点,可表示为左孩子有兄弟。由于根节点没有兄弟,所以由树转换而得的二叉树没有右子树。
二叉排序树的查找
BSTNode *BST_Search(BitTree T,int key,BSTNode *&p)
{ // 返回指向关键字为key的结点指针,若不存在则返回NULL
p=NULL; // p指向被查找结点的双亲,用于插入和删除操作中。
while(T!=NULL&&key!=T->data)
{
p=T;
if(key<T->data)
T=T->lchild;
else
T=T->rchild;
}
return T;
}
二叉排序树的插入
int BST_Insert(BitTree &T,int k)
{
if(T==NULL) // 原树为空,新插入的记录为根节点。
{
T=(BitTree)malloc(sizeof(BSTNode));
T->data=k;
T->lchild=T->rchild=NULL;
return 1;
}
else if(k==T->data) // 存在相同的结点。
return 0;
else if(k<T->data) // 插入到T的左子树中
return BST_Insert(T->lchild,k);
else
return BST_Insert(T->rchild,k);
}
由此可见,插入的新节点一定是某个叶节点。在一个二叉排序树先后依次插入结点28和58,虚线表示的边是其查找的路径。
二叉排序树的构造
void Create_BST(BitTree &T,int str[],int n)
{
T=NULL;
int i=0;
while(i<n)
{
BST_Insert(T,str[i]);
i++;
}
}
二叉排序树的删除
在二叉排序树中删除一个结点时,不能把以该结点为根的子树上的结点都删除,必须先把被删除结点从存储二叉排序树的链表上摘下来,将因删除结点而断开的二叉链表重新链接起来,同时确保二叉排序树的性质不会丢失。
删除操作的实现过程按照以下三种情况进行处理:
- 如果被删除结点z是叶节点,则直接删除,不会破坏二叉排序树的性质。
- 如果结点z只有一颗左子树后者右子树,则让z的子树称为z父结点的子树,替代z的位置。
- 若结点z有左右两棵子树,则令z的直接后继(或者直接前驱)替代z,然后从二叉排序树中删去这个直接后继(或者直接前驱),这样就变成了第一种或者第二种情况。
平衡二叉树的插入:二叉排序树保证平衡的基本思想:每当在二叉排序树中插入(或删除)一个节点时,首先要检查其在插入路径上的节点是否因此次操作导致了不平衡。如果导致了不平衡,则先找到插入路径上距离插入节点最近的平衡因子绝对值大于1的节点A,再对以A为根的子树,在保持二叉排序树特性的前提下调整各节点的位置关系,使之重新达到平衡。
注意:每次调整的对象都是最小不平衡子树,即在插入路径上距离插入节点最近的平衡因子的绝对值大于1的结点作为根的子树。
从上述步骤中可以看出哈夫曼树具有如下的特点:
1. 每个初始结点最终都会称为叶节点,并且权值越小的结点路径长度越大。
2. 构造过程中共新建了$N-1$个结点,因此哈夫曼树中结点的总数为$2N-1$。
3. 每次构造都选择两棵树作为新节点的孩子,因此哈夫曼树中不存在度为1的结点。
哈夫曼树编码
对待处理一个字符串序列,如果每个字符用同样长度的二进制位来表示,则这种方式称为固定长度编码。若允许对不同字符用不等长的二进制位表示,则这种编码方式称为可变长度编码。可变长度编码比固定长度编码好得多,其特点是对频率高的字符赋予段编码,而对频率较低的字符则赋予一些较长的编码,从而可以是字符的平均编码长度被缩短,起到压缩数据的效果。哈夫曼编码是一种被广泛应用而且非常有效的数据压缩编码方法。
如果没有一个编码是另一个编码的前缀,则称这样的编码为前缀编码。如0,101,100是前缀编码。对前缀编码的解码也是很简单的。因为没有一个码是其他码的前缀。所以可以识别出第一个编码,将他翻译为源码,在对雨下的编码文件重复同样的操作。如\('00101100'\)可被唯一的分析为0,0,101,100。
由哈夫曼树得到哈夫曼编码是很自然的过程,首先将每个出现的字符当作一个独立的结点,其权值为它出现的频度(或者是次数),构造出对应的哈夫曼树。显然所有字符结点都出现在叶节点中。我们可以将字符的编码解释为从根至该字符的路径上边标记的序列,其中边标记为0表示“转向左孩子”,标记为1表示“转向右孩子”。