数据结构---散列表查找(哈希表)概述和简单实现(Java)
散列表查找定义
散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系f,是的每个关键字key对应一个存储位置f(key)。查找时,根据这个确定的对应关系找到给定值的key的对应f(key)。
我们把这种对应关系f称为散列函数,又称哈希(Hash)函数,按这个思想,采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间成为散列表或哈希表。关键字对应的记录存储位置我们成为散列地址。
查找时的步骤:
- 在存储时,通过散列函数计算记录的散列地址,并按散列地址存储该记录。
- 当查找记录时,通过同样的散列函数计算记录的散列地址,按散列地址访问该记录
所以说,散列技术既是一种存储方法,又是一种查找方法,散列技术的记录之间不存在什么逻辑关系,它只与关键字有关。散列技术最适合的求解问题就是查找和给定值相等的记录。
散列冲突:当两个关键字!=,但是却有f() =f(),这种现象我们叫做散列冲突,并把和称为这个散列函数的同义词。
散列函数的构造方法
散列函数的构造方法遵循两个原则:
- 计算简单
- 散列地址分布均匀
直接定址法
比如我们现在要统计80后出生年份的人口数,那么我们对出生年份这个关键字可以用年份减去1980来作为地址。此时f(key)=key-1980。
地址 | 出生年份 | 人数 |
---|---|---|
0 | 1980 | 1500万 |
01 | 1981 | 1600万 |
02 | 1982 | 1300万 |
… | … | … |
2000 | 2000 | 800万 |
直接定址法就去取关键字的某个线性函数值为散列地址
f(key)=a * key +b
这样的散列函数的优点就是简单均匀,也不会产生冲突,但问题是需要事先知道关键字的分布情况,适合查找表较小且连续的情况。所以这个方法并不常用。
数字分析法
如果我们的关键字是位数较多的数字,比如用11位的手机号"130xxxx1234",其中前三位是接入号,中间四位是HLR识别号,后四位才是真正的用户号,那么我们选择后四位作为散列地址就是不错的选择如果害怕存在冲突现象,我们还可以进行对数字的翻转,右环位移等方法。
数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布均匀,可以考虑使用这个方法。
平方取中法
假设关键字是1234,那么它的平方就是1522756,在抽取中间的三位就是227,用作散列地址。
平方取中法适合不知道关键字的分布,而位数又不是很大的情况
折叠法
折叠法是将关键字从左到右分割成位数相等的几部分(注意最后一部分位数不够可以短些),然后将这几部分进行叠加求和,并按散列表表长,取后几位作为散列地址。
比如我们的关键字是9876543210,散列表表长为3位,我们将它分为4组,987|654|321|0,然后将他们叠加求和为987+654+321+0=1962,在求后面的三位为散列地址962。
折叠法实现不需要知道关键字的分布,适合关键字较多的情况。
除留取余法
此方式为最常用的构造函数方法。对于散列表长为m的散列函数公式为:
f(key) = key mod p (p <= m)
mod是取模(求余数)的意思,该方法的关键就是在于选取合适的p,p如果选的不好,就可能容易出现同义词。
一般来说,若散列表表长为m,通常p为小于或等于表长(最好接近m)的最小质数或不包含小于20质因子的合数。
随机数法
选择一个随机数,取关键字的随机函数值为它的散列地址。也就是f(key)=random(key)。这里random是随机函数,如果关键字长度不等的情况下,采用这个方法构造散列函数是比较合适的。
对于字符串的处理,可以将其转化为ASCII或Unicode码。
总结
在选取散列函数时应从一下因素考虑:
- 计算散列地址所需的时间
- 关键字的长度
- 散列表的大小
- 关键字的分布情况
- 记录查找的频率
综合这些因素,才能决策选择哪种散列函数更合适
处理散列冲突的方法
开放定址法
开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能寻找到,并将记录记下。常用的开放定址法有:线性探查法、二次探查法、随机探测法
线性探测法
设映射函数为f,表的规模为m,被映射的关键字是key。如果在表中散列位置f(key)上发生冲突,那么线性探查法依次检查位置(f(key) + )mod m, i=1,2,…,直到某个(f(key) + )是空位置,或者(f(key) + )mod m = f(key)结束。
(key) = (f(key) + ) MOD m (=1, 2, 3, 4, 5, …, m-1)
线性探测法存在一个问题:考虑最坏情况下,所有的存储值都同一位置存在冲突,每次寻找一个新的位置存储数据,第一次冲突寻找1次,第二次冲突2次,直到第N-1次冲突,需要寻找N-1次。像这种本来不是同义词却需要争夺一个地址的情况,我们称为堆积。堆积的出现,使得我们需要不断的处理冲突,这种堆积效应使得插入和查找的复杂度都变为O(N)。
二次探测法
加入一个散列表最后的key=34,f(key)=10,与它之前的22所在位置冲突,但是22后面没有空位置了,反而它的前面有一个空位置,尽管我们可以不断的求余数后得到结果,但是效率很差,因此我们可以改进=,,,,…,,,(q<=m/2)
这样就等于可以双向寻找可能的空位置,另外增加平方运算的目的是为了不让关键字都聚集在某个快区域,我们称这种方法叫做二次探测法:
设散列函数为f,表的规模为m,要散列的关键字为key。那么,如果在散列位置f(key)发生冲突,二次探查法依次检查位置(f(key) + ),直到某个位置是个空位置,或者已经检查过的位置。
(key) = (f(key) + ) MOD m (=,,,,…,,,(q<=m/2))
相对线性探查法,二次探查确实可以一定程度避免堆积。但二次探查法最坏情况下,即所有关键字在同一个位置冲突下,数组的利用率为1/2。可以证明,对于任意素数N,一旦一个位置被检查两次,那么之后的所有位置都是被已检查过的位置。
//设在i和j结束于相同位置
(h+ i2) mod N = (h+j2) mod N
→ (i+j)(i-j) mod N = 0
//因为N是素数,它必须整除因子(i+j)或(i-j),只有做了N次探查,N才能整除(i-j);同时,使得N整除(i+j)的最小(i+j)为N。
→ i+j = N → j = N - i
//故而不同的探查位置数只能是N/2。
最坏情况的搜索和插入运行时间依旧是O(N)。
随机探测法
在冲突时,对于位移量采用随机函数计算得到的,我们称之为随机探测法
(key)= (f(key) + ) MOD m (是一个随机数列)
再散列函数法
对于散列表,可以准备多个散列函数
(key) = (key) (i=1,2,3,…,k)
就是不同的散列函数,每当发生散列地址冲突时,就换一个散列函数计算,这种方法能够使得关键字不产生聚集,但是相应的也增加了计算时间。
链地址法(封闭寻址法)
将所有关键字为同义词的记录存储在一个单链表中,我们称这种表为同义词字表,在散列表中只存储所有同义词字表的头指针。在java中java.util.HashMap就采用这样的设计。java中HashMap是一种字典结构,实现了散列表的功能,存储(key,value)键值对,至少支持get(key)、put(key,value)、delete(key)方法。广义上来说,列表和二叉查找树都是字典。
同开放寻址法,最坏的插入和搜索的时间复杂度都是O(n),当然如果是对关键字完美散列的散列函数,时间复杂度都是O(1)。
公共溢出法
为所有冲突的关键字建立一个公共的溢出区来进行存放。在查找时,对给定值通过散列函数计算出散列地址后,先与基本表的相应位置进行对比,如果相等则查找成功;如果不懂,则到溢出表中进行顺序查找,在冲突的数据很少情况下,公共出去的结构对查找性能来说还是很高的。
散列表的查找实现
定义基本结构:
int[] elem; //散列表数据存储数组
public int count; //散列表实际存储数据量
private int maxSize = 20; //散列表的最大容量
public final int NULLKEY = -32769; //散列表初始值
public final int SUCCESS = 1;
public final int UNSUCCESS = 0;
对散列表进行初始化:
public HashTable() {
this.elem = new int[maxSize];
this.initHashTable();
}
public HashTable(int maxsize) {
this.maxSize = maxsize;
this.elem = new int[maxSize];
this.initHashTable();
}
public void initHashTable() {
for (int i = 0; i < maxSize; i++) {
this.elem[i]= NULLKEY;
}
}
散列函数:
/**
* 散列函数
* 保留余数法
* @param key
* @return
*/
public int Hash(int key) {
return key % maxSize;
}
散列表的插入操作:
public void insertHash(int key) {
int addr = Hash(key); //求散列地址
while(this.elem[addr] != NULLKEY) {
addr = Hash(addr + 1); //开放定址法的线性探测
}
this.elem[addr] = key;
++count;
}
代码中插入关键字首先要计算散列地址,如果当前地址不为空关键字,则说明存在冲突,此时我们应该进行重新寻址。
查找记录:
public int searchHash(int key) {
int addr = Hash(key);
while(this.elem[addr] != key) {
addr = Hash(addr + 1); //开放定址法的线性探测
if(this.elem[addr] == NULLKEY || addr == Hash(key)) { //如果循环回到原点
return UNSUCCESS; //说明关键字不存在
}
}
return SUCCESS;
}
测试代码:
public static void main(String[] args) {
HashTable h = new HashTable();
h.insertHash(5);
h.insertHash(4);
h.insertHash(3);
h.insertHash(6);
h.insertHash(8);
h.insertHash(9);
h.insertHash(1);
h.insertHash(7);
System.out.println("插入数据数量:" + h.count);
System.out.println("是否存在关键字为0的值:" + h.searchHash(0));
System.out.println("是否存在关键字为9的值:" + h.searchHash(9));
}
结果:
插入数据数量:8
是否存在关键字为0的值:0
是否存在关键字为9的值:1
散列表查找性能分析
理想情况下散列查找的效率最高为O(1),当然只是理想情况下。。。
散列查找的平均查找长度取决于下面的因素:
- 散列函数是否均匀
散列函数的好坏直接影响着出现冲突的频繁程度,但是由于不同的散列函数对同一组随机的关键字,产生冲突的可能性是相同的。所以一般不考虑它对平均查找长度的影响。 - 处理冲突的方法
一般来说,线性探测处理冲突可能会产生堆积,显然没有二次寻址法好,而链地址法处理冲突不会产生任何堆积,因而具有更佳的平均查找性能。 - 散列表的填装因子
设填装因子为a,填入表的记录个个数为m,散列表的长度为n,则:
a = m / n
填装因子标志着散列表的装满的程度,当填入表的记录越多,a越大,产生冲突的可能性就越大。无论记录个数m有多大,我们总可以选择一个合适的填装因子以便将平均查找长度限定在一个范围之内,通常我们都是将散列表的空间设置的比查找集合大,虽然浪费一定的空间,但是查找效率大大提升。