Heaps and priority queues
Implement a binary heap and use it for priority queues and heapsort.
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 and remove it in [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 its children. The minimum sits at the root. A max-heap is the mirror: every parent 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 has
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 .
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 .
Operations
- Peek minimum: read
heap[0], . - Insert: append at the end, sift up. .
- Pop minimum: swap root with the last element, shrink the array, sift down the new root. .
Building a heap
Given an unsorted array of items, calling insert times costs .
Heapify is faster: start from the last non-leaf and sift down each node up to the
root. The total work telescopes to , because most nodes are near the leaves
where sift down is cheap, and only a few near the root require work. So an
array becomes a heap in linear time.
Heapsort
Heapify the array in , then repeatedly swap the root (the current minimum) with the last element and sift down on the reduced heap. Each pop is , so total work is . Heapsort is in-place (no auxiliary array) and worst-case , which is what makes it interesting compared to quicksort.
Check it on arrays
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 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 holds 5 > 3, so swap: [3, 5].
Step 2. Insert 8 at index 2; parent index 0 holds , no swap: [3, 5, 8]. Insert 1 at index 3; parent index 1 holds , swap to [3, 1, 8, 5];
new index 1, parent index 0 holds , swap: [1, 3, 8, 5].
Step 3. Insert 9 at index 4; parent index 1 holds , no swap: [1, 3, 8, 5, 9]. Insert 2 at index 5; parent index holds
, swap to [1, 3, 2, 5, 9, 8]; new index 2, parent index 0 holds
, stop.
Final array: [1, 3, 2, 5, 9, 8]. Check parents: index 0 ( children 3, 2);
index 1 ( children 5, 9); index 2 ( 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 , not . Use the fact that a complete tree of nodes has at most nodes at height .
Solution
Step 1. Sift-down on a node at height costs , since it can descend at most levels. Summing over all nodes, the total work is bounded by .
Step 2. Substitute the node count bound :
Step 3. The series converges to 2 (a standard arithmetico-geometric sum). So the total work is at most , giving . Most nodes sit near the leaves where 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- largest" selector: keep a heap of size , and for each new value, push it and pop the minimum once the heap exceeds . Implement this over a stream and assert it returns the same set as sorting the whole stream and taking the last .
Solution
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- min-heap always holds the largest values seen, because its root is the smallest of those and any new value beating it evicts the root, matching the top- slice of the fully sorted stream at instead of .
The next lesson examines comparison-based sorting more carefully, including the lower bound that makes the limit of what is possible.
References
- [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]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).↩