Prufer序列 学习笔记

\(Prufer\) 序列

\(Prufer\) 序列可以将一棵 \(n\) 个点的有编号无根树 转化成一个长度为 \(n - 2\),值域在 \([1, n]\) 的序列。也可以将 任意一个 长度为 \(n - 2\),值域在 \([1, n]\) 的序列转化成一棵 有编号无根树。可以理解成有标号完全图的生成树与数列之间的双射,常用于对树计数的问题。

对树建立 \(Prufer\) 序列

建立过程:
每次找到编号最小的叶子,往序列末尾加入它所连接的节点编号,然后删掉这个叶子。重复 \(n - 2\) 次后只剩下两个节点结束。

实现方式:
考虑维护指针 \(p\) 指向当前最小的叶子编号,每次删掉 \(p\) 后判断 \(p\) 相连的点是否变成了叶子并且编号小于 \(p\),如果满足这两个条件那么接着删掉这个点。重复这个过程直到当前叶子相连的点不满足上述条件,然后令 \(p\) 不断自增直到找到下一个叶子。

正确性说明:

  • 每次删掉一个叶子后最多只会增加一个叶子,并且如果增加了叶子那么一定是当前叶子所连的点。
  • 设当前叶子为 \(p\),删掉 \(p\) 后新增叶子 \(q\)
  • \(q < p\),那么 \(q\) 一定比其它叶子更小,删 \(q\) 是正确的。
  • \(q > p\),那么后面 \(p\) 自增时一定能枚举到,因此不用管。

这样指针只会移动 \(O(n)\) 次,每个点只会被删除一次,复杂度 \(O(n)\)

代码:

inline void TP() { // 树 -> prufer序列
	for(int i = 1; i < n; i ++ ) deg[fa[i]] ++;
	int p = 1;
	for(int i = 1; i <= n - 2; ) {
		int j = i;
		while(deg[p]) p ++; pf[j ++] = fa[p]; deg[fa[p]] --;
		int tp = p;
		while(j <= n - 2 && !deg[fa[p]] && fa[p] < tp) p = fa[p], pf[j ++] = fa[p], deg[fa[p]] --;
		i = j; p = ++ tp;
	}
}

\(Prufer\) 序列的性质:

  • 构造完 \(Prufer\) 序列后原树会剩下两个节点,其中一定有一个为 \(n\)
  • 原树中的每个点一定在 \(Prufer\) 序列中出现 \(deg - 1\) 次,没有出现过的就是叶子节点。

从上述建立过程可以看出来:
任意一棵 \(n\) 个点有编号无根树都可以建立出唯一的 \(Prufer\) 序列,并且本质不同的树对应的 \(Prufer\) 序列一定不同
这就完成了 从树到 \(Prufer\) 序列 的单射。

\(Prufer\) 序列重建树

建立过程:
对于给定的长为 \(n - 2\),值域在 \([1, n]\)\(prufer\) 序列,我们可以统计每个点的出现次数来求出每个点的度数。考虑每次找到编号最小的叶子来确定与它相连的点,显然这个相连点应该是当前 \(Prufer\) 序列的第一个数。然后把这个叶子删掉,令相连点的度数减一,然后把 \(Prufer\) 序列的第一个数删掉,重复这个过程 \(n - 2\) 次。剩下两个叶子,把它们之间连一条边即可。

实现方式:
注意到最后剩下的两个叶子也一定有一个是 \(n\),我们将 \(Prufer\) 序列的末尾补上一个 \(n\),然后重复上述过程 \(n - 1\) 次就可以连出 \(n - 1\) 条边。

与建立 \(Prufer\) 序列的过程类似,考虑维护指针 \(p\) 表示当前编号最小的叶子,然后每次删掉叶子后判断新增叶子 \(q\) 是否比 \(p\) 小,如果是的话就接着去连 \(q\) 的出边,否则就不断自增 \(p\) 直到找到下一个叶子。可以看作对于当前叶子 \(p\)\(Prufer\) 序列的开头就是以 \(n\) 为根下 \(p\) 的父亲节点

