数据结构

257 阅读12分钟

前言

本文主要介绍一些框架,或者中间件的底层,所使用到的一些数据结构,并详细讲解了每种场景下,使用每种数据结构的优缺点。

1、二叉查找树(BST)

1.1、特性

  1. 子树上所有结点的值均小于或等于它的根结点的值。
  2. 子树上所有结点的值均大于或等于它的根结点的值。
  3. 左、右子树也分别为二叉排序树。 图片.png

1.2、缺点

当是下图形态时虽然也符合二叉查找树的特性,但是查找的性能大打折扣,几乎变成了线性。

图片.png

如何解决二叉查找树多次插入新节点而导致的不平衡呢?红黑树

2、平衡二叉查找树(AVL)

AVL树,也称平衡二叉查找树,AVL是其发明者姓名简写。AVL树属于树的一种,而且它也是一棵二叉查找树,不同的是他通过一定机制能保证二叉查找树的平衡,平衡的二叉查找树的查询效率更高。

2.1、AVL树特点

  • AVL树是一棵二叉查找树。
  • AVL树的左右子节点也是AVL树。
  • AVL树拥有二叉查找树的所有基本特点。
  • 每个节点的左右子节点的高度之差的绝对值最多为1,即平衡因子为范围为[-1,1]。 image

图中红色数字表示对应节点的高度,可以看到同一层的节点高度差都没有超过1。

2.2、二叉查找树的平衡

基础的二叉查找树构建出来可能会存在不平衡的现象,比如极端情况下,按照A B C D E F G H顺序插入树中,结果为,

image

但实际上我们更想要平衡一点的二叉查找树,因为平衡的二叉查找树能有效提高查询效率,比如上面的要查询“H”节点则需要比较8个节点才找到,而平衡的二叉查找树只需要比较3个节点。

所以AVL树的出现就是为了解决平衡性问题,它的核心内容就是平衡处理机制,即所谓的旋转,一共有四种形式的旋转:右单旋、左单旋、左右双旋和右左双旋。 四种形式的旋转的参考文章

3、B+树

B+树常用于数据库和文件系统中,B+树能够保持数据稳定有序,其插入与修改拥有较稳定的对数时间复杂度。B+ 树自底向上插入,这与二叉树恰好相反。

图片.png

3.1、B+树和B树的区别

B+树与B树的主要区别:1.B+树中只有叶子节点会带有指向记录的指针,而B树所有节点都有指针,在内部节点出现的索引项不会再出现在叶子节点中。(B+树的所有全量数据都在叶子节点,而B树每个节点都是全量数据)2.B+树中所有叶子节点都是通过指针连接在一起,而B树不会。
B+ 树的优点在于:

  • IO次数更少:由于B+树在内部节点上不包含数据信息,因此在内存页中能够存放更多的key。 数据存放的更加紧密,具有更好的空间局部性。因此访问叶子节点上关联的数据也具有更好的缓存命中率。
  • 遍历更加方便:B+树的叶子结点都是相链的,因此对整棵树的遍历只需要一次线性遍历叶子结点即可。而且由于数据顺序排列并且相连,所以便于区间查找和搜索。而B树则需要进行每一层的递归遍历。相邻的元素可能在内存中不相邻,所以缓存命中性没有B+树好。

但是B树也有优点,其优点在于,由于B树的每一个节点都包含key和value,因此经常访问的元素可能离根节点更近,因此访问也更迅速。下面是B 树和B+树的区别图:

3.2、为什么MySQL选择B+树做索引

1、 B+树的磁盘读写代价更低:B+树的内部节点并没有指向关键字具体信息的指针,因此其内部节点相对B树更小,如果把所有同一内部节点的关键字存放在同一盘块中,那么盘块所能容纳的关键字数量也越多,一次性读入内存的需要查找的关键字也就越多,相对IO读写次数就降低了。

2、B+树的查询效率更加稳定:由于非终结点并不是最终指向文件内容的结点,而只是叶子结点中关键字的索引。所以任何关键字的查找必须走一条从根结点到叶子结点的路。所有关键字查询的路径长度相同,导致每一个数据的查询效率相当。

