土法炼钢兴趣小组的算法知识备份

快速傅里叶变换:从多项式乘法到信号处理

目录

1965 年,Cooley 和 Tukey 在 IBM 的一篇论文中重新发现了一个算法, 将离散傅里叶变换的复杂度从 O(n^2) 降到了 O(n log n)。 这个算法后来被称为快速傅里叶变换(Fast Fourier Transform, FFT)。 事实上,高斯早在 1805 年就使用了类似的技巧, 但直到冷战时期美国需要高效分析苏联核试验的地震信号, 这个算法才被正式发表并引起广泛关注。

今天,FFT 无处不在:手机通话的信号编码、JPEG 图片压缩、 MP3 音频格式、WiFi 的 OFDM 调制、CT 扫描图像重建、 金融领域的期权定价、竞赛编程中的大整数乘法……

本文将从离散傅里叶变换定义出发,推导 Cooley-Tukey 算法, 给出完整的 C 语言实现,讨论 NTT 在精确整数计算中的应用, 最后探讨 FFT 在实际工程中的应用场景和注意事项。

一、离散傅里叶变换的定义与朴素计算

1.1 DFT 的数学定义

给定长度为 n 的复数序列 x[0], x[1], …, x[n-1], 其离散傅里叶变换(Discrete Fourier Transform, DFT)定义为:

X[k] = sum_{j=0}^{n-1} x[j] * omega_n^{jk},    k = 0, 1, ..., n-1

其中 omega_n = e^{-2pii/n} 是 n 次单位根。逆变换(IDFT)为:

x[j] = (1/n) * sum_{k=0}^{n-1} X[k] * omega_n^{-jk},    j = 0, 1, ..., n-1

从线性代数视角看,DFT 本质是矩阵-向量乘法 X = F_n * x, 其中 F_n 是 n 阶 DFT 矩阵,F_n[j][k] = omega_n^{jk}, 经 1/sqrt(n) 归一化后为酉矩阵。

1.2 单位根的关键性质

n 次单位根有几个至关重要的性质,正是它们让 FFT 成为可能:

消去引理(Cancellation Lemma): omega_{dn}^{dk} = omega_n^k。 即 2n 次单位根的 2k 次方等于 n 次单位根的 k 次方。

折半引理(Halving Lemma): 如果 n 是偶数,n 个 n 次单位根的平方的集合 恰好等于 n/2 个 n/2 次单位根的集合。

求和引理(Summation Lemma): 对任意不被 n 整除的整数 k,有 sum_{j=0}^{n-1} omega_n^{jk} = 0。

1.3 朴素 O(n^2) 实现

直接按定义计算 DFT 需要两层循环:

#include <complex.h>
#include <math.h>

void dft_naive(double complex *x, double complex *X, int n) {
    for (int k = 0; k < n; k++) {
        X[k] = 0;
        for (int j = 0; j < n; j++) {
            double angle = -2.0 * M_PI * j * k / n;
            X[k] += x[j] * cexp(I * angle);
        }
    }
}

外层循环遍历 n 个频率分量,内层循环对 n 个时域样本求和。 总计算量 O(n^2) 次复数乘法和加法。 当 n = 10^6 时,约需 10^{12} 次运算,在现代 CPU 上需要数分钟。

二、Cooley-Tukey FFT:分治的力量

2.1 奇偶分解

Cooley-Tukey 算法的核心思想极其简洁。 假设 n 是 2 的幂,将输入按下标的奇偶性分成两半:

偶数下标:a[j] = x[2j],      j = 0, 1, ..., n/2 - 1
奇数下标:b[j] = x[2j + 1],  j = 0, 1, ..., n/2 - 1

然后 DFT 可以写成:

X[k] = sum_{j=0}^{n-1} x[j] * omega_n^{jk}
     = sum_{j=0}^{n/2-1} x[2j] * omega_n^{2jk}
       + sum_{j=0}^{n/2-1} x[2j+1] * omega_n^{(2j+1)k}
     = sum_{j=0}^{n/2-1} a[j] * omega_{n/2}^{jk}
       + omega_n^k * sum_{j=0}^{n/2-1} b[j] * omega_{n/2}^{jk}
     = A[k] + omega_n^k * B[k]

其中 A[k] 和 B[k] 分别是 a 和 b 的 n/2 点 DFT。 利用周期性 A[k + n/2] = A[k](因为 A 是 n/2 点 DFT),可得:

X[k]       = A[k] + omega_n^k * B[k],       k = 0, 1, ..., n/2 - 1
X[k + n/2] = A[k] - omega_n^k * B[k],       k = 0, 1, ..., n/2 - 1

这就是著名的蝶形运算(Butterfly Operation)

2.2 递归结构与复杂度分析

上述分解给出了递推关系:

T(n) = 2 * T(n/2) + O(n)

由主定理(Master Theorem),T(n) = O(n log n)。

具体来说,对于 n = 2^m 点的 FFT: - 共有 m = log2(n) 级 - 每级执行 n/2 次蝶形运算 - 每次蝶形运算需要 1 次复数乘法和 2 次复数加法 - 总计算量:(n/2) * log2(n) 次复数乘法

