基础-2:B树

1 概述

前一讲提到了二叉搜索树,从直觉的角度看,貌似较好地解决了快速搜索的问题,其实不然。如果给定一个关键字序列<1, 2, 3, 4, 5, 6>,要求按照这个顺序构建一个搜索二叉树,则这个二叉树的高度为5,从而退化为一个链表,并且浪费大量的空间。因此,二叉树在具体的实践中几乎没有应用。

针对二叉搜索树的问题,本文主要讲解B树,也会简单提到B+树(有些数据库索引使用B+树)。下面的内容首先介绍B树的背景,然后重点讲解B树上的相关操作(本文大部分内容参考算法导论和维基百科)。

2 背景

B树系列主要用在哪里?答:主要用在文件系统和数据库系统。大家知道,文件和数据库主要存储在磁盘(外存)上,特别是大数据时代,大规模的数据很难一次性导入到硬盘,即使可以导入,其耗费的时间是也无法容忍的(笔者2012年的时候曾经帮一个公司优化过MySQL数据库,在未经优化的前提下,从100万条数据中查询数据都非常耗时)。下面对磁盘上数据的操作基本原理进行解释。

《基础-2:B树》 图1:机械磁盘

当前应用广泛的机械磁盘如图1所示。数据存储在磁盘盘片上的磁道中,机械磁盘中的盘片围绕机械轴运行,读写磁头定位到需要读写的磁道(机械操作),然后再进行读写操作(电子操作)。机械操作的时间比电子操作的时间要高出5个数量级,因此,读写操作的效率取决于定位磁道(机械操作)的次数,即读写磁盘的次数。因此,降低磁盘读写操作次数是提高读写效率的关键。这时,就需要某种合理的数据组织形式来降低磁盘读写的次数。B树即是解决此类问题的基本数据结构,B树大致的样子如图2所示:

《基础-2:B树》 图2: B树的长相

与二叉搜索树不同,B树是一颗平衡树,即各子树的高度是一致的,且每个节点中存放的关键字数量t通常远大于2(通常是1000左右)。假设除根节点外的所有节点都存储在磁盘上,一次读写操作访问一个B树节点,对于查找操作,定位到任何一个节点需要访问磁盘的次数不超过树的高度h,如果每个节点中存放的关键字越多,则其高度h越小,即访问磁盘的次数越少,这样就可有效降低读写磁盘的次数。

如果t=1000,则高h为2的B树,其存储的关键字个数达10亿个,定位任何一个关键字需要的磁盘操作树不超过2。

3 B树的结构

