← Todas as estruturas

Trie

Árvores · ver código-fonte no GitHub

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

Categoria: Trees

O problema

Uma Hash Table responde "essa chave exata existe?" em O(1) médio, mas não consegue responder "existe alguma chave que comece com este prefixo?" sem varrer todas as chaves armazenadas — o hashing descarta de propósito qualquer relação estrutural entre chaves parecidas. Autocompletar, validação de prefixo e "faz sentido continuar digitando isso" precisam dessa relação preservada.

A solução

Armazene as chaves caractere por caractere ao longo de uma árvore: cada nó guarda seus filhos indexados pelo próximo caractere, e uma flag por nó marca "uma chave completa termina aqui". Buscar uma chave ou um prefixo significa percorrer um caractere de cada vez a partir da raiz — o custo é O(m), em que m é o comprimento da chave ou do prefixo, e, fundamentalmente, esse custo não tem nada a ver com quantas outras chaves estão armazenadas. Uma trie com 100 chaves e uma com 100.000 respondem a mesma consulta de prefixo no mesmo tempo, porque o percurso só toca nós ao longo de um único caminho.

flowchart TD
    R((root)) --> P((p))
    P --> PI((i))
    PI --> PIX(("x*"))
    PIX --> PIX1((1))
    PIX --> PIX2((2))

* marca um nó onde uma chave completa termina (por exemplo, "pix" em si é uma chave cadastrada, assim como "pix1" e "pix2").

Operação Custo Por quê
insert(key) O(m) um nó é criado ou reaproveitado por caractere de key
contains(key) O(m) percorre o caminho exato de key, verifica a flag de fim de palavra
startsWith(prefix) O(m) percorre o caminho exato de prefix, a mera existência já basta

m = comprimento da chave/prefixo. Nenhuma dessas operações depende de quantas outras chaves estão armazenadas — veja o benchmark abaixo.

Exemplo clássico

classic/Trie constrói os filhos de cada nó como um Map<Character, Node> em vez de um array fixo de 26/128 posições, já que as chaves PIX não estão restritas a um único alfabeto (letras, dígitos, @, ., +). TrieTest cobre um bug real encontrado durante a escrita: o nó raiz existe incondicionalmente como campo (não é criado por insert), então startsWith("") em uma trie completamente vazia retornaria true — uma proteção para trie vazia em startsWith corrige isso, e o teste trava o comportamento correto (false).

Exemplo aplicado: índice de prefixo de chaves PIX do BACEN

applied/PixKeyPrefixIndex valida e autocompleta chaves PIX (as chaves cadastradas no BACEN podem ser um CPF, e-mail, número de telefone ou uma chave aleatória no estilo UUID) enquanto o usuário digita em um formulário de pagamento, sem uma ida e volta ao serviço de diretório a cada tecla pressionada: hasKeyStartingWith sustenta a interface de autocompletar, isRegisteredKey é a verificação de correspondência exata quando a digitação termina. PixKeyPrefixIndexTest cobre os dois casos.

Benchmark

./gradlew :trees:trie:jmh

Execução real (JMH 1.37, JDK 26.0.2, 2 iterações de aquecimento + 3 de medição, 1 fork). O comprimento da chave é mantido constante (chaves de 12 caracteres, "PIX" + um número de 9 dígitos com zeros à esquerda) enquanto o número de chaves armazenadas varia — o formato deliberadamente diferente em relação ao benchmark de todos os outros módulos, já que a afirmação aqui é que esse eixo não deveria importar em nada:

Operação 100 chaves 10,000 chaves 100,000 chaves
contains 102.0 ns 98.7 ns 98.2 ns
startsWith 73.5 ns 89.2 ns 58.4 ns

Estável dentro da margem de ruído ao longo de um aumento de 1,000x na quantidade de chaves armazenadas — nenhuma das duas operações se importa com quantas outras chaves compartilham a trie. Compare com Hash Table, em que uma busca por correspondência exata também é estável em relação ao tamanho, mas não consegue responder a uma consulta de prefixo de jeito nenhum sem uma varredura O(n) de todas as chaves.

Quando não usar

Cobertura de testes

100% de cobertura de instruções, 100% de cobertura de branches (JaCoCo). Reproduza você mesmo:

./gradlew :trees:trie:jacocoTestReport

Relatório em trees/trie/build/reports/jacoco/test/html/index.html.

Testes unitários

src/test/java/com/datastructures/trees/trie/classic/TrieTest.java
package com.datastructures.trees.trie.classic;

import org.junit.jupiter.api.Test;

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

class TrieTest {

