目录
1. 基本概念
线性表和树表的查找中,记录在表中的位置与记录的关键字之间不存在确定关系,因此,在这些表中查找记录时需进行一系列的关键字比较。这类查找方法建立在“比较”的基础上,查找的效率取决于比较的次数。
散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
- 散列函数:一个把查找表中的关键字映射成该关键字对应的地址的函数,记为Hash(key) =Addr (这里的地址可以是数组下标、索引或内存地址等)。
- 散列函数可能会把两个或两个以上的不同关键字映射到同一地址,称这种情况为冲突,这些发生碰撞的不同关键字称为同义词。一方面, 设计得好的散列函数应尽量减少这样的冲突;另一方面,由于这样的冲突总是不可避免的,所以还要设计好处理冲突的方法。
- 散列表:根据关键字而直接进行访问的数据结构。也就是说,散列表建立了关键字和存储地址之间的一种直接映射关系。
理想情况下,对散列表进行查找的时间复杂度为0(1), 即与表中元素的个数无关。
给定表M,存在函数f(key),对任意给定的关键字值key,代入函数后若能得到包含该关键字的记录在表中的地址,则称表M为哈希(Hash)表,函数f(key)为哈希(Hash) 函数。
-
若关键字为k,则其值存放在f(k)的存储位置上。由此,不需比较便可直接取得所查记录。称这个对应关系f为散列函数,按这个思想建立的表为散列表。
-
对不同的关键字可能得到同一散列地址,即k1≠k2,而f(k1)=f(k2),这种现象称为冲突(英语:Collision)。具有相同函数值的关键字对该散列函数来说称做同义词。综上所述,根据散列函数f(k)和处理冲突的方法将一组关键字映射到一个有限的连续的地址集(区间)上,并以关键字在地址集中的“像”作为记录在表中的存储位置,这种表便称为散列表,这一映射过程称为散列造表或散列,所得的存储位置称散列地址。
-
若对于关键字集合中的任一个关键字,经散列函数映象到地址集合中任何一个地址的概率是相等的,则称此类散列函数为均匀散列函数(Uniform Hash function),这就是使关键字经过散列函数得到一个“随机的地址”,从而减少冲突。
2. 散列函数的构造方法
散列函数能使对一个数据序列的访问过程更加迅速有效,通过散列函数,数据元素将被更快地定位。
2.1 考虑因素
在构造散列函数时,必须注意以下几点:
- 散列函数的定义域必须包含全部需要存储的关键字,而值域的范围则依赖于散列表的大小或地址范围。
- 散列函数计算出来的地址应该能等概率、均匀地分布在整个地址空间中,从而减少冲突的发生。
- 散列函数应尽量简单,能够在较短的时间内计算出任一关键字对应的散列地址。
2.2 构造方法
2.2.1 直接定址法
取关键字或关键字的某个线性函数值为散列地址。即:
或
其中a和b为常数(这种散列函数叫做自身函数)。若其中H(key)中已经有值了,就往下一个找,直到H(key)中没有值了,就放进去。这种方法计算最简单,且不会产生冲突。它适合关键字的分布基本连续的情况,若关键字分布不连续,空位较多,则会造成存储空间的浪费。
2.2.2 数字分析法
设关键字是r进制数(如十进制数),而r个数码在各位上出现的频率不一定相同, 可能在某些位上分布均匀一些, 每种数码出现的机会均等;(而在某些位上分布不均匀,只有某几种数码经常出现,此时应选取数码分布较为均匀的若干位作为散列地址。这种方法适合于已知的关键字集合,若更换了关键字,则需要重新构造新的散列函数。
比如:分析一组数据,比如一组员工的出生年月日,这时我们发现出生年月日的前几位数字大体相同,这样的话,出现冲突的几率就会很大,但是我们发现年月日的后几位表示月份和具体日期的数字差别很大,如果用后面的数字来构成散列地址,则冲突的几率会明显降低。因此数字分析法就是找出数字的规律,尽可能利用这些数据来构造冲突几率较低的散列地址。
2.2.3 平方取中法
顾名思义,这种方法取关键字的平方值的中间几位作为散列地址。具体取多少位要视实际情况而定。这种方法得到的散列地址与关键字的每位都有关系,因此使得散列地址分布比较均匀,适用于关键字的每位取值都不够均匀或均小于散列地址所需的位数。在不同的情况下,不同的散列函数具有不同的性能,因此不能笼统地说哪种散列函数最好。在实际选择中,采用何种构造散列函数的方法取决于关键字集合的情况,但目标是为了尽量降低产生冲突的可能性。当无法确定关键字中哪几位分布较均匀时,可以先求出关键字的平方值,然后按需要取平方值的中间几位作为哈希地址。这是因为:平方后中间几位和关键字中每一位都相关,故不同关键字会以较高的概率产生不同的哈希地址。
比如:我们把英文字母在字母表中的位置序号作为该英文字母的内部编码。例如K的内部编码为11,E的内部编码为05,Y的内部编码为25,A的内部编码为01, B的内部编码为02。由此组成关键字“KEYA”的内部代码为11052501,同理我们可以得到关键字“KYAB”、“AKEY”、“BKEY”的内部编码。之后对关键字进行平方运算后,取出第7到第9位作为该关键字哈希地址,如下表所示
关键字 | 内部编码 | 内部编码的平方值 | H(k)关键字的哈希地址 |
KEYA | 11052501 | 122157778355001 | 778 |
KYAB | 11250102 | 126564795010404 | 795 |
AKEY | 01110525 | 001233265775625 | 265 |
BKEY | 02110525 | 004454315775625 | 315 |
2.2.4 除留余数法
这是一种最简单、最常用的方法,假定散列表表长为m,取一个不大于m但最接近或等于m的质数p,利用以下公式把关键字转换成散列地址。散列函数为
除留余数法的关键是选好p,使得每个关键字通过该函数转换后等概率地映射到散列空间上的任一地址,从而尽可能减少冲突的可能性。对p的选择很重要,一般取素数或m,若p选的不好,容易产生同义词。
2.2.5 折叠法
将关键字分割成位数相同的几部分,最后一部分位数可以不同,然后取这几部分的叠加和(去除进位)作为散列地址。数位叠加可以有移位叠加和间界叠加两种方法。移位叠加是将分割后的每一部分的最低位对齐,然后相加;间界叠加是从一端向另一端沿分割界来回折叠,然后对齐相加。
2.2.6 随机数法
选择一随机函数,取关键字的随机值作为散列地址,即:
其中random为随机函数,通常用于关键字长度不等的场合。
3. 处理冲突的方法
应该注意到,任何设计出来的散列函数都不可能绝对地避免冲突。为此,必须考虑在发生冲突时应该如何处理,即为产生冲突的关键字寻找下一个“空”的Hash地址。用表示处理冲突中第 i 次探测得到的散列地址,假设得到的另一个散列地址仍然发生冲突,只得继续求下一个地址,以此类推,直到不发生冲突为止,则为关键字在表中的地址。
3.1 开放定址法
所谓开放定址法,是指可存放新表项的空闲地址既向它的同义词表项开放,又向它的非同义词表项开放。其数学递推公式为:
式中,H(key)为散列函数;i=0,1,2,...,k (k≤m-1);m表示散列表表长;di为增量序列,可有下列四种取法:
- ,称线性探测法;
- ,称平方探测法;
- ,称再散列法;
- 伪随机序列,称伪随机序列法;
3.1.1 线性探测法
这种方法的特点是:冲突发生时,顺序查看表中下一个单元(探测到表尾地址m- 1时,下一个探测地址是表首地址0),直到找出一个空闲单元(当表未填满时一定能找到一个空闲单元)或查遍全表。
线性探测法可能使第i个散列地址的同义词存入第i+ 1个散列地址,这样本应存入第i+1个散列地址的元素就争夺第i + 2个散列地址的元素的地址....从而造成大量元素在相邻的散列地址上“聚集”(或堆积)起来,大大降低了查找效率。
3.1.2 平方探测法
散列表长度m必须是一个可以表示成4k+3的素数,又称二次探测法。
平方探测法是一种较好的处理冲突的方法,可以避免出现“堆积”问题,它的缺点是不能探测到散列表上的所有单元,但至少能探测到一半单元。
3.1.3 再散列法
再散列法,又称双散列法。需要使用两个散列函数,当通过第一个散列函数H(key)得到的地址发生冲突时,则利用第2个散列函数计算该关键字的地址增量。它的具体散列函数形式如下:
初始探测位置。i是冲突的次数,初始为0。在再散列法中,最多经过m- 1次探测就会遍历表中所有位置,回到位置。即在同义词产生地址冲突时计算另一个散列函数地址,直到冲突不再发生,这种方法不易产生“聚集”,但增加了计算时间。
3.1.4 伪随机序列法
伪随机序列,称伪随机序列法。
注意:在开放定址的情形下,不能随便物理删除表中的已有元素,因为若删除元素,则会截断其他具有相同散列地址的元素的查找地址。
因此,要删除一个元素时,可给它做一个删除标记,进行逻辑删除。但这样做的副作用是:执行多次删除后,表面上看起来散列表很满,实际上有许多位置未利用,因此需要定期维护散列表,要把删除标记的元素物理删除。
3.2 链地址法(拉链法,chaining)
显然,对于不同的关键字可能会通过散列函数映射到同一地址,为了避免非同义词发生冲突,可以把所有的同义词存储在一个线性链表中,这个线性链表由其散列地址唯一标识。 假设散列地址为 i 的同义词链表的头指针存放在散列表的第 i 个单元中,因而查找、插入和删除操作主要在同义词链中进行。拉链法适用于经常进行插入和删除的情况。Java中hashmap就是这么做的(jdk7和dk8的链表指向又有所区别,简称七上八下)。
例如,关键字序列为{19, 14,23, 01, 68, 20, 84,27,55,11,10, 79},散列函数,用拉链法处理冲突,建立的表如图所示:
3.3 再哈希法
产生冲突时计算另一个哈希函数地址,直到冲突不再发生为止。
3.4 建立一个公共溢出区
建立一个公共溢出区域,就是把冲突的都放在另一个地方,不在表里面。
4. 查找性能
散列表的查找过程基本上和造表过程相同。
对于一个给定的关键字key,根据散列函数可以计算出其散列地址,执行步骤如下:
初始化: Addr=Hash (key) ;
①检测查找表中地址为Addr的位置上是否有记录:
若无记录,返回查找失败;
若有记录,比较它与key的值,
若相等,则返回查找成功标志,
否则执行步骤②。
②用给定的处理冲突方法计算“下一个散列地址”,并把Addr置为此地址,转入步骤①。例如,关键字序列{19, 14, 23, 01, 68, 20, 84, 27, 55, 11, 10, 79}按散列函数H (key) =key813和线性探测处理冲突构造所得的散列表L如图所示。
给定值84的查找过程为:首先求得散列地址H(84)=6,因L[6]不空且L↑6]≠84,则找第-次冲突处理后的地址H=(6+1)816=7, 而L[7]不空且L[7]≠84, 则找第二次冲突处理后的地止H2=(6+2)%16=8,L[8]不空且L[8]=84, 查找成功,返回记录在表中的序号8。给定值38的查找过程为:先求散列地址H(38)=12,L[12]不空且L[12]≠38, 则找下一地此H1=(12+1)号16=13, 由于L[13]是空记录,故表中不存在关键字为38的记录。
查找各关键字的比较次数如图所示。
关键字 | 14 | 01 | 68 | 27 | 55 | 19 | 20 | 84 | 79 | 23 | 11 | 10 |
比较次数 | 1 | 2 | 1 | 4 | 3 | 1 | 1 | 3 | 9 | 1 | 1 | 3 |
平均查找长度ASL为:ASL= (1x6+2+3x3+4+9)/12= 2.5
对同一组关键字,设定相同的散列函数,则不同的处理冲突的方法得到的散列表不同,它们的平均查找长度也不同,本例与上节采用拉链法的平均查找长度不同。
从散列表的查找过程可见:
- 虽然散列表在关键字与记录的存储位置之间建立了直接映像,但由于“冲突”的产生,使寻散列表的查找过程仍然是一一个给定值和关键字进行比较的过程。因此,仍需要以平均查找长度作为衡量散列表的查找效率的度量。
- 散列表的查找效率取决于三个因素:散列函数是否均匀、处理冲突的方法和散列表的装填因子 。
装填因子。散列表的装填因子般记为,定义为一个表的装满程度,即:
其中n代表表中记录数,m代表散列表长度散列表的平均查找长度依赖于散列表的装填因子,而不直接依赖于n或m。直观地看,越大,表示装填的记录越“满”,发生冲突的可能性越大,反之发生冲突的可能性越小。
查找过程中,关键码的比较次数,取决于产生冲突的多少,产生的冲突少,查找效率就高,产生的冲突多,查找效率就低。因此,影响产生冲突多少的因素,也就是影响查找效率的因素。
于表长是定值,α与“填入表中的元素个数”成正比,所以,α越大,填入表中的元素较多,产生冲突的可能性就越大;α越小,填入表中的元素较少,产生冲突的可能性就越小。
实际上,散列表的平均查找长度是装填因子α的函数,只是不同处理冲突的方法有不同的函数。
参阅:王道数据结构