Algorithm Search⌘K

Nested dissection in Python

Order unknowns by recursive separators, giving low fill for planar-like sparse matrices.

File
nested_dissection.py, 219 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
nested_dissection.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/nested-dissection/. The authors are listed in prov/F4FW9V.jsonl.

"""Nested dissection orderings of a sparse symmetric matrix.

A graph is a list adj with adj[v] the neighbours of v (no self loops).
An ordering is a list order; vertex order[k] is eliminated k-th.

nested_dissection works on any graph, with separators taken from breadth-first
level structures (George and Liu's automatic nested dissection, simplified).
grid_nested_dissection orders a k1 x k2 grid by geometric line separators.
Both are iterative: the dissection tree is processed with an explicit stack.
"""

from collections import deque


def graph_from_edges(n: int, edges) -> list[list[int]]:
    adj = [set() for _ in range(n)]
    for v, w in edges:
        if v != w:
            adj[v].add(w)
            adj[w].add(v)
    return [sorted(s) for s in adj]


def grid_graph(k1: int, k2: int) -> list[list[int]]:
    """5-point grid with k1 rows and k2 columns, vertex i * k2 + j."""
    e = []
    for i in range(k1):
        for j in range(k2):
            v = i * k2 + j
            if j + 1 < k2:
                e.append((v, v + 1))
            if i + 1 < k1:
                e.append((v, v + k2))
    return graph_from_edges(k1 * k2, e)


def _bfs_levels(adj, root, inside):
    seen = {root}
    levels = [[root]]
    while True:
        nxt = []
        for v in levels[-1]:
            for w in adj[v]:
                if w in inside and w not in seen:
                    seen.add(w)
                    nxt.append(w)
        if not nxt:
            return levels
        levels.append(nxt)


def _components(adj, inside):
    left = set(inside)
    comps = []
    while left:
        s = min(left)
        left.discard(s)
        comp, q = [s], deque([s])
        while q:
            v = q.popleft()
            for w in adj[v]:
                if w in left:
                    left.discard(w)
                    comp.append(w)
                    q.append(w)
        comps.append(comp)
    return comps


def level_separator(adj, part):
    """A vertex separator of the connected subgraph induced by part: the middle
    level of a breadth-first level structure from a pseudo-peripheral vertex,
    trimmed to the vertices with a neighbour in the next level."""
    inside = set(part)
    r = min(part, key=lambda v: (sum(w in inside for w in adj[v]), v))
    levels = _bfs_levels(adj, r, inside)
    while True:  # pseudo-peripheral root (George and Liu)
        x = min(levels[-1], key=lambda v: (sum(w in inside for w in adj[v]), v))
        lx = _bfs_levels(adj, x, inside)
        if len(lx) <= len(levels):
            break
        levels = lx
    if len(levels) < 3:
        return None
    half, acc, mid = len(part) / 2, 0, 1
    for i, lev in enumerate(levels):
        acc += len(lev)
        if acc >= half:
            mid = min(max(i, 1), len(levels) - 2)
            break
    nxt = set(levels[mid + 1])
    return [v for v in levels[mid] if any(w in nxt for w in adj[v])]


def _local_minimum_degree(adj, part):
    inside = set(part)
    g = {v: {w for w in adj[v] if w in inside} for v in part}
    out = []
    while g:
        v = min(g, key=lambda x: (len(g[x]), x))
        nb = g.pop(v)
        for w in nb:
            g[w].discard(v)
            g[w] |= nb - {w}
        out.append(v)
    return out


def nested_dissection(adj, leaf: int = 8, separators=None) -> list[int]:
    """Nested dissection order of all vertices. Parts with at most leaf vertices,
    or without a level separator, are ordered by minimum degree.
    If separators is a list, the separators found are appended to it."""
    n = len(adj)
    order: list[int] = []  # @line L1
    stack = [("split", list(range(n)))]
    while stack:  # @line L2
        kind, part = stack.pop()  # @line L3
        if kind == "emit":  # @line L4
            order.extend(part)
            continue
        comps = _components(adj, part)  # @line L5
        if len(comps) > 1:
            for c in reversed(comps):
                stack.append(("split", c))
            continue
        sep = level_separator(adj, part) if len(part) > leaf else None
        if sep is None:  # @line L6
            order.extend(_local_minimum_degree(adj, part))
            continue
        if separators is not None:
            separators.append(sep)
        rest = set(part) - set(sep)  # @line L7
        pieces = _components(adj, rest)  # @line L8
        stack.append(("emit", sep))  # @line L9
        for c in reversed(pieces):
            stack.append(("split", c))
    return order  # @line L10


