Algorithm Search⌘K

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
union_find_with_rollback.pyRawGitHub
# 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

Read the full page about Union-find with rollback