FFT&NTT

  • 快速傅里叶变换(Fast-Fourier-Transform)

已知多项式A(x)=i=0Naixi,B(x)=i=0MbixiA(x)B(x).

  显然看出可以枚举两个多项式的系数,依次算出,时间O(nm).

  太慢了!!怎么办?利用一个奇妙的东西:FFT

  • 多项式的点值表示法

    对于一个多项式A(x)=i=0Naixi,可以取N个不同的x值,求得N个多项式值。将其作为点,即(xi,A(xi))

  FFT的大致思路就是

  1.   将多项式化为点值形式。
  2.         将点值相乘。即算出每一个C(x)=A(x)B(x)
  3.        将新的点值转化回多项式形式。

前置芝士:向量与复数

  • 向量

  向量,即有方向的量,在平面直角坐标系上可以用(a,b)表示。

  图形上即为由原点指向点(a,b)的有向线段。

  向量的模长为a2+b2

  向量的幅角为向量逆时针旋转至与x轴正半轴重合时旋转的角度。

  向量的加减法满足平行四边形法则,即m(x1,y1)±n(x2,y2)=p(x1±x2,y1±y2)

  

  • 复数

  定义虚数单位i 满足i2=1,复数域C,形如a+bi,(a,bR)的数叫做复数。

  复数a+bi可以在坐标系中表示为(a,b)的向量。

  同时复数的加减法满足向量的加减法,模长与幅角的定义也与向量相同。

  若复数z的模长为|z|,幅角为θ,根据坐标系则有

z=|z|cosθ+i|z|sinθ

  复数的乘法:

(x1+y1i)(x2+y2i)=x1x2+x2y1i+x1y2iy1y2

=(x1x2y1y2)+(x1y2+x2y1)i

  并且两个复数相乘遵循一个规律:模长相乘,幅角相加。

  • 复数的单位根

   在坐标系中做一个单位圆,将单位圆等分成n份的n个向量所对应的复数称为n次单位根

  幅角最小的记为ωn,而幅角是ωnk倍的单位根为ωnk.

     

  8次单位根↑

  由于我们只需要2n次单位根,所以以下单位根均为2的幂次单位根。

  单位根的性质:

  1.ωnkn=1(kZ),ωnkωnj=ωnk+j 

  根据复数乘法,很明显。

  2.ωnk=cos2πkn+isin2πkn 

  复数的三角表示法。

  3.ωtntk=ωnk

  因为cos2πtktn+isin2πtktn=cos2πkn+isin2πkn.

  4.ωnn2=1

ωnn2=cos2π12+isin2π12

=cosπ+isinπ

=1

快速傅里叶变换O(nlogn)

   设一个多项式A(x)的系数为(a0,a1,a2...an1).

   首先在A(x)后面补系数为0的项,直到n2的幂数,方便接下来运算。

   我们可以将所有的ωnkk[0,n1]代入求得n个点值,并可以做出优化。

A(x)=a0+a1x+a2x2+...+an1xn1

=(a0+a2x2+...+an2xn2)+x(a1+a3x2+...+an1xn2)

      令A1(x)=a0+a2x2+...+an2xn2,A2(x)=a1+a3x2+...+an1xn1.

A(x)=A1(x2)+xA2(x2)

      将x=ωnk(0k<n2)代入上式

A(ωnk)=A1(ωn2k)+ωnkA2(ωn2k)

=A1(ωn2k)+ωnkA2(ωn2k)

    同理将x=ωnk+n2(0k<n2)代入。

A(ωnk+n2)=A1(ωn2k+n)ωnkA2(ωn2k+n)

=A1(ωn2k)ωnkA2(ωn2k)

=A1(ωn2k)ωnkA2(ωn2k)

  之后我们发现只要求出A1(ωn2k)A2(ωn2k)就可以算出两个点值。而他们可以递归去求,并且刚好由n次变为了n2次,时间复杂度类似线段树O(nlogn).

  然后求出两个多项式的所有点值之后将他们分别相乘,得出新多项式的N+M+1个点值,这一步是O(n)的。

快速傅里叶逆变换O(nlogn)

  接下来我们只需要把点值形式转化为多项式形式即可。

  设多项式A(x)(a0,a1,a2...an1)的点值表示为(y0,y1,y2...yn1)

  多项式D(x)=i=0n1yixi,D(x)(ωn0,ωn1,ωn2...ωn(n1))的点值表示为(c0,c1,c2...cn1)

  则有

ck=i=0n1yi(ωnk)i

=i=0n1j=0n1ajωnijωnik

=i=0n1j=0n1aj(ωnjk)i

=j=0n1i=0n1aj(ωnjk)i

=j=0n1aji=0n1(ωnjk)i

  令T(x)=i=0n1xi,则有

T(ωnt)=1+ωnt+(ωnt)2+...+(ωnt)n1  A式

Aωnt得:

ωntT(ωnt)=ωnt+(ωnt)2+...+(ωnt)n B式 

BA

(ωnt1)T(ωnt)=(ωnt)n1

(ωnt1)T(ωnt)=(ωnn)t1=11=0

    所以当ωnt1!=0T(ωnt)=0

    当ωnt1=0时可以得到ωnt=1,t=0.

    则T(ωnt)=T(1)=i=0n11=n.

    有了这个结论后我们来看这个式子:

ck=j=0n1aji=0n1(ωnjk)i

=j=0n1ajT(ωnjk)

   当且仅当j=k时有值,即

ck=ajn

aj=ckn

    所以我们只需要求出多项式D(x)(ωn0,ωn1,ωn2...ωn(n1))的点值表示即可算出ai.

递归版:

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;
}

 但是我们发现,这种写法需要很多次复制数组,既耗内存也耗空间。

迭代优化:

  我们写出n=8时的递归详细:

  我们发现一个神奇的性质:递归到最底层时实际的值为原下标的二进制翻转!!(具体证明见文末)

    于是我们没有必要再进行递归,只需要将数组调换至最底层的状态然后一层一层往回的迭代即可!

  

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的欧拉函数)

  先不用管原根的定义,扔出一个结论(设gP的原根):

ωngP1n(modP)

  原根满足这样的性质:

gi!=gj(modP,i!=j)

  并且根据费马小定理:

ωnngP1=1(modP)

  所以我们知道原根的性质与单位根类似,可以用gP1n来代替ωn.

  如何求质数P的原根?

  首先需要知道满足an1(modP)的最小n值一定满足n|P1.

  质因数分解P1=piai

  那么如果有m|P1,n|P1,m|n,am1(modP)则有an1(modP)

  所以要验证一个数t是不是原根,要枚举每一个pi,均满足tP1pi!=1(modP)成立,则t是原根。

  P一般取998244353,他的原根是3.

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可以整除223所以在快速幂时可以直接除,但是当模数-1不能整除2的高次幂时,就需要使用任意模数NTT(MTT).

关于二进制翻转的证明:

  显然,在前i层对应着原下标的前i位,向左即为0,向右即为1.

  而前i层对应实际系数下标的后i位,向左即为0,向右即为1,因为选出奇数代表选择1,偶数代表0

  所以对于任意一层,原下标的前i位均相等,实际系数下标的后i位均相等,且两者有着翻转关系。

  在最底层即为原下标是实际下标的翻转。

posted @   xxqz  阅读(138)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!
点击右上角即可分享
微信分享提示