B-树是一种平衡的多路查找树,它在文件系统中很有用,常用作文件的索引,用于提高在磁盘中的查找效率。
一棵M阶的B树T,满足以下条件:
(1)每个节点至多拥有M棵子树
(2)若根节点不是叶子节点,则根节点至少拥有2棵子树
(3)除根节点外,其余每个分支节点至少拥有ceil(M/2)棵子树{这也说明除根节点外的分支节点至少拥有ceil(M/2)-1个关键字}
(4)有k棵子树的节点,则存在k-1个关键字
(5)所有叶子节点都在同一层,并且不带信息
常将M设置为一个偶数
typedef struct _btree_node
{
int *key;
btree_node **children;//子树
int num;//关键字数目
int leaf;//是否为叶子节点
} btree_node;
typedef struct _btree
{
btree_node *root;
int t;//M阶b树,M = 2t,即最多只有M棵子树
//+这样可以保证M为偶数,M-1为奇数
} btree;
B-树有两个非常重要的原子操作:分裂
与合并
记住两个关键的原子操作(这是从king老师那里学到的)就像记住九九乘法表一样
(1)分裂
节点子树数目到达M,可以分裂;中间的关键字上浮1,左右侧的关键字成为该关键字左右子树。
分裂分为两种情况:
情况1:该节点为根节点
情况2:该节点非根节点
两者区别在于根节点中间关键字上浮后会成为新的节点;而非根节点的中间关键字直接插入父节点即可
(2)合并
关键字和其左右子树进行合并到其左子树,对于关键字e来说,这是一个下沉2的操作。
首先,需要记住的是:
同二叉搜索树相同,B-树关键字的插入一定会插入叶子节点
其次,插入关键字需要用到前面提到的原子操作——分裂。
这是因为,在插入关键字时,若关键字插入一个已经存在M棵子树的节点,就会导致,该节点不满足性质(3),因此,每次在当前节点查找之前,如果当前节点已经存在M-1个关键字,则需要进行分裂,此外也可以防止该节点的子树分裂后,关键字上浮导致该节点不满足性质(3).
最后,插入关键字的流程:
分为两步:
step1:处理根节点(btree_insert)
step2:处理非根节点(btree_insert_nonfull)
btree_insert_nonfull思路
这是一个递归函数,函数的递归结束条件是——到达叶子节点:找到对应的位置插入即可。
函数思路
当遇到叶子节点:直接找到位置插入,函数返回
而当遇到非叶子节点时:
(1)查找到需要插入的子树
(2)检查需要插入的子树的关键字数目,若已满,则进行分裂
注意:正是由于这一步检查,才保证了在叶子节点时,可以放心地插入,而不必担心叶子节点数量已满,也可以保证在分裂时,上浮的节点不会导致该节点关键字超出限制
代码如下:
void btree_insert_nonfull(btree *T, btree_node *x, KEY_VALUE k)
{
int i = x->num - 1;
//two conditions:
//condition 1: 如果是叶子节点,直接插入即可
//condition 2: 如果不是叶子节点
//(1)检查节点是否已满,若已满则需要分裂
//(2)查找插入的子树
if (x->leaf == 1)
{
while (i >= 0 && x->key[i] > k)
{
x->keys[i + 1] = x->keys[i];
i --;
}
x->keys[i + 1] = k;
}
else
{
//首先,找到要插入的子树
while (i >= 0 && x->keys[i] > k)
{
i--;
}
//其次,查看该子树是否已满节点,因为,该子树可能就是叶子节点
if (x->children[i + 1]->num == (2 * (T->t) - 1))
{
btree_split_child(T, x, i + 1);
if (k > x->keys[i + 1])
{
i++;
}
}
//最后,继续插入关键值
btree_insert_nonfull(T, x->children[i+1], k);
}
}
注意
:当M为偶数时,ceil(M/2) = M/2,下文不作区分
btree_delete_key是一个递归函数:
递归j结束条件——当该关键字存在于叶子节点
1. 思路
当关键字存在于该节点
:进行删除操作
(1)若为叶子节点:直接删除即可,若删除后,叶子节点为空,则需要释放内存(这是什么情况呢?当M为2时)
(2)若为非叶子节点:则需要进行填补,
首先删除当前关键字
a)若左孩子节点的关键字数量 >= M, 则从左孩子子树最大关键字填补,并从左子树中删除该关键字,直接返回
b)若右孩子节点的关键字数量 >= M, 则从右孩子子树最小关键字填补,并从左子树中删除该关键字,直接返回
c)若两侧均不可填补上来,则进行合并left + key + right -> left,再从left中删除关键字key
当关键字不存在于该节点
:继续查找关键字所在的树child,并检查该树的根节点是否满足数量 >= M/2
(1)若子树child为空,则说明B树不存在该关键字,直接返回
(2)若子树child不为空,则说明关键字若存在,只可能存在于该子树,检查子树数量是否 >= M/2
a)若关键字数量 >= M/2 – 1, 则直接btree_delete_key(T,child, key)
b)若关键字数量 < M/2 – 1,需要进行借位,并从借位的子树删除该关键字
(则若关键字在该节点,则删除可能导致不满足性质——除了根节点外的节点,至少拥有 ceil(M/2) 棵子树)
左兄弟树:left = node->child[idx - 1]
右兄弟树:right = node->child[idx + 1]
两者至少存在一个
i)从关键字较多且关键字数量 >= M/2 的子树借位
ii)若不可借位,则进行合并
2.代码
//B树的key删除
/* @node : 从node节点开始找,
* 若node节点为叶子节点,则直接删除该关键值即可;
* 若node节点非叶子节点,则需要
*/
void btree_delete_key(btree *T, btree_node *node, KEY_VALUE k)
{
if (node == NULL)
{
return;
}
int idx = 0;
int i = 0;
int prev = 0;
int nxt = 0;
//找到第一个大于等于key的关键字
while (idx < node->num && key > node->keys[idx])
{
idx++;
}
//若该关键字等于key,key位于该节点
if (idx < node->num && key == node->keys[idx])
{
//若该节点为叶子节点,则直接删除该关键字
if (node->leaf)
{
...
return;
}
/*若该节点非叶子节点,则进行填补式删除
*(1)先试从左侧的子树填补
*(2)若左侧子树无法填补,则试从右侧子树填补
*(3)若两侧均无法填补,则进行合并后再删除
*/
//若该节点非叶子节点,
//+且该节点 的 左侧的子树的关键字数量大于 t - 1(删除一个关键字仍满足性质)
else if (node->children[idx]->num >= T->t)
{
//直接将 前序关键字填补上来,然后从左子树中删除该关键字
//为什么要求left节点的关键字数目要 >= t 呢,因为可能该关键字就在这个节点中
btree_node *left = node->children[idx];
prev = get_max_key(left);
node->keys[idx] = prev;
btree_delete_key(T, left, prev);
}
//若该节点非叶子节点
//+且该节点 的 右侧的子树的关键字数量大于 t - 1
else if (node->children[idx + 1]->num >= T->t)
{
//直接将 后续 关键字填补上来,然后将其从右侧子树中删除
btree_node *right = node->children[idx + 1];
nxt = get_min_key(right);
node->keys[idx] = nxt;
btree_node_key(T, right, nxt);
}
//若该节点叶子节点
//+ 且两侧的子树均无法填补上来(即两侧子树的关键字数量均为t-1),则直接进行合并
//+ 合并是指,将left, node[idx], right 进行合并
//合并后,从left中删除关键字key
else
{
//合并
btree_merge(T, node, idx);
//从left中删除关键字key
btree_delete_key(T, node->children[idx], key);
}
}
else
//key大于该节点上所有关键字(此时idx == node->num)
//+ 这说明该关键字应位于子树children[idx + 1],需要继续进行查找
{
//若该子树为空,说明本节点已经是叶子节点了且该关键字并不存在,说明子树可以为空啊
//因为只有叶子节点,
btree_node *child = node->children[idx];
if (child == NULL)
{
printf("Cannot del key = %d\n", key);
return;
}
//若该子树不为空,继续寻找关键字,此处就解决上面的,删除节点后,可能导致节点
//+不满足性质6)关键字的数目 >= t-1(孩子数目num >= t - 1)
//(1)若该子树不为空,且其关键字的数量为 t - 1,说明再删除一个关键字,就不符合性质6)了
//+ 因此,需要进行借位,以防止key存在于该节点的情况
//+ 先 从左边借位(需要左边的子树节点的num >= t)
//+ 然后 从右边借位(需要右边的子树节点的num >= t)
//+ 如果,两边均不可借位,则进行合并
if (child->num == T->t - 1)
{
btree_node *left = NULL;
btree_node *right = NULL;
if (idx - 1 >= 0)
{
left = node->children[idx - 1];
}
if (idx + 1 <= node->num)
{
right = node->children[idx + 1];
}
//若兄弟树存在且可以借位
//疑问:left可能为空吗?
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;
//borrow from next
if (right && right->num >= T->t && richR)
{
//该子树添加关键字和子树
child->keys[child->num] = node->keys[idx];
child->children[child->num + 1] = right->children[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->children[i] = right->children[i + 1];
}
right->children[i] = right->children[i + 1];
right->keys[right->num - 1] = 0;
right->children[right->];
right->num--;
}
else
//borrow from prev
{
for (i = child->num; i > 0; i--)
{
child->keys[i] = child->keys[i - 1];
child->children[i + 1] = child->children[i];
}
child->children[1] = child->children[0];
child->children[0] = left->children[left->num];
child->keys[0] = node->keys[idx - 1];
child->num++;
node->keys[idx - 1] = left->keys[left->num - 1];
//left tree
left->children[left->num] = NULL;
left->num--;
}
}
//若查找删除节点的关键字数目 == t - 1,且左右兄弟子树均不可借位
//+ 则进行merge
else if ((!left || (left->num == T->t - 1)) && (!right || (right->num == T->t - 1)))
{
//左兄弟树 node->keys[idx - 1],和child进行合并
if (left && left->num == T->t - 1)
{
btree_merge(T, node, idx - 1);
child = left;
}
//右兄弟树,node->keys[idx],和child进行合并
else if (right && right->num == T->t - 1)
{
btree_merge(T, node, idx);
}
}
}
//好了,现在即使,这个key位于该节点,也可以放心地删除了
btree_delete_key(T, child, key);
}
}
//需要合并,说明 其左右子树的关键字数量均小于 t - 1 (则合并后,最大关键字数量为 2t)
void btree_merge(btree* T, btree_node *node, int idx)
{
//难道 非叶子节点,其关键字的左右子树就一定存在?
//是的!性质3)除根节点外,其余每个节点至少拥有M/2棵子树
btree_node *left = node->children[idx];
btree_node *right = node->children[idx+1];
int i = 0;
//data merge
left->keys[T->t - 1] = node->keys[idx];
for (i = 0; i < T->t - 1; i++)
{
left->keys[T->t + i] = right->keys[i];
}
//children merge
if (!left->leaf)
{
for (i = 0; i < t; i++)
{
left->children[T->t + 1 + i] = right->children[i];
}
}
left->num += T->t;
//destroy right
btree_destroy_node(right);
//node
for (i = idx + 1; i < node->num; i++)
{
node->keys[i - 1] = node->keys[i];
node->children[i] = node->children->[i+1]
}
node->children[node->num - 1] = NULL;
node->num -= 1;
//若node的数目为1,则说明该节点为根节点,直接删除,并将left作为根节点
if (node->num == 0)
{
T->root = left;
btree_destroy_node(node);
}
}
相比较于插入和删除较为简单,不在此赘述
(1)搜索关键字
(2)遍历B-树
(3)打印二叉树
1.插入时,到达叶子节点一定可以插入吗,会不会出现叶子节点为空的情况?
一定可以插入。
不会出现叶子节点为空。因为根据b树性质:每个节点的关至少有ceil(M/2)棵子树,也即至少拥有ceil(M/2) – 1个关键字。
2.每个节点至少有ceil(M/2)棵子树是如何保证的?
在插入和删除关键字才会改变B树的结构,因此提问等价于在插入/删除关键字时,如何保证B树的性质。
(1)插入:只有当子树数量到达M时,才会进行分裂
若M为偶数:此时左右的子树数量均为ceil(M/2),可以保证至少有ceil(M/2)棵子树。
若M为奇数:此时一棵子树数量为(M/2)+1,另一棵为(M/2)-1,只要将新插入的节点放入子树数量较少的一棵即可
(2)删除:在删除关键字时,需要提前检查子树节点的数量是否已经等于ciel(M/2),若等于,则需要进行借位,保证即使删除关键字,也可以满足该性质
3.B-树高度是什么时候减少的?
可能大家会疑惑,从来没有更改过一个节点的是否为叶子的属性,为什么B-树的高度就开始减少了…这是因为B-树高度的减少并不是从底部删除节点的,每次删除的节点都是根节点!是的,当发生合并时,你会发现,节点的关键字数目开始减少,而非根节点的关键字数目被限制在ceil(M/2)-1及以上,只有根节点的关键字数目可以为1。当在合并过程中,根节点的最后一个关键字下沉,于是根节点被删除,新的根节点"上位",此时B-树的高度减1.
本文首先介绍了B-树的意义和性质,其次给出B-树的数据结构,然后介绍了原子操作分裂和合并,重点介绍了B树的关键字插入和删除,最后归纳了一些疑问点,欢迎大家批评与指正。
上浮:指节点C的关键字key成为父节点的关键字 ↩︎
下沉:指节点P的关键字key成为其子节点的关键字 ↩︎