也不难看出 \(\to\) 序列序列 \(\to\) 的过程是互逆的。一个 \(Prufer\) 序列重建的树对应的 \(Prufer\) 序列也一定是这个 \(Prufer\) 序列。

复杂度 \(O(n)\)

代码:

inline void PT() { // prufer序列 -> 树
	pf[n - 1] = n;
	for(int i = 1; i <= n - 2; i ++ ) deg[pf[i]] ++;
	int p = 1;
	for(int i = 1; i <= n - 1;) {
		int j = i;
		while(deg[p]) p ++; fa[p] = pf[j ++], deg[fa[p]] --;
		int tp = p;
		while(j <= n - 1 && !deg[fa[p]] && fa[p] < tp) p = fa[p], fa[p] = pf[j ++], deg[fa[p]] --;
		i = j; p = ++ tp;
	}
}

由上述重构过程可以看出:
任意一个长度为 \(n - 2\),值域在 \([1, n]\) 的序列,都能作为一个 \(Prufer\) 序列唯一的构造出一棵树。并且任意两个不同 \(Prufer\) 序列构造的树都是不同的

这样就完成了从 \(Prufer\) 序列到树 的单射。结合上边,就得到了:

\(n\) 个点编号为 \(1 \sim n\) 的无根树与长度为 \(n - 2\),值域为 \([1, n]\) 的序列双射

应用

Cayley 公式

\(n\) 个点有编号的完全图生成树个数为 \(n^{n - 2}\)

证明:双射即可。

[HNOI2004] 树的计数

题意:
给定 \(n\) 个点的度数 \(d_1,\dots d_n\),任意两个点之间可以连边,问有多少个满足度数数组的生成树。

\(1 \leq n \leq 150\)

分析:
特判 \(n = 1\) 的边界情况。那么任意一个点的度数不能为 \(0\),并且所有点的度数和应为 \(2n - 2\)
一个点只要在 \(Prufer\) 序列中出现 \(d_i - 1\) 即满足构造出来的树中度数为 \(d_i\)。这就是多重集的全排列。

CODE:

#include<bits/stdc++.h>
using namespace std;
const int N = 155;
typedef long long LL;
int n, deg[N], all;
LL res = 1, C[N][N];
int main() {
	scanf("%d", &n); int all = n - 2;
	for(int i = 0; i <= n; i ++ ) 
		for(int j = 0; j <= i; j ++ ) 
			if(!j) C[i][j] = 1;
			else C[i][j] = C[i - 1][j - 1] + C[i - 1][j];
	for(int i = 1; i <= n; i ++ ) {
		scanf("%d", &deg[i]);
		if(deg[i] == 0) {
			if(n == 1) {puts("1"); return 0;}
			else {puts("0"); return 0;}
		}
		res = res * C[all][deg[i] - 1];
		all -= deg[i] - 1;
	}
	if(all != 0) puts("0");
	else cout << res << endl;
	return 0;
}

「雅礼集训 2017 Day8」共

题意:
给定 \(n, k\)。 你需要求出有多少个编号为 \(1 \sim n\),以 \(1\) 为根的无向树,满足深度为奇数的点恰好有 \(k\) 个。\(1\) 的深度认为是 \(1\)

\(1 \leq k < n \leq 5 \times 10^5\)

分析:
显然可以将点按照深度的奇偶性分成两类,这将树变成了一张二分图。
\(1\) 划分到左部点中,那么需要从剩下的 \(n - 1\) 个编号里选出 \(k - 1\) 个分到左部点。答案就是 \(\binom{n - 1}{k - 1} \times S(k, n - k)\)
其中 \(S(a, b)\) 表示一张左部点有 \(a\) 个,右部点有 \(b\) 个的有标号完全二分图的生成树个数。
答案是 \(a^{b-1}b^{a - 1}\)
证明:
对于这样二分图的生成树,\(Prufer\) 序列构造过程的末尾一定剩下一个左部点和一个右部点。因此它的 \(Prufer\) 序列一定有 \(b - 1\) 个左部点和 \(a - 1\) 个右部点。
对于这两部分的构成的子序列内部,如果确定了编号和顺序。那么两部分之间在 \(Prufer\) 序列上的顺序就定下来了。因此总方案数就是 \(a^{b - 1}b^{a - 1}\)

