高阶数据结构(4)基本搜索结构 (内存中保存索引的缺陷、加速数据访问的策略、存储分类 )、B/B-树(概念、性质、插入、验证、删除、性能分析)、B+树、B*树、B-树的应用——索引 :MySQL索引

接上次博客:高阶数据结构(3)并查集:并查集原理、并查集实现、并查集应用;LRUCache:概念、LRU Cache的实现、JDK中类似LRUCahe的数据结构LinkedHashMap、LRU Cache的OJ-CSDN博客

目录

基本搜索结构 

内存中保存索引的缺陷:

加速数据访问的策略:

存储分类 

面试题:数据存储到HashMap和存储到文件的区别:

B/B-树

B-树概念

B树的性质

B-树的插入分析

逻辑巧思

拆分策略

插入过程总结

B-树的插入实现

B-树的节点设计

插入 key 的过程

B-树的插入实现

B-树的简单验证

B-树的性能分析 

B+树

B*树

B-树的应用 

索引 

MySQL索引简介

 MyISAM

InnoDB

MySQL 常见面试题


基本搜索结构 

数据结构  数据格式   时间复杂度
顺序查找无要求O(N)
二分查找有序O(log N)
二叉搜索树无要求最差情况下 O(N), 平均情况下 O(log N)
二叉平衡树 (AVL树和红黑树)无要求-最后随机O(log N)
哈希表 无要求平均情况下 O(1), 最坏情况下 O(N)
位图无要求O(1)
布隆过滤器无要求O(K) (K为哈希函数个数,一般比较小)

以上结构适合用于数据量不是很大的情况,当数据量非常大,无法一次性加载到内存中时,经典的数据结构和算法可能会遇到性能瓶颈,使用上述结构就不是很方便。比如:使用平衡树搜索一个大文件。 

上面方法其实只在内存中保存了每一项数据信息中需要查找的字段以及数据在磁盘中的位置,整体的数据实际也在磁盘中。 

内存中保存索引的缺陷:

  1. 树的高度较高: 当使用传统的平衡树结构时,数据的查找过程受制于树的高度。在最差情况下,需要比较树的高度次,导致查询效率下降,尤其是对于大规模数据集。

  2. 大数据量时内存限制: 当数据量非常庞大时,整个树结构可能无法一次性加载到内存中。由于内存的限制,必须进行多次IO操作,从磁盘读取树的部分节点,增加了查询的时间复杂度。

加速数据访问的策略:

  1. 提高IO速度:

    • 高性能存储设备: 将数据存储在高性能存储设备(例如SSD)上,以提高读写速度。
    • 数据压缩: 在磁盘上使用压缩算法存储数据,减小存储空间需求,并在IO时进行解压缩。
    • 并行IO: 利用多线程或异步IO技术,同时进行多个IO操作,提高读写效率。
    • 数据预读取: 在访问数据时,预先加载可能需要的相邻数据块,减少后续IO操作等待时间。
  2. 降低树的高度:

    • 多叉树结构: 使用多叉树(如B树或B+树)而不是二叉树,以降低树的高度,减少查询时需要比较的层数。
    • 分层索引: 使用分层索引结构,将索引分为多个层次,每个层次的索引可以保存更多的信息,降低整体树的高度。

这些策略旨在同时解决磁盘IO速度和树高度的问题,以提高对大规模数据的访问效率。在实际应用中,需要根据具体场景和需求选择适当的策略,并进行合理的权衡。

 

内存中的数据特性:

  • 易丢失性: 内存中的数据是易丢失的,因为内存是一种临时存储设备,是带电存储的。一旦断电,内存中的数据就会丢失,而不会持久保存。
  • new的对象存储在堆内存中: new 操作符用于在堆内存中动态分配对象的空间。这些对象不是存储在缓存中,而是存储在堆内存中。缓存通常指的是一种用于临时存储和快速访问的高速存储区域,而堆内存是一种更大、更灵活但相对较慢的内存区域,用于动态分配和管理对象。

存储分类 

存储可以根据不同的特性和功能进行分类,以下是一些常见的存储分类:

易失性存储(Volatile Storage) vs. 非易失性存储(Non-Volatile Storage):

  • 易失性存储: 数据在断电时会丢失,例如RAM(随机存储器)。
  • 非易失性存储: 数据在断电时仍然保持,例如硬盘驱动器(HDD)、固态硬盘(SSD)、闪存、光盘等。

访问高速存储 vs. 访问低速存储:

  • 访问高速存储: 速度较快,用于快速读写,例如RAM、高速缓存。
  • 访问低速存储: 速度相对较慢,例如硬盘驱动器、光盘。

支持任意位置访问(Random Access) vs. 支持连续访问(Sequential Access):

  • 支持任意位置访问: 可以直接访问存储中的任意位置,例如RAM、硬盘驱动器。
  • 支持连续访问: 数据需要按照顺序逐个读取,例如磁带。

字节访问存储 vs. 块访问存储:

  • 字节访问存储: 数据以字节为单位进行读写,例如RAM。
  • 块访问存储: 数据以固定大小的块为单位进行读写,例如硬盘驱动器。

磁盘(硬盘驱动器) vs. 内存(RAM) vs. 光盘(CD/DVD/Blu-ray):

  • 磁盘(硬盘驱动器): 用于持久性存储,数据在断电时仍然保持。
  • 内存(RAM): 用于临时存储,数据在断电时会丢失。
  • 光盘(CD/DVD/Blu-ray): 通常用于只读存储,数据一经写入通常无法修改。

每种类型的存储都有其适用的场景和用途,选择合适的存储设备取决于应用程序的需求和性能要求。

面试题:数据存储到HashMap和存储到文件的区别:

在讨论存储到HashMap和存储到文件的区别时,主要涉及到持久性、性能和数据访问方面的考虑:

  1. 持久性:

    • HashMap: 存储在HashMap中的数据是在内存中,具有易丢失性。如果程序结束或者发生异常,如断电等等,HashMap中的数据将丢失。
    • 文件存储: 存储到文件中的数据是持久的,可以在多次程序运行之间保留。即使发生断电或程序关闭,文件中的数据仍然可以被保留。
  2. 性能:

    • HashMap: 由于存储在内存中,HashMap的读取速度相对较快,适用于需要快速访问和修改数据的场景。
    • 文件存储: 读取文件的速度相对较慢,尤其是对于大型文件。但文件适用于大规模数据的长期存储和离线处理。
  3. 数据访问:

    • HashMap: 数据存储在内存中,对于读取和写入操作非常迅速。适用于需要频繁访问、更新和查询数据的场景。
    • 文件存储: 读取文件的过程相对较慢,适用于需要长期保存数据、不需要频繁修改、并且对实时性要求不高的场景。

综上所述,选择存储方式要根据具体的需求和场景进行权衡。如果需要快速的读写操作、对实时性要求高,并且数据量较小,HashMap可能更适合。如果需要长期保存大规模数据、对实时性要求不高,文件存储可能更合适。

B/B-树

B树(有些地方写的是B-树,注意不要误读成"B减树"!这个-是分隔符)。

B-树概念

1970年,R. Bayer 和 E. McCreight提出的B树(B-tree)是一种多叉树,特别适用于外部查找的场景,其中数据存储在外部磁盘或其他持久性存储介质上。

B树被设计为一种平衡的多叉树,其中每个节点可以拥有多个子节点,而且具有以下关键性质:

  1. M阶B树: 一棵M阶(M > 2)的B树是一棵平衡的M路平衡搜索树。这表示每个内部节点最多有M-1个关键字和M个孩子,最少有M/2-1(向上取整)个关键字(而且以升序排列)和M/2(向上取整)个孩子。

  2. 平衡性: B树保持平衡,这意味着从根节点到每个叶子节点的路径长度相同。这有助于保持树的高度较小,从而减少检索和插入的操作成本。

  3. 关键字的排序: 在每个节点内,关键字按升序排列。这使得在内部节点进行搜索时可以使用二分查找,提高查找效率。

  4. 节点的最小关键字数: 每个非根节点至少有M/2-1(向上取整)个关键字,以确保节点不会太小,维持树的平衡性。

  5. 节点的最小孩子数: 每个非根节点至少有M/2(向上取整)个孩子。这保证了节点之间的连接关系,以维持整个树的结构平衡。

B树的设计旨在克服传统的搜索树结构在外部存储设备上的性能瓶颈,因为它们更适应外部存储的特性,例如磁盘。B树的平衡性和多路性质使其适用于高效地支持范围查询、插入和删除等操作,特别是在大规模数据存储和检索的场景中。

