Algorithm Search⌘K

Zip tree in Python

A randomized BST with random ranks where insertion and deletion are simple zip and unzip operations on sorted lists.

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

from __future__ import annotations

import random
from typing import Generic, Iterator, Protocol, TypeVar


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


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


class Node(Generic[K]):
    __slots__ = ("key", "rank", "left", "right")

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


def random_rank(rng: random.Random) -> int:
    """Return k with probability 1 / 2^(k+1): the number of heads before
    the first tail."""
    k = 0
    while rng.random() < 0.5:
        k += 1
    return k


def unzip(t: Node[K] | None, k: K) -> tuple[Node[K] | None, Node[K] | None]:
    """Cut the search path of k in t into the nodes with keys < k and the
    nodes with keys > k (k is not in t). O(path length)."""
    p_root: Node[K] | None = None  # @line L5
    q_root: Node[K] | None = None
    p_last: Node[K] | None = None  # bottom of the right spine of P
    q_last: Node[K] | None = None  # bottom of the left spine of Q
    while t is not None:  # @line L6
        if t.key < k:
            if p_last is None:
                p_root = t
            else:
                p_last.right = t
            p_last, t = t, t.right
        else:
            if q_last is None:
                q_root = t
            else:
                q_last.left = t
            q_last, t = t, t.left
    if p_last is not None:  # @line L7
        p_last.right = None
    if q_last is not None:
        q_last.left = None
    return p_root, q_root


def zip_trees(a: Node[K] | None, b: Node[K] | None) -> Node[K] | None:
    """Join a and b, where every key of a is less than every key of b, by
    zipping the right spine of a with the left spine of b. O(spine length)."""
    root: Node[K] | None = None
    hook: Node[K] | None = None  # last node hung
    hook_right = False  # hang the next node as its right child?
    while a is not None and b is not None:  # @line L9
        if a.rank >= b.rank:  # on a tie the smaller key stays on top
            nxt, a, side = a, a.right, True
        else:
            nxt, b, side = b, b.left, False
        if hook is None:
            root = nxt
        elif hook_right:
            hook.right = nxt
        else:
            hook.left = nxt
        hook, hook_right = nxt, side
    rest = a if a is not None else b  # @line L10
    if hook is None:
        return rest
    if hook_right:
        hook.right = rest
    else:
        hook.left = rest
    return root


class ZipTree(Generic[K]):
    """An ordered set kept as a zip tree (Tarjan, Levy and Timmel 2019).

    Each node has a random geometric rank; ranks are in max-heap order, and
    a child may have its parent's rank only if it is a right child.
    Operations take O(log n) expected time.
    """

    def __init__(self, rng: random.Random | None = None) -> None:
        self.root: Node[K] | None = None
        self.rng = rng if rng is not None else random.Random()
        self.n = 0

    def contains(self, k: K) -> bool:
        t = self.root
        while t is not None:
            if k < t.key:
                t = t.left
            elif t.key < k:
                t = t.right
            else:
                return True
        return False

    def insert(self, k: K, rank: int | None = None) -> bool:
        """Add k; return False if it was already present."""
        if self.contains(k):  # @line L1
            return False
        x = Node(k, random_rank(self.rng) if rank is None else rank)
        parent: Node[K] | None = None
        cur = self.root
        while cur is not None and (  # @line L2
            cur.rank > x.rank or (cur.rank == x.rank and cur.key < k)
        ):
            parent = cur
            cur = cur.left if k < cur.key else cur.right
        x.left, x.right = unzip(cur, k)  # @line L3
        if parent is None:  # @line L4
            self.root = x
        elif k < parent.key:
            parent.left = x
        else:
            parent.right = x
        self.n += 1
        return True

    def delete(self, k: K) -> bool:
        """Remove k; return False if it was absent."""
        parent: Node[K] | None = None
        x = self.root
        while x is not None and (k < x.key or x.key < k):  # @line L8
            parent = x
            x = x.left if k < x.key else x.right
        if x is None:
            return False
        c = zip_trees(x.left, x.right)
        if parent is None:
            self.root = c
        elif parent.left is x:
            parent.left = c
        else:
            parent.right = c
        self.n -= 1
        return True

    def __contains__(self, k: K) -> bool:
        return self.contains(k)

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

    def __iter__(self) -> Iterator[K]:
        stack: list[Node[K]] = []
        t = self.root
        while stack or t is not None:
            while t is not None:
                stack.append(t)
                t = t.left
            t = stack.pop()
            yield t.key
            t = t.right

    def keys(self) -> list[K]:
        return list(self)

    def height(self) -> int:
        best = -1
        stack: list[tuple[Node[K] | None, int]] = [(self.root, 0)]
        while stack:
            x, d = stack.pop()
            if x is not None:
                best = max(best, d)
                stack.append((x.left, d + 1))
                stack.append((x.right, d + 1))
        return best

    def check_invariant(self) -> None:
        """Assert search order and the rank rule."""
        def go(t: Node[K] | None) -> int:
            if t is None:
                return 0
            if t.left is not None:
                assert t.left.key < t.key and t.left.rank < t.rank, "left child"
            if t.right is not None:
                assert t.key < t.right.key and t.right.rank <= t.rank, "right child"
            return 1 + go(t.left) + go(t.right)
        assert go(self.root) == self.n, "count"
        ks = self.keys()
        assert all(a < b for a, b in zip(ks, ks[1:])), "order"
