☰
用矩阵乘法拆解傅里叶变换:从DFT到FFT的原理与Python实践
2026/10/19 8:54:48 网站建设 项目流程

看到"傅里叶变换"四个字,搞技术的基本都绕不开。我自己做信号处理这些年,处理音频滤波、振动分析、通信解调,来来回回都在跟它打交道。但很多同行一上来就背公式、调库调包,很少有人认真想过另一条路——直接用矩阵乘法,把傅里叶变换一行一行拆开算出来。这听起来像是绕远路,但恰恰是这个"绕远路"的过程,能把傅里叶变换的数学本质看得清清楚楚。

这篇文章我想聊的就是这件事:傅里叶变换和矩阵乘法到底什么关系,为什么DFT本质上就是一次矩阵运算,以及与工程实际密切相关的采样率、频率分辨率、频谱泄露这些概念该怎么理解。文章后面还会附上完整的Python实操代码,我会带着你把傅里叶矩阵一行行写出来,亲手算出一个信号的频谱,再跟标准FFT结果做对比。不管你是做音频、做振动监测,还是单纯想把信号处理基础夯实,这篇内容都值得静下心来看完。

1. 傅里叶变换是在做什么

很多人学傅里叶变换的痛点是:公式一个比一个长,符号一个比一个怪,但始终没搞明白它"到底在算什么"。其实换个视角,用线性代数的语言来理解,事情会清晰得多。

1.1 从时域到频域,本质是一次坐标变换

先看连续傅里叶变换的经典公式:

F(ω) = ∫ f(t) * e^(-jωt) dt

再看离散版本(DFT):

X[k] = Σ_{n=0}^{N-1} x[n] * e^(-j2πkn/N), k = 0, 1, ..., N-1

如果你只是把这个公式背下来,数学上你掌握了变换,但工程直觉仍然缺失。我这里换一个说法:傅里叶变换的本质,是把一组用"时间"为坐标描述的数据,改写成一组长度的"频率分量"。它做的事就是一个坐标系的旋转——想象一个三维空间里,你从标准直角坐标系看某个矢量,它有一组坐标;你旋转坐标系再去看,同样的矢量会有另一组坐标。矢量没变,变得只是你看它的角度。

傅里叶变换也是同一个道理:信号不变,变的是描述信号的基。时域用的是无数个不同时刻的脉冲(单位冲激函数)作为基,频域用的是一族不同频率的正弦波作为基。变换的过程,就是计算原始信号在每个正弦波基上的"投影"有多大。

用生活化的类比来说:一杯混合果汁,由橙汁、苹果汁、西瓜汁按一定比例调出来。时域信号是那杯果汁的"整体口感";傅里叶变换做的事情,就是拿"纯橙汁""纯苹果汁""纯西瓜汁"逐一去比对,算出这杯混合饮品里每一种成分各占多少。这里每一种"纯果汁"就是一个频率分量,算出来的比例就是该频率下的幅度。

1.2 离散傅里叶变换的矩阵化表达

现在看DFT的计算方式。X[k]是输出,x[n]是输入,那e^(-j2πkn/N)是什么?它其实是一个关于k和n的二元函数。如果你把k作为行索引、n作为列索引,把这个函数在N×N个位置上取值填进矩阵,就会得到下面这个结构:

W[k][n] = e^(-j2πkn/N)

这个矩阵就是傅里叶矩阵。那么DFT的计算就可以从"一个求和公式"直接改写成矩阵乘法:

X = W · x

这里x是长度为N的输入向量,W是N×N的傅里叶矩阵,X是长度为N的输出向量。用Python的NumPy天然地就能处理:

import numpy as np def dft_matrix(N): n = np.arange(N) k = n.reshape((N, 1)) return np.exp(-2j * np.pi * k * n / N) N = 8 W = dft_matrix(N) x = np.random.randn(N) X = W @ x

代码看起来只有四五行,但它背后的信息量很大:傅里叶变换不比信号本身高级,它在数学上就是一次线性映射,完全符合矩阵乘法的所有规则——线性性、叠加性、可逆性,全部成立。所以你可以毫无障碍地把它当作一个普通的线性变换来处理。

2. 为什么矩阵乘法能算傅里叶变换

这一节我把数学细节摊开,看看矩阵乘法这条路是怎么走通的。不跳步,从旋转因子讲到完整计算。

2.1 旋转因子、欧拉公式与DFT矩阵的构造

矩阵里的每个元素W[k][n] = e^(-j2πkn/N),就是"旋转因子",业内常用符号W_N^(kn)来表示。它为什么叫旋转因子?因为e^(-jθ) = cos(θ) - j*sin(θ),代表复平面上角度为θ的一个单位向量,当k和n逐个变化时,这个角度也在绕圈旋转。

