Boyer-Myrvold planarity test in Python
Simplified linear-time planarity test that also produces an embedding or a Kuratowski subgraph.
- File
boyer_myrvold_planarity.py, 330 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/boyer-myrvold-planarity-test/. The authors are listed in prov/1RE4H8.jsonl.
"""Boyer-Myrvold edge addition planarity test.
Input: adjacency lists adj[v] of a simple undirected graph on 0..n-1.
No embedding is given. Output of planar_embedding: a rotation system
rot[v], the neighbours of v in counterclockwise order, or None if the
graph is not planar.
The code follows Boyer and Myrvold (2004): vertices are processed in
decreasing DFS order; Walkup marks the pertinent part of the embedding for
the back edges into v, and Walkdown adds those back edges along the
external faces of the biconnected components, merging components at cut
vertices. Flips are lazy: a flipped component gets sign -1 on its root
tree edge, and one pass at the end fixes the orientation of every list.
The short-circuit edges of the paper are left out; they do not change the
result. Everything is iterative.
kuratowski_subgraph returns the edges of a subdivision of K5 or K3,3 for
a non-planar graph. It deletes edges while the graph stays non-planar,
which takes O(m) runs of the test; the paper's isolator does it in linear
time but is much longer.
"""
def _check_simple(adj):
n = len(adj)
for v in range(n):
seen = set()
for w in adj[v]:
if not 0 <= w < n or w == v or w in seen:
raise ValueError("graph must be simple, without loops")
seen.add(w)
for w in adj[v]:
if v not in adj[w]:
raise ValueError("adjacency lists must be symmetric")
def planar_embedding(adj):
"""Return a planar rotation system of the graph, or None."""
_check_simple(adj)
n = len(adj)
m = sum(len(a) for a in adj) // 2
if n >= 3 and m > 3 * n - 6: # @line L1
return None
if n == 0:
return []
# Depth-first search: dfi, parent, lowpoint, least ancestor. # @line L2
dfi = [-1] * n
vert = []
par = [-1] * n # in DFI labels
nb = [None] * n # neighbours in DFI labels, filled below
for r in range(n):
if dfi[r] != -1:
continue
dfi[r] = len(vert)
vert.append(r)
stack = [(r, 0)]
while stack:
u, i = stack[-1]
if i < len(adj[u]):
stack[-1] = (u, i + 1)
x = adj[u][i]
if dfi[x] == -1:
dfi[x] = len(vert)
vert.append(x)
par[dfi[x]] = dfi[u]
stack.append((x, 0))
else:
stack.pop()
for v in range(n):
nb[v] = [dfi[x] for x in adj[vert[v]]]
children = [[] for _ in range(n)]
for v in range(n):
if par[v] != -1:
children[par[v]].append(v)
least = list(range(n)) # least ancestor by a direct back edge
fwd = [[] for _ in range(n)] # descendants w with a back edge (w, v)
for v in range(n):
for w in nb[v]:
if w < v and w != par[v]:
if w < least[v]:
least[v] = w
elif w > v and par[w] != v:
fwd[v].append(w)
low = least[:]
for v in range(n - 1, -1, -1):
p = par[v]
if p != -1 and low[v] < low[p]:
low[p] = low[v]
buckets = [[] for _ in range(n)]
for c in range(n):
if par[c] != -1:
buckets[low[c]].append(c)
sep = [[] for _ in range(n)] # children sorted by lowpoint
for b in buckets:
for c in b:
sep[par[c]].append(c)
sp = [0] * n
merged = [False] * n
# Embedding structure. Nodes 0..n-1 are vertices, n + c is the root
# copy of par[c] in the component of the tree edge (par[c], c).
N = 2 * n
head = [[-1] * N, [-1] * N] # the two ends of each adjacency list
to = []
lk = [[], []] # lk[s][a]: the neighbour of arc a toward end s
sign = [1] * n
def new_arc_pair(x, y):
to.extend((y, x))
lk[0].extend((-1, -1))
lk[1].extend((-1, -1))
return len(to) - 2
def insert(x, a, s):
h = head[s][x]
lk[s][a] = -1
lk[1 - s][a] = h
if h == -1:
head[1 - s][x] = a
else:
lk[s][h] = a
head[s][x] = a
def ext_next(x, e):
"""Leave x on the side other than e; return (y, side of arrival).
If y has one arc, both sides are that arc, and the walk keeps its
side, as it would in a consistently oriented component."""
a = head[1 - e][x]
y = to[a]
b = a ^ 1
if head[0][y] == head[1][y]:
return y, e
return y, (0 if head[0][y] == b else 1)
for c in range(n): # @line L3
if par[c] != -1:
a = new_arc_pair(n + c, c)
insert(n + c, a, 0)
insert(c, a ^ 1, 0)
bflag = [-1] * n
visited = [-1] * N
pr = [[] for _ in range(n)] # pertinent roots: internal first, then external
pr_head = [0] * n
def ext_active(w, v):
if least[w] < v:
return True
s = sep[w]
i = sp[w]
while i < len(s) and merged[s[i]]:
i += 1
sp[w] = i
return i < len(s) and low[s[i]] < v
def pertinent(w, v):
return bflag[w] == v or pr_head[w] < len(pr[w])
def first_active(rc, e, v):
"""First vertex on the external face from root rc, leaving on the
side other than e, that is pertinent or externally active."""
y, ye = ext_next(rc, e)
while y != rc and not pertinent(y, v) and not ext_active(y, v):
y, ye = ext_next(y, ye)
return y, ye
def walkup(v, w):
bflag[w] = v # @line L11
x = w
while True:
if visited[x] == v:
return
visited[x] = v
zig = (x, 1)
zag = (x, 0)
root = -1
while root == -1: # @line L12
for k in range(2):
y, e = ext_next(*(zig if k == 0 else zag))
if y >= n:
root = y
break
if visited[y] == v:
return
visited[y] = v
if k == 0:
zig = (y, e)
else:
zag = (y, e)
if visited[root] == v:
return
visited[root] = v
c = root - n
p = par[c]
if p == v:
return
lst = pr[p]
if low[c] < v: # externally active component goes last # @line L13
lst.append(root)
else: # internally active component goes first
if pr_head[p] > 0:
pr_head[p] -= 1
lst[pr_head[p]] = root
else:
lst.insert(0, root)
x = p
def merge(u, ue, rc, dc):
c = rc - n
if dc == ue: # flip the child component # @line L24
a = head[0][rc]
while a != -1:
nxt = lk[1][a]
lk[0][a], lk[1][a] = lk[1][a], lk[0][a]
a = nxt
head[0][rc], head[1][rc] = head[1][rc], head[0][rc]
sign[c] = -1
a = head[0][rc]
while a != -1:
to[a ^ 1] = u
a = lk[1][a]
s = ue # @line L25
hu = head[s][u]
lk[s][hu] = head[1 - s][rc]
lk[1 - s][head[1 - s][rc]] = hu
head[s][u] = head[s][rc]
head[0][rc] = head[1][rc] = -1
pr_head[u] += 1
merged[c] = True
def walkdown(v, R):
for d in (0, 1): # @line L14
stack = []
w, e = ext_next(R, 1 - d) # @line L15
while w != R: # @line L16
if w >= n:
break
if bflag[w] == v: # @line L17
while stack:
u, ue, rc, dc = stack.pop()
merge(u, ue, rc, dc)
a = new_arc_pair(R, w)
insert(R, a, d)
insert(w, a ^ 1, e)
bflag[w] = -1
if pr_head[w] < len(pr[w]): # @line L18
rc = pr[w][pr_head[w]]
x, xe = first_active(rc, 1, v) # @line L19
y, ye = first_active(rc, 0, v)
if pertinent(x, v) and not ext_active(x, v):
choice = (x, xe, 0)
elif pertinent(y, v) and not ext_active(y, v):
choice = (y, ye, 1)
elif pertinent(x, v):
choice = (x, xe, 0)
else:
choice = (y, ye, 1)
stack.append((w, e, rc, choice[2])) # @line L20
w, e = choice[0], choice[1]
elif not ext_active(w, v): # inactive: skip it # @line L21
w, e = ext_next(w, e)
else: # stopping vertex # @line L22
break
if stack: # @line L23
return
for v in range(n - 1, -1, -1): # @line L4
for w in fwd[v]:
walkup(v, w) # @line L5
for c in children[v]:
walkdown(v, n + c) # @line L6
for w in fwd[v]:
if bflag[w] == v: # a back edge was not embedded # @line L7
return None
# Join the remaining components at their cut vertices. # @line L8
for c in range(n):
if par[c] != -1 and not merged[c]:
u, rc = par[c], n + c
a = head[0][rc]
while a != -1:
to[a ^ 1] = u
a = lk[1][a]
if head[0][u] == -1:
head[0][u], head[1][u] = head[0][rc], head[1][rc]
else:
lk[1][head[1][u]] = head[0][rc]
lk[0][head[0][rc]] = head[1][u]
head[1][u] = head[1][rc]
# Fix orientations: a vertex is reversed if the product of signs on
# its tree path is -1. # @line L9
orient = [1] * n
rot = [None] * n
for v in range(n):
if par[v] != -1:
orient[v] = orient[par[v]] * sign[v]
lst = []
a = head[0][v]
while a != -1:
lst.append(vert[to[a]])
a = lk[1][a]
if orient[v] == -1:
lst.reverse()
rot[vert[v]] = lst
return rot # @line L10
def is_planar(adj):
return planar_embedding(adj) is not None
def kuratowski_subgraph(adj):
"""Return the edge list of a subdivision of K5 or K3,3 in a non-planar
graph, or None if the graph is planar."""
if is_planar(adj):
return None
n = len(adj)
cur = [set(a) for a in adj]
edges = [(u, w) for u in range(n) for w in adj[u] if u < w]
for u, w in edges:
cur[u].discard(w)
cur[w].discard(u)
if planar_embedding([sorted(s) for s in cur]) is not None:
cur[u].add(w)
cur[w].add(u)
return [(u, w) for u in range(n) for w in cur[u] if u < w]
boyer_myrvold_planarity_test.py 8 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/boyer-myrvold-planarity-test/. The authors are listed in prov/1RE4H8.jsonl.
"""Entry point named after the slug. The code is in boyer_myrvold_planarity.py,
because the tools treat a file whose name ends in _test as a test file."""
from boyer_myrvold_planarity import is_planar, kuratowski_subgraph, planar_embedding
__all__ = ["is_planar", "kuratowski_subgraph", "planar_embedding"]
test_boyer_myrvold_planarity_test.py 343 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/boyer-myrvold-planarity-test/. The authors are listed in prov/1RE4H8.jsonl.
import random
import pytest
from boyer_myrvold_planarity_test import is_planar, kuratowski_subgraph, planar_embedding
def oracle_planar(n, edges):
"""Independent oracle for small graphs, by Kuratowski's theorem.
Vertices of degree at most 2 are removed or suppressed (this keeps
planarity in both directions). A graph with more than 3k - 6 edges is
not planar. Otherwise the graph is non-planar exactly when it is K5 or
K3,3, or when deleting some edge leaves a non-planar graph.
"""
memo = {}
def reduce(E):
E = set(E)
while True:
nbr = {}
for u, v in E:
nbr.setdefault(u, []).append(v)
nbr.setdefault(v, []).append(u)
changed = False
for x, nb in nbr.items():
if len(nb) == 1:
E.discard((min(x, nb[0]), max(x, nb[0])))
changed = True
break
if len(nb) == 2:
a, b = nb
E.discard((min(x, a), max(x, a)))
E.discard((min(x, b), max(x, b)))
E.add((min(a, b), max(a, b)))
changed = True
break
if not changed:
return frozenset(E), nbr
def kuratowski_graph(E, nbr):
V = list(nbr)
if len(V) == 5 and len(E) == 10:
return True
if len(V) == 6 and len(E) == 9 and all(len(nbr[x]) == 3 for x in V):
col = {V[0]: 0}
st = [V[0]]
while st:
x = st.pop()
for y in nbr[x]:
if y not in col:
col[y] = 1 - col[x]
st.append(y)
elif col[y] == col[x]:
return False
return sum(col.values()) == 3
return False
def nonplanar(E):
E, nbr = reduce(E)
if E in memo:
return memo[E]
k = len(nbr)
if k >= 3 and len(E) > 3 * k - 6:
r = True
elif k <= 4:
r = False
elif kuratowski_graph(E, nbr):
r = True
else:
r = any(nonplanar(E - {e}) for e in E)
memo[E] = r
return r
return not nonplanar(frozenset((min(u, v), max(u, v)) for u, v in edges))
def random_triangulation(n, rng):
"""Edges of a random plane triangulation on n >= 3 vertices."""
faces = [(0, 1, 2), (0, 2, 1)]
E = {(0, 1), (0, 2), (1, 2)}
for v in range(3, n):
i = rng.randrange(len(faces))
a, b, c = faces[i]
faces[i] = faces[-1]
faces.pop()
faces += [(a, b, v), (b, c, v), (c, a, v)]
E |= {(min(a, v), max(a, v)), (min(b, v), max(b, v)), (min(c, v), max(c, v))}
return E
def to_adj(n, edges, rng=None):
adj = [[] for _ in range(n)]
for u, v in edges:
adj[u].append(v)
adj[v].append(u)
if rng is not None:
for a in adj:
rng.shuffle(a)
return adj
def complete(n):
return [(i, j) for i in range(n) for j in range(i + 1, n)]
K33 = [(a, b) for a in range(3) for b in range(3, 6)]
PETERSEN = ([(i, (i + 1) % 5) for i in range(5)] + [(i, i + 5) for i in range(5)]
+ [(5 + i, 5 + (i + 2) % 5) for i in range(5)])
OCTAHEDRON = [(i, j) for i in range(6) for j in range(i + 1, 6) if j != i + 3]
def subdivide(n, edges, k):
"""Replace every edge by a path with k inner vertices."""
out = []
for u, v in edges:
prev = u
for _ in range(k):
out.append((prev, n))
prev = n
n += 1
out.append((prev, v))
return n, out
def random_case(rng):
"""A small random graph near the planarity threshold."""
n = rng.randint(1, 9)
if n >= 3 and rng.random() < 0.6:
E = {e for e in random_triangulation(n, rng) if rng.random() < 0.85}
for _ in range(rng.randint(0, 3)):
u, v = rng.sample(range(n), 2)
E.add((min(u, v), max(u, v)))
else:
p = rng.random()
E = {(u, v) for u in range(n) for v in range(u + 1, n) if rng.random() < p}
perm = list(range(n))
rng.shuffle(perm)
return n, [(perm[u], perm[v]) for u, v in E]
def is_plane_rotation(adj, rot):
"""True if rot is a rotation system of adj whose faces satisfy Euler's
formula n - m + f = 1 + c on every component (isolated vertices count
one face each), that is, a planar embedding."""
n = len(adj)
if len(rot) != n:
return False
for v in range(n):
if sorted(rot[v]) != sorted(adj[v]):
return False
pos = [{w: i for i, w in enumerate(r)} for r in rot]
seen = set()
faces = 0
for v in range(n):
for w in rot[v]:
if (v, w) in seen:
continue
faces += 1
a, b = v, w
while (a, b) not in seen:
seen.add((a, b))
r = rot[b]
a, b = b, r[(pos[b][a] + 1) % len(r)]
comp = [-1] * n
c = 0
for s in range(n):
if comp[s] == -1:
comp[s] = c
st = [s]
while st:
x = st.pop()
for y in adj[x]:
if comp[y] == -1:
comp[y] = c
st.append(y)
c += 1
m = sum(len(a) for a in adj) // 2
isolated = sum(1 for v in range(n) if not adj[v])
return n - m + faces + isolated == 2 * c
def is_kuratowski(adj, K):
"""True if the edge list K is a subgraph of adj that is a subdivision
of K5 or K3,3."""
nbr = {}
for u, v in K:
if v not in adj[u]:
return False
nbr.setdefault(u, set()).add(v)
nbr.setdefault(v, set()).add(u)
changed = True
while changed:
changed = False
for x in list(nbr):
if len(nbr[x]) == 2:
a, b = nbr[x]
if b in nbr[a]:
return False
nbr[a].discard(x)
nbr[b].discard(x)
nbr[a].add(b)
nbr[b].add(a)
del nbr[x]
changed = True
break
V = list(nbr)
m = sum(len(s) for s in nbr.values()) // 2
if any(len(nbr[x]) < 3 for x in V):
return False
if len(V) == 5 and m == 10:
return True
if len(V) == 6 and m == 9:
col = {V[0]: 0}
st = [V[0]]
while st:
x = st.pop()
for y in nbr[x]:
if y not in col:
col[y] = 1 - col[x]
st.append(y)
elif col[y] == col[x]:
return False
return sum(col.values()) == 3
return False
@pytest.mark.parametrize("n,edges,expected", [
(0, [], True),
(1, [], True),
(2, [(0, 1)], True),
(3, [], True),
(5, [(0, 1), (1, 2), (1, 3), (3, 4)], True),
(4, complete(4), True),
(5, complete(5), False),
(6, K33, False),
(6, K33[:-1], True),
(5, complete(5)[:-1], True),
(10, PETERSEN, False),
(6, OCTAHEDRON, True),
])
def test_small_cases(n, edges, expected):
adj = to_adj(n, edges)
rot = planar_embedding(adj)
assert (rot is not None) is expected
assert is_planar(adj) is expected
if expected:
assert is_plane_rotation(adj, rot)
assert kuratowski_subgraph(adj) is None
else:
assert is_kuratowski(adj, kuratowski_subgraph(adj))
def test_k4_rotation():
rot = planar_embedding(to_adj(4, complete(4)))
assert is_plane_rotation(to_adj(4, complete(4)), rot)
assert len(rot) == 4 and all(len(r) == 3 for r in rot)
def test_random_against_oracle():
rng = random.Random(2004)
seen = [0, 0]
for _ in range(1500):
n, edges = random_case(rng)
expected = oracle_planar(n, edges)
seen[expected] += 1
adj = to_adj(n, edges, rng)
rot = planar_embedding(adj)
assert (rot is not None) is expected, (n, edges)
if expected:
assert is_plane_rotation(adj, rot), (n, edges)
assert min(seen) > 150
def test_certificates_on_medium_graphs():
rng = random.Random(41)
seen = [0, 0]
for _ in range(150):
n = rng.randint(10, 40)
E = {e for e in random_triangulation(n, rng) if rng.random() < rng.choice([0.7, 1.0])}
for _ in range(rng.choice([0, 1, 3])):
u, v = rng.sample(range(n), 2)
E.add((min(u, v), max(u, v)))
adj = to_adj(n, E, rng)
rot = planar_embedding(adj)
seen[rot is not None] += 1
if rot is not None:
assert is_plane_rotation(adj, rot)
else:
assert is_kuratowski(adj, kuratowski_subgraph(adj))
assert min(seen) > 20
def test_disconnected_and_cut_vertices():
adj = to_adj(9, [(0, 1), (1, 2)] + [(u + 4, v + 4) for u, v in complete(5)])
assert planar_embedding(adj) is None
chain = []
for b in range(50):
chain += [(3 * b + u, 3 * b + v) for u, v in complete(4)]
adj = to_adj(151, chain)
assert is_plane_rotation(adj, planar_embedding(adj))
def test_large_inputs():
rng = random.Random(5)
n = 30000
E = random_triangulation(n, rng)
adj = to_adj(n, E, rng)
assert is_plane_rotation(adj, planar_embedding(adj))
u, v = 0, n - 1
while (min(u, v), max(u, v)) in E:
v -= 1
E.add((min(u, v), max(u, v)))
assert planar_embedding(to_adj(n, E, rng)) is None
n = 200000
adj = to_adj(n, [(i, (i + 1) % n) for i in range(n)])
assert is_plane_rotation(adj, planar_embedding(adj))
k = 150
grid = [(i * k + j, i * k + j + 1) for i in range(k) for j in range(k - 1)]
grid += [(i * k + j, (i + 1) * k + j) for i in range(k - 1) for j in range(k)]
adj = to_adj(k * k, grid, rng)
assert is_plane_rotation(adj, planar_embedding(adj))
def test_long_subdivisions():
n, E = subdivide(6, K33, 2000)
assert planar_embedding(to_adj(n, E)) is None
n, E = subdivide(5, complete(5)[:-1], 2000)
adj = to_adj(n, E)
assert is_plane_rotation(adj, planar_embedding(adj))
def test_rejects_bad_input():
with pytest.raises(ValueError):
planar_embedding([[0]])
with pytest.raises(ValueError):
planar_embedding([[1, 1], [0, 0]])
with pytest.raises(ValueError):
planar_embedding([[1], []])
with pytest.raises(ValueError):
planar_embedding([[3]])
What the next level needs
- the tests did not run in this build