Algorithm Search⌘K

Dulmage-Mendelsohn decomposition in Python

Canonical block decomposition of a bipartite graph from all of its maximum matchings.

File
dulmage_mendelsohn_decomposition.py, 203 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
dulmage_mendelsohn_decomposition.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/dulmage-mendelsohn-decomposition/. The authors are listed in prov/7KYG8D.jsonl.

from collections import deque
from typing import NamedTuple

INF = float("inf")


class Part(NamedTuple):
    left: list[int]  # sorted left vertices of the part
    right: list[int]  # sorted right vertices of the part


class DMDecomposition(NamedTuple):
    size: int  # the matching number
    mate_left: list[int]  # a maximum matching: mate_left[x] is y or -1
    mate_right: list[int]  # mate_right[y] is x or -1
    x_surplus: Part  # reachable from free left vertices; more left than right
    blocks: list[Part]  # the square part, in topological order
    y_surplus: Part  # reachable from free right vertices; more right than left


def dulmage_mendelsohn(
    n_left: int, n_right: int, edges: list[tuple[int, int]]
) -> DMDecomposition:
    """Dulmage-Mendelsohn decomposition of a bipartite graph.

    The left side is 0 .. n_left - 1, the right side 0 .. n_right - 1, and each
    pair (x, y) joins left x to right y; the graph is unweighted. Returns a
    maximum matching, the part reachable by alternating paths from free left
    vertices, the part reachable from free right vertices, and the blocks of the
    rest, ordered so that every edge from block i to block j has its left end in
    block i, its right end in block j, and i < j.
    O((n + m) sqrt(n)) time for the matching, then O(n + m); O(n + m) space.
    """
    adj_l: list[list[int]] = [[] for _ in range(n_left)]
    adj_r: list[list[int]] = [[] for _ in range(n_right)]
    for x, y in edges:
        if not (0 <= x < n_left and 0 <= y < n_right):
            raise ValueError("edge end out of range")
        adj_l[x].append(y)
        adj_r[y].append(x)
    mate_l, mate_r, size = _hopcroft_karp(n_left, n_right, adj_l)  # @line L1

    # Alternating search from the free left vertices: non-matching edges from
    # left to right, matching edges from right to left.
    in_xl = [mate_l[x] < 0 for x in range(n_left)]  # @line L2
    in_xr = [False] * n_right
    queue = deque(x for x in range(n_left) if in_xl[x])
    while queue:  # @line L3
        x = queue.popleft()
        for y in adj_l[x]:  # @line L4
            if not in_xr[y]:
                in_xr[y] = True
                x2 = mate_r[y]  # @line L5: y is matched, else an augmenting path
                if not in_xl[x2]:
                    in_xl[x2] = True
                    queue.append(x2)

    # The same search from the free right vertices, sides swapped.
    in_yr = [mate_r[y] < 0 for y in range(n_right)]  # @line L6
    in_yl = [False] * n_left
    queue = deque(y for y in range(n_right) if in_yr[y])
    while queue:
        y = queue.popleft()
        for x in adj_r[y]:
            if not in_yl[x]:
                in_yl[x] = True
                y2 = mate_l[x]
                if not in_yr[y2]:
                    in_yr[y2] = True
                    queue.append(y2)

    square = [  # @line L7
        x for x in range(n_left) if not in_xl[x] and not in_yl[x]
    ]
    # Digraph H on the square left vertices: x -> mate_r[y] for an edge xy.
    arcs: dict[int, list[int]] = {x: [] for x in square}  # @line L8
    for x in square:
        for y in adj_l[x]:
            x2 = mate_r[y]
            if x2 != x and x2 in arcs:
                arcs[x].append(x2)
    comps = _tarjan_scc(square, arcs)  # @line L9
    comps.reverse()  # Tarjan emits sink components first
    blocks = [  # @line L10
        Part(sorted(c), sorted(mate_l[x] for x in c)) for c in comps
    ]
    x_surplus = Part(
        [x for x in range(n_left) if in_xl[x]],
        [y for y in range(n_right) if in_xr[y]],
    )
    y_surplus = Part(
        [x for x in range(n_left) if in_yl[x]],
        [y for y in range(n_right) if in_yr[y]],
    )
    return DMDecomposition(  # @line L11
        size, mate_l, mate_r, x_surplus, blocks, y_surplus
    )


