Algorithm Search⌘K

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

Read the full page about Boyer-Myrvold planarity test