淺談算法和數據結構: 哈希表

作者: yangecnuyangecnu's Blog on 博客園) 

出處:http://www.cnblogs.com/yangecnu/  http://www.cnblogs.com/yangecnu/p/Introduce-Hashtable.html


基於無序列表的順序查找基於有序數組的二分查找平衡查找樹,以及紅黑樹,下圖是他們在平均以及最差情況下的時間複雜度:

Different structure with different efficient

可以看到在時間複雜度上,紅黑樹在平均情況下插入,查找以及刪除上都達到了lgN的時間複雜度。

那麼有沒有查找效率更高的數據結構呢,答案就是本文接下來要介紹了散列表,也叫哈希表(Hash Table)

什麼是哈希表

哈希表就是一種以 鍵-值(key-indexed) 存儲數據的結構,我們只要輸入待查找的值即key,即可查找到其對應的值。

哈希的思路很簡單,如果所有的鍵都是整數,那麼就可以使用一個簡單的無序數組來實現:將鍵作爲索引,值即爲其對應的值,這樣就可以快速訪問任意鍵的值。這是對於簡單的鍵的情況,我們將其擴展到可以處理更加複雜的類型的鍵。

使用哈希查找有兩個步驟:

  1. 使用哈希函數將被查找的鍵轉換爲數組的索引。在理想的情況下,不同的鍵會被轉換爲不同的索引值,但是在有些情況下我們需要處理多個鍵被哈希到同一個索引值的情況。所以哈希查找的第二個步驟就是處理衝突
  2. 處理哈希碰撞衝突。有很多處理哈希碰撞衝突的方法,本文後面會介紹拉鍊法和線性探測法。

哈希表是一個在時間和空間上做出權衡的經典例子。如果沒有內存限制,那麼可以直接將鍵作爲數組的索引。那麼所有的查找時間複雜度爲O(1);如果沒有時間限制,那麼我們可以使用無序數組並進行順序查找,這樣只需要很少的內存。哈希表使用了適度的時間和空間來在這兩個極端之間找到了平衡。只需要調整哈希函數算法即可在時間和空間上做出取捨。

哈希函數

哈希查找第一步就是使用哈希函數將鍵映射成索引。這種映射函數就是哈希函數。如果我們有一個保存0-M數組,那麼我們就需要一個能夠將任意鍵轉換爲該數組範圍內的索引(0~M-1)的哈希函數。哈希函數需要易於計算並且能夠均勻分佈所有鍵。比如舉個簡單的例子,使用手機號碼後三位就比前三位作爲key更好,因爲前三位手機號碼的重複率很高。再比如使用身份證號碼出生年月位數要比使用前幾位數要更好。

在實際中,我們的鍵並不都是數字,有可能是字符串,還有可能是幾個值的組合等,所以我們需要實現自己的哈希函數。

1. 正整數

獲取正整數哈希值最常用的方法是使用除留餘數法。即對於大小爲素數M的數組,對於任意正整數k,計算k除以M的餘數。M一般取素數。

2. 字符串

將字符串作爲鍵的時候,我們也可以將他作爲一個大的整數,採用保留除餘法。我們可以將組成字符串的每一個字符取值然後進行哈希,比如

public int GetHashCode(string str)
{
    char[] s = str.ToCharArray();
    int hash = 0;
    for (int i = 0; i < s.Length; i++)
    {
        hash = s[i] + (31 * hash); 
    }
    return hash;
}

上面的哈希值是Horner計算字符串哈希值的方法,公式爲:

   h = s[0] · 31L–1 + … + s[L – 3] · 312 + s[L – 2] · 311 + s[L – 1] · 310

舉個例子,比如要獲取”call”的哈希值,字符串c對應的unicode爲99,a對應的unicode爲97,L對應的unicode爲108,所以字符串”call”的哈希值爲 3045982 = 99·313 + 97·312 + 108·311 + 108·31= 108 + 31· (108 + 31 · (97 + 31 · (99)))

如果對每個字符去哈希值可能會比較耗時,所以可以通過間隔取N個字符來獲取哈西值來節省時間,比如,可以 獲取每8-9個字符來獲取哈希值:

public int GetHashCode(string str)
{
    char[] s = str.ToCharArray();
    int hash = 0;
    int skip = Math.Max(1, s.Length / 8);
    for (int i = 0; i < s.Length; i+=skip)
    {
        hash = s[i] + (31 * hash);
    }
    return hash;
}

但是,對於某些情況,不同的字符串會產生相同的哈希值,這就是前面說到的哈希衝突(Hash Collisions),比如下面的四個字符串:

hash code collision

如果我們按照每8個字符取哈希的話,就會得到一樣的哈希值。所以下面來講解如何解決哈希碰撞:

避免哈希衝突

拉鍊法 (Separate chaining with linked lists)

通過哈希函數,我們可以將鍵轉換爲數組的索引(0-M-1),但是對於兩個或者多個鍵具有相同索引值的情況,我們需要有一種方法來處理這種衝突。

