數據結構——散列表(Hash Table)(哈希表)

散列表

散列表英文是hash table,經常被叫做Hash表,或者哈希表。

哈希表其實就是由數組演化而來的,利用的就是數組支持按照下標隨機訪問數據的特性,可以說散列表就是數組的一種擴展。

 

百度文庫對散列表的解釋:

根據關鍵碼值(Key value)而直接進行訪問的數據結構。也就是說,它通過把關鍵碼值映射到表中一個位置來訪問記錄,以加快查找的速度。這個映射函數叫做散列函數,存放記錄的數組叫做散列表。

 

散列表有兩個核心問題:散列函數的設計散列衝突的解決

 

散列思想

舉例:假如我們有 89 名選手參加學校運動會。爲了方便記錄成績,每個選手胸前都會貼上自己的參賽號碼。

參賽號碼用 6 位數字來表示。比如 051167,其中,前兩位 05 表示年級,中間兩位 11 表示班級,最後兩位是選手的編號 1 到 89。

我們該如何存儲選手信息,才能夠支持通過編號來快速查找選手信息呢?

我們可以截取參賽編號的後兩位作爲數組下標,來存取選手信息數據。

當通過參賽編號查詢選手信息的時候,取參賽編號的後兩位,作爲數組下標,來讀取數組中的數據。

這個例子就是經典的散列思想,參賽選手的編號我們叫作鍵(key)或者關鍵字。我們用它來標識一個選手。

我們把參賽編號轉化爲數組下標的映射方法就叫作散列函數(或“Hash 函數”“哈希函數”)。

而散列函數計算得到的值就叫作散列值(或“Hash 值”“哈希值”)。

 

散列函數

顧名思義,它是一個函數,它在散列表中起着至關重要的作用。

我們可以定義它爲 hash(key),其中 key 表示元素的鍵值,hash(key) 的值表示經過散列函數計算得到的散列值。

如剛纔的參賽選手例子,就可以寫成一個散列函數

int hash(String key) {
  // 獲取後兩位字符
  string lastTwoChars = key.subString(length-2);
  // 將後兩位字符轉換爲整數
  int hashValue = String.parseInt(lastTwoChars);
  return hashValue;
}

這只是一個非常簡單的舉例,這個散列函數比較簡單。

 

散列函數設計的基本要求

一、 散列函數計算得到的散列值是一個非負整數;

二、 如果 key1 = key2,那 hash(key1) == hash(key2);

三、 如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)。

 

第一點很好理解,因爲數組的下標是從0開始的。

第二點也好理解,相同的key經過散列函數得到的散列值也應該是相同的。

根據第二點來看,第三點也合情合理。但是實際情況下第三點很難做到。

不滿足第三點的情況,我們叫做散列衝突(哈希衝突),所謂散列衝突就是不同鍵值的元素對應着相同的存儲地址。

在真實的情況下,要想找到一個不同的 key 對應的散列值都不一樣的散列函數,幾乎是不可能的。

即便像業界著名的MD5、SHA、CRC等哈希算法,也無法完全避免這種散列衝突。

而且,因爲數組的存儲空間有限,也會加大散列衝突的概率。

 

如何設計散列函數

 

散列函數的設計不能太複雜

過於複雜的散列函數,勢必會消耗很多計算時間,也就間接的影響到散列表的性能。

 

散列函數生成的值要儘可能隨機並且均勻分佈

這樣才能避免或者最小化散列衝突

即便出現衝突,散列到每個槽裏的數據也會比較平均,不會出現某個槽內數據特別多的情況。

 

散列函數的設計方法

散列函數的設計方法有很多,比如接尋址法、平方取中法、摺疊法、隨機數法等。

散列函數的設計方法

 

散列衝突

再好的散列函數也無法避免散列衝突,所以針對散列衝突問題,我們需要通過其他途徑來解決。

我們常用的散列衝突解決方法有兩類,開放尋址法(open addressing)和鏈表法(chaining)。

 

開放尋址法

核心思想:如果出現了散列衝突,我們就重新探測一個空閒位置,將其插入。

如何探測新的位置?

根據探測方法又分爲線性探測(Linear Probing)、二次探測(Quadratic probing)和雙重散列(Double hashing)。

 

線性探測

當我們往散列表中插入數據時,如果某個數據經過散列函數散列之後,存儲位置已經被佔用了。

我們就從當前位置開始,依次往後查找,看是否有空閒位置,直到找到爲止。