CODE:

// 这种双射还真是秒啊
#include<bits/stdc++.h>
using namespace std;
const int N = 5e5 + 10;
typedef long long LL;
int n, k, p;
inline LL Pow(LL x, LL y) {
	LL res = 1, k = x;
	while(y) {
		if(y & 1) res = res * k % p;
		y >>= 1;
		k = k * k % p;
	}
	return res;
}
LL fac[N], inv[N];
int main() {
	cin >> n >> k >> p;
	fac[0] = 1; for(int i = 1; i < N; i ++ ) fac[i] = fac[i - 1] * i % p;
	inv[N - 1] = Pow(fac[N - 1], p - 2); for(int i = N - 2; i >= 0; i -- ) inv[i] = inv[i + 1] * (i + 1) % p;
	int L = k, R = n - k; 
	LL res = fac[n - 1] * inv[k - 1] % p * inv[n - k] % p * Pow(L, R - 1) % p * Pow(R, L - 1) % p;
	cout << res << endl;
	return 0;
}

[THUPC 2018] 城市地铁规划

题意:

给定一个 \(n, k, mod\),然后给你一个 \(k\) 次多项式 \(f(x)\) 的每一项系数 \(a_0,\dots,a_k\)。对于一个度数为 \(d\) 的点,它的贡献为 \(f(d) \pmod{mod}\)。你可以给编号为 \(1 \sim n\) 的点之间任意连边,求所有生成树中所有点贡献和最大的方案,输出最大贡献和以及一组构造。

\(1 \leq n \leq 3000, 0 \leq k \leq 10, mod = 59393\)

分析:
首先预处理出 \(i\in [1, n]\) 的所有 \(w_i = f(i)\)
注意到任意一个 \(Prufer\) 序列都能构造出一棵树,并且在这棵树上一个点的度数为它在序列中的出现次数加一。因此有一个暴力的 \(dp\)
\(f_{i, j}\) 表示考虑了编号为 \(1 \sim i\) 的点, 这些点在 \(Prufer\) 序列的出现总次数为 \(j\) 的最大代价。转移枚举 \(i + 1\) 的出现次数 \(k\) 即可。最后的答案就是 \(f_{n, n - 2}\)
但是这样 \(dp\) 的复杂度为 \(O(n^3)\),考虑优化:
注意到我们不关心每个点的出现次数,只关心 每种出现次数有几个点。 所以可以以出现次数为阶段 \(dp\)
\(f_{i, j}\) 表示考虑了 \(1 \sim i\) 这些出现次数,当前总出现次数为 \(j\) 的最大代价。
那么转移是:\(\Large{f_{i, j} = \max\limits_{k = 0}^{\left \lfloor \frac{j}{i} \right \rfloor}}(f_{i - 1, j - i \times k}+k \times w_{i + 1})\)

现在转移复杂度就变成\(O(n\ln n)\),总复杂度 \(O(n^2\ln n)\)
但是好像有个问题,我们没有计算度数为 \(1\) 的点的贡献,并且记录的出现次数也没办法体现有多少个点的度数不是 \(1\)
肯定不能多加一维来记录。我们考虑初始令 \(f_{0, 0} = n \times w_1\),然后每次转移除了 \(+k\times w_{i}\) 还要 \(-k\times w_1\) 表示把这 \(k\) 个点的代价换掉。
这样就对了。构造只需要对每个状态记录前驱,然后任意拿一些编号构造 \(prufer\) 序列即可。

复杂度 \(O(n^2 \ln n)\)
CODE:

