转载自:http://www.gatevin.moe/acm/fft%E7%AE%97%E6%B3%95%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0/
写在前面
关于学习FFT算法的资料个人最推荐的还是算法导论上的第30章(第三版), 多项式与快速傅里叶变换, 基础知识都讲得很全面
这篇文章即是博主在阅读FFT算法的相关学习资料之后的一些总结, 作为日后复习查阅的材料来源, 同时也可以作为读者学习FFT算法的一份资料
FFT算法基本概念
FFT(FastFourierTransformation)
即快速傅里叶变换, 是离散傅里叶变换的加速算法, 可以在
O(nlogn) 的时间里完成
DFT , 利用相似性也可以在同样复杂度的时间里完成逆
DFT
DFT(DiscreteFourierTransform)
即离散傅里叶变换, 这里主要就是多项式的系数向量转换成点值表示的过程
在ACM-ICPC竞赛中, FFT算法常被用来为多项式乘法加速, 即在 O(nlogn)
复杂度内完成多项式乘法, 当然实际应用不仅仅限于这些, 时常出现需要构造多项式相乘来进行计数的问题, 也需要用FFT算法来解决, 相关的几个问题在本文中也会提及
FFT算法需要的基础数学知识
多项式相关:
多项式相关的定义:一个以 x
为变量的多项式定义在代数域F上, 将函数 A(x)
表示为形式和
Σj=0n−1ajxj
, 称
aj 为系数, 所有系数属于代数域
F , 如果一个多项式的最高非零系数是
ak , 那么成这个多项式的
次数是
k , 记做
degree(A)=k , 任何一个严格大于一个多项式次数的整数都是该多项式的
次数界
关于多项式的加法和乘法相信看到这篇博客的读者都会最基本的中学的算法, 在计算的时候, 如果采用传统的中学的计算方法, 多项式加法的时间复杂度是 O(n)
, 乘法的时间复杂度是 O(n2)
(n是两个多项式A和B的次数).
多项式的表示
在平常的学习中, 最常见的是多项式的系数表达方式,
次数界为n的多项式 A(x)=a0+a1x+a2x2+...+an−1xn−1
的系数表达为一个由系数组成的向量 a=(a1,a2,...an−1)
但是多项式还有一个比较常用的表示方法, 即多项式的点值表达方式
一个次数界为n的多项式 A(x)=a0+a1x+a2x2+...+an−1xn−1
的点值表达就是一个由n个点对组成的集合
{(x0,y0),(x1,y1),...,(xn−1,yn−1)}
使得对任意的整数
k=0,1,..n−1 ,
xk 各不相同, 且
yk=A(xk)
关于点值表达的正确性证明:
对于任意n个点值对组成的集合 {(x0,y0),(x1,y1),...,(xn−1,yn−1)}
, 如果存在一个次数界为n的多项式 A(x)
过这n个点, 那么
⎡⎣⎢⎢⎢⎢⎢11⋮1x0x1⋮xn−1x20x21⋮x2n−1⋯⋯⋱⋯xn−10xn−11⋮xn−1n−1⎤⎦⎥⎥⎥⎥⎥⎡⎣⎢⎢⎢⎢a0a1⋮an−1⎤⎦⎥⎥⎥⎥=⎡⎣⎢⎢⎢⎢y0y1⋮yn−1⎤⎦⎥⎥⎥⎥
最左边这个n*n的矩阵称为范德蒙德矩阵, 可以用数学归纳法证明它的行列式值为
Π0≤j<k≤n−1(xk−xj)
当
xk 两两不相同时明显这个行列式的值不为0, 该矩阵可逆, 于是存在唯一解, 所以多项式的点值表达是合理的
相应的通过n个点的坐标直接确定多项式各个系数的值的方法是存在的, 感兴趣的读者可以查询拉格朗日公式的相关资料, 利用拉格朗日插值公式可以在 O(n2)
的时间复杂度内得到多项式的系数表达, 这个也是算法导论中的一个习题
点值表达方式下多项式的乘法, 不难发现, 如果多项式 A(x),B(x)
的点值表示分别是
{(x0,ya0),(x1,ya1),...,(xn−1,yan−1)}
和
{(x0,yb0),(x1,yb1),...,(xn−1,ybn−1)}
那么如果多项式
C(x)=A(x)B(x) , 那么
C(x) 的点值表达是
{(x0,ya0yb0),(x1,ya1yb1),...,(xn−1,yan−1ybn−1)}
卷积
对于两个多项式的系数向量 a=(a0,a1,..,an−1)
和
b=(b0,b1,..,bn−1) , 两个多项式相乘得到的多项式的系数向量
c=(c0,c1,..,c2n−2) 满足
cj=Σjk=0
akbj−k , 称系数向量c是输入向量a和b的卷积, 记作
c=a⊗b
在简单的多项式乘法的计算方法中, 每一个多项式的系数都通过系数表示方式下卷积的方式来进行计算, 时间复杂度是 O(n2)
, 但是FFT是先将多项式的从系数表示法转换成点值表示法(可以在
O(nlogn) 的时间复杂度下完成, 也就是加速的DFT变换, 然后在点值表示法下进行乘积计算, 在
O(n) 的时间复杂度内得到结果的点值表示法, 然后进行逆DFT变换, 在
O(nlogn)
的时间复杂度下完成逆DFT变换得到系数表示法
而要理解DFT, 则需要一定复数上的数学知识:
复数相关的基础知识:
单位复数根:n次单位复数根指的是满足 ωn=1
的所有复数
ω , n次单位复数根刚好有n个, 他们是
e2kπni , 其中i是复数单位,
k=0,1,2...n−1
, 在复平面上这n个根均匀的分布在半径为1的圆上, 关于复数指数的定义如下:
eui=cos(u)+sin(u)i
其中
ωn=e2iπn 倍称为主n次单位根(这个定义好像接下来没用到= =)
关于复数根的几个定理和引理:
消去引理: 对任何整数 n≥0,k≥0,d≥0
有
ωdkdn=ωkn
证明: ωdkdn=(e2iπdn)dk=(e2iπn)k=ωkn
一个推论: 对任意偶数 n > 0 有 ωn2n=ω2=−1
证明:设n = 2*k那么 ωn2n=ωk2k=ω2=eπ=cos(π)+sin(π)i=−1
折半引理:如果n > 0是偶数, 那么n个n次单位复数根的平方的集合就是n/2个n/2次单位复数根的集合
证明:实际上这个引理就是证明了 (ωk+n2n)2=ω2k+nn=ω2knωnn=(ωkn)2
折半引理对于采用分治对多项式系数向点值表达的转换有很大作用, 保证了递归的子问题是原问题规模的一半
求和引理:对任意整数 n≥1
和不能被n整除的非负整数k, 有
Σj=0n−1(ωkn)j=0
这个问题通过等比数列求和公式就可以得到: Σn−1j=0(ωkn)j=(ωkn)k−1ωkn−1=1k−1ωkn−1=0
DFT与FFT, 以及逆DFT
DFT
在DFT变换中, 希望计算多项式A(x)在复数根 ω0n,ω1n,...,ωn−1n
处的值, 也就是求
yk=A(ωkn)=Σj=0n−1ajωkjn
称向量
y=(y0,y1,...,yn−1) 是系数向量
a=(a0,a1,...,an−1) 的离散傅里叶变换, 记为
y=DFTn(a)
FFT
直接计算DFT的复杂度是 O(n2)
, 而利用复数根的特殊性质的话, 可以在
O(nlogn)
的时间内完成, 这个方法就是FFT方法, 在FFT方法中采用分治策略来进行操作, 主要利用了消去引理之后的那个推论
在FFT的策略中, 多项式的次数是2的整数次幂, 不足的话再前面补0, 每一步将当前的多项式A(x), 次数是2的倍数, 分成两个部分:
A[0](x)=a0+a2x+a4x2+...+an−2xn2−1
A[1](x)=a1+a3x1+a5x2+...+an−1xn2−1
于是就有了
A(x)=A[0](x2)+xA[1](x2)
那么我们如果能求出次数界是
n2 的多项式
A[0](x) 和
A[1](x) 在n个n次单位复数根的平方处的取值就可以了, 即在
(ω0n)2,(ω1n)2,(ω2n)2,...,(ωn−1n)2
处的值, 那么根据折半引理, 这n个数其实只有
n2 个不同的值, 也就是说, 对于每次分出的两个次数界
n2 的多项式, 只需要求出其
n2 个不同的值即可, 那么问题就递归到了原来规模的一半, 也就是说如果知道了两个子问题的结果, 当前问题可以在两个子问题次数之和的复杂度内解决, 那么这样递归问题的复杂度将会是O(nlogn)的, 用
a=(a0,a1,...,an−1) 表示系数向量,
y=(y0,y1,...,yn−1) 表示离散变换之后的向量,
这里给出将算导上的代码翻译出来的C++代码实现(以解决算法第三版大论第三十章的一个习题, 求(0, 1, 2, 3)的DFT为例):
/* * Author: Gatevin * Created Time: 2015/7/12 20:08:47 * File Name: FFT.cpp */ #include
#include #include #include #include #include #include #include #include
可以得到系数向量(0, 1, 2, 3)经过DFT之后是(6, -2-2i, -2, -2+2i)
在上面这个视线中需要注意的就是RecursiveFFT中的y[k] = y0[k] + w*y1[k];和y[k + (n >> 1)] = y0[k] - w*y1[k];的变化, 正是利用了对于偶数的n, 有 ωn2n=−1
成立, 得到数组y的所有值
另外有一个概念性的东西: yk=y[0]k+ωkny[1]k
和
yk+n2=y[0]k−ωkny[1]k 都成立, 在这其中将
ωkn
称为旋转因子
在进行了DFT操作之后, 将多项式的系数表达成功转为了点值表达 接下来是如何在 O(nlogn)
的时间内完成逆DFT的问题, 通过逆DFT将点值表达还原为系数表达
逆DFT
根据DFT得到的向量y和系数向量a之间的关系, 可以用矩阵乘积的形式来表达他们之间的关系, 即 y=Vna
, 也就是
⎡⎣⎢⎢⎢⎢⎢⎢⎢⎢⎢y0y1y2y3⋮yn−1⎤⎦⎥⎥⎥⎥⎥⎥⎥⎥⎥=⎡⎣⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢1111⋮11ωnω2nω3n⋮ωn−1n1ω2nω4nω6n⋮ω2(n−1)n1ω3nω6nω9n⋮ω3(n−1)n⋯⋯⋯⋯⋱⋯1ωn−1nω2(n−1)nω3(n−1)n⋮ω(n−1)(n−1)n⎤⎦⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎡⎣⎢⎢⎢⎢⎢⎢⎢⎢⎢a0a1a2a3⋮an−1⎤⎦⎥⎥⎥⎥⎥⎥⎥⎥⎥
那么要将DFT变化得到的向量y还原成向量a的话, 只需要用 Vn
的逆矩阵
V−1n
乘上向量y即可, 这里需要用到一个定理
在矩阵 Vn
中, 不难发现对于任意的
j,k=0,1,...,n−1,Vn(k,j)=ωkjn , 那么可以找到这样一个矩阵
V−1n , 对于任意的
j,k=0,1,...,n−1 ,
V−1n 的
(j,k) 出的元素为
ω−kjnn ,
V−1n 是矩阵
Vn
的逆矩阵
证明如下:要证明这两个矩阵互逆, 证明其积为单位矩阵即可, 考虑两个矩阵的乘积在 (j,j′)
出的元素, 可以发现这个元素是
Σn−1k=0(ω−kjnn)(ωkj′n)=
Σn−1k=0ωk(j′−j)nn
当 j′=j
时, 这个和是1, 否则根据求和引理, 这个和是0, 故这两个矩阵互逆
那么根据这个逆矩阵可以发现要计算 DFT−1n(y)
的话, 有关系式
aj=1nΣn−1k=0ykω−kjn 比较这个式子和之前DFT里面y和a的关系式子, 可以发现只需要用
ω−1n 替换掉
ωn 即可, 最后结果需要除以n, 所以计算逆DFT的方法和计算DFT和相似, 都可以在
O(nlogn)
的时间复杂度内解决
卷积定理
对任意两个长度为n的向量a和b, 其中n是2的幂, 有
a⊗b=DFT−12n(DFT2n(a)⋅DFT2n(b))
其中向量a和b用0填充, 使其长度达到2n, 并用
⋅ 表示两个2n个元素组成向量的点乘(也就是每一维上的数相乘)
这个式子实际上就是多项式的系数表达在乘法时进行的卷积运算得到的结果, 等同于通过将其系数进行DFT变换变成点值表达之后相乘再换回来的过程
关于FFT算法的迭代实现
在递归实现DFT过程的FFT算法中, 我们每次将系数向量a分成两个部分利用折半引理来降低计算的规模, 可以发现在每次分组当中他们满足这样一个完全二叉树的分组(n是2的幂):
通过上图的流程可以看出, 最后一层的子节点下标的顺序实际上就是其下标转换成二进制串的倒序的字符串按照字典序排列的顺序
比如a0~a7得到的序列a0, a4, a2, a6, a1, a5, a3, a7下标的二进制是000, 100, 010, 110, 001, 101, 011, 111对应的串的倒序是000, 001, 010, 011, 100, 101, 110, 111这个倒序的二进制刚好是0, 1, 2, 3, 4, 5, 6, 7的二进制表示, 于是我们可以在 O(nlogn)
的复杂度内得到做下面一层的下标顺序, 然后可以根据子节点的结果向上迭代得到父亲结点的值, 这样计算的话直接避免了递归, 如果直接递归的话在一些OJ上可能会造成爆栈的错误, 所以还是采用迭代的方式进行比较好.
关于迭代形式的FFT算法, C++代码实现如下(同样以求(0, 1, 2, 3)的DFT变换为例):
这段代码的话同时也进行了逆DFT, DFT和逆DFT的过程相似, 加上一个标记判断当前执行的是哪一种就行了
通过上面这个代码的示例, FFT算法的实现基本没有什么问题了, 另外算法导论中的习题有一些很不错, 便于熟悉这一算法的很多细节, 这里就不一一提及了
经过这些学习之后, 进行一些实战演练是很有必要的, 接下来是相关习题的练习部分
相关练习:
HDU 1402 A*B Problem Plus 大整数乘法, 我的解答:戳我
HDU 4609 3-idiots FFT计数, 解答:戳我
UVALive 4671 K-neighbor Substrings FFT算字符串Hamming距离 解答:戳我
UVA 12298 Super Poker II FFT计数, long double, 解答: 戳我
URAL 1996 Cipher Massage 3 FFT + KMP 解答:戳我
CodeChef COUNTARI Arithmetic Progressions FFT + 分块, 解答:戳我
ZOJ 3856 Goldbach FFT计数, 解答:戳我
UVALive 6886 Golf Bot FFT模板题, 解答: 戳我