Categoria: Trees
O problema
Algumas filas não são FIFO — o próximo item a processar não é o que chegou primeiro, e sim o que é mais urgente agora. Manter uma lista ordenada por prioridade torna "pegar o mais urgente" O(1), mas cada inserção passa a ser O(n) para manter a ordenação. Uma Binary Search Tree resolve a inserção, mas adiciona overhead de ponteiros e complexidade para uma necessidade que, no fundo, é só "saber sempre o mínimo, de forma barata".
A solução
Armazene uma árvore binária completa implicitamente em um array simples: o elemento no índice i tem filhos nos índices 2i+1 e 2i+2, então não é necessário nenhum ponteiro — as relações pai/filho são pura aritmética sobre o índice. Mantenha exatamente um invariante: todo nó é <= a ambos os seus filhos. Isso já basta para que o mínimo esteja sempre no índice 0 (peek é O(1)), e tanto offer quanto poll só precisam corrigir o invariante ao longo de um único caminho raiz-folha — nunca a árvore inteira — o que é o que torna ambos O(log n).
flowchart TD
R["3"] --> L["7"]
R --> Rt["5"]
L --> LL["12"]
L --> LR["9"]
| Operação | Custo | Por quê |
|---|---|---|
peek |
O(1) | o mínimo está sempre na posição raiz do array |
offer |
O(log n) | reposiciona o novo elemento para cima ao longo de no máximo um caminho até a raiz |
poll |
O(log n) | move o último elemento para a raiz e o reposiciona para baixo ao longo de no máximo um caminho |
Exemplo clássico
classic/MinHeap é um min-heap binário sobre um Object[] puro (sem java.util.PriorityQueue), reaproveitando a ideia de crescimento por duplicação de Dynamic Array para o offer. MinHeapTest percorre manualmente sequências de offer/poll que passam por todos os ramos de siftUp e siftDown — subir zero passos, um passo e múltiplos passos; descer quando o filho esquerdo, o filho direito, ou nenhum dos dois, é o menor.
Exemplo aplicado: fila de escalonamento de SLA de telecom
applied/SlaEscalationQueue ordena chamados de suporte pelo tempo restante de SLA: o chamado mais próximo de violar o SLA é sempre o "mínimo" segundo a ordenação natural de SlaTicket, e submeter um chamado recém-chegado ou retirar o próximo a escalonar é sempre O(log n), independentemente do tamanho da fila. SlaEscalationQueueTest cobre a ordem de escalonamento com chamados submetidos fora da ordem de urgência.
Benchmark
./gradlew :trees:heap:jmh
Execução real (JMH 1.37, JDK 26.0.2, 2 iterações de aquecimento + 3 de medição, 1 fork). Medir offer e poll contra um heap crescente da forma ingênua (reconstruir um heap novo de tamanho N a cada chamada cronometrada) esconde o sinal de O(log n) sob o ruído de GC/alocação da própria reconstrução — então este benchmark, em vez disso, constrói o heap uma única vez por trial e combina cada operação cronometrada com uma operação compensatória barata e não cronometrada para manter o tamanho estável, o padrão usual do JMH para medir o custo em regime estável de uma estrutura mutável:
| Operação (regime estável) | 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, e log2(10,000/100) = log2(100) ≈ 6.64. Ambas as operações crescem aproximadamente nesse formato, em vez de plano ou linear: poll cresce ~2.4x de size=100 para size=100,000 (verificação do formato previsto: ~2x por década de tamanho), não os ~1,000x que uma varredura linear mostraria.
Quando não usar
- Precisa encontrar ou remover um elemento arbitrário, não só o mínimo? Um heap só oferece acesso barato ao mínimo — buscar qualquer outra coisa é O(n), igual a um array não ordenado.
- Precisa da ordem totalmente ordenada, não só de acesso repetido ao mínimo atual? Heapsort é um uso razoável dessa estrutura, mas se os dados também precisam permanecer ordenados para consultas de intervalo, uma Binary Search Tree se encaixa melhor.
- Precisa de um máximo em vez de um mínimo? Inverta a comparação (ou negue a ordenação natural) — este módulo só implementa um min-heap, já que o cenário aplicado só precisava de uma direção.
Cobertura de testes
100% de cobertura de instruções, 100% de cobertura de branches (JaCoCo). Reproduza você mesmo:
./gradlew :trees:heap:jacocoTestReport
Relatório em trees/heap/build/reports/jacoco/test/html/index.html.
Testes unitários
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);
}
}