Binary heap in Python
Complete binary tree stored in an array that supports insert and extract-min in O(log n).
- File
binary_heap.py, 94 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/binary-heap/. The authors are listed in prov/1QKQ6A.jsonl.
from collections.abc import Iterable
from typing import Any, Generic, Protocol, TypeVar
class Ordered(Protocol):
def __lt__(self, other: Any, /) -> bool: ...
T = TypeVar("T", bound=Ordered)
class BinaryHeap(Generic[T]):
"""A binary min-heap of keys in a 0-based Python list.
The parent of index i is (i - 1) // 2 and its children are 2i + 1 and
2i + 2. insert, extract_min and decrease_key take O(log n) time (insert
is amortized, because the list grows), find_min takes O(1) time and
build takes O(n) time. Keys need only "<".
"""
def __init__(self) -> None:
self.a: list[T] = []
def __len__(self) -> int:
return len(self.a)
def _sift_up(self, i: int) -> None:
a = self.a
while i > 0 and a[i] < a[(i - 1) // 2]: # @line L1
p = (i - 1) // 2
a[i], a[p] = a[p], a[i] # @line L2
i = p # @line L3
def _sift_down(self, n: int, i: int) -> None:
a = self.a
while True: # @line L4
m = i # @line L5
left, right = 2 * i + 1, 2 * i + 2
if left < n and a[left] < a[m]: # @line L6
m = left
if right < n and a[right] < a[m]: # @line L7
m = right
if m == i: # @line L8
return
a[i], a[m] = a[m], a[i] # @line L9
i = m # @line L10
def insert(self, k: T) -> None:
"""Add the key k. O(log n) amortized time."""
self.a.append(k) # @line L11 @line L12
self._sift_up(len(self.a) - 1) # @line L13
def find_min(self) -> T:
"""Return a minimum key. Raise IndexError on an empty heap."""
if not self.a: # @line L14
raise IndexError("heap underflow")
return self.a[0] # @line L15
def extract_min(self) -> T:
"""Remove and return a minimum key. Raise IndexError on an empty heap."""
a = self.a
if not a: # @line L16
raise IndexError("heap underflow")
smallest = a[0] # @line L17
last = a.pop() # @line L19
if a:
a[0] = last # @line L18
self._sift_down(len(a), 0) # @line L20
return smallest # @line L21
def decrease_key(self, i: int, k: T) -> None:
"""Lower the key at the 0-based index i to k. Raise ValueError if k is larger."""
if not 0 <= i < len(self.a):
raise IndexError("index out of range")
if self.a[i] < k: # @line L22
raise ValueError("new key is larger than the current key")
self.a[i] = k # @line L23
self._sift_up(i) # @line L24
def build(self, items: Iterable[T]) -> None:
"""Replace the contents by the given keys and make a heap in O(n) time."""
self.a = list(items) # @line L25
n = len(self.a)
for i in range(n // 2 - 1, -1, -1): # @line L26
self._sift_down(n, i)
def check_invariant(self) -> None:
"""Raise AssertionError if a key is smaller than its parent."""
a = self.a
for i in range(1, len(a)):
assert not a[i] < a[(i - 1) // 2], f"heap order fails at index {i}"
test_binary_heap.py 89 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/binary-heap/. The authors are listed in prov/1QKQ6A.jsonl.
import random
import pytest
from binary_heap import BinaryHeap
def test_example() -> None:
h: BinaryHeap[int] = BinaryHeap()
h.build([5, 3, 8, 1, 9, 2])
assert h.a == [1, 3, 2, 5, 9, 8]
h.insert(4)
assert h.a == [1, 3, 2, 5, 9, 8, 4]
h.insert(0)
assert h.a == [0, 1, 2, 3, 9, 8, 4, 5]
assert h.extract_min() == 0
assert h.a == [1, 3, 2, 5, 9, 8, 4]
h.decrease_key(4, 0)
assert h.a == [0, 1, 2, 5, 3, 8, 4]
assert h.extract_min() == 0
assert h.a == [1, 3, 2, 5, 4, 8]
h.check_invariant()
def test_edges() -> None:
h: BinaryHeap[int] = BinaryHeap()
with pytest.raises(IndexError):
h.find_min()
with pytest.raises(IndexError):
h.extract_min()
h.build([])
assert len(h) == 0
h.insert(7)
assert h.find_min() == 7
assert h.extract_min() == 7
assert len(h) == 0
h.build([2, 2, 2, 2])
assert [h.extract_min() for _ in range(4)] == [2, 2, 2, 2]
h.build([3, 1])
with pytest.raises(ValueError):
h.decrease_key(1, 5)
h.build([1, 2, 3])
h.decrease_key(2, 3)
h.check_invariant()
def test_sorted_and_reversed() -> None:
for keys in (list(range(50)), list(range(50, 0, -1))):
h: BinaryHeap[int] = BinaryHeap()
for k in keys:
h.insert(k)
h.check_invariant()
assert [h.extract_min() for _ in keys] == sorted(keys)
def test_random_against_model() -> None:
rng = random.Random(6121)
for _ in range(500):
h: BinaryHeap[int] = BinaryHeap()
model: list[int] = [rng.randint(-20, 20) for _ in range(rng.randint(0, 12))]
h.build(model)
h.check_invariant()
for _ in range(rng.randint(0, 40)):
op = rng.random()
if op < 0.4:
k = rng.randint(-20, 20)
h.insert(k)
model.append(k)
elif op < 0.75:
if model:
want = min(model)
assert h.find_min() == want
assert h.extract_min() == want
model.remove(want)
else:
with pytest.raises(IndexError):
h.extract_min()
elif model:
i = rng.randrange(len(h))
old = h.a[i]
k = old - rng.randint(0, 10)
h.decrease_key(i, k)
model.remove(old)
model.append(k)
h.check_invariant()
assert sorted(h.a) == sorted(model)
What the next level needs
- the tests did not run in this build