12、散列表(二)

  • 散列表的查詢效率並不能籠統地說成是 O(1)。它跟散列函數、裝載因子、散列衝突等都有關係。
  • 若是散列函數設計得很差,或者裝載因子太高,均可能致使散列衝突發生的機率升高,查詢效率降低。
  • 在極端狀況下,有些惡意的攻擊者,有可能經過精心構造的數據,使得全部的數據通過散列函數以後,都散列到同一個槽裏。
  • 若是此時使用的是基於鏈表的衝突解決方法,那這個時候,散列表就會退化爲鏈表,查詢的時間複雜度就從 O(1) 急劇退化爲 O(n)。
  • 若是散列表中有10萬個數據,退化後的散列表查詢的效率就降低了10萬倍。更直接點說,若是以前運行100次查詢只須要0.1秒,那如今就須要1萬秒。
  • 這樣就有可能由於查詢操做消耗大量CPU或者線程資源,致使系統沒法響應其餘請求,從而達到拒絕服務攻擊(DoS)的目的。這也就是散列表碰撞攻擊的基本原理。
  • 因此生產環境中散列函數的設計相當重要。

1、設計散列函數

  • 散列函數設計的好壞,決定了散列表衝突的機率大小,也直接決定了散列表的性能。
  • 首先,散列函數的設計不能太複雜。過於複雜的散列函數,勢必會消耗不少計算時間,也就間接的影響到散列表的性能。
  • 其次,散列函數生成的值要儘量隨機而且均勻分佈,這樣才能避免或者最小化散列衝突,並且即使出現衝突,散列到每一個槽裏的數據也會比較平均,不會出現某個槽內數據特別多的狀況。
  • 實際工做中,還須要綜合考慮各類因素。這些因素有關鍵字的長度、特色、分佈、還有散列表的大小等。
  • 舉個例子:
    • 上一節的學生運動會的例子中經過分析參賽編號的特徵,把編號中的後兩位做爲散列值。
    • 還能夠用相似的散列函數處理手機號碼,由於手機號碼前幾位重複的可能性很大,可是後面幾位就比較隨機,能夠取手機號的後四位做爲散列值。這種散列函數的設計方法,通常叫做「數據分析法」。
    • Word 拼寫檢查功能中。裏面的散列函數,能夠這樣設計:將單詞中每一個字母的ASCll碼值「進位」相加,而後再跟散列表的大小求餘、取模,做爲散列值。
    • 好比,英文單詞nice,轉化出來的散列值就是下面這樣:hash("nice")=(("n" - "a") * 262626 + ("i" - "a")2626 + ("c" - "a")*26+ ("e"-"a")) / 78978。
  • 散列函數的設計方法有不少,好比直接尋址法、平方取中法、摺疊法、隨機數法等。

