../

Heaps

heapq in Python 3.14: the heap model on a plain list, max-heaps (new functions in 3.14), priorities and tiebreakers, updating and removing without decrease-key, top k and merging, two heaps, costs, the alternatives, and the priority-queue idioms DS&A solutions are built from. How a binary heap works inside is in Heaps; graphs that use heaps are in Graphs.

The heapq model

heapq turns a plain list into a binary min-heap: for every index k, h[k] <= h[2*k+1] and h[k] <= h[2*k+2]. Only h[0], the smallest item, has a known place; the rest of the list is not sorted. There is no heap class: you pass the list to each function.

CallDoesNotes
heappush(h, x)add xO(log n)
heappop(h)remove and return the smallestO(log n); IndexError: index out of range when empty
h[0]peek at the smallestO(1); IndexError when empty, so test if h: first
heapify(xs)rearrange a list into a heap, in placeO(n), cheaper than n pushes; returns None
heappushpop(h, x)push x, then pop the smallestreturns x untouched when x <= h[0]; works on an empty heap
heapreplace(h, x)pop the smallest, then push xmay return an item larger than x; IndexError on an empty heap; the size stays fixed
nsmallest(k, it, key=f), nlargest(k, it, key=f)the k smallest or largest, as a sorted listany iterable; doesn't change it
merge(*its, key=f, reverse=False)one sorted iterator over already-sorted inputslazy: holds one item per input
import heapq
 
h = [5, 1, 8, 3]
heapq.heapify(h)  # in place, O(n)
h[0]  # 1: the smallest is always at index 0
heapq.heappush(h, 0)
heapq.heappop(h), heapq.heappop(h)  # (0, 1)
sorted(h)  # [3, 5, 8]: the heap itself isn't sorted
 
g = [2, 7]
heapq.heappushpop(g, 1)  # 1: smaller than g[0], g unchanged
heapq.heapreplace(g, 1)  # 2: pops first, then pushes 1
sorted(g)  # [1, 7]
[0] [1] [2] [3] [4] [5] [6] 1 1 3 3 2 2 7 7 5 5 4 4 1 1 3 3 2 2 7 7 5 5 4 4 0 0 1 1 3 3 0 0 7 7 5 5 4 4 2 2 0 0 3 3 1 1 7 7 5 5 4 4 2 2 2 2 3 3 1 1 7 7 5 5 4 4 1 1 3 3 2 2 7 7 5 5 4 4 1 1 3 3 2 2 7 7 5 5 4 4 h = pushed 0 popped 0 moving item compared with settled children of i: 2i+1, 2i+2 parent: (i - 1) // 2

1/7

h = [1, 3, 2, 7, 5, 4] is a heap: every parent is at most its children, and the children of index i sit at 2i + 1 and 2i + 2.

Max-heaps

VersionMax-heapNotes
3.14+heapify_max, heappush_max, heappop_max, heappushpop_max, heapreplace_maxh[0] is the largest; works for any items with <, strings included
anypush -x, negate again on popnumbers only
anytuples with negated numeric fields: (-count, word)the other fields stay ascending
anya wrapper class whose __lt__ is reversedfor strings and objects before 3.14
import heapq
from dataclasses import dataclass
 
scores = [70, 95, 82]
heapq.heapify_max(scores)  # 3.14+
heapq.heappush_max(scores, 88)
heapq.heappop_max(scores), scores[0]  # (95, 88)
 
neg = [-s for s in [70, 95, 82]]  # before 3.14
heapq.heapify(neg)
-heapq.heappop(neg)  # 95
 
words = ["pear", "apple", "fig"]
heapq.heapify_max(words)  # strings can't be negated
heapq.heappop_max(words)  # 'pear'
 
 
@dataclass
class Rev:  # max-heap of strings before 3.14
    s: str
 
    def __lt__(self, other: Rev) -> bool:
        return self.s > other.s
 
 
r = [Rev(w) for w in ["pear", "apple", "fig"]]
heapq.heapify(r)
heapq.heappop(r).s  # 'pear'

Use one family per list: a _max function on a min-heap (or the reverse) silently breaks it.

Priorities & tie-breaks

Heap entries are usually tuples, compared field by field (see Comparing & sorting). When two priorities tie, the next field decides, so whatever follows the priority must be comparable, or never reached.

EntryPops in order ofRisk
(prio, item)priority, then itema tie compares the items: TypeError for dicts and most objects
(prio, count, item)priority, then insertion ordernone: counts never tie
(prio, -count, item)priority, then newest firstnone
@dataclass(order=True) with item: T = field(compare=False)the compared fields onlyties have no defined order unless a counter field is compared too
import heapq
from dataclasses import dataclass, field
from itertools import count
 
