The LLL algorithm, introduced by Arjen Lenstra, Hendrik Lenstra, and László Lovász in their 1982 paper Factoring polynomials with rational arithmetic, was the first polynomial-time algorithm for finding a provably short vector in an arbitrary lattice. Its primary motivation was factoring polynomials with rational coefficients, but it soon found applications in attacking knapsack cryptosystems, solving integer programs in fixed dimension, and Diophantine approximation. Before LLL, lattice reduction was an open problem. After LLL, it became the workhorse of lattice cryptanalysis — and remains so today.

What Does LLL Stand For?

LLL stands for Lenstra–Lenstra–Lovász, the surnames of its three inventors. The algorithm takes a long, nearly dependent lattice basis and returns a shorter, more orthogonal basis for the same lattice. It runs in polynomial time and gives an approximate short vector rather than solving exact SVP.

Intuition

Imagine walking directions for a city where every step is a long detour (a “bad basis”). The LLL algorithm rewrites them into short, nearly perpendicular steps that still reach exactly the same places.

It works on neighbouring basis vectors: it subtracts whole multiples of earlier vectors to shorten the current one, and swaps two vectors when the later one is clearly shorter. It repeats until neither move helps. The result is not always the shortest possible basis, but its first vector is provably within a known factor of the shortest, is usually much closer in practice, and the whole process runs in polynomial time.

Gram–Schmidt Orthogonalization

LLL is built on the Gram–Schmidt orthogonalization of the basis. Given basis vectors b1,…,bnb_1, \dots, b_n, define the Gram–Schmidt vectors b~1,…,b~n\tilde{b}_1, \dots, \tilde{b}_n by:

b~i=bi−∑j=1i−1μi,j b~j\tilde{b}_i = b_i - \sum_{j=1}^{i-1} \mu_{i,j}\, \tilde{b}_j

where the Gram–Schmidt coefficients are:

μi,j=⟨bi, b~j⟩⟨b~j, b~j⟩\mu_{i,j} = \frac{\langle b_i,\, \tilde{b}_j \rangle}{\langle \tilde{b}_j,\, \tilde{b}_j \rangle}

The Gram–Schmidt vectors are orthogonal but not in the lattice — they live in the real span. Their norms ∥b~i∥\|\tilde{b}_i\| measure how much each basis vector contributes beyond the previous ones. A reduced basis has Gram–Schmidt vectors of roughly comparable length.

LLL Reduction Conditions

A basis B=[b1∣⋯∣bn]B = [b_1 \mid \cdots \mid b_n] is LLL-reduced with parameter δ∈(1/4,1)\delta \in (1/4, 1) if it satisfies two conditions:

Size reduction — all off-diagonal Gram–Schmidt coefficients are small:

∣μi,j∣≤12for all 1≤j<i≤n|\mu_{i,j}| \leq \frac{1}{2} \quad \text{for all } 1 \leq j < i \leq n

Lovász condition — consecutive Gram–Schmidt vectors do not decrease too fast:

δ∥b~i−1∥2≤∥b~i∥2+μi,i−12∥b~i−1∥2for all i\delta \|\tilde{b}_{i-1}\|^2 \leq \|\tilde{b}_i\|^2 + \mu_{i,i-1}^2 \|\tilde{b}_{i-1}\|^2 \quad \text{for all } i

The standard choice is δ=3/4\delta = 3/4. When δ\delta is closer to 1, the output is better but the algorithm takes more iterations. At δ=1\delta = 1 the algorithm is no longer known to run in polynomial time, and its output is still only an approximation — it does not solve SVP.

The Algorithm

LLL interleaves two operations: size-reduce each vector against all previous ones, then check the Lovász condition and swap adjacent vectors if it fails.

LLL in 2D, one step at a time

Press Next step (or Play) and read what each move does. On step 0 you can drag the tips of b₁ and b₂.

