之前介绍的几种查找算法,无论是有序表查找还是平衡二叉树(二叉查找树),都需要进行元素间的比较或遍历,因此效率最高也只是O(logn),那有没有不需要进行元素间的比较或遍历直接就能查找到所需数据的算法呢?还真有!那就是通过关键字key进行查找的算法——散列表查找,我们通常称为哈希表。
简介
查找算法的效率在很大程度上依赖于数据结构或存储模式,之前介绍的查找算法之所以达不到很高的效率是因为存储模式的原因,我们之前使用顺序表存储数据时,要么是通过数据的某些特性进行排序,要么是通过数据插入的先后顺序进行排序,并且我们不知道所存储数据和它们位置之间的关系,因此我们查找时自然费一些功夫才能查找成功。但如果我们建立一个数据和它存储位置之间的映射,设置每个数据和它存储位置之间的关系,其实就是相当于人们的名字一样,比如我们去学校找一个人叫“张三”,我们肯定不会自己去一个个的问,那样太傻了!我们需要通过一个媒介(算法),比如我们去学生处找,学生处的老师拿出学生目录从头到尾一个一个找,终于找到了他的宿舍(这就是顺序查找),但如果我们通过张三的室友(哈希表)就可以直接找到他的宿舍。
通过张三的室友比通过学生处找张三更快的原因就在于,学生处的老师不知道张三住在哪所以要通过一个表按顺序查找,而张三的同学知道张三住在哪,所以直接就可以找到,这时有同学该问了,我们是如何找到张三的室友的呢?其实这两张三的室友就代表着张三和他住所之间的映射关系,我们找人时只需提供名字,然后再通过这个人的室友(映射)找到这个人的住址。这就是哈希表的查找思想,其中的关键就是提供一个数据的存储位置和它关键字之间的映射(函数)f,使每个关键字key对应一个存储位置f(key):
存
储
位
置
=
f
(
k
e
y
)
存储位置=f(key)
存储位置=f(key)我们称这个映射f为散列函数,又称哈希(Hash)函数。存储数据所使用的的数据结构仍为一块连续的存储空间,称为散列表或哈希表。
哈希表查找步骤
哈希表查找的过程其实就两步:
- 存储数据:通过哈希函数计算每个关键字代表的数据的地址,然后按其地址存储数据,如下图所示,如果是“张三丰”,我们就让他在体育馆,如果是“爱因斯坦”,我们就让他在图书馆…。
- 查找数据:通过同样的哈希函数计算你所提供的关键字的存储地址,按其地址直接访问数据。
可知哈希表既是一种查找技术,也是一种存储技术,它与线性表、树和图等结构不同,前几种结构各数据之间都存在某种逻辑关系,但哈希表中的数据之间不存在什么逻辑关系,只有各数据的关键字与其地址的关系。它最适合的求解问题是查找与给定值相等的记录。但有利必有弊,哈希表也存在很多问题。
比如同样的关键字对应很多记录的情况,就不适合哈希查找。如一个班中的学生,我们将性别当做关键字就不适合,因为性别只有男或女,其一个关键字可能对应很多记录。相比之下使用学号或身份证号就更适合,这就引出一个设计哈希函数很重要的原则,那就是使各关键字与其地址的映射是唯一的。
另一个问题是冲突,如果只让各关键字与其地址的映射是唯一的是不够的,还可能出现不同的关键字映射相同的地址的问题:f(key1)=f(key2),此时我们把key1和key2称为同义词。当数据量很大时冲突在哈希表中是无法避免的,所以设计哈希函数另一个重要的原则就是,冲突应尽可能少发生。现在让我们按照这两个原则构造哈希函数。
哈希函数构造方法
哈希函数的构造方法有很多,这里限于篇幅就只介绍一种最常用的方法——除留余数法,看下面函数: f ( k e y ) = k e y / p ( p < = m ) f(key)=key/p(p<=m) f(key)=key/p(p<=m)下表便是采用除留余数法计算出的关键字与其地址下标的映射,我们取 p = 12 p=12 p=12。
下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
---|---|---|---|---|---|---|---|---|---|---|---|---|
关键字 | 12 | 25 | 38 | 15 | 16 | 29 | 78 | 67 | 56 | 21 | 22 | 47 |
但另 p = 12 p=12 p=12是存在风险的,因为 12 = 2 ∗ 6 = 3 ∗ 4 12=2*6=3*4 12=2∗6=3∗4比如现在插入数字 18 ( 3 ∗ 6 ) 、 30 ( 5 ∗ 6 ) 、 42 ( 7 ∗ 6 ) 18(3*6)、30(5*6)、42(7*6) 18(3∗6)、30(5∗6)、42(7∗6)等,它们余数都为6,这就和78所存储位置冲突了,如若出现更极端的情况,如数据关键字全都是12的整数倍,那所有关键字都得出0这个余数,所有的数据都将冲突。那我们将 p = 11 p=11 p=11如何呢?看下表。
下标 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 0 | 1 |
---|---|---|---|---|---|---|---|---|---|---|---|---|
关键字 | 12 | 24 | 36 | 48 | 60 | 72 | 84 | 96 | 108 | 120 | 132 | 144 |
我们发现即使数据关键字全都是12的整数倍,冲突的数据也只有12和144。
根据前人经验,我们通常遵循一个原则:若哈希表表长为
m
m
m,我们通常另
p
p
p小于等于表长的最小质数或不包括小于20质因子的合数。
处理冲突
那如果发生了无法避免的冲突怎么办呢?我们有几种解决的方法,如开放定址法、再哈希函数法或链地址法。我们在这里讲解开放定址法。
开放定址法是指一旦发生冲突就寻找下一个可用的地址。为了总是可以找到我们需要将存储空间设置比较大,公式为:
f
(
k
e
y
)
=
(
f
(
k
e
y
)
+
d
)
/
m
(
d
<
=
m
−
1
)
f(key)=(f(key)+d)/m(d<=m-1)
f(key)=(f(key)+d)/m(d<=m−1)例如我们存储关键字集合{12,67,56,16,25,37,22,29,15,47,48,34},表长12。我们使用哈希函数
f
(
k
e
y
)
=
k
e
y
/
12
f(key)=key/12
f(key)=key/12。
计算前5个数时没有冲突:
下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
---|---|---|---|---|---|---|---|---|---|---|---|---|
关键字 | 12 | 25 | 16 | 67 | 56 |
计算 k e y = 37 key=37 key=37,发现 f ( 37 ) = 1 f(37)=1 f(37)=1,与25发生冲突,我们使用上述公式 f ( 37 ) = ( f ( 37 ) + 1 ) / 12 = 2 f(37)=(f(37)+1)/12=2 f(37)=(f(37)+1)/12=2,将37存入下标2的位置。然后存入22,29,15,47都没有冲突。
下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
---|---|---|---|---|---|---|---|---|---|---|---|---|
关键字 | 12 | 25 | 37 | 15 | 16 | 29 | 67 | 56 | 22 | 47 |
然后存储48, f ( 48 ) = 0 f(48)=0 f(48)=0又发生冲突,同理 f ( 48 ) = ( f ( 48 ) + 1 ) / 12 = 1 f(48)=(f(48)+1)/12=1 f(48)=(f(48)+1)/12=1仍冲突,我们将 d + 1 d+1 d+1, f ( 48 ) = ( f ( 48 ) + 2 ) / 12 = 2 f(48)=(f(48)+2)/12=2 f(48)=(f(48)+2)/12=2,还冲突,继续…直到 f ( 48 ) = ( f ( 48 ) + 6 ) / 12 = 6 f(48)=(f(48)+6)/12=6 f(48)=(f(48)+6)/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这种本来不为同义词的数据仍需要争夺同一地址,我们称这种情况为堆积。很显然堆积会降低效率,那我们可以使用下面式子降低堆积的频率。
f
(
k
e
y
)
=
(
f
(
k
e
y
)
+
d
)
/
m
(
d
=
1
2
,
−
1
2
,
2
2
,
−
2
2
,
.
.
.
,
q
2
,
−
q
2
(
q
<
=
m
/
2
)
)
f(key)=(f(key)+d)/m(d=1^2,-1^2,2^2,-2^2,...,q^2,-q^2(q<=m/2))
f(key)=(f(key)+d)/m(d=12,−12,22,−22,...,q2,−q2(q<=m/2))我们称这种方法为二次探测法。
代码实现
首先我们定义一个哈希表结构及相关常数,我们使用一个结构体HashTable,其中包括一个指向存储数据的数组的指针elem和一个记录元素个数的count。
#define SUCCESS 1
#define UNSUCCESS 0
#define HASHSIZE 12 //哈希表容量
#define NULLKEY -32768
using namespace std;
typedef struct HashTable{
int *elem;
int count;
};
int m = 0; //哈希表长
然后对哈希表初始化。
bool 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 true;
}
哈希函数。
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;
}
哈希查找。
bool 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))
return UNSUCCESS;
}
return SUCCESS;
}
性能分析
综上所述,如果没有冲突的话,哈希查找的时间复杂度为O(1),可惜这只是一种理想的情况,事实上当数据量巨大时冲突是不可避免的。所以为了使冲突尽量少发生,我们通常将哈希表的空间设置的很大,这样虽然浪费了一定空间但换来了查找效率的提高,所以哈希查找适用于对查找效率要求很高但对其空间效率要求不高的情况。