← Todas as estruturas

B-Tree

Árvores · ver código-fonte no GitHub

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

Categoria: Trees

O problema

Um Binary Search Tree responde "encontre esta chave" em O(altura), mas cada nó guarda exatamente uma chave e tem no máximo dois filhos — então a altura cresce com log2(n) mesmo no melhor caso, e degenera para O(n) em ordem de inserção adversarial. Para uma árvore em memória, isso geralmente é aceitável. Deixa de ser aceitável no momento em que a árvore não cabe mais em memória: um índice de banco de dados real vive em disco, e cada nível da árvore atravessado durante a busca é, no pior caso, uma leitura de página de disco. Um índice de um milhão de linhas como árvore binária precisa de cerca de 20 níveis — 20 leituras de página em potencial — só para encontrar uma linha. A latência de disco (ou até de SSD) por leitura ofusca uma comparação em memória por ordens de grandeza, então o número de níveis, não o número de comparações, é o que realmente precisa ser minimizado.

A solução

Deixe cada nó guardar muitas chaves em vez de uma, e ter proporcionalmente muitos filhos em vez de dois. Uma árvore B de grau mínimo t empacota entre t - 1 e 2t - 1 chaves em cada nó que não seja raiz, com até 2t filhos — então, em vez de ramificar por 2 em cada nível, ela ramifica de t a 2t. Essa única mudança é o que colapsa a altura da árvore de O(log2 n) para O(log_t n): com t = 32, uma árvore que precisaria de ~20 níveis como árvore binária precisa de 3-4.

A inserção aqui usa a estratégia de "divisão preventiva no caminho para baixo": ao descer em direção à folha à qual uma nova chave pertence, qualquer nó cheio encontrado ao longo do caminho — incluindo a raiz — é dividido antes de a recursão entrar nele. Isso garante que o pai de um nó cheio prestes a ser dividido sempre tenha espaço para a chave mediana que a divisão promove para cima, então uma divisão nunca precisa "borbulhar de volta" depois. Isso também garante que toda folha permaneça exatamente na mesma profundidade o tempo todo, o que é o que torna "a altura da árvore" um único número bem definido, em vez de "a altura de qualquer ramo que aconteça de ser o mais profundo".

flowchart TD
    R["20 | 40"] --> C1["10"]
    R --> C2["25 | 30"]
    R --> C3["50 | 60 | 70"]
Operação Custo Por quê
get O(log_t n) a altura é O(log_t n); cada nível faz uma varredura O(t) pelas chaves daquele nó
insert O(log_t n) amortizado mesmo limite de altura; cada divisão preventiva ao longo do caminho custa O(t)
height() O(log_t n) percorre o único caminho mais à esquerda uma vez — toda folha está na mesma profundidade

Exemplo clássico

classic/BTree implementa insert, get e height() do zero, com um grau mínimo t configurável (parâmetro do construtor, padrão 3). A divisão é a parte difícil: splitChild quebra um nó cheio de 2t - 1 chaves em dois nós de t - 1 chaves e promove a chave/valor mediano para o pai, e insertNonFull reverifica a chave recém-promovida após cada divisão que dispara, já que essa chave pode acabar sendo a chave que está sendo inserida (uma sobrescrita, não uma chave nova). BTreeTest força divisões em múltiplos níveis com sequências de inserção de 200 chaves tanto crescentes quanto decrescentes (usando t = 2, o menor grau permitido, para tornar as divisões o mais frequentes possível), e inclui uma sequência deliberadamente construída que reinsere uma chave no exato momento em que ela é a mediana de um nó prestes a ser dividido preventivamente — o branch mais complicado de toda a classe.

Exemplo aplicado: simulação de um índice legado de contas bancárias

applied/AccountIndexSimulation indexa registros de contas por número de conta da mesma forma que um índice de RDBMS real faria durante uma modernização de mainframe para microsserviços: "encontrar a conta 4471203" precisa continuar rápido, seja a tabela contendo mil linhas ou cem milhões. É exatamente por isso que bancos de dados de produção indexam com uma árvore B (ou uma parente próxima) em vez de uma árvore binária — cada nó de árvore B é dimensionado para corresponder aproximadamente a uma página de disco, então um fator de ramificação alto significa diretamente menos páginas tocadas por busca, não apenas um expoente assintótico menor. AccountIndexSimulationTest indexa 100,000 contas e verifica que a altura resultante permanece em 4 ou menos, e separadamente confirma que um grau mínimo menor produz um índice mensuravelmente mais alto para a mesma quantidade de contas — a afirmação sobre o fator de ramificação, tornada concreta.

