Algorithm Search⌘K

Yao's minimum spanning tree algorithm in Python

Deterministic MST algorithm with O(m log log n) running time.

File
yao_mst.py, 122 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
yao_mst.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/yao-mst/. The authors are listed in prov/CXZBFW.jsonl.

import math


def _select(items: list, r: int, key) -> object:
    """Return the item of rank r (0-based) in items under key, by median of medians."""
    while True:
        if len(items) <= 5:
            return sorted(items, key=key)[r]
        groups = [sorted(items[i:i + 5], key=key) for i in range(0, len(items), 5)]
        medians = [g[len(g) // 2] for g in groups]
        pivot = _select(medians, len(medians) // 2, key)
        pk = key(pivot)
        low = [x for x in items if key(x) < pk]
        high = [x for x in items if key(x) > pk]
        if r < len(low):
            items = low
        elif r == len(low):
            return pivot
        else:
            r -= len(low) + 1
            items = high


def _split(items: list, k: int, key) -> list[list]:
    """Split items into k groups, each key in a group below each key in the next."""
    if k == 1 or not items:
        return [items] + [[] for _ in range(k - 1)]
    k1 = k // 2
    r = len(items) * k1 // k
    if r == 0:
        low, high = [], items
    else:
        pk = key(_select(items, r - 1, key))
        low = [x for x in items if key(x) <= pk]
        high = [x for x in items if key(x) > pk]
    return _split(low, k1, key) + _split(high, k - k1, key)


def yao_mst(n: int, edges: list[tuple[int, int, float]], phases: int | None = None) -> list[int]:
    """Return the indices of the edges of a minimum spanning forest, in increasing order.

    The vertices are 0 .. n - 1 and edges[i] = (u, v, w) is an undirected
    edge of weight w. Weights may be negative and may tie; ties are broken by
    the edge index, so the result is the unique minimum forest for the order
    (w, index). The graph may be disconnected, and parallel edges and
    self-loops are allowed. phases is the number of plain Borůvka phases
    before the grouped phases; the default is ceil(log2 log2 n).

    Cost: O(m log log n) time and O(n + m) extra space, where m = len(edges).
    """
    key = lambda i: (edges[i][2], i)
    forest: list[int] = []  # @line L1
    comp = list(range(n))

    def find(x: int) -> int:
        while comp[x] != x:
            comp[x] = comp[comp[x]]
            x = comp[x]
        return x

    def boruvka_phase() -> bool:
        best: dict[int, int] = {}
        for i, (u, v, _) in enumerate(edges):
            a, b = find(u), find(v)
            if a == b:
                continue
            for c in (a, b):
                if c not in best or key(i) < key(best[c]):
                    best[c] = i
        for i in set(best.values()):
            a, b = find(edges[i][0]), find(edges[i][1])
            if a != b:
                comp[a] = b
                forest.append(i)
        return bool(best)

    if phases is None:
        phases = math.ceil(math.log2(math.log2(n))) if n > 2 else 1
    for _ in range(phases):  # @line L2
        if not boruvka_phase():
            break
    # The contracted graph H: one vertex for each component, only edges between components.
    reps = sorted({find(x) for x in range(n)})
    inc: dict[int, list[int]] = {r: [] for r in reps}
    for i, (u, v, _) in enumerate(edges):
        a, b = find(u), find(v)
        if a != b:
            inc[a].append(i)
            inc[b].append(i)
    hv = [r for r in reps if inc[r]]
    k = max(1, math.ceil(math.log2(len(hv)))) if len(hv) > 1 else 1  # @line L3
    groups: dict[int, list[list[int]]] = {}
    cur: dict[int, int] = {}
    for v in hv:  # @line L4
        groups[v] = _split(inc[v], k, key)  # @line L5
        cur[v] = 0  # @line L6

    while True:  # @line L7
        best: dict[int, int] = {}
        for x in hv:  # @line L8
            c = find(x)  # @line L9
            while True:  # @line L10
                g = [i for i in groups[x][cur[x]] if find(edges[i][0]) != find(edges[i][1])]
                groups[x][cur[x]] = g
                if g or cur[x] == k - 1:
                    break
                cur[x] += 1
            if g:
                ev = min(g, key=key)  # @line L11
                if c not in best or key(ev) < key(best[c]):
                    best[c] = ev  # @line L12
        if not best:
            break
        for i in set(best.values()):  # @line L13
            a, b = find(edges[i][0]), find(edges[i][1])
            if a != b:
                comp[a] = b
                forest.append(i)
    return sorted(forest)  # @line L14
test_yao_mst.py 137 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/yao-mst/. The authors are listed in prov/CXZBFW.jsonl.

import itertools
import random

from yao_mst import _split, yao_mst

EXAMPLE = [(0, 1, 4), (0, 2, 3), (1, 2, 1), (1, 3, 2), (2, 3, 4), (2, 4, 5), (3, 4, 7), (3, 5, 6), (4, 5, 2)]


def kruskal_oracle(n, edges):
    """The unique minimum spanning forest for the order (w, index)."""
    parent = list(range(n))

    def find(x):
        while parent[x] != x:
            x = parent[x]
        return x

    out = []
    for i in sorted(range(len(edges)), key=lambda i: (edges[i][2], i)):
        a, b = find(edges[i][0]), find(edges[i][1])
        if a != b:
            parent[a] = b
            out.append(i)
    return sorted(out)


def components(n, edges, idx):
    parent = list(range(n))

    def find(x):
        while parent[x] != x:
            x = parent[x]
        return x

    for i in idx:
        parent[find(edges[i][0])] = find(edges[i][1])
    return len({find(x) for x in range(n)})


def brute_min_weight(n, edges):
    """Least weight of a spanning forest with the components of the graph, by trying all edge sets."""
    target = components(n, edges, range(len(edges)))
    size = n - target
    best = None
    for sub in itertools.combinations(range(len(edges)), size):
        if components(n, edges, sub) == target:
            w = sum(edges[i][2] for i in sub)
            best = w if best is None or w < best else best
    return best


def weight(edges, idx):
    return sum(edges[i][2] for i in idx)


def test_example_all_phase_counts():
    for p in (0, 1, 2, 3):
        assert yao_mst(6, EXAMPLE, phases=p) == [1, 2, 3, 5, 8]
    assert yao_mst(6, EXAMPLE) == [1, 2, 3, 5, 8]


def test_example_groups():
    key = lambda i: (EXAMPLE[i][2], i)
    assert _split([1, 2, 4, 5], 3, key) == [[2], [1], [4, 5]]


def test_split_is_ordered_and_balanced():
    rng = random.Random(7)
    for _ in range(300):
        d = rng.randint(0, 40)
        k = rng.randint(1, 9)
        keys = [rng.randint(0, 5) for _ in range(d)]
        key = lambda i: (keys[i], i)
        gs = _split(list(range(d)), k, key)
        assert len(gs) == k
        assert sorted(x for g in gs for x in g) == list(range(d))
        for g, h in zip(gs, gs[1:]):
            if g and h:
                assert max(map(key, g)) < min(map(key, h))
        assert max(len(g) for g in gs) <= -(-d // k) + 1


def test_empty_and_single():
    assert yao_mst(0, []) == []
    assert yao_mst(1, []) == []
    assert yao_mst(1, [(0, 0, 5)]) == []
    assert yao_mst(2, [(0, 1, 3)]) == [0]


def test_ties_all_equal():
    k4 = [(a, b, 1) for a, b in itertools.combinations(range(4), 2)]
    for p in (0, 1, 2):
        assert yao_mst(4, k4, phases=p) == kruskal_oracle(4, k4)


def test_parallel_edges_and_loops():
    edges = [(0, 1, 5), (0, 1, 2), (1, 1, -9), (1, 2, 2), (2, 0, 2)]
    assert yao_mst(3, edges, phases=0) == [1, 3]


def test_negative_weights():
    edges = [(0, 1, -3), (1, 2, -1), (0, 2, -2)]
    assert yao_mst(3, edges, phases=0) == [0, 2]


def test_disconnected():
    edges = [(0, 1, 1), (1, 2, 2), (0, 2, 3), (3, 4, 1), (4, 5, 1), (3, 5, 1)]
    for p in (0, 1, 5):
        out = yao_mst(7, edges, phases=p)
        assert out == kruskal_oracle(7, edges)
        assert len(out) == 7 - 3


def test_random_against_brute_force():
    rng = random.Random(1975)
    for _ in range(600):
        n = rng.randint(1, 7)
        m = rng.randint(0, 9)
        edges = [(rng.randrange(n), rng.randrange(n), rng.randint(-3, 4)) for _ in range(m)]
        p = rng.choice([None, 0, 1, 2])
        out = yao_mst(n, edges, phases=p)
        assert out == kruskal_oracle(n, edges)
        assert len(out) == n - components(n, edges, range(m))
        assert weight(edges, out) == brute_min_weight(n, edges)


def test_random_large_against_kruskal():
    rng = random.Random(23)
    for _ in range(60):
        n = rng.randint(2, 120)
        m = rng.randint(0, 400)
        edges = [(rng.randrange(n), rng.randrange(n), rng.randint(0, 20)) for _ in range(m)]
        for p in (None, 0):
            assert yao_mst(n, edges, phases=p) == kruskal_oracle(n, edges)

What the next level needs

  • the tests did not run in this build

Read the full page about Yao's minimum spanning tree algorithm