许多应用都需要一种动态集合结构,它至少要支持INSERT,SEARCH,DELETE字典操作,例如,用于程序语言编译的编译器维护了一个符号表,其中元素的关键字为任意字符串,它与程序中的标识符相对应。散列表(hash table)是实现字典操作的一种有效的数据结构。尽管最坏的情况下,散列表中查找一个元素的时间与链表中查找的时间相同,达到了。然而在实际应用中,散列查找的性能是极好的。在一些合理的假设中,在散列表中查找一个元素的平均时间是O(1);
一,直接寻址法
当存储的数据的关键字的范围比较小时,直接寻址是一种简单而有效的技术。假设某应用要用到一个动态集合,其中每个元素都是取自于全域U = {0,1,2...,m-1}中的关键字,这里m不是一个很大的数。另外,假设没有冲突(没有两个元素具有相同的关键字)。
为表示动态集合,我们用一个数组,或称为直接寻址表(direct-address table),记为T[0...m-1]
其中每个位置,或称为槽(slot),对应全域U中的一个关键字。槽k指向集合中一个关键字为k的元素,如果给集合中没有关键字为k的元素,则T[k]为空。
如图:
测试代码:
#include<iostream>
using namespace std;
typedef char DataType;
typedef struct DATA
{
int key;
DataType data;
}DATA;
DataType DIRECT_ADDRESS_SEARCH(DATA* T[], int k) //搜索
{
return T[k]->data;
}
void DIRECT_ADDRESS_INSERT(DATA* T[], DATA& x) //插入数据
{
T[x.key] = &x;
}
void DIRECT_ADDRESS_DELETE(DATA* T[], DATA& x) //删除数据
{
T[x.key] = NULL;
}
int main()
{
DATA a1, a2, a3;
a1.key = 3;
a1.data = 'A';
a2.key = 4;
a2.data = 'B';
a3.key = 5;
a3.data = 'C';
DATA* T[9];
for (int i = 0; i < 9; ++i)
{
T[i] = NULL;
}
DIRECT_ADDRESS_INSERT(T, a1);
DIRECT_ADDRESS_INSERT(T, a2);
DIRECT_ADDRESS_INSERT(T, a3);
//数据存到寻址表之后再需要寻找数据可直接通过关键字查找
for (int i = 0; i < 9; ++i)
{
printf("T[%d]->", i);
if(T[i])
printf("[%d][%c]\n", T[i]->key, T[i]->data);
else
printf("NULL\n");
}
printf("T[3]=%c\n", DIRECT_ADDRESS_SEARCH(T, 3));
printf("T[4]=%c\n", DIRECT_ADDRESS_SEARCH(T, 4));
printf("T[5]=%c\n", DIRECT_ADDRESS_SEARCH(T, 5));
DIRECT_ADDRESS_DELETE(T, a1);
for (int i = 0; i < 9; ++i)
{
printf("T[%d]->", i);
if (T[i])
printf("[%d][%c]\n", T[i]->key, T[i]->data);
else
printf("NULL\n");
}
}
输出:
T[0]->NULL
T[1]->NULL
T[2]->NULL
T[3]->[3][A]
T[4]->[4][B]
T[5]->[5][C]
T[6]->NULL
T[7]->NULL
T[8]->NULL
T[3]=A
T[4]=B
T[5]=C
T[0]->NULL
T[1]->NULL
T[2]->NULL
T[3]->NULL
T[4]->[4][B]
T[5]->[5][C]
T[6]->NULL
T[7]->NULL
T[8]->NULL
对于某些应用,直接寻址表本身就可以存放动态集合中的元素。也就是说,并不把每个元素的关键字及其数据都放在直接寻址表外部的一个对象中,再由表中某个槽的指针指向该对象,而是直接把该对象存放在表的槽中,从而节省了空间。我们使用对象内的特殊关键字来表明该槽为空槽。而且,通常不必存储该对象的关键字属性,因为如果直到一个对象在表中的下标,就可以得到它的关键字。
二,散列表
直接寻址技术的缺点是非常明显的:如果全域U很大,则在一台标准的计算机可用内存容量中,要存储大小为|U|的一张表T也许不太实际,甚至是不可能的。还有,实际存储的关键字集合K相对U来说可能很小,使得分配给T的大部分空间都将浪费掉。
在散列方式下,该元素存放在槽h(k)中;即利用散列函数(hash function) h,由关键字k计算出槽的位置。这里,函数h将关键字的全域U映射到散列表(hash table)T[0...m-1]的槽位上:
这里存在一个问题:两个关键字可能映射到同一个槽中。我们称这种情形为冲突(collision)。幸运的是,我们能找到有效的方法来解决冲突。
当然,理想的解决方法是避免所有的冲突。我们可以试图选择一个合适的散列函数h。但是,由于U>m,故至少有两个关键字其散列值相同,所以要完全避免冲突是不可能的。因此,我面一方面可以通过精心设计的散列函数来尽量减少冲突的次数,另一方面仍需要有解决冲突的办法。
我们先介绍一种冲突解决方法,称为链接法(chaining)。
链接法
把散列到同一个槽中的元素都放在一个链表中。
如图:
#include<iostream>
using namespace std;
typedef char DataType;
typedef struct DATA
{
int key;
DataType data;
struct DATA* next = NULL;
}DATA;
//散列函数:除法散列法
int h(int k)
{
return k % 9;
}
bool CHAINED_HASH_SEARCH(DATA* T[], int k, DataType& x) //搜索
{
DATA* a = T[h(k)];
while (a && a->key != k)
{
a = a->next;
}
if (a)
{
x = a->data;
return true;
}
printf("没有找到key=%d的元素\n", k);
return false;
}
void CHAINED_HASH_INSERT(DATA* T[], DATA& x) //插入数据
{
if (T[h(x.key)])
x.next = T[h(x.key)];
T[h(x.key)] = &x;
}
void CHAINED_HASH_DELETE(DATA* T[], DATA& x) //删除数据
{
DATA* pre = NULL, * cur;
cur = T[h(x.key)];
while (cur && cur->key != x.key)
{
pre = cur;
cur = cur->next;
}
if (cur == NULL)
{
printf("没有该元素\n");
return;
}
if (pre == NULL)
T[h(x.key)] = cur->next;
else
{
pre->next = cur->next;
}
printf("已删除元素%c\n", x.data);
}
int main()
{
DATA a1, a2, a3, a4,a5;
a1.key = 235;
a1.data = 'A';
a2.key = 127;
a2.data = 'B';
a3.key = 32;
a3.data = 'C';
a4.key = 215;
a4.data = 'D';
a5.key = 24;
a5.data = 'E';
DATA* T[9];
for (int i = 0; i < 9; ++i)
{
T[i] = NULL;
}
CHAINED_HASH_INSERT(T, a1);
CHAINED_HASH_INSERT(T, a2);
CHAINED_HASH_INSERT(T, a3);
CHAINED_HASH_INSERT(T, a4);
CHAINED_HASH_INSERT(T, a5);
for (int i = 0; i < 9; ++i)
{
printf("T[%d]->", i);
DATA* p = T[i];
while (p)
{
printf("[%d][%c]->", p->key, p->data);
p = p->next;
}
printf("NULL\n");
}
DataType x;
int n = 235;
if (CHAINED_HASH_SEARCH(T, n, x))
printf("找到了key=%d的元素,元素为:%c\n",n, x);
CHAINED_HASH_DELETE(T, a1);
for (int i = 0; i < 9; ++i)
{
printf("T[%d]->", i);
DATA* p = T[i];
while (p)
{
printf("[%d][%c]->", p->key, p->data);
p = p->next;
}
printf("NULL\n");
}
if (CHAINED_HASH_SEARCH(T, n, x))
printf("找到了key=%d的元素,元素为:%c\n", n, x);
}
结果:
T[0]->NULL
T[1]->[127][B]->[235][A]->NULL
T[2]->NULL
T[3]->NULL
T[4]->NULL
T[5]->[32][C]->NULL
T[6]->[24][E]->NULL
T[7]->NULL
T[8]->[215][D]->NULL
找到了key=235的元素,元素为:A
已删除元素A
T[0]->NULL
T[1]->[127][B]->NULL
T[2]->NULL
T[3]->NULL
T[4]->NULL
T[5]->[32][C]->NULL
T[6]->[24][E]->NULL
T[7]->NULL
T[8]->[215][D]->NULL
没有找到key=235的元素
用链接法散列的最坏的情况性能很差:所有的n个关键字都散列到同一个槽中,从而产生出一个长度为n的链表。这时,最坏情况下的查找时间为,再加上计算散列函数的时间,如此就和用一个链表来链接所有元素差不多了。
散列方法的平均性能依赖于所选的散列函数h,是否能将所有的关键字集合均匀分布在m个槽位上。
三,散列函数
1.除法散列法
在用来设计散列函数的除法散列法中,通过取k除以m的余数,将关键字k映射到m个槽中的某一个上,即散列函数为:
h(k) = k mod m
例如,如果散列表的大小为m=12,所给关键字k=100,则h(k)=4。由于只要做一次除法操作,所以除法散列法是非常快的。
注有两个极端:
(1)当散列表的大小取偶数,然而所给的关键字全部都是偶数,那么这样取余的结果都会是偶数,导致奇数的槽位没有数据。
证: 2n mod 2m = 2( n mod m )
所以散列表的大小尽量不要取偶数
(2)当散列表的大小为2的幂时,即
与2的幂相同性质的还有10的幂,这个要好理解,当你散列表大小取10 ,那么只要个位数不变,那么h(k)就不变。
一般一个不太接近2的整数幂的素数,常常是m的一个较好的选择。
2.乘法散列法
构造散列函数的乘法散列法包含两个步骤。第一步,用关键字k乘上常数A(0<A<1),并提取k*A的小数部分。第二步,用m乘以这个值,再向下取整。总之,散列函数为:
h(k) =floor(m*fmod(k*A , 1) )
fmod() 函数返回 x / y 的模,也就是余数。floor()为向下取整。都包含于头文件math.h中。
乘法散列的优点是对m的取值不是特别关键,虽然这个方法对任何A(0~1)都适用,计算机鼻祖Knuth认为A值取0.618效果最好。
四,开放寻址法
开放寻址法的好处就在于它不用指针,而是计算出要存取的槽序列。于是,不用存储指针节省的空间,使得可以用同样的空间来提供更多的槽,潜在的减少了冲突,提高了检索速度。
为了使用开放寻址法插入一个元素,需要连续的检查散列表,或称为探查(probe),直到找到一个空槽来放置待插入的关键字为止。检查的顺序不一定是0,1,2.......m-1,而是依赖于插入的关键字通过散列函数第一次计算的位置开始,若这位置有人就进行第二次探查,以此类推。我们将散列函数加以扩充,使之包含探查号(从0开始)作为第二个输入参数。
h( k , i )
如图
搜索也是一样的,先通过散列函数计算出位置,如果不是就继续探查。找到了就返回数据,当探查到空时就放回NULL
从开放寻址法的散列表中删除操作元素比较困难,当我们从槽 i 中删除关键字时,不能仅将NULL之余其中来标识它为空。如果这样做,就会有问题:插入关键字k时,发现槽 i 被占用了,则k就被插入到后面的位置上;此时将槽 i 的位置删除后,这样搜索k的时候搜索到 i 出为空就放回了,不会继续向后,有一个解决方案,就是在槽 i 中置一个特定的值DELETE代替NULL来标记该槽。碰到DELETE就继续探查。
1.线性探查
线性探查采用的散列函数为:
h( k, i ) = ( h( k , 0 ) + i ) mod m , i = 0,1,2.......m-1
线性探查方法比较容易实现,但它存在着一个问题,称为一次集群(primary clustering)。随着连续被占用的槽不断增加,平均查找时间也随着增加。
2.二次探查
二次探查采用如下散列函数:
h(k,i)=(h_1(k)+c1*i+c2*i^2) mod m
其中h_1是一个辅助散列函数。c1和c2为正的辅助常数,i= 0,1,2.......m-1,初始探查位置为T[h_1(k)],后续的探查位置要加上一个偏移量。该探查比线性好的多,但是,如果两个关键字的初始探测位置相同,那么它们的探查序列也是相同的(因为初始探查位置相同,后面的变化是和k无关的),这一性质可导致一种轻度集群,称为二次集群(secondary clustering)。就像我们在线性探查中一样初始探查位置决定了整个序列。
3.双重散列
双重散列是用于开放寻址法的最好方法之一,因为它所产生的排列具有随机选择排序的许多特征。双重散列采用的散列函数如下:
h(k,i)=(h_1(k) +i*h_2(k) ) mod m
其中h_1和h_2均为辅助散列函数。i =0,初始探查位置为T[h_2(k)],后续的探查位置是前一个位置加上偏移量h_2(k)然后模m。因此,不像线性探查或二次探查,就算两个关键字的初始探查位置相同,也可以通过h_2该辅助散列函数使后面的探查位置不同。
简单的实现了一下双重散列的开放寻址法:
#include<stdio.h>
typedef struct Open_Addressing
{
int label=0;
int key;
}OpenA;
//辅助散列函数1
int h1(int k)
{
return k % 8;
}
//辅助散列函数2
int h2(int k)
{
return (k % 9) + 1;
}
//主散列函数
int h(int k, int i)
{
return (h1(k) + i * h2(k)) % 8;
}
//插入
void HASH_INSERT(OpenA T[], int k)
{
int i = 0;
while (T[h(k, i)].label != 0)
{
++i;
}
T[h(k, i)].key = k;
T[h(k, i)].label = 1;
}
//搜索
bool HASH_SEARCH(OpenA T[], int k)
{
int i = 0;
while (T[h(k, i)].label != 0&& T[h(k, i)].key != k)
{
++i;
}
if (T[h(k, i)].label == 0)
return false;
else
return true;
}
//删除
void HASH_DELETE(OpenA T[], int k)
{
int i = 0;
while (T[h(k, i)].label != 0 && T[h(k, i)].key != k)
{
++i;
}
T[h(k, i)].label = -1;
}
//输出
void Print(OpenA T[])
{
for (int i = 0; i < 8; ++i)
{
printf("T[%d][%d]", i, T[i].label);
if (T[i].label == 1)
printf("->%d", T[i].key);
printf("->NULL\n");
}
}
int main()
{
OpenA T[8];
HASH_INSERT(T, 765);
HASH_INSERT(T, 23);
HASH_INSERT(T, 43);
HASH_INSERT(T, 55);
HASH_INSERT(T, 234);
Print(T);
if (HASH_SEARCH(T, 23))
printf("找到了\n");
else
printf("没找到\n");
if(HASH_SEARCH(T,999))
printf("找到了\n");
else
printf("没找到\n");
HASH_DELETE(T, 23);
HASH_DELETE(T, 55);
HASH_DELETE(T, 234);
Print(T);
}
T[0][0]->NULL
T[1][1]->55->NULL
T[2][1]->234->NULL
T[3][1]->43->NULL
T[4][0]->NULL
T[5][1]->765->NULL
T[6][0]->NULL
T[7][1]->23->NULL
找到了
没找到
T[0][0]->NULL
T[1][-1]->NULL
T[2][-1]->NULL
T[3][1]->43->NULL
T[4][0]->NULL
T[5][1]->765->NULL
T[6][0]->NULL
T[7][-1]->NULL