B树是为实现高效的磁盘存取而设计的多叉平衡搜索树。这个概念在文件系统,数据库系统中非常重要。当然,有关于B树的产生,发展,结构等等方面的介绍已经非常详细,所以本文只是介绍有关于B树和B+树最核心的知识点,也算是我本人的学习笔记。至于详细的资料,因为毕竟有着太多,所以不再赘述。可以向大家推荐一篇博客:从B树、B+树、B*树谈到R 树,这篇文章中,作者对于B树系列数据结构的讲解非常详细,我的这篇博客,也是大量参考了人家的很多例子和描述。
首先,其实b树和b-树是一个东西。开始我以为不一样。是因为B-Tree,可以把中间的-当破折号也可以当减号= =简单说一下B树产生的原因。B树是一种查找树,我们知道,这一类树(比如二叉查找树,红黑树等等)最初生成的目的都是为了解决某种系统中,查找效率低的问题。B树也是如此,它最初启发于二叉查找树,二叉查找树的特点是每个非叶节点都只有两个孩子节点。然而这种做法会导致当数据量非常大时,二叉查找树的深度过深,搜索算法自根节点向下搜索时,需要访问的节点也就变的相当多。如果这些节点存储在外存储器中,每访问一个节点,相当于就是进行了一次I/O操作,随着树高度的增加,频繁的I/O操作一定会降低查询的效率。
这里有一个基本的概念,就是说我们从外存储器中读取信息的步骤,简单来分,大致有两步:
综上,对于外存储器的信息读取最大的时间消耗在于寻找磁盘页面。那么一个基本的想法就是能不能减少这种读取的次数,在一个磁盘页面上,多存储一些索引信息。B树的基本逻辑就是这个思路,它要改二叉为多叉,每个节点存储更多的指针信息,以降低I/O操作数。
1. B树的定义
有关于B树概念的定义,不同的资料在表述上有所差别。我在这里采用《算导》中的定义,用最小度tt tt来定义B树。一棵最小度为tt tt的B树是满足如下四个条件的平衡多叉树:
每个节点最多包含2t−12t−1 2t - 12t−1个关键字;除根节点外的每个节点至少有t−1t−1 t - 1t−1个关键字(t≤2t≤2 t \leq 2t≤2),根节点至少有一个关键字;
一个节点uu uu中的关键字按非降序排列:u.key1≤u.key2≤…u.keynu.key1≤u.key2≤…u.keyn u.key_1 \leq u.key_2 \leq \dots u.key_nu.key1≤u.key2≤…u.keyn;
每个节点的关键字对其子树的范围分割。设节点uu uu有n+1n+1 n + 1n+1个指针,指向其n+1n+1 n + 1n+1棵子树,指针为u.p1,…u.pnu.p1,…u.pn u.p_1, \dots u.p_nu.p1,…u.pn,关键字kiki k_iki为u.piu.pi u.p_iu.pi所指的子树中的关键字,有k1≤u.key1≤k2≤u.key2…k1≤u.key1≤k2≤u.key2… k_1 \leq u.key_1 \leq k_2 \leq u.key_2 \dotsk1≤u.key1≤k2≤u.key2…成立;
所有叶子节点具有相同的深度,即树的高度hh hh。这表明B树是平衡的。平衡性其实正是B树名字的来源,B表示的正是单词Balanced;
一个标准的B树如下图:
2. B树的高度
我直接给出结论了:对于一个包含nn nn个关键字(n≥1n≥1 n \geq 1n≥1),最小度数t≥2t≥2 t \geq 2t≥2的B树T,其高度hh hh满足如下规律:
h≤logtn+12h≤logtn+12
在搜索B树时,很明显,访问节点(即读取磁盘)的次数与树的高度呈正比,而B树与红黑树和普通的二叉查找树相比,虽然高度都是对数数量级,但是显然B树中loglog loglog函数的底可以比2更大,因此,和二叉树相比,极大地减少了磁盘读取的次数。
这里,我直接用博客从B树、B+树、B*树谈到R 树中的例子(因为这个例子非常好,也有现成的图示,就直接拿来用,不再自己班门弄斧了),一棵已经建立好的B树如下图所示,我们的目的是查找关键字为29的文件:
先简单对上图说明一下:
图中的小红方块表示对应关键字所代表的文件的存储位置,实际上可以看做是一个地址,比如根节点中17旁边的小红块表示的就是关键字17所对应的文件在硬盘中的存储地址。
P是指针,不用多说了,需要注意的是:指针,关键字,以及关键字所代表的文件地址这三样东西合起来构成了B树的一个节点,这个节点存储在一个磁盘块上
下面,看看搜索关键字的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树种对新关键字的插入:首先找到要插入的关键字应该插入的叶子节点(为方便描述,设这个叶子节点为uu uu),如果uu uu是满的(恰好有2t−12t−1 2t - 12t−1个关键字),那么由于不能将一个关键字插入满的节点,我们需要对uu uu按其当前排在中间关键字u.keytu.keyt u.key_tu.keyt进行分裂,分裂成两个节点u1,u2u1,u2 u_1, u_2u1,u2;同时,作为分裂标准的关键字u.keytu.keyt u.key_tu.keyt会被上移到uu uu的父节点中,在u.keytu.keyt u.key_tu.keyt插入前,如果uu uu的父节点未满,则直接插入即可;如果uu uu的父节点已满,则按照上面的方法对uu uu的父节点分裂,这个过程如果一直不停止的话,最终会导致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树
**Case - 1:**如果要删除的关键字kk kk在节点uu uu中,而且uu uu是叶子节点,那么直接删除kk kk
**Case - 2:**如果要删除的关键字kk kk在节点uu uu中,而且uu uu是内部节点,那么分以下3种情况讨论:
(1) 如果uu uu中前于kk kk的子节点u1u1 u_1u1中至少含有tt tt个关键字,则找出kk kk在以u1u1 u_1u1为根的子树中的前驱k′k′ k'k′(前驱的意思是u1u1 u_1u1中比kk kk小的关键字中最大的),然后在以u1u1 u_1u1为根的子树中删除k′k′ k'k′,并在uu uu中以k′k′ k'k′替代kk kk
(2) 如果上面的条件(1)不成立,也就是说,前于kk kk的子节点中关键字的个数小于tt tt了,那么就去找后于kk kk的子节点,记为u2u2 u_2u2。若u2u2 u_2u2中至少含有tt tt个关键字,则找出kk kk在以u2u2 u_2u2为根的子树中的后继k′k′ k'k′(大于kk kk的关键字中最小的),然后在以u2u2 u_2u2为根的子树中删除k′k′ k'k′,并在uu uu中以k′k′ k'k′替代kk kk。可以看出(2)是(1)的一个对称过程
(3) 如果u1,u2u1,u2 u_1, u_2u1,u2中的关键字个数都是t−1t−1 t - 1t−1,则将kk kk和u2u2 u_2u2合并后并入u1u1 u_1u1,这样uu uu就失去了kk kk和指向u2u2 u_2u2的指针,最后递归地从u1u1 u_1u1中删除kk kk
**Case - 3:**如果要删除的关键字kk kk不在当前节点uu uu中,而且uu uu是内部节点(如果自上而下扫描到叶子都没有这个关键字的话,那就说明要删除的关键字根本就不存在,所以此处只考虑uu uu是内部节点的情况),则首先确定包含kk kk的uu uu的子树,我们这里设为u.piu.pi u.p_iu.pi。如果u.piu.pi u.p_iu.pi中至少含有tt tt个关键字,那么继续扫描,寻找下一个要被扫描的子树;如果u.piu.pi u.p_iu.pi中只含有t−1t−1 t - 1t−1个关键字,则需要分下面两种情况进行操作:
(1) 如果u.piu.pi u.p_iu.pi至少有一个相邻的兄弟比较“丰满”(即这个兄弟至少有tt tt个关键字)。则将uu uu中的一个关键字降至u.piu.pi u.p_iu.pi,同时令u.piu.pi u.p_iu.pi的最“丰满”的兄弟中升一个关键至uu uu。然后继续扫描B树,寻找kk kk
(2) 如果u.piu.pi u.p_iu.pi的两个相邻的兄弟都不“丰满”(都只有t−1t−1 t - 1t−1个关键字)。则令u.piu.pi u.p_iu.pi和其一个兄弟合并,再将uu uu的一个关键字降至新合并的节点。使之成为该节点的中间关键字。
举个例子,就可以清晰看到上面说的这几种删除的情况。拿下图所示的最小度为3的B树为例(即树中除根和叶子之外的节点只能有2,3,4,5四种情况的关键字个数):
Step 1: 删除上图中的关键字F,过程如下:先扫描根节点(含P),再扫描其左孩子(含CGM),发现丰满,继续扫描到左起第二个叶子,然后就是符合Case - 1的情况了。结果如下图所示:
Step 2: 再删除M,此时遇到**Case - 2(1)**的情况,结果如下图所示:
Step 3: 再删除G,G的前驱、后驱都是不丰满的。也就是**Case - 2(3)**的情况,结果如下图所示:
Step 4: 再删除D,扫描至含CL的节点后,发现它不丰满,且他的兄弟也不丰满。则将节点CL和TX合并,并降关键字P至新合并的节点。也就是**Case - 3(2)**的情况,结果如下图所示,此时,树高减1:
Step 5: 再删除B,也就是**Case - 3(1)**的情况,结果如下图所示:
下面总结一下B树的删除原理:
B+树是B树的一种变形,它更适合实际应用中操作系统的文件索引和数据库索引。定义如下:(为和大多资料保持一致,这里使用阶数mm mm来定义B+树,而不像之前的B树中,使用的是最小度tt tt来定义)
比如,下图就是一个非常典型的B+树的例子。
B+树和B树相比,主要的不同点在以下3项:
根据B+树的结构,我们可以发现B+树相比于B树,在文件系统,数据库系统当中,更有优势,原因如下:
B+树的磁盘读写代价更低
B+树的内部结点并没有指向关键字具体信息的指针。因此其内部结点相对B树更小。如果把所有同一内部结点的关键字存放在同一盘块中,那么盘块所能容纳的关键字数量也越多。一次性读入内存中的需要查找的关键字也就越多。相对来说I/O读写次数也就降低了。
B+树的查询效率更加稳定
由于内部结点并不是最终指向文件内容的结点,而只是叶子结点中关键字的索引。所以任何关键字的查找必须走一条从根结点到叶子结点的路。所有关键字查询的路径长度相同,导致每一个数据的查询效率相当。
B+树更有利于对数据库的扫描
B树在提高了磁盘IO性能的同时并没有解决元素遍历的效率低下的问题,而B+树只需要遍历叶子节点就可以解决对全部关键字信息的扫描,所以对于数据库中频繁使用的range query,B+树有着更高的性能。
红黑树(Red-Black Tree,简称R-B Tree),它一种特殊的二叉查找树。
红黑树是特殊的二叉查找树,意味着它满足二叉查找树的特征:任意一个节点所包含的键值,大于等于左孩子的键值,小于等于右孩子的键值。
除了具备该特性之外,红黑树还包括许多额外的信息。
红黑树的每个节点上都有存储位表示节点的颜色,颜色是红(Red)或黑(Black)。
红黑树的特性:
(1) 每个节点或者是黑色,或者是红色。
(2) 根节点是黑色。
(3) 每个叶子节点是黑色。 [注意:这里叶子节点,是指为空的叶子节点!]
(4) 如果一个节点是红色的,则它的子节点必须是黑色的。
(5) 从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。
关于它的特性,需要注意的是:
第一,特性(3)中的叶子节点,是只为空(NIL或null)的节点。
第二,特性(5),确保没有一条路径会比其他路径长出俩倍。因而,红黑树是相对是接近平衡的二叉树。
红黑树的主要是想对2-3查找树进行编码,尤其是对2-3查找树中的3-nodes节点添加额外的信息。红黑树中将节点之间的链接分为两种不同类型,红色链接,他用来链接两个2-nodes节点来表示一个3-nodes节点。黑色链接用来链接普通的2-3节点。特别的,使用红色链接的两个2-nodes来表示一个3-nodes节点,并且向左倾斜,即一个2-node是另一个2-node的左子节点。这种做法的好处是查找的时候不用做任何修改,和普通的二叉查找树相同。
根据以上描述,红黑树定义如下:
红黑树是一种具有红色和黑色链接的平衡查找树,同时满足:
1.红色节点向左倾斜
2.一个节点不可能有两个红色链接
3.整个树完全黑色平衡,即从根节点到所以叶子结点的路径上,黑色链接的个数都相同。
下图可以看到红黑树其实是2-3树的另外一种表现形式:如果我们将红色的连线水平绘制,那么他链接的两个2-node节点就是2-3树中的一个3-node节点了。
我们可以在二叉查找树的每一个节点上增加一个新的表示颜色的标记。该标记指示该节点指向其父节点的颜色。
private class Node {
Node left, right;//左右子树
TKey key;//键
TValue value;//相关联的值
int n;//这颗子树中的节点总数
boolean color;//由父节点指向它的连接的颜色
public Node(TKey key, TValue value, int number, boolean color) {
this.key = key;
this.value = value;
this.n = number;
this.color = color;
}
}
private boolean isRed(Node node) {
if (node == null) return false;
return node.color == RED;
}
红黑树是一种特殊的二叉查找树,他的查找方法也和二叉查找树一样,不需要做太多更改。
但是由于红黑树比一般的二叉查找树具有更好的平衡,所以查找起来更快。
//查找获取指定的值
public TValue get(TKey key) {
return getValue(root, key);
}
private TValue getValue(Node node, TKey key) {
if (node == null) return null;
int cmp = key.compareTo(node.Key);
if (cmp == 0) {
return node.value;
} else if (cmp > 0) {
return getValue(node.right, key);
} else {
return getValue(node.left, key);
}
}
在介绍插入之前,我们先介绍如何让红黑树保持平衡,因为一般的,我们插入完成之后,需要对树进行平衡化操作以使其满足平衡化。
旋转
旋转又分为左旋和右旋。通常左旋操作用于将一个向右倾斜的红色链接旋转为向左链接。对比操作前后,可以看出,该操作实际上是将红线链接的两个节点中的一个较大的节点移动到根节点上。
左旋操作如下图:
//左旋转
private Node rotateLeft(Node h) {
Node x = h.right;
//将x的左节点复制给h右节点
h.right = x.left;
//将h复制给x右节点
x.left = h;
x.color = h.color;
h.color = RED;
x.n = h.n;
h.n = 1 + size(h.left) + size(h.right);
return x;
}
左旋的动画效果如下:
右旋是左旋的逆操作,过程如下:
代码如下:
//右旋转
private Node rotateRight(Node h) {
Node x = h.left;
h.left = x.right;
x.right = h;
x.color = h.color;
h.color = RED;
x.n = h.n;
h.n = 1 + size(h.left) + size(h.right);
return x;
}
右旋的动画效果如下:
颜色反转
当出现一个临时的4-node的时候,即一个节点的两个子节点均为红色,如下图:
这其实是个A,E,S 4-node连接,我们需要将E提升至父节点,操作方法很简单,就是把E对子节点的连线设置为黑色,自己的颜色设置为红色。
有了以上基本操作方法之后,我们现在对应之前对2-3树的平衡操作来对红黑树进行平衡操作,这两者是可以一一对应的,如下图:
现在来讨论各种情况:
Case 1 往一个2-node节点底部插入新的节点
先热身一下,首先我们看对于只有一个节点的红黑树,插入一个新的节点的操作:
这种情况很简单,只需要:
1.标准的二叉查找树遍历即可。新插入的节点标记为红色
2.如果新插入的节点在父节点的右子节点,则需要进行左旋操作
Case 2往一个3-node节点底部插入新的节点
假设我们往一个只有两个节点的树中插入元素,如下图,根据待插入元素与已有元素的大小,又可以分为如下三种情况:
1.如果带插入的节点比现有的两个节点都大,这种情况最简单。我们只需要将新插入的节点连接到右边子树上即可,然后将中间的元素提升至根节点。这样根节点的左右子树都是红色的节点了,我们只需要调研FlipColor方法即可。其他情况经过反转操作后都会和这一样。
2.如果插入的节点比最小的元素要小,那么将新节点添加到最左侧,这样就有两个连接红色的节点了,这是对中间节点进行右旋操作,使中间结点成为根节点。这是就转换到了第一种情况,这时候只需要再进行一次FlipColor操作即可。
3.如果插入的节点的值位于两个节点之间,那么将新节点插入到左侧节点的右子节点。因为该节点的右子节点是红色的,所以需要进行左旋操作。操作完之后就变成第二种情况了,再进行一次右旋,然后再调用FlipColor操作即可完成平衡操作。
有了以上基础,我们现在来总结一下往一个3-node节点底部插入新的节点的操作步骤,下面是一个典型的操作过程图:
可以看出,操作步骤如下:
1.执行标准的二叉查找树插入操作,新插入的节点元素用红色标识。
2.如果需要对4-node节点进行旋转操作
3.如果需要,调用FlipColor方法将红色节点提升
4.如果需要,左旋操作使红色节点左倾。
5.在有些情况下,需要递归调用Case1 Case2,来进行递归操作。如下:
void flipColors(Node h) {
h.color = RED;
h.left.color = BLACK;
h.right.color = BLACK;
}
private Node put(Node h, TKey key, TValue value) {
if (h == null) {
return new Node(key, value, 1, RED);
}
int cmp = key.compareTo(h.key);
if (cmp < 0) {
h.left = put(h.left, key, value);
} else if (cmp > 0) {
h.right = put(h.right, key, value);
} else {
h.value = value;
}
if (isRed(h.right) && !isRed(h.left)) {
h = rotateLeft(h);
}
if (isRed(h.left) && isRed(h.left.left)) {
h = rotateRight(h);
}
if (isRed(h.left) && isRed(h.right)) {
flipColors(h);
}
h.n = size(h.left) + size(h.right) + 1;
return h;
}
–
对红黑树的分析其实就是对2-3查找树的分析,红黑树能够保证符号表的所有操作即使在最坏的情况下都能保证对数的时间复杂度,也就是树的高度。
1. 在最坏的情况下,红黑树的高度不超过2lgN
最坏的情况就是,红黑树中除了最左侧路径全部是由3-node节点组成,即红黑相间的路径长度是全黑路径长度的2倍。
下图是一个典型的红黑树,从中可以看到最长的路径(红黑相间的路径)是最短路径的2倍:
2. 红黑树的平均高度大约为lgN
下图是红黑树在各种情况下的时间复杂度,可以看出红黑树是2-3查找树的一种实现,他能保证最坏情况下仍然具有对数的时间复杂度。
下图是红黑树各种操作的时间复杂度。
前文讲解了自平衡查找树中的2-3查找树,这种数据结构在插入之后能够进行自平衡操作,从而保证了树的高度在一定的范围内进而能够保证最坏情况下的时间复杂度。但是2-3查找树实现起来比较困难,红黑树是2-3树的一种简单高效的实现,他巧妙地使用颜色标记来替代2-3树中比较难处理的3-node节点问题。红黑树是一种比较高效的平衡查找树,应用非常广泛,很多编程语言的内部实现都或多或少的采用了红黑树。
B树相对于红黑树的区别
在大规模数据存储的时候,红黑树往往出现由于树的深度过大而造成磁盘IO读写过于频繁,进而导致效率低下的情况。为什么会出现这样的情况,我们知道要获取磁盘上数据,必须先通过磁盘移动臂移动到数据所在的柱面,然后找到指定盘面,接着旋转盘面找到数据所在的磁道,最后对数据进行读写。磁盘IO代价主要花费在查找所需的柱面上,树的深度过大会造成磁盘IO频繁读写。根据磁盘查找存取的次数往往由树的高度所决定,所以,只要我们通过某种较好的树结构减少树的结构尽量减少树的高度,B树可以有多个子女,从几十到上千,可以降低树的高度。
为什么说B+比B树更适合实际应用中操作系统的文件索引和数据库索引?
1) B+的磁盘读写代价更低
B+的内部结点并没有指向关键字具体信息的指针。因此其内部结点相对B树更小。如果把所有同一内部结点的关键字存放在同一盘块中,那么盘块所能容纳的关键字数量也越多。一次性读入内存中的需要查找的关键字也就越多。相对来说IO读写次数也就降低了。
2) B+-tree的查询效率更加稳定
由于非终结点并不是最终指向文件内容的结点,而只是叶子结点中关键字的索引。所以任何关键字的查找必须走一条从根结点到叶子结点的路。所有关键字查询的路径长度相同,导致每一个数据的查询效率相当。
数据库索引采用B+树的主要原因是 B树在提高了磁盘IO性能的同时并没有解决元素遍历的效率低下的问题。正是为了解决这个问题,B+树应运而生。B+树只要遍历叶子节点就可以实现整棵树的遍历。而且在数据库中基于范围的查询是非常频繁的,而B树不支持这样的操作(或者说效率太低)