从二叉树到 B+ 树:一文搞懂工程中「树」的演化之道

273 阅读14分钟

本文系统梳理「树」这一核心数据结构,从基础概念到工程实践,全部代码用 Go 实现,带你搞懂为什么不同的场景需要不同的树。


一、为什么我们需要树?

在接触树之前,我们已经学过了数组和链表,也知道了散列表(Hash Table)可以在 O(1) 时间内完成查找。那么问题就来了:

有了如此高效的散列表,为什么还需要二叉树?

这是数据结构与算法中一个经典问题,也是很多面试中的高频考点。

散列表的局限

散列表虽然查询快,但它有几个硬伤:

  1. 散列冲突:当数据量增大时,冲突不可避免,性能会退化
  2. 无序性:散列表中的数据是乱序的,不支持范围查找
  3. 扩容代价:动态扩容时需要 rehash,时间开销大
  4. 内存占用:为了保证较低的装载因子,通常需要预留更多空间

而树结构,尤其是二叉查找树(BST),天然支持有序存储,可以非常方便地进行范围查询(比如查找某个区间内的所有数据),这是散列表无法替代的。


二、树的基本概念:先搞清楚这些术语

在深入各种树之前,先把基础术语梳理清楚。概念清晰了,后面的理解才会顺畅。

2.1 什么是树?

树(Tree)是一种非线性的数据结构,它由节点(Node)和边(Edge)组成,用来模拟具有层级关系的数据。

生活中常见的树状结构:公司的组织架构、文件系统的目录结构、HTML 的 DOM 树……

2.2 核心术语一览

术语英文含义类比
根节点Root树的最顶端节点,没有父节点公司的 CEO
父节点Parent某个节点的上层节点你的直属领导
子节点Child某个节点的下层节点你的下属
兄弟节点Sibling具有相同父节点的节点你的同事
叶子节点Leaf没有子节点的节点基层员工
内部节点Internal Node非叶子节点(有子节点)中层管理者
节点的度Degree该节点拥有的子节点个数下属人数
树的度Tree Degree树中所有节点度的最大值公司最多下属人数
节点的深度Depth从根节点到该节点经过的边数层级(CEO 是 0 层)
节点的高度Height从该节点到最远叶子节点的边数该节点往下管多少层
树的高度Tree Height根节点的高度,即树的最大深度公司总层级
层(Level)Level根为第 1 层,往下逐层递增第几层组织架构

2.3 一张图看懂所有概念

                    A          ← 根节点(Root),深度 0,高度 3
                   /|\
                  / | \
                 B  C  D       ← B、C、D 是 A 的子节点
                / \     \      ← B 的度是 2,D 的度是 1
               E   F     G     ← E、F 是 B 的子节点,也是兄弟节点
              /              ← E 的度是 1
             H                ← H 是叶子节点(Leaf)
  • A 的深度:0,A 的高度:3
  • B 的深度:1,B 的高度:2
  • H 的深度:3,H 的高度:0(叶子节点高度为 0)
  • 这棵树的度:3(A 有 3 个子节点,是最大值)
  • 这棵树的高度:3

2.4 树 vs 图:千万别混淆

树有一个非常重要的性质:

树是「无环」的连通图,任意两个节点之间有且只有一条路径。

如果出现了环,那就不是树,而是图了。这也是面试中常考的区分点。

2.5 二叉树 vs 多叉树

根据一个节点最多能有多少个子节点,树可以分为:

类型定义例子
二叉树每个节点最多有 2 个子节点二叉查找树、AVL 树、红黑树
多叉树每个节点可以有多个子节点B 树、B+ 树、Trie 树

三、二叉树基础:Go 代码全实现

3.1 二叉树的分类

二叉树(Binary Tree)是每个节点最多有两个子节点的树,分别称为左子树和右子树。

根据节点的排列方式,二叉树又分为几种特殊形态:

类型定义特点
满二叉树除叶子节点外,每个节点都有左右两个子节点,且所有叶子都在同一层节点数 = 2^h - 1
完全二叉树除最后一层外,其他层都是满的;最后一层的节点都靠左排列适合用数组存储
二叉查找树(BST)左子树所有值 < 根节点 < 右子树所有值支持高效查找
平衡二叉树左右子树高度差不超过 1防止退化成链表

满二叉树一定是完全二叉树,但完全二叉树不一定是满二叉树。

3.2 存储方式:链式 vs 数组

存储方式适用场景特点
链式存储普通二叉树每个节点包含 Val、Left、Right 指针,灵活但占用额外空间
数组存储完全二叉树按层序存储,父节点 i 的左右子节点分别在 2i+1 和 2i+2

什么样的二叉树适合用数组存储?
答案是:完全二叉树。
因为完全二叉树除了最后一层,其他层都是满的,最后一层的节点都靠左排列,这样用数组存储不会浪费空间。而普通的二叉树如果用数组存储,可能会产生大量空洞,浪费内存。

