【数据结构】深度解析B树、B+树、B*树数据结构特性与MySQL中InnoDB、MyISAM存储引擎差异

🍑个人主页:Jupiter.
🚀 所属专栏:高阶数据结构
欢迎大家点赞收藏评论😊

在这里插入图片描述

在这里插入图片描述


常见的搜索结构

种类数据格式时间复杂度
顺序查询无要求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数据,无法一次放进内存中,那就只能放在磁盘上了,如果放在磁盘上,有需要搜索某些数据,那么如果处理呢?

  • 那么我们可以考虑将存放关键字及其映射的数据的地址放到一个内存中的搜索树的节点中,那么要访问数据时,先取这个地址去磁盘访问数据。
  • 假设对于大量数据查找时,使用的是AVL树:
  • 假设现在查找13这个节点:
    • 首先从根节点出发,然后读取磁盘,将节点对应的磁盘的数据加载到内存,进行比较,11<13,所以在右边进行查找,读取磁盘,将节点对应的数据读取到内存,再进行比较,发现13<17,然后去左边,…循环上面的操作,直到访问到13这个节点。

根据上面的模拟查找过程,我们很容易可以发现,查找次数就是与磁盘IO次数, l o g 2 N log_2N log2N次的磁盘访问,在内存中是很快的,但是当数据都在磁盘中时,访问磁盘速度很慢。

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

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

总结一下常见查找数据结构在查找海量数据时的缺点:

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

  • 平衡二叉搜索树(AVL与红黑树)的高度是 l o g 2 N log_2N log2N,这个查找次数在内存中是很快的。但是当数据都在磁盘中时,访问磁盘速度很慢,在数据量很大时, l o g 2 N log_2N log2N次的磁盘访问,是一个难以接受的结果。

