七、线索二叉树:让空指针不再闲置

说到二叉树,你肯定遇到过这种情况——明明有那么多空指针,却白白浪费着。我刚开始学树结构时也觉得可惜,一个节点两个指针域,叶子节点全空着,这存储利用率也太低了。后来接触了线索二叉树,才明白前辈们早就想到了这个优化点。

7.1 线索化的概念

线索二叉树,说白了就是把那些空着的指针利用起来。左孩子为空时,让它指向前驱节点;右孩子为空时,让它指向后继节点。这样遍历的时候就不用递归或者栈了,直接顺着线索走就行。

但这里有个问题——我怎么知道一个指针是真正的孩子指针,还是线索?

嗯,解决办法很简单:每个节点加两个标志位。ltag 和 rtag,0 表示指向孩子,1 表示指向前驱/后继。

节点结构定义:

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

我个人习惯把 ltag 和 rtag 定义成 int 类型,虽然用 1 个 bit 就够了,但为了代码可读性,用 int 更直观。你在实际项目中如果追求极致内存,可以用位域,不过一般没必要。

7.2 中序线索二叉树的构建

构建线索二叉树的过程,本质上就是一次中序遍历,在遍历过程中把空指针改成线索。我当年第一次写这个代码时,犯了个低级错误——忘记处理最后一个节点的右指针。你想想看,最后一个节点没有后继,它的 rchild 应该指向 NULL,同时 rtag 置 1。

来看代码,我习惯用一个全局变量 pre 来记录上一个访问的节点:

ThreadNode *pre = NULL;  // 全局变量,指向当前节点的前驱

void InThread(ThreadTree &p) {
    if (p == NULL) return;
    
    InThread(p->lchild);  // 递归线索化左子树
    
    // 处理当前节点
    if (p->lchild == NULL) {
        p->lchild = pre;   // 左指针指向前驱
        p->ltag = 1;       // 标记为线索
    }
    if (pre != NULL && pre->rchild == NULL) {
        pre->rchild = p;   // 前驱的右指针指向当前节点
        pre->rtag = 1;     // 标记为线索
    }
    pre = p;  // 更新前驱
    
    InThread(p->rchild);  // 递归线索化右子树
}

void CreateInThread(ThreadTree T) {
    pre = NULL;
    if (T != NULL) {
        InThread(T);
        // 处理最后一个节点
        if (pre->rchild == NULL) {
            pre->rtag = 1;
        }
    }
}

我曾经踩过的坑:递归线索化时,pre 指针的更新时机很重要。一定要在访问完当前节点、处理完线索后再更新 pre。如果顺序搞反了,线索会指向错误的前驱节点。调试这种 bug 特别痛苦,因为线索错误会导致遍历时死循环。

7.3 线索二叉树的遍历

线索化之后,遍历就变得很优雅了。不需要递归,不需要栈,顺着线索走就行。中序线索二叉树的遍历,核心就是找到第一个节点,然后不断找后继。

找第一个节点很简单——沿着左孩子一直走,直到 ltag 为 1 的那个节点:

ThreadNode *FirstNode(ThreadNode *p) {
    while (p->ltag == 0) {
        p = p->lchild;
    }
    return p;
}

找后继节点呢?如果 rtag 为 1,说明右指针就是线索,直接返回 rchild。如果 rtag 为 0,说明有右子树,那么后继就是右子树中第一个被访问的节点:

ThreadNode *NextNode(ThreadNode *p) {
    if (p->rtag == 1) {
        return p->rchild;  // 线索直接指向后继
    } else {
        return FirstNode(p->rchild);  // 右子树的最左下节点
    }
}

有了这两个函数,中序遍历就简单了:

void InOrder(ThreadTree T) {
    for (ThreadNode *p = FirstNode(T); p != NULL; p = NextNode(p)) {
        printf("%d ", p->data);
    }
}

你看,这个 for 循环写得多清爽。没有递归调用,没有栈操作,时间复杂度 O(n),空间复杂度 O(1)。

小技巧:如果你需要反向遍历(从最后一个节点往前),可以对称地写 LastNode 和 PreNode 函数。LastNode 沿着右孩子走到最右,PreNode 判断左线索或左子树的最右下节点。这在某些场景下很有用,比如需要双向遍历的时候。

7.4 知识体系总览

线索二叉树的核心逻辑,我画了张图帮你理清思路:

线索二叉树知识体系 线索二叉树 核心概念 中序线索化构建 线索遍历 利用空指针指向前驱/后继 ltag/rtag 标志位区分 中序遍历过程中线索化 pre 指针记录前驱节点 最后节点右指针置 NULL FirstNode 找最左下节点 NextNode 找后继节点 O(n)时间 O(1)空间

7.5 线索二叉树的优缺点

优点 缺点
遍历不需要递归或栈,空间效率高 插入和删除节点时需要维护线索,操作复杂
可以快速找到任意节点的前驱和后继 需要额外的两个标志位,存储开销略增
中序遍历时间复杂度 O(n),空间 O(1) 线索化过程本身需要递归,构建时仍有栈开销

说实话,线索二叉树在实际工程中用得不算特别多。但它的思想很巧妙——把浪费的资源利用起来。我在做嵌入式系统开发时,遇到过内存只有几十 KB 的场景,那时候用线索二叉树替代递归遍历,确实省下了不少栈空间。

如果你现在正在学习数据结构,我建议你把线索二叉树的代码亲手敲一遍。尤其是那个 pre 指针的传递逻辑,理解了它,你对递归和指针的理解会上一个台阶。


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