B树

来源:互联网 发布:c二维数组列排序 编辑:程序博客网 时间:2024/05/16 04:09

用阶定义的B树

B树又叫平衡多路查找树。一棵m阶的B树的特性如下:
  1. 树中每个结点最多含有m个孩子(m >= 2)
  2. 除根结点和叶子结点外,其它每个结点至少有ceil(m / 2)个孩子(ps:ceil(x)取x的上整数)
  3. 若根结点不是叶子结点,则至少有2个孩子(特殊情况:没有孩子的根结点,即根节点为叶子结点,整棵树只有一个根结点)
  4. 所有叶子都出现在同一层
  5. 每个非终端结点中包含有n个关键字信息:(n, p0, k1, p1, k2, p2, ..., kn, pn).其中 (1)n为该结点的关键字的总个数,满足ceil(m/2) - 1 <= n <= m -1 (2)ki(i = 1, .., n)为关键字,且关键字按升序排序 (3)pi为指向子树根的结点,且指针p(i-1)指向子树中所有结点的关键字均小于ki,但都大于k(i-1)

用度定义的B树

一棵B树T是具有如下性质的有根树(根为root[T]):

1)每个结点x有以下域:
  • n[x],当前存储在结点x中的关键字数
  • n[x]个关键字本身,以非降序存放,因此key1[x] <= key2[x] <= ... <= key(n[x])[x]
  • leaf[x],是一个布尔值,如果x是叶子的话,则它为TRUE,如果x为一个内结点,则为FALSE
2)每个内结点x还包含n[x] + 1个指向其子女的指针c1[x],c2[x],...,c(n[x] + 1)[x].叶子结点没有子女,故它们的ci域无定义
3)如果ki为存储在以ci[x]为根的子树中的关键字,则k1 <= key1[x] <= k2 <= key2[x] <= ... <= key(n[x])[x] <= k(n[x] + 1)
4)每个叶子结点具有相同的深度,即树的高度h
5)每一个结点能包含的关键字数有一个上界和下界。这些界可用一个称作B树的最小度数的固定整数t >= 2来表示
  • 每个非根的结点必须至少有t - 1个关键字。每个非根的内结点至少有t个子女。如果树是非空的,则根结点至少包含一个关键字
  • 每个结点可包含至多2t-1个关键字。所以一个内结点至多可有2t个子女。我们说一个结点是满的,如果它恰好有2t - 1个关键字

外存储器——磁盘

计算机存储设备一般分为两种:内存储器(main memory)和外存储器(external memory)。内存存取速度快,但容量小,价格昂贵,而且不能长期保存数据(不通电的情况下数据会丢失)

外存储器磁盘是一种直接存取的存储设备

磁盘构造


一个典型的磁盘驱动器。它由若干绕主轴旋转的盘片组成。每个盘片通过磁臂末端的磁头来读写。这些磁臂连在一起同时移动磁头。在这里,这些磁臂绕着一个共用的传动轴旋转。当读/写磁头静止时,由它下方经过的磁盘表面称为一个磁道。
柱面由一组垂直的磁道构成

磁盘的读写原理和效率

磁盘上数据必须用一个三维地址唯一表示:
  1. 柱面号
  2. 盘面号
  3. 块号(磁道上的盘块)

读写磁盘上某一指定数据需要下面三个步骤:

  1. 首先,移动磁臂根据柱面号使磁头移动到所需要的柱面上,这一过程被称为定位或查找
  2. 例如当所有的磁头都定位到10个盘面的10条磁道上,这时根据盘面号来确定指定盘面上的磁道
  3. 盘面确定后,盘面开始旋转,将指定块号的磁道段移动至磁头下
经过上面三个步骤,指定数据的存储位置就被找到,这时可以开始读/写操作了

访问某一具体信息,由三部分时间组成:

  • 查找时间(seek time):完成上述步骤1所需要的时间,这部分时间代价最高,最大可达0.1s左右
  • 等待时间(latency time):完成上述步骤3所需要的时间。由于盘片绕主轴旋转速度很快,一般为7200rpm
  • 传输时间(transmission time):数据通过系统总线传送到内存的时间

磁盘读取数据是以盘块(block)为基本单位。位于同一盘块的所有数据都能被一次性全部读取出来。而磁盘IO代价主要花费在查找时间上。因此我们应该尽量将相关信息存放在同一盘块,同一磁道。

所以,在大规模数据存储方面,大量数据存储在外存磁盘中,而在外存磁盘中读取/写入块(block)中某数据时,首先需要快速定位到磁盘中的某块,如何有效地查找磁盘中的数据,需要一种合理的外存数据结构,这就是B树