h: list[tuple[int, dict[str, int]]] = []
heapq.heappush(h, (1, {"id": 1}))
try:
    heapq.heappush(h, (1, {"id": 2}))  # tie on 1
except TypeError as e:
    print(e)
# '<' not supported between instances of 'dict' and 'dict'
 
tick = count()  # a unique, increasing tiebreaker
q: list[tuple[int, int, str]] = []
for prio, job in [(2, "b"), (1, "a"), (2, "c")]:
    heapq.heappush(q, (prio, next(tick), job))
[heapq.heappop(q)[2] for _ in range(3)]  # ['a', 'b', 'c']
 
 
@dataclass(order=True)
class Task:
    prio: int
    name: str = field(compare=False)  # never compared
 
 
t = [Task(3, "ship"), Task(1, "plan"), Task(1, "spec")]
heapq.heapify(t)
heapq.heappop(t).prio  # 1: the names were never compared

heapq is not stable: without a counter, items with equal priorities come out in no promised order.

Updating & removing

A heap can't find an item or change a priority in place, so there is no decrease-key. Leave the old entry where it is and deal with it when it reaches the top.

NeedDoCost
lower or raise a prioritypush a new entry; when popping, skip entries that no longer matchO(log n) push; at most one extra entry per update
delete an itemmark it (a set of removed keys, or a flag on the entry) and skip it when poppedO(1) to mark
delete for realh.remove(x), then heapify(h)O(n)
change many prioritiesrebuild the list and heapify itO(n)
import heapq
 
best = {"a": 5, "b": 3}  # the current priority of each key
h = [(5, "a"), (3, "b")]
heapq.heapify(h)
best["a"] = 1  # "decrease key": record it...
heapq.heappush(h, (1, "a"))  # ...and push a fresh entry
order: list[tuple[str, int]] = []
while h:
    prio, key = heapq.heappop(h)
    if prio != best[key]:
        continue  # stale: a newer entry already came out
    order.append((key, prio))
print(order)  # [('a', 1), ('b', 3)]

Dijkstra skips stale entries the same way (if d > dist[u]: continue). The heapq docs show a complete updatable queue with an entry map and a removal marker; a typed version is in Recipes. Don't reach for the underscored helpers such as heapq._siftup: they are private and can change between versions.

Top k & merging

NeedUseWhy
the k smallest or largest, k much smaller than nnsmallest(k, xs, key=f), nlargest(k, xs, key=f)O(n log k)
k close to nsorted(xs)[:k]the docs recommend sorted for larger k
k == 1min(xs), max(xs)the docs recommend these for one item
the k largest of a streama size-k min-heap, fed with heappushpopO(log k) per item, O(k) memory
one sorted stream from k sorted onesheapq.merge(*its)lazy; with reverse=True the inputs must be sorted largest first

On 100,000 random floats (3.14), nlargest(10, xs) ran about 17× faster than sorted(xs, reverse=True)[:10], about 5× faster at k = 1000, and about 4× slower at k = 50000.

import heapq
from collections.abc import Iterable
 
 
def top_k(stream: Iterable[int], k: int) -> list[int]:
    h: list[int] = []  # min-heap of the k best so far
    for x in stream:
        if len(h) < k:
            heapq.heappush(h, x)
        else:  # O(1) when x can't beat h[0]
            heapq.heappushpop(h, x)
    return sorted(h, reverse=True)
 
 
top_k([5, 1, 9, 3, 7, 9, 2], 3)  # [9, 9, 7]
list(heapq.merge([1, 4, 9], [2, 3, 10], [5]))
# [1, 2, 3, 4, 5, 9, 10]
heapq.nlargest(2, ["aa", "b", "cccc"], key=len)
# ['cccc', 'aa']
stream 5 1 9 3 7 9 2 item item item item item item item h = [0] [1] [2] h[0]: the weakest of the k kept 5 heappush(h, 5) 1 5 heappush(h, 1) 1 5 9 heappush(h, 9) 3 5 9 1 evicted heappushpop(h, 3) 5 7 9 3 evicted heappushpop(h, 7) 7 9 9 5 evicted heappushpop(h, 9) 7 9 9 2 rejected, O(1) heappushpop(h, 2) 7 9 9 sorted(h, reverse=True) → [9, 9, 7] new item popped out result

1/8

Item 5: the heap holds fewer than k = 3 items, so heappush adds it. h = [5].

