Euler tour tree in Python
Stores each tree as a balanced sequence of its Euler tour, supporting link, cut and subtree queries.
- File
euler_tour_tree.py, 191 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/euler-tour-tree/. The authors are listed in prov/TH5ZDM.jsonl.
"""Euler tour trees: a dynamic forest stored as Euler tours in treaps.
Each vertex v has a loop node (v, v), and each tree edge {u, v} has two arc
nodes (u, v) and (v, u). The nodes of one tree, in the order of an Euler tour
of that tree, form one treap ordered by position. Supports link, cut,
connected, reroot, component aggregates and subtree aggregates in O(log n)
expected time per operation. The structure is fully dynamic for forests and
deterministic except for the random treap priorities.
"""
import random
class _Node:
__slots__ = ("pri", "left", "right", "parent", "size", "loops", "val", "agg", "u", "v")
def __init__(self, u: int, v: int, pri: float, val: int = 0) -> None:
self.u, self.v = u, v
self.pri = pri
self.left: "_Node | None" = None
self.right: "_Node | None" = None
self.parent: "_Node | None" = None
self.val = val
self.loops = 1 if u == v else 0
self.size = 1
self.agg = val
def _size(t: "_Node | None") -> int:
return t.size if t else 0
def _update(t: _Node) -> None:
t.size, t.loops, t.agg = 1, (1 if t.u == t.v else 0), t.val
for c in (t.left, t.right):
if c:
t.size += c.size
t.loops += c.loops
t.agg += c.agg
def _join(a: "_Node | None", b: "_Node | None") -> "_Node | None":
"""Concatenate two treaps (all of a before all of b). Depth O(log n) expected."""
if a is None:
return b
if b is None:
return a
if a.pri > b.pri:
r = _join(a.right, b)
a.right = r
r.parent = a
_update(a)
a.parent = None
return a
l = _join(a, b.left)
b.left = l
l.parent = b
_update(b)
b.parent = None
return b
def _split(t: "_Node | None", k: int) -> "tuple[_Node | None, _Node | None]":
"""Split t into its first k nodes and the rest."""
if t is None:
return None, None
t.parent = None
if _size(t.left) >= k:
a, b = _split(t.left, k)
t.left = b
if b:
b.parent = t
_update(t)
return a, t
a, b = _split(t.right, k - _size(t.left) - 1)
t.right = a
if a:
a.parent = t
_update(t)
return t, b
def _root(x: _Node) -> _Node:
while x.parent:
x = x.parent
return x
def _pos(x: _Node) -> int:
"""Index of x in its sequence."""
i = _size(x.left)
while x.parent:
p = x.parent
if p.right is x:
i += _size(p.left) + 1
x = p
return i
def _sum_prefix(t: "_Node | None", k: int) -> int:
"""Sum of the values of the first k nodes of t, without changing t."""
s = 0
while t and k > 0:
if _size(t.left) >= k:
t = t.left
continue
s += (t.left.agg if t.left else 0) + t.val
k -= _size(t.left) + 1
t = t.right
return s
class EulerTourForest:
"""A forest on vertices 0 .. n-1, with an integer value on each vertex."""
def __init__(self, n: int, values: "list[int] | None" = None, seed: int = 1) -> None:
self._rng = random.Random(seed)
vals = values if values is not None else [0] * n
self._loop = [_Node(v, v, self._rng.random(), vals[v]) for v in range(n)]
self._arc: dict[tuple[int, int], _Node] = {}
def reroot(self, v: int) -> _Node:
"""Rotate the tour of v's tree so that it starts at the loop node of v."""
x = self._loop[v]
a, b = _split(_root(x), _pos(x)) # @line L1
return _join(b, a) # @line L2
def connected(self, u: int, v: int) -> bool:
return _root(self._loop[u]) is _root(self._loop[v]) # @line L3
def link(self, u: int, v: int) -> None:
"""Add the edge {u, v}. Precondition: u and v are in different trees."""
if self.connected(u, v):
raise ValueError("u and v are already connected")
tu = self.reroot(u) # @line L4
tv = self.reroot(v)
a = _Node(u, v, self._rng.random()) # @line L5
b = _Node(v, u, self._rng.random())
self._arc[(u, v)], self._arc[(v, u)] = a, b
_join(_join(_join(tu, a), tv), b) # @line L6
def cut(self, u: int, v: int) -> None:
"""Remove the tree edge {u, v}. Precondition: it is an edge of the forest."""
a = self._arc.pop((u, v)) # @line L7
b = self._arc.pop((v, u))
if _pos(a) > _pos(b): # @line L8
a, b = b, a
r = _root(a)
i, j = _pos(a), _pos(b)
x, rest = _split(r, i) # @line L9
_, rest = _split(rest, 1)
y, rest = _split(rest, j - i - 1)
_, z = _split(rest, 1)
_join(x, z) # @line L10
def component_size(self, v: int) -> int:
return _root(self._loop[v]).loops # @line L11
def component_sum(self, v: int) -> int:
return _root(self._loop[v]).agg
def set_value(self, v: int, x: int) -> None:
t: "_Node | None" = self._loop[v]
t.val = x
while t:
_update(t)
t = t.parent
def subtree_sum(self, v: int, p: int) -> int:
"""Sum of the values on v's side of the tree edge {p, v}."""
a, b = self._arc[(p, v)], self._arc[(v, p)] # @line L12
r = _root(a)
i, j = _pos(a), _pos(b)
if i < j: # @line L13
return _sum_prefix(r, j) - _sum_prefix(r, i + 1)
return r.agg - (_sum_prefix(r, i) - _sum_prefix(r, j + 1)) # @line L14
def tour(self, v: int) -> list[tuple[int, int]]:
"""The nodes of v's tree in tour order, as (u, v) pairs. Iterative in-order walk."""
out, stack, t = [], [], _root(self._loop[v])
while stack or t:
while t:
stack.append(t)
t = t.left
t = stack.pop()
out.append((t.u, t.v))
t = t.right
return out
test_euler_tour_tree.py 107 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/euler-tour-tree/. The authors are listed in prov/TH5ZDM.jsonl.
import random
from euler_tour_tree import EulerTourForest
def _side(adj: dict[int, set[int]], v: int, p: int) -> set[int]:
seen, stack = {v}, [v]
while stack:
x = stack.pop()
for y in adj[x]:
if y not in seen and not (x == v and y == p):
seen.add(y)
stack.append(y)
return seen
def _comp(adj: dict[int, set[int]], v: int) -> set[int]:
return _side(adj, v, -1)
def _is_tour(tour: list[tuple[int, int]], verts: set[int]) -> bool:
"""A cyclic sequence of arcs and loops is an Euler tour of a tree."""
walk = [(u, v) for u, v in tour if u != v]
loops = {u for u, v in tour if u == v}
if loops != verts:
return False
for k in range(len(walk)):
if walk[k][1] != walk[(k + 1) % len(walk)][0]:
return False
return True
def test_example() -> None:
f = EulerTourForest(6, [1, 2, 3, 4, 5, 6])
for u, v in [(0, 1), (1, 2), (1, 3), (3, 4)]:
f.link(u, v)
assert f.connected(0, 4) and not f.connected(0, 5)
assert f.component_size(0) == 5 and f.component_sum(2) == 15
assert f.subtree_sum(3, 1) == 9
f.cut(1, 3)
assert not f.connected(0, 4)
assert f.component_size(0) == 3 and f.component_size(4) == 2
f.link(4, 5)
f.link(2, 5)
assert f.connected(0, 3) and f.component_sum(0) == 21
def test_single_vertex() -> None:
f = EulerTourForest(1, [7])
assert f.connected(0, 0)
assert f.component_size(0) == 1 and f.component_sum(0) == 7
assert f.tour(0) == [(0, 0)]
def test_random_against_naive() -> None:
rng = random.Random(4242)
for trial in range(40):
n = rng.randint(1, 12)
vals = [rng.randint(-5, 9) for _ in range(n)]
f = EulerTourForest(n, list(vals), seed=trial)
adj: dict[int, set[int]] = {v: set() for v in range(n)}
edges: set[tuple[int, int]] = set()
for _ in range(150):
op = rng.random()
u, v = rng.randrange(n), rng.randrange(n)
if op < 0.35 and u != v and v not in _comp(adj, u):
f.link(u, v)
adj[u].add(v)
adj[v].add(u)
edges.add((min(u, v), max(u, v)))
elif op < 0.55 and edges:
a, b = rng.choice(sorted(edges))
if rng.random() < 0.5:
a, b = b, a
f.cut(a, b)
adj[a].discard(b)
adj[b].discard(a)
edges.discard((min(a, b), max(a, b)))
elif op < 0.65:
x = rng.randint(-5, 9)
f.set_value(u, x)
vals[u] = x
elif op < 0.75:
f.reroot(u)
for w in range(n):
c = _comp(adj, w)
assert f.component_size(w) == len(c)
assert f.component_sum(w) == sum(vals[x] for x in c)
assert _is_tour(f.tour(w), c)
assert f.connected(u, v) == (v in _comp(adj, u))
for a, b in edges:
assert f.subtree_sum(a, b) == sum(vals[x] for x in _side(adj, a, b))
assert f.subtree_sum(b, a) == sum(vals[x] for x in _side(adj, b, a))
def test_long_path_no_recursion_error() -> None:
n = 3000
f = EulerTourForest(n, [1] * n)
for v in range(n - 1):
f.link(v, v + 1)
assert f.component_size(0) == n
assert f.subtree_sum(n // 2, n // 2 - 1) == n - n // 2
f.cut(1000, 1001)
assert f.component_size(0) == 1001 and f.component_size(n - 1) == n - 1001
What the next level needs
- the tests did not run in this build