← Todas as estruturas

Hash Table

Hashing · ver código-fonte no GitHub

Leia em: English · Português · Español

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

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;
        }
    }
}

Ver relatório completo de cobertura JaCoCo →