Algorithm Search⌘K

Petkovšek's Hyper algorithm in Python

Finds all hypergeometric solutions of linear recurrences with polynomial coefficients.

File
petkovsek_hyper_algorithm.py, 317 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
petkovsek_hyper_algorithm.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/petkovsek-hyper-algorithm/. The authors are listed in prov/M8QSZ7.jsonl.
"""Petkovsek's algorithm Hyper over Q, with exact rational arithmetic.

A polynomial in n is a list of Fractions, lowest degree first, with no
trailing zeros; [] is the zero polynomial. A recurrence operator
L = sum p_i(n) S^i is the list [p_0, ..., p_r], and it acts on a sequence by
(L t)(n) = sum p_i(n) t(n + i).

hyper(L) returns every ratio t(n+1)/t(n), as a pair (numerator, denominator)
of polynomials in lowest terms with monic denominator, of the hypergeometric
solutions t of L t = 0 whose ratio lies in Q(n). It needs p_0 and p_r to split
into linear factors over Q, which it checks; then the list is complete in the
sense of the theorem in the proof file: every such solution is a linear
combination of solutions whose ratios are returned.
"""
from fractions import Fraction
from itertools import product
from math import comb


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


def add(p, q):
    n = max(len(p), len(q))
    return trim([(p[i] if i < len(p) else 0) + (q[i] if i < len(q) else 0) for i in range(n)])


def scale(p, c):
    return trim([c * a for a in p])


def mul(p, q):
    if not p or not q:
        return []
    r = [Fraction(0)] * (len(p) + len(q) - 1)
    for i, a in enumerate(p):
        if a:
            for j, b in enumerate(q):
                r[i + j] += a * b
    return trim(r)


def shift(p, h):
    """p(n + h), by the binomial expansion of each (n + h)^j."""
    if h == 0 or not p:
        return list(p)
    r = [0] * len(p)
    for j, c in enumerate(p):
        if c:
            hp = 1
            for k in range(j, -1, -1):
                r[k] += c * comb(j, k) * hp
                hp *= h
    return trim(r)


def evaluate(p, x):
    v = Fraction(0)
    for c in reversed(p):
        v = v * x + c
    return v


def divmod_poly(p, q):
    p = trim(p)
    quo = [Fraction(0)] * max(len(p) - len(q) + 1, 0)
    while p and len(p) >= len(q):
        c = Fraction(p[-1]) / q[-1]
        k = len(p) - len(q)
        quo[k] = c
        p = add(p, scale([Fraction(0)] * k + q, -c))
    return trim(quo), p


def gcd_poly(p, q):
    p, q = trim(p), trim(q)
    while q:
        p, q = q, divmod_poly(p, q)[1]
    return scale(p, Fraction(1) / p[-1]) if p else []


