Prüfer 序列(普吕弗序列)
Prüfer 序列的作用:
————建立“无根标号树”与“一维数组”之间的完美双射(一一对应)。
一棵 \(n\) 个节点的树,必然能唯一对应一个长度为 \(n-2\),且值域在 \([1, n]\) 的序列;反之亦然。
一、 核心算法机制
不论是“树转序列”还是“序列转树”,最朴素的做法(每次找最小叶子)都需要用优先队列(小根堆),时间复杂度是 \(O(N \log N)\)。
但在算法竞赛的极限数据下,我们有一个极其优雅的 \(O(N)\) 单调指针魔法。
1. 树变序列(Encode)朴素规则:
每次找到编号最小的叶子节点,记录它的唯一邻居,然后删掉这个叶子,直到剩下 2 个节点。
\(O(N)\) 魔法:我们维护一个指针 ptr,初始指向编号最小的叶子。当我们删掉当前叶子 \(u\) 后,它的邻居 \(v\) 的度数会减 1。如果 \(v\) 变成了新叶子,且 \(v < ptr\):不要再去往后找了!\(v\) 绝对是当前全树最小的叶子,下一步直接处理 \(v\)。否则:说明新产生的叶子比 ptr 大,不着急,让 ptr 继续老老实实往右遍历找下一个叶子就行。
2. 序列变树(Decode)朴素规则:
序列中数字出现的次数 \(+ 1\) 就是该节点的度数。每次找度数为 1 的最小节点,与序列当前的第一个数字连边,然后两者的度数同时减 1。
\(O(N)\) 魔法:同样维护一个指针 ptr 指向最小的叶子。当我们把序列的当前元素 \(v\) 和叶子 \(u\) 连边后,\(v\) 的度数减 1。如果 \(v\) 变成了新叶子,且 \(v < ptr\):直接把下一个要连边的叶子指定为 \(v\)。否则:ptr 继续往右找。
模板
#include <iostream>
#include <vector>
#include <numeric>
using namespace std;
// ==========================================
// 1. 树转 Prüfer 序列 (O(N))
// 传入:n 个点,n-1 条边 (0-based 或 1-based 无所谓,这里以 1-based 为例)
// 返回:长度为 n-2 的 Prüfer 序列
// ==========================================
vector<int> tree_to_prufer(int n, const vector<pair<int, int>>& edges) {
vector<int> degree(n + 1, 0);
vector<int> parent(n + 1, 0);
vector<vector<int>> adj(n + 1);
// 建图并统计度数
for (auto& edge : edges) {
adj[edge.first].push_back(edge.second);
adj[edge.second].push_back(edge.first);
degree[edge.first]++;
degree[edge.second]++;
}
// 以 n 为根,将无根树转为有根树,方便找每个节点的唯一父节点
auto dfs = [&](auto&& self, int u, int p) -> void {
parent[u] = p;
for (int v : adj[u]) {
if (v != p) self(self, v, u);
}
};
dfs(dfs, n, 0);
vector<int> prufer;
int ptr = 1;
while (degree[ptr] != 1) ptr++; // 找到编号最小的叶子
int leaf = ptr;
for (int i = 0; i < n - 2; ++i) {
int p = parent[leaf];
prufer.push_back(p);
degree[p]--;
// 魔法指针回退逻辑
if (degree[p] == 1 && p < ptr) {
leaf = p;
} else {
ptr++;
while (degree[ptr] != 1) ptr++;
leaf = ptr;
}
}
return prufer;
}
// ==========================================
// 2. Prüfer 序列转树 (O(N))
// 传入:长度为 n-2 的 Prüfer 序列 (值域 1~n)
// 返回:n-1 条边构成的树
// ==========================================
vector<pair<int, int>> prufer_to_tree(int n, const vector<int>& prufer) {
vector<int> degree(n + 1, 1);
// 统计 Prüfer 序列中每个节点的出现次数
for (int x : prufer) {
degree[x]++;
}
vector<pair<int, int>> edges;
int ptr = 1;
while (degree[ptr] != 1) ptr++; // 找到编号最小的叶子
int leaf = ptr;
for (int x : prufer) {
edges.push_back({leaf, x});
degree[x]--;
// 魔法指针回退逻辑
if (degree[x] == 1 && x < ptr) {
leaf = x;
} else {
ptr++;
while (degree[ptr] != 1) ptr++;
leaf = ptr;
}
}
// 最后必然剩下两个度数为 1 的节点,将它们连接
edges.push_back({leaf, n});
return edges;
}
三、 核心题型
题型 1:Cayley 定理(完全图生成树计数)
题意:给你 \(n\) 个不同的节点,它们之间两两都可以连边,问一共能组成多少棵不同的树?破局:一棵树对应一个长度为 \(n-2\) 的 Prüfer 序列。序列里的每个位置都可以随便填 \(1\) 到 \(n\)。公式:$$n^{n-2}$$
题型 2:已知所有节点度数的树计数题意:
给你 \(n\) 个节点,规定每个节点的度数必须是 \(d_i\),求能构成多少棵树?破局:在序列中,节点 \(i\) 必须出现 \(d_i - 1\) 次。这就是一个总长为 \(n-2\) 的多重全排列。公式:$$\frac{(n-2)!}{\prod_{i=1}^{n} (d_i-1)!}$$
题型 3:森林连通计数(拼图游戏)题意:
现在图里已经有 \(k\) 个连通块了,它们的大小分别是 \(s_1, s_2, \dots, s_k\)。现在我要加 \(k-1\) 条边,把它们连成一棵包含全图 \(n\) 个节点的大树,有多少种连法?破局:把每个连通块当成一个“超级节点”,它们之间的连边方案同样受 Prüfer 序列约束。公式:$$n^{k-2} \prod_{i=1}^{k} s_i$$(变种:如果只说图里有 \(k\) 个连通块但没给定大小,生成树的方案数是 \(k \times n^{n-k-1}\))。
题型 4:期望与生成函数(高端局)题意:
树的权值与每个节点的度数有关(比如 \(\prod W(d_i)\)),求所有生成树的权值之和或期望。破局:既然 \(d_i = \text{出现次数} + 1\),题目就等价于:把 \(n-2\) 个球放入 \(n\) 个不同的盒子里,每个盒子有一个关于球数的权值函数,求所有分配方案的权值和。这就完美转化成了 EGF(指数型生成函数)或者背包 DP,彻底脱离了图论的苦海!
模板题
P2290 [HNOI2004] 树的计数
题目描述
一个有 \(n\) 个节点的树,设它的节点分别为 \(v_1,v_2,\ldots,v_n\),已知第 \(i\) 个节点 \(v_i\) 的度数为 \(d_i\),问满足这样的条件的不同的树有多少棵。
输入格式
输入文件第一行是一个正整数 \(n\) ,表示树有 \(n\) 个结点。第二行有 \(n\) 个数,第 \(i\) 个数表示 \(d_i\),即树的第 \(i\) 个结点的度数。
输出格式
输出满足条件的树有多少棵。
输入输出样例 #1
输入 #1
4
2 1 2 1
输出 #1
2
说明/提示
\(1\le n\le 150\),保证满足条件的树不超过 \(10^{17}\) 个。
可以转换成在一个n-2的排列中,知道每个数字出现的次数,计算可行的答案
题目不去取模,所以我们计算答案的时候学习一个勒让德定理,计算出n!中包含的质数,并且计算出他的次幂,然后就可以避免高精度除法。
#include <iostream>
#include <vector>
#include <numeric>
using namespace std;
const int MAXN = 1005;
// ================= 1. 高精度乘法模板 =================
struct BigInt {
vector<int> d;
BigInt() { d.push_back(1); } // 默认初始值为 1
// 乘一个单精度整数 x
void mul(int x) {
int carry = 0;
for (size_t i = 0; i < d.size(); ++i) {
long long cur = 1LL * d[i] * x + carry;
d[i] = cur % 10;
carry = cur / 10;
}
while (carry) {
d.push_back(carry % 10);
carry /= 10;
}
}
// 倒序打印
void print() {
if (d.empty()) { cout << 0 << "\n"; return; }
for (int i = (int)d.size() - 1; i >= 0; --i) {
cout << d[i];
}
cout << "\n";
}
};
// ================= 2. 筛质数 =================
int prime[MAXN], p_cnt;
bool is_p[MAXN];
void get_prime(int n) {
fill(is_p + 2, is_p + n + 1, true);
for (int i = 2; i <= n; ++i) {
if (is_p[i]) prime[++p_cnt] = i;
for (int j = 1; j <= p_cnt && i * prime[j] <= n; ++j) {
is_p[i * prime[j]] = false;
if (i % prime[j] == 0) break;
}
}
}
// ================= 3. 勒让德定理统计阶乘的质因数 =================
int cnt[MAXN]; // 记录每个质数出现的次数
// sign 为 1 表示在分子(乘法),为 -1 表示在分母(除法)
void add_fact(int x, int sign) {
for (int i = 1; i <= p_cnt; ++i) {
int p = prime[i];
if (p > x) break;
int temp = x;
while (temp) {
cnt[p] += sign * (temp / p);
temp /= p;
}
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<int> d(n + 1);
long long sum = 0;
for (int i = 1; i <= n; ++i) {
cin >> d[i];
sum += d[i];
}
// 陷阱 3:特判 n = 1
if (n == 1) {
if (d[1] == 0) cout << 1 << "\n";
else cout << 0 << "\n";
return 0;
}
// 陷阱 1:度数之和必须等于 2n - 2
if (sum != 2 * n - 2) {
cout << 0 << "\n";
return 0;
}
// 陷阱 2:如果有节点度数为 0,不可能连通
for (int i = 1; i <= n; ++i) {
if (d[i] == 0) {
cout << 0 << "\n";
return 0;
}
}
// 预处理 1~n 范围内的素数
get_prime(n);
// 分子:(n-2)!
add_fact(n - 2, 1);
// 分母:所有 (d[i]-1)!
for (int i = 1; i <= n; ++i) {
add_fact(d[i] - 1, -1);
}
// 将剩下的质数全部乘起来
BigInt ans;
for (int i = 1; i <= p_cnt; ++i) {
while (cnt[prime[i]] > 0) {
ans.mul(prime[i]);
cnt[prime[i]]--;
}
}
ans.print();
return 0;
}

浙公网安备 33010602011771号