如果你是一个熟悉 B 树的性质,正在寻找代码如何实现的同学,那这篇文章就是为你准备的
这篇文章来分享一个复杂度稍弱于红黑树
难度的数据结构--B 树
什么是 B 树?
简单来说,B树是一个特殊的搜索多叉树。相比于搜索二叉树,搜索多叉树也满足左节点 < 根节点 < 右节
这样的性质,与之不同的是搜索多叉树有多个分支,根节点的关键词也可以有多个,就像下图所示。
相对于普通的搜索树,B树的要求会更加严格。下面是 B树性质的介绍:
在这样的严格要求之下,对于 B树就可以推出它的核心特性:
上面的截图均来自 B站的王道数据结构视频课: 王道-7.4_2_B树的插入删除
假设一个 B树的阶是 5,那么根节点的关键词个数范围就是[1, m-1]
, 非根节点的关键词个数的范围是 [2, 4]
,任意非叶节点的分支个数的范围是 [3, 5]
。
并且所有的子树的高度都相同,这个比 AVL 平衡二叉树的要求更加严格。一颗 AVL 只要求左右子树的要读差不超过 1 。不过这样的严格要求也为代码的编写带来了方便。
准备数据
javascript
class BTreeNode {
constructor() {
this.keys = [];
this.children = [];
}
}
class BTree {
constructor(level) {
this.root = new BTreeNode();
this.level = level;
}
insert(){}
}
const data = Array(9)
.fill(1)
.map((item, index) => index);
const btree = new BTree(5);
data.forEach((item) => btree.insert(item));
先定义了 B树节点的数据结构,其中有 keys,用来存储节点的关键词;children 用来储存节点的分支。
然后定义了 BTree 的结构,用来构建 B树。
接下来就是实现 insert 方法了
创建 B树
javascript
/**
* 向 B 树中插入一个新值。
* @param {*} value - 要插入到 B 树中的新值。
*/
insert(value) {
// 调用私有方法 _insert,传入根节点和要插入的值。
this._insert(null, this.root, value);
}
/**
* 私有方法,用于向 B 树中插入一个新值。
* @param {BTreeNode} parent - 当前插入节点的孩子节点的父节点。
* @param {BTreeNode} node - 当前插入节点。
* @param {*} value - 要插入到 B 树中的新值。
*/
_insert(parent, node, value) {
// 如果当前节点的子节点数为 0,将新值添加为新的键。
if (node.children.length == 0) {
// 将新值添加为新的键。
this.addKeys(node, value);
} else {
// 遍历当前节点的子节点。
let flag = -1;
for (let i = 0; i < node.keys.length; i++) {
// 如果新值小于或等于当前键,将新值插入到对应子节点的键中。
if (value <= node.keys[i]) {
this._insert(node, node.children[i], value);
flag = i;
break;
}
}
// 如果新值大于所有键,将其插入到子节点数组中的最后一个元素。
if (flag == -1) {
this._insert(node, node.children.slice(-1)[0], value);
}
}
// 判断节点数量是否过多
this.judgeOver();
}
judgeOver(){
...
}
/**
* Adds a new key to the current node.
* @param {BTreeNode} node - The current node.
* @param {*} value - The new key to be added.
*/
addKeys(node, value) {
...
}
上面的代码比较多,一起来分析分析
首先 insert
方法是提供外界调用的方法,接收一个节点的值。insert
内部调用 _insert
函数,实现节点的插入。
_insert
函数实现插入的过程和排序二叉树的类似,如果小于根节点,就往左节点插入;如果大于根节点,就往右节点插入。我这里支持相同节点的重复插入,所以插入的节点等于当前节点,也往左节点插入。
与排序二叉树不同的是,B树有多个关键词,也有多个分支,所以需要与各个关键词比较,才能知道要往哪个分支继续插入。代码中设置了 flag
变量,用来监控是否大于所有的关键词,如果 flag==-1
,那就将 value
插入到最后一个分支中。
继续往子节点插入是通过递归调用_insert
方法实现的,非常方便。等到了叶子节点,也就是node.children.length == 0
,就不能再往下了递归了。就在叶子节点的关键词中找个合适位置插入。
合适的位置就是使插入之后,叶子节点关键词依旧是有序的,即从左往右,依次增大。插入的逻辑是 addKeys
实现的,相当于一个插入排序的算法:
addKeys
javascript
/**
* 向当前节点中添加一个新的键。
* @param {BTreeNode} node - 当前节点。
* @param {number} value - 要添加的新键。
*/
addKeys(node, value) {
// 遍历当前节点的键数组,查找插入新键的位置。
for (let i = 0; i < node.keys.length; i++) {
// 如果新键小于或等于当前键,将新键添加到当前键数组中。
if (value <= node.keys[i]) {
// 将新键添加到当前键数组中。
node.keys.splice(i, 0, value);
return;
}
}
// 如果新键大于所有键,将新键添加到子节点数组中的最后一个元素。
node.keys.push(value);
}
在结束了插入节点的工作后,就要检查叶子节点是否超标了。上文提到,节点关键词的数量最多是 m-1
,即比阶数小一。实现检查的逻辑是 judgeOver
实现的
judgeOver
javascript
/**
* 检查当前节点是否有过多键。如果是,则分裂节点。
* @param {BTreeNode} parent - 当前节点的父节点。
* @param {BTreeNode} node - 当前节点。
*/
judgeOver(parent, node) {
// 如果当前节点的键数超过最大允许的节点数,将节点分裂。
if (node.keys.length >= this.level) {
// 创建一个新的节点并将一些键和子节点移动到新节点中。
this.splitNode(parent, node);
}
}
/**
* 分裂一个节点,创建一个新的节点并将一些键和子节点移动到新节点中。
* @param {BTreeNode} parent - 当前节点的父节点。
* @param {BTreeNode} node - 当前节点。
* @returns {BTreeNode} - 新节点的父节点。
*/
splitNode(parent, node) {
...
}
如果node.keys.length >= this.level
就说明关键词多了,必须要差分节点,将节点的关键一分为二。
如何拆分呢?
将节点的关键词从中间破开,分成三份,左边 [0~mid-1)
, mid-1
, (mid-1, level]
。mid
就是Math.ceil(level/ 2) - 1
,翻译一下:阶数除以 2,向上取整,然后减一。
左边的关键词和右边的关键词各生成一个节点,中间的关键词上升到父节点的位置。除此之外,父节点还多了一个指向右边新节点的分支。
这就是拆分的过程。很简单吧
这个图也是从王道考研数据结构视频课中截取的,咸鱼学长可太厉害了,快去给他打 call 吧 7.4_2_B树的插入删除
splitNode
splitCode 代码实现:
javascript
/**
* 分裂一个节点,创建一个新的节点并将一些键和子节点移动到新节点中。
* @param {BTreeNode} parent - 当前节点的父节点。
* @param {BTreeNode} node - 当前节点。
* @returns {BTreeNode} - 新节点的父节点。
*/
splitNode(parent, node) {
// 如果父节点为空,则创建一个新的父节点。
if (parent == null) {
parent = new BTreeNode();
this.root = parent;
}
// 计算分割键的索引。
const mid = Math.ceil(node.keys.length / 2) - 1;
// 将分割键添加到父节点中。
this.addKeys(parent, node.keys[mid]);
// 创建一个新的节点并将分割后的键和子节点移动到新节点中。
const rightKeys = node.keys.slice(mid + 1);
const rightChildren = node.children.slice(mid + 1);
const newLeaf = new BTreeNode();
newLeaf.keys = rightKeys;
newLeaf.children = rightChildren;
// 如果父节点没有子节点,将新节点添加为父节点的子节点。
if (parent.children.length == 0) {
parent.children.push(node, newLeaf);
} else {
// 否则,将新节点添加为父节点的子节点。
parent.children.push(newLeaf);
}
// 从当前节点中移除分割键和子节点。
node.keys = node.keys.slice(0, mid);
node.children = node.children.slice(0, mid + 1);
}
代码不难,就是按照刚才讲的。将 node 一分为三,一左,一父,一右。
方法开头的判断parent == null
,是为了拆分根节点做处理的。如果 node
为根节点,那么 parent
就一定为 null
,所以需要创建一个父节点,并将 root
指向 parent
。
拆分节点会增加父节点的关键词,所以检查完当前节点后,还需要检查父节点是否 Over。怎么实现呢?其实递归的_insert
已经实现了。当子节点 judgeOver
之后,就会回到父节点的_insert
函数,从而对父节点进行judgeOver
。这就是递归的好处
完整代码
代码讲完了,来看看完整代码:
javascript
class BTree {
constructor(level) {
this.root = new BTreeNode();
this.level = level;
}
/**
* 向 B 树中插入一个新值。
* @param {*} value - 要插入到 B 树中的新值。
*/
insert(value) {
// 调用私有方法 _insert,传入根节点和要插入的值。
this._insert(null, this.root, value);
}
/**
* 私有方法,用于向 B 树中插入一个新值。
* @param {BTreeNode} parent - 当前插入节点的孩子节点的父节点。
* @param {BTreeNode} node - 当前插入节点。
* @param {*} value - 要插入到 B 树中的新值。
*/
_insert(parent, node, value) {
// 如果当前节点的子节点数为 0,将新值添加为新的键。
if (node.children.length == 0) {
// 将新值添加为新的键。
this.addKeys(node, value);
} else {
// 遍历当前节点的子节点。
let flag = -1;
for (let i = 0; i < node.keys.length; i++) {
// 如果新值小于或等于当前键,将新值插入到对应子节点的键中。
if (value <= node.keys[i]) {
this._insert(node, node.children[i], value);
flag = i;
break;
}
}
// 如果新值大于所有键,将其插入到子节点数组中的最后一个元素。
if (flag == -1) {
this._insert(node, node.children.slice(-1)[0], value);
}
}
// 如果当前节点的键数超过最大允许的节点数,将节点分裂。
this.judgeOver(parent, node);
}
/**
* 检查当前节点是否有过多键。如果是,则分裂节点。
* @param {BTreeNode} parent - 当前节点的父节点。
* @param {BTreeNode} node - 当前节点。
*/
judgeOver(parent, node) {
// 如果当前节点的键数超过最大允许的节点数,将节点分裂。
if (node.keys.length >= this.level) {
// 创建一个新的节点并将一些键和子节点移动到新节点中。
this.splitNode(parent, node);
}
}
/**
* 分裂一个节点,创建一个新的节点并将一些键和子节点移动到新节点中。
* @param {BTreeNode} parent - 当前节点的父节点。
* @param {BTreeNode} node - 当前节点。
* @returns {BTreeNode} - 新节点的父节点。
*/
splitNode(parent, node) {
// 如果父节点为空,则创建一个新的父节点。
if (parent == null) {
parent = new BTreeNode();
this.root = parent;
}
// 计算分割键的索引。
const mid = Math.ceil(node.keys.length / 2) - 1;
// 将分割键添加到父节点中。
this.addKeys(parent, node.keys[mid]);
// 创建一个新的节点并将分割后的键和子节点移动到新节点中。
const rightKeys = node.keys.slice(mid + 1);
const rightChildren = node.children.slice(mid + 1);
const newLeaf = new BTreeNode();
newLeaf.keys = rightKeys;
newLeaf.children = rightChildren;
// 如果父节点没有子节点,将新节点添加为父节点的子节点。
if (parent.children.length == 0) {
parent.children.push(node, newLeaf);
} else {
// 否则,将新节点添加为父节点的子节点。
parent.children.push(newLeaf);
}
// 从当前节点中移除分割键和子节点。
node.keys = node.keys.slice(0, mid);
node.children = node.children.slice(0, mid + 1);
}
/**
* 向当前节点中添加一个新的键。
* @param {BTreeNode} node - 当前节点。
* @param {*} value - 要添加的新键。
*/
addKeys(node, value) {
// 遍历当前节点的键数组,查找插入新键的位置。
for (let i = 0; i < node.keys.length; i++) {
// 如果新键小于或等于当前键,将新键添加到当前键数组中。
if (value <= node.keys[i]) {
// 将新键添加到当前键数组中。
node.keys.splice(i, 0, value);
return;
}
}
// 如果新键大于所有键,将新键添加到子节点数组中的最后一个元素。
node.keys.push(value);
}
}
测试一下:
javascript
const data = Array(9)
.fill(1)
.map((item, index) => index);
const btree = new BTree(5);
data.forEach((item) => btree.insert(item));
创建了一个从 0 到 8 的数据,然后将这 9 个数字依次插入到 B树中,将 B树打印出来看看:
javascript
let res = "";
const printTree = (data, deeps = [1]) => {
if (!data) return res;
let space = deeps
.slice(0, -1)
.map((item) => {
return item == 1 ? "|\t\t" : "\t\t";
})
.join("");
space += "|__";
const content = data.keys.join(", ");
res = res + space + content + "\n";
if (!data) return res;
data.children.forEach((item, index) => {
printTree(item, [...deeps, index == data.children.length - 1 ? 0 : 1]);
});
return res;
};
printTree(btree.root);
console.log(res);
printTree
是一个将 B 树结构在控制台以树形结构输出的函数,下面是打印结果:
确实满足 B树的性质,但打印结果也不能说明一切,来个更具说服力的证据
这是一个线上的数据结构网站,我依次插入了 9 个数字,B树的结构和我控制台打印出来的结构一模一样。
这是网址,感兴趣的网页可以去看看:B-Tree Visualization
再来看几组:
完美,一摸一样,代码完全正确🙆
总结:
这篇文章分享了一个比较有难度的数据结构--B树
。
如果你是一个熟悉 B 树的性质,正在寻找代码如何实现的同学,那这篇文章就是为你准备的;当然如果你仅有一些 B 树的基础,文中的代码你还是可以看得懂的。如果你一点 B 树的基础都没有,建议先看 B 栈王道考研数据结构的视频,对 B 树有个大致的了解,再来看这篇文章,相信你会很有收获
下篇文章,我将会分享 B 树节点删除,这个比 B树创建更加复杂。不过我相信,只要你认真看,还是可以拿下的
你觉得这篇文章怎么样?喜欢就点赞+关注吧