【C++数据结构】B树概念及其实现(详解)

B树学习正式开始~~

前言

1970年,R.Bayer和E.mccreight提出了一种适合外查找的树,它是一种平衡的多叉树,称为B树。

外查找即在不在内存当中查找,通常是在磁盘,磁盘数据通常挨着挨着存,所以只能通过建立索引的方式去找到对应的数据地址。由于AVL,红黑树的高度过高,采用这类数据结构会导致IO次数过多,所以我们通常选择层数更低的B树或者B+树。

原理即是让每一层更多。并且B树天然平衡,呈现向右向上增长的趋势。

B树的规则

  1. 根节点至少有两个孩子
  2. 每个分支节点都包含k-1个关键字和k个孩子,其中 ceil(m/2) ≤ k ≤ m ceil是向上取整函数
  3. 每个叶子节点都包含k-1个关键字,其中 ceil(m/2) ≤ k ≤ m
  4. 所有的叶子节点都在同一层
  5. 每个节点中的关键字从小到大排列,节点当中k-1个元素正好是k个孩子包含的元素的值域划分
  6. 每个结点的结构为:(n,A0,K1,A1,K2,A2,… ,Kn,An)其中,Ki(1≤i≤n)为关键字,且Ki<Ki+1(1≤i≤n-1)。Ai(0≤i≤n)为指向子树根结点的指针。且Ai所指子树所有结点中的关键字均小于Ki+1。 n为结点中关键字的个数,满足ceil(m/2)-1≤n≤m-1。

m为3为例子,我们每一个节点可以保存2个key值与3个子节点的指针,但是每个数组多开一个好处的就是便于分散。
在这里插入图片描述

在这里插入图片描述
如下图当我们要插入key值为5或者是-1,我们如果不开这个长度的数组,就需要插入前做判断,但是开了数组我们可以先插入,再去分裂。
在这里插入图片描述
根节点最少有两孩子,可以都为NULL。
在这里插入图片描述

当我们多开辟一个key值与一个child指针,每次分裂恰好能满足两边的满足k-1个关键字与k个孩子。既保持孩子比关键字多一个。如m为10,则最少关键字为4,最少5个孩子,最多9个关键字,10个孩子。

注意:
同时,为了便于分裂时插入父节点,我们使用一个指针记录父节点。

节点结构设计


每一个节点会记录一组有效元素keys,包含着最多M-1个key值,通常这个M可以设计的比较大,如1024。还有至多M个孩子节点。一个父亲指针,以及有效元素的标识_size 因为这里的每一个节点的有效元素不同哦。

实现当中,查找也可以采用二分查找的方式定位key值。

//K表示key值,M表示多少叉的一棵树
template<class K,size_t M>
struct BTreeNode
{
    
    
	BTreeNode()
	{
    
    
		//初始化
		for (int i = 0; i < M; ++i)
		{
    
    
			_keys[i] = K();
			_subs[i] = nullptr;
		}
		_parent = nullptr;
		//孩子比关键字多一个
		_subs[M] = nullptr;
	}
	//每一个节点掌管M个元素的数组
	K _keys[M];
	//有M+1个孩子,这里我们多开一个是为了方便进行分裂
	BTreeNode<K, M>* _subs[M+1];
	BTreeNode<K, M>* _parent;

	size_t _n = 0; // 记录实际存储多个关键字 
};

template<class K,size_t M>
	class BTree
	{
    
    
		typedef BTreeNode<K, M> Node;
	private:
		Node* _root = nullptr;
	};

以插入 53,139,75,49,145,36,50,47,101 为例子


第一次插入


插入53的时候
在这里插入图片描述

if (!_root)
{
    
    
	//一开始创建节点
	_root = new Node();

	_root->_keys[0] = key;
	_root->_n++;
	return true;
}

查找函数


由于插入的时候我们需要先判断是否B树已经存在该键值,我们实现的是不允许键值冗余的版本,FindKey函数提供两个功能,查找到了返回该节点和对应的下标,查找不到返回插入的位置以及-1位置。

