DSA DSA In Depth
0%
lesson fftnttconvolution

Fast Fourier Transform (Cooley-Tukey FFT) & O(N log N) Polynomial Convolution

7 min read Module 2.3: Binary Search on Monotonic Spaces & Divide-and-Conquer Updated Oct 23, 2026
📄 Cheat Sheet
But what is the Fast Fourier Transform? A visual introduction (Reducible)
Reducible But what is the Fast Fourier Transform? A visual introduction (Reducible) Watch on YouTube ↗

Crystal-clear visual derivation of polynomial evaluation vs interpolation, +/- pairing symmetry, complex roots of unity, and the Cooley-Tukey O(N log N) butterfly.

0:00 / --:--

Coefficient Representation vs. Point-Value Representation

A polynomial $A(x) = \sum_{j=0}^{n-1} a_j x^j$ of degree $< n$ can be represented in two dual ways:

  1. Coefficient Form: The vector $(a_0, a_1, \dots, a_{n-1})$. Adding two polynomials takes $O(n)$, but multiplying $C(x) = A(x)B(x)$ requires discrete convolution $c_k = \sum_{j=0}^{k} a_j b_{k-j}$ in $\Theta(n^2)$ time.
  2. Point-Value Form: A set of $n$ distinct sample points $\{(x_0, y_0), \dots, (x_{n-1}, y_{n-1})\}$ where $y_k = A(x_k)$. By the Fundamental Theorem of Algebra, $n$ point-value pairs uniquely determine a polynomial of degree $< n$. In point-value form, multiplying $C(x) = A(x)B(x)$ (padded to $N \ge 2n - 1$ points) is simply pointwise multiplication $C(x_k) = A(x_k) \cdot B(x_k)$ in $\Theta(N)$ time!

The Collapsing Property of Complex $N$-th Roots of Unity

How can we evaluate $A(x)$ at $N$ points in $O(N \log N)$ time instead of $O(N^2)$? Group the terms of $A(x)$ into even-indexed and odd-indexed coefficients:

$$A(x) = \underbrace{(a_0 + a_2 x^2 + a_4 x^4 + \dots)}_{A_{\text{even}}(x^2)} + x \underbrace{(a_1 + a_3 x^2 + a_5 x^4 + \dots)}_{A_{\text{odd}}(x^2)}$$

Notice that both $A_{\text{even}}(u)$ and $A_{\text{odd}}(u)$ are polynomials of half the size ($N/2$) evaluated at $u = x^2$. If we choose evaluation points that come in opposite pairs $\pm x$, then $(+x)^2 = (-x)^2 = x^2$, so evaluating $A$ at $N$ points reduces to evaluating $A_{\text{even}}$ and $A_{\text{odd}}$ at only $N/2$ squared points! For this +/- symmetry to hold recursively across all $\log_2 N$ levels (where squares of squares must also be +/- pairs), we must choose the complex $N$-th roots of unity:

$$\omega_N^k = e^{2\pi i k / N} = \cos\!\left(\frac{2\pi k}{N}\right) + i \sin\!\left(\frac{2\pi k}{N}\right), \qquad k = 0, 1, \dots, N-1$$
Cooley-Tukey Radix-2 FFT Butterfly, Karatsuba & CDQ Divide-and-Conquer
O(N log N) FFT / O(N log² N) CDQ

Inspect the Radix-2 Cooley-Tukey FFT butterfly y_k = E_k + ω_n^k O_k, Karatsuba 3-multiplication algebraic identity, and CDQ Divide-and-Conquer left-to-right contribution transfer.

Step 1 / 7
Custom Input:
Timeline

Invariant & State

