Algorithm Search⌘K

Equal-degree factorization in Python

Splits a product of irreducibles of one common degree into its factors, given that degree.

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

"""Equal-degree factorization over Z/pZ: split a product of irreducibles of one degree d.

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.

For odd p, a random a modulo f gives b = a^((p^d - 1)/2), which is 0, 1 or
-1 modulo each irreducible factor, and gcd(b - 1, f) is a proper factor with
probability at least 1/2 - 1/(2 p^d) or so. For p = 2 the trace
a + a^2 + ... + a^(2^(d-1)) takes the values 0 and 1 instead. Pieces are kept
on an explicit stack, so there is no recursion.
"""

from __future__ import annotations

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

Poly = List[int]


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 splitting_element(a: Sequence[int], h: Sequence[int], d: int, p: int) -> Poly:
    """b = a^((p^d - 1)/2) mod h for odd p, and the trace a + a^2 + ... + a^(2^(d-1)) mod h for p = 2."""
    if p == 2:
        b = rem(a, h, p)
        t = b
        for _ in range(d - 1):
            t = rem(mul(t, t, p), h, p)
            b = add(b, t, p)
        return b
    return powmod(a, (p ** d - 1) // 2, h, p)


def split_once(h: Sequence[int], d: int, p: int, rng: random.Random) -> Optional[Poly]:
    """One random splitting attempt; returns a proper monic factor of h, or None."""
    a = trim([rng.randrange(p) for _ in range(len(h) - 1)])  # @line L5
    if len(a) <= 1:  # @line L5
        return None
    g = gcd_p(a, h, p)  # @line L6
    if len(g) > 1:  # @line L6
        return g
    b = splitting_element(a, h, d, p)  # @line L7
    g = gcd_p(sub(b, [1], p), h, p)  # @line L8
    if 1 < len(g) < len(h):  # @line L9
        return g
    return None


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

    f must be monic, square-free, and a product of irreducibles of degree d.
    rng is the source of random elements (default: a fixed seed). If stats is
    a dict, stats["attempts"] counts the splitting attempts.
    """
    f = reduce_p(f, p)
    if not f or f[-1] != 1:
        raise ValueError("f must be monic")
    if d < 1 or (len(f) - 1) % d:
        raise ValueError("the degree of f must be a multiple of d")
    rng = rng or random.Random(1)
    out: List[Poly] = []
    stack = [f] if len(f) > 1 else []  # @line L1
    attempts = 0
    while stack:  # @line L2
        h = stack.pop()  # @line L3
        if len(h) - 1 == d:  # @line L4
            out.append(h)  # @line L4
            continue
        while True:
            attempts += 1
            g = split_once(h, d, p, rng)
            if g is not None:
                break
            if attempts > 200 * (len(f) + 10):
                raise ValueError("no split found: f is not a product of distinct irreducibles of degree d")
        stack.append(g)  # @line L9
        stack.append(divmod_p(h, g, p)[0])  # @line L9
    if stats is not None:
        stats["attempts"] = attempts
    return sorted(out, key=lambda g: (len(g), g))  # @line L10
test_equal_degree_factorization.py 182 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/equal-degree-factorization/. The authors are listed in prov/H77D0W.jsonl.

import random

import pytest

from equal_degree_factorization import equal_degree_factorization as edf, gcd_p, powmod, sub


# 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 irreducibles(d, p):
    """All monic irreducibles of degree d over Z/pZ, by trial division (small p and d only)."""
    return [g for g in monic_polys(d, p) if brute_factor(g, p) == [g]]


def test_worked_example():
    p = 7
    f = ref_product([[1, 0, 1], [4, 0, 1], [3, 1, 1]], p)
    assert f == [5, 4, 5, 5, 1, 1, 1]
    # a = x^2 is a square modulo every factor, so b = 1 and nothing splits
    assert powmod([0, 0, 1], 24, f, p) == [1]
    # a = x gives b = 1, 1, -1 modulo the three factors
    b = powmod([0, 1], 24, f, p)
    assert b == [4, 0, 2, 0, 6]
    assert gcd_p(sub(b, [1], p), f, p) == [4, 0, 5, 0, 1]
    h = [4, 0, 5, 0, 1]
    b = powmod([1, 1], 24, h, p)
    assert b == [4, 0, 3]
    assert gcd_p(sub(b, [1], p), h, p) == [1, 0, 1]
    assert edf(f, 2, p) == [[1, 0, 1], [3, 1, 1], [4, 0, 1]]


@pytest.mark.parametrize("p,d", [(2, 1), (2, 2), (2, 3), (2, 4), (3, 1), (3, 2), (3, 3), (7, 1), (7, 2)])
def test_random_products_of_irreducibles(p, d):
    irr = irreducibles(d, p)
    rng = random.Random(100 * p + d)
    for trial in range(15):
        k = rng.randint(1, min(len(irr), 6))
        pick = rng.sample(irr, k)
        f = ref_product(pick, p)
        stats = {}
        got = edf(f, d, p, random.Random(trial), stats)
        assert got == sorted(pick, key=lambda g: (len(g), g))
        assert stats["attempts"] <= 12 * k + 10


def test_large_prime():
    p = 998244353
    c = nonresidues(p, 8)
    cases = [
        ([[p - a, 1] for a in (1, 2, 3, 10 ** 6, 123456789)], 1),
        ([[p - x, 0, 1] for x in c[:4]], 2),
        ([[p - x, 0, 0, 0, 1] for x in c[4:7]], 4),
    ]
    for pick, d in cases:
        f = ref_product(pick, p)
        assert edf(f, d, p, random.Random(d)) == sorted(pick, key=lambda g: (len(g), g))


def test_success_rate_is_about_one_half():
    # with two factors, one attempt succeeds with probability about 1/2
    p, d = 3, 2
    irr = irreducibles(d, p)
    f = ref_product(irr[:2], p)
    total = 0
    for seed in range(200):
        stats = {}
        edf(f, d, p, random.Random(seed), stats)
        total += stats["attempts"]
    assert 1.4 < total / 200 < 2.8


def test_edge_cases():
    assert edf([1], 3, 5) == []
    assert edf([1, 1, 1], 2, 2) == [[1, 1, 1]]
    assert edf([3, 0, 1, 0, 0], 2, 7) == [[3, 0, 1]]
    # unreduced coefficients are reduced first
    assert edf([-1, 0, 1], 1, 5) == [[1, 1], [4, 1]]
    with pytest.raises(ValueError):
        edf([1, 0, 1, 1], 2, 2)  # degree 3 is not a multiple of 2
    with pytest.raises(ValueError):
        edf([1, 2], 1, 5)  # not monic
    with pytest.raises(ValueError):
        edf([1, 1], 0, 5)

What the next level needs

  • the tests did not run in this build

Read the full page about Equal-degree factorization