1. 程式人生 > >資料結構和演算法之——散列表中

資料結構和演算法之——散列表中

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

1. 如何設計雜湊函式?

雜湊函式設計的好壞,決定了雜湊衝突發生的概率,也直接決定了散列表的效能。那什麼才是好的雜湊函式呢?

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

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

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

散列表上 實現 Word 中拼寫檢查功能時,我們可以這樣設計:將單詞中每個字母的 ASCII 值“進位”相加,然後再和散列表的大小求餘、取模,作為雜湊值。比如,英文單詞 nice,轉化出來的雜湊值就是:

hash("nice")=(("n" - "a") *26*26*26 + ("i" - "a")*26*26 + ("c" - "a")*26+ ("e"-"a")) / 78978
複製程式碼

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

2. 裝載因子過大了怎麼辦?

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

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

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

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

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

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

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

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

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

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

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

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

4.1. 開放定址法

  • 優點
    • 資料都儲存在陣列中,可以有效地利用 CPU 快取加快查詢速度
    • 沒有指標,序列化起來比較簡單
  • 缺點
    • 刪除資料需要特殊標記,比較麻煩
    • 衝突的代價更高,一般裝載因子上限不能太大,更浪費記憶體

當資料量比較小、裝載因子比較小的時候,適合用開放定址法。

4.2. 連結串列法

  • 優點
    • 記憶體利用率比開放定址法要高,連結串列結點可以在需要的時候再建立
    • 對大裝載因子容忍度更高,只要雜湊函式的值隨機均勻,即使裝載因子變成 10,也就是連結串列的長度變長了而已
  • 缺點
    • 儲存小物件需要額外的指標,比較耗記憶體,但對於大物件則可以忽略
    • 連結串列分散儲存,無法利用 CPU 快取

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

基於連結串列的雜湊衝突方法比較適合儲存大物件、大資料量的散列表,而且,比起開放定址法,它更加靈活,支援更多的優化策略。

5.工業級散列表舉例分析?

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

  1. 初始大小 HashMap 的初始預設大小為 16,如果我們事先知道大概的資料量有多大,可以修改預設初始化大小的值。

  2. 裝載因子和動態擴容 最大裝載因子預設是 0.75,當超過這個閾值時,就會啟動動態擴容,每次擴容都會擴容為原來的兩倍大小。

  3. 雜湊衝突解決方法 HashMap 底層採用連結串列法來解決衝突,在 JDK 1.8 版本中,當連結串列長度太長時(預設超過 8),連結串列就會轉化為紅黑樹。

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

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

  • 支援快速地查詢、插入和刪除操作
  • 記憶體佔用合理,不能浪費過多的記憶體空間
  • 效能穩定,極端情況下,散列表的效能也不會退化到無法接受的程度

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

  • 設計一個合適的雜湊函式
  • 定義裝載因子閾值,並且設計動態擴容策略
  • 選擇合適的雜湊衝突解決方法

參考資料-極客時間專欄《資料結構與演算法之美》

獲取更多精彩,請關注「seniusen」!