Algorithm Search⌘K

Dense polynomial (recursive dense representation) in Python

Stores every coefficient of a univariate polynomial in an array.

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

"""Dense polynomials over a commutative ring R, and the recursive dense
representation of multivariate polynomials.

A DensePoly is a list of coefficients c[0], c[1], ..., c[m-1] in R, lowest
degree first, with c[m-1] != 0 (the empty list is the zero polynomial). The
coefficients may themselves be DensePoly objects: R[x_1, ..., x_n] is held as
R[x_1, ..., x_{n-1}][x_n], a dense polynomial in the main variable x_n whose
coefficients are dense polynomials in x_1, ..., x_{n-1}. Arithmetic on the
coefficients goes through Python's operators, so the depth of nested calls
equals the number of variables and no more.

Cost model: ring operations in R (for nested polynomials, operations in the
coefficient ring at the next level). For m and n coefficients, add costs
O(m + n), mul O(m n), evaluate O(m) and pseudo_divmod O(m (m - n + 1))
operations in R.
"""

from typing import Any, Dict, List, Tuple


class DensePoly:
    """A univariate dense polynomial over a commutative ring R.

    zero is the zero element of R; it is needed to build polynomials and to
    normalize, because R may itself be a polynomial ring.
    """

    __slots__ = ("c", "zero")

    def __init__(self, coeffs: List[Any], zero: Any) -> None:
        self.zero = zero
        self.c = _normalize(list(coeffs), zero)

    # basic access -----------------------------------------------------------

    def degree(self) -> int:
        """Degree in the main variable; -1 for the zero polynomial."""
        return len(self.c) - 1

    def lc(self) -> Any:
        """Leading coefficient. Precondition: self is not zero."""
        return self.c[-1]

    def is_zero(self) -> bool:
        return not self.c

    def __eq__(self, other: object) -> bool:
        if isinstance(other, DensePoly):
            return self.c == other.c
        if other == 0 or other == self.zero:
            return not self.c
        return False

    def __hash__(self) -> int:
        return hash(tuple(self.c))

    def __repr__(self) -> str:
        return "DensePoly(%r)" % (self.c,)

    # arithmetic ---------------------------------------------------------------

    def __add__(self, other: "DensePoly") -> "DensePoly":
        """Sum; O(m + n) additions in R."""
        a, b = self.c, other.c
        m, n = len(a), len(b)
        c = [self.zero] * max(m, n)  # @line L1
        for i in range(max(m, n)):
            c[i] = (a[i] if i < m else self.zero) + (b[i] if i < n else self.zero)
        return DensePoly(c, self.zero)  # @line L2

    def __neg__(self) -> "DensePoly":
        return DensePoly([-x for x in self.c], self.zero)

    def __sub__(self, other: "DensePoly") -> "DensePoly":
        return self + (-other)

    def __mul__(self, other: "DensePoly") -> "DensePoly":
        """Schoolbook product; m n multiplications in R."""
        a, b = self.c, other.c
        m, n = len(a), len(b)
        if m == 0 or n == 0:  # @line L3
            return DensePoly([], self.zero)
        c = [self.zero] * (m + n - 1)  # @line L4
        for i in range(m):  # @line L5
            for j in range(n):
                c[i + j] = c[i + j] + a[i] * b[j]
        return DensePoly(c, self.zero)  # @line L6

    def scale(self, s: Any) -> "DensePoly":
        """s times self, for s in R."""
        return DensePoly([s * x for x in self.c], self.zero)

    def shift(self, k: int) -> "DensePoly":
        """self times x^k."""
        if not self.c:
            return self
        return DensePoly([self.zero] * k + self.c, self.zero)

    def evaluate(self, u: Any) -> Any:
        """Value at x = u in R (for nested polynomials, u is in the coefficient
        ring, so this substitutes for the main variable). Horner, O(m)."""
        s = self.zero
        for x in reversed(self.c):
            s = s * u + x
        return s

    def derivative(self) -> "DensePoly":
        """Formal derivative in the main variable; i * c[i] as a sum of i copies
        is written i * c[i], which Python's ints and Fractions support, and for
        nested polynomials goes through scale_int."""
        out = []
        for i in range(1, len(self.c)):
            x = self.c[i]
            out.append(x.scale_int(i) if isinstance(x, DensePoly) else i * x)
        return DensePoly(out, self.zero)

    def scale_int(self, k: int) -> "DensePoly":
        """k times self, for an integer k, at every level, without recursion
        beyond the number of variables."""
        return DensePoly([x.scale_int(k) if isinstance(x, DensePoly) else k * x for x in self.c], self.zero)

    def pseudo_divmod(self, b: "DensePoly") -> Tuple["DensePoly", "DensePoly"]:
        """(q, r) with lc(b)^(m-n+1) self = q b + r and deg r < deg b, where
        m - 1 = deg self and n - 1 = deg b; needs no division in R.

        Precondition: b is not zero. Cost: O(m (m - n + 1)) operations in R.
        """
        if b.is_zero():
            raise ZeroDivisionError("pseudo-division by the zero polynomial")
        zero = self.zero
        r = self  # @line L9
        q = DensePoly([], zero)
        ell = b.lc()
        e = max(self.degree() - b.degree() + 1, 0)
        while r.degree() >= b.degree():  # @line L10
            t = DensePoly([r.lc()], zero).shift(r.degree() - b.degree())  # @line L11
            q = q.scale(ell) + t  # @line L12
            r = r.scale(ell) - t * b
            e -= 1  # @line L13
        f = _power(ell, e, zero)
        return q.scale(f), r.scale(f)  # @line L14

    # conversion -----------------------------------------------------------------

    def to_dict(self) -> Dict[Tuple[int, ...], Any]:
        """The distributed form: exponent tuple (e_1, ..., e_n) -> nonzero base
        coefficient. Uses an explicit stack."""
        out: Dict[Tuple[int, ...], Any] = {}
        stack: List[Tuple[Any, Tuple[int, ...]]] = [(self, ())]
        while stack:
            p, exps = stack.pop()
            if isinstance(p, DensePoly):
                for i, x in enumerate(p.c):
                    stack.append((x, (i,) + exps))
            elif p != 0:
                out[exps] = p
        return out

    def check_invariant(self) -> None:
        """At every level the last coefficient is nonzero, and all coefficients
        at one level have the same nesting depth."""
        stack: List[Any] = [self]
        while stack:
            p = stack.pop()
            if not isinstance(p, DensePoly):
                continue
            if p.c:
                assert not _is_zero(p.c[-1], p.zero), "trailing zero"
            kinds = {_depth(x) for x in p.c}
            assert len(kinds) <= 1, "mixed nesting depths"
            if p.c:
                assert _depth(p.zero) == kinds.pop()
            stack.extend(p.c)


