快速傅立葉變換原理介紹及遞迴程式碼實現

Binfun發表於2021-08-03

上一篇文章介紹了離散傅立葉變換

快速傅立葉變換是離散傅立葉變換的一種快速實現方式,快速傅立葉變換可用於多項式乘法、大數乘法、卷積等操作,把原本的O(n^2)計算量優化到了O(nlogn),這是質的飛躍。我們現在能這麼快的網上衝浪,這個演算法居功至偉,讓我們為它鼓掌!

O(n^2)和O(nlogn)的差距在哪裡,這裡的log底數是2。

如果要處理一萬個資料,O(n^2)需要計算一億次,O(nlogn)則僅需要計算十三萬次。

接下來,下面我們會看到快速傅立葉變換是如何一步步地將O(n^2)優化到O(nlogn)的。

暴力破解法

根據上一篇文章所述,離散傅立葉公式如下:

舉個例子,當N為4的時候,離散傅立葉變換的運算相當於如下矩陣乘積:

按照這個操作,我們也可以實現N*N操作的離散傅立葉變換程式碼。

#include<cmath>
#include<iostream>
#include<complex>
#include<vector>

using namespace std;
const double pi = acos(-1.0);

vector<complex<double>> fft(vector<complex<double>> input)
{
    vector<complex<double>> output;
    int N = input.size(), k = 0, n = 0;
    for (k = 0; k < N; k++)
    {
        complex<double> sum(0,0);
        for (n = 0; n < N; n++)
        {
            complex<double> wn(polar<double>(1, -2*pi/N * n * k));
            sum += input[n] * wn;
        }
        output.push_back(sum);
    }
    return output;
}

int main()
{
    vector<complex<double>> in{1, 2, 3};
    auto re = fft(in);
    for (auto i: re)
    {
        cout << "real:" << i.real() << " imag:" << i.imag() << endl;
    }
    return 0;
}

離散傅立葉變換運算有兩層for迴圈,時間複雜度是O(n^2)。

從上面那張矩陣圖可以看到,公式的

這個$$ e^{-i2\pi } $$ 是固定不變的,變化的只有N, n, k。

再此多說一句:$$ e^{-i2\pi } $$是順時針旋轉一週的意思。同理如下圖的$$ e^{i\pi } $$ 也就是逆時針旋轉半周的意思。

至於旋轉就是這麼轉,這麼地轉動:

我們可以將$$ e^{-i2\pi } $$簡化表示成$$ \omega $$,並將N作為下標,且nk作為上標如:$$ \omega_{N}^{nk} $$

如果$$ \omega $$代表順時針旋轉一週,那麼$$ \omega_{N}^{nk} $$就代表,其步進值是順時針旋轉N分之一週,且步進次數是n*k次後的結果。

每一次旋轉的步進值就是單位複數根:

還是當N為4的時候舉個例子,運算矩陣如下:

命運輪迴,上述的ω再怎麼變,也跳不出下圖這四個紅點:

而當N為2時,ω再怎麼變,也跳不出下圖這兩個紅點:

旋轉定理

此時我們可以得到這樣的定理。

定理一


這是旋轉的週期性決定,旋轉一週就等於沒有旋轉。

定理二


當N為偶數的時候,圓的每個點都即為X軸對稱,也為Y軸對稱。

定理三


以上,當N、k均為偶數時,當N為之前的一半的時候,單位複數根也增大了一倍,所以是成立的。

根據定理一二三,我們可以把矩陣從這樣:

轉換成這樣:

看這裡的時候不必過分追究細節,具體推導在下面。

我們好像看到了一些東西,似乎我們只需要求得 $$ \omega _{2}^{1} $$ 和 $$ \omega _{4}^{1} $$ 即可?

進一步可以轉換成這樣:

嗯?好像看到了一點規律的感覺,當我們轉換一個N=4的序列的時候,我們似乎可以將它分成兩個N=2的序列,各自進行轉換。但是為什麼會這樣?

推導過程如下(也可以直接拉到最後看結論),以下參考羅博士的推導,我重新寫了公式。考慮一個4點DFT,其原始公式是:


奇偶下標項拆分一下:


提取公共項:


統一一下求和符號的下標n:

注意到N=4為偶數,根據單位復根的定理三,可以做一個比例變換,得到:


再對求和符號的下標做一個變換:

拆分成0,1和2,3:

其中k=2,3項可以轉換成以下公式:

根據定理一和定理二可以轉換成以下公式:

最終得到前後半部分的公式:

可以看到,如果把N=4的序列分成,偶數下標項和奇數下標項兩部分,分別做FFT得到A和B,那麼N=4的序列的傅立葉變換的結果:

這個時候再看看之前的這個矩陣,好像有點意思:

這裡為了加深理解,加上X0和X2,當N=2時的FFT如下:

遞迴法(自頂向下)

如果是這樣的話,原本之前我們的時間複雜度是O(N^2),現在變成了O(N*N/2)。

進一步,如果N的長度是2次冪的話,那麼這個不斷拆分偶數和奇數下標項的過程可以一直遞迴下去。

例如,如果要計算一個N=8的序列,那麼拆分成兩個N=4的序列計算,再拆分成四個N=2的序列計算,再拆分八個N=2的序列計算,是不是有一點歸併排序的感覺了?

那麼最終計算量可以降低到O(N*logN)

void FFT_recursion(vector<complex<double>> &input) 
{
    int n = input.size();
    if(n == 1)
    	return;
    int mid= input.size()/2;
    vector<complex<double>> A0, A1;
    for(int i = 0; i < n; i += 2){//拆分奇偶下標項
        A0.push_back(input[i]);
        A1.push_back(input[i + 1]);
    }
    FFT_recursion(A0);
    FFT_recursion(A1);
    complex<double> w0(1,0);
    complex<double> wn(polar<double>(1, -2*pi/n));//單位根
    for(int i=0; i < mid; i++, w0*=wn){//合併多項式
        input[i] = A0[i] + w0*A1[i];
        input[i + mid] = A0[i] - w0*A1[i];
    }
}

以上我們明白了優化的思路,上述程式碼雖然時間複雜度降到了O(nlogn),但是空間複雜度也是O(nlogn)。下一篇將介紹時間複雜度O(nlogn),同時空間複雜度為O(1)的操作……

參考資料

  1. 《演算法導論》第30章
  2. https://blog.csdn.net/u012061345/article/details/32132247
  3. https://blog.csdn.net/u012061345/article/details/32112665
  4. https://www.bilibili.com/video/BV1za411F76U

相關文章