FFT學習筆記

无敌の暗黑魔王發表於2024-11-04

$\quad $ 本人蒟蒻,只能介紹FFT在OI中的應用,如有錯誤或不當之處還請指出。

$\quad $ 首先先說一下那一堆什麼什麼 \(TT\) 的都是什麼

DET:離散傅立葉變換 用於求多項式乘法 \(O(n^2)\)
FFT :快速傅立葉變換 用於求多項式乘法 \(O(n log(n))\)
FNTT/NTT :FTT的最佳化,常數及精度更優
FWT:快速沃爾什變換
MTT:任意模數NTT
FMT 快速莫比烏斯變換

$\quad $ 後面三個我都不會,這裡也不介紹 NTT我也不會,仙姑了~

以下是一些前置知識(當然不只是前置知識🌚)

多項式

$\quad $ 多項式有係數表示法和點值表示法兩種,我們常見的 \(1+x+x^2+x^3\) 這樣的就叫做係數表示法,而用若干個點表示多項式的就叫做點值表示法。

$\quad $ 把多項式看作函式,如果你學過一點插值的話,就可以知道給定座標系中橫座標互不相同的 \(n+1\) 個點,可以唯一確定一條 \(n\) 次函式經過這 \(n+1\) 個點。

$\quad $ 用係數表示法來計算兩個多項式之積,列舉各項係數相乘,時間複雜度是 \(O(n^2)\) 的。但是點值表示法呢?我們只需要把在某一點處兩個多項式的值相乘,就能得到結果多項式在該處的值,直接列舉 \(n\) 個點即可,時間複雜度 \(O(n)\)

$\quad $ 那麼我們就可以考慮將兩個多項式全部轉化為點值表示,然後計算乘積,再將結果多項式從點值表示轉化為係數表示,這也就是FFT的大致流程。

$\quad $ 那麼點值表示和係數表示相互轉化的時間複雜度呢?發現是 \(O(n^2)\) 的,《這麼一整直接把常數最佳化大了🌚》

$\quad $ 那麼我們就要引出快速傅立葉變換了,在這之前我們引出單位根這個概念。

單位根

$\quad $ 如果有沒學過複數運算的,請先去學複數運算。

$\quad $ 首先對於方程 \(x^n=1\) ,其在複數域中有 \(n\) 個解,並且如果畫出來的話,這 \(n\) 個解恰好等分單位圓,我們按照逆時針順序分別叫他們 \(\omega _{n}^{1} 、 \omega _{n}^{2} 、\cdots 、\omega _{n}^{n}\) ,不難發現其性質:

\[\omega _{2n}^{2k}=\omega _{n}^{k} \]

\[\omega _{2n}^{k+n}=-\omega _{2n}^{k} \]

\[(\omega _{n}^{i})^j=(\omega _{n}^{j})^i \]

\[\sum _{i=0}^{n-1}(\omega _{n}^{k})^i=n\times [k==n] \]

$\quad $ 前三條性質顯然,我證明一下第四條:

$\quad $ 首先看這個級數:\(\sum _{i=0}^{n}x^i\) ,我們令 \(f=\sum _{i=0}^{n}x^i\) ,那麼 \(xf=\sum _{i=1}^{n}x^i\) ,相減可以求出 \(f=\frac{x ^{n+1}-1}{x-1}\) ,當然在分母為 \(0\) 時不能用該式求解。我們發現上式就是一個等比數列的形式,所以:

\begin{aligned}
\sum _{i=0}^{n-1}{ ( \omega _{n}^{k} ) ^ i}&=\frac{(\omega _{n}^{k}) ^ n}{\omega _{n}^{k}-1}=\frac{(\omega _{n}^{n}) ^ k -1}{\omega _{n}^{k}-1}=\frac{1-1}{\omega _{n}^{k}-1}
\end{aligned}

$\quad $ 這裡分子一定為零了,我們討論分母。發現當且僅當 \(k=n\) 時,分母為零,此時直接代入原式得到答案為 \(n\) 。其餘情況該式均為零。

