Algorithm Search⌘K

VF2 algorithm in Python

Depth-first search over partial mappings with cheap feasibility rules for graph isomorphism and induced subgraph isomorphism, using memory linear in the graph size.

File
vf2_algorithm.py, 148 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
vf2_algorithm.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/vf2-algorithm/. The authors are listed in prov/M8JFJA.jsonl.
"""VF2 state-space search for graph isomorphism, induced subgraph isomorphism and monomorphism.

Graphs are undirected and simple, given as (n, edges) with vertices 0..n-1, and may carry
integer vertex labels that a mapping must keep. G1 is the pattern and G2 the host.
The search is iterative, with an explicit stack, so its depth is not limited by recursion.
"""

from collections.abc import Iterator

Graph = tuple[int, list[tuple[int, int]]]
MODES = ("iso", "induced", "mono")


def _adj(n: int, edges: list[tuple[int, int]]) -> list[set[int]]:
    a: list[set[int]] = [set() for _ in range(n)]
    for u, v in edges:
        a[u].add(v)
        a[v].add(u)
    return a


def vf2_matches(g1: Graph, g2: Graph, mode: str = "iso",
                labels1: list[int] | None = None, labels2: list[int] | None = None) -> Iterator[list[int]]:
    """Yield every mapping m (m[u] is the host vertex of pattern vertex u) of the given kind.

    mode "iso": bijection keeping edges and non-edges; "induced": injection keeping edges and
    non-edges; "mono": injection keeping edges. Cost: exponential in the worst case; each search
    state costs O(n1 + n2 + d1 * d2) time for degree bounds d1, d2; space O(n1 + n2 + m1 + m2).
    """
    if mode not in MODES:
        raise ValueError("mode must be one of " + ", ".join(MODES))
    n1, e1 = g1
    n2, e2 = g2
    if mode == "iso" and (n1 != n2 or len(e1) != len(e2)):
        return
    if n1 > n2:
        return
    a1, a2 = _adj(n1, e1), _adj(n2, e2)
    lab1 = labels1 if labels1 is not None else [0] * n1
    lab2 = labels2 if labels2 is not None else [0] * n2
    core1 = [-1] * n1
    core2 = [-1] * n2
    t1 = [0] * n1  # number of mapped neighbors; an unmapped vertex with t > 0 is in T
    t2 = [0] * n2

    def candidates() -> list[tuple[int, int]]:
        # @line L3
        term1 = [u for u in range(n1) if core1[u] < 0 and t1[u] > 0]
        term2 = [v for v in range(n2) if core2[v] < 0 and t2[v] > 0]
        if term1:
            if not term2:
                return []
            u = term1[0]
            return [(u, v) for v in term2]
        if mode == "iso" and term2:
            return []
        u = next(x for x in range(n1) if core1[x] < 0)
        pool = [v for v in range(n2) if core2[v] < 0] if mode != "iso" else [v for v in range(n2) if core2[v] < 0 and t2[v] == 0]
        return [(u, v) for v in pool]

    def feasible(u: int, v: int) -> bool:
        # @line L9
        if lab1[u] != lab2[v]:
            return False
        if mode == "iso" and len(a1[u]) != len(a2[v]):
            return False
        if mode != "iso" and len(a1[u]) > len(a2[v]):
            return False
        # @line L10
        for w in a1[u]:
            if core1[w] >= 0 and core1[w] not in a2[v]:
                return False
        if mode != "mono":
            for x in a2[v]:
                if core2[x] >= 0 and core2[x] not in a1[u]:
                    return False
        # @line L11
        term_u = sum(1 for w in a1[u] if core1[w] < 0 and t1[w] > 0)
        term_v = sum(1 for x in a2[v] if core2[x] < 0 and t2[x] > 0)
        new_u = sum(1 for w in a1[u] if core1[w] < 0 and t1[w] == 0)
        new_v = sum(1 for x in a2[v] if core2[x] < 0 and t2[x] == 0)
        if mode == "iso":
            return term_u == term_v and new_u == new_v
        # @line L12
        if mode == "induced":
            return term_u <= term_v and new_u <= new_v
        return term_u <= term_v and term_u + new_u <= term_v + new_v

    def push(u: int, v: int) -> None:
        core1[u], core2[v] = v, u
        for w in a1[u]:
            t1[w] += 1
        for x in a2[v]:
            t2[x] += 1

    def pop(u: int, v: int) -> None:
        core1[u], core2[v] = -1, -1
        for w in a1[u]:
            t1[w] -= 1
        for x in a2[v]:
            t2[x] -= 1

    # @line L1
    if n1 == 0:
        yield []
        return
    depth = 0
    # a frame is [candidate pairs, index of the next pair to try, pair applied from this frame or None]
    stack: list[list] = [[candidates(), 0, None]]
    while stack:
        frame = stack[-1]
        if frame[2] is not None:
            # @line L8
            pop(*frame[2])
            frame[2] = None
            depth -= 1
        pairs, i = frame[0], frame[1]
        # @line L4
        while i < len(pairs) and not feasible(*pairs[i]):
            i += 1
        if i == len(pairs):
            stack.pop()
            continue
        # @line L5
        u, v = pairs[i]
        frame[1] = i + 1
        # @line L6
        push(u, v)
        frame[2] = (u, v)
        depth += 1
        # @line L2
        if depth == n1:
            yield list(core1)
            continue
        # @line L7
        stack.append([candidates(), 0, None])


def vf2_isomorphic(g1: Graph, g2: Graph, labels1: list[int] | None = None, labels2: list[int] | None = None) -> bool:
    """True when g1 and g2 are isomorphic (labels kept). Cost: as vf2_matches, stopping at the first match."""
    return next(vf2_matches(g1, g2, "iso", labels1, labels2), None) is not None


