Algorithm Search⌘K

Zassenhaus algorithm in Python

Factors integer polynomials by factoring modulo a prime, Hensel lifting the factors, and trying products of subsets of them. Fast in practice, exponential in the worst case.

File
zassenhaus_algorithm.py, 303 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
zassenhaus_algorithm.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/zassenhaus-algorithm/. The authors are listed in prov/0Y712F.jsonl.
"""The Zassenhaus algorithm: factoring in Z[x] by factoring modulo p,
Hensel lifting, and recombination of the lifted factors over subsets.

Polynomials are lists of ints, lowest degree first; the zero polynomial is [].
"""

from __future__ import annotations

import random
from fractions import Fraction
from itertools import combinations
from math import gcd, isqrt
from typing import Sequence

Poly = list[int]


# ---------------------------------------------------------------- over Z


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


def _mul(a: Sequence[int], b: Sequence[int]) -> Poly:
    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 _strip(c)


def _content(a: Sequence[int]) -> int:
    c = 0
    for x in a:
        c = gcd(c, x)
    return c


def _primitive(a: Poly) -> Poly:
    c = _content(a)
    if a[-1] < 0:
        c = -c
    return [x // c for x in a]


def _is_squarefree_over_q(f: Poly) -> bool:
    a = [Fraction(x) for x in f]
    b = _strip([Fraction(i * x) for i, x in enumerate(f)][1:])
    while b:
        r = list(a)
        while len(r) >= len(b):
            t = r[-1] / b[-1]
            k = len(r) - len(b)
            for j, y in enumerate(b):
                r[k + j] -= t * y
            r.pop()
            _strip(r)
        a, b = b, r
    return len(a) == 1


# ---------------------------------------------------------------- over F_p


def _red(a: Sequence[int], p: int) -> Poly:
    return _strip([x % p for x in a])


def _divmod_p(a: Sequence[int], b: Sequence[int], p: int) -> tuple[Poly, Poly]:
    r = _red(a, p)
    n = len(b) - 1
    if len(r) <= n:
        return [], r
    inv = pow(b[-1], -1, p)
    q = [0] * (len(r) - n)
    for k in range(len(r) - 1, n - 1, -1):
        t = r[k] * inv % p
        if t:
            q[k - n] = t
            for j in range(n + 1):
                r[k - n + j] = (r[k - n + j] - t * b[j]) % p
    return _strip(q), _strip(r[:n])


def _mulmod_p(a: Poly, b: Poly, f: Poly, p: int) -> Poly:
    return _divmod_p(_mul(a, b), f, p)[1]


def _powmod_p(a: Poly, e: int, f: Poly, p: int) -> Poly:
    result, base = _divmod_p([1], f, p)[1], _divmod_p(a, f, p)[1]
    while e:
        if e & 1:
            result = _mulmod_p(result, base, f, p)
        base = _mulmod_p(base, base, f, p)
        e >>= 1
    return result


def _monic_p(a: Poly, p: int) -> Poly:
    inv = pow(a[-1], -1, p)
    return [x * inv % p for x in a]


def _gcd_p(a: Poly, b: Poly, p: int) -> Poly:
    a, b = _red(a, p), _red(b, p)
    while b:
        a, b = b, _divmod_p(a, b, p)[1]
    return _monic_p(a, p) if a else []


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


def _equal_degree(f: Poly, d: int, p: int, rng: random.Random) -> list[Poly]:
    """Cantor-Zassenhaus splitting of a monic square-free f whose irreducible
    factors all have degree d. Iterative, with a work list."""
    out, work = [], [f]
    while work:
        g = work.pop()
        if len(g) - 1 == d:
            out.append(g)
            continue
        while True:
            a = [rng.randrange(p) for _ in range(len(g) - 1)]
            if len(_strip(list(a))) < 2:
                continue
            if p == 2:
                t, s = list(a), list(a)
                for _ in range(d - 1):
                    t = _mulmod_p(t, t, g, p)
                    s = _sub_p(s, [-x for x in t], p)
                h = _gcd_p(s, g, p)
            else:
                b = _powmod_p(a, (p ** d - 1) // 2, g, p)
                h = _gcd_p(_sub_p(b, [1], p), g, p)
            if 1 < len(h) < len(g):
                work.append(h)
                work.append(_divmod_p(g, h, p)[0])
                break
    return out


def factor_mod_p(f: Sequence[int], p: int, seed: int = 0) -> list[Poly]:
    """Monic irreducible factors of f in F_p[x].

    Precondition: p is prime, f mod p is square-free of degree at least 1.
    Cost: distinct-degree factorization, then Cantor-Zassenhaus splitting;
    expected O(n^3 log p) operations in F_p with the schoolbook products here.
    """
    g = _monic_p(_red(f, p), p)
    rng = random.Random(seed)
    out: list[Poly] = []
    h = [0, 1]
    d = 0
    while len(g) - 1 >= 2 * (d + 1):
        d += 1
        h = _powmod_p(h, p, g, p)
        c = _gcd_p(_sub_p(h, [0, 1], p), g, p)
        if len(c) > 1:
            out.extend(_equal_degree(c, d, p, rng))
            g = _divmod_p(g, c, p)[0]
            h = _divmod_p(h, g, p)[1]
    if len(g) > 1:
        out.append(g)
    return out


# ---------------------------------------------------------------- Hensel lifting


def _symmetric(a: Sequence[int], m: int) -> Poly:
    half = m // 2
    return _strip([(x % m) - m if (x % m) > half else (x % m) for x in a])


def _hensel_two(f: Poly, g: Poly, h: Poly, p: int, k: int) -> tuple[Poly, Poly]:
    """Lift f = g h (mod p), g monic, gcd(g, h) = 1 mod p, to f = g h (mod p^k)
    with g monic. Linear lifting: one power of p per step."""
    # t with t h = 1 (mod g, p): extended Euclid in F_p[x]
    r0, r1, t0, t1 = _red(g, p), _red(h, p), [], [1]
    while len(r1) > 1:
        q, r = _divmod_p(r0, r1, p)
        r0, r1 = r1, r
        t0, t1 = t1, _sub_p(t0, _mul(q, t1), p)
    inv = pow(r1[0], -1, p)
    t = [x * inv % p for x in t1]
    pj = p
    for _ in range(1, k):
        e = [x // pj for x in _sub_z(f, _mul(g, h))]
        a = _divmod_p(_mul(e, t), g, p)[1]
        b = _divmod_p(_sub_p(_red(e, p), _mul(a, h), p), g, p)[0]
        g = _add_z(g, [pj * x for x in a])
        h = _add_z(h, [pj * x for x in b])
        pj *= p
    return g, h


def _add_z(a: Poly, b: Poly) -> Poly:
    n = max(len(a), len(b))
    return _strip([(a[i] if i < len(a) else 0) + (b[i] if i < len(b) else 0) for i in range(n)])


def _sub_z(a: Poly, b: Poly) -> Poly:
    return _add_z(a, [-x for x in b])


def hensel_lift(f: Poly, us: list[Poly], p: int, k: int) -> list[Poly]:
    """Monic v_i = u_i (mod p) with f = lc(f) v_1 ... v_r (mod p^k).

    Precondition: f = lc(f) u_1 ... u_r (mod p), the u_i monic and pairwise
    coprime modulo p, p not dividing lc(f).
    """
    m = p ** k
    lc = f[-1]
    out = []
    rest = list(f)
    for i in range(len(us) - 1):
        h = [lc % p]
        for u in us[i + 1:]:
            h = _red(_mul(h, u), p)
        g, rest = _hensel_two(rest, list(us[i]), h, p, k)
        out.append(_symmetric(g, m))
        rest = _symmetric(rest, m)
    inv = pow(lc, -1, m)
    out.append(_symmetric([x * inv for x in rest], m))
    return out


# ---------------------------------------------------------------- Zassenhaus


def _choose_prime(f: Poly) -> int:
    p = 2
    df = _strip([i * x for i, x in enumerate(f)][1:])
    while True:
        if all(p % d for d in range(2, isqrt(p) + 1)) and f[-1] % p:
            if len(_gcd_p(f, df, p)) == 1:
                return p
        p += 1


def _norm1(a: Poly) -> int:
    return sum(abs(x) for x in a)


def _lc_times(lc: int, vs: list[Poly], m: int) -> Poly:
    g = [lc]
    for v in vs:
        g = _symmetric(_mul(g, v), m)
    return g


def zassenhaus(f: Sequence[int]) -> list[Poly]:
    """The irreducible factors of f in Z[x], primitive with positive leading
    coefficients, whose product is f.

    Precondition: f is primitive, square-free, of degree n >= 1, with
    lc(f) > 0; a ValueError is raised otherwise.
    Cost: one factorization modulo a small prime p, Hensel lifting to
    p^k > 2 B with B = 2^n lc(f) ceil(||f||_2), and at most (r + 1) 2^r
    candidate pairs in the recombination for r factors modulo p, each
    costing two products modulo p^k. So it is exponential in r in the
    worst case and polynomial in n and the coefficient length otherwise.
    """
    f = _strip(list(f))
    if len(f) < 2 or f[-1] < 0 or _content(f) != 1 or not _is_squarefree_over_q(f):
        raise ValueError("f must be primitive, square-free, of degree >= 1, with lc(f) > 0")
    n = len(f) - 1
    p = _choose_prime(f)  # @line L1
    us = factor_mod_p(f, p)  # @line L2
    bound = (1 << n) * f[-1] * (isqrt(sum(c * c for c in f)) + 1)  # @line L3
    k = 1
    while p ** k <= 2 * bound:
        k += 1
    m = p ** k
    vs = hensel_lift(f, us, p, k)  # @line L4
    big_f, found, s = f, [], 1  # @line L5
    while 2 * s <= len(vs):  # @line L6
        hit = None
        for idx in combinations(range(len(vs)), s):  # @line L7
            g = _lc_times(big_f[-1], [vs[i] for i in idx], m)
            h = _lc_times(big_f[-1], [v for i, v in enumerate(vs) if i not in idx], m)
            if _norm1(g) * _norm1(h) <= bound:
                hit = (idx, g, h)
                break
        if hit is not None:  # @line L8
            idx, g, h = hit
            found.append(_primitive(g))
            big_f = _primitive(h)
            vs = [v for i, v in enumerate(vs) if i not in idx]
        else:
            s += 1  # @line L9
    return found + [big_f]  # @line L10
test_zassenhaus_algorithm.py 311 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/zassenhaus-algorithm/. The authors are listed in prov/0Y712F.jsonl.

import itertools
import random
from fractions import Fraction
from math import gcd

import pytest

import zassenhaus_algorithm as Z


# ---------------------------------------------------------------- reference helpers


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


def mul(a, b):
    """Schoolbook product, the reference."""
    if not a or not b:
        return []
    c = [a[0] * 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 strip(c)


def product(fs):
    out = [1]
    for g in fs:
        out = mul(out, g)
    return out


def ev(f, x):
    s = 0
    for c in reversed(f):
        s = s * x + c
    return s


def key(fs):
    return sorted(tuple(g) for g in fs)


def divisors(n):
    n = abs(n)
    return [d for d in range(1, n + 1) if n % d == 0]


def rational_root_factors(f):
    """Oracle for degree <= 3: factor a primitive f with lc > 0 by the rational
    root test. A part of degree 2 or 3 without rational roots is irreducible."""
    f = list(f)
    out = []
    changed = True
    while changed and len(f) > 2:
        changed = False
        if f[0] == 0:
            out.append([0, 1])
            f = f[1:]
            changed = True
            continue
        for b in divisors(f[-1]):
            for a in divisors(f[0]):
                for sa in (a, -a):
                    if gcd(sa, b) == 1 and ev([Fraction(c) for c in f], Fraction(sa, b)) == 0:
                        q = exact_div(f, [-sa, b])
                        out.append([-sa, b])
                        f = q
                        changed = True
                        break
                if changed:
                    break
            if changed:
                break
    out.append(f)
    return out


def exact_div(f, g):
    r = [Fraction(c) for c in f]
    q = [Fraction(0)] * (len(f) - len(g) + 1)
    for k in range(len(r) - len(g), -1, -1):
        t = r[k + len(g) - 1] / g[-1]
        q[k] = t
        for j, y in enumerate(g):
            r[k + j] -= t * y
    assert not any(r)
    assert all(x.denominator == 1 for x in q)
    return [int(x) for x in q]


def is_primitive(f):
    c = 0
    for x in f:
        c = gcd(c, x)
    return c == 1


def squarefree_q(f):
    a = [Fraction(x) for x in f]
    b = strip([Fraction(i * x) for i, x in enumerate(f)][1:])
    while b:
        r = list(a)
        while len(r) >= len(b):
            t = r[-1] / b[-1]
            k = len(r) - len(b)
            for j, y in enumerate(b):
                r[k + j] -= t * y
            r = strip(r)
        a, b = b, r
    return len(a) == 1


def brute_factor_mod(f, p):
    """Oracle over F_p: trial division by all monic polynomials, by degree."""
    f = strip([x % p for x in f])
    inv = pow(f[-1], -1, p)
    f = [x * inv % p for x in f]
    out, d = [], 1
    while len(f) > 1:
        if 2 * d > len(f) - 1:
            out.append(f)
            break
        hit = False
        for tail in itertools.product(range(p), repeat=d):
            g = list(tail) + [1]
            q, r = Z._divmod_p(f, g, p)
            if not r:
                out.append(g)
                f = q
                hit = True
                break
        if not hit:
            d += 1
    return out


def irreducible_mod_some_prime(f):
    for p in (2, 3, 5, 7):
        if f[-1] % p and len(brute_factor_mod(f, p)) == 1:
            return True
    return False


# ---------------------------------------------------------------- the worked example


def test_worked_example():
    f = [-2, 0, -1, 0, 1]  # x^4 - x^2 - 2 = (x^2 - 2)(x^2 + 1)
    assert Z._choose_prime(f) == 5
    assert key(Z.factor_mod_p(f, 5)) == key([[2, 1], [3, 1], [3, 0, 1]])
    lifted = Z.hensel_lift(f, Z.factor_mod_p(f, 5), 5, 3)
    assert key(lifted) == key([[57, 1], [-57, 1], [-2, 0, 1]])
    assert (57 * 57) % 125 == 124
    assert Z.zassenhaus(f) == [[-2, 0, 1], [1, 0, 1]]


def test_page_examples():
    assert Z.zassenhaus([1, 0, 0, 0, 1]) == [[1, 0, 0, 0, 1]]
    assert Z.zassenhaus([1, 0, -10, 0, 1]) == [[1, 0, -10, 0, 1]]
    assert key(Z.zassenhaus([6, 5, 1])) == key([[2, 1], [3, 1]])


# ---------------------------------------------------------------- edge cases


def test_degree_one_and_nonmonic():
    assert Z.zassenhaus([3, 2]) == [[3, 2]]
    assert Z.zassenhaus([0, 1]) == [[0, 1]]
    assert key(Z.zassenhaus([-3, 1, 2])) == key([[-1, 1], [3, 2]])  # (x - 1)(2x + 3)
    assert key(Z.zassenhaus([0, -1, 0, 4])) == key([[0, 1], [-1, 2], [1, 2]])


@pytest.mark.parametrize("bad", [[], [5], [2, 4], [1, -2, 1], [-1, 0, -1], [0, 0, 1], [0, 0, 0]])
def test_rejects_bad_input(bad):
    with pytest.raises(ValueError):
        Z.zassenhaus(bad)


def test_leading_zeros_are_ignored():
    assert key(Z.zassenhaus([6, 5, 1, 0, 0])) == key([[2, 1], [3, 1]])


# ---------------------------------------------------------------- exhaustive against the rational root oracle


def test_exhaustive_degree_at_most_three():
    count = 0
    for deg in (1, 2, 3):
        for lc in (1, 2, 3):
            for low in itertools.product(range(-3, 4), repeat=deg):
                f = list(low) + [lc]
                if not is_primitive(f) or not squarefree_q(f):
                    continue
                got = Z.zassenhaus(f)
                assert product(got) == f
                assert key(got) == key(rational_root_factors(f)), f
                count += 1
    assert count >= 500


# ---------------------------------------------------------------- constructed products of certified irreducibles


def random_irreducible(rng, deg):
    while True:
        g = [rng.randint(-5, 5) for _ in range(deg)] + [rng.randint(1, 3)]
        if is_primitive(g) and irreducible_mod_some_prime(g):
            return g


def test_random_products():
    rng = random.Random(20261107)
    for _ in range(60):
        fs = []
        while len(fs) < rng.randint(2, 4):
            g = random_irreducible(rng, rng.randint(1, 3))
            if g not in fs:
                fs.append(g)
        f = product(fs)
        assert key(Z.zassenhaus(f)) == key(fs)


def test_recombination_cases():
    # irreducible but split modulo every prime
    sd = [1, 0, -10, 0, 1]
    assert Z.zassenhaus(sd) == [sd]
    for g in ([1, 0, 1], [-2, 0, 1], [-3, 1, 1]):
        f = mul(sd, g)
        assert key(Z.zassenhaus(f)) == key([sd, g])
    f = mul([1, 0, 0, 0, 1], [1, 0, -10, 0, 1])
    assert key(Z.zassenhaus(f)) == key([[1, 0, 0, 0, 1], [1, 0, -10, 0, 1]])


def cyclotomic(n, cache={}):
    if n not in cache:
        f = [-1] + [0] * (n - 1) + [1]
        for d in range(1, n):
            if n % d == 0:
                f = exact_div(f, cyclotomic(d))
        cache[n] = f
    return cache[n]


@pytest.mark.parametrize("n", [1, 2, 6, 8, 12, 15, 16, 20, 24])
def test_cyclotomic(n):
    f = [-1] + [0] * (n - 1) + [1]
    want = [cyclotomic(d) for d in range(1, n + 1) if n % d == 0]
    assert key(Z.zassenhaus(f)) == key(want)


# ---------------------------------------------------------------- factoring modulo p


@pytest.mark.parametrize("p", [2, 3, 7])
def test_factor_mod_p_against_trial_division(p):
    rng = random.Random(p)
    done = 0
    while done < 60:
        n = rng.randint(1, 6)
        f = [rng.randrange(p) for _ in range(n)] + [rng.randrange(1, p)]
        g = Z._gcd_p(f, strip([i * x for i, x in enumerate(f)][1:]), p)
        if len(g) != 1:
            continue
        got = Z.factor_mod_p(f, p, seed=done)
        assert key(got) == key(brute_factor_mod(f, p))
        done += 1


def test_factor_mod_large_prime():
    p = 998244353
    rng = random.Random(5)
    for _ in range(8):
        roots = rng.sample(range(p), 5)
        f = product([[-r % p, 1] for r in roots])
        f = [x % p for x in f]
        got = Z.factor_mod_p(f, p)
        assert key(got) == key([[-r % p, 1] for r in roots])
    # x^2 - c with c a non-residue (Euler's criterion) stays irreducible
    c = 3
    assert pow(c, (p - 1) // 2, p) == p - 1
    f = mul([-c % p, 0, 1], [-1 % p, 1])
    got = Z.factor_mod_p([x % p for x in f], p)
    assert key(got) == key([[p - c, 0, 1], [p - 1, 1]])


def test_hensel_lift_invariants():
    rng = random.Random(11)
    for _ in range(20):
        fs = [random_irreducible(rng, rng.randint(1, 3)) for _ in range(3)]
        f = product(fs)
        if not squarefree_q(f) or not is_primitive(f):
            continue
        p = Z._choose_prime(f)
        us = Z.factor_mod_p(f, p)
        for k in (1, 2, 5):
            vs = Z.hensel_lift(f, us, p, k)
            m = p ** k
            assert all(v[-1] == 1 for v in vs)
            assert key([[x % p for x in v] for v in vs]) == key(us)
            diff = [a - b for a, b in zip(f, [f[-1] * x for x in product(vs)])]
            assert all(d % m == 0 for d in diff)

What the next level needs

  • the tests did not run in this build

Read the full page about Zassenhaus algorithm