关于B-Tree的学习笔记

65 阅读13分钟

B树是一种多路搜索树

相比二叉树,其特点就是节点会存在多个(大于2)子节点;且节点中并不是只有一对key-value

  • 在相等节点数量的情况下,B树的可以以更低的层高出现
  • 在相同层高的情况下,B树可以包含更多个节点

什么是B树?

  • 一棵M阶B树满足以下条件
    • 每个节点至多拥有M棵子树
    • 根节点至少拥有2棵子树
    • 除根节点以外,其余每个分支节点至少拥有M/2棵子树
    • 所有的叶子节点都在同一层
    • 有k棵子树的分支节点则存在k-1个关键字,且关键字递增有序
    • 除根节点以外,其余每个分支节点的关键字数量满足 [ceil(M / 2) - 1 , M - 1]
  • 为了便于操作,通常将M定义为2t的类型,即采用偶数阶表示,那关键字的数量范围就变成了 [t-1, 2t -1],这是实现B树过程中最重要的理论依据

相比二叉树的优势如何体现?

  • 二叉树查找目标节点时,必须按照key的递增顺序查找N个节点,那就需要执行N次寻址操作;但B树在一个节点中可以找到一组key,大大减少了寻址次数。
  • 寻址次数带来的影响:如果数据都是保存在内存中,其效率基本没有差异;但如果数据本身是保存在磁盘中,那寻址次数就等于磁盘的IO次数,磁盘的读写效率是远低于内存的,此时相对少量的寻址次数就大大提高了读写的效率。
  • 寻址按照 “cpu->cpu寄存器->物理内存->磁盘”的顺序递进查找,如果每次都需要进行磁盘的IO,那效率必然是最低的,想提高读写效率就需要减少磁盘的读写次数。

实现一棵B树,阶数 M = 2 * t = 6

  • 相关数据定义
typedef struct _btree_node{
   struct _btree_node **childrens;  // 子树集
   int *keys; // 关键字集,这里以int为键值类型举例
   
   int num; // 已有关键字个数
   int leaf; // 标记是否为叶子节点  0代表叶子 1代表非叶子
}btree_node;

typedef struct _btree{
    struct _btree_node *root;  // 根节点
    int t; // 树的阶数 = 2 * t
}btree;

创建一棵B树

// 创建一个新节点
btree_node* btree_create_node(int t, int leaf)
{
    btree_node *node = (btree_node *)malloc(sizeof(btree_node));
    if(node == NULL) return NULL;
    memset(node, 0 , sizeof(btree_node));
    
    //初始化节点成员
    node->num = 0;
    node->leaf = leaf;
    
    node->childrens = (btree_node *)calloc(1, (2 * t) * sizeof(btree_node *));
    node->keys = (int*)calloc(1, (2 * t - 1) * sizeof(int));
    
    return node;
} 

// 创建一棵树
void btree_create_tree(btree *T, int t)
{
    if(T == NULL) return;
    T->root = btree_create_node(t, 0);
    T->t = t;
}

添加数据

  • 与红黑树等二叉树不同,B树添加数据并不是通过添加节点,因为B树的节点中可以保存多个数据,节点的增加是由于待插入节点已满,此时需要将满节点分裂成两个节点,再进行插入。结合下图,来自【零声教育】c/c++ linux服务器开发课程。 屏幕截图 2023-03-13 140106.png
  • 参看图解过程,数据根据key值递增向后插入,可以发现在插入F、I节点时,由于待插入节点的关键字个数达到了最大值2t-1 = 5,所以分别进行了一次分裂;
  • 插入F节点时,分裂导致层高由1变为了2;插入I节点时,层高未发生变化,但是第二层的节点数由2变为了3;由此不难发现,根节点的分裂跟非根节点的分裂过程是有区别的
  • 非根节点的分裂(比如插入I时的目标节点y,包含【DEFGH】)
    • 关键字F = y.keys[t-1],移动到上层根节点x中
    • y中关键字F后面的 t-1个关键字被分配到一个新的相邻节点z中,此时z中包含了【GH】两个关键字
    • 根据B树的特性,关键字的数量 = 当前节点子树数量 - 1,所以在关键字个数产生变化时,y节点中子树也同时被z节点拷贝走了一半,即t棵
  • 根节点的分裂
    • 根节点的分裂过程通过退化为非根节点分裂的方式进行处理
    • 首先创建一个新的节点x作为新的根节点,并将现有的根节点y作为新节点的第0棵子树
    • 此时场景就变成了根节点x的某一个子树y需要进行分裂,结合非根节点的分裂处理方式就知道,此时拿走y节点的中间关键字到x中,同时创建一个新的相邻节点z用来存放y节点的一半数据元素
  • 按照上述两种情况,可以将非根节点的分裂方式实现为源语操作进行使用。分析得知分裂的过程会涉及三个节点
    1. 需要分裂的节点y(即x的第i棵子树节点)
    2. 节点y的上一层节点x
    3. 新创建的节点z
