B树

B-树

B-树的意义

B-树是一种平衡的多路查找树,它在文件系统中很有用,常用作文件的索引,用于提高在磁盘中的查找效率。

B-树的性质

一棵M阶的B树T,满足以下条件:
(1)每个节点至多拥有M棵子树
(2)若根节点不是叶子节点,则根节点至少拥有2棵子树
(3)除根节点外,其余每个分支节点至少拥有ceil(M/2)棵子树{这也说明除根节点外的分支节点至少拥有ceil(M/2)-1个关键字}
(4)有k棵子树的节点,则存在k-1个关键字
(5)所有叶子节点都在同一层,并且不带信息

常将M设置为一个偶数

B-树的数据结构

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-树原子操作

B-树有两个非常重要的原子操作:分裂合并
记住两个关键的原子操作(这是从king老师那里学到的)就像记住九九乘法表一样

(1)分裂
节点子树数目到达M,可以分裂;中间的关键字上浮1,左右侧的关键字成为该关键字左右子树。
分裂分为两种情况:
情况1:该节点为根节点
情况2:该节点非根节点
两者区别在于根节点中间关键字上浮后会成为新的节点;而非根节点的中间关键字直接插入父节点即可

分裂如图所示:
B树_第1张图片
B树_第2张图片

(2)合并
关键字和其左右子树进行合并到其左子树,对于关键字e来说,这是一个下沉2的操作。
B树_第3张图片

B-树的关键字插入

首先,需要记住的是:
同二叉搜索树相同,B-树关键字的插入一定会插入叶子节点
其次,插入关键字需要用到前面提到的原子操作——分裂。

这是因为,在插入关键字时,若关键字插入一个已经存在M棵子树的节点,就会导致,该节点不满足性质(3),因此,每次在当前节点查找之前,如果当前节点已经存在M-1个关键字,则需要进行分裂,此外也可以防止该节点的子树分裂后,关键字上浮导致该节点不满足性质(3).

最后,插入关键字的流程:
分为两步:
step1:处理根节点(btree_insert)
step2:处理非根节点(btree_insert_nonfull)

btree_insert流程图
B树_第4张图片

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);
	}
}

B-树的关键字删除

注意:当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);
	}
}

B-树的其它操作

相比较于插入和删除较为简单,不在此赘述
(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树的关键字插入和删除,最后归纳了一些疑问点,欢迎大家批评与指正。


  1. 上浮:指节点C的关键字key成为父节点的关键字 ↩︎

  2. 下沉:指节点P的关键字key成为其子节点的关键字 ↩︎

你可能感兴趣的:(数据结构)