哎,這讓人摳腦袋的 LFU。

這是why哥的第 83 篇原創文章node

讓人摳腦袋的 LFU

前幾天在某APP看到了這樣的一個討論:web

看到一個有點意思的評論:面試

LFU 是真的難,腦袋都給我摳疼了。算法

若是說 LRU 是 Easy 模式的話,那麼把中間的字母從 R(Recently) 變成 F(Frequently),即 LFU ,那就是 hard 模式了。apache

你不認識 Frequently 不要緊,畢竟這是一個英語專八的詞彙,我這個英語八級半的選手教你:緩存

因此 LFU 的全稱是Least Frequently Used,最不常用策略。數據結構

很明顯,強調的是使用頻率。mvc

而 LRU 算法的全稱是Least Recently Used。最近最少使用算法。編輯器

強調的是時間。函數

當統計的維度從時間變成了頻率以後,在算法實現上發生了什麼變化呢?

這個問題先按下不表,我先和以前寫過的 LRU 算法進行一個對比。

LRU vs LFU

LRU 算法的思想是若是一個數據在最近一段時間沒有被訪問到,那麼在未來它被訪問的可能性也很小。因此,當指定的空間已存滿數據時,應當把最久沒有被訪問到的數據淘汰。

也就是淘汰數據的時候,只看數據在緩存裏面待的時間長短這個維度。

而 LFU 在緩存滿了,須要淘汰數據的時候,看的是數據的訪問次數,被訪問次數越多的,就越不容易被淘汰。

可是呢,有的數據的訪問次數多是相同的。

怎麼處理呢?

若是訪問次數相同,那麼再考慮數據在緩存裏面待的時間長短這個維度。

也就是說 LFU 算法,先看訪問次數,若是次數一致,再看緩存時間。

給你們舉個具體的例子。

假設咱們的緩存容量爲 3,按照下列數據順序進行訪問:

若是按照 LRU 算法進行數據淘汰,那麼十次訪問的結果以下:

十次訪問結束後,緩存中剩下的是 b,c,d 這三個元素。

你有沒有以爲有一絲絲不對勁?

十次訪問中元素 a 被訪問了 5 次,結果最後元素 a 被淘汰了?

若是按照 LFU 算法,最後留在緩存中的三個元素應該是 b,c,a。

這樣看來,LFU 比 LRU 更加合理,更加巴適。

假設,要咱們實現一個 LFUCache:

class LFUCache {

    public LFUCache(int capacity) {

    }
    
    public int get(int key) {

    }
    
    public void put(int key, int value) {

    }
}

那麼思路應該是怎樣的呢?

帶你瞅一眼。

LFU 方案一 - 一個雙向鏈表

若是在徹底沒有接觸過 LFU 算法以前,讓我硬想,我能想到的方案也只能是下面這樣的:

由於既須要有頻次,又須要有時間順序。

咱們就能夠搞個鏈表,先按照頻次排序,頻次同樣的,再按照時間排序。

由於這個過程當中咱們須要刪除節點,因此爲了效率,咱們使用雙向鏈表。

仍是假設咱們的緩存容量爲 3,仍是用剛剛那組數據進行演示。

咱們把頻次定義爲 freq,那麼前三次訪問結束後,即這三個請求訪問結束後:

鏈表裏面應該是這樣的:

三個元素的訪問頻次都是 1。

對於前三個元素來講,value=a 是頻次相同的狀況下,最久沒有被訪問到的元素,因此它就是 head 節點的下一個元素,隨時等着被淘汰。

接着過來了 1 個 value=a 的請求:

當這個請求過來的時候,鏈表中的 value=a 的節點的頻率(freq)就變成了2。

此時,它的頻率最高,最不該該被淘汰。

所以,鏈表變成了下面這個樣子:

接着連續來了 3 個 value=a 的請求:

此時的鏈表變化就集中在 value=a 這個節點的頻率(freq)上:

接着,這個 b 請求過來了:

b 節點的 freq 從 1 變成了 2,節點的位置也發生了變化:

而後,c 請求過來:

你說這個時候會發生什麼事情?

鏈表中的 c 當前的訪問頻率是 1,當這個 c 請求過來後,那麼鏈表中的 c 的頻率就會變成 2。

你說巧不巧,此時,value=b 節點的頻率也是 2。

撞車了,那麼你說,這個時候怎麼辦?