快速傅立葉變換

$\quad $ 那麼介紹這個單位根幹什麼呢?我們可以取一個 \(n-1\) 次多項式在這 \(n\) 個單位根處的取值來作為這個多項式的點值表示,至於為什麼這麼取,看下面就知道了。

$\quad $ 我們定義一個 \(n-1\) 次多項式 \(A(x)=\sum _{i=0}^{n-1}a _{i}x ^{i}\) ,其各項係數為 \(a _{0},a _{1},\cdots,a _{n-1}\)

$\quad $ 我們按照各項係數下標的奇偶性把係數分成兩類,並各作為一個 \(\frac{n}{2}-1\) 次多項式的係數,從而得到下面兩個新的多項式:

\[A _{1}(x)=\sum _{i=0}^{\frac{n}{2}-1}a _{2i} x ^{i} \]

\[A _{2}(x)=\sum _{i=0}^{\frac{n}{2}-1}a _{2i+1} x ^{i} \]

$\quad $ 並且有:\(A(x)=A _{1}(x^2)+xA _{2}(x^2)\)

$\quad $ 再看一下我們要求什麼,我們要求:\(A(\omega _{n}^{1}),A(\omega _{n}^{2}),\cdots,A(\omega _{n}^{n})\)
$\quad $ 分別代入 \(A(\omega _{n}^{k})\)\(A(\omega _{n}^{k+\frac{n}{2}})\) ,我們可以得到以下兩個式子:

\begin{aligned}
A(\omega _{n}^{k})&=A _{1} ((\omega _{n}^{k}) ^ 2)+\omega _{n}^{k} A _{2} ((\omega _{n}^{k}) ^ 2)\\
&=A _{1} (\omega _{n}^{2k})+\omega _{n}^{k} A _{2} (\omega _{n}^{2k})\\
&=A _{1} (\omega _{\frac{n}{2}}^{k})+\omega _{n}^{k} A _{2} (\omega _{\frac{n}{2}}^{k})\\
\end{aligned}

\begin{aligned}
A(\omega _{n}^{k+\frac{n}{2}})&=A _{1} ((\omega _{n}^{k+\frac{n}{2}}) ^ 2)+\omega _{n}^{k+\frac{n}{2}} A _{2} ((\omega _{n}^{k+\frac{n}{2}}) ^ 2)\\
&=A _{1} (\omega _{n}^{2k+n})+\omega _{n}^{k+\frac{n}{2}} A _{2} (\omega _{n}^{2k+n})\\
&=A _{1} (\omega _{\frac{n}{2}}^{k})-\omega _{n}^{k} A _{2} (\omega _{\frac{n}{2}}^{k})\\
\end{aligned}

$\quad $ 發現這兩個式子驚人的相似,那我們只需要求出 \(A(\omega _{n}^{1}),A(\omega _{n}^{2}),\cdots,A(\omega _{n}^{\frac{n}{2}})\) ,然後就可以 \(O(1)\) 地求出 \(A(\omega _{n}^{\frac{n}{2}+1}),A(\omega _{n}^{\frac{n}{2}+2}),\cdots,A(\omega _{n}^{n})\)

$\quad $ 我們考慮等式右邊的東西,它的形式和原來那個問題一樣,於是我們就可以遞迴求解了。但是這裡的除法均不是向下取整,所以在實現的時候我們需要把 \(n\) 補成一個 \(2\) 的整數次冪,也就是把原先的多項式都補成一個 \(2^k\) 次的多項式,至於多出來的係數,直接置為零就好了,也不會影響答案。不難發現,這樣的演算法時間複雜度是 \(O(n log(n))\) 的。

