數據結構-散列表

1 散列表

散列表的英文叫「Hash Table」,咱們平時也叫它「哈希表」或者「Hash 表」,散列表用的就是數組支持按照下標隨機訪問的時候,時間複雜度是 O(1) 的特性。咱們經過散列函數把元素的鍵值映射爲下標,而後將數據存儲在數組中對應下標的位置,因此散列表其實就是數組的一種擴展,由數組演化而來。若是沒有數組,就沒有散列表。web

image

白話算法

有 89 名選手參加學校運動會,每一名選手的信息都保存在一個數組中。每一名選手都對應一個6 位數字編號。好比 051167,這段數字含義以下:前兩位 05 表示年級,中間兩位 11 表示班級,最後就是數組中的編號 1 到 89,這樣咱們能經過編號查詢每一名選手的信息。數組

其中,參賽選手的編號咱們叫做(key)或者關鍵字。咱們用它來標識一個選手。咱們把參賽編號轉化爲數組下標的映射方法就叫做散列函數(或「Hash 函數」「哈希函數」),而散列函數計算獲得的值就叫做散列值(或「Hash 值」「哈希值」)。緩存

2 散列函數

散列函數在散列表中起着很是關鍵的做用,咱們能夠把它定義成hash(key),其中 key 表示元素的鍵值,hash(key) 的值表示通過散列函數計算獲得的散列值。bash

2.1 簡單的散列函數

上面的例子用僞代碼表示以下數據結構

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

2.2 基本要求

剛剛舉的學校運動會的例子,散列函數比較簡單,也比較容易想到。可是,若是參賽選手的編號是隨機生成的 6 位數字,又或者用的是 a 到 z 之間的字符串,該如何構造散列函數呢?我總結了三點散列函數設計的基本要求:svg

  • 散列函數計算獲得的散列值是一個非負整數;
  • 若是 key1 = key2,那 hash(key1) == hash(key2);
  • 若是 key1 ≠ key2,那 hash(key1) ≠ hash(key2)。

由於數組下標是從 0 開始的,因此散列函數生成的散列值也要是非負整數。第二點也很好理解。相同的 key,通過散列函數獲得的散列值也應該是相同的。函數

第三點理解起來可能會有問題,我着重說一下。這個要求看起來合情合理,可是在真實的狀況下,要想找到一個不一樣的 key 對應的散列值都不同的散列函數,幾乎是不可能的。即使像業界著名的MD五、SHA、CRC等哈希算法,也沒法徹底避免這種散列衝突。並且,由於數組的存儲空間有限,也會加大散列衝突的機率。性能

2.3 如何設計散列函數?

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

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

  • 散列函數生成的值要儘量隨機而且均勻分佈,這樣才能避免或者最小化散列衝突,並且即使出現衝突,散列到每一個槽裏的數據也會比較平均,不會出現某個槽內數據特別多的狀況

案例

第一個例子就是咱們上一節的學生運動會的例子,咱們經過分析參賽編號的特徵,把編號中的後兩位做爲散列值。咱們還能夠用相似的散列函數處理手機號碼,由於手機號碼前幾位重複的可能性很大,可是後面幾位就比較隨機,咱們能夠取手機號的後四位做爲散列值。這種散列函數的設計方法,咱們通常叫做「數據分析法」。

第二個例子就是上一節的開篇思考題,如何實現 Word 拼寫檢查功能。這裏面的散列函數,咱們就能夠這樣設計:將單詞中每一個字母的ASCll 碼值「進位」相加,而後再跟散列表的大小求餘、取模,做爲散列值。好比,英文單詞 nice,咱們轉化出來的散列值就是下面這樣:

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

3 散列衝突(Hash衝突)

再好的散列函數也沒法避免散列衝突。那究竟該如何解決散列衝突問題呢?咱們經常使用的散列衝突解決方法有兩類,開放尋址法(open addressing)和鏈表法(chaining)

3.1 開放尋址法

開放尋址法的核心思想是,若是出現了散列衝突,咱們就從新探測一個空閒位置,將其插入。那如何從新探測新的位置呢? 有以下機制方法
線性探測(Linear Probing),二次探測(Quadratic probing)
,雙重散列(Double hashing)

3.1.1 線性探測

插入元素

當咱們往散列表中插入數據時,若是某個數據通過散列函數散列以後,存儲位置已經被佔用了,咱們就從當前位置開始,依次日後查找,看是否有空閒位置,直到找到爲止。

image

從圖中能夠看出,散列表的大小爲 10,在元素 x 插入散列表以前,已經 6 個元素插入到散列表中。x 通過 Hash 算法以後,被散列到位置下標爲 7 的位置,可是這個位置已經有數據了,因此就產生了衝突。因而咱們就順序地日後一個一個找,看有沒有空閒的位置,遍歷到尾部都沒有找到空閒的位置,因而咱們再從表頭開始找,直到找到空閒位置 2,因而將其插入到這個位置。

查找元素

在散列表中查找元素的過程有點兒相似插入過程。咱們經過散列函數求出要查找元素的鍵值對應的散列值,而後比較數組中下標爲散列值的元素和要查找的元素。若是相等,則說明就是咱們要找的元素;不然就順序日後依次查找。若是遍歷到數組中的空閒位置,尚未找到,就說明要查找的元素並無在散列表中

爲何遍歷到空位置就表示不存在
由於插入元素時發生散列衝突,會找到數組一個空位置就插入了。若是查找時遍歷到一個空位置尚未找到則說明要查找的元素並無在散列表中

image

刪除元素

在散列表中刪除元素有些特殊,咱們不能單純地把要刪除的元素設置爲空。這是爲何呢?

