散列(哈希)表

1.如何构造散列函数,总结三点散列函数设计的基本要求:

1)散列函数计算得到的散列值是一个非负整数        //下标从0开始

2)如果key1 = key2,那么hash(key1)==hash(key2) //相同的key经过hash 得到的散列值应该是相等的。

3)  如果key1 != key2,  那么hash(key1) != hash(key2)//这个是理想情况下,越接近这种就证明hash函数设计的完美,会有相等的情况,就是发生了散列冲突

2.散列冲突

散列冲突的解决方法有两类,开放寻址和链表法

1.开发寻址

1) 线性探测

线性探测查找和插入差不多,先计算散列值,如果此时已经存在元素了,则往下,直到找到相等的值或者找到空的位置。 但如果空的位置是我们后删除的呢,如下图

1的位置被我们删除的,如果不做任何处理,那么即使y在2的位置上,也找不到,到1的时候发现这个位置是空的,说明y没插入过。 所以我们要加上一个deleted标志,遇到这个标志后要继续往下走即可。

线性探测存在很大的问题。当散列表中插入的数据越来越多时,散列冲突发生的可能性就会越来越大,空闲位置会越来越少,线性探测的时间就会越来越久。极端情况下,我们可能需要探测整个散列表,所以最坏情况下的时间复杂度为 O(n)。同理,在删除和查找时,也有可能会线性探测整张散列表,才能找到要查找或者删除的数据。

对于开放寻址冲突解决方法,除了线性探测方法之外,还有另外两种比较经典的探测方法,二次探测(Quadratic probing)和双重散列(Double hashing)。

所谓二次探测,跟线性探测很像,线性探测每次探测的步长是 1,那它探测的下标序列就是 hash(key)+0,hash(key)+1,hash(key)+2……而二次探测探测的步长就变成了原来的“二次方”,也就是说,它探测的下标序列就是 hash(key)+0,hash(key)+12,hash(key)+22……

所谓双重散列,意思就是不仅要使用一个散列函数。我们使用一组散列函数 hash1(key),hash2(key),hash3(key)……我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。

不管采用哪种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表中有一定比例的空闲槽位。我们用装载因子(load factor)来表示空位的多少。

装载因子的计算公式是:

 

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

装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。

2.链表法

如图所示:位置冲突后,用链表降散列值相等的元素关联起来。

插入的时间复杂度为O(1)显而易见

那么查找或者删除的复杂度是?

实际上这两个操作的时间复杂度跟链表的长度k成正比,也就是O(k)。对于散列比较均匀的散列函数来说,理论上讲,k=n/m,其中n表示散列中数据的个数,m表示散列表中的槽数。

3.如何设计工业级散列表

1.初始大小

hashmap默认的初始大小是16,当然这个默认值是可以设置的,如果事先知道大概的数据量有多大,可以通过修改默认初始值大小,减少动态扩容的次数或者降低空间的浪费。

2.装载因子和动态扩容

最大装载因子默认是0.75,当hashmap中元素个数超过0.75*capacity(表示散列表的容量)的时候,就会启动扩容,每次扩容都会扩容为原来的两倍大小。

动态扩容如下图所示

并不是扩容后,就全都一下把数据hash到新的内存空间上,而是每次插入新元素的时候去把旧的一个重新散列到新的散列表上,查找的时候就是双读,先新后旧。

插入一个数据,最好情况下,不需要扩容,最好时间复杂度是O(1),最坏情况下,散列表装载因子过高,启动扩容,我们需要重新申请内存空间,重新计算哈希位置,并且搬移数据,所以时间复杂度是O(n)。用摊还分析法,时间复杂度接近最好情况,就是O(1)。

3.散列冲突解决方法:

hashmap底层采用链表法来解决冲突。即使负载因子和散列函数设计得再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响hashmap的性能。

于是为了对hashmap做进一步优化,我们引入了红黑树。而当链表长度太长(默认超过8)时,链表就转换为红黑树。我们可以利用红黑树快速增删该查的特点,提高性能。当红黑树节点个数少于8个的时候,又会将红黑树转化为链表。因为在数据量较小的情况下,红黑树要维护平衡,比起链表来,性能上的优势并不明显。

如何实现一个LRU算法

我们需要维护一个按照访问时间从大到小有序排列的链表结构。因为缓存大小有限,当缓存空间不够,需要淘汰一个数据的时候,我们直接将链表头部的结点删除。当要缓存某个数据的时候,先在链表中查找这个数据。如果没有找到,则直接将数据放到链表的尾部;如果找到了,我们就把它移到链表的头部。因为查找数据需要遍历链表,所以单纯的用链表实现的LRU缓存淘汰算法的时间复杂度很高,是O(n)

实际上:一个缓存系统主要包含下面这几个操作

1.往缓存中添加一个数据

2.从缓存中删除一个数据

3.在缓存中查找一个数据

这三个操作都涉及查找操作,如果单纯地采用链表的话,时间复杂度只能是O(n).如果我们将散列表和链表两种数据结构组合使用,可以将这三个操作的时间复杂度都降低到O(1),具体的结构如下:

发布了43 篇原创文章 · 获赞 37 · 访问量 7万+

猜你喜欢

转载自blog.csdn.net/qq_28119741/article/details/101755925