Horowitz-Ostrogradsky method in Python
Computes the rational part of a rational integral by solving a linear system for undetermined coefficients.
- File
horowitz_ostrogradsky.py, 114 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/horowitz-ostrogradsky/. The authors are listed in prov/KX6NMH.jsonl.
"""Horowitz-Ostrogradsky method: the rational part of the integral of A/D.
Polynomials over Q are lists of Fraction, lowest degree first, with a nonzero
last entry; the zero polynomial is []. For deg A < deg D the function returns
B, Dm, C, Ds with
integral of A/D = B/Dm + integral of C/Ds,
where Dm = gcd(D, D'), Ds = D/Dm is square-free, deg B < deg Dm, deg C < deg Ds.
"""
from fractions import Fraction
from typing import List, Tuple
Poly = List[Fraction]
def trim(f: Poly) -> Poly:
f = [Fraction(a) for a in f]
while f and f[-1] == 0:
f.pop()
return f
def add(f: Poly, g: Poly) -> Poly:
n = max(len(f), len(g))
return trim([(f[i] if i < len(f) else 0) + (g[i] if i < len(g) else 0) for i in range(n)])
def scale(c: Fraction, f: Poly) -> Poly:
return trim([c * a for a in f])
def mul(f: Poly, g: Poly) -> Poly:
if not f or not g:
return []
r = [Fraction(0)] * (len(f) + len(g) - 1)
for i, a in enumerate(f):
for j, b in enumerate(g):
r[i + j] += a * b
return trim(r)
def deriv(f: Poly) -> Poly:
return trim([i * f[i] for i in range(1, len(f))])
def divmod_poly(f: Poly, g: Poly) -> Tuple[Poly, Poly]:
"""Quotient and remainder of f by a nonzero g."""
r = list(trim(f))
q = [Fraction(0)] * max(len(r) - len(g) + 1, 0)
while len(r) >= len(g) and r:
c = r[-1] / g[-1]
s = len(r) - len(g)
q[s] = c
for i, b in enumerate(g):
r[s + i] -= c * b
r = trim(r)
return trim(q), r
def gcd_poly(f: Poly, g: Poly) -> Poly:
"""Monic gcd by the Euclidean algorithm."""
f, g = trim(f), trim(g)
while g:
f, g = g, divmod_poly(f, g)[1]
return scale(1 / f[-1], f) if f else []
def solve(M: List[List[Fraction]], rhs: List[Fraction]) -> List[Fraction]:
"""Solve the square nonsingular system M y = rhs by Gaussian elimination."""
n = len(M)
A = [list(M[i]) + [rhs[i]] for i in range(n)]
for col in range(n):
piv = next(r for r in range(col, n) if A[r][col] != 0)
A[col], A[piv] = A[piv], A[col]
inv = 1 / A[col][col]
A[col] = [inv * a for a in A[col]]
for r in range(n):
if r != col and A[r][col] != 0:
c = A[r][col]
A[r] = [a - c * b for a, b in zip(A[r], A[col])]
return [A[i][n] for i in range(n)]
def horowitz_ostrogradsky(A: Poly, D: Poly) -> Tuple[Poly, Poly, Poly, Poly]:
"""Return (B, Dm, C, Ds) with integral A/D = B/Dm + integral C/Ds."""
A, D = trim(A), trim(D)
if not D:
raise ValueError("D must be nonzero")
if len(A) >= len(D):
raise ValueError("need deg A < deg D")
Dm = gcd_poly(D, deriv(D)) # @line L1
Ds = divmod_poly(D, Dm)[0] # @line L2
n, m = len(Dm) - 1, len(Ds) - 1 # @line L3
# L4: B = sum b_i x^i (i < n) and C = sum c_j x^j (j < m) are the unknowns.
E = divmod_poly(mul(Ds, deriv(Dm)), Dm)[0] # Ds * Dm' / Dm, a polynomial
columns: List[Poly] = [] # @line L4
for i in range(n): # the image of x^i as B: (x^i)' Ds - x^i E
xi = [Fraction(0)] * i + [Fraction(1)]
columns.append(add(mul(deriv(xi), Ds), scale(Fraction(-1), mul(xi, E))))
for j in range(m): # the image of x^j as C: x^j Dm
xj = [Fraction(0)] * j + [Fraction(1)]
columns.append(mul(xj, Dm))
size = n + m # = deg D
M = [[col[r] if r < len(col) else Fraction(0) for col in columns] for r in range(size)] # @line L5
rhs = [A[r] if r < len(A) else Fraction(0) for r in range(size)]
y = solve(M, rhs) # @line L6
B = trim(y[:n])
C = trim(y[n:])
return B, Dm, C, Ds # @line L7
test_horowitz_ostrogradsky.py 71 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/horowitz-ostrogradsky/. The authors are listed in prov/KX6NMH.jsonl.
import random
from fractions import Fraction
import pytest
from horowitz_ostrogradsky import add, deriv, gcd_poly, horowitz_ostrogradsky, mul, scale, trim
F = Fraction
def check(A, D):
"""Check the output by differentiation and by the degree and square-free conditions."""
B, Dm, C, Ds = horowitz_ostrogradsky(A, D)
A, D = trim(A), trim(D)
# (B/Dm)' + C/Ds = A/D, cross-multiplied by D * Dm^2 * Ds.
lhs = mul(A, mul(mul(Dm, Dm), Ds))
num = add(mul(add(mul(deriv(B), Dm), scale(F(-1), mul(B, deriv(Dm)))), Ds), mul(C, mul(Dm, Dm)))
assert lhs == mul(D, num)
assert mul(Dm, Ds) == D
assert len(B) < len(Dm) or not B
assert len(C) < len(Ds) or not C
assert len(gcd_poly(Ds, deriv(Ds))) == 1 # Ds is square-free
return B, Dm, C, Ds
def test_worked_example():
B, Dm, C, Ds = check([1], [1, 0, 2, 0, 1]) # 1/(x^2 + 1)^2
assert B == [0, F(1, 2)] and Dm == [1, 0, 1]
assert C == [F(1, 2)] and Ds == [1, 0, 1]
def test_second_example():
D = mul(mul([-1, 1], [-1, 1]), [1, 0, 1]) # (x - 1)^2 (x^2 + 1)
B, Dm, C, Ds = check([0, 0, 1], D)
assert B == [F(-1, 2)] and Dm == [-1, 1]
assert C == [F(1, 2), F(1, 2)] and Ds == [-1, 1, -1, 1]
def rand_poly(rng, deg, size=4):
p = [F(rng.randint(-size, size)) for _ in range(deg + 1)]
if p[-1] == 0:
p[-1] = F(rng.choice([-1, 1]))
return p
def test_random():
rng = random.Random(31415)
for _ in range(500):
D = [F(rng.randint(1, 3))]
for _ in range(rng.randint(1, 3)):
p = rand_poly(rng, rng.randint(1, 2))
for _ in range(rng.randint(1, 3)):
D = mul(D, p)
if len(D) > 9:
continue
A = trim(rand_poly(rng, rng.randint(0, len(D) - 2)))
check(A, D)
def test_edge_cases():
assert horowitz_ostrogradsky([], [1, 0, 1]) == ([], [1], [], [1, 0, 1])
B, Dm, C, Ds = check([3], [-2, 1]) # square-free: no rational part
assert B == [] and Dm == [1] and C == [3]
B, Dm, C, Ds = check([1], [0, 0, 0, 0, 0, 1]) # 1/x^5 = (-1/(4x^4))'
assert B == [F(-1, 4)] and C == []
check([5, -1], mul([2, 3], mul([2, 3], [2, 3]))) # a non-monic cube
with pytest.raises(ValueError):
horowitz_ostrogradsky([1, 1], [1, 1])
What the next level needs
- the tests did not run in this build