peiwenjun's blog 没有知识的荒原

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\) 的连通块个数(不包含空集)。

转移方程如下:

\[f_{u,i}=\prod_{v\in son(u)}f_{v,i-1}+1\\ g_{u,i}=g_{fa_u,i-1}\cdot\prod_{v\in son(fa_u),v\neq u}f_{v,i-2}+1\\ \]

点的贡献为 \(\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\) 维护 muladd 两个 tag ,分别为全局乘法标记和加法标记。

我们希望后缀乘能改为全局乘,然后前缀乘上其逆元。

更新时为了规避求逆元的 \(\log\) ,再维护 imul 表示 mul 的逆元即可。

然而 \(f_{v,i-1}\) 有可能刚好是模数的倍数,这时后缀乘的数是零,并不存在逆元!

更逆天的是出题人还真把这种情况造出来了,不处理这种情况会被卡 \(5\) 个点。

此时后缀乘等价于后缀赋值,额外维护 limval 两个 tag ,表示所有下标 \(\ge lim\) 的位置都应该赋值为 val ,注意 val 也会受到前面 muladd 标记的影响。

更新标记也很有技术含量:

如果 \(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\) 降序排序。

后缀乘还是按照套路变成全局乘和前缀乘逆元,由于没有加法,所以维护 mullim 就足够了。

对于后缀积的部分,观察发现我们只会倒序用到所有点的状态

对每个点 \(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;
}

后记

无注释版本代码可以看这里

已经绝望了。

posted on 2025-08-29 12:35  peiwenjun  阅读(4)  评论(0)    收藏  举报

导航