畜中牲都不一定能理解的 FFT

前言

借鉴

看了一上午的 FFT 竟然学会了。于是写下这篇来纪念。

期间涉及复平面的相关知识,我这个畜中牲竟然懂了,真是神奇,请不要望而却步,勇于面对,死磕一下总是好的。

FFT 中文名 快速傅里叶变换

OIer 经常拿它来解决高精度乘法的问题。朴素高精乘是 O(n2) 的,只能解决两个 1010000 级别的数字相乘,而 FFT 是 O(nlogn) 的,可以解决两个 101000000 级别的数乘起来的问题。

以下保证 log2n\N

What is FFT ?

快速傅里叶变换(FFT)是一种能在 O(nlogn) 的时间内将一个多项式从它的系数表示法转换成它的点值表示的算法。

什么是多项式的系数表示法和点值表示法

一个 n1 次的多项式 A(x)=a0+a1x1+a2x2+...+an1xn1 ,这个就是系数表示法。

而一个 n1 次的多项式,代入 n 个不同的 x 值,将会得到 n 个对应的 y 值。那么如果我们知道这些数对 (xk,yk) ,就可以计算得这个多项式中的每个 a 。所以这些数对就是点值表示法。

朴素傅里叶变换(DFT,FFT 的理论基础)

强大的傅里叶前辈发明了一种方法,将 xxn=1n 个复数解。

牛逼的 C++ 给了复数模板:

头文件 #include <complex>

定义 complex<double> x;

ωnk=ei2kπn=cos(2kπn)+isin(2kπn) (即 xn=1n 个复数解),并称 ω 为单位根。

于是可以得到单位根的几个性质:

  • ω2n2k=ωnk 显然,因为表示的都是一个数(实际上你可以带进去计算一下);
  • ωnk+n2=ωnk 因为关于原点对称。
  • (ωnk)m=ωnmk ,显然符合复数相乘的幅角相加法则。

为什么要选择这些来代入式子呢?

这就牵扯到 DFT 的优美性质了。

我们将 (ωn0,ωn1,ωn2,...,ωnn1) 代入 A(x)=a0+a1x1+a2x2+...+an1xn1 。令 yk=A(ωnk),那么就会有 ny(y0,y1,y2,...,yn1),再新来一个多项式 B(x)=y0+y1x1+y2x2+...+yn1xn1,将 (ωn0,ωn1,ωn2...,ωn(n1)) (单位根的倒数)代入 B(x) ,令 zk=B(ωnk)

zk=i=0n1yi(ωnk)i=i=0n1(j=0n1aj(ωni)j)(ωnk)i=i=0n1(j=0n1aj(ωnjk)i)=j=0n1aj(i=0n1(ωnjk)i)

i=0n1(ωnjk)i 是可以求的。

  • j=k 时,原式 =n
  • jk 时,原式 =(ωnjk)n1ωnjk1=(ωnn)jk1ωnjk1=11ωnjk1=0 (等比序列求和)

zk=nak

ak=zkn

结论

(ωn0,ωn1,ωn2,...,ωnn1) 代入 A(x)=a0+a1x1+a2x2+...+an1xn1 得到 (y0,y1,y2,...,yn1),将 (ωn0,ωn1,ωn2...,ωn(n1)) 代入 B(x)=y0+y1x1+y2x2+...+yn1xn1 得到 (z0,z1,z2,...,zn1),那么对于任何一个 ak ,都有 ak=zkn

快速傅里叶变换(FFT)

虽然我们搞出了伟大的 DFT 的结论,但如果暴力代入还是 O(n2) ,不能接受。

于是 FFT 油然而生。

数学证明

A(x)=a0+a1x1+a2x2+...+an1xn1

先将 A(x) 的每一项按奇偶进行划分:

A(x)=(a0+a2x2+a4x4+...+an2xn2)+(a1x1+a3x3+a5x5+...+an1xn1)

设两个多项式:

A1(x)=a0+a2x+a4x2+...+an2xn21A2(x)=a1+a3x+a5x2+...+an1xn21

A(x)=A1(x2)+xA2(x2)

假设 k<n2 ,将 x=ωnk 代入。

A(ωnk)=A1(ωn2k)+ωnkA2(ωn2k)=A1(ωn2k)+ωnkA2(ωn2k)

那么对于 A(ωnk+n2)

A(ωnk+n2)=A1(ωn2k+n)+ωnk+n2A2(ωn2k+n)=A1(ωn2k)+ωnk+n2A2(ωn2k)=A1(ωn2k)ωnkA2(ωn2k)

如果我们知道 A1(x)A2(x)(ωn20,ωn21,ωn22,...,ωn2n21) 的点值表示,就可以 O(n) 求出 A(x)(ωn0,ωn1,ωn2,...,ωnn1) 的点值表示了。而 A1(x)A2(x) 都是规模缩小一半的子问题,总时间复杂度 O(nlogn) 。分治条件是 n=1 此时什么都不干,直接 return 。

