Algorithm Search⌘K

Cantor-Zassenhaus algorithm in Python

Randomized factorization over finite fields by square-free, distinct-degree and random gcd splitting.

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

"""The Cantor-Zassenhaus algorithm: factor a polynomial over Z/pZ completely.

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 pipeline is: make f monic; split it into square-free parts with gcds
and p-th roots; split each part by degree (distinct-degree factorization);
split each degree part into irreducibles with random gcds (equal-degree
factorization). For p = 2 the random power is replaced by a trace. All loops
are iterative.
"""

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 pth_root(a: Sequence[int], p: int) -> Poly:
    """Return r with r^p = a, for a whose exponents are all multiples of p.

    Over Z/pZ every coefficient c satisfies c^p = c, so r has the
    coefficients of a at the exponents 0, p, 2p, ...
    """
    if any(a[i] for i in range(len(a)) if i % p):
        raise ValueError("a is not a p-th power")
    return trim([a[i] for i in range(0, len(a), p)])


def distinct_degree(f: Sequence[int], p: int) -> List[Tuple[Poly, int]]:
    """Distinct-degree factorization of a monic square-free f: the pairs (g_i, i)."""
    out: List[Tuple[Poly, int]] = []
    h = rem([0, 1], f, p)
    r, i = list(f), 0
    while len(r) - 1 >= 2 * (i + 1):
        i += 1
        h = powmod(h, p, r, p)
        g = gcd_p(sub(h, [0, 1], p), r, p)
        if len(g) > 1:
            out.append((g, i))
            r = divmod_p(r, g, p)[0]
            h = rem(h, r, p)
    if len(r) > 1:
        out.append((r, len(r) - 1))
    return out


