数据结构编程笔记十六:第六章 树和二叉树 线索二叉树的实现

时间:2021-02-06 17:29:41

上次的文章介绍了树和二叉树的转换,这次的文章介绍线索二叉树的实现。

还是老规矩:

程序在码云上可以下载。
地址:https://git.oschina.net/601345138/DataStructureCLanguage.git

对于二叉树遍历的递归和非递归算法,在《数据结构编程笔记十四:第六章 树和二叉树 二叉树基本操作及四种遍历算法的实现》一文中已经有所介绍,通过阅读文章,基本可以得出以下结论:
1.递归算法虽然结构简单,容易阅读,但是却因为其伴随着多次的函数调用过程致使其存在效率低下的弊端(无论是时间上还是空间上皆是如此)。非递归算法较好的改进了递归算法存在的效率低的问题。
2.由于二叉树的遍历过程中存在试探和回溯的过程,无法通过递推手段转换为非递归算法,需要借助栈来记忆回溯点在可以实现递归算法非递归算法的转换。
3.二叉树的先序、中序、后序遍历的区别在于访问根结点的时机不同。先序遍历是按照“根左右”方式遍历。中序遍历是按照“左根右”顺序遍历。后序遍历则是按照“左右根”顺序遍历。
4.层序遍历需要借助队列实现。

非递归算法相对于递归算法执行效率已经是提升不少了,但是它必须带栈操作,离了栈它玩不转,每次遍历前先开辟一个栈,用完了栈还得销毁,栈的各项操作本身就需要时间,栈自己还占着内存空间。那么我们是不是可以考虑再大胆一点,把栈也给摘掉,对非递归算法瘦身减肥,行不行呢?

如果直接摘掉栈肯定是不行的——回溯点没法保存了。那我们必须要想办法保存这些回溯的信息,使得非递归遍历操作不借用栈也能顺利进行。容易想到的一种方式就是在遍历的时候把结点的前驱和后继信息记下来,以后遍历的时候直接读取这些信息,这样不就OK了吗?

记录前驱和后继有两种方案:
1.另外定义一个单独的数据结构,另外开辟内存空间记录前驱和后继信息。
2.利用现有的存储空间,直接就地保存前驱和后继信息。

显然,第二种想法更好,因为第一种想法实现起来就和栈差不多了——也需要时间去操作这个数据结构,也需要内存去存储这个新的数据结构。第二种做法不需要开辟把内存空间去存储新的数据结构的信息,也不需要花时间执行额外操作。但是这样美好的愿景能实现吗?

我们发现,二叉树的指针域有很多是空的。空指针域并没有存储有效的信息,在二叉链表中属于被浪费的存储空间。那么这些空间有多少呢?

一棵使用二叉链表存储的二叉树中,若二叉树有n个结点,则必定存在n+1个空指针域。所以被浪费的指针域个数就是n+1了。

把这n+1个空指针域拿出来存储前驱和后继信息是一个不错的想法,废物利用嘛。

但是我们很快发现:没办法区分指针域存的是指针还是线索。所以想要正确表示线索我们还得给二叉链表“加点料”——加标志位,标志位就是起个标记作用,说明这个指针域存的是指针还是线索,如果存的是指针,标志位就是0,要是存的是线索,标志位就是1。

所以书上的线索二叉树这样定义:

//------------------- 二叉树的二叉线索存储表示 --------------------
//采用枚举类型定义指针标志位
typedef enum{
Link, //Link(0): 指针 0
Thread //Thread(1):线索 1
}PointerTag;

typedef struct BiThrNode{
TElemType data; //数据域,存储节点数据
struct BiThrNode *lchild, *rchild; //左右孩子指针
PointerTag LTag, RTag; //左右标志
}BiThrNode, *BiThrTree;

这个过程就像是在二叉树的空指针域上“穿针引线”,将遍历过程中将二叉树的前驱和后继穿了起来,并且保存了这种前驱和后继的关系,使得以后的遍历更快速——终于甩掉“栈”这个包袱了。这样的二叉树我们称之为线索二叉树。

