数据结构与算法之美——红黑树、B树以及B+树及应用

目录

一.二叉查找树(二叉搜索树,BST)

 1.1查找操作

1.2插入操作

1.3删除操作

1.4支持重复数据的二叉查找树

1.5二叉查找树的性能分析

二.平衡二叉查找树

2.1定义

2.2红黑树

2.3为什么红黑树这么受欢迎

三.哈希表为什么不能替代二叉查找树

四.MySQL数据库索引是如何实现的

基于数组的解决方案

基于哈希的解决方案

基于平衡二叉查找树的解决方案

外存储器—磁盘知识的补充

磁盘的构造

磁盘的读/写原理

磁盘预读

影响IO效率的因素

B树

B树里的节点分裂

B树的查找

B+树

为什么MySQL索引使用的是B+树而非B树


先来看一下什么是二叉查找树

一.二叉查找树(二叉搜索树,BST)

对于二叉查找树中的任意一个节点,其左子树中每个节点的值都要小于这个节点的值,而右子树中每个节点的值都要大于这个节点的值

 1.1查找操作

我们先取根节点,如果它等于我们要查找的数据,那就返回。如果要查找的数据比根节点的值小,那就在左子树中递归查找;如果要查找的数据比根节点的值大,那就在右子树中递归查找。

public TreeNode searchBST(TreeNode root, int val) {
    if(root==null)return null;
    if(root.val>val)return searchBST(root.left,val);
    if(root.val<val)return searchBST(root.right,val);
    return root;
}

1.2插入操作

我们从根节点开始,依次比较要插入的数据和节点的大小关系

public TreeNode insertIntoBST(TreeNode root, int val) {
    //找到空位置插入新节点
    if(root==null)return new TreeNode(val);
    if(root.val<val)root.right=insertIntoBST(root.right,val);
    if(root.val>val)root.left=insertIntoBST(root.left,val);
    return root;
}

1.3删除操作

针对要删除节点的子节点个数的不同,我们需要分三种情况来处理:

  1. 情况 1A 恰好是末端节点,两个子节点都为空,那么它可以当场去世了。
  2. 情况 2A 只有一个非空子节点,那么它要让这个孩子接替自己的位置。
  3. 情况 3A 有两个子节点,麻烦了,为了不破坏 BST 的性质,A 必须找到左子树中最大的那个节点,或者右子树中最小的那个节点来接替自己。我们以第二种方式讲解。
public TreeNode deleteNode(TreeNode root, int key) {
     if(root==null)return null;
     if(root.val<key)root.right=deleteNode(root.right,key);
     if(root.val>key)root.left=deleteNode(root.left,key);
     if(root.val==key){
         //处理情况 1 和 2
         if(root.left==null)return root.right;
         if(root.right==null)return root.left;
         //处理情况 3
         TreeNode minNode=getMin(root.right);
         //删除 右子树的最小节点
         root.right=deleteNode(root.right,minNode.val);
         //用右子树的最小节点替换 root 节点
         minNode.left=root.left;
         minNode.right=root.right;
         //注意
         root=minNode;
     }
     return root;
}
public TreeNode getMin(TreeNode root){
    while (root.left!=null){
        root=root.left;
    }
    return root;
}

1.4支持重复数据的二叉查找树

针对包含值相同的节点的二叉查找树,有两种存储方式。

  • 第一种方法:二叉查找树中每一个节点不仅会存储一个数据,因此我们通过链表和支持动态扩容的数组等数据结构,把值相同的数据都存储在同一个节点上
  • 第二种方法:每个节点仍然只存储一个数据。在查找插入位置的过程中,如果碰到一个节点的值,与要插入数据的值相同,我们就将这个要插入的数据放到这个节点的右子树,也就是说,把这个新插入的数据当作大于这个节点的值来处理

  • 当要查找数据的时候,遇到值相同的节点,我们并不停止查找操作,而是继续在右子树中查找,直到遇到叶子节点,才停止。这样就可以把键值等于要查找值的所有节点都找出来。
  • 对于删除操作,我们也需要先查找到每个要删除的节点,然后再按前面讲的删除操作的方法,依次删除。

1.5二叉查找树的性能分析

对于同一组数据,我们可以构造不同的二叉查找树

不管操作是插入、删除还是查找,时间复杂度其实都跟树的高度成正比

