news 2026/10/14 1:35:29

手写基2时间抽取FFT(DIT-FFT):从DFT到蝶形运算与位反转

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写基2时间抽取FFT(DIT-FFT):从DFT到蝶形运算与位反转

前面三篇笔记把频域滤波的主线推到了DFT这一步:信号变到频域,乘上滤波器传递函数,再变回时域。思路很顺,可实际操作时有个过不去的坎——DFT按定义直接算,计算量是O(N²)。我印象很深,有次做一个采集分析Demo,8K采样率、每次处理4096点,按DFT定义硬算一帧频谱,开发机都要明显卡一下;换成性能弱一些的板子,帧间隔根本压不住,做实时的念头直接泡汤。后来老老实实换成FFT,同样帧长瞬间出结果,差距完全不在一个量级。

这篇笔记就专门拆解FFT里最常用的一支:基2时间抽取FFT(DIT-FFT)。它不依赖任何库,从推导到代码都能自己写完,也是后续做频域滤波正反变换时绕不开的内核。适合刚把DFT公式背熟、想知道“计算机里到底怎么算傅里叶变换”的读者,也适合想手写FFT用于移植或教学的人。

1. DFT直接算到底贵在哪:从一次实测说起

1.1 一组数据感受一下N²的量级

DFT的定义式长这样:X(k) = Σ_{n=0}^{N-1} x(n) * W_N^{nk},其中 W_N = e^{-j2π/N},k 取 0 到 N-1。

直接按这个公式实现,每个 X(k) 都要做 N 次复乘,一共 N 个频点,所以总复乘数是 N²。N=8 的时候 64 次,无所谓;N=256 的时候 65536 次,也还能忍。但到了 N=1024,就是一百多万次复乘;N=8192,差不多六千七百万次;N=16384,直接奔着两亿七千万次去了。

两亿七千万次复乘听起来是个数字,放到嵌入式环境里就是秒级的处理时间。而你做频域滤波时,FFT 只是链条里的一环,前面要加窗,后面要频域相乘、再做 IFFT,任何一个环节卡住,整条链路都跑不起来。这也是为什么 Cooley 和 Tukey 在 1965 年重新整理出 FFT 算法后,数字信号处理才算真正进入工程时代——复杂度从 O(N²) 降到 O(N log₂N),N=16384 时大约只需要 114688 次复乘,差了接近两千三百倍。

1.2 旋转因子的三个性质:周期、对称、可约

FFT 能省算力,核心不是发明了什么新运算,而是把 DFT 里大量重复的复数乘法合并掉了。重复的根源在旋转因子 W_N^{nk} 身上,它有三个教科书一定会写、但初学者不太当回事的性质:

  • 周期性:W_N^{k+N} = W_N^k,意思是旋转因子按 N 循环,顺序转一圈又回到原点。
  • 对称性:W_N^{k+N/2} = -W_N^k,等价于“转了半圈就是相反方向”。
  • 可约性:W_{aN}^{ak} = W_N^k,上下同乘一个倍数,指数可以约分。

这三个性质看起来平淡,实际上蝴蝶效应的起点。比如 W_8^2 = W_4^1,W_16^4 = W_4^1,同一个旋转因子可以被许多频点复用。朴素的 DFT 把每个 X(k) 当独立任务,各算各的;FFT 则是把共用的旋转因子提取出来,用分治法的骨架把任务层层拆小,让一个乘法结果能被多个输出复用。

1.3 “合并同类项”这个方向是怎么来的

如果你自己动手推导过 N=4 或者 N=8 的 DFT,会注意到很多 W_N^{nk} 的值是一样的,甚至有些因为对称性可以加个负号直接复用。我最早手算 N=8 的 DFT 时,就发现某些中间乘法在多个频点里重复出现,当时只是觉得“这有点浪费”,还没想到能做成通用算法。后来才理解,FFT 的路线就是把这种“浪费”系统化:先把问题拆成子问题,子问题里的公共计算只做一次,再通过蝶形运算把结果合并起来。

2. 基2时间抽取的完整推导:一分为二、二分为四

