高阶数据结构之B树

在数据结构中,有很多高效的数据结构,比如红黑树、AVL树、hash表这种,它们的搜索速度都很快,但是都有一个问题,面对外存储时,它们的效率依旧不够高。

**外存储:**即从磁盘上进行存取操作,对应的就是在内存上进行的内存储。

为什么不够高呢?首先是 红黑树 和 AVL树,它们能够做到 Log(N) 级别的搜索,但是对于操作系统来说, Log(N) 次的磁盘IO 依旧是很慢的。

而hash呢?虽然有 O(1) 级别的搜索效率,但是极端场景下,它的哈希冲突一旦很多,也需要多次磁盘IO。

那么如何提高数据访问速度呢?

  1. 提高IO速度,即使用更高速的磁盘
  2. 减少检索的次数,即降低树的高度---B树

B树概念

一颗 M 阶的B树是平衡的 M 路平衡搜索树,可以是空树。

它有以下性质:

  • 根节点至少有两个孩子
  • 每个分支节点都有 k-1 个 key 值和 k 个孩子 (ceil(m/2) <= k <=m)
  • 每个叶子节点都有 k-1 个关键字(ceil(m/2) <= k <=m)
  • 叶子节点都在同一层
  • 每个节点关键字从小到大排序,节点第 k-1 个元素正好是第 k 个孩子包含的元素的值的域划分
  • 每个节点结构为 n,A0,K1,A1,K2....AN,KN。其中 n 是节点关键字个数, K 是关键字,A是子节点的指针,n 满足 (ceil(m/2) <= n <=m)

也许这个性质看着一头雾水,不过和 红黑树不同,B树的性质是服务于结构的,红黑树是结构服务于性质的,因此接下来我们讲解一下B树的插入,此时大家就会有更深的印象了。

B树的插入

我们先设一个 B树 为 3 阶,用 {53, 139, 75, 49, 145, 36, 101} 构建一个 B 树。

插入 53 和 139 后,发现 75 应该在中间,因此会将 139 往后挪,然后找到75应该在的位置后,再插入 75.

此时节点已经满了,因此需要进行分裂操作。

分裂操作:将节点分为三部分,左边、中位数、右边。

右边的关键字都给兄弟节点,自己这个节点保留左边,生成一个父亲节点,保留中位数

分裂后编程这样子。

通过分裂后的图发现一件事,这个 B树 严格符合性质5:

每个节点关键字从小到大排序,节点第 k-1 个元素正好是第 k 个孩子包含的元素的值的域划分

可以看到,childs[0] 下标指的节点,其最大值一定不超过 75,childs[1] 下标指向的节点,其最小值一定不低于 75.

继续插入。

发现插入到 36 时又需要分裂。

分裂后变成这样。

从图中发现,childs[0] 的孩子节点的值一定都小于49,而 childs[1] 的孩子节点的值则一定都在 49---75 之间。

继续插入 101 后,就会发现会一直分裂,然后根节点也需要分裂。

而无论是哪里,都是严格符合B树的性质。

这就是一个B树的插入过程。

那么如何实现呢?

B树插入代码实现

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

#pragma once
template<class K,size_t M>
struct BTreeNode
{
	
	K _keys[M];
	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;
		n = 0;
		_parent = nullptr;
	}
};


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

	pair<Node*, int> Find(const K& key)
	{
		Node* parent = nullptr;
		Node* cur = _root;

		//遍历cur这个节点的所有key值,看是不是放在这个节点内部
		while (cur)
		{
			size_t i = 0;
			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];
		}
		//如果 key 值节点不存在,那么必定这里返回的是一个叶子节点
		return make_pair(parent, -1);
	
	}

	void _InsertKey(Node* node,const K& key,Node* child)
	{
		size_t end = node->n - 1;
		while (end)
		{
			//将大的数向后挪动,并且挪动孩子
			if (key < node->_keys[end])
			{
				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;

		//有可能没有子节点,因为可能没有满,只用将key插入到node中即可
		//但是如果有分裂情况,child 可能就不为空了
		if (child)
		{
			child->_parent = node;
		}

		node->n++;
	}

	bool Insert(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;
		}

		//此时就是不存在
		//不存在就找到了需要插入的叶子节点
		Node* parent = ret.first;
		K Newkey = key;
		Node* child = nullptr;
		while (1)
		{
			_InsertKey(parent, key, child);

			//插入之后,看被插入的叶子节点满没满,满了就分裂
			if (parent->n < M)
			{
				return true;
			}
			else {
				//满了进行分裂操作
				size_t mid = M / 2;
				Node* bro = new Node;

				size_t j = 0;
				size_t i = mid + 1;
				for (; i < M; i++)
				{
					//parent 中间往后的key和子节点都给兄弟
					bro->_keys[j] = parent->_keys[i];
					bro->_subs[j] = parent->_subs[i];
					if (parent->_subs[i])
					{
						parent->_subs[i]->_parent = bro;
					}

					j++;

					parent->_keys[i] = K();
					parent->_subs[i] = nullptr;
				}
				//由于孩子比key多一个,因此这里需要额外设置一下
				bro->_subs[j] = parent->_subs[j];
				if (parent->_subs[i])
				{
					parent->_subs[i]->_parent = bro;
				}
				parent->_subs[i] = nullptr;

				bro->n = j;
				parent->n -= (bro->n + 1);

				//然后将中间key值给父亲节点
				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] = bro;
					_root->n = 1;

					parent->_parent = _root;
					bro->_parent = _root;

					break;
				}
				//否则就需要往上继续遍历
				else {
					Newkey = midKey;
					parent = parent->_parent;
					child = bro;
				}
			}
		}
		return true;
	}

