首先自平衡树是为了解决二叉搜索树在有序数据中退化为链表的问题(即查找时间退化为 O(n) 级别)。
自平衡树中,B树、B+树可以说是最简单的,没有旋转、变色等操作。我们可以拿多路平衡查找树和同样是自平衡树的AVL、红黑树进行对比:
- B树、B+树
- 优点:查询次数少,放在内存中时,没有明显优点,放在硬盘中时,可以凭借较少的查询次数,节省大量磁盘 IO 时间。插入、删除操作简单。
- 缺点:查询次数也受节点存储关键值数量的影响,一个节点被换入内存的时候, 存储的关键值越多,越有利于快速查询,相反如果单个关键值占用内存页的比例较大,则不利于减少磁盘 IO
- 特点:树的高度变化始终发生在根结点处
- AVL
- 优点:(我觉得和实现复杂度相比,优点不值一提,死不承认不知道,欢迎补充…)
- 缺点:实现复杂
- 特点:严格的平衡树,旋转 + 计算高度。
- 红黑树
- 优点:实用性能较好,实现相比 AVL 简单
- 缺点:(欢迎补充…)
- 特点:不是严格的平衡树,但实用场景中表现良好。 旋转 + 颜色。
B树
一棵 M 阶的 B 树,满足以下条件:
- 每个节点至多拥有 M 棵子树
- 有 K 棵子树的分支节点有 K - 1 个关键字,关键字按递增顺序排序
- 关键字数量 n n n 满足 ⌈ M 2 ⌉ − 1 < = n < = M − 1 \lceil \frac{M}{2} \rceil - 1 <= n <= M - 1 ⌈2M⌉−1<=n<=M−1
- 根节点至少有 2 棵子树(根节点最少一个关键字)
- 除了根节点,其余每个分支节点至少有 ⌈ M 2 ⌉ \lceil \frac{M}{2} \rceil ⌈2M⌉ 棵子树
- 所有叶节点都在同一层
#define KEY_VALUE int
#define M 6
#define DEGREE ((M + 1)/2)
//感觉 ((M + 1)/2) 比 (M/2) 当 M 为奇数,这里就可以自动上取整
struct btree_node {
KEY_VALUE* keys; // 关键字数组
struct btree_node** childs; // 子树数组
int num; // 关键字数量
int leaf; // 是否为叶节点
};
struct btree {
struct btree_node* root;
};
Q:4K 的页面大小,我们有 4G 的硬盘存储,需要什么样的 B 树形式?
- 4K 的页面大小,4G 的存储空间,要尽可能少的寻址次数,最少需要两次(根节点 1024 个子树,第一层每个节点 1024 个子树)。
- 总共需要 1024 * 1024 + 1 个节点。
B 树 B+ 树 B* 树:
- B(B-)树:多路搜索树,每个结点存储M/2到M个关键字,非叶子结点存储指向关键字范围的子结点;所有关键字在整颗树中出现,且只出现一次,非叶子结点可以命中;
- B+树:在B-树基础上,为叶子结点增加链表指针,所有关键字都在叶子结点中出现,非叶子结点作为叶子结点的索引;B+树总是到叶子结点才命中;
- B*树:在B+树基础上,为非叶子结点也增加链表指针,将结点的最低利用率从1/2提高到2/3;
B+ 树:
数据库的聚集索引(Clustered Index)中,叶子节点直接包含卫星数据(卫星数据可以理解为,和索引相关联的一行记录的信息,实际上除了主键索引其他索引不会存储整行数据,其他索引的卫星数据一般存储主键索引的地址或存储主键,需要其他信息的时候,做回表查询,得到整行数据)。在非聚集索引(NonClustered Index)中,叶子节点带有指向卫星数据的指针。
B+树的特征:
- 有k个子树的中间节点包含有k个元素(B树中是k-1个元素),每个元素不保存数据,只用来索引,所有数据都保存在叶子节点。
- 所有的叶子结点中包含了全部元素的信息,及指向含这些元素记录的指针,且叶子结点本身依关键字的大小自小而大顺序链接。
- 所有的中间节点元素都同时存在于子节点,在子节点元素中是最大(或最小)元素。
B+树的优势:
- 单一节点存储更多的元素,使得查询的IO次数更少。
- 所有查询都要查找到叶子节点,查询性能稳定。
- 所有叶子节点形成有序链表,便于范围查询。
- 卫星数据全在子节点, B 树的卫星数据就在其所有节点上。没有卫星数据的中间节点意味着同样大小的磁盘页可以存储更多的节点元素,查找次数也就比 B 树少。
B 树详细过程部分参考自此处,其部分内容和《算法导论》不太一致,对于理解还是比较有好处的。
B+ 树简单理解参考此处。
基本操作
- 查找
- 创建树
- 创建新节点
- 分裂节点(
Tree_Split_Child
) - 插入
- 根节点已经满的情况下的插入(因为根节点分裂和其他节点分裂不同)
- 非满的插入
- 删除
- 合并、前驱、后驱
插入
如果节点未满,直接插入。如果根节点满,就分裂,再向下执行插入(B 树高度只会在根节点处增加)。插入的时候,插入在叶节点,向下查询过程种碰到哪个节点满了,就对他执行分裂。保证向下的过程中,叶节点一定有位置可以插入。
在 M 阶的 B 树新插入值 x ,通过比较 x 和 键的大小逐层向下(有序可以用二分),直到找到叶子节点合适的位置,在叶子节点进行插入。插入之前检查空间是否足够,如果叶子节点键的个数为 M - 1,没有添加新的元素的位置,就进行分裂。
- 找到中间元素,上升到父节点相应位置。(因此 M 要尽量定义为偶数)
- 再申请一个节点把原来的子节点(除去上升的键)一分为二
如果父节点因为上升的键导致键的个数等于 M - 1(即父节点没有位置存放新的键了),会在下次进行分裂。
// T 哪棵树
// x 要插入位置的父节点
// i 要插入位置是 x 的第几个子树
void btree_split_child(struct btree* T, struct btree_node* parent, int idx) {
// 获取要分裂的节点
struct btree_node* oldSon = parent->childs[idx];
// 新增一个节点
struct bree_node* newSon = (struct bree_node*)malloc(sizeof(struct btree_node));
// 该节点的数组还也同样需要申请空间,此处省略...
int j;
for(j = DEGREE; j < oldSon.num; ++ j ) {
newSon.keys[j] = oldSon.keys[j];
// 拷贝右半边关键字
}
if (oldSon.leaf == 0) {
// 如果不是叶子节点拷贝左边的子树指针
for (j = DEGREE; j < oldSon.num + 1; ++ j ) {
newSon.childs[j] = oldSon.childs[j];
}
}
newSon->num = oldSon.num - DEGREE - 1;
// parent idx 都向后移动一位,然后把子节点中间的提上来
// 移动代码省略...
parent->keys[idx] = oldSon->keys[DEGREE - 1];
oldSon->num = DEGREE;// 修改剩余关键字的数量
// 所有的 childs 从 idx + 1 向后移动一位,次数省略代码
parent->childs[idx + 1] = newSon;
parent->num ++;
}
// 普通的插入
void btree_insert_nonfull(struct btree* t, struct btree_node* x, KEY_VALUE key) {
int i = x->num - 1;
if (x->leaf == 1) {
// 插入
} else {
// 递归查找
while (i >= 0 && x->keys[i] > key) i --;
// 找到了合适位置就判断其子树是否是满的
// 满就进行分裂
if (x->childs[i + 1] == M - 1) {
btree_split_child(t, x, i + 1);
// 分裂后看看插入到 x->childs[i + 1]
// 还是 childs[i + 2]
if (x->keys[i + 1] <= key) i ++;
}
btree_insert_nonfull(t, x->childs[i + 1], key);
}
}
// 针对根节点插入的方法
void btree_insert(struct btree* t, KEY_VALUE key) {
struct btree_node* r = T->root;
if (r->num == M - 1) {
// 创建新节点
struct bree_node* node = (struct bree_node*)malloc(sizeof(struct btree_node));
t->root = node;
node->childs[0] = r;
btree_split_child(t, node, 0);
}
}
删除
- 如果是叶子节点,直接删除
- 如果是在内部节点,则找其前驱或者后继。
- 在前驱(或后继)所在节点至少有 t 个关键字时,用其前驱(或后继)代替当前被删除的 key,然后递归的删除其前驱(或后继)。
- 如果都没有(前驱和后继所在节点都只有 t - 1 个关键字),则其前驱和后继所在的节点和当前被删除的关键字合并,递归的删除 key
- 如果内部节点不包含当前 key,则找到必包含 key 的子树 x。如果其子树只有 t - 1 个关键字,则需要保证其包含至少 t 个关键字。
- 如果 x 不满足至少 t 个关键字,则看其相邻兄弟,如果其相邻的某个兄弟有至少 t 个关键字,则选取前驱(或后继)上移到根节点 x,x 下移一个值到必包含 key 的子树的根节点。前驱(或后继)上移到 x 的相应孩子移动到必包含 key 的子树的根节点最前(或最后,因为是前驱或后继)。
- 如果 x 不满足,且其相邻兄弟也都不满足,则必包含 key 的子树的根节点与其某个兄弟节点合并,然后继续向下删除即可。
B树的线程安全如何实现
- 锁整棵树,粒度太大, 影响性能。
- 锁子树(自旋锁),往下一层走的时候,加锁下一层子树,解锁当前层。
自旋锁:不断地占用 CPU 检测锁是否可用, 不会陷入阻塞、睡眠。