第十一章:二叉树的应用——线索二叉树、哈夫曼树与并查集

这一章咱们来聊三个非常实用的数据结构。说实话,这三个东西在教科书里经常被放在一起,但它们的应用场景其实完全不同。我当年学的时候也觉得有点跳跃,后来做项目多了才明白——它们分别解决了三个经典问题:遍历加速、数据压缩、集合管理

好,咱们一个一个来。

11.1 线索二叉树:让遍历不再依赖递归

先问个问题:你写二叉树遍历的时候,是不是经常用递归?递归确实好写,但有个毛病——栈开销大,而且你没法快速找到某个节点的前驱和后继。比如中序遍历,你拿到一个节点,想知道它的“下一个”是谁,如果不从头递归一遍,根本不知道。

线索二叉树就是来解决这个问题的。说白了,它把那些空闲的左右指针利用起来,指向遍历序列中的前驱和后继。

11.1.1 线索化的核心思想

一个普通的二叉树节点,左右孩子指针不一定都用得上。叶子节点两个指针都是NULL,单孩子节点有一个是NULL。这些空指针,浪费了大概一半的空间。

线索化的做法是:

  • 如果左指针为空,就指向前驱节点
  • 如果右指针为空,就指向后继节点
  • 加两个标志位 ltagrtag,区分是指向孩子还是线索

节点结构定义:

typedef struct ThreadNode {
    int data;
    struct ThreadNode *lchild, *rchild;
    int ltag, rtag;  // 0表示孩子,1表示线索
} ThreadNode, *ThreadTree;

嗯,这里要注意:ltag == 0 时,lchild 指向左孩子;ltag == 1 时,lchild 指向前驱。右指针同理。

11.1.2 中序线索化的构造过程

我个人习惯用中序线索化,因为中序遍历用得最多。构造过程其实就是在中序遍历的过程中,记录前驱节点,然后把当前节点的空指针指回去。

void InThread(ThreadTree &p, ThreadTree &pre) {
    if (p == NULL) return;
    InThread(p->lchild, pre);   // 线索化左子树
    if (p->lchild == NULL) {    // 左指针为空
        p->lchild = pre;
        p->ltag = 1;
    }
    if (pre != NULL && pre->rchild == NULL) {  // 前驱右指针为空
        pre->rchild = p;
        pre->rtag = 1;
    }
    pre = p;                     // 更新前驱
    InThread(p->rchild, pre);   // 线索化右子树
}

我在项目中遇到过一个问题:线索化之后,如果你修改了树的结构(比如插入删除节点),线索就全乱了。所以线索二叉树适合静态树,不适合频繁修改的场景

小技巧:线索化之后,中序遍历可以不用递归,直接循环找后继就行。代码写起来像链表遍历,效率高很多。

11.2 哈夫曼树与哈夫曼编码:数据压缩的经典方案

说到数据压缩,你肯定听过ZIP、RAR这些。它们的底层原理之一就是哈夫曼编码。哈夫曼树,也叫最优二叉树,核心思想很简单:出现频率高的字符用短编码,频率低的用长编码

11.2.1 哈夫曼树的构造

构造过程其实是个贪心算法:每次从森林中选两个权值最小的树合并。我刚开始学的时候觉得这有什么难的?后来自己手写才发现,选最小两个节点这一步,如果用数组每次扫描,效率很低。实际项目中我一般用最小堆来优化。

构造步骤:

  1. 把所有节点按权值放入最小堆
  2. 弹出两个最小的,合并成一个新节点,权值相加
  3. 新节点入堆
  4. 重复直到只剩一个节点

哈夫曼树节点定义:

typedef struct HuffmanNode {
    int weight;
    int parent, lchild, rchild;  // 用数组下标表示
} HuffmanNode;

// 构造函数(核心逻辑)
void CreateHuffmanTree(HuffmanNode huffTree[], int n) {
    // 初始化所有节点
    for (int i = 0; i < 2*n - 1; i++) {
        huffTree[i].parent = -1;
        huffTree[i].lchild = -1;
        huffTree[i].rchild = -1;
    }
    // 每次选两个最小的
    for (int i = n; i < 2*n - 1; i++) {
        int min1 = -1, min2 = -1;
        // 找两个最小的无父节点
        for (int j = 0; j < i; j++) {
            if (huffTree[j].parent == -1) {
                if (min1 == -1 || huffTree[j].weight < huffTree[min1].weight) {
                    min2 = min1;
                    min1 = j;
                } else if (min2 == -1 || huffTree[j].weight < huffTree[min2].weight) {
                    min2 = j;
                }
            }
        }
        huffTree[i].weight = huffTree[min1].weight + huffTree[min2].weight;
        huffTree[i].lchild = min1;
        huffTree[i].rchild = min2;
        huffTree[min1].parent = i;
        huffTree[min2].parent = i;
    }
}