这也是为什么堆(一种完全二叉树)通常用数组来实现。

3.3 二叉树的遍历(Go 实现)

首先定义节点结构:

package main

import "fmt"

// TreeNode 二叉树节点
type TreeNode struct {
    Val   int
    Left  *TreeNode
    Right *TreeNode
}

前序遍历:根 -> 左 -> 右

func preOrder(root *TreeNode) {
    if root == nil {
        return
    }
    fmt.Printf("%d ", root.Val)
    preOrder(root.Left)
    preOrder(root.Right)
}

中序遍历:左 -> 根 -> 右(BST 中序遍历即有序序列)

func inOrder(root *TreeNode) {
    if root == nil {
        return
    }
    inOrder(root.Left)
    fmt.Printf("%d ", root.Val)
    inOrder(root.Right)
}

后序遍历:左 -> 右 -> 根

func postOrder(root *TreeNode) {
    if root == nil {
        return
    }
    postOrder(root.Left)
    postOrder(root.Right)
    fmt.Printf("%d ", root.Val)
}

层序遍历(BFS)

func levelOrder(root *TreeNode) {
    if root == nil {
        return
    }
    queue := []*TreeNode{root}
    for len(queue) > 0 {
        node := queue[0]
        queue = queue[1:]
        fmt.Printf("%d ", node.Val)
        if node.Left != nil {
            queue = append(queue, node.Left)
        }
        if node.Right != nil {
            queue = append(queue, node.Right)
        }
    }
}

遍历的记忆口诀:

  • 前序:根左右(先访问根)
  • 中序:左根右(BST 中序 = 有序序列)
  • 后序:左右根(先处理子树,适合释放内存)
  • 层序:一层一层来(BFS,适合找最短路径)

四、二叉查找树(BST):Go 实现增删查

二叉查找树是二叉树最重要的应用形式,它满足:

  • 左子树所有节点的值 < 根节点的值
  • 右子树所有节点的值 > 根节点的值
  • 左右子树也是二叉查找树

BST 的查找、插入、删除时间复杂度都是 O(树的高度)。

4.1 BST 查找

func searchBST(root *TreeNode, target int) *TreeNode {
    if root == nil || root.Val == target {
        return root
    }
    if target < root.Val {
        return searchBST(root.Left, target)
    }
    return searchBST(root.Right, target)
}

4.2 BST 插入

func insertBST(root *TreeNode, val int) *TreeNode {
    if root == nil {
        return &TreeNode{Val: val}
    }
    if val < root.Val {
        root.Left = insertBST(root.Left, val)
    } else if val > root.Val {
        root.Right = insertBST(root.Right, val)
    }
    return root
}

4.3 BST 删除

删除是 BST 中最复杂的操作,分三种情况:

  1. 删除叶子节点:直接删除
  2. 删除只有一个子节点的节点:用子节点替代
  3. 删除有两个子节点的节点:用右子树的最小值(或左子树的最大值)替代
func deleteBST(root *TreeNode, val int) *TreeNode {
    if root == nil {
        return nil
    }
    if val < root.Val {
        root.Left = deleteBST(root.Left, val)
    } else if val > root.Val {
        root.Right = deleteBST(root.Right, val)
    } else {
        if root.Left == nil {
            return root.Right
        }
        if root.Right == nil {
            return root.Left
        }
        minNode := root.Right
        for minNode.Left != nil {
            minNode = minNode.Left
        }
        root.Val = minNode.Val
        root.Right = deleteBST(root.Right, minNode.Val)
    }
    return root
}

4.4 BST 的致命缺陷:退化成链表

但是! 如果插入的数据是有序的(比如 1, 2, 3, 4, 5),BST 会退化成一条链表,查找复杂度退化为 O(n)。

    1              3
     \           / \
      2    →    2   4
       \       /     \
        3     1       5
         \
          4
           \
            5
            
  退化成链表        平衡的理想形态

这就引出了下一个关键问题:如何让树保持平衡?


五、BBST(平衡二叉查找树):解决 BST 的退化问题

5.1 什么是 BBST?

BBST 全称 Balanced Binary Search Tree,即平衡二叉查找树。它是一类特殊的二叉查找树,通过某种机制自动维持树的平衡,确保树的高度始终保持在 O(log n),从而保证查找、插入、删除操作的时间复杂度稳定为 O(log n)。

BBST 不是一个具体的树,而是一类树的统称。 常见的 BBST 包括:

  • AVL 树(严格平衡)
  • 红黑树(近似平衡)
  • Treap(树堆,结合 BST 和堆)
  • Splay 树(伸展树,自调整)

5.2 BBST 的核心思想:旋转