3、B+树更便于遍历:由于B+树的数据都存储在叶子结点中,分支结点均为索引,方便扫库,只需要扫一遍叶子结点即可,但是B树因为其分支结点同样存储着数据,我们要找到具体的数据,需要进行一次中序遍历按序来扫,所以B+树更加适合在区间查询的情况,所以通常B+树用于数据库索引。

4、B+树更适合基于范围的查询:B树在提高了IO性能的同时并没有解决元素遍历的我效率低下的问题,正是为了解决这个问题,B+树应用而生。B+树只需要去遍历叶子节点就可以实现整棵树的遍历。而且在数据库中基于范围的查询是非常频繁的,而B树不支持这样的操作或者说效率太低。

4、HashMap为什么使用红黑树

Hashmap使用红黑树的原因是:这样可以利用链表对内存的使用率以及红黑树的高效检索,是一种很有效率的数据结构。平衡二叉搜索树是一种高度平衡的二叉树,所以查找的效率非常高,但是,有利就有弊,平衡二叉搜索树为了维持这种高度的平衡,就要付出更多代价。每次插入、删除都要做调整,复杂、耗时。所以,hashmap用红黑树。

4.1、红黑树回顾

红黑树是一种自平衡二叉查找树,也被称为"对称二叉B树",它可以在O(logn)时间内利用 O(logn)的空间来完成查找、插入、删除操作。红黑树的读操作与普通二叉查找树相同,而插入和删除操作可能会破坏红黑树的规则,需要进行恢复操作。恢复红黑树的性质需要少量的颜色变更(实际是非常快速的)和不超过三次树旋转(对于插入操作是两次),虽然插入和删除很复杂,但操作时间仍可以保持为O(logn)。

红黑树的规则:
  1. 节点是红色或黑色。
  2. 根节点是黑色。
  3. 每个叶子节点都是黑色的空节点(NIL节点)。
  4. 每个红色节点的两个子节点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点)
  5. 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。

图片.png

4.2、红黑树的优势

  1. 红黑树是”近似平衡“的。红黑树相比avl树,在检索的时候效率其实差不多,都是通过平衡来二分查找。但对于插入删除等操作效率提高很多。红黑树不像avl树一样追求绝对的平衡,他允许局部很少的不完全平衡,这样对于效率影响不大,但省去了很多没有必要的调平衡操作,avl树调平衡有时候代价较大,所以效率不如红黑树,在现在很多地方都是底层都是红黑树。
  2. AVL树是一种高度平衡的二叉树,所以查找的非常高,但是,有利就有弊,AVL树为了维持这种高度的平衡,就要付出更多代价。每次插入、删除都要做调整,就比较复杂、耗时。所以,对于有频繁的插入、删除操作的数据集合,使用AVL树的代价就有点高了。
  3. 红黑树的高度只比高度平衡的AVL树的高度(log2n)仅仅大了一倍,在性能上却好很多。
  4. 红黑树只是做到了近似平衡,并不严格的平衡,所以在维护的成本上,要比AVL树要低。 所以,红黑树的插入、删除、查找各种操作性能都比较稳定。对于工程应用来说,要面对各种异常情况,为了支撑这种工业级的应用,我们更倾向于这种性能稳定的平衡二叉查找树。HashMap在里面就是链表加上红黑树的一种结构,这样利用了链表对内存的使用率以及红黑树的高效检索,是一种很好的数据结构。

4.3、总结

  1. 红黑树牺牲了一些查找性能 但其本身并不是完全平衡的二叉树。因此插入删除操作效率略高于AVL树。
  2. AVL树用于自平衡的计算牺牲了插入删除性能,但是因为最多只有一层的高度差,查询效率会高一些。
  3. 红黑树结构的数据常常存在于主存中,主要用于快速查找。树的每个节点存储的数据量比较小,cpu通过与主存少量的交互就能获取树的全部数据,并快速的查找到所需数据。而B+树形式的数据常常存在于SSD或磁盘中,由于树的深度比较小(一般3~4),能够减少cpu于磁盘间的交互时间。

