一文学会快速傅里叶变换(FFT)

目录

•写在前面

•讲一讲多项式

多项式的系数表示法

多项式的点值表示法

多项式的乘法

•了解一下复数

复数中的单位根

•离散傅里叶变换(DFT)

•离散傅里叶逆变换

•快速傅里叶变换实现

Fortran源码

C++代码

•参考资料


•写在前面

快速傅里叶变换是一种可在 O(n l o g n) 时间内完成的离散傅里叶变换算法。在算法竞赛中的运用主要是用来加速多项式的乘法。我们这里做一个简单的引入,考虑到两个多项式 A(x), B(x) 的乘积 C(x) ,假设 A(x) 的项数为 n,其系数构成的 n 维向量为 \left(a_{0}, a_{1}, a_{2}, \dots, a_{n-1}\right)B(x) 的项数为 m ,其系数构成的 m 维向量为 \left(b_{0}, b_{1}, b_{2}, \dots, b_{n-1}\right) 。我们要求 C(x) 的系数构成的 n + m - 1维的向量,先考虑朴素做法,可以用这段代码表示:

for ( int i = 0 ; i < n ; ++ i )
    for ( int j = 0 ; j < m ; ++ j ) 
        c [i + j] += a [i] * b [j] ;

这里思路虽然非常清晰,但是它的时间复杂度是O\left(n^{2}\right),显然不够高效,那么我们就来学快速傅里叶变换,提高效率。

•讲一讲多项式

多项式有两种表示方法,系数表达法与点值表达法。

多项式的系数表示法

设多项式 A(x) 为一个 n - 1  次的多项式,显然,所有项的系数组成的系数向量 \left(a_{0}, a_{1}, a_{2}, \dots, a_{n-1}\right) 唯一确定了这个多项式。数学形式为:A(x)=\sum_{i=0}^{n-1} a_{i} \cdot x^{i}

多项式的点值表示法

设多项式 A(x) 为一个 n - 1  次的多项式,将一组互不相同的 \left(x_{0}, x_{1}, x_{2}, \dots, x_{n}\right) (叫插值节点)分别带入 A(x) ,得到 n 个取值 \left(y_{0}, y_{1}, y_{2}, \dots, y_{n}\right) ,数学形式为:y_{i}=\sum_{j=0}^{n-1} a_{j} \cdot x_{i}^{j}

可能对于点值表示法不太理解,这里我进一步解释一下,首先我们先的知道定理结论:一个 n - 1 次多项式在 n 个不同点的取值唯一确定了该多项式。为什么有这个结论?证明如下:

理解了不?如果还不理解,好,那么我更加通俗的来讲,求多项式乘法像这样: \left(8 x^{3}+6 x^{2}+7\right) \times\left(4 x^{8}+x^{4}+2 x\right) 。可是如果单单就是这样相乘就没有什么可优化的了,让我们来这样想一下,多项式其实也是一个函数,每一个 x 值都对应一个 y 值,那么就如两个点确定一条直线一样,我们也能用几个点确定一个多项式,这就是点值表示法。 如果多项式的最高次项的次数为 n ,那么在常数已知的情况下,我们只需要知道n个不同点就能联立求得多项式了,那么我就可以用这 n 个点的集合表示这个多项式了。只要把点值对应相乘就可以了,复杂度为 O(n)。 那么问题来了,如果单纯的代值进去,运算量会非常大,复杂度为O(n^2) ,这时候就需要用到快速傅立叶变换了。

多项式的乘法

