Union-find with rollback in Python
Union-find without path compression that supports undoing the most recent unions in stack order.
- File
union_find_with_rollback.py, 105 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/union-find-with-rollback/. The authors are listed in prov/CSRV45.jsonl.
from collections.abc import Hashable
from typing import Generic, TypeVar
T = TypeVar("T", bound=Hashable)
class UnionFindWithRollback(Generic[T]):
"""Union-find with union by size, no path compression, and undo of the latest unions.
Each union pushes one record on a stack: the pair (child, root) of its
link, or None for a union inside one set. undo pops a record and
restores the two fields. find and union take O(log n) time in the worst
case; undo takes O(1).
"""
def __init__(self) -> None:
self.parent: dict[T, T] = {}
self.size: dict[T, int] = {}
self.history: list[tuple[T, T] | None] = []
self.count = 0
def __contains__(self, x: object) -> bool:
return x in self.parent
def make_set(self, x: T) -> None:
"""Add x as a set of its own. Raise ValueError if x is already present."""
if x in self.parent:
raise ValueError("element already present")
self.parent[x] = x # @line L1
self.size[x] = 1
self.count += 1
def find(self, x: T) -> T:
"""Return the root of x without changing the forest."""
p = self.parent
while p[x] != x: # @line L2
x = p[x]
return x # @line L3
def union(self, x: T, y: T) -> bool:
"""Merge the sets of x and y and record the change. Return False if they were one set."""
x, y = self.find(x), self.find(y) # @line L4
if x == y: # @line L5
self.history.append(None)
return False
if self.size[x] > self.size[y]: # @line L6
x, y = y, x
self.history.append((x, y)) # @line L7
self.parent[x] = y # @line L8
self.size[y] += self.size[x]
self.count -= 1
return True # @line L9
def undo(self) -> None:
"""Undo the most recent union. Raise IndexError if there is none."""
e = self.history.pop() # @line L10
if e is None: # @line L11
return
x, y = e # @line L12
self.size[y] -= self.size[x] # @line L13
self.parent[x] = x
self.count += 1
def snapshot(self) -> int:
"""Return the number of unions on the stack, to pass to rollback later."""
return len(self.history)
def rollback(self, t: int) -> None:
"""Undo unions until only the first t remain."""
if not 0 <= t <= len(self.history):
raise ValueError("no such snapshot")
while len(self.history) > t:
self.undo()
def connected(self, x: T, y: T) -> bool:
"""Return True if x and y are in the same set."""
return self.find(x) == self.find(y)
def check_invariant(self) -> None:
"""Check the forest, the sizes, the size rule on each edge, and the records."""
n = len(self.parent)
sub = {x: 1 for x in self.parent}
depth: dict[T, int] = {}
for x in self.parent:
y, d = x, 0
while self.parent[y] != y:
y = self.parent[y]
d += 1
assert d < max(n, 1)
sub[y] += 1
depth[x] = d
assert (1 << d) <= n
roots = [x for x in self.parent if self.parent[x] == x]
assert len(roots) == self.count
for r in roots:
assert self.size[r] == sub[r]
for x in self.parent:
if self.parent[x] != x:
assert 2 * sub[x] <= sub[self.parent[x]]
for e in self.history:
if e is not None:
assert self.parent[e[0]] == e[1]
test_union_find_with_rollback.py 85 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/union-find-with-rollback/. The authors are listed in prov/CSRV45.jsonl.
import random
import pytest
from union_find_with_rollback import UnionFindWithRollback
def test_example() -> None:
u: UnionFindWithRollback[int] = UnionFindWithRollback()
for x in range(6):
u.make_set(x)
assert u.union(0, 1) and u.union(2, 3)
t = u.snapshot()
assert t == 2
assert u.union(1, 3) and u.union(4, 5)
assert not u.union(0, 2)
u.check_invariant()
assert [u.parent[x] for x in range(6)] == [1, 3, 3, 3, 5, 5]
assert u.history == [(0, 1), (2, 3), (1, 3), (4, 5), None]
assert u.connected(0, 2)
u.rollback(t)
u.check_invariant()
assert [u.parent[x] for x in range(6)] == [1, 1, 3, 3, 4, 5]
assert u.size[1] == 2 and u.size[3] == 2 and u.size[5] == 1
assert not u.connected(0, 2)
assert u.count == 4
def test_edges() -> None:
u: UnionFindWithRollback[str] = UnionFindWithRollback()
with pytest.raises(IndexError):
u.undo()
u.make_set("a")
assert not u.union("a", "a")
u.undo()
assert u.snapshot() == 0
with pytest.raises(ValueError):
u.rollback(1)
with pytest.raises(ValueError):
u.make_set("a")
def test_height_is_logarithmic() -> None:
u: UnionFindWithRollback[int] = UnionFindWithRollback()
n = 1 << 11
for x in range(n):
u.make_set(x)
for x in range(1, n):
u.union(x, x - 1)
u.check_invariant()
u.rollback(0)
assert u.count == n
u.check_invariant()
def test_random_against_label_stack() -> None:
rng = random.Random(41)
for _ in range(500):
n = rng.randrange(1, 12)
u: UnionFindWithRollback[int] = UnionFindWithRollback()
for x in range(n):
u.make_set(x)
labels = [list(range(n))]
for _ in range(rng.randrange(40)):
r = rng.random()
label = labels[-1]
a, b = rng.randrange(n), rng.randrange(n)
if r < 0.45:
assert u.union(a, b) == (label[a] != label[b])
old, new = label[a], label[b]
labels.append([new if v == old else v for v in label])
elif r < 0.65 and len(labels) > 1:
u.undo()
labels.pop()
elif r < 0.75:
t = rng.randrange(len(labels))
u.rollback(t)
del labels[t + 1 :]
else:
assert u.connected(a, b) == (label[a] == label[b])
u.check_invariant()
assert u.count == len(set(labels[-1]))
What the next level needs
- the tests did not run in this build