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

文章目录

  • 一、内查找与外查找
    • 1.内查找
    • 2.外查找
  • 二、B树概念
  • 三、B树的插入
    • 1.B树的插入分析
    • 2.B树插入总结
    • 3.插入代码实现
    • 4.B树满树和最空时候的对比
    • 5.B树的删除
    • 6.遍历B树
    • 7.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]和brotherparent = 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);}
}

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

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]和brotherparent = 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();
}

运行结果为

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/271108.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

IEEE754标准的c语言阐述,以及几个浮点数常量

很多年前&#xff0c;调研过浮点数与整数之间的双射问题&#xff1a; win7 intel x64 cpu vs2013 c语言浮点数精度失真问题 最近重新学习了一下IEEE754标准&#xff0c;也许实际还有很多深刻问题没有被揭示。 计算机程序设计艺术&#xff0c;据说这本书中也有讨论。 参考&…

如何将字体添加到 ONLYOFFICE 桌面编辑器8.0

作者&#xff1a;VincentYoung 为你写好的文字挑选一款好看的字体然而自带的字体列表却找不到你喜欢的怎么办&#xff1f;这只需要自己手动安装一款字体即可。这里教你在不同的桌面操作系统里的多种字体安装方法。 ONLYOFFICE 桌面编辑器 ONLYOFFICE 桌面编辑器是一款免费的办…

【C语言】终の指针(前篇)

个人主页点这里~ 指针初阶点这里~ 指针初阶2.0点这里~ 指针进阶点这里~ 终の指针 一、回调函数二、qsort函数1、整形比较2、结构数据比较①结构体②-> 的使用③结构数据比较 一、回调函数 回调函数就是⼀个通过函数指针调用的函数。 把一个函数的指针作为参数传递给另一…

@ResponseStatus

目录 概述&#xff1a; 用途&#xff1a; 参数&#xff1a; 注意事项&#xff1a; 自定义异常类&#xff1a; 底层原理&#xff1a; 概述&#xff1a; 在 Spring MVC 中&#xff0c;我们有很多方法来设置 HTTP 响应的状态码其中最直接的方法&#xff1a;使用 ResponseSt…

思维题(蓝桥杯 填空题 C++)

目录 题目一&#xff1a; ​编辑 代码&#xff1a; 题目二&#xff1a; 代码&#xff1a; 题目三&#xff1a; 代码&#xff1a; 题目四&#xff1a; 代码&#xff1a; 题目五&#xff1a; 代码&#xff1a; 题目六&#xff1a; 代码七&#xff1a; 题目八&#x…

分享关于如何解决系统设计问题的逐步框架

公司广泛采用系统设计面试&#xff0c;因为在这些面试中测试的沟通和解决问题的技能与软件工程师日常工作所需的技能相似。面试官的评估基于她如何分析一个模糊的问题以及如何逐步解决问题。测试的能力还包括她如何解释这个想法&#xff0c;与他人讨论&#xff0c;以及评估和优…

外包干了2年,技术退步明显

先说一下自己的情况&#xff0c;研究生&#xff0c;19年进入广州某软件公司&#xff0c;干了接近4年的功能测试&#xff0c;今年年初&#xff0c;感觉自己不能够在这样下去了&#xff0c;长时间呆在一个舒适的环境会让一个人堕落!而我已经在一个企业干了四年的功能测试&#xf…

docker部署若依项目