// 结合上面的分析,我们需要以这三个节点为切入点实现节点的分裂
void btree_child_split(btree *T, btree_node *x, int i)
{   
    btree_node *y = x->children[i];
    btree_node *z = btree_create_node(T->t, y->leaf);
    
    // 处理z节点,拷贝y中需要移动的元素,修改关键字个数
    for(int j = 0 ; j < T->t - 1; j++)
    {
        z->keys[j] = y->keys[j + T->t];
    }
    
    if(z->leaf != 0)
    {
        for(int j = 0; j < T->t; j++)
        {
            z->children[j] = y->children[j + T->t];
        }
    }
    z->num = T->t - 1;
    
    // 处理Y节点
    y->num = T->t -1;
    
    // 处理x节点, 指针后移, 修改关键字个数
    // 第i+1个子树指针开始向后移动
    for(int j = x->num; j >= i; j--)
    {
        x->children[j + 1] = x->children[j];
    }
    // 第i+1个子树指针位置放置新的节点
    x->children[i + 1] = z;
    
    // 第i个关键字开始往后移动 
    for(int j = x->num - 1; j >= i; j--)
    {
        x->keys[j + 1] = x->keys[j];
    }
    // 第i个关键字的位置放置新的关键字
    x->keys[i] = y->keys[T->t - 1];
    
    x->num++;
}
  • 在实现了分裂的源语功能之后,开始考虑如何将一个新的元素插入到当前树中。这里梳理一下插入的过程:
    • 第一个参与运算的节点是root,如果是非叶子节点,则首先跟root中关键字比较,找到下一级可能插入的节点指针;如果root是叶子节点且未满,则直接插入;如果是叶子节点且满了,则需要先进行分裂,再对新的根节点重新进行一次递归过程;
    • 如果找到了x节点的下一级节点y,那么先判断节点y是否已满;如果满了,则需要分裂该节点,那么此时多出来了一个子节点z;
    • 再通过跟最新上浮的关键字进行比较,来决定新的元素应该放到y节点还是z节点,再查看y或z节点是否为叶子节点,如果是叶子节点则直接插入;如果不是叶子节点,则递归查找的过程,向下查找;
  • 总结下来就是:从根节点开始检索,找到合适的位置后判断其是否需要分裂,若需要分裂,则进行分裂得到两个新的节点,最后根据新上浮的关键字判断插入到哪个子节点,直到目标节点是叶子节点且未满,就进行插入-----递归方式找子树;先分裂,再插入
// 根据上述分析插入操作必定发生在未满的叶子节点中
void btree_insert_nonfull(btree* T, btree_node *x, int key)
{
   int i = x->num -1;     

    // 如果是叶子节点,则直接插入到叶子节点中
    if(x->leaf == 1) 
    { 
        while( i >= 0 && x->keys[i] > key)
        {
            x->keys[i + 1] = x->keys[i];
            i--;        // 游标向前移动
        }
        x->keys[i+1] = key;     // 当前i位置上的key已经不满足x->keys[i] > key的条件,所以应该插入到右侧一个位置
        x->num++;
    }
    // 如果不是叶子节点,则插入需要在其子树中进行
    // 1.通过二分查找,找到带插入关键字与当前节点已有关键字的关系,以此找到需要使用的子树位置
    // 2.找到子树位置后,如果子树已满,则先对该子树进行分裂
    // 3.分裂后目标子树的父节点的子树数量加1且关键字数量也加1,此时对新加入的key进行判断,确定待插入的子树位置是否需要变更
    // 4.此时回到往未满节点中插入新数据的过程,开始递归进行
    else
    {
        while( i >= 0 && x->keys[i] > key) i--;     // i节点就是待插入位置的前一个位置

        if(x->children[i+1]->num == (T->t * 2 -1))
        {
            btree_spit_child(T, x, i+1);
            if(key > x->keys[i+1]) i++;
        }

        btree_insert_nonfull(T, x->children[i+1], key);
    }
}

