Caché LRU: hash map y lista enlazada trabajando juntos
Ninguna de las dos estructuras alcanza sola. El hash map encuentra en tiempo constante pero no sabe qué se usó hace más tiempo; la lista enlazada mantiene el orden de uso pero buscar en ella cuesta recorrerla. La caché LRU las combina para que las dos operaciones cuesten O(1).
Para este tema conviene tener claro:Tablas hash y resolución de colisionesListas enlazadas
Una caché tiene lugar para elementos y en algún momento se llena. Ahí aparece la pregunta que define todo: cuál se tira. La política LRU —least recently used— tira el que hace más tiempo que nadie toca.
Suena simple hasta que hay que implementarla: para saber cuál se usó hace más tiempo hace falta un orden, y para encontrar una clave hace falta un índice. Ninguna estructura sola da las dos cosas en tiempo constante.
Por qué una estructura sola no alcanza
| Sólo hash map | Sólo lista enlazada | Las dos juntas | |
|---|---|---|---|
| Buscar una clave | O(1) | O(n): hay que recorrerla | O(1) |
| Saber cuál se usó hace más | No lo sabe | Es el último nodo | O(1) |
| Marcar algo como recién usado | No aplica | O(1) si ya tenés el nodo | O(1) |
| Desalojar el menos usado | Hay que recorrer todo | O(1) | O(1) |
La clave está en la tercera fila: mover un nodo al frente de una lista doblemente enlazada cuesta si ya tenés el nodo en la mano. Y el hash map es exactamente lo que te lo pone en la mano.
El invariante
La estructura sostiene una sola promesa, y todas las operaciones la mantienen:
De ahí salen las tres operaciones:
Qué hace cada operación
- Leer una clave que está. El hash map da el nodo, se mueve al frente y se devuelve el valor.
- Leer una clave que no está. Falla la búsqueda y no cambia nada. Es un miss.
- Escribir una clave que ya está. Se actualiza el valor del nodo y se mueve al frente.
- Escribir una clave nueva con lugar libre. Se crea el nodo al frente y se registra en el hash map.
- Escribir una clave nueva sin lugar. Se saca el último nodo de la lista, se borra su clave del hash map, y recién entonces se inserta el nuevo al frente.
La caché, paso a paso
Caché vacía, capacidad 3.
Antes de seguir, predecí
La implementación
Toda la complejidad está en dos funciones privadas: desenganchar un nodo y engancharlo al frente. El resto es combinarlas.
interface CacheNode<K, V> {
key: K;
value: V;
previous: CacheNode<K, V> | null;
next: CacheNode<K, V> | null;
}
class LruCache<K, V> {
private readonly nodesByKey = new Map<K, CacheNode<K, V>>();
private head: CacheNode<K, V> | null = null;
private tail: CacheNode<K, V> | null = null;
constructor(private readonly capacity: number) {
if (capacity < 1) throw new Error('Una caché necesita capacidad para al menos un elemento.');
}
get(key: K): V | undefined {
const node = this.nodesByKey.get(key);
if (!node) return undefined;
this.detach(node);
this.attachToFront(node);
return node.value;
}
put(key: K, value: V): void {
const existing = this.nodesByKey.get(key);
if (existing) {
existing.value = value;
this.detach(existing);
this.attachToFront(existing);
return;
}
if (this.nodesByKey.size === this.capacity) this.evictLeastRecentlyUsed();
const node: CacheNode<K, V> = { key, value, previous: null, next: null };
this.nodesByKey.set(key, node);
this.attachToFront(node);
}
private evictLeastRecentlyUsed(): void {
const leastUsed = this.tail;
if (!leastUsed) return;
this.detach(leastUsed);
this.nodesByKey.delete(leastUsed.key);
}
private detach(node: CacheNode<K, V>): void {
if (node.previous) node.previous.next = node.next;
else this.head = node.next;
if (node.next) node.next.previous = node.previous;
else this.tail = node.previous;
node.previous = null;
node.next = null;
}
private attachToFront(node: CacheNode<K, V>): void {
node.next = this.head;
if (this.head) this.head.previous = node;
this.head = node;
this.tail ??= node;
}
}Qué cuesta
| Operación | Tiempo | Por qué |
|---|---|---|
| get | O(1) promedio | Búsqueda en el hash map más cuatro reasignaciones de punteros |
| put existente | O(1) promedio | Lo mismo, más actualizar el valor |
| put nuevo con desalojo | O(1) promedio | La cola se conoce sin recorrer nada |
| Memoria | O(k) | Un nodo y una entrada de hash por elemento: dos punteros extra por nodo |
Más a fondo · nivel seniorCuando la caché la usan varios hilos
Cada get escribe: mueve un nodo al frente. Eso convierte a una caché LRU en una estructura de
escritura aun para operaciones de lectura, y bajo concurrencia el candado sobre la lista se vuelve
el cuello de botella de todo el sistema.
Por eso las cachés de alto tráfico rara vez implementan LRU exacto. Las salidas habituales son particionar la caché en varios segmentos con su propio candado, o aproximar el LRU: guardar por cada entrada un bit o una marca de tiempo aproximada y, al desalojar, tomar una muestra pequeña al azar y tirar la peor de la muestra. Se pierde exactitud en qué se desaloja y se gana no serializar todas las lecturas.
LRU no siempre es la política correcta
| Política | Qué desaloja | Cuándo conviene |
|---|---|---|
| LRU | Lo que hace más tiempo que no se usa | Acceso con localidad temporal: lo recién usado se vuelve a usar |
| LFU | Lo que se usó menos veces | Hay elementos populares estables en el tiempo |
| FIFO | Lo más viejo, sin mirar el uso | Cuando el orden de llegada es lo que importa y se quiere simplicidad |
| TTL | Lo que venció | Cuando el dato caduca por sí solo, no por presión de memoria |
| Aleatoria | Cualquiera | Sorprendentemente decente, y sin costo de mantenimiento ni contención |
Cierre
Autoevaluación
¿Lo entendiste?
Práctica