B树的高度

B树上的大部分操作所需的磁盘存取次数与B树的高度成正比。下面来分析B树的最坏高度情况:

定理18.1 如果n >= 1, 则对任意一棵包含n个关键字、高度h、最小度数t >= 2的B树T,有:



证明:如果一棵B树的高度为h,其根结点包含至少一个关键字而其他结点包含至少t - 1个关键字。这样,在深度1至少有两个结点,在深度2至少有2t个结点,在深度3至少有2t2个结点,等等。因此,关键字的个数n满足不等式:



与红黑树相比,这里我们看道了B树的能力。虽然二者的高度都是以O(lgn)的速度增长(t是个常数),对B树来说对数的底要打很多倍。对于大多数的树操作来说,要查找的结点数在B树中要比在红黑树中少大约lgt的因子。因为在树中查找任意一个结点通常都需要一次磁盘存取,所以磁盘存取的次数大大地减少了

B树的ADT

#define m 1024 /*B树的阶(代表每个结点至多有m棵子树)*/// B树ADTstruct btnode{int keynum; /*结点中关键字个数, keynum < m*/struct btnode *parent; /*指向父结点*/struct btnode *ptr; /*子树指针向量:ptr[0]...ptr[keynum]*/int *key; /*关键字向量:key[1]...key[keynum]*/int *offset; /*key在硬盘中的存储位置:offset[1]...offset[keynum]*/};



B树的基本操作

下面要给出B-TREE-SEARCH,B-TREE-CREATE和B-TREE-INSERT的操作,在这些过程中,采用两个约定:
  • B树的根结点始终在主存中,因而无需对根做DISK-READ;但是,每当根结点被改变后,都需要对根结点做一次DISK-WRITE
  • 任何被当作参数的结点被传递之前,要先对它们做一次DISK-READ

搜索B树

搜索B树与搜索二叉查找树类似,只是在每个结点 所做的不是个二叉或者“两路”分支决定,而是根据该结点的子女数所做的多路分支决定。更准确的说,在每个内结点x处,要做(n[x] + 1)路的分支决定。

