資料結構複習一:雜湊表的總結

FreeeLinux發表於2017-02-01

昨天覆習了一下雜湊表,今天來總結一下。

雜湊表概述

雜湊表,是根據關鍵碼值(key value)而直接進行訪問的資料結構。也就是說,它通過把關鍵碼值對映到表中一個位置來訪問記錄。由於可以根據雜湊函式直接得到對應位置,雜湊表的查詢時間複雜度為O(1)。這是一種以空間換時間的做法。

通常雜湊表的做法是採用陣列實現,利用雜湊函式把key轉化成整形數字,然後將該數字對陣列長度進行取餘,取餘結果當作陣列的下標,將value儲存在以該數字為下標的陣列空間裡。

雜湊表的查詢同樣採用雜湊函式,找到key對應的陣列下標,檢視對應的value是否在該位置上即可。

構造雜湊函式

雜湊函式能使一個對資料序列的訪問過程更加有效,通過雜湊函式,資料元素將被更快定位。

  1. 直接定址法:取關鍵字或關鍵字的某個線性函式值為雜湊地址。即hash(k)=k
    hash(k)= k
    hash(k)=ak+b
    hash(k)=a*k+b
    ,其中 ab
    a b
    為常數(這種雜湊函式叫做自身函式)。
  2. 數字分析法:假設關鍵字是以r為基的數,並且雜湊表中可能出現的關鍵字都是事先知道的,則可取關鍵字的若干數位組成雜湊地址。
  3. 平方取中法:區關鍵字平方後的中間幾位為雜湊地址。比如112=121
    11^2=121
    ,取2為雜湊地址。
  4. 摺疊法:將關鍵字分割成位數相同的幾部分(最後一部分位數可以不同),然後取這幾部分的疊加和(捨去進位)作為雜湊地址。
  5. 隨機數法
  6. 除留餘數法:取關鍵字被某個不大於雜湊表表長m的數p除後所得的餘數為雜湊地址。即hash(k)=kmodppm
    hash(k) = k mod p,p ≤ m
    。不僅可以對關鍵字直接取模,也可以在摺疊法、平方取中法等運算之後取模。對p的選擇很重要,一般取素數或m,若p選擇的不好,容易產生衝突。

實際工作中不同情況採用不同雜湊函式,通常,考慮的因素有:

  1. 計算雜湊函式所需的時間(包括硬體指令的因素)。
  2. 關鍵字的長度。
  3. 雜湊表的大小
  4. 關鍵字的分佈情況
  5. 記錄的查詢頻率

通常我們採用除留餘數法。下面來一個雜湊函式舉例:
比如我們現在要對某個字串進行雜湊。我們雜湊函式可以選擇如下:

  1. 將字串所有字元的ASCII值相加,然後對TableSize取餘。這種方法如果表很大,則函式不會很好的分配關鍵字。如TableSize=10007,並假設所有的關鍵字為8個字元長。由於char型別值最多為127,那麼雜湊函式只能取值在0~1016之間,其中1016=127×8。顯然不是均勻分配。
  2. 將前三個字元分別乘以素數再疊加起來,然後取模TableSize。調查顯示,3個字母的不同組合數實際上只有2851,所以只有表的28%被用到,顯然也不合適。
  3. 採用關鍵字當中的所有字元,分別乘以素數,疊加,然後將它們的和取模TableSize作為雜湊地址,這種方法一般可以分佈的很好。我們在這種方法中可以儘量採用位運算減少雜湊函式消耗的時間。甚至可以只使用奇數位置字元。

下面程式碼例程是PHP採用的DJBX33A演算法,就是利用33這個素數,配合位運算實現雜湊函式的。後文中雜湊表的實現標頭檔案中hash_func基類的hash成員函式用到該函式,後文不再贅述。

#include "hash_table.h"

int hash_func::hash(const std::string& key, const int table_size) const
{
    int hash_val = 0;

    for(auto k : key)
        hash_val = ((hash_val << 5) + hash_val) + k; //PHP DJBX33A hash algorithm.

    hash_val %= table_size;
    if(hash_val < 0)
        hash_val += table_size;

    return hash_val;
}

