演算法就像搭樂高:帶你手擼 LFU 演算法
層層拆解,帶你手寫 LFU 演算法
讀完本文,你不僅學會了演算法套路,還可以順便去 LeetCode 上拿下如下題目:
-----------
上篇文章 帶你手寫LRU演算法 寫了 LRU 快取淘汰演算法的實現方法,本文來寫另一個著名的快取淘汰演算法:LFU 演算法。
LRU 演算法的淘汰策略是 Least Recently Used,也就是每次淘汰那些最久沒被使用的資料;而 LFU 演算法的淘汰策略是 Least Frequently Used,也就是每次淘汰那些使用次數最少的資料。
LRU 演算法的核心資料結構是使用雜湊連結串列 LinkedHashMap
,首先借助連結串列的有序性使得連結串列元素維持插入順序,同時藉助雜湊對映的快速訪問能力使得我們可以在 O(1) 時間訪問連結串列的任意元素。
從實現難度上來說,LFU 演算法的難度大於 LRU 演算法,因為 LRU 演算法相當於把資料按照時間排序,這個需求借助連結串列很自然就能實現,你一直從連結串列頭部加入元素的話,越靠近頭部的元素就是新的資料,越靠近尾部的元素就是舊的資料,我們進行快取淘汰的時候只要簡單地將尾部的元素淘汰掉就行了。
而 LFU 演算法相當於是把資料按照訪問頻次進行排序,這個需求恐怕沒有那麼簡單,而且還有一種情況,如果多個數據擁有相同的訪問頻次,我們就得刪除最早插入的那個資料。也就是說 LFU 演算法是淘汰訪問頻次最低的資料,如果訪問頻次最低的資料有多條,需要淘汰最舊的資料。
所以說 LFU 演算法是要複雜很多的,而且經常出現在面試中,因為 LFU 快取淘汰演算法在工程實踐中經常使用,也有可能是應該 LRU 演算法太簡單了。不過話說回來,這種著名的演算法的套路都是固定的,關鍵是由於邏輯較複雜,不容易寫出漂亮且沒有 bug 的程式碼
那麼本文 labuladong 就帶你拆解 LFU 演算法,自頂向下,逐步求精,就是解決複雜問題的不二法門。
PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的演算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種演算法套路後投再入題海就如魚得水了。
一、演算法描述
要求你寫一個類,接受一個 capacity
引數,實現 get
和 put
方法:
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)
。
當快取達到容量 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 演算法的邏輯,我們先列舉出演算法執行過程中的幾個顯而易見的事實:
1、呼叫 get(key)
方法時,要返回該 key
對應的 val
。
2、只要用 get
或者 put
方法訪問一次某個 key
,該 key
的 freq
就要加一。
3、如果在容量滿了的時候進行插入,則需要將 freq
最小的 key
刪除,如果最小的 freq
對應多個 key
,則刪除其中最舊的那一個。
好的,我們希望能夠在 O(1) 的時間內解決這些需求,可以使用基本資料結構來逐個擊破:
1、使用一個 HashMap
儲存 key
到 val
的對映,就可以快速計算 get(key)
。
HashMap<Integer, Integer> keyToVal;
2、使用一個 HashMap
儲存 key
到 freq
的對映,就可以快速操作 key
對應的 freq
。
HashMap<Integer, Integer> keyToFreq;
3、這個需求應該是 LFU 演算法的核心,所以我們分開說。
3.1、首先,肯定是需要 freq
到 key
的對映,用來找到 freq
最小的 key
。
3.2、將 freq
最小的 key
刪除,那你就得快速得到當前所有 key
最小的 freq
是多少。想要時間複雜度 O(1) 的話,肯定不能遍歷一遍去找,那就用一個變數 minFreq
來記錄當前最小的 freq
吧。
3.3、可能有多個 key
擁有相同的 freq
,所以 freq
對 key
是一對多的關係,即一個 freq
對應一個 key
的列表。
3.4、希望 freq
對應的 key
的列表是存在時序的,便於快速查詢並刪除最舊的 key
。
3.5、希望能夠快速刪除 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 這個需求。
PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的演算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種演算法套路後投再入題海就如魚得水了。
綜上,我們可以寫出 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 教你三個技巧:
1、不要企圖上來就實現演算法的所有細節,而應該自頂向下,逐步求精,先寫清楚主函式的邏輯框架,然後再一步步實現細節。
2、搞清楚對映關係,如果我們更新了某個 key
對應的 freq
,那麼就要同步修改 KF
表和 FK
表,這樣才不會出問題。
3、畫圖,畫圖,畫圖,重要的話說三遍,把邏輯比較複雜的部分用流程圖畫出來,然後根據圖來寫程式碼,可以極大減少出錯的概率。
下面我們先來實現 get(key)
方法,邏輯很簡單,返回 key
對應的 val
,然後增加 key
對應的 freq
:
相關推薦:
_____________
本文只能在 labuladong 公眾號檢視,關注後可直接搜尋本站內容: