多项式乘法(FFT)学习笔记

Reference:

@自为风月马前卒 亦 @attack 的博客

@一扶苏一 的博客

@FlashHu 的博客



一、什么是 FFT

快速傅里叶变换(Fast Fourier Transform)是一种用于在 O(nlogn) 实现多项式“点值表示法”和“系数表示法”之间变换的算法。

而往往在把系数表示法转化为了点值表示法后,我们可以更方便地解决一些问题,比如说多项式乘法。



二、思想

P3803 【模板】多项式乘法(FFT) 为例。


1. 多项式相乘

其实很简单,这里给出一个形式化定义。

f(x) 的系数为向量 (a0,a1,,an1)g(x) 的系数为 (b0,b1,,bm1),则 f(x)g(x) (亦称 f(x),g(x) 的卷积)的系数 (c0,c1,,cn+m1) 满足:

cj=i=0jaibji


2. 点值表示法

如果要在 O(n2) 的时间内解决这道题该怎么办?很简单,根据“多项式相乘的形式化表述”中的那个式子递推即可。

为了优化复杂度,前人们引入了多项式的一个新的表示法——点值表示法。具体地,每一个多项式可以看成一个函数,那么每一个取值 x 都可以对应一个点 (x,f(x))。明显,对于一个 n1 次的多项式,当我们取 n 个不同点时,即可唯一地确定一个多项式。

引入点值表示法对于复杂度的优化有什么益处呢?如果现在已经有了两个多项式的点值表示法,且每个点取的 x 是相同的,那么它们的卷积的点值表示法即为 (x,f(x)g(x))。这一计算可以在 O(n+m) 的时间内完成。(当然,由于相乘后多项式次数增高,对于 f(x),g(x) 分别计算点值表示法时,不能只各取 n,m 个点,都要至少取到 n+m 个才可以直接相乘计算它们的卷积。)

但是由于直接计算点值表示法的复杂度仍旧为 O(n2) 的,这看上去对复杂度并没有起到任何的优化效果。于是让我们请出神奇的快速傅里叶变换,看它是如何在 O(nlogn) 的时间内实现系数表示法和点值表示法之间的转换的。


3. 一点点前置数学知识

  • 三角函数

  • 向量

  • 复数

(前两者由于笔者在攥稿时已经在数学课上学习过了,暂且略去。)

  • 虚数单位:定义常数 i 满足 i2=1,称为虚数单位。

  • 复数:定义形如 a+bi(a,bR) 的数为复数。

  • 实数和虚数:当且仅当 b=0 时,它是实数;当且仅当 b0 时,它是虚数;当且仅当 a=0b0 时,它是纯虚数。

image

  • 复平面:我们已知实数与数轴上的数一一对应。由于一个复数可以唯一地写作 a+bi(a,bR),所以每一个复数都可以对应平面上的一个向量——(a,b)。称这个平面为复平面。x 轴是实数轴,即实轴;y 轴为纯虚数轴,即虚轴。

image

  • 模(绝对值):定义复数所对应向量的模,为该复数的模(又称绝对值)。形式化地,可表示为 a2+b2

  • 幅角:以 x 轴正半轴为始边,复数对应的向量所在的射线为终边,形成的角。

  • 三角表示法:一个复数还可以用模长 + 幅角的形式表示。设模长为 r,幅角为 θ,则它的三角表示法为:r(cosθ+isinθ)

  • 复数的加、减:和向量的加减法则是一样的。

  • 复数的乘法:

    • 代数上来说,设 x=a+bi,y=c+di,则有:

      x×y=(a+bi)(c+di)=ac+bci+adi+bdi2=ac+bci+adibd=(acbd)+(bc+ad)i

    • 几何上来说,它是模长相乘、幅角相加。设复数 x=r1(cosθ1+isinθ1),y=r2(cosθ2+isinθ2),则有:

      x×y=r1r2(cosθ1+isinθ1)(cosθ2+isinθ2)=r1r2(cosθ1cosθ2+icosθ1sinθ2+isinθ1cosθ2+i2sinθ1sinθ2)=r1r2[(cosθ1cosθ2sinθ1sinθ2)+i(cosθ1sinθ2+sinθ1cosθ2)]=r1r2[cos(θ1+θ2)+isin(θ1+θ2)]

  • 单位根:定义满足 xn=1xn 次单位根。注意,同次的单位根不止一个。这里姑且将所有 n 次单位根的集合记作 En

  • 本原单位根:定义 xEn,满足 En{y|y=xk,kN}(说人话,就是它的非负整数次幂可以生成所有的别的 n 次单位根),为 n 次本原单位根。记作 ωn

  • 本原单位根的几何意义:在复平面上作一个单位圆。从 x 轴开始,把单位圆 n 等分,圆上分出来的点中幅角最小的那一个就是 ωn。其余的点,则都可以写作 ωnkk0n1 的整数),这一结论由于复数乘法的几何意义是显然的。

