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