1. 线索二叉树是什么:先搞懂它解决了什么问题
二叉树大家都不陌生,前序、中序、后序遍历随口就能背出来。但如果你真去写代码,递归遍历简单,非递归遍历就得靠栈,中序遍历非递归写法尤其容易绕晕。更麻烦的是,树里大量指针是空的——一个 n 个节点的二叉树,一共 2n 个指针域,实际只用了 n-1 个指向孩子,剩下的 n+1 个全是 NULL。这么多空闲指针放在那里什么都不干,总让人觉得浪费。
线索二叉树(Threaded Binary Tree)就是把这些空指针利用起来,让它们指向遍历序列中的前驱节点或后继节点。这样改造之后,树不仅能像普通二叉树一样按层次关系使用,还能直接在线性顺序上做遍历,不用递归也不用栈。说白了,线索化之后,二叉树就带上了一条“隐形的链表”,你在中序序列里找某个节点的下一个节点,复杂度是 O(1),不需要再重新遍历一遍。
我第一次接触这个概念是在学习数据结构中二叉树那一章,当时书上画的那张带虚线的图把我看懵了,后来自己动手写代码才真正理解。线索化不是要改变树本身的结构,而是在原有二叉链表的基础上,给每个节点增加两个标志位,标明当前指针指向的是孩子节点还是线索。理解了这个思路,后面不管是中序线索化、前序线索化还是后序线索化,都一通百通。
这篇文章我会从原理讲到代码实现,再讲遍历和查找的具体写法,最后把常见问题也整理出来。无论你是正在准备期末考试、考研复习,还是工作中真的需要手写数据结构,这篇内容都能帮上忙。我会用我实际写代码时踩过的坑给你做提醒,保证不是那种光讲理论的教科书复述。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 线索化的核心概念与结构设计
2.1 从空指针到线索:关键设计思路
先看一棵最简单的中序线索二叉树。以中序遍历序列为准,每个节点在序列中都有前驱和后继(除了第一个节点没有前驱、最后一个节点没有后继)。传统的二叉链表里,节点只有左孩子指针和右孩子指针,如果想找中序后继,就不得不从根重新遍历;如果想找中序前驱,同样很麻烦,甚至需要遍历完整个左子树才能确定。
线索化的做法是:如果某个节点的左孩子指针为空,就让它指向中序前驱;如果右孩子指针为空,就让它指向中序后继。那些指向线索的指针,在图上通常用虚线表示,而指向真实孩子的指针用实线表示。
这里有一个必须立刻想清楚的问题:指针指向的到底是真孩子还是线索,仅靠指针本身无法判断。例如一个节点的左指针指向了某个节点,这个“某个节点”可能是它的左孩子,也可能是中序前驱。所以需要额外的标志位来区分。标准做法是在节点结构体里加两个布尔字段,比如 leftTag 和 rightTag:
- leftTag == 0:左指针指向左孩子
- leftTag == 1:左指针指向中序前驱(线索)
- rightTag == 0:右指针指向右孩子
- rightTag == 1:右指针指向中序后继(线索)
这个设计是整个线索二叉树的地基。如果你真想自己写一遍,建议先把这个结构定义写明白,别急着写线索化的递归函数。标志位想不清楚,后面所有代码都会乱。
2.2 节点结构与内存布局
在实际编码中,我见过有些初学者把线索二叉树的节点定义成带“prev”和“next”指针的结构,这是不对的。线索二叉树并没有增加新的指针,它只是对原来的 left 和 right 指针做了重新解释,只是额外多了两个标志。这样定义的好处是:线索化完成后,这棵树依然可以当作普通二叉树使用,遍历算法兼容性也好,代码逻辑也更干净。
典型的 C 语言定义如下:
c复制typedef enum { Link, Thread } PointerTag; // Link=0 表示指向孩子,Thread=1 表示指向线索
typedef struct ThreadNode {
int data; // 数据域
struct ThreadNode *left, *right; // 左右孩子指针
PointerTag leftTag, rightTag; // 左右标志位
} ThreadNode, *ThreadTree;
如果使用 C++,可以简化成 bool 或者直接用 int 0/1 都行。我个人偏好用枚举,因为看代码的时候语义更明确,调试时也更友好。
这里需要特别提醒一个容易犯的错误:很多人会在线索化之后忘记维护标志位,导致后续遍历时把“线索”误当成“孩子”去访问。尤其是做中序线索化的时候,递归过程中节点的指向会动态变化,如果只改指针不改 tag,遍历时就会陷入死循环或者访问到错误节点。
3. 中序线索化的完整实现过程
3.1 线索化的递归本质
中序线索化的过程,本质上是在中序遍历的过程中对空指针做“接线”操作。遍历的核心顺序不变——左子树、根节点、右子树,但在访问根节点时额外做两件事:
- 如果当前节点左指针为空,就把左指针指向刚刚访问过的前驱节点 pre,并把 leftTag 设为 Thread;
- 如果前驱节点 pre 的右指针为空,就把 pre 的右指针指向当前节点,并把 pre 的 rightTag 设为 Thread。
第二步很多人第一次写容易漏。原因是在中序遍历进行中,当前节点是 pre 的“后继”,但遍历到 pre 的时候还没看到后继是谁。所以只能等遍历到当前节点时,反过来处理 pre 的右线索。这是线索化代码里最绕也最关键的地方。
整个递归过程需要一个外部变量 pre 来记录上一次访问的节点,初始为 NULL。用 C 代码来表达大概是这个样子:
c复制void inOrderThreading(ThreadTree &root) {
ThreadNode *pre = NULL; // 记录中序遍历过程中刚刚访问过的节点
inThread(root, pre);
// 遍历结束后,pre 指向中序序列的最后一个节点,它的右指针此时应为空
if (pre) {
pre->right = NULL; // 一般置空,也可以不处理,用于统一收尾
pre->rightTag = Thread;
}
}
void inThread(ThreadNode *node, ThreadNode *&pre) {
if (node == NULL) return;
inThread(node->left, pre); // 递归线索化左子树
// 当前节点的左子树为空,则建立前驱线索
if (node->left == NULL) {
node->left = pre;
node->leftTag = Thread;
}
// 前驱节点的右子树为空,则建立后继线索,指向当前节点
if (pre != NULL && pre->right == NULL) {
pre->right = node;
pre->rightTag = Thread;
}
pre = node; // 当前节点变成了“前驱”
inThread(node->right, pre); // 递归线索化