b₁b₂
step
0 / 6
‖b₁‖
3.62
‖b₂‖
5.00
μ = ⟨b₂,b₁⟩/‖b₁‖²
-1.37
orthogonality defect
12.58

Step 0: the starting basis. Press Next step, or drag the tips of b₁ and b₂ to pick your own.

What to notice: Only two moves are ever used: size-reduce (subtract a whole multiple of b₁ from b₂) and swap (put the shorter vector first). Neither move changes the lattice: the dots never move. In 2D, LLL with δ = 1 is exactly Lagrange–Gauss reduction and ends with a shortest vector in b₁. In higher dimensions LLL applies the same two moves to neighbouring pairs bk−1, bk, and uses the Lovász condition to decide when to swap.

Input: basis B=(b1,…,bn)B = (b_1, \dots, b_n), parameter δ∈(1/4,1)\delta \in (1/4, 1)
Output: LLL-reduced basis

1. Compute Gram–Schmidt orthogonalization of BB.
2. Set k=2k = 2.
3. Size-reduce bkb_k: for j=k−1j = k-1 down to 11, set bk←bk−⌊μk,j⌉ bjb_k \leftarrow b_k - \lfloor \mu_{k,j} \rceil\, b_j and update Gram–Schmidt.
4. If Lovász condition holds at kk, increment k←k+1k \leftarrow k+1.
5. Otherwise swap bk−1b_{k-1} and bkb_k, update Gram–Schmidt, set k←max⁡(k−1,2)k \leftarrow \max(k-1, 2).
6. Repeat from step 3 until k>nk > n.

LLL algorithm control flow. Size reduction shortens the current vector. A failed Lovász test swaps two neighboring vectors and moves backward; a passed test advances through the basis.

Output Quality

The first vector b1b_1 of an LLL-reduced basis satisfies:

∥b1∥≤(24δ−1)n−1⋅λ1(Λ)\|b_1\| \leq \left(\frac{2}{\sqrt{4\delta - 1}}\right)^{n-1} \cdot \lambda_1(\Lambda)

For δ=3/4\delta = 3/4 this gives an approximation factor of 2(n−1)/22^{(n-1)/2}. In practice LLL performs far better than this worst-case bound — experimentally the output is roughly 1.02n⋅λ11.02^n \cdot \lambda_1.

Complexity

LLL runs in polynomial time. For an nn-dimensional lattice with basis vectors of bit-length at most BB:

O(n6B3)\mathcal{O}(n^6 B^3)

bit operations (with schoolbook integer arithmetic). The number of swap steps is bounded by O(n2B)\mathcal{O}(n^2 B). This polynomial bound — compared to the exponential time needed for exact SVP — is what makes LLL practical: libraries such as fplll reduce bases with hundreds of dimensions on an ordinary laptop.

Cryptographic Impact

The original 1982 paper applied LLL to factoring polynomials with rational coefficients. Shamir broke the basic Merkle–Hellman knapsack cryptosystem the same year using Lenstra’s integer programming algorithm rather than LLL, but LLL soon became the standard knapsack-breaking tool: Lagarias and Odlyzko (1985) used it to solve low-density subset-sum instances by finding a short vector in a carefully constructed lattice.

LLL is a subroutine inside every stronger lattice reduction algorithm (BKZ, HKZ). It is also used in attacks on poorly parameterized RSA (Coppersmith’s method uses LLL to find small roots of polynomials), in breaking ECDSA when nonces are biased, and in solving the hidden number problem.

For lattice-based cryptography like Kyber and Dilithium, the parameters are chosen precisely so that LLL alone is far too weak — but it remains the essential building block of every known attack.

LLL Variants: Complexity Comparison

Several improved variants of LLL have been developed, trading implementation complexity for better practical performance. The table below summarizes the main variants by their theoretical complexity and practical approximation quality.