我们还可以考虑在二叉树的根结点前面加头结点,把头结点和线索二叉树的根连接在一起,就像一个双向链表一样,可以从前向后遍历,也可以从后向前遍历。

但是我认为书上的存储结构并不是最佳的,我们不妨算笔账:

标志位本身是要占用内存空间的,加标志位势必会使每个结点的大小增加,使得二叉链表的整体大小增加。那我们就得想办法,既要保证线索信息和指针信息的正确存储和表示,还得想办法减少这些附加的标志位占用的内存空间。我们必须要保证标志位占用的空间足够小,因为二叉链表中的指针变量和基本数据类型变量已经没有压榨空间了,只能压榨标志位。

以下这段程序会测试出指针变量的大小,还会给我们算笔账:

#include <stdio.h>
#include <iostream>
using namespace std;

typedef int TElemType;

//----------------原始二叉链表------------------------
struct BiNode{
TElemType data;
struct BiNode *lchild,*rchild; //孩子结点指针
};

//--------------------线索二叉树标志位---------------
typedef enum{
Link, //Link(0): 指针 0
Thread //Thread(1):线索 1
}PointerTag;

//----------------线索二叉树(不加左右标志)---------
struct BiThrNode1{
TElemType data; //数据域,存储节点数据
struct BiThrNode *lchild, *rchild; //左右孩子指针
};

//-----------------线索二叉树(加左右标志)---------
struct BiThrNode{
TElemType data; //数据域,存储节点数据
struct BiThrNode *lchild, *rchild; //左右孩子指针
PointerTag LTag, RTag; //左右标志
};

int main() {
int a = 1;
char b = 'a';
float c = 1.0;
void *p;

printf("int型变量所占用的大小(字节):%d\n", sizeof(int));
printf("float型变量所占用的大小(字节):%d\n", sizeof(float));
printf("char型变量所占用的大小(字节):%d\n", sizeof(char));
printf("C++支持(C语言不支持):boolean型变量所占用的大小(字节):%d\n", sizeof(bool));
printf("unsigned int型变量所占用的大小(字节):%d\n", sizeof(unsigned int));
printf("short型变量所占用的大小(字节):%d\n", sizeof(short));

p = &a;
printf("指向int型变量的指针变量所占用的大小(字节):%d\n", sizeof(p));
p = &b;
printf("指向char型变量的指针变量所占用的大小(字节):%d\n", sizeof(p));
p = &c;
printf("指向float型变量的指针变量所占用的大小(字节):%d\n", sizeof(p));

printf("枚举类型所占用的大小(字节):%d\n", sizeof(PointerTag));

printf("原来的二叉链表结点大小(字节):%d\n", sizeof(struct BiNode));
printf("线索二叉树结点(不加标志位)大小(字节):%d\n", sizeof(struct BiThrNode1));
printf("线索二叉树结点(加标志位)大小(字节):%d\n", sizeof(struct BiThrNode));
int tagsize = (sizeof(struct BiThrNode) - sizeof(struct BiThrNode1)) / 2;
printf("标志位在结构体中的大小(字节):%d\n", tagsize);

PointerTag pt1 = Link;
PointerTag pt2 = Link;

printf("&pt1= %d, &pt2= %d\n", &pt1, &pt2);


return 0;
} //main

我的操作系统是64位的,但用的是32位的gcc编译器,从运行结果看出指针变量大小为4字节。
数据结构编程笔记十六:第六章 树和二叉树 线索二叉树的实现

同时我们也看到一个有趣的现象——指针变量大小和枚举类型的变量所占大小一样,都是4个字节。

通过对结构体大小的计算,有左右标志位结构体大小为20字节,没有左右标志位的结构体大小为12字节,因此我们可以得出标志位占的内存大小与int型一样大——4个字节。左右标志位各占4字节。

而且通过打印两个接收枚举类型数值的变量pt1和pt2的地址时,发现即便枚举值相同的两个变量内存空间也不共享——地址不一样。所以可以得出结论——C语言把标志位当成了一个int型整数变量去处理了

