Cairn
algorithms · number theory · O(√p) time and space

back to Number Theory

Baby-Step Giant-Step Algorithm

The site's ninth Number Theory entry, and a mirror image of one already here. Modular Exponentiation answers "given g, x, and p, what is gx mod p" — cheaply, in O(log x). The discrete logarithm problem asks the reverse: given g, h, and p, find the exponent x such that gx ≡ h (mod p). Squaring is fast in both directions for ordinary real numbers — if you can compute gx quickly, "undoing" it with a logarithm is just as quick. Modular exponentiation breaks that symmetry: computing the forward direction stays cheap, but no sub-exponential general algorithm is known for reversing it when p is large, and that one-way gap is the entire basis of Diffie–Hellman key exchange and ElGamal encryption. Baby-step giant-step, published by Daniel Shanks in 1971, doesn't break that gap — it's still exponential in the number of digits of p — but it beats the obvious brute-force search (O(p), try every exponent) by trading time for memory via one of the oldest tricks in algorithm design: meet in the middle.

Try it

Enter a modulus p, a base g, and a target h, then step through both phases. p = 23, g = 5, h = 8 is the standard case — 5 is a primitive root mod 23 (it generates every nonzero residue), and the algorithm finds x = 6. p = 23, g = 5, h = 14 needs the full table: the true answer is x = 21, the largest exponent in range, right at the edge the table has to reach — toggle "undersized m" below on this preset and watch it report no solution even though one exists, the first pitfall below. p = 23, g = 4, h = 5 has no solution at all: 4 only generates 11 of the 22 nonzero residues mod 23, and 5 isn't one of them — watch the giant-step phase run to completion and correctly report failure instead of hanging or guessing.

baby steps — table of g^j mod p, oldest to newest:

giant steps — h·g^(−jm) mod p checked against the table:

Press Load, then Step through both phases.

Meet in the middle

Let m = ⌈√(p − 1)⌉. Any exponent x in range [0, p − 1) can be written as x = i·m + j for some i, j both in [0, m) — a two-digit number in base m, essentially. Substituting into gx ≡ h gives

g^(i·m + j) ≡ h (mod p)
g^j ≡ h · g^(−i·m) (mod p)

The left side depends only on j; the right side depends only on i. That split is what makes meeting in the middle possible: precompute every left-side value once (the "baby steps", g0, g1, …, gm−1 mod p, stored in a lookup table keyed by value), then walk the right side ("giant steps", h·g0, h·g−m, h·g−2m, …) checking each one against that table. The first giant step that lands on a value already in the table gives both halves of x at once: j from the table, i from how many giant steps it took to get there. Each phase costs at most m work, so the total is O(m) = O(√p) instead of brute force's O(p) — for a 40-digit prime, that's the difference between roughly 20 digits of work and 10.

Reference implementation

function modPow(base, exp, mod) {
  base = ((base % mod) + mod) % mod;
  let result = 1n % mod;
  while (exp > 0n) {
    if (exp & 1n) result = (result * base) % mod;
    base = (base * base) % mod;
    exp >>= 1n;
  }
  return result;
}

function modInv(a, mod) {          // extended Euclidean; works for any mod with gcd(a, mod) = 1
  let [old_r, r] = [a, mod];
  let [old_s, s] = [1n, 0n];
  while (r !== 0n) {
    const q = old_r / r;
    [old_r, r] = [r, old_r - q * r];
    [old_s, s] = [s, old_s - q * s];
  }
  return ((old_s % mod) + mod) % mod;
}

function isqrtCeil(n) {            // ceil(sqrt(n)) for a non-negative BigInt n
  if (n < 2n) return n;
  let x = n, y = (x + 1n) / 2n;
  while (y < x) { x = y; y = (x + n / x) / 2n; }
  return (x * x === n) ? x : x + 1n;
}

function babyStepGiantStep(g, h, p) {   // find x with g^x = h (mod p), or null
  const m = isqrtCeil(p - 1n);
  const table = new Map();
  let e = 1n % p;
  for (let j = 0n; j < m; j++) {
    if (!table.has(e)) table.set(e, j);   // first occurrence only
    e = (e * g) % p;
  }
  const giantFactor = modPow(modInv(g, p), m, p);   // g^(-m) mod p
  let gamma = h % p;
  for (let i = 0n; i < m; i++) {
    if (table.has(gamma)) return i * m + table.get(gamma);
    gamma = (gamma * giantFactor) % p;
  }
  return null;   // no x in [0, m^2) works
}