void btree_insert(btree *T, int key)
{
    btree_node *root  = T->root;
    if(root->num == 2 * T->t - 1)  // 根节点满的情况
    {
        // 插入根节点,根节点满的情况,创建一个新的空根节点作为分裂的上层,执行子节点分裂的过程
        btree_node *node = btree_create_node(T, 0);
        T->root = node;
        node->children[0] = root;

        btree_spit_child(T, node, 0);       // 此时出现一个新的空根节点;以及两个新的左右子树

        // 插入操作变成在一颗二叉树上进行二叉查找,再插入到一个键值未满的子节点的过程
        int i = 0;  // 决定插入到左子树还是右子树,因为此时根节点只有两个子树
        if(node->keys[0] < key) i++;

        // 找到需要插入的子树后,执行对该子树的插入操作
        btree_insert_nonfull(T, node->children[i], key);
    }
    else{
        // 此时根节点没满,则进行二叉查找去找到需要插入的子树
        // 找到需要插入的子树之后,执行对该子树的插入操作           ---->  此时已经抽象出统一的插入操作
        btree_insert_nonfull(T, root, key);
    }
}

复盘老师代码的时候发现逻辑有些问题,再进行分裂的时候并没有对上层的节点进行判断,分裂产生上浮,那么上层节点x有可能是接收不了的,那此时应该会进行递归分裂?直到需要分裂的是根节点,实现特定的根节点分裂功能?好难啊,慢慢理吧,数学不好还有强迫症,想不通的地方好容易卡住 TAT

删除数据

  • 删除操作是指,根据key删除记录,如果B树中的记录中不存对应key的记录,则删除失败。
  • 基本思想是采用待删除元素的直接前驱或者后驱来替代该元素的位置(中序遍历下的前后关系)
  • 最重要的目标就是删除一个元素之后,该节点中的key的数量不能少于 t - 1个

步骤

  • 第1步:如果待删除元素时在叶子节点中,直接从当前节点中删除;如果当前需要删除的key位于非叶子结点上,则用后继元素覆盖要删除的key(记得同时移动其关联的子树指针),然后在后继元素所在的子支中删除该后继元素,该子树节点作为新的当前节点。删除这个记录后执行第2步(此时的当前节点必是叶子节点
  • 第2步:该结点key个数大于等于t-1,结束删除操作,否则执行第3步。
  • 第3步:如果兄弟结点key个数大于t-1,则父结点中的key下移到该结点,兄弟结点中的一个key上移,删除操作结束。若不满足执行第4步(父节点借给当前节点->兄弟节点借给父节点
  • 第4步:将父结点中的key下移与当前结点及它的兄弟结点中的key合并(下沉元素 + 当前节点 + 兄弟节点 = 新的节点),形成一个新的结点。原父结点中的key的两个孩子指针就变成了一个孩子指针,指向这个新结点。然后将父节点作为新的当前节点,重复执行第2步(兄弟节点没有可借元素,只跟父节点借->父节点关键字减1,通过合并实现子树数量减1->父节点作为当前节点,开始递归删除的过程)

有些结点它可能既有左兄弟,又有右兄弟,那么我们任意选择一个兄弟结点进行操作即可。最终只是树的形态有区别,对树的性质没有影响

//{child[idx], key[idx], child[idx+1]} 合并
void btree_merge(btree *T, btree_node *node, int idx) 
{
    btree_node *left = node->childrens[idx];
    btree_node *right = node->childrens[idx+1];

    int i = 0;

    /////data merge
    left->keys[T->t-1] = node->keys[idx];
    for (i = 0;i < T->t-1;i ++) 
    {
        left->keys[T->t+i] = right->keys[i];
    }
    if (!left->leaf) 
    {
            for (i = 0;i < T->t;i ++) 
            {
                left->childrens[T->t+i] = right->childrens[i];
            }
    }
    left->num += T->t;

    //destroy right
    btree_destroy_node(right);

    //node 
    for (i = idx+1;i < node->num;i ++) 
    {
        node->keys[i-1] = node->keys[i];
        node->childrens[i] = node->childrens[i+1];
    }
    node->childrens[i+1] = NULL;
    node->num -= 1;

    if (node->num == 0) 
    {
        T->root = left;
        btree_destroy_node(node);
    }
}

// 按照关键字删除数据
void btree_delete_key(btree *T, btree_node *node, KEY_VALUE key) 
{
    if (node == NULL) return ;

    int idx = 0, i;

    while (idx < node->num && key > node->keys[idx]) 
    {
        idx ++;
    }

    if (idx < node->num && key == node->keys[idx]) 
    {
        if (node->leaf) 
        {
            for (i = idx;i < node->num-1;i ++) 
            {
                node->keys[i] = node->keys[i+1];
            }
            node->keys[node->num - 1] = 0;
            node->num--;

            if (node->num == 0) 
            { //root
                free(node);
                T->root = NULL;
            }
            return ;
            } 
            else if (node->childrens[idx]->num >= T->t) 
            {
                btree_node *left = node->childrens[idx];
                node->keys[idx] = left->keys[left->num - 1];

                btree_delete_key(T, left, left->keys[left->num - 1]);
            } 
            else if (node->childrens[idx+1]->num >= T->t) 
            {
                btree_node *right = node->childrens[idx+1];
                node->keys[idx] = right->keys[0];

                btree_delete_key(T, right, right->keys[0]);
            } 
            else 
            {
                btree_merge(T, node, idx);
                btree_delete_key(T, node->childrens[idx], key);
            }
    } else 
    {
        btree_node *child = node->childrens[idx];
        if (child == NULL) 
        {
            printf("Cannot del key = %d\n", key);
            return ;
        }

        if (child->num == T->t - 1) 
        {

            btree_node *left = NULL;
            btree_node *right = NULL;
            if (idx - 1 >= 0)
                    left = node->childrens[idx-1];
            if (idx + 1 <= node->num) 
                    right = node->childrens[idx+1];

            if ((left && left->num >= T->t) || (right && right->num >= T->t)) 
            {
                int richR = 0;
                if (right) richR = 1;
                if (left && right) richR = (right->num > left->num) ? 1 : 0;

                if (right && right->num >= T->t && richR) 
                { //borrow from next
                    child->keys[child->num] = node->keys[idx];
                    child->childrens[child->num+1] = right->childrens[0];
                    child->num ++;

                    node->keys[idx] = right->keys[0];
                    for (i = 0;i < right->num - 1;i ++) 
                    {
                        right->keys[i] = right->keys[i+1];
                        right->childrens[i] = right->childrens[i+1];
                    }

                    right->keys[right->num-1] = 0;
                    right->childrens[right->num-1] = right->childrens[right->num];
                    right->childrens[right->num] = NULL;
                    right->num --;

                } 
                else 
                { //borrow from prev
                    for (i = child->num;i > 0;i --) 
                    {
                        child->keys[i] = child->keys[i-1];
                        child->childrens[i+1] = child->childrens[i];
                    }
                        child->childrens[1] = child->childrens[0];
                        child->childrens[0] = left->childrens[left->num];
                        child->keys[0] = node->keys[idx-1];

                        child->num ++;

                        node->key[idx-1] = left->keys[left->num-1];
                        left->keys[left->num-1] = 0;
                        left->childrens[left->num] = NULL;
                        left->num --;
                }

            } 
            else if((!left || (left->num == T->t - 1)) && (!right || (right->num == T->t - 1))) 
            {
                if (left && left->num == T->t - 1) 
                {
                    btree_merge(T, node, idx-1);					
                    child = left;
                } 
                else if (right && right->num == T->t - 1) 
                {
                    btree_merge(T, node, idx);
                }
            }
        }
        btree_delete_key(T, child, key);
    }	
}

在了解B树的实现原理之后,值得一提的是B+树。

第一个想到的就是Innodb的索引树,他就是基于B+树实现的。

B+树的特点

B+树跟B树最大的区别就在于B树的所有节点中都保存了数据;而B+树的非叶子节点中只保存了key,实际的数据都放在叶子节点中,并且B+树的叶子节点彼此间通过链表的方式形成了一个支持范围查找的内存结构。而B树每次查找都需要重新从根节点遍历,在这一点上B+树的优化非常明显。这也是索引树为什么采用B+树而不是B树的主要原因之一。