2.1 为什么按奇偶分,而不是按前后分

“时间抽取”这个名字的来由就是:把时域序列按照序号 n 的奇偶性拆成两组。有人会问,为什么不按前半段、后半段分?答案是:按前后分也能拆出递归结构,但旋转因子没法干净地约分成 N/2 点 DFT;按奇偶分,x(2r) 和 x(2r+1) 天然让旋转因子的指数变成 2nk,正好套用可约性 W_N^{2nk} = W_{N/2}^{nk},子问题就变成两个纯粹的 N/2 点 DFT。这一步是整个算法结构的支点。

2.2 三步展开:奇偶分列、代入约分、整理成蝶形

设 N 为 2 的幂,记 W_N = e^{-j2π/N}。把 x(n) 按 n 的奇偶拆开:

X(k) = Σ_{r=0}^{N/2-1} x(2r) W_N^{2rk} + Σ_{r=0}^{N/2-1} x(2r+1) W_N^{(2r+1)k}

第一项里 W_N^{2rk} = W_{N/2}^{rk},第二项里 W_N^{(2r+1)k} = W_N^k * W_{N/2}^{rk}。于是:

X(k) = G(k) + W_N^k * H(k)

其中 G(k) = Σ_{r=0}^{N/2-1} x(2r) W_{N/2}^{rk},是偶数点子的 N/2 点 DFT;H(k) = Σ_{r=0}^{N/2-1} x(2r+1) W_{N/2}^{rk},是奇数点子的 N/2 点 DFT。

2.3 后半段频点:X(k+N/2) 的四行推导

现在关键来了。G(k) 和 H(k) 只有 N/2 个不同值(k 从 0 到 N/2-1),但 X(k) 需要 N 个输出。剩下的一半怎么来?把 k 换成 k+N/2 代入:

X(k+N/2) = G(k+N/2) + W_N^{k+N/2} H(k+N/2)

因为 G 和 H 都是 N/2 点 DFT,它们满足周期性:G(k+N/2) = G(k),H(k+N/2) = H(k)。再配合旋转因子的对称性 W_N^{k+N/2} = -W_N^k,于是:

X(k+N/2) = G(k) - W_N^k * H(k)

这一个加号、一个减号,就是蝶形运算的原型。把 G(k) 和 W_N^k H(k) 当作两个输入,一次蝶形同时产出两个输出:X(k) 和 X(k+N/2)。整个 FFT 就是不断重复这个“两输入、两输出”的结构。

2.4 递归到底:2点DFT就是一次蝶形

子问题 N/2 点 DFT 还能继续按奇偶拆分,拆到 N/2=1 为止。但实践中拆到 2 点就够了,因为 2 点 DFT 本身就是蝶形:X(0) = x(0)+x(1),X(1) = x(0)-x(1),正好对应 W_2^0 = 1 的蝶形。所以基2 FFT 的“基”指的就是每层只按二进制的一位拆分,一直分到 2 为止。

从复杂度看,每分一层,子问题变成两个半尺寸 DFT,加上 N/2 次蝶形合并。递归关系式 T(N) = 2T(N/2) + N/2,解出来正好是 N/2 * log₂N 次复乘。

3. 蝶形运算与8点FFT的手工推演

3.1 蝶形单元的输入输出关系

蝶形运算的接口很固定:两个复数输入 a、b,一个旋转因子 W,输出 A = a + Wb,B = a - Wb。一次蝶形只做一次复乘、两次复加。所谓“就地计算”,就是算完后把 A 存回 a 原来的位置,B 存回 b 原来的位置,后续级数不再需要旧值。

我用生活化的比喻:DFT 是整个合唱团按乐谱各唱各的;FFT 是一群人对拍子,每一轮两两一组对一下,对完就同步一部分信息,下一轮再扩大组合。蝶形就是最小的“对拍子”动作。

3.2 旋转因子在每一级怎么摆

N=8 时一共 3 级,每级 4 个蝶形。旋转因子的位置不是乱放的,规律如下:

级数 L蝶形组数每组蝶形数蝶距旋转因子
1411W_8^0
2222W_8^0,W_8^2
3144W_8^0,W_8^1,W_8^2,W_8^3

