图文解说B树的插入与删除
最近在做CMU的15445的数据库课程, 需要复习一些高级的数据结构. 记录了一些学习笔记.
B树(B-Tree)
B树实际上是从二叉平衡树衍生而来, B树的B是 Balanced Tree 的意思, 并不是二叉树的意思.
与传统的二叉搜索树不同, B树的特征是它们可以存储在单个节点中的大量键值对, 这就是为什么它们也被称为“大键”树的原因. B树中的每个节点都可以包含多个键, 这使树具有较大的分支因子, 从而减小树的深度. 较小的深度导致磁盘I/O较少, 从而可以更快的进行搜索和插入操作. B树特别适合缓慢, 大量的数据访问(例如硬盘驱动器,闪存和CD-ROM)的存储系统.
B树的时间复杂度
B树的搜索, 插入, 与删除的时间复杂度均为 \(O(log\ n)\).
B树的性质
- 所有的叶子节点都在同一层
- 限制B树大小结构的元素是最小度 \(t\), \(t\) 的值取决于磁盘块的大小. 例如, 我们下图中的磁盘块的大小为2.
- 除了根节点之外, 每一个节点至少包含 \(t-1\) 个Key, 根节点至少有一个 Key.
- 所有的节点, 包括根节点, 至多有 \(2t -1\) 个Keys.
- 一个节点的孩子的个数, 等于这个节点的Keys的个数加一
- 一个节点中, 所有的Keys是按照升序排列的. \(Key_1\) 和 \(Key_2\) 之间的所有孩子节点包含 \((Key_1, Key_2)\) 范围内的 Keys.
- B 树的插入只发生在叶子节点.
除了我上述使用使用最小度来限制B树的结构, 另一个B树的属性是Order \(m\). 也就是我们常说的 \(m\) 阶. 一个 \(m\) 阶的B树每个节点最多有 \(m\) 个孩子. 一棵 \(m\) 阶的B-Tree有如下特性:
- 每个节点最多有 \(m\) 个孩子.
- 除了根节点和叶子节点外, 其它每个节点至少有 \(\lceil \frac{m}{2} \rceil\) 个孩子.
- 若根节点不是叶子节点, 则至少有2个孩子.
- 所有叶子节点都在同一层, 且不包含其它关键字信息.
- 每个非终端节点包含 \(n\) 个关键字信息
- 关键字的个数 \(n\) 满足:\(\lceil \frac{m}{2} \rceil-1 <= n <= m-1 \)
注意这是从两个不同的属性来定义一颗 B树的, 满足 \(t = \lceil \frac{m}{2} \rceil\). 但是 \(m==2t\)不一定成立, 所以如果给出了一棵树的 \(t\) 和 \(m\), 我们需要同时满足.
B树的插入
我们以上面的图为例, 使用order的方式描述一棵树, 取值 \(m=3\).
B树插入的过程中需要保证一直满足我们上述所列的条件, 每个节点最有多 \(m\) 个孩子, 每一个节点的关键字的个数 \(n\) 满足:\(\lceil \frac{m}{2} \rceil-1 <= n <= m-1 \).
我总结节点的插入流程如下:
- 遍历B树, 找到对应的叶子节点, 从叶子节点开始插入新的键值对.
- 判断节点是否超过order限制, 如果超过, 节点需要分裂, 节点一分为二, 然后将中间节点的值插入到父节点中.
- 父节点也需要判断孩子节点是否超过限制, 如果超过了, 需要再次分裂, 这个过程需要向上回溯.
- 我们找到新的节点插入的位置需要递归的向下查找, 然后分裂, 新建节点的过程又需要回溯向上判断.
实现代码的核心步骤就是 Insert
函数与 Split
函数. 核心部分的代码如下:
template <class T, int Order>
int Node<T, Order>::Insert(T value) {
//if the node is leaf, 如果该节点是叶子节点
if (this->childs[0] == nullptr) {
// position 位置修改, 记录Value
this->keys[++this->position] = value;
++this->NumbersOfKeys;
// 将数组中的Key 按照Value的大小排序
for (int i = this->position; i > 0; i--) {
if (this->keys[i] < this->keys[i - 1]) std::swap(this->keys[i], this->keys[i - 1]);
}
}
//if the node is not leaf
else {
// count to get place of child to put the value in it
int i = 0;
// 找到插入的位置, 找到包含value范围的Key
for (; i < this->NumbersOfKeys && value > this->keys[i];) {
i++;
}
// Check if the child is full to split it, 递归的向下, 在对应的位置插入节点
int check = this->childs[i]->Insert(value);
// if node full, 回溯判断节点是否满了, 如果满了向上分裂节点
if (check) {
// 找出孩子节点Split之后返回的Value, 这个Value插入当前节点
T mid;
int TEMP = i;
Node<T, Order>* newNode = split(this->childs[i], &mid); //Splitted Node to store the values and child that greater than the midValue
//allocate midValue in correct place, 为向上一层的Value分配正确的位置
for (; i < this->NumbersOfKeys && mid > this->keys[i];) {
i++;
}
// 将第i个后面的键值对向后移动一位
for (int j = this->NumbersOfKeys; j > i; j--) this->keys[j] = this->keys[j - 1];
// 将分裂后的数据写入到index为i的位置
this->keys[i] = mid;
++this->NumbersOfKeys;
++this->position;
//allocate newNode Splitted in the correct place
int k;
for (k = this->NumbersOfKeys; k > TEMP + 1; k--)
this->childs[k] = this->childs[k - 1];
// 此时 k==TEMP+1, 该节点指向分裂后的新节点, 因为 this->childs[TEMP] 指向的是分裂之前的节点
this->childs[k] = newNode;
}
}
// 如果这个节点有 order 个Keys, 那么就有order+1个孩子, 满了, 这个节点需要分裂
if (this->NumbersOfKeys == Order) return 1; //to split it
else return 0;
}
template <class T, int Order>
Node<T, Order>* Node<T, Order>::split(Node* node, T* med) //mid to store value of mid and use it in insert func
{
int NumberOfKeys = node->NumbersOfKeys;
Node<T, Order>* newNode = new Node<T, Order>();
//Node<T,Order> *newParentNode = new Node<T,Order>(order);
int midValue = NumberOfKeys / 2;
*med = node->keys[midValue];
int i;
//take the values after mid value
for (i = midValue + 1; i < NumberOfKeys; ++i)
{
newNode->keys[++newNode->position] = node->keys[i];
newNode->childs[newNode->position] = node->childs[i];
++newNode->NumbersOfKeys;
--node->position;
--node->NumbersOfKeys;
node->keys[i] = 0;
node->childs[i] = nullptr;
}
// 孩子节点的个数要比Key的个数多一个
newNode->childs[newNode->position + 1] = node->childs[i];
node->childs[i] = nullptr;
--node->NumbersOfKeys; //because we take mid value...
--node->position;
return newNode;
}
用上面的图中的示例描述插入的过程就是:
-
我们依次插入 [39, 93, 50]. 插入50的时候, 磁盘10的节点中, 节点的个数超过了3个, 因此需要分裂.
-
分裂时, 磁盘10的节点的前面部分不变, 新建磁盘节点13, 旧节点的中间的 value 向上层移动. 参考上面的
Split
函数.
-
我们将50向上移动一层后, 从分裂节点的父节点后面开始, 将Key节点向后移动一位, 移动如下:
for (; i < this->NumbersOfKeys && mid > this->keys[i];) {
i++;
}
// 将第i个后面的键值对向后移动一位
for (int j = this->NumbersOfKeys; j > i; j--) this->keys[j] = this->keys[j - 1];
// 将分裂后的数据写入到index为i的位置
this->keys[i] = mid;
- 将分裂节点的父节点的后面的节点作为新建节点的父节点.
对应的操作如下:
//allocate newNode Splitted in the correct place
int k;
for (k = this->NumbersOfKeys; k > TEMP + 1; k--)
this->childs[k] = this->childs[k - 1];
// 此时 k==TEMP+1, 该节点指向分裂后的新节点, 因为 this->childs[TEMP] 指向的是分裂之前的节点
this->childs[k] = newNode;
我们用下图解释:
在父节点插入一个节点后, 父节点的个数超过了限制, 因此也需要分裂, 分裂的步骤和它的子节点的分裂以及向上传送数据的步骤是一样的. 这个过程如下图:
再向上传输分裂后的数字, 根节点的节点数目超出限制, 根节点需要分裂:
最后整棵树会变成下面的结构:
我们可以看到根节点也进行分裂了, B树的层数会增加一, 所以我们整棵树的插入操作应该如下:
template <class T, int Order>
void BTree<T, Order>::Insert(T value) {
count++;
//if Tree is empty
if (this->Root == NULL) {
this->Root = new Node<T, Order>(this->order);
this->Root->keys[++this->Root->position] = value;
this->Root->NumbersOfKeys = 1;
}
//if tree not empty
else {
// 如果根节点插入之后需要分裂, 表示B树的层数需要加一
int check = Root->Insert(value);
if (check) {
T mid;
// 将Root节点分裂
Node<T, Order>* splittedNode = this->Root->split(this->Root, &mid);
Node<T, Order>* newNode = new Node<T, Order>(this->order);
newNode->keys[++newNode->position] = mid;
newNode->NumbersOfKeys = 1;
// 将原来的Root与分裂的节点作为新的根节点的左右子节点
newNode->childs[0] = Root;
newNode->childs[1] = splittedNode;
this->Root = newNode;
}
}
}
B树节点的删除操作
B树的删除操作同样是先递归的走到叶子节点, 然后回溯处理父节点. 删除操作的流程如下:
- 递归的查找需要删除的元素, 如果需要删除的元素找不到, 那么直接返回.
- 向下替换: 找到需要删除的结果如果是中间节点, 从该节点开始, 将该节点与该节点的直接后续节点交换, 直到该节点与叶子节点替换, 删除该叶子节点. 如果需要删除的节点是叶子节点, 直接删除该叶子节点.
- 回溯:
回溯的时候有几种不同的情况:
a. 被删除节点的关键字的个数大于 \(\lceil \frac{m}{2} \rceil-1\), 直接删除该节点, 然后返回.
b. 被删除节点的关键字的个数为 \(\lceil \frac{m}{2} \rceil-1\), 但是该存在一个左兄弟节点, 或者右兄弟节点的关键字的个数大于 \(\lceil \frac{m}{2} \rceil-1\), 将该节点的兄弟节点中的一个关键字上移, 父节点向下移动到当前节点.
假设是左兄弟节点的关键字个数大于 \(\lceil \frac{m}{2} \rceil-1\), 那么首先将当前节点所有关键字与节点后移一位, 将父节点的关键字作为当前节点的第一个关键字, 将左兄弟节点的最后一个子节点, 作为当前节点的第一个子节点. 最后将左兄弟节点的最后一个关键字上移. 父节点的左右两边仍指向左兄弟节点与当前节点.
下图是一个示例, 假设Order==5 在删除节点 \(Q\) 之后, 进行了一次合并, 然后根节点的第二个孩子节点只剩下一个 Key, 此时可以向左兄弟节点借一个 Key. 步骤如下:
c. 合并节点: 被删除节点的关键字的个数为 \(\lceil \frac{m}{2} \rceil-1\), 兄弟节点的关键字的个数也均为 \(\lceil \frac{m}{2} \rceil-1\). 此时, 将当前节点父节点下移到兄弟节点, 然后将当前节点与兄弟节点合并, 这里需要注意与左兄弟节点或者右兄弟节点合并.
d. 合并之后父节点中的一个Key被删除, 重新进行回溯.
下图示例中, Order==5, 我们删除了节点 \(M\) 之后, 进行了一次合并, 将其父节点合并, 然后父节点删除一个节点后, Key的个数也不满足条件, 所以回溯, 再次合并, 合并之后根节点的 Keys 不满足条件, 因为根节点变成新的根节点.
B树删除的代码实现
代码实现这一部分还是很考验耐心的, 我借用了一部分一位老哥的代码, 并做了一些修改, 传送门,
我自己的实现也记录了一下, 后续可能还会添加 B+ 树, 红黑树, 等高级数据结构的实现, 欢迎指正, 传送门.