第 4.1 章 特殊树

Search Trees, AVL, B-Tree, B+Tree

开场:查找树的主线

查找树用结构约束换取更快查找。

结构 约束 目标
BST 左小右大 平均 \(O(\log n)\) 查找
Indexed BST 每个结点记录左子树规模 支持第 \(k\) 小查询
AVL 左右子树高度差不超过 1 保证高度 \(O(\log n)\)
m-way search tree 每个结点保存多个 key 降低树高
B-tree 平衡的多路搜索树 降低磁盘 I/O
B+ tree 数据全在叶子,叶子有序链接 范围查询和数据库索引

学习目标

  • 掌握二叉搜索树的定义、查找、插入、删除
  • 理解 BST 高度对复杂度的决定性影响
  • 掌握 Indexed BST 的 leftSize 思想
  • 掌握 AVL 树平衡因子、旋转和高度界
  • 理解 m-way search tree 的区间划分
  • 正确定义 B-tree 的阶、结点容量、叶层一致性
  • 区分 B-tree 与 B+ tree 的数据存放方式和范围查询能力

路线图

G bst BST indexed Indexed BST bst->indexed avl AVL indexed->avl mway m-way Search Tree avl->mway btree B-tree mway->btree bplus B+ tree btree->bplus

二叉搜索树定义

二叉搜索树可以为空。

非空二叉搜索树满足:

  • 每个结点有唯一 key
  • 左子树所有 key 小于根 key
  • 右子树所有 key 大于根 key
  • 左右子树也都是二叉搜索树

BST 示例

G 45 45 12 12 45->12 53 53 45->53 3 3 12->3 24 24 12->24 90 90 53->90 37 37 24->37 78 78 90->78 100 100 90->100 61 61 78->61

中序遍历 BST 会得到递增序列。

查找操作

template <class Key>
Node<Key>* find(Node<Key>* root, const Key& x) {
  if (root == nullptr) return nullptr;
  if (x < root->key) return find(root->left, x);
  if (root->key < x) return find(root->right, x);
  return root;
}

每一步只进入一个子树,因此时间复杂度是:

\[ O(h) \]

其中 \(h\) 是树高。

findMin 与 findMax

template <class Key>
Node<Key>* findMin(Node<Key>* root) {
  if (root == nullptr) return nullptr;
  while (root->left != nullptr) {
    root = root->left;
  }
  return root;
}

template <class Key>
Node<Key>* findMax(Node<Key>* root) {
  if (root == nullptr) return nullptr;
  while (root->right != nullptr) {
    root = root->right;
  }
  return root;
}

最小值一路向左,最大值一路向右。

BST 插入

template <class Key>
Node<Key>* insert(Node<Key>* root, const Key& x) {
  if (root == nullptr) {
    return new Node<Key>(x);
  }
  if (x < root->key) {
    root->left = insert(root->left, x);
  } else if (root->key < x) {
    root->right = insert(root->right, x);
  }
  return root;
}

重复 key 可以选择忽略、计数或放入重复链表,必须在接口中明确。

BST 删除三种情况

情况 处理
删除叶结点 直接删除
只有一个非空子树 用非空子树替代该结点
有两个非空子树 用左子树最大值或右子树最小值替代,再删除替代结点

删除后必须保持中序递增。

删除代码

template <class Key>
Node<Key>* remove(Node<Key>* root, const Key& x) {
  if (root == nullptr) return nullptr;
  if (x < root->key) {
    root->left = remove(root->left, x);
  } else if (root->key < x) {
    root->right = remove(root->right, x);
  } else if (root->left != nullptr && root->right != nullptr) {
    Node<Key>* successor = findMin(root->right);
    root->key = successor->key;
    root->right = remove(root->right, successor->key);
  } else {
    Node<Key>* old = root;
    root = (root->left != nullptr) ? root->left : root->right;
    delete old;
  }
  return root;
}

BST 退化

若按递增顺序插入:

\[ 1,2,3,\ldots,n \]

BST 会退化成链表。