布尔型(C不支持)只占一个字节,short类型(c支持)只占2个字节。这两种类型表示一个只有0和1两种取值的变量是没问题的,并且占空间都比枚举变量小。

书上用枚举变量来存储标志位有其好处——限制了标志位的取值范围,并且可以用能让人看懂的符号代替具体的值,增强了程序的可读性。但是也有明显的弊端——浪费空间多。存储0和1其实只需要1个二进制位就够了。一个字节=8个二进制位,表示0和1绰绰有余。书上却要浪费4个字节去存储0和1。如果不考虑C语言的兼容性,C++中的布尔类型就能很好的满足这样的需求,且只需要枚举变量1/4的内存空间。如果考虑C语言的兼容性,那就用short,占用内存空间大小是枚举类型的一半。

通过上面的一连串计算,发现书上的标志位内存空间还没有压榨到极致,还有压榨空间。我们为了后续操作方便,就不去改了,和书上保持一致,如果大家有兴趣,改了也无妨。

接下来一起看看线索二叉树中序线索化的实现:

//>>>>>>>>>>>>>>>>>>>>>>>>>引入头文件<<<<<<<<<<<<<<<<<<<<<<<<<<<<

#include <stdio.h> //使用了标准库函数
#include <stdlib.h> //使用了动态内存分配函数

//>>>>>>>>>>>>>>>>>>>>>>>自定义符号常量<<<<<<<<<<<<<<<<<<<<<<<<<<

#define OVERFLOW -2 //内存溢出错误常量
#define OK 1 //表示操作正确的常量
#define ERROR 0 //表示操作错误的常量
#define TRUE 1 //表示逻辑真的常量
#define FALSE 0 //表示逻辑假的常量

//>>>>>>>>>>>>>>>>>>>>>>>自定义数据类型<<<<<<<<<<<<<<<<<<<<<<<<<<

typedef char TElemType;
typedef int Status;

//定义NIL为空格
TElemType NIL = ' ';

//------------------- 二叉树的二叉线索存储表示 --------------------
//采用枚举类型定义指针标志位
typedef enum{
Link, //Link(0): 指针 0
Thread //Thread(1):线索 1
}PointerTag;

typedef struct BiThrNode{
TElemType data; //数据域,存储节点数据
struct BiThrNode *lchild, *rchild; //左右孩子指针
PointerTag LTag, RTag; //左右标志
}BiThrNode, *BiThrTree;

//---------------------------线索二叉树操作----------------------------

/*
函数:CreateBiThrTree
参数:BiThrTree &T 线索二叉树的引用
返回值:状态码,操作成功返回OK,否则返回ERROR
作用:按先序输入线索二叉树中结点的值,构造线索二叉树T。
空格表示空结点
*/

Status CreateBiThrTree(BiThrTree &T) {

//保存从键盘接收的字符
TElemType ch;
scanf("%c", &ch);

//若输入了空格,则此节点为空
if(ch == NIL) {
T = NULL;
}//if
else{ //若输入的不是空格则创建新结点并添加到线索二叉树的合适位置
//申请根结点存储空间
T = (BiThrTree)malloc(sizeof(BiThrNode));

//检查空间分配是否成功
if(!T) {
exit(OVERFLOW);
}//if

//给根结点赋植
T->data = ch;

//递归地构造左子树
CreateBiThrTree(T->lchild);

//若有左孩子则将左标志设为指针
if(T->lchild) {
T->LTag = Link;
}//if

//递归地构造右子树
CreateBiThrTree(T->rchild);

//若有右孩子则将右标志设为指针
if(T->rchild) {
T->RTag = Link;
}//if
}//else

//操作成功
return OK;
}//CreateBiThrTree

//---------------------------- 中序线索化 ---------------------------

//全局变量,始终指向刚刚访问过的结点
BiThrTree pre;

/*
函数:InThreading
参数:BiThrTree p 指向线索二叉树结点的指针p
返回值:无
作用:通过中序遍历递归地对头结点外的其他结点进行中序线索化,
线索化之后pre指向最后一个结点。
此函数会被InOrderThreading函数调用。
*/