放個碼
struct complex{
    double x,y;
    complex operator+(const complex op)const{
        return complex{x+op.x,y+op.y};
    } 
    complex operator-(const complex op)const{
        return complex{x-op.x,y-op.y};
    }
    complex operator*(const complex op)const{
        return complex{x*op.x-y*op.y,x*op.y+y*op.x};
    }
};//當然你也可以直接用std裡的complex
void FFT(complex*a,int n,int op){
    if(!n)return;
    complex a1[n],a2[n];
    for(int i=0;i<n;++i)a1[i]=a[i<<1],a2[i]=a[i<<1|1];
    FFT(a1,n>>1,op);FFT(a2,n>>1,op);
    complex W{cos(pi/n),sin(pi/n)*op},w{1,0};
    for(int i=0;i<n;++i,w=w*W)a[i]=a1[i]+w*a2[i],a[i+n]=a1[i]-w*a2[i];
}

$\quad $ 於是我們有了 \(O(nlog(n))\) 將一個係數表示的多項式轉為點值表示的方法,然後呢?不要忘了我們還要將點值表示的結果多項式再轉為係數表示,所以下面我們介紹快速傅立葉逆變換。

快速傅立葉逆變換

$\quad $ 這裡有線性代數的推法,如果你是會範德蒙德矩陣巨佬的話,就不要看這裡的介紹了。🌚

$\quad $ 我們再定義一個多項式 \(B=\sum _{i=0}^{n-1}y _{i} x^i\) ,這裡的 \(y _{i}\)\(A\)\(\omega _{n}^{i}\) 處的值,即 \(A(\omega _{n}^{i})\) (說了句廢話🌚)。

$\quad $ 上面的 \(\omega _{n}^{0}\) 就等於 \(\omega _{n}^{n}\)怕有人沒想過來還是寫一下🌚。

$\quad $ 這是係數表示 \(B\) ,我們考慮用點值表示法來表示 \(B\) ,這次我們取 \(b _{i}=\omega _{n}^{-i}\) ,那為什麼要這麼取呢?我也不知道 可以去學一下用範德蒙德矩陣推導,然後就知道為什麼是這個了🌚。

$\quad $ 我們先試著把 \(b _{k}\) 代入 \(B\) 中:

\begin{aligned}
B(b _{k})&=\sum _{i=0}^{n-1}A(\omega _{n}^{i})(\omega _{n}^{-k}) ^{i}\\
&=\sum _{i=0}^{n-1}\sum _{j=0}^{n-1}a _{j}(\omega _{n}^{i}) ^{j}(\omega _{n}^{-k}) ^{i}\\
&=\sum _{j=0}^{n-1}a _{j}\sum _{i=0}^{n-1}(\omega _{n}^{j-k}) ^{i}
\end{aligned}

$\quad $ 後面的部分是個等比數列,直接用上文中單位根的第四條性質可以得到只有當 \(j=k\) 時,該部分值為 \(n\) ,否則為零,不難得出:

\[B(b _{k})=B(\omega _{n}^{-k})=na _{k} \]

$\quad $ 發現我們只需要得到 \(B\)\(\omega _{n}^{-1},\omega _{n}^{-2},\cdots,\omega _{n}^{-n},\) 處的取值,就可以推出結果多項式 \(A\) 的各項係數,也就有了我們想要的東西。

$\quad $ 如果你有過人的觀察能力,就發現這步和上一步相比多了個負號,那麼我們是否還可以用之前的方法去做呢?當然是可以的,這裡我就不再贅述了,自己推導即可。實現的時候多傳一個引數區分開變換和逆變換就行了。

$\quad $ 於是我們就得到了遞迴版的FFT:

