从零开始的FFT(ACM/OI)

1. 什么是DFT,FFT

离散傅里叶变换(Discrete Fourier Transform,DFT)傅里叶分析方法是信号分析的最基本方法,傅里叶变换是傅里叶分析的核心,通过它把信号从时间域变换到频率域,进而研究信号的频谱结构和变化规律。

——百度百科

​ 关于DFT在信号分析上的应用,本文并不涉及(因为我也不会)。

​ 在我个人看来,DFT是一个将多项式从系数表示法转换为点值表示法的算法。而FFT(Fast Fourier Transform)顾名思义,即快速计算DFT的算法。

​

2. DFT,FFT有什么用

​ 在算法竞赛中,我们常需要解决多项式A乘以多项式B并得到新多项式C的问题。

​ 使用常规的计算方法,将多项式A的每一项乘以多项式B的每一项并相加,所得的新多项式即为多项式C。假设多项式A和B均有n项,则两个多项式相乘的时间复杂度为\(O(n^2)\)

​ 常规方法计算两多项式乘积的速度令人并不满意,有没有更快的计算方法呢,答案是:有。

​ 而这就需要用到接下来要讲的DFT和FFT。

​

3. 一些前置知识

​ 学习DFT和FFT之前,需先了解以下概念:

1. 点值表示法
2. 复数

3.1 点值表示法

​ 通常,我们表示一个有n项的多项式常使用以下方法:

\[\large A(x)=a_0x^0+a_1x^1+a_2x^2+\cdots+a_{n-1}x^{n-1} \]

​

​ 这种表示方法我们称为系数表示法。

​ 除此之外,还有另一种表达多项式的方法,即上文提到的点值表示法。

​ 由数学知识可知:两点确定一条直线。即2个点可以确定一个1次多项式,相应的,n+1个点可确定一个n次多项式(带上常数项共有n+1个系数)。那么我们可以通过一系列的点表示一个多项式。

​ 表示方式如下:

\[\large A(x)=\{(x_0,A(x_0)),(x_1,A(x_1)),(x_2,A(x_2)),\cdots,(x_{n-1},A(x_{n-1}))\} \]

​

​ 举个例子:

​ 这里有一个二次多项式,其系数表示法为:

\[\large A(x)=2x^2+x-3 \]

​

​ 我们任意代入三个数:1、2、3。则