int hash_func::hash(const int key, const int table_size) const
{
    return hash(std::to_string(key), table_size);
}

雜湊函式有了,當多個不同元素通過雜湊函式對映到同一位置,那麼就會產生衝突。接下來討論如何處理衝突。

處理衝突

分離連結法(Separate Chaining)

該方法又稱為拉鍊法。分離連結法通常情況下是通過陣列加連結串列實現的,將產生衝突的元素在衝突的位置用連結串列連結起來。新元素由於最新插入可能會最先被訪問所以插入連結串列的前端。

採用分離連結法,執行find或insert操作我們需要先使用雜湊函式查詢元素對應雜湊表的下表,然後再遍歷連結串列,檢視元素是否已經存在。如果是insert操作,元素已經存在,我們就什麼也不做(如果要插入重複元,那麼通常要留出一個額外的資料成員,當出現匹配事件時這個計數增1)。

除連結串列外,任何的方案也都有可能用來解決衝突現象。比如紅黑樹,甚至是另外一張雜湊表。在JAVA的HashMap中,當連結串列的長度超過8時,連結串列會轉化為紅黑樹處理。

下面是分離連結法的實現程式碼:
首先是hash_table.h,其中hash_func成員函式上文已給出:

#ifndef _HASH_TABLE_H
#define _HASH_TABLE_H

#include <string>
#include <vector>
#include <list>
#include <algorithm>

class hash_func {
protected:
    virtual int hash(const std::string& key, const int table_size) const;
    virtual int hash(const int key, const int table_size) const;
};

template <typename HASH>
class hash_table : public hash_func {
public:
    explicit hash_table(int size = 101)
        : lists_(size), current_size_(0)
    { make_empty(); }

    bool contains(const HASH& x) const;

    bool insert(const HASH& x);
    bool remove(const HASH& x);
private:
    void make_empty();
    void rehash();
private:
    std::vector<std::list<HASH>> lists_;     //the array of lists
    int                          current_size_;
};

template <typename HASH>
void hash_table<HASH>::make_empty()
{
    std::for_each(lists_.begin(), lists_.end(), [] (std::list<HASH>& lst) {
        lst.clear(); } );
}

template <typename HASH>
bool hash_table<HASH>::contains(const HASH& x) const
{
    const std::list<HASH>& lst = lists_[hash(x, lists_.size())];
    return std::find(lst.begin(), lst.end(), x) != lst.end();
}

template <typename HASH>
bool hash_table<HASH>::insert(const HASH& x)
{
    auto& lst= lists_[hash(x, lists_.size())];
    if(std::find(lst.begin(), lst.end(), x) != lst.end())
        return false;
    lst.push_back(x);

    if(++current_size_ > lists_.size())
        rehash();
    return true;
}

template <typename HASH>
bool hash_table<HASH>::remove(const HASH& x)
{
    auto& lst = lists_[hash(x, lists_.size())];
    auto target = std::find(lst.begin(), lst.end(), x);

    if(target == lst.end())
        return false;

    lst.erase(target);
    --current_size_;
    return true;
}

template <typename HASH>
void hash_table<HASH>::rehash()
{
    auto lists_copy = lists_;
    current_size_ = 0;     //don't forget this line, otherwise current_size_ will accumulate.
    lists_.resize(lists_.size() << 1);
    make_empty();  //the resize operation never clear the vector.

    for(auto lc : lists_copy)
        for(auto i : lc)
            insert(i);
}

在這裡引入負載因子(load factor)的概念。它用來衡量雜湊表的 空/滿 程度,一定程度上也可以體現查詢的效率,計算公式為:

負載因子λ = 總的元素個數 / bucket數目(陣列的項數)

由於分離連結法衝突時使用連結串列解決衝突,所以它的負載因子可能>1。不過通常情況下,為了保持雜湊表的高效性,我們可能會維持λ<=1。否則需要進行擴容,執行rehash。

不過,memcached原始碼中,它的實現機制是當λ=1.5時,才會執行rehash,也就是平均一個bucket儲存1.5個元素,實際上效率也幾乎沒有影響。

程式碼中有rehash操作,後文會分析。

