上篇文章 帶你手寫LRU算法 寫了 LRU 緩存淘汰算法的實現方法,本文來寫另外一個著名的緩存淘汰算法:LFU 算法。git
LRU 算法的淘汰策略是 Least Recently Used,也就是每次淘汰那些最久沒被使用的數據;而 LFU 算法的淘汰策略是 Least Frequently Used,也就是每次淘汰那些使用次數最少的數據。面試
LRU 算法的核心數據結構是使用哈希鏈表 LinkedHashMap
,首先借助鏈表的有序性使得鏈表元素維持插入順序,同時藉助哈希映射的快速訪問能力使得咱們能夠在 O(1) 時間訪問鏈表的任意元素。算法
從實現難度上來講,LFU 算法的難度大於 LRU 算法,由於 LRU 算法至關於把數據按照時間排序,這個需求借助鏈表很天然就能實現,你一直從鏈表頭部加入元素的話,越靠近頭部的元素就是新的數據,越靠近尾部的元素就是舊的數據,咱們進行緩存淘汰的時候只要簡單地將尾部的元素淘汰掉就好了。緩存
而 LFU 算法至關因而把數據按照訪問頻次進行排序,這個需求恐怕沒有那麼簡單,並且還有一種狀況,若是多個數據擁有相同的訪問頻次,咱們就得刪除最先插入的那個數據。也就是說 LFU 算法是淘汰訪問頻次最低的數據,若是訪問頻次最低的數據有多條,須要淘汰最舊的數據。數據結構
因此說 LFU 算法是要複雜不少的,並且常常出如今面試中,由於 LFU 緩存淘汰算法在工程實踐中常用,也有多是應該 LRU 算法太簡單了。不過話說回來,這種著名的算法的套路都是固定的,關鍵是因爲邏輯較複雜,不容易寫出漂亮且沒有 bug 的代碼。app
那麼本文 labuladong 就帶你拆解 LFU 算法,自頂向下,逐步求精,就是解決複雜問題的不二法門。框架
要求你寫一個類,接受一個 capacity
參數,實現 get
和 put
方法:ide
class LFUCache { // 構造容量爲 capacity 的緩存 public LFUCache(int capacity) {} // 在緩存中查詢 key public int get(int key) {} // 將 key 和 val 存入緩存 public void put(int key, int val) {} }
get(key)
方法會去緩存中查詢鍵 key
,若是 key
存在,則返回 key
對應的 val
,不然返回 -1。函數
put(key, value)
方法插入或修改緩存。若是 key
已存在,則將它對應的值改成 val
;若是 key
不存在,則插入鍵值對 (key, val)
。this
當緩存達到容量 capacity
時,則應該在插入新的鍵值對以前,刪除使用頻次(後文用 freq
表示)最低的鍵值對。若是 freq
最低的鍵值對有多個,則刪除其中最舊的那個。
// 構造一個容量爲 2 的 LFU 緩存 LFUCache cache = new LFUCache(2); // 插入兩對 (key, val),對應的 freq 爲 1 cache.put(1, 10); cache.put(2, 20); // 查詢 key 爲 1 對應的 val // 返回 10,同時鍵 1 對應的 freq 變爲 2 cache.get(1); // 容量已滿,淘汰 freq 最小的鍵 2 // 插入鍵值對 (3, 30),對應的 freq 爲 1 cache.put(3, 30); // 鍵 2 已經被淘汰刪除,返回 -1 cache.get(2);
必定先從最簡單的開始,根據 LFU 算法的邏輯,咱們先列舉出算法執行過程當中的幾個顯而易見的事實:
PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,所有發佈在 labuladong的算法小抄,持續更新。建議收藏,按照個人文章順序刷題,掌握各類算法套路後投再入題海就如魚得水了。
一、調用 get(key)
方法時,要返回該 key
對應的 val
。
二、只要用 get
或者 put
方法訪問一次某個 key
,該 key
的 freq
就要加一。
三、若是在容量滿了的時候進行插入,則須要將 freq
最小的 key
刪除,若是最小的 freq
對應多個 key
,則刪除其中最舊的那一個。
好的,咱們但願可以在 O(1) 的時間內解決這些需求,可使用基本數據結構來逐個擊破:
一、使用一個 HashMap
存儲 key
到 val
的映射,就能夠快速計算 get(key)
。
HashMap<Integer, Integer> keyToVal;
二、使用一個 HashMap
存儲 key
到 freq
的映射,就能夠快速操做 key
對應的 freq
。
HashMap<Integer, Integer> keyToFreq;
三、這個需求應該是 LFU 算法的核心,因此咱們分開說。
3.一、首先,確定是須要 freq
到 key
的映射,用來找到 freq
最小的 key
。
3.二、將 freq
最小的 key
刪除,那你就得快速獲得當前全部 key
最小的 freq
是多少。想要時間複雜度 O(1) 的話,確定不能遍歷一遍去找,那就用一個變量 minFreq
來記錄當前最小的 freq
吧。
3.三、可能有多個 key
擁有相同的 freq
,因此 freq
對 key
是一對多的關係,即一個 freq
對應一個 key
的列表。
3.四、但願 freq
對應的 key
的列表是存在時序的,便於快速查找並刪除最舊的 key
。
3.五、但願可以快速刪除 key
列表中的任何一個 key
,由於若是頻次爲 freq
的某個 key
被訪問,那麼它的頻次就會變成 freq+1
,就應該從 freq
對應的 key
列表中刪除,加到 freq+1
對應的 key
的列表中。
HashMap<Integer, LinkedHashSet<Integer>> freqToKeys; int minFreq = 0;
介紹一下這個 LinkedHashSet
,它知足咱們 3.3,3.4,3.5 這幾個要求。你會發現普通的鏈表 LinkedList
可以知足 3.3,3.4 這兩個要求,可是因爲普通鏈表不能快速訪問鏈表中的某一個節點,因此沒法知足 3.5 的要求。
LinkedHashSet
顧名思義,是鏈表和哈希集合的結合體。鏈表不能快速訪問鏈表節點,可是插入元素具備時序;哈希集合中的元素無序,可是能夠對元素進行快速的訪問和刪除。
那麼,它倆結合起來就兼具了哈希集合和鏈表的特性,既能夠在 O(1) 時間內訪問或刪除其中的元素,又能夠保持插入的時序,高效實現 3.5 這個需求。
綜上,咱們能夠寫出 LFU 算法的基本數據結構:
class LFUCache { // key 到 val 的映射,咱們後文稱爲 KV 表 HashMap<Integer, Integer> keyToVal; // key 到 freq 的映射,咱們後文稱爲 KF 表 HashMap<Integer, Integer> keyToFreq; // freq 到 key 列表的映射,咱們後文稱爲 FK 表 HashMap<Integer, LinkedHashSet<Integer>> freqToKeys; // 記錄最小的頻次 int minFreq; // 記錄 LFU 緩存的最大容量 int cap; public LFUCache(int capacity) { keyToVal = new HashMap<>(); keyToFreq = new HashMap<>(); freqToKeys = new HashMap<>(); this.cap = capacity; this.minFreq = 0; } public int get(int key) {} public void put(int key, int val) {} }
LFU 的邏輯不難理解,可是寫代碼實現並不容易,由於你看咱們要維護 KV
表,KF
表,FK
表三個映射,特別容易出錯。對於這種狀況,labuladong 教你三個技巧:
一、不要企圖上來就實現算法的全部細節,而應該自頂向下,逐步求精,先寫清楚主函數的邏輯框架,而後再一步步實現細節。
二、搞清楚映射關係,若是咱們更新了某個 key
對應的 freq
,那麼就要同步修改 KF
表和 FK
表,這樣纔不會出問題。
三、畫圖,畫圖,畫圖,重要的話說三遍,把邏輯比較複雜的部分用流程圖畫出來,而後根據圖來寫代碼,能夠極大減小出錯的機率。
PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,所有發佈在 labuladong的算法小抄,持續更新。建議收藏,按照個人文章順序刷題,掌握各類算法套路後投再入題海就如魚得水了。
下面咱們先來實現 get(key)
方法,邏輯很簡單,返回 key
對應的 val
,而後增長 key
對應的 freq
:
public int get(int key) { if (!keyToVal.containsKey(key)) { return -1; } // 增長 key 對應的 freq increaseFreq(key); return keyToVal.get(key); }
增長 key
對應的 freq
是 LFU 算法的核心,因此咱們乾脆直接抽象成一個函數 increaseFreq
,這樣 get
方法看起來就簡潔清晰了對吧。
下面來實現 put(key, val)
方法,邏輯略微複雜,咱們直接畫個圖來看:
這圖就是隨手畫的,不是什麼正規的程序流程圖,可是算法邏輯一目瞭然,看圖能夠直接寫出 put
方法的邏輯:
public void put(int key, int val) { if (this.cap <= 0) return; /* 若 key 已存在,修改對應的 val 便可 */ if (keyToVal.containsKey(key)) { keyToVal.put(key, val); // key 對應的 freq 加一 increaseFreq(key); return; } /* key 不存在,須要插入 */ /* 容量已滿的話須要淘汰一個 freq 最小的 key */ if (this.cap <= keyToVal.size()) { removeMinFreqKey(); } /* 插入 key 和 val,對應的 freq 爲 1 */ // 插入 KV 表 keyToVal.put(key, val); // 插入 KF 表 keyToFreq.put(key, 1); // 插入 FK 表 freqToKeys.putIfAbsent(1, new LinkedHashSet<>()); freqToKeys.get(1).add(key); // 插入新 key 後最小的 freq 確定是 1 this.minFreq = 1; }
increaseFreq
和 removeMinFreqKey
方法是 LFU 算法的核心,咱們下面來看看怎麼藉助 KV
表,KF
表,FK
表這三個映射巧妙完成這兩個函數。
首先來實現 removeMinFreqKey
函數:
private void removeMinFreqKey() { // freq 最小的 key 列表 LinkedHashSet<Integer> keyList = freqToKeys.get(this.minFreq); // 其中最早被插入的那個 key 就是該被淘汰的 key int deletedKey = keyList.iterator().next(); /* 更新 FK 表 */ keyList.remove(deletedKey); if (keyList.isEmpty()) { freqToKeys.remove(this.minFreq); // 問:這裏須要更新 minFreq 的值嗎? } /* 更新 KV 表 */ keyToVal.remove(deletedKey); /* 更新 KF 表 */ keyToFreq.remove(deletedKey); }
刪除某個鍵 key
確定是要同時修改三個映射表的,藉助 minFreq
參數能夠從 FK
表中找到 freq
最小的 keyList
,根據時序,其中第一個元素就是要被淘汰的 deletedKey
,操做三個映射表刪除這個 key
便可。
PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,所有發佈在 labuladong的算法小抄,持續更新。建議收藏,按照個人文章順序刷題,掌握各類算法套路後投再入題海就如魚得水了。
可是有個細節問題,若是 keyList
中只有一個元素,那麼刪除以後 minFreq
對應的 key
列表就爲空了,也就是 minFreq
變量須要被更新。如何計算當前的 minFreq
是多少呢?
實際上沒辦法快速計算 minFreq
,只能線性遍歷 FK
表或者 KF
表來計算,這樣確定不能保證 O(1) 的時間複雜度。
可是,其實這裏不必更新 minFreq
變量,由於你想一想 removeMinFreqKey
這個函數是在何時調用?在 put
方法中插入新 key
時可能調用。而你回頭看 put
的代碼,插入新 key
時必定會把 minFreq
更新成 1,因此說即使這裏 minFreq
變了,咱們也不須要管它。
下面來實現 increaseFreq
函數:
private void increaseFreq(int key) { int freq = keyToFreq.get(key); /* 更新 KF 表 */ keyToFreq.put(key, freq + 1); /* 更新 FK 表 */ // 將 key 從 freq 對應的列表中刪除 freqToKeys.get(freq).remove(key); // 將 key 加入 freq + 1 對應的列表中 freqToKeys.putIfAbsent(freq + 1, new LinkedHashSet<>()); freqToKeys.get(freq + 1).add(key); // 若是 freq 對應的列表空了,移除這個 freq if (freqToKeys.get(freq).isEmpty()) { freqToKeys.remove(freq); // 若是這個 freq 剛好是 minFreq,更新 minFreq if (freq == this.minFreq) { this.minFreq++; } } }
更新某個 key
的 freq
確定會涉及 FK
表和 KF
表,因此咱們分別更新這兩個表就好了。
和以前相似,當 FK
表中 freq
對應的列表被刪空後,須要刪除 FK
表中 freq
這個映射。若是這個 freq
剛好是 minFreq
,說明 minFreq
變量須要更新。
能不能快速找到當前的 minFreq
呢?這裏是能夠的,由於咱們剛纔把 key
的 freq
加了 1 嘛,因此 minFreq
也加 1 就好了。
至此,通過層層拆解,LFU 算法就完成了。
_____________