cv.cm

編碼 · 資料結構

LRU 快取教學:原理、程式碼與時間複雜度

閱讀約 7 分鐘

簡答

LRU(least recently used,最近最少使用)快取只保存固定數量的項目,滿了之後就會淘汰最久沒被使用的那一個。經典的設計是結合雜湊表與雙向鏈結串列:雜湊表能在 O(1) 內找到任何鍵,雙向鏈結串列則依最近使用的順序排列,能在 O(1) 內移動或移除任何項目。在 Python 中,collections.OrderedDict 只要幾行就能做出 LRU 快取,而 functools.lru_cache 則可為任何函式加上快取。

LRU 快取解決什麼問題

快取會保存緩慢操作的結果,例如資料庫查詢、網路請求或昂貴的運算,讓重複的請求能快速得到回應。記憶體有限,所以快取在滿了之後必須決定要丟掉什麼。LRU 賭的是:最近用過的資料很快會再被用到,而很久沒碰的資料大概不會。這個賭注對許多真實的工作負載都很有效,這也是為什麼 CPU 快取、作業系統的分頁快取、資料庫、網頁伺服器與 CDN 中都能看到 LRU 及其近似做法。

概念:兩個結構合作

我們需要兩件事都很快:找到某個鍵,以及知道哪個鍵是最近最少使用的。只有雜湊表沒有順序,只有串列則搜尋太慢。兩者結合就能兩件事都做到:

組成部分職責為什麼是 O(1)
雜湊表把每個鍵對應到它在串列中的節點平均情況下的雜湊查找
雙向鏈結串列讓節點由最近使用到最久未使用依序排列用 prev 與 next 指標解開一個節點只需要改動四個參考
頭端最近使用的項目新增與被存取的項目都會移到這裡
尾端最近最少使用的項目快取滿了時,從這裡淘汰

執行 get(key) 時,先在雜湊表中找到節點,把它移到最前面,再回傳它的值。執行 put(key, value) 時,更新或建立最前面的節點;如果此時快取超過容量,就移除最後面的節點,並從雜湊表中刪除它的鍵。

容量為 2 的實例演練

操作結果之後的順序(最近使用的在前)備註
put(1, "a")-1
put(2, "b")-2, 1快取已滿
get(1)"a"1, 21 變成最近使用
put(3, "c")-3, 1淘汰 2,也就是最近最少使用的
get(2)未命中3, 12 已被淘汰
put(1, "A")-1, 3更新會把 1 移到最前面
put(4, "d")-4, 1淘汰 3

從頭開始的 Python 實作

哨兵頭節點與尾節點讓空串列不再有特殊情況:每個真正的節點前後永遠都有節點。

class Node:
    __slots__ = ("key", "value", "prev", "next")

    def __init__(self, key=None, value=None):
        self.key, self.value = key, value
        self.prev = self.next = None

class LRUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.map = {}
        self.head, self.tail = Node(), Node()   # sentinels
        self.head.next, self.tail.prev = self.tail, self.head

    def _unlink(self, node):
        node.prev.next, node.next.prev = node.next, node.prev

    def _push_front(self, node):
        node.prev, node.next = self.head, self.head.next
        self.head.next.prev = node
        self.head.next = node

    def get(self, key):
        node = self.map.get(key)
        if node is None:
            return None
        self._unlink(node)
        self._push_front(node)
        return node.value

    def put(self, key, value):
        if key in self.map:
            self._unlink(self.map[key])
        node = self.map[key] = Node(key, value)
        self._push_front(node)
        if len(self.map) > self.capacity:
            lru = self.tail.prev
            self._unlink(lru)
            del self.map[lru.key]
cache = LRUCache(2)
cache.put(1, "a")
cache.put(2, "b")
print(cache.get(1))   # a
cache.put(3, "c")     # evicts key 2
print(cache.get(2))   # None
cache.put(1, "A")
cache.put(4, "d")     # evicts key 3
print(cache.get(3), cache.get(1), cache.get(4))  # None A d

使用 OrderedDict 的簡短版本

collections.OrderedDict 本身就是雜湊表加雙向鏈結串列,而 move_to_end 與 popitem(last=False) 都是 O(1)。因此當你需要一個 LRU 快取物件時,這是 Python 中慣用的選擇。

