目录
正在加载目录…
专栏文章
专栏文章
数据结构专栏
1. 线性数据结构:数组、链表、栈、队列与跳表 2. 哈希表与位图:冲突处理、HashMap 与空间优化 3. 树与堆:二叉树、平衡树与优先队列

树与堆:二叉树、平衡树与优先队列

发布于 2026-08-17 15:07 · 最后编辑于 2026-08-17 15:07 · 字数 3,139 👁 39 次阅读

本文覆盖二叉树(遍历/完全/满)、BST、AVL 树、红黑树、堆(堆排序/优先级队列)和 Trie 树,重点理解各数据结构的适用场景操作复杂度,以及设计背后的"为什么"。

目录

章节说明
二叉树基础定义、存储、三种遍历
二叉搜索树(BST)插入/删除/查找,三种删除场景
平衡二叉树AVL 树与红黑树,工程选型对比
最大堆/最小堆、核心操作、堆排序、应用
Trie 树字符串前缀匹配,空间优化

二叉树基础

核心概念

概念说明为什么这样定义
根节点没有父节点的节点树的唯一入口,所有遍历从此出发
叶子节点没有子节点的节点递归终止条件,遍历边界
高度(Height)从底层往上数,从 0 开始衡量树的深度,影响操作复杂度
深度(Depth)从根节点往下数,从 0 开始衡量节点距根的距离
层(Level)从根节点往下数,从 1 开始按层遍历(BFS)的自然分层

满二叉树 vs 完全二叉树

满二叉树:叶子节点全在最底层,每个非叶子节点都有左右两个子节点。节点数恰好为 2ⁿ-1。

完全二叉树:叶子节点在最底下两层,最后一层叶子节点靠左排列,其他层节点数达到最大。

为什么完全二叉树适合数组存储? 节点位置可以用下标直接计算(父节点 i/2,左子 2i,右子 2i+1),无需额外指针,节省内存。堆就是完全二叉树的典型应用。

存储方式

链式存储:每个节点有 leftright 两个指针,灵活,适合大多数树(BST、AVL、红黑树)。

数组顺序存储(适合完全二叉树):

根节点存储在 i=1 的位置(i=0 空置,方便计算)
左子节点:2*i
右子节点:2*i+1
父节点:i/2(整除)

三种遍历

tree traversal

三种遍历本质上是递归访问顺序不同:前序先处理根,后序先处理子树,中序在 BST 中天然得到有序序列。

// 前序遍历:根 → 左 → 右
// 用途:复制树结构、序列化/反序列化
void preOrder(Node root) {
    if (root == null) return;
    print(root);          // 先处理根
    preOrder(root.left);
    preOrder(root.right);
}

// 中序遍历:左 → 根 → 右
// 用途:BST 中序遍历得到有序序列(排序的副产品)
void inOrder(Node root) {
    if (root == null) return;
    inOrder(root.left);
    print(root);          // 根在中间
    inOrder(root.right);
}

// 后序遍历:左 → 右 → 根
// 用途:计算目录大小、删除树(先删子节点再删父节点)
void postOrder(Node root) {
    if (root == null) return;
    postOrder(root.left);
    postOrder(root.right);
    print(root);          // 最后处理根
}

遍历时间复杂度:O(n),每个节点恰好被访问一次。

二叉搜索树(BST)

二叉搜索树(Binary Search Tree) 满足:左子树所有节点 < 根节点 < 右子树所有节点。

为什么这个性质有用? 查找时每次比较都能排除一半子树,平均 O(logn),中序遍历直接输出有序序列,天然支持范围查找。

插入与删除

tree bst

删除节点的三种情况

情况操作原因
叶子节点直接删除无子节点,不影响树结构
只有一个子节点用子节点替代子节点继承位置,BST 性质不变
有两个子节点找右子树最小节点(中序后继)替代,再删该节点中序后继是比被删节点大的最小值,替代后 BST 性质保持
// 查找操作(迭代版,避免递归栈开销)
Node find(Node root, int data) {
    while (root != null) {
        if (data < root.val) root = root.left;
        else if (data > root.val) root = root.right;
        else return root;
    }
    return null;
}

操作复杂度

操作平均最坏(退化为链表)
查找O(logn)O(n)
插入O(logn)O(n)
删除O(logn)O(n)
中序遍历(输出有序序列)O(n)O(n)

