Algorithm Search⌘K

Lagrange-Gauss reduction in Python

Exact algorithm that reduces a two-dimensional lattice basis and finds the shortest vector.

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

from fractions import Fraction
from math import floor
from typing import Sequence, Union

Number = Union[int, Fraction]
Vector = list[Number]


def _dot(u: Sequence[Number], v: Sequence[Number]) -> Number:
    return sum((a * b for a, b in zip(u, v)), 0)


def _nearest(x: Fraction) -> int:
    """The integer nearest to x; a half is rounded up, so -1/2 <= x - result < 1/2."""
    return floor(x + Fraction(1, 2))


def lagrange_gauss_reduce(b1: Sequence[Number], b2: Sequence[Number]) -> tuple[Vector, Vector]:
    """Reduce a basis of a lattice of rank 2.

    Input: two linearly independent vectors of the same length m >= 1 with int or
    Fraction entries (exact arithmetic). Output: (b1, b2), a basis of the same
    lattice with ||b1|| <= ||b2|| and |<b1, b2>| <= ||b1||^2 / 2. Then ||b1|| is
    the first minimum and ||b2|| the second minimum of the lattice.
    Cost: O(1 + log(||b1|| / lambda_1)) loop iterations of O(m) arithmetic
    operations each, where b1 is the shorter input vector.
    Raises ValueError if the vectors are dependent or of different lengths.
    """
    u, v = list(b1), list(b2)
    if len(u) != len(v) or not u:
        raise ValueError("the two vectors must have the same positive length")
    if _dot(u, u) * _dot(v, v) - _dot(u, v) ** 2 == 0:
        raise ValueError("the vectors are linearly dependent")
    if _dot(u, u) > _dot(v, v):  # @line L1
        u, v = v, u
    while True:  # @line L2
        q = _nearest(Fraction(_dot(u, v)) / _dot(u, u))  # @line L3
        v = [y - q * x for x, y in zip(u, v)]  # @line L4
        if _dot(v, v) >= _dot(u, u):  # @line L5
            return u, v
        u, v = v, u  # @line L6
test_lagrange_gauss_reduction.py 147 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/lagrange-gauss-reduction/. The authors are listed in prov/TQ44SE.jsonl.

import itertools
import random
from fractions import Fraction
from math import isqrt

import pytest

from lagrange_gauss_reduction import lagrange_gauss_reduce


def dot(u, v):
    return sum((a * b for a, b in zip(u, v)), 0)


def same_lattice(b, c):
    """True if the rows of c are a basis of the lattice with basis rows b (rank 2).

    Solves c = U b exactly with the Gram matrix and checks that U is an integer
    matrix with determinant +1 or -1.
    """
    g11, g12, g22 = dot(b[0], b[0]), dot(b[0], b[1]), dot(b[1], b[1])
    det = Fraction(g11 * g22 - g12 * g12)
    u = []
    for row in c:
        r1, r2 = dot(row, b[0]), dot(row, b[1])
        x = (r1 * g22 - r2 * g12) / det
        y = (r2 * g11 - r1 * g12) / det
        if x.denominator != 1 or y.denominator != 1:
            return False
        if [x * p + y * q for p, q in zip(b[0], b[1])] != list(row):
            return False
        u.append((int(x), int(y)))
    return abs(u[0][0] * u[1][1] - u[0][1] * u[1][0]) == 1


def minima_brute(b):
    """(lambda_1^2, lambda_2^2) by search over a coefficient box that is proved large enough.

    For a lattice vector x1 b1 + x2 b2 of norm at most R, |x_i| <= R ||d_i||, where
    d_1, d_2 is the dual basis (Cauchy-Schwarz on x_i = <v, d_i>). R^2 is the larger
    of the two squared norms, which bounds lambda_2^2.
    """
    g11, g12, g22 = dot(b[0], b[0]), dot(b[0], b[1]), dot(b[1], b[1])
    det = Fraction(g11 * g22 - g12 * g12)
    r2 = max(g11, g22)
    # ||d_1||^2 = g22 / det and ||d_2||^2 = g11 / det
    k1 = isqrt(int(r2 * g22 / det)) + 1
    k2 = isqrt(int(r2 * g11 / det)) + 1
    vecs = []
    for x1, x2 in itertools.product(range(-k1, k1 + 1), range(-k2, k2 + 1)):
        if (x1, x2) != (0, 0):
            v = [x1 * p + x2 * q for p, q in zip(b[0], b[1])]
            vecs.append((dot(v, v), (x1, x2)))
    vecs.sort()
    l1, s = vecs[0]
    l2 = min(n for n, x in vecs if x[0] * s[1] - x[1] * s[0] != 0)
    return l1, l2


