Skip to content

第四章 树形结构、搜索树与堆

📖 ⏱️ 预计阅读时长

1. 树的基本概念

当数据之间不再是简单的前后关系,而是呈现层次关系时,线性结构就不再足够,树形结构由此出现。树是一种典型的非线性结构,适合表示层次关系。

树中常见术语包括:

  1. 根节点:树的起始节点。
  2. 父节点、子节点:体现层次关系。
  3. 兄弟节点:具有相同父节点的节点。
  4. 叶子节点:没有子节点的节点。
  5. 节点的度:该节点拥有的子节点个数。
  6. 树的高度或深度:树的层数或最长路径层级。

树在现实中的应用非常广泛,如组织结构图、文件目录、HTML DOM 树、表达式树等。

树之所以重要,在于它能自然表达“部分从属于整体”的层次关系。在线性结构中,元素之间主要是前后关系;而在树结构中,一个节点可以派生出多个下级节点,这使它特别适合表示目录、语法、机构、决策和索引等层级对象。

2. 二叉树

二叉树是每个节点最多只有两个子节点的树结构,通常称为左子树和右子树。

2.1 二叉树的性质

  1. i 层最多有 2^(i-1) 个节点。
  2. 高度为 h 的二叉树最多有 2^h - 1 个节点。
  3. 满二叉树、完全二叉树、二叉排序树等都是常见特殊类型。

二叉树之所以在教学和工程中都占据核心地位,是因为“每个节点最多两个孩子”这一限制既足够简单,便于分析和实现,又足够灵活,能够承载搜索、表达式处理、优先级管理等多种任务。很多更复杂的树结构,都可以看作对二叉树思想的扩展。

2.2 二叉树遍历

遍历是树结构中的基础操作,常见方式如下:

  1. 前序遍历:根 -> 左 -> 右。
  2. 中序遍历:左 -> 根 -> 右。
  3. 后序遍历:左 -> 右 -> 根。
  4. 层序遍历:按层从上到下、从左到右访问。

在实际应用中,表达式求值、编译语法分析、文件系统处理等都依赖树遍历思想。

2.3 为什么遍历顺序重要

同一棵树,只因访问顺序不同,就可能得到完全不同的结果。例如:

  1. 前序遍历适合表达“先访问节点,再处理子问题”的场景。
  2. 中序遍历在二叉搜索树中会得到有序序列。
  3. 后序遍历适合先处理子树、再处理根节点的场景,如释放整棵树内存。
  4. 层序遍历适合逐层分析结构,如最短层级问题。

3. 二叉搜索树

二叉搜索树(BST, Binary Search Tree)满足如下性质:对于任一节点,其左子树所有关键字小于该节点关键字,右子树所有关键字大于该节点关键字。

基于这一性质,二叉搜索树在理想情况下能以较高效率完成查找、插入和删除。

它之所以比无序线性结构更适合查找,是因为每比较一次关键字,通常就能排除一整部分无关节点。也就是说,二叉搜索树并不是简单“把节点排成一棵树”,而是在结构中编码了大小关系,从而把查找过程转化为不断缩小范围的过程。

3.1 性能特点

  1. 若树较平衡,则查找、插入、删除通常为 O(log n)
  2. 若树严重退化为链表,则上述操作最坏可能为 O(n)

这也解释了为什么“树结构”本身并不自动等于高效。只有当树的高度保持合理时,查找路径才会足够短;一旦结构失衡,二叉搜索树就会失去原有优势。这正是后续需要引入平衡树的根本原因。

3.2 删除操作的三种情况

在二叉搜索树中,删除操作通常分为三种情况:

  1. 待删除节点是叶子节点,可直接删除。
  2. 待删除节点只有一个孩子,可让其孩子直接接替原位置。
  3. 待删除节点有两个孩子,通常用其中序前驱或中序后继替换该节点,再删除被替换位置的节点。

之所以要区分这三种情况,是因为二叉搜索树删除后仍必须保持左小右大的搜索性质,不能像普通链表删除那样只断开一个指针即可。

4. C 语言示例:二叉搜索树插入与中序遍历

c
#include <stdio.h>
#include <stdlib.h>

typedef struct TreeNode {
    int key;
    struct TreeNode *left;
    struct TreeNode *right;
} TreeNode;

TreeNode *create_node(int key) {
    TreeNode *node = (TreeNode *)malloc(sizeof(TreeNode));
    if (node == NULL) {
        return NULL;
    }
    node->key = key;
    node->left = NULL;
    node->right = NULL;
    return node;
}

TreeNode *insert(TreeNode *root, int key) {
    if (root == NULL) {
        return create_node(key);
    }
    if (key < root->key) {
        root->left = insert(root->left, key);
    } else if (key > root->key) {
        root->right = insert(root->right, key);
    }
    return root;
}

