Bootstrap

B树系列详解

前言

常见的搜索结构:

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

以上结构适合用于数据量相对不是很大,能够一次性存放在内存中,进行数据查找的场景。如果数据量很大,比如有100G数据,无法一次放进内存中,那就只能放在磁盘上了,如果放在磁盘上,有需要搜索某些数据,那么如果处理呢?那么我们可以考虑将存放关键字及其映射的数据的地址放到一个内存中的搜索树的节点中,那么要访问数据时,先取这个地址去磁盘访问数据。
在这里插入图片描述
磁盘是外部设备,其访问速度远远慢于访问内存,所以为了提高效率,我们应该尽可能的减少访问磁盘的次数。

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

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

  1. 提高IO的速度(SSD相比传统机械硬盘快了不少,但是还是没有得到本质性的提升)
  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. B-树的插入

为了简单起见,假设M = 3. 即三叉树,每个节点中存储两个数据,两个数据可以将区间分割成三个部分,因此节点应该有三个孩子,为了后续实现简单,节点的结构如下:
在这里插入图片描述
注意:孩子永远比数据多一个
用序列{53, 139, 75, 49, 145, 36, 101}构建B树的过程如下:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
插入过程总结:

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

3. B-树的插入实现

3.1 B-树节点设计

template<class K,size_t N> //关键字类型和N路B树
struct BTreeNode {
	BTreeNode()
	 :_parent(nullptr), _n(0)
	{
		for (int i = 0; i < N; i++)
		{
			_keys[i] = K();
			_subs[i] = nullptr;
		}
		_subs[N] = nullptr;
	}
	//额外开一个空间,方便节点满时进行分裂
	K _keys[N];
	BTreeNode* _subs[N+1];
	int _n;
	BTreeNode* _parent;
};

3.2 插入key的过程

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

//类似于插入排序的一次插入过程
void InsertKey(const K& key, Node* parent, Node* child)
{
	int j = parent->_n - 1;
	while (j >= 0 && parent->_keys[j] > key)
	{
	//不仅要挪动关键字还要挪动孩子指针
		parent->_keys[j + 1] = parent->_keys[j];
		parent->_subs[j + 2] = parent->_subs[j + 1];
		j--;
	}
	parent->_keys[j + 1] = key;
	parent->_subs[j + 2] = child;
	if (child != nullptr)
		child->_parent = parent;
	parent->_n++;
}

B-树插入实现

//在B树中查找对应关键字,找到返回节点指针和关键字下标,找不到返回叶子节点指针和-1
std::pair<Node*, int> Find(const K& key)
{
	Node* parent = nullptr;
	Node* cur = _root;
	while (true)
	{
		if (cur == nullptr) break;
		//二分查找
		int l = 0, r = cur->_n - 1;
		while (l < r)
		{
			int mid = (l + r + 1) / 2;
			if (cur->_keys[mid] > key) r = mid-1;
			else l = mid + 1;
		}
		if (cur->_keys[r] == key)
		{
			return { cur,r };
		}
		else if (cur->_keys[r] < key)
		{
			parent = cur;
			cur = cur->_subs[r + 1];
		}
		else {
			parent = cur;
			cur = cur->_subs[r];
		}
	}
	return { parent,-1 };
}
bool Insert(const K& key)
{
	//如果树为空,新增根节点
	if (_root == nullptr)
	{
		_root = new Node;
		_root->_keys[0] = key;
		_root->_n = 1;
		return true;
	}
	std::pair<Node*,int> p = Find(key);
	//如果关键字已存在,则不插入
	if (p.second != -1) return false;
	K midKey = key;
	Node* parent = p.first;
	Node* child = nullptr;
	while (true)
	{
		InsertKey(midKey, parent, child);
		if (parent->_n != N) //如果插入后容量没满,则插入完成
		{
			break;
		}
		//分裂
		Node* brother = new Node;
		int mid = parent->_n / 2;
		midKey = parent->_keys[mid];
		parent->_keys[mid] = K();

		int j = 0;
		for (size_t i = mid + 1; i < parent->_n; i++)
		{
			brother->_keys[j] = parent->_keys[i];
			brother->_subs[j] = parent->_subs[i];
			if(brother->_subs[j] != nullptr) brother->_subs[j]->_parent = brother;
			parent->_keys[i] = K();
			parent->_subs[i] = nullptr;
			j++;
		}
		brother->_n = j;
		brother->_subs[j] = parent->_subs[parent->_n];
		parent->_subs[parent->_n] = nullptr;

		if (brother->_subs[j] != nullptr) brother->_subs[j]->_parent = brother;

		parent->_n -= j + 1;
		//如果分裂后没有父节点,则要新增父节点
		if (parent->_parent == nullptr)
		{
			_root = new Node;
			_root->_keys[0] = midKey;
			_root->_subs[0] = parent;
			_root->_subs[1] = brother;
			brother->_parent = _root;
			parent->_parent = _root;
			_root->_n = 1;
			break;
		}
		else
		{
			brother->_parent = parent->_parent;
			parent = parent->_parent;
			child = brother;
		}
	}
	return true;
}

3.3 B树的简单验证

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

void _Inorder(Node* root)
{
	if (root == nullptr) return;
	for (size_t i = 0; i < root->_n; ++i)
	{
		_Inorder(root->_subs[i]);
		std::cout << root->_keys[i] << ' ';
	}
	_Inorder(root->_subs[root->_n]);
}

3.4 B树性能分析

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

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

4. B+树和B*树

4.1 B+树

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

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

在这里插入图片描述
B+树的特性:

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

5.2 B*树

B*树是B+树的变形,在B+树的非根和非叶子节点再增加指向兄弟节点的指针。
在这里插入图片描述
B+树的分裂:
当一个结点满时,分配一个新的结点,并将原结点中1/2的数据复制到新结点,最后在父结点中增加新结点的指针;B+树的分裂只影响原结点和父结点,而不会影响兄弟结点,所以它不需要指向兄弟的指针。

B*树的分裂:
当一个结点满时,如果它的下一个兄弟结点未满,那么将一部分数据移到兄弟结点中,再在原结点插入关键字,最后修改父结点中兄弟结点的关键字(因为兄弟结点的关键字范围改变了);如果兄弟也满了,则在原结点与兄弟结点之间增加新结点,并各复制1/3的数据到新结点,最后在父结点增加新结点的指针。

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

5.3 总结

通过以上介绍,大致将B树,B+树,B*树总结如下:
B树:有序数组+平衡多叉树;
B+树:有序数组链表+平衡多叉树;
B*树:一棵更丰满的,空间利用率更高的B+树。

悦读

道可道,非常道;名可名,非常名。 无名,天地之始,有名,万物之母。 故常无欲,以观其妙,常有欲,以观其徼。 此两者,同出而异名,同谓之玄,玄之又玄,众妙之门。

;