快速傅里叶变换FFT修订版

以前我也学习过FFT
但是你也看见了,内容很冗杂,而且当时什么鬼都不懂。
所以写的很马虎
现在我学废了一点点
就回来写了这篇文章。
所以在这片文章里不会有太多的前置知识,需要的知识会使用超链接告诉你。
主要集中在原理方面
最重要一点:
请各位读者有任何问题务必在评论区提出,也好让我知道在这方面我有什么不足。

多项式乘法

快速傅里叶变换最简单的用法就是用在多项式乘法
现在给你两个函数f,g他们的次数分别是n1,n2
那么现在要求h=fg,他的次数就应该是n=n1+n2下文的n就是在这里定义的
那么我们知道要确定一个n次的多项式,如果我们把它看作一个函数的话,我们就需要知道在函数图象上的至少n个点
FFT就是通过构造这n个点来求出h的解析式的。
所以就涉及到系数转点值点值转系数两个关键操作。
系数是什么呢?我们知道我们有一个多项式,用f举例吧,这是一个n1次多项式,所以就可以表示为n1+1个单项式相加,他们就是

f(x)=i=0n1aixi

其中ai就被称为i次项系数我们就可以用n1+1个系数来表示一个n1次多项式。
同样,我们要知道h的解析式,我们就需要确定n+1个系数
然后初中的待定系数法告诉我们,我们有n+1个未知的系数,我们就需要至少获得n+1个点
所以我们就取n+1个互不相同的xi代入到h(xi)=f(xi)g(xi),就可以求解出h的解析式。
理论上。
实际实操起来还是比较艰难的。
首先就是怎么选取xi
虽然说这个xi是随便取的,但是这样我们计算n+1个点,每个点的时间复杂度都是n1,这样时间复杂度很不划算,还不如直接n1n2搞暴力。
我们就要取一些xi的值,使得这个时间复杂度可以蜜汁迷之降低。
这个我们后面再讨论

快速傅立叶变换

我们先看看我们可以怎么转化掉这个f,把它变成点值。
看好了

f(x)=a0x0+a1x1+a2x2+

这是系数表达方法
然后我们按照次数的奇偶性分类

f0(x)=a0x0+a2x1+a4x2+f1(x)=a1x0+a3x1+a5x2+

这样分类之后就出来了两个次数为n1÷2+1的多项式
然后我们是有

f(x)=f0(x2)+xf1(x2)

是不是很神奇?我也不知道FFT怎么想出来的但是反正成立就行了。
我们就可以抓住这个不放,想一下在这个式子上怎么优化。
我们根据高中时候做偶函数题目的直觉
我们发现后面传进去的都是x2,唯独前面的系数是一个x
就是如果没有这个x,我们就会有f(x)=f(x)
但是他有
怎么办?
其实也是一样的。你在求f(x)的时候就要求f0(x2),f1(x2)的值,然后在合起来。
那么f(x)f(x)只是一个系数上的不同,因为这时候你的自变量已经确定了,所以我们可以暂且认为,这两个式子只是在一个系数上不一样。
所以我们在求f(x)的时候求出来的f0(x2),f1(x2)的值在求f(x)的时候一样也需要用,这样我们相当于用O(1)求出了f(x)
所以我们只要让我们取的xi满足

(1)i,j[0,n+1],ij,xixji[0,n+1],!j[0,n+1],ji,xi+xj=0

这样我们就可以把求解的范围缩小一半。
但是这一半貌似不太够。
所以我们观察f0,f1
发现他们也是多项式!
他们转点值也可以这样二分。
前提是满足n1÷2+1同样是奇数。
然后他们又二分,又二分。。。
一路二分下去,最后的时间复杂度就降到了O(nlog2n)
需要满足的条件就是当前多项式的次数必须是奇数,除非你递归到了边界:0次
所以我们就要求对于所有的次数必须都是偶数,也就是一开始多项式的次数必须是2的整数次幂。
所以前面我们提到的n点一下)最小取值就应该是

n=2log2(n1+n2)

然后我们回到xi的取值问题。
我们设当前处理的多项式次数为m,很明显这个东西是2的整数次幂。然后hf=m÷2
那么我们分出来的两个子函数f0,f1的次数都应该是hf
然后由f1组成f中次数为奇数的,f0组成是偶数的。
那么我们选取这个xi就很讲究了,应该要满足

