前言

部分内容摘自程杰的《大话数据结构》

1. 多路查找树(B树)

  内存一般都是由硅制的存储芯片组成,这种技术的每一个存储单位代价都要比磁存储技术昂贵两个数量级,因此基于磁盘技术的外存,容量比内存的容量至少大两个数量级。这也就是目前 PC 通常内存几个 G 而已、而硬盘却可以成百,上千 G 容量的原因。
  我们前面讨论过的数据结构,处理数据都是在内存中,因此考虑的都是内存中的运算时间复杂度。
  但如若我们要操作的数据集非常大,大到内存已经没办法处理了怎么办呢?如数据库中的上千万条记录的数据表、硬盘中的上万个文件等。在这种情况下,对数据的处理需要不断从硬盘等存储设备中调入或调出内存页面。
  一旦涉及到这样的外部存储设备,关于时间复杂度的计算就会发生变化,访问该集合元素的时间已经不仅仅是寻找该元素所需比较次数的函数,我们必须考虑对硬盘等外部存储设备的访问时间以及将会对该设备做出多少次单独访问。
  试想一下,为了要在一个拥有几十万个文件的磁盘中查找一个文本文件,你设计的算法需要读取磁盘上万次还是读取几十次,这是有本质差异的。此时,为了降低对外存设备的访问次数,我们就需要新的数据结构来处理这样的问题。
  我们之前谈的树,都是一个结点可以有多个孩子,但是它自身只存储一个元素。二叉树限制更多,结点最多只能有两个孩子。
  一个结点只能存储一个元素, 在元素非常多的时候,就使得要么树的度非常大(结点拥有子树的个数的最大值),要么树的高度非常大,甚至两者都必须足够大才行。这就使得内存存取外存次数非常多,这显然成了时间效率上的瓶颈,这迫使我们要打破每一个结点只存储一个元素的限制, 为此引入了多路查找树的概念。
  多路查找树(Mut-Way Search Tree),其每一个结点的孩子数可以多于两个,且每一个结点处可以存储多个元素。由于它是查找树,所有元素之间存在某种特定的排序关系。
  在这里,每一个结点可以存储多少个元素,以及它的孩子数的多少是非常关键的。为此,我们讲解它的 4 种特殊形式:2-3树2-3-4树B树B+树

1) 2-3树

  2-3树是这样的一棵多路查找树:其中的每一个结点都具有两个孩子(我们称它为2结点)或三个孩子(我们称它为3结点)。
  一个2结点包含一个元素和两个孩子(或没有孩子),且与二叉排序树类似,左子树包含的元素小于该元素,右子树包含的元素大于该元素。不过,与二叉排序树不同的是,这个 2 结点要么没有孩子,要有就有两个,不能只有一个孩子。
  一个3结点包含一小一大两个元素和三个孩子(或没有孩子),一个 3 结点要么没有孩子,要么具有 3 个孩子。如果某个 3 结点有孩子的话,左子树包含小于较小元素的元素,右子树包含大于较大元素的元素,中间子树包含介于两元素之间的元素。
  并且 2-3 树中所有的叶子都在同一层次上。如下图所示,就是一棵有效的 2-3 树。
  事实上,2-3 树复杂的地方就在于新结点的插入和已有结点的删除。毕竟,每个结点可能是 2 结点也可能是 3 结点,要保证所有叶子都在同一层次,是需要进行一番复杂操作的。

