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); // 递归线索化右子树
}
注意这里 pre 必须传引用(或者用全局变量),否则递归返回后 pre 不会更新,线索化就断了。如果你写的是 Java,可以定义一个类成员变量 pre,效果一样。
3.2 中序线索化的手动模拟
光看代码可能还是有点飘,我拿一棵具体的小树手动走一遍流程。假设树结构如下:
code复制 1
/ \
2 3
/ \
4 5
中序遍历序列是:4 -> 2 -> 5 -> 1 -> 3。
递归处理的详细过程大致如下:
- 从根节点 1 出发,先递归左子树。
- 节点 2 有左孩子 4,递归到节点 4。节点 4 左右孩子都为空,此时 pre 还是 NULL,所以节点 4 的左指针指向 NULL(其实本来就是 NULL),leftTag 设为 Thread;pre 为空,不做右线索处理。pre 更新为节点 4。
- 回到节点 2,节点 2 的左孩子是 4,不是空,所以不建立左线索;节点 2 的右孩子是 5,不是空,也不建立右线索;pre 更新为节点 2。
- 递归节点 5,节点 5 左右孩子都为空,它的左指针指向 pre(节点 2),leftTag 设为 Thread;然后检查 pre 即节点 2 的右指针——不为空,是节点 5,所以不处理。pre 更新为节点 5。
- 回到根节点 1,节点 1 左孩子不空,不做左线索;右孩子不空,不做右线索;pre 更新为节点 1。
- 递归节点 3,节点 3 左右孩子都为空,左指针指向 pre(节点 1),leftTag 设为 Thread;同时 pre 即节点 1 的右指针指向节点 3 且不是空,所以不处理。pre 更新为节点 3。
- 递归结束。中序序列中 3 是最后一个节点,它的右指针本来为空,可以设置 rightTag。
最后形成的线索是:节点 4 的左指针指向 NULL(无前驱),节点 5 的左指针指向节点 2(前驱),节点 3 的左指针指向节点 1(前驱)。在遍历到节点 5 后,可以通过某种方法直接拿到它的后继——这个线索就是节点 2 的右指针指向节点 5,但节点 2 的右孩子本来就是 5,所以不算新增线索。
看到这里你应该能明白一个要点:线索不是凭空构造链表,只有当某个节点的左/右孩子为空时,这个空位才能被用来放线索。如果节点的左右子树都齐全,那它的左右指针都必须指向真实孩子,不能同时充当线索。
3.3 带不带头节点的线索化差异
有些教科书和网上的代码会在线索二叉树前面加一个头节点 head,head 的左指针指向根节点,右指针指向中序序列的最后一个节点,同时中序序列的第一个节点的左线索指向 head,最后一个节点的右线索也指向 head,构成一个双向循环的线索链表。
带头节点版本的好处是:遍历可以从头节点出发,形成一个环形结构,第一次进入时向左走到最左节点,之后沿着右线索遍历,最终会绕回头节点,判断统一方便。不过对于初学者来说,头节点的存在会让代码理解难度上升不少,增大了调试成本。
我个人的建议是:初学阶段先写不带头节点的版本,把核心逻辑跑通了、理解了,再看带头节点的写法,会容易接受很多。实际工程里,不带头节点的中序线索二叉树就已经能满足大部分查找和遍历需求,头节点更多是教科书里的设计偏好,并非必需。
4. 在中序线索二叉树上做遍历与查找
4.1 找中序后继节点的实现
线索二叉树最大的卖点就是找后继快。在中序线索树中,给定一个节点 p,找它的中序后继规则如下:
- 如果 p.rightTag == Thread,说明右指针直接指向后继,直接返回 p.right 即可。
- 如果 p.rightTag == Link,说明 p 有右孩子。根据中序遍历顺序“左-根-右”,p 的后继应该是它右子树中最左下的节点。做法是从 p.right 出发,一路沿着 left 指针向左走,直到走到 leftTag == Thread 的节点。
这个实现需要注意边界条件:如果 p 是中序序列的最后一个节点,且使用了空指针表示结束,那么找后继时要判断返回的节点是否为 NULL,避免空指针解引用。
代码实现大概长这样:
c复制ThreadNode *firstNode(ThreadNode *node) {
while (node && node->leftTag == Link) {
node = node->left;
}
return node;
}
ThreadNode *nextNode(ThreadNode *node) {
if (node->rightTag == Thread) {
return node->right;
}
return firstNode(node->right);
}
firstNode 是从某个子树根出发找中序起始节点的函数。因为中序顺序是左根右,所以要一直往左下角走,直到 leftTag 等于 Thread 为止。这里 leftTag == Link 表示还有左孩子,继续往左;如果 leftTag == Thread,说明左指针要么是空要么指向前驱,已经没有更左的节点了,当前节点就是该子树中序遍历的第一个节点。
4.2 中序线索二叉树的中序遍历
有了 nextNode 之后,中序遍历就变成了一件很简单的事。整体思路是:
- 先找到整棵树中序遍历的第一个节点(从根一路向左);
- 循环调用 nextNode,依次访问后续所有节点,直到返回 NULL。
完整代码:
c复制void inOrderTraverse(ThreadTree head) {
if (head == NULL) return;
ThreadNode *node = firstNode(head);
while (node != NULL) {
printf("%d ", node->data); // 访问当前节点
node = nextNode(node); // 找下一个节点
}
}
这个遍历方式不需要递归,不需要栈,时间复杂度是 O(n),每个节点访问一次。相比普通二叉树递归遍历的 O(n) 递归栈开销,线索二叉树在空间复杂度上优势非常明显。如果你在嵌入式环境或者递归深度受限的场景下处理大二叉树,这种遍历方式几乎是最优选择。
另外,如果要实现“反向遍历”——从最后一个节点开始往前找前驱,逻辑完全镜像:找最右下的节点作为起点,然后不断找前驱节点,规则是把 leftTag 判断换一下。代码逻辑是对称的,理解了正向的,反向的很快能写出来。
4.3 查找中序序列中的第 k 个节点
这个能力在线索树里表现很出色。因为中序线索树相当于把中序序列变成了一个单向链表(如果有头节点甚至变成双向循环链表),所以找第 k 个节点的做法就是从头开始沿后继走 k-1 次。
c复制ThreadNode *findKthNode(ThreadTree head, int k) {
if (head == NULL || k <= 0) return NULL;
ThreadNode *node = firstNode(head);
for (int i = 1; i < k && node != NULL; i++) {
node = nextNode(node);
}
return node;
}
在普通二叉树中找中序第 k 个节点,往往需要维护一个计数器做递归遍历,代码量更大且逻辑不够直观。线索树把这个场景简化成了线性链表遍历,效率稳定且不容出错。
但要注意:这里的时间复杂度仍然是 O(k),前提是你已经完成了线索化。如果一棵树没有线索化,你并不能直接做这种遍历。所以使用场景需要判断清楚——如果你经常需要按中序顺序查找元素,线索化值得;如果只是偶尔做一次全遍历,普通递归就够了。
5. 前序与后序线索二叉树:逻辑完全不同
5.1 前序线索化及遍历要点
前序线索化的思路与中序类似,区别在于线索化的时机是在“访问根节点”之前还是之后。前序遍历的顺序是根-左-右,所以在访问根节点后就立即处理线索,然后递归线索化左右子树。
这里有一个我当年掉进去过的坑:前序线索化时,如果先递归线索化左子树,再处理当前节点的前驱后继续,可能会导致 pre 指针已经指向了左子树末尾,逻辑就会错乱。正确顺序应该是:先处理当前节点与 pre 之间的线索关系,再把 pre 更新为当前节点,然后递归左子树,再递归右子树。这样才能保证 pre 的顺序与前序遍历序列一致。
前序线索树的遍历逻辑和中序也有差异。因为前序序列中,某个节点的后继规则是:
- 如果 p.leftTag == Link,后继就是 p.left;
- 否则,如果 p.rightTag == Link,后继就是 p.right;
- 否则,继续通过线索往上回溯,找到第一个有右子树的祖先节点,其右子树根就是后继。
第三步往往被初学者忽略。前序线索树在“叶子节点”上,右线索通常指向的是某个祖先节点或后续节点的起点,不能像中序那样直接拿 p.right 当后继。
前序线索化的代码示例:
c复制void preThread(ThreadNode *node, ThreadNode *&pre) {
if (node == NULL) return;
// 建立当前节点的前驱线索
if (node->left == NULL) {
node->left = pre;
node->leftTag = Thread;
}
// 为前驱节点建立后继线索
if (pre != NULL && pre->right == NULL) {
pre->right = node;
pre->rightTag = Thread;
}
pre = node;
// 注意:这里必须判断 leftTag 是否为 Link
// 如果左指针已经变成线索,就不能再作为左子树入口递归
if (node->leftTag == Link) {
preThread(node->left, pre);
}
if (node->rightTag == Link) {
preThread(node->right, pre);
}
}
注意递归条件里加上了 leftTag 和 rightTag 的判断,这是因为在建立线索后,指针可能不再指向真正的子树,如果无条件递归就会出问题。中序线索化没有这个问题,因为中序是在左子树递归完之后才线索化根节点,而前序是先线索化根节点再递归子树,风险完全不同。
5.2 后序线索化及遍历复杂性
后序线索化比前序和中序都要麻烦,原因在于后序遍历的顺序是左-右-根,线索化完成后,想从某个节点找后序“后继”非常困难——因为如果一个节点是右子树的根,它的后继可能是父节点,但父节点并不在它的子树范围内。理论上,需要给节点增加一个指向父节点的指针才能高效完成后序遍历,标准的三叉链表在这里反而派上了用场。
如果只使用二叉链表结构做后序线索化,只能方便地找后序前驱,找后序后继则需要借助栈或者父指针。这让后序线索二叉树在实际工程中热度远低于中序线索二叉树。
所以我个人的建议是:除非考试要求或者算法研究需要,否则不要过度纠结后序线索二叉树的遍历代码。理解“后序线索化后找后继需要父指针”的原理就够了。把中序线索二叉树吃透,收益最大。
6. 线索二叉树的建立过程:从普通树到线索树
6.1 先建普通二叉树,再线索化
线索化不是一个独立创建树的过程,而是在一棵已经存在的普通二叉链表树上追加信息。因此第一步一定是用普通建树方法创建二叉树,然后用线索化函数对树进行遍历式修改。
常用的建树方式有两种:
- 从控制台按先序序列输入,空节点用特殊符号(比如 #)表示;
- 读取数组或文件中的层级序列建树。
我以先序输入为例:
c复制ThreadNode *createTree() {
int val;
scanf("%d", &val);
if (val == -1) { // 约定 -1 表示空节点
return NULL;
}
ThreadNode *node = (ThreadNode *)malloc(sizeof(ThreadNode));
node->data = val;
node->leftTag = Link; // 初始都是 Link
node->rightTag = Link;
node->left = createTree();
node->right = createTree();
return node;
}
创建完普通二叉树之后,节点的 leftTag 和 rightTag 初始都设置成 Link,表示当前空指针还没有被当成线索来用。然后调用 inOrderThreading 开始线索化。
整个建树-线索化-遍历的完整流程在 main 函数里可以这么组织:
c复制int main() {
ThreadTree root = createTree();
inOrderThreading(root);
printf("中序线索化遍历结果:\n");
inOrderTraverse(root);
return 0;
}
注意在 createTree 阶段一定要把两个 tag 初始化为 Link,不然线索化函数里判断“tag 是否已经变了”就会依赖随机值,程序可以直接崩掉。这种初始化错误非常隐蔽,因为线索化函数一般只看左右指针是否为空,不一定每个分支都用 tag 做条件,但遍历时 tag 判断全靠它,所以初始化必须养成习惯。
6.2 验证线索化正确性的方法
线索化做完,怎么确认自己写对了?实际测试时,可以先用普通中序遍历打印一遍序列,再用线索化遍历打印一遍,对比两者结果是否一致。这种方式最直观。
我自己刷题和调试的时候,会额外写一个 debug 函数,专门打印每个节点的 data、leftTag、rightTag、left 指向的节点 data(或 NULL)、right 指向的节点 data(或 NULL)。这样一眼就能看出线索有没有接错、tag 是不是跟实际指向匹配。对于中序线索树,比如节点 5 的 leftTag 应该是 Thread 并且 left 指向节点 2,这几个信息打印出来核对一遍就放心了。
尤其是“前驱节点的右线索处理”这段逻辑,极容易写错。你可以在纸上画出树,标注出每个空指针在线索化后应该指向谁,再跟运行结果对比。这一步花不了多少时间,但能帮你建立很强的直觉。
7. 常见问题与排查技巧实录
7.1 线索化后遍历死循环是怎么回事
最常见的死循环原因是:在建树时没有正确初始化 leftTag 和 rightTag,导致遍历时把左孩子为 NULL 的节点误判为 Link 或者误判为 Thread,从而走进早已不是子树的指针里。另一种情况是线索化函数中没有在 pre 的右指针为 NULL 时给 pre 设置 rightTag,导致 pre 的右指针指向了一个真实节点,但 rightTag 仍然错误地保留为 Thread,遍历时以为可以直接跳到后继,结果跳到的地方根本不是后继,循环可能出现问题。
解决办法是在递归函数中先输出日志或打印信息,把每个节点在递归时的 pre 是谁输出出来,这样能快速定位是哪个节点接线错误。调试这类二叉树代码,打印日志永远比盯着代码找快。
7.2 指针指向线索却被当成孩子递归访问
这个问题主要出在前序线索化上。前序线索化时,如果已经对一个空指针设置了线索,下一次递归就不应该再把这个指针当子树入口。前面已经提到,递归前必须判断 tag 是否为 Link。如果不加判断,代码会试图递归访问 pre 节点或者后继节点,导致逻辑完全错乱。
中序线索化虽然有天然的时机保护,但在非递归改造时也容易出现类似问题。比如你在中序线索化的非递归版里,处理完某个节点的左线索后继续往左走,就可能把线索当作左孩子再一次入栈。这个问题容易在实现“非递归中序线索化”时碰到,我的建议是:先老老实实写递归版本,跑通再考虑改成非递归。
7.3 节点查找和删除操作中的坑
线索二叉树本质上是静态构建的结构。它适合反复做遍历和查找,但如果你频繁插入、删除节点,维护线索的成本会非常高昂。因为每插入或删除一个节点,树中受影响的那一段中序序列的线索都要重新调整,这比普通二叉树只调整局部父子关系要复杂得多。
所以,在我实际用过的场景中,线索二叉树更多用于结构基本不变、查询频繁的场景,比如表达式树的中序输出、语法分析树的中序遍历缓存等。如果是动态操作特别多的业务,优先考虑普通二叉树或者干脆换平衡树结构。这个选型思考有时候比实现本身更重要。
7.4 线索化之后如何还原成普通二叉树
有些场景下你在线索化之后还想使用原来的递归遍历,比如后序遍历或层次遍历。这时候就必须把线索信息清理掉。好在逻辑也很简单:遍历所有节点,把 leftTag 是 Thread 的节点的 left 指针置为 NULL,把 rightTag 是 Thread 的节点的 right 指针视需求置为 NULL 或者保留后继信息;然后将 tag 改回 Link。这个过程并不会破坏原树的孩子关系,因为真正指向孩子的指针对应的 tag 本来就是 Link,不会被误伤。
如果你暂时不想清除线索又想用普通递归方法访问,那大概率会出问题,因为递归函数递归左孩子时可能顺着线索走到了祖先节点,造成类似环路的访问。这点务必留意。
8. 线索二叉树的工程应用与扩展思考
8.1 实际应用场景
线索二叉树看起来理论学习味道很重,但在几个真实场景里确实有不可替代的价值。
第一个场景是数据库或编译器里的语法树遍历。语法树一旦构建完成,基本不再修改,但需要反复按特定顺序输出节点或查找指定位置节点。线索化之后,遍历不用递归栈,对性能敏感的前端工具很有意义。
第二个场景是表达式求值。比如把中缀表达式解析成表达式树之后,中序遍历可以直接得到原表达式;若对每个叶子节点做连续访问或比较相邻节点,线索化的优势就体现出来了。
第三个场景是树形结构序列化与反序列化。线索本身可以携带中序信息,重建时结合前序或后序序列能更快地恢复树的完整结构。
如果你在学习阶段,把中序线索树做完之后,建议再试一次前序线索树,体会两者写代码时“时机不同导致复杂度不同”的微妙差异。这个对比能加深对递归时机和数据结构的理解,比单纯多刷几道题收获大得多。
8.2 与其他遍历方案对比
普通二叉树加栈做非递归遍历,虽然不需要递归调用,但需要显式维护一个栈,空间复杂度最坏是 O(n)。线索二叉树把空间换成了线性结构的指针预留,遍历时的额外空间接近 O(1)。对于深度极大的树——比如右单枝树——普通递归遍历可能直接爆栈,线索树不会有这个问题,因为遍历过程根本不需要保存返回路径。
当然线索二叉树也有代价:每个节点至少增加两个 tag 字段,如果二叉树节点数非常多,内存开销会上升。但通常 tag 可以用一个字节甚至一个 bit 表示,所以实际开销远小于增加指针。用位域或者枚举压缩一下,整体内存控制得相当好。
8.3 后续可以继续深入的方向
如果你对树形结构感兴趣,线索二叉树的思路还可以延展到其他场景。比如 B+ 树的叶子节点之间用指针串成链表,本质上就是“层序线索”的思想;跳表在多层链表之间建立加速指针,思想跟线索化也有异曲同工之处。理解线索化,你会更容易理解这些工程化数据结构——它们在索引结构上添加了额外的顺序访问通道,大幅度优化范围查询和顺序遍历的效率。
从考试角度来说,掌握“中序线索化找前驱/后继”的操作题是最重要的,代码题一般也围绕中序出题。前序和后序更多是理解原理层面的差异。从工程角度来说,线索二叉树本身的直接使用频率不算高,但它背后的设计思路——利用空余空间维护额外顺序信息——在系统设计里非常常见,值得好好品味。
9. 代码实现全汇总:可以直接抄作业的版本
这里给出一份完整可运行的 C 代码,实现普通二叉树创建、中序线索化、中序线索遍历、找中序后继,以及前序线索化和遍历。代码不含头节点版本,逻辑清楚,测试方便。
c复制#include <stdio.h>
#include <stdlib.h>
typedef enum { Link, Thread } PointerTag;
typedef struct ThreadNode {
int data;
struct ThreadNode *left, *right;
PointerTag leftTag, rightTag;
} ThreadNode, *ThreadTree;
// 按先序创建二叉树,输入 -1 表示空节点
ThreadNode *createTree() {
int val;
scanf("%d", &val);
if (val == -1) {
return NULL;
}
ThreadNode *node = (ThreadNode *)malloc(sizeof(ThreadNode));
node->data = val;
node->leftTag = Link;
node->rightTag = Link;
node->left = createTree();
node->right = createTree();
return node;
}
// 中序线索化递归函数
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);
}
void inOrderThreading(ThreadTree &root) {
ThreadNode *pre = NULL;
inThread(root, pre);
if (pre != NULL) {
pre->right = NULL;
pre->rightTag = Thread;
}
}
// 找到中序线索树中的第一个节点
ThreadNode *firstNode(ThreadNode *node) {
while (node != NULL && node->leftTag == Link) {
node = node->left;
}
return node;
}
// 找到中序线索树中某个节点的后继节点
ThreadNode *nextNode(ThreadNode *node) {
if (node->rightTag == Thread) {
return node->right;
}
return firstNode(node->right);
}
// 中序线索遍历
void inOrderTraverse(ThreadTree root) {
ThreadNode *node = firstNode(root);
while (node != NULL) {
printf("%d ", node->data);
node = nextNode(node);
}
printf("\n");
}
// 前序线索化递归函数
void preThread(ThreadNode *node, ThreadNode *&pre) {
if (node == NULL) return;
if (node->left == NULL) {
node->left = pre;
node->leftTag = Thread;
}
if (pre != NULL && pre->right == NULL) {
pre->right = node;
pre->rightTag = Thread;
}
pre = node;
if (node->leftTag == Link) {
preThread(node->left, pre);
}
if (node->rightTag == Link) {
preThread(node->right, pre);
}
}
void preOrderThreading(ThreadTree &root) {
ThreadNode *pre = NULL;
preThread(root, pre);
}
// 前序线索遍历
void preOrderTraverse(ThreadTree root) {
ThreadNode *node = root;
while (node != NULL) {
printf("%d ", node->data);
if (node->leftTag == Link) {
node = node->left;
} else if (node->rightTag == Link) {
node = node->right;
} else {
// 左右都是线索时,需要向上回溯,这里简化为跳到右线索指向的节点
// 注意:完整逻辑依赖父指针或辅助栈,这里演示最简场景
node = node->right;
}
}
printf("\n");
}
int main() {
ThreadTree root = createTree();
inOrderThreading(root);
printf("中序线索遍历: ");
inOrderTraverse(root);
// 注意:同一棵树不能同时做中序线索化和前序线索化,需要重新建树
return 0;
}
上面代码中,前序线索遍历在最简单设计下并不完全通用,因为当某个节点左右都变成线索时,要准确找到前序后继,必须向上回溯找第一个存在右子树的祖先,这需要栈或父指针。我在代码注释里已经指明这一点,避免给你一个表面上能用、实际坏掉的假实现。如果你要完整支持前序线索遍历,我建议在节点中加入 parent 指针,把它升级为三叉链表之后再做。
这段代码可以直接编译运行,测试时按先序序列输入一棵树,比如输入:1 2 4 -1 -1 5 -1 -1 3 -1 -1,就对应前面手动模拟的那棵树。运行后会输出 4 2 5 1 3,和中序遍历结果一致,说明线索化正确。
10. 写在最后的实操建议
线索二叉树是那种“看视频觉得很简单,一写代码就卡住”的知识点。卡住的地方往往不是递归本身,而是你不知道在哪个时机去修改 pre 的右指针。我的建议是:一定要先在纸上画一棵树,然后对照中序遍历的顺序,把每个节点的前驱和后继都标出来,再动手写代码。你只要把“pre 是上一个被访问的节点”这层关系彻底想透,代码基本能一遍写对。
调试时可以把访问节点和更新 pre 的语句都加打印信息,观察 pre 的变化是否和中序遍历顺序一致。这个技巧能帮你省下大量排查时间。考试或者面试前,重点复习中序线索化找前驱和后继的规则以及代码框架,前序和后序理解差异即可。
还有个小经验:如果你在做题时遇到“在已线索化的树上删除一个叶子节点”这种题,别急着写代码,先想清楚删除之后,前驱和后继指针应该怎么重新连接。线索二叉树在动态修改上的复杂度确实高,理解这个代价之后,你对数据结构选型的判断力也会明显提升。
