9.散列表(学习笔记)

2.9.1散列思想

    散列表用的是数组支持按照下标随机访问数据的特性,所以散列表其实就是数组的一种扩展,由数组演化而来。

    举一个简单的例子:89小学生参加运功会比赛,每个人都有一个参赛号,参赛号是通过年级班级加序号来组成,例如051101,表示五年级11班,后面是01-89编号。想要存储选手的信息,来支持通过编号快速查询选手信息。不可以直接把编号当做数组的下标,但是可以截取编号的后两位来作为数组下标。这时:参赛选手的编号就是或者关键字,通过把参赛编号转换成数组下标的映射方法就叫做散列函数,而通过散列函数计算得到的值就叫做散列值。

9.散列表(学习笔记)_第1张图片

散列函数设计的基本要求:

①.散列函数计算得到的散列值是一个非负整数。

②.如果key1=key2,那hash(key1)==hash(key2);

③.如果key1≠key2,那hash(key1) ≠ hash(key2);

    第三点看起来合情合理,要想找到一个不同的key对应的散列值都不一样的散列函数几乎是不可能的,即便是业界著名的MD5,SHA,CRC等哈希算法也无法完全避免这种散列冲突。而且,数组的存储空间有限,也会加大这种散列冲突的概率。常用的解决散列冲突的方法有两类:开放寻址法、链表法。

2.9.2开放寻址法

    开放寻址法核心思想是如果出现了散列冲突,就重新检测一个空闲位置,将其插入,而如何检测新的位置,可以运用线性探测:当往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,就从头开始依次往后查找,看是否有空闲位置,直到找到为止。如下图来表示:橙色代表已填充,黄色代表未填充。x经过散列函数计算后,被散列到位置下标为7的位置,但在7的位置上已经有了数据,于是就从表头开始找,直到找到空闲位置2,于是将其插入到这个位置上。

9.散列表(学习笔记)_第2张图片

    在散列表中查找元素有点类似于插入,通过散列函数求出要查找的元素的键值对应的散列值,然后比较数组中下标为键值的元素和要查找的元素,如果相等,就是要查找的元素,如果不相等,就从头按照顺序依次查找,如果遍历到数组中空闲位置,还没有找到,就说明要查找的元素不在散列表中(因为上述插入过程,如果出现散列冲突的情况,就是从头比遍历插入到空闲位置了)。而当散列表中数据越来越多时,散列冲突的可能性越来越高,空闲位置会越来越少,线性检测时间就越来越久。最坏情况下时间复杂度会达到O(n)。为了保证散列表的操作效率,一般情况下,会尽可能的保存散列表中有一定比例的空闲槽位,用装载因子来表示空位的多少。装载因子越大表示空闲位置越少,冲突越多,散列表的性能会下降。

    散列表的装载因子=填入表中的元素个数 / 散列表的长度

    而如果进行删除操作,不可以单纯的直接将元素删除,因为直接删除的话查找的时候就是出问题,所以会将删除掉的元素进行特殊标记为delected。当线性探测时,遇到delected不会停下来,而会继续向前检测。

    开发地址法中除了线性探测还有两种经典的方法,二次探测双重散列,所谓二次探测和线性探测很像,线性探测每次步长是1,二次探测步长变成了原来的二次方。双重散列意思就是不止一个散列函数,第一个散列函数计算得到的存储位置如果已经被占用,就用第二个散列函数进行计算...第三个,第四个,直到找到空闲的存储位置。

    线性探测的下表序列就是:hash(key)+0,hash(key)+1,hash(key)+2...

    二次探测的下标序列就是:hash(key)+0,hash(key)+1²,hash(key)+2²...

2.9.3链表法

    链表法要更加常用,而且简单得多,如下图,这就是之前面试时候所说的数组形的链表,在散列表中每个“槽”会对应一条链表,所有散列值相同的元素都放到相同槽位对应的链表中。这样的话,插入的时间复杂度是O(1),插入和删除的时间复杂度是和链表的长度相关的,假设链表长度为k,时间复杂度就是O(k),对于散列比较均匀的散列函数,假设n表示散列中元素数,m表示“槽数”,理论上k=n/m。

9.散列表(学习笔记)_第3张图片

2.9.4课后思考

①假设有10万条URL,如何根据访问次数给URL进行排序。

    以URL为key,访问次数为value存放到散列表中,同时记录访问次数的最大值K,时间复杂度为O(n),如果K不是很大,可以用桶排序,如果K很大,比如说大于10万,可以用快排。

