本文系统梳理「树」这一核心数据结构,从基础概念到工程实践,全部代码用 Go 实现,带你搞懂为什么不同的场景需要不同的树。
一、为什么我们需要树?
在接触树之前,我们已经学过了数组和链表,也知道了散列表(Hash Table)可以在 O(1) 时间内完成查找。那么问题就来了:
有了如此高效的散列表,为什么还需要二叉树?
这是数据结构与算法中一个经典问题,也是很多面试中的高频考点。
散列表的局限
散列表虽然查询快,但它有几个硬伤:
- 散列冲突:当数据量增大时,冲突不可避免,性能会退化
- 无序性 :散列表中的数据是乱序的,不支持范围查找
- 扩容代价:动态扩容时需要 rehash,时间开销大
- 内存占用:为了保证较低的装载因子,通常需要预留更多空间
而树结构,尤其是二叉查找树(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 一张图看懂所有概念
css
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 实现)
首先定义节点结构:
go
package main
import "fmt"
// TreeNode 二叉树节点
type TreeNode struct {
Val int
Left *TreeNode
Right *TreeNode
}
前序遍历:根 -> 左 -> 右
go
func preOrder(root *TreeNode) {
if root == nil {
return
}
fmt.Printf("%d ", root.Val)
preOrder(root.Left)
preOrder(root.Right)
}
中序遍历:左 -> 根 -> 右(BST 中序遍历即有序序列)
go
func inOrder(root *TreeNode) {
if root == nil {
return
}
inOrder(root.Left)
fmt.Printf("%d ", root.Val)
inOrder(root.Right)
}
后序遍历:左 -> 右 -> 根
go
func postOrder(root *TreeNode) {
if root == nil {
return
}
postOrder(root.Left)
postOrder(root.Right)
fmt.Printf("%d ", root.Val)
}
层序遍历(BFS)
go
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 查找
go
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 插入
go
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 中最复杂的操作,分三种情况:
- 删除叶子节点:直接删除
- 删除只有一个子节点的节点:用子节点替代
- 删除有两个子节点的节点:用右子树的最小值(或左子树的最大值)替代
go
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)。
markdown
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)
当某个节点的右子树过高时,需要左旋:
css
x y
/ \ / \
a y → x c
/ \ / \
b c a b
go
// 左旋:解决右子树过高
func leftRotate(x *TreeNode) *TreeNode {
y := x.Right
b := y.Left
y.Left = x
x.Right = b
return y
}
右旋(Right Rotation)
当某个节点的左子树过高时,需要右旋:
css
y x
/ \ / \
x c → a y
/ \ / \
a b b c
go
// 右旋:解决左子树过高
func rightRotate(y *TreeNode) *TreeNode {
x := y.Left
b := x.Right
x.Right = y
y.Left = b
return x
}
双旋:LR 型和 RL 型
有时候单次旋转不够,需要两次旋转:
- LR 型(左子树的右子树过高):先对左子树左旋,再对根右旋
- RL 型(右子树的左子树过高):先对右子树右旋,再对根左旋
go
// 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):
平衡因子 = 左子树高度 - 右子树高度
go
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 实现)
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 家族中最著名的成员之一。它放弃了「严格平衡」,转而追求「近似平衡」,通过给节点增加颜色属性(红或黑),并遵守以下五条规则:
- 每个节点要么是红色,要么是黑色
- 根节点是黑色
- 所有叶子节点(NIL)都是黑色
- 红色节点的两个子节点必须是黑色(不能有两个连续的红色节点)
- 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点
为什么工程中都用红黑树?
红黑树通过牺牲严格的平衡性,换取了更少的旋转次数。
| 特性 | 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+ 树?
- I/O 次数更少:非叶子节点不存数据,单个节点更小,一次磁盘页(通常 4KB)能装入更多索引
- 范围查询极快:叶子节点形成有序链表,范围查找只需遍历链表,无需回溯
- 查询性能稳定:每次查询都到叶子节点,不会出现「有时快有时慢」的情况
九、递归树:分析算法复杂度的利器
除了作为数据结构,树还可以用来分析算法的时间复杂度。
一个非常实用的技巧是:递归树法。
以归并排序为例:
scss
T(n)
/ \
T(n/2) T(n/2)
/ \ / \
T(n/4)... T(n/4)...
每一层的工作量都是 O(n) ,树的高度是 log n ,所以总时间复杂度就是 O(n log n)。
递归树法特别适用于分析分治算法、递归算法的复杂度,把抽象的递归过程可视化,非常直观。
十、总结:一张图看懂树的演化
css
┌─────────────────────────────────────────────────────────────┐
│ 树的演化之路 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 树(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 后端