6.10 线索二叉树

6.10.1 线索二叉树原理

我们现在提倡节约型社会,一切都应该节约为本。对待我们的程序当然也不例外,能不浪费的时间或空间,都应该考虑节省。我们再来观察图6-10-1,会发现指针域并不是都充分的利用了,有许许多多的“∧”,也就是空指针域的存在,这实在不是好现象,应该要想办法利用起来。

6.10 线索二叉树 - 图1

图6-10-1

首先我们要来看看这空指针有多少个呢?对于一个有n个结点的二叉链表,每个结点有指向左右孩子的两个指针域,所以一共是2n个指针域。而n个结点的二叉树一共有n-1条分支线数,也就是说,其实是存在2n-(n-1)=n+1个空指针域。比如图6-10-1有10个结点,而带有“∧”空指针域为11。这些空间不存储任何事物,白白的浪费着内存的资源。

另一方面,我们在做遍历时,比如对图6-10-1做中序遍历时,得到了HDIBEJAFCG这样的字符序列,遍历过后,我们可以知道,结点I的前驱是D,后继是B,结点F的前驱是A,后继是C。也就是说,我们可以很清楚的知道任意一个结点,它的前驱和后继是哪一个。

可是这是建立在已经遍历过的基础之上的。在二叉链表上,我们只能知道每个结点指向其左右孩子结点的地址,而不知道某个结点的前驱是谁,后继是谁。要想知道,必须遍历一次。以后每次需要知道时,都必须先遍历一次。为什么不考虑在创建时就记住这些前驱和后继呢,那将是多大的时间上的节省。

综合刚才两个角度的分析后,我们可以考虑利用那些空地址,存放指向结点在某种遍历次序下的前驱和后继结点的地址。就好像GPS导航仪一样,我们开车的时候,哪怕我们对具体目的地的位置一无所知,但它每次都可以告诉我从当前位置的下一步应该走向哪里。这就是我们现在要研究的问题。我们把这种指向前驱和后继的指针称为线索,加上线索的二叉链表称为线索链表,相应的二叉树就称为线索二叉树(Threaded Binary Tree)。

请看图6-10-2,我们把这棵二叉树进行中序遍历后,将所有的空指针域中的rchild,改为指向它的后继结点。于是我们就可以通过指针知道H的后继是D(图中①),I的后继是B(图中②),J的后继是E(图中③),E的后继是A(图中④),F的后继是C(图中⑤),G的后继因为不存在而指向NULL(图中⑥)。此时共有6个空指针域被利用。

6.10 线索二叉树 - 图2

图6-10-2

再看图6-10-3,我们将这棵二叉树的所有空指针域中的lchild,改为指向当前结点的前驱。因此H的前驱是NULL(图中①),I的前驱是D(图中②),J的前驱是B(图中③),F的前驱是A(图中④),G的前驱是C(图中⑤)。一共5个空指针域被利用,正好和上面的后继加起来是11个。

6.10 线索二叉树 - 图3

图6-10-3

通过图6-10-4(空心箭头实线为前驱,虚线黑箭头为后继),就更容易看出,其实线索二叉树,等于是把一棵二叉树转变成了一个双向链表,这样对我们的插入删除结点、查找某个结点都带来了方便。所以我们对二叉树以某种次序遍历使其变为线索二叉树的过程称做是线索化。

6.10 线索二叉树 - 图4

图6-10-4

不过好事总是多磨的,问题并没有彻底解决。我们如何知道某一结点的lchild是指向它的左孩子还是指向前驱?rchild是指向右孩子还是指向后继?比如E结点的lchild是指向它的左孩子J,而rchild却是指向它的后继A。显然我们在决定lchild是指向左孩子还是前驱,rchild是指向右孩子还是后继上是需要一个区分标志的。因此,我们在每个结点再增设两个标志域ltag和rtag,注意ltag和rtag只是存放0或1数字的布尔型变量,其占用的内存空间要小于像lchild和rchild的指针变量。结点结构如表6-10-1所示。

6.10 线索二叉树 - 图5

表6-10-1

其中:

  • ltag为0时指向该结点的左孩子,为1时指向该结点的前驱。
  • rtag为0时指向该结点的右孩子,为1时指向该结点的后继。
  • 因此对于图6-10-1的二叉链表图可以修改为图6-10-5的样子。

6.10 线索二叉树 - 图6

图6-10-5

6.10.2 线索二叉树结构实现

由此二叉树的线索存储结构定义代码如下:

  1. /* 二叉树的二叉线索存储结构定义 */
  2. /* Link==0表示指向左右孩子指针 */
  3. /* Thread==1表示指向前驱或后继的线索 */
  4. typedef enum {Link, Thread} PointerTag;
  5. /* 二叉线索存储结点结构 */
  6. typedef struct BiThrNode
  7. {
  8. /* 结点数据 */
  9. TElemType data;
  10. /* 左右孩子指针 */
  11. struct BiThrNode *lchild, *rchild;
  12. PointerTag LTag;
  13. /* 左右标志 */
  14. PointerTag RTag;
  15. } BiThrNode, *BiThrTree;

