python数据结构学习笔记-2016-11-14-01-散列表

本文介绍了散列表的基本概念,包括散列函数的设计原则及其在解决冲突时的不同方法,如线性探查、二次探查及双散列法等。同时讨论了散列表的效率分析、再散列以及分离链接法等内容。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

        11.1 简介

        基于比较的搜索(comparison-based searches):通过元素之间的相互比较,在容器中搜索特定的元素。之前的线性搜索和二分法搜索都是基于比较的搜索,其时间复杂度也就仅能达到O(log n)。

        对于由几个正整数组成的集合


       将上述各数映射到[100, 199]的数组内,即


      这时,我们就可以通过数组的索引,来对这些正整数进行访问,而相应的搜索的时间复杂度也就变成了O(1)。

      

      11.2 散列

       散列(hashing)是将搜索关键字映射到特定范围内的数组,来提供对该关键字直接访问的过程。其中的数组就称为散列表(hashing table),其中的映射就是散列函数(hash function)。

      对于下面的几个整数

765,431,96,142,579,226,903,388

      使用如下的散列函数:

h(key) = key % M,其中M = 13。

      会有

h(765) = 11, h(431) = 5, h(142) = 12, h(579) = 7

      此时,


        11.2.1 线性探查(linear probing)

        在将元素226映射入数组中时,h(226) = 5,这就与已经存在的96造成了冲突(collision)。

        最简单的解决办法就是线性探查法(linear probe),该法将数组视为一个环形数组,如果发生冲突时,从初始散列位置(对于226来说,就是5)开始,按照顺序,遇到空位,就将该值放入。在本例来说,就是索引6的位置。

        此时,


       将所有元素映射进入后,即


        在进行搜索时,在散列表中搜索,与向散列表中添加元素类似。也是通过散列函数,得到搜索关键字在散列表中的索引,如果该搜索关键字在相应的索引中,则该关键字存在,而如果不存在,则该关键字不存在于散列表中。

       但是在散列表中,删除元素就要特别注意。如删除元素226后,如果将散列表中的索引6的值直接变为None,在散列表中搜索元素903时,按照散列函数,元素903对应的是索引6,但是此时索引6的值为None,因此得出“元素903不在散列表中”的结论,但是元素903确实是存在于散列表中。因此,在散列表中删除元素时,不能简单的将值改为None,而是应该标记被删除,以Δ表示,该索引的值被删除。



        11.2.2 聚集(clustering)

        冲突的出现,促成了聚集(cluster)的形成。随着聚集的增长,下一个新加进来的关键字发生冲突的概率也会随之升高。

        对于上面的数组,下一个添加进来的关键字占据索引4的概率是1/13,而占领索引9的概率确是5/13(关键字经散列函数映射后的索引可为5,6,7,8,9)。后者的情形就称为一次聚集(primary clustering)。一次聚集出现的原因便是邻近初始散列位置的地方被占据,此时如果采用原来的线性探查法,将使得搜索效率下降,因此应尽量避免这一情况出现。要降低一次聚集的数量,可以对线性探查法进行改良。

        改良后的线性探查法

        探查序列(probe sequence):对于每一个关键字,在添加进散列表时,所探查过的索引组成的序列就是探查序列。

        对于前述的数组,散列函数为

slot = (home + i) % M

        home是关键字的初始散列位置,i=0,1,2, ..., M - 1。因此有

        h(765) => 11, h(579) => 7, h(431) => 2, h(96) => 5, h(142) => 12,  h(226) => 5 => 6, h(903) => 6 => 7 => 8 => 9, h(388) => 11 => 12 => 0.

        可以考虑用与M互素的数c,作为因子,与i相乘,

slot = (home + c * i) % M

        例如取c = 3,从而

        h(765) => 11, h(431) => 2, h(96) => 5, h(142) => 12, h(579) => 7, h(226) => 5 => 8, h(903) => 6, h(388) => 11 => 1

        可以看到,仅造成两个冲突,与原来相比大大降低。


   

        二次探查(quadratic probing)

        改良后的线性探查法虽然将各关键字从初始散列位置分散开来,但是仍然无法避免出现聚集的情况。

        此时可以用二次探测法,即使用公式:

slot = (home + i²) % M

        二次探查法通过增大探查距离来消除一次聚集。

        根据前面的数组建立散列表

        h(765) => 11, h(431) => 2, h(96) => 5, h(142) => 12, h(579) => 7, h(226) => 5 => 6, h(903) => 6 => 7 => 10, h(388) => 11 => 12 => 2 => 7 => 1.

        但是二次探查虽说是消除了一次聚集,但却又引入了二次聚集(secondary clustering)。二次聚集发生在两个关键字,映射到同一散列表的某一项,并具有相同的探查序列。例如如果在上面的散列表中添加关键字648,它的初始散列位置即为11,与388有着相同的探测序列。

        二次探查法不能保证能将散列表的每一项都能探查,但如果散列表的长度是一个素数的话,那么至少有一半的散列表项目被探查到。


        双散列法(double hashing)

        二次聚集出现的原因是因为探查函数仅依靠初始散列位置。要降低二次聚集,可以基于探查序列本身(?)。

        双散列法,是在冲突发生时,使用如下的探查函数

