当前位置:   article > 正文

【C++从0到王者】第五十站:B树

【C++从0到王者】第五十站:B树

一、内查找与外查找

1.内查找

像我们之前所用的在内存中的查找就是内查找

种类数据格式时间复杂度
顺序查找无要求O(N)
二分查找有序O(log_2 N)
二叉搜索树无要求O(N)
二叉平衡树(AVL树和红黑树)无要求O(log_2 N)
哈希无要求O(1)

以上结构适合用于数据量相对不是很大,能够一次性存放在内存中,进行数据查找的场景。

2.外查找

如果数据量很大,比如有100G数据,无法一次放进内存中,那就只能放在磁盘上了,如果放在磁盘上,有需要搜索某些数据,那么如果处理呢?那么我们可以考虑将存放关键字及其映射的数据的地址放到一个内存中的搜索树的节点中,那么要访问数据时,先取这个地址去磁盘访问数据。

如下所示,是在一颗二叉树中,在内存中存储的只是在磁盘当中的地址。然后就可以去磁盘当中去寻找对应的值

然而只是使用这种二叉树的结构,还是存在一些问题

使用平衡二叉树搜索树的缺陷:

  • 平衡二叉树搜索树的高度是logN,这个查找次数在内存中是很快的。但是当数据都在磁盘中时,访问磁盘速度很慢,在数据量很大时,logN次的磁盘访问,是一个难以接受的结果。

使用哈希表的缺陷:

  • 哈希表的效率很高是O(1),但是一些极端场景下某个位置冲突很多,导致访问次数剧增,也是难以接受的。

下面是对磁盘IO访问速度的解释

image-20240224202818040

那如何加速对数据的访问呢?

  1. 提高IO的速度(SSD相比传统机械硬盘快了不少,但是还是没有得到本质性的提升)

  2. 降低树的高度—多叉树平衡树

对于红黑树和AVL树而言,他们需要经历高度次IO,哈希表极端场景下冲突很多,效率下降十分严重

我们可以在平衡搜索树中去寻找优化空间

  1. 压缩高度,二叉变多叉
  2. 一个结点里面有多个关键字及其映射的值

二、B树概念

1970年,R.Bayer和E.mccreight提出了一种适合外查找的树,它是一种平衡的多叉树,称为B树(后面有一个B的改进版本B+树,然后有些地方的B树写的的是B-树,注意不要误读成"B减树")。一棵m阶(m>2)的B树,是一棵平衡的M路平衡搜索树,可以是空树或者满足一下性质:

  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

简单的来解读一下上面的概念

  • 首先对于第二条和第三条,除了对于叶子结点他是没有子树的,当然不需要孩子之外,剩下的规则是一样的,一个结点有k-1个关键字和k个孩子,也就是关键字是要比孩子少一个的。并且k也是有范围的。比如当m为10的时候,最少的情况是,4个关键字,5个孩子。最多的情况是9个关键字,10个孩子

  • 其次对于第五条和第六条。其实意思就是说 A是孩子,K是关键字。且A0结点中的所有值<K1<A1结点中的所有值<K2....<Kn<An以此类推下去。也就是K-1个元素将K个孩子所包含的元素的值域进行了划分

image-20240224205049053

B树的这种设计就可以保证了,整体而言存储的地址就要少了许多许多了。比如原先由10亿人,我们就按一个指针4字节,需要40亿字节去存储指针,这还不包括其他的连接树所用到的指针。而现在,当M为10的时候,直接缩减了10倍。如果M更大,那么节省的内存越多。并且它还可以提高读取的效率,因为很多个数据在一块连着放着,根据前面关于磁盘读写速度的介绍可以得知,它只有在定位的时候是因为是机械运动导致很慢,如果非常离散的放着,那么频繁的机械运动势必大大降低效率,而现在是集中放着,就可以减少机械运动提高效率,因为它的读取并不慢,只是机械运动很慢。而且在每个结点内部由于是排序的,所以可以在内部还用一次二分查找,也能提高效率。而不是暴力查找。

像实际设计的时候,m通常设计的比较大,一般m=1024,但是M也不能太大,否则造成空间浪费

三、B树的插入

1.B树的插入分析

对于B树,我们为了简单的进行分析,我们先以M=3时的B树进行分析

对于M=3时候,这颗树即三叉树,每个节点中存储两个数据,两个数据可以将区间分割成三个部分,因此节点应该有三个孩子 ,它应该是下图中的左边部分,但是这里我们为了分析变得简单一些,我们让三阶的B树是右图所示的结构

image-20240225153737481

这里需要注意的是

  • 孩子永远比数据多一个
  • B树的插入只在叶子结点中进行插入,不会在其他的结点中进行插入,且所有的叶子结点一定在最后一层