#include <bits/stdc++.h>
using cpd = std::complex<double>;
#define rep(i, a, b) for(int i = (a); i <= (b); ++i)
#define il inline
const int N = 1e6 + 10;
const double pi = acos(-1.0);
il cpd omega(const int n, const int k) {
return cpd(cos(2.0 * k * pi / n), sin(2.0 * k * pi / n));
}
il void FFT(cpd *a, int n, bool inv) {//inv 的作用是标记是否要取倒数
if (n == 1) return;
static cpd tmp[N];
int m = n / 2;
rep(i, 0, m - 1)//按照奇偶分为两组
{
tmp[i] = a[2 * i];
tmp[i + m] = a[2 * i + 1];
}
rep(i, 0, n - 1) a[i] = tmp[i];
FFT(a, m, inv);//递归处理两个子问题
FFT(a + m, m, inv);
rep(i, 0, m - 1)
{
cpd x = omega(n, i);
if(inv) x = conj(x);
tmp[i] = a[i] + x * a[i + m];
tmp[i + m] = a[i] - x * a[i + m];
}
rep(i, 0, n - 1) a[i] = tmp[i];
}
cpd a[N];
int main() {
int n;
std::cin >> n;
rep(i, 0, n - 1)
{
double t;
std::cin >> t;
a[i].real(t);
}
FFT(a, n, true);
rep(i, 0, n - 1)
std::cout << a[i] << '\n';
}

递归真的超级慢,也超级难写。于是我们学一些优化。

FFT 优化

from 递归 to 非递归

在进行 FFT 时,我们要把各个系数不断分组并放到两侧,那么一个系数原来的位置和最终的位置有什么规律呢?

初始位置 0 1 2 3 4 5 6 7
第一轮后 0 2 4 6 1 3 5 7
第二轮后 0 4 2 6 1 5 3 7
第三轮后 0 4 2 6 1 5 3 7

可以发现,这你都能发现,原本位置为 a 的数,最后所在的位置是 a 二进制翻转得到的数,如 6=(011)2 最后的位置就是 3=(110)21=(001)2 到了 5=(100)2

那么我们可以据此写出非递归版本 FFT :先把每个数放到最后的位置上,然后不断向上还原,同时求出点值表示。

#include <bits/stdc++.h>
using cpd = std::complex<double>;
#define rep(i, a, b) for(int i = (a); i <= (b); ++i)
#define il inline
const int N = 1e6 + 10;
const double pi = acos(-1.0);
int n;
cpd a[N], b[N], omg[N], inv[N];
il void init() {
rep(i, 0, n - 1)
{
omg[i] = cpd(cos(2 * i * pi / n), sin(2 * i * pi / n));
inv[i] = conj(omg[i]);
}
}
il void FFT(cpd *a, cpd *omg) {
int lim = 0;
while((1 << lim) < n) lim++;
rep(i, 0, n - 1)
{
int t = 0;
rep(j, 0, lim - 1)
if((i >> j) & 1)
t |= (1 << (lim - j - 1));
if(i < t) swap(a[i], a[t]); // i < t 的限制使得每对点只被交换一次(否则交换两次相当于没交换)
}
static cpd buf[N];
for(int l = 2; l <= n; l *= 2)
{
int m = l / 2;
for(int j = 0; j < n; j += l)
rep(i, 0, m - 1)
{
buf[j + i] = a[j + i] + omg[n / l * i] * a[j + i + m];
buf[j + i + m] = a[j + i] - omg[n / l * i] * a[j + i + m];
}
rep(j, 0, n - 1) a[j] = buf[j];
}
}
int main() {
std::cin >> n;
init();
rep(i, 0, n - 1)
{
double t;
std::cin >> t;
a[i].real(t);
}
FFT(a, inv);
rep(i, 0, n - 1)
{
std::cout << a[i] << '\n';
}
}

蝴蝶优化

别跑呀,实际上很简单。

buf[j + i] = a[j + i] + omg[n / l * i] * a[j + i + m];
buf[j + i + m] = a[j + i] - omg[n / l * i] * a[j + i + m];

我们发现 buf 数组的作用实际上是为了让这两行不互相影响,但是如果写成:

cpd t = omg[n / l * i] * a[j + i + m]
a[j + i + m] = a[j + i] - t
a[j + i] = a[j + i] + t

就可以抛弃 buf 数组了。

最终版本

#include <bits/stdc++.h>
using cpd = std::complex<double>;
#define rep(i, a, b) for(int i = (a); i <= (b); ++i)
#define il inline
const int N = 1e6 + 10;
const double pi = acos(-1.0);
int n;
cpd a[N], b[N], omg[N], inv[N];
il void init() {
rep(i, 0, n - 1)
{
omg[i] = cpd(cos(2 * i * pi / n), sin(2 * i * pi / n));
inv[i] = conj(omg[i]);
}
}
void FFT(cpd *a, cpd *omg) {
int lim = 0;
while((1 << lim) < n) lim++;
for(int i = 0; i < n; i++)
{
int t = 0;
for(int j = 0; j < lim; j++)
if((i >> j) & 1)
t |= (1 << (lim - j - 1));
if(i < t) swap(a[i], a[t]); // i < t 的限制使得每对点只被交换一次(否则交换两次相当于没交换)
}
for(int l = 2; l <= n; l *= 2)
{
int m = l / 2;
for(cpd *p = a; p != a + n; p += l)
for(int i = 0; i < m; i++)
{
cpd t = omg[n / l * i] * p[i + m];
p[i + m] = p[i] - t;
p[i] += t;
}
}
}
int main() {
std::cin >> n;
init();
rep(i, 0, n - 1)
{
double t;
std::cin >> t;
a[i].real(t);
}
FFT(a, inv);
rep(i, 0, n - 1)
{
std::cout << a[i] << '\n';
}
}
posted @   Rich1  阅读(12)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· winform 绘制太阳,地球,月球 运作规律
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· AI与.NET技术实操系列(五):向量存储与相似性搜索在 .NET 中的实现
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
点击右上角即可分享
微信分享提示