Categoria: Hashing
O problema
Buscar um valor por chave em uma lista ou array significa varrer — O(n) no pior caso, e também na média se a chave pudesse estar em qualquer lugar. Conforme o conjunto de dados cresce, essa varredura fica proporcionalmente mais lenta. O que se precisa é de uma forma de pular direto para, aproximadamente, onde o valor de uma chave vive, sem varrer o que veio antes dela.
A solução
Calcule um hash numérico a partir da chave, reduza-o a um índice em um array de buckets de tamanho fixo, e armazene a entrada ali. Duas chaves diferentes podem gerar hash para o mesmo bucket (uma colisão); esta tabela resolve isso com encadeamento separado (separate chaining) — cada bucket contém uma pequena cadeia encadeada de entradas, e uma busca percorre apenas essa cadeia, não a tabela inteira. O O(1) médio de busca se mantém enquanto as cadeias permanecerem curtas, e é por isso que a tabela dobra sua contagem de buckets e refaz o hash de tudo assim que o fator de carga (entradas ÷ buckets) ultrapassa 0.75 — isso mantém o comprimento médio da cadeia limitado independentemente de quanto a tabela cresça.
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"]
| Operação | Média | Pior caso | Por que o pior caso acontece |
|---|---|---|---|
get / put / remove |
O(1) | O(n) | toda chave colide no mesmo bucket |
| resize (disparado internamente) | O(n) | O(n) | cada entrada tem seu hash refeito na nova tabela |
Exemplo clássico
classic/HashTable
implementa encadeamento separado do zero — sem java.util.HashMap por baixo. Ele espalha as
chaves com o mesmo truque hashCode() ^ (h >>> 16) que o HashMap usa (dobrando os bits altos
para baixo para que uma tabela de tamanho potência de dois, que olha apenas para os bits baixos,
não colapse no mesmo bucket hashes que diferem só nos bits altos), e redimensiona dobrando de
tamanho assim que o fator de carga ultrapassa 0.75.
HashTableTest
força colisões reais com uma chave cujo hashCode() é constante, e verifica que toda entrada
sobrevive a um redimensionamento.
Exemplo aplicado: cache de chave de idempotência do PIX
applied/IdempotencyKeyCache
é a pré-verificação em memória que um gateway de pagamento executa antes de uma transação PIX
chegar ao banco de dados, onde uma constraint de unicidade sobre a chave de idempotência é a
real fonte de verdade. Uma verificação "já vi essa chave?" com O(1) médio evita uma ida ao banco
no caso comum: um cliente reenviando a mesma requisição segundos depois. A tabela não tem
ordenação, então evictOlderThan — expirar entradas antigas — é necessariamente uma varredura
completa O(n); um cache de produção que precisasse de remoção barata combinaria uma tabela hash
com uma lista duplamente encadeada entrelaçada pelas entradas (a combinação clássica de cache
LRU), que é o trade-off que este módulo deixa visível em vez de esconder.
IdempotencyKeyCacheTest
cobre detecção de duplicatas e remoção baseada em tempo com um clock controlável.
Benchmark
./gradlew :hashing:hash-table:jmh
Execução real (JMH 1.37, JDK 26.0.2, 2 iterações de aquecimento + 3 de medição, 1 fork). Dois conjuntos de chaves dos mesmos tamanhos: um com hash normal, outro projetado para que toda chave colida no bucket 0.
Custo de get |
size=100 | size=10,000 | size=100,000 |
|---|---|---|---|
| hashing uniforme | 3.79 ns | 3.65 ns | 3.77 ns |
| toda chave colidindo em um bucket | 133.8 ns | 29,083.8 ns | 179,652.8 ns |
O hashing uniforme se mantém estável independentemente do tamanho — O(1), confirmado. O
conjunto de chaves colidentes fica cerca de 200x mais lento ao ir de 100 para 10,000 chaves (um
aumento de 100x no tamanho), que é exatamente a cara de uma varredura linear de cadeia O(n)
quando toda chave vive no mesmo bucket. Esse é também o motivo, no mundo real, pelo qual um
hashCode() de má qualidade ou previsível por um atacante é uma preocupação de corretude e
de negação de serviço, não apenas um detalhe de performance.
Quando não usar
- Precisa de percurso ordenado, consultas por intervalo, ou buscas de "chave mais próxima" (floor/ceiling)? Uma hash table não tem ordenação por construção — veja o módulo Binary Search Tree deste repositório.
- Precisa de uma garantia de pior caso (não só de caso médio) O(log n)? Uma árvore balanceada limita o pior caso; o pior caso de uma hash table é O(n), mesmo que raro na prática.
- Chaves com um
hashCode()de baixa qualidade (ou um que um adversário consiga prever e mirar) degradam em direção ao benchmark de colisão acima — essa é uma classe real de ataque (hash-flooding DoS), não uma preocupação teórica.
Cobertura de testes
100% de cobertura de instruções, 100% de cobertura de branches (JaCoCo). Reproduza você mesmo:
./gradlew :hashing:hash-table:jacocoTestReport
Relatório em hashing/hash-table/build/reports/jacoco/test/html/index.html.
Testes unitários
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;
}
}
}