← Todas las estructuras

Hash Table

Hashing · ver código fuente en GitHub

Leer en: English · Português · Español

Categoría: Hashing

El problema

Buscar un valor por clave en una lista o array significa recorrer — O(n) en el peor caso, y también en promedio si la clave pudiera estar en cualquier lugar. A medida que el conjunto de datos crece, ese recorrido se vuelve proporcionalmente más lento. Lo que hace falta es una forma de saltar directamente a, aproximadamente, dónde vive el valor de una clave, sin recorrer lo que vino antes.

La solución

Calcula un hash numérico a partir de la clave, redúcelo a un índice dentro de un array de buckets de tamaño fijo, y almacena la entrada ahí. Dos claves distintas pueden generar hash hacia el mismo bucket (una colisión); esta tabla resuelve eso con encadenamiento separado (separate chaining) — cada bucket contiene una pequeña cadena enlazada de entradas, y una búsqueda recorre solo esa cadena, no toda la tabla. El O(1) promedio de búsqueda se mantiene mientras las cadenas sigan siendo cortas, por lo que la tabla duplica su cantidad de buckets y rehace el hash de todo en cuanto el factor de carga (entradas ÷ buckets) supera 0.75 — eso mantiene la longitud promedio de la cadena acotada sin importar cuánto crezca la tabla.

flowchart LR
    K["key"] --> H["hashCode() ^ (h >>> 16)"]
    H --> M["& (bucketCount - 1)"]
    M --> B0["bucket 0: empty"]
    M --> B1["bucket 1: A -> C"]
    M --> B2["bucket 2: B"]
Operación Promedio Peor caso Por qué ocurre el peor caso
get / put / remove O(1) O(n) toda clave colisiona en el mismo bucket
resize (activado internamente) O(n) O(n) cada entrada recibe un nuevo hash hacia la nueva tabla

Ejemplo clásico

classic/HashTable implementa encadenamiento separado desde cero — sin java.util.HashMap por debajo. Distribuye las claves con el mismo truco hashCode() ^ (h >>> 16) que usa HashMap (plegando los bits altos hacia abajo para que una tabla de tamaño potencia de dos, que solo mira los bits bajos, no colapse en el mismo bucket hashes que solo difieren en los bits altos), y redimensiona duplicando el tamaño en cuanto el factor de carga supera 0.75. HashTableTest fuerza colisiones reales con una clave cuyo hashCode() es constante, y verifica que cada entrada sobreviva a un redimensionamiento.

Ejemplo aplicado: caché de clave de idempotencia de PIX

applied/IdempotencyKeyCache es la pre-verificación en memoria que un gateway de pagos ejecuta antes de que una transacción PIX llegue a la base de datos, donde una restricción de unicidad sobre la clave de idempotencia es la verdadera fuente de verdad. Una verificación "¿ya vi esta clave?" con O(1) promedio evita un viaje de ida y vuelta para el caso común: un cliente reintentando la misma solicitud segundos después. La tabla no tiene orden, así que evictOlderThan — expirar entradas antiguas — es necesariamente un recorrido completo O(n); una caché de producción que necesitara desalojo barato combinaría una tabla hash con una lista doblemente enlazada entrelazada entre las entradas (la combinación clásica de caché LRU), que es el trade-off que este módulo hace visible en lugar de ocultar. IdempotencyKeyCacheTest cubre la detección de duplicados y el desalojo basado en tiempo con un reloj controlable.

Benchmark

./gradlew :hashing:hash-table:jmh

Ejecución real (JMH 1.37, JDK 26.0.2, 2 iteraciones de calentamiento + 3 de medición, 1 fork). Dos conjuntos de claves de los mismos tamaños: uno con hash normal, otro diseñado para que toda clave colisione en el bucket 0.

Costo de get size=100 size=10,000 size=100,000
hashing uniforme 3.79 ns 3.65 ns 3.77 ns
todas las claves colisionando en un bucket 133.8 ns 29,083.8 ns 179,652.8 ns

