一、什么是 LRU 算法
就是一種緩存淘汰策略。
計算機的緩存容量有限,如果緩存滿了就要刪除一些內容,給新內容騰位置。但問題是,刪除哪些內容呢?我們肯定希望刪掉哪些沒什么用的緩存,而把有用的數據繼續留在緩存里,方便之后繼續使用。那么,什么樣的數據,我們判定為「有用的」的數據呢?
LRU 緩存淘汰算法就是一種常用策略。LRU 的全稱是 Least Recently Used,也就是說我們認為最近使用過的數據應該是是「有用的」,很久都沒用過的數據應該是無用的,內存滿了就優先刪那些很久沒用過的數據。
舉個簡單的例子,安卓手機都可以把軟件放到后台運行,比如我先后打開了「設置」「手機管家」「日歷」,那么現在他們在后台排列的順序是這樣的:

但是這時候如果我訪問了一下「設置」界面,那么「設置」就會被提前到第一個,變成這樣:

假設我的手機只允許我同時開 3 個應用程序,現在已經滿了。那么如果我新開了一個應用「時鍾」,就必須關閉一個應用為「時鍾」騰出一個位置,關那個呢?
按照 LRU 的策略,就關最底下的「手機管家」,因為那是最久未使用的,然后把新開的應用放到最上面:

現在你應該理解 LRU(Least Recently Used)策略了。當然還有其他緩存淘汰策略,比如不要按訪問的時序來淘汰,而是按訪問頻率(LFU 策略)來淘汰等等,各有應用場景。本文講解 LRU 算法策略。
二、LRU 算法描述
LRU 算法實際上是讓你設計數據結構:首先要接收一個 capacity 參數作為緩存的最大容量,然后實現兩個 API,一個是 put(key, val) 方法存入鍵值對,另一個是 get(key) 方法獲取 key 對應的 val,如果 key 不存在則返回 -1。
注意哦,get 和 put 方法必須都是 \(O(1)\) 的時間復雜度,我們舉個具體例子來看看 LRU 算法怎么工作。
/* 緩存容量為 2 */
LRUCache cache = new LRUCache(2);
// 你可以把 cache 理解成一個隊列
// 假設左邊是隊頭,右邊是隊尾
// 最近使用的排在隊頭,久未使用的排在隊尾
// 圓括號表示鍵值對 (key, val)
cache.put(1, 1);
// cache = [(1, 1)]
cache.put(2, 2);
// cache = [(2, 2), (1, 1)]
cache.get(1); // 返回 1
// cache = [(1, 1), (2, 2)]
// 解釋:因為最近訪問了鍵 1,所以提前至隊頭
// 返回鍵 1 對應的值 1
cache.put(3, 3);
// cache = [(3, 3), (1, 1)]
// 解釋:緩存容量已滿,需要刪除內容空出位置
// 優先刪除久未使用的數據,也就是隊尾的數據
// 然后把新的數據插入隊頭
cache.get(2); // 返回 -1 (未找到)
// cache = [(3, 3), (1, 1)]
// 解釋:cache 中不存在鍵為 2 的數據
cache.put(1, 4);
// cache = [(1, 4), (3, 3)]
// 解釋:鍵 1 已存在,把原始值 1 覆蓋為 4
// 不要忘了也要將鍵值對提前到隊頭
三、LRU 算法設計
分析上面的操作過程,要讓 put 和 get 方法的時間復雜度為 O(1),我們可以總結出 cache 這個數據結構必要的條件:查找快,插入快,刪除快,有順序之分。
因為顯然 cache 必須有順序之分,以區分最近使用的和久未使用的數據;而且我們要在 cache 中查找鍵是否已存在;如果容量滿了要刪除最后一個數據;每次訪問還要把數據插入到隊頭。
那么,什么數據結構同時符合上述條件呢?哈希表查找快,但是數據無固定順序;鏈表有順序之分,插入刪除快,但是查找慢。所以結合一下,形成一種新的數據結構:哈希鏈表。
LRU 緩存算法的核心數據結構就是哈希鏈表,雙向鏈表和哈希表的結合體。這個數據結構長這樣:

