LCA by Euler tour and RMQ in Python
Reduce LCA to a range-minimum query on the Euler tour; constant time per query after preprocessing.
- File
euler_tour_lca.py, 83 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-lca/. The authors are listed in prov/SKXZFC.jsonl.
class EulerTourLca:
"""Lowest common ancestors by an Euler tour and a sparse table.
The tree has the vertices 0 .. n-1 (n >= 1), is unweighted, and is given
by its n - 1 undirected edges and a root. Children are visited in
increasing label order. Preprocessing takes O(n log n) time and memory;
each query takes O(1) time. No recursion is used.
"""
def __init__(self, n: int, edges: list[tuple[int, int]], root: int = 0) -> None:
if n < 1 or len(edges) != n - 1 or not 0 <= root < n:
raise ValueError("a tree needs n >= 1 vertices, n - 1 edges and a root in range")
adj: list[list[int]] = [[] for _ in range(n)]
for a, b in edges:
if not (0 <= a < n and 0 <= b < n) or a == b:
raise ValueError("edge endpoints must be distinct vertices in range")
adj[a].append(b)
adj[b].append(a)
for lst in adj:
lst.sort()
depth = [-1] * n
first = [0] * n
nxt = [0] * n # index of the next neighbour of each vertex to try
depth[root] = 0 # @line L1
first[root] = 0
euler = [root]
stack = [root]
while stack: # @line L2
v = stack[-1] # @line L3
nbrs = adj[v]
i = nxt[v]
while i < len(nbrs) and depth[nbrs[i]] != -1:
i += 1
nxt[v] = i + 1
if i < len(nbrs): # @line L4
c = nbrs[i]
depth[c] = depth[v] + 1 # @line L5
first[c] = len(euler)
euler.append(c)
stack.append(c)
else:
stack.pop() # @line L6
if stack:
euler.append(stack[-1])
if len(euler) != 2 * n - 1:
raise ValueError("the edges do not form a tree")
self.n = n
self.root = root
self.depth = depth
self.first = first
self.euler = euler
table = [euler[:]] # @line L7
m = len(euler)
k = 1
while (1 << k) <= m: # @line L8
prev = table[-1]
half = 1 << (k - 1)
# @line L9
row = [a if depth[a] <= depth[b] else b for a, b in zip(prev[: m - (1 << k) + 1], prev[half:])]
table.append(row)
k += 1
self._table = table
def lca(self, u: int, v: int) -> int:
"""Return the lowest common ancestor of u and v."""
if not (0 <= u < self.n and 0 <= v < self.n):
raise IndexError("vertex out of range")
lo, hi = self.first[u], self.first[v] # @line L10
if lo > hi:
lo, hi = hi, lo
k = (hi - lo + 1).bit_length() - 1 # @line L11
row = self._table[k]
a, b = row[lo], row[hi - (1 << k) + 1] # @line L12
return a if self.depth[a] <= self.depth[b] else b
def distance(self, u: int, v: int) -> int:
"""Return the number of edges on the path between u and v."""
w = self.lca(u, v)
return self.depth[u] + self.depth[v] - 2 * self.depth[w]
test_euler_tour_lca.py 99 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/euler-tour-lca/. The authors are listed in prov/SKXZFC.jsonl.
import random
import pytest
from euler_tour_lca import EulerTourLca
PARENT = [-1, 0, 1, 2, 3, 4, 2, 6, 0, 8, 3, 7]
def tree_edges(parent: list[int]) -> list[tuple[int, int]]:
return [(v, p) for v, p in enumerate(parent) if p >= 0]
def naive_lca(parent: list[int], u: int, v: int) -> int:
seen = set()
while u != -1:
seen.add(u)
u = parent[u]
while v not in seen:
v = parent[v]
return v
def test_page_example() -> None:
t = EulerTourLca(12, tree_edges(PARENT), 0)
assert t.euler == [0, 1, 2, 3, 4, 5, 4, 3, 10, 3, 2, 6, 7, 11, 7, 6, 2, 1, 0, 8, 9, 8, 0]
assert t.first == [0, 1, 2, 3, 4, 5, 11, 12, 19, 20, 8, 13]
assert t.lca(5, 11) == 2
assert t.lca(11, 5) == 2
assert t.lca(9, 10) == 0
assert t.lca(4, 10) == 3
assert t.lca(5, 4) == 4
assert t.lca(7, 7) == 7
assert t.distance(5, 11) == 6
def test_single_vertex() -> None:
t = EulerTourLca(1, [], 0)
assert t.euler == [0]
assert t.lca(0, 0) == 0
def test_star() -> None:
n = 6
t = EulerTourLca(n, [(0, i) for i in range(1, n)], 0)
assert t.euler == [0, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0]
for a in range(1, n):
for b in range(1, n):
assert t.lca(a, b) == (a if a == b else 0)
# rooted at a leaf
t = EulerTourLca(n, [(0, i) for i in range(1, n)], 3)
assert t.lca(1, 2) == 0
assert t.lca(1, 3) == 3
def test_long_path() -> None:
n = 100_000
t = EulerTourLca(n, [(i, i + 1) for i in range(n - 1)], 0)
assert len(t.euler) == 2 * n - 1
rng = random.Random(1)
for _ in range(2000):
u, v = rng.randrange(n), rng.randrange(n)
assert t.lca(u, v) == min(u, v)
# the same path rooted in the middle
t = EulerTourLca(n, [(i, i + 1) for i in range(n - 1)], n // 2)
assert t.lca(0, n - 1) == n // 2
assert t.lca(10, 20) == 20
def test_bad_input() -> None:
with pytest.raises(ValueError):
EulerTourLca(0, [], 0)
with pytest.raises(ValueError):
EulerTourLca(3, [(0, 1)], 0)
with pytest.raises(ValueError):
EulerTourLca(4, [(0, 1), (1, 0), (2, 3)], 0)
t = EulerTourLca(2, [(0, 1)], 0)
with pytest.raises(IndexError):
t.lca(0, 2)
def test_random_against_naive() -> None:
rng = random.Random(7)
for _ in range(600):
n = rng.randint(1, 40)
perm = list(range(n))
rng.shuffle(perm)
parent = [-1] * n
for i in range(1, n):
parent[perm[i]] = perm[rng.randrange(i)]
root = perm[0]
t = EulerTourLca(n, tree_edges(parent), root)
assert len(t.euler) == 2 * n - 1
for _ in range(30):
u, v = rng.randrange(n), rng.randrange(n)
assert t.lca(u, v) == naive_lca(parent, u, v)
What the next level needs
- the tests did not run in this build