Why it matters

Priority queues are one of the most-used data structures. Wrong choice (linked list vs sorted array vs heap) has 100x impact on performance. Heaps are usually the answer.

Advertisement

The architecture

Array representation: parent(i) = (i-1)/2, left(i) = 2i+1, right(i) = 2i+2. No pointers needed.

Insert: append at end, bubble up (swap with parent while out of order). O(log n).

Extract-min: return root, move last to root, bubble down (swap with smaller child). O(log n).

Heap operationsInsertappend + bubble upExtract minswap + bubble downHeapifybuild O(n)Array-backed tree with implicit parent-child pointers via index math
Core heap operations.
Advertisement

How it works end to end

Heapify: convert unordered array to heap in O(n). Bubble down from last non-leaf backwards. This is faster than n inserts.

Heap sort: heapify array, then repeatedly extract-min into a result. O(n log n), in-place, not stable.

d-ary heaps: nodes have d children instead of 2. Shallower tree, more comparisons per level. Sometimes faster in practice.

Fibonacci heap: better amortized decrease-key. Theoretically improves Dijkstra to O(E + V log V).

The array layout: a tree with no pointers

A binary heap is a complete binary tree - every level is full except possibly the last, which fills left to right. That single shape invariant is what makes the pointer-free representation work. Because there are never holes, the nodes can be written into a flat array in breadth-first order, and the tree edges become arithmetic on the index rather than memory the structure has to store.

With zero-based indexing, the children of node i live at 2i+1 and 2i+2, and the parent of a non-root node i is (i-1)/2 under integer division. One-based indexing is slightly cheaper and is what most textbook pseudocode uses: children are 2i and 2i+1, the parent is i/2, so both directions are a single shift. The saving is one add per level, which matters only in the innermost loop of a hot sift, but it is the reason production heaps often waste slot zero.

The payoff over a pointer-based tree is not asymptotic, it is constant-factor and it is large. A heap of n 8-byte keys occupies exactly 8n bytes. A node-per-element binary search tree of the same data carries two or three pointers plus an allocator header per node, so it costs three to five times the memory and scatters those nodes across the address space. The heap's top levels - the ones every operation touches - are a few hundred bytes that stay resident in L1 indefinitely. There is also no allocation on insert and no deallocation on extract, which removes an entire class of latency spikes from the operation.

Sift-up and sift-down are not symmetric

Both directions are described as O(log n), and both are, but they behave very differently and the asymmetry drives most heap tuning.

Sift-up does one comparison per level. A node has exactly one parent, so restoring the invariant upward means comparing against that parent and stopping the moment the order holds. The worst case is the full height, but the average case for a randomly ordered insert is close to constant: roughly half the nodes are leaves, a quarter sit one level up, and so on, so a key landing in a random position stops after a small constant number of comparisons. Pushing a batch of unordered keys therefore costs far less than the log-n-per-insert bound suggests.

Sift-down does two comparisons per level - one to pick the smaller child, one to test that child against the moving key - and it rarely stops early. The element being sifted down after an extract is the last element of the array, which is a leaf, which means it is statistically one of the larger keys in the structure. It usually travels most of the way back to the bottom. Extract-min really does pay close to 2 log n comparisons, while insert usually pays a handful. If you are profiling a heap-heavy loop, the extracts are where the time is.

Floyd's optimisation exploits exactly this. Instead of testing at every level whether the moving key has come to rest, sift it down unconditionally along the path of smaller children all the way to a leaf, then sift it back up. The downward pass drops to one comparison per level, and the upward correction is short because the key was large to begin with. It roughly halves the comparison count of extract-min, which is worthwhile when comparisons are expensive - string keys, tuples, or a user-supplied comparator - and not worth the complexity when keys are machine integers.

Why building a heap is O(n), not O(n log n)

Converting an unordered array into a heap by sifting down from the last internal node backwards costs linear time, and the reason is worth working through because the naive count gives the wrong answer.

The naive count says: n/2 internal nodes, each sifting down up to log n levels, therefore O(n log n). That is a valid upper bound and it is loose, because it charges every node the height of the whole tree. The real cost depends on each node's own height above the leaves, and the node population is wildly skewed toward the bottom.

At height h above the leaves there are at most n / 2^(h+1) nodes, and sifting one of them down costs O(h). Summing over the tree gives n/2 multiplied by the series h / 2^h for h from 0 upward. That series converges to 2, so the total work is bounded by n - a constant, not a logarithm.

The intuition behind the algebra: half the nodes are leaves and do zero work, a quarter can move at most one level, and only the single root can fall the full log n. The expensive nodes are exactly the rare ones. Inserting the same keys one at a time inverts this - there, the many leaf-level insertions are the ones that can travel the full height - which is why repeated insertion really is O(n log n) and bottom-up construction is not.

