目录
前言
二叉树的存储结构
遍历二叉树
二叉树的建立
线索二叉树
树、森林与二叉树的转换
总结
这是《大话数据结构》第六章树的最后一部分,也是最重要的一部分,当然内容也会更多。
废话不多说,进入正题。
顺序存储结构:
由于二叉树的特殊性,可以使用一维数组存储二叉树的结点。
首先看看完全二叉树如何利用顺序存储结构存储。
(关于完全二叉树的内容,可以点击右边直达【大话数据结构】第六章总结——树(中))
将上图存储在一维数组中,如下图所示:
数组的下标代表结点的位置,比如E结点的数组下标是5,即代表它的位置是5。
如果将如下的非完全二叉树存在数组中,该如何表示呢?
上图是一棵非完全二叉树,其中该二叉树只存在ABCEGJ(即蓝色部分)结点,
为了方便在数组中存储,需要将其补成完全二叉树,用^表示不存在的结点,如下图所示:
再考虑一种极端情况,如果是一棵深度为k的右斜树,只有k个结点,但是要分配2 ^ k - 1个存储单元空间,明显造成空间的浪费,如下图所示:
因此我们也可以得知顺序存储结构一般只适用于完全二叉树。
链式存储结构:
从上面可以得知,顺序存储结构的适用性不强,因此我们考虑用链式存储结构。
由于二叉树的每个结点最多有两个孩子,所以为它设计一个数据域和两个指针域是比较自然的想法,这样的链表也成为二叉链表。
结点的结构图如下所示:
其中data是数据域,lchild是存放左孩子的指针域,rchild是存放右孩子的指针域
代码表示结点结构如下:
//二叉树的二叉链表结点结构定义
typedef struct BiTNode {
TElemType data;//结点数据
struct BiTNode *lchild, *rchild;//左右孩子指针
}BiTNode, *BiTree;
结构示意图如下所示:
二叉树的遍历(traversing binary tree)是指从根结点出发,按照某种次序依次访问二叉树中所有的结点,使得每个结点被访问一次且仅被访问一次。
二叉树的主要遍历方式有四种,分别为:前序遍历、中序遍历、后序遍历、层序遍历。
前序遍历(根左右):
若二叉树为空,则不操作,直接返回;
否则,先访问根结点,然后遍历左子树再遍历右子树,即按照根左右的顺序,下图的前序遍历结果为ABDGHCEIF
中序遍历(左根右):
若二叉树为空,则不操作,直接返回;
否则,从根结点开始(注意不是先访问根结点),中序遍历根结点的左子树,再访问根结点,最后中序遍历右子树
即按照左根右的顺序,下图的中序遍历结果为GDHBAEICF
后序遍历(左右根):
若二叉树为空,则不操作,直接返回;
否则,从左到右先叶子后结点的方式遍历访问左右子树,最后是访问根结点,
即按照左右根的顺序,下图的后序遍历结果为GHDBIEFCA
层序遍历:
若二叉树为空,则不操作,直接返回;
否则,从树的第一层,也就是根结点开始访问,从上而下逐层遍历,在同一层中,按从左到右的顺序对结点逐个访问
下图的层序遍历结果为ABCDEFGHI
前序遍历算法
由于二叉树可以用递归的方式来定义,所以也可以使用递归的方式来遍历一棵二叉树。
前序遍历算法的代码如下:
//二叉树的前序遍历递归算法
void PreOrderTraverse(BiTree T)
{
if(T==NULL)
return;
printf("%c",T->data);/* 显示结点数据,可以更改为其它对结点操作 */
PreOrderTraverse(T->lchild); /* 再先序遍历左子树 */
PreOrderTraverse(T->rchild); /* 最后先序遍历右子树 */
}
中序遍历算法
其实前序遍历算法和中序遍历算法很像,只是部分代码的顺序不同而已!
中序遍历算法的代码如下:
//二叉树的中序遍历递归算法
void InOrderTraverse(BiTree T)
{
if(T==NULL)
return;
InOrderTraverse(T->lchild); /* 中序遍历左子树 */
printf("%c",T->data);/* 显示结点数据,可以更改为其它对结点操作 */
InOrderTraverse(T->rchild); /* 最后中序遍历右子树 */
}
中序遍历算法只是把调用左孩子的递归函数提前了,相信大家可以想到后序遍历算法的实现了
后序遍历算法
没错,就是把打印语句放在最后一句了。
//二叉树的中序遍历递归算法
void PostOrderTraverse(BiTree T)
{
if(T==NULL)
return;
PostOrderTraverse(T->lchild); /* 先后序遍历左子树 */
PostOrderTraverse(T->rchild); /* 再后序遍历右子树 */
printf("%c",T->data);/* 显示结点数据,可以更改为其它对结点操作 */
}
其实说了半天还是没有生成一棵二叉树,树都没有,怎么遍历呢?所以接下来谈谈关于二叉树建立的问题
如果我们要建立下面一棵二叉树,为了方便,我们对它进行了扩展。
变成右图的样子,也就是将二叉树中每个结点的空指针引出一个虚结点,其值为一特定值,比如“#”。
我们把这种处理后的二叉树为原二叉树的扩展二叉树。此时右图的前序遍历结果为AB#D##C##
这时,我们来看看如何生成一棵二叉树。假设二叉树的结点均为一个字符,我们把刚才前序遍历序列AB#D##C##用键盘挨个输入。
算法如下:
/* 按前序输入二叉树中结点的值(一个字符) */
/* #表示空树,构造二叉链表表示二叉树T。 */
void CreateBiTree(BiTree *T)
{
TElemType ch;
/* scanf("%c",&ch); */
ch=str[index++];
if(ch=='#')
*T=NULL;
else
{
*T=(BiTree)malloc(sizeof(BiTNode));
if(!*T)
exit(OVERFLOW);
(*T)->data=ch; /* 生成根结点 */
CreateBiTree(&(*T)->lchild); /* 构造左子树 */
CreateBiTree(&(*T)->rchild); /* 构造右子树 */
}
}
在建立二叉树的时候,也是用了递归的原理,只不过在原来应该打印节点的地方改成了生成结点、给结点赋值的操作而已。
(当然,也可以用中序或者后序遍历的方式实现二叉树的建立,只不过代码里生成结点和构造左右子树的代码顺序交换一下)
为什么要建立线索二叉树?线索二叉树又是什么?
我们先看下图,会发现指针域并不是都充分的利用了,存在许多的“^”,也就是空指针域的存在。
对于一个有n个结点的二叉链表,每个结点有指向左右孩子的两个指针域,所以一共有2n个指针域。
而n个结点的二叉树一共有n - 1条分支线数,也就是说,存在2n - (n - 1) = n + 1个空指针域。
比如图中有10个结点,空指针域达到了11个,白白浪费了内存的资源,这也是我们为什么要利用这些空指针域的原因。
另一方面,如果我们想知道某个结点的前驱和后继,我们可能需要进行一次遍历才得知。以后每次需要知道时,我们又得进行一次遍历,如果树很大的话,无疑会浪费大量重复的时间。为什么不考虑在创建时就记住这些前驱后继呢?
因此,我们可以把上图中存在的大量空指针域拿来存放结点的前驱和后继节点的地址。
我们把这种指向前驱和后继的指针称为线索,加上线索的二叉链表称为线索链表,相应的二叉树就称为线索二叉树(Threaded Binary Tree)
如上图所示,将所有空指针域的rchild改为指向它的后继结点。于是我们就可以知道H的后继结点是D,I的后继结点是B,J的后继结点是E,E的后继结点是A,F的后继结点是C,G由于不存在而指向NULL,此时有6个空指针域被利用。
至于为什么结点H的后继结点是D,I的后继结点是B。。。?其实是因为我们对其进行了一次中序遍历,即HDIBJEAFCG。
再看上图,此时把所有空指针域中的lchild改为指向当前结点的前驱。因此H的前驱是NULL,I的前驱是D,J的前驱是B,F的前驱是A,G的前驱是C。一共有5个空指针域被利用,加起来刚好利用了11个空指针域。
通过上图我们可以发现(空心箭头实线为前驱,虚线黑箭头为后继),其实线索二叉树,等于是把一棵二叉树转变成了一个双向链表,此时插入删除结点、查找结点都给我们带来了方便。所以我们对二叉树以某种次序遍历使其变为线索二叉树的过程称作是线索化。
不过问题还没有彻底解决。我们怎么知道某个结点的lchild是指向它的左孩子还是指向前驱?rchild是指向它的右孩子还是指向后继?
显然,我们在决定lchild是指向左孩子还是前驱,rchild是指向右孩子还是后继上是需要一个区分标志的。
因此我们在每个结点再增设两个标志域ltag和rtag,只存放0或1数字的布尔类型,结点结构如下:
其中:
ltag为0时,指向该结点的左孩子,为1时指向该结点的前驱
rtag为0时,指向该结点的右孩子,为1时指向该结点的后继。
因此,刚刚的二叉树链表图可以修改为如下所示:
线索二叉树结构实现:
定义结构代码如下:
typedef enum {Link,Thread} PointerTag; /* Link==0表示指向左右孩子指针, */
/* Thread==1表示指向前驱或后继的线索 */
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); /* 递归右子树线索化 */
}
}
遍历的代码如下:
/* 中序遍历二叉线索树T(头结点)的非递归算法 */
Status InOrderTraverse_Thr(BiThrTree T)
{
BiThrTree p;
p=T->lchild; /* p指向根结点 */
while(p!=T)
{ /* 空树或遍历结束时,p==T */
while(p->LTag==Link)
p=p->lchild;
if(!visit(p->data)) /* 访问其左子树为空的结点 */
return ERROR;
while(p->RTag==Thread&&p->rchild!=T)
{
p=p->rchild;
visit(p->data); /* 访问后继结点 */
}
p=p->rchild;
}
return OK;
}
通过以上,我们可以总结出,如果所用的二叉树需要经常遍历或者查找结点时需要某种遍历序列中的前驱和后继,那么采用线索二叉链表的存储结构是一种非常不错的选择。
树转换为二叉树
1、加线。在所有兄弟结点之间加一条连线
2、去线。对树中每个结点,只保留它与第一个孩子结点的连线,删除它与其他孩子结点之间的连线。
3、层次调整。以树的根结点为轴心,将整棵树顺时针旋转一定的角度,使之结构层次分明。
(注意第一个孩子是二叉树结点的左孩子,兄弟转换过来的孩子是结点的右孩子)
如下图,就是将一棵树转换为二叉树的步骤。
森林转换为二叉树
森林是由若干棵树组成的,因此我们可以理解为,森林中的每一棵树都是兄弟,可以按照兄弟的处理方法来操作。
1、把每个树转成二叉树
2、第一棵二叉树不动,从第二棵二叉树开始,依次把后一棵二叉树的根结点作为前一棵二叉树的根结点的右孩子,用线连接起来。当所有的二叉树连接起来后,就得到了由森林转换过来的二叉树。
如下图所示,将森林的三棵树转换成一棵二叉树
二叉树转换成树
二叉树转换为树其实就是树转换成二叉树的逆过程。
1、加线。若某结点的左孩子结点存在,则将这个左孩子的右孩子结点、右孩子的右孩子结点、右孩子的右孩子的右孩子的结点。。。。。
即左孩子的n个右孩子结点都作为此结点的孩子。将该结点与这些右孩子结点用线连接起来。
2、去线。删除原二叉树中所有结点与其右孩子结点的连线。
3、层次调整。使之结构层次分明。
二叉树转换成森林
判断二叉树是否能够转换成一棵树或者森林,关键点就是判断这棵二叉树的根结点有没有右孩子,有就是森林,没有就是一棵树。
1、从根结点开始,若右孩子存在,则把与右孩子结点的连线删除,再查看分离后的二叉树,若右孩子存在,则连线删除。。。。,直到所有右孩子连线都删除为止,得到分离的二叉树
2、再将每棵分离后的二叉树转换成为树即可。
遍历二叉树其实可以说是一门学问,前中后以及层序遍历都是需要熟练的,其实我建议可以自己模拟计算机,自己动手画一下递归方法的实现,可以加深我们对递归的理解。