B树的性质

  1. 根节点孩子数: B树的根节点至少有两个孩子。这保证了树的平衡性,且根节点不容易成为性能瓶颈。

  2. 关键字数目范围: 每个非根节点至少有 M/2-1 个关键字,至多有 M-1 个关键字。这个范围保证了树的平衡,并且关键字数目不会太少或太多。

  3. 孩子数目范围: 每个非根节点至少有 M/2 个孩子,至多有 M 个孩子。这也是为了保持树的平衡,以确保树的高度不会过高。

  4. 关键字的排序: 在每个节点中,关键字 key[i] 和 key[i+1] 之间的孩子节点的值介于 key[i] 和 key[i+1] 之间。这个性质确保了在树的内部节点中进行搜索时,可以通过二分查找更快地找到目标关键字。

  5. 叶子节点同层: 所有的叶子节点都在同一层。这是B树的一个重要性质,使得从根节点到叶子节点的路径长度相同,从而保持了查询的效率。

  6. 节点分裂和合并: 当节点关键字达到最大值时,节点会被分裂成两个节点;当节点关键字数量过小时,可以从其兄弟节点借用关键字,或者进行节点合并。这确保了B树的动态平衡性。

B树的这些性质使其适用于外部存储的场景,因为它能够减少磁盘IO次数,提高数据的访问效率。

我们可以看看,当M为1024的时候,B树的存储容量及层次结构使得B树对数据的处理能力有多强大:

  1. M = 1024的情况下: 这里指的是B树的每个节点最多可以容纳1024个关键字。

  2. 第一层: 1023个关键字: 第一层是树的根节点,它可以容纳1024个关键字,但由于B树的性质,第一层的关键字数量通常比孩子节点的最大关键字数少一个,因此是1023个关键字。

  3. 第二层: 1024孩子 * 1023关键字: 第二层是根节点的孩子节点,每个孩子节点也可以容纳1024个关键字。因此,第二层总共有1024个孩子节点,每个孩子节点有1023个关键字。这使得第二层的总关键字数量约为100W(100万)。

  4. 第三层: 1024孩子 * 1024 * 1024 * 1023子 * 1024每个节点: 第三层是第二层的孩子节点,每个孩子节点同样可以容纳1024个关键字。这样,第三层的总节点数量为1024 * 1024 * 1023,每个节点有1024个孩子。这使得第三层的总关键字数量达到了10亿级别。

  5. 第四层: 1024 * 1024 * 1024 * 1023 -》 1024 * 1024=》孩子,每个孩子1024个节点,每个节点又是1023: 类似地,第四层是第三层的孩子节点,每个孩子节点仍然可以容纳1024个关键字。该层的节点数量为1024 * 1024 * 1023,每个节点有1024个孩子。这使得第四层的总关键字数量达到了一万亿级别。

B树在M=1024的情况下,其强大的存储容量和层次结构,使其能够有效地处理极大规模的数据。

B-树的插入分析

B树的插入过程涉及到节点的拆分,当一个节点中的关键字数量达到M-1时,需要进行拆分操作。

逻辑巧思

为了简单起见,比如,我们现在假设M = 3, 即这是一个三叉树,每个节点中存储两个数据,两个数据可以将区间分割成三个部分,因此节点应该有三个孩子。

但是为了后续实现的简单,我们将节点的结构定义如下: 

也就是说,我们实现的时候,故意使其变为一个四叉树,此时就最多可以存放3个元素。

这里简化成四叉树(M=3)的设计主要是为了确保在节点拆分的过程中有足够的空间进行排序和提取中间元素的操作。

让我们进一步解释这个思路:

  1. 排序和提取中间元素: 在B树的节点拆分中,我们通常会涉及到先放元素、然后排序、最后提取中间元素的步骤。这是为了确保插入的元素能够有序地放入节点,并且能够方便地提取中间位置的元素作为父节点的关键字。

  2. 节点容量的限制: 如果我们将节点设计成一个三叉树(M=3,M-1=2),那么节点最多只能存放2个元素。这样,在插入新元素并触发拆分的过程中,可能会遇到一个问题:在插入最后一个元素之前,已经进行了排序并提取了中间元素,但是插入最后一个元素时,可能无法进行排序和提取。

  3. 为什么选择四叉树: 设计为四叉树(M=4,M-1=3)的节点,确保了每个节点最多可以存放3个元素。这样,在插入元素的过程中,即使是插入最后一个元素,也有足够的空间进行排序和提取中间元素的操作。这种设计避免了上述问题,使得节点的插入和拆分操作更为简单和一致。

  4. 简化实现: 这种设计选择的一个关键点是简化实现。通过确保节点容量足够,在拆分时能够方便地排序和提取中间元素,我们可以简化插入和拆分的逻辑,提高代码的可读性和实现的简洁性。

综上所述,这种选择四叉树的设计是为了在插入和拆分的过程中保持逻辑的一致性和简便性。

拆分策略

拆分策略在B树的实现中起到了关键作用,它决定了在节点达到最大容量时如何进行分裂,以保持B树的平衡性和有序性:

  1. 触发节点的拆分: 当插入元素导致节点的关键字数量达到 M-1(节点已满)时,触发节点的拆分操作。这是为了确保节点中的关键字数量不超过规定的上限,保持B树的平衡性。

  2. 节点结构设计为四叉树(M=3): 本来每个节点最多存放2个元素,即M-1。现在设计为四叉树,每个节点最多就可以存放3个元素了,这样的设计简化了实现过程,因为在节点拆分时,我们需要进行先插入再排序,最后提取中间元素的操作。多扩展一个虚拟空间可以简化整个过程。

  3. 新建右侧节点进行拆分: 在节点拆分时,新建一个空的右侧节点,将当前节点中右半部分的关键字和孩子节点移动到新节点中。这确保了新节点仍然满足B树的性质,而且右侧节点是一个空节点,可以用于接收插入元素。

  4. 提升中间位置的关键字到父节点: 将当前节点中的中间位置的关键字提升到父节点中。这是为了确保父节点中的关键字仍然保持有序,并为可能的递归拆分提供了空间。

  5. 递归拆分父节点: 如果父节点也满了,就递归进行父节点的拆分。这个递归过程确保了整个B树的平衡性,因为如果每个节点都满了,就需要一直向上拆分,直到根节点。这样,整个B树的结构得到了调整,以适应新的插入元素。

通过这样的拆分策略,B树在插入元素时能够保持平衡和有序,使得树的高度相对较小,从而提高检索效率。这种灵活的节点拆分策略是B树在处理大量动态数据的情况下表现出色的关键因素之一。

插入过程:

  1. 查找插入位置: 从根节点开始,递归地在B树中查找要插入的位置,找到合适的叶子节点。

  2. 插入关键字: 在叶子节点中插入新的关键字,如果叶子节点中关键字数量未超过M-1,插入完成。否则,进行分裂操作。

  3. 分裂操作: 如果分裂发生在根节点,那么新的根节点就被创建;否则,分裂操作会将中间的关键字提取到父亲节点中,左边的部分构成一个新的节点,右边的部分构成另一个新的节点。这样,整个B树的结构保持平衡。

找到该元素的插入位置(索要插入节点pCur),按照插入排序的思想将该元素插入到该节点(pCur)的合适位置。随后,检测该节点是否满足B树的性质:

  • 满足B树性质: 如果插入元素后节点仍然满足B树的性质,即节点的关键字数量不超过规定的上限,插入操作结束。

  • 不满足B树性质: 如果插入元素后节点的关键字数量超过了规定的上限,即节点溢出,就需要对该节点进行分裂操作。分裂操作涉及将该节点的中间关键字提取到其父亲节点中,然后将节点分成左右两部分,分别形成两个新的节点。这样,B树的性质得到维护,并使得插入操作完成。

总体而言,插入过程遵循B树的平衡性和有序性原则,通过检测节点是否满足B树的性质,保持B树的结构平衡。这种动态调整的过程确保了在B树中进行插入操作时,树的高度保持相对较小,从而提高了检索的效率。

多叉平衡树的体现:

B树的分裂是横向的,只有横向在增加,也就意味着树的高度没有增加。只有分裂根节点的时候树的高度才会增加。 