def _is_zero(x: Any, zero: Any) -> bool:
    if isinstance(x, DensePoly):
        return not x.c
    return x == zero


def _normalize(c: List[Any], zero: Any) -> List[Any]:
    while c and _is_zero(c[-1], zero):  # @line L7
        c.pop()
    return c  # @line L8


def _depth(x: Any) -> int:
    d = 0
    while isinstance(x, DensePoly):
        d += 1
        x = x.zero
    return d


def _power(x: Any, e: int, zero: Any) -> Any:
    """x^e by repeated squaring; x^0 is the one of the ring, built as x^0 = 1."""
    result: Any = None
    base = x
    while e > 0:
        if e & 1:
            result = base if result is None else result * base
        base = base * base
        e >>= 1
    if result is None:
        return _one_like(x)
    return result


def _one_like(x: Any) -> Any:
    """The one of the ring that x belongs to."""
    if isinstance(x, DensePoly):
        return DensePoly([_one_like(x.zero)], x.zero)
    return type(x)(1)


def zero_of(nvars: int, base_zero: Any) -> Any:
    """The zero of R[x_1, ..., x_nvars] in the recursive dense representation."""
    z = base_zero
    for _ in range(nvars):
        z = DensePoly([], z)
    return z


def from_dict(d: Dict[Tuple[int, ...], Any], nvars: int, base_zero: Any) -> Any:
    """Build the recursive dense form of sum c * x_1^e_1 ... x_n^e_n.

    Works level by level, innermost variable x_1 first, without recursion.
    For nvars = 0 the result is the constant itself.
    """
    if nvars == 0:
        return d.get((), base_zero)
    level: Dict[Tuple[int, ...], Any] = {k: v for k, v in d.items() if v != 0}
    zero: Any = base_zero
    for _ in range(nvars):
        groups: Dict[Tuple[int, ...], Dict[int, Any]] = {}
        for exps, v in level.items():
            groups.setdefault(exps[1:], {})[exps[0]] = v
        level = {}
        for rest, g in groups.items():
            cs = [zero] * (max(g) + 1)
            for i, v in g.items():
                cs[i] = v
            level[rest] = DensePoly(cs, zero)
        zero = DensePoly([], zero)
    return level.get((), zero)
