快速傅里叶变换(FFT)算法

By Long Luo

之前的文章 傅里叶变换(Fourier Transform) 详细介绍了傅里叶变换 (Fourier Transform) 是什么做什么,相信你已经感受到了傅里叶变换的强大之处。

但理论要联系实际,今天我们就来学习 快速傅里叶变换 (Fast Fourier Transform, FFT) 1 的实际运用:多项式乘法

快速傅里叶变换(Fast Fourier Transform, FFT)

快速傅里叶变换 (Fast Fourier Transform, FFT) 2 是一种可在 O(nlogn) 时间内完成的 离散傅里叶变换3 (Discrete Fourier transform, DFT) 算法。

FFT可以做什么?

傅里叶变换 (Fourier Transform) 本质上是信号与三角函数进行卷积运算,而快速傅里叶变换 (FFT) 就是提高卷积的计算效率,时间复杂度从 O(n2) 降低到 O(nlogn)

FFT 在算法中的运用主要是用来加速多项式乘法大数乘法

多项式乘法

正如之前的文章 卷积(Convolution) 所说,多项式乘法也是一种卷积运算。

在计算中,泰勒级数4 可以使用多项式函数来逼近一个函数,所以计算中多项式乘法非常重要。

大数乘法

超大数字乘法,可以参考 超大数字的四则运算是如何实现的呢? 。朴素的算法就是列竖式做乘法,算法时间复杂度为 O(n2) ,如果数字太大的话,效率也不够高,如果应用 (FFT) 则可以使算法时间复杂度降至 O(nlogn)

不妨设十进制数字 num=123456789 ,很容易知道:

123456789=1×108+2×107+3×106+4×105+5×104+6×103+7×102+8×101+9×100

x=10,则可以转化为:

1×x8+2×x7+3×x6+4×x5+5×x4+6×x3+7×x2+8×x1+9×x0

所以大数乘法就是 x=10 情况下的多项式乘法!

那下面我们就以多项式乘法的为例来学习快速傅里叶变换 (FFT) 具体是如何做的。

多项式

在学习多项式乘法之前,我们需要先学习下有关多项式的知识。

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

系数表示法

设多项式 A(x) 为一个 nn1 次的多项式,显然,所有项的系数组成的系数向量 (a0,a1,a2,,an1) 唯一确定了这个多项式。

A(x)=i=0n1aixi=a0+a1x1+a2x2++an1xn1A(x)=a0,a1,,an1

点值表示法

点值表示法是把这个多项式看成一个函数,从其中选取 n 个不同的点,从而利用这 n 个点来唯一地表示这个函数。

A(x0)=y0=a0+a1x0+a2x02+a3x03++an1x0n1A(x1)=y1=a0+a1x1+a2x12+a3x13++an1x1n1A(x2)=y2=a0+a1x2+a2x22+a3x23++an1x2n1A(xn1)=yn1=a0+a1xn1+a2xn12+a3xn13++an1xn1n1

那么用点值表示法表示 A(x) 如下:

A(x)=a0+a1x+a2x2++an1xn1A(x)=(x0,y0),(x1,y1),,(xn1,yn1)

为什么用 n 个不同点就能唯一地表示一个 n1 次函数?

证明如下:

  • Proof 1 :

两点确定一条直线。再来一个点,能确定这个直线中的另一个参数,那么也就是说 n 个点能确定 n1 个参数(不考虑倍数点之类的没用点)。

  • Proof 25 :

假设原命题不成立,则存在两个不同的 n1 次多项式函数 A(x)B(x) ,那么 A(x)B(x)n1 个交点,即任何 i[0,n1],有 A(xi)=B(xi)

C(x)=A(x)B(x) ,则 C(x) 也是一个 n1 次多项式。对于任何 i[0,n1],都有 C(xi)=0

C(x)n 个根,这与代数基本定理(一个 n1 次多项式在复数域上有且仅有 n1 个根)相矛盾,故 C(x) 并不是一个 n1 次多项式,推导矛盾。

故原命题成立。

多项式乘法

考虑两个多项式 A(x)B(x) ,其乘积 C(x)=A(x)B(x)

假设 A(x) 的项数为 n ,其系数构成的 n 维向量为 (a0,a1,a2,,an1)B(x) 的项数为 m ,其系数构成的 m 维向量为 (b0,b1,b2,,bm1)6

我们要求 C(x) 的系数构成的 n+m1 维的向量,先考虑暴力做法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public int[] mutply(int[] A, int[] B) {
int n = A.length;
int m = B.length;

int[] C = new int[n + m];

for (int i = 0; i < n; ++i) {
for (int j = 0; j < m; ++j) {
C[i + j] += A[i] * B[j];
}
}

return C;
}

可见时间复杂度是 O(n2)

如何加速多项式乘法?

