Mathematical Algorithms/Number Theory: GCD & Modular Arithmetic

Lesson 10.12,850 words

Number Theory: GCD & Modular Arithmetic

This lesson opens the mathematical-algorithms module with the bedrock of computational number theory. We prove Euclid's recurrence gcd(a,b)=gcd(b,amodb)\gcd(a,b)=\gcd(b,\,a\bmod b) and its O(logmin(a,b))O(\log\min(a,b)) running time, extend it to recover Bézout coefficients x,yx,y with ax+by=gcd(a,b)ax+by=\gcd(a,b), and build modular arithmetic on residue classes — including when a modular inverse a1modma^{-1}\bmod m exists and how to compute it.

╌╌╌╌

Most of this course has measured algorithms against the size of their input: elements, vertices, levels of a tree. Number-theoretic algorithms break that habit. Their inputs are single integers, and the interesting cost is measured against the magnitude of those integers, or equivalently the number of bits needed to write them down. The oldest non-trivial algorithm we know, Euclid's, from around 300 BCE, belongs to this family, and it is still the right way to compute a greatest common divisor. This lesson develops it carefully, extends it to solve linear Diophantine equations, and uses both to lay the foundations of modular arithmetic, the arithmetic that underlies hashing, cryptography, and the math problems you will meet in practice.

Divisibility and the greatest common divisor

For integers and we say divides (written ) if for some integer . A common divisor of and is an integer dividing both. The greatest common divisor is the largest such integer, with the conventions and . Throughout we take ; signs only flip the answer's sign.1

The naive way to compute , factoring both numbers and multiplying the shared prime powers, is a mistake: integer factorization is believed to be hard, and the sieve and factorization methods that find those prime powers are themselves a separate study. Euclid's insight is that the factorization is never needed.

Euclid's algorithm

The entire algorithm rests on one recurrence.

The base case is immediate: every integer divides , so the largest divisor of and is itself. The recurrence follows from a sharper claim: the two pairs share exactly the same set of common divisors, hence the same greatest one.

Because the second argument strictly shrinks () and stays non-negative, the recursion must terminate, and it terminates at a pair whose answer is .

Algorithm:Euclid(a,b)\textsc{Euclid}(a, b) — greatest common divisor, O(logmin(a,b))O(\log\min(a,b))
  1. 1
    while b0b \ne 0 do
  2. 2
    ramodbr \gets a \bmod b
  3. 3
    aba \gets b
  4. 4
    brb \gets r
  5. 5
    return aa
gcd.pypython
def gcd(first: int, second: int) -> int:
  """
    Greatest common divisor of `first` and `second`.\n
    Signs are ignored: the result is non-negative, with gcd(0, 0) = 0.\n
  """
  # signs don't affect divisors, so work with magnitudes.
  current: int = abs(first)
  divisor: int = abs(second)

  # replace (current, divisor) by (divisor, current mod divisor) until it's 0.
  while divisor != 0:
    current, divisor = divisor, current % divisor

  return current

