前言: 数组和链表是我们经常使用的数据结构。
数组寻址容易,在知道其下标时获取元素的速度是非常快的 O(1),但在不知道下标的情况下,只能对数组进行线性查找,效率很低,在删除与插入时效率很低,要对该对象后面的数据进行重写覆盖O(n)。
链表插入和删除效率高O(1),但查询效率低O(n)。
有没有一种寻址简单,插入删除也快速的数据结构?嘿,还真有,那就是哈希表。
首先,我们可以确定一哈希函数f 存储位置=f(关键字),通过这个f,使得每个关键字key对应一个存储位置f(key),将记录存储在一块连续的存储空间之中,这块空间就叫哈希表。打个比方,我们可以建立一个哈希表,通过自己的身份证经过哈希函数转化成地址,在哈希表中找到自己。可能有些聪明蛋可能会问,是否存在着自己定的哈希函数,使得不同的key经过哈希函数的转化在哈希表中具有同样的地址?还真有,这是哈希表中的不可避免的一个缺点———哈希冲突,即key1!=key2,但有f(key1)=f(key2).稍后我会简述一下解决方法。
一.哈希表的查询步骤
1.在存储时通过同一个哈希函数计算散列地址,再进行存储。
2.在查找记录时,也通过同一个哈希函数计算地址,进行查找。
一言以蔽之,就是,怎么存的,就去哪找。
二.哈希函数的构造方法
要建立一个好的哈希表,核心的是建立一个好的哈希函数,可以从以下几点来考虑:
1.计算简单.
由于我们每次存储数据和查询数据都需要通过哈希函数来计算地址,如果一个算法需要复杂的时间,那效率肯定会大大降低。如果哈希函数的计算时间比其他查找技术与关键词比较的时间还长,用哈希表也就没有意义了。
2.散列地址分布均匀。让散列地址均匀的分布可以保证存储空间的充分应用,减少哈希冲突的发生。
1.直接定址法
地址 成绩 人数
100 100 121
99 99 1
。。。 。。。
类似上图统计成绩的人数,可以直接用成绩作为地址 ,f(key)=key;
其他的类似的取关键词的线性函数值作为散列地址,都是直接定址法的例子
f(key)=a*key+b
优点:简单,均匀,不会产生冲突。
限制:需要知道关键字的分布情况。
适用:表较小且连续。
2.数字分析法
比如这是我们的学号:
04211001
04211002
03211123
前4位表示入学年分,后三位表示学号
如果登记时把学号作为关键字,那么前4位有很大概率是一样的,那么把后三位学号作为散列地址是不错的选择。如果这样还发生冲突,可以对其进行翻转(321),右环位移(321),左环位移,位数叠加(6)。
总的就是从关键词里抽取一部分来计算散列地址。l
适用:关键字位数较多,事先知道关键字的分布且关键字的若干位分布较为均匀。
3.平方取中法
对关键字先平方,再取中间几位。
比如 1234 平方是1522756 取中277
4321 平方18671041 取中可以是710也可以是671
适用:不知道关键词分布,位数不是很多
4.折叠法
将关键词从左往右分割成位数相等的几部分,再叠加求和,根据散列表表长,取后几位作为散列地址。
比如9876543210,散列表长3,变成987|654|321|0,叠加求和1962,取后三位962
适用:不需要知道关键词的分布,适合关键词较多。
5.除留余数法
f(key)=key mod p (p<=m)
在长度为6的散列表中,比如29mod6=5,下标为5
但如果11mod6=5,就哈希冲突了。
所以p的选取至关重要。
若散列表的表长为m,通常p为小于或等于表长的最小质数或不包含小于20质因子的合数。
6.随机数法
选一个随机数,关键字的随机函数值为它的散列地址。f(key)=random(key).
适用:关键词长度不等。
综上,这么多的哈希函数,选择因素有那些?
1.计算散列地址的时间
2.散列表大小
3。关键字分布情况
4关键字长度
5记录查找的频率
三.处理哈希冲突的方法
1.开放定址法
一旦发生冲突,就取寻找下个空的散列地址,只要散列表足够大,空的散列地址总能找到。
f(key)=(f(key)+di)mod m di=1,2,3,4...m-1
这种解决冲突的开放定址法称为线性探索法,但是这种方法很容易使得,两个本来就不是同一个地址的关键词却需要争夺一个相同的地址,这种现象叫做堆积,我们就需要一直解决冲突,效率会大大降低,或者在该关键词地址的后面已经没有空位但在之前有位置,效率也很低。
所以,改进的方法来了,二次探测法:
f(key)=(f(key)+di)mod m (di=1*1,-1*1,2*2,-2*2.....q<=m/2),
通过增加平方运算,不让关键词都聚集在同一区域。
2.再散列函数法
我们提前准备多几个散列函数,如果冲突了,就换一个函数,虽然能够是的关键词不聚集,但是也增加了运算时间。
3.链地址法
顾名思义,如果有冲突,不用换地方,直接将所有关键词为同义字的记录存储在一个单链表中,散列表中存头指针即可。比如mod4,这样的话,其实就是四个链表了0,1,2,3,其他多的数字都是给上述链表增加节点而已。
链地址法提供了绝对不会找不到地址的保障,但是增加了遍历单链表的性能消耗。
4.公共溢出法
其实就是把冲突的数据单开一个溢出链表存储,在存储的适合,先通过哈希函数计算地址后,在原哈希表中查找,找到就OK,没查到就去溢出表进行顺序查找。如果有冲突的数据很少的情况下,公共溢出区的查找性能还是很高的。
四。哈希表查找的实现代码()
#include <iostream>
#define SUCCESS 1
#define UNSUCCESS 0
#define HASHSIZE 12 //散列表长度
#define NULLKEY -32768
typedef class
{
public:
int *elem;
int count;
} HashTable;
int m = 0; //散列表长度
void InitHashTable(HashTable *H) //初始化
{
int i;
m = HASHSIZE;
H->elem = (int *)malloc(sizeof(int) * m);
H->count = 0;
for (int i = 0; i < m; i++)
{
H->elem[i] = NULLKEY;
}
}
int Hash(int key) //哈希函数
{
return key % m; //除留取余法
}
void InsertHash(HashTable *H, int key) //插入关键词
{
int count = 0, x = 0;
int addr = Hash(key);
while (H->elem[addr] != NULLKEY) //哈希冲突
{
count++;
// addr=(addr+1)%m; //开放定址的线性检测
{
if (count % 2 == 0)
{
if ((addr - x * x) % m > 0)
addr = (addr - x * x) % m;
}
else
{
x++;
addr = (addr + x * x) % m;
}
} //二次探测法
}
H->elem[addr] = key;
}
int SearchHash(HashTable H, int key, int *addr)
{
*addr = Hash(key);
while (H.elem[*addr] != key)
{
*addr = (*addr + 1) % m; //开放定址法的线性探测法
if (H.elem[*addr] == NULLKEY || *addr == Hash(key)) //遍历一遍都没有
{
return UNSUCCESS;
}
}
return SUCCESS;
}
int main()
{
int i, result;
int addr;
HashTable hashTable;
int arr[HASHSIZE] = {1, 2, 12, 24, 13, 15};
//初始化哈希表
InitHashTable(&hashTable);
//利用插入函数构造哈希表
for (i = 0; i < HASHSIZE; i++)
{
InsertHash(&hashTable, arr[i]);
}
//调用查找算法
result = SearchHash(hashTable, 13, &addr);
if (result == 0)
printf("查找失败");
else
printf("13在哈希表中的位置是:%d\n", addr);
for (int i = 0; i < m; i++)
std::cout << hashTable.elem[i] << " ";
free(hashTable.elem);
return 0;
}
性能分析:
在没有冲突的情况下,散列表的查找时间复杂度为O(1),可惜冲突不可避免。散列查找的平均长度取决于以下几点。
1.散列函数是否均匀。散列函数的好坏直接影响着冲突的频繁程度。
2.怎么处理冲突的方法。线性探索会产生堆积,没有二次探索好,而链地址不会产生任何堆积,因此有更佳的性能。
3.装填因子 x=填入表中的个数/散列表长度,因子越大,产生冲突的可能性越大。所以我们通常将散列表的空间设置得比集合大,效率会提高很多。说白了,就是拿空间换时间。