【散列表】哈希表

散列表(hash table)

         散列表,也叫哈希表,是实现字典操作的一种有效数据结构。尽管最坏情况下,散列表中查找一个元素的时间与链表中查找的时间相同,达到O(n),然而实际应用中,散列查找的性能是极好的。在一些合理的假设下,在散列表中查找一个元素的平均时间是O(1)。是普通数组概念的推广,在散列表中不是直接把关键字key作为数组下标,而是根据关键字计算出相应下标。在构造散列函数时,会发生冲突,解决冲突的方法有两大类:开放寻址法和链地址法所以一个散列表的研究主要有两个问题,如何构造散列函数,和如何处理冲突

一、直接寻址表

    当关键字的的全域(范围)U比较小的时,直接寻址是简单有效的技术,一般可以采用数组实现直接寻址表,数组下标对应的就是关键字的值,(类似于桶排的实现原理),即具有关键字k的元素被放在直接寻址表的槽k中。直接寻址表的字典操作实现比较简单,直接操作数组即可以,只需O(1)的时间。

二、散列表

    直接寻址技术确定是非常明显的,如果全域(范围)U很大,要存储大小为U的一张不太实际,也还有关键字集合K相对U来说可能很小,使得空间大部分浪费掉。直接寻址方式下,具有关键字k的元素被存在槽k中,在散列方式下,该元素存放在槽H(k)中;

先来了解下一些概念:

   1)散列函数和散列地址:在记录的存储位置p和其关键字key之间建立一个确定的对应关系H,使得p=H(key),称这个对应关系H为散列函数,p为散列地址。

   2)散列表:一个有限连续的地址空间,用以存储按散列函数计算得到相应散列地址的数据记录。通常散列表的存储空间是一个一维数组,散列地址是数组的下标。

   3)冲突和同义词:对不同的关键字可能得到同一散列地址,即key1!=key2,而H(key1)=H(key2),这种现象称为冲突。具有相同函数值的关键字对该散列函数来说称作同义词,key1与key2互称为同义词。

   例如,对C语言某些关键字集合建立一个散列表,关键字集合为: 

        S1={main,int,float,while,return,break,switch,case,do}

  设定一个长度为26的散列表应该足够,散列表可定义为

                      char HT[26][8];

   假设散列函数的值取关键字key中第一个字母在字母表{a,b,...,z}的序号(序号范围为0-25),即

                      H(key)=key[0]-'a';

   其中,设key的类型是长度为8的字符数组,根据此散列函数构造的散列表如下表所示。


   假设关键字集合扩从为:

       S2=S1+{short,default,double,static,for,struct}

    如果散列函数不变,新加入的七个关键字经过计算得到:H(short)=H(static)=H(struct)=18,H(default)=H(double)=3,H(for)=5,而18、3和5这几个位置均

已存放相应的关键字,这就发生了冲突现象,其中,switch、short、 static和struct称为同义词;float和for称为同义词,do、default和double称为同义词。

    集合S2中的关键字仅有15个,仔细分析这15个关键字的特性,应该不难构造一个散列函数避免冲突。但在实际应用中,理想化的、不产生冲突的散列函数极少存在

,这是因为通常散列表中关键字的取值集合远远大于表空间的地址集。因此,我们一方面可以通过精心设计的散列函数来尽量减少冲突的次数,另一方面仍需要有解决可

能出现冲突的办法。

三、散列函数

   一个好的散列函数应(近似地)满足简单均匀散列假设;每个关键字都被等可能地散列m个槽位中的任何一个,并与其它关键字已散列到哪个槽位无关。在实际应用

中,常常可以运用启发式方法来构造性能好的散列函数。散列函数的选择有两条标准:简单和均匀。简单指散列函数的计算简单快速;均匀指对于关键字集合中的任一关

字,散列函数能以等概率将其映射到表空间的任何一个位置上。也就是说,散列函数能将子集K随机均匀地分布在表的地址集{0,1,…,m-1}上,以使冲突最小化。

   将关键字转换为自然数,多数散列函数都假定关键字的全域为自然数集N={0,1,2...}。因此所给关键字不是自然数,就需要找到一种方法来将它们转换为自然数

    例如,一个字符串可以被转换为按适当的基数符号表示的整数。这样,就可以将标志符pt转换为进制整数对(112,116),这是因为在ASCII字符集中,p=112,t=116。然后,以128为基数来表示,pt即为(112×128)+116=14452。

