INF01203 - Estruturas de Dados - Instituto de Informática (UFRGS) - Prof. Dennis Giovani Balreira



Aula 22 - Árvores de Busca em Strings: Trie, Patrícia e TST

Imagine que você está construindo um mecanismo de autocompletar — como o campo de busca do Google ou a barra de endereços do navegador. O usuário digita "pre" e em milissegundos você precisa retornar todas as palavras do seu dicionário de 100.000 entradas que começam com "pre": "prefixo", "preto", "preço", "preguiça", "presidência", ...

Quão rápido as estruturas que você conhece resolvem isso?

  • ABP (árvore binária de busca): cada nó guarda uma palavra inteira e dois ponteiros (subárvore esquerda e direita). A árvore é organizada em ordem lexicográfica (a do dicionário): uma palavra menor que a do nó vai para a esquerda, uma maior vai para a direita. Por exemplo, inserindo "mesa", "casa", "preto", "prefixo" e "zebra": "mesa" é a raiz; "casa" < "mesa" vai à esquerda; "preto" > "mesa" vai à direita; "prefixo" > "mesa" e < "preto" fica à esquerda de "preto"; "zebra" fica à direita de "preto". Em cada nó visitado, comparar duas palavras exige ler os caracteres desde o início até achar o primeiro que difere: custo O(m), onde m é o comprimento da palavra. Como todas as palavras com prefixo "pre" ocupam uma faixa contínua da ordem lexicográfica, dá para descer até a primeira delas e coletar as demais em ordem, mas descer custa O(m) por nível (O(m·h), com h a altura da árvore — que chega a n se ela estiver desbalanceada) e o prefixo "pre" é relido, caractere por caractere, em cada comparação. A árvore não sabe que "prefixo", "preto" e "preço" compartilham o início: cada uma guarda e compara a palavra toda de novo.
  • Tabela hash: O(1) para busca exata — mas completamente incapaz de busca por prefixo. O hash de "pre" não tem nenhuma relação com os hashes de "prefixo", "preto" ou "preço". Não há como pular de um para o outro.
  • Vetor ordenado com busca binária: O(m log n) para busca exata; busca por prefixo é possível mas exige O(m log n) para localizar o ponto de início mais O(k) para coletar os k resultados — e ainda precisa manter o vetor ordenado a custo O(mn) nas inserções.

A raiz do problema: essas estruturas tratam cada palavra como um bloco opaco. Elas não exploram a estrutura interna dos caracteres. Para strings, existem estruturas especializadas que são dramaticamente mais eficientes. Nesta aula: Trie, Patrícia (Trie Comprimida) e TST.


Exemplos retráteis: os exemplos desta aula ficam recolhidos (clique em "Ver exemplo" ou no título do passo). Expandir todos | Recolher todos

1. Trie (Árvore de Prefixos)

1.1 Estrutura

Uma Trie (do inglês retrieval) é uma árvore em que cada nó representa um caractere — não uma palavra inteira. O caminho da raiz até um nó marcado como "fim de palavra" representa uma chave armazenada.

  • Cada nó tem um vetor de filhos com uma entrada por caractere do alfabeto (26 para letras minúsculas).
  • Cada entrada pode ser NULL (nenhuma palavra passa por aqui) ou apontar para o próximo nó.
  • Cada nó tem um booleano fim que indica se uma palavra termina naquele nó.
  • A raiz não representa nenhum caractere — ela é o ponto de partida.
Observação importante: um nó pode ter fim = true e ainda ter filhos. Por exemplo, se o dicionário contém "ar" e "arco", o nó 'r' (no caminho "a"→"r") tem fim = true (palavra "ar") e tem um filho 'c' (continuação de "arco").

Exemplo usado em toda a seção: as palavras "ar", "arco", "arte" e "as". Círculo duplo = fim de palavra (fim = true).

raiz a r "ar" c o "arco" t e "arte" s "as"

1.2 Inserção

Seguimos a chave letra por letra a partir da raiz. Para cada letra, usamos o filho correspondente se ele existir e o criamos se não existir. Ao acabar as letras, marcamos fim = true no último nó. Os casos:

Verde = caminho percorrido (verde forte = encontrado); vermelho = onde a busca para, ou o que será removido (tracejado); amarelo = novo ou alterado; círculo duplo = fim de palavra.

Caso 1 — Algum nó do caminho não existe

Criam-se os nós que faltam e marca-se o fim no último.

Ver exemplo: Inserir "arpa"
raiz a r "ar" c o "arco" p a "arpa" t e "arte" s "as"
Inserir "arpa". Os nós verdes já existiam. O nó 'r' não tem filho 'p': cria-se 'p' e, abaixo dele, 'a' (amarelos), que marca o fim.

Caso 2 — O caminho já existe, mas o último nó não marca fim

A palavra era apenas prefixo de outra. Não se cria nó algum: só se marca o fim.

Ver exemplo: Inserir "arc"
raiz a r "ar" c "arc" o "arco" t e "arte" s "as"
Inserir "arc". Todo o caminho já existia (era prefixo de "arco"), mas 'c' não marcava fim. Marcamos o fim nele (amarelo).

Caso 3 — O caminho existe e o último nó já marca fim

A palavra já está na árvore: nada muda.

Ver exemplo: Inserir "arco"
raiz a r "ar" c já existe o "arco" t e "arte" s "as"
Inserir "arco". O caminho existe e 'o' já marca fim: nada muda.

1.3 Busca

Seguimos a chave letra por letra a partir da raiz. O resultado depende de onde o caminho termina; a palavra só é encontrada no caso 1.

Caso 1 — Encontrada

Todos os nós do caminho existem e o último marca fim.

Ver exemplo: "arco"
raiz a r "ar" c o "arco" t e "arte" s "as"
"arco": todos os nós existem e o último marca fim → encontrada.

Caso 2 — O caminho se interrompe

Algum nó não tem o filho da próxima letra: a palavra não existe.

Ver exemplo: "arca"
raiz a r "ar" sem filho 'a' c o "arco" t e "arte" s "as"
"arca": o nó 'c' não tem filho 'a' → não encontrada.

Caso 3 — O caminho existe, mas o último nó não marca fim

A chave é só um prefixo de outras palavras.

Ver exemplo: "arc"
raiz a r "ar" fim = false c o "arco" t e "arte" s "as"
"arc": o caminho existe até 'c', mas 'c' não marca fim (é só prefixo de "arco") → não encontrada.

1.4 Remoção

Busca-se a palavra e desmarca-se o fim. Depois, subindo pelo caminho, apagam-se os nós que ficaram sem filhos e sem marca de fim; a subida pára no primeiro nó que ainda é necessário. Os casos:

Caso 1 — A palavra não existe

A busca falha e a árvore não muda.

Ver exemplo: Remover "arca"
raiz a r "ar" sem filho 'a' c o "arco" t e "arte" s "as"
Remover "arca". A busca falha em 'c' (não há filho 'a'): a palavra não existe e nada muda.

Caso 2 — A palavra é prefixo de outras (o nó tem filhos)

Nenhum nó pode ser apagado, porque outras palavras passam por ele: só se desmarca o fim.

Ver exemplo: Remover "ar"
raiz a r "ar" c o "arco" t e "arte" s "as"
Remover "ar" — antes. Encontramos 'r', que marca fim mas tem filhos ('c' e 't').
raiz a r c o "arco" t e "arte" s "as"
Depois. Nada é apagado: 'r' só perde a marca de fim (amarelo). "arco" e "arte" continuam usando o caminho.

Caso 3 — O nó é folha (a palavra termina no fim de um ramo)

Apaga-se o nó e sobe-se, apagando cada nó que ficar sem filhos e sem fim, até encontrar um nó ainda necessário. Duas formas de a subida parar:

3a — Para num nó que ainda tem outro ramo:

Ver exemplo: Remover "arco"
raiz a r "ar" c o "arco" t e "arte" s "as"
Remover "arco" — antes. 'o' é folha: perde o fim e é apagado. Subindo, 'c' fica sem filhos e sem fim: também é apagado.
raiz a r "ar" t e "arte" s "as"
Depois. A subida pára em 'r' (amarelo): ele ainda tem outro filho ('t') e marca o fim de "ar".

3b — Para num nó que marca fim:

Ver exemplo: Remover "arte" (na árvore com "ar", "arte" e "as")
raiz a r "ar" t e "arte" s "as"
Remover "arte" (na árvore com "ar", "arte" e "as") — antes. 'e' e depois 't' ficam sem filhos e sem fim: são apagados.
raiz a r "ar" s "as"
Depois. A subida pára em 'r' (amarelo) porque ele marca o fim de "ar", mesmo sem ter mais filhos.

1.5 Exemplo completo: todos os casos em sequência

Exemplos retráteis: os exemplos desta aula ficam recolhidos (clique em "Ver exemplo" ou no título do passo). Expandir todos | Recolher todos

