哈希表是一种数据结构,它可以提供快速的插入和查找操作。不管哈希表中有多少数据,插入和删除时间都是接近常量的时间:即O(1)。
缺点是:它是基于数组的,数组创建后难以扩展,哈希表被基本填满时,性能下降严重,而且不能够顺序遍历数据。
由于哈希表是基于数组的,所以就要把关键字转化成数组下标。
哈希函数负责把大范围的数字(由关键字计算得出)转换成小范围的数字。这个小范围的数字对应着数组下标。
转化时会碰到一个问题:不是数组每个位置都有值,也不是每个位置都保证只有一个值。
当冲突发生时,一个方法是通过系统的方法找到数组的一个空位,把这个数据填入,而不再用哈希函数得到的数组下标,这个方法叫做开放地址法。
第二种方法是创建一个存放数据的链表的数组,这样当冲突发生时新的数据项直接接到数组下标所指的链表中。这种方法叫链地址法。
java中object类的hashCode方法,默认返回的一般不是对象的地址,具体hashCode与jvm实现有关(C++实现的ObjectSynchronizer::FastHashCode调用get_next_hash方法计算hash值)。
大多数对象默认的hashCode意义不大,比如整数的hash值就是整数本身。
如果重写了对象的equals方法,一般也要重写hashCode方法,保证相等。
推测:保证对象相等时哈希值一致的目的,是为了hashSet,hashMap,hashtable等hash体系的数据结构具有高效的性能。
hash值需要遵守的约定:
- 一致性(consistent),在程序的一次执行过程中,对同一个对象必须一致地返回同一个整数。
- 如果两个对象通过equals(Object)比较,结果相等,那么对这两个对象分别调用hashCode方法应该产生相同的整数结果。
- 如果两个对象通过java.lang.Object.equals(java.lang.Ojbect)比较,结果不相等,不必保证对这两个对象分别调用hashCode也返回两个不相同的整数。
开放地址法
在开放地址法中,若数据不能直接放在由哈希函数计算出来的数据下标所指的单元时,就需要寻找数组的其它位置。下面介绍探索开放地址的三种方法:线性探测、二次探测和再哈希法。
线性探测
如果542是要插入的位置,而它已经被占用了,那么就插入543,依次递增。
查找时,先根据关键字计算出哈希值对应的索引,如果有元素,检查是不是符合要求的,如果不是则去下一个索引找。
一种实现方式:
public class HashRelative {
int arraySize;
DataItem[] hashArray;
public DataItem find(int key){
int hashValu=hashFunc(key);
while (hashArray[hashValu]!=null){
if (hashArray[hashValu].key==key){
return hashArray[hashValu];
}
hashValu++;
hashValu %=arraySize;
}
return null;
}
public void insert(DataItem item){
int key=item.key;
int hashVal=hashFunc(key);
while (hashArray[hashVal]!=null){
++hashVal;
hashVal %= arraySize;
}
hashArray[hashVal]=item;
}
private int hashFunc(int key) {
return key % arraySize;
}
}
hash表的元素数量不能超过总容量的一半,当哈希表变得太满时,一个选择是扩展数组。
创建一个新的数组时,不能直接复制原数组的位置到新数组,而是要重新计算索引,这也叫做重新哈希化。
扩展后的数组容量通常是原来的2倍,实际上,数组容量应该是一个质数。
下面的算法可以帮助计算新数组的容量:
private int getPrime(int min){
for (int j=min+1;true;j++){
if (isPrime(j)){
return j;
}
}
}
private boolean isPrime(int n) {
for (int j=2;j*j<=n;j++){
if (n%j==0){
return false;
}
}
return true;
}
二次探测
在开放地址法的线性探测中会发生聚集,一旦聚集形成,新的数据项就更容易发生移动,导致聚集增长变快。
装填因子:总元素个数/总容量。
装填因子太大时,会降低哈希表的性能。
二次探测是防止聚集产生的一种尝试。思想是探测相隔较远的单元,而不是相邻的单元。
二次探测步长为 hash+11,hash+22,hahs+3*3…
二次探测的问题是可能产生二次聚集,每有一个关键字映射到某个地址,就需要更长的步长探测。
再哈希法
为了消除原始聚集和二次聚集,有一种更好的方法再哈希法。
再哈希法是产生一种依赖关键字的探测序列,专家发现下面形式的哈希函数效果很好:
stepSize=constant-(key % constant);
其中constant是质数,且少于数组容量。
public void insertData(DataItem item){
int key=item.key;
int hashVal=hashFunc(key);
int step=hashFunc2(key);
while (hashArray[hashVal]!=null){
hashVal+=step;
hashVal %= arraySize;
}
hashArray[hashVal]=item;
}
private int hashFunc(int key) {
return key % arraySize;
}
public int hashFunc2(int key){
return 5-key % 5;
}
使用质数作为容量,是因为任何数都不会被整除,因此探测序列最终会检查所有单元。
使用开放地址策略时,探测序列通常用再哈希法生成。
链地址法
链地址法在哈希表每个单元中设置链表,这样就不需要寻找空位。
链地址法更加健壮,装填因子可以比1大,且对性能影响不大。
public void insert(Link item){
int key=item.iData;
int hashVal=hashFunc(key);
links[hashVal].insert(item);
}
public void insert(Link theLink){
int key=theLink.iData;
Link previous=null;
Link current=first;
while (current!=null && key >current.iData){
previous=current;
current=current.next;
}
if (previous==null){
first=theLink;
}else {
previous.next=theLink;
}
theLink.next=current;
}
哈希函数的核心是找到简单又快的哈希函数,而且去掉关键字的无用数据,并且尽可能的使用所有的数据。
对于字符串的hash值,可以采用下面一种取法
取32作为每一位的基数,使用位操作提高性能,提前取余防止溢出。
public static int hashFun(String key){
int hashval=0;
for (int j=0;j<key.length();j++){
int letter=key.charAt(j)-96;
hashval=(hashval<<5+letter)%size;
}
return hashval;
}
对于固定长度length的数据源,可以考虑分组折叠法,根据数组容量设计分组策略,比如容量1000的数组,那么就是3个一组,length%3一组,共分为length/3+1组,把每组的值加起来就是哈希值了。容量100的数组,那么就是2个一组,length%2一组,共分为length/2+1组。
哈希化的效率
插入和搜索都可以达到O(1)级别,发生哈希值冲突时,插入和搜索时间与探测长度成正比。探测长度又取决于装填因子。
对于容量不变,内存充足,装填因子低于0.5时,线性探测简单好用。
二次探测和再哈希法性能相当,一般优于线性探测。小型的哈希表,再哈希法性能一般好一些。
当要保存的数据项数目未知时,优先考虑链地址法。