00 · 设计推演:数据结构怎么选

“不存在’最好的’数据结构。每一个结构都是被一组具体约束逼出来的形状。“


开场:一个具体的起点

老陈:需求很朴素——存 100 万用户,支持按 ID 查询。

小林:用 Go 的 map 啊。

老陈:不许用现成的。你自己设计。

小林:……那就用个数组?

老陈:好。写下来,然后我问你:查询 id=873245 要多久?


v0:无序数组

type User struct {
	ID   int
	Name string
}
 
var users []User   // 100 万个
 
func Find(id int) *User {
	for i := range users {
		if users[i].ID == id {
			return &users[i]
		}
	}
	return nil
}

这一版能工作。

失败:查询 O(n),100 万数据平均要扫 50 万次。假设每次比较 1ns,就是 500 微秒。

老陈:如果 QPS 是 1 万,光查找就要花掉 5 秒的 CPU 时间/秒。也就是 5 个核全耗在查找上。

小林:那……排序一下?

老陈:排序了怎么查?


v1:有序数组 + 二分查找

// 按 ID 排序
func Find(id int) *User {
	lo, hi := 0, len(users)-1
	for lo <= hi {
		mid := (lo + hi) / 2
		switch {
		case users[mid].ID == id:
			return &users[mid]
		case users[mid].ID < id:
			lo = mid + 1
		default:
			hi = mid - 1
		}
	}
	return nil
}

改进巨大:O(n) → O(log n)。100 万数据只要 20 次比较。

失败:

#问题后果
1插入 O(n)插到中间要移动后面所有元素
2删除 O(n)同理
3★ 缓存不友好(隐蔽但严重)见下

问题 3 值得展开:

二分查找的访问模式:
  第1次: users[500000]
  第2次: users[250000]    ← 跳了 250000 × sizeof(User)
  第3次: users[125000]
  ...

★ 每次跳跃都是几十 KB 甚至 MB 的跨度
★ 每次都是 cache miss + 大概率 TLB miss

实测对比(第 1 章第 01 节的数据):
  顺序扫描:   ~3 ns/元素
  随机访问:   ~50 ns/元素(跨页时更糟)

★ 所以"20 次比较"听起来很美,但可能是 20 次 cache miss
  实际耗时可能接近顺序扫几百个元素

老陈:这就是”理论复杂度”和”实际性能”的第一次冲突。

后面你会反复遇到:O(log n) 的随机访问,可能比 O(n) 的顺序扫描还慢。

数据结构的性能 = 复杂度 × 每次操作的常数,而常数主要由内存访问模式决定。


v2:链表

针对 v1 的插入问题,改用链表。

type node struct {
	user User
	next *node
}

改进:插入 O(1)(已知位置时)。

失败:查询回到 O(n),而且更慢——

数组的二分: 20 次跳跃访问,虽然 cache miss,但至少能跳
链表的查找: 必须顺着 next 指针走,n 次访问,且每个节点可能在不同内存页

★ 链表是最缓存不友好的结构,没有之一

老陈:链表在现代 CPU 上几乎是性能灾难。

Go 的 container/list 用得少不是没原因的。

如果非要链表语义,用数组 + 索引(第 1 章讲的 CSR 格式)——把指针换成 int32 索引,缓存友好得多。

v1 和 v2 各解决一半问题。能不能都要?


v3:二叉搜索树(BST)

思路:既支持 O(log n) 查询,又支持 O(1) 插入。

        500
       /   \
    250     750
   /  \     /  \
 125  375  625  875
type node struct {
	user       User
	left, right *node
}
 
func (n *node) find(id int) *User {
	for n != nil {
		switch {
		case n.user.ID == id:
			return &n.user
		case id < n.user.ID:
			n = n.left
		default:
			n = n.right
		}
	}
	return nil
}

理想情况:O(log n) 查询 + O(log n) 插入。

★ 致命失败:退化

如果按 ID 递增顺序插入: 1, 2, 3, 4, 5...

