高效的hash表實現----legend050709

(1)背景

(2)hash函數的好壞評價標準

(3)裝載因子(元素個數)過大怎麼辦?

(4)解決hash衝突的方法

(4.1)拉鍊法

(4.2)開放地址法

(5)工業級散列表範例

-------

(1)背景

散列表的查詢效率並不能籠統地說成是 O(1)。它跟散列函數、裝載因子(元素個數)、散列衝突等都有關係。如果散列函數設計得不好,或者裝載因子過高,都可能導致散列衝突發生的概率升高,查詢效率下降。

在極端情況下,有些惡意的攻擊者,還有可能通過精心構造的數據,使得所有的數據經過散列函數之後,都散列到同一個槽裏。如果我們使用的是基於鏈表的衝突解決方法,那這個時候,散列表就會退化爲鏈表,查詢的時間複雜度就從 O(1) 急劇退化爲 O(n)。

如果散列表中有 10 萬個數據,退化後的散列表查詢的效率就下降了 10 萬倍。更直觀點說,如果之前運行 100 次查詢只需要 0.1 秒,那現在就需要 1 萬秒。這樣就有可能因爲查詢操作消耗大量 CPU 或者線程資源,導致系統無法響應其他請求,從而達到拒絕服務攻擊(DoS)的目的。這也就是散列表碰撞攻擊的基本原理。

如何設計一個可以應對各種異常情況的工業級散列表,來避免在散列衝突的情況下,散列表性能的急劇下降,並且能抵抗散列碰撞攻擊?

(2)思路

要思考何爲一個工業級的散列表?

(2.1)工業級的散列表的特性

1>支持快速的查詢、插入、刪除操作;

2>內存佔用合理,不能浪費過多的內存空間;

3>性能穩定,極端情況下,散列表的性能也不會退化到無法接受的情況。

(2.2)設計思路:

1>設計一個合適的散列函數;

2>定義裝載因子閾值,並且設計動態擴容策略;

3>選擇合適的散列衝突解決方法。

 

(2.3)hash函數的好壞評價標準

1)計算速度快
散列函數的設計也不能太複雜,太複雜就會太耗時間,也會影響散列表的性能。
2)碰撞率低
具有較高的平衡性與低碰撞率;
3)計算結果隨機且均勻分佈

儘可能讓散列後的值隨機且均勻分佈,這樣會盡可能地減少散列衝突,即便衝突之後,分配到每個槽內的數據也比較均勻

 

(2.4)針對上面的散列表碰撞攻擊的問題,現在想的解決方法爲:

A> 儘量減少hash衝突;

即 hash值相同的情況儘量少發生;針對這種情況的解決方法就是動態擴容;

B>hash衝突時儘量快速的查找到目標;

針對該問題的解決方法就是拉鍊法時,使用紅黑樹或者跳錶取代鏈表;

 

(3)裝載因子(元素個數)過大怎麼辦?

(3.1)背景

裝載因子越大,說明散列表中的元素越多,空閒位置越少,散列衝突的概率就越大。

不僅插入數據的過程要多次尋址或者拉很長的鏈,查找的過程也會因此變得很慢。

對於動態散列表來說,數據集合是頻繁變動的,我們事先無法預估將要加入的數據個數,所以我們也無法事先申請一個足夠大的散列表。隨着數據慢慢加入,裝載因子就會慢慢變大。當裝載因子大到一定程度之後,散列衝突就會變得不可接受。

(3.2)解決方法

(3.2.1)動態擴容

當裝載因子過大,超過某個閾值時,我們可以進行動態擴容;

重新申請一個更大的散列表,將數據搬移到這個新散列表中(即一次性擴容,爲低效擴容)。因爲散列表的大小變了,數據的存儲位置也變了,所以我們需要通過散列函數重新計算每個數據的存儲位置。

(3.2.1.1)如何避免低效地擴容?

1》背景

大部分情況下,動態擴容的散列表插入一個數據都很快,但是在特殊情況下,當裝載因子已經到達閾值,需要先進行擴容,再插入數據,即一次性擴容。這個時候,插入數據就會變得很慢;

2》解決方法

 

分批擴容:

即將擴容操作穿插在插入操作的過程中,分批完成;

當有新數據要插入時,我們將新數據插入新散列表中,並且從老的散列表中拿出一個數據放入到新散列表。每次插入一個數據到散列表,我們都重複上面的過程。經過多次插入操作之後,老的散列表中的數據就一點一點全部搬移到新散列表中了。這樣沒有了集中的一次性數據搬移,插入操作就都變得很快了。

 

分批擴容的查詢:

對於查詢操作,爲了兼容了新、老散列表中的數據,我們先從新散列表中查找,如果沒有找到,再去老的散列表中查找。

通過這樣均攤的方法,將一次性擴容的代價,均攤到多次插入操作中,就避免了一次性擴容耗時過多的情況。這種實現方式,任何情況下,插入一個數據的時間複雜度都是 O(1)。

 

(3.2.2)動態縮容

對於動態散列表,隨着數據的刪除,散列表中的數據會越來越少,空閒空間會越來越多。如果我們對空間消耗非常敏感,我們可以在裝載因子小於某個值之後,啓動動態縮容。

