各种数据结构

二叉树

满二叉树

  • 解释1:除了最后一层无任何子结点外,每一层上的所有结点都有两个子结点的二叉树。

  • 解释2:叶子节点全都在最底层,并且除了叶子节点之外,每个节点都有左右两个子节点

完全二叉树

  • 叶子节点都在最底下两层,最后一层的叶子节点都靠左排列,并且除了最后一层,其他层的节点个数都要达到最⼤

  • 堆其实就是一种完全二叉树,最常见的存储方式就是数组

  • 数组顺序存储的方式比较适合完全二叉树

二叉搜索(查找)树(Binary Search Tree)

二叉查找树介绍

二叉查找树定义

  • 二叉查找树要求在树中的任意一个节点:(左子节点和它的子孙< 节点< 右子节点和它的子孙)

    • 其左子树中的每个节点的值,都要小于这个节点的值,

    • 而右子树节点的值都大于这个节点的值。

  • 二叉查找树最大的特点就是,支持动态数据集合的快速插入、删除、查找操作

  • 二叉查找树是为了实现快速查找而生的。不过,它不仅仅支持快速查找一个数据,还支持快速插入、删除一个数据

二叉查找树的查找操作:递归查找

  • 我们先取根节点,如果它等于我们要查找的数据,那就返回。

  • 如果要查找的数据比根节点的值小,那就在左子树中递归查找;

  • 如果要查找的数据比根节点的值大,那就在右子树中递归查找。

二叉查找树的插入操作:满足二叉查找树条件下

  • 二叉查找树的插入过程有点类似查找操作。新插入的数据一般都是在叶子节点上,所以我们只需要从根节点开始,依次比较要插入的数据和节点的大小关系。

    • 如果要插入的数据比节点的数据大,并且节点的右子树为空,就将新数据直接插到右子节点的位置;

    • 如果不为空,就再递归遍历右子树,查找插入位置。

    • 同理,如果要插入的数据比节点数值小,并且节点的左子树为空,就将新数据插入到左子节点的位置;

    • 如果不为空,就再递归遍历左子树,查找插入位置。

二叉查找树的删除操作:父节点指针改变

  • 二叉查找树的查找、插入操作都比较简单易懂,但是它的删除操作就比较复杂了 。针对要删除节点的子节点个数的不同,我们需要分三种情况来处理。

    • 第一种情况是,如果要删除的节点没有子节点,我们只需要直接将父节点中指向要删除节点的指针置为null。比如图中的删除节点55。

    • 第二种情况是,如果要删除的节点只有一个子节点(只有左子节点或者右子节点),我们只需要更新父节点中指向要删除节点的指针,让它指向要删除节点的子节点就可以了。比如图中的删除节点13。

    • 第三种情况是,如果要删除的节点有两个子节点,这就比较复杂了。

      • 我们需要找到这个节点的右子树中的最小节点(最小节点都往右子树左找,不用往右找),把它替换到要删除的节点上。

      • 然后再删除掉这个最小节点:

        • 因为最小节点肯定没有左子节点(如果有左子结点,那就不是最小节点了),所以,我们可以应用上面两条规则来删除这个最小节点。比如图中的删除节点18。

  • 实际上,关于二叉查找树的删除操作,还有个非常简单、取巧的方法:

    • 就是单纯将要删除的节点标记为“已删除”,但是并不真正从树中将这个节点去掉。

    • 这样原本删除的节点还需要存储在内存中,比较浪费内存空间,但是删除操作就变得简单了很多。

    • 而且,这种处理方法也并没有增加插入、查找操作代码实现的难度。

二叉查找树其他操作

  • 除了插入、删除、查找操作之外,二叉查找树中还可以支持快速地查找:

    • 最大节点

    • 最小节点

    • 前驱节点

    • 后继节点

二叉查找树遍历特性

  • 中序遍历二叉查找树,可以输出有序的数据序列,时间复杂度是O(n),非常高效。

  • 因此,二叉查找树也叫作二叉排序树。

