数据结构--B树

B树的预备知识

我们平常查找比如用搜索二叉树哈希表这些数据结构,一般都是数据在内存中的,这样的话访问数据的速度就很快,这种查找也叫做内查找。二分查找或者直接顺序查找也适合内查找。

但是当数据量非常大时,比如有100G的数据,此时内存就放不下了,只能放在磁盘上了,在内存外的存储结构上查找也就是外查找。

对于外查找以我们之前所学习的知识,该用什么思路呢?

数据在磁盘中,并且是连在一起的,它们也有地址。我们可以用一个平衡搜索树存每个数据的地址,这样每次查找我们需要通过一次文件IO将地址转化成数据进行比较,然后再向下进行。这样的话查找起来主要耗时就在磁盘IO上,如果是这种结构,那么就需要高度次IO,也就是O(logN)级别,比如AVL/红黑树。或者我们也可以用哈希表,它是O(1)级别的,但是哈希表在极端场景下哈希冲突非常严重,也会导致效率低下。

而且磁盘慢主要是它的每一次查找定位很慢,一旦定位到了就很快了,这跟之后B树设计有关系。

B树就是在搜索树的基础上进行优化的:

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。
    第一次接触B树的话看这些概念是非常抽象的,首先我们可以将B树的结点具体化出来

如图,这是m = 3。那么它就最多有m/2个关键字,3个孩子。

不过需要注意的是,一般我们会多开一个空间,以此来方便后续关键字满了后要分裂的场景,比如

m依旧为3,但是结点如下

在实际设计中,M的值一般会设计的很大,比如1024

B树的插入

首先我们知道,一个结点中,关键字的顺序是从小到大排序的,那么当结点中的关键字没有满时,我们就需要找到这个关键字适合插入的位置。

如果结点满了,那么这个结点就需要分裂出一个兄弟结点,并将自己一半的关键字和孩子分给这个兄弟结点。然后再选一个中位数大小的关键字给父亲结点(也要注意顺序),然后将兄弟结点与父亲结点进行连接,如果父亲结点不存在,那么就先创建一个父亲结点,然后记得连接子结点。

后续插入就是以此类推,从父结点开始找,根据大小一直找到对应的叶子结点,然后再插入,如果满了就分裂。B树的新结点的插入一定是在叶子结点上的。叶子没有孩子,不影响关键字和孩子的关系,叶子结点满了,就分裂出一个兄弟,再提取中位数,向父亲结点插入这个关键字,和一个孩子(也就是兄弟结点)。

由此我们可以发现,B树是天然平衡的,因为它的生长是向右和向上生长的。

B树的简单实现

cpp 复制代码
#pragma once
#include <utility>
#include <iostream>

using namespace std;

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 (size_t i = 0; i < M; ++i)
		{
			_keys[i] = K();
			_subs[i] = nullptr;
		}

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

// 这里我们简化代码,只实现K模型的而不是K-V模型的
// 外查找时数据存在磁盘里,那么肯定得是K-V模型。
template<class K,size_t M>
class BTree
{
	typedef BTreeNode<K, M> Node;
public:
	pair<Node*, int> Find(const K& key)
	{
		Node* parent = nullptr;
		Node* cur = _root;

		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];
		}

		return make_pair(parent, -1);  // 到叶子结点了,这个-1是用来判断是否走到叶子结点
	}

	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];  // 注意key和它的右孩子的对应关系
				--end;
			}
			else
			{
				break;
			}
		}

		// 插入
		node->_keys[end + 1] = key;
		node->_subs[end + 2] = child;
		if (child)
		{
			child->_parent = node;
		}

		node->_n++;
	}


	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插入 newkey和child
		Node* parent = ret.first;
		K newKey = key;
		Node* child = nullptr;
		while (1)
		{
			InsertKey(parent, newKey, child);
			// 没有满,插入就结束
			if (parent->_n < M)  // 这里就对应了为什么在Node里要多开一个空间。
			{
				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;  // 这里为什么要+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->_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); // 为什么要+1因为还有一个要给父亲

				K midKey = parent->_keys[mid];  // 准备给父亲结点插入key了
				parent->_keys[mid] = K();

				// 说明刚刚分裂是根结点
				if (parent->_parent == nullptr)
				{
					_root = new Node;
					_root->_keys[0] = midKey;
					_root->_subs[0] = parent;
					_root->_subs[1] = brother; // 写到这里再回想一下,B树的规则:根结点至少有两个孩子
					_root->_n = 1;

					parent->_parent = _root;
					brother->_parent = _root;
					break;
				}
				else
				{
					// 如果不是根结点,那么就往父亲结点插入,以此循环,直到新建了一个根结点
					// 或者在下一次插入的父亲结点中,在插入后未满
					newKey = midKey;
					child = brother;
					parent = parent->_parent;
				}
			}
		}

		return true;
	}

	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);
	}
