一、雜湊思想
- 雜湊表的英文叫“Hash Table”,也叫它“雜湊表”或者“Hash表”。
- 雜湊表用的是陣列支援按照下標隨機訪問資料的特性,所以雜湊表其實就是陣列的一種擴充套件,由陣列演化而來。可以說,如果沒有陣列,就沒有雜湊表。
舉個例子:
-
假如有89名選手參加學校運動會。為了方便記錄成績,每個選手胸前都會貼上自己的參賽號碼。這89名選手的編號依次是1到89。
-
現在希望程式設計實現這樣一個功能,通過編號快速找到對應的選手資訊。怎麼做呢?
- 可以把這89名選手的資訊放在陣列裡。
- 編號為1的選手,我們放到陣列中下標為1的位置;編號為2的選手,我們放到陣列中下標為2的位置。
- 以此類推,編號為 k 的選手放到陣列中下標為 k 的位置。
- 因為參賽編號跟陣列下標一一對應,當需要查詢參賽編號為 x 的選手的時候,只需要將下標為 x 的陣列元素取出來就可以了。
- 時間複雜度就是 O(1),這樣按照編號查詢選手資訊,效率很高。
- 實際上,這個例子已經用到了雜湊的思想。在這個例子裡,參賽編號是自然數,並且與陣列的下標形成一一對映,所以利用陣列支援根據下標隨機訪問的時候,
時間複雜度是O(1)這一特性,就可以實現快速查詢編號對應的選手資訊。
-
在來改造一下這個例子。
-
假設校長說,參賽編號不能設定得這麼簡單,要加上年級、班級這些更詳細的資訊。
- 所以把編號的規則稍微修改了一下,用6位數字來表示。
- 比如051167,其中,前兩位05表示年級,中間兩位11表示班級,最後兩位還是原來的編號1到89。
- 這個時候該如何儲存選手資訊,才能夠支援通過編號來快速查詢選手資訊呢?
- 思路還是跟前面類似。儘管此時不能直接把編號作為陣列下標,但可以擷取參賽編號的後兩位作為陣列下標,來存取選手資訊資料。
- 當通過參賽編號查詢選手資訊的時候,用同樣的方法,取參賽編號的後兩位,作為陣列下標,來讀取陣列中的資料。
- 這就是典型的雜湊思想。其中,參賽選手的編號叫作鍵(key)或者關鍵字。用它來標識一個選手。
- 把參賽編號轉化為陣列下標的對映方法就叫作雜湊函式(或“Hash函式”“雜湊函式”),而雜湊函式計算得到的值就叫作雜湊值(或“Hash值”“雜湊值”)。
-
通過上面的例子,可以總結出這樣的規律:
- 雜湊表用的就是陣列支援按照下標隨機訪問的時候,時間複雜度是O(1)的特性。
- 通過雜湊函式把元素的鍵值對映為下標,然後將資料儲存在陣列中對應下標的位置。
- 當按照鍵值查詢元素時,用同樣的雜湊函式,將鍵值轉化陣列下標,從對應的陣列下標的位置取資料。
二、雜湊函式
- 從上面的例子可以看到,雜湊函式在雜湊表中起著非常關鍵的作用。
- 雜湊函式,顧名思義,它是一個函式。可以把它定義成 hash(key),其中 key 表示元素的鍵值, hash(key) 的值表示經過雜湊函式計算得到的雜湊值。
- 那第一個例子中,編號就是陣列下標,所以 hash(key) 就等於 key。
- 改造後的例子,雜湊函式用虛擬碼表示就是下面這樣:
int hash(String key) {
// 獲取後兩位字元
string lastTwoChars = key.substr(length-2, length);
// 將後兩位字元轉換為整數
int hashValue = convert lastTwoChas to int-type;
return hashValue;
}
- 雜湊函式設計的基本要求:
- 雜湊函式計算得到的雜湊值是一個非負整數;
- 如果key1 = key2,那 hash(key1) == hash(key2);
- 如果key1 ≠ key2,那 hash(key1) ≠ hash(key2)。
- 解釋一下這三點:
- 其中,第一點因為陣列下標是從0開始的,所以雜湊函式生成的雜湊值也要是非負整數。
- 第二點相同的 key,經過雜湊函式得到的雜湊值也應該是相同的。
- 第三點看起來合情合理,但是在真實的情況下,要想找到一個不同的key對應的雜湊值都不一樣的雜湊函式,幾乎是不可能的。即便像業界著名的MD5、 SHA、 CRC等雜湊演算法,也無法完全避免這種雜湊衝突。而且,因為陣列的儲存空間有限,也會加大雜湊衝突的概率。
- 所以幾乎無法找到一個完美的無衝突的雜湊函式,即便能找到,付出的時間成本、計算成本也是很大的,所以針對雜湊衝突問題,需要通過其他途徑來
解決。
三、雜湊衝突
再好的雜湊函式也無法避免雜湊衝突。常用的雜湊衝突解決方法有兩類,開放定址法(open addressing)和連結串列法(chaining)。
3.1、開放定址法
開放定址法的核心思想是,如果出現了雜湊衝突,就重新探測一個空閒位置,將其插入。
1.線性探測
-
重新探測新的位置一個比較簡單的探測方法是線性探測(Linear Probing)。
-
當往雜湊表中插入資料時,如果某個資料經過雜湊函式雜湊之後,儲存位置已經被佔用了,就從當前位置開始,依次往後查詢,看是否有空閒位置,直到找到為止。
-
從圖中可以看出,雜湊表的大小為10,在元素 x 插入雜湊表之前,已經6個元素插入到雜湊表中。 x 經過 Hash 演算法之後,被雜湊到位置下標為7的位置,但是這個位置已經有資料了,所以就產生了衝突。於是就順序地往後一個一個找,看有沒有空閒的位置,遍歷到尾部都沒有找到空閒的位置,於是再從表頭開始找,直到找到空閒位置2,於是將其插入到這個位置。
查詢操作:
- 在雜湊表中查詢元素的過程有點兒類似插入過程。
- 通過雜湊函式求出要查詢元素的鍵值對應的雜湊值,然後比較陣列中下標為雜湊值的元素和要查詢的元素。
- 如果相等,則說明就是要找的元素;否則就順序往後依次查詢。如果遍歷到陣列中的空閒位置,還沒有找到,就說明要查詢的元素並沒有在雜湊表中。
刪除操作:
- 雜湊表跟陣列一樣,不僅支援插入、查詢操作,還支援刪除操作。
- 對於使用線性探測法解決衝突的雜湊表,刪除操作稍微有些特別。不能單純地把要刪除的元素設定為空。
- 在查詢的時候,一旦通過線性探測方法,找到一個空閒位置,就可以認定雜湊表中不存在這個資料。
- 但是,如果這個空閒位置是後來刪除的,就會導致原來的查詢演算法失效。本來存在的資料,會被認定為不存在。
- 此時可以將刪除的元素,特殊標記為 deleted。當線性探測查詢的時候,遇到標記為 deleted 的空間,並不是停下來,而是繼續往下探測。
2.其他方法
- 線性探測法其實存在很大問題。當雜湊表中插入的資料越來越多時,雜湊衝突發生的可能性就會越來越大,空閒位置會越來越少,線性探測的時間就會越來越久。
- 極端情況下,可能需要探測整個雜湊表,所以最壞情況下的時間複雜度為O(n)。
- 同理,在刪除和查詢時,也有可能會線性探測整張雜湊表,才能找到要查詢或者刪除的資料。
- 對於開放定址衝突解決方法,除了線性探測方法之外,還有另外兩種比較經典的探測方法, 二次探測(Quadratic probing)和雙重雜湊(Double hashing)。
二次探測
- 跟線性探測很像,線性探測每次探測的步長是1,那它探測的下標序列就是 hash(key)+0, hash(key)+1, hash(key)+2……
- 而二次探測探測的步長就變成了原來的“二次方”,也就是說,它探測的下標序列就是 hash(key)+0, hash(key)+1², hash(key)+2²……
雙重雜湊
- 就是不僅要使用一個雜湊函式。使用一組雜湊函式 hash1(key), hash2(key), hash3(key)……
- 先用第一個雜湊函式,如果計算得到的儲存位置已經被佔用,再用第二個雜湊函式,依次類推,直到找到空閒的儲存位置。
總結一下:
- 不管採用哪種探測方法,當雜湊表中空閒位置不多的時候,雜湊衝突的概率就會大大提高。
- 為了儘可能保證雜湊表的操作效率,一般情況下,會盡可能保證雜湊表中有一定比例的空閒槽位。
- 用裝載因子(load factor)來表示空位的多少。
- 裝載因子的計算公式是:
- 雜湊表的裝載因子 = 填入表中的元素個數 / 雜湊表的長度
- 裝載因子越大,說明空閒位置越少,衝突越多,雜湊表的效能會下降。
3.2、連結串列法
-
連結串列法是一種更加常用的雜湊衝突解決辦法,相比開放定址法,它要簡單很多。
-
如下圖所示,在雜湊表中,每個“桶(bucket) ”或者“槽(slot) ”會對應一條連結串列,所有雜湊值相同的元素都放到相同槽位對應的連結串列中。
-
當插入的時候,只需要通過雜湊函式計算出對應的雜湊槽位,將其插入到對應連結串列中即可,所以插入的時間複雜度是O(1)。
-
當查詢、刪除一個元素時,同樣通過雜湊函式計算出對應的槽,然後遍歷連結串列查詢或者刪除。
-
查詢或刪除操作的時間複雜度跟連結串列的長度 k 成正比,也就是 O(k)。對於雜湊比較均勻的雜湊函式來說,理論上講, k=n/m,其中 n 表示雜湊中資料的個數,m 表示雜湊表中“槽”的個數。