【数据结构】树(四):B树(C++实现)

> 《算法导论》学习

基本介绍

B树是为磁盘或者其他直接存取的辅助存储(secondary storage)设备而设计的平衡搜索树。B树类似于红黑树,但是在降低磁盘I/O操作数方面表现更好。许多数据库系统使用B树或其变种来存储信息。

《【数据结构】树(四):B树(C++实现)》

一个典型的磁盘驱动器如图所示,从存储结构中读取某段信息的步骤如下:
①查看主存(primary memory/main memory),假如对象在主存中,则可以向平常一样引用该对象;
②否则该对象存储在磁盘上,需要先从磁盘读入主存;
③将对该对象的修改保存到磁盘中。
为了加快数据的读写,我们需要每次的读写操作都能读取到尽可能多的信息,减少读取操作。一个B树节点通常和一个完整磁盘页一样大,并且磁盘页的大小限制了一个B树节点可以含有的孩子个数。一个大的分支因子可以大大地降低树的高度以及查找任何一个关键字所需的磁盘存储次数。

B树定义

首先需要区分对于一棵B树度与阶:
最小度数(minmum degree)是一个固定整数表示B树中一个节点所包含孩子节点个数的下界。
阶(order)表示B树中一个节点所包含孩子节点个数上界。

一棵B树T是具有以下性质的有根树:
1. 每个节点具有属性:(1) x.n,当前存储在节点x中的关键字个数;(2)x.n个关键字本身的 x.key1,x.key2,x.key3,...,x.keyx.n ,以非降序存放,即 x.key1x.key2x.key3...x.keyx.n ;(3)x.leaf,一个布尔值,标识是否为叶子节点。
2. 每个内部节点x还包含x.n+1个指向其孩子的指针 x.c1,x.c2,x.c3,...,x.cx.n+1 。叶节点没有孩子,所以其 ci 属性没有定义。
3. 关键字 x.keyi 对存储在各子树中的关键字范围加以分割:若 ki 为任意一个存储在以 x.ci 为根的子树中的关键字,那么

k1x.key1k2x.key2...x.keyx.nkx.n+1

4. 每个叶节点具有相同的深度,即树的高度h。

5. 每个节点所包含的关键字个数有上界和下界。用一个被称为B树的最小度数的固定整数

t2 来表示这些界:(1) 除了根节点以外的每个节点必须至少有t-1个关键字,即除了根节点以外的每个内部节点至少有t个孩子。若树非空,根节点至少有一个关键字。(2) 每个节点至多可以包含2t-1个关键字。因此一个内部节点至多可有2t个孩子。当一个节点恰好有2t-1个关键字时,称该节点是满的(full)。

B树上的基本操作

一、插入、分裂关键字

B树插入一个关键字比较复杂,不能简单的创建一个新的叶节点然后将其插入,因为此时得到的不再是是合法的B树。通常,我们是将新的关键字插入到一个已经存在的叶节点上。由于不能将关键字插入一个满的叶节点,所以需要引入一个操作,将一个满的节点y(有2t-1个关键字)按照其中间关键字 y.keyt 分裂成两个各含t-1个关键字的节点。中间关键字被提升到y的父节点,以标识两棵新树的划分点。假如y的父节点也是满的,则必须在插入新的关键字之前将其分裂,最终满节点的分裂会沿着树向上传播。注意当沿着树往下查找新的关键字所属位置时,就分裂沿途遇到的每个满节点(包括叶节点本身)。因此每当要分裂一个满节点y时,就能确保它的父节点不是满的。分裂是树长高的唯一途径。
(算法导论的示例图好像有点问题,所以自己画了一个,如果大佬看出是我错了,请告诉我)
《【数据结构】树(四):B树(C++实现)》

/* ================== 分裂节点 ================= */
/* x: 被分裂节点的父节点 */
/* y: 被分裂节点,是x的第i个孩子 */
/* z: x的新孩子取走y的后t-1个关键字及相应的t个孩子 */
/* */
B-TREE-SPLIT-CHILD(x, i)
    /* 创建节点z */
    z = ALLOCATE-NODE()
    y = x.c(i)
    z.leaf = y.leaf
    z.n = t-1
    for j=1 to t-1
        z.key(j) = y.key(j+t)
    if not y.leaf /* 假如y为内部节点 */
        for j = 1 to t
            z.c(j) = y.c(j+t)
    y.n = t-1 /* 调整y的关键字个数 */

    /* 将z插入为x的一个孩子 */
    for j=x.n+1 downto i+1
        x.c(j+1) = x.c(j)
    x.c(i+1) = z
    for j=x.n downto i
        x.key(j+1) = x.key(i)
    x.key(i) = y.key(t)
    x.n = x.n + 1
    DISK-WRITE(y)
    DISK-WRITE(z)
    DISK-WRITE(x)

在一棵高度为h的B树T中,以沿树单程下行的方式插入一个关键字k的操作需要O(h)次磁盘存取。所需要的CPU时间为 O(th)=O(tlogtn)