用N=4来举一个具体的例子,把公式展开。旋转因子e^(-j2πkn/4),k取0到3,n取0到3,得到4×4矩阵:

  • 第0行(k=0):所有元素都是e^0 = 1,这一行相当于"直流分量",把所有输入相加了
  • 第1行(k=1):元素依次为1, -j, -1, j,对应频率为1的正弦波采样
  • 第2行(k=2):元素依次为1, -1, 1, -1,对应最高频率的"交变"模式
  • 第3行(k=3):和第1行共轭对称,对应负频率分量

计算X = W·x,其实就是对x在这四个"模式"上分别做内积。内积值越大,x中包含该模式的成分就越多。这个理解方式非常直观,而且能帮你理解为什么N点DFT的频谱是左右对称的。

2.2 一个极简的4点DFT手算示例

我建议初学者找一个N=4的简单信号,真的动笔算一次。下面是信号x = [1, 0, -1, 0]的逐步计算过程。

写出4点傅里叶矩阵:

W = [ [1, 1, 1, 1], [1, -j, -1, j], [1, -1, 1, -1], [1, j, -1, -j], ]

然后做乘法:

X[0] = 1×1 + 1×0 + 1×(-1) + 1×0 = 0

X[1] = 1×1 + (-j)×0 + (-1)×(-1) + j×0 = 2

X[2] = 1×1 + (-1)×0 + 1×(-1) + (-1)×0 = 0

X[3] = 1×1 + j×0 + (-1)×(-1) + (-j)×0 = 2

结果说明:x里不含直流分量,主要能量集中在k=1和k=3(即正负频率),幅度都是2。这完全符合预期,因为x = [1, 0, -1, 0]就是一个正弦序列,频率正好对应k=1。

手算一次之后你会发现,所谓傅里叶变换并没有玄学,就是信号向量和一系列"模板"向量做内积。而矩阵乘法不过是用统一框架把这N次内积一次性算完。

2.3 复杂度对比:O(N²)为什么不够用

直接使用矩阵乘法计算DFT,运算量是N次内积,每次内积N次复数乘法,总复杂度O(N²)。这个复杂度在N比较小时看不出问题,但N一上去就扛不住了:

NO(N²) 复数乘O(N log N) FFT 复数乘加速比
864242.7x
25665536204832x
1024104857610240102x
65536约4.3亿约104万410x

这就是为什么1965年Cooley和Tukey的FFT算法被视为里程碑——它不是发明了一个新变换,而是找到了一种复用中间计算结果的方式,把复杂度从O(N²)降到了O(N log N)。但要注意,FFT算出来的结果跟矩阵乘法算出来的结果在数学上是完全一样的,一个是快速算法,一个是朴素算法,仅此而已。

3. 用Python亲手实现DFT矩阵乘法

这一节直接上可运行代码。我会把从零构造傅里叶矩阵到信号频谱还原的完整过程走一遍,最后与NumPy内置FFT做对比验证。

3.1 环境准备与依赖说明

需要的东西不多:Python 3.8以上,numpy库负责矩阵运算,matplotlib库负责画图。如果你还没装,一个命令搞定:

pip install numpy matplotlib

说明一下:我们自己写的dft_matrix函数使用的是复数运算,NumPy的数组原生支持复数类型,直接做矩阵乘法即可生成复数频谱。代码全程在Jupyter或任何普通.py脚本里都能跑。

3.2 构造傅里叶矩阵的完整代码

import numpy as np import matplotlib.pyplot as plt # 构造N点DFT矩阵 def dft_matrix(N): n = np.arange(N) k = n.reshape(N, 1) # 使k变成列向量,n变成行向量 return np.exp(-2j * np.pi * k * n / N) # 生成测试信号:两个正弦波 + 直流偏置 def generate_test_signal(N, fs): t = np.arange(N) / fs signal = 1.0 + 0.5 * np.sin(2 * np.pi * 50 * t) + 0.25 * np.sin(2 * np.pi * 120 * t) return t, signal N = 128 fs = 1024.0 t, signal = generate_test_signal(N, fs) # 用矩阵乘法做DFT W = dft_matrix(N) X_matrix = W @ signal

这个dft_matrix函数只用一行NumPy表达式就完成了双重for循环的工作:k被reshape成(N, 1)列向量,n是(1, N)行向量,两者广播相乘得到一个N×N矩阵,再套上指数函数。这不是什么黑魔法,就是NumPy广播机制的常规运用。

3.3 与标准FFT结果对比验证

同一个信号,再用numpy.fft.fft算一遍,然后把两条路径的结果做对比:

X_numpy = np.fft.fft(signal) # 最大绝对误差 max_error = np.max(np.abs(X_matrix - X_numpy)) print(f"最大绝对误差: {max_error:.2e}") # 对比幅度谱 amp_matrix = np.abs(X_matrix) / N amp_numpy = np.abs(X_numpy) / N freqs = np.fft.fftfreq(N, d=1/fs)

实际运行时你会发现最大误差通常在1e-13量级,这是浮点数精度范围内的误差,完全可以忽略。这验证了一个关键结论:FFT和矩阵乘法DFT在数学上是等价的,只是计算路径不一样。

3.4 可视化完整频谱

把结果画出来,可以直观看到信号的频率成分:

plt.figure(figsize=(10, 5)) plt.subplot(1, 2, 1) plt.plot(t, signal) plt.title("时域信号") plt.xlabel("时间 (s)") plt.ylabel("幅度") plt.subplot(1, 2, 2) plt.plot(freqs[:N//2], amp_matrix[:N//2], marker='o', markersize=4, linestyle='-', linewidth=1) plt.title("频域幅度谱(单边)") plt.xlabel("频率 (Hz)") plt.ylabel("幅度") plt.grid(True) plt.tight_layout() plt.show()

频谱图上你会在50Hz和120Hz位置看到两根清晰的谱峰,幅度分别对应0.5和0.25,另外0Hz处有直流分量1.0。三个数字和构造信号时输入的参数一一对应,说明整个流程是通的。

4. 频域分析的几个关键参数

矩阵乘法DFT给了一个可逐点追溯的计算模型,但它不会替你回答工程上的参数问题。实际分析信号时,采样率、采样点数、窗函数才是一步一步决定成败的地方。

4.1 采样率规划与频率分辨率计算

频率分辨率是这个公式:

Δf = fs / N

fs是采样率,N是采样点数。它表示频谱上相邻两个频点之间的间隔,也是你能分辨的最小频率差。举个工程场景:你监测一台旋转设备的振动,主轴转速30Hz,轴承缺陷特征频率是97.5Hz,两套频率并不接近,用fs=1024Hz、N=1024的点数就够。但如果你需要分辨48Hz和48.5Hz两个几乎重叠的信号,就必须把N加大或者降低采样率。

还有一条铁律:采样率必须大于信号最高频率的两倍,这就是奈奎斯特采样定理。工程上保险做法是让最高频率分量远远低于fs/2,工程留出通带余量,否则频谱会出现混叠,高频分量折叠到低频区域,数据完全失真。设计测量流程时,一般先按照预期的最高频率把fs定下来,再根据需要的分辨率决定N。

4.2 频谱泄露与加窗处理的必要性

直接对一段截断信号做DFT,等效于用一个矩形窗截断了原始波形。矩形窗的两端导数不连续,会在频谱上产生旁瓣,把本来集中在单一频点的能量"涂抹"到相邻频点上,这就是频谱泄露。

针对这个问题的标准做法是加窗。汉宁窗、汉明窗、布莱克曼窗都是工程里常用的窗函数。窗函数的作用是抑制截断边缘的突变,让信号的两端平滑落到接近零。

window = np.hanning(N) signal_windowed = signal * window X_windowed = dft_matrix(N) @ signal_windowed amp_windowed = np.abs(X_windowed) / N

加窗之后,主瓣变得更宽,但旁瓣大幅降低,频率泄漏明显缓解。代价就是频率分辨率略有下降。在检测中,你可以用窗函数把中小幅值的邻近频率分量从噪声里捞出来;而对于宽带随机信号,不需要加窗处理,因为矩形窗本身的行为就可以接受。

5. 从朴素矩阵乘法到快速算法

直接矩阵乘法DFT在工程上很少用,因为复杂度太高。但理解了矩阵形态之后,再去看FFT会有一种豁然开朗的感觉——它不是"另起炉灶",而是利用傅里叶矩阵的对称性和周期性把计算量压了下来。

5.1 旋转因子的两个核心性质

傅里叶矩阵中旋转因子W_N = e^(-j2π/N)有两个关键性质。

一是周期性:W_N^(k+N) = W_N^k。指数部分加了一个N之后,角度转回原处,值不变。

二是对称性:W_N^(k+N/2) = -W_N^k。角度转了半圈,符号正好反过来。

正是这两条性质,让很多中间结果可以被反复使用,从而开启递归分解的可能。

5.2 偶奇分解与蝶形运算

Cooley-Tukey FFT的核心思想,是将N点DFT按n的奇偶性拆成两个N/2点DFT。拆开之后:

X[k] = X_even[k] + W_N^k * X_odd[k]

这个式子里X_even和X_odd分别是偶序号点和奇序号点各自做N/2点DFT的结果。k取后半段时,因为旋转因子的对称性,可以把计算转换为:

X[k + N/2] = X_even[k] - W_N^k * X_odd[k]

这两条式子合成一次"蝶形运算",画出来就是两只输入、两只输出的交叉结构。全程不断二分,直到N=2不能再拆为止,总共只有log2(N)层,每层N/2次蝶形操作,复杂度就变成了O(N log N)。

5.3 实测性能差距

我拿N=16384做了个实际对比。同一台笔记本上,矩阵乘法DFT大约需要数秒,而FFT只需要几毫秒。差距接近三个数量级。这就是算法优化的力量所在——FFT没有改变DFT的数学定义,只是改变了计算路径,省掉了大量重复的乘法。

从工程角度看,这个差距意味着实时信号处理系统中你几乎只能用FFT。但在学习、教学、分析工具中,矩阵乘法DFT依然无价:它逻辑直接,每一步都可验证,不会被黑盒运算掩盖细节。

6. 常见问题与排查实录

实操过程中会遇到一些典型问题。我把它们整理成一个速查表,再分享几个个人觉得很有价值的排查思路。

6.1 典型问题速查表

现象可能原因排查方法
频谱幅度与信号实际幅值不符忘记除以N或没有区分单双边谱确认幅度谱定义:单边谱除以N再乘2
谱峰位置偏移,频率读数不对采样率fs与频率轴生成方式不匹配检查np.fft.fftfreq(N, d=1/fs)的参数
低频出现大片连续突起信号含有直流分量或慢漂移先做去均值处理,再用高频高通滤波
加窗后频谱峰值变小加窗降低了信号总能量工程上常用幅度恢复系数进行补偿
计算速度极慢N取值过大或内部使用双重for遍历改用NumPy广播构造矩阵,N大时使用FFT
相位谱乱跳幅度接近零的频点相位无意义只用幅度大于阈值的频点显示相位

6.2 几个值得记录的经验

第一,写DFT矩阵时最容易错的不是矩阵公式,而是索引方向。我见过不少初学者把行和列反了,出来的结果频域翻转。用X = W @ x时,W的行必须是k、列必须是n,手算过一次N=4后再想反都难。

第二,做幅值谱分析时先去看时域信号有没有直流分量。一个常见的错误是:时域均值明明是5V,FFT后在0Hz出现一个大到不合理的谱线,把其他频率的细节全部掩盖了。先在时域做一次去均值操作,很多问题当场解决。

第三,判断代码是否写错有一个高效手段:构造一个已知信号(比如一个单频正弦再加零均值噪声),先用FFT算一遍,再和矩阵乘法结果对比最大误差。如果误差在1e-12量级,你的实现基本没问题;如果误差达到1e-3量级以上,一定是逻辑错误,而不是浮点误差。

第四,如果信号点数不是2的幂次,标准FFT库会自动填充零或者使用混合基算法,依然能计算。但自己写机器码级FFT实现时,2的幂次的输入最保险,工程上也最常见。

7. 手动实践的收获与建议

从矩阵乘法的角度重新理解傅里叶变换,实际做一遍下来,收获比想象中大。首先是彻底弄清了DFT的代数结构——它不过是一个线性变换,一个正交变换,信号处理里的滤波、去噪、频谱分析,统统建立在这个线性框架上。其次是理解了FFT到底优化了什么:它优化的是计算路径,而不是数学定义。

如果你想自己动手加深印象,我建议做三件事:

  • 第一个建议跟信号构造有关。先去构造一个由直流分量、50Hz正弦、120Hz正弦叠加的测试信号,分别用矩阵乘法和FFT计算频谱,验证两种方法结果的一致性。再来一次加窗对比,直观感受频谱泄漏如何缓解。

  • 第二个建议是从公式出发,用N=8写一个按偶奇分解的手工FFT实现。不用递归,就用循环加数组索引,把每一层蝶形结果都打印出来,你会发现FFT结构清清楚楚,再也不觉得它是个黑盒子。

  • 第三个建议是用自己写的DFT矩阵去做一个逆变换,验证X = W·x和x = W⁻¹·X是否完全对得上。傅里叶矩阵的逆与共轭转置有特殊关系,亲手验证一次,你对变换本质的理解会再深一层。

我的经验是:凡是肯花时间把矩阵乘法这条路走一遍的人,之后用FFT时心里都是踏实的——因为你知道每一个数字是怎么来的,知道什么时候该信它,什么时候该怀疑它。希望在读完这篇内容之后,你也可以把傅里叶变换这一页真正翻过去。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询