← All structures

Heap (Priority Queue)

Trees · view source on GitHub

Read this in: English · Português · Español

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

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);
    }
}

View full JaCoCo coverage report →