The hand-rolled k-way merge over linked lists is in Merge k sorted lists, and the k-th smallest with a heap in K-th smallest with a heap.

Two heaps

Two heaps keep a moving boundary: a max-heap holds everything below it and a min-heap everything above it, so both items next to the boundary are O(1) to read. The running median is the classic case: keep len(lo) equal to len(hi) or one more.

import heapq
 
 
class Median:
    def __init__(self) -> None:
        self.lo: list[int] = []  # max-heap: the smaller half
        self.hi: list[int] = []  # min-heap: the larger half
 
    def add(self, x: int) -> None:
        heapq.heappush_max(self.lo, x)  # 3.14+
        heapq.heappush(self.hi, heapq.heappop_max(self.lo))
        if len(self.hi) > len(self.lo):
            back = heapq.heappop(self.hi)
            heapq.heappush_max(self.lo, back)
 
    def median(self) -> float:
        if len(self.lo) > len(self.hi):
            return self.lo[0]
        return (self.lo[0] + self.hi[0]) / 2
 
 
m = Median()
out: list[float] = []
for x in [5, 15, 1, 3]:
    m.add(x)
    out.append(m.median())
print(out)  # [5, 10.0, 5, 4.0]
lo: max-heap, smaller half hi: min-heap, larger half boundary [0] [0] [1] [1] [2] [2] add(5) 5 → lo, then lo's largest (5) → hi [] (empty) 5 len(lo) = 0 len(hi) = 1 len(hi) > len(lo): rebalance next medians so far: [] add(5) hi is bigger: its smallest (5) → lo 5 [] (empty) len(lo) = 1 len(hi) = 0 median = lo[0] = 5 medians so far: [5] add(15) 15 → lo, then lo's largest (15) → hi 5 15 len(lo) = 1 len(hi) = 1 median = (5 + 15) / 2 = 10.0 medians so far: [5, 10.0] add(1) 1 → lo, then lo's largest (5) → hi 1 5 15 len(lo) = 1 len(hi) = 2 len(hi) > len(lo): rebalance next medians so far: [5, 10.0] add(1) hi is bigger: its smallest (5) → lo 5 1 15 len(lo) = 2 len(hi) = 1 median = lo[0] = 5 medians so far: [5, 10.0, 5] add(3) 3 → lo, then lo's largest (5) → hi 3 1 5 15 len(lo) = 2 len(hi) = 2 median = (3 + 5) / 2 = 4.0 medians so far: [5, 10.0, 5, 4.0] just moved

1/6

add(5): 5 goes into lo, and lo's largest item, 5, moves straight on into hi. Routing every new item through lo keeps everything in lo at most everything in hi.

Every add routes the new value through lo into hi, so every item in lo stays at most every item in hi; the last step rebalances the sizes. Before 3.14, store -x in lo and negate on the way out. The same shape answers "maximize capital" (a pool of locked projects ordered by cost, a max-heap of affordable profits; see Recipes).

Costs

OperationCostNotes
h[0]O(1)
heappush, heappop, heapreplaceO(log n)
heappushpop(h, x)O(log n)O(1) when x <= h[0]: it returns x without touching the heap
heapify(xs)O(n)n separate pushes cost O(n log n)
nsmallest(k, xs), nlargest(k, xs)O(n log k)
merge(*its) over k inputsO(log k) per itemO(k) memory
x in h, h.remove(x)O(n)remove must be followed by heapify: O(n)
sorted(h)O(n log n)a heap is not a sorted list
n pops in a row (heapsort)O(n log n)not stable

On 100,000 random floats, pushing them all and popping them all took 0.045 s with heapq, 0.125 s with queue.PriorityQueue (about 2.8× slower) and 1.27 s with bisect.insort plus pop(0).

Alternatives

UseWhenNotes
heapq on a listthe default for any priority queuethe push and pop functions run in C; not thread-safe
queue.PriorityQueueseveral threads share the queuelocks on every call: about 2.8× slower in the run above
a sorted list with bisect.insortyou also need ranks or removal by value, and n is smallO(n) per insert: 28× slower than heapq at 100,000 items
SortedList from sortedcontainers (third-party, PyPI)many inserts, removals and rank or range querieskeeps everything sorted, not just the minimum
Counter(xs).most_common(k)the k most frequent itemscalls heapq.nlargest under the hood when k is given
a monotonic collections.dequethe max or min of a sliding windowO(n) for the whole scan; a heap needs O(n log n) and lazy deletion
min(xs), max(xs)one extreme, onceO(n), no heap needed

DS&A idioms

