数据结构基础16———非线性数据结构之自平衡树之B,B+,B*树

B树

在B树中,内部(非叶子)节点可以拥有可变数量的子节点(数量范围预先定义好)。当数据被插入或从一个节点中移除,它的子节点数量发生变化。为了维持在预先设定的数量范围内,内部节点可能会被合并或者分离。因为子节点数量有一定的允许范围,所以B树不需要像其他自平衡查找树那样频繁地重新保持平衡,但是由于节点没有被完全填充,可能浪费了一些空间。子节点数量的上界和下界依特定的实现而设置。例如,在一个2-3 B树(通常简称2-3树),每一个内部节点只能有2或3个子节点。(回想2-3树中的3节点)

B树的定义 有关于B树概念的定义,不同的资料在表述上有所差别。(甚至术语在不同的文献中定义也不同...)

鲁道夫·拜尔(Rudolf Bayer)和 艾华·M·麦克雷(Ed M. McCreight)于1972年在波音研究实验室(Boeing Research Labs)工作时发明了B 树,但是他们没有解释B 代表什么意义(如果有的话)。道格拉斯·科默尔(Douglas Comer)解释说: 两位作者从来都没解释过B树的原始意义。正如我们所见,“balanced”, “broad” 或 “bushy” 可能适合。其他人建议字母“B”代表 Boeing。源自于他的赞助,不过,看起来把B树当作“Bayer”树更合适些
高德纳(Donald Knuth) 在他1980年5月发表的题为“CS144C classroom lecture about disk storage and B-trees”的论文中推测了B树的名字取义,提出“B”可能意味Boeing 或者Bayer 的名字。


Bayer & McCreight(1972),Comer(1979)等人将B树的 阶 定义为非根节点拥有键的最小数量。Folk & Zoellick(1992) 指出这一术语是模糊不清的。一个 3 阶B树键的最大数量可能为 6 或 7。 Knuth(1998, p. 483) 通过将 阶 定义为最大数量的子节点(比最大数量的键大1)来避免这一问题。

术语 叶子 的定义也不一致。 Bayer & McCreight(1972) 认为叶子层是最下面一层的键,但是 Knuth 认为叶子层是最下面一层键之下的一层 (Folk & Zoellick 1992,p.363)。可能的实现有许多。在一些设计中,叶子可能保存了完整的数据记录;在另一些设计中,叶子可能只保存了指向数据记录的指针。

为了简化,许多作者假定一个节点能够容纳固定数量的键。基础的假设是键和节点的大小都是固定的。事实上,可变长度的键可能会被使用 (Folk & Zoellick 1992,p.379)。

一棵标准的B树:

image.png

看到这个定义时马上想到的就是文件系统的目录

B树的高度
对于一个包含n个关键字(n≥1),最小度数t≥2t的B树T,其高度h满足如下规律:
image.png

场景
前面我们讲了平衡二叉树,像AVL,红黑等等最开始都是为了解决某种系统中,查找效率低的问题。而B树也是一样,但是不同与平衡二叉树的是,平衡二叉树只能有两个结点,这种做法会导致当数据量非常大时,二叉查找树的深度过深,搜索算法自根节点向下搜索时,需要访问的节点也就变的相当多。如果这些节点存储在外存储器中(比如磁盘),每访问一个节点,相当于就是进行了一次I/O操作,随着树高度的增加,频繁的I/O操作一定会降低查询的效率。所以如果我们每次向下访问一个节就是一个I/O操作,这样就需要我们减少树的高度,既然如此有没有什么解决方案呢?
最直观的方案就是既然二叉树最多只能有两个子节点,那么我们把二叉变成N叉不就可以了吗?
这就是B树的基本实现理念,简单易懂。
一棵m阶的B-Tree有如下特性:

1.每个节点最多有m个孩子
2.除了根节点和叶子节点外,其它每个节点至少有Ceil(m/2)个孩子。
3.若根节点不是叶子节点,则至少有2个孩子
4.所有叶子节点都在同一层,且不包含其它关键字信息
5.每个非终端节点包含n个关键字信息(P0,P1,…Pn, k1,…kn)
6.关键字的个数n满足:ceil(m/2)-1 <= n <= m-1
7. ki(i=1,…n)为关键字,且关键字升序排序。
8.Pi(i=1,…n)为指向子树根节点的指针。P(i-1)指向的子树的所有节点关键字均小于ki,但都大于k(i-1)

B-Tree是为磁盘等外存储设备设计的一种平衡查找树。因此在讲B-Tree之前先了解下磁盘的相关知识。

系统从磁盘读取数据到内存时是以磁盘块(block)为基本单位的,位于同一个磁盘块中的数据会被一次性读取出来,而不是需要什么取什么。