第一种二叉查找树,根节点的左右子树极度不平衡,已经退化成了链表,所以查找的时间复杂度就变成了O(n)。 

最理想的情况下二叉查找树是一棵完全二叉树(或满二叉树),如上图第三种,插入、删除、查找操作的时间复杂度是O(logn)。

二.平衡二叉查找树

二叉查找树在频繁的动态更新过程中,可能会出现树的高度远大于log n的情况,从而导致各个操作的效率下降。为了避免时间复杂度的退化,针对二叉查找树,引出了一种更加复杂的树,平衡二叉查找树,时间复杂度可以做到稳定的O(logn)

2.1定义

平衡二叉树的严格定义:二叉树中任意一个节点的左右子树的高度相差不能大于1

平衡二叉查找树中“平衡”的意思,其实就是让整棵树左右看起来比较“对称”、比较“平衡”,不要出现左子树很高、右子树很矮的情况。这样就能让整棵树的高度相对来说低一些,相应的插入、删除、查找等操作的效率高一些。

2.2红黑树

红黑树是平衡二叉树的一种,红黑树中的节点,一类被标记为黑色,一类被标记为红色。除此之外,一棵红黑树还需要满足这样几个要求:

  • 根节点是黑色的;

  • 每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据;

  • 任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的;

  • 每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点;

 当然红黑树同时还要满足二叉查找树的特点

2.3为什么红黑树这么受欢迎

平衡二叉查找树其实有很多,比如AVL(严格符合平衡二叉树的定义)、Splay Tree(伸展树)、Treap(树堆)等,但是在实际工程开发中,对于很多需要平衡二叉查找树的地方,更多会选择使用红黑树。

  • Treap、Splay Tree,绝大部分情况下,它们操作的效率都很高,但是也无法避免极端情况下时间复杂度的退化。尽管这种情况出现的概率不大,但是对于单次操作时间非常敏感的场景来说,它们并不适用。
  • AVL树是一种高度平衡的二叉树,所以查找的效率非常高,但是,有利就有弊,AVL树为了维持这种高度的平衡,就要付出更多的代价。每次插入、删除都要做调整,就比较复杂、耗时。所以,对于有频繁的插入、删除操作的数据集合,使用AVL树的代价就有点高了。当然如果是插入特别少,查询特别多的情况下推荐使用AVL树。
  • 红黑树只是做到了近似平衡并不是严格的平衡“平衡”的意思可以等价为性能不退化。“近似平衡”就等价为性能不会退化的太严重),但树的高度仍然是对数量级的,因此性能的损失并不多,并且红黑树降低了对旋转的要求,在插入时避免了大量的旋转提高了插入,删除的操作性能,所以在维护平衡的成本上,要比AVL树要低。对于工程应用来说,要面对各种异常情况,为了支撑这种工业级的应用,我们更倾向于这种性能稳定的平衡二叉查找树。

三.哈希表为什么不能替代二叉查找树

散列表的插入、删除、查找操作的时间复杂度可以做到常量级的O(1),非常高效。而二叉查找树在比较平衡的情况下,插入、删除、查找操作时间复杂度才是O(logn),相对散列表,好像并没有什么优势。那为什么还要用二叉查找树呢?

  1. 散列表中的数据是无序存储的,如果要输出有序的数据,需要先进行排序或者配合有序链表来使用。而对于二叉查找树来说,我们只需要中序遍历,就可以在O(n)的时间复杂度内,输出有序的数据序列。
  2. 散列表扩容耗时很多,而且当遇到散列冲突时,性能不稳定,尽管二叉查找树的性能不稳定,但是在工程中,我们最常用的平衡二叉查找树的性能非常稳定,时间复杂度稳定在O(logn)。
  3. 散列表的构造比二叉查找树要复杂,需要考虑的东西很多。比如散列函数的设计、冲突解决办法、扩容、缩容等。平衡二叉查找树只需要考虑平衡性这一个问题,而且这个问题的解决方案比较成熟、固定。

四.MySQL数据库索引是如何实现的

  • 功能性需求比如常见的按值查询区间查询
  • 性能方面的需求,我们主要考察时间和空间两方面,也就是执行效率和存储空间。在执行效率方面,我们希望通过索引,查询数据的效率尽可能的高;在存储空间方面,我们希望索引不要消耗太多的内存空间。

