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

从零开始认识B树
- B树的概念
- B树的插入分析
- B树的删除分析
- B+树与B*树
- B树的应用
B树的概念
先前我们学习过的数据结构有红黑树,二叉搜索树,平衡搜索树,哈希表… 对于搜索问题,这几个数据结构各有优缺点
| 种类 | 数据格式 | 时间复杂度 | 特点 |
|---|---|---|---|
| 顺序查找 | 无要求 | O(N) | 优点是对数据格式无任何要求、实现最简单;缺点是数据量越大效率越低,无法利用数据特征优化。 |
| 二分查找 | 有序 | O(log2Nlog_2 Nlog2N) | 优点是时间复杂度低,仅需比较操作;缺点是依赖有序数据,插入 / 删除后维持有序成本高。 |
| 二叉搜索树 | 无要求 | O(N) | 优点是兼顾搜索与动态插入 / 删除;缺点是极端情况下退化为链表,效率骤降,稳定性差。 |
| 二叉平衡树(AVL树和红黑树) | 无要求 | O(log2Nlog_2 Nlog2N) | 优点是解决了普通二叉搜索树的失衡问题,搜索、插入、删除均稳定在 O (log₂N);缺点是维护平衡的旋转操作复杂,实现成本高。 |
| 哈希 | 无要求 | O(1) | 优点是搜索效率理论上达到常数级,动态操作也高效;缺点是存在哈希冲突,需额外处理(如链地址法),无序存储无法支持范围查询。 |
同时,上面的数据结构处理的数据量不会很大,因为他们都需要在内存中构建相应的结构,然后在内存进行搜索。如果出现了100G,内存中无法正常储存时,那么想要使用以上的数据结构就不成立了!那如果我们想要搜索这些数据应该如何处理呢?
那么我们可以考虑将存放关键字及其映射的数据的地址放到一个内存中的搜索树的节点中,那么要访问数据时,先取这个地址去磁盘访问数据。

假如我们将平衡搜索树的节点换为数据储存的地址,那么会得到上图的树。上面的树层数只有3层,如果有100层,我们可以模拟一下搜索的过程:
- 原本可以直接从内存中读取出来的数据,现在需要去磁盘进行一次IO
- 如果我们当前节点是位于叶子节点,此时就需要进行100次的磁盘IO,对应时间复杂度是O(log2Nlog_2 Nlog2N)
- 内存 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路平衡搜索树,可以是空树或者满足一下性质:
- 根节点至少有两个孩子
- 每个分支节点都包含 k - 1 个关键字和 k 个孩子,其中 ceil(m / 2) ≤ k ≤ m ,ceil是向上取整函数
- 每个叶子节点都包含 k - 1个关键字,其中 ceil(m / 2) ≤ k ≤ m
- 所有的叶子节点都在同一层
- 每个节点中的关键字从小到大排列,节点当中k-1个元素正好是k个孩子包含的元素的值域划分
- 每个结点的结构为:(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树的插入分析

插入的步骤:
- 找到应该插入到的节点
- 将数据放入到该节点中关键字中
- 判断当前关键字是否满足m阶B树的要求,如果不满足需要进行分裂。
- 分裂的过程是将一半的数据分给新的brother节点,再将中间的关键字给于父节点(为了保证分裂可以均分子节点)。
- 对父节点进行同样的检查,父节点是根节点并且需要分裂时需要特殊处理,构建一个新的根节点:
#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-树,查找和插入需要logM−1Nlog{M-1}NlogM−1N~logM/2Nlog{M/2}NlogM/2N次比较,这个很好证明:对于度为M的B-树,每一个节点的子节点个数为M/2 ~(M-1)之间,因此树的高度应该在要logM−1Nlog{M-1}NlogM−1N和logM/2Nlog{M/2}NlogM/2N之间,在定位到该节点后,再采用二分查找的方式可以很快的定位到该元素。
B-树的效率是很高的,对于N = 62*1000000000个节点,如果度M为1024,则logM/2Nlog_{M/2}NlogM/2N <=4,即在620亿个元素中,如果这棵树的度为1024,则需要小于4次即可定位到该节点,然后利用二分查找可以快速定位到该元素,大大减少了读取磁盘的次数。
B树的删除分析
B树的删除是一个很复杂的过程:
核心原则是保证删除后每个节点(除根节点外)的关键字数量不低于 ⌈M/2⌉ - 1(下限),否则需要通过 “借兄弟节点” 或 “合并节点” 来维持平衡。以下是具体步骤:
参考视频:B树删除

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

与B树的最大区别就是非根节点的关键字与节点数是一样的,并且只有叶子结点储存数据。同时叶子节点是互相相连的,更加便于遍历查找。
B+树的特性:
- 所有关键字都出现在叶子节点的链表中,且链表中的节点都是有序的。
- 不可能在分支节点中命中。
- 分支节点相当于是叶子节点的索引,叶子节点才是存储数据的数据层。
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域

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