Laplacian matrix in Python
Degree matrix minus adjacency matrix; its spectrum encodes connectivity and structure.
- File
laplacian_matrix.py, 103 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/laplacian-matrix/. The authors are listed in prov/QTKKXV.jsonl.
from fractions import Fraction
from typing import Sequence
class Laplacian:
"""The Laplacian L = D - W of an undirected weighted graph on 0..n-1.
Stored sparsely: deg[u] is the weighted degree D[u][u], and adj[u] is a
list of (v, w) pairs, one for each edge {u, v} of weight w. Parallel edges
are allowed; their weights add up in L. Self-loops are rejected, because
they do not change D - W.
"""
def __init__(self, n: int) -> None:
self.n = n
self.deg: list[float] = [0] * n
self.adj: list[list[tuple[int, float]]] = [[] for _ in range(n)]
def add_edge(self, u: int, v: int, w: float = 1) -> None:
if u == v:
raise ValueError("a self-loop does not change the Laplacian")
if w <= 0:
raise ValueError("weights must be positive")
self.deg[u] += w # @line L1
self.deg[v] += w
self.adj[u].append((v, w)) # @line L2
self.adj[v].append((u, w)) # @line L3
def multiply(self, x: Sequence[float]) -> list[float]:
"""Return y = L x in Theta(n + m) time."""
y = [0] * self.n
for u in range(self.n): # @line L4
y[u] = self.deg[u] * x[u] # @line L5
for v, w in self.adj[u]: # @line L6
y[u] -= w * x[v] # @line L7
return y # @line L8
def quadratic_form(self, x: Sequence[float]) -> float:
"""Return x^T L x, the sum of w (x_u - x_v)^2 over the edges."""
s = 0 # @line L9
for u in range(self.n): # @line L10
for v, w in self.adj[u]: # @line L11
if u < v:
s += w * (x[u] - x[v]) ** 2 # @line L12
return s # @line L13
def entry(self, u: int, v: int) -> float:
"""L[u][v] in O(1 + d(u)) time."""
if u == v:
return self.deg[u]
return -sum(w for t, w in self.adj[u] if t == v)
def dense(self) -> list[list[float]]:
a = [[0] * self.n for _ in range(self.n)]
for u in range(self.n):
a[u][u] = self.deg[u]
for v, w in self.adj[u]:
a[u][v] -= w
return a
def _eliminate(a: list[list[Fraction]]) -> tuple[int, Fraction]:
"""Gaussian elimination over the rationals; return (rank, determinant)."""
a = [row[:] for row in a]
rows = len(a)
cols = len(a[0]) if rows else 0
rank, det = 0, Fraction(1)
for c in range(cols):
piv = next((r for r in range(rank, rows) if a[r][c] != 0), None)
if piv is None:
det = Fraction(0)
continue
if piv != rank:
a[rank], a[piv] = a[piv], a[rank]
det = -det
det *= a[rank][c]
for r in range(rank + 1, rows):
f = a[r][c] / a[rank][c]
if f:
for k in range(c, cols):
a[r][k] -= f * a[rank][k]
rank += 1
return rank, det
def components(lap: Laplacian) -> int:
"""The number of connected components, n - rank(L)."""
if lap.n == 0:
return 0
m = [[Fraction(x) for x in row] for row in lap.dense()]
return lap.n - _eliminate(m)[0]
def spanning_tree_count(lap: Laplacian) -> Fraction:
"""Matrix-tree theorem: the determinant of L with row and column 0 removed.
For weights this is the sum over spanning trees of the product of weights."""
if lap.n <= 1:
return Fraction(1)
m = [[Fraction(x) for x in row[1:]] for row in lap.dense()[1:]]
return _eliminate(m)[1]
test_laplacian_matrix.py 80 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/laplacian-matrix/. The authors are listed in prov/QTKKXV.jsonl.
import itertools
import random
import pytest
from laplacian_matrix import Laplacian, components, spanning_tree_count
def example() -> Laplacian:
lap = Laplacian(4)
for u, v in [(0, 1), (0, 2), (1, 2), (2, 3)]:
lap.add_edge(u, v)
return lap
def test_example() -> None:
lap = example()
assert lap.dense() == [[2, -1, -1, 0], [-1, 2, -1, 0],
[-1, -1, 3, -1], [0, 0, -1, 1]]
assert lap.multiply([1, 1, 1, 1]) == [0, 0, 0, 0]
assert lap.multiply([1, 0, 0, 0]) == [2, -1, -1, 0]
assert lap.quadratic_form([1, 1, 0, 0]) == 2
assert lap.quadratic_form([0, 0, 0, 1]) == 1
assert components(lap) == 1
assert spanning_tree_count(lap) == 3
def test_matrix_tree() -> None:
for n in range(1, 7):
k = Laplacian(n)
for u, v in itertools.combinations(range(n), 2):
k.add_edge(u, v)
assert spanning_tree_count(k) == n ** (n - 2) if n >= 2 else 1
for n in range(3, 9):
c = Laplacian(n)
for i in range(n):
c.add_edge(i, (i + 1) % n)
assert spanning_tree_count(c) == n
def test_components_and_rejects() -> None:
lap = Laplacian(5)
lap.add_edge(0, 1)
lap.add_edge(2, 3)
assert components(lap) == 3
assert spanning_tree_count(lap) == 0
with pytest.raises(ValueError):
lap.add_edge(4, 4)
with pytest.raises(ValueError):
lap.add_edge(0, 4, 0)
def test_parallel_edges_add() -> None:
lap = Laplacian(2)
lap.add_edge(0, 1, 2)
lap.add_edge(0, 1, 3)
assert lap.entry(0, 1) == -5 and lap.entry(0, 0) == 5
assert spanning_tree_count(lap) == 5
def test_random_against_dense() -> None:
rng = random.Random(3)
for _ in range(100):
n = rng.randint(1, 8)
lap = Laplacian(n)
for _ in range(rng.randint(0, 12)):
u, v = rng.sample(range(n), 2) if n >= 2 else (0, 0)
if u != v:
lap.add_edge(u, v, rng.randint(1, 4))
a = lap.dense()
x = [rng.randint(-5, 5) for _ in range(n)]
y = [sum(a[i][j] * x[j] for j in range(n)) for i in range(n)]
assert lap.multiply(x) == y
assert lap.quadratic_form(x) == sum(x[i] * y[i] for i in range(n))
assert lap.quadratic_form(x) >= 0
assert all(sum(row) == 0 for row in a)
assert all(lap.entry(i, j) == a[i][j] for i in range(n) for j in range(n))
What the next level needs
- the tests did not run in this build