← Todas las estructuras

Heap (Priority Queue)

Árboles · ver código fuente en GitHub

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

Categoría: Trees

El problema

Algunas colas no son FIFO — el próximo elemento a procesar no es el que llegó primero, sino el que es más urgente en este momento. Mantener una lista ordenada por prioridad hace que "obtener el más urgente" sea O(1), pero cada inserción pasa a ser O(n) para mantenerla ordenada. Un Binary Search Tree resuelve la inserción, pero agrega overhead de punteros y complejidad para una necesidad que, en el fondo, es solo "conocer siempre el mínimo, de forma barata".

La solución

Almacena un árbol binario completo de forma implícita en un array simple: el elemento en el índice i tiene hijos en 2i+1 y 2i+2, así que no hace falta ningún puntero — las relaciones padre/hijo son pura aritmética sobre el índice. Mantén exactamente un invariante: todo nodo es <= que ambos sus hijos. Con eso alcanza para que el mínimo esté siempre en el índice 0 (peek es O(1)), y tanto offer como poll solo necesitan corregir el invariante a lo largo de un único camino raíz-hoja — nunca el árbol entero — lo que es lo que los hace O(log n).

flowchart TD
    R["3"] --> L["7"]
    R --> Rt["5"]
    L --> LL["12"]
    L --> LR["9"]
Operación Costo Por qué
peek O(1) el mínimo está siempre en la posición raíz del array
offer O(log n) desplaza el nuevo elemento hacia arriba a lo largo de, como máximo, un camino hasta la raíz
poll O(log n) mueve el último elemento a la raíz y lo desplaza hacia abajo a lo largo de, como máximo, un camino

Ejemplo clásico

classic/MinHeap es un min-heap binario sobre un Object[] puro (sin java.util.PriorityQueue), reutilizando la idea de crecimiento por duplicación de Dynamic Array para el offer. MinHeapTest recorre a mano secuencias de offer/poll que pasan por todas las ramas de siftUp y siftDown — subiendo cero pasos, un paso y varios pasos; bajando cuando el hijo izquierdo, el hijo derecho, o ninguno de los dos, es el menor.

Ejemplo aplicado: cola de escalamiento de SLA de telecom

applied/SlaEscalationQueue ordena los tickets de soporte por el tiempo de SLA restante: el ticket más cercano a incumplir su SLA es siempre el "mínimo" según el orden natural de SlaTicket, y siempre es O(log n) tanto enviar un ticket recién llegado como extraer el próximo a escalar, sin importar el tamaño de la cola. SlaEscalationQueueTest cubre el orden de escalamiento con tickets enviados fuera de orden de urgencia.

Benchmark

./gradlew :trees:heap:jmh

Ejecución real (JMH 1.37, JDK 26.0.2, 2 iteraciones de calentamiento + 3 de medición, 1 fork). Medir offer y poll contra un heap creciente de la forma ingenua (reconstruir un heap nuevo de tamaño N en cada llamada cronometrada) entierra la señal de O(log n) bajo el ruido de GC/asignación de la propia reconstrucción — así que este benchmark, en cambio, construye el heap una sola vez por trial y empareja cada operación cronometrada con una operación compensatoria barata y no cronometrada para mantener el tamaño estable, el patrón estándar de JMH para medir el costo en estado estable de una estructura mutable:

Operación (estado estable) size=100 size=10,000 size=100,000
offer 66.8 ns 118.5 ns 135.2 ns
poll 64.5 ns 130.2 ns 154.7 ns

log2(100,000/100) = log2(1,000) ≈ 9.97, y log2(10,000/100) = log2(100) ≈ 6.64. Ambas operaciones crecen aproximadamente con esa forma, en lugar de plana o lineal: poll crece ~2.4x de size=100 a size=100,000 (verificación de la forma esperada: ~2x por década de tamaño), no el ~1,000x que mostraría un recorrido lineal.

Cuándo no usarlo

Cobertura de pruebas

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

./gradlew :trees:heap:jacocoTestReport

