B树和B+树
B树(B-tree)
- B树是一种平衡的、多路搜索树,每个节点可以存储多个关键字和对应的值。
由来
传统用于搜索的平衡二叉树(如AVL树、红黑树)在一般情况下具有良好的查询性能。然而,当面对大规模数据时,这些树结构可能无法满足需求。主要原因在于数据量巨大时,无法一次性将所有数据加载到内存中,而需要频繁进行磁盘IO操作。由于磁盘的读取速度通常比内存慢几个数量级,导致程序大部分时间都在等待磁盘IO操作完成。
为了提高程序性能,关键在于减少磁盘IO次数,以降低程序的等待时间。传统平衡二叉树的设计无法有效应对磁盘IO的挑战。因此,我们需要一种更适合磁盘存储的数据结构。
这时,B树和B+树就成为了一种解决方案。它们具有以下特点:
-
多路搜索树: B树和B+树允许每个节点存储多个关键字和对应的值,从而减少树的高度,降低磁盘IO次数。
-
平衡性: B树和B+树通过调整节点的分裂和合并来保持树的平衡,确保各个子树的高度相近。
-
顺序访问: B+树将所有数据记录存储在叶子节点上,并使用指针进行连接,形成有序链表。这样可以支持范围查询和顺序访问的高效率。
-
磁盘友好: B树和B+树的设计考虑了磁盘IO的特点,尽量减少IO次数,提高访问效率。
由于B树和B+树适应了磁盘存储的特点,它们被广泛应用于需要存储大量数据并需要高效检索的场景。通过减少磁盘IO次数,B树和B+树能够提供更高的查询性能,有效地解决了传统平衡二叉树在大规模数据场景下的性能问题。
因此,当面临大规模数据并需要高效检索的场景时,选择使用B树或B+树作为数据结构可以极大地提高程序性能,减少磁盘IO操作的影响,实现更快速、高效的数据访问。
设计
平衡二叉树的限制和磁盘IO挑战
平衡二叉树通过旋转操作来保持平衡,旋转是对整棵树的操作。然而,如果只部分加载平衡二叉树到内存中,无法完整执行旋转操作,从而影响平衡性的维护。此外,平衡二叉树的高度相对较大,约为log n(底数为2),这意味着逻辑上相邻的节点在实际存储中可能相距很远。这导致无法充分利用磁盘预读的优势,无法发挥空间局部性原理的效果。
空间局部性原理指出,如果一个存储器位置被访问,那么附近位置也会被访问。而平衡二叉树的节点可能在磁盘上离散存储,无法连续预读数据。这导致磁盘IO次数的增加,影响了查询效率。
B-树的设计考虑磁盘IO
为了更好地适应磁盘存储和减少磁盘IO次数,B-树采用了一种不同的设计思路:
-
多区间分割: B-树将范围分割为多个区间,而不是像平衡二叉树一样分割为两个区间。区间的数量越多,定位数据的速度越快且更精确。
-
节点大小与磁盘页对齐: 在B-树中,每个节点表示一个区间范围。为了减少磁盘IO次数,当新建节点时,直接申请和磁盘页大小相等的空间(如512字节、4KB、8KB或16KB)。这样,一个节点的数据可以通过一次磁盘IO进行读取。
B-树的设计考虑了磁盘IO的特点,通过多区间分割和与磁盘页对齐的方式,有效减少了磁盘IO次数。这使得B-树能够更好地适应大规模数据的存储和高效检索需求。通过一次IO读取一个节点的数据,B-树能够充分利用磁盘预读的优势,提高查询性能。
因此,B-树在数据库和文件系统等领域被广泛选择,以解决平衡二叉树在大规模数据存储和磁盘IO方面的限制。通过减少磁盘IO次数,B-树能够提供更高效的数据访问,提升程序的性能。
B树特点
- 每个节点最多包含m个子节点(m>=2)。
- 根节点至少有两个子节点。
- 除根节点和叶子节点外,其他节点至少有m/2个子节点。
- 所有叶子节点位于同一层。
- 节点中的关键字按升序排列,用于支持快速的查找操作。
B树的应用场景:
- 适用于磁盘或其他外部存储设备的存储结构,可以减少磁盘I/O操作次数,提高存储效率。
- 适用于需要高效地进行范围查询(如区间查询)的场景,如数据库索引。
代码实现
public class BTree<Key extends Comparable<Key>, Value> {
private static final int DEFAULT_T = 2; // 默认的度数
private int t; // B树的度数
private Node root; // B树的根节点
private class Node {
private int n; // 节点中的键值对数量
private boolean leaf; // 是否为叶子节点
private List<Key> keys; // 节点中的键
private List<Value> values; // 节点中的值
private List<Node> children; // 节点的子节点
// 构造函数
public Node() {
this.n = 0;
this.leaf = true;
this.keys = new ArrayList<>();
this.values = new ArrayList<>();
this.children = new ArrayList<>();
}
}
// 构造函数
public BTree() {
this(DEFAULT_T);
}
/**
* 构造函数
*
* @param t B树的度数
* @throws IllegalArgumentException 如果度数t小于2
*/
public BTree(int t) {
if (t < 2) {
throw new IllegalArgumentException("Degree (t) of BTree must be at least 2");
}
this.t = t;
this.root = new Node();
}
/**
* 在B树中查找指定的键,并返回对应的值
*
* @param key 要查找的键
* @return 对应的值,如果键不存在则返回null
*/
public Value search(Key key) {
return search(root, key);
}
/**
* 在B树中递归查找指定的键,并返回对应的值
* @param node 当前节点
* @param key 要查找的键
* @return 对应的值,如果键不存在则返回null
*/
private Value search(Node node, Key key) {
int i = 0;
while (i < node.n && key.compareTo(node.keys.get(i)) > 0) {
i++;
}
if (i < node.n && key.compareTo(node.keys.get(i)) == 0) {
return node.values.get(i);
} else if (node.leaf) {
return null;
} else {
return search(node.children.get(i), key);
}
}
/**
* 向B树中插入指定的键值对
* @param key 要插入的键
* @param value 要插入的值
*/
public void insert(Key key, Value value) {
Node rootNode = root;
// 如果根节点已满,需要进行分裂
if (rootNode.n == 2 * t - 1) {
Node newRootNode = new Node();
root = newRootNode; // 将新的根节点设置为当前根节点
newRootNode.children.add(rootNode); // 将当前根节点作为新根节点的子节点
splitChild(newRootNode, 0, rootNode); // 分裂当前根节点
insertNonFull(newRootNode, key, value); // 在分裂后的新根节点中插入键值对
} else {
insertNonFull(rootNode, key, value); // 在当前根节点中插入键值对
}
}
/**
* 在非满节点中插入指定的键值对
*
* @param node 当前节点
* @param key 要插入的键
* @param value 要插入的值
*/
private void insertNonFull(Node node, Key key, Value value) {
int i = node.n - 1;
if (node.leaf) {
// 在叶子节点中找到合适的位置插入键值对
while (i >= 0 && key.compareTo(node.keys.get(i)) < 0) {
i--;
}
node.keys.add(i + 1, key);
node.values.add(i + 1, value);
node.n++;
} else {
// 在非叶子节点中找到合适的子节点进行递归插入
while (i >= 0 && key.compareTo(node.keys.get(i)) < 0) {
i--;
}
i++;
if (node.children.get(i).n == 2 * t - 1) {
// 如果子节点已满,则先分裂子节点
splitChild(node, i, node.children.get(i));
if (key.compareTo(node.keys.get(i)) > 0) {
i++;
}
}
insertNonFull(node.children.get(i), key, value);
}
}
/**
* 分裂指定节点的子节点
*
* @param parentNode 父节点
* @param childIndex 子节点在父节点中的索引
* @param childNode 要分裂的子节点
*/
private void splitChild(Node parentNode, int childIndex, Node childNode) {
// 创建一个新的节点作为分裂后的节点
Node newNode = new Node();
// 将子节点的中间键和值提升到父节点中
parentNode.keys.add(childIndex, childNode.keys.get(t - 1));
parentNode.values.add(childIndex, childNode.values.get(t - 1));
parentNode.n++;
// 将子节点的右半部分键和值移动到新的节点中
newNode.keys.addAll(childNode.keys.subList(t, 2 * t - 1));
newNode.values.addAll(childNode.values.subList(t, 2 * t - 1));
newNode.n = t - 1;
// 如果子节点不是叶子节点,则还需要将子节点的右半部分子节点移动到新的节点中
if (!childNode.leaf) {
newNode.children.addAll(childNode.children.subList(t, 2 * t));
childNode.children.subList(t, 2 * t).clear();
}
// 更新子节点的键、值和子节点个数,使其成为左半部分
childNode.keys.subList(t - 1, 2 * t - 1).clear();
childNode.values.subList(t - 1, 2 * t - 1).clear();
childNode.n = t - 1;
// 将新的节点插入到父节点的合适位置
parentNode.children.add(childIndex + 1, newNode);
}
/**
* 从B树中删除指定的键
*
* @param key 要删除的键
*/
public void delete(Key key) {
delete(root, key);
}
/**
* 在指定节点中删除指定键对应的键值对
*
* @param node 当前节点
* @param key 要删除的键
*/
private void delete(Node node, Key key) {
// 在当前节点中查找要删除的键
int index = node.keys.indexOf(key);
if (index >= 0) {
if (node.leaf) {
// 如果当前节点是叶子节点,则直接删除键值对
node.keys.remove(index);
node.values.remove(index);
node.n--;
} else {
// 如果当前节点不是叶子节点,则找到前驱键和值,用前驱键替换要删除的键,并在相应的子节点中递归删除前驱键
Key predecessorKey = getPredecessorKey(node, index);
Value predecessorValue = search(predecessorKey);
node.keys.set(index, predecessorKey);
node.values.set(index, predecessorValue);
delete(node.children.get(index), predecessorKey);
}
} else {
// 如果在当前节点中未找到要删除的键,则根据键的大小递归搜索合适的子节点
int i = 0;
while (i < node.n && key.compareTo(node.keys.get(i)) > 0) {
i++;
}
if (i < node.n && key.compareTo(node.keys.get(i)) < 0) {
delete(node.children.get(i), key);
} else {
delete(node.children.get(i + 1), key);
}
}
}
/**
* 获取指定节点中指定索引位置的前驱键
*
* @param node 当前节点
* @param index 要获取前驱键的键值对在当前节点中的索引
* @return 前驱键
*/
private Key getPredecessorKey(Node node, int index) {
// 从当前节点的指定子节点开始,沿着最右侧的子节点一直向下遍历,直到找到叶子节点
Node current = node.children.get(index);
while (!current.leaf) {
current = current.children.get(current.n);
}
// 返回叶子节点中最右侧的键,即为要获取的前驱键
return current.keys.get(current.n - 1);
}
}
B+树(B+ tree)
简述
B+树是B-树的变种,由Bayer和McCreight于1972年提出,它与B-树的不同之处在于:
- 在B+树中,key 的副本存储在内部节点,真正的 key 和 data 存储在叶子节点上 。
- n 个 key 值的节点指针域为 n 而不是 n+1。
-
所有数据记录都存储在叶子节点,并按照关键字的顺序进行链接,形成一个有序链表
-
B+树的叶子节点形成一个稠密索引,便于顺序访问和范围查询
B+树的应用场景
- 适用于数据库和文件系统等需要高效地进行范围查询、顺序访问的场景。
- 适用于需要支持大量数据的高效索引结构。
简单的代码实现
首先,我们定义B+树节点的结构:
public class BPlusTreeNode<K extends Comparable<K>, V> {
private List<K> keys;
private List<BPlusTreeNode<K, V>> children;
private BPlusTreeNode<K, V> next; // 用于连接叶子节点的指针
private boolean leaf;
private int n;
// 构造函数和其他方法省略...
}
然后,我们定义B+树的主要类:
public class BPlusTree<K extends Comparable<K>, V> {
private BPlusTreeNode<K, V> root;
private int t; // B+树的阶数
// 构造函数和其他方法省略...
}
接下来,我们实现B+树的插入、查找、删除等操作。以下是简化的示例代码:
public class BPlusTree<K extends Comparable<K>, V> {
// 省略构造函数和其他方法
public void insert(K key, V value) {
if (root == null) {
root = new BPlusTreeNode<>();
root.setLeaf(true);
root.getKeys().add(key);
root.getValues().add(value);
root.setN(1);
} else {
insertNonFull(root, key, value);
}
}
private void insertNonFull(BPlusTreeNode<K, V> node, K key, V value) {
int i = node.getN() - 1;
if (node.isLeaf()) {
while (i >= 0 && key.compareTo(node.getKeys().get(i)) < 0) {
i--;
}
node.getKeys().add(i + 1, key);
node.getValues().add(i + 1, value);
node.setN(node.getN() + 1);
} else {
while (i >= 0 && key.compareTo(node.getKeys().get(i)) < 0) {
i--;
}
i++;
if (node.getChildren().get(i).getN() == 2 * t - 1) {
splitChild(node, i);
if (key.compareTo(node.getKeys().get(i)) > 0) {
i++;
}
}
insertNonFull(node.getChildren().get(i), key, value);
}
}
private void splitChild(BPlusTreeNode<K, V> parentNode, int childIndex) {
BPlusTreeNode<K, V> childNode = parentNode.getChildren().get(childIndex);
BPlusTreeNode<K, V> newNode = new BPlusTreeNode<>();
parentNode.getKeys().add(childIndex, childNode.getKeys().get(t - 1));
parentNode.getValues().add(childIndex, childNode.getValues().get(t - 1));
parentNode.getChildren().add(childIndex + 1, newNode);
newNode.setLeaf(childNode.isLeaf());
newNode.setN(t - 1);
for (int j = 0; j < t - 1; j++) {
newNode.getKeys().add(childNode.getKeys().get(j + t));
newNode.getValues().add(childNode.getValues().get(j + t));
}
if (!childNode.isLeaf()) {
for (int j = 0; j < t; j++) {
newNode.getChildren().add(childNode.getChildren().get(j + t));
}
childNode.getChildren().subList(t, 2 * t).clear();
}
childNode.getKeys().subList(t - 1, 2 * t - 1).clear();
childNode.getValues().subList(t - 1, 2 * t - 1).clear();
childNode.setN(t - 1);
}
// 其他方法省略...
}
请注意,这只是一个简化的B+树实现示例,没有完整处理各种边界情况。在实际应用中,需要更完整地考虑异常情况和调优。
B-树和B+树比较
1 B+树内节点不存储数据,所有 data 存储在叶节点导致查询时间复杂度固定为 log n。而B-树查询时间复杂度不固定,与 key 在树中的位置有关,最好为O(1)。
查询节点 key 为 50 的 data
key 为 50 的节点就在第一层,B-树只需要一次磁盘 IO 即可完成查找。所以说B-树的查询最好时间复杂度是 O(1)
B+树所有的 data 域都在根节点,所以查询 key 为 50的节点必须从根节点索引到叶节点,时间复杂度固定为 O(log n)
2.B+树叶节点两两相连可大大增加区间访问性,可使用在范围查询等,而B-树每个节点 key 和 data 在一起,则无法区间查找。
B+树可以很好的利用局部性原理,若我们访问节点 key为 50,则 key 为 55、60、62 的节点将来也可能被访问,我们可以利用磁盘预读原理提前将这些数据读入内存,减少了磁盘 IO 的次数。
当然B+树也能够很好的完成范围查询。比如查询 key 值在 50-70 之间的节点。
3.B+树更适合外部存储。由于内节点无 data 域,每个节点能索引的范围更大更精确
B-树节点内部每个key都带着data域,而B+树节点只存储key的副本,真实的key和data域都在叶子节点存储。由于磁盘IO数据大小是固定的,在一次IO中,B+树单次磁盘IO的信息量大于B-树,因此B+树相对于B-树磁盘IO次数较少。