斯特林数

1 上升幂和下降幂

1.1 定义

我们定义上升幂 nm 和下降幂 nm 如下:

nm=i=nn+m1i=(n+m1)!(n1)!nm=i=nm+1ni=n!(nm)!

需要注意这里的 n,m 同样可以取到负数。

1.2 性质

首先我们先来看上升幂和下降幂对于指数求和的展开形式,显然有:

na+b=na×(n+a)bna+b=na×(na)b

这个性质十分良好,因为它启示我们利用倍增去求解一些多项式,比如说 f(x)=xn 展开后的系数。在下文中会重新提到这一部分。

然后考虑上升下降幂之间的转化,显然有:

nm=(1)m(n)m=(n+m1)mnm=(1)m(n)m=(nm+1)m

接下来是下降幂和组合数的一些性质。实际上不难看出,nm 实际上就是 Anm,于是我们可以将组合数转化如下:

(nm)=nmm!

于是我们可以推出下面的式子,通过这个我们可以换掉组合数的底数而保持值不变:

(nm)=nmm!=(1)m(n)mm!=(1)m(mn1)mm!=(1)m(mn1m)

接下来进一步的,我们将组合数与下降幂相乘:

(nk)ki=n!k!(nk)!k!(ki)!=(ni)!(ki)!(nk)!n!(ni)!=(niki)ni

如此操作后我们便将 ki 这个变量改成了 ni 这个定值,然后就可以进一步求解了。

不难看出,这一部分的难点就在于推式子并化简,上面是几个常见的化简形式,做题时需要注意。

2 第二类斯特林数

斯特林数是一个组合数学概念,分为第一类斯特林数和第二类斯特林数,是一种广泛运用于解决组合问题的利器。

由于第二类斯特林数更加常见,所以先介绍第二类斯特林数。

2.1 定义

第二类斯特林数写作 {nm},表示将 n 个不同元素划分为 m 个互不区分的非空子集方案数。更通俗的来讲就是将 n 个不同的球放入 m 个相同的盒子,每个盒子至少放一个的方案数。

接下来我们容易写出其递推式,如下:

{nm}={n1m1}+m{n1m}

边界是 {n0}=[n=0]。考虑用组合意义证明其正确性。当我们考虑放一个新球的时候,有两种方案:

  • 将球放到一个空的盒子里,方案数 {n1m1}
  • 将球放到一个现有的非空盒子里,方案数 m{n1m}

显然递推求解的复杂度是 O(nm) 的。

2.2 通项公式

第二类斯特林数有实用的通项公式,如下:

{nm}=i=0m(1)mi(mi)!ini!

接下来我们考虑证明这个公式,需要用到二项式反演。

Fi 表示将 n 个不同的球放到 i 个不同的盒子里,每个盒子至少放一个的方案数。令 Gi 表示将 n 个不同的球放到 i 个不同的盒子里的方案数。显然有:

Gi=in

接下来根据二项式反演得到:

Gi=j=0i(ij)FjFi=j=0i(1)ij(ij)Gj

然后可以得到:

Fi=j=0i(1)ij(ij)Gj=j=0i(1)ij(ij)jn=j=0i(1)ij×i!×jn(ij)!×j!

由于 Fi 求得是盒子不同的方案数,而斯特林数求得是盒子相同的方案数,所以最后除以 i! 即 可得到斯特林数的通项公式:

{nm}=Fmm!=i=0m(1)mi(mi)!ini!

2.3 同一行第二类斯特林数的计算

回到上面的通项公式,不难发现对于相同的 n,后面的和式正好构成了一个和卷积的形式,于是我们直接使用 FFT / NTT 对其进行求解即可。

模板题:第二类斯特林数·行,代码如下:

#include <bits/stdc++.h>
#define int long long

using namespace std;

const int Maxn = (1 << 19) + 5;
const int Inf = 2e9;
const int Mod = 167772161;
const int YG = 3;
const int InvG = 55924054;

int n;