Uma sequência de operações sobre uma única árvore, partindo da árvore vazia. Cada passo indica qual caso (das seções de inserção, busca e remoção acima) ele exemplifica; no conjunto, os passos cobrem todos os casos das três operações, como mostra a tabela. Nos desenhos, cada passo mostra a árvore após a operação (nas remoções, antes e depois).

OperaçãoCasoPasso(s)
InserçãoCaso 1 — algum nó do caminho não existe1, 2, 3
InserçãoCaso 2 — o caminho existe, mas o último nó não marca fim4, 12
InserçãoCaso 3 — o caminho existe e o último nó já marca fim5
BuscaCaso 1 — encontrada6
BuscaCaso 2 — o caminho se interrompe7
BuscaCaso 3 — o último nó não marca fim8
RemoçãoCaso 1 — a palavra não existe9
RemoçãoCaso 2 — a palavra é prefixo de outras (nó com filhos)10
RemoçãoCaso 3a — folha; a subida pára num nó com outro ramo11
RemoçãoCaso 3b — folha; a subida pára num nó que marca fim13

Verde = caminho percorrido (verde forte = encontrado); vermelho = onde a busca para, ou o que será removido (tracejado); amarelo = novo ou alterado; círculo duplo = fim de palavra.

Passo 1 — Inserir "arco" (Inserção, caso 1)

Árvore vazia: nenhum nó do caminho existe, então criam-se 'a', 'r', 'c' e 'o', e 'o' marca o fim.

raiz a r c o "arco"
Passo 2 — Inserir "arte" (Inserção, caso 1)

'a' e 'r' já existem; criam-se 't' e 'e' (fim).

raiz a r c o "arco" t e "arte"
Passo 3 — Inserir "as" (Inserção, caso 1)

'a' já existe; cria-se 's' (fim).

raiz a r c o "arco" t e "arte" s "as"
Passo 4 — Inserir "ar" (Inserção, caso 2)

O caminho 'a' → 'r' já existia (era prefixo de "arco" e "arte"), mas 'r' não marcava fim: só marcamos o fim.

raiz a r "ar" c o "arco" t e "arte" s "as"
Passo 5 — Inserir "ar" (Inserção, caso 3)

'r' já marca fim: a palavra já está na árvore e nada muda.

raiz a já existe r "ar" c o "arco" t e "arte" s "as"
Passo 6 — Buscar "arco" (Busca, caso 1)

Todos os nós existem e o último marca fim: encontrada.

raiz a r "ar" c o "arco" t e "arte" s "as"
Passo 7 — Buscar "arca" (Busca, caso 2)

O nó 'c' não tem filho 'a': o caminho se interrompe.

raiz a r "ar" sem filho 'a' c o "arco" t e "arte" s "as"
Passo 8 — Buscar "arc" (Busca, caso 3)

O caminho existe, mas 'c' não marca fim: é só prefixo de "arco".

raiz a r "ar" fim = false c o "arco" t e "arte" s "as"
Passo 9 — Remover "arca" (Remoção, caso 1)

A palavra não existe (a busca falha em 'c'): nada muda.

raiz a r "ar" sem filho 'a' c o "arco" t e "arte" s "as"
Passo 10 — Remover "ar" (Remoção, caso 2)

'r' tem filhos ('c' e 't'): só perde a marca de fim.

raiz a r "ar" c o "arco" t e "arte" s "as"
Antes
raiz a r c o "arco" t e "arte" s "as"
Depois
Passo 11 — Remover "arco" (Remoção, caso 3a)

'o' e 'c' são apagados; a subida pára em 'r' porque ele ainda tem outro filho ('t').

raiz a r c o "arco" t e "arte" s "as"
Antes
raiz a r t e "arte" s "as"
Depois
Passo 12 — Inserir "ar" (Inserção, caso 2 (de novo))

Para preparar o próximo caso, "ar" volta a ser palavra: o caminho existe e 'r' não marca fim, então só marcamos o fim.

raiz a r "ar" t e "arte" s "as"
Passo 13 — Remover "arte" (Remoção, caso 3b)

'e' e 't' são apagados; a subida pára em 'r' porque ele marca o fim de "ar", mesmo sem ter mais filhos.

raiz a r "ar" t e "arte" s "as"
Antes
raiz a r "ar" s "as"
Depois

1.6 Complexidade

m = comprimento da chave (número de letras); n = número de chaves (palavras) armazenadas; |Σ| = tamanho do alfabeto (26 para a-z), tratado como constante; p = comprimento do prefixo buscado; t = nós visitados abaixo do prefixo (pelo menos o número de palavras encontradas e, no máximo, o total de letras delas).

Pior caso = o maior custo que pode acontecer, para o pior conjunto de palavras e a pior ordem de inserção. Caso médio = o custo típico, com palavras em ordem aleatória. Quando as duas colunas coincidem, o custo não depende de azar nos dados.

Operação Pior caso Caso médio Em uma frase
BuscaO(m)O(m)um passo por letra da chave
InserçãoO(m)O(m)desce e cria, no máximo, m nós novos
RemoçãoO(m)O(m)desce m passos e sobe, no máximo, m passos
Busca por prefixoO(p + t)O(p + t)desce p passos e depois percorre a subárvore
EspaçoO(n × m × |Σ|)bem menorprefixos em comum são guardados uma só vez

Por que busca, inserção e remoção custam O(m)?

Intuição: a Trie "lê" a chave letra por letra. Para cada letra ela dá um único passo: usa a letra como índice no vetor de filhos do nó atual (acesso direto, sem comparar com nada) e desce. Uma chave de m letras exige, no máximo, m passos. Disso decorrem duas coisas:

  • O custo não depende de n. Buscar "arco" custa 4 passos com 10 mil palavras e os mesmos 4 passos com 10 milhões. Numa ABP balanceada com 1 milhão de palavras, seriam cerca de 20 comparações de palavras inteiras (log2 106 ≈ 20).
  • O custo não depende da forma da árvore: para uma dada chave, o caminho sempre tem no máximo m nós. Não existe "entrada azarada"; por isso o pior caso e o caso médio são ambos O(m).
  • Busca: o pior caso é quando a palavra existe (exatamente m passos) ou quando a falta só aparece na última letra. Se faltar uma letra no meio, a busca para antes (ex.: "xyz" para no 1.º passo, custo O(1)), mas o limite O(m) vale para todas.
  • Inserção: desce pelos nós que já existem e cria os que faltam: no máximo m nós novos. Criar um nó = alocar e zerar |Σ| ponteiros, o que é constante para alfabeto fixo (se contarmos o zeramento, o custo é O(m × |Σ|)).
  • Remoção: desce m passos até o nó da palavra e sobe, no máximo m passos, apagando os nós que ficaram sem filhos e sem fim. Saber se um nó ficou "sem filhos" exige olhar seus |Σ| ponteiros (constante para alfabeto fixo).

Busca por prefixo: O(p + t)

Descemos p passos até o nó do prefixo e depois percorremos toda a subárvore abaixo dele para coletar as palavras. O custo é proporcional ao tamanho da resposta: t é pelo menos o número de palavras encontradas (cada uma termina num nó) e, no máximo, o total das letras delas (nós compartilhados são visitados uma só vez). Um prefixo curto como "a" pode devolver metade do dicionário e t ser enorme, mas aí o trabalho extra é justamente gerar a resposta. A fórmula é a mesma no pior caso e no caso médio.

Espaço

  • Pior caso: palavras sem prefixo em comum. Cada palavra cria m nós próprios: n × m nós, cada um com |Σ| ponteiros → O(n × m × |Σ|). Ex.: 1.000 palavras de 7 letras → até 7.000 nós × 26 ponteiros × 8 bytes ≈ 1,5 MB.
  • Caso típico: bem menor, porque palavras que começam igual compartilham os nós do início. "ar", "arco", "arte" e "as" têm 12 letras no total, mas a Trie tem apenas 7 nós (a, r, c, o, t, e, s) além da raiz.
  • O gargalo é o vetor de |Σ| ponteiros por nó: quase todas as posições ficam NULL.

1.7 Estrutura e funções em C

#define ALPHA 26

typedef struct NodoTrie {
    struct NodoTrie *filhos[ALPHA];
    int fim;  /* 1 se este nó termina uma palavra */
} NodoTrie;

NodoTrie *novoNodo(void);                              /* cria um nó vazio (usado também como raiz) */
void      inserir(NodoTrie *raiz, const char *s);       /* insere a palavra s */
int       buscar(NodoTrie *raiz, const char *s);        /* 1 se s está armazenada, 0 caso contrário */

main de demonstração:

int main(void) {
    NodoTrie *raiz = novoNodo();

    inserir(raiz, "ar");
    inserir(raiz, "arco");
    inserir(raiz, "arte");

    printf("buscar(arco) = %d\n", buscar(raiz, "arco"));   /* 1 */
    printf("buscar(arca) = %d\n", buscar(raiz, "arca"));   /* 0 */
    printf("buscar(arc)  = %d\n", buscar(raiz, "arc"));    /* 0: é só um prefixo */
    return 0;
}

1.8 O problema

