Piwry
2020-01-17 07:28:53
本文为日报特供版QAQ
考虑到前面已经有人写过FFT了,所以部分FFT的基础内容不会出现在本篇,如基本的公式,NTT(它仅涉及单位复根循环的性质)等。
本文主要是讲解一些竞赛中会用到的围绕着FFT算法的一些内容,如DFT过程的几何直观、卷积和*CZT等,另外,最后也会介绍一些冷门却好用的tricks。
之前日报的FFT可以看这里
如果是FFT入门建议看下我之前的文章(有NTT)(排版警告QWQ)(另外这篇文章我没太大精力校对,所以其中的小漏洞可能较多。但对于FFT实现的部分还是很准确的)
1.DFT简介
2.DFT/IDFT的几何直观
3.卷积初步
4.*CZT
5.卷积与多项式
6.*卷积的意义浅谈
7.一些tricks
FT,即 傅里叶变换,在方式上,它实质就是·是用三角函数拟近函数。傅里叶变换在不同领域有着不同的意义,但它通常用来便捷地计算卷积。
不过连续的傅里叶变换显然不是计算机能够处理的。在此基础上,就有了 离散傅里叶变换 (此处指变换前后均离散),即 DFT。
实际上,DFT 就是 Z变换 的一种,或者说 Z变换 其实就是 DFT 的拓展。具体关于 Z变换的内容会在 第四节 中讲到。
.
这即是DFT的式子。
注意其中
本节主要是尝试建立DFT/IDFT的一种几何直观。
.
首先,自不用说是单位根。设单位复根
由复数的运算法则(幅角相加模相乘),我们其实可以将 与
同时,我们可以发现由不同次数的单位根组成的点是在单位圆上平均分布的。
注意,这里的旋转和平均的思想在下文十分重要。
.
接下来我们分析式子的几何意义。
观察式子(已用单位复根替换):
DFT时,对于所有的
我们可以画出它们:
可以发现:
1.部分点所在的半径重叠
2.对于
[1]即是我们FFT优化的根本(但这和本节没关系);
[2]是一个很有趣的性质,它是由遍历单位复根次数导来的,它和IDFT的几何直观息息相关。
我们先设设
发现,若求
但是,还有个唯一的特殊情况:若
这样,
这其实就和做IDFT主过程后不需要做转换,并要乘
.
然后我们再思考下
为了便于区分,我们重新定义
对于每项
这个式子看起来没什么可化简的,但我们注意到
于是就有
再观察DFT的式子中
因此,我们上面推出的求和为
.
那
发现特别的当
这样,我们就构建起了DFT/IDFT的一种几何直观(但变换公式本身是如何推得的还要另外讨论)
卷积的应用十分广泛,且在不同的领域有不同的意义。本节主要是单纯地介绍卷积和其代码实现,对其意义不做探讨。
.
卷积可以分为两种:线性卷积 和 循环卷积 。下面我会分别讲解它们:
(科普:
(约定:仅带条件的
其式子为
我们也可以从这个式子知道线性卷积结果的规模为
可以做个图,更生动些:
(图中限于篇幅只表示了一项)
仔细观察的话可以发现,这形式就像我们平时所做的 多项式乘法 。
其式子为
或
注意到,特别的是,循环卷积要求两个序列长度相等,且结果的规模是不变的。
我们可以将取模想象为在序列后面加一个负数编号的“假序列”,做一张图:
或者,也可以形象地理解为先将一个序列反序,再将两个序列分别按 逆时针 和 顺时针 顺序放到两个同心圆上,逐位旋转相乘,每种旋转状态下的积的和就是结果序列该项的值(细节实现可能还要一些规定)。
就像这样:
扯了这么多,难道我们只能朴素地
并不,因为 DFT 和 卷积其实是大有关系的。
首先扯一点简单的结论:DFT后对序列做逐项相加后IDFT,是等同于 对原序列做逐项相加的。
我们可以想到,幅角相同的复数相加后,其幅角是不变的;
也就是说,DFT后对序列做逐项相加,其贡献和 对原序列做逐项相加是一样的:每一位的每一个变换映射都对应相加了(如果您真的看懂了第二节,这个应该是很显然w)。由此得证。
接下来是一个关键的结论:DFT后对序列做逐项相乘后IDFT,是等同于 对原序列做循环卷积的。
这是一个非常重要的性质,也正是这个性质使得我们可以用 FFT 优化序列的操作。
.
但我尝试了很多办法,都不能找到一个简洁的图像表示法证明这个结论(窝太太菜惹QAQ),这里没办法,只能给出一个利用单位根(这里定义循环节终点一定是
经过尝试,我们可以发现:如果将两个序列补零拓展到
还是上面那张图,我们对
可以发现,除去和
而通过式子我们也可以发现,对于
这样,我们便可以用 循环卷积 来计算 线性卷积,从而弥补 DFT只能快速计算循环卷积 的缺陷。
在题目中,我们常常会遇到这种卷积形式:
我们可以对[-n+1, n-1]
的整数取值[0, n-1]
地做一个序列。
(即
上式即可化成:
我们可以发现这很像一个完整的线性卷积形式。实际上,它就是在计算[n-1, 2n-2]
项。
我们可以形象地做张图:
另外,如果
其实这里的卷积长度可以只做到[0, 2n-2]
,或者说不做末
具体的证明可以详见 6. IV 。
这里给出关键部分的代码(且已经使用了 3.IV.I 所述的优化):
//常见卷积形式代码
//n 如上文所述,N 是指第一个大于等于 2n-1 的2的幂次数(即 FFT的规模)
//数组缺省值为 0
inline void Conv(){
for(int i =-n+1; i <= n-1; ++i) f[n-1+i].real =F(i)/*计算 f的函数*/;
for(int i =0; i < n; ++i) g[i].real =G(i)/*计算 g的函数*/;
pre()/*预处理*/, FFT(f), FFT(g);
for(int i =0; i < N; ++i) Q[i] =f[i]*g[i];
IFFT(Q);
for(int i =0; i < n; ++i) ans[i] =int(Q[i+n-1].real/N+0.5);
}
我们知道DFT是为了快速计算卷积,但为什么要计算卷积呢?
原因之一是卷积其实描述了一种非常常见的情景--"信号的叠加"。DFT,就可以视为快速计算的桥梁。
但显然真实的情景肯定不止这一种,于是就有了定义更广泛的 Z变换。
其式子的一种形式为:
注意这里的
如做DFT实际上就可以规定
当然Z变换的变换对还有很多,每个变换对也有不同的含义;同时其通用的逆变换形式也较复杂。这些和下文没有太大联系,这里就不展开了。
显然朴素计算Z变换是
其实若对于一般的Z变换,确实是很难找到快速计算的算法的。不过如果限定一些条件,我们还是有比较显然的方法的。
现在我们限定
即
这样就可以尝试把变换化成卷积的形式了:
接下来我们要想办法分别化出仅两个(在求和式子中)自变量与
最简单地,可以想到用平方式:
显然,只要设
代码就不放惹。
这即是 CZT算法。
.
当然 CZT 本身的含义、其几何直观也是可以展开来讲的。但和 Z变换 一样,其坑太深并且和 DFT、卷积 没太大关系,这里就不展开来讲了。
读者如果有兴趣的话,可以参考 这篇ppt 对CZT的讲解。
我们发现多项式相乘,其系数的变化规律其实就是做线性卷积。
于是直接带入模板求解即可。
上文我们曾提到:“卷积其实描述了一种非常常见的情景--"信号的叠加"”。但具体又是怎样的呢?下面,我会给出一个简单的例子:
.
我们先定义信号强度函数
然后再定义一个信号密度函数
定义单位时刻信号总强度为该时刻各个信号强度和,题中单位均给定统一。
.
现在,我们要求求所有可能的时刻
朴素的模拟每个信号是
我们设
这非常显然就是个卷积的式子。
我们想到,既然变换的本质是对向量做旋转,我们可不可以构造斜率不同的过原点的直线作为数轴,将实数一一映射到它们上面(即调整斜率),从而一次打包多个多项式的FFT?
构造任意数轴并映射所需要的计算量是巨大的;但不同的是正交的两个数轴虚数轴和实数轴有特别的性质。
因此,接下来我们考虑打包两个序列做FFT,即将其中一个序列映射到虚数轴上。
我们只需将两个DFT后的序列(且DFT时的旋转方向一致)的其中一个沿DFT的旋转方向再旋转90度(即乘
DFT的打包比起IDFT的打包要复杂得多。
若我们要打包序列
先设
我们可以表示为
.
变换后:
这个等式其实是转换前后始终满足的:因为DFT/IDFT就是对这些向量做旋转,而
.
现在我们要简化
我们观察
于是我们尝试做
这不就是将
即
.
整理一下:
1.设
2.有
3.有
这样我们便成功将DFT也两两打包为一次。
.
在这篇论文中其实有用式子推导DFT的打包的方法。但这种方法没有一个明显的几何直观,这里就不展开了。
为了简洁,这里仅给出打包的核心代码:
//该程序计算了(A+B)*(C+D)(线性卷积),为了减小精度负担而拆开计算
//cp是复数类,ll是long long,conj()是取共轭复数
//MAXN2较小,MAXN较大
//变量缺省值均为 0
int N1 /*A, B长度*/, N2 /*C, D长度*/, A[MAXN2], B[MAXN2], C[MAXN2], D[MAXN2];
int N /*FFT规模。注意预处理时 N的具体大小(为两个序列相乘长度 )*/, M /*模数*/;
ll ans[MAXN];
cp P[MAXN], Q[MAXN], A2[MAXN], B2[MAXN], C2[MAXN], D2[MAXN];
inline void conv(){
/*这里P打包A, B; Q打包C, D*/
for(int i =0; i < N1; ++i) P[i].real =A[i], P[i].imag =B[i];
for(int i =0; i < N2; ++i) Q[i].real =C[i], Q[i].imag =D[i];
preFFT();/*预处理*/
FFT(P), FFT(Q);
for(int i =0; i < N; ++i){/*提取,这里把除法整合了,注意前面的cp(0, 0.5)|cp(0.5, 0)*/
cp P2 =conj(P[(N-i)&(N-1)]);
A2[i] =cp(0.5, 0)*(P[i]+P2), B2[i] =cp(0, 0.5)*(P2-P[i]);
cp Q2 =conj(Q[(N-i)&(N-1)]);
C2[i] =cp(0.5, 0)*(Q[i]+Q2), D2[i] =cp(0, 0.5)*(Q2-Q[i]);
}
/*这里P打包ac,ad,Q打包bc,bd*/
/*程序中有简化运算*/
for(int i =0; i < N; ++i) P[i] =A2[i]*(C2[i]+cp(0, 1)*D2[i]);
for(int i =0; i < N; ++i) Q[i] =B2[i]*(C2[i]+cp(0, 1)*D2[i]);
IFFT(P), IFFT(Q);
for(int i =0; i < N1+N2-1; ++i){/*最后检索虚实数轴即可*/
ll ac =(ll)(P[i].real/N+0.5)%M,
ad =(ll)(P[i].imag/N+0.5)%M,
bc =(ll)(Q[i].real/N+0.5)%M,
bd =(ll)(Q[i].imag/N+0.5)%M;
ans[i] =(ac+bd+ad+bc)%M;
}
}
文章完成后,我又在评论区( 感谢 command_block )发现了一种非常初等的针对计算 (A+B)*(C+D)=AC+BD+AD+BC
的右边四项(通常是为了防止炸精度)的优化方法:
这样我们只需计算三次DFT和两次IDFT就可以加减计算出答案了(而上文介绍的方法只需四次,两者间仅差一次)。
具体实现可以参考原文(是一篇MTT的题解)
这是一个很好用的技巧:预处理单位根,然后将代码中访问数组下标的操作替换为指针,可以节省不少时间。
这里直接给出对于FFT示范代码(NTT可以照这样子写):
//cp为复数类
inline void FFT(cp *f/*操作序列*/){
for(int i =0; i < N; ++i) if(i < S[i])
swap(f[i], f[S[i]]);
/*w的步长,N为总范围,除掉操作范围2,之后每次除2即可(操作范围在增倍)*/
for(int lim =1/*操作范围的1/2*/, step =N>>1; lim < N; lim <<=1, step >>=1){
for(int s/*起始点*/ =0; s < N; s +=lim<<1){
cp *w =W, *f0 =f+s, *f1 =f+s+lim;/*f0为左区间,f1为右区间*/
for(int i =0; i < lim; ++i){
cp res =*f1 * *w;
*f1 =*f0-res, *f0 =*f0+res;//这里的运算顺序按照值变化的顺序有所改变
w +=step, ++f0, ++f1;
}
}
}
}
FFT单位根预处理是这样子的:
for(int i =0; i < N; ++i) W[i] =cp(cos(2*pi*i/N), sin(2*pi*i/N));//直接对于最小的步长求单位复根
//这里也可以递推求,但精度会下降很多
做IFFT时单位根,可以直接对原单位根序列取共轭复数即可。像这样:
inline void op(){
for(int i =0; i < N; ++i) W[i] =cp(W[i].real, -W[i].imag);
}
.
NTT单位根的预处理也大同小异。
但做INTT的单位根时,是可以直接从 NTT的单位根 推得的。
我们可以发现这样一个式子:
也就是说,
直观的理解的话可以参考上文中打包FFT时那两个序列的关系(
代码如下:
inline void getWinv(){
for(int i =0; i < N; ++i) Winv[i] =W[(N-1)&(N-i)];
}
做题时,选择预处理的方式也是有技巧的。若题目精度要求不大,递推处理即可;否则一定要每一项都根据公式算,避免精度误差。同时打包操作也是会一定地影响精度的。
由于打包FFT的效率极高,个人还是推荐用公式保证精度再加打包;实在不行拆系数算也是比常规算法效率要高的。
这条被我验证过其实是无效的QWQ。
.
但为什么还要占一个位子呢?
原本我分享的是利用 double
的玄学精度,尽量地让操作数字转变为小数来提高精度。
但实际上 double
是按 数字长度 做精度的,和 数字大小 则没关系。
窝太菜re呜呜呜
如像上面提到的"常见卷积形式",需要求A[0, n-1]
与B[0, 2n-2]
的卷积C[0, 3n-2]
的[n-1, 3n-3]
项,常规操作是把A, B全部扩充到[0, 3n-3]
然后再做卷积,但实际上只需要扩充到[0, 2n-2]
就行了。
.
我们观察下线性卷积的图解发现:使用"0"
最多的是答案序列的最右项,且正好"用"完了所有的"0"
(如果序列长度不一的话,上下统计结果也是不一样的。这里取剩下最少的一组):
而从最右项从右往左,"用"的"0"
的数量也会逐个减少。
假设我们减少一位序列的长度,正好"用"完"0"
的那项就会受"干扰"。
也就是说,假如我们逐个减小序列的长度,就会逐次地从右往左地影响答案。注意这里原有序列是不变量,这个结论要求减少的项是扩充的"0"
若我们只做[0, 2n-2]
,就相当于不做末[0, n-2]
,正好保证了答案不受影响。
代码就如上文提到的一样。
.
具体的式子也是可以列出来的,但没有图像直观,这里就不多提了。
有时候,题目会要求我们做任意长度的循环卷积,但显然我们不能用朴素的算法。我们需要一个复杂度和FFT近似的算法才能通过评测。
我们再做一次循环卷积的示意图,但这次并不做多余的序列。
我们发现,它其实是以答案的位置为分界点,把序列分成了 包括答案位置的右边 和 不包括答案位置的左边 两段,并分别做了一个"对称"的特殊乘法。
接下来我们再观察为了做线性卷积而拓展后的答案序列的 [x]
项 与 [x+n]
项 :
[x]
:
[x+n]
:
我们发现,[x]
项所缺的部分正好就是[x+n]
所有的部分!
[x]
项组成首先右边序列贡献不变是可以保证的。
我们再看左边序列:发现扩充时补零的长度至少为
[x+n]
项组成首先它的左边序列一定是全由"0"
组成的,贡献也为零。
然后我们发现,若从右往左计数,至[x+n]
项正好有"0"
,这和[x]
项右边序列的长度相等。也就是说,保证了[x]
右边序列的元素不做贡献。
而对于中间那段,因为这种特殊乘法的"对称性",中间的贡献和原来[x]
项左边序列的贡献是等价的。(这里也建议想象一下)
另外,特殊的对于[x+n]
项补充。(可自证)
[x+kn]
项的存在性补足的长度至少为[x+n]
项补充的(可观察图)。
对于[x+kn]
[x+kn]
的贡献也是为零的(这时从左往右计数的"0"数量已经超过原有序列长度了)。
但要注意,保证存在的项只有[n, 2n-2]
,实际使用时还是要特判的。
我们只需做一次 线性卷积。然后仅将[x+n]
项与[x]
项叠加即可(特别的对于[n-1]
项不考虑)。
代码如下:
//此程序可以快速计算任意长度的 循环卷积
//cp是复数类
//n是序列规模,N是FFT的规模
//MAXN2较小,MAXN较大
//变量缺省值均为 0
cp P[MAXN], Q[MAXN];
int ans[MAXN2];
inline void Conv(int *f, int *g){
for(int i =0; i < n; ++i) P[i].real =f[i], Q[i].real =g[i];
preFFT()/*预处理*/, FFT(P), FFT(Q);
for(int i =0; i < N; ++i) P[i] =P[i]*Q[i];
IFFT(P);
for(int i =0; i < n-1; ++i) ans[i] =int(P[i].real/N+0.5)+int(P[i+n].real/N+0.5);
ans[n-1] =int(P[n-1].real/N+0.5);/*为了避免越界特判了对于 [n-1]项的计算*/
}
.
这种算法其实已经很优秀了。在不要求对DFT(未IDFT)的序列做操作时,它甚至是最优的。
但一旦题目要求做如序列(循环卷积)幂,这个算法最终会多出一个
因此,我们需要一个切实能做 任意长度DFT 的算法。
上文中我们曾提到 CZT算法。
若设
而对于 IDFT,我们只需改下符号重新推导,最后加个
这即是 Bluestein's Algorithm。
但是,这种拆法在做NTT时是行不通的:我们无法保证幂数乘
所以我们可以拆成均是相邻整数相乘的式子:
(这个式子其实也可用组合数解释,具体的话就是
然后直接带入即可:
设
便有:
(注意这份代码也使用了 6.IV 的优化)
//此程序可以快速计算任意长度的 DFT/IDFT;此处实例为NTT
//cp是复数类,ll是long long,Pow()是快速幂
//gg是原根,M是模数,n是规模,invn是模 M意义下 n的逆元,N是FFT的规模
//MAXN2较小,MAXN较大
//变量缺省值均为 0
inline int G(int x, int op, int *f){ return Pow(gg, op*-x*(x-1)/2)*f[x]%M; }
inline int F(int x, int op){ return Pow(gg, op*x*(x+1)/2); }
int f[MAXN2], g[MAXN2];
cp P[MAXN], Q[MAXN];
inline void CZT(int *A, int op /*1为正变换,-1为逆变换*/){
for(int i =-n+1; i <= n-1; ++i) f[n-1+i] =F(i, op);
for(int i =0; i < n; ++i) g[i] =G(i, op, A);
for(int i =0; i < 2*n-1; ++i) P[i].real =f[i], Q[i].real =g[i];
preFFT()/*预处理*/, FFT(P), FFT(Q);
for(int i =0; i < N; ++i) P[i] =P[i]*Q[i];
IFFT(P);
for(int i =n-1; i < 2*n-1; ++i){
A[i-n+1] =(ll)(P[i].real/N+0.5)%M*Pow(gg, op*-i*(i+1)/2);
if(op == -1) A[i-n+1] =A[i-n+1]*invn%M;
}
}
代码中有很多部分是可以预处理的。但为了简洁,这里就用最朴素的版本了。
在审核回复我前一篇文章时,我发现除了一个通过的,只有我的文章没有明确不予通过QWQ。
所以就心血来潮又写了一篇。( 现在看来和上一篇已经有天差地别的区别惹w )
这篇和上一篇不同的是,里面的概念和技巧都是较深奥的,可能不适合FFT初学者看。但考虑到前面已经有入门教程,想让自己的文章上日报还是要拿出一些干货的。
本文最有价值的内容应该是最后一节的优化技巧。里面我整理了尤其是在 《再探快速傅里叶变换》--毛啸 里提到的打包技巧,和我平日里总结的优化方式。(有了这些一定可以让您的FFT爆踩NTT)
最后再扯件事。本来文章开头是想从最最基本的 FT,傅里叶变换 讲起的。但中途查了许多资料,最后也没有完全理解连续意义下的傅里叶变换,还险些推翻了之前对DFT的认识(要是真推翻了重写让我这个鸽子怎么办),于是最后这部分还是咕咕了(你可以试着读下本文链接,猜下原来的标题是什么QWQ)
希望最终可以通过审核把,毕竟我的博客太惨了QAQ(真·一个人都没)
.
现在通过审核了www。
FT部分其实多少也快扯到了,不过当然仅仅是其冰山一角QAQ。
文章为了通俗易懂舍弃了部分严谨性,一些分析也没有采用更系统的办法(典型的如卷积那部分)。这点我在写文时就想到了,不过也无可奈何(有兴趣的读者可以百度了解一下信号处理的领域,那是一个很大的坑,且基本与竞赛无关)。
能看到这里,想必您已经读完全文了把。最后感谢您的阅读~QWQ 别忘了点赞