LLL (δ=3/4\delta = 3/4): original algorithm. Bit complexity O(n6B3)\mathcal{O}(n^6 B^3). Approximation factor 2(n−1)/22^{(n-1)/2} worst case.

L2 algorithm (Nguyen–Stehlé, 2005): floating-point LLL with rigorous error control. Runs in O(n5(n+B)B)\mathcal{O}(n^5 (n + B) B) bit operations — strictly better than classical LLL. Achieves the same output quality guarantee but is the standard in all modern implementations. The key insight is to use a fixed-precision floating-point arithmetic model with carefully chosen precision η≥n+log⁡2B\eta \geq n + \log_2 B bits, re-establishing exactness guarantees without symbolic arithmetic.

Deep insertions LLL (Schnorr–Euchner, 1994): instead of only swapping adjacent vectors, inserts bkb_k at the earliest position where it would satisfy the Lovász condition. Produces better bases in practice, but with unrestricted deep insertions no polynomial worst-case running-time bound is known.

BKZ-β (Schnorr, 1987; Schnorr–Euchner, 1994): with block size β=2\beta = 2, BKZ is essentially LLL (each 2-dimensional block is Lagrange-reduced). Block size is the parameter controlling the transition from polynomial (LLL) to exponential (BKZ) complexity.

Python Implementation from Scratch

The following is a complete, self-contained Python implementation of the LLL algorithm. It uses only NumPy and follows the mathematical description above step by step: Gram–Schmidt orthogonalization, size reduction, and the Lovász swap condition.

import numpy as np

def gram_schmidt(B):
    """
    Compute Gram-Schmidt orthogonalization.

    Returns:
        B_star: orthogonal vectors (same shape as B)
        mu: matrix of Gram-Schmidt coefficients
             mu[i,j] = <b_i, b*_j> / <b*_j, b*_j>
    """
    n = B.shape[0]
    B_star = np.zeros_like(B, dtype=float)
    mu = np.zeros((n, n), dtype=float)

    for i in range(n):
        B_star[i] = B[i].copy()
        for j in range(i):
            # project b_i onto each previous orthogonal vector b*_j
            mu[i, j] = np.dot(B[i], B_star[j]) / np.dot(B_star[j], B_star[j])
            B_star[i] -= mu[i, j] * B_star[j]

    return B_star, mu


def lll_reduction(B, delta=0.75):
    """
    LLL lattice basis reduction.

    Args:
        B: integer matrix where rows are basis vectors (n x m)
        delta: reduction parameter in (0.25, 1). Default 0.75.
               Higher delta → better output but more iterations.

    Returns:
        B: LLL-reduced basis (modified in place)
    """
    B = np.array(B, dtype=float)
    n = B.shape[0]
    B_star, mu = gram_schmidt(B)

    k = 1
    while k < n:
        # === Step 1: Size reduction ===
        # Make |mu[k,j]| <= 0.5 for all j < k
        # This ensures basis vectors are not too skewed
        for j in range(k - 1, -1, -1):
            if abs(mu[k, j]) > 0.5:
                # Subtract the nearest integer multiple of b_j from b_k
                q = round(mu[k, j])
                B[k] -= q * B[j]
                # Update Gram-Schmidt coefficients
                B_star, mu = gram_schmidt(B)

        # === Step 2: Lovász condition ===
        # Check: delta * ||b*_{k-1}||^2 <= ||b*_k||^2 + mu[k,k-1]^2 * ||b*_{k-1}||^2
        # This prevents Gram-Schmidt norms from dropping too fast
        norm_k = np.dot(B_star[k], B_star[k])
        norm_k1 = np.dot(B_star[k - 1], B_star[k - 1])
        lovasz = norm_k + mu[k, k - 1] ** 2 * norm_k1

        if lovasz >= delta * norm_k1:
            # Condition satisfied → move to next index
            k += 1
        else:
            # Condition violated → swap b_k and b_{k-1}
            B[[k, k - 1]] = B[[k - 1, k]]
            B_star, mu = gram_schmidt(B)
            # Step back (but not below index 1)
            k = max(k - 1, 1)

    return B