Cada nó da Trie guarda 26 ponteiros, mesmo que quase todos fiquem NULL. Para 1.000 palavras com média de 7 letras são cerca de 7.000 nós × 26 ponteiros × 8 bytes ≈ 1,5 MB só em ponteiros, e muitos desses nós têm um único filho. A próxima estrutura, a Patrícia, ataca exatamente esse desperdício.


2. Patrícia (Trie Comprimida / Radix Tree)

2.1 Estrutura

Em uma Trie com vocabulário esparso, muitos nós têm exatamente um único filho: são nós de passagem, que só prolongam um caminho sem nenhuma bifurcação. Isso desperdiça memória e indireções.

A solução da Patrícia (Trie Comprimida): comprimir cadeias de nós com filho único em uma única aresta rotulada com a substring inteira.

raiz a r "ar" c o "arco" t e "arte" s "as"
Trie para "ar", "arco", "arte" e "as".
a r s co te raiz "ar" "arco" "arte" "as"
Patrícia para as mesmas palavras: as arestas "co" e "te" substituem c→o e t→e.

Regra de fusão. Um nó da Trie pode ser fundido com o seu único filho quando (1) ele tem exatamente um filho e (2) não marca fim de palavra. No exemplo, 'c' e 't' cumprem as duas condições e viram parte das arestas "co" e "te". Já o nó 'a' tem dois filhos ('r' e 's') e o 'r' marca o fim de "ar": ambos permanecem.

O que cada nó guarda:

  • o rótulo da aresta que chega nele (uma string, não um caractere);
  • a marca de fim de palavra;
  • os filhos, numa lista. A primeira letra do rótulo identifica a aresta: dois irmãos nunca começam com a mesma letra.

2.2 Inserção

Descemos a partir da raiz com o resto da chave: a primeira letra do resto escolhe a aresta e o rótulo é comparado de uma vez com o início do resto. Em cada passo vale um dos casos abaixo (exemplos na árvore com "ar", "arco", "arte" e "as"):

Verde = caminho percorrido (verde forte = encontrado); vermelho = onde a busca para, ou o que será removido (tracejado); amarelo = novo ou alterado; círculo duplo = fim de palavra.

Caso 1 — Nenhuma aresta começa com a letra

Cria-se uma folha com o resto da chave como rótulo, marcada como fim.

Ver exemplo: Inserir "arpa"
a r s co pa te raiz "ar" "arco" "arpa" "arte" "as"
Inserir "arpa". Descemos por "a" e "r" até o nó de "ar" (verde). Sobra "pa" e nenhuma aresta começa com 'p': cria-se a folha "pa" (amarelo).

Caso 2 — O rótulo casa inteiro com o início do resto

Desce-se para o filho e continua-se com o que sobrou da chave.

Ver exemplo: Inserir "arcos"
a r s co te s raiz "ar" "arco" "arcos" "arte" "as"
Inserir "arcos". As arestas "a", "r" e "co" casam inteiras (verde) e descemos por elas. Sobra "s" e a folha "arco" não tem filhos: cria-se a folha "s".

Caso 3 — O rótulo casa só em parte

Divide-se a aresta: um nó novo guarda o prefixo comum, e o resto do rótulo vira filho dele, levando junto a subárvore e a marca de fim antigas. Dois sub-casos, conforme sobre ou não parte da chave:

Ver exemplos (2 figuras): 3a — Inserir "arca"
a r s c te a o raiz "ar" "arca" "arco" "arte" "as"
3a — Inserir "arca". No nó de "ar" sobra "ca". A aresta "co" só compartilha o "c": ela é dividida em "c" + "o", e o resto da chave ("a") vira nova folha.
a r s c te o raiz "ar" "arc" "arco" "arte" "as"
3b — Inserir "arc". Mesma divisão de "co" em "c" + "o", mas a chave acaba exatamente no ponto da divisão: o nó novo marca o fim e não há folha nova.

Caso 4 — A chave acaba num nó que já existe

Nada é criado; basta olhar a marca de fim desse nó:

Ver exemplos (2 figuras): 4a — Inserir "a"
a r s co te raiz "a" "ar" "arco" "arte" "as"
4a — Inserir "a". A aresta "a" casa inteira e a chave acaba no nó seguinte, que não marcava fim: só marcamos o fim (amarelo).
a r s co te raiz já existe "ar" "arco" "arte" "as"
4b — Inserir "ar". A chave acaba no nó de "ar", que já marca fim: nada muda.

2.3 Busca

Descemos como na inserção, mas sem criar nada. A chave só é encontrada no caso 1; os outros quatro são falhas.

Caso 1 — Encontrada

Os rótulos casam inteiros até consumir a chave e o último nó marca fim.

Ver exemplo: "arco"
a r s co te raiz "ar" "arco" "arte" "as"
"arco": "a", "r" e "co" casam inteiras e o nó final marca fim → encontrada.

Caso 2 — Nenhuma aresta começa com a letra

No nó atual, nenhum filho tem rótulo começando com a próxima letra da chave.

Ver exemplo: "arma"
a r s co te raiz sem aresta com 'm' "ar" "arco" "arte" "as"
"arma": no nó de "ar" sobra "ma", mas nenhuma aresta começa com 'm' → não encontrada.

Caso 3 — O rótulo diverge no meio

A aresta começa com a letra certa, mas alguma letra seguinte do rótulo difere da chave.

Ver exemplo: "arca"
a r s co te raiz 'ca' difere de 'co' "ar" "arco" "arte" "as"
"arca": no nó de "ar" sobra "ca"; a aresta "co" começa com 'c', mas o 'a' diverge do 'o' (tracejada) → não encontrada.

Caso 4 — A chave acaba no meio de um rótulo

O que sobrou da chave é um prefixo do rótulo, mas não o rótulo inteiro.

Ver exemplo: "arc"
a r s co te raiz 'c' acaba no meio de 'co' "ar" "arco" "arte" "as"
"arc": sobra "c", que acaba no meio da aresta "co" (tracejada) → não encontrada (só prefixo).

Caso 5 — A chave acaba num nó que não marca fim

Os rótulos casam inteiros, mas a palavra não termina ali.

Ver exemplo: "a"
a r s co te raiz sem fim "ar" "arco" "arte" "as"
"a": a aresta "a" casa, mas o nó seguinte não marca fim (é só prefixo de outras palavras) → não encontrada.

2.4 Remoção

Busca-se a palavra e desmarca-se o fim. Depois, para manter a árvore comprimida: um nó sem filhos é apagado, e um nó que ficou com um único filho e sem marca de fim é fundido com esse filho (os rótulos se concatenam). Os casos:

Caso 1 — A palavra não existe

A busca falha e a árvore não muda.

Ver exemplo: Remover "arca"
a r s co te raiz 'ca' difere de 'co' "ar" "arco" "arte" "as"
Remover "arca". A busca falha (o rótulo "co" diverge de "ca"): a palavra não existe e nada muda.

Caso 2 — O nó da palavra tem dois ou mais filhos

Nada é apagado nem fundido: só se desmarca o fim.

Ver exemplo: Remover "ar"
a r s co te raiz "ar" "arco" "arte" "as"
Remover "ar" — antes. O nó de "ar" marca fim e tem dois filhos ("co" e "te").
a r s co te raiz "arco" "arte" "as"
Depois. O nó só perde a marca de fim (amarelo); continua com os dois filhos.

Caso 3 — O nó da palavra tem um único filho

Ao desmarcar o fim, o nó fica com um único filho e sem fim: funde-se com esse filho.

Ver exemplo: Remover "ar" (na árvore com "ar", "arte" e "as")
a r s te raiz "ar" "arte" "as"
Remover "ar" (na árvore com "ar", "arte" e "as") — antes. O nó de "ar" marca fim e tem um único filho ("te").
a rte s raiz "arte" "as"
Depois. As arestas "r" e "te" são fundidas em "rte" (amarelo).

Caso 4 — O nó da palavra é folha

Apaga-se a folha (e a aresta que chega nela). Depois olha-se o pai:

4a — O pai continua necessário (marca fim ou ainda tem outro filho): nada mais muda.

Ver exemplo: Remover "arco"
a r s co te raiz "ar" "arco" "arte" "as"
Remover "arco" — antes. A folha "co" é apagada.
a r s te raiz "ar" "arte" "as"
Depois. O pai (amarelo) marca o fim de "ar" e ainda tem o filho "te": continua necessário.

4b — O pai fica com um único filho e sem fim: funde-se com esse filho.

Ver exemplo: Remover "as"
a r s co te raiz "ar" "arco" "arte" "as"
Remover "as" — antes. A folha "s" é apagada.
ar co te raiz "ar" "arco" "arte"
Depois. O nó de cima ficou com um único filho ("r") e não marca fim: as arestas "a" e "r" são fundidas em "ar" (amarelo).

2.5 Exemplo completo: todos os casos em sequência

Exemplos retráteis: os exemplos desta aula ficam recolhidos (clique em "Ver exemplo" ou no título do passo). Expandir todos | Recolher todos

