散列表(Hash Table)
,我们平时叫它哈希(Hash)表
。散列表的实现常常叫做散列(hashing)
。散列是一种用于以常数平均时间执行插入、删除和查找的技术。但是,那些需要元素间任何排序信息的树操作将不会得到有效的支持。
散列表用的是数组支持按照下标随机访问数据的特性,所以散列表其实就是数组的一种扩展,由数组演化而来。可以说,如果没有数组,就没有散列表。
概念有点抽象是么,没有关系,我们来举个例子:
我们上大学都有自己的学号对吧,比如我的学号2012010118016
,其中2012
代表我的年级,0101
代表我的学院以及专业,18
代表我的班级,016
代表我的编号。学校的系统该如何存储学生的信息,才能通过学号来快速查找学生信息呢?我们可以取学号的后三位,作为数组下标,来读取数组中的数据。
这就是典型的散列思想。其中,学生的学号我们叫做键
或者关键字
。我们用它来标识一个学生。我们把学生的学号转化为数组下标的映射方法就叫作散列函数(哈希/Hash函数)
,而散列函数计算得到的值就叫作散列值(哈希/Hash值)
。
通过这个例子,我们可以总结出这样的规律:散列表用的就是数组支持按照下标随机访问的时候,时间复杂度是 O(1)
的特性。我们通过散列函数把元素的键值映射为下标,然后将数据存储在数组中对应下标的位置。当我们按照键值查询元素时,我们用同样的散列函数,将键值转化数组下标,从对应的数组下标的位置取数据。
因此,我们需要寻找一个散列函数,该函数要在单元之间均匀的分配关键字。当两个关键字散列到同一个值的时候叫做散列冲突
。我们接下来详细分析下散列函数
与散列冲突
。
从上面例子看也看出,散列函数在散列表中起着至关重要的作用。
散列函数
,顾名思义,它是一个函数
。我们可以把它定义成 hash(key)
,其中 key
表示元素的键值
,hash(key)
的值表示经过散列函数计算得到的散列值
。
根据上面的例子,学号就是数组下标,所以 hash(key) 就等于 key。哈希函数可以用下面代码表示:
public static int hash(String key, int tableSize) {
int hashVal = 0;
for (int i = 0; i < key.length(); i++) {
hashVal = 27 * hashVal + key.charAt(i);
}
hashVal %= tableSize;
if (hashVal < 0) hashVal += tableSize;
return hashVal;
}
这个散列函数涉及关键字中所有字符,并且一般可以分布的很好,它计算下面这个公式,并将结果限制在适当的范围内。
∑ i = 0 K e y S i z e − 1 K e y [ K e y S i z e − i − 1 ] ∗ 2 7 i \displaystyle\sum_{i=0}^{KeySize-1} Key[KeySize - i- 1] * 27^i i=0∑KeySize−1Key[KeySize−i−1]∗27i
程序这里根据 Horner 法则计算一个(27的)多项式函数。例如:
计算 h k = h 0 + 27 k 1 + 2 7 2 k 2 h_k = h_0 + 27k_1 + 27^2k_2 hk=h0+27k1+272k2 是借助公式 h k = ( ( k 2 ) ∗ 27 + k 1 ) ∗ 27 + k 0 h_k = ((k_2) * 27 + k_1)* 27 + k_0 hk=((k2)∗27+k1)∗27+k0 进行。 Horner 法则将其扩展到用于 n 次多项式。
这个散列函数利用到事实:允许溢出。这可能会引进负的数,因此在代码后面做了兼容。
上面这个散列函数未必是最好的,但却是具有极其简单的优点而且速度也很快。如果关键字特别长,那么散列函数计算将会花费过多的时间。有些程序设计人员通过只使用奇数位置上的字符来实现它们的散列函数。这里有这么一层想法:用计算散列函数节省下的时间来补偿由此产生的对均匀分布的函数的轻微干扰。
剩下的主要编程细节是解决冲突的消除问题。如果当一个元素被插入时与一个已经插入的元素散列到相同的值,那么就产生一个冲突,这个冲突需要消除。解决这种冲突的方法有几种,我们将讨论其中最简单的两种:分离链接法
和开放定址法
。
1、分离链接法
分离链接法
是一种更加常用的散列冲突解决办法,相比开放寻址法,它要简单很多。我们来看下面我画的图,在散列表中,每个桶(bucket)或槽(slot)
会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中。我这里是 hash(x) = x mod 4
举的例子。
当查找、删除一个元素时,我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除。实际上,这两个操作的时间复杂度跟链表的长度 k 成正比,也就是 O(k)。对于散列比较均匀的散列函数来说,理论上讲,k=n/m,其中 n 表示散列中数据的个数,m 表示散列表中“槽”的个数。
当插入的时候,我们只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,所以插入的时间复杂度是 O(1)。这里需要注意一下:如果允许插入重复元素,那么通常要留出一个额外的域,这个域出现匹配事件时增1;如果这个元素是新元素,那么它将被插入到链表的前端,这不仅因为方便,还因为常常发生这样的事实:新近插入的元素最有可能不久又被访问。
分离链接法散列表的代码供小伙伴们参考:
import java.util.LinkedList;
import java.util.List;
public class SeparateChainingHashTable<AnyType> {
private static final int DEFAULT_TABLE_SIZE = 101;
private List<AnyType>[] theLists;
private int currentSize;
public SeparateChainingHashTable() {
this(DEFAULT_TABLE_SIZE);
}
public SeparateChainingHashTable(int size) {
theLists = new LinkedList[nextPrime(size)];
for (int i = 0; i < theLists.length; i++) {
theLists[i] = new LinkedList<>();
}
}
public void insert(AnyType x) {
List<AnyType> whichList = theLists[myhash(x)];
if (!whichList.contains(x)) {
whichList.add(x);
if (++currentSize > theLists.length) {
rehash();
}
}
}
public void remove(AnyType x) {
List<AnyType> whichList = theLists[myhash(x)];
if (whichList.contains(x)) {
whichList.remove(x);
currentSize--;
}
}
public boolean contains(AnyType x) {
List<AnyType> whichList = theLists[myhash(x)];
return whichList.contains(x);
}
public void makeEmpty() {
for (int i = 0; i < theLists.length; i++) {
theLists[i].clear();
}
}
private void rehash() {
List<AnyType>[] oldLists = theLists;
// Create new double-sized, empty table
theLists = new List[nextPrime(2 * theLists.length )];
for(int j = 0; j < theLists.length; j++) {
theLists[j] = new LinkedList<>();
}
// Copy table over
currentSize = 0;
for(List<AnyType> list : oldLists) {
for(AnyType item : list) {
insert(item);
}
}
}
private int myhash(AnyType x) {
int hashVal = x.hashCode();
hashVal %= theLists.length;
if (hashVal < 0) {
hashVal += theLists.length;
}
return hashVal;
}
private static int nextPrime(int n) {
if(n % 2 == 0) n++;
for(; !isPrime(n); n += 2) {
;
}
return n;
}
private static boolean isPrime(int n) {
if(n == 2 || n == 3) return true;
if(n == 1 || n % 2 == 0) return false;
for(int i = 3; i * i <= n; i += 2) {
if(n % i == 0) return false;
}
return true;
}
}
2、开放定址法
开放定址法的核心思想是:如果出现了散列冲突,我们就重新探测一个空闲位置,将其插入。那如何重新探测新的位置呢?我先讲一个比较简单的探测方法,线性探测(Linear Probing)
。
当我们往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。
有点抽象,我来画个图大家就明白了。
博主这里画的散列表大小为8,在元素 x 插入散列表之前,已经 4 个元素插入到散列表中。假设 x 经过 Hash 算法之后,被散列到位置下标为 6 的位置,但是这个位置已经有数据了,所以就产生了冲突。于是我们就顺序地往后一个一个找,看有没有空闲的位置,遍历到尾部都没有找到空闲的位置,于是我们再从表头开始找,直到找到空闲位置 1,于是将其插入到这个位置。
在散列表中查找元素的过程有点儿类似插入过程。我们通过散列函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素。如果相等,则说明就是我们要找的元素;否则就顺序往后依次查找。如果遍历到数组中的空闲位置,还没有找到,就说明要查找的元素并没有在散列表中。
散列表跟数组一样,不仅支持插入、查找操作,还支持删除操作。对于使用线性探测法解决冲突的散列表,删除操作稍微有些特别。我们不能单纯地把要删除的元素设置为空。这是为什么呢?
还记得我们刚讲的查找操作吗?在查找的时候,一旦我们通过线性探测方法,找到一个空闲位置,我们就可以认定散列表中不存在这个数据。但是,如果这个空闲位置是我们后来删除的,就会导致原来的查找算法失效。本来存在的数据,会被认定为不存在。这个问题如何解决呢?
我们可以将删除的元素,特殊标记为 deleted
。当线性探测查找的时候,遇到标记为 deleted
的空间,并不是停下来,而是继续往下探测。
你可能已经发现了,线性探测法其实存在很大问题。当散列表中插入的数据越来越多时,散列冲突发生的可能性就会越来越大,空闲位置会越来越少,线性探测的时间就会越来越久。极端情况下,我们可能需要探测整个散列表,所以最坏情况下的时间复杂度为 O(n)
。同理,在删除和查找时,也有可能会线性探测整张散列表,才能找到要查找或者删除的数据。
对于开放定址冲突解决方法,除了线性探测方法之外,还有另外两种比较经典的探测方法,平方探测(Quadratic probing)
和双重散列(Double hashing)
。
所谓平方探测
,跟线性探测很像,线性探测每次探测的步长是 1,那它探测的下标序列就是 hash(key)+0,hash(key)+1,hash(key)+2……而平方探测探测的步长就变成了原来的“二次方”,也就是说,它探测的下标序列就是 hash(key)+0,hash(key)+ 1 2 1^2 12,hash(key)+ 2 2 2^2 22……
关于平方探测,有这么个定理:
如果使用平方探测,且表的大小为是素数,那么当表至少有一半是空的时候,总能够插入一个新的元素。
关于如何证明,感兴趣的小伙伴可以查看数据结构与算法分析第三版的 Page125,我这里就不证明了哈。
所谓双重散列
,意思就是不仅要使用一个散列函数。我们使用一组散列函数 hash1(key),hash2(key),hash3(key)……我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。不管采用哪种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表中有一定比例的空闲槽位。我们用装载因子(load factor)
来表示空位的多少。
装载因子的计算公式是:
散列表的装载因子 = 填入表中的元素个数 / 散列表的长度
装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。
这一篇就先到这里吧,下一篇再深入探讨散列函数与散列冲突这两个问题。
下一篇:数据结构与算法分析:(十二)散列表(下)