cover

LRU Cache 是面試最愛考的「設計題」,也是你每天都在受益但沒意識到的東西。

先講結論

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.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);
        }
    }
}

一個小技巧:用虛擬的 head 和 tail 節點,避免處理邊界條件。這樣不管鏈表是空的還是只有一個元素,操作邏輯都一樣。

LRU vs LFU

LRULFU
全名Least Recently UsedLeast 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 的人生哲學:不管你過去多重要,只要你最近不活躍,就會被淘汰。聽起來殘酷,但你的瀏覽器每天都在這麼做。