Mathematical Algorithms/Fast Fourier Transform

Lesson 10.63,766 words

Fast Fourier Transform

Multiplying two degree-nn polynomials by the schoolbook method costs Θ(n2)\Theta(n^2). Evaluating them at the **nn-th roots of unity turns multiplication into pointwise products, and the Cooley–Tukey FFT** computes all those evaluations in Θ(nlogn)\Theta(n\log n) by splitting even and odd coefficients.

╌╌╌╌

Karatsuba cut integer multiplication below quadratic by trading one multiplication for a few additions, reaching . This lesson goes further, to , with the Fast Fourier Transform. The route is indirect. The obstacle to fast multiplication is the coefficient representation of a polynomial, in which the product is a convolution costing . If instead we represent a polynomial by its values at enough points, the product is computed pointwise in linear time. The FFT converts between the two representations in , and the points it chooses — the complex roots of unity — are what make that conversion fast.

The problem: polynomial multiplication is convolution

A polynomial of degree is given by its coefficient vector , meaning . The product has coefficients

This sum is the convolution . Computed directly, each of the output coefficients is a sum over up to products, so the cost is — the schoolbook method, and exactly the cost of grade-school long multiplication when and are the digit vectors of two integers. Convolution is everywhere (signal filtering, string matching, probability of sums), so a faster algorithm pays off far beyond polynomials.

A polynomial is its values

A degree- polynomial is uniquely determined by its values at any distinct points — the interpolation fact that points fix a degree- curve (two points fix a line, three fix a parabola). So besides the coefficient vector there is a second, equally faithful description: the point-value representation, a list of pairs .

In this representation, multiplication is cheap. If and are both sampled at the same points , then the product is sampled there by a single multiply per point:

That is work, not . The catch is that the product has degree up to , so points are needed to determine it; we must therefore sample and at (at least) points up front, padding their coefficient vectors with zeros to length . The strategy is now a three-step detour:

Multiply via point-value form: evaluate both polynomials (DFT), multiply pointwise, interpolate back (inverse DFT)

