哈希表

在前面的系列文章中,依次介紹了基於無序列表的順序查找基於有序數組的二分查找平衡查找樹,以及紅黑樹,下圖是他們在平均以及最差狀況下的時間複雜度:php

Different structure with different efficient

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

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

什麼是哈希表

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

哈希的思路很簡單,若是全部的鍵都是整數,那麼就可使用一個簡單的無序數組來實現:將鍵做爲索引,值即爲其對應的值,這樣就能夠快速訪問任意鍵的值。這是對於簡單的鍵的狀況,咱們將其擴展到能夠處理更加複雜的類型的鍵。編程

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

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

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

哈希函數

哈希查找第一步就是使用哈希函數將鍵映射成索引。這種映射函數就是哈希函數。若是咱們有一個保存0-M數組,那麼咱們就須要一個可以將任意鍵轉換爲該數組範圍內的索引(0~M-1)的哈希函數。哈希函數須要易於計算而且可以均勻分佈全部鍵。好比舉個簡單的例子,使用手機號碼後三位就比前三位做爲key更好,由於前三位手機號碼的重複率很高。再好比使用身份證號碼出生年月位數要比使用前幾位數要更好。數據結構

在實際中,咱們的鍵並不都是數字,有多是字符串,還有多是幾個值的組合等,因此咱們須要實現本身的哈希函數。dom

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

.NET中哈希的實現

咱們能夠經過在線源碼查看.NET 中Dictionary,類型的實現,咱們知道任何做爲key的值添加到Dictionary中時,首先會獲取key的hashcode,而後將其映射到不一樣的bucket中去:

public Dictionary(int capacity, IEqualityComparer<TKey> comparer) {
    if (capacity < 0) ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.capacity);
    if (capacity > 0) Initialize(capacity);
    this.comparer = comparer ?? EqualityComparer<TKey>.Default;
}

在Dictionary初始化的時候,會若是傳入了大小,會初始化bucket 就是調用Initialize方法:

private void Initialize(int capacity) {
    int size = HashHelpers.GetPrime(capacity);
    buckets = new int[size];
    for (int i = 0; i < buckets.Length; i++) buckets[i] = -1;
    entries = new Entry[size];
    freeList = -1;
}

咱們能夠看看Dictonary的Add方法,Add方法在內部調用了Insert方法:

private void Insert(TKey key, TValue value, bool add) 
{
        if( key == null ) {
            ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key);
        }
 
        if (buckets == null) Initialize(0);
        int hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF;
        int targetBucket = hashCode % buckets.Length;
 
#if FEATURE_RANDOMIZED_STRING_HASHING
        int collisionCount = 0;
#endif
 
        for (int i = buckets[targetBucket]; i >= 0; i = entries[i].next) {
            if (entries[i].hashCode == hashCode && comparer.Equals(entries[i].key, key)) {
                if (add) { 
                    ThrowHelper.ThrowArgumentException(ExceptionResource.Argument_AddingDuplicate);
                }
                entries[i].value = value;
                version++;
                return;
            } 
 
#if FEATURE_RANDOMIZED_STRING_HASHING
            collisionCount++;
#endif
        }
        int index;
        if (freeCount > 0) {
            index = freeList;
            freeList = entries[index].next;
            freeCount--;
        }
        else {
            if (count == entries.Length)
            {
                Resize();
                targetBucket = hashCode % buckets.Length;
            }
            index = count;
            count++;
        }
 
        entries[index].hashCode = hashCode;
        entries[index].next = buckets[targetBucket];
        entries[index].key = key;
        entries[index].value = value;
        buckets[targetBucket] = index;
        version++;
 
#if FEATURE_RANDOMIZED_STRING_HASHING
        if(collisionCount > HashHelpers.HashCollisionThreshold && HashHelpers.IsWellKnownEqualityComparer(comparer)) 
        {
            comparer = (IEqualityComparer<TKey>) HashHelpers.GetRandomizedEqualityComparer(comparer);
            Resize(entries.Length, true);
        }
#endif
 
    }

首先,根據key獲取其hashcode,而後將hashcode除以backet的大小取餘映射到目標backet中,而後遍歷該bucket存儲的鏈表,若是找到和key相同的值,若是不容許後添加的鍵與存在的鍵相同替換值(add),則拋出異常,若是容許,則替換以前的值,而後返回。

若是沒有找到,則將新添加的值放到新的bucket中,當空餘空間不足的時候,會進行擴容操做(Resize),而後從新hash到目標bucket。這裏面須要注意的是Resize操做比較消耗資源。

總結

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

search method efficient conclusion

在實際編寫代碼中,如何選擇合適的數據結構須要根據具體的數據規模,查找效率要求,時間和空間侷限來作出合適的選擇。但願本文以及前面的幾篇文章對您有所幫助。

 

參考資料

相關文章
相關標籤/搜索