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
# 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