散列表的大小爲 10,在元素 x 插入散列表之前,已經 6 個元素插入到散列表中。x 經過 Hash 算法之後,被散列到位置下標爲 7 的位置,但是這個位置已經有數據了,所以就產生了衝突。於是我們就順序地往後一個一個找,看有沒有空閒的位置,直到找到空閒位置 2,於是將其插入到這個位置。

在散列表中查找元素的過程有點兒類似插入過程。

我們通過散列函數求出要查找元素的鍵值對應的散列值,然後比較數組中下標爲散列值的元素和要查找的元素。如果相等,則說明就是我們要找的元素;否則就順序往後依次查找。

***如果遍歷到數組中的空閒位置,還沒有找到,就說明要查找的元素並沒有在散列表中。***

在查找的時候,一旦我們通過線性探測方法,找到一個空閒位置,我們就可以認定散列表中不存在這個數據。

但是,如果這個空閒位置是我們後來刪除的,就會導致原來的查找算法失效。本來存在的數據,會被認定爲不存在。

對於使用線性探測法解決衝突的散列表,刪除操作稍微有些特別。我們不能單純地把要刪除的元素設置爲空。

我們可以將刪除的元素特殊標記爲 deleted。線性探測查找的時候,遇到標記爲 deleted 的空間不會停下來,而是繼續往下探測。

 

線性探測法的問題

當散列表中插入的數據越來越多時,散列衝突發生的可能性就會越來越大,空閒位置會越來越少,線性探測的時間就會越來越久。

極端情況下,我們可能需要探測整個散列表,所以最壞情況下的時間複雜度爲 O(n)。(刪除、查找操作同理)

 

二次探測

二次探測類似於線性探測。

線性探測每次探測的步長是 1,那它探測的下標序列就是 hash(key)+0,hash(key)+1,hash(key)+2……

而二次探測探測的步長就變成了原來的“二次方”。

它探測的下標序列就是 hash(key)+0,hash(key)+1²,hash(key)+2²……

 

雙重散列

雙重的意思是就是不僅要使用一個散列函數。我們使用一組散列函數 hash1(key),hash2(key),hash3(key)……

我們先用第一個散列函數,如果計算得到的存儲位置已經被佔用,再用第二個散列函數,依次類推,直到找到空閒的存儲位置。

 

 

不論是哪一種探測方法,當散列表中空閒位置不多的時候,散列衝突的概率就會大大提高。

爲了儘可能保證散列表的操作效率,一般情況下,我們會儘可能保證散列表中有一定比例的空閒槽位。

我們用裝載因子(load factor)來表示空位的多少 (裝載因子/加載因子/負載因子)

散列表的裝載因子=填入表中的元素個數/散列表的長度

裝載因子越大,說明空閒位置越少,衝突越多,散列表的性能會下降。

 

鏈表法

鏈表法是一種更加常用的散列衝突解決辦法,相比開放尋址法,要簡單很多。

在散列表中,每個“桶(bucket)”或者“槽(slot)”會對應一條鏈表,所有散列值相同的元素我們都放到相同槽位對應的鏈表中。

插入的時間複雜度是 O(1)。

當查找、刪除一個元素時,我們同樣通過散列函數計算出對應的槽,然後遍歷鏈表查找或者刪除。

查找和刪除操作的時間複雜度跟鏈表的長度k成正比。也就是O(k)。

於散列比較均勻的散列函數來說,理論上講,k=n/m,其中 n 表示散列中數據的個數,m 表示散列表中“槽”的個數。

 

裝載因子過大怎麼辦

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

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

對於沒有頻繁插入和刪除的靜態數據集合來說,

我們很容易根據數據的特點、分佈等,設計出完美的、極少衝突的散列函數,因爲畢竟之前數據都是已知的。

 

對於動態散列表來說,數據集合是頻繁變動的,我們事先無法預估將要加入的數據個數,所以我們也無法事先申請一個足夠大的散列表。

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

數組、棧、隊列都支持動態擴容,

其實針對散列表,當裝載因子過大時,我們也可以進行動態擴容,重新申請一個更大的散列表,將數據搬移到這個新散列表中。

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

如果原來散列表的裝載因子是 0.8,那經過擴容之後,新散列表的裝載因子就下降爲原來的一半,變成了 0.4。

針對數組的擴容,數據搬移操作比較簡單。

但是,針對散列表的擴容,數據搬移操作要複雜很多。

因爲散列表的大小變了,數據的存儲位置也變了,所以我們需要通過散列函數重新計算每個數據的存儲位置。

 

插入一個數據,最好情況下,不需要擴容,最好時間複雜度是 O(1)。