我们用下面的序列对B树的插入进行分析

用序列{53, 139, 75, 49, 145, 36, 101}构建B树的过程如下

  1. 我们一开始只有一个B树的结点,这个结点里面什么都没有,且它本身就是一个叶子节点,现在我们第一次的插入就是插入53,此时直接放入数据中即可。
  2. 接下来插入139,由于B树中结点的数据是排序好的,所以139需要在53后面进行放置。此时理论上,对于M=3的B树,数据已经满了。但是此时还满足着B树的满足条件
  3. 接下来插入75,对于75,按照正常情况下,3阶B树的数据域已经被填满了,我们已经无法在这个结点中进行插入数据了。但是注意,我们用的是比理论上的B树多开了一个数值位和孩子指针位。这里就很微妙了,我们先按照排序的规则将75先插入到这个结点中去。然后接下来,我们检测到此时就是超出了一个数据,此时对这个结点进行分裂即可

image-20240225154136015


分裂过程(被分裂结点为根结点)

如下图所示,是分裂过程,还是前面的例子,此时对于三阶的B树,本来只应该有2个数据,但是此时已经有3个数据了,需要进行分裂了。所谓分裂就是因为该结点插入的数据多了一个,所以新开辟一个结点,先计算出一个mid = M/2,注意这个mid是一个下标,由于下标天然的比第几个数据要小一个,所以这里就天然的向上取整了,而我们这里需要的就是向上取整,目的是为了让[0,mid]的数据个数要比[mid+1,M-1]的数据大于一个或者等于,总之使之相差不大。,然后让[mid +1, M-1]之间的所有数据给这个新的结点。

给了这个新的结点以后,此时我们就面临两个选择了,就是原来我们被分裂的这个结点它是不是根结点。

为了方便描述,我们令前面过程中被分裂的结点为parent,新生出来的结点为brother

  • 如果是根结点,就是我们下面的这个例子了,我们在开辟一个结点,让这个结点只放入左结点的最后一个数据,然后让他去作为原来的parent和brother结点的父亲结点。注意,由于parent本身就是按照排序排列的,所以一定有左边的结点的全部数据小于父节点的全部数据小于右结点的全部数据。而由于父节点一定只有一个数据,也就是我们刚刚移过去的数据,所以我们直接让75的第0下标的指针指向这个左结点,1下标的指针指向右节点。这样一来,在满足B树规则的前提下,我们成功的实现了插入的分裂。

  • 如果不是根结点呢?此时还没有到具体的例子的情况,我们在后面细说。

image-20240225154750975


  1. 我们接下来插入49和145。我们先看49,对于49,我们一定要遵守的规则是只在叶子结点中进行插入!此时49是小于75的,那么它一定在75的左边的指针域指向的结点中,在这个结点中,我们发现当前就是叶子结点,所以开始插入,因为这个结点原先就是53一个数值,因为每个结点都要按照升序的顺序,所以让49放到53之前,此时并没有让数组填满,所以不用进行分裂。

接下来填充145,此时145是要比75大的,但是75后面是没有值了,并且它是并不是叶子结点,所以就直接1号下标的指针所指向的结点当中去找,此时发现当前是叶子结点,并且是比139要大的,所以直接插入在139的后面即可。此时它也不需要进行分裂,因为满足B树的所有规则,并且它也是平衡的。

image-20240225161505533


分裂过程分析(非根结点)

  1. 插入36,当我们继续插入36以后,36小于75,那么一定在75的左侧指针所指向的结点处。而此时36又小于49(第一个值)且该节点是叶子结点,所以进行插入,此时我们发现又满了。那么就该分裂了。

此时我们的分裂过程如下:此时是对于一个非根结点进行分裂的,前面的步骤都是一样的,先创建好兄弟结点,将一半的值拷贝个兄弟结点,然后此时由于被分裂的结点是具有父亲结点的,那么此时就不在需要新创建一个父亲结点了,而是利用相似的方法,经过计算后mid为49所处的位置,所以我们将49给插入到它的父节点当中,由于需要按顺序进行排列,所以49在75之前。现在的问题是,我们前面的操作似乎已经破坏了原本的平衡了,即不满足A0<K0<A1<K1…的规则了。因为上面的顺序交换之后,指针的位置也需要适当的调整一下。

我们现在可以分析一下每个结点的变化:对于最上面的父节点中的元素而言,我们知道,它每次总是从被分裂的结点中取出一个最大的放上去的,所以父节点的元素一定大于原来被分裂结点的所有元素,所以它的位置不应该被改变。因为这是恒定的。

