雜湊表是一種資料結構,它可以提供快速的插入和刪除操作。無論雜湊表有多少資料,插入、刪除只需要接近常量的時間,即 O(1) 的時間級。明顯比樹還快,樹的操作通常需要O(N)的時間級。
缺點:它是基於數組的,數組建立之後難以維護。某些雜湊表被基本填滿時,效能下降非常嚴重。而且也沒有提供一種方法可以以任何一種順序(例如從大到小)遍曆表中資料項目。
若需把單詞當做key(數組下標)擷取value(資料),可以把單詞分解成字母組合,把字母轉化為它們的數字代碼(a-1,b-2,c-3……z-26,空格-27),每個數字乘以相應的27(因為字母有27種可能,包括空格)的冪,然後結果相加,就可以每個單詞對應一個獨一無二的數字。
例如 cats 轉換數字:3*273 + 1*272 + 20*271 + 19*270 = 60337
這種方案會使得數組的長度太大,而且只有很少的一部分下標是有資料的。
雜湊化
arrayIndex = hugeNumber % arraySize,這是一種雜湊函數,它把一個大範圍的數字雜湊(轉化)成一個小數位範圍。
使用取餘操作符(%),把巨大的整數範圍轉換為兩倍於要儲存內容的數組下標範圍。下面是雜湊函數的例子:
arraySize = wordNumber * 2;
arrayIndex = hugeNumber % arraySize;
期待的數組應該有這樣的特點:平均起來,每兩個數組單元,就有一個數值,有些單元沒有數值,但有些單元可能有多個數值。
衝突
把巨大的數字空間壓縮為較小的數字空間,必然要付出代價,即不能保證每個單詞都映射到數組的空白單元。假設在數組中需要插入單詞zoo,雜湊化之後得到它的下標,發現該單元已經有了其它另一個不同的單詞,這個情況叫做“衝突”。
解決方案1 - 開放地址法
前面已經提過指定的數組大小兩倍於需要儲存的資料量,因此還有一半單元是空白的。當發生衝突的時候,通過系統的方法找到數組的一個空位,並把單詞放進去,而不再用雜湊函數得到的數組下標,這種方法叫做“開放地址法”。
解決方案2 - 鏈地址法
建立一個存放單詞鏈表的數組,數組內不直接儲存單詞,這樣,但發生衝突的時候,新的資料項目直接接到這個數組下標所指的鏈表當中。這種方法叫做“鏈地址法”。
開放地址法
尋找數組的其它位置有三種方法:線性探測、二次探測、再雜湊法。
1)線性探測
線性尋找空白單元,如果 21 是要插入資料的位置,它已經被佔用了。那麼就使用 22 ,然後是 23 ,以此類推,數組下標一直遞增,直到找到空位為止。
插入(insert)
當資料項目的數目占雜湊表的一半,或最多三分之二時,雜湊表的效能是最好的。可以看出已填充的單元分布不均勻,有時一串空白單元,有時有一串已填充的單元。
在雜湊表中,一串聯續的已填充單元叫做“以數列填滿”。增加越來越多的資料項目時,以數列填滿變得越來越長,這叫做“聚集”。
刪除(Delete)
在雜湊表中,尋找演算法是以雜湊化的關鍵字開始,沿著數組一個一個尋找,如果在尋找到關鍵字之前遇到一個空白單元,說明尋找失敗。
delete不是簡單地把某個單元的資料項目變為空白(null),因為在一個以數列填滿中間有個空白,尋找演算法就會中途放棄尋找。因此需要一個有特殊關鍵字的資料項目代替要被delete的資料項目。標記資料項目不存在。
public class DataItem { private int i; public DataItem(int i) { this.i = i; } public int getKey() { return i; } public void printf() { System.out.println("data -> " + i); }}
public class HashTable { private DataItem[] itemArray; private int arraySize; private DataItem nonItem; // for deleted items public HashTable(int size) { this.arraySize = size; itemArray = new DataItem[arraySize]; nonItem = new DataItem(-1); } public void display() { for (DataItem data : itemArray) { if (data != null) { data.printf(); } } } public int hashFuc(int key) { return key % arraySize; } public void insert(DataItem item) { int key = item.getKey(); int hashVal = hashFuc(key); DataItem tItem; while ((tItem = itemArray[hashVal]) != null && tItem.getKey() != -1) { if (tItem.getKey() == key) { itemArray[hashVal] = item; return; } hashVal++; // go to next cell hashVal %= arraySize; // wraparound if necessary } itemArray[hashVal] = item; } public DataItem delete(int key) { int hashVal = hashFuc(key); DataItem item; while ((item = itemArray[hashVal]) != null) { // until empty cell if (item.getKey() == key) { itemArray[hashVal] = nonItem; return item; } hashVal++; hashVal %= arraySize; } return null; } public DataItem find(int key) { int hashVal = hashFuc(key); DataItem item; while ((item = itemArray[hashVal]) != null) { // until empty cell if (item.getKey() == key) { return item; } hashVal++; hashVal %= arraySize; } return null; }}
public static void main(String[] args) { HashTable t = new HashTable(10); t.insert(new DataItem(39)); t.insert(new DataItem(51)); t.insert(new DataItem(23)); t.insert(new DataItem(25)); t.insert(new DataItem(23)); t.insert(new DataItem(10)); t.insert(new DataItem(9)); t.delete(25); t.insert(new DataItem(79)); t.insert(new DataItem(81)); t.display(); }
列印結果:
data -> 10
data -> 51
data -> 9
data -> 23
data -> 79
data -> 81
data -> 39
擴充數組
當雜湊表太滿,需要擴充數組。只能建立一個新的更大的數組,然後把舊的數組所有資料項目插入到新的數組。由於雜湊函數是根據數組的大小計算資料項目的位置,所以不能簡單把一個資料項目插入新的數組,需要按順序遍曆舊的數組,然後調用 insert()向新的數組插入每個資料項目。這叫做“重新雜湊化”。
擴充後的數組容量通常是原來的兩倍,實際上數組的容量應該是一個質數,所以新的數組要比兩倍容量多一點。
好的HASH函數需要把未經處理資料均勻地分布到HASH數組裡,比如大部分是偶數,這時候如果HASH數組容量是偶數,容易使未經處理資料HASH後不會均勻分布:
2 4 6 8 10 12這6個數,如果對 6 取餘 得到 2 4 0 2 4 0 只會得到3種HASH值,衝突會很多。如果對 7 取餘 得到 2 4 6 1 3 5 得到6種HASH值,沒有衝突。
同樣地,如果資料都是3的倍數,而HASH數組容量是3的倍數,HASH後也容易有衝突,用一個質數則會減少衝突的機率,更分散。
以下是求質數的代碼:
private int getPrime(int min) { for (int j = min;; j++) { if (isPrime(j)) { return j; } } } private boolean isPrime(int num) { for (int j = 2; j * j <= num; j++) { if (num % j == 0) { return false; } } return true; }
3)二次探測
線上性探測中會發生聚集,一旦聚集形成,它會越來越大,雜湊化後的落在聚集範圍內的資料項目都要一步步移動,效能越差。
裝填因子:已填入雜湊表的資料項目和表長的比率叫做裝填因子。loadFactor = nItems / arraySize ;
二次探測是防止聚集的產生,思想是探測相隔較遠的單元,而不是相鄰的單元。
步驟是步數的平方:假設雜湊表中原始下標是x,那麼線性探測是:x+1,x+2,x+3……;而在二次探測中,探測過程是:x+12,x+22,x+32……。
二次探測消除了線上性探測產生的聚集問題,這種聚集問題叫做“原始聚集”。然而二次探測產生了另外一種更細的聚集問題。之所以會發生,是因為所有映射到同一個位置的關鍵字在尋找空位時,探測的單元都是一樣的(步長總是固定的,都是:1、4、9、16、25、36……)。
4)再雜湊法
為了消除原始聚集和二次聚集,可使用另一種方法:再雜湊法。現在需要的一種方法產生一種依賴關鍵字的探測序列,而不是每個關鍵字都一樣,那麼,不同的關鍵字即使映射到相同的數組下標,也可以使用不同的探測序列。
方法是把關鍵字用不同的雜湊函數再做一遍雜湊化,用這個結果作為步長。對於指定的關鍵字,步長在整個探測是不變的,不過不同關鍵字使用不同步長。
經驗說明,第二雜湊函數必須具備以下條件:
與第一個雜湊函數不同
不能輸入0(否則沒有步長,每次探測都原地踏步,死迴圈。)
stepSize = constant - (key % constant),其中 constant 是質數,且小於數組容量。例如:stepSize = 5 - key % 5 ;
public class HashTable2 { private DataItem[] itemArray; private int arraySize; private DataItem nonItem; // for deleted items public HashTable2(int size) { this.arraySize = size; itemArray = new DataItem[arraySize]; nonItem = new DataItem(-1); } public void display() { for (DataItem data : itemArray) { if (data != null) { data.printf(); } } } public int hashFuc1(int key) { return key % arraySize; } public int hashFuc2(int key) { /* * non-zero, less than array size, different from hashFuc1. array size * must be relatively prime to 5, 4, 3, 2 */ return 5 - key % 5; } public void insert(DataItem item) { int key = item.getKey(); int hashVal = hashFuc1(key); int stepSize = hashFuc2(key); DataItem tItem; while ((tItem = itemArray[hashVal]) != null && tItem.getKey() != -1) { if (tItem.getKey() == key) { itemArray[hashVal] = item; return; } hashVal += stepSize; // add the step hashVal %= arraySize; // wraparound if necessary } itemArray[hashVal] = item; } public DataItem delete(int key) { int hashVal = hashFuc1(key); int stepSize = hashFuc2(key); DataItem item; while ((item = itemArray[hashVal]) != null) { // until empty cell if (item.getKey() == key) { itemArray[hashVal] = nonItem; return item; } hashVal += stepSize; hashVal %= arraySize; } return null; } public DataItem find(int key) { int hashVal = hashFuc1(key); int stepSize = hashFuc2(key); DataItem item; while ((item = itemArray[hashVal]) != null) { // until empty cell if (item.getKey() == key) { return item; } hashVal += stepSize; hashVal %= arraySize; } return null; }}
表的容量必須是一個質數
再雜湊法要求表的容量是一個質數。為什麼會有這個限制,假設表的容量不是質數,表長是15(座標 0 - 14),有一個特別關鍵字映射到0,步長為5,探測序列為0、5、10、0、5……,一直迴圈下去,演算法只會嘗試這三個單元,不可能找到其它空白單元,演算法崩潰。
如果數組容量是13,即一個質數,那麼探測序列會訪問到所有單元。即0、5、10、2、7、12、4、9、1、6、11、3,一直下去,只要表中有一個空位,就可以探測到它。用質數作為數組容量使得任何數想整除它是不可能的,因此探測序列最終會檢查到所有單元。