古典叙事 · 技术札记

序章

山河有卷
人间有声

写代码,也写长风、旧城与未熄的灯。
愿每一篇随笔,都有自己的山水与回声。
阅览随笔

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;
}
posted @ 2026-08-11 13:42  Morphis‘  阅读(8)  评论(0)    收藏  举报