哈希概念
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即 O(log N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。
如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立 一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
向该结构当中插入和搜索元素的过程如下:
- 插入元素: 根据待插入元素的关键码,用此函数计算出该元素的存储位置,并将元素存放到此位置。
- 搜索元素: 对元素的关键码进行同样的计算,把求得的函数值当作元素的存储位置,在结构中按此位置取元素进行比较,若关键码相等,则搜索成功。
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称 为哈希表(Hash Table)(或者称散列表)
例如:数据集合{1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。
用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快
问题:按照上述哈希方式,向集合中插入元素44,会出现什么问题?
44 % 10 = 4,而4的位置以及被占了,这时候就出现了冲突,发生了哈希冲突
哈希冲突
哈希冲突是指在使用哈希函数进行数据处理时,由于哈希函数的特性,可能会出现不同的输入数据经过哈希函数处理后得到相同的哈希值的情况,这就是所谓的哈希冲突。
哈希函数
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
哈希函数设计原则:
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值 域必须在0到m-1之间
- 哈希函数计算出来的地址能均匀分布在整个空间中
- 哈希函数应该比较简单
常见哈希函数:
一、直接定址法(常用)
取关键字的某个线性函数为哈希地址:H a s h ( K e y ) = A ∗ K e y + B Hash(Key)=A*Key+BHash(Key)=A∗Key+B。
优点:每个值都有一个唯一位置,效率很高,每个都是一次就能找到。
缺点:使用场景比较局限,通常要求数据是整数,范围比较集中。
使用场景:适用于整数,且数据范围比较集中的情况。
二、除留余数法(常用)
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:H a s h ( K e y ) = K e y % p ( p < = m ) Hash(Key)=Key%p(p<=m)Hash(Key)=Key%p(p<=m),将关键码转换成哈希地址。
优点:使用场景广泛,不受限制。
缺点:存在哈希冲突,需要解决哈希冲突,哈希冲突越多,效率下降越厉害。
三、平方取中法
假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址。
使用场景:不知道关键字的分布,而位数又不是很大的情况。
四、折叠法
折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按哈希表表长,取后几位作为哈希地址。
使用场景:折叠法适合事先不需要知道关键字的分布,或关键字位数比较多的情况。
五、随机数法
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H a s h ( K e y ) = r a n d o m ( K e y ) Hash(Key)=random(Key)Hash(Key)=random(Key),其中random为随机数函数。
使用场景:通常应用于关键字长度不等时。
六、数字分析法
设有n个d位数,每一位可能有r种不同的符号,这r中不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,而在某些位上分布不均匀,只有几种符号经常出现。此时,我们可根据哈希表的大小,选择其中各种符号分布均匀的若干位作为哈希地址。
注意:哈希函数设计的越精妙,产生哈希冲突的可能性越低,但是无法避免哈希冲突。
哈希冲突解决
解决哈希冲突的方法主要有开放定址法(闭散列)和链地址法(开散列)。
- 开放定址法(闭散列):当哈希地址发生冲突时,寻找下一个空的哈希地址,然后将元素放入新的哈希地址中。常见的开放定址法有线性探测、二次探测和双散列等。这种方法的一个明显特点是所有的元素都存放在哈希表中,因而哈希表的大小是固定的。
- 链地址法(开散列):当哈希地址发生冲突时,将同一哈希地址的所有元素组织在一个链表中。这种方法的一个明显特点是哈希表的大小可以动态增长,它的大小并不影响元素插入的速度,但在查询时可能需要遍历链表。
开散列和闭散列各有优点和缺点:
- 开散列的优点是插入速度快,不受哈希表大小的影响,适合处理大量的数据。缺点是查询速度受链表长度的影响,如果链表较长,查询效率将降低。
- 闭散列的优点是查询速度快,因为所有元素都在哈希表中。缺点是插入速度受哈希表大小的影响,如果哈希表已满,插入新元素的速度将下降。
闭散列 —— 开放定址法
闭散列,也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表种必然还有空位置,那么可以把产生冲突的元素存放到冲突位置的“下一个”空位置中去。
寻找“下一个位置”的方式多种多样,常见的方式有以下两种:
线性探测
线性探测是开放定址法的一种,也是最简单的一种解决哈希冲突的策略。当插入新的元素时,线性探测会检查哈希表中的位置,如果哈希函数指定的位置已经被占用(发生了冲突),线性探测就会检查表中的下一个位置,这个过程会一直持续到找到一个未被占用的位置。
具体步骤如下:
- 当要插入一个新元素时,通过哈希函数计算得出它在哈希表中的位置。
- 如果这个位置未被占用,新元素就被插入到这个位置。
- 如果这个位置已被占用,则向后查找,直到找到一个未被占用的位置。
我们将数据插入到有限的空间,那么空间中的元素越多,插入元素时产生冲突的概率也就越大,冲突多次后插入哈希表的元素,在查找时的效率必然也会降低。介于此,哈希表当中引入了负载因子(载荷因子):表的性能。
负载因子 = 表中有效数据个数 / 空间的大小
- 负载因子越大,产出冲突的概率越高,增删查改的效率越低。
- 负载因子越小,产出冲突的概率越低,增删查改的效率越高。
但负载因子越小,也就意味着空间的利用率越低,此时大量的空间实际上都被浪费了。对于闭散列(开放定址法)来说,负载因子是特别重要的因素,一般控制在0.7~0.8以下,超过0.8会导致在查表时CPU缓存不命中(cache missing)按照指数曲线上升。
线性探测的优点在于它的实现非常简单,只需要顺序地查找哈希表中的位置。然而,缺点也是显而易见的,即当哈希表中的元素比较密集(或者说负载因子较高)时,冲突的概率会增大,需要探测的位置也会增多,从而导致性能下降。
二次线性探测
在线性探测中,当发生冲突时,我们只是简单地检查哈希表中的下一个位置;而在二次探测中,我们会使用一个二次的探测序列,在表中跳过更多的位置。
具体步骤如下:
- 计算新的元素在哈希表中的位置,如果这个位置未被占用,新元素就被插入到这个位置。
- 如果这个位置已被占用,则向后查找,但不是向后查找一个位置,而是向后查找
i^2
个位置,其中i
是已经查找过的位置数量(从1开始)。
例如,如果哈希函数计算出的位置已经被占用,我们就查找下一个位置。如果下一个位置也被占用,我们就跳过一个位置查找。如果该位置也被占用,我们就跳过三个位置查找,以此类推。这就是为什么它被称为"二次"探测。
二次探测的优点是能够避免线性探测的"聚集"问题,即连续的位置被占用。然而,二次探测也有其缺点,即它可能会错过表中的空位置,导致哈希表被提前判定为满。例如,如果哈希表的大小是4,而我们在第一个位置发生了冲突,那么二次探测的序列将是1, 2, 4,这意味着我们将从未检查第三个位置。因此,二次探测最适合哈希表的大小是素数的情况。
开散列 —— 链地址法(拉链法、哈希桶)
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
注意:图中我们链接使用的是头插法
头插法的优点在于插入操作非常快,只需要几个简单的步骤就可以完成。具体操作如下:
- 计算新元素的哈希值。
- 找到哈希表中对应的位置。
- 在该位置的链表头部插入新元素。
头插法的缺点在于,如果元素被插入的顺序是有意义的,那么头插法可能会改变原有的顺序。另外,由于新插入的元素总是在链表的前端,所以对于查询操作来说,最近插入的元素将被优先查询到,这可能会影响到哈希表的查询性能。相反,尾插法可以保持元素的插入顺序,但插入操作可能会稍慢一些,因为需要遍历整个链表来找到尾部的位置。
闭散列解决哈希冲突,采用的是一种报复的方式,“我的位置被占用了我就去占用其他位置”。而开散列解决哈希冲突,采用的是一种乐观的方式,“虽然我的位置被占用了,但是没关系,我可以‘挂’在这个位置下面”。
与闭散列不同的是,这种将相同哈希地址的元素通过单链表链接起来,然后将链表的头结点存储在哈希表中的方式,不会影响与自己哈希地址不同的元素的增删查改的效率,因此开散列的负载因子相比闭散列而言,可以稍微大一点。
- 闭散列的开放定址法,负载因子不能超过1,一般建议控制在[0.0, 0.7]之间。
- 开散列的哈希桶,负载因子可以超过1,一般建议控制在[0.0, 1.0]之间。
在实际中,开散列的哈希桶结构比闭散列更实用,主要原因有两点:
- 哈希桶的负载因子可以更大,空间利用率高。
- 哈希桶在极端情况下还有可用的解决方案。
哈希桶的极端情况就是,所有元素全部产生冲突,最终都放到了同一个哈希桶中,此时该哈希表增删查改的效率就退化成了O(N):
这时,我们可以考虑将这个桶中的元素,由单链表结构改为红黑树结构,并将红黑树的根结点存储在哈希表中。
在这种情况下,就算有十亿个元素全部冲突到一个哈希桶中,我们也只需要在这个哈希桶中查找30次左右,这就是所谓的“桶里种树”。
为了避免出现这种极端情况,当桶当中的元素个数超过一定长度,有些地方就会选择将该桶中的单链表结构换成红黑树结构.
但有些地方也会选择不做此处理,因为随着哈希表中数据的增多,该哈希表的负载因子也会逐渐增大,最终会触发哈希表的增容条件,此时该哈希表当中的数据会全部重新插入到另一个空间更大的哈希表,此时同一个桶当中冲突的数据个数也会减少,因此不做处理问题也不大。
种极端情况,当桶当中的元素个数超过一定长度,有些地方就会选择将该桶中的单链表结构换成红黑树结构.
但有些地方也会选择不做此处理,因为随着哈希表中数据的增多,该哈希表的负载因子也会逐渐增大,最终会触发哈希表的增容条件,此时该哈希表当中的数据会全部重新插入到另一个空间更大的哈希表,此时同一个桶当中冲突的数据个数也会减少,因此不做处理问题也不大。