B树、B+树作为最基本的数据结构,对于他们的原理,设计的各种介绍和讨论已经非常普及了。但是很少有具体实现方面的分析。本文就从实现的角度就相关的各种操作步骤和性能上分析两种数据结构在应用和性能上的差别。
从性能角度来看,二叉树无疑是搜索类数据结构中搜索效率最高的,但是考虑到外存储设备都是块设备,单块尺寸远大于索引数据。如果一个外存储块只存放一个索引,实在是太浪费存储空间;而如果简单地把二叉树按某种结构保存到外存中,则可能会在访问和修改时反复操作多个存储块,引起的开销可能会大大削弱甚至抵消二叉树的效率优势。基于以上考虑,我们不得不变通使用B树(也叫B-树)和B+树来实现索引。
关于B树和B+树的定义和结构,以及各自在索引性能上的优劣,已经有无数文章写过,本文就不再详述了。
本文从编程角度来讨论一下这两种树的不同。
关于使用这两种树的使用,我们分三个部分来讲:
- 索引
我们先来看看两种树各自的索引流程。
-
- B+树索引
假设有如下B+树
图1 一棵B+树,Block是外存储设备的最小存储元,对于硬盘就是扇区
要查找元素05的步骤如下:
第一步:首先读取根节点Block1
第二步:对比当前节点的所有索引,发现它应该在索引06的子节点中
第三步:读取索引06的子节点Block3
第四步:对比当前节点的所有索引,找到目标
内存访问速度是硬盘的1000倍以上,所以内存比较操作跟磁盘访问比起来可以忽略不计,所以我们只统计外存读写操作。本次搜索合计两次读操作。
查找元素06的步骤如下:
第一步:首先读取根节点Block1
第二步:对比当前节点的所有索引,找到目标06
本次搜索合计一次读操作。
-
- B树索引
假设有如下B树(这里我们假设非叶子节点大于它的所有子节点,下同)
图2 一棵B树,Block的意义同图1
要查找元素05的步骤如下:
第一步:首先读取根节点Block1
第二步:对比当前节点中的所有索引,发现它应该在索引07的子节点中
第三步:读取索引07的子节点Block3
第三步:对比当前节点的所有索引,找到目标
本次搜索合计两次读操作。
查找元素07的步骤如下:
第一步:首先读取根节点Block1
第二步:对比当前节点的所有索引,找到目标
本次搜索合计一次读操作。
对于索引操作,B树和B+树几乎是一样的。都有两种情况:索引的元素在叶子节点/非叶子节点上。从索引效率上来说也几乎没区别。不过考虑到B树中没有冗余节点,因此对于元素数量相同的情况,B树效率要高一点点。但是B+树的叶子深度上可以视为完整的链表,因此在区域遍历时性能更高。
- 增加索引
- B+树增加索引
B+树增加索引有2种情况
-
-
- 适合存放新索引的节点中有空闲空间容纳新的节点
-
直接将新索引写到节点中适当的位置,操作结束。
此操作需要执行n次(n是B+树的深度) 读取操作,执行1次写入操作。
-
-
- 适合存放新索引的节点中已满,无法容纳新的索引
-
第一步:分配一个新的节点,将原节点中的前一半索引移动到新分配的节点中
第二步:按排序规则将新增加的索引加入新分配的节点或原有节点中
第三步:将新分配的节点和被取走一半索引的原始节点写入磁盘
第四步:取出新分配节点中的最后一个索引
第五步:把取出的索引增加到原始节点的父节点中
第六步:如果父节点有空闲空间存放取出的索引,则直接加入父节点中,增加索引操作结束
第七步:如果父节点的空闲空间不足以存放新增加的索引,则重复操作第一步至第五步,直到某一级父节点有足够空间能容纳新索引或到达树根为止
第八步:如果根节点中都无法存放新索引,则建立一个新的根节点作为父节点,将原根节点的最后一个索引放入新建立的根节点中。此时树的深度加1
此操作最少需要执行n次(n是B+树的深度)读取操作,执行1+m*2次写入操作,m是要上溯的父节点的层数。
-
- B树增加索引
B树增加索引同样有2种情况
-
-
- 适合存放新索引的节点中有空闲空间容纳新的索引
-
直接将新索引写到节点中适当的位置,操作结束。
此操作需要执行n次(n是B树的深度) 读取操作,执行1次写入操作。
-
-
- 适合存放新索引的节点中已满,无法容纳新的索引
-
第一步:分配一个新的节点,把原节点中的前一半索引移动到新分配的节点中
第二步:按排序规则将新索引加入新分配的节点或原有节点中
第三步:将新分配的节点中的最后一个索引(A)独立出来,即从节点块中删除,作为一个孤立的索引(作为新节点的父索引)
第四步:将新分配的节点和被取走一半索引的原始节点写入磁盘
第五步:将新分配节点父索引A增加到原始块的父节点中
第六步:如果父节点中没有足够的剩余空间存放新加入的索引,则重复第一步到第五步,依次在父节点中增加索引,一直到上层中适当的节点能容纳新节点的父索引或到达树根为止
此操作最少需要执行n次(n是B+树的深度)读取操作,执行1+m*2次写入操作,m是要上溯的父节点的层数。
- 删除索引
- B+树删除索引
B+树删除索引有两种情况
-
-
- 要删除的索引只存在于叶子节点,而且所属节点块中不止有一个索引
-
直接删除该索引,操作结束。
此操作需要执行n次(n是B+树的深度) 读取操作,执行1次写入操作。
-
-
- 要删除的索引还存在于一至多层父节点中
-
第一步:找到索引对应的叶子节点并删除该索引
第二步:如果该索引是节点中唯一的索引,则删除该节点
第三步:检查父节点中是否有同一索引存在,如果是则重复第一步和第二步直致所有父节点都没有该索引存在为止。
第四步:如果要删除的索引是根节点中唯一的索引,则要把它的子节点中最后一个索引复制出来,替换成根索引
此操作需要执行n次(n是B+树的深度) 读取操作,写入m-x次(m是节点的深度,x是要释放的块的个数)。
这里要注意的是删除操作需要考虑的极端情况比增加操作复杂得多,比如节点块中只有待删除的索引、要删除的索引是根索引,都需要额外的代码和和开销去处理。
同时,以上操作没有考虑多次删除操作之后,各索引块中的节点普遍变少的情况,最极端情况可能如下图所示:
图3 复杂操作后极端状态的B+树
如图所示,这样会极大程度地降低索引效率,同时也不再符合B+树的定义之一:非根节点至少有M/2个子节点。因此在删除操作中需要检测邻居节点是否能够合并。而合并节点有以下情况。
-
-
-
- 检查左邻居,有两种情况:
- 跟左邻居合并之后索引数<M,即一个节点块能容纳两块合并之后的所有索引(注意在实际环境,尤其是文件系统中的时候,一个节点块能容纳的索引数并不见得是一个固定的值)
- 检查左邻居,有两种情况:
-
-
此时需要将左邻居从树中删除,然后将左邻居的所有索引复制到当前节点块中。再次左邻居的所有父索引从树中删除。
此操作需要执行1次读取操作,执行1+l次写操作(l是左邻居的父索引存在于多少层节点块中),如果左邻居的某个父索引是所在节点中的唯一一个索引,则写操作次数减一。
-
-
-
-
- 跟左邻居合并之后索引数>M,即一个节点块不能容纳合并之后的所有节点。
-
-
-
此时需要将左邻居和当前节点块的所有索引平分到两个节点块中,然后用左邻居中新的最后索引生成新的左邻居的父索引,替换掉左邻居原来的父索引。
此操作需要执行1次读取操作,执行2+n次写入操作(n是原左邻居的父节点在树中占据的层数)。
-
-
-
- 检查右邻居,可以视为以右邻居为当前索引块的情况下操作左邻居,所以这里就不复述了。
-
-
还需要注意的是,要处理的不止是叶子层的节点块,对于除根节点外的所有节点块,在执行增删操作的时候都应该检查邻居块和当前块的情况以便执行合并操作。好在不论是哪一层的操作程序都是一样的,因此可以通过递归执行来减少代码和逻辑设计。
-
- B树删除索引
B树删除索引有三种情况
-
-
- 要删除的索引在叶子节点中且所属节点块中不止有一个索引。
-
直接删除该索引,操作结束。
此操作需要执行n次读取操作(n是B树的深度),执行1次写入操作。
-
-
- 要删除的索引在叶子节点上且所属节点块上只有除待删除索引这一个索引。
-
释放索引所在的节点块,将节点块的父索引从所在节点块中删除,然后将被删除的索引以一个独立索引的方式插入树中。即把删除操作变成了插入操作。
此操作需要执行n次读取操作(n是B树的深度),执行1次写入操作。以及增加索引所需的读写开销。
-
-
- 要删除的索引在非叶子节点上
-
第一步:找到索引
第二步:找到该索引对应的子节点
第三步:找到子节点块的最后一个索引对应的子节点
第四步:循环直到叶子节点为止
第五步:从叶子节点块开始,删除块中最后一个索引,用这个索引替换掉父节点中当前节点的父索引
第六步:依次向上循环,用当前节点中的最后一个索引替换掉当前节点块的父索引
第七步:一直到替换掉待删除节点为止。操作完成
此操作需要执行n次读取操作(n是B树的深度),执行n-m次写入操作。m是节点在树中的深度。如果在替换过程中,出现某个节点中只有一个索引的情况,那么还需要将子节点中最后一个索引删除出来作为该子节点的新的父索引的情况,即需要重复执行删除操作。
B树跟B+树一样有各节点块中存储的索引项引起的效率问题。因此同样在删除索引动作(这里的删除不是指用户的删除指令,而是具体执行时对节点块的删除动作)中需要处理索引块合并。由于B树的结构,其操作跟B+树有很大不同。
-
-
- 合并索引块
- 检查左邻居,有两种情况:
- 跟左邻居合并之后节点数<M,即一个节点块能容纳合并之后的所有索引(注意,这里跟B+树有所不同,还需要考虑左邻居的父索引)
- 检查左邻居,有两种情况:
- 合并索引块
-
此时需要将左邻居从树中删除,即在父节点块中删除左邻居的父索引,然后将左邻居的所有索引和它的父索引加入到当前节点块中。
此操作需要执行3次读取操作,2次写入操作。(可能出现的上层节点块合并操作不计入内)
-
-
-
-
- 跟左邻居合并之后索引数>M,即一个节点块不能容纳合并之后的所有索引
-
-
-
从左邻居中提取一些索引(包括左邻居的父索引)平均分配给两个节点块,然后将左邻居的最后一个索引独立出来作为左邻居的新父索引替换掉原来的父索引。
此操作需要执行3次读取操作,3次写入操作。
-
-
-
- 合并右邻居,可以视为以右邻居为当前索引块的情况下操作左邻居,所以这里就不复述了。
-
-
我们用一张表列出B树和B+树的异同。
操作 | 细分 | B树 | B+树 | 备注 |
索引操作 | 索引项在叶子节点 | n次读操作 | n次读操作 | B树没有冗余数据,性能略高 |
索引项不在叶子节点 | m次读操作 | m次读操作 | ||
增加索引 | 节点能装得下新索引 | n次读操作 1次写操作 | n次读操作 1次写操作 | 基本相当 |
节点装不下新索引 | n次读操作 1+o*2次写操作 | n次读操作 1+o*2次写操作 | 基本相当 | |
删除索引 | 删除叶子节点中的索引 | n次读操作 1次写操作 | n次读操作 1次写操作 | B树复杂度高 |
删除只有唯一索引的节点块中的叶子索引 | n次读操作 1次写操作+ (1次读操作 1次写操作) or (1次读操作 1+o*2次写操作)*1 | |||
删除非叶子节点中的索引 | n次读操作 写入n-m次 | n次读取操作 n-m+x次写操作 | B+树性能略差 | |
节点块合并 | 空间足够合并 | 3次读操作 2次写操作 | m+1次读操作 1+l次写操作 | 取决于树的深度,越深B+树性能越差 维护不当的B树出复杂操作(删除只有一个索引的节点)的概率高 |
空间不够合并 | 3次读操作 3次写操作 | m+1次读操作 2+p次写操作 |
n是树的深度
m是节点在树中的深度
o是需要向上扩展索引块的层数
x是要释放的块的个数
l是左邻居有多少个父节点所在的索引块需要合并。对于B+树,如果左邻居的父节点在树中占据的层数大于要合并的层数,还要加上这个层数差
p是原左邻居的父节点在树中占据的层数
*1 将该节点的父节点作为孤立节点插入树的操作。
结语
两种树搜索操作时性能仅因为各自的结构中是否有含有冗余数据而有少许差别,对于每个节点能存储n个索引,并且存储分布一致的情况下,B树的索引性能比B+树大约高1/n左右。
两种树在维护上则各有各的复杂点。虽然B树复杂度要相对高一些,但是随着索引量的增加,它在维护上的性能会比B+树有明显的提高,因此大型数据场景更趋向于使用B树。B+树则是相对简单,而且它的叶子节点是一个完整的索引链,在遍历上比B树方便很多。使用者可以按照自己的需求选择到底使用哪种树。