
做数据结构的同学一定对二叉树的遍历不陌生但很多人写递归遍历时都没注意到一个问题一棵 n 个节点的二叉树用链式存储要开 2n 个指针域真正存了孩子地址的只有 n-1 个剩下 n1 个指针全是 NULL。数据量小的时候没啥感觉一旦树的规模上去这些空指针占的内存就是实打实的浪费。线索二叉树干的事情就是把这 n1 个空指针利用起来让它们指向遍历序列里的前驱和后继节点。这篇博客我会用 C 和 Java 各写一份完整实现把中序线索化的原理、代码、遍历方式、测试验证流程全部讲透。适合写过二叉树递归遍历、想进阶理解线索化机制的读者也适合正在准备数据结构面试的人。在展开代码之前我想先聊聊为什么要做线索化这个问题。很多人学线索二叉树是为了应付考试背了一套算法就过了但实际工程里我确实遇到过需要频繁访问某个节点前驱后继的场景——比如在语法树里做语义分析或者维护一个有序的中序序列做插入删除操作。如果每次找前驱后继都从根节点重新遍历时间复杂度直接拉到 O(n)在树很高很频繁的场景下是扛不住的。线索化之后找前驱和后继的均摊成本降到了 O(1)这个收益是实打实的。1. 为什么需要线索二叉树被浪费的指针和反复重跑的遍历传统二叉树节点只有左右孩子两个指针遍历全靠递归或者显式栈。你要是想在中序序列里找一个节点的前驱就是中序遍历时它前面那个节点最朴素的做法是从根节点重新走一遍完整的中序遍历中途记录上一个访问的节点是谁直到遇到目标节点。这个做法的缺点很明显每一次查找前驱或者后继都要把整棵树重新遍历一遍时间复杂度 O(n)而 n 很大的时候这个代价是无法接受的。还有另一个容易被忽略的问题空指针的内存浪费。一个 n 个节点的二叉树每个节点有 2 个指针域共 2n 个。n 个节点的树恰好有 n-1 条边也就是只有 n-1 个指针真正指向了孩子节点其余 n1 个指针都是 NULL。这 n1 个空指针如果不利用起来就纯粹是内存里的空洞。线索化做的事情特别朴素对于某个节点如果它的左孩子为空就让左指针指向前驱如果右孩子为空就让右指针指向后继。这样一来空指针被填充上了实际有用的信息而在遍历时遇到线索指针可以直接跳转不需要重新递归。听起来简单但实现起来有几个关键细节非常容易踩坑后面我会一个个讲。为了区分一个指针到底存的是孩子地址还是前驱/后继线索每个节点上需要两个额外的标志位ltag 和 rtag。约定 0 表示指针指向的是真实孩子1 表示指向的是线索。这也是线索二叉树节点相比普通二叉树节点多出来的唯一成本。2. 线索化原理tag 标志位与 pre 指针的配合逻辑线索化的核心思路可以用一句话概括在中序遍历的递归过程中用两个指针一前一后地扫描整棵树。当前访问的节点记为 p刚刚访问过的节点记为 pre那么在中序序列里pre 就是 p 的前驱p 就是 pre 的后继。这个pre 正好是 p 的前驱不是碰巧而是由中序遍历的顺序决定的。中序遍历的访问顺序是左子树、根节点、右子树。当递归函数处理完 p 的左子树、即将处理 p 本身的时候左子树里的最后一个被访问的节点恰好就是中序序列中 p 前面那个节点。这个节点保存在 pre 里。所以处理 p 的时候如果 p 左孩子为空就让 p-lchild 指向 pre同时把 ltag 置为 1如果 pre 的右孩子为空就让 pre-rchild 指向 p把 rtag 置为 1。整个过程中最重要的就是那句话pre 指针的更新时机。pre 必须是在每次访问完一个节点之后才跟着移动而不是在处理孩子的过程中随意更新。很多初学者写出来的线索化代码乱成一团基本都是 pre 更新时机不对导致的。中序线索化最经典因为中序遍历的结果是一个有序序列对于二叉搜索树来说线索化之后可以双向遍历既能找前驱也能找后继。前序线索化和后序线索化虽然也能做但前序线索化找前驱非常麻烦后序线索化找后继非常麻烦都需要额外的父节点信息工程上用得少。所以这篇博客以中序线索化为主线。先看普通二叉树节点到线索二叉树节点的结构变化。普通节点只有 data、lchild、rchild线索化之后多了 ltag 和 rtag。加上这两个标志位的根本原因是程序需要区分一个指针是孩子还是线索否则遍历的时候会陷入死循环——你把前驱指针当成左孩子继续往下递归就永远走不到头了。3. C 语言实现从结构体定义到中序线索化完整代码C 语言版本最能反映线索化的底层操作因为指针操作都是显式的。先定义结构体#include stdio.h #include stdlib.h typedef enum { Link 0, Thread 1 } PointerTag; typedef struct ThreadNode { char data; struct ThreadNode *lchild, *rchild; PointerTag ltag, rtag; } ThreadNode, *ThreadTree;这里用枚举类型 PointerTag 定义 Link 和 ThreadLink 表示孩子指针Thread 表示线索指针。枚举的底层是整数所以 Link 就是 0、Thread 就是 1可以直接用来判断。用枚举比直接用 0/1 可读性好得多。接下来是核心的中序线索化递归函数ThreadNode *pre NULL; // 全局变量指向刚刚访问过的节点 void InThread(ThreadTree p) { if (p ! NULL) { InThread(p-lchild); // 处理当前节点 p 的左指针如果左孩子为空指向前驱 pre if (p-lchild NULL) { p-lchild pre; p-ltag Thread; } // 处理前驱节点 pre 的右指针如果右孩子为空指向当前节点 p if (pre ! NULL pre-rchild NULL) { pre-rchild p; pre-rtag Thread; } pre p; // 更新 pre 为当前节点 InThread(p-rchild); } } void CreateInThread(ThreadTree T) { pre NULL; if (T ! NULL) { InThread(T); // 中序遍历最后一个节点的右指针一定为空需要单独置线索 if (pre-rchild NULL) { pre-rtag Thread; } } }这段代码有几个地方需要重点理解。第一个关键点是处理 p 左指针时不需要判断 pre 是否为 NULL。因为第一个被访问的节点中序最左节点的左孩子为空此时 pre 是 NULL它的前驱不存在左指针置为 NULL 合情合理。第二个关键点是处理 pre 右指针时要判断 pre 是否为空。pre 为 NULL 说明当前 p 是中序第一个节点此时还没有前驱存在自然不能对 pre 解引用。第三个关键点是递归结束之后pre 指向的一定是中序遍历的最后一个节点最右节点。这个节点的右孩子为空但没有后续节点来触发pre-rchild 下一个节点这个操作所以线索化完成后需要单独把 pre 的右指针置为线索指向 NULL。我当初学这段代码时最大的困惑是为什么在递归函数里处理 pre 的右指针而不是等全部递归结束了再处理答案在于pre 的右线索是在遇到下一个访问节点时才知道该指向谁而这个下一个节点只有在递归继续深入时才会出现。所以每次访问完当前节点、更新 pre 之后等到访问下一个节点时自然会把 pre 的右指针补上。递归的层序天然保证了这一点。为了验证线索化是否正确写一个找中序后继的函数和最朴素的中序遍历// 求以 p 为根节点的子树中中序序列的第一个节点 ThreadNode *FirstNode(ThreadNode *p) { while (p-ltag Link) { p p-lchild; } return p; } // 求节点 p 在中序序列中的后继节点 ThreadNode *NextNode(ThreadNode *p) { if (p-rtag Thread) { return p-rchild; } return FirstNode(p-rchild); } // 利用线索进行中序遍历不需要递归和栈 void InOrder(ThreadTree T) { for (ThreadNode *p FirstNode(T); p ! NULL; p NextNode(p)) { printf(%c , p-data); } printf(\n); }注意 NextNode 的逻辑如果右指针是线索直接返回右孩子实际上是后继如果右指针是真实孩子说明当前节点右子树非空那么后继就是右子树中第一个被中序访问的节点也就是从右孩子开始一路向左走到头。这个一路向左就是 FirstNode 干的事情。这套代码的时间复杂度是 O(n)因为每个节点最多被访问两次一次是通过真实的孩子指针进入一次是通过线索跳转。相比普通递归遍历少了递归栈的调用开销在树比较深的情况下也能避免栈溢出的风险——这个优势在极端不平衡的树上特别明显。4. Java 版本实现与 C 语言的关键差异Java 实现和 C 语言版本在核心思想上完全一致但有几个差异必须注意否则很容易踩坑。Java 的节点类定义public class ThreadedBinaryTree { private static class Node { char data; Node left, right; boolean leftThread, rightThread; // true 表示该指针为线索false 表示为孩子 Node(char data) { this.data data; left right null; leftThread rightThread false; } } private Node root; private Node pre; // 相当于 C 语言版本的全局变量 pre public ThreadedBinaryTree(Node root) { this.root root; } public void createInThread() { pre null; if (root ! null) { inThread(root); if (pre.right null) { pre.rightThread true; } } } private void inThread(Node p) { if (p ! null) { inThread(p.left); if (p.left null) { p.left pre; p.leftThread true; } if (pre ! null pre.right null) { pre.right p; pre.rightThread true; } pre p; inThread(p.right); } } }第一个差异是标志位的数据类型。C 语言用枚举 PointerTagJava 里直接用了两个 boolean。boolean 的语义更清晰true 表示这个指针是线索false 表示这个指针是孩子。不过需要注意boolean 默认值是 false刚好对应 Link孩子指针所以节点创建时不需要额外初始化这个默认行为在很多场景下省了不少事。第二个差异是 pre 的存储方式。C 语言用了全局变量Java 则用了实例字段。这里我要特别提醒一个 Java 新手很容易掉进去的坑不要试图用方法参数传递 pre 来保存状态。Java 是值传递你传一个 Node 引用进去在递归函数内部修改 pre 指向这个修改不会反映到外层调用者的变量上。换句话说你写inThread(p.left, pre)递归返回后外层函数的 pre 还是原来的值线索化一定会出错。正确的做法是把它放到类字段里或者用数组包装Node[] preArr new Node[1]。类字段最直观但要注意同一棵树实例的多次线索化调用之间要重置 pre。第三个差异是空指针判断。C 语言里写p-lchild NULLJava 里写p.left null本质一样但 Java 里如果 pre 为 null 时访问 pre.right会直接抛 NullPointerException。所以必须严格保持判断顺序先判 pre ! null再判 pre.right null。Java 版本的遍历代码private Node firstNode(Node p) { while (p.leftThread false) { p p.left; } return p; } private Node nextNode(Node p) { if (p.rightThread true) { return p.right; } return firstNode(p.right); } public void inOrder() { if (root null) { return; } System.out.print(InOrder: ); for (Node p firstNode(root); p ! null; p nextNode(p)) { System.out.print(p.data ); } System.out.println(); }这个遍历逻辑和 C 语言一模一样。我实际跑过一棵六个节点的测试树输出完全正确。Java 版的代码写起来比 C 简洁一些因为不用管内存释放但正因为不用管内存释放很多人反而不去思考指针到底指向了哪里导致线索化逻辑出错时排查起来更困难。5. 遍历线索二叉树利用线索找后继的两种写法遍历线索二叉树有两种典型写法一种是不带头节点的版本一种是带头节点的版本。前面代码里给出的是不带头节点的版本这里重点讲带头节点的版本因为它在实际代码设计里更优雅也更容易处理第一和最后一个节点的边界情况。带头节点的思路是额外创建一个头节点 head让 head 的左指针指向二叉树的根节点head 的右指针初始指向自己。中序遍历时第一个节点的前驱指向 head最后一个节点的后继也指向 head。这样整棵树就变成了一个双向循环结构遍历代码里判断循环终止条件只需要判断 p ! head 就行不需要每次判断 p 是否为 NULL。带头节点的线索化代码void CreateInThreadWithHead(ThreadTree T, ThreadNode *head) { // 头节点初始化 head-ltag Link; head-rtag Thread; head-rchild head; // 右指针先指向自己 if (T NULL) { head-lchild head; // 空树时左指针也指向自己 return; } head-lchild T; pre head; // 从 head 开始第一个节点的前驱就是 head InThread(T); // 线索化完成后pre 指向中序最后一个节点 pre-rchild head; pre-rtag Thread; head-rchild pre; // 头节点的右指针指向中序最后一个节点 }注意这里的 InThread 还是第 3 节那个函数但 pre 初始值是 head 而不是 NULL所以第一个节点中序最左节点的左线索会指向 head而不是 NULL。遍历的起点也变了要从 head 的左指针找到根节点再从根节点找第一个节点void InOrderWithHead(ThreadNode *head) { for (ThreadNode *p FirstNode(head-lchild); p ! head; p NextNode(p)) { printf(%c , p-data); } printf(\n); }循环条件是 p ! head遍历最后一个节点之后NextNode 返回 head循环结束。这个设计把遍历结束和后继为 NULL两个问题统一成了遇到头节点代码可读性高了不少。我个人的建议是日常练习用不带头节点的版本就够理解起来直接但如果你要在项目里封装一个线索二叉树类带头节点会省很多边界判断特别是在实现从最后一个节点向前遍历这种操作时头节点能天然地作为双向链表里的哨兵节点用。这里还要补一个很多人忽略的细节带头节点版本里中序最后一个节点和头节点之间的连接是在 InThread 函数返回后手动建立的而不是在递归函数内部完成的。原因是递归函数内部当 pre 移动到最后一个节点时这个节点的右孩子为空按逻辑应该让 pre-rchild 指向 head但递归函数并不知道 head 的存在。所以在 CreateInThreadWithHead 里做完 InThread 之后再补这一步正好对应了最后一个节点右指针的收尾工作。6. 测试用例设计与调试如何证明前驱后继全对光把代码写出来不算完我每次写完这种指针操作的重度代码都会构造具体的测试数据把每个节点的前驱和后继用手工推一遍再跟程序输出逐项比对。这里用一棵六个节点的二叉树来演示完整的验证过程。树的形态A / \ B C / \ \ D E F注意 C 只有右孩子 F没有左孩子。中序遍历这棵树的结果是D B E A C F。线索化之后每个节点的左指针和右指针指向如下不含头节点版本节点左孩子右孩子左指针实际指向右指针实际指向ltagrtagD无无NULLBThreadThreadBDEDELinkLinkE无无BAThreadThreadABCBCLinkLinkC无FAFThreadLinkF无无CNULLThreadThread这张表值得仔细看几遍。A 的左孩子是 B所以 A 的左指针存的是 B 这个真实孩子ltag 是 Link虽然 A 在中序序列里的前驱是 E但左指针并没有存 E。真正存前驱线索的是那些左孩子为空的节点比如 E 的左孩子为空所以 E 的左指针存了它的前驱 B。同理 C 的左孩子为空所以 C 的左指针存了它的前驱 A。程序断言验证法是最靠谱的。C 语言里可以用 assert 宏Java 里可以用 assert 关键字或者手动抛异常。比如验证 D 的后继是 Bassert(NextNode(D) B); assert(NextNode(E) A);实际调试时还有一个非常实用的技巧在 InThread 函数里临时加打印输出每次访问的节点 p 和 pre 的 data 值。比如printf(visit %c, pre %c, p.ltag %d, p.rtag %d\n, p-data, pre ? pre-data : #, p-ltag, p-rtag);中序线索化的访问顺序是 D、B、E、A、C、F所以输出里 pre 依次是 #、D、B、E、A、C刚好对应前驱关系。如果某个节点的 pre 和预期不一致那基本可以断定是递归顺序出了问题或者树本身建错了。一个经典的错误场景是建树时 B 的右孩子写成了 NULL 而不是 E但你又让 E 单独存在结果中序遍历变成了 D B A C F 而不是 D B E A C F。这种树结构错误导致的假线索化成功最坑人因为代码逻辑没错错的是输入数据。所以我每次建完测试树都会先用普通中序遍历打印一遍确认序列符合预期再去做线索化验证。7. 线索二叉树的应用边界什么时候该用它什么时候别用聊完实现说点工程上的判断。线索二叉树在面试中属于高频考点因为它能把中序遍历的递归过程指针的使用空间复杂度分析一锅端考了。实际工程里它的应用场景其实比较垂直这恰恰是它的价值所在。最适合用线索二叉树的场景有两个特征第一树的结构基本固定插入删除很少第二你需要频繁地在中序遍历序列里查找某个节点的前驱或后继。典型的例子包括语法分析树里做符号表管理、文本编辑器里维护的行索引结构、以及一些需要双向遍历的树形缓存结构。这些场景里线索化的一次性构建成本O(n)摊薄到大量前驱/后继查询里非常划算。反过来如果你的树经常插入删除节点线索二叉树的维护成本就很高了。每次插入或删除都要重新调整相关节点的线索这个操作的复杂度虽然也是 O(h)但因为它涉及指针方向的判断和 tag 的更新实际写起来比普通二叉搜索树的插入删除要繁琐得多。这种场景下普通二叉树加递归遍历或者直接用数组存储反而是更务实的选择。还有一个容易被低估的问题内存占用。线索二叉树省掉了 n1 个空指针但每个节点多了 ltag 和 rtag 两个标志位。在 C 语言里如果结构体按字节对齐两个枚举或 int 类型可能让每个节点从 16 字节涨到 32 字节省下的指针空间反而被对齐填充吃掉了。如果只用 char 或位域才能实际省内存。Java 里 boolean 单个节点占 1 字节但 JVM 对象头和各字段对齐的开销更大所以线索化更多是用两个布尔标志换遍历效率内存收益在 Java 里基本可以忽略真正的价值在于消除了递归调用栈。三种线索化的选型建议中序线索化最实用因为它能同时支持高效的前驱和后继查找而且中序序列对二叉搜索树来说恰好是有序序列应用场景最广。前序线索化适合只需要快速遍历前序序列的场景但找某个节点的前序前驱很麻烦——需要知道父节点工程上得用三叉链表才方便。后序线索化的实用性最低找后继的复杂度很高能不用尽量不用。我个人在实际项目里的体会是线索二叉树这个概念真正的价值不在省那一点内存而是提供了一种让数据结构自己记住访问上下文的思路。跟跳表维护多层索引、LRU 缓存用哈希表加双向链表一样都是用一点额外的指针信息换取关键操作的常数级加速。这种思路在系统设计里比比皆是理解了线索化再看很多经典缓存和数据结构的内部实现会顺畅不少。最后分享一个小技巧如果你在面试里被问到底层原理不要只是背结论把手工模拟线索化的过程画出来。画一个六节点的树从递归的第一个节点开始逐步画出 pre 指针的移动轨迹和每个空指针的赋值方向。能把这张图表画明白面试官对你的数据结构功底基本就有数了。代码可以忘但这个思维过程值得记一辈子。