实际运用中多项式的项数非常多,比如 105 这种级别,那么有没有什么方法可以加速运算呢?

已知在一组插值节点 (x0,x1,x2,,xn)A(x)B(x) (假设多项式的项数相同,没有则视为 0 。)的点值向量分别为 (ya0,ya1,ya2,,yan)(yb0,yb1,yb2,,ybn) ,则:

A(x)=(x0,ya0),(x1,ya1),(x2,ya2),,(xn,yan)B(x)=(x0,yb0),(x1,yb1),(x2,yb2),,(xn,ybn)

那么 C(x)=A(x)B(x) ,那么其点值表示法可以在 O(n) 的时间内求出:

C(x)=(x0,ya0yb0),(x1,ya1yb1),(x2,ya2yb2),,(xn,yanybn)

多项式乘法的系数表示法的时间复杂度是 O(n2) ,而点值表示法的时间复杂度是 O(n)

其实可以观察到,多项式乘法的系数表示法就是做卷积运算,而点值表示法是做乘法运算。卷积定理7 告诉我们在一个域中的卷积相当于另一个域中的乘积。

还记得傅立叶变换的本质就是从另外一个维度看待世界吗?

在这里,系数表示法就是时域上表达,很复杂,而点值表示法就是频域上表示,就非常简单。那么我们将其转换为频域上表示,就可以大大降低其复杂度!

但在得到了 C(x)点值表达式之后,还是不行,因为我们要的是系数表达式

接下来问题变成了从点值回到系数。如果我们带入到高斯消元法的方程组中去,会把复杂度变得非常高。光是计算 xi(0in) 就是 n 项,这就已经 O(n2) 了,更别说还要把 n+1 个方程进行消元…

不过我们暂时忽略如何将点值表达式变成系数表达式,关注点放在乘法运算上。因为系数表达式和点值表达式运算复杂度差距如此之大,如果能在运算之前把多项式变成点值表示法,做完乘法之后,再将点值表示法变成系数表示法不就可以大大提高效率了吗?

点值表示法 和 系数表示法 如何转换?

想象有个魔法黑匣子,可以实现多项式的点值表示法系数表示法的互换。

每次运算前,我们先向黑匣子里输入 A(x)B(x) 系数表达式,黑匣子内部先将 A(x)B(x) 都变成点值表达式,黑匣子进行乘法运算之后,再在内部转换为系数表达式,返回给我们,不就行了吗?

所以这个黑匣子里面是什么?能不能实现这个黑匣子呢?

其实这个魔法黑匣子本质就是我们今天要研究的快速傅里叶变换 (FFT) ,其思路就是由系数表达式到点值表达式,生成结果的点值表达式,再将点值表达式转换为结果的系数表达式。

离散傅里叶变换(Discrete Fourier Transform)

上一章讲到了一个魔法黑匣子可以实现多项式的点值表达式和系数表达式的转换,这一章我们就来研究如何将多项式系数表达式转换为点值表达式。

DFT

考虑一个 n 项( n=2x )的多项式 A(x) ,其系数向量为 (a0,a1,a2,,an1)

A(x)=a0x0+a1x1++an1xn1

n 次单位根的 [0,n1] 次幂分别带入 A(x) 得到其点值向量 (A(wn0),A(wn1),A(wn2),,A(wnn1))

这个过程称为离散傅里叶变换 (Discrete Fourier Transform)

如果朴素代入计算,时间复杂度也是 O(n2) ,所以我们必须要利用到单位根 ω 的特殊性质以减少运算。

奇偶次幂分组

对于 A(x)=a0x0+a1x1+a2x2+a3x3++an1xn1

将其按照奇偶次幂分组:

A(x)=(a0x0+a2x2+a4x4++an2xn2)+(a1x1+a3x3+a5x5++an1xn1)=(a0x0+a2x2+a4x4++an2xn2)+x(a1+a3x2+a5x4++an1xn2)

Ae(x)=(a0x0+a2x+a4x2++an2xn22)Ao(x)=(a1+a3x+a5x2++an1xn22)

那么易得: A(x)=Ae(x2)+xAo(x2)

分类讨论

  1. 0kn21,kZ ,代入单位根 ωnk ,则:

A(ωnk)=Ae(ωn2k)+ωnkAo(ωn2k)

由上文提到的折半引理

A(ωnk)=Ae(ωn2k)+ωnkAo(ωn2k)

  1. n2k+n2n1时,

A(ωnk+n2)=Ae(ωn2k+n)+ωnk+n2Ao(ωn2k+n)

其中 ωn2k+n=ωn2kωnn=ωn2k=ωn2k

由消去引理 ωnk+n2=ωnk

那么:

A(ωnk+n2)=Ae(ωn2k)ωnkAo(ωn2k)