5、redis为什么使用跳跃表

跳跃表(skiplist)是一个有序的数据结构,它通过在每个节点维护不同层次指向后续节点的指针,以达到快速访问指定节点的目的。跳跃表在查找指定节点时,平均时间复杂度为O(log n) ,最坏时间复杂度为O(N)。

Redis使用跳跃表(skiplist)作为有序集合(zset)的底层实现之一。当有序集合的元素个数大于等于zset-max-ziplist-entries(默认为128个),或者每个元素成员的长度大于等于zset-max-ziplist-value(默认为64字节)的时候,使用跳跃表和哈希表作为有序集合的内部实现。

5.1、跳跃表的实现

在Redis中的跳跃表是由zskiplist结构表示的,zskiplist结构包含由多个跳跃表节点组成的双向链表,每一个跳跃表节点都保存着元素成员和对应的分钟。下面我们一个一个地详细了解一下。

(1)zskiplist结构

跳跃表是由zskiplist结构表示的,它包含以下几个属性:

  • header属性: 指向头部跳跃表节点的指针。
  • tail属性:指向尾部跳跃表节点的指针。
  • level属性:表示跳跃表中层数最大的节点的层数,表头节点的层数不计算在内。
  • length属性:表示跳跃表中的节点总数。

(2)跳跃表节点的结构

跳跃表节点使用zskiplistNode结构表示,它包含以下几个属性:

  • level属性:表示层的数组,数组中每个项使用zskiplistLevel结构表示,它包含以下两个属性:forward属性:指向位于表尾方向其他节点的指针。span属性:当前节点到forward指向的节点跨越了多少个节点。
  • backward属性:指向当前节点的前一个节点的指针。
  • obj属性:指向元素成员的指针。
  • score属性:当前元素成员对应的分数。

5.2、为什么不使用平衡树?

跳跃表以有序的方式在层次化的链表中保存元素, 在大多数情况下,跳跃表的效率可以和平衡树媲美,查找、删除、添加等操作都可以在对数期望时间下完成, 并且比起平衡树来说, 跳跃表的实现要简单直观得多。所以在Redis中没有使用平衡树,而是使用了跳跃表。 参考链接

6、AQS底层为什么使用双向链表

按理说CLH这种单向的链表结构应该够用了(入队、出队、锁获取与释放),通过查看源码,AQS是使用的CLH队列,单向链表实现的同步队列,但每个Node实例节点却是维护了前后节点的指针,prev和next指针主要是中断和唤醒后续阻塞线程时需要用到,也就是双向链表结构。

6.1、用于中断

中断操作需要在 AQS 同步队列中删除线程 Node,这也就转化为在链表中删除节点的问题。如果想从CLH 单向链表中间删除一个 Node,因为只维护了前一个节点的指针,想要知道后一个节点的指针的话,不通过从tail开始使用快慢指针遍历是无法办到的。因此直接维护prev、next指针,以降低删除操作的复杂性。

6.2、用于唤醒

我们知道,CLH是单向的,维护前一个节点指针,后继线程轮询前一个节点的状态,从而判断是否可以获取锁。

而当多线程竞争时,CLH的轮询是非常耗费性能的,无论是对CPU还是总线来说,都是一种巨大的压力。

AQS对CLH进行了改进,后继获取锁的线程在经过有限次的轮询后,依旧获取不到锁将陷入阻塞。优点:减少轮询无效操作;缺点:后继线程Node在阻塞后无法感知前一个线程Node的状态,锁被释放时将无法主动醒来。

于是AQS使用了双指针,在CLH的prev基础上增加了next。AQS维护了next指针,以便活跃线程释放锁后主动唤醒后续阻塞线程去竞争锁。

我们直接上代码,对以上论述进行验证: 在这里插入图片描述

参考链接:blog.csdn.net/qq_41490274…