退化场景:按升序插入 [1,2,3,4,5],BST 退化为链表,高度 n,查找 O(n)。这是 AVL/红黑树存在的根本原因。

BST vs 散列表

维度BST散列表
时间复杂度O(logn)O(1)
有序输出支持(中序遍历)不支持
范围查找支持不支持
内存消耗较少较多(装载因子)

需要有序性或范围查找时选 BST;只需快速点查时选散列表。

平衡二叉树

BST 在最坏情况下退化为链表,平衡二叉树通过旋转操作维持树高在 O(logn)。

AVL 树

AVL 树严格要求任意节点左右子树高度差不超过 1(平衡因子 ∈ {-1, 0, 1})。

  • 查找/插入/删除:O(logn)
  • 每次插入/删除后可能需要旋转(单旋或双旋)调整
  • 适合查询多、更新少的场景(如只读索引)

红黑树

红黑树是一种近似平衡的二叉查找树,高度不超过 2log₂n。用颜色代替严格高度约束,换取更少的旋转次数。

五条性质

  1. 节点是红色或黑色
  2. 根节点是黑色
  3. 叶子节点(NIL)是黑色
  4. 红色节点的两个子节点都是黑色(不能有连续红节点)
  5. 从任意节点到其叶子节点的路径,包含相同数目的黑色节点

为什么工程中用红黑树而不是 AVL 树?

维度AVL 树红黑树
平衡严格度严格(高度差 ≤ 1)近似(高度 ≤ 2log₂n)
查找效率略高(树更矮)略低
插入/删除调整频繁旋转,成本高旋转次数少(最多 3 次),成本低
适用场景查多改少插入/删除/查找均衡

工程选择红黑树的核心原因:大多数场景写操作不可忽视,红黑树通过放松平衡条件减少旋转次数,整体吞吐量更高。

实际应用:Java TreeMap/TreeSet/HashMap(链表转树)、Linux 内核进程调度(CFS)、C++ std::map

堆(Heap) 是满足堆性质的完全二叉树,用数组存储(利用完全二叉树的下标计算优势):

  • 大顶堆:每个节点的值 ≥ 子树中所有节点的值
  • 小顶堆:每个节点的值 ≤ 子树中所有节点的值

为什么堆用数组而不用链表? 完全二叉树的父子关系可以用下标直接计算,无需指针,内存连续,缓存友好,节省每个节点 2 个指针的空间。

核心操作

tree heap ops

插入(从下往上堆化):将新元素放到数组末尾,与父节点比较,若不满足堆性质则交换,向上递归。

public void insert(int data) {
    if (count >= n) return;      // 堆已满
    a[++count] = data;           // 放到末尾
    int i = count;
    while (i / 2 > 0 && a[i] > a[i / 2]) { // 大顶堆:子 > 父则交换
        swap(a, i, i / 2);
        i = i / 2;               // 向上移动
    }
}

删除堆顶(从上往下堆化):将最后一个元素移到堆顶,与较大子节点比较,向下递归。

为什么不直接删堆顶? 直接删除会破坏完全二叉树结构(产生空洞),改用末尾元素填充再堆化,保持完全二叉树形态。

public void removeMax() {
    if (count == 0) return;
    a[1] = a[count--];          // 末尾元素移到堆顶
    heapify(a, count, 1);       // 从堆顶向下堆化
}

private void heapify(int[] a, int n, int i) {
    while (true) {
        int maxPos = i;
        if (i * 2 <= n && a[i] < a[i * 2]) maxPos = i * 2;
        if (i * 2 + 1 <= n && a[maxPos] < a[i * 2 + 1]) maxPos = i * 2 + 1;
        if (maxPos == i) break;  // 已满足堆性质
        swap(a, i, maxPos);
        i = maxPos;
    }
}

堆排序

分两步:

  1. 建堆:从 n/2 开始向前,对每个节点做从上往下堆化,时间复杂度 O(n)
  2. 排序:反复将堆顶(最大值)与末尾元素交换,堆大小减一,再堆化,时间复杂度 O(nlogn)
public static void sort(int[] a, int n) {
    buildHeap(a, n);        // O(n):建大顶堆
    int k = n;
    while (k > 1) {
        swap(a, 1, k--);    // 将最大值放到末尾
        heapify(a, k, 1);   // O(logn):重新堆化
    }
}