void inorder(TreeNode *root) {
    if (root == NULL) {
        return;
    }
    inorder(root->left);
    printf("%d ", root->key);
    inorder(root->right);
}

int main(void) {
    TreeNode *root = NULL;
    root = insert(root, 50);
    root = insert(root, 30);
    root = insert(root, 70);
    root = insert(root, 20);
    root = insert(root, 40);
    inorder(root);   /* 输出:20 30 40 50 70 */
    return 0;
}

该示例的中序遍历结果是有序序列,这正是二叉搜索树的重要性质。

5. 平衡二叉树与红黑树

普通二叉搜索树可能退化,因此在工程中常使用平衡搜索树。

5.1 平衡二叉树(AVL 树)

AVL 树要求任一节点左右子树高度差的绝对值不超过 1。它的查找效率稳定,但插入删除时可能需要较频繁旋转调整。

5.2 红黑树

红黑树通过颜色规则控制树的整体平衡。它不要求像 AVL 那样“严格平衡”,但能保证树高不会过分失控,因此在标准库映射结构、操作系统内核、调度系统中应用广泛。

从总体上看:

  1. AVL 树查找更“紧凑”,适合查找密集场景。
  2. 红黑树更新代价更平衡,工程应用更广。

5.3 为什么工程实现更偏爱红黑树

很多标准库和系统组件更偏爱红黑树,而不是 AVL 树,原因并不在于红黑树“更高级”,而在于其平衡要求相对宽松,插入和删除时通常需要的调整更少,因此在查找、插入、删除都较频繁的综合场景中表现更均衡。

6. 堆与优先队列

堆是一种特殊的完全二叉树,分为大根堆和小根堆。

  1. 大根堆:每个节点值都不小于其孩子节点。
  2. 小根堆:每个节点值都不大于其孩子节点。

堆通常用数组实现,因为完全二叉树非常适合按层顺序存储。若根节点下标为 i,则:

  1. 左孩子通常为 2i + 1
  2. 右孩子通常为 2i + 2
  3. 父节点通常为 (i - 1) / 2

完全二叉树之所以适合数组实现,是因为其节点分布非常规整,不会像一般树那样出现大量“空洞位置”。这种规则性使得父子关系可以直接通过下标计算得到,从而省去了显式指针连接的开销。

6.1 堆的复杂度

操作复杂度
读取堆顶O(1)
插入元素O(log n)
删除堆顶O(log n)
建堆O(n)

6.2 典型应用

  1. 优先队列。
  2. 堆排序。
  3. Dijkstra 算法、A* 搜索等图算法中的最优候选选择。
  4. 大规模数据中的 Top K 问题。

上图是一个大根堆的示意。需要注意,堆并不要求像二叉搜索树那样满足“左小右大”,它只要求父节点与孩子节点之间满足大小关系。因此,堆更适合维护最值,而不适合中间查找。

这也解释了一个常见误区:很多人看到堆和树,就误以为堆也适合高效查找任意元素。事实上,堆擅长的是“快速得到当前最大值或最小值”,而不是维护整体有序关系。它服务的是优先队列问题,不是一般查找问题。

7. 字典树与多路搜索树

7.1 字典树(Trie)

Trie 常用于字符串前缀匹配。它把字符串按字符逐层展开,适合处理:

  1. 自动补全。
  2. 敏感词过滤。
  3. 词典检索。
  4. 最长公共前缀查询。

若字符串长度为 L,查找或插入的复杂度通常与 L 成正比,而与集合中字符串总数关系较弱。

7.2 B 树与 B+ 树

B 树和 B+ 树属于多路平衡搜索树,特别适合外存环境与数据库索引。

其核心思想是:

  1. 一个节点存放多个关键字和多个子指针。
  2. 通过提高树的分支因子,显著降低树高。
  3. 尽量减少磁盘 I/O 次数。

数据库系统之所以广泛采用 B+ 树,是因为它不仅保持平衡,而且叶子节点通常形成有序链,十分适合范围查询与顺序扫描。

7.3 为什么数据库偏爱 B+ 树而不是普通二叉搜索树

根本原因在于数据库面对的是磁盘或 SSD 上的大规模数据,而不是只放在内存中的小规模对象。B+ 树通过一个节点容纳多个关键字和子指针,提高了分支因子,降低了树高,从而减少了外存访问次数。同时,叶子节点有序链结构又让范围查询非常高效。这些特性都是普通二叉搜索树难以替代的。

8. 本章小结

树形结构使计算机能够高效表达层次关系,搜索树使“查找”问题获得了系统化解决方案,平衡树解决了退化问题,堆则把“按优先级取出元素”的需求高效落地,Trie 与 B+ 树则针对字符串检索和外存索引提供了专门方案。本章的核心不是记住树的种类多少,而是理解每一种树究竟在解决什么问题。