B树
本文章是自己查询资料总结复习使用的,不喜勿喷
什么是B树
B 树(Balanced Tree)是一种平衡的多路搜索树,多用于文件系统、数据库的实现。
B-tree树即B树,B即Balanced,平衡的意思。因为B树的原英文名称为B-tree,而国内很多人喜欢把B-tree译作B-树,其实,这是个非常不好的直译,很容易让人产生误解。如人们可能会以为B-树是一种树,而B树又是另一种树。而事实上是,B-tree就是指的B树。特此说明。
B树、B-树、B+树、B*树之间的关系
二叉搜索树的特性
- 所有非叶子结点至多拥有两个儿子(Left和Right);
- 所有结点存储一个关键字;
- 非叶子结点的左指针指向小于其关键字的子树,右指针指向大于其关键字的子树;
- 如果二叉搜索树的所有非叶子结点的左右子树的结点数目均保持差不多(平衡),那么B树的搜索性能逼近二分查找;但它比连续内存空间的二分查找的优点是,改变二叉搜索树结构(插入与删除结点)不需要移动大段的内存数据,甚至通常是常数开销;
- 右边也是一个二叉搜索树,但它的搜索性能已经是线性的了;同样的关键字集合有可能导致不同的树结构索引;所以,使用二叉搜索树还要考虑尽可能让B树保持左图的结构,和避免右图的结构,也就是所谓的“平衡”问题;
- 实际使用的二叉搜索树都是在原二叉搜索树的基础上加上平衡算法,即“平衡二叉树”;如何保持B树结点分布均匀的平衡算法是平衡二叉树的关键;平衡算法是一种在二叉搜索树中插入和删除结点的策略;
B树
B树大量应用在数据库和文件系统当中。
它的设计思想是,将相关数据尽量集中在一起,以便一次读取多个数据,减少硬盘操作次数。B树算法减少定位记录时所经历的中间过程,从而加快存取速度。
假定一个节点可以容纳100个值,那么3层的B树可以容纳100万个数据,如果换成二叉查找树,则需要20层!假定操作系统一次读取一个节点,并且根节点保留在内存中,那么B树在100万个数据中查找目标值,只需要读取两次硬盘。
如mongoDB数据库使用,单次查询平均快于Mysql(但侧面来看Mysql至少平均查询耗时差不多
- 是一种多路搜索树(并不是二叉的):
- 定义任意非叶子结点最多只有M个儿子;且M>2;
- 根结点的儿子数为[2, M];
- 除根结点以外的非叶子结点的儿子数为[M/2, M];
- 每个结点存放至少M/2-1(取上整)和至多M-1个关键字;(至少2个关键字)
- 非叶子结点的关键字个数=指向儿子的指针个数-1;
- 非叶子结点的关键字:K[1], K[2], …, K[M-1];且K[i] < K[i+1];
- .非叶子结点的指针:P[1], P[2], …, P[M];其中P[1]指向关键字小于K[1]的子树,P[M]指向关键字大于K[M-1]的子树,其它P[i]指向关键字属于(K[i-1], K[i])的子树;
- 所有叶子结点位于同一层;
B-树
- 关键字集合分布在整颗树中;
- 任何一个关键字出现且只出现在一个结点中;
- 搜索有可能在非叶子结点结束;
- 其搜索性能等价于在关键字全集内做一次二分查找;
- 自动层次控制;
由于限制了除根结点以外的非叶子结点,至少含有M/2个儿子,确保了结点的至少利用率,其最底搜索性能为:
- 其中,M为设定的非叶子结点最多子树个数,N为关键字总数;
- 所以B-树的性能总是等价于二分查找(与M值无关),也就没有B树平衡的问题;
- 由于M/2的限制,在插入结点时,如果结点已满,需要将结点分裂为两个各占
- M/2的结点;删除结点时,需将两个不足M/2的兄弟结点合并;
B+树(mysql使用B+树作为索引)
B+树是B-树的变体,也是一种多路搜索树:
- 其定义基本与B-树同,除了:
- 非叶子结点的子树指针与关键字个数相同;
- 非叶子结点的子树指针P[i],指向关键字值属于[K[i], K[i+1])的子树(B-树是开区间);
- 为所有叶子结点增加一个链指针;
- 所有关键字都在叶子结点出现;
- B+的特性:
*所有关键字都出现在叶子结点的链表中(稠密索引),且链表中的关键字恰好是有序的;
*不可能在非叶子结点命中;
*非叶子结点相当于是叶子结点的索引(稀疏索引),叶子结点相当于是存储(关键字)数据的数据层;
*更适合文件索引系统;
B+树相对B树的优点:
- B+树的所有Data域在叶子节点,一般来说都会进行一个优化,就是将所有的叶子节点用指针串联起来,遍历叶子节点就能获取全部数据,这样就能进行区间访问了。
- IO一次读数据是从磁盘上读的,磁盘容量是固定的,取数据量大小是固定的,非叶子节点不存储数据,节点小,磁盘IO次数就少。
B*树
- 是B+树的变体,在B+树的非根和非叶子结点再增加指向兄弟的指针;
- B*树定义了非叶子结点关键字个数至少为(2/3)*M,即块的最低使用率为2/3(代替B+树的1/2);
- B+树的分裂:当一个结点满时,分配一个新的结点,并将原结点中1/2的数据复制到新结点,最后在父结点中增加新结点的指针;B+树的分裂只影响原结点和父结点,而不会影响兄弟结点,所以它不需要指向兄弟的指针;
- B*树的分裂:当一个结点满时,如果它的下一个兄弟结点未满,那么将一部分数据移到兄弟结点中,再在原结点插入关键字,最后修改父结点中兄弟结点的关键字因为兄弟结点的关键字范围改变了);如果兄弟也满了,则在原结点与兄弟结点之间增加新结点,并各复制1/3的数据到新结点,最后在父结点增加新结点的指针;
- 所以,B*树分配新结点的概率比B+树要低,空间使用率更高;
总结
- 二叉搜索树:二叉树,每个结点只存储一个关键字,等于则命中,小于走左结点,大于走右结点;
- B(B-)树:多路搜索树,每个结点存储M/2到M个关键字,非叶子结点存储指向关键字范围的子结点;
- 所有关键字在整颗树中出现,且只出现一次,非叶子结点可以命中;
- B+树:在B-树基础上,为叶子结点增加链表指针,所有关键字都在叶子结点中出现,非叶子结点作为叶子结点的索引;B+树总是到叶子结点才命中;
- B*树:在B+树基础上,为非叶子结点也增加链表指针,将结点的最低利用率从1/2提高到2/3;
特点:
- 1 个结点可以存储超过 2 个元素,可以拥有超过 2 个子结点
- 拥有二叉搜索树的一些性质
- 平衡,每个结点的所有子树高度一致
- 比较矮
m 阶 B 树的性质(m ≥ 2)
- m 阶 B 树指的是一个结点最多拥有 m 个子结点。假设一个结点存储的元素个数为 x,那么如果这个结点是:
根结点:1 ≤ x ≤ m - 1
非根结点:┌ m / 2 ┐ - 1 ≤ x ≤ m - 1- 如果有子结点,子结点个数为 y = x + 1,那么如果这个结点是:
根结点:2 ≤ y ≤ m
非根结点:┌ m / 2 ┐ ≤ y ≤ m- 向上取整(Ceiling),指的是取比自己大的最小整数,用数学符号 ┌ ┐ 表示;向下取整(Floor),指的是取比自己小的最大整数,用数学符号 └ ┘ 表示。
比如 m=3,子结点个数 2≤y≤3,这个 B 树可以称为(2,3)树、2-3 树。
比如 m=4,子结点个数 2≤y≤4,这个 B 树可以称为(2,4)树、2-3-4 树。
比如 m=5,子结点个数 3≤y≤4,这个 B 树可以称为(3,5)树、3-4-5 树。
以此类推。
定义
#define DEGREE 3
typedef int KEY_VALUE;
typedef struct _btree_node {
KEY_VALUE *keys; // 结点存储的关键字
// 指向孩子结点的指针数组 创建结点时,会一次性分配nMaxNumOfKey+2个指针空间(多分配1个空间 用于分裂)
struct _btree_node **childrens;
int num; // 结点存储的关键字的个数
int leaf; //B树的阶层
} btree_node;
typedef struct _btree {
btree_node *root; //指向根节点
//B树的度数
int t; // 分裂索引 分裂时,该位置上的关键字会提升到父结点中
} btree;
创建节点
btree_node *btree_create_node(int t, int leaf) {
//申请一个节点
btree_node *node = (btree_node*)calloc(1, sizeof(btree_node));
if (node == NULL) assert(0);
//填充数据
node->leaf = leaf;//是否是叶子节点,1为叶子节点,0为非叶子节点
node->keys = (KEY_VALUE*)calloc(1, (2*t-1)*sizeof(KEY_VALUE)); //2t-1个关键字
node->childrens = (btree_node**)calloc(1, (2*t) * sizeof(btree_node));
node->num = 0; //node节点个数
return node;
}
void btree_create(btree *T, int t) {
T->t = t;
btree_node *x = btree_create_node(t, 1);
T->root = x;
}
销毁节点
void btree_destroy_node(btree_node *node) {
assert(node);
//释放孩子节点指针内存->释放关键字内存->释放节点 跟插入相反的顺序
free(node->childrens);
free(node->keys);
free(node);
}
分裂节点
void btree_split_child(btree *T, btree_node *x, int i) {
int t = T->t;
btree_node *y = x->childrens[i];//获取到要分裂的节点的指针
btree_node *z = btree_create_node(t, y->leaf);//创建一个新的节点
int j = 0;
for (j = 0;j < t-1;j ++) { //拷贝y的一半关键字给z
z->keys[j] = y->keys[j+t];
}
if (y->leaf == 0) {//判断是否是叶子系欸,如果不是,拷贝指针
for (j = 0;j < t;j ++) {
z->childrens[j] = y->childrens[j+t];
}
}
//更新z,y的num
z->num = t - 1;
y->num = t - 1;
//移动x的孩子节点,添加指针z的指针
for (j = x->num;j >= i+1;j --) {
x->childrens[j+1] = x->childrens[j];
}
x->childrens[i+1] = z;
//移动x的节点,留下i的空位,然后插入
for (j = x->num-1;j >= i;j --) {
x->keys[j+1] = x->keys[j];
}
x->keys[i] = y->keys[t-1];//中间关键字移动到父结点中;
x->num += 1;
}
插入
在B树中插入关键码key的思路
- 对高度为h的m阶B数,新节点一搬是插在第h层.通过检索可以确定关键码应该插入的结点位置,然后分两种情况讨论:
情况1:若该结点中的关键码个数小于m-1,则直接插入即可。
情况2:若该系欸但中关键码个数等于m-1,则将引起结点的分裂。以中间关键码为界将节点一分为二,产生一个新节点并把关键码插入父结点(h-1层)中;- 重复上述工作,最坏情况一直分裂到根结点,建立一个新的根结点,整个B树增加一层
- 情况1:
- 情况2:
void btree_insert_nonfull(btree *T, btree_node *x, KEY_VALUE k) {
int i = x->num - 1;//这个次啊是真正的插入函数
if (x->leaf == 1) {//如果是叶子节点,就可以插入
while (i >= 0 && x->keys[i] > k) {//循环比较关键字,看插入哪个位置
x->keys[i+1] = x->keys[i];//能到这一步插入了,就都不是满节点,不需要考虑溢出问题
i --;
}
x->keys[i+1] = k; //以i为分界,往后移动,留下i作为新节点的位置
x->num += 1;
} else {//不是叶子节点
while (i >= 0 && x->keys[i] > k) i --;//循环比较关键字,看插入哪个位置
if (x->childrens[i+1]->num == (2*(T->t))-1) {//判断childrens[i]指向的子节点是否是满的
btree_split_child(T, x, i+1);//分裂
if (k > x->keys[i+1]) i++;//分裂完成后,再判断一下新添加到x节点的i+1的值和k比较
}
btree_insert_nonfull(T, x->childrens[i+1], k);
}
}
插入一个元素时,首先在B树中是否存在,如果不存在,即在叶子结点处结束,然后在叶子结点中插入该新的元素
- 如果叶子结点空间足够,这里需要向右移动该叶子结点中大于新插入关键字的元素
- 如果空间满了以致没有足够的空间去添加新的元素,则将该结点进行“分裂”,将一半数量的关键字元素分裂到新的其相邻右结点中,中间关键字元素上移到父结点中(当然,如果父结点空间满了,也同样需要“分裂”操作),而且当结点中关键元素向右移动了,相关的指针也需要向右移。
- 如果在根结点插入新元素,空间满了,则进行分裂操作,这样原来的根结点中的中间关键字元素向上移动到新的根结点中,因此导致树的高度增加一层。
//插入结点的时候,要先判断是否是满结点,判断满结点也是分两种情况,一种是根结点,一种是其他结点
void btree_insert(btree *T, KEY_VALUE key) {
//int t = T->t;
btree_node *r = T->root;
if (r->num == 2 * T->t - 1) {//根节点为满节点的时候
btree_node *node = btree_create_node(T->t, 0);//创建节点node
T->root = node;//将新节点作为根节点
node->childrens[0] = r;
btree_split_child(T, node, 0);//分裂
int i = 0;
if (node->keys[0] < key) i++;
btree_insert_nonfull(T, node->childrens[i], key);
} else {
btree_insert_nonfull(T, r, key);
}
}
查找节点
int btree_bin_search(btree_node *node, int low, int high, KEY_VALUE key) {
int mid;
if (low > high || low < 0 || high < 0) {
return -1;
}
while (low <= high) {
mid = (low + high) / 2;
if (key > node->keys[mid]) {
low = mid + 1;
} else {
high = mid - 1;
}
}
return low;
}
合并节点
//{child[idx], key[idx], child[idx+1]}
void btree_merge(btree *T, btree_node *node, int idx) {
btree_node *left = node->childrens[idx];
btree_node *right = node->childrens[idx+1];
int i = 0;
/data merge
left->keys[T->t-1] = node->keys[idx];//node->key[idx]作为中间关键字
for (i = 0;i < T->t-1;i ++) {
left->keys[T->t+i] = right->keys[i];
}
if (!left->leaf) {
for (i = 0;i < T->t;i ++) {
left->childrens[T->t+i] = right->childrens[i];
}
}
left->num += T->t;//还有一个key
//destroy right
btree_destroy_node(right);
//node 删除node[idx],从后往前移
for (i = idx+1;i < node->num;i ++) {
node->keys[i-1] = node->keys[i];//这个拷贝的时候需要很注意
node->childrens[i] = node->childrens[i+1];
}
node->childrens[i+1] = NULL;
node->num -= 1;
if (node->num == 0) {
T->root = left;
btree_destroy_node(node);
}
}
删除节点
分析:
- 第1个被删的关键字h是在叶子中,且该叶子的keynum>Min(5阶B-树的Min=2),故直接删去即可。
- 第2个删去的r不在叶子中,故用中序后继s取代r,即把s复制到r的位置上,然后从叶子中删去s。
- 第3个删去的p所在的叶子中的关键字数目是最小值Min,但其右兄弟的keynum>Min,故可以通过左移,将双亲中的s移到p所在的结点,而将右兄弟中最小(即最左边)的关键字t上移至双亲取代s。
- 当删去d时,d所在的结点及其左右兄弟均无多余的关键字,故需将删去d后的结点与这两个兄弟中的一个(图中是选择左兄弟(ab))及其双亲中分隔这两个被合并结点的关键字c合并在一起形成一个新结点(abce)。但因为双亲中失去c后keynum<Min,故必须对该结点做调整操作,此时它只有一个右兄弟,且右兄弟无多余的关键字,不可能通过移动关键字来解决。因此引起再次合并,因根只有一个关键字,故合并后树高度减少一层,从而得到上图的最后一个图。
删除操作
- 删除操作的两个步骤
第一步骤:在树中查找被删关键字K所在的地点
第二步骤:进行删去K的操作- 删去K的操作
B-树是二叉排序树的推广,中序遍历B-树同样可得到关键字的有序序列(具体遍历算法【参见练习】)。任一关键字K的中序前趋(后继)必是K的左子树(右子树)中最右(左)下的结点中最后(最前)一个关键字。
void btree_delete_key(btree *T, btree_node *node, KEY_VALUE key) {
if (node == NULL) return ;
int idx = 0, i;
//遍历查找Key是否再当前节点node中,这个是从0下标找起
while (idx < node->num && key > node->keys[idx]) {
idx ++;//在树中查找被删关键字K所在的下标
}
if (idx < node->num && key == node->keys[idx]) {
//如果再当前节点上
if (node->leaf) {//判断是否是叶子节点,如果是叶子节点就直接删除
//删除节点,把后面的节点往前移,这个是叶子节点,不用移动指针
for (i = idx;i < node->num-1;i ++) {
node->keys[i] = node->keys[i+1];
}
node->keys[node->num - 1] = 0;
node->num--;
if (node->num == 0) { //root 如果num=0,说明只剩下根节点了
free(node);
T->root = NULL;
}
return ;
} else if (node->childrens[idx]->num >= T->t) {//后面的都不是叶子节点
//前于k的子结点y,至少包含T->degree各个关键字
//找出k在以**y为根的子树中的前驱k',递归的删除k'**,并在x中用k'代替k。
btree_node *left = node->childrens[idx];
//用k'替换k
node->keys[idx] = left->keys[left->num - 1];
//递归替换
btree_delete_key(T, left, left->keys[left->num - 1]);
} else if (node->childrens[idx+1]->num >= T->t) {
//后于k的子结点z,至少包含T->degree各个关键字
//找出k在以**z为根的子树中的后驱k',递归的删除k'**,并在x中用k'代替k。
btree_node *right = node->childrens[idx+1];
//用k'替换k
node->keys[idx] = right->keys[0];
//递归替换
btree_delete_key(T, right, right->keys[0]);
} else {
//y和z都包含T->degree-1个关键字,需要合并,然后在从y中递归删除k
btree_merge(T, node, idx);//合并
btree_delete_key(T, node->childrens[idx], key);//递归删除
}
} else {
//如果不在当前节点,就往孩子节点找
btree_node *child = node->childrens[idx];
if (child == NULL) {
printf("Cannot del key = %d\n", key);
return ;
}
if (child->num == T->t - 1) {
//判断孩子结点的个数是不是小于最小值,如果是就要特殊处理
btree_node *left = NULL;
btree_node *right = NULL;
if (idx - 1 >= 0)
left = node->childrens[idx-1];
if (idx + 1 <= node->num)
right = node->childrens[idx+1];
if ((left && left->num >= T->t) ||
(right && right->num >= T->t)) {
int richR = 0;
if (right) richR = 1;
if (left && right) richR = (right->num > left->num) ? 1 : 0;
if (right && right->num >= T->t && richR) { //borrow from next
//找到右边兄弟替补
//先把node节点的数据往children里放,放到children最后一个key中
child->keys[child->num] = node->keys[idx];
//把right的第一个孩子指针挂接到child的最后一个指针上
child->childrens[child->num+1] = right->childrens[0];
child->num ++;
//把右孩子结点往父节点提
node->keys[idx] = right->keys[0];
//右孩子节点往前移
for (i = 0;i < right->num - 1;i ++) {
right->keys[i] = right->keys[i+1];
right->childrens[i] = right->childrens[i+1];
}
right->keys[right->num-1] = 0;//TODO:什么意思
right->childrens[right->num-1] = right->childrens[right->num];
right->childrens[right->num] = NULL;
right->num --;
} else { //borrow from prev 找到左边兄弟替补
//左边跟右边不是对称关系,操作有点不一样,不过大体都一样
//先移动child的结点,空出第一个结点,等到父节点node插入元素
for (i = child->num;i > 0;i --) {
child->keys[i] = child->keys[i-1];
child->childrens[i+1] = child->childrens[i];
}
child->childrens[1] = child->childrens[0];
//把左边孩子最后一个孩子指针挂接到child的0
child->childrens[0] = left->childrens[left->num];
//把父结点的关键字赋值给left的0下标
child->keys[0] = node->keys[idx-1];
child->num ++;
//把左孩子的最后一个元素赋值给父节点的元素
node->key[idx-1] = left->keys[left->num-1];
left->keys[left->num-1] = 0;
left->childrens[left->num] = NULL;
left->num --;
}
} else if ((!left || (left->num == T->t - 1))
&& (!right || (right->num == T->t - 1))) {//左右兄弟都小于T->degree-1的时候
if (left && left->num == T->t - 1) { //左边兄弟存在,并且满足T->degree-1
btree_merge(T, node, idx-1);
child = left;
} else if (right && right->num == T->t - 1) { //右边兄弟存在,并且满足T->degree-1
btree_merge(T, node, idx);
}
}
}
//如果不是,直接调用删除函数
btree_delete_key(T, child, key);
}
}
int btree_delete(btree *T, KEY_VALUE key) {
if (!T->root) return -1;
btree_delete_key(T, T->root, key);
return 0;
}
若被删关键字K所在的结点非树叶,则用K的中序前趋(或后继)K’取代K,然后从叶子中删去K’。从叶子*x开始删去某关键字K的三种情形为:
- 情形一:
若x->keynum>Min,则只需删去K及其右指针(*x是叶子,K的右指针为空)即可使删除操作结束。- 情形二:
若x->keynum=Min,该叶子中的关键字个数已是最小值,删K及其右指针后会破坏B-树的性质(3)。若x的左(或右)邻兄弟结点y中的关键字数目大于Min,则将y中的最大(或最小)关键字上移至双亲结点parent中,而将parent中相应的关键字下移至x中。显然这种移动使得双亲中关键字数目不变;y被移出一个关键字,故其keynum减1,因它原大于Min,故减少1个关键字后keynum仍大于等于Min;而x中已移入一个关键字,故删K后x中仍有Min个关键字。涉及移动关键字的三个结点均满足B-树的性质(3)。 请读者验证,上述操作后仍满足B-树的性质(1)。移动完成后,删除过程亦结束。- 情形三:
若x及其相邻的左右兄弟(也可能只有一个兄弟)中的关键字数目均为最小值Min,则上述的移动操作就不奏效,此时须x和左或右兄弟合并。不妨设x有右邻兄弟y(对左邻兄弟的讨论与此类似),在x中删去K后,将双亲结点parent中介于x和y之间的关键字K,作为中间关键字,与并x和y中的关键字一起"合并"为一个新的结点取代x和y。因为x和y原各有Min个关键字,从双亲中移人的K’抵消了从x中删除的K,故新结点中恰有2Min(即2「m/2」-2≤m-1)个关键字,没有破坏B-树的性质(3)。但由于K’从双亲中移到新结点后,相当于从parent中删去了K’,若parent->keynum原大于Min,则删除操作到此结束;否则,同样要通过移动parent的左右兄弟中的关键字或将*parent与其 左右兄弟合并的方法来维护B-树性质。最坏情况下,合并操作会向上传播至根,当根中只有一个关键字时,合并操作将会使根结点及其两个孩子合并成一个新的根,从而使整棵树的高度减少一层。- 注意: Min=【M/2】-1
遍历树和打印
void btree_traverse(btree_node *x) {
int i = 0;
for (i = 0;i < x->num;i ++) {
if (x->leaf == 0)
btree_traverse(x->childrens[i]);
printf("%C ", x->keys[i]);
}
if (x->leaf == 0) btree_traverse(x->childrens[i]);
}
void btree_print(btree *T, btree_node *node, int layer)
{
btree_node* p = node;
int i;
if(p){
printf("\nlayer = %d keynum = %d is_leaf = %d\n", layer, p->num, p->leaf);
for(i = 0; i < node->num; i++)
printf("%c ", p->keys[i]);
printf("\n");
#if 0
printf("%p\n", p);
for(i = 0; i <= 2 * T->t; i++)
printf("%p ", p->childrens[i]);
printf("\n");
#endif
layer++;
for(i = 0; i <= p->num; i++)
if(p->childrens[i])
btree_print(T, p->childrens[i], layer);
}
else printf("the tree is empty\n");
}
B 树 VS 二叉搜索树
- 这是一棵二叉搜索树,通过某些父子结点合并,恰好能与上面的 B 树对应。
- B 树和二叉搜索树,在逻辑上是等价的
- 多代结点合并,可以获得一个超级结点,且 n 代合并的超级结点,最多拥有 (2^n) 个子结点 (至少是 (2^n) 阶 B 树)
红黑树与 B 树的等价变换
- 红黑树与 4 阶 B 树(2-3-4树)具有等价性
- 黑色结点与红色子结点融合在一起,形成 1 个 B 树结点
- 红黑树的黑色结点个数与 4 阶 B 树的结点总个数相等