Algorithm Search⌘K

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

Read the full page about Hopcroft-Tarjan planarity test