Algorithm Search⌘K

Kronecker's factorization method in Python

Factors univariate integer polynomials by interpolating at integer points and searching divisors.

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

"""Kronecker's method: factor a primitive polynomial in Z[x] into irreducibles.

A polynomial is a list of integers [a_0, ..., a_d], lowest degree first, with a
nonzero last entry. The method is exponential in the degree and is meant for
small inputs and for teaching.
"""

from fractions import Fraction
from itertools import product
from math import gcd
from typing import List, Optional

Poly = List[int]


def evaluate(f: Poly, a: int) -> int:
    v = 0
    for c in reversed(f):
        v = v * a + c
    return v


def divisors(n: int) -> List[int]:
    """The positive divisors of n != 0, by trial division."""
    n = abs(n)
    small, large = [], []
    i = 1
    while i * i <= n:
        if n % i == 0:
            small.append(i)
            if i * i != n:
                large.append(n // i)
        i += 1
    return small + large[::-1]


def exact_quotient(f: Poly, g: Poly) -> Optional[Poly]:
    """f / g if g divides f in Z[x], else None."""
    r = list(f)
    q = [0] * (len(f) - len(g) + 1)
    for s in range(len(f) - len(g), -1, -1):
        c, m = divmod(r[s + len(g) - 1], g[-1])
        if m:
            return None
        q[s] = c
        for i, b in enumerate(g):
            r[s + i] -= c * b
    return q if not any(r) else None


def interpolate(points: List[int], values: List[int]) -> Optional[Poly]:
    """The polynomial of degree < len(points) through the points, if it is in Z[x]."""
    n = len(points)
    coeffs = [Fraction(0)] * n
    for i in range(n):
        basis = [Fraction(1)]  # prod_{j != i} (x - a_j) / (a_i - a_j)
        for j in range(n):
            if j != i:
                den = points[i] - points[j]
                nxt = [Fraction(0)] * (len(basis) + 1)
                for k, c in enumerate(basis):
                    nxt[k] -= c * points[j] / den
                    nxt[k + 1] += c / den
                basis = nxt
        for k in range(n):
            coeffs[k] += values[i] * basis[k]
    if any(c.denominator != 1 for c in coeffs):
        return None
    g = [int(c) for c in coeffs]
    while g and g[-1] == 0:
        g.pop()
    return g


def choose_points(f: Poly, count: int) -> List[int]:
    """count integers with f(a) != 0, preferring values with few divisors."""
    cands = []
    a = 0
    while len(cands) < 2 * len(f) + count:
        for b in ((a,) if a == 0 else (a, -a)):
            if evaluate(f, b) != 0:
                cands.append(b)
        a += 1
    cands.sort(key=lambda b: (len(divisors(evaluate(f, b))), abs(b)))
    return cands[:count]


def find_factor(f: Poly, e: int) -> Optional[Poly]:
    """A factor of f of degree exactly e, or None if there is none."""
    pts = choose_points(f, e + 1)  # @line L3
    divs = [divisors(evaluate(f, a)) for a in pts]  # @line L4
    choices = [divs[0]] + [d + [-x for x in d] for d in divs[1:]]  # g(a_0) > 0
    for vals in product(*choices):  # @line L5
        g = interpolate(pts, list(vals))  # @line L6
        if g is not None and len(g) == e + 1:  # @line L7
            q = exact_quotient(f, g)
            if q is not None:
                return [-c for c in g] if g[-1] < 0 else g
    return None


def kronecker_factor(f: Poly) -> List[Poly]:
    """The irreducible factors of a primitive f with positive leading coefficient.

    The product of the returned factors is f. Each factor has a positive
    leading coefficient. Constants give the empty list.
    """
    f = list(f)
    while f and f[-1] == 0:
        f.pop()
    if not f:
        raise ValueError("f must be nonzero")
    c = 0
    for a in f:
        c = gcd(c, a)
    if c != 1 or f[-1] < 0:
        raise ValueError("f must be primitive with a positive leading coefficient")
    factors: List[Poly] = []
    e = 1  # @line L1
    while 2 * e <= len(f) - 1:  # @line L2
        g = find_factor(f, e)
        if g is None:
            e += 1  # @line L9
        else:
            factors.append(g)  # @line L8
            f = exact_quotient(f, g)
    if len(f) > 1:
        factors.append(f)  # @line L10
    return factors  # @line L11
test_kronecker_factorization_method.py 100 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/kronecker-factorization-method/. The authors are listed in prov/9JH5VC.jsonl.

import random
from fractions import Fraction
from math import gcd

import pytest

from kronecker_factorization_method import divisors, evaluate, exact_quotient, interpolate, kronecker_factor


def mul(a, b):
    r = [0] * (len(a) + len(b) - 1)
    for i, x in enumerate(a):
        for j, y in enumerate(b):
            r[i + j] += x * y
    return r


def primitive(f):
    c = 0
    for a in f:
        c = gcd(c, a)
    if f[-1] < 0:
        c = -c
    return [a // c for a in f]


def has_rational_root(f):
    """Rational root test: a root p/q has p | a_0 and q | lc."""
    if f[0] == 0:
        return True
    for p in divisors(f[0]):
        for q in divisors(f[-1]):
            for s in (1, -1):
                r = Fraction(s * p, q)
                if sum(c * r ** i for i, c in enumerate(f)) == 0:
                    return True
    return False


def check(f):
    fs = kronecker_factor(f)
    prod = [1]
    for g in fs:
        assert g[-1] > 0 and len(g) >= 2
        prod = mul(prod, g)
    assert prod == f
    for g in fs:
        if len(g) - 1 in (2, 3):  # degree 2 or 3: irreducible iff no rational root
            assert not has_rational_root(g)
    return fs


def test_worked_example():
    assert check([4, 0, 0, 0, 1]) == [[2, -2, 1], [2, 2, 1]]  # x^4 + 4


def test_known_factorizations():
    assert sorted(check([-1, 0, 0, 0, 0, 0, 1])) == sorted([[-1, 1], [1, 1], [1, -1, 1], [1, 1, 1]])
    assert check([1, 0, 1]) == [[1, 0, 1]]
    assert check([-2, 0, 1]) == [[-2, 0, 1]]
    assert check([1, 1, 1, 1, 1]) == [[1, 1, 1, 1, 1]]  # cyclotomic, irreducible
    assert check([0, 1]) == [[0, 1]]
    assert check([0, 0, 0, 1]) == [[0, 1], [0, 1], [0, 1]]
    assert check([1, 2, 1]) == [[1, 1], [1, 1]]
    assert check([3, 2]) == [[3, 2]]
    assert kronecker_factor([1]) == []


def test_random_products():
    rng = random.Random(1882)
    for _ in range(500):
        f = [1]
        for _ in range(rng.randint(1, 3)):
            deg = rng.randint(1, 2)
            g = [rng.randint(-3, 3) for _ in range(deg + 1)]
            if g[-1] == 0:
                g[-1] = 1
            f = mul(f, g)
        f = primitive(f)
        check(f)


def test_interpolation_and_division():
    rng = random.Random(5)
    for _ in range(500):
        g = [rng.randint(-5, 5) for _ in range(rng.randint(1, 4))] + [rng.randint(1, 3)]
        pts = list(range(-2, len(g) - 2))
        assert interpolate(pts, [evaluate(g, a) for a in pts]) == g
        h = [rng.randint(-3, 3) for _ in range(3)] + [1]
        assert exact_quotient(mul(g, h), g) == h


def test_bad_input():
    with pytest.raises(ValueError):
        kronecker_factor([2, 4])
    with pytest.raises(ValueError):
        kronecker_factor([1, -1])

What the next level needs

  • the tests did not run in this build

Read the full page about Kronecker's factorization method