B树和平衡二叉树稍有不同的是B树属于多叉树又名平衡多路查找树(查找路径不只两个),数据库索引技术里大量使用者B树和B+树的数据结构
B树的意义
-
系统从磁盘读取数据到内存时是以磁盘块(block)为基本单位的,位于同一个磁盘块中的数据会被一次性读取出来,而不是需要什么取什么。此功能叫磁盘预读。
-
二叉树需要加载到内存的,如果二叉树的节点少,没有什么问题,但是如果二叉树的节点很多上亿,由于二叉树每个节点只能存储一个值则会导致在构建二叉树时,需要多次进行i/o操作(海量数据存在数据库或文件中),节点海量,构建二叉树时,速度有影响。同时也会造成二叉树的高度很大,会降低操作速度。而B树通过重新组织节点,降低树的高度,并且减少i/o读写次数来提升效率
-
另外一方面,同样的数据,二叉树的阶数更大,B树更短,这样查找的时候当然B树更具有优势了,效率也就越高。
B树的规则
- 排序方式:所有节点关键字是按递增次序排列,并遵循左小右大原则;
- 子节点数:非叶节点的子节点数>1,且<=M ,且M>=2,空树除外(注:M阶代表一个树节点最多有多少个查找路径,当M=2则是2叉树,M=3则是3叉);
- 关键字数:枝节点的关键字数量大于等于ceil(M/2)-1个且小于等于M-1个(注:ceil()是个朝正无穷方向取整的函数 如ceil(1.1)结果为2);
- 所有叶子节点均在同一层、叶子节点除了包含了关键字和关键字记录的指针外也有指向其子节点的指针只不过其指针地址都为null对应下图最后一层节点的空格子;
最后我们用一个图和一个实际的例子来理解B树(这里为了理解方便我就直接用实际字母的大小来排列C>B>A)
B树的查询流程
如上图,加入我们要查找E,则流程为:
- 获取根节点的关键字进行比较,当前根节点关键字为M,E<M(26个字母顺序),所以往找到指向左边的子节点(二分法规则,左小右大,左边放小于当前节点值的子节点、右边放大于当前节点值的子节点);
- 拿到关键字D和G,D<E<G 所以直接找到D和G中间的节点;
- 拿到E和F,因为E=E 所以直接返回关键字和指针信息(如果树结构里面没有包含所要查找的节点则返回null);
B Tree的插入
- 新添加的元素必定是添加到叶子节点
- 若该节点元素个数小于m-1,直接插入;
- 若该节点元素个数等于m-1,引起节点分裂;以该节点中间元素为分界,将这个节点分为左右两部分,取中间元素(偶数个数,中间两个随机选取)插入到父节点中;
- 重复上面动作,直到所有节点符合B树的规则;最坏的情况一直分裂到根节点,生成新的根节点,高度增加1;
例子
以5节B树为例
5阶B树
2<=根节点子节点个数<=5
3<=内节点子节点个数<=5
1<=根节点元素个数<=4
2<=非根节点元素个数<=4
- 插入8 的时候上溢(>M-1)所以需要分裂 中间节点mid=6上溢到父节点,分裂成左右两个子节点【1 3】 和【7 8】
- 插入 0 5 11 满足B树性质无需调整直接插入
- 插入4 节点元素超出最大数量4,进行分裂,提取中间元素【3】,插入到父节点当中,放入6的前面,对应子节点进行分裂为【0 1】【4 5】
- 继续插入2 15 20 放入最右边无需分裂
- 插入17 的时候产生上溢出,需要上移动中间节点mid=[15] 并分裂【7 8】 【17 20】
- 插入 10 16 25 无需分裂
- 插入 11产生上溢出 中间节点10 上移动到父节点 子节点分裂【7 8】 【11 12】
8. 插入 19 产生上溢,移动中间节点19 放入父节点,分裂成【16 17】 【20 25】,加入父节点后又产生上溢,移动中间节点10 放入新的父节点,子节点分裂为【3 6】 【15 19】
插入代码如下
插入:
private BTreeNode<K,V> insertNode(BTreeNode<K,V> insertNode, Entry<K,V> entry, BTreeNode<K,V> childNode){
var insertIndex=0;//初始化插入位置
var entryKey=insertNode.entryKey;//获得插入节点的key
while (insertIndex<entryKey.size()&&entryKey.get(insertIndex).key.compareTo(entry.key)<0){//找到要插入的位置
insertIndex++;
}
//将节点插入
entryKey.add(insertIndex,entry);
childNode.parent= insertNode;
insertNode.childList.add(insertIndex+1,childNode);
//若插入后节点的度大于树的阶-1,则需要分裂
if (insertNode.entryKey.size()>degree-1){
return splitNode(insertNode);
}
return this;
}
节点分裂
private BTreeNode<K,V> splitNode(BTreeNode<K,V> insertNode){
//找到要分裂的位置,并获取该节点
var middleIndex=degree/2;
var middleEntry=insertNode.entryKey.get(middleIndex);
//设置rNode为新的父节点
var rNode = new BTreeNode<K,V>(degree);
rNode.entryKey= new LinkedList<>(insertNode.entryKey.subList(middleIndex+1, insertNode.entryKey.size()));
rNode.childList= new LinkedList<>(insertNode.childList.subList(middleIndex+1, insertNode.childList.size()));
for(var rChild : rNode.childList) {
rChild.parent = rNode;
}
insertNode.entryKey= new LinkedList<>(insertNode.entryKey.subList(0, middleIndex));
insertNode.childList= new LinkedList<>(insertNode.childList.subList(0, middleIndex+1));
if (insertNode.parent==null){
insertNode.parent= new BTreeNode<>(degree);
insertNode.parent.entryKey.add(middleEntry);
insertNode.parent.childList.add(insertNode);
insertNode.parent.childList.add(rNode);
rNode.parent = insertNode.parent;
return insertNode;
}
return insertNode(insertNode.parent, middleEntry, rNode);
}
B 树的删除
-
如果删除的是叶子节点上的数据,删除之后移动元素保证该叶子节点顺序不变。
1.1 如果此时关键字个数依然满足条件,则删除结束。
1.2 如果此时关键字个数小于条件个数,则需要进行移动。1.2.1 首先看其相邻兄弟节点是否有富余关键字[>ceil(M / 2)-1],如果有,则将父节点的合适的关键字下移到当前要删除字节点内的最小位置,注意此处放入的时候需要保持顺序,然后将富余节点的最左(最右)关键字上移到父节点中,然后删除该关键字。 1.2.2 如果其相邻兄弟节点没有富余关键字了,则需要将该节点和相邻节点进行合并(选择左右没关系,但是要保证顺序的一致就行),移动的规则是将父节点的中间元素(父节点必须在两个需要合并的节点之间)下移到改节点中被删除的关键字处,然后记性合并操作,操作之后可能父节点能满足数目条件,如果不满足的话,仍然再次执行1.2.1 --> 1.2.2的步骤,简要的说就是先看相邻兄弟节点是否富余,富余的话借父节点,不富余的话就合并。
-
如果删除的是非叶子节点上的数据,非叶子节点特殊性在于它存在孩子节点。
2.1 首先将该关键字的后继节点中的最左边上移到该位置(这个最左边是指中序遍历后继节点中最左边的节点),如果后继节点上移一个关键字之后满足数目条件,则结束。
2.2 后继节点数目不满足条件数目了,则需要观察能否进行借的操作,如果不能借,则需要合并,和叶子节点操作类似
关键要领:元素个数小于 (m/2 -1)就合并,大于 (m-1)就分裂
删除小结: 先找到根节点,从根节点往下找要删除的关键字,如果关键字不存在,抛出删除异常;
如果存在,若关键字不是最下层非终端节点(叶子节点的上一层),此时只需要关键字和关键字节点紧邻的右子树中的最小值N互换,然后删除N,
所以已转化为待删除关键字在最下层非终端节点的情况,所以只需讨论关键字在最下层非终端节点的情况。此时分为三种情况:
- 被删除关键字所在的节点关键字数大于等于 ceil(M/2)
- 被删除关键字所在的节点关键字数等于 ceil(M/2)-1,且该节点相邻右兄弟(或左兄弟)中的关键字数大于 ceil(M/2)-1,只需将该兄弟节点中的最小(或最大)关键字上移到 双亲节点中,而将双亲节点中小于(或大于)该上移动关键字的紧邻关键字下移到被删关键字所在的节点中。
- 被删除关键字所在的节点关键字数等于 ceil(M/2)-1,且左右兄弟节点的关键字数都等于 ceil(M/2)-1,假设该节点有右兄弟A,则在删除关键字之后,它所在的节点中剩余的关键字和 孩子引用,加上双亲节点中的指向A的左侧关键字一起,合并到A中去(若没有右兄弟则合并到左兄弟中)。此时双亲节点的关键字数减少了一个,若因此导致其关键字数小于ceil(M/2)-1,则对双亲节点做递归处理。
例子
以5阶B树为例,详细讲解删除的动作;
关键要领,元素个数小于 2(Math.ceil(m/2) -1)就合并,大于4(m-1)就分裂
-
删除0 先从根节点查找找到0,然后删除0 删除后该子节点为2–不会产生下溢(<2)
-
删除4 ,删除4 后该子节点产生下溢需要修复,此时该节点左右子节点均无法借,所以需要与兄弟节点合并(那边有就和那边合并) --这里和左节点合并
1 .下移父节点关键字3 放入该删除节点4 的位置
2 .将左兄弟节点与当前删除节点的子节点合并【1 2 3 5】合并后发现父节点关键字下溢所以需要继续处理,右兄弟关键字不足无法借所以需要与右兄弟合并
- 下移父节点关键字10 放入合适的位置(6的后面)
- 6 所在子节点与右兄弟节点合并【6 10 15 19】
此时树的高度已经降低1层
-
删除 8 删除后该节点下溢,所以需要修复,发现该节点的左兄弟节点节点富余,可以向左兄弟节点借关键字以满足B树性质要求
-
删除 10 发现10,为非叶子节点,则找10的后继节点进行替换,然后删除后继节点即可 10 的后继节点为11
删除后发现12 位置的字节点产生下溢,所以需要修复- 下移父节点关键字11,放入该删除节点的位置
- 检查兄弟节点关键字发现无法借,则需要合并
- 与左兄弟合并(右兄弟存在亦可)
代码实现
public BTreeNode<K,V> delete(K key){
Objects.requireNonNull(key);
var root = this.getRoot();
if (root==null) return this;
var keyNode = root.search(key);
//找到当前要删除的节点
var deletedIndex = keyNode.entryKey.stream().filter(e -> e.key.compareTo(key) == 0).mapToInt(keyNode.entryKey::indexOf).findFirst().orElseThrow(()->new NoSuchElementException("key is not present to delete"));
//找后继
BTreeNode<K,V> successorNode,targetNode=keyNode;
if ((successorNode=keyNode.childList.get(deletedIndex+1)).entryKey.size()>0){
while (successorNode.childList.get(0).entryKey.size()>0){
successorNode=successorNode.childList.get(0);
}
deletedIndex=0;
targetNode.entryKey.remove(deletedIndex);
targetNode.entryKey.add(deletedIndex,successorNode.entryKey.get(0));
targetNode=successorNode;
}
return delete(targetNode, deletedIndex, 0);
}
private void downParent(BTreeNode<K,V> targetNode,int parentIndex,int childIndex, boolean hasLeftChild){
//下移父节点关键字
var downEntry=targetNode.parent.entryKey.remove(parentIndex);
var subChild= targetNode.parent.childList.get(childIndex);
var removeKeyIndex=hasLeftChild?subChild.entryKey.size()-1:0;
//上移动兄弟节点对应的关键字
var upEntry = subChild.entryKey.remove(removeKeyIndex++);
var insetIndex=hasLeftChild?0:targetNode.entryKey.size();
targetNode.entryKey.add(insetIndex ,downEntry);
var removeChildIndex=hasLeftChild?removeKeyIndex:0;
//目标节点加入兄弟节点的删除的关键字对应的孩子节点
var removeChild = subChild.childList.remove(removeChildIndex);
targetNode.childList.add(hasLeftChild?insetIndex:0,removeChild);
removeChild.parent=targetNode;
//父节点加入兄弟节点上移动的关键字
targetNode.parent.entryKey.add(parentIndex,upEntry);
}