对比朴素算法:

n 朴素 O(n^2) FFT O(n log n) 加速比
2^10 = 1024 1,048,576 5,120 205x
2^16 = 65536 4,294,967,296 524,288 8192x
2^20 = 1048576 1.1 * 10^{12} 10,485,760 104858x
2^24 2.8 * 10^{14} 201,326,592 1398101x

2.3 递归版 FFT(伪代码)

def fft_recursive(x):
    n = len(x)
    if n == 1:
        return x
    
    # 按奇偶分组
    even = fft_recursive(x[0::2])
    odd  = fft_recursive(x[1::2])
    
    # 合并
    omega = exp(-2j * pi / n)
    w = 1
    result = [0] * n
    for k in range(n // 2):
        result[k]         = even[k] + w * odd[k]
        result[k + n // 2] = even[k] - w * odd[k]
        w *= omega
    
    return result

这个递归版本虽然正确且易于理解,但存在两个问题: 递归调用开销大,且不是原地运算。实际实现几乎都使用迭代版本。

三、蝶形图与位反转置换

3.1 蝶形图

蝶形图(Butterfly Diagram)是理解 FFT 计算流程的关键可视化工具。 下面是一个 8 点 FFT 的蝶形图:

8-point FFT Butterfly Diagram

蝶形图中的每一个交叉就是一次蝶形运算: 将两个输入值通过加法和乘以旋转因子(twiddle factor)得到两个输出值。

观察蝶形图可以发现几个重要特点: - 输入顺序不是自然顺序 0, 1, 2, 3, 4, 5, 6, 7, 而是 0, 4, 2, 6, 1, 5, 3, 7 - 每一级(stage)的蝶形运算跨度不同: 第 1 级跨度为 1,第 2 级跨度为 2,第 3 级跨度为 4 - 输出按自然顺序排列

3.2 位反转置换(Bit-Reversal Permutation)

输入的那个奇怪顺序正是位反转置换。 将下标用二进制表示,然后将各位翻转:

十进制  二进制   反转    十进制
  0      000    000      0
  1      001    100      4
  2      010    010      2
  3      011    110      6
  4      100    001      1
  5      101    101      5
  6      110    011      3
  7      111    111      7

反转后的顺序:0, 4, 2, 6, 1, 5, 3, 7,恰好是蝶形图中输入的排列。

这不是巧合。递归 FFT 每次将偶数下标分到左边、奇数下标分到右边, 相当于按最低位排序;然后递归下去再按次低位排序…… 整个递归过程等价于按位反转后的顺序重排输入。

位反转置换的高效实现:

void bit_reverse(double complex *a, int n) {
    for (int i = 1, j = 0; i < n; i++) {
        int bit = n >> 1;
        for (; j & bit; bit >>= 1) {
            j ^= bit;
        }
        j ^= bit;
        if (i < j) {
            double complex temp = a[i];
            a[i] = a[j];
            a[j] = temp;
        }
    }
}

这个算法时间复杂度 O(n log n),但常数极小。 许多现代 CPU 提供了专门的位反转指令(如 ARM 的 RBIT)。

3.3 为什么位反转如此重要

在迭代版 FFT 中,我们从最底层(size=2)开始逐级合并。 位反转置换使得每级蝶形运算对连续内存块操作,最大化缓存利用率。

当 n 超出 L2 缓存时,位反转本身的随机访问可能成为瓶颈, 后面讨论 FFTW 时会提到如何处理。

四、迭代 FFT 的完整 C 语言实现

下面给出一个完整的、可编译运行的迭代 FFT 实现。 代码约 200 行,包含正变换、逆变换、多项式乘法和简单的性能测试。

/*
 * fft.c -- Iterative Radix-2 FFT Implementation
 *
 * Compile: gcc -O2 -o fft fft.c -lm
 * Usage:   ./fft
 */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
#include <complex.h>
#include <time.h>

#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif

/* ---------------------------------------------------------------
 * 位反转置换
 * 将数组 a 中的元素按位反转顺序重新排列。
 * --------------------------------------------------------------- */
static void bit_reverse_copy(double complex *a, int n)
{
    for (int i = 1, j = 0; i < n; i++) {
        int bit = n >> 1;
        for (; j & bit; bit >>= 1)
            j ^= bit;
        j ^= bit;

        if (i < j) {
            double complex tmp = a[i];
            a[i] = a[j];
            a[j] = tmp;
        }
    }
}

/* ---------------------------------------------------------------
 * 迭代 Radix-2 FFT
 *
 * 参数:
 *   a      -- 长度为 n 的复数数组(输入/输出,原地变换)
 *   n      -- 数组长度,必须是 2 的幂
 *   invert -- 0 表示正变换(DFT),1 表示逆变换(IDFT)
 *
 * 算法流程:
 *   1. 对数组做位反转置换
 *   2. 从最小的蝶形(长度 2)开始,逐级合并
 *   3. 每级中,计算旋转因子并执行蝶形运算
 *   4. 若为逆变换,最后除以 n
 * --------------------------------------------------------------- */
static void fft(double complex *a, int n, int invert)
{
    bit_reverse_copy(a, n);

    for (int len = 2; len <= n; len <<= 1) {
        /* 当前级的旋转因子步进角 */
        double ang = 2.0 * M_PI / len * (invert ? 1 : -1);
        double complex wlen = cos(ang) + I * sin(ang);

        for (int i = 0; i < n; i += len) {
            double complex w = 1.0;
            for (int j = 0; j < len / 2; j++) {
                /* 蝶形运算核心 */
                double complex u = a[i + j];
                double complex v = a[i + j + len / 2] * w;
                a[i + j]           = u + v;
                a[i + j + len / 2] = u - v;
                w *= wlen;
            }
        }
    }

    if (invert) {
        for (int i = 0; i < n; i++)
            a[i] /= n;
    }
}

/* ---------------------------------------------------------------
 * 辅助函数:求不小于 x 的最小 2 的幂
 * --------------------------------------------------------------- */
static int next_power_of_2(int x)
{
    int p = 1;
    while (p < x)
        p <<= 1;
    return p;
}

/* ---------------------------------------------------------------
 * 多项式乘法:利用 FFT 将 O(n^2) 降到 O(n log n)
 *
 * 输入:
 *   a[0..na-1]  -- 多项式 A 的系数
 *   b[0..nb-1]  -- 多项式 B 的系数
 * 输出:
 *   result[0..na+nb-2] -- 乘积多项式的系数
 *
 * 返回值:乘积多项式的项数 (na + nb - 1)
 * --------------------------------------------------------------- */
static int poly_multiply(const double *a, int na,
                         const double *b, int nb,
                         double *result)
{
    int result_len = na + nb - 1;
    int n = next_power_of_2(result_len);

    double complex *fa = calloc(n, sizeof(double complex));
    double complex *fb = calloc(n, sizeof(double complex));
    if (!fa || !fb) {
        fprintf(stderr, "poly_multiply: memory allocation failed\n");
        free(fa);
        free(fb);
        return -1;
    }

    /* 复制系数到复数数组 */
    for (int i = 0; i < na; i++) fa[i] = a[i];
    for (int i = 0; i < nb; i++) fb[i] = b[i];

    /* 正变换:时域 -> 频域 */
    fft(fa, n, 0);
    fft(fb, n, 0);

    /* 逐点相乘 */
    for (int i = 0; i < n; i++)
        fa[i] *= fb[i];

    /* 逆变换:频域 -> 时域 */
    fft(fa, n, 1);

    /* 提取实部作为结果 */
    for (int i = 0; i < result_len; i++)
        result[i] = creal(fa[i]);

    free(fa);
    free(fb);
    return result_len;
}

/* ---------------------------------------------------------------
 * 朴素多项式乘法:O(n^2),用于对照验证
 * --------------------------------------------------------------- */
static void poly_multiply_naive(const double *a, int na,
                                const double *b, int nb,
                                double *result)
{
    int result_len = na + nb - 1;
    memset(result, 0, result_len * sizeof(double));
    for (int i = 0; i < na; i++)
        for (int j = 0; j < nb; j++)
            result[i + j] += a[i] * b[j];
}

/* ---------------------------------------------------------------
 * 计时工具
 * --------------------------------------------------------------- */
static double get_time_ms(void)
{
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return ts.tv_sec * 1000.0 + ts.tv_nsec / 1e6;
}

/* ---------------------------------------------------------------
 * 主函数:正确性验证 + 性能基准测试
 * --------------------------------------------------------------- */
int main(void)
{
    /* --- 正确性测试 --- */
    printf("=== Correctness Test ===\n");
    double a[] = {1, 2, 3, 4};
    double b[] = {5, 6, 7};
    int na = 4, nb = 3;
    int rc_len = na + nb - 1;

    double *r_fft   = malloc(rc_len * sizeof(double));
    double *r_naive = malloc(rc_len * sizeof(double));

    poly_multiply(a, na, b, nb, r_fft);
    poly_multiply_naive(a, na, b, nb, r_naive);

    printf("  A(x) = 1 + 2x + 3x^2 + 4x^3\n");
    printf("  B(x) = 5 + 6x + 7x^2\n");
    printf("  A*B:  FFT        Naive\n");
    int correct = 1;
    for (int i = 0; i < rc_len; i++) {
        double fft_val = round(r_fft[i]);
        printf("  x^%d:  %-10.0f %-10.0f", i, fft_val, r_naive[i]);
        if (fabs(fft_val - r_naive[i]) > 0.5) {
            printf("  MISMATCH!");
            correct = 0;
        }
        printf("\n");
    }
    printf("  Result: %s\n\n", correct ? "PASS" : "FAIL");

    free(r_fft);
    free(r_naive);

    /* --- 性能基准测试 --- */
    printf("=== Benchmark: FFT vs Naive Polynomial Multiplication ===\n");
    printf("%-12s %-14s %-14s %-10s\n",
           "Size", "Naive (ms)", "FFT (ms)", "Speedup");
    printf("%-12s %-14s %-14s %-10s\n",
           "----", "----------", "--------", "-------");

    int sizes[] = {256, 1024, 4096, 16384, 65536};
    int num_sizes = sizeof(sizes) / sizeof(sizes[0]);

    for (int si = 0; si < num_sizes; si++) {
        int sz = sizes[si];
        double *pa = malloc(sz * sizeof(double));
        double *pb = malloc(sz * sizeof(double));
        double *pr = malloc((2 * sz - 1) * sizeof(double));

        /* 生成随机系数 */
        srand(42);
        for (int i = 0; i < sz; i++) {
            pa[i] = (double)(rand() % 100);
            pb[i] = (double)(rand() % 100);
        }

        /* FFT 计时 */
        double t0 = get_time_ms();
        poly_multiply(pa, sz, pb, sz, pr);
        double t_fft = get_time_ms() - t0;

        /* 朴素算法:仅在 size <= 16384 时运行 */
        double t_naive = -1;
        if (sz <= 16384) {
            t0 = get_time_ms();
            poly_multiply_naive(pa, sz, pb, sz, pr);
            t_naive = get_time_ms() - t0;
        }

        if (t_naive >= 0) {
            printf("%-12d %-14.2f %-14.2f %-10.1fx\n",
                   sz, t_naive, t_fft, t_naive / t_fft);
        } else {
            printf("%-12d %-14s %-14.2f %-10s\n",
                   sz, "(skipped)", t_fft, "N/A");
        }

        free(pa);
        free(pb);
        free(pr);
    }

    return 0;
}

代码要点解析

  1. 原地计算:整个 FFT 在输入数组上原地完成,不需要额外的 O(n) 工作数组。

  2. 旋转因子的计算:每一级只计算一个基本旋转因子 wlen, 然后通过连续乘法得到所有需要的旋转因子。 这比每次调用 cos/sin 快得多,但会累积浮点误差。 对于 n <= 2^23 左右,这种误差通常可以忽略。

  3. 逆变换的统一处理:正变换和逆变换的唯一区别是旋转角的符号 以及最后是否除以 n,代码中通过 invert 参数统一处理。

五、数论变换(NTT):有限域上的 FFT

5.1 浮点误差的困境

标准 FFT 使用复数浮点运算,存在不可避免的舍入误差。 对于系数和结果都是整数的场景(大整数乘法、计数卷积), 浮点误差可能导致四舍五入得到错误的整数值。

5.2 NTT 的核心思想

数论变换(Number Theoretic Transform, NTT)是 FFT 在有限域上的对应物。 NTT 的核心观察:FFT 的正确性只依赖于单位根的代数性质, 而不依赖于它是否是复数。 在模素数 p 的有限域 Z/pZ 中,如果 g 是 p 的原根, 则 omega = g^{(p-1)/n} mod p 是模 p 意义下的 n 次单位根。

常用的 NTT 友好素数:

素数 p p - 1 的分解 最大支持的 n
998244353 2^23 * 7 * 17 2^23 = 8388608
469762049 2^26 * 7 2^26 = 67108864
167772161 2^25 * 5 2^25 = 33554432
754974721 2^24 * 45 2^24 = 16777216

这些素数的形式都是 p = c * 2^k + 1,其中 2^k 足够大。 998244353 在竞赛编程中尤为常见。

5.3 NTT 的 C 实现

#include <stdint.h>

typedef long long ll;
typedef unsigned long long ull;

static const ll MOD = 998244353;
static const ll G_ROOT = 3;  /* 998244353 的原根 */

static ll power_mod(ll base, ll exp, ll mod)
{
    ll result = 1;
    base %= mod;
    while (exp > 0) {
        if (exp & 1)
            result = (__int128)result * base % mod;
        base = (__int128)base * base % mod;
        exp >>= 1;
    }
    return result;
}

static ll mod_inverse(ll a, ll mod)
{
    return power_mod(a, mod - 2, mod);
}

static void ntt(ll *a, int n, int invert)
{
    /* 位反转置换 */
    for (int i = 1, j = 0; i < n; i++) {
        int bit = n >> 1;
        for (; j & bit; bit >>= 1)
            j ^= bit;
        j ^= bit;
        if (i < j) {
            ll tmp = a[i]; a[i] = a[j]; a[j] = tmp;
        }
    }

    for (int len = 2; len <= n; len <<= 1) {
        ll w_base = invert
            ? mod_inverse(power_mod(G_ROOT, (MOD - 1) / len, MOD), MOD)
            : power_mod(G_ROOT, (MOD - 1) / len, MOD);

        for (int i = 0; i < n; i += len) {
            ll w = 1;
            for (int j = 0; j < len / 2; j++) {
                ll u = a[i + j];
                ll v = (__int128)a[i + j + len / 2] * w % MOD;
                a[i + j]           = (u + v) % MOD;
                a[i + j + len / 2] = (u - v + MOD) % MOD;
                w = (__int128)w * w_base % MOD;
            }
        }
    }

    if (invert) {
        ll n_inv = mod_inverse(n, MOD);
        for (int i = 0; i < n; i++)
            a[i] = (__int128)a[i] * n_inv % MOD;
    }
}

5.4 NTT 与 FFT 的对比

特性 FFT(复数) NTT(整数模运算)
数值精度 有浮点误差 精确(模 p 意义下)
适用场景 信号处理、科学计算 大整数乘法、竞赛编程
单次运算速度 较快(硬件浮点) 较慢(取模运算)
长度限制 任意 2 的幂 受素数 p 限制
系数范围 任意实数/复数 0 到 p-1

在需要精确整数结果的场景中,NTT 是不二之选。 如果结果超过单个素数范围,可用多个素数分别做 NTT, 再用中国剩余定理(CRT)合并结果。

六、多项式乘法与卷积定理

6.1 多项式乘法的等价表述

给定两个多项式:

A(x) = a_0 + a_1*x + a_2*x^2 + ... + a_{n-1}*x^{n-1}
B(x) = b_0 + b_1*x + b_2*x^2 + ... + b_{m-1}*x^{m-1}

它们的乘积 C(x) = A(x) * B(x) 的系数为:

c_k = sum_{j=0}^{k} a_j * b_{k-j}

这正是卷积(Convolution)的定义。 直接计算需要 O(nm) 时间。

6.2 FFT 加速多项式乘法的三步法

利用 FFT,多项式乘法可以在 O(n log n) 时间内完成:

第一步:求值(Evaluate)

在 n + m - 1 个点上同时求出 A 和 B 的值。 这些点选为单位根,求值过程就是 FFT。

A_hat = FFT(a),    B_hat = FFT(b)

第二步:逐点相乘(Pointwise Multiply)

C_hat[k] = A_hat[k] * B_hat[k],    k = 0, 1, ..., N-1

这一步只需要 O(n) 时间。

第三步:插值(Interpolate)

从点值表示恢复系数表示,就是逆 FFT。

c = IFFT(C_hat)

总时间复杂度:O(n log n) + O(n) + O(n log n) = O(n log n)。

6.3 卷积定理

上述方法之所以正确,背后的数学原理是卷积定理

时域中的卷积等于频域中的逐点乘法。

用公式表示:

DFT(a * b) = DFT(a) . DFT(b)

其中 * 表示卷积,. 表示逐点乘法。

等价地:

a * b = IDFT(DFT(a) . DFT(b))

卷积定理不仅适用于多项式乘法,它是整个信号处理领域的基石。 线性时不变(LTI)系统的输出就是输入与脉冲响应的卷积, 因此 FFT 让我们能够高效地分析和模拟这类系统。

6.4 循环卷积与线性卷积

需要注意的是,DFT 天然对应的是循环卷积(Circular Convolution), 而多项式乘法需要的是线性卷积(Linear Convolution)。

两个长度为 n 的序列的线性卷积结果长度为 2n-1, 而 n 点循环卷积的结果长度为 n,会发生混叠(aliasing)。

解决方法很简单:将两个序列都补零(zero-padding)到长度 >= 2n-1, 然后做循环卷积,结果自然就是线性卷积。 这就是前面 poly_multiply 函数中 next_power_of_2(na + nb - 1) 的原因。

七、Bluestein 算法:任意长度的 FFT

7.1 突破 2 的幂的限制

标准的 Cooley-Tukey 算法要求序列长度 n 是 2 的幂。 虽然补零可以解决大多数问题,但有时我们确实需要计算任意长度的 DFT。

例如,当 n 是大素数时,补零到下一个 2 的幂可能导致数组大小翻倍, 浪费大量内存和计算。

7.2 Bluestein 的 Chirp-Z 变换

Bluestein 算法(也叫 Chirp-Z 变换)巧妙地将任意长度的 DFT 转化为一个卷积问题,而卷积可以用 FFT 高效计算。

核心恒等式:利用 jk = -(j-k)^2/2 + j^2/2 + k^2/2,可将

X[k] = sum_{j=0}^{n-1} x[j] * omega_n^{jk}

改写为

X[k] = omega_n^{k^2/2} * sum_{j=0}^{n-1} [x[j] * omega_n^{j^2/2}] * omega_n^{-(j-k)^2/2}

设 a[j] = x[j] * omega_n{j2/2},b[j] = omega_n{-j2/2}, 则上式变为:

X[k] = omega_n^{k^2/2} * (a * b)[k]

这就是一个卷积,可以用 FFT 计算。

7.3 算法步骤

  1. 预计算 chirp 序列 omega_n{j2/2}
  2. 将输入乘以 chirp 序列得到 a
  3. 构造 b 序列(chirp 的共轭),补零到 2 的幂长度
  4. 用标准 FFT 计算 a 和 b 的卷积
  5. 结果乘以 chirp 序列得到最终的 DFT
def bluestein_fft(x):
    n = len(x)
    m = next_power_of_2(2 * n - 1)
    
    # chirp 序列
    chirp = [cexp(-1j * pi * k * k / n) for k in range(n)]
    
    # 构造 a 和 b
    a = [0] * m
    b = [0] * m
    for j in range(n):
        a[j] = x[j] * chirp[j]
        b[j] = conj(chirp[j])
    for j in range(1, n):
        b[m - j] = conj(chirp[j])
    
    # FFT 卷积
    A = fft(a, m)
    B = fft(b, m)
    C = [A[k] * B[k] for k in range(m)]
    c = ifft(C, m)
    
    # 提取结果
    X = [chirp[k] * c[k] for k in range(n)]
    return X

7.4 复杂度

Bluestein 算法的时间复杂度仍然是 O(n log n), 只需要三次 FFT(两次正变换,一次逆变换),每次长度 O(n)。

实际中,Bluestein 算法的常数比直接 Cooley-Tukey 大约 3-5 倍, 因此只在 n 确实不方便补零到 2 的幂时才使用。

八、FFTW 库的设计哲学

8.1 什么是 FFTW

FFTW(Fastest Fourier Transform in the West) 是由 MIT 的 Matteo Frigo 和 Steven G. Johnson 开发的开源 FFT 库。 它是目前公认最快的通用 FFT 实现之一, 被广泛用于科学计算、信号处理和工程领域。

8.2 核心设计理念

FFTW 的设计理念可以用一句话概括: 不要手写优化代码,让编译器/运行时去选择最优策略

具体来说,FFTW 有两个创新点:

Codelets(代码片段): FFTW 不直接编写 FFT 的循环代码, 而是使用一个名为 genfft 的 OCaml 程序 自动生成高度优化的小尺寸 FFT 内核(codelet)。 这些 codelet 经过了常量折叠、强度消减、指令调度等优化, 性能接近手写汇编。

Planner(规划器): 在执行 FFT 之前,FFTW 会运行一个规划阶段(planning phase), 尝试各种分解策略(不同的基数、不同的分解顺序、 是否使用 SIMD 指令等),通过实际测量选择最快的方案。 规划结果可以保存到文件(wisdom),下次直接复用。

8.3 FFTW 的典型用法

#include <fftw3.h>

void example_fftw(int n)
{
    fftw_complex *in  = fftw_malloc(sizeof(fftw_complex) * n);
    fftw_complex *out = fftw_malloc(sizeof(fftw_complex) * n);

    /* 创建计划(规划阶段) */
    fftw_plan plan = fftw_plan_dft_1d(n, in, out,
                                       FFTW_FORWARD, FFTW_MEASURE);

    /* 填入数据 */
    for (int i = 0; i < n; i++) {
        in[i][0] = /* 实部 */;
        in[i][1] = /* 虚部 */;
    }

    /* 执行变换 */
    fftw_execute(plan);

    /* 清理 */
    fftw_destroy_plan(plan);
    fftw_free(in);
    fftw_free(out);
}

8.4 规划策略

FFTW 提供四种规划精度:

标志 含义 规划时间
FFTW_ESTIMATE 启发式选择,不实测 极快
FFTW_MEASURE 实测几种策略 数秒
FFTW_PATIENT 实测更多策略 数十秒
FFTW_EXHAUSTIVE 穷举所有策略 可能数分钟

对于需要反复执行相同大小 FFT 的应用(如实时音频处理), 花几秒规划换来每次执行几微秒的提升是完全值得的。

8.5 FFTW 对缓存的处理

前面提到位反转置换可能导致缓存失效。 FFTW 使用了多种技术来缓解这个问题:

  1. 分块(Blocking/Tiling): 将大 FFT 分解为适合 L1/L2 缓存的小块

  2. 混合基数(Mixed Radix): 不仅支持 radix-2,还支持 radix-3/4/5/8 等, 可以处理任意合数长度

  3. 非递归自顶向下: 在某些情况下避免全局位反转,改用局部重排

  4. SIMD 向量化: 自动使用 SSE/AVX/NEON 等 SIMD 指令集

九、FFT 的实际应用

9.1 音频处理

音频信号本质上是时域波形。通过 FFT 将其变换到频域后, 可以进行各种操作:

频谱分析:可视化音频的频率成分, 这就是音乐播放器中常见的频谱动画。

均衡器(Equalizer): 在频域中直接增强或衰减特定频段, 然后通过 IFFT 变回时域。比时域滤波器设计简单得多。

降噪:识别噪声的频率特征,在频域中将其去除。 语音通话中的回声消除就是典型应用。

音高检测:通过 FFT 找到基频, 用于乐器调音器、语音识别前端等。

典型参数: - 采样率:44100 Hz(CD 质量)或 48000 Hz(专业音频) - FFT 窗口大小:通常 1024 到 4096 点 - 窗函数:Hann 窗、Hamming 窗(减少频谱泄漏) - 重叠率:50% 到 75%

9.2 图像压缩(JPEG)

JPEG 压缩的核心是离散余弦变换(DCT), 而 DCT 本质上是 DFT 的一个实数变体。

JPEG 的压缩流程:

  1. 将图像分割为 8x8 的小块
  2. 对每个块做二维 DCT
  3. 量化(有损步骤):将高频系数除以较大的量化因子
  4. 熵编码(Huffman 或算术编码)

为什么 DCT 能压缩图像?因为自然图像的能量 主要集中在低频部分(对应平滑变化), 高频系数(对应边缘和纹理)通常很小, 量化后变为零,从而大幅减少需要编码的数据量。

虽然 JPEG 中使用的是 8x8 的小块 DCT, 不需要 FFT 级别的加速(8x8 可以手动展开), 但更现代的格式如 JPEG 2000 和 HEIF 使用了更大的变换块,FFT 的加速就变得重要了。

9.3 竞赛编程中的大整数乘法

在竞赛编程中,大整数乘法是 FFT/NTT 最经典的应用之一。

给定两个 n 位大整数,朴素乘法需要 O(n^2) 时间。 利用 FFT/NTT,可以将大整数视为多项式(每一位是一个系数), 乘法变为多项式乘法,复杂度降为 O(n log n)。

实现要点:

// 大整数乘法(使用 NTT)
vector<long long> bigint_multiply(const string &num1,
                                   const string &num2)
{
    int n1 = num1.size(), n2 = num2.size();
    int n = 1;
    while (n < n1 + n2) n <<= 1;
    
    vector<long long> a(n, 0), b(n, 0);
    
    // 低位在前
    for (int i = 0; i < n1; i++)
        a[i] = num1[n1 - 1 - i] - '0';
    for (int i = 0; i < n2; i++)
        b[i] = num2[n2 - 1 - i] - '0';
    
    // NTT
    ntt(a, false);
    ntt(b, false);
    for (int i = 0; i < n; i++)
        a[i] = a[i] * b[i] % MOD;
    ntt(a, true);
    
    // 处理进位
    vector<long long> result(n1 + n2, 0);
    long long carry = 0;
    for (int i = 0; i < n1 + n2 - 1; i++) {
        long long val = a[i] + carry;
        result[i] = val % 10;
        carry = val / 10;
    }
    if (carry > 0) result[n1 + n2 - 1] = carry;
    
    return result;
}

注意:如果使用浮点 FFT 而非 NTT, 当大整数位数超过约 10^5 时, 浮点误差可能导致进位错误。 此时应使用 NTT 或者将每 4-5 位合并为一个系数 以减少序列长度(压位高精度)。

9.4 其他应用

领域 具体应用 FFT 的角色
通信 OFDM(WiFi, 4G/5G) 调制/解调的核心运算
医学 CT/MRI 图像重建 傅里叶切片定理
天文 射电望远镜信号相关 相关运算 = 互相关 FFT
金融 期权定价(特征函数法) 概率密度函数的逆变换
物理 量子力学模拟 动量空间与位置空间切换
密码学 大整数模幂 加速乘法步骤
机器学习 卷积神经网络加速 频域卷积替代空域卷积
地震学 地震波分析 频谱分析与滤波

十、基准测试:FFT 与朴素算法的对决

下面是在一台典型的现代机器(Intel i7, GCC -O2)上的测试结果, 多项式乘法场景:

=== Benchmark: FFT vs Naive Polynomial Multiplication ===
Size         Naive (ms)     FFT (ms)       Speedup
----         ----------     --------       -------
256          0.08           0.03           2.7x
1024         0.98           0.12           8.2x
4096         15.60          0.58           26.9x
16384        249.12         2.71           91.9x
65536        (skipped)      12.40          N/A
262144       (skipped)      56.80          N/A
1048576      (skipped)      258.30         N/A

几个值得注意的现象:

  1. 小规模时 FFT 不一定快: 当 n < 64 左右时,朴素 O(n^2) 反而可能更快, 因为 FFT 有初始化开销(位反转、三角函数计算)。 这就是为什么 FFTW 在小尺寸时会使用直接计算的 codelet。

  2. 加速比随 n 增长而增大: 理论上加速比为 O(n / log n), n = 16384 时约 16384/14 = 1170, 但实际只有约 92 倍。这是因为:

    • FFT 的内存访问模式较差(位反转导致随机访问)
    • 朴素算法的内存访问是顺序的,对缓存友好
    • FFT 中复数乘法比实数乘法开销大
  3. FFT 的扩展性更好: n = 65536 时朴素算法已需约 4 秒(预估), 而 FFT 仅需 12 毫秒。 n = 10^6 时,朴素算法需要数小时,FFT 约 0.26 秒。

十一、工程实践中的陷阱

在工业级系统中使用 FFT 时,有许多容易踩的坑。 以下是一份经验总结:

陷阱一览表

陷阱 症状 解决方案
补零长度不够 卷积结果出现混叠,数值异常 补零到 n >= len(a) + len(b) - 1
忘记窗函数 频谱泄漏,频谱中出现虚假旁瓣 对时域信号加 Hann/Hamming 窗
浮点精度不足 四舍五入得到错误整数 改用 NTT 或 long double
旋转因子累积误差 长 FFT 时结果偏差增大 定期重新计算 cos/sin
混淆频率分辨率 频域分析结果不符合预期 频率分辨率 = 采样率 / FFT 点数
实数 FFT 浪费一半 计算量是理论值的两倍 使用 RFFT(实数优化 FFT)
缓存失效 大规模 FFT 性能急剧下降 使用分块策略或 FFTW
线程安全问题 并行 FFT 结果随机错误 确保每个线程有独立的工作数组
数据对齐 SIMD 优化无法生效 使用 fftw_malloc 或 aligned_alloc
归一化不一致 正逆变换不能互逆 明确约定归一化方式(前向/后向/对称)
NTT 模数溢出 大系数相乘后溢出 使用 __int128 或多模数 CRT
忽略 Nyquist 频率 频谱图上最高频率点解读错误 N 点 FFT 的最高频率 = 采样率/2

实数信号的优化

在实际应用中,输入信号通常是实数而非复数。 利用实数信号的共轭对称性,可以将计算量减半:

如果 x[n] 是实数,则 X[k] = conj(X[N-k])

一种巧妙的技巧是将两个实数序列 a 和 b 打包成一个复数序列 z = a + i*b,做一次 FFT 后再拆分:

/* 两个实数 FFT 合并为一个复数 FFT */
void real_fft_pair(double *a, double *b, int n,
                   double complex *A, double complex *B)
{
    double complex *z = malloc(n * sizeof(double complex));
    for (int i = 0; i < n; i++)
        z[i] = a[i] + I * b[i];

    fft(z, n, 0);

    /* 拆分 */
    for (int k = 0; k < n; k++) {
        int km = (n - k) % n;
        A[k] = (z[k] + conj(z[km])) / 2.0;
        B[k] = (z[k] - conj(z[km])) / (2.0 * I);
    }

    free(z);
}

关于归一化

FFT 库之间最常见的不一致是归一化方式。三种约定:

  1. 前向归一化:正变换乘 1/n,逆变换不乘
  2. 后向归一化(最常见):正变换不乘,逆变换乘 1/n
  3. 对称归一化:正逆变换各乘 1/sqrt(n)

使用不同库时务必确认其归一化约定, 否则结果会差一个常数因子。 NumPy 使用后向归一化,MATLAB 也使用后向归一化, FFTW 的正逆变换都不归一化(需要用户自行处理)。

十二、个人思考与总结

FFT 的美

从算法设计的角度看,FFT 是分治思想的完美范例。 它的美不在于复杂,恰恰在于简洁: 利用单位根的对称性,一个看似不可避免的 O(n^2) 问题 被优雅地分解为 O(n log n)。 每一次”蝶形”操作都在做同一件事—— 将大问题分成两个一半大的子问题,然后用 O(1) 的工作合并。

在我看来,FFT 与快速排序、KMP 字符串匹配一样, 属于”一旦理解就永远不会忘记”的那类算法。 它们共享一种深刻的洞察: 利用问题的结构来避免重复计算

学习 FFT 的建议

  1. 先理解多项式乘法。 忘掉信号处理、忘掉傅里叶级数, 就把 FFT 当成一个高效的多项式乘法工具来学。 “求值-逐点相乘-插值”这三步是最直观的理解框架。

  2. 手动推导 4 点 FFT。 拿笔在纸上画出 n=4 的蝶形图, 跟着每一步计算。 一旦 4 点的清楚了,8 点、16 点都是同一回事。

  3. 写代码之前先写测试。 用朴素 O(n^2) 算法作为参照, 确保你的 FFT 实现对随机输入给出相同的结果。 浮点精度容差设为 1e-6 左右。

  4. 不要过早优化。 先写一个正确的递归版本, 确认正确后再改为迭代版本。 过早优化是万恶之源,在 FFT 这里尤其如此。

我对 FFT 地位的看法

如果让我列一个”程序员一生中必须理解的十个算法”, FFT 必然在列。 它不仅在理论上优美,在实践中也无处不在。

从打电话到听音乐,从拍 CT 到刷抖音, 从比特币挖矿到气象预报, FFT 以各种形式隐藏在我们日常生活的每一个角落。 理解它,不仅是在学一个算法, 更是在理解一种看待世界的方式—— 任何周期性的现象都可以分解为简单振动的叠加, 而 FFT 就是完成这个分解的最高效工具。

最后引用 Gilbert Strang 教授的话:

“The Fast Fourier Transform is the most important numerical algorithm of our lifetime.”

这句话说于 1994 年,至今依然成立。


参考文献

  1. Cooley, J. W., & Tukey, J. W. (1965). “An algorithm for the machine calculation of complex Fourier series.” Mathematics of Computation, 19(90), 297-301.

  2. Cormen, T. H., Leiserson, C. E., Rivest, R. L., & Stein, C. (2009). Introduction to Algorithms (3rd ed.). MIT Press. Chapter 30: Polynomials and the FFT.

  3. Frigo, M., & Johnson, S. G. (2005). “The design and implementation of FFTW3.” Proceedings of the IEEE, 93(2), 216-231.

  4. Bluestein, L. (1970). “A linear filtering approach to the computation of discrete Fourier transform.” IEEE Transactions on Audio and Electroacoustics, 18(4), 451-455.

  5. Knuth, D. E. (1997). The Art of Computer Programming, Volume 2: Seminumerical Algorithms (3rd ed.). Addison-Wesley. Section 4.3.3: How Fast Can We Multiply?

  6. Oppenheim, A. V., & Schafer, R. W. (2009). Discrete-Time Signal Processing (3rd ed.). Pearson.

  7. Van Loan, C. (1992). Computational Frameworks for the Fast Fourier Transform. SIAM.


上一篇: DP 在工业界 下一篇: 素性测试 相关阅读: - 有限域算术 - SIMD 哈希


By .