數據結構:散列表

散列表的英文叫 "Hash Table",我們也叫它 「哈希表」 或者 「Hash 表」。

1. 散列思想?

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

假如我們有 100 名選手參加運動會,參賽號碼從 0~99。爲了方便記錄查詢成績,我們將參賽號碼爲 0 的選手的成績放在數組下標爲 0 的位置,參賽號碼爲 1 的選手的成績放在數組下標爲 1 的位置,以此類推。

這樣,當我們想要查找某個選手的成績時,我們只需要取出數組中該選手參賽號碼對應下標的數值即可,時間複雜度爲 O(1),效率非常高。

在這個例子中,參賽號碼是自然數,並且與數組的下標形成一一映射,這其實就有了散列的思想。

但事實上,有時候我們不能直接將編號作爲數組下標,比如參賽選手的編號可能爲 051167,05 表示年級,11 表示班級,67 表示序號。

這時候,我們可以通過截取參賽編號的後兩位作爲下標,當查詢選手信息的時候,我們用同樣的方法,取出後兩位數字,作爲數組下標來讀取數據。

 

這就是典型的散列思想。其中,參賽選手的編號我們叫作鍵(key)或關鍵字,我們用它來標識一個選手。而把參賽編號轉化爲數組下標的映射方法就叫作散列函數(或 「Hash 函數」,「哈希函數」),而散列函數計算得到的值就叫作散列值(或 「Hash 值」,「哈希值」)。

散列表其實就是通過散列函數把元素的鍵值映射爲下標,然後將數據存儲在數組中對應下標的位置。當我們按照鍵值查詢元素的時候,我們用同樣的散列函數,將鍵值轉化爲數組下標,從對應下標位置的數組中取數據。

 

2. 散列函數?

散列函數在散列表中起着非常關鍵的作用。

上面兩個例子中的散列函數都比較簡單,也很容易理解。但如果參賽選手的編號是隨機生成的 6 位數字,又或者是字符時,我們該如何構造散列函數呢?

散列函數有以下三個基本要求:

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

  • 如果 key1=key2hash(key1)=hash(key2)key1=key2,那麼hash(key1)=hash(key2)

  • 如果 key1key2hash(key1)hash(key2)key1≠key2,那麼hash(key1)≠hash(key2)

第一點和第二點都非常好理解,第三點要求看起來合情合理,但在真實情況下,要想找到一個不同 key 值對應的散列值都不一樣的散列函數,幾乎是不可能的。而且,因爲數組的存儲空間有限,也會加大散列衝突的概率。因此,我們需要通過其他途徑來解決散列衝突問題。

 

3. 散列衝突?

再好的散列函數也無法避免散列衝突,常用的解決蛋類衝突解決方法有兩類,開放尋址法(open addressing)鏈表法(chaining)

3.1. 開放尋址法

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

線性探測(Linear Probing) 就是當我們往散列表中插入數據時,如果計算得到的散列值對應的位置已經被佔用了,我們就從當前位置開始,依次往後查找,看是否有空閒位置,直到找到爲止。

 

看下面的例子,橙色表示已經有元素,黃色表示空閒。當計算新插入的 x 的散列值爲 7 時,我們發現數組中下標爲 7 的地方已經有數據了,於是我們就依次向後查找,遍歷到尾部都沒有找到空閒位置。我們再從頭開始查找,直到找到數組第 2 個位置空閒,我們就將 x 插入到這個地方。

 

 

在散列表中查找元素的過程與插入類似,我們通過散列函數求出要查找元素的鍵值對應的散列值,然後比較數組中下標爲散列值的元素和要查找的元素。如果相等,那說明就是我們要查找的元素;否則就順序往後依次查找,若遍歷到數組中的空閒位置還沒有找到,說明要查找的元素並沒有在散列表中。

 

散列表跟數組一樣,不僅支持插入、查找操作,還支持刪除操作。對於使用線性探測解決衝突的散列表,刪除操作稍微有點特別,我們不能單純地把要刪除的元素設置爲空

