NTT&FFT(快速?變換,以及擴充套件)
FFT&NTT(以及擴充套件)
預備知識:用於NTT
NTT/FFT其實本質相同,用途是快速求解 多項式乘積
前言
FT: 傅立葉變換:
這是一個工程上的概念,可以簡述為:一個週期性的訊號波段可以用 若干個正弦曲線 的帶權和表示
DFT: 離散傅立葉變換,這是傅立葉變換在離散情況下的變種
FFT: 快速傅立葉變換
NTT: 快速數論變換
\[\ \]
談及核心思想
1.單位根:
構造\(\omega_n\)為\(n\)階單位根(不知道\(\omega_n\)的值域),滿足性質\(\omega_n^n=\omega_n^0=1\)
對於\(2|n\),\(\omega _n^{\frac{n}{2}}=-1\)
顯然\(\omega_n\)滿足一個非常簡單的性質:折半引理 \(\forall 2|i\and 2|n , \omega_n^i=\omega_{\frac{n}{2}}^{\frac{i}{2}}\)
\(\omega_n\)實際上是一個在冪次上呈現\(n\)元迴圈的數值
2.多項式與點值式的轉化
一個\(n\)階多項式最普通的表示就是\(F(x)=\sum_{i=0}^{n-1} a_ix^i\)
然而,多項式也可以用\(n\)個互不相關的點表示,即\((x_0,y_0),(x_1,y_1),\cdots,(x_{n-1},y_{n-1})\)
兩者可以互相轉化
對於同\(x_i\)的點值,兩個多項式卷積時,其\(y_i\)
FFT/NTT的核心過程是
多項式\(\longrightarrow\) 點值式\(\longrightarrow\)點值式對應相乘\(\longrightarrow\)多項式
而用單位根來構造快速的多項式與點值式的轉化
3.分治思想
用於降低多項式與點值式轉換的複雜度
\[\ \]
FFT的單位根
\((x,y)\)指複數\(i=\sqrt{-1},(x,y)=x+yi\)
基本運算\((x,y)+(a,b)=(x+a,y+b),(x,y)\cdot (a,b)=(ax-by,ay+bx)\)
FFT的單位根是:\(\omega_n\)=\((cos(\frac{2\pi}{n}),sin(\frac{2\pi}{n}))\)
而\(\omega _n^i=(cos(\frac{2\pi}{n}\cdot i),sin(\frac{2\pi}{n}\cdot i))\) (展開發現就是三角函式求和公式)
顯然滿足單位根的性質
(實際上可以發現,這個說是點值其實就是訊號序列的三角函式表示)
\[\ \]
NTT
相信您已經瞭解了原根的一些性質,\(\text{NTT}\)的單位根常用原根構造
\(\text{NTT}\)的單位根實際有較大的侷限性,對於質數\(P\)只能構造出\(n|P-1,\omega_n=g^{\frac{P-1}{n}}\)
計算在模意義下就能滿足單位根的性質
通常我們\(P\)取\(998244353\),\(2^{23}|(P-1)\),它的一個原根是3
實際上,為了滿足下面分治需要,構造的模數通常滿足\(P-1=s\cdot 2^t\)的\(t\)較大,這類模數我們常稱作\(\text{NTT}\)模數
\[\ \]
\[\ \]
以上部分均為基礎知識,相對來說應該不會太難,下面是主要難點
\[\ \]
多項式轉點值式
接下來我們考慮如何將多項式轉化為點值式
對於點值式,我們構造的點橫座標為\(x_i=\omega_n^i\)
具體目標是對於函式\(F(x)\),求出在\(x_0,x_1,\cdots ,x_{n-1}\)上的函式值
即求出\(F(x_i)=a_0\omega_n^0+a_1\omega_n^{i}+a_2\omega_n^{2i}+\cdots\)
接下來就是核心的分治思想,注意,這裡的分治是子問題嚴格等大的
對於當前問題,分成兩部分子問題求解(實際是可以分成多部分的,但是這個是特殊情況暫時不予討論),即求解
令\(m=\frac{n}{2}\)
\(2|i,G(x_i)=a_0\omega_{m}^0+a_2\omega_{m}^{\frac{i}{2}}+a_4\omega_{m}^{\frac{i}{2}\cdot 2}+\cdots\)
\(2|i,H(x_i)=a_1\omega_{m}^0+a_3\omega_{m}^{\frac{i}{2}}+a_5\omega_{m}^{\frac{i}{2}\cdot 2}+\cdots\)
更簡潔的描述為
\(i<m,G(x_i')=a_0\omega_{m}^0+a_2\omega_{m}^{i}+a_4\omega_{m}^{2i}+\cdots\)
\(i<m,H(x_i')=a_1\omega_{m}^0+a_3\omega_{m}^{i}+a_5\omega_{m}^{2i}+\cdots\)
由於\(G(x'_i),H(x'_i)\)計算的是\([0,m-1]\)項,而求\(F(x_i)\)時用到的是\(0,2,4,\cdots\)項,實際需要訪問\(G(x^2_i),H(x^2_i)\)
和\(F(x_i)\)的式子比較,我們得到合併的式子為
\(F(x_i)=G(x^2_i)+x_i H(x^2_i)\)
帶入折半引理,實際等價於
\(F(x_i)=G(x'_i)+x_i H(x'_i)\)
注意\(x_i=x'_{i\mod m}\)
為了保證複雜度,儘量使得每次分治的子問題都分為兩部分,這樣的複雜度為\(O(n\log n)\)
附:實際上,分為\(d\)個子問題時,每次合併的複雜度為\(O(n\cdot d)\),因此複雜度為\(O(n\cdot d \log_dn)\)
保證每次分治為兩個嚴格等大的子問題,可以從一開始就把\(n\)擴充為\(2\)的冪次
int N=1;
while(N<=n+m) N<<=1;
附:\(d\)個子問題時,設子問題答案為\(G_j(x_i)\),則合併的式子為
\(\begin{aligned} F(x_i)=\sum_{j=0}^{d-1}x_i^jG_j(x_i^d)=\sum_{i=0}^{d-1}x_i^jG_j(x'_{i\mod \frac{n}{d}})\end{aligned}\)
點值式轉多項式
一個簡單的性質:單位根反演 \(\sum_{j=0}^{n-1}\omega_n^{ij}= \left\{\begin{aligned} \frac{\omega_n^{in}-1}{\omega_n^i-1}=0 && i\ne 0\\ n && i=0\end{aligned} \right.\)
設點值式對應\(y_i\)的序列為\(b_i\)
則\(n\cdot a_i=\sum_{j=0}^{n-1}\omega_n^{-ij} b_j\)
證明
$\begin{aligned} \sum_{j=0}^{n-1}\omega_n^{-ij}b_j=\sum_{j=0}^{n-1} \omega_n^{-ij}(\sum_{k=0}^{n-1}a_k\omega_n^{jk})\end{aligned} $
$\begin{aligned} \sum_{j=0}^{n-1}\omega_n^{-ij}b_j= \sum_{k=0}^{n-1}a_k\sum_{j=0}^{n-1}\omega_n^{j(k-i)} \end{aligned} $
由上面的式子,發現只有\(k-i=0\)時右邊的求和式有值,所以上式成立
因此點值式轉多項式直接把係數改為\(\omega_n^{-i}\)即可
\[\ \]
\[\ \]
程式碼實現與優化
然後我們得到一份優美的程式碼(FFT)
(Complex是C++庫自帶的複數,M_PI是C++自帶\(\pi\)常量)
void FFT(int n,Complex *a,int f) {
if(n==1) return;
Complex tmp[N];
int m=n/2;
rep(i,0,m-1) tmp[i]=a[i<<1],tmp[i+m]=a[i<<1|1]; // 按照奇偶分類
memcpy(a,tmp,sizeof(Complex) * n);
FFT(m,a,f),FFT(m,a+m,f); // 分兩半,算g(x),h(x)
Complex w(cos(2*M_PI/n),f*sin(2*M_PI/n)),e(1,0); // w=x^1,e=x^i
rep(i,0,m-1) {
tmp[i]=a[i]+e*a[i+m]; // f(x_i)=g(x_i)+e*h(x_i)
e=e*w;
}
rep(i,m,n-1) {
tmp[i]=a[i-m]+e*a[i];
e=e*w;
}
memcpy(a,tmp,sizeof(T)*n);
}
由於\((\omega_n)^{\frac{n}{2}}=-1\),所以還可以簡化為
Complex w(cos(2*M_PI/n),f*sin(2*M_PI/n)),e(1,0);
rep(i,0,m-1) {
tmp[i]=a[i]+e*a[i+m];
tmp[i+m]=a[i]-e*a[i+m];
e=e*w;
}
由於用了double,最後輸出要取整
蝴蝶優化
我們加一點優化,取代遞迴的分治過程
可以看到,分治時我們按照\(i \mod 2\)分成兩組,然後繼續分
這個過程中,實際上我們就是將\(i\)的二進位制位前後翻轉
所以我們可以暴力處理出\(i\)分治底層的位置
rep(i,0,n-1) {
int x=i,s=0;
for(int j=1;(j<<c)<=n;++j) {
s=(s<<1)|(x&1);
x>>=1;
} // s就是最終位置
}
當然也是有\(O(n)\)處理方法的
int N=1,c=-1;
while(N<=n+m) N<<=1,c++;
rep(i,1,N-1) rev[i]=(rev[i>>1]>>1)|((i&1)<<c);
(建議自己模擬一下)
有了這個翻轉陣列,我們可以直接從分治底層開始解決整個問題,每次合併操作完全相同
每次分治問題的大小,依次合併每一個子問題區間即可
為了在一個數組上完成操作,還需要注意合併順序
程式碼解釋\(i\):分治子問題大小為\(2i\),\(l\):合併區間的左端點為\(l\),右端點為\(l+2i\),\(j\)列舉合併位置
for(int i=1;i<n;i<<=1) {
Complex w(cos(2*M_PI/n),f*sin(2*M_PI/n));
for(int l=0;l<n;l+=i*2) {
Complex e(1,0);
for(int j=l;j<l+i;++j,e=e*w) {
Complex t=a[j+m]*e; // a'[j]=a[j]+e*a[j+m]
// a'[j+i]=a[j]-e*a[j+m]
a[j+m]=a[j]-t;
a[j]=a[j]+t;
}
}
}
事實上我們還有更快的寫法,就是將\(\omega_n^i\)預處理出來(注意這個預處理很考驗double精度,不能每次都直接累乘上去,隔幾個就要重新呼叫依次三角函式)
當然如果自己寫複數會更快
// Complex
struct Cp{
double x,y;
Cp(){}
Cp(double _x,double _y){ x=_x,y=_y; }
Cp operator + (const Cp t) { return Cp(x+t.x,y+t.y); }
Cp operator - (const Cp t) { return Cp(x-t.x,y-t.y); }
Cp operator * (const Cp t) { return Cp(x*t.x-y*t.y,x*t.y+y*t.x); }
};
這個運算可以背一下...
\[\ \]
順便提供一份我平時的NTT模板(希望沒有敲錯)
const int N=1<<18|5,P=998244353;
ll a[N],b[N],w[N];
int rev[N];
void NTT(int n,ll *a,int f){
rep(i,0,n-1)if(i<rev[i]) swap(a[i],a[rev[i]]);
for(int i=1;i<n;i<<=1) {
int len=n/i/2;
for(int l=0;l<n;l+=i*2) {
for(int j=l;j<l+i;++j) {
ll t=a[j+i]*w[len*(j-l)]%P;
a[j+i]=(a[j]-t)%P;
a[j]=(a[j]+t)%P;
}
}
}
if(f==-1) {
ll base=qpow(n,P-2);
rep(i,0,n-1) a[i]=a[i]*base%P;
}
}
int main(){
int R=1,c=-1;
while(R<=n)R<<=1,c++;
rep(i,1,R) rev[i]=(rev[i>>1]>>1)|((i&1)<<c);
w[0]=1,w[1]=qpow(3,(P-1)/R);
rep(i,1,R-1) w[i]=w[i-1]*w[1]%P;
NTT(R,a,1),NTT(R,b,1);
w[0]=1,w[1]=qpow((P+1)/3,(P-1)/R);
rep(i,1,R-1)w[i]=w[i-1]*w[1]%P;
rep(i,0,R-1)a[i]=a[i]*b[i]%P;
NTT(R,a,-1);
}
練習建議:
1.高精度乘法
然後去學下面的拓展1,2,3
6.\(CDQ\)分治+\(FFT\):HDU-5730 題解
7.\(CDQ\)+NTT/降次字首和優化\(dp\):HDU-5332 題解
9.圖上\(dp\):
聯通圖個數:BZOJ-3456 題解
森林數量和帶限制森林數量:HDU - 5279 題解
10.點分治+FFT:CodeChef-PRIMEDST 題解
\[\ \]
\[\ \]
\[\ \]
拓展
1.分治+NTT
常用於處理n元計數揹包的快速合併
我們可以用NTT\(nlogn\)合併兩個大小為\(n\)的揹包
分治時,每次合併兩個分治子問題,總共的時間就是\(\sum size\log n\)
每個揹包的\(size\)會被計算\(\log n\)次,所以總共複雜度是\(n \log ^2 n\)
\[\ \]
2.CDQ+NTT
對於形如\(dp[i]=\sum_0^{i-1}dp[j]g[i-j]\)的\(dp\)轉移(就是dp轉移與差值有關)
我們用NTT/FFT對於每個\((l,mid)\)向\((mid+1,r)\)的轉移,總複雜度為\(n\log^2 n\)
演算法流程
void Solve(int l,int r){
if(l==r) return;
int mid=(l+r)>>1;
Solve(l,mid);
(l,mid)->(mid+1,r);
Solve(mid+1,r);
}
\[\ \]
3.MTT(任意模數NTT)
就是模數比較奇怪,不能直接算,我們要先算出準確值再取模,但是double算絕對爆炸...
通常有一種比較假但是很好理解:取幾個互質的模數分別做一次,然後中國剩餘定理合併(賊慢)
正解:拆係數FFT
將係數\(a_i\)分成\(A_i\cdot S+C_i\)
\(b_i\):\(B_i\cdot S+D_i\)
(其中\(S\ge \sqrt{Mod}\)是一個常數,\(0 \leq A_i,B_i,C_i,D_i<S\))
轉化後係數值域變小,double精度吃得消
最後的答案轉化為\(A_iB_jS^2+(C_iB_j+A_iD_j)S+C_iD_j\)
如果直接求解,可以看出要求四次乘積,\(FFT12\)次,不可接受
利用虛數的一些性質,有些東西我們可以一起算
構造
\(f(x)=\sum (A_i,C_i)x^i\)
\(g(x)=\sum(B_i,D_i)x^i\)
\(f(x)g(x)=\sum \sum (A_iB_j-C_iD_j, A_iD_j+C_iB_j)x^{i+j}\)
(其實已經求出一半了)
\(h(x)=\sum B_ix^i\)
\(f(x)h(x)=\sum \sum (A_iB_j,C_iB_j)x^{i+j}\)
取一部分即可
最終一共有5次FFT
Tips:負數取整一定要注意,因為是向0取整,而不是向下取整
\[\ \]
4.\(n\)元點值式
(如果是NTT,必須滿足\(n|(P-1)\))
設\(\omega _n\)=t
看到我們要求的\(f(x^k)=\sum a_i\cdot t^{i k}\)
\(i\cdot k=\cfrac{i^2+k^2-(i-k)^2}{2}\)
我們可以對於每一個\(a_i\)計算其對於每個\(f(x^k)\)的貢獻(Bluestein’s Algorithm)
具體過程:
構造\(g(x)=\sum a_it^{i^2}x^i,h(x)=t^{-i^2}x^i\)
將兩個式子相乘,得到了\(\cfrac{f(x^{2k})}{t^{k^2}}\)
最後再亂處理一下即可
n元點值式有啥用?
上文我們提到,FFT/NTT得到的結果是\(f(x^{i\mod n})\)
也就是說係數同樣存在迴圈關係,我們可以利用\(n\)元卷積做到指定大小的迴圈卷積,可以處理一些特定問題
\[\ \]
\[\ \]
更多應用和優化參見毛嘯2016論文
(如:兩次FFT做卷積,4次FFT做MTT。。。)