淺析雜湊儲存

大盜發表於2017-08-07

說到雜湊,我們可能覺得是個新奇的事物,但實際上學過Java的人就會知道,在equal函式中會使用到hashcode,那就是所謂的雜湊碼,而將對應的字轉換成雜湊碼的過程,我們稱之為雜湊過程,這個過程函式被稱之為雜湊函式。

理想情況下,不同的關鍵字的雜湊碼是不會相同的。

1. 雜湊表


現在讓我們儲存一些資料,要求滿足:

  • 通過關鍵字來決定儲存的位置。
  • 關鍵字即為索引。
  • 不同的關鍵字所對應的內容不同。
  • 通過關鍵字直接查詢到其對應的資料內容。

閱讀完要求,我們立即就能想到,通過建立一個大小為n(n大於所要儲存資料的個數)的陣列,陣列元素的下標對應於不同的關鍵字(這裡假設關鍵字為自然數,關鍵字i對應陣列第i個元素),這樣的一個陣列便可以滿足上述的所有要求。

但是往深裡想,如果我們要儲存的資料非常多,而記憶體空間又是有限的情況下,這樣的行為很可能就會撐爆記憶體。雖然在理想情況下,使用陣列儲存的效果非常顯著,但是實際的情況總會有些出入。

上述過程是一種靜態集合的表述,相對的,我們還有動態集合,這種集合形式也是很常見的(比如Java程式設計師所熟知的HashMap),與靜態集合大體相似,其不同在於,動態集合可能並不會將全域中所有的關鍵字都儲存在表中。

假設我們所儲存的關鍵字是從一個集合中取得的一部分,將其儲存在一個陣列中的對應位置。這樣的一個集合我們稱為全域,這個儲存關鍵字的陣列也可以被稱為直接定址表,該表中的每個位置都被稱為

也正是因為這個可能,也就造成了實際儲存時關鍵字集合K相對於全域U來說可能很小的情況,此時若採用直接定址表(表大小=全域的關鍵字個數),會造成空間浪費;而如果儲存全域(假設該域非常大)中所有的值,那麼這個表又會非常的大,那麼又會撐爆記憶體空間,於是我們便開始研究其他替代方案——雜湊表。

雜湊表本質上也是一個陣列,只是關鍵字到陣列中位置的轉換關係是使用雜湊函式來確定的。我們知道,函式的對映關係是1-1或是多-1的關係,雜湊函式也有這種情況,不同的關鍵字在雜湊後可能對應相同的雜湊碼(即key1 ≠ key2,hashcode(key1) = hashcode(key2)),這種情況就被稱之為雜湊衝突。對於雜湊衝突,我們也有對應的解決方法如,連結法和開放定址法。

不過,要儘可能的去減少雜湊衝突,使關鍵字儘可能的均勻分佈,這樣雜湊表的效能才能更好的發揮出來。
Java庫函式中就有通過再雜湊的方式使關鍵字能夠均勻分佈。

2. 雜湊衝突


雜湊衝突是不可避免的,但是如果是雜湊函式的設計不當,那麼我們可以通過改進雜湊函式來降低雜湊衝突,但當我們現階段所使用的雜湊函式已經優化的很好時,如何處理雜湊衝突才是我們需要考慮的問題。

2.1 連結法解決雜湊衝突

所謂連結法,說的比較籠統,其實在我們學習圖的資料結構時接觸過,就是鄰接表的形式

鄰接表
鄰接表

不過,鄰接表在圖的資料結構中是為了儲存其中一個頂點到其餘各頂點的路徑長度(也可說兩點間是否存在一條路徑),在這裡,我們稱其為索引連結串列也許更為合適。

連結法解決雜湊衝突的主要思想是:(假設所有的關鍵字是互異的)將雜湊到同一個槽的關鍵字放在一個連結串列中,該連結串列的頭結點指向索引表中對應的槽。

連結法解決雜湊衝突
連結法解決雜湊衝突

從上圖中我們可以發現,關鍵字域包含於全域(一般情況下|U|<<k),給出向雜湊表中插入、刪除以及查詢的操作:

bool insert(Node *key)
{
    int hashcode = hash(key->getKey());//先得到結點關鍵字key的雜湊碼
    if (T[hashcode]) == NULL) //判斷槽位是否已經儲存元素,即衝突
    {
        key->setNext(T[hashcode].getNext());//插入槽位對應連結串列的頭部,這樣便不用迴圈查詢連結串列尾結點
        T[hashcode].setNext(key);
        return true; //插入成功
    }
    else 
    {
        T[hashcode].setNext(key);
        key.setNext(NULL);
        return true;
    }
}

Node *search(int key)//假設查詢關鍵字為key的結點
{
    int hashcode = hash(key);
    if (T[hashcode] == NULL)
        return NULL;//查詢失敗,表中無該結點
    Node *t = T[hashcode]; //記錄槽位指標資訊
    while (t->getNext() != NULL && t->getNext()->getKey() == key)
    { 
        return t;  //返回該結點的前一個結點,方便刪除操作
        t = t->getNext();
    }
}

bool delete(int key)//刪除關鍵字為key的結點,雙向連結串列的刪除操作會比較簡單
{
    Node *d = search(key);//先進行查詢
    if (d == NULL)
        return true;  //表中無該結點,認為刪除成功
    Node *dn = d->getNext();//獲取真正需要刪除的結點
    d->setNext(dn->getNext());
    dn->setNext(NULL);
    delete dn;
}複製程式碼

