B树是为实现高效的磁盘存取而设计的多叉平衡搜索树。这个概念在文件系统,数据库系统中非常重要。当然,有关于B树的产生,发展,结构等等方面的介绍已经非常详细,所以本文只是介绍有关于B树和B+树最核心的知识点,也算是我本人的学习笔记。至于详细的资料,因为毕竟有着太多,所以不再赘述。可以向大家推荐一篇博客:从B树、B+树、B*树谈到R 树,这篇文章中,作者对于B树系列数据结构的讲解非常详细,我的这篇博客,也是大量参考了人家的很多例子和描述。
B树
一、基本原理
首先,简单说一下B树产生的原因。B树是一种查找树,我们知道,这一类树(比如二叉查找树,红黑树等等)最初生成的目的都是为了解决某种系统中,查找效率低的问题。B树也是如此,它最初启发于二叉查找树,二叉查找树的特点是每个非叶节点都只有两个孩子节点。然而这种做法会导致当数据量非常大时,二叉查找树的深度过深,搜索算法自根节点向下搜索时,需要访问的节点也就变的相当多。如果这些节点存储在外存储器中,每访问一个节点,相当于就是进行了一次I/O操作,随着树高度的增加,频繁的I/O操作一定会降低查询的效率。
这里有一个基本的概念,就是说我们从外存储器中读取信息的步骤,简单来分,大致有两步:
- 找到存储这个数据所对应的磁盘页面,这个过程是机械化的过程,需要依靠磁臂的转动,找到对应磁道,所以耗时长。
- 读取数据进内存,并实施运算,这是电子化的过程,相当快。
综上,对于外存储器的信息读取最大的时间消耗在于寻找磁盘页面。那么一个基本的想法就是能不能减少这种读取的次数,在一个磁盘页面上,多存储一些索引信息。B树的基本逻辑就是这个思路,它要改二叉为多叉,每个节点存储更多的指针信息,以降低I/O操作数。
二、基本结构
1. B树的定义
有关于B树概念的定义,不同的资料在表述上有所差别。我在这里采用《算导》中的定义,用最小度t<script type="math/tex" id="MathJax-Element-108">t</script>来定义B树。一棵最小度为t<script type="math/tex" id="MathJax-Element-109">t</script>的B树是满足如下四个条件的平衡多叉树:
每个节点最多包含2t?1<script type="math/tex" id="MathJax-Element-110">2t - 1</script>个关键字;除根节点外的每个节点至少有t?1<script type="math/tex" id="MathJax-Element-111">t - 1</script>个关键字(t≤2<script type="math/tex" id="MathJax-Element-112">t \leq 2</script>),根节点至少有一个关键字;
一个节点u<script type="math/tex" id="MathJax-Element-113">u</script>中的关键字按非降序排列:u.key1≤u.key2≤…u.keyn<script type="math/tex" id="MathJax-Element-114">u.key_1 \leq u.key_2 \leq \dots u.key_n</script>;
每个节点的关键字对其子树的范围分割。设节点u<script type="math/tex" id="MathJax-Element-115">u</script>有n+1<script type="math/tex" id="MathJax-Element-116">n + 1</script>个指针,指向其n+1<script type="math/tex" id="MathJax-Element-117">n + 1</script>棵子树,指针为u.p1,…u.pn<script type="math/tex" id="MathJax-Element-118">u.p_1, \dots u.p_n</script>,关键字ki<script type="math/tex" id="MathJax-Element-119">k_i</script>为u.pi<script type="math/tex" id="MathJax-Element-120">u.p_i</script>所指的子树中的关键字,有k1≤u.key1≤k2≤u.key2…<script type="math/tex" id="MathJax-Element-121">k_1 \leq u.key_1 \leq k_2 \leq u.key_2 \dots</script>成立;
所有叶子节点具有相同的深度,即树的高度h<script type="math/tex" id="MathJax-Element-122">h</script>。这表明B树是平衡的。平衡性其实正是B树名字的来源,B表示的正是单词Balanced;
一个标准的B树如下图:
2. B树的高度
我直接给出结论了:对于一个包含n<script type="math/tex" id="MathJax-Element-123">n</script>个关键字(n≥1<script type="math/tex" id="MathJax-Element-124">n \geq 1</script>),最小度数t≥2<script type="math/tex" id="MathJax-Element-125">t \geq 2</script>的B树T,其高度h<script type="math/tex" id="MathJax-Element-126">h</script>满足如下规律:
h≤logtn+12
<script type="math/tex; mode=display" id="MathJax-Element-127">\begin{equation}
h \leq \log_{t}{\frac{n + 1}{2}}
\end{equation}</script>
在搜索B树时,很明显,访问节点(即读取磁盘)的次数与树的高度呈正比,而B树与红黑树和普通的二叉查找树相比,虽然高度都是对数数量级,但是显然B树中log<script type="math/tex" id="MathJax-Element-128">log</script>函数的底可以比2更大,因此,和二叉树相比,极大地减少了磁盘读取的次数。
三、搜索算法
这里,我直接用博客从B树、B+树、B*树谈到R 树中的例子(因为这个例子非常好,也有现成的图示,就直接拿来用,不再自己班门弄斧了),一棵已经建立好的B树如下图所示,我们的目的是查找关键字为29的文件:
先简单对上图说明一下:
下面,看看搜索关键字的29的文件的过程:
从根节点开始,读取根节点信息,根节点有2个关键字:17和35。因为17 < 29 < 35,所以找到指针P2指向的子树,也就是磁盘块3(1次I/0操作)
读取当前节点信息,当前节点有2个关键字:26和30。26 < 29 < 30,找到指针P2指向的子树,也就是磁盘块8(2次I/0操作)
读取当前节点信息,当前节点有2个关键字:28和29。找到了!(3次I/0操作)
由上面的过程可见,同样的操作,如果使用平衡二叉树,那么需要至少4次I/O操作,B树比之二叉树的这种优势,还会随着节点数的增加而增加。另外,因为B树节点中的关键字都是排序好的,所以,在节点中的信息被读入内存之后,可以采用二分查找这种快速的查找方式,更进一步减少了读入内存之后的计算时间,由此更能说明对于外存数据结构来说,I/O次数是其查找信息中最大的时间消耗,而我们要做的所有努力就是尽量在搜索过程中减少I/O操作的次数。
四、向B树插入关键字
向B树种插入关键字的过程与向二叉查找树中插入关键字的过程类似,但是要稍微复杂一点,因为根据上面B树的定义,我们可以看出,B树每个节点中关键字的个数是有范围要求的,同时,B树是平衡的,所以,如果像二叉查找树那样,直接找到相关的叶子,插入关键字,有可能会导致B树的结构发生变化而这种变化会使得B树不再是B树。
所以,我们这样来设计B树种对新关键字的插入:首先找到要插入的关键字应该插入的叶子节点(为方便描述,设这个叶子节点为u<script type="math/tex" id="MathJax-Element-129">u</script>),如果u<script type="math/tex" id="MathJax-Element-130">u</script>是满的(恰好有2t?1<script type="math/tex" id="MathJax-Element-131">2t - 1</script>个关键字),那么由于不能将一个关键字插入满的节点,我们需要对u<script type="math/tex" id="MathJax-Element-132">u</script>按其当前排在中间关键字u.keyt<script type="math/tex" id="MathJax-Element-133">u.key_t</script>进行分裂,分裂成两个节点u1,u2<script type="math/tex" id="MathJax-Element-134">u_1, u_2</script>;同时,作为分裂标准的关键字u.keyt<script type="math/tex" id="MathJax-Element-135">u.key_t</script>会被上移到u<script type="math/tex" id="MathJax-Element-136">u</script>的父节点中,在u.keyt<script type="math/tex" id="MathJax-Element-137">u.key_t</script>插入前,如果u<script type="math/tex" id="MathJax-Element-138">u</script>的父节点未满,则直接插入即可;如果u<script type="math/tex" id="MathJax-Element-139">u</script>的父节点已满,则按照上面的方法对u<script type="math/tex" id="MathJax-Element-140">u</script>的父节点分裂,这个过程如果一直不停止的话,最终会导致B树的根节点分裂,B树的高度增加一层。
我用《算导》中的一个题目展示一下这种插入关键字的过程:
现在我们要将关键字序列:F, S, Q, K, C, L, H, T, V, W, M, R, N, P, A, B, X, Y依次插入一棵最小度为2的B树中。也就是说,这棵树的节点中,最多有3个关键字,最少有1个关键字。
第1步,F, S, Q可以被插入一个节点(也就是根节点)
第2步,插入关键字K,因为节点已满,所以在插入前,发生分裂,中间关键字Q上移,建立了一个新的根节点:
第3步,插入关键字C:
第4步,插入关键字L,L应该被插入到根节点的左侧的孩子中,因为此时该节点已满,所以在插入前,发生分裂:
第5步,插入关键字H, T, V,这个过程没有发生节点的分裂:
第6步,插入关键字W,W应该被插入到根节点的最右侧的孩子中,因为此时该节点已满,所以在插入前,关键字T上移,最右端的叶子节点发生分裂:
第7步,插入关键字M,M应该被插入到根节点的左起第2个孩子中,因为此时该节点已满,所以在插入前,发生分裂,分裂之后,中间关键字K上移,导致根节点发生分裂,树高增加1:
第8步,同样的道理,插入关键字R, N, P, A, B, X, Y:最终得到的B树如下:
五、从B树删除关键字
删除操作的基本思想和插入操作是一样的,都是不能因为关键字的改变而改变B树的结构。插入操作主要防止的是某个节点中关键字的个数太多,所以采用了分裂;删除则是要防止某个节点中,因删除了关键字而导致这个节点的关键字个数太少,所以采用了合并操作。
下面分三种情况来讨论下删除操作是如何工作的,这个过程的顺序是自根节点起向下遍历B树
Case - 1:如果要删除的关键字k<script type="math/tex" id="MathJax-Element-141">k</script>在节点u<script type="math/tex" id="MathJax-Element-142">u</script>中,而且u<script type="math/tex" id="MathJax-Element-143">u</script>是叶子节点,那么直接删除k<script type="math/tex" id="MathJax-Element-144">k</script>
Case - 2:如果要删除的关键字k<script type="math/tex" id="MathJax-Element-145">k</script>在节点u<script type="math/tex" id="MathJax-Element-146">u</script>中,而且u<script type="math/tex" id="MathJax-Element-147">u</script>是内部节点,那么分以下3种情况讨论:
(1) 如果u<script type="math/tex" id="MathJax-Element-148">u</script>中\textcolor{red}{前于}k<script type="math/tex" id="MathJax-Element-149">k</script>的子节点u1<script type="math/tex" id="MathJax-Element-150">u_1</script>中至少含有t<script type="math/tex" id="MathJax-Element-151">t</script>个关键字,则找出k<script type="math/tex" id="MathJax-Element-152">k</script>在以u1<script type="math/tex" id="MathJax-Element-153">u_1</script>为根的子树中的\textcolor{red}{前驱}k′<script type="math/tex" id="MathJax-Element-154">k‘</script>(前驱的意思是u′<script type="math/tex" id="MathJax-Element-155">u‘</script>中比k<script type="math/tex" id="MathJax-Element-156">k</script>大的关键字中最小的关键字),然后在以u1<script type="math/tex" id="MathJax-Element-157">u_1</script>为根的子树中删除k′<script type="math/tex" id="MathJax-Element-158">k‘</script>,并在u<script type="math/tex" id="MathJax-Element-159">u</script>中以k′<script type="math/tex" id="MathJax-Element-160">k‘</script>替代k<script type="math/tex" id="MathJax-Element-161">k</script>
(2) 如果u<script type="math/tex" id="MathJax-Element-162">u</script>中\textcolor{red}{后于}k<script type="math/tex" id="MathJax-Element-163">k</script>的子节点u2<script type="math/tex" id="MathJax-Element-164">u_2</script>中至少含有t<script type="math/tex" id="MathJax-Element-165">t</script>个关键字,则找出k<script type="math/tex" id="MathJax-Element-166">k</script>在以u2<script type="math/tex" id="MathJax-Element-167">u_2</script>为根的子树中的\textcolor{red}{后继}k′<script type="math/tex" id="MathJax-Element-168">k‘</script>,然后在以u2<script type="math/tex" id="MathJax-Element-169">u_2</script>为根的子树中删除k′<script type="math/tex" id="MathJax-Element-170">k‘</script>,并在u<script type="math/tex" id="MathJax-Element-171">u</script>中以k′<script type="math/tex" id="MathJax-Element-172">k‘</script>替代k<script type="math/tex" id="MathJax-Element-173">k</script>。可以看出(2)是(1)的一个对称过程
(3) 如果u1,u2<script type="math/tex" id="MathJax-Element-174">u_1, u_2</script>中的关键字个数都是t?1<script type="math/tex" id="MathJax-Element-175">t - 1</script>,则将k<script type="math/tex" id="MathJax-Element-176">k</script>和u2<script type="math/tex" id="MathJax-Element-177">u_2</script>合并后并入u1<script type="math/tex" id="MathJax-Element-178">u_1</script>,这样u<script type="math/tex" id="MathJax-Element-179">u</script>就失去了k<script type="math/tex" id="MathJax-Element-180">k</script>和指向u2<script type="math/tex" id="MathJax-Element-181">u_2</script>的指针,最后递归地从u1<script type="math/tex" id="MathJax-Element-182">u_1</script>中删除k<script type="math/tex" id="MathJax-Element-183">k</script>
Case - 3:如果要删除的关键字k<script type="math/tex" id="MathJax-Element-184">k</script>不在当前节点u<script type="math/tex" id="MathJax-Element-185">u</script>中,而且u<script type="math/tex" id="MathJax-Element-186">u</script>是内部节点(如果自上而下扫描到叶子都没有这个关键字的话,那就说明要删除的关键字根本就不存在,所以此处只考虑u<script type="math/tex" id="MathJax-Element-187">u</script>是内部节点的情况),则首先确定包含k<script type="math/tex" id="MathJax-Element-188">k</script>的u<script type="math/tex" id="MathJax-Element-189">u</script>的子树,我们这里设为u.pi<script type="math/tex" id="MathJax-Element-190">u.p_i</script>。如果u.pi<script type="math/tex" id="MathJax-Element-191">u.p_i</script>中至少含有t<script type="math/tex" id="MathJax-Element-192">t</script>个关键字,那么继续扫描,寻找下一个要被扫描的子树;如果u.pi<script type="math/tex" id="MathJax-Element-193">u.p_i</script>中只含有t?1<script type="math/tex" id="MathJax-Element-194">t - 1</script>个关键字,则需要分下面两种情况进行操作:
(1) 如果u.pi<script type="math/tex" id="MathJax-Element-195">u.p_i</script>至少有一个相邻的兄弟比较“丰满”(即这个兄弟至少有t<script type="math/tex" id="MathJax-Element-196">t</script>个关键字)。则将u<script type="math/tex" id="MathJax-Element-197">u</script>中的一个关键字降至u.pi<script type="math/tex" id="MathJax-Element-198">u.p_i</script>,同时令u.pi<script type="math/tex" id="MathJax-Element-199">u.p_i</script>的最“丰满”的兄弟中升一个关键至u<script type="math/tex" id="MathJax-Element-200">u</script>。然后继续扫描B树,寻找k<script type="math/tex" id="MathJax-Element-201">k</script>
(2) 如果u.pi<script type="math/tex" id="MathJax-Element-202">u.p_i</script>的两个相邻的兄弟都不“丰满”(都只有t?1<script type="math/tex" id="MathJax-Element-203">t - 1</script>个关键字)。则令u.pi<script type="math/tex" id="MathJax-Element-204">u.p_i</script>和其一个兄弟合并,再将u<script type="math/tex" id="MathJax-Element-205">u</script>的一个关键字降至新合并的节点。使之成为该节点的中间关键字。
举个例子,就可以清晰看到上面说的这几种删除的情况。拿下图所示的最小度为3的B树为例(即树中除根和叶子之外的节点只能有2,3,4三种情况的关键字个数):
Step 1: 删除上图中的关键字F,过程如下:先扫描根节点(含P),再扫描其左孩子(含CGM),发现丰满,继续扫描到左起第二个叶子,然后就是符合Case - 1的情况了。结果如下图所示:
Step 2: 再删除M,此时遇到Case - 2(a)的情况,结果如下图所示:
Step 3: 再删除G,G的前驱、后驱都是不丰满的。也就是Case - 2(c)的情况,结果如下图所示:
Step 4: 再删除D,扫描至含CL的节点后,发现它不丰满,且他的兄弟也不丰满。则将节点CL和TX合并,并降关键字P至新合并的节点。也就是Case - 3(2)的情况,结果如下图所示,此时,树高减1:
Step 5: 再删除B,也就是Case - 3(1)的情况,结果如下图所示:
下面总结一下B树的删除原理:
- 基本原则是不能破坏关键字个数的限制;
- 如果在当前节点中,找到了要删的关键字,且当前节点为内部节点。那么,如果有比较丰满的前驱或后继,借一个上来,再把要删的关键字降下去,在子树中递归删除;如果没有比较丰满的前驱或后继,则令前驱与后继合并,把要删的关键字降下去,递归删除;
- 如果在当前节点中,还未找到要删的关键字,且当前节点为内部节点。那么去找下一步应该扫描的孩子,并判断这个孩子是否丰满,如果丰满,继续扫描;如果不丰满,则看其有无丰满的兄弟,有的话,从父亲那里接一个,父亲再找其最丰满的兄弟借一个;如果没有丰满的兄弟,则合并,再令父亲下降,以保证B树的结构。
B+树
B+树的定义
B+树是B树的一种变形,它更适合实际应用中操作系统的文件索引和数据库索引。定义如下:(为和大多资料保持一致,这里使用阶数m<script type="math/tex" id="MathJax-Element-206">m</script>来定义B+树,而不像之前的B树中,使用的是最小度t<script type="math/tex" id="MathJax-Element-207">t</script>来定义)
- 除根节点外的内部节点,每个节点最多有m<script type="math/tex" id="MathJax-Element-208">m</script>个关键字,最少有?m2?<script type="math/tex" id="MathJax-Element-209">\lceil \frac{m}{2} \rceil</script>个关键字。其中每个关键字对应一个子树(也就是最多有m<script type="math/tex" id="MathJax-Element-210">m</script>棵子树,最少有?m2?<script type="math/tex" id="MathJax-Element-211">\lceil \frac{m}{2} \rceil</script>棵子树);
- 根节点要么没有子树,要么至少有2棵子树;
所有的叶子节点包含了全部的关键字以及这些关键字指向文件的指针,并且:
- 所有叶子节点中的关键字按大小顺序排列
- 相邻的叶子节点顺序链接(相当于是构成了一个顺序链表)
- 所有叶子节点在同一层
- 所有分支节点的关键字都是对应子树中关键字的最大值
比如,下图就是一个非常典型的B+树的例子。
B+树和B树相比,主要的不同点在以下3项:
- 内部节点中,关键字的个数与其子树的个数相同,不像B树种,子树的个数总比关键字个数多1个
- 所有指向文件的关键字及其指针都在叶子节点中,不像B树,有的指向文件的关键字是在内部节点中。换句话说,B+树中,内部节点仅仅起到索引的作用,
- 在搜索过程中,如果查询和内部节点的关键字一致,那么搜索过程不停止,而是继续向下搜索这个分支。
根据B+树的结构,我们可以发现B+树相比于B树,在文件系统,数据库系统当中,更有优势,原因如下:
B+树的磁盘读写代价更低
B+树的内部结点并没有指向关键字具体信息的指针。因此其内部结点相对B树更小。如果把所有同一内部结点的关键字存放在同一盘块中,那么盘块所能容纳的关键字数量也越多。一次性读入内存中的需要查找的关键字也就越多。相对来说I/O读写次数也就降低了。
B+树的查询效率更加稳定
由于内部结点并不是最终指向文件内容的结点,而只是叶子结点中关键字的索引。所以任何关键字的查找必须走一条从根结点到叶子结点的路。所有关键字查询的路径长度相同,导致每一个数据的查询效率相当。
B+树更有利于对数据库的扫描
B树在提高了磁盘IO性能的同时并没有解决元素遍历的效率低下的问题,而B+树只需要遍历叶子节点就可以解决对全部关键字信息的扫描,所以对于数据库中频繁使用的range query,B+树有着更高的性能。
<script type="text/javascript"> $(function () { $(‘pre.prettyprint code‘).each(function () { var lines = $(this).text().split(‘\n‘).length; var $numbering = $(‘