private:
	Node* _root = nullptr;
};

void TestBtree()
{
	int a[] = { 53, 139, 75, 49, 145, 36, 101 };
	BTree<int, 3> t;
	for (auto e : a)
	{
		t.Insert(e);
	}
	t.InOrder();
}

这中间还有一个中序遍历,其实也非常简单,二叉树的中序遍历是 左 根 右。而这里B树的遍历就是 左 根 左 根 .... 右,就是最后再补一个右即可。

B+树

B+树的概念

B+树是B树的变形,是在B树基础上优化的多路平衡搜索树,B+树的规则跟B树基本类似,但是又
在B树的基础上做了以下几点改进优化:
分支节点的子树指针与关键字个数相同。
分支节点的子树指针p[i]指向关键字值大小在[k[i],k[i+1])区间之间。
所有叶子节点增加一个链接指针链接在一起。
所有关键字及其映射数据都在叶子节点出现。

由图我们可以看到,叶子结点是链接在一起的,这样话我们也可以直接遍历叶子结点来找到我们想要的数据。

分支节点里面都只存Key,只有叶子节点里面才存Key和Val。

分支结点根叶子结点有重复的值,分支结点存的是叶子结点的索引。

父亲结点存的是孩子结点中最小的值作为索引。

所以现在可以做一个小小的总结:

1.B+树简化了B树孩子比关键字多一个的规则,B+树的关键字和孩子数量相等。

2.父亲中存的孩子结点中最小的值作为索引。

B+树的插入

B+树的插入跟B树相似,但又略有不同,这里就只说思想了。

但是我们知道,B+树的分支结点只存Key,叶子结点既存Key也存Val。因此,在第一次插入的时候,B+树就有两层!一层做根,一层做叶子。我们依旧以M=3为例

第一次插入53

一直插入直到49开始第一次分裂

这里的处理也比B树简单一些,它是直接将一半的索引给分裂出来的兄弟结点,然后直接将兄弟的最小值给父亲结点作为索引。

接下来一直插入,直到插入101进行第二次分裂

第二次分裂与第一次逻辑一样。

接下来再补充插入一些结点(150和155),来看看第三次分裂

此时我们发现分裂后,父亲结点满了,那么父亲结点也需要进行分裂

这样B+树就由最初的两层变成了三层。

这就是B+树的插入思想。

总结B+树的特性:

  1. 所有关键字都出现在叶子节点的链表中,且链表中的节点都是有序的。
  2. 不可能在分支节点中命中。因为分支节点中存的是Key。
  3. 分支节点相当于是叶子节点的索引,叶子节点才是存储数据的数据层。
    实际上,B+树的应用比B树要广泛很多。

B*树

B*树又是在B+树的基础上作出了新的规则。与B+树不同的是,B*树在非根和非叶子结点中也增加了指向兄弟结点的指针。

我们来总结一下B+树的分裂:
当一个结点满时,分配一个新的结点,并将原结点中1/2的数据复制到新结点,最后在父结点中增
加新结点的指针;B+树的分裂只影响原结点和父结点,而不会影响兄弟结点,所以它不需要指向
兄弟的指针。

再来说说B*树的分裂:
当一个结点满时,如果它的下一个兄弟结点未满,那么将一部分数据移到兄弟结点中,再在原结
点插入关键字,最后修改父结点中兄弟结点的关键字(因为兄弟结点的关键字范围改变了);如
果兄弟也满了,则在原结点与兄弟结点之间增加新结点,并各复制1/3的数据到新结点,最后在父
结点增加新结点的指针。
所以,B*树分配新结点的概率比B+树要低,空间使用率更高;
注意:B*树结点关键字数量的范围是[2/3M,M],因此分裂的时候

自己和兄弟各自凑出1/3M,给新的兄弟结点。

总结三种树

B 树:有序数组 + 平衡多叉树;
B+ 树:有序数组链表 + 平衡多叉树;
B* 树:一棵更丰满的,空间利用率更高的 B+ 树。
实际应用中还是B+树使用的更为广泛。

B-树的应用

B-树在内查找和外查找中的表现

内查找

内查找也就是在内存中查找。

B树系列和哈希表和平衡搜索树(AVL树和RB树)的对比:

如果只看树的高度,和搜索效率的话,B树系列确实不错。

但是B树系列也有缺点:

1.空间利用率低,消耗大。我们可以看到很多结点都不一定会存满。

2.插入数据和删除数据时,会分裂和合并结点,那么必然就会挪动数据,带来隐形的消耗。

