散列思想
散列表的英文叫 “Hash Table” ,我们平时也叫它 “ 哈希表 ” 或者 “Hash 表 ” 。
散列冲突
开放寻址法
开放寻址法的核心思想是,如果出现了散列冲突,我们就重新探测一个空闲位置,将其插入。讲一个比较简单的探测方法,线性探测(Linear Probing)。
优点
- 散列表中的数据都存储在数组中,可以有效地利用CPU缓存加快查询速度。
- 这种方法实现的散列表,序列化起来比较简单。
缺点
- 删除数据的时候比较麻烦,需要特殊标记已经删除掉的数据。
- 所有的数据都存储在一个数组中,比起链表法来说,冲突的代价更高。所以,使用开放寻址法解决冲突的散列表,装载因子的上限不能太大,比链表法更浪费内存空间。
线性探测(Linear Probing)
当我们往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。
除了线性探测方法之外,还有另外两种比较经典的探测方法,二次探测(Quadratic probing)和双重散列(Double hashing)。
所谓二次探测,跟线性探测很像,线性探测每次探测的步长是 1 ,那它探测的下标序列就是 hash(key)+0 , hash(key)+1 , hash(key)+2…… 而二次探测探测的步长就变 成了原来的“二次方”,也就是说,它探测的下标序列就是hash(key)+0,hash(key)+1 2 ,hash(key)+2 2
所谓双重散列,意思就是不仅要使用一个散列函数。我们使用一组散列函数 hash1(key) , hash2(key) , hash3(key)…… 我们先用第一个散列函数,如果计算得到的存 储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。
不管采用哪种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证 散列表中有一定比例的空闲槽位。我们用装载因子(load factor)来表示空位的多少。
装载因子的计算公式是:
散列表的装载因子=填入表中的元素个数/散列表的长度
装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。
链表法
当插入的时候,我们只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,所以插入的时间复杂度是O(1)。当查找、删除一个元素时,我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除。
优点
- 链表法对内存的利用率比开放寻址法要高。因为链表结点可以在需要的时候再创建
- 链表法比起开放寻址法,对大装载因子的容忍度更高
如何设计散列函数?
在极端情况下,有些恶意的攻击者,还有可能通过精心构造的数据,使得所有的数据经过散列函数之后,都散列到同一个槽里。如果我们使用的是基于链表的冲 突解决方法,那这个时候,散列表就会退化为链表,查询的时间复杂度就从 O(1) 急剧退化为 O(n) 。这也就是散列表碰撞攻击的基本原理。
散列函数基本原则
- 散列函数的设计不能太复杂
- 手机号码前几位重复的可能性很大,但是后面几位就比较随机。叫作 “ 数据分析法 ” 。
- ASCll码值“进位”,
hash("nice")=(("n" - "a") * 26*26*26 + ("i" - "a")*26*26 + ("c" - "a")*26+ ("e"-"a")) / 78978
- 装载因子过大时,进行动态扩容
- 装载因子阈值需要选择得当。如果太大,会导致冲突过多;如果太小,会导致内存浪费严重
如何避免低效地扩容?
为了解决一次性扩容耗时过多的情况,我们可以将扩容操作穿插在插入操作的过程中,分批完成。当装载因子触达阈值之后,我们只申请新空间,但并不将老的数据搬移到新散列表中。
当有新数据要插入时,我们将新数据插入新散列表中,并且从老的散列表中拿出一个数据放入到新散列表。每次插入一个数据到散列表,我们都重复上面的过 程。经过多次插入操作之后,老的散列表中的数据就一点一点全部搬移到新散列表中了。这样没有了集中的一次性数据搬移,插入操作就都变得很快了。
工业级散列表举例分析
- 初始大小 HashMap 默认的初始大小是 16 ,当然这个默认值是可以设置的,如果事先知道大概的数据量有多大,可以通过修改默认初始大小,减少动态扩容的次数,这样会 大大提高 HashMap 的性能。
- 装载因子和动态扩容 最大装载因子默认是 0.75 ,当 HashMap 中元素个数超过 0.75*capacity ( capacity 表示散列表的容量)的时候,就会启动扩容,每次扩容都会扩容为原来的两倍大小。
- 散列冲突解决方法 HashMap 底层采用链表法来解决冲突。即使负载因子和散列函数设计得再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响 HashMap 的性 能。 于是,在 JDK1.8 版本中,为了对 HashMap 做进一步优化,我们引入了红黑树。而当链表长度太长(默认超过 8 )时,链表就转换为红黑树。我们可以利用红黑树快 速增删改查的特点,提高 HashMap 的性能。当红黑树结点个数少于 8 个的时候,又会将红黑树转化为链表。因为在数据量较小的情况下,红黑树要维护平衡,比起 链表来,性能上的优势并不明显。
- 散列函数 散列函数的设计并不复杂,追求的是简单高效、分布均匀。我把它摘抄出来,你可以看看。
int hash(Object key) {
int h = key.hashCode();
// 是将高 16 位移到低 16 位,这样计算出来的整型值将 “ 具有 ” 高位和低位的性质
// 因为 A % B = A & (B - 1) ,所以, (h ^ (h >>> 16)) & (capitity -1) = (h ^ (h >>> 16)) % capitity
return (h ^ (h >>> 16)) & (capitity -1); //capicity表示散列表的大小
}
其中, hashCode() 返回的是 Java 对象的 hash code 。比如 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;
}
总结
结合已经学习过的散列知识,我觉得应该有这样几点要求:
- 支持快速的查询、插入、删除操作;
- 内存占用合理,不能浪费过多的内存空间;
- 性能稳定,极端情况下,散列表的性能也不会退化到无法接受的情况。
如何实现这样一个散列表呢?根据前面讲到的知识,我会从这三个方面来考虑设计思路:
- 设计一个合适的散列函数;
- 定义装载因子阈值,并且设计动态扩容策略;
- 选择合适的散列冲突解决方法。
为什么散列表和链表经常会一起使用?
LRU 缓存淘汰算法
首先,我们来看如何查找一个数据。我们前面讲过,散列表中查找数据的时间复杂度接近O(1),所以通过散列表,我们可以很快地在缓存中找到一个数据。当找 到数据之后,我们还需要将它移动到双向链表的尾部。 其次,我们来看如何删除一个数据。我们需要找到数据所在的结点,然后将结点删除。借助散列表,我们可以在O(1)时间复杂度里找到要删除的结点。因为我们 的链表是双向链表,双向链表可以通过前驱指针 O(1) 时间复杂度获取前驱结点,所以在双向链表中,删除结点只需要 O(1) 的时间复杂度。
最后,我们来看如何添加一个数据。添加数据到缓存稍微有点麻烦,我们需要先看这个数据是否已经在缓存中。如果已经在其中,需要将其移动到双向链表的尾 部;如果不在其中,还要看缓存有没有满。如果满了,则将双向链表头部的结点删除,然后再将数据放到链表的尾部;如果没有满,就直接将数据放到链表的尾 部。
这整个过程涉及的查找操作都可以通过散列表来完成。其他的操作,比如删除头结点、链表尾部插入数据等,都可以在 O(1) 的时间复杂度内完成。所以,这三个 操作的时间复杂度都是 O(1) 。
至此,我们就通过散列表和双向链表的组合使用,实现了一个高效的、支持 LRU 缓存淘汰算法的缓存系统原型。
Java LinkedHashMap
实际上,LinkedHashMap是通过双向链表和散列表这两种数据结构组合实现的。LinkedHashMap中的“Linked”实际上是指的是双向链表,并 非指用链表法解决散列冲突。