# === Example usage ===
# Create a "bad" 4-dimensional basis with nearly parallel vectors
basis = np.array([
    [1,  1,  1,  1],
    [-1, 0,  0,  0],
    [3,  5,  6,  6],
    [1,  2,  3,  7],
])

print("Original basis:")
print(basis)
print("Norms:", [round(np.linalg.norm(row), 2) for row in basis])

reduced = lll_reduction(basis, delta=0.75)
print("\nLLL-reduced basis:")
print(reduced.astype(int))
print("Norms:", [round(np.linalg.norm(row), 2) for row in reduced])

How this works step by step:

1. Gram–Schmidt orthogonalization decomposes each basis vector into a component orthogonal to all previous vectors (b~i\tilde{b}_i) plus projections onto them (μi,j\mu_{i,j} coefficients). The orthogonal components reveal the “true” contribution of each vector.

2. Size reduction ensures ∣μk,j∣≤1/2|\mu_{k,j}| \leq 1/2 by subtracting integer multiples of earlier basis vectors. This is the lattice analogue of reducing off-diagonal entries in row echelon form — except we round to the nearest integer to stay in the lattice.

3. The Lovász condition checks that consecutive Gram–Schmidt norms do not drop too sharply. When violated, the algorithm swaps the two vectors. This swap strictly decreases a potential function ∏i∥b~i∥2(n−i)\prod_i \|\tilde{b}_i\|^{2(n-i)}, guaranteeing termination in polynomial time.

4. The index kk moves forward when the Lovász condition holds and steps back after a swap, ensuring no earlier violations are introduced.

Note: This implementation recomputes Gram–Schmidt from scratch after each update for clarity. Production implementations (like FPLLL) use incremental updates, which reduces the per-iteration cost from O(n2)\mathcal{O}(n^2) to O(n)\mathcal{O}(n).

LLL in Practice: FPLLL

The standard implementation of LLL (and BKZ) is FPLLL (Floating-Point LLL), an open-source C++ library maintained by the lattice cryptography community. It implements the L2 algorithm, BKZ 2.0 with pruned enumeration, and the g6k sieve interface.

A minimal FPLLL session in Python via the fpylll wrapper:

from fpylll import IntegerMatrix, LLL

# create a random 40-dimensional lattice basis
A = IntegerMatrix.random(40, "qary", k=20, bits=30)

# run LLL with delta = 0.99
LLL.reduction(A, delta=0.99)

# inspect first basis vector length
print(A[0].norm())

Typical input/output: a 100-dimensional basis with entries of ~100 bits. LLL reduces it in under one second on a modern CPU. The first vector of the output is typically around 1.021001.02^{100} times the lattice minimum — roughly 7× the theoretical minimum, but far shorter than a random basis vector (which would be exponentially longer). For an nn-dimensional lattice arising from an RSA modulus, this translates directly into small-root-finding capability via Coppersmith’s method.

Floating-point precision: the L2 algorithm uses double precision (≈53\approx 53 bits) for small dimensions and switches to arbitrary-precision arithmetic (via MPFR) when the basis coefficients grow large. FPLLL handles this transition automatically, controlled by the float_type parameter (“double”, “long double”, “dpe”, “mpfr”). For dimensions above ~170 or basis bitlength above ~500, double precision accumulates enough rounding error to produce incorrect Gram–Schmidt coefficients, and MPFR is required.

Check Your Understanding

  1. Q1. What does the parameter δ control in LLL?

  2. Q2. Why does size reduction subtract round(μ)·bⱼ instead of exactly μ·bⱼ?

  3. Q3. You run LLL on a 100-dimensional lattice. What should you expect from the first output vector?

  4. Q4. Why is LLL alone not a threat to Kyber or Dilithium?