El hashing uniforme se mantiene estable sin importar el tamaño — O(1), confirmado. El conjunto de claves colisionantes queda aproximadamente 200x más lento al pasar de 100 a 10,000 claves (un aumento de 100x en el tamaño), que es exactamente el aspecto de un recorrido lineal de cadena O(n) cuando todas las claves viven en el mismo bucket. Esta es también la razón, en el mundo real, por la que un hashCode() de mala calidad o predecible por un atacante es una preocupación de corrección y de denegación de servicio, no solo un detalle de rendimiento.

Cuándo no usarlo

Cobertura de pruebas

100% de cobertura de instrucciones, 100% de cobertura de ramas (JaCoCo). Reprodúcelo tú mismo:

./gradlew :hashing:hash-table:jacocoTestReport

Reporte en hashing/hash-table/build/reports/jacoco/test/html/index.html.

Pruebas unitarias

src/test/java/com/datastructures/hashing/hashtable/classic/HashTableTest.java
package com.datastructures.hashing.hashtable.classic;

import org.junit.jupiter.api.Test;

import java.util.NoSuchElementException;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class HashTableTest {

    @Test
    void startsEmpty() {
        HashTable<String, Integer> table = new HashTable<>();

        assertThat(table.isEmpty()).isTrue();
        assertThat(table.size()).isZero();
    }

    @Test
    void putThenGetReturnsTheStoredValue() {
        HashTable<String, Integer> table = new HashTable<>();

        table.put("a", 1);

        assertThat(table.get("a")).isEqualTo(1);
        assertThat(table.size()).isEqualTo(1);
        assertThat(table.isEmpty()).isFalse();
    }

    @Test
    void getOnAMissingKeyReturnsNull() {
        HashTable<String, Integer> table = new HashTable<>();

        assertThat(table.get("missing")).isNull();
        assertThat(table.containsKey("missing")).isFalse();
    }

    @Test
    void puttingAnExistingKeyOverwritesTheValueAndReturnsThePrevious() {
        HashTable<String, Integer> table = new HashTable<>();
        table.put("a", 1);

        Integer previous = table.put("a", 2);

        assertThat(previous).isEqualTo(1);
        assertThat(table.get("a")).isEqualTo(2);
        assertThat(table.size()).isEqualTo(1);
    }

    @Test
    void removeDeletesTheEntryAndReturnsItsValue() {
        HashTable<String, Integer> table = new HashTable<>();
        table.put("a", 1);

        Integer removed = table.remove("a");

        assertThat(removed).isEqualTo(1);
        assertThat(table.containsKey("a")).isFalse();
        assertThat(table.size()).isZero();
    }

    @Test
    void removingAMissingKeyThrows() {
        HashTable<String, Integer> table = new HashTable<>();

        assertThatThrownBy(() -> table.remove("missing")).isInstanceOf(NoSuchElementException.class);
    }

    @Test
    void keysThatCollideOnTheSameBucketAreAllRetrievableIndependently() {
        HashTable<CollidingKey, String> table = new HashTable<>();
        CollidingKey first = new CollidingKey("first");
        CollidingKey second = new CollidingKey("second");
        CollidingKey third = new CollidingKey("third");

        table.put(first, "1");
        table.put(second, "2");
        table.put(third, "3");

        assertThat(table.get(first)).isEqualTo("1");
        assertThat(table.get(second)).isEqualTo("2");
        assertThat(table.get(third)).isEqualTo("3");
        assertThat(table.size()).isEqualTo(3);
    }

    @Test
    void removingOneCollidingKeyDoesNotAffectItsBucketmates() {
        HashTable<CollidingKey, String> table = new HashTable<>();
        CollidingKey first = new CollidingKey("first");
        CollidingKey second = new CollidingKey("second");
        table.put(first, "1");
        table.put(second, "2");

        table.remove(first);

        assertThat(table.containsKey(first)).isFalse();
        assertThat(table.get(second)).isEqualTo("2");
    }

    @Test
    void growingPastTheLoadFactorResizesAndKeepsEveryEntryRetrievable() {
        HashTable<Integer, Integer> table = new HashTable<>();
        int initialBucketCount = table.bucketCount();

        for (int i = 0; i < 1000; i++) {
            table.put(i, i * 10);
        }

        assertThat(table.bucketCount()).isGreaterThan(initialBucketCount);
        assertThat(table.size()).isEqualTo(1000);
        for (int i = 0; i < 1000; i++) {
            assertThat(table.get(i)).isEqualTo(i * 10);
        }
    }

    @Test
    void putRejectsNullKeys() {
        HashTable<String, Integer> table = new HashTable<>();

        assertThatThrownBy(() -> table.put(null, 1)).isInstanceOf(NullPointerException.class);
    }

    /** A key whose hashCode is fixed regardless of content, to force every instance into the same bucket. */
    private static final class CollidingKey {
        private final String label;

        CollidingKey(String label) {
            this.label = label;
        }

        @Override
        public int hashCode() {
            return 42;
        }

        @Override
        public boolean equals(Object other) {
            return other instanceof CollidingKey that && this.label.equals(that.label);
        }
    }
}
src/test/java/com/datastructures/hashing/hashtable/applied/IdempotencyKeyCacheTest.java
package com.datastructures.hashing.hashtable.applied;

