Java数据结构 - 散列表原理

Java数据结构 - 散列表原理

数据结构与算法目录(https://www.cnblogs.com/binarylei/p/10115867.html)

散列表(Hash table) 也叫哈希表,借助散列函数对数组进行扩展,利用的是数组支持按照下标随机访问元素的特性。散列表两个核心问题是散列函数散列冲突。散列函数有 MD5、SHA、CRC 等哈希算法。散列冲突开放寻址法和链表法两种。

几种典型数据结构特点总结:

  • 数组的特点:随机访问容易,插入和删除困难,而且能充分利用 CPU 缓存进行预卖。
  • 链表的特点:随机访问困难,插入和删除容易。
  • 散列表的特点:结合了两者的优势,寻址容易,插入删除也容易。但数据经过 hash 散列后是无序的。
  • 散列表 + 链表结构的特点:在软件开发过程中经常组合使用。结合了散列表和链表的优势,查找、插入和删除的时间复杂度都是 O(1),同时也支持顺序访问。如基于链表的 LRU 时间复杂度为 O(n),但如果改成基于散列表 + 链表则时间复杂度为 O(1)。

1. 散列函数

散列函数设计的好坏决定了散列冲突的概率,也就决定散列表的性能。散列函数设计的基本要求:

  1. 散列函数计算得到的散列值是一个非负整数。因为数组下标是从 0 开始的。
  2. 如果 key1 = key2,那 hash(key1) == hash(key2)。
  3. 如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)。

虽然说,我们希望不同的 key 通过 hash 函数后生成不同的散列值。但真实情况是,要想找到一个不同的 key 对应的散列值都不一样的散列函数,几乎是不可能的。即便像业界著名的 MD5、SHA、CRC 等哈希算法,也无法完全避免这种散列冲突。而且,因为数组的存储空间有限,也会加大散列冲突的概率。

散列函数除了满足了以上的基本要求外,那什么才是好的散列函数呢?

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

1.1 直接定址法

关键码本身和地址之间存在某个线性函数关系时,散列函数取为关键码的线性函数,即:hash(key) = a * key + b(a、b 均为常数)。

这样的散列函数优点就是简单、均匀,也不会产生冲突,但问题是这需要事先知道关键字的分布情况,适合査找表较小且连续的情况。由于这样的限制,在现实应用中虽然简单,但却并不常用。

1.2 余数法

通过选择适当的正整数 p,按计算公式 hash(K) = K % p 来计算关键码 K 的散列地址。这种方法计算最简单,也不需根据全部关键码的分布情况研究如何从中析取数据,最常用。

1.3 平方取中法

将关键码 K 平方,取 K^2 中间几位作为其散列地址 hash(K) 的值。

假如有以下关键字序列 {421,423,436},平方之后的结果为 {177241,178929,190096},那么可以取 {72,89,00} 作为 Hash 地址。

1.4 随机数法

采用随机函数作为散列函数 hash(Key) = random(Key),其中 random 为随机函数。当关键码长度不等时,采用该方法较恰当。

2. 散列冲突

hash 冲突不可避免,怎么解决呢?业界主要有以下方法:开放定址法和拉链法。

  • 缓冲区:建立一个缓冲区,把凡是 Hash(key) 重复的元素放到缓冲区中。当通过 key 查找时,发现找的不对,就在缓冲区里找。很少用。
  • 开放定址法(open addressing):如果出现了散列冲突,就重新探测一个空闲位置。根据重新探测的方式,又可以分为线性探测、二次探测、双重哈希三种。
    • 线性探测(Linear Probing):从发生冲突位置依次往后查找空闲位置。线性探测会导致数据集中到某一块区域。
    • 二次探测(Quadratic probing):如果说线性探测每次探测的步长是 1,即线性探测的下标序列就是 hash(key) + 0,hash(key) + 1,hash(key) + 2……。而二次探测探测的步长就变成了原来的“二次方”,也就是说,它探测的下标序列就是 hash(key) + 02,hash(key) + 12,hash(key) + 22……。
    • 双重哈希(Double hashing):前面的两种探测方式都只使用一个散列函数,而双重哈希则会使用一组散列函数 hash1(key),hash2(key),hash3(key)……我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。
  • 拉链法(chaining):链表法是一种更加常用的散列冲突解决办法,相比开放寻址法,它要简单很多。使用链表将所有散列值相同的元素我们都放到相同槽位对应的链表中。当然,这个链表可能为简单链表,也可能是红黑树,如 HahMap。

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

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

2.1 线性探测

我们分析一下线性探测是如何进行元素的插入、删除、查找。

(1)插入