我曾经在一个嵌入式项目里用哈夫曼编码压缩传感器数据。当时内存只有64KB,压缩率大概能到60%左右。不过要注意,哈夫曼编码需要保存编码表,如果数据量太小,编码表本身的开销可能抵消压缩收益

11.2.2 哈夫曼编码的生成

从根节点到叶子节点的路径,左分支为0,右分支为1,就得到了每个字符的编码。注意:哈夫曼编码是前缀编码,任何一个编码都不是另一个编码的前缀,所以解码时不会产生歧义。

避坑指南:我曾经犯过一个错误——直接用递归生成编码,结果栈溢出。因为树可能很深(比如所有字符频率都差不多)。后来改成迭代方式,用数组模拟栈,稳得很。

11.3 并查集:高效管理不相交集合

并查集这个名字听起来有点学术,其实它的用途很直观:判断两个元素是否在同一个集合里,以及合并两个集合。比如社交网络里的“好友圈”、图论中的连通分量判断,都用得到。

11.3.1 基本操作

并查集的核心就三个操作:

  • 初始化:每个元素自成一个集合
  • 查找(Find):找到元素所属集合的代表元
  • 合并(Union):把两个集合合并成一个

最简单的实现就是用数组,下标表示元素,值表示父节点。根节点的父节点指向自己。

#define MAXN 1000
int parent[MAXN];

void init(int n) {
    for (int i = 0; i < n; i++) {
        parent[i] = i;  // 每个元素都是自己的根
    }
}

int find(int x) {
    while (parent[x] != x) {
        x = parent[x];
    }
    return x;
}

void unionSet(int x, int y) {
    int rootX = find(x);
    int rootY = find(y);
    if (rootX != rootY) {
        parent[rootY] = rootX;  // 简单合并
    }
}

这个版本能工作,但效率不行。你想想看,如果树退化成一条链,find 操作就是O(n)的。我早期写代码就吃过这个亏,数据量一上来直接超时。

11.3.2 路径压缩与按秩合并

两个优化,让并查集几乎变成常数时间:

路径压缩:在 find 的时候,把路径上所有节点直接挂到根节点下面。

int find(int x) {
    if (parent[x] != x) {
        parent[x] = find(parent[x]);  // 递归压缩
    }
    return parent[x];
}

按秩合并:合并时,把高度低的树挂到高度高的树下面,避免树长高。

int rank[MAXN];  // 记录树的高度

void unionSet(int x, int y) {
    int rootX = find(x);
    int rootY = find(y);
    if (rootX == rootY) return;
    if (rank[rootX] < rank[rootY]) {
        parent[rootX] = rootY;
    } else if (rank[rootX] > rank[rootY]) {
        parent[rootY] = rootX;
    } else {
        parent[rootY] = rootX;
        rank[rootX]++;
    }
}

个人经验:路径压缩和按秩合并一起用,时间复杂度接近O(α(n)),α(n)是阿克曼函数的反函数,增长极慢。说白了,对于任何实际数据规模,都可以认为是常数时间。

11.4 本章知识体系总览

下面这张图把三个知识点的核心逻辑串起来了,你可以对照着复习:

第十一章 二叉树的应用 线索二叉树 哈夫曼树 并查集 核心:利用空指针指向前驱/后继 标志位:ltag / rtag 遍历:无需递归,线性时间 适用:静态树结构 中序线索化流程: 1. 递归线索化左子树 2. 处理当前节点空指针 3. 处理前驱的右指针 4. 更新前驱 5. 递归线索化右子树 核心:频率高→短编码 构造:贪心选最小权值合并 编码:前缀编码,无歧义 应用:数据压缩、文件编码 构造步骤: 1. 所有节点入最小堆 2. 弹出两个最小权值 3. 合并为新节点入堆 4. 重复直到只剩一个 5. 从根到叶子生成编码 核心:集合的查找与合并 操作:Find / Union 优化1:路径压缩 优化2:按秩合并 时间复杂度: 朴素实现:O(n) 路径压缩:O(log n) 按秩合并:O(log n) 两者结合:O(α(n)) α(n) ≤ 5 对于所有实际数据 三个数据结构分别解决:遍历加速 · 数据压缩 · 集合管理

好了,这一章的内容就到这里。线索二叉树、哈夫曼树、并查集,每个都是很实用的工具。你写代码的时候,遇到遍历性能瓶颈,想想线索二叉树;遇到数据压缩需求,试试哈夫曼编码;遇到集合合并查询,直接用并查集加路径压缩。嗯,多练练,这些都会变成你的肌肉记忆。