數據結構-05 |散列表-②| 工業級水平的散列表

 

1. 散列碰撞攻擊

散列表的查詢效率並不能籠統地說成是 O(1)。它跟散列函數、裝載因子、散列衝突等都有關係。如果散列函數設計得不好,或者裝載因子過高,都可能導致散列衝突發生的概率升高,查詢效率下降。在極端情況下,有些惡意的攻擊者,還有可能通過精心構造的數據,使得所有的數據經過散列函數之後,都散列到同一個槽裏。如果我們使用的是基於鏈表的衝突解決方法,那這個時候,散列表就會退化爲鏈表,查詢的時間複雜度就從 O(1) 急劇退化爲 O(n)。

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

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

2. 如何設計散列函數?

散列函數設計的好壞,決定了散列表衝突的概率大小,也直接決定了散列表的性能。那什麼纔是好的散列函數呢?

  • 首先,散列函數的設計不能太複雜。過於複雜的散列函數,勢必會消耗很多計算時間,也就間接的影響到散列表的性能。
  • 其次,散列函數生成的值要儘可能隨機並且均勻分佈,這樣才能避免或者最小化散列衝突,而且即便出現衝突,散列到每個槽裏的數據也會比較平均,不會出現某個槽內數據特別多的情況。

實際工作中,我們還需要綜合考慮各種因素。這些因素有關鍵字的長度、特點、分佈、還有散列表的大小等。散列函數各式各樣比如:

第一個例子就是學生運動會的例子,我們通過分析參賽編號的特徵,把編號中的後兩位作爲散列值。我們還可以用類似的散列函數處理手機號碼,因爲手機號碼前幾位重複的可能性很大,但是後面幾位就比較隨機,我們可以取手機號的後四位作爲散列值。這種散列函數的設計方法,一般叫作“數據分析法”

第二個例子如何實現 Word 拼寫檢查功能。這裏面的散列函數,我們就可以這樣設計:將單詞中每個字母的ASCll 碼值“進位”相加,然後再跟散列表的大小求餘、取模,作爲散列值。比如,英文單詞 nice,我們轉化出來的散列值就是下面這樣:

hash("nice")=(("n" - "a") * 26*26*26 + ("i" - "a")*26*26 + ("c" - "a")*26+ ("e"-"a")) / 78978

實際上,散列函數的設計方法還有很多,比如直接尋址法、平方取中法、摺疊法、隨機數法等,瞭解即可。

2.1 裝載因子過大了怎麼辦?

散列表的裝載因子越大,說明散列表中的元素越多,空閒位置越少,散列衝突的概率就越大。不僅插入數據的過程要多次尋址或者拉很長的鏈,查找的過程也會因此變得很慢。

對於沒有頻繁插入和刪除的靜態數據集合來說,我們很容易根據數據的特點、分佈等,設計出完美的、極少衝突的散列函數,因爲畢竟之前數據都是已知的。

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

 “動態擴容”嗎?  如何做數組、棧、隊列的動態擴容的。

針對散列表,當裝載因子過大時,我們也可以進行動態擴容,重新申請一個更大的散列表,將數據搬移到這個新散列表中。假設每次擴容我們都申請一個原來散列表大小兩倍的空間。如果原來散列表的裝載因子是 0.8,那經過擴容之後,新散列表的裝載因子就下降爲原來的一半,變成了 0.4。

針對數組的擴容,數據搬移操作比較簡單。但是,針對散列表的擴容,數據搬移操作要複雜很多。因爲散列表的大小變了,數據的存儲位置也變了,所以我們需要通過散列函數重新計算每個數據的存儲位置。  下圖在原來的散列表中,21 這個元素原來存儲在下標爲 0 的位置,搬移到新的散列表中,存儲在下標爲 7 的位置。

對於支持動態擴容的散列表,插入操作的時間複雜度是多少呢?

插入一個數據,最好情況下,不需要擴容,最好時間複雜度是 O(1)。最壞情況下,散列表裝載因子過高,啓動擴容,我們需要重新申請內存空間,重新計算哈希位置,並且搬移數據,所以時間複雜度是 O(n)。用攤還分析法,均攤情況下,時間複雜度接近最好情況,就是 O(1)。

實際上,對於動態散列表,隨着數據的刪除,散列表中的數據會越來越少,空閒空間會越來越多。如果我們對空間消耗非常敏感,我們可以在裝載因子小於某個值之後,啓動動態縮容。當然,如果我們更加在意執行效率,能夠容忍多消耗一點內存空間,那就可以不用費勁來縮容了。

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

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

