1、前言
微软创始人比尔盖茨在 1981 年提出了一个观点:640K ought to be enough for anybody 。他想说明的是,在计算机世界中,实际上只需要 640K 的内容容量就足够存放一切信息了。
也许现在你会觉得这是不具有长远眼光的一句话,但是当你完成 B 树的学习之后,你可能会认为这句话是正确的!
1.1、越来越小的内存
由于科学技术和工业的发展,内存的绝对大小当然是不断增大的,而我们这里说的“小”是相对于实际应用需求而言的。
目前,问题规模的膨胀却远远快于存储能力的增长!
以数据库为例,在20世纪80年代初,典型数据库的规模为10~100 MB,而三十年后的今天,典型数据库的规模已需要以 TB 为单位来计量。计算机存储能力提高速度相对滞后,是长期存在的现象,而且随着时间的推移,这一矛盾将日益凸显。
那么此时就会有人问,为什么不直接增大内存容量呢?这对于现在的技术应该很容易实现。但是在同样的成本下,存储器的容量越大(小)则访问速度越慢(快),因此一味地提高存储器容量,亦非解决这一矛盾的良策。
实践证明,分级存储才是行之有效的方法。在由内存与外存(磁盘)组成的二级存储系统中,数据全集往往存放于外存中,计算过程中则可将内存作为外存的高速缓存,存放最常用数据项的复本。借助高效的调度算法,如此便可将内存的“高速度”与外存的“大容量”结合起来。
两个相邻存储级别之间的数据传输,统称I/O操作!
1.2、两个重要事实
事实一:不同容量的存储器,访问速度差异悬殊
以内存与磁盘为例,其单次访问延迟大致分别在纳秒(ns)和毫秒(ms)级别,相差5至6个数量级。也就是说,对内存而言的一秒,相当于磁盘的一星期。
因此,为了避免一次外存访问,我们宁愿访问内存上千次。
多数的存储系统,都是分级组织,最常用的数据尽可能放在更高层,更小的存储器中,实在找不到,才向更底层,更大的存储器中索取。
注:算法的 I/O 复杂度正比于不同存储级别之间的传输次数,因此算法的实际运行速度也往往主要取决于此。
事实二:从磁盘中读写 1B,与读写 1KB 几乎一样快
因此一般可以采用批量式访问,以页或块为单位;其效果是明显的,单位字节的 1KB 访问时间大大缩短。
2、B树结构
2.1、基本介绍
当数据规模大到内存已不足以容纳时,常规平衡二叉搜索树的效率将大打折扣。其原因在于,查找过程对外存的访问次数过多。例如,若将10^9个记录在外存中组织为AVL树,则每次查找大致需做30次外存访问。
因此我们需要将通常的二叉搜索树,改造为多路搜索树。在中序遍历的意义下,这也是一种等价变换。
经过适当的合并,可以得到超级节点:
每2代合并:4路
每3代合并:8路
...
没d代合并:m = 2^d 路,m - 1 个关键码
(这里提到的路,即分支数,m 路即有 m 条分支)
在多级存储中使用 B树,可针对外部查找,大大减少 I/O 次数!
难道使用 AVL 还不够吗?假如有 n = 1G 个记录,那么每次查找就需要 log(2, 10^9) = 30 次 I/O 操作,每次只读取一个关键码,得不偿失!
而 B 树却能充分利用外存对批量访问的高效支持,正如上面提到的,对于硬盘而言,访问 1B 和访问 1KB 几乎没有差别。此时 B 树每下降一层,都会以超级节点为单位读取一组关键码。
上面提到的一组到底有多大,需要视磁盘的数据块大小决定,目前多数数据库系统采用的是 m = 200 - 300 。
如果使用 B 树访问 1G 个记录,且此时 m = 256 时,则每次查找只需要 log(256, 10^9) = 4 次 I/O 操作。
或许你会觉得 4 次和 30 次 I/O 操作的差距并不大,但实际上这是针对计算机中内外存的跨级处理,打个形象的比喻,如果对于人而言,就好比 4 年 和 30 年的差距,你可以选择花 4 年时间去读大学,但是如果要你花 30 年读大学,那么绝大部分人都可能会放弃!
2.2、具体结构
所谓 m 阶 B 树,即 m 路平衡搜索树(其中 m >= 2 )。
外部节点的深度统一,所有叶节点的的深度统一。树高 h = 外部节点的深度。
m 路分支的一颗平衡搜索树,对应的每一个超级节点又是怎样一个情况呢?
(n 是节点数,m 是分支数,n + 1 = m,因此分支数正好比节点数多 1 )
需要注意的是,要区分 节点 和 分支 数量之间的关系。
例如对于一个 m = 5 的 5 路平衡搜索树而言:
它的分支数量范围是:3 - 5,故又称为 (3,5)树
它的一个超级节点的内部节点范围是:2 - 4(分支树比节点数多 1)
2.3、紧凑表示
为了使得结构展示更加简洁,因此统一采用了紧凑的表示方法,大致是将外部引用的节点隐藏,直接用两个数据节点的间隙代替,同时还可以省去外部节点的展示:
3、接口实现
我们首先需要实现超级节点的 BTNode ,需要主要的是,在实现中,我们使用两个向量来分别保存关键码和对应的孩子向量:
其中 O 代表关键码,X 代表孩子向量,你可以想象将 X 的这一行向左移动半格,形成一个错位效果,那么我们可以得到下面这种简单表示:
3.1、超级节点 BTNode
template <typename T> struct BTNode;
template <typename T> using BTNodePosi = BTNode<T>*; //B-树节点位置
template <typename T> struct BTNode {
BTNodePosi<T> parent; // 父节点
Vector<T> key; // 关键码向量
Vector< BYNodePosi<T> > child; // 孩子向量,总比关键码多一个
// 构造函数1:创建一个空节点
BTNode() {
parent = NULL;
child.insert( 0, NULL );
}
// 构造函数2
BTNode(T e, BTNodePosi<T> lc, BTNodePosi<T> rc) {
parent = NULL; // 作为根节点时
key.insert( 0, e ); // 只有一个关键码
child.insert( 0, lc ); if ( lc ) lc -> parent = this; // 左孩子
child.insert( 1, rc ); if ( rc ) rc -> parent = this; // 右孩子
}
}
3.2、BTree 模板类
template <typename T> class BTree { //B-树模板类
protected:
int _size; // 存放的关键码总数
int _order; // B-树的阶次,至少为3——创建时指定,一般不能修改
BTNodePosi<T> _root; // 根节点
BTNodePosi<T> _hot; // BTree::search()最后访问的非空(除非树空)的节点位置
void solveOverflow ( BTNodePosi<T> ); //因插入而上溢之后的分裂处理
void solveUnderflow ( BTNodePosi<T> ); //因删除而下溢之后的合并处理
public:
BTNodePosi<T> search ( const T& e ); //查找
bool insert ( const T& e ); //插入
bool remove ( const T& e ); //删除
}; //BTree
需要指明的是,这并不是全部的接口,我们只关注其中最重要的几个(搜索,插入与删除)。
3.3、search 实现
要实现 search ,那么首先要明确要以怎样的查找思路进行:
1)将根节点作为当前节点(根节点常驻 RAM);
2)只要当前节点非外部节点,则在当前节点中顺序查找(此时在 RAM 内部,速度超快);
3)若找到关键码,则返回查找成功;否则沿着引用转至对应的子树,所没有对应引用,返回查找失败;
4)转至对应的子树时,是将其子树根节点读入内存(这就是 I/O 操作,此操作为主要耗时的部分);
5)更新当前节点;
6)如第三点,沿着子树到达最底部,则返回查找失败
现在我们就要根据上面的思路实现 search 算法:
template <typename T> BTNodePosi(T) BTree::search ( const T & e ) {
BTNodePosi(T) v = _root; _hot = NULL; // 从根节点开始遍历,此时的 _hot 为 NULL
while ( v ) { // 逐层查找
Rank r = v -> key.search( e ); // 在当前节点中,顺序查找不大于e的最大关键码
if ( r >= 0 && e == v -> key[ r ] ) { // 找到的情况
return v;
}
// 未找到的情况,沿着引用转至对应的下层引用,并载入其根( I/O )
_hot = v; v = v -> child[ r + 1 ];
} // v 为 NULL,意味着抵达外部节点,退出循环
return NULL;
}
需要单独指出的是,关于搜索树的特性,即目标值大小的比较实际上集成在了向量的搜索中,要明确一下这一点!
关于复杂度的分析:
首先我们在查找思路中明确,根节点是常驻内存中的。那么首先可以忽略内存中的查找时间(因为内存搜索效率高,且 m 也一般介于 200 至 300)。
那么剩下的运行时间就主要取决于 I/O 操作,而从算法中可以看出,在每一层只有转变引用时才会用到一次 I/O 操作,因此运行时间就是取决于终止节点的深度,即运行时间为 O(h)。
此时你应该已经意识到,树的高度是影响复杂的重要因素,因此我们需要再来讨论一下树高的情况!
1、最大树高:
含 N 个关键码的 m 阶 B树,最大高度是多少呢?
为了尽可能高,则分支树统一取 m 除以的上整,因此各层节点数量为:
同时考虑外部节点所在层的情况,那么经过推导可以得到最大树高为:
或许这个你仍没有一个直观的感觉,我们以 BBST 做对比的话,若 m 取 256 ,则 B 树的树高仅有 BBST 的 1/7 。
2、最小树高:
为了尽可能的低,那么每层超级节点的分支数就应该取到最大,则取 m,那么各层的节点数为:
同时考虑外部节点所在层的情况,那么经过推导可以得到最小树高为:
同样以 BBST 做对比的话,若 m 取 256 ,则 B 树的树高仅有 BBST 的 1/8 。
那么如果用 B 树来表示我们已知的某些数量时,树高的表现会足够优秀吗?
下面给出一组数据:
可以看到,在表现足够大数据量的情况下,B 树仍然有足够优异的表现!
通过选取合适的节点规模(m),足够弥补存储层级之间巨大的速度差异。
3.4、insert 实现
首先来看一下具体的 insert 实现:
template <typename T>
bool BTree::insert ( const T & e ) {
BTNodePosi(T) v = search( e ); // 查找目标节点是否存在,若不存在返回 NULL
if ( v ) {
return false; // 确定目标节点在树中不存在
}
// 注意:经过上面search方法,_hot 已经为失败情况下最后一个节点,
// key和child都已经为最后一层引用
Rank r = _hot -> key.search( e ); // 查找 e 应该插入的位置
_hot -> key.insert( r + 1, e ); // 将 e 插入对应位置
_hot -> child.insert( r + 2, NULL ); // 创建一个空子树指针
_size++; // 更新全树规模
solveOverflow ( _hot ); // 如有必要,需做分裂
return true;
}
整个插入的算法实现思路并不复杂,根据代码中注释能帮助更好理解。
上面代码中,最重要的一步是 solveOverflow 方法的执行!下面会详细介绍。
1、上溢:
首先明确上溢的定义:在插入的情况下,因为新插入的关键码导致某个超级节点内的关键码数量超过了 m - 1,此时 B 树的结构被破坏,这就是上溢。
如下图,加入这是一颗 m = 6 的六阶的 B树,那么其关键码数量范围应该为 2 - 5 ,而由于 37 的插入,导致关键码超过了规定范围,则此时即为发生上溢。
2、解决上溢:分裂
发生上溢后,由于 B 树结构被破坏,因此我们需要想办法进行恢复。其关键就在于分裂操作!
仍然利用上面的图,在插入 37 后,该超级节点中的关键码就已经超出了最小值,因此我们可以在该超级节点中进行一次分裂操作。具体的操作步骤是这样的:
1)取中位数:m 除以 2 ,取下整(注意,这里和前面计算关键码数量范围时不同,那里是取上整);
2)那么 6 除以 2 取下整结果为 3;
3)注意是以 0 为下标的起始位置,因此下标为 3 的值为 37,此时就以 37 为界限将该超级节点分为三个部分:[17, 20, 31], [37], [41, 56]
4)此时将 37 上升一层,然后以[17, 20, 31] 和 [41, 56] 分别作为 37 这个关键码的左右孩子,就像这样:
注意:经过分裂后的左右孩子一定满足 m 阶 B 树的条件!
然后,接收下层关键码的父级超级节点如果已经饱和,则再接受之后也会发生上溢,因此需要再次进行分裂,大可套用前法,继续分裂,就像这样:
上溢可能会持续发生,在最坏的情况下会传递到根节点,那么可以令被提升的关键码自成节点,作为新的树根,如上图右侧两步。同时这也是 B 树增高的唯一可能。
注意,新的树根是可以仅有两个分支的,这也是 B 树的结构条件之一!
而且可以很容易的清楚,分裂操作的时间不会超过树的高度,即不超过 O(h)。
需要特别指明的是,虽然上溢至根节点而产生新的树根的情况存在,但是出现这样情况的概率极低!
下面来看几个具体实例:(3-5树,则关键码范围为:2-4)
3、solveOverFlow 实现
template <typename T>
void BTree::solveOverFlow ( BTNodePosi(T) v ) {
// 递归基条件:如果超级节点中关键码数量小于等于 _order 阶次,则停止递归
if ( v -> child.size() <= _order ) return;
/**
* 1、在 C++ 中,整数 / 直接相当于取下整,此为轴点
* 2、此时是上溢状态,则该超级节点内的关键码个数正好比约束范围多一
* 即:此时应有_order = key.size() = child.size() - 1
*/
Rank s = _order / 2;
// 注意:新节点已有一个空孩子;此节点用于存放轴点右侧的节点
BTNodePosi(T) u = new BTNode<T>();
// 分裂出右侧节点 u
for ( Rank j = 0; j < _order - s - 1; j ++ ) {
u -> child.insert( j, v -> child.remove( s + 1 ) );
u -> key.insert( j, v -> key.remove( s + 1 ) );
}
// 移动 v 最靠右的孩子,这是因为在上面循环中并不能完全把child移动完
u -> child[ _order - s -1 ] = v -> child.remove( s + 1 );
// 若 u 的孩子们非空,则统一令其以 u 为 父节点
if ( u -> child[0] ) {
for (Rank j = 0; j < _order - s; j++) {
u -> child[j] -> parent = u;
}
}
BTNodePosi(T) p = v -> parent; // 记录 v 的当前父节点 p
if ( !p ) {
_root = p = new BTNode<T>();
p -> child[0] = v;
v -> parent = p;
}
Rank r = 1 + p -> key.search( v -> key[0] ); // p中指向 v 的指针的秩
// 插入 s ,即轴点的关键码上升
p -> key.insert( r, v -> key.remove( s ) );
// 插入 u,要注意原来的 v 的指向未变
p -> child.insert( r + 1, u );
u -> parent = p;
solveOverflow ( p ); //上升一层,如有必要则继续分裂——至多递归O(logn)层
}
结合前面的思路梳理和代码中的注释,可以更好的理解代码的实现!
3.5、remove 实现
首先来看一下具体的 remove 实现:
template <typename T>
bool BTree::remove ( const T & e ) {
// 搜索目标关键码是否存在
BTNodePosi(T) v = search( e );
if ( !v ) { // 若目标不存在,直接返回 false
return false;
}
Rank r = v -> key.search( e ); // 确定 e 在 v 中的关键码向量的位置
// v 非叶节点的情况:找到 e 的直接后继(必属于某叶节点),然后交换其位置
if ( v -> child[0] ) {
BTNodePosi(T) u = v -> child[ r + 1 ]; // 一定在 r + 1 的右侧
while ( u -> child[0] ) {
u = u -> child[0]; // 一直向左
}
// 找到后,交换其位置,这里的关键在于 = 实现了相互赋值!!!
v -> key[r] = u -> key[0];
v = u; // 这里是整个超级节点的相互赋值
r = 0;
} // 至此,v必然位于最底层,且其中第r个关键码就是待删除者
v -> key.remove( r );
v -> child.remove( r + 1 );
_size--;
solveUnderflow ( v ); //如有必要,需做旋转或合并
return true;
}
与 insert 类似,在 remove 的过程中,某些超级节点内的关键码数量会低于 B 树的最低限制(下溢)此时 B 树结构被破坏,我们也需要进行一些操作使 B 树恢复其合法结构!
1、下溢
首先明确下溢的定义:在删除的情况下,因为删除的关键码导致某个超级节点内的关键码数量少于 (m / 2)取上整再减去 1,此时 B 树的结构被破坏,这就是下溢。
2、解决下溢
首先明确一点,某节点 v 发生下溢时,必恰好包含:
接下来,我们需要观察其左(L)、右兄弟(R)中所含关键码的数量,可分为三种情况!
(1)若 L 存在,且至少包含 (m/2)取上整 个关键码
可以将 y 从 P 中移至 V 中(作为 V 中最小的关键码)
然后将关键码 x 从 L 中一直 P 中(取代原来的关键码 y)
结果是这样的:
实际上这是一种“借”的思想,V 中节点不够,然后又发现其兄弟中拥有足够的节点,则可以向兄弟“借”节点。
从思路上我们是直接向兄弟“借”,但是实际上这样直接借可能会破坏顺序性,因此通过“旋转”的方式相借。具体的原理是:
y 的左右子树中,V 中所有节点一定大于 y,L 中所有节点的值一定小于 y 。因此 y 一定能在 V 中作为最小值存在,而 L 中的最大值 x 也一定可以替代 y 的位置。
至此,局部乃至整数都重新满足 B 树的合法结构,即下溢修复完毕!
(2)若 R 存在,且至少包含 (m/2)取上整 个关键码
此一种情况与(1)中完全对称。
前两种都是“旋转”,是因为有兄弟节点可以借出来关键码,那么如果没有关键码可以借呢?接下来让我们一起来看看第三种情况。
(3)L 和 R 或者不存在,或者所含的关键码均不足(m/2)取上整 个
注意:L 和 R 仍必有其一,且恰含 (m/2)取上整 - 1 个关键码
实际上,此时的 L 和 R 不可能同时不存在,因为 m 至少为 3,要满足B树结构则必然有足够的分支,也就有足够的兄弟。
从 P 中抽出介于 L 和 V 之间的关键码 y,通过 y 做粘接,以 L 和 V 合成一个节点,同时合并此前 y 的孩子引用。
结果:
这一操作称为“合并”,如此操作后,原高度处的下溢得以修复,但是却可能导致更高层的下溢,不过不需要担心,只需要套用这三种方法进行旋转或者合并即可!
而下溢虽然可能持续发生,但最坏的情况也不过到达根,最多不会超过 O(h) 次。同时根的直接孩子发生下溢,若需要合并时,树的高度会减小 1,这也是 B 树唯一可能高度减小的情况。
同 insert 中高度增加的概率类似,这种高度减小的情况出现的概率极低!
来看看一些实例:
最后,来看看 solveUnderflow 的具体实现:
template <typename T>
void BTree::solveUnderFlow( BTNodePosi(T) v ) {
/**
* 1、(_order + 1) / 2 即表示 _order 除以 2 取上整
* 2、递归基,v 未发生下溢时的情况
*/
if ( (_order + 1) / 2 <= v -> child.size() ) {
return;
}
// 对树根处的特别处理
BTNodePosi(T) p = v -> parent;
if ( !p ) { //递归基:已到达根节点
// 若作为树根的 v 已不含关键码,却有(唯一的)非空孩子
if ( !v -> key.size() && v -> child[0] ) {
_root = v -> child[0]; _root -> parent = NULL;
v -> child[0] = NULL; release( v ); // 销毁这个无用节点
} // 整树高度降低一层
return;
}
// 确定 v 是 p 的第 r 个孩子,因为此时 v 可能不含关键码,故不能通过关键码查找
Rank r = 0;
while ( p -> child[r] != v ) {
r++;
}
// 确定了 v 的位置,下面就需要分情况进行讨论了
// 情况1:向左兄弟借关键码
if ( 0 < r ) { // 若v不是p的第一个孩子,则左兄弟必存在
BTNodePosi(T) ls = p -> child[ r -1 ];
// 判断左孩子是否有能力借出关键码
if ( (_order + 1) / 2 < ls -> child.size() ) {
// p 借出一个关键码给v(作为最小关键码)
v -> key.insert( 0, p -> key[ r - 1 ] );
// ls 中的最大关键码转入 p
p -> key[r-1] = ls -> key.remove( ls -> key.size() - 1 );
// 同时ba把 ls 的最右侧孩子过继给 v
v -> child.insert( 0, ls -> child.remove( ls -> child.size() -1 ) );
if ( v -> child[0] ) {
v -> child[0] -> parent = v
}
// 至此,通过右旋已完成当前层(以及所有层)的下溢处理
return;
}
} // 至此,左兄弟要么为空,要么不足以借出关键码
// 情况2:向右兄弟借关键码,情况与 1 完全对称
if ( p -> child.size() - 1 > r ) { // 若v不是p的最后一个孩子,则右兄弟必存在
BTNodePosi<T> rs = p->child[ r + 1 ];
// 判断右兄弟是否能借出关键码
if ( ( __order + 1 ) / 2 < rs->child.size() ) {
// p 借出一个关键码给 v(作为最大关键码)
v -> key.insert ( v -> key.size(), p->key[r] );
// rs 的最小关键码转入 p
p -> key[r] = rs -> key.remove ( 0 );
//同时rs的最左侧孩子过继给v
v->child.insert ( v -> child.size(), rs -> child.remove ( 0 ) );
if ( v -> child[ v -> child.size() - 1 ] ) { //作为v的最右侧孩子
v -> child[ v -> child.size() - 1 ] -> parent = v;
}
return; //至此,通过左旋已完成当前层(以及所有层)的下溢处理
}
} //至此,右兄弟要么为空,要么不足以借出关键码
// 情况3:左、右兄弟要么为空(但不可能同时),要么都不足以借出关键码
// 与左兄弟合并
if ( 0 < r ) {
BTNodePosi<T> ls = p -> child[r - 1]; // 左兄弟必存在
// 将上层 p 中的对应位置的节点插入 ls 中
ls -> key.insert( ls -> key.size(), p -> key.remove ( r - 1 ) );
p -> child.remove ( r );
// p的第 r - 1 个关键码转入 ls 后,则 v 不再是 p 的第 r 个孩子
ls -> child.insert ( ls -> child.size(), v -> child.remove ( 0 ) );
// v 的最左侧孩子过继给 ls 做最右侧孩子
if ( ls -> child[ ls -> child.size() - 1 ] ) {
ls -> child[ ls -> child.size() - 1 ] -> parent = ls;
}
// v 剩余的关键码和孩子,依次转入ls
while ( !v -> key.empty() ) {
ls -> key.insert ( ls -> key.size(), v -> key.remove ( 0 ) );
ls -> child.insert ( ls -> child.size(), v -> child.remove ( 0 ) );
if ( ls -> child[ ls -> child.size() - 1 ] ) {
ls -> child[ ls -> child.size() - 1 ] -> parent = ls;
}
}
release ( v ); // 释放v
} else { // 与右兄弟合并
BTNodePosi<T> rs = p -> child[r + 1]; //右兄弟必存在
rs -> key.insert ( 0, p -> key.remove ( r ) );
p->child.remove ( r );
// p 的第 r 个关键码转入 rs,v 不再是 p 的第 r 个孩子
rs -> child.insert ( 0, v -> child.remove ( v -> child.size() - 1 ) );
//v的最右侧孩子过继给rs做最左侧孩子
if ( rs->child[0] ) {
rs->child[0]->parent = rs;
}
// v 剩余的关键码和孩子,依次转入 rs
while ( !v -> key.empty() ) {
rs -> key.insert ( 0, v -> key.remove ( v -> key.size() - 1 ) );
rs -> child.insert ( 0, v -> child.remove ( v -> child.size() - 1 ) );
if ( rs -> child[0] ) {
rs -> child[0] -> parent = rs;
}
}
release ( v ); //释放v
}
solveUnderFlow ( p ); // 上升一层,如有必要则继续分裂——至多递归O(logn)层
return;
}
复杂度:
与插入操作类似,在存在 N 个关键码的 m 阶 B 树中每次关键码的删除,都可以在 O(logmN) 时间内完成。同样,因为某一关键码的删除而导致 logmN 次合并操作的情况概率极低,单次删除操作的平均只需要长舒茨节点的合并。
至此,关于 B 树的介绍就告一段落了!
最后,欢迎大家关注我的微信公众号:火锅只爱鸳鸯锅!