思想很簡單,就是借助哈希表賦予了鏈表快速查找的特性嘛:可以快速查找某個 key 是否存在緩存(鏈表)中,同時可以快速刪除、添加節點。回想剛才的例子,這種數據結構是不是完美解決了 LRU 緩存的需求?
也許讀者會問,為什么要是雙向鏈表,單鏈表行不行?另外,既然哈希表中已經存了 key,為什么鏈表中還要存鍵值對呢,只存值不就行了?
想的時候都是問題,只有做的時候才有答案。這樣設計的原因,必須等我們親自實現 LRU 算法之后才能理解,所以我們開始看代碼吧~
四、代碼實現
很多編程語言都有內置的哈希鏈表或者類似 LRU 功能的庫函數,但是為了幫大家理解算法的細節,我們用 Java 自己造輪子實現一遍 LRU 算法。
首先,我們把雙鏈表的節點類寫出來,為了簡化,key 和 val 都認為是 int 類型:
class Node {
public int key, val;
public Node next, prev;
public Node(int k, int v) {
this.key = k;
this.val = v;
}
}
然后依靠我們的 Node 類型構建一個雙鏈表,實現幾個需要的 API(這些操作的時間復雜度均為 \(O(1)\)):
class DoubleList {
// 在鏈表頭部添加節點 x,時間 O(1)
public void addFirst(Node x);
// 刪除鏈表中的 x 節點(x 一定存在)
// 由於是雙鏈表且給的是目標 Node 節點,時間 O(1)
public void remove(Node x);
// 刪除鏈表中最后一個節點,並返回該節點,時間 O(1)
public Node removeLast();
// 返回鏈表長度,時間 O(1)
public int size();
}
PS:這就是普通雙向鏈表的實現,為了讓讀者集中精力理解 LRU 算法的邏輯,就省略鏈表的具體代碼。
到這里就能回答剛才“為什么必須要用雙向鏈表”的問題了,因為我們需要刪除操作。刪除一個節點不光要得到該節點本身的指針,也需要操作其前驅節點的指針,而雙向鏈表才能支持直接查找前驅,保證操作的時間復雜度 \(O(1)\)。
有了雙向鏈表的實現,我們只需要在 LRU 算法中把它和哈希表結合起來即可。我們先把邏輯理清楚:
// key 映射到 Node(key, val)
HashMap<Integer, Node> map;
// Node(k1, v1) <-> Node(k2, v2)...
DoubleList cache;
int get(int key) {
if (key 不存在) {
return -1;
} else {
將數據 (key, val) 提到開頭;
return val;
}
}
void put(int key, int val) {
Node x = new Node(key, val);
if (key 已存在) {
把舊的數據刪除;
將新節點 x 插入到開頭;
} else {
if (cache 已滿) {
刪除鏈表的最后一個數據騰位置;
刪除 map 中映射到該數據的鍵;
}
將新節點 x 插入到開頭;
map 中新建 key 對新節點 x 的映射;
}
}
如果能夠看懂上述邏輯,翻譯成代碼就很容易理解了:
class LRUCache {
// key -> Node(key, val)
private HashMap<Integer, Node> map;
// Node(k1, v1) <-> Node(k2, v2)...
private DoubleList cache;
// 最大容量
private int cap;
public LRUCache(int capacity) {
this.cap = capacity;
map = new HashMap<>();
cache = new DoubleList();
}
public int get(int key) {
if (!map.containsKey(key))
return -1;
int val = map.get(key).val;
// 利用 put 方法把該數據提前
put(key, val);
return val;
}
public void put(int key, int val) {
// 先把新節點 x 做出來
Node x = new Node(key, val);
if (map.containsKey(key)) {
// 刪除舊的節點,新的插到頭部
cache.remove(map.get(key));
cache.addFirst(x);
// 更新 map 中對應的數據
map.put(key, x);
} else {
if (cap == cache.size()) {
// 刪除鏈表最后一個數據
Node last = cache.removeLast();
map.remove(last.key);
}
// 直接添加到頭部
cache.addFirst(x);
map.put(key, x);
}
}
}
這里就能回答之前的問答題“為什么要在鏈表中同時存儲 key 和 val,而不是只存儲 val”,注意這段代碼:
if (cap == cache.size()) {
// 刪除鏈表最后一個數據
Node last = cache.removeLast();
map.remove(last.key);
}
當緩存容量已滿,我們不僅僅要刪除最后一個 Node 節點,還要把 map 中映射到該節點的 key 同時刪除,而這個 key 只能由 Node 得到。如果 Node 結構中只存儲 val,那么我們就無法得知 key 是什么,就無法刪除 map 中的鍵,造成錯誤。
至此,你應該已經掌握 LRU 算法的思想和實現了,很容易犯錯的一點是:處理鏈表節點的同時不要忘了更新哈希表中對節點的映射。
我最近精心制作了一份電子書《labuladong的算法小抄》,分為【動態規划】【數據結構】【算法思維】【高頻面試】四個章節,共 60 多篇原創文章,絕對精品!限時開放下載,在我的公眾號 labuladong 后台回復關鍵詞【pdf】即可免費下載!
歡迎關注我的公眾號 labuladong,技術公眾號的清流,堅持原創,致力於把問題講清楚!