注意: kk+n2 取遍了 [0,n1] 中的 n 个整数,保证了可以由这 n 个点值反推解出系数。

DFT小结

综合这两个式子,如果知道 Ae(x)Ao(x) 分别在 ωn20,ωn21,,ωn2n21 处的点值,就可以 O(n) 的时间内求出 A(x)ωn0,ωn1,,ωnn1 处的点值。

Ae(x)Ao(x) 都是 A(x) 一半的规模,可以转化为子问题递归求解。

所以时间复杂度:

T(n)=2T(n2)+O(n)=O(nlogn)

所以明白在一开始的时候 n=2x 吧?

分治 DFT 能处理的多项式长度只能是 2m(mN),否则在分治的时候左右不一样长。

离散傅里叶逆变换(Inverse Discrete Fourier Transform)

上一节我们讲了离散傅里叶变换 (Discrete Fourier Transform),即实现了魔法黑匣子的一半,实现了多项式系数表达式转换成点值表达式,那么这一章我们就来实现魔法黑匣子的另一半,将多项式点值表达式转化为系数表达式。

将点值表达式的多项式转化为系数表达式,这个过程叫做离散傅里叶逆变换 (Inverse Discrete Fourier Transform)

IDFT求解过程

问题:

对于多项式 A(x)=a0x0+a1x1+a2x2+a3x3++an1xn1 ,已知 n 个点,其 n 维点值向量为 (A(x0),A(x1),,A(xn1)),请求解其 n 维系数向量 (a0,a1,,an1)8

  1. (d0,d1,,dn1)(a0,a1,,an1) 得到的离散傅里叶变换的结果。

构造一个多项式:

F(x)=d0+d1x+d2x2++dn1xn1

  1. 设向量 (c0,c1,,cn1),其中 ckF(x)x=ωnk 的点值表示,即 ck=i=0n1di(ωnk)i

如何得到di

因为

ck=i=0n1[j=0n1aj(ωni)j](ωnk)i

由和式的性质

ck=j=0n1aji=0n1(ωni)j(ωnk)i=j=0n1aji=0n1(ωni)jk

S(j,k)=i=0n1(ωni)jk,对其进行化简:

jk=δ,则:

S(j,k)=ωn0+ωnδ+ωn2δ++ωn(n1)δ

可见 ωnk 构成等比数列,其公比为 ωnδ

  • ωnδ=1δ=0 时,S(j,k)=n 此时 δ=0jk=0j=k

  • ωnδ1δ0 时,由等比数列求和公式:

S(j,k)=ωn0[(ωnδ)n1]ωnδ1=1[(ωnn)δ1]ωnδ1=[(1)δ1]ωnδ1=0ωnδ1=0

,此时 jk

综合可得:S(j,k)=[j=k]n

求出ak

S(j,k) 带入原式:

ck=j=0n1ajS(j,k)=j=0n1aj[j=k]n=akn

所以:ak=ckn ,最终我们得到了原多项式 A(x) 的系数向量 (a0,a1,,an) 中的 ak

小结

对于多项式 A(x) 由插值节点 (ωn0,ωn1,ωn2,,ωnn1) 做离散傅里叶变换得到的点值向量 (d0,d1,,dn1)

(ωn0,ωn1,ωn2,,ωn(n1)) 作为插值节点,(d0,d1,,dn1) 作为系数向量,做一次离散傅里叶变换得到的向量每一项都除以 n 之后得到的 (c0n,c1n,,cn1n) 就是多项式的系数向量 (a0,a1,,an1)

注意:ωnkωnk共轭复数

通过这个过程我们就实现了将点值转换为系数表示。

FFT的本质

通过上面两节的 DFTIDFT,我们实现了魔法黑匣子的功能,也就是实现了多项式的点值表达式和系数表达式的互相转换。

下面进行总结9

从时域到频域:DFT

DFT

DFT(a0,a1,,an1)=(y0,y1,,yn1)=(A(wn0),A(wn1),,A(wnn1))=(A(wn0),A(wn1),,A(wnn1))

从时域到频域:IDFT

IDFT

InverseDFT(y0,y1,,yn1)=(a0,a1,,an1)

矩阵运算

我们可以将 A(x) 写成矩阵乘法的形式:

(y0y1y2y3yn1)=(wn0wn0wn0wn0wn0wn0wn1wn2wn3wnn1wn0wn2wn4wn6wn2(n1)wn0wn3wn6wn9wn3(n1)wn0wnn1wn2(n1)wn3(n1)wn(n1)(n1))(a0a1a2a3an1)

其中的 W 矩阵叫做 Vandermonde Matrix

已知 A(x) 点值表达式,也就是已知 Y=(y0,y1,,yn1)W ,求 A(x) 的系数向量 (a0,a1,,an1)A=YW=Y×W1

