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
# 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