最壞情況下,散列表裝載因子過高,啓動擴容,我們需要重新申請內存空間,重新計算哈希位置,並且搬移數據,所以時間複雜度是 O(n)。

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

對於動態的散列表來說,有時候隨着數據的刪除,散列表中的數據會越來越少,空閒空間會越來越多。

如果對空間消耗非常敏感,我們可以在裝載因子小於某個值之後,啓動動態縮容。

如果我們更加在意執行效率,能夠容忍多消耗一點內存空間,可以不必如此。

 

當散列表的裝載因子超過某個閾值時,就需要進行擴容。裝載因子的閾值一定要選擇得當。

如果太大,會導致衝突過多;如果太小,會導致內存浪費嚴重。

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

如果內存空間緊張,對執行效率要求又不高,可以增加負載因子的值,甚至可以大於 1。

 

避免底效擴容

散列表插入一個數據很快,但是特殊情況下,裝載因子到達閾值,就需要先進行擴容,再插入數據。

這時候插入數據就會變得很慢。甚至令人無法接受。舉一個極端的例子:

如果散列表當前大小爲 1GB,要想擴容爲原來的兩倍大小,

那就需要對 1GB 的數據重新計算哈希值,並且從原來的散列表搬移到新的散列表。這個過程十分耗時。

爲了避免這種極個別非常慢的插入操作,就要避免這種“一次性”的擴容機制。

 

爲了解決一次性擴容耗時過多的情況,我們可以將擴容操作穿插在插入操作的過程中,分批完成。

這種形式被叫做“漸進式遷移”。

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

當有新數據要插入時,我們將新數據插入新散列表中,並且從老的散列表中拿出一個數據放入到新散列表。

每次插入一個數據到散列表,我們都重複上面的過程。

經過多次插入操作之後,老的散列表中的數據就一點一點全部搬移到新散列表中了。

 

通過這樣均攤的方法,將一次性擴容的代價,均攤到多次插入操作中,就避免了一次性擴容耗時過多的情況。這

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

 

但是這種做法會對查詢造成一定影響,我們按照以前的查詢方式肯定是行不通的

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

 

如何選擇解決衝突的方法

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

Java 中 LinkedHashMap 就採用了鏈表法解決衝突,ThreadLocalMap 是通過線性探測的開放尋址法來解決衝突。

 

開放尋址法的優點

開放尋址法不需要拉很多鏈表。散列表中的數據都存儲在數組中,可以有效地利用 CPU 緩存加快查詢速度。

開放尋址法實現的散列表,序列化起來比較簡單。鏈表法包含指針,序列化起來就沒那麼容易。

開放尋址法的缺點

用開放尋址法解決衝突的散列表,刪除數據的時候比較麻煩,需要特殊標記已經刪除掉的數據。

在開放尋址法中,所有的數據都存儲在一個數組中,比起鏈表法來說,衝突的代價更高。

正因爲衝突的代價高,裝載因子的上限不能太大。這也導致這種方法比鏈表法更浪費內存空間。

開放尋址法只能適用裝載因子小於 1 的情況。

接近 1 時,就可能會有大量的散列衝突,導致大量的探測、再散列等,性能會下降很多。

 

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

這也是 Java 中的ThreadLocalMap使用開放尋址法解決散列衝突的原因。

 

鏈表法的優點

鏈表法對內存的利用率比開放尋址法要高。因爲鏈表結點可以在需要的時候再創建,並不需要像開放尋址法那樣事先申請好。

比起開放尋址法,鏈表法對大裝載因子的容忍度更高。

對於鏈表法來說,只要散列函數的值隨機均勻,即便裝載因子變成 10,也就是鏈表的長度變長了而已。

雖然查找效率有所下降,但是比起順序查找還是快很多。

鏈表法的缺點

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

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


如果我們存儲的是大對象,也就是說要存儲的對象的大小遠遠大於一個指針的大小(4 個字節或者 8 個字節)

那鏈表中指針的內存消耗在大對象面前就可以忽略了。

 

我們將鏈表法中的鏈表改造爲其他高效的動態數據結構,比如跳錶、紅黑樹。這樣,即便出現散列衝突。

極端情況下,所有的數據都散列到同一個桶內,那最終退化成的散列表的查找時間也只不過是 O(logn)。

這樣可以有效避免了散列碰撞攻擊。

 

總結:基於鏈表的散列衝突處理方法比較適合存儲大對象、大數據量的散列表。

而且,比起開放尋址法,它更加靈活,支持更多的優化策略,比如用紅黑樹代替鏈表。

 

散列碰撞攻擊

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

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

這也就是散列表碰撞攻擊的基本原理。

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