Algorithm Search⌘K

Leftist heap in Python

Binary heap tree that keeps the right spine short, so merge runs in O(log n).

File
leftist_heap.py, 130 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
leftist_heap.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/leftist-heap/. The authors are listed in prov/Z662CN.jsonl.

from collections import deque
from typing import Any, Generic, Iterable, Iterator, Protocol, TypeVar


class Ordered(Protocol):
    def __lt__(self, other: Any, /) -> bool: ...


K = TypeVar("K", bound=Ordered)


class Node(Generic[K]):
    """A node: key, left and right subtrees, and rank s (null path length)."""

    __slots__ = ("key", "left", "right", "s")

    def __init__(self, key: K) -> None:
        self.key = key
        self.left: Node[K] | None = None
        self.right: Node[K] | None = None
        self.s = 1


def rank(v: "Node[Any] | None") -> int:
    """The rank s(v): 0 for an empty tree, else the length of the right spine."""
    return 0 if v is None else v.s


def merge(a: "Node[K] | None", b: "Node[K] | None") -> "Node[K] | None":
    """Merge two leftist heaps and return the root.

    This is the recursive MERGE of the page, done with an explicit stack: walk
    down the two right spines, then fix the ranks on the way back up.
    """
    if a is None:  # @line L1
        return b
    if b is None:
        return a
    path: list[Node[K]] = []
    while a is not None and b is not None:
        if b.key < a.key:  # @line L2
            a, b = b, a
        path.append(a)
        a = a.right  # MERGE(a.right, b) continues with this pair
    rest = a if a is not None else b
    for v in reversed(path):
        v.right = rest  # @line L3
        if rank(v.left) < rank(v.right):  # @line L4
            v.left, v.right = v.right, v.left
        v.s = rank(v.right) + 1  # @line L5
        rest = v  # @line L6
    return rest


class LeftistHeap(Generic[K]):
    """A min-heap stored as a leftist tree."""

    def __init__(self, items: Iterable[K] = ()) -> None:
        """Build a heap from items in O(n) time by merging in FIFO order."""
        q: deque[Node[K]] = deque(Node(x) for x in items)
        self.n = len(q)
        while len(q) > 1:
            q.append(merge(q.popleft(), q.popleft()))  # type: ignore[arg-type]
        self.root: Node[K] | None = q[0] if q else None

    def __len__(self) -> int:
        return self.n

    def is_empty(self) -> bool:
        """Return True if the heap holds no item."""
        return self.root is None

    def insert(self, key: K) -> None:
        """Add key to the heap."""
        self.root = merge(self.root, Node(key))  # @line L7
        self.n += 1

    def find_min(self) -> K:
        """Return a smallest key; the heap must not be empty."""
        if self.root is None:
            raise IndexError("find_min on an empty heap")
        return self.root.key

    def delete_min(self) -> K:
        """Remove and return a smallest key; the heap must not be empty."""
        r = self.root
        if r is None:
            raise IndexError("delete_min on an empty heap")
        self.root = merge(r.left, r.right)  # @line L8
        self.n -= 1
        return r.key  # @line L9

    def meld(self, other: "LeftistHeap[K]") -> None:
        """Move all items of other into this heap; other becomes empty."""
        self.root = merge(self.root, other.root)
        self.n += other.n
        other.root, other.n = None, 0

    def keys(self) -> Iterator[K]:
        """Yield all keys in preorder."""
        stack = [self.root] if self.root is not None else []
        while stack:
            v = stack.pop()
            yield v.key
            for c in (v.right, v.left):
                if c is not None:
                    stack.append(c)

    def check_invariant(self) -> None:
        """Check heap order, the leftist property, the ranks and the count."""
        count = 0
        order: list[Node[K]] = []
        stack = [self.root] if self.root is not None else []
        while stack:
            v = stack.pop()
            order.append(v)
            count += 1
            for c in (v.left, v.right):
                if c is not None:
                    assert not (c.key < v.key), "heap order"
                    stack.append(c)
        for v in reversed(order):  # children before parents
            assert rank(v.left) >= rank(v.right), "leftist property"
            assert v.s == rank(v.right) + 1, "rank"
        assert count == self.n, "count"
        if self.n > 0:
            assert rank(self.root) <= (self.n + 1).bit_length() - 1, "rank bound"
test_leftist_heap.py 103 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/leftist-heap/. The authors are listed in prov/Z662CN.jsonl.

import heapq
import random

import pytest

from leftist_heap import LeftistHeap, Node, merge


def sh(v: "Node[int] | None") -> str:
    """The tree as key(left, right), as written on the page."""
    if v is None:
        return "-"
    if v.left is None and v.right is None:
        return str(v.key)
    return f"{v.key}({sh(v.left)}, {sh(v.right)})"


def test_example_inserts() -> None:
    h: LeftistHeap[int] = LeftistHeap()
    seen = []
    for k in [5, 3, 8, 1, 7, 2]:
        h.insert(k)
        h.check_invariant()
        seen.append(sh(h.root))
    assert seen == ["5", "3(5, -)", "3(5, 8)", "1(3(5, 8), -)", "1(3(5, 8), 7)", "1(3(5, 8), 2(7, -))"]
    assert h.delete_min() == 1
    assert sh(h.root) == "2(3(5, 8), 7)"
    assert h.delete_min() == 2
    assert sh(h.root) == "3(5, 7(8, -))"
    h.check_invariant()


def test_example_meld() -> None:
    x: LeftistHeap[int] = LeftistHeap()
    for k in [1, 3, 8, 5, 7, 2]:
        x.insert(k)
    y: LeftistHeap[int] = LeftistHeap()
    for k in [4, 6, 9, 10]:
        y.insert(k)
    assert sh(x.root) == "1(5(8, 7), 2(3, -))"
    assert sh(y.root) == "4(6, 9(10, -))"
    x.meld(y)
    x.check_invariant()
    assert sh(x.root) == "1(5(8, 7), 2(4(6, 9(10, -)), 3))"
    assert x.root is not None and x.root.s == 3
    assert len(x) == 10 and len(y) == 0 and y.is_empty()


def test_empty() -> None:
    h: LeftistHeap[int] = LeftistHeap()
    h.check_invariant()
    assert h.is_empty() and len(h) == 0
    assert merge(None, None) is None
    with pytest.raises(IndexError):
        h.delete_min()
    with pytest.raises(IndexError):
        h.find_min()


def test_duplicates_and_sorted_inputs() -> None:
    for data in ([2] * 50, list(range(200)), list(range(200, 0, -1))):
        h = LeftistHeap(data)
        h.check_invariant()
        out = [h.delete_min() for _ in range(len(data))]
        assert out == sorted(data)


def test_increasing_inserts_keep_spine_short() -> None:
    h: LeftistHeap[int] = LeftistHeap()
    for k in range(10000):
        h.insert(k)
    h.check_invariant()
    assert h.root is not None and h.root.s <= 13


def test_random_against_heapq() -> None:
    rng = random.Random(20240611)
    for _ in range(600):
        heaps = [LeftistHeap[int]() for _ in range(3)]
        refs: list[list[int]] = [[] for _ in range(3)]
        for _ in range(rng.randrange(1, 60)):
            i = rng.randrange(3)
            op = rng.random()
            if op < 0.55:
                k = rng.randrange(-20, 20)
                heaps[i].insert(k)
                heapq.heappush(refs[i], k)
            elif op < 0.85:
                if refs[i]:
                    assert heaps[i].find_min() == refs[i][0]
                    assert heaps[i].delete_min() == heapq.heappop(refs[i])
            else:
                j = rng.randrange(3)
                if i != j:
                    heaps[i].meld(heaps[j])
                    refs[i] = refs[i] + refs[j]
                    heapq.heapify(refs[i])
                    refs[j] = []
            heaps[i].check_invariant()
            assert sorted(heaps[i].keys()) == sorted(refs[i])

What the next level needs

  • the tests did not run in this build

Read the full page about Leftist heap