int qpow(int a, int b) {
	int res = 1;
	while(b) {
		if(b & 1) res = res * a % Mod;
		a = a * a % Mod; b >>= 1;
	} 
	return res;
}

int f[Maxn], g[Maxn];
void init() {
	f[0] = 1;
	for(int i = 1; i <= n; i++) f[i] = f[i - 1] * i % Mod;
	g[n] = qpow(f[n], Mod - 2);
	for(int i = n - 1; i >= 0; i--) g[i] = g[i + 1] * (i + 1) % Mod;
}

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 ? YG : InvG, (Mod - 1) / (h << 1));
			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 = a[i + j + h] * w % Mod;
					a[i + j] = (x + y) % Mod;
					a[i + j + h] = (x - y + Mod) % Mod;
				}
			}
		}
		if(typ == -1) {
			int iv = qpow(len, Mod - 2);
			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, G;

signed main() {
	ios::sync_with_stdio(0);
	cin.tie(0), cout.tie(0);
	cin >> n;
	init();
	F.reset(n), G.reset(n);
	for(int i = 0; i <= n; i++) {
		F[i] = g[i] * ((i & 1) ? -1 : 1);
		G[i] = g[i] * qpow(i, n) % Mod;
	}
	F = F * G;
	for(int i = 0; i <= n; i++) cout << F[i] << " ";
	return 0;
}

同一列第二类斯特林数的计算较为困难,以后有机会再写。

3 第一类斯特林数

3.1 定义

第一类斯特林数写作 [nm],表示将 n 个两两不同的元素划分成 m 个互不区分的非空轮换的方案数。通俗来讲就是 n 个不同的人坐在 m 张相同的圆桌上,每一张圆桌至少坐一个人的方案数。

接下来我们容易写出其递推式,如下:

[nm]=[n1m1]+(n1)[n1m]

边界依旧是 [n0]=[n=0]。考虑用组合意义证明其正确性。当我们考虑放一个新人的时候,有两种方案:

  • 将这个人放到已经有的圆桌中,那么需要考虑它坐在那一个人旁边,方案数为 (n1)[n1m]
  • 将这个人放到一个新的空圆桌中,方案数为 [n1m1]

显然递推求解的复杂度是 O(nm) 的。

第一类斯特林数没有实用的通项公式,在此不做介绍。

3.2 同一行第一类斯特林数的计算

我们构造出同一行第一类斯特林数的生成函数如下:

Fn(x)=i=0n[ni]xi

然后接下来根据递推式写出生成函数递推式:

Fn(x)=xFn1(x)+(n1)Fn1(x)=(x+n1)Fn1(x)

于是有:

Fn(x)=i=0n1(x+i)=xn

于是实际上同一行第一类斯特林数的生成函数就是 xn。在 1.2 小节中我们提到过可以使用倍增来求出其各项系数,现在我们来看怎样求。O(nlog2n) 直接分治的做法是显然的,不过我们有单 log 做法:

首先明确 Fn(x)=xn,设 Fn(x)=i=0naixi,于是会有:

F2n(x)=x2n=xn(x+n)n=(i=0naixi)(i=0nai(x+n)i)=(i=0naixi)(i=0naij=0i(ij)xjnij)=(i=0naixi)(j=0nxji=jnai(ij)nij)=(i=0naixi)(j=0nxji=jnainiji!j!(ij)!)=(i=0naixi)(i=0nxii!j=in(ajj!)nji(ji)!)

最后面的和式显然就是一个差卷积的形式,多项式乘起来即可。然后这两个括号相乘还是多项式乘法,再乘一次即可得出 F2n(x)。于是我们可以在:

T(n)=T(n2)+O(nlogn)=O(nlogn)

的复杂度内求出 xn 的展开形式,即同一行第一类斯特林数的值。当然如果 n 是奇数的话直接求会漏掉 x+n 这一项,暴力乘上去就行。

模板题:第一类斯特林数·行,代码如下:

#include <bits/stdc++.h>
#define int long long

using namespace std;

const int Maxn = (1 << 20) + 5;
const int Inf = 2e9;
const int Mod = 167772161;
const int YG = 3;
const int InvG = 55924054;

int n;

int qpow(int a, int b) {
	int res = 1;
	while(b) {
		if(b & 1) res = res * a % Mod;
		a = a * a % Mod, b >>= 1;
	}
	return res;
}

int f[Maxn], g[Maxn];
void init() {
	f[0] = 1;
	for(int i = 1; i <= n; i++) f[i] = f[i - 1] * i % Mod;
	g[n] = qpow(f[n], Mod - 2);
	for(int i = n - 1; i >= 0; i--) g[i] = g[i + 1] * (i + 1) % Mod;
}

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 ? YG : InvG, (Mod - 1) / (h << 1));
			for(int i = 0; i < len; i += (h << 1)) {
				for(int j = 0, w = 1; j < h; j++, w = w * cur % Mod) {
					int x = a[i + j], y = a[i + j + h] * w % Mod;
					a[i + j] = (x + y) % Mod;
					a[i + j + h] = (x - y + Mod) % Mod;
				}
			}
		}
		if(typ == -1) {
			int iv = qpow(len, Mod - 2);
			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;
	}
};

