Algorithm Search⌘K

Hao-Orlin algorithm in Python

Compute the directed global min cut in the time of a single push-relabel run.

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

from collections import deque
from fractions import Fraction
from typing import Callable, Optional, Union

Number = Union[int, Fraction, float]


def _rooted_min_cut(
    n: int,
    edges: list[tuple[int, int, Number]],
    s: int,
    on_phase: Optional[Callable[[int, Number, list[int]], None]],
) -> tuple[Number, list[int]]:
    """Least c(X, V - X) over sets X with s in X and X != V, by one Hao-Orlin run."""
    head: list[int] = []
    res: list[Number] = []
    adj: list[list[int]] = [[] for _ in range(n)]
    for u, v, cap in edges:
        if u == v:
            continue
        adj[u].append(len(head))
        head.append(v)
        res.append(cap)
        adj[v].append(len(head))
        head.append(u)
        res.append(0)
    excess: list[Number] = [0] * n
    d = [0] * n  # @line L1
    d[s] = n
    in_s = [False] * n
    in_s[s] = True
    awake = [v != s for v in range(n)]  # membership of W
    dormant: list[list[int]] = []  # dormant sets other than S, oldest first
    count: dict[int, int] = {0: n - 1}  # number of awake vertices with each label

    def push(a: int, amount: Number) -> None:
        res[a] -= amount
        res[a ^ 1] += amount
        excess[head[a ^ 1]] -= amount
        excess[head[a]] += amount

    def put_to_sleep(group: list[int]) -> None:
        for w in group:
            awake[w] = False
            count[d[w]] -= 1
        dormant.append(group)

    for a in adj[s]:  # @line L2
        if res[a] > 0:
            push(a, res[a])
    best_value: Optional[Number] = None
    best_side: list[int] = []
    t = min((v for v in range(n) if awake[v]), key=lambda v: (d[v], v))  # @line L3
    for _ in range(n - 1):  # @line L4
        queue = deque(v for v in range(n) if awake[v] and v != t and excess[v] > 0)
        it = [0] * n
        while queue:  # @line L5
            v = queue.popleft()
            while awake[v] and v != t and excess[v] > 0:
                if it[v] < len(adj[v]):
                    a = adj[v][it[v]]
                    w = head[a]
                    if res[a] > 0 and awake[w] and d[v] == d[w] + 1:  # @line L6
                        amount = min(excess[v], res[a])
                        push(a, amount)
                        if w != t and excess[w] > 0 and excess[w] <= amount:
                            queue.append(w)
                    else:
                        it[v] += 1
                    continue
                it[v] = 0
                if count[d[v]] == 1:  # @line L7
                    put_to_sleep([w for w in range(n) if awake[w] and d[w] >= d[v]])
                else:
                    labels = [d[head[a]] for a in adj[v] if res[a] > 0 and awake[head[a]]]
                    if not labels:  # @line L8
                        put_to_sleep([v])
                    else:
                        count[d[v]] -= 1  # @line L9
                        d[v] = 1 + min(labels)
                        count[d[v]] = count.get(d[v], 0) + 1
        value: Number = 0  # @line L10
        for u, v, cap in edges:
            if not awake[u] and awake[v]:
                value += cap
        side = [v for v in range(n) if not awake[v]]
        if on_phase is not None:
            on_phase(t, value, side)
        if best_value is None or value < best_value:
            best_value, best_side = value, side
        in_s[t] = True  # @line L11
        awake[t] = False
        count[d[t]] -= 1
        d[t] = n
        for a in adj[t]:  # @line L12
            if res[a] > 0 and not in_s[head[a]]:
                push(a, res[a])
        if not any(awake):  # @line L13
            if not dormant:
                break
            for w in dormant.pop():
                awake[w] = True
                count[d[w]] = count.get(d[w], 0) + 1
        t = min((v for v in range(n) if awake[v]), key=lambda v: (d[v], v))  # @line L14
    assert best_value is not None
    return best_value, best_side


