← Todas las estructuras

AVL Tree

Árboles · ver código fuente en GitHub

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

Categoría: Trees

El problema

El Binary Search Tree ya demostró el problema aquí: la altura de un BST simple depende enteramente del orden de inserción. Un orden aleatorio tiende a O(log n); un orden ordenado (o adversarial) lo degenera en una cadena recta, altura O(n), no mejor que una lista enlazada. Quien lo invoca no siempre puede controlar el orden de inserción — y no debería tener que hacerlo solo para mantener las búsquedas rápidas.

La solución

Después de cada inserción, sube de vuelta hacia la raíz restaurando un invariante en cada nodo: |height(left) - height(right)| <= 1. La inserción siempre hace crecer un solo subárbol en exactamente un nivel, así que un nodo solo puede desbalancearse en exactamente 2 — lo que significa que una sola rotación (uno de cuatro casos: left-left, right-right, left-right, right-left) siempre es suficiente para corregirlo antes de continuar subiendo. Esa única garantía es lo que hace que la altura sea demostrablemente O(log n) sin importar el orden de inserción — ordenado, ordenado en reversa, adversarial, no importa.

flowchart TD
    subgraph "Before: right-heavy at 10"
        A1["10"] --> A2["null"]
        A1 --> A3["20"]
        A3 --> A4["null"]
        A3 --> A5["30"]
    end
    subgraph "After: rotateLeft(10)"
        B1["20"] --> B2["10"]
        B1 --> B3["30"]
    end
Operación Costo Por qué
insert O(log n) garantizado la altura está demostrablemente acotada; cada inserción hace como máximo una rotación
get / contains O(log n) garantizado mismo límite de altura, descenso al estilo BST simple
height() O(1) almacenado en caché por nodo, actualizado durante las rotaciones en lugar de recalculado

Ejemplo clásico

classic/AvlTree implementa insert, get, contains y height() desde cero, con los cuatro casos de rotación. La eliminación queda deliberadamente fuera de alcance — la eliminación real en AVL necesita las mismas cuatro rotaciones más el control de empalme de dos hijos que el Binary Search Tree ya cubre, sin ningún valor didáctico nuevo. AvlTreeTest traza a mano una secuencia de inserción dedicada para cada uno de los cuatro casos de rotación y — el punto real del módulo — inserta exactamente la misma secuencia ordenada de 100 claves que degenera la altura del BinarySearchTree simple a 100, y verifica que la altura del árbol AVL se mantiene en 7.

Ejemplo aplicado: índice de reglas de una plataforma de detección de fraude

applied/FraudRuleIndex indexa reglas de detección de fraude por el umbral de score de riesgo en el que cada una se dispara. Los equipos de compliance tienden a registrar reglas en orden ascendente de umbral a medida que se lanzan nuevos niveles ("agregar una en 700, luego 750, luego 800...") — precisamente el patrón de inserción ordenada que degrada un BST simple. Como la búsqueda de reglas está en el camino crítico de cada transacción puntuada, un O(log n) garantizado sin importar el orden de registro es el requisito real, no solo el caso común. FraudRuleIndexTest cubre la búsqueda por umbral exacto y el caso de umbral inexistente.

Benchmark

./gradlew :trees:avl-tree:jmh

Ejecución real (JMH 1.37, JDK 26.0.2, 2 iteraciones de calentamiento + 3 de medición, 1 fork) — costo de get() en el propio BinarySearchTree de este repositorio (vía una dependencia real de project(":trees:binary-search-tree")) frente al AvlTree de este módulo, cada uno construido tanto a partir de una secuencia de claves mezclada aleatoriamente como de una secuencia ordenada:

Costo de get (ns/op) size=100 size=1,000 size=10,000
BST, orden de inserción aleatorio 20.8 44.1 31.2
BST, orden de inserción ordenado 128.0 1,253.7 21,514.5
AVL, orden de inserción aleatorio 18.5 20.9 36.0
AVL, orden de inserción ordenado 24.1 29.5 27.8

El BST simple explota con entrada ordenada — ~168x más lento en size=10,000 que su propia ejecución en orden aleatorio. El árbol AVL casi no nota en qué orden llegaron las mismas claves: sus números en orden ordenado y en orden aleatorio se mantienen en la misma banda estrecha en todos los tamaños. Esa es la garantía, hecha medible en lugar de simplemente afirmada.

Cuándo no usarlo

Cobertura de pruebas

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

./gradlew :trees:avl-tree:jacocoTestReport

Informe en trees/avl-tree/build/reports/jacoco/test/html/index.html.

Pruebas unitarias

src/test/java/com/datastructures/trees/avltree/classic/AvlTreeTest.java
package com.datastructures.trees.avltree.classic;

import com.datastructures.trees.binarysearchtree.classic.BinarySearchTree;
import org.junit.jupiter.api.Test;

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

class AvlTreeTest {

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

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

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

