Algorithm Search⌘K

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
euler_tour_lca.pyRawGitHub
# 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

Read the full page about LCA by Euler tour and RMQ