還記得咱們剛講的查找操做嗎?在查找的時候,一旦咱們經過線性探測方法,找到一個空閒位置,咱們就能夠認定散列表中不存在這個數據。可是,若是這個空閒位置是咱們後來刪除的,就會致使原來的查找算法失效。原本存在的數據,會被認定爲不存在。這個問題如何解決呢?

解決思路

  • 1 咱們能夠將刪除的元素,特殊標記爲 deleted。當線性探測查找的時候,遇到標記爲 deleted 的空間,並非停下來,而是繼續往下探測。
image
  • 2 從數組中刪除該元素,同時繼續向後探測,將散列衝突的元素填入刪除元素的位置。參考ThreadLocalMap
3.1.2 二次探測

跟線性探測很像,線性探測每次探測的步長是 1,那它探測的下標序列就是 hash(key)+0,hash(key)+1,hash(key)+2……而二次探測探測的步長就變成了原來的「二次方」,也就是說,它探測的下標序列就是 hash(key)+0,hash(key)+12,hash(key)+22……

3.1.3 雙重散列

意思就是不只要使用一個散列函數。咱們使用一組散列函數 hash1(key),hash2(key),hash3(key)……咱們先用第一個散列函數,若是計算獲得的存儲位置已經被佔用,再用第二個散列函數,依次類推,直到找到空閒的存儲位置。

3.2 鏈表法

鏈表法是一種更加經常使用的散列衝突解決辦法,相比開放尋址法,它要簡單不少。咱們來看這個圖,在散列表中,每一個「桶(bucket)」或者「槽(slot)」會對應一條鏈表,全部散列值相同的元素咱們都放到相同槽位對應的鏈表中。

image

當插入的時候,咱們只須要經過散列函數計算出對應的散列槽位,將其插入到對應鏈表頭部便可,因此插入的時間複雜度是 O(1)。當查找、刪除一個元素時,咱們一樣經過散列函數計算出對應的槽,而後遍歷鏈表查找或者刪除。

4 裝載因子

當散列表中插入的數據愈來愈多時,散列衝突發生的可能性就會愈來愈大,數組中空閒位置會愈來愈少.對於開放尋址法來講,線性探測的時間就會愈來愈久。極端狀況下,咱們可能須要探測整個散列表,因此最壞狀況下的時間複雜度爲 O(n),對於鏈表法來講鏈表回伴隨着衝突發生鏈表愈來愈長,從而致使查找效率從 O(1)變成O(k),這裏k是鏈表的長度。

爲了儘量保證散列表的操做效率,通常狀況下,咱們會盡量保證散列表中有必定比例的空閒槽位。咱們用裝載因子(load factor)來表示空位的多少,裝載因子的計算公式是:

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

裝載因子越大,說明空閒位置越少,衝突越多,散列表的性能會降低。當裝載因子大到必定程度以後,散列衝突就會變得不可接受。這個時候,咱們該如何處理呢?

4.1 動態擴容

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

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

舉個栗子:下圖中在原來的散列表中,21 這個元素原來存儲在下標爲 0 的位置,搬移到新的散列表中,存儲在下標爲 7 的位置。

image

在插入時擴容

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

擴容的優化

動態擴容的散列表插入一個數據都很快,可是在特殊狀況下,當裝載因子已經到達閾值,須要先進行擴容,再插入數據。這個時候,插入數據就會變得很慢,甚至會沒法接受。

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

優化方式

避免「一次性」擴容的機制,爲了解決一次性擴容耗時過多的狀況,咱們能夠將擴容操做穿插在插入操做的過程當中,分批完成。當裝載因子觸達閾值以後,咱們只申請新空間,但並不將老的數據搬移到新散列表中。

當有新數據要插入時,咱們將新數據插入新散列表中,而且從老的散列表中拿出一個數據放入到新散列表。每次插入一個數據到散列表,咱們都重複上面的過程。通過屢次插入操做以後,老的散列表中的數據就一點一點所有搬移到新散列表中了。這樣沒有了集中的一次性數據搬移,插入操做就都變得很快了。


image

這期間的查詢操做怎麼來作呢?對於查詢操做,爲了兼容了新、老散列表中的數據,咱們先重新散列表中查找,若是沒有找到,再去老的散列表中查找。
經過這樣均攤的方法,將一次性擴容的代價,均攤到屢次插入操做中,就避免了一次性擴容耗時過多的狀況。這種實現方式,任何狀況下,插入一個數據的時間複雜度都是 O(1)。

4.2 裝載因子閾值選擇

當散列表的裝載因子超過某個閾值時,就須要進行擴容。裝載因子閾值須要選擇得當。若是太大,會致使衝突過多;若是過小,會致使內存浪費嚴重。
裝載因子閾值的設置要權衡時間、空間複雜度。若是內存空間不緊張,對執行效率要求很高,能夠下降負載因子的閾值;相反,若是內存空間緊張,對執行效率要求又不高,能夠增長負載因子的值,甚至能夠大於 1。

5 如何選擇衝突解決方法

開放尋址法和鏈表法。這兩種衝突解決辦法在實際的軟件開發中都很是經常使用。好比,Java 中 LinkedHashMap 就採用了鏈表法解決衝突,ThreadLocalMap 是經過線性探測的開放尋址法來解決衝突。那你知道,這兩種衝突解決方法各有什麼優點和劣勢,又各自適用哪些場景嗎?

5.1 開放尋址法

開放尋址法優勢

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

開放尋址法缺點

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

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

5.2 鏈表法

鏈表法優勢

鏈表法對內存的利用率比開放尋址法要高。由於鏈表結點能夠在須要的時候再建立,並不須要像開放尋址法那樣事先申請好。實際上,這一點也是咱們前面講過的鏈表優於數組的地方。

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

鏈表法缺點

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

鏈表法優化

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

鏈表法2

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

參考 極客時間 數據結構算法之美