/** * 从根结点为root的B树上查找关键字k对应记录的存储位置 */int btree_search(struct btnode *root, KeyType k){int i;// 待比较的关键字序号struct btnode *p = root;while (p != NULL) {i = 1;while (i <= p->keynum && k > p->key[i]) {i ++;}if (i <= p->keynum && k == p->key[i]) {return p->offset[i];} else {p = p->ptr[i - 1];}}return -1;// 查找失败返回-1}


如下图所示,即是一棵B树,一棵关键字为英语中辅音字母的B树,现在要从树中查找字母R(包含n[x]个关键字的内结点x,x有n[x] + 1个子女(也就是说,一个内结点x若含有n[x]个关键字,那么x将含有n[x] + 1个子女)。所有的叶子结点都处于相同的深度,颜色稍浅的结点为查找字母R时需要检查的结点):



下面,我们来模拟一下查找R的过程:

  1. 根据根结点找到根磁盘块1,将其中的信息导入到内存    [磁盘IO操作1次]
  2. 此时内存中有一个辅音字母M和两个存储其他磁盘页面地址的数据。根据算法我们发现,R > M,因此我们找到了第二个指针,里面的key包含了Q、T、X
  3. 根据第二个指针,我们定位到包含QTX的磁盘块,并将其信息导入到内存    [磁盘IO操作2次(累加计数)]
  4. 此时内存中有三个辅音字母Q、T、X和其他四个存储其他磁盘页面地址的数据。根据算法我们发现:Q < R < T,因此我们找到包含第二个指针
  5. 根据第二个指针,我们定位到包含R、S的磁盘块,并将其中的信息导入到内存    [磁盘IO操作3次]
  6. 此时内存中包含了R、S,根据算法我们查到了该内存块,并返回R的磁盘地址
分析上面的过程,发现需要3次磁盘IO操作和3次内存查找操作。关于内存中的文件查找,由于是一个有序表结构,可以利用折半查找提高效率。至于IO操作则是影响整个B树查找效率的决定因素。

创建一棵空的B树

为了构造一个B树T,先用init_btree来创建一个空的根结点,再调用insert_btree来加入新的关键字

/** * 初始化B树,树根指针置空 */inline void init_btree(struct btnode *root){root = NULL;}

向B树插入关键字

B树插入是指到一个已知的叶结点上,因为不能把关键字插到一个满的叶结点上,故引入一个操作,将一个满的结点y(有2t-1个关键字)按其中间关键字key[y]分裂成两个各含t-1个关键字的结点,中间关键字提升到y的双亲结点,如果y的双亲也是满的,则自底向上传播分裂

如同二叉查找树,插入时,需要从根部沿着树下降到叶子,当沿着树往下查找新关键字所属位置时,就分裂遇到的每一个满结点,这样就能保证,要分裂一个满结点y时,就能确保它的双亲不是满的

B树中的结点分裂

满结点y按其中间关键字S进行分裂,S则被提升到y的双亲结点x中。y中大于中间关键字的那些关键字都置于新结点z中,它成为x的一个新孩子。




在B树中插入关键码key的思路

对高度为h的m阶树,新节点一般是插在第h层。通过检索可以确定关键码应插入的结点位置。然后分两种情况讨论:
  1. 若该结点中关键码个数小于m-1,则直接插入即可
  2. 若该结点中关键码个数等于m-1,则将引起结点的分裂。以中间关键码为界将结点一分为二,产生一个新结点,并把关键中间码插入到父结点(h-1)层中
  3. 重复上述工作,最坏情况一直分裂到根结点,建立一个新的根结点,整个B树增加一层

我这里没有按照《算法导论》上按照单程下行遍历树碰到满结点就分裂的方式进行数据的插入,我通过一个实例解释一下进行插入操作的思路:

需求:将C,N,G,A,H,E,K,Q,M,F,W,L,T,Z,D,P,R,X,Y,S 插入到一棵空的5阶B树中

(1)首先,结点空间足够,4个字母按照升序插入到相同的结点中,如下图:



(2)尝试插入H时,结点发现空间不够用,然后从第ceil(m / 2)个结点为中间点,分裂成2个结点,移动中间元素G到新的根结点中,在实现过程中,把A、C留在当前结点中,而H和N放置到新的右邻居结点中。如下图:



(3)插入E,K,Q时,不需要做任何分裂操作



(4)插入M需要一次分裂,恰好M是中间关键元素,因此M上移到根结点



(5)插入F,W,L,T不需要任何分裂操作



(6)插入Z时,导致最右边叶子结点被分裂,中间元素T上移到父结点中。注意,通过上移中间元素,树最终还是保持平衡



(7)插入D时,导致最左边的叶子结点破裂,D恰好也是中间元素,上移到父结点,然后字母R,X,Y陆续插入不需要任何分裂操作



(8)最后插入s时,含有NPQR的结点需要分裂,把中间元素Q上移到父结点中,这时父结点空间已满,也需要进行分裂,因此将中间元素M上移形成新的根结点




B树插入操作代码(c实现)

/** * 向根结点为mt的B树中插入一个关键字为k,记录存储位置为num的索引项 */void btree_insert(struct btnode *mt, KeyType k, int num){if (mt == NULL) { // B树为空时mt = (struct btnode *)malloc(sizeof(struct btnode));mt->keynum = 1;mt->parent = NULL;mt->key[1] = k;mt->offset[1] = num;mt->ptr[0] = mt->ptr[1] = NULL;return;} else { // B树不为空struct btnode *root, *tp; root = mt;tp = (struct btnode *)malloc(sizeof(struct btnode));int pos;// 在B树上查找关键字k的插入位置,指针tp为k所在的结点,pos为k在结点中的下标while (root != NULL) {pos = 1;while (k > root->key[pos] && pos <= root->keynum) {pos ++;}if (pos <= root->keynum && k == root->key[pos]) {printf("元素已经存在了!\n");return;} else {tp = root;root = root->ptr[pos - 1];}}struct btnode *ap = NULL;// (k, num, ap)为待插入的索引项while (1) {int i, n;n = tp->keynum;for (i = n; i >= pos; i --) { // 索引项后移tp->key[i + 1] = tp->key[i];tp->offset[i + 1] = tp->offset[i];tp->ptr[i + 1] = tp->ptr[i];}tp->key[pos] = k;// (k, num, ap)索引项插入tp->offset[pos] = num;tp->ptr[pos] = ap;tp->keynum ++;if (tp->keynum <= m - 1) { // 结点关键字个数<= m - 1,插入完成,不需要分裂return;}// 计算出>= m / 2的最小int值int j = (int)((double)m / 2 + 0.5); // 向上取整// 建立新分裂结点ap = (struct btnode *)malloc(sizeof(struct btnode));ap->keynum = m - j;ap->parent = tp->parent;for (i = 1; i <= ap->keynum; i ++) {ap->key[i] = tp->key[j + i];ap->offset[i] = tp->offset[j + i];ap->ptr[i] = tp->ptr[j + i];if (ap->ptr[i] != NULL) {ap->ptr[i]->parent = ap;}}tp->keynum = j - 1;k = tp->key[j];num = tp->offset[j];if (tp->parent == NULL) { // 说明根结点需要分裂,B树高度加1break;}tp = tp->parent;i = 1;while (k > tp->key[i]) i ++;pos = i;}// 根节点需要分裂的情况,对应于while(1)中break的场景struct btnode *new_root = (struct btnode *)malloc(sizeof(struct btnode));new_root->keynum = 1;new_root->key[1] = k;new_root->offset[1] = num;new_root->ptr[0] = tp;new_root->ptr[1] = ap;new_root->parent = NULL;tp->parent = ap->parent = new_root;mt = new_root;}}


B树的删除操作

B树的删除操作相对比较复杂,我这里也是参考了“july”对B树删除的讲解来进行学习的。

首先,查找B树中需要删除的元素,如果该元素在B树中存在,则将该元素在其结点中进行删除,如果删除该元素后,首先判断该元素是否有左右孩子结点,如果有,则上移孩子结点中的某相近元素到父结点中,然后是移动之后的情况;如果没有,直接删除后,然后是移动之后的情况

删除元素,移动相应元素之后的处理过程
  • 如果某结点中元素数组(即关键字数)小于ceil(m/2)-1,则需要看其某相邻兄弟结点是否丰满(结点中的元素个数大于ceil(m/2)-1)
  • 如果丰满,则向父结点借一个元素来满足条件
  • 如果其相邻兄弟都刚脱贫,即借了之后其结点数目小于ceil(m/2)-1,则该结点与其相邻某一兄弟结点进行“合并”成一个结点,以此来满足条件

实例演示

上面插入操作构建的一棵5阶B树(树中每个结点最多有4个元素,最少有2个元素),依次删除H,T,R,E

(1)B树初始状态



(2)删除H结点,首先查找H结点,H在一个叶子结点中,且该叶子结点元素数目大于3大于最小的元素数目2,则操作很简单,只需要移动K至原来H的位置,移动L至原来K的位置(也就是结点中删除元素后面的元素依次向前移动)



(3)删除元素T,元素T没有在叶子结点中,而是在中间结点中找到,发现他的继承者W,将W上移到T的位置,然后将原包含W的孩子结点中的W进行删除,这里恰好删除W后,该孩子结点中元素个数大于2,无需进行合并操作



(4)删除元素R,R在叶子结点中,但是该结点中元素数目为2,删除导致只有1个元素,已经小于最小元素数目ceil(5/2)-1=2,根据移动后的方案我们知道:如果其某个相邻兄弟结点中比较丰满,则可以向父结点借一个元素,然后将最丰满的相邻兄弟结点中上移最后或最前一个元素到父结点中,在这个例子中,右相邻兄弟结点比较丰满,所以先向父结点借一个元素W下移到叶子结点中,替代原来S的位置,同时S前移;然后X在相邻右兄弟结点中上移到父结点中,最后在相邻右兄弟结点中删除X,后面元素前移



(5)删除元素E,删除后会导致很多问题,因为E所在的结点数目刚好达标,刚好满足最小元素个数2,而相邻的兄弟结点也是同样的情况,删除一个元素都不能满足条件,所以需要该结点与某相邻兄弟结点进行合并操作;首先,移动父结点的元素(该元素在两个需要合并的结点元素之间)下移到其子结点中,然后将这两个结点合并称一个结点,所以在该实例中,首先将父结点中的元素D下移到已经删除E而只有F的结点中,然后含有D、F的结点和含有A、C的结点进行合并成为一个结点



(6)但是,这样并没有结束,因为父结点只包含一个元素G,不满足5阶B树每个结点至少有2个关键字的特性。如果这个问题结点的相邻兄弟结点比较丰满,则可以向父结点借一个元素。假设这时右兄弟结点(含有Q、X)有两个以上的元素,可以把M下移到元素少的结点,将Q上移到M的位置,这时,Q的左子树变成含有G、M的树。但是这个实例中,相邻结点都正好脱贫,所以,只能与兄弟结点合并成一个结点,而根节点中唯一的元素M下移到子结点,这样,树的高度减少一层。




B+树

声明,这里直接引用了一淘工程师张洋的blog——http://blog.codinglabs.org/articles/theory-of-mysql-index.html,张洋也是我非常佩服的技术牛人,这里推荐一下张洋的博客

B树有很多变种,其中最常见的是B+树。例如Mysql就普遍使用B+树实现其索引结构




参考链接

http://blog.csdn.net/v_JULY_v/article/details/6530142
http://mindlee.net/2011/09/18/b-trees/






原创粉丝点击