基于数组的解决方案

  • 查找的效率很慢
  • 在查找时如果设计插入或删除,算法开销很高
  • 文件系统和数据库的索引都是存在硬盘上的,并且如果数据量大的话,不一定能一次性加载到内存中

基于哈希的解决方案

  • hash冲突后,数据散列不均匀,产生大量线性查询,效率低

  • 等值查询可以,但是不支持区间快速查找数据,只能挨个遍历
  • 使用hash索引数据存储没有顺序,在order by情况下还要对数据重新排序

基于平衡二叉查找树的解决方案

  • 尽管平衡二叉查找树查询的性能也很高,时间复杂度是O(log n)。而且,对树进行中序遍历,我们还可以得到一个从小到大有序的数据序列,但这仍然不足以支持按照区间快速查找数据。
  • 当数据量比较大的时候,树的深度会变深,查找的次数也会变多,IO的次数也会变多,影响读取的效率。总结:红黑树不适合IO级别的操作,更适合内存级别的应用。

比如Java中:

  • TreeMap、TreeSet都使用红黑树作为底层数据结构
  • JDK 1.8开始,HashMap也引入了红黑树:当冲突的链表长度超过8时,自动转为红黑树

有人可能会疑惑为什么这里会涉及到IO次数呢?假设给一亿个数据构建二叉查找树索引,那索引中会包含大约1亿个节点,每个节点假设占用16个字节,那就需要大约1GB的内存空间。给一张表建立索引,我们需要1GB的内存空间。如果我们要给10张表建立索引,那对内存的需求是无法满足的。所以文件系统的索引都是保存在磁盘当中的。

外存储器—磁盘知识的补充

计算机存储设备一般分为两种:内存储器(main memory)和外存储器(external memory)

  • 外存:外存也叫做外存储器,是指除计算机内存及CPU缓存以外的储存器,此类储存器一般断电后仍然能保存数据。常见的外存储器有硬盘、软盘、光盘、U盘等。
  • 内存:内存是计算机中重要的部件之一,它是与CPU进行沟通的桥梁。计算机中所有程序的运行都是在内存中进行的,因此内存的性能对计算机的影响非常大。

磁盘的构造

磁盘是一个扁平的圆盘(与电唱机的唱片类似)。盘面上有许多称为磁道的圆圈数据就记录在这些磁道上。磁盘可以是单片的,也可以是由若干盘片组成的盘组,每一盘片上有两个面。如下图中所示的6片盘组为例,除去最顶端和最底端的外侧面不存储数据之外,一共有10个面可以用来保存信息。

  • 磁盘驱动器执行读/写功能时。盘片装在一个主轴上,并绕主轴高速旋转,当磁道在读/写头(又叫磁头) 下通过时,就可以进行数据的读 / 写了。
  • 一般磁盘分为固定头盘(磁头固定)和活动头盘固定头盘的每一个磁道上都有独立的读/写头(又叫磁头),它是固定不动的,专门负责这一磁道上数据的读/写。
  • 活动头盘读/写头(又叫磁头)可移动的每一个盘面上只有一个磁头(磁头是双向的,因此正反盘面都能读写)。它可以从该面的一个磁道移动到另一个磁道。所有磁头都装在同一个动臂上,因此不同盘面上的所有磁头都是同时移动的(行动整齐划一)。当盘片绕主轴旋转的时候,磁头与旋转的盘片形成一个圆柱体。各个盘面上半径相同的磁道组成了一个圆柱面,我们称为柱面 。因此,柱面的个数也就是盘面上的磁道数。

磁盘的读/写原理

磁盘上数据必须用一个三维地址唯一标示:柱面号(用来定位盘面上的磁道)、盘面号(选择盘面)、块号(定位是哪个扇区)

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

  1. 首先移动臂根据柱面号(用来定位盘面上的磁道)使磁头移动到所需要的柱面上,这一过程被称为定位或查找 。(主要时间)
  2. 如上图所示的6盘组示意图中,所有磁头都定位到了10个盘面的10条磁道上(磁头都是双向的)。这时根据盘面号(选择盘面)来确定指定盘面上的磁道。
  3. 盘面确定以后,盘片开始旋转,将指定块号(定位是哪个扇区)的磁道段移动至磁头下。

经过上面三个步骤,指定数据的存储位置就被找到。这时就可以开始读/写操作

