前言:
动态查找树主要有:二叉查找树(Binary Search Tree),平衡二叉查找树(Balanced Binary Search Tree),红黑树(Red-Black Tree ),B-tree/B+-tree/ B*-tree (B~Tree)。前三者是典型的二叉查找树结构,其查找的时间复杂度O(log2N)与树的深度相关,那么降低树的深度自然会提高查找效率。
咱们有一个实际问题:就是大规模数据存储中,实现索引查询这样一个实际背景下,树节点存储的元素数量是有限的(如果元素数量非常多的话,查找就退化成节点内部的线性查找了),这样导致二叉查找树结构由于树的深度过大而造成磁盘I/O读写过于频繁,进而导致查询效率低下,那么如何减少树的深度(当然是不能减少查询的数据量),一个基本的想法就是:采用多叉树结构(由于树节点元素数量是有限的,自然该节点的子树数量也就是有限的)。
也就是说,因为磁盘的操作费时费资源,如果过于频繁的多次查找势必效率低下。那么如何提高效率,即如何避免磁盘过于频繁的多次查找呢?根据磁盘查找存取的次数往往由树的高度所决定,所以,只要我们通过某种较好的树结构减少树的结构尽量减少树的高度,那么是不是便能有效减少磁盘查找存取的次数呢?那这种有效的树结构是一种怎样的树呢?
这样我们就提出了一个新的查找树结构——多路查找树。根据平衡二叉树的启发,自然就想到平衡多路查找树结构,也就是这篇文章所要阐述的第一个主题B~tree,即B树结构(后面,我们将看到,B树的各种操作能使B树保持较低的高度,从而达到有效避免磁盘过于频繁的查找存取操作,从而有效提高查找效率)。
外存储器简单介绍
在开始介绍B~tree之前,先了解下相关的硬件知识,才能很好的了解为什么需要B-tree这种外存数据结构。
当磁盘驱动器执行读/写功能时。盘片装在一个主轴上,并绕主轴高速旋转,当磁道在读/写头(又叫磁头) 下通过时,就可以进行数据的读 / 写了。
当盘片绕主轴旋转的时候,磁头与旋转的盘片形成一个圆柱体。各个盘面上半径相同的磁道组成了一个圆柱面,我们称为柱面 。因此,柱面的个数也就是盘面上的磁道数。
磁盘的读/写原理和效率
磁盘上数据必须用一个三维地址唯一标示:柱面号、盘面号、块号(磁道上的盘块)。
磁盘读/写步骤:
- 首先移动臂根据柱面号使磁头移动到所需要的柱面上,这一过程被称为定位或查找 。
- 如上图所示的4盘组示意图中,所有磁头都定位到了6个盘面的6条磁道上(磁头都是双向的)。这时根据盘面号来确定指定盘面上的磁道。
- 盘面确定以后,盘片开始旋转,将指定块号的磁道段移动至磁头下。
经过上面三个步骤,指定数据的存储位置就被找到。这时就可以开始读/写操作了。
磁盘读/写时间组成:
- 查找时间(seek time) Ts: 完成上述步骤(1)所需要的时间。这部分时间代价最高,最大可达到0.1s左右。
- 等待时间(latency time) Tl: 完成上述步骤(3)所需要的时间。由于盘片绕主轴旋转速度很快,一般为7200转/分(电脑硬盘的性能指标之一, 家用的普通硬盘的转速一般有5400rpm(笔记本)、7200rpm几种)。因此一般旋转一圈大约0.0083s。
- 传输时间(transmission time) Tt: 数据通过系统总线传送到内存的时间,一般传输一个字节(byte)大概0.02us=2*10^(-8)s
总结:
每次磁盘的读写,IO代价主要花费在查找时间Ts上,所以,在大规模数据存储方面,大量数据存储在外存磁盘中,而在外存磁盘中读取/写入块(block)中某数据时,首先需要定位到磁盘中的某块,如何有效地查找磁盘中的数据,需要一种合理高效的外存数据结构,就是下面所要重点阐述的B-tree结构,以及相关的变种结构:B+-tree结构和B*-tree结构。
B-树
具体讲解之前,有一点,再次强调下:B-树,即为B树。因为B树的原英文名称为B-tree,而国内很多人喜欢把B-tree译作B-树,其实,这是个非常不好的直译,很容易让人产生误解。如人们可能会以为B-树是一种树,而B树又是一种一种树。而事实上是,B-tree就是指的B树。特此说明。
B树是为了磁盘或其它存储设备而设计的一种多叉平衡查找树,与红黑树很相似。但是也存在一些不同。B树与红黑树最大的不同在于,B树的结点可以有多个子女,从几个到上千个。那为什么又说B树与红黑树很相似呢?因为与红黑树一样,一棵含n个结点的B树的高度也为O(lgn),但可能比一棵红黑树的高度小许多,应为它的分支因子比较大。所以,B树可以在O(logn)时间内,实现各种如插入(insert),删除(delete)等动态集合操作。
B树的定义:
用m阶表示(上限):(上图为一颗4阶B树)
- 每一个结点最多有m-1个关键字(上图最多有3个);所以它最多含有m个孩子(m>=2).
- 除根结点和叶子结点外,每一个结点的关键字最少有[ceil(m / 2)]-1个(上图最少有1个);所以它最少含有[ceil(m / 2)]个孩子(其中ceil(x)是一个取上限的函数);
- 若根结点不是叶子结点,则至少有2个孩子(特殊情况:没有孩子的根结点,即根结点为叶子结点,整棵树只有一个根节点);
- 所有叶子结点都出现在同一层,叶子结点不包含任何关键字信息(可以看做是外部接点或查询失败的接点,实际上这些结点不存在,指向这些结点的指针都为null);
- 每个非终端结点中包含有n个关键字信息: (n,P0,K1,P1,K2,P2,……,Kn,Pn)。其中:
- Ki (i=1…n)为关键字,且关键字按顺序升序排序K(i-1)< Ki。
- Pi为指向子树根的接点,且指针P(i-1)指向子树种所有结点的关键字均小于Ki,但都大于K(i-1)。
- 关键字的个数n必须满足: [ceil(m / 2)-1]<= n <= m-1。
用度表示(下限):(上图为一颗度为2的B树)
B树中每一个结点能包含的关键字数有一个上界和下界。这个下界可以用一个称作B树的最小度数(算法导论中文版上译作度数,最小度数即内节点中节点最小孩子数目)t(t>=2)表示。
- 每个结点可包含至多2t-1个关键字(上图最多有3个)。所以一个内结点至多可有2t个子女。如果一个结点恰好有2t-1个关键字,我们就说这个结点是满的(而稍后介绍的B*树作为B树的一种常用变形,B*树中要求每个内结点至少为2/3满,而不是像这里的B树所要求的至少半满);
- 当关键字数t=2(t=2的意思是, tmin =2,t可以>=2)时的B树是最简单的(有很多人会因此误认为B树就是二叉查找树,但二叉查找树就是二叉查找树,B树就是B树,B树是一棵含有t(t>=2)个关键字的平衡多路查找树),此时,每个内结点可能因此而含有2个、3个或4个子女,亦即一棵2-3-4树,然而在实际中,通常采用大得多的t值。
B树的高度:
对于辅存做IO读的次数取决于B树的高度。而B树的高度由什么决定的呢?
若B树某一非叶子节点包含N个关键字,则此非叶子节点含有N+1个孩子结点,而所有的叶子结点都在第I层,我们可以得出:
- 因为根至少有两个孩子,因此第2层至少有两个结点。
- 除根和叶子外,其它结点至少有 t 个孩子,
- 因此在第3层至少有2*t个结点,
- 在第4层至少有 2t2 个结点,
- 在第 I 层至少有 2tl−2 个结点;
- 当B树有n个关键字,那它的最大高度有: n≥2th−1 ,(根节点为第0层)
B树插入:
- 若该结点中关键码个数小于m-1,则直接插入即可
- 若该结点中关键码个数等于m-1,则将引起结点的分裂。以中间关键码为界将结点一分为二,产生一个新结点,并把关键中间码插入到父结点(h-1)层中
- 重复上述工作,最坏情况一直分裂到根结点,建立一个新的根结点,整个B树增加一层
以下所有图M值取3。
树为空时
插入20时,对已有键值10和20进行比较,按照从左到右从小到大的顺序插入。
当20插入根节点以后,节点size等于M,此时需要对节点进行分裂。若不分裂,则该节点孩子为四个,违反了性质2。这里节点结构定义时多给了一格,以便插入键值时键值数组不会越界。
分裂过程如下图:
分裂时,创建两个新节点,一个作为根节点用以存放节点中间键值为20的节点,一个用来存放中间键值右边的所有键值,其次,更新孩子双亲指向关系。
树不空
依次插入40和50,自上而下查找插入位置,根据大小排列,插入30所在节点,50插入后需要再次分裂节点,此时,因该节点非根节点,则,分裂时,将中间键值之后的键值移入新节点中,中间键值存入双亲节点中,在此例中,其双亲为根节点,往双亲插入中间键值时,按照从左向右,从小到大的顺序,即键值的插入顺序。
再次插入80,70。图示如下:分裂图示如下:
上图中,70插入后,该节点需要分裂,分裂完毕之后,70存入根节点,此时根节点也需要分裂,以满足B树性质。需要注意的是,分裂过程中,各个节点的孩子双亲指向需要及时更改,否则出错,具体细节见代码实现。
B树删除:
首先,查找B树中需要删除的元素,如果该元素在B树中存在,则将该元素在其结点中进行删除,如果删除该元素后,首先判断该元素是否有左右孩子结点,如果有,则上移孩子结点中的某相近元素到父结点中,然后是移动之后的情况;如果没有,直接删除后,然后是移动之后的情况
删除元素,移动相应元素之后的处理过程
- 如果某结点中元素数组(即关键字数)小于ceil(m/2)-1,则需要看其某相邻兄弟结点是否丰满(结点中的元素个数大于ceil(m/2)-1)
- 如果丰满,则向父结点借一个元素来满足条件
- 如果其相邻兄弟都刚脱贫,即借了之后其结点数目小于ceil(m/2)-1,则该结点与其相邻某一兄弟结点进行“合并”成一个结点,以此来满足条件
下面看图解:一棵5阶B树(树中每个结点最多有4个元素,最少有2个元素)
B树初始状态
依次删除H,T,R,E
删除H结点,首先查找H结点,H在一个叶子结点中,且该叶子结点元素数目大于3大于最小的元素数目2,则操作很简单,只需要移动K至原来H的位置,移动L至原来K的位置(也就是结点中删除元素后面的元素依次向前移动)
删除元素T
删除元素T,元素T没有在叶子结点中,而是在中间结点中找到,发现他的继承者W,将W上移到T的位置,然后将原包含W的孩子结点中的W进行删除,这里恰好删除W后,该孩子结点中元素个数大于2,无需进行合并操作
删除元素R
删除元素R,R在叶子结点中,但是该结点中元素数目为2,删除导致只有1个元素,已经小于最小元素数目ceil(5/2)-1=2,根据移动后的方案我们知道:如果其某个相邻兄弟结点中比较丰满,则可以向父结点借一个元素,然后将最丰满的相邻兄弟结点中上移最后或最前一个元素到父结点中,在这个例子中,右相邻兄弟结点比较丰满,所以先向父结点借一个元素W下移到叶子结点中,替代原来S的位置,同时S前移;然后X在相邻右兄弟结点中上移到父结点中,最后在相邻右兄弟结点中删除X,后面元素前移
删除元素E
删除元素E,删除后会导致很多问题,因为E所在的结点数目刚好达标,刚好满足最小元素个数2,而相邻的兄弟结点也是同样的情况,删除一个元素都不能满足条件,所以需要该结点与某相邻兄弟结点进行合并操作;首先,移动父结点的元素(该元素在两个需要合并的结点元素之间)下移到其子结点中,然后将这两个结点合并称一个结点,所以在该实例中,首先将父结点中的元素D下移到已经删除E而只有F的结点中,然后含有D、F的结点和含有A、C的结点进行合并成为一个结点
但是,这样并没有结束,因为父结点只包含一个元素G,不满足5阶B树每个结点至少有2个关键字的特性。如果这个问题结点的相邻兄弟结点比较丰满,则可以向父结点借一个元素。假设这时右兄弟结点(含有Q、X)有两个以上的元素,可以把M下移到元素少的结点,将Q上移到M的位置,这时,Q的左子树变成含有G、M的树。但是这个实例中,相邻结点都正好脱贫,所以,只能与兄弟结点合并成一个结点,而根节点中唯一的元素M下移到子结点,这样,树的高度减少一层。