image
(引自 @FlashHu 的博客)

  • ωnk 的值:根据复数乘法的几何意义,明显可以得到:

ωnk=cosk2πn+isink2πn

【注意下面两条性质,它们是快速傅里叶变换的关键。】

  • 性质一:ωanak=ωnk

    证明:ωanak=cosak2πan+isinak2πan=ωnk

  • 性质二:ωnk+n2=ωnk

    证明:ωnk+n2=ωnkωnn2=ωnkω21=ωnk


4. 快速傅里叶变换(FFT)

简单概括一下 FFT 的思想:将所有的 ωnkk0n1 的整数)代入多项式,并且利用上面的两条性质,进行分治优化。

【注意:以下推导默认 n 为偶数】

先设当前有一个多项式:

f(x)=a0+a1x+a2x2++an1xn1

对它的下标进行奇偶性分类:

f(x)=(a0+a2x2++an2xn2)+(a1x+a3x3++an1xn1)

给右边的式子提个公因数,将左右化得更为相似:

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

设:

f1(x)=a0+a2x++an2xn21

f2(x)=a1+a3x++an1xn21

则:

f(x)=f1(x2)+xf2(x2)

这样,每次计算 f(x) 时,我们就可以递归分别计算两个项数更少的 f1(x)f2(x),再加起来得到答案了。

看上去还是没用?让我们尝试随便代入一个值 ωnk(k<n2)

f(ωnk)=f1(ωn2k)+ωnkf2(ωn2k)=f1(ωn2k)+ωnkf2(ωn2k)

再代入一个值 ωnk+n2(k<n2)

f(ωnk+n2)=f1(ωn2k+n2)+ωnk+n2f2(ωn2k+n2)=f1(ωn2k)ωnkf2(ωn2k)

可以发现,前一个式子和后一个式子几乎是一样的。因此,当我们用递归计算 f1f2 的方法计算出 f(ωnk) 时,我们完全可以顺便求出 f(ωnk+n2)。如此,计算范围就缩小了一半。

而在递归分别计算 f1f2 的过程中,也同样可以按照上面的方法,缩小一半范围计算。

当然,运用上述方法的必要条件是项数为偶数。为了保证每一层递归时项数都是偶数,我们要将项数设置为一个大于等于原项数的 2 正整数次幂,并补上一些系数为 0 的项。

一直递归,层数最多为 logn。总复杂度 O(nlogn)


5. 快速傅里叶逆变换(IFFT)

快速傅里叶变换让我们成功地将系数表示法转化为了点值表示法。但是在我们运用点值表示法快速地得到了新多项式时,还需要把点值表示法转化回系数表示法。这时就需要用到快速傅里叶逆变换。

快速傅里叶逆变换的思路非常奇怪。它的思路是:将当前得到的点值表示,当作一个多项式的系数,再对这个新多项式做一遍快速傅里叶变换求点值表示。可以证明,这个二次快速傅里叶变换所得到的的结果和原本的系数表示法之间存在某种关系。

这里为了帮助我自己理解,手推(抄)了一遍 dalao@自为风月马前卒 给出的快速傅里叶逆变换的证明。