點選檢視程式碼
#include<bits/stdc++.h>
const int N=4e6+10;
const double pi=acos(-1);
struct complex{
    double x,y;
    complex operator+(const complex op)const{
        return complex{x+op.x,y+op.y};
    } 
    complex operator-(const complex op)const{
        return complex{x-op.x,y-op.y};
    }
    complex operator*(const complex op)const{
        return complex{x*op.x-y*op.y,x*op.y+y*op.x};
    }
}a[N],b[N];
int n,m;
inline int read(){
    int ans=0;char ch=getchar_unlocked();bool fl=0;
    while(ch<'0'||ch>'9'){if(ch=='-')fl=1;ch=getchar_unlocked();}
    while(ch>='0'&&ch<='9')ans=(ans<<3)+(ans<<1)+(ch^48),ch=getchar_unlocked();
    return fl?~ans+1:ans;
}
inline void print(int x){(x<0)&&(putchar_unlocked('-'),x=-x);(x>9)&&(print(x/10),0);putchar_unlocked(x%10|48);}
void FFT(complex*a,int n,int op){
    if(!n)return;
    complex a0[n],a1[n];
    for(int i=0;i<n;++i)a0[i]=a[i<<1],a1[i]=a[i<<1|1];
    FFT(a0,n>>1,op);FFT(a1,n>>1,op);
    complex W{cos(pi/n),sin(pi/n)*op},w{1,0};
    for(int i=0;i<n;++i,w=w*W)a[i]=a0[i]+w*a1[i],a[i+n]=a0[i]-w*a1[i];
}
int main(){
    // freopen("1.in","r",stdin);
    scanf("%d%d",&n,&m);
    for(int i=0;i<=n;++i)a[i]={1.0*read(),0};
    for(int i=0;i<=m;++i)b[i]={1.0*read(),0};
    m=m+n;n=1;
    while(n<=m)n<<=1;
    FFT(a,n>>1,1);FFT(b,n>>1,1);
    for(int i=0;i<n;++i)a[i]=a[i]*b[i];
    FFT(a,n>>1,-1);
    for(int i=0;i<=m;++i)printf("%.0f ",fabs(a[i].x)/n);
}

$\quad $ 這裡應該是過不了板子題的,但是它意外過了,沒看懂,但是還是要引出最佳化。《就當上面那個碼過不了吧》

$\quad $ 於是我們引出一些最佳化,注意到上面的程式碼中有:a[i]=a1[i]+w*a2[i],a[i+n]=a1[i]-w*a2[i]; ,其中 w*a2[i] 我們計算了兩次,而複數相乘常數會很大,所以我們拿個變數記一下就好了。於是我們完成了所有的最佳化

$\quad $ 《顯然不是的🌚》,發現遞迴常數大,我們考慮是否可以擺脫遞迴,就有了下面這個最佳化:

位逆序置換

$\quad $ 我們觀察每次分治後下標的位置變化,這裡以七次多項式舉例:

\([a _{0},a _{1},a _{2},a _{3},a _{4},a _{5},a _{6},a _{7}]\)
\([a _{0},a _{2},a _{4},a _{6}] [a _{1},a _{3},a _{5},a _{7}]\)
\([a _{0},a _{4}][a _{2},a _{6}][a _{1},a _{5}][a _{3},a _{7}]\)
\([a _{0}][a _{4}][a _{2}][a _{6}][a _{1}][a _{5}][a _{3}][a _{7}]\)

$\quad $ 再用二進位制表示出開始和結尾的位置:

\(000,001,010,011,100,101,110,111\)
\(000,100,010,110,001,101,011,111\)

$\quad $ 如果我們知道每個係數遞迴到最後的位置,那麼我們就可以自下而上的遞推求解,也就擺脫了遞迴。

$\quad $ 那麼我們如何求呢?顯然我們可以一位一位地翻轉求解,這樣的時間複雜度是 \(O(n log(n))\) 的,當然我們還有更好的方法。

$\quad $ 記原先下標為 \(n\) 的係數最後的位置為 \(f(n)\) ,我們考慮遞推求解:

$\quad $ 我們可以先將 \(n\) 右移一位,翻過來再右移一位,最後再加上 \(n\) 最低位翻轉的結果,即可得到 \(f(n)\) ,寫成數學式子是:\(f(n)=\lfloor \frac{f(\lfloor\frac{n}{2}\rfloor)}{2}\rfloor+(n\bmod2)\times \frac{len}{2}\)證明我也不知道

