B树概念和插入实现

Posted 两片空白

tags:

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

目录

前言

一.B树概念

        1.1 概念和性质

        1.2 分裂

 二.插入的实现

三.性能分析

四.B树的删除

五.B树的优化B+树和B*树

        5.1 B+树

        5.2 B*树

六.B树的应用

        6.1 MyISAM中的索引

        6.2 Innodb引擎


前言

        之前我们学了有很多数据结构,比如顺序表,链表,栈,队列和二叉树。也有很多高效的数据结构。比如:二叉搜索树,平衡二叉树,红黑树和哈希表等。但是,这些数据结构只适用于数据量不大的情况。

        如果数据量很大,一次性无法加载到内存中。数据需要保存在磁盘中。

        在磁盘中如果用以上的数据结构组织,效率同样会很低。比如:在磁盘中,用红黑树组织数据。会有以下的缺陷:

  • 树的高度比较高,查找最差的情况需要比较高度次。
  • 数据量比较大时,不能将所有结点读取到内存中,需要进行多次IO。

如何提高对数据的访问呢?

  • 降低树的高度。
  • 减少IO次数。

        于是,在1970年,R.Bayer和E.mccreight提出了一种适合外查找的树,它是一种平衡的多叉树,称为B(有些地方写 的是B-树,注意不要误读成"B减树")。

        说明:大量数据时,是在磁盘中组织成B树的结构。再将B树结点(保存数据)读取到内存中。并不是将数据读取到内存中,再组织成B树的结构。

一.B树概念

        1.1 概念和性质

        B树是一颗多叉平衡树,一颗M(M>2)阶的B树。可以是空树或者满足以下性质。

  1. 根节点至少有两个孩子结点。
  2. 每一个非根结点至少有M/2(向上取整)个孩子结点,至多有M个孩子结点。
  3. 非根结点,至少M/2-1(向上取整)个关键字,至多有M-1个关键字,并且以升序排列。
  4. key[i] 和key[i+1]之间的孩子结点值介于key[i]和key[i+1]值之间。(保证是搜索树)。
  5. 所有叶子结点在同一层。

总结以下:

  • 关键字的数量比孩子结点数少1。
  • 根节点关键字数量范围[1,M-1],孩子结点数量[2,M]。
  • 非根节点关键字数量范围[M/2-1,M-1],孩子数量[M/2,M]。
  • 每一个节点关键字在节点中以升序排列。
  • 所有叶子节点都在同一层。

B树的高度低是:因为每一个节点的会保存多个数据,并且是多叉树。

        1.2 分裂

        根据B树的性质,当插入数据,节点的数据个数到达M个时(超过上限),需要进行分裂。

        分裂:分裂出一个兄弟节点,分出一半的关键字给兄弟节点。中位数插入父节点。如果父节点不存在,创建一个父节点,再插入。

         细节:兄弟节点不仅要拷贝关键字,还需要将对应孩子节点拷贝。

下面画图来演示一下:

        为了简单起见,假设M=3,即三叉树。由上面B树的性质,可以知道,每一个节点中最多保存2个关键字,有3个孩子节点。

         但是,我们设计成可以存储3个关键字,4个孩子节点。

        原因是:如果关键字个数等于M-1时,来了一个值,我们需要先插入,排好序,才方便确定分出的一半关键字。

        注意:孩子节点比关键字多一个。

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

 二.插入的实现

过程:

  • B树中没有节点,创建节点后,直接插入。
  • 插入的节点,必须插入到叶子节点中。所以首先需要找到叶子节点。
  • 插入节点存在,不插入。
  • 插入节点不存在,按照插入排序 法,插入节点。
  • 插入节点后的情况:
    1. 插入节点没有破坏B树性质(数据个数超过M-1),不需要操作。
    2. 插入节点破坏B树的性质,进行分裂。创建兄弟节点,拷贝一半数据和对应孩子节点,如果没有根节点,创建根节点,中间数据和对应孩子节点直接插入。
    3. 插入节点破坏B树的性质,进行分裂。创建兄弟节点,拷贝一半数据和对应孩子节点,如果有父节点,中间数据和对应孩子节点插入到对应位置。如果父节点B树性质被破坏,需要将父节点重复分裂操作操作。直到父节点没有破坏B树性质。

为什么必须往叶子节点插入?

        如果不往叶子节点插入,插入一个数据,还需要一个孩子节点,并且不为空。

        往叶子节点插入,插入一个数据,孩子节点就是空。

