快速傅里叶变换(FFT)详解

本文只讨论FFT在信息学奥赛中的应用html

文中内容均为我的理解,若有错误请指出,不胜感激ios

前言

先解释几个比较容易混淆的缩写吧c++

DFT:离散傅里叶变换—>$O(n^2)$计算多项式乘法算法

FFT:快速傅里叶变换—>$O(n*\log(n)$计算多项式乘法数组

FNTT/NTT:快速傅里叶变换的优化版—>优化常数及偏差ide

FWT:快速沃尔什变换—>利用相似FFT的东西解决一类卷积问题学习

MTT:毛爷爷的FFT—>很是nb/任意模数优化

FMT 快速莫比乌斯变化—>感谢stump提供spa

多项式

系数表示法

设$A(x)$表示一个$n-1$次多项式code

则$A(x)=\sum_{i=0}^{n} a_i * x^i$

例如:$A(3)=2+3*x+x^2$

利用这种方法计算多项式乘法复杂度为$O(n^2)$

(第一个多项式中每一个系数都须要与第二个多项式的每一个系数相乘)

点值表示法

将$n$互不相同的$x$带入多项式,会获得$n$个不一样的取值$y$

则该多项式被这$n$个点$(x_1,y_1),(x_2,y_2),\dots,(x_n,y_n)$惟一肯定

其中$y_i=\sum_{j=0}^{n-1} a_j*x_i^j$

例如:上面的例子用点值表示法能够为$(0,2),(1,6),(2,12)$

利用这种方法计算多项式乘法的时间复杂度仍然为$O(n^2)$

(选点$O(n)$,每次计算$O(n)$)

 

咱们能够看到,两种方法的时间复杂度都为$O(n^2)$,咱们考虑对其进行优化

对于第一种方法,因为每一个点的系数都是固定的,想要优化比较困难

对于第二种方法,貌似也没有什么好的优化方法,不过当你看完下面的知识,或许就不这么想了

 

复数

在介绍复数以前,首先介绍一些可能会用到的东西

向量

同时具备大小和方向的量

在几何中一般用带有箭头的线段表示

圆的弧度制

等于半径长的圆弧所对的圆心角叫作1弧度的角,用符号rad表示,读做弧度。用弧度做单位来度量角的制度叫作弧度制

公式:

$1^{\circ }=\dfrac{\pi}{180}rad$

$180^{\circ }=\pi rad$

平行四边形定则

 

(好像画的不是很标准。。)

平行四边形定则:AB+AD=AC

 

复数

定义

设$a,b$为实数,$i^2=-1$,形如$a+bi$的数叫复数,其中$i$被称为虚数单位,复数域是目前已知最大的域

在复平面中,$x$表明实数,$y$轴(除原点外的点)表明虚数,从原点$(0,0)$到$(a,b)$的向量表示复数$a+bi$

模长:从原点$(0,0)$到点$(a,b)$的距离,即$\sqrt{a^2+b^2}$

幅角:假设以逆时针为正方向,从$x$轴正半轴到已知向量的转角的有向角叫作幅角

运算法则

加法:

由于在复平面中,复数能够被表示为向量,所以复数的加法与向量的加法相同,都知足平行四边形定则(就是上面那个)

乘法:

几何定义:复数相乘,模长相乘,幅角相加

代数定义:$$(a+bi)*(c+di)$$

$$=ac+adi+bci+bdi^2$$

$$=ac+adi+bci-bd$$

$$=(ac-bd)+(bc+ad)i$$

 

单位根

下文中,默认$n$为$2$的正整数次幂

在复平面上,以原点为圆心,$1$为半径做圆,所得的圆叫单位圆。以圆点为起点,圆的$n$等分点为终点,作$n$个向量,设幅角为正且最小的向量对应的复数为$\omega_n$,称为$n$次单位根。

根据复数乘法的运算法则,其他$n-1$个复数为$\omega_n^2,\omega_n^3,\ldots,\omega_n^n$

注意$\omega_n^0=\omega_n^n=1$(对应复平面上以$x$轴为正方向的向量)

那么如何计算它们的值呢?这个问题能够由欧拉公式解决$$\omega_{n}^{k}=\cos\ k *\frac{2\pi}{n}+i\sin k*\frac{2\pi}{n}$$

 

 

例如

 

图中向量$AB$表示的复数为$8$次单位根

单位根的幅角为周角的$\frac{1}{n}$

在代数中,若$z^n=1$,咱们把$z$称为$n$次单位根

单位根的性质

  • $\omega _{n}^{k}=\cos k\dfrac{2\pi}{n}+i\sin k\dfrac {2\pi }{n}$(即上面的公式)
  • $\omega _{2n}^{2k}=\omega _{n}^{k}$

证实:

$$\omega _{2n}^{2k}=\cos 2k*\frac{2\pi}{2n}+i\sin2k*\frac{2\pi}{2n}$$

$$=\omega _{n}^{k}$$

  • $\omega _{n}^{k+\frac{n}{2}}=-\omega _{n}^{k}$

$$\omega _{n}^{\frac{n}{2}}=\cos\frac{n}{2}*\frac{2\pi}{n}+i\sin\frac{n}{2}*\frac{2\pi}{n}$$

$$=\cos \pi+i\sin\pi$$

$$=-1$$

  • $\omega _{n}^{0}=\omega _{n}^{n}=1$

 讲了这么多,貌似跟咱们的正题没啥关系啊。。

 OK!各位坐稳了,前方高能!

快速傅里叶变换

咱们前面提到过,一个$n$次多项式能够被$n$个点惟一肯定。

那么咱们能够把单位根的$0$到$n-1$次幂带入,这样也能够把这个多项式肯定出来。可是这样仍然是$O(n^2)$的呀!

咱们设多项式$A(x)$的系数为$(a_o,a_1,a_2,\ldots,a_{n-1})$

那么$$A(x)=a_0+a_1*x+a_2*{x^2}+a_3*{x^3}+a_4*{x^4}+a_5*{x^5}+ \dots+a_{n-2}*x^{n-2}+a_{n-1}*x^{n-1}$$

将其下标按照奇偶性分类

$$A(x)=(a_0+a_2*{x^2}+a_4*{x^4}+\dots+a_{n-2}*x^{n-2})+(a_1*x+a_3*{x^3}+a_5*{x^5}+ \dots+a_{n-1}*x^{n-1})$$

 

$$A_1(x)=a_0+a_2*{x}+a_4*{x^2}+\dots+a_{n-2}*x^{\frac{n}{2}-1}$$

$$A_2(x)=a_1+a_3*{x}+a_5*{x^2}+ \dots+a_{n-1}*x^{\frac{n}{2}-1}$$

那么不可贵到

$$A(x)=A_1(x^2)+xA_2(x^2)$$

咱们将$\omega_n^k (k<\frac{n}{2}) $代入得

$$A(\omega_n^k)=A_1(\omega_n^{2k})+\omega_n^kA_2(\omega_n^{2k})$$

$$=A_1(\omega_{\frac{n}{2}}^{k})+\omega_n^kA_2(\omega_{\frac{n}{2}}^{k})$$

同理,将$\omega_n^{k+\frac{n}{2}}$代入得

$$A(\omega_n^{k+\frac{n}{2}})=A_1(\omega_n^{2k+n})+\omega_n^{k+\frac{n}{2}}(\omega_n^{2k+n})$$

$$=A_1(\omega_n^{2k}*\omega_n^n)-\omega_n^kA_2(\omega_n^{2k}*\omega_n^n)$$

$$=A_1(\omega_n^{2k})-\omega_n^kA_2(\omega_n^{2k})$$

 

你们有没有发现什么规律?

没错!这两个式子只有一个常数项不一样!

那么当咱们在枚举第一个式子的时候,咱们能够$O(1)$的获得第二个式子的值

又由于第一个式子的$k$在取遍$[0,\frac{n}{2}-1]$时,$k+\frac{n}{2}$取遍了$[\frac{n}{2},n-1]$

因此咱们将原来的问题缩小了一半!

而缩小后的问题仍然知足原问题的性质,因此咱们能够递归的去搞这件事情!

直到多项式仅剩一个常数项,这时候咱们直接返回就好啦

 

时间复杂度:

不难看出FFT是相似于线段树同样的分治算法。

所以它的时间复杂度为$O(nlogn)$

 

快速傅里叶逆变换

不要觉得FFT到这里就结束了。

咱们上面的讨论是基于点值表示法的。

可是在日常的学习和研究中不多用点值表示法来表示一个多项式。

因此咱们要考虑如何把点值表示法转换为系数表示法,这个过程叫作傅里叶逆变换

 

$(y_0,y_1,y_2,\dots,y_{n-1})$为$(a_0,a_1,a_2,\dots,a_{n-1})$的傅里叶变换(即点值表示)

设有另外一个向量$(c_0,c_1,c_2,\dots,c_{n-1})$知足

$$c_k=\sum_{i=0}^{n-1}y_i(\omega_n^{-k})^i$$

即多项式$B(x)=y_0,y_1x,y_2x^2,\dots,y_{n-1}x^{n-1}$在$\omega_n^{0},\omega_n^{-1},\omega_n^{-2},\dots,\omega_{n-1}^{-(n-1)}$处的点值表示

emmmm又到推公式时间啦

$(c_0,c_1,c_2,\dots,c_{n-1})$知足
$$c_k=\sum_{i=0}^{n-1}y_i(\omega_n^{-k})^i$$

$$=\sum_{i=0}^{n-1}(\sum_{j=0}^{n-1}a_j(\omega_n^i)^j)(\omega_n^{-k})^i$$

$$=\sum_{i=0}^{n-1}(\sum_{j=0}^{n-1}a_j(\omega_n^j)^i)(\omega_n^{-k})^i$$

$$=\sum_{i=0}^{n-1}(\sum_{j=0}^{n-1}a_j(\omega_n^j)^i(\omega_n^{-k})^i)$$

$$=\sum_{i=0}^{n-1}\sum_{j=0}^{n-1}a_j(\omega_n^j)^i(\omega_n^{-k})^i$$

$$=\sum_{i=0}^{n-1}\sum_{j=0}^{n-1}a_j(\omega_n^{j-k})^i$$

$$=\sum_{j=0}^{n-1}a_j(\sum_{i=0}^{n-1}(\omega_n^{j-k})^i)$$

 

设$S(x)=\sum_{i=0}^{n-1}x^i$

将$\omega_n^k$代入得

$$S(\omega_n^k)=1+(\omega_n^k)+(\omega_n^k)^2+\dots(\omega_n^k)^{n-1}$$

当$k!=0$时

等式两边同乘$\omega_n^k$得

$$\omega_n^kS(\omega_n^k)=\omega_n^k+(\omega_n^k)^2+(\omega_n^k)^3+\dots(\omega_n^k)^{n}$$

两式相减得

$$\omega_n^kS(\omega_n^k)-S(\omega_n^k)=(\omega_n^k)^{n}-1$$

$$S(\omega_n^k)=\frac{(\omega_n^k)^{n}-1}{\omega_n^k-1}$$

$$S(\omega_n^k)=\frac{(\omega_n^n)^{k}-1}{\omega_n^k-1}$$

$$S(\omega_n^k)=\frac{1-1}{\omega_n^k-1}$$

观察这个式子,不难看出它分母不为0,可是分子为0

所以,当$K!=0$时,$S(\omega^{k}_{n})=0$

那当$k=0$时呢?

很显然,$S(\omega^{0}_{n})=n$

 

继续考虑刚刚的式子

$$c_k=\sum_{j=0}^{n-1}a_j(\sum_{i=0}^{n-1}(\omega_n^{j-k})^i)$$
当$j \neq k$时,值为$0$
当$j=k$时,值为$n$
所以,
$$c_k=na_k$$
$$a_k=\frac{c_k}{n}$$

这样咱们就获得点值与系数之间的表示啦

 

理论总结

至此,FFT的基础理论部分就结束了。

咱们来小结一下FFT是怎么成功实现的

 

首先,人们在用系数表示法研究多项式的时候遇阻

因而开始考虑可否用点值表示法优化这个东西。

而后根据复数的两条性质(这个思惟跨度比较大)获得了一种分治算法。

最后又推了一波公式,找到了点值表示法与系数表示法之间转换关系。

 

emmmm

其实FFT的实现思路大概就是

系数表示法—>点值表示法—>系数表示法

引用一下远航之曲大佬的图

 

 

固然,在实现的过程当中还有不少技巧

咱们根据代码来理解一下

 

递归实现

递归实现的方法比较简单。

就是按找咱们上面说的过程,不断把要求的序列分红两部分,再进行合并

在c++的STL中提供了现成的complex类,可是我不建议你们用,毕竟手写也就那么几行,并且万一某个毒瘤卡STL那岂不是很GG?

 

// luogu-judger-enable-o2
// luogu-judger-enable-o2
#include<iostream>
#include<cstdio>
#include<cmath>
using namespace std;
const int MAXN = 4 * 1e6 + 10;
inline int read() {
    char c = getchar(); int x = 0, f = 1;
    while (c < '0' || c > '9') {if (c == '-')f = -1; c = getchar();}
    while (c >= '0' && c <= '9') {x = x * 10 + c - '0'; c = getchar();}
    return x * f;
}
const double Pi = acos(-1.0);
struct complex {
    double x, y;
    complex (double xx = 0, double yy = 0) {x = xx, y = yy;}
} a[MAXN], b[MAXN];
complex operator + (complex a, complex b) { return complex(a.x + b.x , a.y + b.y);}
complex operator - (complex a, complex b) { return complex(a.x - b.x , a.y - b.y);}
complex operator * (complex a, complex b) { return complex(a.x * b.x - a.y * b.y , a.x * b.y + a.y * b.x);} //不懂的看复数的运算那部分
void fast_fast_tle(int limit, complex *a, int type) {
    if (limit == 1) return ; //只有一个常数项
    complex a1[limit >> 1], a2[limit >> 1];
    for (int i = 0; i <= limit; i += 2) //根据下标的奇偶性分类
        a1[i >> 1] = a[i], a2[i >> 1] = a[i + 1];
    fast_fast_tle(limit >> 1, a1, type);
    fast_fast_tle(limit >> 1, a2, type);
    complex Wn = complex(cos(2.0 * Pi / limit) , type * sin(2.0 * Pi / limit)), w = complex(1, 0);
    //Wn为单位根,w表示幂
    for (int i = 0; i < (limit >> 1); i++, w = w * Wn) //这里的w至关于公式中的k
        a[i] = a1[i] + w * a2[i],
               a[i + (limit >> 1)] = a1[i] - w * a2[i]; //利用单位根的性质,O(1)获得另外一部分
}
int main() {
    int N = read(), M = read();
    for (int i = 0; i <= N; i++) a[i].x = read();
    for (int i = 0; i <= M; i++) b[i].x = read();
    int limit = 1; while (limit <= N + M) limit <<= 1;
    fast_fast_tle(limit, a, 1);
    fast_fast_tle(limit, b, 1);
    //后面的1表示要进行的变换是什么类型
    //1表示从系数变为点值
    //-1表示从点值变为系数
    //至于为何这样是对的,能够参考一下c向量的推导过程,
    for (int i = 0; i <= limit; i++)
        a[i] = a[i] * b[i];
    fast_fast_tle(limit, a, -1);
    for (int i = 0; i <= N + M; i++) printf("%d ", (int)(a[i].x / limit + 0.5)); //按照咱们推倒的公式,这里还要除以n
    return 0;
}
递归版

 

update:递归版我本地是能够AC的,只要开大数组就能够了,可是交到洛谷上会WA0

 

 

woc?  脸好疼。。。。。。

咳咳。

速度什么的才不是关键呢?

关键是咱们AC不了啊啊啊

表着急,AC不了不表明我们的算法不对,只能说这种实现方法太low了

下面介绍一种更高效的方法

 

迭代实现

再盗一下那位大佬的图

 

观察一下原序列和反转后的序列?

聪明的你有没有看出什么显而易见的性质?

没错!

咱们须要求的序列实际是原序列下标的二进制反转!

所以咱们对序列按照下标的奇偶性分类的过程实际上是没有必要的

 

这样咱们能够$O(n)$的利用某种操做获得咱们要求的序列,而后不断向上合并就行了

 

// luogu-judger-enable-o2
#include<iostream>
#include<cstdio>
#include<cmath>
using namespace std;
const int MAXN = 1e7 + 10;
inline int read() {
    char c = getchar(); int x = 0, f = 1;
    while (c < '0' || c > '9') {if (c == '-')f = -1; c = getchar();}
    while (c >= '0' && c <= '9') {x = x * 10 + c - '0'; c = getchar();}
    return x * f;
}
const double Pi = acos(-1.0);
struct complex {
    double x, y;
    complex (double xx = 0, double yy = 0) {x = xx, y = yy;}
} a[MAXN], b[MAXN];
complex operator + (complex a, complex b) { return complex(a.x + b.x , a.y + b.y);}
complex operator - (complex a, complex b) { return complex(a.x - b.x , a.y - b.y);}
complex operator * (complex a, complex b) { return complex(a.x * b.x - a.y * b.y , a.x * b.y + a.y * b.x);} //不懂的看复数的运算那部分
int N, M;
int l, r[MAXN];
int limit = 1;
void fast_fast_tle(complex *A, int type) {
    for (int i = 0; i < limit; i++)
        if (i < r[i]) swap(A[i], A[r[i]]); //求出要迭代的序列
    for (int mid = 1; mid < limit; mid <<= 1) { //待合并区间的长度的一半
        complex Wn( cos(Pi / mid) , type * sin(Pi / mid) ); //单位根
        for (int R = mid << 1, j = 0; j < limit; j += R) { //R是区间的长度,j表示前已经到哪一个位置了
            complex w(1, 0); //
            for (int k = 0; k < mid; k++, w = w * Wn) { //枚举左半部分
                complex x = A[j + k], y = w * A[j + mid + k]; //蝴蝶效应
                A[j + k] = x + y;
                A[j + mid + k] = x - y;
            }
        }
    }
}
int main() {
    int N = read(), M = read();
    for (int i = 0; i <= N; i++) a[i].x = read();
    for (int i = 0; i <= M; i++) b[i].x = read();
    while (limit <= N + M) limit <<= 1, l++;
    for (int i = 0; i < limit; i++)
        r[i] = ( r[i >> 1] >> 1 ) | ( (i & 1) << (l - 1) ) ;
    // 在原序列中 i 与 i/2 的关系是 : i能够看作是i/2的二进制上的每一位左移一位得来
    // 那么在反转后的数组中就须要右移一位,同时特殊处理一下奇数
    fast_fast_tle(a, 1);
    fast_fast_tle(b, 1);
    for (int i = 0; i <= limit; i++) a[i] = a[i] * b[i];
    fast_fast_tle(a, -1);
    for (int i = 0; i <= N + M; i++)
        printf("%d ", (int)(a[i].x / limit + 0.5));
    return 0;
}
相关文章
相关标签/搜索