// prufer 序列与有标号无根树双射的好处:任意 prufer 序列都能构造出一棵树。因此只需要考虑 prufer 序列最优即可
// 对每个点去 dp 它在 prufer 序列出现多少次,复杂度 n^3 优化不了。
// 但是我们只关心每种次数有多少个。 对每个次数 dp 复杂度就降到了 n^2 ln n
#include<bits/stdc++.h>
using namespace std;
const int N = 4010;
const int mod = 59393;
int n, k, a[N];
int val[N], f[N][N], pre[N][N]; // f[i][j] 表示考虑了前 1~i 这些次数, 当前用的总次数为 j 的最优值
int fa[N], prufer[N], tot, p, deg[N];
inline void get_prufer(int x, int y) {
	if(!x) return ;
	if(pre[x][y] < y) {
		for(int i = 1; i <= (y - pre[x][y]) / x; i ++ ) {
			for(int j = 1; j <= x; j ++ ) prufer[++ tot] = p;
			p ++;
		}
	}
	get_prufer(x - 1, pre[x][y]);
}
inline void PT() {
	prufer[n - 1] = n;
	for(int i = 1; i <= n - 1; i ++ ) deg[prufer[i]] ++;
	int p = 1;
	for(int i = 1; i <= n - 1;) {
		int j = i;
		while(deg[p]) p ++; fa[p] = prufer[j ++], deg[fa[p]] --;
		int tp = p;
		while(j <= n - 1 && !deg[fa[tp]] && fa[tp] < p) tp = fa[tp], fa[tp] = prufer[j ++], deg[fa[tp]] --;
		i = j; p ++;
	}
}
int main() {
	scanf("%d%d", &n, &k);
	for(int i = 0; i <= k; i ++ ) scanf("%d", &a[i]);
	for(int i = 0; i <= n; i ++ ) {
		int v = 1;
		for(int j = 0; j <= k; j ++ ) {
			val[i] = (val[i] + v * a[j] % mod) % mod;
			v = v * i % mod;
		}
	}
	if(n == 1) {printf("0 %d\n", val[0]); return 0;}
	memset(f, 0xcf, sizeof f); f[0][0] = n * val[1]; // 初始所有度数都是 1
	for(int i = 1; i <= n - 2; i ++ ) {
		for(int j = 0; j <= n - 2; j ++ ) {
			for(int k = 0; k * i <= j; k ++ ) {
				if(f[i - 1][j - k * i] + k * val[i + 1] - k * val[1] > f[i][j]) {
					f[i][j] = f[i - 1][j - k * i] + k * val[i + 1] - k * val[1];
					pre[i][j] = j - k * i;
				}
			}
		}
	}
	printf("%d %d\n", n - 1, f[n - 2][n - 2]);
	p = 1;
	get_prufer(n - 2, n - 2);
	PT();
	for(int i = 1; i < n; i ++ ) printf("%d %d\n", i, fa[i]);
	return 0;
}

CF156D Clues

题意:
给定一张 \(n\) 个点, \(m\) 条边的有标号无向图,它有 \(k\) 个连通块,求添加 \(k - 1\) 条边使得图联通的方案数。答案对 \(p\) 取模。

\(1 \leq n,m \leq 10^5, 1 \leq p \leq 10^9\)

分析:
将连通块缩成一个点,就转换成了类似完全图求生成树的问题。
如果用连通块的编号来构造 \(Prufer\) 序列,那么答案就是 \(k^{k - 2}\)。这显然不对,原因在于一个连通块编号实际上代表了 \(size\) 个点。
如果对于每一种以连通块编号构造的 \(Prufer\) 序列,我们都把每个连通块的编号换成任意连通块内的点的编号。那么这实际上就等于将所有点的编号拿出来构造长为 \(k - 2\)\(Prufer\) 序列,方案数为 \(n^{k - 2}\)
这对不对呢?很遗憾,它也是不对的。
我们来尝试理解这样得到的 \(Prufer\) 序列有什么实际含义:相当于每次取出度数为 \(0\) 的编号最小的连通块,然后把这个连通块和 \(Prufer\) 序列开头编号的连通块相连,并且根据 \(Prufer\) 序列我们知道连向的点的编号就是序列开头数字。
然后你就能发现问题所在了:我们不知道当前的 "叶子" 连出去的点编号是什么!!
因此开始的时候将答案乘上 \(\prod size_i\) 表示先对每个连通块钦定一个往外连出去的点的编号,然后再乘上 \(n^{k - 2}\) 就行。