分裂节点步骤:

  • 找到当前节点的中间位置。
  • 将中间位置右边数据拷贝到兄弟节点
  • 将中间位置数据,插入到父节点中。
    • 父节点不存在,创建父节点插入
    • 父节点存在,按照插入排序插入,判断父节点否B树性质是否被破坏。破坏重复父节点分裂操作。
  • 注意:孩子节点需要拷贝到对应位置。

节点设计:

        数据个数最多M-1,孩子节点最多M个。设计多一个位置,当数据达到M-1时,需要插入节点时,方便先插入,再选择需要搬走的数据。

        注意:孩子节点,数据左孩子节点的下标等于数据的下标,右孩子节点的下标等于数据下标加1。

template<class K, class V, size_t M>
struct BtreeNode

	BtreeNode()
		:parent(nullptr)
		,ksize(0)
	
		for (int i = 0; i < M + 1; i++)
			child[i] = nullptr;
		
	
	//设计多一个位置,方便最后插入
	pair<K, V> k[M];
	//父子节点
	BtreeNode *child[M + 1];
	BtreeNode *parent;

	size_t ksize;
;

插入:

#pragma once
#include<iostream>
using namespace std;

template<class K, class V, size_t M>
struct BtreeNode

	BtreeNode()
		:parent(nullptr)
		,ksize(0)
	
		for (int i = 0; i < M + 1; i++)
			child[i] = nullptr;
		
	
	//设计多一个位置,方便最后插入
	pair<K, V> kv[M];
	//父子节点
	BtreeNode<K, V, M> *child[M + 1];
	BtreeNode<K, V, M> *parent;

	size_t ksize;
;

template<class K, class V, size_t M>
class Btree
	typedef BtreeNode<K, V, M> Node;
private:
	Node *root = nullptr;


	void InserKV(Node *cur, pair<K, V> kv, Node *sub)
		int i = cur->ksize - 1;
		while (i >= 0)
			if (cur->kv[i].first <= kv.first)
				break;
			
			cur->kv[i + 1] = cur->kv[i];
			cur->child[i + 2] = cur->child[i + 1];
			i--;
		

		cur->kv[i + 1] = kv;
		cur->child[i + 2] = sub;
		cur->ksize++;
		//注意更新父亲
		if (sub)
			sub->parent = cur;
		
	

	void _Inorder(Node *root)
		if (root == nullptr)
			return;
		
		size_t i = 0;
		for (; i < root->ksize; i++)
			//先访问左
			_Inorder(root->child[i]);
			cout << root->kv[i].first<<" ";
		
		//再访问最后一个右
		_Inorder(root->child[i]);

		
	

public:
	//左孩子等于数据下标等于i
	//右孩子是数据下标是i+1
	pair<Node*, int> find(const K& key)
		Node *cur = root;
		Node *parent = nullptr;
		while (cur)
			parent = cur;
			size_t i = 0;
			while (i<cur->ksize)
				if (cur->kv[i].first < key)
					i++;
				
				else if (cur->kv[i].first > key)
					break;
				
				else
					//找到
					return make_pair(cur, i);
				
			
			//
			cur = cur->child[i];
		
		//没找到,返回上一个节点
		return make_pair(parent, -1);
	

	bool Insert(const pair<K, V>& kv)
		if (root == nullptr)
			root = new Node;

			root->kv[0] = kv;
			root->ksize = 1;
			return true;
		

		pair<Node *, int> ret = find(kv.first);

		if (ret.second >= 0)
			cout << "已存在" << endl;
			return false;
		

		//插入,不存在
		Node *cur = ret.first;//插入的节点
		pair<K, V> newkv = kv;//插入的KV
		Node *sub = nullptr;//插入的孩子节点
		//往cur插入sub和newkv
		while (1)
			InserKV(cur, newkv, sub);

			if (cur->ksize < M)
				return true;
			
			//需要分裂
			//兄弟节点
			Node *bro = new Node;
			//拷贝一半的数据
			size_t mid = M / 2;
			size_t j = 0;
			size_t i = mid + 1;
			
			for (; i < cur->ksize; i++)
				bro->ksize++;
				bro->kv[j] = cur->kv[i];
				//还需要将子节点拷贝过去
				bro->child[j] = cur->child[i];
				cur->child[i] = nullptr;
				cur->kv[i] = pair<K, V>();
				//注意更新父亲节点
				if (bro->child[j])
					bro->child[j]->parent = bro;
				
				j++;
			
			//还剩最后一个孩子
			bro->child[j] = cur->child[i];
			cur->child[i] = nullptr;

			if (bro->child[j])
				bro->child[j]->parent = bro;
			

			cur->ksize = mid;

			//1.没有父亲,cur就是根,产生新根
			//2.有父亲,插入数据和孩子,继续判断是否需要分裂
			if (cur->parent == nullptr)
				//没有父节点
				//创建新根
				root = new Node;
				root->kv[0] = cur->kv[mid];
				root->ksize = 1;
				cur->kv[mid] = pair<K, V>();
				//更新父节点和子节点
				root->child[0] = cur;
				root->child[1] = bro;

				cur->parent = root;
				bro->parent = root;

				return true;
			

			//有父节点,插入bro和kv[mid]利用循环 
			newkv = cur->kv[mid];
			cur->kv[mid] = pair<K, V>();
			cur = cur->parent;
			
			sub = bro;
		



	
	//中序遍历
	void Inoeder()
	
		_Inorder(root);
	