def _hopcroft_karp(
    n_left: int, n_right: int, adj: list[list[int]]
) -> tuple[list[int], list[int], int]:
    """Maximum bipartite matching in O((n + m) sqrt(n)) time, iterative."""
    mate_l = [-1] * n_left
    mate_r = [-1] * n_right
    size = 0
    while True:
        dist: list[float] = [0 if mate_l[x] < 0 else INF for x in range(n_left)]
        queue = deque(x for x in range(n_left) if mate_l[x] < 0)
        big_d: float = INF
        while queue:
            x = queue.popleft()
            if dist[x] < big_d:
                for y in adj[x]:
                    x2 = mate_r[y]
                    if x2 < 0:
                        big_d = min(big_d, dist[x] + 1)
                    elif dist[x2] == INF:
                        dist[x2] = dist[x] + 1
                        queue.append(x2)
        if big_d == INF:
            return mate_l, mate_r, size
        ptr = [0] * n_left
        for root in range(n_left):
            if mate_l[root] >= 0 or dist[root] != 0:
                continue
            stack = [root]
            level = {root: 0.0}
            dist[root] = INF
            chosen: dict[int, int] = {}
            while stack:
                x = stack[-1]
                if ptr[x] == len(adj[x]):
                    stack.pop()
                    continue
                y = adj[x][ptr[x]]
                ptr[x] += 1
                x2 = mate_r[y]
                if x2 < 0:
                    if level[x] + 1 == big_d:
                        chosen[x] = y
                        for v in stack:
                            mate_l[v] = chosen[v]
                            mate_r[chosen[v]] = v
                        size += 1
                        break
                elif dist[x2] == level[x] + 1:
                    chosen[x] = y
                    level[x2] = dist[x2]
                    dist[x2] = INF
                    stack.append(x2)


def _tarjan_scc(nodes: list[int], arcs: dict[int, list[int]]) -> list[list[int]]:
    """Strongly connected components by Tarjan's algorithm with an explicit stack.

    Components come out in reverse topological order. O(nodes + arcs) time.
    """
    index: dict[int, int] = {}
    low: dict[int, int] = {}
    on_stack: dict[int, bool] = {}
    stack: list[int] = []
    comps: list[list[int]] = []
    counter = 0
    for start in nodes:
        if start in index:
            continue
        work = [(start, 0)]
        index[start] = low[start] = counter
        counter += 1
        stack.append(start)
        on_stack[start] = True
        while work:
            v, i = work[-1]
            if i < len(arcs[v]):
                work[-1] = (v, i + 1)
                w = arcs[v][i]
                if w not in index:
                    index[w] = low[w] = counter
                    counter += 1
                    stack.append(w)
                    on_stack[w] = True
                    work.append((w, 0))
                elif on_stack.get(w, False):
                    low[v] = min(low[v], index[w])
            else:
                work.pop()
                if work:
                    u = work[-1][0]
                    low[u] = min(low[u], low[v])
                if low[v] == index[v]:
                    comp = []
                    while True:
                        w = stack.pop()
                        on_stack[w] = False
                        comp.append(w)
                        if w == v:
                            break
                    comps.append(comp)
    return comps
test_dulmage_mendelsohn_decomposition.py 190 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/dulmage-mendelsohn-decomposition/. The authors are listed in prov/7KYG8D.jsonl.

import random
import unittest

from dulmage_mendelsohn_decomposition import dulmage_mendelsohn


