Estruturas de Dados

Árvores B
B-Trees

Uma estrutura que mantém dados sempre ordenados e permite buscas, inserções e remoções em tempo O(log n) — mesmo com milhões de registros em disco.

📖 Baseado em Entendendo Algoritmos 🐍 Exemplos em Python com dicionários 🎮 Interativo

O que é uma Árvore B?

Imagine que você tem uma biblioteca gigante com milhões de livros. Uma lista simples seria lenta demais — você teria que verificar livro por livro. Uma árvore binária de busca (BST) ajuda, mas pode ficar desequilibrada. A Árvore B resolve isso de forma elegante.

💡 Analogia do livro Pense numa Árvore B como o índice de um livro didático: o índice te manda para a página certa sem ler o livro todo. Cada "nó" da árvore funciona como uma divisória que te guia pelo caminho correto.

Criada em 1972 por Rudolf Bayer e Edward McCreight nos laboratórios da Boeing, a Árvore B foi projetada especificamente para funcionar bem com armazenamento em disco, onde ler dados é muito mais lento que na memória RAM.

Onde ela é usada hoje?

🗄️ Bancos de dados
PostgreSQL, MySQL (InnoDB), SQLite e Oracle usam variações de B-Trees para seus índices.
💾 Sistemas de arquivos
NTFS (Windows), HFS+ e APFS (macOS), Btrfs (Linux) organizam arquivos com B-Trees.
🔑 Sistemas de chave-valor
LevelDB e RocksDB (usados no Chrome e Facebook) utilizam estruturas derivadas.
📡 DNS e roteamento
Tabelas de roteamento e caches de DNS usam árvores balanceadas para buscas rápidas.

Árvore Binária vs. Árvore B

A diferença fundamental: numa árvore binária, cada nó tem no máximo 2 filhos. Numa Árvore B, cada nó pode ter dezenas ou centenas de filhos. Isso deixa a árvore muito mais "achatada" (com menos níveis), reduzindo o número de leituras em disco.

Característica Árvore Binária (BST) Árvore B
Filhos por nó Até 2 Até m (ordem da árvore)
Chaves por nó 1 Até m-1
Altura típica O(n) pior caso O(log n) garantido
Balanceamento Pode degerar Sempre balanceada
Uso ideal Memória RAM Disco / grandes volumes

Propriedades Formais

Uma Árvore B de ordem m (também chamada de grau mínimo t onde m = 2t) segue regras rígidas que garantem que ela se mantenha sempre balanceada:

1
Raiz especial
A raiz tem entre 1 e m-1 chaves. Todos os outros nós têm entre ⌈m/2⌉-1 e m-1 chaves.
2
Chaves ordenadas
Dentro de cada nó, as chaves estão sempre em ordem crescente: k₁ < k₂ < ... < kₙ.
3
Filhos encaixados
Se um nó tem n chaves, ele tem n+1 ponteiros para filhos. Os filhos "encaixam" entre as chaves.
4
Folhas no mesmo nível
Todas as folhas estão na mesma profundidade. Isso garante o balanceamento perfeito.
5
Subárvores coerentes
Todos os valores na subárvore à esquerda de uma chave são menores que ela; todos à direita são maiores.
📐 Exemplo concreto Numa Árvore B de ordem 5 (t=2): cada nó interno (exceto raiz) tem entre 2 e 4 chaves, e entre 3 e 5 filhos. Se a raiz tiver 4 chaves, ela tem 5 filhos.

Comparativo entre variantes

Tipo Dados nos nós internos Dados nas folhas Ligação entre folhas Uso comum
B-Tree Sim Sim Não Índices gerais
B+-Tree Só chaves Chaves + dados Sim (lista encadeada) PostgreSQL, MySQL
B*-Tree Sim Sim Parcial HFS+ (macOS)

Na B+-Tree as folhas formam uma lista ligada, o que torna buscas por intervalo (ex: "todos os usuários entre 20 e 30 anos") muito eficientes.

Visualizando a Estrutura

Abaixo temos uma Árvore B de ordem 5 (cada nó comporta até 4 chaves e até 5 filhos). Passe o mouse sobre os nós para ver as informações.

Nó interno / raiz Nó folha Ponteiro filho

Como interpretar o diagrama

Cada retângulo representa um . Dentro dele, as chaves estão separadas por linhas verticais. Entre cada par de chaves (e antes da primeira e após a última) existe um ponteiro implícito para um nó filho.

🌳 Lendo a árvore No nó raiz [10 | 20 | 30]: tudo menor que 10 vai para o 1º filho, entre 10 e 20 vai para o 2º filho, entre 20 e 30 para o 3º filho, e maior que 30 para o 4º filho.

