理想情况下的散列表是仅包括一些数据项的具有固定大小的数组,数据项关键字通过散列函数被映射到数组的特定位置并存储。理想情况下的散列函数应该计算简单,并且保证任何两个数据项映射到不同的位置。但是这是不可能的。
散列函数
- 如果输入的关键字是整数,则一般的处理方式是 key mod TableSize ,并且通常数组的大小 TableSize 为素数。但是在JDK的HashMap包中数组的大小被设置成了 2n ,这样有两个好处,一是位置映射的时候可以用与运算代替取余运算;二是扩容的时候数组内的数据迁移更方便。
- 如果关键字是字符串,通常有如下两种解决方式:
- 把字符串中所有字符的ASCII码相加作为散列值 ∑n−1i=0key.charAt(i)
- 假设字符串中至少有三个字符
∑2i=027i∗key.charAt(i)
但是,散列值映射到数组槽位的时候有可能出现冲突的问题,一般通过分离链表法和开放定址法。
分离链接法
分离链接法是将散列到同一个值的所有元素保存到链表中,执行查找操作,首先使用散列函数定位元素具体在哪个链表中,然后在确定的链表中执行查找;执行插入操作,首先检查相应的链表中是否已经存在该元素,如果不存在则将该元素插入到链表的前端(因为刚插入的元素最有可能不久又被访问)。
import java.util.LinkedList;
import java.util.List;
public class SeparateChainingHashTable<T> {
private static final int DEFAULT_TABLE_SIZE = 101;
private List<T>[] theLists;
private int currentSize;
public SeparateChainingHashTable(){
this(DEFAULT_TABLE_SIZE);
}
public SeparateChainingHashTable(int size){
theLists = new LinkedList[size];
for(int i=0;i<size;i++){
theLists[i] = new LinkedList<>();
}
}
public void insert(T x){
List<T> whichList = theLists[myHash(x)];
if(!whichList.contains(x)){
whichList.add(x);
if(++currentSize > theLists.length)//这里默认装填因子为1.0
rehash();
}
}
public void remove(T x){
List<T> whichList = theLists[myHash(x)];
if(whichList.contains(x)){
whichList.remove(x);
currentSize--;
}
}
public boolean contains(T x){
List<T> whichList = theLists[myHash(x)];
return whichList.contains(x);
}
public void makeEmpty(){
for(int i=0;i<theLists.length;i++){
theLists[i].clear();
}
currentSize = 0;
}
private int myHash(T x){
int hashVal = x.hashCode();
hashVal %= theLists.length;
if(hashVal < 0)
hashVal += theLists.length;
return hashVal;
}
private void rehash(){
List<T>[] oldLists = theLists;
theLists = new List[2*oldLists.length];//最好选择大于两倍数组长度的最小素数
for(int i=0;i<theLists.length;i++){
theLists[i] = new LinkedList<>();
}
currentSize = 0;
for(int i=0;i<oldLists.length;i++){
for(T item:oldLists[i])
insert(item);
}
}
}
不使用链表的散列表
分离链接法因为使用链表存储冲突元素,因而需要在初始化时为每个位置的链表分配内存空间等操作,导致算法速度减慢。另外一种不使用链表解决冲突的办法是尝试另外一些单元,
(hash(x)+f(i))mod TableSize
,其中
i
为尝试的次数。这种方式通常称作探测散列表,其状装填因子通常低于
线性探测法
在线性探测法中,函数
f
是
Position | Empty Table | After 89 | After 18 | After 49 | After 58 | After 69 |
---|---|---|---|---|---|---|
0 | 49 | 49 | 49 | |||
1 | 58 | 58 | ||||
3 | 69 | |||||
4 | ||||||
5 | ||||||
6 | ||||||
7 | ||||||
8 | 18 | 18 | 18 | 18 | ||
9 | 89 | 89 | 89 | 89 | 89 |
第一个冲突是在插入49时产生,它被尝试放入下一个空闲地址,即位置0;关键字58先与18冲突再与89冲突又与49冲突,试选三次后放入到位置1,其余的操作依次类推。
但是线性探测容易形成区块而导致一次聚集。一次不成功查找中探测的期望次数正是直到找到一个空单元的探测的期望次数,而空单元所占的份额为
1−λ
,因此预计探测的次数为
1/(1−λ)
,一次成功查找的探测次数等于该特定元素插入时所需要的探测次数。因为装填因子
λ
是逐渐增大的,因而可以使用积分的形式求解平均探测次数。
平方探测
平方探测可以消除线性探测中出现的一次聚集的问题,平方探测就是冲突函数为二次的探测方法,通常使用 f(i)=i2 ,如下表所示,将 89,18,49,58,69 依次插入到散列表中。
Position | Empty Table | After 89 | After 18 | After 49 | After 58 | After 69 |
---|---|---|---|---|---|---|
0 | 49 | 49 | 49 | |||
1 | ||||||
3 | 58 | 58 | ||||
4 | 69 | |||||
5 | ||||||
6 | ||||||
7 | ||||||
8 | 18 | 18 | 18 | 18 | ||
9 | 89 | 89 | 89 | 89 | 89 |
public class QuadraticProbingHashTable<T> {
private static final int DEFAULT_TABLE_SIZE = 11;
private HashEntry<T>[] array;
private int currentSize;
private static class HashEntry<T>{
public T element;
public boolean isActive;//false if marked deleted
public HashEntry(T e){
this(e, true);
}
public HashEntry(T e,boolean i){
this.element = e;
this.isActive = i;
}
}
public QuadraticProbingHashTable(){
this(DEFAULT_TABLE_SIZE);
}
public QuadraticProbingHashTable(int size){
allocateArray(size);
makeEmpty();
}
public void makeEmpty(){
currentSize = 0;
for(int i=0;i<array.length;i++){
array[i] = null;
}
}
public boolean contains(T x){
int currentPos = findPos(x);
return isActive(currentPos);
}
public void insert(T x){
int currentPos = findPos(x);
if(isActive(currentPos))
return;
array[currentPos] = new HashEntry(x,true);
if(++currentSize > array.length/2)
rehash();
}
public void remove(T x){
int currentPos = findPos(x);
if(isActive(currentPos))
array[currentPos].isActive = false;//懒惰删除
}
private void rehash(){
HashEntry<T>[] oldArray = array;
allocateArray(2*oldArray.length);
currentSize = 0;
for(int i=0;i<oldArray.length;i++){
if(oldArray[i] != null && oldArray[i].isActive)
insert(oldArray[i].element);
}
}
private int findPos(T x){
int offset = 1;
int currentPos = myHash(x);
while(array[currentPos] != null && !array[currentPos].element.equals(x)){//如果array装满并且散列表中不存在x,可能造成无限循环。但是散列表的装载因子是0.5,超过了0.5会执行再散列。
currentPos += offset;//实现平方探测,避免了乘法运算
offset += 2;
if(currentPos >= array.length){
currentPos -= array.length;
}
}
return currentPos;
}
private int myHash(T x){
int hashVal = x.hashCode();
hashVal %= array.length;
if(hashVal < 0)
hashVal += array.length;
return hashVal;
}
private boolean isActive(int pos){
return array[pos] != null && array[pos].isActive;
}
private void allocateArray(int arraySize){
array = new HashEntry[arraySize];
}
}
双散列
对于双散列,一种流行的选择是
f(i)=i∗hash2(x)
,将第二个散列函数应用到
x
并在距离
再散列
不管是分离链接法还是探测法,如果散列表的装载因子过大将导致执行操作的运行时间变长,甚至可能会出现插入失败的情况,通常的解决方案是建立另外一个大约两倍大的表,扫描整个原始散列表,计算每个元素的新散列值并将其插入到新表中。当表满到一般时再散列、插入失败时再散列、装填因子达到某个特定值时再散列是三种不同的选择策略。