一種比較直接的辦法就是,將大小爲M 的數組的每一個元素指向一個條鏈表,鏈表中的每一個節點都存儲散列值爲該索引的鍵值對,這就是拉鍊法。下圖很清楚的描述了什麼是拉鍊法

seperate chaining with link list

圖中,”John Smith”和”Sandra Dee” 通過哈希函數都指向了152 這個索引,該索引又指向了一個鏈表, 在鏈表中依次存儲了這兩個字符串。

該方法的基本思想就是選擇足夠大的M,使得所有的鏈表都儘可能的短小,以保證查找的效率。對採用拉鍊法的哈希實現的查找分爲兩步,首先是根據散列值找到等一應的鏈表,然後沿着鏈表順序找到相應的鍵。 我們現在使用我們之前介紹符號表中的使用無序鏈表實現的查找表SequentSearchSymbolTable 來實現我們這裏的哈希表。當然,您也可以使用.NET裏面內置的LinkList。

首先我們需要定義一個鏈表的總數,在內部我們定義一個SequentSearchSymbolTable的數組。然後每一個映射到索引的地方保存一個這樣的數組。

public class SeperateChainingHashSet<TKey, TValue> : SymbolTables<TKey, TValue> where TKey : IComparable<TKey>, IEquatable<TKey>
{
    private int M;//散列表大小
    private SequentSearchSymbolTable<TKey, TValue>[] st;//

    public SeperateChainingHashSet()
        : this(997)
    {

    }

    public SeperateChainingHashSet(int m)
    {
        this.M = m;
        st = new SequentSearchSymbolTable<TKey, TValue>[m];
        for (int i = 0; i < m; i++)
        {
            st[i] = new SequentSearchSymbolTable<TKey, TValue>();
        }
    }

    private int hash(TKey key)
    {
        return (key.GetHashCode() & 0x7fffffff) % M;
    }

    public override TValue Get(TKey key)
    {
        return st[hash(key)].Get(key);
    }

    public override void Put(TKey key, TValue value)
    {
        st[hash(key)].Put(key, value);
    }

}

可以看到,該實現中使用

  • Get方法來獲取指定key的Value值,我們首先通過hash方法來找到key對應的索引值,即找到SequentSearchSymbolTable數組中存儲該元素的查找表,然後調用查找表的Get方法,根據key找到對應的Value。
  • Put方法用來存儲鍵值對,首先通過hash方法找到改key對應的哈希值,然後找到SequentSearchSymbolTable數組中存儲該元素的查找表,然後調用查找表的Put方法,將鍵值對存儲起來。
  • hash方法來計算key的哈希值, 這裏首先通過取與&操作,將符號位去除,然後採用除留餘數法將key應到到0-M-1的範圍,這也是我們的查找表數組索引的範圍。

實現基於拉鍊表的散列表,目標是選擇適當的數組大小M,使得既不會因爲空鏈表而浪費內存空間,也不會因爲鏈表太而在查找上浪費太多時間。拉鍊表的優點在於,這種數組大小M的選擇不是關鍵性的,如果存入的鍵多於預期,那麼查找的時間只會比選擇更大的數組稍長,另外,我們也可以使用更高效的結構來代替鏈表存儲。如果存入的鍵少於預期,索然有些浪費空間,但是查找速度就會很快。所以當內存不緊張時,我們可以選擇足夠大的M,可以使得查找時間變爲常數,如果內存緊張時,選擇儘量大的M仍能夠將性能提高M倍。

線性探測法

線性探測法是開放尋址法解決哈希衝突的一種方法,基本原理爲,使用大小爲M的數組來保存N個鍵值對,其中M>N,我們需要使用數組中的空位解決碰撞衝突。如下圖所示:

open address

對照前面的拉鍊法,在該圖中,”Ted Baker” 是有唯一的哈希值153的,但是由於153被”Sandra Dee”佔用了。而原先”Snadra Dee”和”John Smith”的哈希值都是152的,但是在對”Sandra Dee”進行哈希的時候發現152已經被佔用了,所以往下找發現153沒有被佔用,所以存放在153上,然後”Ted Baker”哈希到153上,發現已經被佔用了,所以往下找,發現154沒有被佔用,所以值存到了154上。

開放尋址法中最簡單的是線性探測法:當碰撞發生時即一個鍵的散列值被另外一個鍵佔用時,直接檢查散列表中的下一個位置即將索引值加1,這樣的線性探測會出現三種結果:

  1. 命中,該位置的鍵和被查找的鍵相同
  2. 未命中,鍵爲空
  3. 繼續查找,該位置和鍵被查找的鍵不同。

實現線性探測法也很簡單,我們只需要兩個大小相同的數組分別記錄key和value。

public class LinearProbingHashSet<TKey, TValue> : SymbolTables<TKey, TValue> where TKey : IComparable<TKey>, IEquatable<TKey>
{
    private int N;//符號表中鍵值對的總數
    private int M = 16;//線性探測表的大小
    private TKey[] keys;
    private TValue[] values;