1
 \
  2
   \
    3
     \
      4
       \
        5

★ 退化成链表!查询变回 O(n)

老陈:这就是”最坏情况”的价值。

BST 平均 O(log n) 听起来不错,但”平均”取决于输入分布。 而真实数据往往是有序的(自增 ID、时间戳)——恰好是最坏情况。

记住:永远问”最坏情况是什么”。如果最坏情况不可接受,平均再好也没用。

这也是为什么快速排序要随机选 pivot(第 2 章第 02 节),为什么 Go 的 map 要随机哈希种子。


v4:自平衡树(AVL / 红黑树)

思路:插入删除时自动调整,保证树高 O(log n)。

AVL红黑树
平衡标准左右子树高度差 ≤ 1黑高相同,无连续红节点
树高上限1.44 log n2 log n
查询更快(更平衡)稍慢
插入删除更多旋转更少旋转
适用查询密集★ 插入删除频繁(通用)

改进:最坏情况也是 O(log n)。这就是 std::map、Java TreeMap、Linux 内核 rbtree 的选择。

失败:

#问题说明
1指针跳转,缓存不友好树高 20 = 20 次随机内存访问
2★ 磁盘上完全不可用见下
3并发困难旋转会修改多个节点,锁粒度大

问题 2 是关键的:

假设数据在磁盘上,1 亿条记录:

红黑树:  树高 log₂(10⁸) ≈ 27
  → 查询一次要 27 次磁盘 IO
  → HDD: 27 × 10ms = 270 ms   ★ 灾难
  → SSD: 27 × 100μs = 2.7 ms

★ 磁盘的"每次访问成本"是内存的 10⁵ 倍
  这时候"减少访问次数"比"减少比较次数"重要一万倍

老陈:这就是数据结构设计里最重要的一次视角转换:

当存储介质变化时,优化目标也变了。

内存:  优化"比较次数" → 平衡树
磁盘:  优化"IO 次数"   → B+Tree

不是红黑树不好,是它的优化目标不适合磁盘。


v5:哈希表

换个思路:不做比较,直接算出位置。

type HashTable struct {
	buckets [][]Entry
}
 
func (h *HashTable) Get(key string) (Entry, bool) {
	idx := hash(key) % len(h.buckets)
	for _, e := range h.buckets[idx] {
		if e.Key == key {
			return e, true
		}
	}
	return Entry{}, false
}

改进:O(1) 查询,一次哈希 + 一两次内存访问。

失败:

#问题后果
1★ 不支持范围查询查询 id 在 1000-2000 之间 只能全表扫
2哈希冲突退化成链表(所以要好的哈希函数 + 扩容)
3无序不能顺序遍历
4扩容抖动rehash 时延迟尖刺(Go map 用渐进式扩容缓解)

问题 1 是根本性的:

真实业务里,"范围查询"的需求极其常见:
  · 查某时间段的订单
  · 查分数前 100 名
  · 查某价格区间的商品
  · ORDER BY / GROUP BY

★ 数据库索引必须支持范围查询,所以哈希索引几乎不被用作主索引
  (MySQL 只有 MEMORY 引擎支持哈希索引,且是显式指定)

v6:跳表(Skip List)

思路:在有序链表上加”多层索引”,用概率代替严格平衡。

第3层:  1 ────────────────────► 9
第2层:  1 ────────► 5 ────────► 9
第1层:  1 ──► 3 ──► 5 ──► 7 ──► 9

改进:

优势说明
实现简单比红黑树简单一个数量级(Redis 作者的理由)
★ 范围查询友好底层是有序链表,找到起点后顺序扫
并发友好插入只改局部指针,不需要旋转
查询 O(log n)概率意义下

Redis 选跳表而不是红黑树的理由(作者 antirez 的原话):

“Skip lists are… simpler to implement… and they offer… better performance for range queries (ZRANGE).”

失败:

#问题
1还是内存结构,磁盘上依然不行
2内存开销(每个节点多层指针,约 1.33 倍)
3概率平衡,最坏情况无保证(虽然概率极低)

v7:B+Tree —— 为磁盘而生

核心洞察:磁盘的最小 IO 单位是”页”(4KB/16KB)。一次 IO 能读一个页,那就要让一个页里装尽可能多的索引项。

第一个改动:多路搜索树

红黑树:    每个节点 1 个 key,2 个子节点
B+Tree:    每个节点 1000+ 个 key,1000+ 个子节点

★ 树高从 27 降到 3
★ IO 次数从 27 降到 3

第二个改动:节点大小 = 页大小

节点 16KB:
  key 8 字节 + 指针 6 字节 = 14 字节
  → 1170 个 key/节点
  → 树高 3: 1170³ ≈ 16 亿条记录
  → ★ 16 亿数据,最多 3-4 次磁盘 IO

第三个改动:内部节点只存 key,不存数据

B-Tree:   所有节点都存数据 → 每节点 key 数少 → 树高
B+Tree:   只有叶子存数据   → 每节点 key 数多 → 树矮
★ 这是 B+Tree 相对 B-Tree 的核心优势

第四个改动:叶子用链表相连

范围查询 "id 1000-2000":
  1. 定位到 id=1000 的叶子(3 次 IO)
  2. ★ 顺着链表扫,几乎全是顺序 IO
  
  B-Tree 要中序遍历(在树里上下跳,随机 IO)
  B+Tree 只要顺序扫

这四个改动,每一个都针对磁盘的一个特性:

改动针对的磁盘特性
多路减少 IO 次数(IO 是主要成本)
节点 = 页大小一次 IO 读回尽量多的数据
内部节点只存 key让树更矮
叶子链表范围查询变成顺序 IO

失败:

#问题
1随机写
2页分裂代价高
3崩溃时页可能写一半

v8:LSM-Tree —— 换个方向优化写

B+Tree 的问题 1(随机写)在 HDD 上是致命的(随机写比顺序写慢 100 倍)。

换个思路:如果所有写都是追加呢?

B+Tree:  找到位置 → 原地修改 → 随机写
LSM:     追加到日志 → 内存排序 → 批量写成有序段 → 后台合并
         ★ 全是顺序写

(完整演进见第 4 章 00-设计推演:从零造一个存储引擎)

代价转换:

B+Tree:  写放大(页级)  →  读快(3-4 次 IO)
LSM:     写快(顺序)    →  读放大(要查多层多段)

★ 这是一次”约束转移”,不是”问题解决”。

老陈:这里有个很重要的认知升级:

很多设计改进,本质上不是”把问题解决”,而是”把问题从贵的资源转移到便宜的资源”。

  • LSM: 把”磁盘随机写”转移到”磁盘顺序写 + CPU(合并)+ 空间(写放大)”
  • 缓存: 把”延迟”转移到”内存成本”
  • 索引: 把”查询时间”转移到”存储空间 + 写放大”
  • CDN: 把”源站带宽”转移到”边缘节点成本”

当你优化不动时,问自己:“这个成本能不能转嫁到别的地方?”


★ 决策树:怎么选

需要什么操作?
│
├─ 只要点查询,不要范围
│   ├─ 数据在内存  → 哈希表
│   └─ 数据在磁盘  → 哈希索引(很少见)或 B+Tree
│
├─ 需要范围查询
│   ├─ 数据在内存
│   │   ├─ 读多写少        → 有序数组(二分)
│   │   ├─ 读写均衡        → 红黑树
│   │   ├─ 并发高 + 实现简单 → ★ 跳表
│   │   └─ 插入多、查询少   → 链表(但先看能不能用数组+索引)
│   │
│   └─ 数据在磁盘
│       ├─ 读多写少 / 需要事务 → ★ B+Tree
│       └─ 写多读少 / 数据量大 → ★ LSM-Tree
│
└─ 需要模糊查询 / 全文检索
    └─ ★ 倒排索引(第 4 章第 03 节)