private static void buildHeap(int[] a, int n) {
    for (int i = n / 2; i >= 1; --i) { // 从最后一个非叶节点开始
        heapify(a, n, i);
    }
}

建堆为什么是 O(n) 而不是 O(nlogn)? 从底部开始建堆,底层节点需要堆化的高度很小。数学上求和:∑(i=1 to logn) i × n/2ⁱ = O(n)。

堆排序 vs 快速排序

两者时间复杂度同为 O(nlogn),但实际性能快排更好:

原因说明
缓存不友好堆排序访问数组时跳跃(1→2→4→8...),快排顺序访问,缓存命中率高
交换次数多建堆阶段会打乱原有有序度,增加总交换次数

堆的应用

优先级队列:用大顶堆/小顶堆实现,O(logn) 插入,O(1) 查看最值,O(logn) 删除最值。Java PriorityQueue 即堆实现。

Top K 问题:维护一个大小为 K 的小顶堆,遍历数据,若比堆顶大则替换堆顶并堆化。时间复杂度 O(nlogK),空间 O(K)。

为什么用小顶堆而不是大顶堆?小顶堆堆顶是 K 个数中最小的,方便判断新元素是否能进入 Top K。

求中位数:维护一个大顶堆(存较小的一半)和一个小顶堆(存较大的一半),保持两者大小相差不超过 1。中位数为大顶堆堆顶(或两堆顶的平均值)。

操作复杂度

操作时间复杂度说明
插入O(logn)向上堆化,最多 logn 次交换
删除堆顶O(logn)向下堆化,最多 logn 次交换
查看堆顶O(1)直接访问 a[1]
建堆O(n)数学证明,非直觉上的 O(nlogn)
堆排序O(nlogn)建堆 O(n) + n 次删除 O(logn)

Trie 树

Trie 树(前缀树) 是专门处理字符串匹配的树形数据结构,利用字符串的公共前缀减少存储和查询开销。

为什么不直接用散列表存字符串? 散列表能做精确匹配,但不支持前缀查找(如"输入 hel,找所有以 hel 开头的词")。Trie 天然支持前缀匹配,且多个字符串共享公共前缀,节省空间。

结构

tree trie

每个节点代表一个字符,从根节点到某个标记为"结尾"的节点的路径表示一个完整字符串。公共前缀只存储一次。

class TrieNode {
    char data;
    TrieNode[] children = new TrieNode[26]; // 英文字母
    boolean isEndingChar = false;           // 是否是某个词的结尾
}

class Trie {
    private TrieNode root = new TrieNode('/');

    // 插入:O(m),m 为字符串长度
    public void insert(String text) {
        TrieNode p = root;
        for (int i = 0; i < text.length(); ++i) {
            int index = text.charAt(i) - 'a';
            if (p.children[index] == null) {
                p.children[index] = new TrieNode(text.charAt(i));
            }
            p = p.children[index];
        }
        p.isEndingChar = true; // 标记词尾
    }

    // 查找:O(m)
    public boolean find(String pattern) {
        TrieNode p = root;
        for (int i = 0; i < pattern.length(); ++i) {
            int index = pattern.charAt(i) - 'a';
            if (p.children[index] == null) return false;
            p = p.children[index];
        }
        return p.isEndingChar; // 必须是完整词,不能只是前缀
    }
}

操作复杂度

操作时间复杂度说明
插入O(m)m 为字符串长度,与词典大小无关
查找(精确)O(m)沿路径走 m 步
前缀匹配O(m)找到前缀节点后,DFS 收集所有词

空间消耗与优化

每个节点需存储子节点指针数组(26 个英文字母则需 26 个指针),内存消耗大,空间换时间。

优化方案原理适用场景
散列表代替数组只存实际存在的子节点字符集大、稀疏时
压缩 Trie(Radix Tree)合并只有一个子节点的路径词典较小时
Double Array Trie用两个数组压缩存储高性能场景(如分词器)

应用场景

  • 搜索引擎关键词提示(输入前缀,返回候选词)
  • 输入法联想词
  • 路由器 IP 路由匹配(最长前缀匹配)
  • 敏感词过滤(配合 AC 自动机实现多模式匹配)

参考资料

  • 《数据结构与算法之美》— 23~29、35 章节
← 返回列表
(1 人打了分,平均分: 5.00)

评论 (0)

暂无评论,来留下第一条吧。
登录注册 后才能发表评论