Algorithm Search⌘K

Nagamochi-Ibaraki algorithm in Python

Deterministic min cut via maximum adjacency orderings and sparse certificates.

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

import heapq
from itertools import count


def nagamochi_ibaraki(n: int, edges: list[tuple[int, int, float]]) -> tuple[float, set[int]]:
    """Global minimum cut of an undirected graph by maximum adjacency orderings.

    Vertices are 0 .. n-1 with n >= 2, and edges (u, v, w) are undirected with
    weight w >= 0, an integer or a real number. Parallel edges add up and
    self-loops are ignored. Returns (value, S), where S is a nonempty proper
    subset of the vertices whose cut has the least total weight.

    Each round computes one maximum adjacency ordering with a heap, gives every
    edge the value q(e), a lower bound on the connectivity of its ends, and
    contracts all edges with q(e) at least the best cut found so far.
    """
    if n < 2:
        raise ValueError("need at least two vertices")
    adj: dict[int, dict[int, float]] = {v: {} for v in range(n)}
    for u, v, c in edges:
        if c < 0:
            raise ValueError("weights must be nonnegative")
        if u != v and c > 0:
            adj[u][v] = adj[u].get(v, 0) + c
            adj[v][u] = adj[v].get(u, 0) + c
    group = {v: {v} for v in range(n)}
    best, best_set = None, set()  # @line L1
    while len(adj) > 1:  # @line L2
        for v, nbrs in adj.items():  # @line L3
            d = sum(nbrs.values())
            if best is None or d < best:
                best, best_set = d, set(group[v])
        if best == 0:  # @line L4
            break
        q = ma_ordering(adj)  # @line L5
        merge = {v: v for v in adj}

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

        for (u, v), value in q.items():  # @line L6
            if value >= best:
                ru, rv = find(u), find(v)
                if ru != rv:
                    merge[rv] = ru
        new_adj: dict[int, dict[int, float]] = {}
        for v in adj:
            rv = find(v)
            new_adj.setdefault(rv, {})
            if rv != v:
                group[rv] |= group.pop(v)
        for u, nbrs in adj.items():
            ru = find(u)
            for v, c in nbrs.items():
                rv = find(v)
                if ru != rv:
                    new_adj[ru][rv] = new_adj[ru].get(rv, 0) + c
        adj = new_adj
    return best, best_set  # @line L7


def ma_ordering(adj: dict[int, dict[int, float]]) -> dict[tuple[int, int], float]:
    """One maximum adjacency ordering; returns q(e) for each edge e = (u, v), u scanned first.

    q(e) is the key of v right after the weight of e is added to it, that is,
    the total weight from v to the vertices scanned up to and including u.
    """
    key = {v: 0 for v in adj}
    scanned = set()
    tie = count()
    heap = [(0, next(tie), v) for v in adj]
    heapq.heapify(heap)
    q = {}
    while heap:
        negk, _, u = heapq.heappop(heap)
        if u in scanned or -negk != key[u]:
            continue
        scanned.add(u)
        for v, c in adj[u].items():
            if v not in scanned:
                key[v] += c
                q[(u, v)] = key[v]
                heapq.heappush(heap, (-key[v], next(tie), v))
    return q
test_nagamochi_ibaraki.py 97 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/nagamochi-ibaraki/. The authors are listed in prov/6ZSTKE.jsonl.

import itertools
import random
from fractions import Fraction

import pytest

from nagamochi_ibaraki import nagamochi_ibaraki, ma_ordering

# a, b, c, d, e, f = 0 .. 5: triangles abc and def with weight 2, joined by cd and be
EXAMPLE = [(0, 1, 2), (0, 2, 2), (1, 2, 2), (3, 4, 2), (3, 5, 2), (4, 5, 2), (2, 3, 1), (1, 4, 1)]


def cut_value(edges, side):
    return sum(c for u, v, c in edges if (u in side) != (v in side))


def brute(n, edges):
    """Independent oracle: the least cut over all nonempty proper subsets holding vertex 0."""
    best = None
    for r in range(n - 1):
        for extra in itertools.combinations(range(1, n), r):
            c = cut_value(edges, {0, *extra})
            best = c if best is None else min(best, c)
    return best


def check(n, edges, value, side):
    assert 0 < len(side) < n
    assert cut_value(edges, side) == value


def test_example() -> None:
    value, side = nagamochi_ibaraki(6, EXAMPLE)
    assert value == 2
    assert side in ({0, 1, 2}, {3, 4, 5})
    check(6, EXAMPLE, value, side)


def test_two_vertices_and_parallel_edges() -> None:
    assert nagamochi_ibaraki(2, [(0, 1, 3), (1, 0, 4)]) == (7, {0})
    assert nagamochi_ibaraki(2, []) == (0, {0})


def test_disconnected_zero_weight_and_loops() -> None:
    value, side = nagamochi_ibaraki(4, [(0, 1, 5), (2, 3, 5), (1, 2, 0), (0, 0, 9)])
    assert value == 0
    check(4, [(0, 1, 5), (2, 3, 5), (1, 2, 0)], value, side)


def test_cycle_and_real_weights() -> None:
    cycle = [(i, (i + 1) % 6, 1) for i in range(6)]
    assert nagamochi_ibaraki(6, cycle)[0] == 2
    edges = [(0, 1, Fraction(1, 2)), (1, 2, Fraction(1, 3)), (2, 0, Fraction(1, 7)), (2, 3, Fraction(2, 5))]
    value, side = nagamochi_ibaraki(4, edges)
    assert value == brute(4, edges) == Fraction(2, 5)
    check(4, edges, value, side)


def test_errors() -> None:
    with pytest.raises(ValueError):
        nagamochi_ibaraki(1, [])
    with pytest.raises(ValueError):
        nagamochi_ibaraki(2, [(0, 1, -1)])


def test_random_against_brute_force() -> None:
    rng = random.Random(31337)
    for _ in range(800):
        n = rng.randint(2, 8)
        m = rng.randint(0, 20)
        edges = [(rng.randrange(n), rng.randrange(n), rng.choice([0, 1, 1, 2, 3, 7]))
                 for _ in range(m)]
        value, side = nagamochi_ibaraki(n, edges)
        assert value == brute(n, edges)
        check(n, edges, value, side)


def test_q_is_a_lower_bound_on_local_connectivity() -> None:
    rng = random.Random(5)
    for _ in range(300):
        n = rng.randint(2, 7)
        edges = [(rng.randrange(n), rng.randrange(n), rng.randint(1, 5)) for _ in range(rng.randint(1, 14))]
        adj = {v: {} for v in range(n)}
        for u, v, c in edges:
            if u != v:
                adj[u][v] = adj[u].get(v, 0) + c
                adj[v][u] = adj[v].get(u, 0) + c
        for (u, v), q in ma_ordering(adj).items():
            local = min(
                cut_value(edges, {u, *extra})
                for r in range(n - 1)
                for extra in itertools.combinations([x for x in range(n) if x not in (u, v)], r)
            )
            assert q <= local

What the next level needs

  • the tests did not run in this build

Read the full page about Nagamochi-Ibaraki algorithm