这是B树的一个重要特性,保持了树的平衡性和高效性:

  1. 横向的分裂: B树的分裂是指当一个节点达到最大容量时,该节点被分成两个节点。这两个节点成为兄弟节点,它们在同一层级上。这种分裂是横向的,因为它在同一层级上增加了节点的数量,而不是垂直方向上增加树的高度。

  2. 树的高度: 在B树中,节点的分裂并不一定导致树的高度增加。只有在分裂根节点时,树的高度才会增加。因为树的高度是由根节点到叶子节点的最长路径决定的,而其他节点的分裂只是在同一层级上增加了节点数量,不会影响树的高度。

  3. 保持平衡性: B树通过横向的分裂操作来保持树的平衡性。这样可以确保在插入或删除操作后,树仍然具有较短的高度,提高了检索效率。而且,由于节点在同一层级上分裂,树的结构保持相对均衡。

  4. 分裂根节点: 当根节点分裂时,新的根节点产生,原有的根节点被拆分为两个兄弟节点,并成为新根节点的子节点。这时树的高度增加了一层,但这是唯一能导致高度增加的情况。

总体而言,B树的设计旨在平衡树的结构,保持树的高度相对较小,从而提高检索和插入等操作的效率。

此时根节点不满足B树的性质,要分裂: 

整个分裂的过程中,会一直向上分裂,知道根节点分裂结束。 

每次移动节点当中的数据时,孩子节点+父母节点都需要进行适当的改动。

根节点的关键字数量【1,M-1】,孩子节点的数量【2,M】;

非根节点的关键字数量【M/2 - 1,M - 1】,孩子节点的数量【M/2,M】。

插入过程总结

  1. 树为空: 如果B-树为空,直接创建一个新节点,并将待插入的元素放入该节点。这个新节点成为树的根节点。

  2. 树非空,找插入位置: 对于非空的B-树,从根节点开始递归查找待插入元素的位置。在内部节点中,选择下降的子树,直到到达叶子节点,找到插入位置。(注意:找到的插入节点位置一定在叶子节点中)

  3. 检测是否已存在: 检测插入位置的叶子节点中是否已经存在相同的关键字。如果存在,插入操作结束,因为B-树中的关键字是唯一的。

  4. 插入元素: 将待插入的元素按照插入排序的思想插入到找到的叶子节点中。插入后,确保节点内的关键字仍然保持有序。

  5. 检测节点是否满足B-树性质: 检测插入元素后,该节点是否仍然满足B-树的性质,即节点中的关键字个数是否小于等于M-1。如果满足,插入操作结束。

  6. 节点分裂: 如果插入元素后节点不满足B-树的性质,触发节点的分裂操作:

    • 申请一个新节点作为右侧节点。
    • 找到该节点的中间位置,将中间位置的关键字提升到其双亲节点中。
    • 将中间位置右侧的关键字以及其孩子节点搬移到新节点中。
    • 新节点成为中间位置关键字的右子节点。
    • 将新节点插入到中间位置关键字的右侧。
    • 递归地检测中间位置的双亲节点是否满足B-树性质,如果不满足则继续进行分裂。
  7. 插入结束: 如果分裂操作上升到根节点位置,可能会创建一个新的根节点。插入操作结束。

这个过程保持了B-树的平衡性和有序性。在插入元素时,通过适时地对节点进行分裂操作,确保B-树的结构在插入操作后仍然保持平衡。这有助于维持B-树的高效性质,使得插入操作不会导致树的高度显著增加。

B-树的插入实现

B-树的节点设计

class BTreeNode {
    int[] keys;        // 存放当前节点中的元素 key
    BTreeNode[] subs;  // 存放当前节点的孩子节点
    BTreeNode parent;  // 在分裂节点后可能需要继续向上插入,为实现简单增加 parent 域
    int size;          // 当前节点中有效节点的个数

    // 参数 M 代表 B-Tree 为 M 叉树
    BTreeNode(int M) {
        // M 叉树:即一个节点最多有 M 个孩子,M-1 个数据域
        // 为实现简单期间,数据域与孩子与多增加一个(原因参见上文对插入过程的分析)
        keys = new int[M];
        subs = new BTreeNode[M + 1];  // 注意:孩子比元素多一个
        size = 0;
    }
}

插入 key 的过程

先查找 key 是否在 B-树 中:

// 返回值的含义:
// 键值对中的 key:表示元素所在的节点
// 键值对中的 value:表示元素在该节点中的位置
// 当 value 为 -1 时,表示该元素在节点中不存在
public Pair<BTreeNode, Integer> find(int key) {
    BTreeNode cur = root;
    BTreeNode parent = null;

    while (cur != null) {
        int index = 0;
        while (index < cur.size) {
            if (key == cur.keys[index])
                return new Pair<BTreeNode, Integer>(cur, index);
            else if (key < cur.keys[index])  // 在该节点的第 index 个孩子中查找
                break;
            else
                index++;  // 在该节点中继续查找
        }
        // 在 cur 节点的第 index 的孩子节点中找 key
        parent = cur;
        cur = cur.subs[index];
    }

    // 未找到 key,索引返回 -1
    return new Pair<BTreeNode, Integer>(parent, -1);
}

B-树的插入实现

class BTreeNode {
    int[] keys; // 存放当前节点中的元素key
    BTreeNode[] subs; // 存放当前节点的孩子节点
    BTreeNode parent; // 在分裂节点后可能需要继续向上插入,为实现简单增加parent域
    int size; // 当前节点中有效节点的个数
    // 参数M代表B-Tree为M叉树
    BTreeNode(int M) {
        // M叉树:即一个节点最多有M个孩子,M-1个数据域
        // 为实现简单的分裂,数据域与孩子与多增加一个(原因参见上文对插入过程的分析)
        keys = new int[M];
        subs = new BTreeNode[M + 1]; // 注意:孩子比元素多一个
        size = 0;
    }
}

public class BTree {
    private BTreeNode root;
    private int M; // B-Tree的阶数

    public BTree(int M) {
        this.M = M;
        this.root = null;
    }
    /**
     * 将键插入BTreeNode中。
     *
     * @param cur 要插入键的BTreeNode。
     * @param key 要插入的键。
     * @param sub 与键关联的子节点(可以为null)。
     */
    private void insertKey(BTreeNode cur, int key, BTreeNode sub) {
        // 使用插入排序找到插入的正确位置。
        int end = cur.size - 1;

        while (end >= 0 && key < cur.keys[end]) {
            cur.keys[end + 1] = cur.keys[end];
            cur.subs[end + 2] = cur.subs[end + 1];
            end--;
        }

        // 插入键并更新子节点。
        cur.keys[end + 1] = key;
        cur.subs[end + 2] = sub;
        cur.size++;

        if (sub != null)
            sub.parent = cur; // 更新子节点中的父引用。
    }

    /**
     * 在BTree中搜索键,并返回找到键或应插入的节点和索引的Pair。
     *
     * @param key 要搜索的键。
     * @return 包含BTreeNode和索引的Pair。如果找到键,则索引是节点中的位置。如果未找到,索引为-1。
     */
    private Pair<BTreeNode, Integer> find(int key) {
        BTreeNode cur = root;
        BTreeNode parent = null;

        // 遍历树,找到插入的正确位置或定位键。
        while (cur != null) {
            int index = 0;

            // 在当前节点中搜索正确的位置。
            while (index < cur.size) {
                if (key == cur.keys[index])
                    return new Pair<>(cur, index); // 在当前节点中找到键。
                else if (key < cur.keys[index])
                    break;
                else
                    index++;
            }

            parent = cur;
            cur = cur.subs[index]; // 移动到树中的下一层。
        }

        return new Pair<>(parent, -1); // 返回父节点和-1,表示应插入键。
    }