前面說了:頻率同樣的時候,看時間。

value=c 的節點是正在被訪問的,因此要淘汰也應該淘汰以前被訪問的 value=b 的節點。

此時的鏈表,就應該是這樣的:

而後,最後一個請求過來了:

d 元素,以前沒有在鏈表裏面出現過,而此時鏈表的容量也滿了。

該進行淘汰了。

因而把 head 的下一個節點(value=b)淘汰,並把 value=d 的節點插入:

最終,全部請求完畢。

留在緩存中的是 d,c,a 這三個元素。

總體的流程就是這樣的:

固然,這裏只是展現了鏈表的變化。

其實咱們放的是 key-value 鍵值對。

因此應該還有一個 HashMap 來存儲 key 和鏈表節點的映射關係。

這個簡單,用腳趾頭都能想到,我也就不展開來講了。

按照上面這個思路,你慢慢的寫代碼,應該是能寫出來的。

上面這個雙鏈表的方案,就是扣着腦袋硬想,大部分人能直接想到的方案。

面試官要的確定是時間複雜度爲 O(1) 的解決方案。

如今的這個解決方案時間複雜度爲 O(N)。

O(1) 解法

若是咱們要拿出時間複雜度爲 O(1) 的解法,咱們就得細細的分析了,不能扣着腦袋硬想了。

先分析一下需求。

第一點:咱們須要根據 key 查詢其對應的 value。

用腳趾頭都能想到,用 HashMap 存儲 key-value 鍵值對。

查詢時間複雜度爲 O(1),知足。

第二點:每當咱們操做一個 key 的時候,不管是查詢仍是新增,都須要維護這個 key 的頻次,記做 freq。

由於咱們須要頻繁的操做 key 對應的 freq,也就是得在時間複雜度爲 O(1) 的狀況下,獲取到指定 key 的 freq。

來,請你大聲的告訴我,用什麼數據結構?

是否是還得再來一個 HashMap 存儲 key 和 freq 的對應關係?

第三點:若是緩存裏面放不下了,須要淘汰數據的時候,把 freq 最小的 key 刪除掉。

注意啊,上面這句話:[把 freq 最小的 key 刪除掉]。

freq 最小?

咱們怎麼知道哪一個 key 的 freq 最小呢?

前面說了,有一個 HashMap 存儲 key 和 freq 的對應關係。

固然咱們能夠遍歷這個 HashMap,來獲取到 freq 最小的 key。

可是啊,朋友們,遍歷出現了,那麼時間複雜度還會是 O(1) 嗎?

那怎麼辦呢?

注意啊,高潮來了,一學就廢,一點就破。

咱們能夠搞個變量來記錄這個最小的 freq 啊,記爲 minFreq,不就好了?

如今咱們有最小頻次(minFreq)了,須要獲取到這個最小頻次對應的 key,時間複雜度得爲 O(1)。

來,朋友,請你大聲的告訴我,你又想起了什麼數據結構?

是否是又想到了 HashMap?

好了,咱們如今有三個 HashMap 了,給你們介紹一下:

  • 一個存儲 key 和 value 的 HashMap,即HashMap<key,value>。
  • 一個存儲 key 和 freq 的 HashMap,即HashMap<key,freq>。
  • 一個存儲 freq 和 key 的 HashMap,即HashMap<freq,key>。

它們每一個都是各司其職,目的都是爲了時間複雜度爲 O(1)。

可是咱們能夠把前兩個 HashMap 合併一下。

咱們弄一個對象,對象裏面包含兩個屬性分別是value、freq。

假設這個對象叫作 Node,它就是這樣的,頻次默認爲 1:

class Node {
    int value;
    int freq = 1;
    //構造函數省略
}

那麼如今咱們就能夠把前面兩個 HashMap ,替換爲一個了,即 HashMap<key,Node>。

同理,咱們能夠在 Node 裏面再加入一個 key 屬性:

class Node {
    int key;
    int value;
    int freq = 1;
    //構造函數省略
}

由於 Node 裏面包含了 key,因此能夠把第三個 HashMap<freq,key> 替換爲 HashMap<freq,Node>。

到這一步,咱們還差了一個很是關鍵的信息沒有補全,就是下面這一個點。

第四點:可能有多個 key 具備相同的最小的 freq,此時移除這一批數據在緩存中待的時間最長的那個元素。

