Fast polynomial interpolation in Python
Interpolates at n points in O(M(n) log n) using the subproduct tree and the transposed multipoint evaluation.
- File
fast_interpolation.py, 307 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-interpolation/. The authors are listed in prov/2CT8SN.jsonl.
"""Fast polynomial interpolation with 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 []. Points and values may be Fraction,
elements of a Z/pZ class with `/`, or int. The differences of the points must
be units, so over Z only very special point sets work; a non-unit raises
ValueError.
Costs are counted in ring operations. The multiplication is a parameter; the
default is Karatsuba multiplication. All loops over the tree levels are
iterative.
"""
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("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 derivative(f: Sequence[Any]) -> Poly:
"""The formal derivative of f."""
return normalize([k * f[k] for k in range(1, len(f))])
def evaluate_on_tree(f: Sequence[Any], levels: List[List[Poly]], mul: MulFn = karatsuba_mul) -> List[Any]:
"""Fast multipoint evaluation of f at the leaves of a subproduct tree."""
zero = levels[0][0][1] - levels[0][0][1]
h = len(levels) - 1
rems = [_rem(normalize(f), levels[h][0], mul)]
for j in range(h - 1, -1, -1):
rems = [_rem(rems[k // 2], m, mul) for k, m in enumerate(levels[j])]
return [r[0] if r else zero for r in rems]
def linear_combination(c: Sequence[Any], levels: List[List[Poly]], mul: MulFn = karatsuba_mul) -> Poly:
"""Return sum_i c_i m / (x - u_i), where m is the root of the tree."""
r: List[Poly] = [[ci] for ci in c]
for j in range(1, len(levels)):
below = levels[j - 1]
nxt: List[Poly] = []
for k in range(len(levels[j])):
if 2 * k + 1 < len(below):
left = mul(r[2 * k], below[2 * k + 1])
right = mul(r[2 * k + 1], below[2 * k])
nxt.append(_add(left, right))
else:
nxt.append(r[2 * k])
r = nxt
return normalize(r[0])
def interpolate(points: Sequence[Any], values: Sequence[Any], mul: MulFn = karatsuba_mul) -> Poly:
"""Return the f of degree < n with f(u_i) = v_i, in O(M(n) log n)."""
points = list(points)
n = len(points)
if n != len(values):
raise ValueError("points and values differ in length")
if n == 0: # @line L1
return []
levels = subproduct_tree(points, mul) # @line L2
s = evaluate_on_tree(derivative(levels[-1][0]), levels, mul) # @line L3
c = []
for vi, si in zip(values, s):
if si == 0:
raise ValueError("two points coincide")
c.append(vi * _unit_inverse(si)) # @line L4
return linear_combination(c, levels, mul) # @line L5
def interpolate_newton(points: Sequence[Any], values: Sequence[Any]) -> Poly:
"""Reference: Newton divided differences, Theta(n^2) operations."""
points = list(points)
n = len(points)
if n == 0:
return []
d = list(values)
for j in range(1, n):
for i in range(n - 1, j - 1, -1):
d[i] = (d[i] - d[i - 1]) * _unit_inverse(points[i] - points[i - j])
f = [d[n - 1]]
for i in range(n - 2, -1, -1):
f = _add(schoolbook_mul(f, [-points[i], points[i] - points[i] + 1]), [d[i]])
return normalize(f)
if __name__ == "__main__":
from fractions import Fraction
print(interpolate([Fraction(0), Fraction(1), Fraction(-1)], [1, 3, 3]))
test_fast_interpolation.py 190 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/fast-interpolation/. The authors are listed in prov/2CT8SN.jsonl.
import random
from fractions import Fraction
import pytest
from fast_interpolation import (
interpolate,
interpolate_newton,
schoolbook_mul,
subproduct_tree,
linear_combination,
)
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: Lagrange's formula with plain integers modulo p, or with
# Fraction, and evaluation of the result at the points.
def lagrange(points, values, p=None):
n = len(points)
f = [0] * n
for i in range(n):
num = [1]
den = 1
for j in range(n):
if j != i:
num = [(a - points[j] * b) for a, b in zip([0] + num, num + [0])]
den *= points[i] - points[j]
w = values[i] * (pow(den, -1, p) if p else Fraction(1, 1) / den)
for k in range(n):
f[k] += w * num[k]
if p:
f = [c % p for c in f]
while f and f[-1] == 0:
f.pop()
return f
def ints(v):
return [x.v if isinstance(x, ModP) else x for x in v]
def distinct(rng, n, lo, hi):
return rng.sample(range(lo, hi), n)
PRIMES = [3, 7, 998244353]
def test_page_example():
pts = [Fraction(u) for u in [0, 1, 2, 3, 4]]
vals = [1, 15, 129, 547, 1593]
assert interpolate(pts, vals) == [1, 2, 3, 4, 5]
def test_linear_combination_page_trace():
levels = subproduct_tree([0, 1, 2, 3])
# c = (1, 1, 1, 1) gives m' for m = x^4 - 6x^3 + 11x^2 - 6x.
assert linear_combination([1, 1, 1, 1], levels) == [-6, 22, -18, 4]
@pytest.mark.parametrize("p", PRIMES)
def test_mod_p_random(p):
rng = random.Random(p)
for _ in range(40):
n = rng.randint(1, min(p, 40))
pts = distinct(rng, n, 0, p) if p < 10**6 else [rng.randrange(p) for _ in range(n)]
if len(set(pts)) < n:
continue
vals = [rng.randrange(p) for _ in range(n)]
got = interpolate([ModP(u, p) for u in pts], [ModP(v, p) for v in vals])
assert ints(got) == lagrange(pts, vals, p)
def test_mod_2_all_points():
for vals in ([0, 0], [1, 0], [0, 1], [1, 1]):
got = interpolate([ModP(0, 2), ModP(1, 2)], [ModP(v, 2) for v in vals])
assert ints(got) == lagrange([0, 1], vals, 2)
def test_fraction_random():
rng = random.Random(6)
for _ in range(40):
n = rng.randint(1, 25)
pts = [Fraction(u, rng.randint(1, 4)) for u in distinct(rng, n, -60, 60)]
if len(set(pts)) < n:
continue
vals = [Fraction(rng.randint(-20, 20), rng.randint(1, 3)) for _ in range(n)]
f = interpolate(pts, vals)
assert f == lagrange(pts, vals)
assert f == interpolate_newton(pts, vals)
def test_round_trip_mod_p():
p = 998244353
rng = random.Random(10)
n = 200
f = [rng.randrange(p) for _ in range(n)]
pts = rng.sample(range(p), n)
vals = [sum(c * pow(u, k, p) for k, c in enumerate(f)) % p for u in pts]
got = interpolate([ModP(u, p) for u in pts], [ModP(v, p) for v in vals])
assert ints(got) == f
def test_schoolbook_multiplier_agrees():
rng = random.Random(3)
p = 7
pts = [ModP(u, p) for u in range(7)]
for _ in range(20):
vals = [ModP(rng.randrange(p), p) for _ in range(7)]
assert interpolate(pts, vals, mul=schoolbook_mul) == interpolate(pts, vals)
def test_over_z_unit_differences():
# Over Z the differences must be +1 or -1: only two consecutive integers.
assert interpolate([0, 1], [3, 5]) == [3, 2]
assert interpolate([4], [9]) == [9]
with pytest.raises(ValueError):
interpolate([0, 2], [1, 1])
def test_edge_cases():
assert interpolate([], []) == []
# All values zero gives the zero polynomial.
assert interpolate([Fraction(1), Fraction(2), Fraction(3)], [0, 0, 0]) == []
# Constant values give a constant.
assert interpolate([Fraction(u) for u in range(9)], [5] * 9) == [5]
# Degree 1 data.
assert interpolate([Fraction(u) for u in range(6)], [2 * u + 1 for u in range(6)]) == [1, 2]
# Odd number of points, so a tree node has no sibling.
pts = [Fraction(u) for u in range(7)]
assert interpolate(pts, [u * u for u in range(7)]) == [0, 0, 1]
# Repeated points are rejected.
with pytest.raises(ValueError):
interpolate([Fraction(1), Fraction(1)], [1, 2])
with pytest.raises(ValueError):
interpolate([ModP(1, 7), ModP(8, 7)], [ModP(1, 7), ModP(2, 7)])
with pytest.raises(ValueError):
interpolate([1, 2], [1])
What the next level needs
- the tests did not run in this build