第十一章:线索二叉树——让空指针不再闲置

各位同学,今天我们来聊一个很有意思的话题——线索二叉树。

说实话,我第一次接触线索二叉树的时候,心里想的是:“这玩意儿有必要吗?” 但后来在项目中吃了不少苦头,才明白它的价值。嗯,咱们慢慢聊。

11.1 线索化的概念

先问大家一个问题:一棵普通的二叉树,有多少个空指针?

你想想看,一棵有 n 个结点的二叉树,总共有 2n 个指针域。但实际用到的只有 n-1 个(根结点没有父指针)。也就是说,有 n+1 个指针是空的。浪费啊!

我当年做嵌入式文件系统时,需要频繁遍历目录树。每次递归遍历,栈开销大得吓人。后来我就在想:“这些空指针能不能利用起来?”

线索化,说白了就是:把空指针改成指向遍历序列中的前驱或后继结点

  • 左空指针 → 指向前驱结点
  • 右空指针 → 指向后继结点

这样,你就不需要递归或栈,直接沿着线索走一遍就行。

核心要点:线索化后的二叉树,遍历时间复杂度仍然是 O(n),但空间复杂度从 O(n)(递归栈)降到了 O(1)。

11.2 中序线索二叉树的构造

我个人习惯用中序线索化,因为中序遍历在二叉搜索树里太常用了。构造过程其实不复杂,核心就是一次中序遍历,边遍历边改指针。

先看结点结构:

typedef struct ThreadNode {
    int data;
    struct ThreadNode *lchild, *rchild;
    int ltag, rtag;  // 0表示孩子,1表示线索
} ThreadNode, *ThreadTree;

这里多了两个标志位:ltagrtag。它们告诉程序:这个指针到底指向孩子还是线索。

构造过程我画了一张图,大家看看:

中序线索二叉树构造流程 原始二叉树 A B C NULL NULL NULL NULL 中序遍历 线索化后 A B C 前驱 后继 后继 中序序列:B → A → C 实线 = 孩子指针,虚线 = 线索指针 // 中序线索化核心代码(递归版) void InThread(ThreadTree p, ThreadTree &pre) { if (p) { InThread(p->lchild, pre); ... }

构造代码我贴一下,大家注意看 pre 这个指针的用法:

void InThread(ThreadTree p, ThreadTree &pre) {
    if (p == NULL) return;
    
    // 1. 线索化左子树
    InThread(p->lchild, pre);
    
    // 2. 处理当前结点
    if (p->lchild == NULL) {
        p->lchild = pre;   // 左空指向前驱
        p->ltag = 1;
    }
    if (pre != NULL && pre->rchild == NULL) {
        pre->rchild = p;   // 前驱右空指向当前
        pre->rtag = 1;
    }
    pre = p;  // 更新前驱
    
    // 3. 线索化右子树
    InThread(p->rchild, pre);
}

小技巧:我习惯在递归前先判断 p == NULL,而不是在调用处判断。这样代码更干净,不容易漏判。

11.3 线索二叉树的遍历

线索化之后,遍历就变得非常优雅了。不需要递归,不需要栈,一个循环搞定。

中序线索遍历的思路:

  1. 找到最左边的结点(第一个要访问的结点)
  2. 访问它
  3. 如果它有右线索,直接沿线索走
  4. 如果它有右孩子,去右子树找最左边
  5. 重复直到 NULL
void InOrderTraverse(ThreadTree T) {
    ThreadTree p = T;
    while (p != NULL) {
        // 找到最左边的结点
        while (p->ltag == 0)
            p = p->lchild;
        
        // 访问结点
        printf("%d ", p->data);
        
        // 沿线索走
        while (p->rtag == 1) {
            p = p->rchild;
            if (p == NULL) break;
            printf("%d ", p->data);
        }
        
        // 去右子树
        p = p->rchild;
    }
}

这段代码我当年调试了整整一个下午。为什么?因为 rtag == 1 时,我忘了判断 p == NULL,结果一直死循环。嗯,边界条件永远是魔鬼

11.4 前序与后序线索化简介

中序线索化最常用,但前序和后序也有用武之地。

类型 线索指向 遍历特点 我遇到的场景
前序线索化 前驱、后继 先访问根,再沿线索走 表达式树求值
中序线索化 前驱、后继 左-根-右,最自然 二叉搜索树遍历
后序线索化 前驱、后继 需要栈辅助(较复杂) 目录树删除

前序线索化代码和中序很像,只是把「处理当前结点」挪到了递归左右子树之前:

void PreThread(ThreadTree p, ThreadTree &pre) {
    if (p == NULL) return;
    
    // 处理当前结点(前序:先处理根)
    if (p->lchild == NULL) {
        p->lchild = pre;
        p->ltag = 1;
    }
    if (pre != NULL && pre->rchild == NULL) {
        pre->rchild = p;
        pre->rtag = 1;
    }
    pre = p;
    
    // 注意:这里要判断 ltag,避免走进线索
    if (p->ltag == 0)
        PreThread(p->lchild, pre);
    if (p->rtag == 0)
        PreThread(p->rchild, pre);
}

我曾经踩过的坑:前序线索化时,如果不判断 ltag == 0 就直接递归左子树,会沿着线索回到父结点,造成无限递归。这个 bug 我查了整整两天才找到。

后序线索化更复杂一些。因为后序遍历是「左-右-根」,线索化后遍历时,你没法简单地沿线索走完——需要栈来辅助。我个人在实际项目中很少用后序线索化,除非内存极度受限且必须做后序遍历。

11.5 总结一下

线索二叉树的核心思想就一句话:让空指针干活,别闲着

  • 中序线索化最实用,构造和遍历都简单
  • 前序线索化注意递归时别走进线索
  • 后序线索化复杂,非必要不用

我记得有一次做嵌入式文件系统,内存只有 64KB,递归遍历目录树直接栈溢出。改成线索二叉树后,不仅省了栈空间,遍历速度还快了 30%。有时候,好的数据结构比好的算法更管用


公众号:蓝海资料掘金营,微信deep3321