當然,如果我們更加在意執行效率,能夠容忍多消耗一點內存空間,那就可以不用縮容了。

 

(3.2.3)裝載因子閾值的設置

當散列表的裝載因子超過某個閾值時,就需要進行擴容。裝載因子閾值需要選擇得當。如果太大,會導致衝突過多;如果太小,會導致內存浪費嚴重。

裝載因子閾值的設置要權衡時間、空間複雜度。如果內存空間不緊張,對執行效率要求很高,可以降低負載因子的閾值;相反,如果內存空間緊張,對執行效率要求又不高,可以增加負載因子的值。

(4)解決hash衝突的方法

(4.1)開放地址法(open address)

(4.1.3)定義

如果出現了散列衝突、我們就重新探測一個空閒位置、將其插入、那如何重新探測新的位置呢?

當某個數據經過散列函數散列滯後、存儲位置已經被佔用了;我們就從當前位置開始、依次往後查找,看是否有空閒位置,直到找到爲止;

隨機數法:就是有衝突了,按照隨機數列表來找下一個位置,直到找到了下一個空閒位置爲止;

(4.1.2)節點的查找

根據要查找的元素的key進行hash後得到hash表中的元素和要查找的元素的value是否相等;

1)相等則找到;

2)不相等且下一個位置非空時,一直往下找;

     直到找到 或者下一個位置爲空(存在delete標記也需要繼續往下找),爲空則表示不存在;

 

(4.1.3)節點的刪除

不能單純地把刪除的元素設置爲空,而是設置一個標記;

因爲如果把刪除的位置設置爲空,在找到一個空閒位置,我們就可以認定散列表中不存在這個數據。但是,如果這個空閒位置是我們後來刪除的,就會導致hash值相同但是處於刪除節點的後續節點的查找都失效了。

可以將刪除的元素、特殊標記爲deleted,當線性探測的時候、遇到標記爲deleted的空間、並不是停下來、而是繼續往下探測;

 

(4.1.4)優點

1> 每個hash值最多隻對應一個元素;

2> 數據都存儲在數組中,可以有效地利用 CPU 緩存加快查詢速度;

(4.1.5)缺點

1> 刪除時,需要特殊標記已經刪除;

2> 比起鏈表法來說, 衝突的代價更高;

所以,使用開放尋址法解決衝突的散列表,裝載因子的上限不能太大。這也導致這種方法比鏈表法更浪費內存空間;

(4.1.6)應用

當數據量比較小、裝載因子小的時候,適合採用開放尋址法;

 

(4.2)拉鍊法

(4.2.1)優點

1> 鏈表法對內存的利用率比開放尋址法要高;

2> 對大裝載因子(元素個數多)的容忍度更高;

(4.2.2)缺點

1> 存儲指針,對小存儲對象而言,佔用更多內存;

鏈表因爲要存儲指針,所以對於比較小的對象的存儲,是比較消耗內存的,還有可能會讓內存的消耗翻倍;

如果我們存儲的是大對象,也就是說要存儲的對象的大小遠遠大於一個指針的大小(4 個字節或者 8 個字節),那鏈表中指針的內存消耗在大對象面前就可以忽略了。

2> 鏈表對cpu緩存不友好;

鏈表中的結點是零散分佈在內存中的,不是連續的,所以對 CPU 緩存是不友好的; 對於執行效率也有一定的影響;

(4.2.3)改進

將鏈表法中的鏈表改造爲其他高效的動態數據結構,比如跳錶、紅黑樹。這樣,即便出現散列衝突,極端情況下,所有的數據都散列到同一個桶內,那最終退化成的散列表的查找時間也只不過是 O(logn)。

這樣也就有效減輕了前面講到的散列碰撞攻擊。

(4.2.4)應用

基於鏈表的散列衝突處理方法比較適合存儲大對象、大數據量的散列表,而且,比起開放尋址法,它更加靈活,支持更多的優化策略,比如用紅黑樹代替鏈表。

 

(5)工業級散列表範例

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)散列函數

散列函數的設計並不複雜,追求的是簡單高效、低碰撞率、分佈均勻;

其中,hashCode() 返回的是 Java 對象的 hash code。

比如String類型的對象的hashCode()就是下面這樣:

1

2

3

4

5

6

7

8

9

10

11

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;

}

 

(6)散列表的應用

問題:

旦我們在Word裏輸入一個個錯誤的英文單詞,它就會用標紅的放式提示“拼寫錯誤”。

Word的這個單詞拼寫檢查功能,雖然很小但卻非常實用。你有沒有想過,這個功能是如何實現的呢?

 

分析:

常用的英語單詞有20萬個左右,假設單詞的平均速度是10個字母,平均一個單詞佔用10個字節的內存空間,那20萬英個單詞大約佔2MB的存儲空間,就算放大10倍也就是20MB。

對於現在的計算機來說,這個現在完全可以放在內存裏面。所以我們可以用散列表來存儲整個英文單詞詞典。當用戶輸入某個英文單詞時,我們拿用戶輸入的單詞去散列表中查找。如果查到,則說明拼寫正確;如果沒有查到,則說明拼寫可能有誤,給予提示。

藉助散列表這種數據結構,我們就可以輕鬆實現快速判斷是否存在拼寫錯誤。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章