此文是数据结构与算法之美学习笔记
散列表英文名叫“Hash Table”,也叫哈希表或者Hash表。它利用数组支持按照下标随机访问的特性,所以散列表其实是数组的一种扩展,由数组演化而来。
通过散列函数,吧元素的键值映射为下标,然后把数据存储到数组中对应下标的位置。当我们按照键值查询元素的时候,使用同样的散列函数,将键值转化为数组的下标,然后向对应的下标位置去取数据。
文中有个例子很好,比如有89名参赛选手,他们的编号是年级加班级加编号(从1到89)的样子比如060760,表示六年级7班60号。那怎么通过编号来快速查找某个选手的信息呢?因为最后两位编号是连续的,我们可以截取后两位来作为数组的下标,那信息放到数组的对应位置
比如上面的060760,我们通过一个散列函数截取出60,然后在数组的下标为60的位置把060760这个数据放进去
散列函数
散列函数我们定义为hash(key),其中key表示元素的键值,hash(key)的值表示经过散列函数计算得到的散列值。
散列函数的设计基本要求:
(1)因为数组下标是从0开始的,所以散列函数计算得到的散列值是一个非负整数。
(2)同样的key经过散列函数得到的散列值应该是相同的,如果key1 = key2 那么hash(key1)=hash(key2)
(3)如果key1 != key2 那么hash(key1) !=hash(key2)。在真实情况下,想要找到一个不同的key对应的散列值都不一样的散列函数几乎不可能。就算MD5、SHA、CRC等哈希算法也不能避免。这叫做散列冲突,因为数组的存储空间有限,这也会加大散列冲突的概率。
解决散列冲突的办法
常用的办法有两种,开发寻址法和链表法。
1.开放寻址法
如果出现了散列冲突,我们就重新探测一个空闲位置,将其插入。怎么重新探测新的位置呢?
(1)线性探测
当我们往散列表中添加数据的时候,如果某个数据经过散列函数处理之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。
当我们去散列表中查找数据的时候,通过散列函数算出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的位置的的元素和要查找的元素,如果相等,则说明就是我们要查找的元素,否则就继续往后一次查找,如果遍历到数组为空的位置还没找到,说明要查找的元素不存在。
删除操作,删除数据的时候不能把对应的元素置空,因为上面的查找操作当查找到空闲位置的时候,就认为不存在这个数据,但是如果这个空闲位置是我们后来删除的,就会导致原来的查找算法失效了。我们可以将删除的元素特殊标记为deleted,线性探测的时候,遇到deleted的标记就继续往下。
(2)二次探测
跟线性探测很想,如果线性探测的每次探测的步长是1,二次探测的步长是原来的二次方。
线性探测:hash(key)+0,:hash(key)+1,:hash(key)+2,:hash(key)+3 …
二次探测::hash(key)+0,:hash(key)+1的平方,:hash(key)+2的平方,:hash(key)+3的平方 …
(3)双重散列
不止使用一个散列函数,先使用第一个散列函数,如果计算得到的位置已经被占用,在使用第二个函数计算依次类推直到找到空闲位置。
探测法存在很大的问题,当散列表中的数据越来越多的时候,散列冲突就越来越大。为了保证散列表的操作效率,一般情况下,我们尽可能保证散列表中有一定比例的空闲位置,用装载因子来表示空位的多少。装载因子约到表明空闲位置越少,冲突越多,散列表的性能会下降
散列表的装载因子 = 填入表中的元素个数 / 散列表的长度
2.链表法
链表法更加常用,相比寻址法更简单,数组中的每个位置都对应一个链表,所有散列值相同的元素,都放到对应的链表中。
插入操作,通过散列函数计算出对应的位置,将其插入到对应的链表中即可
查找和删除操作,通过散列函数找到对应的位置,然后遍历链表查找或者删除
如何设计散列函数
散列函数设计的好坏,决定了散列表冲突的概率大小,也就决定了散列表的性能。
1.散列表的设计不能太复杂
过于复杂的散列函数,肯定会消耗更多的计算时间。也就影响到散列表的性能。
2.散列表的值尽可能随机并且均匀分布,这样才能尽可能的减少散列冲突,即便出现冲突,散列到每个槽里的数据也会比较平均,不会出现某个槽内的数据特别多的情况。
装载因子太大怎么办
装载因子越大说明散列表中的元素越多,空闲的位置越少,出现散列冲突的概率就越大,插入数据需要经过很多次的寻址或者拉很长的链,查找过程也会很慢。
这个时候可以进行动态扩容,重新申请一个更大的散列表,将数据版移到新的散列表中。如果我们申请一个原来散列表两倍大小的空间,新的散列表的装载因子就是原来的一半了。
装载因子超过某个阈值的时候就开始扩容,所以装载因子的阈值的设定需要选择得当,需要权衡时间和空间的复杂度,如果内存紧张,对执行效率要求很高,可以降低装载因子的阈值,反之可以增大装载因子的阈值。
如何避免低效的扩容
散列表插入一个数据是很快的,但是假如某个插入操作正好达到了装载因子的阈值,需要对散列表进行扩容,这时候需要先扩容在重新计算哈希值并搬运到新的表中,如果数据特别大的情况下,这是非常耗时的。比如我们用一个软件正常都是挺好的,突然又一次卡主的现象,这种体验很不好。
为了解决这种一次性扩容耗时过多的情况,我们可以将扩容操作穿插在插入操作的过程中分批完成。当装载因子达到某个阈值的时候,我们只申请新的空间并不把老的数据搬移到新的散列表中。
当在有新的数据插入的时候,把新的数据插入到新的散列表中,并从旧的表中拿出一个来放到新的表中,重复上面的操作,经过多次插入操作之后老的散列表中的数据就慢慢搬移到新表中了。这样就把统一扩容的时间代价均摊了。
在这期间,如果有查询操作,先从新表中查找,如果没有,在去老的表中查找。
怎么选择冲突的解决方法
上面说了实际开发中经常用到的两种解决冲突的方法,开放寻址法和链表法,比如Java中的LinkedHashMap使用了链表法,ThreadLocalMap使用了线性探测来解决冲突。他们各有优势,那怎么选择呢?
1.开放寻址法
优点:
散列表中的数据都存在数组中,可以有效的利用CPU的缓存加快查询速度,序列化起来也比较简单。列表法有指针序列化起来麻烦
缺点:
删除数据的时麻烦,需要标记为deleted,所有的数据都存在数组中,相对于链表冲突的代价更高,所以使用装载因子的上限不能太大。而且比链表法更浪费内存空间。
当数据量比较小,装载因子小的时候,适合使用寻址法。
2.链表法
优点:
链表法对内存的利用率比寻址法高。因为链表节点可以在需要的时候在创建,不像寻址法那样实现申请好。
链表法对装载因子的容忍度更高,寻址法只能使用与装载因子小于1的情况,接近1的时候就可能有大量的散列冲突、从而导致大量的探测和在散列性能会下降。而链表法只要散列函数的值随机均匀就好,即使装载因子变成10,也就是链表的长度变的长了,查询虽然会有所下降但是相比于寻址法好很多
缺点:
因为链表需要存指针,所以对于比较小的对象的存储,是比较耗内存的。
因为链表中的节点是零散分部在内存中的,不是连续的,多以对CPU不友好,会影响一定的执行效率。
如果把链表换成一些高效的数据结构,如跳表,红黑树等,效率也会更高。
基于链表的散列冲突的处理方法适合存储大对象、大数据量的散列表。比开放寻址更加灵活,支持更多的优化策略。
Java HashMap
1.HashMap的初始大小是16,这个值是可以设置的,如果事先知道大概的数据量有多少,可以通过修改默认初始大小减少动态扩容的次数,这样会大大提高HashMap的性能。
2.最大装载因子默认是0.75,当HashMap中的元素个数超过0.75*capacity(容量)的时候就会启动扩容,每次扩大为原来的两倍
3.HashMap底层采用链表来解决冲突,即使负载因子和散列函数设计的再好也无法避免链表过长的情况,一旦链表过长会严重影响HashMap的性能。
所以在JDK1.8版本中,对HashMap做了优化,假如了红黑树,当链表长度太长默认超过8,链表就转为红黑树,可以利用红黑树快可快速增删查的特点提高HashMap的性能。当红黑树结点小于8的时候红黑树在转化为链表,因为在数据量较小的情况下,红黑树要维护平衡比起链表,性能提升并不明显。
4.散列函数的设计并不复杂,主球简单高效、均匀
int hash(Object key) {
int h = key.hashCode();
return (h ^ (h >>> 16)) & (capitity -1); //capicity 表示散列表的大小
}
//hashCode()返回的是java对象的hashcode,比如String的hashcode是下面
public int hashCode() {
int var1 = this.hash;
if(var1 == 0 && this.value.length > 0) {
char[] var2 = this.value;
for(int var3 = 0; var3 < this.value.length; ++var3) {
var1 = 31 * var1 + var2[var3];
}
this.hash = var1;
}
return var1;
}
5.get()方法:当我们调用 get() 方法,HashMap 会使用键对象的 hashcode 找到数组的对应位置,找到 位置之后,会调用 keys.equals() 方法去找到链表中正确的节点,最终找到要找的值对象。
LRU缓存淘汰算法
(1)通过链表实现LRU淘汰算法
维护一个按照访问时间从大到小的有序排列的链表结构,因为缓存的大小有限,当缓存空间不够的时候,直接将链表头部的节点删除。
当要缓存一个数据的时候,先去链表中查找是否有这个数据,如果没有就直接把数据放到链表的尾部,如果有就把它移动到链表的尾部。
从缓存中查找、添加、删除一个数据都会涉及到查找这个操作,由于查找数据需要遍历链表,所以单纯的使用链表实现的LRU缓存淘汰算法不是太高效。
(2)使用散列表和链表结合实现LRU淘汰算法
使用散列表和双向链表来实现。使用双向链表来存储数据,这里会有两种链
第一种是一个双向链表通过双向链表的前驱(prev)和后继(next)指针把所有的节点都串起来
第二种是散列表中为了解决散列冲突而形成的一个数据链,这时候给双向链表在加上一个后继指针(hnext),把散列表中的拉链串联起来。
Redis有序集合
在有序集合中,每个成员对象有两个重要的属性,key(键值),score(分值)。我们不仅会通过score来查找数据,也会通过key来查找数据
比如用户的积分排行榜,我们可以通过用户的id来查找积分信息,也可以通过积分区间来查找用户id和用户信息。用户id就是key,积分就是score
有序集合的操作一般有:
如果只是使用分值把成员组织成跳表结构,那么使用键值来删除、查询成员对象就会很慢。
我们可以按照键值构建一个散列表,这样按照key来删除、查找一个对象的效率就高了
Java LinkedHashMap
HashMap<Integer, Integer> m = new LinkedHashMap<>();
m.put(3, 11);
m.put(1, 12);
m.put(5, 23);
m.put(2, 22);
for (Map.Entry e : m.entrySet()) {
System.out.println(e.getKey());
}
上面这单代码打印的顺序是 3,1,5,2也就是按照数据的插入顺序来打印,散列表中的数据是经过散列函数打乱之后无规律存储的,这里为什么会按照插入的顺序打印呢?
LinkedHashMap也是通过散列表和链表组合在一起实现的。它不仅支持按照插入的顺序遍历数据,还支持按照访问顺序来遍历数据。
// 10 是初始大小,0.75 是装载因子,true 是表示按照访问时间排序
HashMap<Integer, Integer> m = new LinkedHashMap<>(10, 0.75f, true);
m.put(3, 11);
m.put(1, 12);
m.put(5, 23);
m.put(2, 22);
m.put(3, 26);
m.get(5);
for (Map.Entry e : m.entrySet()) {
System.out.println(e.getKey());
}
上面这段代码的打印顺序是 1,2,3,5。
每次执行put()函数,都会把数据添加到链表的尾部。
在执行第8行的时候,先查找这个键值是否已经有了,然后把(3,11)删除,并且把新的(3,26)放到链表的尾部。
在执行第9行的时候,把被访问的数据移动到链表的尾部。
其实其原理跟LRU缓存实现原理一样
LinkedHashMap是通过双向链表和散列表两种数据结构实现的。
为什么散列表和链表经常一块使用?
散列表虽然支持高效的插入、删除、查找操作,但是散列表中的数据都是通过散列函数打乱之后无规律存储的,无法支持按照某种顺序快速的遍历数据。如果想要按照顺序遍历散列表中的数据,需要把散列表中的数据拷贝到数组中,然后排序在遍历
散列表是动态的数据结构,会不停地插入、删除按顺序遍历散列表总的数据的时候,都需要先排序,那效率就低了,为了解决这个问题,我们把散列表和链表(或者跳表)结合起来使用。