開放定址法

分離連結雜湊法的缺點是使用一些連結串列,由於給新單元分配地址需要時間,這就導致演算法速度有些減慢。同時還需要連結串列這種資料結構的實現。我們現在來看一下另外的思路,也就是用來解決衝突的探測方法。

線性探測法

線性探測法的缺點是容易產生一次聚集(primary clustering)。

平方探測法

平方探測法雖然排除了一次聚集,但是雜湊到同一位置上的那些元素將探測相同的備選單元。這叫做二次聚集(secondary clustering)。

以上兩種方法不再詳述,參見維基百科即可:雜湊表

雙雜湊

雙雜湊法就就用來解決”聚集”這個問題的,利用已產生的hash值+(一個小於陣列長度的素數-hash(該素數)產生的值),得出最終的位置。雙雜湊的公式同樣參考維基百科即可:Double hashing。這裡有一個雙雜湊的圖,非常形象:

這裡寫圖片描述

該圖原址為:雙雜湊圖。頁面可以左右劃,還有其他圖示。

雙雜湊理論上解決衝突效果很好,但是平方探測法不需要使用第二個雜湊函式,從而在實踐中可能更簡單並且更快。

下面給出開放地址法的程式碼,採用了平方探測法:

#ifndef _HASH_TABLE_H
#define _HASH_TABLE_H

#include <string>
#include <vector>
#include <list>
#include <algorithm>

class hash_func {
protected:
    virtual int hash(const std::string& key, const int table_size) const;
    virtual int hash(const int key, const int table_size) const;
};

template <typename HASH>
class hash_table : public hash_func {
public:
    explicit hash_table(int size = 3)  
        : array_(size), current_size_(0)
    { make_empty(); }

    bool contains(const HASH& x) const;

    bool insert(const HASH& x); 
    bool remove(const HASH& x); 
private:
    void make_empty();
    void rehash();

    bool is_active(int current_pos) const;
    int  find_pos(const HASH& x) const;
private: 
    enum EntryType {ACTIVE, EMPTY, DELETED};
    struct hash_entry {
        HASH      element_;
        EntryType info_;

        hash_entry(const HASH& e = HASH(), EntryType i = EMPTY)
            : element_(e), info_(i) 
        {}  
    };  
private:
    std::vector<hash_entry> array_;     //the array of lists
    int                     current_size_;
};  

template <typename HASH>
void hash_table<HASH>::make_empty()
{
    std::for_each(array_.begin(), array_.end(), [] (hash_entry& v) {
        v.info_ = EMPTY; } );
}

template <typename HASH>
bool hash_table<HASH>::contains(const HASH& x) const
{
    return is_active(find_pos(x));
}

template <typename HASH>
bool hash_table<HASH>::is_active(int current_pos) const
{
    return array_[current_pos].info_ == ACTIVE;
}

template <typename HASH>
int hash_table<HASH>::find_pos(const HASH& x) const
{
    int offset = 1;
    int current_pos = hash(x, array_.size());

    while(array_[current_pos].info_ != EMPTY &&  //first condition
          array_[current_pos].element_ != x){    //second condition. you can't swap the first and the second condition.
                current_pos += offset;    //compute ith probe         
                offset += 2;
                if(current_pos >= array_.size())
                    current_pos -= array_.size();
          }
    return current_pos;
}

template <typename HASH>
bool hash_table<HASH>::insert(const HASH& x)
{
    //insert x as active
    int current_pos = find_pos(x);   //find a position
    if(is_active(current_pos))  //if already inserted, failed.
        return false;

    array_[current_pos] = hash_entry(x, ACTIVE);

    if(++current_size_ > (array_.size() >> 1))  //the load factor must less than 0.5.
        rehash();
    return true;
}

template <typename HASH>
bool hash_table<HASH>::remove(const HASH& x)
{
    int current_pos = find_pos(x);
    if(!is_active(current_pos))
        return false;
    array_[current_pos].info_ = DELETED;   //laze delete
    return true;
}