Poly solve(int n) {
	if(n == 1) {
		Poly F; F.reset(n);
		F[1] = 1; return F;
	}
	int m = n >> 1;
	Poly F = solve(m);
	Poly G, H; G.reset(m), H.reset(m);
	for(int i = 0; i <= m; i++) G[i] = qpow(m, i) * g[i] % Mod, H[m - i] = F[i] * f[i] % Mod;
	H = G * H; G.reset(m);
	for(int i = 0; i <= m; i++) G[i] = H[m - i] * g[i] % Mod;
	G = F * G; F.reset(n);
	for(int i = 0; i <= n; i++) {
		if(n & 1) F[i] = (G[i] * (n - 1) % Mod + (i ? G[i - 1] : 0)) % Mod;
		else F[i] = G[i];
	}	
	return F;
}

signed main() {
	ios::sync_with_stdio(0);
	cin.tie(0), cout.tie(0);
	cin >> n;
	init();
	Poly F = solve(n);
	for(int i = 0; i <= n; i++) {
		cout << F[i] << " ";
	}
	return 0;
}

同一列第一类斯特林数的计算也较为困难,以后再写。

4 应用

4.1 幂的互化

首先我们回到上升幂和下降幂,考虑上升幂、下降幂与普通幂之间的互相转化。

在 3.2 小节内我们已经给出了上升幂转化成普通幂的形式:

xn=i=0n[ni]xi

对其作斯特林反演即可得到普通幂转化成上升幂的形式:

xn=i=0n{ni}(1)nixi

让我们来考虑普通幂转化成下降幂的形式,我们有:

xn=i=0n{ni}i!(xi)=i=0n{ni}xi

考虑组合意义,xn 即表示将 n 个不同的球放到 x 个不同的盒子里,盒子可以为空的方案数;那么不妨先枚举有球的盒子的个数 i,然后我们要将 n 个球放到这 i 个不同的盒子里,盒子不可以为空,显然这个方案数就是 {ni}i!。所以上式成立。

对其作斯特林反演即可得到下降幂转化成普通幂的形式:

xn=i=0n[ni](1)nixi

不过实际上斯特林反演的证明需要运用到这个公式,所以我们需要用另外的方法证明它。容易发现:

xn=(1)n(x)n=(1)ni=0n[ni](x)i=i=0n(1)n+i[ni]xi=i=0n(1)ni[ni]xi

至此我们就可以实现普通幂、上升幂、下降幂之间的灵活转化了。

4.2 例题

例 1 [联合省选 2020 A 卷] 组合数问题

Link

暴力推式子即可。首先化开 f(k)

k=0nf(k)×xk×(nk)=k=0ni=0maiki×xk×(nk)=i=0maik=0nki×xk×(nk)

现在看后面的和式,对其进行如下变换:

