← Todas as estruturas

Binary Search Tree

Árvores · ver código-fonte no GitHub

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

Categoria: Trees

O problema

Um array ordenado oferece busca O(log n) via busca binária, mas inserir no meio custa O(n) para deslocar tudo que vem depois. Uma lista encadeada oferece inserção O(1), mas busca O(n). Nenhum dos dois oferece busca rápida e inserção rápida ao mesmo tempo — e nenhum consegue responder "qual a chave mais próxima de X" sem uma varredura.

A solução

Mantenha a chave de cada nó maior do que tudo em sua subárvore esquerda e menor do que tudo em sua subárvore direita. Esse único invariante é o que permite que buscas, inserções e consultas de "chave mais próxima" descartem metade da árvore restante a cada passo, da mesma forma que a busca binária faz — exceto que é a própria estrutura que está ordenada, não um array subjacente, então a inserção não precisa deslocar nada.

flowchart TD
    N50(("50")) --> N20(("20"))
    N50 --> N80(("80"))
    N20 --> N10(("10"))
    N20 --> N30(("30"))
    N80 --> N70(("70"))
    N80 --> N90(("90"))

Nada aqui se rebalanceia. Essa é a pegadinha: a ordem de inserção controla o formato da árvore. Uma ordem de inserção aleatória tende a uma altura de aproximadamente O(log n). Uma ordem de inserção ordenada (ou em ordem reversa) degenera a árvore em uma cadeia reta — altura O(n), buscas O(n), nada melhor do que uma lista encadeada. O benchmark abaixo mede exatamente essa diferença; um futuro módulo AVL/Red-Black neste repositório existe justamente para eliminá-la, rebalanceando a cada inserção.

Operação Média (ordem de inserção aleatória) Pior caso (ordem de inserção ordenada)
get / insert / delete O(log n) O(n)
floorEntry (chave mais próxima <= X) O(log n) O(n)
inOrderKeys (percurso ordenado) O(n) O(n)

Exemplo clássico

classic/BinarySearchTree implementa insert, get, delete, floorEntry e o percurso em ordem (in-order) do zero. O delete trata os três casos clássicos — folha, um filho, dois filhos (encaixando o sucessor em ordem, a menor chave da subárvore direita) — sem deixar a propriedade da BST quebrada. BinarySearchTreeTest cobre os três casos de delete, além do caso degenerado diretamente: inserir 100 chaves em ordem crescente e verificar que a altura resultante é exatamente 100.

Exemplo aplicado: consulta de faixa de limite de transação do BACEN

applied/TransactionLimitTierIndex resolve qual faixa de limite de transação PIX definida pelo BACEN se aplica a um determinado valor — as faixas são definidas por limiar ("a partir de R$1.000 aplica-se até que um limiar maior seja ultrapassado"), então responder "qual faixa cobre R$1.347,50?" exige uma busca ordenada por piso (floor), não uma busca por correspondência exata. Essa é a operação que uma hash table estruturalmente não consegue oferecer melhor do que uma varredura completa; uma BST responde isso em O(altura) por construção. TransactionLimitTierIndexTest cobre um valor exatamente em uma fronteira, um valor entre duas faixas e um valor abaixo de todos os limiares cadastrados.

Benchmark

./gradlew :trees:binary-search-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, mesma operação de busca — a única variável é se a árvore foi construída a partir de uma ordem de inserção embaralhada ou ordenada.

Custo de get size=100 size=1,000 size=10,000
ordem de inserção aleatória 18.9 ns 38.7 ns 32.3 ns
ordem de inserção ordenada (degenerada) 115.7 ns 1,079.1 ns 22,575.8 ns

O custo de busca da árvore com ordem aleatória permanece praticamente estável ao longo de um aumento de 100x no tamanho — o formato que O(log n) prevê. Já o custo da árvore com ordem ordenada cresce quase linearmente com o tamanho — passar de 1,000 para 10,000 chaves (10x mais dados) torna as buscas ~21x mais lentas, consistente com a árvore ter degenerado em uma cadeia de 10,000 nós. Mesmo código, mesmos dados, apenas a ordem de inserção mudou — e é exatamente por isso que nada aqui se rebalanceia sozinho, e por que isso importa.

Quando não usar

Cobertura de testes

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