void InThreading(BiThrTree p) {

//线索二叉树不空
if(p) {

//递归左子树线索化
InThreading(p->lchild);

//由于已经访问过前驱结点,此时就可以完成前驱结点的线索化了。
//当前结点的前驱就是pre指向的结点。如果当前结点没有左孩子
//那么左孩子的指针域就可以拿来存放前驱结点的线索信息。

//若当前结点没有左孩子,则左指针域可以存放线索
if(!p->lchild) {

//左标志为前驱线索
p->LTag = Thread;

//左孩子指针指向前驱
p->lchild = pre;
}//if

//此时还未访问后继结点,但可以确定当前结点p一定是前驱结点pre
//的后继,所以要把前驱结点的后继指针域填上线索信息

//前驱没有右孩子
if(!pre->rchild) {

//前驱的右标志为线索(后继)
pre->RTag = Thread;

//前驱右孩子指针指向其后继(当前结点p)
pre->rchild = p;
}//if

//使pre指向的结点p的新前驱
pre = p;

//递归右子树线索化
InThreading(p->rchild);
}//if
}//InThreading

/*
函数:InOrderThreading
参数:BiThrTree &Thrt 头结点的引用
BiThrTree T 指向线索二叉树根结点的指针
返回值:状态码,操作成功返回OK,否则返回ERROR
作用:中序遍历二叉树T,并将其中序线索化。
*/

Status InOrderThreading(BiThrTree &Thrt, BiThrTree T) {

//申请头结点内存空间
//if(!(Thrt = (BiThrTree)malloc(sizeof(BiThrNode))))
//相当于以下两行代码:
//Thrt = (BiThrTree)malloc(sizeof(BiThrNode));
//if(!Thrt) <=> if(Thrt == NULL)
if(!(Thrt = (BiThrTree)malloc(sizeof(BiThrNode)))) {
//内存分配失败
exit(OVERFLOW);
}//if

//建头结点,左标志为指针
Thrt->LTag = Link;

//右标志为线索
Thrt->RTag = Thread;

//右指针指向结点自身
Thrt->rchild = Thrt;

//若二叉树空,则左指针指向结点自身
if(!T) { //if(!T) <=> if(T == NULL)
Thrt->lchild = Thrt;
}//if
else{
//头结点的左指针指向根结点
Thrt->lchild = T;

//pre(前驱)的初值指向头结点
pre = Thrt;

//中序遍历进行中序线索化,pre指向中序遍历的最后一个结点
//InThreading(T)函数递归地完成了除头结点外其他结点的线索化。
InThreading(T);

//最后一个结点的右指针指向头结点
pre->rchild = Thrt;

//最后一个结点的右标志为线索
pre->RTag = Thread;

//头结点的右指针指向中序遍历的最后一个结点
Thrt->rchild = pre;
}//else

//操作成功
return OK;
}//InOrderThreading

/*
函数:Print
参数:TElemType c 被访问的元素
返回值:状态码,操作成功返回OK,操作失败返回ERROR
作用:访问元素e的函数,通过修改该函数可以修改元素访问方式,
该函数使用时需要配合遍历函数一起使用。
*/

Status Print(TElemType c){

//以控制台输出的方式访问元素
printf(" %c ", c);

//操作成功
return OK;
}//Print

/*
函数:InOrderTraverse_Thr
参数:BiThrTree T 指向线索二叉树根结点的指针
Status(*Visit)(TElemType) 函数指针,指向元素访问函数
返回值:状态码,操作成功返回OK,否则返回ERROR
作用:中序遍历线索二叉树T(头结点)的非递归算法。
*/