非遞迴版FFT
#include<bits/stdc++.h>
const int N=4e6+10;
const double pi=acos(-1);
struct complex{
    double x,y;
    complex operator+(const complex op)const{
        return complex{x+op.x,y+op.y};
    } 
    complex operator-(const complex op)const{
        return complex{x-op.x,y-op.y};
    }
    complex operator*(const complex op)const{
        return complex{x*op.x-y*op.y,x*op.y+y*op.x};
    }
}a[N],b[N],buf[N];
int n,m,pos[N];
inline int read(){
    int ans=0;char ch=getchar_unlocked();bool fl=0;
    while(ch<'0'||ch>'9'){if(ch=='-')fl=1;ch=getchar_unlocked();}
    while(ch>='0'&&ch<='9')ans=(ans<<3)+(ans<<1)+(ch^48),ch=getchar_unlocked();
    return fl?~ans+1:ans;
}
inline void print(int x){(x<0)&&(putchar_unlocked('-'),x=-x);(x>9)&&(print(x/10),0);putchar_unlocked(x%10|48);}
void FFT(complex a[],int flag){
    for(int i=0;i<n;i++)if(i<pos[i])std::swap(a[i],a[pos[i]]);
    for(int mid=1;mid<n;mid<<=1){
        complex W={cos(pi/mid),flag*sin(pi/mid)};
        for(int r=mid<<1,j=0;j<n;j+=r){
            complex w={1,0};
            for(int k=0;k<mid;k++,w=w*W){
                complex op=w*a[j+k+mid];
                buf[j+k]=a[j+k]+op;
                buf[j+k+mid]=a[j+k]-op;
            }
        }
        for(int i=0;i<n;i++)a[i]=buf[i];
    }
}
int main(){
    scanf("%d%d",&n,&m);
    for(int i=0;i<=n;++i)a[i]={1.0*read(),0};
    for(int i=0;i<=m;++i)b[i]={1.0*read(),0};
    m=m+n;n=1;
    while(n<=m)n<<=1;
    for(int i=1;i<=n;i++)pos[i]=(pos[i>>1]>>1)|((i&1)*(n>>1));
    FFT(a,1);FFT(b,1);
    for(int i=0;i<n;++i)a[i]=a[i]*b[i];
    FFT(a,-1);
    for(int i=0;i<=m;++i)printf("%.0f ",fabs(a[i].x)/n);
}

$\quad $ 發現遞迴改成非遞迴,直接慢了0.49s,這是怎麼燴逝呢?我也不知道,但理論上是快了🌚,大概是我寫的常數太大了吧🌚。

$\quad $ 但是無所謂,我們認為它起到了一定的最佳化作用,但是我們發現多了一個陣列,那我們可不可以不要這個陣列呢?於是就有了下面這個最佳化:

蝶形運算最佳化

$\quad $ 原本的方法可能要動腦子,我這裡就說一種不用動腦子的方法。

$\quad $ 觀察這裡:

for(int r=mid<<1,j=0;j<n;j+=r){
    complex w={1,0};
    for(int k=0;k<mid;k++,w=w*W){
        complex op=w*a[j+k+mid];
        buf[j+k]=a[j+k]+op;
        buf[j+k+mid]=a[j+k]-op;
    }
}
for(int i=0;i<n;i++)a[i]=buf[i];

$\quad $ 發現兩次賦值比較慢,直接拿兩個變數臨時存一下,直賦值給原陣列即可,這樣就少了個陣列,並且不會有影響。

