Planarization heuristic in Python
Heuristic that finds a large planar subgraph, then inserts the remaining edges as crossings.
- File
planarization_heuristic.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/planarization-heuristic/. The authors are listed in prov/8EHK3E.jsonl.
"""Planarization heuristic: a maximal planar subgraph, then edge insertion.
A graph is given by n (vertices 0..n-1) and a list of edges. The result is a
planar graph in which every crossing is a new dummy vertex of degree 4,
together with a rotation system: rot[v] lists the neighbours of v in
counterclockwise order. Faces are traced with the rule that the dart after
(a, b) is (b, c), where c is the neighbour just before a in rot[b].
"""
from collections import deque
Edge = tuple[int, int]
def _key(u: int, v: int) -> Edge:
return (u, v) if u < v else (v, u)
def _blocks(n: int, adj: list[list[int]]) -> list[list[Edge]]:
"""Edges of each biconnected component, by iterative DFS."""
disc = [-1] * n
low = [0] * n
blocks: list[list[Edge]] = []
time = 0
for s in range(n):
if disc[s] != -1:
continue
disc[s] = low[s] = time
time += 1
stack = [(s, -1, iter(adj[s]))]
edges: list[Edge] = []
while stack:
v, p, it = stack[-1]
descended = False
for w in it:
if w == p:
continue
if disc[w] == -1:
edges.append((v, w))
disc[w] = low[w] = time
time += 1
stack.append((w, v, iter(adj[w])))
descended = True
break
if disc[w] < disc[v]:
edges.append((v, w))
low[v] = min(low[v], disc[w])
if descended:
continue
stack.pop()
if stack:
u = stack[-1][0]
low[u] = min(low[u], low[v])
if low[v] >= disc[u]:
block: list[Edge] = []
while True:
e = edges.pop()
block.append(e)
if e == (u, v):
break
blocks.append(block)
return blocks
def _embed_block(block: list[Edge]) -> dict[int, list[int]] | None:
"""Demoucron-Malgrange-Pertuiset path addition on one biconnected block.
Returns the rotation at each vertex of the block, or None if the block is
not planar.
"""
adj: dict[int, set[int]] = {}
for u, v in block:
adj.setdefault(u, set()).add(v)
adj.setdefault(v, set()).add(u)
if len(block) == 1:
u, v = block[0]
return {u: [v], v: [u]}
# A first cycle: the edge (a, b) plus a shortest a-b path avoiding it.
a, b = block[0]
parent = {a: a}
queue = deque([a])
while queue:
x = queue.popleft()
for y in adj[x]:
if y not in parent and not (x == a and y == b):
parent[y] = x
queue.append(y)
cycle = [b]
while cycle[-1] != a:
cycle.append(parent[cycle[-1]])
in_h = set(cycle)
h_edges = {_key(cycle[i], cycle[i - 1]) for i in range(len(cycle))}
faces = [cycle, cycle[::-1]]
while len(h_edges) < len(block):
# Fragments: chords, and components of the vertices not yet in H.
fragments: list[tuple[set[int], list[int]]] = [] # (attachments, path)
for u, v in block:
if u in in_h and v in in_h and _key(u, v) not in h_edges:
fragments.append(({u, v}, [u, v]))
seen: set[int] = set()
for s in adj:
if s in in_h or s in seen:
continue
comp = [s]
seen.add(s)
att: set[int] = set()
for x in comp:
for y in adj[x]:
if y in in_h:
att.add(y)
elif y not in seen:
seen.add(y)
comp.append(y)
seed = next(x for x in comp if any(y in in_h for y in adj[x]))
fragments.append((att, [seed])) # seed: a vertex next to H
best = None
for att, path in fragments:
ok = [f for f in faces if att <= set(f)]
if not ok:
return None
if best is None or len(ok) < len(best[2]):
best = (att, path, ok)
if len(ok) == 1:
break
att, path, ok = best
face = ok[0]
if len(path) == 1: # a component: find a path between two attachments
s = path[0]
start = next(y for y in adj[s] if y in in_h)
prev = {s: s}
queue = deque([s])
end = None
while queue and end is None:
x = queue.popleft()
for y in adj[x]:
if y in in_h:
if y != start:
end = (x, y)
break
elif y not in prev:
prev[y] = x
queue.append(y)
x, stop = end
inner = [x]
while inner[-1] != s:
inner.append(prev[inner[-1]])
path = [start] + inner[::-1] + [stop]
u, v = path[0], path[-1]
i, j = face.index(u), face.index(v)
k = len(face)
seg1 = [face[(i + t) % k] for t in range((j - i) % k + 1)] # u .. v
seg2 = [face[(j + t) % k] for t in range((i - j) % k + 1)] # v .. u
mid = path[1:-1]
faces.remove(face)
faces.append(seg1 + mid[::-1])
faces.append(seg2 + mid)
for t in range(len(path) - 1):
h_edges.add(_key(path[t], path[t + 1]))
in_h.update(mid)
# Rotation from faces: for consecutive a, b, c on a face, a follows c at b.
nxt: dict[int, dict[int, int]] = {v: {} for v in adj}
for f in faces:
k = len(f)
for t in range(k):
x, y, z = f[t - 1], f[t], f[(t + 1) % k]
nxt[y][z] = x
rot: dict[int, list[int]] = {}
for v in adj:
first = next(iter(adj[v]))
order = [first]
while nxt[v][order[-1]] != first:
order.append(nxt[v][order[-1]])
rot[v] = order
return rot
def planar_embedding(n: int, edges: list[Edge]) -> list[list[int]] | None:
"""A planar rotation system of a simple graph, or None if it is not planar."""
adj: list[list[int]] = [[] for _ in range(n)]
for u, v in edges:
adj[u].append(v)
adj[v].append(u)
rot: list[list[int]] = [[] for _ in range(n)]
for block in _blocks(n, adj):
brot = _embed_block(block)
if brot is None:
return None
for v, around in brot.items(): # blocks at a cut vertex are concatenated
rot[v].extend(around)
return rot
def faces(rot: list[list[int]]) -> list[list[Edge]]:
"""All faces of a rotation system, each as its cycle of darts."""
pos = [{w: i for i, w in enumerate(around)} for around in rot]
seen: set[Edge] = set()
result = []
for u in range(len(rot)):
for v in rot[u]:
if (u, v) in seen:
continue
walk = []
d = (u, v)
while d not in seen:
seen.add(d)
walk.append(d)
a, b = d
d = (b, rot[b][pos[b][a] - 1])
result.append(walk)
return result
def planarize(n: int, edges: list[Edge]) -> dict:
"""Planarize a simple graph.
Returns a dict with
rot rotation system of the planarized graph on n + crossings vertices
chains for each input edge, its path of vertices in the planarized graph
kept indices of the edges of the maximal planar subgraph
crossings number of dummy vertices, n, n + 1, ...
"""
seen: set[Edge] = set()
for u, v in edges:
if u == v or not (0 <= u < n and 0 <= v < n) or _key(u, v) in seen:
raise ValueError("the graph must be simple, with vertices 0..n-1")
seen.add(_key(u, v))
kept: list[int] = [] # @line L1
removed: list[int] = []
sub: list[Edge] = []
for idx, (u, v) in enumerate(edges): # @line L2
if planar_embedding(n, sub + [(u, v)]) is not None: # @line L3
sub.append((u, v))
kept.append(idx)
else:
removed.append(idx)
rot = planar_embedding(n, sub) # @line L4
# Every graph edge (x, y) of the planarized graph maps to its input edge.
owner: dict[Edge, int] = {_key(*edges[i]): i for i in kept}
chains: dict[int, list[int]] = {i: list(edges[i]) for i in kept}
for idx in removed: # @line L5
u, v = edges[idx]
fs = faces(rot) # @line L6
face_of: dict[Edge, int] = {}
for f, walk in enumerate(fs):
for d in walk:
face_of[d] = f
at_v = {f for f, walk in enumerate(fs) if any(d[0] == v for d in walk)}
# Breadth-first search in the dual, from all faces at u.
back: dict[int, tuple[int, Edge] | None] = {}
queue: deque[int] = deque() # @line L7
for f, walk in enumerate(fs):
if any(d[0] == u for d in walk):
back[f] = None
queue.append(f)
goal = None
while queue:
f = queue.popleft()
if f in at_v:
goal = f
break
for d in fs[f]:
g = face_of[(d[1], d[0])]
if g not in back:
back[g] = (f, d)
queue.append(g)
crossed: list[Edge] = [] # darts crossed, each seen from the earlier face
path_faces = [goal]
while back[path_faces[-1]] is not None:
f, d = back[path_faces[-1]]
crossed.append(d)
path_faces.append(f)
crossed.reverse()
path_faces.reverse()
# Subdivide each crossed edge by a dummy vertex.
dummies = []
for x, y in crossed: # @line L8
d = len(rot)
rot.append([x, y])
rot[x][rot[x].index(y)] = d
rot[y][rot[y].index(x)] = d
j = owner.pop(_key(x, y))
chain = chains[j]
for t in range(len(chain) - 1):
if _key(chain[t], chain[t + 1]) == _key(x, y):
chain.insert(t + 1, d)
break
owner[_key(x, d)] = j
owner[_key(d, y)] = j
dummies.append((d, x, y))
# Connect u, d_1, ..., d_k, v through the faces of the path.
seq = [u] + [d for d, _, _ in dummies] + [v] # @line L9
# A corner of z on a face is a pair of darts (a, z), (z, c) of its
# walk; c is just before a in rot[z], and a new neighbour put between
# them lies in that face. The search never crosses an edge at u or v
# (both sides of such an edge are already start or goal faces), so the
# vertices a found here are still neighbours of u and v.
cu = _corner(fs[path_faces[0]], u)
cv = _corner(fs[path_faces[-1]], v)
_insert_after_pred(rot, u, cu, seq[1])
_insert_after_pred(rot, v, cv, seq[-2])
for t, (d, x, y) in enumerate(dummies):
# rot[d] = [x, y]; the earlier face holds (x, d), (d, y), so its
# corner at d is after y; the later face's corner is after x.
rot[d] = [x, seq[t + 2], y, seq[t]]
for t in range(len(seq) - 1):
owner[_key(seq[t], seq[t + 1])] = idx
chains[idx] = seq
return { # @line L10
"rot": rot,
"chains": [chains[i] for i in range(len(edges))],
"kept": kept,
"crossings": len(rot) - n,
}
def _corner(walk: list[Edge], z: int) -> int:
"""The vertex a of some dart (a, z) on the walk, the start of a corner at z."""
for t, d in enumerate(walk):
if d[0] == z:
return walk[t - 1][0]
raise AssertionError("vertex not on face")
def _insert_after_pred(rot: list[list[int]], b: int, a: int, w: int) -> None:
"""Insert w into the corner at b entered from a: just before a in rot[b]."""
around = rot[b]
around.insert(around.index(a), w)
test_planarization_heuristic.py 189 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/planarization-heuristic/. The authors are listed in prov/8EHK3E.jsonl.
import random
from itertools import combinations, permutations, product
from planarization_heuristic import faces, planar_embedding, planarize
def components(rot):
seen = [False] * len(rot)
comps = []
for s in range(len(rot)):
if seen[s]:
continue
seen[s] = True
comp = [s]
for x in comp:
for y in rot[x]:
if not seen[y]:
seen[y] = True
comp.append(y)
comps.append(comp)
return comps
def euler_ok(rot):
"""Every component with an edge satisfies V - E + F = 2 under the rotation."""
for v, around in enumerate(rot):
assert len(set(around)) == len(around), "repeated neighbour"
for w in around:
assert v != w and v in rot[w], "rotation not symmetric"
fs = faces(rot)
comp_of = {}
comps = components(rot)
for i, comp in enumerate(comps):
for v in comp:
comp_of[v] = i
nf = [0] * len(comps)
for walk in fs:
nf[comp_of[walk[0][0]]] += 1
for i, comp in enumerate(comps):
e = sum(len(rot[v]) for v in comp) // 2
if e and len(comp) - e + nf[i] != 2:
return False
return True
def brute_planar(n, edges):
"""Oracle: try every rotation system of every component."""
adj = [[] for _ in range(n)]
for u, v in edges:
adj[u].append(v)
adj[v].append(u)
for comp in components(adj):
if len(comp) < 3:
continue
choices = []
for v in comp:
first, rest = adj[v][0], adj[v][1:]
choices.append([[first] + list(p) for p in permutations(rest)])
e = sum(len(adj[v]) for v in comp) // 2
found = False
for pick in product(*choices):
rot = [[] for _ in range(n)]
for v, around in zip(comp, pick):
rot[v] = around
if len(comp) - e + len(faces(rot)) == 2:
found = True
break
if not found:
return False
return True
def check(n, edges):
r = planarize(n, edges)
rot, chains, kept = r["rot"], r["chains"], r["kept"]
assert len(rot) == n + r["crossings"]
assert euler_ok(rot)
# Every dummy has degree 4, and the two chains through it alternate.
through = {}
for i, chain in enumerate(chains):
assert chain[0] == edges[i][0] and chain[-1] == edges[i][1]
for t in range(1, len(chain) - 1):
d = chain[t]
assert d >= n
through.setdefault(d, []).append((i, chain[t - 1], chain[t + 1]))
assert sorted(through) == list(range(n, len(rot)))
for d, uses in through.items():
assert len(uses) == 2 and uses[0][0] != uses[1][0]
assert len(rot[d]) == 4
(_, a, b), (_, c, e) = uses
assert {rot[d][0], rot[d][2]} in ({a, b}, {c, e})
assert {rot[d][1], rot[d][3]} in ({a, b}, {c, e})
# The chains are exactly the edges of the planarized graph.
pieces = sorted(tuple(sorted(p)) for ch in chains for p in zip(ch, ch[1:]))
graph = sorted((v, w) for v in range(len(rot)) for w in rot[v] if v < w)
assert pieces == graph
# Every removed edge is crossed at least once (kept edges may be crossed
# by later insertions).
for i, chain in enumerate(chains):
if i not in kept:
assert len(chain) >= 3
# Lower bound from the edge bound of planar graphs.
if n >= 3:
assert r["crossings"] >= len(edges) - (3 * n - 6)
return r
def test_small_cases():
assert planarize(0, [])["crossings"] == 0
r = check(1, [])
assert r["rot"] == [[]] and r["crossings"] == 0
tree = [(0, 1), (1, 2), (1, 3), (3, 4)]
r = check(5, tree)
assert r["crossings"] == 0 and r["kept"] == [0, 1, 2, 3]
k4 = list(combinations(range(4), 2))
r = check(4, k4)
assert r["crossings"] == 0 and len(faces(r["rot"])) == 4
def test_kuratowski_graphs():
k5 = list(combinations(range(5), 2))
r = check(5, k5)
assert r["crossings"] == 1 and len(r["kept"]) == 9
assert r["chains"][-1] == [3, 5, 4]
k33 = [(a, b) for a in range(3) for b in range(3, 6)]
r = check(6, k33)
assert r["crossings"] == 1 and len(r["kept"]) == 8
k6 = list(combinations(range(6), 2))
assert check(6, k6)["crossings"] == 3
assert planar_embedding(5, k5) is None and planar_embedding(6, k33) is None
def test_invalid_input():
for n, edges in [(2, [(0, 0)]), (2, [(0, 1), (1, 0)]), (2, [(0, 2)])]:
try:
planarize(n, edges)
except ValueError:
pass
else:
raise AssertionError("expected ValueError")
def random_graph(rng, n, p):
edges = [e for e in combinations(range(n), 2) if rng.random() < p]
rng.shuffle(edges)
return [(v, u) if rng.random() < 0.5 else (u, v) for u, v in edges]
def test_planarity_against_brute_force():
rng = random.Random(1)
for _ in range(600):
n = rng.randint(1, 6)
edges = random_graph(rng, n, rng.uniform(0.3, 1.0))
if len(edges) > 11:
edges = edges[:11]
expect = brute_planar(n, edges)
rot = planar_embedding(n, edges)
assert (rot is not None) == expect
if rot is not None:
assert euler_ok(rot)
def test_maximal_subgraph_against_brute_force():
rng = random.Random(2)
for _ in range(500):
n = rng.randint(3, 6)
edges = random_graph(rng, n, rng.uniform(0.5, 1.0))[:11]
r = check(n, edges)
sub = [edges[i] for i in r["kept"]]
assert brute_planar(n, sub)
for i in range(len(edges)):
if i not in r["kept"]:
assert not brute_planar(n, sub + [edges[i]])
def test_random_larger_graphs():
rng = random.Random(3)
for _ in range(150):
n = rng.randint(5, 18)
edges = random_graph(rng, n, rng.uniform(0.1, 0.6))
r = check(n, edges)
sub = [edges[i] for i in r["kept"]]
assert planar_embedding(n, sub) is not None
for i in range(len(edges)):
if i not in r["kept"]:
assert planar_embedding(n, sub + [edges[i]]) is None
What the next level needs
- the tests did not run in this build