你有 100 万个用户 ID,要反复回答”这个 ID 在不在里面”。存成 Python 列表,每次 x in lst 平均要比较 50 万次;存成 set,一次查询几乎是常数时间。数据是一样的,只是摆放方式不同,速度差了五个数量级。数据结构研究的就是这件事:为你最常做的操作,选择最合适的摆放方式。
抽象与实现:先说清要什么操作
CS61B 的第一课是把”接口”和”实现”分开。抽象数据类型(ADT)只规定有哪些操作:一个 Set 要支持 add、contains、remove;一个优先队列要支持 insert 和 removeMin。至于底下用数组、链表、树还是哈希表,是实现的选择,不同的选择对应不同的性能。
Java 把这件事写进了语言:List<Integer> xs = new ArrayList<>(); 左边是接口,右边是实现,换成 LinkedList 其余代码不用改。(下面是 Java 示意,不运行)
// 示意:同一个接口,两种实现,性能特征完全不同
List<Integer> a = new ArrayList<>(); // get(i) O(1),头部插入 O(n)
List<Integer> b = new LinkedList<>(); // get(i) O(n),头部插入 O(1)Java 还有一个初学者常踩的坑,与后面所有结构都有关:引用语义。除了 int、double 这 8 种基本类型,Java 变量里存的都是对象的地址。Node b = a; 不复制节点,只复制地址,于是 b.next = null 也改了 a 看到的节点。链表和树就是由这些地址串起来的。Python 的名字和对象也是同一种模型(见 Python 对象模型),下面的代码用 Python 写,概念完全通用。
数组与链表:两种最基本的摆放
数组把元素放在一段连续内存里。知道起始地址和下标,第 i 个元素的地址是 base + i * size,所以随机访问是 。代价是插入和删除:在中间插一个元素,后面的元素都要往后挪,。
链表让每个节点保存一个值和指向下一个节点的引用。在已知位置插入或删除只要改几个引用,;但想找第 i 个元素只能从头一个个走,。CS61B 用哨兵节点(sentinel)简化链表代码:在链表头放一个永远存在的假节点,空表和非空表就不用分情况处理。双向链表再加一个尾哨兵,两端的插入删除都是 。
数组有一个更根本的问题:长度固定。动态数组(Python list、Java ArrayList)的做法是:满了就申请一块更大的内存,把旧元素拷过去。关键在于扩多大。
如果每次只扩 1 格,插入 个元素要拷贝 次。如果每次翻倍,拷贝次数是 ,平均到每次插入只有常数次。这叫均摊 :个别插入很贵,但贵的操作足够稀少,摊下来每次都便宜。
import sys
xs, last = [], sys.getsizeof([])
# 观察 CPython list 什么时候真正重新分配内存
for i in range(40):
xs.append(i)
size = sys.getsizeof(xs)
if size != last:
print(f"len={len(xs):3d} bytes={size}")
last = size输出:
len= 1 bytes=88
len= 5 bytes=120
len= 9 bytes=184
len= 17 bytes=248
len= 25 bytes=312
len= 33 bytes=376每次 bytes 跳变就是一次重新分配(每个槽 8 字节)。CPython 的规则大约是新容量 = n + n/8 + 小常数,小规模时看起来像每次多给 8 个槽,规模大了才接近 1.125 倍,比翻倍保守,但道理一样:只要按比例增长,均摊代价就是常数;按固定量增长,均摊代价就是线性的。
在数组和链表之上,栈(后进先出)和队列(先进先出)只是限制了操作的位置。栈用动态数组的尾部实现最自然,append 和 pop 都是均摊 ;函数调用栈、括号匹配、DFS 都用它。队列在数组上需要环形缓冲区,或者用双向链表;Python 的 collections.deque 就是分块双向链表,BFS 用它。用 list.pop(0) 实现队列是常见的性能错误,那是 。
还有一个数组胜出的理由在复杂度里看不出来:缓存。数组连续存放,读一个元素时 CPU 会把附近的元素一起装进 cache line;链表的节点散落在堆上,每走一步都可能是一次 cache miss。实际测量中,遍历数组往往比遍历同样长度的链表快好几倍(见 内存层次与缓存)。
动态数组每次满了就把容量扩大为原来的 1.5 倍。连续 append n 个元素,总的拷贝次数是什么量级?
按比例扩容时,各次扩容的拷贝量构成几何级数,总和被最后一次的若干倍界住,是 Θ(n),所以均摊常数。
每次扩容确实拷贝全部元素,但扩容很稀少,不是每次 append 都发生。扩容次数是 log n 级别,但每次的拷贝量不同,不能简单相乘。总量是 Θ(n) 不是常数,常数的是均摊到每次的代价。
哈希表:用计算代替查找
回到开头的问题:怎么让 contains 变成 ?
一个荒唐但有启发的想法:如果 ID 都是 0 到 999999 之间的整数,开一个长度为 100 万的布尔数组,present[id] = True,查询就是一次数组访问。问题是键的范围可能极大(任意字符串),根本开不出这么大的数组。
哈希表的修正是:用一个哈希函数把任意键映射成一个小范围内的整数 h(key) % m,作为桶的下标。不同的键可能落进同一个桶(鸽巢原理保证这一定会发生,见 离散数学),处理冲突有两种主流办法:
- 拉链法(separate chaining):每个桶是一个链表,冲突的键都挂在上面。Java 的
HashMap用它,桶里元素太多时还会把链表换成红黑树。 - 开放寻址:冲突了就按某种规则找下一个空位。CPython 的
dict和set用它,元素连续存放对缓存更友好。
性能取决于负载因子 (元素数除以桶数)。哈希函数足够均匀时,拉链法每次操作期望 。只要在 超过阈值(比如 0.75)时把桶数翻倍并重新哈希所有元素, 始终有界,操作就是均摊期望 ,和动态数组的论证是同一个套路。
import random, time
class ChainedHashSet:
def __init__(self, m=8):
self.buckets = [[] for _ in range(m)]; self.n = 0
def _bucket(self, key):
return self.buckets[hash(key) % len(self.buckets)]
def add(self, key):
b = self._bucket(key)
if key in b: return
b.append(key); self.n += 1
if self.n / len(self.buckets) > 0.75: # 负载因子有界才能保证 O(1)
old = [k for b in self.buckets for k in b]
self.buckets = [[] for _ in range(2 * len(self.buckets))]
for k in old: self._bucket(k).append(k)
def __contains__(self, key):
return key in self._bucket(key)
random.seed(0)
ids = random.sample(range(10**9), 100_000)
hs, lst = ChainedHashSet(), list(ids)
for x in ids: hs.add(x)
queries = random.sample(ids, 200)
t = time.perf_counter(); r1 = sum(q in hs for q in queries); t1 = time.perf_counter() - t
t = time.perf_counter(); r2 = sum(q in lst for q in queries); t2 = time.perf_counter() - t
print("桶数:", len(hs.buckets), " 最长的桶:", max(map(len, hs.buckets)))
print(f"hash set: {t1*1e3:.2f} ms list: {t2*1e3:.1f} ms 结果一致: {r1 == r2}")输出:
桶数: 262144 最长的桶: 6
hash set: 0.20 ms list: 99.3 ms 结果一致: True10 万个元素,最长的桶也只有个位数长度,哈希查询比线性扫描快了几个数量级。
哈希表有两条使用规则,违反了就会出诡异的 bug。第一,相等的对象必须有相同的哈希值。在 Java 里重写了 equals 却忘了重写 hashCode,两个”相等”的对象会落进不同的桶,contains 找不到。第二,键不能在放进去之后被修改,否则它的哈希值变了,却还待在旧桶里。这就是 Python 不允许 list 当字典键、只允许 tuple 这类不可变对象的原因。
哈希表的代价是无序:它回答不了”比 x 大的最小元素是什么”或”按顺序列出所有元素”。需要顺序,就需要树。
一个 Java 类重写了 equals(按 id 字段比较),但没有重写 hashCode。把两个 id 相同的对象依次放入 HashSet,可能发生什么?
默认的 hashCode 基于对象身份,两个对象哈希值通常不同,会落进不同的桶,HashSet 根本不会在同一个桶里调用 equals 比较它们,于是重复元素进来了,查询也找不到。
Java 编译器不检查这条约定,也不会抛异常,程序会安静地给出错误结果,这正是它危险的地方。
二叉搜索树:有序地快速查找
想要既快又有序,最自然的想法来自二分查找:有序数组上查找是 ,但插入要挪元素,。能不能把”二分”的结构直接用指针搭出来?
二叉搜索树(BST)的每个节点满足:左子树所有键都比它小,右子树所有键都比它大。查找从根出发,比当前节点小就往左,大就往右,每一步排除一棵子树。插入就是查找到空位后挂上新节点。中序遍历(左、根、右)按从小到大的顺序输出所有键,于是”有序”免费得到;求后继、范围查询也都沿着树走一条路径。
所有操作的代价都是树高 。随机顺序插入时,期望高度是 。但如果按已排序的顺序插入,每个新节点都挂在最右边,树退化成一条链,。
import random
def insert(root, key):
# 用 [key, left, right] 列表代替类,保持示例短小
if root is None: return [key, None, None]
i = 1 if key < root[0] else 2
root[i] = insert(root[i], key)
return root
def height(t):
return 0 if t is None else 1 + max(height(t[1]), height(t[2]))
import sys; sys.setrecursionlimit(10000)
n = 2000
keys = list(range(n)); random.seed(1); random.shuffle(keys)
t_random = None
for k in keys: t_random = insert(t_random, k)
t_sorted = None
for k in range(n): t_sorted = insert(t_sorted, k)
print("随机顺序插入,高度:", height(t_random))
print("有序插入,高度:", height(t_sorted))输出:
随机顺序插入,高度: 24
有序插入,高度: 2000现实数据经常是有序或接近有序的(时间戳、自增 ID),所以普通 BST 不可靠。需要一种能自己保持平衡的树。
2-3 树的想法是让节点变”胖”:一个节点可以放 1 个键(2 个孩子)或 2 个键(3 个孩子)。插入时新键总是先放进叶子;如果叶子变成 3 个键,就把中间的键往上推给父节点,自己分裂成两个。分裂可能一路传到根,根分裂时树高加 1。注意树只会从根长高,所以所有叶子永远在同一深度,树高被严格控制在 。
2-3 树概念清晰但实现繁琐(节点类型不统一)。左倾红黑树(LLRB)是它的二叉编码:把 3-节点拆成两个普通节点,中间用一条”红色”的左链接相连。红黑树和 2-3 树一一对应,于是所有操作只需要普通 BST 的代码,加上三种局部修正:左旋、右旋、颜色翻转。Java 的 TreeMap、C++ 的 std::map 都是红黑树。
把”胖节点”的想法推到极致,让一个节点装几百个键,就得到 B 树:树高只有三四层,每层一次磁盘读,这是数据库索引的基础(见 数据库系统)。
依次向一棵空的普通 BST 插入 1, 2, 3, ..., 1000,然后查找 1000。需要比较多少次?
有序插入时,每个新键都比已有的所有键大,总是挂在最右边,树高等于 n,查找最大值要走完整条链。
O(log n) 只在树平衡时成立,普通 BST 没有任何保证。500 次是对随机位置的平均,这里查的是最深的节点。BST 插入不会把新节点放到根上,根始终是第一个插入的 1。
堆:只关心最小的那个
有些场景不需要完整的顺序,只需要反复取出”当前最小(最紧急)的那个”:操作系统调度进程,Dijkstra 算法取下一个最近的顶点,合并 k 个有序列表,推理服务按优先级取请求。这就是优先队列 ADT:insert 和 removeMin。
用有序数组实现,removeMin 是 但 insert 是 ;用平衡树两者都是 ,但杀鸡用了牛刀。二叉堆用更弱的约束换来更简单的实现:每个节点比它的孩子小(堆序),树是完全二叉树(除最后一层外全满,最后一层从左往右排)。
完全二叉树可以直接存进数组,不需要指针:下标 i 的孩子在 2i+1 和 2i+2,父亲在 (i-1)//2。
insert:放到数组末尾,然后不断和父亲比较,比父亲小就交换(上浮),。removeMin:取走根,把最后一个元素移到根,然后不断和较小的孩子比较,比它大就交换(下沉),。
import heapq, random
class MinHeap:
def __init__(self): self.a = []
def push(self, x):
a = self.a; a.append(x); i = len(a) - 1
while i > 0 and a[(i - 1) // 2] > a[i]:
a[(i - 1) // 2], a[i] = a[i], a[(i - 1) // 2]; i = (i - 1) // 2
def pop(self):
a = self.a; top = a[0]; last = a.pop()
if a:
a[0] = last; i = 0
while True:
c = 2 * i + 1
if c >= len(a): break
if c + 1 < len(a) and a[c + 1] < a[c]: c += 1 # 和较小的孩子换才能保持堆序
if a[i] <= a[c]: break
a[i], a[c] = a[c], a[i]; i = c
return top
random.seed(2); data = [random.randint(0, 99) for _ in range(12)]
h = MinHeap()
for x in data: h.push(x)
print("数组布局:", h.a)
print("依次弹出:", [h.pop() for _ in range(len(data))])
print("与 heapq 一致:", heapq.nsmallest(12, data) == sorted(data))输出:
数组布局: [7, 11, 10, 32, 21, 77, 85, 46, 39, 77, 27, 94]
依次弹出: [7, 10, 11, 21, 27, 32, 39, 46, 77, 77, 85, 94]
与 heapq 一致: True数组布局看起来并不有序,只保证每个父亲不大于孩子,但依次弹出就是排好序的序列,这就是堆排序。从一个无序数组原地建堆只需 (从最后一个非叶子往前依次下沉,大部分节点都在底层,下沉距离很短),比逐个插入的 好。Python 的 heapq 就是这样一个数组上的最小堆。
并查集:动态的连通性
另一类问题是关于分组的:网络里不断有新连接加入,随时问”这两台机器连通吗”;Kruskal 算法加边时问”这条边会不会形成环”;图像里把相邻同色像素合成区域。需要两个操作:union(a, b) 合并两组,find(a) 返回 a 所在组的代表。
最朴素的做法是给每个元素记一个组号,union 时把一组的所有元素改号,。并查集(disjoint set union)换一种表示:每个元素指向一个父亲,根就是组的代表,find 沿父指针走到根,union 把一棵树的根挂到另一棵的根下。
这样 union 很快了,但树可能长成一条长链,find 变成 。两个修正:
- 按大小合并:总是把小树挂到大树下。一个元素所在的树每次被挂到别人下面,大小至少翻倍,所以深度不超过 。
- 路径压缩:
find时把沿途所有节点直接指向根,下次就一步到位。
两者结合后, 次操作的总代价是 , 是反 Ackermann 函数,对任何现实的 都不超过 5,实践中可以当作常数。
import random
class DSU:
def __init__(self, n):
self.parent = list(range(n)); self.size = [1] * n
def find(self, x):
root = x
while self.parent[root] != root: root = self.parent[root]
while self.parent[x] != root: # 路径压缩:把沿途节点直接挂到根上
self.parent[x], x = root, self.parent[x]
return root
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb: return False
if self.size[ra] < self.size[rb]: ra, rb = rb, ra
self.parent[rb] = ra; self.size[ra] += self.size[rb]
return True
def max_depth(d):
def depth(x):
k = 0
while d.parent[x] != x: x = d.parent[x]; k += 1
return k
return max(depth(x) for x in range(len(d.parent)))
n = 100_000; random.seed(3)
d = DSU(n)
for _ in range(n): d.union(random.randrange(n), random.randrange(n))
groups = len({d.find(x) for x in range(n)})
print("连通分量数:", groups, " 压缩后最大深度:", max_depth(d))输出:
连通分量数: 16128 压缩后最大深度: 1并查集只用"按大小合并"、不用路径压缩时,为什么任意元素的深度不超过 log2 n?
元素深度只在它所在的树被挂到另一棵不小于它的树下时加 1,合并后的大小至少是原来的两倍,大小从 1 翻倍到 n 最多 log2 n 次。
并查集不会重建树;压平路径是路径压缩的作用,题目里没有使用;并查集的树没有键的大小顺序,也不是二叉的,和 BST 无关。
图的表示:选择决定了算法的代价
最后,前面所有的结构在图上汇合。图的概念见 离散数学,这里只关心怎么存。
邻接矩阵: 的布尔矩阵,adj[u][v] 表示是否有边。判断两点是否相邻 ,但空间是 ,遍历一个顶点的邻居也要扫一整行 。
邻接表:每个顶点存一个邻居列表。空间 ,遍历邻居的代价正比于邻居数。现实中的图几乎都是稀疏的:一个社交网络有十亿用户,但每人只有几百个好友,邻接矩阵需要 个格子,邻接表只需要千亿级。
| 操作 | 邻接矩阵 | 邻接表 |
|---|---|---|
| 空间 | ||
判断 (u, v) 是否有边 | ||
遍历 u 的所有邻居 | ||
| 遍历整个图 |
于是 BFS 和 DFS 在邻接表上是 ,在邻接矩阵上是 。图算法的复杂度几乎都依赖表示方式。一个算法用到哪些数据结构,也决定了它的速度:BFS 用队列,DFS 用栈(或递归),Dijkstra 用堆,Kruskal 用并查集,Prim 用堆。这些算法本身在 算法设计 里讲。
from collections import deque
edges = [(0, 1), (0, 2), (1, 3), (2, 3), (3, 4), (5, 6)]
n = 7
adj = [[] for _ in range(n)]
for u, v in edges: adj[u].append(v); adj[v].append(u)
def bfs(s):
dist = [None] * n; dist[s] = 0; q = deque([s]) # deque 让出队是 O(1)
while q:
u = q.popleft()
for v in adj[u]:
if dist[v] is None: dist[v] = dist[u] + 1; q.append(v)
return dist
print("邻接表:", adj)
print("从 0 出发的 BFS 距离:", bfs(0))输出:
邻接表: [[1, 2], [0, 3], [0, 3], [1, 2, 4], [3], [6], [5]]
从 0 出发的 BFS 距离: [0, 1, 1, 2, 3, None, None]顶点 5 和 6 与 0 不连通,距离保持 None。
下面哪些选择是合理的?
稀疏图用邻接表空间是 V + E 级别;Dijkstra 反复取最小距离,正是优先队列的用途;Kruskal 判环就是问两个端点是否已经连通,并查集近乎常数时间回答。
list.pop(0) 要把后面的元素全部前移,是 O(n),BFS 会因此退化到平方时间,应该用 collections.deque。
记住这三件事
- 先定操作再选结构:数组随机访问快、链表局部插删快、哈希表查找快但无序、平衡树有序且对数时间、堆只管最小值。
- 按比例扩容带来均摊 ;负载因子有界才有哈希表的 ;树高有界才有 BST 的 。
- 图用邻接表还是邻接矩阵,决定了图算法的复杂度;经典图算法就是”图 + 队列/栈/堆/并查集”。
延伸阅读
- UC Berkeley CS61B Data Structures(Josh Hug)与配套电子书 CS61B Textbook
- Sedgewick & Wayne,《Algorithms》第 4 版及 官网代码(红黑树、并查集的经典讲法)
- Cormen et al.,《Introduction to Algorithms》(CLRS)第 10–13、19 章
- VisuAlgo:数据结构操作的动画演示
- CPython listobject.c 与 dictobject.c(真实世界的动态数组与开放寻址哈希表)