本文主要简单写写自己在算法竞赛中学习FFT的经历以及一些自己的理解和想法。

FFT的介绍以及入门就不赘述了,网上有许多相关的资料,入门的话推荐这篇博客:FFT(最详细最通俗的入门手册),里面介绍得很详细。

为什么要学习FFT呢?因为FFT能将多项式乘法的时间复杂度由朴素的$O(n^2)$降到$O(nlogn)$,这相当于能将任意形如$f[k]=\sum\limits _{i+j=k}f[i]\cdot f[j]$的转移方程的计算在$O(nlogn)$的时间内完成。因此对于想要进阶dp的同学来说,FFT是必须掌握的技能之一。(虽然在赛场上可能没什么用武之地)

我学习FFT的过程也是比较曲折的,从接触到真正理解它的原理前前后后经历了半年的时间。(实际上我从去年接触了FFT之后就一直把它当做一个黑盒算法来用,研究的事就扔到一边了,只是偶尔简单推算过几次公式,直到这个月初才开始深入学习它的原理)

由于本人才疏学浅,所以自己的叙述若存在一些错误或者不足之处,敬请读者指正。

首先FFT的作用是什么?可以将多项式的系数表达式转化成点值表达式(或者反过来,方法都是一样的)。FFT(a,n)的作用是将多项式a(系数表达式)从$w_{n}^{0}$到$w_{n}^{n-1}$的所有根对应的取值求出来。也就是说,设$f(x)=\sum\limits_{i=0}^{n-1}a[i]\cdot x^i$,经过FFT变换后,a[i]变成了$f(w_{n}^{i})$。

