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
# 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