2、裝載因子

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

  • 不只插入數據的過程要屢次尋址或者拉很長的鏈,查找的過程也會所以變得很慢。api

  • 對於沒有頻繁插入和刪除的靜態數據集合來講,很容易根據數據的特色、分佈等,設計出完美的、極少衝突的散列函數,由於畢竟以前數據都是已知的。數組

  • 對於動態散列表來講,數據集合是頻繁變更的,事先沒法預估將要加入的數據個數,因此也沒法事先申請一個足夠大的散列表。緩存

  • 隨着數據慢慢加入,裝載因子就會慢慢變大。當裝載因子大到必定程度以後,散列衝突就會變得不可接受。數據結構

  • 這個時候,進行動態擴容,從新申請一個更大的散列表,將數據搬移到這個新散列表中。函數

  • 假設每次擴容咱們都申請一個原來散列表大小兩倍的空間。性能

  • 若是原來散列表的裝載因子是 0.8,那通過擴容以後,新散列表的裝載因子就降低爲原來的一半,變成了0.4。大數據

  • 針對數組的擴容,數據搬移操做比較簡單。可是,針對散列表的擴容,數據搬移操做要複雜不少。由於散列表的大小變了,數據的存儲位置也變了,因此須要經過散列函數從新計算每一個數據的存儲位置優化

  • 以下圖所示在原來的散列表中,21這個元素原來存儲在下標爲0的位置,搬移到新的散列表中,存儲在下標爲7的位置。this

  • 對於支持動態擴容的散列表,插入一個數據,最好狀況下,不須要擴容,最好時間複雜度是 O(1)。

  • 最壞狀況下,散列表裝載因子太高,啓動擴容,須要從新申請內存空間,從新計算哈希位置,而且搬移數據,因此時間複雜度是O(n)。

  • 均攤狀況下,時間複雜度接近最好狀況,就是O(1)。

  • 實際上,對於動態散列表,隨着數據的刪除,散列表中的數據會愈來愈少,空閒空間會愈來愈多。

  • 若是對空間消耗很是敏感,能夠在裝載因子小於某個值以後,啓動動態縮容。

  • 固然,若是更加在乎執行效率,可以容忍多消耗一點內存空間,那就能夠不用費勁來縮容了。

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

  • 裝載因子閾值的設置要權衡時間、空間複雜度。

  • 若是內存空間不緊張,對執行效率要求很高,能夠下降負載因子的閾值;相反,若是內存空間緊張,對執行效率要求又不高,能夠增長負載因子的值,甚至能夠大於1

3、如何避免低效地擴容?

  • 大部分狀況下,動態擴容的散列表插入一個數據都很快,可是在特殊狀況下,當裝載因子已經到達閾值,須要先進行擴容,再插入數據。

  • 這個時候,插入數據就會變得很慢,甚至會沒法接受。

  • 舉一個極端的例子,若是散列表當前大小爲1GB,要想擴容爲原來的兩倍大小,那就須要對1GB的數據從新計算哈希值,而且從原來的散列表搬移到新的散列表,這個操做很耗時。

  • 若是業務代碼直接服務於用戶,儘管大部分狀況下,插入一個數據的操做都很快,可是,極個別很是慢的插入操做,也會讓用戶崩潰。這個時候,「一次性」擴容的機制就不合適了。

  • 爲了解決一次性擴容耗時過多的狀況,能夠將擴容操做穿插在插入操做的過程當中,分批完成。

  • 當裝載因子觸達閾值以後,只申請新空間,但並不將老的數據搬移到新散列表中。

  • 當有新數據要插入時,將新數據插入新散列表中,而且從老的散列表中拿出一個數據放入到新散列表。每次插入一個數據到散列表,都重複上面的過程。

  • 通過屢次插入操做以後,老的散列表中的數據就一點一點所有搬移到新散列表中了。這樣沒有了集中的一次性數據搬移,插入操做就都變得很快了。

  • 這期間對於查詢操做,爲了兼容了新、老散列表中的數據,先重新散列表中查找,若是沒有找到,再去老的散列表中查找

  • 經過這樣均攤的方法,將一次性擴容的代價,均攤到屢次插入操做中,就避免了一次性擴容耗時過多的狀況。

  • 這種實現方式,任何狀況下,插入一個數據的時間複雜度都是O(1)。

4、如何選擇衝突解決方法?

兩種主要的散列衝突的解決辦法,開放尋址法和鏈表法。
這兩種衝突解決辦法在實際的軟件開發中都很是經常使用。好比,Java中LinkedHashMap 就採用了鏈表法解決衝突,ThreadLocalMap 是經過線性探測的開放尋址法來解決衝突。

4.一、開放尋址法

優勢

  • 開放尋址法不像鏈表法,須要拉不少鏈表。散列表中的數據都存儲在數組中,能夠有效地利用 CPU 緩存加快查詢速度。
  • 並且,這種方法實現的散列表,序列化起來比較簡單。鏈表法包含指針,序列化起來就沒那麼容易。