    @Test
    void startsEmpty() {
        Trie trie = new Trie();

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

    @Test
    void insertThenContainsFindsTheExactKey() {
        Trie trie = new Trie();

        trie.insert("cat");

        assertThat(trie.contains("cat")).isTrue();
        assertThat(trie.size()).isEqualTo(1);
        assertThat(trie.isEmpty()).isFalse();
    }

    @Test
    void containsIsFalseWhenTheKeyWasNeverInserted() {
        Trie trie = new Trie();
        trie.insert("cat");

        assertThat(trie.contains("dog")).isFalse();
    }

    @Test
    void containsIsFalseForAStoredPrefixThatWasNeverInsertedAsItsOwnKey() {
        Trie trie = new Trie();
        trie.insert("caterpillar");

        // "cat" exists as a path through the trie (it's a prefix of "caterpillar"), but it was
        // never itself inserted as a complete key, so contains() must say no.
        assertThat(trie.contains("cat")).isFalse();
        assertThat(trie.startsWith("cat")).isTrue();
    }

    @Test
    void insertingTheSameKeyTwiceDoesNotDoubleCountSize() {
        Trie trie = new Trie();
        trie.insert("cat");

        trie.insert("cat");

        assertThat(trie.size()).isEqualTo(1);
        assertThat(trie.contains("cat")).isTrue();
    }

    @Test
    void insertingKeysThatShareAPrefixReusesTheSharedNodes() {
        Trie trie = new Trie();
        trie.insert("car");

        trie.insert("card"); // shares "car" with the first key, then extends with "d"

        assertThat(trie.contains("car")).isTrue();
        assertThat(trie.contains("card")).isTrue();
        assertThat(trie.size()).isEqualTo(2);
    }

    @Test
    void startsWithIsTrueForAnyStoredPrefixOfAKey() {
        Trie trie = new Trie();
        trie.insert("caterpillar");

        assertThat(trie.startsWith("c")).isTrue();
        assertThat(trie.startsWith("cate")).isTrue();
        assertThat(trie.startsWith("caterpillar")).isTrue();
    }

    @Test
    void startsWithIsFalseWhenNoStoredKeyMatchesThePrefixAtAll() {
        Trie trie = new Trie();
        trie.insert("cat");

        assertThat(trie.startsWith("dog")).isFalse();
    }

    @Test
    void startsWithIsFalseWhenThePrefixDivergesPartwayThroughAStoredKey() {
        Trie trie = new Trie();
        trie.insert("cat");

        // Shares "ca" with the stored key but diverges at the third character.
        assertThat(trie.startsWith("cow")).isFalse();
    }

    @Test
    void emptyStringIsAPrefixOfEverythingAndTheEmptyKeyIsHandledAsAWordItself() {
        Trie trie = new Trie();

        assertThat(trie.startsWith("")).isFalse(); // nothing stored yet at all

        trie.insert("");

        assertThat(trie.contains("")).isTrue();
        assertThat(trie.startsWith("")).isTrue();
        assertThat(trie.size()).isEqualTo(1);

        trie.insert("cat");

        assertThat(trie.startsWith("")).isTrue();
        assertThat(trie.size()).isEqualTo(2);
    }
}
src/test/java/com/datastructures/trees/trie/applied/PixKeyPrefixIndexTest.java
package com.datastructures.trees.trie.applied;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

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

class PixKeyPrefixIndexTest {

    private final PixKeyPrefixIndex index = new PixKeyPrefixIndex();

    @BeforeEach
    void registerAFewPixKeysOfDifferentTypes() {
        index.register("12345678900"); // CPF-style key
        index.register("leon.gomes@example.com"); // email key
        index.register("+5511999998888"); // phone key
        index.register("a1b2c3d4-e5f6-7890-abcd-ef1234567890"); // random UUID-style key
    }

    @Test
    void anExactlyRegisteredKeyIsRecognizedAsRegistered() {
        assertThat(index.isRegisteredKey("leon.gomes@example.com")).isTrue();
    }

    @Test
    void aKeyThatWasNeverRegisteredIsNotRecognized() {
        assertThat(index.isRegisteredKey("someone-else@example.com")).isFalse();
    }

    @Test
    void autocompleteRecognizesAPartiallyTypedPrefixOfARegisteredKey() {
        assertThat(index.hasKeyStartingWith("leon.")).isTrue();
        assertThat(index.hasKeyStartingWith("+5511")).isTrue();
    }

    @Test
    void autocompleteRejectsAPrefixThatMatchesNoRegisteredKey() {
        assertThat(index.hasKeyStartingWith("99999")).isFalse();
    }

    @Test
    void anEmptyIndexHasNoStartingMatchesAndNoRegisteredKeys() {
        PixKeyPrefixIndex emptyIndex = new PixKeyPrefixIndex();

        assertThat(emptyIndex.hasKeyStartingWith("1")).isFalse();
        assertThat(emptyIndex.isRegisteredKey("12345678900")).isFalse();
    }
}

Ver relatório completo de cobertura JaCoCo →