我看起来像是没事一样么……但是逃避又能怎样,就算害怕,我也要战斗。——《辉夜大小姐想让我表白~天才们的恋爱头脑战》

这东东并不属于中国计算机学会划定的提高组知识点考察范围,所以为了 提高组的小朋友可以退出这里了(除非你想多掉点头发)。

快速傅里叶变换

快速傅里叶变换( ,是快速计算序列的离散傅里叶变换( )或其逆变换的方法。傅里叶分析将信号从原始域(通常是时间或空间)转换到频域的表示或者逆过来转换。主要用于解决一些多项式问题(卷积)问题,很大部分的现代技术,包括无线通讯,定位系统与广阔的信号处理领域相关的东西都基于该算法。

前置芝士有矩阵、离散傅里叶变换、时域到频域转换、原根、复数、微积分、欧拉公式等等,但是我一个都不会(真的,不是开玩笑)。

所以我们先从多项式乘法开始讲起。

多项式

多项式乘法

从这儿开始就轻松多了嘛假设我们现在有两个多项式:

然后现在我们重复应用乘法分配律来相乘多项式:

非常好用,但是很慢,然而这就是乘法的本质,卷积

卷积

如果现在用暴力来做多项式乘法,很轻松地得到以下方法:

设:

那么可以表达成:

也就是想要得到 ,就需要 的第 项和 的第 项。

同样也可以写成这样:

多项式乘法就是加法卷积,然后因为后面时间复杂度会噼里啪啦地炸开来所以就不讲了(擦汗)。

系数表达

然后有一个中朋友和大朋友能想到但是小朋友不能想到的想法就是将这个多项式的系数对应到一个序列里去:

列表中第 个系数对应到多项式的第 次项,这就是多项式的系数表示

如果存在:

这样子 里的每项系数都会去乘一边 里的每一项系数,时间复杂度就是是糟糕的

我们会环着这片海走下去。或许有一天,我们会真正回到起点

点值表示

一个真正不错的想法是点值表示

次数为 的任何多项式都可以唯一地由 个点表示。

如果想要证明,和该点集对应的有且只有一组系数,如果我们实际计算经过这些点的多项式(这就是点值表示):

然后我们就得到了一组方程式,就可以将其写成矩阵向量乘积来帮助分析(具体这个东西我也不是很清楚):

这里就不多加赘述了。

而我们将多项式的系数表示转换为点值表示,这个炫酷的魔术,就是闻名中外的快速傅里叶变换

多项式求值

设我们现在有一个多项式:

选取 个点,且满足 ,最简单的方法就是随机选取 坐标,然后计算对应的 坐标,但是当我们解构该过程时,又要使用到原多项式来求出 坐标,这样时间复杂度是 的,因为 ,兜兜转转又回到了起点,所以我们要找到优化的方式。

假设现在取一函数为 ,令 ,那么我们在这条函数上找那些知道一个点的函数值就知道另一点的函数值的点。

少女爬到函数上寻找中……

显然因为偶函数的定义 ,我们得到几个正负对的点。

再扩展到 的情况上:

少女爬到函数上找……这个小数好长

此时因为奇函数的性质 ,得到以上的点(奇函数和偶函数是必修一的东西,小朋友可以感性理解)。

那么我们利用此想法扩展到更一般的多项式,对于一个多项式:

分解这个多项式:

这样可以减少我们正负点对的计算,而且还把点的数量减少到了 个:

然后我们就得到了一个时间复杂度为 的优秀算法,但是其中有一个问题,在递归的时候,因为每个都是平方值,所有点的值最终都为正,所以递归会中断:

唯一可行的方法是扩展初始点的值域,所以我们引入一个不得不引入的绝对领域,复数。

复数

在了解复数之前,首先我们要提到一个对于小朋友和中朋友很降智的问题:

诶,等等,根号里面不是不可以是负数的吗?

对于很多大朋友及以上的朋友,答案就是它了:

所以:

这玩意不是我定义的所以我也不知道(笑)。

我们把形如 的数称为复数,其中的 称为实部, 称为虚部,复数域是实数域的代数闭包,即任何复系数多项式在复数域中总有根,复数域也是目前已知最大的域。在复平面中, 表示实数, 轴(除原点外的数)表示虚数,从原点 的向量表示复数 ,模长就是原点到点 的距离,即 。所谓幅角,假设以逆时针为正方向,从 轴正半轴到已知向量的转角的有向角叫做幅角。因为复数可以被表示为向量所以也可以使用平行四边形法则,这一点可在下面的代码中看到:

这里给出复数的代码,这份是使用重载运算符的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct Complex { // 复数类型  
Complex (double xx = 0, double yy = 0) {x = xx, y = yy};
double x, y;
Complex operator + (Complex const &B) const // 加法
{
return Complex (x + B.x, y + B.y);
}
Complex operator - (Complex const &B) const // 减法
{
return Complex (x - B.x, y + B.y);
}
Complex operator * (Complex const &B) const // 乘法
{
return Complex (x * B.x - y * B.y, x * B.y + y * B.x);
}
Complex operator / (Complex const &B) const // 除法
{
double t = B.x * B.x + B.y * B.y;
return Complex ((x * B.x + y * B.y) / t, (y * B.x - x * B.y) / t);
}
} a, b;

当然也有模板类版的(由 友情支持,但是跑得很慢,所以不推荐使用):

1
typedef complex <double> comp; // 重定义一下(要定义就定义,不定义拉倒) 

单位根

上面好像只是一个广告,现在我们步入正题,假设现在有一个三阶多项式:

那么我们需要 个点来表示该多项式,而且这些点还必须是正负对,那么可以想象这么一张图:

来模拟!

为了递归的可行性,这两点也必须是正负对,所以 之间需要一个等价关系,然后在底层,将只有一个 的点:

模拟成功得差不多了!

那么如果我们现在用 来代替这些数:

换过之后就可以引出复数的用法了!

为了让右边等于 ,所以右上的两个数答案就是

这组特殊的点就是四次单位根(诺,就最底下的 )。

那么如果我们有一个 次多项式,我们将需要 ,但是选 的整次方更简单,所以我们选 ,那么很简单了,推广到 次多项式,选择 ,使得 的整次方,选择的点集就是 单位根

的解是 次单位根均分在一个单位圆上,间隔点之间的角度是 ,简洁地列出这些点的方法就是欧拉公式

这里要提到的一个东西是单位圆,在这里の三角函数采用弧度制,一个整圆不再是 ,而是 ,那么我们将其分成 份。

次单位根的标准定义是:

完了听不懂了怎么办

如果听不懂了,没有关系,后面的你更听不懂,这样就简洁地定义了每一个 次单位根,比如(就是上面写的那些):

然后,当我们要计算 次单位根处求一个多项式的值时,我们实际要做的就是求 の对应函数值,然后是 ,以此类推,直到 ,对于 ,求出它们在单位圆(模长为 )上的的横坐标和纵坐标,实际上只需要算出 次就能得到 了, 的坐标为

至于欧拉公式,是这么个东西(反正我是看不懂,不重要这个不是我们的重点,请感性理解,但是接下来你必须使用到它):

让我们回到最初的问题,为什么要在 次单位根处用递归算法求

让我们回到这里

观察上图发现,第 的根 为一对,在递归中我们将这些点进行平方,然后将其代入偶数项和奇数项两个多项式:

给出用欧拉公式求单位根的代码:

1
2
3
4
5
6
Complex sav (cos (2 * Pi / n), sin (2 * Pi / n)), buf (1, 0);
for (int i = 0; i < n; i ++)
{
w[i] = buf; // w 因为很像 ω 所以就可以被使用(好吧其实我也想用 ω 奈何不彳亍)
buf = buf * sav; // 这里要是简写你就翘辫子了
}

FFT

DFT

讲了那么多,让我们来理一理 的过程:

先处理当 的时候,我们要对一点求值,这也是递归的出口。

接下来,该多项式要调用 两遍,一次在只有偶数项的多项式,一次在只有奇数项的多项式,这样新的多项式就是原来多项式的一半,我们只需要用 次单位根来求这些多项式的值,将偶数多项式和奇数多项式对应的点值表示结合起来以获取 多项式的点值表示。

其中利用到了正负对的思想:

然后因为单位根的性质,就会产生以下关系:

如果你懵掉了的话,我们需要讲一个欧拉恒等式

利用单位根的性质,我们就可以把前面的第二个等式修改成:

然后,在代码里处理的时候:

最后一步了(坚持住啊,后面还有更难的在等着你!),我们要返回在 次单位根处求出的多项式 的值:

看,这不就结束了吗(真的结束了吗?),现在,我们放出简单实现的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
#include <cstdio>
#include <cmath>

using namespace std;

const double Pi = acos (-1); // 结论,可以提高精度,环境允许也可以使用 M_PI(要用就用不用拉倒)
const int N = 1e6 + 1;
int n, m;

struct Complex { // 复数类型
Complex (double xx = 0, double yy = 0) {x = xx, y = yy;}
double x, y;
Complex operator + (Complex const &B) const // 重载运算符
{
return Complex (x + B.x, y + B.y);
}
Complex operator - (Complex const &B) const
{
return Complex (x - B.x, y - B.y);
}
Complex operator * (Complex const &B) const
{
return Complex (x * B.x - y * B.y, x * B.y + y * B.x);
}
Complex operator / (Complex const &B) const // 实际上其实是用不到的
{
double t = B.x * B.x + B.y * B.y;
return Complex ((x * B.x + y * B.y) / t, (y * B.x - x * B.y) / t);
}
} f[N], sav[N];

void dft (Complex *f, int len) // 传入数组和长度
{
if (len == 1) // 边界
return ;
Complex *fl = f, *fr = f + len / 2; // 注意这里对指针巧妙的食用,注意完了吗,好,我也不懂
for (int k = 0; k < len; k ++)
sav[k] = f[k]; // 复制数组
for (int k = 0; k < len / 2; k ++)
{
fl[k] = sav[k << 1]; // 分成两半
fr[k] = sav[k << 1 | 1];
}
dft (fl, len / 2); // 向下递归
dft (fr, len / 2); // 同上
Complex tG (cos (2 * Pi / len), sin (2 * Pi / len)), buf (1, 0); // 每次都要算单位根因为长度不同
for (int k = 0; k < len / 2; k ++)
{
sav[k] = fl[k] + buf * fr[k]; // 这里详见下面的公式(如果你不记得我们前面讲了什么的话,等等我前面好像没讲,讲了吗好像没讲讲了吧好像我记得算了管它讲没讲反正重复几遍总没有问题的)
sav[k + len / 2] = fl[k] - buf * fr[k]; // 同上
buf = buf * tG; // 得到下一个单位根,个十千百万不要简写
}
for (int k = 0; k < len; k ++)
f[k] = sav[k]; // 把数组复制过来
}

int main ()
{
scanf ("%d", &n);
for (int i = 0; i < n; i ++)
scanf ("%lf", &f[i].x); // 初始虚部为 0 ,所以不用管
for (m = 1; m < n; m <<= 1); // 将其补到 2 的幂次形式,反正都是 0 ,所以小问题
dft (f, m); // 传入数组和长度
for (int i = 0; i < m; i ++)
printf ("(%.4lf,%.4lf)\n", f[i].x, f[i].y); // 这个输出有点奇怪啊,为什么不是多项式格式的呢?
return 0;
}

给出上面说的主要公式:

因为是分治的那种感觉,所以时间复杂度是还能算优秀的

IDFT

诶诶诶上面的话都说了,还没有结束呢,做人啊,不能拉屎拉一半,屁股让别人擦(好像有啥不对但是我不记得那句话怎么讲了,反正就是咱们还有一个烂摊子要收就对了)。

上面的东西求完以后,只是求出了一个答案的点值表达,而并非最终结果,最后一步,我们要将求出的点值表达式还原成多项式,最终完成乘法,这个过程叫做傅里叶逆变换(不妨叫它大小姐逆告白 www)

然后这里有一个非常有意思的结论(涉及单位根反演,也可以理解成范德蒙德矩阵求逆):

中的 换成 ,做完以后除以 即可,所以其实可以把 改一改就能叫 了(记住这句话)。

现在假设多项式 在变换之后得到的点值数列为 ,即

所以有以下式子:

结论即:

最终的一个转换是,其中 是点值表示, 是向量:

所以我们把代码改成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void idft (Complex *f, int len) // 逆快速傅里叶变换 
{
if (len == 1)
return ;
Complex *fl = f, *fr = f + len / 2; // 注意这里对指针巧妙的食用,注意完了吗,好,我差不多懂了
for (int k = 0; k < len; k ++)
sav[k] = f[k]; // 转移数组
for (int k = 0; k < len / 2; k ++)
{
fl[k] = sav[k << 1]; // 奇偶打乱
fr[k] = sav[k << 1 | 1];
}
idft (fl, len / 2); // 递归下去
idft (fr, len / 2);
Complex tG (cos (2 * Pi / len), -sin (2 * Pi / len)), buf (1, 0);
// 贺代码的时候 ↑别忘了
for (int k = 0; k < len / 2; k ++)
{
// 这里 buf 是 len 次反单位根的第 k 次
sav[k] = fl[k] + buf * fr[k]; // 见上面公式,可能有点远要爪巴一下子
sav[k + len / 2] = fl[k] - buf * fr[k]; // 不对啊,你前面难道没看懂吗?为什么还要翻上去呢
buf = buf * tG; // 得到下一个单位根,十万百万千万不要简写
}
for (int k = 0; k < len; k ++)
f[k] = sav[k];
}

好像没啥改变,只有一个负号。

但是因为常数太大了所以时间复杂度会膨的一下爆炸,应该是过不了模板题。

代码实现

最后这里给出最简单的代码实现,我们可以把 合起来变成一个我们今天的主题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#include <cstdio>
#include <cmath>

using namespace std;

const double Pi = acos (-1); // 结论,可以提高精度,环境允许也可以使用 M_PI(要用就用不用拉倒)
const int N = 1e6 + 5e5 + 1; // 洛谷上的数据有点东西,如果只开 1e6 会 re 两个点
int n, m;

struct Complex { // 复数类型
Complex (double xx = 0, double yy = 0) {x = xx, y = yy;}
double x, y;
Complex operator + (Complex const &B) const // 重载运算符
{
return Complex (x + B.x, y + B.y);
}
Complex operator - (Complex const &B) const
{
return Complex (x - B.x, y - B.y);
}
Complex operator * (Complex const &B) const
{
return Complex (x * B.x - y * B.y, x * B.y + y * B.x);
}
Complex operator / (Complex const &B) const // 实际上其实是用不到的
{
double t = B.x * B.x + B.y * B.y;
return Complex ((x * B.x + y * B.y) / t, (y * B.x - x * B.y) / t);
}
} f[N << 1], p[N << 1], sav[N << 1];

void fft (Complex *f, int len, bool flag) // 传入数组和长度,和一个判断(逆或不逆)
{
if (len == 1) // 边界
return ;
Complex *fl = f, *fr = f + len / 2; // 注意这里对指针巧妙的食用,注意完了吗,好,我完全懂了
for (int k = 0; k < len; k ++)
sav[k] = f[k]; // 复制数组
for (int k = 0; k < len / 2; k ++)
{
fl[k] = sav[k << 1]; // 分成两半
fr[k] = sav[k << 1 | 1];
}
fft (fl, len / 2, flag); // 向下递归
fft (fr, len / 2, flag); // 同上
Complex tG (cos (2 * Pi / len), sin (2 * Pi / len)), buf (1, 0); // 每次都要算单位根因为长度不同
if (!flag)
tG.y *= -1;
for (int k = 0; k < len / 2; k ++)
{
sav[k] = fl[k] + buf * fr[k]; // 详见……自己上去看吧我不想再说一遍了
sav[k + len / 2] = fl[k] - buf * fr[k]; // 什么??这里你也要我再说一遍??
buf = buf * tG; // 得到下一个单位根
}
for (int k = 0; k < len; k ++)
f[k] = sav[k]; // 把数组复制过来
}

int main ()
{
scanf ("%d %d", &n, &m);
for (int i = 0; i <= n; i ++)
scanf ("%lf", &f[i].x); // 初始虚部为 0 ,所以不用管
for (int i = 0; i <= m; i ++)
scanf ("%lf", &p[i].x);
for (m += n ,n = 1; n <= m; n <<= 1); // 将其补到 2 的幂次形式,反正都是 0 ,所以小问题
fft (f, n, 1); // 传入数组和长度,和判断
fft (p, n, 1);
for (int i = 0; i < n; i ++)
f[i] = f[i] * p[i];
fft (f, n, 0);
for (int i = 0; i <= m; i ++)
printf ("%d ", (int) (f[i].x / n + 0.49));
return 0;
}

提交记录:

看上去是比较糟糕的

所以我们要优化!

优化

减少计算次数

来看这一段代码:

1
2
sav[k] = fl[k] + buf * fr[k]; // 都看到优化了你不会还不懂吧 
sav[k + len / 2] = fl[k] - buf * fr[k]; // emmm……

复数做乘法的代价是很高的,但是我们却偏偏把 算了两遍!

所以可以改成这样:

1
2
3
Complex temp = buf * fr[k]; // 先算出来 
sav[k] = fl[k] + temp; // 大哥……都到拆开来的时候了……这时候你要是再不懂
sav[k + len / 2] = fl[k] - temp; // 我是真滴没有办法了

蝴蝶变换

在代码中我们使用了大量的数组拷贝,现在要通过一些歪门邪道来避免数组拷贝(标题看着很阴间但是其实还好啦)。

让我们用一张图来看看分治究竟在搞什么飞机:

我们要先求出最后一层的数组状况,然后才能向上合并。

然后其实你可以观察一下(反正我是没看出来),有一个小小的规律:

是不是看出来了?看不出来我也要揭秘了,再不揭秘花儿都要谢了。

可以看到 原来的位置,而且它们的二进制数组是反过来的,这就是蝴蝶变换

如果使用 的算法,就太糟糕了,我们要使用递推 的方法!代码如下:

1
2
for (int i = 0; i < limit; i ++)
tr[i] = (tr[i >> 1] >> 1) | ((i & 1) ? limit >> 1 : 0); // tr[i] 是二进制反转

这个过程可以类比数位 (不会的可以溜去《动态规划目录》康康)

我们可以把一个二进制的反转拆成其他部分和最后一位两部分来看,其他部分的反转就是 tr[i >> 1] >> 1​ ,然后判一下最后一位,如果是 就加上 limit >> 1

也可以这样写:

1
2
3
4
5
6
for (int i = 0; i < limit; i ++)
{
tr[i] = (tr[i >> 1] >> 1) | ((i & 1) << bit);
if (i < tr[i]) // 防止交换
swap (a[i], a[tr[i]]);
}

迭代

因为所有位置都确定了所以可以使用迭代的写法(而且这是最经典的写法,为什么不去学 ):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#include <bits/stdc++.h>

using namespace std;

const double Pi = acos (-1); // 结论,可以提高精度,环境允许也可以使用 M_PI(要用就用不用拉倒)
const int N = 1e6 + 5e5 + 1; // 洛谷上的数据有点东西,如果只开 1e6 会 re 两个点
int n, m;

struct Complex { // 复数类型
Complex (double xx = 0, double yy = 0) {x = xx, y = yy;}
double x, y;
Complex operator + (Complex const &B) const // 重载运算符
{
return Complex (x + B.x, y + B.y);
}
Complex operator - (Complex const &B) const
{
return Complex (x - B.x, y - B.y);
}
Complex operator * (Complex const &B) const
{
return Complex (x * B.x - y * B.y, x * B.y + y * B.x);
}
Complex operator / (Complex const &B) const // 实际上其实是不得不得不得不用不到的
{
double t = B.x * B.x + B.y * B.y;
return Complex ((x * B.x + y * B.y) / t, (y * B.x - x * B.y) / t);
}
} f[N << 1], p[N << 1];

int tr[N << 1];

void fft (Complex *f, bool flag) // 传入数组和长度,和一个判断(不得不逆或不逆)
{
for (int i = 0; i < n; i ++)
if (i < tr[i])
swap (f[i], f[tr[i]]);
for (int p = 2; p <= n; p <<= 1)
{
int len = p >> 1;
Complex tG (cos (2 * Pi / p), sin (2 * Pi / p));
if (!flag)
tG.y *= -1;
for (int k = 0; k < n; k += p) // 枚举起始点
{
Complex buf (1, 0);
for (int l = k; l < k + len; l ++)
{
Complex tt = buf * f[len + l];
f[len + l] = f[l] - tt;
f[l] = f[l] + tt;
buf = buf * tG;
}
}
}
}

int main ()
{
scanf ("%d %d", &n, &m);
for (int i = 0; i <= n; i ++)
scanf ("%lf", &f[i].x); // 初始虚部为 0 ,所以不用管
for (int i = 0; i <= m; i ++)
scanf ("%lf", &p[i].x);
for (m += n ,n = 1; n <= m; n <<= 1); // 将其补到 2 的幂次形式,反正都是 0 ,所以小问题
for (int i = 0; i < n; i ++)
tr[i] = (tr[i >> 1] >> 1) | ((i & 1) ? n >> 1 : 0);
fft (f, 1); // 传入数组和判断
fft (p, 1);
for (int i = 0; i < n; i ++)
f[i] = f[i] * p[i];
fft (f, 0);
for (int i = 0; i <= m; i ++)
printf ("%d ", (int) (f[i].x / n + 0.49));
return 0;
}

如果要用建议直接用这个板子,下面是提交记录:

可以看到和之前的速度相比快了很多

三次变两次

我刚开始看到这个我记得不是遇到鬼的时候要三步变两步来着说……,其实是减少调用函数的次数。

根据:

假设我们要求:

复多项式

那么实部就是 ,虚部为

则:

发现 的虚部就是 ,也就是说求出 之后,把它的虚部除以 即可。

那么主函数的那块只要这样写就可以了:

1
2
3
4
5
6
fft (f, 1); // 传入数组和判断
for (int i = 0; i < n; i ++)
f[i] = f[i] * f[i];
fft (f, 0);
for (int i = 0; i <= m; i ++)
printf ("%d ", (int) (f[i].y / n / 2 + 0.49));

然后快了很多:

请不要在意我的背景

注意,如果两个多项式的值域相差太大就会卡精度。

当然求多项式还有多种方法,像 (快速数论变换), (快速沃尔什变换), (某知名公司,也可以将其叫成快速谭炜谭变换)等等……