    public LinearProbingHashSet()
    {
        keys = new TKey[M];
        values = new TValue[M];
    }

    private int hash(TKey key)
    {
        return (key.GetHashCode() & 0xFFFFFFF) % M;
    }

    public override TValue Get(TKey key)
    {
        for (int i = hash(key); keys[i] != null; i = (i + 1) % M)
        {
            if (key.Equals(keys[i])) { return values[i]; }
        }
        return default(TValue);
    }

    public override void Put(TKey key, TValue value)
    {
        int hashCode = hash(key);
        for (int i = hashCode; keys[i] != null; i = (i + 1) % M)
        {
            if (keys[i].Equals(key))//如果和已有的key相等,則用新值覆蓋
            {
                values[i] = value;
                return;
            }
            //插入
            keys[i] = key;
            values[i] = value;
        }
    }
}

線性探查(Linear Probing)方式雖然簡單,但是有一些問題,它會導致同類哈希的聚集。在存入的時候存在衝突,在查找的時候衝突依然存在。

性能分析

我們可以看到,哈希表存儲和查找數據的時候分爲兩步,第一步爲將鍵通過哈希函數映射爲數組中的索引, 這個過程可以認爲是只需要常數時間的。第二步是,如果出現哈希值衝突,如何解決,前面介紹了拉鍊法和線性探測法下面就這兩種方法進行討論:

對於拉鍊法,查找的效率在於鏈表的長度,一般的我們應該保證長度在M/8~M/2之間,如果鏈表的長度大於M/2,我們可以擴充鏈表長度。如果長度在0~M/8時,我們可以縮小鏈表。

對於線性探測法,也是如此,但是動態調整數組的大小需要對所有的值從新進行重新散列並插入新的表中。

不管是拉鍊法還是散列法,這種動態調整鏈表或者數組的大小以提高查詢效率的同時,還應該考慮動態改變鏈表或者數組大小的成本。散列表長度加倍的插入需要進行大量的探測, 這種均攤成本在很多時候需要考慮。

哈希碰撞攻擊

我們知道如果哈希函數選擇不當會使得大量的鍵都會映射到相同的索引上,不管是採用拉鍊法還是開放尋址法解決衝突,在後面查找的時候都需要進行多次探測或者查找, 在很多時候會使得哈希表的查找效率退化,而不再是常數時間。下圖清楚的描述了退化後的哈希表:

hashCollision 

哈希表攻擊就是通過精心構造哈希函數,使得所有的鍵經過哈希函數後都映射到同一個或者幾個索引上,將哈希表退化爲了一個單鏈表,這樣哈希表的各種操作,比如插入,查找都從O(1)退化到了鏈表的查找操作,這樣就會消耗大量的CPU資源,導致系統無法響應,從而達到拒絕服務供給(Denial of Service, Dos)的目的。之前由於多種編程語言的哈希算法的“非隨機”而出現了Hash碰撞的DoS安全漏洞,在ASP.NET中也曾出現過這一問題

在.NET中String的哈希值內部實現中,通過使用哈希值隨機化來對這種問題進行了限制,通過對碰撞次數設置閾值,超過該閾值就對哈希函數進行隨機化,這也是防止哈希表退化的一種做法。下面是BCL中string類型的GetHashCode方法的實現,可以看到,當碰撞超過一定次數的時候,就會開啓條件編譯,對哈希函數進行隨機化。

[ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail), SecuritySafeCritical, __DynamicallyInvokable]
public override unsafe int GetHashCode()
{
    if (HashHelpers.s_UseRandomizedStringHashing)
    {
        return InternalMarvin32HashString(this, this.Length, 0L);
    }
    fixed (char* str = ((char*) this))
    {
        char* chPtr = str;
        int num = 0x15051505;
        int num2 = num;
        int* numPtr = (int*) chPtr;
        int length = this.Length;
        while (length > 2)
        {
            num = (((num << 5) + num) + (num >> 0x1b)) ^ numPtr[0];
            num2 = (((num2 << 5) + num2) + (num2 >> 0x1b)) ^ numPtr[1];
            numPtr += 2;
            length -= 4;
        }
        if (length > 0)
        {
            num = (((num << 5) + num) + (num >> 0x1b)) ^ numPtr[0];
        }
        return (num + (num2 * 0x5d588b65));
    }
}


總結

基於無序列表的順序查找基於有序數組的二分查找平衡查找樹,以及紅黑樹,本篇文章最後介紹了查找算法中的最後一類即符號表又稱哈希表,並介紹了哈希函數以及處理哈希衝突的兩種方法:拉鍊法和線性探測法。各種查找算法的最壞和平均條件下各種操作的時間複雜度如下圖:

search method efficient conclusion

在實際編寫代碼中,如何選擇合適的數據結構需要根據具體的數據規模,查找效率要求,時間和空間侷限來做出合適的選擇。

發佈了31 篇原創文章 · 獲贊 11 · 訪問量 15萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章