AI Knowledge Tree

Heaps and priority queues

Implement a binary heap and use it for priority queues and heapsort.

⏱ 30 minPrerequisites:Binary search trees and balancing

A binary search tree keeps elements in full sorted order, which is more than you need if all you want is the minimum (or maximum). A heap maintains only a partial order, enough to find the extreme in O(1)O(1) and remove it in O(log⁡n)O(\log n)[1]. It is the underlying data structure of priority queues, of Dijkstra's algorithm, and of heapsort[2].

The heap property

A min-heap is a complete binary tree (every level full except possibly the last, which is filled left to right) where every parent is ≤\le its children. The minimum sits at the root. A max-heap is the mirror: every parent ≥\ge its children. The heap property is local (parent versus children only), and weaker than the BST property, but it is exactly what is needed for extreme-element access.

Array layout

Heaps are stored in a flat array, not as pointer-linked nodes, because the completeness condition makes the tree position of every node a function of its index. For a 0-indexed heap, node ii has

parent(i)=⌊i−12⌋,left(i)=2i+1,right(i)=2i+2.\text{parent}(i) = \left\lfloor \frac{i-1}{2} \right\rfloor, \quad \text{left}(i) = 2i + 1, \quad \text{right}(i) = 2i + 2.

The array layout has the same cache-locality advantages as any dynamic array. The tree structure is implicit in the indexing.

Sift up and sift down

Two restoring operations keep the heap property after a change at one position:

Sift up. After inserting at the end, the new element may be smaller than its parent. Swap it with the parent while it violates the heap property; repeat. Each swap moves one level up, so the cost is O(log⁡n)O(\log n).

Sift down. After replacing the root (for a pop) with the last element, the root may be larger than its children. Swap it with its smaller child while it violates the property; repeat. Cost is O(log⁡n)O(\log n).

Operations

  • Peek minimum: read heap[0], O(1)O(1).
  • Insert: append at the end, sift up. O(log⁡n)O(\log n).
  • Pop minimum: swap root with the last element, shrink the array, sift down the new root. O(log⁡n)O(\log n).

Building a heap

Given an unsorted array of nn items, calling insert nn times costs O(nlog⁡n)O(n \log n). Heapify is faster: start from the last non-leaf and sift down each node up to the root. The total work telescopes to O(n)O(n), because most nodes are near the leaves where sift down is cheap, and only a few near the root require log⁡n\log n work. So an array becomes a heap in linear time.

Heapsort

Heapify the array in O(n)O(n), then repeatedly swap the root (the current minimum) with the last element and sift down on the reduced heap. Each pop is O(log⁡n)O(\log n), so total work is O(nlog⁡n)O(n \log n). Heapsort is in-place (no auxiliary array) and worst-case O(nlog⁡n)O(n \log n), which is what makes it interesting compared to quicksort.

Check it on arrays

python · numpy
class MinHeap:
    def __init__(self): self.h = []
    def __len__(self): return len(self.h)
    def peek(self): return self.h[0]

    def push(self, x):
        self.h.append(x)
        i = len(self.h) - 1
        while i > 0:
            p = (i - 1) // 2
            if self.h[p] <= self.h[i]: break
            self.h[p], self.h[i] = self.h[i], self.h[p]
            i = p

    def pop(self):
        last = self.h.pop()
        if not self.h: return last
        top, self.h[0] = self.h[0], last
        i, n = 0, len(self.h)
        while True:
            l, r = 2 * i + 1, 2 * i + 2
            small = i
            if l < n and self.h[l] < self.h[small]: small = l
            if r < n and self.h[r] < self.h[small]: small = r
            if small == i: break
            self.h[i], self.h[small] = self.h[small], self.h[i]
            i = small
        return top

import random
rng = random.Random(0)
data = [rng.randint(0, 1000) for _ in range(20)]

# Use the heap as a priority queue
h = MinHeap()
for x in data: h.push(x)
ordered = [h.pop() for _ in range(len(data))]
print("priority-queue popping yields sorted order:", ordered == sorted(data))