from collections import OrderedDict

class LRU:
    def __init__(self, capacity):
        self.capacity = capacity
        self.data = OrderedDict()

    def get(self, key):
        if key not in self.data:
            return None
        self.data.move_to_end(key)          # mark as most recent
        return self.data[key]

    def put(self, key, value):
        self.data[key] = value
        self.data.move_to_end(key)
        if len(self.data) > self.capacity:
            self.data.popitem(last=False)   # drop least recent

c = LRU(2)
c.put("x", 1)
c.put("y", 2)
c.get("x")
c.put("z", 3)
print(list(c.data))  # ['x', 'z']

用 functools.lru_cache 快取函式結果

要快取函式的回傳值,請使用 functools.lru_cache 裝飾器。引數必須是可雜湊的,因為它們會變成字典的鍵。cache_info() 會顯示命中次數、未命中次數與目前的大小。

from functools import lru_cache

@lru_cache(maxsize=128)
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)

print(fib(80))           # 23416728348467685
print(fib.cache_info())
# CacheInfo(hits=78, misses=81, maxsize=128, currsize=81)

maxsize=None 代表沒有上限的快取,這正是 functools.cache(Python 3.9 及之後)所做的事。呼叫 fib.cache_clear() 可清空快取。

時間與空間複雜度

操作時間原因
get平均 O(1)雜湊查找,加上解開並重新連接一個節點
put(插入或更新)平均 O(1)雜湊插入,加上常數次指標變更
淘汰O(1)最近最少使用的節點永遠緊鄰尾端哨兵節點
空間O(容量)每個被快取的項目一筆雜湊表項目和一個串列節點

變體、常見錯誤與何時使用 LRU

  • LFU(least frequently used,最不常使用)會淘汰存取次數最少的項目;它能保護熱門項目不受一次性掃描的影響,但實作較複雜。
  • 近似 LRU:像 Redis 這類系統會抽樣幾個鍵,並淘汰其中最舊的,而不是維護精確的串列,這樣可以節省記憶體。
  • TTL 快取會在固定時間後讓項目過期,通常會搭配大小限制一起使用。
  • 忘了在 get 時,或對既有的鍵執行 put 時,把項目移到最前面,會讓 LRU 變成 FIFO。
  • 忘了從雜湊表中刪除被淘汰的鍵,會留下懸空的項目並造成記憶體洩漏。
  • 在多個執行緒之間共用 LRU 快取時,每個操作都需要加鎖;functools.lru_cache 則可以安全地從多個執行緒呼叫。

當近期的使用能預測未來的使用時,LRU 是很好的預設選擇。當大量一次性掃描把常用資料擠出去時,它的表現就不好;這類工作負載更適合 LFU 或抗掃描的策略。設計出每個操作都是 O(1) 的 LRU 快取,也是最常見的程式面試題之一,因為它同時考驗雜湊表與鏈結串列。

常見問題

LRU 是什麼的縮寫?

Least recently used,最近最少使用。快取滿了時,它會淘汰最久沒有被讀取或寫入的項目。

為什麼 LRU 快取需要雙向鏈結串列?

要在 O(1) 內從串列中間移除一個節點,你需要知道它的前一個節點。雙向鏈結串列在每個節點上都存有 prev;單向鏈結串列則需要 O(n) 的搜尋。

只用 Python 的 dict 就能實作 LRU 快取嗎?

從 Python 3.7 起,dict 會保留插入順序,所以你可以刪除再重新插入某個鍵來標記它為最近使用,並淘汰 next(iter(d))。OrderedDict 搭配 move_to_end 與 popitem(last=False),更能清楚表達意圖。

functools 中的 lru_cache 和 cache 有什麼差別?

functools.cache 是沒有上限的快取,等同於 lru_cache(maxsize=None)。指定數字的 lru_cache 則會在達到該大小後,淘汰最近最少使用的結果。

LRU 永遠是最佳的淘汰策略嗎?

不是。它簡單,而且在近期項目很可能被重複使用時效果很好,但大量的循序掃描可能會沖掉有用的資料。LFU、ARC 等其他策略更能處理這類工作負載。

全部編碼教學