    // 插入key
    public boolean insert(int key) {
        if (null == root) {
            root = new BTreeNode(M);
            root.keys[0] = key;
            root.size = 1;
            return true;
        }

        // 查找当前元素的插入位置
        // 如果返回的键值对的value不等于-1,说明该元素已经存在,则不插入
        Pair<BTreeNode, Integer> ret = find(key);
        if (-1 != ret.getValue())
            return false;

        // 注意:在B-Tree中找到的待插入的节点都是叶子节点
        BTreeNode cur = ret.getKey();
        int k = key;
        BTreeNode sub = null; // 主要在分裂节点时起作用
        while (true) {
            insertKey(cur, k, sub);
            // 元素插入后,当前节点可以放的下,不需要分列
            if (cur.size < M)
                break;

            // 新节点插入后,cur节点不满足B-Tree的性质,需要对节点进行分列
            // 具体分裂的方式
            // 1. 找到节点的中间位置
            // 2. 将中间位置右侧的元素以及孩子插入到分列的新节点中
            // 3. 将中间位置的元素以及分列出的新节点向当前分列节点的双亲中继续插入
            int mid = (cur.size >> 1);
            BTreeNode newNode = new BTreeNode(M);

            // 将中间位置右侧的所有元素以及孩子搬移到新节点中
            int i = 0;
            int index = mid + 1; // 中间位置的右侧
            for (; index < cur.size; ++index) {
                // 搬移元素
                newNode.keys[i] = cur.keys[index];
                // 搬移元素对应的孩子
                newNode.subs[i++] = cur.subs[index];
                // 孩子被搬移走了,需要重新更新孩子双亲
                if (cur.subs[index] != null)
                    cur.subs[index].parent = newNode;
            }

            // 注意:孩子要比双亲多搬移一个
            newNode.subs[i] = cur.subs[index];
            if (cur.subs[index] != null)
                cur.subs[index].parent = newNode;

            // 更新newNode以及cur节点中剩余元素的个数
            // cur中的i个元素搬移到了newNode中
            // cur节点的中间位置元素还要继续向其双亲中插入
            newNode.size = i;
            cur.size = cur.size - i - 1;
            k = cur.keys[mid];

            // 说明分列的cur节点刚好是根节点
            if (cur == root) {
                root = new BTreeNode(M);
                root.keys[0] = k;
                root.subs[0] = cur;
                root.subs[1] = newNode;
                root.size = 1;
                cur.parent = root;
                newNode.parent = root;
                break;
            } else {
                // 继续向双亲中插入
                sub = newNode;
                cur = cur.parent;
            }
        }
        return true;
    }


    // 示例Pair类的简单实现
    private static class Pair<A, B> {
        private final A key;
        private final B value;

        public Pair(A key, B value) {
            this.key = key;
            this.value = value;
        }

        public A getKey() {
            return key;
        }

        public B getValue() {
            return value;
        }
    }
    private void inorder(BTreeNode root) {
        if (root == null)
            return;

        for (int i = 0; i < root.size; ++i) {
            inorder(root.subs[i]);
            System.out.println(root.keys[i]);
        }

        inorder(root.subs[root.size]);
    }

}

这两段代码块看起来很相似,都是为了更新孩子节点的父引用。在这段代码的上下文中,第一处更新发生在搬移元素之前,第二处在搬移元素之后。这两处更新是为了确保在搬移孩子节点的同时,也要正确更新孩子节点的父引用。

第一处更新:

// 孩子被搬移走了,需要重新更新孩子双亲
if (cur.subs[index] != null)
    cur.subs[index].parent = newNode;

 这里检查当前节点 cur 中的孩子节点 cur.subs[index] 是否存在,如果存在,则将其父引用更新为 newNode。这发生在元素和对应孩子节点搬移到新节点之前。

具体来说,在当前节点 cur 中的孩子节点 cur.subs[index] 即将被搬移到新节点 newNode 中。更新这个孩子节点的父引用,将其指向新的父节点 newNode。这样确保了在搬移之前,已经更新了孩子节点的父引用,避免了在搬移元素后孩子节点的引用不正确。

第二处更新:

// 注意:孩子要比双亲多搬移一个
newNode.subs[i] = cur.subs[index];
if (cur.subs[index] != null)
    cur.subs[index].parent = newNode;

这是搬移元素后的处理。cur.subs[index] 已经被搬移到新节点 newNode 中,因此将 newNode 中对应位置的孩子节点设置为 cur.subs[index]。同时,确保更新搬移走的孩子节点的父引用,将其指向新的父节点 newNode。这样确保了在搬移元素后,孩子节点的引用仍然是正确的。

总的来说,这两处更新是为了维护节点和孩子节点之间正确的父子关系。在 B 树的插入操作中,由于搬移元素,我们需要保证更新所有涉及到的孩子节点的父引用,以保持树的结构正确性。

里面的插入(包含分裂)的代码比较复杂,也可以写成这样:

// 插入key
public boolean insert(int key) {
    if (null == root) {
        root = new BTreeNode(M);
        root.keys[0] = key;
        root.size = 1;
        return true;
    }

    // 查找当前元素的插入位置
    // 如果返回的键值对的value不等于-1,说明该元素已经存在,则不插入
    Pair<BTreeNode, Integer> ret = find(key);
    if (-1 != ret.getValue())
        return false;

    // 注意:在B-Tree中找到的待插入的节点都是叶子节点
    BTreeNode cur = ret.getKey();
    int index = cur.size - 1;

    // 移动元素,为插入新元素腾出位置
    while (index >= 0 && cur.keys[index] >= key) {
        cur.keys[index + 1] = cur.keys[index];
        index--;
    }

    // 插入新元素
    cur.keys[index + 1] = key;
    cur.size++;

    // 判断是否需要分裂
    if (cur.size >= M) {
        split(cur);
    }

    return true;
}

// 分裂节点
public void split(BTreeNode cur) {
    int mid = cur.size / 2;

    // 创建新节点
    BTreeNode newNode = new BTreeNode(M);

    // 将中间位置右侧的元素以及孩子搬移到新节点中
    for (int i = mid + 1, j = 0; i < cur.size; i++, j++) {
        newNode.keys[j] = cur.keys[i];
        newNode.subs[j] = cur.subs[i];
        if (cur.subs[i] != null) {
            cur.subs[i].parent = newNode;
        }
    }

    newNode.subs[cur.size - mid - 1] = cur.subs[cur.size];
    if (cur.subs[cur.size] != null) {
        cur.subs[cur.size].parent = newNode;
    }

    // 更新当前节点和新节点的元素个数
    cur.size = mid;
    newNode.size = cur.size - mid;

    // 提升中间位置的元素到父节点
    insertKey(cur.parent, cur.keys[mid], newNode);
}

B-树的简单验证

对B树进行中序遍历,如果能得到一个有序的序列,说明插入正确。

private void inorder(BTreeNode root) {
    if (root == null)
        return;

    for (int i = 0; i < root.size; ++i) {  
        inorder(root.subs[i]);
        System.out.println(root.keys[i]);
    }

    inorder(root.subs[root.size]);  
}


  • 基本情况: 如果当前节点为空(root == null),直接返回。这是递归的基本情况,遍历到叶子节点的子树时停止递归。
  • 递归左子树: 对于当前节点 root,通过 for 循环对其左子树进行递归中序遍历。对于B-树而言,root.subs[i] 表示当前节点的第 i 个子树。
  • 打印当前节点的关键字: 在 for 循环中,通过 System.out.println(root.keys[i]) 打印当前节点的关键字。这保证了在中序遍历中,关键字是按照从小到大的顺序输出的。
  • 递归右子树: 最后,通过 inorder(root.subs[root.size]) 对右子树进行递归中序遍历。root.subs[root.size] 表示当前节点的最右边的子树。

综合起来,这段代码确保了在B-树中进行中序遍历,即按照从小到大的顺序输出所有关键字。这是一种验证B-树插入操作是否正确的方法,因为在B-树中,中序遍历的结果应该是有序的。如果插入操作破坏了B-树的性质,那么中序遍历的结果就会不是有序的。

B-树的删除

当执行B-树的删除操作时,具体步骤可以更详细地分解如下:

Step 1: 搜索 在B-树中搜索给定关键字值的元素。如果搜索不成功,则删除运算结束。如果搜索成功,得到包含该元素的叶子节点。

Step 2: 判断删除类型 判断删除类型,根据删除类型选择相应的删除方法:

  • 如果被删除的元素在叶子结点上,转至Step 3执行从叶子结点中删除该元素的操作。
  • 如果被删除的元素不在叶子结点上,用该元素右侧子树上的最小元素取代它,从而将问题转化为情形1。然后,转至Step 3执行从叶子结点中删除该元素的操作。

Step 3: 从叶子结点中删除元素 在叶子结点中执行删除操作:

  • 从叶子结点中直接删除该元素,更新结点的大小(size)。
  • 如果删除元素后结点的大小没有下溢,删除运算结束。
  • 如果删除元素后结点的大小小于 M / 2(下溢),则考虑采用"借"或"并"的方法进行处理。

处理下溢的方法:

  • 借的方法处理下溢:

    • 尝试从左兄弟节点借元素。如果左兄弟节点有富余元素,则借用元素并更新相应的结点信息。
    • 如果左兄弟节点没有富余元素,则尝试从右兄弟节点借元素。如果右兄弟节点有富余元素,则借用元素并更新相应的结点信息。
    • 如果兄弟节点成功借元素,则删除运算结束。
  • 并的方法处理下溢:

    • 合并当前结点和左兄弟节点或右兄弟节点。选择合并的节点取决于借元素的尝试和成功与否。
    • 将父节点中的关键字下移,右兄弟节点中的关键字和子节点合并到左兄弟节点。
    • 删除父节点中的关键字和右兄弟节点的引用。
    • 如果父节点为空,说明根节点合并,更新根节点。
    • 更新左兄弟节点和子节点的父引用。
    • 递归检查双亲结点的下溢问题,执行上述过程。

