FFT学习
代码见github。
P开头的题目来自luogu.com.cn
Introduction
FFT用于解决离散卷积问题。比如说已知
{f0,f1,⋯,fn−1}{g0,g1,⋯,gn−1}
求
{h0,h1,⋯,hn−1}
满足
hi=j=0∑ifj⋅gi−j
即
h=f∗g
Analysis
插值定理
已知n个点(x0,y0),(x1,y1),⋯,(xn−1,yn−1),其中xi=xj(i=j),xi,yi∈C,则可以唯一确定通过这n个点的n−1次多项式
pn−1(x)=a0+a1x+a2x2+⋯+an−1xn−1
证明
设多项式形式如上,则
⎩⎪⎪⎪⎪⎪⎨⎪⎪⎪⎪⎪⎧y0y1yn−1=a0+a1x0+⋯+an−1x0n−1=a0+a1x1+⋯+an−1x1n−1⋮=a0+a1xn−1+⋯+an−1xn−1n−1
写成矩阵的形式:
⎝⎜⎜⎜⎜⎛y0y1⋮yn−1⎠⎟⎟⎟⎟⎞=⎝⎜⎜⎜⎜⎛11⋮1x0x1⋮xn−1⋯⋯⋱⋯x0n−1x1n−1⋮xn−1n−1⎠⎟⎟⎟⎟⎞⎝⎜⎜⎜⎜⎛a0a1⋮an−1⎠⎟⎟⎟⎟⎞
中间大矩阵是一个vandermonde矩阵,行列式为∏i>j(xi−xj)=0,所以是可逆的。
y已知,求a,用a=X−1y即可。
问题转换
不妨把f,g数列都写成多项式的形式,即:
f(x)=f0+f1x+⋯+fn−1xn−1g(x)=g0+g1x+⋯+gn−1xn−1
则f×g可以算出来:
h(x)=f(x)×g(x)=i=0∑2n−2xij∑fjgi−j
得到的h(x)是一个2n−2次多项式,且h(x)的x0,x1,⋯,xn−1的系数就是h数列。
假设我们选定x0,x1,⋯,x2n−2,用这2n−1个点作为横坐标对f和g两个函数进行插值,由于f和g是已知的,所以可以计算f(x0),f(x1),⋯,f(x2n−2)和g(x0),g(x1),⋯,g(x2n−2)
同时有
h(x)=f(x)g(x)
所以
h(x0)h(x1)⋮h(x2n−2)=f(x0)g(x0)=f(x1)g(x1)=f(x2n−2)g(x2n−2)
所以我们得到了h函数的2n−1个插值,于是唯一确定了h(x)这个函数。得到该函数后取前n个系数就可以得到h数列。
于是我们期望的流程是:
- 对f和g函数插值;
- 计算出h在各个插值点的值;
- 反解出h的系数。
为了以后说明的方便,我们假设n=2m。
Interpolate
接下来研究怎么对f和g函数插值。只研究f函数,因为g完全相同。
为了方便,我们现在假设只需要对f进行n个点的插值(本身需要2n−1个点,但是我们也可以直接把n←2n,然后假设f是一个2n次多项式,进行2n个点的插值,效果相同)。
计算f(x0)是O(n)的,所以获得所有插值就O(n2)了!
怎么解决这个问题呢?我们需要取一些特殊的插值点,以便重复利用插值点的数值。
如果有两个插值点x0和x1满足x0=−x1,则我们如果对f的系数进行奇偶分类(我们假设n=2m):
fe(x)=f0+f2x+f4x2+⋯+fn−2xn/2−1fo(x)=f1+f3x+f5x2+⋯+fn−1xn/2−1
那么有
f(x0)=fe(x02)+x0fo(x02)f(x1)=fe(x12)+x1fo(x12)
由于x02=x12,可以化为
f(x0)=fe(x02)+x0fo(x02)f(x1)=fe(x02)−x0fo(x02)
也就是说,我们只需要计算fe和fo两个长度为n/2的函数在x02处的插值信息,我们就可以得到f在x0和x1处的插值信息。
但是,如果插值点都取实数的话,到第二层,所有x≥0,也就不存在相反数了(不能递归)。
解决这个问题的办法就是采用复数单位根来作为插值点!
对于长度为n的函数f,我们把复平面上的单位圆分成n份来取插值点。具体而言:
xk=en2jπk=cos(n2πk)+jsin(n2πk)
其中j=−1。
为了方便,我们可以记ω=en2jπ,则
xk=ωk
那么就有性质:
xk=−xk+2n,k<2n
这样就可以两两凑成一对,问题转化为了两个2n的子问题,并且由于xk2刚好可以覆盖所有的enjπk,所以这个问题可以一直递归下去,边界就是返回当前的系数。
复杂度:
T(n)=2T(n/2)+O(n)⟹T(n)=O(nlogn)
Calculate h
第二步:计算出h函数在插值点的值,直接乘就好了,O(n)。接下来研究第三步:反解出h的系数。
我们不妨先回顾一下第一步解决了什么问题:
给定n−1次多项式f,求出f在n次单位根上的值。
形式化的说:已知f0,f1,⋯,fn−1,计算y0,y1,⋯,yn−1:
⎝⎜⎜⎜⎜⎛y0y1⋮yn−1⎠⎟⎟⎟⎟⎞=⎝⎜⎜⎜⎜⎜⎛11⋮1x0x1⋮xn−1⋯⋯⋱⋮x0n−1x1n−1⋮xn−1n−1⎠⎟⎟⎟⎟⎟⎞⎝⎜⎜⎜⎜⎛f0f1⋮fn−1⎠⎟⎟⎟⎟⎞
其中xk=ωk,ω=e2jπ/n。
为了方便,我们依然认为h函数的长度只有n(如果不够,用之前相同的方法,后面补0,把f,g,h都补到相同的n=2m)。
那么当前的问题是
⎝⎜⎜⎜⎜⎜⎛y0(h)y1(h)⋮yn−1(h)⎠⎟⎟⎟⎟⎟⎞=⎝⎜⎜⎜⎜⎜⎛11⋮1x0x1⋮xn−1⋯⋯⋱⋮x0n−1x1n−1⋮xn−1n−1⎠⎟⎟⎟⎟⎟⎞⎝⎜⎜⎜⎜⎛h0h1⋮hn−1⎠⎟⎟⎟⎟⎞
已知y(h),求h。
我们知道中间那个矩阵是可逆的,所以我们现在尝试求他的逆(普通方法是O(n3)的,我们需要更快的办法)。
令中间的矩阵为X,则
Xij=(ωi)j=ωij
则可以看出X是一个对称矩阵。计算XX∗T:
XXij∗T=k=0∑n−1ωikω−kj=1−ωi−j1−ω(i−j)n={0ni=ji=j
所以说$$\bm X^{-1} = \frac{1}{n} \bm X^{T} = \frac{1}{n}\bm X^{}$$
那么问题就转化为了
h=X∗y(h)
这个问题和第一阶段的问题非常相似,只不过第一阶段用的ω换成了ω−1。
所以我们再做一次第一阶段的问题,就可以在O(nlogn)时间内完成反解!
NTT
复数的计算依赖浮点数,容易丢失精度。
于是可以使用数论变换代替(寻找一个恰当的模数MOD,某个整数ω在模MOD运算下可以形成一个2m的环)。
实现技巧
- 复数运算加速:使用手写的
complex类,可以快1.5x~2x(见P3803_fast.cpp);
- 使用倍增替代递归:朴素的实现(不改变数据分布)不会带来好处,只会让cache全部未命中(stride太大)。见
P3803_loop.cpp);更优化的实现,将初始数据排布变为左边都是偶数,右边都是奇数,连续访问内存。见P3803_loop_v2.cpp)。
- 减少运算冗余:每次插值计算只需要算一半,另一半用缓存好的取相反数;
- 使用NTT解决精度问题。没有使用NTT很难过
P4721,见P4721_naiveFFT.cpp。优化过的见P4721_NTT.cpp。可以通过python3 P4721_judger.py检验正确性。
练习题
编译环境
g++14, macOS
参考vscode tasks.json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| { "version": "2.0.0", "tasks": [ { "type": "shell", "label": "C++ build & run", "command": "/opt/homebrew/bin/g++-14", "args": [ "-g", "${file}", "-o", "${fileDirname}/build/${fileBasenameNoExtension}", "&&", "${fileDirname}/build/${fileBasenameNoExtension}" ], "options": { "cwd": "${fileDirname}" }, "problemMatcher": [ "$gcc" ], "group": "build", "detail": "compiler: /opt/homebrew/bin/g++-14" } ] }
|