1. 引言:二叉树中的"隐形浪费"
在学习二叉树的时候,我们习惯于关注节点中的数据域 和孩子指针 ,却很少思考一个问题:那些指向 nullptr 的空指针到底浪费了多少空间?
答案是惊人的:一棵有 n 个节点的二叉树,恰好存在 n+1 个空指针域。例如一棵有 10 个节点的二叉树,就有 11 个指针是空的。这些空指针不仅白白占用内存,更在遍历时成为"死胡同"------每次递归到底,都不得不原路返回。
《大话数据结构》6.7 节提出了一个巧妙的思路:把这些空指针利用起来,存储"前驱"和"后继"节点的地址 。这样一来,二叉树就被改造成了一个线性结构,中序遍历不再需要递归或栈,只需要沿着指针一路走下去即可。这就是**线索二叉树(Threaded Binary Tree)**的核心思想。
**一句话总结:**线索二叉树 = 普通二叉树 + 利用空指针存储遍历序列中的前驱和后继信息。
2. 为什么需要线索二叉树?
回顾普通二叉树的中序遍历,通常有两种实现方式:
- **递归实现:**代码简洁,但递归深度受限于树的高度,存在栈溢出风险,且空间复杂度为 O(h)。
- **非递归实现(栈模拟):**需要显式维护一个栈,最坏情况下空间复杂度为 O(n)。
这两种方式都有一个共同的问题:每次遍历都需要"重新走一遍"整棵树。如果某个应用场景需要频繁地在中序序列中前后移动(比如数据库索引的 B+ 树叶子节点之间的跳转),那么每次都要从头遍历,效率极低。
线索二叉树的优势在于:
- 中序遍历的空间复杂度降为 O(1),只需要几个辅助指针。
- 可以在 O(1) 时间内找到任意节点的前驱或后继,无需重新遍历。
- 遍历过程变成了"沿着链表走",逻辑更清晰,也更适合迭代器模式。
3. 节点结构改造:增加两个标志位
线索二叉树的关键在于区分一个指针到底是指向孩子节点,还是指向前驱/后继线索 。为此,每个节点需要增加两个标志位 LTag 和 RTag:
LTag = 0(Link):lchild指向左孩子LTag = 1(Thread):lchild指向前驱RTag = 0(Link):rchild指向右孩子RTag = 1(Thread):rchild指向后继
改造后的节点结构如下:
cpp
enum PointerTag { Link, Thread }; // Link = 0 指向孩子,Thread = 1 指向线索
struct BiThrNode {
int data; // 数据域
BiThrNode *lchild, *rchild; // 左右指针
PointerTag LTag, RTag; // 左右标志位
// 构造函数:默认初始化为"指向孩子"
BiThrNode(int d) : data(d), lchild(nullptr), rchild(nullptr),
LTag(Link), RTag(Link) {}
};
注意构造函数的默认值:新节点创建时,两个标志位都设为 Link ,表示指针默认指向孩子。线索化过程中,才会根据实际情况将标志位改为 Thread。
4. 中序线索化的核心实现
线索化的本质是:在中序遍历的过程中,顺便把空指针"补上"。具体来说:
- 当访问到某个节点,发现它的
lchild为空 → 把lchild指向前驱节点(即刚刚访问过的节点)。 - 当发现前驱节点 的
rchild为空 → 把前驱的rchild指向当前节点(即后继)。
这里需要一个全局指针 pre 来始终记录"刚刚访问过的节点":
cpp
BiThrNode* pre = nullptr; // 全局变量,始终指向当前节点的前驱
void InThreading(BiThrNode* p) {
if (p == nullptr) return;
// 第一步:递归处理左子树
InThreading(p->lchild);
// 第二步:线索化处理
if (p->lchild == nullptr) { // 左孩子为空 → 指向前驱
p->LTag = Thread;
p->lchild = pre;
}
if (pre && pre->rchild == nullptr) { // 前驱的右孩子为空 → 指向后继
pre->RTag = Thread;
pre->rchild = p;
}
pre = p; // 更新前驱
// 第三步:递归处理右子树
InThreading(p->rchild);
}
**重点理解这段代码的执行顺序:**它是在中序遍历的"访问节点"环节插入线索化逻辑。由于中序遍历的顺序是「左 → 根 → 右」,所以当代码执行到线索化处理部分时,左子树已经全部处理完毕,pre 正好指向当前节点在中序序列中的前驱。这就是为什么线索化必须放在递归调用之间。
4.1 带头节点的完整线索化
《大话数据结构》中推荐使用头节点来简化边界处理。头节点不存储实际数据,只是作为遍历的"哨兵":
cpp
void InOrderThreading(BiThrNode*& head, BiThrNode* T) {
// 创建头节点
head = new BiThrNode(0);
head->LTag = Link;
head->RTag = Thread;
head->rchild = head; // 右指针先指向自己
if (T == nullptr) { // 空树情况
head->lchild = head;
return;
}
// 头节点的左孩子指向根节点
head->lchild = T;
pre = head;
// 中序线索化整棵树
InThreading(T);
// 处理最后一个节点(中序序列的最后一个节点)
pre->RTag = Thread;
pre->rchild = head; // 最后一个节点的后继指向头节点
// 头节点的右孩子指向最后一个节点
head->rchild = pre;
}
头节点的作用非常巧妙:
- 头节点的
lchild指向根节点,方便从根开始遍历。 - 头节点的
rchild指向中序序列的最后一个节点 ,形成一个双向循环结构。 - 最后一个节点的后继指向头节点,遍历到头节点时就知道结束了。
5. 利用线索进行中序遍历
线索化完成后,中序遍历就变成了纯指针操作,不再需要递归或栈:
cpp
void InOrderTraverse_Thr(BiThrNode* head) {
BiThrNode* p = head->lchild; // p 指向根节点
while (p != head) { // 回到头节点时结束
// 第一步:找到最左下的节点(中序序列的第一个节点)
while (p->LTag == Link) {
p = p->lchild;
}
// 第二步:访问当前节点
cout << p->data << " ";
// 第三步:沿着后继线索一路访问
while (p->RTag == Thread && p->rchild != head) {
p = p->rchild;
cout << p->data << " ";
}
// 第四步:转向右子树
p = p->rchild;
}
}
遍历过程可以总结为四个步骤的循环:
- 向左走到底:找到当前子树中最左下的节点,即中序序列的起点。
- 访问节点:输出当前节点数据。
- 沿后继线索走 :如果右标志是
Thread,说明右指针是后继线索,直接跳过去访问。 - 转向右子树 :如果右标志是
Link,说明右指针指向真正的右孩子,进入右子树重复上述过程。
核心理解:线索化后的中序遍历,本质上就是在一个双向链表中来回穿梭。向左走到底找到起点,然后不断沿着后继指针前进,遇到"断点"(右孩子)就进入右子树继续找起点。
6. 线索二叉树的图示理解
假设有如下二叉树:
bash
1
/ \
2 3
/ \ \
4 5 6
中序遍历序列为:4 → 2 → 5 → 1 → 3 → 6
线索化之前,每个节点的左右指针状态如下表:
| 节点 | lchild | 是否为空 | rchild | 是否为空 |
|---|---|---|---|---|
| 1 | 指向 2 | 否 | 指向 3 | 否 |
| 2 | 指向 4 | 否 | 指向 5 | 否 |
| 3 | nullptr | 是 | 指向 6 | 否 |
| 4 | nullptr | 是 | nullptr | 是 |
| 5 | nullptr | 是 | nullptr | 是 |
| 6 | nullptr | 是 | nullptr | 是 |
7 个节点,空指针数量 = 7 + 1 = 8 个(表中标"是"的单元格),完全符合 n+1 的规律。
线索化之后,这些空指针被重新"赋值":
| 节点 | lchild(线索化后) | LTag | rchild(线索化后) | RTag |
|---|---|---|---|---|
| 1 | 指向 2(孩子) | Link | 指向 3(孩子) | Link |
| 2 | 指向 4(孩子) | Link | 指向 5(孩子) | Link |
| 3 | 指向 1(前驱) | Thread | 指向 6(孩子) | Link |
| 4 | 指向头节点(前驱) | Thread | 指向 2(后继) | Thread |
| 5 | 指向 2(前驱) | Thread | 指向 1(后继) | Thread |
| 6 | 指向 3(前驱) | Thread | 指向头节点(后继) | Thread |
注意观察:原本的 8 个空指针,现在全部变成了有意义的线索。中序遍历时,从节点 4 开始,沿着后继线索可以一路走到节点 6,最后回到头节点,形成一个完整的闭环。
7. 深度分析:线索化 vs 普通遍历
下面从几个关键维度对比普通二叉树中序遍历和线索二叉树中序遍历:
| 对比维度 | 普通二叉树中序遍历 | 线索二叉树中序遍历 |
|---|---|---|
| 空间复杂度 | O(h) 递归栈 或 O(n) 显式栈 | O(1),仅使用几个辅助指针 |
| 查找前驱/后继 | 需要重新遍历或借助父指针 | O(1) 直接通过线索获取 |
| 插入节点 | 简单,只需修改指针 | 较复杂,需要同步维护线索 |
| 删除节点 | 简单,只需修改指针 | 较复杂,需要同步维护线索 |
| 适用场景 | 通用场景,树结构频繁变化 | 需要频繁遍历且树结构相对稳定 |
| 遍历本质 | 深度优先搜索(DFS) | 链表式线性遍历 |
关键结论:线索二叉树的核心价值在于"用空间换时间"的逆向思维 ------它并没有额外分配空间,而是把原本就存在的空指针域废物利用 ,从而将遍历的时间效率提升到了极致。付出的代价是插入和删除操作变复杂了,因为每次修改树结构都要同步更新线索。
8. 完整代码示例
下面给出一个完整的、可直接运行的 C++ 示例,包含树的构建、线索化、遍历和内存释放:
cpp
#include <iostream>
using namespace std;
enum PointerTag { Link, Thread };
struct BiThrNode {
int data;
BiThrNode *lchild, *rchild;
PointerTag LTag, RTag;
BiThrNode(int d) : data(d), lchild(nullptr), rchild(nullptr),
LTag(Link), RTag(Link) {}
};
BiThrNode* pre = nullptr;
// 中序线索化
void InThreading(BiThrNode* p) {
if (p == nullptr) return;
InThreading(p->lchild);
if (p->lchild == nullptr) {
p->LTag = Thread;
p->lchild = pre;
}
if (pre && pre->rchild == nullptr) {
pre->RTag = Thread;
pre->rchild = p;
}
pre = p;
InThreading(p->rchild);
}
// 创建带头节点的线索二叉树
void InOrderThreading(BiThrNode*& head, BiThrNode* T) {
head = new BiThrNode(0);
head->LTag = Link;
head->RTag = Thread;
head->rchild = head;
if (T == nullptr) {
head->lchild = head;
return;
}
head->lchild = T;
pre = head;
InThreading(T);
pre->RTag = Thread;
pre->rchild = head;
head->rchild = pre;
}
// 线索化中序遍历
void InOrderTraverse_Thr(BiThrNode* head) {
BiThrNode* p = head->lchild;
while (p != head) {
while (p->LTag == Link) p = p->lchild;
cout << p->data << " ";
while (p->RTag == Thread && p->rchild != head) {
p = p->rchild;
cout << p->data << " ";
}
p = p->rchild;
}
cout << endl;
}
int main() {
// 手动构建二叉树:
// 1
// / \
// 2 3
// / \ \
// 4 5 6
BiThrNode* root = new BiThrNode(1);
root->lchild = new BiThrNode(2);
root->rchild = new BiThrNode(3);
root->lchild->lchild = new BiThrNode(4);
root->lchild->rchild = new BiThrNode(5);
root->rchild->rchild = new BiThrNode(6);
BiThrNode* head = nullptr;
InOrderThreading(head, root);
cout << "中序遍历结果:";
InOrderTraverse_Thr(head);
// 输出:4 2 5 1 3 6
return 0;
}
运行这段代码,输出为:4 2 5 1 3 6,正是二叉树的中序遍历序列。整个过程没有使用递归,也没有显式维护栈,完全依靠线索指针驱动遍历。
9. 扩展:前序线索化与后序线索化
中序线索化是最常用的,因为中序遍历的前驱和后继关系最直观。但线索化的思想同样可以应用于前序和后序遍历:
- 前序线索化:在前序遍历的"访问节点"环节插入线索化逻辑。前序线索化后,可以沿后继线索实现前序遍历,但查找前驱比较困难(因为前序序列中,一个节点的前驱可能是其父节点或兄弟节点,关系复杂)。
- 后序线索化:在后序遍历的"访问节点"环节插入线索化逻辑。后序线索化后,可以沿后继线索实现后序遍历,但查找后继比较困难(因为后序序列中,一个节点的后继同样关系复杂)。
这也是为什么中序线索二叉树是最实用的------它的前驱和后继关系在树结构上都比较直观,查找起来自然方便。
10. 总结与思考
线索二叉树的精髓可以归纳为三点:
- **废物利用:**不额外分配空间,而是把 n+1 个空指针域变成有意义的线索。
- **空间换时间:**用两个标志位的微小代价,换取了 O(1) 空间遍历和 O(1) 查找前驱/后继的能力。
- **结构转换:**把非线性结构(树)转换成了线性结构(双向链表),让遍历变得像链表一样简单。
在实际应用中,线索二叉树的思想被广泛借鉴。例如 MySQL 的 InnoDB 存储引擎中,B+ 树的叶子节点之间就通过类似"线索"的指针串联起来,以实现高效的范围查询和顺序扫描。理解了线索二叉树,再去理解 B+ 树叶子节点的链表结构,会发现它们有着异曲同工之妙。
**学习建议:**建议读者亲手用纸笔画出第 6 节中的示例二叉树,然后按照中序线索化的步骤,一步步把空指针改成线索,再用眼睛沿着线索"走"一遍中序遍历。这个过程比看十遍代码都管用。
参考资料:《大话数据结构》程杰 著,第 6 章第 7 节「线索二叉树」。