本文参考链接:
https://www.cnblogs.com/yelongsan/p/8340047.html
https://www.cnblogs.com/hongshijie/p/9443452.html
散列表(Hash Table ADT)的实现常常叫做散列(Hashing)。散列是一种用于以常数平均时间执行插入、删除和查找的技术。但是,那些需要元素间任何排序信息的树操作将不会得到有效的支持。因此,诸如findMin、findMax以及线性时间将排过序的整个表进行打印的操作都是散列所不支持的。
1 一般想法
理想的散列表数据结构只不过是一个包含一些项(item)的具有固定大小的数组。通常查找是对项的某个部分(即数据域)进行的。这部分就叫做关键字(key)。每个关键字被映射到从0到TableSize-1这个范围中的某个数,并且被放到适当单元中。这个映射就叫做散列函数(Hash Function)。
理想情况下,散列函数应该计算简单,并且应该保证任何两个不同的关键字映射到不同的单元。不过,这是不可能的,因为单元的数目是有限的,而关键字实际上是用不完的。因此,我们寻找一个散列函数,该函数要在单元之间均匀地分配关键字。这就是散列的基本想法。
剩下的问题就是要选择一个函数,决定当两个关键字散列到同一个值的时候(这叫做冲突(collision))应该做什么以及如何确定散列表的大小。
2散列函数
如果输入的关键字是整数,则一般合理的方法就是直接返回Key mod TableSize,除非Key碰巧具有某些不合乎需要的性质。在这种情况下,散列函数的选择需要仔细地考虑。例如,若表的大小是10而关键字都以0为个位,则此时上述标准的散列函数就不是一个好的选择。为了避免上面的情况,好的办法是保证表的大小是素数。这样,当输入的关键字是随机整数时,散列函数不仅计算起来简单,而且关键字的分配也很均匀。
如果当一个元素被插入时,与一个已经插入的元素散列到相同的值,那么就产生一个冲突,这个冲突需要消除。解决这种冲突的方法有几种,我们将讨论其中最简单的两种:分离链接法和开放定址法。
3分离链接法
解决冲突的第一种方法通常叫做分离链接法(separate chaining),其做法是将散列到同一个值的所有元素保留到一个表中,下图为一个示例:
为执行一次查找,我们使用散列函数来确定究竟遍历哪个链表。然后我们再在被确定的链表中执行一次查找。为执行insert,我们检查相应的链表看看该元素是否已经自在适当的位置(如果允许插入重复元素,那么通常要留出一个额外的域,这个域当出现匹配事件时增1)。如果这个元素是个新元素,那么它将被插入到链表的前端,这不仅是因为方便,还因为常常发生这样的事实:新近插入的元素最有可能不久又被访问。
4 探测散列表
分离散列算法的缺点是使用一些链表,由于给新单元分配地址需要时间,因此这就导致算法 的速度有些减慢,同时算法实际上还要求对第二种数据结构的实现。另一种不用链表解决冲突的方法是尝试另外一些单元,直到找到空的单元为止。更常见的是,单元h0(x),h1(x),h2(x),…相继被试选,其中hi(x)=(hash(x)+f(i)) mod TableSize,且f(0)=0。函数f是冲突解决方法。因为所有的数据都要置入表内,所以这种解决方案所需要的表要比分离链接散列的表大。
我们定义散列表的装填因子(Load Factor)λ为散列表中的元素个数对该表大小的比。一般说来,对于不使用分离链接的散列表来说,其装填因子应该低于0.5。我们把这样的表叫做探测散列表(probing hash table),通常有如下三种冲突解决方案:
(1)线性探测法
在线性探测法中,函数f是i的线性函数,典型情形是f(i)=i。这相当于相继探测逐个单元(必要时可以回绕)以查找出一个空的单元。
当装填因子λ=0.75,在线性探测中一次插入预计8.5次探测。如果λ=0.9,则预计为50次探测,这显然不切实际了。
一次聚集(primary clustering):即使表相对较空,占据的单元也会开始形成一些区块,其结果称为一次聚集,也就是说,散列到区块中的任何关键字都需要多次试选单元才能够解决冲突,然后该关键字被添加到相应的区块中。
(2)平方探测法
平方探测是消除线性探测中一次聚集问题的冲突解决方法。平方探测就是冲突函数为二次的探测方法,流行的选择是f(i)=i2。
对于线性探测,让散列表几乎填满元素并不是个好主意,因为此时表的性能会降低。对于平方探测情况甚至更糟:一旦表被填充超过一半,当表的大小不是素数时,甚至在表被填充一半之前,就不能保证一次找到空的单元了。这是因为最多有表的一半可以用作解决冲突的备选位置。
(3)双散列
我们将要考察的最后一个冲突解决方法是双散列(double hasing),对于双散列,一种流行的选择是f(i)=i*hash2(x)。这个公式是说,我们将第二个散列函数应用到x并在距离hash2(x),2hash2(x),…等处探测。
hash2(x)的选择至关重要,同时,保证所有的单元都能被探测到也很重要。
5再散列
对于使用平方探测的开放定址散列法,如果散列表填得太满,那么操作的运行时间将开始消耗过长,且插入操作可能失败。这可能发生在有太多的移动和插入混合的场合。此时,一种解决方法是建立另外一个大约两倍大的表(而且使用一个相关的新散列函数),扫描整个原始散列表,计算每个(未删除的)元素的新散列值并将其插入到新表中。
再散列的目的是为了后续的插入方便。
比如我们把{6, 15, 23, 24,6}插入到Size=7的闭散列里,Hash(x)= x % 7,用线性探测的方法解决冲突,会得到这样一个结果:
现在还剩23,把这个插入之后,整个表里就填满了70%以上:
于是我们要建立一个新的表,newSize=17,这是离原规模2倍大小的最近素数。新的散列函数是Hash( x ) = x % 17。扫描原来的表,把所有元素插入到新的表里,得到这个:
这一顿操作就是再散列。可以看出这会付出很昂贵的代价:运行时间O(N),不过庆幸的是实际情况里并不会经常需要我们再散列,都是等快填满了才做一次,所以还没那么差。得说明一下,这种技术是对程序员友好而对用户不友好的。因为如果我们把这种结构应用于某个程序,那并不会有什么显著的效果,另一方面,如果再散列作为交互系统的一部分运行,可能使用户感到系统变慢。所以到底用不用还是要权衡一番的,运行速度不敏感的场景就可以用,方便自己,因为这个技术把程序员从对表规模的担心中解放出来了。
具体实现可以用平方探测以很多种方式实现:
(1)只要表满到一半就再散列
(2)只有当插入失败时才再散列(这种比较极端)
(3)途中策略(middle-of-the-road):当表到达某个装填因子时进行再散列
由于随着装填因子的增加,表的性能会有所下降,所以第三个方法或许是最好的。再散列把程序员从对表规模的担心中解放出来了,这一点的重要之处在于在复杂程序中散列表不可能一开始就做得很大,然后高枕无忧。因为我们也不知道多大才够用,所以能使她动态调整这个特性就很有必要了。
对于分离链接散列表再散列是类似的。如下是简单的其中一种简单的实现方法:
/**
*分离链接散列表,摘自《数据结构与算法分析——Java语言描述》
**/
private void rehash() {
List<AnyType>[] = oldLists = theLists;
//创建双倍的空表
theLists = new List[nextPrime(2*theLists.length)];
for (int j=0;j<theLists.length;j++) {
theLists[j] = new LinkedList<AnyType>;
}
//拷贝原数组
currentSize=0;
for (int i=0;i<oldLists.length;i++) {
for (AnyType item:oldLists[i]) {
insert(item);
}
}
}
/**
*探测散列表:摘自《数据结构与算法分析——Java语言描述》
**/
private void rehash() {
HashEntry<AnyType>[] oldArray = array;
//新建双倍大小的空表
allocateArray(nextPrim(2*oldArray.length));
currentSize=0;
//拷贝原表数据
for (int i = 0;i < oldArray.length; i++) {
if (oldArray[i] ! null && oldArray[i].isActive)
insert(oldArray[i].element);
}
}
6 Java标准库中的散列表
Java标准库包括Set和Map的散列表实现,包括HashSet、HashMap、LinkedHashSet、LinkedHashMap以及HashTable等。HashSet中的项(或HashSet中的关键字)必须提供equals方法和hashCode方法。
在JDK1.8之前,Java仅采用链表解决散列冲突,因此,在最坏情况下,假定所有节点关键字的hash值都相等,则所有节点插入同一槽位,导致HashMap退化为该槽位的链表,查找节点的时间复杂度为O(n)。JDK1.8在解决散列冲突时引入了红黑树,在某槽位的链表长度超过限额之后,则将链表转换为红黑树。我们知道红黑树能够保证最坏情况的操作时间复杂度为O(Log(n)),因此,使得HashMap在散列冲突时的性能有较大程度的提升。