1.1) 2-3树的插入实现

  对于 2-3 树的插入来说,与二叉排序树相同,插入操作一定是发生在叶子结点上。可与二叉排序树不同的是,2-3 树插入一个元素的过程有可能会对该树的其余结构产生连锁反应。
  2-3 树插入可分为三种情况。

  1. 对于空树,插入一个 2 结点即可,这很容易理解。
  2. 插入结点到一个 2 结点的叶子上。应该说,由于其本身就只有一个元素,所以只需要将其升级为 3 结点即可。如下图所示。我们希望从左图的 2-3 树中插入元素 3,根据遍历可知,3 比 8 小比 4 小,于是就只能考虑插入到叶子结点 1 所在的位置,因此很自然的想法就是将此结点变成一个 3 结点,即右图这样完成插入操作。当然,要视插入的元素与当前叶子结点的元素比较大小后,决定谁在左谁在右。例如,若插入的是 0,则此结点就是 “0” 在左 “1” 在右了。
  3. 要往 3 结点中插入一个新元素。 因为 3 结点本身已经是 2-3 树的结点最大容量(已经有两个元素),因此就需要将其拆分,且将树中两元素或插入元素的三者中选择其一向上移动一层。复杂的情况也正在于此。

  第一种情况,见下图,需要向左图中插入元素 5。经过遍历可得到元素 5 比 8 小比 4 大,因此它应该是需要插入在拥有 6、7 元素的 3 结点位置。问题就在于,6 和 7 结点已经是 3 结点,不能再加。此时发现它的双亲结点 4 是个 2 结点,因此考虑让它升级为 3 结点,这样它就得有三个孩子,于是就想到,将 6、7 结点拆分,让 6 与 4 结成 3 结点,将 5 成为它的中间孩子,将 7 成为它的右孩子,如下图的右图所示。

  另一种情况,如下图所示,需要向左图中插入元素 11。 经过遍历可得到元素 11 比 12、14 小比 9、10 大,因此它应该是需要插入在拥有 9、10 元素的 3 结点位置。同样道理,9 和 10 结点不能再增加结点。此时发现它的双亲结点 12、14 也是一个 3 结点,也不能再插入元素了。再往上看,12、14 结点的双亲,结点 8 是个 2 结点。于是就想到,将 9、10 拆分,12、14 也拆分,让根结点 8 升级为 3 结点,最终形成如下图的右图样子。

  再来看个例子,如下图所示,需要在左图中插入元素 2。经过遍历可得到元素 2 比 4 小 6 比 1 大,因此它应该是需要插入在拥有 1、3元素的 3 结点位置。与上例一样,你会发现,1、3 结点,4、6 结点都是 3 结点,都不能再插入元素了,再往上看,8、12 结点还是一个 3 结点,那就意味着,当前我们的树结构是三层已经不能满足当前结点增加的需要了。于是将 1、3 拆分,4、6 拆分,连根结点 8、12 也拆分,最终形成如下图的右图样子。

  通过这个例子,也让我们发现,如果 2-3 树插入的传播效应导致了根结点的拆分,则树的高度就会增加。

1.2) 2-3树的删除实现

  对于 2-3 树的删除来说,如果对前面插入的理解足够到位的话,应该不是难事了。2-3 树的删除也分为三种情况。与插入相反,我们从 3 结点开始说起。

  1. 所删除元素位于一个 3 结点的叶子结点上,这非常简单,只需要在该结点处删除该元素即可,不会影响到整棵树的其他结点结构。如下图所示,删除元素 9,只需要将此结点改成只有元素 10 的 2 结点即可。
  2. 所删除的元素位于一个 2 结点上,即要删除的是一个只有一个元素的结点。如果按照以前树的理解,删除即可,可现在的 2-3 树的定义告诉我们这样做是不可以的。比如下图所示,如果我们删除了结点 1,那么结点 4 本来是一个 2 结点(它拥有两个孩子),此时它就不满足定义了。

      因此,对于删除叶子是 2 结点的情况,我们需要分四种情形来处理。
      情形一,此结点的双亲也是 2 结点,且拥有一个 3 结点的右孩子。如下图所示,删除结点 1,那么只需要左旋,即 6 成为双亲,4 成为 6 的左孩子,7 是 6 的右孩子。

      情形二,此结点的双亲是 2 结点,它的右孩子也是 2 结点。如下图所示,此时删除结点 1,如果直接左旋会造成没有右孩子,因此需要对整棵树变形,办法就是,我们目标是让结点 7 变成 3 结点,那就得让比 7 稍大的元素 8 下来,随即就得让比元素 8 稍大的元素 9 补充结点 8 的位置,于是就有了下图的中间图,于是再用左旋的方式,变成右图结果。

      情形三,此结点的双亲是一个 3 结点。如下图所示,此时删除结点 10,意味着双亲 12、14 这个结点不能成为 3 结点了,于是将此结点拆分,并将 12 与 13 合并成为左孩子。

      情形四,如果当前树是一个满二叉树的情况,此时删除任何一个叶子都会使得整棵树不能满足 2-3 树的定义。如下图所示,删除叶子结点 8 时(其实删除任何一个结点都一样),就不得不考虑要将 2-3 的层数减少,办法是将 8 的双亲和其左子树 6 合并为一 3 个结点,再将 14 与 9 合并为 3 结点,最后成为右图的样子。
  3. 所删除的元素位于非叶子的分支结点。此时我们通常是将树按中序遍历后得到此元素的前驱或后继元素,考虑让它们来补位即可。

  如果我们要删除的分支结点是 2 结点。如下图所示我们要删除 4 结点,分析后得到它的前驱是 1 后继是 6,显然,由于 6、7 是 3 结点,只需要用 6 来补位即可,如下图右图所示。

  如果我们要删除的分支结点是 3 结点的某一元素,如下图所示我们要删除 12、14 结点的 12,此时,经过分析,显然应该是将是 3 结点的左孩子的 10 上升到删除位置合适。

  当然,如果对 2-3 树的插入和删除等所有的情况进行讲解,既占篇幅,又没必要,总的来说它是有规律的,需要在上面的这些例子中多去体会后掌握。