②有两个字符串数组,每个数组大约有10 万条字符串,如何快速找出两个数组中相同的字符串?

    将第一个数组存放到一个散列表中,以字符串为key,以出现次数为value,然后遍历第二个数组,以第二个数组的字符串为key在散列表中查找,如果value大于0,则说明存在相同的字符串,时间复杂度为O(n)。

2.9.5如何打造一个工业级水平的散列表?

    在极端情况下,散列表时间复杂度可能会达到O(n),而一个工业级别的散列表一定是要避免这些问题的,它跟散列函数、装载因子、解决散列冲突都有关系。

    散列函数:散列函数的设计不能太复杂,过于复杂,计算时间长,一定会影响效率,散列函数分布要均匀。对于数据集合变动不频繁的静态集合来说,比较容易设计出完美的散列函数,因为毕竟数据都是已知的。

    装载因子:装载因子过大,在查询时可能要多次寻址,或者会有很长的链表,结果就会很慢。对于动态散列表来说,数据集合频繁变动,所以要申请一个足够大的散列表,随着数据量不断增多,装载因子不断变大,散列冲突到不可接受,就要进行动态扩容。针对数组的扩容,数据搬移比较简单,但是散列表的扩容就比较麻烦了,因为需要重新计算每个数据的存储位置。对于动态散列表,随着数据的删除,散列表中的数据量减少,装载因子减小,如果对于空间消耗十分敏感,可以进行动态缩容。装载因子阈值的设置要权衡时间、空间。如果对空间要求不高,可以把阈值设小一点,反之...

9.散列表(学习笔记)_第4张图片

1)如何避免低效扩容:

    在一个动态列表进行插入数据,最好情况时间复杂度是O(1),最坏情况是需要进行扩容,这次会特别的慢,最坏情况时间复杂度是O(n),用摊还分析法进行分析,平均情况时间复杂度就是O(1)。但是对于用户体验来说,偶尔的一次会特别慢到让人无法接受,所以这样就不合适了。为了解决一次性扩容耗时过多的问题,可以将扩容穿插在插入操作中,分批完成,当装载因子达到阈值时,只申请新空间,但不进行搬移,在插入新数据时,将新数据插入到新的散列表中,同时从旧的散列表中搬移一个数据到新散列表,这样就快多了。

    在此期间的查询操作,为了兼顾新老散列表中的数据,先从新散列表中进行查询,找不到再从旧散列表中进行查询。

2)如何选择散列冲突解决方法:

    在java中LinkedHashMap采用链表法解决冲突,ThreadLocalMap采用开放寻址法解决散列冲突。

    1.开放寻址法:优点是可以有效地利用CPU缓存加快查询速度,而且序列化起来简单。缺点是删除数据比较麻烦,而且数据全部存储在一个数组中,冲突解决的代价更高,而且装载因子不能太大。适合用于数据量较小,装载因子小

    2.链表法:优点是对于内存的利用率要比开放寻址法高,因为链表结点可以需要的时候在创建,而不需要事先申请,这也是链表比数组好的地方,链表法对装载因子的容忍度更高,只要散列函数分布均匀,即使链表长度很大,虽然查找效率有所下降,但是还是比顺序查找快。缺点是链表法会对内存有一些浪费,因为要存储指针,对于比较小的对象的存储,是比较消耗内存的,当然如果对象很大就无所谓了。实际上,对链表法进行改造,可以实现更高效的散列表,就是将链表法中的链表替换成更高效的数据结构,比如说跳表,红黑树。这样就算是极端情况下,所有的数据全都散列到一个桶里,时间复杂度也是O(logn)。适合大对象,大数据量的散列表。而且更灵活

3)工业级散列表举例分析:来分析HashMap

    a. 初始大小:HashMap的初始大小是16,如果事先知道数据量大概有多大,可以修改默认初始大小,避免动态扩容,提高性能。

    b. 装载因子和动态扩容:最大装载因子是0.75,当超过0.75时就会自动扩容,每次扩容为之前的两倍。

    c. 散列冲突解决方法:链表法上进行改进,JDK1.8中HashMap的散列冲突解决方法是链表法(默认值为8),链表就转换成红黑树,当结点个数小于6时,又会转换回链表,因为在数据量较小时,红黑树优势并不明显。

    d. 散列函数:

       在这里用到了公式A&(B-1)=A%B,其中B为2的n次幂  
9.散列表(学习笔记)_第5张图片

4)工业级的散列表应该具有什么特点

    a. 支持快速查询、插入、删除操作;

    b. 内存占用合理,不能浪费过多空间;

    c. 性能稳定,极端情况下,散列表的性能也不会退化到无法接受的情况。

5)如何实现这样的一个散列表

    a. 设计一个合适的散列函数

    b. 定义装载因子阈值,并且设计动态扩容策略

    c. 选择合适的散列冲突解决方法

