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
- ¿Necesitas recorrido ordenado, consultas por rango, o búsquedas de "clave más cercana" (floor/ceiling)? Una hash table no tiene orden por construcción — mira el módulo Binary Search Tree de este repositorio.
- ¿Necesitas una garantía de peor caso (no solo de caso promedio) O(log n)? Un árbol balanceado acota el peor caso; el peor caso de una hash table es O(n), aunque sea poco frecuente en la práctica.
- Las claves con un
hashCode()de baja calidad (o uno que un adversario pueda predecir y atacar) degradan hacia el benchmark de colisión de arriba — esta es una clase real de ataque (hash-flooding DoS), no una preocupación teórica.
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;
}
}
}