P5291 [十二省联考 2019] 希望 题解
如果你不想浪费大量的刷题时间,请慎入。
参考题解。
题目描述
给定一棵 \(n\) 个节点的树,选择 \(k\) 个连通块,要求在连通块交集中存在一点 \(u\) ,使得 \(u\) 到连通块并集中所有点的距离都 \(\le l\) ,求方案数。
数据范围
- \(1\le n\le 10^6,1\le k\le 10,0\le l\le n\) 。
时间限制 \(\texttt{5s}\) ,空间限制 \(\texttt{1.5GB}\) 。
分析
假设我们已经知道了这 \(k\) 个连通块的形状,那么合法的 \(u\) 也会构成一个连通块。
注意到树上连通块满足 \(|V|-|E|=1\) ,所以如果用点的贡献减去边的贡献,那么每种合法的方案恰好会贡献一次。
考虑树形 \(dp\) :
\(f_{u,i}\) 表示仅考虑 \(u\) 子树内,包含 \(u\) ,最远点到 \(u\) 距离 \(\le i\) 的连通块个数(包含空集)。
\(g_{u,i}\) 表示仅考虑 \(u\) 子树外,包含 \(u\) ,最远点到 \(u\) 距离 \(\le i\) 的连通块个数(不包含空集)。
转移方程如下:
点的贡献为 \(\big((f_{u,l}-1)\cdot g_{u,l})\big)^k\) ,边的贡献为 \(\big((f_{u,l-1}-1)\cdot(g_{u,l}-1)\big)^k\) 。
\(f_{u,*}\) 减一是因为要删去空集,计算边的贡献时 \(g_{u,l}\) 减一是因为对于 \(u\) 子树外的连通块,仅有单点 \(u\) 是不合法的。
注意到 \(f_{u,i-2}\) 可能为零(没有逆元),预处理前缀后缀积即可做到 \(\mathcal O(n\cdot l)\) ,代码如下:
#include<bits/stdc++.h>
using namespace std;
const int maxn=1e6+5,mod=998244353;
int k,l,n,u,v,res;
vector<int> f[maxn],g[maxn],h[maxn];
inline void mul(int &x,int y)
{
x=1ll*x*y%mod;
}
int qpow(int a,int k)
{
int res=1;
while(k)
{
if(k&1) mul(res,a);
mul(a,a),k>>=1;
}
return res;
}
void dfs1(int u,int fa)
{
for(int i=0;i<=l;i++) f[u][i]=1;
for(auto v:h[u])
{
if(v==fa) continue;
dfs1(v,u);
for(int i=1;i<=l;i++) mul(f[u][i],f[v][i-1]);
}
for(int i=0;i<=l;i++) f[u][i]++;
}
void dfs2(int u,int fa)
{
int m=0;
vector<int> cur={0};
for(auto v:h[u]) if(v!=fa) m++,cur.push_back(v);
vector<int> pre(m+2),suf(m+2);
for(int j=1;j<=m;j++) g[cur[j]][0]=1;
for(int i=1;i<=l;i++)
{
pre[0]=suf[m+1]=1;
for(int j=1;j<=m;j++) pre[j]=1ll*pre[j-1]*(i>=2?f[cur[j]][i-2]:1)%mod;
for(int j=m;j>=1;j--) suf[j]=1ll*suf[j+1]*(i>=2?f[cur[j]][i-2]:1)%mod;
for(int j=1;j<=m;j++) g[cur[j]][i]=(1ll*g[u][i-1]*pre[j-1]%mod*suf[j+1]+1)%mod;
}
for(int j=1;j<=m;j++) dfs2(cur[j],u);
}
int main()
{
scanf("%d%d%d",&n,&l,&k);
for(int i=1;i<=n-1;i++)
{
scanf("%d%d",&u,&v);
h[u].push_back(v),h[v].push_back(u);
}
if(!l) printf("%d\n",n),exit(0);
for(int i=1;i<=n;i++) f[i].resize(l+5),g[i].resize(l+5);
dfs1(1,0);
for(int i=0;i<=l;i++) g[1][i]=1;
dfs2(1,0);
for(int u=1;u<=n;u++) res=(res+qpow((f[u][l]-1ll)*g[u][l]%mod,k))%mod;
for(int u=2;u<=n;u++) res=(res-qpow((f[u][l-1]-1ll)*(g[u][l]-1)%mod,k))%mod;
printf("%d\n",(res+mod)%mod);
return 0;
}
再考虑一档 \(l=n\) 的部分分,也就是不必考虑距离 \(\le l\) 的限制。
本质是对每个点 \(u\) ,求包含 \(u\) 的连通块个数。
\(f_u\) 表示仅考虑 \(u\) 子树内,包含 \(u\) 的连通块个数,包含空集。
\(g_u\) 表示仅考虑 \(u\) 子树外,包含 \(u\) 的连通块个数,不包含空集。
当然你可以认为是上面的 \(\texttt{dp}\) 去掉了第二维限制,转移方程留给读者自行思考。
时间复杂度 \(\mathcal O(n\log k)\) 。
#include<bits/stdc++.h>
using namespace std;
const int maxn=1e6+5,mod=998244353;
int k,l,n,u,v,res;
int f[maxn],g[maxn];
vector<int> h[maxn];
int qpow(int a,int k)
{
int res=1;
while(k)
{
if(k&1) res=1ll*res*a%mod;
a=1ll*a*a%mod,k>>=1;
}
return res;
}
void dfs1(int u,int fa)
{
f[u]=1;
for(auto v:h[u])
{
if(v==fa) continue;
dfs1(v,u),f[u]=1ll*f[u]*f[v]%mod;
}
f[u]++;
}
void dfs2(int u,int fa)
{
int m=0;
vector<int> cur={0};
for(auto v:h[u]) if(v!=fa) m++,cur.push_back(v);
vector<int> pre(m+2),suf(m+2);
pre[0]=suf[m+1]=1;
for(int i=1;i<=m;i++) pre[i]=1ll*pre[i-1]*f[cur[i]]%mod;
for(int i=m;i>=1;i--) suf[i]=1ll*suf[i+1]*f[cur[i]]%mod;
for(int i=1;i<=m;i++) g[cur[i]]=(1ll*g[u]*pre[i-1]%mod*suf[i+1]+1)%mod;
for(int i=1;i<=m;i++) dfs2(cur[i],u);
}
int main()
{
scanf("%d%d%d",&n,&l,&k);
for(int i=1;i<=n-1;i++)
{
scanf("%d%d",&u,&v);
h[u].push_back(v),h[v].push_back(u);
}
if(!l) printf("%d\n",n);
dfs1(1,0),g[1]=1,dfs2(1,0);
for(int u=1;u<=n;u++) res=(res+qpow((f[u]-1ll)*g[u]%mod,k))%mod;
for(int u=2;u<=n;u++) res=(res-qpow((f[u]-1ll)*(g[u]-1)%mod,k))%mod;
printf("%d\n",(res+mod)%mod);
return 0;
}
期望 \(20pts\) ,但实际能得 \(40pts\) 。
正解需要用到长剖优化,先考虑 \(f\) 怎么求。
关键细节:
普通的长剖第二维信息量仅有 \([0,mxd_u]\) ,而本题为 \([0,l]\) 。
但是根据 \(\texttt{dp}\) 数组的含义,对于 \(j\gt mxd_u\) ,有 \(f_{u,j}=f_{u,mxd_u}\) ,因此仅需维护 \([0,mxd_u]\) 的信息。
长儿子的信息直接继承,对于其他儿子 \(v\) , \([1,mxd_v]\) 直接暴力合并, \([mxd_v+1,mxd_u]\) 做区间乘,最后还要全局加,可持久化线段树维护即可。
需要可持久化的原因是第二遍 dfs
求 \(g\) 时还需要用到 \(f\) 。
再考虑 \(g\) 怎么做。
注意到 \(g_{u,i}\) 仅依赖于 \(g_{fa,i-1}\) ,并且我们只需用到 \(g_{u,l}\) 的值。
因此对 \(\forall 1\le u\le n\) ,第二维只需要保留 \([l-mxd_u,l]\) 的信息!
真正难搞的是 \(\prod\limits_{v\in son(fa_u),v\neq u}f_{v,i-2}\) 。
看似我们需要维护一个 \(|son(fa_u)|\times mxd_u\) 的矩阵,但实际上对于每棵子树 \(v\) ,有用的 \(f\) 只有 \(\mathcal O(mxd_v)\) 个,配合支持区间乘的数据结构,实际上只有 \(\mathcal O(\sum mxd_v)\) 个操作。
令长儿子直接继承父节点的信息,遍历所有轻儿子把系数乘上去即可。
轻儿子还是要拆成前缀后缀积,前缀积我们可以在遍历时顺便维护,后缀积可以通过第一遍求 \(f\) 的过程中逆序遍历轻儿子,利用可持久化数组维护。
这样我们花费了 \(\mathcal O(\sum\limits_{v\in lson(u)}mxd_v)\) 的代价算出了所有子节点的 \(\texttt{dp}\) 值,套用长剖的时间复杂度证明,总代价为 \(\mathcal O(n)\) 。
算上线段树的代价,我们获得了一个 \(\mathcal O(n\log n)\) 的做法,代码自然是没有。
我们希望能把线段树的 \(\log\) 砍掉,发现瓶颈在于我们需要支持区间加和区间乘。
对于 \(f\) 的优化,先不考虑可持久化,我们需要支持的是对于整条链,后缀乘和全局加。
对每个点 \(u\) 维护 mul
和 add
两个 tag
,分别为全局乘法标记和加法标记。
我们希望后缀乘能改为全局乘,然后前缀乘上其逆元。
更新时为了规避求逆元的 \(\log\) ,再维护 imul
表示 mul
的逆元即可。
然而 \(f_{v,i-1}\) 有可能刚好是模数的倍数,这时后缀乘的数是零,并不存在逆元!
更逆天的是出题人还真把这种情况造出来了,不处理这种情况会被卡 \(5\) 个点。
此时后缀乘等价于后缀赋值,额外维护 lim
和 val
两个 tag
,表示所有下标 \(\ge lim\) 的位置都应该赋值为 val
,注意 val
也会受到前面 mul
和 add
标记的影响。
更新标记也很有技术含量:
如果 \(f_{v,mxd_v}=0\) ,赋值 \(lim_u=mxd_v+1\) ,并根据 \(mul_u\) 和 \(add_u\) 反推出 \(val_u\) 的值。
否则暴力枚举轻儿子,如果更新时碰到了 \(\ge lim_u\) 的下标,那就暴力将 \(lim_u\) 往后推。
下面是一份还没有回退的代码:
namespace get_f
{
int add[maxn],mul[maxn],imul[maxn],lim[maxn],val[maxn];
void assign(int u)
{
f[u]=it,it+=mxd[u]+1;///正常分配f的空间
it+=mxd[u]-1,g[u]=it-max(l-mxd[u],0),it+=mxd[u]+1;///g[u]允许访问下标[max(l-mxd[u],0),l]
///注意g[u]指针可能指到tmp数组外面,不过问题不大
}
int ask(int u,int i)
{///询问真实的f[u][i]
return (1ll*mul[u]*(i<lim[u]?f[u][i]:val[u])+add[u])%mod;
}
int get(int u,int val)
{///通过真实值val反推数组中需要存储的值
return 1ll*(val-add[u]+mod)*imul[u]%mod;
}
void dfs(int u)
{
int x=son[u];
if(!x)
{///叶子节点f[u][i]=2
mul[u]=imul[u]=1,add[u]=2;
return ;
}
f[x]=f[u]+1,g[x]=g[u]-1,dfs(x);
add[u]=add[x],mul[u]=mul[x],imul[u]=imul[x],lim[u]=lim[x]+1,val[u]=val[x];
f[u][0]=get(u,1);///继承长儿子信息,注意f[u][0]=1
for(auto v:e[u])
{
assign(v),dfs(v);
for(int i=1;i<=mxd[v];i++)
{
if(lim[u]==i) f[u][lim[u]++]=val[u];
f[u][i]=get(u,1ll*ask(u,i)*ask(v,i-1)%mod);///先算f[u][i]*f[v][i-1],再赋值回去
}
///给f[u][mxd[v]+1~l]乘上f[v][mxd[v]]
int cur=ask(v,mxd[v]);
if(!cur)
{///特殊处理f[v][mxd[v]]=0的情况
lim[u]=mxd[v]+1,val[u]=get(u,0);
///注意操作前lim[u]>=mxd[v]+1,因此可以直接修改lim[u]
}
else
{///拆成全局乘和前缀乘逆元
::mul(add[u],cur),::mul(mul[u],cur),::mul(imul[u],inv[v]);
for(int i=0;i<=mxd[v];i++) upd(v,f[u][i]),f[u][i]=get(u,1ll*ask(u,i)*inv[v]%mod);
///注意有加法标记的存在,前缀乘逆元不能简单地写成f[u][i]*=inv[v]
}
}
add[u]=(add[u]+1)%mod;///最后别忘了全局+1
}
void solve()
{
assign(1),dfs(1);
}
}
对于 \(g\) 的优化,记 \(b_i=\prod\limits_{v\in lson(u)}f_{v,i}\) 。
按 \(mxd\) 升序枚举 \(v\) ,将 \(f_{v,0\sim mxd_v}\) 的信息贡献给 \(b\) 即可维护前缀积。
那么对 \(\forall i\ge mxd_v\) ,所有 \(b_i\) 都相同,因此只需要记录 \(b[0\sim mxd_v]\) 的信息。
这也是按 \(mxd_v\) 排序的原因所在,注意这意味着处理 \(f\) 时需要先将所有 \(v\) 降序排序。
后缀乘还是按照套路变成全局乘和前缀乘逆元,由于没有加法,所以维护 mul
和 lim
就足够了。
对于后缀积的部分,观察发现我们只会倒序用到所有点的状态!
对每个点 \(v\) 开一个栈,存储所有和 \(v\) 有关(包括 \(f_{v,*}\) 和各种 tag
)的修改位置的指针和修改前的值,即可支持回退。
由于转移方程最后还有一个全局 \(+1\) 的操作,所以我们要用到和求 \(f\) 的过程中完全相同的数据结构(即同时维护 mul,add,imul,lim,val
五个 tag
)。
最后不要忘了求逆元的一只 \(\log\) 。
观察发现整个过程我们只会用到 \(f_{v,mxd_v}\) 的逆元,离线预处理即可。
至于 \(f_{v,mxd_v}\) 怎么求,只需要做一遍简单的 dfs
,其实就是 \(l=n\) 的那档部分分。
很遗憾统计答案时的 \(\log\) 没有办法消去,因此完整时间复杂度为 \(\mathcal O(n\log k)\) 。
但是复杂度瓶颈显然是前面长剖优化树形 \(\texttt{dp}\) 的过程。
#include<bits/stdc++.h>
using namespace std;
const int maxn=1e6+5,mod=998244353;
int k,l,n,u,v,res;
int mxd[maxn],son[maxn];
int tmp[maxn<<2],*it=tmp,*f[maxn],*g[maxn];
vector<int> e[maxn],h[maxn];///e[u]初始按照mxd降序存储u的所有轻儿子
struct node
{
int *v,w;///处理f时存下所有修改,处理g时回退
};
vector<node> vec[maxn];///vector的空间消耗比stack小
void mul(int &x,int y)
{
x=1ll*x*y%mod;
}
int qpow(int a,int k)
{
int res=1;
while(k)
{
if(k&1) mul(res,a);
mul(a,a),k>>=1;
}
return res;
}
namespace pre_work
{
int f[maxn],inv[maxn],pre[maxn],suf[maxn];///f[u]为以u为根的连通块个数,inv[u]为其逆元
bool cmp(int x,int y)
{
return mxd[x]>mxd[y];
}
void dfs(int u,int fa)
{
f[u]=mxd[u]=1;
for(auto v:h[u])
{
if(v==fa) continue;
dfs(v,u);
mul(f[u],f[v]),mxd[u]=max(mxd[u],mxd[v]+1);
if(mxd[v]>=mxd[son[u]]) son[u]=v;
}
f[u]=(f[u]+1)%mod;
for(auto v:h[u]) if(v!=fa&&v!=son[u]) e[u].push_back(v);
sort(e[u].begin(),e[u].end(),cmp);///可以用桶排做到严格线性,但是我懒了
}
void solve()
{
dfs(1,0),pre[0]=1;
for(int i=1;i<=n;i++) pre[i]=1ll*pre[i-1]*(f[i]?f[i]:1)%mod;
suf[n]=qpow(pre[n],mod-2);
for(int i=n;i>=1;i--) suf[i-1]=1ll*suf[i]*(f[i]?f[i]:1)%mod;
for(int i=1;i<=n;i++) if(f[i]) inv[i]=1ll*pre[i-1]*suf[i]%mod;
}
}
using pre_work::inv;
namespace get_f
{
int add[maxn],mul[maxn],imul[maxn],lim[maxn],val[maxn];
void assign(int u)
{
f[u]=it,it+=mxd[u]+1;///正常分配f的空间
it+=mxd[u]-1,g[u]=it-max(l-mxd[u],0),it+=mxd[u]+1;///g[u]允许访问下标[max(l-mxd[u],0),l]
///注意g[u]指针可能指到tmp数组外面,不过问题不大
}
int ask(int u,int i)
{///询问真实的f[u][i]
return (1ll*mul[u]*(i<lim[u]?f[u][i]:val[u])+add[u])%mod;
}
int get(int u,int val)
{///通过真实值val反推数组中需要存储的值
return 1ll*(val-add[u]+mod)*imul[u]%mod;
}
void upd(int u,int &x)
{
vec[u].push_back({&x,x});
}
void dfs(int u)
{
int x=son[u];
if(!x)
{///叶子节点f[u][i]=2
mul[u]=imul[u]=1,add[u]=2;
return ;
}
f[x]=f[u]+1,g[x]=g[u]-1,dfs(x);
add[u]=add[x],mul[u]=mul[x],imul[u]=imul[x],lim[u]=lim[x]+1,val[u]=val[x];
f[u][0]=get(u,1);///继承长儿子信息,注意f[u][0]=1
for(auto v:e[u])
{
assign(v),dfs(v);
for(int i=1;i<=mxd[v];i++)
{
if(lim[u]==i) upd(v,f[u][i]),upd(v,lim[u]),f[u][lim[u]++]=val[u];
upd(v,f[u][i]),f[u][i]=get(u,1ll*ask(u,i)*ask(v,i-1)%mod);///先算f[u][i]*f[v][i-1],再赋值回去
}
///给f[u][mxd[v]+1~l]乘上f[v][mxd[v]]
int cur=ask(v,mxd[v]);
if(!cur)
{///特殊处理f[v][mxd[v]]=0的情况
upd(v,lim[u]),upd(v,val[u]),lim[u]=mxd[v]+1,val[u]=get(u,0);
///注意操作前lim[u]>=mxd[v]+1,因此可以直接修改lim[u]
}
else
{///拆成全局乘和前缀乘逆元
upd(v,add[u]),upd(v,mul[u]),upd(v,imul[u]);
::mul(add[u],cur),::mul(mul[u],cur),::mul(imul[u],inv[v]);
for(int i=0;i<=mxd[v];i++) upd(v,f[u][i]),f[u][i]=get(u,1ll*ask(u,i)*inv[v]%mod);
///注意有加法标记的存在,前缀乘逆元不能简单地写成f[u][i]*=inv[v]
}
}
if(!e[u].empty()) upd(e[u].back(),add[u]);///将全局+1算成最后1个轻儿子上的操作
add[u]=(add[u]+1)%mod;///最后别忘了全局+1
}
void solve()
{
assign(1),dfs(1);
}
}
namespace get_g
{
int pos,b[maxn];///b为辅助数组,存储前缀积
int add[maxn],mul[maxn],imul[maxn],lim[maxn],val[maxn];
int ask(int u,int i)
{
return (1ll*mul[u]*(i<lim[u]?g[u][i]:val[u])+add[u])%mod;
}
int get(int u,int val)
{
return 1ll*(val-add[u]+mod)*imul[u]%mod;
}
void dfs(int u)
{
res=(res+qpow((get_f::ask(u,l)-1ll)*ask(u,l)%mod,k))%mod;///更新答案
if(u!=1) res=(res-qpow((get_f::ask(u,l-1)-1ll)*(ask(u,l)-1)%mod,k))%mod;
int x=son[u];
if(!x) return ;
reverse(e[u].begin(),e[u].end());///翻转轻儿子,求出所有轻儿子的dp值
b[pos=0]=1;///pos记录当前b数组的边界,即上一次的mxd[v]
for(auto v:e[u])
{
while(!vec[v].empty()) *vec[v].back().v=vec[v].back().w,vec[v].pop_back();
///回退到v之前的状态,此时f[u]保存的是后缀积
add[v]=mul[v]=imul[v]=1,lim[v]=l+1;///最后全局+1的贡献在这里提前统计
for(int i=max(l-mxd[v],1);i<=l;i++)///g[v][0]=1,已经统计过了
g[v][i]=ask(u,i-1)*(i>=2?1ll*b[min(i-2,pos)]*get_f::ask(u,i-1)%mod:1)%mod;///g[u][i-1]*前缀*后缀
///由于u和x的头指针错开了一位,所以后缀\prod f[v][i-2]保存在f[u][i-1]的位置
for(int i=0,tmp=b[pos];i<=mxd[v];i++) b[i]=1ll*(i<=pos?b[i]:tmp)*get_f::ask(v,i)%mod;
///更新前缀积,注意v为链顶,因此f[v]的信息可以直接用
pos=mxd[v];
}
///接下来考虑轻子树对x的贡献,执行到这里f[x]的信息已经恢复
add[x]=add[u],mul[x]=mul[u],imul[x]=imul[u],lim[x]=lim[u]+1,val[x]=val[u];///长儿子继承u的信息
for(auto v:e[u])
{
for(int i=max(l-mxd[x],1);i<=min(l,mxd[v]+2);i++)
{
if(lim[x]==i) g[x][lim[x]++]=val[x];
g[x][i]=get(x,1ll*ask(x,i)*(i>=2?get_f::ask(v,i-2):1)%mod);
}
if(l<=mxd[v]+2) continue;///已经给[max(l-mxd[x],0),l]全部乘上相应贡献,直接跳过
int cur=get_f::ask(v,mxd[v]);
if(!cur) lim[x]=mxd[v]+2,val[x]=get(x,0);
else
{
::mul(add[x],cur),::mul(mul[x],cur),::mul(imul[x],inv[v]);
for(int i=max(l-mxd[x],1);i<=mxd[v]+2;i++) g[x][i]=get(x,1ll*ask(x,i)*inv[v]%mod);
}
}
add[x]=(add[x]+1)%mod;///最后全局+1
if(l-mxd[x]<=0) g[x][0]=get(x,1);///继承时没有考虑g[x][0]的边界,单独处理一下
dfs(x);
for(auto v:e[u]) dfs(v);
}
void solve()
{
add[1]=mul[1]=imul[1]=1,lim[1]=l+1,dfs(1);///初始g[1][i]=1
}
}
int main()
{
scanf("%d%d%d",&n,&l,&k);
for(int i=1;i<=n-1;i++)
{
scanf("%d%d",&u,&v);
h[u].push_back(v),h[v].push_back(u);
}
if(!l) printf("%d\n",n),exit(0);
pre_work::solve();
get_f::solve();
get_g::solve();
printf("%d\n",(res+mod)%mod);
return 0;
}
后记
无注释版本代码可以看这里。
已经绝望了。
本文来自博客园,作者:peiwenjun,转载请注明原文链接:https://www.cnblogs.com/peiwenjun/p/19064346