def sift_down(a, i, n):
    while True:
        c = 2 * i + 1                 # left child
        if c >= n:
            return
        if c + 1 < n and a[c + 1] < a[c]:
            c += 1                    # pick the smaller child
        if a[i] <= a[c]:
            return                    # invariant restored
        a[i], a[c] = a[c], a[i]
        i = c

def build_heap(a):
    # last internal node is at (len(a) // 2) - 1; leaves need no work
    for i in range(len(a) // 2 - 1, -1, -1):
        sift_down(a, i, len(a))

One thing linear construction does not buy you is a sorted array. The heap property is far weaker than a total order - it constrains each node only against its own descendants - so there is no conflict with the comparison-sorting lower bound. That bound and the general complexity vocabulary are developed in Big-O Notation.

Heapsort: in-place, worst-case optimal, and usually second place

Heapsort falls out of the two primitives. Build a max-heap over the array in linear time, then repeatedly swap the root with the last live slot, shrink the live region by one, and sift the new root down. After n-1 rounds the array is sorted ascending, and the sorted suffix has been accumulating in exactly the space the heap vacated.

Its properties are unusually clean. It is genuinely in-place - a constant number of scratch variables, no recursion, no auxiliary array. Its worst case is O(n log n), with no adversarial input that degrades it, unlike quicksort's quadratic worst case. It is not stable: equal keys are flung across the array by sifts and their relative order is destroyed.

And yet on typical hardware it loses to a decent quicksort by a factor of two or more. The reason is memory access, not instruction count. Sifting down walks i -> 2i+1 -> 4i+3, so the stride doubles at every level; once the live region exceeds the last-level cache, each step of the descent is a fresh cache miss with no useful prefetch, and the deeper the heap the worse the ratio. Quicksort's partition is two sequential scans converging on each other, which is the access pattern hardware prefetchers were designed for. Heapsort also does more comparisons per element than a well-tuned quicksort, and every one of its swaps is long-distance.

So heapsort's production role is as an insurance policy rather than a primary algorithm. Introsort - the strategy behind the C++ standard library's std::sort - runs quicksort, tracks recursion depth, and switches the current subrange to heapsort once the depth exceeds roughly 2 * log2(n). Quicksort handles the common case at full speed; heapsort caps the worst case, guaranteeing O(n log n) against inputs crafted to defeat the pivot choice. The same library's partial_sort is heap-based for the same structural reason: a bounded heap is the natural way to keep the best k seen so far without sorting the rest. That bounded-k pattern belongs to Partial Sort and Top-K / heavy hitters rather than here.

d-ary heaps and the cache-line argument

Nothing about the array encoding requires two children. In a d-ary heap the children of i occupy the contiguous run d*i+1 through d*i+d, and the parent is (i-1)/d. The tree becomes shallower - depth log_d n instead of log_2 n - and the two sift directions move in opposite directions as a result.

Sift-up gets strictly cheaper. It costs one comparison per level, so raising d divides its work by log d; a 4-ary heap halves the sift-up path against a binary heap, an 8-ary heap cuts it to a third. Sift-down gets more expensive, because finding the smallest of d children costs d-1 comparisons per level. Multiply that by the depth and the total comparison count for a descent grows as (d-1) / ln d, which increases with d without bound. Purely on comparison count, binary wins for extraction.

Comparison count is not the whole cost on real hardware, which is why 4-ary and 8-ary heaps routinely beat binary ones in benchmarks. The d children are adjacent in memory. Scanning them is a linear walk over one or two cache lines, and a 64-byte line holds eight 8-byte keys - so with d=8 and the array padded so sibling groups align to line boundaries, an entire sibling group is one cache miss. A binary heap pays a miss per level too, but it has three times as many levels to descend. Trading a handful of extra comparisons, which run out of registers, for a third of the cache misses is a good trade whenever the heap is larger than the last-level cache. Past about d=8 the trade reverses: comparison work keeps growing linearly while depth only shrinks logarithmically, and the sibling group stops fitting in a line or two.

The other reason to reach for a d-ary heap is workloads that are insert-heavy or decrease-key-heavy relative to extraction, since those are the operations that ride the cheap upward path.

Decrease-key needs a handle, and that is the real cost

The array encoding gives fast access to the root and to any index, and no way whatsoever to locate a particular element. Finding one is a linear scan. So decrease-key and arbitrary delete, which every graph algorithm wants, are not natively supported operations - they are operations you have to build scaffolding for.

The scaffolding is a map from element identity to current array index, and the expensive part is keeping it honest: every swap performed by every sift must update two entries in that map. This roughly doubles the write traffic of each sift and inserts a hash lookup or an indirection into the innermost loop. Java's PriorityQueue is the standard illustration of the alternative choice - it maintains no such map, so remove(Object) is documented as linear time, because it has to scan for the element before it can fix up the array.

Two escape hatches cover almost every real case:

An index array, when identities are dense integers. If your elements are graph vertices numbered 0..V-1, the map is a plain int[V] holding each vertex's current heap position. Updates are two stores into a small hot array, with no hashing and no allocation. This is cheap enough to be the right answer whenever it applies.

Lazy deletion, when they are not. Skip the map entirely. To lower a key, push a second entry with the better value and leave the stale one in place; on pop, discard any entry whose recorded key no longer matches the current best for that element - the familiar if key > best[u]: continue guard. The heap can grow to one entry per relaxation rather than one per element, so it is asymptotically worse in space and in the log factor, and it still usually wins, because every operation stays a bare push or pop with no bookkeeping and pushes are the cheap direction.

This is also the practical reason the exotic heaps with better decrease-key bounds so rarely pay off. Their improvements are amortised and carry heavy constants and pointer chasing, while the binary heap's decrease-key problem usually dissolves into lazy deletion. Those structures have their own articles: Fibonacci Heap, Pairing Heap and Leftist Heap. The shortest-path side of the story lives in Dijkstra and Dijkstra with a Fibonacci Heap.

Failure modes that bite in production

Heaps are not stable, and the instability is not deterministic. Equal keys emerge in an order determined by the array permutation at that instant, so the same multiset inserted in the same order can come out differently depending on what else was in the heap. If ties must break FIFO, make the key a pair of priority and a monotonically increasing sequence number. The classic bug here is the half-fix: a tuple of (priority, payload) compares fine until two priorities are equal, at which point the comparator falls through to the payload, and if the payload is not comparable the whole thing raises at some arbitrary later moment under load rather than at insert time.

Mutating a key while its element is in the heap silently corrupts the structure. There is no validation and no crash - the invariant simply stops holding on the path from that node, and pops start returning wrong answers that look like application bugs. Store an immutable snapshot of the ordering key in the heap entry and keep the mutable object behind it.

Do not read a heap as a sorted sequence. Only the root is guaranteed. Slots 1 and 2 are the two candidates for second place, but slot 3 can hold anything at all, and iterating the backing array yields an order with no useful meaning. Any code that iterates a heap to display "the top ten" is wrong past the first element.

Merging two binary heaps is O(n+m). There is no cheap meld - the usual approach is to concatenate the arrays and rebuild bottom-up, which at least gets linear rather than m log n from re-inserting. This is the one operation binary heaps are genuinely bad at, and it is the entire reason mergeable heaps exist. If your workload melds often, a binary heap is the wrong structure.

On the credit side, growth is unusually cheap. Doubling a dynamic array backing a heap is a flat memcpy with no pointer fixups to perform, because there are no pointers - so amortised growth costs a bulk copy and nothing else. Presize when you know the bound and even that disappears. And peek is a single array read, which is why heaps are the right structure for the "what expires soonest" loop that timers and timeout wheels are built around.

When a binary heap is the wrong answer

A binary heap is a specialist: it is close to optimal for extract-min over unbounded keys, in memory, on one thread, and mediocre or worse at everything adjacent. Some alternatives worth weighing:

A sorted array gives O(1) peek and O(n) insert, and beats a heap outright when the collection is tiny or reads dominate writes. For fewer than roughly a dozen elements a linear insertion into a contiguous array is faster in wall-clock terms than any log-n structure, because the whole thing lives in one or two cache lines.

A balanced BST or skip list costs O(log n) for the same operations but also gives ordered iteration, predecessor and successor queries, and arbitrary deletion without a side map. You pay two to three times the constant factor and the pointer overhead. Take it when you need more than the minimum - see Skip list architecture.

A bucket queue or timer wheel is O(1) per operation when priorities are small bounded integers or timestamps within a known horizon, because it replaces comparison with array indexing. Schedulers and timeout managers almost always want this rather than a heap.

Concurrency changes the answer entirely. Every heap operation touches the root and mutates the array shape, so the structure is a single hot contention point and is notoriously hard to make lock-free - see Lock-Free Queues. The pragmatic designs shard into per-thread heaps and steal, or accept a mutex around a heap small enough that the critical section is short. Once priority handling becomes a service-level concern - fairness, aging, starvation, admission - the data structure stops being the interesting part; that ground belongs to Priority queue service architecture.

A binary heap is a complete tree flattened into an array, where 2i+1 replaces a pointer and the shape invariant guarantees no holes. Sift-up is one comparison per level and usually stops early; sift-down is two per level and usually runs to the bottom, which is why extraction dominates the profile. Bottom-up construction is linear because half the nodes are leaves that move zero levels and only the root can fall log n. Its real weaknesses are structural rather than asymptotic: no cheap merge, no way to find an element without a side index, no stability, and a cache-hostile access pattern that a 4-ary or 8-ary fan-out largely fixes. Reach for lazy deletion before reaching for an exotic heap.