因爲在查找的過程中,一旦我們遍歷到數組中的空閒位置,我們就認定數據不在散列表中。但如果這個空閒位置是我們後來刪除的,就會導致我們的查找算法失效,本來存在的數據也會被認定爲不存在。

 

我們可以將刪除的元素特殊標記爲 deleted,然後當我們查找到標記爲 deleted 的位置時,我們不是停下來,而是繼續往下探測。

 

線性探測存在很大的問題,當散列表中插入的數據越來越多時,散列衝突的可能性就會越來越大,空閒位置越來越少,線性探測的時間也會越來越久

除了線性探測,還有另外兩種比較經典的探測方法,二次探測(Quadratic Probing)雙重探測(Double Probing)

所謂二次探測,就是說每次探測的步長變成了原來的二次方,也就是說,它探測的下標序列變爲 hash(key)+0,hash(key)+12,hash(key)+22hash(key)+0,hash(key)+12,hash(key)+22……

所謂雙重探測,就是說每次不僅僅使用一個散列函數,當第一個散列函數計算得到的存儲位置被佔用的時候,再使用第二個散列函數,以此類推,直到找到空閒的位置。

不管採用哪種探測方法,當散列表中的空閒位置不多時,散列衝突的概率就會大大提高。我們引入一個裝載因子(load factor)來表示散列表中空位的多少 散列表的裝載因子 = 填入表中的元素個數 / 散列表的長度。裝載因子越大,說明空閒位置越少,衝突越多,散列表的性能會下降。

3.2. 鏈表法

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

 

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

 

向散列表中插入數據的時間複雜度爲 O(1),而查找或者刪除的時間複雜度則與鏈表的長度 k 成正比。

 

4. 如何打造一個工業級的散列表?

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

4.1 如何設計散列函數?

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

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

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

手機號碼前面幾位重複的可能性很大,但是後面幾位就比較隨機,我麼可以取手機號的後四位數作爲散列值;對運動會參賽成員統計成績的時候,選手後兩位的號碼就可以作爲散列值。這種散列函數的設計方法,我們一般叫作「數據分析法」。

在 散列表上 實現 Word 中拼寫檢查功能時,我們可以這樣設計:將單詞中每個字母的 ASCII 值「進位」相加,然後再和散列表的大小求餘、取模,作爲散列值。比如,英文單詞 nice,轉化出來的散列值就是:hash("nice")=(("n" - "a") *26*26*26 + ("i" - "a")*26*26 + ("c" - "a")*26+ ("e"-"a")) / 78978

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

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

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

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

但是,針對散列表的擴容,數據搬移要複雜很多,因爲散列表的大小變了,數據的存儲位置也變了,所以我們需要散列函數重新計算每個數據的存儲位置。

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

實際上,對於動態散列表,隨着數據的刪除,散列表越來越小,我們還可以在裝載因子小於某個值之後,啓動動態縮容。

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

4.3 如何避免低效地擴容?

我們剛剛分析到,大部分情況下,動態擴容的散列表插入數據都很快,但是在特殊情況下,當裝載因子達到閾值時,需要先進行擴容,再插入數據 ,這時候,插入數據就會很慢,尤其是在數據量已經非常大的情況下。

因此,我們可以考慮不要一次性把數據全部都搬移過去。當裝載因子達到閾值時,我們申請新的空間,但並不將老的數據搬移到新散列表中。當有新的數據要插入時,我們不僅將新數據插入到新散列表中,而且同時從老的散列表中拿出一個數據放到新散列表中。這樣,經過多次插入操作後,我們就一點一點地完成了數據搬移,插入操作也變得更快了。

至於這期間的查詢操作,我們先從新散列表中查找,如果沒有找到,再去老的散列表中查找。

通過這樣的均攤方法,任何情況下,插入一個數據的時間複雜度都爲 O(1)O(1)

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