二叉查找树时间复杂度

  • 在二叉查找树中,查找、插入、删除等很多操作的时间复杂度都跟树的高度成正比。

    • 两个极端情况的时间复杂度分别是O(n)和O(logn),分别对应二叉树退化成链表的情况和完全二叉树。

    • 最糟糕情况:根节点的左右子树极度不平衡,已经退化成了链表,所以查找的时间复杂度就变成了O(n)

    • 最理想情况:二叉查找树是一棵完全二叉树(或满二叉树)

  • 为了避免时间复杂度的退化,针对二叉查找树,我们们设计了一种更加复杂的树,平衡二叉查找树,时间复杂度可以做到稳定的O(logn)

    • 不管怎么删除、插入数据,在任何时候,都能保持任意节点左右子树都比较平衡的二叉查找树

    • 平衡二叉查找树的⾼度接近logn,所以插入、删除、查找操作的时间复杂度也比较稳定,是O(logn)

二叉查找树 VS 散列表

  • 二叉查找树最大的特点就是,支持动态数据集合的快速插入、删除、查找操作。

  • 而散列表也是支持这些操作的,并且散列表的这些操作比二叉查找树更高效,时间复杂度是O(1)。

  • 而二叉查找树在比较平衡的情况下,插入、删除、查找操作时间复杂度才是O(logn)

  • 相对散列表,好像并没有什么优势,那我们为什么还要用二叉查找树呢?

    • 原因一:

      • 散列表中的数据是无序存储的,如果要输出有序的数据,需要先进行排序。

      • 而对于二叉查找树来说,我们只需要中序遍历,就可以在O(n)的时间复杂度内,输出有序的数据序列。

    • 原因二:

      • 散列表扩容耗时很多,而且当遇到散列冲突时,性能不稳定

      • 尽管二叉查找树的性能不稳定,但是在编程中,我们最常用的平衡二叉查找树的性能非常稳定,时间复杂度稳定在O(logn)。

    • 原因三:

      • 笼统地来说,尽管散列表的查找等操作的时间复杂度是常量级的,但因为哈希冲突的存在,这个常量不一定比logn小,所以实际的查找速度可能不一定比O(logn)快。加上哈希函数的耗时,也不一定就比平衡二叉查找树的效率高。
    • 原因四:

      • 散列表的构造比二叉查找树要复杂,需要考虑的东西很多。比如散列函数的设计、冲突解决办法、扩容、缩容等。

      • 平衡二叉查找树只需要考虑平衡性这一个问题,而且这个问题的解决方案比较成熟、固定。

    • 原因五:

      • 为了避免过多的散列冲突,散列表装载因子不能太大,特别是基于开放寻址法解决冲突的散列表,不然会浪费一定的存储空间。
  • 综合这一点,平衡二叉查找树在某些方面还是优于散列表的,所以,这两者的存在并不冲突。我们在实际的开发过程中,需要结合具体的需求来选择使用哪一个。

平衡二叉树

  • 平衡二叉树的严格定义是这样的:

    • 二叉树中任意一个节点的左右子树的高度相差不能大于 1。

    • 即任何节点的左右子树高度相差不超过 1

  • 完全二叉树、满二叉树都是平衡二叉树,但是非完全二叉树也有可能是平衡二叉树。

平衡二叉查找树(ALV树)

  • 平衡二叉搜索树:又被称为AVL(Adelson-Velsky and Landis)树,且具有以下性质:

    • 满足平衡二叉树特点:任何节点的左右子树高度相差不超过 1

    • 满足二叉搜索树特点:在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。

      • 每个节点的值都大于左子树节点的值,小于右子树节点的值
  • 平衡二叉搜索树是二叉搜索树和平衡二叉树的结合解决普通二叉查找树在频繁的插入、删除等动态更新的情况下,出现时间复杂度退化的问题

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

  • 最后一棵不是平衡二叉树,因为它的左右两个子树的高度差的绝对值超过了1。

