Algorithm Search⌘K

Fast polynomial division in Python

Computes the quotient by inverting the reversed divisor with Newton iteration, in multiplication time.

File
fast_polynomial_division.py, 225 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
fast_polynomial_division.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/fast-polynomial-division/. The authors are listed in prov/X5VM5A.jsonl.

"""Fast division with remainder of polynomials, by Newton inversion of the reversed divisor.

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 []. Inputs may have zero entries at the
top; they are removed first. The coefficients may be int, Fraction, or any
ring element type with + - * and `/` for the inverse of a unit (for example a
Z/pZ class). The divisor's leading coefficient must be a unit; over Z this
means +1 or -1, so a monic divisor always works.

Costs are counted in ring operations. The multiplication is a parameter; the
default is Karatsuba multiplication, which works over every commutative ring
and has recursion depth O(log n).
"""

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)  # @line L1
    b = normalize(b)
    if not b:
        raise ZeroDivisionError("division by the zero polynomial")
    n, m = len(a) - 1, len(b) - 1
    if n < m:  # @line L2
        return [], a
    zero = b[0] - b[0]
    k = n - m + 1  # @line L3
    if rinv is None:
        rinv = inverse(b[::-1], k, mul)  # @line L4
    elif len(rinv) < k:
        raise ValueError("the precomputed inverse is too short")
    ra = a[::-1][:k]  # @line L5
    rq = _pad(mul(ra, list(rinv[:k]))[:k], k, zero)  # @line L6
    q = rq[::-1]  # @line L7
    if m == 0:
        return normalize(q), []
    qb = _pad(mul(q[:m], b[:m])[:m], m, zero)  # @line L8
    r = [a[i] - qb[i] for i in range(m)]  # @line L9
    return normalize(q), normalize(r)  # @line L10


def divmod_schoolbook(a: Sequence[Any], b: Sequence[Any]) -> Tuple[Poly, Poly]:
    """Long division, the reference: Theta((n - m + 1) m) ring operations."""
    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
    u = _unit_inverse(b[-1])
    r = list(a)
    q: Poly = [b[0] - b[0]] * (n - m + 1)
    for i in range(n - m, -1, -1):
        c = r[i + m] * u
        q[i] = c
        for j in range(m + 1):
            r[i + j] = r[i + j] - c * b[j]
    return normalize(q), normalize(r[:m])


if __name__ == "__main__":
    print(divmod_fast([6, 5, 4, 3, 2, 1], [1, 1, 1]))
test_fast_polynomial_division.py 234 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/fast-polynomial-division/. The authors are listed in prov/X5VM5A.jsonl.

import random
from fractions import Fraction

import pytest

from fast_polynomial_division import (
    divmod_fast,
    divmod_schoolbook,
    karatsuba_mul,
    normalize,
    reversed_inverse,
    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 on plain ints: textbook long division modulo p, or over
# Fraction, written without any code from the module under test.


def oracle_divmod(a, b, p=None):
    a = [x % p for x in a] if p else [Fraction(x) for x in a]
    b = [x % p for x in b] if p else [Fraction(x) for x in b]
    while a and a[-1] == 0:
        a.pop()
    while b and b[-1] == 0:
        b.pop()
    q = [0] * max(len(a) - len(b) + 1, 0)
    r = list(a)
    inv = pow(b[-1], -1, p) if p else 1 / b[-1]
    while len(r) >= len(b):
        c = r[-1] * inv
        if p:
            c %= p
        s = len(r) - len(b)
        q[s] = c
        for j, y in enumerate(b):
            r[s + j] -= c * y
            if p:
                r[s + j] %= p
        r.pop()
        while r and r[-1] == 0:
            r.pop()
    while q and q[-1] == 0:
        q.pop()
    return q, r


def to_int(v):
    return [x.v if isinstance(x, ModP) else x for x in v]


def rand_poly(rng, deg, lo=-9, hi=9):
    return [rng.randint(lo, hi) for _ in range(deg)] + [rng.choice([x for x in range(lo, hi + 1) if x])]


def check_identity(a, b, q, r):
    """a == q b + r and deg r < deg b, using the schoolbook product."""
    a, b = normalize(a), normalize(b)
    qb = schoolbook_mul(q, b) if q else []
    n = max(len(a), len(qb), len(r))
    pad = lambda v: list(v) + [0] * (n - len(v))
    assert [x - y - z for x, y, z in zip(pad(a), pad(qb), pad(r))] == [0] * n
    assert len(r) < len(b)
    assert not r or r[-1] != 0
    assert not q or q[-1] != 0


PRIMES = [2, 3, 7, 998244353]


def test_small_example():
    q, r = divmod_fast([6, 5, 4, 3, 2, 1], [1, 1, 1])
    assert q == [2, 1, 1, 1] and r == [4, 2]


def test_worked_example():
    # The example on the page: a = x^5 + 2x^4 + 3x^3 + 4x^2 + 5x + 6, b = x^2 + x + 1.
    a = [6, 5, 4, 3, 2, 1]
    b = [1, 1, 1]
    rinv = reversed_inverse(b, 4)
    assert rinv == [1, -1, 0, 1]
    assert divmod_fast(a, b, rinv=rinv) == ([2, 1, 1, 1], [4, 2])


def test_over_z_monic_random():
    rng = random.Random(1)
    for _ in range(300):
        n = rng.randint(0, 60)
        m = rng.randint(0, 40)
        a = rand_poly(rng, n)
        b = rand_poly(rng, m)[:-1] + [rng.choice([1, -1])]
        q, r = divmod_fast(a, b)
        check_identity(a, b, q, r)
        assert (q, r) == divmod_schoolbook(a, b)
        oq, orr = oracle_divmod(a, b)
        assert q == oq and r == orr


@pytest.mark.parametrize("p", PRIMES)
def test_mod_p_random(p):
    rng = random.Random(p)
    for _ in range(200):
        n = rng.randint(0, 50)
        m = rng.randint(0, 50)
        a = [ModP(rng.randrange(p), p) for _ in range(n + 1)]
        b = [ModP(rng.randrange(p), p) for _ in range(m)] + [ModP(rng.randrange(1, p), p)]
        q, r = divmod_fast(a, b)
        oq, orr = oracle_divmod(to_int(a), to_int(b), p)
        assert to_int(q) == oq and to_int(r) == orr
        assert (q, r) == divmod_schoolbook(a, b)


def test_fraction_non_monic():
    rng = random.Random(5)
    for _ in range(150):
        n = rng.randint(0, 30)
        m = rng.randint(0, 25)
        a = [Fraction(x) for x in rand_poly(rng, n)]
        b = [Fraction(x) for x in rand_poly(rng, m)]
        q, r = divmod_fast(a, b)
        check_identity(a, b, q, r)
        oq, orr = oracle_divmod([int(x) for x in a], [int(x) for x in b])
        assert q == oq and r == orr


def test_schoolbook_multiplier_agrees():
    rng = random.Random(9)
    for _ in range(50):
        a = rand_poly(rng, rng.randint(0, 80))
        b = rand_poly(rng, rng.randint(0, 30))[:-1] + [1]
        assert divmod_fast(a, b, mul=schoolbook_mul) == divmod_fast(a, b, mul=karatsuba_mul)


def test_precomputed_inverse_reused():
    rng = random.Random(11)
    b = rand_poly(rng, 7)[:-1] + [1]
    rinv = reversed_inverse(b, 40)
    for _ in range(50):
        a = rand_poly(rng, rng.randint(0, 46))
        assert divmod_fast(a, b, rinv=rinv) == divmod_schoolbook(a, b)
    with pytest.raises(ValueError):
        divmod_fast([0] * 60 + [1], b, rinv=rinv)


def test_edge_cases():
    # Zero dividend.
    assert divmod_fast([], [3, 1]) == ([], [])
    assert divmod_fast([0, 0], [3, 1]) == ([], [])
    # Dividend of lower degree than the divisor.
    assert divmod_fast([1, 2], [0, 0, 1]) == ([], [1, 2])
    # Constant divisors.
    assert divmod_fast([4, 5, 6], [1]) == ([4, 5, 6], [])
    assert divmod_fast([4, 5, 6], [-1]) == ([-4, -5, -6], [])
    assert divmod_fast([Fraction(4), 6], [Fraction(2)]) == ([2, 3], [])
    # Constant dividend and divisor.
    assert divmod_fast([7], [1]) == ([7], [])
    # Degree 1 divisor: remainder is a(c) for b = x - c.
    a = [1, -3, 0, 2]
    q, r = divmod_fast(a, [-2, 1])
    assert r == [1 - 6 + 0 + 16]
    # Leading zeros are stripped.
    assert divmod_fast([6, 5, 4, 3, 2, 1, 0, 0], [1, 1, 1, 0]) == ([2, 1, 1, 1], [4, 2])
    # Same degree.
    assert divmod_fast([5, 3, 2], [1, 1, 1]) == ([2], [3, 1])
    # Exact division.
    assert divmod_fast(schoolbook_mul([1, 2, 1], [3, 0, 1]), [3, 0, 1]) == ([1, 2, 1], [])
    # Very different degrees.
    rng = random.Random(3)
    a = rand_poly(rng, 400)
    for b in ([5, 1], rand_poly(rng, 399)[:-1] + [1]):
        assert divmod_fast(a, b) == divmod_schoolbook(a, b)


def test_errors():
    with pytest.raises(ZeroDivisionError):
        divmod_fast([1, 2], [])
    with pytest.raises(ZeroDivisionError):
        divmod_fast([1, 2], [0, 0])
    # Non-unit leading coefficient over Z.
    with pytest.raises(ValueError):
        divmod_fast([1, 2, 3], [1, 2])
    with pytest.raises(ValueError):
        divmod_schoolbook([1, 2, 3], [1, 2])

What the next level needs

  • the tests did not run in this build

Read the full page about Fast polynomial division