;

三.性能分析

        对于一颗节点为N度为M的B树,查找和插入的时间在以M-1为底LogN到以M/2为底的LogN之间。因为一个节点的数据在[M/2,M-1]之间。

        定位到节点后,由于数据保存是有序的。可以利用二分查找查找,查找该数据。这样效率是很高的。

        并且对于数据量很多的情况,树的高度也会很低。因为一个节点的保存的数据M是可以控制的,并且它是多叉树。

比如:如果M设置为1024。

B树第一层:保存的数据最多1023

B树第二层:保存的数据最多1024*1023

B树第三层:保存的数据最多1024*1024*1023

B树底四层:保存的数据最多1024*1024*1024*1023

......

当N=62*1000000000个数据,最多只需要4次就可以定位到元素。

如何实现IO次数少的?

        在磁盘中按照B树的结构保存数据。在查找数据时,假设:每次读一个节点上来。根据数据的大小,找到孩子节点。再将孩子节点读上来。IO的最大次数是B树的高度次。由于B树高度低。所以IO次数少。

B树没有经过平衡调节,是如何达到平衡的?

        B树只有在节点满的时候,才会新增一层。但是是从下往上增长的。并且分裂多了一个节点是从左往右增长的。B树天然就会是平衡的。

        B树的的所有叶子节点在同一层,近似于完全二叉树的结构。

四.B树的删除

简单思路:

        如果是叶子节点,可以直接删除。

        不是叶子节点,到左孩子中拿最大值或者在右孩子中拿最小值上来。如果被借值的节点不是叶子节点,需要继续往下借,直到借到叶子节点。

        因为:如果删除一个数据,就需要删除一个系欸但,只有叶子节点的孩子节点为空,方便删除。

        如果叶子节点删完后值得数量不够1了,找同层的兄弟节点借。

        如果兄弟节点也借完之后也会值的数量也不够1,就将两个节点合并。

五.B树的优化B+树和B*树

        5.1 B+树

B+树是B树的变形,也是一颗多路搜索树。

  1. 定义和B树基本相同。
  2. 非叶子节点的子树指针与关键字数量相同。
  3. 非叶子节点的子树关键字值sub[i]在当前关键字[k[i],k[i+1])之间。(保证搜索树的性质)。
  4. 所有叶子节点增加一个链指针,将所有叶子节点连接起来。
  5. 所有数据都保存在叶子节点中。

总结:

  • 根节点关键字和孩子数量为[1,M]个。
  • 非根节点关键字和孩子的数量为[M/2,M]个。
  • 每个节点中关键字的数量等于孩子数量。
  • 节点中的数据,按照升序排列,并且,孩子节点的值sub[i]在当前节点k[i]和k[i+1]之间。
  • 所有数据保存在叶子节点中,非叶子节点值保存了关键字。父亲节点只保存了当前节点中关键字最小的。
  • 所有叶子节点都连接起来,还有一个指针指向第一个叶子节点。

如下图:

B+树的特性:

  1. 所有数据都保存在叶子节点的链表中(稠密索引),且链表的结点是有序的。
  2. 数据不可能在非叶子结点命中。
  3. 非叶子结点相当于叶子结点的索引。如果是KV模型,非叶子结点中只保存K,并且是孩子结点中最小的。

用在文件系统中,非叶子结点相当于数据索引,叶子节点相当于存储数据的数据层。

 B+树和B树的区别:

        B+树只能在叶子节点命中数据(数据保存在叶子节点中),B树可以在非叶子节点命中数据。

        B+树节点关键字数量和孩子数量相同。B树孩子数量比关键字数量少一个。

B+树的优点:

        方便遍历,所有值保存在叶子节点中,并且用链表连接起来了。

B+树的插入:

  • 如果B+树为空,创建父亲节点和叶子节点,数据保存在叶子节点中,根节点,保存关键字。
  • 如果B+树不为空,找到叶子节点插入。
  • 如果插入节点是最小值,更新父节点中保存的数据。
  • 如果叶子节点插入满了,需要进行分裂。