Benchmark

./gradlew :trees:b-tree:jmh

Execução real (JMH 1.37, JDK 26.0.2, 2 iterações de aquecimento + 3 de medição, 1 fork). Mesmo conjunto de chaves embaralhadas (semente Random(42)) inserido tanto em uma árvore B (t = 32) quanto no BinarySearchTree deste repositório — o @Setup do benchmark imprime a altura real, recém-medida, de cada estrutura logo após construí-la:

Altura (níveis para descer) size=1,000 size=10,000 size=100,000
B-tree (t=32) 2 3 3
BinarySearchTree (ordem de inserção aleatória) 27 30 44

Esse é o ponto central deste módulo: as mesmas 100,000 chaves precisam de 3 níveis em uma árvore B com t=32 e de 44 em uma árvore binária desbalanceada — uma redução de aproximadamente 15x no número de visitas a nós/páginas que uma busca precisa fazer, que aumenta (e não apenas proporcionalmente) conforme a quantidade de chaves cresce.

O custo de get conta uma história diferente, igualmente honesta:

Custo de get size=1,000 size=10,000 size=100,000
B-tree (t=32) 37.7 ns 115.0 ns 167.3 ns
BinarySearchTree (ordem de inserção aleatória) 35.3 ns 27.2 ns 48.2 ns

Contraintuitivamente, o get da árvore B não é mais rápido aqui, apesar de precisar de bem menos níveis — nesses tamanhos, ele é ligeiramente mais lento. O motivo é a outra metade da troca envolvendo altura: cada nó de árvore B guarda até 2t - 1 = 63 chaves, e get varre essa lista linearmente em cada nível, então o total de comparações acaba na mesma faixa de percorrer uma árvore binária mais alta uma comparação de cada vez. O ganho de altura só se paga quando cada visita a um nó tem um custo real associado a ela — uma leitura de página de disco, uma viagem de ida e volta pela rede, um cache miss em dados grandes demais para caber na RAM — que é precisamente o cenário que AccountIndexSimulation modela, e que um benchmark JMH simples em memória não consegue: uma visita a nó em memória é barata independentemente do fator de ramificação, então este benchmark mostra corretamente que a troca tem dois lados, não apenas o favorável com que este módulo começa.

Quando não usar

Cobertura de testes

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

./gradlew :trees:b-tree:jacocoTestReport

Relatório em trees/b-tree/build/reports/jacoco/test/html/index.html.

Testes unitários

src/test/java/com/datastructures/trees/btree/classic/BTreeTest.java
package com.datastructures.trees.btree.classic;

import org.junit.jupiter.api.Test;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;

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

class BTreeTest {

    @Test
    void startsEmpty() {
        BTree<Integer, String> tree = new BTree<>();

        assertThat(tree.isEmpty()).isTrue();
        assertThat(tree.size()).isZero();
        assertThat(tree.height()).isZero();
    }

    @Test
    void constructorRejectsMinDegreeBelowTwo() {
        assertThatThrownBy(() -> new BTree<Integer, String>(1))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("minDegree");
    }

    @Test
    void insertThenGetReturnsTheStoredValue() {
        BTree<Integer, String> tree = new BTree<>(2);

        tree.insert(10, "ten");

        assertThat(tree.get(10)).isEqualTo("ten");
        assertThat(tree.contains(10)).isTrue();
        assertThat(tree.isEmpty()).isFalse();
        assertThat(tree.size()).isEqualTo(1);
        assertThat(tree.height()).isEqualTo(1);
    }

    @Test
    void getOnAMissingKeyReturnsNull() {
        BTree<Integer, String> tree = new BTree<>(2);
        tree.insert(10, "ten");

        assertThat(tree.get(99)).isNull();
        assertThat(tree.contains(99)).isFalse();
    }

    @Test
    void getOnAnEmptyTreeReturnsNull() {
        BTree<Integer, String> tree = new BTree<>(2);

        assertThat(tree.get(1)).isNull();
    }