其次,对于原来的第二个指针,我们可以观察到,父节点的结点总是会向后移动的,因为往原来被分裂的结点中插入的结点一定是小于上面的结点的,而现在,我们对它分裂的时候,是从它里面取数据放上去的,所以,75一定会向后移动,也就是说,从它之后的所有数据都要向后移动。而这样一来,那么紧随其的原来的右指针,一定会全部向右移动。这样才能满足B树规则

最后,对于兄弟结点而言,它当中的最小的一定比原来被分裂的要大,而要比75要小,所以,它一定位于1号下标处。

最终,我们总结一下对于非根结点的调整规则:求出mid以后,让[mid+1,M-1]区间的数据交给兄弟结点,然后将被分裂结点的mid对应的值插入到,它在父节点中所处的区间的右侧位置处,让后面的数据(75以及它后面的数据)以及指针(75的右侧区间的指针开始)全部往右移动一位,然后让腾出来的位置指向兄弟结点

这样就完成了非根结点的分裂过程了。

image-20240225165721669


连续分裂

  1. 接下来是插入101,如下图也正好是前面的非根结点分裂结束之后的样子

这里我们先要在B树中找到该结点的对应的插入位置

找到之后,我们按照插入排序的思路可以将这个101给放进去,放进去之后就开始检测是否满足B树性质,如果需要分裂了,就进行分裂。

显然101比75要大,但是75之后没有数据了,且75不是叶子节点,所以取75的右侧区域去找,然后此时这个结点就是叶子结点了,这个结点就是要插入的结点,我们用插入排序的方法很容易就将101给插入到第一个位置上,注意该移动的数据都要进行对应的移动,此时由于是叶子结点,所以他们也没有什么指针需要进行移动。此时我们发现这个需要进行分裂了。并且此时是非根结点的分裂,按照分裂的规则进行分裂完成

image-20240225165757370

image-20240225174345459

此时分裂结束后就是如下情况

image-20240225174916276

不过我们又发现了,根节点,也要不满足情况了,所以根节点也要进行分裂了。

注意这里的分裂要小心了。不同的是,根节点它是有指针的,这也就意味着:它的指针也要跟着进行分裂

所以这里我们的策略其实就是,首先不变的是,求出mid,然后让mid后面的数据给兄弟结点,不过在给数据的时候,还要将所对应的指针区域也要给了。因为要满足B树的规则。我们让key和key的左孩子都给拷贝过去。然后最后额外再拷贝一次剩余的最后的右孩子。这样拷贝的问题就被处理了。不过这里有一个隐藏的细节问题,那就是如果我们在结点的结构体里面定义了指向父节点的指针,注意这里一定要修改为brother为父亲了。处理了下面的指针,然后就是上面的指针了,这个处理过程与前面是一样的。

如此一来,在不破坏B树规则的前提下,成功实现了连续分裂了。

image-20240225182929704

2.B树插入总结

插入过程总结:

  1. 如果树为空,直接插入新节点中,该节点为树的根节点
  2. 树非空,找待插入元素在树中的插入位置(注意:找到的插入节点位置一定在叶子节点中)
  3. 检测是否找到插入位置(假设树中的key唯一,即该元素已经存在时则不插入)
  4. 按照插入排序的思想将该元素插入到找到的节点中
  5. 检测该节点是否满足B-树的性质:即该节点中的元素个数是否等于M,如果小于则满足
  6. 如果插入后节点不满足B树的性质,需要对该节点进行分裂:
  • 申请新节点
  • 找到该节点的中间位置
  • 将该节点中间位置右侧的元素以及其孩子搬移到新节点中
  • 将中间位置元素以及新节点往该节点的双亲节点中插入,即继续4
  1. 如果向上已经分裂到根节点的位置,插入结束

3.插入代码实现

如下代码所示,这里笔者已经将代码的解读放到了注释之中,已经是十分之详细了!

#pragma once
#include <iostream>

using namespace std;

template<class K, size_t M>
struct BTreeNode
{
	//K _keys[M - 1];
	//BTreeNode<K, M>* _subs[M];
	
	//为了方便插入以后再分裂,多给一个空间
	K _keys[M]; //key值
	BTreeNode<K, M>* _subs[M + 1]; //指针
	BTreeNode<K, M>* _parent; //记录一下该节点的父节点
	size_t _n; //记录实际存储了多少个关键字

	BTreeNode()
	{
		for (int i = 0; i < M; i++)
		{
			_keys[i] = K();
			_subs[i] = nullptr;
		}
		_subs[M] = nullptr;
		_parent = nullptr;
		_n = 0;
	}

};