查找、插入、删除从期望的 \(O(\log n)\) 退化为 \(O(n)\)

平衡树的目标是控制高度。

Indexed BST

Indexed BST 在每个结点增加 leftSize

\[ leftSize = |leftSubtree| + 1 \]

用途:

  • 查询第 \(k\) 小元素
  • 查询某个 key 的排名
  • 在有序集合中做 order statistics

第 k 小查询

template <class Key>
Node<Key>* select(Node<Key>* root, int k) {
  if (root == nullptr) return nullptr;
  if (k == root->leftSize) return root;
  if (k < root->leftSize) return select(root->left, k);
  return select(root->right, k - root->leftSize);
}

每一步进入一个子树,复杂度为 \(O(h)\)

AVL 树定义

AVL 树是二叉搜索树,并且对任意结点:

\[ |height(left)-height(right)|\le 1 \]

平衡因子常定义为:

\[ bf(x)=height(right)-height(left) \]

因此合法值是 \(-1,0,1\)

AVL 插入与旋转

AVL 插入流程

  1. 按 BST 规则插入新 key
  2. 沿插入路径向上回溯
  3. 更新高度或平衡因子
  4. 找到离插入点最近的失衡结点
  5. 判断外侧或内侧
  6. 外侧用单旋转,内侧用双旋转

插入后只需要在最小失衡子树处旋转一次。

四种 AVL 失衡

类型 插入位置 调整
LL 左子树的左侧 右单旋
RR 右子树的右侧 左单旋
LR 左子树的右侧 先左旋左孩子,再右旋根
RL 右子树的左侧 先右旋右孩子,再左旋根

旋转必须同时保持 BST 的中序顺序不变。

右单旋代码

template <class Key>
Node<Key>* rotateWithLeftChild(Node<Key>* k2) {
  Node<Key>* k1 = k2->left;
  k2->left = k1->right;
  k1->right = k2;
  updateHeight(k2);
  updateHeight(k1);
  return k1;
}

左单旋是对称操作。

双旋代码

template <class Key>
Node<Key>* doubleWithLeftChild(Node<Key>* k3) {
  k3->left = rotateWithRightChild(k3->left);
  return rotateWithLeftChild(k3);
}

LR 和 RL 失衡需要双旋,因为中间子树必须先被转到外侧。

AVL 高度界