//FindKey,找不到下标返回-1,返回可以插入的数组
//找到了返回对应的数组和下标
pair<Node*, int> FindKey(const K& key)
{
    
    
	Node* cur = _root;
	Node* parent = nullptr;
	//现在第一行寻找
	while (cur)
	{
    
    
		int i = 0;
		for (; i < cur->_n; ++i)
		{
    
    
			if (key < cur->_keys[i])
			{
    
    
				//此时去他的左孩子当中寻找
				parent = cur;
				cur = cur->_subs[i];
				break;
			}
			else if (key > cur->_keys[i])
			{
    
    
				continue;
			}
			else
			{
    
    
				//表示该元素出现在了cur这个节点,cur->_keys[i]当中。
				return std::make_pair(cur,i);
			}
		}
		//当key是当前数组的最大值
		if (cur && i == cur->_n)
		{
    
    
			//表示去最后一个节点的右孩子
			parent = cur;
			cur = cur->_subs[cur->_n];
		}
		
	}
	//走到这个位置,说明走到结尾都没有找到
	return std::make_pair(parent, -1);
}

分裂插入举例


依次插入到79为分裂

我们首先通过FindKey函数进行查找,通过pair的second得知没有在B树中存在,然后在pair的first当中插入key值。
在这里插入图片描述
此时需要进行分裂,第一步就是将int mid = M/2保证了双边都是M-1,将[mid,cur->_n]这键值key给到brother,以及将对应key值的左右孩子指针拷贝(这里为NULL先不关心),然后将_keys[mid]也就是中间节点给到father(若不存在,则需要new一个,然后设置father的左右孩子节点,以及左右孩子节点的父亲节点都需要设置)。
在这里插入图片描述

  • 逻辑为先挪动键值key与孩子节点,让我们的key有位置可以插入,此时注意还需要将最后一个右孩子也给挪走
  • cur->_n != M标识插入结束
  • 若cur->_n == M则需要做以下步骤:创建兄弟节点brother,分一半的键值和孩子指针给兄弟。然后需要考虑是否已经有父亲节点(若存在,则问题转化为往父亲插入一个key值)。若不存在则只需要新创建插入key,然后将cur和brother与父亲链接起来即可。

注意:
cur->_subs[i]->_parent = brother;表示需要将分裂给brother的孩子节点重新父亲链接到brother(若存在),否则插入最后一个101的时候会观察到右子树当中的叶子节点链接不正确的情况。

void InsertKeyToNode(const K& key, Node* cur,Node* brother)
{
    
    
	//将节点往后挪,知道该位置的值小于key
	int end = cur->_n - 1;
	while (end >= 0  && cur->_keys[end] > key)
	{
    
    
		//父亲指针和数值都需要移动
		cur->_keys[end + 1] = cur->_keys[end];
		cur->_subs[end + 2] = cur->_subs[end + 1];
		end--;
	}
	//插入节点
	cur->_keys[end + 1] = key;
	if(brother)
	cur->_subs[end + 2] = brother;
	cur->_n++;

	if (cur->_n == M)
	{
    
    
		//说明此时需要分裂
		Node* brother = new Node;
		//中间节点需要往父亲提上去
		int mid = M / 2;
		K midKey = cur->_keys[mid];
		int j = 0;// 给bro用
		//tmp用于清空,便于观察
		K tmp = K();
		cur->_keys[mid] = tmp;

		for (int i = mid + 1; i < cur->_n; ++i)
		{
    
    
			//这部分数据需要移动给bro
			brother->_keys[j] = cur->_keys[i];
			//孩子需要给
			brother->_subs[j] = cur->_subs[i];
			if(cur->_subs[i])
			cur->_subs[i]->_parent = brother;
			
			//清空操作
			cur->_keys[i] = tmp;
			cur->_subs[i] = nullptr;

			j++;
		}
		brother->_parent = cur->_parent;

		//brother需要给最后一个
		brother->_n = cur->_n - mid - 1;
		cur->_n = mid;
		brother->_subs[j] = cur->_subs[cur->_n + 2];
		cur->_subs[cur->_n + 1] = nullptr;

		
		Node* parent = cur->_parent;
		//若头节点存在
		if (parent)
		{
    
    
			//问题转化为像头节点插入midKey,此时需要把brother也设置好
			InsertKeyToNode(midKey, parent,brother);
		}//若头节点不存在
		else
		{
    
    
			//中间节点提上去
			//初始化新头节点
			Node* newRoot = new Node;
			//由于是新增节点,所以需要设置他的cur和brother的父亲节点
			cur->_parent = newRoot;
			brother->_parent = newRoot;
			
			newRoot->_keys[0] = midKey;
			newRoot->_subs[0] = cur;
			newRoot->_subs[1] = brother;
			newRoot->_n++;

			_root = newRoot;
		}
	}
}

