高性能查找之基础数据结构与各类算法思想的碰撞

前言

应小伙伴的号召,打算写一篇关于数据结构的文章,也算是一段数据结构学习的总结吧

本篇文章不会详细讲解什么是二分查找,什么是跳表、字典、查找树,因为这类资料是非常多的(其中跳表会在下一篇文章中单独进行讲解)。本篇文章侧重点在于这些查找的方式各自的优劣处与如何选择的问题。建议读者先大致了解这些东西是什么再来阅读本篇文章,相信会有一定的启发作用。

这篇文章的议题主要是关于查找数据,围绕查找这一话题展开介绍各种用于查找的数据结构,这类数据结构非常多,各自有各自的优劣之处和自己的特色。本篇的议题有如下几个方面:

  • 为何Redis使用跳表做有序集合?
  • 为何MySQL使用查找树(B+Tree)作为索引实现?
  • 字典表
  • 二分查找(算法,但与议题相符,也一并讨论)

1. 快速查找的几种方式

要理解为什么中间件要选择某种数据结构,首先需要学习一下各自查找的数据结构和算法

很多快速的查找是 logN 时间复杂度,看起来不快,但实际计算一下会发现其其实相当恐怖,例如n如果等于2的32次方,就是42亿左右(java中int的正负范围),如果在42亿个数据中进行查找一个数据,仅仅需要32次!

1.1 二分查找

首先先来讲一个相对简单的算法,虽然不是数据结构但我也将其列入此篇文章中,是因为有如下优势:

  • 其作为搜索算法,具有查找某个值相当快(log(N)的时间复杂度)
  • 支持范围查找,这点有时候还是比较关键的
  • 甚至比其他几种查找的方式更节省内存,只需要一个数组就可以撑起一个二分查找算法。对比字典,需要冗余数组空间,在解决哈希冲突时如果使用链表,还需要保存指针信息。对比查找树跳表,需要保存指针信息

但是其也具有一定的局限性:

  • 此算法支撑的数据结构只能是数组,因为其使用下标访问是O(1)的复杂度,如果换成链表,遍历第N个位置的数据是O(N)的复杂度,所以链表无法作为其数据结构
  • 只有有序的数据才可以使用二分查找

此时想象一个场景,一个有序的数组,其适用于二分查找,如果有新的数据来到,需要做增删的操作,那么就需要从数组中插入一条数据,我们知道,数组是一段连续的内存地址且容量固定(在分配时就指定好了),那么从中间插入一条数据需要将中间到结尾的数据全部都往后移动一位,如果容量不够,又需要新分配一个数组,然后将数据全部拷贝过去,从这点来看,二分查找是不适用于频繁插入和删除操作的场景,这里我们得出结论,如果有一次排序,多次查找的场景,才可以使用二分查找,并且这种方法又快又节省内存。

单值查找

在MySQL查询中,IN 函数就使用了二分查找的方式,像这么一条语句:

select * from city where city_id in (1,2,7,3,5,10)

假设 city_id 不是主键也没有索引,此时MySQL会先进行全表扫描,将 city 表中的数据先全部取出来,然后一条一条的将数据中的 city_id 列与 in 列表进行二分查找对比,看看此行数据是否在 in 列表中,如果是,加入结果集。

如果不用二分查找呢?假设 in 列表中有 n 个条件,表中有 m 条数据,如果取出 in 列表与一行行数据对比 或者是取出一行数据与一个个 in 列表进行对比,都需要扫描 n * m 次,如果是二分查找,只需要logN * m次,在in列表很大的情况下将大大减少次数,但排序最快需要 N*logN 的代价,尽量保持 in 列表的有序吧!

范围查找

二分查找最大的用处就是用在范围查找上,场景是查找不大于18岁的人,或不小于20岁的人,这样都是一个范围查找,这点是字典表所做不到的。

例如通过 IP 地址来查找归属地,我们知道,每一段 IP 地址区间都归属于一个地区

// 瞎编的
[47.102.133.0, 47.102.133.255] 山东济南
[47.102.135.0, 47.102.136.255] 福建泉州
[47.102.137.0, 47.102.137.255] 福建厦门

将全国的所有IP段起始点放到一个数组中,即可快速查询到一个IP的归属地,例如上面的例子,可以在数组中这样存放

// ip地址本来就是一个数字,使用分段显示只不过是为了阅读方便
// 需要将ip地址先转为32位整形数
// 数字也是瞎编的,真实ip比较长,不利于我们讲解
// 202.102.133.13 -> 212232285
int[] ip = new int[]{10, 40, 60}

二分查找找到最后第一个比它小的数

