数据结构与算法之美 - 哈希表与哈希算法

497 阅读11分钟

哈希表(散列表)

哈希表(Hash table,也叫散列表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。

hash就是找到一种数据内容和数据存放地址之间的映射关系。

当使用哈希表hashtable(key,value) 进行查询的时候,就是使用哈希函数将关键码key转换为对应的数组下标,并定位到该空间获取value,如此一来,就可以充分利用到数组的定位性能进行数据定位。

Hash Table的查询速度非常的快,几乎是**O(1)**的时间复杂度。

散列函数设计

  • 散列函数的设计不能太复杂。过于复杂的散列函数,势必会消耗很多计算时间,也就间接的影响到散列表的性能。
  • 散列函数生成的值要尽可能随机并且均匀分布。这样才能避免或者最小化散列冲突,而且即便出现冲突,散列到每个槽里的数据也会比较平均,不会出现某个槽内数据特别多的情况。

应用

  • 将散列表用于查找,例如:DNS解析
  • 防止重复
  • 将散列表用作缓存

哈希冲突

哈希冲突(hash collision):哈希函数将两个不同的键映射到同一个索引的情况。

哈希冲突是不可避免的,如果遇到冲突,最常用的解决办法就是开放定址法链地址法

开放定址法

开放定址法是遇到冲突的时候,顺着原来哈希地址查找下一个空闲地址然后插入。

一般情况下,我们会尽可能保证散列表中有一定比例的空闲槽位。我们用装填因子(load factor)来表示空位的多少。散列表的装填因子 = 填入表中的元素个数 / 散列表的长度

开放定址法.png

优点:

  • 可以有效地利用 CPU 缓存加快查询速度
  • 序列化起来比较简单

缺点:

  • 删除数据的时候比较麻烦,需要特殊标记已经删除掉的数据。
  • 装载因子的上限不能太大,这也导致这种方法比链表法更浪费内存空间

总结:

当数据量比较小、装载因子小的时候,适合采用开放寻址法。

链地址法

链地址法的原理时如果遇到冲突,他就会在原地址新建一个空间,然后以链表结点的形式插入到该空间。

链地址法.png

优点:

  • 链表法对内存的利用率比开放寻址法要高
  • 对大装载因子的容忍度更高。

缺点:

  • 对于比较小的对象的存储,是比较消耗内存的,还有可能会让内存的消耗翻倍。
  • 因为链表中的结点是零散分布在内存中的,不是连续的,所以对 CPU 缓存是不友好的,这方面对于执行效率也有一定的影响。

总结:

基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表。

装填因子过大怎么办?

装载因子越大,说明散列表中的元素越多,空闲位置越少,散列冲突的概率就越大。不仅插入数据的过程要多次寻址或者拉很长的链,查找的过程也会因此变得很慢。

针对散列表,当装载因子过大时,我们可以进行动态扩容,重新申请一个更大的散列表,将数据搬移到这个新散列表中。因为散列表的大小变了,数据的存储位置也变了,所以我们需要通过散列函数重新计算每个数据的存储位置。

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

装载因子阈值的设置要权衡时间、空间复杂度。如果内存空间不紧张,对执行效率要求很高,可以降低负载因子的阈值;相反,如果内存空间紧张,对执行效率要求又不高,可以增加负载因子的值,甚至可以大于 1。

如何避免低效地扩容?

为了解决一次性扩容耗时过多的情况,我们可以将扩容操作穿插在插入操作的过程中,分批完成。

  • 当装载因子触达阈值之后,我们只申请新空间,但并不将老的数据搬移到新散列表中。
  • 当有新数据要插入时,我们将新数据插入新散列表中,并且从老的散列表中拿出一个数据放入到新散列表。
  • 每次插入一个数据到散列表,我们都重复上面的过程。
  • 经过多次插入操作之后,老的散列表中的数据就一点一点全部搬移到新散列表中了。这样没有了集中的一次性数据搬移,插入操作就都变得很快了。

通过这样均摊的方法,将一次性扩容的代价,均摊到多次插入操作中,就避免了一次性扩容耗时过多的情况。这种实现方式,任何情况下,插入一个数据的时间复杂度都是 O(1)。

工业级散列表举例分析:Java 中的 HashMap

1. 初始大小

HashMap 默认的初始大小是 16,当然这个默认值是可以设置的,如果事先知道大概的数据量有多大,可以通过修改默认初始大小,减少动态扩容的次数,这样会大大提高 HashMap 的性能。

2. 装载因子和动态扩容

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

3. 散列冲突解决方法

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

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

4. 散列函数

散列函数的设计并不复杂,追求的是简单高效、分布均匀。

int hash(Object key) {
	int h = key.hashCode(); # Java 对象的 hash code
	return (h ^ (h >>> 16)) & (capitity -1); //capicity 表示散列表的大小
}

性能

在平均情况下,散列表执行各种操作的时间都为O(1)。O(1)被称为常量时间。你以前没有见过常量时间,它并不意味着马上,而是说不管散列表多大,所需的时间都相同。

在最糟情况下,散列表所有操作的运行时间都为O(n)——线性时间,这真的很慢。我们来将散列表同数组和链表比较一下。

数组链表散列表平均情况散列表最糟情况
查找O(1)O(n)O(1)O(n)
插入O(n)O(1)O(1)O(n)
删除O(n)O(1)O(1)O(n)

在平均情况下,散列表的查找(获取给定索引处的值)速度与数组一样快,而插入和删除速度与链表一样快,因此它兼具两者的优点!但在最糟情况下,散列表的各种操作的速度都很慢。因此,在使用散列表时,避开最糟情况至关重要。为此,需要避免冲突。而要避免冲突,需要有:

  • 较低的填装因子
    • 填装因子:散列表元素数 / 位置总数
    • 填装因子大于1意味着元素数量超过了数组的位置数。
    • 一旦填装因子开始增大,你就需要在散列表中添加位置,这被称为调整长度(resizing),通常将数组增长一倍。
    • 一个不错的经验规则是:一旦填装因子大于0.7,就调整散列表的长度。
  • 良好的散列函数
    • 最理想的情况是:散列函数将键均匀地映射到散列表的不同位置。
    • 如果散列表存储的链表很长,散列表的速度将急剧下降。然而,如果使用的散列函数很 好,这些链表就不会很长!

哈希算法

什么是哈希算法?

将任意长度的二进制值串映射为固定长度的二进制值串,这个映射的规则就是哈希算法,而通过原始数据映射之后得到的二进制值串就是哈希值。

一个优秀的哈希算法所需要满足的要求:

  • 从哈希值不能反向推导出原始数据(所以哈希算法也叫单向哈希算法)
  • 对输入数据非常敏感,哪怕原始数据只修改了一个 Bit,最后得到的哈希值也大不相同
  • 散列冲突的概率要很小,对于不同的原始数据,哈希值相同的概率非常小
  • 哈希算法的执行效率要尽量高效,针对较长的文本,也能快速地计算出哈希值

哈希算法的应用

一、安全加密

常见的加密算法:

  • MD5(MD5 Message-Digest Algorithm,MD5 消息摘要算法)
  • SHA(Secure Hash Algorithm,安全散列算法)
  • DES(Data Encryption Standard,数据加密标准)
  • AES(Advanced Encryption Standard,高级加密标准)

对用于加密的哈希算法来说,有两点格外重要。第一点是很难根据哈希值反向推导出原始数据,第二点是散列冲突的概率要很小。实际上,不管是什么哈希算法,我们只能尽量减少碰撞冲突的概率,理论上是没办法做到完全不冲突的(鸽巢原理)。一般情况下,哈希值越长的哈希算法,散列冲突的概率越低。

即便哈希算法存在散列冲突的情况,但是因为哈希值的范围很大,冲突的概率极低,在有限的时间和资源下,哈希算法还是被很难破解的。

二、唯一标识

哈希算法可以对大数据做信息摘要,通过一个较短的二进制编码来表示很大的数据。

三、数据校验

用于校验数据的完整性和正确性

我们通过哈希算法,对 100 个文件块分别取哈希值,并且保存在种子文件中。我们在前面讲过,哈希算法有一个特点,对数据很敏感。只要文件块的内容有一丁点儿的改变,最后计算出的哈希值就会完全不同。所以,当文件块下载完成之后,我们可以通过相同的哈希算法,对下载好的文件块逐一求哈希值,然后跟种子文件中保存的哈希值比对。如果不同,说明这个文件块不完整或者被篡改了,需要再重新从其他宿主机器上下载这个文件块。

四、散列函数

相对于哈希算法的其他应用,散列函数对于散列算法冲突的要求要低很多。即便出现个别散列冲突,只要不是过于严重,我们都可以通过开放寻址法或者链表法解决。

散列函数对于散列算法计算得到的值,是否能反向解密也并不关心。

散列函数中用到的散列算法,更加关注散列后的值是否能平均分布

散列函数执行的快慢,也会影响散列表的性能,所以,散列函数用的散列算法一般都比较简单,比较追求效率。

五、负载均衡

利用哈希算法替代映射表,可以实现一个会话粘滞的负载均衡策略。

六、数据分片

通过哈希算法对处理的海量数据进行分片,多机分布式处理,可以突破单机资源的限制

七、分布式存储

利用一致性哈希算法,可以解决缓存等分布式系统的扩容、缩容导致数据大量搬移的难题。