(y0,y1,,yn1) 为多项式 (a0,a1,,an1)x(ωn0,ωn1,,ωnn1) 时的点值表示(亦称傅里叶变换)。形式化地,它满足:

yi=j=0n1aj(ωni)j

设有一向量 (c0,c1,,cn1)(y0,y1,,yn1)x(ωn0,ωn1,,ωn(n1)) 时的点值表示。形式化地,它满足:

ck=i=0n1yi(ωnk)i

然后开始推导。

ck=i=0n1yi(ωnk)i=i=0n1(j=0n1aj(ωni)j)(ωnk)i=i=0n1j=0n1aj(ωni)j(ωnk)i=i=0n1j=0n1aj(ωnjk)i=j=0n1aj(i=0n1(ωnjk)i)

观察上式中第二个 的内容,它是一个关于 ωnjk 的等比数列。当 jk0 时,根据等比数列求和公式,有:

i=0n1(ωnjk)i=(ωnjk)n1ωnjk1=(ωnn)jk1ωnjk1=11ωnjk1=0

而当 jk=0 时,有 ωnjk=1,那么求和的结果即为 n

因此有:

ck=akn

即:

ak=ckn

在学了 skc 的网课后,发现这还有另外一种从矩阵角度的理解方法:

image

image



三、实现

1. 递归版

我们来浅浅总结一下整体的流程:

  • 先求出一个 N,满足为第一个大于 n+m2 的正整数次幂,令它为新项数。并为两个多项式补上一些系数为 0 的项。

  • 对多项式 f(x),g(x) 分别跑一次 FFT,得到二者的点值表示法。

  • 将二者的点值表示法逐个相乘,得到二者卷积的点值表示法。

  • 对卷积的点值表示法跑一遍 IFFT,得到它的系数表示法。

FFT 的流程:

  • 当前递归到的是一个项数为 n 的多项式。

  • 将该多项式的系数按照奇偶性拆为两个多项式,分别递归计算值。

  • 通过计算到的两个多项式的答案,合并得到当前的答案。

IFFT 的流程:

  • 将原本的单位根 ωn 改为 ωn1,对点值表示法计算二次 FFT。一般直接使用性质 ωnk=ωnkωnn=ωnnk 来计算 ωnk

  • 将二次 FFT 后得到的值全都除以 n,即得到系数表示法。

直接使用递归法由于 FFT 的常数过大,在洛谷上无法通过。


2. 递推版

回忆一下上文的递归式:

f(ωnk)=f1(ωn2k)+ωnkf2(ωn2k)

f(ωnk+n2)=f1(ωn2k)ωnkf2(ωn2k)

如果按照递归写法,它需要开很多数组 f,f1,f2,,而接下来介绍的递推法能够在只开一个数组的情况下解决问题。

观察下图:

image

可以发现两条巧妙的性质:

  1. 对于当前规模为 n 的递归层,f[k]f[k+n2] 两个位置在下一层递归中恰好对应 f1[k]f2[k]

  2. 最后结束时的序列为原序列的二进制翻转。

性质 1. 使得我们每一次从下往上递推时只需要调用下面的 f[k],f[k+n2] 即可计算出 f[k],f[k+n2]。这样就只需要开一个数组了。

性质 2. 使得我们可以轻松地得到递推的起始状态。


3. c++ STL complex 类

实现的时候我们可以选择自己封装一个复数类,也可以选择使用 c++ STL 自带的 complex 类。

  1. 定义

基本格式为 complex<T> x;T 为一种浮点数类型。

初始化变量的值有很多种方式。下面列举了几种:

complex<double> a(3, 4);
complex<double> b = 3.0 + 4i;
complex<double> c = {3, 4};
complex<double> d(3);//只初始化实部
  1. 实部和虚部

使用 real(x), imag(x) 可以分别取出 x 的实部和虚部,但无法赋值。

使用 x.real(), x.imag() 也可以分别取出 x 的实部和虚部。并且,可以通过 x.real(a), x.imag(b) 的方式分别将 x 的实部和虚部赋值为 ab

  1. 运算

