Algorithm Search⌘K

Berlekamp's factorization algorithm in Python

Factors a square-free polynomial over GF(p) by finding the null space of the Berlekamp matrix.

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

"""Berlekamp's algorithm: factor a monic square-free polynomial over Z/pZ.

A polynomial a_0 + a_1 x + ... + a_n x^n is the list [a_0, ..., a_n] of ints
in 0 .. p - 1, lowest degree first; the zero polynomial is []. p must be a
prime.

The Berlekamp algebra of f is the set of v with deg v < n and v^p = v mod f.
It is the left null space of Q - I, where row i of Q holds x^(p i) mod f, and
its dimension is the number of irreducible factors. For small p the factors
are split deterministically with gcd(v - s, h) for all s in Z/pZ; for large
p a random element of the algebra is raised to the power (p - 1)/2.
"""

from __future__ import annotations

import random
from typing import List, Optional, Sequence, Tuple

Poly = List[int]

SMALL_P = 50


def trim(a: Sequence[int]) -> Poly:
    """Return a copy of a without trailing zero coefficients."""
    out = list(a)
    while out and out[-1] == 0:
        out.pop()
    return out


def reduce_p(a: Sequence[int], p: int) -> Poly:
    """Reduce every coefficient into 0 .. p - 1 and strip leading zeros."""
    return trim([x % p for x in a])


def add(a: Sequence[int], b: Sequence[int], p: int) -> Poly:
    n = max(len(a), len(b))
    return trim([((a[i] if i < len(a) else 0) + (b[i] if i < len(b) else 0)) % p for i in range(n)])


def sub(a: Sequence[int], b: Sequence[int], p: int) -> Poly:
    n = max(len(a), len(b))
    return trim([((a[i] if i < len(a) else 0) - (b[i] if i < len(b) else 0)) % p for i in range(n)])


def mul(a: Sequence[int], b: Sequence[int], p: int) -> Poly:
    """Schoolbook product in (Z/pZ)[x]."""
    if not a or not b:
        return []
    c = [0] * (len(a) + len(b) - 1)
    for i, x in enumerate(a):
        if x:
            for j, y in enumerate(b):
                c[i + j] += x * y
    return reduce_p(c, p)


def divmod_p(a: Sequence[int], b: Sequence[int], p: int) -> Tuple[Poly, Poly]:
    """Quotient and remainder of a by the nonzero b in (Z/pZ)[x]."""
    b = reduce_p(b, p)
    if not b:
        raise ZeroDivisionError("division by the zero polynomial")
    r = reduce_p(a, p)
    inv = pow(b[-1], -1, p)
    db = len(b) - 1
    if len(r) <= db:
        return [], r
    q = [0] * (len(r) - db)
    for i in range(len(r) - 1, db - 1, -1):
        c = r[i] * inv % p
        if c:
            q[i - db] = c
            for j in range(db + 1):
                r[i - db + j] = (r[i - db + j] - c * b[j]) % p
    return trim(q), trim(r[:db])


def rem(a: Sequence[int], b: Sequence[int], p: int) -> Poly:
    return divmod_p(a, b, p)[1]


def monic(a: Sequence[int], p: int) -> Poly:
    """a divided by its leading coefficient; the zero polynomial stays zero."""
    a = reduce_p(a, p)
    if not a:
        return []
    inv = pow(a[-1], -1, p)
    return [x * inv % p for x in a]


def gcd_p(a: Sequence[int], b: Sequence[int], p: int) -> Poly:
    """The monic gcd in (Z/pZ)[x], by the Euclidean algorithm."""
    a, b = reduce_p(a, p), reduce_p(b, p)
    while b:
        a, b = b, rem(a, b, p)
    return monic(a, p)


def powmod(a: Sequence[int], e: int, f: Sequence[int], p: int) -> Poly:
    """a^e mod f in (Z/pZ)[x], by binary exponentiation (a loop)."""
    result = rem([1], f, p)
    base = rem(a, f, p)
    while e > 0:
        if e & 1:
            result = rem(mul(result, base, p), f, p)
        base = rem(mul(base, base, p), f, p)
        e >>= 1
    return result


def derivative(a: Sequence[int], p: int) -> Poly:
    return reduce_p([i * a[i] for i in range(1, len(a))], p)


def berlekamp_matrix(f: Sequence[int], p: int) -> List[List[int]]:
    """The n x n matrix Q whose row i holds the coefficients of x^(p i) mod f."""
    n = len(f) - 1
    xp = powmod([0, 1], p, f, p)
    rows: List[List[int]] = []
    cur: Poly = [1]
    for _ in range(n):
        rows.append(cur + [0] * (n - len(cur)))
        cur = rem(mul(cur, xp, p), f, p)
    return rows


def left_null_space(a: Sequence[Sequence[int]], p: int) -> List[Poly]:
    """A basis of the vectors v with v a = 0 over Z/pZ, by Gauss-Jordan elimination.

    The basis vector for the free variable j has a 1 at position j and zeros
    at the other free positions; they are returned in increasing order of j.
    """
    n = len(a)
    m = [[a[i][j] % p for i in range(n)] for j in range(len(a[0]))]  # transpose: m v = 0
    pivots: List[int] = []
    row = 0
    for col in range(n):
        piv = next((r for r in range(row, len(m)) if m[r][col]), None)
        if piv is None:
            continue
        m[row], m[piv] = m[piv], m[row]
        inv = pow(m[row][col], p - 2, p)
        m[row] = [x * inv % p for x in m[row]]
        for r in range(len(m)):
            if r != row and m[r][col]:
                c = m[r][col]
                m[r] = [(x - c * y) % p for x, y in zip(m[r], m[row])]
        pivots.append(col)
        row += 1
    free = [j for j in range(n) if j not in pivots]
    basis: List[Poly] = []
    for j in free:
        v = [0] * n
        v[j] = 1
        for r, col in enumerate(pivots):
            v[col] = (-m[r][j]) % p
        basis.append(v)
    return basis


def berlekamp_factorization(f: Sequence[int], p: int, method: Optional[str] = None,
                            rng: Optional[random.Random] = None,
                            stats: Optional[dict] = None) -> List[Poly]:
    """Return the monic irreducible factors of a monic square-free f, sorted.

    method is "deterministic" (gcds with v - s for all s) or "random"
    (powers (p - 1)/2 of random elements of the algebra); the default is
    deterministic for p <= SMALL_P. If stats is a dict, it receives the
    matrix Q, the basis and the number r of factors.
    """
    f = reduce_p(f, p)
    if not f or f[-1] != 1:
        raise ValueError("f must be monic")
    if len(f) <= 1:
        return []
    if len(gcd_p(f, derivative(f, p), p)) > 1:
        raise ValueError("f must be square-free")
    if method is None:
        method = "deterministic" if p <= SMALL_P else "random"
    if method == "random" and p == 2:
        method = "deterministic"
    n = len(f) - 1  # @line L1
    q = berlekamp_matrix(f, p)  # @line L1
    a = [[(q[i][j] - (i == j)) % p for j in range(n)] for i in range(n)]  # @line L2
    basis = [trim(v) for v in left_null_space(a, p)]  # @line L2
    r = len(basis)  # @line L2
    if stats is not None:
        stats.update(q=q, basis=basis, r=r)
    if method == "random":
        return _random_split(f, p, basis, rng or random.Random(1))
    factors = [f]  # @line L3
    for v in basis[1:]:  # @line L4
        if len(factors) == r:  # @line L4
            break
        new: List[Poly] = []  # @line L5
        for h in factors:  # @line L6
            if len(h) == 2:
                new.append(h)
                continue
            for s in range(p):  # @line L7
                g = gcd_p(sub(v, [s], p), h, p)  # @line L8
                if len(g) > 1:  # @line L8
                    new.append(g)  # @line L8
        factors = new  # @line L9
    return sorted(factors, key=lambda g: (len(g), g))  # @line L10


def _random_split(f: Poly, p: int, basis: List[Poly], rng: random.Random) -> List[Poly]:
    """Split with gcd(a^((p - 1)/2) - 1, h) for random a in the Berlekamp algebra (odd p)."""
    r = len(basis)
    factors = [f]
    e = (p - 1) // 2
    rounds = 0
    while len(factors) < r:
        rounds += 1
        if rounds > 64 * (r + 4):
            raise RuntimeError("no split found")
        a: Poly = []
        for v in basis:
            c = rng.randrange(p)
            a = add(a, [c * x % p for x in v], p)
        new: List[Poly] = []
        for h in factors:
            if len(h) == 2:
                new.append(h)
                continue
            g = gcd_p(sub(powmod(a, e, h, p), [1], p), h, p)
            if 1 < len(g) < len(h):
                new.extend([g, divmod_p(h, g, p)[0]])
            else:
                new.append(h)
        factors = new
    return sorted(factors, key=lambda g: (len(g), g))
