数据结构——散列表(Hash Table)(哈希表)

散列表

散列表英文是hash table,经常被叫做Hash表,或者哈希表。

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

百度文库对散列表的解释:

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

散列表有两个核心问题:散列函数的设计散列冲突的解决

 

散列思想

举例:假如我们有 89 名选手参加学校运动会。为了方便记录成绩,每个选手胸前都会贴上自己的参赛号码。

参赛号码用 6 位数字来表示。比如 051167,其中,前两位 05 表示年级,中间两位 11 表示班级,最后两位是选手的编号 1 到 89。

我们该如何存储选手信息,才能够支持通过编号来快速查找选手信息呢?

我们可以截取参赛编号的后两位作为数组下标,来存取选手信息数据。

当通过参赛编号查询选手信息的时候,取参赛编号的后两位,作为数组下标,来读取数组中的数据。

这个例子就是经典的散列思想,参赛选手的编号我们叫作键(key)或者关键字。我们用它来标识一个选手。

我们把参赛编号转化为数组下标的映射方法就叫作散列函数(或“Hash 函数”“哈希函数”)。

而散列函数计算得到的值就叫作散列值(或“Hash 值”“哈希值”)。

散列函数

顾名思义,它是一个函数,它在散列表中起着至关重要的作用。

我们可以定义它为 hash(key),其中 key 表示元素的键值,hash(key) 的值表示经过散列函数计算得到的散列值。

如刚才的参赛选手例子,就可以写成一个散列函数

int hash(String key) {
  // 获取后两位字符
  string lastTwoChars = key.subString(length-2);
  // 将后两位字符转换为整数
  int hashValue = String.parseInt(lastTwoChars);
  return hashValue;
}

这只是一个非常简单的举例,这个散列函数比较简单。

散列函数设计的基本要求

一、 散列函数计算得到的散列值是一个非负整数;

二、 如果 key1 = key2,那 hash(key1) == hash(key2);

三、 如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)。

第一点很好理解,因为数组的下标是从0开始的。

第二点也好理解,相同的key经过散列函数得到的散列值也应该是相同的。

根据第二点来看,第三点也合情合理。但是实际情况下第三点很难做到。

不满足第三点的情况,我们叫做散列冲突(哈希冲突),所谓散列冲突就是不同键值的元素对应着相同的存储地址。

在真实的情况下,要想找到一个不同的 key 对应的散列值都不一样的散列函数,几乎是不可能的。

即便像业界著名的MD5、SHA、CRC等哈希算法,也无法完全避免这种散列冲突。

而且,因为数组的存储空间有限,也会加大散列冲突的概率。

如何设计散列函数

散列函数的设计不能太复杂

过于复杂的散列函数,势必会消耗很多计算时间,也就间接的影响到散列表的性能。

散列函数生成的值要尽可能随机并且均匀分布

这样才能避免或者最小化散列冲突

即便出现冲突,散列到每个槽里的数据也会比较平均,不会出现某个槽内数据特别多的情况。

 

散列函数的设计方法

散列函数的设计方法有很多,比如接寻址法、平方取中法、折叠法、随机数法等。

散列函数的设计方法

散列冲突

再好的散列函数也无法避免散列冲突,所以针对散列冲突问题,我们需要通过其他途径来解决。

我们常用的散列冲突解决方法有两类,开放寻址法(open addressing)和链表法(chaining)。

开放寻址法

核心思想:如果出现了散列冲突,我们就重新探测一个空闲位置,将其插入。

如何探测新的位置?

根据探测方法又分为线性探测(Linear Probing)、二次探测(Quadratic probing)和双重散列(Double hashing)。

 

线性探测

当我们往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了。

我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。

散列表的大小为 10,在元素 x 插入散列表之前,已经 6 个元素插入到散列表中。x 经过 Hash 算法之后,被散列到位置下标为 7 的位置,但是这个位置已经有数据了,所以就产生了冲突。于是我们就顺序地往后一个一个找,看有没有空闲的位置,直到找到空闲位置 2,于是将其插入到这个位置。

在散列表中查找元素的过程有点儿类似插入过程。

我们通过散列函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素。如果相等,则说明就是我们要找的元素;否则就顺序往后依次查找。

***如果遍历到数组中的空闲位置,还没有找到,就说明要查找的元素并没有在散列表中。***

在查找的时候,一旦我们通过线性探测方法,找到一个空闲位置,我们就可以认定散列表中不存在这个数据。