slot = (home + i * hp(key)) % M

        其中hp(key)是另外一个散列函数。

        只要步长保持不变,如果两个关键字的初始散列位置相同,则再经过另外一个散列函数的作用后,两者的探查序列就不同了。注意,hp(key)尽量不与原散列函数相同,但其值的范围应该在(0, M)。例如取如下的函数作为另外一个散列函数:

hp(key) = 1 + key % P, 0 < P < M

        如果P = 8,对于上例有:

        h(765) => 11, h(431) => 2, h(96) => 5, h(142) => 12, h(579) => 7, h(226) => 5 => 8, h(903) => 6, h(388) => 11 => 3.

 

         双散列法最常用,为了保证散列表中的每一项都能被探查,散列表的长度必须是一个素数。 


        11.2.3 再散列(rehash)

        散列表是以数组作为底层结构,当其中的项增加到一定程度时,需要对底层数组进行扩容。扩容的过程与之前的Python列表类似,但是再将原数组的元素转移至新数组的过程不一样,python列表是直接逐个复制到新数组中,而散列表则是经历再散列过程(rehash),即将这一过程视为向一个散列表中添加关键字的过程。

        例如如果将之前的散列表扩容,将其底层数组由13个扩容至17个,此时M发生变化,因此散列函数也发生了变化。

        h(765) => 0, h(431) => 6, h(96) => 11, h(142) => 6 => 7, h(579) => 1, h(226) => 5, h(903) => 2, h(388) => 14.

 

        随着散列表变得更满,冲突发生的概率就也高。所以为了降低冲突发生的概率,有必要在散列表的元素达到一定程度之时,对底层数组进行扩容,一般是接近3/4的程度(?)。散列表中关键字的数目与散列表的长度的比值称为装载因子(load factor),在装载因子达到80%之前,散列表就必须扩容。

        扩容的程度也是要考虑的问题。按照经验,一般是扩容两倍左右,但是鉴于散列表的长度是一个素数较好,可以寻找大于两倍原本散列表长度的最小素数,作为散列表的长度。


        11.2.4 效率分析

        散列表操作的效率取决于散列函数、散列表的长度以及用以解决冲突的探查方法。

        假定散列表的长度是m,其中含有n个关键字。在最好情况下,散列表操作的时间复杂度是O(1),没有冲突出现,而在最坏情况下,冲突出现,需要对散列表的每一项进行探查,则其时间复杂度是O(m)。

        在最坏情况上,散列表的搜索似乎与普通的线性搜索并无太大差异,但在平均情况上,散列表要优于线性搜索。


        当装载因子在1/2~2/3之间时,散列表搜索仅需固定时间。


        11.3 分离链接法(separate chaining)

         要想完全消除冲突,可以把映射到散列表同一项的关键码放入到一个链表中,这些链表就被称为链(chains)。

       

       分离链接法又被称为开散列法(open hashing),又叫开地址法(open addressing),因为关键码储存在散列表之外,而之前的方法可以统称为闭散列法(closed hashing),又叫闭地址法(closed addressing)。

        使用分离链接法的效率是

        

        可以看到开散列法的效率比闭散列法的效率要高,只是开散列法需要额外的储存来储存链表的结点。


        11.4 散列函数

        散列函数的目的在于将关键码映射到散列表的某一项中。完美的散列函数会将所有关键码映射到不同的项中,没有冲突出现,但这很少会实现。

        设计散列函数的重要指南:

  • 计算简单,便于快速得到结果;
  • 其返回值必须是确定的,不能具有随机性;
  • 如果该关键码由多个部分组成,则其每一部分都对基位置的计算有贡献;
  • 散列表的长度应为一个素数。
        整数关键码较容易处理,对于非整数关键码,需要对其首先转化成一个整数值,再使用基于整数的散列函数计算出基位置。

        

        基于整数的散列函数

         除法

        这是最简单的散列函数,可运用于整数或其他可转变为整数的数据类型。

h(key) = key % M

        截断

        对于大整数来说,关键码中的某些列可被忽略。在这种情况下,可以挑选出特殊的列组合起来,作为其在散列表的基位置的索引。例如,如果所有关键码都由7位数字组成,而散列表长度不超过1000,我们可以截取关键码的第一位、第三位和第六位出来,组合在一起作为该关键码的基位置,比如说,关键码4873152,其基位置即为812。


        折叠

        在这一方法中,关键码被分割成多个部分,通过各部分的相加或相乘,得到基位置的索引。例如,对于关键码4873152,可以分割成48, 731, 52,然后各部分相加48 + 731 + 52 = 831,得到基位置,

        该法主要用于当数据由明确的几部分组成时,比如身份证号码或电话号码。


        对字符串的散列

        对于字符串,必须转换成整数,才能使用散列函数。最简单的办法就是将字符串中的每一个字符转换成其ASCII值,再使用基于整数的散列函数。例如字符串"hashing",其结果为 104 + 97 + 115 + 104 + 105 + 110 + 103 = 738,这一方法对于长度较短的散列表较为有效。

        但是对于较长的散列表而言,短的字符串无法映射到较大的索引,因此发生冲突的概率很高。在这种情况下,仍然是将字符串的每一个字符转换成其ASCII值,再用多项式计算出基位置的索引。

      



         

      

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值