def all_maximum_matchings(n_left, n_right, edges):
    """Every maximum matching, as a frozenset of edge indices, by enumeration."""
    best: list[frozenset[int]] = []
    best_size = -1

    def rec(i, used_l, used_r, chosen):
        nonlocal best, best_size
        if i == len(edges):
            if len(chosen) > best_size:
                best_size = len(chosen)
                best = []
            if len(chosen) == best_size:
                best.append(frozenset(chosen))
            return
        rec(i + 1, used_l, used_r, chosen)
        x, y = edges[i]
        if not used_l >> x & 1 and not used_r >> y & 1:
            chosen.append(i)
            rec(i + 1, used_l | 1 << x, used_r | 1 << y, chosen)
            chosen.pop()

    rec(0, 0, 0, [])
    return best_size, best


def oracle(n_left, n_right, edges):
    """The canonical parts, computed from all maximum matchings."""
    nu, mms = all_maximum_matchings(n_left, n_right, edges)
    missed_l = set()
    missed_r = set()
    allowed = set()
    for mm in mms:
        cov_l = {edges[i][0] for i in mm}
        cov_r = {edges[i][1] for i in mm}
        missed_l |= set(range(n_left)) - cov_l
        missed_r |= set(range(n_right)) - cov_r
        allowed |= mm
    xs_r = {y for (x, y) in edges if x in missed_l}
    ys_l = {x for (x, y) in edges if y in missed_r}
    sq_l = set(range(n_left)) - missed_l - ys_l
    sq_r = set(range(n_right)) - missed_r - xs_r
    # Blocks: components of the allowed edges inside the square part.
    parent = {("l", x): ("l", x) for x in sq_l}
    parent.update({("r", y): ("r", y) for y in sq_r})

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

    for i in allowed:
        x, y = edges[i]
        if x in sq_l and y in sq_r:
            parent[find(("l", x))] = find(("r", y))
    groups: dict = {}
    for v in parent:
        groups.setdefault(find(v), set()).add(v)
    blocks = set()
    for g in groups.values():
        blocks.add(
            (
                tuple(sorted(v for s, v in g if s == "l")),
                tuple(sorted(v for s, v in g if s == "r")),
            )
        )
    return nu, (missed_l, xs_r), blocks, (ys_l, missed_r), allowed


def check(tc, n_left, n_right, edges, res):
    nu, xs, blocks, ys, allowed = oracle(n_left, n_right, edges)
    tc.assertEqual(res.size, nu)
    tc.assertEqual(sum(1 for y in res.mate_left if y >= 0), nu)
    for x, y in enumerate(res.mate_left):
        if y >= 0:
            tc.assertIn((x, y), set(edges))
            tc.assertEqual(res.mate_right[y], x)
    tc.assertEqual((set(res.x_surplus.left), set(res.x_surplus.right)), xs)
    tc.assertEqual((set(res.y_surplus.left), set(res.y_surplus.right)), ys)
    got_blocks = {(tuple(b.left), tuple(b.right)) for b in res.blocks}
    tc.assertEqual(got_blocks, blocks)
    for b in res.blocks:
        tc.assertEqual(len(b.left), len(b.right))
        tc.assertGreater(len(b.left), 0)
    # Surplus parts are strictly unbalanced when they are not empty.
    if res.x_surplus.left:
        tc.assertGreater(len(res.x_surplus.left), len(res.x_surplus.right))
    if res.y_surplus.right:
        tc.assertGreater(len(res.y_surplus.right), len(res.y_surplus.left))
    # Topological order: edges between blocks go from a left end in an
    # earlier block to a right end in a later one.
    where_l = {x: i for i, b in enumerate(res.blocks) for x in b.left}
    where_r = {y: i for i, b in enumerate(res.blocks) for y in b.right}
    for x, y in edges:
        if x in where_l and y in where_r:
            tc.assertLessEqual(where_l[x], where_r[y])
    # Allowed edges are exactly those inside one part or one block.
    for i, (x, y) in enumerate(edges):
        inside = (
            (x in xs[0] and y in xs[1])
            or (x in ys[0] and y in ys[1])
            or (x in where_l and y in where_r and where_l[x] == where_r[y])
        )
        tc.assertEqual(inside, i in allowed, (edges, x, y))