2.2 如何避免低效地擴容?

    大部分情況下,動態擴容的散列表插入一個數據都很快,但是在特殊情況下,當裝載因子已經到達閾值,需要先進行擴容,再插入數據。這時,插入數據就會變得很慢,甚至會無法接受。    比如一個極端的例子,如果散列表當前大小爲 1GB,要想擴容爲原來的兩倍大小,那就需要對 1GB 的數據重新計算哈希值,並且從原來的散列表搬移到新的散列表,聽起來就很耗時,如果我們的業務代碼直接服務於用戶,儘管大部分情況下,插入一個數據的操作都很快,但是,極個別非常慢的插入操作,也會讓用戶崩潰。這個時候,“一次性”擴容的機制就不合適了。

    爲了解決一次性擴容耗時過多的情況,我們可以將擴容操作穿插在插入操作的過程中,分批完成。當裝載因子觸達閾值之後,只申請新空間,但並不將老的數據搬移到新散列表中。 當有新數據要插入時,我們將新數據插入新散列表中,並且從老的散列表中拿出一個數據放入到新散列表。每次插入一個數據到散列表,我們都重複上面的過程。經過多次插入操作之後,老的散列表中的數據就一點一點全部搬移到新散列表中了。這樣沒有了集中的一次性數據搬移,插入操作就都變得很快了。

這期間的查詢操作怎麼來做呢?對於查詢操作,爲了兼容了新、老散列表中的數據,我們先從新散列表中查找,如果沒有找到,再去老的散列表中查找。

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

2.3 如何選擇衝突解決方法?

解決散列衝突的兩種辦法: 開放尋址法和鏈表法。這兩種衝突解決辦法在實際的軟件開發中都非常常用。

比如,Java 中 LinkedHashMap 就採用了鏈表法解決衝突,ThreadLocalMap 是通過線性探測的開放尋址法來解決衝突。這兩種衝突解決方法各有什麼優勢和劣勢,又各自適用哪些場景嗎?

1. 開放尋址法

開放尋址法的優點: 

開放尋址法不像鏈表法,需要拉很多鏈表。散列表中的數據都存儲在數組中,可以有效地利用 CPU 緩存加快查詢速度。而且,這種方法實現的散列表,序列化起來比較簡單。鏈表法包含指針,序列化起來就沒那麼容易。你可不要小看序列化,很多場合都會用到的。

我們再來看下,開放尋址法有哪些缺點。

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

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

2. 鏈表法

首先,鏈表法對內存的利用率比開放尋址法要高。因爲鏈表結點可以在需要的時候再創建,並不需要像開放尋址法那樣事先申請好。這一點也是鏈表優於數組的地方。

鏈表法比起開放尋址法,對大裝載因子的容忍度更高。開放尋址法只能適用裝載因子小於 1 的情況。接近 1 時,就可能會有大量的散列衝突,導致大量的探測、再散列等,性能會下降很多。但是對於鏈表法來說,只要散列函數的值隨機均勻,即便裝載因子變成 10,也就是鏈表的長度變長了而已,雖然查找效率有所下降,但是比起順序查找還是快很多。

鏈表因爲要存儲指針,所以對於比較小的對象的存儲,是比較消耗內存的,還有可能會讓內存的消耗翻倍。而且,因爲鏈表中的結點是零散分佈在內存中的,不是連續的,所以對 CPU 緩存是不友好的,這方面對於執行效率也有一定的影響。

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

實際上,我們對鏈表法稍加改造,可以實現一個更加高效的散列表。那就是,我們將鏈表法中的鏈表改造爲其他高效的動態數據結構,比如跳錶、紅黑樹。這樣,即便出現散列衝突,極端情況下,所有的數據都散列到同一個桶內,那最終退化成的散列表的查找時間也只不過是 O(logn)。這樣也就有效避免了前面講到的散列碰撞攻擊。

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

3. 工業級散列表舉例分析

Java 中的 HashMap 這樣一個工業級的散列表,分析下上邊這些技術是怎麼應用的。

1. 初始大小

HashMap 默認的初始大小是 16,當然這個默認值是可以設置的,如果事先知道大概的數據量有多大,可以通過修改默認初始大小,減少動態擴容的次數,這樣會大大提高 HashMap 的性能。

2. 裝載因子和動態擴容

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

3. 散列衝突解決方法

HashMap 底層採用鏈表法來解決衝突。即使負載因子和散列函數設計得再合理,也免不了會出現拉鍊過長的情況,一旦出現拉鍊過長,則會嚴重影響 HashMap 的性能。

於是,在 JDK1.8 版本中,爲了對 HashMap 做進一步優化,我們引入了紅黑樹。而當鏈表長度太長(默認超過 8)時,鏈表就轉換爲紅黑樹。我們可以利用紅黑樹快速增刪改查的特點,提高 HashMap 的性能。當紅黑樹結點個數少於 8 個的時候,又會將紅黑樹轉化爲鏈表。因爲在數據量較小的情況下,紅黑樹要維護平衡,比起鏈表來,性能上的優勢並不明顯。

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

總結:

如何設計的一個工業級的散列函數?

何爲一個工業級的散列表?工業級的散列表應該具有哪些特性?

結合散列知識,我覺得應該有這樣幾點要求:

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

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

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

如何實現這樣一個散列表呢?從以下三個方面來考慮設計思路:

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

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

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

