che dan环节:
概述
快速傅里叶变换(Fast Fourier Transformation),简称\(FFT\),它可以在\(O(n \ log \ n)\)的时间内算两个多项式的乘积的所有系数。
是一个听起来逼格很高,实际逼格更高的算法。
一些定义
下面我们来交代一些奇怪的东西。
一个\(n\)项\(n-1\)次多项式,\(F(x) = a_0x^0 + a_1x^1 + \dots + a_{n-1}x^{n-1}\)
则多项式\(F(x)\)可以用 __系数表示法__表示成\(F(x) = \{ a_0,a_1,\dots,a_{n-1} \}\)
方便的,我们可以把\(F\)看成一个数组,用\(F[k]\)表示多项式\(F\)的\(k\)次项系数,即\(F[k] = a_k\),如果\(F\)不存在\(k\)次项的话系数就是零即\(F[k] = 0\)。
那么多项式\(F\)(\(n\)次),\(G\)(\(m\)次)的乘积会得到一个\(n + m\)次多项式\(H\)就满足\(H[k] = \sum\limits_{i = 0}^{k} F[i]G[k-i]\),按这样算多项式乘法就是\(O(n^2)\)的。
如果您是个明白人的话会发现多项式乘法就是一个加法卷积(当然如果不知道也没关系)。
多项式还有一个比较有用的表示法叫 点值表示法,就是把多项式看做函数。
记一个结论:用\(n\)个点能确定一个\(n-1\)次多项式 意会,意会~。
即代\(n\)个\(x\)到\(F(x)\)里,就可以确定这个多项式$ F(x)\(的\)n$个系数(不要忘了常数项)。
假设我们代入的是\(\{ x_0,x_1, \dots ,x_{n-1} \}\),令\(y_i = F(x_i)\)
那么\(F(x)\)就可以表示为\(F(x) = \{ (x_0,y_0),(x_1,y_1), \dots,(x_{n-1},y_{n-1}) \}\)
总结
总结一下,多项式\(F(x) = a_0x^0 + a_1x^1 + \dots + a_{n-1}x^{n-1}\)有两种表示方法
- 系数表示法:\(F(x) = \{ a_0,a_1,\dots,a_{n-1} \}\)
- 点值表示法:即把\(F(x)\)看做一个函数,用\(n\)个\(x\)就可以确定\(F(x)\),\(F(x) = \{ (x_0,y_0),(x_1,y_1), \dots,(x_{n-1},y_{n-1}) \}\)
那这些有什么用呢?我们用系数表示法做乘法的复杂度是铁定\(n\)方的。
那用点值表示法呢?
如果要计算\(F(x) \cdot G(x)\)(\(F\)有\(n\)次,\(G\)有\(m\)次)的话,我们先把同一组\(\{ x_i : 0 \le i \le n+m \}\)(注意是\(n + m + 1\)个\(x\),实际上多代了也不亏。)代入\(F,G\),那么设\(H(x) = F(x) \cdot G(x)\),多项式\(H\)的点值表示法就是
\[ H = \{ (x_0, F(x_0) \cdot G(x_0)),\dots,(x_{n+m},F(x_{n+m})\cdot G(x_{n+m})) \} \]
这样求出\(F\)和\(G\)的点至表示法后我们就可以通过\(O(m +n)\)的算出\(H\)的点值表示。
然后呢?然后啥啊。。。
我们会得到$n + m + 1 $个点,但我们要算各项的系数啊。
那咋算呢?\(FFT\)就是做的这个。
提前交代一下\(FFT\)的流程
- \(1.\) 先分别算出两个多项式的点值表示法(我们称之为
绝活\(DFT\)) - \(2.\) 把这些对应点分别乘起来,得到乘积多项式\(H\)的点值表示法
- \(3.\) 再把\(H\)的点值表示法转回系数表示法(即\(DFT\)的逆变换,我们叫它\(IDFT\))
前置姿势
复数
定义
形如\(z = a + bi\)的数我们管他叫复数,其中\(i = \sqrt{-1}\),我们称为虚数单位,实数\(a\)称为\(z\)的实部,实数\(b\)称为\(z\)的虚部。
一个复数\(z = a + bi\)可以用复平面上的一个点\((a,b)\)表示。
复平面:
可以这么理解,把他当成直角坐标系的话,上面的一点\((x,y)\)就对应了一个复数\(x + iy\)。
原来直角坐标系下的\(x\)轴在复平面下对应实轴(上面的数表示实部\(a\)),\(y\)轴上的数对应虚部\(b\),我们叫虚轴。
模长:
复数所对应的点即到点\((0,0)\)的距离,记为\(|z|\)。
辐角:
复数\(a + bi\)的辐角即实轴按逆时针方向旋转到点\((a,b)\)的旋转角度,类似直角坐标系下的极角。
记为\(arg (z)\)
运算
加减法:
这个应该很好理解吧,实部虚部分别加减就好了,即\((a + bi) \pm (c+di) = (a\pm c) + (b\pm d)i\),
乘法
恩,这个比较重要。
可以暴力的乘起来:\((a + bi)(c + di) = ac + adi + cbi + bdi^2 = (ac - bd) + (ad + cb)i\)(因为\(i^2 = -1\))。
即点\((a,b)\)与点\((c,d)\)通过乘法得到点\((ac - bd,ad + cb)\)
再记个结论:
设\(z = z_0 \times z_1\),则\(|z| = |z_0| \cdot |z_1|,arg(z) = arg(z_0) + arg(z_1)\)
即 复数乘法,模长相乘,辐角相加(这玩意儿非常有用)
Code
这是复数运算的一些\(Code\),并没有涉及到复数的除法,因为我不会\(FFT\)并不会用除法。
struct cpl{
db x,y;
cpl operator + (cpl k1)const{return (cpl){x+k1.x,y+k1.y};}
cpl operator - (cpl k1)const{return (cpl){x-k1.x,y-k1.y};}
cpl operator * (cpl k1)const{return (cpl){x*k1.x-y*k1.y,x*k1.y+y*k1.x};}
}
单位根
这个有点东西啊。
定义
可以理解为\(x^n = 1\)这个方程的根,我们称之为 \(n\)次单位根。
在实数域中,这个方程当\(n\)为奇数时有一个解\(x = 1\),\(n\)为偶数时有两个解\(x_0 = 1, x_1 =-1\)
那扩展到复数域呢?
在复平面下,复数的乘法有一个非常\(nb\)的性质,叫 模长相乘,辐角相加(不知道的话百度一下吧!)
那么能成为\(x^n = 1\)这个方程的解的复数\(x\),有一个前提条件叫\(|x | = 1\)(\(x\)的模长为\(1\))。
那么单位根就只会在复平面的单位圆上(蛤?啥是单位圆)。
上图(单位圆):
还有一点叫 辐角相加,那么\(n\)次单位根就一定是单位圆周上的\(n\)个\(n\)等分点。
因为这样的点辐角一定可以表示为\(\frac{2 \pi}{n} \cdot k\)(弧度制),自乘\(n\)次后辐角为\(2 \pi k\),恰好绕圆周\(k\)圈后到点\((1,0)\)
那么就表明\(n\)次单位根有且仅有\(n\)个,即单位圆周上的\(n\)等分点,一般的,我们把\(n\)次单位根记为\(\omega_{n}^0,\omega_{n}^{1},\dots,\omega_{n}^{n-1}\)
举个例子,\(n = 4\)时,有\(4\)个单位根,如图
性质
\((1)\) \(\omega_{n}^{0} = 1\),显然。
\((2)\) \(\omega_{n}^{k} = \left( \omega_{n}^{1} \right) ^ k\)更一般的\((\omega_{n}^{k}) ^ i = \omega_{n}^{ik}\),结合复数乘法的性质,辐角相加,也非常显然。
\((3)\) \(\omega_{n}^{i} \cdot \omega_{n}^{j} = \omega_{n}^{i + j}\)
由\((2)\)知\(\omega_{n}^{i} = \left( \omega_{n}^{1} \right)^i, \omega_{n}^{j} = \left( \omega_{n}^{1} \right)^j\),则\(\omega_{n}^{i} \cdot \omega_{n}^{j} = \left ( \omega_{n}^{1} \right)^{i+j} = \omega_{n}^{i + j}\)
\((4)\) \(\omega_{n}^{k} = \omega_{n}^{k \ mod \ n}\),即绕了一圈又回来了。那么\(\omega_{n}^{-k} = \omega_{n}^{n-k}\)。
- \((5)\) 如果\(n\)是偶数,\(\omega_{n}^{k+n/2} = -\omega_{n}^{k}\),跑了半个圆周,刚好到相反方向。
\((6)\) \(\omega_{mn}^{mk} = \omega_{n}^{k}\),这个也挺显然的。
还有一些等用到了再说吧(其实鉴于复数乘法的优秀特性,这些性质都比较显然)。
好啦,前置姿势胡完了!
坐稳了!
DFT & IDFT
下面终于要讲一些有用的东西了。。。
DFT
上文也说了,\(DFT\)可以快速求多项式\(F(x) = a_0x_0 + a_1x_1 + \dots + a_{n-1}x^{n-1}\)(下面我们特别的规定\(n\)能表示成\(2^k\))的一组点值表示。
上古时期,有一位名叫傅里叶的大佬,他发现把\(\omega_{n}^{0},\dots,\omega_{n}^{n-1}\)代入\(F(x)\),鉴于单位根的一些性质,可以很快的完成\(DFT\)求出一组\(F(x)\)的点值表达。
为了方便,我们令\(DFT(F(x)) = \{ (x_0,y_0),(x_1,y_1), \dots,(x_{n-1},y_{n-1}) \}\),其中$x_i = \omega_{n}^{i},y_i = F(x_i) $
我们也把\(F(x)\)的一个\(DFT\)看做一个数组,并设\(G = DFT(F)\),有\(G[k] = y_k\)
傅里叶先把\(F(x)\)按次数奇偶分组,得到两个小一点大小均为\(n/2\)的多项式
\[ f(x) = F[0]x_0 + F[2]x^1 + \cdots + F[n-2]x^{n/2-1} \]
\[ g(x) = F[1]x_0 + F[3]x^1 + \dots + F[n-1]x^{n/2-1} \]
有\(F(x) = f(x^2) + x\cdot g(x^2)\)。
发现如果\(x = \omega_{n}^{k} (k < n/2)\)的话
\[ \begin{aligned}F(x) &= f((\omega_{n}^{k}) ^ 2) + \omega_{n}^{k} g((\omega_{n}^{k}) ^ 2) \\&= f(\omega_{n/2}^{k}) + \omega_{n}^{k} g(\omega_{n/2}^{k})\end{aligned} \]
我们设\(f' = DFT(f), g' = DFT(g)\),那么有\(G[k] = f'[k] + \omega_{n}^{k}g'[k] (k
那如果代入\(x = \omega_{n}^{k + n/2}(k < n/2)\)呢?
\[ \begin{aligned}F(x) &= f((\omega_{n}^{k+n/2}) ^ 2) + \omega_{n}^{k+n/2} g((\omega_{n}^{k+n/2}) ^ 2) \\&= f(\omega_{n/2}^{k+n/2}) + \omega_{n}^{k+n/2} g(\omega_{n/2}^{k+n/2}) \\&= f(\omega_{n/2}^{k}) - \omega_{n}^{k}g(\omega_{n/2}^{k})\end{aligned} \]
换句话说\(G[k+n/2] = f'[k] - \omega_{n}^{k}g'[k] (k < n/2)\)。
那么我们就有了两个柿子\(k < n/2\):
\[ G[k] = f'[k] + \omega_{n}^{k}g'[k] \]
\[ G[k+n/2] = f'[k] - \omega_{n}^{k}g'[k] \]
我们只需要求出\(f'[0...n/2-1]\)和\(g'[0...n/2-1]\)就可以算出\(G[0...n]\)了。
这样我们就可以分治算\(G\)就可以了。
啊,忘了。可以用三角函数来算出\(\omega_{n}^{1}\),然后自乘\(k\)次就可以得到\(\omega_{n}^{k}\)了。
Code
所以代码:
#include
using namespace std;
typedef double db;
const db PI=acos(-1.0);
const int N=4e6+10;
struct cpl{
db x,y;
cpl operator + (cpl k1)const{return (cpl){x+k1.x,y+k1.y};}
cpl operator - (cpl k1)const{return (cpl){x-k1.x,y-k1.y};}
cpl operator * (cpl k1)const{return (cpl){x*k1.x-y*k1.y,x*k1.y+y*k1.x};}
}buf[N];
void dft(cpl *f,int n){
if (n==1)return;
for (int i=0;i>1); // 直接用f的空间。
for (int i=0;i<(n>>1);i++) L[i]=buf[i<<1],R[i]=buf[i<<1|1];
dft(L,n>>1),dft(R,n>>1);
cpl w=(cpl){1,0},w1=(cpl){cos(2*PI/n),sin(2*PI/n)}; // w是不是很像$\omega$?
for (int i=0;i<(n>>1);i++){ // 上文中的k
buf[i]=L[i]+w*R[i];
buf[i+(n>>1)]=L[i]-w*R[i];
w=w*w1;
}
for (int i=0;i
IDFT
但只有\(DFT\)还是不够啊。。。我们还要把它再搞回系数表示。
于是就有了\(DFT\)的逆变换$IDFT $。
记个结论:
\(DFT\)那里我们已经知道了\(G = DFT(F)\),且\(G[k] = \sum\limits_{i = 0}^{n-1} (\omega_{n}^{k})^i F[i]\)。
结论就是\(n \cdot F[k] = \sum_{i=0}^{n-1} (\omega_{n}^{-k})^iG[i]\),证明还是要有滴~
等式右边
\[ \begin{aligned}\sum_{i=0}^{n-1} (\omega_{n}^{-k})^iG[i]&= \sum\limits_{i = 0} ^ {n - 1} \omega_{n}^{-ki} \sum\limits_{j = 0}^{n-1} (\omega_{n}^{i}) ^ j F[j] \\&= \sum\limits_{i = 0} ^ {n - 1} \omega_{n}^{-ki} \sum\limits_{j = 0}^{n-1} \omega_{n}^{ji} F[j] \\&= \sum\limits_{j = 0} ^ {n - 1} F[j] \sum\limits_{i=0}^{n-1} (\omega_{n}^{j-k})^i\end{aligned} \]
分类讨论一下
\(j = k\)时,因为\(\omega_{n}^{0} = 1\),所以那一项的贡献就是
\[ F[j] \sum\limits_{i=0}^{n-1} (\omega_{n}^{j-k})^i = F[j] \cdot n \cdot 1 = n F[k] \]
\(j \not = k\)呢?观察到第二个\(\sum\)是个公比为\(\omega_{n}^{j-k}\)的等比数列。
求和一下
\[ \sum\limits_{i=0}^{n-1} (\omega_{n}^{j-k})^i = \frac{(\omega_{n}^{j-k})^n - (\omega_{n}^{j-k})^0}{\omega_{n}^{j-k} - 1} \]
由于\((\omega_{n}^{j-k})^n = \omega_{n}^{n(j-k)} = \omega_{n}^{0} = 1,(\omega_{n}^{j-k})^0 = 1\),所以分子是\(0\),这一项的贡献也是\(0\)。
因此\(n \cdot F[k] = \sum_{i=0}^{n-1} (\omega_{n}^{-k})^iG[i]\)。
这东西的本质好像是什么 复读机单位根反演?不会。。。
这玩意儿直接在$DFT $的代码中改一个符号就好了。
Code
void idft(cpl *f,int n){
if (n==1)return;
for (int i=0;i>1); // 直接用f的空间。
for (int i=0;i<(n>>1);i++) L[i]=buf[i<<1],R[i]=buf[i<<1|1];
idft(L,n>>1),idft(R,n>>1);
cpl w=(cpl){1,0},w1=(cpl){cos(2*PI/n), - sin(2*PI/n)}; // w是不是很像$\omega$?
// 就是这个负号!
for (int i=0;i<(n>>1);i++){
buf[i]=L[i]+w*R[i];
buf[i+(n>>1)]=L[i]-w*R[i];
w=w*w1;
}
for (int i=0;i
main
里面别忘了\(IDFT\)后把每一项的值除以一个\(n\)。
Code
其实我们完全可以把\(DFT \& IDFT\)写一起,这样我们就得到了板子题的代码。
#include
using namespace std;
typedef double db;
const db PI=acos(-1.0);
const int N=4e6+10;
struct cpl{
db x,y;
cpl operator + (cpl k1)const{return (cpl){x+k1.x,y+k1.y};}
cpl operator - (cpl k1)const{return (cpl){x-k1.x,y-k1.y};}
cpl operator * (cpl k1)const{return (cpl){x*k1.x-y*k1.y,x*k1.y+y*k1.x};}
}buf[N];
void fft(cpl *f,int n,int k1){ // k1==1 ? DFT : IDFT
if (n==1)return;
for (int i=0;i>1);
for (int i=0;i<(n>>1);i++) L[i]=buf[i<<1],R[i]=buf[i<<1|1];
fft(L,n>>1,k1),fft(R,n>>1,k1);
cpl w=(cpl){1,0},w1=(cpl){cos(2*PI/n),k1*sin(2*PI/n)};
for (int i=0;i<(n>>1);i++){
buf[i]=L[i]+w*R[i];
buf[i+(n>>1)]=L[i]-w*R[i];
w=w*w1;
}
for (int i=0;i
然后愉快的交一发:23333
淦。。。我写了一个假的\(FFT\)......
常数优化
我\(O(n \ log \ n)\)的那么优秀的复杂度啊!咋\(T\)了呢?
冷静分析.jpg......
注意到板子题里\(n \le 10^6\),再加上我们要做两边\(DFT\)和一边\(IDFT\),每次复杂度是\(n \ log \ n\)的,还有系统栈的巨大常数。。。
好吧,下面我们来卡卡常。
首先观察我们\(DFT \& IDFT\)的过程。
第一层:0 1 2 3 4 5 6 7 (f数组的下标)
第二层:0 2 4 6|1 3 5 7
第三层:0 4|2 6|1 5|3 7
第四层:0|4|2|6|1|5|3|7
发现原来\(f\)数组中的第\(i\)个数会出现在最后一层中第(\(i\)的二进制翻转)个数中。
举个例子,\(1:(001)_2\)出现在了\(4: (100)_2\)上,\(3: (011)_2\)出现在了\(6:(110)_2\)上。
这样我们就可以把最后一层的情况求出来,然后再递归分治。
那怎么求最后一层呢?也就是求\(i\)的二进制翻转。(这个好像叫蝴蝶变换)
设补完项后的多项式长度为limit
,令rev[i]
表示\(i\)的二进制翻转,则rev[i]=rev[i>>1]>>1|((i&1)?limit>>1:0)
,即不看\(i\)的最低位,把i>>1
翻转过来后再把\(i\)的最低位拼上去。另外我们还能省几个数组。
#include
using namespace std;
const int N=4e6+10;
typedef double db;
const db PI=acos(-1.0);
struct cpl{
db x,y;
cpl(db xx=0,db yy=0):x(xx),y(yy){}
cpl operator + (cpl k1)const{return cpl(x+k1.x,y+k1.y);}
cpl operator - (cpl k1)const{return cpl(x-k1.x,y-k1.y);}
cpl operator * (cpl k1)const{return cpl(x*k1.x-y*k1.y,x*k1.y+y*k1.x);}
};
void fft(cpl *f,int n,int k1){
if (n==1) return;
fft(f,n>>1,k1),fft(f+(n>>1),n>>1,k1);
cpl w(1,0),w1(cos(2*PI/n),sin(2*PI/n)*k1);
for (int i=0;i<(n>>1);i++){
cpl tmp=w*f[i+(n>>1)];
f[i+(n>>1)]=f[i]-tmp; // 注意这里的赋值顺序!
f[i]=f[i]+tmp;
w=w*w1;
}
}
int rev[N];
void change(cpl *f,int n){
for (int i=0;i>1]>>1|((i&1)?limit>>1:0);
change(f,len),change(g,len);
fft(f,limit,1),fft(g,limit,1);
for (int i=0;i
欸!那我们为什么不能直接自底向上合并呢?这样还省去了系统栈的常数。
最终Code
#include
using namespace std;
const int N=4e6+10;
typedef double db;
const db PI=acos(-1.0);
struct cpl{
db x,y;
cpl operator + (cpl k1)const{return (cpl){x+k1.x,y+k1.y};}
cpl operator - (cpl k1)const{return (cpl){x-k1.x,y-k1.y};}
cpl operator * (cpl k1)const{return (cpl){x*k1.x-y*k1.y,x*k1.y+y*k1.x};}
};
int rev[N];
void fft(cpl *f,int n,int k1){
for (int i=0;ii)swap(f[rev[i]],f[i]);
for (int len=2;len<=n;len<<=1){
cpl w1=(cpl){cos(2*PI/len),sin(2*PI/len)*k1};
for (int i=0;i>1);j++){
cpl tmp=w*f[j+(len>>1)];
f[j+(len>>1)]=f[j]-tmp;
f[j]=f[j]+tmp;
w=w*w1;
}
}
}
}
cpl f[N],g[N];
int main(){
int n,m; scanf("%d%d",&n,&m);
for (int i=0;i<=n;i++) scanf("%lf",&f[i].x);
for (int i=0;i<=m;i++) scanf("%lf",&g[i].x);
int limit=1; for(;limit<=n+m;limit<<=1);
for (int i=0;i>1]>>1|((i&1)?limit>>1:0);
fft(f,limit,1),fft(g,limit,1);
for (int i=0;i
这样我们就能过模板题了。