缺點

  • 刪除數據的時候比較麻煩,須要特殊標記已經刪除掉的數據。
  • 並且,在開放尋址法中,全部的數據都存儲在一個數組中,比起鏈表法來講,衝突的代價更高。
  • 因此,使用開放尋址法解決衝突的散列表,裝載因子的上限不能太大。這也致使這種方法比鏈表法更浪費內存空間。

總結一下,當數據量比較小、裝載因子小的時候,適合採用開放尋址法。這也是 Java 中的 ThreadLocalMap 使用開放尋址法解決散列衝突的緣由

4.二、鏈表法

  • 首先,鏈表法對內存的利用率比開放尋址法要高。由於鏈表結點能夠在須要的時候再建立,並不須要像開放尋址法那樣事先申請好。
  • 實際上,這一點也是鏈表優於數組的地方。
  • 鏈表法比起開放尋址法,對大裝載因子的容忍度更高。
  • 開放尋址法只能適用裝載因子小於 1 的狀況。接近 1 時,就可能會有大量的散列衝突,致使大量的探測、再散列等,性能會降低不少。
  • 可是對於鏈表法來講,只要散列函數的值隨機均勻,即使裝載因子變成 10,也就是鏈表的長度變長了而已,雖然查找效率有所降低,可是比起順序查找仍是快不少。
  • 鏈表由於要存儲指針,因此對於比較小的對象的存儲,是比較消耗內存的,還有可能會讓內存的消耗翻倍。
  • 並且,由於鏈表中的結點是零散分佈在內存中的,不是連續的,因此對CPU緩存是不友好的,這方面對於執行效率也有必定的影響。
  • 固然,若是存儲的是大對象,也就是說要存儲的對象的大小遠遠大於一個指針的大小(4個字節或者8個字節),那鏈表中指針的內存消耗在大對象面前就能夠忽略了。
  • 實際上,對鏈表法稍加改造,能夠實現一個更加高效的散列表。那就是,將鏈表法中的鏈表改造爲其餘高效的動態數據結構,好比跳錶、紅黑樹。
  • 這樣,即使出現散列衝突,極端狀況下,全部的數據都散列到同一個桶內,那最終退化成的散列表的查找時間也只不過是 O(logn)。這樣也就有效避免了散列碰撞攻擊。

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

5、實際中散列表分析

Java 中的 HashMap 是一個常常用到的散列表,來具體看下,這些技術是怎麼應用的。

5.一、初始大小

  • HashMap 默認的初始大小是16。
  • 固然這個默認值是能夠設置的,若是事先知道大概的數據量有多大,能夠經過修改默認初始大小,減小動態擴容的次數,這樣會大大提升 HashMap 的性能。

5.二、裝載因子和動態擴容

  • 最大裝載因子默認是 0.75,當 HashMap 中元素個數超過 0.75*capacity(capacity表示散列表的容量)的時候,就會啓動擴容。
  • 每次擴容都會擴容爲原來的兩倍大小。

5.三、散列衝突解決方法

  • HashMap 底層採用鏈表法來解決衝突。
  • 即便負載因子和散列函數設計得再合理,也免不了會出現拉鍊過長的狀況,一旦出現拉鍊過長,則會嚴重影響 HashMap 的性能。
  • 因而,在JDK1.8版本中,爲了對 HashMap 作進一步優化,引入了紅黑樹。而當鏈表長度太長(默認超過8)時,鏈表就轉換爲紅黑樹。
  • 能夠利用紅黑樹快速增刪改查的特色,提升 HashMap 的性能。
  • 當紅黑樹結點個數少於8個的時候,又會將紅黑樹轉化爲鏈表。
  • 由於在數據量較小的狀況下,紅黑樹要維護平衡,比起鏈表來,性能上的優點並不明顯。

5.四、散列函數

  • 散列函數的設計並不複雜,追求的是簡單高效、分佈均勻。
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;
}
相關文章
相關標籤/搜索