Category: Trees
The problem
Some queues aren't FIFO — the next item to process isn't whichever arrived first, it's whichever is most urgent right now. Keeping a list sorted by priority makes "get the most urgent one" O(1), but every insertion becomes O(n) to keep it sorted. A Binary Search Tree fixes insertion but adds pointer overhead and complexity for a need that's really just "always know the minimum, cheaply."
The solution
Store a complete binary tree implicitly in a plain array: the element at index i has children
at 2i+1 and 2i+2, so no pointers are needed at all — parent/child relationships are just
arithmetic on the index. Maintain exactly one invariant: every node is <= both its children.
That's enough to make the minimum always sit at index 0 (peek is O(1)), and both offer and
poll only ever need to fix the invariant along a single root-to-leaf path — never the whole
tree — which is what makes them O(log n).
flowchart TD
R["3"] --> L["7"]
R --> Rt["5"]
L --> LL["12"]
L --> LR["9"]
| Operation | Cost | Why |
|---|---|---|
peek |
O(1) | the minimum is always the array's root slot |
offer |
O(log n) | sifts the new element up at most one path to the root |
poll |
O(log n) | moves the last element to the root, sifts it down at most one path |
Classic example
classic/MinHeap is a
binary min-heap on a raw Object[] (no java.util.PriorityQueue), reusing the doubling-growth
idea from Dynamic Array for offer. MinHeapTest
hand-traces offer/poll sequences that walk through every branch of siftUp and siftDown —
sifting up zero steps, one step, and multiple steps; sifting down where the left child, the
right child, or neither is smaller.
Applied example: telecom SLA escalation queue
applied/SlaEscalationQueue
orders support tickets by remaining SLA time: the ticket closest to breaching its SLA is always
the "minimum" by SlaTicket's
natural ordering, and it's always O(log n) to submit a newly-arrived ticket or pull the next one
to escalate, regardless of queue size. SlaEscalationQueueTest
covers escalation order across tickets submitted out of urgency order.
Benchmark
./gradlew :trees:heap:jmh
Real run (JMH 1.37, JDK 26.0.2, 2 warmup + 3 measurement iterations, 1 fork). Measuring offer
and poll against a growing heap the naive way (rebuild a fresh size-N heap on every timed
call) buries the O(log n) signal under GC/allocation noise from the rebuild itself — so this
benchmark instead builds the heap once per trial and pairs every timed operation with a cheap,
untimed compensating operation to hold size steady, the standard JMH pattern for measuring a
mutating structure's steady-state cost:
| Operation (steady state) | 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, and log2(10,000/100) = log2(100) ≈ 6.64. Both
operations grow by roughly that shape rather than flat or linear: poll grows ~2.4x from
size=100 to size=100,000 (predicted-shape check: ~2x per decade of size), not the ~1,000x a
linear scan would show.
When not to use it
- Need to find or remove an arbitrary element, not just the minimum? A heap only gives cheap access to the minimum — searching for anything else is O(n), same as an unsorted array.
- Need the fully sorted order, not just repeated access to the current minimum? Heapsort is a reasonable use of this structure, but if the data needs to stay sorted for range queries too, a Binary Search Tree is a better fit.
- Need a maximum instead of a minimum? Flip the comparison (or negate the natural ordering) — this module only implements a min-heap, since the applied scenario only needed one direction.
Test coverage
100% instruction coverage, 100% branch coverage (JaCoCo). Reproduce it yourself:
./gradlew :trees:heap:jacocoTestReport
Report at trees/heap/build/reports/jacoco/test/html/index.html.
Unit tests
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);
}
}