//数据是存在磁盘中的,K是磁盘地址
template<class K, size_t M>
class BTree
{
	typedef BTreeNode<K, M> Node;
public:
	//该函数用于寻找一个结点,即给一个key值,求出key在哪个结点里面
	//返回值的第一个参数是该结点的指针,第二个参数意味着该key应该在该结点的第几个下标处
	//如果找到了就正常返回<该节点的指针,key位于该结点的第几个下标>
	//如果没有找到,那么就返回<应该插入的结点的指针,-1>,也就是我们可以通过这个-1来辨别是否插入成功
	pair<Node*, int> Find(const K& key)
	{
		Node* cur = _root; //当前结点
		Node* parent = nullptr; //当前结点的父节点
		while (cur) //如果没有找到一定会导致cur为nullptr的。因为最终一定会去跑到叶子结点的某个孩子处,也就是nullptr
		{
			//在一个结点中查找
			size_t i = 0; //这个i代表着在当前结点的第几个下标
			while (i < cur->_n) //_n代表每个结点的数据个数,这里确保不要越界,如果超出了范围,那么这个i正好就是_n的值,也就是最右侧孩子处的结点,可以与下面的结合,直接跳转到最右孩子处
			{
				if (key < cur->_keys[i]) //一旦小于的话,那么我们一定可以确定的是,不在这个结点内,且当前的i正好指向这个_keys[i]的左孩子处
				{
					break; //直接使用break就是一种很巧妙的做法,可以利用外面的语句,直接跳转到对应的左孩子位置。
				}
				else if (key > cur->_keys[i]) //如果大于,那么说明可能在右侧可以找到
				{
					++i;
				}
				else //找到了,直接返回该节点指针和key下标
				{
					return make_pair(cur, i);
				}
			}
			//先记录一下我们的这个当前结点。以至于即便没有找到,那么我们也知道应该要去哪里插入了。即返回了要插入的那个叶子节点
			parent = cur;
			//往孩子去跳,一行代码两用,巧妙的控制了i,既在该该跳转到左孩子时候可以直接跳转到左孩子,也可以直接跳转到右孩子。比较巧妙
			cur = cur->_subs[i]; 
		}
		//用第二个参数-1来区分是找到了还是没找到,因为下标不可能是-1,第一个参数用来指明既然没有找到,那么如果还想要插入这个key的话,那么去这里插入是对的
		return make_pair(parent, -1);
	}

	//该函数的应用场景有两种情形,一种是为叶子直接插入key值,此时child为nullptr,一种是下层的为叶子结点插入时候发生了分裂,导致为上层插入key,此时需要插入child兄弟指针
	//为node结点插入key和child。key是关键字。node就是父节点,child是兄弟结点,因为我们可能会插入新分裂出来的一个结点
	//因为我们的插入是需要移动key值和指针的。两个最后都会空余的。这两个就是用来插入这两个的
	void InsertKey(Node* node,const K& key, Node* child)
	{
		//利用插入排序的方式将结点的指针全部移动
		int end = node->_n - 1;
		while (end >= 0)
		{
			if (key < node->_keys[end])
			{
				//不仅要挪动key,还要挪动右孩子
				//挪出位置来
				node->_keys[end + 1] = node->_keys[end];
				node->_subs[end + 2] = node->_subs[end + 1];
				--end;
			}
			else
			{
				break;
			}
		}
		node->_keys[end + 1] = key; //插入key值
		node->_subs[end + 2] = child; //插入可能的兄弟结点,child有可能为空,即并没有分裂。此时它一定为叶子结点,本身就是nullptr,所以赋值与否并不影响
										//因为我们只能对叶子结点进行直接插值,如果对非叶子结点插入值,只能是下层的进行了分裂,导致上层的才添加了数据。此时本身就需要将兄弟结点的指针挪动到该位置处

		//如果child为nullptr,那么是叶子结点,不用让兄弟结点去指向父亲节点。
		//如过不是nullptr,那么插入了这个结点以后让他指向父亲结点
		if (child)
		{
			child->_parent = node; //兄弟结点的父亲结点设置为node
		}
		//无论如何,node结点的数据个数一定是加一的。
		node->_n++;
	}

