1. 程式人生 > >【每日演算法】雜湊表(Hash Table)

【每日演算法】雜湊表(Hash Table)

概述

雜湊表又稱散列表,它用於快速查詢。

查詢,如果能夠不經過比較,直接就能得到待查記錄的儲存位置,那效率必定很高。

通過在記錄的儲存位置和它的關鍵碼之間建立一個確定的對應關係H,使得每個關鍵碼key跟唯一的儲存位置H(key)對應,那麼當我們想查詢關鍵碼為k的記錄時,直接到H(k)處取即可。這種查詢技術叫做雜湊技術

採用雜湊技術將記錄儲存在一塊連續的儲存空間中,這塊連續的儲存空間稱為散列表,將關鍵碼對映為散列表中適當儲存位置的函式稱為雜湊函式

雜湊主要是面向查詢的資料結構,它不適用於範圍查詢(如找最大最小值、某一個範圍內的值等等)。它最適合回答的問題是:如果有的話,哪個記錄的關鍵碼等於待查值。

散列表有一個問題:對於兩個不同的關鍵碼k1 != k2,有H(k1) = H(k2),即兩個不同的記錄需要存放在同一個儲存空間中,這種現象稱為衝突

雜湊技術需要考慮的兩個主要問題:

  1. 雜湊函式的設計(力求簡單、均勻、儲存利用率高);
  2. 衝突的處理

雜湊函式

設計原則:

  • 計算簡單(否則影響查詢效率);
  • 函式值,即雜湊地址均勻分佈(充分利用儲存空間,減少衝突)。

直接定址

雜湊函式為關鍵碼的線性函式:

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;
    }
}

刪除

當從閉散列表中刪除一個記錄時,需要考慮以下兩點:

  1. 刪除一個記錄一定不能影響以後的查詢;
  2. 刪除記錄後的儲存單元應該能夠為將來的插入使用。

假如有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!

(●’◡’●)

本人水平有限,如文章內容有錯漏之處,敬請各位讀者指出,謝謝!