Fast multipoint evaluation in Python
Evaluates a polynomial at n points in O(M(n) log n) by remainder descent down the subproduct tree.
- File
fast_multipoint_evaluation.py, 267 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/fast-multipoint-evaluation/. The authors are listed in prov/VW4WSE.jsonl.
"""Fast multipoint evaluation by remainders down the subproduct tree.
A polynomial a_0 + a_1 x + ... + a_n x^n is the list [a_0, ..., a_n], lowest
degree first; the zero polynomial is []. Coefficients and points may be int,
Fraction, or any ring element type with + - * (and `/` for the inverse of a
unit). Every divisor in the tree is monic, so no inverse is ever needed except
of 1, and the method works over every commutative ring, including Z.
Costs are counted in ring operations. The multiplication is a parameter; the
default is Karatsuba multiplication. The tree has ceil(log2 n) levels and all
loops are iterative, so there is no deep recursion.
"""
from __future__ import annotations
from typing import Any, Callable, List, Sequence, Tuple
Poly = List[Any]
MulFn = Callable[[Sequence[Any], Sequence[Any]], Poly]
KARATSUBA_CUTOFF = 32
def schoolbook_mul(a: Sequence[Any], b: Sequence[Any]) -> Poly:
"""Return the full product of two coefficient lists, len(a) + len(b) - 1 entries."""
if not a or not b:
return []
c: Poly = [a[0] - a[0]] * (len(a) + len(b) - 1)
for i, x in enumerate(a):
for j, y in enumerate(b):
c[i + j] = c[i + j] + x * y
return c
def _add(a: Sequence[Any], b: Sequence[Any]) -> Poly:
"""Return the coefficientwise sum of two lists of possibly different lengths."""
if len(a) < len(b):
a, b = b, a
return [x + y for x, y in zip(a, b)] + list(a[len(b):])
def karatsuba_mul(a: Sequence[Any], b: Sequence[Any]) -> Poly:
"""Return the full product of two coefficient lists by Karatsuba's method.
Works over every commutative ring. The recursion splits the longer operand
in halves, so its depth is O(log(len(a) + len(b))).
"""
if not a or not b:
return []
if len(a) < len(b):
a, b = b, a
if len(b) <= KARATSUBA_CUTOFF:
return schoolbook_mul(a, b)
zero = a[0] - a[0]
h = len(a) // 2
a0, a1 = list(a[:h]), list(a[h:])
if len(b) <= h:
# Unbalanced: b fits in the low half; multiply each half of a by b.
lo = karatsuba_mul(a0, b)
hi = karatsuba_mul(a1, b)
c = [zero] * (len(a) + len(b) - 1)
for i, v in enumerate(lo):
c[i] = c[i] + v
for i, v in enumerate(hi):
c[i + h] = c[i + h] + v
return c
b0, b1 = list(b[:h]), list(b[h:])
z0 = karatsuba_mul(a0, b0)
z2 = karatsuba_mul(a1, b1)
sa = _add(a0, a1)
sb = _add(b0, b1)
z1 = karatsuba_mul(sa, sb)
for i, v in enumerate(z0):
z1[i] = z1[i] - v
for i, v in enumerate(z2):
z1[i] = z1[i] - v
c = [zero] * (len(a) + len(b) - 1)
for i, v in enumerate(z0):
c[i] = c[i] + v
for i, v in enumerate(z1):
if i + h < len(c):
c[i + h] = c[i + h] + v
for i, v in enumerate(z2):
c[i + 2 * h] = c[i + 2 * h] + v
return c
def _unit_inverse(c: Any) -> Any:
"""Return c^(-1), or raise ValueError if c is not a unit."""
if isinstance(c, bool):
raise TypeError("bool is not a ring element here")
if isinstance(c, int):
if c in (1, -1):
return c
raise ValueError("constant term is not a unit of Z")
if c == 0:
raise ValueError("constant term is zero")
one = c - c + 1
try:
return one / c
except ZeroDivisionError as exc:
raise ValueError("constant term is not a unit") from exc
def precisions(n: int) -> List[int]:
"""Return the precisions 1 = k_s < ... < k_0 = n with k_(i+1) = ceil(k_i / 2)."""
ks = [n]
while ks[-1] > 1:
ks.append((ks[-1] + 1) // 2)
ks.reverse()
return ks
def inverse(f: Sequence[Any], n: int, mul: MulFn = karatsuba_mul) -> Poly:
"""Return [g_0, ..., g_(n-1)] with f g = 1 mod x^n.
Precondition: n >= 0, and f_0 is a unit of the coefficient ring.
Cost: at most 3 M(n) + lower order terms ring operations, where M(n) is the
cost of `mul` on inputs of length n.
"""
if n <= 0:
return []
if not f:
raise ValueError("the zero series has no inverse")
g: Poly = [_unit_inverse(f[0])]
m = 1
for m2 in precisions(n)[1:]:
d = mul(list(f[:m2]), g)[:m2]
while len(d) < m2:
d.append(f[0] - f[0])
t = [-c for c in d[m:m2]]
c = mul(g, t)[: m2 - m]
while len(c) < m2 - m:
c.append(f[0] - f[0])
g = g + c
m = m2
return g
def normalize(a: Sequence[Any]) -> Poly:
"""Return a copy of a without zero entries at the top."""
out = list(a)
while out and out[-1] == 0:
out.pop()
return out
def _pad(a: Poly, k: int, zero: Any) -> Poly:
while len(a) < k:
a.append(zero)
return a
def reversed_inverse(b: Sequence[Any], k: int, mul: MulFn = karatsuba_mul) -> Poly:
"""Return the inverse of rev(b) modulo x^k, for reuse with many dividends."""
b = normalize(b)
if not b:
raise ZeroDivisionError("division by the zero polynomial")
return inverse(b[::-1], k, mul)
def divmod_fast(
a: Sequence[Any],
b: Sequence[Any],
mul: MulFn = karatsuba_mul,
rinv: Sequence[Any] = None,
) -> Tuple[Poly, Poly]:
"""Return (q, r) with a = q b + r and deg r < deg b, both normalized.
Precondition: b is nonzero and lc(b) is a unit. If rinv is given, it must
be the inverse of rev(b) modulo x^k for some k >= deg a - deg b + 1.
Cost: at most 4 M(k) + M(m) + o(M(k)) + O(n) ring operations, with
n = deg a, m = deg b and k = n - m + 1; M(k) + M(m) + O(n) with rinv.
"""
a = normalize(a)
b = normalize(b)
if not b:
raise ZeroDivisionError("division by the zero polynomial")
n, m = len(a) - 1, len(b) - 1
if n < m:
return [], a
zero = b[0] - b[0]
k = n - m + 1
if rinv is None:
rinv = inverse(b[::-1], k, mul)
elif len(rinv) < k:
raise ValueError("the precomputed inverse is too short")
ra = a[::-1][:k]
rq = _pad(mul(ra, list(rinv[:k]))[:k], k, zero)
q = rq[::-1]
if m == 0:
return normalize(q), []
qb = _pad(mul(q[:m], b[:m])[:m], m, zero)
r = [a[i] - qb[i] for i in range(m)]
return normalize(q), normalize(r)
def subproduct_tree(points: Sequence[Any], mul: MulFn = karatsuba_mul) -> List[List[Poly]]:
"""Return the levels of the subproduct tree, leaves first.
Level 0 holds the monic polynomials x - u_i. Node k of level j + 1 is the
product of nodes 2k and 2k + 1 of level j; a last node without a sibling
is copied up unchanged. The last level has one node, prod (x - u_i).
"""
one = None
level: List[Poly] = []
for u in points:
one = u - u + 1
level.append([-u, one])
levels = [level]
while len(level) > 1:
nxt = []
for k in range(0, len(level) - 1, 2):
nxt.append(mul(level[k], level[k + 1]))
if len(level) % 2 == 1:
nxt.append(level[-1])
levels.append(nxt)
level = nxt
return levels
def _rem(a: Poly, m: Poly, mul: MulFn) -> Poly:
"""Remainder of a modulo the monic m, by fast division."""
if len(a) < len(m):
return a
return divmod_fast(a, m, mul)[1]
def evaluate(f: Sequence[Any], points: Sequence[Any], mul: MulFn = karatsuba_mul) -> List[Any]:
"""Return [f(u) for u in points], with O(M(n) log n) ring operations."""
points = list(points)
n = len(points)
if n == 0: # @line L1
return []
zero = points[0] - points[0]
levels = subproduct_tree(points, mul) # @line L2
h = len(levels) - 1
rems = [_rem(normalize(f), levels[h][0], mul)] # @line L3
for j in range(h - 1, -1, -1): # @line L4
nodes = levels[j]
nxt = [] # @line L5
for k, m in enumerate(nodes): # @line L6
nxt.append(_rem(rems[k // 2], m, mul)) # @line L7
rems = nxt # @line L8
return [r[0] if r else zero for r in rems] # @line L9
def evaluate_horner(f: Sequence[Any], points: Sequence[Any]) -> List[Any]:
"""Reference: Horner's rule at each point, n (deg f) multiplications each."""
out = []
for u in points:
acc = u - u
for c in reversed(list(f)):
acc = acc * u + c
out.append(acc)
return out
if __name__ == "__main__":
print(evaluate([1, -2, 0, 1], [0, 1, 2, 3]))
test_fast_multipoint_evaluation.py 163 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/fast-multipoint-evaluation/. The authors are listed in prov/VW4WSE.jsonl.
import random
from fractions import Fraction
import pytest
from fast_multipoint_evaluation import (
evaluate,
evaluate_horner,
schoolbook_mul,
subproduct_tree,
)
class ModP:
"""An element of Z/pZ, enough for the tests."""
def __init__(self, v: int, p: int) -> None:
self.p = p
self.v = v % p
def _c(self, o):
return o.v if isinstance(o, ModP) else o
def __add__(self, o):
return ModP(self.v + self._c(o), self.p)
__radd__ = __add__
def __sub__(self, o):
return ModP(self.v - self._c(o), self.p)
def __rsub__(self, o):
return ModP(self._c(o) - self.v, self.p)
def __mul__(self, o):
return ModP(self.v * self._c(o), self.p)
__rmul__ = __mul__
def __neg__(self):
return ModP(-self.v, self.p)
def __truediv__(self, o):
d = self._c(o) % self.p
if d == 0:
raise ZeroDivisionError
return ModP(self.v * pow(d, -1, self.p), self.p)
def __rtruediv__(self, o):
return ModP(o, self.p) / self
def __eq__(self, o):
return self.v == self._c(o) % self.p
def __hash__(self):
return hash(self.v)
def __repr__(self):
return f"{self.v} mod {self.p}"
# Independent oracle: the sum of c_k u^k with plain integer powers.
def oracle(f, points, p=None):
out = []
for u in points:
if p:
out.append(sum(c * pow(u, k, p) for k, c in enumerate(f)) % p)
else:
out.append(sum(c * u**k for k, c in enumerate(f)))
return out
def ints(v):
return [x.v if isinstance(x, ModP) else x for x in v]
PRIMES = [2, 3, 7, 998244353]
def test_page_example():
assert evaluate([1, -2, 0, 1], [0, 1, 2, 3]) == [1, 0, 5, 22]
levels = subproduct_tree([0, 1, 2, 3])
assert levels[1] == [[0, -1, 1], [6, -5, 1]]
assert levels[2] == [[0, -6, 11, -6, 1]]
def test_over_z_random():
rng = random.Random(1)
for _ in range(150):
n = rng.randint(1, 70)
f = [rng.randint(-50, 50) for _ in range(rng.randint(0, n))]
pts = [rng.randint(-30, 30) for _ in range(n)]
assert evaluate(f, pts) == oracle(f, pts)
@pytest.mark.parametrize("p", PRIMES)
def test_mod_p_random(p):
rng = random.Random(p)
for _ in range(60):
n = rng.randint(1, 60)
f = [rng.randrange(p) for _ in range(rng.randint(0, n))]
pts = [rng.randrange(p) for _ in range(n)]
got = evaluate([ModP(c, p) for c in f], [ModP(u, p) for u in pts])
assert ints(got) == oracle(f, pts, p)
def test_fraction_points():
rng = random.Random(4)
for _ in range(40):
n = rng.randint(1, 30)
f = [Fraction(rng.randint(-9, 9), rng.randint(1, 5)) for _ in range(n)]
pts = [Fraction(rng.randint(-9, 9), rng.randint(1, 7)) for _ in range(n)]
assert evaluate(f, pts) == oracle(f, pts)
def test_schoolbook_multiplier_and_horner_agree():
rng = random.Random(8)
for _ in range(30):
n = rng.randint(1, 50)
f = [rng.randint(-9, 9) for _ in range(n)]
pts = [rng.randint(-9, 9) for _ in range(n)]
assert evaluate(f, pts, mul=schoolbook_mul) == evaluate_horner(f, pts) == evaluate(f, pts)
def test_large_mod_p():
p = 998244353
rng = random.Random(12)
n = 300
f = [rng.randrange(p) for _ in range(n)]
pts = [rng.randrange(p) for _ in range(n)]
got = evaluate([ModP(c, p) for c in f], [ModP(u, p) for u in pts])
assert ints(got) == oracle(f, pts, p)
def test_edge_cases():
assert evaluate([1, 2, 3], []) == []
# Zero polynomial, with and without stored zeros.
assert evaluate([], [1, 2, 3]) == [0, 0, 0]
assert evaluate([0, 0], [5, 6]) == [0, 0]
# Constants and degree 1.
assert evaluate([7], [0, 1, -4]) == [7, 7, 7]
assert evaluate([1, 2], [3]) == [7]
# Leading zero coefficients are ignored.
assert evaluate([1, -2, 0, 1, 0, 0], [0, 1, 2, 3]) == [1, 0, 5, 22]
# Repeated points.
assert evaluate([1, 1, 1], [2, 2, 2, -1]) == [7, 7, 7, 1]
# Degree much larger than the number of points, and much smaller.
rng = random.Random(2)
f = [rng.randint(-5, 5) for _ in range(300)] + [1]
pts = [rng.randint(-5, 5) for _ in range(5)]
assert evaluate(f, pts) == oracle(f, pts)
pts = [rng.randint(-5, 5) for _ in range(200)]
assert evaluate([3, 1], pts) == oracle([3, 1], pts)
# An odd number of points, so a node is carried up without a sibling.
assert evaluate([0, 0, 1], [1, 2, 3, 4, 5]) == [1, 4, 9, 16, 25]
# Over Z/2: all points, every value.
two = [ModP(c, 2) for c in [1, 1, 0, 1]]
assert ints(evaluate(two, [ModP(0, 2), ModP(1, 2)])) == [1, 1]
What the next level needs
- the tests did not run in this build