i[0,hf),xi+xi+hf=0

也就是我加上一半的下标之后的数和我是相反数。
那很简单,我们只需要一个数组x,在里面随便构造。
然后你就发现了一个问题。
当你要二分下去的时候,你发现你的子函数。。。他所需要的下标不知道怎么取了吧。。。因为你的子函数除了系数下标奇偶性要考虑之外你还要让他相同的系数代入相同的xi
很难取吧。。。

如果你觉得这都有可能的话可以学完单位根的方法之后再把这个方法写出来。
你写的出来,我当场就
把它加入到这个博客里面以表示你的大聪明。

这个时候聪明的不知道谁(FLY?)提出了使用单位根
首先证明充分性,为什么代入单位根可以呢?因为单位根是复数,所以。。。我上面也没说用复数求解不行啊。。。复数求解是可以的,这个理由很充分。
然后我们知道当前处理的多项式的次数m是2的整数次幂,所以我们的m一定是偶数,然后我们构造m次单位根,设他们之中辐角最小的为ωm,那么所有的单位根的集合就可以表示为{ωmi|i[0,m)}。因为ωm0=ωmm=1(参考单位根定义)所以这里是左闭右开区间。
就会满足hf=m÷2,ωmi=ωmi+hf,i[0,hf)
所以用单位根也满足了我们的式子(1)(往上翻一翻,在页面右边有标记某个数学公式的编号为(1)
然后证明必要性,我们为什么有使用单位根的必要呢?
这里我们已经通过感性想象知道了使用数组提前搞我是搞不定的。
然后我们看看单位根啊。
按照奇偶性分好子函数之后,他们的次数就是hf了,他们执行的时候分别构造hf次单位根,可以保证

{ωhfi|i[0,hf)}{ωmi|i[0,m)}

我们粗略证明一下,hf次单位根必定都是m次单位根。
(ωhfi)m=(ωhfm=2hf)i=(ωhfhf)2i=12i=1他的m次幂为1他就是m次单位根。
而且根据一些感性的理解(在单位圆里面隔一个单位根取一个)我们可以得知,这个ωhfi和子函数获得的系数在原函数里是一一对应的(因为系数也是隔一个取一个才能满足下标的奇偶性相同)
这就完美解决了我们搞不定的问题。
所以我们每次就二分,求解,可以通过O(nlog2n)求出h的点值表达。

快速傅立叶逆变换

上一句话我们说,这个是点值表达。
但是我们需要的是系数表达。
那么我们要给他转换回来。
我们设h的系数分别是a0,a1,a2,,an1
然后我们求出来的点值分别是y0,y1,y2,,yn1
那么就应该满足有全称命题i[0,n),h(ωni)=yi
h(ωni)暴力拆分一下(貌似这里以前那个还推错了。。。)

h(ωni)=j=0naj(ωni)j=yi

看上去还是很难的。
然后我们考虑一下DFT的某些过程
这里是DFT不是FFT
DFT就是离散傅立叶变换,FFT可以认为是求解DFT的一种快速的方法。
求解DFT的正常方法很简单,就是我在开头说的,直接随便取n个值带入进去完事,不需要什么单位根甚至不需要满足相反数。
这样的做法是很高时间复杂度的。
但是他给我们提供了一个思路
他求点值的方法就是直接暴力。
如果我们代入单位根,但是我们同样暴力。
那么这个过程就变成了一个矩阵乘向量的过程(注意不是矩阵乘法

[h(ωn0)=y0h(ωn1)=y1h(ωn2)=y2h(ωnn)=yn]=[(ωn0)0(ωn0)1(ωn0)2(ωn0)n1(ωn1)0(ωn1)1(ωn1)2(ωn1)n1(ωn2)0(ωn2)1(ωn2)2(ωn2)n1(ωnn1)0(ωnn1)1(ωnn1)2(ωnn1)n1][a0a1a2an1]

然后现在倒过来,我知道了等号左边的向量要求右边的向量。
尔曹可知逆元?
算了就是逆矩阵。
我们就有

[a0a1a2an1]=[(ωn0)0(ωn0)1(ωn0)2(ωn0)n1(ωn1)0(ωn1)1(ωn1)2(ωn1)n1(ωn2)0(ωn2)1(ωn2)2(ωn2)n1(ωnn1)0(ωnn1)1(ωnn1)2(ωnn1)n1]1[y0y1y2yn]

根据大量的草稿纸和代数运算我们求出中间最大的那个矩阵的逆元

[(ωn0)0(ωn0)1(ωn0)2(ωn0)n1(ωn1)0(ωn1)1(ωn1)2(ωn1)n1(ωn2)0(ωn2)1(ωn2)2(ωn2)n1(ωnn1)0(ωnn1)1(ωnn1)2(ωnn1)n1]1=1n[(ωn0)0(ωn0)1(ωn0)2(ωn0)(n1)(ωn1)0(ωn1)1(ωn1)2(ωn1)(n1)(ωn2)0(ωn2)1(ωn2)2(ωn2)(n1)(ωnn1)0(ωnn1)1(ωnn1)2(ωnn1)(n1)]

然后我们就发现,我们求a0,a1,的过程和我们求y0,y1,的过程是很像的。
只是我们的单位根是倒着取的。这个小小的变动很容易实现,只需要更改一下循环变量的更新就可以了。
然后最后输出的时候不要忘了逆矩阵前面还有一个系数1n
此处的参考资料:快速傅里叶变换(FFT)——有史以来最巧妙的算法?

Code

#include <cstdio>
#include <cstring>
#include <cmath>
const int N=2097153;
const double Pi=acos(-1.);
struct complex
{
private:
	double rl,ige;
public:
	inline double real(double a)
	{
		return rl=a;
	}
	inline double image(double b)
	{
		return ige=b;
	}
	inline double real() const
	{
		return rl;
	}
	inline double image() const
	{
		return ige;
	}
	complex(double a=0, double b=0)
	{
		rl=a;
		ige=b;
	}
	inline complex operator+(const complex b)
	{
		return complex(this->rl+b.rl,this->ige+b.ige);
	}
	inline complex operator-() const
	{
		return complex(-rl,-ige);
	}
	inline complex operator-(const complex b)
	{
		return *this+(-b);
	}
	inline complex operator*(const complex b)
	{
		return complex
		(
		rl*b.rl-ige*b.ige,
		rl*b.ige+ige*b.rl
		);
	}
};
complex a[N],b[N];
// 注意这里不能用short(我也不知道为什么我卡空间要用short)
long unsigned res[N];
char str[1000001];
unsigned n,m,limit=1;
void FFT(unsigned,complex[],const int=1);

int main()
{
	scanf("%s",str);
	n=strlen(str);
	for(int i=n-1;i>=0;--i) a[i].real(str[n-i-1]-48);
	scanf("%s",str);
	m=strlen(str);
	for(int i=m-1;i>=0;--i) b[i].real(str[m-i-1]-48);
	while(limit<(n+m)) limit<<=1;
	FFT(limit,a);
	FFT(limit,b);
	for(int i=0;i<=limit;++i) a[i]=a[i]*b[i];
	FFT(limit,a,-1);
	for(int i=0;i<=limit;++i) res[i]=(unsigned long)(a[i].real()/limit+.5);
	register unsigned bt;
	for(bt=0;bt<=limit;++bt)
	{
		res[bt+1]+=res[bt]/10;
		res[bt]%=10;
		if(bt==limit&&res[bt+1])++limit;
	}
	while(bt&&res[--bt]==0);
	for(int i=bt;i>=0;--i) printf("%lu",res[i]);
	printf("\n");
	return 0;
}
void FFT(unsigned limit,complex a[],const int I)
{
	if(limit==1) return;
	register const unsigned limits=limit>>1;
	complex a1[limits+1],a2[limits+1];
	for(int i=0;i<=limit;++++i)
	{
		a1[i>>1]=a[i];
		a2[i>>1]=a[i|1];
	}
	FFT(limits,a1,I); FFT(limits,a2,I);
	// 如果是IFFT就是矩阵的逆,单位元逆着取就是了,所以这里的omega作为每次的加量,如果是IFFT需要乘以-1
	register const complex omega(cos(2*Pi/limit),I*sin(2*Pi/limit));
	register complex root(1);
	for(register int i=0;i<limits;++i)
	{
		a[i]=a1[i]+root*a2[i];
		a[i+limits]=a1[i]-root*a2[i];
		root=root*omega;
	}
}

迭代优化/蝴蝶变换

我们知道,我们上述二分的过程

f0(x)=a0x0+a2x2+f1(x)=a1x1+a3x3+

我们对于每一个子过程都这样执行的话,根据别人的图

n=23为例,我们可以发现原序列a1,a2a8,经过二分的“提取”之后,他的序列的下标变了。
不过貌似有一定的规律
我们观察一下这个三位下标,设Ai表示排在第i个位置的原序列的下标,Bi表示后序列下标,那么我们就有

A0=0=(000)2,B0=0=(000)2A1=1=(001)2,B1=4=(100)2A2=2=(010)2,B2=3=(010)2A3=3=(011)2,B3=6=(110)2A4=4=(100)2,B4=1=(001)2A5=5=(101)2,B5=5=(101)2A6=6=(110)2,B6=3=(011)2A7=7=(111)2,B7=7=(111)2

发现了什么规律没有?
通过肉眼观察,我们可以看见,Bi其实就是Ai3位下的二进制翻转,比如A4=(011)2,我们把他从后往前读,就变成了B4=(110)2
那么推广一下,如果当前二项式卷积后的位数是n位,n2的正整数次幂,那么我们对应的后序列B就是原序列Alog2n位下的二进制翻转。
那么我们虽然观察得出了这个规律,但是如果我们

for(int i=0;i<n;++i)
{
  b[i]=0;
  for(int bit=0;bit<log2(n);++bit)
  {
    b[i]|= bool(a[i] & (1<<bit)) << (log2(n)-bit); // a[i]=i;
  }
}

老是这样检测第i位,然后用bool转换,然后再位运算,这样的效率未免还是有点低。
我们来观察一下上面的式子,看看有什么规律。
(确实也看不出什么规律)
那么这样呢
B0=(000)2,B1=(100)2,B2=(010)2,B3=(110)2,B2=(010)2,B4=(001)2,B5=(101)2,B3=(110)2,B6=(011)2,B7=(111)2
如果只看前两列,我们可以看见,第二列就是第一列整体右移一位。
然后再看最后一列:第二列最高位都是0,然后最后一列的最高位就变成了1,剩下的和第二列都是一样的。
然后再来观察一下下标,如果第一列的下标是i,那么这一行第二列和第三列的下标就是2i,2i+1
那么我们貌似得到了一个结论:随着下标的左移一位,后序列会右移一位;偶数下标加一,后序列最高位会变为1

那么其他情况是不是这样的呢?我们来推广一下
n=2k,kN

B0=(00k0)2B0=(00k0)2B1=(100k10)2B1=(100k10)2B2=(0100k20)2B3=(1100k20)2B2=(0100k20)2B4=(00100k30)2B5=(10100k30)2

我们可以知道Ai=i,这是因为原序列。。。就是原下标。
那么一些数的后序列我们就可以得出,就像上面那样。

  • 如果原序列我们左移一位,并在最后填0,那么变成后序列,也就是从后往前读,就相当于右移了一位,然后最高位填0。这样我们就可以得到所有的偶数的后序列。
  • 如果原序列是偶数,也就是最后一位是0,那么我们加一变成奇数,这个时候最后一位就变成了1,反映在后序列里面就是最高位从0变成了1。这样我们就可以得到所有奇数的后序列

那么我们就可以得出后序列的递推代码了。

B[0]=0;
for(int i=1;i<n;++i)
{
  B[i]=
      (B[i>>1] >> 1) // 可以通过i/2的后序列得到i的后序列
      |    // 按位或,用来处理最高位
      ((i&1) << (k-1)); // 如果是奇数,那么最高位也就是第k位要变成1
}

这样我们就O(n)搞定了O(nlog2n)的二分过程
的一半。
先来总结一下这一半,主要就是在主函数里O(n)求出后序列,然后再把原序列按照后序列进行下标变换,我们就可以得到以前经过辛苦二分才能得到的序列
这个下标变换,主要就是a[i]=a[b[i]];但是这样会导致一些玄学的覆盖问题,所以我们再观察可以得到,如果x的后序列是x,那么x的后序列也是x(翻转两次等于什么都没干),所以我们只需要

if(i<B[i]) swap(a[i],a[B[i]]);

就可以不重不漏实现下标的变换了。


二分是一个分治的过程。我们搞定了前一半:分;现在是时候来走后一半:治。
我们再看回来源于网络的图片

这个治的过程其实我们很熟悉,就是我们在二分的时候调用完子函数再进行的操作。只不过现在我们没有子函数,我们要用一个循环体来搞定子函数做的事。
(这个我熟我就试过广搜求dfs序)
观察,我们这个循环体需要执行k次,上面那张图少拆了一层,应该拆到最后叶子结点只有一个元素,然后再拼回去。
i次需要进行的操作就是将两个长度为2i1的区间合并。
这个合并并不是像归并排序那样简单地合并,我们是要根据

f(x)=f0(x2)+xf1(x2)

来进行合并。具体一点,我们代入的是单位根,所以我们就应该是

aωni=aωn2i+ωniaωn2i

这样我们就可以把原来储存着f0,f1的值的a合并为现在储存f的值的a
然后我们用循环体来重复这个合并,就可以做到迭代优化了。

详见代码(卡了常所以有点丑)
#include <cstdio>
#include <iostream>
#include <cmath>
const double PI=acos(-1.);

struct complex
{
	double real,image;
	inline complex operator +(const complex& n) const
	{
		return (complex){real+n.real,image+n.image};
	}
	inline complex operator *(const complex& n) const
	{
		return (complex){real*n.real-image*n.image,image*n.real+real*n.image};
	}
	inline complex operator -(const complex& n) const
	{
		return (complex){real-n.real,image-n.image};
	}
}a[2097153],b[2097153];
int n,m,k=0,x,B[2097153];
int limit=1;
inline void FFT(complex[],const int,const int=1);

int main()
{
	scanf("%d%d",&n,&m);
	for(register int i=0;i<=n;++i) 
	{
		scanf("%d",&x);
		a[i].real=x; a[i].image=0;
	}
	for(register int i=0;i<=m;++i)
	{
		scanf("%d",&x);
		b[i].real=x; b[i].image=0;
	}
	while(n+m>(1<<++k)); limit=1<<k;
	B[0]=0;
	for(register int i=1;i<=limit;++i) B[i]=(B[i>>1]>>1)|((i&1)<<(k-1));
	FFT(a,limit);
	FFT(b,limit);
	for(register int i=0;i<=limit;++i) a[i]=a[i]*b[i];
	FFT(a,limit,-1);
	for(register int i=0;i<=n+m;++i)
	{
		printf("%d ",int(a[i].real/limit+.5));
	}
	printf("\n");
	return 0;
}


inline void FFT(complex a[],const int limit,const int flag)
{
	for(register int i=1;i<limit;++i) // emmm第一个和最后一个怎么换都是自己所以并不需要交换,可以卡常虽然好像没什么用
	{
		if(i<B[i]) std::swap(a[i],a[B[i]]);
	}
	for(register int mid=1;mid<limit;mid<<=1)
	{
		/// 当前处理好多个长度为mid的子区间
		// 那么我们就应该取2*mid次单位根,最小单位根的辐角就是(2*PI)/(2*mid)
		register const complex omega=(complex){cos(PI/mid),flag*sin(PI/mid)};
		for(register int R=mid<<1,j=0;j<limit;j+=R)
		{
			/// 当前处理一对长度为mid的子区间
			// 这对子区间的总长度是R,靠左边的那个子区间的起点是j
			register complex root=(complex){1,0};
			for(register int opt=0;opt<mid;++opt,root=root*omega)
			{
				/// 通过第一个区间的第opt位,也就是f_0
				/// 和第二个区间的opt位,也就是f_1
				/// 通过运算得出合并后应该在这两个位置上的值
				register const complex x=a[j+opt], y=root*a[j+opt+mid];
				a[j+opt]=x+y; a[j+opt+mid]=x-y;
			}
		}
	}
}
posted @   IdanSuce  阅读(100)  评论(0编辑  收藏  举报
编辑推荐:
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具
点击右上角即可分享
微信分享提示