Algorithm Search⌘K

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

Read the full page about Laplacian matrix