如果你是一个熟悉 B 树的性质,正在寻找代码如何实现的同学,那这篇文章就是为你准备的
上篇文章分享了 B树的创建--节点的插入,在此基础,再分享一篇关于 B树的内容--节点的删除
节点的删除比插入要更加复杂,有多种不同的情况:
- 删除叶子节点
- 节点关键词数量没有低于下限
- 节点关键词数量低于下限
- 向右兄弟借个关键词
- 右兄弟可以借
- 没有右兄弟,或者右兄弟关键词不够
- 向左兄弟借个关键词
- 左兄弟可以借
- 没有左兄弟,或者左兄弟关键词不够
- 左右兄弟都借不了,那就合并
- 向右兄弟借个关键词
- 删除非叶子节点,用叶子节点替代
捋一遍删除节点的过程
如果是删除非叶子节点 N1,和搜索二叉树一样,删除的节点用中序遍历的直接后驱 N2
替代,即 N2
从树上摘下来放在 N1
的位置 。 这样删除非叶子节点就转换成了删除叶子节点的情况
如果是删除叶子节点 N1
,和搜索二叉树一样,直接从树上摘除就好了。不同的 B树还要检查 关键词的数量是否过少。上篇文章中提到节点关键词的数量不能少于 Math.ceil(level / 2)-1
,如果少于这个数就需要借了。
- 先看看有没有右兄弟,如果有右兄弟
N1R
,看看 N1R的关键词是否大于Math.ceil(level / 2)-1
,如果足够,就可以借了。怎么借?将父节点的一个关键词挪下来,然后把N1R
一个关键词放到父节点上。这样做是为了在调整关键词的位置之后,关键词之间的大小关系依旧符合多叉搜索树。我说的还是有些笼统,可以看下面的动图,会有更直观的感受
- 如果
N1R
的主意打不了,就看看有没有左兄弟N1L
,并且N1L
能不能借,如果能借,就像下面这样:
- 如果左右兄弟都借不了,应该怎么办呢?应该合并!先看有没有右兄弟
N1R
合并,下面是合并的动图:
上面合并过程中,会减少父节点的关键词,导致父节点需要进行借关键词,甚至合并关键词。过程和子节点相同。
- 如果没有右兄弟,就合并左兄弟
上面的动图来自 B站王道考研数据结构,文字解释远不如咸鱼学长讲得好,大家有需求可以移步 B 站 观看视频:【王道计算机考研 数据结构】 7.4_2_B树的插入删除_哔哩哔哩
删除节点的过程都讲完了,情况比较多,但一一分析,还是不难的。
到这里还有一个问题,如果在合并的过程中,父节点原先只有一个节点的节点的话,把父节点的关键词借了,父节点的岂不就没有关键词了?是有这种情况,而且只发生在 B 树根节点。也就是说 B 树高度坍塌只发生在根节点身上。大家可以想一想为什么。
因为只有根节点才允许只有一个关键词。
准备数据
javascript
class BTreeNode {
constructor() {
this.keys = [];
this.children = [];
}
}
class BTree {
constructor(level) {
this.root = new BTreeNode();
this.level = level;
}
insert(value){...}
}
const data = Array(16)
.fill(1)
.map((item, index) => index);
const btree = new BTree(5);
data.forEach((item) => btree.insert(item));
上面代码准备了有一个 16 个节点 B树
具体的
insert
代码,可以看这篇文章:🥳每日一练-B树的创建-JS不难版 - 掘金
删除节点
javascript
class BTree{
//省略其他代码
delete(value) {
this._delete(null, this.root, value, null);
}
/**
* 删除节点
* @param {BTreeNode} parent - 父节点
* @param {BTreeNode} node - 当前节点
* @param {number} value - 要删除的节点的值
* @param {number} index - 当前节点在父节点中的索引
*/
_delete(parent, node, value, index) {
// 如果当前节点是叶子节点
if (node.children.length == 0) {
// 移除要删除的值
node.keys = node.keys.filter((item) => item !== value);
} else {
// 遍历当前节点的子节点
let flag = false;
for (let i = 0; i < node.keys.length; i++) {
// 如果要删除的值等于当前节点的键
if (value == node.keys[i]) {
// 获取子节点中的最小值节点
const minNode = this.getMinNode(node.children[i + 1]);
// 替换要删除的值,并递归删除最小值节点
node.keys.splice(i, 1, minNode);
this._delete(node, node.children[i + 1], minNode, i + 1);
// 标记已删除
flag = true;
break;
} else if (value < node.keys[i]) {
// 递归删除要删除的值
this._delete(node, node.children[i], value, i);
// 标记已删除
flag = true;
break;
}
}
// 如果未找到要删除的值,则删除子节点中的最后一个节点
if (flag == false) {
this._delete(node, node.children.slice(-1)[0], value, node.children.length - 1);
}
}
// 判断当前节点的关键是否达标
this.judgeLess(parent, node, index);
}
getMinNode(node){
}
judgeLess(parent, node, index){
}
delete
是供外部直接调用的方法,接收一个节点的值,表示需要删除该节点。
_delete
则是供内部调用的函数,用来具体实现删除的逻辑。接收四个参数,parent
,node
,value
,index
。
parent
表示node
的父节点。因为节点调整的时候,需要父节点的参与,所以传入了parent
node
表示当前被检查的节点value
表示要删除关键词的值index
表示node
属于parent
的哪个分支
node
可能是叶子节点,也可能不是。两者的删除逻辑不一样,所以需要加个判断。
- 如果是叶子节点,就直接找出对应的关键词,并将其删除。(代码的逻辑有些瑕疵,直接 filter 是建立在删除的节点一定存在于 B树)
- 如果不是叶子节点,
- 先看
node
的关键词中是否有要被删除的,如果有,就用getMinNode
找到直接后驱,用minNode
替代当前的node
,并且删除minNode
,所以代码中继续调用_delete
,并且传入的value
是minNode
。
- 先看
getMinNode
的逻辑是找到直接后驱,过程和搜索二叉树一致就不解释了
javascript
/**
* 获取子节点中的最小值节点
* @param {BTreeNode} node - 子节点
*/
getMinNode(node) {
// 一直向子节点查找,直到找到最小值节点
while (node.children.length !== 0) {
node = node.children[0];
}
// 返回最小值节点
return node.keys[0];
}
- 如果
node
中的关键词没有匹配上,就去node
的分支继续找,在node
的分支上执行删除操作 - 在
_delete
的后面有个judgeLess
函数调用,目的是在删除关键词之后,看看关键词的数量是否达标,如果不达标就采取借关键词的操作,或者合并节点的操作
判断节点的关键词是否过少
javascript
/**
* 判断当前节点是否需要分裂
* @param {BTreeNode} parent - 父节点
* @param {BTreeNode} node - 当前节点
* @param {number} index - 当前节点在父节点中的索引
*/
judgeLess(parent, node, index) {
if (parent == null) return node; // 如果是根节点,直接返回
let haveRightSibling = false,
haveLeftSibling = false;
if (node.keys.length < this.minCount) {
if (index != parent.children.length - 1) {
haveRightSibling = true;
// 存在右兄弟节点
const siblingNodeRight = parent.children[index + 1];
if (siblingNodeRight.keys.length > this.minCount) {
// 向右兄弟借关键词
this.blowFromRight(parent, node, index);
return;
}
} else if (index != 0) {
haveLeftSibling = true;
// 存在左兄弟节点
const siblingNodeLeft = parent.children[index - 1];
if (siblingNodeLeft.keys.length > this.minCount) {
// 从左兄弟借关键词
this.blowFromLeft(parent, node, index);
return;
}
}
if (haveRightSibling) {
// 合并右兄弟节点
this.combineRightSibling(parent, node, index);
} else {
// 合并左兄弟节点
this.combineLeftSibling(parent, node, index);
}
}
}
/**
*
* @param {BTreeNode} parent
* @param {BTreeNode} node
* @param {number} value
*/
blowFromRight(parent, node, index) {
const rightSibling = parent.children[index + 1];
const rightFirstKey = rightSibling.keys.shift();
const rightFirstChildren = rightSibling.children.shift();
node.keys.push(parent.keys[index]);
rightFirstChildren && node.children.push(rightFirstChildren);
parent.keys.splice(index, 1, rightFirstKey);
}
/**
*
* @param {BTreeNode} parent
* @param {BTreeNode} node
* @param {number} value
*/
blowFromLeft(parent, node, index) {
const leftSibling = parent.children[index - 1];
const leftLastKey = leftSibling.keys.pop();
const leftLastChildren = leftSibling.children.pop();
node.keys.unshift(parent.keys[index - 1]);
leftLastChildren && node.children.unshift(leftLastChildren);
parent.keys.splice(index - 1, 1, leftLastKey);
}
/**
*
* @param {BTreeNode} parent
* @param {BTreeNode} node
* @param {number} value
*/
combineRightSibling(parent, node, index) {
const rightSibling = parent.children[index + 1];
node.keys.push(parent.keys[index], ...rightSibling.keys);
node.children.push(...rightSibling.children);
if (parent.keys.length == 1) {
this.root = node;
return;
}
parent.children.splice(index + 1, 1);
parent.keys.splice(index, 1);
}
/**
*
* @param {BTreeNode} parent
* @param {BTreeNode} node
* @param {number} value
*/
combineLeftSibling(parent, node, index) {
const leftSibling = parent.children[index - 1];
node.keys.unshift(...leftSibling.keys, parent.keys[index - 1]);
node.children.unshift(...leftSibling.children);
if (parent.keys.length == 1) {
this.root = node;
return;
}
parent.children.splice(index - 1, 1);
parent.keys.splice(index - 1, 1);
}
judgeLess
一开始判断如果是根节点,就直接返回,不做检查。因为根节点关键词永远不会过少,可能还会因为 parent==null
的特殊情况把代码搞复杂。
judgeLess
的逻辑和上文说的一致,先看有没有右兄弟,能不能借;再看有没有左兄弟,能不能借;如果都借不到,就看能不能和并右兄弟,否则合并左兄弟。
一个非根节点,左兄弟和右兄弟必有其中一个。所以上面一轮检查,肯定能解决关键词过少的问题。
剩下的四个函数blowFromRight
,blowFromLeft
,combineRightSibling
,combineLeftSibling
分别是调整的时候遇到的四种不同的情况,需要结合上面的动图才能理解。
其中的parent.keys.length == 1
判断,是看 parent
是不是根节点,如果是根节点, 那么合并节点必然会导致高度的坍塌,并且原先的 parent
需要摘出。所以就将 this.root
指向了新的节点 node
,然后直接返回。
代码测试
javascript
console.log(printTree(btree.root));
btree.delete(12);
res = "";
console.log(printTree(btree.root));
先打印出 B树原本的结构,然后删除关键词 12,再打印出 B 树的结构
这里用网站的结构图辅助证明代码的正确性:
这是网站的网址:B-Tree Visualization
再删除关键词 10
javascript
btree.delete(10);
res = "";
console.log(printTree(btree.root));
删除节点 10,发生了节点调整,并且右节点含有三个关键词,可以借给左节点。
再删除关键词 1
javascript
btree.delete(1);
res = "";
console.log(printTree(btree.root));
没问题,都和网站保持一致
注:代码和网站的策略有不一致的地方。
- 网站优先合并左节点
- 在删除非叶节点的时候,网站会找直接前驱,代码中找的是直接后驱
策略不同并不会影响代码的有效性,但测试过程会有出入,请悉知。
总结:
这篇文章分享了 如何删除 B 树的节点。节点的删除很简单,但节点的调整很麻烦。好在有 B站的王道数据结构视频和我的代码,为大家的学习保驾护航。一切都可以搞定😄
你觉得这篇文章怎么样?喜欢就点赞+关注吧