【算法笔记】快速 Fourier 变换(FFT)
- 本文总计约 20000 字,阅读大约需要 60 分钟。
- 警告!警告!警告!本文有大量的 公式渲染,可能会导致加载异常缓慢!
前言
我终于学会 FFT 了 QwQ!虽然我非常的菜,但是还是辛苦地把这些令人费解的知识给理解下来了,太难了~
或许初学 FFT 的人都会有和我一样的感觉:这在说的是啥?什么叫 DFT?蝴蝶变换又是什么奇葩的东西?Vandermonde 矩阵又是什么?
但是当我们逐渐平静下来,再来仔细地研究这个算法时,就会发现,FFT 真的是很美的一个算法。当我们看见单位根带入了奇偶分组的多项式时,仿佛真的看见了一个由系数组成的蝴蝶在翩翩起舞;当一个多项式被从中间一分为二的时候,我们更能感受到分治地美感之所在。
前人的智慧,总是会让我们叹服叫绝。但我们又应该如何去应用呢?如何把这些智慧内化于心,并且改进出更好的理论呢?我们又不能不感到任重而道远。
正好最近在研读《论语》,用其中的一言以蔽:这不正是做学问做应该有的,『仰之弥高,钻之弥坚。瞻之在前,忽焉在后』的灵魂吗?
大概,科学真正的内涵,就寓于这十六个字里吧。
题目引入
- 给定两个正整数 ,求 的值。其中 。
『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
- 给定两个正整数 ,求 的值。其中 。
『嗯……看样子不能直接 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
- 给定两个正整数 ,求 的值。其中 。
『……』
如果像解决 那样写简单的高精,那么时间复杂度为 ,其中 代表 位数的几何平均值。在 都有 位的时候,再用 的乘法就会 TLE 了。
那么,有没有更高效的,像 时间复杂度的乘法算法呢?
前置知识
学习 FFT 的前置知识非常~非常~得多 QwQ,主要包含下面的这几条:
- 多项式的系数表示
- 多项式的点值表示
- 复数的乘法与单位根
如果您已经保证了上面的这些知识都已经掌握了,那么请您跳过这一段吧 QwQ~
多项式
我们在初中的时候就已经接触过了多项式相关的问题,但是我还是应该给出多项式的定义,形如: 的式子,我们称为多项式。其中的 为 的 次项系数, 为多项式的常数。
我们定义两个多项式 与 的卷积为 ,如果有 ,,那么我们有 。其中,。
实际上,两个多项式的卷积就是它们的乘积。例如 ,那么就有 。
点值表示法
我们平时接触的多项式,都是用上面的,类似于 的形式表示的。像这样,用 的 个系数来表示 的表示方法,叫做系数表示法。
假如我们向这个多项式中代入 个值,记做 ,我们就可以得到 个值,记作 。
由 Lagrange 插值定理,这 个点 ,可以唯一确定一个多项式 。
那么,用这 个点来表示一个多项式的表示方法,叫做点值表示法。
如果用点值表示法来表示两个最高次数均为 的多项式 的话,我们分别代入 个值 (为什么要代入 个值呢 QwQ?留给读者思考吧),那么 和 都可以被表示为:
如何求这两个多项式的卷积呢?我们可以直接拿这 个点的数值一一对应相乘,像这样:
这样求卷积的时间复杂度为 。
『哇,那么就是说求多项式的卷积的时间复杂度被优化到线性的了吗?』
错!因为我们对于每个多项式 ,至少要代入 个点才能将其转化为可以用于求卷积的点值表示法。而对每个点来说,我们可以应用秦九韶算法在 内求单个点的点值,这个过程,也就是『系数转点值』总复杂度达到了 。
而且,就算我们真的求出了 的结果,也是用点值表示法表示的,但我们平时使用的都是系数表示法。想象一下,如果我问你 的结果,你会回答结果是 吗?
我们当然可以用 Gauss 消元法把点值在转化为系数表示,但它的时间复杂度为 ,还不如直接求卷积呢!
『呃……』
复数
虽然直接用点值表示法来计算多项式卷积看上去效率并不高,但是实际上,它为我们提供了该选择代入哪些值的自由。也就是说,如果我们选得点足够好,可能会让点值法求卷积的效率发生质的飞跃。
我们考虑引入复数的概念。
定义虚数单位 ,那么形如 的数,我们称之为复数,其中的 称作复数 的实部, 称作复数 的虚部。
由所有复数组成的集合记为 ,即 。
复数的四则运算
与实数的加减法类似,我们可以定义复数的四则运算:对于任意的两个复数 ,我们定义:
复数的坐标表示
既然复数有实部和虚部两个参数,那么我们就可以用这两个参数作为一个点的横纵坐标,在平面直角坐标系中表示出任意一个复数了。
换言之,对于复数 ,我们可以用平面中的一个点 来代表复数 。如下图,点 就代表了复数 。
我们记坐标原点为 ,有向线段 的长度记为 , 与坐标系中横轴正半轴的夹角记为 ,那么对于复数 ,都有转换关系: 。
我们定义上述描述中的 为复数 的模长, 为复数 的辐角。显然,。
复数的三角形式
对于复数 ,设 的模长和辐角分别为 , 的模长和辐角为 ,那么就有
像这样表示复数的方式,称为复数的三角形式。
对于上面的两个复数 ,我们可以计算出来:
从这里,我们也可以知道,对于任意的两个复数,将它们的乘的结果,即为两复数的辐角相加,模长相乘。
当然,伟大的数学家 也给出了大名鼎鼎的 Euler 定理,即:
这就让我们能够用更简便的方式来证明上面的结论:若 ,那么 当然这不重要啦 QwQ。
单位根
根据 Gauss 代数基本定理, 次方程 在复数域内有且仅有 个根,通过上面的结论,我们不妨设 ,那么一定有:,其中 ,我们取 时的解,记做 ,显然,这 个根分别可以记做 ,这 个复数,我们定义为 次单位根。
让我们更直观地看一下,这是 次单位根 在平面直角坐标系上的表示:
它们均匀地分布在单位圆上,并且将单位圆 等分。
显然,单位根有如下的两个性质(划重点!后面要用到):
- ,这个性质又被称为消去引理;
- ,这个性质又被称为折半引理。
这样的性质,使得单位根有成为了点值表示法的点值的优势。为什么这么说?请往下看。
快速 Fourier 变换(FFT)
从 DFT 变换谈起
对于多项式 ,我们考虑分别代入 个 次单位根,即 ,那么我们就可以获得 个点:
这个代入的名称叫做离散 Fourier 变换(Discrete Fourier Transform,DFT)。尽管这个过程并不是那么令人惊讶,当然,朴素 DFT 的时间复杂度依旧是 。
但是,如果我们把 DFT 的过程做一个小小的变换,那么就可以得到很大的优化。
快速 Fourier 变换的思想
前方高能,系好安全带 QwQ!
我们假定 是一个偶数,对于多项式 ,那么我们可以按照次数的奇偶性,把系数分组,如下:
我们不妨设 ,那么
假如我们代入 ,则有:;
假如我们代入 ,则有:。
由消去引理,则有 ;
由折半引理,则有
我们注意到上面这两个式子中,只有 的系数的符号有所不同。所以,我们带入 的值,也就是一半的点值,就能够得到所有的点值了。比起朴素的 DFT,问题的规模便减少了一半!
既然 都是多项式,我们就可以像上面那样分治地完成这样一个过程。这时,时间复杂度为 ,解得 。这样,多项式的乘法就比暴力的 的乘法高效了很多!因此,这个操作又被称为快速 Fourier 变换(Fast Fourier Transform,FFT)。
怎么转回系数表示法呢?
怎么转回系数表示?
我们不妨定义多项式的系数组成了一个列向量,如下:
又有 Vandermonde 矩阵:
可知:
经计算(虽然我太蒻了 QwQ,不会算)得:
那么,就可以得知:
当然,如果上面的东西你看不懂,那就直接记结论吧:把 个点值分别记作 新多项式系数,代入 再做一次 FFT,得到新的 个点值,把这些点值都除以 ,就得到了原多项式的各项系数。
上面的这步操作,叫做快速 Fourier 逆变换(Inverse Fast Fourier Transform,IFFT)。时间复杂度嘛,自然与 FFT 是一样的,也是 。
所以,我们是可以做到在 的复杂度内完成求多项式的卷积的。
代码
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 或许非常快,但还不够快。
这是我在洛谷上提交 P3803【模板】多项式乘法(FFT) 的提交记录,我们可以发现,我们的代码在开 的情况下,最慢也已经跑到了 以上,虽然本题的时限开到了 ,还是可以 AC 的,但是如果真的在实战中,很有可能会被卡常。
因为在递归的过程中,会有大量的函数入栈出栈的过程。众所周知,函数信息的入栈和出栈会造成大量的时间浪费,所以,把 FFT 函数设法变得非递归,可以产生非常有效的优化。
怎么样才能做到呢?
我们观察一下被转化为点值表示的多项式,这里以 时为例:。
把它的系数写成一排,为:;
第一次奇偶分组后,变为:;
第二次奇偶分组后,变为:;
第三次奇偶分组后,变为:。
我们再观察一下分组之后,每个系数的下标的规律:
分组 | 第 项下标 | 第 项下标 | 第 项下标 | 第 项下标 | 第 项下标 | 第 项下标 | 第 项下标 | 第 项下标 |
---|---|---|---|---|---|---|---|---|
分组前 | ||||||||
分组后 |
发现什么规律没?
没错!分组后,每个系数的下标均为分组前系数下标的二进制反转!这是更直观的图解。
『好漂亮!就跟蝴蝶的翅膀一样呢!』
所以,这样的变换,名字叫做蝴蝶变换。
我们可以事先预处理出所有下标的二进制反转,然后套一层循环就可以了。这样,就没必要进行递归了。
代码如下:
#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
这份代码在洛谷的模板题上能够达到最慢 的好成绩。
应用
FFT 的用途可多了去了 QwQ,几乎所有的学习多项式的 OIer,都是从 FFT 开始学习的。
当然,FFT 可能会因为卡精度而 WA 掉,所以从 FFT 又衍生出了快速数论变换(Number Theory Transform,NTT)。然后又衍生出了类如拆系数 FFT,任意模数 NTT(MTT)等黑科技,虽然我都不会 QwQ。
当然也可以用来加速高精度乘法 QwQ。
除此以外,FFT 在真正的计算机工程中也有大量的应用,从神经网络,到信号处理,应用非常广泛。
因此,学习 FFT 是一件很有用的事!
例题
本题目列表会持续更新。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】