./gradlew :trees:binary-search-tree:jacocoTestReport

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

Testes unitários

src/test/java/com/datastructures/trees/binarysearchtree/classic/BinarySearchTreeTest.java
package com.datastructures.trees.binarysearchtree.classic;

import org.junit.jupiter.api.Test;

import java.util.Map;

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

class BinarySearchTreeTest {

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

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

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

        tree.insert(50, "root");

        assertThat(tree.get(50)).isEqualTo("root");
        assertThat(tree.contains(50)).isTrue();
        assertThat(tree.isEmpty()).isFalse();
    }

    @Test
    void getOnAMissingKeyReturnsNull() {
        BinarySearchTree<Integer, String> tree = new BinarySearchTree<>();
        tree.insert(50, "root");

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

    @Test
    void insertingAnExistingKeyOverwritesItsValue() {
        BinarySearchTree<Integer, String> tree = new BinarySearchTree<>();
        tree.insert(50, "original");

        tree.insert(50, "replaced");

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

    @Test
    void inOrderKeysAreAlwaysSortedRegardlessOfInsertionOrder() {
        BinarySearchTree<Integer, String> tree = new BinarySearchTree<>();
        int[] insertionOrder = {50, 20, 80, 10, 30, 70, 90};
        for (int key : insertionOrder) {
            tree.insert(key, "v" + key);
        }

        assertThat(tree.inOrderKeys()).containsExactly(10, 20, 30, 50, 70, 80, 90);
    }

    @Test
    void deletingALeafRemovesItAndNothingElse() {
        BinarySearchTree<Integer, String> tree = new BinarySearchTree<>();
        tree.insert(50, "50");
        tree.insert(20, "20");
        tree.insert(80, "80");

        tree.delete(20);

        assertThat(tree.contains(20)).isFalse();
        assertThat(tree.inOrderKeys()).containsExactly(50, 80);
        assertThat(tree.size()).isEqualTo(2);
    }

    @Test
    void deletingANodeWithOneChildSplicesTheChildIntoItsPlace() {
        BinarySearchTree<Integer, String> tree = new BinarySearchTree<>();
        tree.insert(50, "50");
        tree.insert(20, "20");
        tree.insert(10, "10");

        tree.delete(20);

        assertThat(tree.contains(20)).isFalse();
        assertThat(tree.inOrderKeys()).containsExactly(10, 50);
        assertThat(tree.get(10)).isEqualTo("10");
        assertThat(tree.size()).isEqualTo(2);
    }

    @Test
    void deletingANodeWithTwoChildrenSplicesInTheInOrderSuccessor() {
        BinarySearchTree<Integer, String> tree = new BinarySearchTree<>();
        int[] insertionOrder = {50, 20, 80, 10, 30, 70, 90};
        for (int key : insertionOrder) {
            tree.insert(key, "v" + key);
        }

        tree.delete(50);

        assertThat(tree.contains(50)).isFalse();
        assertThat(tree.inOrderKeys()).containsExactly(10, 20, 30, 70, 80, 90);
        assertThat(tree.size()).isEqualTo(6);
        // The successor (70) must have been spliced up, not just copied and left duplicated.
        assertThat(tree.get(70)).isEqualTo("v70");
    }

    @Test
    void deletingAKeyGreaterThanTheRootDescendsRightBeforeMatching() {
        BinarySearchTree<Integer, String> tree = new BinarySearchTree<>();
        int[] insertionOrder = {50, 20, 80, 10, 30, 70, 90};
        for (int key : insertionOrder) {
            tree.insert(key, "v" + key);
        }

        tree.delete(90);

        assertThat(tree.contains(90)).isFalse();
        assertThat(tree.inOrderKeys()).containsExactly(10, 20, 30, 50, 70, 80);
        assertThat(tree.size()).isEqualTo(6);
    }

    @Test
    void deletingFromAnEmptyTreeIsANoOp() {
        BinarySearchTree<Integer, String> tree = new BinarySearchTree<>();

        tree.delete(1);

        assertThat(tree.isEmpty()).isTrue();
    }

    @Test
    void floorEntryReturnsTheExactMatchWhenPresent() {
        BinarySearchTree<Integer, String> tree = new BinarySearchTree<>();
        tree.insert(100, "tier-100");
        tree.insert(500, "tier-500");

        Map.Entry<Integer, String> floor = tree.floorEntry(500);

        assertThat(floor.getKey()).isEqualTo(500);
        assertThat(floor.getValue()).isEqualTo("tier-500");
    }

    @Test
    void floorEntryReturnsTheLargestKeyNotGreaterThanTheQuery() {
        BinarySearchTree<Integer, String> tree = new BinarySearchTree<>();
        tree.insert(100, "tier-100");
        tree.insert(500, "tier-500");
        tree.insert(1000, "tier-1000");

        Map.Entry<Integer, String> floor = tree.floorEntry(750);

        assertThat(floor.getKey()).isEqualTo(500);
    }

    @Test
    void floorEntryReturnsNullWhenTheQueryIsBelowEveryStoredKey() {
        BinarySearchTree<Integer, String> tree = new BinarySearchTree<>();
        tree.insert(100, "tier-100");

        assertThat(tree.floorEntry(50)).isNull();
    }

    @Test
    void sortedInsertionOrderDegeneratesHeightToTheNumberOfNodes() {
        BinarySearchTree<Integer, String> tree = new BinarySearchTree<>();
        for (int key = 0; key < 100; key++) {
            tree.insert(key, "v" + key);
        }

        assertThat(tree.height()).isEqualTo(100);
    }

    @Test
    void randomishInsertionOrderStaysWellBelowTheDegenerateHeight() {
        BinarySearchTree<Integer, String> tree = new BinarySearchTree<>();
        int[] balancedOrder = {50, 25, 75, 12, 37, 62, 87, 6, 18, 31, 43, 56, 68, 81, 93};
        for (int key : balancedOrder) {
            tree.insert(key, "v" + key);
        }

        assertThat(tree.height()).isEqualTo(4);
    }
}
src/test/java/com/datastructures/trees/binarysearchtree/applied/TransactionLimitTierIndexTest.java
package com.datastructures.trees.binarysearchtree.applied;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.math.BigDecimal;
import java.util.Optional;

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

class TransactionLimitTierIndexTest {

    private final TransactionLimitTierIndex index = new TransactionLimitTierIndex();

    @BeforeEach
    void registerBacenTiers() {
        index.registerTier(new TransactionLimitTier(BigDecimal.ZERO, "daytime-standard", BigDecimal.ZERO));
        index.registerTier(new TransactionLimitTier(BigDecimal.valueOf(1000), "daytime-elevated", BigDecimal.valueOf(24)));
        index.registerTier(new TransactionLimitTier(BigDecimal.valueOf(10000), "daytime-high-value", BigDecimal.valueOf(72)));
    }

    @Test
    void amountExactlyOnABoundaryResolvesToThatTier() {
        Optional<TransactionLimitTier> tier = index.tierFor(BigDecimal.valueOf(1000));

        assertThat(tier).isPresent();
        assertThat(tier.get().tierName()).isEqualTo("daytime-elevated");
    }

    @Test
    void amountBetweenTwoBoundariesResolvesToTheLowerTier() {
        Optional<TransactionLimitTier> tier = index.tierFor(BigDecimal.valueOf(5000));

        assertThat(tier).isPresent();
        assertThat(tier.get().tierName()).isEqualTo("daytime-elevated");
    }

    @Test
    void amountAboveTheHighestBoundaryResolvesToTheHighestTier() {
        Optional<TransactionLimitTier> tier = index.tierFor(BigDecimal.valueOf(50000));

        assertThat(tier).isPresent();
        assertThat(tier.get().tierName()).isEqualTo("daytime-high-value");
    }

    @Test
    void amountBelowEveryRegisteredThresholdIsAbsentWhenNoZeroFloorTierExists() {
        TransactionLimitTierIndex indexWithoutZeroFloor = new TransactionLimitTierIndex();
        indexWithoutZeroFloor.registerTier(new TransactionLimitTier(BigDecimal.valueOf(1000), "daytime-elevated", BigDecimal.valueOf(24)));

        Optional<TransactionLimitTier> tier = indexWithoutZeroFloor.tierFor(BigDecimal.valueOf(500));

        assertThat(tier).isEmpty();
    }
}

Ver relatório completo de cobertura JaCoCo →