使用哈希表的缺陷:

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

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。(如下图:举个例子:看根节点,根节点的左边的节点的key都是小于17,右边节点的key都是大于17的;再举个例子:节点6的 左边是1 和 4,是小于6的,6的右边是8和11,是大于6的

B-树的插入分析

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

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

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

// 参数:key为待查找的元素
// 返回值:PNode代表找到的节点,int为该元素在该节点中的位置
pair<PNode, int> Find(const K& key)
{
	// 从根节点的位置开始查找
	 PNode pCur = _pRoot;
	 PNode pParent = NULL;
 	 size_t i = 0;
	// 节点存在
	 while(pCur)
 	 {
		 i = 0;
    	 // 在该节点的值域中查找
		 while(i < pCur->_size)
	 	{
         	// 找到返回
 			if(key == pCur->_keys[i])
 				return pair<PNode, int>(pCur, i);
			else if(key < pCur->_keys[i])  // 该元素可能在i的左边的孩子节点中
		 		break;
 			else
 				i++;  // 继续向右查找
	 	}
     
    	 // 在pCur中没有找到,到pCur节点的第i个孩子中查找
 	 	pParent = pCur;
		pCur = pCur->_pSub[i];
		}
		// 没有找到
 		return pair<PNode, int>(pParent, -1);
	}

插入过程总结:

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

B-树的插入实现

B-树的节点设计

// M叉树:即一个节点最多有M个孩子,M-1个数据域
// 为实现简单期间,数据域与孩子与多增加一个(原因参见上文对插入过程的分析)
template<class K, int M = 3>
struct BTreeNode
{
	K _keys[M];  //存放元素
	BTreeNode<K, M>* _pSub[M + 1];	 //存放孩子节点,注意:孩子比数据多一个
	BTreeNode<K, M>* _pParent;		 //在分裂节点后可能需要继续向上插入,为实现简单增加parent域
	size_t _size;					 //节点中有效元素的个数

	BTreeNode()
		: _pParent(NULL)
		, _size(0)
	{
		for (size_t i = 0; i <= M; ++i)
			_pSub[i] = NULL;
	}
};

插入key的过程

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

void _InsertKey(Node* pCur, const K& key, Node* pSub)
{
	// 按照插入排序思想插入key
	int end = pCur->_size - 1;
	while (end >= 0)
	{
		if (key < pCur->_keys[end])
		{
			// 将该位置元素以及其右侧孩子往右搬移一个位置
			pCur->_keys[end + 1] = pCur->_keys[end];
			pCur->_pSub[end + 2] = pCur->_pSub[end + 1];
			end--;
		}
		else
			break;
	}
	// 插入key以及新分裂出的节点
	pCur->_keys[end + 1] = key;
	pCur->_pSub[end + 2] = pSub;

	// 更新节点的双亲
	if (pSub)
		pSub->_pParent = pCur;

	pCur->_size++;
}
B-树的插入实现

	bool Insert(const K& key)
	{
		// 如果树为空,直接插入
		if (NULL == _pRoot)
		{
			_pRoot = new Node();
			_pRoot->_keys[0] = key;
			_pRoot->_size = 1;
			return true;
		}
		// 找插入位置,如果该元素已经存在,则不插入
		pair<Node*, int> ret = Find(key);
		if (-1 != ret.second)	return false;
		K k = key;
		Node* temp = NULL;
		Node* pCur = ret.first;
		while (true)
		{
			// 将key插入到pCur所指向的节点中
			_InsertKey(pCur, k, temp);
			// 检测该节点是否满足B-树的性质,如果满足则插入成功返回,否则,对pCur节点进行分裂
			if (pCur->_size < M)	return true;
			// 申请新节点
			temp = new Node;
			// 找到pCur节点的中间位置
			// 将中间位置右侧的元素以及孩子搬移到新节点中
			int mid = (M >> 1);
			for (size_t i = mid + 1; i < pCur->_size; ++i)
			{
				temp->_keys[temp->_size] = pCur->_keys[i];
				temp->_pSub[temp->_size++] = pCur->_pSub[i];
				
				// 更新孩子节点的双亲
				if (pCur->_pSub[i])
					pCur->_pSub[i]->_pParent = temp;
			}
			// 注意:孩子比关键字多搬移一个
			temp->_pSub[temp->_size] = pCur->_pSub[pCur->_size];
			
			if (pCur->_pSub[pCur->_size])
				pCur->_pSub[pCur->_size]->_pParent = temp;
			
			// 更新pCur节点的剩余数据个数
			pCur->_size -= (temp->_size + 1);
			
			// 如果分裂的节点为根节点,重新申请一个新的根节点,将中间位置数据以及分裂出的新节点插入到新的根节点中,插入结束
			if (pCur == _pRoot)
			{
				_pRoot = new Node;
				_pRoot->_keys[0] = pCur->_keys[mid];
				_pRoot->_pSub[0] = pCur;
				_pRoot->_pSub[1] = temp;
				_pRoot->_size = 1;
				pCur->_pParent = temp->_pParent = _pRoot;
				return true;
			}
			else
			{
				// 如果分裂的节点不是根节点,将中间位置数据以及新分裂出的节点继续向pCur的双亲中进行插入
				k = pCur->_keys[mid];
				pCur = pCur->_pParent;
			}
		}
		return true;
	}

B-树的简单验证

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

void InOrder(Node* pRoot)
{
	if (NULL == pRoot)
		return;
	for (size_t i = 0; i < pRoot->_size; ++i)
	{
		InOrder(pRoot->_pSub[i]);
		cout << pRoot->_keys[i] << " ";
	}
	InOrder(pRoot->_pSub[pRoot->_size]);
}

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次即可定位到该节点,然后利用二分查找可以快速定位到该元素,大大减少了读取磁盘的次数。

B+树和B*树

B+树

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

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

B+树的特性:

  1. 所有关键字都出现在叶子节点的链表中,且链表中的节点都是有序的。
  2. 不可能在分支节点中命中。(分支节点只存储索引,不存储数据,所以必须找到叶子节点对应的索引值,才能拿到数据,才命中)
  3. 分支节点相当于是叶子节点的索引,叶子节点才是存储数据的数据层。
B树与B+树的对比

MySQL默认使用的索引数据结构是B+树,为什么不是B树?

B树和B+树是常用的数据结构,用于在数据库中进行索引操作。它们之间的区别主要有以下几个方面:

  • 数据存储万式: 在B树中,每个节点都包含键和对应的值,叶子节点存储了实际的数据记录;而B+树中,只有叶子节点存储了实际的数据记录,非叶子节点只包含键信息和子节点的指针。所以B+树在非叶子节点可以存储更多的键值,相应的树的阶数就会更大,树也就变得更矮胖(树更宽更低),树的高度决定了检索次数,这样在进行磁盘I/O的次数就会大大减少,数据查询的效率也会更快。
  • 数据检索方式: 在B树中,由于非叶子节点也存储了数据(叶子节点中没有分支节点,如图),所以查询时可以直接在非叶子节点找到对应的数据,具有更短的查询路径;而B+树的所有数据都存储在叶子节点上,只有通过叶子节点才能获取到完整的数据。
  • 范围查询效率: 由于B+树的所有数据都存储在叶子节点上,并且叶子节点之间使用双向链表连接,所以范围查询和排序的效率较高。而在B树中,范围查询需要通过遍历多个层级的节点,效率相对较低。比如如上图:假设要查询一个范围,id在3~7之间,那么B+树只需要索引找到3,然后根据链表向后遍历找到4567即可,如果是B树就会每次从根节点重新索引4567。

适用场景:B树适合进行随机读写操作,因为每个节点都包含了数据;而B+树适合进行范围查询和顺序访问,因为数据都存储在叶子节点上,并且叶子节点之间使用链表连接,有利于顺序遍历,

B*树

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

B-树的应用

索引

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

MySQL索引简介

  • mysql是目前非常流行的开源关系型数据库,不仅是免费的,可靠性高,速度也比较快,而且拥有灵活的插件式存储引擎,如下:

MySQL中索引属于存储引擎级别的概念,不同存储引擎对索引的实现方式是不同的。
注意:索引是基于表的,而不是基于数据库的。

MyISAM

MyISAM引擎是MySQL5.5.8版本之前默认的存储引擎,不支持事物,支持全文检索,使用B+Tree作为索引结构,叶节点的data域存放的是数据记录的地址,非聚簇索引 其结构如下:

当我们使用MyISAM存储引擎建立一张表时,系统会创建三个文件,myisam.MYD(存储数据),myisam.MYI(存储索引),mysam.frm(表结构)。

  • 索引和数据单独存放

  • 表有三个文件

  • 叶子结点存放行数据地址信息

  • 通过查找到主键key,然后还得根据指针地址去数据表中找对应的数据行

  • 根据上图可以看出 MyISAM 的索引文件仅仅保存数据记录的地址。在MyISAM中,主索引和辅助索引(Secondary key)在结构上没有任何区别,只是主索引要求key是唯一的,而辅助索引的key可以重复。

  • 同样也是一棵B+Tree,data域保存数据记录的地址。因此,MyISAM中索引检索的算法为首先按照B+Tree搜索算法搜索索引,如果指定的Key存在,则取出其data域的值,然后以data域的值为地址,读取相应数据记录。MyISAM的索引方式也叫做“非聚集索引”的。

InnoDB

InnoDB存储引擎支持事务,其设计目标主要面向在线事务处理的应用,从MySQL数据库5.5.8版本开始,InnoDB存储引擎是默认的存储引擎。InnoDB支持B+树索引、全文索引、哈希索引。但InnoDB使用B+Tree作为索引结构时,具体实现方式却与MyISAM截然不同。

  • 第一个区别是索引和数据存放在一起的(innodb.ibd文件),InnoDB的数据文件本身就是索引文件。MyISAM索引文件和数据文件是分离的,索引文件仅保存数据记录的地址。而InnoDB索引,表数据文件本身就是按B+Tree组织的一个索引结构,这棵树的叶节点data域保存了完整的数据记录。这个索引的key是数据表的主键,因此InnoDB表数据文件本身就是主索引。
  • 使用InnoDB存储引擎创表之后会产生两个文件,innodb.frm与innodb.ibd。
  • 叶子节点存放行数据。
  • InnoDB的辅助索引data域存储相应记录主键的值而不是地址,所有辅助索引都引用主键作为data域。

上图是InnoDB主索引(同时也是数据文件)的示意图,可以看到叶节点包含了完整的数据记录,这种索引叫做聚集索引。因为InnoDB的数据文件本身要按主键聚集,所以InnoDB要求表必须有主键(MyISAM可以没有),如果没有显式指定,则MySQL系统会自动选择一个可以唯一标识数据记录的列作为主键,如果不存在这种列,则MySQL自动为InnoDB表生成一个隐含字段作为主键,这个字段长度为6个字节,类型为长整型。

第二个区别是InnoDB的辅助索引data域存储相应记录主键的值而不是地址,所有辅助索引都引用主键作为data域。

为什么辅助索引data域存储相应记录主键的值而不是直接存储真实数据?

  • 如果辅助索引直接存储真实数据,每建立一个辅助索引就会复制一份完整的数据记录。当存在多个辅助索引时,这些索引会分别存储相同数据的多份副本,导致存储空间的极大浪费。
  • 如果辅助索引直接存储真实数据,当数据记录发生更新时,所有包含该记录的辅助索引都需要进行相应的更新。这种更新操作不仅耗时,而且容易出错,尤其是在数据量庞大或更新频繁的情况下。存储主键的话,只需要更新主键值(在辅助索引中),并且主键值通常不会频繁更改,因此这种更新方式更加高效和稳定。

聚集索引这种实现方式使得按主键的搜索十分高效,但是辅助索引搜索需要检索两遍索引:首先检索辅助索引获得主键,然后用主键到主索引中检索获得记录。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值