【数据结构】从零开始认识B树 --- 高效外查找的数据结构


挫折会来也会过去,
热泪会流下也会收起,
没有什么可以让我气馁的,
因为,我有着长长的一生。
--- 席慕蓉 《写给幸福》---


从零开始认识B树

B树的概念

先前我们学习过的数据结构有红黑树,二叉搜索树,平衡搜索树,哈希表... 对于搜索问题,这几个数据结构各有优缺点

种类 数据格式 时间复杂度 特点
顺序查找 无要求 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 (log₂N);缺点是维护平衡的旋转操作复杂,实现成本高
哈希 无要求 O(1) 优点是搜索效率理论上达到常数级,动态操作也高效;缺点是存在哈希冲突,需额外处理(如链地址法),无序存储无法支持范围查询。

同时,上面的数据结构处理的数据量不会很大,因为他们都需要在内存中构建相应的结构,然后在内存进行搜索。如果出现了100G,内存中无法正常储存时,那么想要使用以上的数据结构就不成立了!那如果我们想要搜索这些数据应该如何处理呢?

那么我们可以考虑将存放关键字及其映射的数据的地址 放到一个内存中的搜索树的节点 中,那么要访问数据时,先取这个地址去磁盘访问数据。

假如我们将平衡搜索树的节点换为数据储存的地址,那么会得到上图的树。上面的树层数只有3层,如果有100层,我们可以模拟一下搜索的过程:

  1. 原本可以直接从内存中读取出来的数据,现在需要去磁盘进行一次IO
  2. 如果我们当前节点是位于叶子节点,此时就需要进行100次的磁盘IO,对应时间复杂度是O( l o g 2 N log_2 N log2N)
  3. 内存 IO 和磁盘 IO 的读写速度差距极大,通常在10 万倍到 100 万倍的量级,那么可以想象到这一次的搜索会花费大量的时间在磁盘IO上。

如果使用哈希表,是不是可以进行稳定O(1)的磁盘IO呢?必然是不可能的,哈希表中出现大量哈希冲突时,对于开散列版本的哈希表也是需要进行大量IO的

显然,上述的数据结构都是不能满足大量数据时的磁盘读取!所以对此就产生了一个特别的树: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

这些性质乍一看很复杂,但其实很好理解,下面我们通过3阶的B树构建过程可以快速理解B树的结构

B树的插入分析

插入的步骤:

  1. 找到应该插入到的节点
  2. 将数据放入到该节点中关键字中
  3. 判断当前关键字是否满足m阶B树的要求,如果不满足需要进行分裂。
  4. 分裂的过程是将一半的数据分给新的brother节点,再将中间的关键字给于父节点(为了保证分裂可以均分子节点)。
  5. 对父节点进行同样的检查,父节点是根节点并且需要分裂时需要特殊处理,构建一个新的根节点:
cpp 复制代码
#pragma once
#include<iostream>
#include<vector>
#include<assert.h>

using namespace std;

namespace BTree {

	template<class K , size_t M>
	struct TreeNode {
		std::vector<K> keys;//储存的关键值
		std::vector<TreeNode<K , M>*> subs;//储存的子节点