已知在一组插值节点 \left(x_{0}, x_{1}, x_{2}, \dots, x_{n}\right)A(x),B(x) (假设个多项式的项数相同,没有的视为 0 )的点值向量分别为 \left(y_{a0}, y_{a1}, y_{a2}, \dots, y_{an}\right),\left(y_{b0}, y_{b1}, y_{b2}, \dots, y_{bn}\right) ,那么 C(x) = A(x)*B(x) 的点值表达式可以在 O(n) 的时间内求出,为 \left(y_{a0} \cdot y_{b0} , y_{a1} \cdot y_{b1},y_{a2} \cdot y_{b2}, \dots, y_{an} \cdot y_{bn}\right) 。因为 C(x) 的项数为 A(x), B(x) 的项数之和。设 A(x), B(x) 分别有 n,m 项。所以我们带入的插值节点有至少有 n + m 个。如果我们能快速通过点值表式求出系数表示,那么就搭起了它们之间的一座桥了。这也是快速傅里叶变换的基本思路,由系数表达式到点值表达式到结果的点值表达式再到结果的系数表达式。

可能到这里还是不够形象,那么引入卷积来解释,我这里引入卷积并不是百度什么的那么抽象,我用一个通俗的例子来解释:假如我打了你一拳,这一拳的痛感会在 1 个小时之内消失,但你这一个小时之内的感觉也不一样。我们记使用最轻的力打你一拳时,你在这一个小时内的疼痛感觉图像为 h(t),那么用两倍的力打你就是 2h(t),三倍力 3h(t)……f 倍力就为f(n)h(t)。如果我每秒打你一次,连续打你半分钟,那么你在一个小时零半分钟之内都会感到疼(每一次有新痛感来临时上一次痛感都会变化),那么怎么计算你在这一时间段内某一秒时的痛感呢?就是把之前的所有痛感叠加起来啊!Y(t) = 0 + \dots+ f(n)h(t-n),然而当你每次被打一拳的前无穷小时刻与后无穷小时刻都被打了一拳,那么我就认为打你是一件连续的行为,这时候就要把加和改写为积分了:Y(t)=\int_{-\infty}^{+\infty} f(n) h(t-n) d t,卷积就可以这么形象的理解。

而卷积定理指出:函数卷积的傅里叶变换是函数傅里叶变换后的乘积! 也就是说要求两个函数的卷积,我们就可以先把两个函数做傅立叶变换,之后算出它们的乘积,再做一次傅立叶逆变换就得出结果了!瞬间变得简单了有没有。

•了解一下复数

我们把形如 a + bi 这样的数叫做复数,复数集合用 C 来表示。其中 a 称为实部 , b 称为虚部 , i 为虚数单位,指满足 x^2 = -1的一个解 \sqrt{-1} ;此外,对于这样对复数开偶次幂的数叫做虚数 。

每一个复数 a + bi 都对应了一个平面上的向量 (a,b) 我们把这样的平面称为复平面 ,它是由水平的实轴与垂直的虚轴建立起来的复数的几何表示。所以,每一个复数唯一对应了一个复平面上的向量,每一个复平面上的向量也唯一对应了一个复数。其中 0 既被认为是实数,也被认为是虚数。其中复数 z = a + bi 的模长 \left | z \right | 定义为 z 在复平面的距离到原点的距离,即 \left | z \right | = \sqrt{a^2 + b^2}。幅角 \Theta 为实轴的正半轴正方向(逆时针)旋转到 z 的有向角度。由于虚数无法比较大小。复数之间的大小关系只存在等于与不等于两种关系,两个复数相等当且仅当实部虚部对应相等。对于虚部为 0 的复数之间是可以比较大小的,相当于实数之间的比较。

复数之间的运算满足结合律,交换律和分配律,由此定义复数之间的运算法则,如下:

复数运算的加法满足平行四边形法则,乘法满足幅角相加,模长相乘。

对于一个复数 z = a + bi ,它的共轭复数是 {z}' = a - bi , {z}' 称为 z 的复共轭 。共轭复数有一些性质:z\cdot {z}' = a^2 + b^2 以及 z = \left | {z}' \right |

复数中的单位根

