顺序查找,折半查找、分块查找等查找方法都是适用于计算机中内存较小的文件,统称为内查找法。若文件很大,且存放于外存进行查找时,这些查找方法就不适用了。
下面学习一种适用于外查找的平衡多叉树——B树。磁盘管理系统中的目录管理,以及数据库系统中的索引组织多数都采用B树这种数据结构。
一棵非空的m阶的B树,有以下性质:
那么为什么B树被称为平衡多叉树?
“平衡”二字说明它是一棵平衡树,有平衡二叉树的性质,而平衡二叉树又属于二叉排序树,所以B树就是平衡、有序的,那么多叉呢?从它的性质可以看出来,B树不再局限于“二叉”,每个结点可以拥有多棵子树,且每个结点可以有多个关键字。
例如:
上图就是一棵4阶的B树中我们可以看到,有些结点有两棵子树,有些结点有三棵子树;有些结点只有1个关键字,有些结点有2、3个关键字,这就是B树的多路。同时我们可以发现,箭头都是从关键字旁边的空白处出发,分别指向它们的子树的。如果我们将它们的子树放回到箭头出发的空白处,那么这棵B树的关键字从左往右看就是从小到大的排序,这就是B树的有序。
由上图,我们可以进一步定义B树结点的存储结构
| parent | n | P 0 P_{0} P0 | K 1 K_{1} K1 | P 1 P_{1} P1 | … | K n K_{n} Kn | P n P_{n} Pn |
|---|
其中parent指针指向其双亲,n表示该结点关键字的个数, P P P为指向子树的指针(也就是前面所说的空白), K K K为关键字。再由前面的性质,我们可以知道关键字的个数总是比子树的个数小1,那么它的存储结构也就好理解了。
进而再用代码来描述它:
typedef struct BTNode
{
int keynum; //关键字个数
struct BTNode *parent;
int K[m+1]; //m为B树的阶数,自己定义
struct BTNode *ptr[m+1] //指向子树的指针数组
Record *recptr[m+1];
}BTNode,*BTree;
typedef struct //用于保存查找的结果
{
BTNode *pt; //指向找到的结点
int i; //关键字序号
int tag; //标志域,tag=1成功,tag=0失败
}Result;
在这里需要注意每个数组的大小 m + 1 m+1 m+1,把它设为 m + 1 m+1 m+1不是没有道理的。
| ptr | P 0 P_{0} P0 | P 1 P_{1} P1 | P 2 P_{2} P2 | … | P m − 1 P_{m-1} Pm−1 | P m P_{m} Pm |
|---|---|---|---|---|---|---|
| K | K 1 K_{1} K1 | K 2 K_{2} K2 | … | K m − 1 K_{m-1} Km−1 |
因为每个关键字左右都有一个指针,所以每个关键字要像前面那样排列,即下面这种间断式。
| P 0 P_{0} P0 | K 1 K_{1} K1 | P 2 P_{2} P2 |
|---|
所以 K K K数组的0号元素不用,是为了表示 P 0 P_{0} P0在 K 1 K_{1} K1的左边,那么 K K K数组中 m + 1 m+1 m+1号位置有什么用?若所给值比该结点中最大关键字还要大,那么应从它右边指针指向的子树中继续去查找。这个要结合B树查找的代码一起理解。
由B树的存储结构可知,一个结点中的每个关键字前后都有一个指向子树的指针,因此,对于给定要查找的值 k e y key key,我们需要与这个结点的各个关键字比较,如果等于该结点中的关键字,则查找成功;若不等于,则确定该给定值在哪棵子树。
例如:若
K
i
<
k
e
y
<
K
i
+
1
K_{i}
具体代码:
Result SearchBTree(BTree T,int key)
{
int i=0;
bool found=false; //指示是否查找成功
BTree p,q;
p=T; //p指向待查找结点
q=NULL; //q指向p的双亲
while(p&&!found)
{
i++;
//获得满足Ki<=key
for(;i<=p->keynum;i++)
{
if(key<=p->K[i])
break;
}
if(i>0&&p->K[i]==key)
found=true;
else
{
i=0;
q=p;
p=p->ptr[i-1];
}
if(found)
return(p,i,1);
else
return(q,i,0);
}
}
在B树中的查找,无非就是两步:
由于B树通常存储在磁盘上,则对结点的查找是在磁盘上进行的,而后一查找是在内存中进行的。即在磁盘上找到指针p所指结点后,先将结点中的信息读入内存,然后再利用顺序查找或折半查找查询等于 k e y key key的关键字。显然,在磁盘上进行一次查找比在内存中进行一次查找耗费时间多出很多,所以,在磁盘上进行查找的次数,即待查找关键字所在结点在B树上的层次数,是决定B树查找效率的首要因素。
先回忆一下B树的相关性质,除根结点外的所有非终端结点至少有 ⌈ m / 2 ⌉ \lceil m/2 \rceil ⌈m/2⌉棵子树。
若要求深度为 h + 1 h+1 h+1的 m m m阶B树的最少结点树,根据B树的定义,第一层至少有1个结点,第二层至少有2个结点,第三层至少有 2 × ⌈ m / 2 ⌉ 2\times\lceil m/2 \rceil 2×⌈m/2⌉个结点(第二层至少有两个结点,每个结点有至少有 ⌈ m / 2 ⌉ \lceil m/2 \rceil ⌈m/2⌉棵子树),第4层至少有 2 ⌈ m / 2 ⌉ 2 2\lceil m/2 \rceil^2 2⌈m/2⌉2个结点(第三层有 2 ⌈ m / 2 ⌉ 2\lceil m/2 \rceil 2⌈m/2⌉个结点,每个结点至少有 ⌈ m / 2 ⌉ \lceil m/2 \rceil ⌈m/2⌉棵子树),…,第 h + 1 h+1 h+1层至少有 2 ⌈ m / 2 ⌉ h − 1 2\lceil m/2 \rceil^{h-1} 2⌈m/2⌉h−1个结点。
若该B树中有 N N N个关键字,由前面可知,每个关键字左右都有一个指向子树的指针,因此可以推出叶子结点有 N + 1 N+1 N+1个。所以有 N + 1 ≥ 2 ⌈ m / 2 ⌉ h − 1 N+1\geq 2\lceil m/2 \rceil^{h-1} N+1≥2⌈m/2⌉h−1,经变换,有 h ≤ log ⌈ m / 2 ⌉ ( N + 1 2 ) + 1 h\leq \log_{\lceil m/2 \rceil}(\frac{N+1}{2})+1 h≤log⌈m/2⌉(2N+1)+1。也就是说,**在含有N个关键字的B树上进行查找时,从根结点到关键字所在结点的路径上涉及的结点数(层数)不超过 log ⌈ m / 2 ⌉ ( N + 1 2 ) + 1 \log_{\lceil m/2 \rceil}(\frac{N+1}{2})+1 log⌈m/2⌉(2N+1)+1。
B树是动态查找树,因此其生成过程是从空树起,在查找过程中通过逐个插入关键字而得到。每一次插入一个关键是在最底层(也就是叶子结点处)的某个结点中添加一个关键字,若添加后该结点关键字不超过 m − 1 m-1 m−1,则插入完成,否则说明该结点的关键字已满,这是该结点就需要分裂,将此结点在同一层分为两个结点。一般情况下分裂方法是:以中间关键字为界把结点一分为二,并把中间关键字插入到双亲结点上,若双亲结点已满,则采用同样的方法继续分解。最坏的请开个下,一直分解到树根结点,这时B树高度增加1。
例如,对下图的3阶B树进行各种插入:
插入30:
因为,该B树最大关键字为2,所以成功插入30。
插入26:
原来
(
30
,
37
)
(30,37)
(30,37)结点插入26后变成了
(
26
,
30
,
37
)
(26,30,37)
(26,30,37),很明显关键字最大数已经超过2了,所以将中间关键字30拿到双亲结点,再将其余的分为两半,就变成了上图。
插入85:

