Contents
概述
计算多项式的乘法,或者计算两个大整数的乘法是在计算机中很常见的运算,如果用普通的方法进行,复杂度将会是 的,还有一种分治乘法,需要新东西吗?play daisy slots 测试您的数学能力。 可以做到
时间计算(可以看这里)。下面从计算多项式的乘法出发,介绍快速傅里叶变换(Fast Fourier Transform, FFT)如何在
的时间内计算出两个多项式的乘积。另外,存在只需要两次快速傅立叶变换就可以计算大整数乘法的方法,具体见实序列离散傅里叶变换的快速算法
准备知识
这里介绍一些后面可能会用到的知识(主要是关于多项式、卷积以及复数的),如果你已经知道觉得它太水了或者想用到的时候再看就跳过吧
多项式
简单来说,形如 的代数表达式叫做多项式,可以记作
,
叫做多项式的系数,
是一个不定元,不表示任何值,不定元在多项式中最大项的次数称作多项式的次数
多项式的系数表示法
像刚刚我们提到的那些多项式,都是以系数形式表示的,也就是将 次多项式
的系数
看作
维向量
,其系数表示(coefficient representation)就是向量
多项式的点值表示法
如果选取 个不同的数
对多项式进行求值,得到
,那么就称
为多项式
的点值表示(point-value representation)
多项式 的点值表示不止一种,你只要选取不同的数就可以得到不同的点值表示,但是任何一种点值表示都能唯一确定一个多项式,为了从点值表示转换成系数表示,可以直接通过插值的方法
复数
后面提到的 ,除非作为
求和的变量,其余的都表示虚数单位
单位根
次单位根是指能够满足方程
的复数,这些复数一共有
个它们都分布在复平面的单位圆上,并且构成一个正
边形,它们把单位圆等分成
个部分
根据复数乘法相当于模长相乘,幅角相加就可以知道, 次单位根的模长一定是
,幅角的
倍是
这样, 次单位根也就是
再根据欧拉公式
就可以知道 次单位根的算术表示
如果记 ,那么
次单位根就是
多项式的乘法
给定两个多项式
将这两个多项式相乘得到 ,在这里
如果一个个去算 的话,要花费
的时间才可以完成,但是,这是在系数表示下计算的,如果转换成点值表示,知道了
的点值表示后,由于只有
个点,就可以直接将其相乘,在
的时间内得到
的点值表示
如果能够找到一种有效的方法帮助我们在多项式的点值表示和系数表示之间转换,我们就可以快速地计算多项式的乘法了,快速傅里叶变换就可以做到这一点
快速傅里叶变换
快速傅里叶变换你可以认为有两个部分,DFT 和 IDFT,分别可以在 的时间内将多项式的系数表示转化成点值表示,并且转回来,就像下面这张图所示
Cooley-Tukey算法
FFT 最常见的算法是 Cooley-Tukey 算法,它的基本思路在 1965 年由 J. W. Cooley 和 J. W. Tukey 提出的,它是一个基于分治策略的算法
假设现在有一个 次多项式
(为了方便,假设
,如果不足可以在高次项系数补成
)
将 个
次单位根
带入
将其转换成点值表达
点值向量 称作系数向量
的离散傅里叶变换(Discrete Fourier Transform, DFT),也记作
到此为止,直接计算 还是需要
的时间,Cooley-Tukey 算法接下来做的事情是将每一项按照指数奇偶分类
但是,如果直接这样递归下去,你需要带入的值还是有 个,也就是说,现在只是将系数减半,而没有将需要带入的值减半,上面的
还是
,这样的话复杂度还是
但是你会注意到,根据准备知识中 ,并且
次单位根只有
个,也就是说,我们要带入的值再平方以后似乎变少了一半?仔细想想就会发现,既然单位根把单位圆等分,那么肯定会对称,也就是有一个正的,就会有一个负的,平方后这两个当然就相同了。严格一点的证明就是
这也就是说,对于 的时候
并且
这样我们就将需要带入的值也减少成了 ,问题变成了两个规模减半的子问题,只要递归下去计算就可以了,至于复杂度
傅里叶逆变换(IDFT)
刚刚计算的是 ,可以将多项式转化成点值表示,现在为了将点值表示转化成系数表示,需要计算 IDFT(Inverse Discrete Fourier Transform),它是 DFT 的逆
这个问题实际上相当于是一个解线性方程组的问题,也就是给出了 个线性方程
写成矩阵方程的形式就是
记上面的系数矩阵为 现在考虑下面这个矩阵
设它们相乘后的结果是
当 时,
当 时,
因此可以知道 ,所以
将 在
左乘就会得到
这样,IDFT 就相当于把 DFT 过程中的 换成
,然后做一次 DFT,之后结果除以
就可以了。
算法实现
递归实现
根据前面的说明,递归实现的 FFT 应该不是什么大问题,下面就直接给出 C++ 代码了(主意 要补齐到
)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
void fft(int n, complex<double>* buffer, int offset, int step, complex<double>* epsilon) { if(n == 1) return; int m = n >> 1; fft(m, buffer, offset, step << 1, epsilon); fft(m, buffer, offset + step, step << 1, epsilon); for(int k = 0; k != m; ++k) { int pos = 2 * step * k; temp[k] = buffer[pos + offset] + epsilon[k * step] * buffer[pos + offset + step]; temp[k + m] = buffer[pos + offset] - epsilon[k * step] * buffer[pos + offset + step]; } for(int i = 0; i != n; ++i) buffer[i * step + offset] = temp[i]; } |
这里的
epsilon 是事先打好了的 的表
1 2 3 4 5 6 7 8 9 |
void init_epsilon(int n) { double pi = acos(-1); for(int i = 0; i != n; ++i) { epsilon[i] = complex<double>(cos(2.0 * pi * i / n), sin(2.0 * pi * i / n)); arti_epsilon[i] = conj(epsilon[i]); } } |
迭代实现
假设现在有 个数要进行
来看看递归的过程
在 Step1 -> Step2 的过程中,按照奇偶分类,二进制位中最后一位相同的被分到同一组
在 Step2 -> Step3 的过程中,仍然按照奇偶,只不过不是按照数字的奇偶性,而是下标的奇偶性,二进制位中最后两位相同的才被分到同一组
在 Step3 -> Step4 的过程中,二进制位中最后三位相同的数字被分在同一组
现在将整个二进制位反转,例如 0010 就变成 0100,这时候每次在同一组的数字,反转后的二进制位前几位都是相同的,这似乎十分类似加法,相邻两组二进制位反转之后数字会是连续的一段区间。例如在 Step3 中,1、5、9、13 这一组,反转二进制后是 1(1000)、5(1010)、9(1001)、13(1011),分组后是 1(1000)、9(1001) 和 5(1010)、13(1011)
假设 reverse(i) 是将二进制位反转的操作,DFT 最后一步的数组是 B,原来的数组是 A,那么 A 和 B 之间有这样的关系 B[reverse(i)]=A[i],也就是说, B[i + 1]=A[reverse(reverse(i) + 1)],B 中第 i 项的下一项就是将 i 反转后加 1 再反转回来 A 中的那一项,所以现在要模拟的就是从高位开始的二进制加法
考虑正向二进制加法的过程,相当于从最低位开始,找到第一个 0,然后把这个 0 改成 1,之前的 1 全部变成 0。那么反向二进制加法就是从最高位开始,找到第一个 0,然后把这个 0 改成 1,前面的 1 全部改成 0,所以就是这样
1 2 3 4 5 |
int reverse_add(int x) { for(int l = 1 << bit_length; (x ^= l) < l; l >>= 1); return x; } |
为了从原来的 A 数组,得到最后一步所需要的 B 数组,只要维护两个变量,一个是当前下标 i,一个是反向加的下标 j,表示 B[i] 应该放 A[j] 放的东西,如果 i > j,只要将 i 和 j 存的东西交换,这样最后就可以得到所需要的 B 数组
1 2 3 4 5 6 7 8 9 |
/* 这时候 n 已经补齐到 2 的幂次 */ void bit_reverse(int n, complex_t *x) { for(int i = 0, j = 0; i != n; ++i) { if(i > j) swap(x[i], x[j]); for(int l = n >> 1; (j ^= l) < l; l >>= 1); } } |
现在已经把要变换的元素排在相邻位置了,所以从下往上 开始到
来进行计算,每次枚举一块往上迭代即可!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
void transform(int n, complex_t *x, complex_t *w) { bit_reverse(n, x); for(int i = 2; i <= n; i <<= 1) { int m = i >> 1; for(int j = 0; j < n; j += i) { for(int k = 0; k != m; ++k) { complex_t z = x[j + m + k] * w[n / i * k]; x[j + m + k] = x[j + k] - z; x[j + k] += z; } } } } |
快速数论变换
由于 FFT 涉及到复数运算,难免有精度问题,在计算一些只要求整数的卷积或高精度乘法的时候就有可能由于精度出现错误,这便让我们考虑是否有在模意义下的方法,这就是快速数论变换(Fast Number-Theoretic Transform,FNT)
首先来看 FFT 中能在 时间内变换用到了单位根
的什么性质
是互不相同的,这样带入计算出来的点值才可以用来还原出系数
,这使得在按照指数奇偶分类之后能够把带入的值也减半使得问题规模能够减半
这点保证了能够使用相同的方法进行逆变换得到系数表示
原根
现在我们要在数论中寻找满足这三个性质的数,首先来介绍原根的概念,根据费马定理我们知道,对于一个素数 ,有下面这样的关系
这一点和单位根 十分相似,
的原根
定义为使得
互不相同的数
如果我们取素数 ,并且找到它的原根
,然后我们令
,这样就可以使得
互不相同,并且
,这便满足了性质一和性质二
由于 是素数,并且
,这样
必然是
或
,再根据
互不相同这个特点,所以
,满足性质三
对于性质四,和前面一样也可以验证是满足的,因此再 FNT 中,我们可以用原根替代单位根,这里已经有了一些数 及其原根,可以满足大部分需求
模数任意的解决方案
前面说了,要进行快速数论变换需要模数是 形式的素数,但是在实际应用中,要求的模数可能不是这样的形式,甚至是一个合数!
假设现在需要模 ,并且进行变换的长度是
那么这样任何多项式系数的范围是 ,两个相乘,不会超过
,一共
项相加,不会超过
这样的话,选取 个有上面形式的素数
,要求满足
然后分别在 的剩余系下做变换,最后使用中国剩余定理合并(当然这时候或许是需要高精度或者
__int128 的)
代码实现
FNT 的代码实现和 FFT 是一样的,只要把复数运算换成在 剩余系下的运算即可
应用
快速卷积
现有两个定义在 上的函数
,定义
和
的卷积(convolution)为
就像上面的图一样,注意到卷积的形式和多项式乘法的形式是相同的,也就是两个多项式 ,令
,那么会有
,因此可以用 FFT 来计算卷积
对于要计算某些形如 的问题,可以令
,这样问题就变成计算
,也就是一个卷积的形式
例1:[ZJOI2014]力
题目给出 个数
,要求计算
观察一下,假设有四个数 ,那么
初看之下似乎没什么规律,但是这之中出现的几个数列出来
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
列出来之后你看看每个 的计算,就会发现刚好是像上面那张图一样的顺序相乘再相加,是个卷积的形式!因此最后只需要用 FFT 优化计算卷积,就可以解决此问题,不过要注意精度问题
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 77 78 79 |
#include <cstdio> #include <complex> #include <cmath> #include <algorithm> const int MaxL = 18, MaxN = 1 << MaxL; typedef std::complex<double> complex_t; complex_t f[MaxN], g[MaxN]; complex_t eps[MaxN], inv_eps[MaxN]; void init_eps(int p) { double pi = acos(-1); double angle = 2.0 * pi / p; for(int i = 0; i != p; ++i) eps[i] = complex_t(cos(2.0 * pi * i / p), sin(2.0 * pi * i / p)); for(int i = 0; i != p; ++i) inv_eps[i] = conj(eps[i]); } void transform(int n, complex_t *x, complex_t *w) { for(int i = 0, j = 0; i != n; ++i) { if(i > j) std::swap(x[i], x[j]); for(int l = n >> 1; (j ^= l) < l; l >>= 1); } for(int i = 2; i <= n; i <<= 1) { int m = i >> 1; for(int j = 0; j < n; j += i) { for(int k = 0; k != m; ++k) { complex_t z = x[j + m + k] * w[n / i * k]; x[j + m + k] = x[j + k] - z; x[j + k] += z; } } } } int main() { int n; std::scanf("%d", &n); int l = 0, p = 1; while(p < n) ++l, p <<= 1; ++l, p <<= 1; for(int i = 0; i != p; ++i) f[i] = g[i] = 0.0; for(int i = 0; i != n; ++i) { double x; std::scanf("%lf", &x); f[i] = x; } for(int i = 0; i + 1 < n; ++i) { g[i] = 1.0 / ((n - i - 1.0) * (n - i - 1.0)); g[2 * n - i - 2] = -g[i]; } init_eps(p); std::reverse(g, g + p); transform(p, f, eps); transform(p, g, eps); for(int i = 0; i != p; ++i) f[i] *= g[i]; transform(p, f, inv_eps); for(int i = p - n; i != p; ++i) std::printf("%.3lf\n", f[i].real() / p); return 0; } |
生成函数运算
对于一些需要用到生成函数的计数问题,在列出生成函数之后有可能需要将其平方、求对数、求逆元或者开方,这时便可以用 FFT 来加速计算
例2:[BZOJ3771]Triple
这个问题就是用 FFT 加速多项式乘法的过程,具体可以看上面这篇题解
多项式求逆、除法、取模
关于多项式的求逆元,可以看这里
关于多项式的除法和求模,可以看这里
多项式多点求值和快速插值
关于多项式的多点求值和快速插值,可以看这里
感觉吊得不行...前排跪..
博主介绍多项式的地方貌似写错了东西…… a0X+a1X+a2X2+⋯+anXn,这个地方a0后面不应该有个x吧……
a0后面是x^0,所以对于a0
T(n)=2T(n/2 )+O(n)=O(nlogn)这个算法时间复杂度怎么变成nlogn的? O(n)是n次加法,2T(n/2 )指的是什么,希望能告诉我这是怎么理解的
具体怎么化简出来的,简单来说
第一层递归,问题规模是
,一共需要调用 1 次,花费时间 n
第二层递归,问题规模是
,一共需要调用 2 次,花费时间 n
第k层递归,问题规模是
,一共需要调用
次,花费时间 n
然后递归层数是 logn 级别的所以最后就是 nlogn
你可以翻翻算法导论应该是在分治那一章有一个叫做主定理的东西,比较详细说了类似这样的复杂度分析
博主大大,我看懂了您写的关于DFT和IDFT的推导,但是我无法理解您是如何在O(nlogn)的时间里完成IDFT的,代码也不是很懂。
啊,我突然懂了。。。谢谢您的博客,写的真的很好。
任意模数那里没看明白,比如我现在要模素数10^9+7,要怎么搞呢?
就比如说你有
项东西要卷积,那计算完后最多可能会到
,这里 
然后你找两个(或者更多)可以 FFT 的素数,并且乘起来大于上面那个最大的数
用这几个找的素数分别做 FFT,计算出来答案用中国剩余定理合并完再模
100000000个赞
无限赞。。。 以前被数字信号的傅里叶变换坑的不行,这篇博客才是我想要找的。。。 真是后悔当初怎么没有早点发现这篇博客
写的很好,非常感谢
感谢楼上安利
直接计算和用快速傅里叶变换直接有误差吗?如果有,有多大?
orz Miskcoo
这个必须赞
能引用一下吗,写得太好了!
可以
感谢博主, 写得太好了!
感谢博主,前面的预备知识对后面的理解非常有帮助
写得太好了,非常清晰,能转载一下吗!
可以的
博主写的真好。十分感谢。 我最近也在写FFT的笔记,有些部分摘抄您的。 如有不妥,麻烦您在我那篇博客的留言板留言指出,谢谢。
大赞~~~ 写的太好了!!!
模数任意写得有些简略没看太明白,但还是只想仰望福一大佬。想问下学长在哪里工作呢?我是泉五的……
我还没毕业呢
太强了!!我终于看懂FFT了
16个数进行DFT的那个step4最后一步你马虎了啊,(7,15)应该放在最后一个,右孩子。
请问博主,递归实现,就是第一个代码块。第七行前面是不是应该加上init_epsilon(int n);
你可以认为是在外面初始化好了传进来的