一、散列表相关概念
散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系f,使得每个关键字key对应一个存储位置f(key)。
这里把这种对应关系f称为散列函数,又称为哈希(Hash)函数。按这个思想,采用散列技术将记录存在在一块连续的存储空间中,这块连续存储空间称为散列表或哈希表。那么,关键字对应的记录存储位置称为散列地址。
散列技术最适合的求解问题是查找与给定值相等的记录。对于查找来说,简化了比较过程,效率会大大 提高。但是,散列技术部具备很多常规数据结构的能力,如比较同样的关键字,对应很多记录的情况,不适合用散列技术;散列表也不适合范围查找等等。
在理想的情况下,每一个关键字,通过散列函数计算出来的地址都是不一样的,可现实中,这只是一个理想。市场会碰到两个关键字key1 != key2,但是却有f(key1) = f(key2),这种现象称为冲突。出现冲突将会造成查找错误,因此可以通过精心设计散列函数让冲突尽可能的少,但是不能完全避免。
二、散列函数的构造方法
2.1 直接定址法
所谓直接定址法就是说,取关键字的某个线性函数值为散列地址,即
优点:简单、均匀,也不会产生冲突。
缺点:需要事先知道关键字的分布情况,适合查找表较小且连续的情况。
由于这样的限制,在现实应用中,此方法虽然简单,但却并不常用。
2.2 数字分析法
如果关键字时位数较多的数字,比如11位的手机号"130****1234",其中前三位是接入号;中间四位是HLR识别号,表示用户号的归属地;后四为才是真正的用户号。如下图所示。
如果现在要存储某家公司的登记表,若用手机号作为关键字,极有可能前7位都是相同的,选择后四位成为散列地址就是不错的选择。若容易出现冲突,对抽取出来的数字再进行反转、右环位移等。总的目的就是为了提供一个散列函数,能够合理地将关键字分配到散列表的各个位置。
数字分析法通过适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布比较均匀,就可以考虑用这个方法。
2.3 平方取中法
这个方法计算很简单,假设关键字是1234,那么它的平方就是1522756,再抽取中间的3位就是227,用做散列地址。
平方取中法比较适合不知道关键字的分布,而位数又不是很大的情况。
2.4 折叠法
折叠法是将关键字从左到右分割成位数相等的几部分(注意最后一部分位数不够时可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
比如关键字是9876543210,散列表表长为三位,将它分为四组,987|654|321|0,然后将它们叠加求和987 + 654 + 321 + 0 = 1962,再求后3位得到散列地址962。
折叠法事先不需要知道关键字的分布,适合关键字位数较多的情况。
2.5 除留余数法
此方法为最常用的构造散列函数方法。对于散列表长为m的散列函数公式为:
mod是取模(求余数)的意思。事实上,这方法不仅可以对关键字直接取模,也可以再折叠、平方取中后再取模。
很显然,本方法的关键在于选择合适的p,p如果选不好,就可能会容易产生冲突。
根据前辈们的经验,若散列表的表长为m,通常p为小于或等于表长(最好接近m)的最小质数或不包含小于20质因子的合数。
2.6 随机数法
选择一个随机数,取关键字的随机函数值为它的散列地址。也就是f(key) = random(key)。这里random是随机函数。当关键字的长度不等时,采用这个方法构造散列函数是比较合适的。
总之,现实中,应该视不同的情况采用不同的散列函数,这里只能给出一些考虑的因素来提供参考:
(1)计算散列地址所需的时间
(2)关键字的长度;
(3)散列表的长度;
(4)关键字的分布情况;
(5)记录查找的频率。
综合以上等因素,才能决策选择哪种散列函数更合适。
三、处理散列冲突的方法
3.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}时,都是没有冲突的散列地址,直接存入,如下表所示。
计算key = 37时,发现f(37) = 1,此时就与25所在的位置冲突。于是应用上面的公式f(37) = (f(37) + 1) mod 12 =2,。于是将37存入下标为2的位置。如下表所示。
接下来22,29,15,47都没有冲突,正常的存入,如下标所示。
到了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时,才有空位,如下表所示。
把这种解决冲突的开放定址法称为线性探测法。
考虑深一步,如果发生这样的情况,当最后一个key = 34,f(key) = 10,与22所在的位置冲突,可是22后面没有空位置了,反而它的前面有一个空位置,尽管可以不断地求余后得到结果,但效率很差。因此可以改进di=12, -12, 22, -22.........q2, -q2(q<= m/2),这样就等于是可以双向寻找到可能的空位置。对于34来说,取di = -1即可找到空位置了。另外,增加平方运算的目的是为了不让关键字都聚集在某一块区域。称这种方法为二次探测法。
还有一种方法,在冲突时,对于位移量di采用随机函数计算得到,称之为随机探测法。
既然是随机,那么查找的时候不也随机生成di 吗?如何取得相同的地址呢?这里的随机其实是伪随机数。伪随机数就是说,如果设置随机种子相同,则不断调用随机函数可以生成不会重复的数列,在查找时,用同样的随机种子,它每次得到的数列是想通的,相同的di 当然可以得到相同的散列地址。
总之,开放定址法只要在散列表未填满时,总是能找到不发生冲突的地址,是常用的解决冲突的方法。
3.2 再散列函数法
对于散列表来说,可以事先准备多个散列函数。
这里RHi 就是不同的散列函数,可以把前面说的除留余数、折叠、平方取中全部用上。每当发生散列地址冲突时,就换一个散列函数计算。
这种方法能够使得关键字不产生聚集,但相应地也增加了计算的时间。
3.3 链地址法
将所有关键字为同义词的记录存储在一个单链表中,称这种表为同义词子表,在散列表中只存储所有同义词子表前面的指针。对于关键字集合{12, 67, 56, 16, 25, 37, 22, 29, 15, 47, 48, 34},用前面同样的12为余数,进行除留余数法,可以得到下图结构。
此时,已经不存在什么冲突换地址的问题,无论有多少个冲突,都只是在当前位置给单链表增加结点的问题。
链地址法对于可能会造成很多冲突的散列函数来说,提供了绝不会出现找不到地址的保证。当然,这也就带来了查找时需要遍历单链表的性能损耗。
3.4 公共溢出区法
这个方法其实更好理解,你冲突是吧?那重新给你找个地址。为所有冲突的关键字建立一个公共的溢出区来存放。
就前面的例子而言,共有三个关键字37、48、34与之前的关键字位置有冲突,那就将它们存储到溢出表中。如下图所示。
在查找时,对给定值通过散列函数计算出散列地址后,先与基本表的相应位置进行比对,如果相等,则查找成功;如果不相等,则到溢出表中进行顺序查找。如果相对于基本表而言,有冲突的数据很少的情况下,公共溢出区的结构对查找性能来说还是非常高的。
四、散列表查找实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
|
#include
#include
#define OK 1
#define ERROR 0
#define SUCCESS 1
#define UNSUCCESS 0
#define HASHSIZE 12 //定义散列表表未数组的长度
#define NULLKEY -32768
typedef struct
{
int *elem; //数据元素存储基地址,动态分配数组
int count; //当前数据元素个数
}HashTable;
int m = 0; //散列表长,全局变量
//初始化散列表
int InitHashTable(HashTable *h)
{
int i;
m = HASHSIZE;
h->elem = (int *)malloc(sizeof(int) * m );
if(h->elem == NULL)
{
fprintf(stderr, "malloc() error.\n");
return ERROR;
}
for(i = 0; i < m; i++)
{
h->elem[i] = NULLKEY;
}
return OK;
}
//散列函数
int Hash(int key)
{
return key % m; //除留余数法
}
//插入关键字进散列表
void InsertHash(HashTable *h, int key)
{
int addr = Hash(key); //求散列地址
while(h->elem[addr] != NULLKEY) //如果不为空,则冲突
{
addr = (addr + 1) % m; //开放地址法的线性探测
}
h->elem[addr] = key; //直到有空位后插入关键字
}
//散列表查找关键字
int SearchHash(HashTable h, int key)
{
int addr = Hash(key); //求散列地址
while(h.elem[addr] != key) //如果不为空,则冲突
{
addr = (addr + 1) % m; //开放地址法的线性探测
if(h.elem[addr] == NULLKEY || addr == Hash(key))
{
//如果循环回原点
printf("查找失败, %d 不在Hash表中.\n", key);
return UNSUCCESS;
}
}
printf("查找成功,%d 在Hash表第 %d 个位置.\n", key, addr);
return SUCCESS;
}
int main(int argc, char **argv)
{
int i = 0;
int num = 0;
HashTable h;
//初始化Hash表
InitHashTable(&h);
//未插入数据之前,打印Hash表
printf("未插入数据之前,Hash表中内容为:\n");
for(i = 0; i < HASHSIZE; i++)
{
printf("%d ", h.elem[i]);
}
printf("\n");
//插入数据
printf("现在插入数据,请输入(A代表结束哦).\n");
while(scanf("%d", &i) == 1 && num <hashsize) div="" <="">
{
if(i == 'A')
{
break;
}
num++;
InsertHash(&h, i);
}
if(num > HASHSIZE)
{
printf("插入数据超过Hash表大小\n");
return ERROR;
}
//打印插入数据后Hash表的内容
printf("插入数据后Hash表的内容为:\n");
for(i = 0; i < HASHSIZE; i++)
{
printf("%d ", h.elem[i]);
}
printf("\n");
printf("现在进行查询.\n");
SearchHash(h, 12);
SearchHash(h, 100);
return 0;
}
|
五、散列表的性能分析
如果没有冲突,散列查找是所介绍过的查找中效率最高的。因为它的时间复杂度为O(1)。但是,没有冲突的散列只是一种理想,在实际应用中,冲突是不可避免的。
那散列查找的平均查找长度取决于哪些因素呢?
(1)散列函数是否均匀
散列函数的好坏直接影响着出现冲突的频繁程度,但是,不同的散列函数对同一组随机的关键字,产生冲突的可能性是相同的(为什么??),因此,可以不考虑它对平均查找长度的影响。
(2)处理冲突的方法
相同的关键字、相同的散列函数,但处理冲突的方法不同,会使得平均查找长度不同。如线性探测处理冲突可能会产生堆积,显然就没有二次探测好,而链地址法处理冲突不会产生任何堆积,因而具有更好的平均查找性能。
(3)散列表的装填因子
所谓的装填因子a = 填入表中的记录个数/散列表长度。a标志着散列表的装满的程度。当填入的记录越多,a就越大,产生冲突的可能性就越大。也就说,散列表的平均查找长度取决于装填因子,而不是取决于查找集合中的记录个数。
不管记录个数n有多大,总可以选择一个合适的装填因子以便将平均查找长度限定在一个范围之内,此时散列表的查找时间复杂度就是O(1)了。为了这个目标,通常将散列表的空间设置的比查找表集合大。