數據結構與算法-day7-散列表與鏈表

我發現,有兩種數據結構,散列表和鏈表,常常會被放在一塊兒使用。你還記得,前面的章節中都有哪些地方講到散列表和鏈表的組合使用嗎?java

  • 在鏈表那一節,我講到如何用鏈表來實現 LRU 緩存淘汰算法,可是鏈表實現的 LRU 緩存淘汰算法的時間複雜度是 O(n),當時提到了,經過散列表能夠將這個時間複雜度下降到 O(1)。算法

  • 在跳錶那一節,我提到 Redis 的有序集合是使用跳錶來實現的,跳錶能夠看做一種改進版的鏈表。當時咱們也提到,Redis 有序集合不只使用了跳錶,還用到了散列表。編程

  • 除此以外,若是你熟悉 Java 編程語言,你會發現 LinkedHashMap 這樣一個經常使用的容器,也用到了散列表和鏈表兩種數據結構。數組

今天,咱們就來看看,在這幾個問題中,散列表和鏈表都是如何組合起來使用的,以及爲何散列表和鏈表會常常放到一塊使用。緩存

LRU 緩存淘汰算法

在鏈表那一節中,我提到,藉助散列表,咱們能夠把 LRU 緩存淘汰算法的時間複雜度下降爲 O(1)。如今,咱們就來看看它是如何作到的。數據結構

首先,咱們來回顧一下當時咱們是如何經過鏈表實現 LRU 緩存淘汰算法的。編程語言

咱們須要維護一個按照訪問時間從大到小有序排列的鏈表結構。由於緩存大小有限,當緩存空間不夠,須要淘汰一個數據的時候,咱們就直接將鏈表頭部的結點刪除。函數

當要緩存某個數據的時候,先在鏈表中查找這個數據。spa

  • 若是沒有找到,則直接將數據放到鏈表的尾部;
  • 若是找到了,咱們就把它移動到鏈表的尾部

由於查找數據須要遍歷鏈表,因此單純用鏈表實現的 LRU 緩存淘汰算法的時間複雜很高,是 O(n)。3d

實際上,我總結一下,一個緩存(cache)系統主要包含下面這幾個操做:

  • 往緩存中添加一個數據;
  • 從緩存中刪除一個數據;
  • 在緩存中查找一個數據。

這三個操做都要涉及「查找」操做,若是單純地採用鏈表的話,時間複雜度只能是 O(n)。若是咱們將散列表和鏈表兩種數據結構組合使用,能夠將這三個操做的時間複雜度都下降到 O(1)。具體的結構就是下面這個樣子:

咱們使用雙向鏈表存儲數據,鏈表中的每一個結點處理存儲數據(data)、前驅指針(prev)、後繼指針(next)以外,還新增了一個特殊的字段 hnext

由於咱們的散列表是經過鏈表法解決散列衝突的,因此每一個結點會在兩條鏈中。一個鏈是剛剛咱們提到的雙向鏈表,另外一個鏈是散列表中的拉鍊。前驅和後繼指針是爲了將結點串在雙向鏈表中,hnext 指針是爲了將結點串在散列表的拉鍊中。

瞭解了這個散列表和雙向鏈表的組合存儲結構以後,咱們再來看,前面講到的緩存的三個操做,是如何作到時間複雜度是 O(1) 的?

  1. 首先,咱們來看如何查找一個數據。咱們前面講過,散列表中查找數據的時間複雜度接近 O(1),因此經過散列表,咱們能夠很快地在緩存中找到一個數據。當找到數據以後,咱們還須要將它移動到雙向鏈表的尾部

  2. 其次,咱們來看如何刪除一個數據。咱們須要找到數據所在的結點,而後將結點刪除。藉助散列表,咱們能夠在 O(1) 時間複雜度裏找到要刪除的結點。由於咱們的鏈表是雙向鏈表,雙向鏈表能夠經過前驅指針 O(1) 時間複雜度獲取前驅結點,因此在雙向鏈表中,刪除結點只須要 O(1) 的時間複雜度。

  3. 最後,咱們來看如何添加一個數據。添加數據到緩存稍微有點麻煩,

    • 咱們須要先看這個數據是否已經在緩存中。若是已經在其中,須要將其移動到雙向鏈表的尾部;
    • 若是不在其中,還要看緩存有沒有滿。
      • 若是滿了,則將雙向鏈表頭部的結點刪除,而後再將數據放到鏈表的尾部;
      • 若是沒有滿,就直接將數據放到鏈表的尾部。

