Hopcroft-Tarjan planarity test in Python
First linear-time planarity test; it splits a depth-first search tree into paths and checks with a stack of blocks that the pieces hanging off each path can be put on two sides.
- File
hopcroft_tarjan_planarity.py, 284 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/hopcroft-tarjan-planarity-test/. The authors are listed in prov/QDYX9D.jsonl.
"""Hopcroft-Tarjan path addition planarity test, decision version.
The graph is given by adjacency lists adj[v] of a simple undirected graph.
No embedding is given or returned: the function only answers whether the
graph is planar. The test follows the formulation of Hopcroft and Tarjan
(1974) in the form of Mehlhorn: split into biconnected components, order the
arcs of a depth-first search by low points, and check each segment for
strong planarity with a stack of blocks of attachments.
Recursion is replaced by explicit stacks, so deep graphs are fine.
"""
# Attachment lists are singly linked lists sorted in decreasing order, so
# that the head is the largest attachment and the tail the smallest.
# They are stored in two global arrays and handled as [head, tail] pairs.
class _Lists:
def __init__(self):
self.val = []
self.nxt = []
def single(self, x):
self.val.append(x)
self.nxt.append(-1)
i = len(self.val) - 1
return [i, i]
def concat(self, a, b):
"""Append list b to list a (in place on a) and return a."""
if b[0] == -1:
return a
if a[0] == -1:
a[0], a[1] = b[0], b[1]
return a
self.nxt[a[1]] = b[0]
a[1] = b[1]
return a
def pop_head(self, a):
i = a[0]
a[0] = self.nxt[i]
if a[0] == -1:
a[1] = -1
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 v in range(n):
for w in adj[v]:
if v not in adj[w]:
raise ValueError("adjacency lists must be symmetric")
def _blocks(adj):
"""Biconnected components with at least 5 vertices, as lists of edges.
Iterative Hopcroft-Tarjan biconnectivity with an edge stack."""
n = len(adj)
num = [-1] * n
low = [0] * n
out = []
counter = 0
for r in range(n):
if num[r] != -1:
continue
num[r] = low[r] = counter
counter += 1
estack = []
stack = [(r, -1, 0)]
while stack:
v, p, i = stack[-1]
if i < len(adj[v]):
stack[-1] = (v, p, i + 1)
w = adj[v][i]
if num[w] == -1:
num[w] = low[w] = counter
counter += 1
estack.append((v, w))
stack.append((w, v, 0))
elif w != p and num[w] < num[v]:
estack.append((v, w))
if num[w] < low[v]:
low[v] = num[w]
else:
stack.pop()
if p == -1:
continue
if low[v] < low[p]:
low[p] = low[v]
if low[v] >= num[p]:
comp = []
while True:
e = estack.pop()
comp.append(e)
if e == (p, v):
break
out.append(comp)
return out
def _strongly_planar_block(edges):
"""Test a biconnected graph, given as a list of edges, for planarity."""
label = {}
for u, v in edges:
for x in (u, v):
if x not in label:
label[x] = len(label)
k = len(label)
if k <= 4:
return True
if len(edges) > 3 * k - 6:
return False
nb = [[] for _ in range(k)]
for u, v in edges:
nb[label[u]].append(label[v])
nb[label[v]].append(label[u])
# Depth-first search; vertices are renamed by their DFS number. # @line L3
num = [-1] * k
parent = [-1] * k
num[0] = 0
order = [0]
stack = [(0, 0)]
while stack:
v, i = stack[-1]
if i < len(nb[v]):
stack[-1] = (v, i + 1)
w = nb[v][i]
if num[w] == -1:
num[w] = len(order)
order.append(w)
parent[w] = v
stack.append((w, 0))
else:
stack.pop()
par = [-1] * k
tree = [[] for _ in range(k)]
back = [[] for _ in range(k)]
for v in range(k):
dv = num[v]
if parent[v] != -1:
par[dv] = num[parent[v]]
tree[num[parent[v]]].append(dv)
for w in nb[v]:
dw = num[w]
if dw < dv and dw != par[dv]:
back[dv].append(dw)
# Low points: lowpt1 is the smallest vertex reachable from the subtree
# by one back arc (or the vertex itself), lowpt2 the second smallest. # @line L4
l1 = list(range(k))
l2 = list(range(k))
for v in range(k - 1, -1, -1):
a, b = v, v
for w in back[v]:
if w < a:
a, b = w, a
elif a < w < b:
b = w
for c in tree[v]:
if l1[c] < a:
a, b = l1[c], min(a, l2[c])
elif l1[c] == a:
b = min(b, l2[c])
else:
b = min(b, l1[c])
l1[v], l2[v] = a, b
# Order the arcs out of each vertex by cost with a bucket sort. # @line L5
buckets = [[] for _ in range(2 * k + 2)]
for v in range(k):
for c in tree[v]:
cost = 2 * l1[c] + (1 if l2[c] < v else 0)
buckets[cost].append((v, c))
for w in back[v]:
buckets[2 * w].append((v, w))
adj = [[] for _ in range(k)]
for bucket in buckets:
for v, w in bucket:
adj[v].append(w)
lists = _Lists()
def spine(y): # @line L8
wk = y
while adj[wk][0] > wk:
wk = adj[wk][0]
return wk, adj[wk][0]
def add_block(S, A):
B = [A, [-1, -1]] # [left, right] attachments # @line L17
while True: # @line L18
if S and S[-1][0][0] != -1 and lists.val[B[0][1]] < lists.val[S[-1][0][0]]:
S[-1][0], S[-1][1] = S[-1][1], S[-1][0] # @line L19
if S and S[-1][0][0] != -1 and lists.val[B[0][1]] < lists.val[S[-1][0][0]]:
return False # @line L20
if S and S[-1][1][0] != -1 and lists.val[B[0][1]] < lists.val[S[-1][1][0]]:
T = S.pop() # @line L21
lists.concat(B[0], T[0])
lists.concat(B[1], T[1])
else:
break # @line L22
S.append(B) # @line L23
return True
# A frame is [x, w0, w, i, S]: segment of the tree arc into the spine
# x -> ... -> w (current vertex), its lowest attachment w0, the next
# arc index i at w, and the stack S of blocks.
y0 = adj[0][0]
wk, w0 = spine(y0)
frames = [[0, w0, wk, 1, []]] # @line L9
result = None
while frames:
f = frames[-1]
if result is not None:
if not add_block(f[4], result): # @line L13
return False
result = None
pushed = False
while f[2] != f[0]: # @line L10
w = f[2]
if f[3] < len(adj[w]): # @line L11
t = adj[w][f[3]]
f[3] += 1
if t > w: # tree arc: test its segment first # @line L12
sk, s0 = spine(t)
frames.append([w, s0, sk, 1, []])
pushed = True
break
if not add_block(f[4], lists.single(t)): # @line L13
return False
else:
p = par[w] # @line L14
S = f[4]
while S:
L, R = S[-1]
while L[0] != -1 and lists.val[L[0]] == p:
lists.pop_head(L)
while R[0] != -1 and lists.val[R[0]] == p:
lists.pop_head(R)
if L[0] == -1 and R[0] == -1:
S.pop()
else:
break
f[2] = p
f[3] = 1
if pushed:
continue
# The spine is done: collect the attachments of the segment.
x, s0, S = f[0], f[1], f[4]
att = [-1, -1]
while S:
L, R = S.pop()
hl = lists.val[L[0]] if L[0] != -1 else -1
hr = lists.val[R[0]] if R[0] != -1 else -1
if hl > s0 and hr > s0:
return False # @line L15
lists.concat(att, R if hr > s0 else L) # @line L16
if s0 != x:
lists.concat(att, lists.single(s0))
frames.pop()
result = att
return True # @line L6
def is_planar(adj):
"""Return True if the simple graph with adjacency lists adj is planar."""
_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 False
for comp in _blocks(adj): # @line L2
if not _strongly_planar_block(comp): # @line L6
return False
return True # @line L7
hopcroft_tarjan_planarity_test.py 8 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/hopcroft-tarjan-planarity-test/. The authors are listed in prov/QDYX9D.jsonl.
"""Entry point named after the slug. The code is in hopcroft_tarjan_planarity.py,
because the tools treat a file whose name ends in _test as a test file."""
from hopcroft_tarjan_planarity import is_planar
__all__ = ["is_planar"]
test_hopcroft_tarjan_planarity_test.py 213 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/hopcroft-tarjan-planarity-test/. The authors are listed in prov/QDYX9D.jsonl.
import random
import pytest
from hopcroft_tarjan_planarity_test import is_planar
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]
@pytest.mark.parametrize("n,edges,expected", [
(0, [], True),
(1, [], True),
(2, [(0, 1)], 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),
(6, complete(6), False),
])
def test_small_cases(n, edges, expected):
assert is_planar(to_adj(n, edges)) is expected
def test_random_against_oracle():
rng = random.Random(1974)
seen = [0, 0]
for _ in range(1500):
n, edges = random_case(rng)
expected = oracle_planar(n, edges)
seen[expected] += 1
assert is_planar(to_adj(n, edges, rng)) is expected, (n, edges)
assert min(seen) > 150
def test_disconnected_and_cut_vertices():
# K5 hidden in a second component, and K4 blocks glued at cut vertices.
adj = to_adj(9, [(0, 1), (1, 2)] + [(u + 4, v + 4) for u, v in complete(5)])
assert not is_planar(adj)
chain = []
for b in range(50):
chain += [(3 * b + u, 3 * b + v) for u, v in complete(4)]
assert is_planar(to_adj(151, chain))
def test_large_triangulation():
rng = random.Random(7)
n = 30000
E = random_triangulation(n, rng)
assert len(E) == 3 * n - 6
assert is_planar(to_adj(n, E, rng))
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 not is_planar(to_adj(n, E, rng))
def test_long_subdivisions():
n, E = subdivide(6, K33, 3000)
assert not is_planar(to_adj(n, E))
n, E = subdivide(5, complete(5)[:-1], 3000)
assert is_planar(to_adj(n, E))
n = 200000
assert is_planar(to_adj(n, [(i, (i + 1) % n) for i in range(n)]))
def test_rejects_bad_input():
with pytest.raises(ValueError):
is_planar([[0]])
with pytest.raises(ValueError):
is_planar([[1, 1], [0, 0]])
with pytest.raises(ValueError):
is_planar([[1], []])
with pytest.raises(ValueError):
is_planar([[3]])
What the next level needs
- the tests did not run in this build