散列函數、裝載因子、動態擴容策略,還有散列衝突的解決辦法,具體如何選擇,還要結合具體的業務場景、具體的業務數據來具體分析。不過只要我們朝這三個方向努力,就離設計出工業級的散列表不遠了。

小結

如何設計一個工業級的散列表,以及如何應對各種異常情況,防止在極端情況下,散列表的性能退化過於嚴重。分三部分來講解這些內容,分別是:如何設計散列函數,如何根據裝載因子動態擴容,以及如何選擇散列衝突解決方法。

關於散列函數的設計,我們要儘可能讓散列後的值隨機且均勻分佈,這樣會盡可能地減少散列衝突,即便衝突之後,分配到每個槽內的數據也比較均勻。除此之外,散列函數的設計也不能太複雜,太複雜就會太耗時間,也會影響散列表的性能。

關於散列衝突解決方法的選擇,對比了開放尋址法和鏈表法兩種方法的優劣和適應的場景。大部分情況下,鏈表法更加普適。而且,我們還可以通過將鏈表法中的鏈表改造成其他動態查找數據結構,比如紅黑樹,來避免散列表時間複雜度退化成 O(n),抵禦散列碰撞攻擊。但是,對於小規模數據、裝載因子不高的散列表,比較適合用開放尋址法。

對於動態散列表來說,不管我們如何設計散列函數,選擇什麼樣的散列衝突解決方法。隨着數據的不斷增加,散列表總會出現裝載因子過高的情況。這個時候,我們就需要啓動動態擴容。

 

在你熟悉的編程語言中,哪些數據類型底層是基於散列表實現的?散列函數是如何設計的?散列衝突是通過哪種方法解決的?是否支持動態擴容呢?

int hash(Object key) {
    int h = key.hashCode();
    return (h ^ (h >>> 16)) & (capitity -1); //capicity 表示散列表的大小
}

在JDK HashMap源碼中,是分兩步走的:
1. hash值的計算,源碼如下:
static final int hash(Object key) {
        int hash;
        return key == null ? 0 : (hash = key.hashCode()) ^ hash >>> 16;
 }

2. 在插入或查找的時候,計算Key被映射到桶的位置:
int index = hash(key) & (capacity - 1)

----------------------------
JDK HashMap中hash函數的設計,確實很巧妙:

首先hashcode本身是個32位整型值,在系統中,這個值對於不同的對象必須保證唯一(JAVA規範),這也是大家常說的,重寫equals必須重寫hashcode的重要原因。
獲取對象的hashcode以後,先進行移位運算,然後再和自己做異或運算,即:hashcode ^ (hashcode >>> 16),這一步甚是巧妙,是將高16位移到低16位,這樣計算出來的整型值將“具有”高位和低位的性質
最後,用hash表當前的容量減去一,再和剛剛計算出來的整型值做位與運算。進行位與運算,很好理解,是爲了計算出數組中的位置。但這裏有個問題:
爲什麼要用容量減去一?
因爲 A % B = A & (B - 1),所以,(h ^ (h >>> 16)) & (capitity -1) = (h ^ (h >>> 16)) % capitity,可以看出這裏本質上是使用了「除留餘數法」
綜上,可以看出,hashcode的隨機性,加上移位異或算法,得到一個非常隨機的hash值,再通過「除留餘數法」,得到index,整體的設計過程與老師所說的“散列函數”設計原則非常吻合!

 

JDK hashMap源碼,hash表中數組位置的計算分兩步:
1.計算hash值:
 hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
這一步有一種說法,叫它擾動函數,爲什麼要右移16位再與本身異或呢?
1.1 首先hashCode()返回值int最高是32位,如果直接拿hashCode()返回值作爲下標,大概40億的映射空間,只要哈希函數映射得比較均勻鬆散,一般是很難出現碰撞的。
問題是一個40億長度的數組,內存是放不下的。
1.2 所以,用自己的高半區和低半區做異或,混合原始哈希碼的高位和低位,關鍵是以此來加大低位的隨機性。爲後續計算index截取低位,保證低位的隨機性。
1.3 這樣設計保證了對象的hashCode的32位值只要有一位發生改變,整個hash()返回值就會改變,高位的變化會反應到低位裏,保證了hash值的隨機性。

2.在插入或查找的時候,計算Key被映射到桶的位置:
int index = hash(key) & (capacity - 1)
hash()擾動函數計算的值和hash表當前的容量減一,做按位與運算。
這一步,爲什麼要減一,又爲什麼要按位與運算?
因爲A % B = A & (B - 1),當B是2的指數時,等式成立。
本質上是使用了「除留餘數法」,保證了index的位置分佈均勻。

爲什麼HashMap的數組長度必須是2的整次冪?
數組長度是2的整次冪時,(數組長度-1)正好相當於一個**“低位掩碼”**,“與”操作的結果就是散列值的高位全部歸零,只保留低位值,用來做數組下標訪問。

以初始長度16爲例,16-1=15。2進製表示是00000000 00000000 00001111。“與”操作的結果就是截取了最低的四位值。也就相當於取模操作。

 

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