BBST 维持平衡的核心武器是旋转(Rotation)。旋转操作可以在不改变 BST 性质(左小右大)的前提下,调整树的结构,降低树的高度。

左旋(Left Rotation)

当某个节点的右子树过高时,需要左旋:

    x              y
   / \            / \
  a   y    →     x   c
     / \        / \
    b   c      a   b
// 左旋:解决右子树过高
func leftRotate(x *TreeNode) *TreeNode {
    y := x.Right
    b := y.Left
    y.Left = x
    x.Right = b
    return y
}

右旋(Right Rotation)

当某个节点的左子树过高时,需要右旋:

      y            x
     / \          / \
    x   c   →    a   y
   / \              / \
  a   b            b   c
// 右旋:解决左子树过高
func rightRotate(y *TreeNode) *TreeNode {
    x := y.Left
    b := x.Right
    x.Right = y
    y.Left = b
    return x
}

双旋:LR 型和 RL 型

有时候单次旋转不够,需要两次旋转:

  • LR 型(左子树的右子树过高):先对左子树左旋,再对根右旋
  • RL 型(右子树的左子树过高):先对右子树右旋,再对根左旋
// LR 型:先左旋后右旋
func leftRightRotate(z *TreeNode) *TreeNode {
    z.Left = leftRotate(z.Left)
    return rightRotate(z)
}

// RL 型:先右旋后左旋
func rightLeftRotate(z *TreeNode) *TreeNode {
    z.Right = rightRotate(z.Right)
    return leftRotate(z)
}

5.3 平衡因子

如何判断一棵树是否平衡?通常使用平衡因子(Balance Factor):

平衡因子 = 左子树高度 - 右子树高度

func height(node *TreeNode) int {
    if node == nil {
        return 0
    }
    leftH := height(node.Left)
    rightH := height(node.Right)
    if leftH > rightH {
        return leftH + 1
    }
    return rightH + 1
}

func getBalance(node *TreeNode) int {
    if node == nil {
        return 0
    }
    return height(node.Left) - height(node.Right)
}

当平衡因子的绝对值超过某个阈值时(AVL 树是 1),就需要通过旋转来恢复平衡。

5.4 BBST 的完整分类

BBST 类型平衡策略特点应用场景
AVL 树严格平衡(平衡因子绝对值 ≤ 1)查找极快,但旋转频繁查找密集型场景
红黑树近似平衡(黑高相同)增删效率高,旋转次数少TreeMap、Go 标准库、内核调度
Treap随机优先级 + 堆性质实现简单,期望平衡竞赛编程
Splay 树自调整(访问后旋转到根)局部性好时性能优异缓存场景

六、AVL 树:严格平衡的典范

AVL 树是最早的自平衡二叉查找树,它要求任意节点的左右子树高度差不超过 1。

  • 优点:查找效率极高,时间复杂度稳定 O(log n)
  • 缺点:为了保持严格平衡,插入和删除时可能需要频繁旋转,维护成本高

AVL 树插入(Go 实现)

func insertAVL(node *TreeNode, val int) *TreeNode {
    if node == nil {
        return &TreeNode{Val: val}
    }
    if val < node.Val {
        node.Left = insertAVL(node.Left, val)
    } else if val > node.Val {
        node.Right = insertAVL(node.Right, val)
    } else {
        return node // 重复值不插入
    }

    balance := getBalance(node)

    // LL 型:左子树的左子树过高
    if balance > 1 && val < node.Left.Val {
        return rightRotate(node)
    }
    // RR 型:右子树的右子树过高
    if balance < -1 && val > node.Right.Val {
        return leftRotate(node)
    }
    // LR 型:左子树的右子树过高
    if balance > 1 && val > node.Left.Val {
        node.Left = leftRotate(node.Left)
        return rightRotate(node)
    }
    // RL 型:右子树的左子树过高
    if balance < -1 && val < node.Right.Val {
        node.Right = rightRotate(node.Right)
        return leftRotate(node)
    }

    return node
}

七、红黑树:近似平衡的工程之选

红黑树是 BBST 家族中最著名的成员之一。它放弃了「严格平衡」,转而追求「近似平衡」,通过给节点增加颜色属性(红或黑),并遵守以下五条规则:

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

为什么工程中都用红黑树?

红黑树通过牺牲严格的平衡性,换取了更少的旋转次数。

特性AVL 树红黑树
平衡度严格平衡近似平衡
查找O(log n),略快O(log n),最多多一层
插入旋转最多 2 次最多 2 次
删除旋转最坏 O(log n) 次最多 3 次
实现复杂度较高相对简单

核心洞察:红黑树的任何不平衡都可以在 三次旋转之内 解决!这使得它在频繁插入、删除的场景下表现更优。