test_dense_polynomial.py 171 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/dense-polynomial/. The authors are listed in prov/RVZNCP.jsonl.

import random
from fractions import Fraction
from typing import Any, Dict, Tuple

import pytest

from dense_polynomial import DensePoly, from_dict, zero_of

# Oracle: distributed polynomials as dicts {exponent tuple: nonzero coefficient}.
D = Dict[Tuple[int, ...], Any]


def d_clean(a: D) -> D:
    return {k: v for k, v in a.items() if v != 0}


def d_add(a: D, b: D, s: int = 1) -> D:
    r = dict(a)
    for k, v in b.items():
        r[k] = r.get(k, 0) + s * v
    return d_clean(r)


def d_mul(a: D, b: D) -> D:
    r: D = {}
    for k1, v1 in a.items():
        for k2, v2 in b.items():
            k = tuple(x + y for x, y in zip(k1, k2))
            r[k] = r.get(k, 0) + v1 * v2
    return d_clean(r)


def d_main_deg(a: D) -> int:
    return max((k[-1] for k in a), default=-1)


def d_lc(a: D) -> D:
    """Leading coefficient in the last (main) variable, as a dict with exponent 0 there."""
    m = d_main_deg(a)
    return {k[:-1] + (0,): v for k, v in a.items() if k[-1] == m}


def d_subs_main(a: D, u: D) -> D:
    """Substitute u (free of the main variable) for the main variable."""
    n = len(next(iter(a))) if a else 0
    r: D = {}
    for k, v in a.items():
        term: D = {k[:-1] + (0,): v}
        for _ in range(k[-1]):
            term = d_mul(term, u)
        r = d_add(r, term)
    return r


def rand_dict(rng: random.Random, n: int, deg: int, terms: int, frac: bool) -> D:
    d: D = {}
    for _ in range(terms):
        k = tuple(rng.randint(0, deg) for _ in range(n))
        v = Fraction(rng.randint(-5, 5), rng.randint(1, 3)) if frac else rng.randint(-5, 5)
        d[k] = d.get(k, 0) + v
    return d_clean(d)


def test_worked_examples() -> None:
    # (x^2 + 1) y^2 + 3x in Q[x][y]: outer list indexed by the power of y
    f = from_dict({(2, 2): Fraction(1), (0, 2): Fraction(1), (1, 0): Fraction(3)}, 2, Fraction(0))
    assert [p.c for p in f.c] == [[0, 3], [], [1, 0, 1]]
    f.check_invariant()
    # pseudo-division over Z: a = x^3 + 2x + 1, b = 2x + 1
    a = DensePoly([1, 2, 0, 1], 0)
    b = DensePoly([1, 2], 0)
    q, r = a.pseudo_divmod(b)
    assert q.c == [9, -2, 4] and r.c == [-1]
    assert (q * b + r).c == a.scale(8).c
    # schoolbook product and Horner
    assert (DensePoly([1, 1], 0) * DensePoly([-1, 1], 0)).c == [-1, 0, 1]
    assert DensePoly([1, 2, 3], 0).evaluate(2) == 17