目录 目录 一、搭建局域 二、redis安装 1.创建目录 2. redis.conf修改 三、MySQL安装 1. 安装 2. 设置远程连接 3. 创建数据库 四、若依后端项目搭建 1. 切换到家目录 2. 上传jar包 3. 上传Dockerfile文件 4. 构建镜像 5. 运行容器 6. 查看运行情况 7. 测试(自己…

mysql bug( InnoDB: Error number 22),表突然不能读取

mysql bug&#xff08; InnoDB: Error number 22&#xff09;&#xff0c;表突然不能读取 bug最开始的bug&#xff1a;表突然不能读取关闭mysql容器&#xff0c;再次重启失败 解决方案不重建容器的几种可能措施重建容器重建如果懒得打命令或者忘记命令可能的run bug&#xff1a…

Hololens 2应用开发系列(2)——MRTK基础知识及配置文件配置(上)

Hololens 2应用开发系列&#xff08;2&#xff09;——MRTK基础知识及配置文件配置 一、前言二、MRTK基础知识2.1 MRTK概述2.2 MRTK运行逻辑2.3 MRTK配置文件介绍2.4 MRTK服务 三、配置文件使用3.1 总配置文件3.2 相机配置3.3 其他配置 参考文献 一、前言 在前面的文章中&…

【智能家居入门1之环境信息监测】(STM32、ONENET云平台、微信小程序、HTTP协议)

作为入门本篇只实现微信小程序接收下位机上传的数据&#xff0c;之后会持续发布如下项目&#xff1a;①可以实现微信小程序控制下位机动作&#xff0c;真正意义上的智能家居&#xff1b;②将网络通讯协议换成MQTT协议再实现上述功能&#xff0c;此时的服务器也不再是ONENET&…

一文看懂:组件化设计,B端系统不再重复造轮子。

B端组件化设计是指将企业级系统的界面设计和开发分解为独立的可复用组件&#xff0c;以实现系统的模块化和灵活性。 B端组件化设计的核心思想是将界面拆分成独立的功能组件&#xff0c;每个组件负责特定的功能或业务逻辑&#xff0c;可以独立开发、测试和维护&#xff0c;同时可…

[HackMyVM]靶场 Wild

kali:192.168.56.104 主机发现 arp-scan -l # arp-scan -l Interface: eth0, type: EN10MB, MAC: 00:0c:29:d2:e0:49, IPv4: 192.168.56.104 Starting arp-scan 1.10.0 with 256 hosts (https://github.com/royhills/arp-scan) 192.168.56.1 0a:00:27:00:00:05 …

综合练习

Oracle从入门到总裁:​​​​​​https://blog.csdn.net/weixin_67859959/article/details/135209645 现有一个商店的数据库&#xff0c;记录顾客及其购物情况。根据要求完成任务 此数据库由下面 3 个表组成。 商品 product&#xff08;商品号 productid&#xff0c;商品名 p…

交友盲盒系统PHP开源的盲盒源码

源码介绍&#xff1a; 交友盲盒系统是一款基于PHP开发的开源免费盲盒系统&#xff0c;旨在为用户提供一个充满乐趣和惊喜的社交体验。该系统具有丰富的功能和灵活的扩展性&#xff0c;可以轻松地满足各种线上交友、抽奖活动等场景的需求。 安装说明&#xff1a; PHP版本&…

海外IP代理应用:亚马逊使用什么代理IP?

代理IP作为网络活动的有力工具&#xff0c;同时也是跨境电商的必备神器。亚马逊作为跨境电商的头部平台&#xff0c;吸引了大量的跨境电商玩家入驻&#xff0c;想要做好亚马逊&#xff0c;养号、测评都需要代理IP的帮助。那么应该使用什么代理IP呢&#xff1f;如何使用&#xf…

LeetCode 刷题 [C++] 第300题.最长递增子序列

题目描述 给你一个整数数组 nums &#xff0c;找到其中最长严格递增子序列的长度。 子序列 是由数组派生而来的序列&#xff0c;删除&#xff08;或不删除&#xff09;数组中的元素而不改变其余元素的顺序。例如&#xff0c;[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。 题目…

【学习笔记】卫星基础知识

一、什么是卫星以及它如何工作&#xff1f; 通信卫星是一种人造卫星&#xff0c;通过使用转发器在源和接收器之间中继和放大无线电电信信号。卫星的工作原理是接收从地球发送的无线电信号并将无线电信号重新发送回地球。卫星使用从大型太阳能电池阵列收集的太阳能&#xff0c;…

npm ERR! code ERR_INVALID_URL报错解决

这个报错是URL错误&#xff0c;要排除两个点 npm的registry有没有搞错&#xff0c;也就是npm源有没有搞错 打开文件C:/User/<用户名>/.npmrc查看npm设置查看registry的设置有没有格式错误正确设置格式&#xff1a;registry"https://registry.npmmirror.com"或…

Spring揭秘:ApplicationContextAware应用场景及实现原理!

内容概要 ApplicationContextAware接口能够轻松感知并在Spring中获取应用上下文&#xff0c;进而访问容器中的其他Bean和资源&#xff0c;这增强了组件间的解耦&#xff0c;了代码的灵活性和可扩展性&#xff0c;是Spring框架中实现高级功能的关键接口之一。 核心概念 它能用…