数据结构与算法-散列表查找(哈希表)
基本概念
散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系f,使得每个关键字key对应一个存储位置f(key)。查找时,根据这个确定的对应关系找到给定值key的映射f(key),若查找集合中存在这个记录,则必定在f(key)的位置上。
这里我们把这种对应关系f称为散列函数,又称为哈希(Hash)函数。按这个思想,采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为散列表或哈希表(Hash table)。那么关键字对应的存储位置我们称为散列地址。
冲突,在理想的情况下,每一个关键字,通过散列函数计算出来的地址都是不一样的,可现实中,这只是一个理想。我们时常会碰到两个关键字key1 != key2,但是却有f(key1) = f(key2),这种现象我们称为冲突(collision),并把key1 和 key2称为这个散列函数的同义词(synonym)。
散列技术既是一种存储方法,也是一种查找方法。它最适合的求解问题是查找与给定值相等的记录。
散列函数构造方法
构造原则:1)设计简单,如果一个算法可以保证所有的关键字都不会产生冲突,但是这个算法需要很复杂的计算,会耗费很多时间,这对于需要频繁地查找来说,就会大大降低查找的效率。2)散列地址分布均匀,为了避免冲突,最好的办法就是尽量让散列地址均匀地分布在存储空间中,这样可以保证存储空间的有效利用,并减少为处理冲突而耗费的时间。
- 直接定址法(不常用)
取关键字的某个线性函数值为散列地址,即f(key) = a x key + b (a、b为常数),这样的散列函数优点就是简单、均匀,也不会产生冲突,但问题是这需要事先直到关键字的分布情况,适合查找表较小且连续的情况。
由于这样的限制,在现实应用中,此方法虽然简单,但却不常用。
- 数字分析法(适合于关键字位数较大的情况)
如果我们的关键字是位数较多的数字,比如我们的11位手机号187xxxx7127,其中前三位是接入号,一般对应不同运营商公司的子品牌;中间四位是HLR识别号,表示用户号的归属地;后四位才是真正的用户号,若我们现在要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是相同的。那么我们可以选择后面的四位作为散列地址,如果这样的抽取还是容易出现冲突,还可以对抽取出来的数字再进行反转(1234改成4321)、右环位移(1234改成4123)等方法。总的目的就是为了提供一个散列函数,能够合理地将关键字分配到散列表的各位置。
数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀,就可以考虑用这个方法。
- 平方取中(适合于位数不是很大的情况)
这个方法计算很简单,假设关键字是1234,那么它的平方就是1522756,再抽取中间的3位就是227,用做散列地址。
平方取中法适合于不知道关键字的分布,而位数又不是很大的情况。
- 折叠法(适合于关键字位数较多的情况)
折叠法是将关键字从左到右分割成位数相等的几部分(注意最后一部分位数不够时可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。比如我们的关键字是9876543210,散列表表长为三位,我们将它分为四组,987|654|321|0,然后将他们叠加求和987+654+321+0=1962,再求后3位得到散列地址为962。
折叠法事先不需要知道关键字的分布,适合关键字位数较多的情况。
- 除留余数法(最常用)
此方法为最常用的构造散列函数方法。对于散列表长为m的散列函数公式为f(key) = key mod p (p≤m),mod是取模(求余数)的意思。事实上,这方法不仅可以对关键字直接取模,也可以在折叠、平方取中后再取模。
很显然,本方法的关键就在于选择合适的p,p如果选得不好,就会容易产生同义词。根据前辈们的经验,若散列表表长为m,通常p为小于或等于表长(最好接近m)的最小质数或不包含小于20质因子的合数。
处理散列冲突的方法
- 开放定址法
所谓的开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。它的公式是fi = ( f(key) + di ) MOD m (di = 1,2,3,···m-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的位置。如图所示:
我们把这种解决冲突的开放定址法称为线性探测法。
当存入48时会与12、25、37等发生冲突,像这种本来都不是同义词却需要争夺一个地址的情况,我们称这种现象为堆积。很显然,堆积的出现,使得我们需要不断处理冲突,无论是存入还是查找效率都会大大降低。
- 再散列函数法
对于我们的散列表来说,我们事先准备多个散列函数。fi = RHi(key) (i = 1,2···k)。这里RHi就是不同的散列函数,你可以把我们前面说的什么除留余数、折叠、平方取中全部用上。每当发生散列地址冲突时,就换一个散列函数计算,相信总会有一个可以把冲突解决掉。这种方法能够使得关键字不产生聚焦,当然,相应地也增加了计算的时间。
- 链地址法
思路还可以换一换,为什么有冲突就要换地方呢,我们直接就在原地想办法不可以吗?于是我们就有了链地址法。
将所有关键字为同义词的记录存储在一个单链表中,我们称这种表为同义词子表,在散列表中只存储所有同义词子表的头指针。对于关键字集合{12,67,56,16,25,37,22,29,15,47,48,34},我们用前面同样的12作为余数,进行除留余数法,可得到如图所示结构,此时,已经不存在冲突环址的问题,无论有多少个冲突,都只是在当前位置给单链表增加结点的问题。
链地址法对于可能会造成很多冲突的散列函数来说,提供了绝不会找不到地址的保障。当然,这也就带来了查找时需要遍历单链表的性能损耗。
- 公共溢出区法
我们为所有冲突的关键字建立一个公共的溢出区来存放。如图所示:
在查找时,对给定值通过散列函数计算出散列地址后,先与基本表的相应位置进行比对,如果相等,则查找成功;如果不相等,则到溢出表去进行顺序查找。如果相对于基本表而言,有冲突的数据很少的情况下,公共溢出区的结构对查找性能来说还是非常高的。
java代码实现
/**
* 散列函数(除留余数法)
*
* @param key 给定值
* @param m 散列表长度
* @return. 散列地址
*/
Integer hash(Integer key, Integer m) {
return key % m;
}
/**
* 插入关键字进散列表
*
* @param h 散列表
* @param key 给定值
*/
void insertHash(List<Integer> h, Integer key) {
Integer addr = hash(key, h.size()); /* 求散列地址 */
System.out.println("插入~散列地址~"+addr);
Integer d = 1;
while (null != h.get(addr)) { /* 如果不为空,则冲突 */
addr = (addr + d) % h.size(); /* 开放地址法的线性探测 */
System.out.println("插入~解决冲突地址~"+addr);
d++;
}
h.set(addr, key); /* 直到有空位后插入关键字 */
}
/**
* 散列查询关键字
*
* @param h 散列表
* @param key 给定值
* @return 关键字的散列地址
*/
Integer searchHash(List<Integer> h, Integer key) {
Integer addr = hash(key, h.size()); /* 求散列地址 */
System.out.println("查询~散列地址~"+addr);
Integer d = 1;
while (h.get(addr) != key) { /* 如果不为空,则冲突 */
addr = (addr + d) % h.size(); /* 开放地址法的线性探测 */
System.out.println("查询~解决冲突地址~"+addr);
if (null == h.get(addr) || addr == hash(key, h.size())) {
return null; /* 如果循环回到原点,则说明关键字不存在 */
}
d++;
}
return addr;
}
//测试调用
/* 初始化散列表 */
List<Integer> h = new ArrayList<Integer>();
for (int i = 0; i < 12; i++) {
h.add(null);
}
/* 插入关键字进散列表 */
insertHash(h, 3);
System.out.println(h.toString());
/* 输出
插入~散列地址~3
[null, null, null, 3, null, null, null, null, null, null, null, null]
*/
insertHash(h, 15);
System.out.println(h.toString());
/* 输出
插入~散列地址~3
插入~解决冲突地址~4
[null, null, null, 3, 15, null, null, null, null, null, null, null]
*/
/* 散列表中查询关键字 */
Integer addr1 = searchHash(h, 3);
System.out.println(addr1);
/* 输出
查询~散列地址~3
3
*/
Integer addr2 = searchHash(h, 15);
System.out.println(addr2);
/* 输出
查询~散列地址~3
查询~解决冲突地址~4
4
*/
散列表查找性能分析
-
如果没有冲突,散列查找是所有查找算法中效率最高的,因为它的时间复杂度为0(1)。
-
然而没有冲突的散列只是一种理想,在实际的应用中,冲突不可避免。那么散列查找的平均查找长度取决于哪些因素呢?
1)散列函数是否均匀
散列函数的好坏直接影响着出现冲突的频繁程度,不过由于不同的散列函数对于同一组随机的关键字,产生冲突的可能性是相同的,因此我们可以不考虑它对平均查找长度的影响。2)处理冲突的方法
相同的关键字,相同的散列函数,但处理冲突的方法不同,会使得平均查找长度不同。比如线性探测处理冲突可能会产生堆积,显然就没有二次探测法(di = 1,-12,2,-22,···m/2,在冲突点前后两个方向定址)好,而链地址法处理冲突不会产生任何堆积,因而具有更佳的平均查找性能。3)散列表的装填因子
所谓的装填因子α=填入表中的记录个数/散列表长度。α标志着散列表的装满程度。当填入表中的记录越多,α就越大,产生冲突的可能性就越大。也就是说,散列表的平均查找长度取决于装填因子,而不是取决于查找集合中的记录个数。不管记录个数有多大,我们总可以选择一个合适的装填因子以便将平均查找长度限定在一个范围之内,此时我们散列查找的时间复杂度就真的是0(1)了。为了做到这一点,通常我们都是将散列表的空间设置得比查找集合大,此时虽然是浪费了一定的空间,但换来的是查找效率的大大提升,总的来说,还是非常值得的。