Evaluate, multiply pointwise, interpolate. The middle step is linear, so the total cost depends on the two end steps: evaluating a polynomial at points, and recovering coefficients from values. Naively each is (evaluating at one point by Horner's rule is , times points), so a poor choice of points gains nothing. For one special set of points, both steps drop to .

The roots of unity

The special points are the -th roots of unity: the complex numbers with . They are equally spaced around the unit circle in the complex plane,

where is the principal -th root and its powers enumerate all of them. Geometrically, multiplying by rotates a point by radians; such rotations come full circle back to the start, which is the statement .

The eight -th roots of unity on the unit circle, labelled by their exponent in , equally spaced by ; squaring doubles the angle, folding the eight points onto the four -th roots

Two algebraic properties of these points make the FFT work.

The evaluation points, when squared, become only distinct points, and they pair up as of each other. These two facts turn one size- evaluation into two size- evaluations.

The DFT and the Cooley–Tukey split

Evaluating at all roots of unity is, by definition, the Discrete Fourier Transform of its coefficient vector.

Computed as written, that is evaluations of an -term sum: . The FFT computes the same vector in by a divide-and-conquer split on the parity of the coefficient index. Separate into its even- and odd-indexed coefficients and factor an out of the odd part:

where and are each polynomials of half the degree, in the variable . Repeating the split recursively builds a tree: at each level the input is sorted by parity into two halves, dark for the even indices and light for the odd, until single coefficients sit at the leaves.

The even/odd recursive split for . Each level partitions the indices by parity — dark tint = even-indexed coefficients, light tint = odd-indexed — so after levels every leaf is one coefficient

Now evaluate at a root . By the halving lemma, , so

The right-hand side asks for and evaluated at the -th roots of unity — two DFTs of size . And here the pairing pays off: the point squares to the same , so the two half-size results serve both and at once, with only a sign flip on the odd part:

These paired equations are the butterfly: two outputs assembled from two inputs by one multiply ( times the odd value, the twiddle factor) and one add/subtract. A full level of butterflies combines two half-DFTs into one full DFT in work.

One butterfly. The two half-DFT outputs and form the twiddle ; the top output is , the bottom

Recursing this split bottoms out at size- DFTs (a single coefficient evaluates to itself), and at every level above it does combining work, giving the recurrence

This is the mergesort recurrence. By the Master Theorem (, , , the balanced case ), it solves to

Algorithm 1:FFT(a)\textsc{FFT}(a) — recursive Cooley–Tukey DFT, nn a power of two
  1. 1
    nlength(a)n \gets \text{length}(a)
  2. 2
    if n=1n = 1 then return aa
    size-1 DFT is the identity
  3. 3
    aeven(a0,a2,,an2)a_{\text{even}} \gets (a_0, a_2, \dots, a_{n-2})
    even-indexed
  4. 4
    aodd(a1,a3,,an1)a_{\text{odd}} \gets (a_1, a_3, \dots, a_{n-1})
    odd-indexed
  5. 5
    EFFT(aeven)E \gets \textsc{FFT}(a_{\text{even}})
    half-size DFT
  6. 6
    OFFT(aodd)O \gets \textsc{FFT}(a_{\text{odd}})
    half-size DFT
  7. 7
    ω1,ωne2πi/n\omega \gets 1,\quad \omega_n \gets e^{2\pi i / n}
  8. 8
    for k0k \gets 0 to n/21n/2 - 1 do
  9. 9
    tωOkt \gets \omega \cdot O_k
    twiddle the odd part
  10. 10
    a^kEk+t\hat a_k \gets E_k + t
    butterfly: top output
  11. 11
    a^k+n/2Ekt\hat a_{k + n/2} \gets E_k - t
    butterfly: bottom output
  12. 12
    ωωωn\omega \gets \omega \cdot \omega_n
    advance to next root
  13. 13
    return a^\hat a
fft.pypython
import cmath
from collections.abc import Sequence

def next_power_of_two(value: int) -> int:
  """
    Smallest power of two that is >= `value` (and >= 1).\n
  """
  size: int = 1
  while size < value:
    size <<= 1
  return size

def naive_dft(coefficients: Sequence[complex]) -> list[complex]:
  """
    The Discrete Fourier Transform computed straight from the definition,\n
    hat_a[k] = sum_j a[j] * omega^(k j), in O(n^2). Serves as the slow but\n
    obviously-correct reference the fast transform is checked against.\n
  """
  length: int = len(coefficients)
  if length == 0:
    return []

  # the principal n-th root of unity all frequencies are sampled against.
  principal_root: complex = cmath.exp(2j * cmath.pi / length)

  # for each frequency k, sum a[j] * omega^(k j) over every coefficient.
  transform: list[complex] = []
  for frequency in range(length):
    total: complex = sum(
      coefficient * principal_root ** (frequency * index)
      for index, coefficient in enumerate(coefficients)
    )
    transform.append(total)

  return transform

def _transform(coefficients: list[complex], inverse: bool) -> list[complex]:
  """
    The shared recursive butterfly engine. `inverse` flips the sign of the\n
    root's angle (conjugate roots); the 1/n scaling is applied once by the\n
    public `inverse_fft` wrapper, not here, so the recursion stays uniform.\n
  """
  # a size-1 DFT is the identity: one coefficient evaluates to itself.
  length: int = len(coefficients)
  if length == 1:
    return list(coefficients)

  # recurse on the even- and odd-indexed halves.
  even_half: list[complex] = _transform(coefficients[0::2], inverse)
  odd_half: list[complex] = _transform(coefficients[1::2], inverse)

  # principal root for this level; angle flips sign when inverting.
  angle: float = (-2.0 if inverse else 2.0) * cmath.pi / length
  principal_root: complex = cmath.exp(1j * angle)
  root: complex = 1 + 0j

  # combine halves with butterflies, each filling the k and k + n/2 outputs.
  transform: list[complex] = [0j for _ in range(length)]
  half: int = length // 2
  for frequency in range(half):
    twiddle: complex = root * odd_half[frequency]
    transform[frequency] = even_half[frequency] + twiddle
    transform[frequency + half] = even_half[frequency] - twiddle
    root *= principal_root

  return transform

def _pad_to_power_of_two(coefficients: Sequence[complex]) -> list[complex]:
  """
    Copy `coefficients` into a fresh list zero-padded up to the next power\n
    of two, the length the radix-2 split requires.\n
  """
  # copy into a zero-filled buffer sized to the next power of two.
  target: int = next_power_of_two(max(1, len(coefficients)))
  padded: list[complex] = [0j for _ in range(target)]
  for index, coefficient in enumerate(coefficients):
    padded[index] = coefficient

  return padded

def fft(coefficients: Sequence[complex]) -> list[complex]:
  """
    Forward FFT: evaluate the polynomial with these coefficients at the N-th\n
    roots of unity, where N is the next power of two >= len(coefficients).\n
    The input is zero-padded as needed, so the output has length N.\n
  """
  return _transform(_pad_to_power_of_two(coefficients), inverse=False)

def inverse_fft(values: Sequence[complex]) -> list[complex]:
  """
    Inverse FFT: recover coefficients from values sampled at the roots of\n
    unity. Runs the same butterflies on the conjugate roots, then divides\n
    every output by N. `values` must already have power-of-two length.\n
  """
  length: int = len(values)
  if length == 0:
    return []

  # invert on the conjugate roots, then divide every output by N.
  transform: list[complex] = _transform(list(values), inverse=True)
  return [entry / length for entry in transform]

The algorithm requires to be a power of two so the halving is exact; pad the coefficient vector with zeros up to the next power of two (and to at least , for the product's degree) before transforming. A non-recursive, in-place variant first permutes the inputs into bit-reversed order and then runs the butterflies bottom-up, which is what production libraries implement, but the recursion above is the cleanest statement of why it is .

Inverting the transform

Evaluation is half the round trip; we still must turn the product values back into coefficients — the inverse DFT. The DFT is a linear map, multiplication of by the matrix with (a Vandermonde matrix at the roots of unity). Its inverse has a simple form.

The consequence is that the inverse transform is the same algorithm: replace by its conjugate , run unchanged, and divide every output by . We never invert a matrix or solve a linear system — interpolation at the roots of unity is just another FFT.

Forward and inverse FFT are the same machine. The forward pass evaluates at the roots ; the inverse runs the identical butterflies on the conjugate roots and scales the result by

Putting it together: fast multiplication

The three-step plan now has an cost on every leg. Pad and to length , the next power of two ; transform both; multiply the two value vectors pointwise; inverse-transform. Three FFTs plus one linear pointwise multiply:

Algorithm 2:Poly-Multiply(a,b)\textsc{Poly-Multiply}(a, b) — convolution in Θ(nlogn)\Theta(n\log n)
  1. 1
    NN \gets next power of two 2n\ge 2n
  2. 2
    pad a,ba, b with zeros to length NN
  3. 3
    a^FFT(a)\hat a \gets \textsc{FFT}(a)
    evaluate at the NN-th roots
  4. 4
    b^FFT(b)\hat b \gets \textsc{FFT}(b)
  5. 5
    for k0k \gets 0 to N1N - 1 do
  6. 6
    c^ka^kb^k\hat c_k \gets \hat a_k \cdot \hat b_k
    pointwise product
  7. 7
    c1NFFT-conj(c^)c \gets \tfrac{1}{N}\,\textsc{FFT-conj}(\hat c)
    inverse FFT (conjugate roots, /N)
  8. 8
    return cc
    the convolution aba \ast b
poly_multiply.pypython
from collections.abc import Sequence

from fft import fft, inverse_fft, next_power_of_two

def naive_convolution(left: Sequence[float], right: Sequence[float]) -> list[float]:
  """
    The schoolbook convolution c[k] = sum_j left[j] * right[k - j], in\n
    O(n m). Exact reference for the FFT-based product.\n
  """
  if not left or not right:
    return []

  # accumulate every term left[j] * right[i] into output slot j + i.
  result: list[float] = [0.0 for _ in range(len(left) + len(right) - 1)]
  for left_index, left_value in enumerate(left):
    for right_index, right_value in enumerate(right):
      result[left_index + right_index] += left_value * right_value

  return result

def poly_multiply(left: Sequence[float], right: Sequence[float]) -> list[float]:
  """
    Multiply two real coefficient vectors (lowest degree first) via three\n
    FFTs and a pointwise product. The result has length len(left) +\n
    len(right) - 1; the imaginary parts of the inverse transform are rounding\n
    noise and are discarded.\n
  """
  if not left or not right:
    return []

  result_length: int = len(left) + len(right) - 1

  # pad to a power of two large enough to hold the product's degree.
  size: int = next_power_of_two(result_length)
  left_padded: list[complex] = [complex(value) for value in left] + \
    [0j for _ in range(size - len(left))]
  right_padded: list[complex] = [complex(value) for value in right] + \
    [0j for _ in range(size - len(right))]

  left_values: list[complex] = fft(left_padded)
  right_values: list[complex] = fft(right_padded)

  product_values: list[complex] = [
    left_value * right_value
    for left_value, right_value in zip(left_values, right_values)
  ]

  coefficients: list[complex] = inverse_fft(product_values)
  return [coefficient.real for coefficient in coefficients[:result_length]]

def poly_multiply_round(
  left: Sequence[int],
  right: Sequence[int],
) -> list[int]:
  """
    Integer-coefficient product: multiply via FFT, then round each output to\n
    the nearest integer. Correct as long as the coefficients stay well below\n
    the float precision limit.\n
  """
  return [round(value) for value in poly_multiply(left, right)]

Worked example (a size- transform). Take , coefficient vector padded to length . The -th roots of unity are . Splitting by parity, gives (a constant evaluates to itself at both nd roots), and gives . The butterflies with twiddles and assemble

Check directly: , , , — the transform is exactly evaluated at the four roots. To square (compute ), multiply the transform pointwise — — then run the inverse FFT (conjugate roots, divide by ), recovering , the coefficients of .

This is the asymptotic payoff that beats both schoolbook and Karatsuba's . The application that started us off, big-integer multiplication, is the same algorithm: an -digit integer in base is the polynomial evaluated at , so multiplying two big integers is multiplying their digit polynomials, then carrying the over-large coefficients — the problem behind Multiply Strings, and at large enough sizes it is what bignum libraries do; the same FFT pipeline (with carries) is the route from Karatsuba's down toward the near-linear that the divide-and-conquer lesson flagged as the next rung. Add Two Numbers shares the carry mechanics on a per-digit scale.

ntt.pypython
from collections.abc import Sequence

# a classic NTT-friendly prime: 998244353 = 119 * 2^23 + 1, so it admits
# primitive 2^k-th roots of unity for every k up to 23. 3 is a primitive root.
MODULUS: int = 998244353
PRIMITIVE_ROOT: int = 3

def _next_power_of_two(value: int) -> int:
  """
    Smallest power of two that is >= `value` (and >= 1).\n
  """
  size: int = 1
  while size < value:
    size <<= 1
  return size

def _transform(coefficients: list[int], inverse: bool) -> list[int]:
  """
    Iterative radix-2 NTT in place over a working copy. `inverse` uses the\n
    modular inverse of the root; the 1/n scaling is left to the caller. The\n
    length of `coefficients` must already be a power of two.\n
  """
  length: int = len(coefficients)
  values: list[int] = list(coefficients)

  # bit-reversal permutation so the butterflies can run bottom-up in place.
  position: int = 0
  for index in range(1, length):
    bit: int = length >> 1
    while position & bit:
      position ^= bit
      bit >>= 1
    position ^= bit
    if index < position:
      values[index], values[position] = values[position], values[index]

  # merge blocks bottom-up, doubling the block size each pass.
  block: int = 2
  while block <= length:

    # the primitive block-th root of unity in Z_p (inverted when inverting).
    exponent: int = (MODULUS - 1) // block
    root: int = pow(PRIMITIVE_ROOT, exponent, MODULUS)
    if inverse:
      root = pow(root, MODULUS - 2, MODULUS)

    # butterfly every block, walking the twiddle through its powers of root.
    for start in range(0, length, block):
      twiddle: int = 1
      half: int = block // 2
      for offset in range(half):
        even: int = values[start + offset]
        odd: int = values[start + offset + half] * twiddle % MODULUS
        values[start + offset] = (even + odd) % MODULUS
        values[start + offset + half] = (even - odd) % MODULUS
        twiddle = twiddle * root % MODULUS

    block <<= 1

  return values

def ntt(coefficients: Sequence[int]) -> list[int]:
  """
    Forward NTT modulo `MODULUS`: evaluate at the n-th roots of unity in Z_p,\n
    where n is the next power of two >= len(coefficients). Input is reduced\n
    mod p and zero-padded to length n.\n
  """
  size: int = _next_power_of_two(max(1, len(coefficients)))
  padded: list[int] = [value % MODULUS for value in coefficients]
  padded += [0 for _ in range(size - len(padded))]
  return _transform(padded, inverse=False)

def inverse_ntt(values: Sequence[int]) -> list[int]:
  """
    Inverse NTT: recover coefficients mod `MODULUS`. Runs the transform with\n
    the inverse root, then scales by the modular inverse of n. `values` must\n
    have power-of-two length.\n
  """
  length: int = len(values)
  if length == 0:
    return []
  transformed: list[int] = _transform(list(values), inverse=True)
  scale: int = pow(length, MODULUS - 2, MODULUS)
  return [entry * scale % MODULUS for entry in transformed]

def ntt_convolution(
  left: Sequence[int],
  right: Sequence[int],
) -> list[int]:
  """
    Exact convolution of two integer vectors modulo `MODULUS`, in\n
    O(n log n). The result has length len(left) + len(right) - 1, each entry\n
    reduced mod p.\n
  """
  if not left or not right:
    return []

  result_length: int = len(left) + len(right) - 1
  size: int = _next_power_of_two(result_length)

  left_padded: list[int] = [value % MODULUS for value in left] + \
    [0 for _ in range(size - len(left))]
  right_padded: list[int] = [value % MODULUS for value in right] + \
    [0 for _ in range(size - len(right))]

  left_values: list[int] = _transform(left_padded, inverse=False)
  right_values: list[int] = _transform(right_padded, inverse=False)

  product_values: list[int] = [
    left_value * right_value % MODULUS
    for left_value, right_value in zip(left_values, right_values)
  ]

  coefficients: list[int] = inverse_ntt(product_values)
  return coefficients[:result_length]
big_integer_multiply.pypython
from poly_multiply import poly_multiply_round

# base for the digit decomposition; powers of ten keep the conversion to and
# from decimal strings trivial while staying small enough for float precision.
_BASE: int = 10

def _digits_of(magnitude: int) -> list[int]:
  """
    Least-significant-first base-`_BASE` digit list of a non-negative int.\n
  """
  if magnitude == 0:
    return [0]

  # peel off base-_BASE digits from the bottom up.
  digits: list[int] = []
  while magnitude > 0:
    digits.append(magnitude % _BASE)
    magnitude //= _BASE

  return digits

def _carry(raw_coefficients: list[int]) -> int:
  """
    Fold a list of (possibly over-large) base-`_BASE` coefficients, lowest\n
    first, into a single integer by propagating carries.\n
  """
  value: int = 0
  place: int = 1
  carry: int = 0

  # absorb each coefficient plus the running carry into one digit place.
  for coefficient in raw_coefficients:
    total: int = coefficient + carry
    value += (total % _BASE) * place
    carry = total // _BASE
    place *= _BASE

  # flush any carry left above the most significant coefficient.
  while carry > 0:
    value += (carry % _BASE) * place
    carry //= _BASE
    place *= _BASE

  return value

def big_integer_multiply(first: int, second: int) -> int:
  """
    The product `first * second`, computed by convolving the two integers'\n
    digit vectors with the FFT and then carrying. Handles negatives and\n
    zero; the result matches ordinary integer multiplication exactly.\n
  """
  if first == 0 or second == 0:
    return 0

  sign: int = 1 if (first < 0) == (second < 0) else -1

  first_digits: list[int] = _digits_of(abs(first))
  second_digits: list[int] = _digits_of(abs(second))

  raw_product: list[int] = poly_multiply_round(first_digits, second_digits)
  magnitude: int = _carry(raw_product)
  return sign * magnitude

The FFT beyond powers of two

Cooley and Tukey's 1965 paper is one of the most-cited in all of computing, and the ideas around it stretch from signal processing to the fastest multiplication algorithm known.

Not just powers of two. The recursion above needs to be a power of two. Real FFT libraries handle any length by mixed-radix splitting (factor and do transforms of size then of size ), and for a prime length they use Bluestein's algorithm, which re-expresses the size- DFT as a convolution and pads that to a convenient power of two.2 So the bound holds for every , not only the padded powers of two — a detail that matters when you cannot afford to round the transform size up.

The FFT was not new. Cooley and Tukey rediscovered and popularized the algorithm, but Gauss had written down the same even/odd recursion around 1805 to interpolate asteroid orbits — before Fourier's own work was published.3 The 1965 paper's real impact was timing: it arrived just as digital computers made spectral analysis suddenly practical, launching modern digital signal processing.

Toward optimal multiplication. FFT-based multiplication leads to the asymptotically fastest integer multiplication known. Schönhage–Strassen (1971) runs the transform over a ring of the form — no floating-point, no NTT-prime constraints — to multiply -bit integers in .4 The factor stood for nearly fifty years until Harvey and van der Hoeven (2021) reached the conjectured optimum .5 These are galactic algorithms — the crossover point is enormous — but Schönhage–Strassen is genuinely used by GMP for numbers beyond a few tens of thousands of digits.

Beyond multiplication. The same transform accelerates string matching (match a pattern against a text by convolving indicator vectors, catching wildcards), the computation of the autocorrelation and power spectrum of a signal, large-scale polynomial interpolation, and the multiplication step inside the Fiduccia/Kitamasa recurrence solver from the matrix-exponentiation lesson. All of these reduce to convolution, which the FFT makes cheap.

Takeaways

  • Multiplying polynomials (or big integers) is convolution, in the coefficient representation; but in the point-value representation the product is a pointwise multiply at shared sample points.
  • The bottleneck is converting between the two forms. Sampling at the -th roots of unity makes both conversions cheap because squaring halves the root set and the second half is the negation of the first.
  • The DFT evaluates at all roots of unity; the Cooley–Tukey FFT splits on even/odd coefficient parity into two half-size DFTs combined by butterflies, giving by the Master Theorem.
  • The inverse FFT is the same algorithm with conjugate roots and a scaling, because the DFT matrix's inverse is (a geometric-series argument).
  • Three FFTs plus a pointwise multiply give polynomial and big-integer multiplication; the NTT does the same exactly modulo a prime.

Footnotes

  1. CLRS, Ch. 30 — Polynomials and the FFT: the coefficient and point-value representations, the recursive Cooley–Tukey FFT with the recurrence, the inverse DFT via , and polynomial multiplication.
  2. L. Bluestein, A linear filtering approach to the computation of discrete Fourier transform, IEEE Trans. Audio and Electroacoustics 18(4), 1970; the chirp-z transform for arbitrary and prime lengths.
  3. M. T. Heideman, D. H. Johnson, C. S. Burrus, Gauss and the history of the fast Fourier transform, IEEE ASSP Magazine 1(4), 1984 — tracing the algorithm to Gauss (c. 1805), before Cooley–Tukey (1965).
  4. A. Schönhage and V. Strassen, Schnelle Multiplikation großer Zahlen, Computing 7, 1971 — integer multiplication in .
  5. D. Harvey and J. van der Hoeven, Integer multiplication in time , Annals of Mathematics 193(2), 2021.
Practice

╌╌ END ╌╌