注意:插入101是一个很好的检验,通过观察就可以发现满足以下三部,先叶子节点分裂,再到_root节点的分裂产生新的根。建议根据代码调试

1.在这里插入图片描述
2.在这里插入图片描述
3.在这里插入图片描述



附上每一步的插入图片

插入53:
在这里插入图片描述
插入139:
在这里插入图片描述

插入75:
在这里插入图片描述

插入49:
在这里插入图片描述
插入145:
在这里插入图片描述
插入36:
在这里插入图片描述

插入50:
在这里插入图片描述

插入47:
在这里插入图片描述
插入101分为3步骤:

1.在这里插入图片描述
2.在这里插入图片描述
3.在这里插入图片描述

B树效率分析

m为4的情况: 从下面二图分别有最少存放数的四层和最多存放数,可以看到第四层可以存2.5亿数据,1万亿数据。
其中每个节点的关键字按最低的算即可,算最低的时候是511,最多的时候是1023。
在这里插入图片描述
在这里插入图片描述

h为高度层,N为总结点数,M为每个节点的孩子数量。
M+M^2+M^3+....M^h = N
h = logmN

中序遍历~~

中序遍历

在这里插入图片描述

void Inorder()
{
    
    
	return Inorder(_root);
}
void Inorder(Node* root)
{
    
    
	if (root == nullptr)
		return;
	for (int i = 0; i < root->_n; ++i)
	{
    
    
		Inorder(root->_subs[i]);
		cout << root->_keys[i] << " ";
	}
	Inorder(root->_subs[root->_n]);
}

结果:
在这里插入图片描述

B+树 与 B*树

简单了解下~~

B+树

B+树对比B树在分支节点不存实际数据,将数据都放在叶子节点,并且将叶子节点用链表链接起来,方便遍历。每个分支节点不存数据会让每个分支节点能够存储更多的key值,保证了树能够进一步的压缩。

B+树添加/更新的规则:

  1. 分支节点的子树指针与关键字个数相同
  2. 分支节点的子树指针p[i]指向关键字值大小在[k[i],k[i+1])区间之间(相当于取消最左边的孩子)
  3. 所有叶子节点增加一个链接指针链接在一起
  4. 所有关键字及其映射数据都在叶子节点出现

在这里插入图片描述

B+树的特性:

  1. 所有关键字都出现在叶子节点的链表中,且链表中的节点都是有序的。
  2. 不可能在分支节点中命中。
  3. 分支节点相当于是叶子节点的索引,叶子节点才是存储数据的数据层。

B+树的分裂:
当一个结点满时,分配一个新的结点,并将原结点中1/2的数据复制到新结点,最后在父结点中增加新结点的指针;B+树的分裂只影响原结点和父结点,而不会影响兄弟结点,所以它不需要指向兄弟的指针。

B* 树啦~~

B*树


B*树创建的时候是想让每个节点浪费的空间更小,由于上述的B+或者B*可能导致大量节点只用了一半的空间,导致空间浪费严重。

B*树的分裂:
当当前节点数据满了,先给部分数据给兄弟,只有兄弟也满了,才会创建新节点,两个节点都给1/3给父亲,这样就保证了每个节点空间都使用了2/3。

在这里插入图片描述


到此为止啦~

在这里插入图片描述
详细代码–》码云

  • 喜欢就收藏
  • 认同就点赞
  • 支持就关注
  • 疑问就评论

猜你喜欢

转载自blog.csdn.net/weixin_52344401/article/details/125898517