2) 2-3-4树

  有了 2-3 树的讲解,2-3-4树就很好理解了,它其实就是2-3树的概念扩展,包括了 4 结点的使用。一个 4 结点包含小中大三个元素和四个孩子(或没有孩子),一个 4 结点要么没有孩子,要么具有 4 个孩子。如果某个 4 结点有孩子的话,左子树包含小于最小元素的元素;第二子树包含大于最小元素,小于第二元素的元素;第三子树包含大于第二元素,小于最大元素的元素;右子树包含大于最大元素的元素。
  由于 2-3-4 树和 2-3 树是类似的,这里就简单介绍一下,如果我们构建一个数组为 {7,1,2,5,6,9,8,4,3} 的 2-3-4 树的过程,如下图所示。图 1 是在分别插入 7、1、2 时的结果图,因为 3 个元素满足 2-3-4 树的单个4结点定义,因此此时不需要拆分,接着插入元素 5,因为已经超过了 4 结点的定义,因此拆分为图 2 的形状。之后的图其实就是在元素不断插入时最后形成了图 7 的 2-3-4 树。

  下图是对一个 2-3-4 树的删除结点的演变过程,删除顺序是 1、6、3、4、5、2、9。

3) B树

  本节名称叫B树,但到了现在才开始提到它,似乎这主角出来的实在太晚了,可其实,我们前面一直都在讲 B 树。
  B树(B-tree)是一种平衡的多路查找树,2-3 树和 2-3-4 树都是 B 树的特例。结点最大的孩子数目称为B树的阶(order), 因此,2-3 树是 3 阶 B 树,2-3-4 树是 4 阶 B 树。
  一个m阶的 B 树具有如下属性:

  • 如果根结点不是叶结点, 则其至少有两棵子树。
  • 每一个非根的分支结点都有k-1个元素和k个孩子,其中 \lceilm/2\rceil\leqk\leqm。每一个叶子结点n都有k-1个元素,其中\lceilm/2\rceil\leqk\leqm。
  • 所有叶子结点都位于同一层次。
  • 所有分支结点包含下列信息数据 (n, A0_0, K1_1, A1_1, K2_2, A2_2, ···, Kn_n, An_n),其中:Ki_i(i=1, 2, ···, n) 为关键字,且 Ki_i<Ki+1_{i+1}(i=1, 2, ···, n-1);Ai_i(i=0, 2, ···, n) 为指向子树根结点的指针,且指针 Ai+1_{i+1} 所指子树中所有结点的关键字均小于 Ki_i(i=1, 2, ···, n),An_n 所指子树中所有结点的关键字均大于 Kn_n,n(\lceilm/2\rceil-1\leqn\leqm-1) 为关键字的个数(或n+1为子树的个数)。

  例如,在讲 2-3-4 树时插入 9 个数后的图转成 B 树示意就如下图的右图所示。左侧灰色方块表示当前结点的元素个数。

  在 B 树上查找的过程是一个顺指针查找结点和在结点中查找关键字的交叉过程。
  比方说,我们要查找数字 7,首先从外存(比如硬盘中)读取得到根结点 3、5、8 三个元素,发现 7 不在当中,但在 5 和 8 之间,因此就通过 A2_2 再读取外存的 6、7 结点,查找到所要的元素。
  至于 B 树的插入和删除,方式是与 2-3 树和 2-3-4 树相类似的,只不过阶数可能会很大而已。
  我们在本节的开头提到,如果内存与外存交换数据次数频繁,会造成了时间效率上的瓶颈,那么 B 树结构怎么就可以做到减少次数呢?
  我们的外存,比如硬盘,是将所有的信息分割成相等大小的页面,每次硬盘读写的都是一个或多个完整的页面,对于一个硬盘来说,一页的长度可能是 211 到 214 个字节。
  在一个典型的 B 树应用中,要处理的硬盘数据量很大,因此无法一次全部装入内存。因此我们会对 B 树进行调整,使得 B 树的阶数(或结点的元素)与硬盘存储的页面大小相匹配。比如说一棵 B 树的阶为 1001(即 1 个结点包含 1000 个关键字),高度为 2,它可以储存超过 10 亿个关键字,我们只要让根结点持久地保留在内存中,那么在这棵树上,寻找某一个关键字至多需要两次硬盘的读取即可。这就好比我们普通人数钱都是一张一张的数,而银行职员数钱则是五张、十张,甚至几十张一数,速度当然是比常人快了不少。
  通过这种方式,在有限内存的情况下,每一次磁盘的访问我们都可以获得最大数量的数据。由于 B 树每结点可以具有比二叉树多得多的元素,所以与二叉树的操作不同,它们减少了必须访问结点和数据块的数量,从而提高了性能。可以说,B 树的数据结构就是为内外存的数据交互准备的。
  那么对于n个关键字的m阶 B 树,最坏情况是要查找几次呢?我们来作一分析。
  第一层至少有 1 个结点,第二层至少有 2 个结点,由于除根结点外每个分支结点至少有 \lceilm/2\rceil 棵子树,则第三层至少有 2×\times\lceilm/2\rceil 个结点,······,这样第k+1层至少有 2×(\times(\lceilm/2)k1\rceil)^{k-1} 个结点,而实际上,k+1层的结点就是叶子结点。若m阶 B 树有n个关键字,那么当你找到了叶子结点,其实也就等于查找不成功的结点为n+1,因此 n+1\geq 2×(\times(\lceilm/2)k1\rceil)^{k-1},即:

klogm2(n+12+1)k\leq log_{\lceil\frac{m}{2}\rceil}(\frac{n+1}{2}+1)

  也就是说,在含有n个关键字的 B 树上查找时,从根结点到关键字结点的路径上涉及的结点数不超过logm2(n+12+1)log_{\lceil\frac{m}{2}\rceil}(\frac{n+1}{2}+1)

4) B+树

  尽管前面已经讲了 B 树的诸多好处,但其实它还是有缺陷的。对于树结构来说,我们都可以通过中序遍历来顺序查找树中的元素,这一切都是在内存中进行。
  可是在 B 树结构中,我们往返于每个结点之间也就意味着,我们必须得在硬盘的页面之间进行多次访问,如下图所示,我们希望遍历这棵 B 树,假设每个结点都属于硬盘的不同页面,我们为了中序遍历所有的元素,页面2\rightarrow页面1\rightarrow页面3\rightarrow页面1\rightarrow页面4\rightarrow页面1\rightarrow页面5。而且我们每次经过结点遍历时,都会对结点中的元素进行一次遍历,这就非常糟糕。有没有可能让遍历时每个元素只访问一次呢?

  为了说明这个解决的办法,我举个例子。一个优秀的企业尽管可能有非常成熟的树状组织结构,但是这并不意味着员工也很满意,恰恰相反,由于企业管理更多考虑的是企业的利益,这就容易忽略员工的各种诉求,造成了管理者与员工之间的矛盾。正因为此,工会就产生了,工会原意是指基于共同利益而自发组织的社会团体。这个共同利益团体诸如为同一雇主工作的员工, 在某一产业领域的个人。 工会组织成立的主要作用,可以与雇主谈判工资薪水、工作时限和工作条件等。这样,其实在整个企业的运转过程中,除了正规的层级管理外,还有一个代表员工的团队在发挥另外的作用。
  同样的,为了能够解决所有元素遍历等基本问题,我们在原有的 B 树结构基础上,加上了新的元素组织方式,这就是B+树
  B+树是应文件系统所需而出的一种 B 树的变形树,注意严格意义上讲,它其实已经不是前面定义的树了。在 B 树中,每一个元素在该树中只出现一次,有可能在叶子结点上,也有可能在分支结点上。而在 B+树 中,出现在分支结点中的元素会被当作它们在该分支结点位置的中序后继者(叶子结点)中再次列出。另外,每一个叶子结点都会保存一个指向后一叶子结点的指针。
  例如下图所示,就是一棵 B+树 的示意,灰色关键字即是根结点中的关键字在叶子结点再次列出,并且所有叶子结点都链接在-起。

  一棵m阶的 B+树 和m阶的 B 树的差异在于:

  • n棵子树的结点中包含有n个关键字;
  • 所有的叶子结点包含全部关键字的信息,及指向含这些关键字记录的指针,叶子结点本身依关键字的大小自小而大顺序链接;
  • 所有分支结点可以看成是索引,结点中仅含有其子树中的最大(或最小)关键字。

  这样的数据结构最大的好处就在于,如果是要随机查找,我们就从根结点出发,与 B 树的查找方式相同,只不过即使在分支结点找到了待查找的关键字,它也只是用来索引的,不能提供实际记录的访问,还是需要到达包含此关键字的终端结点。
  如果我们是需要从最小关键字进行从小到大的顺序查找,我们就可以从最左侧的叶子结点出发,不经过分支结点,而是延着指向下一叶子的指针就可遍历所有的关键字。
  B+树 的结构特别适合带有范围的查找。比如查找我们学校 18~22 岁的学生人数,我们可以通过从根结点出发找到第一个 18 岁的学生,然后再在叶子结点按顺序查找到符合范围的所有记录。
  B+树 的插入、删除过程也都与 B 树类似,只不过插入和删除的元素都是在叶子结点上进行而已。

2. 总结

  B 树这种数据结构是针对内存与外存之间的存取而专门设计的。由于内外存的查找性能更多取决于读取的次数,因此在设计中要考虑 B 树的平衡和层次。