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.
| Call | Does | Notes |
|---|---|---|
heappush(h, x) | add x | O(log n) |
heappop(h) | remove and return the smallest | O(log n); IndexError: index out of range when empty |
h[0] | peek at the smallest | O(1); IndexError when empty, so test if h: first |
heapify(xs) | rearrange a list into a heap, in place | O(n), cheaper than n pushes; returns None |
heappushpop(h, x) | push x, then pop the smallest | returns x untouched when x <= h[0]; works on an empty heap |
heapreplace(h, x) | pop the smallest, then push x | may 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 list | any iterable; doesn't change it |
merge(*its, key=f, reverse=False) | one sorted iterator over already-sorted inputs | lazy: 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]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
| Version | Max-heap | Notes |
|---|---|---|
| 3.14+ | heapify_max, heappush_max, heappop_max, heappushpop_max, heapreplace_max | h[0] is the largest; works for any items with <, strings included |
| any | push -x, negate again on pop | numbers only |
| any | tuples with negated numeric fields: (-count, word) | the other fields stay ascending |
| any | a wrapper class whose __lt__ is reversed | for 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.
| Entry | Pops in order of | Risk |
|---|---|---|
(prio, item) | priority, then item | a tie compares the items: TypeError for dicts and most objects |
(prio, count, item) | priority, then insertion order | none: counts never tie |
(prio, -count, item) | priority, then newest first | none |
@dataclass(order=True) with item: T = field(compare=False) | the compared fields only | ties 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 comparedheapq 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.
| Need | Do | Cost |
|---|---|---|
| lower or raise a priority | push a new entry; when popping, skip entries that no longer match | O(log n) push; at most one extra entry per update |
| delete an item | mark it (a set of removed keys, or a flag on the entry) and skip it when popped | O(1) to mark |
| delete for real | h.remove(x), then heapify(h) | O(n) |
| change many priorities | rebuild the list and heapify it | O(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
| Need | Use | Why |
|---|---|---|
the k smallest or largest, k much smaller than n | nsmallest(k, xs, key=f), nlargest(k, xs, key=f) | O(n log k) |
k close to n | sorted(xs)[:k] | the docs recommend sorted for larger k |
k == 1 | min(xs), max(xs) | the docs recommend these for one item |
the k largest of a stream | a size-k min-heap, fed with heappushpop | O(log k) per item, O(k) memory |
one sorted stream from k sorted ones | heapq.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']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]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
| Operation | Cost | Notes |
|---|---|---|
h[0] | O(1) | |
heappush, heappop, heapreplace | O(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 inputs | O(log k) per item | O(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
| Use | When | Notes |
|---|---|---|
heapq on a list | the default for any priority queue | the push and pop functions run in C; not thread-safe |
queue.PriorityQueue | several threads share the queue | locks on every call: about 2.8× slower in the run above |
a sorted list with bisect.insort | you also need ranks or removal by value, and n is small | O(n) per insert: 28× slower than heapq at 100,000 items |
SortedList from sortedcontainers (third-party, PyPI) | many inserts, removals and rank or range queries | keeps everything sorted, not just the minimum |
Counter(xs).most_common(k) | the k most frequent items | calls heapq.nlargest under the hood when k is given |
a monotonic collections.deque | the max or min of a sliding window | O(n) for the whole scan; a heap needs O(n log n) and lazy deletion |
min(xs), max(xs) | one extreme, once | O(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).
| Job | Python | Watch out | More |
|---|---|---|---|
k-th largest | nlargest(k, xs)[-1], or h[0] of a size-k min-heap | a min-heap keeps the k largest: the weakest sits on top, ready to evict | Top-k |
Top k frequent | Counter(xs).most_common(k) | ties come out in first-seen order | Top k frequent |
k-way merge | heapq.merge(*lists), or (value, i, j) entries | the index i stops comparisons reaching payloads such as list nodes | Merge k sorted lists |
| Dijkstra, Prim | (dist, node) entries; continue when d > dist[node] | no decrease-key: stale entries stay until popped | Shortest paths |
| Meeting rooms | a min-heap of end times; heapreplace when the earliest room is free | sort the meetings by start first | Intervals |
| Task scheduler, reorganize string | a max-heap of remaining counts; hold the last-used item out for a round | push it back only after the next pick | Reorganize string |
k closest points | nsmallest(k, pts, key=lambda p: p[0]**2 + p[1]**2) | compare squared distances: no sqrt, no float error | Top-k |
| Running median | max-heap of the low half, min-heap of the high half | keep len(lo) - len(hi) at 0 or 1 | Two heaps |
| Unlock-then-pick | locked items ordered by cost, a max-heap of unlocked values | move items across as the budget grows | Maximize capital |
| Lazy deletion | a set (or a Counter, with duplicates) of removed items; pop while h[0] is marked | the heap keeps dead entries until they reach the top | Updating & removing |
| Sliding window max | a monotonic deque of indexes | a heap works too, with lazy deletion, but costs O(log n) per step | Monotonic stack & deque |
| Objects in a heap | (prio, count, obj) or @dataclass(order=True) | type checkers accept (int, dict) tuples; the tie fails at runtime | Priorities & 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
| Annotation | For |
|---|---|
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.
| JavaScript | Python |
|---|---|
| no built-in heap or priority queue | heapq functions on a plain list |
xs.push(x) then xs.sort((a, b) => a - b) on every insert | heappush(h, x): O(log n) instead of O(n log n) |
a comparator (a, b) => a.prio - b.prio | tuples (prio, count, item), or key= on nsmallest, nlargest and merge |
a hand-written MinHeap class | not needed: heapq ships with Python and its core functions run in C |
| a max-heap from a reversed comparator | the 3.14 _max functions, or negated numbers |
heap.peek() | h[0] |
heap.size | len(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)])) # 1Kth 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 writeseq 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])) # 6Nothing new joins the locked pool, so a sorted list with a pointer stands in for its min-heap.
References
- Python docs: heapq (opens in a new tab): every function, including the 3.14 max-heap ones
- Python docs: heapq, priority queue implementation notes (opens in a new tab): tiebreakers, the entry map and the removal marker
- Python docs: heapq, theory (opens in a new tab): why
heapifyis linear and how the invariant works - What's New in Python 3.14: heapq (opens in a new tab): the max-heap functions
- Python docs: queue.PriorityQueue (opens in a new tab): the thread-safe wrapper
- Python docs: dataclasses.field (opens in a new tab):
compare=Falsefor heap payloads - Python docs: Counter.most_common (opens in a new tab): top
kby count