原先结点的中间关键字加入到双亲结点后又满了,这时需要再一次地分裂:
算法分析:
具体代码:
int Roundup(int s,BTree q)
{
s=(float)(q->keynum+1)/2;
int s1=(q->keynum+1)/2;
if(s-s1==0)
return s1;
else if(s-s1>0)
return (s1+1);
}
int InsertBTree(BTree *T,int key,BTree q,int i) //q表示插入的结点,i表示在K[i]和K[i+1]之间的位置,即ptr[i]
{
int x=key,s; //新插入的关键字
BTree ap=NULL;
ap=(BTree)malloc(sizeof(BTNode)); //空指针
bool finished=false;
while(q&&!finished)
{
//先把K[i]后面的元素往后移,然后i+1空出来把插入元素放进去
for(int j=q->keynum+1;j>i+1;j--)
{
q->K[j]=q->K[j-1];
q->ptr[j]=q->ptr[j-1];
}
q->K[i+1]=x;
q->ptr[i+1]=ap;
q->keynum++;
//然后判断关键字是否超出了最大值
if(q->keynum<m)
finished=true;
else
{
s=Roundup(s,q);
for(int j=s+1,u=0;j<=q->keynum;j++,u++)
{
ap->K[u]=q->K[j];
ap->ptr[u]=q->ptr[j];
ap->recptr[u]=q->recptr[j];
}
x=q->K[s];
q=q->parent;
if(q)
{
for(int j=1;j<=q->keynum;j++)
{
if(x<=q->K[i])
{
i=j;
break;
}
}
}
}
}
if(!finished)
{
BTree newT=NULL;
newT=(BTree)malloc(sizeof(BTNode));
newT->K[1]=x;
newT->ptr[0]=(*T);
newT->ptr[1]=ap;
}
return 0;
}
B树的删除操作不同于一般树的删除操作,它是在B树的某个结点中删除指定的关键字及其邻近的一个指针,删除后应进行调整使该树满足B树的定义,也就是要保证每个结点的关键字数目范围为 [ ⌈ m / 2 ⌉ − 1 , m ] [\lceil m/2 \rceil-1,m] [⌈m/2⌉−1,m],为什么是 ⌈ m / 2 ⌉ − 1 \lceil m/2 \rceil-1 ⌈m/2⌉−1?根据B树的性质,每个结点至少有 ⌈ m / 2 ⌉ \lceil m/2 \rceil ⌈m/2⌉棵子树,也就是有 ⌈ m / 2 ⌉ \lceil m/2 \rceil ⌈m/2⌉指向子树的指针,而关键字的个数比该指针域的个数少1。
B树的删除可以分为两大情况:删除最底层结点的关键字、删除其它层结点的关键字。
首先是能同时针对这两种情况的一种方法:


然后是一种针对删除最下层结点的关键字的方法,分为三种情况:
上述的右兄弟结点只是一个特例,左兄弟结点也可以,不过某些地方要做一些小小的调整。
具体算法:待写——
B+树是B树的一种变形树。其性质如下:
例如下图:
代码待学习
B树本质上还是一棵树,且有平衡二叉树的性质,不过是从二叉变成多叉,每个结点中可以有好几个关键字和好几棵,主要用于外查找,也就是在外存中查找。比如磁盘管理系统中的目录管理以及数据库系统中的索引组织等。
B+树不能说是一种特殊的B树,它们两者的性质相差还是比较大的,只能说是B树的一种变形。B+树就在于“+”,结点中增加了相应的一些信息,且B+树的各种限制也更好理解。因为它独特的存储结构,相较于B树,它更适用于文件索引系统。