import org.junit.jupiter.api.Test;

import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;

import static org.assertj.core.api.Assertions.assertThat;

class IdempotencyKeyCacheTest {

    @Test
    void aKeySeenForTheFirstTimeIsNotADuplicate() {
        IdempotencyKeyCache cache = new IdempotencyKeyCache(Clock.systemUTC());

        assertThat(cache.isDuplicate("tx-1")).isFalse();
    }

    @Test
    void markingAKeyProcessedMakesTheNextCheckReportADuplicate() {
        IdempotencyKeyCache cache = new IdempotencyKeyCache(Clock.systemUTC());

        cache.markProcessed("tx-1");

        assertThat(cache.isDuplicate("tx-1")).isTrue();
    }

    @Test
    void differentKeysAreTrackedIndependently() {
        IdempotencyKeyCache cache = new IdempotencyKeyCache(Clock.systemUTC());

        cache.markProcessed("tx-1");

        assertThat(cache.isDuplicate("tx-1")).isTrue();
        assertThat(cache.isDuplicate("tx-2")).isFalse();
        assertThat(cache.size()).isEqualTo(1);
    }

    @Test
    void reMarkingTheSameKeyDoesNotGrowTheCache() {
        IdempotencyKeyCache cache = new IdempotencyKeyCache(Clock.systemUTC());

        cache.markProcessed("tx-1");
        cache.markProcessed("tx-1");

        assertThat(cache.size()).isEqualTo(1);
    }

    @Test
    void evictOlderThanRemovesOnlyExpiredEntries() {
        Instant now = Instant.parse("2026-08-16T12:00:00Z");
        MutableClock clock = new MutableClock(now);
        IdempotencyKeyCache cache = new IdempotencyKeyCache(clock);

        cache.markProcessed("old-tx");
        clock.advance(Duration.ofMinutes(10));
        cache.markProcessed("recent-tx");

        cache.evictOlderThan(now.plus(Duration.ofMinutes(5)));

        assertThat(cache.isDuplicate("old-tx")).isFalse();
        assertThat(cache.isDuplicate("recent-tx")).isTrue();
        assertThat(cache.size()).isEqualTo(1);
    }

    /** A JDK {@link Clock} whose {@code instant()} can be moved forward on demand for tests. */
    private static final class MutableClock extends Clock {
        private Instant current;

        MutableClock(Instant current) {
            this.current = current;
        }

        void advance(Duration duration) {
            current = current.plus(duration);
        }

        @Override
        public ZoneOffset getZone() {
            return ZoneOffset.UTC;
        }

        @Override
        public Clock withZone(java.time.ZoneId zone) {
            throw new UnsupportedOperationException();
        }

        @Override
        public Instant instant() {
            return current;
        }
    }
}

Ver informe completo de cobertura JaCoCo →