参考了:Matrix67 - 经典证明:Prüfer编码与Cayley公式
是一种挺有意思的转化
在无向图中构造无根树时,如果限制条件比较简单,Prufer序列是可以完全替代矩阵树定理的
~ Prufer编码 ~
Prufer编码,是一种对于带标号无根树的编码,使得一个Prufer序列$p$能够唯一对应一棵带标号无根树,且不重不漏
编码方式是这样的:
对于一棵$n$个节点的带标号无根树($ngeq 2$),我们每次找到标号最小的叶节点,将其所连接的非叶节点的标号添加进序列,然后删去这个叶节点;不断这样循环,直到剩余节点数为$2$
由于这个做法将$n$个节点的树删成$2$个节点,所以得到的Prufer序列的长度为$n-2$
引用Matrix67的例子:对于以下带标号无根树求Prufer序列
1. 先把所有叶节点全部拎出来,发现为${4,7,8,9}$,其中节点$4$最小,于是将节点$4$所连接的非叶节点$3$加入序列、并删去节点$4$;序列$p$暂时为${3}$
2. 此时的叶节点有${7,8,9}$,将$7$所连接的非叶节点$3$加入序列、并删去节点$7$;序列$p$暂时为${3,3}$
3. 此时的叶节点有${3,8,9}$,将节点$3$所连接的非叶节点$5$加入序列、并删去节点$3$;序列$p$暂时为${3,3,5}$
4. 之后不断重复这个过程,最终得到Prufer序列$p={3,3,5,2,5,6,1}$
上面我们提到了 一个Prufer序列唯一对应一棵有标号的无根树
那么给定一个Prufer序列时,如何构造对应的树呢?其实和上面的做法是十分类似的
首先我们有一个性质:在Prufer序列$p$中的节点为无根树中的非叶节点,不在$p$中的节点为叶节点
这是显然的,因为叶节点只会被删去,而每次加入序列的点都是叶节点所连接的非叶节点
然后可以将这个性质稍加推广:在子序列$p[i...n-2]$中的节点为操作过$i-1$次的无根树中的非叶节点,不在$p[i...n-2]$中、且未被前$i-1$次操作删去的节点为叶节点
这又可以推出另一个性质:Prufer序列所确定的无根树,节点$i$的度数等于$i$在Prufer序列中出现次数+1
那么我们可以维护当前无根树的叶节点集合,而集合中的最小元素就是将被删去的叶节点
multiset<int> nleaf,leaf; for(int i=1;i<=n-2;i++) nleaf.insert(p[i]); for(int i=1;i<=n;i++) if(nleaf.find(i)==nleaf.end()) leaf.insert(i); for(int i=1;i<=n-2;i++) { printf("%d %d ",*leaf.begin(),p[i]); leaf.erase(leaf.begin()); nleaf.erase(nleaf.find(p[i])); if(nleaf.find(p[i])==nleaf.end()) leaf.insert(p[i]); } printf("%d %d ",*leaf.begin(),*(++leaf.begin()));
利用Prufer序列,我们尝试证明Cayley公式
Cayley公式指的是,$n$阶完全图$K_n$有$n^{n-2}$棵生成树;或者说$n$个节点的带标号无根树有$n^{n-2}$棵
这里的幂次$n-2$让我们很眼熟,因为这恰是Prufer序列的长度;那么$n^{n-2}$就代表着,$n-2$个位置任意填$[1,n]$中的数,都可以构成Prufer序列
事实上也确实是这样的,因为我们总能保证叶节点集合的大小至少为$2$——在$p[i...n-2]$中出现的非叶节点数加上已删去的$i-1$个叶节点最多只有$n-2$个,剩余的至少$2$个节点均为当前无根树的叶节点;所以这$n^{n-2}$个序列均为合法的Prufer序列
而一个Prufer序列唯一对应一个带标号无根树,那么Cayley公式得证
还有一个奇妙的结论是,一个度数序列为${d_1,d_2,...,d_n}$的带标号无根树共有$frac{(n-2)!}{prod_{i=1}^{n} (d_i-1)!}$棵
证明比较容易:
一个度数序列为$d_i$的节点必会在Prufer序列中出现$d_i-1$次,那么不妨从$i=1$开始计算;要满足$1$号节点的度数为$d_1$,就需要在Prufer序列的$n-2$个位置中选$d_1-1$个填$1$,则有$egin{pmatrix} n-2\d_1-1end{pmatrix}$中选法;而$i=2$时需要从剩余的$(n-2)-(d_1-1)$个位置中选$d_2-1$个,有$egin{pmatrix} (n-2)-(d_1-1)\d_2-1end{pmatrix}$……将组合数用阶乘展开,就是$frac{(n-2)!}{(d_1-1)![(n-2)-(d_1-1)]!}cdot frac{[(n-2)-(d_1-1)]!}{(d_2-1)![(n-2)-(d_1-1)-(d_2-1)]!}cdot ...$,即为上面的结论
~ 一些题目 ~
BZOJ 1005 (明明的烦恼,$HNOI2008$)
先把$d_i eq -1$的情况按照上面的方法用阶乘展开,那么$n-2-sum_{d_i eq -1} (d_i-1)$个未被选的位置就可以任意填$d_i=-1$的$i$
这题本来需要高精度乘除,但是由于$n$很小,所以可以在阶乘展开式中统计每个数被乘了几次,然后质因数分解,这样就只需要处理高精乘了
#include <cstdio> #include <vector> #include <algorithm> using namespace std; const int N=1005; int n,cnt,sum; int d[N]; vector<int> fac[N]; int val[N]; int len,bignum[3*N]; int main() { for(int i=2;i<N;i++) { int x=i; for(int j=2;j*j<=x;j++) while(x%j==0) { fac[i].push_back(j); x/=j; } if(x>1) fac[i].push_back(x); } scanf("%d",&n); for(int i=1;i<=n;i++) { scanf("%d",&d[i]); if(d[i]>0) cnt++,sum+=d[i]; if(d[i]>=n || d[i]==0) { printf("0 "); return 0; } } if(sum+n-cnt>2*n-2 || (cnt==n && sum!=2*n-2)) printf("0 "); else { int rem=n-2; for(int i=1;i<=n;i++) { if(d[i]<0) continue; for(int j=1;j<=rem;j++) val[j]++; for(int j=1;j<=d[i]-1;j++) val[j]--; for(int j=1;j<=rem-(d[i]-1);j++) val[j]--; rem-=(d[i]-1); } val[n-cnt]+=rem; for(int i=1;i<=n;i++) if(fac[i].size()>1) { for(int j=0;j<fac[i].size();j++) val[fac[i][j]]+=val[i]; val[i]=0; } len=1,bignum[1]=1; for(int i=2;i<=n;i++) for(int j=1;j<=val[i];j++) { int carry=0; for(int k=1;k<=len;k++) { int tmp=bignum[k]*i+carry; bignum[k]=tmp%10; carry=tmp/10; if(carry) len=max(len,k+1); } } for(int i=len;i>=1;i--) printf("%d",bignum[i]); } return 0; }
HDU 5629 ($Clarke and tree$)
看到这种计算方案数的题目,就能想到是DP
首先很明显需要两维$i,k$分别表示选择到了节点$i$、Prufer序列中已填过了$k$个位置(这里的Prufer序列没有固定的长度,而是仅仅是一个可以插入数值的序列);但是仅仅两维不足以表示状态,因为我们无法知道当前一共选中了多少个节点(度数为$1$的点不会对$k$产生影响),所以需要再加一维$j$表示一共选中了$j$个节点
那么$dp[i][j][k]$有两种转移方式,分别是选中/不选中节点$i$
若不选中节点$i$,那么有$dp[i+1][j][k]+=dp[i][j][k]$
若选中节点$i$,那么可以对$dp[i+1][j+1][l]$产生贡献(向$l$转移时需要考虑$a[i]$的限制);那么就相当于向之前长度为$k$的Prufer序列中插入$l-k$个$i$,这可以看成有$l$个位置,先选择$l-k$个填$i$,剩下$k$个依次填之前的Prufer序列,于是贡献为$egin{pmatrix}l\l-kend{pmatrix}$
于是转移方程为$dp[i+1][j+1][l]+=dp[i][j][k]cdot egin{pmatrix}l\l-kend{pmatrix}$
那么最终选择$i(igeq 2)$个节点的答案为$dp[n+1][i][i-2]$;当$i=1$时答案为$n$
#include <cstdio> #include <cstring> #include <algorithm> using namespace std; typedef long long ll; const int N=55; const int MOD=1000000007; ll C[N][N]; int n; int a[N]; ll dp[N][N][N]; int main() { for(int i=0;i<N;i++) C[i][0]=C[i][i]=1; for(int i=1;i<N;i++) for(int j=1;j<i;j++) C[i][j]=(C[i-1][j]+C[i-1][j-1])%MOD; int T; scanf("%d",&T); while(T--) { scanf("%d",&n); for(int i=1;i<=n;i++) scanf("%d",&a[i]); memset(dp,0,sizeof(dp)); dp[1][0][0]=1; for(int i=1;i<=n;i++) for(int j=0;j<i;j++) for(int k=0;k<=n-2;k++) { if(!dp[i][j][k]) continue; dp[i+1][j][k]=(dp[i+1][j][k]+dp[i][j][k])%MOD; for(int l=k;l<=n-2 && l-k<a[i];l++) dp[i+1][j+1][l]=(dp[i+1][j+1][l]+dp[i][j][k]*C[l][l-k])%MOD; } printf("%d ",n); for(int i=2;i<=n;i++) printf("%d",dp[n+1][i][i-2]),putchar(i==n?' ':' '); } return 0; }
CF 156D ($Clues$)
在通过Prufer序列恢复带标号无根树的过程中,我们是将节点相连构成一棵树
而在这题中,我们有许多连通块;如果我们将每一个连通块缩成一个点,那么就相当于将缩点相连构成一棵树
那么我们就可以将Prufer序列进行推广
假设有$m$个连通块,其中每个连通块的大小为$s[i]$($1leq ileq m$),那么推广的Prufer序列长度为$m-2$
在每一个位置,我们可以任意填$xin [1,n]$,表示有一个缩点与$x$所在的缩点相连
这样填完以后,我们就得到了缩点之间的相连关系(其中非叶缩点的连接处确定),但是我们并不知道叶缩点的连接处是什么
事实上,叶缩点的连接处可以是这个连通块的任意节点,即有连通块大小种连接方式;那么长度为$m-2$的序列就能确定$m-2$个叶缩点的连接方式
现在唯一没有确定的就是最后剩下来的$2$个缩点了;不过他们之间也可以随意连,所以连接方式为两连通块大小之积
于是答案为$n^{m-2}cdot prod_{i=1}^{m} s[i]$,十分奥妙
#include <cstdio> #include <vector> #include <cstring> #include <algorithm> using namespace std; typedef long long ll; const int N=100005; int n,m,k; vector<int> v[N]; bool vis[N]; void dfs(int x,int &sz) { sz++; vis[x]=true; for(int i=0;i<v[x].size();i++) { int y=v[x][i]; if(!vis[y]) dfs(y,sz); } } int main() { scanf("%d%d%d",&n,&m,&k); for(int i=1;i<=m;i++) { int x,y; scanf("%d%d",&x,&y); v[x].push_back(y); v[y].push_back(x); } ll ans=1; int cnt=0,sz; for(int i=1;i<=n;i++) if(!vis[i]) { cnt++,sz=0; dfs(i,sz); ans=ans*sz%k; } for(int i=1;i<=cnt-2;i++) ans=ans*n%k; printf("%lld ",cnt==1?1LL%k:ans); return 0; }
ZOJ 4069 ($Sub-cycle Graph$,$2018ICPC$青岛)
Prufer序列+生成函数
CF 917D ($Stranger Tree$)
Luogu P5219 (无聊的水题)
还要生成函数+NTT,那就咕了
牛客 5672I (Valuable Forests,2020牛客暑期多校第七场)
可以用Cayley公式+dp推出$n$个点无根森林的结果。困难的地方在于对题目中条件的转化,以及固定两点、三点时生成树个数是通过卷积来计算的。
AtCoder arc106F (Figures)
根据题目的意思,所有的part通过connecting components相连,那么part就相当于节点,connecting component相当于边。
这样来看,原图中的$d_i$就限制了该节点在树中的度数必须在$1leq deg_ileq d_i$之间。
假设我们已经确定了树中每个点的度数序列为$deg_i$,考虑该度数序列可以产生多少种不同的figure。
首先根据Prufer序列的推论,一个度数序列为$deg_i$的带标号无根树的数量为$frac{(n-2)!}{prod_{i=1}^n (deg_i-1)!}$。再考虑第$i$个节点的$deg_i$条边可能占用hole的不同情况有$frac{d_i!}{(d_i-deg_i)!}$种(依次给$deg_i$条边确定hole,第一条边有$n$种方案,第二条有$n-1$种,…)。于是该度数序列能够产生的figure数量为:
[egin{align*} &frac{(n-2)!}{prod_{i=1}^n (deg_i-1)!}cdot prod_{i=1}^nfrac{d_i!}{(n-deg_i)!}\ =&(n-2)!cdot frac{d_i!}{(deg_i-1)!cdot (d_i-deg_i)!}\ =&(n-2)!cdot prod_{i=1}^negin{pmatrix} d_i\ deg_i-1end{pmatrix}cdot d_iend{align*}]
由于$prod$里面是组合数相乘的形式,所以对其考虑构造生成函数求解。
$(1+x)^{d_i}$中,$x^{deg_i}$项的系数为$egin{pmatrix}d_i\ deg_iend{pmatrix}$,那么如果对其求导正是所要求的式子。于是构造的生成函数为:
[prod_{i=1}^n d_icdot (1+x)^{d_i-1}=(1+x)^{sum_{i=1}^n d_i -n}cdot prod_{i=1}^n d_i]
而我们想要的是其中第$n-2$项的系数(一个合法的度数序列应该有$sum_{i=1}^n d_i=2(n-1)$,而由于我们对于每一项均求导,所以需要的幂次为$2(n-1)-n=n-2$),其为$egin{pmatrix}sum_{i=1}^n d_i-n\ n-2end{pmatrix}$。
将其带回原来的式子,则最终结果为:
[egin{align*} &(n-2)!cdot egin{pmatrix}sum_{i=1}^n d_i-n\ n-2end{pmatrix}\ =&(n-2)!cdot frac{(sum_{i=1}^n d_i-n)!}{(n-2)!cdot (sum_{i=1}^n d_i-2n-2)!}\ =&frac{(sum_{i=1}^n d_i-n)!}{(sum_{i=1}^n d_i-2n-2)!}end{align*}]
#include <cstdio> #include <cstring> #include <algorithm> using namespace std; const int N=200005; const int mod=998244353; int n; int d[N]; int main() { scanf("%d",&n); int sum=0,mul=1; for(int i=1;i<=n;i++) { scanf("%d",&d[i]); sum=(sum+d[i])%mod; mul=1LL*mul*d[i]%mod; } int ans=mul; for(int i=-n;i>=-2*n+3;i--) ans=1LL*ans*(sum+i+mod)%mod; printf("%d ",ans); return 0; }
(待续)