本文详细介绍一下B树的实现。
1. B-树
B-tree
树即B树
, 这里B即Balanced的意思。因为B树的原英文名称为B-tree
,而国内很多人喜欢把B-tree
翻译为B-树
, 其实这是个非常不好的直译, 很容易让人产生误解。如人们可能会以为B-树
是一种树, 而B树
又是另外一种树。事实上,B-tree
就是指的B树
,特此说明。
1.1 B-树的定义
B-树
是一种平衡的多路查找树, 它在文件系统中很有用。我们在这里先介绍一下这种树结构。
一颗m阶
的B-树
, 或为空树,或为满足下列特性的m叉
树:
-
树中每个节点至多有m颗子树;
-
若根节点不是叶子节点,则至少有两颗子树
-
除根以外的所有非终端节点至少有⌈m/2⌉
棵子树
-
所有的非终端节点中包含下列信息数据
(n, A0, K1, A1, K2, A2, ... , Kn, An)
其中: Ki(i=1, ... ,n)
为关键字,且Ki<Ki+1(i=1, ... ,n-1)
; Ai(i=0, ... ,n)
为指向子树根节点的指针, 且Ai-1
所指子树中所有节点的关键字均小于Ki(i=1, ... ,n)
,An
所指子树中所有关键字均大于Kn
,n(⌈m/2⌉-1 <= n<= m-1)
为关键字的个数(n+1为子树个数)
- 所有的叶子节点都出现在同一层次上,并且不带信息(可以看作是外部节点或查找失败的节点,实际上这些节点不存在,指向节点的指针为空)
下图所示为一颗4阶
的B-树
, 其深度为4(F
为叶子节点):
1.2 B-树的特性
由B-树
的定义可以知道,其具有如下特性:
2. B-树的查找
由B-树
的定义可知,在B-树
上进行查找的过程和二叉排序树
的查找类似。例如,在上图所示的B树中查找关键字47
的过程如下:首先从根开始,根据根节点指针t
找到*a
节点,因*a
节点中只有一个关键字,且给定值47
大于关键字35
, 则若存在必在指针A1所指的子树内;顺指针找到*c
节点,该节点有两个关键字(43和78),而43<47<78,则若存在必在指针A1所指的子树中。同样顺指针找到*g
节点,在该节点中顺序查找到关键字47, 由此查找成功。查找不成功的过程也类似,例如在同一棵树中查找23。从根开始,因为23<35,则顺该节点中指针A0找到*b
节点,又因为*b
节点中只有一个关键字18,且23>18,所以顺节点中第二个指针A1找到*e
节点。同理因为23<27,则顺指针往下找,此时因指针所指为叶子节点,说明此棵B-树
中不存在关键字23,查找失败而告终。
2.1 查找分析
通过上面可知,在B-树
上进行查找包含两种基本操作: 1) 在B-树
中找节点; 2) 在节点中找关键字。由于B-树
通常存储在磁盘上,则前一查找操作是在磁盘上进行的,而后一查找操作是在内存中进行的,即在磁盘上找到指针p
所指节点后,先将节点中的信息读入内存,然后再利用顺序查找或者折半查找查询等于K
的关键字。显然,在磁盘上进行一次查找比在内存中进行一次查找耗费时间多得多,因此,在磁盘上进行查找的次数、即待查关键字所在节点在B-树
上的层次数,是决定B-树
查找效率的首要因素。
现考虑最坏的情况,即待查节点在B-树
上的最大层次数。也就是,含有N
个关键字的m阶B-树
的最大深度是多少?我们先看一棵3阶B-树
。按B-树
的定义,3阶的B-树
上所有非终端节点至多可以有两个关键字,至少有一个关键字(即子树个数为2或3, 故又称为2-3树
)。因此, 若关键字个数<=2时,树的深度为2(即nil叶子节点层次为2);若关键字个数<=6时,树的深度不超过3。反之,若B-树
的深度为4,则关键字个数必须>=7。此时,每个节点都含有可能的关键字的最小数目:
一般情况的分析可类似二叉平衡树进行,先讨论深度为l+1
的m阶B-树
所具有的最少节点数。根据B-树
的定义,第一层至少有一个节点;第二层至少有2个节点;由于除根之外的每个非终端节点至少有⌈m/2⌉
棵子树,则第三层至少有2⌈m/2⌉
个节点, ……, 依次类推, 第l+1层
至少有2⌈m/2⌉^(l-1)
个节点。而l+1
层的节点为叶子节点。若m阶B-树
中具有N个关键字,则叶子节点即查找不成功的节点为N+1
,由此有:
因此可求得l<= log_(⌈m/2⌉)〖(N+1)/2〗 + 1
。这就是说,在含有N个关键字的B-树
上进行查找时,从根节点到关键字所在节点的路径上涉及的节点数不超过log_(⌈m/2⌉)〖(N+1)/2〗 + 1
。
3. B-树的插入
其实B-树
的插入是很简单的,它主要分为如下两个步骤:
1) 使用之前介绍的查找算法查找出关键字的插入位置, 如果我们在B-树
中查找到了关键字, 则直接返回; 否则它一定会失败在某个最低层的终端节点上
2) 然后, 我们就需要判断那个终端节点上的关键字是否满足n<=m-1
, 如果满足的话,就直接在该终端节点上添加一个关键字, 否则我们就需要产生节点的分裂
。
节点分裂的方法是: 生成一个新节点。然后把原节点上的关键字和k
(需要插入的值)按升序排列, 从中间位置把关键字分成左右两个部分(形成3个部分: 左半部分、中间关键字、右半部分)。再接着把左半部分所含关键字放在旧节点中,右半部分所含关键字放在新节点中, 中间位置的关键字连同新节点的地址插入到父节点中。如果父节点的关键字个数也超过m-1
,则要再分裂,再往上插入,直至这个过程传到根节点为止。
下面我们举个例子进行说明。这里假设这棵B-树
的阶为3,树在初始化时如下:
首先,我们需要插入一个关键字30
,可以得到如下结果:
再插入26
, 得到如下结果:
如上所示,在插入的那个终端节点中,它的关键字数已经超过了m-1
,所以我们需要对节点进行分裂。我们先对关键字进行排序,得到:26 30 37
,它的左半部分为 26,中间值为30, 右半部分是37。然后我们将左半部分放在原来的节点,右半部分放在新的节点,而中间值则插入到父节点,并且父节点会产生一个新的指针,指向新的节点位置,如下图所示:
4. B-树的删除
B-树
的删除操作同样分为两个步骤:
1) 利用前述的B-树
的查找算法找出该关键字所在的节点,如果没有找到,直接返回; 否则根据k
(需要删除的关键字)所在的节点是否为叶子节点有不同的处理方法;
2) 若该节点为非叶子节点,且被删关键字为该节点中第i
个关键字key[i]
,则可以从指针children[i]
中找出最小关键字Y
, 代替key[i]
的位置, 然后在叶节点中删去Y
;
如果是叶子节点
的话,需要分为下面3种情况进行删除:
- 如果被删关键字所在的节点的原关键字个数
n >= ⌈m/2⌉
, 说明删去该关键字后该节点仍满足B-树
的定义。这种情况最为简单,只需要删除对应的关键字K
和指针A
即可。
- 如果被删关键字所在节点的关键字个数
n
等于⌈m/2⌉-1
, 而与该节点相邻的右兄弟(或左兄弟)节点中关键字数目大于⌈m/2⌉-1
,则需将其兄弟节点中最小(或最大)的关键字上移至双亲节点,而将双亲节点中小于(或大于)且紧靠该上移关键字的关键字下移至被删关键字所在的节点中。
我们从上图中删除关键字50
,需将其右兄弟节点中的61上移至*e
节点中,而将*e
节点中的53移至*f
,从而使*f
和*g
中关键字数目均不小于⌈m/2⌉-1
,而双亲节点中关键字数目不变。
- 被删关键字所在节点和其相邻的兄弟节点中的关键字数目均等于
⌈m/2⌉-1
。假设该节点有右兄弟,且其右兄弟节点地址由双亲节点中的指针Ai
所指,则在删去关键字之后,它所在节点中剩余的关键字和指针, 加上双亲节点中的关键字Ki
一起,合并到Ai
所指的兄弟节点中(若没有右兄弟,则合并至左兄弟节点中)。
从上图所示B-树
中删去53
,则应删去*f
节点,并将*f
中剩余信息(指针“空”)和双亲节点*e
节点中的61一起合并到右兄弟节点*g
中。如果因此使双亲节点中的关键字数目小于⌈m/2⌉-1
,则依次类推作相应处理。如下图所示:
如上B-树
中删去关键字37之后,双亲b
节点中剩余信息(“指针”c)应和其双亲*a
节点中关键字45一起合并到右兄弟节点*e
中。
5. 相关代码参考
5.1 B-树头文件
如下是B-tree
头文件btree.h:
5.2 实现文件
如下是B-tree
实现源代码文件btree.c:
5.3 测试
如下是测试源文件main.c:
编译运行:
# gcc -o btree *.c
# ./btree
inorder tranverse:
10 20 80 150 180 250 350 200 220 400 500 650 1000
[参看]:
-
B-树的详解
-
从B树、B+树、B*树谈到R 树