template <typename HASH>
void hash_table<HASH>::rehash()
{
    auto vec_copy = array_;
    current_size_ = 0;     //don't forget this line, otherwise current_size_ will accumulate.
    array_.resize(array_.size() << 1);
    make_empty();
    for(auto v : vec_copy){
        if(v.info_ == ACTIVE)
            insert(v.element_);
    }
}

#endif

使用開放定址法時,刪除就不能直接刪除了。我們需要採用惰性刪除(lazy deleted),做個標記即可。因為該位置有可能之前產生過沖突,我們需要通過標記來尋訪之前產生衝突的元素。

再雜湊(rehash)

如果雜湊表元素填的太滿,會引起雜湊表的效能下降。尤其是對於開放定址法,探測的時間可能很長,且有可能插入失敗。所以這個時候我們就需要執行rehash。可以建立另外一個大約兩倍大的表,然後把原始表中的所有元素通過新雜湊函式(TableSize已經改變)插入到新表之中。

rehash是一種非常昂貴的操作,其執行時間為O(N),因為要遍歷原始表。不過,由於不經常發生,所以是效果沒有這麼差。

通常情況下,平方探測法可能在λ=0.75時就進行rehash,JAVA就是這樣做的;而拉鍊法會高一些,比如memcached在λ=1.5才進行rehash。

各種衝突解決方法的優點和缺點

原文如下:

這裡寫圖片描述

大意是:分離連結法使用連結串列,花費一定的時間在申請記憶體操作上。線性探測法容易實現,但執行效率隨著負載因子的增加會出現一次聚集問題。平方探測法實現難度只有一點增加並且在實踐中有很好的效率。如果表半空插入可能失敗(當表大小不是素數會發生),但這不太可能。即使是這樣的插入將是如此昂貴,這也沒有關係,因為它暴露除了雜湊函式是有問題的。雙雜湊能消除一次聚集和二次聚集問題,但是多一次雜湊函式的計算是要花費代價的。實踐表明,平方探測法會帶來最好的效能。

雜湊表和其他資料結構的對比

優點:
記錄量很大的時候,處理記錄的速度很快,平均的操作時間是一個不大的常數。

缺點:

  1. 好的雜湊函式(good hash function)的計算成本有可能會顯著高於線性表或者紅黑樹在查詢時的內部迴圈成本,所以資料量非常小的時候,雜湊表是低效的
  2. 雜湊表相比紅黑樹按照key對value有序遍歷是比較麻煩的,需要先取出所有記錄再進行額外的排序。
  3. 雜湊表處理衝突的機制本身可能就是一個缺陷,攻擊者可能而已構造資料,來實現處理衝突的最壞情況,即每次都出現衝突。以此大幅降低雜湊表的效能。比如PHP的雜湊碰撞攻擊,讓雜湊表退化為一個單連結串列,請求堆積,最終演變成拒絕服務的狀態。

雜湊表的加鎖

雜湊表的rehash是一個耗時的過程,那麼這個過程怎麼加鎖呢?

我們顯然不能上全域性鎖,這樣鎖衝突不能避免,且工作執行緒就無法插入資料了。在雜湊表遷移的時候,我們可以採用多個分段鎖。比如每次上鎖只針對一個buket進行遷移,遷移完畢立即釋放鎖。這樣會減少遷移執行緒持有鎖的時間,工作執行緒能更大機率搶佔到鎖,然後進行資料的插入。

至於資料插入到新表還是舊錶,參照memcached,使用一個目前已經遷移的bucket下標,每遷移一個bucket該下表自增1。通過兩個執行緒減共享下標的資訊判斷要插入的元素的位置是否已經遷移。如果已經遷移,就插入到新表之中。

關於memached的hash原始碼可以看這篇部落格: memcached原始碼分析—–雜湊表基本操作以及擴容過程

另外,Redis的rehash過程雖然是單執行緒的,但也是採用了類似的思想。每次遷移一小部分,並且每次插入進行判斷。Redis的hash參考: 深入理解雜湊表(JAVA和Redis雜湊表實現)

還有一個hash的高階內容參考:談談面試–雜湊表系列

關於雜湊表的大資料面試題

在這篇部落格從頭到尾徹底解析Hash表演算法有很好的例子,不再贅述。

相關文章