2.9.5散列表和链表

1)LRU缓存算法

    当要缓存某个数据时,先在链表中查找这个数据,如果有的话就从原位置删除,把他插入到头部,如果没有的话就直接插入在头部,如果缓存空间已满,就把尾结点数据删除,而仍将,然后将新的数据插入到链表的头部。这样的话缓存的时间复杂度为O(n)。

    一个缓存系统一般包括三个操作:往缓存中添加一个数据、从缓存中删除一个数据、在缓存中查找数据,这三个操作都涉及“查找”操作。要实现LRU算法,如果单纯采用链表的话,时间复杂度就是O(n),如果将散列表和链表两种数据结构结合,可以将这三个操作的时间复杂度都降到O(1)。

9.散列表(学习笔记)_第6张图片

    如上图通过双向链表来存储数据,链表中每个结点存储数据data,前驱指针pre,后继指针next,还新增一个字段hnext。因为散列表通过链表法解决散列冲突,所以每个结点会在两条链中。一个是刚刚提到的双向链表,另一个链是散列表中的拉链。前驱和后继指针为了将结点串在双向链表中,hnext指针为了将结点串在散列表的拉链中。在双向链表中,所以时间是从大到小的,在hnext的拉表中,时间也是从左到右依次减小的。(都是因为在这里优先的都是设定在链表的头结点)

    查找数据:通过散列表可以很快的在散列表中查到一个数据,查到之后还需要将他移动到双向链表的头部。

    删除数据:找到要查找数据的结点,借助散列表,可以在O(1)时间复杂度内找到要删除结点,因为链表是双向链表,查找的时间复杂度是O(1),所以在双向链表中,删除结点的时间复杂度是O(1)。(删除数据,这里如果是要删除双向链表中的某个值,不是也得从头遍历,这样的话时间复杂度不应该是O(n)吗)

    添加数据:添加数据比较麻烦,首先看这个数据是否在缓存中。如果已经在其中,需要将他移动到双向链表的头部,如果不在,看缓存有没有满,如果满的情况下,将双向链表的尾结点删除,然后将它放在头结点位置;如果没有满,直接将数据放在链表头结点。查找、删除、插入数据的平均时间复杂度都是O(1),所以这样就实现了一个高效的LRU缓存淘汰算法的缓存系统模型。

2)Redis的有序集合

    在redis的有序集合中,每个成员对象有两个重要属性,key(键值)和score(分值)。不仅会通过score来查找数据,还可以通过key来查找数据。

    比如有一个用户积分排行榜:包含ID、姓名和积分的用户信息就是成员对象,用户的ID就是key,积分就是score。可以通过ID来查找积分,也可以通过积分区间来查找ID或者姓名信息。

    细化一下redis有序集合的操作,①添加一个成员对象②按照键值来删除一个成员对象③按照键值来查找一个成员对象④按照分值区间,查找某个区间内的成员对象⑤按照分值从小到大排序成员变量。

    如果按照分值将成员对象组织成跳表的结构,那如果按照键值来删除、查询对象就会变得很慢,对此的解决方法和LRU淘汰算法类似,可以按照键值在构建一个散列表,这样按照key来进行操作时间复杂度就变为O(1)了。所以目前来看redis的有序集合是通过跳表和散列表这种组合结构来实现的。(后续还有一种操作,比如说查找成员对象的排名,又是通过其他的结构来实现的)。

3)LinkedHashMap

    LinkedHashMap相对于HashMap又多了一个Linked,那么是不是通过链表法来解决散列冲突的HashMap,是这样但不仅仅是这样,的确是通过链表法来解决散列冲突,Linked其实指的是双向链表,看一下下面这段代码:

9.散列表(学习笔记)_第7张图片
会输出3,1,5,2
9.散列表(学习笔记)_第8张图片
会输出1,2,3,5
9.散列表(学习笔记)_第9张图片
9.散列表(学习笔记)_第10张图片
9.散列表(学习笔记)_第11张图片

    由上可以发现LinkedHashMap在每次调用put()函数时,都会将数据加到链表的尾部,举例说明来看,而且它不仅支持按照插入顺序来遍历数据,还按照访问顺序来遍历数据。所以才会3,1,5,2变为1,2,3,5。可以发现LinkedHashMap本身就是支持LRU缓存淘汰策略的缓存系统(只不过最近访问数据都是放在尾部,实现原理是一样的)

(本文是个人听课笔记,不少东西摘取于王争老师的原文,原文链接http://gk.link/a/10aMZ)

你可能感兴趣的:(9.散列表(学习笔记))