B树原理及Go实践
一、相关知识
B树的特点
B树,又称为多路平衡查找树,B树中所有节点的孩子个数的最大值称为B树的阶,通常用m表示。一颗m阶B树或为空树,或为满足如下特性的m叉树:
-
树中每个节点之多有m棵子树,即至多含有m-1个关键字。
-
若根节点不是终端节点,则至少有两棵子树。
-
除根节点外的所有非叶节点至少有 ⌈ m / 2 ⌉ \lceil m/2 \rceil ⌈m/2⌉棵子树,即至少含有 ⌈ m / 2 ⌉ − 1 \lceil m/2 \rceil -1 ⌈m/2⌉−1个关键字。
-
所有非叶子系欸但的结构如下:
n P0 K1 P1 K2 … Kn Pn 其中, K i ( i = 1 , 2 , . . . , n ) K_{i}(i = 1,2,...,n) Ki(i=1,2,...,n)为指向子树根节点的指针,且指针 P i − 1 P_{i-1} Pi−1所指子树中所有节点的关键字均小于 K i K_{i} Ki, P i P_{i} Pi所指子树中所有节点的关键字均大于 K − i K-{i} K−i, n ( ⌈ m / 2 ⌉ − 1 < = n < = m − 1 ) n(\lceil m/2 \rceil - 1 <= n <= m - 1) n(⌈m/2⌉−1<=n<=m−1)为节点中关键字的个数。
-
所有叶节点都出现在同一层次上,并且不带信息(可以视为外部节点或类似于折半查找判定树的查找失败节点,实际上这些节点不存在,指向这些节点的空指针为空).
B树是所有节点的平衡因子均等于0的多路平衡查找树.
如上图所示的B树种所有节点的最大孩子数m=5,因此它是一棵5阶B树,在m阶B树种节点最多可以有m个孩子.可以借助该实例来分析上述性质.
我们可以简化理解B树.二叉树应该算是B树的特殊化(当阶数为1时).不同的是B树的节点种中放的是一组数据(也可以说是关键字,有序排列),而节点的子树可以理解为从节点两个关键字间生长出来(包含两侧)且子树上的所有数值都位于这两个关键字的区间内.
B树的高度
B树中的大部分操作所需的磁盘存取次数与B树的高度成正比。
下面来分析B树在不同情况下的高度。当然,首先应该明确B树的高度不包括最后的不带任何信息的叶子节点所处的那一层(有些地方对B树的高度定义中,包含最后的那一层)。
若 n > = 1 n >= 1 n>=1,则对任意一棵包含n个关键字、高度为h、阶数为m的B树:
-
因为B树中每个节点最多有m棵子树,m-1个关键字,所以在一棵高度为h的m阶B树种关键字的个数应满足 n < = ( m − 1 ) ( 1 + m + m 2 + . . . + m h − 1 ) = m k − 1 n<=(m-1)(1+m+m^2+...+m^{h-1}) = m^k - 1 n<=(m−1)(1+m+m2+...+mh−1)=mk−1,因此有
h > = l o g m ( n + 1 ) h >= log_{m}(n+1) h>=logm(n+1) -
若让每个节点中的关键字个数达到最少,则容纳同样多关键字的B树的高度达到最大。由B树的定义:第一层至少由1个节点;第二层至少有2个节点;除根节点外的每个非终端节点至少有 ⌈ m / 2 ⌉ \lceil m/2 \rceil ⌈m/2⌉棵子树,则第三层至少有 2 ⌈ m / 2 ⌉ 2\lceil m/2 \rceil 2⌈m/2⌉个节点…第 h + 1 h+1 h+1层至少有$ 2(\lceil m/2 \rceil)^{h-1} 个节点,注意到第 个节点,注意到第 个节点,注意到第h+1 层是不包含任意信息的叶节点。对于关键字个数为 n 的 B 树,叶节点即查找不成功的节点为 n + ! ,由此有 层是不包含任意信息的叶节点。对于关键字个数为n的B树,叶节点即查找不成功的节点为n+!,由此有 层是不包含任意信息的叶节点。对于关键字个数为n的B树,叶节点即查找不成功的节点为n+!,由此有n+1 >= 2(\lceil m/2 \rceil)^{h-1} $ 即 h < = l o g ⌈ m / 2 ⌉ ( ( n + 1 ) / 2 ) + 1 h <= log_{\lceil m/2 \rceil}((n+1)/2) + 1 h<=log⌈m/2⌉((n+1)/2)+1
二、基本操作
B树的查找
在B树上进行查找与二叉查找树是很像的,不同的是每个节点都是多个关键字的有序列表,在每个节点上所作的不是两路分支决定,而是根据该节点的子树所做的多路分支决定。
B树的查找包含两个基本操作:① 在B树中找节点; ② 在节点内找关键字。由于B树常存储在磁盘上,因此前一个查找操作是在磁盘上进行的,而后一个查找操作是在内存中进行的,即在找到目标节点后,先将节点信息读入内存,然后在节点内采用顺序查找法或折半查找法。
B树的插入
与二叉树的插入操作相比,B树的插入操作要复杂得多。在二叉查找树中,仅需查找到需插入的中断节点的位置。但是,在B树种找到插入的位置后,并不能简单地将其添加到终端节点种,因为直接插入将可能导致B树的特征被破坏。将关键字key插入B树的过程如下:
- 定位 利用前述的B树查找算法,找出插入该关键字的最低层中的某个非叶结点(在B树中查找Key时,会找到表示查找失败的叶结点,这样就确定了最底层非叶结点的插入位置。
注意:插入的位置一定是最低层中的某个非叶节点
)。 - 插入 在B树种,每个非失败节点的关键字个数都在区间 [ ⌈ m / 2 ⌉ − 1 , m − 1 ] [\lceil m/2 \rceil - 1, m-1] [⌈m/2⌉−1,m−1]内。插入后的结点关键字个数小于m。可以直接插入;插入后检查被插入结点内关键字的个数,当插入后的结点关键字数大于m-1时,必须对结点进行分裂。
分裂的方法是:取一个新结点,在插入key后的原结点,从中间位置 ( ⌈ m / 2 ⌉ ) (\lceil m/2 \rceil) (⌈m/2⌉)将其中的关键字分为两部分,左部分包含的关键字放在原结点中,右部分包含的关键字放到新结点中。中间位置 ( ⌈ m / 2 ⌉ ) (\lceil m/2 \rceil) (⌈m/2⌉)的结点插入原结点的父结点中。若此时导致其父结点的关键字个数叶超过了上限,则继续进行这种分裂操作,直至这个过程传到根结点为止,进而导致B树高度增1.
插入分裂的示例
对于 m = 3 m=3 m=3的B树,所有结点中最多有 m − 1 = 2 m-1=2 m−1=2个关键字,若某结点中已有两个关键字,则结点已满,如下图(a)所示。插入一个关键字60后,结点内的关键字个数超过了m-1,如下图(b)所示,此时必须进行结点分裂,分裂的结果如图©所示。
处理插入部分的代码
package main
import (
"errors"
)
// 找到比key大的数中的最小数的位置
func FindIndex(arr []*Item, key int) (index int) {
for index = 0; len(arr) > index && arr[index].key < key; index++ {
}
return
}
// 处理上溢出 分裂
func (N *BNode) SplitNode(t *BTree) {
var p *BNode
maxCap := t.m
currentNode := N
for len(currentNode.items) >= maxCap {
insertIndex := 0
p = currentNode.parent
mid := len(N.items) / 2
// 若当前节点为根节点时 需创建新的根节点
if p == nil {
newRoot := InitBNode(nil, t.m)
t.root, p = newRoot, newRoot
} else {
// 找到在父节点插入的位置
insertIndex = FindIndex(p.items, currentNode.items[mid].key)
}
p.items = append(p.items[:insertIndex], append([]*Item{currentNode.items[mid]}, p.items[insertIndex:]...)...)
// 更新父节点的子树
lNode := &BNode{
parent: p,
items: currentNode.items[:mid],
children: make([]*BNode, 0, maxCap),
}
rNode := &BNode{
parent: p,
items: currentNode.items[mid+1:],
children: make([]*BNode, 0, maxCap),
}
if mid < len(currentNode.children) {
// 更新分裂后节点对应父节点的指向
for _, child := range currentNode.children[:mid+1] {
child.parent = lNode
}
for _, child := range currentNode.children[mid+1:] {
child.parent = rNode
}
lNode.children = currentNode.children[:mid+1]
rNode.children = currentNode.children[mid+1:]
}
// 更新插入后的叶子节点
if insertIndex < len(p.children) {
p.children = append(p.children[:insertIndex],
append([]*BNode{lNode, rNode}, p.children[insertIndex+1:]...)...)
} else {
p.children = append(p.children[:insertIndex], []*BNode{lNode, rNode}...)
}
currentNode = p
}
}
// 处理向B-Tree中插入数据
func BTNodeInsert(t *BTree, item *Item) (err error) {
N := t.root
// 定位
TNode, index, err := N.FindNode(item.key)
if err != nil {
return
} else if index < len(TNode.items) && TNode.items[index].key == item.key {
return errors.New("key can't be repeat")
}
// 插入
if index < len(TNode.items) {
TNode.items = append(TNode.items[:index], append([]*Item{item}, TNode.items[index:]...)...)
} else {
TNode.items = append(TNode.items, item)
}
TNode.SplitNode(t)
return
}
B树的删除
B树中的删除操作与插入操作类似,但要稍微复杂一些,即要使得删除后的结点中的关键字个数 > ⌈ m / 2 ⌉ − 1 >\lceil m/2 \rceil - 1 >⌈m/2⌉−1,因此设计结点的"合并"问题
当被删除关键字K不在终端结点(最低层非叶子结点)中时,可以用k的前驱(或后继)k’来替代k,然后再相应的结点中删除k’,关键字k’必定落在某个终端结点中,则转换成了被删关键字在终端结点中的情形。在下图的4阶B树种,删除关键字80,用其前驱78替代,然后在终端结点种删除78,因此只需讨论删除终端结点种关键字的情形。
当被删关键字在终端结点(最低层非叶子结点)中时,有下面三种情况:
- 直接删除关键字 若被删除关键字所在的结点的关键字个数 > ⌈ m / 2 ⌉ >\lceil m/2 \rceil >⌈m/2⌉,则表明删除该结点后仍然满足B树的定义,则直接删去该关键字。
- 兄弟够借 若被删除关键字所在结点删除前的关键字个数 = ⌈ m / 2 ⌉ − 1 =\lceil m/2 \rceil - 1 =⌈m/2⌉−1,且与此结点相邻的右(左)兄弟结点的关键字个数 > = ⌈ m / 2 ⌉ >= \lceil m/2 \rceil >=⌈m/2⌉,则需要调整该结点、右(左)兄弟结点及其双亲结点(父子换位法),以达到新的平衡。在下图(a)中删除4阶B树的关键字65,右兄弟关键字个数 > = ⌈ m / 2 ⌉ = 2 >= \lceil m/2 \rceil=2 >=⌈m/2⌉=2,将71取代原65的位置,将74调整到71的位置。
- 兄弟不够借 若被删除关键字所在结点删除前的关键字个数 = ⌈ m / 2 ⌉ − 1 =\lceil m/2 \rceil - 1 =⌈m/2⌉−1,且此时与该结点相邻的左、右兄弟结点的关键字个数均 = ⌈ m / 2 ⌉ − 1 =\lceil m/2 \rceil - 1 =⌈m/2⌉−1,则将关键字删除后与左(右)兄弟结点及双亲中的关键字进行合并。下图(b)中删除4阶B树的关键字5,它及其右兄弟结点的关键字个数 = ⌈ m / 2 ⌉ − 1 = 1 =\lceil m/2 \rceil - 1 = 1 =⌈m/2⌉−1=1, 故在5删除后将60合并到65结点中。
在合并的过程中,双亲结点中的关键字个数会减1.若其双亲结点是根结点且关键字个数减少至0(根结点关键字个数为1时,有2棵子树),则直接将根结点删除,合并后的新结点成为根;若双亲结点不是根结点,且关键字个数减少到 ⌈ m / 2 ⌉ − 2 \lceil m/2 \rceil - 2 ⌈m/2⌉−2,则又要与它自己的兄弟结点进行调整或合并操作,并重复上述步骤,直至符合B树的要求为止。
处理删除部分代码
实现过程:
- 定位结点位置
- 删除数据,向前驱结点借用(递归借用)
- 由于上一步并没有考虑到前驱结点是否可被借用,在这步从最后借用的前驱结点不断地通过向兄弟节点借用合并以处理不满足要求的节点
package main
// 处理节点的下溢
// 传入结点的最小限制
func (N *BNode) SolveUnderFlow(t *BTree, maxCap int) {
var placeInFatherNode = 0
p := N.parent
// 当前结点为根节点 且无数据
if p == nil || len(N.items) >= maxCap/2 {
if len(N.items) == 0 {
t.root = N.children[0]
}
return
}
// 找到节点在兄弟中的位置
for index, child := range p.children {
if child == N {
placeInFatherNode = index
break
}
}
// 旋转
if placeInFatherNode > 0 {
// 向左兄弟借
lNode := p.children[placeInFatherNode]
if len(lNode.items) > maxCap/2 {
templateItem := lNode.items[len(lNode.items)-1]
// 进行右旋
N.items = append([]*Item{p.items[placeInFatherNode]}, N.items...)
p.items[placeInFatherNode] = templateItem
lNode.items = lNode.items[:len(lNode.items)-1]
// 修改旋转后结点的父结点的指向
lNode.children[len(lNode.children)-1].parent = N
N.children = append([]*BNode{lNode.children[len(lNode.children)-1]}, N.children...)
lNode.children = lNode.children[:len(lNode.children)-1]
}
}
if placeInFatherNode < len(p.items) {
// 向右兄弟借
rNode := p.children[placeInFatherNode+1]
if len(rNode.items) > maxCap/2 {
templateNode := rNode.items[0]
// 进行左旋
N.items = append(N.items, templateNode)
p.items[placeInFatherNode] = templateNode
rNode.items = rNode.items[1:]
rNode.children[0].parent = N
N.children = append(N.children, rNode.children[0])
rNode.children = rNode.children[1:]
}
}
//合并
if placeInFatherNode > 0 {
merge(p, p.children[placeInFatherNode-1], N, placeInFatherNode-1)
} else {
merge(p, N, p.children[placeInFatherNode+1], placeInFatherNode)
}
p.SolveUnderFlow(t, maxCap)
}
// 跟前驱交换数据
// ``return: 最后抽取结点``
func (N *BNode) predecessor(pos int) (pred *BNode) {
pred = N.children[pos]
for !pred.IsLeaf() {
pred = pred.children[len(pred.children)-1]
}
swapIndex := len(pred.items) - 1
// 前驱结点删除已经交换的数据项
N.items[pos] = pred.items[swapIndex]
pred.items = pred.items[:swapIndex]
return pred
}
// 从树种删除元素
func DeleteItem(t *BTree, key int) (item *Item) {
TNode, index, err := t.root.FindNode(key)
// 找到该结点就继续, 否则退出
if err == nil && index < len(TNode.items) && key == TNode.items[index].key {
item = TNode.items[index]
} else {
return
}
isLeaf := TNode.IsLeaf()
// 为最低层叶子节点的时候直接删除
if isLeaf {
TNode.items = append(TNode.items[:index], TNode.items[index+1:]...)
} else {
// 若不是叶子结点则不断地从前驱结点抽取数据
TNode = TNode.predecessor(index)
}
//
TNode.SolveUnderFlow(t, t.m)
return
}
小结
本文介绍了B树的基本操作。因为网上没怎么看到完整的实现代码,感觉逻辑是清晰明了的但是实现其中所有的细节并跑通感觉还是挺复杂的。
学完之后感觉可以有两个发散性方向:
- 这些数据结构往往都是应用的底层核心部分。若要使之能适用,我们还需要关注更多的细节。比如实现一个真正可用的数据库,还需要考虑锁、事务…… 不考虑额外功能,我们还可以考虑实现的数据结构是否可并发,代码的结构是否能够优化。
- ① 学习B树的优化B+树 ②与其他数据结构进行组合,以达到更优化的数据结构。例如与散列表进行结合模拟实现MySql的底层数据结构
参考
- 上文参考 《2022年数据结构考研复习指导》.
- B树详解与实现 - 知乎 (zhihu.com)