线索化的实质就是将二叉链表中的空指针改为指向前驱或后继的线索。由于前驱和后继的信息只有在遍历该二叉树时才能得到,所以线索化的过程就是在遍历的过程中修改空指针的过程。

中序遍历线索化的递归函数代码如下:

  1. BiThrTree pre; /* 全局变量,始终指向刚刚访问过的结点 */
  2. /* 中序遍历进行中序线索化 */
  3. void InThreading(BiThrTree p)
  4. {
  5. if (p)
  6. {
  7. /* 递归左子树线索化 */
  8. InThreading(p->lchild);
  9. /* 没有左孩子 */
  10. if (!p->lchild)
  11. {
  12. /* 前驱线索 */
  13. p->LTag = Thread;
  14. /* 左孩子指针指向前驱 */
  15. p->lchild = pre;
  16. }
  17. /* 前驱没有右孩子 */
  18. if (!pre->rchild)
  19. {
  20. /* 后继线索 */
  21. pre->RTag = Thread;
  22. /* 前驱右孩子指针指向后继(当前结点p) */
  23. pre->rchild = p;
  24. }
  25. /* 保持pre指向p的前驱 */
  26. pre = p;
  27. /* 递归右子树线索化 */
  28. InThreading(p->rchild);
  29. }
  30. }

你会发现,这代码除加粗代码以外,和二叉树中序遍历的递归代码几乎完全一样。只不过将本是打印结点的功能改成了线索化的功能。

中间加粗部分代码是做了这样的一些事。

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,以便于下一次使用。

有了线索二叉树后,我们对它进行遍历时发现,其实就等于是操作一个双向链表结构。

和双向链表结构一样,在二叉树线索链表上添加一个头结点,如图6-10-6所示,并令其lchild域的指针指向二叉树的根结点(图中的①),其rchild域的指针指向中序遍历时访问的最后一个结点(图中的②)。反之,令二叉树的中序序列中的第一个结点中,lchild域指针和最后一个结点的rchild域指针均指向头结点(图中的③和④)。这样定义的好处就是我们既可以从第一个结点起顺后继进行遍历,也可以从最后一个结点起顺前驱进行遍历。

6.10 线索二叉树 - 图7

图6-10-6

遍历的代码如下:

  1. /* T指向头结点,头结点左链lchild指向根结点,
  2. 头结点右链rchild指向中序遍历的 */
  3. /* 最后一个结点。中序遍历二叉线索链表表示的二
  4. 叉树T */
  5. Status InOrderTraverse_Thr(BiThrTree T)
  6. {
  7. BiThrTree p;
  8. /* p指向根结点 */
  9. p = T->lchild;
  10. /* 空树或遍历结束时,p==T */
  11. while (p != T)
  12. {
  13. /* 当LTag==0时循环到中序序列第一个结点 */
  14. while (p->LTag == Link)
  15. p = p->lchild;
  16. /* 显示结点数据,可以更改为其他对结点操作 */
  17. printf("%c", p->data);
  18. while (p->RTag == Thread && p->rchild != T)
  19. {
  20. p = p->rchild;
  21. printf("%c", p->data);
  22. }
  23. /* p进至其右子树根 */
  24. p = p->rchild;
  25. }
  26. return OK;
  27. }

1.代码中,第4行,p=T->lchild;意思就是图6-10-6中的①,让p指向根结点开始遍历。

2.第5~16行,while(p!=T)其实意思就是循环直到图中的④的出现,此时意味着p指向了头结点,于是与T相等(T是指向头结点的指针),结束循环,否则一直循环下去进行遍历操作。

3.第7~8行,while(p->LTag==Link)这个循环,就是由A→B→D→H,此时H结点的LTag不是Link(就是不等于0),所以结束此循环。

4.第9行,打印H。

5.第10~14行,while(p->RTag==Thread&&p->rchild!=T),由于结点H的RTag==Thread(就是等于1),且不是指向头结点。因此打印H的后继D,之后因为D的RTag是Link,因此退出循环。

6.第15行,p=p->rchild;意味着p指向了结点D的右孩子I。

7.……,就这样不断循环遍历,路径参照图6-10-4,直到打印出HDIBJEAFCG,结束遍历操作。

从这段代码也可以看出,它等于是一个链表的扫描,所以时间复杂度为O(n)。

由于它充分利用了空指针域的空间(这等于节省了空间),又保证了创建时的一次遍历就可以终生受用前驱后继的信息(这意味着节省了时间)。所以在实际问题中,如果所用的二叉树需经常遍历或查找结点时需要某种遍历序列中的前驱和后继,那么采用线索二叉链表的存储结构就是非常不错的选择。