Aula 23 - B-Trees e B+-Trees
Motivação: o problema do disco
Tudo o que estudamos até agora — ABP, AVL, tabela hash — tem uma premissa implícita: os dados cabem na memória RAM. Quando isso é verdade, as operações custam O(log n) ou O(1) com constantes pequenas, e tudo funciona bem.
Na prática, porém, sistemas reais armazenam bilhões de registros:
- Um banco de dados de e-commerce pode ter 10 bilhões de transações históricas.
- Um sistema de arquivos do Linux indexa milhões de arquivos em um único disco.
- Um índice de mecanismo de busca contém trilhões de URLs.
Esses dados vivem em disco (HD ou SSD). E disco tem uma propriedade fundamental, diferente da RAM:
| Acesso ao disco é feito em BLOCOS. O disco não lê um único inteiro — ele lê um bloco inteiro de uma vez (tipicamente 4KB a 64KB). Ler 4 bytes ou ler 4.000 bytes custa exatamente o mesmo tempo se estiverem no mesmo bloco. E um acesso ao disco (mesmo SSD) é 100.000× mais lento que um acesso à RAM. |
Agora pense numa AVL com 1 bilhão de nós. A altura é ≈ 30. Uma busca faz 30 comparações, cada uma em um nó diferente — nós espalhados aleatoriamente pelo disco. Resultado: 30 leituras de disco. Cada leitura traz um bloco de 4KB, mas usa apenas os ≈ 20 bytes do nó AVL. 99,5% do bloco é desperdiçado.
Exemplo concreto: um banco com 1 bilhão de contas, buscando uma conta pelo número dela.
|
A solução é mudar o raciocínio: em vez de guardar 1 chave por nó, guardar centenas de chaves por nó, preenchendo um bloco inteiro. Assim, cada leitura de disco traz informação máxima, e a altura da árvore despenca de 30 para 4 ou 5. Esta é a ideia central da B-Tree.
Exemplos retráteis: os exemplos desta aula ficam recolhidos (clique em "Ver exemplo" ou no título do passo). Expandir todos | Recolher todos
1. B-Tree (Árvore B)
1.1 Definição formal
Uma B-Tree é uma árvore de busca em que cada nó guarda várias chaves em ordem (e não apenas uma, como na ABP e na AVL). Ela é descrita por um número inteiro t ≥ 2, o grau mínimo (minimum degree), que controla o quão cheio ou vazio cada nó pode estar. Propriedades de uma B-Tree de grau mínimo t:
- Número de filhos. Todo nó interno tem no máximo 2t filhos e, exceto a raiz, no mínimo t.
- Número de chaves. Todo nó tem no máximo 2t−1 chaves e, exceto a raiz, no mínimo t−1.
- Raiz. Pode ter menos chaves que os demais nós (no mínimo 1); se for interna (não folha), precisa ter pelo menos 2 filhos.
- Balanceamento. Todas as folhas estão na mesma profundidade: a árvore é perfeitamente balanceada em altura.
- Ordem. As chaves dentro de cada nó estão em ordem crescente.
- Chaves e filhos. Um nó interno com k chaves possui exatamente k+1 filhos, cada um responsável por um intervalo de valores: o 1.º filho guarda as chaves menores que a 1.ª chave do nó; o i-ésimo filho guarda as chaves entre a (i−1)-ésima e a i-ésima chave do nó; o último guarda as chaves maiores que a última chave.
| Grau mínimo | Chaves por nó (não-raiz) | Filhos por nó interno (não-raiz) | Chaves na raiz |
|---|---|---|---|
| t (geral) | t−1 a 2t−1 | t a 2t | 1 a 2t−1 |
| t = 2 | 1 a 3 | 2 a 4 | 1 a 3 |
| t = 3 | 2 a 5 | 3 a 6 | 1 a 5 |
| t = 170 | 169 a 339 | 170 a 340 | 1 a 339 |
Exemplo da propriedade 6: um nó [20, 40, 60] tem 3 chaves e portanto 4 filhos: o 1.º guarda valores < 20; o 2.º, valores entre 20 e 40; o 3.º, entre 40 e 60; o 4.º, valores > 60.
Por que essas regras? O máximo (2t−1) é o que cabe num bloco de disco. O mínimo (t−1) impede nós quase vazios, garantindo que cada bloco lido seja bem aproveitado e que a árvore seja baixa. A regra das folhas no mesmo nível garante que toda busca percorre o mesmo número de nós. As operações das seções seguintes (divisão, empréstimo e fusão) existem justamente para manter essas propriedades verdadeiras depois de cada inserção e remoção.
| Exemplo prático: Um bloco de disco de 4KB guarda chaves inteiras (4 bytes cada) e ponteiros de 64 bits (8 bytes cada). Em um nó com m chaves e m+1 ponteiros: 4m + 8(m+1) ≤ 4096, logo m ≤ 340. Portanto t ≈ 170 — cada nó da B-Tree cabe num bloco e carrega ~340 chaves. Isso é dramaticamente diferente de 1 chave por nó (AVL). |
Nota: outros livros usam convenções diferentes (por exemplo, a ordem m = máximo de filhos, com m = 2t). Aqui adotamos o grau mínimo t do livro Introduction to Algorithms (Cormen, Leiserson, Rivest e Stein), conhecido pela sigla CLRS, formada pelas iniciais dos autores.
1.2 Estrutura e exemplo usado na seção
Para desenhar, usamos t = 2: cada nó tem de 1 a 3 chaves (a raiz pode ter 1 a 3) e, se for interno, de 2 a 4 filhos. Dizemos que um nó estoura quando passa de 3 chaves (4 chaves) e que fica abaixo do mínimo quando tem menos de 1 chave (0 chaves, vazio). Em um nó, as chaves ficam lado a lado e os filhos penduram entre elas: um nó com k chaves tem k+1 filhos.
Exemplo usado em toda a seção: uma B-Tree de 5 nós com as chaves 10, 20, ..., 90 (a raiz e 4 folhas).
Verificando as propriedades:
- Raiz [20, 40, 60]: 3 chaves = 2t−1, o máximo permitido (a raiz tem 4 filhos, entre 2 e 2t).
- Folhas [10], [30] e [50]: 1 chave = t−1, o mínimo permitido. Folha [70, 80, 90]: 3 chaves, o máximo.
- Todas as folhas estão na profundidade 1, e as chaves estão em ordem dentro de cada nó.
- Guias corretas: a folha [10] só tem valores < 20; [30] está entre 20 e 40; [50] entre 40 e 60; [70, 80, 90] só tem valores > 60.
Esta árvore é exatamente o que se obtém inserindo 10, 20, 30, ..., 90 nessa ordem a partir da árvore vazia (veja o exemplo completo, passos 1 a 9). Numa ABP, essa mesma ordem de inserção produziria uma lista encadeada.
Verde = caminho percorrido (verde forte = encontrado); vermelho = chave a remover, ou nó com problema (estourado ou abaixo do mínimo, tracejado); amarelo = nó novo ou alterado; amarelo forte = chave inserida ou que mudou de lugar.
1.3 Inserção
Inserção em uma B-Tree:
- Localizar a folha onde a nova chave deve ser inserida, descendo a partir da raiz.
- Inserir a chave na posição correta, mantendo a ordenação.
- Se o nó ultrapassar o máximo de chaves (2t−1), ele estoura: realizar um split (divisão), deixando as t−1 chaves menores num nó e as t maiores em outro.
- Promover a chave mediana (a t-ésima) para o nó pai.
- Se o pai também ultrapassar o máximo, repetir o processo, subindo até a raiz. Se a raiz estourar, cria-se uma nova raiz (é a única forma de a altura crescer).
Ideia principal: inserir na folha e dividir os nós quando necessário, mantendo a árvore balanceada. Os casos:
Caso 1 — A folha tem espaço
Basta inserir a chave na posição certa. Nada mais muda.
Ver exemplo: Inserir 35
Raiz [20, 40, 60]: 20 < 35 < 40, desce para a folha [30], que tem 1 chave e comporta até 3: vira [30, 35].
Caso 2 — A folha estoura e o pai tem espaço
Divide-se a folha; a chave do meio sobe e entra em ordem no pai, que continua com no máximo 3 chaves. (Se a folha for a própria raiz, não há pai: a chave que sobe forma uma nova raiz.)
Ver exemplo: Inserir 60 numa árvore com raiz [20] e folhas [10] e [30, 40, 50]
Essa é a árvore do passo 5 do exemplo completo (a árvore base, com a raiz cheia, não serve aqui, porque o pai não teria espaço). 60 vai para [30, 40, 50], que estoura com 4 chaves. A 2.ª chave (40) sobe e as demais se separam em [30] e [50, 60]. O pai [20] tinha espaço e vira [20, 40].
Caso 3 — A divisão se propaga: o pai também estoura
Quando a chave que sobe faz o pai passar de 3 chaves, divide-se o pai do mesmo jeito, e assim por diante. Se a propagação chegar à raiz, nasce uma nova raiz e a altura aumenta em 1.
Ver exemplo: Inserir 85 na árvore base
85 vai para a folha [70, 80, 90], que estoura. Sobe 80 e a folha vira [70] e [85, 90]. A raiz [20, 40, 60] recebe 80, fica com 4 chaves e também estoura: sobe 40, que forma a nova raiz; sobram [20] (filhos [10] e [30]) e [60, 80] (filhos [50], [70] e [85, 90]).
Caso 4 — A chave já existe
Como as chaves são únicas, nada é inserido (a busca pela posição encontra a chave).
Ver exemplo: Inserir 50 na árvore base
A busca por 50 chega à folha [50]: a chave já está lá, então a árvore não muda.
1.4 Busca
Busca em uma B-Tree:
- Começar pela raiz.
- Comparar a chave buscada com as chaves do nó atual, que estão em ordem.
- Se for igual a uma delas, a chave foi encontrada.
- Se não for e o nó for folha, a chave não existe.
- Caso contrário, descer para o filho cujo intervalo contém a chave e repetir.
Ideia principal: como na ABP, mas escolhendo entre até 2t caminhos por nó; cada nó visitado é uma leitura de disco, e a altura é pequena. Os casos:
Caso 1 — Encontrada num nó interno
Na B-Tree, as chaves (com seus dados) ficam em qualquer nó, então a busca pode terminar antes de chegar às folhas.
Ver exemplo: Buscar 40
A raiz [20, 40, 60] contém 40: encontrada com 1 única leitura de disco.
Caso 2 — Encontrada numa folha
Desce-se um nível por leitura até a folha que contém a chave.
Ver exemplo: Buscar 80
Raiz [20, 40, 60]: 80 > 60, então desce pelo último filho. A folha [70, 80, 90] contém 80: 2 leituras.
Caso 3 — Não encontrada
Chegamos a uma folha que não contém a chave: não há mais para onde descer.
Ver exemplo: Buscar 35
Raiz [20, 40, 60]: 20 < 35 < 40, desce pelo 2.º filho. A folha [30] não tem 35 e não tem filhos: a chave não existe (2 leituras).
1.5 Remoção
Remoção em uma B-Tree: a remoção sempre acontece numa folha. Se a chave estiver num nó interno, troca-se por seu predecessor (maior chave da subárvore da esquerda) ou sucessor (menor da subárvore da direita), que estão em folhas, e remove-se este (de preferência o de uma folha com folga). Depois de remover da folha, existem três situações:
- Remoção simples: a folha continua com pelo menos t−1 chaves; nada mais a fazer.
- Empréstimo (redistribuição): a folha ficou com poucas chaves e um irmão adjacente tem folga; ele empresta uma chave, passando pelo pai (rotação).
- Fusão (merge): nenhum irmão pode emprestar; junta-se a folha, o irmão e a chave separadora do pai num único nó. O pai perde uma chave e pode ficar com poucas, repetindo o problema um nível acima.
Se o problema chegar à raiz e ela ficar vazia, seu único filho vira a nova raiz (a altura diminui).
Ideia principal: manter todos os nós dentro dos limites de ocupação (pelo menos t−1 chaves) e preservar o balanceamento da árvore. Os casos:
Caso 1 — A chave não existe
A busca termina numa folha sem a chave: nada muda.
Ver exemplo: Remover 55 da árvore base
Raiz [20, 40, 60]: 40 < 55 < 60, desce para [50]. Não há 55.
Caso 2 — A chave está numa folha com folga
A folha tem mais que t−1 chaves: remove-se a chave e pronto.
Ver exemplo: Remover 80 da árvore base
A folha [70, 80, 90] tem 3 chaves; sem o 80 sobram 2, mais que o mínimo (1).
Caso 3 — A chave está num nó interno
Troca-se a chave pelo sucessor (ou predecessor) e remove-se este da folha. Se a folha ficar abaixo do mínimo, aplicam-se os casos 4 a 6.
Ver exemplo: Remover 60 da árvore base
60 está na raiz. Seu predecessor (50) está numa folha no mínimo; o sucessor (70) está na folha [70, 80, 90], que tem folga. Troca-se 60 por 70 e remove-se 70 da folha, que vira [80, 90].
Caso 4 — A folha fica abaixo do mínimo e um irmão pode emprestar
Rotação: a chave separadora do pai desce para a folha e a chave do irmão mais próxima dela sobe para o pai. O irmão perde uma chave, a folha ganha uma, o pai mantém o mesmo número.
Ver exemplo: Remover 50 da árvore base
Sem o 50, a folha fica vazia. O irmão da esquerda, [30], está no mínimo e não pode emprestar; o da direita, [70, 80, 90], pode. O separador 60 desce para a folha e 70 sobe para o pai.
Caso 5 — A folha fica abaixo do mínimo, nenhum irmão pode emprestar e o pai tem folga
Fusão: a folha, o irmão e a chave separadora do pai viram um único nó (com no máximo (t−1) + 1 + (t−1) = 2t−1 chaves, sempre cabe). O pai perde uma chave; como tinha mais que o mínimo, termina.
Ver exemplo: Remover 10 da árvore base
A folha [10] fica vazia e seu único irmão, [30], está no mínimo. Funde-se [ ] + 20 (que desce da raiz) + [30] = [20, 30]. A raiz passa de 3 para 2 chaves, ainda válida.
Caso 6 — A fusão se propaga: o pai também fica abaixo do mínimo
O pai passa por um empréstimo (6a) ou por outra fusão (6b), como se fosse uma folha. A diferença é que um nó interno também carrega filhos: na rotação, o filho que fica "entre" as chaves muda de pai; na fusão, os filhos dos dois nós ficam juntos. Se a raiz esvaziar, a árvore perde um nível.
Ver exemplo 6a: Remover 10 da árvore de 3 níveis obtida ao inserir 85 (caso 3)
A folha [10] fica vazia e funde-se com [30] e o separador 20: [20, 30]. O pai [20] fica sem chaves. O irmão [60, 80] tem folga e empresta por rotação: 40 desce da raiz e 60 sobe para a raiz; o filho [50], que estava à esquerda de 60, passa a ser filho do nó [40].
Ver exemplo 6b: Remover 10 numa B-Tree de 3 níveis com todos os nós no mínimo
Aqui nenhum nó pode emprestar. A fusão das folhas deixa o pai [20] vazio; ele se funde com o irmão [50] e o separador 30 que desce da raiz, e a raiz esvazia. Resultado: a altura cai de 2 para 1.
1.6 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 B-Tree (t = 2), partindo da árvore vazia. Cada passo indica o caso (das seções acima) que exemplifica; no conjunto, os passos cobrem todos os casos de busca, inserção e remoção. Nos desenhos, cada passo mostra a árvore após a operação (nas remoções, antes e depois). Ao fim do passo 9 temos a árvore base de 5 nós.
| Operação | Caso | Passo(s) |
|---|---|---|
| Inserção | Caso 1 — a folha tem espaço | 1, 2, 3, 5, 7, 9, 11 |
| Inserção | Caso 2 — a folha estoura e o pai tem espaço (ou nasce a raiz) | 4, 6, 8 |
| Inserção | Caso 3 — a divisão se propaga | 10 |
| Inserção | Caso 4 — a chave já existe | 12 |
| Busca | Caso 1 — encontrada num nó interno | 13 |
| Busca | Caso 2 — encontrada numa folha | 14 |
| Busca | Caso 3 — não encontrada | 15 |
| Remoção | Caso 1 — a chave não existe | 16 |
| Remoção | Caso 2 — folha com folga | 17 |
| Remoção | Caso 3 — chave em nó interno | 18 |
| Remoção | Caso 4 — empréstimo do irmão | 21 |
| Remoção | Caso 5 — fusão com o pai folgado | 22 |
| Remoção | Caso 6a — a fusão se propaga e o pai empresta do irmão | 19 |
| Remoção | Caso 6b — fusão que se propaga até a raiz | 20 |
Verde = caminho percorrido (verde forte = encontrado); vermelho = chave a remover, ou nó com problema (estourado ou abaixo do mínimo, tracejado); amarelo = nó novo ou alterado; amarelo forte = chave inserida ou que mudou de lugar.
Passo 1 — Inserir 10 (Inserção, caso 1)
Árvore vazia: cria-se um único nó, que é ao mesmo tempo raiz e folha, com a chave 10.
Passo 2 — Inserir 20 (Inserção, caso 1)
A raiz-folha tem espaço (o máximo é 3 chaves): 20 entra em ordem.
Passo 3 — Inserir 30 (Inserção, caso 1)
30 também cabe. A raiz-folha fica cheia, com 3 chaves = 2t−1.
Passo 4 — Inserir 40 (Inserção, caso 2)
Inserir 40 deixaria a raiz com 4 chaves, [10, 20, 30, 40]: ela estoura. A 2ª chave (20) sobe; as menores ficam à esquerda ([10]) e as maiores à direita ([30, 40]). Como não existia pai, 20 forma uma nova raiz: a altura passa de 0 para 1.
Passo 5 — Inserir 50 (Inserção, caso 1)
50 > 20: vai para o filho da direita, [30, 40], que tem espaço.
Passo 6 — Inserir 60 (Inserção, caso 2)
60 vai para [30, 40, 50], que estoura. A 2ª chave (40) sobe; sobram [30] e [50, 60]. O pai [20] tinha espaço e vira [20, 40]: a divisão para aí.
Passo 7 — Inserir 70 (Inserção, caso 1)
70 vai para [50, 60], que tem espaço: [50, 60, 70].
Passo 8 — Inserir 80 (Inserção, caso 2)
80 estoura [50, 60, 70, 80]. Sobe 60; ficam [50] e [70, 80]. A raiz vira [20, 40, 60], cheia mas válida. A árvore tem agora exatamente 5 nós.
Passo 9 — Inserir 90 (Inserção, caso 1)
90 cabe em [70, 80, 90]. Esta é a árvore base de 5 nós da seção 1.2 (inserir as chaves em ordem crescente produz uma árvore perfeitamente balanceada, ao contrário da ABP).
Passo 10 — Inserir 85 (Inserção, caso 3)
85 vai para [70, 80, 90], que estoura; sobe 80. A raiz [20, 40, 60] recebe 80 e também estoura; sobe 40, que forma a nova raiz. A divisão se propagou até o topo e a altura passou de 1 para 2.
Passo 11 — Inserir 95 (Inserção, caso 1)
95 desce por [40] → [60, 80] até a folha [85, 90], que tem espaço.
Passo 12 — Inserir 50 (Inserção, caso 4)
A chave 50 já existe na folha [50]: nada muda.
Passo 13 — Buscar 80 (Busca, caso 1)
Raiz [40]: 80 > 40, vai para [60, 80], onde encontra 80. Foram 2 leituras de nó, sem descer até as folhas.
Passo 14 — Buscar 30 (Busca, caso 2)
Raiz [40]: 30 < 40 → [20]: 30 > 20 → folha [30]: encontrada (3 leituras).
Passo 15 — Buscar 55 (Busca, caso 3)
Raiz [40]: 55 > 40 → [60, 80]: 55 < 60 → folha [50]. Não há 55, e uma folha não tem para onde descer: não existe.
Passo 16 — Remover 99 (Remoção, caso 1)
99 > 40, 99 > 80: a busca chega em [85, 90, 95] sem achar 99. Nada muda.
Passo 17 — Remover 90 (Remoção, caso 2)
90 está numa folha com 3 chaves. Depois de removê-la sobram 2 (≥ 1, o mínimo): fim.
Passo 18 — Remover 80 (Remoção, caso 3)
80 está no nó interno [60, 80]. Troca-se 80 pelo seu sucessor (85, a menor chave da subárvore da direita), que está numa folha com folga ([85, 95]). Remove-se 85 da folha, que continua com 1 chave.
Passo 19 — Remover 10 (Remoção, caso 6a)
Remover 10 esvazia a folha [10]; o irmão [30] está no mínimo, então fundem-se com o separador 20: [20, 30]. O pai [20] fica sem chaves (abaixo do mínimo), mas o irmão dele, [60, 85], tem folga e empresta por rotação: 40 desce da raiz para o pai e 60 sobe do irmão para a raiz. O filho [50], que estava à esquerda da chave 60 no irmão, passa a ser filho do nó [40]. A altura não muda.
Passo 20 — Remover 70 (Remoção, caso 6b)
Remover 70 esvazia a folha [70]; o irmão [95] está no mínimo, então fundem-se com o separador 85: [85, 95]. O pai [85] fica sem chaves e o irmão dele, [40], também está no mínimo: fundem-se com o separador 60 que desce da raiz, formando [40, 60]. A raiz [60] ficou vazia, então seu único filho vira a raiz: a altura volta de 2 para 1.
Passo 21 — Remover 50 (Remoção, caso 4)
50 é a única chave da folha [50]. O irmão esquerdo [20, 30] tem folga e empresta por rotação: 40 desce do pai para a folha ([40]) e 30 sobe do irmão para a raiz, que vira [30, 60].
Passo 22 — Remover 20 (Remoção, caso 5)
20 é a única chave da folha [20] e o irmão da direita, [40], está no mínimo: fundem-se com o separador 30 que desce da raiz, formando [30, 40]. A raiz [30, 60] vira [60], que ainda tem o mínimo de 1 chave: fim.
1.7 Complexidade
n = número de chaves armazenadas; t = grau mínimo (cada nó tem de t−1 a 2t−1 chaves); h = altura da árvore (número de arestas da raiz até uma folha). Como cada nó é um bloco de disco, medimos o custo principal em leituras de disco (uma por nó visitado); o custo de CPU dentro do nó vem à parte.
Pior caso = o maior custo que pode acontecer, para o pior conjunto de chaves e a pior ordem de operações. Caso médio = o custo típico, com chaves em ordem aleatória.
| Operação | Pior caso (disco) | Caso médio (disco) | Em uma frase |
|---|---|---|---|
| Busca | O(logt n) | O(logt n) | uma leitura por nível, da raiz até a folha |
| Inserção | O(logt n) | O(logt n) leituras, em geral 1 escrita | desce até a folha; o split é raro, mas pode subir até a raiz |
| Remoção | O(logt n) | O(logt n) leituras, em geral 1 escrita | desce até a folha; fusões em cascata só quando todos estão no mínimo |
| Espaço | O(n) | O(n) | todo nó, exceto a raiz, está pelo menos meio cheio |
Por que a altura é O(logt n)?
Intuição: cada nó interno tem pelo menos t filhos (a raiz, pelo menos 2). Então, mesmo no pior caso (todos os nós no mínimo), o número de nós multiplica por t a cada nível: 1 nó na raiz, pelo menos 2 no nível 1, pelo menos 2t no nível 2, 2t² no nível 3, e assim por diante. Contando as chaves, uma árvore de altura h tem no mínimo 2th − 1 chaves. Logo n ≥ 2th − 1, ou seja, h ≤ logt((n+1)/2). Para t = 170 e n = 109, h ≤ 3,6: no máximo 4 níveis (4 leituras de disco), contra cerca de 30 numa AVL. O caso melhor é a árvore com todos os nós cheios, de altura ≈ log2t n; na prática os nós ficam em torno de 70% cheios (para chaves inseridas em ordem aleatória), e a altura fica entre os dois extremos, sempre O(logt n).
- Busca: uma leitura por nível, no máximo h + 1. Dentro do nó, com varredura linear, o custo de CPU é O(t) por nó, ou O(t · logt n) no total; com busca binária no nó cai para O(log2 t) por nó, o que dá O(log n) no total, igual à AVL (a vantagem da B-Tree não está na CPU, e sim no número de leituras de disco).
- Inserção: desce h leituras até a folha e insere. Um split custa O(t) (copiar metade das chaves) e cria dois nós pela metade: a mesma folha só voltará a estourar depois de pelo menos t novas inserções nela. Por isso, em média, quase toda inserção termina na folha, sem dividir nada. No pior caso, o split sobe por todos os h níveis (como no caso 3), o que custa O(t · logt n) em CPU.
- Remoção: também desce até uma folha (h leituras) e conserta no caminho de volta. Uma fusão só se propaga quando o irmão e o pai estão no mínimo; no caso comum, a folha tem folga (caso 2) ou o empréstimo resolve (caso 4). O pior caso (como o do passo 20) percorre os h níveis, com custo O(t · logt n) em CPU.
- Espaço: como todo nó (menos a raiz) tem pelo menos metade das chaves possíveis, o espaço desperdiçado é no máximo cerca de metade.
1.8 Estrutura e funções em C (TAD simples)
Uma forma simples de representar a B-Tree em C: cada nó guarda um vetor de chaves, um vetor de filhos (ignorado se for folha) e um contador de quantas chaves estão em uso. O grau mínimo t fixa os tamanhos máximos: até 2t−1 chaves e 2t filhos por nó.
#define T 2 /* grau mínimo da árvore */ typedef struct NodoB { int n; /* número de chaves em uso */ int chaves[2*T - 1]; /* chaves em ordem crescente */ struct NodoB *filhos[2*T]; /* filhos (não usados se for folha) */ int folha; /* 1 se for folha, 0 caso contrário */ } NodoB; NodoB *criarNodoB(int folha); /* cria um nó vazio (folha ou interno) */ NodoB *buscarB(NodoB *raiz, int chave); /* devolve o nó onde a chave está, ou NULL */ NodoB *inserirB(NodoB *raiz, int chave); /* insere a chave; devolve a (nova) raiz */ NodoB *removerB(NodoB *raiz, int chave); /* remove a chave; devolve a (nova) raiz */ void destruirB(NodoB *raiz); /* libera todos os nós */
main de demonstração:
int main(void) { NodoB *raiz = criarNodoB(1); /* raiz começa como folha vazia */ int chaves[] = {10, 20, 30, 40, 50, 60, 70, 80, 90}; for (int i = 0; i < 9; i++) raiz = inserirB(raiz, chaves[i]); printf("buscarB(70) = %s\n", buscarB(raiz, 70) ? "achou" : "não achou"); /* achou */ printf("buscarB(75) = %s\n", buscarB(raiz, 75) ? "achou" : "não achou"); /* não achou */ raiz = removerB(raiz, 60); destruirB(raiz); return 0; }
2. B+-Tree (Árvore B+)
2.1 A diferença fundamental
Na B-Tree, os dados (valores dos registros) podem estar em qualquer nó — interno ou folha. Na B+-Tree, há uma separação rigorosa:
|
B+-Tree: • Nós internos guardam apenas chaves de navegação — servem exclusivamente para guiar a busca. • Folhas guardam todos os dados (pares chave/valor ou ponteiros para os registros). • As folhas são ligadas em uma lista duplamente encadeada → percurso sequencial sem subir na árvore. |
Por que isso é melhor para bancos de dados?
- Nós internos mais densos: sem dados, os nós internos cabem mais chaves por bloco → árvore mais larga → altura menor → menos leituras de disco.
- Consultas por intervalo eficientes: para buscar todos os registros com chave entre A e B, navegamos até a folha com A e escaneamos a lista encadeada de folhas até passar B. Isso é O(logt n + k), onde k é o número de resultados. Na B-Tree comum, cada resultado poderia estar num nó interno diferente, exigindo várias subidas e descidas na árvore.
- Varredura sequencial: percorrer todos os registros em ordem = percorrer a lista encadeada de folhas, sem tocar nos nós internos.
2.2 Estrutura e exemplo usado na seção
Mantemos t = 2. Os nós internos têm de 1 a 3 chaves de navegação (os separadores) e de 2 a 4 filhos. As folhas têm de t a 2t−1 chaves (2 ou 3): assim, uma folha que estoura (4 chaves) se divide em duas folhas de exatamente 2. Um nó interno com separadores s1 < s2 < ... leva ao filho i as chaves k com si ≤ k < si+1: uma chave igual a um separador vai para a direita. As setas ↔ entre as folhas são a lista encadeada.
Exemplo usado em toda a seção: uma B+-Tree de 5 nós com as mesmas chaves 10, 20, ..., 90 (raiz interna e 4 folhas). Nos desenhos, nós internos em cinza e folhas em azul.
Observe que 30, 50 e 70 aparecem na raiz e nas folhas. Isso é intencional: na B+-Tree, as chaves dos nós internos são cópias que servem apenas como separadores. Todos os dados reais estão nas folhas. Por serem só placas de sinalização, um separador pode continuar na árvore mesmo depois de a chave ter sido removida da folha (veja a remoção).
Verde = caminho percorrido (verde forte = encontrado); vermelho = chave a remover, ou nó com problema (estourado ou abaixo do mínimo, tracejado); amarelo = nó novo ou alterado; amarelo forte = chave inserida, copiada ou que mudou de lugar.
2.3 Inserção
Inserção em uma B+-Tree:
- Localizar a folha onde a nova chave deve ser inserida (chave igual a um separador vai para a direita).
- Inserir a chave na posição correta, mantendo a ordenação.
- Se a folha ultrapassar o máximo de chaves (2t−1), realizar um split: duas folhas com t chaves cada.
- Copiar a menor chave da folha da direita para o nó pai (ela continua na folha, porque o dado só existe lá).
- Se o pai também ultrapassar o máximo, dividir o nó interno: a chave do meio sobe, sem cópia. Repetir até a raiz, podendo criar uma nova raiz.
Ideia principal: inserir na folha e dividir quando necessário; na divisão de folha a chave é copiada para o pai, e na divisão de nó interno ela é promovida. Os casos:
Caso 1 — A folha tem espaço
Ver exemplo: Inserir 25 na árvore base
Raiz [30, 50, 70]: 25 < 30, desce para [10, 20], que vira [10, 20, 25]. Os separadores não mudam.
Caso 2 — A folha estoura e o pai tem espaço
Divide-se a folha em duas e copia-se a menor chave da direita para o pai. (Se a folha for a raiz, a cópia forma uma nova raiz.)
Ver exemplo: Inserir 60 numa árvore com raiz [30] e folhas [10, 20] e [30, 40, 50]
Essa é a árvore do passo 5 do exemplo completo (a raiz da árvore base está cheia). 60 vai para [30, 40, 50], que estoura: dividem-se em [30, 40] e [50, 60] e 50 é copiado para o pai, que vira [30, 50].
Caso 3 — A divisão se propaga: o pai também estoura
O nó interno que estoura (4 separadores) é dividido: a 2.ª chave sobe (sem cópia) e as outras ficam repartidas. Se for a raiz, nasce uma nova raiz e a altura aumenta em 1.
Ver exemplo: Inserir 85 na árvore base
85 vai para [70, 80, 90], que estoura: dividem-se em [70, 80] e [85, 90], e 85 é copiado para a raiz, que passa a [30, 50, 70, 85] e também estoura. A 2.ª chave (50) sobe e forma a nova raiz; sobram [30] e [70, 85].
Caso 4 — A chave já existe
Ver exemplo: Inserir 40 na árvore base
A busca chega à folha [30, 40] e a chave já está lá: nada muda.
2.4 Busca
Busca em uma B+-Tree:
- Começar pela raiz.
- Em cada nó interno, comparar a chave com os separadores e descer pelo filho certo (chave igual a um separador vai para a direita).
- Repetir até chegar a uma folha: a busca sempre desce até ela, mesmo que a chave apareça como separador.
- Procurar a chave na folha: se estiver lá, foi encontrada; senão, não existe.
Ideia principal: os dados só ficam nas folhas, então toda busca percorre o mesmo número de nós. Os casos:
Caso 1 — A chave é igual a um separador
A busca não termina no nó interno: segue pelo filho da direita e encontra a chave na folha.
Ver exemplo: Buscar 50
A raiz [30, 50, 70] tem 50 como separador, mas 50 ≥ 50 manda a busca para o 3.º filho. A folha [50, 60] contém 50: encontrada, com 2 leituras.
Caso 2 — A chave está numa folha (e não é separador)
Ver exemplo: Buscar 80
Raiz [30, 50, 70]: 80 ≥ 70, desce pelo último filho. A folha [70, 80, 90] contém 80.
Caso 3 — Não encontrada
A folha em que a chave deveria estar não a contém.
Ver exemplo: Buscar 45
Raiz [30, 50, 70]: 30 ≤ 45 < 50, desce para a folha [30, 40], que não tem 45.
Busca por intervalo
É aqui que a B+-Tree brilha. Desce-se uma única vez até a folha que contém (ou conteria) o início do intervalo e, daí em diante, percorre-se a lista encadeada de folhas coletando as chaves até passar do fim, sem voltar aos nós internos.
Ver exemplo: Buscar todas as chaves entre 35 e 75
- Raiz [30, 50, 70]: 30 ≤ 35 < 50, desce para a folha [30, 40]. As chaves ≥ 35 dela: 40.
- Segue o ponteiro para a folha [50, 60]: coleta 50 e 60.
- Segue o ponteiro para [70, 80, 90]: coleta 70; 80 > 75, então para.
Resultado: {40, 50, 60, 70}. Custo: 1 descida + 3 folhas = O(logt n + k).
Compare com uma ABP balanceada: cada um dos k resultados custaria uma nova busca, O(k log n); e numa B-Tree comum os k resultados podem estar em nós internos e folhas misturados, exigindo subidas e descidas.
2.5 Remoção
Remoção em uma B+-Tree: toda chave está numa folha, então a remoção sempre começa nela (não há troca por predecessor ou sucessor). Os separadores dos nós internos não precisam ser apagados, pois continuam sendo guias válidos. Depois de remover da folha, existem três situações:
- Remoção simples: a folha continua com pelo menos t chaves; nada mais a fazer.
- Empréstimo (redistribuição): a folha ficou com poucas chaves e um irmão adjacente tem folga; ele empresta uma chave e o separador do pai é atualizado.
- Fusão (merge): nenhum irmão pode emprestar; juntam-se as duas folhas e o separador do pai é descartado. O pai perde uma chave e pode ficar com poucas, repetindo o problema um nível acima (nos nós internos, empréstimo e fusão funcionam como na B-Tree: o separador passa pelo pai ou desce).
Se o problema chegar à raiz e ela ficar vazia, seu único filho vira a nova raiz (a altura diminui).
Ideia principal: manter todos os nós dentro dos limites de ocupação e preservar o balanceamento da árvore, com os dados sempre nas folhas. Os casos:
Caso 1 — A chave não existe
Ver exemplo: Remover 45 da árvore base
A busca chega à folha [30, 40], que não tem 45. Nada muda.
Caso 2 — A folha tem folga
A folha tem mais que t chaves: remove-se a chave. Mesmo que ela seja um separador, o separador permanece no nó interno.
Ver exemplo: Remover 70 da árvore base
A folha [70, 80, 90] vira [80, 90], com 2 chaves (o mínimo). O 70 continua na raiz como separador: tudo que é ≥ 70 ainda vai para essa folha e tudo que é < 70 vai para a anterior, então a busca continua correta.
Caso 3 — A folha fica abaixo do mínimo e um irmão pode emprestar
A folha recebe a chave do irmão mais próxima dela e o separador do pai é atualizado para a nova menor chave da folha da direita.
Ver exemplo: Remover 60 da árvore base
[50, 60] vira [50], abaixo do mínimo (2). O irmão da esquerda, [30, 40], está no mínimo; o da direita, [70, 80, 90], tem folga e empresta 70. A folha vira [50, 70] e o separador entre as duas, que era 70, passa a ser 80.
Caso 4 — A folha fica abaixo do mínimo, nenhum irmão pode emprestar e o pai tem folga
Fundem-se as duas folhas (cabem: 2 + 1 ≤ 3) e o separador do pai que as dividia é descartado (diferente da B-Tree, em que a chave desce).
Ver exemplo: Remover 10 da árvore base
[10, 20] vira [20] e seu irmão [30, 40] está no mínimo. Fundem-se em [20, 30, 40] e o separador 30 sai da raiz, que vira [50, 70].
Caso 5 — A fusão se propaga: o pai também fica abaixo do mínimo
O nó interno que perdeu o separador fica sem chaves. Ele pede emprestado ao irmão (5a, por rotação pelo pai) ou se funde com o irmão, com o separador do avô descendo (5b). Se a raiz esvaziar, a árvore perde um nível.
Ver exemplo 5a: Remover 10 da árvore de 3 níveis obtida ao inserir 85 (caso 3)
As folhas [20] e [30, 40] se fundem em [20, 30, 40] e o separador 30 sai do pai, que fica sem chaves. O irmão [70, 85] tem folga e empresta por rotação: 50 desce da raiz e 70 sobe para a raiz; a folha [50, 60] passa a ser filha do nó [50].
Ver exemplo 5b: Remover 10 numa B+-Tree de 3 níveis com todos os nós no mínimo
Nenhum nó pode emprestar. As folhas se fundem e o pai [30] fica vazio; os nós internos [ ] e [70] se fundem com o separador 50 que desce da raiz, e a raiz esvazia. A altura cai de 2 para 1.
2.6 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 B+-Tree (t = 2), partindo da árvore vazia. Cada passo indica o caso que exemplifica; no conjunto, os passos cobrem todos os casos, exceto o 5a da remoção (visto na seção anterior). Nos desenhos, cada passo mostra a árvore após a operação (nas remoções, antes e depois). Ao fim do passo 9 temos a árvore base de 5 nós.
| Operação | Caso | Passo(s) |
|---|---|---|
| Inserção | Caso 1 — a folha tem espaço | 1, 2, 3, 5, 7, 9, 11, 12 |
| Inserção | Caso 2 — a folha estoura e o pai tem espaço (ou nasce a raiz) | 4, 6, 8 |
| Inserção | Caso 3 — a divisão se propaga | 10 |
| Inserção | Caso 4 — a chave já existe | 13 |
| Busca | Caso 1 — chave igual a um separador | 14 |
| Busca | Caso 2 — chave numa folha | 15 |
| Busca | Caso 3 — não encontrada | 16 |
| Busca | Por intervalo | 17 |
| Remoção | Caso 1 — a chave não existe | 18 |
| Remoção | Caso 2 — folha com folga | 19 |
| Remoção | Caso 3 — empréstimo do irmão | 20 |
| Remoção | Caso 4 — fusão com o pai folgado | 21 |
| Remoção | Caso 5b — fusão que se propaga até a raiz | 22 |
Verde = caminho percorrido (verde forte = encontrado); vermelho = chave a remover, ou nó com problema (estourado ou abaixo do mínimo, tracejado); amarelo = nó novo ou alterado; amarelo forte = chave inserida, copiada ou que mudou de lugar.
Passo 1 — Inserir 10 (Inserção, caso 1)
Árvore vazia: uma única folha, que é a raiz, com a chave 10.
Passo 2 — Inserir 20 (Inserção, caso 1)
20 cabe na folha-raiz.
Passo 3 — Inserir 30 (Inserção, caso 1)
30 cabe: a folha fica cheia (3 chaves = 2t−1).
Passo 4 — Inserir 40 (Inserção, caso 2)
40 deixaria a folha com 4 chaves: ela estoura e se divide em duas folhas de 2 chaves, [10, 20] e [30, 40]. O separador é uma cópia de 30 (a menor chave da folha da direita), colocada numa nova raiz; 30 continua na folha. A altura passa de 0 para 1.
Passo 5 — Inserir 50 (Inserção, caso 1)
50 ≥ 30: vai para a folha da direita, que tem espaço.
Passo 6 — Inserir 60 (Inserção, caso 2)
60 estoura [30, 40, 50, 60]: dividem-se em [30, 40] e [50, 60], e 50 é copiado para o pai, que vira [30, 50].
Passo 7 — Inserir 70 (Inserção, caso 1)
70 vai para a folha [50, 60], que tem espaço.
Passo 8 — Inserir 80 (Inserção, caso 2)
80 estoura [50, 60, 70, 80]: [50, 60] e [70, 80], com 70 copiado para o pai, que vira [30, 50, 70] (cheio). A árvore tem 5 nós.
Passo 9 — Inserir 90 (Inserção, caso 1)
90 cabe em [70, 80, 90]. Esta é a árvore base de 5 nós da seção 2.2.
Passo 10 — Inserir 85 (Inserção, caso 3)
85 estoura a folha [70, 80, 85, 90]: dividem-se em [70, 80] e [85, 90], e 85 é copiado para a raiz, que estoura ([30, 50, 70, 85]). Ao dividir um nó interno a chave do meio (50) sobe, sem cópia, e forma a nova raiz.
Passo 11 — Inserir 35 (Inserção, caso 1)
35 desce até a folha [30, 40], que tem espaço.
Passo 12 — Inserir 95 (Inserção, caso 1)
95 vai para a folha [85, 90], que tem espaço.
Passo 13 — Inserir 40 (Inserção, caso 4)
A chave 40 já existe na folha [30, 35, 40]: nada muda.
Passo 14 — Buscar 50 (Busca, caso 1)
50 é igual ao separador da raiz, mas separador não é dado: como 50 ≥ 50, a busca desce pelo filho da direita, depois pela folha [50, 60] e encontra a chave na folha.
Passo 15 — Buscar 80 (Busca, caso 2)
Raiz [50]: 80 ≥ 50 → [70, 85]: 70 ≤ 80 < 85 → folha [70, 80]: encontrada.
Passo 16 — Buscar 55 (Busca, caso 3)
55 chega à folha [50, 60], que não tem 55: não existe.
Passo 17 — Buscar o intervalo [35, 75] (Busca por intervalo)
Desce-se até a folha onde estaria 35 ([30, 35, 40]) e coletam-se 35 e 40. Seguindo o ponteiro, a folha [50, 60] dá 50 e 60; a próxima, [70, 80], dá 70, e 80 > 75 encerra. Não houve nenhuma volta aos nós internos.
Passo 18 — Remover 99 (Remoção, caso 1)
99 chega à folha [85, 90, 95] e não está lá: nada muda.
Passo 19 — Remover 90 (Remoção, caso 2)
90 está numa folha com 3 chaves. Sobram 2 (o mínimo): fim.
Passo 20 — Remover 20 (Remoção, caso 3)
Remover 20 deixa a folha [10] com 1 chave (mínimo 2). O irmão da direita, [30, 35, 40], tem folga e empresta 30; o separador do pai passa de 30 para 35 (a nova menor chave da folha da direita).
Passo 21 — Remover 70 (Remoção, caso 4)
Remover 70 deixa [80] abaixo do mínimo; os irmãos [50, 60] e [85, 95] estão no mínimo. Fundem-se as folhas [50, 60] e [80] em [50, 60, 80] e o separador 70 é descartado do pai, que vira [85] (ainda com o mínimo de 1).
Passo 22 — Remover 10 (Remoção, caso 5b)
Remover 10 deixa [30] abaixo do mínimo; o irmão [35, 40] está no mínimo. Fundem-se em [30, 35, 40] e o separador 35 é descartado, deixando o pai sem chaves. O irmão do pai, [85], também está no mínimo: fundem-se os dois nós internos, com o separador 50 descendo da raiz: [50, 85]. A raiz esvaziou, então seu único filho vira a raiz: a altura volta de 2 para 1.
2.7 Complexidade
n = número de chaves; t = grau mínimo; k = número de resultados de uma busca por intervalo. Como na B-Tree, o custo principal é medido em leituras de disco (um nó por leitura).
| Operação | Pior caso (disco) | Caso médio (disco) | Em uma frase |
|---|---|---|---|
| Busca exata | O(logt n) | O(logt n) | sempre desce até a folha: custo igual em todas as chaves |
| Inserção | O(logt n) | O(logt n) leituras, em geral 1 escrita | split raro; só em cascata quando os nós estão cheios |
| Remoção | O(logt n) | O(logt n) leituras, em geral 1 escrita | sem troca por sucessor; fusões em cascata são raras |
| Busca por intervalo | O(logt n + k) | O(logt n + k) | desce uma vez e percorre a lista de folhas |
| Varredura completa | O(n) | O(n) | só a lista de folhas, sem tocar nos nós internos |
Por que a busca custa sempre a mesma coisa?
Intuição: como os dados só ficam nas folhas e todas as folhas estão na mesma profundidade, toda busca percorre exatamente h + 1 nós, achando a chave ou não. Na B-Tree uma busca pode parar antes, num nó interno; na B+-Tree isso nunca acontece, mas em compensação os nós internos são mais densos (sem dados, cabem mais separadores por bloco), o que deixa a árvore mais larga e mais baixa: com blocos de 4KB, chaves de 4 bytes e ponteiros de 8 bytes, cada nó interno comporta cerca de 340 filhos, e 4 níveis alcançam bilhões de chaves. A altura segue h ≤ logt n (mesma dedução da B-Tree: cada nível multiplica o número de nós por pelo menos t).
Intervalo: o custo é a descida, O(logt n), mais a leitura das folhas que contêm os k resultados. Como cada folha guarda de t a 2t−1 chaves, são no máximo ⌈k/t⌉ + 1 folhas: no total, O(logt n + k/t) leituras (e O(k) comparações), contra O(k · logt n) da B-Tree.
Inserção e remoção: descem até uma folha e consertam no caminho de volta. Como na B-Tree, a divisão de uma folha deixa duas folhas pela metade, então a mesma folha só volta a estourar depois de várias inserções: em média, quase sempre basta escrever na folha. No pior caso (todos os nós cheios na inserção, todos no mínimo na remoção), a correção sobe por todos os h níveis.
Comparação resumida com a B-Tree e com a AVL (n = 109, t = 170):
| Operação | B-Tree | B+-Tree | AVL |
| Busca exata (leituras de disco) | ≈ 4 (pode ser menos) | ≈ 4 (sempre até a folha) | ≈ 30 |
| Inserção | O(logt n) leituras | O(logt n) leituras | O(log n) |
| Remoção | O(logt n) leituras | O(logt n) leituras | O(log n) |
| Busca por intervalo (k resultados) | O(k · logt n) | O(logt n + k) | O(k · log n) |
| Varredura sequencial completa | O(n) | O(n) — lista de folhas | O(n) |
Note que a busca por intervalo é onde a B+-Tree brilha: O(logt n + k) versus O(k · logt n) da B-Tree. Para k = 100.000 resultados, a diferença é absurda.
2.8 Estrutura e funções em C (TAD simples)
A diferença para a B-Tree aparece em dois pontos da struct: as chaves de um nó interno são só separadores (não guardam dado associado), e toda folha tem um ponteiro prox para a folha seguinte, formando a lista encadeada que liga todas as folhas da esquerda para a direita.
#define T 2 /* grau mínimo da árvore */ typedef struct NodoBMais { int n; /* número de chaves em uso */ int chaves[2*T - 1]; /* separadores (interno) ou chaves reais (folha) */ struct NodoBMais *filhos[2*T]; /* filhos (não usados se for folha) */ int folha; /* 1 se for folha, 0 caso contrário */ struct NodoBMais *prox; /* próxima folha, da esquerda p/ direita (só em folhas) */ } NodoBMais; NodoBMais *criarNodoBMais(int folha); /* cria um nó vazio (folha ou interno) */ NodoBMais *buscarBMais(NodoBMais *raiz, int chave); /* devolve a folha onde a chave está, ou NULL */ NodoBMais *inserirBMais(NodoBMais *raiz, int chave);/* insere a chave; devolve a (nova) raiz */ NodoBMais *removerBMais(NodoBMais *raiz, int chave);/* remove a chave; devolve a (nova) raiz */ void destruirBMais(NodoBMais *raiz); /* libera todos os nós */
main de demonstração:
int main(void) { NodoBMais *raiz = criarNodoBMais(1); /* raiz começa como folha vazia */ int chaves[] = {10, 20, 30, 40, 50, 60, 70, 80, 90}; for (int i = 0; i < 9; i++) raiz = inserirBMais(raiz, chaves[i]); printf("buscarBMais(70) = %s\n", buscarBMais(raiz, 70) ? "achou" : "não achou"); /* achou */ /* busca por intervalo: desce até a 1ª folha e segue os ponteiros prox */ NodoBMais *f = buscarBMais(raiz, 30); while (f != NULL) { /* imprimiria aqui as chaves de f, em ordem, até o fim do intervalo */ f = f->prox; } raiz = removerBMais(raiz, 60); destruirBMais(raiz); return 0; }
3. Aplicações em Sistemas Reais
3.1 Sistemas de arquivos
- ext4 (Linux): usa B-Trees para indexar blocos de dados de arquivos grandes e para o diretório HTree (uma variante de B-Tree hash).
- NTFS (Windows): o MFT (Master File Table) usa B+-Trees para indexar arquivos por nome.
- HFS+ / APFS (macOS): catálogo de arquivos implementado como B-Tree.
- Btrfs: um sistema de arquivos inteiramente construído sobre B-Trees copy-on-write.
3.2 Bancos de dados
- MySQL InnoDB: todos os índices (primário e secundários) são B+-Trees. A tabela em si é organizada como uma B+-Tree com os dados nas folhas (chamada de clustered index).
- PostgreSQL: o tipo de índice padrão é B+-Tree. É o que se cria com
CREATE INDEXsem especificar tipo. - SQLite: a estrutura de dados central é uma B-Tree (com variante B+ para índices).
- Oracle, SQL Server: também usam B+-Trees para índices.
3.3 Por que não AVL em banco de dados?
|
A AVL garante O(log n) operações, mas isso é enganoso no contexto de disco. Para n = 10⁹ registros: • AVL: altura ≈ 30 → 30 leituras de disco por busca. Cada leitura traz 4KB mas usa apenas ≈ 20 bytes (nó AVL). Aproveitamento: 0,5%. • B+-Tree com t=200: altura ≈ 4 → 4 leituras de disco por busca. Cada leitura traz um nó com ~400 chaves. Aproveitamento: 100%. O O(log n) da AVL assume que cada "passo" tem custo O(1). No disco, cada passo custa O(1 leitura de bloco) — mas a AVL usa <1% do bloco lido. A B-Tree usa 100%. Quando o custo dominante é o I/O de disco, o fator de ramificação t é crucial. |
3.4 Comparação B-Tree vs B+-Tree
| Característica | B-Tree | B+-Tree |
| Localização dos dados | Nós internos E folhas | Apenas nas folhas |
| Chaves em nós internos | Chaves reais com dados | Cópias para navegação |
| Densidade de nós internos | Menor (dados ocupam espaço) | Maior (só chaves de guia) |
| Busca por intervalo | O(k · logt n) | O(logt n + k) |
| Varredura sequencial | Percurso em-ordem da árvore | Scan da lista de folhas |
| Busca exata pode terminar cedo? | Sim (dado pode estar em nó interno) | Não (sempre chega à folha) |
| Aplicação típica | Sistemas de arquivos, índices simples | Bancos de dados, índices relacionais |
Resumo
- B-Trees e B+-Trees foram projetadas para o modelo de acesso a disco: cada nó = um bloco = uma leitura.
- O grau mínimo t define o fator de ramificação: nós têm entre t−1 e 2t−1 chaves. Com t grande, a altura é minúscula.
- A altura garantida O(logt n) implica O(logt n) leituras de disco por operação — tipicamente 3 a 5 para bilhões de registros.
- A B-Tree mantém todas as folhas na mesma profundidade via divisões (inserção) e empréstimos e fusões (remoção) — balanceamento perfeito.
- Cada operação tem poucos casos: inserção (folha com espaço, split, split em cascata), remoção (folha com folga, empréstimo, fusão, fusão em cascata) e busca (achar, não achar).
- A B+-Tree separa navegação (nós internos) de dados (folhas) e encadeia as folhas, tornando buscas por intervalo dramaticamente mais eficientes.
- MySQL InnoDB, PostgreSQL, ext4, NTFS e a maioria dos sistemas de armazenamento modernos usam B+-Trees como estrutura de índice central.
Exercícios
-
Uma B-Tree tem grau mínimo t=3. Qual o número mínimo e máximo de chaves por nó não-raiz? E de filhos?
-
Por que uma B-Tree garante que todas as folhas estão na mesma profundidade? Qual invariante assegura isso?
-
Insira as chaves 5, 15, 25, 35, 45 em ordem numa B-Tree vazia de grau t=2. Mostre a árvore após cada inserção, incluindo splits.
-
Qual é a altura máxima de uma B-Tree de grau t com n chaves? Expresse como fórmula e calcule para t=100 e n=10⁹.
-
Qual é a principal diferença entre B-Tree e B+-Tree em termos de onde os dados ficam armazenados? Qual suporta melhor buscas por intervalo, e por quê?
-
Na B+-Tree, por que as chaves aparecem tanto nos nós internos quanto nas folhas? Isso representa um problema de redundância?
-
Um banco de dados tem 50 milhões de registros. Um índice B+-Tree com t=200 é usado. Quantas leituras de disco uma busca exata demora aproximadamente? Compare com uma AVL balanceada.
-
Por que sistemas de arquivos e bancos de dados usam B-Trees em vez de AVL, mesmo ela garantindo O(log n)?
-
Na B-Tree de 5 nós desta aula (t=2, raiz [20, 40, 60], folhas [10], [30], [50] e [70, 80, 90]), remova, nesta ordem, as chaves 80, 60 e 10. Mostre a árvore após cada remoção e diga qual caso da seção 1.5 foi aplicado.
-
Na B+-Tree de 5 nós desta aula (raiz [30, 50, 70], folhas [10, 20], [30, 40], [50, 60] e [70, 80, 90]), insira a chave 85 e, em seguida, remova a chave 10. Mostre a árvore após cada operação, indicando os casos aplicados (seções 2.3 e 2.5).
Gabaritos
Sugestão de resposta — Exercício 1
Com t=3:
• Chaves mínimas: t−1 = 2.
• Chaves máximas: 2t−1 = 5.
• Filhos mínimos (nó interno): t = 3.
• Filhos máximos (nó interno): 2t = 6.
Obs.: a raiz pode ter apenas 1 chave (e 2 filhos), sem restrição de mínimo.
Sugestão de resposta — Exercício 2
A altura só cresce quando a raiz é dividida: ao inserir numa raiz cheia, ela é dividida e uma nova raiz é criada com a mediana. Ambos os filhos da nova raiz ficam na mesma profundidade. Da mesma forma, splits propagam para cima mas nunca adicionam folhas em profundidades diferentes — a divisão sempre cria dois nós no mesmo nível.
O invariante é: toda inserção e remoção percorre a árvore de cima para baixo, e qualquer reorganização (split ou merge) ocorre localmente em nós do mesmo nível. Assim, a propriedade "todas as folhas na mesma profundidade" é mantida após cada operação.
Sugestão de resposta — Exercício 3
t=2: nós têm entre 1 e 3 chaves; raiz pode ter 1 a 3.
Após inserir 5: [5]
Após inserir 15: [5, 15]
Após inserir 25: [5, 15, 25] (raiz com 3 chaves = 2t-1, ok mas cheia)
Após inserir 35:
35 entra em [5, 15, 25] e a raiz estoura: [5, 15, 25, 35] (4 chaves). Sobe a 2ª chave (15), que forma a nova raiz:
[15]
/ \
[5] [25]
O 35 fica na folha da direita:
[15]
/ \
[5] [25, 35]
Após inserir 45:
Raiz [15], 45 > 15 → filho [25, 35], 45 > 35 → inserir em [25,35] → [25,35,45] (cheio, mas é folha e cabe 3 chaves).
[15]
/ \
[5] [25, 35, 45]
Nota: [25,35,45] tem 3 = 2t−1 chaves — é o máximo permitido. Se inserirmos mais uma chave nesse filho, ele estoura e precisa ser dividido.
Sugestão de resposta — Exercício 4
A altura máxima ocorre quando todos os nós têm o número mínimo de chaves (t−1 por nó não-raiz, 1 na raiz). Nesse pior caso, contando a altura h da raiz (profundidade 0) até as folhas (profundidade h):
h ≤ logt((n+1)/2)
Isso vem do fato de que uma B-Tree de altura h tem no mínimo 1 + 2·(t−1)·(1 + t + t² + ... + t^(h−1)) = 1 + 2·(t^h − 1) = 2t^h − 1 chaves (a raiz tem 1; o nível 1 tem 2 nós; o nível 2 tem 2t; e assim por diante). Logo n ≥ 2t^h − 1, e resolvendo: h ≤ logt((n+1)/2).
Para t=100 e n=10⁹:
h ≤ log100((10⁹+1)/2) ≈ log100(5 · 10⁸) = (8 + log 5) / 2 ≈ 8,7 / 2 ≈ 4,35
Como h é inteiro, h ≤ 4: a árvore tem no máximo 5 níveis (raiz + 4) — no máximo 5 leituras de disco para qualquer busca num banco de dados de 1 bilhão de registros.
Sugestão de resposta — Exercício 5
B-Tree: dados podem ser armazenados em qualquer nó — interno ou folha. Uma busca pode terminar antes de chegar às folhas se a chave for encontrada num nó interno.
B+-Tree: dados estão exclusivamente nas folhas. Os nós internos contêm apenas cópias de chaves para guiar a navegação. As folhas são encadeadas em ordem.
A B+-Tree suporta melhor buscas por intervalo porque: após encontrar a primeira chave do intervalo numa folha (O(logt n)), basta percorrer os ponteiros da lista encadeada de folhas para coletar os k resultados (O(k)). Total: O(logt n + k). Na B-Tree, os k resultados podem estar em nós internos espalhados pela árvore, e cada um exige uma travessia separada — O(k · logt n).
Sugestão de resposta — Exercício 6
As chaves nos nós internos são cópias — separadores que guiam a navegação. Como os dados reais só existem nas folhas, a chave precisa aparecer na folha para ser acessível ao usuário. Mas para saber em qual filho procurar, os nós internos precisam de referências a essas mesmas chaves.
Isso não é um problema na prática, pelas seguintes razões:
- Os nós internos armazenam apenas a chave (por exemplo, um inteiro de 4 bytes), não o dado completo (que pode ser um registro de kilobytes). A redundância é mínima em tamanho.
- A separação navegação/dados é justamente o que torna a B+-Tree eficiente: nós internos mais compactos permitem maior fator de ramificação (mais chaves por bloco), reduzindo a altura da árvore.
- Conceitualmente, as chaves nos nós internos são apenas guias, não duplicatas dos registros.
Sugestão de resposta — Exercício 7
B+-Tree com t=200, n=50.000.000:
h = ⌈log200(50.000.000)⌉ = ⌈log(5×10⁷) / log(200)⌉ = ⌈7,699 / 2,301⌉ = ⌈3,35⌉ = 4 leituras de disco
AVL, n=50.000.000:
h ≈ 1,44 · log₂(50.000.000) ≈ 1,44 · 25,6 ≈ 37 leituras de disco
A B+-Tree é ~9× mais rápida em número de acessos ao disco. E cada acesso AVL desperdiça 99%+ do bloco lido, enquanto cada acesso B+ usa o bloco inteiro. Na prática, a diferença real de tempo pode ser 50× a 100× em favor da B+-Tree.
Sugestão de resposta — Exercício 8
O O(log n) da AVL esconde uma premissa implícita: cada "passo" (acesso a um nó) custa O(1). Isso é verdade na RAM, mas falso no disco.
No disco, o custo de um acesso é o mesmo independente de quantos bytes são lidos — um bloco inteiro (4KB a 64KB) é carregado. Uma AVL usa ~20 bytes de um bloco de 4KB: aproveitamento de 0,5%. Uma B+-Tree preenche o bloco com 400+ chaves: aproveitamento de ~100%.
Portanto, o modelo de custo correto para disco é: número de blocos lidos, não número de operações. Neste modelo:
- AVL: O(log₂ n) = O(log n) blocos lidos. Para n=10⁸: ≈ 27 blocos.
- B+-Tree (t=200): O(log₂₀₀ n) = O(log n / log 200) blocos lidos. Para n=10⁸: ≈ 3 blocos.
A B+-Tree vence em disco justamente porque foi projetada para o modelo de acesso a disco: 1 nó = 1 bloco = 1 acesso. A AVL foi projetada para RAM, onde a premissa de O(1) por acesso é válida.
Sugestão de resposta — Exercício 9
Árvore inicial: [20, 40, 60] com folhas [10] [30] [50] [70, 80, 90] Remover 80 (caso 2: a chave está numa folha com folga) [70, 80, 90] vira [70, 90] (2 chaves, mais que o mínimo). [20, 40, 60] com folhas [10] [30] [50] [70, 90] Remover 60 (caso 3: a chave está num nó interno) Predecessor: 50, numa folha no mínimo. Sucessor: 70, numa folha com folga. Troca 60 por 70 e remove 70 da folha, que vira [90]. [20, 40, 70] com folhas [10] [30] [50] [90] Remover 10 (caso 5: fusão, com o pai folgado) A folha [10] fica vazia; seu único irmão, [30], está no mínimo. Funde [ ] + 20 (desce da raiz) + [30] = [20, 30]. A raiz perde uma chave. [40, 70] com folhas [20, 30] [50] [90]
Sugestão de resposta — Exercício 10
Árvore inicial: raiz [30, 50, 70] com folhas [10, 20] [30, 40] [50, 60] [70, 80, 90]
Inserir 85 (inserção, caso 3: a divisão se propaga)
85 vai para [70, 80, 90], que estoura: [70, 80] e [85, 90]; 85 é copiado para a raiz.
A raiz [30, 50, 70, 85] estoura: 50 sobe e forma a nova raiz.
[50]
/ \
[30] [70, 85]
/ \ / | \
[10,20] [30,40] [50,60] [70,80] [85,90]
Remover 10 (remoção, caso 5a: a fusão se propaga e o pai empresta)
[10, 20] vira [20], abaixo do mínimo (2); o irmão [30, 40] está no mínimo.
Funde as folhas em [20, 30, 40] e descarta o separador 30; o nó [30] fica vazio.
O irmão [70, 85] tem folga: por rotação, 50 desce da raiz e 70 sobe para a raiz.
[70]
/ \
[50] [85]
/ \ / \
[20,30,40] [50,60] [70,80] [85,90]