設計資料結構-LRU快取演算法
LRU快取演算法
力扣第 146 題「LRU快取機制」就是讓你設計資料結構:
首先要接收一個
capacity
引數作為快取的最大容量,然後實現兩個 API,一個是put(key, val)
方法存入鍵值對,另一個是get(key)
方法獲取key
對應的val
,如果key
不存在則返回 -1。
get
和put
方法必須都是O(1)
的時間複雜度
要讓 put
和 get
方法的時間複雜度為 O(1),我們可以總結出 cache
這個資料結構必要的條件:
cache
中的元素必須有時序,以區分最近使用的和久未使用的資料,當容量滿了之後要刪除最久未使用的那個元素騰位置。- 我們要在
cache
key
是否已存在並得到對應的val
; - 每次訪問
cache
中的某個key
,需要將這個元素變為最近使用的,也就是說cache
要支援在任意位置快速插入和刪除元素。
雜湊表查詢快,但是資料無固定順序;連結串列有順序之分,插入刪除快,但是查詢慢。所以結合一下,形成一種新的資料結構:雜湊連結串列 LinkedHashMap
。
LRU 快取演算法的核心資料結構就是雜湊連結串列,雙向連結串列和雜湊表的結合體。這個資料結構長這樣:
來逐一分析上面的 3 個條件:
- 如果我們每次預設從連結串列尾部新增元素,那麼顯然越靠尾部的元素就是最近使用的,越靠頭部的元素就是最久未使用的。
- 對於某一個
key
val
。 - 連結串列顯然是支援在任意位置快速插入和刪除的,改改指標就行。只不過傳統的連結串列無法按照索引快速訪問某一個位置的元素,而這裡藉助雜湊表,可以通過
key
快速對映到任意一個連結串列節點,然後進行插入和刪除。
這裡可以通過hashmap的key來快速找到對應的node節點,所以就能快速的插入刪除了。
先自己造輪子實現一遍 LRU 演算法
首先,把雙鏈表的節點類寫出來:
class Node { public int key, val; public Node next, prev; public Node(int k, int v) { this.key = k; this.val = v; } }
然後依靠我們的 Node
型別構建一個雙鏈表,實現幾個 LRU 演算法必須的 API:
class DoubleList {
// 頭尾虛節點
private Node head, tail;
// 連結串列元素數
private int size;
public DoubleList() {
// 初始化雙向連結串列的資料
head = new Node(0, 0);
tail = new Node(0, 0);
head.next = tail;
tail.prev = head;
size = 0;
}
// 在連結串列尾部新增節點 x,時間 O(1)
public void addLast(Node x) {
x.prev = tail.prev;
x.next = tail;
tail.prev.next = x;
tail.prev = x;
size++;
}
// 刪除連結串列中的 x 節點(x 一定存在)
// 由於是雙鏈表且給的是目標 Node 節點,時間 O(1)
public void remove(Node x) {
x.prev.next = x.next;
x.next.prev = x.prev;
size--;
}
// 刪除連結串列中第一個節點,並返回該節點,時間 O(1)
public Node removeFirst() {
if (head.next == tail)
return null;
Node first = head.next;
remove(first);
return first;
}
// 返回連結串列長度,時間 O(1)
public int size() { return size; }
}
到這裡就能回答剛才「為什麼必須要用雙向連結串列」的問題了,因為我們需要刪除操作。刪除一個節點不光要得到該節點本身的指標,也需要操作其前驅節點的指標,而雙向連結串列才能支援直接查詢前驅,保證操作的時間複雜度 O(1)。
有了雙向連結串列的實現,我們只需要在 LRU 演算法中把它和雜湊表結合起來即可,先搭出程式碼框架:
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();
}
由於我們要同時維護一個雙鏈表 cache
和一個雜湊表 map
,很容易漏掉一些操作,比如說刪除某個 key
時,在 cache
中刪除了對應的 Node
,但是卻忘記在 map
中刪除 key
。
解決這種問題的有效方法是:在這兩種資料結構之上提供一層抽象 API。
儘量讓 LRU 的主方法 get
和 put
避免直接操作 map
和 cache
的細節。我們可以先實現下面幾個函式:
/* 將某個 key 提升為最近使用的 */
private void makeRecently(int key) {
Node x = map.get(key);
// 先從連結串列中刪除這個節點
cache.remove(x);
// 重新插到隊尾
cache.addLast(x);
}
/* 新增最近使用的元素 */
private void addRecently(int key, int val) {
Node x = new Node(key, val);
// 連結串列尾部就是最近使用的元素
cache.addLast(x);
// 別忘了在 map 中新增 key 的對映
map.put(key, x);
}
/* 刪除某一個 key */
private void deleteKey(int key) {
Node x = map.get(key);
// 從連結串列中刪除
cache.remove(x);
// 從 map 中刪除
map.remove(key);
}
/* 刪除最久未使用的元素 */
private void removeLeastRecently() {
// 連結串列頭部的第一個元素就是最久未使用的
Node deletedNode = cache.removeFirst();
// 同時別忘了從 map 中刪除它的 key
int deletedKey = deletedNode.key;
map.remove(deletedKey);
}
這裡就能回答之前的問答題「為什麼要在連結串列中同時儲存 key 和 val,而不是隻儲存 val」,注意 removeLeastRecently
函式中,我們需要用 deletedNode
得到 deletedKey
。
先來實現 LRU 演算法的 get
方法:
public int get(int key) {
if (!map.containsKey(key)) {
return -1;
}
// 將該資料提升為最近使用的
makeRecently(key);
return map.get(key).val;
}
put
方法稍微複雜一些,我們先來畫個圖搞清楚它的邏輯:
寫出 put
方法的程式碼:
public void put(int key, int val) {
if (map.containsKey(key)) {
// 刪除舊的資料
deleteKey(key);
// 新插入的資料為最近使用的資料
addRecently(key, val);
return;
}
if (cap == cache.size()) {
// 刪除最久未使用的元素
removeLeastRecently();
}
// 新增為最近使用的元素
addRecently(key, val);
}
最後用 Java 的內建型別 `LinkedHashMap` 來實現 LRU 演算法,邏輯和之前完全一致:
class LRUCache {
int cap;
LinkedHashMap<Integer, Integer> cache = new LinkedHashMap<>();
public LRUCache(int capacity) {
this.cap = capacity;
}
public int get(int key) {
if (!cache.containsKey(key)) {
return -1;
}
// 將 key 變為最近使用
makeRecently(key);
return cache.get(key);
}
public void put(int key, int val) {
if (cache.containsKey(key)) {
// 修改 key 的值
cache.put(key, val);
// 將 key 變為最近使用
makeRecently(key);
return;
}
if (cache.size() >= this.cap) {
// 連結串列頭部就是最久未使用的 key
int oldestKey = cache.keySet().iterator().next();
cache.remove(oldestKey);
}
// 將新的 key 新增連結串列尾部
cache.put(key, val);
}
private void makeRecently(int key) {
int val = cache.get(key);
// 刪除 key,重新插入到隊尾
cache.remove(key);
cache.put(key, val);
}
}