/* ================== 插入节点 ================= */
/* T: 插入节点的树 */
/* k: 插入的关键字 */
/* s: 当根节点为满时创建的新的根节点 */
/* r: 原先的根节点 */
/* */
B-TREE-INSERT(T, k)
    r = T.root
    if r.n == 2t-1 /* 根节点为满 */
        s = ALLOCATE-NODE()
        T.root = s
        s.leaf = FALSE
        s.n = 0
        s.c(1) = r
        B-TREE-SPLIT-CHILD(s, 1)
        B-TREE-INSERT-NONFULL(s, k)
    else
        B-TREE-INSERT-NONFULL(r, k)

/* ============== 辅助的递归过程 ================ */
/* x: 关键字想要插入的节点 */
/* k: 插入的关键字 */
/* */
B-TREE-INSERT-NONFULL(x,k)
    i = x.n
    if x.leaf
        while i>=1 and k<x.key(i)
            x.key(i+1) = x.key(i)
            i = i-1
        x.key(i+1) = k
        x.n = x.n + 1
        DISK-WRITE(x)
    else while i>=1 and k<x.key(i)
            i = i-1
        i = i+1
        DISK-READ(x.c(i))
        if x.c(i).n == 2t-1
            B-TREE-SPLIT-CHILD(x, i)
            if k > x.key(i)
                i = i+1
        B-TREE-INSERT-NONFULL(x.c(i), k)

二、删除关键字

删除操作也需要特殊处理,当从一个内部节点删除一个关键字时,还要重新安排这个节点的孩子。同时与插入操作类似,当删除一个节点时,必须保证一个结点不会在删除其间变得太小(根节点除外,因为根节点允许有比最少关键数t-1还少的关键字个数),当要删除关键字路径上节点(非根)有最少的关键字个数时,也可能需要向上回溯。
过程B-TREE-DELETE从以x为根的子树中删除关键字k。该过程保证无论何时,节点x递归调用自身时,x中关键字个数至少为最小度数t。注意到,这个条件要求比通常B树中的最少关键字个数多一个以上,使得有时在递归下降至子节点之前,需要把一个关键字移到子节点中。

《【数据结构】树(四):B树(C++实现)》

分情况讨论:
1. 情况1:若关键字k在节点x中,并且x是叶节点,则从x中删除k。
2. 情况2:若关键字k在节点x中,并且x是内部节点。则进行以下操作:(a). 如果节点x中前于k的子节点y至少包含t个关键字,则找出k在以y为根的子树中的前驱k’。递归的删除k’,并在x中用k’替代k(找到k’并删除它可在沿树下降的单过程中完成)。(b). 类似(a),若y有少于t个关键字,则检查节点x中后于k的子节点z。如果z至少有t个关键字,则找出k在以z为根的子树中的后继k’。递归的删除k’,并在x中用k’代替k(找到k’并删除它可在沿树下降的单过程中完成)。(c). 否则,若y和z都只含有t-1个关键字,则将k和z的全部合并进y,这样x就失去了k和指向z的指针,并且y现在含有2t-1个关键字,然后释放z递归的从y中删除k。
3. 若关键字k不在内部节点x中,则确定必包含k的子树的根 x.ci (如果k确实在树中),如果 x.ci 只有t-1个关键字,必须执行步骤3a或3b来保证降至一个至少包含t个关键字的节点,然后通过对x的某个合适的子节点进行递归而结束。
伪代码如下所示,为个人思路,如有错误请指出:

/* ================== 合并节点 ================= */
/* 合并的两个子节点的父节点 */
/* index: 被合并的节点的左节点 */
/* */
B-TREE-MERGE-CHILD(x, index)
    pchild1 = x.c(index)
    pchild2 = x.c(index+1)
    pchild1.key(t) = x.key(index) /* 父节点index位置的关键字下降 */
    for j=1 to t-1 /* 将右兄弟节点关键字合并到左兄弟节点 */
        pchild1.key(j+t) = pchild2.key(j)
    if !pchild1.leaf
        for j=1 to t-1 /* 将右兄弟节点孩子合并到左兄弟节点 */
            pchild1.c(j+t-1) = pchild2.c(j)
    pchild1.n = 2t-1
    for j=index to x.n /* 父节点删除一个关键字,index后前移一位 */
        x.key(j) = x.key(j+1)
        x.c(j+1) = x.c(j+2)
    delete pchild2
    if(x->n == 0) delete x

/* ================== 删除节点 ================= */
/* T: 删除节点的树 */
/* k: 删除的关键字 */
/* rchild1: 当根节点只有一个关键字时的左孩子 */
/* rchild2: 当根节点只有一个关键字时的右孩子 */
/* */
B-TREE-DELETE(T, k)
    /* 注意:需要确认该关键字确实存在 */
    r = T.root
    if r.n==1
        if r.leaf /* 只有一个节点的树,需要删除根 */
            delete r
            T.root = NIL
        else /* case 3b,根据插入规则,可知此时若有孩子只可能有两个孩子 */
            rchild1 = r.c(1)
            rchild2 = r.c(2)
            if rchild1.n==t-1 and rchild2.n==t-1
                B-TREE-MERGE-CHILD(r, 0)
                delete r
                T.root = rchild1
    B-TREE-DELETE-RECURSIVE(r, k)