如上图所示,如果 a1, a2 二个元素的 hash 值相等。如果要插入元素 a2,则会与元素 arr[1] = a1 发生 hash 碰撞,从 a1 开始一直往后查找空闲位置 arr[3],然后将 a2 放到 arr[3] 的位置即可。如果一直查找到数组尾 arr[ ] 还是无法查找到空闲位置,则从数组头 arr[0] 重新开始遍历。

需要注意的是:后面的 arr[2] = b 可能是与 arr[1] = a1 发生 hash 碰撞的元素,也可能是 hash 值本身就是 arr[2] 的元素。

(2)删除

当我们要删除元素时,不能从数组中直接设置为 null,而只能标记为 deleted。我们在插入时已经提到,探测到 null 元素时则停止探测,如果设置为 null 则会导致后面的元素失联。既然不能直接删除元素,只能先标记为 deleted,那数组中的元素都标记为 deleted 后怎么办呢?

当数组中的 deleted 比较多时,可以集中删除一次。但删除元素需要对之后的元素进行搬移,需要将之后的元素搬移到正确的位置,保证相同 hash 的元素不被 null 分隔。这部分比较复杂,可以参考一下 ThreadLocal 的实现方式。

(3)查找

同数据插入一样,从 hash 值开始查找,直到找到对应 key 的元素或为 null 的元素为止。这也是为什么不能直接将删除的元素设置为 null 的原因。

2.2 拉链法

链表法相比开放寻址法,要更简单、常用,如 HashMap 就是使用链表,不过 JDK8 中当链表的长度比较长时,会由普通链表转换为红黑树结构。

2.3 线性探测 vs 拉链法

开放寻址法和链表法。这两种冲突解决办法在实际的软件开发中都非常常用。比如,LinkedHashMap 就采用了链表法解决冲突,ThreadLocalMap 是通过线性探测的开放寻址法来解决冲突。那你知道,这两种冲突解决方法各有什么优势和劣势,又各自适用哪些场景吗?

(1)开放寻址法优缺点

  • 开放寻址法优点:

    • 散列表中的数据都存储在数组中,可以有效地利用 CPU 缓存加快查询速度。
    • 数组实现的散列表,序列化起来比较简单。链表法包含指针,序列化起来就没那么容易。你可不要小看序列化,很多场合都会用到的。
  • 开放寻址法有缺点:

    • 用开放寻址法解决冲突的散列表,删除数据的时候比较麻烦,需要特殊标记已经删除掉的数据。
    • 在开放寻址法中,所有的数据都存储在一个数组中,比起链表法来说,冲突的代价更高。
    • 装载因子的上限不能太大,导致这种方法比链表法更浪费内存空间。

总结: 当数据量比较小、装载因子小的时候,适合采用开放寻址法。这也是 ThreadLocalMap 使用开放寻址法解决散列冲突的原因。

(2)拉链法优缺点

  • 拉链法优点:

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

    • 链表法比起开放寻址法,对大装载因子的容忍度更高。开放寻址法只能适用装载因子小于 1 的情况。接近 1 时,就可能会有大量的散列冲突,导致大量的探测、再散列等,性能会下降很多。但是对于链表法来说,只要散列函数的值随机均匀,即便装载因子变成 10,也就是链表的长度变长了而已,虽然查找效率有所下降,但是比起顺序查找还是快很多。

    • 可以将链表改造为其他高效的动态数据结构,比如跳表、红黑树。这样,即便出现散列冲突,极端情况下,所有的数据都散列到同一个桶内,那最终退化成的散列表的查找时间也只不过是 O(logn)。

  • 拉链法缺点:

    • 链表因为要存储指针,所以对于比较小的对象的存储,是比较消耗内存的,还有可能会让内存的消耗翻倍。但如果我们存储的是大对象,此时存储对象远远大于指针大小(4 byte 或 8 byte),那链表中指针的内存消耗在大对象面前就可以忽略了。
    • 因为链表中的结点是零散分布在内存中的,不是连续的,所以对 CPU 缓存是不友好的,这方面对于执行效率也有一定的影响。

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

3. 场景应用

场景应用1:Word 文档中单词拼写检查功能是如何实现的?

常用的英文单词有 20 万个左右,假设单词的平均长度是 10 个字母,平均一个单词占用 10 个字节的内存空间,那 20 万英文单词大约占 2MB 的存储空间,就算放大 10 倍也就是 20MB。对于现在的计算机来说,这个大小完全可以放在内存里面。所以我们可以用散列表来存储整个英文单词词典。


每天用心记录一点点。内容也许不重要,但习惯很重要!

posted on 2020-03-07 09:56  binarylei  阅读(830)  评论(1编辑  收藏  举报

导航