Representação em Python (com dicionários)

No livro Entendendo Algoritmos, estruturas são representadas de forma simples e direta. Seguindo esse espírito, usamos apenas dicionários Python:

# Um nó de Árvore B é apenas um dicionário Python
# Sem classes, sem complicação — só dados!

# Criando um nó folha com 2 chaves
no_folha = {
    "chaves": [5, 8],       # as chaves armazenadas, sempre ordenadas
    "filhos": [],           # folha não tem filhos
    "eh_folha": True
}

# Criando um nó interno (raiz) com 3 chaves e 4 filhos
no_raiz = {
    "chaves": [10, 20, 30],
    "filhos": [filho1, filho2, filho3, filho4],
    "eh_folha": False
}

# A árvore inteira é representada assim
arvore_b = {
    "raiz": no_raiz,
    "ordem": 5   # cada nó tem no máximo 4 chaves (ordem - 1)
}

Simples assim! Um nó é um dicionário com três campos: chaves, filhos e eh_folha. A árvore toda é um dicionário com a raiz e a ordem.

Operação: Busca

Buscar um valor numa Árvore B é como seguir um mapa: em cada nó, você decide se encontrou o valor, se vai para a esquerda, para o meio ou para a direita.

Passo a passo

  1. Comece pela raiz
  2. Compare o valor buscado com as chaves do nó, da esquerda para a direita
  3. Se encontrou: retorne o nó e a posição ✅
  4. Se o valor é menor que a chave atual: desça pelo filho à esquerda dessa chave
  5. Se passou por todas as chaves: desça pelo filho mais à direita
  6. Se chegou numa folha e não encontrou: o valor não existe na árvore
Passo 1 de 4 — Busca pelo valor 25
Começamos na raiz: nó com chaves [10, 20, 30].
Queremos encontrar o valor 25.

Código Python — busca com dicionários

def buscar(no, valor):
    """
    Busca um valor na árvore B.
    Retorna (nó, índice) se encontrado, ou None se não existe.
    
    no    → dicionário representando o nó atual
    valor → o número que estamos procurando
    """
    
    # Percorre as chaves do nó da esquerda para a direita
    i = 0
    while i < len(no["chaves"]) and valor > no["chaves"][i]:
        i += 1
    
    # Encontrou o valor exato!
    if i < len(no["chaves"]) and valor == no["chaves"][i]:
        return (no, i)  # ✅ achou
    
    # Chegou em folha e não achou: valor não existe
    if no["eh_folha"]:
        return None     # ❌ não existe
    
    # Desce para o filho adequado e repete
    return buscar(no["filhos"][i], valor)


# --- Exemplo de uso ---
resultado = buscar(arvore_b["raiz"], 25)

if resultado:
    no, idx = resultado
    print(f"Encontrado! Chave 25 está no índice {idx} do nó {no['chaves']}")
else:
    print("Valor não encontrado na árvore.")
⏱ Complexidade A busca visita no máximo h nós, onde h é a altura da árvore. Como h = O(log n), a busca é O(log n) — mesmo com bilhões de registros, são apenas ~30 comparações!

Operação: Inserção

Inserir numa Árvore B é mais elaborado que buscar. O desafio: a árvore deve sempre manter suas propriedades após a inserção. O mecanismo chave é o split (divisão) de nós cheios.

A ideia do Split

Quando um nó está cheio (tem o máximo de chaves), ele é dividido em dois. A chave do meio sobe para o nó pai. É como dividir um compartimento lotado em dois menores.

Passo 1 de 5 — Inserindo o valor 15
Estado inicial da árvore. Vamos inserir 15. Descemos pela raiz e encontramos o nó filho adequado.

Inserção interativa

Insira valores na árvore abaixo e observe o comportamento visual:

Digite um número e clique em Inserir.

Código Python — split e inserção

# ─────────────────────────────────────────────────
# SPLIT: divide um filho cheio em dois nós menores
# ─────────────────────────────────────────────────
def split_filho(pai, i, ordem):
    """
    pai   → nó pai (dicionário)
    i     → índice do filho cheio em pai["filhos"]
    ordem → ordem da árvore (máximo de filhos por nó)
    """
    t = ordem // 2              # metade da ordem (grau mínimo)
    filho_cheio = pai["filhos"][i]

    # Cria o novo nó que receberá a metade direita
    novo_no = {
        "chaves": filho_cheio["chaves"][t:],    # metade direita
        "filhos": filho_cheio["filhos"][t:] if not filho_cheio["eh_folha"] else [],
        "eh_folha": filho_cheio["eh_folha"]
    }

    # A chave do meio sobe para o pai
    chave_do_meio = filho_cheio["chaves"][t - 1]

    # Filho original fica só com a metade esquerda
    filho_cheio["chaves"] = filho_cheio["chaves"][:t - 1]
    if not filho_cheio["eh_folha"]:
        filho_cheio["filhos"] = filho_cheio["filhos"][:t]

    # Insere a chave do meio no pai, na posição correta
    pai["chaves"].insert(i, chave_do_meio)
    pai["filhos"].insert(i + 1, novo_no)


# ─────────────────────────────────────────────────
# INSERÇÃO num nó que não está cheio
# ─────────────────────────────────────────────────
def inserir_nao_cheio(no, valor, ordem):
    """Insere valor num nó que tem espaço disponível."""
    max_chaves = ordem - 1
    i = len(no["chaves"]) - 1  # começa pelo final

    if no["eh_folha"]:
        # Folha: insere o valor na posição correta (mantém ordenado)
        no["chaves"].append(None)
        while i >= 0 and valor < no["chaves"][i]:
            no["chaves"][i + 1] = no["chaves"][i]
            i -= 1
        no["chaves"][i + 1] = valor
    else:
        # Interno: encontra o filho correto para descer
        while i >= 0 and valor < no["chaves"][i]:
            i -= 1
        i += 1

        # Se o filho está cheio, faz split antes de descer
        if len(no["filhos"][i]["chaves"]) == max_chaves:
            split_filho(no, i, ordem)
            # Após split, decide qual dos dois novos filhos usar
            if valor > no["chaves"][i]:
                i += 1

        inserir_nao_cheio(no["filhos"][i], valor, ordem)


# ─────────────────────────────────────────────────
# INSERÇÃO principal (ponto de entrada)
# ─────────────────────────────────────────────────
def inserir(arvore, valor):
    """
    Insere um valor na árvore B.
    arvore → dicionário com "raiz" e "ordem"
    """
    raiz = arvore["raiz"]
    ordem = arvore["ordem"]
    max_chaves = ordem - 1

    # Caso especial: raiz está cheia → cresce a altura da árvore
    if len(raiz["chaves"]) == max_chaves:
        nova_raiz = {
            "chaves": [],
            "filhos": [raiz],  # raiz antiga vira filho
            "eh_folha": False
        }
        arvore["raiz"] = nova_raiz      # nova raiz assume o topo
        split_filho(nova_raiz, 0, ordem) # divide a raiz antiga
        inserir_nao_cheio(nova_raiz, valor, ordem)
    else:
        inserir_nao_cheio(raiz, valor, ordem)


# --- Exemplo completo ---
minha_arvore = {
    "raiz": {"chaves": [], "filhos": [], "eh_folha": True},
    "ordem": 5
}

for num in [10, 20, 5, 6, 12, 30, 7, 17]:
    inserir(minha_arvore, num)
    print(f"Inserido {num}. Raiz: {minha_arvore['raiz']['chaves']}")

Operação: Remoção

A remoção é a operação mais complexa. Depois de remover uma chave, o nó pode ficar com poucas chaves (violando as propriedades). A solução: redistribuição (empresta do irmão) ou merge (une dois nós).

📤 Redistribuição Se o irmão vizinho tem chaves sobrando, "empresta" uma chave passando pela chave separadora do pai. É como redistribuir fichas entre dois jogadores.
🔀 Merge (fusão) Se nenhum irmão tem chaves sobrando, une o nó com seu irmão e desce a chave separadora do pai. Dois nós viram um. O pai perde uma chave.

Casos da remoção

  1. Chave está em folha: remove diretamente (se o nó continuar com chaves suficientes)
  2. Chave está em nó interno: substitui pelo predecessor ou sucessor em folha, depois remove da folha
  3. Nó tem poucas chaves: redistribui ou faz merge antes de descer

Código Python — remoção com dicionários

def predecessor(no):
    """Maior chave da subárvore esquerda (predecessor em-ordem)."""
    while not no["eh_folha"]:
        no = no["filhos"][-1]  # sempre vai para o filho mais à direita
    return no["chaves"][-1]


def merge_filhos(pai, i, ordem):
    """
    Une filho[i] com filho[i+1], descendo chave[i] do pai.
    Resultado: filho[i] absorve tudo, filho[i+1] some.
    """
    t = ordem // 2
    filho_esq = pai["filhos"][i]
    filho_dir = pai["filhos"][i + 1]

    # Chave separadora do pai desce para o meio
    filho_esq["chaves"].append(pai["chaves"].pop(i))

    # Absorve todas as chaves e filhos do irmão direito
    filho_esq["chaves"].extend(filho_dir["chaves"])
    filho_esq["filhos"].extend(filho_dir["filhos"])

    # Remove o filho direito (não existe mais)
    pai["filhos"].pop(i + 1)


def remover(no, valor, ordem):
    """
    Remove um valor da subárvore com raiz em 'no'.
    Mantém todas as propriedades da Árvore B.
    """
    t = ordem // 2
    min_chaves = t - 1    # mínimo de chaves por nó (exceto raiz)
    max_chaves = ordem - 1

    # Encontra a posição do valor (ou onde deveria estar)
    i = 0
    while i < len(no["chaves"]) and valor > no["chaves"][i]:
        i += 1

    # CASO 1: Valor encontrado neste nó
    if i < len(no["chaves"]) and no["chaves"][i] == valor:

        if no["eh_folha"]:
            # 1a. Folha: remove diretamente
            no["chaves"].pop(i)

        else:
            # 1b. Nó interno: substitui pelo predecessor
            pred = predecessor(no["filhos"][i])
            no["chaves"][i] = pred                    # substitui
            remover(no["filhos"][i], pred, ordem)     # remove predecessor da folha

    # CASO 2: Valor não está neste nó → desce para o filho certo
    else:
        if no["eh_folha"]:
            print(f"Valor {valor} não encontrado na árvore.")
            return

        filho = no["filhos"][i]

        # Garante que o filho tem chaves suficientes antes de descer
        if len(filho["chaves"]) == min_chaves:

            # Tenta redistribuir com irmão esquerdo
            if i > 0 and len(no["filhos"][i-1]["chaves"]) > min_chaves:
                irm_esq = no["filhos"][i - 1]
                filho["chaves"].insert(0, no["chaves"][i - 1])
                no["chaves"][i - 1] = irm_esq["chaves"].pop()
                if not irm_esq["eh_folha"]:
                    filho["filhos"].insert(0, irm_esq["filhos"].pop())

            # Tenta redistribuir com irmão direito
            elif i < len(no["filhos"]) - 1 and len(no["filhos"][i+1]["chaves"]) > min_chaves:
                irm_dir = no["filhos"][i + 1]
                filho["chaves"].append(no["chaves"][i])
                no["chaves"][i] = irm_dir["chaves"].pop(0)
                if not irm_dir["eh_folha"]:
                    filho["filhos"].append(irm_dir["filhos"].pop(0))

            # Nenhum irmão tem chaves sobrando → faz merge
            else:
                if i > 0:
                    merge_filhos(no, i - 1, ordem)
                    i -= 1  # após merge, o filho ficou em i-1
                else:
                    merge_filhos(no, i, ordem)

        remover(no["filhos"][i], valor, ordem)


# --- Exemplo de uso ---
remover(minha_arvore["raiz"], 20, minha_arvore["ordem"])
print("Após remover 20:", minha_arvore["raiz"]["chaves"])

Análise de Complexidade

A beleza das Árvores B está na garantia matemática de eficiência. Como a árvore está sempre balanceada, todas as operações têm comportamento previsível.

Operação Tempo (melhor) Tempo (pior) Espaço
Busca O(log n) O(log n) O(1)
Inserção O(log n) O(log n) O(log n)*
Remoção O(log n) O(log n) O(log n)*
Percurso em ordem O(n) O(n) O(h)

* Espaço da pilha de recursão, onde h = O(log n) é a altura da árvore.

Por que O(log n)?

A altura de uma Árvore B de ordem m com n chaves é no máximo:

📐 Fórmula da altura h ≤ log⌈m/2⌉((n+1)/2) = O(log n)

Com m=1000 (comum em bancos de dados), uma árvore com 1 bilhão de registros tem altura máxima de apenas 3 níveis! Ou seja, qualquer busca exige no máximo 3 leituras em disco.

Comparativo com outras estruturas

Estrutura Busca Inserção Remoção Balanceamento
Árvore B O(log n) O(log n) O(log n) Automático
AVL O(log n) O(log n) O(log n) Estrito
Red-Black O(log n) O(log n) O(log n) Parcial
Hash Table O(1) médio O(1) médio O(1) médio Não suporta ordem
Lista Ordenada O(log n)* O(n) O(n) Manual
BST (pior caso) O(n) O(n) O(n) Pode degerar

* Busca binária em lista ordenada é O(log n), mas inserção e remoção são O(n) por deslocamento de elementos.

Quiz — Teste seus conhecimentos

5 perguntas para fixar o conteúdo. Cada resposta inclui uma explicação.

0/5