递归检查双亲结点的下溢问题:

  • 如果当前结点的下溢导致双亲结点也发生下溢,则递归进行借和并的处理。
  • 如果没有下溢出,则删除运算结束。

这些步骤确保了在删除元素的过程中,B-树的结构得到正确地调整以满足B-树的性质。这包括借元素、合并节点、更新父引用等操作,以保持B-树的平衡和有序性。

  public boolean delete(int key) {
        if (root == null) {
            // 树为空,无法删除
            return false;
        }

        Pair<BTreeNode, Integer> ret = find(key);
        if (ret.getValue() == -1) {
            // 找不到要删除的元素
            return false;
        }

        BTreeNode cur = ret.getKey();
        int index = ret.getValue();

        // 删除元素
        deleteKey(cur, index);

        // 检查节点是否小于M/2,如果小于,需要进行调整
        while (cur != null && cur.size < M / 2) {
            // 先尝试从兄弟节点借元素
            if (!borrowFromSibling(cur)) {
                // 如果兄弟节点无法借元素,进行合并
                mergeWithSibling(cur);
            }
            cur = cur.parent; // 向上调整
        }

        // 更新根节点
        while (root.parent != null) {
            root = root.parent;
        }

        return true;
    }

    private void deleteKey(BTreeNode cur, int index) {
        // 简单的删除操作,将元素和子节点往前移动
        for (int i = index; i < cur.size - 1; i++) {
            cur.keys[i] = cur.keys[i + 1];
            cur.subs[i + 1] = cur.subs[i + 2];
        }
        cur.size--;

        // 如果是叶子节点,直接删除即可
        if (cur.subs[0] == null) {
            return;
        }

        // 如果有子节点,需要更新子节点的父引用
        for (int i = 0; i <= cur.size; i++) {
            if (cur.subs[i] != null) {
                cur.subs[i].parent = cur;
            }
        }
    }

    private boolean borrowFromSibling(BTreeNode cur) {
        // 在这里声明 curIndex
        int curIndex = -1;
        // 从左兄弟节点借元素
        if (cur.parent != null) {
            curIndex = indexOfNode(cur.parent, cur);

            if (curIndex > 0 && cur.parent.subs[curIndex - 1].size > M / 2) {
                BTreeNode leftSibling = cur.parent.subs[curIndex - 1];
                borrowElementFromSibling(leftSibling, cur, cur.parent, curIndex - 1);
                return true;
            }
        }

        // 从右兄弟节点借元素
        if (cur.parent != null && cur.parent.size > curIndex + 1 && cur.parent.subs[curIndex + 1].size > M / 2) {
            BTreeNode rightSibling = cur.parent.subs[curIndex + 1];
            borrowElementFromSibling(cur, rightSibling, cur.parent, curIndex);
            return true;
        }

        return false;
    }

    private void borrowElementFromSibling(BTreeNode from, BTreeNode to, BTreeNode parent, int parentIndex) {
        // 从兄弟节点借一个元素和一个子节点
        to.keys[to.size] = parent.keys[parentIndex];
        to.size++;

        parent.keys[parentIndex] = from.keys[from.size - 1];
        from.size--;

        if (to.subs[0] != null) {
            to.subs[to.size] = from.subs[from.size + 1];
            to.subs[to.size].parent = to;
        }
    }

    private void mergeWithSibling(BTreeNode cur) {
        int curIndex = indexOfNode(cur.parent, cur);

        if (cur.parent == null) {
            return; // 添加对 cur.parent 为 null 的检查
        }

        // 合并左兄弟节点
        if (curIndex > 0) {
            BTreeNode leftSibling = cur.parent.subs[curIndex - 1];
            mergeNodes(leftSibling, cur.parent, curIndex - 1);
        }
        // 合并右兄弟节点
        else if (cur.parent.size > curIndex + 1) {
            BTreeNode rightSibling = cur.parent.subs[curIndex + 1];
            mergeNodes(cur, cur.parent, curIndex);
        }
    }


    private void mergeNodes(BTreeNode left, BTreeNode parent, int parentIndex) {
        // 将父节点的关键字下移
        left.keys[left.size] = parent.keys[parentIndex];
        left.size++;

        // 将右兄弟节点的关键字和子节点合并到左兄弟节点
        for (int i = 0; i < parent.subs[parentIndex + 1].size; i++) {
            left.keys[left.size] = parent.subs[parentIndex + 1].keys[i];
            left.subs[left.size] = parent.subs[parentIndex + 1].subs[i];
            left.size++;
        }
        left.subs[left.size] = parent.subs[parentIndex + 1].subs[parent.subs[parentIndex + 1].size];

        // 删除父节点中的关键字和右兄弟节点的引用
        for (int i = parentIndex; i < parent.size - 1; i++) {
            parent.keys[i] = parent.keys[i + 1];
            parent.subs[i + 1] = parent.subs[i + 2];
        }
        parent.size--;

        // 如果父节点为空,说明根节点合并,更新根节点
        if (parent.size == 0) {
            root = left;
            left.parent = null;
        }

        // 更新左兄弟节点和子节点的父引用
        for (int i = 0; i <= left.size; i++) {
            if (left.subs[i] != null) {
                left.subs[i].parent = left;
            }
        }
    }

    private int indexOfNode(BTreeNode parent, BTreeNode cur) {
        if (parent == null) {
            return -1;
        }

        for (int i = 0; i <= parent.size; i++) {
            if (parent.subs[i] == cur) {
                return i;
            }
        }
        return -1;
    }

总的测试一下:

 public BTreeNode getRoot() {
        return root;
    }
    public static void main(String[] args) {
        // 创建B-树,假设阶数为3
        BTree bTree = new BTree(3);

        // 插入一些元素
        int[] keysToInsert = {10, 20, 5, 6, 12, 30, 7, 17};
        for (int key : keysToInsert) {
            boolean inserted = bTree.insert(key);
            System.out.println("Inserting key " + key + ": " + (inserted ? "Success" : "Already exists"));
        }

        // 遍历B-树
        System.out.println("In-order traversal of B-Tree:");
        bTree.inorder(bTree.getRoot());
        System.out.println();

        // 删除一些元素
        int[] keysToDelete = {20, 6, 30};
        for (int key : keysToDelete) {
            boolean deleted = bTree.delete(key);
            System.out.println("Deleting key " + key + ": " + (deleted ? "Success" : "Not found"));
            System.out.println("In-order traversal after deletion:");
            bTree.inorder(bTree.getRoot());
            System.out.println();
        }
    }

如果还是不太能理解上述逻辑的画,可以去看看这篇博客,删除部分画图很详细:
滑动验证页面 

B-树的性能分析 

对于一棵节点为 N 度为 M 的 B-树,查找和插入的性能可以通过比较次数来衡量,通常在 log_(M/2) N 到 log_(M-1) N 次之间。这个范围的证明可以从 B-树的结构出发,考虑到每个节点的子节点数量在 M/2 到 M-1 之间。这确保了树的高度在 log_(M-1) N 和 log_(M/2) N 之间。一旦定位到特定节点,使用二分查找的方式可以迅速定位到目标元素。

B-树因其平衡性和高效的检索特性而被认为是高效的数据结构。对于给定的节点数量 N = 62 * 10^9 和度数 M = 1024 的情况,log_(M/2) N 的值不应超过 4,这意味着在 620 亿个元素中,通过最多 4 次比较就可以定位到目标节点。接着,通过采用二分查找,可以迅速准确定位到目标元素,显著降低了读取磁盘的次数。

这种高效性是由于 B-树的平衡性和对磁盘访问的优化。每个节点能够容纳多个关键字和对应的子节点,从而减少了对磁盘的频繁访问。这在处理大规模数据时尤为关键,因为磁盘访问通常是相对较慢的操作。因此,B-树在数据库系统、文件系统和索引结构中的应用非常广泛,为快速而高效的数据检索提供了强大支持。

当考虑B-树的删除操作性能分析时,我们可以关注以下几个方面:

  1. 删除操作的平均复杂度:

    • 删除操作的平均复杂度仍然保持在 log_(M/2) N 到 log_(M-1) N 之间。这是因为删除操作与查找操作相似,都涉及到在B-树中定位元素的过程。
    • 删除元素时可能需要进行借或并的操作,但这些操作是基于树的平衡性进行的,通常不会导致显著的性能下降。
  2. 高效的删除过程:

    • 在B-树中,由于每个节点有多个关键字和子节点,相比于二叉搜索树,调整的开销相对较小。
    • 在删除元素后,可能需要进行一系列的调整操作,如借、并等。这些调整操作涉及到对节点的合并、拆分和关键字的移动,但它们的次数通常是受控制的。由于B-树的平衡性,这些调整通常是局部的,不会导致整个树的重组。这有助于保持树的高度相对较小,进而提高删除操作的效率。
    • 删除元素时可能触发的借和并的操作是基于节点的子节点数量,而不是基于节点关键字的数量,这有助于平衡调整的精细度。
  3. 减少磁盘访问次数:

    • B-树的平衡性保证了树的高度相对较小,从而减少了对磁盘的频繁访问。即使在进行删除调整时,树的整体结构保持相对平衡,减少了整个树的扫描和调整的需求。这在处理大规模数据时尤为重要,因为磁盘访问通常是较为昂贵的操作。
    • 删除操作在定位到目标元素后,采用二分查找的方式迅速定位到需要删除的元素,最终通过较少的比较次数实现元素的删除。
  4. 适用于大规模数据:

    • 对于大规模数据集,B-树的高效删除操作是非常关键的。即使在数十亿甚至数百亿的元素中,删除操作的性能也能够保持在可接受的范围内,不会导致性能的急剧下降。
    • B-树的设计使其特别适用于数据库系统、文件系统等需要高效处理大规模数据的应用场景。

总体而言,B-树的删除操作在维持平衡性和高效检索的同时,提供了可控的性能。对于给定节点数量 N = 62 * 10^9 和度数 M = 1024 的情况,删除操作的性能在合理范围内,并且在处理大规模数据时依然表现出色。

B+树

B+树是B-树的一种变体,它在基本定义上与B-树相似,但有一些关键的区别。

  1. 结构与B-树相似: B+树也是一种多路搜索树,与B-树在结构上有很多相似之处。它具有根节点、内部节点和叶子节点。

  2. 非叶子节点的子树指针: 与B-树不同的是,B+树的非叶子节点的子树指针和关键字个数相同。对于非叶子节点,子树指针p[i]指向的子树包含关键字值属于【k[i],k[i+1])的范围,即该子树为当前关键字的右子树。这样的设计使得B+树更加紧凑,提高了内部节点的利用率。

  3. 链指针: B+树的叶子节点增加了一个链指针。这意味着所有的叶子节点之间建立了一个有序链表,可以通过链指针按顺序遍历所有叶子节点。这对于范围查询和范围遍历操作非常有用。

  4. 关键字仅出现在叶子节点: 与B-树不同,B+树的所有关键字都出现在叶子节点上,而非叶子节点仅包含用于导航的关键字。这种设计简化了搜索操作,因为只有在叶子节点上进行关键字比较。这也降低了非叶子节点的存储负担,使得B+树更为适用于磁盘存储等场景。

B+树相较于B-树在某些场景下更适用,特别是在数据库索引的设计中。由于叶子节点构成有序链表,范围查询和范围遍历变得更加高效。此外,由于关键字只出现在叶子节点,非叶子节点的大小可以减小,从而减少树的高度,提高了磁盘I/O的效率。

 B+树的插入操作

  1. 搜索插入位置:

    从根节点开始,递归地在B+树中搜索要插入的位置。找到叶子节点,该节点即为插入位置。
  2. 插入元素:

    在找到的叶子节点中插入新的关键字。如果插入后该节点的关键字个数没有超过度数限制,插入操作结束。
  3. 处理上溢:

    • 如果插入后叶子节点的关键字个数超过度数(M-1),发生上溢。此时,需要进行分裂操作。
    • 将叶子节点中的关键字一分为二,左半部分保留在原节点,右半部分移到一个新的节点中。
    • 将原节点的右兄弟指针指向新节点,新节点的右兄弟指针指向原来节点的右兄弟。
  4. 向上更新父节点:

    将新节点的第一个关键字插入到原节点的父节点中。如果插入后父节点的关键字个数超过度数,递归向上处理上溢。
  5. 更新根节点:

    • 如果更新导致根节点的上溢,进行根节点的分裂操作。
    • 创建一个新的根节点,将原根节点的关键字一分为二,左半部分保留在原根节点,右半部分移到新的根节点中。
  6. 调整链表结构:

    • 如果插入导致新节点的关键字成为链表中的最小值,需要调整链表结构。
    • 将新节点的左兄弟指针指向原来节点的左兄弟,将新节点的右兄弟指针指向原来节点。

B+树的性能和特性进行:

  1. 搜索性能: B+树的搜索操作与B-树基本相同,但有一个关键区别,即B+树只有在达到叶子节点时才能命中。这是因为所有关键字都出现在叶子节点的链表中,而非叶子节点只是用于导航的索引。相比之下,B-树在非叶子节点中也可以命中,这使得B+树更适合范围查询和范围遍历,因为这些操作只需遍历叶子节点的链表。

  2. 关键字出现在叶子节点: 所有关键字都出现在叶子节点的链表中,形成了一种稠密索引。这种设计使得B+树非常适合范围查询,因为范围内的关键字都可以顺序地沿着链表访问。同时,这也有助于提高磁盘I/O效率,因为数据存储在叶子节点上,而非叶子节点只存储导航信息。

  3. 非叶子节点作为索引: 非叶子节点在B+树中相当于是叶子节点的索引,形成了一种稀疏索引。这使得非叶子节点相对较小,减少了内存占用和磁盘I/O成本。这种设计也有助于提高搜索性能,因为更多的索引可以被加载到内存中,加速导航操作。

  4. 适用于文件索引系统: B+树的特性使其特别适用于文件索引系统,尤其是在数据库中的索引设计。由于关键字的有序排列和链表的存在,B+树支持高效的范围查询和顺序遍历,这对于数据库系统中的排序和连接等操作非常重要。

总体而言,B+树通过其特定的设计使得在大规模数据存储和查询场景中表现出色,特别是在数据库领域,为高效的数据管理提供了有力支持。

B+树优缺点

优点:

  1. 有序链表提高范围查询效率:

    • B+树的叶子节点通过有序链表相连接,使得范围查询变得非常高效。对于范围查询或范围遍历操作,可以通过遍历链表实现,而不需要像B-树那样进行额外的中序遍历。
  2. 减少磁盘I/O次数:

    • B+树的所有关键字都出现在叶子节点上,而非叶子节点只包含导航关键字。这降低了搜索过程中磁盘I/O的次数,因为只有在叶子节点上进行关键字比较。
  3. 适合范围查询和范围遍历:

    • 由于有序链表和关键字只出现在叶子节点上的特性,B+树在范围查询和范围遍历方面表现出色。这对于数据库系统等需要频繁进行这类操作的应用场景非常有利。
  4. 简化内部节点结构:

    • 非叶子节点的子树指针和关键字个数相同,使得B+树的内部节点结构更为简化和紧凑。这有助于提高内存和缓存的利用率。
  5. 适用于磁盘存储:

    • 由于减少了磁盘I/O次数,B+树更适用于磁盘存储,特别是在大规模数据的场景下,能够显著提升查询性能。

缺点:

  1. 不适合等值查询:

    • 对于等值查询(查找某个具体值),B+树相对于B-树来说可能会略显不足。因为所有的关键字都出现在叶子节点上,需要在叶子节点链表上进行顺序查找。
  2. 更新代价较高:

    • 插入和删除操作可能涉及到链表的调整,这会增加一些额外的开销。虽然B+树在大部分插入和删除场景中仍然是高效的,但相比于B-树,它可能需要更多的操作。
  3. 非叶子节点关键字冗余:

    • 非叶子节点的关键字实际上是为了导航,可能包含冗余信息。这样的设计增加了内部节点的存储开销,尽管提高了内部节点的紧凑性。
  4. 不适用于内存存储:

    • B+树的设计是为了优化磁盘I/O,因此在内存中的应用可能没有B-树那么高效。在内存存储场景下,一些其他结构如红黑树可能更为合适。

B*树

B*树是B+树的一种变体,它在B+树的基础上引入了一些额外的指针,这些指针连接了相邻的非根和非叶子节点,即在B+树的非根和非叶子节点再增加指向兄弟节点的指针。。这种设计旨在优化范围查询时的性能。以下是B树的主要特点:

  1. 结构与B+树相似: B*树的基本结构与B+树相似,仍然是一种多路搜索树。它包括根节点、内部节点和叶子节点。

  2. 指向兄弟节点的指针: 与B+树不同的是,在B*树的非根和非叶子节点中,除了包含子树指针和关键字之外,还增加了指向相邻兄弟节点的指针。这种指针使得相邻节点之间的导航更加高效,特别是在范围查询时。

  3. 范围查询优化: B*树的设计目标之一是优化范围查询的性能。通过兄弟节点的指针,可以更快速地跳跃到相邻节点,从而减少了在链表中的遍历时间。这对于一次性获取一个范围内的结果集非常有用。

  4. 其他B+树特性保持不变: B*树保持了B+树的其他特性,包括关键字只出现在叶子节点、叶子节点形成有序链表等。

  5. 适用于高度动态的数据库系统: B树的设计使其特别适用于高度动态的数据库系统,其中频繁进行范围查询。通过引入兄弟节点的指针,B树在处理这类查询时能够提供更好的性能。

总体而言,B*树是B+树的改进版本,通过引入指向兄弟节点的指针来优化范围查询的性能。这使得B树在某些特定的应用场景中表现得更为出色。

B树是B+树的一种改进,它在结构上与B+树相似,但在一些关键方面有所不同。下面是对B树与B+树的比较和特点的详细扩写:

  1. 非叶子节点关键字个数:

    • 在B+树中,非叶子节点的关键字个数与子树指针个数相同,即每个非叶子节点都包含M个关键字和M个子树指针。
    • 而在B*树中,非叶子节点的关键字个数至少为(2/3)M。这一改变的目的是提高树的空间利用率,将结点的最低利用率从1/2提高到2/3,减少非叶子节点的数量,从而降低B树的高度。
  2. 分裂策略:

    • 在B+树中,当一个节点满时,进行分裂操作,将1/2的数据复制到新节点,并在父节点中增加新节点的指针。分裂只影响原节点和父节点,不会影响兄弟节点,因此不需要指向兄弟的指针。
    • 在B*树中,分裂操作的策略更加灵活。如果一个节点满了,首先尝试将一部分数据移到下一个兄弟节点中,然后在原节点插入关键字,最后修改父节点中兄弟节点的关键字范围。只有当兄弟节点也满时,才会在原节点与兄弟节点之间增加一个新节点,并将各复制1/3的数据到新节点。
  3. 空间利用率:

    • 由于B树采用更灵活的分裂策略,分配新节点的概率比B+树要低。这导致B树的空间利用率更高,能够更好地适应动态数据的插入和删除操作,减少树的高度。

总体而言,B树在空间利用率上进行了优化,通过更加灵活的分裂策略减少了新节点的分配,从而提高了B树的性能。然而,实现和维护B*树相对复杂,需要更多的代码逻辑来处理分裂操作的多种情况。

B-树的应用 

索引 

索引在数据库中扮演着关键的角色,通过提供一种高效的数据结构,它使得数据的检索和管理变得更加便捷和快速:

  1. 索引的基本概念: 索引的概念类似于书籍的目录或互联网页面的导航,其主要目的是为了让用户能够快速找到所需的信息。在数据库中,索引通过提供一种数据结构,帮助数据库系统高效获取数据,加速查询操作。

  2. B-树的常见应用: B-树是最常见的用于实现数据库索引的数据结构。其平衡的特性使得在大规模数据集上进行高效的查找、插入和删除操作成为可能。数据库引擎通常使用B-树来维护索引,以满足多种查询需求。

  3. MySQL中的索引定义: MySQL官方对索引的定义强调了其在提高数据检索效率方面的重要性。索引不仅仅是一种辅助数据结构,更是数据库系统的关键组成部分,为高级查找算法提供了基础。

  4. 数据库的数据管理与索引: 随着数据量的增大,为了方便管理数据并提高查询效率,通常会选择将数据存储在数据库中。数据库系统不仅仅用于管理数据,还负责维护特定查找算法所需的数据结构,这些结构即索引。

  5. 高级查找算法的支持: 索引的存在使得数据库能够支持高级的查找算法,如快速的搜索、排序和过滤。通过在数据结构上实现这些算法,数据库能够在海量数据中以较低的时间复杂度执行复杂的查询。

  6. 合理设计索引的重要性: 在设计数据库时,合理选择和创建索引是至关重要的。过多或不必要的索引可能导致性能下降,因为索引的维护也会产生开销。需要根据具体的查询需求和数据模型来进行权衡。

  7. 索引的实时更新: 索引需要与底层数据同步更新,对数据的插入、更新和删除操作都会触发相应的索引维护。在高写入负载的情况下,需要谨慎设计索引,以避免对性能的负面影响。

综合而言,索引在数据库中扮演着至关重要的角色,不仅提高了数据检索的效率,还支持了高级的查询算法,使得数据库系统能够更加灵活和高效地管理和查询大规模数据。在实际应用中,合理设计和使用索引是数据库性能优化的一个关键方面。

B树、B-树、B+树、B*树 - 独孤求败 - 博客园 (cnblogs.com)

MySQL索引简介

MySQL作为一款广受欢迎的开源关系型数据库,其强大的特性和灵活性使得它在众多应用场景中得到了广泛应用。它不仅是免费的,可靠性高,速度也比较快,而且拥有灵活的插件式存储引擎。如下:

  1. 免费、可靠性高、速度快: MySQL是一款开源软件,用户可以免费获取、使用、修改和分发它。其可靠性高,被广泛用于生产环境,能够处理大量事务和数据。此外,MySQL的查询性能通常较快,特别是在合理使用索引的情况下。

  2. 插件式存储引擎: MySQL支持插件式存储引擎,允许用户选择不同的存储引擎以适应不同的应用需求。其中一些常见的存储引擎包括InnoDB、MyISAM、MEMORY等。每种存储引擎都有其独特的优势和适用场景。

  3. 事务支持: MySQL的事务支持使其适用于需要事务处理的应用,例如银行系统、电商平台等。InnoDB存储引擎是一个支持事务和ACID属性(原子性、一致性、隔离性、持久性)的存储引擎。

  4. 分布式数据库: MySQL也提供了一些分布式数据库的解决方案,如MySQL Cluster,允许用户构建高可用性和可扩展性的数据库集群。

  5. 索引与存储引擎: 索引是MySQL中的关键概念,用于提高查询性能。值得注意的是,索引属于存储引擎级别,而不同的存储引擎对索引的实现方式可能不同。例如,InnoDB使用B+树索引,而MyISAM使用B树索引。

  6. 基于表的索引: 索引是基于表的,而不是基于整个数据库的。每个表可以有自己的索引,这种设计允许用户为每个表根据具体的查询需求选择和创建合适的索引,以提高查询性能。

总体而言,MySQL的广泛应用和持续发展源于其免费、开源的特性,以及强大的性能和灵活的配置选项。了解MySQL的不同特点有助于开发者更好地利用其功能和性能,从而满足各种应用场景的需求。

 MyISAM

MyISAM是MySQL5.5.8版本之前默认的存储引擎,虽然在后续版本中被InnoDB取代为默认引擎,但它仍然被广泛应用,特别是在一些读操作较频繁、不需要事务支持的场景:

  1. 不支持事务: MyISAM存储引擎是非事务性的,这意味着它不支持事务的特性,如事务的原子性、一致性、隔离性和持久性。在需要强调事务处理的应用中,InnoDB等支持事务的存储引擎更为合适。

  2. 支持全文检索: MyISAM是MySQL中唯一一个在表级别上支持全文检索的存储引擎。这使得在文本字段上进行高效的全文搜索成为可能,对于包含大量文本信息的表格尤为有用。

  3. B+Tree作为索引结构: MyISAM使用B+Tree作为其索引结构,包括主索引和辅助索引。这种结构在范围查询和排序操作上表现较好,但在高并发写入场景下可能存在性能瓶颈。

  4. 索引结构示意图: MyISAM中主索引和辅助索引在结构上没有本质区别,只是主索引要求key是唯一的。索引文件仅保存数据记录的地址,这使得索引检索的算法相对简单。

  5. 非聚集索引: MyISAM的索引方式被称为“非聚集索引”,这意味着索引文件和实际数据文件是分开存储的。索引文件保存了数据记录的地址,而数据文件中保存了实际的数据。这种结构在某些场景下提供了一些灵活性,但也带来了一些性能上的牺牲。

  6. 辅助索引的结构: 如果在Col2上建立一个辅助索引,其结构与主索引相似,同样是一棵B+Tree,其中data域保存了数据记录的地址。

