這是why哥的第 83 篇原創文章node
前幾天在某APP看到了這樣的一個討論:web
看到一個有點意思的評論:面試
LFU 是真的難,腦袋都給我摳疼了。算法
若是說 LRU 是 Easy 模式的話,那麼把中間的字母從 R(Recently) 變成 F(Frequently),即 LFU ,那就是 hard 模式了。apache
你不認識 Frequently 不要緊,畢竟這是一個英語專八的詞彙,我這個英語八級半的選手教你:緩存
因此 LFU 的全稱是Least Frequently Used,最不常用策略。數據結構
很明顯,強調的是使用頻率。mvc
而 LRU 算法的全稱是Least Recently Used。最近最少使用算法。編輯器
強調的是時間。函數
當統計的維度從時間變成了頻率以後,在算法實現上發生了什麼變化呢?
這個問題先按下不表,我先和以前寫過的 LRU 算法進行一個對比。
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 算法以前,讓我硬想,我能想到的方案也只能是下面這樣的:
由於既須要有頻次,又須要有時間順序。
咱們就能夠搞個鏈表,先按照頻次排序,頻次同樣的,再按照時間排序。
由於這個過程當中咱們須要刪除節點,因此爲了效率,咱們使用雙向鏈表。
仍是假設咱們的緩存容量爲 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) 的解法,咱們就得細細的分析了,不能扣着腦袋硬想了。
先分析一下需求。
第一點:咱們須要根據 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,分別是 HashMap<key,Node> 和 HashMap<freq,LinkedHashSet
而後還須要維護一個最小訪問頻次,minFreq。
哦,對了,還得來一個參數記錄緩存支持的最大容量,capacity。
沒了。
有的小夥伴確定要問了:你卻是給我一份代碼啊?
這些分析出來了,代碼本身慢慢就擼出來了。
思路清晰後再去寫代碼,就算面試的時候沒有寫出 bug free 的代碼,也基本上八九不離十了。
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,一個主要寫代碼,常常寫文章,偶爾拍視頻的程序猿。