正是因为这种「工程上的性价比」,Go 标准库中的内部实现、Java 的 TreeMap、TreeSet,C++ 的 std::map,Linux 内核的完全公平调度器(CFS)都选择了红黑树。


八、B+ 树:数据库索引的幕后英雄

8.1 从二叉到多叉:磁盘 I/O 的考量

当数据量大到无法全部放入内存时,树节点需要存储在磁盘上。此时,树的高度直接决定了磁盘 I/O 的次数。

二叉树的问题在于:每个节点最多只有两个子节点,导致树很高。假设有 1000 万条数据,二叉树高度约为 24,最坏情况下需要 24 次磁盘 I/O!

B 树(B-Tree)的解决思路很直接:

让每个节点存储多个关键字,拥有多个子节点(多叉树),从而降低树的高度。

8.2 B+ 树 vs B 树

MySQL 的 InnoDB 引擎使用的是 B+ 树 作为索引结构。B+ 树是 B 树的变种,主要有以下区别:

特性B 树B+ 树
数据存储每个节点都存储数据只有叶子节点存储数据,非叶子节点只存索引
叶子节点相互独立通过链表相连,支持范围查询
查询稳定性可能在非叶子节点命中,不稳定必须查到叶子节点,稳定 O(log n)
磁盘 I/O节点大,一次读入的索引少节点小,一次读入的索引多

为什么数据库偏爱 B+ 树?

  1. I/O 次数更少:非叶子节点不存数据,单个节点更小,一次磁盘页(通常 4KB)能装入更多索引
  2. 范围查询极快:叶子节点形成有序链表,范围查找只需遍历链表,无需回溯
  3. 查询性能稳定:每次查询都到叶子节点,不会出现「有时快有时慢」的情况

九、递归树:分析算法复杂度的利器

除了作为数据结构,树还可以用来分析算法的时间复杂度。

一个非常实用的技巧是:递归树法。

以归并排序为例:

            T(n)
           /    \
        T(n/2)  T(n/2)
        /  \     /  \
     T(n/4)... T(n/4)...

每一层的工作量都是 O(n),树的高度是 log n,所以总时间复杂度就是 O(n log n)。

递归树法特别适用于分析分治算法、递归算法的复杂度,把抽象的递归过程可视化,非常直观。


十、总结:一张图看懂树的演化

┌─────────────────────────────────────────────────────────────┐
│                        树的演化之路                          │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   树(Tree)                                                 │
│     │                                                       │
│     ├── 二叉树(Binary Tree)                                │
│     │     │                                                 │
│     │     ├── 完全二叉树 → 堆(优先队列、Top K)              │
│     │     │                                                 │
│     │     └── 二叉查找树(BST)                              │
│     │           │                                           │
│     │           ├── 不平衡 → 退化成链表(O(n))              │
│     │           │                                           │
│     │           └── 平衡化 → BBST(平衡二叉查找树)           │
│     │                 │                                     │
│     │                 ├── AVL 树(严格平衡,查找快)          │
│     │                 │                                     │
│     │                 ├── 红黑树(近似平衡,增删快)          │
│     │                 │      └─ Go 标准库、Linux CFS        │
│     │                 │                                     │
│     │                 ├── Treap(随机平衡)                   │
│     │                 └── Splay 树(自调整)                  │
│     │                                                       │
│     └── 多叉树(Multi-way Tree)                             │
│           │                                                 │
│           └── B 树族                                         │
│                 ├── B 树(文件系统)                          │
│                 └── B+ 树(数据库索引,MySQL InnoDB)         │
│                                                             │
└─────────────────────────────────────────────────────────────┘
数据结构核心优势典型应用
二叉查找树有序存储、支持范围查询基础理解
BBST自动维持平衡,避免退化通用平衡方案
AVL 树查找极快查找密集型场景
红黑树增删效率高、实现相对简单Go 标准库、内核调度
B+ 树降低磁盘 I/O、范围查询快MySQL 索引、文件系统
堆快速获取极值Top K、优先队列

十一、写在最后

学习数据结构与算法的关键,不在于死记硬背,而在于掌握其中的思想和精髓,学会解决实际问题的方法。

树的演化过程,本质上就是计算机科学家们在不同约束条件下做权衡的过程:

  • BST 简单但会退化 → 引入 BBST 解决
  • BBST 中 AVL 树严格平衡但旋转多 → 红黑树牺牲一点平衡换更少旋转
  • 二叉树节点少、树太高 → B+ 树用多叉降低高度,减少磁盘 I/O

理解这些设计背后的为什么,比记住代码实现更重要。希望这篇文章能帮你建立起对「树」的系统认知。


如果这篇文章对你有帮助,欢迎点赞 👍、收藏 ⭐、评论 💬。你的支持是我持续输出的动力!


文章标签建议:Go 算法 数据结构 二叉树 BBST 红黑树 B+树 MySQL 后端