红黑树:不严格的平衡二叉查找树

  • 设计一个新的平衡二叉查找树,只要树的高度不比 log2n 大很多(比如树的高度仍然是对数量级的),尽管它不符合我们前面讲的严格的平衡二叉查找树的定义,但我们仍然可以说,这是一个合格的平衡二叉查找树

  • 平衡二叉查找树其实有很多,比如,Splay Tree(伸展树)、Treap(树堆)等,但是我们提到平衡二叉查找树,听到的基本都是红黑树。它的出镜率甚至要高于“平衡二叉查找树”这几个字,有时候,我们甚至默认平衡二叉查找树就是红黑树

  • 红黑树 简称 R-B Tree,是不严格的平衡二叉查找树,解决二叉查找树因为动态更新导致的性能退化问题

  • 顾名思义,红黑树中的节点,一类被标记为黑色,一类被标记为红色。除此之外,一棵红黑树还需要满足这样几个要求:

    • 根节点是黑色的;

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

      • 主要是为了简化红黑树的代码实现而设置
    • 任何(上下)相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的;

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

  • 红黑树是“近似平衡”的

    • 平衡二叉查找树的初衷,是为了解决二叉查找树因为动态更新导致的性能退化问题。所以,“平衡”的意思可以等价为性能不退化。“近似平衡”就等价为性能不会退化得太严重。

      • 二叉查找树很多操作的性能都跟树的高度成正比,一棵极其平衡的二叉树(满二叉树或完全二叉树)的高度大约是 log2n。

      • 而红黑树的高度比较稳定地趋近 log2n,所以红黑树是近似平衡的,插入、删除、查找操作的时间复杂度都是 O(logn)

    • 实际上红黑树的性能更好

      • AVL 树是一种高度平衡的二叉树,所以查找的效率非常高,但是,有利就有弊,AVL 树为了维持这种高度的平衡,就要付出更多的代价。每次插入、删除都要做调整,就比较复杂、耗时。

      • 所以,对于有频繁的插入、删除操作的数据集合,使用 AVL 树的代价就有点高了。

      • 红黑树只是做到了近似平衡,并不是严格的平衡,所以在维护平衡的成本上,要比 AVL 树要低。

  • 因为红黑树是一种性能非常稳定的二叉查找树,所以,在工程中,但凡是用到动态插入、删除、查找数据的场景,都可以用到它

    • 所以,红黑树的插入、删除、查找各种操作性能都比较稳定。对于工程应用来说,要面对各种异常情况,为了支撑这种工业级的应用,我们更倾向于这种性能稳定的平衡二叉查找树。

B树和B+树:属于平衡多路查找树⭐⭐⭐

B-树是一种多路搜索树(并不一定是二叉的)

  • 一棵m阶B树 是一棵平衡的m路搜索树。它或者是空树,或者是满足下列性质的树:

    1、根结点至少有两个子女;

    2、每个非根节点所包含的关键字个数 j 满足:┌m/2┐ - 1 <= j <= m - 1;

    3、除根结点以外的所有结点(不包括叶子结点)的度数正好是关键字总数加1,故内部子树个数 k 满足:┌m/2┐ <= k <= m ;

    4、所有的叶子结点都位于同一层。

  • 特点:

    是一种多路搜索树(并不是二叉的):

    1、定义任意非叶子结点最多只有M个儿子;且M>2;

    2、根结点的儿子数为[2, M];

    3、除根结点以外的非叶子结点的儿子数为[M/2, M];

    4、每个结点存放至少M/2-1(取上整)和至多M-1个关键字;(至少2个关键字)

    5、非叶子结点的关键字个数=指向儿子的指针个数-1;

    6、非叶子结点的关键字:K[1], K[2], …, K[M-1];且K[i] < K[i+1];

    7、非叶子结点的指针:P[1], P[2], …, P[M];其中P[1]指向关键字小于K[1]的子树,P[M]指向关键字大于K[M-1]的子树,其它P[i]指向关键字属于(K[i-1], K[i])的子树;

    8、所有叶子结点位于同一层;

  • 规则:

    1、排序方式:所有节点关键字是按递增次序排列,并遵循左小右大原则;

    2、子节点数:非叶节点的子节点数>1,且<=M (M>=2),空树除外(注:M阶代表一个树节点最多有多少个查找路径,M=M路,当M=2则是2叉树,M=3则是3叉);

    3、关键字数:枝节点的关键字数量大于等于ceil(m/2)-1个且小于等于M-1个(注:ceil()是个朝正无穷方向取整的函数 如ceil(1.1)结果为2);

    4、所有叶子节点均在同一层、叶子节点除了包含了关键字和关键字记录的指针外也有指向其子节点的指针只不过其指针地址都为null对应下图最后一层节点的空格子;

  • 如:(M=3)

    • B-树的搜索,从根结点开始,对结点内的关键字(有序)序列进行二分查找,如果命中则结束,否则进入查询关键字所属范围的儿子结点;重复,直到所对应的儿子指针为空,或已经是叶子结点;