# Heapsort (in-place via the same operations)
def heapsort(a):
    h = MinHeap(); h.h = a[:]                          # treat as array, build via push
    # Linear-time heapify: sift down from last non-leaf
    n = len(h.h)
    for i in range(n // 2 - 1, -1, -1):
        # inline sift down on h
        j = i
        while True:
            l, r = 2 * j + 1, 2 * j + 2; s = j
            if l < n and h.h[l] < h.h[s]: s = l
            if r < n and h.h[r] < h.h[s]: s = r
            if s == j: break
            h.h[j], h.h[s] = h.h[s], h.h[j]; j = s
    return [h.pop() for _ in range(n)]

print("heapsort matches sorted():", heapsort(data) == sorted(data))

# Standard library uses heapq under the hood
import heapq
heap = list(data); heapq.heapify(heap)                 # O(n)
print("heapq peek:", heap[0], " min:", min(data))

Popping from the heap yields the sorted order, heapsort matches sorted(), and heapq.heapify confirms the linear-time build.

Exercises

Exercise 1. Insert the keys 5,3,8,1,9,25, 3, 8, 1, 9, 2 one at a time into an initially empty min-heap, using the array layout and sift-up. Give the final array and verify the heap property at every parent.

Solution

Step 1. Insert 5: [5]. Insert 3 at index 1; parent index ⌊(1−1)/2⌋=0\lfloor (1-1)/2 \rfloor = 0 holds 5 > 3, so swap: [3, 5].

Step 2. Insert 8 at index 2; parent index 0 holds 3≤83 \le 8, no swap: [3, 5, 8]. Insert 1 at index 3; parent index 1 holds 5>15 > 1, swap to [3, 1, 8, 5]; new index 1, parent index 0 holds 3>13 > 1, swap: [1, 3, 8, 5].

Step 3. Insert 9 at index 4; parent index 1 holds 3≤93 \le 9, no swap: [1, 3, 8, 5, 9]. Insert 2 at index 5; parent index ⌊(5−1)/2⌋=2\lfloor (5-1)/2 \rfloor = 2 holds 8>28 > 2, swap to [1, 3, 2, 5, 9, 8]; new index 2, parent index 0 holds 1≤21 \le 2, stop.

Final array: [1, 3, 2, 5, 9, 8]. Check parents: index 0 (1≤1 \le children 3, 2); index 1 (3≤3 \le children 5, 9); index 2 (2≤2 \le child 8). The heap property holds everywhere, and the minimum 1 is at the root.

Exercise 2. Prove that building a heap by sifting down from the last non-leaf up to the root costs O(n)O(n), not O(nlog⁡n)O(n \log n). Use the fact that a complete tree of nn nodes has at most ⌈n/2h+1⌉\lceil n / 2^{h+1} \rceil nodes at height hh.

Solution

Step 1. Sift-down on a node at height hh costs O(h)O(h), since it can descend at most hh levels. Summing over all nodes, the total work is bounded by ∑h=0⌊log⁡2n⌋(nodes at height h)⋅O(h)\sum_{h=0}^{\lfloor \log_2 n \rfloor} (\text{nodes at height } h) \cdot O(h).

Step 2. Substitute the node count bound ⌈n/2h+1⌉\lceil n / 2^{h+1} \rceil:

∑h=0⌊log⁡2n⌋n2h+1⋅h=n2∑h=0∞h2h.\sum_{h=0}^{\lfloor \log_2 n \rfloor} \frac{n}{2^{h+1}} \cdot h = \frac{n}{2} \sum_{h=0}^{\infty} \frac{h}{2^{h}}.

Step 3. The series ∑h=0∞h/2h\sum_{h=0}^{\infty} h / 2^{h} converges to 2 (a standard arithmetico-geometric sum). So the total work is at most n2⋅2=n\frac{n}{2} \cdot 2 = n, giving O(n)O(n). Most nodes sit near the leaves where hh is small, so the cheap sift-downs dominate the count and the expensive root-level ones are too few to matter.

Exercise 3 (code). A min-heap gives a streaming "top-kk largest" selector: keep a heap of size kk, and for each new value, push it and pop the minimum once the heap exceeds kk. Implement this over a stream and assert it returns the same set as sorting the whole stream and taking the last kk.

Solution
python · numpy
import numpy as np
import heapq

rng = np.random.default_rng(0)
stream = rng.integers(0, 1000, size=500).tolist()
k = 10

heap = []                                  # min-heap of the k largest so far
for x in stream:
    heapq.heappush(heap, x)
    if len(heap) > k:
        heapq.heappop(heap)                # drop the smallest, keep top-k

top_k = sorted(heap)
expected = sorted(stream)[-k:]
assert top_k == expected
print("top-k via heap:", top_k)

The size-kk min-heap always holds the kk largest values seen, because its root is the smallest of those and any new value beating it evicts the root, matching the top-kk slice of the fully sorted stream at O(nlog⁡k)O(n \log k) instead of O(nlog⁡n)O(n \log n).

The next lesson examines comparison-based sorting more carefully, including the lower bound that makes nlog⁡nn \log n the limit of what is possible.


References

  1. [1]Cormen, T. H., Leiserson, C. E., Rivest, R. L., & Stein, C. (2009). Introduction to Algorithms (3rd ed.). MIT Press. Binary heaps, heapify, and heap operations (§6.1, §6.2, §6.3).↩
  2. [2]Cormen, T. H., Leiserson, C. E., Rivest, R. L., & Stein, C. (2009). Introduction to Algorithms (3rd ed.). MIT Press. Heapsort and priority queues (§6.4, §6.5).↩