一、散列表是什么?
散列表的英文为“Hash Table”,也叫哈希表,ta 用的是数组支持按照下标随机访问数组的特性,所以说散列表就是数组的一种拓展。
我们通过散列函数把键值映射为下标,然后将数据存储在数组中对应下标的位置当我们按照键值查询元素时,我们相同的散列函数将键值转化为数组下标,从相应的数组下标位置取出数据。
二、散列函数
散列函数,我们可以将其定义成hash(key),其中key表示元素的键值,hash(key)的值表示经过散列函数计算后的散列值。
以下为散列函数的设计要求:
- 散列函数计算得到的散列值为非负整数;
- 如果key1 = key2,则hash(key1) == hash(key2);
- 如果key1 ≠ key2,则hash(key1)!=hash(key2)。
三、散列冲突
1.开放寻址法
开放寻址法的核心思想是,如果出现了散列冲突,就重新探测一个空闲位置,将其插入。那如何重新探测新的位置呢?有比较简单的探测方法——线性探测法。即当需要往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。
散列表跟数组一样,不仅支持插入、查找操作,还支持删除操作。对于使用线性探测法解决冲突的散列表,删除操作稍微有些特别。我们不能单纯地把要删除的元素设置为空。这是因为在查找的时候,一旦通过线性探测方法找到一个空闲的位置,就可以认定散列表中不存在这个数据。但是,如果这个空闲位置是后来删除的,就会导致原来的查找算法失效。本来存在的数据,会被认定为不存在。
为了避免这样的问题发生,我们可以将删除的元素,特殊标记为deleted。当线性探测查找的时候,遇到标记为 deleted 的空间,并不是停下来,而是继续往下探测。
其实,仔细思考以后你会发现,线性探测法其实存在很大问题。当散列表中插入的数据越来越多时,散列冲突发生的可能性就会越来越大,空闲位置会越来越少,线性探测的时间就会越来越久。极端情况下,我们可能需要探测整个散列表,所以最坏情况下的时间复杂度为O(n)。同理,在删除和查找时,也有可能会线性探测整张散列表,才能找到要查找或者删除的数据。
对于开放寻址冲突解决方法,除了线性探测方法之外,还有另外两种比较经典的探测方法,二次探测(Quadratic probing)和双重散列(Double hashing),对于这两个方法就不详细讲述了。
不管采用哪种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表中有一定比例的空闲槽位。我们用装载因子(load factor)【散列表的装载因子 = 填入表中的元素个数 / 散列表的长度】来表示空位的多少。装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。
当数据量比较小、装载因子小的时候,适合采用开放寻址法。
2.链表法
链表法是一种更加常用的散列冲突解决办法,相比开放寻址法,它要简单很多。如图,在散列表中,每个“桶(bucket)”或者“槽(slot)”会对应一条链表,所有散列值相同的元素都放到相同槽位对应的链表中。
当插入的时候,我们只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,所以插入的时间复杂度是 O(1)。当查找、删除一个元素时,我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除。那查找或删除操作的时间复杂度是多少呢?
实际上,这两个操作的时间复杂度跟链表的长度 k 成正比,也就是O(k)。对于散列比较均匀的散列函数来说,理论上讲,k=n/m,其中 n 表示散列中数据的个数,m 表示散列表中“槽”的个数。
如果对链表法稍加改造,可以实现一个更加高效的散列表。那就是,我们将链表法中的链表改造为其他高效的动态数据结构,比如跳表、红黑树。这样,即便出现散列冲突,极端情况下,所有的数据都散列到同一个桶内,那最终退化成的散列表的查找时间也只不过是O(logn)。这样也就有效避免了前面讲到的散列碰撞攻击。
所以,基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表。
小练习
采用散列映射,统计文本中各字符出现的次数
【核