complex 类内置了复数加减乘除,以及与实数的加减乘除运算。我们只需要用运算符号正常运算即可。

  1. 输出

complex 类内置了输入输出流。直接写 cout<<x<<endl;,会输出形如 (real,imag) 的结果。


4. code

点击查看代码
#include<bits/stdc++.h>
#define cp complex<double>
using namespace std;

const int MAXN = (1<<21)+5;
const double Pi = acos(-1);//为确保精度,需要写成这样。 
int n, m, N = 1, rev[MAXN];
cp f[MAXN], g[MAXN], h[MAXN];

inline void Prework(){//预处理二进制翻转。 
    while(N <= n+m)   N <<= 1;
    for(int i = log2(N)-1, t = 0; i >= 0; i--){
        int x = t;
        for(int j = 0; j <= x; j++) rev[++t] = (1<<i)|rev[j];
    }
    return;
}

inline void DFT(cp a[], int v){//v 表示当前是 w_n^k 还是 w_n^{-k} 
    for(int i = 0; i < N; i++)   if(rev[i] < i) swap(a[rev[i]], a[i]);
    for(int i = 2; i <= N; i <<= 1)//枚举当前层中,每一组的大小 i 
        for(int k = 0; k < i/2; k++){//枚举组内编号 k
			//从逻辑上应该先枚举 j 再枚举 k,但是我为了只计算一次不同的 w_n^k,就调换了一下顺序。 
            cp w(cos(2*Pi/i*k), sin(2*Pi/i*k*v));
            for(int j = 0; j < N; j += i){//枚举每个组的开头下标 j 
                cp x = a[j+k], y = a[i/2+j+k];
                a[j+k] = x+w*y;
                a[i/2+j+k] = x-w*y;
            }
        }
    return;
}

inline void IDFT(cp a[]){
    DFT(a, -1);
    for(int i = 0; i < N; i++)  a[i] /= N;
    return;
}

int main(){
    scanf("%d%d", &n, &m);
    for(int i = 0; i <= n; i++){
        int x; scanf("%d", &x);
        f[i].real(x);
    }
    for(int i = 0; i <= m; i++){
        int x; scanf("%d", &x);
        g[i].real(x);
    }
    Prework();
    DFT(f, 1), DFT(g, 1);
    for(int i = 0; i < N; i++)  h[i] = f[i]*g[i];
    IDFT(h);
    for(int i = 0; i <= n+m; i++)   printf("%d ", int(round(real(h[i]))));

    return 0;
}


四、NTT

NTT 和 FTT 不同的地方在于,它被用于解决模意义下的多项式乘法。

其实本质上的思路和 FFT 只能说是一模一样。只不过 NTT 中的“单位根”,是模意义下的单位根而已。

如何求出模意义下的单位根呢?首先需要看一下 原根与阶。设在该模数 p 下有原根 g,可以证明,任意满足 xgt(modp)x,一定满足 tord(x)=ord(g)。故存在一个单位根 ωngord(g)n=gφ(p)n(当然,前提条件一定是 n|φ(p))。

NTT 的题目一般用 p=998244353 做模数,因为它是一个质数,所以有 φ(p)=p1,且满足 p1=223×7×17,所以在 n223 的情况下都存在 n 次单位根。而这个模数的最小原根为 3,可以直接用。

点击查看代码
#include<bits/stdc++.h>
#define ll long long
using namespace std;

const int MAXN = (1<<21)+5;
const ll Mod = 998244353, Rt = 3;
int n, m, N = 1, rev[MAXN];
ll f[MAXN], g[MAXN], h[MAXN];

inline ll Quick_Power(ll x, ll p){
    if(!p)  return 1;
    ll tmp = Quick_Power(x, p>>1);
    if(p&1) return tmp*tmp%Mod*x%Mod;
    else    return tmp*tmp%Mod;
}

inline ll Inv(ll x){
    return Quick_Power(x, Mod-2);
}

