
LRU Cache 是面試最愛考的「設計題」,也是你每天都在受益但沒意識到的東西。
兩個結構湊出 O(1)
LRU Cache = HashMap(O(1) 查找)+ 雙向鏈表(O(1) 維護使用順序)。容量滿了淘汰最久沒用的。get 和 put 都是 O(1)。瀏覽器快取、CDN、資料庫、Redis 的記憶體淘汰策略,底層都有它。
運作方式
容量: 3
put(1) → [1]
put(2) → [2, 1]
put(3) → [3, 2, 1]
get(1) → [1, 3, 2] ← 1 被用到,移到最前面
put(4) → [4, 1, 3] ← 滿了,淘汰最久沒用的 2
get(2) → -1 ← 2 已經被淘汰了規則只有一條:每次存取都把該元素移到最前面,淘汰從最後面開始。
為什麼是 HashMap + 雙向鏈表?
先想想不用這組合會怎樣:
- 只用 Array?找元素 O(n),移動元素 O(n),全都慢
- 只用 HashMap?沒有順序概念,不知道誰最久沒用
- 只用鏈表?找元素 O(n)
- HashMap + 單向鏈表?找到元素了但刪不掉——因為要知道前一個節點才能刪,得再走一遍 O(n)
HashMap + 雙向鏈表才是正解:HashMap O(1) 找到節點,雙向鏈表 O(1) 刪除它(因為有 prev 指標),再 O(1) 插到頭部。
核心操作
int get(int key) {
if (!map.containsKey(key)) return -1;
Node node = map.get(key);
moveToHead(node); // 用到了就移到最前面
return node.value;
}
void put(int key, int value) {
if (map.containsKey(key)) {
Node node = map.get(key);
node.value = value;
moveToHead(node);
} else {
Node newNode = new Node(key, value);
map.put(key, newNode);
addToHead(newNode);
if (map.size() > capacity) {
Node tail = removeTail(); // 淘汰最久沒用的
map.remove(tail.key);
}
}
}上面 get/put 呼叫的 moveToHead、addToHead、removeTail,拆開來其實只是幾行指標接線。之所以每一個都是 O(1),關鍵就在雙向鏈表的 prev:
void addToHead(Node node) { // 接到 dummy head 後面
node.prev = head;
node.next = head.next;
head.next.prev = node;
head.next = node;
}
void removeNode(Node node) { // 前後兩鄰居直接牽手,把自己跳過
node.prev.next = node.next;
node.next.prev = node.prev;
}
void moveToHead(Node node) { // 「用到了」= 先拔掉再插回最前面
removeNode(node);
addToHead(node);
}
Node removeTail() { // 淘汰:拿 dummy tail 前一個開刀
Node node = tail.prev;
removeNode(node);
return node;
}removeNode 敢寫得這麼乾淨,全靠 node.prev 隨手可得——單向鏈表在這裡就得再從頭走一趟找前驅,O(1) 直接變 O(n),整個 LRU 就垮了。
一個小技巧:用虛擬的 head 和 tail 節點,避免處理邊界條件。這樣不管鏈表是空的還是只有一個元素,removeNode 裡的 node.prev.next 都不會踩到 null,操作邏輯全部統一。
🎬 互動視覺化:鏈結串列指標操作動畫 — LRU 的靈魂就是雙向鏈表的接線,看指標怎麼在 moveToHead、removeTail 時重連,比讀 code 更快抓到手感。
LRU vs LFU
| LRU | LFU | |
|---|---|---|
| 全名 | Least Recently Used | Least Frequently Used |
| 淘汰依據 | 最久沒用 | 使用次數最少 |
| 偏好 | 最近用過的 | 最常用的 |
LFU 的問題:某個元素早期被大量存取但後來不再使用,它的高頻率讓它霸占快取很久。LRU 比較「健忘」,但這種健忘反而在大部分場景更實用。
Redis 的預設淘汰策略也是 LRU 的近似版(allkeys-lru)。
Java 的偷吃步
LinkedHashMap<Integer, Integer> cache =
new LinkedHashMap<>(capacity, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > capacity;
}
};LinkedHashMap 的第三個參數設成 true 就是 access-order 模式——存取過的元素自動移到最後面。配合 removeEldestEntry 就是完整的 LRU。面試時先手寫,然後補充「Java 其實有 built-in」,加分。
LRU Cache 的人生哲學:不管你過去多重要,只要你最近不活躍,就會被淘汰。聽起來殘酷,但你的瀏覽器每天都在這麼做。
接下來往哪走
- Deque 雙端佇列 — 下一篇:另一個「兩端都要動」的資料結構
- 快取是什麼:為什麼你的 DB 遲早會逼你加一層 — LRU 是快取的淘汰策略,這篇講工程上為什麼需要快取這一層
- Redis 是什麼:很多人只拿它當快取,其實它是後端的瑞士刀 —
allkeys-lru這類淘汰策略在 Redis 裡怎麼用