test_berlekamp_factorization.py 215 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/berlekamp-factorization/. The authors are listed in prov/T0YM2W.jsonl.

import random

import pytest

from berlekamp_factorization import berlekamp_factorization as bf, berlekamp_matrix, left_null_space


# Independent oracle: plain mod-p list arithmetic and factorization by trial division.
# Independent oracle: plain mod-p list arithmetic and factorization by trial division.
def ref_trim(a):
    a = list(a)
    while a and a[-1] == 0:
        a.pop()
    return a


def ref_mod(a, p):
    return ref_trim([x % p for x in a])


def ref_mul(a, b, p):
    if not a or not b:
        return []
    out = [0] * (len(a) + len(b) - 1)
    for i in range(len(a)):
        for j in range(len(b)):
            out[i + j] += a[i] * b[j]
    return ref_mod(out, p)


def ref_divmod(a, b, p):
    """Long division by the nonzero b, coefficients modulo p."""
    a = ref_mod(a, p)
    inv = pow(b[-1], -1, p)
    q = [0] * max(0, len(a) - len(b) + 1)
    while len(a) >= len(b):
        c = a[-1] * inv % p
        s = len(a) - len(b)
        q[s] = c
        a = ref_mod([a[i] - (c * b[i - s] if i >= s else 0) for i in range(len(a))], p)
    return ref_trim(q), a


def ref_product(polys, p):
    out = [1]
    for g in polys:
        out = ref_mul(out, g, p)
    return out


def monic_polys(deg, p):
    """All monic polynomials of the given degree over Z/pZ."""
    for k in range(p ** deg):
        coeffs = []
        for _ in range(deg):
            coeffs.append(k % p)
            k //= p
        yield coeffs + [1]


def brute_factor(f, p):
    """Monic irreducible factors of a monic f, with repetition, by trial division in degree order."""
    f = ref_mod(f, p)
    out = []
    d = 1
    while 2 * d <= len(f) - 1:
        for g in monic_polys(d, p):
            while True:
                q, r = ref_divmod(f, g, p)
                if r:
                    break
                out.append(g)
                f = q
            if 2 * d > len(f) - 1:
                break
        d += 1
    if len(f) > 1:
        out.append(f)
    return sorted(out, key=lambda g: (len(g), g))


def is_square_free_brute(f, p):
    fs = brute_factor(f, p)
    return len(set(map(tuple, fs))) == len(fs)


def random_monic(rng, deg, p):
    return [rng.randrange(p) for _ in range(deg)] + [1]


