Algorithm Search⌘K

Doubly connected edge list in Python

Half-edge representation of a planar subdivision, supporting face and edge traversal.

File
doubly_connected_edge_list.py, 227 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
doubly_connected_edge_list.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/doubly-connected-edge-list/. The authors are listed in prov/69ZYTE.jsonl.

"""A doubly connected edge list (half-edge structure) for a connected plane graph.

The embedding is given combinatorially, as a rotation system: for each vertex
the list of its neighbours in counterclockwise order. No coordinates are used.
Half-edges are numbered so that the twin of half-edge h is h ^ 1.
"""


class DCEL:
    """Half-edge structure of a connected plane graph (no loops, no parallel edges)."""

    def __init__(self) -> None:
        """Make the plane graph with one vertex, no edges and one face."""
        self.origin: list[int] = []  # origin[h]: the vertex where h starts
        self.next: list[int] = []  # next[h]: the half-edge after h on its face
        self.prev: list[int] = []  # prev[h]: the half-edge before h on its face
        self.face: list[int] = []  # face[h]: the face to the left of h
        self.vertex_edge: list[int] = [-1]  # one half-edge out of each vertex, or -1
        self.face_edge: list[int] = [-1]  # one half-edge of each face, -1 for no edge, -2 for deleted

    # ------------------------------------------------------------------ build

    @classmethod
    def from_rotation(cls, rot: list[list[int]]) -> "DCEL":
        """Build the structure from a rotation system of a connected graph.

        rot[u] lists the neighbours of u in counterclockwise order. Time and
        space O(n + m).
        """
        d = cls()
        n = len(rot)
        d.vertex_edge = [-1] * n
        d.face_edge = []
        half = {}  # (u, v) -> half-edge id
        for u in range(n):  # @line L1
            for v in rot[u]:
                if (u, v) in half:
                    continue
                h = len(d.origin)
                half[(u, v)], half[(v, u)] = h, h ^ 1  # @line L2
                d.origin += [u, v]
                d.vertex_edge[u] = h
                d.vertex_edge[v] = h ^ 1
        m2 = len(d.origin)
        d.next, d.prev, d.face = [-1] * m2, [-1] * m2, [-1] * m2
        pos = [{v: i for i, v in enumerate(rot[u])} for u in range(n)]
        for (u, v), h in half.items():  # @line L3
            r = rot[v]
            w = r[(pos[v][u] - 1) % len(r)]  # the neighbour before u around v
            nh = half[(v, w)]
            d.next[h] = nh  # @line L4
            d.prev[nh] = h
        for h in range(m2):  # @line L5
            if d.face[h] != -1:
                continue
            f = len(d.face_edge)
            d.face_edge.append(h)
            g = h
            while d.face[g] == -1:  # @line L6
                d.face[g] = f
                g = d.next[g]
        if n == 1 and m2 == 0:
            d.face_edge = [-1]
        return d

    # ------------------------------------------------------------- queries

    def twin(self, h: int) -> int:
        """The opposite half-edge of h."""
        return h ^ 1

    def num_vertices(self) -> int:
        return len(self.vertex_edge)

    def num_edges(self) -> int:
        return len(self.origin) // 2

    def faces(self) -> list[int]:
        """The ids of the faces that exist."""
        return [f for f, e in enumerate(self.face_edge) if e != -2]

    def face_boundary(self, f: int) -> list[int]:
        """The half-edges of face f in order. Time O(length of the boundary)."""
        h0 = self.face_edge[f]  # @line L9
        if h0 < 0:
            return []
        out, h = [], h0
        while True:
            out.append(h)
            h = self.next[h]
            if h == h0:
                return out

    def neighbors(self, v: int) -> list[int]:
        """The neighbours of v in counterclockwise order. Time O(degree)."""
        h0 = self.vertex_edge[v]  # @line L7
        if h0 < 0:
            return []
        out, h = [], h0
        while True:
            out.append(self.origin[h ^ 1])
            # the next half-edge out of v, counterclockwise
            h = self.prev[h] ^ 1  # @line L8
            if h == h0:
                return out

    # ----------------------------------------------------------- updates

    def _new_pair(self, u: int, v: int) -> int:
        h = len(self.origin)
        self.origin += [u, v]
        self.next += [-1, -1]
        self.prev += [-1, -1]
        self.face += [-1, -1]
        return h

    def _link(self, a: int, b: int) -> None:
        self.next[a] = b
        self.prev[b] = a

    def add_leaf(self, a: int) -> int:
        """Add a new vertex w and the edge from u = origin[a] to w, in the face of a.

        If the graph has no edge, a must be -1 and u is vertex 0. The new edge
        comes just before a around the face. Returns the half-edge u -> w. O(1).
        """
        w = len(self.vertex_edge)
        self.vertex_edge.append(-1)
        if a == -1:
            u, f = 0, 0
            h = self._new_pair(u, w)
            self._link(h, h ^ 1)
            self._link(h ^ 1, h)
        else:
            u, f = self.origin[a], self.face[a]
            h = self._new_pair(u, w)
            p = self.prev[a]  # @line L13
            self._link(p, h)
            self._link(h, h ^ 1)
            self._link(h ^ 1, a)
        self.face[h] = self.face[h ^ 1] = f
        self.face_edge[f] = h
        self.vertex_edge[u] = h
        self.vertex_edge[w] = h ^ 1
        return h

    def split_face(self, a: int, b: int) -> int:
        """Add the edge from origin[a] to origin[b] through the common face of a and b.

        Precondition: a != b, face[a] == face[b], origin[a] != origin[b], and the
        two vertices are not yet adjacent. The face is split in two. Returns the
        new half-edge h from origin[a] to origin[b]; its face is the new face.
        Time O(length of the new face).
        """
        f = self.face[a]
        u, v = self.origin[a], self.origin[b]
        h = self._new_pair(u, v)  # @line L10
        t = h ^ 1
        pa, pb = self.prev[a], self.prev[b]
        self._link(pa, h)  # @line L11
        self._link(h, b)
        self._link(pb, t)
        self._link(t, a)
        g = len(self.face_edge)  # @line L12
        self.face_edge.append(h)
        self.face_edge[f] = t
        x = h
        while True:
            self.face[x] = g
            x = self.next[x]
            if x == h:
                break
        self.face[t] = f
        return h

    def join_faces(self, h: int) -> None:
        """Delete the edge of h when its two sides are different faces; merge the faces.

        The face of h ^ 1 survives. Time O(length of the face of h).
        """
        t = h ^ 1
        f, g = self.face[t], self.face[h]
        if f == g:
            raise ValueError("the two sides of the edge are the same face")
        x = self.next[h]  # @line L14
        while x != h:
            self.face[x] = f
            x = self.next[x]
        self._link(self.prev[h], self.next[t])  # @line L15
        self._link(self.prev[t], self.next[h])
        u, v = self.origin[h], self.origin[t]
        if self.vertex_edge[u] == h:
            self.vertex_edge[u] = self.prev[h] ^ 1
        if self.vertex_edge[v] == t:
            self.vertex_edge[v] = self.prev[t] ^ 1
        self.face_edge[f] = self.next[t]
        self.face_edge[g] = -2
        # the two half-edges stay in the arrays but are unlinked
        for y in (h, t):
            self.next[y] = self.prev[y] = self.face[y] = -1
            self.origin[y] = -1

    # -------------------------------------------------------------- check

    def check_invariant(self) -> None:
        """Raise AssertionError if a pointer invariant or Euler's formula fails."""
        live = [h for h in range(len(self.origin)) if self.origin[h] != -1]
        for h in live:
            assert self.origin[h ^ 1] != -1
            assert self.prev[self.next[h]] == h and self.next[self.prev[h]] == h
            assert self.origin[self.next[h]] == self.origin[h ^ 1]
            assert self.face[self.next[h]] == self.face[h]
            assert self.face_edge[self.face[h]] >= 0
        for v, h in enumerate(self.vertex_edge):
            assert h == -1 or self.origin[h] == v
        fs = self.faces()
        seen = 0
        for f in fs:
            b = self.face_boundary(f)
            assert all(self.face[x] == f for x in b)
            seen += len(b)
        assert seen == len(live)
        n, m = self.num_vertices(), len(live) // 2
        assert n - m + len(fs) == 2