速查表

结构查询插入范围有序磁盘友好并发代表
无序数组O(n)O(1)摊销❌❌❌✅小数据集
有序数组O(log n)O(n)✅✅⚠️✅静态查表
链表O(n)O(1)❌可维护❌⚠️队列/栈
BSTO(log n)均O(log n)均✅✅❌⚠️教学
红黑树O(log n)O(log n)✅✅❌⚠️TreeMap、epoll
跳表O(log n)概O(log n)概✅✅❌✅Redis ZSet
哈希表O(1)O(1)❌❌❌⚠️Go map
B+TreeO(log n)O(log n)✅✅★✅⚠️MySQL、PG
LSMO(层数)★O(1)✅✅★✅✅RocksDB、Lucene

★ 这一节真正的收获

不是”这些结构长什么样”,而是这条需求驱动的推导链:

需求: 存 100 万用户,按 ID 查
  │
  ├─ v0 无序数组      失败: O(n) 查询
  ↓
  ├─ v1 有序数组+二分  失败: O(n) 插入;cache 不友好
  ↓
  ├─ v2 链表          失败: 查询回退 O(n),缓存更糟
  ↓
  ├─ v3 BST           失败: 有序输入退化成链表(最坏情况!)
  ↓
  ├─ v4 红黑树        失败: 磁盘上树太高(27 次 IO)
  ↓
  ├─ v5 哈希表        失败: 不支持范围查询
  ↓
  ├─ v6 跳表          失败: 仍是内存结构
  ↓
  ├─ v7 B+Tree        ← 优化目标从"比较次数"转为"IO 次数"
  │    失败: 随机写、写放大
  ↓
  └─ v8 LSM           ← 用"顺序写"换"读放大"

五个可以迁移的思维模式

① 永远问最坏情况

v3 的 BST 平均 O(log n) 很美,但有序输入退化成 O(n)。平均好不算好。

② 理论复杂度 ≠ 实际性能

v1 的二分是 O(log n),但 20 次 cache miss 可能比顺序扫 200 个元素还慢。 常数因子由内存访问模式决定。

③ 存储介质变了,优化目标就变了

内存: 优化比较次数(红黑树)
磁盘: 优化 IO 次数(B+Tree)
SSD:  优化写放大(LSM)

④ 优化不动时,转移成本

LSM 没有”解决”写放大,而是把它转移成了读放大和空间放大。 问自己:“这个成本能转嫁到哪里?”

⑤ 先做最差版本,让失败指引方向

v0 很烂,但每个后续版本的改动,都是被 v0-vN 的具体失败逼出来的。 没有失败的清单,就没有改进的方向。


现在,轮到你了

练习:为下面这个需求推导数据结构。

需求:实时排行榜。100 万玩家,分数会频繁变动(每秒几万次更新),要能:

  • 查某个玩家的排名(第几名)
  • 查前 100 名
  • 查某个玩家前后 10 名(“我附近的人”)

v0 会是什么?会失败在哪?

提示(不要用,先自己想):

  • v0 可能是”排序数组”,失败在更新 O(n)
  • 需要考虑:排名怎么 O(log n) 算出来?
  • “查前 100 名”要求什么遍历能力?
  • 分数相同怎么办?

老陈:答案是”跳表 + 每个节点维护 span(跨度)“——Redis 的 ZSet 在元素较大时就是这么做的。

span 让”查排名”从 O(n) 变成 O(log n):从最高层往下走,累加经过的跨度。

这个”用额外字段加速特定查询”的技巧,跟 B+Tree 的叶子链表是一类思路: 为高频查询专门设计一条快速路径。


下一节

01 · 复杂度分析与算法工程观

这一节我们建立了”约束塑造结构”的视角,那一节会量化它——为什么 O(log n) 可能比 O(n) 慢,以及怎么算真实的常数因子。