【数据结构高阶】B-树

目录

一、常见的搜索结构

二、B树

[2.1 B树的概念](#2.1 B树的概念)

[2.2 B树插入数据的分析](#2.2 B树插入数据的分析)

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

[2.4 模拟实现B树](#2.4 模拟实现B树)

[2.4.1 B树节点的定义](#2.4.1 B树节点的定义)

[2.4.2 B树数据的查找](#2.4.2 B树数据的查找)

[2.4.3 B树节点的数据插入](#2.4.3 B树节点的数据插入)

[2.4.4 B树的遍历](#2.4.4 B树的遍历)

[2.4.5 模拟实现B树实现的完整代码](#2.4.5 模拟实现B树实现的完整代码)

三、B+树

[3.1 B+树的概念](#3.1 B+树的概念)

[3.2 B+树插入数据的分析](#3.2 B+树插入数据的分析)

四、B*树

五、B树系列总结

六、B-树的应用

[6.1 索引](#6.1 索引)

[6.2 MySQL索引简介](#6.2 MySQL索引简介)


一、常见的搜索结构

在正式介绍B树之前的我们先来看一下常用到的搜索结构:

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

以上结构适合用于数据量相对不是很大,能够一次性存放在内存中,进行数据查找的场景。如果 数据量很大,比如有100G数据,无法一次放进内存中,那就只能放在磁盘上了。

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

但是这样的存储方式一旦数据量较大,内存很想要存储全部的数据是很难的;如果我们为了节省空间只存外存地址不存关键字的话,需要我们不断的去访问外存,即便是O(㏒⑵N)的访问次数对于时间的消耗也是巨大的,外存的IO是很慢的:

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

为了解决这种方法我们来引入本期的主角:B树

二、B树

2.1 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。

上面这些乍一眼看让人不知所云,下面我们结合代码和图来细细分析这些规则:

2.2 B树插入数据的分析

为了简单起见,我们先来看看m=3(三叉树)情况下的B树的插入过程:

来分析一下,当m=3时,每个节点中存储最多两个数据(data1,data2),这两个数据可以将该节点的区间分为三部分(即小于data1、大于data1小于data2、大于data2这三个部分):

但是在具体实现时,为了方便数据的插入我们将每个节点存储数据的大小设为初始大小+1:

至于为什么在原初始大小上+1,下面在数据插入的时候来体会:

下面我们插入数据:{40, 126, 62, 36, 132, 23, 188}

我们插入62这个数据后发现节点中数据达到m个,但节点中最多存储m-1个数据,所以下一步我们要进行节点的分裂数据的迁移

从这个过程可以看出,通过节点的分裂和数据的迁移,即便节点的存储数据的大小为m但实际只能存储m-1个有效数据

下面我们接着插入数据:

后面的数据插入不再赘述,我们看看到关键的节点分裂和数据迁移的过程:

最后我们综合之前的规律,插入最后一个数据

一直到现在我们都发现这棵数一直都是平衡的,这是为什么呢?B树怎么是一棵自平衡树呢?

这是因为B树是向右和向上增长的,这种分裂方式让B树始终是一棵平衡树(其他的大部分树都是纵向向下增长的)

2.3 B树的性能分析

实际的B树不会这么平凡的分裂,一般将M设为1024,那么想象一下,当M = 1024是,插入数据时,这个树的高度会如何变化?

第一层:1023个关键字;

第二层:1024个子结点 * 1023个关键字,大约是100W的级别;

第三层:1024 * 1024 * 1023,大约是10亿的级别;

第四层:1024 * 1024 * 1024 * 1023,大约是万亿级别;

但是上面的情况是理想化的满数据和节点的情况,那我们来算一下在最坏情况下:

第一层:1个关键字;

第二层:2个子结点 * 512个关键字,大约是1K的级别;

第三层:2 * 512 * 512,大约是10W的级别;

第四层:2 * 512 * 512 * 512,大约是2.5亿级别;

可以看到这个数据规模也高的惊人

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

那么它的时间复杂度在㏒(M-1)N ~ ㏒(M/2)N之间,也就是说M越大,效率越高,但是M也不是越大越好,因为会有空间的浪费,有因为结点满了要拷走一半,浪费一个结点一半的空间;

最后我们来算一下对于N = 62*1000000000个节点,如果度M为1024,则㏒(M/2)N <= 4,即在620亿个元素中,如果这棵树的度为1024,则需要小于4次即可定位到该节点,然后利用二分查找可以快速定位到该元素,大大减少了读取磁盘的次数。

2.4 模拟实现B树

下面我们来手搓一棵B树,但这只是最基本的B树,想要它作为数据库的引擎还是需要很多优化和改进的:

2.4.1 B树节点的定义

cpp 复制代码
template<class K, size_t M>//K我们要存储的关键字,M控制B树的叉数
struct BTreeNode
{
	//将节点所能存储的数据数和孩子数+1,方便我们后续的插入
	K _keys[M];//最多有M-1个数据
	BTreeNode<K, M>* _subs[M+1];//最多有M个孩子
	BTreeNode<K, M>* _pather;//记录节点的父亲节点,方便后续插入操作
	size_t _n;//记录节点中有效数据个数

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

template<class K,size_t M>
class BTree
{
	typedef BTreeNode<K, M> Node;

private:
	Node* _root = nullptr;
};

2.4.2 B树数据的查找

cpp 复制代码
template<class K,size_t M>
class BTree
{
	typedef BTreeNode<K, M> Node;

public:
	pair<Node*, int> Find(const K& key)//查找key值所对应的节点
	{
		Node* parent = nullptr, * cur = _root;
		size_t i = 0;
		while (cur)
		{
			while (i < cur->_n)//在当前节点查找适合的位置
			{
				if (key < cur->_keys[i])
				{
					break;
				}
				else if (key > cur->_keys[i])
				{
					++i;
				}
				else
				{
					return make_pair(cur, i);
				}
			}
			parent = cur;
			cur = cur->_subs[i];//当前节点没找到,向其孩子节点再找
			i = 0;
		}
		return make_pair(parent, -1);//B树中没有该值,返回parent节点方便后续数据的插入
	}

private:
	Node* _root = nullptr;
};

2.4.3 B树节点的数据插入

cpp 复制代码
template<class K,size_t M>
class BTree
{
	typedef BTreeNode<K, M> Node;

	bool InsertKey(Node* node, Node* child, const K& key)//在node节点中插入新值及其孩子节点
	{
		int end = node->_n - 1;
		while (end >= 0)//直接插入排序
		{
			if (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;
		node->_n++;

		if (child)//插入的孩子节点不为空要连接上其父亲节点
		{
			child->_parent = node;
		}

		return true;
	}

public:

	pair<Node*, int> Find(const K& key)//查找key值所对应的节点
	{
        .....
	}

	bool Insert(const K& key)
	{
		if (_root == nullptr)//第一次插入
		{
			_root = new Node;
			_root->_keys[0] = key;
			_root->_n++;
			return true;
		}
		pair<Node*, int>ret = Find(key);//查找要插入的值是否在B树中存在
		if (ret.second >= 0)
		{
			return false;//存在就直接返回
		}

		//不存在在该节点中进行插入
		Node* parent = ret.first;
		K newKey = key;
		Node* child = nullptr;
		while (1)
		{
			InsertKey(parent, child, newKey);//将数据插入到节点中
			//判断该节点是否需要进行分裂
			if (parent->_n < M)
			{
				return true;
			}
			else//进行节点的分裂
			{
				Node* brother = new Node;//创建兄弟节点
				//将一半的数据转移到兄弟节点
				size_t mid = M / 2;
				size_t j = 0;
				for (size_t i = mid + 1; i < M; ++i)
				{
					//转移节点数据
					brother->_keys[j] = parent->_keys[i];
					parent->_keys[i] = K();//转移掉的数据恢复初始值
					//转移左节点孩子
					brother->_subs[j++]= parent->_subs[i];
					if (parent->_subs[i])//转移走的孩子节点不为空要转换其父亲节点
					{
						parent->_subs[i]->_parent = brother;
					}
					parent->_subs[i] = nullptr;//转移掉的孩子置空
				}
				//转移最后的右节点孩子
				brother->_subs[j] = parent->_subs[M];
				if (parent->_subs[M])//转移走的孩子节点不为空要转换其父亲节点
				{
					parent->_subs[M]->_parent = brother;
				}
				parent->_subs[M] = nullptr;//转移掉的孩子置空

				brother->_n = j;

				//将该节点的中间值拿出来作为newKey,继续插入到该节点的父亲节点中
				parent->_n -= (brother->_n + 1);
				newKey = parent->_keys[mid];
				parent->_keys[mid] = K();//转移掉的数据恢复初始值

				if (parent->_parent == nullptr)//分裂的节点是根节点
				{
					_root = new Node;
					_root->_keys[0] = newKey;
					_root->_subs[0] = parent;
					_root->_subs[1] = brother;
					_root->_n = 1;
					parent->_parent = _root;
					brother->_parent = _root;
					break;//分裂完毕后直接返回
				}
				else
				{
					//向上跳一层接着进行插入
					parent = parent->_parent;
					child = brother;
				}
			}
		}
		return true;
	}

private:
	Node* _root = nullptr;
};

2.4.4 B树的遍历

由于B树的有序性,我们选择中序对其遍历(与二叉搜索树大同小异),在我们遍历其节点时要先走完其数据的所有的左子树,最后再走右子树:

cpp 复制代码
template<class K,size_t M>
class BTree
{
	typedef BTreeNode<K, M> Node;

	void _InOrder(Node* cur)
	{
		if (cur == nullptr)
		{
			return;
		}
		size_t i = 0;
		for (; i < cur->_n; ++i)
		{
			_InOrder(cur->_subs[i]);//遍历左子树
			cout << cur->_keys[i] << " ";//遍历根
		}
		_InOrder(cur->_subs[i]);//遍历右子树
	}

public:

	void InOrder()
	{
		_InOrder(_root);
		cout << endl;
	}

private:
	Node* _root = nullptr;
};

2.4.5 模拟实现B树实现的完整代码

cpp 复制代码
#include<utility>
using namespace std;

template<class K, size_t M>//K我们要存储的关键字,M控制B树的叉数
struct BTreeNode
{
	//将节点所能存储的数据数和孩子数+1,方便我们后续的插入
	K _keys[M];//最多有M-1个数据
	BTreeNode<K, M>* _subs[M+1];//最多有M个孩子
	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;
		_n = 0;
		_parent = nullptr;
	}
};

template<class K,size_t M>
class BTree
{
	typedef BTreeNode<K, M> Node;

	bool InsertKey(Node* node, Node* child, const K& key)//在node节点中插入新值及其孩子节点
	{
		int end = node->_n - 1;
		while (end >= 0)//直接插入排序
		{
			if (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;
		node->_n++;

		if (child)//插入的孩子节点不为空要连接上其父亲节点
		{
			child->_parent = node;
		}

		return true;
	}

	void _InOrder(Node* cur)
	{
		if (cur == nullptr)
		{
			return;
		}
		size_t i = 0;
		for (; i < cur->_n; ++i)
		{
			_InOrder(cur->_subs[i]);//遍历左子树
			cout << cur->_keys[i] << " ";//遍历根
		}
		_InOrder(cur->_subs[i]);//遍历右子树
	}

public:
	pair<Node*, int> Find(const K& key)//查找key值所对应的节点
	{
		Node* parent = nullptr, * cur = _root;
		size_t i = 0;
		while (cur)
		{
			while (i < cur->_n)//在当前节点查找适合的位置
			{
				if (key < cur->_keys[i])
				{
					break;
				}
				else if (key > cur->_keys[i])
				{
					++i;
				}
				else
				{
					return make_pair(cur, i);
				}
			}
			parent = cur;
			cur = cur->_subs[i];//当前节点没找到,向其孩子节点再找
			i = 0;
		}
		return make_pair(parent, -1);//B树中没有该值,返回parent节点方便后续数据的插入
	}

	bool Insert(const K& key)
	{
		if (_root == nullptr)//第一次插入
		{
			_root = new Node;
			_root->_keys[0] = key;
			_root->_n++;
			return true;
		}
		pair<Node*, int>ret = Find(key);//查找要插入的值是否在B树中存在
		if (ret.second >= 0)
		{
			return false;//存在就直接返回
		}

		//不存在在该节点中进行插入
		Node* parent = ret.first;
		K newKey = key;
		Node* child = nullptr;
		while (1)
		{
			InsertKey(parent, child, newKey);//将数据插入到节点中
			//判断该节点是否需要进行分裂
			if (parent->_n < M)
			{
				return true;
			}
			else//进行节点的分裂
			{
				Node* brother = new Node;//创建兄弟节点
				//将一半的数据转移到兄弟节点
				size_t mid = M / 2;
				size_t j = 0;
				for (size_t i = mid + 1; i < M; ++i)
				{
					//转移节点数据
					brother->_keys[j] = parent->_keys[i];
					parent->_keys[i] = K();//转移掉的数据恢复初始值
					//转移左节点孩子
					brother->_subs[j++]= parent->_subs[i];
					if (parent->_subs[i])//转移走的孩子节点不为空要转换其父亲节点
					{
						parent->_subs[i]->_parent = brother;
					}
					parent->_subs[i] = nullptr;//转移掉的孩子置空
				}
				//转移最后的右节点孩子
				brother->_subs[j] = parent->_subs[M];
				if (parent->_subs[M])//转移走的孩子节点不为空要转换其父亲节点
				{
					parent->_subs[M]->_parent = brother;
				}
				parent->_subs[M] = nullptr;//转移掉的孩子置空

				brother->_n = j;

				//将该节点的中间值拿出来作为newKey,继续插入到该节点的父亲节点中
				parent->_n -= (brother->_n + 1);
				newKey = parent->_keys[mid];
				parent->_keys[mid] = K();//转移掉的数据恢复初始值

				if (parent->_parent == nullptr)//分裂的节点是根节点
				{
					_root = new Node;
					_root->_keys[0] = newKey;
					_root->_subs[0] = parent;
					_root->_subs[1] = brother;
					_root->_n = 1;
					parent->_parent = _root;
					brother->_parent = _root;
					break;//分裂完毕后直接返回
				}
				else
				{
					//向上跳一层接着进行插入
					parent = parent->_parent;
					child = brother;
				}
			}
		}
		return true;
	}



	void InOrder()
	{
		_InOrder(_root);
		cout << endl;
	}

private:
	Node* _root = nullptr;
};

void TestBTree()
{
	int a[] = { 40, 126, 62, 36, 132, 23, 188 };
	BTree<int, 3> tree;
	for (auto& x : a)
	{
		tree.Insert(x);
	}
	tree.InOrder();
}

三、B+树

3.1 B+树的概念

B+树是B树的变形,是在B树基础上优化的多路平衡搜索树,B+树的规则跟B树基本类似,但是又在B树的基础上做了以下几点改进优化:

  1. 分支节点的子树指针与关键字个数相同

  2. 分支节点的子树指针p[i]指向关键字值大小在[k[i],k[i+1])区间之间

  3. 所有叶子节点增加一个链接指针链接在一起

  4. 所有关键字及其映射数据都在叶子节点出现

我们来分析一下B+树和B树的区别:

由第一点规则我们可以发现B+树是将B树每个节点的最左边的子树去除了,使得数据个数与孩子树相等;

第二点规则可以看到B+树的分支节点都是叶子节点的值的最小值,以此来让父节点存储最小值来做索引方便查找;

第三点规则极大程度上方便了B+树的遍历,我们只需要遍历叶子节点就可以得到全部的数据,为范围查找提供了基础

第四点规则就可以让k-v结构的B+树的分支节点只存储key值,最后在叶子节点中也可以找到对应的value值,减少了空间上的花费

3.2 B+树插入数据的分析

为了简单起见,我们先来看看m=3(三叉树)情况下的B+树的插入过程:

来分析一下,当m=3时,每个节点中存储最多两个数据(data1,data2,data3),这三个数据可以将该节点的区间分为三部分(即大于等于data1小于data2、大于等于data2小于data3、大于等于data3这三个部分):

和B树一样,在具体实现时,为了方便数据的插入我们将每个节点存储数据的大小设为初始大小+1:

下面我们插入数据:{40, 126, 62, 36, 132, 23, 188}

我们可以发现B+树一开始就是有两层的结构的,第一层作为索引,第二层才真正的存储数据,每当新插入的数据值小于索引值时,索引是要进行更新的(例如36的插入)

插入36这个数据后发现节点中数据达到m个,但节点中最多存储m-1个数据,所以下一步我们要进行节点的分裂数据的迁移

下面我们接着插入数据:

最后我们再插入两个数据,来看看没有父亲节点的节点的分裂:

这时这课B+树的根节点满了,我们来看看其分裂:

四、B*树

B*树是B+树的变形,在B+树的非根和非叶子节点再增加指向兄弟节点的指针:

但是B*树的节点数据满了并不进行分裂,如果它的下一个兄弟结点未满,那么将一部分数据移到兄弟结点中,再在原结点插入关键字,最后修改父结点中兄弟结点的关键字(因为兄弟结点的关键字范围改变了);但如果兄弟节点也满了,则在原结点与兄弟结点之间增加新结点,并各复制1/3的数据到新结点(这样子原节点,兄弟节点和新节点的空间都使用了2/3),最后在父结点增加新结点的指针。

所以,B*树分配新结点的概率比B+树要低,空间使用率更高

但是由于B树系列常常在外存中使用,对于存储空间丰富的磁盘来说,B*树对空间利用率的提升效果并不明显,所以在很多使用场景中常常使用结构更为简单的B+树

五、B树系列总结

通过上述的介绍分析,大致将B树,B+树,B*树总结如下:

B树:有序数组+平衡多叉树
B+树:有序数组链表+平衡多叉树
B*树:一棵更丰满的,空间利用率更高的B+树

接下来我们来谈谈,B树系列在内存和外存中使用与哈希和二叉平衡搜索树的对比:

在内存中:

单论树高度,搜索效率而言,B树确实不错

但是B树系列有一些隐形的坏处:

1、空间利用率低,消耗高。

2、插入删除数据,分裂和合并节点时,必然会挪动数据,效率低。

3、虽然高度更低,但是在内存中而言,跟哈希和平衡搜索树还是一个量级的(因为内存的空间并不大,在N数量级较小时log以2为底的和以M(M≈1024)为底的结果差距并不大;而且这些微小的差距在极快的内存处理效率面前体现极小)

结论:实质上B树系列在内存中体现不出优势。

在外存中:

在外存容量是内存好几个数量级的场景里,B树系列多消耗的空间几乎微乎其微

接下来我们来算一下1亿以2为底的对数约为30,以1024为底的对数为3;假设外存完成一次搜索需要1s,那这两者之间就相差了29s,这在时间上的体现是极大的

结论:B树系列在外存中的优势极大。

六、B-树的应用

6.1 索引

B-树最常见的应用就是用来做索引。索引通俗的说就是为了方便用户快速找到所寻之物,比如: 书籍目录可以让读者快速找到相关信息,hao123网页导航网站,为了让用户能够快速的找到有价值的分类网站,本质上就是互联网页面中的索引结构。

MySQL官方对索引的定义为:索引(index)是帮助MySQL高效获取数据的数据结构,简单来说:索引就是数据结构。

当数据量很大时,为了能够方便管理数据,提高数据查询的效率,一般都会选择将数据保存到数据库,因此数据库不仅仅是帮助用户管理数据,而且数据库系统还维护着满足特定查找算法的数据结构,这些数据结构以某种方式引用数据,这样就可以在这些数据结构上实现高级查找算法, 该树据结构就是索引。

6.2 MySQL索引简介

对于此有兴趣的同学们可以看到这里:MySQL索引详解:概念、类型与优化


本期博客到这里就结束啦,让大家久等了,后期会保持连续更新~

相关推荐
小庞在加油16 分钟前
【C++开源库】Boost.Asio网络库使用介绍
网络·c++·开源·boost网络库
陌然。。31 分钟前
【39. 组合总和 中等】
数据结构·c++·笔记·算法·leetcode
爱是小小的癌1 小时前
Java-数据结构-链表(LinkedList)-双向链表
java·数据结构·链表
z樾1 小时前
回溯专题 记录
数据结构·算法·leetcode
Say-hai2 小时前
QT中的字符串类型
网络·c++·qt
不如学也2 小时前
c++ 17 constexpr
开发语言·c++
baiyu333 小时前
C++ Primer Notes(4): 变量初始化和作用域
c++
IOT-Power3 小时前
<C++学习> C++ Boost 字符串操作教程
c++
誓约酱4 小时前
Linux下文件操作相关接口
linux·运维·服务器·c语言·c++·后端