Brent-Kung composition algorithm in Python
Composes polynomials in O(sqrt(n) M(n)) operations by baby-step giant-step over the Horner form.
- File
brent_kung_composition.py, 323 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/brent-kung-composition/. The authors are listed in prov/48QJRY.jsonl.
"""Brent-Kung modular composition: f(g) mod h by baby steps and giant steps.
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 may be int, Fraction, or
any ring element type with + - * (and `/` for the inverse of a unit). The
leading coefficient of h must be a unit, so over Z it must be 1 or -1.
With N = len(f) and k = ceil(sqrt(N)), the algorithm forms the baby steps
g^0, ..., g^k mod h, gets every block F_j(g) mod h from one matrix product
of an m x k matrix by a k x n matrix, and joins the blocks by Horner's rule
in g^k mod h. That is about 2 sqrt(N) products modulo h, plus the matrix
product, which this file does by the schoolbook method. Costs are counted in
ring operations. All loops are iterative.
"""
from __future__ import annotations
from math import isqrt
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 block_size(N: int) -> int:
"""Return ceil(sqrt(N)) for N >= 1."""
k = isqrt(N)
return k if k * k == N else k + 1
class _Modulus:
"""Reduction modulo a fixed h, with the inverse of rev(h) computed once."""
def __init__(self, h: Sequence[Any], mul: MulFn) -> None:
self.h = normalize(h)
if not self.h:
raise ZeroDivisionError("modulus is the zero polynomial")
self.n = len(self.h) - 1
self.mul = mul
self.rinv = reversed_inverse(self.h, max(self.n, 1), mul) if self.n >= 1 else None
self.count = 0
def reduce(self, a: Sequence[Any]) -> Poly:
a = normalize(a)
if len(a) <= self.n:
return a
if self.n == 0:
return []
k = len(a) - self.n
rinv = self.rinv if k <= len(self.rinv) else None
return divmod_fast(a, self.h, self.mul, rinv)[1]
def mulmod(self, a: Sequence[Any], b: Sequence[Any]) -> Poly:
self.count += 1
return self.reduce(self.mul(list(a), list(b)))
def mat_mul(a: List[List[Any]], b: List[List[Any]], zero: Any) -> List[List[Any]]:
"""Schoolbook product of an r x s matrix by an s x t matrix."""
t = len(b[0]) if b else 0
out = []
for row in a:
acc = [zero] * t
for i, c in enumerate(row):
if c == 0:
continue
bi = b[i]
for j in range(t):
acc[j] = acc[j] + c * bi[j]
out.append(acc)
return out
def compose_mod(
f: Sequence[Any],
g: Sequence[Any],
h: Sequence[Any],
mul: MulFn = karatsuba_mul,
stats: dict = None,
) -> Poly:
"""Return f(g) mod h, normalized.
Precondition: h is nonzero with a unit leading coefficient.
Cost: about 2 sqrt(N) products modulo h and an m x k by k x n matrix
product, with N = len(f), k = ceil(sqrt(N)), m = ceil(N / k), n = deg h.
"""
md = _Modulus(h, mul)
n = md.n
f = normalize(f)
if n == 0 or not f:
return []
zero = md.h[0] - md.h[0]
one = zero + 1
g = md.reduce(g)
N = len(f)
k = block_size(N) # @line L1
m = -(-N // k)
G: List[Poly] = [[one], g] # @line L2
for _ in range(2, min(k, N - 1) + 1): # @line L3
G.append(md.mulmod(G[-1], g))
A = [[f[j * k + i] if j * k + i < N else zero for i in range(k)] for j in range(m)] # @line L4
B = [_pad(list(G[i]), n, zero) if i < len(G) else [zero] * n for i in range(k)] # @line L5
C = mat_mul(A, B, zero) # @line L6
s = C[m - 1] # @line L7
for j in range(m - 2, -1, -1): # @line L8
s = _add(md.mulmod(s, G[k]), C[j]) # @line L9
if stats is not None:
stats["mulmod"] = md.count
stats["k"], stats["m"] = k, m
return normalize(s) # @line L10
def compose_mod_horner(f: Sequence[Any], g: Sequence[Any], h: Sequence[Any]) -> Poly:
"""Reference: Horner's rule modulo h with schoolbook arithmetic."""
h = normalize(h)
if not h:
raise ZeroDivisionError("modulus is the zero polynomial")
f = normalize(f)
n = len(h) - 1
if n == 0 or not f:
return []
inv = _unit_inverse(h[-1])
def rem(a: Poly) -> Poly:
a = list(a)
for d in range(len(a) - 1, n - 1, -1):
c = a[d] * inv
if c != 0:
for i in range(n + 1):
a[d - n + i] = a[d - n + i] - c * h[i]
return normalize(a[:n])
s: Poly = []
for c in reversed(f):
s = rem(_add(schoolbook_mul(s, g), [c]))
return s
if __name__ == "__main__":
st = {}
f, g, h = [1, 2, 3, 4], [0, 1, 1], [-2, 0, 0, 0, 1]
print(compose_mod(f, g, h, stats=st), st, compose_mod_horner(f, g, h))
test_brent_kung_composition.py 199 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/brent-kung-composition/. The authors are listed in prov/48QJRY.jsonl.
import random
from fractions import Fraction
import pytest
from brent_kung_composition import block_size, compose_mod, compose_mod_horner, schoolbook_mul
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: expand f(g) in full by schoolbook products, then reduce
# by schoolbook long division with the inverse of the leading coefficient.
def strip(a):
a = list(a)
while a and a[-1] == 0:
a.pop()
return a
def full_compose(f, g, zero):
out = []
pw = [zero + 1]
for c in f:
term = [c * x for x in pw]
if len(out) < len(term):
out += [zero] * (len(term) - len(out))
for i, x in enumerate(term):
out[i] = out[i] + x
pw = schoolbook_mul(pw, g) if g else []
if not pw:
pw = []
return strip(out)
def long_rem(a, h, inv):
a = list(a)
n = len(h) - 1
for d in range(len(a) - 1, n - 1, -1):
c = a[d] * inv
for i in range(n + 1):
a[d - n + i] = a[d - n + i] - c * h[i]
return strip(a[:n])
def oracle(f, g, h, zero, inv):
h = strip(h)
if len(h) == 1:
return []
return long_rem(full_compose(strip(f), strip(g), zero), h, inv)
PRIMES = [2, 3, 7, 998244353]
def test_page_example():
st = {}
f, g, h = [1, 2, 3, 4], [0, 1, 1], [-2, 0, 0, 0, 1]
assert compose_mod(f, g, h, stats=st) == [31, 26, 13, 10]
assert st == {"mulmod": 2, "k": 2, "m": 2}
# the intermediate values of the trace
g2 = long_rem(schoolbook_mul(g, g), h, 1)
assert g2 == [2, 0, 1, 2]
c1 = [3, 4, 4]
c0 = [1, 2, 2]
s = long_rem(schoolbook_mul(c1, g2), h, 1)
assert s == [30, 24, 11, 10]
assert [x + y for x, y in zip(s, c0 + [0])] == [31, 26, 13, 10]
def test_edge_cases():
h = [-2, 0, 0, 0, 1]
assert compose_mod([], [1, 1], h) == []
assert compose_mod([5], [1, 1], h) == [5]
assert compose_mod([0, 0, 0], [1, 1], h) == []
assert compose_mod([1, 2, 3], [], h) == [1]
assert compose_mod([1, 2, 3], [1, 2], [7]) == []
assert compose_mod([1, 2, 3], [1, 2], [1]) == []
# leading zeros in all three inputs are stripped
assert compose_mod([1, 2, 3, 0, 0], [0, 1, 1, 0], h + [0, 0]) == compose_mod([1, 2, 3], [0, 1, 1], h)
# degree 1 modulus: f(g) mod (x - a) is the constant f(g(a))
assert compose_mod([1, 2, 3], [4, 5], [-2, 1]) == [1 + 2 * 14 + 3 * 14 * 14]
# g of higher degree than h is reduced first
assert compose_mod([0, 1], [1, 2, 3, 4, 5, 6], h) == oracle([0, 1], [1, 2, 3, 4, 5, 6], h, 0, 1)
# leading coefficient -1 is a unit of Z
assert compose_mod([1, 1, 1], [0, 1, 1], [2, 0, -1]) == oracle([1, 1, 1], [0, 1, 1], [2, 0, -1], 0, -1)
with pytest.raises(ValueError):
compose_mod([1, 1, 1], [0, 1], [1, 0, 2])
with pytest.raises(ZeroDivisionError):
compose_mod([1, 1], [0, 1], [0, 0])
def test_over_z_random():
rng = random.Random(1)
for _ in range(100):
n = rng.randint(1, 25)
h = [rng.randint(-5, 5) for _ in range(n)] + [rng.choice([1, -1])]
f = [rng.randint(-9, 9) for _ in range(rng.randint(0, n + 4))]
g = [rng.randint(-3, 3) for _ in range(rng.randint(0, n + 3))]
want = oracle(f, g, h, 0, h[-1])
assert compose_mod(f, g, h) == want
assert compose_mod_horner(f, g, h) == want
@pytest.mark.parametrize("p", PRIMES)
def test_over_z_mod_p(p):
rng = random.Random(p)
zero = ModP(0, p)
for _ in range(40):
n = rng.randint(1, 25)
lc = rng.randrange(1, p)
h = [ModP(rng.randrange(p), p) for _ in range(n)] + [ModP(lc, p)]
f = [ModP(rng.randrange(p), p) for _ in range(rng.randint(0, n + 2))]
g = [ModP(rng.randrange(p), p) for _ in range(rng.randint(0, n + 2))]
want = oracle(f, g, h, zero, ModP(pow(lc, -1, p), p))
assert compose_mod(f, g, h) == want
def test_over_fractions_non_monic():
rng = random.Random(5)
zero = Fraction(0)
for _ in range(30):
n = rng.randint(1, 12)
h = [Fraction(rng.randint(-5, 5), rng.randint(1, 4)) for _ in range(n)] + [Fraction(rng.randint(1, 5), rng.randint(1, 3))]
f = [Fraction(rng.randint(-5, 5), rng.randint(1, 4)) for _ in range(rng.randint(0, n + 1))]
g = [Fraction(rng.randint(-3, 3), rng.randint(1, 3)) for _ in range(rng.randint(0, n))]
assert compose_mod(f, g, h) == oracle(f, g, h, zero, 1 / h[-1])
def test_counts_and_larger_case():
rng = random.Random(9)
p = 998244353
n = 64
h = [rng.randrange(p) for _ in range(n)] + [1]
g = [rng.randrange(p) for _ in range(n)]
f = [rng.randrange(p) for _ in range(n)]
hp, gp, fp = ([ModP(c, p) for c in v] for v in (h, g, f))
st = {}
got = compose_mod(fp, gp, hp, stats=st)
assert got == compose_mod_horner(fp, gp, hp)
k = block_size(n)
assert st["k"] == 8 and st["m"] == 8
assert st["mulmod"] == 2 * k - 2
for N in range(1, 80):
k = block_size(N)
assert (k - 1) ** 2 < N <= k * k
st = {}
compose_mod([1] * N, [0, 1], [0, 0, 1], stats=st)
assert st["mulmod"] <= max(2 * k - 2, 0)
What the next level needs
- the tests did not run in this build