Algorithm Search⌘K

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
lehmer_schur_method.pyRawGitHub
# 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

Read the full page about Lehmer-Schur method