def splitting_element(a: Sequence[int], h: Sequence[int], d: int, p: int) -> Poly:
    """a^((p^d - 1)/2) mod h for odd p; 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 equal_degree(f: Sequence[int], d: int, p: int, rng: random.Random, stats: dict) -> List[Poly]:
    """Equal-degree factorization of a monic square-free f whose factors all have degree d."""
    out: List[Poly] = []
    stack = [list(f)]
    limit = 200 * (len(f) + 10)
    tries = 0
    while stack:
        h = stack.pop()
        if len(h) - 1 == d:
            out.append(h)
            continue
        while True:
            tries += 1
            stats["attempts"] = stats.get("attempts", 0) + 1
            if tries > limit:
                raise RuntimeError("no split found")
            a = trim([rng.randrange(p) for _ in range(len(h) - 1)])
            if len(a) <= 1:
                continue
            g = gcd_p(a, h, p)
            if len(g) > 1:
                break
            g = gcd_p(sub(splitting_element(a, h, d, p), [1], p), h, p)
            if 1 < len(g) < len(h):
                break
        stack.append(g)
        stack.append(divmod_p(h, g, p)[0])
    return out


def cantor_zassenhaus(f: Sequence[int], p: int, rng: Optional[random.Random] = None,
                      stats: Optional[dict] = None) -> Tuple[int, List[Tuple[Poly, int]]]:
    """Factor f over Z/pZ as c * q_1^e_1 * ... * q_k^e_k.

    Returns (c, [(q_1, e_1), ...]) with c the leading coefficient and the q_i
    distinct monic irreducibles, sorted by degree and then by coefficients.
    rng is the source of random elements (default: a fixed seed). If stats
    is a dict, it receives the number of splitting attempts and the pieces.
    """
    f = reduce_p(f, p)
    if not f:
        raise ValueError("the zero polynomial has no factorization")
    rng = rng or random.Random(1)
    stats = {} if stats is None else stats
    stats.setdefault("attempts", 0)
    stats["square_free"] = []
    stats["degree_parts"] = []
    c = f[-1]  # @line L1
    result: List[Tuple[Poly, int]] = []  # @line L1
    work = [(monic(f, p), 1)]  # @line L1
    while work:  # @line L2
        u, k = work.pop()  # @line L3
        v = gcd_p(u, derivative(u, p), p)  # @line L3
        w = divmod_p(u, v, p)[0]  # @line L3
        i = 1  # @line L3
        while len(w) > 1:  # @line L4
            y = gcd_p(w, v, p)  # @line L5
            s = divmod_p(w, y, p)[0]  # @line L5
            if len(s) > 1:  # @line L6
                stats["square_free"].append((s, i * k))
                for g, d in distinct_degree(s, p):  # @line L7
                    stats["degree_parts"].append((g, d))
                    for q in equal_degree(g, d, p, rng, stats):  # @line L8
                        result.append((q, i * k))  # @line L8
            w = y  # @line L9
            v = divmod_p(v, y, p)[0]  # @line L9
            i += 1  # @line L9
        if len(v) > 1:  # @line L10
            work.append((pth_root(v, p), p * k))  # @line L10
    result.sort(key=lambda t: (len(t[0]), t[0]))  # @line L11
    return c, result  # @line L11
test_cantor_zassenhaus_algorithm.py 221 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/cantor-zassenhaus-algorithm/. The authors are listed in prov/1P1SGK.jsonl.

import random

import pytest

from cantor_zassenhaus_algorithm import cantor_zassenhaus as cz, pth_root


# 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 expand(c, factors, p):
    out = [c % p]
    for q, e in factors:
        for _ in range(e):
            out = ref_mul(out, q, p)
    return out


def grouped(qs):
    out = {}
    for q in qs:
        out[tuple(q)] = out.get(tuple(q), 0) + 1
    return sorted(((list(q), e) for q, e in out.items()), key=lambda t: (len(t[0]), t[0]))


def check(f, p, rng=None):
    c, fac = cz(f, p, rng=rng)
    g = ref_mod(f, p)
    assert expand(c, fac, p) == g
    assert c == g[-1]
    return c, fac


def test_worked_example():
    p = 3
    f = [0, 1, 2, 2, 2, 0, 1, 1, 1, 2]  # 2 x (x + 2) (x + 1)^3 (x^2 + 1)^2
    st = {}
    c, fac = cz(f, p, stats=st)
    assert c == 2
    assert fac == [([0, 1], 1), ([1, 1], 3), ([2, 1], 1), ([1, 0, 1], 2)]
    assert st["square_free"] == [([0, 2, 1], 1), ([1, 0, 1], 2), ([1, 1], 3)]
    assert st["degree_parts"] == [([0, 2, 1], 1), ([1, 0, 1], 2), ([1, 1], 1)]


@pytest.mark.parametrize("p,maxdeg,count", [(2, 9, 120), (3, 8, 120), (7, 6, 80)])
def test_random_against_trial_division(p, maxdeg, count):
    rng = random.Random(1000 + p)
    for t in range(count):
        n = rng.randint(1, maxdeg)
        f = [rng.randrange(p) for _ in range(n)] + [rng.randrange(1, p)]
        c, fac = check(f, p, random.Random(t))
        assert fac == grouped(brute_factor(ref_mod([x * pow(c, -1, p) for x in f], p), p))


@pytest.mark.parametrize("p", [2, 3, 7])
def test_products_with_multiplicities(p):
    rng = random.Random(77 + p)
    for t in range(40):
        qs = []
        for _ in range(rng.randint(1, 4)):
            d = rng.randint(1, 3)
            irr = [g for g in monic_polys(d, p) if brute_factor(g, p) == [g]]
            q = rng.choice(irr)
            qs.extend([q] * rng.choice([1, 1, 2, p, p + 1, 2 * p]))
        c = rng.randrange(1, p)
        f = ref_product(qs, p)
        f = ref_mod([c * x for x in f], p)
        got_c, fac = cz(f, p, rng=random.Random(t))
        assert got_c == c
        assert fac == grouped(qs)


def test_large_prime():
    p = 998244353
    irr = big_prime_irreducibles(p)
    rng = random.Random(5)
    for t in range(6):
        qs = []
        for q in rng.sample(irr, rng.randint(2, 4)):
            qs.extend([q] * rng.randint(1, 2))
        f = ref_mod([3 * x for x in ref_product(qs, p)], p)
        c, fac = cz(f, p, rng=random.Random(t))
        assert c == 3
        assert fac == grouped(qs)
    # integer input with negative and large coefficients: x^2 - 1 = (x - 1)(x + 1)
    assert cz([-1, 0, 1 + p], p) == (1, [([1, 1], 1), ([p - 1, 1], 1)])


def test_pth_powers():
    for p in (2, 3, 7):
        # x^p - x factors into all linear factors
        f = [0, p - 1] + [0] * (p - 2) + [1]
        assert cz(f, p)[1] == [([a, 1], 1) for a in range(p)]
        # (x^2 + x + 1)^(p^2) has zero derivative twice over
        g = [1, 1, 1]
        h = [1]
        for _ in range(p * p):
            h = ref_mul(h, g, p)
        expected = grouped(brute_factor(g, p) * (p * p))
        assert cz(h, p)[1] == expected
    assert pth_root([1, 0, 0, 2, 0, 0, 1], 3) == [1, 2, 1]
    with pytest.raises(ValueError):
        pth_root([1, 1], 3)


def test_edge_cases():
    for p in (2, 3, 7, 998244353):
        with pytest.raises(ValueError):
            cz([], p)
        with pytest.raises(ValueError):
            cz([0, 0, 0], p)
        with pytest.raises(ValueError):
            cz([p, 2 * p], p)  # reduces to the zero polynomial
        assert cz([1], p) == (1, [])
        assert cz([1, 0, 0], p) == (1, [])  # leading zeros are stripped
        assert cz([-1 % p, 1], p) == (1, [([p - 1, 1], 1)])
        assert cz([0, 1, 0], p) == (1, [([0, 1], 1)])
    assert cz([5], 7) == (5, [])
    assert cz([4, 2], 7) == (2, [([2, 1], 1)])  # non-monic linear: 2 (x + 2)
    assert cz([1, 0, 1], 3) == (1, [([1, 0, 1], 1)])  # irreducible
    assert cz([1, 0, 1], 2) == (1, [([1, 1], 2)])  # (x + 1)^2 over F_2
    assert cz([0, 0, 0, 0, 1], 5) == (1, [([0, 1], 4)])

What the next level needs

  • the tests did not run in this build

Read the full page about Cantor-Zassenhaus algorithm