private:
	Node* _root = nullptr;
};

首先是查找的实现,其实 B 树也是二叉搜索树的基础上演变而来的,因此查找也是类似的方式,不过由于B树的性质**,每次插入时都是需要找到一个叶子节点才能插入**,因此这里如果没找到key值的节点位置,就直接返回当前这个叶子节点,这个叶子节点也是 key 值可以插入的位置。

为什么每次插入都需要找叶子节点呢?

这是由于B树的性质5导致的:节点第 k-1 个元素正好是第 k 个孩子包含的元素的值的域划分

如果插入的时候不找叶子节点,而是直接插入,就会破坏性质。

而实际上的插入函数是这个,需要找到插入的叶子节点,并且插入数据,按照平衡树的性质,来从后往前将数据往后挪,直到 key 值大于 _keys[end],此时 end + 1 就是 key 值存在的位置。

而child是有可能为空的,假设 B 树只有一个根节点,而且根节点没满,那么这个 child 一定是空,那么什么时候不为空呢?那就是节点需要分裂,child 是该节点分裂出来的兄弟节点

而如果插入的时候, root 没有数,就需要自行创建一个 Node,并且存储key值后返回。

而如果root有,并且 key 值存在,就不用插入,直接返回false。

而如果key值不存在,就需要将数据插入 。

由于之前已经找到了key值存在的叶子节点,因此可以直接往这个叶子节点中插入key值。

插入后节点的值未满,就可以直接返回,满了就需要进行分裂操作。

分裂则需要将 key 值和 该节点的所有子节点分为两部分:左边和右边

而key 值的中间值,则需要跟父亲节点,右边的都给兄弟节点保存,如果子节点存在,还需要修改子节点的父亲指针指向。

有一个细节是由于 子节点 比 key 值多一个,因此最后出循环后,j = M 的情况下,需要将最后一个子节点也设置给兄弟节点。

然后初始化兄弟节点的key值个数。

而如果分裂操作进行完后,就需要将中间值给父亲节点。

如果已经是根节点,就新生成一个节点。

不过如果分裂的节点有父亲节点,则需要向上进行遍历,将兄弟节点、中间值给它,不过这个父亲节点有概率会满,因此需要向上遍历来进行下一步分裂操作。

B树的性能分析

B树的平均搜索效率应该在 Log(M-1)N ~ log(M/2)N 之间,因为每个节点的key值数量都在 M/2到 M-1 之间。

这是一个非常快的效率。

假设一颗B树每个节点有 1023 个数据。

那么第一层有1023,第二次有 1024*1023个数据(节点比数据多一个),第三层就有 1024*1024*1023,第四层就有 1024*1024*1024*1023,仅仅几层就能到一个天文数字,用在磁盘搜索正好,可以很快就定位到数据所在的位置,然后就可以利用二分查找,以常数级别的次数找到数据。

B+树和B*树

B+树

B+树在B树的基础上修改了几项规则,主要和B树没什么区别,唯一区别是B+树只有叶子节点存储数据,其他节点存的只是一个范围。

  • 分支节点的叶子结点和关键字个数相同
  • 分支节点的子树指针 p[i] 所指向的节点内的关键字大小在分支节点 k[i] k[i+1] 之间
  • 叶子节点之间通过指针链接在一起
  • 关键字及其映射数据只有叶子节点有