磁盘读取数据是以盘块(扇区)为基本单位的。位于同一盘块中的所有数据都能被一次性全部读取出来。而磁盘IO代价主要花费在查找时间上。因此我们应该尽量将相关信息存放在同一盘块,同一磁道中。或者至少放在同一柱面或相邻柱面上,以求在读/写信息时尽量减少磁头来回移动的次数,避免过多的查找时间Ts。

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

磁盘预读

当内存读取B文件索引时,程序需要将根节点从磁盘读取到内存中。如果下一个子节点也存储在磁盘中,程序需要从磁盘中读取该节点。为了减少磁盘I/O操作的次数,程序通常会将多个磁盘块读入内存中,这些块中至少包含下一个要访问的子节点。这个操作被称为预读

如果B文件的节点和数据存储在不同的磁盘块中,程序可能需要多次从磁盘中读取数据才能获取完整的节点或数据。这会导致频繁的磁盘I/O操作,降低程序的性能。

程序在内存中访问B文件的节点和数据时,也需要考虑页的大小。如果一个节点或数据的大小超过了一页的大小,程序需要将其分成多个段,每个段存储在不同的页中。这个过程被称为分页分块

概念解析

页(datapage)页是内存操作的基本单位,页一般由操作系统决定是多大,一般是4k。我们在数据交互时,可以取页的整数倍来进行读取。

我们可以看一个word文档的属性

 实际大小只有11.5KB,但是占用空间却是12.0KB(4的倍数)

磁盘块(扇区):磁盘块(扇区)是文件系统用来管理磁盘空间的基本单位。

  • 在虚拟存储器中,操作系统将内存分成若干个页,也将磁盘分成若干个磁盘块,以便将数据从磁盘读入内存或将数据从内存写入磁盘时进行管理。
  • 在文件系统中,文件通常被分成若干个磁盘块进行存储,而当程序需要读取文件时,操作系统将磁盘块逐一读入内存中的页中,以便程序能够对文件进行访问。

影响IO效率的因素

 为了降低树的高度,所以我们引出了B树

B树

