Learning Objectives
- 說明快取為什麼要有淘汰策略,以及 LRU(淘汰最久沒用的)在做什麼
- 說明只用串列或只用雜湊表實作 LRU 時,哪一個操作會變成 O(n)
- 用雜湊表加上有頭尾哨兵節點的雙向鏈結串列實作 LRU,追蹤 get、put、淘汰時改了哪些指標
- 使用 Python 的 OrderedDict、Java 的 LinkedHashMap 做出 LRU 快取
- 列出程式最常做的操作,依操作成本選擇或組合資料結構
快取放不下時,要丟掉誰
快取(cache)是把「最近可能還會用到」的資料放在比較快的地方:瀏覽器把看過的圖片存在本機、 資料庫把常讀的資料頁留在記憶體、CPU 把剛讀過的記憶體內容放在晶片上。快取比原來的地方快,但容量有限, 放滿之後要放新資料,就得先丟掉一筆。決定丟掉誰的規則叫淘汰策略(eviction policy)。
LRU(least recently used,最近最少使用)的規則是:丟掉最久沒被用過的那一筆。 背後的想法是:剛用過的資料,很可能馬上又會用到;很久沒碰的,大概暫時用不到了。
LRU 快取要支援兩個操作,而且希望都是 O(1),因為快取就是為了快:
| 操作 | 做什麼 |
|---|---|
get(key) |
有這個 key 就回傳值(命中,hit),並把它記成「最近用過」;沒有就回傳 -1(沒命中,miss) |
put(key, val) |
放入或更新一筆,記成「最近用過」;快取滿了就先淘汰最久沒用的那一筆 |
這一課把前面學過的兩個結構組合起來完成它,最後整理整個資料結構軌:遇到一個問題,怎麼挑資料結構。
只用串列記順序:每次都要搬
先試直覺的做法:雜湊表 data 存值,另外用一個陣列 order 記使用的先後。get 查雜湊表很快,但「把 key 搬到最後面」要先一格一格找到它、再把後面的元素全部往前搬;淘汰第 0 格也一樣要搬。看 ▲ i 在 order 裡走了多少格。
只用一種結構都不夠
LRU 要同時做到兩件事:用 key 很快找到資料,以及很快調整與找出使用順序。
| 做法 | get 找資料 | 更新成「最近用過」 | 找出最久沒用的並淘汰 |
|---|---|---|---|
| 雜湊表+陣列記順序(上一個範例) | O(1) | O(n):找到再搬移 | O(n):拿掉第 0 格要搬全部 |
| 只用鏈結串列 | O(n):從頭找 key | O(1) | O(1) |
| 只用雜湊表,每筆記「上次使用時間」 | O(1) | O(1):改時間 | O(n):掃過全部找最小的時間 |
| 雜湊表+堆積(依時間排) | O(1) | O(log n):改了時間要調整堆積 | O(log n) |
每種結構都只擅長一半。解法是兩種一起用,各管一件事:
- 雜湊表:key → 節點。負責「用 key 找到資料」,O(1)
- 雙向鏈結串列:依使用順序串起所有節點,最前面最近用過,最後面最久沒用。負責「調整順序」與「找出最久沒用的」
關鍵在雜湊表存的不是值,而是串列裡那個節點的位置(Python 是物件參考、C++ 是指標、Java 是參考)。 拿到節點之後,從串列裡拿掉、再接到最前面,只要改幾個指標,不必在串列裡找。
還有兩個細節:
- 為什麼要雙向:從串列拿掉一個節點,要改「前一個節點的 next」。單向串列不知道前一個是誰,只能從頭找(第 06 課);雙向串列的
node.prev直接就是。 - 節點裡要存 key:淘汰時拿到的是串列尾端的節點,要把它從雜湊表刪掉,得知道它的 key 是什麼。
雙向串列加頭尾哨兵:拿掉與接到最前面
head、tail 是兩個不存資料的哨兵節點(sentinel)。有了它們,串列再空也至少有兩個節點,每一個真正的節點前後一定有鄰居,remove_node 和 add_front 不必特別處理「串列是空的」「拿掉的是第一個」這些情況。看 ⇄ 的變化:拿掉中間的 2 只改兩個指標,接到最前面改四個。
LRU 快取:雜湊表加雙向串列
容量 3。先放入 1、2、3(串列變成 3 ⇄ 2 ⇄ 1,1 最久沒用);get(1) 命中,1 搬到最前面;put(4, 40) 時快取滿了,淘汰尾端的 2。注意每一個操作都只改固定幾個指標、查固定幾次雜湊表,和快取裡有幾筆無關。put(3, 33) 是更新:3 本來就在,只改值並搬到最前面,不會淘汰任何人。
各語言的現成做法
實際寫程式時,Python 和 Java 都有「雜湊表+雙向串列」的現成容器,幾行就能做出 LRU。
Python:collections.OrderedDict 會記住 key 的順序。move_to_end(key) 把 key 搬到最後面,
popitem(last=False) 拿掉最前面的一筆(last 預設是 True,拿最後面的)。
from collections import OrderedDict
class LRUCache:
def __init__(self, capacity):
self.capacity = capacity
self.data = OrderedDict() # 依使用順序排:最前面最久沒用
def get(self, key):
if key not in self.data:
return -1
self.data.move_to_end(key) # 用過了,搬到最後面
return self.data[key]
def put(self, key, val):
if key in self.data:
self.data.move_to_end(key)
self.data[key] = val
if len(self.data) > self.capacity:
self.data.popitem(last=False) # 拿掉最前面(最久沒用)的
要快取的是「函數的回傳值」時,直接用 functools.lru_cache 裝飾器,它內部就是 LRU。
Java:LinkedHashMap 有一個建構子 LinkedHashMap(initialCapacity, loadFactor, accessOrder),accessOrder 設成 true,
走訪順序就是「最久沒存取的在前、最近存取的在後」;再覆寫 removeEldestEntry,每次放入新資料後,它會問要不要移除最舊的一筆。
import java.util.LinkedHashMap;
import java.util.Map;
class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
LRUCache(int capacity) {
super(16, 0.75f, true); // true:依存取順序排列
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity; // 超過容量就移除最久沒用的
}
}
使用時 cache.getOrDefault(key, -1) 取值、cache.put(key, val) 放入;get、put、getOrDefault 都算一次存取。
C++:標準函式庫沒有現成的,通常用 list(雙向鏈結串列)加 unordered_map 存「key → 串列裡的位置(迭代器)」。
list::splice 可以把一個節點搬到另一個位置,不必複製、也不會讓指向它的迭代器失效:
int capacity = 3;
list<pair<int, int>> items; // 最前面最近用過
unordered_map<int, list<pair<int, int>>::iterator> pos; // key → 在 items 裡的位置
int get(int key) {
auto it = pos.find(key);
if (it == pos.end()) return -1;
items.splice(items.begin(), items, it->second); // 搬到最前面,O(1)
return it->second->second;
}
void put(int key, int val) {
auto it = pos.find(key);
if (it != pos.end()) {
it->second->second = val;
items.splice(items.begin(), items, it->second);
return;
}
if ((int)items.size() == capacity) {
pos.erase(items.back().first); // 串列節點存著 key
items.pop_back();
}
items.push_front({key, val});
pos[key] = items.begin();
}
三種寫法的結構和前面自己做的完全相同:雜湊表找節點,雙向串列管順序。
想一想:LRU 快取
容量 2 的 LRU 快取依序執行:put(1)、put(2)、get(1)、put(3)。哪一個 key 被淘汰?
LRU 的串列節點為什麼除了值,還要存 key?
把 LRU 的雙向串列換成單向串列(只有 next),哪一個操作會變慢?
依操作挑資料結構
LRU 的解法示範了選資料結構的一般步驟:
- 列出程式要做哪些操作,以及各自多常做(LRU:用 key 查、更新順序、找最舊的)
- 找出每個操作最適合的結構,看它的成本(查表)
- 沒有一種結構能讓所有操作都夠快時,組合兩種,讓它們指向同一份資料
- 最後才考慮記憶體、常數、程式好不好寫、語言有沒有內建
整個資料結構軌學過的結構,依「要做什麼」整理如下(n 是元素個數;雜湊表是平均情況):
| 最常做的操作 | 選擇 | 成本 | 課 |
|---|---|---|---|
| 依位置存取第 i 個 | 陣列、動態陣列 | O(1) | 02、03 |
| 在尾端加入 | 動態陣列 | 攤銷 O(1) | 03 |
| 兩端加入、拿出(先進先出) | 環狀佇列、雙端佇列 | O(1) | 04 |
| 只在一端放、拿(後進先出) | 堆疊 | O(1) | 05 |
| 已經拿到節點,在旁邊插入、刪除 | 鏈結串列(雙向) | O(1) | 06 |
| 用 key 查、加入、刪除,不在乎順序 | 雜湊表 | O(1) | 08 |
| 用 key 查,還要依大小走訪、找範圍、找前一個後一個 | 平衡二元搜尋樹(AVL、紅黑樹) | O(log n) | 10、11、12 |
| 資料大到放在磁碟上,要盡量少讀幾次 | B 樹、B+ 樹 | O(log n),每次讀一個大節點 | 13 |
| 每次拿出最小(或最大)的 | 堆積(優先佇列) | 加入、拿出 O(log n),看最小 O(1) | 14 |
| 依前綴找字串、自動完成 | Trie | O(字串長度) | 15 |
| 表示誰和誰相連 | 鄰接串列(稀疏)、鄰接矩陣(稠密) | 列出鄰居 O(deg)、查邊 O(1) | 16 |
| 區間和、區間最小值,資料還會修改 | 線段樹、Fenwick 樹 | O(log n) | 17 |
| 用 key 查,還要知道誰最久沒用 | 雜湊表+雙向鏈結串列 | O(1) | 18 |
常見的組合還有:雜湊表+堆積(優先佇列裡的元素要能依 key 找到並修改優先順序)、 平衡樹+雜湊表(依 key 查的同時維持排名)。看到一個需求同時要求兩種很不一樣的操作時,先想想能不能讓兩個結構指向同一份資料。
情境題:挑一個資料結構
一個成績系統常常要回答「分數在 60 到 70 之間的有哪些學生」,也會不斷新增、刪除成績。最適合用哪一種結構存分數?
醫院急診依病情嚴重程度看診,隨時有新病人進來,每次叫號都叫目前最嚴重的。用哪一種結構?
手機輸入法要在使用者打出前幾個字母時,列出所有以它開頭的單字。用哪一種結構?
瀏覽器的「上一頁、下一頁」:按上一頁回到前一個頁面,按下一頁再回來;開新頁面時,下一頁的紀錄就清空。最自然的做法是?
實作:LRU 的 get 與淘汰
remove_node、add_front 和 put 的大部分已經寫好。請完成兩個地方:lru_get 命中時要把節點搬到最前面再回傳值;lru_put 在快取滿的時候要淘汰最久沒用的節點(從串列拿掉、從雜湊表刪掉,並印出「 淘汰 k」)。這一次容量是 2。如果 get 忘了搬動節點,被淘汰的會是錯的 key,輸出就會不同。
重點整理
- 快取容量有限,放滿時依淘汰策略丟掉一筆;LRU 丟掉最久沒用的
- 只用陣列記順序:更新順序與淘汰是 O(n);只用串列:找 key 是 O(n);只用雜湊表記時間:找最舊的是 O(n)
- 雜湊表(key → 節點)+雙向鏈結串列(依使用順序):get、put、淘汰都是 O(1)。串列要雙向,拿掉節點才不必找前一個;節點要存 key,淘汰時才能刪雜湊表
- 頭尾哨兵節點讓插入與刪除不必處理空串列與頭尾的特殊情況
- Python 用
OrderedDict(move_to_end、popitem(last=False))或functools.lru_cache;Java 用LinkedHashMap(容量, 0.75f, true)加removeEldestEntry;C++ 用list加unordered_map與splice - 選資料結構:先列出操作與頻率,查每種結構的成本;一種不夠就組合兩種,讓它們指向同一份資料
資料結構軌到這裡結束。十八課裡的每一種結構都是在「哪些操作要快、願意多付出多少空間」之間的一種取捨; 遇到新問題時,從要做的操作出發,你就能判斷該用哪一種,或怎麼把它們組合起來。演算法軌會繼續用這些結構解決排序、搜尋、最短路徑等問題。