Frederickson's topology trees in Python
Hierarchical partition of a spanning forest that supports dynamic minimum spanning tree updates.
- File
frederickson_topology_trees.py, 337 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/frederickson-topology-trees/. The authors are listed in prov/ZS0KCA.jsonl.
"""Frederickson's clustering method for fully dynamic minimum spanning forests.
The minimum spanning forest F is cut into connected clusters of at most z
vertices. For every pair of clusters, the non-tree edges between them are
kept in a table, so the lightest edge across a cut of F can be found by
looking at pairs of clusters instead of at edges. This is the one-level
structure of Frederickson (1985). His topology trees build the clusters into
a hierarchy, and his two-dimensional topology trees keep the minima over
pairs of clusters of all levels, which lowers the update time to O(m^(1/2)).
build_topology_tree builds the multilevel partition of a tree of degree at
most three that a topology tree records.
Edges are ordered by (weight, smaller end, larger end), so the forest is unique.
"""
from math import ceil
from typing import Optional
Key = tuple[float, int, int]
def _key(u: int, v: int, w: float) -> Key:
return (w, u, v) if u < v else (w, v, u)
class DynamicMSF:
def __init__(self, n: int) -> None:
self.n = n
self.tadj: list[dict[int, Key]] = [dict() for _ in range(n)] # forest edges
self.weight: dict[tuple[int, int], float] = {} # all edges
self.nontree: dict[tuple[int, int], Key] = {}
self.nt_at: list[set[tuple[int, int]]] = [set() for _ in range(n)]
self.updates = 0
self._recluster()
# ---- clusters -------------------------------------------------------
def _recluster(self) -> None:
"""Greedy bottom-up partition of each tree of F into connected clusters of size at most z."""
n = self.n
self.z = max(1, ceil(n ** (2 / 3)))
self.cid = [-1] * n
self.members: dict[int, set[int]] = {}
self.next_id = 0
seen = [False] * n
for r in range(n):
if seen[r]:
continue
order, parent, st = [], {r: -1}, [r]
seen[r] = True
while st:
x = st.pop()
order.append(x)
for y in self.tadj[x]:
if not seen[y]:
seen[y] = True
parent[y] = x
st.append(y)
open_set: dict[int, set[int]] = {}
for x in reversed(order): # children before parents
cur = {x}
for y in self.tadj[x]:
if parent.get(y) == x:
part = open_set.pop(y)
if len(cur) + len(part) <= self.z:
cur |= part
else:
self._new_cluster(part)
open_set[x] = cur
self._new_cluster(open_set.pop(r))
# cluster tree and table of non-tree edges by pairs of clusters
self.ctree: dict[int, dict[int, int]] = {c: {} for c in self.members}
for u in range(n):
for v in self.tadj[u]:
if u < v:
self._ct_add(self.cid[u], self.cid[v])
self.table: dict[tuple[int, int], dict[tuple[int, int], Key]] = {}
for e, k in self.nontree.items():
self._tab_add(e, k)
def _new_cluster(self, vs: set[int]) -> int:
c = self.next_id
self.next_id += 1
self.members[c] = vs
for v in vs:
self.cid[v] = c
return c
def _ct_add(self, a: int, b: int) -> None:
if a != b:
self.ctree[a][b] = self.ctree[a].get(b, 0) + 1
self.ctree[b][a] = self.ctree[b].get(a, 0) + 1
def _ct_del(self, a: int, b: int) -> None:
if a != b:
for x, y in ((a, b), (b, a)):
self.ctree[x][y] -= 1
if not self.ctree[x][y]:
del self.ctree[x][y]
def _pair(self, e: tuple[int, int]) -> tuple[int, int]:
a, b = self.cid[e[0]], self.cid[e[1]]
return (a, b) if a <= b else (b, a)
def _tab_add(self, e: tuple[int, int], k: Key) -> None:
self.table.setdefault(self._pair(e), {})[e] = k
def _tab_del(self, e: tuple[int, int]) -> None:
p = self._pair(e)
del self.table[p][e]
if not self.table[p]:
del self.table[p]
def _move(self, vs: set[int], new: int) -> None:
"""Move the vertices vs into the cluster new, fixing the cluster tree and the table."""
old = self.cid[next(iter(vs))]
touched = set()
for v in vs:
for y in self.tadj[v]:
self._ct_del(self.cid[v], self.cid[y])
touched |= self.nt_at[v]
for e in touched:
self._tab_del(e)
for v in vs:
self.members[self.cid[v]].discard(v)
for v in vs:
self.cid[v] = new
self.members[new].add(v)
for v in vs:
for y in self.tadj[v]:
self._ct_add(self.cid[v], self.cid[y])
for e in touched:
self._tab_add(e, self.nontree[e])
if not self.members[old]:
del self.members[old]
del self.ctree[old]
# ---- forest edges ---------------------------------------------------
def _add_tree(self, u: int, v: int, k: Key) -> None:
self.tadj[u][v] = k
self.tadj[v][u] = k
self._ct_add(self.cid[u], self.cid[v])
a, b = self.cid[u], self.cid[v]
if a != b and len(self.members[a]) + len(self.members[b]) <= self.z:
small, big = (a, b) if len(self.members[a]) <= len(self.members[b]) else (b, a)
self._move(set(self.members[small]), big)
def _del_tree(self, u: int, v: int) -> None:
del self.tadj[u][v]
del self.tadj[v][u]
c = self.cid[u]
if c != self.cid[v]:
self._ct_del(c, self.cid[v])
return
side = self._reach_in_cluster(u, c) # @line L4
if v in side:
raise AssertionError("tree edge was on a cycle")
rest = self.members[c] - side
new = self._new_cluster(set())
self.ctree[new] = {}
self._move(side if len(side) <= len(rest) else rest, new)
def _reach_in_cluster(self, s: int, c: int) -> set[int]:
seen, st = {s}, [s]
while st:
x = st.pop()
for y in self.tadj[x]:
if y not in seen and self.cid[y] == c:
seen.add(y)
st.append(y)
return seen
def _side(self, c: int) -> set[int]:
"""Clusters in the tree of the cluster c, by a walk on the cluster tree."""
seen, st = {c}, [c]
while st:
x = st.pop()
for y in self.ctree[x]:
if y not in seen:
seen.add(y)
st.append(y)
return seen
def _path_max(self, u: int, v: int) -> Optional[Key]:
"""Heaviest forest edge on the path from u to v, inside the clusters of the cluster path."""
cu, cv = self.cid[u], self.cid[v]
prev = {cu: -1}
st = [cu]
while st:
x = st.pop()
for y in self.ctree[x]:
if y not in prev:
prev[y] = x
st.append(y)
if cv not in prev:
return None
allowed = set()
x = cv
while x != -1:
allowed.add(x)
x = prev[x]
par = {u: (-1, None)}
st = [u]
while st:
x = st.pop()
for y, k in self.tadj[x].items():
if y not in par and self.cid[y] in allowed:
par[y] = (x, k)
st.append(y)
best, x = None, v
while x != u:
x, k = par[x]
if best is None or k > best:
best = k
return best
# ---- the algorithm --------------------------------------------------
def insert(self, u: int, v: int, w: float) -> None:
if u == v:
return
e = (min(u, v), max(u, v))
if e in self.weight:
raise ValueError("edge already present")
k = _key(u, v, w)
self.weight[e] = w
if self.cid[v] not in self._side(self.cid[u]):
self._add_tree(u, v, k) # @line L1
else:
mx = self._path_max(u, v) # @line L2
if k < mx:
a, b = mx[1], mx[2]
self._del_tree(a, b)
self._add_tree(u, v, k)
self._add_nontree((a, b), mx) # @line L3
else:
self._add_nontree(e, k)
self._tick()
def delete(self, u: int, v: int) -> None:
e = (min(u, v), max(u, v))
del self.weight[e]
if e in self.nontree:
self._remove_nontree(e)
self._tick()
return
self._del_tree(u, v)
A, B = self._side(self.cid[u]), self._side(self.cid[v])
best = None
for a in A: # @line L5
for b in B:
p = (a, b) if a <= b else (b, a)
if p in self.table:
k = min(self.table[p].values())
if best is None or k < best:
best = k
if best is not None:
r = (best[1], best[2])
self._remove_nontree(r)
self._add_tree(r[0], r[1], best) # @line L6
self._tick()
def _add_nontree(self, e: tuple[int, int], k: Key) -> None:
self.nontree[e] = k
self.nt_at[e[0]].add(e)
self.nt_at[e[1]].add(e)
self._tab_add(e, k)
def _remove_nontree(self, e: tuple[int, int]) -> None:
self._tab_del(e)
del self.nontree[e]
self.nt_at[e[0]].discard(e)
self.nt_at[e[1]].discard(e)
def _tick(self) -> None:
self.updates += 1
if self.updates % self.z == 0:
self._recluster() # @line L7
def forest(self) -> set[tuple[int, int]]:
return {(u, v) for u in range(self.n) for v in self.tadj[u] if u < v}
def total(self) -> float:
return sum(self.weight[e] for e in self.forest())
def build_topology_tree(n: int, edges: list[tuple[int, int]]) -> list[list[frozenset[int]]]:
"""Levels of a restricted multilevel partition of a forest of degree at most 3.
Level 0 has one cluster per vertex. Each higher level joins pairs of
adjacent clusters of the level below: a cluster of external degree 3 is
joined only with a neighbor of external degree 1, and a cluster of
degree at most 2 with a neighbor of degree at most 2, or with a degree 1
neighbor. A greedy maximal set of such pairs is joined; the other
clusters are copied up. It stops when no pair can be joined.
"""
adj: list[set[int]] = [set() for _ in range(n)]
for a, b in edges:
adj[a].add(b)
adj[b].add(a)
if any(len(s) > 3 for s in adj):
raise ValueError("degree above 3")
cl = list(range(n)) # cluster index of each vertex
levels = [[frozenset([v]) for v in range(n)]]
while True: # @line L8
k = len(levels[-1])
nb: list[set[int]] = [set() for _ in range(k)]
deg = [0] * k
for a, b in edges:
if cl[a] != cl[b]:
nb[cl[a]].add(cl[b])
nb[cl[b]].add(cl[a])
deg[cl[a]] += 1
deg[cl[b]] += 1
mate = [-1] * k
for x in range(k):
if mate[x] != -1:
continue
for y in sorted(nb[x]):
if mate[y] == -1 and (min(deg[x], deg[y]) == 1 or max(deg[x], deg[y]) <= 2):
mate[x], mate[y] = y, x # @line L9
break
if all(m == -1 for m in mate):
return levels
new_index = [-1] * k
nxt: list[frozenset[int]] = []
for x in range(k):
if new_index[x] == -1:
group = levels[-1][x] | (levels[-1][mate[x]] if mate[x] != -1 else frozenset())
new_index[x] = len(nxt)
if mate[x] != -1:
new_index[mate[x]] = len(nxt)
nxt.append(group) # @line L10
cl = [new_index[c] for c in cl]
levels.append(nxt)
test_frederickson_topology_trees.py 140 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/frederickson-topology-trees/. The authors are listed in prov/ZS0KCA.jsonl.
import math
import random
from frederickson_topology_trees import DynamicMSF, _key, build_topology_tree
def _kruskal(n: int, weight: dict[tuple[int, int], float]) -> set[tuple[int, int]]:
parent = list(range(n))
def find(x: int) -> int:
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
out = set()
for e in sorted(weight, key=lambda e: _key(e[0], e[1], weight[e])):
a, b = find(e[0]), find(e[1])
if a != b:
parent[a] = b
out.add(e)
return out
def _check_clusters(d: DynamicMSF) -> None:
for c, vs in d.members.items():
assert vs and all(d.cid[v] == c for v in vs)
s = next(iter(vs))
assert d._reach_in_cluster(s, c) == vs # each cluster is connected in F
assert sum(len(vs) for vs in d.members.values()) == d.n
for p, es in d.table.items():
for e in es:
assert d._pair(e) == p and e in d.nontree
def test_example() -> None:
d = DynamicMSF(4)
for u, v, w in [(0, 1, 1), (0, 2, 5), (0, 3, 4), (1, 2, 2), (1, 3, 6), (2, 3, 3)]:
d.insert(u, v, w)
assert d.forest() == {(0, 1), (1, 2), (2, 3)} and d.total() == 6
d.delete(1, 2)
assert d.forest() == {(0, 1), (2, 3), (0, 3)} and d.total() == 8
def test_random_against_kruskal() -> None:
rng = random.Random(4)
for trial in range(500):
n = rng.randint(1, 14)
d = DynamicMSF(n)
weight: dict[tuple[int, int], float] = {}
for _ in range(rng.randint(1, 40)):
if weight and rng.random() < 0.4:
e = rng.choice(list(weight))
del weight[e]
d.delete(*e)
else:
u, v = rng.randrange(n), rng.randrange(n)
if u == v or (min(u, v), max(u, v)) in weight:
continue
w = rng.randint(1, 6)
weight[(min(u, v), max(u, v))] = w
d.insert(u, v, w)
assert d.forest() == _kruskal(n, weight)
if trial % 5 == 0:
_check_clusters(d)
def test_larger_graph() -> None:
rng = random.Random(8)
n = 60
d = DynamicMSF(n)
weight: dict[tuple[int, int], float] = {}
for step in range(1500):
if weight and rng.random() < 0.45:
e = rng.choice(list(weight))
del weight[e]
d.delete(*e)
else:
u, v = rng.randrange(n), rng.randrange(n)
if u == v or (min(u, v), max(u, v)) in weight:
continue
w = rng.random()
weight[(min(u, v), max(u, v))] = w
d.insert(u, v, w)
if step % 10 == 0:
assert d.forest() == _kruskal(n, weight)
_check_clusters(d)
assert all(len(vs) <= d.z for vs in d.members.values())
def _check_levels(n: int, edges: list[tuple[int, int]], levels) -> None:
adj = [set() for _ in range(n)]
for a, b in edges:
adj[a].add(b)
adj[b].add(a)
for i, lev in enumerate(levels):
assert sorted(v for c in lev for v in c) == list(range(n))
for c in lev:
ext = sum(1 for v in c for y in adj[v] if y not in c)
assert ext <= 3
s = next(iter(c))
seen, st = {s}, [s]
while st:
x = st.pop()
for y in adj[x]:
if y in c and y not in seen:
seen.add(y)
st.append(y)
assert seen == c
if i:
kids = [k for k in levels[i - 1] if k <= c]
assert 1 <= len(kids) <= 2 and frozenset().union(*kids) == c
def _random_tree_deg3(rng: random.Random, n: int) -> list[tuple[int, int]]:
deg = [0] * n
edges = []
for v in range(1, n):
u = rng.choice([x for x in range(v) if deg[x] < 3])
deg[u] += 1
deg[v] += 1
edges.append((u, v))
return edges
def test_topology_tree() -> None:
rng = random.Random(2)
for _ in range(300):
n = rng.randint(1, 40)
edges = _random_tree_deg3(rng, n)
levels = build_topology_tree(n, edges)
_check_levels(n, edges, levels)
assert len(levels[-1]) == 1
assert len(levels) <= 4 * math.log2(n + 1) + 4
n = 1024
path = [(i, i + 1) for i in range(n - 1)]
assert len(build_topology_tree(n, path)) == 11
What the next level needs
- the tests did not run in this build