我们可以之间看看 B+ 树的结构。

(图片来自网络)

从结构上可以看到,B+树所有节点的子节点个数和关键字个数相同。

其次,叶子节点存储的值都在父节点的关键字之间。

换句话说:分支节点存储的都是目录, 类似于告诉你这个分支节点的这个子节点的范围是多少,如果你查询的数据在这个范围内,就去这个子节点找,反之,就去后面。

B+树的分裂

B+树的分裂很简单,如果叶子节点满了,就分一半给兄弟节点,然后将兄弟节点的最小值给父亲节点,让父亲节点新增一个范围。

如果父亲节点满了,则让父亲节点也分一半给兄弟节点。

B*树

B*树是在B+树的基础上增加了一个分支节点之间的指针

B*树的分裂

B*树的分裂则看兄弟节点满没满,自己满了就将数据放入没满的兄弟节点,同时修改父亲节点的关键字(范围)。

如果兄弟满了,则新增兄弟节点,并且自己和兄弟节点各分1/3给这个新增节点,然后让父亲节点新增指针。

B树的应用

数据库是一种常用的存储数据的手段,因此需要经常和磁盘存储打交道,因此B树在数据库中应用很广泛。

数据库在建表的时候,可以设置某个数据为索引,如主键索引,唯一键索引。

当这个表有索引时,那么数据库就会在底层以索引为关键字,来创建一个 B+树。

这样当出现海量数据时,数据库可以快速的通过索引查询数据,并且获取到数据。

MyIsam

数据库中有很多存储引擎,MyIsam就是其中之一,它采用 B+树来作为索引结构。

不过它的叶子节点并不直接存储数据,而是存储索引对应数据的地址 , 也就是所谓的非聚簇索引。

这种索引方式会导致主键索引和辅助索引没有区别 ,因为非聚簇索引无论是按主键索引还是辅助索引,都是找数据的地址,因此没有区别

从下图可以看到,二者都是之间通过索引找到地址,因此主键索引和辅助索引没有区别。

唯一的区别是主键唯一,辅助索引不唯一。

InnoDB

InnoDB 也是一个引擎,其内部也是通过 B+树作为查询结构的。

它是MySQL 的默认存储引擎,不过它和 MyIsam 不同的是,它是聚簇索引,即索引对应的节点存储的是数据,而非地址。

这就导致了它通过主键索引和辅助索引查询效率不同,主键索引查询效率高于辅助索引查询

因为辅助索引查询的数据其实是这个辅助索引对应的主键索引(为了减少内存),然后找到这个主键后再去通过主键索引查询数据,因此通过辅助索引有两次查询,就导致数据下降。

而且InnoDB是聚簇索引,必须通过主键建立一个B+树,因此InnoDB引擎必须有一个主键,如果没有,它自己会有一个默认主键,该主键按数据插入时间顺序排序。

聚簇索引导致主键索引查询效率高,但是其辅助索引的查询效率低。

总结

本文讲述了 B树的概念,以及插入和实现,也了解了B+树和B*树的概念,并且学习了B树的应用场景,MyIsam 和 InnoDB 两个存储引擎的区别也有所涉及,希望对大家有所帮助。

相关推荐
大草原的小灰灰13 分钟前
C++ STL之容器介绍(vector、list、set、map)
数据结构·c++·算法
小刘|1 小时前
数据结构的插入与删除
java·数据结构·算法
w(゚Д゚)w吓洗宝宝了1 小时前
List详解 - 双向链表的操作
数据结构·c++·链表·list
打不了嗝 ᥬ᭄3 小时前
P3884 [JLOI2009] 二叉树问题
数据结构·算法·蓝桥杯
Lulsj5 小时前
代码随想录day28 | leetcode 56.合并区间 738.单调自增的数字 968.监控二叉树
数据结构·算法·leetcode
get_money_6 小时前
动态规划汇总1
开发语言·数据结构·笔记·算法·leetcode·动态规划·代理模式
Huazzi.6 小时前
【算法学习】——整数划分问题详解(动态规划)
开发语言·数据结构·c++·学习·算法·动态规划
泡泡鱼(敲代码中)8 小时前
数据结构二叉树-C语言
c语言·开发语言·数据结构·笔记·学习
比特在路上8 小时前
初阶数据结构【双链表及其接口的实现】
c语言·数据结构·链表
用手码出世界9 小时前
二分查找算法——搜索插入位置
数据结构·算法·leetcode