本文探討拉鍊表解決哈希衝突的方式和幾種常見的散列函數。
首先,什麼是散列表?
對於一個數組,我們在O(1)時間複雜度完成可以完成其索引的查找,現在我們想要存儲一個key value的鍵值對,我們可以根據key生成一個數組的索引,然後在索引存儲這個key value鍵值對。我們把根據key生成索引這個函數叫做散列函數。顯而易見,不同的key有可能產生相同的索引,這就叫做哈希碰撞,也叫哈希衝突。一個優秀的哈希函數應該儘可能避免哈希碰撞。
而對於本文就將介紹如何拉鍊法解決哈希衝突的方法,以及常用的哈希函數。
拉鍊法
這種方法的關鍵是把同一個散列槽(在我們這裏就是數組的每一個槽)中的所有元素放到一個鏈表中。
我們拿到一個索引首先計算其在散列函數作用下映射出來的索引,如果索引沒有元素,直接插入;如果有元素,但是key值和我們要插入的數據不一樣,我們就把key value鍵值對插入鏈表頭;如果存在和我們要插入數據相同key的鍵值對,我們就把value進行更換。
我們畫一張圖來表示拉鍊法put時候發生的情況:
1、沒有發生哈希碰撞的時候
2、發生哈希碰撞但是key值不同
3、發生哈希碰撞並且key值相同
下面我們使用一個不會動態開闢新空間的靜態哈希表來做一個簡單示範,使用的是Java代碼:
import java.util.Iterator;
import java.util.LinkedList;
interface HashFunctionHost {
//接口約定根據index確定的hash值
public <K,V> double hashFunction(HashTable<K,V> hashTable,K x);
}
public class HashTable<K, V> {
public static class Entry<K, V>{
private K key;
private V value;
public Entry(K key, V value){
this.key = key;
this.value = value;
}
public K getKey(){
return key;
}
public V getValue(){
return value;
}
}
private LinkedList<Entry<K,V>>[] elements;
private HashFunctionHost hashFunctionHost;
private int capacity;
public static final int DEFAULT_SIZE = 10;
public static final HashFunctionHost DEFAULT_HASH_FUNCTION_HOST = new DivisionHashFunctionHost();
@SuppressWarnings("unchecked")
public HashTable(int size, HashFunctionHost hashFunctionHost){
elements = new LinkedList[size];
for(int i = 0 ; i < size ; i++)
elements[i] = new LinkedList<Entry<K,V>>();
this.hashFunctionHost = hashFunctionHost;
capacity = 0;
}
public HashTable() {
this(DEFAULT_SIZE, DEFAULT_HASH_FUNCTION_HOST);
}
private Entry<K,V> getEntry(K key){
int index = (int) hashFunctionHost.hashFunction(this, key);
Iterator<Entry<K, V>> iterator = elements[index].iterator();
while(iterator.hasNext()){
//找到了重複的key則直接修改entry中key對應的value
Entry<K, V> temp = iterator.next();
if(key.equals(temp.getKey())){
return temp;
}
}
return null;
}
public void put(K key ,V value){
int index = (int) hashFunctionHost.hashFunction(this,key);
Entry<K, V> newEntry = new Entry<K,V>(key,value);
//沒有哈希衝突
if(elements[index].size()==0){
elements[index].add(newEntry);
capacity++;
}
//發生了哈希碰撞則需要遍歷鏈表判斷k值是不是已經存在了
else{
Entry<K,V> entry = getEntry(key);
if(entry != null){
entry.value = value;
return;
}
//執行到這裏說明沒有發現重複的key 插在鏈表頭部
elements[index].addFirst(newEntry);
}
}
public boolean delete(K key){
int index = (int) hashFunctionHost.hashFunction(this, key);
Iterator<Entry<K, V>> iterator = elements[index].iterator();
while(iterator.hasNext()){
Entry<K, V> entry = iterator.next();
if(entry.getKey()==key){
iterator.remove();
return true;
}
}
return false;
}
public V get(K key){
Entry<K, V> entry = getEntry(key);
if(entry == null)
return null;
return entry.getValue();
}
public int bucketNum(){
return elements.length;
}
public int size(){
return capacity;
}
}
實際上實現起來也很簡單,但是請注意一些細節:
①、首先對於一個散列表,這裏我們可以自己傳入一個哈希函數來完成我們的映射,但是Java不提供函數指針怎麼辦?《Effective Java》一書說到過這個問題,我們可以使用一個宿主類來包含這個我們想要傳遞的方法,通過傳遞宿主類來起來傳遞方法的作用,也就是我們代碼中的接口HashFunctionHost的實現類。
②、在遍歷數組中的鏈表的時候,我們不要使用for循環加上鍊表的get()方法,而是使用Iterator。看底層源碼我們就可以發現get()方法實際上會從頭遍歷一遍鏈表,直到找到對應元素,也就是說我們會做很多的無用遍歷,這是我們不能接受的。相反Iterator就不是這樣,對於訪問Iterator下一個元素的複雜度是O(1)。
③、我們在put的時候有三種情況,分別是沒有哈希衝突,直接插入;有哈希衝突,但是沒有相同的key,插入到鏈表頭部;有哈希衝突,而且存在相同key,我們就需要修改那個key對應的value。
④、在比較key的時候使用equals而不是==,這個不用多說了,equals比較的應該是值,==比較的是內存地址。如果我們想要在key值相等的時候就對value做出替換,那麼我們可能要用的是equals而不能用==,並且要對存入散列表的對象的equals方法進行重寫。
散列函數
上面的哈希表實現過程我們就看到了我們實現了一個默認的散列函數,我們下面來討論其他的散列函數。
首先我們需要思考一個好的散列函數需要有的特點:每個關鍵字都儘量等可能地散列到m個槽中地任何一個,並且和其他關鍵的散列無關。
下面我們就來介紹常用的幾種散列函數。因爲我們使用的是Java,因此我們先聲明一個接口,表示散列函數的宿主類需要實現這個接口:
interface HashFunctionHost {
//接口約定根據index確定的hash值
public <K,V> double hashFunction(HashTable<K,V> hashTable,K x);
}
1、除法散列法
這種方法是我們上面哈希表的默認哈希函數,也是jdk中HashMap和HashTable的哈希函數。
現在我們有一個關鍵字k和散列槽的個數m,我們可以通過取k除以m的餘數來將關鍵字映射到m個槽,用數學公式表示就是:
h(k) = k mod m
這個方法運算很快,比較只是以此模運算。然而在m的選擇上有一些技巧,我們應該選擇不太靠近2的整數次冪的素數m,這個時候哈希碰撞會很少。我們使用Java代碼來實現這種散列函數:
public class DivisionHashFunctionHost implements HashFunctionHost {
@Override
public <K,V> double hashFunction(HashTable<K,V> hashTable, K x) {
return Math.abs(x.hashCode())%hashTable.bucketNum();
}
}
由於一些對象的hashCode可能會出現負值,所以我加上了一個絕對值。
2、乘法散列法
乘法散列函數的計算分爲幾個步驟,用關鍵字k乘以一個常數A,提取kA的小數部分,然後用槽的個數m乘以這個值,最後向下取整。
這種散列函數對m選擇不是很關鍵,但是常數A對其影響較大,一般我們認爲黃金分割率(美妙的大自然),也就是(√5 - 1)/2,大約是0.6180339887是一個很理想的值。
下面是我的實現代碼:
public class MultiplicationHashFunctionHost implements HashFunctionHost {
private double constant;
public static final double DEFAULT_CONSTANT = 0.6180339887;
public MultiplicationHashFunctionHost(double constant) {
if(constant<=0 || constant>=1)
throw new IllegalArgumentException("非法參數constant:沒有位於(0,1)區間");
this.constant = constant;
}
public MultiplicationHashFunctionHost() {
this(DEFAULT_CONSTANT);
}
@Override
public <K, V> double hashFunction(HashTable<K, V> hashTable, K x) {
return (int)((constant*x.hashCode()-(int)(constant*x.hashCode()))*hashTable.size());
}
}
3、全域散列法
我覺得這種算法更像是一種散列函數的綜合。當一個使用者惡意地把n個關鍵字放入一個槽中的時候,複雜度會變成O(n),這不是我們想看到的,因此衍生出了這種思路。
我們隨機選擇散列函數,使得關鍵字k和關鍵字l不相等的時候,發生哈希碰撞的概率不大於1/m,用戶不能控制關鍵字與槽的映射,從而使得平均性能很好。畢竟我都random了,你如何確定關鍵字k與槽的映射。
這更像是一種散列函數的綜合的思想,我就不給出代碼實現了。
以上,有問題或者對我觀點不認同歡迎評論。