def vf2_count(g1: Graph, g2: Graph, mode: str = "iso") -> int:
    """Number of mappings of the given mode. Cost: as vf2_matches, over the whole search."""
    return sum(1 for _ in vf2_matches(g1, g2, mode))
test_vf2_algorithm.py 116 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/vf2-algorithm/. The authors are listed in prov/M8JFJA.jsonl.

import itertools
import random

import pytest

from vf2_algorithm import vf2_count, vf2_isomorphic, vf2_matches

Edges = list[tuple[int, int]]


def brute(n1: int, e1: Edges, n2: int, e2: Edges, mode: str,
          l1: list[int] | None = None, l2: list[int] | None = None) -> set[tuple[int, ...]]:
    l1 = l1 or [0] * n1
    l2 = l2 or [0] * n2
    s2 = {frozenset(e) for e in e2}
    s1 = {frozenset(e) for e in e1}
    out = set()
    if mode == "iso" and n1 != n2:
        return out
    for m in itertools.permutations(range(n2), n1):
        if any(l1[u] != l2[m[u]] for u in range(n1)):
            continue
        ok = True
        for u, v in itertools.combinations(range(n1), 2):
            a = frozenset((u, v)) in s1
            b = frozenset((m[u], m[v])) in s2
            if (a and not b) or (mode != "mono" and b and not a):
                ok = False
                break
        if ok:
            out.add(m)
    return out


def rand_graph(rng: random.Random, n: int, p: float) -> Edges:
    return [(u, v) for u in range(n) for v in range(u + 1, n) if rng.random() < p]


def rook4() -> tuple[int, Edges]:
    c = [(i, j) for i in range(4) for j in range(4)]
    return 16, [(a, b) for a in range(16) for b in range(a + 1, 16) if c[a][0] == c[b][0] or c[a][1] == c[b][1]]


def shrikhande() -> tuple[int, Edges]:
    c = [(i, j) for i in range(4) for j in range(4)]
    d = {(0, 1), (0, 3), (1, 0), (3, 0), (1, 1), (3, 3)}
    return 16, [(a, b) for a in range(16) for b in range(a + 1, 16)
                if ((c[b][0] - c[a][0]) % 4, (c[b][1] - c[a][1]) % 4) in d]


def test_page_example() -> None:
    g1 = (4, [(0, 1), (1, 2), (2, 0), (2, 3)])
    g2 = (4, [(3, 1), (1, 0), (0, 3), (0, 2)])
    assert list(vf2_matches(g1, g2, "iso")) == [[1, 3, 0, 2], [3, 1, 0, 2]]
    p3 = (3, [(0, 1), (1, 2)])
    c5 = (5, [(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)])
    k4 = (4, [(i, j) for i in range(4) for j in range(i + 1, 4)])
    c4 = (4, [(0, 1), (1, 2), (2, 3), (3, 0)])
    assert vf2_count(p3, c5, "mono") == 10 and vf2_count(p3, c5, "induced") == 10
    assert vf2_count(c4, k4, "mono") == 24 and vf2_count(c4, k4, "induced") == 0


def test_edge_cases() -> None:
    assert list(vf2_matches((0, []), (0, []))) == [[]]
    assert list(vf2_matches((0, []), (3, [(0, 1)]), "induced")) == [[]]
    assert vf2_count((1, []), (1, [])) == 1
    assert vf2_count((2, []), (1, []), "mono") == 0
    assert not vf2_isomorphic((3, [(0, 1)]), (3, [(1, 2), (0, 2)]))
    assert vf2_isomorphic((2, []), (2, []), [0, 1], [1, 0])
    assert not vf2_isomorphic((2, []), (2, []), [0, 1], [1, 1])
    with pytest.raises(ValueError):
        list(vf2_matches((1, []), (1, []), "bad"))


def test_regular_and_strongly_regular_pairs() -> None:
    c6 = (6, [(i, (i + 1) % 6) for i in range(6)])
    two_k3 = (6, [(0, 1), (1, 2), (0, 2), (3, 4), (4, 5), (3, 5)])
    assert not vf2_isomorphic(c6, two_k3)
    assert vf2_count(c6, c6) == 12
    r, s = rook4(), shrikhande()
    assert not vf2_isomorphic(r, s)
    assert vf2_count(r, r) == 1152
    assert vf2_count(s, s) == 192
    perm = list(range(16))
    random.Random(3).shuffle(perm)
    s2 = (16, [(perm[u], perm[v]) for u, v in s[1]])
    m = next(vf2_matches(s, s2))
    assert m is not None


def test_random_against_brute_force() -> None:
    rng = random.Random(20261105)
    for trial in range(700):
        mode = ("iso", "induced", "mono")[trial % 3]
        if mode == "iso":
            n1 = n2 = rng.randint(1, 6)
        else:
            n2 = rng.randint(1, 7)
            n1 = rng.randint(1, min(n2, 5))
        p = rng.choice([0.25, 0.5, 0.75])
        e2 = rand_graph(rng, n2, p)
        if mode == "iso" and trial % 2 == 0:
            perm = list(range(n2))
            rng.shuffle(perm)
            e1 = [(perm[u], perm[v]) for u, v in e2]
        else:
            e1 = rand_graph(rng, n1, p)
        labelled = trial % 5 == 0
        l1 = [rng.randrange(2) for _ in range(n1)] if labelled else None
        l2 = [rng.randrange(2) for _ in range(n2)] if labelled else None
        got = [tuple(m) for m in vf2_matches((n1, e1), (n2, e2), mode, l1, l2)]
        assert len(got) == len(set(got))
        assert set(got) == brute(n1, e1, n2, e2, mode, l1, l2)

What the next level needs

  • the tests did not run in this build

Read the full page about VF2 algorithm