多项式乘法
多项式算法作为 OI 中较为困难的部分,其包含的知识是比较丰富的。本文将介绍多项式算法的基础——多项式乘法。
1 快速傅里叶变换 FFT
1.1 引入
首先我们知道一个多项式
那么我们如果想对两个多项式做乘法,会有如下结果:
也就是说,得到的新多项式的系数可以写成:
这种形式被称为卷积,其特征是相乘的两个数下标为定值。不难发现,如果直接暴力计算,对于两个
为了实现以后的更多多项式操作,我们需要降低多项式乘法的复杂度,于是就有了快速傅里叶变换(Fast Fourier Transform,FFT)来帮助我们求解这个问题。
1.2 前置知识
1.2.1 系数表示与点值表示
FFT 的基础是系数表示和点值表示。系数表示不必多说,对于一个多项式
重要的部分是点值表示。在拉格朗日插值中我们提到过这样一个定理:对于一个
这有什么用呢?考虑两个多项式
1.2.2 复数
复数是形如
然后可以定义复数的加、减、乘运算如下:
。 。 。
接下来我们引入复平面,以实部作为
1.2.3 单位根
对于一个
我们称这
根据定义,可以利用三角函数简单求出
然后我们还需要知道单位根的若干重要性质,这在 FFT 中有很大用处:
。 。 。
证明都不困难,结合复平面上的几何意义以及适量代数推导都可以简单得出。
1.3 离散傅立叶变换 DFT
离散傅里叶变换 DFT 实际上描述的就是将多项式从系数表示转化成点值表示的一种方法。例如对于多项式
考虑一个
那么我们显然有
同理,将
于是当我们求出
实现时为了方便,我们需要将多项式的项数补到一个
当然实际运用中一般不会写递归分治写法,原因在于其常数过大,我们一般采用倍增法实现。关于倍增实现有两个基本要点:位逆序置换和蝶形运算优化。
考虑我们拆分多项式的过程,设有一个
- 初始下标为
。 - 第一次分治后下标为
。 - 第二次分治后下标为
。 - 第三次分治后下标为
。
不妨写出最开始和最后系数下标的二进制表示,会发现如下情况:
- 初始下标为
。 - 结束下标为
。
对比后不难发现,每一个下标都只是翻转了其二进制表示。这就是位逆序置换。我们可以提前
接下来是蝶形运算优化,我们会发现在拆分系数之后,
1.4 逆离散傅立叶变换 IDFT
和上面的 DFT 类似,IDFT 就是将点值表示转化为系数表示的变换。不过我们不必实现一个新的函数,因为实际上 IDFT 可以转化为 DFT 来实现。
上述 DFT 的过程可以看成是对系数表示的向量左乘了一个矩阵,得到了点值表示的向量,即下式:
将中间的大矩阵记作
即
接下来不难发现:
- 当
时,显然 。 - 当
时,运用等比数列求和公式可知 。
所以说
由于上面我们实际上
1.5 常数优化及代码
在上述过程中,我们对两个多项式分别作了一次 DFT,乘起来之后又作了一次 IDFT,总共三次 FFT 操作。实际上我们可以通过一些操作使得只需要进行两次 FFT 操作。
具体的,我们将第二个多项式的系数放到第一个多项式的虚部上,然后让第一个多项式平方。根据
不过这种方法也有弊端,如果两个相乘的多项式值域相差过大,那么由于有平方项的存在这种做法会严重掉精度,然后导致 WA。解决方法就是将两个多项式的系数先乘到一个差不多一致的值域范围内,求答案的时候再除回去即可。不过这样的话除法还有巨大的常数,所以使用的时候需要考虑一下。
模板题:【模板】多项式乘法(FFT),采用朴素三次 FFT 实现代码如下:
#include <bits/stdc++.h>
using namespace std;
const int Maxn = (1 << 21) + 1;
const int Inf = 2e9;
const double Pi = acos(-1);
struct comp {//手写复数类
double a, b;
comp operator + (comp y) {return {a + y.a, b + y.b};}
comp operator - (comp y) {return {a - y.a, b - y.b};}
comp operator * (comp y) {return {a * y.a - b * y.b, a * y.b + b * y.a};}
comp operator / (int y) {return {a / y, b / y};}
};
int r[Maxn];
struct Poly {
int n;//n 次多项式
vector <comp> a;
void reset(int len) {n = len, a.resize(len + 1);}//调整多项式长度
comp& operator [](int x) {return a[x];}
void FFT(int len, int typ) {
//处理 len 项多项式
reset(len - 1);
for(int i = 0; i < len; i++) {
if(i < r[i]) swap(a[i], a[r[i]]);
}
for(int h = 1; h < len; h <<= 1) {
//枚举小区间长度 h,当前处理长度为 2h 区间
comp cur = {cos(Pi / h), typ * sin(Pi / h)};//单位根 w(2h)
for(int i = 0; i < len; i += (h << 1)) {//跳大区间
comp w = {1, 0};
for(int j = 0; j < h; j++, w = w * cur) {//遍历小区间
comp x = a[i + j], y = w * a[i + j + h];
a[i + j] = x + y;
a[i + j + h] = x - y;//蝶形运算
}
}
}
if(typ == -1) {//IDFT 的时候要除以项数 len
for(int i = 0; i < len; i++) a[i] = a[i] / len;
}
}
Poly operator * (Poly y) {
Poly x = *this, z;
int n = x.n + y.n, len = 1;
while(len <= n) len <<= 1;//将项数变成 2 的整数次幂
for(int i = 0; i < len; i++) {//预处理位逆序
r[i] = (r[i >> 1] >> 1) | ((i & 1) * (len >> 1));
}
x.FFT(len, 1), y.FFT(len, 1);
z.reset(len);
for(int i = 0; i < len; i++) z[i] = x[i] * y[i];
z.FFT(len, -1);
z.reset(n);
return z;
}
}F, G;
int n, m;
int main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> n >> m;
F.reset(n), G.reset(m);
for(int i = 0; i <= n; i++) cin >> F[i].a;
for(int i = 0; i <= m; i++) cin >> G[i].a;
F = F * G;
for(int i = 0; i <= n + m; i++) cout << (int)(F[i].a + 0.5) << ' ';//精度问题
return 0;
}
1.6 例题
例 1 [BZOJ2194] 快速傅里叶之二
题意:求
容易发现此时后面的下标差值恒为定值
为了使下标和为定值,我们需要将里面的一个
实际上这就告诉我们差卷积也是卷积的基本形式,也可以直接 FFT 求解
例 2 [Tyvj1953] Normal
首先难点实际上在于转化期望式子,转化不对你是做不出来的。我们考虑每一个点都会做一次分治中心,当点
考虑后面的概率怎么求,不难发现的是,如果
考虑怎样求这个,实际上就是求每种长度的路径有多少条,这个时候就要用点分治了。考虑怎样统计子树信息,由于子树合并的复杂度我不会证,所以考虑子树容斥。具体的,先求出当前分治中心内所有点到分治中心的距离,然后我们需要两两组合拼成一条新路径。设距离分治中心距离为
标准的不能再标准的卷积形式了,将
例 3 [BZOJ3771] Triple
题意: 给出
其实这道题运用的思想和生成函数差不多。我们构造这样一个多项式
然后考虑怎么求,分类讨论即可。
- 对于选
个,直接累加到答案里即可。 - 对于选
个,先求出 ,然后需要减去选两个重复下标的方案数,重新构造一个多项式 来表示这个方案数,答案即为 ,注意最后要除以 。 - 对于选
个,先求出 ,然后要减去选了两个重复下标以及三个重复下标的方案数。构造一个多项式 来表示选三个重复下标的方案数。对于前者先求出 ,但是这样又多算了选三个重复下标的方案,所以再减去 ;并且由于这种选法会重复算三次,所以应该减去 。于是答案即为 。注意最后要除以 。
例 4 [BZOJ3160] 万径人踪灭
这道题告诉我们一个事实:FFT 可以实现对字符串的操作。
考虑到回文串的实质实际上就是下标和相等的字符相等。看到下标和相等就可以联想到和卷积,现在的问题是我们怎样构造多项式使得我们能够区分出相等字符和不相等字符。
考虑卷积需要将两项相乘,于是我们令 a
代表 b
代表
然后我们现在没有判掉的就是连续的回文串,用 Manacher 再跑一遍即可。
2 快速数论变换 NTT
前置知识:原根相关。
2.1 引入
上文中我们介绍了 FFT,它可以在
于是我们就引入了数论变换(Number-Theoretic Transform,NTT)来解决模意义下的多项式乘法。
2.2 替换单位根
实际上,在模意义下,FFT 过程中只有单位根不能再使用。我们考虑模意义下有没有能够代替单位根的东西。考虑上面 FFT 中我们运用了单位根的那些性质:
。 。 。 对于 互不相等,且 。
那么什么数可以代替单位根呢?实际上就是上面提到的原根。
对于一个质数
-
。 。 -
。 。 -
。 。只需证明 即可。由于
,于是 。又由于 ,所以 。 -
最后一个条件上文已经说明过,不再赘述。
那么我们就可以确定新的 “单位根” 就是
。 。 。
所以我们其实只需要将 FFT 中的
2.3 代码
const int Mod = 1004535809;
const int G = 3;//原根
const int InvG = 334845270;//原根逆元
int r[Maxn];
struct Poly {
int n; vector <int> a;
void reset(int len) {n = len, a.resize(len + 1);}
int& operator [](int x) {return a[x];}
void NTT(int len, int typ) {
reset(len - 1);
for(int i = 0; i < len; i++) if(i < r[i]) swap(a[i], a[r[i]]);
for(int h = 1; h < len; h <<= 1) {
int cur = qpow(typ == 1 ? G : InvG, (Mod - 1) / (h << 1), Mod);
for(int i = 0; i < len; i += (h << 1)) {
int w = 1;
for(int j = 0; j < h; j++, w = w * cur % Mod) {
int x = a[i + j], y = w * a[i + j + h] % Mod;
a[i + j] = (x + y) % Mod;
a[i + j + h] = (x - y + Mod) % Mod;
}
}
}
if(typ == -1) {
int iv = qpow(len, Mod - 2, Mod);
for(int i = 0; i < len; i++) a[i] = a[i] * iv % Mod;
}
}
Poly operator * (Poly y) {
Poly x = *this, z;
int n = x.n + y.n, len = 1;
while(len <= n) len <<= 1;
for(int i = 0; i < len; i++) r[i] = (r[i >> 1] >> 1) | ((i & 1) * (len >> 1));
x.NTT(len, 1), y.NTT(len, 1);
z.reset(len - 1);
for(int i = 0; i < len; i++) z[i] = x[i] * y[i] % Mod;
z.NTT(len, -1);
z.reset(n);
return z;
}
}F;
2.4 例题
例 1 [SDOI2015] 序列统计
这个题看上去就是一个普通的背包问题,但是权值从和变成了乘。考虑怎样将乘法化为加法,自然可以想到对数。那么在模
于是我们对于
例 2 [HAOI2018] 染色
首先满足要求的最多颜色数是
相信这一步读者可以自行完成。然后根据二项式反演可以得到:
将中间的组合数拆开可以得到:
我们发现后面的和式正好可以分为两个部分,并且满足差卷积的形式,因此构造出两个多项式然后直接卷积即可求出
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)
· winform 绘制太阳,地球,月球 运作规律