Search Trees, Balance, and External-Memory Indexing
当数据量变大以后,最大的问题是怎样尽快找到我们想要的数据。
共同目标:让查询沿着一条短而有方向的路径前进,而不是扫描全部数据。
同样一组关键字,只满足“左小右大”还不够;树形状会直接决定搜索代价。
二叉搜索树(Binary Search Tree,BST)的操作都是 \(O(h)\)。如果结构退化,\(h\) 可以从 \(O(\log n)\) 变成 \(O(n)\)。
本章的核心就是:怎样增加结构约束,使搜索路径在目标工作负载下保持足够短。
leftSize 思想二叉搜索树可以为空。
非空二叉搜索树满足:
中序遍历二叉搜索树会得到递增序列。
find(…) 根据当前结构的不变量定位目标;未命中时返回失败结果而不破坏结构。
null 仍未命中,候选集合已经为空;查找失败。总成本是 O(h),由树高而不是结点总数直接决定。
findMin 与 findMax最小值和最大值不需要“搜索整个二叉搜索树”。因为二叉搜索树的顺序关系已经把方向写进结构里:最小值只可能沿左孩子链出现,最大值只可能沿右孩子链出现。
findMin / findMax 代码沿一个指针走到底:
static <Key extends Comparable<? super Key>> Node<Key> findMin(Node<Key> root) {
if (root == null) {
return null;
}
while (root.left != null) {
root = root.left;
}
return root;
}
static <Key extends Comparable<? super Key>> Node<Key> findMax(Node<Key> root) {
if (root == null) {
return null;
}
while (root.right != null) {
root = root.right;
}
return root;
}findMin(…) 沿有序树的最左路径寻找最小关键字,不需要扫描其他分支。
findMax(…) 返回整个序列的最大元素;扫描过程中只需维护“已看前缀的最大值”这一不变量。
O(h)。
插入先做一次和查找完全相同的比较路径。真正不同的只有最后一步:当应该继续前进的孩子是 null 时,就在那个位置创建新叶结点。
下面把 61 插入到当前二叉搜索树。注意整个过程中原有结点都不需要搬动。
递归代码中的 root == null 对应搜索路径最终到达的空孩子位置。
private static <Key extends Comparable<? super Key>> int size(Node<Key> node) {
return node == null ? 0 : node.size;
}
private static <Key extends Comparable<? super Key>> int height(Node<Key> node) {
return node == null ? -1 : node.height;
}
private static <Key extends Comparable<? super Key>> void refresh(Node<Key> node) {
node.height = 1 + Math.max(height(node.left), height(node.right));
node.size = 1 + size(node.left) + size(node.right);
}
static <Key extends Comparable<? super Key>> Node<Key> insert(Node<Key> root, Key key) {
if (root == null) {
return new Node<>(key);
}
int comparison = key.compareTo(root.key);
if (comparison < 0) {
root.left = insert(root.left, key);
} else if (comparison > 0) {
root.right = insert(root.right, key);
} else {
return root;
}
refresh(root);
return root;
}size(…) 只报告当前逻辑规模,不应触发结构遍历或修改。
size 把空子树视为 0 个结点,非空时直接读缓存;排名查询因此不需要重新数整棵子树。
height(…) 返回子树高度;空树给出基值,非空结点取两个孩子高度的较大者再加 1。
height 把空子树视为 -1,让叶子高度自然得到 0;AVL 等高度维护可直接复用这个约定。
refresh(…) 根据孩子状态重算结点的派生信息,使高度/规模字段重新与子树一致。
refresh 只根据两个孩子重新计算当前结点的高度和规模;结构一旦变化,缓存必须立即跟上真实子树。
insert(…) 把新数据纳入当前结构;操作结束时必须恢复该结构原有的不变量。
null 时,就是新关键字唯一合法的位置;在那里创建叶结点后把新子树根向上返回。
refresh 并返回当前根,使所有祖先的规模/高度与插入后的结构重新一致。
三种情况: 叶结点直接删除;只有一个非空子树时由该子树接替;有两个非空子树时,用左子树最大值或右子树最小值替代,再递归删除替代结点。
static <Key extends Comparable<? super Key>> Node<Key> remove(Node<Key> root, Key key) {
if (root == null) {
return null;
}
int comparison = key.compareTo(root.key);
if (comparison < 0) {
root.left = remove(root.left, key);
} else if (comparison > 0) {
root.right = remove(root.right, key);
} else if (root.left != null && root.right != null) {
Node<Key> successor = findMin(root.right);
root.key = successor.key;
root.right = remove(root.right, successor.key);
} else {
return root.left != null ? root.left : root.right;
}
refresh(root);
return root;
}remove(…) 从当前结构移除一个目标;操作结束时其余元素仍必须满足原来的表示不变量。
null 让父层保持原本的空链接,不制造额外结构变化。
null 顶替当前结点。
带顺序统计的二叉搜索树在每个结点增加 leftSize:
leftSize = |leftSubtree| + 1
用途:
static <Key extends Comparable<? super Key>> Node<Key> select(Node<Key> root, int zeroBasedRank) {
if (root == null || zeroBasedRank < 0 || zeroBasedRank >= size(root)) {
return null;
}
int leftSize = size(root.left);
if (zeroBasedRank < leftSize) {
return select(root.left, zeroBasedRank);
}
if (zeroBasedRank == leftSize) {
return root;
}
return select(root.right, zeroBasedRank - leftSize - 1);
}select(…) 利用子树规模直接跳过整棵子树,返回按中序排名为 k 的元素。
leftSize 直接告诉我们当前根之前有多少个更小关键字,也就是当前根在这棵子树中的 0-based 排名。
leftSize 时,答案一定在左子树,而且它在左子树中的排名不变,因此只需递归左侧。
leftSize 就命中当前根;更大时进入右子树,并扣掉已经跳过的“整个左子树 + 当前根”共 leftSize+1 个元素。
二叉搜索树插入本身没有任何“拉回平衡”的动作。若按递增顺序插入:
10, 20, 30, 40, 50, 60
每个新关键字都只能继续落到最右边。下面逐步看这棵完全合法的二叉搜索树怎样被插入顺序拉成一条链:
二叉搜索树的查找、插入、删除本质上都是沿一条根到结点的路径工作,所以复杂度应写成 \(O(h)\),不是天然的 \(O(\log n)\)。
有序性还不够,我们还需要控制高度。
理想的改进应该同时保住三件事:
AVL 的思路就是给二叉搜索树再加一个局部高度约束,并在约束被破坏时用旋转恢复它。
AVL 树首先仍然是一棵二叉搜索树;除此之外,对每一个结点都要求:
\[ |height(left)-height(right)|\le 1 \]
本课定义平衡因子为:
\[ bf(x)=height(right)-height(left) \]
因此合法值只有 \(-1,0,1\)。一旦某个结点变成 \(+2\) 或 \(-2\),说明插入让这一局部子树失衡,必须修复。
注意:AVL 不是要求左右子树“完全一样高”,而是只允许高度差最多 1。
旋转的本质是在保持中序顺序不变的前提下改变局部高度。
rotateWithLeftChild(…) 用一次右旋修复左左型失衡,同时重新计算受影响结点的高度。
oldRoot.left,再让 oldRoot 成为 newRoot.right;中序关键字顺序完全不变,只改变局部高度。
oldRoot,再更新新根;返回 newRoot 让父层接回旋转后的子树入口。
LR / RL 的新结点落在“内侧”。例如 LR:失衡点 k3 的左边过高,但真正变高的是 k3.left 的右子树。如果直接右旋 k3,折线仍没有被正确展开。
因此先把内侧折线拉直,再做普通单旋:
k3.left 做一次左旋,把中间结点提上来;k3 做右旋。private static <Key extends Comparable<? super Key>> Node<Key> rotateWithRightChild(
Node<Key> oldRoot) {
Node<Key> newRoot = Objects.requireNonNull(oldRoot.right, "right child");
oldRoot.right = newRoot.left;
newRoot.left = oldRoot;
refresh(oldRoot);
refresh(newRoot);
return newRoot;
}
static <Key extends Comparable<? super Key>> Node<Key> doubleWithLeftChild(Node<Key> root) {
root.left = rotateWithRightChild(root.left);
return rotateWithLeftChild(root);
}doubleWithLeftChild(…) 用左旋再右旋修复左右型失衡,使中间关键字成为新的局部根。
root.left 把“内侧折线”变成 LL,再右旋原根。两个单旋组合后同时恢复局部高度和平衡。
设 \(N_h\) 是高度为 \(h\) 的 AVL 树最少结点数。
最坏形态的一侧高度为 \(h-1\),另一侧高度为 \(h-2\):
\[ N_h=N_{h-1}+N_{h-2}+1 \]
这与 Fibonacci 数同阶,因此:
\[ h=O(\log n) \]
AVL 的查找、插入、删除最坏都是 \(O(\log n)\)。
\(m\) 路搜索树的每个内部结点最多有 \(m\) 个孩子,最多有 \(m-1\) 个关键字。
若某结点有关键字:
\[ k_1<k_2<\cdots<k_p \]
则孩子区间为:
结点内用顺序或二分查找定位区间
二叉搜索树每个结点只有一个关键字、两个孩子。即使保持平衡,数据规模很大时仍需要经过较多层;如果结点存放在磁盘页(page)中,下降一层通常就意味着一次新的磁盘页访问。
B 树把多个有序关键字放进同一个磁盘页。一个结点可以把值域一次分成多个区间,因此扇出(fanout)远大于 2;在相同数据规模下,树通常更矮,查找需要访问的磁盘页也更少。
一个内部结点中有 k 个有序关键字,就把值域切成 k+1 段,因此必须有 k+1 个孩子指针。B 树的关键字本身可以对应记录,所以查找可能在内部结点直接命中。
t:中间结点只能有 \([t, 2t]\) 个子结点(至少有 t-1 个关键字,最多有 2t-1 个关键字)2t-1 个关键字,但是没有下界本节示例取 t=2,所以非根结点允许 1..3 个关键字
private static <Key extends Comparable<? super Key>, Value> int lowerBoundEntries(
List<Entry<Key, Value>> entries, Key key) {
int left = 0;
int right = entries.size();
while (left < right) {
int middle = left + (right - left) / 2;
if (entries.get(middle).key().compareTo(key) < 0) {
left = middle + 1;
} else {
right = middle;
}
}
return left;
}
BTree(int maxKeys) {
if (maxKeys < 3) {
throw new IllegalArgumentException("maxKeys must be at least 3");
}
this.maxKeys = maxKeys;
}
Value search(Key key) {
Objects.requireNonNull(key, "key");
BNode<Key, Value> node = root;
while (true) {
int index = lowerBoundEntries(node.entries, key);
if (index < node.entries.size() && node.entries.get(index).key().compareTo(key) == 0) {
return node.entries.get(index).value();
}
if (node.isLeaf()) {
return null;
}
node = node.children.get(index);
}
}lowerBoundEntries 找到第一个不小于目标的位置,同时确定可能命中的记录与下一层孩子区间。
BTree(…) 构造满足该数据结构初始不变量的对象;后续操作都以这个初始状态为前提。
maxKeys 只决定磁盘页何时溢出并分裂;它不改变关键字有序以及内部结点的区间划分规则。
search(…) 根据当前结构的不变量定位目标;未命中时返回失败结果而不破坏结构。
每到一个结点,先在结点内比较:若关键字命中当前记录,查找结束;否则根据关键字落在哪个区间,只沿一个孩子指针继续下降。
因此查找的两个层次是:结点内定位区间,然后树上下降一层。
下面固定 t=2,插入 63。待插入的结点如果超过 3 个关键字,就需要分裂。
分裂总是从叶子结点开始,并可能逐层向上传导。
private Split<Key, Value> splitNode(BNode<Key, Value> node) {
int middle = node.entries.size() / 2;
Entry<Key, Value> promoted = node.entries.get(middle);
BNode<Key, Value> right = new BNode<>();
right.entries.addAll(node.entries.subList(middle + 1, node.entries.size()));
node.entries.subList(middle, node.entries.size()).clear();
if (!node.isLeaf()) {
right.children.addAll(node.children.subList(middle + 1, node.children.size()));
node.children.subList(middle + 1, node.children.size()).clear();
}
return new Split<>(promoted, right);
}splitNode(…) 把溢出的 B 树结点分成两个合法结点,并把中间关键字提升给父结点。
promoted + right 供父层接入。
private Split<Key, Value> insertRec(BNode<Key, Value> node, Key key, Value value) {
int index = lowerBoundEntries(node.entries, key);
if (index < node.entries.size() && node.entries.get(index).key().compareTo(key) == 0) {
node.entries.set(index, new Entry<>(key, value));
return null;
}
if (node.isLeaf()) {
node.entries.add(index, new Entry<>(key, value));
} else {
Split<Key, Value> childSplit = insertRec(node.children.get(index), key, value);
if (childSplit != null) {
node.entries.add(index, childSplit.promoted());
node.children.add(index + 1, childSplit.right());
}
}
return node.entries.size() > maxKeys ? splitNode(node) : null;
}insertRec(…) 递归下降到目标子树,并把下层分裂产生的提升键逐层向上传播。
void insert(Key key, Value value) {
Objects.requireNonNull(key, "key");
Objects.requireNonNull(value, "value");
Split<Key, Value> split = insertRec(root, key, value);
if (split != null) {
BNode<Key, Value> oldRoot = root;
BNode<Key, Value> newRoot = new BNode<>();
newRoot.entries.add(split.promoted());
newRoot.children.add(oldRoot);
newRoot.children.add(split.right());
root = newRoot;
}
}insert(…) 把新数据纳入当前结构;操作结束时必须恢复该结构原有的不变量。
null 表示旧根仍能容纳结果,树高不变。
删除仍取 t=2,所以非根结点删除后至少要保留 t-1=1 个关键字。先删除;如果删除让结点低于容量下界,就通过借位(borrow)或合并(merge)修复。
t-1 个关键字:同样的容量不足继续向上传导;插入只能发生在叶子结点,然后把分裂结果向上传导。
删除可以发生在任意位置,需要借位和合并。
B 树的关键字记录本身可以带数据指针;这让内部磁盘页能直接命中记录,但也会占用原本可以放孩子指针的磁盘页空间。磁盘页大小固定时,内部磁盘页能容纳的孩子指针越少,扇出越小,树越高,查找需要访问的磁盘页也可能更多。
另一个问题是范围查询:B 树查到起点后,不能简单沿叶层顺序扫描;后续关键字可能分布在内部记录与不同子树中,范围查询往往需要继续做树遍历或反复定位。
t..2t 对,本节 t=2 即 2..4 对t-1..2t-1 个数据项,本节即 1..3 个next 串成有序链private static <Key extends Comparable<? super Key>> int lowerBoundKeys(
List<Key> keys, Key key) {
int left = 0;
int right = keys.size();
while (left < right) {
int middle = left + (right - left) / 2;
if (keys.get(middle).compareTo(key) < 0) {
left = middle + 1;
} else {
right = middle;
}
}
return left;
}
private static <Key extends Comparable<? super Key>> int childIndex(
List<Key> lowerBounds, Key key) {
int left = 0;
int right = lowerBounds.size();
while (left < right) {
int middle = left + (right - left) / 2;
if (lowerBounds.get(middle).compareTo(key) <= 0) {
left = middle + 1;
} else {
right = middle;
}
}
return Math.max(0, left - 1);
}
private BPlusNode<Key, Value> findLeaf(Key key) {
BPlusNode<Key, Value> node = root;
while (!node.leaf) {
node = node.children.get(childIndex(node.keys, key));
}
return node;
}
BPlusTree(int maxEntries) {
if (maxEntries < 3) {
throw new IllegalArgumentException("maxEntries must be at least 3");
}
this.maxEntries = maxEntries;
}
Value search(Key key) {
Objects.requireNonNull(key, "key");
BPlusNode<Key, Value> leaf = findLeaf(key);
int index = lowerBoundKeys(leaf.keys, key);
if (index < leaf.keys.size() && leaf.keys.get(index).compareTo(key) == 0) {
return leaf.values.get(index);
}
return null;
}lowerBoundKeys 在叶页找到第一个不小于目标的位置;同一个位置既用于判断精确命中,也用于确定新关键字的插入点。
childIndex 寻找最后一个不大于目标的下界,因此只需下降到一个候选孩子。
findLeaf(…) 按分隔键选择孩子,直到到达实际保存数据项的叶结点。
findLeaf 只沿内部索引下降;真实记录不会在途中返回,直到叶结点才结束导航。
BPlusTree(…) 构造满足该数据结构初始不变量的对象;后续操作都以这个初始状态为前提。
maxEntries 只控制磁盘页何时溢出并分裂;它不改变“内部结点只导航、叶结点才存真实关键字和值”的分工。
search(…) 根据当前结构的不变量定位目标;未命中时返回失败结果而不破坏结构。
B 树的真实“关键字 + 数据地址”记录既可能在叶结点,也可能在内部磁盘页。查询同一个区间 [22,57] 时,找到左端点附近后仍要按关键字顺序继续遍历,并在访问完一个孩子后回溯到父结点,因为父结点自己也可能包含范围内的记录。
查询区间 [22,57]:先分别定位左右端点所在的叶结点。之后不需要再回到内部树结构,只要从左端点所在叶结点开始,沿 next 从左到右扫描,直到超过右端点。
插入 53。
注意:分裂还必须修复叶结点之间的 next 链。
private Split<Key, Value> splitLeaf(BPlusNode<Key, Value> leaf) {
int middle = (leaf.keys.size() + 1) / 2;
BPlusNode<Key, Value> right = new BPlusNode<>(true);
right.keys.addAll(leaf.keys.subList(middle, leaf.keys.size()));
right.values.addAll(leaf.values.subList(middle, leaf.values.size()));
leaf.keys.subList(middle, leaf.keys.size()).clear();
leaf.values.subList(middle, leaf.values.size()).clear();
right.next = leaf.next;
leaf.next = right;
return new Split<>(right.keys.get(0), right);
}splitLeaf(…) 把溢出的 B+ 树叶结点分裂,同时维护叶层顺序链和父结点分隔键。
next 链:先继承原后继,再让左叶指向新右叶;返回的下界与右页供父层新增导航对。
private static <Key extends Comparable<? super Key>, Value> Key minKey(
BPlusNode<Key, Value> node) {
while (!node.leaf) {
node = node.children.get(0);
}
if (node.keys.isEmpty()) {
throw new IllegalStateException("non-root subtree must not be empty");
}
return node.keys.get(0);
}
private Split<Key, Value> splitInternal(BPlusNode<Key, Value> node) {
int middle = node.children.size() / 2;
BPlusNode<Key, Value> right = new BPlusNode<>(false);
right.keys.addAll(node.keys.subList(middle, node.keys.size()));
right.children.addAll(node.children.subList(middle, node.children.size()));
node.keys.subList(middle, node.keys.size()).clear();
node.children.subList(middle, node.children.size()).clear();
return new Split<>(right.keys.get(0), right);
}
private Split<Key, Value> insertRec(BPlusNode<Key, Value> node, Key key, Value value) {
if (node.leaf) {
int index = lowerBoundKeys(node.keys, key);
if (index < node.keys.size() && node.keys.get(index).compareTo(key) == 0) {
node.values.set(index, value);
return null;
}
node.keys.add(index, key);
node.values.add(index, value);
return node.keys.size() > maxEntries ? splitLeaf(node) : null;
}
int childIndex = childIndex(node.keys, key);
Split<Key, Value> childSplit = insertRec(node.children.get(childIndex), key, value);
node.keys.set(childIndex, minKey(node.children.get(childIndex)));
if (childSplit != null) {
node.keys.add(childIndex + 1, childSplit.lowerBound());
node.children.add(childIndex + 1, childSplit.right());
}
return node.children.size() > maxEntries ? splitInternal(node) : null;
}minKey 沿最左孩子走到叶结点,得到子树下界;内部磁盘页据此刷新对应孩子的导航关键字。
splitInternal(…) 分裂溢出的内部结点并向上传递分隔键,使每层继续满足阶数约束。
insertRec(…) 递归下降到目标子树,并把下层分裂产生的提升键逐层向上传播。
void insert(Key key, Value value) {
Objects.requireNonNull(key, "key");
Objects.requireNonNull(value, "value");
Split<Key, Value> split = insertRec(root, key, value);
if (split != null) {
BPlusNode<Key, Value> oldRoot = root;
BPlusNode<Key, Value> newRoot = new BPlusNode<>(false);
newRoot.keys.add(minKey(oldRoot));
newRoot.children.add(oldRoot);
newRoot.keys.add(split.lowerBound());
newRoot.children.add(split.right());
root = newRoot;
}
}insert(…) 把新数据纳入当前结构;操作结束时必须恢复该结构原有的不变量。
真实记录只在叶结点中删除。删除后回溯更新各子树下界。若发生下溢,考虑借位或合并,并保持叶结点的 next 链连续。
真实数据库索引页往往可以容纳大量“下界关键字 + 孩子指针”导航对;扇出越大,同样数据量所需的高度越低。
图中的省略号表示大量未画出的兄弟磁盘页和叶磁盘页。由于内部磁盘页不存真实记录,B+ 树通常能获得更高扇出;在相同磁盘页大小和数据规模下,这往往意味着更低的树高与更少的根到叶磁盘页输入/输出。
leftSize,把“第 \(k\) 小”查询转化为沿路径比较左子树规模。