Tabla hash
En resumen: Una tabla hash (HashMap en Java) guarda pares clave-valor en cubetas: el hash de la clave dice en qué cubeta está, así que buscar e insertar son O(1) en promedio.
Problema
El caché de precios se vuelve lento
La página del producto lee el precio de un caché: una tabla SKU → precio que el programa guarda en memoria. Alguien escribió su propia PriceTable, con una función hash que es «rápida»: solo la primera letra del SKU.
PriceTable cache = new PriceTable(key -> key.charAt(0));Con 20 productos en la prueba todo vuela. Con el catálogo real la página va a paso de tortuga, y el profiler señala get. Con 1000 productos, cada uno escrito y leído una vez, la tabla hace 1.000.000 comparaciones de claves: mil por producto. Una tabla hash debería hacer una o dos.
Todos los SKU empiezan por «S». ¿Qué pasa por dentro?
La idea
Cubetas, hash y número de cubeta
En la lección «Códigos hash y funciones hash» viste la tabla desde fuera. Por dentro son tres cosas:
- un arreglo de cubetas;
- una función hash que convierte la clave en un entero;
- el número de cubeta = hash % número de cubetas. Así cada clave tiene exactamente un lugar donde se busca.
Dos claves con el mismo número de cubeta son una colisión. Aquí las colisiones viven en una cadena: cada cubeta es el comienzo de una lista enlazada de pares clave-valor.
String[] keys = { "Clara", "Luis", "Iván", "Rosa" };int buckets = 8;for (String key : keys) { int hash = key.hashCode(); int index = Math.floorMod(hash, buckets); System.out.println(key + " -> " + hash + " -> cubeta " + index);}Paso a paso, en palabras
- Cuatro claves y ocho cubetas vacías, numeradas del 0 al 7.
- "Clara".hashCode() es 65190183. El resto de dividir entre 8 es 7: esa es la cubeta.
- "Luis" da 2379923, cubeta 3.
- "Iván" da 2295226, cubeta 2.
- "Rosa" también cae en la cubeta 3: una colisión. Las dos claves están en una misma cubeta: "Rosa" cuelga detrás de "Luis" como una cadena.
- Se busca "Rosa": el mismo número, la misma cubeta 3. equals con "Luis": no. Con "Rosa": sí.
get calcula la cubeta y recorre solo su cadena. Si las cadenas son cortas, eso es O(1), sin importar cuántas claves haya.
Míralo
Tu propia tabla
La función hash de un SKU es la misma que el hashCode de un string en Java: 31 · h + char por cada carácter. Al final se quita el signo, para que el número no sea negativo:
static int skuHash(String key) { int h = 0; for (int i = 0; i < key.length(); i++) { h = 31 * h + key.charAt(i); } return h & 0x7fffffff;}Un par es un nodo con una clave, un precio y una flecha al siguiente de la cadena:
class Entry { final String key; double price; Entry next;
Entry(String key, double price, Entry next) { this.key = key; this.price = price; this.next = next; }}La tabla recibe su función hash desde fuera, para que puedas comparar dos. compares cuenta las comparaciones de claves:
class PriceTable { private final ToIntFunction<String> hash; private Entry[] buckets = new Entry[4]; private int size = 0; long compares = 0;
PriceTable(ToIntFunction<String> hash) { this.hash = hash; }
int capacity() { return buckets.length; }
private int index(String key) { return hash.applyAsInt(key) % buckets.length; }
void put(String key, double price) { int i = index(key); for (Entry e = buckets[i]; e != null; e = e.next) { compares++; if (e.key.equals(key)) { e.price = price; return; } } buckets[i] = new Entry(key, price, buckets[i]); size++; if (size > buckets.length * 3 / 4) resize(); }
private void resize() { Entry[] fresh = new Entry[buckets.length * 2]; for (Entry head : buckets) { for (Entry e = head; e != null; e = e.next) { int i = hash.applyAsInt(e.key) % fresh.length; fresh[i] = new Entry(e.key, e.price, fresh[i]); } } buckets = fresh; }
Double get(String key) { int i = index(key); for (Entry e = buckets[i]; e != null; e = e.next) { compares++; if (e.key.equals(key)) return e.price; } return null; }}putrecorre la cadena de la cubeta. Si la clave está ahí, cambia el precio. Si no, pone un par nuevo al principio de la cadena: eso no necesita recorrido.getrecorre la misma cadena y se detiene en la clave.
Pruébalo
Completa
El número de cubeta:
return hash.applyAsInt(key) % ;Divides entre el número de cubetas. El resto va de 0 a número de cubetas − 1, justo los índices del arreglo.
La idea
Factor de carga y crecimiento
Cuantos más pares, más largas las cadenas. La proporción pares / cubetas se llama factor de carga. Aquí, cuando pasa de 3/4, la tabla crea el doble de cubetas.
Las cubetas no se copian una a una. El número de cubeta es hash % número de cubetas, y ese número ha cambiado. Por eso resize recorre cada par y calcula su cubeta de nuevo: eso es rehash. Al duplicar de 4 a 8, una clave de la cubeta b va a b o a b + 4, y las cadenas se dividen.
private int index(String key) { return hash.applyAsInt(key) % buckets.length;}
void put(String key, double price) { int i = index(key); for (Entry e = buckets[i]; e != null; e = e.next) { compares++; if (e.key.equals(key)) { e.price = price; return; } } buckets[i] = new Entry(key, price, buckets[i]); size++; if (size > buckets.length * 3 / 4) resize();}
private void resize() { Entry[] fresh = new Entry[buckets.length * 2]; for (Entry head : buckets) { for (Entry e = head; e != null; e = e.next) { int i = hash.applyAsInt(e.key) % fresh.length; fresh[i] = new Entry(e.key, e.price, fresh[i]); } } buckets = fresh;}Paso a paso, en palabras
- Un caché de precios con 4 cubetas. La cubeta de una clave es su hash % 4.
- SKU-1003: 1091696434 % 4 = 2. SKU-1001: % 4 = 0. Cubetas vacías: sin comparaciones.
- SKU-1007 también cae en la cubeta 2: una colisión. put lo compara con SKU-1003 (no es la misma clave) y lo pone al principio de la cadena.
- SKU-1005 se une a SKU-1001 en la cubeta 0. Ahora hay 4 pares, más de 3/4 de 4 cubetas: las cadenas empiezan a crecer.
- resize crea 8 cubetas. Cada clave se calcula de nuevo con % 8: se queda en su número o sube 4.
- La tabla nueva: cada cadena tiene un par, así que un get hace una comparación. Crecer cuesta n, pero ocurre pocas veces.
PriceTable cache = new PriceTable(Main::skuHash);cache.put("SKU-1003", 24.9);cache.put("SKU-1001", 12.5);cache.put("SKU-1007", 8.99);cache.printBuckets();System.out.println("---");cache.put("SKU-1005", 45.5);cache.printBuckets();0: SKU-10011:2: SKU-1007 SKU-10033:---0: SKU-10011:2: SKU-10033:4: SKU-10055:6: SKU-10077:Crecer cuesta tanto como el número de pares. Pero ocurre pocas veces: con 4, 8, 16… pares, como el crecimiento del arreglo en Complejidad amortizada. En promedio put sigue siendo O(1).
Pruébalo
¿Cuántas cubetas?
La tabla empieza con 4 cubetas. Entran siete productos uno tras otro:
PriceTable cache = new PriceTable(Main::skuHash);StringBuilder caps = new StringBuilder();for (int n = 2001; n <= 2007; n++) { cache.put("SKU-" + n, 10); caps.append(cache.capacity()).append(" ");}System.out.println(caps.toString().trim());El límite es 3/4 de las cubetas. Con 4 cubetas son 3: el cuarto producto lo pasa y las cubetas pasan a ser 8. Con 8 el límite es 6: el séptimo lo pasa y pasan a ser 16.
La idea
Un mal hash: la tabla se convierte en lista
Volvamos al caché. El hash «primera letra» da 83 (el código de «S») para todos los SKU. 83 % 4, 83 % 8, 83 % 1024: con cualquier número de cubetas el número es el mismo. Todos los pares están en una cadena, y la tabla es una lista enlazada donde cada put y cada get la recorren entera.
Crecer no ayuda: solo divide cadenas cuando las claves tienen hashes distintos. Una función hash debe depender de la clave entera y dispersar claves cercanas (SKU-10000, SKU-10001) lejos unas de otras.
static long compares(PriceTable cache, int n) { for (int i = 0; i < n; i++) cache.put("SKU-" + (10000 + i), 1 + i); for (int i = 0; i < n; i++) cache.get("SKU-" + (10000 + i)); return cache.compares;}System.out.println("primera letra: " + compares(new PriceTable(key -> key.charAt(0)), 1000));System.out.println("SKU completo: " + compares(new PriceTable(Main::skuHash), 1000));primera letra: 1000000SKU completo: 2593Ejecuta las dos tablas en el navegador:
- Hash de todo el SKU
O(n) - Hash de la primera letra
O(n²)
| Algoritmo | n = 10 | n = 1000 | n = 100.000 |
|---|---|---|---|
| Hash de todo el SKU | 10 | 2593 | 211.990 |
| Hash de la primera letra | 100 | 1.000.000 | ≈ 10.000.000.000 |
Con 1000 productos el hash perezoso hace 1 000 000 de comparaciones, y el hash del SKU completo hace 2593: menos de tres por producto. El perezoso es O(n) por operación y O(n²) en total: n productos, cada uno escrito y leído una vez, hacen exactamente n² comparaciones.
Más precisamente, para curiosos
Direccionamiento abierto (linear probing). En vez de cadenas, cada par vive en el propio arreglo. Si el lugar hash % n está ocupado, pruebas el siguiente, luego el que sigue, hasta encontrar un lugar libre o la clave. get sigue el mismo camino y se detiene en un lugar vacío. La ventaja es que todo está en un solo arreglo y el procesador lo lee en orden. Las desventajas: los lugares ocupados se juntan en montones y cada montón crece cada vez más rápido, por eso la tabla debe estar más vacía (crece ya hacia la mitad o hacia 2/3). Al borrar no se puede simplemente vaciar el lugar, porque se cortaría el camino a las claves que vienen después: se deja una marca de «borrado».
Qué hacen los lenguajes:
- Java
HashMap: cadenas. El número de cubetas es una potencia de 2 (al principio 16), el factor de carga es 0,75. Al hash se le mezclan los bits altos (h ^ (h >>> 16)), porque con una máscara de potencia de 2 solo contarían los bits bajos. Una cadena con más de 8 pares se convierte en un árbol rojo-negro si hay al menos 64 cubetas (si no, primero crece la tabla). Así, incluso con muchas colisiones, la búsqueda es O(log n) y no O(n). - C#
Dictionary<TKey, TValue>: también cadenas, pero en arreglos:_bucketsguarda el número del primer par, y los pares en_entriestienen un índicenext. El número de cubetas es un número primo (3, 7, 17, 37, 89…), y la tabla crece cuando el arreglo de pares se llena. No hay un árbol como en Java. Contra las colisiones provocadas ayuda que el hash de un texto es distinto en cada ejecución. - Python
dict: direccionamiento abierto, pero no con linear probing: el siguiente lugar se calcula con una fórmula en la que también entran los bits altos del hash. La tabla es una potencia de 2 y crece cuando está llena a más de 2/3. Los pares viven en un arreglo denso aparte en orden de inserción, y la tabla guarda solo índices hacia él. Por esodictconserva el orden de inserción (garantizado desde Python 3.7). - JavaScript
Map: la especificación exige iterar en orden de inserción y búsquedas que en promedio sean mejores que lineales, pero no dice cómo. V8 usa una tabla hash ordenada: cubetas con cadenas, y los pares viven en un arreglo en orden de inserción. JavaScript no da un hash propio para los objetos: una clave que es un objeto se compara por referencia.
Pruébalo
Encuentra el error
Alguien reescribió resize. Los cuatro productos entran, pero dos de ellos después «desaparecen»:
private void resize() { Entry[] fresh = new Entry[buckets.length * 2]; for (Entry head : buckets) { for (Entry e = head; e != null; e = e.next) { int i = hash.applyAsInt(e.key) % buckets.length; fresh[i] = new Entry(e.key, e.price, fresh[i]); } } buckets = fresh;}PriceTable cache = new PriceTable(Main::skuHash);cache.put("SKU-1003", 24.9);cache.put("SKU-1001", 12.5);cache.put("SKU-1007", 8.99);cache.put("SKU-1005", 45.5);Double p1003 = cache.get("SKU-1003");System.out.println("SKU-1003: " + (p1003 == null ? "ninguno" : p1003));Double p1001 = cache.get("SKU-1001");System.out.println("SKU-1001: " + (p1001 == null ? "ninguno" : p1001));Double p1007 = cache.get("SKU-1007");System.out.println("SKU-1007: " + (p1007 == null ? "ninguno" : p1007));Double p1005 = cache.get("SKU-1005");System.out.println("SKU-1005: " + (p1005 == null ? "ninguno" : p1005));SKU-1003: 24.9SKU-1001: 12.5SKU-1007: ningunoSKU-1005: ninguno¿En qué línea está el error?
Elige la línea con el error.
La cubeta se calcula con el número antiguo de cubetas (4), y get después busca con el nuevo (8). Una clave con hash % 8 = 4 está en la cubeta 0, pero se busca en la cubeta 4, que está vacía. La cubeta nueva debe calcularse con la longitud del arreglo nuevo: % fresh.length.
Ejercicio
Un producto deja de venderse
Resuelve esta tarea en tu propio editor.
Borrar del caché. Añade a PriceTable un método remove(key) que quite el par y devuelva si había uno. Ten cuidado: el par puede ser el primero de la cadena (entonces la cubeta apunta al siguiente) o estar en medio (entonces el par anterior apunta al siguiente). Pon SKU-1001, SKU-1005, SKU-1009 y SKU-1012 con precios de 10,5, 11,5, 12,5 y 13,5 €; tras crecer a 8 cubetas tres de ellos están en la cubeta 0, y SKU-1009 está en medio de la cadena. Quita SKU-1009 y lee los cuatro:
SKU-1001: 10.5SKU-1005: 11.5SKU-1009: ningunoSKU-1012: 13.5productos: 3Más tareas
- Linear probing. Escribe una
ProbeTablesin cadenas: si un lugar está ocupado, prueba el siguiente. Crece cuando la tabla esté medio llena. ¿Cuántas comparaciones hace con 1000 SKU? - Encoger. Tras una liquidación el caché se queda con 10 productos y 4096 cubetas. ¿Cuándo encogerías la tabla?
- Un hash perezoso mejor. El hash «suma de los códigos de los caracteres» mira la clave entera. Pruébalo con SKU-10000 hasta SKU-10999. ¿Por qué sigue habiendo muchas colisiones? (Pista: SKU-10019 y SKU-10091.)
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Complejidad amortizada · El arreglo dinámico por dentro · Lista enlazada · Pila y cola
- Continúa en: Árbol binario de búsqueda · Dos punteros · Ventana deslizante · Sumas prefijas
- Ejercicios sobre el tema: Estructuras de datos