test_doubly_connected_edge_list.py 259 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/doubly-connected-edge-list/. The authors are listed in prov/69ZYTE.jsonl.

import math
import random

from doubly_connected_edge_list import DCEL

# ---- independent oracles ----


def rotation_from_points(pts, edges):
    """Rotation system of a straight-line drawing: neighbours sorted by angle."""
    adj = [[] for _ in pts]
    for u, v in edges:
        adj[u].append(v)
        adj[v].append(u)
    rot = []
    for u, nb in enumerate(adj):
        x, y = pts[u]
        rot.append(sorted(nb, key=lambda v: math.atan2(pts[v][1] - y, pts[v][0] - x)))
    return rot


def trace_faces(rot):
    """Face cycles of a rotation system as lists of darts (u, v), traced with dicts."""
    seen = set()
    out = []
    for u in range(len(rot)):
        for v in rot[u]:
            if (u, v) in seen:
                continue
            cyc = []
            a, b = u, v
            while (a, b) not in seen:
                seen.add((a, b))
                cyc.append((a, b))
                r = rot[b]
                c = r[(r.index(a) - 1) % len(r)]
                a, b = b, c
            out.append(cyc)
    return out


def canon(cycle):
    """A rotation-invariant key of a cyclic list."""
    if not cycle:
        return ()
    k = min(range(len(cycle)), key=lambda i: cycle[i:] + cycle[:i])
    return tuple(cycle[k:] + cycle[:k])


