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
- ¿Necesitas encontrar o eliminar un elemento arbitrario, no solo el mínimo? Un heap solo da acceso barato al mínimo — buscar cualquier otra cosa es O(n), igual que en un array sin ordenar.
- ¿Necesitas el orden totalmente ordenado, no solo acceso repetido al mínimo actual? Heapsort es un uso razonable de esta estructura, pero si los datos también necesitan permanecer ordenados para consultas de rango, un Binary Search Tree encaja mejor.
- ¿Necesitas un máximo en lugar de un mínimo? Invierte la comparación (o niega el orden natural) — este módulo solo implementa un min-heap, ya que el escenario aplicado solo necesitaba una dirección.
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);
}
}