如果二分找到的数字比其小,后面一段数据肯定比它大,继续往后二分,找到后需要看看前一个数字是否小于它,如果前一个数据小于它,后面一个数字又大于它,那可以证明其前面这个数字是最后一个它小的,其就是起始点。

假设来了一个 IP 地址是 47.102.135.50 ,转化为43,二分查找到60,其大于本数,查看前面一个数字40小于本数,则40就是最后一个比它小的,它这个起始点对应了[47.102.135.0, 47.102.136.255] 福建泉州 这个ip段。在ip数据量非常大的时候,并且ip段起始点不容易改变,符合一次排序多次查找的规则,所以这个场景适用二分查找来很快的找到某个ip的归属地。

1.2 跳表

链表数据结构,使用多级链表索引组装即为跳表

其数据结构较为复杂,考虑单独起一篇来讲解跳表

上面的二分查找有一个局限,其只能用数组来做,在增删操作方面有很大的劣势,那么有没有一种数据结构,能使用链表的方式组织数据,这样增删操作就可以很快,而查找值又是 logN的时间复杂度呢?这就是跳表。其具有二分查找的所有优点(除了节省内存,因为其需要存放一些节点的指针),跳表有如下优点:

  • 由于其链表结构,增删节点非常快,只需要改变前后指针即可完成增删操作
  • 范围查找很快
  • 单值查找很快

单值查找

跳表这个数据结构,和二分查找的思想十分相似,二分查找每次查找都会过滤掉一半的数据,例如猜数字,目标数字为57,范围为0-100,第一次猜中点50,比其小,搜索50-100,这样就过滤掉了0-50这一段数据,所以二分查找的时间复杂度在大数据量下会非常低(对数,相对于指数爆炸),而跳表也是这个思想,每一次“跳跃”都可以过滤掉一段数据,所以在单值查找操作上的时间复杂度上也是对数级别

范围查找

由于其是有序的方式组织数据,所以跳表是有序链表,查找一个范围只需要查找一个单值 logN 的时间复杂度,然后往后遍历即可得到这个范围。

在增删操作上,需要保持有序特性,就需要承担 logN 的时间复杂度,因为增删操作时,首先需要找到某个值,是 logN 的时间复杂度,然后再切段链表节点的前后指针,是 O(1) 的时间复杂度,合在一起就是 logN 的时间复杂度,相对来说,也不慢其实。

1.3 字典表

其也叫散列表、哈希表(Hash表),在Java中使用频率极高,主要数据结构是数组(一般哈希冲突时会用到链表或者红黑树进行扩展)

字典表使用Key(键)、Value(值)两个数据来组织的,特性是可以使用特定Key查到特定的Value,主要是使用一个哈希函数将Key散列成一个数字(很多东西都可以散列为一个数字,例如图片等等),然后由数组的长度就可以决定这个散列值对应的数组下标,然而访问数组下标的时间复杂度是O(1),所以可以总结如下:

  • 散列表利用访问数组下标时间复杂度为O(1)的特性,其增删或是查找一个数据都是相当快速的,这点比上述的二分查找或是跳表、查找树都是更快的

那为什么查找数据不都替换为字典表呢?因为其有如下局限

  • 无法支持范围查找。其组织数据的方式是无序的,在顺序方面字典表没能力做到,而跳表和B+Tree的场景下,有很多时候都需要查找一个范围的数据

其实字典表的时间复杂度不能单纯地说成是O(1)的,影响因素有很多,先来看一下字典表的大致运转流程:

  1. 使用哈希函数将某个Key散列为一个散列值(整数)
  2. 数组大小决定了此散列值对应的数组下标,放入下标中
  3. 如果有哈希冲突,使用链表或二叉树解决冲突

上述流程就已经讲述了决定字典表性能的几个主要因素:

  • 散列函数
  • 装载因子(数据数量与容量的比例,例如 int[16] ,那么如果有12个数据,此时装载因子就是0.75)
  • 散列冲突解决策略

其中,散列函数得出的散列值不够”散“,或是装载因子太大(数组装了过多的数据),都会造成散列冲突,而冲突地越多,就越会退化为链表的时间复杂度。所以一个好的散列表性能,就由以上三个因素决定。

那么下面就围绕以上因素来谈谈字典表的高性能设计

设计字典表

散列函数

  • 不能太过复杂,函数的计算时间太长会过多消耗CPU,变相增多时间复杂度
  • 函数生成的值要尽可能均匀分布,这样才能避免散列冲突

装载因子

