B树——C++

目录

[1. 常见的搜索结构](#1. 常见的搜索结构)

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

使用哈希表的缺陷:

[2. B树概念](#2. B树概念)

[3. B-树的插入分析](#3. B-树的插入分析)

[4. B-树的插入实现](#4. B-树的插入实现)

[4.1 B-树的节点设计](#4.1 B-树的节点设计)

[4.2 插入key的过程](#4.2 插入key的过程)

[4.3 B-树的插入实现](#4.3 B-树的插入实现)

[4.4 B-树的简单验证](#4.4 B-树的简单验证)

[4.5 B-树的性能分析](#4.5 B-树的性能分析)

[4.6 B-树的删除](#4.6 B-树的删除)


1. 常见的搜索结构

以上结构适合用于数据量相对不是很大,能够一次性存放在内存中,进行数据查找的场景。如果数据量很大,比如有100G数据,无法一次放进内存中,那就只能放在磁盘上了,如果放在磁盘上,有需要搜索某些数据,那么如何处理呢?那么我们可以考虑将存放关键字及其映射的数据的地址放到一个内存中的搜索树的节点中,那么要访问数据时,先取这个地址去磁盘访问数据。

我简单总结一下,由于磁盘的物理结构和查询逻辑,我们要从磁盘上锁定区域查到数据都是要比内存慢的,如果我们没有一个数据结构来很好的管理这些区域,那么我们的查找效率将会非常慢。

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

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

使用哈希表的缺陷:

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

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

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

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

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。Ai(0≤i≤n)为指向子树根结点的指针。且Ai所指子树所有结点中的关键字均小于Ki+1。 n为结点中关键字的个数,满足ceil(m/2)-1≤n≤m-1。

3. B-树的插入分析

为了简单起见,假设M = 3. 即三叉树,每个节点中存储两个数据,两个数据可以将区间分割成三个部分,因此节点应该有三个孩子,为了后续实现简单期间,节点的结构如下:

注意:孩子永远比数据多一个

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

插入过程总结:

  1. 如果树为空,直接插入新节点中,该节点为树的根节点

  2. 树非空,找待插入元素在树中的插入位置(注意:找到的插入节点位置一定在叶子节点中) 3. 检测是否找到插入位置(假设树中的key唯一,即该元素已经存在时则不插入)

  3. 按照插入排序的思想将该元素插入到找到的节点中

  4. 检测该节点是否满足B-树的性质:即该节点中的元素个数是否等于M,如果小于则满足

  5. 如果插入后节点不满足B树的性质,需要对该节点进行分裂:

申请新节点

找到该节点的中间位置

将该节点中间位置右侧的元素以及其孩子搬移到新节点中

将中间位置元素以及新节点往该节点的双亲节点中插入,

即继续4

  1. 如果向上已经分裂到根节点的位置,插入结束

4. B-树的插入实现

4.1 B-树的节点设计

cpp 复制代码
//数据是存在磁盘,K是磁盘地址
template<class K,size_t M>
struct BTreeNode
{
	//K _keys[M - 1];
	//BTreeNode<K, M>* _subs[M];

	//为了方便插入以后再分裂,可以多给一个空间
	K _keys[M];
	BTreeNode<K, M>* _subs[M + 1];
	BTreeNode<K, M>* _parent;
	size_t _n;//记录实际存储了多少个关键字

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

		_subs[M] = nullptr;
		_parent = nullptr;
		_n = 0;
	}
};

节点中我们需要的成员变量有存关键字的数组,还有存孩子节点的数组以及父亲节点,我们还要一个_n来记录关键字数量。

接下来我们对它们进行初始化就好了。

4.2 插入key的过程

按照插入排序的思想插入key,注意:在插入key的同时,可能还要插入新分裂出来的节点。

cpp 复制代码
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;
		node->_subs[end + 2] = child;
		if (child)
		{
			child->_parent = node;
		}
		node->_n++;

	}

参数的意思分别是node表示当前的节点,key是关键字,child是孩子节点。Node是对B树节点的重命名。

插入关键字key的操作逻辑比较复杂,我一一道来,首先我们插入key大致会遇到以下两种情况,有关键字或则没有关键字(注意这里没有满了分离的操作,这是因为我们这个函数的设计很单纯的就是插入key,其它的操作都会在后续的insert函数里面体现,说得直白一点,InsertKey函数可以算作insert的子函数),所以我们需要end来记录一下当前节点的最后一个关键字下标,插入操作实际上是对数组的操作,因此end就是我们要管理的一个下标,倘若当前B树没有关键字我们就直接在关键字数组里面将key给插入进去,并且把孩子数组的元素也要作调整,按照我们上面分析的逻辑,我们可以发现调整孩子数组实际上我们只需要调整关键字索引对应的下一个位置的孩子把它们全部往后调整就好了。在当前的情况下,我们只要操作一次就好了,因为这个时候没有关键字,所以我们不需要调整只需要插入孩子就可以。

但我们如果有关键字呢?那我们就需要就需要循环操作来将它们一个一个进行调整,我们采用从后往前比大小的方式,它能是我们对数组调整的操作更加方便。我们从最后往前找,并调整key数组和孩子数组,当我们找到位置之后,我们再进行插入key和孩子的操作。

倘若我们的child是非空的,我们就可以将child的父亲节点指向给指到node节点。

如果大家还有点摸不清,我们的下一个函数可以帮助大家更好理解。

4.3 B-树的插入实现

cpp 复制代码
	bool Insert(const K& key)
	{
		if (_root == nullptr)
		{
			_root = new Node;
			_root->_keys[0] = key;
			_root->_n++;

			return true;
		}

		//key已经存在,不准插入
		pair<Node*, int> ret = Find(key);
		if (ret.second >= 0)
		{
			return false;
		}
		//如果没有找到,find顺便带回了要插入的那个叶子节点
		//循环每次往cur插入key和child
		Node* parent = ret.first;
		K newkey = key;
		Node* child = nullptr;
		while (1)
		{
			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++)
				{
					//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
				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];
				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;
				}
				else
				{
					//转换成往parent->parent 去插入parent->[mid]和brother
					newkey = midKey;
					child = brother;
					parent = parent->_parent;
				}
			}
		}
		return true;
	}

在我们这简易的B树里,形参只要我们的key就够了。我们第一步要做的就是判断我们的B树是不是空的,如果是那我们就创建节点并将key插入到数组里面,_n++就可以了。倘若不是那么我们就要进行下面的操作。

我们先判断一下key是否存在,如果存在我们就返回false,Find函数是我们自己编写的一个查找函数,它的逻辑就是按照搜索树那样来找,所以如果没有在原树当中找到,那么我们就会直接返回应该插入的那个节点,然后我们接下来就要进行循环,将我们的key和child插入进去,至于为什么要设计成循环,这是因为我们的B树节点可能会满,如果满了我们就要进行分离操作,所以我们才要设计成循环。

进入循环我们先将key和child插入,如果插入完后关键字小于M路,那么我们就插入成功直接返回true,倘若满了,我们就要进行分离操作,我们得先找到中间的那个关键字,然后创建一个兄弟节点,将一半的关键字和孩子拷贝到我们的兄弟节点上面,如果孩子非空,我们要将它们指向新的父亲节点,然后再清理一下拷贝完的数据就好了。在for循环结束后我们还有一个孩子没有拷贝到,我们再来处理一下孩子就好了,然后我们将这两个节点的关键字数量进行一下更新就好了,搞定完兄弟节点,我们就要搞根节点了,我们mid位置的关键字是插入到根节点上的,如果没有根节点,那么我们就创建,有我们就将三个变量进行变换交给下一次循环。

4.4 B-树的简单验证

对B树进行中序遍历,如果能得到一个有序的序列,说明插入正确。

cpp 复制代码
	void _InOrder(Node* root)
	{
		if (root == nullptr)
		{
			return;
		}
		//递归走它的左 根 左 根...右
		size_t i = 0;
		for (; i < root->_n; i++)
		{
			_InOrder(root->_subs[i]);//左子树
			cout << root->_keys[i] << " ";//根
		}

		_InOrder(root->_subs[i]);//最后的那个右子树
	}

	void InOrder()
	{
		_InOrder(_root);
	}

只不过跟传统的中序遍历不太一样,根据我们的结构来,我们就只需要左,根,左,根的去遍历就好了,最后再递归地遍历最后一个孩子。

4.5 B-树的性能分析

对于一棵节点为N度为M的B-树,查找和插入需要log{M-1}N~log{M/2}N次比较,这个很好证明:对于度为M的B-树,每一个节点的子节点个数为M/2 ~(M-1)之间,因此树的高度应该在要 log{M-1}Nlog{M/2}N之间,在定位到该节点后,再采用二分查找的方式可以很快的定位到该元素。

B-树的效率是很高的,对于N = 62*1000000000个节点,如果度M为1024,则log_{M/2}N ,即在620亿个元素中,如果这棵树的度为1024,则需要小于4次即可定位到该节点,然后利用二分查找可以快速定位到该元素,大大减少了读取磁盘的次数

4.6 B-树的删除

学习B树的插入足够帮助我们理解B树的特性了,如果对删除有兴趣的同学们参考《算法导论》-- 伪代码和《数据结构-殷人昆》--C++实现代码。

相关推荐
羊小猪~~1 小时前
C/C++语言基础--C++STL库算法记录(质变算法、非质变算法、查找、排序、排列组合、关系算法、集合算法、堆算法等)
c语言·开发语言·数据结构·c++·算法·stl
bachelores2 小时前
数据结构-栈、队列和数组
数据结构·算法
好记性+烂笔头3 小时前
hot100_73. 矩阵置零
数据结构·算法·矩阵
WeeJot嵌入式3 小时前
【数据结构】顺序表
数据结构
Non importa5 小时前
【初阶数据结构】线性表之单链表
数据结构·考研·算法·链表·github·学习方法
Static_Xiao5 小时前
C语言导航 7.1函数的概念与定义
c语言·数据结构·算法
萌の鱼6 小时前
leetcode 53. 最大子数组和
数据结构·c++·算法·leetcode
圆圆滚滚小企鹅。6 小时前
刷题记录 回溯算法-5:17.电话号码的字母组合
数据结构·python·算法·leetcode
JLiuli6 小时前
排序算法——堆排序
数据结构·算法·排序算法
奔跑de自由6 小时前
C 语言内存秘籍:memcpy 等四函数的实用指南与底层解析
c语言·数据结构·算法