\(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-way Search Tree

m 路搜索树的每个内部结点最多有 \(m\) 个孩子,最多有 \(m-1\) 个 key。

若某结点有 key:

\[ k_1<k_2<\cdots<k_p \]

则孩子区间为:

  • \(C_0\):小于 \(k_1\)
  • \(C_i\):大于 \(k_i\) 且小于 \(k_{i+1}\)
  • \(C_p\):大于 \(k_p\)

m 路搜索树示意

G r 20 50 80 a a r:c0->a b 20..50 r:c1->b c 50..80 r:c2->c d d r:c3->d

结点内用顺序或二分查找定位区间。

B-tree 正确定义

一棵阶为 \(m\) 的 B-tree 是平衡 m 路搜索树,满足:

  • 每个结点最多有 \(m\) 个孩子,最多有 \(m-1\) 个 key
  • 除根以外,每个内部结点至少有 \(\lceil m/2\rceil\) 个孩子
  • 除根以外,每个结点至少有 \(\lceil m/2\rceil-1\) 个 key
  • 若根不是叶子,根至少有 2 个孩子
  • 所有叶子在同一层
  • key 和数据记录可以存放在内部结点,也可以存放在叶子结点

B-tree 查找

查找过程:

  1. 在当前结点内查找 key
  2. 若命中,查找成功
  3. 若未命中,根据区间选择一个孩子
  4. 到达叶子仍未命中,则失败

B-tree 的高度很小,适合磁盘或 SSD 页式访问。

B-tree 插入

插入总是先定位到叶子。

若叶子未满:

  • 直接把 key 插入该结点并保持有序

若叶子已满:

  • 临时插入 key
  • 取中间 key 上升到父结点
  • 左右两半分裂成两个结点
  • 父结点若溢出,继续向上分裂
  • 根分裂时树高增加 1

B-tree 删除

若 key 在叶结点:

  • 删除后仍满足最小 key 数,直接删除
  • 若不足,向兄弟借 key 或与兄弟合并

若 key 在内部结点:

  • 用右子树最小 key 或左子树最大 key 替代
  • 再在叶层删除替代 key
  • 若下溢,继续调整

删除可能一路向上合并,根可能变矮。

B+ tree 定义

B+ tree 与 B-tree 不同。

B+ tree 满足:

  • 内部结点只存索引 key,不存完整数据记录
  • 所有数据记录都存放在叶子结点
  • 叶子结点按 key 有序链接成链表
  • 查找总是走到叶子才确定数据记录
  • 范围查询可以在叶子链表上顺序扫描

B-tree 与 B+ tree 对比

维度 B-tree B+ tree
数据记录位置 内部结点和叶子都可存 只在叶子
查找是否可能在内部结点结束 可以 不可以
叶子链表 不是定义要求 是核心特征
范围查询 需要中序遍历式访问 叶子链表顺序扫描
数据库索引 可用 更常用

为什么数据库常用 B+ tree

数据库索引关注:

  • 单点查询
  • 范围查询
  • 顺序扫描
  • 磁盘页利用率
  • 稳定的树高

B+ tree 内部结点只放 key 和孩子指针,同一页能放更多分支,树更矮。

叶子链表让范围查询只需定位起点后顺序向后扫。

多维分析

结构 查找 插入 删除 空间/工程特征
BST \(O(h)\) \(O(h)\) \(O(h)\) 简单但可能退化
Indexed BST \(O(h)\) \(O(h)\) \(O(h)\) 支持排名和选择
AVL \(O(\log n)\) \(O(\log n)\) \(O(\log n)\) 旋转维护高度
m-way Tree \(O(h\cdot m)\)\(O(h\log m)\) 依实现 依实现 降低高度
B-tree \(O(\log_m n)\) 页访问 分裂 借/合并 外存友好
B+ tree \(O(\log_m n)\) 页访问 分裂 借/合并 范围查询强

练习 1:插入 BST

依次插入:

\[ 3,1,4,6,9,2,5,7 \]

得到:

G 3 3 1 1 3->1 4 4 3->4 2 2 1->2 6 6 4->6 5 5 6->5 9 9 6->9 7 7 9->7

练习 2:检测 BST

template <class Key>
bool isBST(Node<Key>* node, const Key* low, const Key* high) {
  if (node == nullptr) return true;
  if (low != nullptr && !( *low < node->key )) return false;
  if (high != nullptr && !( node->key < *high )) return false;
  return isBST(node->left, low, &node->key) &&
         isBST(node->right, &node->key, high);
}

每个结点携带合法区间,避免只比较父子导致的错误。

练习 3:路径三集合判断

在有序集 \(S\) 的 BST 中,任意根到叶路径把 \(S\) 分为:

  • \(S_1\):路径左边的结点
  • \(S_2\):路径上的结点
  • \(S_3\):路径右边的结点

不一定对任意 \(a\in S_1,b\in S_2,c\in S_3\) 都有 \(a\le b\le c\)

原因:路径上不同祖先产生的左右区间不同,不能把所有左侧结点和所有右侧结点分别合并成全局小于/大于路径的集合。

练习 4:AVL 最少结点数

\(N_h\) 是高度为 \(h\) 的 AVL 树最少结点数:

\[ N_{-1}=0,\quad N_0=1 \]

\[ N_h=N_{h-1}+N_{h-2}+1 \]

因此 \(N_h\) 与 Fibonacci 数同阶。

对于 \(n\) 个结点的 AVL 树:

\[ h=O(\log n) \]

练习 5:B-tree 与 B+ tree

题目:内部结点只存索引、所有数据都在叶子、叶子按 key 链接,这是什么树?

答案:B+ tree。

B-tree 不要求所有数据都在叶子,也不要求叶子之间有顺序链表。