		TreeNode<K, M>* parent;//父节点
		size_t size;//有效数据个数
		TreeNode() {
			keys.resize(M, K());//最多M个关键字
			subs.resize(M + 1, nullptr);//M+1个子节点
			size = 0;
			parent = nullptr;//初始父节点为空指针
		}
		
	};
	template<class K , size_t M>
	class BTree {
	public:
		typedef TreeNode<K, M> node;
		//构造函数
		BTree() : _root ( nullptr){}
		//查找目标函数
		std::pair<node*, int> find(const K& key) {
			//从根节点开始寻找
			node* cur = _root;
			node* parent = nullptr;
			while (cur != nullptr) {
				//现在当前节点的keys关键字中寻找
				size_t i = 0;
				for (; i < cur->size; i++) {
					if (key > cur->keys[i]) {
						continue;
					}
					else if (key < cur->keys[i]) {
						//说明不在当前节点中 需要向下寻找子节点
						break;
					}
					else {
						//找到了 - 返回当前节点与下标
						return std::make_pair(cur, i);
					}
				}
				//走入最后一个节点
				parent = cur;
				cur = cur->subs[i];
			}
			//没有找到 返回最后走入的节点(应该插入的节点)
			return std::make_pair(parent, -1);
		}
		//节点插入数据
		void InsertKey(node* cur, const K& key , node* child) {
			//向cur中插入key
			//找到key对应的位置
			int end = cur->size - 1;
			while (end >= 0) {
				if (key < cur->keys[end]) {
					//向后挪动
					cur->keys[end + 1] = cur->keys[end];
					cur->subs[end + 2] = cur->subs[end + 1];
					end -= 1;
				}
				//找到合适位置
				else {
					break;
				}
			}
			cur->keys[end + 1] = key;
			cur->subs[end + 2] = child;
			if (child)
			{
				child->parent = cur;
			}
			cur->size++;

		}
		//树中插入数据
		bool Insert(const K& key) {
			//如果是第一次插入 创建根节点
			if (_root == nullptr) {
				_root = new node();
				_root->keys[0] = key;
				_root->subs[0] = nullptr;
				_root->size = 1;
				_root->parent = nullptr;
				return true;
			}
			//不是第一次插入数据 
			//先判断是否已经存在
			std::pair<node*, int> p = find(key);
			if (p.second != -1) {
				//说明已经插入过了
				return false;
			}
			//没有插入过 那么find会返回应该插入到的叶子结点
			node* cur = p.first;
			K newKey = key;
			node* child = nullptr;
			//开始进行插入
			while (true) {
				InsertKey(cur, newKey, child);
				//判断是否需要分裂
				if (cur->size < M) {
					//没有超出返回 成功插入
					return true;
				}
				//该节点的数据满了 需要进行分裂
				//1. 将一半的数据+子节点分给brother节点
				//2. 将中间节点+brother给父节点
				//3. 对父节点继续进行处理

				node* brother = new node();
				size_t mid = M / 2;
				//迁移数据
				//分裂一半[mid+1, M-1]给兄弟
				size_t i = 0;
				size_t j = mid + 1;
				for (; j < M; j++ , i++) {
					brother->keys[i] = cur->keys[j];
					brother->subs[i] = cur->subs[j];
					//子节点的父节点转移
					if (cur->subs[j] != nullptr) {
						cur->subs[j]->parent = brother;
					}
					//清空cur的数据
					cur->keys[j] = K();
					cur->subs[j] = nullptr;
				}
				//转移最后一个子节点
				brother->subs[i] = cur->subs[j];
				//子节点的父节点转移
				if (cur->subs[j] != nullptr) {
					cur->subs[j]->parent = brother;
				}

				//更新cur的数据
				cur->subs[j] = nullptr;
				brother->size = i;
				cur->size -= (brother->size + 1);//更新数据量

				//brother处理完成 向上处理
				// 将中间节点给父节点
				K midKey = cur->keys[mid];
				cur->keys[mid] = K();//迁移原数据

				//判断父节点是否存在
				if (cur->parent == nullptr) {
					//说明是根节点 需要新建一个新的根节点
					_root = new node();
					_root->keys[0] = midKey;
					_root->subs[0] = cur;
					cur->parent = _root;
					_root->subs[1] = brother;
					brother->parent = _root;
					_root->size = 1;
					_root->parent = nullptr;

					return true;//完成插入
				}

				//不是根节点 就要继续向上处理
				newKey = midKey;
				child = brother;
				cur = cur->parent;
				
			}
		}
	private:
		TreeNode<K, M>* _root;//根节点
	};
}

对于一棵节点为N度为M的B-树,查找和插入需要 l o g M − 1 N log{M-1}N logM−1N~ 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 logM−1N和 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树的删除是一个很复杂的过程:
核心原则是保证删除后每个节点(除根节点外)的关键字数量不低于 ⌈M/2⌉ - 1(下限),否则需要通过 "借兄弟节点" 或 "合并节点" 来维持平衡。以下是具体步骤

参考视频:B树删除

B+树与B*树

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

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

与B树的最大区别就是非根节点的关键字与节点数是一样的,并且只有叶子结点储存数据。同时叶子节点是互相相连的,更加便于遍历查找。

B+树的特性:

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

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

通过以上介绍,大致将B树,B+树,B
树总结如下:

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

B树的应用

数据库索引是B树最重要的应用 ,之前在mysql文章提到过索引是依赖B树建立的。

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

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

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

mysql中主要的储存引擎有MyISAM 和 InnoDB 这两者的索引结构是不同的:

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

上图是以以Col1为主键,MyISAM的示意图,可以看出MyISAM的索引文件仅仅保存数据记录的地址。在MyISAM中,主索引和辅助索引(Secondary key)在结构上没有任何区别,只是主索引要求key是唯一的,而辅助索引的key可以重复。如果想在Col2上建立一个辅助索引,则此索引的结构如下图所示

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

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

第一个区别是InnoDB的数据文件本身就是索引文件MyISAM索引文件和数据文件是分离的 ,索引文件仅保存数据记录的地址。而InnoDB索引,表数据文件本身就是按B+Tree组织的一个索引结构,这棵树的叶节点data域保存了完整的数据记录。这个索引的key是数据表的主键,因此InnoDB表数据文件本身就是主索引

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

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

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

相关推荐
zzzsde2 小时前
【C++】红黑树:使用及实现
开发语言·c++·算法
点云SLAM2 小时前
C++ 中的栈(Stack)数据结构与堆的区别与内存布局(Stack vs Heap)
开发语言·数据结构·c++·内存布局·栈数据结构·c++标准算法·heap内存分配
码界奇点2 小时前
Linux进程间通信三System V 共享内存完全指南原理系统调用与 C 封装实现
linux·c语言·网络·c++·ux·risc-v
小无名呀3 小时前
tcp_Calculator(自定义协议,序列化,反序列化)
网络·c++·网络协议·tcp
AA陈超3 小时前
ASC学习笔记0001:处理目标选择系统中当Actor拒绝目标确认时的调用
c++·笔记·学习·游戏·ue5·游戏引擎·虚幻
前端小L3 小时前
图论专题(六):“隐式图”的登场!DFS/BFS 攻克「岛屿数量」
数据结构·算法·深度优先·图论·宽度优先
-大头.3 小时前
Python数据结构之旅:09-图论基础——连接万物的网络
数据结构·图论
..过云雨3 小时前
13.【Linux系统编程】从ELF格式深入理解动静态库
linux·c语言·c++·后端
长沙红胖子Qt3 小时前
QGIS开发笔记(五):qgis加载标记点功能,基础标记数量与性能对比测试
c++