def lcm(first: int, second: int) -> int:
  """
    Least common multiple, via lcm(a, b) = |a * b| / gcd(a, b).\n
    Defined as 0 when either argument is 0.\n
  """
  if first == 0 or second == 0:
    return 0

  # divide before multiplying to keep the intermediate value small.
  return abs(first // gcd(first, second) * second)

def gcd_of_all(values: list[int]) -> int:
  """
    Greatest common divisor of a whole list, folding gcd left to right.\n
    The gcd of an empty list is 0 (the identity for gcd).\n
  """
  result: int = 0
  for value in values:
    result = gcd(result, value)
  return result

Why it is fast

Each iteration replaces with . The key fact is that two iterations at least halve the larger argument.

So after every two steps the first argument drops below half its value; the number of iterations is therefore once the first swap orders the arguments.2 Each iteration does one division on numbers of bits, so the bit-complexity is polynomial in the input size, exponentially better than factoring. (For a refresher on this kind of logarithmic bound, see asymptotic analysis.)

The worst case is slow precisely because every quotient is the smallest it can be, , so each step subtracts only once and the pair merely slides to the previous Fibonacci pair. A single larger quotient would collapse the chain far faster.

Fibonacci inputs are the worst case: every quotient is , so the pair steps down through every Fibonacci number one rung at a time.

Each iteration simply replaces the pair by and recurses; tracing shows the second argument collapsing to in three steps.

Euclid's remainder steps for . Each arrow applies : the divisor slides down to become the new first argument and the remainder becomes the new second, until the second argument hits and the first is the answer.

Euclid, geometrically

Replacing by repeated subtraction gives the subtractive form of the algorithm, and it has a geometric reading: tile an rectangle greedily with the largest squares that fit. Cut off a square as many times as you can, then recurse on the leftover strip. The side of the last square is the gcd.

as the largest square that tiles an rectangle

Extended Euclid: Bézout's identity

Euclid tells us what the gcd is; the extended algorithm tells us how to build it out of and .

So the back-substitution recurrence is

Algorithm:Extended-Euclid(a,b)\textsc{Extended-Euclid}(a, b) — returns (g,x,y)(g, x, y) with ax+by=g=gcd(a,b)ax+by=g=\gcd(a,b)
  1. 1
    if b=0b = 0 then
  2. 2
    return (a, 1, 0)(a,\ 1,\ 0)
  3. 3
    (g, x, y)Extended-Euclid(b, amodb)(g,\ x',\ y') \gets \textsc{Extended-Euclid}(b,\ a \bmod b)
  4. 4
    xyx \gets y'
  5. 5
    yxa/byy \gets x' - \lfloor a / b \rfloor \cdot y'
  6. 6
    return (g, x, y)(g,\ x,\ y)
extended_gcd.pypython
from typing import NamedTuple

class Bezout(NamedTuple):
  """
    A Bezout solution: gcd together with coefficients x, y such that\n
    a*x + b*y = gcd holds for the inputs (a, b) that produced it.\n
  """
  gcd: int
  x: int
  y: int

def extended_gcd(first: int, second: int) -> Bezout:
  """
    Greatest common divisor of `first` and `second` with Bezout coefficients\n
    x, y satisfying first*x + second*y == gcd. The returned gcd is\n
    non-negative; x and y may be negative.\n
    Implemented iteratively to avoid recursion depth on large inputs.\n
  """
  # track remainders alongside their bezout coefficient pairs.
  old_remainder, remainder = first, second
  old_x, current_x = 1, 0
  old_y, current_y = 0, 1

  # run euclid, carrying each coefficient through the same back-substitution.
  while remainder != 0:
    quotient: int = old_remainder // remainder
    old_remainder, remainder = remainder, old_remainder - quotient * remainder
    old_x, current_x = current_x, old_x - quotient * current_x
    old_y, current_y = current_y, old_y - quotient * current_y

  # inputs may leave the gcd negative; flip its sign and the coefficients.
  if old_remainder < 0:
    return Bezout(-old_remainder, -old_x, -old_y)

  return Bezout(old_remainder, old_x, old_y)

It performs the same divisions as plain Euclid, so it is also . The table below traces : the forward pass fills the remainder/quotient columns top-down, and the coefficients are filled bottom-up by the back-substitution recurrence, landing on Bézout coefficients for the original pair in the top row.

back-substitution yields for

When does have a solution?

Bézout characterizes exactly when the general linear Diophantine equation is solvable.

This is the predicate behind the Water and Jug Problem (can we measure liters using jugs of capacity and ? iff and ) and Check if Point Is Reachable, where the reachable lattice is governed by the gcd of the allowed steps.

linear_diophantine.pypython
from typing import NamedTuple, Optional

from extended_gcd import extended_gcd

class DiophantineSolution(NamedTuple):
  """
    A particular solution (x, y) to a*x + b*y = c, plus the steps that\n
    generate every other solution: the general solution is\n
    (x + k*x_step, y + k*y_step) for every integer k.\n
  """
  x: int
  y: int
  x_step: int
  y_step: int

def solve_diophantine(
  coefficient_a: int, coefficient_b: int, target: int
) -> Optional[DiophantineSolution]:
  """
    Solve coefficient_a * x + coefficient_b * y = target in integers.\n
    Returns a `DiophantineSolution` describing the whole solution family, or\n
    None when no integer solution exists (i.e. gcd does not divide target).\n
    The degenerate all-zero-coefficient cases are handled explicitly.\n
  """
  # with both coefficients zero, only 0 = target (i.e. target 0) is solvable.
  if coefficient_a == 0 and coefficient_b == 0:
    if target != 0:
      return None
    return DiophantineSolution(0, 0, 1, 0)

  # bezout's gcd must divide the target for any integer solution to exist.
  bezout = extended_gcd(coefficient_a, coefficient_b)
  divisor: int = bezout.gcd
  if target % divisor != 0:
    return None

  # scale bezout's coefficients by target / gcd for a particular solution.
  scale: int = target // divisor
  particular_x: int = bezout.x * scale
  particular_y: int = bezout.y * scale

  # stepping along (b/g, -a/g) leaves a*x + b*y fixed, enumerating all others.
  x_step: int = coefficient_b // divisor
  y_step: int = -coefficient_a // divisor

  return DiophantineSolution(particular_x, particular_y, x_step, y_step)

def has_diophantine_solution(
  coefficient_a: int, coefficient_b: int, target: int
) -> bool:
  """
    Whether coefficient_a * x + coefficient_b * y = target is solvable in\n
    integers — exactly the predicate gcd(a, b) | target.\n
  """
  return solve_diophantine(coefficient_a, coefficient_b, target) is not None

Modular arithmetic

Fix a modulus . We say is congruent to modulo , written i.e. and leave the same remainder on division by . Congruence is an equivalence relation, and it partitions the integers into residue classes. The decisive property is that the class operations are well-defined: if and , then So you may reduce mod at any point in a chain of , , without changing the final residue, the foundation of every answer modulo problem and of combinatorics modulo a prime.3

When two coprime moduli are at play, the residue classes interlock perfectly: the pair pins down uniquely modulo . The grid below tabulates that bijection, with landing in each cell, the constructive heart of the Chinese Remainder Theorem we revisit in combinatorics.

recovered from — a bijection of residues

Modular inverse and linear congruences

A modular inverse of modulo is an integer with . It is what lets you divide by .

There are two standard ways to produce the inverse:

  1. Extended Euclid. Run to get . Reducing mod kills the term, leaving , so is the inverse. This works for any coprime modulus and costs .
  2. Fermat's little theorem. When is prime, every is coprime to , and , hence . Computed by fast exponentiation in multiplications, the subject of the next lesson, on modular exponentiation and primality.

The reason an inverse exists exactly when is visible directly: multiplying every nonzero residue by such an permutes them, so some residue must land on , and that residue is . Below, multiplying by modulo shuffles the set, and the arrow into comes from , so .

, so ; multiplying by permutes
Algorithm:Mod-Inverse(a,m)\textsc{Mod-Inverse}(a, m) — inverse of aa modulo mm, or "none"
  1. 1
    (g, x, y)Extended-Euclid(amodm, m)(g,\ x,\ y) \gets \textsc{Extended-Euclid}(a \bmod m,\ m)
  2. 2
    if g1g \ne 1 then
  3. 3
    return "no inverse"
    not coprime
  4. 4
    return ((xmodm)+m)modm((x \bmod m) + m) \bmod m
    normalize into [0,m)[0, m)
mod_inverse.pypython
from typing import Optional

from extended_gcd import extended_gcd

def mod_inverse(value: int, modulus: int) -> Optional[int]:
  """
    The inverse of `value` modulo `modulus`, as a representative in\n
    [0, modulus), or None when `value` is not coprime to `modulus`.\n
    `modulus` must be at least 1; the only unit modulo 1 is 0.\n
  """
  if modulus <= 0:
    raise ValueError("modulus must be positive")

  # everything is congruent to 0 modulo 1, and 0 is its own inverse there.
  if modulus == 1:
    return 0

  # an inverse exists only when value is coprime to the modulus.
  bezout = extended_gcd(value % modulus, modulus)
  if bezout.gcd != 1:
    return None

  # normalize x into [0, modulus); bezout.x can be negative.
  return ((bezout.x % modulus) + modulus) % modulus

The same machinery solves the general linear congruence . Let .

When this reduces to multiply both sides by and yields the single solution , the everyday case. The general count applies when the modulus and coefficient share a factor.

linear_congruence.pypython
from extended_gcd import extended_gcd
from mod_inverse import mod_inverse

def solve_linear_congruence(
  coefficient: int, target: int, modulus: int
) -> list[int]:
  """
    All solutions to coefficient * x ≡ target (mod modulus), as a sorted list\n
    of representatives in [0, modulus). Returns an empty list when there is no\n
    solution (i.e. gcd(coefficient, modulus) does not divide target).\n
    `modulus` must be at least 1.\n
  """
  if modulus <= 0:
    raise ValueError("modulus must be positive")

  # solvable only when gcd(coefficient, modulus) divides the target.
  divisor: int = extended_gcd(coefficient, modulus).gcd
  if target % divisor != 0:
    return []

  # divide through by the gcd to a coprime congruence mod m/g.
  reduced_modulus: int = modulus // divisor
  reduced_coefficient: int = (coefficient // divisor) % reduced_modulus
  reduced_target: int = (target // divisor) % reduced_modulus

  # the reduced coefficient is coprime to m/g, so its inverse always exists.
  inverse = mod_inverse(reduced_coefficient, reduced_modulus)
  assert inverse is not None  # coprimality guarantees the inverse exists.
  base_solution: int = (reduced_target * inverse) % reduced_modulus

  # the g distinct solutions are spaced m/g apart; return them sorted.
  solutions: list[int] = [
    (base_solution + step * reduced_modulus) % modulus
    for step in range(divisor)
  ]
  solutions.sort()

  return solutions

Worked example (a linear congruence with a shared factor). Solve . Here , , , and . Since divides , the congruence is solvable, and the theorem promises exactly solutions modulo . Divide the whole congruence through by : with , , we solve . The inverse (from the permutation figure above), so . Lifting back to modulus , the two solutions are and and . Checking: and , both correct.

GCD refinements and where Bézout matters

Euclid's algorithm is the oldest non-trivial algorithm in continuous use, and the modern refinements of it are worth knowing.

Binary GCD (Stein's algorithm). Division is expensive on hardware that lacks a fast divide, and each Euclid step needs a modulo. In 1967 Josef Stein published a variant that uses only subtraction, comparison, and shifts — no division at all.4 It rests on three facts: (pull out a common factor of two), when is odd (a factor of two in one argument alone is irrelevant to an odd gcd), and for odd. Stripping factors of two is a single shift instruction, so binary GCD is often faster in practice than Euclid despite touching the same number of bits. On : pull out one common to reach ; is even and odd, so drop that factor to , then ; both odd now, subtract to .

Bit complexity, done honestly. Counting each division as one step gives operations, but on numbers of bits, one schoolbook division already costs bit operations, so Euclid is bit operations in the naive accounting. The half-GCD algorithm, using fast multiplication, computes a gcd in bit operations by a Knuth–Schönhage divide-and-conquer that processes the high-order bits in one batch,5 the same asymptotic class as multiplication itself. This is what large-integer libraries (GMP) actually run for big inputs.

Where Bézout coefficients matter. The extended algorithm's coefficients serve directly as the modular inverse (), the CRT reconstruction weights, and the private exponent in RSA key generation ( is one extended-Euclid call). Every time a cryptographic library inverts modulo the group order, it is running the algorithm on this page.

Takeaways

  • is computed by Euclid's algorithm via , base ; the recurrence is exact because and have identical common divisors.
  • Euclid runs in iterations because two steps at least halve the argument; the worst case is consecutive Fibonacci numbers.
  • Extended Euclid returns Bézout coefficients with , and is solvable iff , the test behind Water-and-Jug and Check-if-Point-Is-Reachable.
  • Modular arithmetic is arithmetic on residue classes; are well-defined, but division requires an inverse and overflow must be guarded.
  • A modular inverse exists iff , found by extended Euclid, or by Fermat () when is prime; the linear congruence is solvable iff , with exactly solutions.

Footnotes

  1. CLRS, Ch. 31 — Number-Theoretic Algorithms (§31.1–31.2): divisibility, common divisors, and the recursive characterization of the gcd.
  2. CLRS, Ch. 31 — Number-Theoretic Algorithms (§31.2): Euclid's and the extended algorithm; the Fibonacci worst case bounds the iteration count to .
  3. Skiena, § — Number Theory: residue classes, congruences, and practical modular-arithmetic pitfalls (overflow, negative remainders).
  4. J. Stein, Computational problems associated with Racah algebra, Journal of Computational Physics 1(3), 1967 — the binary (shift-and-subtract) GCD; see also Knuth, The Art of Computer Programming, Vol. 2 §4.5.2.
  5. Knuth, The Art of Computer Programming, Vol. 2 §4.5.2 (the half-GCD / Schönhage recursion): a gcd in bit operations via fast multiplication.
Practice

╌╌ END ╌╌