如何實現LRU演算法?
1.什麼是LRU演算法?
LRU是一種快取淘汰機制策略。
計算機的快取容量有限,如果快取滿了就要刪除一些內容,給新的內容騰位置。但是要刪除哪些內容呢?我們肯定希望刪掉那些沒有用的快取,而把有用的資料繼續留在快取中,方便之後繼續使用。那麼,什麼樣的資料我們可以判定為有用的資料呢?
LRU快取淘汰演算法就是一種常用策略。LRU的全稱是Least Recently Used,也就是說我們認為最近使用過的資料應該是有用的,很久都沒用過的資料應該是無用的,快取滿了就優先刪除那些很久沒有用過的資料。
舉個簡單的例子,安卓手機都可以吧軟體放在後臺執行,比如我先後打開了“設定”、“手機管家”、“日曆”,那麼現在他們在後臺排列的順序是這樣的:
但是這時候如果我訪問了一下“設定”介面,那麼“設定”就會被提前到第一個,變成這樣:
假設我的手機只允許我同時開啟3個應用程式,現在已經滿了。那麼如果我新開了一個應用“時鐘”,就必須關閉一個應用為“時鐘”騰出一個位置,那麼關閉哪個呢?
按照LRU的策略,就關最底下的“手機管家”,因為那是最久未使用的,然後把新開的應用放到最上面:
現在你應該理解LRU策略了,當然還有其他快取策略,比如不要按訪問的時序來淘汰,而是按訪問頻率(LFU策略)來淘汰等等,各有應用場景。本文講解LRU演算法策略。
2、LRU演算法描述
LeetCode上有一道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
public void addFirst(Node x);
// 刪除連結串列中的 x 節點(x 一定存在)
public void remove(Node x);
// 刪除連結串列中最後一個節點,並返回該節點
public Node removeLast();
// 返回連結串列長度
public int size();
}
PS:這就是普通雙向連結串列的實現,為了讓讀者集中精力理解 LRU 演算法的邏輯,就省略連結串列的具體程式碼。
到這裡就能回答剛才“為什麼必須要用雙向連結串列”的問題了,因為我們需要刪除操作。刪除一個連結串列節點不光要得到該節點本身的指標,也需要操作其前驅節點的指標,而雙向連結串列才能支援直接查詢前驅,保證操作的時間複雜度 O(1)。
有了雙向連結串列的實現,我們只需要在 LRU 演算法中把它和雜湊表結合起來即可。我們先把邏輯理清楚:
如果能夠看懂上述邏輯,翻譯成程式碼就很容易理解了:
這裡就能回答之前的問題“為什麼要在連結串列中同時儲存 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 演算法的思想和實現了,很容易犯錯的一點是:處理連結串列節點的同時不要忘了更新雜湊表中對節點的對映