《算法与数据结构》第六章[第3节]:二叉树(第二部分)
一、线索二叉树
1、线索二叉树定义
我们现在来回头看看之前实现的二叉链表的存储情况,如下图
可以看出,还是有许多指针域是“^”,特别是叶子结点,它们的两个指针域都是空的,首先我们来看一看这种空指针域有多少。
对于一棵有nnn个结点的二叉链表,每个结点都有lchild
和rchild
两个指针域,所以这个二叉链表中有2n2n2n个指针域,而nnn个结点的二叉树一共有n−1n-1n−1条分支线数,也就是说,存在的空指针域有2n−(n−1)=n+12n-(n-1)=n+12n−(n−1)=n+1个空指针域。比如图中有10个结点,而空指针域有11个,这些空间就是被白白浪费了。
另一方面,我们来看图1中这棵树的中序遍历序列GDHBEIAFJCGDHBEIAFJCGDHBEIAFJC,虽然我们之前说树是一种一对多结构,没有明确的前驱后继关系,但是在遍历序列中,我们清楚知道“AAA”的前驱是“III”,后继是“FFF”,它们之间就有了明确的前驱后继关系。
可是这样明确的的前驱后继关系是建立在已经遍历过后的基础上的,若未遍历过,我们就不知道它们之间这样的关系,若我们想再次获得,便需要再次遍历才能知道,那我们可不可以在建立二叉树的同时,根据类似某一规律(前中后序)建立起前驱后继关系呢?
这样一来,我们同时可以把那些空指针域给利用起来,还能减少获取前驱后继的时间,一石二鸟,何乐不为。
我们把这种指向前驱后继的指针称为线索,加上线索的二叉链表称为线索链表,相应的二叉树就称作线索二叉树。
我们将如图1的二叉树进行中序遍历后,就可以将所有空指针域中的rchild
改为指向其后继结点,如下图所示
这样我们就可以直接得到结点“GGG”的后继为“DDD”(①),结点“HHH”的后继为“BBB”(③)…而不用老老实实进行中序遍历后再先进入其不存在的孩子结点中再一层层返回了。而最后一个被遍历的结点“CCC”则指向空(NULL
)作为结束。此时我们将5个指针域利用起来了。
同样的道理,我们将没有利用起来的左孩子域lchild
,改为当前结点的前驱,如下图。这样就有结点“GGG”的前驱为NULL
(①),结点“HHH”的前驱为“DDD”(②),结点“EEE”的前驱为“BBB”(③)…这样又有6个空指针域被利用起来,与后继加起来正好是11个。
此时我们保留图中原来的中序“双亲-孩子”访问联系,添加我们后序添加的前驱后继关系,将结点简化为圆点,再来看一看我们这棵树。
细看可以看出,二叉树的线索化,实际上就是将它转化为双向链表,这样一来,我们就可以更加方便的对其进行增删改查操作了。
我们对二叉树以某种次序遍历使其变为线索二叉树的过程称作线索化。
但是细心的同学会在上述过程中发现一个缺点,我们无法得知每个结点的lchild
中存储的是这个结点的左孩子还是前驱,rchild
也是同理,同时还存在部分结点lchild
存储左孩子,rchild
存储其后继结点,所以为此做出区分也是十分有必要的。因此,我们为每个结点再设置两个布尔型标志域ltag
和rtag
来区分它们到底存储的是孩子还是前驱后继。这样一来,结点结构就变成了下图所示的样子。
其中:
ltag
为0(false
)时指向该结点左孩子,为1(true
)时指向其前驱;rtag
为0(false
)时指向该结点右孩子,为1(true
)时指向其前驱;
因此图1中的二叉树存储情况就变成了如下所示(为避免前驱后继连线影响区分,省去了前驱后继连线)
2、线索二叉树结构实现
根据上述内容,我们可以给出二叉树的线索存储结构定义代码如下:
typedef char ElemType;
typedef enum {Link, Thread} PointerTag;
// 使用枚举类型区分tag,Link表示指向孩子,Thread表示指向前驱后继typedef struct BiThrNode {ElemType data; //结点数据域struct BiThrNode *lchild, *rchild; //指针域PointerTag ltag; //左标签域PointerTag rtag; //右标签域
}BiThrNode, *BiThrTree;
我们已经知道线索化就是将空指针域改为前驱后继的过程,并且我们只能在遍历后得知每个结点之间的前驱后继关系,所以我们就在遍历中修改空指针实现线索化。
我们在此给出中序遍历线索化的过程代码:
BiThrTree pre; //全局变量,用于指出刚才访问过的结点void InThreading(BiThrTree T)
{if(T){InThreading(T->lchild); //对当前结点左子树进行线索化if(!T->lchild) //没有左孩子{T->ltag=Thread; //置左标签域为前驱T->ltag=pre; //让左孩子域指向上一个访问结点}if(!pre->rchild) //前驱没有右孩子{pre->rtag=Thread; //置前驱的右标签域为后继pre->rchild=T; //让前驱的右孩子域指向当前结点}pre=T; //将当前结点作为上一访问结点进行后序访问InThreading(T->rchild); //对当前结点右子树进行线索化}
}
可以看到,大体上的中序规则是没有变的,只不过将中间的输出改为了线索化操作,我们来看看都干了些什么。
首先是if(!T->lchild)
表示当前结点的左孩子域为空,既然它为空我们就要让其指向其前驱,正好,全局变量pre
存储了我们刚刚访问过的结点,我们只需将pre
赋值给lchild
后设置标签值即可完成其前驱结点的线索化。
后继的判断变成了pre->rchild
而不是T->rchild
,这是为什么呢?因为我们还没有访问到当前结点的后继,哪怕当前结点的右孩子域是空的,我们目前也没有办法进行线索化,但是我们当前访问的结点是上一个访问结点(pre
)的后继啊,那就意味着我们现在可以找到我们上一个访问结点的后继,若上一访问结点的右孩子域是空的,我们就可以直接将当前结点赋值给它的右孩子域并设置标签值了。也就是说,这里完成的后继线索化实际上是上一个访问的结点的。怎么样,是不是十分巧妙。
当然,最后别忘了将当前结点置为上一访问结点再进行后序操作。
二、树、森林与二叉树的转换
我们在前面已经探讨过树的定义和存储结构了,它的优势就是在满足树的条件下可以是任何形状,但是劣势也在这里,任意形状导致我们对树进行处理的各类操作十分复杂。我们在后面又学习了二叉树,相比与一般的树来说,二叉树因为结构固定,便衍生出了许多性质,而这些性质也极大地方便了我们对其所进行的许多操作。
那既然二叉树这么好,我们就想办法把一般的树转换为二叉树呗?可行吗?当然可行,回忆一下我们在树的最后提到的孩子兄弟表示法,就是将树转换为二叉树来存储的一种途径。
1、树转换为二叉树
将树转换为二叉树的步骤如下:
- 加线:在所有兄弟结点之间添加连线;
- 去线:对树中每个结点,只保留它与它的第一个孩子间的连线,删除它与其它孩子之间的连线;
- 层次调整:以树的根结点为轴,将整棵树旋转一定角度,使之结构层次分明。
以下是示意图:
可能有大部分同学喜欢这个方法,本人不是很习惯用这样的方法,所以下面再给出本人的方法。
其实我的方法就是树的孩子兄弟表示法,只是将其简化后成了:从根结点开始,每个结点的最左孩子为其左孩子,这个左孩子的兄弟全部都在它(这个左孩子)的右子树分支上。
2、森林转换为二叉树
森林是由若干棵树组成的,所以可以理解为,森林中每棵树都是兄弟,这样一来,我们就可以以兄弟关系来处理每棵树了。
步骤如下:
- 将每棵树都转化为二叉树;
- 第一棵二叉树不变,从第二棵二叉树开始,依次把后一棵二叉树的根结点作为前一棵二叉树的根结点的右孩子,并连线。将所有二叉树连接起来后就得到了由森林转化来的二叉树。
以下为示意图:
3、二叉树转换为树
我们可以看出,当将一棵树转换为二叉树时,二叉树根结点是不会有右子树的,但是将一个森林转换为二叉树时,这时的二叉树根结点就有右子树了。这也正是我们逆向转换的一个判定标准。即若一棵二叉树没有右子树,则它可以转换为一棵树;若一棵二叉树有右子树,则它可以转换为森林。
我们已经学习了树转换为二叉树,那将二叉树转换回树便是将之前的操作还原一遍即可,操作步骤如下:
- 加线:若二叉树中的某一结点存在左孩子,则将其左孩子的右孩子,左孩子的右孩子的右孩子…均添加与其的连线;
- 去线:删去每一结点原来与其右孩子的连线;
- 层次调整:以树的根结点为轴,将整棵树旋转一定角度,使之结构层次分明。
以下为示意图:
4、二叉树转换为森林
同样,我们也已经学习了森林转化为二叉树的过程,且也已经知道若一棵二叉树其根结点存在右子树,则可将其转化为一个森林,步骤如下:
- 从根结点开始,若其右孩子存在,则去掉根结点与其右孩子的连线,分离出一棵二叉树;再看分离出来的二叉树,若其根结点右子树存在,则去掉其根结点与其右孩子的连线,重复上述操作,直到最后分离出的二叉树根结点没有右孩子为止;
- 再将每棵分离后的二叉树按照“二叉树转换为树”的方法转化为树即可。
示意如下:
5、树与森林的遍历
最后我们简单讲一下树与森林的遍历。
树的遍历方式有两种。
- 先根遍历:先访问树的根结点,然后依次遍历根的每棵子树,类似于二叉树的先序遍历,只是树中的子树数量没有特别限制;
- 后根遍历:先依次以后根次序遍历树的每棵子树,然后再访问根结点,同样类似于二叉树的后序遍历。
例如图9中的树④
该树的先根遍历序列是“ABEFCDGABEFCDGABEFCDG”,后根遍历序列是“EFBCGDAEFBCGDAEFBCGDA”。
具体的算法不在此提供,大家可以基于我们上述实现的几种树的存储结构来自行实现。
森林的遍历方式也有两种。
- 先序遍历:先访问森林中第一棵树的根结点,然后依次先序遍历根的每棵子树,然后再依次以同样的方式遍历除去第一棵树的剩余树所构成的森林。
- 中序遍历:先访问森林中的第一棵树,以后根遍历的方式遍历该树,然后访问该树根结点,再依次以同样的方式遍历除去第一棵树的剩余树所构成的森林。
关于森林的第二种遍历方式存在争议,严教授书中所写的是“中序遍历”,而程老师所写《大话数据结构》中将其称为“后序遍历”,网络上也是众说纷纭,其实两种名字都各有其原因:称其“中序遍历”是因为森林中每棵树的根对于整个森林的遍历序列来说不算是完全在最后,而且该方式遍历森林所得结果与将该森林转换为二叉树后的中序遍历结果是一致的,故称其中序遍历也是自然而然;而称其“后序遍历”多半是因为森林中每棵树都是以后根遍历的顺序来进行的,类似二叉树后序遍历,这样看来,称其后序遍历好像也无可厚非。但是在学习的过程中,认识其过程和思想远比知道其名字重要,大家完全没有必要为一个名字纠结。
例如图10-③所示的森林,其先序遍历序列“ABCDEFGHJIABCDEFGHJIABCDEFGHJI”,中序遍历序列“BCDAFEJHIGBCDAFEJHIGBCDAFEJHIG”。这时我们就可以看到,若对图10-①中的二叉树进行先序遍历和中序遍历也会分别得到这两个序列。由此可见,不论是树还是森林,它们总是可以通过某种方式与二叉树/二叉链表产生联系,我们便可以通过这种联系结合二叉树的性质来简化问题了。