目录
一、B-树的性质
首先纠正一下读法B-树读作B树(不是B减树,这只是一个分隔符)。
1970年,R.Bayer 和 E.mccreight 提出了一种适合外查找的树,它是一种平衡的多叉树,称为B树(有些地方写的是B树,注意不要误读成"B减树")。一棵M阶(M > 2)的B树,是一棵平衡的M路平衡搜索树,可以是空树或者满足以下性质:
• 根节点至少有两个孩子。
• 每个非根节点至少有 M / 2 - 1(上取整) 个关键字,至多有 M - 1 个关键字,并且以升序排列。例如:当M = 3的时候,至少有3 / 2 = 1.5,向上取整等于 2,2 - 1 = 1 个关键字,最多是 2 个关键字。
• 每个非根节点至少有 M / 2 (上取整)个孩子,至多有 M 个孩子。例如:当 M = 3 的时候,至少有3 / 2 = 1.5,向上取整等于 2 个孩子。最多有 3 个孩子。
• key[i] 和 key[i+1] 之间的孩子节点的值介于 key[i]、key[i+1] 之间。
• 所有的叶子节点都在同一层。
• 孩子个数永远比关键字个数多一个。
二、为什么需要B-树
到现在我们已经学了不少的搜索算法和数据结构,如下:
以上结构适合用于数据量不是很大的情况,如果数据量非常大,一次性无法加载到内存中,使用上述结构就不是很方便。比如:利用平衡树搜索一个大文件(为了存储更多的数据,我们只保留查找的关键字和数据对应在磁盘中的地址)。
上面方法其实只在内存中保存了每一项数据信息中需要查找的字段以及数据在磁盘中的位置,整体的数据实际也在磁盘中。
利用平衡树来搜索大文件的缺点如下:
• 树的高度比较高,查找时最差情况下要比较树的高度次。
• 数据量如果特别大时,树中的节点可能无法一次性加载到内存中,需要多次IO(非常耗时)。
那么我们如何来提高访问大量数据的速度呢?
• 提高 IO 的速度。
• 降低树的高度---多叉树平衡树。
要想提高 IO 速度的话,就要提升硬件水平,和操作系统做出一定的改进,这显然有点困难,因此我们只能从降低树的高度来入手(树的高度越高,IO 次数越多)。这样我们的 B-树就派上用场了。因为 B 树可以有效的减少 IO 次数,可以理解为一个节点 IO 一次,所以我们要减少树的高度(B-树一个节点包含许多数据)。
三、B-树的代码实现
为了简单起见,假设 M = 3。即三叉树,每个节点中存储两个数据,两个数据可以将区间分割成三个部分,因此节点应该有三个孩子,为了后续实现简单,我们将孩子节点个数和关键字个数都增加一个,对应节点的结构如下:
注意:孩子永远比数据多一个。
为什么要多设置一个空节点?
答:多设置一个空节点,方便分裂(不用分类讨论)。
3.1 B-树插入过程的手动模拟:
为了方便后续代码的编写,我先带大家手动的走一遍B-树的插入过程,最后带着大家编写代码。
我们要插入的数据为{15, 30, 20, 70, 50, 16,17};
第一步:插入15,30。
按照顺序插入即可,节点没有满不需要分裂。新插入的节点我用红色标记。
第二步:插入20(分裂根节点)
这里是进行根节点的分裂(普通节点的分裂和根节点的分裂是有区别的)。分裂的影响到的数据我都用绿色标出。
第三步:插入70,50(分裂非根节点) ,因为插入 70 操作简单,所以就一起加在插入 50 的地方。
第四步:插入16,17
到这我们发现根节点又满了,所以我们要再次进行根节点的分裂。
第5步:分裂根节点。
到这里我们就已经把插入的所有情况都模拟了一遍,接下来我会给出总结,和带着大家编写代码。
3.2 B-树插入过程的总结:
• 如果树为空,直接插入新节点中,该节点为树的根节点。
• 树非空,找待插入元素在树中的插入位置。(注意:找到的插入节点位置一定在叶子节点中)
• 检测是否找到插入位置(假设树中的key唯一,即该元素已经存在时则不插入)。
• 按照插入排序的思想将该元素插入到找到的节点中。
• 检测该节点是否满足B-树的性质:即该节点中的元素个数是否等于M,如果小于则满足,否者要分裂。
• 如果插入后节点不满足B树的性质,需要对该节点进行分裂:
1. 申请新节点(根节点要申请两个,非根节点只要申请一个即可)。
2. 找到该节点的中间位置。
3. 将该节点的中间位置右侧元素搬到新节点,中间元素搬到父节点(非根节点),中间元素搬到另一个新节点。
4. 搬运的过程记得要把孩子节点一起搬运,还有孩子节点的父亲节点也要改变指向。
• 如果向上已经分裂到根节点的位置,代表这插入结束。
3.3 B-树的节点设计:
为了方便友友们阅读代码,下面我先给出B-树的节点构造方式,和一些变量含义,具体如下:
public class MyBtree {
//写入各类参数
public static final int M = 3;//表示是三叉树
static class BTRNode {
public int[] keys;//存储关键字
public int usedSize;//表示已经存储关键字的数量,孩子数就是关键字的数量加 1
public BTRNode parent;//当前节点的父亲节点
public BTRNode[] subs;//当前节点的孩子节点
public BTRNode() {
//多给一个的目的是方便分裂
keys = new int[M - 1 + 1];
subs = new BTRNode[M + 1];
}
public BTRNode root;//表示根节点
}
}
我们利用静态内部类直接定义出B-树的节点,对应信息如上代码所示,就不多赘述了。
3.4 查找B-树的插入位置:
如果发现要插入的节点已经存在,返回对应节点和关键字下标,如果不存在返回对应节点和 -1。这样我们就能很好的区分要插入的节点在B-树中是否存在。
由于插入操作需要先查找到B-树的插入位置,为了使代码更加整洁,我们将其实现成 find 方法。利用 fand 方法查找返回插入节点和插入节点 keys 的下标,因为要返回两个值,所以我们可以实现一个pair<K,V>类(Java有自带的,不过这里我们是自己实现),来返回。
public class Pair<K,V> {
private K key;
private V val;
public Pair(K key,V val){
this.key = key;
this.val = val;
}
public K getKey() {
return key;
}
public void setKey(K key) {
this.key = key;
}
public V getVal() {
return val;
}
public void setVal(V val) {
this.val = val;
}
}
在 find 方法中会使用到二分查找算法,如果没有学过的友友可以先看看二分查找算法,因为我们插入的 key 值都是在叶子节点上进行插入的,所以我们要先找到对应的叶子节点,再找到对应 keys 的下标。
//查找节点
public Pair<BTRNode, Integer> find(int key) {
BTRNode cur = root;//当前节点
BTRNode parent = cur.parent;//用来保存当前节点的父亲节点
while (cur != null) {
int i = 0;
int index = binary(cur.keys,cur.usedSize,key);//利用二分查找算法,快速找到插入点
if(index != cur.usedSize && cur.keys[index] == key){
//插入节点已经存在
return new Pair<>(cur,index);
}
parent = cur;//保存下来当前节点
//进入到孩子节点(因为我们的插入操作只能在叶子节点上面完成,所以我们要找到叶子节点)
cur = cur.subs[index];//这里不会越界,因为孩子节点比 keys 多一个
}
//走到这里代表 B树里面没有该节点
return new Pair<>(parent, -1);//最好加上 <>
}
//二分查找
public int binary(int[] arr,int end,int key){
if(key > arr[end - 1]){//等于的情况在上面已经判断过了
return end;
}
int right = end - 1;int left = 0;
while(right > left){
//左模板
int mid = left + (right - left) / 2;
if(key > arr[mid]){
left = mid + 1;
}else{
right = mid;
}
}
return left;
}
3.5 B-树的分裂:
注意:要分别处理根节点和非根节点的情况,搬运数据的边界情况,孩子节点也要搬运,搬运完,对应节点的关键字数量该加就加该减就减(不要漏了),建议编写代码的时候自己要手动模拟走一遍,照着图编写代码就不容易出错,就算出错也好调试。分裂完之后要再判断一下,父亲节点要不要分裂,例如上面手动模拟的第五步,我们可以利用递归来解决。具体代码如下:
public void split(BTRNode cur) {
//注意根节点和一般的节点是不一样的
//一般节点
BTRNode parent = cur.parent;
BTRNode newNode = new BTRNode();//创建新节点
int mid = M / 2;
int i = mid + 1;//mid 的右边
int j = 0;//新节点的左边
//将 cur 节点右边转移到新节点上面去
//对应的孩子节点也要转移
//搬运到兄弟节点
for (; i < cur.usedSize; i++) {
newNode.keys[j] = cur.keys[i];
newNode.subs[j] = cur.subs[i];
cur.keys[i] = 0;//将原来节点删去。
cur.subs[i] = null;
if (cur.subs[i] != null) {
//孩子的父节点也要改变
cur.subs[i].parent = newNode;
}
j++;
}
newNode.subs[j] = cur.subs[i];
newNode.usedSize++;//新节点的关键字个数要加 1
if (cur.subs[i] != null) {
cur.subs[i].parent = newNode;
}
cur.subs[i] = null;
cur.usedSize = cur.usedSize - j - 1;//更新 cur 的节点数
int curMid = cur.keys[mid];
cur.keys[mid] = 0;
//当时根节点的情况
if (cur == root) {
root = new BTRNode();
root.keys[0] = curMid;
//互相指向
root.subs[0] = cur;
root.subs[1] = newNode;
cur.parent = root;
newNode.parent = root;
root.usedSize++;
return;
}
//在父节点处找到合适的位置插入 curMid 和 新节点(放在孩子节点)
i = parent.usedSize - 1;
//插入排序
for (; i >= 0; i--) {
if (curMid <= parent.keys[i]) {
parent.keys[i + 1] = parent.keys[i];
parent.subs[i + 2] = parent.subs[i + 1];//对应的孩子节点
} else {
break;
}
}
//插入数据
parent.keys[i + 1] = curMid;
parent.usedSize++;
parent.subs[i + 2] = newNode;
newNode.parent = parent;
if (parent.usedSize == M) {
split(parent);//递归,处理父亲节点
}
}
3.6 B-树的插入:
主要的逻辑都已经实现好了,插入这里直接调用即可,注意根节点为空的情况要特判。因为这里是在叶子节点操作,所以就不涉及孩子节点。孩子节点的各种指向已经在分裂中实现完毕。插入过程利用插入排序的思想。
public boolean insert(int key) {
if (root == null) {//当根节点为空的情况
root = new BTRNode();
root.keys[0] = key;
root.usedSize++;
return true;
}
//注意只能在根节点进行插入操作
Pair<BTRNode, Integer> pair = find(key);//封装起来,代码风格更好
if (pair.getVal() != -1) {//该节点已经存在,插入失败
return false;
}
//插入排序
BTRNode cur = pair.getKey();
int index = cur.usedSize - 1;
while (index >= 0) {
if (cur.keys[index] >= key) {
cur.keys[index + 1] = cur.keys[index];
index--;
} else {
break;
}
}
cur.keys[index + 1] = key;
cur.usedSize++;
if (cur.usedSize >= M) {
//分裂
split(cur);//进行分裂
return true;
} else {
//没有满
return true;//插入成功
}
}
3.7 B-树的简单验证:
其实还有一个删除操作,删除操作比插入操作要更加复杂,文章篇幅有限,这里就不写了。
B-树本质是多叉搜索树,因此中序遍历节点,节点值是有序的。我们就利用中序遍历来验证是否插入正确。
这个要怎么理解呢?其实在 for 循环里面就已经是左根右了,最后的 subs[root.usedSize]只是因为孩子节点的数量比关键字的数量多一个,需要在循环外面再来一次。如果不太理解的话,可以用我上面话的图来手动走一遍。
public void inOrder(BTRNode root){
if(root == null){
return;
}
//中序遍历,左根右
for(int i = 0;i < root.usedSize;i++){
inOrder(root.subs[i]);
System.out.println(root.keys[i]);
}
inOrder(root.subs[root.usedSize]);
}
对应的验证代码如下:
public class Main {
public static void main(String[] args) {
MyBtree myBtree = new MyBtree();
int[] nums = new int[]{15,30,20,70,50,100,16,17};
for(int i = 0;i < nums.length;i++){
myBtree.insert(nums[i]);
}
myBtree.inOrder(myBtree.root);
}
}
我们的案例结果如下:
到这里我们的插入操作就已经成功了🎉🎉🎉
四、B-树的性能分析
五、B-树、B+树、B*树的区别:
5.1 B+树:
• B+树的性质:
B+树是B-树的变形,也是一种多路搜索树:
其定义基本与B-树相同,除了:
1. 非叶子节点的子树指针与关键字个数相同(孩子和关键字数量一样多)。
2. 非叶子节点的子树指针p[i],指向关键字值属于(k[i],k[i+1])的子树。
3. 为所有叶子节点增加一个链指针。
4. 所有关键字都在叶子节点出现。
例如下图就是一颗B+树:
B+树的搜索与B-树基本相同,区别是B+树只有达到叶子节点才能命中(B-树可以在非叶子节点中命中),其性能也等价与在关键字全集做一次二分查找。
• B+树的特性:
1. 所有关键字都出现在叶子节点的链表中(稠密索引),且链表中的节点都是有序的。
2. 不可能在非叶子节点中命中。
3. 非叶子节点相当于是叶子节点的索引(稀疏索引),叶子节点相当于是存储数据的数据层。
4. 更适合文件索引系统。
• B+树较B-树的优势:
1. 方便范围查询。(找到起点后,直接遍历链表就行,B-树还要回溯)
2. 查询操作稳定。
3. 数据存储在叶子节点,非叶子节点能够在内存中缓存。
因为方便范围查询,所以在 MySql 中的索引就是使用B+树的数据结构。
5.2 B*树:
B*树是B+树的变形,在B+树的非根和非叶子节点再增加指向兄弟节点的指针。
B*树使用的就很少了,因为较B+树,提升的不是很明显。
• B*树胶B+树的优化:
六、B-树的应用
6.1 索引:
B-树最常见的应用就是用来做索引。索引通俗的说就是为了方便用户快速找到所寻之物,比如:书籍目录可以让读者快速找到相关信息,百度网站,为了让用户能够快速的找到有价值的分类网站,本质上就是互联网页面中的索引结构。
MySQL官方对索引的定义为:索引(index)是帮助MySQL高效获取数据的数据结构,简单来说:索引就是数据结构。
当数据量很大时,为了能够方便管理数据,提高数据查询的效率,一般都会选择将数据保存到数据库,因此数据库不仅仅是帮助用户管理数据,而且数据库系统还维护着满足特定查找算法的数据结构,这些数据结构以某种方式引用数据,这样就可以在这些数据结构上实现高级查找算法,该数据结构就是索引。
6.2 MySQL索引简介:
mysql 是目前非常流行的开源关系型数据库,不仅是免费的,可靠性高,速度也比较快,而且拥有灵活的插件式存储引擎,如下:
MySQL 中索引属于存储引擎级别的概念,不同存储引擎对索引的实现方式是不同的。
注意:索引是基于表的,而不是基于数据库的。
6.2.1 MyISAM:
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 的索引方式也叫做“非聚集索引”(存储的是地址,不是本身数据)。
6.2.2 InnoDB:
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 域。
聚集索引这种实现方式使得按主键的搜索十分高效,但是辅助索引搜索需要检索两遍索引:首先检索辅助索引获得主键,然后用主键到主索引中检索获得记录。
结语:
其实写博客不仅仅是为了教大家,同时这也有利于我巩固知识点,和做一个学习的总结,由于作者水平有限,对文章有任何问题还请指出,非常感谢。如果大家有所收获的话还请不要吝啬你们的点赞收藏和关注,这可以激励我写出更加优秀的文章。