在前面的系列文章中,依次介紹了基於無序列表的順序查詢,基於有序陣列的二分查詢,平衡查詢樹,以及紅黑樹,下圖是它們在平均以及最差情況下的時間複雜度:
可以看到在時間複雜度上,紅黑樹在平均情況下插入,查詢以及刪除上都達到了lgN的時間複雜度。
那麼有沒有查詢效率更高的資料結構呢,答案就是本文接下來要介紹了雜湊表,也叫雜湊表(Hash Table)
什麼是雜湊表
雜湊表就是一種以 鍵-值(key-indexed) 儲存資料的結構,我們只要輸入待查詢的值即key,即可查詢到其對應的值。
雜湊的思路很簡單,如果所有的鍵都是整數,那麼就可以使用一個簡單的無序陣列來實現:將鍵作為索引,值即為其對應的值,這樣就可以快速訪問任意鍵的值。這是對於簡單的鍵的情況,我們將其擴充套件到可以處理更加複雜的型別的鍵。
使用雜湊查詢有兩個步驟:
- 使用雜湊函式將被查詢的鍵轉換為陣列的索引。在理想的情況下,不同的鍵會被轉換為不同的索引值,但是在有些情況下我們需要處理多個鍵被雜湊到同一個索引值的情況。所以雜湊查詢的第二個步驟就是處理衝突
- 處理雜湊碰撞衝突。有很多處理雜湊碰撞衝突的方法,本文後面會介紹拉鍊法和線性探測法。
雜湊表是一個在時間和空間上做出權衡的經典例子。如果沒有記憶體限制,那麼可以直接將鍵作為陣列的索引。那麼所有的查詢時間複雜度為O(1);如果沒有時間限制,那麼我們可以使用無序陣列並進行順序查詢,這樣只需要很少的記憶體。雜湊表使用了適度的時間和空間來在這兩個極端之間找到了平衡。只需要調整雜湊函式演算法即可在時間和空間上做出取捨。
雜湊函式
雜湊查詢第一步就是使用雜湊函式將鍵對映成索引。這種對映函式就是雜湊函式。如果我們有一個儲存0-M陣列,那麼我們就需要一個能夠將任意鍵轉換為該陣列範圍內的索引(0~M-1)的雜湊函式。雜湊函式需要易於計算並且能夠均勻分佈所有鍵。比如舉個簡單的例子,使用手機號碼後三位就比前三位作為key更好,因為前三位手機號碼的重複率很高。再比如使用身份證號碼出生年月位數要比使用前幾位數要更好。
在實際中,我們的鍵並不都是數字,有可能是字串,還有可能是幾個值的組合等,所以我們需要實現自己的雜湊函式。
1. 正整數
獲取正整數雜湊值最常用的方法是使用除留餘數法。即對於大小為素數M的陣列,對於任意正整數k,計算k除以M的餘數。M一般取素數。
2. 字串
將字串作為鍵的時候,我們也可以將他作為一個大的整數,採用保留除餘法。我們可以將組成字串的每一個字元取值然後進行雜湊,比如
1 2 3 4 5 6 7 8 9 10 |
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·310 = 108 + 31· (108 + 31 · (97 + 31 · (99)))
如果對每個字元去雜湊值可能會比較耗時,所以可以通過間隔取N個字元來獲取哈西值來節省時間,比如,可以 獲取每8-9個字元來獲取雜湊值:
1 2 3 4 5 6 7 8 9 10 11 |
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),比如下面的四個字串:
如果我們按照每8個字元取雜湊的話,就會得到一樣的雜湊值。所以下面來講解如何解決雜湊碰撞:
避免雜湊衝突
拉鍊法 (Separate chaining with linked lists)
通過雜湊函式,我們可以將鍵轉換為陣列的索引(0-M-1),但是對於兩個或者多個鍵具有相同索引值的情況,我們需要有一種方法來處理這種衝突。
一種比較直接的辦法就是,將大小為M 的陣列的每一個元素指向一個條連結串列,連結串列中的每一個節點都儲存雜湊值為該索引的鍵值對,這就是拉鍊法。下圖很清楚的描述了什麼是拉鍊法。
圖中,”John Smith”和”Sandra Dee” 通過雜湊函式都指向了152 這個索引,該索引又指向了一個連結串列, 在連結串列中依次儲存了這兩個字串。
該方法的基本思想就是選擇足夠大的M,使得所有的連結串列都儘可能的短小,以保證查詢的效率。對採用拉鍊法的雜湊實現的查詢分為兩步,首先是根據雜湊值找到等一應的連結串列,然後沿著連結串列順序找到相應的鍵。 我們現在使用我們之前介紹符號表中的使用無序連結串列實現的查詢表SequentSearchSymbolTable 來實現我們這裡的雜湊表。當然,您也可以使用.NET裡面內建的LinkList。
首先我們需要定義一個連結串列的總數,在內部我們定義一個SequentSearchSymbolTable的陣列。然後每一個對映到索引的地方儲存一個這樣的陣列。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
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,我們需要使用陣列中的空位解決碰撞衝突。如下圖所示:
對照前面的拉鍊法,在該圖中,”Ted Baker” 是有唯一的雜湊值153的,但是由於153被”Sandra Dee”佔用了。而原先”Snadra Dee”和”John Smith”的雜湊值都是152的,但是在對”Sandra Dee”進行雜湊的時候發現152已經被佔用了,所以往下找發現153沒有被佔用,所以存放在153上,然後”Ted Baker”雜湊到153上,發現已經被佔用了,所以往下找,發現154沒有被佔用,所以值存到了154上。
開放定址法中最簡單的是線性探測法:當碰撞發生時即一個鍵的雜湊值被另外一個鍵佔用時,直接檢查雜湊表中的下一個位置即將索引值加1,這樣的線性探測會出現三種結果:
- 命中,該位置的鍵和被查詢的鍵相同
- 未命中,鍵為空
- 繼續查詢,該位置和鍵被查詢的鍵不同。
實現線性探測法也很簡單,我們只需要兩個大小相同的陣列分別記錄key和value。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
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時,我們可以縮小連結串列。
對於線性探測法,也是如此,但是動態調整陣列的大小需要對所有的值從新進行重新雜湊並插入新的表中。
不管是拉鍊法還是雜湊法,這種動態調整連結串列或者陣列的大小以提高查詢效率的同時,還應該考慮動態改變連結串列或者陣列大小的成本。雜湊表長度加倍的插入需要進行大量的探測, 這種均攤成本在很多時候需要考慮。
雜湊碰撞攻擊
我們知道如果雜湊函式選擇不當會使得大量的鍵都會對映到相同的索引上,不管是採用拉鍊法還是開放定址法解決衝突,在後面查詢的時候都需要進行多次探測或者查詢, 在很多時候會使得雜湊表的查詢效率退化,而不再是常數時間。下圖清楚的描述了退化後的雜湊表:
雜湊表攻擊就是通過精心構造雜湊函式,使得所有的鍵經過雜湊函式後都對映到同一個或者幾個索引上,將雜湊表退化為了一個單連結串列,這樣雜湊表的各種操作,比如插入,查詢都從O(1)退化到了連結串列的查詢操作,這樣就會消耗大量的CPU資源,導致系統無法響應,從而達到拒絕服務供給(Denial of Service, Dos)的目的。之前由於多種程式語言的雜湊演算法的“非隨機”而出現了Hash碰撞的DoS安全漏洞,在ASP.NET中也曾出現過這一問題。
在.NET中String的雜湊值內部實現中,通過使用雜湊值隨機化來對這種問題進行了限制,通過對碰撞次數設定閾值,超過該閾值就對雜湊函式進行隨機化,這也是防止雜湊表退化的一種做法。下面是BCL中string型別的GetHashCode方法的實現,可以看到,當碰撞超過一定次數的時候,就會開啟條件編譯,對雜湊函式進行隨機化。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
[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中去:
1 2 3 4 5 |
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方法:
1 2 3 4 5 6 7 |
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方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
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操作比較消耗資源。
總結
前面幾篇文章先後介紹了基於無序列表的順序查詢,基於有序陣列的二分查詢,平衡查詢樹,以及紅黑樹,本篇文章最後介紹了查詢演算法中的最後一類即符號表又稱雜湊表,並介紹了雜湊函式以及處理雜湊衝突的兩種方法:拉鍊法和線性探測法。各種查詢演算法的最壞和平均條件下各種操作的時間複雜度如下圖:
在實際編寫程式碼中,如何選擇合適的資料結構需要根據具體的資料規模,查詢效率要求,時間和空間侷限來做出合適的選擇。希望本文以及前面的幾篇文章對您有所幫助。
參考資料