Algorithm Search⌘K

Zipper in Python

A tree or list plus a path of context that gives O(1) local navigation and update while keeping old versions.

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

"""A Huet zipper over immutable binary trees.

A tree is None (empty) or a tuple (left, value, right). A zipper is a focus
subtree plus a path of crumbs back to the root. Each crumb records the
direction taken, the value of the parent, and the sibling subtree. The path
is a persistent stack of tuples (crumb, rest). Nothing is ever mutated, so
every zipper value stays valid after any later operation.
"""

from typing import Any, Iterator, List, Optional, Tuple

Tree = Optional[Tuple[Any, Any, Any]]  # (left, value, right) or None
_LEFT, _RIGHT = 0, 1


def node(left: Tree, value: Any, right: Tree) -> Tree:
    """Make a tree node. Cost: O(1)."""
    return (left, value, right)


def inorder(t: Tree) -> List[Any]:
    """Values of t in symmetric order, iteratively. Cost: Θ(size of t)."""
    out: List[Any] = []
    stack: List[Tuple[Any, Any, Any]] = []
    while stack or t is not None:
        while t is not None:
            stack.append(t)
            t = t[0]
        top = stack.pop()
        out.append(top[1])
        t = top[2]
    return out


class Zipper:
    """An immutable cursor into a binary tree."""

    __slots__ = ("_focus", "_path", "_depth")

    def __init__(self, tree: Tree, path: Any = None, depth: int = 0) -> None:
        """A zipper whose focus is the root of ``tree``. Cost: O(1)."""
        self._focus = tree
        self._path = path  # None or ((direction, value, sibling), rest)
        self._depth = depth

    @property
    def focus(self) -> Tree:
        """The subtree at the focus. Cost: O(1)."""
        return self._focus

    @property
    def depth(self) -> int:
        """Number of crumbs on the path. Cost: O(1)."""
        return self._depth

    def at_top(self) -> bool:
        """Whether the focus is the root. Cost: O(1)."""
        return self._path is None

    def value(self) -> Any:
        """The value at the focus. Cost: O(1)."""
        if self._focus is None:
            raise ValueError("the focus is empty")
        return self._focus[1]

    def left(self) -> "Zipper":
        """Move the focus to the left child. Cost: O(1)."""
        t, p = self._focus, self._path  # @line L1
        if t is None:  # @line L2
            raise ValueError("cannot go down from an empty focus")
        l, x, r = t  # @line L3
        return Zipper(l, ((_LEFT, x, r), p), self._depth + 1)  # @line L4

    def right(self) -> "Zipper":
        """Move the focus to the right child. Cost: O(1)."""
        t, p = self._focus, self._path
        if t is None:
            raise ValueError("cannot go down from an empty focus")
        l, x, r = t  # @line L5
        return Zipper(r, ((_RIGHT, x, l), p), self._depth + 1)  # @line L6

    def up(self) -> "Zipper":
        """Move the focus to the parent, rebuilding one node. Cost: O(1)."""
        t, p = self._focus, self._path
        if p is None:  # @line L7
            raise ValueError("already at the top")
        (d, x, s), rest = p  # @line L8
        if d == _LEFT:  # @line L9
            return Zipper((t, x, s), rest, self._depth - 1)
        return Zipper((s, x, t), rest, self._depth - 1)  # @line L10

    def replace(self, t: Tree) -> "Zipper":
        """Replace the focus subtree by t. Cost: O(1)."""
        return Zipper(t, self._path, self._depth)  # @line L11

    def set_value(self, x: Any) -> "Zipper":
        """Replace the value at the focus. Cost: O(1)."""
        if self._focus is None:
            raise ValueError("the focus is empty")
        l, _, r = self._focus
        return self.replace((l, x, r))

    def top(self) -> "Zipper":
        """Move the focus to the root. Cost: Θ(1 + depth)."""
        z = self
        while not z.at_top():  # @line L12
            z = z.up()
        return z  # @line L13

    def to_tree(self) -> Tree:
        """The whole tree that this zipper represents. Cost: Θ(1 + depth)."""
        return self.top().focus

    def path_values(self) -> Iterator[Any]:
        """Values of the ancestors of the focus, nearest first. Cost: Θ(depth)."""
        p = self._path
        while p is not None:
            yield p[0][1]
            p = p[1]

    def check_invariant(self) -> None:
        """Check that the path has ``depth`` well-formed crumbs and that the
        focus and the siblings are well-formed trees. Cost: Θ(depth)."""
        p, n = self._path, 0
        while p is not None:
            crumb, p = p
            assert crumb[0] in (_LEFT, _RIGHT) and len(crumb) == 3
            n += 1
        assert n == self._depth
        assert self._focus is None or len(self._focus) == 3