4.4.1  開放尋址法

  • 優點

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

  • 沒有指針,序列化起來比較簡單

  • 缺點

  • 刪除數據需要特殊標記,比較麻煩

  • 衝突的代價更高,一般裝載因子上限不能太大,更浪費內存

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

4.4.2  鏈表法

  • 優點

  • 內存利用率比開放尋址法要高,鏈表結點可以在需要的時候再創建

  • 對大裝載因子容忍度更高,只要散列函數的值隨機均勻,即使裝載因子變成 10,也就是鏈表的長度變長了而已

  • 缺點

  • 存儲小對象需要額外的指針,比較耗內存,但對於大對象則可以忽略

  • 鏈表分散存儲,無法利用 CPU 緩存

    另外,我們還可以對鏈表法加以改造,將鏈表改造成其他更高效的動態數據結構,比如跳錶、紅黑樹。這樣,即使出現散列衝突,也可以保證查找的時間複雜度爲 O(logn)O(logn)

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

4.5 工業級散列表舉例分析?

讓我們來看一下 Java 中的 HashMap 是怎麼實現的。

    • 初始大小:HashMap 的初始默認大小爲 16,如果我們事先知道大概的數據量有多大,可以修改默認初始化大小的值。

    • 裝載因子和動態擴容:最大裝載因子默認是 0.75,當超過這個閾值時,就會啓動動態擴容,每次擴容都會擴容爲原來的兩倍大小。

    • 散列衝突解決方法:HashMap 底層採用鏈表法來解決衝突,在 JDK 1.8 版本中,當鏈表長度太長時(默認超過 8),鏈表就會轉化爲紅黑樹。

4.6 .如何設計一個工業級散列表?

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

  • 支持快速地查詢、插入和刪除操作

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

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

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

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

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

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

 

5. 爲什麼散列表和鏈表經常會一起使用?

散列表和鏈表經常組合起來使用,但它們是如何組合起來使用的,爲什麼它們會經常一塊使用呢?

5.1  LRU 緩存淘汰算法?

基於鏈表實現 LRU 緩存淘汰算法的原理是這樣的:我們維護一個有序單鏈表,越靠近鏈表頭部的結點是越早訪問的。當有一個新的數據被訪問時,我們從鏈表頭開始順序遍歷鏈表。

5.1.1 如果此數據之前已經被緩存在鏈表中了,我們將其從原來的位置刪除,然後再插入到鏈表的尾部。

5.1.2 如果此數據沒有緩存在鏈表中,又可以分爲兩種情況:

  • 如果緩存未滿,直接將此結點插入到鏈表的尾部

  • 如果緩存已滿,則將鏈表尾結點刪除,然後再將新的數據結點插入到鏈表的尾部

因爲不管緩存是否已滿,我們都需要遍歷一遍鏈表,因此,基於鏈表實現的緩存訪問的時間複雜度爲 O(n)

一個緩存(cache)系統主要包含下面這幾個操作:

  • 往緩存中添加一個數據

  • 從緩存中刪除一個數據

  • 在緩存中查找一個數據

如果我們將散列表和鏈表兩種數據結構結合起來使用,可以將這幾個操作的時間複雜度都降低到 O(1)

具體的結構就是下面這個樣子:

使用雙向鏈表來存儲數據,鏈表中的每個結點包括數據(data)、前驅指針(prev)、後繼指針(next)還有一個特殊的 hnext 指針。

因爲我們使用鏈表法來解決散列衝突,所以每個結點都會在兩條鏈中存在。一個鏈是上面的雙向鏈表,另一個鏈則是散列表中散列值相同的元素組成的拉鍊。前驅和後繼指針是爲了將結點串在雙向鏈表中,hnext 指針是爲了將結點串在散列表的拉鍊中。

查找數據的時候,我們通過散列表可以在時間複雜度接近於 O(1)O(1) 內找到一個數據,然後,我們再將其移動到雙向鏈表的尾部。

