最近开始学习王争老师的《数据结构与算法之美》,通过总结再加上自己的思考的形式记录这门课程,文章主要作为学习历程的记录。
散列表用的是数组支持按照下标随机访问数据的特性,所以散列表其实就是数组的一种扩展,由数组演化而来。
举个例子,运动员参加学校运动会,每个运动员有自己的编号。希望实现一个功能通过编号快速找到对应的选手信息,我们可以把这些运动员信息放在数组里,编号为k的选手放在数组中下标为k的位置。这个例子已经用到了散列表的思想。参赛编号与数组的下标形成一一映射,所以利用数值支持根据下标随机访问的时候,时间复杂度为O(1)。其中参赛选手的编号叫做键(key)或关键字,我们用它来标识一个选手。我们把参赛编号转化为数组下标的映射方式叫做散列函数(哈希函数),而散列函数计算得到的值叫做散列值(哈希值)。python的内建数据类型:字典,就是用哈希表实现的。
通过这个例子,我们可以总结出这样的规律:散列表用的就是数组支持按照下标随机访问的时候,时间复杂度为O(1)的特性。通过散列函数把元素的键值映射为下标,然后将数据存储在数组中对应下标的位置。当我们按照键值查询元素时,我们用同样的散列函数将键值转化为数组下标,从对应的数组下标位置取数据。
为了解释哈希表的工作原理,我们来尝试在不使用字典的情况下实现哈希表结构。一种简单是实现方法是建立一个线性表,使用元组来实现 key-value 的映射关系.
class LinearMap(object):
""" 线性表结构 """
def __init__(self):
self.items = []
def add(self, k, v): # 往表中添加元素
self.items.append((k,v))
def get(self, k): # 线性方式查找元素
for key, val in self.items:
if key==k: # 键存在,返回值,否则抛出异常
return val
raise KeyError
以力扣205题“同构字符串”为例,给定两个字符串 s 和 t,判断它们是否是同构的。如果 s 中的字符可以被替换得到 t ,那么这两个字符串是同构的。所有出现的字符都必须用另一个字符替换,同时保留字符的顺序。两个字符不能映射到同一个字符上,但字符可以映射自己本身。
class Solution(object):
def isIsomorphic(self, s, t):
"""
:type s: str
:type t: str
:rtype: bool
"""
slen,tlen = len(s),len(t)
if slen!=tlen:
return False
dic = {}
for i in range(slen):
if s[i] not in dic:
if t[i] in dic.values():
return False
else:
dic[s[i]] = t[i]
else:
if dic[s[i]]!= t[i]:
return False
return True
散列函数
散列函数,我们可以把它定义为hash(key),其中key表示元素的键值,hash(key)的值表示经过散列函数计算得到的散列值。该如何构造散列函数呢?下面是三条散列函数设计的基本要求:
1.散列函数计算得到的散列值是一个非负整数,这是因为数组下标是从0开始,所以散列函数生成的散列值也要是非负整数。
2.如果key 1= key 2,那hash(key 1)==hash(key 2),相同的key经过散列函数得到的散列值也应相同。
3.如果key 1≠ key 2,那hash(key 1)≠hash(key 2),对于这一点,虽然看起来合情合理,但在真实情况下要找到一个不同的key对应的散列表值都不一样的散列函数几乎是不可能的,而且数组的存储空间有限,也会加大散列冲突的概率。针对散列冲突问题,我们需要通过其他途径解决。
散列冲突
常用的散列冲突有两类:开放寻址法和链表法。
1.开放寻址法
其核心是出现了散列冲突,就重新探测一个空闲位置,将其插入。先介绍一种简单的探测方法——线性探测。当我们往散列表中插入数据时,如果某个数据经过散列函数散列后,存储位置已经被占用率,就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止,如下图:
在x插入散列表之前,已经有6个元素插入到散列表中。x经过Hash算法后,被散列到位置下标为7的位置,但这个位置已经有数据了,就会发生冲突。于是就按顺序往后一个一个地找,没有空闲位置又从表头开始找,直至找到空闲位置2,将其插入。
在散列表中查找元素的过程有点类似插入。通过散列函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素。如果相等,则说明是我们要找的元素,否则就顺序往后依次查找。如果遍历到数组中的空闲位置,还没找到,就说明要查找的元素并没有在散列表中。
散列表跟数组一样,不仅支持插入、查找操作,还支持删除操作。但在删除操作中,不能单纯地把要删除的元素设置为空,否则易使原来的查找算法失效。故可将删除的元素标记为deleted,进行查找时遇到deleted的空间,继续往下探测。
但是线性探测法存在很大问题。当散列表中插入的数据越来越多,散列冲突发生的可能性越来越大,空闲位置越来越少,探测时间越来越长。最坏情况下时间复杂度为O(n)。
对于开发寻址冲突解决方法,除了线性探测之外,还有另外两种比较经典的探测方法——二次探测和双重散列。
二次探测:将线性探测的下标序列hash(key)+0,hash(key)+1,hash(key)+2变成hash(key)+0,hash(key)+ 1 2 1^2 12,hash(key)+ 2 2 2^2 22…
双重探测:使用一组散列函数hash1(key),hash2(key),hash3(key)…先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直至找到空闲的空间位置。
当散列表中空闲位置不多时,散列冲突的概率会大大提高。为了保证散列表的操作效率,一般我们会尽可能保证散列表中有一定比例的空闲槽位。用装载因子(load factor)来表示空位的多少。
装载因子 = 填入表中的元素个数/散列表的长度
装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。
2.链表法
链表法是更常用的散列冲突解决方法,相比开放寻址法,它更简单。在散列表中,每个”桶(bucket)“或”槽(slot)“会对应一条链表,所有散列值相同的元素都放在相同槽位对应的链表。
当插入时,只需要通过散列函数计算出对应的散列槽位,将其插入到对应的链表中即可,插入的时间复杂度为O(1)。当查找、删除一个元素时,我们同样通过散列函数计算出对应的槽,然后遍历链表查找或删除,那查找或删除操作时间复杂度为多少呢?
实际上,这两个时间复杂度与链表的长度k成正比,也就是O(k)。对于散列比较均匀的散列函数来说, k = n / m k=n/m k=n/m。其中n表示散列中数据的个数,m表示散列表中“槽”的个数。
设计散列表
在极端情况下,所有数据经过散列函数之后,都散列到同一个槽里。如果使用的是基于链表的冲突解决方法,那那时散列表就会退化为链表,查询时间复杂度就从O(1)退化为O(n)。因此需要设计可以应对各种异常情况的工业级散列表来避免散列冲突。
散列函数设计的好坏,决定了散列表冲突的概念大小,也直接决定了散列表的性能。其设计应符合以下原则:
1.散列函数的设计不能太复杂——过于复杂,会消耗大量的计算时间。
2.散列函数生成的值要尽可能随机并且均匀分布,尽可能避免或最小化散列冲突。
以用散列函数处理手机号码为例。因为手机号码前几位重复可能性很大,但后面几位就比较随机,可以取手机号后四位作为散列值。这种方法一般叫做“数据分析法”。
再以实现Word拼写检查功能为例,可以这样设计:将每个字母根据ASCII码“进位”相加,然后再跟散列表的大小求余,取模作为散列值。如英文单词“nice”, h a s h ( " n i c e " ) hash("nice") hash("nice")= ( ( " n " − " a " ) ∗ 26 ∗ 26 ∗ 26 ) (("n"-"a")*26*26*26) (("n"−"a")∗26∗26∗26)+ ( " i " − " a " ) ∗ 26 ∗ 26 ("i"-"a")*26*26 ("i"−"a")∗26∗26+ ( " c " − " a " ) ∗ 26 ("c"-"a")*26 ("c"−"a")∗26+ ( " e " − " a " ) ) / 78978 ("e"-"a"))/78978 ("e"−"a"))/78978
解决装载因子过大
装载因子过大容易出现散列冲突。对于没有频繁插入和删除的静态数据集合来说,很容易根据数据的特点、分布等,设计出完美的,极少冲突的散列函数。对于动态散列表来说,数据集合是频繁变动的,无法事先预估加入数据的个数,无法事先申请一个足够大的散列表。随着数据逐渐加入,装载因子就会慢慢变大。当大到一定程度时,散列冲突就会变得不可接受。这时该如何处理呢?
其实如同之前的栈、数组、队列一样,可以进行动态扩容——重新申请一个更大的散列表,将数据搬移到新的散列表中,但是针对散列表的扩容,数据搬移操作要复杂得多。因为散列表大小变了,数据存储位置也变了,需要通过散列函数重新计算每个数据的存储位置。
插入一个数据,最好时间复杂度为O(1),最坏情况下,散列表装载因子过高,启动扩容,我们需要重新申请内存空间,重新计算哈希位置,并且搬移数据,所以时间复杂度为O(n),用摊还分析法,均摊情况下,时间复杂度接近最好情况,即O(1)。
对于动态散列表,随着数据的删除,散列表中的数据越来越少,空闲空间越来越多。如果对空间消耗非常敏感的话,可以启动动态缩容。
当散列表的装载因子超过阈值时,就需要进行扩容。装载因子阈值需要选择得当。如果太大,会导致冲突过多;如果太小,会导致内存浪费严重。装载因子阈值的设置要权衡时间和空间复杂度。
如何避免低效地扩容?
大部分情况下,动态扩容的散列表插入数据很快, 但在特殊情况下,当装载因子已经到达阈值,需要先进行扩容,再插入数据。此时,插入数据就会变得很慢,甚至无法接受。为了解决一次性扩容耗时过多的情况,可以将扩容操作穿插在插入操作过程中,分批完成。当装载因子触达阈值后,我们只申请新空间,但并不将老的数据搬移到新的散列表中。
当有新数据要插入时,我们将新数据插入到新散列表中,并且从老的散列表中拿出一个数据放入到新散列表中,每次插入一个数据到散列表,都重复上面的过程。经过多次插入操作之后,老的散列表中的数据就一点一点地搬移到新散列表中,这样没有了集中的一次性数据搬移,插入操作就变得很快。
这期间的查询操作是这样的:先从新散列表中进行查找,没有找到,再去老的散列表中查找。通过这样均摊的方法,插入一个数据的时间复杂度为O(1)。
如何选择冲突解决方法?
我们现在主要有两种散列冲突的解决办法——开放寻址法和链表法,分别介绍一下两种方法的优缺点:
1、开放寻址法
优:开放寻址法不像链表法,需要拉很多链表。散列表中数据都存储在数据中,可以有效地利用CPU缓存加快查询速度,而且这种方法实现的散列表,较链表而言,序列化起来比较简单。
缺:这种方式的散列表需要特殊标记已经删除的数据。而且,在开放寻址中,所有的数据都存储在一个数组中,比起链表法来说,冲突的代价更高。
总结:当数据量比较小,装载因子小的时候,适合采用开放寻址法。
2、链表法
优:链表法对内存的利用率比开放寻址法要高。因为链表结点可以在需要的时候再创建,并不需要像开放寻址法那样事先申请好。链表对大装载因子的容忍度更高。开放寻址法当装载因子接近1时,易发生大量的散列冲突,性能下降。对于链表法来说,只要散列函数的值随机均匀,即使装载因子变成10,查找效率有所下降,但比顺序查找还要快很多。
缺:链表要存储指针,故对比较小的对象的存储,比较消耗内存,而且因为链表中的结点是离散分布在内存中,不是连续的,对CPU缓存不友好。
总结:基于链表的散列冲突处理方法比较适合存储大对象,大数据量的散列表。
子变成10,查找效率有所下降,但比顺序查找还要快很多。
缺:链表要存储指针,故对比较小的对象的存储,比较消耗内存,而且因为链表中的结点是离散分布在内存中,不是连续的,对CPU缓存不友好。
总结:基于链表的散列冲突处理方法比较适合存储大对象,大数据量的散列表。
参考资料:王争《数据结构与算法之美》