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
- Conjuntos de dados pequenos, inteiramente em memória, sem custo de disco ou rede por acesso a nó: como o benchmark de
getacima mostra, a varredura linear por nó de uma árvore B pode torná-la mais lenta que um Binary Search Tree simples quando não há custo de leitura de página para amortizar. - Esta implementação só suporta
inserteget— semdelete. A remoção real em árvore B (pegar emprestado de nós irmãos ou mesclar com eles para manter cada nó emt - 1chaves ou mais) é uma das operações mais intrincadas de toda essa família de estruturas, e está fora do escopo aqui. - Precisa de consultas de intervalo ordenadas em uma estrutura já em memória, sem o enquadramento de página de disco? Um Binary Search Tree oferece o mesmo acesso ordenado no formato
O(log n)com uma implementação mais simples.
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());
}
}