146. LRU Cache
題目 / Problem
中文: 設計一個遵循「最近最少使用」(LRU)淘汰策略的資料結構。實作 LRUCache 類別:
LRUCache(int capacity):以正整數容量capacity初始化快取。int get(int key):如果key存在就回傳它的值,否則回傳-1。void put(int key, int value):如果key已存在就更新它的值;否則新增這個鍵值對。如果加入後鍵的數量超過容量,就淘汰「最近最少使用」的那個鍵。
get 和 put 兩個操作都必須平均以 O(1) 時間完成。
English: Design a data structure that follows the Least Recently Used (LRU) eviction policy. Implement the LRUCache class:
LRUCache(int capacity): initialize the cache with a positivecapacity.int get(int key): return the value ofkeyif it exists, otherwise return-1.void put(int key, int value): update the value ifkeyexists; otherwise insert the pair. If the number of keys exceedscapacityafter insertion, evict the least recently used key.
Both get and put must run in O(1) average time.
Constraints / 限制:
- 1 <= capacity <= 3000
- 0 <= key <= 10^4
- 0 <= value <= 10^5
- At most 2 * 10^5 calls to get and put.
Worked example / 範例:
LRUCache cache = new LRUCache(2); // capacity = 2
cache.put(1, 1); // cache: {1=1}
cache.put(2, 2); // cache: {1=1, 2=2}
cache.get(1); // returns 1 -> now 1 is most recent
cache.put(3, 3); // capacity full, evict LRU (key 2) -> {1=1, 3=3}
cache.get(2); // returns -1 (evicted)
cache.put(4, 4); // evict LRU (key 1) -> {3=3, 4=4}
cache.get(1); // returns -1
cache.get(3); // returns 3
cache.get(4); // returns 4
名詞解釋 / Glossary
- 快取 / cache:一塊容量有限的儲存空間,用來暫存資料以便快速讀取。滿了就必須丟掉一些舊資料。 / A limited-size store that keeps data for fast access; when full, something must be thrown out.
- LRU(最近最少使用)/ Least Recently Used:一種淘汰策略——當空間不夠時,丟掉「最久沒被碰過」的那筆資料。每次
get或put都算「碰過一次」。 / An eviction policy that discards the item untouched for the longest time. Everygetorputcounts as "touching" an item. - 雜湊表 / hash map(C 用陣列、C++ 用
unordered_map):一種能用「鍵」在 O(1) 平均時間找到「值」的表格。 / A table that maps a key to a value in O(1) average time. - 雙向鏈結串列 / doubly-linked list:由節點串成的一條鏈,每個節點同時記住「前一個」和「後一個」。因為兩邊都知道,所以能在 O(1) 時間把任一節點抽掉或插入。 / A chain of nodes where each node knows both its previous and next node, so any node can be removed or inserted in O(1) time.
- 虛擬頭尾節點 / dummy head & tail (sentinel nodes):兩個不存資料的假節點,永遠放在串列的最前和最後。有了它們,插入/刪除時就不必特別處理「串列是空的」或「操作到端點」的情況。 / Two fake nodes with no real data, always kept at the front and back, so insert/remove code never needs special cases for empty lists or endpoints.
- O(1) 時間 / O(1) time:不管資料多大,操作花的時間都固定,不會變慢。 / Constant time: the operation cost does not grow with the amount of data.
- 指標 / pointer(C):一個變數,裡面存的是「另一個東西的記憶體位址」,而不是東西本身。用
->可以透過指標存取結構的欄位。 / A variable holding the memory address of something else;->accesses a struct field through a pointer.
思路
最直覺的暴力想法是用一個陣列或串列存所有鍵值對,並額外記錄每筆資料「上次被使用的時間」。get 時線性掃過去找鍵;put 且空間滿時,線性掃過去找出時間最舊的那筆來淘汰。問題是:查找和淘汰都是 O(n),當呼叫次數高達 2×10⁵ 時會太慢,也違反題目要求的 O(1)。
要達到 O(1),我們需要拆成兩個子問題:(1)用鍵快速找到值——這是雜湊表最擅長的;(2)快速知道「誰最久沒用」並能瞬間把任一筆搬到「最新」——這需要一個能 O(1) 插入/刪除任意位置的結構,也就是雙向鏈結串列。關鍵洞見是把兩者結合:雜湊表的「值」不直接存資料,而是存「指向鏈結串列中對應節點的指標」。這樣雜湊表負責 O(1) 定位節點,鏈結串列負責 O(1) 維護使用順序。
我們讓串列維持一個不變量(invariant):越靠近頭部的節點越「新」,越靠近尾部的越「舊」。於是淘汰時只要拿掉尾部節點即可。為了讓插入與刪除完全不必判斷邊界,我們在串列兩端各放一個虛擬頭節點與虛擬尾節點(sentinel),真正的資料節點永遠夾在它們之間。get(key):用雜湊表找到節點,回傳其值,並把該節點移到頭部(表示剛用過)。put(key,value):若鍵存在,更新值並移到頭部;若不存在,建立新節點插到頭部並登記進雜湊表,然後如果超出容量,就刪掉尾部前一個真正的節點,並把它從雜湊表移除。每一步都是常數次指標操作與一次雜湊表存取,因此平均 O(1)。
The brute-force idea is to store all pairs in an array and stamp each with a "last used" time: get scans linearly to find the key, and put scans linearly to find the oldest entry to evict. Both are O(n), which is too slow for up to 2×10⁵ calls and violates the required O(1).
To hit O(1) we split the job in two: (1) find a value by its key fast — that is exactly what a hash map does; and (2) know instantly who is least recently used and move any entry to "most recent" instantly — that needs a structure allowing O(1) insertion/removal anywhere, i.e. a doubly-linked list. The key trick is to combine them: the hash map does not store the value directly, it stores a pointer to the list node that holds the data. The map gives O(1) lookup of a node; the list gives O(1) reordering.
We maintain the invariant that nodes nearer the head are more recently used and nodes nearer the tail are older, so eviction is simply "remove the node just before the tail." To avoid any edge-case checks for empty lists or endpoints, we place a dummy head and dummy tail node at the two ends, with real nodes always sandwiched between them. get(key): locate the node via the map, return its value, and move the node to the front. put(key,value): if the key exists, update the value and move it to the front; otherwise create a new node at the front and register it in the map, then if capacity is exceeded, unlink the node before the tail and erase it from the map. Every step is a constant number of pointer updates plus one map access, so it is O(1) on average.
逐步走查 / Walkthrough
Tracing the sample with capacity = 2. The list is shown from head (most recent) to tail (least recent), between the dummy head H and dummy tail T.
| Step / 步驟 | Operation | Map (key→value) | List: H ↔ ... ↔ T | Result / 回傳 |
|---|---|---|---|---|
| 1 | put(1,1) |
{1:1} | H ↔ 1 ↔ T | — |
| 2 | put(2,2) |
{1:1, 2:2} | H ↔ 2 ↔ 1 ↔ T | — |
| 3 | get(1) |
{1:1, 2:2} | H ↔ 1 ↔ 2 ↔ T | 1 (node 1 moved to front / 節點 1 移到最前) |
| 4 | put(3,3) |
{1:1, 3:3} | H ↔ 3 ↔ 1 ↔ T | size would be 3 > 2, so evict tail-side node 2 / 淘汰最舊的 2 |
| 5 | get(2) |
{1:1, 3:3} | H ↔ 3 ↔ 1 ↔ T | -1 (2 was evicted / 2 已被淘汰) |
| 6 | put(4,4) |
{4:4, 3:3} | H ↔ 4 ↔ 3 ↔ T | size 3 > 2, evict LRU 1 / 淘汰 1 |
| 7 | get(1) |
{4:4, 3:3} | H ↔ 4 ↔ 3 ↔ T | -1 |
| 8 | get(3) |
{4:4, 3:3} | H ↔ 3 ↔ 4 ↔ T | 3 (node 3 to front / 節點 3 移到最前) |
| 9 | get(4) |
{4:4, 3:3} | H ↔ 4 ↔ 3 ↔ T | 4 (node 4 to front / 節點 4 移到最前) |
Notice at step 4 how the least-recently-used entry (key 2, sitting nearest the tail) is exactly the one evicted — that is the whole point of keeping the list ordered by recency.
Solution — C
// 演算法 / Algorithm:
// 雜湊表(以 key 為索引的陣列) + 雙向鏈結串列。
// Hash table (an array indexed by key) + doubly-linked list.
// 鏈結串列維持「頭=最新、尾=最舊」的順序;查找靠雜湊表 O(1),
// 移動與淘汰靠鏈結串列 O(1)。The list keeps head=newest, tail=oldest;
// the hash table gives O(1) lookup, the list gives O(1) move/evict.
#include <stdlib.h> // malloc / free / calloc
// 鏈結串列的節點 / a node of the doubly-linked list
typedef struct Node {
int key; // 存 key,淘汰時要用它從雜湊表移除 / needed to erase from the map on eviction
int value; // 這個 key 對應的值 / the value for this key
struct Node *prev; // 指向前一個節點 / pointer to previous node
struct Node *next; // 指向後一個節點 / pointer to next node
} Node;
// 因為 0 <= key <= 10^4,直接開一個大小 10001 的陣列當雜湊表最簡單
// Since 0 <= key <= 10^4, the simplest hash table is a plain array of size 10001.
#define KEY_RANGE 10001
typedef struct {
int capacity; // 最多能放幾個 key / max number of keys allowed
int size; // 目前放了幾個 key / current number of keys
Node **map; // map[key] = 指向該 key 節點的指標,沒有則為 NULL / pointer to that key's node, or NULL
Node *head; // 虛擬頭節點(不存資料) / dummy head (no real data)
Node *tail; // 虛擬尾節點(不存資料) / dummy tail (no real data)
} LRUCache;
// 把節點 n 從串列中「拆掉」:讓它的前後互相牽手,跳過 n
// Unlink node n from the list: connect its neighbours to each other, skipping n.
static void unlink_node(Node *n) {
n->prev->next = n->next; // 前一個的 next 直接指向後一個 / previous now points to next
n->next->prev = n->prev; // 後一個的 prev 直接指回前一個 / next now points back to previous
}
// 把節點 n 插到緊接在虛擬頭之後(即成為最新) / insert n right after dummy head (becomes most recent)
static void insert_front(LRUCache *c, Node *n) {
n->prev = c->head; // n 的前面是 head / n's previous is head
n->next = c->head->next; // n 的後面是原本 head 後面的那個 / n's next is the old first real node
c->head->next->prev = n; // 原第一個節點回頭指向 n / old first node points back to n
c->head->next = n; // head 現在指向 n / head now points to n
}
// 移到最前 = 先拆掉再插到最前 / move to front = unlink then insert at front
static void move_front(LRUCache *c, Node *n) {
unlink_node(n);
insert_front(c, n);
}
LRUCache *lRUCacheCreate(int capacity) {
LRUCache *c = (LRUCache *)malloc(sizeof(LRUCache)); // 配置快取本體 / allocate the cache struct
c->capacity = capacity;
c->size = 0;
// calloc 會把所有格子初始化為 0(即 NULL),表示一開始每個 key 都沒有節點
// calloc zero-initializes every slot to NULL, meaning no key has a node yet.
c->map = (Node **)calloc(KEY_RANGE, sizeof(Node *));
c->head = (Node *)malloc(sizeof(Node)); // 建立虛擬頭 / create dummy head
c->tail = (Node *)malloc(sizeof(Node)); // 建立虛擬尾 / create dummy tail
c->head->prev = NULL;
c->head->next = c->tail; // 一開始 head 直接接 tail(串列為空) / initially head links straight to tail (empty)
c->tail->prev = c->head;
c->tail->next = NULL;
return c;
}
int lRUCacheGet(LRUCache *c, int key) {
Node *n = c->map[key]; // O(1) 用陣列索引查節點 / O(1) lookup by array index
if (n == NULL) return -1; // 不存在就回傳 -1 / not found -> -1
move_front(c, n); // 用過了,變成最新 / touched, so make it most recent
return n->value; // 回傳它的值 / return its value
}
void lRUCachePut(LRUCache *c, int key, int value) {
Node *n = c->map[key]; // 先看 key 在不在 / check if key already exists
if (n != NULL) { // 已存在:更新值並移到最前 / exists: update value, move to front
n->value = value;
move_front(c, n);
return;
}
// 不存在:建立新節點 / does not exist: create a new node
Node *fresh = (Node *)malloc(sizeof(Node));
fresh->key = key;
fresh->value = value;
insert_front(c, fresh); // 新的一定是最新,插到最前 / new entry is most recent
c->map[key] = fresh; // 在雜湊表登記 / register in the hash table
c->size++; // 數量加一 / one more key stored
if (c->size > c->capacity) { // 超出容量就要淘汰 / over capacity -> evict
Node *lru = c->tail->prev; // 尾巴前一個就是最舊的真實節點 / node before tail is the LRU
unlink_node(lru); // 從串列拆掉 / unlink from list
c->map[lru->key] = NULL; // 從雜湊表移除(用 key 定位) / erase from map using its key
free(lru); // 釋放記憶體,避免洩漏 / free memory to avoid a leak
c->size--; // 數量減一 / one fewer key
}
}
void lRUCacheFree(LRUCache *c) {
Node *cur = c->head; // 從頭開始逐一釋放 / free every node starting from head
while (cur != NULL) {
Node *nxt = cur->next; // 先記住下一個,否則 free 後就找不到了 / save next before freeing
free(cur);
cur = nxt;
}
free(c->map); // 釋放雜湊表陣列 / free the hash table array
free(c); // 釋放快取本體 / free the cache struct
}
Solution — C++
// 演算法 / Algorithm:
// unordered_map(雜湊表) + list(標準庫的雙向鏈結串列)。
// unordered_map + std::list (the standard doubly-linked list).
// list 頭=最新、尾=最舊;map 把 key 對應到 list 中的迭代器,
// 讓我們能 O(1) 找到並用 splice 移動節點。
// The list keeps head=newest/tail=oldest; the map maps key to a list iterator,
// so we can find and splice a node in O(1).
#include <list>
#include <unordered_map>
using namespace std;
class LRUCache {
private:
int cap; // 容量 / capacity
// list 每個元素是一對 {key, value};pair.first=key, pair.second=value
// Each list element is a pair {key, value}.
list<pair<int,int>> items;
// map: key -> 指向 list 中對應元素的迭代器(iterator,可想成「位置憑證」)
// map: key -> an iterator (a handle to the element's position in the list)
unordered_map<int, list<pair<int,int>>::iterator> pos;
public:
LRUCache(int capacity) : cap(capacity) {} // 成員初始化列表設定容量 / init capacity
int get(int key) {
auto it = pos.find(key); // 在 map 中找 key / look up key in the map
if (it == pos.end()) return -1; // 找不到就回傳 -1 / not found -> -1
// splice 把節點從原位置搬到 items 的最前面,指標不失效、O(1)。
// splice moves the node to the front of items in O(1) without invalidating iterators.
// it->second 是那個節點的迭代器 / it->second is the node's iterator
items.splice(items.begin(), items, it->second);
return it->second->second; // 迭代器指向 pair,.second 就是 value / the value
}
void put(int key, int value) {
auto it = pos.find(key); // 先看 key 在不在 / check existence
if (it != pos.end()) { // 已存在 / exists
it->second->second = value; // 更新 value / update value
items.splice(items.begin(), items, it->second); // 移到最前 / move to front
return;
}
if ((int)items.size() == cap) { // 滿了才淘汰(先淘汰再插入也可) / full -> evict LRU first
auto &oldest = items.back(); // list 尾端是最舊的 / back of list is least recently used
pos.erase(oldest.first); // 用它的 key 從 map 移除 / erase from map by its key
items.pop_back(); // 從 list 移除尾端 / remove it from the list
}
// emplace_front 直接在最前面就地建立 {key,value},比 push_front 少一次複製
// emplace_front constructs {key,value} in place at the front (fewer copies than push_front)
items.emplace_front(key, value);
pos[key] = items.begin(); // 登記新節點的迭代器 / record the new node's iterator
}
};
/**
* 你的 LRUCache 會被這樣使用 / Your LRUCache object will be instantiated and called as such:
* LRUCache* obj = new LRUCache(capacity);
* int param_1 = obj->get(key);
* obj->put(key,value);
*/
複雜度 / Complexity
- Time: O(1) per operation(每次操作) — 中文:
get與put都只做「一次雜湊表查找」加上「常數次指標/迭代器操作」(拆節點、插到頭、可能淘汰尾),沒有任何迴圈掃過所有元素,所以與快取內元素數量無關。 / English: Bothgetandputdo a single hash lookup plus a constant number of pointer/iterator updates (unlink, insert-front, possibly evict tail). No loop scans the elements, so cost is independent of how many items are stored. (C 版建構是 O(KEY_RANGE),因為 calloc 要清零整個陣列,但這是一次性的。 / The C constructor is O(KEY_RANGE) once, to zero the array.) - Space: O(capacity) — 中文:鏈結串列最多存
capacity個節點,雜湊表最多存capacity個對應項(C 版的陣列額外用固定的 O(10001),屬常數)。 / English: The list holds at mostcapacitynodes and the map at mostcapacityentries. (The C array adds a fixed O(10001), which is constant.)
Pitfalls & Edge Cases
- 忘了在淘汰時同步移除雜湊表項 / Forgetting to erase the map entry on eviction:只把節點從串列拿掉、卻沒清掉
map[key],之後get那個 key 會拿到一個「懸空」指標而崩潰。程式碼在淘汰時同時做unlink_node+map[key]=NULL(C)/pos.erase+pop_back(C++)。 / If you unlink the node but leavemap[key]pointing at freed memory, a latergetdereferences a dangling pointer and crashes. Both maps are kept in sync on eviction. - 這就是為什麼節點要存自己的 key / Why each node stores its own key:淘汰時我們手上是「尾端節點」,必須反查它的 key 才能從雜湊表刪除——所以節點裡一定要存 key,不能只存 value。 / At eviction we only have the tail node; we need its key to remove it from the map, so the node must store the key, not just the value.
- 更新已存在的 key 時別重複插入 / Don't insert a duplicate when the key already exists:
put一個已存在的 key 只能更新值並移到最前,若當成新節點插入會使串列出現兩個相同 key、size也算錯。程式碼先用查找分流處理。 / Aputon an existing key must update-and-move, not insert; otherwise you get two nodes with the same key and a wrongsize. The code branches on lookup first. - 虛擬頭尾節點避免邊界判斷 / Sentinels remove edge cases:沒有虛擬節點時,對空串列或第一個/最後一個元素做插入刪除都要特判 NULL;有了 head/tail,
insert_front與unlink_node永遠有前後鄰居可牽手。 / Without sentinels, inserting/removing at the ends or into an empty list needs NULL checks; with dummy head/tail, neighbours always exist. - C++ 迭代器不失效才敢用 splice /
splicekeeps iterators valid:std::list的splice移動節點時不會使指向該節點的迭代器失效,所以存在pos裡的迭代器搬動後仍然有效——這正是選list而非vector的原因。 /std::list::splicedoes not invalidate iterators to the moved node, so the iterators stored inposstay valid — the reason we uselist, notvector. - 容量比較的號誌方向 / Off-by-one on the capacity check:淘汰條件是「數量 > 容量」(先插入後檢查),或「數量 == 容量」(先淘汰後插入)。兩種寫法都可,但別混用導致多存或漏存一個。C 版用前者,C++ 版用後者。 / Evict when
size > capacity(insert-then-check) or whensize == capacity(evict-then-insert). Either works; mixing them stores one too many or too few. C uses the former, C++ the latter.