Algorithm Search⌘K

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
planarization_heuristic.pyRawGitHub
# 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

Read the full page about Planarization heuristic