Algorithm Search⌘K

Blum's disjoint-set structure in Python

Disjoint-set structure with logarithmic worst-case time per single operation.

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


class BlumUnionFind:
    """Blum's k-UF trees: O(log n / log log n) worst-case time per operation.

    The elements 0..n-1 are the leaves. In each tree all leaves are at the
    same depth, the root has at least two children unless it is a leaf, and
    every other inner node has at least k children. find walks from the leaf
    to the root; union moves fewer than k children or adds one edge.
    """

    def __init__(self, n: int, k: int | None = None) -> None:
        if n < 0:
            raise ValueError("n must be non-negative")
        if k is None:
            lg = max(1, (n - 1).bit_length())
            lglg = max(1, lg.bit_length())
            k = max(2, -(-lg // lglg))
        if k < 2:
            raise ValueError("k must be at least 2")
        self.n = n
        self.k = k
        self.parent: list[int] = [-1] * n
        self.children: list[list[int]] = [[] for _ in range(n)]
        self.height: list[int] = [0] * n
        self.name: list[int] = list(range(n))
        self.count = n

    def _root(self, x: int) -> int:
        p = self.parent
        while p[x] != -1:  # @line L1
            x = p[x]
        return x

    def find(self, x: int) -> int:
        """Return the name of the set of x, an element of that set."""
        if not 0 <= x < self.n:
            raise IndexError("no such element")
        return self.name[self._root(x)]  # @line L2

    def connected(self, x: int, y: int) -> bool:
        """Return True if x and y are in the same set."""
        return self._root(x) == self._root(y)

    def _new_node(self, h: int) -> int:
        self.parent.append(-1)
        self.children.append([])
        self.height.append(h)
        self.name.append(-1)
        return len(self.parent) - 1

    def _adopt(self, v: int, c: int) -> None:
        self.parent[c] = v
        self.children[v].append(c)

    def _move_children(self, s: int, v: int) -> None:
        for c in self.children[s]:
            self._adopt(v, c)
        self.children[s] = []

    def _down(self, r: int, h: int) -> int:
        while self.height[r] > h:
            r = self.children[r][0]
        return r

    def union(self, x: int, y: int) -> bool:
        """Merge the sets of x and y. Return False if they were one set."""
        r, s = self._root(x), self._root(y)  # @line L3
        if r == s:
            return False
        self.count -= 1
        k = self.k
        if self.height[r] < self.height[s]:  # @line L4
            r, s = s, r
        if self.height[r] == self.height[s]:  # @line L5
            dr, ds = len(self.children[r]), len(self.children[s])
            if self.height[r] == 0 or (dr >= k and ds >= k):  # @line L6
                t = self._new_node(self.height[r] + 1)
                self.name[t] = self.name[r]
                self._adopt(t, r)
                self._adopt(t, s)
                return True
            if dr < ds:  # @line L7
                r, s = s, r
            self._move_children(s, r)  # @line L8
            return True
        if self.height[s] == 0 or len(self.children[s]) >= k:  # @line L9
            self._adopt(self._down(r, self.height[s] + 1), s)  # @line L10
        else:
            self._move_children(s, self._down(r, self.height[s]))  # @line L11
        return True  # @line L12

    def check_invariant(self) -> None:
        """Check the shape of every k-UF tree and the parent and child links."""
        roots = set()
        for x in range(self.n):
            d, y = 0, x
            while self.parent[y] != -1:
                p = self.parent[y]
                assert y in self.children[p]
                assert self.height[p] == self.height[y] + 1
                y = p
                d += 1
            assert self.height[y] == d  # all leaves of a tree at the same depth
            roots.add(y)
        assert len(roots) == self.count
        live = set()
        for r in roots:
            stack = [r]
            leaves = 0
            while stack:
                v = stack.pop()
                live.add(v)
                ch = self.children[v]
                if self.height[v] == 0:
                    assert not ch and v < self.n
                    leaves += 1
                elif v == r:
                    assert len(ch) >= 2
                else:
                    assert len(ch) >= self.k
                for c in ch:
                    assert self.parent[c] == v
                stack.extend(ch)
            h = self.height[r]
            assert h == 0 or leaves >= 2 * self.k ** (h - 1)
            assert 0 <= self.name[r] < self.n and self._root(self.name[r]) == r
test_blum_union_find.py 60 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/blum-union-find/. The authors are listed in prov/YNSV90.jsonl.

import random

import pytest

from blum_union_find import BlumUnionFind


def test_example() -> None:
    u = BlumUnionFind(9, k=3)
    for x, y in [(0, 1), (2, 3), (0, 2), (4, 5), (6, 4), (7, 8), (0, 4), (7, 0)]:
        assert u.union(x, y)
        u.check_invariant()
    a = u.parent[0]
    assert sorted(u.children[a]) == [0, 1, 2, 3, 7, 8]
    assert sorted(u.children[u.parent[4]]) == [4, 5, 6]
    assert u.height[u._root(0)] == 2
    assert len({u.find(x) for x in range(9)}) == 1
    assert not u.union(3, 6)


def test_edges() -> None:
    u = BlumUnionFind(0)
    assert u.count == 0
    u = BlumUnionFind(1)
    assert u.find(0) == 0
    with pytest.raises(IndexError):
        u.find(1)
    with pytest.raises(ValueError):
        BlumUnionFind(3, k=1)


def test_height_bound() -> None:
    n = 1 << 14
    u = BlumUnionFind(n)
    for x in range(1, n):
        u.union(x, x - 1)
    u.check_invariant()
    h = u.height[u._root(0)]
    assert u.k ** (h - 1) * 2 <= n


def test_random_against_labels() -> None:
    rng = random.Random(99)
    for _ in range(500):
        n = rng.randrange(1, 40)
        u = BlumUnionFind(n, k=rng.choice([None, 2, 3, 4]))
        label = list(range(n))
        for _ in range(2 * n):
            x, y = rng.randrange(n), rng.randrange(n)
            if rng.random() < 0.6:
                assert u.union(x, y) == (label[x] != label[y])
                old = label[x]
                label = [label[y] if v == old else v for v in label]
            else:
                assert u.connected(x, y) == (label[x] == label[y])
                assert (u.find(x) == u.find(y)) == (label[x] == label[y])
        u.check_invariant()

What the next level needs

  • the tests did not run in this build

Read the full page about Blum's disjoint-set structure