test_zip_tree.py 104 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/zip-tree/. The authors are listed in prov/4VXZ7W.jsonl.

import math
import random
from collections import Counter

from zip_tree import Node, ZipTree, random_rank, unzip, zip_trees

RANKS = [(10, 0), (20, 2), (30, 0), (40, 1), (50, 3), (60, 0), (70, 1)]


def shape(t):
    if t is None:
        return None
    if t.left is None and t.right is None:
        return t.key
    return (shape(t.left), t.key, shape(t.right))


def page_tree():
    z = ZipTree()
    for k, r in RANKS:
        z.insert(k, r)
    return z


def test_page_example():
    z = page_tree()
    assert shape(z.root) == ((10, 20, (30, 40, None)), 50, (60, 70, None))
    z.insert(35, 2)  # 20 has the same rank and a smaller key, so 35 goes below it
    assert shape(z.root) == ((10, 20, (30, 35, 40)), 50, (60, 70, None))
    z.delete(50)  # zip the spines 20, 35, 40 and 70
    assert shape(z.root) == (10, 20, (30, 35, (None, 40, (60, 70, None))))
    z.check_invariant()


def test_shape_depends_only_on_keys_and_ranks():
    rng = random.Random(7)
    for _ in range(50):
        pairs = [(k, random_rank(rng)) for k in rng.sample(range(100), 30)]
        a, b = ZipTree(), ZipTree()
        for k, r in pairs:
            a.insert(k, r)
        for k, r in sorted(pairs, reverse=True):
            b.insert(k, r)
        assert shape(a.root) == shape(b.root)


def test_unzip_and_zip_are_inverse():
    z = page_tree()
    p, q = unzip(z.root, 45)
    assert shape(p) == (10, 20, (30, 40, None)) and shape(q) == (None, 50, (60, 70, None))
    assert shape(zip_trees(p, q)) == ((10, 20, (30, 40, None)), 50, (60, 70, None))
    assert unzip(None, 3) == (None, None) and zip_trees(None, None) is None
    n = Node(5, 0)
    assert zip_trees(n, None) is n and zip_trees(None, n) is n


def test_edge_cases_and_rank_distribution():
    z = ZipTree(random.Random(1))
    assert not z.delete(1) and z.height() == -1
    assert z.insert(1) and not z.insert(1) and z.delete(1) and z.root is None
    rng = random.Random(2019)
    c = Counter(random_rank(rng) for _ in range(40000))
    for k in range(4):
        assert abs(c[k] / 40000 - 0.5 ** (k + 1)) < 0.01


def test_expected_depth():
    rng = random.Random(2021)
    n = 1 << 13
    z = ZipTree(rng)
    keys = list(range(n))
    rng.shuffle(keys)
    for k in keys:
        z.insert(k)
    z.check_invariant()
    total, stack = 0, [(z.root, 0)]
    while stack:
        x, d = stack.pop()
        if x is not None:
            total += d
            stack += [(x.left, d + 1), (x.right, d + 1)]
    assert total / n <= 1.5 * math.log2(n) + 2


def test_random_against_set():
    rng = random.Random(1990)
    for case in range(600):
        z = ZipTree(random.Random(case))
        ref = set()
        for _ in range(rng.randint(0, 80)):
            k = rng.randint(0, 40)
            if rng.random() < 0.6:
                assert z.insert(k) == (k not in ref)
                ref.add(k)
            else:
                assert z.delete(k) == (k in ref)
                ref.discard(k)
            z.check_invariant()
        assert z.keys() == sorted(ref)
        for k in range(-1, 42):
            assert (k in z) == (k in ref)

What the next level needs

  • the tests did not run in this build

Read the full page about Zip tree