The interactive demo above uses an equivalent generator function that yields one event per baby or giant step, purely to drive the step-through UI — the arithmetic is identical, including storing only the first occurrence of each baby-step value (see Pitfalls for why that matters) and computing the giant-step multiplier as a single modInv plus modPow up front rather than recomputing g−im from scratch on every giant step. Verified against 3,000 randomized trials: pick a random prime p up to 3,000, a random base g, and a random true exponent x; compute h = gx mod p with modular exponentiation, then confirm babyStepGiantStep(g, h, p) returns some x′ with gx′ mod p === h — not necessarily the original x itself, since g may have several valid discrete logarithms of h if its order divides p − 1 more than once, only that the returned answer actually checks out. Zero mismatches across all 3,000. The three worked presets above (x = 6, x = 21, and the no-solution case) were each checked directly against a brute-force scan of every exponent from 0 to p − 2, not just against the algorithm's own output.

Pitfalls

Rounding m down instead of up doesn't fail loudly — it silently shrinks the range of exponents the search can ever find, and reports "no solution" for real ones that fall outside it. With i and j both confined to [0, m), the largest reachable exponent is (m − 1)·m + (m − 1) = m² − 1. For p = 23, the true range needed is [0, 21] (22 possible exponents), which requires m ≥ ⌈√22⌉ = 5; ⌊√22⌋ = 4 only reaches up to 4² − 1 = 15. Checked directly: g = 5, h = 14, p = 23 has the true answer x = 21, and with the correctly-rounded m = 5 the algorithm finds it in 5 giant steps — but with the floor-rounded m = 4 (toggle the checkbox above on this exact preset), it exhausts all 4 giant steps and reports no solution, on an input that has one. The failure is silent in the specific sense that matters most: nothing about it looks like an error, it looks exactly like a legitimately unsolvable instance, which is exactly what the next pitfall covers.

A genuinely unsolvable instance looks identical to an undersized-m failure from the caller's side — the algorithm can't tell you which one happened. g = 4 has order 11 mod 23 (it only generates 11 of the 22 nonzero residues), so h = 5, which isn't among them, has no discrete logarithm base 4 at all — checked by brute force across every exponent 0 through 21, none of them produce 5. Run the algorithm on this input (correctly-rounded m = 5, no bug involved) and it exhausts all 5 giant steps and returns "no match," the exact same outcome shape as the previous pitfall's bug. Both cases are "ran to completion, found nothing" — the difference between "this genuinely has no answer" and "the search window was too small" isn't visible from the algorithm's own return value, only from knowing independently whether h lies in the subgroup g generates. In practice this rarely bites: most real uses (Diffie–Hellman, ElGamal) fix g as a known generator of the full group specifically so this case can't arise, but it's worth knowing the failure modes are indistinguishable before assuming a "no solution" result rules out a search bug.

The baby-step table must keep only the first j for a repeated value, not the last. If g's own order is smaller than m, the sequence g0, g1, … starts repeating before the table finishes filling — overwriting an earlier entry with a later, larger j would hand back a valid-looking but needlessly large x, or in the worst case, one that's actually wrong if a giant step matches against the overwritten value using the wrong j. Storing only the first occurrence (the reference implementation's if (!table.has(e)) guard) keeps every stored j the smallest one that produces that value, which is also the smallest correct answer the algorithm could ever return for a giant step landing on it.

Complexity

O(√p) time and O(√p) space: the baby-step table holds up to m ≈ √p entries, and the giant-step loop runs at most m times, each a single lookup plus one modular multiplication. That's a real improvement over brute force's O(p) time — for a 40-digit prime, roughly 20 digits of work instead of 40 — but it isn't free: unlike every other entry in this category, this algorithm's cost is dominated by memory, not just time, since the whole baby-step table has to stay resident to be checked against on every giant step. And critically, √p is still exponential in the number of bits of p — doubling p's bit-length roughly squares the work, not doubles it — which is exactly why cryptographic protocols that rely on the discrete-log problem's hardness use moduli hundreds of digits long: large enough that even this quadratic speedup over brute force stays computationally out of reach. Faster algorithms exist for special cases (Pohlig–Hellman when p − 1 has only small prime factors, index calculus for prime fields), but no known general algorithm beats this square-root shape by more than a constant factor for a well-chosen, safe prime.

This site's guide, Choosing a Number Theory Algorithm, compares this entry against the other thirteen Number Theory entries side by side.