如果装载因子过大,散列函数再均匀都会哈希冲突,所以散列函数与装载因子是相辅相成的,那么装载因子过大怎么办呢?可以参考Java中HashMap的解决方式,在大于某个装载因子的时候直接将数组容量扩容两倍,这样就能减小了一半装载因子,此时涉及新数组的分配,数据拷贝,和数据的rehash,为什么需要rehash呢?因为数据在数组中的下标是由数组容量决定的,散列值模上一个数组容量(2的次方可以用与运算,更加快速)计算出这个散列值对应的下标,那么容量变了下标就有可能会变化,例如24的散列值,16的数组容量,其下标是8,如果扩容,数组容量变为32,下标就变为24了。

所以,有时候如果知道元素数量,最好提前设置一个合理的初始容量,避免rehash和扩容分配,拷贝数据的开销。

在Redis中,如果字典表数据太大,扩容就十分耗时,1G的数据扩容就要分配2G容量,计算1G的rehash,1G的数据拷贝,如果直接扩容,将会阻塞服务器很久,这对于Redis的响应性很不利,于是在Redis中就采用了慢慢迁移的策略,要扩容时先分配2G容量,在每次的定时任务迁移一小段,或是如果此时有客户端会对Key进行操作,就在这个操作中顺便迁移了。

装载因子的阈值的设置,权衡了时间、空间复杂度,试想,如果装载因子阈值设置为0.1,16容量的数组只能存放1个元素,直接大大避免了哈希冲突,但也付出了冗余15个容量的代价,使用了空间换取时间复杂度。如果阈值太大,为2,16个容量虽然可以放置32个数据,但冲突大大增加,很大概率退化为链表,这就使用了时间复杂度换取了空间

发散知识:考虑java中并发HashMap(ConcurrentHashMap),因为需要检查装载因子是否超过阈值,那么每次添加元素时就都要检查此时的容量大小,那么此时size这个值就是热点数据,因为每一个线程的put和remove都要访问这个值,那么如何设计这个值的高并发性,就是一门艺术了,关于这一点,可以查看这篇文章

散列冲突策略

  • 开放寻址法:代表为Java中的ThreadLocalMap
    • put数据时,当冲突发生,再计算一次index。查找数据时,需要先查看key是否相等(equal),不相等需要使用同样的方法再计算一次index,继续往下查找。最简单的就是+1法,冲突了就下标加1,继续看看有没有冲突
    • 优点:所有的数据都在一个数组上,这样可以很好的利用到CPU缓存,并且序列化起来比较容易
    • 缺点:更容易造成哈希冲突,这样就导致了性能与链表法持平的话需要更小的负载因子阈值,就更需要内存空间了
  • 链表法:代表为Java中的HashMap
    • put数据时,当冲突发生,直接将值存放到冲突值的下一个指针中。查找数据时,需要先查看key是否相等(equal),不相等需要查找值的下一个指针是否为空,不为空就往下查找
    • 优点:对于大数据量的负载容忍度高,相对开放寻址法更不容易哈希冲突(如果链表过长,还可以变化为红黑树,这是JDK1.8的做法,这样可以使得查找时间复杂度从链表的 O(N)到红黑树的 O(logN) )

散列与链表的相辅相成

很多时候,散列表和链表两个数据结构会一起使用,散列表擅长查找一个数据,但无序,链表擅长存放一个顺序,但查找一个数据很慢,两者相辅相成:

  • LRU淘汰算法:使用字典表存放元素,使用链表存放元素顺序
    • 查找:在存放元素的时候可以使用字典表快速查找是否缓存过这个元素,若没有插入到链表的末尾节点,如果有,将这个节点删除插入到末尾节点
    • 有序:若容量超过阈值,删除链表的头部
    • 这一过程,利用了字典表的 O(1) 查找,链表的顺序(头部尾部操作O(1) ),链表的删除插入O(1),十分高效
  • Redis有序集合:Redis中的有序集合其实不仅仅使用跳表(链表)来做,其实还与字典表结合来使用,因为跳表中查找一个数据是 logN 的时间复杂度,如果配合字典表将会更快
    • 字典表的查找复杂度 O(1)
      • 删除指定元素
      • 查找指定元素
    • 跳表的快速范围查找
      • 按照score区间查找,例如查找score在[3, 10]之间的元素
      • 按照分值从大到小排序(跳表本就是有序排列,只需要取出数据即可)
  • Java中的LinkedHashMap:如果想要一个Key-Value的数据结构,又想要其保持插入时的顺序怎么办?LinkedHashMap是一个好选择,例如在Spring解析@Configuration下的Bean时,保存BeanDefinition的时候就使用了LinkedHashMap(我在暗示Spring解析配置Bean的时候是顺序初始化的,这样可以保证优先级和自动装配的Bean在最后才初始化,这样有些Condition条件就可以适用了)

