Lehmer-Schur method in Python
Bisection in the plane for polynomial zeros. Keep a disc known to hold a zero and test its half-radius disc and eight covering discs exactly; each pass cuts the radius to at most 33/64.
- File
lehmer_schur_method.py, 216 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/lehmer-schur-method/. The authors are listed in prov/GXZ3FQ.jsonl.
"""The Lehmer-Schur method: locate a zero of a complex polynomial by
shrinking discs, deciding with the exact Schur-Cohn test whether each disc
contains a zero.
Coefficients are given from the constant term up. They may be int,
Fraction, float or complex, and are converted exactly to rationals. All
centres and radii are dyadic rationals, so every inclusion test is exact.
"""
from dataclasses import dataclass, field
from fractions import Fraction
from math import gcd
from typing import Any, List, Optional, Sequence, Tuple
GQ = Tuple[Fraction, Fraction]
ZERO: GQ = (Fraction(0), Fraction(0))
RHO = Fraction(13, 16) # distance of the covering centres, in units of r
BETA = Fraction(7, 16) # radius of the covering discs, in units of r
S = Fraction(181, 256) # a dyadic value just below 1/sqrt(2)
DIRECTIONS: List[GQ] = [
(Fraction(1), Fraction(0)), (S, S), (Fraction(0), Fraction(1)), (-S, S),
(Fraction(-1), Fraction(0)), (-S, -S), (Fraction(0), Fraction(-1)), (S, -S),
]
PERTURB = [Fraction(1), Fraction(33, 32), Fraction(31, 32), Fraction(65, 64), Fraction(63, 64)]
@dataclass
class LehmerSchurResult:
"""The final disc (centre, radius), the zeros counted in it, and the work done."""
center: Optional[complex]
radius: Optional[Fraction]
count: int # zeros of p, with multiplicity, in the final open disc
reason: str # "converged", "no roots", "singular", "failure" or "kmax reached"
iterations: int
tests: int # number of Schur-Cohn tests, including retries
radii: List[Fraction] = field(default_factory=list)
center_exact: Optional[GQ] = None
@property
def converged(self) -> bool:
return self.reason == "converged"
def to_gq(v: Any) -> GQ:
if isinstance(v, tuple):
return (Fraction(v[0]), Fraction(v[1]))
if isinstance(v, complex):
return (Fraction(v.real), Fraction(v.imag))
return (Fraction(v), Fraction(0))
def _mul(a: GQ, b: GQ) -> GQ:
return (a[0] * b[0] - a[1] * b[1], a[0] * b[1] + a[1] * b[0])
def _add(a: GQ, b: GQ) -> GQ:
return (a[0] + b[0], a[1] + b[1])
def _sub(a: GQ, b: GQ) -> GQ:
return (a[0] - b[0], a[1] - b[1])
def _conj(a: GQ) -> GQ:
return (a[0], -a[1])
def _trim(p: List[GQ]) -> List[GQ]:
while p and p[-1] == ZERO:
p.pop()
return p
def _primitive(p: List[GQ]) -> List[GQ]:
den = 1
for c in p:
for x in c:
den = den * x.denominator // gcd(den, x.denominator)
g = 0
for c in p:
for x in c:
g = gcd(g, int(x * den))
s = Fraction(den, g)
return [(c[0] * s, c[1] * s) for c in p]
def shift_scale(p: Sequence[GQ], c: GQ, r: Fraction) -> List[GQ]:
"""Coefficients of p(c + r w), exactly."""
q = list(p)
n = len(q) - 1
for i in range(n):
for k in range(n - 1, i - 1, -1):
q[k] = _add(q[k], _mul(c, q[k + 1]))
s = Fraction(1)
for k in range(n + 1):
q[k] = (q[k][0] * s, q[k][1] * s)
s *= r
return q
def schur_cohn_count(p: Sequence[GQ]) -> Optional[int]:
"""Zeros of p in the open unit disc, or None when a Schur step is singular.
The coefficients are scaled once to Gaussian integers; each transform is
then done in integers and divided by the positive integer content.
"""
p = _trim(_primitive(list(p)))
a = [(int(c[0]), int(c[1])) for c in p]
degrees, deltas = [], []
while len(a) > 1:
n = len(a) - 1
(x0, y0), (xn, yn) = a[0], a[n]
q = []
for k in range(n + 1):
u, v = a[k]
w, t = a[n - k] # p* has coefficient conj(a[n - k]) = (w, -t)
# conj(a0) * a_k - a_n * conj(a_{n-k})
q.append((x0 * u + y0 * v - (xn * w + yn * t), x0 * v - y0 * u - (yn * w - xn * t)))
if q[0][0] == 0:
return None
degrees.append(n)
deltas.append(q[0][0])
while q and q[-1] == (0, 0):
q.pop()
g = 0
for u, v in q:
g = gcd(gcd(g, u), v)
a = [(u // g, v // g) for u, v in q]
k = 0
for j in range(len(deltas) - 1, -1, -1):
if deltas[j] < 0:
k = degrees[j] - k
return k
def _count(p: Sequence[GQ], c: GQ, r: Fraction, work: List[int]) -> Tuple[Optional[int], Fraction]:
"""Count zeros in D(c, r f) for the first factor f in PERTURB that gives a regular test."""
for f in PERTURB:
work[0] += 1
k = schur_cohn_count(shift_scale(p, c, r * f))
if k is not None:
return k, r * f
return None, r
def cauchy_radius(p: Sequence[GQ]) -> Fraction:
"""A power of 2 that is at least 1 + max |a_i / a_n|; every zero lies inside it."""
an = p[-1]
m2 = an[0] * an[0] + an[1] * an[1]
best = Fraction(0)
for c in p[:-1]:
q = _mul(c, _conj(an)) # a_i / a_n = q / |a_n|^2
best = max(best, (abs(q[0]) + abs(q[1])) / m2) # |re| + |im| >= modulus
b = Fraction(1)
while b < 1 + best:
b *= 2
return b
def lehmer_schur(coeffs: Sequence[Any], tol: Any = Fraction(1, 2 ** 40),
max_iter: int = 400) -> LehmerSchurResult:
"""Find a disc of radius at most tol that contains a zero of p."""
p = _trim([to_gq(a) for a in coeffs])
if not p:
raise ValueError("the zero polynomial")
tol = Fraction(tol)
if tol <= 0:
raise ValueError("tol must be positive")
if len(p) == 1: # @line L1
return LehmerSchurResult(None, None, 0, "no roots", 0, 0)
c: GQ = ZERO
r = cauchy_radius(p)
work = [0]
radii = [r]
count = len(p) - 1
for k in range(1, max_iter + 1): # @line L2
if r <= tol: # @line L3
return LehmerSchurResult(complex(c[0], c[1]), r, count, "converged", k - 1, work[0], radii, c)
m, rh = _count(p, c, r / 2, work) # @line L4
if m is None:
return LehmerSchurResult(complex(c[0], c[1]), r, count, "singular", k - 1, work[0], radii, c)
if m >= 1:
r, count = rh, m
radii.append(r)
continue
inner = rh # no zero in D(c, inner), so the zero is in the annulus inner <= |z - c| < r
found = False
for d in DIRECTIONS: # @line L5
cj = _add(c, (d[0] * RHO * r, d[1] * RHO * r)) # @line L6
m, rj = _count(p, cj, BETA * r, work)
if m is not None and m >= 1:
c, r, count = cj, rj, m
radii.append(r)
found = True
break
if not found: # @line L7
return LehmerSchurResult(complex(c[0], c[1]), r, count, "failure", k, work[0], radii, c)
return LehmerSchurResult(complex(c[0], c[1]), r, count, "kmax reached", max_iter, work[0], radii, c) # @line L8
def poly_from_roots(roots: Sequence[Any]) -> List[GQ]:
"""Exact coefficients, constant term first, of the monic polynomial with these roots."""
p: List[GQ] = [(Fraction(1), Fraction(0))]
for z0 in roots:
z = to_gq(z0)
q = [ZERO] * (len(p) + 1)
for i, a in enumerate(p):
q[i + 1] = _add(q[i + 1], a)
q[i] = _sub(q[i], _mul(z, a))
p = q
return p
test_lehmer_schur_method.py 122 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/lehmer-schur-method/. The authors are listed in prov/GXZ3FQ.jsonl.
import math
import random
from fractions import Fraction
import pytest
from lehmer_schur_method import (
BETA,
RHO,
cauchy_radius,
lehmer_schur,
poly_from_roots,
schur_cohn_count,
shift_scale,
to_gq,
)
SHRINK = Fraction(33, 64) # the largest possible radius ratio per pass
def _inside(z, c, r):
dx, dy = z[0] - c[0], z[1] - c[1]
return dx * dx + dy * dy < r * r
def _check_shrink(res):
for a, b in zip(res.radii, res.radii[1:]):
assert b <= SHRINK * a
def test_quintic_page_example():
res = lehmer_schur([-1, -1, 0, 0, 0, 1], tol=Fraction(1, 2 ** 40))
assert res.converged and res.count == 1
assert abs(res.center - 1.1673039782614187) < float(res.radius)
assert res.iterations == 39 and res.tests == 80
_check_shrink(res)
def test_iteration_bound():
for coeffs in ([-2, -1, 0, 1], [1, 0, 1], [-1, -1, 0, 0, 0, 1]):
tol = Fraction(1, 2 ** 30)
res = lehmer_schur(coeffs, tol=tol)
p = [to_gq(a) for a in coeffs]
bound = math.ceil(math.log(cauchy_radius(p) / tol) / math.log(64 / 33))
assert res.converged and res.iterations <= bound
# at most 9 regular tests per pass, each with at most 5 tries
assert res.tests <= 45 * res.iterations
def test_observed_linear_order():
res = lehmer_schur([-2, -1, 0, 1], tol=Fraction(1, 2 ** 40))
ratios = [float(b / a) for a, b in zip(res.radii, res.radii[1:])]
assert all(0.43 <= q <= 0.516 for q in ratios)
mean = math.exp(sum(math.log(q) for q in ratios) / len(ratios))
assert 0.43 < mean < 0.52 # order 1, rate about 0.47 per pass
def test_complex_root_and_double_root_and_zero_root():
res = lehmer_schur([1, 0, 1])
assert res.converged and abs(abs(res.center) - 1) < 1e-11 and abs(res.center.real) < 1e-11
res = lehmer_schur([1, -2, 1]) # (z - 1)^2
assert res.converged and res.count == 2 and abs(res.center - 1) < 1e-11
res = lehmer_schur([0, 0, 1]) # z^2: only inner halvings
assert res.converged and res.count == 2 and res.center == 0
assert res.tests == res.iterations
def test_edge_cases():
assert lehmer_schur([7]).reason == "no roots"
with pytest.raises(ValueError):
lehmer_schur([0, 0])
with pytest.raises(ValueError):
lehmer_schur([1, 1], tol=0)
res = lehmer_schur([-2, -1, 0, 1], max_iter=3)
assert res.reason == "kmax reached" and not res.converged
res = lehmer_schur([Fraction(-1, 2), 1, 0, 0]) # trailing zero coefficients ignored
assert res.converged and abs(res.center - 0.5) < 1e-11
def test_float_and_complex_coefficients():
res = lehmer_schur([0.25j, -1.5, 1.0])
z = res.center
assert res.converged and abs(z * z - 1.5 * z + 0.25j) < 1e-10
def test_schur_cohn_count_helper():
p = [to_gq(a) for a in [3, 2, -19, 6]] # zeros 1/2, -1/3, 3
assert schur_cohn_count(p) == 2
assert schur_cohn_count(shift_scale(p, to_gq(3), Fraction(1, 10))) == 1
assert schur_cohn_count([to_gq(-1), to_gq(1)]) is None # zero on the circle
def test_covering_constants():
# every point with 0.484375 <= |z| <= 1 is within 0.4238 of a centre (see the proof)
s_min = Fraction(1, 2) * Fraction(31, 32)
for s in (float(s_min), 1.0):
for rho in (float(RHO), float(RHO) * 181 / 256 * math.sqrt(2)):
d = math.sqrt(s * s + rho * rho - 2 * s * rho * math.cos(math.pi / 8))
assert d < float(BETA * Fraction(31, 32))
def test_random_known_roots():
rng = random.Random(8112026)
for _ in range(500):
n = rng.randint(1, 4)
roots = []
for _ in range(n):
if roots and rng.random() < 0.1:
roots.append(roots[-1])
else:
im = Fraction(rng.randint(-24, 24), 8) if rng.random() < 0.5 else Fraction(0)
roots.append((Fraction(rng.randint(-24, 24), 8), im))
lead = Fraction(rng.randint(1, 5))
p = [(a[0] * lead, a[1] * lead) for a in poly_from_roots(roots)]
res = lehmer_schur(p, tol=Fraction(1, 2 ** 10))
assert res.converged
_check_shrink(res)
c, r = res.center_exact, res.radius
assert res.count == sum(_inside(z, c, r) for z in roots) >= 1
What the next level needs
- the tests did not run in this build