這個需求,咱們須要經過 freq 查找 Node,那麼操做的就是 HashMap<freq,Node> 這個哈希表。

上面說[多個 key 具備相同的最小的 freq],也就是說經過 minFreq ,是能夠查詢到多個 Node 的。

因此HashMap<freq,Node> 這個哈希表,應該是這樣的:

HashMap<freq,集合 >。

此時的問題就變成了:咱們應該用什麼集合來裝這個 Node 對象呢?

不慌,咱們先理一下這個集合須要知足什麼條件。

首先,須要刪除 Node 的時候。

由於這個集合裏面裝的是訪問頻次同樣的數據,那麼但願這批數據能有時序,這樣能夠快速的刪除待的時間最久的 Node。

有時序,能快速查找刪除待的時間最久的 key,你能想到什麼數據結構?

這不就是雙向鏈表嗎?

而後,須要訪問 Node 的時候。

一個 Node 被訪問,那麼它的頻次必然就會加一。

好比下面這個例子:

假設最小訪問頻次就是 5,而 5 對應了 3 個 Node 對象。

此時,我要訪問 value=b 的對象,那麼該對象就會從 key=5 的 value 中移走。

而後頻次加一,即 5+1=6。

加入到 key=6 的 value 集合中,變成下面這個樣子:

也就是說咱們得支持任意 node 的快速刪除。

咱們能夠針對上面的需求,自定義一個雙向鏈表。

可是在 Java 集合類中,有一個知足上面說的有序且支持快速刪除的條件的集合。

那就是 LinkedHashSet。

因此,HashMap<freq,集合 >,就是HashMap<freq,LinkedHashSet >。

總結一下。

咱們須要兩個 HashMap,分別是 HashMap<key,Node> 和 HashMap<freq,LinkedHashSet >。

而後還須要維護一個最小訪問頻次,minFreq。

哦,對了,還得來一個參數記錄緩存支持的最大容量,capacity。

沒了。

有的小夥伴確定要問了:你卻是給我一份代碼啊?

這些分析出來了,代碼本身慢慢就擼出來了。

思路清晰後再去寫代碼,就算面試的時候沒有寫出 bug free 的代碼,也基本上八九不離十了。

Dubbo 中的 LFU 算法

Dubbo 在 2.7.7 版本以後支持了 LFU 算法:

其源碼的位置是:org.apache.dubbo.common.utils.LFUCache

代碼不長,總共就 200 多行,和咱們上面說的 LFU 實現起來還有點不同。

你能夠看到它甚至沒有維護 minFreq。

可是這些都不重要,打個斷點調試一下很快就能分析出來做者的代碼思路。

重要的是,我在看 Dubbo 的 LFU 算法的時候發現了一個 bug。

不是指這個 LFU 算法實現上的 bug,算法實現我看了是沒有問題的。

bug 是 Dubbo 雖然加入了 LFU 緩存算法的實現,可是做爲使用者,卻不能使用。

問題出在哪裏呢?

我帶你瞅一眼。

源碼裏面告訴我這樣配置一下就可使用 lfu 的緩存策略:

可是,當我這樣配置,發起調用以後,是這樣的:

能夠看到當前請求的緩存策略確實是 lfu。

可是會拋出一個錯誤:

No such extension org.apache.dubbo.cache.CacheFactory by name lfu

沒有 lfu 這個策略。

這不是玩我嗎?

再看一下具體的緣由。

org.apache.dubbo.common.extension.ExtensionLoader#getExtensionClasses處只獲取到了 4 個緩存策略,並無咱們想要的 LFU:

因此,在這裏拋出了異常:

爲何沒有找到咱們想要的 LFU 呢?

那就的看你熟不熟悉 SPI 了。

在 SPI 文件中,確實沒有 lfu 的配置:

這就是 bug。

因此怎麼解決呢?

很是簡單,加上就完事了。

害,一不當心又給 Dubbo 貢獻了一行源碼。

最後說一句(求關注)

才疏學淺,不免會有紕漏,若是你發現了錯誤的地方,能夠在後臺提出來,我對其加以修改。

感謝您的閱讀,我堅持原創,十分歡迎並感謝您的關注。

我是 why,一個主要寫代碼,常常寫文章,偶爾拍視頻的程序猿。

相關文章
相關標籤/搜索