数据结构与算法学习之路6--散列表

215 阅读5分钟

散列表的定义

散列表又称哈希表,它是在数组的基础上演化而来的一种数据结构。它使用数组支持按照下标快速访问随机数据的特性,能够通过给定的关键字的值直接访问到具体对应的值。通常我们把关键字称为key,对应的值称为value,将关键字转化为数组下标的映射方法称为散列函数(也称哈希函数),通过散列函数获取到的值称为散列值(也称哈希值)。

因为散列表使用了数组的特性,所以访问的时间复杂度为O(1)。

散列函数

通过散列函数我们把key转化为数组下标,散列函数需要满足几个设计原则:

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

其中第三条属于理想状态,要想找到一个不同的key对应的散列值都不一样的散列函数,几乎是不可能的,因为数组的存储空间有限所以也会加重出现重复的概率。我们把这种出现不同key得到同一个散列值的现象称为散列冲突。针对散列冲突我们需要对应的解决方案。

散列冲突解决

我们常用的解决散列冲突的方案有两种:开放寻址法和链表法

开放寻址法

开放寻址法的核心思想就是如果出现了散列冲突就重新探测一个空闲的位置将其插入。 探测空闲位置的方法有线性探测、二次探测和双重散列

  • 线性探测就是如果某个数据经过散列函数散列之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。
  • 二次探测和线性探测类似,但是二次探测的步长就变成了原来的“二次方”
  • 双重散列意思就是不仅要使用一个散列函数。我们使用一组散列函数 hash1(key),hash2(key),hash3(key)……我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。

开放寻址法优点:散列表中的数据都存储在数组中,可以有效地利用 CPU 缓存加快查询速度。
开放寻址缺点:开放寻址法解决冲突的散列表,删除数据的时候比较麻烦,需要特殊标记已经删除掉的数据。而且,在开放寻址法中,所有的数据都存储在一个数组中,比起链表法来说,冲突的代价更高。
因此当数据量比较小、装载因子小的时候,适合采用开放寻址法。

链表法(更常用)

链表法的核心思想是把相同散列值对应的key放在一个链表中。

当插入的时候,我们只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,所以插入的时间复杂度是 O(1)。 对应的查找和删除这两个操作的时间复杂度跟链表的长度 k 成正比,也就是 O(k)。对于散列比较均匀的散列函数来说,理论上讲,k=n/m,其中 n 表示散列中数据的个数,m 表示散列表中“槽”的个数。 链表法的优点:链表法对内存的利用率比开放寻址法要高,对大装载因子的容忍度更高。
链表法缺点:链表因为要存储指针所以对应的内存消耗要高。而且,因为链表中的结点是零散分布在内存中的,不是连续的,所以对 CPU缓存是不友好的,这方面对于执行效率也有一定的影响。
基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,我们将链表法中的链表改造为其他高效的动态数据结构来提高查询效率。

装载因子

当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表中有一定比例的空闲位置。我们用装载因子(load factor)来表示空位的多少,散列表的装载因子=填入表中的元素个数/散列表的长度。装载因子越大代表空闲位置越少,需要花更多的时间来查找空闲位置,因此散列表的性能也越差。
当散列表的装载因子过大影响了性能时我们可以采用扩容的方式来减小装载因子。散列表扩容之后数据的存储位置也变了,因此需要重新计算散列值然后再进行数据搬迁,对应的时间复杂度为O(n)。
为了解决一次性扩容耗时过多的情况,我们可以将扩容操作穿插在插入操作的过程中,分批完成。当装载因子触达阈值之后,我们只申请新空间,但并不将老的数据搬移到新散列表中。对于查询操作,为了兼容了新、老散列表中的数据,我们先从新散列表中查找,如果没有找到,再去老的散列表中查找。