Summary
前面的数组、链表、栈、队列都是"一条线"——每个元素最多一个前驱、一个后继。但现实世界大量是层次关系:文件系统的目录树、公司的组织架构、HTML 的 DOM、区块链的 Merkle 树。树就是描述这种"一对多"层次关系的结构。更重要的是,树把查找从线性结构的 O(n) 压到了 O(log n)——这是它存在的核心价值。这个结构应用得很广泛,需要重点学习。
树是什么
树是 n 个节点的有限集合,它满足:
- 有且仅有一个根节点(root),没有父节点。
- 除根外,每个节点有且仅有一个父节点;一个节点可以有 0 个或多个子节点(child)。
- 没有环——从根出发不会绕回自己。
换句话说,树是一种特殊的图:连通、无环、n 个节点恰好 n-1 条边。它的"非线性"体现在:一个节点可以有多个后继(孩子),所以数据是分叉展开的,不再是一条线。
根 root
/ | \
A B C ← A、B、C 是根的孩子,根是它们的父
/ \ |
D E F ← D、E、F 是叶子(no child)
树的关键词
用上面这棵树对照:
- 节点的度(degree):该节点的孩子个数。根的度是 3,A 的度是 2,D 的度是 0。
- 叶子节点(leaf):度为 0 的节点(B、D、E、F)。
- 深度(depth):从根到该节点的边数(根深度 0)。
- 高度(height):从该节点到最深叶子的边数;树的高度=根的高度。上图高度为 2。
- 层(level):通常根为第 1 层,往下递增。
- 子树(subtree):任意节点连同它下面所有节点,自成一棵树。
树上大多数操作(查找、插入)的代价正比于高度 h。树越"矮胖平衡",h 越接近 log n,操作越快;越"高瘦"(退化成链),h 越接近 n,越慢。“如何让树保持矮"是二叉树的精髓
二叉树(Binary Tree)——最重要的一类树
每个节点最多两个孩子,分别叫左孩子和右孩子,且左右有序(不能交换)。
两种特殊形态
- 满二叉树(full/perfect):每一层都填满,叶子全在最底层。高度 h 的满二叉树有
2^(h+1) - 1个节点。 - 完全二叉树(complete):除最后一层外都填满,且最后一层的节点靠左连续排列。这个"靠左连续"的性质极其重要——它让树可以用数组紧凑存储。
满二叉树: 完全二叉树(最后一层靠左):
1 1
/ \ / \
2 3 2 3
/ \ / \ / \ /
4 5 6 7 4 5 6 ← 7 的位置空着也没关系,只要靠左连续
代码实现
使用链表的方式实现是最高效快捷的,代码如下 :
type TreeNode struct {
Val int
Left *TreeNode
Right *TreeNode
}
完全二叉树使用数组的形式也是可以实现的,每个下标对应的节点是
对下标 i(从 0 开始)的节点:
左孩子 = 2i + 1
右孩子 = 2i + 2
父亲 = (i - 1) / 2
遍历
树的遍历是按照某种特定的顺序进行访问,通常分为两大类:深度遍历、广度遍历
深度遍历
有三种遍历顺序
- 前序遍历:根 -> 左 -> 右
- 中序遍历:左 -> 根 -> 右 对于二叉搜索树,中序遍历就是升序遍历
- 后序遍历:右 -> 左 -> 根 使用场景:计算目录大小、释放子树、表达式求值。
使用递归可以很方便的写出上述遍历,代码如下:
func Traversal(root *TreeNode) {
if root == nil {
return
}
fmt.Println(root.Val) // 根 —— 把这行移到下面一行是中序, 移到最后是后序
Traversal(root.Left) // 左
Traversal(root.Right) // 右
}
广度遍历
一层一层从上到下、从左到右访问。
func levelOrder(root *TreeNode) [][]int {
if root == nil {
return nil
}
var res [][]int
queue := []*TreeNode{root}
for len(queue) > 0 {
n := len(queue) // 获取当层节点数量
level := make([]int, 0, n)
for i := 0; i < n; i++ {
node := queue[0]
queue = queue[1:]
level = append(level, node.Val)
if node.Left != nil {
queue = append(queue, node.Left)
}
if node.Right != nil {
queue = append(queue, node.Right)
}
}
res = append(res, level) // 每一层单独成组
}
return res
}
二叉搜索树(BST)
普通的树,节点之间是没有任何规律的,进行查找还需要遍历,效率接近O(n)。
二叉搜索树是一个特殊的树,它的节点遵循,左节点的值小于 < 父节点,右节点的值 > 父节点的值,这使得查询的效率提成到了O(h)树高 | log(n),类似二分查找。
func (t *TreeNode) Search(target int) *TreeNode {
cur := t
for cur != nil {
switch {
case target == cur.Val:
return cur
case target < cur.Val:
cur = cur.Left // 目标更小,去左子树
default:
cur = cur.Right // 目标更大,去右子树
}
}
return nil
}
删除操作
删除是此数最麻烦的操作,为了保证遵循的规则,删除分三种情况:
- 叶子节点,直接删除就可以了
- 有一个子节点,直接让叶子节点变成自己
- 有两个字节点,删除时,需要找到右子树里最小的节点(中序后继)或左子树里最大的(中序前驱),用它的值替换自己,再去删那个后继/前驱的节点(它必然最多一个孩子,转化为情况 1 或 2)。
代码如下 :
func (node *TreeNode) Delete(value int) *TreeNode {
if node == nil {
return nil
}
if value < node.Value {
node.Left = node.Left.Delete(value)
} else if value > node.Value {
node.Right = node.Right.Delete(value)
} else {
if node.Left == nil {
return node.Right
} else if node.Right == nil {
return node.Left
}
minRight := node.Right.findMin() // 找到右节点中最小的 // 中序前继,找到左节点中最大的子节点 maxLeft := node.Left.findMax()
node.Value = minRight.Value // node.Value = maxLeft.Value
node.Right = node.Right.Delete(minRight.Value) // node.Left = node.Left.LeftDelete(maxLeft.Value)
}
return node
}
func (node *TreeNode) findMin() *TreeNode {
current := node
for current.Left != nil {
current = current.Left
}
return current
}
func (node *TreeNode) findMax() *TreeNode {
current := node
for current.Right != nil {
current = current.Right
}
return current
}
测试时,可以使用下面的结构构建树:
50
/ \
30 70
/ \
20 40
删除50这个node,最终40会提升上去。30的这个node的右节点是nil。
极端情况
二叉搜索树的搜索复杂度 O(log n) 是有前提的——树必须是平衡的。如果按已经有序的数据依次插入(1,2,3,4,5……),每次都插到右边,树就退化成一根"只有右孩子的链”,高度 h=n,查找退回 O(n),和链表没区别,如下:
1
\
2
\
3
\
4
\
5
所以针对涂上情况,出现了平衡树
平衡二叉树
针对上述情况导致数长歪了,那就在每次删除插入之后,自动调整,保证子树的高度不超过某个上限,把高度牢牢控制在O(log n),调整的核心手段是旋转-通过局部的左旋转和右旋转,在保证BST的性质下降低高度。常用的结构有:
- AVL树:属于规则最严格的树,任意节点的左右子树高度差<=1。查找最快,但每次插入和删除旋转调整频繁。
- 红黑树:对平衡的规则要求稍微放宽了一些,插入删除调整的最少,也是最常用的平衡树(用红/黑规则保证最长路径≤ 2×最短路径)。
- B 树/ B+ 树 :一个节点可以储存多个子节点,让树更矮胖,极少磁盘IO,这种树在数据库中经常使用。
B 树 / B+ 树
之前演示的树都是按照放在内存里面进行的测试,但如果把树放到磁盘上,这些树就会很吃力了,因为磁盘IO是有上限的。总所周知,内存随机访问是很快的,可能也就几纳秒,磁盘一次随机访问的时间可能是几毫秒,差了很多倍。访问树时,没往下走一层,可能就需要一次磁盘IO,一颗存了10 万条记录的二叉树/平衡树,访问叶子节点,需要的IO次数可能超过你的想象。
而针对这类情况,解决方法也是有的,那就是让树变得矮胖,把一个节点只有两个子节点改成可以拥有多个节点,树的高度就直接塌下来了,这个就是B 树。
- 一个节点存一批有序的键 + 多个孩子指针,不再是二叉树。一个节点的大小正好对齐一个磁盘页(通常 4KB/16KB),这样"读一个节点 = 一次磁盘 IO"最划算——一次 IO 就把几百个键捞进内存,并且数据全部下沉到叶子节点,叶子节点使用双向链表串起来,这个就是B+ 树。
- 所有叶子在同一层(严格平衡),查找/插入/删除都是 O(logₘ n),这里的底数 m(路数/扇出)是几百上千,所以树高极矮。
- 算一笔账:扇出 1170 的 B+ 树,3 层就能索引约 2000 万条记录——查任意一条,最多 3 (计算: log₁₁₇₀(2000万) ≈ 3)次磁盘 IO。对比二叉树的 25(计算:log₂(2000万) ≈ 25) 次磁盘IO,这就是数据库索引选它的根本原因。
扇出 1170 这个数字不是大风刮来的,是计算出来的,一个磁盘页,按照16KB进行计算,假设主键是bigint,8字节,子节点指针6字节,一条索引项是14字节, 16384 / 14 = 1170,三层树的结构就可以包含
1 层:1170
2 层:1170² ≈ 137 万
3 层:1170³ ≈ 16 亿
B+ 树(3 阶示意,真实场景每节点几百个键):
[ 10 | 20 ] ← 内部节点:只放"路标"键 + 孩子指针
/ | \
[1|4|8] [11|15] [22|30] ← 叶子:放全部真实数据
↕ ↕ ↕
叶子之间用链表串起来 → 范围查询顺着链走,不用回树上跳
B 树和 B+ 树的区别大致如下:
B 树: [key|data|key|data|...] 一页塞不下几条
B+树: [key|ptr|key|ptr|...] 一页塞 1170 条
并不是所有的搜索引擎都用B+ 树。像LevelDB这种使用的是LSM-Tree(日志结构合并树)- 把随机写变成顺序写(先写内存memtable,再批量写到磁盘中的有序文件中)。使用: 读多写少用 B+ 树, 写多读少用 LSM-Tree。
Trie(前缀树 / 字典树)
Trie 是一种多叉树,专门用来高效存储和检索字符串集合,尤其擅长前缀匹配。
思路:把字符串的每个字符作为一条边,从根到某节点的路径就拼成一个前缀。 公共前缀的字符串共享同一段路径。
存入 {"cat", "car", "card", "dog"}:
(root)
/ \
c d
| |
a o
/ \ |
t r g
(end)|
(end)
|
d
(end)
- 查一个词是否存在、或以某前缀开头,时间只和词的长度 L 有关,即 O(L),与词库大小无关。
- 代价是空间:每个节点要为可能的下一个字符存指针(如 26 个字母就是
[26]*Node或一个 map)。
用途:搜索引擎/输入法的自动补全、拼写检查、IP 路由表的最长前缀匹配、敏感词过滤。以太坊的 MPT(Merkle Patricia Trie) 就是 Trie 的密码学增强变体。