InnoDB存储引擎中有页(Page)的概念,页是其磁盘管理的最小单位。InnoDB存储引擎中默认每个页的大小为16KB,可通过参数innodb_page_size将页的大小设置为4K、8K、16K,在MySQL中可通过如下命令查看页的大小:

mysql> show variables like 'innodb_page_size';

而系统一个磁盘块的存储空间往往没有这么大,因此InnoDB每次申请磁盘空间时都会是若干地址连续磁盘块来达到页的大小16KB。InnoDB在把磁盘数据读入到磁盘时会以页为基本单位,在查询数据时如果一个页中的每条数据都能有助于定位数据记录的位置,这将会减少磁盘I/O次数,提高查询效率。

B-Tree结构的数据可以让系统高效的找到数据所在的磁盘块。为了描述B-Tree,首先定义一条记录为一个二元组[key, data] ,key为记录的键值,对应表中的主键值,data为一行记录中除主键外的数据。对于不同的记录,key值互不相同。

B-Tree中的每个节点根据实际情况可以包含大量的关键字信息和分支,如下图所示为一个3阶的B-Tree(回想2-3树中的3节点):
image.png

每个节点占用一个盘块的磁盘空间,一个节点上有两个升序排序的关键字和三个指向子树根节点的指针,指针存储的是子节点所在磁盘块的地址。两个关键词划分成的三个范围域对应三个指针指向的子树的数据的范围域。以根节点为例,关键字为17和35,P1指针指向的子树的数据范围为小于17,P2指针指向的子树的数据范围为17~35,P3指针指向的子树的数据范围为大于35。

模拟查找关键字29的过程:

  1. 根据根节点找到磁盘块1,读入内存。【磁盘I/O操作第1次】
  2. 比较关键字29在区间(17,35),找到磁盘块1的指针P2。
  3. 根据P2指针找到磁盘块3,读入内存。【磁盘I/O操作第2次】
  4. 比较关键字29在区间(26,30),找到磁盘块3的指针P2。
  5. 根据P2指针找到磁盘块8,读入内存。【磁盘I/O操作第3次】
  6. 在磁盘块8中的关键字列表中找到关键字29。

分析上面过程,发现需要3次磁盘I/O操作,和3次内存查找操作。由于内存中的关键字是一个有序表结构,可以利用二分法查找提高效率。而3次磁盘I/O操作是影响整个B-Tree查找效率的决定因素。B-Tree相对于AVLTree缩减了节点个数,使每次磁盘I/O取到内存的数据都发挥了作用,从而提高了查询效率。

image.png

B树的搜索,从根结点开始,对结点内的关键字(有序)序列进行二分查找,如果

命中则结束,否则进入查询关键字所属范围的儿子结点;重复,直到所对应的儿子指针为

空,或已经是叶子结点;

B树的特性:

   1.关键字集合分布在整颗树中;

   2.任何一个关键字出现且只出现在一个结点中;

   3.搜索有可能在非叶子结点结束;

   4.其搜索性能等价于在关键字全集内做一次二分查找;

   5.自动层次控制;

由于限制了除根结点以外的非叶子结点,至少含有M/2个儿子,确保了结点的至少利用率,其最底搜索性能为:
image.png

其中,M为设定的非叶子结点最多子树个数,N为关键字总数;
所以B-树的性能总是等价于二分查找(与M值无关),也就没有B树平衡的问题;
由于M/2的限制,在插入结点时,如果结点已满,需要将结点分裂为两个各占M/2的结点;删除结点时,需将两个不足M/2的兄弟结点合并;

搜索

B树的搜索和二叉搜索树类似。从根节点开始,从上到下递归的遍历树。在每一层上,搜索的范围被减小到包含了搜索值的子树中。子树值的范围被它的父节点的键确定。

插入

B树插入的例子。 节点最多有3个孩子 (Knuth 阶为 3).
所有的插入都从根节点开始。要插入一个新的元素,首先搜索这棵树找到新元素应该被添加到的叶子节点。将新元素插入到这一节点中的步骤如下:

如果节点拥有的元素数量小于最大值,那么有空间容纳新的元素。将新元素插入到这一节点,且保持节点中元素有序。

否则的话这一节点已经满了,将它平均地分裂成两个节点:

从叶子节点的元素和新的元素中选择出中位数
小于这一中位数的元素放入左边节点,大于这一中位数的元素放入右边节点,中位数作为分隔值。
分隔值被插入到父节点中,这可能会造成父节点分裂,分裂父节点时可能又会使它的父节点分裂,以此类推。如果没有父节点(这一节点是根节点),就创建一个新的根节点(增加了树的高度)。
如果分裂一直上升到根节点,那么一个新的根节点会被创建,它有一个分隔值和两个子节点。这就是根节点并不像内部节点一样有最少子节点数量限制的原因。每个节点中元素的最大数量是 U-1。当一个节点分裂时,一个元素被移动到它的父节点,但是一个新的元素增加了进来。所以最大的元素数量 U-1 必须能够被分成两个合法的节点。如果 U-1 是奇数,那么 U=2L ,总共有 2L-1 个元素,一个新的节点有 L-1 个元素,另外一个有 L 个元素,都是合法的节点。如果 U-1 是偶数,那么 U=2L-1,总共有 2L-2 个元素。 一半是 L-1,正好是节点允许的最小元素数量。