	bool Insert(const K& key)
	{
		//第一次插入,直接开辟一个结点出来,然后将这个key值直接插入进去
		if (_root == nullptr)
		{
			_root = new Node;
			_root->_keys[0] = key;
			_root->_n++;
			return true;
		}
		//插入之前先看看有没有它
		pair<Node*, int> ret = Find(key);
		//如果找到了,说明已经有它了,我们让他直接插入失败。
		if (ret.second >= 0)
		{
			return false;
		}
		//如果没找到,那么ret的first也正好是要插入的那个叶子节点
		Node* parent = ret.first; //parent就是要插入的哪个结点
		K newkey = key;	//这里我们把这个key值在保存一份,因为我们原来的这个key是不可以被修改的,但是我们后面是需要修改一下的,这里主要是为了照顾连续插入的场景,至少发生了一次分裂
		Node* child = nullptr; //兄弟指针,这里的名字可能不太好,它是为因为叶子节点产生分裂时,上层要插入数据的时候,一定会有一个key和一个指针的插入的。
		while (1)
		{
			//无论parent是叶子结点还是非叶子结点,这个函数一语双关,都可以成功实现我们的目标
			InsertKey(parent, newkey, child);
			//满了就要分裂了,没有满就插入结束
			if (parent->_n < M)
			{
				return true;
			}
			//此时需要分裂了。因为满了,下面的同样是既可以照顾到叶子结点的分裂,也可以照顾到非叶子结点的分裂,一块函数,两种妙用
			else  
			{
				//分裂一半
				size_t mid = M / 2;
				//分裂[mid+1, M-1]给兄弟
				Node* brother = new Node;

				//兄弟结点要拷贝被分裂结点的数据
				size_t j = 0;
				size_t i = mid + 1;
				for (; i <= M - 1; ++i) //从mid+1开始进行拷贝
				{
					//key和key的左孩子被拷贝给兄弟结点
					brother->_keys[j] = parent->_keys[i];
					brother->_subs[j] = parent->_subs[i];
					//要指向它的父亲
					if (parent->_subs[i])
					{
						parent->_subs[i]->_parent = brother;
					}
					j++; //记录着兄弟结点的元素个数。

					//清理一下被拷贝走的部分方便我们观察
					parent->_keys[i] = K(); //拷贝走的,我们清空一下
					parent->_subs[i] = nullptr; //清空指针
				}
				//最后一个右孩子的拷贝
				brother->_subs[j] = parent->_subs[i];
				if (parent->_subs[i])
				{
					parent->_subs[i]->_parent = brother;
				}
				//清理一下
				parent->_subs[i] = nullptr;
				//兄弟结点的个数
				brother->_n = j;

				//多减去一个,因为还有一个要给父亲,这里顺便就减去了。
				parent->_n -= (brother->_n + 1);

				K midKey = parent->_keys[mid]; //提前保存一下要插入给父亲结点的那个值。

				//清理被分裂的mid处的值。因为这个值要给父亲,至此被分裂的结点的拷贝就彻底结束了。兄弟结点也已经就绪了。
				parent->_keys[mid] = K();


				//说明刚刚分裂的是根结点,根节点的分裂比较特殊,因为需要多产生一个结点
				if (parent->_parent == nullptr)
				{
					_root = new Node;
					
					_root->_keys[0] = midKey; //根节点的值
					_root->_subs[0] = parent; //连接左孩子
					_root->_subs[1] = brother; //连接右孩子
					_root->_n = 1;

					parent->_parent = _root; //左孩子连接根节点
					brother->_parent = _root;//右孩子连接根节点

					break; //这里其实已经可以直接return true了,不过因为编译器只是简单的认为不是所有的情况都有返回值。所以我们使用一下break,在外层返回一下也是可以的。毕竟里层的是个死循环,只有这里可以打破死循环
				}
				//如果分裂的不是根节点,就不需要额外创建那个结点
				else
				{
					newkey = midKey; //newkey是InsertKey的一个参数
					child = brother; //它也是上面函数的一个参数
					//转换成往parent->parent插入去插入parent->_keys[mid]和brother
					parent = parent->_parent; //这个结点就是parent的父节点,也就是mid和brother都需要与他建立连接
					//由于该函数正好写在第一行,所以,我们直接循环回去去完成插入这件事情。
					//至此,这个循环就一直在运行,而且它最终一定会出去的,只要我们的B树是正确的!!!
				}
			}
		}
		return true;
	}
private:
	Node* _root = nullptr;
};