def test_edge_cases() -> None:
    z = DensePoly([], 0)
    assert z.degree() == -1 and z.is_zero() and z == 0
    assert DensePoly([0, 0, 0], 0).c == []
    assert (z * DensePoly([1, 2], 0)).c == []
    assert (DensePoly([1, 2], 0) - DensePoly([1, 2], 0)).c == []
    assert DensePoly([5], 0).derivative().c == []
    assert z.evaluate(3) == 0
    c = DensePoly([7], 0)
    q, r = DensePoly([1, 2, 3], 0).pseudo_divmod(c)  # 7^3 a = q * 7
    assert q.c == [49, 98, 147] and r.c == []
    q, r = DensePoly([4], 0).pseudo_divmod(DensePoly([1, 1], 0))  # deg a < deg b
    assert q.c == [] and r.c == [4]
    with pytest.raises(ZeroDivisionError):
        c.pseudo_divmod(z)
    zz = zero_of(3, Fraction(0))
    assert zz.is_zero() and zz.to_dict() == {}
    zz.check_invariant()
    # zero divisors are not an issue for Z, but cancellation at inner levels is
    p = from_dict({(1, 1): 1, (0, 1): 1}, 2, 0)  # (x + 1) y
    m = from_dict({(1, 1): -1, (0, 1): -1, (0, 0): 2}, 2, 0)
    s = p + m
    assert s.degree() == 0 and s.to_dict() == {(0, 0): 2}
    s.check_invariant()


@pytest.mark.parametrize("n,frac", [(1, True), (1, False), (2, True), (2, False), (3, False)])
def test_random_against_distributed_oracle(n: int, frac: bool) -> None:
    rng = random.Random(1000 * n + frac)
    base = Fraction(0) if frac else 0
    for _ in range(120):
        da = rand_dict(rng, n, rng.randint(0, 4), rng.randint(0, 7), frac)
        db = rand_dict(rng, n, rng.randint(0, 3), rng.randint(0, 6), frac)
        a, b = from_dict(da, n, base), from_dict(db, n, base)
        a.check_invariant()
        assert a.to_dict() == da
        assert (a + b).to_dict() == d_add(da, db)
        assert (a - b).to_dict() == d_add(da, db, -1)
        ab = a * b
        ab.check_invariant()
        assert ab.to_dict() == d_mul(da, db)
        assert a.degree() == d_main_deg(da)
        # derivative in the main variable
        dd = {k[:-1] + (k[-1] - 1,): k[-1] * v for k, v in da.items() if k[-1] > 0}
        assert a.derivative().to_dict() == d_clean(dd)
        # evaluation of the main variable at a polynomial in the other variables
        du = rand_dict(rng, n, 2, 3, frac)
        du = {k[:-1] + (0,): v for k, v in du.items()}
        du = d_clean({k: sum(v for k2, v in du.items() if k2 == k) for k in du})
        u = from_dict({k[:-1]: v for k, v in du.items()}, n - 1, base)
        val = a.evaluate(u)
        got = val.to_dict() if isinstance(val, DensePoly) else ({(): val} if val != 0 else {})
        want = {k[:-1]: v for k, v in d_subs_main(da, du).items()}
        assert got == want
        # pseudo-division: lc(b)^(m - n + 1) a = q b + r, deg r < deg b
        if db:
            q, r = a.pseudo_divmod(b)
            q.check_invariant()
            r.check_invariant()
            e = max(d_main_deg(da) - d_main_deg(db) + 1, 0)
            lhs = da
            for _ in range(e):
                lhs = d_mul(lhs, d_lc(db))
            assert d_add(d_mul(q.to_dict(), db), r.to_dict()) == lhs
            assert r.degree() < b.degree()


def test_pseudo_division_matches_field_division() -> None:
    rng = random.Random(7)
    for _ in range(200):
        a = DensePoly([Fraction(rng.randint(-9, 9)) for _ in range(rng.randint(0, 9))], Fraction(0))
        b = DensePoly([Fraction(rng.randint(-9, 9)) for _ in range(rng.randint(1, 5))], Fraction(0))
        if b.is_zero():
            continue
        q, r = a.pseudo_divmod(b)
        e = max(a.degree() - b.degree() + 1, 0)
        f = b.lc() ** e
        q1, r1 = q.scale(1 / f), r.scale(1 / f)
        assert (q1 * b + r1).c == a.c and r1.degree() < b.degree()


def test_large_dense_product() -> None:
    rng = random.Random(3)
    a = DensePoly([rng.randint(-3, 3) for _ in range(300)] + [1], 0)
    b = DensePoly([rng.randint(-3, 3) for _ in range(200)] + [1], 0)
    p = a * b
    assert p.degree() == 500
    for x in (-2, -1, 0, 1, 2, 3):
        assert p.evaluate(x) == a.evaluate(x) * b.evaluate(x)

What the next level needs

  • the tests did not run in this build

Read the full page about Dense polynomial (recursive dense representation)