这个利用单位根来表示的点值表达式的一个好处是如果已知FFT(a0,n/2)以及FFT(a1,n/2)(a0为a的偶数次项所构成的多项式,a1为a的奇数次项所构成的多项式),则根据性质$\left\{\begin{matrix}\begin{aligned}&a[i]=a_0[i]+w_{n}^{i}\cdot a_1[i]\\&a[i+\frac{n}{2}]=a_0[i]-w_{n}^{i}\cdot a_1[i]\end{aligned} \end{matrix}\right.$可以在$O(n)$的时间内算出数组a的值。

为什么要用单位根呢?因为对于任意的数组长度n,在FFT的过程中使用单位根都只需要计算n个不同变量的值,与数组长度是线性相关的,而且一定能保证取到n个不同的值。而假如取2,3,4这样的数的话,在对任意子数组进行FFT时仍需计算n个不同变量的值,这样的话总的复杂度仍为$O(n^2)$,没有丝毫降低。而假如取-1,1这样的数,虽然只需要计算常数个变量的值了,但无论如何只能取到一两个变量的值,也就是只能确定两点,无法确定一个具有n个维度的多项式。

接下来就是代码实现了。

首先我们做一下预处理:

 typedef double db;
const db pi=acos(-);

把double定义成db的作用,一是可以简化代码,二是需要调整精度的时候可以很方便地替换成其他变量类型,比如long double。

FFT的运算要用到复数,这就意味着我们必须找到一个能够代表复数的变量类型。图方便的话,C++库中内置的complex类就够用了。不过还是推荐自己写一个结构体,比C++自带的要快很多,而且也很好写。

由于复数是一个二元组,和二维平面上的点非常类似,因此可以直接套用二维几何中的点的结构体代码。加减数乘等操作都完全一样,只是多了个乘法。但这并不影响它的几何意义,因为在计算几何中两向量乘法我一般喜欢用dot(点积)和cross(叉积)两个函数来表示。此外,乘法运算符也可以表示坐标的旋转。

复数(点)的结构体代码如下:

 struct P {
db x,y;
P operator+(const P& b) {return {x+b.x,y+b.y};}
P operator-(const P& b) {return {x-b.x,y-b.y};}
P operator*(const P& b) {return {x*b.x-y*b.y,x*b.y+y*b.x};}
P operator/(db b) {return {x/b,y/b};}
}

接下来就是FFT的实现了。有了FFT的基本概念和点的表示方法之后,我们不难写出这样的代码:(f为1代表正变换(取值),f为1代表逆变换(插值))

 void FFT(P* a,int n,int f) {
if(n==)return;
static P b[N];
for(int i=; i<n; i+=)b[i/]=a[i],b[(i+n)/]=a[i+];
for(int i=; i<n; ++i)a[i]=b[i];
FFT(a,n/,f),FFT(a+n/,n/,f);
P wn= {cos(*pi/n),f*sin(*pi/n)},w= {,};
for(int i=; i<n/; ++i,w=w*wn) {
P x=a[i],y=w*a[i+n/];
a[i]=x+y,a[i+n/]=x-y;
}
}

可以看出,这个代码是递归式的,其基本思想是将数组a分成两部分,偶数次项放在左半边,奇数次项放在右半边,然后对左右两部分分别递归做同样的处理,最后把两部分的答案合并,合并后a[0]-a[n-1]中的值分别为$f(w_{n}^{0})$-$f(w_{n}^{n-1})$的值。

但是递归在速度方面毕竟是硬伤,因此我们希望能将递归换成迭代的形式,这样速度会快很多。

通过观察,我们不难发现,FFT的第一步总是将a[i]与a[i+n/2]合并,每个多项式相邻两项在数组中的距离为n(即只有一项),而最后一步总是将a[i]与a[i+1]合并,每个多项式相邻两项的距离为2,中间每合并一轮,距离减半。经过一番观察和推理之后,我们可以得到如下改进后的代码:

 void FFT(P* a,int n,int f) {
static P b[N];
P *A=a,*B=b;
for(int k=n; k>=; k>>=,swap(A,B))
for(int i=; i<k>>; ++i) {
P wn= {cos(pi*k/n),f*sin(pi*k/n)},w= {,};
for(int j=i; j<n; j+=k,w=w*wn) {
P x=A[j],y=w*A[j+(k>>)];
B[((j-i)>>)+i]=x+y,B[((j-i)>>)+(n>>)+i]=x-y;
}
}
if(A!=a)for(int i=; i<n; ++i)a[i]=A[i];
if(!~f)for(int i=; i<n; ++i)a[i]=a[i]/n;
}

这样我们就成功地去掉了递归,换成了迭代实现的版本。中间使用了两个指针A,B,是用乒乓效应减少数组的复制次数,有点类似倍增求后缀数组的方法。

但是这样虽然去掉递归了,但仍需要$O(n)$的辅助空间,而且如果迭代次数为奇数次的话,最后还需要把变换后的数组复制回原数组,不太美观。可以把辅助空间去掉,直接在原数组上进行合并吗?

对于上述代码,假设我们把x+y,x-y的值分别直接赋给a[((j-i)>>1)+i]和a[((j-i)>>1)+(n>>1)+i],那么原来这两个位置上的信息就消失了,而这些信息在后面的合并中可能还需要用到,赋给其他位置也是同理。因此不能直接在原数组上进行赋值。这意味着,如果想直接在原数组上进行合并,合并后的两个值和合并前的两个值所存放的位置必须相同。例如,假如我们要合并下标分别为{0,4,8,12}和{2,6,10,14}的两个数组,那么a[0]+w*a[2]的值必须放在a[0]或者a[2]的位置,a[0]-w*a[2]的值则必须放在另一个对应的位置。这样一来,顺序会变得很乱(自己试一试就知道了),因此若想在合并后不改变原数组中各项的位置,就必须在合并前把原数组“打乱”(当然不是随便打乱,是对原数组进行一定规则的变换)。

如何“打乱”呢?我们可以把合并的过程倒过来观察一下,这里借用一下网络上的一张图:

如图所示,我们把“合并”的过程看成是倒过来“拆分”的过程,这是其中一种拆分的方法,可以发现,这种拆分的方法能保证“任意两个位置上的数进行合并后的结果仍保存在它们各自的位置上,且合并后原数组的顺序不变”,这样就可以直接在原数组上进行合并了。

这种拆分方法有什么规律呢?同样也可以发现,第一次拆分后,偶数次项都被分到了左边,而奇数次项都分到了右边。第二次拆分后,把每个项的次数都除以二(向下取整),得到的数为偶数的继续被分到左边,为奇数则被分到右边,同理第三次拆分后要把每个项的次数除以4,第四次除以8......以此类推。从而我们可以总结出规律:设$n=2^t$,$rev(i)$为原数组的位置i拆分后对应的下标,$b(i,k)$为数字i的二进制第k位上的数(k∈{0,1}),利用按位累加的方法可以得到:

$b(rev(i),t-1-k)=\left\{\begin{matrix}\begin{aligned}0,b(i,k)=0\\ 1,b(i,k)=1\end{aligned}\end{matrix}\right.$

这相当于,每个数的拆分后的二进制第k位和原来的第t-1-k位是相同的,相当于把这个数的前t位二进制位进行了反转。

如何利用数组拆分后,对应的下标二进制反转的特性来对数组重排呢?一种比较普遍的方法是利用递推的方法求出原数组反转后的rev数组(方法不再叙述,网络上一搜便知),再从前往后扫一遍原数组,遇到rev数组中对应的元素比它小的情况,就交换一次。这种方法的时间复杂度是$O(n)$的,但仍需要$O(n)$的辅助空间,而且对于不同的n要重新求一遍rev数组,比较麻烦。直到我找到了这样的一段代码:

 void change(P* a,int n) {
for(int i=,j=n>>,k; i<n-; ++i) {
if(i<j)swap(a[i],a[j]);
k=n>>;
while(j>=k)j-=k,k>>=;
j+=k;
}
}

这段代码打眼一看可能会有点懵逼,这是在干嘛?其实自己模拟一下便知,这是在对一个数组“暴力”进行反转,方法是模拟“倒过来加”的过程,把左起第一个0变成1,把前面的1都变成0,这样倒过来看就好像是整个数加了1,从头到尾扫一遍就行了。甚至可以改写成位运算的形式:

 void change(P* a,int n) {
for(int i=,j=n>>,k; i<n-; ++i,j^=k) {
if(i<j)swap(a[i],a[j]);
for(k=n>>; j&k; j^=k,k>>=);
}
}

这样一来,FFT的空间消耗就彻底变成$O(1)$了。但是还有一个问题,就是这个函数的时间复杂度是多少呢?

可以看出,这个函数的时间复杂度主要取决于k的移动次数。不考虑边界情况的话,假如j的第一个0在第n-1位,那么k只需要移动一次(赋值成n/2),这样的情况一共有n/2种;假如第一个0在第n-2位,那么k需要移动两次,这样的情况一共有n/4种...以此类推。最坏的情况是第一个0在第0位,此时需要移动logn次,但这只有一种情况。

因此,假设$n=2^t$,则函数中的k总共需要移动$\sum\limits_{i=1}^ti\cdot 2^{t-i}$次。

这个式子怎么算呢?

我们考虑等比级数$\sum\limits_{i=1}^tx^{t-i+1}=\frac{x(1-x^t)}{1-x}$

等式两边求导得$\sum\limits_{i=1}^t(t-i+1)x^{t-i}=\frac{1-(t+1)x^t+tx^{t+1}}{(1-x)^2}$

又有$\sum\limits_{i=1}^t(t-i+1)x^{t-i}=(t+1)\sum\limits_{i=1}^tx^{t-i}-\sum\limits_{i=1}^tix^{t-i}$

即$\sum\limits_{i=1}^tix^{t-i}=(t+1)\sum\limits_{i=1}^tx^{t-i}-\sum\limits_{i=1}^t(t-i+1)x^{t-i}=\frac{(t+1)(1-x^t)}{1-x}-\frac{1-(t+1)x^t+tx^{t+1}}{(1-x)^2}$

将x=2代入得$\sum\limits_{i=1}^ti\cdot 2^{t-i}=\frac{(t+1)(1-2^t)}{1-2}-\frac{1-(t+1)2^t+t2^{t+1}}{(1-2)^2}$

化简得$\sum\limits_{i=1}^ti\cdot 2^{t-i}=2^{t+1}-t-2=2n-logn-2=O(n)$

对,你没有看错,空间复杂度降到了$O(1)$,而时间复杂度仍为$O(n)$,刺不刺激?

经过多次优化,可以最终得到了如下的FFT代码:

 void FFT(P* a,int n,int f) {
for(int i=,j=n>>,k; i<n-; ++i,j^=k) {
if(i<j)swap(a[i],a[j]);
for(k=n>>; j&k; j^=k,k>>=);
}
for(int k=; k<n; k<<=) {
P wn= {cos(pi/k),f*sin(pi/k)};
for(int i=; i<n; i+=k<<) {
P w= {,};
for(int j=i; j<i+k; ++j,w=w*wn) {
P x=a[j],y=w*a[j+k];
a[j]=x+y,a[j+k]=x-y;
}
}
}
if(!~f)for(int i=; i<n; ++i)a[i]=a[i]/n;
}

非递归实现,$O(nlogn)$的时间复杂度,O(1)的空间复杂度,既保证了效率又简洁了代码,岂不美哉?

有了FFT的代码,就可以实现多项式乘法了。用FFT实现多项式乘法的一般步骤是将被乘的两个多项式分别用FFT转化成点值表达式,然后对应位相乘,最后再用FFT逆变换转化回来就行了。

值得注意的是,对被乘的两个多项式进行FFT时,数组长度至少应大于两个多项式的最高次数之和,否则会出现莫名其妙的错误。又因为数组长度必须是2的t次方的形式,保险起见最好开到多项式相乘后的最高次数的两倍或以上。

最后推荐几道FFT的练习题:

HDU - 4609 3-idiots

UVA - 12298 Super Poker II

Gym - 101002E K-Inversions

Gym - 101667H Rock Paper Scissors

HDU - 1402 A * B Problem Plus

Gym - 101234D Forest Game

顺便附上UVA - 12298的完整代码:

 #include<bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef long double db;
const int N=2e5+;
const db pi=acos(-);
struct P {
db x,y;
P operator+(const P& b) {return {x+b.x,y+b.y};}
P operator-(const P& b) {return {x-b.x,y-b.y};}
P operator*(const P& b) {return {x*b.x-y*b.y,x*b.y+y*b.x};}
P operator/(db b) {return {x/b,y/b};}
} p[][N];
void FFT(P* a,int n,int f) {
for(int i=,j=n>>,k; i<n-; ++i,j^=k) {
if(i<j)swap(a[i],a[j]);
for(k=n>>; j&k; j^=k,k>>=);
}
for(int k=; k<n; k<<=) {
P wn= {cos(pi/k),f*sin(pi/k)};
for(int i=; i<n; i+=k<<) {
P w= {,};
for(int j=i; j<i+k; ++j,w=w*wn) {
P x=a[j],y=w*a[j+k];
a[j]=x+y,a[j+k]=x-y;
}
}
}
if(!~f)for(int i=; i<n; ++i)a[i]=a[i]/n;
}
int com[N],a,b,c; int main() {
memset(com,,sizeof com);
for(int i=; i<N; ++i)if(!com[i])for(int j=i*; j<N; j+=i)com[j]=;
while(scanf("%d%d%d",&a,&b,&c)&&(a||b||c)) {
int m;
for(m=; m<=b*; m<<=);
for(int f=; f<; ++f)fill(p[f],p[f]+m,(P) {,});
while(c--) {
int x;
char ch;
scanf("%d%c",&x,&ch);
if(x>b)continue;
if(ch=='S')p[][x].x--;
else if(ch=='H')p[][x].x--;
else if(ch=='C')p[][x].x--;
else if(ch=='D')p[][x].x--;
}
for(int f=; f<; ++f)
for(int i=; i<=b; ++i)p[f][i].x+=com[i];
for(int i=; i<; ++i)FFT(p[i],m,);
for(int f=; f<; ++f) {
FFT(p[],m,);
for(int i=; i<m; ++i)p[][i]=p[][i]*p[f][i];
FFT(p[],m,-);
for(int i=b+; i<m; ++i)p[][i]= {,};
}
for(int i=a; i<=b; ++i)printf("%lld\n",ll(p[][i].x+0.5));
puts("");
}
return ;
}

浅谈FFT(快速傅里叶变换)的更多相关文章

  1. 浅谈FFT(快速博立叶变换)&学习笔记

    0XFF---FFT是啥? FFT是一种DFT的高效算法,称为快速傅立叶变换(fast Fourier transform),它根据离散傅氏变换的奇.偶.虚.实等 特性,对离散傅立叶变换的算法进行改进 ...

  2. FFT 快速傅里叶变换 学习笔记

    FFT 快速傅里叶变换 前言 lmc,ikka,attack等众多大佬都没教会的我终于要自己填坑了. 又是机房里最后一个学fft的人 早背过圆周率50位填坑了 用处 多项式乘法 卷积 \(g(x)=a ...

  3. CQOI2018 九连环 打表找规律 fft快速傅里叶变换

    题面: CQOI2018九连环 分析: 个人认为这道题没有什么价值,纯粹是为了考算法而考算法. 对于小数据我们可以直接爆搜打表,打表出来我们可以观察规律. f[1~10]: 1 2 5 10 21 4 ...

  4. 「学习笔记」FFT 快速傅里叶变换

    目录 「学习笔记」FFT 快速傅里叶变换 啥是 FFT 呀?它可以干什么? 必备芝士 点值表示 复数 傅立叶正变换 傅里叶逆变换 FFT 的代码实现 还会有的 NTT 和三模数 NTT... 「学习笔 ...

  5. 浅谈FFT(快速傅里叶变换)

    前言 啊摸鱼真爽哈哈哈哈哈哈 这个假期努力多更几篇( 理解本算法需对一些< 常 用 >数学概念比较清楚,如复数.虚数.三角函数等(不会的自己查去(其实就是懒得写了(¬︿̫̿¬☆) 整理了一 ...

  6. FFT —— 快速傅里叶变换

    问题: 已知A[], B[], 求C[],使: 定义C是A,B的卷积,例如多项式乘法等. 朴素做法是按照定义枚举i和j,但这样时间复杂度是O(n2). 能不能使时间复杂度降下来呢? 点值表示法: 我们 ...

  7. 浅谈FFT、NTT和MTT

    前言 \(\text{FFT}\)(快速傅里叶变换)是 \(O(n\log n)\) 解决多项式乘法的一个算法,\(\text{NTT}\)(快速数论变换)则是在模域下的,而 \(\text{MTT} ...

  8. [C++] 频谱图中 FFT快速傅里叶变换C++实现

    在项目中,需要画波形频谱图,因此进行查找,不是很懂相关知识,下列代码主要是针对这篇文章. http://blog.csdn.net/xcgspring/article/details/4749075 ...

  9. matlab中fft快速傅里叶变换

    视频来源:https://www.bilibili.com/video/av51932171?t=628. 博文来源:https://ww2.mathworks.cn/help/matlab/ref/ ...

随机推荐

  1. 常见Web源码泄露总结

    来自:http://www.hacksec.cn/Penetration-test/474.html 摘要 背景 本文主要是记录一下常见的源码泄漏问题,这些经常在web渗透测试以及CTF中出现. .h ...

  2. XSS插入绕过一些方式总结

    详见:http://blog.csdn.net/keepxp/article/details/52054388 1 常规插入及其绕过 1.1 Script 标签 绕过进行一次移除操作: <scr ...

  3. 美图秀秀 web开发图片编辑器

    美图秀秀web开发平台 http://open.web.meitu.com/wiki/ 1.环境配置 1.1.设置crossdomain.xml 下载crossdomain.xml文件,把解压出来的c ...

  4. SQL语句 自连表查询。inner join用法,partition by ,列转行查询

    use mydb1 go -- 表T_Employee2 -- Id Name Position Dept -- 1 张三 员工 市场部 -- 2 李四 经理 销售部 -- 3 王五 经理 市场部 - ...

  5. sublime text - vintage

    使用sublime text的vim模式的同学注意了: { "color_scheme": "Packages/Color Scheme - Default/Mac Cl ...

  6. 每天一个Linux命令(44)crontab命令

        crontab命令被用来提交和管理用户需要周期性执行的任务,与windows下的计划任务类似.     (1)用法:     用法: crontab  [-u user]  file cron ...

  7. PCIE phy和控制器

    转:https://wenku.baidu.com/view/a13bc1c20722192e4436f617.html 文章中的第11页开始有划分phy和控制器部分....

  8. JavaScript笔记01——数据存储(包括.js文件的引用)

    While, generally speaking, HTML is for content and CSS is for presentation, JavaScript is for intera ...

  9. S005SELinux(SEAndroid)的实际文件组成无标题文章

    SEAndroid 是将SELinux 移植到Android 上的产物,可以看成SELinux 辅以一套适用于Android 的策略. 那么在android系统中那些文件是与SELinux(SEAnd ...

  10. 域名解析中TTL是什么意思

    在做域名解析的时候都会看到一个叫“TTL”的值,一般都有一个默认的值,不过不同注册商默认的值也会不一样,常见的是3600和7200这两个值. 另外ping的时候也可以看到“TTL=XXX”的字样,(如 ...