Halving Lemma: Because ω_n^(k + n/2) = −ω_n^k and (ω_n^k)² = ω_{n/2}^k, a single product t = ω_n^k · O_k yields both y_k = E_k + t and y_{k+n/2} = E_k − t.
E_k O_k × ω_n^k y_k = E_k + ω_n^k·O_k y_{k+n/2} = E_k − ω_n^k·O_k
Butterfly Operations
1 Complex Multiply + 2 Adds
Stages / Width
log₂(N) stages × (N/2) butterflies
Polynomial Multiply
O(N log N) (vs O(N²) naive)
[Step 1] Combined even DFT E_k and twiddle-multiplied odd DFT ω_n^k·O_k across Radix-2 butterfly.
Transform / Convolution Method Time Complexity Arithmetic Domain Numerical Precision / Constraints
Direct Convolution / Karatsuba $\Theta(N^2)$ / $\Theta(N^{1.585})$ Exact Integers / Reals Exact; slow for $N > 5000$
Cooley-Tukey Radix-2 FFT $\Theta(N \log N)$ Complex double ($mathbb{C}$) ~53-bit mantissa; rounding error for coefficients $> 10^{14}$
Number-Theoretic Transform (NTT) $\Theta(N \log N)$ $\mathbb{Z}_p$ ($p = 998244353 = 119 \cdot 2^{23} + 1$) 100% Exact Modular Arithmetic (Primitive Root $g = 3$)
Halving & Reflection Lemmas (The Cooley-Tukey Butterfly)

For any even $N$ and $k \in [0, N/2)$:

  1. Halving Lemma: $\left(\omega_N^k\right)^2 = \left(\omega_N^{k + N/2}\right)^2 = e^{2\pi i k / (N/2)} = \omega_{N/2}^k$. Squaring all $N$ roots of unity collapses them into the $N/2$-th roots of unity twice over.
  2. Reflection Lemma: $\omega_N^{k + N/2} = -\omega_N^k$.

Combining these gives the Radix-2 Butterfly Operation that computes both $y_k$ and $y_{k + N/2}$ from $u = y^{[\text{even}]}_k$ and $v = \omega_N^k \cdot y^{[\text{odd}]}_k$ in $O(1)$:

$$y_k = u + v, \qquad y_{k + N/2} = u - v$$

And because the Vandermonde matrix $V_N[j, k] = \omega_N^{jk}$ satisfies $V_N^{-1}[j, k] = \frac{1}{N} \omega_N^{-jk}$, the Inverse FFT (IFFT) uses the exact same algorithm with $\omega_N^{-1} = e^{-2\pi i / N}$ followed by dividing each output by $N$!

8th Roots of Unity on the Unit Circle & The Radix-2 Butterfly Operation ω^0 = 1 ω^1 ω^2 = i ω^4 = -1 ω^5 = -ω^1 Cooley-Tukey Radix-2 Butterfly u = A_even(ω_N^{2k}) v = ω_N^k · A_odd(ω_N^{2k}) y_k = u + v y_{k+N/2} = u - v
flowchart LR A["Coeffs A(x), B(x) Zero-Pad to 2^k >= deg(A)+deg(B)+1"] --> B["Forward Iterative FFT Bit-Reversal + log N Butterfly Stages O(N log N)"] B --> C["Pointwise Product C(ω^k) = A(ω^k) * B(ω^k) O(N)"] C --> D["Inverse FFT (IFFT) Conjugate Roots ω^{-k} and Divide by N O(N log N)"]
Hardware Reality: Bit-Reversal In-Place Iterative FFT vs. Recursive Out-of-Place

A recursive FFT allocates temporary arrays at every level to separate even indices ($0, 2, 4, 6$) and odd indices ($1, 3, 5, 7$). Observe what happens to the binary representation of index $i$ as we repeatedly partition by parity (least significant bit first, then second bit, etc.): an index $i$ ends up at the position given by reversing its $\log_2 N$ binary bits! By permuting the array in-place via a single $O(N)$ bit-reversal pass upfront, we can execute all $\log_2 N$ butterfly stages purely in-place and bottom-up ($len = 2, 4, 8, \dots, N$) with zero heap allocations and sequential SIMD memory strides.

Production C++20 Implementation: Iterative In-Place FFT & Polynomial Convolution

#include <algorithm>
#include <cmath>
#include <complex>
#include <cstddef>
#include <cstdint>
#include <numbers>
#include <vector>

using cd = std::complex<double>;

