Algorithm Search⌘K

Linked-list disjoint sets in Python

Representation of each set as a linked list with a head pointer, using weighted union by appending the smaller list.

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

from collections.abc import Hashable, Iterator
from typing import Generic, TypeVar

T = TypeVar("T", bound=Hashable)


class LinkedListDisjointSets(Generic[T]):
    """Disjoint sets as linked lists with weighted union.

    Each set is a list of its members, named by its head. Each element stores
    the head of its set and the next element of its list. find takes O(1)
    time. union appends the shorter list to the longer one and renames its
    elements, so m operations on n elements take O(m + n log n) time.
    """

    def __init__(self) -> None:
        self.head: dict[T, T] = {}
        self.next: dict[T, T | None] = {}
        self.tail: dict[T, T] = {}
        self.length: dict[T, int] = {}

    def __contains__(self, x: object) -> bool:
        return x in self.head

    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.head:
            raise ValueError("element already present")
        self.tail[x] = x  # @line L1
        self.length[x] = 1
        self.head[x] = x  # @line L2
        self.next[x] = None

    def find(self, x: T) -> T:
        """Return the head of the list of x, the representative of its set."""
        return self.head[x]  # @line L3

    def union(self, x: T, y: T) -> bool:
        """Merge the sets of x and y. Return False if they were already one set."""
        s, t = self.head[x], self.head[y]  # @line L4
        if s == t:  # @line L5
            return False
        if self.length[s] > self.length[t]:  # @line L6
            s, t = t, s
        z: T | None = s
        while z is not None:  # @line L7
            self.head[z] = t
            z = self.next[z]
        self.next[self.tail[t]] = s  # @line L8
        self.tail[t] = self.tail.pop(s)
        self.length[t] += self.length.pop(s)  # @line L9
        return True

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

    def size(self, x: T) -> int:
        """Return the number of elements in the set of x."""
        return self.length[self.head[x]]

    def members(self, x: T) -> Iterator[T]:
        """Yield the elements of the set of x, from the head to the tail."""
        z: T | None = self.head[x]
        while z is not None:
            yield z
            z = self.next[z]

    @property
    def count(self) -> int:
        """The number of sets."""
        return len(self.length)

    def check_invariant(self) -> None:
        """Check that each list has its size, its tail and the right heads, and covers each element once."""
        seen: set[T] = set()
        assert self.tail.keys() == self.length.keys()
        for h in self.length:
            assert self.head[h] == h
            z: T | None = h
            last = h
            k = 0
            while z is not None:
                assert z not in seen
                seen.add(z)
                assert self.head[z] == h
                last = z
                z = self.next[z]
                k += 1
            assert k == self.length[h]
            assert last == self.tail[h]
        assert seen == self.head.keys()
test_linked_list_disjoint_sets.py 85 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/linked-list-disjoint-sets/. The authors are listed in prov/JEF2AM.jsonl.

import random

import pytest

from linked_list_disjoint_sets import LinkedListDisjointSets


def test_example() -> None:
    d: LinkedListDisjointSets[int] = LinkedListDisjointSets()
    for x in range(7):
        d.make_set(x)
    moves = {x: 0 for x in range(7)}
    lists = []
    for a, b in [(0, 1), (2, 3), (1, 3), (4, 0), (5, 4), (3, 6)]:
        before = dict(d.head)
        assert d.union(a, b)
        d.check_invariant()
        for x in range(7):
            moves[x] += before[x] != d.head[x]
        lists.append(list(d.members(a)))
    assert lists[0] == [1, 0]
    assert lists[2] == [3, 2, 1, 0]
    assert lists[-1] == [3, 2, 1, 0, 4, 5, 6]
    assert sum(moves.values()) == 7
    assert max(moves.values()) == 2
    assert d.find(6) == 3 and d.size(0) == 7 and d.count == 1


def test_edges() -> None:
    d: LinkedListDisjointSets[str] = LinkedListDisjointSets()
    assert d.count == 0
    d.make_set("a")
    assert d.find("a") == "a" and d.size("a") == 1
    assert list(d.members("a")) == ["a"]
    assert not d.union("a", "a")
    d.check_invariant()
    with pytest.raises(ValueError):
        d.make_set("a")
    with pytest.raises(KeyError):
        d.find("b")


def test_moves_are_logarithmic() -> None:
    n = 1 << 10
    d: LinkedListDisjointSets[int] = LinkedListDisjointSets()
    for x in range(n):
        d.make_set(x)
    moves = [0] * n
    rng = random.Random(5)
    while d.count > 1:
        heads = list(d.length)
        a, b = rng.sample(heads, 2)
        before = [d.head[x] for x in range(n)]
        d.union(a, b)
        for x in range(n):
            moves[x] += before[x] != d.head[x]
    assert max(moves) <= 10


def test_random_against_model() -> None:
    rng = random.Random(31)
    for _ in range(500):
        n = rng.randrange(1, 14)
        d: LinkedListDisjointSets[int] = LinkedListDisjointSets()
        label = list(range(n))
        for x in range(n):
            d.make_set(x)
        for _ in range(rng.randrange(30)):
            a, b = rng.randrange(n), rng.randrange(n)
            r = rng.random()
            if r < 0.4:
                assert d.union(a, b) == (label[a] != label[b])
                old, new = label[a], label[b]
                label = [new if v == old else v for v in label]
                d.check_invariant()
            elif r < 0.7:
                assert d.connected(a, b) == (label[a] == label[b])
            else:
                expect = sorted(x for x in range(n) if label[x] == label[a])
                assert sorted(d.members(a)) == expect
                assert d.size(a) == len(expect)
        assert d.count == len(set(label))

What the next level needs

  • the tests did not run in this build

Read the full page about Linked-list disjoint sets