inline void Prework(){
    while(N <= n+m)   N <<= 1;
    for(int i = log2(N)-1, t = 0; i >= 0; i--){
        int x = t;
        for(int j = 0; j <= x; j++) rev[++t] = (1<<i)|rev[j];
    }
    return;
}

inline void DFT(ll a[], ll v){
    for(int i = 0; i < N; i++)   if(rev[i] < i) swap(a[rev[i]], a[i]);
    for(int i = 2; i <= N; i <<= 1)
        for(int k = 0; k < i/2; k++){
            ll w = Quick_Power(Rt, (Mod-1)/i*(i+k*v));
            for(int j = 0; j < N; j += i){
                ll x = a[j+k], y = a[i/2+j+k];
                a[j+k] = (x+w*y)%Mod;
                a[i/2+j+k] = (x-w*y%Mod+Mod)%Mod;
            }
        }
    return;
}

inline void IDFT(ll a[]){
    DFT(a, -1);
    ll inv = Inv(N);
    for(int i = 0; i < N; i++)  (a[i] *= inv) %= Mod;
    return;
}

int main(){
    scanf("%d%d", &n, &m);
    for(int i = 0; i <= n; i++) scanf("%lld", f+i);
    for(int i = 0; i <= m; i++) scanf("%lld", g+i);
    Prework();
    DFT(f, 1), DFT(g, 1);
    for(int i = 0; i < N; i++)  h[i] = f[i]*g[i]%Mod;
    IDFT(h);
    for(int i = 0; i <= n+m; i++)   printf("%lld ", h[i]);

    return 0;
}


五、应用

【FBI Warning:以下的应用全都在模 xn 意义下进行,也就是说我们只考虑多项式的 0xn 项。】

1. 多项式求逆

P4238 【模板】多项式乘法逆

参照 @Great_Influence 的题解,写得超级清楚。

【关于他的推导过程,这里留一点备注:为什么平方以后模数就可以由 xn2 变为 xn?因为 BB 的前 n21 项为 0,故 (BB)2 的最高非 0 次项 xn。】

这个方法简言之,就是构造了一个 n2n 的递推式。通过主定理,可以计算得总复杂度为 O(nlogn)(尽管单次多项式乘法也为 O(nlogn))。

简记一下结论:

B(x)2B(x)A(x)B(x)2(modxn)

关于实现,需要注意的是:这种结果对于 xn 取模的多项式乘法,两个因式在相乘前必须对 xn 取模,且也必须完整地按照普通 NTT 的过程算出 2n 个项后再对 xn 取模。

点击查看代码
#include<bits/stdc++.h>
#define ll long long
using namespace std;

const int MAXN = (1<<19)+5;
const ll Mod = 998244353, Rt = 3;
int n, Rev[MAXN];
ll W, w[MAXN];

inline ll Quick_Power(ll x, int p){
	if(!p)	return 1;
	ll tmp = Quick_Power(x, p>>1);
	if(p&1)	return tmp*tmp%Mod*x%Mod;
	else	return tmp*tmp%Mod;
}

inline ll Inv(ll x){
	return Quick_Power(x, Mod-2);
}

struct Polynomial{
	int len;
	ll a[MAXN];
	
	inline void Input(){
		for(int i = 0; i < n; i++)	scanf("%lld", a+i);
		len = 1;
		while(len < n)	len <<= 1;
		return;
	}
	
	inline void Output(){
		for(int i = 0; i < n; i++)	printf("%lld ", a[i]);
		return;
	}
	
	inline void Module(int N){
		for(int i = N; i < len; i++)	a[i] = 0;
		len = N;
		return;
	}
	
	inline void DFT(int N){
		w[0] = 1;
		for(int i = 1; i < N; i++)	w[i] = w[i-1]*W%Mod;
		for(int i = 0; i < N; i++)	if(Rev[i] > i) swap(a[i], a[Rev[i]]);
		for(int i = 2; i <= N; i <<= 1)
			for(int j = 0; j < N; j += i)
				for(int k = 0; k < i/2; k++){
					ll x = a[j+k], y = a[i/2+j+k];
					a[j+k] = (x+y*w[N/i*k]%Mod)%Mod;
					a[i/2+j+k] = (x-y*w[N/i*k]%Mod+Mod)%Mod;
				}
		return;
	}
	