B+树

  • B+树是应文件系统所需而出的一种B-树的变型树。一棵m阶的B+树和m阶的B-树的差异在于:

    1、有n棵子树的结点中含有n个关键字,每个关键字不保存数据,只用来索引,所有数据都保存在叶子节点。

    2、所有的叶子结点中包含了全部关键字的信息,及指向含这些关键字记录的指针,且叶子结点本身依关键字的大小自小而大顺序链接。

    3、所有的非叶子结点可以看成是索引部分,结点中仅含其子树(根结点)中的最大(或最小)关键字。

  • 通常在B+树上有两个头指针,一个指向根结点,一个指向关键字最小的叶子结点。

  • B+树是B-树的变体,也是一种多路搜索树,其定义基本与B-树同,除了:

    1、非叶子结点的子树指针与关键字个数相同;

    2、非叶子结点的子树指针P[i],指向关键字值属于[K[i], K[i+1])的子树

    (B-树是开区间);

    3、为所有叶子结点增加一个链指针;

    4、所有关键字都在叶子结点出现;

  • 如:(M=3)

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

    1、所有关键字都出现在叶子结点的链表中(稠密索引),且链表中的关键字恰好是有序的;

    2、不可能在非叶子结点命中;

    3、非叶子结点相当于是叶子结点的索引(稀疏索引),叶子结点相当于是存储(关键字)数据的数据层;

    4、更适合文件索引系统;

  • B+树遵循的规则:

    • B+跟B树不同之处在于B+树的非叶子节点不保存关键字记录的指针,只进行数据索引,即有k个子树的中间节点包含有k个元素,而B树中是k-1个元素,这样使得B+树每个非叶子节点所能保存的关键字大大增加;

    • B+树叶子节点保存了父节点的所有关键字记录的指针,所有数据地址必须要到叶子节点才能获取到,所以每次数据查询的次数都一样;

    • B+树叶子节点的关键字从小到大有序排列,左边结尾数据都会保存右边节点开始数据的指针(如图中红色虚线框中所示)。

    • 所有的中间节点元素都同时存在于子节点,在子节点元素中是最大(或最小)元素(如图中黑色和红色标出的数字)

    • 非叶子节点的子节点数=关键字数,另一种规则是非叶节点的关键字数=子节点数-1,虽然这两种规则数据排列结构不一样,但其原理还是一样的Mysql 的B+树是用第一种方式实现;

B-树 vs B+树

  • B树的每一个节点都有一个“关键字记录的指针”(索引元素所指向的数据记录,即图中的data部分),而B+树只有叶子节点才有。

    • B树:

    • B+树:

  • B+树相对于B树的优点

    • B+树的层级更少:

      • 相较于B树,B+树每个非叶子节点存储的关键字数更多,树的层级更少所以查询数据更快;
    • B+树查询速度更稳定:

      • B+所有关键字数据地址都存在叶子节点上,所以每次查找的次数都相同,所以查询速度要比B树更稳定;
    • B+树天然具备排序功能:

      • B+树所有的叶子节点数据构成了一个有序链表,在查询大小区间的数据时候更方便,数据紧密性很高,缓存的命中率也会比B树高。
    • B+树全节点遍历更快:

      • B+树遍历整棵树只需要遍历所有的叶子节点即可,而不需要像B树一样需要对每一层进行遍历,这有利于数据库做全表扫描。
  • B树相对于B+树的优点

    • 如果经常访问的数据离根节点很近,而B树的非叶子节点本身存有关键字其数据的地址,所以这种数据检索的时候会要比B+树快。

B+树(属于平衡多路查找树)

  • 支持快速查询、插入等操作的动态数据结构,我们已经学习过散列表、平衡二叉查找树、跳表。

  • 散列表

    • 散列表的查询性能很好,时间复杂度是 O(1)。但是,散列表不能支持按照区间快速查找数据。
  • 平衡二叉查找树

    • 尽管平衡二叉查找树查询的性能也很高,时间复杂度是 O(logn)。而且,对树进行中序遍历,我们还可以得到一个从小到大有序的数据序列,但这仍然不足以支持按照区间快速查找数据。
  • 跳表

    • 跳表是在链表之上加上多层索引构成的。它支持快速地插入、查找、删除数据,对应的时间复杂度是 O(logn)。

    • 并且,跳表也支持按照区间快速地查找数据。

      • 我们只需要定位到区间起点值对应在链表中的结点,然后从这个结点开始,顺序遍历链表,直到区间终点对应的结点为止,这期间遍历得到的数据就是满足区间值的数据
  • 这样看来,跳表是可以解决这个问题。实际上,数据库索引所用到的数据结构跟跳表非常相似,叫作 B+ 树。不过,它是通过二叉查找树演化过来的,而非跳表。

  • B+树:改造二叉查找树使得其可以支持按照区间来查找数据

    • 树中的节点并不存储数据本身,而是只是作为索引。

    • 除此之外,我们把每个叶子节点串在一条链表上,链表中的数据是从小到大有序的

    • 改造之后,如果我们要求某个区间的数据:

      • 我们只需要拿区间的起始值,在树中进行查找,当查找到某个叶子节点之后,我们再顺着链表往后遍历,直到链表中的结点数据值大于区间的终止值为止。

      • 所有遍历到的数据,就是符合区间值的所有数据。

  • 但是,我们要为几千万、上亿的数据构建索引,如果将索引存储在内存中,尽管内存访问的速度非常快,查询的效率非常高,但是,占用的内存会非常多。

  • 比如,我们给一亿个数据构建二叉查找树索引,那索引中会包含大约 1 亿个节点,每个节点假设占用 16 个字节,那就需要大约 1GB 的内存空间。给一张表建立索引,我们需要 1GB 的内存空间。如果我们要给 10 张表建立索引,那对内存的需求是无法满足的。

  • 如何解决这个索引占用太多内存的问题呢?

  • 我们可以借助时间换空间的思路,把索引存储在硬盘中,而非内存中。

  • 我们都知道,硬盘是一个非常慢速的存储设备。通常内存的访问速度是纳秒级别的,而磁盘访问的速度是毫秒级别的。

  • 读取同样大小的数据,从磁盘中读取花费的时间,是从内存中读取所花费时间的上万倍,甚至几十万倍。

  • 这种将索引存储在硬盘中的方案,尽管减少了内存消耗,但是在数据查找的过程中,由于需要读取磁盘中的索引,导致数据查询效率就相应降低很多。

  • 二叉查找树,经过改造之后,支持区间查找的功能就实现了。不过,为了节省内存,如果把树存储在硬盘中,那么每个节点的读取(或者访问),都对应一次磁盘 IO 操作。

  • 树的高度就等于每次查询数据时磁盘 IO 操作的次数。

  • 我们前面讲到,比起内存读写操作,磁盘 IO 操作非常耗时,所以我们优化的重点就是尽量减少磁盘 IO 操作,也就是,尽量降低树的高度。

  • 那如何降低树的高度呢?我们来看下,如果我们把索引构建成 m 叉树,高度是不是比二叉树要小呢?如图所示:

    • 给 16 个数据构建二叉树索引,树的高度是 4,查找一个数据,就需要 4 个磁盘 IO 操作(如果根节点存储在内存中,其他节点存储在磁盘中)

    • 如果对 16 个数据构建五叉树索引,那高度只有 2,查找一个数据,对应只需要 2 次磁盘操作

    • 如果 m 叉树中的 m 是 100,那对一亿个数据构建索引,树的高度也只是 3,最多只要 3 次磁盘 IO 就能获取到数据。磁盘 IO 变少了,查找数据的效率也就提高了。

  • 如果我们将 m 叉树实现 B+ 树索引,用代码实现出来:

    • 对于相同个数的数据构建 m 叉树索引,m 叉树中的 m 越大,那树的高度就越小,那 m 叉树中的 m 是不是越大越好呢?到底多大才最合适呢?

    • 不管是内存中的数据,还是磁盘中的数据,操作系统都是按页(一页大小通常是 4KB,这个值可以通过 getconfig PAGE_SIZE 命令查看)来读取的,一次会读一页的数据。

      • 一次IO操作加载一页
    • 如果要读取的数据量超过一页的大小,就会触发多次 IO 操作。所以,我们在选择 m 大小的时候,要尽量让每个节点的大小等于一个页的大小。读取一个节点,只需要一次磁盘 IO 操作。

  • 尽管索引可以提高数据库的查询效率,但是,作为一名开发工程师,你应该也知道,索引有利也有弊,它也会让写入数据的效率下降。这是为什么呢?

    • 数据的写入过程,会涉及索引的更新,这是索引导致写入变慢的主要原因。
  • 对于一个 B+ 树来说,m 值是根据页的大小事先计算好的,也就是说,每个节点最多只能有 m 个子节点。在往数据库中写入数据的过程中,这样就有可能使索引中某些节点的子节点个数超过 m,这个节点的大小超过了一个页的大小,读取这样一个节点,就会导致多次磁盘 IO 操作。我们该如何解决这个问题呢?

    • 实际上,处理思路并不复杂。我们只需要将这个节点分裂成两个节点。但是,节点分裂之后,其上层父节点的子节点个数就有可能超过 m 个。

    • 不过这也没关系,我们可以用同样的方法,将父节点也分裂成两个节点。这种级联反应会从下往上,一直影响到根节点。这个分裂过程,你可以结合着下面这个图一块看,会更容易理解

    • 图中的 B+ 树是一个三叉树。我们限定叶子节点中,数据的个数超过 2 个就分裂节点;非叶子节点中,子节点的个数超过 3 个就分裂节点。

  • 正是因为要时刻保证 B+ 树索引是一个 m 叉树,所以,索引的存在会导致数据库写入的速度降低。实际上,不光写入数据会变慢,删除数据也会变慢。这是为什么呢?

  • 我们在删除某个数据的时候,也要对应地更新索引节点。这个处理思路有点类似跳表中删除数据的处理思路。频繁的数据删除,就会导致某些节点中,子节点的个数变得非常少,长此以往,如果每个节点的子节点都比较少,势必会影响索引的效率。

  • 我们可以设置一个阈值。在 B+ 树中,这个阈值等于 m/2。如果某个节点的子节点个数小于 m/2,我们就将它跟相邻的兄弟节点合并。不过,合并之后节点的子节点个数有可能会超过 m。针对这种情况,我们可以借助插入数据时候的处理方法,再分裂节点。

  • 文字描述不是很直观,我举了一个删除操作的例子,你可以对比着看下(图中的 B+ 树是一个五叉树。我们限定叶子节点中,数据的个数少于 2 个就合并节点;非叶子节点中,子节点的个数少于 3 个就合并节点。)。

  • 数据库索引以及 B+ 树的由来,到此就讲完了。你有没有发现,B+ 树的结构和操作,跟跳表非常类似。理论上讲,对跳表稍加改造,也可以替代 B+ 树,作为数据库的索引实现的。

  • B+ 树发明于 1972 年,跳表发明于 1989 年,我们可以大胆猜想下,跳表的作者有可能就是受了 B+ 树的启发,才发明出跳表来的。不过,这个也无从考证了。

动态数据结构总结

  • 动态数据结构支持动态的数据插入、删除、查找操作

  • 支持快速查询、插入等操作的动态数据结构有:

posted @ 2021-09-08 00:56  夏目的猫咪老师  阅读(87)  评论(0编辑  收藏  举报