一般规律:第 L 级,蝶距为 2^{L-1},组数为 N / 2^L,第 j 个蝶形(j 从 0 到 2^{L-1}-1)使用旋转因子 W_N^{j*N/2^L}。注意 W_8^2 = W_4^1,W_8^6 之类其实都被可约性消化掉了,代码里可以直接用当前级的 step 来计算。

3.3 手工走一遍N=8:用x[n]=n验证

我拿一个最简单的信号 x[n] = n(n=0..7)完整走一遍,方便你对着验证。先做位反转排序(下一节细讲),输入顺序变为 [0,4,2,6,1,5,3,7]。

第 1 级:蝶距 1,两两相加相减(W=1)。

第 2 级:蝶距 2,两组蝶形,旋转因子用 1 和 -j。中间我算到数组变成:

[12, -4+4j, -4, -4-4j, 16, -4+4j, -4, -4-4j]

第 3 级:蝶距 4,旋转因子分别是 1、W_8^1、-j、W_8^3,其中 W_8^1 ≈ 0.7071-0.7071j,W_8^3 ≈ -0.7071-0.7071j。这一步会得到最终结果。

k实部虚部
0280
1-49.6568
2-44
3-41.6568
4-40
5-4-1.6568
6-4-4
7-4-9.6568

这个结果和标准FFT库算出来完全一致。我建议你拿到代码后第一件事就跑这个用例,只要这 8 个复数对得上,说明位反转、旋转因子方向、蝶形顺序全部正确,后面再换复杂信号都稳。

3.4 计算量复核

N=8 时,直接 DFT 需要 64 次复乘;DIT-FFT 每级 4 次蝶形、共 3 级,总复乘 12 次,节省 5 倍多。N=1024 时节省接近 200 倍,这个差距会随 N 增大越来越夸张。理解这个数字,你就明白为什么频域滤波的链条里,FFT 是绕不开的底桩。

4. 位反转排序:最容易出错且藏得最深的环节

4.1 为什么会有位反转

时间抽取要求先把序列按“二进制最低位”分组:偶数放前半、奇数放后半,下一层再按次低位分组,继续递归。这个过程如果直接体现在数据位置上,最终输入数组的排列顺序就是把每个下标按二进制倒过来。比如下标 1(001)会被换到 4(100),下标 3(011)换到 6(110)。

这个现象我上第一次课的时候没反应过来,后来自己画 N=8 的递归树才看明白:每一层按一个二进制位分左右,所有层叠起来,最终位置恰好是原下标的比特反转。

4.2 N=8的位反转对照表

原下标二进制反转后实际下标
00000000
10011004
20100102
30111106
41000011
51011015
61100113
71111117

所以输入 [0,1,2,3,4,5,6,7] 排完位反转是 [0,4,2,6,1,5,3,7]。这个顺序我上面手工推演里直接用了,现在你明白它是怎么来的了。

4.3 用迭代算法生成位反转序号

一个很简单的方法:对每个 i 单独“求反转数再交换”,但那样效率不高。标准工程写法是用一个迭代器在循环中推导下一个反转序号,不需要查表:

int j = 0; for (int i = 0; i < N - 1; i++) { if (i < j) { swap(x[i], x[j]); } int k = N >> 1; while (j >= k) { j -= k; k >>= 1; } j += k; }

这个想法的本质是:从当前 j 出发,模拟“二进制加 1 再反转”的进位过程。建议你逐行走一遍 N=8 的情况,走完就彻底记住:它输出序列是 0,4,2,6,1,5,3,7。我第一次用这段代码时没走查证,直接套到了 N=16 上,结果频谱乱了半天,最后才发现是位反转写错了一位掩码。

4.4 如果不想显式做位反转

有一个偷懒的办法:用递归实现 FFT。递归调用天然把偶数序列和奇数序列分到两个子数组,数据位置不需要预先重排,递归返回后再合并。代价是要不断分配子数组,内存操作多,性能不如迭代版。我的建议是学习阶段用递归理解原理,工程实现还是用迭代版加位反转,速度更稳。

5. 手写DIT-FFT:C与Python双版本实现及验证

5.1 C语言版本:就地运算

#include <math.h> #include <complex.h> void dit_fft(complex float x[], int n) { // 参数n必须为2的幂 int i, j, k, L, step, half, t, m; // 位反转 j = 0; for (i = 0; i < n - 1; i++) { if (i < j) { complex float tmp = x[i]; x[i] = x[j]; x[j] = tmp; } k = n >> 1; while (j >= k) { j -= k; k >>= 1; } j += k; } // 蝶形运算 for (L = 1; (1 << L) <= n; L++) { step = 1 << L; half = step >> 1; for (t = 0; t < n; t += step) { for (m = 0; m < half; m++) { double angle = -2.0 * M_PI * m / step; complex float w = cos(angle) + I * sin(angle); int a = t + m; int b = t + m + half; complex float u = x[b] * w; x[b] = x[a] - u; x[a] = x[a] + u; } } } }

C 版本直接用 C99 的 complex float,代码很短。需要注意旋转因子角度我写成-2π*m/step,对应教科书里的 W_N^{m*N/step},符号约定别搞反,否则频谱会沿中心翻转。

5.2 Python版本:便于验证和画图

import cmath def bit_reverse(x): n = len(x) j = 0 for i in range(1, n): bit = n >> 1 while j & bit: j ^= bit bit >>= 1 j |= bit if i < j: x[i], x[j] = x[j], x[i] def dit_fft(x): n = len(x) bit_reverse(x) levels = 0 while (1 << levels) < n: levels += 1 for L in range(1, levels + 1): step = 1 << L half = step // 2 for t in range(0, n, step): for k in range(half): w = cmath.exp(-2j * cmath.pi * k / step) a = t + k b = t + k + half u = x[b] * w x[b] = x[a] - u x[a] = x[a] + u x = [0 + 0j, 1 + 0j, 2 + 0j, 3 + 0j, 4 + 0j, 5 + 0j, 6 + 0j, 7 + 0j] dit_fft(x) print(x)

这段代码跑出来的结果,和前文表格里的 8 个复数值完全一致。我的建议是先用 Python 把每个中间数组打印出来,跟手算过程逐级对照,把蝶形顺序彻底弄清,再动手写 C 版,这样排错成本低很多。

5.3 IFFT怎么复用同一套蝶形

频域滤波最终要把结果变回时域,所以 IFFT 也得有。一个经典技巧:IFFT(X) = conj(FFT(conj(X))) / N。也就是说,把输入先取共轭,跑一遍 FFT,再取共轭、除以 N。这个方法对 DIT 结构完全适用,不需要改任何蝶形代码。

我在实际写频域滤波 Demo 时就用的这招,一份 FFT 函数包含正反变换,省事且不容易引入第二套旋转因子 bug。唯一要记住的是 IFFT 最后那个除以 N,很多刚入门的同学会漏掉,滤波输出幅度直接差 N 倍。

5.4 常见坑列表

现象根因
输出频谱左右颠倒旋转因子指数符号写反
结果等于 0 或错乱位反转没做对,输入顺序不对
幅值差 N 倍IFFT 忘记除以 N
长度为非 2 的幂时崩溃代码假设基2,需要先补零
长序列结果有微小“毛刺”浮点累计误差,正常现象

6. 旋转因子表、归一化和其他绕不开的工程细节

6.1 预计算旋转因子表:性能翻倍的做法

上面的代码里每个蝶形都现场调用 cos/sin,这在 PC 上问题不大,但在嵌入式或数据帧率很高的场景,三角函数开销不可忽略。工程做法是提前把旋转因子存成一张表:长度为 N/2,第 k 项存 W_N^k。这样蝶形内部只需要查表,省去大量浮点函数调用。

我自己的经验:N=4096 时,预计算 VS 现场计算的耗时差距在普通开发机上大概是 20%-30%,帧率越高越明显。代价是内存多占用 N/2 个 float 复数对,N 不超过几万的话完全值得。

6.2 归一化与频谱顺序