    @Test
    void insertingAnExistingLeafKeyOverwritesItsValueWithoutGrowingSize() {
        BTree<Integer, String> tree = new BTree<>(2);
        tree.insert(10, "original");

        tree.insert(10, "replaced");

        assertThat(tree.get(10)).isEqualTo("replaced");
        assertThat(tree.size()).isEqualTo(1);
    }

    @Test
    void fillingANodeToCapacityDoesNotYetSplitIt() {
        // t = 2: a node holds up to 2t - 1 = 3 keys before it's full.
        BTree<Integer, String> tree = new BTree<>(2);
        tree.insert(10, "10");
        tree.insert(20, "20");
        tree.insert(30, "30");

        assertThat(tree.height()).isEqualTo(1);
        assertThat(tree.size()).isEqualTo(3);
        assertThat(tree.get(10)).isEqualTo("10");
        assertThat(tree.get(20)).isEqualTo("20");
        assertThat(tree.get(30)).isEqualTo("30");
    }

    @Test
    void insertingIntoAFullRootSplitsItAndGrowsHeight() {
        // t = 2: the 4th insert finds the root full (3 keys) and must preemptively split it.
        BTree<Integer, String> tree = new BTree<>(2);
        tree.insert(10, "10");
        tree.insert(20, "20");
        tree.insert(30, "30");
        tree.insert(40, "40");

        assertThat(tree.height()).isEqualTo(2);
        assertThat(tree.size()).isEqualTo(4);
        for (int key : new int[] {10, 20, 30, 40}) {
            assertThat(tree.get(key)).isEqualTo(String.valueOf(key));
        }
    }

    @Test
    void ascendingInsertionOrderForcesRepeatedSplitsAtMultipleLevels() {
        // t = 2 keeps nodes tiny (max 3 keys), so 200 ascending keys forces splits at several
        // levels, not just at the root.
        BTree<Integer, Integer> tree = new BTree<>(2);
        int count = 200;
        for (int key = 0; key < count; key++) {
            tree.insert(key, key * 10);
        }

        assertThat(tree.size()).isEqualTo(count);
        // A t=2 B-tree of 200 keys is nowhere near the O(n) degenerate height an unbalanced BST
        // would reach for the same sorted insertion order (200) - branching keeps it shallow.
        assertThat(tree.height()).isLessThan(15);
        for (int key = 0; key < count; key++) {
            assertThat(tree.get(key)).isEqualTo(key * 10);
        }
    }

    @Test
    void descendingInsertionOrderAlsoForcesRepeatedSplitsAtMultipleLevels() {
        BTree<Integer, Integer> tree = new BTree<>(2);
        int count = 200;
        for (int key = count - 1; key >= 0; key--) {
            tree.insert(key, key * 10);
        }

        assertThat(tree.size()).isEqualTo(count);
        assertThat(tree.height()).isLessThan(15);
        for (int key = 0; key < count; key++) {
            assertThat(tree.get(key)).isEqualTo(key * 10);
        }
    }

    @Test
    void randomInsertionOrderWithHigherMinDegreeStaysVeryShallow() {
        BTree<Integer, Integer> tree = new BTree<>(16);
        List<Integer> keys = new ArrayList<>();
        for (int i = 0; i < 10_000; i++) {
            keys.add(i);
        }
        Collections.shuffle(keys, new Random(7));
        for (int key : keys) {
            tree.insert(key, key);
        }

        assertThat(tree.size()).isEqualTo(10_000);
        assertThat(tree.height()).isLessThanOrEqualTo(4);
        for (int key : keys) {
            assertThat(tree.get(key)).isEqualTo(key);
        }
    }

    @Test
    void reinsertingAnExistingKeyThatIsCurrentlyTheMedianOfAFullNodeOverwritesItsValue() {
        // t = 2 (max 3 keys/node). Carefully built sequence so that, by the time 33 is
        // reinserted, it sits as the *middle* key of a full node reached only after a
        // preemptive split fires during the descent for this exact insert - this is what
        // exercises the "the split's promoted key equals the key we're inserting" branch.
        BTree<Integer, String> tree = new BTree<>(2);
        int[] buildSequence = {10, 20, 30, 40, 50, 60, 25, 28, 35, 33};
        for (int key : buildSequence) {
            tree.insert(key, "v" + key);
        }

        tree.insert(33, "v33-updated");

        assertThat(tree.get(33)).isEqualTo("v33-updated");
        assertThat(tree.size()).isEqualTo(buildSequence.length);
        // Every other key from the build sequence must have survived untouched.
        for (int key : buildSequence) {
            if (key != 33) {
                assertThat(tree.get(key)).isEqualTo("v" + key);
            }
        }
    }

