资讯动态

后序线索二叉树详解:原理、C语言实现与遍历查找技巧

发布时间:2026/10/15 19:56:39 来源:尧图企业网站定制
很多朋友学数据结构到二叉树这里递归前序中序后序写得飞起可一碰到“线索二叉树”就有点犯迷糊尤其是再往后序一靠直接心态爆炸。这个现象太常见了我当年学到这里也一样课本上中序线索讲得还算清楚后序线索几句话带过结果考试和做项目一用就露馅。线索二叉树其实是利用二叉树里大量空指针做文章的一种优化手段而后序线索化因为“找后继”的规则天然绕弯子成了很多人的死穴。这篇文章我会把后序线索二叉树从头到尾掰开揉碎讲清楚适合正在学数据结构的学生、准备面试的开发者以及那些想真正搞懂二叉树底层机制的人。内容会包含完整的C语言实现、手动跑流程的逐步演示还有我在实际调试中踩过的一堆坑希望能帮你省下不少时间。1. 先搞懂线索二叉树到底解决了什么问题1.1 二叉树遍历后的“空指针浪费”问题先看一个基础事实一个有 n 个节点的二叉树每个节点都有左孩子指针和右孩子指针所以一共是 2n 个指针域。但每个节点除了根节点只有一个父节点也就是真实连接子树的边只有 n-1 条那么 2n 个指针里真正被用来指向孩子节点的只有 n-1 个。剩下的 n1 个指针全是空的要么是叶子节点的左右孩子要么是单分支节点缺失的那一侧。这就意味着一棵 100 个节点的二叉树有 101 个指针是空着的白白占了将近一半的内存。如果节点结构体里用的还是 8 字节指针那就是不少浪费。而线索二叉树的核心思路就是拿这些空指针去存储遍历序列中的“前驱”和“后继”信息。你可以这样理解本来我们要找某个节点在中序遍历里的前一个或后一个节点得重新从根出发做一次遍历时间复杂度 O(n)。但如果把这些空指针利用起来直接记好每个节点前一个是谁、后一个是谁那么找前驱后继的时间就能降到 O(1)。这就是典型的“用空间换时间”的思路而且这里连额外的空间都不怎么需要只是把废指针二次利用属于非常划算的优化。1.2 线索化的本质用标志位重新定义孩子指针问题来了一个指针域原本是用来指向左孩子或者右孩子的现在又要用来指向前驱或者后继那程序怎么区分这个指针到底存的是什么靠的就是额外的标志位。通常的做法是给每个节点增加两个布尔字段有的资料里叫 ltag 和 rtagltag 为 0 时lchild 指向左孩子ltag 为 1 时lchild 指向该节点在某种遍历序列中的前驱。rtag 为 0 时rchild 指向右孩子rtag 为 1 时rchild 指向该节点在某种遍历序列中的后继。这就是“线索”的含义。用 ltag 和 rtag 来标记指针的性质而不是直接靠指针是否为空判断这是整个线索二叉树最关键的认知转折。很多初学者在这里栽跟头是因为他们潜意识里一直觉得“指针不为空就是孩子”但线索化之后这个直觉不成立了你必须养成先看标志位再决定怎么用指针的习惯。另外还要明确一点线索化是针对某一种遍历顺序而言的同一个二叉树按中序线索化和按后序线索化得到的线索内容完全不一样。中序线索化存的是中序遍历的前驱后继后序线索化存的是后序遍历的前驱后继。所以说什么“一棵线索二叉树”其实不严谨应该说“一棵按中序线索化的二叉树”或者“一棵按后序线索化的二叉树”。2. 数据结构怎么设计最顺手2.1 节点结构体的定义与“三叉链”的必要性如果你只用两种标志位加上左右孩子指针这样的节点结构是typedef struct ThreadNode { int data; struct ThreadNode *lchild, *rchild; int ltag, rtag; // 0 表示孩子1 表示线索 } ThreadNode;这种结构做中序线索化完全够用因为在中序线索树里找某个节点的后继只要判断 rtag如果 rtag 为 1那 rchild 就是后继如果 rtag 为 0那就得找 rchild 这个右子树里最左下的节点。整个过程不需要知道父节点是谁。但到了后序线索化这里情况就不一样了。后序遍历的顺序是“左子树、右子树、根”所以后序序列中根是最后一个被访问的而这种“左右根”的顺序导致一个节点很难从它自身的信息推断出后继是谁最典型的例子如果某个节点是父节点的右孩子它的后序后继就是父节点可是这个父节点并不存在于该节点的左右指针里。因此后序线索二叉树通常要额外加一个 parent 指针变成三叉链表typedef struct ThreadNode { int data; struct ThreadNode *lchild, *rchild, *parent; int ltag, rtag; } ThreadNode;加了 parent 后节点才能从自身“往上走”。这也是后序线索化有别于其他两种线索化的硬性要求。你可以这么记中序和前序线索化靠“往下钻”就能解决问题后序线索化很多时候得“往上爬”所以必须知道爸爸是谁。2.2 为什么建议先吃透中序线索化很多教材的顺序是“中序线索化讲得很细前序后序一句话带过”这其实是有道理的。中序线索化的逻辑最直观遍历序列是“左根右”在递归访问完左子树之后访问根之前当前的 pre上一个访问的节点和当前节点之间的关系是最自然的前驱后继关系。而且中序线索树里从任一节点出发找后继规则非常统一要么右指针就是线索要么去右子树的最左下角。这给了你一个很好的参照基准。等你把中序的 pre 工作机制理解透了再看后序线索化的代码会发现前半段几乎一模一样——都是递归处理左子树、递归处理右子树、然后在“访问当前节点”这一步做指针的设置。区别只在于后序需要在最后额外考虑 parent 相关的逻辑以及找后继时不能用中序那套“右子树最左下”的思路。所以我的建议是你先照着中序写一遍、跑通、画图验证再来看后序思路会顺很多。2.3 那个贯穿全程的 pre 变量无论中序还是后序线索化实现的核心都是要维护一个“上一个访问的节点”指针一般命名为 pre。线索化的过程其实就是模拟一次完整的遍历在遍历过程中对于每个节点判断当前节点的左孩子是否为空如果为空就把左孩子指针指向 pre并置 ltag 为 1表示这里存的是前驱线索。判断 pre 的右孩子是否为空如果为空就把 pre 的右孩子指针指向当前节点并置 pre-rtag 为 1表示这里存的是后继线索。这个操作非常容易漏因为它是站在“上一个节点”的角度去设置线索而不是站在当前节点。最后更新 pre 为当前节点继续遍历。上面这个逻辑是统一套路后序线索化基本也是这几步唯一的区别是递归顺序变成了“左、右、根”。这也是后序线索化代码看起来和中序线索化很像、但其实细节完全不同的原因。3. 后序线索化的核心函数实现3.1 完整代码与逐段解读我直接给一份可以跑起来的后序线索化代码用的是前面带 parent 的三叉链结构void PostThread(ThreadNode *p) { if (p NULL) { return; } // 先线索化左子树 PostThread(p-lchild); // 再线索化右子树 PostThread(p-rchild); // 访问当前节点 p也就是后序“根”这一步 // 当前节点的左孩子为空则将左孩子指针指向前驱 pre if (p-lchild NULL) { p-lchild pre; p-ltag 1; } // pre 不为空且 pre 的右孩子为空则将 pre 的右孩子指向当前节点 p // 当前节点 p 就是 pre 的后继 if (pre ! NULL pre-rchild NULL) { pre-rchild p; pre-rtag 1; } // 更新 pre pre p; }调用方式pre NULL; // 全局或外部变量 PostThread(root); // 注意遍历结束后 pre 指向根节点因为后序序列最后一个节点就是根 // 根节点的右孩子线索应指向 NULL因为根节点没有后继 if (pre-rchild NULL) { pre-rtag 1; }逐段看你会发现线索化的核心代码和中序几乎是一样的区别在递归调用的顺序。中序是“左、访问、右”这里的访问逻辑夹在中间后序是“左、右、访问”访问逻辑挪到了最后面。有个细节要注意递归到某个节点时它的左右子树可能已经被部分线索化了也就是说 p-lchild 和 p-rchild 指的不一定是真孩子也可能是线索。所以在代码里我们判断 p-lchild NULL 来决定是否设置左线索而不是先判断 ltag。因为如果左指针已经指向某个真实左孩子它就不为 NULL就不会被误设为线索。3.2 手动跑一遍三节点二叉树的后序线索化为了把过程彻底讲透我拿一棵三个节点的二叉树来手动跑一遍。假设结构如下A / \ B C后序遍历顺序是B、C、A。也就是说后序序列应为 B → C → A。初始状态pre NULL所有节点的 ltag、rtag 都是 0B 和 C 的左右孩子指针都是 NULLA 的左右孩子分别是 B 和 C。递归过程是这样的调用 PostThread(A)因为 A 不为空先递归 PostThread(A-lchild) 也就是 PostThread(B)。处理 BB 的左孩子为空执行 p-lchild pre也就是 NULLp-ltag 1。接着判断 pre 不为空pre 还是 NULL跳过。pre B。回到 A 的调用继续递归 PostThread(A-rchild)也就是 PostThread(C)。处理 CC 的左孩子为空执行 p-lchild pre也就是 Bp-ltag 1。这样 C 的前驱线索就指向了 B这和后序序列里 C 的前驱确实是 B 吻合。再判断 pre 不为空且 pre-rchild 为空pre 是 BB 的右孩子确实为空于是把 B 的右孩子指向 CB-rtag 1。这样 B 的后继线索也指向了 C。最后 pre C。回到 A 的调用现在访问 AA 的左孩子不为空是 B所以不设置左线索。pre 是 CC 的右孩子为空所以把 C 的右孩子指向 AC-rtag 1。这样 C 的后继线索指向了 A和后序序列 C → A 吻合。最后 pre A。整个过程结束后线索设置如下C 的左线索指向前驱 B。B 的右线索指向后继 C。C 的右线索指向后继 A。这时候你会发现一个很有意思的现象根节点 A 的左孩子和右孩子都是真实的所以 A 本身没有线索。而整个后序序列 B → C → A 的每一个相邻关系正好都通过线索连接起来了。3.3 后序线索化时访问孩子的注意事项后序线索化写起来简单但里面有一个特别隐蔽的坑你在递归遍历的过程中可能在当前节点的左右子树都已经处理完、并且已经部分线索化的情况下还要继续访问当前节点的左孩子或右孩子。举个例子按照后序的顺序当递归调用到某个节点 p 时程序已经完整处理了 p 的左子树和右子树此时 p 的左右孩子指针是真实的孩子还是线索这个得分情况看。如果 p 的右孩子为空在遍历右子树时PostThread(NULL) 直接返回并不会给 p 的右指针设置任何东西所以当回溯到 p 时p-rchild 仍然为空那就可以安全地用它来判断。但如果你在代码里写的是if (p-lchild NULL) ...这个判断在大部分情况下是安全的因为 p 的左孩子在递归 PostThread(p-lchild) 时如果是一个真实的左孩子p-lchild 不会被修改如果左孩子为 NULL那递归调用本身就是 PostThread(NULL)也不会修改 p-lchild。真正危险的反而是你在“访问”之后再拿 p-lchild 或 p-rchild 当孩子去递归那就会把线索当孩子用。所以记住一条铁律所有对左孩子、右孩子的递归调用必须在对应指针还没被设置成线索之前完成。后序的顺序天然满足这一点因为在访问也就是设置线索前左右子树都已经处理完了。但是如果你在中序线索化的代码里处理完左子树后再递归左子树或者在线索化完成后再尝试用指针遍历树就必须先检查 ltag 和 rtag否则必然出问题。4. 后序线索二叉树找前驱和后继的完整方案4.1 找前驱右孩子优先左孩子兜底后序线索二叉树里找前驱的规则其实比找后继简单很多。给定一个节点 p要找到后序序列中 p 的前一个节点分两种情况如果 p 有真实的右孩子也就是 p-rtag 0那么 p 的前驱就是 p-rchild。因为后序遍历是“左、右、根”访问完整个右子树之后才会访问 p而右子树中最后被访问的节点就是右子树的根也就是 p 的右孩子。如果 p 没有右孩子也就是 p-rtag 1那么 p 的前驱就是 p 的左孩子。如果 p 也没有左孩子那 p-lchild 存的就是前驱线索直接取用即可。写成代码是这样ThreadNode* PostPreNode(ThreadNode *p) { if (p-rtag 0) { // 有右孩子前驱是右孩子 return p-rchild; } else { // 没有右孩子前驱是左孩子或左线索 return p-lchild; } }注意这个逻辑非常简洁根本不需要 parent 指针。所以如果你只需要频繁地找后序前驱其实用普通二叉链表也能做后序线索化不一定要三叉链。这也印证了前面那句话只有找后继才必须依赖 parent。4.2 找后继必须依赖 parent原因和实现要找后序序列中 p 的后一个节点也就是后继情况就复杂了。先看后序遍历的顺序特点最后一个被访问的节点是根。所以根节点没有后继这一点相对简单。但对于其他节点需要按 p 和父节点的关系来分情况讨论第一种情况p 是根节点则后继为 NULL。第二种情况p 是其父节点的右孩子那么 p 的后继就是父节点。因为后序遍历先处理左子树再处理右子树右子树全部访问完接下来就该访问父节点了。右子树最后访问的是右孩子所以右孩子 p 的后继必然是父节点。第三种情况p 是其父节点的左孩子并且父节点没有右孩子那么 p 的后继也是父节点。因为父节点没有右子树访问完左子树后就直接访问父节点。第四种情况p 是其父节点的左孩子并且父节点有右孩子那么 p 的后继不在父节点身上而是父节点右子树中“后序遍历的第一个节点”。因为左子树访问完之后接下来是右子树右子树后序遍历的第一个节点才是 p 的紧邻后继。这个“右子树后序遍历的第一个节点”怎么找后序遍历从根开始先尽可能往左走直到没有左孩子就往右走重复这个过程直到走到一个既没有左孩子又没有右孩子的节点。注意这里的“没有左孩子/右孩子”指的是真正的孩子为空而不是线索。所以写代码时要用 ltag 和 rtag 来判断。实现如下ThreadNode* FirstNodeInPostOrder(ThreadNode *p) { while (p-ltag 0 || p-rtag 0) { if (p-ltag 0) { p p-lchild; } else { p p-rchild; } } return p; }这个函数的逻辑就是只要有真实的左孩子就往左下钻没有左孩子但有真实的右孩子就往右走直到两边都没有真实的孩子这个节点就是子树后序遍历的第一个节点。综合起来完整找后序后继的函数是ThreadNode* PostNextNode(ThreadNode *p) { if (p-parent NULL) { return NULL; // 根节点无后继 } if (p-parent-rtag 0 p-parent-rchild ! p) { // p 是左孩子且父节点有右孩子 return FirstNodeInPostOrder(p-parent-rchild); } // p 是右孩子或者父节点没有右孩子 return p-parent; }这里写了一个简化判断如果父节点的右孩子存在且不是 p那 p 必然是左孩子返回右子树后序首节点否则返回父节点。4.3 从后序序列的第一个节点开始整体遍历有了找后继的完整逻辑其实已经可以实现从后序第一个节点开始通过后继线索一路走到根的遍历方式。关键在于怎么找到后序序列的第一个节点。后序遍历从根出发规则是“先左后右能左就左不能左就右”。所以整棵树的后序第一个节点就是 FirstNodeInPostOrder(root)。然后就可以循环调用 PostNextNode依次得到整棵树的后序序列void PostOrderTraverseByThread(ThreadNode *root) { ThreadNode *p FirstNodeInPostOrder(root); while (p ! NULL) { printf(%d , p-data); p PostNextNode(p); } }这么一遍遍历下来时间复杂度是 O(n)而且不需要递归和栈。这也是线索二叉树作为遍历优化手段的真正价值。但要特别注意你是从第一个节点开始的不是从根节点开始的。如果你尝试从根节点开始调用 PostNextNode得到的直接是 NULL因为根节点是后序序列最后一个节点它没有后继。这个差异非常容易搞混我身边就有朋友以为后序线索化之后能和前序中序一样直接从根遍历结果一跑就懵。5. 实际使用中的坑与排查方法5.1 用中序思维去找后序后继方向全反很多人第一次写后序后继查找时容易照搬中序的思路有右孩子就去找右子树最左下。在后序线索树里这个逻辑只会让你绕圈。后序序列是左右根如果一个节点有右孩子它的后继根本不是右子树里的某个节点而是要看它在父节点中的位置。我调试时遇到过这样的情况对一个普通三节点树按后序遍历结果遍历函数一进去就死循环原因就是把右子树的 FirstNode 直接用在了所有情况里没有先判断 p 是左孩子还是右孩子。排查方法很简单打印每个节点在递归中的 parent、left、right 和标志位手工构建一棵小树把后序序列先写出来然后用代码算出来的结果去对。不要嫌麻烦这种问题用眼睛看不出来的。5.2 线索化完成后递归释放内存导致死循环这是一个很容易踩的坑树线索化之后有些节点的 rchild 不再指向真实的孩子而是指向后继节点。如果释放内存时直接写void FreeTree(ThreadNode *p) { if (p ! NULL) { FreeTree(p-lchild); FreeTree(p-rchild); free(p); } }那么一旦 p 的 rchild 是线索递归就会顺着线索跑到别的节点上轻则重复释放重则无限递归崩溃。正确的释放方式是在递归前判断 ltag 和 rtag只有等于 0 时才作为孩子递归void FreeThreadTree(ThreadNode *p) { if (p NULL) { return; } if (p-ltag 0) { FreeThreadTree(p-lchild); } if (p-rtag 0) { FreeThreadTree(p-rchild); } free(p); }这点尤其容易出现在你已经把代码写完、功能测试通过之后。因为在功能测试阶段你可能只用遍历不会去销毁树而销毁树这种收尾动作最容易被人忽略。5.3 先线索化再插入节点顺序反了导致线索失效最后一个常见坑是使用时机的问题。线索化完成后如果你再往树里插入一个新节点那么原来设置好的前驱后继关系立刻就不再准确了。比如后序序列本来是从 B 到 C你给 C 加了一个右孩子那么后序序列就变了原来的线索有一部分已经指向错误的目标。所以实际操作时应该是先把二叉树的形态固定下来再做线索化。如果树的形态要频繁变化那线索二叉树根本不适合因为每次增删节点都可能要重新线索化代价太大了。这个道理和建立索引类似索引在数据不变时好用数据一变就得同步维护否则索引就是废的。5.4 线索化时 pre 的初值和根节点右指针的处理很多人写代码时用全局变量 pre初始化成 NULL但是忘了在最后处理根节点。后序序列最后一个节点是根按照线索化的规则pre 最终会停在根节点上而根节点的右指针如果为空需要手动设置 rtag 为 1。不设置的话虽然不影响使用但会造成标志位和数据不一致后续在用 PostNextNode 判断时可能出错。再补充一点如果你的后序线索化函数里 pre 是函数外部传入的指针注意在递归过程中它必须能保持更新后的值。所以最省事的方式是定义一个全局变量或者把它封装在一个结构体里传地址我习惯写成一个内部的静态变量或全局变量避免在递归里传二级指针搞得代码很难看。6. 什么时候确实该用后序线索二叉树6.1 真实场景表达式树求值与后序遍历表达式树是后序遍历很经典的应用。一棵二叉树的节点存操作符叶子存操作数要求表达式的结果就要做后序遍历先算左子树再算右子树最后用根节点的操作符合并。如果这个表达式树要被反复求值而且不同场景下需要频繁获取“某个表达式节点在计算顺序里的前一个或后一个节点”那么把它做成后序线索二叉树就能带来实际收益。比如在某些代码分析工具里需要按后序依赖顺序逐个处理节点又需要偶尔反向回溯依赖谁双向的线索化结合 parent 指针就能让前后移动都变成 O(1)。6.2 后序 vs 中序 vs 前序线索化选型对比我直接给你一张对比表方便在设计和面试时快速决策类型找前驱找后继是否必须 parent 指针遍历根起点中序线索左子树最右下或左线索右子树最左下或右线索不需要中序第一个节点前序线索较容易部分情况需 parent左孩子或右孩子或线索通常不需要根节点后序线索右孩子或左孩子或左线索需分四类讨论必用 parent必须后序第一个节点不是根从表里能看出来后序线索化是三种里面实现和思维负担最重的。前序线索化找后继几乎是无脑操作如果当前节点有左孩子后继就是左孩子没有左孩子但有右孩子后继是右孩子两者都没有就顺着右线索走。中序和后序则各有各的门道只是中序更对称更规整。所以在实际项目里除非你明确需要通过后序线索来高效寻找后继否则没必要强行后序线索化。如果是自己学习那当然要把三种都写一遍尤其是要理解为什么后序必须三叉链这个认知特别能体现你对遍历本质的理解程度。6.3 我的一点实操体会在我自己写后序线索二叉树的过程中最有价值的一步不是把代码写对而是拿着笔在一张纸上把整棵树画出来每设置一个线索就把它标出来然后对照后序遍历的序列逐项检查。很多人在脑海里想“应该是这样”但一落到代码和实际数据结构上就发现不是那回事。我建议你至少手动做一棵 5 个节点以上、包含单分支节点的树把后序序列、线索都完整写出来这比看十篇文章都管用。另外一个小建议后序线索二叉树非常依赖 parent 指针而 parent 指针本身需要在建树时维护好这一点很容易在建树代码里漏掉。你可以在每个 insert 操作里顺手把这个节点的 parent 赋值也可以在树建完后用一次层序遍历统一回填 parent。不管用哪种方式一定要保证 parent 指针的正确性否则后序后继查找的所有逻辑都会建立在错误的基础之上。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