刪除數據的時候,我們在時間複雜度接近於 O(1)O(1) 內找到要刪除的結點,然後由於是雙向鏈表,我們可以直接得到前驅指針,刪除結點也只需要 O(1)O(1) 的時間複雜度。

添加數據的時候,類似於單鏈表的情況,我們也可以在 O(1)O(1) 時間複雜度內完成。

而其他操作,比如刪除頭結點、尾部插入數據等,都可以在 O(1)O(1) 時間複雜度內完成。因此,我們就通過散列表和雙向鏈表的組合使用,實現了一個高效的、支持 LRU 緩存淘太算法的緩存系統原型。

5.2 Redis 有序集合?

    在 跳錶 中,我們實現了一個簡單的有序集合。但實際上,在有序集合中,每個成員對象有兩個重要的屬性,key (鍵值)和 score (分值)。我們不僅會通過 score 來查找數據,還會通過 key 來查找數據。

因此 Redis 有序集合的操作主要有以下幾種:

  • 添加一個成員對象

  • 按照鍵值來刪除一個成員對象

  • 按照鍵值來查找一個成員對象

  • 按照分值區間查找數據

  • 按照分值從小到大排序成員變量

如果我們僅僅按照分值將成員對象組織成跳錶的結構,那按照鍵值來刪除、查找成員對象就會很慢,解決方法與 LRU 緩存淘太算法的解決方法類似。我們可以再按照鍵值構建一個散列表,這樣按照鍵值來刪除、查找成員對象的時間複雜度就變成了 O(1)

5.3 Java LinkedHashMap?

HashMap<Integer, Integer> m = new LinkedHashMap<>();
m.put(3, 11);
m.put(1, 12);
m.put(5, 23);
m.put(2, 22);

for (Map.Entry e : m.entrySet()) {
  System.out.println(e.getKey());
}

這段代碼的輸出是 3, 1, 5, 2,你有沒有覺得奇怪?散列表中的數據是經過散列函數打亂之後無規律存儲的,這裏是如何按照數據的插入順序來遍歷輸出的呢?

其實,LinkedHashMap 也是通過散列表和鏈表結合在一起實現的。實際上,它不僅支持按照插入順序遍歷數據,還支持按照訪問順序來遍歷數據。

// 10 是初始大小,0.75 是裝載因子,true 是表示按照訪問時間排序
HashMap<Integer, Integer> m = new LinkedHashMap<>(10, 0.75f, true);
m.put(3, 11);
m.put(1, 12);
m.put(5, 23);
m.put(2, 22);

m.put(3, 26);
m.get(5);

for (Map.Entry e : m.entrySet()) {
  System.out.println(e.getKey());
}

這段代碼的輸出是 1, 2, 3, 5,我們來具體看一下。

每次調用 put() 函數,都會將數據添加到鏈表的尾部,前四個操作後,鏈表中的數據是下面這樣:

在第八行,當我們再次將鍵值爲 3 的數據放入到 LinkedHashMap 中去的時候,就會先查找這個鍵值是否已經存在。然後,將已經存在的 (3, 11) 刪除,並將新的 (3, 26) 放到鏈表尾部。

在第九行,當我們訪問鍵值爲 5 的數據的時候,我們將被訪問的數據移動到鏈表尾部。

可以看到,按照訪問時間排序的 LinkedHashMap 本身就是一個支持 LRU 緩存淘汰策略的緩存系統。 LinkedHashMap 中的 Linked 實際上指的是雙向鏈表。

5.4 小結?

    散列表這種結構雖然支持非常高效的數據插入、刪除、查找操作,但是散列表中的數據都是通過散列函數打亂之後無規率存儲的。也就是說,它無法支持按照某種順序快速地遍歷數據。如果希望按照順序遍歷散列表中的數據,那我們需要將散列表中的數據拷貝到數組中,然後排序遍歷。但是,散列表是動態數據結構,需要不停地插入、刪除數據,若每次遍歷數據都需要先排序,那效率勢必很低。爲了解決這個問題,我們就將散列表和鏈表(或者跳錶)結合在一起使用。