Sparse polynomial representation in Python
Stores only nonzero terms, as a list or map from exponents to coefficients.
- File
sparse_polynomial.py, 160 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/sparse-polynomial/. The authors are listed in prov/28C10S.jsonl.
"""Sparse distributed multivariate polynomials, sorted by a monomial order.
A polynomial in K[x_1, ..., x_n] is a list of terms (alpha, c), where alpha is
an exponent tuple of length n and c is a nonzero coefficient. The terms are in
strictly decreasing order for a fixed monomial order (lex, grlex or grevlex),
so the first term is the leading term. Coefficients may be int, Fraction, or
any type with +, -, * and == 0 that models a commutative ring.
Costs count ring operations and monomial operations (a comparison or a product
of two exponent tuples, each O(n) integer operations).
"""
from __future__ import annotations
import heapq
from typing import Any, Callable, Iterable
Mono = tuple[int, ...]
Term = tuple[Mono, Any]
def _lex(a: Mono) -> tuple:
return a
def _grlex(a: Mono) -> tuple:
return (sum(a),) + a
def _grevlex(a: Mono) -> tuple:
return (sum(a),) + tuple(-e for e in reversed(a))
ORDERS: dict[str, Callable[[Mono], tuple]] = {"lex": _lex, "grlex": _grlex, "grevlex": _grevlex}
def _mono_mul(a: Mono, b: Mono) -> Mono:
return tuple(x + y for x, y in zip(a, b))
class SparsePolynomial:
"""A polynomial with its terms in strictly decreasing monomial order and no zero coefficient."""
__slots__ = ("nvars", "order", "terms", "_key")
def __init__(self, nvars: int, order: str = "grevlex", terms: Iterable[Term] = ()) -> None:
"""Build from any terms: like terms are added and zero terms dropped. Cost: O(t log t) monomial comparisons."""
if order not in ORDERS:
raise ValueError("order must be lex, grlex or grevlex")
self.nvars = nvars
self.order = order
self._key = ORDERS[order]
acc: dict[Mono, Any] = {}
for a, c in terms:
a = tuple(a)
if len(a) != nvars or min(a, default=0) < 0:
raise ValueError("bad exponent tuple")
acc[a] = acc[a] + c if a in acc else c
self.terms: list[Term] = sorted(((a, c) for a, c in acc.items() if c != 0), key=lambda t: self._key(t[0]), reverse=True)
def _new(self, terms: list[Term]) -> "SparsePolynomial":
p = SparsePolynomial(self.nvars, self.order)
p.terms = terms
return p
def check_invariant(self) -> bool:
"""True when the terms are strictly decreasing in the order and all coefficients are nonzero."""
ks = [self._key(a) for a, _ in self.terms]
return all(c != 0 for _, c in self.terms) and all(ks[i] > ks[i + 1] for i in range(len(ks) - 1))
def is_zero(self) -> bool:
return not self.terms
def leading_term(self) -> Term:
"""LT as (LM, LC). Cost: O(1). The zero polynomial has no leading term."""
if not self.terms:
raise ValueError("the zero polynomial has no leading term")
return self.terms[0]
def to_dict(self) -> dict[Mono, Any]:
return dict(self.terms)
def add(self, other: "SparsePolynomial") -> "SparsePolynomial":
"""Sum by a merge of two sorted lists. Cost: O(s + t) monomial comparisons and additions."""
a, b, key = self.terms, other.terms, self._key
i, j, out = 0, 0, [] # @line A1
while i < len(a) and j < len(b): # @line A2
ka, kb = key(a[i][0]), key(b[j][0])
if ka > kb: # @line A3
out.append(a[i])
i += 1
elif ka < kb: # @line A4
out.append(b[j])
j += 1
else:
u = a[i][1] + b[j][1] # @line A5
if u != 0: # @line A6
out.append((a[i][0], u))
i += 1 # @line A7
j += 1
out.extend(a[i:]) # @line A8
out.extend(b[j:])
return self._new(out)
def neg(self) -> "SparsePolynomial":
return self._new([(a, -c) for a, c in self.terms])
def sub(self, other: "SparsePolynomial") -> "SparsePolynomial":
return self.add(other.neg())
def mul_term(self, mono: Mono, coeff: Any) -> "SparsePolynomial":
"""Product with one term. The order is kept because a monomial order is compatible with products. Cost: O(t)."""
if coeff == 0:
return self._new([])
out = [(_mono_mul(a, mono), c * coeff) for a, c in self.terms]
return self._new([(a, c) for a, c in out if c != 0])
def mul(self, other: "SparsePolynomial") -> "SparsePolynomial":
"""Product by Johnson's heap method. Cost: O(s t log s) monomial operations and s t ring operations, s <= t."""
f, g = self.terms, other.terms
if not f or not g: # @line L1
return self._new([])
if len(f) > len(g):
f, g = g, f
key = self._key
heap: list = []
for i in range(len(f)): # @line L2
m = _mono_mul(f[i][0], g[0][0])
heap.append((tuple(-x for x in key(m)), i, 0, m))
heapq.heapify(heap)
h: list[list] = [] # @line L3
while heap: # @line L4
_, i, j, m = heapq.heappop(heap) # @line L5
c = f[i][1] * g[j][1] # @line L6
if h and h[-1][0] == m: # @line L7
h[-1][1] = h[-1][1] + c
else:
if h and h[-1][1] == 0: # @line L8
h.pop()
h.append([m, c])
if j + 1 < len(g): # @line L9
m2 = _mono_mul(f[i][0], g[j + 1][0])
heapq.heappush(heap, (tuple(-x for x in key(m2)), i, j + 1, m2))
if h and h[-1][1] == 0: # @line L10
h.pop()
return self._new([(m, c) for m, c in h]) # @line L11
def evaluate(self, point: Iterable[Any]) -> Any:
"""Value at a point. Cost: O(t n log d) ring operations with fast powers."""
pt = list(point)
total = 0
for a, c in self.terms:
v = c
for x, e in zip(pt, a):
if e:
v = v * x**e
total = total + v
return total
test_sparse_polynomial.py 125 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/sparse-polynomial/. The authors are listed in prov/28C10S.jsonl.
import random
from fractions import Fraction
import pytest
from sparse_polynomial import ORDERS, SparsePolynomial
def naive_mul(f: dict, g: dict) -> dict:
out: dict = {}
for a, c in f.items():
for b, d in g.items():
m = tuple(x + y for x, y in zip(a, b))
out[m] = out.get(m, 0) + c * d
return {m: c for m, c in out.items() if c != 0}
def naive_add(f: dict, g: dict) -> dict:
out = dict(f)
for a, c in g.items():
out[a] = out.get(a, 0) + c
return {m: c for m, c in out.items() if c != 0}
def rand_poly(rng: random.Random, n: int, order: str, terms: int, deg: int) -> SparsePolynomial:
ts = []
for _ in range(terms):
a = tuple(rng.randint(0, deg) for _ in range(n))
ts.append((a, rng.randint(-3, 3)))
return SparsePolynomial(n, order, ts)
def test_example() -> None:
f = SparsePolynomial(2, "grlex", [((1, 0), 1), ((0, 1), 1), ((0, 0), 1)])
g = SparsePolynomial(2, "grlex", [((1, 0), 1), ((0, 1), -1), ((0, 0), 2)])
h = f.mul(g)
assert h.terms == [((2, 0), 1), ((0, 2), -1), ((1, 0), 3), ((0, 1), 1), ((0, 0), 2)]
assert h.check_invariant()
def test_orders() -> None:
# x^2 y versus x y^3 and x z^2 versus y^3: the standard distinctions.
t = [((2, 1, 0), 1), ((1, 3, 0), 1), ((1, 0, 2), 1), ((0, 3, 0), 1)]
lex = SparsePolynomial(3, "lex", t).terms
grlex = SparsePolynomial(3, "grlex", t).terms
grevlex = SparsePolynomial(3, "grevlex", t).terms
assert [a for a, _ in lex] == [(2, 1, 0), (1, 3, 0), (1, 0, 2), (0, 3, 0)]
assert [a for a, _ in grlex] == [(1, 3, 0), (2, 1, 0), (1, 0, 2), (0, 3, 0)]
assert [a for a, _ in grevlex] == [(1, 3, 0), (2, 1, 0), (0, 3, 0), (1, 0, 2)]
def test_edges() -> None:
z = SparsePolynomial(2, "lex")
one = SparsePolynomial(2, "lex", [((0, 0), 1)])
f = SparsePolynomial(2, "lex", [((3, 1), 5), ((0, 2), -1)])
assert z.mul(f).is_zero() and f.mul(z).is_zero()
assert one.mul(f).terms == f.terms
assert f.sub(f).is_zero()
assert f.add(z).terms == f.terms
assert f.leading_term() == ((3, 1), 5)
with pytest.raises(ValueError):
z.leading_term()
# cancellation inside the product: (x - y)(x + y) = x^2 - y^2
p = SparsePolynomial(2, "lex", [((1, 0), 1), ((0, 1), -1)])
q = SparsePolynomial(2, "lex", [((1, 0), 1), ((0, 1), 1)])
assert p.mul(q).to_dict() == {(2, 0): 1, (0, 2): -1}
# like terms in the constructor are combined, zeros dropped
r = SparsePolynomial(1, "lex", [((1,), 2), ((1,), -2), ((0,), 3)])
assert r.terms == [((0,), 3)]
def test_random_against_dict_model() -> None:
rng = random.Random(28)
for trial in range(600):
order = rng.choice(list(ORDERS))
n = rng.randint(1, 3)
f = rand_poly(rng, n, order, rng.randint(0, 6), 3)
g = rand_poly(rng, n, order, rng.randint(0, 6), 3)
h = f.mul(g)
assert h.check_invariant()
assert h.to_dict() == naive_mul(f.to_dict(), g.to_dict())
s = f.add(g)
assert s.check_invariant() and s.to_dict() == naive_add(f.to_dict(), g.to_dict())
a = tuple(rng.randint(0, 2) for _ in range(n))
mt = f.mul_term(a, 3)
assert mt.check_invariant() and mt.to_dict() == naive_mul(f.to_dict(), {a: 3})
if not h.is_zero():
# the leading term of a product is the product of the leading terms
(a1, c1), (a2, c2) = f.leading_term(), g.leading_term()
assert h.leading_term() == (tuple(x + y for x, y in zip(a1, a2)), c1 * c2)
pt = [Fraction(rng.randint(-5, 5), rng.randint(1, 4)) for _ in range(n)]
assert h.evaluate(pt) == f.evaluate(pt) * g.evaluate(pt)
def test_modular_coefficients() -> None:
p = 7
class Fp:
def __init__(self, v: int) -> None:
self.v = v % p
def __add__(self, o: "Fp") -> "Fp":
return Fp(self.v + o.v)
def __mul__(self, o: "Fp") -> "Fp":
return Fp(self.v * o.v)
def __neg__(self) -> "Fp":
return Fp(-self.v)
def __eq__(self, o: object) -> bool:
return self.v == (o.v if isinstance(o, Fp) else o % p)
def __ne__(self, o: object) -> bool:
return not self == o
# (x + 1)^7 = x^7 + 1 over Z/7Z: all middle terms cancel in the heap.
f = SparsePolynomial(1, "lex", [((1,), Fp(1)), ((0,), Fp(1))])
h = f
for _ in range(6):
h = h.mul(f)
assert [(a, c.v) for a, c in h.terms] == [((7,), 1), ((0,), 1)]
What the next level needs
- the tests did not run in this build