def hao_orlin(
    n: int,
    edges: list[tuple[int, int, Number]],
    on_phase: Optional[Callable[[str, int, Number, list[int]], None]] = None,
) -> tuple[Number, list[int]]:
    """Minimum cut of a directed graph: the least c(X, V - X) over X with X != {}, V.

    The graph has the vertices 0 .. n-1 (n >= 2) and the directed edges (u, v, cap),
    cap >= 0 (int, Fraction or float). Parallel, antiparallel and zero-capacity edges
    are allowed; self-loops are ignored. For an undirected graph give both directions.
    Returns (value, X) with X the sorted source side. Two Hao-Orlin runs from vertex 0,
    one on the graph and one on its reverse, each with n - 1 sink phases sharing one
    push-relabel state. on_phase(run, sink, value, side) reports each phase, with run
    "forward" or "reverse" and side the source side found in that run's graph.
    """
    if n < 2:
        raise ValueError("need at least two vertices")
    for u, v, cap in edges:
        if cap < 0:
            raise ValueError("capacities must be nonnegative")
        if not (0 <= u < n and 0 <= v < n):
            raise ValueError("vertex out of range")
    fwd = None if on_phase is None else (lambda t, val, side: on_phase("forward", t, val, side))
    rev = None if on_phase is None else (lambda t, val, side: on_phase("reverse", t, val, side))
    v1, x1 = _rooted_min_cut(n, edges, 0, fwd)  # @line L15
    v2, x2 = _rooted_min_cut(n, [(v, u, c) for u, v, c in edges], 0, rev)
    if v2 < v1:
        return v2, sorted(set(range(n)) - set(x2))
    return v1, x1
test_hao_orlin.py 102 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/hao-orlin/. The authors are listed in prov/X5P88R.jsonl.

import random
from fractions import Fraction
from itertools import combinations

import pytest

from hao_orlin import hao_orlin

S, A, B, C, D, T = range(6)
RUNNING = [(S, A, 10), (S, C, 10), (A, B, 4), (A, C, 2), (A, D, 8), (C, D, 9), (D, B, 6), (B, T, 10), (D, T, 10)]


def cut(edges, side):
    return sum(c for u, v, c in edges if u in side and v not in side)


def brute(n, edges):
    best = None
    for k in range(1, n):
        for side in combinations(range(n), k):
            c = cut(edges, set(side))
            best = c if best is None else min(best, c)
    return best


def check(n, edges):
    value, side = hao_orlin(n, edges)
    assert 0 < len(side) < n
    assert cut(edges, set(side)) == value
    assert value == brute(n, edges)
    return value, side


def test_running_network_has_a_zero_cut() -> None:
    # nothing enters s, so the cut V - {s} has capacity 0
    assert check(6, RUNNING) == (0, [A, B, C, D, T])


def test_running_network_with_return_arc() -> None:
    edges = RUNNING + [(T, S, 15)]
    log = []
    value, side = hao_orlin(6, edges, lambda *a: log.append(a))
    assert (value, side) == (9, [C])
    assert len(log) == 10
    assert min(v for r, _, v, _ in log if r == "forward") == 10
    check(6, edges)


def test_no_path_and_zero_capacity() -> None:
    assert check(3, [(0, 1, 5), (2, 1, 5)])[0] == 0
    assert check(2, [(0, 1, 0), (1, 0, 3)])[0] == 0
    assert check(2, [])[0] == 0


def test_antiparallel_edges() -> None:
    edges = [(0, 1, 3), (1, 0, 2), (1, 2, 4), (2, 1, 1), (2, 0, 5), (0, 2, 1)]
    # the six cuts: {0} 4, {1} 6, {2} 6, {0, 1} 5, {0, 2} 4, {1, 2} 7
    assert check(3, edges) == (4, [0])


def test_undirected_cycle() -> None:
    n = 6
    edges = []
    for i in range(n):
        edges += [(i, (i + 1) % n, 1), ((i + 1) % n, i, 1)]
    assert check(n, edges)[0] == 2


def test_fractions_and_floats() -> None:
    edges = [(0, 1, Fraction(1, 2)), (1, 2, Fraction(1, 3)), (2, 0, Fraction(1, 6)), (1, 0, Fraction(1, 7))]
    check(3, edges)
    value, _ = hao_orlin(2, [(0, 1, 0.5), (1, 0, 0.25)])
    assert value == 0.25


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


def test_random_against_brute_force() -> None:
    rng = random.Random(1994)
    for _ in range(700):
        n = rng.randint(2, 7)
        edges = [(rng.randrange(n), rng.randrange(n), rng.choice([0, 1, 2, 3, 5, 8])) for _ in range(rng.randint(0, 18))]
        check(n, edges)


def test_random_dense_strongly_connected() -> None:
    rng = random.Random(7)
    for _ in range(150):
        n = rng.randint(3, 8)
        edges = [(i, (i + 1) % n, rng.randint(1, 4)) for i in range(n)]
        edges += [(rng.randrange(n), rng.randrange(n), rng.randint(0, 9)) for _ in range(3 * n)]
        check(n, edges)

What the next level needs

  • the tests did not run in this build

Read the full page about Hao-Orlin algorithm