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", °[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;
}

浙公网安备 33010602011771号