void TestBTree()
{
	int a[] = { 53, 139, 75, 49, 145, 36 ,101 };
	BTree<int, 3> t;
	for (auto e : a)
	{
		if (e == 36)
			int a = 0;
		t.Insert(e);
	}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110
  • 111
  • 112
  • 113
  • 114
  • 115
  • 116
  • 117
  • 118
  • 119
  • 120
  • 121
  • 122
  • 123
  • 124
  • 125
  • 126
  • 127
  • 128
  • 129
  • 130
  • 131
  • 132
  • 133
  • 134
  • 135
  • 136
  • 137
  • 138
  • 139
  • 140
  • 141
  • 142
  • 143
  • 144
  • 145
  • 146
  • 147
  • 148
  • 149
  • 150
  • 151
  • 152
  • 153
  • 154
  • 155
  • 156
  • 157
  • 158
  • 159
  • 160
  • 161
  • 162
  • 163
  • 164
  • 165
  • 166
  • 167
  • 168
  • 169
  • 170
  • 171
  • 172
  • 173
  • 174
  • 175
  • 176
  • 177
  • 178
  • 179
  • 180
  • 181
  • 182
  • 183
  • 184
  • 185
  • 186
  • 187
  • 188
  • 189
  • 190
  • 191
  • 192
  • 193
  • 194
  • 195
  • 196
  • 197
  • 198
  • 199
  • 200
  • 201
  • 202
  • 203
  • 204
  • 205
  • 206
  • 207
  • 208
  • 209
  • 210
  • 211
  • 212
  • 213
  • 214
  • 215
  • 216
  • 217
  • 218
  • 219
  • 220
  • 221
  • 222
  • 223
  • 224
  • 225
  • 226
  • 227
  • 228
  • 229
  • 230

经过了上面的实现,我们也终于可以理解了,为什么它是平衡的,因为它是向右和向上增长的树

4.B树满树和最空时候的对比

image-20240225195839446

5.B树的删除

如下所示:是删除的大致思路:就是

image-20240225205152793

image-20240225205201953

image-20240225205211248

6.遍历B树

B树的遍历就是我们用类似于二叉树的中序遍历即可,不过我们的顺序是左子树,根,左子树,根…右子树

image-20240225211218258

代码如下

#pragma once
#include <iostream>

using namespace std;

template<class K, size_t M>
struct BTreeNode
{
	//K _keys[M - 1];
	//BTreeNode<K, M>* _subs[M];
	
	//为了方便插入以后再分裂,多给一个空间
	K _keys[M]; //key值
	BTreeNode<K, M>* _subs[M + 1]; //指针
	BTreeNode<K, M>* _parent; //记录一下该节点的父节点
	size_t _n; //记录实际存储了多少个关键字

	BTreeNode()
	{
		for (int i = 0; i < M; i++)
		{
			_keys[i] = K();
			_subs[i] = nullptr;
		}
		_subs[M] = nullptr;
		_parent = nullptr;
		_n = 0;
	}

};

//数据是存在磁盘中的,K是磁盘地址
template<class K, size_t M>
class BTree
{
	typedef BTreeNode<K, M> Node;
public:
	//该函数用于寻找一个结点,即给一个key值,求出key在哪个结点里面
	//返回值的第一个参数是该结点的指针,第二个参数意味着该key应该在该结点的第几个下标处
	//如果找到了就正常返回<该节点的指针,key位于该结点的第几个下标>
	//如果没有找到,那么就返回<应该插入的结点的指针,-1>,也就是我们可以通过这个-1来辨别是否插入成功
	pair<Node*, int> Find(const K& key)
	{
		Node* cur = _root; //当前结点
		Node* parent = nullptr; //当前结点的父节点
		while (cur) //如果没有找到一定会导致cur为nullptr的。因为最终一定会去跑到叶子结点的某个孩子处,也就是nullptr
		{
			//在一个结点中查找
			size_t i = 0; //这个i代表着在当前结点的第几个下标
			while (i < cur->_n) //_n代表每个结点的数据个数,这里确保不要越界,如果超出了范围,那么这个i正好就是_n的值,也就是最右侧孩子处的结点,可以与下面的结合,直接跳转到最右孩子处
			{
				if (key < cur->_keys[i]) //一旦小于的话,那么我们一定可以确定的是,不在这个结点内,且当前的i正好指向这个_keys[i]的左孩子处
				{
					break; //直接使用break就是一种很巧妙的做法,可以利用外面的语句,直接跳转到对应的左孩子位置。
				}
				else if (key > cur->_keys[i]) //如果大于,那么说明可能在右侧可以找到
				{
					++i;
				}
				else //找到了,直接返回该节点指针和key下标
				{
					return make_pair(cur, i);
				}
			}
			//先记录一下我们的这个当前结点。以至于即便没有找到,那么我们也知道应该要去哪里插入了。即返回了要插入的那个叶子节点
			parent = cur;
			//往孩子去跳,一行代码两用,巧妙的控制了i,既在该该跳转到左孩子时候可以直接跳转到左孩子,也可以直接跳转到右孩子。比较巧妙
			cur = cur->_subs[i]; 
		}
		//用第二个参数-1来区分是找到了还是没找到,因为下标不可能是-1,第一个参数用来指明既然没有找到,那么如果还想要插入这个key的话,那么去这里插入是对的
		return make_pair(parent, -1);
	}

	//该函数的应用场景有两种情形,一种是为叶子直接插入key值,此时child为nullptr,一种是下层的为叶子结点插入时候发生了分裂,导致为上层插入key,此时需要插入child兄弟指针
	//为node结点插入key和child。key是关键字。node就是父节点,child是兄弟结点,因为我们可能会插入新分裂出来的一个结点
	//因为我们的插入是需要移动key值和指针的。两个最后都会空余的。这两个就是用来插入这两个的
	void InsertKey(Node* node,const K& key, Node* child)
	{
		//利用插入排序的方式将结点的指针全部移动
		int end = node->_n - 1;
		while (end >= 0)
		{
			if (key < node->_keys[end])
			{
				//不仅要挪动key,还要挪动右孩子
				//挪出位置来
				node->_keys[end + 1] = node->_keys[end];
				node->_subs[end + 2] = node->_subs[end + 1];
				--end;
			}
			else
			{
				break;
			}
		}
		node->_keys[end + 1] = key; //插入key值
		node->_subs[end + 2] = child; //插入可能的兄弟结点,child有可能为空,即并没有分裂。此时它一定为叶子结点,本身就是nullptr,所以赋值与否并不影响
										//因为我们只能对叶子结点进行直接插值,如果对非叶子结点插入值,只能是下层的进行了分裂,导致上层的才添加了数据。此时本身就需要将兄弟结点的指针挪动到该位置处

		//如果child为nullptr,那么是叶子结点,不用让兄弟结点去指向父亲节点。
		//如过不是nullptr,那么插入了这个结点以后让他指向父亲结点
		if (child)
		{
			child->_parent = node; //兄弟结点的父亲结点设置为node
		}
		//无论如何,node结点的数据个数一定是加一的。
		node->_n++;
	}

	bool Insert(const K& key)
	{
		//第一次插入,直接开辟一个结点出来,然后将这个key值直接插入进去
		if (_root == nullptr)
		{
			_root = new Node;
			_root->_keys[0] = key;
			_root->_n++;
			return true;
		}
		//插入之前先看看有没有它
		pair<Node*, int> ret = Find(key);
		//如果找到了,说明已经有它了,我们让他直接插入失败。
		if (ret.second >= 0)
		{
			return false;
		}
		//如果没找到,那么ret的first也正好是要插入的那个叶子节点
		Node* parent = ret.first; //parent就是要插入的哪个结点
		K newkey = key;	//这里我们把这个key值在保存一份,因为我们原来的这个key是不可以被修改的,但是我们后面是需要修改一下的,这里主要是为了照顾连续插入的场景,至少发生了一次分裂
		Node* child = nullptr; //兄弟指针,这里的名字可能不太好,它是为因为叶子节点产生分裂时,上层要插入数据的时候,一定会有一个key和一个指针的插入的。
		while (1)
		{
			//无论parent是叶子结点还是非叶子结点,这个函数一语双关,都可以成功实现我们的目标
			InsertKey(parent, newkey, child);
			//满了就要分裂了,没有满就插入结束
			if (parent->_n < M)
			{
				return true;
			}
			//此时需要分裂了。因为满了,下面的同样是既可以照顾到叶子结点的分裂,也可以照顾到非叶子结点的分裂,一块函数,两种妙用
			else  
			{
				//分裂一半
				size_t mid = M / 2;
				//分裂[mid+1, M-1]给兄弟
				Node* brother = new Node;

				//兄弟结点要拷贝被分裂结点的数据
				size_t j = 0;
				size_t i = mid + 1;
				for (; i <= M - 1; ++i) //从mid+1开始进行拷贝
				{
					//key和key的左孩子被拷贝给兄弟结点
					brother->_keys[j] = parent->_keys[i];
					brother->_subs[j] = parent->_subs[i];
					//要指向它的父亲
					if (parent->_subs[i])
					{
						parent->_subs[i]->_parent = brother;
					}
					j++; //记录着兄弟结点的元素个数。

					//清理一下被拷贝走的部分方便我们观察
					parent->_keys[i] = K(); //拷贝走的,我们清空一下
					parent->_subs[i] = nullptr; //清空指针
				}
				//最后一个右孩子的拷贝
				brother->_subs[j] = parent->_subs[i];
				if (parent->_subs[i])
				{
					parent->_subs[i]->_parent = brother;
				}
				//清理一下
				parent->_subs[i] = nullptr;
				//兄弟结点的个数
				brother->_n = j;

				//多减去一个,因为还有一个要给父亲,这里顺便就减去了。
				parent->_n -= (brother->_n + 1);

				K midKey = parent->_keys[mid]; //提前保存一下要插入给父亲结点的那个值。

				//清理被分裂的mid处的值。因为这个值要给父亲,至此被分裂的结点的拷贝就彻底结束了。兄弟结点也已经就绪了。
				parent->_keys[mid] = K();


				//说明刚刚分裂的是根结点,根节点的分裂比较特殊,因为需要多产生一个结点
				if (parent->_parent == nullptr)
				{
					_root = new Node;
					
					_root->_keys[0] = midKey; //根节点的值
					_root->_subs[0] = parent; //连接左孩子
					_root->_subs[1] = brother; //连接右孩子
					_root->_n = 1;

					parent->_parent = _root; //左孩子连接根节点
					brother->_parent = _root;//右孩子连接根节点

					break; //这里其实已经可以直接return true了,不过因为编译器只是简单的认为不是所有的情况都有返回值。所以我们使用一下break,在外层返回一下也是可以的。毕竟里层的是个死循环,只有这里可以打破死循环
				}
				//如果分裂的不是根节点,就不需要额外创建那个结点
				else
				{
					newkey = midKey; //newkey是InsertKey的一个参数
					child = brother; //它也是上面函数的一个参数
					//转换成往parent->parent插入去插入parent->_keys[mid]和brother
					parent = parent->_parent; //这个结点就是parent的父节点,也就是mid和brother都需要与他建立连接
					//由于该函数正好写在第一行,所以,我们直接循环回去去完成插入这件事情。
					//至此,这个循环就一直在运行,而且它最终一定会出去的,只要我们的B树是正确的!!!
				}
			}
		}
		return true;
	}
	void _InOrder(Node* root)
	{
		if (root == nullptr)
		{
			return;
		}
		//左子树,根,左子树,根.....右子树
		int i = 0;

		for (; i < root->_n; i++)
		{
			_InOrder(root->_subs[i]); //左子树
			cout << root->_keys[i] << " ";
		}
		_InOrder(root->_subs[i]); //最后的右子树

	}
	void InOrder()
	{
		_InOrder(_root);
	}

private:
	Node* _root = nullptr;
};

void TestBTree()
{
	int a[] = { 53, 139, 75, 49, 145, 36 ,101 };
	BTree<int, 3> t;
	for (auto e : a)
	{
		if (e == 36)
			int a = 0;
		t.Insert(e);
	}
	t.InOrder();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110
  • 111
  • 112
  • 113
  • 114
  • 115
  • 116
  • 117
  • 118
  • 119
  • 120
  • 121
  • 122
  • 123
  • 124
  • 125
  • 126
  • 127
  • 128
  • 129
  • 130
  • 131
  • 132
  • 133
  • 134
  • 135
  • 136
  • 137
  • 138
  • 139
  • 140
  • 141
  • 142
  • 143
  • 144
  • 145
  • 146
  • 147
  • 148
  • 149
  • 150
  • 151
  • 152
  • 153
  • 154
  • 155
  • 156
  • 157
  • 158
  • 159
  • 160
  • 161
  • 162
  • 163
  • 164
  • 165
  • 166
  • 167
  • 168
  • 169
  • 170
  • 171
  • 172
  • 173
  • 174
  • 175
  • 176
  • 177
  • 178
  • 179
  • 180
  • 181
  • 182
  • 183
  • 184
  • 185
  • 186
  • 187
  • 188
  • 189
  • 190
  • 191
  • 192
  • 193
  • 194
  • 195
  • 196
  • 197
  • 198
  • 199
  • 200
  • 201
  • 202
  • 203
  • 204
  • 205
  • 206
  • 207
  • 208
  • 209
  • 210
  • 211
  • 212
  • 213
  • 214
  • 215
  • 216
  • 217
  • 218
  • 219
  • 220
  • 221
  • 222
  • 223
  • 224
  • 225
  • 226
  • 227
  • 228
  • 229
  • 230
  • 231
  • 232
  • 233
  • 234
  • 235
  • 236
  • 237
  • 238
  • 239
  • 240
  • 241
  • 242
  • 243
  • 244
  • 245
  • 246
  • 247
  • 248
  • 249
  • 250
  • 251
  • 252
  • 253

运行结果为

image-20240225211127553

7.B树的性能分析

对于一棵节点为N度为M的B-树,查找和插入需要log{M-1} N~log{M/2}N次比较,这个很好证明:对于度为M的B-树,每一个节点的子节点个数为M/2 ~(M-1)之间,因此树的高度应该在要log{M-1}N和log{M/2}N之间,在定位到该节点后,再采用二分查找的方式可以很快的定位到该元素。
B-树的效率是很高的,对于N = 62*1000000000个节点,如果度M为1024,则 l o g M / 2 N log_{M/2}N logM/2N <=4,即在620亿个元素中,如果这棵树的度为1024,则需要小于4次即可定位到该节点,然后利用二分查找可以快速定位到该元素,大大减少了读取磁盘的次数。

如下所示,是简单的计算

image-20240226122930485

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/我家自动化/article/detail/187316
推荐阅读
相关标签
  

闽ICP备14008679号