\[\large \left\{ \begin{array}{c} A(1)=0\\ A(2)=7\\ A(3)=18 \end{array} \right. \]

​

​ 换个写法,(1,0) , (2,7) , (3,18) (没错,就是三个点的坐标)

​ 所以,点值表示法为

\[\large A(x)=\{(1,0),(2,7),(3,18)\} \]

3.2 复数

3.2.1 复数的定义

​ 复数分为两部分,分别为实数和虚数,实数不多做赘述,首先定义虚数单位 \(i\),对于虚数单位 \(i\),有\(i^2 = -1\) 。

​ 则复数表示为 \(a+bi\) ,其中\(a\)为实数,\(bi\)为虚数。

​ 将\(a\)视为坐标轴上的\(x\)坐标,\(b\)视为\(y\)坐标,复数可表示为平面上的点。

(偷个图)

图中横轴代表实数,纵轴代表虚数,则图中所表示复数为\(2+3i\)

我们称图中红线所代表长度为复数的模,红线与x轴的夹角称为复数的辐角。

3.2.2 复数的运算

​ 加法 :\(\large (a+bi)+(c+di)=(a+c)+(b+d)i\)

​ 减法 :\(\large (a+bi)-(c+di)=(a-c)+(b-d)i\)

​ 乘法 :\(\large (a+bi)\times(c+di)=(ac-bd)+(ad+bc)i\)

​ 除法 :\(\large \dfrac{a+bi}{c+di}=\dfrac{(a+bi)(c-di)}{(c+di)(c-di)}=\dfrac{(ac+bd)+(bc-ad)i}{c^2+d^2}\)

​ 重点来了

​ 重点来了

​ 重点来了

​

​

​ 再看看这张图,不难发现,复数也可由红线长度\(L\)和角度\(\theta\)表示(类似于极坐标)

​ 对于复数的乘法运算,两复数的模相乘,辐角相加 (重点)

​ 使用三角函数,一个复数也可表示为: \(\large Lcos\theta+L(isin\theta)\) (重点)

​

4. DFT

​ 终于到正片了。。。

​ 前文提到,使用常规的算法计算两多项式相乘的时间复杂度是\(O(n^2)\),但将多项式转换为点值表示法再计算,如:

\[\large A(x)=\{(x_0,A(x_0)),(x_1,A(x_1),(x_2,A(x_2)),\cdots,(x_{n-1},A(x_{n-1}))\} \]

​ 和

\[\large B(x)=\{(x_0,B(x_0)),(x_1,B(x_1),(x_2,B(x_2)),\cdots,(x_{n-1},B(x_{n-1}))\} \]

​ 相乘,则得到的多项式为:

\[\large A(x)\times B(x)=\{(x_0,A(x_0)\times B(x_0)),(x_1,A(x_1)\times B(x_1)),(x_2,A(x_2)\times B(x_2)),\cdots,(x_{n-1},A(x_{n-1})\times B(x_{n-1}))\} \]

​ 不难发现,使用点值表示法进行计算,时间复杂度为\(O(n)\)。

​ 虽然两多项式相乘的时间复杂度降为\(O(n)\),但为了将系数表示法转换为点值表示法,一个n次多项式需要代入n+1个数进行计算,其时间复杂度仍为\(O(n^2)\)。

​ 有没有更好的办法呢。

​ 从此处开始,默认n为2的整数次幂(实际运算中若n不是2的整数次幂就补系数为0的项,直到n是2的整数次幂)

​ 从此处开始,默认n为2的整数次幂(实际运算中若n不是2的整数次幂就补系数为0的项,直到n是2的整数次幂)

​ 从此处开始,默认n为2的整数次幂(实际运算中若n不是2的整数次幂就补系数为0的项,直到n是2的整数次幂)

其实可以代入一组神奇的x,代入以后不用做那么多的次方运算
这些x当然不是乱取的,而且取这些x值应该就是 傅里叶 的主意了。

考虑一下,如果我们代入一些x,使每个x的若干次方等于1,我们就不用做全部的次方运算了。

​ 代入1和-1可以达到我们的目的,若考虑复数,\(i\) 和\(-i\) 同样可以,但是只有四个值远远不够。

​ 傅里叶提出:在这个圆上都可以

image-20210129190216846

​ 以原点为圆心,画一半径为1的圆,同样,x轴代表实数,y轴代表虚数。

​

​ 取值在该圆上的复数,其若干次方必可取值为1。不难理解,因取值在圆上,则模为1,其若干次方的模也必定为1,又由 \(k\theta\equiv0(mod\ 360)\) 可得,必定存在一整数\(k\)使得该等式成立,即经过\(k\)次旋转后辐角为0。

​ 接下来的问题,就是在这个圆上都要取哪些点,答案很简单,只需将这个圆等分成n份,每份之间的分界点既是我们要取的点。

​ 举个例子:假设\(n=8\),则需将圆等分成8份,如图:

​

​ 同时,我们将图中8个点所代表的复数按照辐角大小的顺序命名为:

\[\large \omega_8^0,\omega_8^1,\omega_8^2,\cdots\,,\omega_8^7 \]

​ 我们称第k个n次单位根为 \(\omega_n^k\) ,其中 \(\omega_n^0\)为1。

​ 若仅是将取到的这些特殊值代入,的确可以减少一定的计算量,但在渐进意义上直接代入求值的时间复杂度仍为\(O(n^2)\) ,这时,就该FFT出场了,它可以将两多项式求乘积的时间复杂度降为\(O(nlogn)\) 。

​

5. FFT

​ 首先是给出两个单位根的性质:

​ \(\large\omega_{2n}^{2k}=\omega_n^k\)

​ 证明: \(\large \omega_n^k=cos(\frac{k}{n}\cdot2\pi)+isin(\frac{k}{n}\cdot2\pi)=cos(\frac{2k}{2n}\cdot2\pi)+isin(\frac{2k}{2n}\cdot2\pi)=\omega_{2n}^{2k}\)

​ \(\large \omega_n^{k+\frac{n}{2}}=-\omega_n^k\)

​ 证明: \(\large \omega_n^{k+\frac{n}{2}}=\omega_n^k\cdot\omega_n^{\frac{n}{2}}=\omega_n^k\cdot-1=-\omega_n^k\)

​ 对于多项式

\[\large A(x)=a_0x^0+a_1x^1+a_2x^2+\cdots+a_{n-1}x^{n-1} \]

​

​ 将里面的项换一下位置,将\(x\)的指数为偶数的项放在左边,\(x\)的指数为奇数的项放右边,可以得到

\[\large A(x)=(a_0x^0+a_2x^2+\cdots+a_{n-2}x^{n-2}+a_nx^n)+(a_1x^1+a_3x^3+\cdots+a_{n-3}x^{n-3}+a_{n-1}x^{n-1})\\ \large =(a_0x^0+a_2x^2+\cdots+a_{n-2}x^{n-2}+a_nx^n)+x(a_1x^0+a_3x^2+\cdots+a_{n-3}x^{n-4}+a_{n-1}x^{n-2}) \]

​ 设两多项式\(A_0(x)\)和\(A_1(x)\),使得

\[\large A_0(x)=a_0x^0+a_2x^1+\cdots+a_{n}x^{\frac{n}{2}}\\ \large A_1(x)=a_1x^0+a_3x^1+\cdots+a_{n-1}x^{\frac{n}{2}-1} \]

​

​ 也就是\(A_0(x)\) 为\(A(x)\) 中\(x\)的指数为奇数次的,\(A_1(x)\) 为\(A(x)\) 中\(x\)的指数为偶数次的。

​ 不难发现,\(\large A(x)=A_0(x^2)+xA_1(x^2)\)

​ 也就是说\(A(x)\)变为两个项数为原来一半的多项式的和。

​ 将我们之前取到的特殊值代入:

\[\large A(\omega_n^k)=A_0(\omega_n^{2k})+\omega_n^k \cdot A_1(\omega_n^{2k}) \]

​ 又因为\(\large \omega_{2n}^{2k}=\omega_n^k\) ,

\[\large A(\omega_n^k)=A_0(\omega_{\frac{n}{2}}^{k})+\omega_n^k \cdot A_1(\omega_{\frac{n}{2}}^{2k}) \]

​

​ 没错,这意味着我们可以用递归计算一个多项式的值了,递归到\(n=1\)时直接返回就行。

​

​ 而且又因为\(\large \omega_n^{k+\frac{n}{2}}=-\omega_n^k\) 这个性质,我们计算一个n次多项式时仅递归求出前\(\large \frac{n}{2}\) 项即可,后\(\large \frac{n}{2}\) 项可直接由前\(\large \frac{n}{2}\) 求出。

​ 这样,我们计算代入一个数的时间复杂度为\(O(logn)\),我们需要代入\(n\)个数计算,则最终时间复杂度为\(O(nlogn)\)。

​

​ 附上代码:

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

double pi=acos(-1);
complex<double> a[5000005],b[5000005];

void fft(complex <double> *arr,int len)//len是数组arr的长度
{
    if(len==1) return;//数组长度为1时直接返回
    complex<double> a0[(len>>1)+5],a1[(len>>1)+5];
    for(int i=1;i<=(len>>1);i++)//将arr数组中的偶次项系数和奇次项系数分别赋给a0数组和a1数组
    {
        a0[i]=arr[(i<<1)-1];
        a1[i]=arr[i<<1];
    }
    fft(a0,len>>1);//递归处理a0和a1
    fft(a1,len>>1);
    complex<double> w0(1,0);//初始值
    complex<double> w(cos(2*pi/len),sin(2*pi/len));//w0每次需旋转的角度
    for(int i=1;i<=(len>>1);i++,w0*=w)//循环每次都使w0旋转一次
    {
        arr[i]=a0[i]+w0*a1[i];
        arr[i+(len>>1)]=a0[i]-w0*a1[i];//使用单位根的性质直接求出数组后半部分
    }
    return;
}

​

6. IDFT(傅里叶逆变换)

​ 我们已经将多项式转换为点值表示,并经过了相乘得到新多项式,但它现在仍是点值表示法,我们要将新多项式从点指表示转换回系数表示。

​ 对于前面的DFT的过程,可以用矩阵表示为

\[\large \begin{gathered} \begin{pmatrix} ({\omega_n^0})^0 & ({\omega_n^0})^1 & \cdots & ({\omega_n^0})^{n-1} \\ ({\omega_n^1})^0 & ({\omega_n^1})^1 & \cdots & ({\omega_n^1})^{n-1} \\ ({\omega_n^2})^0 & ({\omega_n^2})^1 & \cdots & ({\omega_n^2})^{n-1} \\ \vdots & \vdots & \ddots & \vdots\\ ({\omega_n^{n-1}})^0 & ({\omega_n^{n-1}})^1 & \cdots & ({\omega_n^{n-1}})^{n-1} \\ \end{pmatrix} \begin{pmatrix} a_0\\a_1\\a_2\\ \vdots \\ a_{n-1} \end{pmatrix} = \begin{pmatrix} A(\omega_n^0)\\ A(\omega_n^1)\\ A(\omega_n^2)\\ \vdots \\ A(\omega_n^{n-1}) \end{pmatrix} \end{gathered} \]

​ 最右边和最左边的矩阵已知,我们需要求中间那个矩阵。也就是先求最左边那个矩阵的逆矩阵\(R\),再将\(R\)乘右边的矩阵可得到中间的矩阵。

​ 对于左边矩阵的逆矩阵\(R\),有一个特殊的性质,\(R\) 中的每一项恰等于左边矩阵每一项的倒数再除以\(n\) ,即:

\[\large R= \large \begin{gathered} \begin{pmatrix} \frac{1}{n}(\omega_n^{-0})^0 & \frac{1}{n}(\omega_n^{-0})^1 & \cdots & \frac{1}{n}(\omega_n^{-0})^{n-1}\\ \frac{1}{n}(\omega_n^{-1})^0 & \frac{1}{n}(\omega_n^{-1})^1 & \cdots & \frac{1}{n}(\omega_n^{-1})^{n-1}\\ \frac{1}{n}(\omega_n^{-2})^0 & \frac{1}{n}(\omega_n^{-2})^1 & \cdots & \frac{1}{n}(\omega_n^{-2})^{n-1}\\ \vdots & \vdots & \ddots & \vdots \\ \frac{1}{n}(\omega_n^{-(n-1)})^0 & \frac{1}{n}(\omega_n^{-(n-1)})^1 & \cdots & \frac{1}{n}(\omega_n^{-(n-1)})^{n-1}\\ \end{pmatrix} \end{gathered} \]

​

​ 以上这些操作,在代码中实现十分简单,只需将前面给出的FFT代码稍作修改即可。

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

double pi=acos(-1);
complex<double> a[5000005],b[5000005];

void fft(complex<double> *arr,int len,int o)//传入的参数多了一个o,o的值决定了是进行DFT操作还是IDFT操作
{
    if(len==1) return;
    complex<double> a0[(len>>1)+5],a1[(len>>1)+5];
    for(int i=1;i<=(len>>1);i++)
    {
        a0[i]=arr[(i<<1)-1];
        a1[i]=arr[i<<1];
    }
    fft(a0,len>>1,o);
    fft(a1,len>>1,o);
    complex<double> w0(1,0);
    complex<double> w(cos(2*pi/len),o*sin(2*pi/len));//当o=1时代表是系数转点值,o=-1时代表点值转系数
    for(int i=1;i<=(len>>1);i++,w0*=w)
    {
        arr[i]=a0[i]+w0*a1[i];
        arr[i+(len>>1)]=a0[i]-w0*a1[i];
    }
    return;
}

​

​ 计算两个多项式的乘积主函数这么写就行:

​ (摘自 洛谷P3803 的代码)

int main()
{
    ios::sync_with_stdio(0);
    cin.tie(0);
    int n,m;//两个多项式的次数
    while(cin>>n>>m)
    {
        memset(a,0,sizeof(a));
        memset(b,0,sizeof(b));
        for(int i=1;i<=n+1;i++)
            cin>>a[i];
        for(int i=1;i<=m+1;i++)
            cin>>b[i];
        int len=1;
        while(len<=n+m) len<<=1;//长度补足至2的整数次幂
        fft(a,len,1);//DFT操作o=1
        fft(b,len,1);
        for(int i=1;i<=len;i++)
            a[i]*=b[i];
        fft(a,len,-1);//IDFT操作o=-1
        for(int i=1;i<=(n+m+1);i++)
            cout<<(int)(a[i].real()/len+0.5)<<" ";//防止浮点数误差加一个0.5
        cout<<"\n";
    }
    return 0;
}

参考资料:

十分简明易懂的FFT(快速傅里叶变换)

FFT从入门到使用(ACM/OI)

浅谈 FFT (终于懂一点了~~)

FFT的迭代写法以后再更


posted @ 2021-02-01 15:34  null4785  阅读(666)  评论(0)    收藏  举报