def grid_nested_dissection(k1: int, k2: int, leaf: int = 1) -> list[int]:
    """Geometric nested dissection of the k1 x k2 grid: split the longer side by
    its middle line, order both halves, then the line."""
    order: list[int] = []
    stack = [("split", 0, k1, 0, k2)]
    while stack:
        task = stack.pop()
        if task[0] == "emit":
            order.extend(task[1])
            continue
        _, r0, r1, c0, c1 = task
        h, w = r1 - r0, c1 - c0
        if h <= 0 or w <= 0:
            continue
        if h * w <= leaf or (h <= 2 and w <= 2):
            order.extend(i * k2 + j for i in range(r0, r1) for j in range(c0, c1))
            continue
        if h >= w:
            m = r0 + h // 2
            line = [m * k2 + j for j in range(c0, c1)]
            halves = [(r0, m, c0, c1), (m + 1, r1, c0, c1)]
        else:
            m = c0 + w // 2
            line = [i * k2 + m for i in range(r0, r1)]
            halves = [(r0, r1, c0, m), (r0, r1, m + 1, c1)]
        stack.append(("emit", line))
        for a, b, c, d in reversed(halves):
            stack.append(("split", a, b, c, d))
    return order


def _numbers(order):
    f = [0] * len(order)
    for k, v in enumerate(order):
        f[v] = k
    return f


def cholesky_pattern(adj, order) -> list[set[int]]:
    """Exact symbolic Cholesky: cols[k] = rows i > k with l_ik != 0 (new numbers)."""
    f = _numbers(order)
    n = len(adj)
    later = [set(f[w] for w in adj[v]) for v in order]
    cols = []
    for k in range(n):
        s = {i for i in later[k] if i > k}
        cols.append(s)
        for i in s:
            later[i] |= s - {i}
    return cols


def fill_and_flops(adj, order) -> tuple[int, int]:
    """(nonzeros of L below the diagonal, sum_k c_k^2)."""
    cols = cholesky_pattern(adj, order)
    return sum(len(c) for c in cols), sum(len(c) ** 2 for c in cols)


def cholesky_float(a, order):
    """Cholesky of P A P^T in floating point (dense storage, sparse loops)."""
    import math
    n = len(a)
    b = [[float(a[i][j]) for j in order] for i in order]
    for k in range(n):
        if b[k][k] <= 0:
            raise ValueError("not positive definite")
        d = math.sqrt(b[k][k])
        b[k][k] = d
        col = [i for i in range(k + 1, n) if b[i][k] != 0]
        for i in col:
            b[i][k] /= d
        for j in col:
            ljk = b[j][k]
            for i in col:
                if i >= j:
                    b[i][j] -= b[i][k] * ljk
    return [[b[i][j] if j <= i else 0.0 for j in range(n)] for i in range(n)]
test_nested_dissection.py 173 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/nested-dissection/. The authors are listed in prov/F4FW9V.jsonl.

import math
import random
from fractions import Fraction

from nested_dissection import (
    _components, cholesky_float, cholesky_pattern, fill_and_flops, graph_from_edges,
    grid_graph, grid_nested_dissection, level_separator, nested_dissection,
)

U = 2.0 ** -53


def random_graph(rng, n, p):
    return graph_from_edges(n, [(i, j) for i in range(n) for j in range(i) if rng.random() < p])


def geometric_graph(rng, n, r2):
    pts = [(rng.random(), rng.random()) for _ in range(n)]
    return graph_from_edges(n, [(i, j) for i in range(n) for j in range(i)
                                if (pts[i][0] - pts[j][0]) ** 2 + (pts[i][1] - pts[j][1]) ** 2 < r2])


def rcm(adj):
    n = len(adj)
    seen, order = [False] * n, []
    for s in sorted(range(n), key=lambda v: (len(adj[v]), v)):
        if seen[s]:
            continue
        seen[s] = True
        order.append(s)
        h = len(order) - 1
        while h < len(order):
            v = order[h]
            h += 1
            for w in sorted((w for w in adj[v] if not seen[w]), key=lambda w: (len(adj[w]), w)):
                seen[w] = True
                order.append(w)
    return order[::-1]


def test_edge_cases():
    assert nested_dissection([]) == []
    assert nested_dissection([[]]) == [0]
    assert grid_nested_dissection(1, 1) == [0]
    assert sorted(nested_dissection([[] for _ in range(20)])) == list(range(20))
    path = graph_from_edges(31, [(i, i + 1) for i in range(30)])
    o = nested_dissection(path, leaf=1)
    assert sorted(o) == list(range(31))
    # dissecting a path creates fill (52 entries against 30 for the natural order) but stays O(n log n)
    assert fill_and_flops(path, o)[0] == 52
    assert fill_and_flops(path, list(range(31)))[0] == 30