Status InOrderTraverse_Thr(BiThrTree T, Status(*Visit)(TElemType)) {

//工作指针p
BiThrTree p;

//p指向线索二叉树的根结点
p = T->lchild;

//空树或遍历结束时,p==T
while(p != T) {

//由根结点一直找到二叉树的最左结点
while(p->LTag == Link) {
p = p->lchild;
}//while

//调用访问函数访问此结点
Visit(p->data);

//p->rchild是线索(后继),且不是遍历的最后一个结点
while(p->RTag == Thread && p->rchild != T) {
p = p->rchild;

//访问后继结点
Visit(p->data);
}//while

//若p->rchild不是线索(是右孩子),p指向右孩子,返回循环,
//找这棵子树中序遍历的第1个结点
p = p->rchild;
}//while

//操作成功
return OK;
}//InOrderTraverse_Thr

/*
函数:DestroyBiTree
参数:BiThrTree &T T指向线索二叉树根结点
返回值:状态码,操作成功返回OK,否则返回ERROR
作用:递归地销毁线索二叉树,被DestroyBiThrTree函数调用。
*/

Status DestroyBiTree(BiThrTree &T) {

//非空树
if(T) { //if(T) <=> if(T != NULL)

//如果有左孩子则递归地销毁左子树
if(T->LTag == Link) {
DestroyBiTree(T->lchild);
}//if

//如果有右孩子则递归地销毁右子树
if(T->RTag == Link) {
DestroyBiTree(T->rchild);
}//if

//释放根结点
free(T);

//指针置空
T = NULL;
}//if

//操作成功
return OK;
}//DestroyBiTree

/*
函数:DestroyBiTree
参数:BiThrTree &Thrt Thrt指向线索二叉树头结点
返回值:状态码,操作成功返回OK,否则返回ERROR
作用:若线索二叉树Thrt存在,递归地销毁线索二叉树Thrt。
*/

Status DestroyBiThrTree(BiThrTree &Thrt) {

//头结点存在
if(Thrt) {

//若根结点存在,则递归销毁头结点lchild所指的线索二叉树
if(Thrt->lchild) { //if(Thrt->lchild) <=> if(Thrt->lchild != NULL)
DestroyBiTree(Thrt->lchild);
}//if

//释放头结点
free(Thrt);

//线索二叉树Thrt指针赋0
Thrt = NULL;
}//if

//操作成功
return OK;
}//DestroyBiThrTree

int main(int argc, char** argv) {

printf("---------------------线索二叉树测试程序-----------------------\n");

BiThrTree H, T;

//使用用户输入的先序遍历序列生成一棵没有被线索化的二叉树
printf("请按先序遍历顺序输入二叉树,空格表示空子树,输入完成后按回车确认\n");
CreateBiThrTree(T);

//在中序遍历过程中线索化二叉树
InOrderThreading(H, T);

//按中序遍历序列输出线索二叉树
printf("中序遍历(输出)线索二叉树:\n");
InOrderTraverse_Thr(H, Print);
printf("\n");

//销毁线索二叉树
DestroyBiThrTree(H);
}//main

测试过程中的输入和程序的输出:

---------------------线索二叉树测试程序-----------------------
请按先序遍历顺序输入二叉树,空格表示空子树,输入完成后按回车确认
ABE*F**C*DGHI*J*K******↙
//说明:此处的*是空格,为方便确认输入了几个空格将空格替换成*,测试输入时请将*改回空格
↙表示回车确认 输入(可直接复制,不要复制↙):ABE F C DGHI J K
中序遍历(输出)线索二叉树:
E F B C I J K H G D A

--------------------------------
Process exited with return value 0
Press any key to continue . . .

总结:
线索二叉树利用了二叉树中废弃的空指针域来保存结点前驱和后继的信息,为了区分指针域保存的是线索还是指针,引入了左右标志位加以区分。虽然左右标志位占用了一些内存空间,但是这种空间上的牺牲是值得的——我们不仅去掉了栈,减少了很多入栈和出栈的操作,还做到了一劳永逸——一次遍历生成的线索信息可以多次使用,大大提高了二叉树遍历操作的速度。在给线索二叉树添加头结点之后,我们可以将其改造为双向线索链表,这样做的好处是线索二叉树既可以正向遍历也可以反向遍历。

下次的文章将会介绍赫夫曼树的实现。感谢大家一直以来的支持,希望大家继续关注我的博客。再见!