上图是复平面中的单位圆。其中 \bar{OA} 单位根,表示为 e^{i\Theta } ,可知 e^{i \theta}=\cos \theta+i \cdot \sin \theta(顺便一提著名的欧拉幅角公式 e^{i\pi } + 1 = 0 其实是由定义来的...)。将单位圆等分成 n 个部分(以单位圆与实轴正半轴的交点一个等分点),以原点为起点,圆的这 n 个 n 等分点为终点,作出 n 个向量。其中幅角为正且最小的向量称为 n 次单位向量,记为 \omega_{n}^{1} 。其余的 n - 1 个向量分别为 \omega_{n}^{2}, \omega_{n}^{3}, \ldots \ldots, \omega_{n}^{n},它们可以由复数之间的乘法得来 w_{n}^{k}=w_{n}^{k-1} \cdot w_{n}^{1}(2 \leq k \leq n) 。

由此我们容易看出 w_{n}^{n}=w_{n}^{0} = 1 ,且 w_{n}^{k} = e^{2 \pi \cdot \frac{k}{n} i} ,带入上方即:\omega_{n}^{k}=\cos \left(2 \pi \cdot \frac{k}{n}\right)+i \cdot \sin \left(2 \pi \cdot \frac{k}{n}\right)

关于单位根有两个性质我们要谈谈

(一)性质一:折半定理,其中,它的数学表示:\omega_{2 n}^{2 k}=\omega_{n}^{k} 。

证明不难,我们可以由单位根的几何意义证明,这两者表示的向量终点是一样的。我们还可以通过计算公式来证明,如下:

\omega_{2 n}^{2 k}=\cos \left(2 \pi \frac{2 k}{2 n}\right)+i \cdot \sin \left(2 \pi \frac{2 k}{2 n}\right)=\cos \left(2 \pi \frac{k}{n}\right)+i \cdot \sin \left(2 \pi \frac{k}{n}\right)=\omega_{n}^{k} ,从而引申出  \omega_{m n}^{m k}=\omega_{n}^{k} 。

(二)性质二:消去引理,其中,它的数学表示:\omega_{n}^{k+\frac{n}{2}}=-\omega_{n}^{k}

证明也不难,我们可以由几何意义,这两者表示的向量终点是相反的,左边较右边在单位圆上多转了半圈。我们也可以通过计算公式来证明,如下:

\omega_{n}^{k+\frac{n}{2}}=\cos \left(2 \pi \frac{k+\frac{n}{2}}{n}\right)+i \cdot \sin \left(2 \pi \frac{k+\frac{n}{2}}{n}\right)=\cos \left(2 \pi \frac{k}{n}+\pi\right)+i \cdot \sin \left(2 \pi \frac{k}{n}+\pi\right)=-\cos \left(2 \pi \frac{k}{n}\right)-i \cdot \sin \left(2 \pi \frac{k}{n}\right)=-\omega_{n}^{k}

•离散傅里叶变换(DFT)

在讲快速傅里叶变换之前,我们还得来提一下离散傅里叶变换,首先我们单独考虑一个 n 项( n = 2^x )的多项式 A(x) ,其系数向量为 \left(a_{0}, a_{1}, a_{2}, \dots, a_{n-1}\right)。我们将 n 次单位根的 0 ~ n-1 次幂分别带入 A(x) 得到其点值向量 \left(A\left(w_{n}^{0}\right), A\left(w_{n}^{1}\right), A\left(w_{n}^{2}\right), \ldots, A\left(w_{n}^{n-1}\right)\right) 。这个过程称为离散傅里叶变换

如果朴素带入,时间复杂度也是 O(n^2) 的。所以我们必须要利用到单位根 \omega 的特殊性质。

对于$$A ( x ) = a _ { 0 } + a _ { 1 } \cdot x ^ { 1 } + a _ { 2 } \cdot x ^ { 2 } + a _ { 3 } \cdot x ^ { 3 } + \ldots + a _ { n - 1 } \cdot x ^ { n - 1 }$$ 考虑将其按照奇偶分组得到如下

此时,令

则可以得到,这里就有一个结论:任何一个DFT其实可以重新改写成两个DFT的和

这里我们进行推导,分类讨论