Reporte en trees/heap/build/reports/jacoco/test/html/index.html.

Pruebas unitarias

src/test/java/com/datastructures/trees/heap/classic/MinHeapTest.java
package com.datastructures.trees.heap.classic;

import org.junit.jupiter.api.Test;

import java.util.NoSuchElementException;

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

class MinHeapTest {

    @Test
    void startsEmpty() {
        MinHeap<Integer> heap = new MinHeap<>();

        assertThat(heap.isEmpty()).isTrue();
        assertThat(heap.size()).isZero();
    }

    @Test
    void canBeConstructedWithAnExplicitInitialCapacity() {
        MinHeap<Integer> heap = new MinHeap<>(64);

        assertThat(heap.isEmpty()).isTrue();
        heap.offer(1);
        assertThat(heap.peek()).isEqualTo(1);
    }

    @Test
    void constructingWithANonPositiveInitialCapacityThrows() {
        assertThatThrownBy(() -> new MinHeap<Integer>(0)).isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void offerThenPeekReturnsTheMinimumWithoutRemovingIt() {
        MinHeap<Integer> heap = new MinHeap<>();
        heap.offer(5);
        heap.offer(3);
        heap.offer(8);

        assertThat(heap.peek()).isEqualTo(3);
        assertThat(heap.peek()).isEqualTo(3); // still there: peek doesn't remove
        assertThat(heap.size()).isEqualTo(3);
        assertThat(heap.isEmpty()).isFalse();
    }

    @Test
    void peekOnAnEmptyHeapThrows() {
        MinHeap<Integer> heap = new MinHeap<>();

        assertThatThrownBy(heap::peek).isInstanceOf(NoSuchElementException.class);
    }

    @Test
    void pollOnAnEmptyHeapThrows() {
        MinHeap<Integer> heap = new MinHeap<>();

        assertThatThrownBy(heap::poll).isInstanceOf(NoSuchElementException.class);
    }

    /**
     * One offer sequence, then a full drain via repeated poll(), engineered so every poll()
     * call exercises a different combination of the sift-down branches — not "insert random
     * stuff and hope." Offering 1, 10, 2, 20, 3 (in that order) builds the array
     * {@code [1, 3, 2, 20, 10]} (worked out by hand: offering 3 last is the only insert whose
     * sift-up actually swaps, bubbling past the 10 at index 1). Draining it one poll() at a
     * time then walks through every branch combination in {@code siftDown}:
     *
     * <ul>
     *   <li>poll #1 (array becomes {@code [10,3,2,20]} before sifting): both children exist,
     *       the right child (2) ends up smaller than the left (3), so {@code smallest} moves to
     *       the left first and then gets overridden by the right — and the swapped-into node
     *       has no children of its own, so the loop's second iteration hits
     *       {@code left < size == false}.</li>
     *   <li>poll #2 (array becomes {@code [20,3,10]} before sifting): both children exist, but
     *       the right child (10) is *not* smaller than the left (3) — {@code smallest} stays on
     *       the left, exercising {@code compare(right, smallest) < 0 == false} with a real
     *       right child present.</li>
     *   <li>poll #3 (array becomes {@code [10,20]} before sifting): only a left child exists
     *       (size 2), and it (20) is *not* smaller than the root (10) — exercises
     *       {@code compare(left, smallest) < 0 == false} together with
     *       {@code right < size == false}.</li>
     *   <li>poll #4 (array becomes {@code [20]} before sifting): no children at all —
     *       {@code left < size == false} with only one element, immediate break.</li>
     *   <li>poll #5: the heap becomes empty after removing the root, exercising {@code poll()}'s
     *       {@code size > 0 == false} branch (siftDown is skipped entirely).</li>
     * </ul>
     */
    @Test
    void pollDrainsEveryElementInAscendingOrderExercisingEverySiftDownBranch() {
        MinHeap<Integer> heap = new MinHeap<>();
        heap.offer(1);
        heap.offer(10);
        heap.offer(2);
        heap.offer(20);
        heap.offer(3);

        assertThat(heap.poll()).isEqualTo(1);
        assertThat(heap.poll()).isEqualTo(2);
        assertThat(heap.poll()).isEqualTo(3);
        assertThat(heap.poll()).isEqualTo(10);
        assertThat(heap.poll()).isEqualTo(20);
        assertThat(heap.isEmpty()).isTrue();
        assertThat(heap.size()).isZero();
    }

    @Test
    void offeringAValueSmallerThanTheCurrentMinimumSiftsItAllTheWayToTheRoot() {
        MinHeap<Integer> heap = new MinHeap<>();
        heap.offer(10);
        heap.offer(20); // larger than its parent: siftUp breaks on the first check, no swap

        heap.offer(5); // smaller than the root: siftUp swaps all the way up

        assertThat(heap.peek()).isEqualTo(5);
    }

    @Test
    void offeringStrictlyIncreasingValuesNeverSwapsDuringSiftUp() {
        MinHeap<Integer> heap = new MinHeap<>();
        for (int i = 1; i <= 5; i++) {
            heap.offer(i); // each new value is >= every existing ancestor: siftUp always breaks immediately
        }

        assertThat(heap.peek()).isEqualTo(1);
        assertThat(heap.size()).isEqualTo(5);
    }

    @Test
    void growingPastTheInitialCapacityKeepsEveryElementCorrect() {
        MinHeap<Integer> heap = new MinHeap<>();
        int[] values = {15, 3, 27, 1, 19, 8, 22, 4, 30, 11, 2, 25, 6, 17, 9, 21, 5, 29, 13, 7};
        for (int value : values) {
            heap.offer(value); // 20 offers > DEFAULT_CAPACITY (16): forces at least one grow()
        }

        assertThat(heap.size()).isEqualTo(values.length);
        int previous = Integer.MIN_VALUE;
        for (int i = 0; i < values.length; i++) {
            int polled = heap.poll();
            assertThat(polled).isGreaterThanOrEqualTo(previous);
            previous = polled;
        }
        assertThat(heap.isEmpty()).isTrue();
    }
}
src/test/java/com/datastructures/trees/heap/applied/SlaEscalationQueueTest.java
package com.datastructures.trees.heap.applied;

import org.junit.jupiter.api.Test;

import java.util.NoSuchElementException;

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

class SlaEscalationQueueTest {