The heap moves problem solutions are built from, and the trap in each. The patterns are in the DS&A sheets (linked in the last column).

JobPythonWatch outMore
k-th largestnlargest(k, xs)[-1], or h[0] of a size-k min-heapa min-heap keeps the k largest: the weakest sits on top, ready to evictTop-k
Top k frequentCounter(xs).most_common(k)ties come out in first-seen orderTop k frequent
k-way mergeheapq.merge(*lists), or (value, i, j) entriesthe index i stops comparisons reaching payloads such as list nodesMerge k sorted lists
Dijkstra, Prim(dist, node) entries; continue when d > dist[node]no decrease-key: stale entries stay until poppedShortest paths
Meeting roomsa min-heap of end times; heapreplace when the earliest room is freesort the meetings by start firstIntervals
Task scheduler, reorganize stringa max-heap of remaining counts; hold the last-used item out for a roundpush it back only after the next pickReorganize string
k closest pointsnsmallest(k, pts, key=lambda p: p[0]**2 + p[1]**2)compare squared distances: no sqrt, no float errorTop-k
Running medianmax-heap of the low half, min-heap of the high halfkeep len(lo) - len(hi) at 0 or 1Two heaps
Unlock-then-picklocked items ordered by cost, a max-heap of unlocked valuesmove items across as the budget growsMaximize capital
Lazy deletiona set (or a Counter, with duplicates) of removed items; pop while h[0] is markedthe heap keeps dead entries until they reach the topUpdating & removing
Sliding window maxa monotonic deque of indexesa heap works too, with lazy deletion, but costs O(log n) per stepMonotonic stack & deque
Objects in a heap(prio, count, obj) or @dataclass(order=True)type checkers accept (int, dict) tuples; the tie fails at runtimePriorities & tie-breaks
import heapq
from collections import Counter
 
nums = [3, 2, 1, 5, 6, 4]
heapq.nlargest(2, nums)[-1]  # 5: the 2nd largest
 
pts = [(1, 3), (-2, 2), (5, 8), (0, 1)]
heapq.nsmallest(2, pts, key=lambda p: p[0] ** 2 + p[1] ** 2)
# [(0, 1), (-2, 2)]
Counter("aaabbc").most_common(2)  # [('a', 3), ('b', 2)]

Typing

AnnotationFor
list[int], list[tuple[int, int, str]]a heap is just a list
a type alias such as type Entry = tuple[int, int, str]naming the entry shape once
@dataclass(order=True)entries with named fields; field(compare=False) for the payload
import heapq
 
type Entry = tuple[int, int, str]  # (prio, count, name)
q: list[Entry] = []
heapq.heappush(q, (1, 0, "plan"))
 
bad: list[dict[str, int]] = []
heapq.heappush(bad, {"a": 1})  # ❌ type error: dicts lack <

The stubs require items that support <, so both mypy and basedpyright reject a heap of dicts. They can't see ties, though: list[tuple[int, dict[str, int]]] passes type checking and still raises TypeError at runtime when two priorities are equal.

Coming from JavaScript

The TypeScript version of this sheet, with a typed heap class, is Heaps.

JavaScriptPython
no built-in heap or priority queueheapq functions on a plain list
xs.push(x) then xs.sort((a, b) => a - b) on every insertheappush(h, x): O(log n) instead of O(n log n)
a comparator (a, b) => a.prio - b.priotuples (prio, count, item), or key= on nsmallest, nlargest and merge
a hand-written MinHeap classnot needed: heapq ships with Python and its core functions run in C
a max-heap from a reversed comparatorthe 3.14 _max functions, or negated numbers
heap.peek()h[0]
heap.sizelen(h)

Recipes

Meeting rooms II

When counting how many rooms (or machines, or trains) must run at once.

import heapq
 
 
def min_rooms(meetings: list[tuple[int, int]]) -> int:
    ends: list[int] = []  # end times of rooms in use
    for start, end in sorted(meetings):
        if ends and ends[0] <= start:
            heapq.heapreplace(ends, end)  # reuse that room
        else:
            heapq.heappush(ends, end)
    return len(ends)
 
 
print(min_rooms([(0, 30), (5, 10), (15, 20)]))  # 2
print(min_rooms([(7, 10), (2, 4)]))  # 1

Kth largest in a stream

When numbers keep arriving and you need the k-th largest after each one.

import heapq
 
 
class KthLargest:
    def __init__(self, k: int, nums: list[int]) -> None:
        self.k = k
        self.h = heapq.nlargest(k, nums)
        heapq.heapify(self.h)  # a min-heap of the k largest
 
    def add(self, x: int) -> int:
        if len(self.h) < self.k:
            heapq.heappush(self.h, x)
        else:
            heapq.heappushpop(self.h, x)
        return self.h[0]
 
 