這整個過程涉及的查找操做均可以經過散列表來完成。其餘的操做,好比刪除頭結點、鏈表尾部插入數據等,均可以在 O(1) 的時間複雜度內完成。因此,這三個操做的時間複雜度都是 O(1)。至此,咱們就經過散列表和雙向鏈表的組合使用,實現了一個高效的、支持 LRU 緩存淘汰算法的緩存系統原型。

Redis 有序集合

在跳錶那一節,講到有序集合的操做時,我稍微作了些簡化。實際上,在有序集合中,每一個成員對象有兩個重要的屬性,key(鍵值)和score(分值)。咱們不只會經過 score 來查找數據,還會經過 key 來查找數據。

舉個例子,好比用戶積分排行榜有這樣一個功能:咱們能夠經過用戶的 ID 來查找積分信息,也能夠經過積分區間來查找用戶 ID 或者姓名信息。這裏包含 ID、姓名和積分的用戶信息,就是成員對象,用戶 ID 就是 key積分就是 score

因此,若是咱們細化一下 Redis 有序集合的操做,那就是下面這樣:

  • 添加一個成員對象;
  • 按照鍵值來刪除一個成員對象;
  • 按照鍵值來查找一個成員對象;
  • 按照分值區間查找數據,好比查找積分在 [100, 356] 之間的成員對象;
  • 按照分值從小到大排序成員變量;

若是咱們僅僅按照分值將成員對象組織成跳錶的結構,那按照鍵值來刪除、查詢成員對象就會很慢,解決方法與 LRU 緩存淘汰算法的解決方法相似。

咱們能夠再按照鍵值構建一個散列表,這樣按照 key 來刪除、查找一個成員對象的時間複雜度就變成了 O(1)。同時,藉助跳錶結構,其餘操做也很是高效。

實際上,Redis 有序集合的操做還有另一類,也就是查找成員對象的排名(Rank)或者根據排名區間查找成員對象。這個功能單純用剛剛講的這種組合結構就沒法高效實現了。這塊內容我後面的章節再講。

Java LinkedHashMap

前面咱們講了兩個散列表和鏈表結合的例子,如今咱們再來看另一個,Java 中的 LinkedHashMap 這種容器。

若是你熟悉 Java,那你幾乎每天會用到這個容器。咱們以前講過,HashMap 底層是經過散列表這種數據結構實現的。

實際上,LinkedHashMap 並無這麼簡單,其中的「Linked」也並不只僅表明它是經過鏈表法解決散列衝突的。

咱們先來看一段代碼。你以爲這段代碼會以什麼樣的順序打印 3,1,5,2 這幾個 key 呢?緣由又是什麼呢?

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() 函數,往 LinkedHashMap 中添加數據的時候,都會將數據添加到鏈表的尾部,因此,在前四個操做完成以後,鏈表中的數據是下面這樣:

在第 8 行代碼中,再次將鍵值爲 3 的數據放入到 LinkedHashMap 的時候,會先查找這個鍵值是否已經有了,而後,再將已經存在的 (3,11) 刪除,而且將新的 (3,26) 放到鏈表的尾部。因此,這個時候鏈表中的數據就是下面這樣:

當第 9 行代碼訪問到 key 爲 5 的數據的時候,咱們將被訪問到的數據移動到鏈表的尾部。因此,第 9 行代碼以後,鏈表中的數據是下面這樣:

因此,最後打印出來的數據是 1,2,3,5。

從上面的分析,你有沒有發現,按照訪問時間排序的 LinkedHashMap 自己就是一個支持 LRU 緩存淘汰策略的緩存系統?

我如今來總結一下,實際上,LinkedHashMap 是經過雙向鏈表和散列表這兩種數據結構組合實現的。LinkedHashMap 中的**「Linked」其實是指的是雙向鏈表,並不是指用鏈表法解決散列衝突**。

小結

散列表這種數據結構雖然支持很是高效的數據插入、刪除、查找操做,可是散列表中的數據都是經過散列函數打亂以後無規律存儲的。也就說,它沒法支持按照某種順序快速地遍歷數據。若是但願按照順序遍歷散列表中的數據,那咱們須要將散列表中的數據拷貝到數組中,而後排序,再遍歷。

由於散列表是動態數據結構,不停地有數據的插入、刪除,因此每當咱們但願按順序遍歷散列表中的數據的時候,都須要先排序,那效率勢必會很低。爲了解決這個問題,咱們將散列表和鏈表(或者跳錶)結合在一塊兒使用。

相關文章
相關標籤/搜索