FFT&NTT
- 快速傅里叶变换(Fast-Fourier-Transform)
已知多项式求.
显然看出可以枚举两个多项式的系数,依次算出,时间.
太慢了!!怎么办?利用一个奇妙的东西:FFT
- 多项式的点值表示法
对于一个多项式,可以取个不同的值,求得个多项式值。将其作为点,即
FFT的大致思路就是
- 将多项式化为点值形式。
- 将点值相乘。即算出每一个
- 将新的点值转化回多项式形式。
前置芝士:向量与复数
- 向量
向量,即有方向的量,在平面直角坐标系上可以用表示。
图形上即为由原点指向点的有向线段。
向量的模长为
向量的幅角为向量逆时针旋转至与x轴正半轴重合时旋转的角度。
向量的加减法满足平行四边形法则,即
- 复数
定义虚数单位 满足,复数域,形如的数叫做复数。
复数可以在坐标系中表示为的向量。
同时复数的加减法满足向量的加减法,模长与幅角的定义也与向量相同。
若复数的模长为,幅角为,根据坐标系则有
复数的乘法:
并且两个复数相乘遵循一个规律:模长相乘,幅角相加。
- 复数的单位根
在坐标系中做一个单位圆,将单位圆等分成份的个向量所对应的复数称为次单位根。
幅角最小的记为,而幅角是的倍的单位根为.
8次单位根↑
由于我们只需要次单位根,所以以下单位根均为的幂次单位根。
单位根的性质:
1.
根据复数乘法,很明显。
2.
复数的三角表示法。
3.
因为.
4.
快速傅里叶变换O(nlogn)
设一个多项式的系数为.
首先在后面补系数为的项,直到为的幂数,方便接下来运算。
我们可以将所有的代入求得个点值,并可以做出优化。
令.
将代入上式
同理将代入。
之后我们发现只要求出就可以算出两个点值。而他们可以递归去求,并且刚好由次变为了次,时间复杂度类似线段树.
然后求出两个多项式的所有点值之后将他们分别相乘,得出新多项式的个点值,这一步是的。
快速傅里叶逆变换O(nlogn)
接下来我们只需要把点值形式转化为多项式形式即可。
设多项式的点值表示为
多项式,在的点值表示为
则有
令,则有
A式
得:
B式
得
所以当时
当时可以得到.
则.
有了这个结论后我们来看这个式子:
当且仅当时有值,即
所以我们只需要求出多项式在的点值表示即可算出.
递归版:
const db pi=acos(-1);
class cplx{
public:
db x,y;
cplx(){x=y=0;}
cplx(const db a,const db b){x=a,y=b;}
friend cplx operator +(const cplx a,const cplx b){return cplx(a.x+b.x,a.y+b.y);}
friend cplx operator -(const cplx a,const cplx b){return cplx(a.x-b.x,a.y-b.y);}
friend cplx operator *(const cplx a,const cplx b){return cplx(a.x*b.x-a.y*b.y,a.y*b.x+a.x*b.y);}
}a[maxn],b[maxn];
int N,M,lim=1;
void fft(int lm,cplx *a,int op){
if(lm==1) return;
cplx a1[lm>>1],a2[lm>>1];
for(int i=0;i<=lm;i+=2)
a1[i>>1]=a[i],a2[i>>1]=a[i+1];
fft(lm>>1,a1,op);
fft(lm>>1,a2,op);
cplx w1=cplx(cos(2*pi/lm),op*sin(2*pi/lm)),wk=cplx(1,0);
for(int i=0;i<(lm>>1);i++,wk=wk*w1){
cplx b=wk*a2[i];
a[i]=a1[i]+b;
a[i+(lm>>1)]=a1[i]-b;
}
}
int MAIN(){
cin>>N>>M;
for(int i=0;i<=N;i++) scanf("%lf",&a[i].x);
for(int i=0;i<=M;i++) scanf("%lf",&b[i].x);
while(lim<=N+M) lim<<=1;
fft(lim,a,1);
fft(lim,b,1);
for(int i=0;i<=lim;i++) a[i]=a[i]*b[i];
fft(lim,a,-1);
for(int i=0;i<=N+M;i++) prt(a[i].x/lim);
return 0;
}
但是我们发现,这种写法需要很多次复制数组,既耗内存也耗空间。
迭代优化:
我们写出时的递归详细:
我们发现一个神奇的性质:递归到最底层时实际的值为原下标的二进制翻转!!(具体证明见文末)
于是我们没有必要再进行递归,只需要将数组调换至最底层的状态然后一层一层往回的迭代即可!
const db pi=acos(-1);
class cplx{
public:
db x,y;
cplx(){x=y=0;}
cplx(const db a,const db b){x=a,y=b;}
friend cplx operator +(const cplx a,const cplx b){return cplx(a.x+b.x,a.y+b.y);}
friend cplx operator -(const cplx a,const cplx b){return cplx(a.x-b.x,a.y-b.y);}
friend cplx operator *(const cplx a,const cplx b){return cplx(a.x*b.x-a.y*b.y,a.y*b.x+a.x*b.y);}
}a[maxn],b[maxn];
int N,M,lim=1,tr[maxn],l=0;
void fft(cplx *a,int op){
for(int i=0;i<lim;i++) if(i<tr[i])swap(a[i],a[tr[i]]);
for(int m=1;m<lim;m<<=1){
cplx w1(cos(pi/m),op*sin(pi/m));
int len=m<<1;
for(int i=0;i<lim;i+=len){
cplx wk(1,0);
for(int k=0;k<m;k++,wk=wk*w1){
cplx a1=a[i+k],a2=wk*a[i+m+k];
a[i+k]=a1+a2;
a[i+m+k]=a1-a2;
}
}
}
}
int MAIN(){
cin>>N>>M;
for(int i=0;i<=N;i++) scanf("%lf",&a[i].x);
for(int i=0;i<=M;i++) scanf("%lf",&b[i].x);
while(lim<=N+M) lim<<=1,++l;
for(int i=1;i<lim;i++){
tr[i]=(tr[i>>1]>>1)|((i&1)?(1<<(l-1)):0);
}
fft(a,1);
fft(b,1);
for(int i=0;i<=lim;i++) a[i]=a[i]*b[i];
fft(a,-1);
for(int i=0;i<=N+M;i++) prt(a[i].x/lim);
return 0;
}
总时间复杂度为O(nlogn).
- 快速数论变换(Number-Theoretic-Transform)
我们发现,FFT中因为要用到三角函数以及浮点数的运算,精度得不到保障,并且复数的常数较大,我们可以进行优化:
引入概念:
- 原根
设m是正整数,a是整数,若a模m的阶等于φ(m),则称a为模m的一个原根。(其中φ(m)表示m的欧拉函数)
先不用管原根的定义,扔出一个结论(设为的原根):
原根满足这样的性质:
并且根据费马小定理:
所以我们知道原根的性质与单位根类似,可以用来代替.
如何求质数的原根?
首先需要知道满足的最小值一定满足.
质因数分解
那么如果有,则有
所以要验证一个数是不是原根,要枚举每一个,均满足成立,则是原根。
一般取,他的原根是.
const int maxn=(1<<21)+5,mod=998244353,g=3;
int qp(int x,int y){
long long ans=1;
while(y){
if(y&1) ans=ans*x%mod;
x=((long long)x*x)%mod;
y>>=1;
}
return (int)ans;
}
const int ginv=qp(g,mod-2);
int a[maxn],b[maxn];
int N,M,lim=1,tr[maxn],l=0;
void ntt(int *a,int op){
for(int i=0;i<lim;i++) if(i<tr[i])swap(a[i],a[tr[i]]);
for(int m=1;m<lim;m<<=1){
int len=m<<1;
int g1=qp(op==1?g:ginv,(mod-1)/len);
for(int i=0;i<lim;i+=len){
int gk=1;
for(int k=0;k<m;k++,gk=(long long)gk*g1%mod){
int a1=a[i+k],a2=(long long)gk*a[i+m+k]%mod;
a[i+k]=(a1+a2)%mod;
a[i+m+k]=(a1-a2+mod)%mod;
}
}
}
}
int MAIN(){
cin>>N>>M;
for(int i=0;i<=N;i++) scanf("%d",&a[i]);
for(int i=0;i<=M;i++) scanf("%d",&b[i]);
while(lim<=N+M) lim<<=1,++l;
for(int i=1;i<lim;i++){
tr[i]=(tr[i>>1]>>1)|((i&1)?(1<<(l-1)):0);
}
ntt(a,1);
ntt(b,1);
for(int i=0;i<=lim;i++) a[i]=((long long)a[i]*b[i])%mod;
ntt(a,-1);
int ny=qp(lim,mod-2);
for(int i=0;i<=N+M;i++) printf("%lld ",(long long)a[i]*ny%mod);
return 0;
}
保证了无精度误差,并且跑的飞快,大概是FFT速度2倍。
这里会出现一个问题,因为998244352可以整除所以在快速幂时可以直接除,但是当模数-1不能整除2的高次幂时,就需要使用任意模数NTT(MTT).
关于二进制翻转的证明:
显然,在前层对应着原下标的前位,向左即为,向右即为.
而前层对应实际系数下标的后位,向左即为,向右即为,因为选出奇数代表选择,偶数代表
所以对于任意一层,原下标的前位均相等,实际系数下标的后位均相等,且两者有着翻转关系。
在最底层即为原下标是实际下标的翻转。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!