雜湊函式如果設計的好,使得元素能夠均勻的分佈在雜湊表中,那麼這種情況下,雜湊表查詢一個元素的時間代價就是常數階,但如果設計的不好,元素未能均勻分佈,在一種極端的情況下,查詢一個元素的時間代價就變為線性階,這也就失去了雜湊表的優勢。

2.2 開放定址法解決雜湊衝突

在開放定址法中,所有的元素都儲存在雜湊表中,但這裡所指的所有元素並不是全域中的所有元素,而是所有需要儲存在雜湊表中的元素,即使衝突。雜湊表也不是索引+連結的形式,而是去掉了連結表,故而雜湊表是可以被填滿的。

這裡的所有元素不好理解,不過依據連結法中的圖示,我們可以說是將連結串列中的所有元素都存入索引表中,無論是否存在衝突。

沒有了連結串列儲存衝突的元素,此時我們就需要在索引表中為衝突元素找到一個槽位存放(一般來說,衝突元素會從衝突的位置開始向後移動,直到找到一個空位再放入,這種方式就稱為探查),檢查空位的順序是依賴於待插入的關鍵字。

我們需要計算出一個探查序列來保證,當雜湊表逐漸被填滿時,每一個表位最終都能被考慮為用來插入新關鍵字的槽。一般來說,我們有三種方式來計算開放定址法的探查序列:線性探查二次探查雙重探查

  1. 線性探查
    給定一個輔助雜湊函式h'(就是普通的雜湊函式),使全域的關鍵字在雜湊表的槽中找到相應的對映,該方法採用的雜湊函式為:h(k,i) = (h'(k) + i) mod m, i = 0, 1, 2…… ,其中,k表示關鍵字,i表示探查序號,m表示槽的總數(表大小),記住這裡的mod m,後續會省略。
    這種線性探查的過程可以表述為:首先探查由輔助雜湊函式所給出的槽位T[h'(k)],再探查槽T[h'(k) + 1],依次類推,直到到達槽T[m-1],然後回到T[0]開始,到T[h'(k) -1]結束。不難發現,如果出現很長的連續空槽,就會使查詢時間不斷的增加。

  2. 二次探查
    二次探查的雜湊函式為:h(k,i) = (h'(k) + c1·i + c2·i^2)mod m 步驟類似於線性探查,只是首次探查結束後,後續的探查位置加上的是一個以二次的方式依賴於探查序號i的偏移量(為了充分利用雜湊表,我們要限制c1,c2以及m的值),但由於是加一個偏移量,故而我們可以發現,如果兩個不同的關鍵字初始探查位置相同,那麼其探查序列也是相同的。

  3. 雙重雜湊
    這種方式所產生的排列具有隨機選擇排列的很多特性,形式如下:h(k,i) = h'(k) + i·h''(k))mod m ,其中h'和h''都是輔助雜湊函式,這就是所謂的雙重,探查步驟與上述兩種探查方式類似,只是由於兩個輔助雜湊函式的存在使得初始探查位置、偏移量均可能發生變化,故而導致探查序列幾乎不存在相同的情況。

上述三種探查方式的函式形式參考自《演算法導論》第三版

3. 雜湊函式的設計


一個好的雜湊函式能夠使關鍵字均勻分佈在表中,而均勻分佈又意味著在查詢等操作中的時間代價會大幅降低,很難出現最壞的情況。

對於那種字元類的關鍵字,我們可以將其通過ASCII表或是Unicode碼錶進行轉換得到自然數再雜湊。

設計一個雜湊函式不難,隨手寫的h(key) = key + 1 這種都可以說是雜湊函式,但是這種雜湊函式並沒有什麼意義,加一個常數或是減一個常數,就僅僅是向後\向前移動了一個常數位,但是這種麼沒有意義的函式卻能夠較為充分的利用雜湊表。

加減乘除四則運算中,將加減運算的任意一種單獨拿出來做雜湊函式並沒有什麼太多的意義,因為組成過於簡單。而乘除法不同,雖然組成同樣簡單,但卻有其特殊的意義。

僅用除法設計雜湊函式,我們可以簡單的寫為h(key) = key / m ,這樣的關鍵字總能找到一個位置存放,再對這個形式進行加強處理,可以得到h(key) = ( (float)key / (float)x ) / m ,這個x是一個小數,在這個函式中,不難看出第一個除法運算實質上是一個乘法的運算,這樣我們也做到了混合運算。

在通過除法設計雜湊函式的時候,我們要記住如果m儘量選為不太接近2的整數冪的素數,選擇2的冪次作為m的值是需要避免的,這並不是因為這是錯的,而是因為在二進位制中2的冪次表示為key的低位,而我們又難以確定低位的排列都是等可能的,這樣會使衝突的可能性無法被保證。

以乘法設計雜湊函式時,最簡單的形式為h(key) = A * key ,當然了,我們需要進行一次取模運算,否則不能保證雜湊碼會有對應的槽位,所以以乘法為基礎設計雜湊函式時,並不能保證其運算的單一性。對簡單形式加強後得到h(key) = m * (k * A mod 1) ,這裡括號內的運算是為了取k*A的小數部分。這種方式沒有對A的值進行限制,但是對一些值來說效果會更加出眾,Kunth[211]認為取黃金分割率為A值會得到較為理想的結果。

通過以上的分析,我們可以看出,雜湊函式的設計不會單純的依靠單一的運演算法則,混合運算設計的雜湊函式相對而言更加有用。

相關文章