总觉得不会写 FFT 有愧于天地。
# 傅里叶变换 —— 连续到离散
高等数学的知识告诉我们,周期信号可以被分解成一系列正弦信号之和(当然要满足一定的条件,不过工科一般也碰不到吧),也就是所谓的傅里叶级数:
f(t)=2A0+n=1∑∞Ancos(nΩt+φn)
这个式子也可以写成复指数的形式:
f(t)=n=−∞∑∞FnejnΩt
对于非周期的信号,可以将其视作周期无穷大的周期信号,在这种情况下,傅里叶级数中Ω 会趋近于无穷小,离散的频谱概念则不再适用。因此需要引入频谱密度的概念:
F(jΩ)=T→∞lim1/TFn=T→∞limFnT
这样,有:
FnT=∫−T/2T/2f(t)e−jnΩtdt
即
f(t)=n=−∞∑∞FnejnΩt
当T→∞,Ω 趋近无穷小,记为dω,则有:
F(jω)=T→∞limFnT=∫−∞∞f(t)e−jωtdt
反之有:
f(t)=2π1∫−∞∞F(jω)ejωtdω
这是傅里叶变换的定义。
不管怎么样,傅里叶变换看起来都还是不错的。不过对于计算机来说却不是 —— 傅里叶变换处理连续的函数,得到连续的频谱。这显然难以通过计算机实现,因此,我们需要将其离散化。
首先,我们将时间轴离散化,将时间轴上的点看作是一个个的采样点,这样,我们就得到了一个离散的序列fN(k)。采用和傅里叶变换类似的定义,有:
F(ejθ)=k=−∞∑∞fN(k)e−jkθ
这也就是离散时间傅里叶变换(DTFT)。然而,这里的θ 是一个连续的值:N→∞,nN2π→θ,这使得θ 成为了一个连续的变量(也就是频谱依旧是一个连续量)。DTFT 的逆变换为:
f(k)=2π1F(ejθ)ejkθdθ
这里仍然需要对一段连续的区间进行积分,对于计算机来说依旧是难以计算的。
如果考虑一个周期序列,周期序列对应的频谱是一个离散谱,那么如果能够将需要计算的序列fN(k) 转换为一个周期序列,就能够得到便于计算机处理的离散谱。
考虑一个有限长序列f(k),其在区间[0,N−1] 中有取值,其余区段取值为 0:
f(k)={f(k),0,0≤k≤N−1otherwise
将其延拓到整个时间轴上,有:
fN(k)=l=−∞∑∞f(k+lN), l∈Z
衍伸傅里叶级数的定义,得到:
F(n)=k=0∑N−1fN(k)e−jN2πnkf(k)=N1n=0∑N−1F(n)ejN2πnk
这便是离散傅里叶变换(DFT)和离散傅里叶逆变换(IDFT)。
我们利用 DFT 的公式可以比较简单的实现离散傅里叶变换:
| def dft(x): |
| N = len(x) |
| n = np.arange(N) |
| return np.array([np.sum(x * np.exp(-1j * 2 * np.pi * k * n / N)) for k in range(N)]) |
| |
| |
| def idft(X): |
| N = len(X) |
| n = np.arange(N) |
| return np.array( |
| [np.sum(X * np.exp(1j * 2 * np.pi * k * n / N)) / N for k in range(N)] |
| ) |
显然,这里的时间复杂度为O(n2),明显是不够好的。我们需要寻找更好的算法。
# 快速傅里叶变换 —— 从多项式乘法开始
在引入快速傅里叶变换的概念之前,先来考虑一个问题 —— 如何快速地计算多项式乘法?
举个例子,现在有两个多项式:
A(x)=x3+2x2+3x+4B(x)=4x3+3x2+2x+1
我们要如何去计算这两个多项式的乘积C(x)=A(x)⋅B(x)?
显然,直接计算不是不行 —— 我们不妨用更一般的形式来写:
A(x)=i=0∑naixiB(x)=i=0∑nbixi
那么C(x) 可以表示为
C(x)=i=0∑2ncixi,ci=j=0∑iajbi−j
很不幸,这也是个O(n2) 的算法。我们也需要寻找更好的算法。
但是,回头看一下上面的式子 —— 有什么特殊的地方?乘积多项式C(x) 的系数ci
ci=j=0∑iajbi−j
正好是多项式A(x) 和B(x) 系数的卷积!
卷积这件事会带来很多有趣的性质,卷积定理告诉我们 —— 时域相乘等于频域卷积,时域卷积等于频域相乘,这也让我们回到了傅里叶变换的问题上来。
不过暂且先抛开傅里叶变换,我们先把目光放到多项式上来。
# 系数表示和点值表示
我们前面采用了多项式的系数来表示它,不过这不算是唯一方法 —— 我们可以通过多项式在一些点上的取值来表示它。
为了后面介绍的方便,我们引入两个术语 ——Evaluation(估计)和 Interpolation(插值)。Evaluation 是指通过多项式的系数来计算多项式在某些点上的取值,而 Interpolation 则是指通过多项式在某些点上的取值来计算多项式的系数。
中学的知识就已经说明了,一个n 次多项式可以通过n+1 个点来唯一确定。因此,我们可以通过多项式在n+1 个点上的取值来表示它:
(xi,yi), i=0,1,⋯,n→A(x)
那么利用这种表示,我们也可以计算多项式的乘法:对于A(x) 上的一点(xi,ya,i) 和B(x) 上的一点(xi,yb,i),我们可以计算出C(x) 上的一点(xi,ya,iyb,i)。那么计算2N 个点,我们便可以通过拉格朗日插值法确定C(x)。
不过,这样的计算量依旧是O(n2) 的。
可以优化吗?
# 利用奇偶函数的性质
很中学地,我们注意到,奇函数和偶函数有着这样的性质:
fodd(x)=−fodd(−x)feven(x)=feven(−x)
对于奇函数和偶函数,我们只需要计算f(x) 就能得到f(−x) 的值 —— 这让我们的计算量减少了一半。
而每个多项式都可以分解为两个部分的和:
f(x)=i=0∑naixi=Peven(x2)i=0∑n/2a2ix2i+xPodd(x2)i=0∑n/2a2i+1x2i=Peven(x2)+xPodd(x2)
(这里的表述有点小问题,但是不影响意思的表达……)
例子
举个例子,对于函数f(x)=5x5+4x4+3x3+2x2+x+1,我们将它转换为:
f(x)=(4x4+2x2+1)+x(5x4+3x2+1)
我们也就得到了
Peven(x2)=4(x2)2+2(x2)+1Podd(x2)=5(x2)2+3(x2)+1
那么我们计算得到Peven(xi2) 和Podd(xi2) 之后,我们就可以得到f(xi) 和f(−xi) 的值了:
f(xi)=Peven(xi2)+xiPodd(xi2)f(−xi)=Peven(xi2)−xiPodd(xi2)
这样我们把一个n 次多项式的计算量减少到了两个2n 次多项式的计算量,而且看起来是能够递归计算的。
能吗?
# 复数?
似乎不能。
还是举个例子。函数f(x)=x3+x2−x+1,我们需要选择四个点才能够唯一确定它。为了尽可能利用上面的性质,我们选择x1,−x1,x2,−x2 这四个x 对应的点。
那我们便需要计算Peven(x2)=x2+1 和Podd(x2)=x2−1 两个函数在x1,x2 处的取值。拿Peven(x2) 为例,为了尽可能利用递归的性质,x2 应该等于−x1。
但是,Peven(x2) 传入的参数是x2,而x2=−x1,这样就会导致Peven(x22)=Peven(x12),这样就无法利用递归的性质了。
解决办法很简单 —— 复数。
如果我们需要计算的多项式次数为d,我们选择n=2⌈log2d⌉(便于二分递归),我们要选的点便是xn=1 的n 个根:
ωi=e−2jπi/n
在这些根中,ωi 和ωn/2+i 是相反的:
ωn/2+i=e−2jπ(n/2+i)/n=e−jπe−2jπii/n=−ωi
这样,可以保证Peven(x2) 和Podd(x2) 的参数可以是一对相反数,便可以利用递归的性质了。
我们可以通过代码来实现这个过程:
| def evaluation(coeffs): |
| n = len(coeffs) |
| if n == 1: |
| |
| return coeffs |
| even, odd = coeffs[::2], coeffs[1::2] |
| |
| even_eval, odd_eval = evaluation(even), evaluation(odd) |
| omega = np.exp(-2j * np.pi / n * np.arange(n // 2)) |
| |
| return np.concatenate( |
| [even_eval + omega * odd_eval, even_eval - omega * odd_eval] |
| ) |
不难发现这是一个O(nlogn) 的过程。
# Evaluation 和 DFT
至少,我们现在获得了一种快速计算多项式值的方式 —— 那么这和这篇文章的标题有什么关系?
记ω=e−2jπ/n,我们现在得到了一系列的值:
F=[f(ω0),f(ω1),⋯,f(ωn−1)]T
如果我们使用传统的方法计算,这些值从何而来?记
f(x)=i=0∑n−1aixi
我们可以把F 的计算过程展开成一个矩阵乘法:
f(ω0)f(ω1)⋮f(ωn−1)=M11⋮11ω⋮ωn−11ω2⋮ω2(n−1)⋯⋯⋱⋯1ωn−1⋮ω(n−1)2a0a1⋮an−1
回过头看 DFT 的表述:
F(n)=k=0∑N−1fN(k)e−jN2πnk=k=0∑N−1fN(k)ωnk
记fN=[a0,a1,...,an−1]T,将 DFT 的结果序列记作F=[F(0),F(1),...,F(n−1)]T,如果将F 的计算过程展开成一个矩阵乘法:
F=MDFTfN
这里的MDFT 和上面的M 是一致的。
也就是说,这个对多项式的 Evaluation 过程,其实就是 DFT 的过程 —— 我们对多项式的系数进行了 DFT,得到了多项式在ω0,ω1,...,ωn−1 这些点上的取值(相当于系数的频谱)。
更加值得庆祝的是,我们现在已经有了一个O(nlogn) 的 DFT 算法了,这就是我们所说的快速傅里叶变换(FFT)。
# Interpolation 与 IDFT
既然 Evaluation 和 DFT 是一致的,而 Interpolation 和 Evaluation 是相反的过程,那 Interpolation 和 IDFT 是否是一致的?
显然是一致的。依据 IDFT 的定义,我们能够写出:
a0a1⋮an−1=n1M−111⋮11ω−1⋮ω−(n−1)1ω−2⋮ω−2(n−1)⋯⋯⋱⋯1ω−(n−1)⋮ω−(n−1)2f(ω0)f(ω1)⋮f(ωn−1)
不难验证M 和M−1 互为逆矩阵,上式中左侧为多项式系数,右侧为逆 DFT 矩阵和多项式在ω0,ω1,...,ωn−1 这些点上的取值,这个过程也就是插值。
更妙的是,和 Evaluation 相比,两者只是相差一个n 的倍数和ω 的符号,从程序上来说,几乎没有区别。
| def ifft(x): |
| n = len(x) |
| if n == 1: |
| return x |
| even, odd = coeffs[::2], coeffs[1::2] |
| |
| even_eval, odd_eval = ifft(even), ifft(odd) |
| omega = np.exp(2j * np.pi / n * np.arange(n // 2)) |
| return np.concatenate( |
| [even_eval + omega * odd_eval, even_eval - omega * odd_eval] |
| ) / n |
# 回到多项式乘法问题
回到原先的多项式乘法……
我们现在已经可以通过 FFT 来计算多项式在ω0,ω1,...,ωn−1 这些点上的取值了,通过两次 FFT,我们可以计算得到多项式A(x) 和B(x) 在这些采样点上的取值,然后相乘,再通过一次 IFFT,我们就可以得到多项式C(x) 的系数。
整个过程的时间复杂度还是O(nlogn)。
# End
嗯,终于学会 FFT 了,可喜可贺,可喜可贺。