B树是一种多路搜索树,它的每个节点可以拥有多于两个孩子节点。M路的B树最多能拥有M个孩子节点(即最多有M-1个关键字

特点(个人觉得这些特点不用死记,不想看的可以跳过)

  • 除根节点外,其他节点至少有M/2(向上取整)个孩子节点
  • 每个结点的值(索引) 都是按递增次序排列存放的,并遵循左小右大原则。
  • 根结点 的 子节点 个数为 [2,M]。
  • B树的所有叶子结点都位于同一层

每个节点的结构

示例

B树里的节点分裂

这里推荐一个演示网站,非常形象:B-Tree Visualization (usfca.edu)

我们选择最大度数为5之后,插入一组数据: 100 65 169 368 900 556 780 35 215 1200 234 888 158 90 1000 88 120 268 250 。然后观察一些数据插入过程中,节点的变化情况。

一旦节点存储的key数量到达5,就会裂变,中间元素会向上分裂 

B树的查找

B树中每个节点都可以存放表的行记录数据,每个节点的读取可以视为一次I/O读取,树的高度表示最多的I/O次数,在相同数量的总记录个数下,每个节点的记录个数越多,高度越低,查询所需的I/O次数越少;假设,硬盘一次I/O数据为16K,一条记录占1K,理论上一个节点最多可以放16个记录,16 ×16 ×16 = 2^12=4096条,当需要存100000条数据时,依然可能导致树高度剧增。

B树和B+树相比的一个特点是 内节点(非叶子节点)也存储了 表中的一行记录。我们可以发现节点的data部分其实占了很大的空间,相比之下指针部分和关键字部分占得却很少。为了进一步提高磁盘的访问效率,就产生了B+树

B+树

B+树与B树最大的不同是内部结点不保存记录数据,只保存关键字,用于查找,所有记录数据都保存在叶子结点中。由于每个非叶子节点只存放关键字,这样节点中能存放的关键字数量就更多,树的结构就更加矮小,访问磁盘的次数就更少。

上图是标准的B+Tree的数据结构,MySQL索引数据结构对经典的B+Tree进行了优化。在原B+Tree的基础上,增加一个指向相邻叶子节点的链表指针,就形成了带有顺序指针的B+Tree,提高区间访问的性能,利于排序。

特点(个人觉得这些特点不用死记,不想看的可以跳过)

  • B+树中的节点不存储数据,只是索引,而B树中的节点存储数据;

  • 通过双向链表将叶子节点串联在一起,这样可以方便按区间查找;

  • 每个节点中子节点的个数不能超过m,也不能小于m/2;

  • 根节点的子节点个数可以不超过m/2,这是一个例外;

为什么MySQL索引使用的是B+树而非B树

  1. B+树与B树的设计主要用于提高I/O速度,也就是读取磁盘的速度。先前说了,对于 B-Tree,无论是叶子节点还是非叶子节点,都会保存数据,这样导致一页中存储的键值减少,指针也跟着减少,要同样保存大量数据,只能增加树的高度,导致性能降低;而对于B+Tree树来说,指针增多,自然树的阶(最大度数)增多,自然层数更低,效率更高
  2. 如果涉及到范围查找, B+ 树由于所有数据都在叶子结点,不用跨层,同时由于有双向链表结构只需要找到首尾,通过链表就能把所有数据取出来了,查询性能更高。而B树则需要全局遍历
  3. 由于B+树所有数据都存储在叶子结点,所以B+树的I/O次数会更加稳定
  4. 基于B+树这样的数据结构,如果采用自增的整型数据作为主键,可以更好的避免数据增加的时候带来的叶子结点分裂
  • 5
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
第1章向读者介绍数据结构作为数据集合的概念。介绍线性和非线性集合的概念。示范说明了Collection类。本章还介绍泛型编程的概念。泛型编程允许程序员编写一个类或一种方法,并且把它用于众多数据类型。泛型编程是C#语言一种重要的新特性(在C#2.0以及更高版本中可用)。这种特性是如此重要以至于在System.Collections.Generic命名空间中存在一个专门的泛型数据结构库。当数据结构具有在此库中能找到的泛型实现时,就会讨论它的用途。本章结尾处介绍了衡量书中讨论的数据结构与算法性能的方法。 第2章提供了数组构造方法的回顾,并连同示例说明了Array类的特征。Array类把许多与数组相关的函数(UBound函数、LBound函数等等)封装到单独一个包中。ArrayLists是数组的一种特殊类型,它支持动态地调整容量。 第3章是对基础排序算法的介绍,例如冒泡排序和插入排序。而第4章则研究了用于内存查找的最基本算法,顺序查找和二叉查找。 第5章探讨了两种经典的数据结构:堆栈和队列。本章节强调的重点是这些数据结构在解决日常数据处理问题中的实际应用。第6章讲述了BitArray类。这种类可以用于有效地表示大量整数值,比如测试成绩。 数据结构的书中通常不包含字符串,但是第7章介绍了字符串、String类和StringBuilder类。这是因为在C#语言中许多的数据处理是在字符串上执行的,读者应该接触基于这两种类的特殊方法。第8章分析了用于文本处理和模式匹配的正则表达式的使用。与较传统的字符串函数和方法相比,正则表达式常常会提供更强大更有效的处理。 第9章向读者介绍作为数据结构的字典的使用。字典和基于字典的不同数据结构把数据作为键/值对来存储。本章向读者说明了如何创建基于DictionaryBase类的他或她自己的类。DictionaryBase类是一个抽象类。第10章包括散列表和HashTable类。HashTable类是字典的一种特殊类型,它用散列算法对内部数据进行存储。 链表作为另外一种经典的数据结构是在第11章介绍。链表在C#语言中不像在C++这样基于指针的语言中那样重要,但是它始终在C#编程中发挥作用。第12章为读者介绍另一种经典数据结构——二叉树。二叉查找树作为二叉树的特殊类型将是本章的主要内容。其他二叉树类型在第15章进行介绍。 第13章向读者说明在集合中存储数据的方法。这种方法在数据结构只存储唯一数据值的情况下是很实用的。第14章涵盖了高级排序算法,包括流行且高效的快速排序算法。此算法是大多数在.NET框架库中实现的排序程序的基础。第15章会看到三种数据结构。在无法使用二叉查找树的时候,这三种数据结构证明对查找是很有用的。他们是:AVL树、红黑树和跳跃表。 第16章讨论了图以及图的算法。图在表示许多不同的数据类型时非常有用,特别是网络的情况。最后,第17章向读者介绍真正的算法设计技巧是什么:动态算法和贪心算法。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值