什么是散列
散列首先是一个数组,所以它的访问速度快,它与数组的不同在于可以通过一个关键字访问数据,这个关键字与数组下标之间的映射关系有散列函数提供。
散列函数
所以说,散列函数对于一个散列是非常重要的,它的第一个主要功能就是将关键字映射到数组下标。比较简单的散列函数,比如关键字是正整数,那么散列函数可以是将关键字与数组长度进行取余操作,获取到下标值。或者关键字是字符串,我们通过某个方法,比如ASCII码将字符串转换为数字,然后再进行取余操作。
散列函数第二个主要功能是将关键字映射得到的下标尽量分散,比如我们有一个长度为8的散列,最好的情况是插入8个元素分别在不同的下标,最坏的情况是8个元素的关键字都得到相同的下标,这样就造成了hash冲突。所以为了减少hash冲突,散列函数需要尽量的分散,下面是《数据结构与算法分析》中提出的比较好的散列函数
public static int hash(String key, int tableSize) {
int hashVal = 0;
// 遍历每一个字符
for(int i = 0; i < key.length(); i++) {
hashVal = 37 * hashVal + key.charAt(i);
}
hashVal %= tableSize;
// 这里算出来的值可能溢出为负
if(hashVal < 0)
hashVal += tableSize;
return hashVal;
}
hash冲突
由于数组长度是有限的,所以散列函数再完美也是无法避免hash冲突的。那么接下来的问题就是如何解决hash冲突。
分离链表法
分离链表法是将冲突的元素按照链表存储在数组中,也就是散列表存储一个链表数组。分离链表法实现简单,缺点是在链表长度过长会导致查找耗时,所以需要在长度达到一定程度的时候做些处理,在java中,HashMap使用分离链表法解决hash冲突,当链表长度超过阈值时,会将链表转换为树形结构存储。
探测散列表
在分离链表法中,我们实现了另一种数据结构(链表)来解决hash冲突,在某些情况下我们可能不希望再实现另一种数据结构,那么我们只能在原表中寻找空的单元,所以探测散列表一般比分离链表法的表更大。
hi(x) = (hash(x) + f(i)) mod tableSize; f(i)是解约冲突的方法。
线性探测法
在线性探测法中,f(i)是i的线性函数,比如f(i) = i;这就相当于逐个位置查找,直到找到空单元。所以只要表足够大,肯定是可以找打空单元的。但是由于f(i)线性的关系,散列后的单元会形成一个区块,这样会导致,在表空间还比较空的时候,散列到区块中的任何关键字都要经过多次操作才能解决冲突,这就是一次聚集。
平方探测法
平方探测就是形如f(x) = i^2; 在一定程度上解决了一次聚集,可能会导致二次聚集;平方探测需要注意一点,如果哈希表的大小比较特殊,可能导致还有空间但是无法找到位置的情况,因为
它不像线性探测可以遍历整张表,平方可能会始终在某几个位置来回跳转。所以如下定理很重要:
如果使用平方探测,且表的大小是素数,那么当表至少有一半是空的时候,总能够插入一个新的元素。
双散列
双散列就是f(x)=hasn2(x),与前面两个没有本质区别,只是函数的差别,它对于散列表的要求也是素数。
再散列
当容量达到一定程度的时候需要扩容,扩容后元素肯定没办法匹配之前的位置,所以需要再散列。
再散列的花销很大,但是发生的次数比较少,一般再散列是再装填因子达到某个值时触发(hashmap是0.75)。
以实际情况举例,比如Java中的HashMap,rehash的大概步骤如下(只考虑有值的情况):
1、设置扩容后的数组大小,如果已经为Integer.MAX则不扩容;其他情况数组大小扩大两倍;
2、遍历数组将数组中的元素放置在新数组中,HashMap使用的是分离链表法,所以也需要对于元素的链表进行遍历。HashMap对于链表有个优化,当链表长度过长(默认是8)会将链表转换为红黑树,所以遍历的时候需要考虑这两种情况。
//TODO 听过更好的rehash策略,可以不用移动旧数据,后面补充。