/* ============== 辅助的递归过程 ================ */
/* x: 删除的关键字所在子树的根节点 */
/* k: 删除的关键字 */
/* y: 节点x中前于k的子节点y */
/* z: 节点x中后于k的子节点z */
/* */
B-TREE-DELETE-RECURSIVE(x, k)
    i = 1
    while i<=x.n and k>x.key(i)
        i = i+1
    if i<=x.n and k==x.key(i) /* 关键字k位于节点x */
        if x.leaf /* case 1 */
            for j=i to x.n-1
                x.key(j) = x.key(j+1)
            x.n = x.n-1
        else /* case 2 */
            if y.n>=t /* case 2a */
                k1 = B-TREE-GET-PREDECESSOR(y)
                B-TREE-DELETE-RECURSIVE(y, k1)
                x.key(i) = k1
                return
            else if z.n>=t /* case 2b */
                k1 = B-TREE-GET-PREDECESSOR(z)
                B-TREE-DELETE-RECURSIVE(z, k1)
                x.key(i) = k1
            else /* case 2c */
                B-TREE-MERGE-CHILD(x, i)
                B-TREE-DELETE-RECURSIVE(y, k)
    else if i<=x.n and k<x.key(i) /* 关键字k不在节点x */
        child = x.c(i) /* 关键字所在的子树根节点 */
        if child.n==t-1
            pLeft = NULL /* child的左兄弟节点 */
            pRight = NULL /* child的右兄弟节点 */
            if i>0
                pLeft = x.c(i-1)
            if i<x.n
                pRight = x.c(i+1)
            if pLeft and pLeft.n>=t /* case 3a: 将左兄弟节点的一个关键字挪到child节点 */
                /* 父节点的第i-1个关键字下降至合并节点 */
                for j=x.n+1 to 2 /* child原有关键字后移一位 */
                    child.key(j) = child.key(j-1)
                child.key(1) = x.key(i-1)
                if !pLeft.leaf /* 移动左兄弟节点对应的孩子节点 */
                    for j=x.n+2 to 2 /* child原有孩子节点后移一位 */
                        child.c(j) = child.c(j-1)
                    child.c(1) = pLeft.c(x.n+1)
                child.n = child.n+1
                x.key(i) = pLeft.key(x.n)
                pLeft.n = pLeft.n-1
            else if pRight and pRight.n>=t /* case 3a: 将右兄弟节点的一个关键字挪到child节点 */
                /* 父节点的第i个关键字下降到child节点 */
                child.key(child.n+1) = x.key(i)
                child.n = child.n+1
                pRight.n = pRight.n-1
                x.key(i) = pRight.key(1) /* pRight中最小关键字上升至x */
                for j=1 to pRight.n
                    pRight.key(j) = pRight.key(j+1)
                if !pRight.leaf
                    child.c(child.n+1) = pRight.c(1)
                    for j=1 to pRight.n+1
                        pRight.c(j) = pRight.c(j+1)
            else if pLeft /* case 3b: 与左兄弟合并 */
                B-TREE-MERGE-CHILD(x, i-1)
                child = pLeft
            else if pRight /* case 3b: 与右兄弟合并 */
                B-TREE-MERGE-CHILD(x, i)
    eB-TREE-DELETE-RECURSIVE(child, k) /* 关键字k不在节点x部分结束 */      

三、搜索关键字

B树搜索与二叉搜索树相似,只是需要根据节点的孩子数做多路分支选择。

B-TREE-SEARCH(x, k)
    i = 1
    while i<=x.n and k>x.key(i)
        i = i+1
    if i<=x.n and k==x.key(i)
        return (x, i)
    elseif x.leaf
        return NIL
    else DISK-READ(x, c(i))
        return B-TREE-SEARCH(x.c(i), k)

总结

  1. B树及其变种经常用于数据库信息存储,对于不同的应用场景可以使用不同的结构来存储关键字,如链表,双向队列,数组等等。查找操作也可以使用顺序查找、二分查找等实现。
  2. 在查找资料的时候发现explicit关键字,之前没有用过,顺带mark一下。按默认规定,只用传一个参数的构造函数也定义了一个隐式转换。即可以通过如A t = 'a';,隐式传入参数‘a’调用只有一个形参的构造函数A(char a);。通过关键字explicit可以抑制隐式转换,避免一些程序逻辑错误(这种错误可以通过编译器编译,难以发现)。

参考及代码

[1] B树的原理与实现(C++)
[2] 自己实现的B树源码(C++),如有错误请指出,非常感谢!

    原文作者:B树
    原文地址: https://blog.csdn.net/Jingle_cjy/article/details/70432105
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞

发表评论

电子邮件地址不会被公开。 必填项已用*标注