Ben-Or and Tiwari algorithm in Python
Recovers a sparse polynomial from 2t evaluations at geometric points via Prony-style linear recurrences.
- File
ben_or_tiwari_algorithm.py, 151 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/ben-or-tiwari-algorithm/. The authors are listed in prov/RHYB1V.jsonl.
"""Ben-Or and Tiwari sparse interpolation over Q.
The black box f takes a tuple of n integers and returns the value of an unknown
polynomial f in Q[x_1, ..., x_n] with at most T terms, as an int or Fraction.
ben_or_tiwari(f, n, T) returns f as a dict from exponent tuples to nonzero
Fractions, using exactly 2T evaluations at the points (p_1^i, ..., p_n^i),
i = 0, ..., 2T - 1, where p_1, ..., p_n are the first n primes.
Cost model: operations in Q, plus 2T calls to the black box. Berlekamp-Massey
costs O(T^2), the transposed Vandermonde system O(t^2), and the integer roots
are found by brute force modulo a small prime q and Newton (Hensel) lifting,
O(q t + t^2 log(d T)) operations on integers of O(d T log p_n) digits, where t
is the true number of terms and d the total degree. Precondition: T >= t.
"""
from fractions import Fraction
from typing import Callable, Dict, List, Sequence, Tuple, Union
Number = Union[int, Fraction]
Mono = Tuple[int, ...]
def is_prime(c: int) -> bool:
return c > 1 and all(c % p for p in range(2, int(c ** 0.5) + 1))
def first_primes(k: int, start: int = 2) -> List[int]:
"""The first k primes that are at least start."""
out: List[int] = []
c = start
while len(out) < k:
if is_prime(c):
out.append(c)
c += 1
return out
def berlekamp_massey(a: Sequence[Fraction]) -> List[Fraction]:
"""Connection polynomial C (C[0] = 1) of the shortest recurrence for a, over Q."""
C: List[Fraction] = [Fraction(1)]
B: List[Fraction] = [Fraction(1)]
L, m, b = 0, 1, Fraction(1)
for N in range(len(a)):
d = a[N]
for i in range(1, L + 1):
d += C[i] * a[N - i]
if d == 0:
m += 1
continue
coef = d / b
T = list(C)
C = C + [Fraction(0)] * (len(B) + m - len(C))
for i, v in enumerate(B):
C[i + m] -= coef * v
if 2 * L <= N:
L, B, b, m = N + 1 - L, T, d, 1
else:
m += 1
return (C + [Fraction(0)] * (L + 1))[: L + 1]
def peval(P: Sequence[int], z: int, mod: int = 0) -> int:
"""Horner evaluation of P (low degree first), optionally modulo mod."""
v = 0
for c in reversed(P):
v = v * z + c
if mod:
v %= mod
return v
def integer_roots(P: List[int]) -> List[int]:
"""The t distinct integer roots of a monic P of degree t that splits over Z with simple roots."""
t = len(P) - 1
dP = [i * P[i] for i in range(1, t + 1)]
bound = 1 + max(abs(c) for c in P[:-1])
q = max(2 * t + 3, 101)
while True:
q += 1
if not is_prime(q):
continue
roots = [r for r in range(q) if peval(P, r, q) == 0]
if len(roots) != t or any(peval(dP, r, q) == 0 for r in roots):
continue
out = []
for r in roots:
mod = q
while mod <= 2 * bound:
mod = mod * mod
inv = pow(peval(dP, r, mod), -1, mod)
r = (r - peval(P, r, mod) * inv) % mod
if r > mod // 2:
r -= mod
out.append(r)
if all(peval(P, r) == 0 for r in out):
return sorted(out)
def solve_transposed_vandermonde(Lam: List[int], nodes: List[int], rhs: List[Fraction]) -> List[Fraction]:
"""Solve sum_j c_j nodes[j]^i = rhs[i] for i < t in O(t^2) operations.
Lam is the monic polynomial with the distinct roots nodes (low degree first).
With Lam_j = Lam / (z - m_j) = sum_i q_i z^i, sum_i q_i rhs[i] = c_j Lam'(m_j).
"""
t = len(nodes)
dLam = [i * Lam[i] for i in range(1, t + 1)]
out: List[Fraction] = []
for m in nodes:
q = [0] * t
acc = 0
for i in range(t, 0, -1):
acc = acc * m + Lam[i]
q[i - 1] = acc
s = sum((Fraction(qi) * r for qi, r in zip(q, rhs)), Fraction(0))
out.append(s / peval(dLam, m))
return out
def ben_or_tiwari(f: Callable[[Tuple[int, ...]], Number], n: int, T: int) -> Dict[Mono, Fraction]:
"""Interpolate a polynomial with at most T terms from 2T black-box values.
Precondition: f is a polynomial over Q in n variables with at most T terms.
Cost: 2T evaluations, O(T^2) operations in Q, and the integer root finding.
"""
primes = first_primes(n) # @line L1
a = [Fraction(f(tuple(p ** i for p in primes))) for i in range(2 * T)] # @line L2
C = berlekamp_massey(a) # @line L3
t = len(C) - 1
if t == 0: # @line L4
return {}
Lam = [C[t - i] for i in range(t + 1)]
assert all(c.denominator == 1 for c in Lam)
P = [int(c) for c in Lam]
m = integer_roots(P) # @line L5
exps: List[Mono] = []
for v in m: # @line L6
e = []
for p in primes:
k = 0
while v % p == 0:
v //= p
k += 1
e.append(k)
if v != 1:
raise ValueError("the black box is not a polynomial with at most T terms")
exps.append(tuple(e))
c = solve_transposed_vandermonde(P, m, a[:t]) # @line L7
return {e: cj for e, cj in zip(exps, c) if cj} # @line L8
test_ben_or_tiwari_algorithm.py 120 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/ben-or-tiwari-algorithm/. The authors are listed in prov/RHYB1V.jsonl.
import random
from fractions import Fraction
from ben_or_tiwari_algorithm import ben_or_tiwari, berlekamp_massey, integer_roots
def evaluate(poly, point):
total = Fraction(0)
for e, c in poly.items():
v = Fraction(c)
for xi, ei in zip(point, e):
v *= Fraction(xi) ** ei
total += v
return total
def black_box(poly, counter=None):
def f(point):
if counter is not None:
counter.append(point)
return evaluate(poly, point)
return f
def random_sparse(rng, n, t, d):
poly = {}
while len(poly) < t:
e = tuple(rng.randint(0, d) for _ in range(n))
c = Fraction(rng.randint(-20, 20), rng.randint(1, 6))
if c:
poly[e] = c
return poly
def test_worked_example():
# f = 3 x^2 y - 2 y^3 + 5, T = 3
poly = {(2, 1): Fraction(3), (0, 3): Fraction(-2), (0, 0): Fraction(5)}
calls = []
out = ben_or_tiwari(black_box(poly, calls), 2, 3)
assert out == poly
assert calls == [(1, 1), (2, 3), (4, 9), (8, 27), (16, 81), (32, 243)]
def test_worked_example_values():
poly = {(2, 1): Fraction(3), (0, 3): Fraction(-2), (0, 0): Fraction(5)}
a = [evaluate(poly, (2 ** i, 3 ** i)) for i in range(6)]
assert a == [6, -13, -1021, -34177, -1000669, -27951313]
C = berlekamp_massey(a)
# Lambda(z) = (z - 12)(z - 27)(z - 1)
assert [C[3 - i] for i in range(4)] == [-324, 363, -40, 1]
def test_edge_cases():
assert ben_or_tiwari(black_box({}), 2, 3) == {}
assert ben_or_tiwari(black_box({(0, 0): Fraction(7)}), 2, 1) == {(0, 0): 7}
assert ben_or_tiwari(black_box({(0, 0, 0): Fraction(-1, 3)}), 3, 4) == {(0, 0, 0): Fraction(-1, 3)}
assert ben_or_tiwari(black_box({(5,): Fraction(2)}), 1, 1) == {(5,): 2}
assert ben_or_tiwari(black_box({}), 1, 1) == {}
one = {(0, 9, 0, 1): Fraction(4, 7)}
assert ben_or_tiwari(black_box(one), 4, 1) == one
def test_univariate_dense():
poly = {(k,): Fraction(k + 1) for k in range(8)}
assert ben_or_tiwari(black_box(poly), 1, 8) == poly
def test_integer_roots():
rng = random.Random(7)
for _ in range(200):
t = rng.randint(1, 6)
roots = rng.sample(range(-500, 500), t)
P = [1]
for r in roots:
Q = [0] * (len(P) + 1)
for i, c in enumerate(P):
Q[i + 1] += c
Q[i] -= r * c
P = Q
assert integer_roots(P) == sorted(roots)
def test_random_exact_bound():
rng = random.Random(2024)
for trial in range(400):
n = rng.randint(1, 3)
d = rng.randint(0, 5)
t = rng.randint(0, min(6, (d + 1) ** n))
poly = random_sparse(rng, n, t, d)
calls = []
out = ben_or_tiwari(black_box(poly, calls), n, t)
assert out == poly
assert len(calls) == 2 * t
def test_random_loose_bound():
rng = random.Random(99)
for trial in range(200):
n = rng.randint(1, 4)
d = rng.randint(0, 4)
t = rng.randint(0, min(5, (d + 1) ** n))
T = t + rng.randint(0, 4)
poly = random_sparse(rng, n, t, d)
out = ben_or_tiwari(black_box(poly), n, T)
assert out == poly
# the defining property: agreement at random points
for _ in range(3):
pt = tuple(rng.randint(-50, 50) for _ in range(n))
assert evaluate(out, pt) == evaluate(poly, pt)
def test_high_degree_sparse():
rng = random.Random(5)
for _ in range(20):
n = 3
poly = random_sparse(rng, n, 4, 30)
assert ben_or_tiwari(black_box(poly), n, 4) == poly
What the next level needs
- the tests did not run in this build