test_zipper.py 112 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/zipper/. The authors are listed in prov/T3A6Y3.jsonl.

import random

import pytest

from zipper import Zipper, inorder, node


def leaf(x):
    return node(None, x, None)


def example_tree():
    return node(node(leaf(1), 2, leaf(3)), 4, node(leaf(5), 6, leaf(7)))


def test_example() -> None:
    t = example_tree()
    z = Zipper(t).left().right()
    assert z.value() == 3 and z.depth == 2 and list(z.path_values()) == [2, 4]
    z2 = z.set_value(30)
    t2 = z2.to_tree()
    assert inorder(t2) == [1, 2, 30, 4, 5, 6, 7]
    assert inorder(t) == [1, 2, 3, 4, 5, 6, 7]  # the old tree is unchanged
    assert t2[2] is t[2]  # the right subtree 6 is shared
    assert t2[0][0] is t[0][0]  # the leaf 1 is shared
    assert t2 is not t and t2[0] is not t[0]
    # Going up without a change rebuilds equal nodes.
    assert inorder(z.to_tree()) == inorder(t)
    z.check_invariant()
    z2.check_invariant()


def test_edges() -> None:
    e = Zipper(None)
    assert e.at_top() and e.to_tree() is None
    with pytest.raises(ValueError):
        e.left()
    with pytest.raises(ValueError):
        e.up()
    with pytest.raises(ValueError):
        e.value()
    z = e.replace(leaf(8))
    assert z.value() == 8
    w = z.left().replace(leaf(3)).up()
    assert inorder(w.to_tree()) == [3, 8]
    assert inorder(z.to_tree()) == [8]
    w.check_invariant()


def test_deep() -> None:
    n = 200000
    z = Zipper(None)
    for i in range(n):  # build a left spine by going down
        z = z.replace(node(None, i, None)).left()
    assert z.depth == n
    t = z.to_tree()  # iterative top
    assert inorder(t)[:3] == [n - 1, n - 2, n - 3]
    assert len(inorder(t)) == n
    z.check_invariant()


def random_tree(rng, size):
    if size == 0:
        return None
    k = rng.randrange(size)
    return node(random_tree(rng, k), rng.randint(0, 99), random_tree(rng, size - 1 - k))


def test_random_against_model() -> None:
    # Model: a tree as nested lists, and the focus as a list of directions.
    rng = random.Random(5150)

    def get_at(t, dirs):
        for d in dirs:
            t = t[0] if d == 0 else t[2]
        return t

    def put_at(t, dirs, s):
        if not dirs:
            return s
        l, x, r = t
        if dirs[0] == 0:
            return (put_at(l, dirs[1:], s), x, r)
        return (l, x, put_at(r, dirs[1:], s))

    for _ in range(500):
        t = random_tree(rng, rng.randint(0, 15))
        z, dirs, model = Zipper(t), [], t
        saved = []
        for _ in range(rng.randint(1, 30)):
            op = rng.randrange(5)
            if op == 0 and z.focus is not None:
                z, dirs = z.left(), dirs + [0]
            elif op == 1 and z.focus is not None:
                z, dirs = z.right(), dirs + [1]
            elif op == 2 and not z.at_top():
                z, dirs = z.up(), dirs[:-1]
            elif op == 3:
                s = random_tree(rng, rng.randint(0, 3))
                z = z.replace(s)
                model = put_at(model, dirs, s)
            else:
                saved.append((z, list(dirs), model))
            assert z.focus == get_at(model, dirs)
            assert z.depth == len(dirs)
            assert z.to_tree() == model
        for oz, od, om in saved:  # old zippers are untouched
            assert oz.to_tree() == om and oz.depth == len(od)
            oz.check_invariant()

What the next level needs

  • the tests did not run in this build

Read the full page about Zipper