B 树是一棵多路平衡查找树。每个节点可以放多个有序关键字,所有叶子都在同一层。数据库索引和文件系统用它,是因为一个节点正好对应磁盘上的一页:一次读入就能比较多个关键字,树变矮,磁盘次数就少。
下面用最小度数 t = 2 举例。这是教学里最常见的一种,也叫 2-3-4 树。
- 一个节点最多
2t - 1 = 3个关键字。 - 除根以外,每个节点至少
t - 1 = 1个关键字。 - 有
k个关键字的内部节点有k + 1个子节点。 - 关键字从小到大排列。第
i个子树里的值,都落在第i-1个和第i个关键字之间。
插入时怎么分裂
依次插入 10, 20, 5, 6, 12, 30, 7, 17。
节点没满时,只是按顺序插进去:
[10] → [10, 20] → [5, 10, 20]
再插入 6 时,叶子已经有 3 个关键字,必须先分裂。中间的 10 升到父节点,左右各留一半:
[10]
/ \
[5, 6] [20]
12、30、7 都还能放进现有叶子。插入 17 时,右叶子 [12, 20, 30] 已满,中间的 20 升上去:
[10, 20]
/ | \
[5, 6, 7] [12, 17] [30]
查找 12:根上 10 < 12 < 20,走进中间那棵子树,在 [12, 17] 里命中。查找 99:从根走到最右边的 [30],叶子里没有,就确定不存在。整棵树的高度始终一致,不会像普通二叉搜索树那样一边倒。
删除时要维持"至少 t-1 个关键字"。节点不够就向兄弟借一个;兄弟也不够,就和兄弟以及父节点里的分隔关键字合并。插入的核心是满了就分裂,删除的核心是不够就借或合并。
class BTreeNode:
def __init__(self, leaf=True):
self.leaf = leaf
self.keys = []
self.children = []
class BTree:
def __init__(self, t):
if t < 2:
raise ValueError("t 必须 >= 2")
self.t = t
self.root = BTreeNode(leaf=True)
def search(self, key, node=None):
node = self.root if node is None else node
i = 0
while i < len(node.keys) and key > node.keys[i]:
i += 1
if i < len(node.keys) and key == node.keys[i]:
return node, i
if node.leaf:
return None
return self.search(key, node.children[i])
def insert(self, key):
root = self.root
if len(root.keys) == 2 * self.t - 1:
new_root = BTreeNode(leaf=False)
new_root.children.append(root)
self._split_child(new_root, 0)
self.root = new_root
self._insert_non_full(self.root, key)
def _insert_non_full(self, node, key):
i = len(node.keys) - 1
if node.leaf:
node.keys.append(None)
while i >= 0 and key < node.keys[i]:
node.keys[i + 1] = node.keys[i]
i -= 1
node.keys[i + 1] = key
return
while i >= 0 and key < node.keys[i]:
i -= 1
i += 1
if len(node.children[i].keys) == 2 * self.t - 1:
self._split_child(node, i)
if key > node.keys[i]:
i += 1
self._insert_non_full(node.children[i], key)
def _split_child(self, parent, i):
t = self.t
full = parent.children[i]
right = BTreeNode(leaf=full.leaf)
mid = full.keys[t - 1]
right.keys = full.keys[t:]
full.keys = full.keys[: t - 1]
if not full.leaf:
right.children = full.children[t:]
full.children = full.children[:t]
parent.keys.insert(i, mid)
parent.children.insert(i + 1, right)
tree = BTree(2)
for k in [10, 20, 5, 6, 12, 30, 7, 17]:
tree.insert(k)
print(tree.search(12) is not None) # True
print(tree.search(99)) # None
数据库里更常见的是 B+ 树:内部节点只放索引用的关键字,真正的数据都在叶子,叶子之间再串成链表,方便按范围往后扫。
B 树和它的变体 B+ 树,主要用在「按关键字找一页数据,还要经常做范围扫描」的地方。一个节点对应磁盘或 SSD 上的一页,树矮,随机读的次数少。
数据库索引是现在最常见的用途。
- MySQL InnoDB 的聚簇索引和二级索引都是 B+ 树,表数据就挂在主键索引的叶子上。
- PostgreSQL 默认的
btree索引、Oracle 的 B-tree 索引、SQL Server 的聚集/非聚集索引,结构都是 B+ 树。 - SQLite 的表和索引也是 B 树。
- MongoDB 的 WiredTiger 用 B+ 树存集合和索引。
文件系统的目录和元数据也大量用它。
- XFS、NTFS、APFS 用 B+ 树管目录和分配信息。
- btrfs 的核心结构就是写时复制的 B 树。
- ext4 的大目录用 HTree,是按哈希分桶的 B 树。
嵌入式键值库里,LMDB、Berkeley DB 以 B+ 树做主结构。读多、数据要落在本地文件、还要范围查询时,这种结构很合适。
写非常多、可以接受后台合并的系统,常见主结构是 LSM-Tree,例如 RocksDB、LevelDB、Cassandra、HBase。分析型引擎如 StarRocks、Doris 的主键模型也不是用 B 树存整表,而是列存加上稀疏前缀索引。在线事务库的索引和文件系统元数据,目前仍然以 B+ 树为主。