3.虽然B树系列高度低,但是就在内存中而言,跟哈希和平衡搜索树还是一个量级的。

总结:B树系列在内存中体现不出优势。

外查找

但是,在外查找,也就是磁盘中就不一样了。

在内存中查找3次和30次,差距并不大。

但是在磁盘中查找3次和30次差距就很大了。

因为我们知道IO操作是很消耗时间的。

因此B树系列在外查找中就能体现出优势。

索引

B-树最常见的应用就是用来做索引。索引通俗的说就是为了方便用户快速找到所寻之物,比如:
书籍目录可以让读者快速找到相关信息,hao123网页导航网站,为了让用户能够快速的找到有价
值的分类网站,本质上就是互联网页面中的索引结构。
MySQL官方对索引的定义为:索引(index)是帮助MySQL高效获取数据的数据结构,简单来说:
索引就是数据结构
当数据量很大时,为了能够方便管理数据,提高数据查询的效率,一般都会选择将数据保存到数
据库,因此数据库不仅仅是帮助用户管理数据,而且数据库系统还维护着满足特定查找算法的数
据结构,这些数据结构以某种方式引用数据,这样就可以在这些数据结构上实现高级查找算法,
该数据结构就是索引。

其中B-树的一般就是做MyISAM或InnoDB的搜索引擎的。

一般是B+树用来做磁盘数据索引的。

比如我们要创建一个学生的信息表,有学生ID,年龄等信息。

在创建的时候,我们还需要选择一个信息作为主键,也就是将来搜索时的关键字。有些数据库不准用名字作为主键,因为名字可能不唯一,比如Mysql,但是有些数据库可以。

比如这里我们拿学生ID作为主键。那么ID就是Key,Val就是这个学生所有的信息的磁盘地址(这个信息一般就是一行)。

另外分支结点也是需要存在磁盘中的,因为数据量大了的话,内存中也存不下。但是分支结点理应被缓存到cache中。

另外当我们在查询数据的时候,我们可以根据ID来找,也可以根据姓名来找。但是ID是主键,用ID来查找的效率要比按姓名来查找的效率要高很多。用主键来查找的效率是 O(logN),而用非主键的信息来查找的效率就是 O(N),因为它是通过遍历叶子结点来进行查找的。

但是如果我们又经常使用姓名去查找的话,我们可以用姓名建立一个索引。

当姓名也就是name索引创建以后,那么就会以B+树以name作为key再创建一颗树。这样的话效率就高了。

mysql的存储引擎

MyISAM引擎是MySQL5.5.8版本之前默认的存储引擎,不支持事物,支持全文检索,使用B+Tree
作为索引结构,叶节点的data域存放的是数据记录的地址。
InnoDB存储引擎支持事务 ,其设计目标主要面向在线事务处理的应用,从MySQL数据库5.5.8版
本开始,InnoDB存储引擎是默认的存储引擎。InnoDB支持B+树索引、全文索引、哈希索引。但
InnoDB使用B+Tree作为索引结构时,具体实现方式却与MyISAM截然不同。
第一个区别是InnoDB的数据文件本身就是索引文件MyISAM索引文件和数据文件是分离的,
索引文件仅保存数据记录的地址。而InnoDB索引,表数据文件本身就是按B+Tree组织的一个索
引结构,这棵树的叶节点data域保存了完整的数据记录。这个索引的key是数据表的主键,因此
InnoDB表数据文件本身就是主索引。

相关推荐
青い月の魔女2 分钟前
数据结构初阶---二叉树
c语言·数据结构·笔记·学习·算法
我要出家当道士28 分钟前
Nginx单向链表 ngx_list_t
数据结构·nginx·链表·c
林的快手1 小时前
209.长度最小的子数组
java·数据结构·数据库·python·算法·leetcode
从以前1 小时前
准备考试:解决大学入学考试问题
数据结构·python·算法
.Vcoistnt1 小时前
Codeforces Round 994 (Div. 2)(A-D)
数据结构·c++·算法·贪心算法·动态规划
ALISHENGYA2 小时前
全国青少年信息学奥林匹克竞赛(信奥赛)备考实战之分支结构(实战训练三)
数据结构·c++·算法·图论
2401_858286116 小时前
117.【C语言】数据结构之排序(选择排序)
c语言·开发语言·数据结构·笔记·算法·排序算法
td爆米花6 小时前
C#冒泡排序
数据结构·算法·排序算法
执着的小火车7 小时前
02-18.python入门基础一基础算法
数据结构·python·算法·排序算法
梦茹^_^7 小时前
排序算法(系列)
数据结构·python·算法·排序算法·希尔排序·基数排序·计数排序和桶排序