图文解说B树的插入与删除

最近在做CMU的15445的数据库课程, 需要复习一些高级的数据结构. 记录了一些学习笔记.

B树(B-Tree)

B树实际上是从二叉平衡树衍生而来, B树的B是 Balanced Tree 的意思, 并不是二叉树的意思.

与传统的二叉搜索树不同, B树的特征是它们可以存储在单个节点中的大量键值对, 这就是为什么它们也被称为“大键”树的原因. B树中的每个节点都可以包含多个键, 这使树具有较大的分支因子, 从而减小树的深度. 较小的深度导致磁盘I/O较少, 从而可以更快的进行搜索和插入操作. B树特别适合缓慢, 大量的数据访问(例如硬盘驱动器,闪存和CD-ROM)的存储系统.

B树的时间复杂度

B树的搜索, 插入, 与删除的时间复杂度均为 \(O(log\ n)\).

B树的性质

  1. 所有的叶子节点都在同一层
  2. 限制B树大小结构的元素是最小度 \(t\), \(t\) 的值取决于磁盘块的大小. 例如, 我们下图中的磁盘块的大小为2.
  3. 除了根节点之外, 每一个节点至少包含 \(t-1\) 个Key, 根节点至少有一个 Key.
  4. 所有的节点, 包括根节点, 至多有 \(2t -1\) 个Keys.
  5. 一个节点的孩子的个数, 等于这个节点的Keys的个数加一
  6. 一个节点中, 所有的Keys是按照升序排列的. \(Key_1\)\(Key_2\) 之间的所有孩子节点包含 \((Key_1, Key_2)\) 范围内的 Keys.
  7. B 树的插入只发生在叶子节点.

除了我上述使用使用最小度来限制B树的结构, 另一个B树的属性是Order \(m\). 也就是我们常说的 \(m\) 阶. 一个 \(m\) 阶的B树每个节点最多有 \(m\) 个孩子. 一棵 \(m\) 阶的B-Tree有如下特性:

  1. 每个节点最多有 \(m\) 个孩子.
  2. 除了根节点和叶子节点外, 其它每个节点至少有 \(\lceil \frac{m}{2} \rceil\) 个孩子.
  3. 若根节点不是叶子节点, 则至少有2个孩子.
  4. 所有叶子节点都在同一层, 且不包含其它关键字信息.
  5. 每个非终端节点包含 \(n\) 个关键字信息
  6. 关键字的个数 \(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 \).
我总结节点的插入流程如下:

  1. 遍历B树, 找到对应的叶子节点, 从叶子节点开始插入新的键值对.
  2. 判断节点是否超过order限制, 如果超过, 节点需要分裂, 节点一分为二, 然后将中间节点的值插入到父节点中.
  3. 父节点也需要判断孩子节点是否超过限制, 如果超过了, 需要再次分裂, 这个过程需要向上回溯.
  4. 我们找到新的节点插入的位置需要递归的向下查找, 然后分裂, 新建节点的过程又需要回溯向上判断.

实现代码的核心步骤就是 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;
}

用上面的图中的示例描述插入的过程就是:

  1. 我们依次插入 [39, 93, 50]. 插入50的时候, 磁盘10的节点中, 节点的个数超过了3个, 因此需要分裂.

  2. 分裂时, 磁盘10的节点的前面部分不变, 新建磁盘节点13, 旧节点的中间的 value 向上层移动. 参考上面的 Split 函数.

  3. 我们将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;
  1. 将分裂节点的父节点的后面的节点作为新建节点的父节点.
    对应的操作如下:
 //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树的删除操作同样是先递归的走到叶子节点, 然后回溯处理父节点. 删除操作的流程如下:

  1. 递归的查找需要删除的元素, 如果需要删除的元素找不到, 那么直接返回.
  2. 向下替换: 找到需要删除的结果如果是中间节点, 从该节点开始, 将该节点与该节点的直接后续节点交换, 直到该节点与叶子节点替换, 删除该叶子节点. 如果需要删除的节点是叶子节点, 直接删除该叶子节点.
  3. 回溯:
    回溯的时候有几种不同的情况:
    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+ 树, 红黑树, 等高级数据结构的实现, 欢迎指正, 传送门.

posted @ 2024-07-16 17:13  虾野百鹤  阅读(178)  评论(0编辑  收藏  举报