分裂和B树分裂差不多。

  • 创建兄弟节点,拷贝一半数据给兄弟节点。
  • 将兄弟节点的最小关键字插入到父亲节点中。
  • 判断父亲节点是否满了。
  • 满了父亲节点分裂,重复上面动作。

        5.2 B*树

        B*树是在B+树的基础上,做了优化。有人觉得B+树在分裂时,是分配一半的数据给兄弟节点,如果一种不往节点插入数据,就会导致节点中空间浪费了1/2。

        B*树,增加了空间利用率。从1/2提高到了2/3。

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

B*树的分裂:

        不会直接生成新节点。如果兄弟节点中有空间,会将部分数据插入到兄弟节点中,再将数据插入到原节点。

        如果兄弟节点也满了,再创建新节点,将原节点1/3的数据插入新节点中,将兄弟节点1/3的数据插入新节点中。

        再按照B+树的规制,将新节点的最小关键字,插入到父亲节点中,并且更新孩子节点指针。

        所以B*树空间利用率会高于B+树。

六.B树的应用

        B树最常见的应用主要是用来做索引。索引是一种基于B树数据结构,主要应用在数据库中。

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

下面讨论主流的数据库mysql

        MySQL中索引属于存储引擎级别的概念,不同存储引擎对索引的实现方式是不同的。MySQL最常用的两种存储引擎是MyISAM和Innodb。

        注意:索引是基于表的,而不是基于数据库的。
        

        6.1 MyISAM中的索引

        MyISAM引擎是MySQL5.5.8版本以前的存储引擎。不支持事务,支持全文检索,使用B+树作为索引结构。用叶节点保存数据的地址或者相对路径。

        B+树非叶子节点保存的数据是主键值,主键值在MySQL中是唯一的。叶子节点中保存了树的路径。

        通过主键值来查找数据保存的路径,效率很高,O(以M为底的LogN~以M/2为底的LogN)。

        但是如果查找数据不是通过主键值,就只能通过遍历叶子节点来查找数据(B+树将叶子节点被组织成了链表结构),效率很低O(N)。

         上面是以Col1为主键构成的索引结构。

  • 假如:一个值不是主键,因为可以重复,但是我们会经常使用这个值来查找数据,效率很低怎么办?

        我们可以以这个值来建立辅助索引数据结构,同样,辅助索引也是一颗B+树。只是辅助索引节点保存的关键字可以重复。

         MyISAM引擎节点中保存的是数据的地址或者路径,在通过路径来找到数据。这种索引方式叫做"非聚集索引"。

  •  主索引是以主键创建的索引数据结构,节点保存的关键字不能重复。
  • 辅助索引不是以主键创建的索引数据结构,节点保存的关键字可以重复。

        6.2 Innodb引擎

        Innodb是从MySQL5.58版本开始的,支持事务,支持B+树索引,全文索引,哈希索引。可以算是MyISAM的优化版本。

        但是InnoDB引擎使用B+树作为索引数据结构,实现方式和MyISAM截然不同。

区别一:

        Innodb数据文件本身就是索引文件,意思就是,索引结构的叶节点中直接保存着数据,这种索引叫做"聚集索引"。而MyISAM索引文件和数据文件分离,索引结构中只是保存数据的路径,再通过路径找数据。

区别二:

        Innodb要求表中必须有主键,因为Innodb索引文件(数据文件),本身要按主键来构建索引结构。如果用户没有显示设置主键,MySQL会自动选择一个可以唯一标识数据记录的列作为主键,比如:自增字段。如果不存在这种列,MySQL会为Innodb表自动生成一个隐含字段作为主键。这个字段占6字节,类型为长整型。

        MyISAM可以没有主键。

        以主键构建的索引为主索引。

 区别三:

        Innodb引擎辅助索引的叶节点中保存的不是数据了,保存的是相应记录主键的值。

        innode引擎索引方式将数据保存到主索引的叶子节点中,索引文件和数据文件是一个。这种方式称为"聚集索引"。

  • 主索引:效率很高,找到叶子节点就找到了数据。
  • 辅助缩影:需要检索两遍索引。首先检索辅助索引,获得主键,通过主键,再检索主缩影获得数据。

       

以上是关于B树概念和插入实现的主要内容,如果未能解决你的问题,请参考以下文章

详解 B树

详解 B树

详解 B树

C++-二叉搜索树的查找&插入&删除-二叉搜索树代码实现-二叉搜索树性能分析及解决方案

B树的相关概念及其插入删除操作(C语言)

AVL树/红黑树介绍及插入操作实现