Heaps & Priority Queue

A heap is a tree-shaped structure built for one job: instantly knowing the smallest (or largest) element in a collection, even as elements are added and removed. Unlike a BST, it makes no promise about ordering elements relative to each other in general — only about which single element sits on top. That narrower promise is exactly what makes heap operations fast and predictable. Python's heapq module gives you this directly on a plain list — with one API quirk that catches almost everyone the first time: it only ever implements a min-heap.

Min-Heap vs Max-Heap

A min-heap keeps the smallest element at the root — every parent is smaller than or equal to both its children. A max-heap is the mirror image — every parent is bigger than or equal to both its children. Note this is a much weaker rule than the BST invariant: a heap says nothing about whether a node's left subtree is smaller than its right subtree, only about parent vs. child.

This is a "complete binary tree" rule, not a full ordering. A min-heap guarantees the root is the overall minimum, but the second-smallest element could be anywhere in the tree — not necessarily the root's direct child. That's the trade-off for O(log n) insert/extract instead of BST's "average O(log n), worst O(n)."

Python's heapq Module — Min-Heap Only, By Design

heapq operates directly on a regular Python list, treating it as a binary heap using the same array index arithmetic every heap uses (parent = (i - 1) // 2, left = 2*i + 1, right = 2*i + 2) — there's no separate heap class to instantiate. And critically: every function in heapq maintains the min-heap property only. There is no max_heapq and no flag to flip it.

import heapq

heap = []
heapq.heappush(heap, 5)
heapq.heappush(heap, 1)
heapq.heappush(heap, 3)
print(heapq.heappop(heap))  # 1 -- the smallest

Time complexity: O(log n) for both heappush (insert) and heappop (extract-min). Space complexity: O(n) to hold n elements.

The gotcha: no built-in max-heap. If you need the largest element on top, the standard trick is to negate every value on the way in and negate it again on the way out — a min-heap of negated values behaves exactly like a max-heap of the originals, since the smallest negative number corresponds to the largest positive one.
import heapq

max_heap = []
for value in [5, 1, 3]:
    heapq.heappush(max_heap, -value)   # push the NEGATED value

print(-heapq.heappop(max_heap))    # 5 -- negate again on the way out to undo it

Time complexity: O(log n) — identical cost to the min-heap version; negation is O(1) extra work per push/pop. Space complexity: O(n).

Sorting non-numeric or complex data on a max-heap: the same negation trick doesn't work directly on strings or tuples of mixed types. In that case, push tuples of (priority, item) and negate only the numeric priority, e.g. heapq.heappush(heap, (-priority, item)) — heapq compares tuples element by element, so it sorts by the negated priority first.

heappush, heappop, and heapify

Besides pushing one element at a time, heapq can convert an existing list into a valid heap in-place, all at once, faster than pushing each element individually.

import heapq

# heapify: turn an existing list into a valid min-heap in-place
nums = [5, 3, 8, 1, 9, 2]
heapq.heapify(nums)
print(nums)              # [1, 3, 2, 5, 9, 8] -- valid heap order, NOT fully sorted

# heappush: add one element, re-establishing the heap property
heapq.heappush(nums, 0)
print(nums[0])          # 0 -- new minimum bubbled to the root

# heappop: remove and return the smallest element
smallest = heapq.heappop(nums)
print(smallest)        # 0

# heapq also has direct helpers for the "top K" pattern:
print(heapq.nsmallest(3, nums))  # 3 smallest values, sorted ascending
print(heapq.nlargest(3, nums))   # 3 largest values, sorted descending

Time complexity: heapify is O(n) — a common surprise, since it sounds like it should cost O(n log n) (n pushes at O(log n) each), but a bottom-up build only needs O(n) total comparisons because most nodes are near the bottom of the tree and need very few swaps. heappush/heappop are O(log n) each. Space complexity: O(1) extra for heapify (it reorders the existing list), O(n) overall to hold the heap.

Why insert/extract is O(log n): insert appends to the end of the list then "bubbles up" — swapping with its parent while it's smaller than that parent — which takes at most the tree's height, O(log n), swaps. Extract-min removes the root, moves the last element to the root, then "bubbles down" — swapping with its smaller child while it's bigger than that child — again at most O(log n) swaps.

Worked Problem: Kth Largest Element

A classic heap problem: given an unsorted list, find the Kth largest element without fully sorting the list. The elegant approach uses a min-heap of size K: keep only the K largest elements seen so far, and the smallest of those K (the heap's root) is always the Kth largest overall.

import heapq

def find_kth_largest(nums, k):
    min_heap = []

    for num in nums:
        heapq.heappush(min_heap, num)
        if len(min_heap) > k:
            heapq.heappop(min_heap)      # evict the current smallest -- it can't be a top-K element

    return min_heap[0]                  # root of the size-k min-heap = Kth largest overall

print(find_kth_largest([3, 2, 1, 5, 6, 4], 2))  # 5

Trace on nums = [3, 2, 1, 5, 6, 4], k = 2 (find the 2nd largest): push 3 → heap {3}. push 2 → heap {2,3}. push 1 → heap {1,2,3}, size > 2, pop evicts 1 → heap {2,3}. push 5 → heap {2,3,5}, size > 2, pop evicts 2 → heap {3,5}. push 6 → heap {3,5,6}, size > 2, pop evicts 3 → heap {5,6}. push 4 → heap {4,5,6}, size > 2, pop evicts 4 → heap {5,6}. Final min_heap[0] is 5 — correctly the 2nd largest value in the list (largest is 6).

Time complexity: O(n log k) — n pushes, each costing O(log k) since the heap never grows past size k (this beats the O(n log n) of fully sorting when k is small). Space complexity: O(k) — the heap never holds more than k elements at once.

Interview follow-up worth knowing: for a one-off query, heapq.nlargest(k, nums)[-1] gets the same answer more concisely, and internally uses this exact size-k-heap technique when k is small relative to n — for repeated queries as the underlying data changes, maintaining your own min-heap like above is still the right call.
Next up: Balanced Trees (Intro) looks at structures like AVL and Red-Black trees, which guarantee the O(log n) height a plain BST only gets on average — and which heaps sidestep entirely by not needing a full ordering in the first place.