(a0a1a2a3an1)=(wn0wn0wn0wn0wn0wn0wn1wn2wn3wnn1wn0wn2wn4wn6wn2(n1)wn0wn3wn6wn9wn3(n1)wn0wnn1wn2(n1)wn3(n1)wn(n1)(n1))1(y0y1y2y3yn1)

W逆矩阵(Inverse Matrix) W1 为:

1n(wn0wn0wn0wn0wn0wn0wn1wn2wn3wn(n1)wn0wn2wn4wn6wn2(n1)wn0wn3wn6wn9wn3(n1)wn0wn(n1)wn2(n1)wn3(n1)wn(n1)(n1))

因此我们可以得到以下公式:

ak=1nj=0n1yjwnkj

yk的获取:

yk=j=0n1ajwnkj

注意到我们只要对矩阵 W 中每一个元素取共轭复数并除以 n ,就得到了其 逆矩阵

FFT 代码实现

上面讲了这么多,下面开始编码实现吧:-)

Complex Number

复数可以使用C++ STL自带的 std::complex<T> ,依照精度要求 T 一般为 double ,也可以自己封装实现10

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct Complex {
double x, y;

Complex(double _x = 0.0, double _y = 0.0) {
x = _x;
y = _y;
}

Complex operator-(const Complex &b) const {
return Complex(x - b.x, y - b.y);
}

Complex operator+(const Complex &b) const {
return Complex(x + b.x, y + b.y);
}

Complex operator*(const Complex &b) const {
return Complex(x * b.x - y * b.y, x * b.y + y * b.x);
}
};

FFT 递归写法

我们可以按照我们刚才的推导,得到递归形式写法,代码如下所示:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
const double PI = acos(-1.0);    // PI = arccos(-1)

/**
* FFT 实现
*
* @param a
* @param invert true means IFFT, else FFT
* @return y
*/
vector<complex<double>> FFT(vector<complex<double>> &a, bool invert) {
//第一个参数为一个多项式的系数, 以次数从小到大的顺序, 向量中每一项的实部为该项系数
int n = a.size();

// 如果当前多项式仅有常数项时直接返回多项式的值
if (n == 1) {
return a;
}

vector<complex<double>> Pe(n / 2), Po(n / 2); // 文中的Pe与Po的系数表示法

for (int i = 0; 2 * i < n; i++) {
Pe[i] = a[2 * i];
Po[i] = a[2 * i + 1];
}

// Divide 分治
// 递归求 ye = Pe(xi), yo = Po(xi)
vector<complex<double>> ye = FFT(Pe, invert);
vector<complex<double>> yo = FFT(Po, invert);

// Combine
vector<complex<double>> y(n);

// Root of Units
double ang = 2 * PI / n * (invert ? -1 : 1);
complex<double> omega(cos(ang), sin(ang)); // omega为第一个n次复根,
complex<double> curRoot(1, 0); // curr为第零0个n次复根, 即为 1

for (int i = 0; i < n / 2; i++) {
y[i] = ye[i] + curRoot * yo[i]; // 求出P(xi)
y[i + n / 2] = ye[i] - curRoot * yo[i]; // 由单位复根的性质可知第k个根与第k + n/2个根互为相反数
curRoot *= omega; // cur * omega得到下一个复根
}

return y; // 返回最终的系数
}

由于 FFTIFFT 操作流都是系数不一致,所以我们可以将其写成一个函数。值得注意的是,IFFT 之后需要都系数除于 n 才是最终的结果。

复杂度分析

  • 时间复杂度:O(nlogn) ,其中 n 为数组长度,每次需要 O(n) ,总共 O(logn) 递归。

  • 空间复杂度:O(n) ,我们需要额外的 n 长度用于存储数据,函数递归栈需要空间是 O(logn) ,所以总空间复杂度为:O(n) + O(logn) = O(n)

总结

跨度一年,终于把这个 FFT 的坑填完了,虽然大学时学《信号与系统》时知道了 FFT ,但真正理解 FFT 还是要等到写完了这几篇文章。

下一篇讲聚焦于 FFT 的优化!

文章如有错误之处,敬请批评指正。

参考资料


  1. Wiki: Fast Fourier Transform↩︎

  2. Wiki: 快速傅里叶变换↩︎

  3. Wiki: 离散傅里叶变换↩︎

  4. Wiki: 泰勒级数↩︎

  5. 快速傅里叶变换算法↩︎

  6. 一小时学会快速傅里叶变换(Fast Fourier Transform)↩︎

  7. Wiki: Convolution theorem↩︎

  8. 一小时学会快速傅里叶变换(Fast Fourier Transform)↩︎

  9. Algorithm: Fast Fourier Transform↩︎

  10. 学习笔记 - 快速傅里叶变换↩︎