Algorithm Search⌘K

FFT-based polynomial multiplication in Python

Multiplies two degree-n polynomials in O(n log n) by evaluating, multiplying pointwise and interpolating.

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

"""Polynomial multiplication by the fast Fourier transform.

Polynomials are coefficient lists, lowest degree first, normalized (no zero
on top; the zero polynomial is []). The exact transform works over Z/pZ for
a prime p with 2^k | p - 1 (a number-theoretic transform). Products over Z,
over Z/mZ for any m, and over Q are reduced to it by several primes and the
Chinese remainder theorem. A floating point version over C is included for
comparison. Costs are in ring operations of Z/pZ: O(N log N) for N >= m + n - 1.
"""

from __future__ import annotations

import cmath
from fractions import Fraction
from math import lcm
from typing import List, Sequence

# Primes p = c 2^k + 1 with a primitive root g, for the exact transform.
NTT_PRIMES = [(998244353, 3), (167772161, 3), (469762049, 3), (754974721, 11)]


def _normalize(c: List) -> List:
    while c and c[-1] == 0:
        c.pop()
    return c


def ntt(a: List[int], omega: int, p: int) -> None:
    """In-place DFT of a, of length N = 2^k, at the powers of omega modulo p.

    Precondition: omega^N = 1 and omega^(N/2) = -1 modulo p.
    Cost: (N/2) log2 N butterflies, each 2 multiplications and 2 additions.
    """
    n = len(a)
    j = 0
    for i in range(1, n):  # @line F1: bit-reversal permutation
        bit = n >> 1
        while j & bit:
            j ^= bit
            bit >>= 1
        j |= bit
        if i < j:
            a[i], a[j] = a[j], a[i]
    length = 2  # @line F2
    while length <= n:  # @line F3
        w_len = pow(omega, n // length, p)  # @line F4
        half = length // 2
        for s in range(0, n, length):  # @line F5
            w = 1  # @line F6
            for k in range(half):  # @line F7
                t = w * a[s + k + half] % p  # @line F8
                x = a[s + k]
                a[s + k] = (x + t) % p  # @line F9
                a[s + k + half] = (x - t) % p
                w = w * w_len % p  # @line F10
        length <<= 1  # @line F11


def multiply_mod(a: Sequence[int], b: Sequence[int], p: int = 998244353, g: int = 3) -> List[int]:
    """The product a b over Z/pZ by the number-theoretic transform.

    Precondition: p is prime, g generates (Z/pZ)^*, and 2^k divides p - 1 for
    the power of two N >= len(a) + len(b) - 1. Cost: O(N log N) operations mod p.
    """
    m, n = len(a), len(b)
    if m == 0 or n == 0:  # @line L1
        return []
    size = 1
    while size < m + n - 1:  # @line L2
        size <<= 1
    if (p - 1) % size:
        raise ValueError("p - 1 is not divisible by the transform length")
    fa = [x % p for x in a] + [0] * (size - m)  # @line L3
    fb = [x % p for x in b] + [0] * (size - n)
    omega = pow(g, (p - 1) // size, p)
    ntt(fa, omega, p)  # @line L4
    ntt(fb, omega, p)
    for i in range(size):  # @line L5
        fa[i] = fa[i] * fb[i] % p
    ntt(fa, pow(omega, p - 2, p), p)  # @line L6
    inv = pow(size, p - 2, p)
    c = [x * inv % p for x in fa[: m + n - 1]]
    return _normalize(c)  # @line L7


def _is_prime(n: int) -> bool:
    """Deterministic Miller-Rabin for n < 3.4 * 10^14."""
    if n < 2:
        return False
    for q in (2, 3, 5, 7, 11, 13, 17):
        if n % q == 0:
            return n == q
    d, s = n - 1, 0
    while d % 2 == 0:
        d, s = d // 2, s + 1
    for b in (2, 3, 5, 7, 11, 13, 17):
        x = pow(b, d, n)
        if x in (1, n - 1):
            continue
        for _ in range(s - 1):
            x = x * x % n
            if x == n - 1:
                break
        else:
            return False
    return True


def _primitive_root(p: int) -> int:
    """The least generator of (Z/pZ)^* for a prime p with p - 1 = c 2^k, c small."""
    m, factors, q = p - 1, [2], 3
    while m % 2 == 0:
        m //= 2
    while q * q <= m:
        if m % q == 0:
            factors.append(q)
            while m % q == 0:
                m //= q
        q += 2
    if m > 1:
        factors.append(m)
    g = 2
    while any(pow(g, (p - 1) // f, p) == 1 for f in factors):
        g += 1
    return g


MAX_LOG = 20  # the extra primes support transform lengths up to 2^20


def _crt_primes(count: int, size: int) -> List[tuple]:
    """count primes p with size | p - 1, each with a primitive root."""
    if size > 1 << MAX_LOG:
        raise ValueError("transform length too large")
    out = [(p, g) for p, g in NTT_PRIMES if (p - 1) % size == 0][:count]
    c = 1
    while len(out) < count:
        p = (c << MAX_LOG) + 1
        if _is_prime(p) and all(p != q for q, _ in out):
            out.append((p, _primitive_root(p)))
        c += 1
    return out


def multiply_int(a: Sequence[int], b: Sequence[int]) -> List[int]:
    """The product a b over Z, exact, by several NTT primes and the CRT.

    Enough primes are used so that their product exceeds twice the bound
    min(m, n) max|a_i| max|b_j| on the absolute value of a coefficient.
    Cost: O(N log N) operations modulo each of the O(1 + log(bound) / 30) primes.
    """
    if not a or not b:
        return []
    bound = min(len(a), len(b)) * max(abs(x) for x in a) * max(abs(x) for x in b)
    size = 1
    while size < len(a) + len(b) - 1:
        size <<= 1
    count, prod = 0, 1
    for p, _ in NTT_PRIMES:
        if prod > 2 * bound:
            break
        count, prod = count + 1, prod * p
    if prod <= 2 * bound:
        count = (2 * bound).bit_length() // 20 + 1
    primes = _crt_primes(count, size)
    residues, mods, prod = [], [], 1
    for p, g in primes:
        residues.append(multiply_mod(a, b, p, g) + [0] * (len(a) + len(b) - 1))
        mods.append(p)
        prod *= p
    out = []
    for i in range(len(a) + len(b) - 1):
        x, mod = 0, 1
        for r, p in zip(residues, mods):
            # Garner step: x = x + mod * ((r_i - x) / mod mod p)
            t = (r[i] - x) * pow(mod, p - 2, p) % p
            x += mod * t
            mod *= p
        if x > prod // 2:
            x -= prod
        out.append(x)
    return _normalize(out)


def multiply_mod_any(a: Sequence[int], b: Sequence[int], m: int) -> List[int]:
    """The product a b over Z/mZ for any modulus m >= 2, through Z."""
    c = multiply_int([x % m for x in a], [x % m for x in b])
    return _normalize([x % m for x in c])


def multiply_fraction(a: Sequence[Fraction], b: Sequence[Fraction]) -> List[Fraction]:
    """The product a b over Q: clear denominators, multiply over Z, divide back."""
    if not a or not b:
        return []
    da = lcm(*[Fraction(x).denominator for x in a])
    db = lcm(*[Fraction(x).denominator for x in b])
    ia = [int(Fraction(x) * da) for x in a]
    ib = [int(Fraction(x) * db) for x in b]
    c = multiply_int(ia, ib)
    return [Fraction(x, da * db) for x in c]


def multiply_complex(a: Sequence[int], b: Sequence[int]) -> List[int]:
    """The product a b over Z by a floating point FFT over C, rounded.

    Correct only while the rounding error stays below 1/2; for 53-bit doubles
    this holds in practice when N max|a_i| max|b_j| is well below 2^50.
    """
    m, n = len(a), len(b)
    if m == 0 or n == 0:
        return []
    size = 1
    while size < m + n - 1:
        size <<= 1

    def fft(v: List[complex], sign: int) -> None:
        j = 0
        for i in range(1, size):
            bit = size >> 1
            while j & bit:
                j ^= bit
                bit >>= 1
            j |= bit
            if i < j:
                v[i], v[j] = v[j], v[i]
        length = 2
        while length <= size:
            half = length // 2
            for k in range(half):
                w = cmath.exp(sign * 2j * cmath.pi * k / length)
                for s in range(0, size, length):
                    t = w * v[s + k + half]
                    x = v[s + k]
                    v[s + k] = x + t
                    v[s + k + half] = x - t
            length <<= 1

    fa = [complex(x) for x in a] + [0j] * (size - m)
    fb = [complex(x) for x in b] + [0j] * (size - n)
    fft(fa, -1)
    fft(fb, -1)
    fc = [x * y for x, y in zip(fa, fb)]
    fft(fc, 1)
    return _normalize([round(x.real / size) for x in fc[: m + n - 1]])
test_fft_polynomial_multiplication.py 155 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/fft-polynomial-multiplication/. The authors are listed in prov/WT2CHV.jsonl.

import random
from fractions import Fraction

import pytest

from fft_polynomial_multiplication import (
    multiply_complex,
    multiply_fraction,
    multiply_int,
    multiply_mod,
    multiply_mod_any,
    ntt,
)


def school(a, b):
    """The schoolbook product, normalized: the reference."""
    if not a or not b:
        return []
    c = [0] * (len(a) + len(b) - 1)
    for i, x in enumerate(a):
        for j, y in enumerate(b):
            c[i + j] += x * y
    while c and c[-1] == 0:
        c.pop()
    return c


def strip(a):
    a = list(a)
    while a and a[-1] == 0:
        a.pop()
    return a


def school_mod(a, b, m):
    return strip([x % m for x in school(a, b)])


def test_example():
    # (1 + 2x)(3 - x) over Z/17Z with N = 4 and omega = 4 (4^2 = -1 mod 17)
    a = [1, 2, 0, 0]
    ntt(a, 4, 17)
    assert a == [3, 9, 16, 10]
    b = [3, 16, 0, 0]
    ntt(b, 4, 17)
    assert b == [2, 16, 4, 7]
    c = [x * y % 17 for x, y in zip(a, b)]
    assert c == [6, 8, 13, 2]
    ntt(c, 13, 17)  # 13 = 4^-1 mod 17
    inv4 = pow(4, -1, 17)
    assert [x * inv4 % 17 for x in c] == [3, 5, 15, 0]  # 3 + 5x - 2x^2
    assert multiply_int([1, 2], [3, -1]) == [3, 5, -2]
    assert multiply_mod([1, 1, 0, 3], [2, 998244352]) == [2, 1, 998244352, 6, 998244350]


def test_dft_is_evaluation():
    p, g = 998244353, 3
    rng = random.Random(3)
    for k in range(0, 7):
        size = 1 << k
        a = [rng.randrange(p) for _ in range(size)]
        w = pow(g, (p - 1) // size, p)
        got = list(a)
        ntt(got, w, p)
        want = [sum(a[j] * pow(w, i * j, p) for j in range(size)) % p for i in range(size)]
        assert got == want


def test_edges():
    assert multiply_int([], [1, 2]) == [] and multiply_int([3], []) == []
    assert multiply_int([5], [7]) == [35] and multiply_int([0, 1], [0, 1]) == [0, 0, 1]
    assert multiply_int([2], [0, 0, 1]) == [0, 0, 2]
    # products that cancel at the top over Z/pZ
    assert multiply_mod_any([1, 2], [1, 2], 4) == [1]
    assert multiply_mod_any([1, 1], [1, 1], 2) == [1, 0, 1]
    assert multiply_mod_any([2], [3], 6) == []
    # negative and large coefficients
    big = 10**30
    assert multiply_int([big, -big], [big, big]) == school([big, -big], [big, big])
    # very different degrees
    rng = random.Random(9)
    long = [rng.randint(-10**6, 10**6) for _ in range(700)] + [1]
    assert multiply_int(long, [3, -1]) == school(long, [3, -1])
    assert multiply_fraction([Fraction(1, 2)], [Fraction(2, 3), Fraction(0)]) == [Fraction(1, 3)]
    with pytest.raises(ValueError):
        multiply_mod([1] * 10, [1] * 10, 7, 3)


def test_random_over_z():
    rng = random.Random(1)
    for _ in range(600):
        a = strip([rng.randint(-100, 100) for _ in range(rng.randint(0, 20))])
        b = strip([rng.randint(-100, 100) for _ in range(rng.randint(0, 20))])
        assert multiply_int(a, b) == school(a, b)
    for _ in range(40):
        a = strip([rng.randint(-10**40, 10**40) for _ in range(rng.randint(1, 30))])
        b = strip([rng.randint(-10**20, 10**20) for _ in range(rng.randint(1, 30))])
        assert multiply_int(a, b) == school(a, b)


@pytest.mark.parametrize("m", [2, 3, 7, 998244353, 10**9 + 7, 4, 12])
def test_random_mod(m):
    rng = random.Random(m)
    for _ in range(500):
        a = strip([rng.randrange(m) for _ in range(rng.randint(0, 16))])
        b = strip([rng.randrange(m) for _ in range(rng.randint(0, 16))])
        assert multiply_mod_any(a, b, m) == school_mod(a, b, m)
        if m == 998244353:
            assert multiply_mod(a, b) == school_mod(a, b, m)


def test_random_ntt_primes():
    rng = random.Random(4)
    from fft_polynomial_multiplication import NTT_PRIMES

    for p, g in NTT_PRIMES:
        for _ in range(150):
            a = strip([rng.randrange(p) for _ in range(rng.randint(0, 40))])
            b = strip([rng.randrange(p) for _ in range(rng.randint(0, 40))])
            assert multiply_mod(a, b, p, g) == school_mod(a, b, p)


def test_random_over_q():
    rng = random.Random(2)
    for _ in range(500):
        a = strip([Fraction(rng.randint(-9, 9), rng.randint(1, 6)) for _ in range(rng.randint(0, 10))])
        b = strip([Fraction(rng.randint(-9, 9), rng.randint(1, 6)) for _ in range(rng.randint(0, 10))])
        assert multiply_fraction(a, b) == school(a, b)


def test_complex_fft_small_coefficients():
    rng = random.Random(6)
    for _ in range(500):
        a = strip([rng.randint(-50, 50) for _ in range(rng.randint(0, 30))])
        b = strip([rng.randint(-50, 50) for _ in range(rng.randint(0, 30))])
        assert multiply_complex(a, b) == school(a, b)


def test_larger_sizes():
    rng = random.Random(8)
    a = [rng.randint(-1000, 1000) for _ in range(2000)] + [1]
    b = [rng.randint(-1000, 1000) for _ in range(1500)] + [1]
    c = multiply_int(a, b)
    # check by evaluation at a few points instead of a slow schoolbook product
    for u in (1, -1, 2, 3):
        def ev(p):
            s = 0
            for x in reversed(p):
                s = s * u + x
            return s
        assert ev(c) == ev(a) * ev(b)

What the next level needs

  • the tests did not run in this build

Read the full page about FFT-based polynomial multiplication