快速傅里叶变换(Fast Fourier Transform, FFT)
多项式乘法与卷积
快速傅里叶变换(Fast Fourier Transform,FFT)可以用来快速计算多项式乘法的问题。值得一提的是,“快速傅里叶变换”和“傅里叶变换(Fourier Transform)”是有很大区别的。信奥里所讲的“快速傅里叶变换”其实是“离散傅里叶变换(Discrete Fourier Transform, DFT)”,而“傅里叶变换”则是讨论连续函数的。
我们知道多项式乘法的逐项相乘原理,这是基于乘法分配律的。由于
a(b+c)=ab+ac
所以(a0+a1x+...+anxn)(b0+b1x+...+bmxm)首先可以写作
a0(b0+b1x+...+bmxm)+a1(b0+b1x+...+bmxm)+...+an(b0+b1x+...+bmxm)
然后再把每个括号展开,就形成了逐项相乘的形式。合并同类项以后,可以发现结果的系数可以写成这样的表达式:
cn=i=0∑naibn−i
这个形式在以后将会非常常见,如果把多项式的系数a、b、c看作关于下标的函数,那么这个运算就好像是通过a、b用某种法则生成了函数c。我们把c叫做a,b的卷积(convolution),因为c好像是由a和b一正一反卷在一起一样。所以我们也可以说,FFT能快速处理求两个函数的卷积的问题。
系数表达式与点值表达式
一个多项式可以看作一个幂函数,一个n次函数需要n+1个系数来确定。还有没有别的方法来确定一个n次函数?我们知道两点确定一个一次函数,三点确定一个二次函数,那么n+1个点的坐标就可以确定一个n次函数。也就是说,一个多项式不仅可以通过n+1个系数来确定(称为系数表达式),也可以通过n+1个坐标点以及其对应的多项式的函数值来确定(称为点值表达式)。
多项式系数逐项相乘的复杂度O(n2),因为我们需要对两个多项式的每一项两两操作。但我们惊奇的发现,点值表达式下的多项式乘法只需要O(n)就可以完成——假设两个点值表达式选择的是相同的坐标点,我们要做的只不过是把函数值相乘而已。
所以,想要快速完成多项式乘法,我们要做的就是解决如何快速完成系数表达式与点值表达式之间的转换问题,这已经称为了多项式乘法的复杂度瓶颈。这就是FFT要做的事。把多项式的系数表达式变换成点值表达式,称为“离散傅里叶变换”。反过来称为“离散傅里叶逆变换”。
离散傅里叶变换(DFT)
问题的关键在于我们应该选择怎么样的点来进行代入。如果我们随便选择n个点代入,那么每个点计算点值的复杂度是O(n)的,总复杂度依然是O(n2)的。而优化就是看我们是否能少做一些点值的计算。也就是去寻找,当我们把不同的点代入后,它们的结果之间是否存在某种联系使得我们可以不用重新计算。
人们发现,用单位根可以解决这个问题。
单位根其实就是xn=1的n个复数解,称为n次单位根。根据复数乘法“模长相乘,幅角相加”的规则,在复平面内,它们是包括(1,0)在内的将单位圆n等分的单位向量。记(1,0)之后逆时针第一个单位根为ωn,第k个单位根可以记作ωnk。
单位根很容易用欧拉公式表示:
ωnk=cosn2kπ+isinn2kπ
现在我们来看看离散傅里叶变换是怎么做的。设有多项式的系数表达式
A(x)=a0+a1x+a2x2+...+an−1xn−1
并且我们不妨假定n是2的幂——只需要设定多出来的项的系数都是0就好了。现在,我们按照指数的奇偶把它拆成两部分,并构成两个新的多项式。(注意指数也发生了变化)
A1(x)=a0+a2x+...+an−2x2n−1
A2(x)=a1+a3x+...+an−1x2n−1
它们满足
A(x)=A1(x2)+xA2(x2)
现在我们将一个ωnk代入A(x)(其中k<2n)得:
A(ωnk)=A1(ωn2k)+ωnkA2(ωn2k)
将相应的ωnk+2n代入A(x)得:
A(ωnk+2n)=A1(ωn2k+n)+ωnk+2nA2(ωn2k+n)
由于我们假定了n是偶数,所以ωn2k=ω2nk。这就好像“把蛋糕切成n份并拿走2k份”和“把蛋糕切成2n份并拿走k份”是一回事一样。同时,ωn2n恰好是走半圈,在复平面上走半圈就是一次关于原点的中心对称,所以又有ωnk+2n=−ωnk。
由此上面两个式子可以化简成:
A(ωnk)=A1(ω2nk)+ωnkA2(ω2nk)
A(ωnk+2n)=A1(ω2nk)−ωnkA2(ω2nk)
两个表达式有了相同的组成部分,只需要完成A1(ω2nk)和A2(ω2nk),就可以求得A(ωnk)和A(ωnk+2n)的计算就可以完成A(ωnk)和A(ωnk+2n)的计算。这是一个递归求解的过程,假设求A(ωn1..n)需要计算f(n)次,就有f(n)=2⋅f(2n)+n,而f(1)=1,所以f(n)=nlog2n+n,复杂度O(nlogn)。
离散傅里叶逆变换(IDFT)
设刚才DFT的结果为点值表达式(y0,y1,...,yn−1),它对应的系数表达式就是原来的(a0,a1,...,an−1)
构造以(y0,y1,...,yn−1)为系数的多项式
B(x)=y0+y1x+...+yn−1xn−1
接下来,将单位根的倒数(ωn0,ωn−1,...,ωn1−n)代入B(x)得到点值表达式(z0,z1,...,zn−1)。(单位根的倒数恰好也是单位根的共轭复数)接着我们来观察z和a之间的联系。
zk=i=0∑n−1yi(ωn−k)i=i=0∑n−1[j=0∑n−1aj⋅(ωni)j](ωn−k)i=i=0∑n−1j=0∑n−1aj⋅(ωnj−k)i=j=0∑n−1aj⋅[i=0∑n−1(ωnj−k)i]
后半部分为等比数列求和。要对公比是否为1进行分类讨论:
i) 当j=k时:
i=0∑n−1(ωnj−k)i=ωnj−k−1(ωnj−k)n−1=ωnj−k−1(ωnn)j−k−1=ωnj−k−11−1=0
ii) 当j=k时:
i=0∑n−1(ωnj−k)i=i=0∑n−11i=n
综上
zk=n⋅ak
所以
ak=nzk
奇妙之处就在于,我们通过代入单位根的倒数恰好消掉了大部分复杂的式子,这使得我们只需要在DFT的基础上略作修改就可以完成IDFT:我们只要把点值表达式当作系数表达式,把单位根取倒数后再做一次DFT,最后除以n,就得到了系数表达式。
数论变换(Number Theoretic Transforms, NTT)
FFT需要用复数运算,存在精度问题。NTT是模意义下的FFT。它的思想核心就是模数的原根来代替单位根。
在FFT中,我们选择单位根ωnk是由于单位根具有如下几个我们所需要的性质:
1) (ωnk)n=1
2) ω2n2k=ωnk
3) ωnk+2n=−ωnk
4)n个单位根互不相同
在模意义下,我们有阶的概念。满足an≡1(modP)的最小的n被称为a在模P意义下的阶(暂时假定P为质数)。我们知道费马小定理:
aP−1≡1(modP)
当a在模P意义下的阶为P−1时,我们便称a为模数P的原根,记作g。常见地,模数998244353的原根是3。
由此,我们可以用gnk来代替原来的ωnk。令:
gnk=gnP−1⋅k
(注意,这同时要求n是P−1的约数。我们之前还假定了n是2的幂。恰好998244353是这样一个满足条件的约数。)
由此可知性质一:
(gnk)n≡gnP−1⋅kn≡(gP−1)k≡1(modP)
性质二:
g2n2k≡g2nP−1⋅2k≡gnP−1⋅k≡gnk(modP)
性质三:
gnk+2n≡gnk⋅gn2n≡gnk⋅gnP−1⋅2n≡gnk⋅g2P−1
由于(g2P−1)2≡1(modP),把它当作一个二次剩余方程可以观察到
g2P−1=−1(modP)
也就有
gnk+2n=−gnk
性质四:
如果存在r<s<P−1使得gr≡gs≡1(modP),则P∣gs−r。于是g(P−1)−(s−r)≡1(modP),这就与“原根的阶是P−1”矛盾了。
综上,我们发现原根具有我们需要的单位根的性质。在FFT中用原根代替单位根,也就是NTT了。注意在IDFT中我们用到了单位根的倒数,这就对应着原根的乘法逆元。
代码实现
根据刚才的理论,可以直接写出递归实现的代码。注意单位根可以在计算的过程中递推,而不用每次重新求。
void DFT(int* a, int n, bool IDFT){
if(n == 1) return;
for(int i = 0; i < n; ++i){
if(i & 1){
b[(n>>1)+(i>>1)] = a[i];
}else{
b[i>>1] = a[i];
}
}
for(int i = 0; i < n; ++i) a[i] = b[i];
DFT(a, n>>1, IDFT);
DFT(a+(n>>1), n>>1, IDFT);
gn = ((IDFT) ? qpow(qpow(3,(P-1)/n),P-2) :qpow(3,(P-1)/n));
w0 = 1;
for(int k = 0; k < (n>>1); ++k){
a1 = a[k], a2 = a[k+(n>>1)];
tmp = mul(w0, a2);
a[k] = add(a1, tmp);
a[k+(n>>1)] = sub(a1, tmp);
w0 = mul(w0, gn);
}
}
能不能迭代实现?这也就要求我们从叶节点出发向上完成计算。首先需要确定叶节点的值。在上述过程中,我们每一轮都会对节点奇偶分类。在最顶层的分类中,偶数被分到前面,奇数被分到后面。(下标从0开始)对于被分到前面的偶数,此时的第偶数个被分到前面,第奇数个被分到后面。如果说第一次看的是奇偶,那第二次看的就是偶数的偶数和偶数的奇数、奇数的偶数和奇数的奇数。仔细思考一下可以发现,这就是二进制的第一位和第二位。最终所有节点是按照二进制的倒序排序的。
在计算二进制倒序的时候,不需要暴力计算,而是可以利用历史信息:ri通过ri/2来转移。
for(int i = 1; i < N; ++i){
r[i] = (r[i>>1]>>1) | ((i&1) ? (N>>1) : 0);
}
迭代实现的代码如下:
inline void DFT(int* a, int n, bool IDFT){
for(int i = 1; i < n; ++i){
if(r[i] < i) swap(a[r[i]],a[i]);
}
for(int i = 2; i <= n; i = i<<1){
gn = ((IDFT) ? qpow(qpow(3,(P-1)/i),P-2) :qpow(3,(P-1)/i));
for(int j = 0; j < n; j = j+i){
w0 = 1;
for(int k = 0; k < (i>>1); ++k){
a1 = a[j+k], a2 = a[j+k+(i>>1)];
tmp = mul(w0, a2);
a[j+k] = add(a1, tmp);
a[j+k+(i>>1)] = sub(a1, tmp);
w0 = mul(w0, gn);
}
}
}
}
任意模数NTT
上面假定了P是一个非常特殊的模数,它首先是指数,P−1还是2的幂次的倍数,它还有一个比较简单的原根。
但假设要求模数任意,怎么解决?在解决这个问题时,人们发现,我们并不需要对我们的算法进行某种修改,而是对我们的结果进行一些变换就可以解决这个问题。对于最终输出的某个答案x,假设我们知道x在其他“特殊模数”下的答案,我们可以通过解同余方程组来求出x。
我们选定满足普通NTT要求的三个模数P1,P2,P3,分别求得x对应的结果x1,x2,x3,于是我们就有
⎩⎨⎧x≡x1(modP1)x≡x2(modP2)x≡x3(modP3)
我们通过中国剩余定理可以得到x≡ans(modP1P2P3),假若P1P2P3已经大于给定的数据的最大范围,那么我们得到的答案就可以被认为是正确的了。
(还没有实践过)