Uma sequência de operações sobre uma única árvore, partindo da árvore vazia. Cada passo indica qual caso (das seções de inserção, busca e remoção acima) ele exemplifica; no conjunto, os passos cobrem todos os casos das três operações, como mostra a tabela. Nos desenhos, cada passo mostra a árvore após a operação (nas remoções, antes e depois).

OperaçãoCasoPasso(s)
InserçãoCaso 1 — nenhuma aresta começa com a letra1, 6
InserçãoCaso 2 — o rótulo casa inteiro (desce)6
InserçãoCaso 3a — o rótulo casa em parte e sobra parte da chave2, 3
InserçãoCaso 3b — o rótulo casa em parte e a chave acaba na divisão7
InserçãoCaso 4a — a chave acaba num nó sem fim4
InserçãoCaso 4b — a chave acaba num nó que já marca fim5
BuscaCaso 1 — encontrada8
BuscaCaso 2 — nenhuma aresta começa com a letra9
BuscaCaso 3 — o rótulo diverge no meio10
BuscaCaso 4 — a chave acaba no meio de um rótulo11
BuscaCaso 5 — a chave acaba num nó sem fim12
RemoçãoCaso 1 — a palavra não existe13
RemoçãoCaso 2 — nó com dois ou mais filhos14
RemoçãoCaso 3 — nó com um único filho (funde)15
RemoçãoCaso 4a — folha; o pai continua necessário16
RemoçãoCaso 4b — folha; o pai funde com o filho restante17

Verde = caminho percorrido (verde forte = encontrado); vermelho = onde a busca para, ou o que será removido (tracejado); amarelo = novo ou alterado; círculo duplo = fim de palavra.

Passo 1 — Inserir "arco" (Inserção, caso 1)

Árvore vazia: nenhuma aresta sai da raiz; cria-se a folha "arco".

arco raiz "arco"
Passo 2 — Inserir "arte" (Inserção, caso 3a)

A aresta "arco" só casa em parte com "arte" (prefixo comum "ar"): é dividida em "ar" + "co", e o resto da chave ("te") vira folha.

ar co te raiz "arco" "arte"
Passo 3 — Inserir "as" (Inserção, caso 3a)

A aresta "ar" só casa em parte com "as" (prefixo comum "a"): é dividida em "a" + "r", e o resto ("s") vira folha.

a r s co te raiz "arco" "arte" "as"
Passo 4 — Inserir "ar" (Inserção, caso 4a)

As arestas "a" e "r" casam inteiras e a chave acaba no nó seguinte, que não marcava fim: só marcamos o fim.

a r s co te raiz "ar" "arco" "arte" "as"
Passo 5 — Inserir "ar" (Inserção, caso 4b)

A chave acaba no nó de "ar", que já marca fim: nada muda.

a r s co te raiz já existe "ar" "arco" "arte" "as"
Passo 6 — Inserir "arpa" (Inserção, casos 2 e 1)

As arestas "a" e "r" casam inteiras (caso 2: descemos por elas). Sobra "pa" e nenhuma aresta começa com 'p' (caso 1): cria-se a folha "pa".

a r s co pa te raiz "ar" "arco" "arpa" "arte" "as"
Passo 7 — Inserir "arc" (Inserção, caso 3b)

A aresta "co" casa só com o "c": é dividida em "c" + "o", e a chave acaba exatamente no ponto da divisão: o nó novo marca o fim.

a r s c pa te o raiz "ar" "arc" "arco" "arpa" "arte" "as"
Passo 8 — Buscar "arco" (Busca, caso 1)

Os rótulos "a", "r", "c" e "o" casam inteiros e o último nó marca fim: encontrada.

a r s c pa te o raiz "ar" "arc" "arco" "arpa" "arte" "as"
Passo 9 — Buscar "arma" (Busca, caso 2)

No nó de "ar" sobra "ma", mas nenhuma aresta começa com 'm'.

a r s c pa te o raiz sem aresta com 'm' "ar" "arc" "arco" "arpa" "arte" "as"
Passo 10 — Buscar "arpe" (Busca, caso 3)

Sobra "pe": a aresta "pa" começa com 'p', mas o 'e' diverge do 'a' no meio do rótulo.

a r s c pa te o raiz 'pe' difere de 'pa' "ar" "arc" "arco" "arpa" "arte" "as"
Passo 11 — Buscar "arp" (Busca, caso 4)

Sobra "p", que acaba no meio da aresta "pa".

a r s c pa te o raiz 'p' acaba no meio de 'pa' "ar" "arc" "arco" "arpa" "arte" "as"
Passo 12 — Buscar "a" (Busca, caso 5)

A aresta "a" casa, mas o nó seguinte não marca fim.

a r s c pa te o raiz sem fim "ar" "arc" "arco" "arpa" "arte" "as"
Passo 13 — Remover "arca" (Remoção, caso 1)

Depois de "ar" e "c" sobra "a", e o nó não tem aresta com 'a': a palavra não existe, nada muda.

a r s c pa te o raiz "ar" sem aresta com 'a' "arc" "arco" "arpa" "arte" "as"
Passo 14 — Remover "ar" (Remoção, caso 2)

O nó de "ar" tem três filhos ("c", "pa" e "te"): só perde a marca de fim.

a r s c pa te o raiz "ar" "arc" "arco" "arpa" "arte" "as"
Antes
a r s c pa te o raiz "arc" "arco" "arpa" "arte" "as"
Depois
Passo 15 — Remover "arc" (Remoção, caso 3)

O nó de "arc" tem um único filho ("o"): perde o fim e é fundido com ele; as arestas "c" e "o" viram "co".

a r s c pa te o raiz "arc" "arco" "arpa" "arte" "as"
Antes
a r s co pa te raiz "arco" "arpa" "arte" "as"
Depois
Passo 16 — Remover "arpa" (Remoção, caso 4a)

A folha "pa" é apagada. O pai (nó de "ar") ainda tem os filhos "co" e "te": continua necessário.

a r s co pa te raiz "arco" "arpa" "arte" "as"
Antes
a r s co te raiz "arco" "arte" "as"
Depois
Passo 17 — Remover "as" (Remoção, caso 4b)

A folha "s" é apagada. O nó de cima fica com um único filho e não marca fim: as arestas "a" e "r" são fundidas em "ar".

a r s co te raiz "arco" "arte" "as"
Antes
ar co te raiz "arco" "arte"
Depois

2.6 Complexidade

m = comprimento da chave (número de letras); n = número de chaves (palavras) armazenadas; |Σ| = tamanho do alfabeto (26 para a-z), tratado como constante; p = comprimento do prefixo buscado; t = nós visitados abaixo do prefixo (pelo menos o número de palavras encontradas e, no máximo, o total de letras delas).

Pior caso = o maior custo que pode acontecer, para o pior conjunto de palavras e a pior ordem de inserção. Caso médio = o custo típico, com palavras em ordem aleatória. Quando as duas colunas coincidem, o custo não depende de azar nos dados.

Operação Pior caso Caso médio Em uma frase
BuscaO(m)O(m)cada letra da chave é comparada no máximo uma vez
InserçãoO(m)O(m)desce, e faz no máximo uma divisão e uma folha nova
RemoçãoO(m)O(m)desce, e faz ajustes locais (no máximo uma fusão)
Busca por prefixoO(p + t)O(p + t)desce até o prefixo e percorre o que está abaixo
Número de nós≤ 2n≤ 2nlimite garantido, para qualquer conjunto de palavras
Letras nos rótulosO(n × m)bem menosO(n) se os rótulos forem índices na palavra original

Por que o tempo continua O(m)?

Intuição: a compressão economiza nós e ponteiros, não letras. Para saber se uma chave está na árvore ainda precisamos conferir as m letras dela; a diferença é que as conferimos em blocos: em cada nó comparamos um rótulo inteiro (várias letras de uma vez) com o trecho correspondente da chave e consumimos esse trecho. Nunca voltamos atrás, então cada letra da chave é consumida no máximo uma vez: no total, no máximo m comparações de letras → O(m).

  • Como na Trie, o custo não depende de n nem da forma da árvore: pior caso e caso médio coincidem.
  • Em cada nó, achar a aresta certa é olhar a lista de irmãos (até |Σ| arestas, constante). Cada nó visitado consome pelo menos uma letra, então visitamos no máximo m nós: continua O(m) para alfabeto fixo. (A Patrícia visita menos nós que a Trie, mas compara as mesmas letras.)
  • Inserção: desce O(m) e, no final, acontece no máximo uma divisão de aresta e no máximo uma folha nova (copiar o resto da chave, até m letras). Nunca mais que isso, porque depois da divisão as duas pontas já são distintas.
  • Remoção: desce O(m) e faz ajustes locais: desmarcar o fim, apagar uma folha e, se o pai ficar com um único filho e sem fim, fundir duas arestas (concatenar os rótulos). Só o pai imediato pode ficar nessa situação, então há no máximo uma fusão.*