注意, k 与  k+\frac{n}{2}  取遍了 [0, n-1] 中的 n 个整数,保证了可以由这 n 个点值反推解出系数(上文已证明)。于是我们可以知道。如果已知了 A1(x),A2(x) 分别在 w_{\frac{n}{2}}^{0},w_{\frac{n}{2}}^{1},\dots,w_{\frac{n}{2}}^{\frac{n}{2}-1} 的取值,可以在 O(n) 的时间内求出 A(x) 的取值。而 A1(x),A2(x) 都是 A(x)  一半的规模,显然可以转化为子问题递归求解。时间复杂度:T(n)=2T(\frac{n}{2})+O(n)=O(nlogn)

•离散傅里叶逆变换

我们已经把快速傅里叶变换的第一步,两个函数做傅立叶变换,之后算出它们的乘积的第一步完成,接下来,我们需要在对结果进行一次傅里叶逆变换求得结果,所以我们还需要了解傅里叶逆变换。正如这里所说,使用快速傅里叶变换将点值表示的多项式转化为系数表示,这个过程叫做离散傅里叶反变换

即由 (A(x_0),A(x_1),\dots,A(x_{n-1})) 维点值向量 n 推出 n 维系数向量\left(a_{0}, a_{1}, \dots, a_{n-1}\right) 。这里设 \left(d_{0}, d_{1}, \dots, d_{n-1}\right) 为 \left(a_{0}, a_{1}, \dots, a_{n-1}\right) 得到离散傅里叶变换的结果。从而我们可以构造一个多项式,如下:

于是,我们可以进行一下一系列的过程,如下:

由此得到,对于多项式 A(x)  由插值节点 ((w_{n}^{0},(w_{n}^{1}),(w_{n}^{2}),\dots,(w_{n}^{n-1})) 做离散傅里叶变换得到的点值向量 \left(d_{0}, d_{1}, \dots, d_{n-1}\right) 。我们将 ((w_{n}^{0},(w_{n}^{-1}),(w_{n}^{-2}),\dots,(w_{n}^{-(n-1)})) 作为插值节点,\left(d_{0}, d_{1}, \dots, d_{n-1}\right) 作为系数向量,做一次离散傅里叶变换得到的向量每一项都除以 n 之后得到的(\frac{c_0}{n},\frac{c_1}{n},\dots,\frac{c_{n-1}}{n}) 就是多项式的系数向量 \left(a_{0}, a_{1}, \dots, a_{n-1}\right) 。

注意到 w_{n}^{-k} 是 w_{n}^{k} 的共轭复数。这整个推导过程称为离散傅里叶逆变换。

•快速傅里叶变换实现

在傅里叶变换和傅里叶逆变换结合使用的过程,就是快速傅里叶变换的实现。我们先通俗的讲一下结论,在进行推导,通过上面的奇偶分解,我们发现这一分为二的DFT组,一个是原来是偶数项(第0,2,4,6...项)组成的,而另一个是由原来的奇数项(odd)组成的。如果你只是看到这一步,你已经发现了一个大事情,就是原来用这个公式来算的话,求一个傅里叶变换不再需要进行N次相乘,而只需要odd组的N/2次运算,even组的直接不用做任何运算直接拉下来就好了。那这样计算量不就减少一半了吗?但是其实事情并没有那么简单,我们刚才证明了任何一个DFT组都能被一分为二,而且计算量减半。但是我们现在一分为二得到的even组和odd组,他们不又是DFT组吗?我们可以继续之前的操作,将他们分别再次分为两个数组的求和!是不是很惊讶,为了更好的理解,我这里形象的放一张图。

一分为二,二分为四,四分为八,一直分到每个数组只剩一个数字为止!而且每进行一次操作,就能使得其中一半的计算量被减少。这样我们就把计算量从 O(N^2) 降到了 N\times log_2(N) 。

我相信如果搞懂了FFT,一定会让你精神抖擞,你将会被它的快速而惊艳到,下面我贴几种方式的源码实现(只是核心的递归代码,我们理解思想就好)

Fortran源码

recursive subroutine fft(x,sgn)
    implicit none
    integer, intent(in) :: sgn
    complex(8), dimension(:), intent(inout) :: x
    complex(8) :: t
    integer :: N
    integer :: i
    complex(8), dimension(:), allocatable :: even, odd

    N=size(x)

    if(N .le. 1) return

    allocate(odd((N+1)/2))
    allocate(even(N/2))

    ! divide
    odd =x(1:N:2)
    even=x(2:N:2)

    ! conquer
    call fft(odd, sgn)
    call fft(even, sgn)

    ! combine
    do i=1,N/2
        t=exp(cmplx(0.0d0,sgn*2.0d0*pi*(i-1)/N))*even(i)
        x(i)     = odd(i) + t
        x(i+N/2) = odd(i) - t
    end do

    deallocate(odd)
    deallocate(even)
end subroutine fft

C++代码

struct Complex  {
	double r, i ;
	Complex ( )  {	}
	Complex ( double r, double i ) : r ( r ), i ( i )  {	}
	inline void real ( const double& x )  {  r = x ;  }
	inline double real ( )  {  return r ;  }
	inline Complex operator + ( const Complex& rhs )  const  {
		return Complex ( r + rhs.r, i + rhs.i ) ;
	}
	inline Complex operator - ( const Complex& rhs )  const  {
		return Complex ( r - rhs.r, i - rhs.i ) ;
	}
	inline Complex operator * ( const Complex& rhs )  const  {
		return Complex ( r * rhs.r - i * rhs.i, r * rhs.i + i * rhs.r ) ;
	}
	inline void operator /= ( const double& x )   {
		r /= x, i /= x ;
	}
	inline void operator *= ( const Complex& rhs )   {
		*this = Complex ( r * rhs.r - i * rhs.i, r * rhs.i + i * rhs.r ) ;
	}
	inline void operator += ( const Complex& rhs )   {
		r += rhs.r, i += rhs.i ;
	}
	inline Complex conj ( )  {
		return Complex ( r, -i ) ;
	}
} ;

struct FastFourierTransform  {
    Complex omega [N], omegaInverse [N] ;

    void init ( const int& n )  {
        for ( int i = 0 ; i < n ; ++ i )  {
            omega [i] = Complex ( cos ( 2 * PI / n * i), sin ( 2 * PI / n * i ) ) ;
            omegaInverse [i] = omega [i].conj ( ) ;
        }
    }

    void transform ( Complex *a, const int& n, const Complex* omega ) {
        for ( int i = 0, j = 0 ; i < n ; ++ i )  {
		if ( i > j )  std :: swap ( a [i], a [j] ) ;
		for( int l = n >> 1 ; ( j ^= l ) < l ; l >>= 1 ) ;
	}

        for ( int l = 2 ; l <= n ; l <<= 1 )  {
            int m = l / 2;
            for ( Complex *p = a ; p != a + n ; p += l )  {
                for ( int i = 0 ; i < m ; ++ i )  {
                    Complex t = omega [n / l * i] * p [m + i] ;
                    p [m + i] = p [i] - t ;
                    p [i] += t ;
                }
            }
        }
    }

    void dft ( Complex *a, const int& n )  {
        transform ( a, n, omega ) ;
    }

    void idft ( Complex *a, const int& n )  {
        transform ( a, n, omegaInverse ) ;
        for ( int i = 0 ; i < n ; ++ i ) a [i] /= n ;
    }
} fft ;

•参考资料

一小时学会快速傅里叶变换

一步一步理解快速傅立叶变换

世界上有哪些代码量很少,但很牛逼很经典的算法或项目案例?怎么理解虚数和复数?

发布了91 篇原创文章 · 获赞 463 · 访问量 91万+

猜你喜欢

转载自blog.csdn.net/DBC_121/article/details/104213919