目录
本文参考:大话数据结构——程杰。之前看了一些博客,觉得内容零零散散,还是看书较容易理解。以下内容大部分来自书里。
1.散列表概述
举个最简单的例子,我们要存储0 2 4 6 8 10这6个数到数组里,直接将它们除以2,对应的值就为存储数组的下标,即2存在arr[1],10存在arr[5]。我们要查找数据时只需要同样将数据除以2,不需要和任何数据比较,直接找出这个数据所在位置。比如我们要查找10,10/2=5即为其所在位置,因此直接查找arr[5]的数据。从而得到O(1)时间复杂度的查找。这里的除以而,就可以看作一个最简单的哈希函数。
1.1散列表查找定义
散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系f,使得每个关键字key对应一个存储位置f (key)。查找时,根据这个确定的对应关系找到给定值key的映射f (key),若查找集合中存在这个记录,则必定在f (key)的位置上。
这里我们把这种对应关系f称为散列函数,又称为哈希(Hash)函数。按这个思想,采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为散列表或哈希表(Hashtable)。那么关键字对应的记录存储位置我们称为散列地址。
1.2散列表查找步骤
(1)在存储时,通过散列函数计算记录的散列地址,并按此散列地址存储该记录。
(2)当查找记录时,我们通过同样的散列函数计算记录的散列地址,按此散列地址访问该记录。说起来很简单,在哪存的,上哪去找,由于存取用的是同一个散列函数,因此结果当然也是相同的。
所以说,散列技术既是一种存储方法,也是一种查找方法。然而它与线性表、 树、图等结构不同的是,前面几种结构,数据元素之间都存在某种逻辑关系,可以用连线图示表示岀来,而散列技术的记录之间不存在什么逻辑关系,它只与关键字有关联。因此,散列主要是面向查找的存储结构。
散列技术最适合的求解问题是査找与给定值相等的记录。对于查找来说,简化了比较过程,效率就会大大提高。但万事有利就有弊,散列技术不具备很多常规数据结构的能力。
比如那种同样的关键字,它能对应很多记录的情况,却不适合用散列技术。一个 班级几十个学生,他们的性别有男有女,你用关键字“男”去查找,对应的有许多学 生的记录,这显然是不合适的。只有如用班级学生的学号或者身份证号来散列存储, 此时一个号码唯一对应一个学生才比较合适。
同样散列表也不适合范围查找,比如查找一个班级18〜22岁的同学,在散列表 中没法进行。想获得表中记录的排序也不可能,像最大值、最小值等结果也都无法从 散列表中计算出来。
2.散列函数的构造方法
不管做什么事要达到最优都不容易,既要付出尽可能的少,又要得到最大化的多。那么什么才算是好的散列函数呢?这里我们有两个原则可以参考。
1.计算简单
你说设计一个算法可以保证所有的关键字都不会产生冲突,但是这个算法需要很复杂的计算,会耗费很多时间,这对于需要频繁地查找来说,就会大大降低查找的效率了。因此散列函数的计算时间不应该超过其他查找技术与关键字比较的时间。
2.散列地址分布均匀
我们刚才也提到冲突带来的问题,最好的办法就是尽量让散列地址均匀地分布在 存储空间中,这样可以保证存储空间的有效利用,并减少为处理冲突而耗费的时间。
接下来我们就要介绍几种常用的散列函数构造方法。
2.1直接定址法
如果我们现在要对0〜100岁的人口数字统计表,如下表所示,那么我们对 年龄这个关键字就可以直接用年龄的数字作为地址。此时f (key) =key。
如果我们现在要统计的是80后出生年份的人口数,如下表所示,那么我们 对出生年份这个关键字可以用年份减去1980来作为地址。此时f (key) =key-1980。
也就是说,我们可以取关键字的某个线性函数值为散列地址,即
f ( key ) =a x key+b ( a、b 为常数)
这样的散列函数优点就是简单、均匀,也不会产生冲突,但问题是这需要事先知 道关键字的分布情况,适合查找表较小且连续的情况。由于这样的限制,在现实应用中,此方法虽然简单,但却并不常用。
2.2数字分析法
如果我们的关键字是位数较多的数字,比如我们的11位手机号 “130xxxxl234”,其中前三位是接入号,一般对应不同运营商公司的子品牌,如130 是联通如意通、136是移动神州行、153是电信等;中间四位是HLR识别号,表示用 户号的归属地;后四位才是真正的用户号。
若我们现在要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可 能前7位都是相同的。那么我们选择后面的四位成为散列地址就是不错的选择。如果 这样的抽取工作还是容易出现冲突问题,还可以对抽取出来的数字再进行反转(如 1234改成4321)、右环位移(如1234改成4123)、左环位移、甚至前两数与后两数 叠加(如1234改成12+34=46)等方法。总的目的就是为了提供一个散列函数,能够 合理地将关键字分配到散列表的各位置。
这里我们提到了一个关键词——抽取。抽取方法是使用关键字的一部分来计算散 列存储位置的方法,这在散列函数中是常常用到的手段。
数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布 且关键字的若干位分布较均匀,就可以考虑用这个方法。
2.3平方取中法
这个方法计算很简单,假设关键字是1234,那么它的平方就是1522756,再抽取 中间的3位就是227,用做散列地址。再比如关键字是4321,那么它的平方就是 18671041,抽取中间的3位就可以是671,也可以是710,用做散列地址。平方取中 法比较适合于不知道关键字的分布,而位数又不是很大的情况。
2.4折叠法
折叠法是将关键字从左到右分割成位数相等的几部分(注意最后一部分位数不够 时可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
比如我们的关键字是9876543210,散列表表长为三位,我们将它分为四组, 987|654|321|0,然后将它们叠加求和987+654+321+0=1962,再求后3位得到散列 地址为962O
有时可能这还不能够保证分布均匀,不妨从一端向另一端来回折叠后对齐相加。 比如我们将987和321反转,再与654和0相加,变成789+654+123+0=1566,此 时散列地址为566。
折叠法事先不需要知道关键字的分布,适合关键字位数较多的情况
2.5除留余数法
此方法为最常用的构造散列函数方法。对于散列表长为m的散列函数公式为
f ( key ) = key mod p (p<=m)
mod是取模(求余数)的意思。事实上,这方法不仅可以对关键字直接取模,也可在折叠、平方取中后再取模。
很显然,本方法的关键就在于选择合适的p, p如果选得不好,就可能会容易产生同义词。
例如下表,我们对于有12个记录的关键字构造散列表时,就用了 f (key) =key mod 12的方法。比如29 mod 12 = 5,所以它存储在下标为5的位置。
不过这也是存在冲突的可能的,因为12=2X6=3X4。如果关键字中有像18 (3X6)、30 (5X6)、42 (7X6)等数字,它们的余数都为6,这就和78所对应的下标位置冲突了。甚至极端一些,对于表8-10-5的关键字,如果我们让p为12的话,就可能出现 下面的情况,所有的关键字都得到了0这个地址数。根据经验,若散列表表长为m,通常p为小于或等于表长(最好接近m)的最小质数或不包含小于20质因子的合数。
2.6随机数法
选择一个随机数,取关键字的随机函数值为它的散列地址。也就是f (key) =ranctom (key)o这里random是随机函数。当关键字的长度不等时,采用这个方法构造散列函数是比较合适的。不过注意这里的随机函数是伪随机数,查找时可以运用同样的随机函数获得关键字的下标。
那如果关键字是字符串如何处理?其实无论是英文字符,还是中文字 符,也包括各种各样的符号,它们都可以转化为某种数字来对待,比如ASCII码或者 Unicode码等,因此也就可以使用上面的这些方法。
总之,现实中,应该视不同的情况采用不同的散列函数。下面给出一些考虑的因素:
- 计算散列地址所需的时间。
- 关键字的长度。
- 散列表的大小。
- 关键字的分布情况。
- 记录查找的频率。综合这些因素,才能决策选择哪种散列函数更合适。
3.处理散列冲突的方法
从刚才除留余数法的例子也可以看出,我们设计得再好的散列函数也不可能完全 避免冲突,这就像我们再健康也只能尽量预防疾病,但却无法保证永远不得病一样, 既然冲突不能避免,就要考虑如何处理它。那么当我们在使用散列函数后发现两个关键字key1!=key2,但是却有f (key1)=f (key2),即有冲突时,怎么办呢?
3.1开放定址法
所谓的开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散 列表足够大,空的散列地址总能找到,并将记录存入。它的公式是:
fi ( key ) = (f ( key ) +di) MOD m ( di=1,2,3,... ,m-1 )
比如说,我们的关键字集合为{12,67,56,16,25,37,22,29,15,47,48,34},表长为12。 我们用散列函数f (key) =key mod 12。当计算前5个数{12,67,56,16,25}时,都是没有冲突的散列地址,直接存入,如下表所示。
下标 |
0 |
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
关键字 |
12 |
25 |
|
|
16 |
|
|
67 |
56 |
|
|
|
计算key=37时,发现f (37) =1,此时就与25所在的位置冲突。于是我们应用 上面的公式f (37) = (f (37) +1) mod 12=2。于是将37存入下标为2的位置。如下所示。
下标 |
0 |
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
关键字 |
12 |
25 |
37 |
|
|
|
67 |
56 |
|
22 |
|
接下来22,29,15,47都没有冲突,正常的存入。到了 key=48,我们计算得到f (48) =0,与12所在的0位置冲突了,不要紧, 我们f (48) = (f (48) +1) mod 12=1,此时又与25所在的位置冲突。于是f (48) = (f (48) +2) mod 12=2,还是冲突……一直到 f (48) = (f (48) +6) mod 12=6时,才有空位,机不可失,赶快存入。
下标 |
0 |
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
关键字 |
12 |
25 |
37 |
15 |
16 |
29 |
48 |
67 |
56 |
|
22 |
47 |
我们把这种解决冲突的开放定址法称为线性探测法。从这个例子我们也看到,我们在解决冲突的时候,还会碰到如48和37这种本来 都不是同义词却需要争夺一个地址的情况,我们称这种现象为堆积。很显然,堆积的岀现,使得我们需要不断处理冲突,无论是存入还是查找效率都会大大降低。
考虑深一步,如果发生这样的情况,当最后一个key=34,f(key)=10,与22所在的位置冲突,可是22后面没有空位置了,反而它的前面有一个空位置,尽管可以不断地求余数后得到结果,但效率很差。因此我们可以改进糸=, -, , -,……, , -, (q<=m/2),这样就等于是可以双向寻找到可能的空位置。对于34来说,我 们取dj=-1即可找到空位置了。另外增加平方运算的目的是为了不让关键字都聚集在某一块区域。我们称这种方法为二次探测法。
fi ( key ) = (f ( key ) +di) MOD m ( di=12. -12, 22, -22,-, q2. -q2,q<=m2 )
还有一种方法是,在冲突时,对于位移量di采用随机函数计算得到,我们称之为随机探测法。这里的随机是伪随机数。伪随机数是说,如果我们设置随机种子相同,则不断调用随机函数可以生成不会重复的数列,我们在查找时,用同样的随机种子,它每次得到的数列是相同的,相同的di可以得到相同的散列地址。
3.2再散列函数法
对于我们的散列表来说,我们事先准备多个散列函数。
fi ( key ) =RHi ( key ) (i=1,2,...,k )
这里RHi就是不同的散列函数,你可以把我们前面说的什么除留余数、折叠、平方取中全部用上。每当发生散列地址冲突时,就换一个散列函数计算,总会有一 个可以把冲突解决掉。这种方法能够使得关键字不产生聚集,当然,相应地也增加了计算的时间。
3.3链地址法
思路还可以再换一换,为什么有冲突就要换地方呢,我们直接就在原地想办法不可以吗?于是我们就有了链地址法。
将所有关键字为同义词的记录存储在一个单链表中,我们称这种表为同义词子 表,在散列表中只存储所有同义词子表的头指针。对于关键字集合{12,67,56,16,25,37, 22,29,15,47,48,34},我们用前面同样的12为除数,进行除留余数法,可得到如下图结构,此时,已经不存在什么冲突换址的问题,无论有多少个冲突,都只是在当前位置给单链表增加结点的问题。
链地址法对于可能会造成很多冲突的散列函数来说,提供了绝不会出现找不到地 址的保障。当然,这也就带来了査找时需要遍历单链表的性能损耗。
3.4公共溢出区法
这个方法其实就更加好理解,你不是冲突吗?好吧,凡是冲突的都跟我走,我给你们这些冲突找个地儿待着。我们为所有冲突的关键字建立了一个公共的溢出区来存放。
就前面的例子而言,我们共有三个关键字(37,48,34)与之前的关键字位置有冲突, 那么就将它们存储到溢出表中,如下图所示。
在查找时,对给定值通过散列函数计算出散列地址后,先与基本表的相应位置进 行比对,如果相等,则查找成功;如果不相等,则到溢出表去进行顺序查找。如果相 对于基本表而言,有冲突的数据很少的情况下,公共溢出区的结构对査找性能来说还是非常高的。
4.散列表查找实现
4.1散列表查找算法实现
首先是需要定义一个散列表的结构以及一些相关的常数。其中HashTable就是散 列表结构。结构当中的elem为一个动态数组。
#define SUCCESS 1
#define UNSUCCESS 0
#define HASHSIZE 12 /*定义散列表长为数组的长度*/
#define NULLKEY -32768
typedef struct
{
int *elem;/*数据元素存储基址,动态分配数组*/
int count;/*当前数据元素个数*/
}HashTable;
int m=0;
有了结构的定义,我们可以对散列表进行初始化。
/*初始化散列表*/
Status InitHashTable (HashTable *H )
{
int i;
m=HASHSIZE;
H->count=m;
H->elem= ( int * ) malloc (m*sizeof ( int));
for (i=0;i<m;i++)
H->elem[i]=NULLKEY;
return OK;
}
为了插入时计算地址,我们需要定义散列函数,散列函数可以根据不同情况更改算法。
/*散列函数*/
int Hash ( int key )
{
return key % m;/* 除留余数法 */
}
初始化完成后,我们可以对散列表进行插入操作。假设我们插入的关键字集合就 是前面的{12,67,56,16,25,37,22,29,15,47, 48,34}。
/*插入关键字进散列表*/
void InsertHash (HashTable *H, int key)
{
int addr = Hash ( key) ; /* 求散列地址 */
while (H->elem[addr] != NULLKEY) /* 如果不为空,貝冲突 */
addr = (addr+1 ) % m; /*开放定址法的线性探测*/
H->elem[addr] = key; /*直到有空位后插入关键字*/
}
代码中插入关键字时,首先算出散列地址,如果当前地址不为空关键字,则说明有冲突。此时我们应用开放定址法的线性探测进行重新寻址,此处也可更改为链地址 法等其他解决冲突的办法。
散列表存在后,我们在需要时就可以通过散列表查找要的记录。
/*散列表查找关键字*/
Status SearchHash ( HashTable H,int key,int *addr )
{
*addr = Hash ( key) ; /* 求散列地址 */
while ( H.elem[*addr] != key) /* 如果不为空,則冲突 */
{
*addr = ( *addr+l) % m; /*开放定址法的线桂探测*/
if (H.elem[*addr] == NULLKEY || addr == Hash ( key ))
{ /*如果循环回到原点*/
return UNSUCCESS; /*则说明关键字不存在*/
}
}
return SUCCESS;
)
查找的代码与插入的代码非常类似,只需做一个不存在关键字的判断而已。
4.2散列表查找性能分析
最后,我们对散列表查找的性能作一个简单分析。如果没有冲突,散列査找它的时间复杂度为O(1).可惜,我说的只是“如果”,没有冲突的散列只是一种理想,在实际的应用中,冲突是不可避免的。那么散列查找的平均查找长度取决于哪些因素呢?1.
1.散列函数是否均匀
散列函数的好坏直接影响着出现冲突的频繁程度,不过,由于不同的散列函数对 同一组随机的关键字,产生冲突的可能性是相同的,因此我们可以不考虑它对平均査找长度的影响。
2.处理冲突的方法
相同的关键字、相同的散列函数,但处理冲突的方法不同,会使得平均查找长度不同。比如线性探测处理冲突可能会产生堆积,显然就没有二次探测法好,而链地址 法处理冲突不会产生任何堆积,因而具有更佳的平均查找性能。
3.散列表的装填因子
所谓的装填因子填入表中的记录个数/散列表长度。标志着散列表的装满的程度。当填入表中的记录越多,就越大,产生冲突的可能性就越大。比如我们前面的例子,如果你的散列表长度是12,而填入表中的记录个数为11,那 么此时的装填因子=11/12=0.9167,再填入最后一个关键字产生冲突的可能性就非常之大。也就是说,散列表的平均查找长度取决于装填因子,而不是取决于査找集合中的记录个数。
不管记录个数n有多大,我们总可以选择一个合适的装填因子以便将平均查找长 度限定在一个范围之内,此时我们散列査找的时间复杂度就真的是0(1)了。为了做到 这一点,通常我们都是将散列表的空间设置得比查找集合大,此时虽然是浪费了一定 的空间,但换来的是查找效率的大大提升,总的来说,还是非常值得的。