Publicado · en mejora
Guía de Caché LRU · 4/6
Por ahora, este capítulo solo está disponible en inglés.
The hash map plus doubly linked list design makes every operation constant time on average. This chapter explains where that bound comes from, what it costs in memory, how simpler designs compare, and why hit ratio matters as much as Big-O.
| Operation | Hash map part | List part | Total |
|---|---|---|---|
| get (hit) | lookup O(1) average | unlink + push front O(1) | O(1) average |
| get (miss) | lookup O(1) average | nothing | O(1) average |
| put (update) | lookup O(1) average | unlink + push front O(1) | O(1) average |
| put (insert, not full) | insert O(1) amortized | push front O(1) | O(1) amortized |
| put (insert, full) | delete + insert O(1) average | unlink tail + push front O(1) | O(1) amortized |
The list operations are O(1) in the worst case because each changes at most four pointers. The hash map is O(1) on average; with pathological collisions a lookup can degrade to O(n), and resizing makes insertion amortized rather than strictly constant. In practice, with a decent hash function, every operation is a handful of memory accesses.
The cache holds at most capacity entries, so space is O(capacity). The constant factor is larger than for a plain dict: each entry needs a map slot, a node object, two pointers and a second reference to the key. You can see the overhead in Python:
import sys
class Node:
__slots__ = ("key", "value", "prev", "next")
class FatNode:
def __init__(self):
self.key = self.value = self.prev = self.next = None
slim, fat = Node(), FatNode()
print(sys.getsizeof(slim)) # e.g. 64 bytes
print(sys.getsizeof(fat) + sys.getsizeof(fat.__dict__)) # noticeably moreExact numbers depend on the Python version, but __slots__ consistently saves memory per node. OrderedDict and functools.lru_cache keep their links in C, which is cheaper still.
Many first attempts keep keys in a plain list and move them around. Finding and removing a key in a Python list costs O(n), so each operation becomes linear.
class NaiveLRU:
def __init__(self, capacity):
self.capacity = capacity
self.order = [] # least recent first
self.data = {}
def get(self, key):
if key not in self.data:
return None
self.order.remove(key) # O(n) search and shift
self.order.append(key)
return self.data[key]
def put(self, key, value):
if key in self.data:
self.order.remove(key)
elif len(self.data) >= self.capacity:
del self.data[self.order.pop(0)] # O(n) shift
self.order.append(key)
self.data[key] = value| Design | get | put / evict | Notes |
|---|---|---|---|
| list of keys + dict | O(n) | O(n) | simple, fine for tiny caches |
| dict + timestamps, scan for minimum | O(1) | O(n) | eviction scans all entries |
| dict + min-heap of timestamps | O(log n) | O(log n) | needs lazy deletion of stale heap items |
| dict + doubly linked list | O(1) | O(1) | the standard design |
| OrderedDict / LinkedHashMap / JS Map | O(1) | O(1) | the same design, provided by the library |
A small benchmark makes the difference visible as the capacity grows.
import random
import timeit
from collections import OrderedDict
def ordered_ops(capacity, keys):
cache = OrderedDict()
for k in keys:
if k in cache:
cache.move_to_end(k)
else:
cache[k] = k
if len(cache) > capacity:
cache.popitem(last=False)
def naive_ops(capacity, keys):
order, data = [], set()
for k in keys:
if k in data:
order.remove(k)
elif len(order) >= capacity:
data.discard(order.pop(0))
order.append(k)
data.add(k)
keys = [random.randrange(4000) for _ in range(20000)]
for name, fn in [("OrderedDict", ordered_ops), ("list", naive_ops)]:
seconds = timeit.timeit(lambda: fn(2000, keys), number=1)
print(f"{name:12} {seconds:.3f}s")| Policy | Typical implementation | Cost per operation |
|---|---|---|
| FIFO | queue + dict | O(1) |
| LRU | dict + doubly linked list | O(1) |
| LFU | dict + frequency buckets of linked lists | O(1); O(log n) with a heap |
| TTL | dict + expiry time, checked on read | O(1) lazily; periodic sweeps cost O(n) |
| CLOCK (LRU approximation) | circular array + reference bits | O(1) amortized |
CLOCK and sampled LRU exist because true LRU updates shared pointers on every read, which is expensive for hardware and for highly concurrent software. They give up exact recency order in exchange for cheaper hits.
All good designs are O(1); what differs in practice is the hit ratio. A useful property of LRU is that it is a stack algorithm: for the same request sequence, a larger LRU cache never has more misses than a smaller one. FIFO does not have this property (Belady's anomaly). Measure the hit ratio on a realistic workload before choosing a capacity.
0 comentarios
Iniciar sesión · Inicia sesión para dejar un comentario.
Sé el primero en comentar.