← Todas las estructuras

Binary Search Tree

Árboles · ver código fuente en GitHub

Leer en: English · Português · Español

Categoría: Trees

El problema

Un array ordenado ofrece búsqueda O(log n) mediante búsqueda binaria, pero insertar en el medio cuesta O(n) porque hay que desplazar todo lo que viene después. Una lista enlazada ofrece inserción O(1), pero búsqueda O(n). Ninguna de las dos ofrece búsqueda rápida e inserción rápida al mismo tiempo — y ninguna puede responder "cuál es la clave más cercana a X" sin un recorrido completo.

La solución

Mantén la clave de cada nodo mayor que todo lo que hay en su subárbol izquierdo y menor que todo lo que hay en su subárbol derecho. Ese único invariante es lo que permite que las búsquedas, inserciones y consultas de "clave más cercana" descarten la mitad del árbol restante en cada paso, de la misma manera que lo hace la búsqueda binaria — salvo que aquí es la propia estructura la que está ordenada, no un array subyacente, así que la inserción no necesita desplazar nada.

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

Nada aquí se rebalancea. Esa es la trampa: el orden de inserción controla la forma del árbol. Un orden de inserción aleatorio tiende a una altura de aproximadamente O(log n). Un orden de inserción ordenado (o en orden inverso) degenera el árbol en una cadena recta — altura O(n), búsquedas O(n), nada mejor que una lista enlazada. El benchmark de abajo mide exactamente esa brecha; un futuro módulo AVL/Red-Black en este repositorio existe específicamente para cerrarla, rebalanceando en cada inserción.

Operación Promedio (orden de inserción aleatorio) Peor caso (orden de inserción ordenado)
get / insert / delete O(log n) O(n)
floorEntry (clave más cercana <= X) O(log n) O(n)
inOrderKeys (recorrido ordenado) O(n) O(n)

Ejemplo clásico

classic/BinarySearchTree implementa insert, get, delete, floorEntry y el recorrido en orden (in-order) desde cero. El delete maneja los tres casos clásicos — hoja, un hijo, dos hijos (empalmando el sucesor en orden, la clave más pequeña del subárbol derecho) — sin dejar rota la propiedad del BST. BinarySearchTreeTest cubre los tres casos de delete, más el caso degenerado directamente: insertar 100 claves en orden ascendente y verificar que la altura resultante sea exactamente 100.

Ejemplo aplicado: búsqueda de nivel de límite de transacción del BACEN

applied/TransactionLimitTierIndex resuelve qué nivel de límite de transacción PIX definido por el BACEN se aplica a un monto dado — los niveles se definen por umbral ("desde R$1.000 se aplica hasta que se cruza un umbral mayor"), así que responder "¿qué nivel cubre R$1.347,50?" requiere una búsqueda ordenada de piso (floor), no una de coincidencia exacta. Esta es la operación que una hash table estructuralmente no puede ofrecer mejor que un recorrido completo; un BST la resuelve en O(altura) por construcción. TransactionLimitTierIndexTest cubre un monto exactamente en un límite, un monto entre dos niveles y un monto por debajo de todos los umbrales registrados.

Benchmark

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

Ejecución real (JMH 1.37, JDK 26.0.2, 2 iteraciones de calentamiento + 3 de medición, 1 fork). Mismo conjunto de claves, misma operación de búsqueda — la única variable es si el árbol se construyó a partir de un orden de inserción mezclado (shuffled) u ordenado.

Costo de get size=100 size=1,000 size=10,000
orden de inserción aleatorio 18.9 ns 38.7 ns 32.3 ns
orden de inserción ordenado (degenerado) 115.7 ns 1,079.1 ns 22,575.8 ns

El costo de búsqueda del árbol con orden aleatorio se mantiene prácticamente plano a lo largo de un aumento de 100x en el tamaño — la forma que predice O(log n). El costo del árbol con orden ordenado, en cambio, crece casi linealmente con el tamaño — pasar de 1,000 a 10,000 claves (10x más datos) hace que las búsquedas sean ~21x más lentas, consistente con que el árbol se haya degenerado en una cadena de 10,000 nodos. Mismo código, mismos datos, solo cambió el orden de inserción — y por eso, precisamente, nada aquí se rebalancea solo, y por eso importa.

Cuándo no usarlo

Cobertura de pruebas

100% de cobertura de instrucciones, 100% de cobertura de ramas (JaCoCo). Reprodúcelo tú mismo:

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

Reporte en trees/binary-search-tree/build/reports/jacoco/test/html/index.html.

Pruebas unitarias

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 informe completo de cobertura JaCoCo →