Trilha 04 · Estruturas de dados

Árvores binárias de busca

A hash table é mais rápida para buscar uma chave exata — mas ela não sabe responder "qual é o menor valor?", "quem vem depois deste?" ou "me dê tudo entre 10 e 50". A árvore binária de busca troca o O(1) por O(log n) e recebe ordem em troca. É a estrutura por trás dos índices de banco de dados e dos mapas ordenados de toda linguagem.

① Intuição

Busca binária que aceita inserções

Você já conhece a busca binária: num array ordenado, compare com o meio e descarte metade. O problema é que manter o array ordenado a cada inserção custa O(n) — todo mundo tem que se deslocar.

A BST é a busca binária transformada em estrutura. Cada nó é um "meio": tudo que é menor fica à esquerda, tudo que é maior fica à direita. Buscar é descer comparando, descartando metade da árvore a cada passo. E inserir é a mesma descida — só que ao chegar num espaço vazio, você pendura o nó ali. Nada se move.

Tudo depende da altura. O custo de buscar, inserir e remover numa BST é proporcional à altura da árvore, não ao número de nós. Com os nós bem distribuídos, a altura é ≈ log₂(n) — 20 comparações para 1 milhão de elementos. Mas se você inserir valores já ordenados, cada novo nó vai sempre para a direita e a árvore vira uma lista ligada: altura n, busca O(n). Esse é o calcanhar de Aquiles que a próxima lição resolve.
② Visualização interativa

Construa, busque e percorra

Insira valores e acompanhe o caminho de descida. Compare a altura real com a altura mínima possível — e clique em "inserir já ordenado" para ver a árvore degenerar. Nas travessias, repare que a travessia em ordem sempre devolve os valores ordenados.

12253750627587
nós: 7altura: 3mínimo possível: 3custo de busca: até 3 comparações
Numa BST, tudo à esquerda de um nó é menor; tudo à direita é maior.
③ Explicação técnica

A invariante e as operações básicas

# A invariante da BST, válida em TODO nó:
#   tudo na subárvore esquerda  < nó
#   tudo na subárvore direita   > nó
#
# Não é só "filho esquerdo menor que o pai" — é a subárvore INTEIRA.
# É essa invariante que permite descartar metade a cada comparação.

class No:
    def __init__(self, v):
        self.v = v
        self.esq = None
        self.dir = None

def inserir(no, v):
    if no is None:  return No(v)     # chegou numa folha: pendura aqui
    if v < no.v:      no.esq = inserir(no.esq, v)
    elif v > no.v:    no.dir = inserir(no.dir, v)
    return no                          # v == no.v: já existe, ignora

def buscar(no, v):
    while no:
        if v == no.v:  return no
        no = no.esq if v < no.v else no.dir   # descarta metade
    return None

# Custo: proporcional à ALTURA da árvore, não ao número de nós.
#   árvore balanceada  → altura ≈ log₂(n)  → O(log n)
#   árvore degenerada  → altura = n        → O(n)

As travessias

# Três formas de visitar todos os nós — a diferença é QUANDO
# o nó é processado em relação às chamadas recursivas.

def em_ordem(no, saida):      # esquerda → NÓ → direita
    if not no: return
    em_ordem(no.esq, saida)
    saida.append(no.v)         #  ← processa no meio
    em_ordem(no.dir, saida)
# Numa BST, devolve os valores ORDENADOS. Essa é a propriedade
# mais importante da estrutura: a ordenação sai de graça.

def pre_ordem(no, saida):     # NÓ → esquerda → direita
    if not no: return
    saida.append(no.v)         #  ← processa antes
    pre_ordem(no.esq, saida)
    pre_ordem(no.dir, saida)
# Reinserir nessa ordem recria a árvore com a MESMA forma.
# Por isso é a travessia usada para serializar/copiar.

def pos_ordem(no, saida):     # esquerda → direita → NÓ
    if not no: return
    pos_ordem(no.esq, saida)
    pos_ordem(no.dir, saida)
    saida.append(no.v)         #  ← processa depois
# Filhos sempre antes do pai. Usada para liberar memória e para
# avaliar árvores de expressão (calcule os operandos antes do operador).

# E há a travessia em LARGURA (nível por nível), que não é recursiva:
from collections import deque
def em_largura(raiz):
    fila, saida = deque([raiz]), []
    while fila:
        no = fila.popleft()
        if not no: continue
        saida.append(no.v)
        fila.append(no.esq); fila.append(no.dir)
    return saida

Remoção: os três casos

# Remoção é a operação difícil da BST — há três casos.
def remover(no, v):
    if no is None: return None
    if v < no.v:
        no.esq = remover(no.esq, v)
    elif v > no.v:
        no.dir = remover(no.dir, v)
    else:
        # CASO 1: folha — some, e pronto
        if not no.esq and not no.dir:
            return None
        # CASO 2: um filho só — o filho toma o lugar
        if not no.esq: return no.dir
        if not no.dir: return no.esq
        # CASO 3: dois filhos — o difícil.
        # Substitui pelo SUCESSOR em ordem: o menor da subárvore direita.
        # Ele é o único valor que pode ficar no lugar sem quebrar a
        # invariante (é maior que toda a esquerda e menor que toda a direita).
        suc = no.dir
        while suc.esq: suc = suc.esq
        no.v = suc.v
        no.dir = remover(no.dir, suc.v)   # remove o sucessor (tem ≤1 filho)
    return no