def dcel_faces(d):
    out = []
    for f in d.faces():
        b = d.face_boundary(f)
        out.append(canon([(d.origin[h], d.origin[h ^ 1]) for h in b]))
    return sorted(out)


def oracle_faces(rot):
    cycles = trace_faces(rot)
    if not cycles:  # a graph with no edge has one face with an empty boundary
        return [()]
    return sorted(canon(c) for c in cycles)


def same_cyclic(a, b):
    return len(a) == len(b) and (not a or canon(list(a)) == canon(list(b)))


def orient(p, q, r):
    return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])


def segments_cross(p, q, r, s):
    """True when closed segments pq and rs meet at a point that is not a shared end."""
    shared = {p, q} & {r, s}
    if shared:
        # two segments with a common end meet elsewhere only if collinear and overlapping
        o = q if p in shared else p
        t = s if r in shared else r
        c = p if p in shared else q
        return orient(c, o, t) == 0 and (o[0] - c[0]) * (t[0] - c[0]) + (o[1] - c[1]) * (t[1] - c[1]) > 0
    d1, d2 = orient(p, q, r), orient(p, q, s)
    d3, d4 = orient(r, s, p), orient(r, s, q)
    if d1 * d2 < 0 and d3 * d4 < 0:
        return True

    def on(a, b, c):
        return orient(a, b, c) == 0 and min(a[0], b[0]) <= c[0] <= max(a[0], b[0]) and min(a[1], b[1]) <= c[1] <= max(a[1], b[1])

    return on(p, q, r) or on(p, q, s) or on(r, s, p) or on(r, s, q)


def connected(n, edges):
    parent = list(range(n))

    def find(x):
        while parent[x] != x:
            parent[x] = parent[parent[x]]
            x = parent[x]
        return x

    for u, v in edges:
        parent[find(u)] = find(v)
    return len({find(x) for x in range(n)}) == 1


# ---- fixed cases ----

K4_POINTS = [(0, 0), (4, 0), (2, 3), (2, 1)]
K4_EDGES = [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]


def test_page_example_k4():
    rot = rotation_from_points(K4_POINTS, K4_EDGES)
    assert rot == [[1, 3, 2], [2, 3, 0], [0, 3, 1], [0, 1, 2]]
    d = DCEL.from_rotation(rot)
    d.check_invariant()
    assert d.num_edges() == 6 and len(d.faces()) == 4
    assert all(len(d.face_boundary(f)) == 3 for f in d.faces())
    assert dcel_faces(d) == oracle_faces(rot)
    for v in range(4):
        assert same_cyclic(d.neighbors(v), rot[v])
    # split no face of K4 is possible; delete edge 2-3 and add it back
    h = next(x for x in range(12) if (d.origin[x], d.origin[x ^ 1]) == (3, 2))
    d.join_faces(h)
    d.check_invariant()
    assert len(d.faces()) == 3
    assert sorted(len(d.face_boundary(f)) for f in d.faces()) == [3, 3, 4]


def test_single_vertex():
    d = DCEL.from_rotation([[]])
    d.check_invariant()
    assert d.faces() == [0] and d.face_boundary(0) == [] and d.neighbors(0) == []
    d2 = DCEL()
    d2.check_invariant()
    h = d2.add_leaf(-1)
    d2.check_invariant()
    assert d2.face_boundary(0) == [h, h ^ 1]