// In-place Iterative Radix-2 Cooley-Tukey FFT (invert = false) and IFFT (invert = true)
void fft_inplace(std::vector<cd>& a, bool invert) noexcept {
    const std::size_t n = a.size();

    // 1. O(N) In-place Bit-Reversal Permutation
    for (std::size_t i = 1, j = 0; i < n; ++i) {
        std::size_t bit = n >> 1;
        for (; j & bit; bit >>= 1) {
            j ^= bit;
        }
        j ^= bit;
        if (i < j) std::swap(a[i], a[j]);
    }

    // 2. Bottom-Up Butterfly Stages: len = 2, 4, 8, ..., n
    for (std::size_t len = 2; len <= n; len <<= 1) {
        const double ang = 2.0 * std::numbers::pi / static_cast<double>(len) * (invert ? -1.0 : 1.0);
        const cd wlen(std::cos(ang), std::sin(ang));
        const std::size_t half = len >> 1;

        for (std::size_t i = 0; i < n; i += len) {
            cd w(1.0, 0.0);
            for (std::size_t j = 0; j < half; ++j) {
                const cd u = a[i + j];
                const cd v = a[i + j + half] * w;
                a[i + j]        = u + v;
                a[i + j + half] = u - v;
                w *= wlen;
            }
        }
    }

    // 3. Normalize by 1/N for Inverse FFT
    if (invert) {
        const double inv_n = 1.0 / static_cast<double>(n);
        for (cd& x : a) x *= inv_n;
    }
}

// Convolves two integer polynomials A and B in O(N log N) time
std::vector<std::int64_t> multiply_polynomials_fft(const std::vector<std::int64_t>& A,
                                                   const std::vector<std::int64_t>& B) {
    if (A.empty() || B.empty()) return {};
    const std::size_t result_len = A.size() + B.size() - 1;
    std::size_t n = 1;
    while (n < result_len) n <<= 1;

    std::vector<cd> fa(n, cd(0.0, 0.0)), fb(n, cd(0.0, 0.0));
    for (std::size_t i = 0; i < A.size(); ++i) fa[i] = static_cast<double>(A[i]);
    for (std::size_t i = 0; i < B.size(); ++i) fb[i] = static_cast<double>(B[i]);

    fft_inplace(fa, false);
    fft_inplace(fb, false);
    for (std::size_t i = 0; i < n; ++i) {
        fa[i] *= fb[i];
    }
    fft_inplace(fa, true);

    std::vector<std::int64_t> result(result_len);
    for (std::size_t i = 0; i < result_len; ++i) {
        result[i] = static_cast<std::int64_t>(std::llround(fa[i].real()));
    }
    return result;
}
Circular Wrap-Around & Floating-Point Twiddle Drift

Two bugs frequently corrupt FFT convolutions:

  1. Under-padding (Circular Convolution): Multiplying degree-$d_1$ and degree-$d_2$ polynomials produces degree $d_1 + d_2$ ($d_1 + d_2 + 1$ coefficients). If you only pad $N$ to $\max(d_1, d_2)$ instead of the next power of two $\ge d_1 + d_2 + 1$, terms with $k \ge N$ alias mod $N$ ($x^N \equiv 1$) and corrupt the low coefficients!
  2. Twiddle Factor Drift & Integer Overflow: Repeatedly multiplying w *= wlen over $N = 2^{20}$ iterations accumulates IEEE-754 rounding error. For exact integer convolutions where coefficients exceed $2^{53} \approx 9 \times 10^{15}$, use the Number-Theoretic Transform (NTT) modulo a prime $p = c \cdot 2^k + 1$ (such as $998244353 = 119 \cdot 2^{23} + 1$, where $3^{(p-1)/N} \bmod p$ serves as an exact integer $N$-th root of unity).
Check yourself Question 1 of 4
Concept Check Why Complex Roots of Unity Collapse Polynomial Evaluation to O(N log N) recall

To evaluate a degree-(N-1) polynomial A(x) = A_even(x^2) + x * A_odd(x^2) at N points in O(N log N) time, we need the set of N evaluation points to shrink to N/2 distinct points when squared, recursively across all log_2 N levels.

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

Why does standard `std::complex` FFT produce wrong answers here, and why does the prime p = 998,244,353 = 119 * 2^{23} + 1 allow an exact integer Number-Theoretic Transform (NTT)?

Key Takeaways

Multiplying polynomials of degree < n takes O(n^2) in coefficient form (convolution) but O(n) in point-value form (pointwise product).
Splitting A(x) = A_even(x^2) + x * A_odd(x^2) and evaluating at complex N-th roots of unity collapses the evaluation points in half at every level via (omega_N^k)^2 = omega_{N/2}^k.
The Inverse FFT uses the conjugate roots omega_N^{-k} and scales by 1/N because the Vandermonde Fourier matrix is unitary up to scale N.
Bit-reversal permutation orders the leaves upfront so all log_2 N butterfly stages run bottom-up and in-place with zero extra memory allocations.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue