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
# 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