FFT(快速傅里叶变换)
简介
FFT 可以在 O(nlogn) 的时间内计算两个序列的卷积/循环卷积。
其最常见的应用是计算多项式乘法。
卷积&多项式乘法
两个数列 a 和 b 的卷积 c 定义为:
ck=i+j=k∑aibj
类似地定义循环卷积:
ck=(i+j)modn=k∑aibj
那么两个多项式 A(x)=i=0∑naixi 和 B(x)=i=0∑nbixi 的乘积定义为:
C(x)=A(x)⋅B(x)=i=0∑nj=0∑naibjxi+j=i=0∑2ncixi
其中,c 是 a 和 b 的卷积。因此多项式乘法本质上就是计算两序列的卷积/循环卷积。
如果直接暴力计算的话,不难发现复杂度是 O(n2) 的。
怎么优化这个 n2 的复杂度啊?
用点值表示法试试!
如果用点值表示法,我们发现,如果知道了 C(x)=A(x)B(x) 的足够多的点值,那么就可以计算出 C(x) 的各项系数。
如果直接算出这些点值,需要在 O(n2) 的时间>_<
所以我们优化了个寂寞啊
至少要低于 O(n2) 吧qwq......
这里我们考虑一组特殊的点值。
单位根
我们定义 −1=i,即 i2=−1。
方程 xn−1=0 的 n 个复根 x 被称为 n 次单位根,记作 ωn0,ωn1,ωn2,⋯,ωnn−1。
其中,ωnk 定义为 en2kπi=cosn2kπ+isinn2kπ。而 eiθ=cosθ+isinθ 就是欧拉公式,它的证明和 FFT 没啥关系,就不写啦>_<
例如,3 次单位根有:ω30=1,ω31=23i−1,ω32=2−3i−1。
我们需要用到单位根的这两个性质:
ωnk=ωndkd,d∈N(1)
事实上,ωndkd=cosnd2kdπ+isinnd2kdπ=cosn2kπ+isinn2kπ=ωnk,故 (1) 成立。
ω2nn+k=−ω2nk(2)
事实上,ω2nn+k=ω2nn⋅ω2nk=ω21⋅ω2nk=−ω2nk,故 (2) 成立。
离散傅里叶变换
设 a 是长为 n 的序列,对于 k∈[0,n),定义:
bk=i=0∑n−1aiωnki
那么我们称 b 为 a 的**「离散傅里叶变换」**,即 DFT ,记作 b=F(a) 。
如果我们将序列 a 与 b 的循环卷积记作 c=a∗b,那么不难发现:
F(c)=F(a∗b)=F(a)⋅F(b)
其中 ⋅ 表示逐点乘积。
如果我们令多项式 A(x)=∑aixi,不难发现 bk 就是 A(x) 在 ωnk 处的点值,即:A(ωnk)=bk。
也就是说,想要计算 a 的 DFT,那么我们就需要 A(x)=∑aixi 的 n 个点值。
同样地,只要我们知道了 a 的 DFT,我们同样可以利用这 n 个点值表示出 A(x),计算出 A(x) 的各项系数。
但是,如果朴素地计算 DFT 也就是这 n 个点值,那复杂度是 O(n2) 的>_<
于是就出现一个问题:如何快速计算一个序列 a 的 DFT 呢?
蝴蝶操作
我们考虑将多项式 A(x)=i=0∑n−1 的 n 项系数按奇偶性分类:
不妨设 n=2m,则
A(x)=i=0∑n−1aixi=i=0∑m−1a2ix2i+i=0∑m−1a2i+1x2i+1=i=0∑m−1a2ix2i+xi=0∑m−1a2i+1x2i
我们记
A0(x)=i=0∑m−1a2ixiA1(x)=i=0∑m−1a2i+1xi
那么就得到了两个次数不超过 m 的多项式 A0(x) 和 A1(x),并且有:
A(x)=A_0(x^2)+xA_1(x^2)\tag{#}
现在,如果我们有了 A0(ωmk) 和 A1(ωmk) 的值,带入 (#) 式,计算得:
A(ωnk)=A0(ωn2k)+ωnkA1(ωn2k)=A0(ωmk)+ωnkA1(ωmk)A(ωnk+m)=A0(ωn2k+n)+ωnk+mA1(ωn2k+n)=A0(ωmk)−ωnkA1(ωmk)
诶,只利用 A0(ωmk) 和 A1(ωmk) 的值,我们居然直接算出了 A(x) 的两个点值!
也就是说,只要我们有了 A0(x) 和 A1(x) 在 ωm0,ωm1,⋯,ωmm−1 处的点值,就可以在 O(n) 的时间内计算出 A(x) 在 ωn0,ωn1,⋯,ωnn−1 处的点值!
这,这不就是个递归的事嘛!
我们递归下去计算 A0(x) 和 A1(x) 的点值,每次将问题规模减半,合并子问题又是 O(n) 的,那么复杂度就是:
T(n)=2T(n/2)+O(n)=O(nlogn)
好耶!我们成功在 O(nlogn) 的时间内计算出了 A(x) 在 n 次单位根上的点值,也就是其系数数列 a 的 DFT !!
这个过程通常被称为**「蝴蝶操作」**。
现在我们就可以在 O(nlogn) 的时间内计算出 a 和 b 的 DFT。
再根据 F(a∗b)=F(a)⋅F(b),我们可以在 O(n) 的时间内计算出它们的卷积的 DFT。
但是我们要算的是 a∗b 而不是它的 DFT 啊=_=
如果把 DFT 看作点值,然后用拉格朗日插值,把 F(a∗b) 转化为 a∗b,那复杂度还是 O(n2) >_<
那现在的问题就是:我们知道了序列 c 的 DFT,如何在低于 O(n2) 的时间内计算出 c?
离散傅里叶变换的逆变换
对于长为 n 的序列 a,若其 DFT 为 b,即:
bk=i=0∑n−1aiωnki(1)
我们证明 (1) 的逆变换,即其 IDFT 为:
ak=n1i=0∑n−1biωn−ki(2)
我们将 (1) 中 bk 的表达式带入 (2),得到:
n1i=0∑n−1biωn−ki=n1i=0∑n−1ωn−kij=0∑n−1aiωnij
交换一下求和顺序(其实就是提取公因式),得到:
n1i=0∑n−1ωn−kij=0∑n−1ajωnij=n1j=0∑n−1aji=0∑n−1ωnij⋅ωn−ki
我们考虑后面的那个小可爱 i=0∑n−1ωnij⋅ωn−ki
当 j=k 时,有:
i=0∑n−1ωnij⋅ωn−ki=i=0∑n−1ωnik−ik=i=0∑n−11=n
当 j=k 时,则有:
i=0∑n−1ωnij⋅ωn−ki=i=0∑n−1ωni(j−k)
我们发现这东西是个类似于等比数列求和的形式,于是用一下等比数列求和公式:
i=0∑n−1ωni(j−k)=i=0∑n−1(ωj−k)i=1−ωnj−k1−(ωnj−k)n=1−ωnj−k1−(ωnn)j−k=1−ωnj−k1−1=0
于是只要 j=k,n1aji=0∑n−1ωnij⋅ωn−ki 整个就是个 0,不会对答案产生任何影响。
因此这个式子就被化简成了:
n1i=0∑n−1biωn−ki=n1j=0∑n−1aji=0∑n−1ωnij⋅ωn−ki=n1ak⋅n=ak
即
ak=n1i=0∑n−1biωn−ki(2)
证毕。
类似地,我们也可以证明 (1) 和 (2) 是等价的。
我们称 (2) 为**「离散傅里叶变换的逆变换」**,即 IDFT。如果朴素地按照表达式计算 IDFT,那么复杂度还是 O(n2) 的。
但实际上,观察 IDFT 的表达式,对比一下 DFT 的表达式:
bk=i=0∑n−1aiωnki(DFT)
ak=n1i=0∑n−1biωn−ki(IDFT)
实际上只需要将计算 DFT 时用到的「蝴蝶操作」中的 ωn 全部替换成 ωn−1 ,类似地递归下去,同样可以在 O(nlogn) 的时间内计算 IDFT。唯一不同的是算完之后需要乘上 n1 。
其实再多看两眼一下 IDFT 的表达式,可以发现只需要翻转一下 b 的后 n−1 项,再做一次 DFT 然后除一下,也相当于做完了 IDFT。
所以 IDFT 同样可以在 O(nlogn) 的时间内被计算。
总结
总结一下我们的 FFT 流程:
- 利用 DFT,在 O(nlogn) 的时间内分别计算出 a 和 b 的 DFT。
- 把算出来的出来的这两个 DFT 相乘,在 O(n) 的时间内得到 c 的 DFT。
- 利用 IDFT,在 O(nlogn) 的时间内,把 c 的 DFT 转化为 c ,就计算出了 a 和 b 的卷积。
综上,我们在 O(nlogn) 的时间内计算出了两个序列的卷积!
FFT 代码实现&注意事项&技巧(递归版)
typedef complex<double> comp;
#define pi acos(-1)//不要手打 pi QAQ,最好用 acos(-1)
void dft(comp *a,int p){
if(p==1)return;//递归边界
int q=p>>1;
comp a1[q],a2[q];
for(int i=0;i<q;i++){
a1[i]=a[i<<1];
a2[i]=a[(i<<1)|1];
}//按奇偶分类
dft(a1,q);dft(a2,q);//分治
for(int i=0;i<q;i++){
comp omega=comp(cos(2*pi*i/p),sin(2*pi*i/p));
a[i]=a1[i]+omega*a2[i];
a[i+q]=a1[i]-omega*a2[i];//照搬我们推出来的式子qwq
}
}
void idft(comp *a,int N){
dft(a,N);
reverse(a+1,a+N);
for(int i=0;i<=N;i++)a[i]=(a[i]+comp(0.5,0))/((double)N);//输出需要是整数
}
注意到如果我们按照上述的操作进行 FFT,只能处理长度为 2k 的数列的卷积,其中 k∈N∗。
因此,我们一般会选取一个最小的 2k≥n+m 作为序列长度,多出来的地方就看做 0,然后再进行 FFT。
所以,实际上,在计算卷积的时候,我们通常会:
int k=1;for(;k<=n+m;k<<=1);
然后再:
dft(x,k);
dft(y,k);
for(int i=0;i<=k;i++)z[i]=x[i]*y[i];
idft(z,k);
最后才是:
for(int i=0;i<=n+m;i++)cout<<(int)z[i].real()<<" ";
此时,由于我们寻找的是最小的 2k≥n+m ,因此数组并不能直接开成 106 这么大,而要开到最小的 2k≥106+106 ,大概是 221=2097152 qwq。
方便起见,开成 2100000 也是没有问题的QAQ。
整个代码十分简洁易懂(所以窝并没有加多少注释),就是把我们之前推导的式子照搬过来了qwq。
迭代实现 FFT
然而递归版常数大到爆炸,尤其是我们递归的时候居然还要递归一下开一遍内存,很容易空间爆炸>_<
(虽然递归版开 O2 就能直接艹过板子题)
而且,FFT 作为一个经常要使用的算法,如果它的常数过大导致整个程序时间爆炸是很不好的qwq。
能不能把 FFT 改写成迭代形式啊?
画出递归树:(图片咕了)
在这个递归树中,如果我们能够快速地处理出叶子节点所构成的序列 A(本例中为 A={a0,a4,a2,a6,a1,a5,a3,a7},那么就可以自底向上地实现整个 FFT 操作。
具体地,我们现在有了一个序列 A,就是这个递归树的叶子节点所构成的序列。
首先我们从前往后,两个两个地取出 A 中元素,然后对于每两个元素,算出它们的 DFT,这样就有了叶子节点上一层的 n/2 个 DFT。
然后用这 n/2 个 DFT ,可以再算出来再往上一层的 n/4 个 DFT,直到最后算出来递归树的「根」处的 **整个序列的 DFT **。
假设我们已经有了这个序列 A 的初始值,那么就能写出这样的代码:
typedef complex<double> comp;
comp A[MAXN];
void DFT(comp *a,int N){ //N 是 2 的正整数次幂。
Pre(a,N);
//假装我们已经通过一些神奇操作在低于 O(n log n) 的时间内预处理出了 A[] qwq。
int n=log2(N);
for(int i=1;i<=n;i++){
int m=1<<i; //m=2^i,就是递归时这一层中每组的长度
for(int j=0;j<N;j+=m){
for(int k=0;k<m/2;k++){
comp omega=comp(cos(2*k*pi/m),sin(2*k*pi/m));
comp x=omega*A[j+k+m/2],y=A[j+k];
A[j+k]=y+x;A[j+k+m/2]=y-x;
}
}
}
//算完之后的 A 就是 a 的 DFT 了qwq。
//看上去是三层的循环qwq
//但其实第二层和第三层合起来才只是一个 O(n),因此实际上总复杂度是 O(n log n)。
}
所以,到底怎么在一个较好的时间复杂度内确定 A 啊?
直接模拟递归过程显然不行qwq......虽然是 O(nlogn) 的,但是递归下去常数不还是爆炸>_<
位逆序置换
我们仔细观察一下递归的过程,还以 7 次多项式的八个系数为例:
- 一开始,这八个系数好好地放在那里:{a0,a1,a2,a3,a4,a5,a6,a7}。
- 我们把这个系数按照奇偶分了个类:{a0,a2,a4,a6},{a1,a3,a5,a7}。
- 又把每一个按照奇偶分了个类:{a0,a4},{a2,a6},{a1,a5},{a3,a7}。
- 最后,变成了:{a0},{a4},{a2},{a6},{a1},{a5},{a3},{a7}。
观察 (0,4,2,6,1,5,3,7) 的二进制表示:(000,100,010,110,001,101,011,111)
诶,把这个二进制表示翻转过来:
000 100 010 110 001 101 011 111
翻转后: 000 001 010 011 100 101 110 111
这,这不就是 (0,1,2,3,4,5,6,7) 的二进制表示嘛!
实际上这其中的道理是很好解释的:考虑我们分组时的分组依据。
第一次我们将奇偶分了个组,把偶数放到前面,把奇数放到后面,也就相当于把二进制表示中末尾为 0 的放到前面,末位为 1 的放到后面。
再想想把 (0,1,2,3,4,5,6,7) 的二进制表示:显然前面四个的首位是 0,后面四个的首位是 1,毕竟这个序列是递增的嘛QAQ。
也就是说,我们在末位上把 0 全都提到了前头,把 1 全都扔到了后头,而 (0,1,2,3,4,5,6,7) 则是在首位上把 0 提到前头,把 1 扔到后头,一模一样啊QAQ!
那同理,我们后面的操作也都是把每组中的 0 提到前头,把 1 扔到后头;这恰巧相当于 (0,1,2,3,4,5,6,7) 这一顺序排列在每组中的操作。
如果我们令 rev(n) 为 「将 n 的二进制表示翻转过来之后得到的值」,根据上面的结论,我们就有:
Arev(i)=ai
那我们就可以这么处理序列 A
for(int i=0;i<n;i++)A[rev(i)]=a[i];
其中 rev(n)
是一个计算「将 n 的二进制翻转后得到的值」的函数。
计算 rev(n)
显然可以做到 O(logn),毕竟 n 在二进制下的位数也一共只有 O(logn) 个,随便你怎么搞都行嘛qwq。
这样的话就做到了 O(nlogn) 计算序列 A。
实际上,如果 n 在二进制表示下的后 L 位(本例中 L=3),那么将 n 的这 L 位翻转,其实就相当于:把前面的 L−1 位拽出来,翻转一下,再把最后一位搞到最前头去qwq!
也就是说,如果我们知道了 rev(n>>1) 即 rev(n/2) 的值,那么就可以直接 O(1) 算出来 rev(n) 的值。
来构造一下这个递推式吧qwq。
首先要把前 L−1 位单拉出来并翻转:rev[i>>1]
。
注意,这里我们相当于把前面的 L−1 全部右移一位,把最后一位「顶掉」,这会导致最前面空出来一个 0。
那翻转之后这个最前面的 0 就被翻到了最后一位,也就是说,我们需要再右移一位,把这个 0 顶掉,即:rev[i>>1]>>1
。
现在,这个 rev[i>>1]>>1
就是 rev[i]
的后面 L−1 位翻转得到的结果了,而且位置是没有问题的qwq。
现在只需要把 i
的最后一位搞到前头去就行啦!
i
的最后一位,就是 i&1
;搞到前头去就相当于左移 L−1 位(这样它变成了第一位),再和刚才的 rev[i>>1]>>1
「或」一下即可qwq。
那么递推式就是:rev[i]=(rev[i>>1]>>1)|((i&1)<<(L-1))
因此,我们就利用递推做到了 O(n) 预处理 rev(i),然后 O(n) 计算序列 A qwq!
FFT 代码实现&注意事项&技巧(非递归版)
此时我们需要算出这些数在二进制下的位数,也就是把这一步改一下:
int k=1,L=0;
for(;k<=(n+m);k<<=1,L++)//L 就是我们需要考虑的位数。
其他的都基本没有什么区别qwq。
typedef complex<double> comp;
comp A[MAXN];
int rev[MAXN],k,L;
void init(){
rev[0]=0;
for(int i=0;i<k;i++)rev[i]=(rev[i>>1]>>1)|((i&1)<<(L-1));
}//预处理 rev[]
void dft(comp *a,int N){ //N 是 2 的正整数次幂。
init();
for(int i=0;i<k;i++)A[rev[i]]=a[i];
int n=log2(N);
for(int i=1;i<=n;i++){
int m=1<<i; //m=2^i,就是递归时这一层中每组的长度
for(int j=0;j<N;j+=m){
for(int l=0;l<m/2;l++){
comp omega=comp(cos(2*l*pi/m),sin(2*l*pi/m));
comp x=omega*A[j+l+m/2],y=A[j+l];
A[j+l]=y+x;A[j+l+m/2]=y-x;
}
}
}
for(int i=0;i<k;i++)a[i]=A[i];
}
void idft(comp *a,int N){
dft(a,N);
reverse(a+1,a+N);
for(int i=0;i<=N;i++)a[i]=(a[i]+comp(0.5,0))/((double)N);//输出需要是整数
}
最后对比一下递归版和非递归版:
(上面是递归版开 O2卡过,下面是非递归版不开 O2)
我知道我对照实验变量不唯一,不管了不管了,就当是突出一下非递归版吊打递归版>_<
其中非递归版最慢的一个点跑了 1.61s,递归版是 1.78s qwq。
用 FFT 切点题?
只过掉板子题怎么行!一个蓝题根本不够嘛!说好的爆切蓝紫黑呢?
- P1919 【模板】A*B Problem升级版(FFT快速傅里叶)
高精度乘法?
把一个数的 10i 位置上的数看成 ai,就有了一个多项式 f(x)=∑aixi,取 x=10 ,这个多项式的值就是这个数。
因此本质上还是计算多项式乘法的过程。
- P3338 [ZJOI2014]力
给一个长为 n 的序列 {qn},定义
Fi=i>j∑(i−j)2qiqj−i<j∑(i−j)2