def divisors(m):
    m = abs(m)
    out, d = [], 1
    while d * d <= m:
        if m % d == 0:
            out.append(d)
            out.append(m // d)
        d += 1
    return sorted(set(out))


def rational_roots(p):
    """The distinct rational roots of p, by the rational root theorem."""
    p = trim(p)
    roots = []
    if not p:
        raise ValueError("zero polynomial")
    while p and p[0] == 0:
        if 0 not in roots:
            roots.append(Fraction(0))
        p = p[1:]
    if len(p) <= 1:
        return roots
    den = 1
    for c in p:
        den = den * c.denominator // _gcd(den, c.denominator)
    z = [int(c * den) for c in p]
    g = 0
    for c in z:
        g = _gcd(g, c)
    z = [c // g for c in z]
    deg = len(z) - 1
    for u in divisors(z[0]):
        for v in divisors(z[-1]):
            if _gcd(u, v) != 1:
                continue
            for su in (u, -u):
                # v^deg p(su / v), in integers
                acc, up, vp = 0, 1, v ** deg
                for c in z:
                    acc += c * up * vp
                    up *= su
                    vp //= v
                if acc == 0:
                    roots.append(Fraction(su, v))
    return roots


def _gcd(a, b):
    while b:
        a, b = b, a % b
    return abs(a)


def linear_factors(p):
    """Roots of p with multiplicities, or ValueError if p does not split over Q."""
    p = trim(p)
    out = []
    for x in rational_roots(p):
        lin = [-x, Fraction(1)]
        while True:
            q, rem = divmod_poly(p, lin)
            if rem:
                break
            out.append(x)
            p = q
    if len(p) != 1:
        raise ValueError("coefficient does not split into linear factors over Q")
    return out


def monic_factors(p):
    """All monic divisors of p, which must split over Q."""
    roots = linear_factors(p)
    distinct = sorted(set(roots))
    mult = [roots.count(x) for x in distinct]
    out = []
    for es in product(*[range(e + 1) for e in mult]):
        f = [Fraction(1)]
        for x, e in zip(distinct, es):
            for _ in range(e):
                f = mul(f, [-x, Fraction(1)])
        out.append(f)
    return out


def nullspace(rows, ncols):
    """A basis of the solutions v of rows * v = 0, by Gauss-Jordan elimination."""
    rows = [list(r) for r in rows if any(r)]
    pivots, rank = [], 0
    for col in range(ncols):
        piv = next((i for i in range(rank, len(rows)) if rows[i][col] != 0), None)
        if piv is None:
            continue
        rows[rank], rows[piv] = rows[piv], rows[rank]
        inv = Fraction(1) / rows[rank][col]
        rows[rank] = [a * inv for a in rows[rank]]
        for i in range(len(rows)):
            if i != rank and rows[i][col] != 0:
                f = rows[i][col]
                rows[i] = [a - f * b for a, b in zip(rows[i], rows[rank])]
        pivots.append(col)
        rank += 1
    basis = []
    for free in range(ncols):
        if free in pivots:
            continue
        v = [Fraction(0)] * ncols
        v[free] = Fraction(1)
        for i, pc in enumerate(pivots):
            v[pc] = -rows[i][free]
        basis.append(v)
    return basis


def degree_bound(Q):
    """Largest possible degree of a nonzero polynomial solution of sum Q_i(n) c(n+i) = 0, or -1.

    Write sum Q_i E^i = sum R_k Delta^k with R_k = sum_(i >= k) C(i, k) Q_i. With
    b = max(deg R_k - k), the coefficient of n^(b + d) for deg c = d is
    lc(c) * phi(d) with phi(d) = sum over k with deg R_k - k = b of
    lc(R_k) d(d-1)...(d-k+1), so d is a root of phi.
    """
    r = len(Q) - 1
    R = [[] for _ in range(r + 1)]
    for k in range(r + 1):
        for i in range(k, r + 1):
            R[k] = add(R[k], scale(Q[i], comb(i, k)))
    b = max(len(R[k]) - 1 - k for k in range(r + 1) if R[k])
    phi = []
    for k in range(r + 1):
        if R[k] and len(R[k]) - 1 - k == b:
            ff = [Fraction(1)]
            for j in range(k):
                ff = mul(ff, [Fraction(-j), Fraction(1)])
            phi = add(phi, scale(ff, R[k][-1]))
    roots = [x for x in rational_roots(phi) if x.denominator == 1 and x >= 0]
    return int(max(roots)) if roots else -1


def polynomial_solutions(Q):
    """A basis of the polynomial solutions c of sum Q_i(n) c(n + i) = 0."""
    Q = [trim(q) for q in Q]
    D = degree_bound(Q)
    if D < 0:
        return []
    cols = []
    for j in range(D + 1):
        col = []
        for i, q in enumerate(Q):
            col = add(col, mul(q, shift([Fraction(0)] * j + [Fraction(1)], i)))
        cols.append(col)
    height = max((len(c) for c in cols), default=0)
    rows = [[c[e] if e < len(c) else Fraction(0) for c in cols] for e in range(height)]
    return [trim(v) for v in nullspace(rows, D + 1)]


def normalize_ratio(num, den):
    g = gcd_poly(num, den)
    num, den = divmod_poly(num, g)[0], divmod_poly(den, g)[0]
    c = den[-1]
    return (tuple(Fraction(x) for x in scale(num, Fraction(1) / c)),
            tuple(Fraction(x) for x in scale(den, Fraction(1) / c)))


def hyper(L, trace=None):
    """Ratios of the hypergeometric solutions of L t = 0 with ratio in Q(n)."""
    L = [trim([Fraction(c) for c in p]) for p in L]
    while L and not L[-1]:
        L.pop()
    r = len(L) - 1
    if r < 1 or not L[0]:
        raise ValueError("need an operator of order at least 1 with p_0 and p_r nonzero")
    found = []  # @line L1
    A = monic_factors(L[0])
    B = monic_factors(shift(L[r], -(r - 1)))
    for a in A:  # @line L2
        for b in B:
            P = []
            for i in range(r + 1):  # @line L3
                f = L[i]
                for j in range(i):
                    f = mul(f, shift(a, j))
                for j in range(i, r):
                    f = mul(f, shift(b, j))
                P.append(f)
            m = max(len(p) - 1 for p in P)  # @line L4
            alpha = [p[m] if len(p) - 1 == m else Fraction(0) for p in P]
            Zs = rational_roots(alpha)
            for Z in Zs:  # @line L5
                if Z == 0:
                    continue
                Q = [scale(P[i], Z ** i) for i in range(r + 1)]
                C = polynomial_solutions(Q)  # @line L6
                if trace is not None:
                    trace.append((tuple(a), tuple(b), Z, [tuple(c) for c in C]))
                for c in C:  # @line L7
                    ratio = normalize_ratio(mul(scale(a, Z), shift(c, 1)), mul(b, c))
                    if ratio not in found:
                        found.append(ratio)
            if trace is not None and not any(Z != 0 for Z in Zs):
                trace.append((tuple(a), tuple(b), None, []))
    return [(list(n), list(d)) for n, d in found]  # @line L8


def is_solution_ratio(L, num, den):
    """Exact check that t(n+1)/t(n) = num/den solves L t = 0 as a rational identity."""
    r = len(L) - 1
    total = []
    for i in range(r + 1):
        f = trim(L[i])
        for k in range(i):
            f = mul(f, shift(num, k))
        for k in range(i, r):
            f = mul(f, shift(den, k))
        total = add(total, f)
    return not total


def op_mul(M, F):
    """Product M F of shift operators, using S a(n) = a(n + 1) S."""
    out = []
    for i, m in enumerate(M):
        for j, f in enumerate(F):
            term = mul(m, shift(f, i))
            k = i + j
            while len(out) <= k:
                out.append([])
            out[k] = add(out[k], term)
    return out
test_petkovsek_hyper_algorithm.py 150 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/petkovsek-hyper-algorithm/. The authors are listed in prov/M8QSZ7.jsonl.
import random
from fractions import Fraction

import pytest

from petkovsek_hyper_algorithm import (evaluate, hyper, is_solution_ratio, op_mul, polynomial_solutions,
                             shift, trim, mul, rational_roots)


def P(*cs):
    return trim([Fraction(c) for c in cs])


def lin_product(roots):
    f = P(1)
    for k in roots:
        f = mul(f, P(-k, 1))
    return f


def integer_roots(polys):
    out = [0]
    for p in polys:
        if len(p) > 1:
            out += [int(x) for x in rational_roots(p) if x.denominator == 1]
    return out


def sequence(num, den, n0, length):
    t, out = Fraction(1), []
    for n in range(n0, n0 + length):
        out.append(t)
        t = t * evaluate(num, n) / evaluate(den, n)
    return out


def rank(rows):
    rows = [list(r) for r in rows]
    rk, ncols = 0, len(rows[0]) if rows else 0
    for c in range(ncols):
        piv = next((i for i in range(rk, len(rows)) if rows[i][c] != 0), None)
        if piv is None:
            continue
        rows[rk], rows[piv] = rows[piv], rows[rk]
        for i in range(rk + 1, len(rows)):
            f = rows[i][c] / rows[rk][c]
            rows[i] = [a - f * b for a, b in zip(rows[i], rows[rk])]
        rk += 1
    return rk


def in_span(L, ratios, target):
    """Is the hypergeometric term with ratio target a combination of the returned ones?"""
    polys = [p for pair in ratios + [target] for p in pair] + [shift(L[-1], -(len(L) - 2))]
    n0 = max(integer_roots(polys)) + 1
    W = len(ratios) + 4
    rows = [sequence(n, d, n0, W) for n, d in ratios]
    t = sequence(target[0], target[1], n0, W)
    return rank(rows + [t]) == rank(rows) if rows else False


def test_worked_example():
    L = [P(0, 2, 2), P(2, -3, -1), P(-1, 1)]  # (n-1) S^2 - (n^2+3n-2) S + 2n(n+1)
    trace = []
    out = hyper(L, trace)
    assert sorted(out) == sorted([(P(2), P(1)), (P(1, 1), P(1))])
    assert len(trace) == 8
    accepted = [(a, b, Z) for a, b, Z, C in trace if C]
    assert accepted == [((1,), (1,), 2), ((0, 1), (1,), 1), ((1, 1), (1,), 1)]
    # 2^n and n! solve it
    for n in range(0, 12):
        f = [Fraction(2) ** k for k in range(n, n + 3)]
        assert sum(evaluate(L[i], n) * f[i] for i in range(3)) == 0
        fac = [Fraction(1)]
        for k in range(1, n + 3):
            fac.append(fac[-1] * k)
        assert sum(evaluate(L[i], n) * fac[n + i] for i in range(3)) == 0


def test_constant_coefficients_and_edges():
    assert sorted(hyper([P(-1), P(0), P(1)])) == sorted([(P(1), P(1)), (P(-1), P(1))])
    out = hyper([P(1), P(-2), P(1)])  # (S - 1)^2: 1 and n
    assert sorted(out) == sorted([(P(1), P(1)), (P(1, 1), P(0, 1))])
    assert hyper([P(-1), P(-1), P(1)]) == []  # Fibonacci: roots are irrational
    assert hyper([P(-1, -1), P(1)]) == [(P(1, 1), P(1))]  # n!
    assert hyper([P(-1), P(1)]) == [(P(1), P(1))]
    with pytest.raises(ValueError):
        hyper([P(), P(1)])
    with pytest.raises(ValueError):
        hyper([P(1)])
    with pytest.raises(ValueError):
        hyper([P(1, 0, 1), P(1)])  # n^2 + 1 does not split over Q
    # polynomial solutions alone: c(n+1) - c(n) = 0 has only constants
    assert polynomial_solutions([P(-1), P(1)]) == [P(1)]
    assert polynomial_solutions([P(-1), P(2)]) == []


def random_split(rng, k, lo=-3, hi=3):
    return [rng.randint(lo, hi) for _ in range(k)]


def test_planted_solutions_found():
    rng = random.Random(1992)
    for _ in range(500):
        Z = Fraction(rng.choice([1, -1, 2, -2, 3]), rng.choice([1, 1, 2]))
        u = lin_product(random_split(rng, rng.randint(0, 2)))
        v = lin_product(random_split(rng, rng.randint(0, 2)))
        F = [scale_neg(mul(P(Z), u)), v]
        order = rng.choice([0, 1, 1, 2])
        if order == 0:
            M = [scale_c(lin_product(random_split(rng, rng.randint(0, 1))), rng.randint(1, 3))]
        else:
            M = [scale_c(lin_product(random_split(rng, rng.randint(0, 1))), rng.choice([1, -1, 2]))]
            for _ in range(order - 1):
                M.append(P(*[rng.randint(-3, 3) for _ in range(rng.randint(0, 2))]))
            M.append(scale_c(lin_product(random_split(rng, rng.randint(0, 1))), rng.choice([1, -2, 3])))
        L = op_mul(M, F)
        target = (mul(P(Z), u), v)
        assert is_solution_ratio(L, *target)
        out = hyper(L)
        for num, den in out:
            assert is_solution_ratio(L, num, den)
        assert in_span(L, out, target)


def scale_neg(p):
    return [-c for c in p]


def scale_c(p, c):
    return [c * a for a in p]


def test_against_brute_force_search():
    rng = random.Random(777)
    cands = [P(1)] + [P(-k, 1) for k in range(-2, 3)]
    for _ in range(25):
        F1 = [scale_neg(mul(P(rng.choice([1, 2, -1])), lin_product(random_split(rng, 1, -2, 2)))), P(1)]
        F2 = [scale_neg(P(rng.choice([1, 2, -2]))), lin_product(random_split(rng, rng.randint(0, 1), -2, 2))]
        L = op_mul(F2, F1)
        out = hyper(L)
        for num, den in out:
            assert is_solution_ratio(L, num, den)
        for Z in (1, -1, 2, -2):
            for a in cands:
                for b in cands:
                    if is_solution_ratio(L, mul(P(Z), a), b):
                        assert in_span(L, out, (mul(P(Z), a), b))

What the next level needs

  • the tests did not run in this build

Read the full page about Petkovšek's Hyper algorithm