MyISAM适用于一些读密集、不需要事务支持的场景,尤其在全文检索方面表现出色。然而,在需要事务支持或具有高并发写入需求的应用中,可能更推荐选择支持事务的存储引擎,如InnoDB。选择存储引擎应该根据具体应用的特点和需求来进行合理的权衡。

MyISAM使用B+Tree作为索引结构, 叶节点的data域存放的是数据记录的地址,其结构如下:

上图是以以Col1为主键,MyISAM的示意图,可以看出MyISAM的索引文件仅仅保存数据记录的地址。在MyISAM 中,主索引和辅助索引(Secondary key)在结构上没有任何区别,只是主索引要求key是唯一的,而辅助索引的 key可以重复。如果想在Col2上建立一个辅助索引,则此索引的结构如下图所示:

同样也是一棵B+Tree,data域保存数据记录的地址。因此,MyISAM中索引检索的算法为首先按照B+Tree搜索算法搜索索引,如果指定的Key存在,则取出其data域的值,然后以data域的值为地址,读取相应数据记录。 MyISAM的索引方式也叫做“非聚集索引”或者“非聚簇索引”。 

在 MyISAM 中,表的数据文件和索引文件是分离的,数据文件保存了实际的数据记录,而索引文件则保存了索引信息。B+Tree 是一种常用于实现索引的树状数据结构,它能够高效地支持按照顺序查找、范围查找和等值查找。

当执行一个按索引检索的查询时,MyISAM 使用 B+Tree 索引进行查找。具体步骤如下:

  1. 使用 B+Tree 搜索算法: 首先,根据查询条件使用 B+Tree 索引进行搜索。这个搜索过程类似于在树状结构中查找目标关键字。

  2. 取出数据地址: 如果找到了目标关键字,那么索引中保存了对应数据的地址。这个地址存储在 B+Tree 的叶子节点中或者对应的数据页中。

  3. 读取数据记录: 利用数据地址,从数据文件中读取相应的数据记录。

需要注意的是,MyISAM 的非聚簇索引意味着索引和实际数据是分离存储的。因此,当通过索引检索数据时,实际的数据记录可能不是紧邻的,而是分散存储在数据文件中。

尽管 MyISAM 使用了 B+Tree 进行索引,但也要注意它的一些特性,比如表级锁定、缓冲池管理等,这些特性在某些场景下可能影响性能和并发性。因此,在选择存储引擎时,需要根据具体的需求和应用场景来权衡各种因素。

在现代 MySQL 数据库中,InnoDB 存储引擎更为常见,它支持事务和行级锁等特性。

InnoDB

InnoDB存储引擎在支持事务的同时,其设计目标主要面向在线事务处理的应用。从MySQL数据库5.5.8版本开始,InnoDB存储引擎成为默认的存储引擎。InnoDB不仅支持B+树索引、全文索引、哈希索引等多种索引类型,而且在使用B+树作为索引结构时,其具体实现方式与MyISAM有着显著的不同。

首先,InnoDB的数据文件本身就是索引文件。这与MyISAM的设计不同,因为MyISAM的索引文件和数据文件是分离的,而索引文件仅保存数据记录的地址。相反,InnoDB的索引结构是将表数据文件本身按照B+Tree组织成一个索引结构,其中叶节点的data域保存了完整的数据记录。这样的索引被称为聚集索引,其key即为数据表的主键。因此,InnoDB表数据文件本身就充当了主索引。

上图是InnoDB主索引(同时也是数据文件)的示意图,可以看到叶节点包含了完整的数据记录,是聚集索引。

在InnoDB中,数据文件按照主键聚集,因此该存储引擎要求表必须有主键,而MyISAM则允许表没有主键。如果没有显式指定主键,MySQL系统会自动选择一个能够唯一标识数据记录的列作为主键。如果不存在这种列,MySQL会为InnoDB表生成一个隐含字段作为主键,该字段长度为6个字节,类型为长整形。

其次,InnoDB的辅助索引中的data域存储相应记录主键的值,而不是地址。所有辅助索引都引用主键作为data域。这种实现方式使得按主键进行搜索非常高效,因为主索引中的叶节点已经包含了完整的数据记录。

然而,辅助索引的搜索需要检索两次索引:首先检索辅助索引以获取主键,然后再使用主键到主索引中检索以获取完整的记录。

所以聚集索引这种实现方式使得按主键的搜索十分高效,但是辅助索引搜索需要检索两遍索引,反而不会太高效。这种双重检索的方式在一些情况下可能会导致性能的损失,特别是在进行范围查询或需要访问大量数据行时。因为在第一次辅助索引的检索中,可能需要加载较多的主键值,而这些主键值还需要用于第二次主索引的检索。

这也是为什么我们在选择创建辅助索引时需要谨慎考虑查询的模式。辅助索引更适合那些针对较小结果集的查询,而对于大规模数据的范围查询,可能需要更多地考虑如何优化查询以减少不必要的双重检索。

总体而言,InnoDB存储引擎通过其聚集索引和数据文件的融合,以及对辅助索引的巧妙处理,实现了高效的事务处理和检索性能。这些设计特点使得InnoDB成为处理在线事务的理想选择。

问题:既然在表格上建立索引可以提高搜索效率,那是否可以在一个表格上任意建立索引? 

在理论上,可以在一个表格上建立多个索引,但在实际应用中我们需要根据具体的查询需求和性能考虑来决定是否创建索引,以及选择哪些列作为索引。虽然索引能够提高搜索效率,但过多或不合理的索引可能导致一些负面影响,如增加插入、更新和删除操作的时间,占用额外的存储空间,以及增加数据库维护的成本。

在选择建立索引时,以下几点需要考虑:

  1. 查询需求: 分析经常执行的查询语句,确定哪些列是常用于搜索、排序或连接的。为这些列建立索引可以显著提高查询性能。

  2. 表的大小: 对于小型表,可能不需要太多的索引。但对于大型表,建立适当的索引可以提高查询速度。

  3. 写入频率: 如果表的写入频率很高,过多的索引可能导致写入性能下降。在这种情况下,需要权衡读取和写入的需求。

  4. 复合索引: 可以考虑创建复合索引,即包含多个列的索引。复合索引可以提高某些查询的性能,但也需要注意不要过度使用。

  5. 唯一性约束: 对于需要保持唯一性的列,如主键或唯一约束列,系统通常会自动创建唯一索引。

  6. 数据分布: 如果数据分布不均匀,可能需要更谨慎地选择建立索引的列,以确保索引的有效性。

综合考虑这些因素,可以根据实际情况选择在表格上建立适当的索引,以达到优化查询性能的目的。在设计数据库时,我们通常需要进行性能测试和监测,以便根据实际负载和查询模式进行调整。

总之,记住不是所有的数据都需要建立索引,索引的创建与使用需要根据实际需求和数据访问模式来进行权衡。

索引的优缺点:

索引的优点包括:

  1. 加速检索: 索引可以显著提高数据检索的速度,特别是在大型数据集上。通过使用索引,数据库系统可以直接定位到包含所需数据的位置,而不必逐行扫描整个表。

  2. 优化排序和分组操作: 当对表进行排序或分组操作时,索引可以提高这些操作的效率。

  3. 加速连接操作: 在连接操作中,索引可以加速表与表之间的关联。

然而,索引并非没有代价的,它也有一些缺点:

  1. 占用存储空间: 索引需要额外的存储空间,因为它是独立于表数据的数据结构。在大型表上,索引可能占用相当大的空间。

  2. 降低写操作性能: 当对表进行插入、更新或删除等写操作时,索引的维护会增加额外的开销。每次写操作都可能涉及到索引的更新,这可能导致写操作变慢。

  3. 可能引入查询优化器选择错误: 数据库系统的查询优化器会根据索引选择执行计划。但有时候,优化器选择的计划可能不是最优的,甚至可能导致性能下降。

因此,在建立索引时需要谨慎。一般来说,对于经常需要进行查询的字段,特别是用于 WHERE、ORDER BY 和 GROUP BY 子句的字段,建立索引是有益的。对于很少被查询的字段,或者数据量较小的表,可能建立索引的成本超过了它的收益。在设计数据库时,需要根据具体的业务需求和查询模式来权衡是否建立索引。

MySQL 常见面试题

最全MySQL面试60题和答案 - 知乎 (zhihu.com)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值