- 雜湊表的查詢效率並不能籠統地說成是 O(1)。它跟雜湊函式、裝載因子、雜湊衝突等都有關係。
- 如果雜湊函式設計得不好,或者裝載因子過高,都可能導致雜湊衝突發生的概率升高,查詢效率下降。
- 在極端情況下,有些惡意的攻擊者,有可能通過精心構造的資料,使得所有的資料經過雜湊函式之後,都雜湊到同一個槽裡。
- 如果此時使用的是基於連結串列的衝突解決方法,那這個時候,雜湊表就會退化為連結串列,查詢的時間複雜度就從 O(1) 急劇退化為 O(n)。
- 如果雜湊表中有10萬個資料,退化後的雜湊表查詢的效率就下降了10萬倍。更直接點說,如果之前執行100次查詢只需要0.1秒,那現在就需要1萬秒。
- 這樣就有可能因為查詢操作消耗大量CPU或者執行緒資源,導致系統無法響應其他請求,從而達到拒絕服務攻擊(DoS)的目的。這也就是雜湊表碰撞攻擊的基本原理。
- 所以生產環境中雜湊函式的設計至關重要。
一、設計雜湊函式
- 雜湊函式設計的好壞,決定了雜湊表衝突的概率大小,也直接決定了雜湊表的效能。
- 首先,雜湊函式的設計不能太複雜。過於複雜的雜湊函式,勢必會消耗很多計算時間,也就間接的影響到雜湊表的效能。
- 其次,雜湊函式生成的值要儘可能隨機並且均勻分佈,這樣才能避免或者最小化雜湊衝突,而且即便出現衝突,雜湊到每個槽裡的資料也會比較平均,不會出現某個槽內資料特別多的情況。
- 實際工作中,還需要綜合考慮各種因素。這些因素有關鍵字的長度、特點、分佈、還有雜湊表的大小等。
- 舉個例子:
- 上一節的學生運動會的例子中通過分析參賽編號的特徵,把編號中的後兩位作為雜湊值。
- 還可以用類似的雜湊函式處理手機號碼,因為手機號碼前幾位重複的可能性很大,但是後面幾位就比較隨機,可以取手機號的後四位作為雜湊值。這種雜湊函式的設計方法,一般叫作“資料分析法”。
- Word 拼寫檢查功能中。裡面的雜湊函式,可以這樣設計:將單詞中每個字母的ASCll碼值“進位”相加,然後再跟雜湊表的大小求餘、取模,作為雜湊值。
- 比如,英文單詞nice,轉化出來的雜湊值就是下面這樣:hash("nice")=(("n" - "a") * 262626 + ("i" - "a")2626 + ("c" - "a")*26+ ("e"-"a")) / 78978。
- 雜湊函式的設計方法有很多,比如直接定址法、平方取中法、摺疊法、隨機數法等。
二、裝載因子
-
裝載因子越大,說明雜湊表中的元素越多,空閒位置越少,雜湊衝突的概率就越大。
-
不僅插入資料的過程要多次定址或者拉很長的鏈,查詢的過程也會因此變得很慢。
-
對於沒有頻繁插入和刪除的靜態資料集合來說,很容易根據資料的特點、分佈等,設計出完美的、極少衝突的雜湊函式,因為畢竟之前資料都是已知的。
-
對於動態雜湊表來說,資料集合是頻繁變動的,事先無法預估將要加入的資料個數,所以也無法事先申請一個足夠大的雜湊表。
-
隨著資料慢慢加入,裝載因子就會慢慢變大。當裝載因子大到一定程度之後,雜湊衝突就會變得不可接受。
-
這個時候,進行動態擴容,重新申請一個更大的雜湊表,將資料搬移到這個新雜湊表中。
-
假設每次擴容我們都申請一個原來雜湊表大小兩倍的空間。
-
如果原來雜湊表的裝載因子是 0.8,那經過擴容之後,新雜湊表的裝載因子就下降為原來的一半,變成了0.4。
-
針對陣列的擴容,資料搬移操作比較簡單。但是,針對雜湊表的擴容,資料搬移操作要複雜很多。因為雜湊表的大小變了,資料的儲存位置也變了,所以需要通過雜湊函式重新計算每個資料的儲存位置。
-
如下圖所示在原來的雜湊表中,21這個元素原來儲存在下標為0的位置,搬移到新的雜湊表中,儲存在下標為7的位置。
-
對於支援動態擴容的雜湊表,插入一個資料,最好情況下,不需要擴容,最好時間複雜度是 O(1)。
-
最壞情況下,雜湊表裝載因子過高,啟動擴容,需要重新申請記憶體空間,重新計算雜湊位置,並且搬移資料,所以時間複雜度是O(n)。
-
均攤情況下,時間複雜度接近最好情況,就是O(1)。
-
實際上,對於動態雜湊表,隨著資料的刪除,雜湊表中的資料會越來越少,空閒空間會越來越多。
-
如果對空間消耗非常敏感,可以在裝載因子小於某個值之後,啟動動態縮容。
-
當然,如果更加在意執行效率,能夠容忍多消耗一點記憶體空間,那就可以不用費勁來縮容了。
-
當雜湊表的裝載因子超過某個閾值時,就需要進行擴容。所以裝載因子閾值需要選擇得當。如果太大,會導致衝突過多;如果太小,會導致記憶體浪費嚴重。
-
裝載因子閾值的設定要權衡時間、空間複雜度。
-
如果記憶體空間不緊張,對執行效率要求很高,可以降低負載因子的閾值;相反,如果記憶體空間緊張,對執行效率要求又不高,可以增加負載因子的值,甚至可以大於1。
三、如何避免低效地擴容?
-
大部分情況下,動態擴容的雜湊表插入一個資料都很快,但是在特殊情況下,當裝載因子已經到達閾值,需要先進行擴容,再插入資料。
-
這個時候,插入資料就會變得很慢,甚至會無法接受。
-
舉一個極端的例子,如果雜湊表當前大小為1GB,要想擴容為原來的兩倍大小,那就需要對1GB的資料重新計算雜湊值,並且從原來的雜湊表搬移到新的雜湊表,這個操作很耗時。
-
如果業務程式碼直接服務於使用者,儘管大部分情況下,插入一個資料的操作都很快,但是,極個別非常慢的插入操作,也會讓使用者崩潰。這個時候,“一次性”擴容的機制就不合適了。
-
為了解決一次性擴容耗時過多的情況,可以將擴容操作穿插在插入操作的過程中,分批完成。
-
當裝載因子觸達閾值之後,只申請新空間,但並不將老的資料搬移到新雜湊表中。
-
當有新資料要插入時,將新資料插入新雜湊表中,並且從老的雜湊表中拿出一個資料放入到新雜湊表。每次插入一個資料到雜湊表,都重複上面的過程。
-
經過多次插入操作之後,老的雜湊表中的資料就一點一點全部搬移到新雜湊表中了。這樣沒有了集中的一次性資料搬移,插入操作就都變得很快了。
-
這期間對於查詢操作,為了相容了新、老雜湊表中的資料,先從新雜湊表中查詢,如果沒有找到,再去老的雜湊表中查詢。
-
通過這樣均攤的方法,將一次性擴容的代價,均攤到多次插入操作中,就避免了一次性擴容耗時過多的情況。
-
這種實現方式,任何情況下,插入一個資料的時間複雜度都是O(1)。
四、如何選擇衝突解決方法?
兩種主要的雜湊衝突的解決辦法,開放定址法和連結串列法。
這兩種衝突解決辦法在實際的軟體開發中都非常常用。比如,Java中LinkedHashMap 就採用了連結串列法解決衝突,ThreadLocalMap 是通過線性探測的開放定址法來解決衝突。
4.1、開放定址法
優點:
- 開放定址法不像連結串列法,需要拉很多連結串列。雜湊表中的資料都儲存在陣列中,可以有效地利用 CPU 快取加快查詢速度。
- 而且,這種方法實現的雜湊表,序列化起來比較簡單。連結串列法包含指標,序列化起來就沒那麼容易。
缺點:
- 刪除資料的時候比較麻煩,需要特殊標記已經刪除掉的資料。
- 而且,在開放定址法中,所有的資料都儲存在一個陣列中,比起連結串列法來說,衝突的代價更高。
- 所以,使用開放定址法解決衝突的雜湊表,裝載因子的上限不能太大。這也導致這種方法比連結串列法更浪費記憶體空間。
總結一下,當資料量比較小、裝載因子小的時候,適合採用開放定址法。這也是 Java 中的 ThreadLocalMap 使用開放定址法解決雜湊衝突的原因。
4.2、連結串列法
- 首先,連結串列法對記憶體的利用率比開放定址法要高。因為連結串列結點可以在需要的時候再建立,並不需要像開放定址法那樣事先申請好。
- 實際上,這一點也是連結串列優於陣列的地方。
- 連結串列法比起開放定址法,對大裝載因子的容忍度更高。
- 開放定址法只能適用裝載因子小於 1 的情況。接近 1 時,就可能會有大量的雜湊衝突,導致大量的探測、再雜湊等,效能會下降很多。
- 但是對於連結串列法來說,只要雜湊函式的值隨機均勻,即便裝載因子變成 10,也就是連結串列的長度變長了而已,雖然查詢效率有所下降,但是比起順序查詢還是快很多。
- 連結串列因為要儲存指標,所以對於比較小的物件的儲存,是比較消耗記憶體的,還有可能會讓記憶體的消耗翻倍。
- 而且,因為連結串列中的結點是零散分佈在記憶體中的,不是連續的,所以對CPU快取是不友好的,這方面對於執行效率也有一定的影響。
- 當然,如果儲存的是大物件,也就是說要儲存的物件的大小遠遠大於一個指標的大小(4個位元組或者8個位元組),那連結串列中指標的記憶體消耗在大物件面前就可以忽略了。
- 實際上,對連結串列法稍加改造,可以實現一個更加高效的雜湊表。那就是,將連結串列法中的連結串列改造為其他高效的動態資料結構,比如跳錶、紅黑樹。
- 這樣,即便出現雜湊衝突,極端情況下,所有的資料都雜湊到同一個桶內,那最終退化成的雜湊表的查詢時間也只不過是 O(logn)。這樣也就有效避免了雜湊碰撞攻擊。
總結一下,基於連結串列的雜湊衝突處理方法比較適合儲存大物件、大資料量的雜湊表,而且,比起開放定址法,它更加靈活,支援更多的優化策略,比如用紅黑樹代替連結串列。
五、實際中雜湊表分析
Java 中的 HashMap 是一個經常用到的雜湊表,來具體看下,這些技術是怎麼應用的。
5.1、初始大小
- HashMap 預設的初始大小是16。
- 當然這個預設值是可以設定的,如果事先知道大概的資料量有多大,可以通過修改預設初始大小,減少動態擴容的次數,這樣會大大提高 HashMap 的效能。
5.2、裝載因子和動態擴容
- 最大裝載因子預設是 0.75,當 HashMap 中元素個數超過 0.75*capacity(capacity表示雜湊表的容量)的時候,就會啟動擴容。
- 每次擴容都會擴容為原來的兩倍大小。
5.3、雜湊衝突解決方法
- HashMap 底層採用連結串列法來解決衝突。
- 即使負載因子和雜湊函式設計得再合理,也免不了會出現拉鍊過長的情況,一旦出現拉鍊過長,則會嚴重影響 HashMap 的效能。
- 於是,在JDK1.8版本中,為了對 HashMap 做進一步優化,引入了紅黑樹。而當連結串列長度太長(預設超過8)時,連結串列就轉換為紅黑樹。
- 可以利用紅黑樹快速增刪改查的特點,提高 HashMap 的效能。
- 當紅黑樹結點個數少於8個的時候,又會將紅黑樹轉化為連結串列。
- 因為在資料量較小的情況下,紅黑樹要維護平衡,比起連結串列來,效能上的優勢並不明顯。
5.4、雜湊函式
- 雜湊函式的設計並不複雜,追求的是簡單高效、分佈均勻。
int hash(Object key) {
int h = key.hashCode();
return (h ^ (h >>> 16)) & (capitity -1); //capicity 表示雜湊表的大小
}
// 其中, hashCode() 返回的是 Java 物件的 hash code。比如 String 型別的物件的 hashCode() 就是下面這樣:
public int hashCode() {
int var1 = this.hash;
if(var1 == 0 && this.value.length > 0) {
char[] var2 = this.value;
for(int var3 = 0; var3 < this.value.length; ++var3) {
var1 = 31 * var1 + var2[var3];
}
this.hash = var1;
}
return var1;
}