Por que o sucessor em ordem? Quando o nó removido tem dois filhos, o valor que o substitui precisa ser maior que tudo à esquerda e menor que tudo à direita. Só dois valores no mundo satisfazem isso: o maior da subárvore esquerda (o predecessor) e o menor da subárvore direita (o sucessor). Ambos servem — e ambos, por construção, têm no máximo um filho, o que reduz o caso 3 aos casos 1 e 2.

BST vs hash table: o que se ganha com a ordem

# O que a BST dá e a hash table NÃO dá: ORDEM.
#
# Hash table: O(1) para buscar uma chave exata. Só isso.
# BST:        O(log n) para buscar — mas também, de graça:

min(arvore)                   # desce sempre à esquerda   — O(log n)
max(arvore)                   # desce sempre à direita    — O(log n)
sucessor(arvore, x)           # o próximo valor acima de x — O(log n)
intervalo(arvore, 10, 50)     # TODAS as chaves entre 10 e 50
em_ordem(arvore)              # todos os valores ordenados — O(n)

# Numa hash table, "me dê todas as chaves entre 10 e 50" exige
# varrer a tabela inteira: O(n). E "qual a menor chave?" também.
#
# É por isso que bancos de dados usam árvores (B-trees) e não hash
# tables como índice padrão: WHERE idade > 30 é uma consulta por
# INTERVALO, e só uma estrutura ordenada responde isso rápido.

# Em Python não existe BST na biblioteca padrão. Use:
import bisect            # busca binária sobre lista ordenada
# ou a biblioteca externa "sortedcontainers" (SortedDict/SortedSet),
# que resolve o mesmo problema com listas de listas em vez de árvore.
# Em C++ é std::map, em Java é TreeMap — ambos árvores rubro-negras.
④ Projeto para programar

Construindo do zero

Mini projeto: implemente a BST completa (inserir, buscar, remover, travessias) e escreva a função eh_bst_valida(no) que verifica a invariante. Cuidado com a armadilha clássica: checar apenas esq.v < no.v < dir.v está errado — isso aceita árvores inválidas. A verificação correta passa um intervalo (mínimo, máximo) pela recursão e o vai estreitando. Escreva um teste que produza um contraexemplo para a versão ingênua.

Projeto principal: use sua BST para construir um índice de intervalo. Carregue 10 mil registros com um campo numérico (por exemplo, timestamps de eventos) e implemente consultar(inicio, fim) que devolva todos os registros no intervalo em O(log n + k), onde k é o número de resultados — descendo até o início do intervalo e depois percorrendo em ordem. Compare o tempo com uma varredura linear sobre uma lista e com um dict.

Desafio extra: escreva serializar(raiz) e desserializar(texto) que salvem a árvore em uma string e a reconstruam com exatamente a mesma forma. Descubra por si mesmo por que a travessia em ordem sozinha não serve para isso (dica: árvores de formas diferentes podem ter a mesma travessia em ordem) e qual travessia resolve, ou qual combinação de duas.

⑤ Exercícios rápidos

Teste sua intuição

O custo de buscar numa BST é proporcional a quê?
Qual travessia devolve os valores de uma BST já ordenados?
O que acontece se você inserir 1, 2, 3, 4, 5… numa BST comum?
Por que bancos de dados usam árvores (B-trees) como índice padrão, e não hash tables?
⑥ Aplicações no mundo real

Onde você encontra isso

🗄️

Índices de banco de dados

Quando você cria um índice no PostgreSQL, MySQL ou SQLite, ele é uma B-tree — uma árvore de busca generalizada, com centenas de chaves por nó em vez de uma. O motivo é o disco: cada nó ocupa exatamente uma página de 4 ou 8 KB, então uma árvore de altura 3 ou 4 encontra qualquer registro entre bilhões com pouquíssimas leituras. É a mesma ideia da BST, otimizada para o custo de I/O.

🗺️

Mapas ordenados das linguagens

std::map em C++, TreeMap em Java e BTreeMap em Rust são árvores de busca balanceadas. Você as escolhe em vez da versão hash exatamente quando precisa iterar em ordem, buscar o vizinho mais próximo ou consultar intervalos. O SortedDict do sortedcontainers cumpre esse papel no ecossistema Python.

🖼️

Particionamento do espaço em gráficos e jogos

Estruturas como quadtrees, octrees e k-d trees são a mesma ideia estendida a duas ou três dimensões: cada nó divide o espaço, e descer a árvore descarta regiões inteiras. É assim que um motor de jogo decide o que está no campo de visão sem testar todos os objetos, que um mapa encontra "restaurantes num raio de 1 km" e que um ray tracer descobre qual triângulo um raio atinge.

← Anterior: Hash tables por dentro Próxima: Árvores balanceadas →