s = KthLargest(3, [4, 5, 8, 2])
print([s.add(x) for x in [3, 5, 10, 9, 4]])
# [4, 5, 5, 8, 8]

Reorganize string

When no two equal items may be adjacent: always place the most frequent remaining item, but never the one just placed.

import heapq
from collections import Counter
 
 
def reorganize(s: str) -> str:
    h = [(-n, ch) for ch, n in Counter(s).items()]
    heapq.heapify(h)  # a max-heap by count, via negation
    out: list[str] = []
    held: tuple[int, str] | None = None  # used last round
    while h:
        n, ch = heapq.heappop(h)
        out.append(ch)
        if held:
            heapq.heappush(h, held)
        held = (n + 1, ch) if n + 1 < 0 else None
    return "".join(out) if len(out) == len(s) else ""
 
 
print(reorganize("aab"), repr(reorganize("aaab")))  # aba ''

Task Scheduler is the same loop with a cooldown of n rounds: hold items in a deque of (ready_time, entry) instead of a single slot.

Updatable priority queue

When priorities change or items get canceled: the heapq docs' entry-map pattern, typed.

import heapq
from dataclasses import dataclass, field
from itertools import count
 
 
@dataclass(order=True)
class Entry:
    prio: int
    seq: int
    task: str = field(compare=False)
    alive: bool = field(default=True, compare=False)
 
 
class PQ:
    def __init__(self) -> None:
        self.h: list[Entry] = []
        self.live: dict[str, Entry] = {}
        self.seq = count()
 
    def push(self, task: str, prio: int) -> None:
        if task in self.live:  # update = remove + add
            self.remove(task)
        entry = Entry(prio, next(self.seq), task)
        self.live[task] = entry
        heapq.heappush(self.h, entry)
 
    def remove(self, task: str) -> None:
        self.live.pop(task).alive = False  # lazy
 
    def pop(self) -> str:
        while self.h:
            entry = heapq.heappop(self.h)
            if entry.alive:
                del self.live[entry.task]
                return entry.task
        raise KeyError("pop from an empty priority queue")
 
 
pq = PQ()
pq.push("write", 3)
pq.push("test", 2)
pq.push("ship", 5)
pq.push("ship", 1)  # raise its priority
pq.remove("test")
print(pq.pop(), pq.pop())  # ship write

seq keeps equal priorities in insertion order, and alive marks dead entries so pop can skip them; both are cheaper than finding the old entry inside the list.

Smallest range covering k lists

When you need one value from each sorted list, as close together as possible.

import heapq
 
 
def smallest_range(lists: list[list[int]]) -> list[int]:
    h = [(xs[0], i, 0) for i, xs in enumerate(lists)]
    heapq.heapify(h)
    hi = max(xs[0] for xs in lists)  # max of current heads
    best = [h[0][0], hi]
    while True:
        lo, i, j = heapq.heappop(h)
        if hi - lo < best[1] - best[0]:
            best = [lo, hi]
        if j + 1 == len(lists[i]):
            return best  # list i is used up
        nxt = lists[i][j + 1]
        hi = max(hi, nxt)
        heapq.heappush(h, (nxt, i, j + 1))
 
 
nums = [[4, 10, 15, 24, 26], [0, 9, 12, 20], [5, 18, 22, 30]]
print(smallest_range(nums))  # [20, 24]

The heap holds one head per list, so the range is always from the heap's minimum to the largest head seen; advancing the minimum is the only move that can shrink it.

Maximize capital

When each pick unlocks more choices: the IPO problem, with k projects and starting capital w.

import heapq
 
 
def max_capital(
    k: int, w: int, profits: list[int], capital: list[int]
) -> int:
    locked = sorted(zip(capital, profits))  # by cost
    ready: list[int] = []  # max-heap of unlocked profits
    i = 0
    for _ in range(k):
        while i < len(locked) and locked[i][0] <= w:
            heapq.heappush_max(ready, locked[i][1])  # 3.14+
            i += 1
        if not ready:
            break  # nothing affordable
        w += heapq.heappop_max(ready)
    return w
 
 
print(max_capital(2, 0, [1, 2, 3], [0, 1, 1]))  # 4
print(max_capital(3, 0, [1, 2, 3], [0, 1, 2]))  # 6

Nothing new joins the locked pool, so a sorted list with a pointer stands in for its min-heap.

References