def test_permutation_on_random_graphs():
    rng = random.Random(21)
    for _ in range(120):
        n = rng.randint(1, 60)
        g = random_graph(rng, n, rng.uniform(0.02, 0.3))
        for leaf in (1, 4, 8):
            assert sorted(nested_dissection(g, leaf)) == list(range(n))


def test_grid_orders_are_permutations():
    for k1 in range(1, 12):
        for k2 in range(1, 12):
            o = grid_nested_dissection(k1, k2)
            assert sorted(o) == list(range(k1 * k2))


def test_level_separator_separates():
    rng = random.Random(22)
    checked = 0
    for _ in range(150):
        n = rng.randint(4, 80)
        g = geometric_graph(rng, n, rng.uniform(0.02, 0.1))
        for comp in _components(g, range(n)):
            sep = level_separator(g, comp)
            if sep is None:
                continue
            assert sep and set(sep) <= set(comp)
            pieces = _components(g, set(comp) - set(sep))
            assert len(pieces) >= 2
            checked += 1
    assert checked > 100


def test_no_fill_between_pieces():
    # in the order, the top separator is last, so no entry of L links the two sides
    rng = random.Random(23)
    for _ in range(40):
        n = rng.randint(20, 120)
        g = geometric_graph(rng, n, 0.03)
        if len(_components(g, range(n))) != 1:
            continue
        seps = []
        order = nested_dissection(g, 4, seps)
        if not seps:
            continue
        top = seps[0]
        assert order[-len(top):] == top
        pieces = _components(g, set(range(n)) - set(top))
        which = {v: t for t, c in enumerate(pieces) for v in c}
        cols = cholesky_pattern(g, order)
        for k, col in enumerate(cols):
            for i in col:
                a, b = order[k], order[i]
                if a in which and b in which:
                    assert which[a] == which[b]


def test_grid_growth_and_comparison():
    prev = None
    for k in (8, 16, 32):
        g = grid_graph(k, k)
        nd = fill_and_flops(g, nested_dissection(g))
        gnd = fill_and_flops(g, grid_nested_dissection(k, k))
        r = fill_and_flops(g, rcm(g))
        for fill, flops in (nd, gnd):
            assert fill <= 4.0 * k * k * math.log2(k)  # O(n log n) with n = k^2
            assert flops <= 14.0 * k ** 3  # O(n^(3/2))
        assert r[1] >= 0.15 * k ** 4 / 2  # RCM: about k^4 / 2 flops
        if k >= 16:
            assert nd[1] < r[1]
        ratio = nd[1] / r[1]
        if prev is not None:
            assert ratio < prev
        prev = ratio


def test_backward_error_of_cholesky_in_nd_order():
    rng = random.Random(24)
    for k in (4, 5, 6):
        g = grid_graph(k, k)
        n = k * k
        order = nested_dissection(g, leaf=2)
        f = [0] * n
        for p, v in enumerate(order):
            f[v] = p
        sym = cholesky_pattern(g, order)
        for _ in range(4):
            a = [[0.0] * n for _ in range(n)]
            for i in range(n):
                for j in g[i]:
                    if j < i:
                        a[i][j] = a[j][i] = -rng.uniform(0.1, 1.0)
            for i in range(n):
                a[i][i] = sum(abs(x) for x in a[i]) * rng.uniform(1.0, 1.5) + 1e-3
            l = cholesky_float(a, order)
            gam = (n + 1) * U / (1 - (n + 1) * U)
            for i in range(n):
                for j in range(i):
                    if l[i][j] != 0:
                        assert i in sym[j]  # computed nonzeros lie in the symbolic pattern
                for j in range(i + 1):
                    exact = sum(Fraction(l[i][t]) * Fraction(l[j][t]) for t in range(j + 1))
                    absl = sum(abs(l[i][t]) * abs(l[j][t]) for t in range(j + 1))
                    err = abs(Fraction(a[order[i]][order[j]]) - exact)
                    assert err <= Fraction(gam * absl)


def test_leaf_size_controls_switch_to_minimum_degree():
    g = grid_graph(6, 6)
    whole = nested_dissection(g, leaf=36)
    seps = []
    nested_dissection(g, leaf=36, separators=seps)
    assert seps == []
    assert sorted(whole) == list(range(36))
    seps = []
    nested_dissection(g, leaf=4, separators=seps)
    assert len(seps) >= 3

What the next level needs

  • the tests did not run in this build

Read the full page about Nested dissection