详解 B树

Posted 小倪同学 -_-

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了详解 B树相关的知识,希望对你有一定的参考价值。

文章目录

常见的搜索结构


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


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

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

使用哈希表的缺陷

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

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

  1. 提高IO的速度(SSD相比传统机械硬盘快了不少,但是还是没有得到本质性的提升)
  2. 降低树的高度—多叉树平衡树

B树概念

1970年,R.Bayer和E.mccreight提出了一种适合外查找的树,它是一种平衡的多叉树,称为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叉树:即一个节点最多有M个孩子,M-1个数据域
// 为实现简单期间,数据域与孩子与多增加一个
template<class K, size_t M>
struct BTreeNode

	K _keys[M];// 存放元素
	BTreeNode<K, M>* _subs[M + 1]; 	// 存放孩子节点,注意:孩子比数据多一个
	BTreeNode<K, M>* _parent; // 在分裂节点后可能需要继续向上插入,为实现简单增加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;
	
;

B树查找结点

// 参数:key为待查找的元素
// 返回值:Node代表找到的节点,int为该元素在该节点中的位置
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);

B树的插入分析

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


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

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





代码实现

void InsertKey(Node* node, const K& key, Node* child)

	// 按照插入排序思想插入key
	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;
		
	

	// 插入key以及新分裂出的节点
	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)
		
			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->_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;

插入过程总结

  1. 如果树为空,直接插入新节点中,该节点为树的根节点
  2. 树非空,找待插入元素在树中的插入位置(注意:找到的插入节点位置一定在叶子节点中)
  3. 检测是否找到插入位置(假设树中的key唯一,即该元素已经存在时则不插入)
  4. 按照插入排序的思想将该元素插入到找到的节点中
  5. 检测该节点是否满足B树的性质:即该节点中的元素个数是否等于M,如果小于则满足
  6. 如果插入后节点不满足B树的性质,需要对该节点进行分裂:

(1)申请新节点
(2)找到该节点的中间位置
(3)将该节点中间位置右侧的元素以及其孩子搬移到新节点中
(4)将中间位置元素以及新节点往该节点的双亲节点中插入,即继续4

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

B树的简单验证

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

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]); // 最后的那个右子树


void InOrder()

	_InOrder(_root);

B树整体实现

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

// 数据是存在磁盘,K是磁盘地址
template<class K, size_t M>
class BTree

	typedef BTreeNode<K, M> Node;
public:
	// 参数:key为待查找的元素
	// 返回值:Node代表找到的节点,int为该元素在该节点中的位置
	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);
	

	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++;
	

	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)
			
				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->_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;
	

	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]); // 最后的那个右子树
	

	void InOrder()
	
		_InOrder(_root);
	

private:
	Node* _root = nullptr;
;

B树的性能分析

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

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

B+树

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

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

B+树的特性

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

B+树的分裂

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

B*树

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

B*树的分裂

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

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

总结:

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

B树的应用

索引

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

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

当数据量很大时,为了能够方便管理数据,提高数据查询的效率,一般都会选择将数据保存到数据库,因此数据库不仅仅是帮助用户

以上是关于详解 B树的主要内容,如果未能解决你的问题,请参考以下文章

二叉树

tree 树

数据结构之各种树

二叉树平衡二叉树红黑树B-树B+树日等之间的详解和比较

博客作业04--树

树和森林