1.4 查找树

更多详情看下面的第三点,B+Tree的讨论

这里讨论的是多路的查找树,相对于二叉树,多路查找树一个节点会保存很多个数据,并且有多路分支,路数越多,高度就比二叉树小很多,在查找一个单值时是 logN (树的高度决定)的时间复杂度,而且查找树都是有序存放的,所以支持范围查找。这一点看来,其和跳表很像,的确,这两者是十分相似的,很多使用红黑树或是m叉树的地方都可以使用跳表来实现,跳表实现起来比较简单,但是红黑树实现起来比较困难,但红黑树历史比较悠久,所以历史遗留问题很多地方都是红黑树实现,而最近的这种场景使用跳表实现的也越来越多了(Redis)

2. Redis选择跳表实现有序列表

有序列表需要完成以下需求:

  • 快速查找一个区间(范围查找)
  • 快速输出一个有序结果
  • 快速插入、查找、删除

在第一第二点上,就决定了字典表无法胜任,二分查找就更不可以了,其在插入删除时表现的特别糟糕,那就只剩查找树和跳表可选了,在按照某个区间查找数据这个操作,普通的像红黑树的效率就没有跳表高了(这也就是为什么MySQL的InnoDB引擎的B+Tree要在叶子节点存放数据链表的原因,类似跳表,这利于范围查找),跳表只需要 logN 复杂度定位到起始节点,然后遍历链表即可得到结果。

还有就是相对红黑树会比较好实现(在上面也提到了),只不过红黑树出现的比较早,占领了很多编程语言的内部数据结构。

3. MySQL选择B+Tree

在这里,针对MySQL对B+Tree进行有针对性的讨论。

在MySQL中,查询需要满足怎样的需求呢?先来看看两句SQL语句

  • select * from city where id = 1
  • select * from city where id < 500

首先,散列表无法支持第二条语句,二叉查找树例如红黑树,也无法支持区间查找,如果是跳表,可以支持以上操作,事实上,对跳表稍加改造,也可以替换B+Tree,但两者是差不多了,而B+Tree出现的又比较早,MySQL索引就没有替换跳表的必要而是使用B+Tree了。

数据库的数据通常会比较大,索引相对也会大一些,索引需要存放在磁盘中,这就意味着每次对索引进行查找都将是一次磁盘IO,我们知道,磁盘IO的代价是比较重的,所以B+Tree的核心理念是减少IO次数,多路使得树结构的高度变得很低,通常来说,一次IO是按页(一页通常4KB)来读取,那么保证B+Tree的一个节点小于4KB,并且存放足够多的数据,其实就可以最大限度减少IO次数,提升性能了,这也就是为什么索引的字段不能太长,会影响索引性能的原因。

如果索引字段值长度是1B,那一个树节点可以存放 4 * 1024 个字段值,相对字段值长度是4B,一个树节点只能存放 1024 个字段值,两者的树高度是完全不一样的,而树的高度又决定了 IO 的次数,这样来看,索引字段如果太大,将会影响索引性能。

4. 总结

其实所有的数据结构,基本上都是由数组和链表所组成,这两个是基础底层数据结构,配合上一些算法思想例如:

  • 哈希+数组 = 字典表
  • 二分思想+链表 = 跳表
  • 二分思想+树形结构的节点(类似链表的指针访问机制) = B+Tree
  • 二分思想+数组 = 二分查找

其各自都有不同的使用场景:

  • 字典表:
    • 快速查找
    • 无序
    • 不可范围查找
    • 配合链表或跳表相辅相成,补字典表范围查找的短板和字典表无序的特点
  • 跳表:
    • 较快速的查找(没字典表那么快)
    • 有序
    • 高效的范围查找
    • 配合字典表,补自身查找单值没字典表那么快的短板
  • B+Tree:
    • 较快速的查找(没字典表那么快)
    • 有序
    • 高效的范围查找(MySQL的InnoDB中,叶子节点存放了有序的数据链表)
    • 多路特性,大幅减少IO次数,十分适合数据库索引
  • 二分查找:
    • 较快速的查找(没字典表那么快)
    • 有序(需要提前排好序)
    • 高效的范围查找(查找一组数据中,第一个大于或小于,最后一个大于或小于某个值的情况,类似IP归属地查找,或是2的平方根,保留 n 位小数点的计算)
    • 局限比较大,只能使用数组,在增删条件下显得比较糟糕,只适用于一次排序,多次查找的场景
    • 相对以上数据结构,其是比较省空间的
发布了84 篇原创文章 · 获赞 85 · 访问量 10万+

猜你喜欢

转载自blog.csdn.net/qq_41737716/article/details/102959047