删除

有两种常用的删除策略

定位并删除元素,然后调整树使它满足约束条件; 或者

从上到下处理这棵树,在进入一个节点之前,调整树使得之后一旦遇到了要删除的键,它可以被直接删除而不需要再进行调整
以下的算法使用了前一种策略。

删除一个元素时有以下两种特殊情况

1.这个元素用于分隔一个内部节点的子节点
2.删除元素会导致它所在的节点的元素或子节点数量小于最低值

下面分别是这些情况的处理过程

删除叶子节点中的元素

1.搜索要删除的元素
2.如果它在叶子节点,将它从中删除
3.如果发生了下溢出,按照后边 “删除后重新平衡”部分的描述重新调整树


删除内部节点中的元素

内部节点中的每一个元素都作为分隔两颗子树的分隔值,因此我们需要重新划分。值得注意的是左子树中最大的元素仍然小于分隔值。同样的,右子树中最小的元素仍然大于分隔值。这两个元素都在叶子节点中,并且任何一个都可以作为两颗子树的新分隔值。算法的描述如下:

1.选择一个新的分隔符(左子树中最大的元素或右子树中最小的元素),将它从叶子节点中移除,替换掉被删除的元素作为新的分隔值。
2.前一步删除了一个叶子节点中的元素。如果这个叶子节点拥有的元素数量小于最低要求,那么从这一叶子节点开始重新进行平衡。

删除后的重新平衡

重新平衡从叶子节点开始向根节点进行,直到树重新平衡。如果删除节点中的一个元素使该节点的元素数量低于最小值,那么一些元素必须被重新分配。通常,移动一个元素数量大于最小值的兄弟节点中的元素。如果兄弟节点都没有多余的元素,那么缺少元素的节点就必须要和他的兄弟节点 合并。合并可能导致父节点失去了分隔值,所以父节点可能缺少元素并需要重新平衡。合并和重新平衡可能一直进行到根节点,根节点变成惟一缺少元素的节点。重新平衡树的算法如下:

如果缺少元素节点的右兄弟存在且拥有多余的元素,那么向左旋转

将父节点的分隔值复制到缺少元素节点的最后(分隔值被移下来;缺少元素的节点现在有最小数量的元素)
将父节点的分隔值替换为右兄弟的第一个元素(右兄弟失去了一个节点但仍然拥有最小数量的元素)
树又重新平衡

否则,如果缺少元素节点的左兄弟存在且拥有多余的元素,那么向右旋转
将父节点的分隔值复制到缺少元素节点的第一个节点(分隔值被移下来;缺少元素的节点现在有最小数量的元素)
将父节点的分隔值替换为左兄弟的最后一个元素(左兄弟失去了一个节点但仍然拥有最小数量的元素)
树又重新平衡

否则,如果它的两个直接兄弟节点都只有最小数量的元素,那么将它与一个直接兄弟节点以及父节点中它们的分隔值合并
将分隔值复制到左边的节点(左边的节点可以是缺少元素的节点或者拥有最小数量元素的兄弟节点)
将右边节点中所有的元素移动到左边节点(左边节点现在拥有最大数量的元素,右边节点为空)
将父节点中的分隔值和空的右子树移除(父节点失去了一个元素)
如果父节点是根节点并且没有元素了,那么释放它并且让合并之后的节点成为新的根节点(树的深度减小)

否则,如果父节点的元素数量小于最小值,重新平衡父节点


B- 树

就是B树的别称

因为B树的英文是B-tree,所以国内在翻译的时候有的会把后面的-带上,就变成了B-树...


B+

B+Tree是在B-Tree基础上的一种优化,使其更适合实现外存储索引结构,InnoDB存储引擎就是用B+Tree实现其索引结构。

从上一节中的B-Tree结构图中可以看到每个节点中不仅包含数据的key值,还有data值。而每一个页的存储空间是有限的,如果data数据较大时将会导致每个节点(即一个页)能存储的key的数量很小,当存储的数据量很大时同样会导致B-Tree的深度较大,增大查询时的磁盘I/O次数,进而影响查询效率。在B+Tree中,所有数据记录节点都是按照键值大小顺序存放在同一层的叶子节点上,而非叶子节点上只存储key值信息,这样可以大大加大每个节点存储的key值数量,降低B+Tree的高度。