就是這樣
#include<bits/stdc++.h>
const int N=4e6+10;
const double pi=acos(-1);
struct complex{
    double x,y;
    complex operator+(const complex op)const{
        return complex{x+op.x,y+op.y};
    } 
    complex operator-(const complex op)const{
        return complex{x-op.x,y-op.y};
    }
    complex operator*(const complex op)const{
        return complex{x*op.x-y*op.y,x*op.y+y*op.x};
    }
}a[N],b[N];
int n,m,pos[N];
inline int read(){
    int ans=0;char ch=getchar_unlocked();bool fl=0;
    while(ch<'0'||ch>'9'){if(ch=='-')fl=1;ch=getchar_unlocked();}
    while(ch>='0'&&ch<='9')ans=(ans<<3)+(ans<<1)+(ch^48),ch=getchar_unlocked();
    return fl?~ans+1:ans;
}
inline void print(int x){(x<0)&&(putchar_unlocked('-'),x=-x);(x>9)&&(print(x/10),0);putchar_unlocked(x%10|48);}
void FFT(complex a[],int flag){
    for(int i=0;i<n;i++)if(i<pos[i])std::swap(a[i],a[pos[i]]);
    for(int mid=1;mid<n;mid<<=1){
        complex W={cos(pi/mid),flag*sin(pi/mid)};
        for(int r=mid<<1,j=0;j<n;j+=r){
            complex w={1,0};
            for(int k=0;k<mid;k++,w=w*W){
                complex op=w*a[j+k+mid],opl=a[j+k];
                a[j+k]=opl+op;
                a[j+k+mid]=opl-op;
            }
        }
    }
}
int main(){
    scanf("%d%d",&n,&m);
    for(int i=0;i<=n;++i)a[i]={1.0*read(),0};
    for(int i=0;i<=m;++i)b[i]={1.0*read(),0};
    m=m+n;n=1;
    while(n<=m)n<<=1;
    for(int i=1;i<=n;i++)pos[i]=(pos[i>>1]>>1)|((i&1)*(n>>1));
    FFT(a,1);FFT(b,1);
    for(int i=0;i<n;++i)a[i]=a[i]*b[i];
    FFT(a,-1);
    for(int i=0;i<=m;++i)printf("%.0f ",fabs(a[i].x)/n);
}

$\quad $ 一個挺簡單也挺好的最佳化,比著上一版非遞迴直接少了1.07s,想再瞭解點的移步oi-wiki,當然還有一個最佳化

三步變兩步最佳化

$\quad $ 顧名思義,之前的程式碼呼叫了三次FFT函式,我們實際上是可以只呼叫兩次的。

$\quad $ 對於給定的兩個多項式 \(A(x)=\sum _{i=0}^{n-1}a _{i}x ^{i},B(x)=\sum _{i=0}^{m-1}b _{i} x^i\) ,我們令 \(a、b\) 分別為新的多項式各項的虛部和實部(我這裡用 \(\rm i\) 來表示虛數單位,注意辨別),從而得到多項式 \(H(x)=\sum _{i=0}^{len-1}(a _{i} + b _{i} {\rm i}) x ^i\)

$\quad $ 可以將其拆成兩項相乘:

\[\begin{aligned} H(x)=(\sum _{i=0}^{len-1}a _{i}x ^{i}+\sum _{i=0}^{len-1}b _{i}{\rm i} x ^{i}) ^ {2}\ &=(\sum _{i=0}^{len-1}a _{i}x ^{i})^ {2}-(\sum _{i=0}^{len-1}b _{i}x ^{i} ) ^ {2}+2\sum _{i=0}^{len} a _{i}x ^{i} \sum _{j=0}^{len}b _{j}x ^{j} {\rm i} \end{aligned}\]

$\quad $ 這樣我們只需要計算 \(H^2\) ,再取其虛部的 \(\frac{1}{2}\) 即可,但是這樣做的誤差會大一點。

$\quad $ 到這裡FFT的內容就結束了。

\[To Be Contnued …… \]

後記

$\quad $ 開始看FFT的時候感覺好難,甚至以為單位根是最難的東西,後來發現這比著後面的部分簡單的要多。許多東西也是邊學邊寫的,如果有不當之處還請各位大佬指出,本人將不勝感激。

相關文章