多项式学习笔记
naoliaok_lovely · · 算法·理论
前置知识:高中数学。
代码可以翻对应题目的提交记录。
多项式
相关定义
如上式子被我们称作关于
这个定义是我们在初中学过的,这个表示方式被我们称作“系数表示法”。But,这个我们还有别的方法可以表示这个多项式,例如图像法等,这里要介绍一种新的方法——点值表示法。
对于
运算
我们已知
加减法只需要对应系数相加减,复杂度
至于乘除法就相对复杂。在以前的计算中,我们会把两个多项式通过乘法分配律,展开进行计算,复杂度达到了惊人的
分治乘法
我们把两个数分别按照前
(其实非常废,屁用没有)
但是,如果是在点值表示法下,乘除法会变得非常简单——直接把对应点值先相乘除嘛!于是我们产生了寻找两种表示法之间关系的想法。
我们考虑如何快速计算上面的“多点求值”&“插值”。通过前面的理论分析,我们需要得到
DFT 帮助我们找到了有关联且方便计算的点。
DFT(离散傅里叶变换)
定义:将数组
事实上,这玩意的原始用途并不是多项式,而是用于信号处理。不过好巧不巧,这玩意在多项式上有很大的作用。如果我们把
但是 DFT 本身并没有加快程序的速度,仍然是
IDFT(离散傅里叶逆变换)
定义:将数组
证明?带入即可。
单位根反演
由 IDFT 发现的一个重要结论:
证明如下:
这个东西可以方便判断倍数,例如 P5591 小猪佩奇学数学。
FFT(快速傅里叶变换)
当
设
其实就是把奇偶分开了。
我们观察这样做有什么好处。设
我们发现,通过将原问题划分为两个子问题,可以在线性复杂度内找到
解决了求值,别忘了还有插值。显然插值对应了求值的逆运算,所以可以用 IDFT 解决。
Code
注意,在理想状态下,算完之后的虚部应当为
void FFT(Complex a[], int tot, int step, int inv)//inv表示正/逆变换
{
if(n == 1) return;
int m = n >> 1;
FFT(a, m, step << 1, inv, a);
FFT(a + step, m, step << 1, inv, b + m);
Complex w = (Complex){cos(PI / m), sin(PI / m) * inv}, wk = (Complex){1, 0};
for(int i = 0; i < m; i++, wk = wk * w)
{
Complex x = a[i], y = a[i + m] * wk;
a[i] = x + y, b[i + m] = x - y;
}
}
int main()
{
FFT(a, tot, 1, 1), FFT(b, tot, 1, 1);
for(int i = 0; i < tot; i++) a[i] = a[i] * b[i];
FFT(a, tot, 1, -1);
for(int i = 0; i < tot; i++) a[i].x /= n, a[i].y /= n;
return 0;
}
然而这玩意根本过不了板题
上述代码的常数很大,我们尝试进行优化。
观察 FFT 的前五行都在干些什么?其实是以一个奇怪的顺序进行 swap 操作。稍加思考可以发现,最后就是把
而且
事实上,优化完的板子由于运算次数减少了,不仅常数会减少,精度也会变高!
常数优化
常见的 fft 有两个比较通用且显著的优化:
三次变两次
上面的程序中,每进行一次多项式乘法都会执行
注意到两个多项式在赋初值的时候,他们的虚部都是空着的,这造成了浪费,于是我们考虑把
长度优化
由于最后的长度必然是
当然这里并不是仅仅指避免开多余的长度——就算是不多于的长度,我们也可以压缩。举个例子,
上面的例子并不是巧合,这是有迹可循的:
因此,如果我们用不到那些被影响的项,完全可以压缩长度。举个例子,我们把
(比较 nice 的是,这个优化显然可以丢到 ntt 上)
NTT(快速数论变换)
FFT 由于涉及到了小数运算,带来了两个缺陷:其一是常数问题,不过也还能接受,毕竟慢得不会太多;其二是精度问题,这就很头疼了,因为避免不了!于是,在涉及到“取模”的问题时,FFT 就显得力不从心了。
带来小数运算的根本原因是单位根!那么有没有什么东西,可以代替掉单位根呢?
由于
- 封闭性
- 结合性
- 单位元
- 逆元
似乎单位根就用到了这些性质?
那有没有替代品呢?
在一般情况下其实不太好找,不过当模数为质数的时候,有一个非常妙的东西——原根!容易发现,当模数为质数的时候,原根满足上述单位根的所有性质!换句话说,我们可以用原根来代替单位根。
NTT 模数
在上面代码中用到了这样一个单位根:
这便是 NTT 模数的由来。因为经过上面的推导,我们发现并非所有数都可以做 ntt。
常见的 NTT 模数:
一个有趣的故事
事实上,在以前的 OI 中,出题人偏爱的模数只有
为了防止这种情况发生,有人便开始呼吁:我们以后都把题目的模数改为
小技巧
由于取模运算很慢,且足足执行了
void NTT(int a[], int inv)
{
if(!rev[bit][1])
for(int i = 0; i < tot; i++) rev[bit][i] = rev[bit][i >> 1] >> 1 | (i & 1) << bit - 1;
static unsigned LL t[N];
for(int i = 0; i < tot; i++) t[i] = a[rev[bit][i]];
static int g[N];
g[0] = 1;
for(int len = 1; len < tot; len <<= 1)
{
int g1 = ksm(~inv ? 3 : 332748118, (mod - 1) / (len << 1));
for(int i = len - 2; i >= 0; i -= 2)
g[i] = g[i >> 1], g[i + 1] = 1ll * g[i] * g1 % mod;
for(int i = 0; i < tot; i += len << 1)
for(int j = i; j < i + len; j++)
{
int g1 = t[j + len] * g[j - i] % mod;
t[j + len] = t[j] - g1 + mod, t[j] += g1;
}
}
for(int i = 0; i < tot; i++) a[i] = t[i] % mod;
if(inv == -1)
{
int t = ksm(tot, mod - 2);
for(int i = 0; i < tot; i++) a[i] = 1ll * a[i] * t % mod;
}
}
在这里我们采取了 unsigned longlong 进行处理,且相比于下面的模板有微小的加速。事实上,我也用普通的 longlong 处理过这个问题,不过很遗憾 WA 飞了。问题在哪里呢?
我们考虑值域变化:注意到代码中的
当然,上述分析仅仅是理论上限,真正想达到这个上界不大可能,所以这个模板平时不会炸,但依然不建议平时用,也就卡常的时候掏出来用用得了……
(文章末尾有一些更高端的卡常技巧,这个其实真的没啥用)
第二类斯特林数·行
学会了 NTT,就可以解决这个经典问题了。
事实上,第二类斯特林数存在通项公式:
证明如下:
考虑如下问题:
有一个长度为n 的序列且满足a_i\in[1,m] ,要求1\sim m 的每个数至少出现一次,求方案数?【法一】原问题等价于先将序列分为
m 个部分,再依次标号1\sim m ,所以方案数为S(n,m)\cdot m! 【法二】考虑二项式反演。既然要求每个数都要出现,容斥即可,方案数为
\sum_{i=1}^m(-1)^{m-i}\cdot C_m^i\cdot i^n 。于是联立上面的式子,将组合数用阶乘表示,即可得证。
我们将上面的式子整理可得:
于是构造
分治 NTT
一个小技巧。
先讲讲比较简单的版本。计算:
如果依次相乘,每次乘法的规模是
但是如果是上面的模板题那样,前后具有依赖关系,好像就不能朴素分治了,需要改一下。其实只需要用类似 CDQ 分治的思路即可,即在之前的基础上统计前面一半区间对后一半的贡献。
void cdq(int l, int r)
{
if(l == r) return;
int mid = l + r >> 1;
cdq(l, mid);
bit = lg2(r - l - 1 + mid - l) + 1, tot = 1 << bit;
for(int i = 0; i < tot; i++) a[i] = b[i] = 0;
for(int i = l; i <= mid; i++) a[i - l] = f[i];
for(int i = 1; i <= r - l; i++) b[i - 1] = g[i];
NTT(a, 1), NTT(b, 1);
for(int i = 0; i < tot; i++) a[i] = 1ll * a[i] * b[i] % mod;
NTT(a, -1);
for(int i = mid + 1; i <= r; i++) f[i] = (f[i] + a[i - l - 1]) % mod;
cdq(mid + 1, r);
}
总用时
具体地说,显然题目告诉我们存在关系
多项式全家桶
多项式求逆
首先要明白一件事:任意一个有限次数多项式,它的逆元一定是无限次数的!这一点很容易说明:采用反证,如果
所以说我们看到题目中的“
考虑倍增。在后面的很多推导中都会这样。
倍增有一个很重要的好处:虽然倍增自带
多项式开方
注意到题目中含有条件
同样倍增。
多项式 ln
首先注意到题目中的限制
证明
明确了这一点之后,其实对数函数的推导本身不算太难。
需要注意的是,因为是不定积分,所以最后算出来的常数项应该为
多项式 exp
牛顿迭代
考虑这样一个问题:已知一个可导单调函数
或许会想到二分。的确,二分可以在
考虑当前答案为
依靠牛顿迭代求解 exp
即:我们要找到一个多项式
同样倍增(倍增的是精度),因为可以使用牛顿迭代了。(在套用牛顿迭代的时候,我们
多项式快速幂
显然当
然后发现题目保证的是
事实上,对 我们不喜欢。
显然有
复杂度
bbbbbut,难道就完了吗?
打开加强版一看,没有了
其实方法很简单,把首位强制变为
最后注意一下,由于最后要把
多项式除法/取模
有点困难
这里要用到一点人类智慧(其实数学上还是非常套路的):对于多项式
由题,我们有
我们可以根据这个式子求出
模板中的
常系数齐次线性递推
喵呜\~\~\~来咯\~\~\~
为了后续方便,我们令首项下标为
以前的做法是矩阵快速幂,复杂度
我们都知道,对于
于是操作如下:
于是我们成功通过递推的方式表示出了 (和暴力一样)
不过,以上做法给了我们灵感:既然
于是,我们将上面的操作总结为:先把两个
这样做的话,暴力乘法和降次的复杂度均为
不过还不够,这道题的数据范围并不支持我们使用
另外,如果使用快速幂会带有一个
f[n] = 1;
for(int i = 0; i < n; i++) f[i] = (mod - b[n - i]) % mod;
res[0] = 1;
for(int i = 32; ~i; i--)
{
bit = lg2(n - 1 << 1) + 1, tot = 1 << bit;
for(int i = n; i < tot; i++) res[i] = 0;
NTT(res, 1);
for(int i = 0; i < tot; i++) res[i] = 1ll * res[i] * res[i] % mod;
NTT(res, -1);
Div(res, tot - 1, f, n, t, res);
if(m >> i & 1)
{
for(int j = n; j; j--) res[j] = res[j - 1];
res[0] = 0;
for(int j = 0; j < n; j++) res[j] = (res[j] + 1ll * res[n] * b[n - j]) % mod;
}
}
for(int i = 0; i < n; i++) ans = (ans + 1ll * res[i] * a[i]) % mod;
多项式多点求值
有一个范德蒙德转置矩阵的做法,是集训队论文中提到的,常数比这里的更小,但不做讲解。
考虑
由于暴力带入的复杂度达到了
但是
事实上,我们可以采用分治的方法。令
这样做的复杂度为
在实现的时候,可以用分治 NTT 求得上面的
void init(int l, int r, int deep)
{
if(l == r)
{
g[deep][l] = mod - a[l];
return;
}
int mid = l + r >> 1;
init(l, mid, deep + 1), init(mid + 1, r, deep + 1);
static int x[N], y[N];
for(int i = l; i <= mid; i++) x[i - l] = g[deep + 1][i];
for(int i = mid + 1; i <= r; i++) y[i - mid - 1] = g[deep + 1][i];
x[mid - l + 1] = y[r - mid] = 1;
bit = lg2(r - l + 1) + 1, tot = 1 << bit;
for(int i = mid - l + 2; i < tot; i++) x[i] = 0;
for(int i = r - mid + 1; i < tot; i++) y[i] = 0;
NTT(x, 1), NTT(y, 1);
for(int i = 0; i < tot; i++) x[i] = 1ll * x[i] * y[i] % mod;
NTT(x, -1);
for(int i = l; i <= r; i++) g[deep][i] = x[i - l];
}
void solve(int l, int r, int deep)
{
if(r - l <= 150)
{
int m = r - l;
for(int i = l; i <= r; i ++ )
{
b[i] = 0;
for(int j = m; j >= 0; j -- )
b[i] = (1ll * b[i] * a[i] + f[deep][j]) % mod;
}
return;
}
int mid = l + r >> 1;
static int x[N];
for(int i = l; i <= mid; i++) x[i - l] = g[deep + 1][i];
x[mid - l + 1] = 1;
Div(f[deep], r - l, x, mid - l + 1, f[0], f[deep + 1]);
solve(l, mid, deep + 1);
for(int i = mid + 1; i <= r; i++) x[i - mid - 1] = g[deep + 1][i];
x[r - mid] = 1;
Div(f[deep], r - l, x, r - mid, f[0], f[deep + 1]);
solve(mid + 1, r, deep + 1);
}
拉格朗日插值
前置知识:拉格朗日插值。
谁规定了自己的前置知识不能是自己
带修
实际上就是重心拉格朗日的应用。
这个是朴素的插值。
这是重心拉插。
于是我们只需要维护
代码很简单,懒得贴了。
多项式快速插值
在上面的公式中,既然我们可以把分子提出来,为什么不能把分母提出来呢?我们试试转化分母:
后面那坨分子分母都为
(服了,这
不过相比之前的东西起码好算得多了。我们令
分母好算多了,直接套用多点求值即可。
剩余的部分可以分治 NTT 了计算了,好耶!
复杂度同多点求值,
多项式复合逆
来点黑科技。该部分内容适合多项式老油条食用,新手建议跳过。
前置知识:拉格朗日反演,Bostan-Mori。
我们知道
大力推式子,尝试用
设
剩下的难点是如何计算所有的
将
最后补充一下二元多项式的乘法。我们用
至此,我们在
卡常宝藏!
气温大哥稳定发力。
还有指令集大手子写的 多项式超级模板。
FWT(快速沃尔什变换)
本质上不是多项式,所以没有丢进上面的封装里。
前置知识:高维前缀和。
FWT_or
然后就可以做了。发现这个东西其实就是高维前缀和,于是 IFWT_or 本质上就是高维差分。
void FWT_or(int a[], int n, int inv)
{
for(int i = 1; i < n; i <<= 1)
for(int j = 0; j < n; j += i << 1)
for(int k = j; k < j + i; k++)
~inv ? ((a[k + i] += a[k]) >= mod && (a[k + i] -= mod)) : ((a[k + i] -= a[k]) < 0 && (a[k + i] += mod));
}
FWT_and
与运算和或运算挺像的,所以把前缀换成后缀就可以解决。
void FWT_and(int a[], int n, int inv)
{
for(int i = 1; i < n; i <<= 1)
for(int j = 0; j < n; j += i << 1)
for(int k = j; k < j + i; k++)
~inv ? ((a[k] += a[k + i]) >= mod && (a[k] -= mod)) : ((a[k] -= a[k + i]) < 0 && (a[k] += mod));
}
FWT_xor
前两个只是开胃菜。这个怎么解决呢?
(尝试发明高维中缀和ing……)
我们观察前面两个的代码——好像和 NTT 极其相似,唯一的区别是括号里面的东西。我们发现,前面的
以
- 对于 FWT_or,
C_0=A_0B_0,C_1=A_0B_1+A_1B_0+A_1B_1 。 - 对于 FWT_and,
C_0=A_0B_0+A_0B_1+A_1B_0,C_1=A_1B_1 。 - 对于 FWT_xor,
C_0=A_0B_0+A_1B_1,C_1=A_0B_1+A_1B_0 。
我们发现无论是前缀和还是后缀和,都是为了因式分解,
此时就得到点值了,即把
void FWT_xor(int a[], int n, int inv)
{
for(int i = 1; i < n; i <<= 1)
for(int j = 0; j < n; j += i << 1)
for(int k = j; k < j + i; k++)
{
int x = a[k], y = a[k + i];
(a[k] = x + y) >= mod && (a[k] -= mod), (a[k + i] = x - y) < 0 && (a[k + i] += mod);
}
if(inv == -1)
{
int t = ksm(n, mod - 2);
for(int i = 0; i < n; i++) a[i] = 1ull * a[i] * t % mod;
}
}
小结
我们发现这些代码与 NTT 都极其相似,不过因为不用乘原根,所以可以省去中间过程的取模,常数小得多。
子集卷积
注意到
考虑转化“与”条件,设
(可以把原先数组的每一项都看作是一个多项式,理解起来可能会简单一点)
for(int i = 0; i <= n; i++) FWT_or(a[i], 1 << n, 1), FWT_or(b[i], 1 << n, 1);
for(int i = 0; i <= n; i++)
for(int j = 0; j <= i; j++)
for(int k = 0; k < 1 << n; k++)
c[i][k] = (c[i][k] + 1ull * a[j][k] * b[i - j][k]) % mod;
for(int i = 0; i <= n; i++) FWT_or(c[i], 1 << n, -1);