Atlasingeniería

Estructuras de datosTablas hash y grafosTema 5

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 kk 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 mapSólo lista enlazadaLas dos juntas
Buscar una claveO(1)O(n): hay que recorrerlaO(1)
Saber cuál se usó hace másNo lo sabeEs el último nodoO(1)
Marcar algo como recién usadoNo aplicaO(1) si ya tenés el nodoO(1)
Desalojar el menos usadoHay que recorrer todoO(1)O(1)
Cada estructura resuelve la mitad del problema. La combinación no es un truco: es el ejemplo canónico de que las estructuras se componen.

La clave está en la tercera fila: mover un nodo al frente de una lista doblemente enlazada cuesta O(1)O(1) 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:

la lista estaˊ ordenada del uso maˊs reciente al maˊs antiguo.\text{la lista está ordenada del uso más reciente al más antiguo.}

De ahí salen las tres operaciones:

Qué hace cada operación

  1. Leer una clave que está. El hash map da el nodo, se mueve al frente y se devuelve el valor.
  2. Leer una clave que no está. Falla la búsqueda y no cambia nada. Es un miss.
  3. Escribir una clave que ya está. Se actualiza el valor del nodo y se mueve al frente.
  4. Escribir una clave nueva con lugar libre. Se crea el nodo al frente y se registra en el hash map.
  5. 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.

1 / 7
La lista siempre va del más reciente (izquierda) al más antiguo (derecha). Leer B lo salva del desalojo, y el que termina afuera es A.

Antes de seguir, predecí

Si en el paso 5 no hubiéramos leído B, ¿a quién habría desalojado put(D)?

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ónTiempoPor qué
getO(1) promedioBúsqueda en el hash map más cuatro reasignaciones de punteros
put existenteO(1) promedioLo mismo, más actualizar el valor
put nuevo con desalojoO(1) promedioLa cola se conoce sin recorrer nada
MemoriaO(k)Un nodo y una entrada de hash por elemento: dos punteros extra por nodo
«Promedio» y no «siempre» por el hash map: el peor caso de una tabla hash sigue siendo su peor caso. La lista no agrega ninguna amortización.
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íticaQué desalojaCuándo conviene
LRULo que hace más tiempo que no se usaAcceso con localidad temporal: lo recién usado se vuelve a usar
LFULo que se usó menos vecesHay elementos populares estables en el tiempo
FIFOLo más viejo, sin mirar el usoCuando el orden de llegada es lo que importa y se quiere simplicidad
TTLLo que vencióCuando el dato caduca por sí solo, no por presión de memoria
AleatoriaCualquieraSorprendentemente decente, y sin costo de mantenimiento ni contención
LRU es un buen valor por defecto y tiene un caso malo conocido: recorrer una vez un conjunto más grande que la caché la vacía entera sin haber acertado nada.

Cierre

Autoevaluación

¿Lo entendiste?

¿Por qué el hash map guarda punteros a nodos y no los valores directamente?
¿Por qué la lista tiene que ser doblemente enlazada?
¿Qué campo del nodo hace falta para desalojar correctamente?
Un proceso recorre una vez un conjunto mucho más grande que la caché. ¿Qué pasa con LRU puro?
¿Por qué las cachés de alto tráfico suelen aproximar el LRU en vez de implementarlo exacto?