B+Tree相对于B-Tree有几点不同:

1.非叶子节点只存储键值信息。

2.所有叶子节点之间都有一个链指针。

3.数据记录都存放在叶子节点中。

image.png

B+的搜索与B-树也基本相同,区别是B+树只有达到叶子结点才命中(B-树可以在非叶子结点命中),其性能也等价于在关键字全集做一次二分查找;

B+的特性:

   1.所有关键字都出现在叶子结点的链表中(稠密索引),且链表中的关键字恰好是有序的;
   2.不可能在非叶子结点命中;
   3.非叶子结点相当于是叶子结点的索引(稀疏索引),叶子结点相当于是存储(关键字)数据的数据层;
   4.更适合文件索引系统;

将上一节中的B-Tree优化,由于B+Tree的非叶子节点只存储键值信息,假设每个磁盘块能存储4个键值及指针信息,则变成B+Tree后其结构如下图所示:

image.png

通常在B+Tree上有两个头指针,一个指向根节点,另一个指向关键字最小的叶子节点,而且所有叶子节点(即数据节点)之间是一种链式环结构。因此可以对B+Tree进行两种查找运算:一种是对于主键的范围查找和分页查找,另一种是从根节点开始,进行随机查找。

可能上面例子中只有22条数据记录,看不出B+Tree的优点,下面做一个推算:

InnoDB存储引擎中页的大小为16KB,一般表的主键类型为INT(占用4个字节)或BIGINT(占用8个字节),指针类型也一般为4或8个字节,也就是说一个页(B+Tree中的一个节点)中大概存储16KB/(8B+8B)=1K个键值(因为是估值,为方便计算,这里的K取值为〖10〗3)也就是说一个深度为3的B+Tree索引可以维护10^3 * 10^3 * 10^3 = 10亿 条记录。

实际情况中每个节点可能不能填充满,因此在数据库中,B+Tree的高度一般都在2-4层。mysql的InnoDB存储引擎在设计时是将根节点常驻内存的,也就是说查找某一键值的行记录时最多只需要1~3次磁盘I/O操作。

数据库中的B+Tree索引可以分为聚集索引(clustered index)和辅助索引(secondary index)。上面的B+Tree示例图在数据库中的实现即为聚集索引,聚集索引的B+Tree中的叶子节点存放的是整张表的行记录数据。辅助索引与聚集索引的区别在于辅助索引的叶子节点并不包含行记录的全部数据,而是存储相应行数据的聚集索引键,即主键。当通过辅助索引来查询数据时,InnoDB存储引擎会遍历辅助索引找到主键,然后再通过主键在聚集索引中找到完整的行记录数据。


B*

B*是B+树的变体,在B+树的非根和非叶子结点再增加指向兄弟的指针;

image.png

B*树定义了非叶子结点关键字个数至少为(2/3)*M,即块的最低使用率为2/3

(代替B+树的1/2);

   B+树的分裂:当一个结点满时,分配一个新的结点,并将原结点中1/2的数据

复制到新结点,最后在父结点中增加新结点的指针;B+树的分裂只影响原结点和父

结点,而不会影响兄弟结点,所以它不需要指向兄弟的指针;

   B*树的分裂:当一个结点满时,如果它的下一个兄弟结点未满,那么将一部分

数据移到兄弟结点中,再在原结点插入关键字,最后修改父结点中兄弟结点的关键字

(因为兄弟结点的关键字范围改变了);如果兄弟也满了,则在原结点与兄弟结点之间增加新结点,并各复制1/3的数据到新结点,最后在父结点增加新结点的指针;

所以,B*树分配新结点的概率比B+树要低,空间使用率更高;


 二叉搜索树:二叉树,每个结点只存储一个关键字,等于则命中,小于走左结点,大于走右结点;

   B(B-)树:多路搜索树,每个结点存储M/2到M个关键字,非叶子结点存储指向关键字范围的子结点;
所有关键字在整颗树中出现,且只出现一次,非叶子结点可以命中;

   B+树:在B-树基础上,为叶子结点增加链表指针,所有关键字都在叶子结点中出现,非叶子结点作为叶子结点的索引;B+树总是到叶子结点才命中;

   B*树:在B+树基础上,为非叶子结点也增加链表指针,将结点的最低利用率从1/2提高到2/3;
更新时间:2020-02-13 15:37:17

本文由 寻非 创作,如果您觉得本文不错,请随意赞赏
采用 知识共享署名4.0 国际许可协议进行许可
本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名
原文链接:https://www.zhouning.group/archives/数据结构基础16非线性数据结构之自平衡树之bbb树
最后更新:2020-02-13 15:37:17

评论

Your browser is out of date!

Update your browser to view this website correctly. Update my browser now

×