1)平方取中法
     具体方法:先通过求关键字的平方值扩大相近数的差别,然后根据表长度取中间的几位数作为散列函数值。又因为一个乘积的中间几位数和乘数的每一位都相关,所以由此产生的散列地址较为均匀。
例如,将一组关键字(0100,0110,1010,1001,0111)平方后得(0010000,0012100,1020100,1002001,0012321)若取表长为1000,则可取中间的三位数作为散列地址集:(100,121,201,020,123)。
相应的散列函数用C实现很简单:

int Hash(int key)
{ //假设key是4位整数
   key*=key; 
   key/=100; //先求平方值,后去掉末尾的两位数
   return key%1000; //取中间三位数作为散列地址返回
}

2)除法散列法

   在用来设计散列函数的除法散列法中,通过取k除以m的余数,将关键k映射到m个槽中的某一个,即散列函数为 h(k)=k mod m

   例如,如果散列表的大小为m=12,所给关键字k=100,则h(k)=4。由于值需要做一次除法操作,所以除法散列法是非常快的。

   应用除法散列法时,需要避免选择m的某些值。例如,m不应为2的幂,一个不太接近2的整数幂的素数,常常是m的一个较好的选择。

   例如,假定我们要分配一张散列表并用链接法解决冲突,表中大约要存放n=2000个字符串,其中每个字符有8位。如果我们不介意一次不成功的查找需要平均检查3个元素,这样分配散列表的大小为m=701。原因是,它是一个接近2000/3但又不接近2的任何次幂的素数。其散列函数为 h(k)=k mod 701

(严蔚敏的数据结构中是这样介绍的:除留余数法 假设列表表长为m,选择一个不大于m的p,用p去除关键字,除后所得余数为散列地址,即H(key)=key%p

这个方法的关键是选取适当的p,一般情况下,可以选取p为小于表长的最大质数。例如,表长m=100,可取p=97。 它不仅可以对关键字直接取摸,也可在折叠、平方去中等运算之后取摸。)

C语言的实现:

int Hash(int key)
{
 return key%m;
}

3)乘法散列法

  构造散列函数的乘法散列法包含两个步骤。第一部分,用关键字k乘上参数A(0<A<1),提取kA 小数部分。第二部分,用m乘以这个值,再向下取整。总之散列函数为:

 

   乘法散列法的一个优点就是对m的选择不是特别关键,比如,完全可选择它是2的整数次幂。虽然该方法对任何A的值都适用,但对某些值效果会更好。Knuth建议选取


该函数的C语言代码:

int Hash(int key)
{
   double d=key *A; //不妨设A和m已有定义
   return (int)(m*(d-(int)d));//(int)表示强制转换后面的表达式为整数
}
4)全域散列法

    随机的选择散列函数,全域散列法在执行开始时,就从一组精心设计的函数中,随机地选择一个作为散列函数,就像在快速排序中一样,随机化保证了没有哪一种输入

会始终导致最坏情况性能。因为随机地选择散列函数,算法在每一次执行时都会有所不同,甚至对相同的输入都会如此。(这样查找一个元素的时候怎么办?)

四、处理冲突的方法

   选择一个“好”的散列函数可以在一定程度上减少冲突,但在实际应用中很难完全避免发生冲突,所以选择一个有效的处理冲突的方法就是散列法的另一个关键问题。

按组织形式的不同,通常分两大类:开放寻址法和链地址法。

1.开放寻址法

     在开放寻址法(open addressing)中所有元素都存放在散列表里。当某一记录关键字key的初始散列地址H0=H(key)发生冲突时,以H0为基础,采取合适方法计算得到另一个地址H1,如果仍然发生冲突,以H1为基础再求下一个地址H2,若H2仍然冲突,再求得H3。依次类推,直到Hk不发生冲突为止,则Hk为该记录在表中的散列地址。

    有三种技术常用来计算开放寻址法中的探查序列:线性探索、二次探索和双重散列 

             Hi=(H(key)+di)%m   i=1,2, ... ,k (k <= m-1)

