【每日演算法】雜湊表(Hash Table)
概述
雜湊表又稱散列表,它用於快速查詢。
查詢,如果能夠不經過比較,直接就能得到待查記錄的儲存位置,那效率必定很高。
通過在記錄的儲存位置和它的關鍵碼之間建立一個確定的對應關係H,使得每個關鍵碼key跟唯一的儲存位置H(key)對應,那麼當我們想查詢關鍵碼為k的記錄時,直接到H(k)處取即可。這種查詢技術叫做雜湊技術。
採用雜湊技術將記錄儲存在一塊連續的儲存空間中,這塊連續的儲存空間稱為散列表,將關鍵碼對映為散列表中適當儲存位置的函式稱為雜湊函式。
雜湊主要是面向查詢的資料結構,它不適用於範圍查詢(如找最大最小值、某一個範圍內的值等等)。它最適合回答的問題是:如果有的話,哪個記錄的關鍵碼等於待查值。
散列表有一個問題:對於兩個不同的關鍵碼k1 != k2,有H(k1) = H(k2),即兩個不同的記錄需要存放在同一個儲存空間中,這種現象稱為衝突。
雜湊技術需要考慮的兩個主要問題:
- 雜湊函式的設計(力求簡單、均勻、儲存利用率高);
- 衝突的處理。
雜湊函式
設計原則:
- 計算簡單(否則影響查詢效率);
- 函式值,即雜湊地址均勻分佈(充分利用儲存空間,減少衝突)。
直接定址
雜湊函式為關鍵碼的線性函式:
H(key) = a * key + b (a、b為常數)
特點:單調、均勻,不會產生衝突;
適用於事先知道關鍵碼的分佈,且關鍵碼集合不是很大而連續性號的情況。
實際不常用。
除留取餘
H(key) = key mod p (p為正整數)
關鍵在於p的選取,一般情況下,若表長為m,通常選p為小於等於表長(最好接近m)的最小素數或與2的整數冪不太接近的質數或不包含小於20質因子的合數。
平方取中
平方取中法將關鍵碼平方後,按散列表的大小,取中間的若干位作為雜湊地址。因為一個數平方後,中間幾位分佈較均勻,從而衝突發生的概率較小。
摺疊法
摺疊法將關鍵碼從左到右分割成位數相等的幾步分,最後一部分位數可以短些,然後將幾部分摺疊求和,並按散列表長度,取後幾位作為雜湊地址。
以key=25346358705為例,散列表長為3位。
移位疊加
253
463
587
+ 05
————
1308
H(key) = 308
間界疊加:
253
364
587
+ 50
————
1254
H(key) = 254
適用於:關鍵碼位數多,每一位分佈都不均勻。
全域雜湊
全域雜湊保證了較好的平均性態。
它從預先設計好的一組函式中隨機選擇一個作為雜湊函式,隨機化保證了沒有哪一種輸入會始終導致最壞情況形態。
關於雜湊函式還有很多,雜湊函式是不通用的,需要針對具體的應用場景來設計,這裡作為入門,不一一介紹了。
衝突處理
開放定址法
用開放定址法處理衝突得到的散列表稱為閉散列表,其做法是:一旦產生衝突,就去尋找下一個空的雜湊地址,只有散列表足夠大,就能找到空的雜湊地址並將記錄存入。
找下一個空的雜湊地址有多種方法,這裡介紹三種:
線性探測法
設散列表長度為m,線性探測法從衝突位置的下一個位置起,依次尋找空的雜湊地址:
Hi = (H(key) + di) % m (di = 1,2,...,m-1)
這個方法將引入一個問題:不是同義詞(即雜湊值不同的記錄)可能爭搶同一個雜湊地址,這稱為堆積。
閉散列表查詢演算法:
int HashSearch(int ht[], int m, int k)
{
j = H(k); //計算雜湊地址
if (ht[j] == k) //沒有衝突,一次查詢成功
return j;
else if (ht[j] == empty)
{
ht[j] = k; //查詢不成功,插入
return 0; //退出
}
//ht[j]不為空,且ht[j] != k,說明有衝突
i = (j+1) % m; //探測的起始下標
while (ht[i] != empty && i != j)
{
if (ht[j] == k) //有衝突,但是查詢若干次後成功了
return i;
else
i = (i+1) % m; //往後探測
}
if (i == j) //找不到合適的地方插入了
throw "溢位";
else
{
ht[i] = k;
return 0;
}
}
刪除
當從閉散列表中刪除一個記錄時,需要考慮以下兩點:
- 刪除一個記錄一定不能影響以後的查詢;
- 刪除記錄後的儲存單元應該能夠為將來的插入使用。
假如有H(11)=H(22)=0,則將11刪除後,22將查詢不到,因此不能簡單地將被刪除的單元清空。
解決方法:在被刪除記錄的位置上放一個特殊標記,標記一個記錄曾經佔用該單元,於是查詢22的時候將不會在11曾經佔用的地方停止,而是繼續查詢下去。當插入遇到一個標記時,則該單元可以儲存新記錄。但是為了避免重複,查詢過程仍然要繼續探測下去,比如在刪除11後,要插入22,因為後面已經有22了,所以22不應該插入到11的位置。
二次探測法
尋找下一個雜湊地址的公式:
Hi = (H(key) + di) % m (di = 1^2,-1^2,2^2,-2^2,...,q^2,-q^2且q<=sqrt(m))
隨機探測法
Hi = (H(key) + di) % m (di為一個隨機序列,i=1,2,...,m-1)
雙重雜湊
雙重雜湊是用於開放定址法的最好方法之一:
Hi = (H1(key) + i*H2(key)) % m
拉鍊法
用拉鍊法(chaining)處理衝突構造的散列表叫做開散列表。
其基本思想是:將所有雜湊地址相同的記錄儲存在一個單鏈表中,散列表中儲存的是連結串列的頭指標。設n個記錄儲存在長度為m的開散列表中,則連結串列平均長度為n/m。
開散列表查詢演算法:
Node<int> *HashSearch(Node<int> *ht[], int m, int k)
{
j = H(k); //計算雜湊地址
p = ht[j]; //工作指標p指向第j個連結串列頭部
while (p && p->data !=k)
p = p->next;
if (p->data == k) //查詢成功
return p;
else
{
q = new Node<int>; //查詢失敗則插入
q->data = k; //頭插法
q->next = ht[j];
ht[j] = q;
}
}
另外還有再雜湊等方法來解決衝突問題,此處不詳述。
平均查詢長度
已知一個線性表(38,25,74,63,52,48),採用的雜湊函式為H(Key)=Key%7,將元素雜湊到表長為7的雜湊表中儲存。若採用線性探測的開放定址法解決衝突,則在該散列表上進行等概率成功查詢的平均查詢長度為多少?
解答:
38 25 74 63 52 48 mod 7分別是 3 4 4 0 3 6
所以採用線性探測的開放定址法解決衝突,表為:
63,48, ,38,25,74,52
找38,1次
找25,1次
找74,2次
找63,1次
找52,4次
找48,3次
所以成功查詢的平均長度為(1+1+2+1+4+3)/6=2
複雜度
建表複雜度O(n);
查詢複雜度O(1)。
最後補充的一個問題
為什麼一般hashtable的桶數會取一個素數?
如果不取素數的話是會有一定危險的,危險出現在當假設所選非素數m=x*y,如果需要hash的key正好跟這個約數x存在關係就慘了,最壞情況假設都為x的倍數,那麼可以想象hash的結果為:1~y,而不是1~m。但是如果選桶的大小為素數是不會有這個問題。
結語
關於雜湊,暫時介紹到這裡,雜湊表的設計看似簡單,實際上在實際應用中要設計的好還是挺複雜的。
剛剛查資料看到一個利用雜湊進行攻擊的事情:不斷新增雜湊地址相同的記錄,於是在拉鍊法中,雜湊表將退化為連結串列,導致訪問速度極慢,形成拒絕服務攻擊。所以實際中需要考慮的問題還有很多。
接下來如果有空,我將開始閱讀STL原始碼,看一看它的雜湊表是如何實現的,有興趣的讀者也可以深入瞭解一下。
每天進步一點點,Come on!
(●’◡’●)
本人水平有限,如文章內容有錯漏之處,敬請各位讀者指出,謝謝!