树 Part 9
二叉树的建立
了解了二叉树的遍历方法,我们如何在内存中生成一棵二叉链表的二叉树呢?树都没有,哪来遍历。所以我们还得来谈谈关于二叉树建立的问题。
如果要在内存中建立一个如左图这样的树,为了能让每个结点确认是否有左右孩子,我们对它进行了扩展,变成右图的样子,也就是将二叉树中每个结点的空指针引出一个虚结点,其值为一特定值,比如“#”。我们称这种处理后的二叉树为原二叉树的扩展二叉树。扩展二叉树就可以做到一个遍历序列确定一棵二叉树了。比如图中的前序遍历序列就为AB#D##C##
有了这样的准备,我们就可以来看看如何生成一棵二叉树了。假设二叉树的结点均为一个字符,我们把刚才前序遍历序列AB#D##C##用键盘挨个输入。实现的算法如下:
/* 按前序输入二叉树中结点的值(一个字符) */
/* #表示空树,构造二叉链表表示二叉树T。 */
void CreateBiTree(BiTree *T)
{TElemType ch;scanf("%c", &ch);if (ch == '#'){*T = NULL;}else{*T = (BiTree)malloc(sizeof(BiTNode));if (!*T) exit(OVERFLOW);(*T)->data = ch; /* 生成根结点 */CreateBiTree(&(*T)->lchild); /* 构造左子树 */CreateBiTree(&(*T)->rchild); /* 构造右子树 */}
}
其实建立二叉树,也是利用了递归的原理。只不过在原来应该是打印结点的地方,改成了生成结点、给结点赋值的操作而已。
线索二叉树
1.线索二叉树原理
对待我们的程序,能不浪费的时间或空间,都应该考虑节省。观察下图,会发现指针域并不是都充分的利用了,有许许多多的“∧”,也就是空指针域的存在,这实在不是好现象,应该要想办法利用起来。
首先要看看这空指针有多少个?对于一个有n个结点的二叉链表,每个结点有指向左右孩子的两个指针域,所以一共是2n个指针域。而n个结点的二叉树一共有n-1条分支线数,也就是说,其实是存在2n-(n-1)=n+1个空指针域。比如图中有10个结点,而带有“∧”空指针域为11。
另一方面,在做遍历时,比如对上图中的中序遍历序列为HDIBEJAFCG,遍历过后,我们可以知道,结点I的前驱是D,后继是B,结点F的前驱是A,后继是C。也就是说,可以很清楚的知道任意一个结点,它的前驱和后继是哪一个 。
可是这是建立在已经遍历过的基础之上的。在二叉链表上,我们只能知道每个结点指向其左右孩子结点的地址,而不知道某个结点的前驱是谁,后继是谁。要想知道,必须遍历一次。以后每次需要知道时,都必须先遍历一次。为什么不考虑在创建时就记住这些前驱和后继呢,那将是多大的时间上的节省。
综合刚才两个角度的分析后,我们可以考虑利用那些空地址,存放指向结点在某种遍历次序下的前驱和后继结点的地址。就好像GPS导航仪一样,我们开车的时候,哪怕我们对具体目的地的位置一无所知,但它每次都可以告诉我从当前位置的下一步应该走向哪里。这就是我们现在要研究的问题。这种指向前驱和后继的指针称为线索,加上线索的二叉链表称为线索链表,相应的二叉树就称为线索二叉树(Threaded Binary Tree)。
把这棵二叉树进行中序遍历后,将所有的空指针域中的rchild,改为指向它的后继结点。通过指针就可以知道H的后继是D(图中①),I的后继是B(图中②),J的后继是E(图中③),E的后继是A(图中④),F的后继是C(图中⑤),G的后继因为不存在而指向NULL(图中⑥)。此时共有6个空指针域被利用。
将这棵二叉树的所有空指针域中的lchild,改为指向当前结点的前驱。因此H的前驱是NULL(图中①),I的前驱是D(图中②),J的前驱是B(图中③),F的前驱是A(图中④),G的前驱是C(图中⑤)。一共5个空指针域被利用,正好和上面的后继加起来是11个。
(空心箭头实线为前驱,虚线黑箭头为后继),就更容易看出,其实线索二叉树,等于是把一棵二叉树转变成了一个双向链表,这样对插入删除结点、查找某个结点都带来了方便。这种对二叉树以某种次序遍历使其变为线索二叉树的过程称做是线索化。
不过问题还并没有彻底解决。我们如何知道某一结点的lchild是指向它的左孩子还是指向前驱?rchild是指向右孩子还是指向后继?比如E结点的lchild是指向它的左孩子J,而rchild却是指向它的后继A。显然我们在决定lchild是指向左孩子还是前驱,rchild是指向右孩子还是后继上是需要一个区分标志的。因此,我们在每个结点再增设两个标志域ltag和rtag,注意ltag和rtag只是存放0或1数字的布尔型变量,其占用的内存空间要小于像lchild和rchild的指针变量。结点结构如表所示。
其中:
ltag为0时指向该结点的左孩子,为1时指向该结点的前驱。
rtag为0时指向该结点的右孩子,为1时指向该结点的后继。
因此对于结点结构的二叉链表图可以修改为下图所示的样子。
2.线索二叉树结构实现
由此二叉树的线索存储结构定义代码如下:
/* 二叉树的二叉线索存储结构定义 */
/* Link==0表示指向左右孩子指针 */
/* Thread==1表示指向前驱或后继的线索 */
typedef enum {Link, Thread} PointerTag; /* 二叉线索存储结点结构 */typedef struct BiThrNode
{TElemType data; /* 结点数据 */struct BiThrNode *lchild, *rchild; /* 左右孩子指针 */PointerTag LTag; /* 左右标志 */PointerTag RTag;
}
线索化的实质就是将二叉链表中的空指针改为指向前驱或后继的线索。由于前驱和后继的信息只有在遍历该二叉树时才能得到,所以线索化的过程就是在遍历的过程中修改空指针的过程。
中序遍历线索化的递归函数代码如下:
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); /* 递归右子树线索化 */ }
}
你会发现,这代码和二叉树中序遍历的递归代码几乎完全一样。只不过将本是打印结点的功能改成了线索化的功能。
if(!p->lchild)表示如果某结点的左指针域为空,因为其前驱结点刚刚访问过,赋值给了pre,所以可以将pre赋值给p->lchild,并修改p->LTag=Thread(也就是定义为1)以完成前驱结点的线索化。
后继就要稍稍麻烦一些。因为此时p结点的后继还没有访问到,因此只能对它的前驱结点pre的右指针rchild做判断,if(!pre->rchild)表示如果为空,则p就是pre的后继,于是pre->rchild=p,并且设置pre->RTag=Thread,完成后继结点的线索化。
完成前驱和后继的判断后,别忘记将当前的结点p赋值给pre,以便于下一次使用。
有了线索二叉树后,我们对它进行遍历时发现,其实就等于是操作一个双向链表结构。
和双向链表结构一样,在二叉树线索链表上添加一个头结点,如图所示,并令其lchild域的指针指向二叉树的根结点(图中的①),其rchild域的指针指向中序遍历时访问的最后一个结点(图中的②)。反之,令二叉树的中序序列中的第一个结点中,lchild域指针和最后一个结点的rchild域指针均指向头结点(图中的③和④)。这样定义的好处就是我们既可以从第一个结点起顺后继进行遍历,也可以从最后一个结点起顺前驱进行遍历。
遍历的代码如下:
/* T指向头结点,头结点左链lchild指向根结点,头结点右链rchild指向中序遍历的 *//* 最后一个结点。中序遍历二叉线索链表表示的二叉树T */
Status InOrderTraverse_Thr(BiThrTree T)
{BiThrTree p;p = T->lchild; /* p指向根结点 *//* 空树或遍历结束时,p==T */while (p != T) {/* 当LTag==0时循环到中序序列第一个结点 */ while (p->LTag == Link) { p = p->lchild; } printf("%c", p->data); /* 显示结点数据,可以更改为其他对结点操作 */while (p->RTag == Thread && p->rchild != T) { p = p->rchild; printf("%c", p->data); }p = p->rchild; /* p进至其右子树根 */}return OK;
}
1.代码中,p=T->lchild;意思就是图中的①,让p指向根结点开始遍历。
2.while(p!=T)其实意思就是循环直到图中的④的出现,此时意味着p指向了头结点,与T相等(T是指向头结点的指针),结束循环,否则一直循环下去进行遍历操作。
3.while(p->LTag==Link)这个循环,就是由A→B→D→H,此时H结点的LTag不是Link(就是不等于0),所以结束此循环。打印H。
4.while(p->RTag==Thread&&p->rchild!=T),由于结点H的RTag==Thread(就是等于1),且不是指向头结点。因此打印H的后继D,之后因为D的RTag是Link,因此退出循环。
5.第一个while中的p=p->rchild;意味着p指向了结点D的右孩子I。
6.……,就这样不断循环遍历,路径参照下图,直到打印出HDIBJEAFCG,结束遍历操作。
从这段代码也可以看出,它等于是一个链表的扫描,所以时间复杂度为O(n)。
由于它充分利用了空指针域的空间(这等于节省了空间),又保证了创建时的一次遍历就可以终生受用前驱后继的信息(这意味着节省了时间)。所以在实际问题中,如果所用的二叉树需经常遍历或查找结点时需要某种遍历序列中的前驱和后继,那么采用线索二叉链表的存储结构就是非常不错的选择。