k=0nki×xk×(nk)=k=0nj=0i{ij}kj×xk×(nk)=k=0nj=0i{ij}nj×xk×(njkj)=j=0i{ij}njk=0nxk×(njkj)=j=0i{ij}njk=0nj(njk)×xk+j=j=0i{ij}njxjk=0nj(njk)×xk=j=0i{ij}njxj(x+1)nj

于是我们就可以在 O(m2) 的复杂度内得到原式结果了。

例 2 [国家集训队] Crash 的文明世界

Link

我们要求距离的 k 次方和,首先想到的肯定是运用二项式定理对距离进行合并或拆分。于是想到换根 dp,设 f(i,j) 表示 i 子树内到 i 的距离的 j 次方和,那么可以在 O(nk2) 的复杂度内求出答案,无法通过。

考虑将 k 次方做斯特林展开:

S(i)=j=1ndis(i,j)k=j=1nm=1k{km}m!(dis(i,j)m)=m=1k{km}m!j=1n(dis(i,j)m)

于是我们只需要求出所有的 (dis(i,j)m) 即可,设 f(i,j) 表示 i 子树内到 i 距离的 (disj) 之和,由于我们有 (n+1m)=(nm)+(nm1),所以转移方程为 f(i,j)=f(to,j)+f(to,j1)。然后做一遍换根即可得出正确答案。复杂度 O(k2+nk),可以通过。

例 3 [TJOI/HEOI2016] 求和

Link

暴力拆式子即可:

f(n)=i=0nj=0i{ij}×2j×j!=i=0nj=0n{ij}×2j×j!=j=0n2j×j!i=0n{ij}=j=0n2j×j!i=0nk=0j(1)jk(jk)!kik!=j=0n2j×j!k=0j(1)jk(jk)!i=0nkik!

发现后面的和式是一个朴素和卷积的形式,因此直接 NTT 求出卷积即可。复杂度 O(nlogn)

例 4 [BZOJ5093] 图的价值

题意:定义一个带标号简单无向图的价值为每个点度数的 k 次方之和,求所有 n 个点的带标号简单无向图价值之和。

不难发现每个点的贡献是独立的,因此我们可以枚举每个点的度数并计算其对应方案数。那么对于一个点,其贡献如下:

i=0n1(n1i)×2n(n1)2n+1×ik

ik 做斯特林展开可得:

i=0n1(n1i)×2n(n1)2n+1×j=0k{kj}j!(ij)=j=0k{kj}j!i=0n1(n1i)×2n(n1)2n+1×(ij)=i=0k{ki}i!j=0n1(n1j)×2n(n1)2n+1×(ji)=i=0k{ki}j=0n12n(n1)2n+1×i!×(n1j)×(ji)=i=0k{ki}j=0n12n(n1)2n+1×(n1iji)×(n1)i=i=0k2n(n1)2n+1{ki}(n1)ij=0n1(n1iji)=i=0k2n(n1)2n+1{ki}(n1)i2n1i

所以我们只需要求出 k 这一行上的斯特林数即可 O(k) 求出答案,求一行上第二类斯特林数直接 NTT 即可,复杂度 O(klogk)

例 5 [FJOI2016] 建筑师

Link

考虑 dp,设 dp(i,j) 表示前 i 个建筑可以看到 j 个的方案数,则有转移:

dp(i,j)=dp(i1,j1)+(i1)dp(i1,j)

不难发现 dp(i,j)=[ij]。然后考虑答案,枚举最大值所在位置,答案即为:

i=1n[i1A1][niB1](n1i1)

复杂度是 O(n) 的,不能接受。考虑组合意义优化,我们认为每个数及其后面被他遮住的建筑为一块,那么我们直接求出 [n1A+B2],这样会构成 A+B2 个块,从这些块里选出 B1 个块放到后面即可构成一个合法序列,所以答案实际上就是:

[n1A+B2](A+B2B1)

如此预处理出第一类斯特林数即可 O(1) 求解答案。

posted @   UKE_Automation  阅读(32)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)
· winform 绘制太阳,地球,月球 运作规律
点击右上角即可分享
微信分享提示