	inline void IDFT(int N){
		W = Inv(W);
		DFT(N);
		ll inv = Inv(N);
		for(int i = 0; i < N; i++)	a[i] = a[i]*inv%Mod;
		return;
	}
	
	Polynomial operator * (const int &tmp)const{
		Polynomial ans; ans.len = len;
		for(int i = 0; i < len; i++)	ans.a[i] = a[i]*tmp%Mod;
		return ans;
	}
	
	Polynomial operator - (const Polynomial &tmp)const{
		Polynomial ans; ans.len = len;
		for(int i = 0; i < len; i++)	ans.a[i] = (a[i]-tmp.a[i]+Mod)%Mod;
		return ans;
	}
} f;

inline void Prework(int N){
    W = Quick_Power(Rt, (Mod-1)/N);
	for(int i = N>>1, j = 0; i; i >>= 1){
		int t = j;
		for(int k = 0; k <= t; k++)	Rev[++j] = Rev[k]+i;
	}
	return;
}

Polynomial operator * (const Polynomial &x, const Polynomial &y){
	int N = y.len<<1;//乘法必须算到 N*2 项 
	Prework(N);
	Polynomial X = x, Y = y, ans;
	X.Module(N>>1), Y.Module(N>>1);//对多项式项数取模 
	X.DFT(N), Y.DFT(N);
	for(int i = 0; i < N; i++)	ans.a[i] = X.a[i]*Y.a[i]%Mod;
	ans.IDFT(N);
	ans.Module(N>>1);
	return ans;
}

inline Polynomial Inv_P(Polynomial &A){
	Polynomial B = (Polynomial){1, {Inv(A.a[0])}};
	for(int N = 2; N < n*2; N <<= 1){//N 为当前取模的长度 
		B.len = N;
		B = B*2-A*B*B;
	}
	return B;
}

int main(){
	scanf("%d", &n);
	f.Input();
	Polynomial ans = Inv_P(f);
	ans.Output();
	
	return 0;
}

2. 多项式 ln

P4725 【模板】多项式对数函数(多项式 ln)

B(x)lnA(x)(modxn) 求导得:

B(x)A(x)A(x)(modxn)

先求逆再乘法即可。


3. 牛顿法

这个的前置知识:微积分学习笔记

其实前面多项式求逆的方法本质运用的是一个方法——“牛顿法”。具体可以参考 @Miskcoo 的博客

在这里就简述一下结论。

在已知 G(x) 的情况下,牛顿法被用于求出下式的 F(x)

G(F(x))0(modxn)

F0(x) 满足:

G(F0(x))0(modxn2)

则通过将 G(F(x))F0(x) 处做泰勒展开,有:

F(x)F0(x)G(F0(x))G(F0(x))(modxn)

注意这里 F0(x) 在计算前一定要保证 xn2 以上的项一定为 0。(问我为什么?只能说从最终表达式看来是这样的,但我也不道啊。。。)

以多项式求逆举例。定义 G(B(x)) 满足:

G(B(x))1B(x)A(x)

则有:

B(x)B0(x)1B0(x)A(x)1B0(x)22B0(x)A(x)B0(x)2

这里的多项式 A(x) 可以当作一个“常数项”,因为我们把多项式本身当作自变量了。


4. 多项式 exp

P4726 【模板】多项式指数函数(多项式 exp)

运用牛顿法推导一下就可以得到:

B(x)B0(x)(1lnB0(x)+A(x))(modxn)

posted @   David_Mercury  阅读(165)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· C#/.NET/.NET Core优秀项目和框架2025年2月简报
· Manus爆火,是硬核还是营销?
· 一文读懂知识蒸馏
· 终于写完轮子一部分:tcp代理 了,记录一下
点击右上角即可分享
微信分享提示