【算法笔记】快速 Fourier 变换(FFT)

  • 本文总计约 20000 字,阅读大约需要 60 分钟
  • 警告!警告!警告!本文有大量的 LATEX 公式渲染,可能会导致加载异常缓慢!

前言

我终于学会 FFT 了 QwQ!虽然我非常的菜,但是还是辛苦地把这些令人费解的知识给理解下来了,太难了~

或许初学 FFT 的人都会有和我一样的感觉:这在说的是啥?什么叫 DFT?蝴蝶变换又是什么奇葩的东西?Vandermonde 矩阵又是什么?

但是当我们逐渐平静下来,再来仔细地研究这个算法时,就会发现,FFT 真的是很美的一个算法。当我们看见单位根带入了奇偶分组的多项式时,仿佛真的看见了一个由系数组成的蝴蝶在翩翩起舞;当一个多项式被从中间一分为二的时候,我们更能感受到分治地美感之所在。

前人的智慧,总是会让我们叹服叫绝。但我们又应该如何去应用呢?如何把这些智慧内化于心,并且改进出更好的理论呢?我们又不能不感到任重而道远。

正好最近在研读《论语》,用其中的一言以蔽:这不正是做学问做应该有的,『仰之弥高,钻之弥坚。瞻之在前,忽焉在后』的灵魂吗?

大概,科学真正的内涵,就寓于这十六个字里吧。

题目引入

  • Take 1: 给定两个正整数 a,b,求 a×b 的值。其中 1a,b109
    『CaO 你这不是糊弄人吗,A*B Problem 还用得着你来讲吗?』
#include <cstdio>
using namespace std;

long long a, b;
int main(void) {
	scanf("%lld%lld", &a, &b);
	printf("%lld", a * b);
	return 0;
}
//by CaO
  • Take 2: 给定两个正整数 a,b,求 a×b 的值。其中 1a,b101000
    『嗯……看样子不能直接 A*B 了,写个高精罢。』
    『然而我会用 Python(逃)』
#include <iostream>
#include <cstring>
using namespace std;
const int MAXN = 10001;

char A[MAXN], B[MAXN];
int n, m, high;
int a[MAXN], b[MAXN], ans[MAXN];

int main(void) {
    cin >> A >> B;

    n = strlen(A), m = strlen(B);

    for(int i = 0; A[i]; ++i) a[i] = A[n - 1 - i] - '0';
    for(int i = 0; B[i]; ++i) b[i] = B[m - 1 - i] - '0';

    for(int i = 0; i < n; ++i) {
        for(int j = 0; j < m; ++j) {
            ans[i + j] += a[i] * b[j];
            ans[i + j + 1] += ans[i + j] / 10;
            ans[i + j] %= 10;
        }
    }

    high = 10000;
    while(!ans[high] && high) --high;
    while(high >= 0) cout << ans[high--];
    return 0;
}
//by CaO
  • Take 3: 给定两个正整数 a,b,求 a×b 的值。其中 1a,b1010000000
    『……』

如果像解决 Take 2 那样写简单的高精,那么时间复杂度为 Θ(n2),其中 n 代表 a,b 位数的几何平均值。在 a,b 都有 107 位的时候,再用 Θ(n2) 的乘法就会 TLE 了。

那么,有没有更高效的,像 Θ(nlogn) 时间复杂度的乘法算法呢?

前置知识

学习 FFT 的前置知识非常~非常~得多 QwQ,主要包含下面的这几条:

  • 多项式的系数表示
  • 多项式的点值表示
  • 复数的乘法与单位根

如果您已经保证了上面的这些知识都已经掌握了,那么请您跳过这一段吧 QwQ~

多项式

我们在初中的时候就已经接触过了多项式相关的问题,但是我还是应该给出多项式的定义,形如:F(x)=i=0naixi=a0+a1x+a2x2++anxn 的式子,我们称为多项式。其中的 ai(i=1,2,,n)xi 次项系数a0 为多项式的常数