* Concatenar rótulos copia letras de outras palavras; na análise, m deve ser o tamanho da maior palavra envolvida. Se os rótulos forem guardados como (início, tamanho) na palavra original, a fusão custa O(1).

Por que no máximo 2n nós?

Depois da compressão, todo nó (menos a raiz) tem uma razão para existir: ou termina uma palavra (no máximo n nós assim) ou se bifurca, isto é, tem pelo menos dois filhos. Uma árvore com f folhas tem, no máximo, f − 1 nós que se bifurcam; e toda folha termina uma palavra, então f ≤ n. Logo: raiz + (≤ n nós de fim) + (< n bifurcações) ≤ 2n. Esse limite vale para qualquer conjunto de palavras (é uma garantia de pior caso), ao contrário da Trie, cujo número de nós pode chegar a n × m.

Os rótulos, porém, guardam letras: no pior caso (palavras sem prefixo em comum) cada palavra vira um único rótulo com todas as suas letras, somando O(n × m) letras. Guardando (início, tamanho) em vez de cópias, cada rótulo ocupa O(1) e o total cai para O(n).

2.7 Estrutura e funções em C

Cada nó guarda o rótulo da aresta que chega nele (uma string, não um caractere), a marca de fim de palavra e seus filhos. Como a Patrícia existe justamente para evitar vetores de 26 ponteiros, os filhos ficam numa lista encadeada de irmãos: filhos aponta para o primeiro filho e irmao para o próximo irmão. Dois irmãos nunca começam com a mesma letra, então a primeira letra do rótulo identifica a aresta certa.

typedef struct NodoPat {
    char *rotulo;             /* rótulo da aresta que chega neste nó */
    int   fim;                /* 1 se uma palavra termina neste nó */
    struct NodoPat *filhos;   /* primeiro filho */
    struct NodoPat *irmao;    /* próximo irmão (mesmo pai) */
} NodoPat;

NodoPat *criarPat(void);                                /* cria a raiz (rótulo vazio) */
void     inserirPat(NodoPat *raiz, const char *s);     /* insere s, criando folha ou dividindo aresta */
int      buscarPat(NodoPat *raiz, const char *s);      /* 1 se s está armazenada, 0 caso contrário */
void     destruirPat(NodoPat *raiz);                    /* libera todos os nós */

main de demonstração:

int main(void) {
    NodoPat *raiz = criarPat();

    const char *palavras[] = {"ar", "arco", "arte", "as", "asa", "bola", "bolo"};
    for (int i = 0; i < 7; i++)
        inserirPat(raiz, palavras[i]);

    inserirPat(raiz, "arca");                /* divide a aresta "co" */

    printf("buscarPat(arco) = %d\n", buscarPat(raiz, "arco"));   /* 1 */
    printf("buscarPat(arca) = %d\n", buscarPat(raiz, "arca"));   /* 1 */
    printf("buscarPat(arc)  = %d\n", buscarPat(raiz, "arc"));    /* 0: é só um prefixo */
    printf("buscarPat(bol)  = %d\n", buscarPat(raiz, "bol"));    /* 0: aresta "bol", sem fim */

    destruirPat(raiz);
    return 0;
}

2.8 O problema

Os rótulos são strings de tamanho variável: dividir uma aresta obriga a copiar e realocar texto, e achar a aresta certa significa percorrer uma lista de irmãos. Seria mais simples ter nós todos iguais, de tamanho fixo e com um único caractere. É o que a TST oferece.


3. TST (Ternary Search Tree / Árvore de Busca Ternária)

3.1 Estrutura

Vale olhar o que as duas estruturas anteriores deixaram em aberto:

  • Trie: segue a chave letra por letra e é simples de implementar, mas cada nó carrega um vetor com um ponteiro para cada letra possível (26 para a-z), quase todos NULL. A memória vai quase toda para ponteiros vazios, e piora para alfabetos grandes.
  • Patrícia: elimina o desperdício fundindo os nós de passagem, mas paga com nós mais complicados: rótulos que são strings de tamanho variável, divisão e fusão de arestas e listas de filhos para percorrer.

A TST (Ternary Search Tree) busca o meio-termo: mantém a simplicidade da Trie (um caractere por nó, seguindo a chave letra a letra), mas troca o vetor de 26 ponteiros por apenas três, e não precisa dos rótulos de tamanho variável da Patrícia. Todos os nós têm o mesmo tamanho pequeno:

  • Cada nó armazena um único caractere e tem três filhos.
  • esq e dir escolhem entre caracteres diferentes na mesma posição da chave (como numa ABP de caracteres); centro avança para o próximo caractere.
  • O nó tem sempre 3 ponteiros, qualquer que seja o alfabeto.
Campo Significado
c O caractere armazenado neste nó
esq Subárvore para caracteres menores que c na posição atual
centro Subárvore para o próximo caractere da string (match: avança na string)
dir Subárvore para caracteres maiores que c na posição atual
fim Booleano: 1 se uma palavra termina neste nó

A lógica é: em cada nó, comparamos o i-ésimo caractere da chave com c. Se menor, vamos para esq (sem avançar i). Se maior, vamos para dir (sem avançar i). Se igual, vamos para centro e avançamos i em 1.

Exemplo usado em toda a seção: as palavras "ar", "as", "arco" e "arte", inseridas nesta ordem. Arestas: < esq, = centro, > dir.

= = > = > = a r c o t e s

3.2 Inserção

Em cada nó visitado comparamos a letra atual da chave com o caractere c do nó: menor → esq; maior → dir; igual → centro, avançando uma letra. Quando o filho escolhido não existe, ele é criado. Os casos (exemplos na TST com "ar", "as", "arco" e "arte"):

Verde = caminho percorrido (verde forte = encontrado); vermelho = onde a busca para, ou o que será removido (tracejado); amarelo = novo ou alterado; círculo duplo = fim de palavra.

Caso 1 — A letra é menor que a do nó

Vai-se para a esq. Se ela estiver vazia, cria-se o nó ali.

Ver exemplo: Inserir "arb"
= = > < = > = a r c b o t e s
Inserir "arb". 'a' e 'r' pelo centro. No terceiro caractere, 'b' < 'c': vai para a esq, que está vazia: cria-se 'b' (fim).

Caso 2 — A letra é maior que a do nó

Vai-se para a dir. Se ela estiver vazia, cria-se o nó ali.

Ver exemplo: Inserir "aru"
= = > = > = > a r c o t e u s
Inserir "aru". 'u' > 'c': vai para a dir ('t'); 'u' > 't': dir vazia, então cria-se 'u' (fim).

Caso 3 — A letra é igual e ainda há letras na chave

Vai-se para o centro e avança-se uma letra. Se o centro estiver vazio, cria-se o nó ali (e os seguintes).

Ver exemplo: Inserir "arcos"
= = > = > = = a r c o s t e s
Inserir "arcos". 'a', 'r', 'c' e 'o' casam pelo centro. Falta o 's' e o centro de 'o' está vazio: cria-se 's' (fim).

Caso 4 — A letra é igual e é a última da chave

Não se cria nó algum; basta olhar a marca de fim:

Ver exemplos (2 figuras): 4a — Inserir "arc"
= = > = > = a r c o t e s
4a — Inserir "arc". 'c' casa e é a última letra, mas não marcava fim: só marcamos o fim (amarelo).
= = > = > = a r c já existe o t e s
4b — Inserir "arco". 'o' casa, é a última letra e já marca fim: nada muda.

Exemplo combinado

Os casos se encadeiam numa mesma inserção:

Ver exemplo: Inserir "arpa"
= = > = > < = = a r c o t p a e s
Inserir "arpa". 'a' e 'r' pelo centro; 'p' > 'c': dir ('t'); 'p' < 't': esq vazia, cria-se 'p'; pelo centro, cria-se 'a' (fim).

3.3 Busca

Comparamos a letra atual com o nó visitado, como na inserção, mas sem criar nada. A chave só é encontrada no caso 1; os demais são falhas.

Caso 1 — Encontrada

A última letra casa num nó que marca fim.

Ver exemplo: "arco"
= = > = > = a r c o t e s
"arco": 'a', 'r', 'c' e 'o' casam pelo centro e 'o' marca fim → encontrada.

Caso 2 — Precisa ir para a esq e ela está vazia

A letra é menor que a do nó e não há filho à esquerda.

Ver exemplo: "arca"
= = > = > = a r c 'a' < 'o', esq vazia o t e s
"arca": chega a 'o' com a letra 'a'; como 'a' < 'o', vai para a esq, que está vazia → não encontrada.

Caso 3 — Precisa ir para a dir e ela está vazia

A letra é maior que a do nó e não há filho à direita.

Ver exemplo: "aru"
= = > = > = a r c o 'u' > 't', dir vazia t e s
"aru": 'u' > 'c': dir ('t'); 'u' > 't': dir vazia → não encontrada.

Caso 4 — Precisa ir para o centro e ele está vazio

A letra casa, ainda faltam letras na chave, mas o nó não tem continuação.

Ver exemplo: "arcos"
= = > = > = a r c centro vazio o t e s
"arcos": 'a', 'r', 'c' e 'o' casam, mas ainda falta o 's' e o centro de 'o' está vazio → não encontrada.

Caso 5 — A última letra casa num nó que não marca fim

A chave é apenas prefixo de outras palavras.

Ver exemplo: "arc"
= = > = > = a r fim = false c o t e s
"arc": 'c' casa e é a última letra, mas não marca fim (é só prefixo de "arco") → não encontrada.

3.4 Remoção

Busca-se a palavra e desmarca-se o fim do nó da última letra. Depois, subindo pelo caminho, apaga-se cada nó que ficou sem nenhum dos três filhos e sem marca de fim; a subida pára no primeiro nó que ainda é necessário. Os casos:

Caso 1 — A palavra não existe

A busca falha e a árvore não muda.

Ver exemplo: Remover "arca"
= = > = > = a r c 'a' < 'o', esq vazia o t e s
Remover "arca". A busca falha em 'o' (esq vazia): a palavra não existe e nada muda.

Caso 2 — O nó da última letra tem filhos

Outras palavras dependem desse nó (ou do que está abaixo dele): só se desmarca o fim.

Ver exemplo: Remover "ar"
= = > = > = a r c o t e s
Remover "ar" — antes. 'r' marca fim e tem filhos (centro 'c' e dir 's').
= = > = > = a r c o t e s
Depois. 'r' só perde a marca de fim (amarelo); nada é apagado.

Caso 3 — O nó da última letra não tem filhos

Apaga-se o nó e sobe-se, apagando cada nó que ficar sem filhos e sem fim. Três situações:

3a — Apaga um nó e pára num nó que ainda tem outro filho:

Ver exemplo: Remover "arco"
= = > = > = a r c o t e s
Remover "arco" — antes. 'o' não tem filhos e perde o fim: é apagado.
= = > > = a r c t e s
Depois. A subida pára em 'c' (amarelo): ele ainda tem o filho da direita ('t', de "arte"), então é necessário.

3b — Apaga vários nós antes de parar:

Ver exemplo: Remover "arte"
= = > = > = a r c o t e s
Remover "arte" — antes. 'e' é apagado; depois 't' fica sem filhos e sem fim: também é apagado.
= = > = a r c o s
Depois. A subida pára em 'c' (amarelo), que ainda tem o filho central 'o' (de "arco").

3c — Pára num nó que marca fim:

Ver exemplo: Remover "arc" (na árvore com "ar" e "arc")
= = a r c
Remover "arc" (na árvore com "ar" e "arc") — antes. 'c' não tem filhos e perde o fim: é apagado.
= a r
Depois. A subida pára em 'r' (amarelo): mesmo sem filhos, ele marca o fim de "ar".

3.5 Exemplo completo: todos os casos em sequência

Exemplos retráteis: os exemplos desta aula ficam recolhidos (clique em "Ver exemplo" ou no título do passo). Expandir todos | Recolher todos

Uma sequência de operações sobre uma única árvore, partindo da árvore vazia. Cada passo indica qual caso (das seções de inserção, busca e remoção acima) ele exemplifica; no conjunto, os passos cobrem todos os casos das três operações, como mostra a tabela. Nos desenhos, cada passo mostra a árvore após a operação (nas remoções, antes e depois).

OperaçãoCasoPasso(s)
InserçãoCaso 1 — letra menor (esq)2
InserçãoCaso 2 — letra maior (dir)3
InserçãoCaso 3 — letra igual e ainda há letras (centro)1
InserçãoCaso 4a — letra igual e última, sem fim4, 15
InserçãoCaso 4b — letra igual e última, já marca fim5
BuscaCaso 1 — encontrada6
BuscaCaso 2 — esq vazia7
BuscaCaso 3 — dir vazia8
BuscaCaso 4 — centro vazio9
BuscaCaso 5 — última letra casa num nó sem fim10
RemoçãoCaso 1 — a palavra não existe11
RemoçãoCaso 2 — o nó da última letra tem filhos12
RemoçãoCaso 3a — apaga um nó e pára num nó com outro filho13
RemoçãoCaso 3b — apaga vários nós antes de parar14
RemoçãoCaso 3c — a subida pára num nó que marca fim16

Verde = caminho percorrido (verde forte = encontrado); vermelho = onde a busca para, ou o que será removido (tracejado); amarelo = novo ou alterado; círculo duplo = fim de palavra.

Passo 1 — Inserir "arco" (Inserção, caso 3)

Árvore vazia: cria-se 'a' e, pelo centro, 'r', 'c' e 'o' (fim).

= = = a r c o
Passo 2 — Inserir "arb" (Inserção, caso 1)

'a' e 'r' pelo centro. Em 'c', 'b' < 'c': esq vazia, cria-se 'b' (fim).

= = < = a r c b o
Passo 3 — Inserir "arte" (Inserção, caso 2)

Em 'c', 't' > 'c': dir vazia, cria-se 't' e, pelo centro, 'e' (fim).

= = < = > = a r c b o t e
Passo 4 — Inserir "ar" (Inserção, caso 4a)

'a' e 'r' casam e 'r' é a última letra, mas não marcava fim: só marcamos o fim.

= = < = > = a r c b o t e
Passo 5 — Inserir "ar" (Inserção, caso 4b)

'r' já marca fim: nada muda.

= = < = > = a já existe r c b o t e
Passo 6 — Buscar "arco" (Busca, caso 1)

'a', 'r', 'c' e 'o' casam pelo centro e 'o' marca fim: encontrada.

= = < = > = a r c b o t e
Passo 7 — Buscar "ara" (Busca, caso 2)

Em 'c', 'a' < 'c': vai para a esq ('b'); em 'b', 'a' < 'b': esq vazia.

= = < = > = a r c 'a' < 'b', esq vazia b o t e
Passo 8 — Buscar "arz" (Busca, caso 3)

Em 'c', 'z' > 'c': dir ('t'); em 't', 'z' > 't': dir vazia.

= = < = > = a r c b o 'z' > 't', dir vazia t e
Passo 9 — Buscar "arcos" (Busca, caso 4)

'a', 'r', 'c' e 'o' casam, mas falta o 's' e o centro de 'o' está vazio.

= = < = > = a r c b centro vazio o t e
Passo 10 — Buscar "arc" (Busca, caso 5)

'c' casa e é a última letra, mas não marca fim.

= = < = > = a r fim = false c b o t e
Passo 11 — Remover "arca" (Remoção, caso 1)

Em 'c', 'a' < 'c': esq ('b'); em 'b', esq vazia: a palavra não existe, nada muda.

= = < = > = a r c b 'a' < 'o', esq vazia o t e
Passo 12 — Remover "ar" (Remoção, caso 2)

'r' tem filhos (o centro 'c'): só perde a marca de fim.

= = < = > = a r c b o t e
Antes
= = < = > = a r c b o t e
Depois
Passo 13 — Remover "arb" (Remoção, caso 3a)

'b' é apagado; a subida pára em 'c', que ainda tem os filhos centro ('o') e dir ('t').

= = < = > = a r c b o t e
Antes
= = = > = a r c o t e
Depois
Passo 14 — Remover "arte" (Remoção, caso 3b)

'e' e depois 't' são apagados; a subida pára em 'c', que ainda tem o filho central 'o'.

= = = > = a r c o t e
Antes
= = = a r c o
Depois
Passo 15 — Inserir "ar" (Inserção, caso 4a (de novo))

Para preparar o próximo caso, "ar" volta a ser palavra: só marcamos o fim em 'r'.

= = = a r c o
Passo 16 — Remover "arco" (Remoção, caso 3c)

'o' e depois 'c' são apagados; a subida pára em 'r' porque ele marca o fim de "ar".

= = = a r c o
Antes
= a r
Depois

3.6 Complexidade

m = comprimento da chave (número de letras); n = número de chaves (palavras) armazenadas; |Σ| = tamanho do alfabeto (26 para a-z), tratado como constante; p = comprimento do prefixo buscado; t = nós visitados abaixo do prefixo (pelo menos o número de palavras encontradas e, no máximo, o total de letras delas).

Pior caso = o maior custo que pode acontecer, para o pior conjunto de palavras e a pior ordem de inserção. Caso médio = o custo típico, com palavras em ordem aleatória. Quando as duas colunas coincidem, o custo não depende de azar nos dados.

Operação Pior caso Caso médio (ordem aleatória) Em uma frase
BuscaO(m × |Σ|)≈ m + log nm passos pelo centro + passos laterais entre as letras
InserçãoO(m × |Σ|)≈ m + log nmesma descida, mais até m nós novos
RemoçãoO(m × |Σ|)≈ m + log nmesma descida, mais a subida apagando nós
Busca por prefixoO(p × |Σ| + t)≈ p + log n + tchega ao prefixo e percorre a subárvore
Número de nós≤ n × mbem menosum nó por letra, compartilhado entre palavras com o mesmo início
Espaço por nóO(1)O(1)1 letra + 3 ponteiros, qualquer que seja o alfabeto