def check(b1, b2):
    u, v = lagrange_gauss_reduce(b1, b2)
    assert same_lattice([b1, b2], [u, v])
    assert dot(u, u) <= dot(v, v)
    assert 2 * abs(dot(u, v)) <= dot(u, u)
    l1, l2 = minima_brute([u, v])
    assert dot(u, u) == l1
    assert dot(v, v) == l2
    return u, v


def test_example():
    u, v = lagrange_gauss_reduce([24, 60], [21, 53])
    assert (u, v) == ([3, -1], [0, 4])
    check([24, 60], [21, 53])


def test_already_reduced_is_kept():
    assert lagrange_gauss_reduce([1, 0], [0, 1]) == ([1, 0], [0, 1])
    assert lagrange_gauss_reduce([3, -1], [0, 4]) == ([3, -1], [0, 4])
    # mu = 1/2 exactly: the result is still reduced, and has the same lengths
    u, v = lagrange_gauss_reduce([2, 0], [1, 5])
    assert dot(u, u) == 4 and dot(v, v) == 26
    check([2, 0], [1, 5])


def test_order_of_input():
    for b1, b2 in (([5, 1], [1, 0]), ([0, 7], [3, 0]), ([1, 1, 1], [1, 0, 0])):
        check(b1, b2)


def test_nearly_dependent():
    rng = random.Random(77)
    for _ in range(300):
        m = rng.randint(2, 6)
        a = [rng.randint(-9, 9) for _ in range(m)]
        if not any(a):
            continue
        k = rng.randint(-40, 40)
        e = [0] * m
        e[rng.randrange(m)] = rng.choice([-1, 1])
        b = [k * x + y for x, y in zip(a, e)]
        if dot(a, a) * dot(b, b) == dot(a, b) ** 2:
            continue
        check(a, b)


def test_rational_entries():
    check([Fraction(1, 2), Fraction(3, 7)], [Fraction(5, 3), Fraction(-2, 9)])
    check([Fraction(10, 3), 7, 1], [3, Fraction(13, 2), 1])


def test_dependent_raises():
    with pytest.raises(ValueError):
        lagrange_gauss_reduce([1, 2], [2, 4])
    with pytest.raises(ValueError):
        lagrange_gauss_reduce([0, 0], [1, 0])
    with pytest.raises(ValueError):
        lagrange_gauss_reduce([1, 2], [1, 2, 3])


def test_random_against_brute_force():
    rng = random.Random(2024)
    done = 0
    while done < 1500:
        m = rng.randint(2, 6)
        span = rng.choice([3, 20, 200])
        b1 = [rng.randint(-span, span) for _ in range(m)]
        b2 = [rng.randint(-span, span) for _ in range(m)]
        if dot(b1, b1) * dot(b2, b2) == dot(b1, b2) ** 2:
            continue
        check(b1, b2)
        done += 1


def test_large_entries():
    rng = random.Random(9)
    for _ in range(200):
        b1 = [rng.randint(-(2**62), 2**62) for _ in range(2)]
        b2 = [rng.randint(-(2**62), 2**62) for _ in range(2)]
        if b1[0] * b2[1] == b1[1] * b2[0]:
            continue
        u, v = lagrange_gauss_reduce(b1, b2)
        assert same_lattice([b1, b2], [u, v])
        assert dot(u, u) <= dot(v, v) and 2 * abs(dot(u, v)) <= dot(u, u)

What the next level needs

  • the tests did not run in this build

Read the full page about Lagrange-Gauss reduction