1)线性探查(linear probing)

     di=1,2,3, ... ,m-1

    这种探查方法可以将散列表假想成一个循环表,发生冲突时,从冲突地址的下一单元顺序寻找空单元,如果到最后一个位置也没找到空单元,则回到表头开始进行寻找,直到找到一个空位,就把此元素放入此空位中。如果找不到空位,则说明散列表已满,需要进行溢出处理。

    线性探查方法比较容易实现,但他存在一个问题,称为一次群集(primary clustering)。随着连续被占用的散列地址不断增加,平均查找时间也随之不断增加。群集现象很容易出现,这是因为当一个空槽前有i个满的槽时,该空槽位下一个被占用的概率为(i+1)/m。

2)二次探查(quadratic probing)

    di=1^2, -(1^2), 2^2, -(2^2) , k^2, -(k^2) (k<=m/2)

    该偏移量以二次的方式依赖探查序号i。这种探查方法的效果比线性探查要好得多,但是也会导致一种轻度群集,称为二次群集(secondary clustering)。

3)双重散列(double hashing)

     是用于开放寻址法的最好方法之一,因为它所产生的排列具有随机选择排列的许多特性,双重散列采用如下形式的散列函数:

               h(k , i)=( h1(k)+ i*h2(k)) mod m

    其中h1和h2均为辅助散列函数,初始探查位置为T[h1(k)],后续的探查位置是前一个位置加上偏移量h2(k)mod m。因此,不像线性探查和二次探查,这里的探查序列以两种不同方式依赖于关键字k,因为初始探查位置、偏移量或者二者都可能发生变化。为了能查找整个散列表,值h2(k)必须要与表的大小m互素。有一种简便的方法确保这个条件成立,就是取m为2的幂,并设计一个总产生奇数的h2。另一种方法是取m为素数,并设计一总是返回较m小的正整数的函数h2。下图给出了一个使用双重散列插入的例子。


    例如,取m为素数,并取 h1(k)=k mod m,h2(k)=1+(k mod m')   其中m'略小于m(比如,m-1)。

    例如,如果k=123 456,m=701,m'=700,则有h1(k)=80,h2(k)=257,可知我们的第一个探查位置为80,然后检查每第257个槽(模m),直到找到关键字,或者遍历了所有槽。当m为素数或者2的幂时,双重散列法中用到了O(m^2)种探查序列,而线下探查或二次探查中用了O(m)种,故前者是后两种的一种改进。

开放寻址法优点:开放寻址法的好处在于它不用指针,而是计算出存取的散列地址,于是,不用存储指针而节省的空间,使得可以用同样的空间来提供更多的散列地址,潜在地减少了冲突,提高了检索速度。

2.链地址法

       把具有相同散列地址的记录放在同一个单链表中,称为同义词链表,有m个散列地址就有m个单链表,同时用数组HT[0...m-1]存放各个链表的头指针,凡是散列地址为i的记录都以结点的方式插入到以HT[i]为头结点的单链表中。(算法导论中说,用双向链表,加快插入与删除)

    例如,已知一组关键字为(19,14,23,1,68,20,84,27,55,11,10,79),设散列函数H(key)=key%13,用链地址法处理冲突,试构造这组关键字的散列表。

   由散列函数H(key)=key%13得知散列地址的值域0-12,故整个散列表由13个单链表组成,用数组HT[0..12]存放各个链表的头指针。如散列地址均为1的同义词14、1、27、79构成一个单链表,链表的头指针保存在HT[1]中,同理们可以构造其他几个单链表,整个散列表的结构如下图。

链地址法优点:链地址法处理冲突时不会发生群集,因为散列地址不同的记录在不同的链表中,所以链地址的平均查找长度小于开放寻址法。另外由于地址法的结点空间是动态申请的,无需事先确定表的容量,因此更适用于表长不确定的情况。同时,易于插入与删除。

附:几种不同方法处理冲突时散列表的平均查找长度




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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值