我们定义两个多项式 F(X)G(X)卷积(FG)(x),如果有 F(x)=i=0naixiG(x)=i=0mbixi,那么我们有 (FG)(x)=i=0n+mcixi。其中,ci=j=0najbij

实际上,两个多项式的卷积就是它们的乘积。例如 F(x)=x+1,G(x)=x23x+1,那么就有 (FG)(x)=F(x)G(x)=x32x22x+1

点值表示法

我们平时接触的多项式,都是用上面的,类似于 F(x)=a0+a1x+a2x2++anxn 的形式表示的。像这样,用 F(x)(n1) 个系数来表示 F(x) 的表示方法,叫做系数表示法

假如我们向这个多项式中代入 (n+1) 个值,记做 x0,x1,,xn,我们就可以得到 (n+1) 个值,记作 F(x0),F(x1),F(x2),,F(xn)

由 Lagrange 插值定理,这 (n+1) 个点 (x0,F(x0)),(x1,F(x1)),(x2,F(x2)),,(xn,F(xn)),可以唯一确定一个多项式 F(x)=i=0naixi

那么,用这 (n+1) 个点来表示一个多项式的表示方法,叫做点值表示法

如果用点值表示法来表示两个最高次数均为 n 的多项式 F(x),G(x) 的话,我们分别代入 (2n+1) 个值 x0,x2,x2,,x2n(为什么要代入 (2n+1) 个值呢 QwQ?留给读者思考吧),那么F(x)G(x) 都可以被表示为:

{F(x)=(x0,F(x0)),(x1,F(x1)),,(x2n,F(x2n));G(x)=(x0,G(x0)),(x1,G(x1)),,(x2n,G(x2n)).

如何求这两个多项式的卷积呢?我们可以直接拿这 (2n+1) 个点的数值一一对应相乘,像这样:

(FG)(x)=(x0,F(x0)G(x0)),(x1,F(x1)G(x1)),,(x2n,F(x2n)G(x2n)).

这样求卷积的时间复杂度为 Θ(n)

『哇,那么就是说求多项式的卷积的时间复杂度被优化到线性的了吗?』

错!因为我们对于每个多项式 F(x),至少要代入 (2n+1) 个点才能将其转化为可以用于求卷积的点值表示法。而对每个点来说,我们可以应用秦九韶算法Θ(n) 内求单个点的点值,这个过程,也就是『系数转点值』总复杂度达到了 Θ(n2)

而且,就算我们真的求出了 (FG)(x) 的结果,也是用点值表示法表示的,但我们平时使用的都是系数表示法。想象一下,如果我问你 F(x)=(x+1)(x+2) 的结果,你会回答结果是 ((1,0),(0,2),(1,6)) 吗?

我们当然可以用 Gauss 消元法把点值在转化为系数表示,但它的时间复杂度为 O(n3),还不如直接求卷积呢!

『呃……』

复数

虽然直接用点值表示法来计算多项式卷积看上去效率并不高,但是实际上,它为我们提供了该选择代入哪些值的自由。也就是说,如果我们选得点足够好,可能会让点值法求卷积的效率发生质的飞跃。

我们考虑引入复数的概念。

定义虚数单位 i2=1,那么形如 z=a+bi 的数,我们称之为复数,其中的 a 称作复数 z实部b 称作复数 z虚部

由所有复数组成的集合记为 C,即 C={a+bi|a,bR}

复数的四则运算

与实数的加减法类似,我们可以定义复数的四则运算:对于任意的两个复数 z1=a+bi,z2=c+di,我们定义:

z1±z2=(a+bi)±(c+di)=(a±c)+(b±d)i,z1z2=(a+bi)(c+di)=(acbd)+(bc+ad)i,z1z2=a+bic+di=(a+bi)(cdi)(c+di)(cdi)=(ac+bd)+(bcad)ic2+d2.

复数的坐标表示

既然复数有实部虚部两个参数,那么我们就可以用这两个参数作为一个点的横纵坐标,在平面直角坐标系中表示出任意一个复数了。

换言之,对于复数 z=x+yi,我们可以用平面中的一个点 (x,y) 来代表复数 z。如下图,点 A(3,2) 就代表了复数 z=3+2i
image

我们记坐标原点为 O,有向线段 |OA| 的长度记为 rOA 与坐标系中横轴正半轴的夹角记为 θ,那么对于复数 z=x+yi,都有转换关系: {r=x2+y2,x=rcosθ,y=rsinθ

我们定义上述描述中的 r 为复数 z模长θ 为复数 z辐角。显然,r0,θ[0,2π)

复数的三角形式

对于复数 z1,z2,设 z1 的模长和辐角分别为 r1,αz2 的模长和辐角为 r2,β,那么就有

{z1=r1cosα+ir1sinα=r1(cosα+isinα),z2=r2cosβ+ir2sinβ=r2(cosβ+isinβ).

像这样表示复数的方式,称为复数的三角形式

对于上面的两个复数 z1,z2,我们可以计算出来:

z1z2=r1(cosα+isinα)r2(cosβ+isinβ)=r1r2(cosα+isinα)(cosβ+isinβ)=r1r2[(cosαcosβsinαsinβ)+i(cosαsinβ+sinαcosβ)]=r1r2(cos(α+β)+isin(α+β))=r1r2cos(α+β)+ir1r2sin(α+β).

从这里,我们也可以知道,对于任意的两个复数,将它们的乘的结果,即为两复数的辐角相加,模长相乘

当然,伟大的数学家 Euler 也给出了大名鼎鼎的 Euler 定理,即:

eiθ=cosθ+isinθ,which e=limn+(1+1n)n.

这就让我们能够用更简便的方式来证明上面的结论:若 z1=r1eiα,z2=r2eiβ,那么 z1z2=r1r2ei(α+β) 当然这不重要啦 QwQ

单位根

根据 Gauss 代数基本定理,n 次方程 xn=1 在复数域内有且仅有 n 个根,通过上面的结论,我们不妨设 x=reiθ,那么一定有:rn=1,nθ=2kπ,其中 k=0,1,2,,n1,我们取 k=1 时的解,记做 ωn1=e2πin,显然,这 n 个根分别可以记做 ωn0,ωn1,ωnn1,这 n 个复数,我们定义为 n 次单位根

让我们更直观地看一下,这是 5 次单位根 ω50,ω51,ω52,ω53,ω54 在平面直角坐标系上的表示:
image

它们均匀地分布在单位圆上,并且将单位圆 5 等分。

显然,单位根有如下的两个性质(划重点!后面要用到):

  1. ω2nn+k=ω2nk,这个性质又被称为消去引理
  2. ω2n2k=ωnk,这个性质又被称为折半引理

这样的性质,使得单位根有成为了点值表示法的点值的优势。为什么这么说?请往下看。

快速 Fourier 变换(FFT)

从 DFT 变换谈起

对于多项式 F(x)=i=0n1aixi,我们考虑分别代入 nn 次单位根,即 ωn0,ωn1,,ωnn1,那么我们就可以获得 n 个点:

F(x)=((ωn0,F(ωn0)),(ωn1,F(ωn1)),,(ωnn1,F(ωnn1))).

这个代入的名称叫做离散 Fourier 变换(Discrete Fourier Transform,DFT)。尽管这个过程并不是那么令人惊讶,当然,朴素 DFT 的时间复杂度依旧是 Θ(n2)

但是,如果我们把 DFT 的过程做一个小小的变换,那么就可以得到很大的优化。

快速 Fourier 变换的思想

前方高能,系好安全带 QwQ!

我们假定 n 是一个偶数,对于多项式 F(x)=a0+a1x+a2x2++an1xn1,那么我们可以按照次数的奇偶性,把系数分组,如下:

F(x)=(a0+a2x2++an2xn2)+(a1x+a3x3++an1xn1)=(a0+a2x2++an2xn2)+x(a1+a3x2++an1xn2).

我们不妨设 A1(x)=a0+a2x++an2xn21,A2(x)=a1+a3x++an1xn21,那么

F(x)=A1(x2)+xA2(x2).

假如我们代入 ωnk(k=0,1,,n21),则有:F(ωnk)=A1(ωn2k)+ωnkA2(ωn2k)

假如我们代入 ωnk+n2(k=0,1,,n21),则有:F(ωnk+n2)=A1(ωn2k+n)+ωnk+n2A2(ωn2k+n)

由消去引理,则有 F(ωnk+n2)=A1(ωn2k)ωnkA2(ωn2k)
由折半引理,则有

{F(ωnk)=A1(ωn2k)+ωnkA2(ωn2k),F(ωnk+n2)=A1(ωn2k)ωnkA2(ωn2k).

我们注意到上面这两个式子中,只有 A2系数的符号有所不同。所以,我们带入 ωn0,ωn1,,ωnn21 的值,也就是一半的点值,就能够得到所有的点值了。比起朴素的 DFT,问题的规模便减少了一半!

既然 A1(x),A2(x) 都是多项式,我们就可以像上面那样分治地完成这样一个过程。这时,时间复杂度为 T(n)=2T(n2)+O(n),解得 T(n)=Θ(nlogn)。这样,多项式的乘法就比暴力的 Θ(n2) 的乘法高效了很多!因此,这个操作又被称为快速 Fourier 变换(Fast Fourier Transform,FFT)。

怎么转回系数表示法呢?

怎么转回系数表示?

我们不妨定义多项式的系数组成了一个列向量,如下:

A=[a0a1an1].

又有 Vandermonde 矩阵:

B=[ωn0ωn0ωn0ωn0ωn1ωnn1ωn0ωnn1ωn(n1)2].

可知:

A×B=[f(ωn0)f(ωn1)f(ωnn1)].

经计算(虽然我太蒻了 QwQ,不会算)得:

B1=1n[ωn0ωn0ωn0ωn0ωn1ωn(n1)ωn0ωn(n1)ωn(n1)2].

那么,就可以得知:

A=[f(ωn0)f(ωn1)f(ωnn1)]B1.

当然,如果上面的东西你看不懂,那就直接记结论吧:n 个点值分别记作 n 新多项式系数,代入 ωn0,ωn1,,ωn(n1) 再做一次 FFT,得到新的 n 个点值,把这些点值都除以 n,就得到了原多项式的各项系数

上面的这步操作,叫做快速 Fourier 逆变换(Inverse Fast Fourier Transform,IFFT)。时间复杂度嘛,自然与 FFT 是一样的,也是 Θ(nlogn)

所以,我们是可以做到在 Θ(nlogn) 的复杂度内完成求多项式的卷积的。

代码

C++ 自带了 <complex> 库,但是封装的 complex 类常数大,在实战的时候容易被卡常,不如自己手动重载的好。

这个是递归的 FFT 代码。

#include <cstdio>
#include <cmath>
#define reg register  //register,卡常技巧来一个 QwQ
using namespace std;
const int MAXN = 5000001;
const double PI = acos(-1.0);

inline long long read(void) {  //为了防卡常,打一个快读 QwQ
    long long s = 1, w = 0; char ch = getchar();

    while(ch < '0' || ch > '9') {if(ch == '-') s = -1; ch = getchar();}
    while(ch >= '0' && ch <= '9') {w = w * 10 + ch - '0'; ch = getchar();}

    return s * w;
}

int n, m, siz = 1;
struct Complex {  //手动重载 Complex 类
    double Re, Im;
    Complex(double R = 0.0, double I = 0.0) {Re = R, Im = I;}
} f[MAXN], g[MAXN];

Complex operator+ (Complex a, Complex b) {return Complex(a.Re + b.Re, a.Im + b.Im);}
Complex operator- (Complex a, Complex b) {return Complex(a.Re - b.Re, a.Im - b.Im);}
Complex operator* (Complex a, Complex b) {return Complex(a.Re * b.Re - a.Im * b.Im, a.Re * b.Im + a.Im * b.Re);}

void fft(Complex* arr, int siz, int tp) {  //tp 代表变换的类型,如果 tp = 1 代表 FFT,tp = -1 代表 IFFT
    if(siz == 1) return ;  //只有一个常数项时,递归达到出口

    Complex arr1[siz >> 1], arr2[siz >> 1];

    for(reg int i(0); i < siz; i += 2) {arr1[i >> 1] = arr[i], arr2[i >> 1] = arr[i + 1];}  //奇偶分组
    fft(arr1, siz >> 1, tp); fft(arr2, siz >> 1, tp);  //分治做 FFT

    Complex Wn = Complex(cos(2.0 * PI / siz), sin(2.0 * PI / siz) * tp), w = Complex(1, 0);  //用 Wn 代表单位根
    for(reg int i(0); i < (siz >> 1); ++i) {
        arr[i] = arr1[i] + w * arr2[i]; arr[i + (siz >> 1)] = arr1[i] - w * arr2[i];  //一步内转移出两个点值
        w = w * Wn;
    }
}

int main(void) {
    n = read(), m = read();

    while(siz <= n + m) siz <<= 1;  //一定要让多项式的最高次数可以表示为 2^{k}-1 的形式

    for(reg int i(0); i <= n; ++i) f[i].Re = read();
    for(reg int j(0); j <= m; ++j) g[j].Re = read();

    fft(f, siz, 1); fft(g, siz, 1);  //分别对 F(x) 和 G(x) 做一遍 FFT

    for(reg int i(0); i <= siz; ++i) f[i] = f[i] * g[i];  //卷起来 QwQ
    fft(f, siz, -1);  //IFFT 还原回去

    for(reg int i(0); i <= n + m; ++i) printf("%d ", int(f[i].Re / siz + 0.5));  //不要忘了除以 n

    return 0;
}
//by CaO

蝴蝶变换

上面的递归版 FFT 或许非常快,但还不够快。

image

这是我在洛谷上提交 P3803【模板】多项式乘法(FFT) 的提交记录,我们可以发现,我们的代码在开 O2 的情况下,最慢也已经跑到了 1000ms 以上,虽然本题的时限开到了 2000ms,还是可以 AC 的,但是如果真的在实战中,很有可能会被卡常。

因为在递归的过程中,会有大量的函数入栈出栈的过程。众所周知,函数信息的入栈和出栈会造成大量的时间浪费,所以,把 FFT 函数设法变得非递归,可以产生非常有效的优化。

怎么样才能做到呢?

我们观察一下被转化为点值表示的多项式,这里以 n=8 时为例:f(x)=a0+a1x+a2x2++a7x7

把它的系数写成一排,为:[a0a1a2a3a4a5a6a7]

第一次奇偶分组后,变为:[a0a2a4a6]  [a1a3a5a7]

第二次奇偶分组后,变为:[a0a4]  [a2a6]  [a1a5]  [a3a7]

第三次奇偶分组后,变为:[a0]  [a4]  [a2]  [a6]  [a1]  [a5]  [a3]  [a7]

我们再观察一下分组之后,每个系数的下标的规律:

分组 0 项下标 1 项下标 2 项下标 3 项下标 4 项下标 5 项下标 6 项下标 7 项下标
分组前 (000)2 (001)2 (010)2 (011)2 (100)2 (101)2 (110)2 (111)2
分组后 (000)2 (100)2 (010)2 (110)2 (001)2 (101)2 (011)2 (111)2

发现什么规律没?

没错!分组后,每个系数的下标均为分组前系数下标的二进制反转!这是更直观的图解。

image

『好漂亮!就跟蝴蝶的翅膀一样呢!』

所以,这样的变换,名字叫做蝴蝶变换

我们可以事先预处理出所有下标的二进制反转,然后套一层循环就可以了。这样,就没必要进行递归了。

代码如下:

#include <bits/stdc++.h>
#define reg register  //继续卡常 QwQ
using namespace std;
const int MAXN = 5000001;
const double PI = acos(-1.0);

inline long long read(void) {  //卡常 QwQ
    long long s = 1, w = 0; char ch = getchar();

    while(ch < '0' || ch > '9') {if(ch == '-') s = -1; ch = getchar();}
    while(ch >= '0' && ch <= '9') {w = w * 10 + ch - '0'; ch = getchar();}

    return s * w;
}

int n, m, siz = 1;
int bit, rev[MAXN];

struct Complex {  //手动重载 Complex 类
    double Re, Im;
    Complex(double R = 0.0, double I = 0.0) {Re = R, Im = I;}
} f[MAXN], g[MAXN];

Complex operator+ (Complex a, Complex b) {return Complex(a.Re + b.Re, a.Im + b.Im);}
Complex operator- (Complex a, Complex b) {return Complex(a.Re - b.Re, a.Im - b.Im);}
Complex operator* (Complex a, Complex b) {return Complex(a.Re * b.Re - a.Im * b.Im, a.Re * b.Im + a.Im * b.Re);}

void fft(Complex* arr, int tp) {  //tp 代表变换的类型,如果 tp = 1 代表 FFT,tp = -1 代表 IFFT
    for(reg int i(0); i < siz; ++i) {
        if(i < rev[i]) swap(arr[i], arr[rev[i]]);  //蝴蝶变换
    }

    for(reg int mid(1); mid < siz; mid <<= 1) {  //枚举每次合并区间的半长度
        Complex Wn = Complex(cos(PI / mid), tp * sin(PI / mid));

        for(reg int i(0); i < siz; i += (mid << 1)) {  //枚举每个区间的左端点
            Complex w = Complex(1, 0);
            for(reg int j(0); j < mid; ++j) {  //FFT 代值
                Complex A1 = arr[i + j], A2 = w * arr[i + j + mid];
                arr[i + j] = A1 + A2, arr[i + j + mid] = A1 - A2;
                w = w * Wn;
            }
        }
    }
}

int main(void) {
    n = read(), m = read();

    while(siz <= n + m) siz <<= 1, ++bit;  //一定要让多项式的最高次数可以表示为 2^{k}-1 的形式
    for(reg int i(0); i < siz; ++i) rev[i] = (rev[i >> 1] >> 1) | ((i & 1) << (bit - 1));  //预处理二进制反转,思考一下为什么这样能够行得通 QwQ

    for(reg int i(0); i <= n; ++i) f[i].Re = read();
    for(reg int j(0); j <= m; ++j) g[j].Re = read();

    fft(f, 1); fft(g, 1);  //分别对 F(x) 和 G(x) 做一遍 FFT

    for(reg int i(0); i <= siz; ++i) f[i] = f[i] * g[i];  //卷起来 QwQ
    fft(f, -1);  //IFFT 还原回去

    for(reg int i(0); i <= n + m; ++i) printf("%d ", int(f[i].Re / siz + 0.5));  //不要忘了除以 n

    return 0;
}
//by CaO

这份代码在洛谷的模板题上能够达到最慢 500ms 的好成绩。

应用

FFT 的用途可多了去了 QwQ,几乎所有的学习多项式的 OIer,都是从 FFT 开始学习的。

当然,FFT 可能会因为卡精度而 WA 掉,所以从 FFT 又衍生出了快速数论变换(Number Theory Transform,NTT)。然后又衍生出了类如拆系数 FFT,任意模数 NTT(MTT)等黑科技,虽然我都不会 QwQ。

当然也可以用来加速高精度乘法 QwQ。

除此以外,FFT 在真正的计算机工程中也有大量的应用,从神经网络,到信号处理,应用非常广泛。

因此,学习 FFT 是一件很有用的事!

例题

本题目列表会持续更新

posted @   CaO氧化钙  阅读(319)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】
点击右上角即可分享
微信分享提示