    @Test
    void reinsertingAnExistingInternalKeyOverwritesItsValueWithoutDescending() {
        // Build a tree with t = 2, forcing at least one internal (non-leaf) key, then reinsert
        // that exact internal key - covers the equality check firing at a non-leaf node.
        BTree<Integer, String> tree = new BTree<>(2);
        tree.insert(10, "10");
        tree.insert(20, "20");
        tree.insert(30, "30");
        tree.insert(40, "40"); // splits the root; 20 becomes the new root's only key.

        tree.insert(20, "20-updated");

        assertThat(tree.get(20)).isEqualTo("20-updated");
        assertThat(tree.size()).isEqualTo(4);
    }

    @Test
    void heightGrowsAsMoreKeysAreInsertedWithASmallBranchingFactor() {
        BTree<Integer, Integer> tree = new BTree<>(2);
        assertThat(tree.height()).isZero();

        tree.insert(1, 1);
        assertThat(tree.height()).isEqualTo(1);

        for (int key = 2; key <= 3; key++) {
            tree.insert(key, key);
        }
        assertThat(tree.height()).isEqualTo(1);

        tree.insert(4, 4);
        assertThat(tree.height()).isEqualTo(2);
    }
}
src/test/java/com/datastructures/trees/btree/applied/AccountIndexSimulationTest.java
package com.datastructures.trees.btree.applied;

import org.junit.jupiter.api.Test;

import java.math.BigDecimal;

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

class AccountIndexSimulationTest {

    @Test
    void startsEmpty() {
        AccountIndexSimulation index = new AccountIndexSimulation();

        assertThat(index.size()).isZero();
        assertThat(index.height()).isZero();
    }

    @Test
    void indexedAccountIsFoundByAccountNumber() {
        AccountIndexSimulation index = new AccountIndexSimulation();
        AccountRecord record = new AccountRecord(1001L, "Leonardo Gomes", BigDecimal.valueOf(5000));

        index.index(record);

        assertThat(index.lookup(1001L)).isEqualTo(record);
        assertThat(index.size()).isEqualTo(1);
    }

    @Test
    void lookupOnAnUnindexedAccountNumberReturnsNull() {
        AccountIndexSimulation index = new AccountIndexSimulation();
        index.index(new AccountRecord(1001L, "Leonardo Gomes", BigDecimal.valueOf(5000)));

        assertThat(index.lookup(9999L)).isNull();
    }

    @Test
    void indexingManyAccountsWithAHighBranchingFactorStaysVeryShallow() {
        AccountIndexSimulation index = new AccountIndexSimulation(); // default minDegree = 32

        for (long accountNumber = 1; accountNumber <= 100_000; accountNumber++) {
            index.index(new AccountRecord(accountNumber, "holder-" + accountNumber, BigDecimal.ZERO));
        }

        assertThat(index.size()).isEqualTo(100_000);
        // log_32(100,000) ~= 3.4, so a handful of levels comfortably covers 100k accounts -
        // this is the concrete "far fewer disk-page reads" claim from this class's Javadoc.
        assertThat(index.height()).isLessThanOrEqualTo(4);
        assertThat(index.lookup(1L).holderName()).isEqualTo("holder-1");
        assertThat(index.lookup(100_000L).holderName()).isEqualTo("holder-100000");
    }

    @Test
    void aLowerMinDegreeProducesATallerIndexForTheSameAccountCount() {
        AccountIndexSimulation wideIndex = new AccountIndexSimulation(32);
        AccountIndexSimulation narrowIndex = new AccountIndexSimulation(2);

        for (long accountNumber = 1; accountNumber <= 1_000; accountNumber++) {
            AccountRecord record = new AccountRecord(accountNumber, "holder-" + accountNumber, BigDecimal.ZERO);
            wideIndex.index(record);
            narrowIndex.index(record);
        }

        assertThat(narrowIndex.height()).isGreaterThan(wideIndex.height());
    }
}

Ver relatório completo de cobertura JaCoCo →