        tree.insert(50, "root");

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

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

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

    @Test
    void insertingAnExistingKeyOverwritesItsValueWithoutChangingShapeOrSize() {
        AvlTree<Integer, String> tree = new AvlTree<>();
        tree.insert(50, "original");
        tree.insert(20, "20");
        int heightBefore = tree.height();

        tree.insert(50, "replaced");

        assertThat(tree.get(50)).isEqualTo("replaced");
        assertThat(tree.size()).isEqualTo(2);
        assertThat(tree.height()).isEqualTo(heightBefore);
    }

    // --- The four rotation cases. Each sequence is hand-picked so the third insert is the one
    // that pushes the tree's root out of balance, and the resulting height (2, not 3) is what
    // proves a rotation actually happened rather than the tree just being left lopsided.

    @Test
    void leftLeftInsertionOrderTriggersASingleRightRotation() {
        AvlTree<Integer, String> tree = new AvlTree<>();

        tree.insert(30, "30");
        tree.insert(20, "20");
        tree.insert(10, "10"); // 30 becomes unbalanced left-left; single right rotation at 30

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

    @Test
    void rightRightInsertionOrderTriggersASingleLeftRotation() {
        AvlTree<Integer, String> tree = new AvlTree<>();

        tree.insert(10, "10");
        tree.insert(20, "20");
        tree.insert(30, "30"); // 10 becomes unbalanced right-right; single left rotation at 10

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

    @Test
    void leftRightInsertionOrderTriggersALeftRotationThenARightRotation() {
        AvlTree<Integer, String> tree = new AvlTree<>();

        tree.insert(30, "30");
        tree.insert(10, "10");
        tree.insert(20, "20"); // 30 becomes left-heavy with a right-leaning left child

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

    @Test
    void rightLeftInsertionOrderTriggersARightRotationThenALeftRotation() {
        AvlTree<Integer, String> tree = new AvlTree<>();

        tree.insert(10, "10");
        tree.insert(30, "30");
        tree.insert(20, "20"); // 10 becomes right-heavy with a left-leaning right child

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

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

        // Same 100-key sorted sequence that this repo's BinarySearchTreeTest proves degenerates
        // the plain BST's height to exactly 100 (a straight chain). The AVL tree rebalances on
        // every insert, so its height stays at exactly 7 instead — right in line with the
        // theoretical log2(100) ≈ 6.64.
        assertThat(plainBst.height()).isEqualTo(100);
        assertThat(avlTree.height()).isEqualTo(7);
        assertThat(avlTree.size()).isEqualTo(100);
        for (int key = 0; key < 100; key++) {
            assertThat(avlTree.get(key)).isEqualTo("v" + key);
        }
    }

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

        assertThat(tree.height()).isLessThanOrEqualTo(4);
        assertThat(tree.size()).isEqualTo(order.length);
    }
}
src/test/java/com/datastructures/trees/avltree/applied/FraudRuleIndexTest.java
package com.datastructures.trees.avltree.applied;

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

import java.util.Optional;

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

class FraudRuleIndexTest {

    private final FraudRuleIndex index = new FraudRuleIndex();

    @BeforeEach
    void registerRulesInAscendingThresholdOrder() {
        // Ascending registration order is deliberate: it's the realistic ops workflow (tiers
        // rolled out low-to-high) and exactly the order that would degenerate a plain BST.
        index.registerRule(new FraudRule(300, "low-risk-flag", "LOG_ONLY"));
        index.registerRule(new FraudRule(700, "elevated-risk-review", "FLAG_FOR_REVIEW"));
        index.registerRule(new FraudRule(900, "high-risk-block", "BLOCK"));
    }

    @Test
    void ruleAtARegisteredThresholdIsFound() {
        Optional<FraudRule> rule = index.ruleAt(700);

        assertThat(rule).isPresent();
        assertThat(rule.get().action()).isEqualTo("FLAG_FOR_REVIEW");
        assertThat(index.hasRuleAt(700)).isTrue();
    }

    @Test
    void thereIsNoRuleAtAnUnregisteredThreshold() {
        Optional<FraudRule> rule = index.ruleAt(750);

        assertThat(rule).isEmpty();
        assertThat(index.hasRuleAt(750)).isFalse();
    }

    @Test
    void registeringAtAnExistingThresholdReplacesTheRule() {
        index.registerRule(new FraudRule(700, "elevated-risk-review-v2", "STEP_UP_AUTH"));

        Optional<FraudRule> rule = index.ruleAt(700);

        assertThat(rule).isPresent();
        assertThat(rule.get().ruleId()).isEqualTo("elevated-risk-review-v2");
        assertThat(index.size()).isEqualTo(3);
    }

    @Test
    void sizeReflectsTheNumberOfDistinctRegisteredThresholds() {
        assertThat(index.size()).isEqualTo(3);
    }
}

Ver informe completo de cobertura JaCoCo →