算法與數據結構(五)哈希表

在講哈希表之前,我們先來看看往一個數組插入數據的過程。

  1. 確認插入數據的下標;
  2. 把數據放入數組。

拿日常生活中根據身高排隊的例子來說,我們想獲取到從低到高的姓名列表。我們就是在重複這樣一個過程:

  1. 找到剩餘隊伍中身高最低人的姓名;
  2. 放入當前數組元素的末尾;
  3. 重複上過程。

但是這裏有很大的限制,就是我們這種情況下能夠知道一個姓名對應數組的下標是多少,很多其他情況下是無法知道的。在這種情況下使用數組處理這個場景是足夠的。

然後看看這樣一個場景,我們需要存儲一個人的姓名及其對應的身高。這個時候我們用數組會怎麼做?我們可以把數組存儲的元素改成是 (姓名, 身高),然後我們查找一個人的身高時,遍歷數組找到姓名,然後返回身高。這種做法問題是查找時間複雜度過高,每次查找有 O(n) 的複雜度。我們能不能在 O(1) 的時間複雜度中查找到元素呢?

這時候就需要哈希表 HashTable。

什麼是哈希表

哈希表 HashTable,也叫散列表、映射、字典等等,哈希表存儲的是鍵值對 —— key、value。

也就是說,哈希表可以直接存儲 "name": height 來解決上述問題。

哈希表關鍵點有三個

  • 哈希函數
  • 哈希衝突
  • 裝載因子

哈希函數

接下來我們結合上圖解釋一下哈希函數。

我們現在要往哈希表中放入 ("xiaoming", 175) 這組數據。發生了什麼事情呢?

首先,數據的 key = "xiaoming" 會經過哈希表的哈希函數,轉換成數組的下標 i。然後,我們把 value = 175 存儲到 array[i] 的位置。

這裏的哈希函數要滿足幾個特性:

  1. 相同的 key 經過哈希函數之後的輸出一致;
  2. 不同的 key 經過哈希函數之後的輸出不同;
  3. 哈希函數產生的輸出在數組的有效範圍內。

當我們在哈希表中查詢 "xiaoming" 的身高時,哈希函數先計算該 key 對應的數組下標,然後取出數組中的數據。

這裏我們可以看到,哈希表 = 哈希函數 + 數組,哈希表其實是數組的一種擴充,是由數組演化而來的,解決數組所無法解決的問題。這裏我們也再強調一下,沒有一種最優的數據結構和算法,只有更加適合某種場景、解決某種問題的數據結構和算法。

後續我們可以看到,哈希表也可能是另外一種形式:哈希表 = 哈希函數 + 數組 + 鏈表。

哈希衝突

你可能會想到,在我們存儲過程中,雖然我們能夠通過哈希函數得到一個 key 的整數值,但是我們的數組是有限的,假設這裏的整數值爲 k,數組長度爲 n。我們通過 k % n 取餘操作拿到 k 實際的數組下標。我們就會碰到兩個整數對 n 取餘之後的下標是相等的情況。比如 3 % 2 == 5 % 2 的情況。這個時候如果數組這個位置已經放置了元素,我們該怎麼處理呢?

因爲是哈希函數輸出的值產生了衝突,所以這種情況我們稱之爲哈希衝突或者哈希值衝突。

而對應的解決辦法有兩種:

  1. 開放尋址法
  2. 鏈表法

開放尋址法

開放尋址法中有線性探測法,該方法是說在發生衝突時,我們就依次在數組中往後查找,直到找到一個空閒位置。但是這種方法,在我們插入數據越來越多時,發生衝突的可能性就越來越大,線性探測時間也就越來越長。這時哈希表的插入、查找時間複雜度會退化,最差情況是 O(n)。Java 中的 ThreadLocalMap 使用此方法來解決哈希衝突。

當我們採用線性探測法時,如何對以上問題做出優化呢?

此時我們就要引入裝填因子的概念,裝填因子 = 已有元素個數 / 散列表的總長度。首先我們確定一個裝填因子,然後當目前的因子大於我們設定的裝填因子時,我們就對數組進行擴容,以此來保證有一定的剩餘空間,進而減少線性探測時的時間複雜度。裝填因子的設置也需要根據情況來看,要對線性探測的執行效率和擴容的成本上進行平衡。本質上還是時間、空間的平衡,當我們要求哈希表執行效率更高時,我們就可以設置更小的裝填因子,增大剩餘的數組空間,有利我們更快的進行線性探測;當我們內存比較緊張時,就需要設定更大的裝填因子。

還有一種開放尋址法是雙重散列。當一個哈希函數輸出的下標值發生衝突時,採用另外一種哈希函數來重新計算下標值,直到找到空閒的位置。

鏈表法

鏈表法另闢蹊徑,它把數組存儲的元素改爲鏈表,當發生衝突時不再嘗試把數據存入到數組中,而是存儲到同一下標的鏈表之中。上圖展示了鏈表法的哈希表結構。這時我們發現 哈希表 = 哈希函數 + 數組 + 鏈表Java 中的 LinkedHashMap 使用了鏈表法解決衝突問題。

當我們插入數據時,相當於在鏈表中插入元素的時間複雜度 O(1)。當我們查詢時,取決於對應鏈表的長度 m,時間複雜度爲 O(m)。

但是對於黑客來說,這種方法存在一個致命漏洞。黑客可以不斷的往哈希表中放入 key 對應下標相同的數據,這會導致數組中只有該位置有長長的鏈表,進而導致查詢時間十分漫長,對你的程序產生攻擊的效果。我們稱之爲哈希表碰撞攻擊。

所以一個能夠產生均勻下標值的哈希函數十分重要。除了採用更優的哈希函數,我們還可以對鏈表做優化,比如說改造成紅黑樹解決查找過慢的問題。其實 Java 8 中就做了這個優化。

經過以上分析,我們知道開放尋址法更加適合數據量比較小、裝填因子較小的場景,這也是 Java 中 ThreadLocalMap 使用開放尋址法的原因;鏈表法比較適合存儲數據量比較大、對象比較大的情況。

什麼是好的哈希表

結合上述內容,我們知道一個好的哈希表要有以下特性:

  • 快速的查詢、插入、刪除
  • 性能穩定,不能夠退化的太嚴重
  • 內存佔用合理

要保證以上特性,需要做到:

  • 哈希函數的輸出比較均勻
  • 合適的裝填因子大小,高性能的動態擴容
  • 合適的哈希衝突解決方法

總結

我們除了要了解哈希表是存儲鍵值對的數據結構之外,還要掌握插入、刪除、搜索一個數據的發生過程是怎樣的。另外我們也要掌握哈希表的三個關鍵要素:哈希函數、衝突解決方法、裝填因子。最後,我們要根據自己的實際場景,通過調整確定三個關鍵要素,選擇合適的哈希表或者實現自己的哈希表。

實踐

最後,推薦大家做一下 Leetcode 上的題目。可以先用自己的思路寫一下代碼,然後結合使用哈希表的方式再寫一份代碼。最後比較這兩者的時間空間複雜度。

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