1. 多路查找树(B树)
内存一般都是由硅制的存储芯片组成,这种技术的每一个存储单位代价都要比磁存储技术昂贵两个数量级,因此基于磁盘技术的外存,容量比内存的容量至少大两个数量级。这也就是目前PC通常内存几个G而已、而硬盘却可以成百上千G容量的原因。
我们前面讨论过的数据结构,处理数据都是在内存中,因此考虑的都是内存中的运算时间复杂度。
但如若我们要操作的数据集非常大,大到内存已经没办法处理了怎么办呢?如数据库中的上千万条记录的数据表、硬盘中的上万个文件等。在这种情况下,对数据的处理需要不断从硬盘等存储设备中调入或调出内存页面。
一旦涉及到这样的外部存储设备,关于时间复杂度的计算就会发生变化,访问该集合元素的时间已经不仅仅是寻找该元素所需比较次数的函数,我们必须考虑对硬盘等外部存储设备的访问时间以及将会对该设备做出多少次单独访问。
试想一下,为了要在一个拥有几十万个文件的磁盘中查找一个文本文件,你设计的算法需要读取磁盘上万次还是读取几十次,这是有本质差异的。此时,为了降低对外存设备的访问次数,我们就需要新的数据结构来处理这样的问题。
我们之前谈的树,都是一个结点可以有多个孩子,但是它自身只存储一个元素。二叉树限制更多,结点最多只能有两个孩子。
个结点只能存储一个元素,在元素非常多的时候,就使得要么树的度非常大(结点拥有子树的个数的最大值),要么树的高度非常大,甚至两者都必须足够大才行。这就使得内存存取外存次数非常多,这显然成了时间效率上的瓶颈,这迫使我们要打破每一个结点只存储一个元素的限制,为此引入了多路查找树的概念。
多路查找树(muitl-way search tree),其每一个结点的孩子数可以多于两个,且每一个结点处可以存储多个元素。由于它是查找树,所有元素之间存在某种特定的排序关系。
在这里,每一个结点可以存储多少个元素,以及它的孩子数的多少是非常关键的。为此,我们讲解它的4种特殊形式:2-3树、2-3-4树、B树和B+树。
2. 2-3树
2-3树是这样的一棵多路查找树:其中的每一个结点都具有两个孩子(我们称它为2结点)或三个孩子(我们称它为3结点)。
一个2结点包含一个元素和两个孩子(或没有孩子),且与二叉排序树类似,左子树包含的元素小于该元素,右子树包含的元素大于该元素。不过,与二叉排序树不同的是,这个2结点要么没有孩子,要有就有两个,不能只有一个孩子。
一个3结点包含一小一大两个元素和三个孩子(或没有孩子),一个3结点要么没有孩子,要么具有3个孩子。如果某个3结点有孩子的话,左子树包含小于较小元素的元素,右子树包含大于较大元素的元素,中间子树包含介于两元素之间的元素。
并且2-3树中所有的叶子都在同一层次上。如下图所示,就是一棵有效的2-3树。
事实上,2-3树复杂的地方就在于新结点的插入和已有结点的删除。毕竟,每个结点可能是2结点也可能是3结点,要保证所有叶子都在同一层次,是需要进行一番复杂操作的。
2.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树插入的传播效应导致了根结点的拆分,则树的高度就会增加。
2.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结点。如图8-8-10所示,此时删除结点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树的插入和删除等所有的情况进行讲解,既占篇幅,又没必要,总的来说它是有规律的,需要你们在上面的这些例子中多去体会后掌握。
3. 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。
4. 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个孩子,其中⌈m/2⌉≤k≤m。每一个叶子结点n都有k-1个元素,其中⌈m/2⌉≤k≤m。■ 所有叶子结点都位于同一层次。■ 所有分支结点包含下列信息数据(n,A0,K1,A1,K2,A2,…,Kn,An),其中:Ki(i=1,2,…,n)为关键字,且Ki<Ki+1(i=1,2,…,n-1);Ai(i=0,2,…,n)为指向子树根结点的指针,且指针Ai-1所指子树中所有结点的关键字均小于Ki(i=1,2,…,n),An所指子树中所有结点的关键字均大于Kn,n(⌈m/2⌉-1≤n ≤m-1)为关键字的个数(或n+1为子树的个数)。
例如,在讲2-3-4树时插入9个数后的图转成B树示意就如下图的右图所示。左侧灰色方块表示当前结点的元素个数。
在B树上查找的过程是一个顺指针查找结点和在结点中查找关键字的交叉过程。
比方说,我们要查找数字7,首先从外存(比如硬盘中)读取得到根结点3、5、8三个元素,发现7不在当中,但在5和8之间,因此就通过A2再读取外存的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个结点,由于除根结点外每个分支结点至少有⌊m/2⌋棵子树,则第三层至少有2×⌊m/2⌋个结点,……,这样第k+1层至少有2×(⌊m/2⌋)k-1个结点,而实际上,k+1层的结点就是叶子结点。若m阶B树有n个关键字,那么当你找到了叶子结点,其实也就等于查找不成功的结点为n+1,因此n+1≥2×(⌊m/2⌋)k-1,就是说,在含有n个关键字的B树上查找时,从根结点到关键字结点的路径上涉及的结点数不超过
5. B+树
尽管前面我们已经讲了B树的诸多好处,但其实它还是有缺陷的。对于树结构来说,我们都可以通过中序遍历来顺序查找树中的元素,这一切都是在内存中进行。
可是在B树结构中,我们往返于每个结点之间也就意味着,我们必须得在硬盘的页面之间进行多次访问,如图8-8-18所示,我们希望遍历这棵B树,假设每个结点都属于硬盘的不同页面,我们为了中序遍历所有的元素,页面2→页面1→页面3→页面1→页面4→页面1→页面5。而且我们每次经过结点遍历时,都会对结点中的元素进行一次遍历,这就非常糟糕。有没有可能让遍历时每个元素只访问一次呢?
为了能够解决所有元素遍历等基本问题,我们在原有的B树结构基础上,加上了新的元素组织方式,这就是B+树。
B+树是应文件系统所需而出的一种B树的变形树,注意严格意义上讲,它其实已经不是第六章定义的树了。在B树中,每一个元素在该树中只出现一次,有可能在叶子结点上,也有可能在分支结点上。而在B+树中,出现在分支结点中的元素会被当作它们在该分支结点位置的中序后继者(叶子结点)中再次列出。另外,每一个叶子结点都会保存一个指向后一叶子结点的指针。
例如下图所示,就是一棵B+树的示意,灰色关键字即是根结点中的关键字在叶子结点再次列出,并且所有叶子结点都链接在一起。
一棵m阶的B+树和m阶的B树的差异在于:
■ 有n棵子树的结点中包含有n个关键字;
■ 所有的叶子结点包含全部关键字的信息,及指向含这些关键字记录的指针,叶子结点本身依关键字的大小自小而大顺序链接;
■ 所有分支结点可以看成是索引,结点中仅含有其子树中的最大(或最小)关键字。
这样的数据结构最大的好处就在于,如果是要随机查找,我们就从根结点出发,与B树的查找方式相同,只不过即使在分支结点找到了待查找的关键字,它也只是用来索引的,不能提供实际记录的访问,还是需要到达包含此关键字的终端结点。
如果我们是需要从最小关键字进行从小到大的顺序查找,我们就可以从最左侧的叶子结点出发,不经过分支结点,而是延着指向下一叶子的指针就可遍历所有的关键字。
B+树的结构特别适合带有范围的查找。比如查找我们学校18~22岁的学生人数,我们可以通过从根结点出发找到第一个18岁的学生,然后再在叶子结点按顺序查找到符合范围的所有记录。
B+树的插入、删除过程也都与B树类似,只不过插入和删除的元素都是在叶子结点上进行而已。