Link-cut tree in Python
Represents a dynamic forest with splay trees, giving O(log n) amortized link, cut and path queries.
- File
link_cut_tree.py, 174 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/link-cut-tree/. The authors are listed in prov/45BF01.jsonl.
"""Link-cut trees (Sleator and Tarjan) on splay trees.
The forest is split into vertex-disjoint preferred paths. Each path is kept in
a splay tree ordered by depth. The root of a splay tree keeps a path-parent
pointer to the tree node above the top of its path. The structure supports
link, cut, make_root (evert), find_root, connected and path sums and maxima in
O(log n) amortized time per operation. It is fully dynamic for forests and
deterministic. All loops are iterative, so long paths cause no recursion.
"""
NEG = float("-inf")
class LinkCutForest:
"""A forest on the vertices 0..n-1 with integer vertex values."""
def __init__(self, n: int, values: list[int] | None = None) -> None:
self.n = n
self.left = [-1] * n
self.right = [-1] * n
self.par = [-1] * n # splay parent, or path-parent at a splay root
self.rev = [False] * n
self.val = list(values) if values is not None else [0] * n
self.sum = list(self.val)
self.mx = list(self.val)
# ----- splay tree helpers -----
def _is_root(self, x: int) -> bool:
p = self.par[x]
return p == -1 or (self.left[p] != x and self.right[p] != x)
def _pull(self, x: int) -> None:
s, m = self.val[x], self.val[x]
for c in (self.left[x], self.right[x]):
if c != -1:
s += self.sum[c]
if self.mx[c] > m:
m = self.mx[c]
self.sum[x], self.mx[x] = s, m
def _push(self, x: int) -> None:
if self.rev[x]:
l, r = self.left[x], self.right[x]
self.left[x], self.right[x] = r, l
if l != -1:
self.rev[l] = not self.rev[l]
if r != -1:
self.rev[r] = not self.rev[r]
self.rev[x] = False
def _rotate(self, x: int) -> None:
p = self.par[x]
g = self.par[p]
p_root = self._is_root(p)
if self.left[p] == x:
b = self.right[x]
self.left[p] = b
self.right[x] = p
else:
b = self.left[x]
self.right[p] = b
self.left[x] = p
if b != -1:
self.par[b] = p
self.par[p] = x
self.par[x] = g
if not p_root:
if self.left[g] == p:
self.left[g] = x
else:
self.right[g] = x
self._pull(p)
self._pull(x)
def _splay(self, x: int) -> None:
# Push pending reversals from the splay root down to x first.
path = [x]
y = x
while not self._is_root(y):
y = self.par[y]
path.append(y)
for y in reversed(path):
self._push(y)
while not self._is_root(x):
p = self.par[x]
if not self._is_root(p):
g = self.par[p]
zigzig = (self.left[g] == p) == (self.left[p] == x)
self._rotate(p if zigzig else x)
self._rotate(x)
# ----- the operations of the main pseudocode -----
def access(self, v: int) -> None:
"""Make the path from the tree root to v preferred, and splay v."""
last = -1 # @line L1
x = v # @line L2
while x != -1:
self._splay(x) # @line L3
self.right[x] = last # @line L4
self._pull(x)
last = x # @line L5
x = self.par[x]
self._splay(v) # @line L6
def make_root(self, v: int) -> None:
self.access(v) # @line L7
self.rev[v] = not self.rev[v] # @line L8
def find_root(self, v: int) -> int:
self.access(v) # @line L9
x = v
self._push(x)
while self.left[x] != -1:
x = self.left[x]
self._push(x)
self._splay(x) # @line L10
return x
def connected(self, u: int, v: int) -> bool:
return u == v or self.find_root(u) == self.find_root(v)
def link(self, u: int, v: int) -> None:
"""Add the edge {u, v}; u and v must be in different trees."""
if self.connected(u, v):
raise ValueError("link would create a cycle")
self.make_root(u) # @line L11
self.access(v)
self.par[u] = v # @line L12
def cut(self, u: int, v: int) -> None:
"""Remove the edge {u, v}, which must be in the forest."""
self.make_root(u) # @line L13
self.access(v)
if self.left[v] != u or self.right[u] != -1:
raise ValueError("no such edge")
self.left[v] = -1 # @line L14
self.par[u] = -1
self._pull(v)
def path_sum(self, u: int, v: int) -> int:
"""Sum of the values on the tree path from u to v."""
self.make_root(u) # @line L15
self.access(v)
return self.sum[v] # @line L16
def path_max(self, u: int, v: int) -> int:
self.make_root(u)
self.access(v)
return self.mx[v]
def set_value(self, v: int, x: int) -> None:
self.access(v)
self.val[v] = x
self._pull(v)
def lca(self, root: int, u: int, v: int) -> int:
"""Lowest common ancestor of u and v when the tree is rooted at root."""
self.make_root(root)
self.access(u)
last = -1
x = v
while x != -1:
self._splay(x)
self.right[x] = last
self._pull(x)
last = x
x = self.par[x]
self._splay(v)
return last
test_link_cut_tree.py 112 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/link-cut-tree/. The authors are listed in prov/45BF01.jsonl.
import random
from link_cut_tree import LinkCutForest
def _path(adj: list[set[int]], u: int, v: int) -> list[int] | None:
prev = {u: -1}
stack = [u]
while stack:
x = stack.pop()
for y in adj[x]:
if y not in prev:
prev[y] = x
stack.append(y)
if v not in prev:
return None
out = [v]
while out[-1] != u:
out.append(prev[out[-1]])
return out
def _lca(adj: list[set[int]], r: int, u: int, v: int) -> int:
pu = set(_path(adj, r, u))
for x in _path(adj, r, v): # the list runs from v up to r
if x in pu:
return x
raise AssertionError
def test_example() -> None:
f = LinkCutForest(7, [5, 3, 8, 1, 4, 7, 2])
for u, v in [(0, 1), (1, 2), (1, 3), (3, 4), (4, 5), (0, 6)]:
f.link(u, v)
assert f.path_sum(2, 5) == 8 + 3 + 1 + 4 + 7
assert f.path_max(6, 4) == 5
assert f.lca(0, 2, 5) == 1
f.cut(1, 3)
assert not f.connected(2, 5)
assert f.path_sum(3, 5) == 12
f.link(2, 5)
assert f.path_sum(6, 3) == 2 + 5 + 3 + 8 + 7 + 4 + 1
assert f.find_root(4) in range(7)
def test_errors() -> None:
f = LinkCutForest(3)
f.link(0, 1)
try:
f.link(1, 0)
raise AssertionError
except ValueError:
pass
try:
f.cut(0, 2)
raise AssertionError
except ValueError:
pass
def test_random_against_naive() -> None:
rng = random.Random(2024)
for _ in range(60):
n = rng.randint(1, 12)
vals = [rng.randint(-9, 9) for _ in range(n)]
f = LinkCutForest(n, vals)
adj: list[set[int]] = [set() for _ in range(n)]
edges: list[tuple[int, int]] = []
for _ in range(200):
op = rng.random()
u, v = rng.randrange(n), rng.randrange(n)
if op < 0.3:
if u != v and _path(adj, u, v) is None:
f.link(u, v)
adj[u].add(v)
adj[v].add(u)
edges.append((u, v))
elif op < 0.45 and edges:
a, b = edges.pop(rng.randrange(len(edges)))
if rng.random() < 0.5:
a, b = b, a
f.cut(a, b)
adj[a].discard(b)
adj[b].discard(a)
elif op < 0.55:
x = rng.randint(-9, 9)
f.set_value(u, x)
vals[u] = x
p = _path(adj, u, v)
assert f.connected(u, v) == (p is not None)
if p is not None:
assert f.path_sum(u, v) == sum(vals[x] for x in p)
assert f.path_max(v, u) == max(vals[x] for x in p)
r = rng.choice(p)
assert f.lca(r, u, v) == r
w = rng.randrange(n)
if _path(adj, w, u) is not None:
assert f.lca(w, u, v) == _lca(adj, w, u, v)
def test_long_path_no_recursion_error() -> None:
n = 5000
f = LinkCutForest(n, list(range(n)))
for i in range(n - 1):
f.link(i, i + 1)
assert f.path_sum(0, n - 1) == n * (n - 1) // 2
f.cut(2500, 2499)
assert not f.connected(0, n - 1)
assert f.path_max(2500, n - 1) == n - 1
What the next level needs
- the tests did not run in this build