最后答案就是 \(n^{k - 2}\prod size_i\)。复杂度线性。

CODE:

#include<bits/stdc++.h>
using namespace std;
const int N = 1e5 + 10;
typedef long long LL;
int n, m, mod, sz[N], bin[N];
int Find(int x) {return x == bin[x] ? x : bin[x] = Find(bin[x]);}
inline void Merge(int u, int v) {
	int f1 = Find(u), f2 = Find(v);
	if(f1 != f2) sz[f2] += sz[f1], bin[f1] = f2;
}
inline LL Pow(LL x, LL y) {
	LL res = 1, k = x;
	while(y) {
		if(y & 1) res = res * k % mod;
		y >>= 1;
		k = k * k % mod;
	}
	return res;
}
int main() {
	ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
	cin >> n >> m >> mod;
	for(int i = 1; i <= n; i ++ ) bin[i] = i, sz[i] = 1;
	for(int i = 1; i <= m; i ++ ) {
		int u, v; cin >> u >> v;
		Merge(u, v);
	}
	LL res = 1; int cnt = 0;
	for(int i = 1; i <= n; i ++ ) {
		if(Find(i) == i) res = res * sz[i] % mod, cnt ++;
	}
	if(cnt == 1) {cout << (1 % mod) << endl; return 0;}
	else {		
		res = res * Pow(n, cnt - 2) % mod;
		cout << res << endl;
	}
	return 0;
}

[ARC106F] Figures

题意:
\(N\) 个点,每个点有 \(d_i\)互不相同、可被区分 的孔,每次可以选择两个不同点,连接两个未被连接过的孔,有多少种方案使得最后形成一棵树。合法方案中可以不把孔填满。答案对 \(998244353\)

\(2 \leq N \leq 2 \times 10^5, 1\leq d_i < 998244353\)

分析:
和上道题非常类似,只是一个联通块中的一个点只能被连接一次,上一道题是可以连接任意次。
一样的思考方式,如果我们把所有孔的编号拿出来,有 \(M = \sum d_i\) 个孔,用 \(A_{M}^{N - 2}\) 来生成 \(Prufer\) 序列。
这样还是不正确的,错误原因仍然是每个点作为叶子连出去时没确定一个孔。
如果最后考虑这个孔是谁,我们还需要知道每个点有多少个孔在 \(Prufer\) 序列出现,这不太好。
还是最开始就考虑这个孔是谁,先乘上 \(\prod d_i\) 的系数,然后这些孔就不能用了,因此我们用 \(A_{M - N}^{N - 2}\) 来构建 \(Prufer\) 序列。这样就对了,答案就是 \(A_{M - N}^{N - 2}\prod d_i\)

复杂度线性。

CODE:

#include<bits/stdc++.h>
using namespace std;
const int N = 2e5 + 10;
typedef long long LL;
const LL mod = 998244353;
int n, d[N];
LL res = 1;
inline LL A(LL n, LL m) {
	if(n < m) return 0;
	LL res = 1;
	for(LL i = n; i > n - m; i -- ) res = res * (i % mod) % mod;
	return res;
}
int main() {
	scanf("%d", &n); LL s = 0;
	for(int i = 1; i <= n; i ++ ) {
		scanf("%d", &d[i]); s += d[i] - 1;
		res = res * d[i] % mod;
	}
	res = res * A(s, n - 2) % mod; 
	cout << res << endl;
	return 0;
}

posted @ 2026-09-09 20:32  Czl2007  阅读(5)  评论(0)    收藏  举报