def test_tree_has_one_face():
    rot = [[1, 2, 3], [0], [0], [0, 4], [3]]
    d = DCEL.from_rotation(rot)
    d.check_invariant()
    assert len(d.faces()) == 1
    assert len(d.face_boundary(d.faces()[0])) == 8


def test_cube():
    pts = [(0, 0), (6, 0), (6, 6), (0, 6), (2, 2), (4, 2), (4, 4), (2, 4)]
    edges = [(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)]
    rot = rotation_from_points(pts, edges)
    d = DCEL.from_rotation(rot)
    d.check_invariant()
    assert len(d.faces()) == 6
    assert all(len(d.face_boundary(f)) == 4 for f in d.faces())


def test_nonplanar_rotations_fail_euler():
    k5 = [[v for v in range(5) if v != u] for u in range(5)]
    k33 = [[3, 4, 5]] * 3 + [[0, 1, 2]] * 3
    for rot in (k5, k33):
        d = DCEL.from_rotation(rot)
        assert dcel_faces(d) == oracle_faces(rot)
        try:
            d.check_invariant()
        except AssertionError:
            continue
        raise AssertionError("a non-planar graph passed the Euler check")


def test_split_face_square():
    rot = [[1, 3], [2, 0], [3, 1], [0, 2]]
    d = DCEL.from_rotation(rot)
    f = d.faces()[0]
    b = d.face_boundary(f)
    a = next(x for x in b if d.origin[x] == 0)
    c = next(x for x in b if d.origin[x] == 2)
    h = d.split_face(a, c)
    d.check_invariant()
    assert len(d.faces()) == 3
    assert len(d.face_boundary(d.face[h])) == 3 and len(d.face_boundary(d.face[h ^ 1])) == 3


# ---- randomized ----


def random_geometric(rng):
    n = rng.randint(1, 8)
    pts = []
    while len(pts) < n:
        p = (rng.randint(0, 12), rng.randint(0, 12))
        if p not in pts:
            pts.append(p)
    pairs = [(u, v) for u in range(n) for v in range(u + 1, n)]
    rng.shuffle(pairs)
    edges = []
    for u, v in pairs:
        if rng.random() < 0.3:
            continue
        if all(not segments_cross(pts[u], pts[v], pts[a], pts[b]) for a, b in edges):
            edges.append((u, v))
    return pts, edges


def test_random_straight_line_drawings():
    rng = random.Random(1)
    done = 0
    while done < 600:
        pts, edges = random_geometric(rng)
        if not connected(len(pts), edges):
            continue
        rot = rotation_from_points(pts, edges)
        d = DCEL.from_rotation(rot)
        d.check_invariant()
        assert dcel_faces(d) == oracle_faces(rot)
        assert len(d.faces()) == len(edges) - len(pts) + 2
        for v in range(len(pts)):
            assert same_cyclic(d.neighbors(v), rot[v])
        done += 1


def test_random_operations():
    rng = random.Random(2)
    for _ in range(500):
        d = DCEL()
        edges = set()
        for _ in range(rng.randint(1, 25)):
            op = rng.random()
            live = [h for h in range(len(d.origin)) if d.origin[h] != -1]
            if not live or op < 0.35:
                a = rng.choice(live) if live else -1
                h = d.add_leaf(a)
                edges.add(frozenset((d.origin[h], d.origin[h ^ 1])))
            elif op < 0.75:
                f = rng.choice(d.faces())
                b = d.face_boundary(f)
                a, c = rng.choice(b), rng.choice(b)
                u, v = d.origin[a], d.origin[c]
                if u == v or frozenset((u, v)) in edges:
                    continue
                h = d.split_face(a, c)
                edges.add(frozenset((u, v)))
            else:
                cand = [h for h in live if d.face[h] != d.face[h ^ 1]]
                if not cand:
                    continue
                h = rng.choice(cand)
                edges.discard(frozenset((d.origin[h], d.origin[h ^ 1])))
                d.join_faces(h)
            d.check_invariant()
            rot = [d.neighbors(v) for v in range(d.num_vertices())]
            got = {frozenset((u, v)) for u in range(len(rot)) for v in rot[u]}
            assert got == edges
            assert dcel_faces(d) == oracle_faces(rot)

What the next level needs

  • the tests did not run in this build

Read the full page about Doubly connected edge list