但是,如果这个空闲位置是我们后来删除的,就会导致原来的查找算法失效。本来存在的数据,会被认定为不存在。

对于使用线性探测法解决冲突的散列表,删除操作稍微有些特别。我们不能单纯地把要删除的元素设置为空。

我们可以将删除的元素特殊标记为 deleted。线性探测查找的时候,遇到标记为 deleted 的空间不会停下来,而是继续往下探测。

线性探测法的问题

当散列表中插入的数据越来越多时,散列冲突发生的可能性就会越来越大,空闲位置会越来越少,线性探测的时间就会越来越久。

极端情况下,我们可能需要探测整个散列表,所以最坏情况下的时间复杂度为 O(n)。(删除、查找操作同理)

二次探测

二次探测类似于线性探测。

线性探测每次探测的步长是 1,那它探测的下标序列就是 hash(key)+0,hash(key)+1,hash(key)+2……

而二次探测探测的步长就变成了原来的“二次方”。

它探测的下标序列就是 hash(key)+0,hash(key)+1²,hash(key)+2²……

双重散列

双重的意思是就是不仅要使用一个散列函数。我们使用一组散列函数 hash1(key),hash2(key),hash3(key)……

我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。

不论是哪一种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高。

为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表中有一定比例的空闲槽位。

我们用装载因子(load factor)来表示空位的多少 (装载因子/加载因子/负载因子)

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

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

链表法

链表法是一种更加常用的散列冲突解决办法,相比开放寻址法,要简单很多。

在散列表中,每个“桶(bucket)”或者“槽(slot)”会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中。

插入的时间复杂度是 O(1)。

当查找、删除一个元素时,我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除。

查找和删除操作的时间复杂度跟链表的长度k成正比。也就是O(k)。

于散列比较均匀的散列函数来说,理论上讲,k=n/m,其中 n 表示散列中数据的个数,m 表示散列表中“槽”的个数。

装载因子过大怎么办

装载因子越大,说明散列表中的元素越多,空闲位置越少,散列冲突的概率就越大。

不仅插入数据的过程要多次寻址或者拉很长的链,查找的过程也会因此变得很慢。

对于没有频繁插入和删除的静态数据集合来说,

我们很容易根据数据的特点、分布等,设计出完美的、极少冲突的散列函数,因为毕竟之前数据都是已知的。

对于动态散列表来说,数据集合是频繁变动的,我们事先无法预估将要加入的数据个数,所以我们也无法事先申请一个足够大的散列表。

随着数据慢慢加入,装载因子就会慢慢变大。当装载因子大到一定程度之后,散列冲突就会变得不可接受。

数组、栈、队列都支持动态扩容,

其实针对散列表,当装载因子过大时,我们也可以进行动态扩容,重新申请一个更大的散列表,将数据搬移到这个新散列表中。

假设每次扩容我们都申请一个原来散列表大小两倍的空间。

如果原来散列表的装载因子是 0.8,那经过扩容之后,新散列表的装载因子就下降为原来的一半,变成了 0.4。

针对数组的扩容,数据搬移操作比较简单。

但是,针对散列表的扩容,数据搬移操作要复杂很多。

因为散列表的大小变了,数据的存储位置也变了,所以我们需要通过散列函数重新计算每个数据的存储位置。

插入一个数据,最好情况下,不需要扩容,最好时间复杂度是 O(1)。

最坏情况下,散列表装载因子过高,启动扩容,我们需要重新申请内存空间,重新计算哈希位置,并且搬移数据,所以时间复杂度是 O(n)。

均摊情况下,时间复杂度接近最好情况,就是 O(1)。

对于动态的散列表来说,有时候随着数据的删除,散列表中的数据会越来越少,空闲空间会越来越多。

如果对空间消耗非常敏感,我们可以在装载因子小于某个值之后,启动动态缩容。

如果我们更加在意执行效率,能够容忍多消耗一点内存空间,可以不必如此。

当散列表的装载因子超过某个阈值时,就需要进行扩容。装载因子的阈值一定要选择得当。

如果太大,会导致冲突过多;如果太小,会导致内存浪费严重。

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

如果内存空间紧张,对执行效率要求又不高,可以增加负载因子的值,甚至可以大于 1。

避免底效扩容

散列表插入一个数据很快,但是特殊情况下,装载因子到达阈值,就需要先进行扩容,再插入数据。

这时候插入数据就会变得很慢。甚至令人无法接受。举一个极端的例子:

如果散列表当前大小为 1GB,要想扩容为原来的两倍大小,

那就需要对 1GB 的数据重新计算哈希值,并且从原来的散列表搬移到新的散列表。这个过程十分耗时。

为了避免这种极个别非常慢的插入操作,就要避免这种“一次性”的扩容机制。

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

这种形式被叫做“渐进式迁移”。

当装载因子触达阈值之后,我们只申请新空间,但并不将老的数据搬移到新散列表中。

当有新数据要插入时,我们将新数据插入新散列表中,并且从老的散列表中拿出一个数据放入到新散列表。

每次插入一个数据到散列表,我们都重复上面的过程。

经过多次插入操作之后,老的散列表中的数据就一点一点全部搬移到新散列表中了。

通过这样均摊的方法,将一次性扩容的代价,均摊到多次插入操作中,就避免了一次性扩容耗时过多的情况。这

种实现方式,任何情况下,插入一个数据的时间复杂度都是 O(1)。

但是这种做法会对查询造成一定影响,我们按照以前的查询方式肯定是行不通的

对于查询操作,为了兼容了新、老散列表中的数据,我们先从新散列表中查找,如果没有找到,再去老的散列表中查找。

如何选择解决冲突的方法

开放寻址法和链表法,这两种冲突解决办法在实际的软件开发中都非常常用。

Java 中 LinkedHashMap 就采用了链表法解决冲突,ThreadLocalMap 是通过线性探测的开放寻址法来解决冲突。

开放寻址法的优点

开放寻址法不需要拉很多链表。散列表中的数据都存储在数组中,可以有效地利用 CPU 缓存加快查询速度。

开放寻址法实现的散列表,序列化起来比较简单。链表法包含指针,序列化起来就没那么容易。

开放寻址法的缺点

用开放寻址法解决冲突的散列表,删除数据的时候比较麻烦,需要特殊标记已经删除掉的数据。

在开放寻址法中,所有的数据都存储在一个数组中,比起链表法来说,冲突的代价更高。

正因为冲突的代价高,装载因子的上限不能太大。这也导致这种方法比链表法更浪费内存空间。

开放寻址法只能适用装载因子小于 1 的情况。

接近 1 时,就可能会有大量的散列冲突,导致大量的探测、再散列等,性能会下降很多。

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

这也是 Java 中的ThreadLocalMap使用开放寻址法解决散列冲突的原因。

链表法的优点

链表法对内存的利用率比开放寻址法要高。因为链表结点可以在需要的时候再创建,并不需要像开放寻址法那样事先申请好。

比起开放寻址法,链表法对大装载因子的容忍度更高。

对于链表法来说,只要散列函数的值随机均匀,即便装载因子变成 10,也就是链表的长度变长了而已。

虽然查找效率有所下降,但是比起顺序查找还是快很多。

链表法的缺点

链表因为要存储指针,所以对于比较小的对象的存储,是比较消耗内存的,还有可能会让内存的消耗翻倍。

因为链表中的结点是零散分布在内存中的,不是连续的,所以对 CPU 缓存是不友好的,这方面对于执行效率也有一定的影响。


如果我们存储的是大对象,也就是说要存储的对象的大小远远大于一个指针的大小(4 个字节或者 8 个字节)

那链表中指针的内存消耗在大对象面前就可以忽略了。

我们将链表法中的链表改造为其他高效的动态数据结构,比如跳表、红黑树。这样,即便出现散列冲突。

极端情况下,所有的数据都散列到同一个桶内,那最终退化成的散列表的查找时间也只不过是 O(logn)。

这样可以有效避免了散列碰撞攻击。

总结:基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表。

而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表。

散列碰撞攻击

在极端情况下,有些恶意的攻击者,还有可能通过精心构造的数据,使得所有的数据经过散列函数之后,都散列到同一个槽里。如果我们使用的是基于链表的冲突解决方法,那这个时候,散列表就会退化为链表,查询的时间复杂度就从 O(1) 急剧退化为 O(n)。

如果散列表中有 10 万个数据,退化后的散列表查询的效率就下降了 10 万倍。更直接点说,如果之前运行 100 次查询只需要 0.1 秒,那现在就需要 1 万秒。这样就有可能因为查询操作消耗大量 CPU 或者线程资源,导致系统无法响应其他请求,从而达到拒绝服务攻击(DoS)的目的。

这也就是散列表碰撞攻击的基本原理。

发布了91 篇原创文章 · 获赞 22 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/qq_42006733/article/details/104541952