B树及其衍生树
B树及其衍生树
一、B树
1. 介绍
B树与其它几种查找树(二叉查找树、AVL树和红黑树)不同,它每个节点最多有N个子节点而不是两个子节点,所以,它的高度是,而不是其它几种树的。
B树是用来为磁盘而设计的数据结构,它区别于针对随机存取的贮存而设计的数据结构。因为磁盘的IO操作比较耗时,大概比主存存取大5个数量级,也就是说,一次磁盘存取大概的时间可以使主存完成大概100000次的存取。
图:B树-每个内节点有1000关键字
上图可以看出,一个B树,每个结点有1000个关键字,高度为2,就能包含10亿个关键字,只有根节点存在主存中,查找一个关键字只需要最多读取两次磁盘就能完成查找。
图:B树的结构图
B树是由有序数组+平衡多叉树组成。
2. B树性质
一棵B树T(根为root[T])具有如下性质(《算法导论》):
(1) 每个结点x有以下域:
l n[x],当前存储在结点x中的关键字数,n[x]个关键字本身,以非降序存放,有key1[x]<=key2[x]<=...<=keyn[x]
l leaf[x],是一个布尔值,表示该结点是否为叶子结点
(2) 每个内结点x还包含n[x]+1个指向子结点的指针c1[x],c2[x],...,cn[x]+1[x],如果是叶子结点,则ci[x]指向空
(3) 各关键字keyi[x]对存储在各子树中的关键字范围加以分隔:如ki为存储在以ci[x]为根的子树中的关键字,则有:
k1<=key1[x]<=k2<=key2[x]<=...<=kn[x]<=keyn[x][x]<=kn[x]+1
(4) 每个叶结点具有相同的深度,即树的高度h
(5) 每个结点能包含的关键字有一个上界和下界。这些界可以称为B树的最小度数的固定整数t>=2来表示
l 每个非根结点必须至少有t-1个关键字,每个非根的内结点至少有t个子女。如果树是非空的,则根结点至少包含一个关键字。
l 每个结点可包含至多2t-1个关键字,每个非根的内结点至多可有2t个子女。如果一个结点是满的,则它有2t-1个关键字。
B树的几项约束(网上对B树性质的解说):
一棵m阶的B树:
l 树中一个节点最多有m棵子树,这也是m阶的含义。
l 若根节点有孩子,至少有两棵子树。
l 除根节点之外的所有非叶子节点至少有[m/2](上界) 棵子树。
l 所有的非终端节点包括的信息有:
n(该节点含有的关键字数目),索引指针A0,关键字K1,索引指针A1,关键字K2...,关键字Kn,索引指针An
也就是说有n个关键字,会有n+1个索引指针,指向其孩子节点。并且,A0所指的孩子节点的关键字均小于K1,A1所指的孩子节点关键字均大于K1。显然n的范围: [m/2]到m-1的闭区间。
l 所有的叶子节点都出现在同一层次上,并且不带信息。即叶子节点不存关键字,是查找失败的节点,实际上不存在,只是一个虚拟的概念。
大体上的说法基本相似,只有对叶结点的看法上有所区别,《算法导论》上的叶结点可以存放关键字,但是没有子结点;而网络上理解的叶结点,是一个类似于红黑树的叶结点那样,不包含关键字,也就更没有子结点了。下面的一些分析都是基于《算法导论》上的说明进行的。
3. B树操作
查找
搜索B树与搜索二叉树很相似,从根节点开始,由于每个结点已经是一个排好序的数组,可以采用顺序查找,或者二分查找等快速查找方法进行结点内的查找,如果没有查找到,则对对应的子结点进行查找,直到查找到或者查找到叶子结点。
伪代码:
BTSearch(T, value) if T is NULL return NULL x <- root[T] while !leaf[x] for i <- 0 to n[x] if value = x[i] return x[i] else if value < x[i] i <- i+1 else break if i != n[x] x = c[i] return NULL
插入
插入操作是由几个操作组成的,先查找到对应的位置,然后在对应的位置进行插入结点,最后查看是否要对该结点进行分裂,如果分裂就继续要对上一层进行查看是否需要分裂。
伪代码:
BTSplit(x, i, y) n[z] <- t - 1 leaf[z] <- leaf[y] for j <- 0 to t - 1 key[z][j] <- key[y][j+t] if !leaf[y] for j <- 0 to t - 1 c[z][j] <- c[y][j+t] n[y] <- t - 1 for j <- n[x]+1 downto i+1 key[x][j+1] <- key[x][j] key[x][i] <- key[y][t] n[x] <- n[x] + 1 return x, y and z BTInsertNode(s, x) for i <- 0 to n[x] if s < x[i] break if i < n[x] for j <- n[x] downto i x[i+1] <- x[i] else x[i+1] <- s n[x] = n[x] + 1 return i BTInsert(value, T) x <- root[T] s <- ALLOC(value) stack st if x = NULL root[T] <- s return while !leaf[x] for i <- 0 to n[x] if s < x[i] st.push(x) st.push(i) x <- c[i] break BTInsertNode(s, x) while n[x] > 2 * t - 1 y <- x i <- st.pop x <- st.pop BTSplit(x, i, y)
说明:分裂函数比较简单,找到分裂的结点及其父结点,以及是的第个结点,然后,将分成两份,第一份的下标,第二份的下标为,第一份为老结点,第二份为新结点,而下标为的结点上升为这两个结点的根节点,插入结点的第个位置。
插入操作是先找到存放结点,如果该树不为空,该结点应该是叶结点(也有可能同时是根节点),然后在该结点内找到对应位置,进行插入,其后的关键字逐个向后移动一个位置。最后判断该结点中的关键字是否超过,如果是,则进行分裂,一旦分裂进行完,就需要迭代判断父结点是否也需要分裂,否则插入操作结束。
删除
删除操作的过程:
(1) 如果关键字在结点中,而且是个叶结点,则从中删除
(2) 如果关键字在结点中,而且是内结点,则操作如下:
(a) 如果结点中前于的子结点包含至少个关键字,则找出在以为根的子树中的前驱结点,迭代的删除,并用代替中的。
(b) 否则,如果结点位于之后的子结点包含至少个关键字,则找出在以为根的子树中的后继结点,迭代的删除,并用代替中的。
(c) 否则,如果和都只有个关键字,则将和中所有的关键字合并进,使失去对和指向的指针,这使包含了个关键字。然后,释放并将从中删除。
(3) 如果关键字不在内结点中,则确定必包含的子树的根。如果只有个关键字,执行步骤(3)-(a)或(3)-(b)以保证我们降至一个包含至少个关键字的结点。然后通过对的某个合适的子结点递归而结束。
(a) 如果只包含个关键字,但它的相邻兄弟包含至少个关键字,则将中的某个关键字降至中,将的相邻左兄弟或右兄弟中的某一个关键字升至,将该兄弟中合适的子女指针移到中,这样使得增加一个额外的关键字。
(b) 如果以及的所有兄弟都包含了个关键字,则将于一个兄弟合并,即将的一个关键字一致新合并的结点,成为该节点的一个中间关键字。
下面根据书上的例子,说明上面的步骤:
图:初始树
图:删除F,步骤1
图:删除M,步骤2a
图:删除G,步骤2c
图:删除D,步骤1和3b(树的高度缩减)
图:删除B,步骤1和3a
删除操作比较麻烦,下面是其伪代码:
BTFindInNode(x, s, c) for i <- 0 to n[x] c <- c[x][i] if x[i] = s return i else if x[i] > s i <- i+1 else return -(i-1) BTRemoveInNode(x, i) t <- x[i] for j <- i to n[x]-1 x[i] <- x[i+1] n[x] = n[x] - 1 delete t BTMerge(x, y, z) i <- n[y] if x != NULL y[i] <- x i <- i + 1 for j <- i to n[z] y[j] <- z[j-i] c[y][j] n[y] <- j delete z BTDelete(T, s) x <- root[T] while x c <- NULL i <- BTFindInNode(x, s, c) if i >= 0 y <- c[x][i] z <- c[x][i+1] if leaf[x] //1 BTRemoveInNode(x, s, i) else while 1 if n[y] >= t x <- y k <- y[n[y]] j <- n[y] if leaf[y] break else y <- c[y][n[y]-1] z <- c[y][n[y]] else if n[z] >= t x <- z k <- z[0] j <- 0 if leaf[z] break y <- c[z][0] z <- c[z][1] else break if n[y] >= t //2a s <- y[n[y]] BTRemoveInNode(y, n[y]) else if n[z] >= t //2b s <- z[0] BTRemoveInNode(z, 0) else //2c BTMerge(NULL, y, z) BTRemoveInNode(x, j) else i <- -i newX <- c[x][i] if n[newX] = t-1 y <- newX z <- newX if i > 0 y <- c[x][i-1] if i < n[x] z <- c[x][i+1] if n[y] >= t or n[z] >= t //3a if n[y] >= t and y != newX for j <- n[newX] downto 0 newX[j+1] <- newX[j] if !leaf[newX] c[newX][j+1] <- c[newX][j] newX[0] <- y[n[y]] n[newX] <- n[newX]+1 if !leaf[newX] c[newX][0] <- c[y][n[y]] BTRemoveInNode(y, 0) else if n[z] >= t and z != newX newX[n[newX]] <- z[0] n[newX] <- n[newX]+1 if !leaf[newX] c[newX][n[newX]] <- c[z][0] BTRemoveInNode(z, 0) else //3b BTMerge(x, y, z) if x = root[T] root[T] <- y x <- newX
二、B+树
1. 介绍
B+树是B树的一种变形树,是为了满足文件系统而产生的。B+树是由有序链表+平衡多叉树组成
2. B+树性质
它与B树的差别在于(m阶B+树和m阶B树):
- 每个关键字对应一棵子树(B树每个关键字对应两棵子树,相邻的两个关键字对应三棵子树)。
- 叶子结点包含了所有的关键字(包括其父结点的关键字),以及叶子结点有指向其兄弟的指针。每个叶子结点是从小到大排好序的,因此通过兄弟指针能获取到整个关键字的顺序链接。
- 非叶子结点虽然也包含部分关键字(对应子结点的最小关键字或最大关键字),但只是为了方便查找到对应的叶子结点,所以非叶子结点完成了索引的功能。
图:B+树结构图
B+树更适合做系统的文件索引和数据库索引的原因:
(1) B+树的磁盘读写代价更低
B+树的内结点没有多余的关键字信息,所占的空间也就比较小,同样一个内结点在B树众占据的空间比在B+树中的要大,也就使在B树中可能需要多次读取才能完成的操作在B+树中只需要读取一次。
(2) B+树的查询效率更稳定
由于只有叶子结点才有关键字信息,而所有叶子结点到根的路径长度是相同的,就保证了每次查询的效率是一样的,也就更稳定了。
3. B+树操作
以下操作都假设在m阶B+树上进行:
查找
查找操作比较简单,由于B+树是由有序链表+平衡多叉树组成,所以有两种查找方式,一种是从平衡二叉树角度查找,即从根节点开始,一直查找到叶子结点;另一种是从有序链表角度查找,即从链表头开始顺序进行查找。
插入
B+树的插入是在叶子结点上进行的,假设要插入关键字a,找到叶子结点后插入a,然后进行以下判定:
(a) 如果插入后结点关键字数目小于等于m并且当前结点是根结点,则算法结束;
(b) 如果插入后结点关键字数目小于等于m并且当前结点是非根结点,则判断若a是新索引值时转步骤(d)后结束,若a不是新索引值则直接结束;
(c) 如果插入后关键字数目大于m(阶数),则结点先分裂成两个结点X和Y,并且他们各自所含的关键字个数分别为:u=大于(m+1)/2的最小整数,v=小于(m+1)/2的最大整数;
由于索引值位于结点的最左端或者最右端,不妨假设索引值位于结点最右端,有如下操作:
如果当前分裂成的X和Y结点原来所属的结点是根结点,则从X和Y中取出索引的关键字,将这两个关键字组成新的根结点,并且这个根结点指向X和Y,算法结束;
如果当前分裂成的X和Y结点原来所属的结点是非根结点,依据假设条件判断,如果a成为Y的新索引值,则得到Y的双亲结点P转步骤(d);
如果a不是Y结点的新索引值,则求出X和Y结点的双亲结点P,然后提取X结点中的新索引值a’,在P中插入关键字a’,从P开始,继续进行插入算法;
(d) 提取结点原来的索引值b,自顶向下,先判断根是否含有b,是则需要先将b替换为a,然后从根结点开始,记录结点地址P,判断P的孩子是否含有索引值b而不含有索引值a,是则先将孩子结点中的b替换为a,然后将P的孩子的地址赋值给P,继续搜索,直到发现P的孩子中已经含有a值时,停止搜索,返回地址P。
删除
B+树的删除也是在叶子结点上进行的,假设要删除关键字a,找到叶子结点后删除a,然后进行以下判定:
(a) 如果当前节点X为根节点,则算法结束;否则到(b)
(b) 如果删除后结点关键字数目大于等于m/2(取大于m/2的最小整数),则判断若a是索引值时转步骤(d)后结束,若a不是索引值则直接结束;
(c) 如果当前结点X关键字数目小于m/2(取大于m/2的最小整数),则取它的兄弟节点Y,进行下面判定(由于索引值位于结点的最左端或者最右端,不妨假设索引值位于结点最右端):
l 如果Y的关键字数目大于m/2(取大于m/2的最小整数),则将Y中的最左关键字移植X中,并更改X在其父结点的索引。
l 如果Y的关键字数目小于等于m/2(取大于m/2的最小整数),则将Y和并到X中,更改X在父结点中的索引,删除Y在父结点对应的索引a’,变为删除a’,将X对应的索引值变为Y的索引值,当前结点变为X的父结点,转到(a)
l 如果Y不存在,如果a是索引值,转到(d);否则算法结束
(d) 提取结点原来的索引值b,自顶向下,先判断根是否含有b,是则需要先将b替换为a,然后从根结点开始,记录结点地址P,判断P的孩子是否含有索引值b而不含有索引值a,是则先将孩子结点中的b替换为a,然后将P的孩子的地址赋值给P,继续搜索,直到发现P的孩子中已经含有a值时,停止搜索,返回地址P。
三、B*树
1. 介绍
B*树是B+树的变体,在B+ 树非根和非叶子结点再增加指向兄弟的指针,且结点的使用率也比B+树高。
B*树是丰满的B+树。
2. B*树性质
B*树定义了非叶子结点关键字个数至少为(2/3)*M,即块的最低使用率为2/3(代替B+树的1/2)。叶子结点和内结点都有指向兄弟的指针。
图:B*树结构图
3. B*树操作
查找
与B+树查找方法相同。
插入
插入本身与B+树相同,区别在于B*树的分裂方式不同:
B+树的分裂:当一个结点满时,分配一个新的结点,并将原结点中1/2的数据复制到新结点,最后在父结点中增加新结点的指针;B+树的分裂只影响原结点和父结点,而不会影响兄弟结点,所以它不需要指向兄弟的指针;
B*树的分裂:当一个结点满时,如果它的下一个兄弟结点未满,那么将一部分数据移到兄弟结点中,再在原结点插入关键字,最后修改父结点中兄弟结点的关键字(因为兄弟结点的关键字范围改变了);如果兄弟也满了,则在原结点与兄弟结点之间增加新结点,并各复制1/3的数据到新结点,最后在父结点增加新结点的指针;
在分裂完成后,需要对到有影响的结点的索引进行更新,也就是查看老的索引是否能匹配操作后的结点,如果不匹配,则找出操作后结点的索引值来替换老的索引值。
删除
删除本身也与B+树相同,区别在于B*树的合并方式不同,区别在于:
B+树的合并:当一个结点中关键字数目小于m/2(取大于m/2的最小整数)时进行合并,将根据兄弟节点的数目决定使用何种合并方式(拆借还是合并)。
B*树的合并:当一个结点中关键字数目小于m2/3(取大于m2/3的最小整数)时进行合并,将根据兄弟节点的数目决定使用何种合并方式(拆借还是合并)。
其它的操作与B+树基本无异。