Por que essa complexidade? Passos centrais e passos laterais

Intuição: na TST, cada posição da chave funciona como uma pequena ABP de letras. Ao percorrer a árvore damos dois tipos de passo:

  • Passo central (=): a letra casou, avançamos para a próxima letra da chave. Acontece exatamente m vezes, uma por letra.
  • Passo lateral (< ou >): a letra ainda não casou e procuramos entre as letras irmãs, como numa ABP. Quantos acontecem depende de como essa pequena ABP ficou.

Total de passos = m (centrais) + L (laterais). Tudo se resume a quanto vale L.

Pior caso: O(m × |Σ|)

Em uma posição há no máximo |Σ| letras diferentes (as irmãs têm letras distintas), então a pequena ABP daquela posição tem no máximo |Σ| nós. Se as letras forem inseridas em ordem alfabética (a, b, c, ..., z), essa ABP degenera numa lista e chegar à letra z custa 25 passos laterais. Se isso acontecer em todas as posições da chave, L chega a m × (|Σ| − 1): o pior caso é O(m × |Σ|). Isso não é só teoria: acontece quando as palavras são inseridas em ordem alfabética, por exemplo ao ler de uma lista já ordenada.

Repare que o pior caso não é O(m + log n): o log n só vale quando as pequenas ABPs estão razoavelmente balanceadas, e a TST não garante isso. Com |Σ| = 26 o estrago é limitado (no máximo uns 26 passos por letra), e para alfabetos enormes (Unicode) o pior caso fica ainda mais relevante.

Caso médio: ≈ m + log n

Com palavras inseridas em ordem aleatória, duas coisas ajudam:

  • As ramificações só acontecem nas primeiras posições (cerca de log|Σ| n delas). Depois que o prefixo identifica uma única palavra, os nós só têm filho central e não há mais passos laterais.
  • Em cada posição ramificada, a pequena ABP (aleatória) tem profundidade média da ordem de log |Σ|.

Multiplicando: (log|Σ| n posições) × (log |Σ| passos por posição) = log n passos laterais no total, pagos uma única vez ao longo da chave inteira (e não a cada letra). Outra forma de ver: cada passo lateral descarta, em média, cerca de metade das palavras que ainda poderiam casar, exatamente como numa ABP de n palavras. Por isso o custo típico é m + log n.

Atenção à notação: o "O(m + log n)" que aparece na literatura é esse custo médio/típico, não uma garantia de pior caso. (Com |Σ| constante, os dois são O(m), mas a constante do pior caso pode ser até |Σ| vezes maior que a do caso típico.) Embaralhar as palavras antes de inserir evita o pior caso na prática.

Inserção e remoção

  • Inserção: a mesma descida da busca (centrais + laterais) mais a criação de, no máximo, m nós novos (O(1) cada). Mesmos limites: pior caso O(m × |Σ|), caso médio ≈ m + log n.
  • Remoção: a descida da busca mais a subida apagando nós que ficaram sem filhos e sem fim; a subida percorre, no máximo, os mesmos nós da descida. Mesmos limites da busca.
  • Busca por prefixo: chegar ao nó do prefixo custa como uma busca de p letras (pior caso p × |Σ|, médio ≈ p + log n), e percorrer a subárvore custa t, proporcional à resposta.

Espaço

  • Cada nó guarda uma letra de uma posição, compartilhada por todas as palavras com o mesmo início. Pior caso: palavras sem prefixo em comum → um nó por letra de cada palavra: até n × m nós. Caso típico: bem menos, pelos prefixos compartilhados.
  • O que diferencia a TST da Trie é o tamanho do nó: 3 ponteiros, 1 letra e 1 marca, sempre, em vez de |Σ| ponteiros. Por isso, mesmo com o mesmo número de nós, a TST ocupa muito menos memória.

3.7 Estrutura e funções em C

typedef struct NodoTST {
    char c;
    int  fim;
    struct NodoTST *esq, *centro, *dir;
} NodoTST;

/* pos = índice do caractere de s que está sendo processado (começar com 0) */
NodoTST *inserirTST(NodoTST *no, const char *s, int pos);   /* devolve a (nova) raiz da subárvore */
int      buscarTST(NodoTST *no, const char *s, int pos);    /* 1 se s está armazenada, 0 caso contrário */

main de demonstração:

int main(void) {
    NodoTST *raiz = NULL;                    /* TST vazia */

    raiz = inserirTST(raiz, "ar",   0);
    raiz = inserirTST(raiz, "as",   0);
    raiz = inserirTST(raiz, "arco", 0);
    raiz = inserirTST(raiz, "arte", 0);

    printf("buscarTST(arco) = %d\n", buscarTST(raiz, "arco", 0));   /* 1 */
    printf("buscarTST(arca) = %d\n", buscarTST(raiz, "arca", 0));   /* 0 */
    printf("buscarTST(ar)   = %d\n", buscarTST(raiz, "ar",   0));   /* 1 */
    return 0;
}

4. Aplicações

4.1 Aplicações Reais

  • Trie: autocompletar (Google Search, IDE), verificador ortográfico (spell checker), dicionários de compressão (LZ78), filtros de palavras proibidas.
  • Patrícia (Trie Comprimida): tabelas de roteamento IP (longest-prefix-match), servidores DNS, índices de strings em sistemas de arquivos.
  • TST: autocompletar com alfabeto grande (Unicode, CJK), indexação de textos em linguagens com muitos caracteres, implementações de dicionários em compiladores.
Qual escolher?

• Alfabeto pequeno e fixo (a-z) + busca por prefixo frequente: Trie — simples.
• Alfabeto grande/variável (Unicode) + busca por prefixo: TST ou Patrícia.
• Apenas busca exata, sem prefixo: tabela hash — mais simples.
• Precisa de ordenação + estrutura de chave: TST ou ABP com comparação por prefixo.

5. Comparativo geral: ABP × Trie × Patrícia × TST

Aspecto ABP (strings) Trie Patrícia TST
O que cada nó guarda A string inteira + 2 ponteiros Vetor de |Σ| ponteiros + marca de fim (o caractere é o índice) Rótulo (substring) + marca de fim + lista de filhos 1 caractere + 3 ponteiros + marca de fim
Tamanho do nó Fixo (ponteiro para a string) Fixo e grande Variável Fixo e pequeno
Aproveita prefixos em comum? Não: cada string é guardada e comparada inteira Sim Sim (e funde os trechos sem bifurcação) Sim
Busca, inserção e remoção — pior caso O(m × n)
árvore degenerada (palavras inseridas em ordem)
O(m) O(m) O(m × |Σ|)
pequenas ABPs de letras degeneradas
Busca, inserção e remoção — caso médio O(m log n)
cada comparação lê até m letras
O(m)
igual ao pior caso
O(m)
igual ao pior caso
≈ m + log n
Busca por prefixo — caso médio O(p log n + k) O(p + t) O(p + t) ≈ p + log n + t
Número de nós (pior caso) n ≤ n × m ≤ 2n ≤ n × m
Depende do alfabeto? Não Sim: |Σ| ponteiros por nó Pouco: só a lista de filhos Não no tamanho do nó (só no pior caso do tempo)
Ordem lexicográfica (em ordem) Sim Sim Sim Sim
Dificuldade de implementação Baixa Baixa Alta (divisão e fusão de arestas) Média

m = comprimento da chave; n = número de chaves; p = comprimento do prefixo; k = número de palavras encontradas; t = nós visitados abaixo do prefixo (≥ k e ≤ total de letras das palavras encontradas); |Σ| = tamanho do alfabeto. O caso médio da ABP e da TST supõe palavras inseridas em ordem aleatória (árvore razoavelmente balanceada). Na Trie e na Patrícia o tempo só depende de m: pior caso e caso médio coincidem.

Em uma frase cada:

  • ABP: guarda palavras inteiras e compara a palavra toda a cada nó visitado.
  • Trie: um caractere por nível; rápida e simples, mas gasta memória com o vetor de ponteiros.
  • Patrícia: a Trie sem os nós de passagem; economiza memória, com nós e operações mais complexos.
  • TST: um caractere por nó com apenas 3 ponteiros; nós pequenos e todos iguais, mas o tempo depende da ordem de inserção.

Resumo

  • Trie: cada nó representa um caractere; caminho raiz→nó marcado = uma chave; |Σ| ponteiros por nó. Busca, inserção e remoção em O(m), tanto no pior caso quanto no caso médio (o custo só depende do tamanho da chave, não de n). Espaço no pior caso O(n × m × |Σ|).
  • Patrícia (Trie Comprimida / Radix): cadeias de nós com filho único viram uma aresta com rótulo (a substring); divisão de arestas na inserção e fusão na remoção. Tempo O(m) (pior caso = caso médio) e no máximo 2n nós, garantido.
  • TST: cada nó armazena um caractere e tem 3 filhos (esq: letra menor, centro: próxima letra, dir: letra maior). Tempo: caso médio ≈ m + log n (inserção em ordem aleatória); pior caso O(m × |Σ|) se as pequenas ABPs de letras degenerarem (inserção em ordem alfabética). 3 ponteiros por nó, tamanho fixo.
  • Diferencial chave: todas as três suportam busca por prefixo em tempo proporcional ao prefixo mais o tamanho da resposta.