B树是一颗有根树(设为T.root),所有节点具有如下性质(有点多,从背景的角度容易理解):

  • 每个节点node具有如下属性:

    • node.n,存储node中的关键字数量;
    • node.n个关键字本身node.key1,node.key2, …, node.keyn以非降序的方式存放;
    • node.leaf是一个布尔值,标示当前节点是否是叶子节点;
  • 每个内部节点(非叶子节点)node包含指向node.n+1个孩子节点的指针node.c1,node.c2,…,node.cn+1

  • 关键字node.keyi对各子树中的关键字加以分割,假设其左右孩子分别为node.ci、node.ci+1,则node.ci中的所有关键字不大于node.keyi,node.ci+1的所有关键字不小于node.keyi

  • 每个叶子节点具有相同的深度;

  • 每个节点包含的关键字个数有规定的取值范围,由B树的度d(>= 2)决定:

    • 除根节点外,其它节点至少有d-1个关键字,即除根节点外,所有节点至少包含d个孩子节点。如果树非空,根节点至少又个关键字。(注:这里没有说根节点只有一个关键字
    • 每个节点至多包含2d-1个关键字。因此,一个内部节点,至多有2d个节点,当一个节点恰好有2d-1个节点时,则称此节点时满的(非常重要,插入删除操作需要关注的重点

B树的高度h满足: h <= logd((n+1)/2),其证明分析如下图3所示:

《基础-2:B树》 图3: B树的高度分析过程

4 B树的操作

在B树定义的基础上,如何查询、增加、删除是关键。在后面的算法描述中,引入磁盘读写操作,分别记作diskRead(t,x), diskWrite(t, x),表示从子树t中读写x。

4.1 查询操作:BTreeSearch

查询操作的思路很容易理解:对于子树t,要搜寻关键字key,首先在子树t中寻找,若存在关键字,则返回,若不存在,则定位到对应的子树(假设一次读取一个节点,这一过程要继续读磁盘),递归搜索。其算法描述如下:
BTreeSearch(t, k){ i =1; while i <= t.n and k > t.key[i]{ i++; } if i <= t.n and k == t.key[i] return (t, i); // t表示子树根,i表示这个根节点中的index elif t.leaf return nil; else diskRead(t, c[i]) return BTreeSearch(t.c[i], k); }
显然,BTreeSearch算法的执行时间分为两部分:1)内存类遍历时间O(t);2)读写磁盘次数为O(h),设读取一次磁盘的时间为T,则总的时间为: O(ht) + TO(h).

4.2 插入操作BTreeInsert(t, k)

B树中的插入操作比较麻烦,因为要保持B树的特征不变。其关键在于要保持除根节点外的所有节点的关键字数目满足:d -1 <= n <= 2d,根据这一规则,分情况讨论如下:

  • 如果t为空树,则直接构造一个节点t,并将此节点作为根;
  • 如果t非空,则遍历t中的关键字,寻找插入点,此时情况又分几种情况:
    1. 如果t.leaf = true(为叶子节点),且t.n < 2d,则按照非降序原则插入关键字k;
    2. 如果t.leaf = true,且t.n = 2d – 1,即此节点是满的,则此节点t需要分裂以保持特性,分裂规则为:将关键字为[t.key1,t.key2,…,t.key2d-1]分裂为tleft=[t.key1,t.key2,…,t.keyd-1]、tright=[t.keyd+1,t.key2,…,t.key2d-1]两棵子树,然后将t.keyd插入到其父节点t.p的合适位置location, 并将tleft和tright分别作为t.p.key[location]的左右子树;如果在向父节点t.p插入t.keyd前,父节点已满,则按照此情况继续分裂父节点t.p;
    3. 如果t.leaf = false(为内部节点,非叶子节点),可以有两种思路,一种思路是先检查当前节点是否已满,如果已满,则分裂,然后再插入节点;另一种则是,先定位到需要插入的叶子节点位置,如果叶子节点已满,则分裂,分裂完成之后,再插入节点,分别介绍如下:
    • 如果t中的节点已满,则按照2)中的方法分裂,分裂后t.key[d]移到父节点t’.key[m],t.key[1]…t.key[d-1]作为t’.key[m]的左孩子left,t.key[d+1]…t.key[2d-1]作为t’.key[m]的右孩子right;然后根据k与t’.key[m]的大小决定插入到哪个子树,若k < t’.key[m],执行BTreeInsert(left, k),反之,则执行BTreeInsert(right, k);
    • 在t中寻找合适的位置m,使其满足: t.key[m] < k < t.key[m+1]或k < t.key[0];当t.key[m] < k < t.key[m+1]满足时,进入t.key[m]的右子树t.child[m],继续执行BTreeInsert(t.child[m], k);当k < t.key[0]时,进入 t.key[0]的左子树t.child[0],执行BTreeInsert(t.child[0], k);直到叶子节点,然后再从叶子节点逐层向上回溯,判断是否需要逐层向上分裂。(显然,这种方法比前一种方法多了一个回溯判断非叶子节点的情况,但是从总体执行效率上讲,两者是一致的,两种方法分裂的次数是一致的,前一种方法将分裂的情况分摊在每一次的插入操作中了,而后者则有可能在某次操作中执行更多的分裂,总体上而言,前者的均衡效果更好,现实中基本上都采用前者

为了不让大家搞晕,请查阅图4所示直观理解。

《基础-2:B树》 图4:B树中插入节点的过程(d=3)

其算法为:
BTreeInsert(t, k){ r = t.root; if r.n = 2d - 1{ s = allocateNode(); // 构造一个B树节点 t.root = s; s.leaf = false; s.n = 0; s.child[1] = r; BTreeSplitChild(s, 1); // 将t分裂 BTreeInsertNotFull(s, k); // 向非空B树中插入节点 } else BTreeInsertNotFull(r, k); }
分析BTreeInsert,读取磁盘的次数为O(h),分裂B树的次数也为O(h),即写磁盘的次数也为O(h),因此,读写总次数为O(h)

4.3 B树删除

在B树中删除节点时,同样要保持B树的特征,此时,关注的重点是:待删除的节点中,删除节点后,其节点个数是否会小于d-1.如果只满足这个要求,是否会出现问题,下面看一个具体的例子:

《基础-2:B树》 图5: d=3的B树

若删除关键字为1的元素,则变为:

《基础-2:B树》 图6: 删除1后的树的情况

非常幸运的是,在这个例子中貌似没有问题,因为根节点的关键字个数可以保持一个,如果图5中的(50, 100)不是根节点,而是内部节点,则删除关键字为1的元素后,图6就违反了B树的特征。

从上面分析可以看出,如果只要求删除节点过程中,仅仅满足节点数不小于d-1,会造成图5->图6变化的问题。

如果在B树T删除k的过程中,始终能够保持当前节点node中数量至少为d,则可以保证不会出现图5图6中那样的问题。在当前节点node中删除k的方法BTreeDelete(node, x)可分为如下几种情况(始终注意当前节点的意义,如下的几种情况明确说明了k是否在当前节点中的情况):

  • 如果node为叶子节点(意味着node.n >=d)且k在node中,则直接删除k;
  • 如果node为内部节点,且k在node中,则分为如下几种情况:
    a. 如果node中,k的左孩子left至少包含d个关键字,则以左孩子left为当前节点,寻找left中的最大关键字k’, 执行BTreeDelete(left, k’),并返回k’,以k’代替node节点中的k;
    b. 如果node中,k的右孩子right至少包含d个关键字,则以右孩子right为当前节点,寻找right中的最小关键字k’, 执行BTreeDelete(right, k’),并返回k’,以k’代替node节点中的k;
    c. 如果node中k的左右孩子关键字均只包含d-1个关键字,则将左右孩子合并即可;
  • 如果关键字k不在当前内部节点node中,若k在树中,则必然在node的某棵子树node.c[i]中。如果node.c[i].n >= d,已满足前面讨论的要求,递归删除即可;若node.c[i].n = d – 1,则要求通过某种操作满足node.c[i].n = d,以规避图5图6中出现的状况,然后继续执行BTreeDelete(node.c[i], k),具体可分为如下两种情况:
    a. 若node.c[i].n = d – 1,但它的相邻左兄弟或有兄弟的关键字数量不少于d,则通过node,将左兄弟或右兄弟的某个关键字移至node中(具体移动哪个关键字其实很容易获取),将node中的对应的关键字移至node.c[i]中;
    b. 若a不成立,则必然是左右兄弟关键字数量都是d-1,将两者合并即可.

分析这一删除过程,由于在当前子树中删除k时,始终保持了当前子树根节点的个数始终不小于d(整棵树的根节点除外),因此,不会出现在删除过程中向上回溯的过程,即节点中关键字移动控制在当前子树中。同样,在分析的过程中,始终是单向由树根向叶子前进,其访问磁盘的次数为O(h)次(h为树高)。

具体的例子如图7所示:

《基础-2:B树》 图7:删除节点图示

关于B树中删除元素的方法其实不止这一种,笔者本人最近也在尝试结合概率算法对B树中的操作进行优化,感兴趣的童鞋也可以进一步思考。

5 总结

B树在算法中的地位至关重要,它对高效访问外存提供了坚实的算法基础。在很多场合,童鞋们可能也听说过B+树,B+树其实就是B树的一个变体,其最大的不同之处在于B+树中的所有节点可以看成是索引,最终的数据内容在叶子节点上,且所有叶子节点组成链表的方式,因此,在B+树中寻找节点,可以同时使用B树搜索和链表搜索,结合具体场景,选择一个更加高效的搜索方法。

    原文作者:CodingTech
    原文地址: https://www.jianshu.com/p/c33c3f9fc422
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