def nonresidues(p, count, start=2):
    """The first count quadratic non-residues modulo the odd prime p, by Euler's criterion."""
    out, c = [], start
    while len(out) < count:
        if pow(c, (p - 1) // 2, p) == p - 1:
            out.append(c)
        c += 1
    return out


def big_prime_irreducibles(p):
    """Distinct monic irreducibles over Z/pZ for p = 998244353 (p = 1 mod 8): x - a and x^(2^j) - c, c a non-residue."""
    c = nonresidues(p, 4)
    return [[p - 5, 1], [p - 12345, 1], [p - c[0], 0, 1], [p - c[1], 0, 1], [p - c[2], 0, 0, 0, 1], [p - c[3]] + [0] * 7 + [1]]


def ref_powmod_x(e, f, p):
    # x^e mod f by repeated multiplication by x (e is small here)
    out = [1]
    for _ in range(e):
        out = ref_divmod([0] + out, f, p)[1]
    return out


def test_worked_example():
    p = 3
    f = [2, 1, 0, 1, 1]  # (x^2 + 1)(x^2 + x + 2)
    st = {}
    assert bf(f, p, stats=st) == [[1, 0, 1], [2, 1, 1]]
    assert st["q"] == [[1, 0, 0, 0], [0, 0, 0, 1], [1, 1, 2, 1], [0, 1, 0, 0]]
    assert st["basis"] == [[1], [0, 1, 0, 1]]
    assert st["r"] == 2
    assert bf(f, p, method="random") == [[1, 0, 1], [2, 1, 1]]


@pytest.mark.parametrize("p", [2, 3, 7])
def test_matrix_rows(p):
    rng = random.Random(p)
    for _ in range(20):
        f = random_monic(rng, rng.randint(1, 6), p)
        q = berlekamp_matrix(f, p)
        n = len(f) - 1
        for i in range(n):
            row = ref_powmod_x(p * i, f, p)
            assert q[i] == row + [0] * (n - len(row))


def test_null_space_small():
    # rows: v a = 0 for v in the span of (1, 0, 0) and (0, 1, 1) over F_5
    a = [[0, 0, 0], [1, 2, 3], [4, 3, 2]]
    basis = left_null_space(a, 5)
    assert basis == [[1, 0, 0], [0, 1, 1]]
    for v in basis:
        assert all(sum(v[i] * a[i][j] for i in range(3)) % 5 == 0 for j in range(3))


@pytest.mark.parametrize("p,maxdeg,count", [(2, 10, 150), (3, 8, 150), (7, 6, 100)])
def test_random_square_free_against_trial_division(p, maxdeg, count):
    rng = random.Random(500 + p)
    done = 0
    while done < count:
        f = random_monic(rng, rng.randint(1, maxdeg), p)
        if not is_square_free_brute(f, p):
            continue
        done += 1
        want = brute_factor(f, p)
        st = {}
        assert bf(f, p, stats=st) == want
        assert st["r"] == len(want)
        if p > 2:
            assert bf(f, p, method="random", rng=random.Random(done)) == want


@pytest.mark.parametrize("p", [2, 3, 7])
def test_products_of_irreducibles(p):
    rng = random.Random(11 * p)
    irr = {d: [g for g in monic_polys(d, p) if brute_factor(g, p) == [g]] for d in (1, 2, 3, 4)}
    for _ in range(25):
        qs = []
        for d in (1, 2, 3, 4):
            k = rng.randint(0, min(2, len(irr[d])))
            qs.extend(rng.sample(irr[d], k))
        if not qs:
            continue
        want = sorted(qs, key=lambda g: (len(g), g))
        assert bf(ref_product(qs, p), p) == want


def test_large_prime():
    p = 998244353
    irr = big_prime_irreducibles(p)
    rng = random.Random(9)
    for t in range(6):
        qs = rng.sample(irr, rng.randint(2, len(irr)))
        want = sorted(qs, key=lambda g: (len(g), g))
        assert bf(ref_product(qs, p), p, rng=random.Random(t)) == want
    # integer input with negative coefficients: x^2 - 1 = (x - 1)(x + 1)
    assert bf([-1, 0, 1], p) == [[1, 1], [p - 1, 1]]
    # both methods on a prime above SMALL_P: x^2 - 1 over F_101
    assert bf([100, 0, 1], 101, method="deterministic") == [[1, 1], [100, 1]]
    assert bf([100, 0, 1], 101) == [[1, 1], [100, 1]]


def test_edge_cases():
    for p in (2, 3, 7, 998244353):
        with pytest.raises(ValueError):
            bf([], p)
        with pytest.raises(ValueError):
            bf([0, 0], p)
        if p != 2:
            with pytest.raises(ValueError):
                bf([1, 2], p)  # not monic
        with pytest.raises(ValueError):
            bf([0, 0, 1], p)  # x^2 is not square-free
        assert bf([1], p) == []
        assert bf([1, 0, 0], p) == []  # leading zeros are stripped
        assert bf([0, 1], p) == [[0, 1]]
        assert bf([3 % p, 1, 0], p) == [[3 % p, 1]]
    with pytest.raises(ValueError):
        bf([1, 0, 0, 1], 3)  # x^3 + 1 = (x + 1)^3 over F_3
    assert bf([1, 1, 1], 2) == [[1, 1, 1]]  # irreducible over F_2
    assert bf([2, 0, 1], 3) == [[1, 1], [2, 1]]  # x^2 - 1 over F_3

What the next level needs

  • the tests did not run in this build

Read the full page about Berlekamp's factorization algorithm