Exercícios

  1. Insira as palavras "gato", "gata", "pato", "pata" em uma Trie com alfabeto de letras minúsculas. Desenhe a estrutura resultante. O que acontece ao tentar inserir "gato" pela segunda vez?

  2. Busque "pata" na Trie do exercício anterior. Quais nós são visitados, em que ordem, e qual a conclusão final?

  3. Por que uma tabela hash não suporta busca por prefixo? Explique com um exemplo concreto usando as palavras "pre", "prefixo", "preto" e "preço".

  4. Uma Trie com alfabeto de 26 letras minúsculas tem 1.000 nós. Quantos ponteiros são armazenados no total? Se o vocabulário for de palavras em português com comprimento médio de 7 caracteres, quantos desses ponteiros provavelmente são NULL?

  5. Qual é a diferença fundamental entre uma Trie e uma Patrícia (Trie Comprimida)? Em que tipo de vocabulário a compressão é mais eficiente?

  6. Na TST, por que o nó raiz não armazena o "primeiro caractere de todas as palavras" como faz a Trie (onde a raiz distribui pelas primeiras letras)?

  7. Comparando Trie e TST: em qual a busca por prefixo "pre" é mais simples de implementar? Por quê?

  8. Uma Trie contém as palavras "pre", "preto", "prefixo", "preço". O que a busca pelo prefixo "pre" retorna? Qual a diferença entre buscar "pre" como palavra e buscar "pre" como prefixo?

  9. Para construir um sistema de autocompletar para um teclado de celular com 50.000 palavras em português, qual estrutura você escolheria entre Trie, Patrícia (Trie Comprimida) e TST? Justifique levando em conta memória disponível e eficiência de busca por prefixo.


Gabaritos

Sugestão de resposta — Exercício 1

Trie resultante:

raiz g a t a "gata" o "gato" p a t a "pata" o "pato"

Ao inserir "gato" pela segunda vez: o algoritmo percorre o caminho 'g'→'a'→'t'→'o', que já existe, e simplesmente define fim = 1 no nó 'o'. Como fim já era 1, nada muda. A Trie não duplica entradas — a palavra continua existindo uma única vez, sem efeito colateral.


Sugestão de resposta — Exercício 2
Busca de "pata":

1. Raiz → filhos['p'−'a'] = nó 'p'. Existe.
2. Nó 'p' → filhos['a'−'a'] = nó 'a'. Existe.
3. Nó 'a' → filhos['t'−'a'] = nó 't'. Existe.
4. Nó 't' → filhos['a'−'a'] = nó 'a'. Existe.
5. String terminou (s[4]='\0'). Verificar fim do nó 'a': fim=1.

Conclusão: ENCONTRADO. Foram visitados 4 nós (excluindo a raiz),
correspondendo exatamente ao comprimento da palavra. Custo: O(4) = O(m).

Sugestão de resposta — Exercício 3

Uma tabela hash transforma cada chave em um número (índice) por meio de uma função de espalhamento. Essa função é projetada para espalhar as chaves o mais uniformemente possível — palavras semelhantes recebem hashes completamente diferentes. Por exemplo:

hash("pre")    = 41723
hash("prefixo")= 89204
hash("preto")  = 12567
hash("preço")  = 73891

Não há nenhuma relação matemática entre hash("pre") e os hashes das palavras que começam com "pre". Para realizar uma busca por prefixo, seria necessário computar o hash de todas as n palavras do dicionário e comparar cada uma com o prefixo — custo O(mn), pior que qualquer estrutura de busca. A hash simplesmente não organiza as chaves por proximidade lexicográfica.


Sugestão de resposta — Exercício 4

Total de ponteiros: 1.000 nós × 26 ponteiros/nó = 26.000 ponteiros.

Quantos são NULL? Cada ponteiro não-NULL corresponde a uma aresta na Trie. Uma Trie com n=1.000 nós (excluindo a raiz) tem exatamente 999 arestas (toda árvore com k nós tem k-1 arestas). Portanto, há 999 ponteiros não-NULL e aproximadamente 25.001 ponteiros NULL — mais de 96% do total. Isso ilustra o desperdício de memória da Trie para vocabulários esparsos com um alfabeto relativamente grande.


Sugestão de resposta — Exercício 5

Na Trie, cada nó representa exatamente um caractere. Na Patrícia, cadeias de nós com filho único são fundidas em uma única aresta cujo rótulo é a substring inteira. O número de nós da Patrícia é sempre O(n) (proporcional ao número de palavras), enquanto a Trie pode ter O(n×m) nós.

A compressão é mais eficiente em vocabulários esparsos — onde as palavras compartilham poucos prefixos e há muitos caminhos longos sem bifurcação. Por exemplo, um dicionário com "banana", "caneta", "elefante" (sem prefixos em comum) terá na Patrícia quase o mesmo número de nós que palavras, enquanto a Trie terá um nó por caractere. Em contraste, se o vocabulário tiver muitos prefixos compartilhados (ex.: "pre", "prefixo", "preto", "previsão", "presidente"), a compressão economiza menos porque as bifurcações acontecem cedo.


Sugestão de resposta — Exercício 6

Na Trie, a raiz tem 26 filhos, um por letra do alfabeto — ela funciona como um "índice" dos primeiros caracteres. Na TST, a raiz armazena o primeiro caractere da primeira palavra inserida e distribui outras palavras pelos filhos esq ou dir com base na comparação desse caractere. É uma ABP sobre o conjunto de primeiros caracteres.

Isso significa que a TST não tem uma "raiz especial" sem caractere — cada nó, incluindo a raiz, armazena um caractere e participa das comparações. A vantagem é que não há necessidade de alocar espaço para 26 filhos na raiz; a desvantagem é que, se as palavras forem inseridas em ordem não balanceada, as cadeias laterais (esq/dir) podem degeneradas — por isso o custo é O(m + log n) e não O(m) puro como na Trie.


Sugestão de resposta — Exercício 7

Na Trie, a busca por prefixo é mais simples: navega-se o prefixo "pre" da raiz até o nó 'e', e então percorre-se toda a subárvore desse nó coletando todas as palavras com fim=1. O código é direto — após o loop de busca, um caminhamento recursivo coleta os resultados.

Na TST, é mais complexo: após navegar pelos filhos centro para cada caractere do prefixo (como na Trie), ao chegar no nó correspondente ao último caractere do prefixo é preciso lembrar que os filhos esq e dir desse nó ainda se referem ao mesmo nível de caractere e também fazem parte das palavras com esse prefixo. A coleta precisa levar em conta todos os três filhos (esq, centro, dir) recursivamente a partir do nó final do prefixo, o que exige cuidado extra na implementação.


Sugestão de resposta — Exercício 8

A busca por prefixo "pre" retorna todas as palavras armazenadas que começam com "pre": {"pre", "preto", "prefixo", "preço"}. O algoritmo navega até o nó 'e' (seguindo 'p'→'r'→'e') e percorre toda a subárvore coletando nós com fim=1.

A busca por "pre" como palavra (busca exata) navega até o nó 'e' e verifica se fim=1. Neste caso, como "pre" está no dicionário, retorna verdadeiro. Mas se "pre" não estivesse no dicionário (só "preto", "prefixo", "preço"), a busca exata retornaria falso — o nó 'e' existiria (pois é nó de passagem para as outras palavras), mas com fim=0. A busca por prefixo, por sua vez, ainda retornaria {"preto", "prefixo", "preço"} independentemente de "pre" ser ou não uma palavra armazenada.


Sugestão de resposta — Exercício 9

Em um teclado de celular, memória é o recurso mais escasso. Avaliando as opções:

  • Trie: 50.000 palavras × comprimento médio ≈ 6 chars = ≈300.000 nós × 26 ponteiros × 4 bytes (32-bit) = ≈31 MB só em ponteiros. Inviável para dispositivos com memória limitada.
  • Patrícia: O(n) nós = ≈50.000 nós + armazenamento das labels (substrings). Muito mais compacto — estimativa: ≈2–4 MB total. Busca por prefixo em O(p + t), com t proporcional ao tamanho da resposta. Excelente escolha.
  • TST: ≈300.000 nós × 3 ponteiros × 4 bytes = ≈3,6 MB em ponteiros. Comparável à Patrícia em espaço, com busca por prefixo em O(p + log n + t) em média. Também boa escolha, especialmente se o vocabulário puder ter caracteres acentuados (onde o alfabeto fixo da Trie seria ainda maior).

Recomendação: Patrícia para português com apenas letras minúsculas e acentuadas (alfabeto de ≈30–40 caracteres), pois oferece O(p + t) na busca por prefixo e O(n) nós. Se o sistema precisar lidar com Unicode amplo (emojis, caracteres especiais), a TST é preferível por não depender de um tamanho fixo de alfabeto.