class TestDulmageMendelsohn(unittest.TestCase):
    def test_worked_example(self):
        # Left 1..6 -> 0..5, right a..f -> 0..5.
        edges = [(0, 0), (1, 0), (2, 0), (2, 1), (2, 2), (3, 1), (3, 2),
                 (2, 3), (4, 3), (5, 3), (5, 4), (5, 5)]
        res = dulmage_mendelsohn(6, 6, edges)
        self.assertEqual(res.size, 5)
        self.assertEqual(res.x_surplus, ([0, 1], [0]))
        self.assertEqual(res.y_surplus, ([5], [4, 5]))
        self.assertEqual(res.blocks, [([2, 3], [1, 2]), ([4], [3])])
        check(self, 6, 6, edges, res)

    def test_empty_graph(self):
        res = dulmage_mendelsohn(0, 0, [])
        self.assertEqual((res.size, res.blocks), (0, []))
        res = dulmage_mendelsohn(3, 2, [])
        self.assertEqual(res.x_surplus, ([0, 1, 2], []))
        self.assertEqual(res.y_surplus, ([], [0, 1]))
        self.assertEqual(res.blocks, [])

    def test_perfect_matching_graphs(self):
        # An even cycle is one block; a perfect matching alone is n blocks.
        c6 = [(0, 0), (0, 1), (1, 1), (1, 2), (2, 2), (2, 0)]
        res = dulmage_mendelsohn(3, 3, c6)
        self.assertEqual(res.blocks, [([0, 1, 2], [0, 1, 2])])
        check(self, 3, 3, c6, res)
        pm = [(0, 0), (1, 1), (2, 2)]
        res = dulmage_mendelsohn(3, 3, pm)
        self.assertEqual(len(res.blocks), 3)
        check(self, 3, 3, pm, res)

    def test_upper_triangular(self):
        # Edges (i, j) for i <= j: a chain of n one-by-one blocks in order.
        n = 6
        edges = [(i, j) for i in range(n) for j in range(i, n)]
        res = dulmage_mendelsohn(n, n, edges)
        self.assertEqual(res.blocks, [([i], [i]) for i in range(n)])
        check(self, n, n, edges, res)

    def test_no_perfect_matching(self):
        # Three left vertices share two right vertices: Hall's condition fails.
        edges = [(0, 0), (0, 1), (1, 0), (1, 1), (2, 0), (2, 1), (3, 2)]
        res = dulmage_mendelsohn(4, 3, edges)
        self.assertEqual(res.size, 3)
        self.assertEqual(res.x_surplus, ([0, 1, 2], [0, 1]))
        check(self, 4, 3, edges, res)

    def test_star_and_long_path(self):
        star = [(0, j) for j in range(4)]
        res = dulmage_mendelsohn(1, 4, star)
        self.assertEqual(res.y_surplus, ([0], [0, 1, 2, 3]))
        check(self, 1, 4, star, res)
        n = 2000  # path x0 y0 x1 y1 ..., deep searches must not recurse
        path = [(i, i) for i in range(n)] + [(i + 1, i) for i in range(n - 1)]
        res = dulmage_mendelsohn(n, n, path)
        self.assertEqual(res.size, n)
        self.assertEqual(len(res.blocks), n)

    def test_bad_edge(self):
        with self.assertRaises(ValueError):
            dulmage_mendelsohn(1, 1, [(0, 1)])

    def test_random_against_brute_force(self):
        rng = random.Random(1958)
        for _ in range(600):
            nl = rng.randint(0, 6)
            nr = rng.randint(0, 6)
            p = rng.random()
            edges = [(x, y) for x in range(nl) for y in range(nr) if rng.random() < p]
            edges = edges[:14]
            rng.shuffle(edges)
            check(self, nl, nr, edges, dulmage_mendelsohn(nl, nr, edges))


if __name__ == "__main__":
    unittest.main()

What the next level needs

  • the tests did not run in this build

Read the full page about Dulmage-Mendelsohn decomposition