FFT 结果不自动归一化,这也是初学者最懵的地方。比如 x[n]=1 的 8 点 FFT,X[0]=8,不是 1。做频域滤波时通常把滤波器传递函数和 FFT 结果相乘,再到 IFFT 时除以 N,所以真正严格的正逆变换要配成一对:FFT 不除,IFFT 除,整体就还原了。

频谱顺序还有个细节:FFT 输出下标 0 是直流,1 到 N/2-1 是正频率,N/2 是奈奎斯特频率,N/2+1 到 N-1 对应负频率。频域滤波如果只想保留某个频率区段,要搞清楚负频率部分的位置,否则滤完的波形会不对称。这点在前几篇笔记里提过,这里再强调一次:先 fftshift 理清频率轴,再设计滤波器响应,能省一整轮调试。

6.3 非2的幂长度:补零或换算法

基2 FFT 最吸引人,但现实信号帧长不一定恰好是 2 的幂。两个选择:一是补零到最近 2 的幂,比如 1500 点补到 2048 点,简单好用,但会引入频率分辨率变化;二是改用混合基 FFT,比如 3、5 等质因数分解,都能基于类似的分治思想实现。我建议绝大多数场景先补零,代码简单、足够可靠。如果业务对分辨率和算力都敏感,再研究混合基。

补零还有个副作用是频谱形状会稍微变“圆滑”,但那只是插值效果,不是信息变多。分析时心里有数即可。

6.4 FFT/IFFT在频域滤波链路里的完整配合

回到这篇笔记的起点——频域滤波。一条完整的链路是:时域分段加窗 → FFT → 频域乘传递函数 → IFFT → 重叠相加或去窗恢复 → 输出时域。其中 FFT 和 IFFT 一共要跑两次,所以我建议把正反变换封装成同一个接口,加一个inverse标志:

  • forward:只做 FFT,不缩放;
  • inverse:共轭技巧 + FFT + 除以 N。

我在做 Demo 时就是这么封的,好处是后续如果要换混合基、加窗、加帧重叠,调用层完全不用动。

6.5 最后一个我个人的小建议

调试这类算法,最忌讳上来就喂真实信号。真实信号混合了噪声、偏置、多频分量,FFT 输出一乱根本不知道哪一步错。我的固定套路是先用 x[n]=n、x[n]=sin(2π*n/N) 这类解析解已知的信号做回归测试,确认 8 点、16 点、1024 点结果全对,再上真实数据。这套流程虽然朴素,但帮我避过好几次位反转和旋转因子符号的坑。

基2时间抽取 FFT 看似公式推导只有几行,真正让它跑起来的是蝶形顺序、位反转、旋转因子方向这些细节。把这一块吃透,后面的频域滤波、加窗、重叠保留法都顺了。下一篇笔记可以接着写频率抽取 FFT 和它与时间抽取的对称性,到时候你会发现,蝶形的内核其实都是同一件事。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 1:35:26

广东省shp地图处理全攻略:从坐标清洗到QGIS可视化

简介&#xff1a;广东省shp地图数据集以Shapefile矢量格式封装&#xff0c;完整覆盖省界、市级与县级行政区划&#xff0c;并附带产业专题与人口专题图层&#xff0c;面向GIS制图人员、规划研究者及数据分析师。在ArcGIS等专业软件中可直接调用&#xff0c;用于行政区划底图制作…

作者头像 李华
网站建设 2026/10/14 1:35:25

MATLAB/Simulink双离合变速器仿真建模与标定实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:34:32

Minecraft数据包开发:零插件实现功能扩展的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:34:18

CST仿真自动化环境配置全攻略:Python与VBScript双路线实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:34:17

代码早就不值钱了:2026年AI建站真正的门槛,是“设计品味”

摘要&#xff1a;2026年&#xff0c;AI建站领域最魔幻的现实是&#xff1a;代码生成已不是问题&#xff0c;设计品味才是真正的门槛。 当OpenPage让JSON成为网站的唯一事实来源&#xff0c;当万小智2.0打通从需求理解到备案上线的全链路&#xff0c;当web-design-skill把“反AI…

作者头像 李华