\(2019.2.18upd:\) \(LINK\)
之前写的比较适合未接触FFT的人阅读……但是有几个地方出了错,大家可以找一下233
啊…本来觉得这是个比较良心的算法没想到这么抽搐这个算法真是将一个人的自学能力锻炼到了极致\(qwq\)
好的,那我们就开始我们的飞飞兔\(FFT\)算法吧!
偷偷说一句,\(FFT\)的代码十分的短哦~并且如果你不喜欢看算法,你可以翻到最下面看心得哟!
写在前面
·好多你不理解的地方在代码里就只有半行。
·三个引理中,只有消去引理跟算法的实现没有关系——消去引理主要是用来证明的\(qwq\)。
·不是特别熟悉矩阵的同学,看到“矩阵”字样时不要慌,矩阵只是辅助工具,跟算法的实现关系不大,但是却是理论的基石。
多项式乘法(卷积
对于多项式\(A\)和多项式\(B\),两者是关于\(x\)的\(n\)次多项式,算上常数项均有\(N+1\)项,那么他们的简单版表示就是\[A(x) = \sum\limits_{i = 0}^{n}{a_ix^i} = a_0 + a_1x + a_2x^2.....+a_nx^n\] \[B(x) = \sum\limits_{i = 0}^{n}{b_ix^i} = b_0 + b_1x + b_2x^2.....+b_nx^n\]
那么显然的话,两者相乘之后得到\(C\),他应该有\[c_i = \sum\limits_{j=0}^{i}{a_jb_{i-j}} \]此处隐含着多项式是按次数的单调性给出的\(qwq.\)
不难看出\(C(x)\)一共由\(2n+1\)项。如果你单纯地用\(FFT\)来做那些友好的题的话,那这其实也无妨理解为\(C\)就是\(A\)和\(B\)的卷积。
\(emmmm\)那么很显然的是,朴素的多项式乘法的时间复杂度是\(O(n^2)\),不是特别可以接受,于是我们思考优化,便引入了\(DFT\)和\(IDFT\)这两个毒瘤可爱的操作\(qwq\)
\(\mathcal{First\_step}\)·换个思路思考多项式
好的,我们现在的问题是,对于这个\(O(n^2)\)的算法进行优化。我们考虑系数乘法的复杂度,一定会是\(O(n^2)\)的,因为其中的每一步都不可忽略,所以我们期望换个思路来搞一搞。
我们知道,原本的多项式是系数表示法,现在我们换个思路,将其转化为点值表示法。即我们可以把多项式\(f(x)\)看作在平面直角坐标系上的函数\(f(x)\),那么这个\(n\)阶函数就可以由\(n+1\)个点唯一确定\((\)不了解的的右转选修\(4-6\)),即\[f(x)<=>{(x_0,y_0),(x_1,y_1),(x_2,y_2)....(x_n,y_n)}\]那么\[\forall k,y_k = f(x_k)\]这是很显然的,并且学过函数的同学们应该知道,这\(n+1\)个点是随意选取的——只要求它们相异即可。
·前置技能一·点乘法:
假设我们有两个关于\(x\)的\(N\)次多项式A(x)和B(x),他们的点乘表达式均已知:\[A(x) = {(x_0,y_0),(x_1,y_1)....(x_n,y_n)}\] \[B(x) = {(x_0,y_0'),(x_1,y_1')....(x_n,y_n)}\]那么他们相加的话,\(O(N)\)即可\(qwq\) \[A(x)+B(x) = {(x_0,y_0+y_0'),(x_1,y_1+y_1')(x_n,y_n+y_n')}\]对于多项式乘法,我们不能传统地按位相加,因为上文中我们提到过\(C(x)\)的项数为\(2n+1\),所以我们考虑补上一堆项(并不是空项),让他们可以像多项式加法一样直接按位运算,类似这样\[A(x) = {(x_0,y_0),(x_1,y_1)....(x_{2n},y_{2n})}\] \[B(x) = {(x_0,y_0'),(x_1,y_1')....(x_{2n},y_{2n})}\] 做乘法可得 \[A(x)B(x) = {(x_0,y_0y_0'),(x_1,y_1y_1')(x_{2n},y_{2n}y_{2n}')}\] 所以我们考虑补项,之后再做点乘,得到\(C(x)\)的点乘表达式。
我们观察点乘法,它的时间复杂度达到了惊人的\(O(n)\),完全可以接受。这便是为什么化系数乘法为点乘法的原因。那么现在我们已经解决了它的主要部分,算法的大体思路也有了:
得到\(A(x)\)和\(B(x)\) ——>对于每个多项式,选取\(n+1\)个点,得出点值表达式\((O(n^2))\) ——>点乘法\((O(n))\)——>将得出来的\(C(x)\)的点值表达式再转换成系数表达式\((O(n^2))\)
这就是\(FFT\)的大体流程。转化之后怎么没多快常数还大了
虽然其余部分的时间复杂度还是很麻烦的\(O(n^2)\),但是都是可以优化成\(O(nlogn)\)的,我们先来看比较朴素的剩余步骤。因为对于第一步,我们可以用秦九韶算法,\(O(n)\)求出一个点,\(O(n^2)\)求出所有点。那么现在我们来思索第三步:
·前置技能二·插值法
看过选修\(4-6\)的小伙伴们对此一定不陌生。那么对于插值法的简要定义如下:
求值计算的逆(从一个多项式的点值表示确定其系数表示中的系数)称为插值(\(interpolation\))
首先我们要明确的一点是,多项式插值具有唯一性,你可以考虑使用“显然意会”法我们接下来证明一下:
我们证明这玩意儿类似于证明矩阵可逆,比如我们借助增广矩阵的相关知识可以得出有如下:
\(\begin{vmatrix} 1 & x_0 & x_0^2 & \cdots & x_0^n \\ 1 & x_1 & x_1^2 & \cdots & x_1^n \\ 1 & x_2 & x_2^2 & \cdots & x_2^n \\ \vdots & \vdots & \vdots & \ddots & \vdots \\ 1 & x_n & x_n^2 & \cdots & x_n^n \\ \end{vmatrix}\) \(\begin{vmatrix} a_0 \\ a_1 \\ a_2 \\ \vdots \\ a_n \end{vmatrix}\) = \(\begin{vmatrix} y_0 \\ y_1 \\ y_2 \\ \vdots \\ y_n \end{vmatrix}\)
这可以很显然地表达出我们的第一步——将系数表达式转化为点值表达式。
我们观察最左边的矩阵,没错,他就是范德蒙德矩阵,记作\(V(x_0,x_1 \cdots x_n)\)。而对于范德蒙德矩阵,他的行列式计算公式为\[det(V) = \prod\limits_{0 \leq j
那我们考虑在弦形袋鼠中有讲过,这个矩阵的行列式非\(0\)即可逆,所以我们可以得到唯一的系数表达\(\vec{A}\),使得\(\vec{A} = V(x_0,x_1 \cdots x_n)^{-1} \times \vec{y}\)
证毕。
然而这样的话,我们在插值的时候可以考虑直接求出范德蒙德矩阵的逆矩阵。\(emmmmm\)但是我们惊奇的发现对矩阵求逆是\(O(n^3)\)的!
好吧,成功负优化了。
嗯,兄弟莫慌,我们有拉格朗日插值公式呢!所以我们可以\(O(n^2)\)求出来这个多项式
\[F(x) = \sum\limits_{k=0}^{n}{y_k\frac{\prod\limits_{j \not= k}^{}{(x-x_j)}}{\prod\limits_{j \not= k}^{}{(x_k-x_j)}}}\]
哈,我们成功地追平了没有优化时的、渐进意义下的时间复杂度!
那这优化好像除了增添一个贼大的常数啥都不能干
嗯,读到这儿,你已经会了\(DFT\)(离散傅立叶变换)和\(IDFT\)(逆离散傅立叶变换)了!实际上,\(DFT\)对应着的就是我们把系数表达式映射到点值表达式的过程,\(IDFT\)对应着的就是我们把点值表达式映射到系数表达式的过程!
那么对这个东西进行加速的就是\(FFT \ \ \ \ \ \ Fast \ \ Fourier \ \ Transformation\)啦!
\(\mathcal{Second\_step}\)·\(FFT\)究竟是如何优化的
下面来解释真相——为何这种变换策略可以称之为优化?
因为实际上,我们的第一步——求值(系数转点值)和我们的第三步(点值转系数)都是可以做到\(nlogn\)的,那么总的时间复杂度,渐进意义下就是\(O(nlogn)\)的!大胜利!
下面就让我们来看看如何优化:
·前置技能三·单位复根
\(n\)次单位复根是满足\(w^n = 1\) 的复数\(w\),其中我们可以由复数的运算法则(辐角相乘,模相加),我们可以很简单地得出\(n\)次单位根有\(n\)个这个结论,而又因为复数\(w^n\)在复数平面上的模都是一,所以相乘之后还会是一,那么所有的\(w_i,1 \leq i \leq
图源自于《算法导论》。
我们考虑欧拉公式:
不了解的戳这里,文章的第四个标题\[e^{ix} = cosx + isinx\]
我们取\(x =2\pi\),可以得到如下关系式:\[e^{2 \pi i} = 1 = w^n\]从而\[w = e^{\frac{2\pi i}{n}}\]
我们把此时的单位根称之为主次单位根,记作\[w_n = e^{\frac{2\pi i}{n}} \]那么对于其他的单位根,记作\[w_n^k=e^{\frac{2\pi ik}{n}},0 \leq k < n\]都是主次单位根的整次幂。大家可以考虑借助上面那张图图意会一下\(qwq.\)
诶,这个有啥用啊\(QAQ\)?
那是因为单位根们有一堆特别好用的性质,让我们可以将数据规模不断折半,使得其达到\(nlogn\)的复杂度……
那么我们先来看其支持其规模减半的引理:
·消去引理
引理:对任何整数\(n \geq 0,k \geq 0,d >0\),有\[w_{dn}^{dk} = w_n^k\]这个好像很好证的样子……代入定义可以获得\[w_{dn}^{dk} = e^{\frac{2\pi dk}{dn}} = e^{\frac{2\pi ik}{n}} = w_n^k,\]那么由此我们可以推导出下面的引理:
·折半引理
引理:对于任何大于\(0\)的偶数\(n\),都有\(n\)个\(n\)次单位复根的平方的集合,等于\(\frac{n}{2}\)个\(\frac{n}{2}\)次单位复根的集合。
我们可以由消去引理得到\[(w_n^k)^2 = w_{n/2}^k\]这个……也可以尝试意会。那么接下来,如果对所有的\(n\)次单位跟平方一下,我们会发现\(\frac{n}{2}\)次单位根每个都恰好出现了两次,证明如下:\[(w_n^{k + \frac{n}{2}})^2 = w_n^{2k + n} = w_n^{2k} \times w_n^n = w_n^{2k} = (w_n^k)^2\]我们参考上面那张图,可以发现它的意义就在于方向相对的两组向量,其平方相等。基于这两个式子便可以保证我们时间\(O(nlogn)\)。
嗯,好的,猜都能猜到你现在一定认为这玩意儿……让人云里雾里的。那简单总结一下吧,我们现在根据这个特别易证但根本无法理解的引理得到了如下两个公式:\[(w_n^{k + \frac{n}{2}})^2 = (w_n^k)^2\] \[(w_n^k)^2 = w_{n/2}^k\]这两个公式被我们所证,一定有它独特的理由,那么证明一的原因是,据此我们可以发现有\(w_n^0 = w_n^{\frac{n}{2}}\),\(w_n^1 = w_n^{\frac{n}{2} + 1}\) \(\cdots\),把所有的单位根画到一个数列上就是这样
但是事实上,只有第一个式子我们并不能说明太多,只能说明\(Lrange\)和\(Rrange\)具有某种映射关系,但我们不知道到底是否是单射关系。即迄今为止,我们在上图中还不可以确定那些红色的带箭头的连线是否恰好正确。这个时候我们就需要公式二\[(w_n^k)^2 = w_{n/2}^k\]这才可以保证,序列左边与右边具有一一对应的映射关系,并且映射序列满足单调性,即\(\frac{n}{2}+1\)号单位根只能和\(1\)号对应。
而我们在代码实现中,不能直接得到\(e\)或者虚数\(i\),所以这个时候求单位根的任务就交给了我们上文中提到过的欧拉公式。
·求和引理
引理:对于任意\(n>0\)且\(k\)不能整除\(n\),我们都有\[\sum\limits_{j =0}^{n-1}{(w_n^k)^j} = 0\]
由几何级数的求和公式(等比数列求和公式)\[\sum\limits_{j = 0}^{n}{x^j} = \frac{x^{j +1} -1}{x -1}\]可得\[\sum\limits_{j =0}^{n-1}{(w_n^k)^j} = \frac{(w_n^k)^n -1}{w_n^k -1} = \frac{(w_n^n)^k -1}{w_n^k -1} = \frac{(1)^k -1}{w_n^k -1}\]由于保证了\(k\)不可整除\(n\)所以分母一定不为\(0.\)
真正地认识\(DFT\)与\(FFT\)
那么我们在了解完单位复数根之后,便可以正式地对\(DFT\)给出定义与操作方案了。
·\(DFT\)
对于我们已知的一个多项式\[A(x) = \sum\limits_{i =0}^{n - 1}{a_ix^i}\]在\(w_n^0,w_n^1,w_n^2 \cdots w_n^{n-1}\)处的取值,我们可以假定\(n\)是\(2\)的幂,因为即使它本身不是\(2\)的幂,我们也可以通过向高次幂补值为\(0\)的项来解决这个问题。而补足\(2\)的幂的目的,就是为了在\(FFT\)分治的过程中,使之可以一直分治下去且每次分治得出的两半可以进行均衡运算。
那我们现在会有一个\(A\)的向量组\(\vec{a} = {a_1, a_2, a_3 \ cdots a_{n-1}}\),对于\(k = 0, 1, 2, \cdots n -1\),定义\(y_k\)如下:\[y_k = A(w_n^k)=\sum\limits_{j =0}^{n -1}{a_j \times w_n^{kj}}\],那么向量\[\vec{y} = {y_0, y_1, y_2 \cdots y_{n-1}}\]就称作系数向量\(\vec{a} = {a_1, a_2, a_3 \cdots a_{n-1}}\)的离散型傅立叶变换(\(Discrete \ \ Fourier \ \ Transformation\))。
嗯,这个离散型我们可以由点乘法联想意会一下:本来\(A(x)\)是一个优美的多项式,转变成函数之后是一条优美的曲线(优美只是定语……不是重要内容\(qwq\)),结果你突然把它拆成了一堆离散的点,把它用点值法表示,故称之曰:“离散型” 。
\(FFT\)是如何求值的
在上文中我们分析过,将系数表达式转化为点值表达式需要的时间复杂度为\(O(n^2)\),这是朴素算法。而我们只需要用一种被称作快速傅立叶变换(\(Fast \ \ Fourier \ \ Transformation\))的方式,就可以将其时间复杂度压缩成\(O(nlogn)\)。而在这里我们就用到了刚才证明的引理——折半引理。
我们考虑将原来的多项式\[A(x) = a_0+a_1x+ a_2x^2 \cdots +a_{n-1}x^{n-1}\]重定义成两个次数域为\(\frac{n}{2}\)的小多项式\(A^{[0]}(x)\)和\(A^{[1]}(x)\):
\[A^{[0]}(x) = a_0 + a_2x+a_4x^2 \cdots +a_{n-2}x^{\frac{n}{2} - 1}\] \[ A^{[1]}(x) = a_1 + a_3x+a_5x^2 \cdots +a_{n-1}x^{\frac{n}{2} - 1}\] 那么也就是说,\(A^{[0]}(x)\)存储的是所有偶数位(二进制位最后一位是\(0\)),而\(A^{[1]}(x)\)存储的是所有的奇数位(二进制位最后一位是\(1\)),那么有下式:\[A(x) = A^{[0]}(x^2)+xA^{[1]}(x^2)\]那我们求\(A(x)\)在单位根们\(w_n^0,w_n^1,w_n^2 \cdots ,w_n^{n-1}\)处的值,就变成了先求出\(A^{[0]}(x^2)\)和\(A^{[1]}(x^2)\)的值,然后根据上式进行合并即可。
而显然的是,根据折半引理,我们根本不需要\(O(n)\)求,而是通过数据规模不断减小使之成为\(O(logn)\)。于是,我们成功通过\(FFT\)优化了求值的复杂度\(qwq\)
int Lim = 1, N, M ;
function FFT(int lenth, complex *A, int flag){
IF (Lim == 1) return ;
complex A0[lenth >> 1], A1[lenth >> 1] ;//分成两部分
for(int j : 0 to lenth by_grow 2) A0[j >> 1] = A[j], A1[j >> 1] = A[j + 1] ;
FFT(lenth >> 1, A0, flag) ;
FFT(lenth >> 1, A1, flag) ;
complex Wn = unit(,) , w = (1, 0) ;//Wn是单位根,w用来枚举幂,即我们令主次单位根不变,由于其余单位根都是其整次幂,所以可以用一个w来记录到第几次幂
/*此处求单位根的时候会用到我们的参数flag……嗯没错就用这一次,并且flag的值域为(-1, 1)……是的,只会有两个值*/
for(int j : 0 to (lenth >> 1) by_grow 1 with w = w * Wn){
A[i] = A0[i] + A1[i] * w ;//应用公式,下同
A[i + (lenth >> 1)] = A0[i] - A1[i] * w ; //顺便求出另一半,由折半引理可显然。
}
}
function Main{
input(N), input(M) ;
for(i : 0 to N by_grow 1) => input(A) ;
for(i : 0 to M by_grow 1) => input(B) ;
while(Lim < N + M) Lim <<= 1 ;//Lim为结果多项式的长度(暂时),化为2的幂的便于分治(二分)
FFT(Lim, A, 1) ;//两遍FFT表示从系数化为点值
FFT(Lim, B, 1) ;
for(i : 0 to Lim by_grow 2) => A[i] *= B[i] ;//点乘法,此处需要重定义乘号,因为每一项现在表示的是一个点,有x和y两个属性qwq
}
以上是基于\(pks\)标准下的伪代码你可以试试在c++标准下运行,其中\(for\)循环部分,grow表示当前循环变量的单次增量,之后带有\(with\)表示每次循环结束都会进行的运算(下同
嗯,这就是求值的方法,好像很\(nice\)地达到了\(O(nlogn)\)
·\(FFT\)是如何插值的
上文中我们曾经提及过的范德蒙德矩阵可以放到这儿用:
\(\begin{vmatrix} 1 & 1 & 1 & \cdots & 1 \\ 1 & w_n & w_n^2 & \cdots & w_n^{n-1} \\ 1 & w_n^2 & w_n^4 & \cdots & w_n^{2(n-1)} \\ \vdots & \vdots & \vdots & \ddots & \vdots \\ 1 & w_n^{n-1} & w_n^{2(n-1)} & \cdots & w_n^{(n-1)(n-1)} \\ \end{vmatrix}\) \(\begin{vmatrix} a_0 \\ a_1 \\ a_2 \\ \vdots \\ a_{n-1} \end{vmatrix}\) = \(\begin{vmatrix} y_0 \\ y_1 \\ y_2 \\ \vdots \\ y_{n-1} \end{vmatrix}\)
那为了求出我们的\(\vec{a} = {a_0, a_1 \cdots ,a_{n-1}}\)我们应该让刚刚求值算出的\(\vec{y}\)乘上我们\(\vec{V}^{-1}\)(\(\vec{V}\)的逆矩阵)即可。但是桥豆麻袋~~~不需要用什么高消啊…余子式啊…我们只需要以下:
定理:对于\(j,k = 0,1, 2 \cdots n-1,V_n^{-1}\)的\((j, k)\)处的值为\(w_n^{-kj}/n\)
证明:我们考虑反向证明,已知\(V_n'\)是一个\((j,k)\)处值为\(w_n^{-kj}/n\),那我们只需要证明\(V' \times V = I_n\)即可,其中\(I_n\)是单位矩阵,即主对角线都是\(1\),其余位置上是\(0\)的矩阵。\(qwq\)这一步转化应该挺简单的吧,至于不知道单位矩阵是啥的…百度去\(QAQ\)。那么我们考察\(V' V\)中的元素\((i, j)\)有如下的式子\[V' \times V = \sum\limits^ {n-1}_{k=0}{(w_n^{-ki}/n)} \times {w_n^{kj}} = \sum\limits^ {n-1}_{k=0}{w_n^{k(j-i)}/n}\]由求和引理当且仅当\(i=j\)时其值为一,其余的时刻均为零,正好和我们单位矩阵的样子一毛一样!我们可以想到单位矩阵\(I_n\)里面也是当\(i =j\)时,在主对角线上其值为\(0\).
于是乎,证毕。
那么我们把我们刚刚求出来的逆矩阵\(V^{-1}\)美化一下,提出每一项所除的\(n\),可以得到\(IDFT\)可以如此求:\[IDFT_n(y) = \frac{1}{n}\sum\limits_{k = 0}^{n-1}{y_kw_n^{-kj}}\]诶,这个好像……跟我们求值时的公式差不多?没错,除了带个负号,其余的都差不多,所以这就可以解释我们刚才的伪代码中读进去的参数\(flag\)看似没有什么用,现在知道了?当\(flag=1\)时,他是正向\(DFT\);当它等于\(-1\)时,它是逆向\(DFT\)。这可以让我们通过这一个函数解决两个过程。我们只需要用\(y\)替换\(a\),用\(w_n^{-1}\)替换\(w_n\),其余的没什么差别,于是……时间复杂度还是\(O(nlogn)\)的!
那么就亮出我们的\(cpp\)吧!
void FFT(int Lim,complex *A,int flag){
if(Lim == 1) return ;
complex A0[Lim >> 1], A1[Lim >> 1] ;
for(int i = 0; i <= Lim ; i += 2)
A0[i >> 1] = A[i], A1[i >> 1] = A[i+1] ;
FFT(Lim >> 1, A0, flag) ;
FFT(Lim >> 1, A1, flag) ;
complex unit = complex(cos(2.0 * Pi / Lim) , flag * sin(2.0 * Pi / Lim)), w = complex(1, 0) ;//欧拉公式
for(int i = 0;i < (Lim >> 1) ; i ++, w = w * unit) {
A[i] = A0[i] + w * A1[i] ;
A[i + (Lim>>1)] = A0[i] - w*A1[i];
}
}
int main(){
......................
FFT(A, 1), FFT(B, 1) ;
for(i = 0; i <= Lim; i ++) A[i] = A[i] * B[i] ;
FFT(A, -1) ;
......................
}
好的,现在嘛……可以考虑撒花花啦!因为我们的\(FFT\)实际上已经结束了!~~~~\(But\),这个递归版本的\(FFT\)的常数,由于牵扯到\(sin/con\)的运算、\(double\)、递归时的入栈出栈(底层),所以特别的大\(emmmmm\),那我们该怎么办呢——
\(\mathcal{Third\_step}\)·最后来一些优化吧!
我们现在要引出的就是迭代版的\(FFTqwq\)
·前置技能四·小\(trick\)与“蝴蝶操作”
\(emmm\)先上一个不是特别卡常数的优化。我们观察之前的代码中,有这么一步:
for(int i = 0;i < (Lim >> 1) ; i ++, w = w * unit) {
a[i] = A0[i] + w * A1[i] ;
a[i + (Lim>>1)] = A0[i] - w*A1[i];
}
我们会发现……\(w \times Ai[i]\)被执行了两次,所以我们不妨用个变量记录它:
for(int i = 0;i < (Lim >> 1) ; i ++, w = w * unit) {
int temp = w * A1[i] ;
a[i] = A0[i] + t ;
a[i + (Lim>>1)] = A0[i] - t ;
}
嗯,这就是全部的优化啦!那么,\(FFT\),完!
\(qwq\)这分明是骗小孩子的啦……如果单单这一步就可以卡常数的话,那这个世界该多么美好\(\mathcal{QAQ}\)。好吧,说这个的原因,只是为了引出我们关于蝴蝶操作的定义:
我们定义\(w_n^k\)为旋转因子,那么每一次我们先将\(y_k^{[1]}\)与旋转因子的乘积存储在一个变量\(t\)里,并在\(y_k^{[0]}\)增加、减去\(t\)的操作称为一次蝴蝶操作。
在了解完蝴蝶操作之后,我们首先考虑按照递归的思路,将\(FFT\)的分治流程刻画一下:
我们会发现,其实我们是可以对它进行反向迭代的。以上面的迭代树为例,我们的步骤大体如下:
\(step \ \ 1\)成对地取出儿子节点,用蝴蝶操作计算出其\(DFT\)。
\(step \ \ 2\)用这一步的\(DFT\)替换之前的;
\(step \ \ 3\)直到我们迭代到根节点为止,否则返回\(step \ \ 1\)
而反向迭代似乎有规律可循。我们发现只要我们用迭代的过程模拟出回溯的过程即可。那么思路便有了:三层\(for\),先枚举区间长度(1,2,4,8……),第二层枚举每个区间的起点(其实只有两段),第三层负责遍历每段区间(其实也只有两段\(ORZ\)),然后\(cpp\)是这样子的:
for(j = 1; j < Lim; j <<= 1){
node T(cos(Pi / j), flag * sin(Pi / j)) ;
for(k = 0; k < Lim; k += (j << 1) ){
node t(1, 0) ;
for(l = 0 ; l < j; l ++, t = t * T){
node Nx = J[k + l], Ny = t * J[k + j + l] ;
J[k + l] = Nx + Ny ;
J[k + j + l] = Nx - Ny ;
}
}
}
嗯,好像……海星?哈,思维不严谨的我们漏了一个地方:我们并不可以得到迄今为止的结构体参数\(J\)(即这一段多项式)每一位的值。因为现在编号为\(1\)的是原序列中编号为\(4\)的,以此类推,我们需要重新排序……但实际上并不需要这么麻烦——
·前置(最后的)技能·蝴蝶定理
这个嘛……我们可以选择打个表观察:
原来的序号 \(0 \ \ 1 \ \ 2 \ \ 3 \ \ 4 \ \ 5 \ \ 6 \ \ 7\)
现在的序号 \(0 \ \ 4 \ \ 2 \ \ 6 \ \ 1 \ \ 5 \ \ 3 \ \ 7\)
原来的二进制表示 \(000 \ \ 001 \ \ 010 \ \ 011 \ \ 100 \ \ 101 \ \ 110 \ \ 111\)
现在的二进制表示 \(000 \ \ 100 \ \ 010 \ \ 110 \ \ 100 \ \ 101 \ \ 011 \ \ 111\)
诶,二进制好像是反序的嗷~~这便是我们的最后一个\(trick\),蝴蝶定理。
因为既然是重新排序,所以必然有\(i < j\)但\(A_i\)反序之后到了\(A_j\)的后面。因为我们观察到的蝴蝶定理是满足一一对应性的,所以我们在\(FFT\)之前\(swap\)一遍即可。
嗯,然后我们可以将这个反序存在一个数组里面。类似这样求出来:
for(i = 0; i < Lim; i ++ ) R[i] = (R[i >> 1] >> 1) | ((i & 1) << (L - 1)) ;
那么我们可以看到,这就简化了很多冗余的步骤,并让我们脱离递归的大常数。真开森啊
最后附迭代版的代码(我写的常数好像有点儿大\(QAQ\))
#include
#include
#include
#define il inline
using namespace std ;
int N, M, K ;
const int MAXN = 3000100 ;
const double Pi = acos(-1.0) ;
int i, j, k, l, Lim = 1, L, R[MAXN] ;
struct node{
double x, y ;
node (double xx = 0, double yy = 0){
x = xx, y = yy ;
}
}A[MAXN], B[MAXN] ;
node operator * (node J, node Q){
return node(J.x * Q.x - J.y * Q.y , J.x * Q.y + J.y * Q.x);
}
node operator + (node J, node Q){
return node(J.x + Q.x , J.y + Q.y);
}
node operator - (node J, node Q){
return node(J.x - Q.x , J.y - Q.y );
}
il int qr(){
int k = 0, f = 1 ;
char c = getchar() ;
while(!isdigit(c)){if(c == '-') f = -1 ;c = getchar() ;}
while(isdigit(c)) k = (k << 1) + (k << 3) + c - 48 ,c = getchar() ;
return k * f ;
}
void FFT(node *J, int flag){
for(i = 0; i < Lim; i ++)
if(i < R[i]) swap(J[i], J[R[i]]) ;
for(j = 1; j < Lim; j <<= 1){
node T(cos(Pi / j), flag * sin(Pi / j)) ;
for(k = 0; k < Lim; k += (j << 1) ){
node t(1, 0) ;
for(l = 0 ; l < j; l ++, t = t * T){
node Nx = J[k + l], Ny = t * J[k + j + l] ;
J[k + l] = Nx + Ny ;
J[k + j + l] = Nx - Ny ;
}
}
}
}
int main(){
N = qr(), M = qr() ;
for(i = 0; i <= N; i ++) A[i].x = qr() ;
for(i = 0; i <= M; i ++) B[i].x = qr() ;
while(Lim <= N + M) Lim <<= 1, L ++ ;
for(i = 0; i < Lim; i ++ ) R[i] = (R[i >> 1] >> 1) | ((i & 1) << (L - 1)) ;
FFT(A, 1), FFT(B, 1) ;
for(i = 0; i <= Lim; i ++) A[i] = A[i] * B[i] ;
FFT(A, -1) ;
for(i = 0; i <= N + M; i ++)
printf("%d ", (int)(A[i].x / Lim + 0.5)) ;//我们推过的公式里面有一个1/n这一项,最后输出的时候添上即可
return 0 ;
}
啊……那就撒花花吧!!
\(\mathcal{Last\_step}\)·感悟
嗯……怎么说呢,现在看这个算法,真是简单的一匹啊……代码这么短这么容易背过。但是当时理解起来却花了很大心思呢!这篇博客我写了整整三天\(qwq\),由于要培训和考试,所以拖拖沓沓地写了三天,一边写一边感叹自己理解的简直太浅显了,每一个证明、每一个引理、甚至每一个符号,我都需要去和其他\(DALAO\)比对审核、或者缠着\(rqy\)问个没完;每次一讲到原理,我都发现自己原来并不理解那些,于是不得不推倒重来。我希望你们可以经历这些,但同时更希望你们不经历这些。这篇博客会持续更新,补充语意不明、证明难以理解的地方。欢迎\(hack\)哟!
鸣谢以下对我有帮助的典籍或者\(dalao\)
·《算法导论》(讲的……还可以吧)
·\(rvalue\)大佬的博客[\(Link\)]【与《算法导论》结合起来食用效果更佳
·\(rqy\)(我缠着他问了好多关键点,大多是原理部分的)