专栏文章

快速傅里叶变换(FFT)学习笔记

算法·理论参与者 17已保存评论 20

文章操作

快速查看文章及其快照的属性,并进行相关操作。

当前评论
20 条
当前快照
1 份
快照标识符
@mipss8sg
此快照首次捕获于
2025/12/03 17:21
3 个月前
此快照最后确认于
2025/12/03 17:21
3 个月前
查看原文

快速傅里叶变换(FFT)学习笔记


前言

这是我第二篇学习笔记,上一篇 SAM 的在这里
我一般不写学习笔记,除非太难理解或者算法本身太过巧妙。
不同于 SAM,这次写学习笔记的原因是因为其太过巧妙了。
前置知识:矩阵,弧度制,三角函数,代数基础,函数基础,复数概念。
参考资料:这里。本学习笔记详细了视频的内容,然后那个视频还是有错误的,我将在这里指正其错误。
如果任何一句话您看不懂,请先尝试找 DeepSeek,它很聪明。如果再看不懂,请评论或者私信我。

01. 引入

如果我们有两个多项式 A(x)=x2+3x+2A(x)=x^2+3x+2B(x)=2x2+1B(x)=2x^2+1
我们要把他相乘得到 C(x)=A(x)B(x)C(x)=A(x)B(x),我们平时是怎么做的?
在数学上,我们使用乘法分配律,如下:
C(x) & =(x^2+3x+2)(2x^2+1) \\ & =x^2(2x^2+1)+3x(2x^2+1)+2(2x^2+1) \\ & =2x^4+x^2+6x^3+3x+4x^2+2 \\ & =2x^4+6x^3+5x^2+3x+2 \end{aligned}$$ 这是数学上的做法。 在计算机中,首先一个重要的事情是,我们如何存储这个多项式? 最自然的做法是用数组存储多项式的系数,如下: $A(x)=2+3x+x^2\Rightarrow A=[2,3,1]$。 $B(x)=1+2x^2\Rightarrow B=[1,0,2]$。 $C(x)=2+3x+5x^2+6x^3+2x^4\Rightarrow C=[2,3,5,6,2]$。 注意,指数从低到高,因为这样的话,数组的第 $k$ 项正好对应多项式的 $k$ 次项系数(数组从 $0$ 开始)。 我们称这种做法是多项式的**系数表示法**。 一般地,给定两个 $d$ 次多项式,相乘后会得到 $2d$ 次多项式。 所以我们会枚举,然后一一配对,系数相乘指数相加,贡献到对应位置。 这样做是 $O(d^2)$ 的,因为我们 $A$ 的每一项都要与 $B$ 的每一项相乘。 我们能优化吗? 有请 FFT(Fast Fourier Transform,快速傅里叶变换)! --------------------- # 02. 快速傅里叶变换 ## 多项式的值表示法 从最简单开始看。一个一次多项式 $f(x)=kx+b$。 这是不是我们小学二年级学过的一次函数(直线)啊? 根据学前班的知识,两点确定一条直线。 所以我们随便取两个不同的点 $x_0,x_1$,这条 $f(x)=kx+b$ 可以被我们表示成 $[(x_0,f(x_0)),(x_1,f(x_1))]$。 同样的,二次多项式能用 $3$ 个点表示;$d$ 次多项式能用 $d+1$ 个点表示。 简单证明一下: > 考虑一个多项式 $p(x)=a_0+a_1x+a_2x^2+\cdots+a_dx^d$。 > > 现在我们需要表示出 $a_0,a_1,\cdots,a_d$ 一共 $d+1$ 个数字。 > > 我们扔 $d+1$ 个不相同的点进去,能形成 $d+1$ 个方程。 > > $d+1$ 个未知数 $d+1$ 个方程,我们是不是能唯一确定未知数的值啊? > > 证毕。 我们称用点表示多项式的方法,叫做多项式的**值表示法**。 这样表示,我们多项式相乘就方便了很多。为什么呢? 举个例。我们把 $A(x)=x^2+2x+1$ 和 $B(x)=x^2-2x+1$ 的图像画出来: ![](https://cdn.luogu.com.cn/upload/image_hosting/ld5zosdl.png) 红色的是 $A$,蓝色的是 $B$。 我们用 $x=-2,-1,0,1,2$ 表示 $A=[(-2,1),(-1,0),(0,1),(1,4),(2,9)]$ 和 $B=[(-2,9),(-1,4),(0,1),(1,0),(2,1)]$。 为什么要选取 $5$ 个 $x$ 呢?因为 $A$ 和 $B$ 都是二次多项式,相乘后是 $4$ 次多项式,要用 $5$ 个点表示。 然后用幼儿园学到的竖式计算: $$\begin{array}{lr} &[(-2,1),(-1,0),(0,1),(1,4),(2,9)]\\ &\times[(-2,9),(-1,4),(0,1),(1,0),(2,1)]\\ &\overline{[(-2,9),(-1,0),(0,1),(1,0),(2,9)]} \end{array}$$ 原理很简单,$C(-2)=A(-2)\times B(-2)=1\times 9=9$,以此类推。 这样我们就得到了 $C=[(-2,9),(-1,0),(0,1),(1,0),(2,9)]$。 把这几个点描在图像上: ![](https://cdn.luogu.com.cn/upload/image_hosting/eb2e1aa4.png) 这几个点唯一确定一个四次函数 $C(x)=x^4-2x^2+1$(先不用管是怎么算出来表达式的): ![](https://cdn.luogu.com.cn/upload/image_hosting/q1j0tp85.png) 好了,现在我们已经把乘法从 $O(d^2)$ 优化成了 $O(d)$。 现在问题是:怎么把系数表达式转化成值表达式,又怎么把值表达式转回系数表达式。 ------------------- ## 求值 其实求值就是**从系数表示转成值表示**的过程。 想想最暴力的方法是什么。是不是随便取 $d+1$ 个点然后算函数值? 但是这样还是 $O(d^2)$ 的,因为我们每一个点要用 $O(d)$ 时间算值,又有 $d+1$ 个点。 试着简化问题,若有一个 $f(x)=x^2$,我们要取 $n=8$ 个点($n$ 理论上是可以随便决定的,只需要满足 $n\ge d+1$)。 ------------------- ### 奇偶函数 由于我们想减少计算量,考虑这样的一对点:我们知道了其中一个的值,就能立刻知道另一个。 这些点可以是这样的: $$\fbox{$\begin{array}{}(1,1),(-1,1)\\(2,4),(-2,4)\\\cdots\\(4,16),(-4,16)\end{array}$}$$ 我们只需要取相反数的点对即可。 这其中的原理很简单,$f$ 是一个偶函数(即 $f(x)=f(-x)$)。 如果三次函数呢? $$\fbox{$\begin{array}{}(1,1),(-1,-1)\\(2,8),(-2,-8)\\\cdots\\(4,64),(-4,-64)\end{array}$}$$ 这也是可以的,只需要加一个负号。 原理也很简单,三次函数是奇函数(即 $-f(x)=f(-x)$)。 同样的四次函数五次函数都是这样。 这样就只用算一半的点了!~~虽然还是没什么用但是先别急。~~ ---------------- ### 非奇非偶函数 看看上面的是否对所有多项式都有用。 随便举个例子:$f(x)=3x^5+2x^4+x^3+7x^2+5x+1$。 然后根据上面的规律,我们**奇偶分开**: $$\begin{aligned}f(x)&=(2x^4+7x^2+1)+(3x^5+x^3+5x)\\&=(2x^4+7x^2+1)+x(3x^4+x^2+5)\\&=f_e(x^2)+x\times f_o(x^2)\end{aligned}$$ 在这里,我们定义 $f_e(x^2)=2x^4+7x^2+1$ 和 $f_o(x^2)=3x^4+x^2+5$。 注意不是 $f_e(x)$ 和 $f_o(x)$,而是 $f_e(x^2)$ 和 $f_o(x^2)$。可以用 $x^2$ 是因为这个多项式都是偶数次项。 所以我们得到了如下规律: $$\fbox{$\begin{array}{l}f(x_i)&=f_e(x_i^2)+x_i\times f_o(x_i^2)\\f(-x_i)&=f_e(x_i^2)-x_i\times f_o(x_i^2)\end{array}$}$$ 所以假如我们知道了 $f_e(x_i^2)$ 和 $f_o(x_i^2)$,我们能一次性算 $f(x_i)$ 和 $f(-x_i)$。 这个时候我们令 $t=x_i^2$,那么 $f_e(x_i^2)=f_e(t)=2t^2+7t+1$,$f_o(x_i^2)=f_o(t)=3t^2+t+5$。 所以我们需要计算 $f_e(t)$ 和 $f_o(t)$ 来得到 $f(x)$。 你会发现我们把求 $f(x)$ 这个 $d$ 次多项式转化成求 $f_e(t)$ 和 $f_o(t)$ 这两个 $\lfloor\dfrac d 2\rfloor$ 次多项式(当 $d$ 为偶数时貌似是一个 $\dfrac d 2$ 和一个 $\dfrac d 2-1$ 的,不过这不重要)。 这是子问题啊,不断**递归解决**即可! ~~但是这样会有亿点小问题,待会就说。~~ 这样做居然是 $O(n\log n)$ 的!因为递归只有 $\log n$ 层,每一层要处理 $O(n)$ 的信息。 我们将会得到 $f=[(x_1,f(x_1)),(-x_1,f(-x_1)),\cdots,(x_{\frac n 2},f(x_{\frac n 2})),(-x_{\frac n 2},f(-x_{\frac n 2}))]$。 ------------------------- ### 寻找合适的数 你们注意到这里有一个小问题没有? 我们递归解决的是 $f_e(t),f_o(t)$,假设可以随便取 $t=[t_1,t_2,\cdots,t_{\frac n 2}]$,那么我们最终会得到 $f(x)$,其中 $x=[\pm\sqrt{t_1},\cdots,\pm\sqrt{t_{\frac n 2}}]$。 虽然 $f$ 是符合我们的预期的(拥有成对相反数),但是由于有 $\sqrt t$,所以 $t\ge 0$,这说明我们不能递归解决 $f_e$ 和 $f_o$ 了。 (因为递归的目的是要用相反数来减少计算,而所有 $\{t_i|i\in[1,\dfrac n 2]\}$ 都是非负数,不存在相反数,所以递归不下去了。) 举个例子,如果递归初始使用 $x=[1,-1,2,-2]$,进入第二层递归时 $t=[1,4]$,此时 $t$ 的两个值不是相反数,不能算出一个立即得出另一个,也就是说,这里就不能递归了。 好了,FFT 最天才的想法来了! 我们**强制让 $t$ 有一半是负数,另一半是正数**。 由于 $x=[\pm\sqrt{t_1},\cdots,\pm\sqrt{t_{\frac n 2}}]$,也就是说**有一半的 $x$ 是复数**~~(别看到复数就跑了,待会有详细解释)~~! 我们还要专门挑一些复数,使得它们平方之后,依旧是**正负成对**出现的。 怎么挑呢?举个例子吧。 $f(x)=x^3+x^2-x-1$。 这是一个三次多项式,我们需要 $4$ 个点来表示它。 这 $4$ 个点要正负成对出现,我们不妨设其为 $[x_1,-x_1,x_2,-x_2]$,这是递归的第一层。 递归后第二层要解决 $[x_1^2,x_2^2]$ 的子问题。 现在我们知道 $x_1^2$ 和 $x_2^2$ 也要是一对相反数。所以 $x_2^2=-x_1^2$。 再递归下去第三层只有一个点了 $x_1^4$。 现在我们可以随便取这个点,我们不妨就令 $x_1^4=1$ 吧。 那么第三层 $[1]$,第二层 $[1,-1]$,第一层 $[1,-1,x_2,-x_2]$。 **这个 $x_2$ 等于 $\sqrt{x_2^2}=\sqrt{-x_1^2}=\sqrt{-1}=i$,对吧!!!** 所以第一层是 $[1,-1,i,-i]$。 > ### 【补充】复数概念 > > 1. 虚数 > > 我们定义 $i=\sqrt{-1}$,$i$ 是虚数单位。你不用管这个 $i$ 的具体含义,它就是个符号。 > > 2. 复数 > > 一个复数 $z$,在复平面能表示成 $z=a+bi$,这里的 $i$ 是单位不是常量,不要理解成 $b\times i$。 > > 其中这里的 $a$ 是实数,叫做 $z$ 的实部;$bi$ 是虚数,叫做 $z$ 的虚部。 > > 事实上 $z$ 在复平面是能表示出来的。我们令 $x$ 轴为实轴(单位长度为 $1$),$y$ 轴为虚轴(单位长度为 $i$)。 > > **$z=a+bi$ 其实是复平面上坐标为 $(a,bi)$ 的点。**因为这里的加法事实上是向量的加法。 > > 不过你不理解向量也无所谓,你只需要记住 **$z=a+bi$ 对应在 $(a,bi)$** 即可。 > > 下图描述了 $z=0.5+0.75i$ 和 $z=-1-0.8i$ 的位置。 > > ![](https://cdn.luogu.com.cn/upload/image_hosting/fljszmbx.png) 从另一个视角来看: 我们实际上是解了一个方程 $x^4=1$,得到了解 $[1,-1,i,-i]$,我们把这四个解称为 **$1$ 的四个四次方根**。 现在来推广到 $d$ 阶多项式。按理来讲我们是需要取 $d+1$ 个点。 但是由于我们每次要折半,不妨让 $n$ 是 $>d$ 的第一个 $2$ 的整数次幂,这不影响答案的计算。 所以我们选的点就是 **$1$ 的 $n$ 个 $n$ 次方根**(即,解 $z^n=1$ 得到的 $n$ 个 $z$。) 但是你们不想想它为什么是对的吗?你不考虑怎么解出这些解吗? ---------------------- ### 证明以及求解这些数 具体的,我们需要证明为什么这些数正负成对出现,并且平方后依然正负成对出现,再平方后还是这样。 > ### 【引入】单位圆 > > 给出结论:**$1$ 的 $n$ 次方根可以被解释为复平面上沿着单位圆等距排布的 $n$ 个点**。 > > 不理解就对了。下面来解释。 > > 下图是单位圆(很好理解啊,半径是一个单位的圆嘛): > > ![](https://cdn.luogu.com.cn/upload/image_hosting/w4updeuy.png) > > 这个圆与 $x$ 轴(实轴)交点为 $1,-1$,与 $y$ 轴(虚轴)交点为 $i,-i$($i$ 是虚数单位,只是一个单位而已)(上图交点数字未标出)。 > > 因为我们知道,复数 $z=a+bi$ 画在复平面上是 $(a,bi)$(上文提到过)。 > > 我们定义 $r$ 为向量 $z$ 的模长(即 $(a,bi)$ 到原点的距离),有 $r=\sqrt{a^2+b^2}$。 > > 我们定义 $\theta$ 为辐角。这是向量 $z$ 与正实轴($x$ 轴正半轴)的逆时针旋转的夹角。 > > - $\cos\theta=\dfrac{\text{邻边}}{\text{斜边}}=\dfrac a r\Rightarrow a=r\cos\theta$ > - $\sin\theta=\dfrac{\text{对边}}{\text{斜边}}=\dfrac b r\Rightarrow b=r\sin\theta$ > - 所以 $z=a+bi=r\cos\theta+i\cdot r\sin\theta=r(\cos\theta+i\sin\theta)$。 > > **因为有欧拉公式:$e^{i\theta}=\cos\theta+i\sin\theta$,我们可以得到 $z=re^{i\theta}$**。 > > 所以 $z^n=r^ne^{in\theta}$。 > > 现在来解 $z^n=1$。 > > 因为 $1=1\cdot e^{i\cdot0}$,所以 $z^n=1\cdot e^{i\cdot 0}=r^ne^{in\theta}$。 > > 为了使其相等,我们要满足两个条件: > > 1. 模相等:$r^n=1$; > 2. 辐角相差 $2\pi$ 的倍数(弧度制下 $2\pi$ 是 $360$ 度,转了一圈):$n\theta=0+2\pi k,k\in\mathbb{Z}$。 > > 解得: > > - 模 $r=1$; > - 辐角 $\theta=\dfrac{2\pi k}n$。 > > 由于 $r=1$,所以它们都在单位圆上。 > > 根据解得的 $\theta$,相邻两个辐角差为 $\dfrac{2\pi(k+1)}n-\dfrac{2\pi k}n=\dfrac{2\pi}n$。 > > 直观理解就是把 $2\pi$ 的圆周均匀分成了 $n$ 份,所以其等距分布。 > > 我们这就**证明了**刚开始得出的结论。 > > 此时 $z=re^{i\theta}=1\cdot e^{i\cdot\frac{2\pi k}n}$,这就是 $z^n=1$ 的解了,其中 $k\in[0,n-1]$。 我们定义 $\omega_n=e^{\frac{2\pi i}n}$,我们称这个 $\omega_n$ 叫做**单位根**。 不难发现 $[\omega_n^0,\omega_n^1,\cdots,\omega_n^{n-1}]$ 就是 $1$ 的 $n$ 个 $n$ 次方根,因为代入它们后,会发现其与上文的 $z=1\cdot e^{i\cdot\frac{2\pi k}n},k\in[0,n-1]$ 是相等的。 所以我们的 FFT **的第一层递归**此时就是要在 $1,\omega_n^1,\omega_n^2,\cdots,\omega_n^{n-1}$ 处取点。 回到原来的问题,为什么这些 $\omega_n^k$ 就能用来递归呢,具体的,为什么其一定有成对的相反数呢? ![](https://cdn.luogu.com.cn/upload/image_hosting/zqbm4z0v.png) 上图是 $n=16$ 时的单位圆和单位根(上文强调过,$n$ 只能取 $2$ 的整数次幂)。 我们可以很清楚地看见 $\omega_{16}^{j+\frac n 2}=-\omega_{16}^j$(注意向量的复平面与平面直角坐标系的区别,复平面的负号是要求与原点对称),这个叫做**单位根的对称性**。 ~~(其实根据这个还能推出**单位根的周期性**,不过不重要。)~~ 上文说过,第 $i$ 层递归用的数应该是第 $i-1$ 层用的数的平方。 所以第二层用的数就是 $[(\omega_{16}^0)^2,(\omega_{16}^1)^2,\cdots,(\omega_{16}^{7})^2]$(只取到 $7$ 是因为 $8\sim15$ 的点与 $0\sim7$ 的点分别关于原点对称,平方后是同一个值。) 你会发现这些东西居然是 $[\omega_{16}^0,\omega_{16}^2,\omega_{16}^4,\cdots,\omega_{16}^{12},\omega_{16}^{14}]$。 然后它居然等价于 $[\omega_8^0,\omega_8^1,\cdots\omega_8^7]$(可以画画图理解,会发现两者等价)。 ~~($\omega_{2n}^{2k}=\omega_n^k$ 其实叫做**折半引理**,不过这也不重要。)~~ 然后这些居然也是具有对称性的。再平方后也是同理的,直到只有一个值。 这样就选完数了。FFT 也就是这样了~~(是不是很简单)~~,下面来对着代码总结 FFT 的过程。 ----------------------- ### FFT 递归版代码 别看我们说了这么多,其实代码超级短($12$ 行)! 我们一点一点来。 ```cpp typedef double lf; const lf PI=acos(-1); void FFT(complex<lf>*a,int n) { } ``` 这是定义 FFT 函数,表示将 $a$ 这个系数表达式转化成值表达式,返回在 $a$ 中。 `complex` 是 c++ STL 提供的复数模板。 传入的时候所有 $a_k$ 的虚部都是 $0i$。 这里 $n$ 是长度,传入的是大于应该有的次数的最小 $2$ 的整数幂。 ```cpp if(n==1)return; ``` 长度为 $1$ 直接退出。 ```cpp int m=n>>1; complex<lf>a0[m],a1[m]; for(int i=0;i<n;i+=2)a0[i>>1]=a[i],a1[i>>1]=a[i+1]; ``` 把其分成更小的长度 $m$,然后把 $a$ 分为奇偶两部分。 ```cpp FFT(a0,m),FFT(a1,m); ``` 递归处理奇偶两部分。 ```cpp complex<lf>wn={cos(2*PI/n),sin(2*PI/n)},w={1,0}; ``` 定义单位根 $w_n$。注意这里的 $w$ 是单位根的幂次,会在循环中不断乘 $w_n$。 事实上,你使用: ```cpp complex<lf>wn=exp(complex<lf>{0,2*PI/n}); ``` 来定义单位根也是可以的(根据欧拉公式)。 但是很多题解用的都是第一种。我们就跟随潮流吧。(或许是因为精度的误差?但两种都能过模板题。有知道的麻烦告知我。) ```cpp for(int i=0;i<m;i++,w*=wn)a[i]=a0[i]+w*a1[i],a[i+m]=a0[i]-w*a1[i]; ``` 然后你把它们合起来就好了(根据先前计算的公式)。 为什么合起来的时候一定是前一半后一半呢? > 先前的公式(去前文看): > > $$\fbox{$\begin{array}{l}f(x_j)&=f_e(x_j^2)+x_j\times f_o(x_j^2)\\f(-x_j)&=f_e(x_j^2)-x_j\times f_o(x_j^2)\end{array}$}$$ > > 我们知道此时 $x_j=\omega^j$,所以如下: > > $$\fbox{$\begin{array}{l}f(\omega^j)&=f_e(\omega^{2j})+\omega^j\times f_o(\omega^{2j})\\f(-\omega^j)&=f_e(\omega^{2j})-\omega^j\times f_o(\omega^{2j})\end{array}$}$$ > > 我们前文也说过 $-\omega^j=\omega^{j+\frac n 2}=\omega^{j+m}$(对称性),所以: > > $$\fbox{$\begin{array}{l}f(\omega^j)&=f_e(\omega^{2j})+\omega^j\times f_o(\omega^{2j})\\f(\omega^{j+m})&=f_e(\omega^{2j})-\omega^j\times f_o(\omega^{2j})\end{array}$}$$ > > 我们 $f$ 的下标用的就是 $\omega$ 的上标,所以一定是前一半后一半地存。 总代码如下: ```cpp typedef double lf; const lf PI=acos(-1); void FFT(complex<lf>*a,int n) { if(n==1)return; int m=n>>1; complex<lf>a0[m],a1[m]; // 这样开数组其实有点不太规范的 for(int i=0;i<n;i+=2)a0[i>>1]=a[i],a1[i>>1]=a[i+1]; FFT(a0,m),FFT(a1,m); complex<lf>wn={cos(2*PI/n),sin(2*PI/n)},w={1,0}; for(int i=0;i<m;i++,w*=wn)a[i]=a0[i]+w*a1[i],a[i+m]=a0[i]-w*a1[i]; } ``` ------------------------- ### FFT 非递归代码 ~~为了阅读的连续性,我不打算先讲 IFFT,心急的读者可以跳到后文中“插值”那一部分。但不建议这么做。~~ 由于递归时动态开 `complex` 数组巨大的常数,有些题是会 TLE 的(虽然模板题过了),我们需要学习非递归版的。 我们来看一下我们的递归过程: ```tex step 1: | 0 1 2 3 4 5 6 7 | step 2: | 0 2 4 6 | 1 3 5 7 | step 3: | 0 4 | 2 6 | 1 5 | 3 7 | step 4: | 0 | 4 | 2 | 6 | 1 | 5 | 3 | 7 | ``` 其中 `step` 是我们递归的层数。 我们观察一下第一层和第四层: ```tex 0 1 2 3 4 5 6 7 0 4 2 6 1 5 3 7 ``` 我们写出其二进制: ```tex 000 001 010 011 100 101 110 111 000 100 010 110 001 101 011 111 ``` 发现什么没有?我们发现第一层的二进制**反过来**就变成了第四层的二进制。 所以我们可以根据第一层的二进制,来预处理出来第四层的二进制。 ```cpp void init(int len) { int b=log2(len); for(int i=1;i<len;i++)rev[i]=(rev[i>>1]>>1)+((i&1)<<(b-1)); } /* 这个 rev 就是 i 的二进制倒过来后的二进制的数 为什么可以这样递推呢? 我们考虑 2*i=(i<<1) 那么反过来的二进制 rev[2*i]=(rev[i]>>1) 所以可以写成 rev[i]=(rev[i>>1]>>1),这里 i 为偶数 考虑到 2*i+1=(i<<1)+1 反过来的二进制是 rev[2*i+1]=(rev[i]>>1)+1 所以可以写成 rev[i]=(rev[i>>1]>>1)+(1<<(b-1)),这里 i 为奇数,其中 b 是 log2(len) 把它们合起来,就变成了最终形式:rev[i]=(rev[i>>1]>>1)+((i&1)<<(b-1)) */ ``` 然后根据递归的合并过程,我们用迭代模拟递归,即枚举长度 $1,2,4$,然后两两合并。具体的看代码。 ```cpp void FFT(complex<lf>*a,int n) { for(int i=0;i<n;i++)if(i<rev[i])swap(a[i],a[rev[i]]); // 按照刚刚的把 a 的顺序调整到最后一层 for(int len=1;len<=(n>>1);len<<=1) // 枚举合并后的长度的一半 { complex<lf>wn={cos(PI/len),sin(PI/len)}; // 单位根。注意此时由于枚举的长度是一半,所以 PI 不需要 *2 for(int i=0;i<n;i+=(len<<1)) // 枚举合并的每一个区间的起始位置 { complex<lf>w={1,0}; for(int j=0;j<len;j++,w*=wn) // 枚举区间的每一个元素 { complex<lf>a0=a[i+j],a1=w*a[i+j+len]; // 好像这里叫做蝴蝶操作,不过这不重要,没什么用 a[i+j]=a0+a1,a[i+j+len]=a0-a1; // 正常计算即可 } } } } ``` 这样非递归版的居然比递归版的快了近 $1$ 倍。 --------------------- ## 插值 其实完整的 FFT 并没有做完。 我们刚刚只是把系数表达式转化成了值表达式。 现在把其对应位置相乘得到了答案。 但是答案依然是值表达式,我们还需要转化成系数表达式,我们称这一步为**插值**。 一般把这一步叫做 **IFFT(Inverse Fast Fourier Transform,快速傅里叶逆变换)**。 做法的解释有很多,我用矩阵来解释一下。 --------------------------- ### 用矩阵的角度看待 FFT 想一想求值的时候我们是在干什么,是不是找一堆 $x$ 然后代入求 $f(x)$? 就是这些式子: $$\begin{array}{c} f(x_0)=p_0+p_1x_0+p_2x_0^2+\cdots+p_{n-1}x_0^{n-1}\\ f(x_1)=p_0+p_1x_1+p_2x_1^2+\cdots+p_{n-1}x_1^{n-1}\\ f(x_2)=p_0+p_1x_2+p_2x_2^2+\cdots+p_{n-1}x_2^{n-1}\\ \vdots\\ f(x_{n-1})=p_0+p_1x_{n-1}+p_2x_{n-1}^2+\cdots+p_{n-1}x_{n-1}^{n-1} \end{array}$$ 实际上可以写成矩阵的形式: $$\begin{bmatrix} f(x_0) \\ f(x_1) \\ f(x_2) \\ \vdots \\ f(x_{n-1}) \end{bmatrix}= \begin{bmatrix} 1 & x_0 & x_0^2 & \cdots & x_0^{n-1} \\ 1 & x_1 & x_1^2 & \cdots & x_1^{n-1} \\ 1 & x_2 & x_2^2 & \cdots & x_2^{n-1} \\ \vdots & \vdots & \vdots & \ddots & \vdots \\ 1 & x_{n-1} & x_{n-1}^2 & \cdots & x_{n-1}^{n-1} \end{bmatrix} \begin{bmatrix} p_0 \\ p_1 \\ p_2 \\ \vdots \\ p_{n-1} \end{bmatrix}$$ 我们知道此时 $x_k=\omega^k$,可以改写成如下形式: $$\begin{bmatrix} f(\omega^0) \\ f(\omega^1) \\ f(\omega^2) \\ \vdots \\ f(\omega^{n-1}) \end{bmatrix}= \begin{bmatrix} 1 & 1 & 1 & \cdots & 1 \\ 1 & \omega & \omega^2 & \cdots & \omega^{n-1} \\ 1 & \omega^2 & \omega^4 & \cdots & \omega^{2(n-1)} \\ \vdots & \vdots & \vdots & \ddots & \vdots \\ 1 & \omega^{n-1} & \omega^{2(n-1)} & \cdots & \omega^{(n-1)(n-1)} \end{bmatrix} \begin{bmatrix} p_0 \\ p_1 \\ p_2 \\ \vdots \\ p_{n-1} \end{bmatrix}$$ 事实上那个 $n\times n$ 的矩阵被称为离散傅里叶变换(DFT)矩阵,不过这不重要。 ----------------------- ### 用矩阵的角度看待 IFFT 事实上本质上我们要根据很多 $(x,f(x))$ 的二元组解出所有 $p_k$。 这不就直接把 DFT 求个逆就好了吗: $$\begin{bmatrix} p_0 \\ p_1 \\ p_2 \\ \vdots \\ p_{n-1} \end{bmatrix}= \begin{bmatrix} 1 & 1 & 1 & \cdots & 1 \\ 1 & \omega & \omega^2 & \cdots & \omega^{n-1} \\ 1 & \omega^2 & \omega^4 & \cdots & \omega^{2(n-1)} \\ \vdots & \vdots & \vdots & \ddots & \vdots \\ 1 & \omega^{n-1} & \omega^{2(n-1)} & \cdots & \omega^{(n-1)(n-1)} \end{bmatrix}^{-1} \begin{bmatrix} f(\omega^0) \\ f(\omega^1) \\ f(\omega^2) \\ \vdots \\ f(\omega^{n-1}) \end{bmatrix}$$ 这个逆矩阵我让 [DeepSeek](https://img.dexbug.com/i/2025/03/27/hgw45m.png) 帮我们求了,所以改写成如下: $$\begin{bmatrix} p_0 \\ p_1 \\ p_2 \\ \vdots \\ p_{n-1} \end{bmatrix}=\dfrac{1}{n} \begin{bmatrix} 1 & 1 & 1 & \cdots & 1 \\ 1 & \omega^{-1} & \omega^{-2} & \cdots & \omega^{-(n-1)} \\ 1 & \omega^{-2} & \omega^{-4} & \cdots & \omega^{-2(n-1)} \\ \vdots & \vdots & \vdots & \ddots & \vdots \\ 1 & \omega^{-(n-1)} & \omega^{-2(n-1)} & \cdots & \omega^{-(n-1)(n-1)} \end{bmatrix}\begin{bmatrix} f(\omega^0) \\ f(\omega^1) \\ f(\omega^2) \\ \vdots \\ f(\omega^{n-1}) \end{bmatrix}$$ 诶这是不是跟之前的 DFT **超级像**? ----------------------- ### IFFT 代码 所以可以直接把之前的 FFT 拿过来,然后改一行代码,再加一行代码即可: ```cpp void IFFT(complex<lf>*a,int n) { for(int i=0;i<n;i++)if(i<rev[i])swap(a[i],a[rev[i]]); for(int len=1;len<=(n>>1);len<<=1) { complex<lf>wn={cos(PI/len),-sin(PI/len)}; // 这里改成虚部 - 的 for(int i=0;i<n;i+=(len<<1)) { complex<lf>w={1,0}; for(int j=0;j<len;j++,w*=wn) { complex<lf>a0=a[i+j],a1=w*a[i+j+len]; a[i+j]=a0+a1,a[i+j+len]=a0-a1; } } } for(int i=0;i<n;i++)a[i]={a[i].real()/n+0.5,a[i].imag()}; // 再加上这一行,矩阵最前方的 1/n } ``` 定义单位根时加负号是因为 $\omega^{-1}=e^{\frac{-2\pi i}{n}}=\cos(\frac{2\pi}n)-i\sin(\frac{2\pi}n)$。 有没有注意到 `+0.5` 这个操作?那是因为复数的精度误差太大了! 这代码这么像,两个东西显然可以**合并**啊: ```cpp int rev[N]; void init(int len) { int b=log2(len); for(int i=1;i<len;i++)rev[i]=(rev[i>>1]>>1)+((i&1)<<(b-1)); } void FFT(complex<lf>*a,int n,int type=1) { for(int i=0;i<n;i++)if(i<rev[i])swap(a[i],a[rev[i]]); for(int len=1;len<=(n>>1);len<<=1) { complex<lf>wn={cos(PI/len),type*sin(PI/len)}; for(int i=0;i<n;i+=(len<<1)) { complex<lf>w={1,0}; for(int j=0;j<len;j++,w*=wn) { complex<lf>a0=a[i+j],a1=w*a[i+j+len]; a[i+j]=a0+a1,a[i+j+len]=a0-a1; } } } } void IFFT(complex<lf>*a,int n){FFT(a,n,-1);for(int i=0;i<n;i++)a[i]={a[i].real()/n+0.5,a[i].imag()};} ``` 这就是全部的 FFT 了。 ------------------------- # 03. 总结 - 我们用多项式乘法提出了 FFT。 - 我们思考把多项式用对应点的值表示。 - 多项式的值需要选取合适的点。 - 我们首先想到了利用相反数。 - 但是这样不方便递归,所以我们想到了复数。 - 我们使用 $1$ 的 $n$ 个 $n$ 次方根,这样平方完,依然是正负成对出现的。 - 这也是 FFT 的精髓。 - 当我们反过来做插值的时候,我们发现 IFFT 居然可以用 FFT 来解决。 - 这样就做完了所有的问题。 - 不得不感叹 FFT 真的太精巧了。 ---------------------- # 04. 模板题代码 模板题在[这里](https://www.luogu.com.cn/problem/P3803)。 ```cpp #include<bits/stdc++.h> #define Code using #define by namespace #define wjb std Code by wjb; int in() { int k=0,f=1;char c=getchar(); while(c<'0'||c>'9'){if(c=='-')f=-1;c=getchar();} while(c>='0'&&c<='9')k=k*10+c-'0',c=getchar(); return k*f; } void out(int x) { if(x<0)putchar('-'),x=-x; if(x<10)putchar(x+'0'); else out(x/10),putchar(x%10+'0'); } typedef double lf; const lf PI=acos(-1); const int N=(1<<21)+20; int rev[N]; void init(int len) { int b=log2(len); for(int i=1;i<len;i++)rev[i]=(rev[i>>1]>>1)+((i&1)<<(b-1)); } void FFT(complex<lf>*a,int n,int type=1) { for(int i=0;i<n;i++)if(i<rev[i])swap(a[i],a[rev[i]]); for(int len=1;len<=(n>>1);len<<=1) { complex<lf>wn={cos(PI/len),type*sin(PI/len)}; for(int i=0;i<n;i+=(len<<1)) { complex<lf>w={1,0}; for(int j=0;j<len;j++,w*=wn) { complex<lf>a0=a[i+j],a1=w*a[i+j+len]; a[i+j]=a0+a1,a[i+j+len]=a0-a1; } } } } void IFFT(complex<lf>*a,int n){FFT(a,n,-1);for(int i=0;i<n;i++)a[i]={a[i].real()/n+0.5,a[i].imag()};} complex<lf>a[N],b[N]; int main() { int n=in(),m=in(); for(int i=0;i<=n;i++)a[i]={in(),0}; for(int i=0;i<=m;i++)b[i]={in(),0}; int len=1;while(len<=n+m)len<<=1; init(len); FFT(a,len),FFT(b,len); for(int i=0;i<len;i++)a[i]*=b[i]; IFFT(a,len); for(int i=0;i<=n+m;i++)out(a[i].real()),putchar(' '); return 0; } ``` --------------------------- # 尾言 ~~$\sout{\LaTeX}$ 太难打啦啊啊!~~ > 在代码的海洋里,我们都是追逐算法星光的旅人。快速傅里叶变换在时光里生长,每一轮迭代都凝结着前人智慧的结晶。或许此刻你正为迭代实现的位逆序而困惑,为单位根的选择而辗转,但若将视角拉长,这些多项式终将编织成照亮前路的银河。 > > 对所有 OIer 而言,那些调试到凌晨的夜晚,那些反复优化的常数,那些 AC 后颤抖的指尖,都将沉淀为生命里珍贵的底色。无论未来是站在领奖台的聚光灯下,还是转身走向更广阔的天地,请记得:你曾在 OI 的星空中留下过属于自己的轨迹。 > > 愿我们永远保持对未知的好奇,像 FFT 处理多项式般优雅地接纳所有可能的挑战。无论未来走向何方,这段为 OI 燃烧的岁月,都将是我们回望时最璀璨的星辰。前路漫漫亦灿灿,愿所有努力终不被辜负,AC 常伴,未来可期。

评论

20 条评论,欢迎与作者交流。

正在加载评论...