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