    @Test
    void startsEmpty() {
        SlaEscalationQueue queue = new SlaEscalationQueue();

        assertThat(queue.isEmpty()).isTrue();
        assertThat(queue.size()).isZero();
    }

    @Test
    void ticketsAreEscalatedInAscendingRemainingSlaOrderRegardlessOfSubmissionOrder() {
        SlaEscalationQueue queue = new SlaEscalationQueue();
        queue.submit(new SlaTicket("TCK-1", 60_000L));
        queue.submit(new SlaTicket("TCK-2", 5_000L));
        queue.submit(new SlaTicket("TCK-3", 30_000L));

        assertThat(queue.nextToEscalate().ticketId()).isEqualTo("TCK-2");
        assertThat(queue.nextToEscalate().ticketId()).isEqualTo("TCK-3");
        assertThat(queue.nextToEscalate().ticketId()).isEqualTo("TCK-1");
        assertThat(queue.isEmpty()).isTrue();
    }

    @Test
    void peekNextReturnsTheMostUrgentTicketWithoutRemovingIt() {
        SlaEscalationQueue queue = new SlaEscalationQueue();
        queue.submit(new SlaTicket("TCK-1", 60_000L));
        queue.submit(new SlaTicket("TCK-2", 5_000L));

        assertThat(queue.peekNext().ticketId()).isEqualTo("TCK-2");
        assertThat(queue.size()).isEqualTo(2);
    }

    @Test
    void escalatingFromAnEmptyQueueThrows() {
        SlaEscalationQueue queue = new SlaEscalationQueue();

        assertThatThrownBy(queue::nextToEscalate).isInstanceOf(NoSuchElementException.class);
    }
}

Ver informe completo de cobertura JaCoCo →