数据结构与算法——散列表

散列思想:散列表用的是数组支持按照下标随机访问数据的特性,所以散列表其实就是数组的一种扩展,由数组演化而来。
可以说,如果没有数组,就没有散列表。

散列函数:顾名思义,它是一个函数。我们可以把它定义成hash(key),其中key表示元素的键值,而hash(key)的值表示经过散列函数计算得到的散列值。

散列函数设计的基本要求
(1)散列函数计算得到的散列值是一个非负整数;
(2)如果key1 = key2 ,那么hash(key1) = hash(key2);
(3)如果key1 ≠ key2, 那么hash(key1) ≠ hash(key2);

前两点其实都比较容易保证,但是第三点仅存在理论情况,实际中任何一种散列函数都会存在不同键值得到相同散列值的情况,我们称之为散列冲突,对于这种散列冲突常用的解决办法有两种,第一、开放寻址法。第二、链表法。

1、开放寻址法
       开放寻址法的核心思想就是,如果出现散列冲突,我们就重新探测一个空闲位置,将其插入。那么我们该如何进行探测呢,常用的方法有线性探测法、二次探测以及双重探测法。

(1)线性探测
       当我们往散列表插入数据时,如果某个数据经过散列函数之后,存储位置已经被占用了,那么就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。如下图所示

其中橙色代表位置已经被占用,而黄色代表位置处于空闲。当键值x经过hash计算之后的结果为7,通过索引定位到数组下标为7的位置发现已经被占用,那么就依次向下搜索空闲位置,直到探测到数组下标为2的位置处于空闲,于是将x存入其中。

        线性探测法其实存在很大问题。当散列表中插入的数据越来越多时,散列冲突发生的可能性就会越来越大,空闲位置会越来越少,线性探测的时间就会越来越久。极端情况下,我们可能需要探测整个散列表,所以最坏情况下的时间复杂度为O(n)。同理,在删除和查找时,也有可能会线性探测整张散列表,才能找到要查找或者删除的数据。

(2)二次探测
        所谓二次探测,跟线性探测很像,线性探测每次探测的步长是1,那它探测的下标序列就是hash(key) + 0,
hash(key)+1, hash(key)+2......而二次探测探测的步长就变成了原来的“二次方”,也就是说,它探测的下标序列就是hash(key)+0, hash(key) + 1^2,hash(key) + 2^2......

(3)双重排列
        意思就是不仅要使用一个散列函数。我们使用一组散列函数hash1(key),hash2(key), hash3(key)......我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。

        不管采用哪种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表中有一定比列的空闲槽位。我们用装载因子来表示空位的多少。
        装载因子 = 填入表中的元素个数  / 散列表的性能会下降。

2、链表法
       链表法是一种更加常用的散列冲突解决办法,相比开放寻址法,它要简单很多。在散列表中,每个“槽”会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中。当插入的时候,我们只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,所以插入的时间复杂度是O(1)。当查找、删除一个元素时,我们同样通过散列函数计算出对应的槽,然后遍历链表进行查找或者删除。链表法的示意图如下:

 

        由图我们不难看出,链表法的实现思路很简单。同样该方法执行查找和删除时的时间复杂度跟链表的长度k成正比,也就是O(k)。对于散列比较均匀的散列函数来说,理论上讲,k = n/m,其中n表示散列中数据的个数,m表示散列表中“槽”的个数 。但是极端情况下,如果数据大量积压在一条链表上,该方法的时间复杂度同样会退化成O(n),由此我们不难看出散列函数的重要性。

          通过前面的介绍,我们知道,散列表的查询效率并不能笼统地说是O(1)。它跟散列函数、装载因子、散列冲突等都有关系。如果散列函数设计的不好,或者装在因子过高,都有可能导致散列冲突发生概率提升,导致查询效率下降。那么我们该如何设计出一个可以应对各种异常情况的工业级散列表,来避免在散列冲突的情况下,散列表性能的急剧下降?我们应该从以下几个方面考虑。

1、散列函数的设计

(1)散列函数不能过于复杂,否则计算散列值会影响执行效率

(2)散列函数生成的值要尽可能随机并且均匀分布,这样才能避免或者最小化散列冲突。

2、装载因子的设置

(1)通过前面的分析我们知道,当装在因子过大的时候,散列冲突出现的机率会提高,而装载因子过小又会造成内存空间的过度浪费。因此,如果事先知道数据规模的情况下,设定合理的装载因子是至关重要的。

(2)当我们无法事先知道数据的规模,那么我们需要数组支持动态扩容,来降低散列冲突的发生的概率。

3、高效的动态扩容

       一般的动态扩容是指,当我们数组的使用率达到设定的上限时,我们可以重新申请一片更大的空间,然后将原来的数据挪到新的空间,再将插入的数据放到新开辟的空间中。但是当插入某一数据,数组刚好达到阈值时,会使得插入这个数据变得很慢,甚至无法接受。

       举一个比较极端的例子,如果散列表中当前大小为1GB,要想扩容为原来的2倍大小,那么就需要对这1GB的数据重新计算哈希值,并且从原来的散列表搬移到新的散列表,我们知道这肯定是非常耗时的。为了解决这一问题,我们可以将扩容操作穿插在插入操作的过程中,分批完成。当装载因子触达阈值之后,我们只申请空间,并不将老的数据搬移到新散列表中。

       当有新数据要插入时,我们将新数据插入新的散列表中,并且从老的散列表中拿出一个数据放到新的散列表。这样就将搬移的工作均摊到每次插入操作中,进而避免出现在某一次插入执行很慢的情况。

4、如何选择冲突的解决方法

(1)开放寻址法的优点:不像链表法,需要拉很多链表。散列表中的数据都存储在数组中,可以有效地利用CPU缓存加快查询速度。而且,这种方法实现的散列表,序列化起来比较容易。

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

(3)因此当数据量比较小,装载因子小的时候,适合开放寻址法。

(4)链表法的优点:内存的利用率比开放寻址法要高,而且对大装载因子的容忍度更高。

(5)链表法的变化种类也有很多,如将链表换成跳表、红黑树等结果,即使出现极端情况,查找数据的时间复杂度也仅为O(logn)。因此该方法比较适合存储大对象,大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略。

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值