已發布·持續改進
LRU 快取 指南 · 2/6
本章目前僅提供英文版。
An LRU cache has to answer two questions quickly: "is this key in the cache?" and "which key was used least recently?". No single classic data structure answers both in constant time, so the standard design combines two: a hash map for lookup and a doubly linked list for recency order. This chapter walks through that design and traces it on a small example.
head) is the most recently used entry; the back (next to tail) is the least recently used.prev and next pointers, unlinking takes O(1).head and tail, never hold data. They remove every special case for an empty list or for the first and last node.head <-> [C] <-> [B] <-> [A] <-> tail
most recent least recent
map: { A: node A, B: node B, C: node C }A get(key) looks the key up in the map. On a miss it returns a sentinel value such as -1 or None. On a hit it unlinks the node and inserts it right after head, then returns the value.
A put(key, value) first checks the map. If the key exists, it updates the value and moves the node to the front, exactly like a hit. Otherwise, if the cache is full, it removes the node just before tail and deletes that node's key from the map; then it creates a new node, inserts it after head and stores it in the map.
All pointer work reduces to two helpers. Each touches a constant number of pointers, which is why both operations are O(1).
def unlink(node):
# A <-> node <-> B becomes A <-> B
node.prev.next = node.next
node.next.prev = node.prev
def push_front(head, node):
# head <-> X becomes head <-> node <-> X
node.prev = head
node.next = head.next
head.next.prev = node
head.next = nodeMoving a node to the front is simply unlink(node) followed by push_front(head, node). Evicting is unlink(tail.prev) plus a map deletion.
The list is written from most recent to least recent.
| Step | Operation | Result | List after the step | Note |
|---|---|---|---|---|
| 1 | put(A, 1) | - | A | insert |
| 2 | put(B, 2) | - | B, A | insert |
| 3 | put(C, 3) | - | C, B, A | cache is now full |
| 4 | get(A) | 1 | A, C, B | hit: A moves to the front |
| 5 | put(D, 4) | - | D, A, C | full: B is evicted |
| 6 | get(B) | -1 | D, A, C | miss: order unchanged |
| 7 | put(C, 30) | - | C, D, A | update: C moves to the front |
| 8 | put(E, 5) | - | E, C, D | full: A is evicted |
| 9 | get(D) | 4 | D, E, C | hit |
Step 5 is the key moment. A was inserted first, so a FIFO cache would have evicted it. Because step 4 read A, LRU evicts B instead. Step 6 shows that a miss changes nothing, and step 7 shows that an update counts as a use.
The quickest way to check a trace is a short simulation with OrderedDict, which already provides the two primitives we need: move_to_end and popitem(last=False).
from collections import OrderedDict
def run(capacity, ops):
cache = OrderedDict() # first = least recent, last = most recent
for op, key, *value in ops:
result = "-"
if op == "get":
if key in cache:
cache.move_to_end(key)
result = cache[key]
else:
result = -1
else:
if key in cache:
cache.move_to_end(key)
elif len(cache) >= capacity:
cache.popitem(last=False)
cache[key] = value[0]
order = ", ".join(reversed(cache)) # most recent first
print(f"{op}({key}) -> {result}: {order}")
run(3, [("put", "A", 1), ("put", "B", 2), ("put", "C", 3), ("get", "A"),
("put", "D", 4), ("get", "B"), ("put", "C", 30), ("put", "E", 5), ("get", "D")])The last printed line is get(D) -> 4: D, E, C, matching the table.
get. The cache then behaves like FIFO.put updates an existing key.head and tail remove edge cases; nodes keep their key so eviction can clean up the map.tail.
0 則留言
登入 · 登入後即可留言。
來留下第一則留言吧。