Aug. 2023 普及组模拟赛 3

题面

T1 最大生成树

Meaning

给定一个完全图,两点之间的边权为这两个点点权之差的绝对值,求这个图的最大生成树。

Solution

对于最小生成树,我们可以考虑 Kruskal 算法。

Kruskal 算法的正确性证明

这里所说的 Kruskal 算法用来求最生成树。

假设 Kruskal 算法求得的生成树所包含的边被包含于边集 \(T\) 中,构成该图的最小生成树的的边被包含于边集 \(S\) 中。每次从 \(T\) 中选出边权最小且不包含在 \(S\) 的边放入 \(S\) 中,设该边为 \(e_1\),边权为 \(w_1\)。假设图 \(G\) 中包含且仅包含 \(S\) 中的边,则 \(e_1\) 的两个端点必定在 \(G\) 中相连。所以,\(e_1\) 的两个端点必定被一条包含他们的最近公共祖先的路径和 \(e_1\) 同时相连。换言之,此时 \(G\) 中存在且仅存在 \(1\) 个环,且包含 \(e_1\)
接下来,从这个环中取出一条权值最小的边放入 \(T\) 中,设该边为 \(e_2\),边权为\(w_2\)。此时环被破坏,\(G\) 变为一棵树。因为若这个环之前就在 \(T\) 中时,为了使 \(T\) 中无环,\(e_1\) 不可能在 \(T\) 中,所以一定有不在 \(T\) 中的 \(e_2\)。重复上述操作,直到两边集边权和相等。
假设 \(w_1>w_2\)\(e_2\) 被 Kruskal 算法舍弃是因为 \(e_2\) 的两个端点已经有更优的连接方案,再次连接会形成回路,这与 \(S\) 作为最小生成树最优的特性矛盾,不成立。
假设 \(w_1<w_2\),由于 \(e_2\) 不是包含在 \(e_1\) 中,就是包含在操作前 \(G\)\(e_1\) 两端点经过他们最近公共祖先的路径中,所以后者一定不如前者更优。故 \(S\) 中理应包含的是 \(e_1\) 而非 \(e_2\),且也能达到联通“环”中点的效果,与 \(S\) 的最优性矛盾,不成立。
综上所述,Kruskal 算法求出的 \(T\) 的边权和满足不经操作就与 \(S\) 的边权和相等,得证。

然而,这道题用 Kruskal 模板解决建图就需要 \(O(n^2)\),效率较低。我们可以直接利用 Kruskal 的贪心思想解题。
对于一个点权的集合 \(S\),设其中最大的点权为 \(S_p\),小的为 \(S_q\),则最长的一条边的长度为 \(S_p-S_q\)。而若要连接所有点,可得第 \(i\) 个点到所有点的路径中最长的为 \(\max_{S_i-S_q,S_p-S_i}\)。因为点 \(p\)\(q\) 的连通性无需考虑,所以对于每个点,只需通过 \(i\)\(p\)\(i\)\(q\) 中边权较大的一条边将点 \(i\) 纳入以 \(p\)\(q\) 为基础的体系中,就得出了最大生成树。

代码
#include<bits/stdc++.h>
using namespace std;
long long n,a[200000],ans;
int main(){
    scanf("%lld",&n);
    for(int i=1;i<=n;++i) scanf("%lld",&a[i]);
    sort(a+1,a+n+1);
    for(int i=2;i<n;++i) ans+=max(a[i]-a[1],a[n]-a[i]);
    ans+=a[n]-a[1];
    printf("%lld",ans);
    return 0;
}

T2 红黑树

Meaning

给定一棵树,树上各个节点的初始权值均为 \(0\),先后将各点边权变成 \(1\),每次操作后求出既包含权值为 \(0\) 的点又包含权值为 \(1\) 的点的子树个数。

Solution

在线算法复杂度较高,考虑离线算法。

如何 Hack 在线做法

目前,笔者所知的最优在线算法是对于每次操作,修改被操作节点及其所有祖先。但如果出现一个长链,最劣单次修改复杂度会达到 \(O(n)\),总复杂度为 \(O(n^2)\),效率较低。

对于一棵树,设其中第一个被改变的节点在第 \(i\) 次操作被改变,最后一次被改变的节点在第 \(j\) 次操作被改变,那么它对答案产生影响的时间段为 \(i\)\(j-1\)(在第 \(j\) 个时间节点该树所有节点的权值已经变为 \(1\))。记录各个节点被改变的时间,通过 dfs 将下一级子树的贡献区间传给上一级子树,以记录每一个子树的贡献区间。

代码
#include<bits/stdc++.h>
using namespace std;
struct node{
    int end,next;
}edge[100100];
struct stru{
    int head,father,dfn,out;
}dot[100100];
int n,cnt,dfncnt,ans[100100];
inline void add(int x,int y){
    edge[++cnt].next=dot[x].head;
    edge[cnt].end=y;
    dot[x].head=cnt;
}
inline void dfs(int x){
    for(int i=dot[x].head;i>0;i=edge[i].next){
        int tem=edge[i].end;
        dfs(tem);
        dot[x].dfn=min(dot[x].dfn,dot[tem].dfn);
        dot[x].out=max(dot[x].out,dot[tem].out);
    }
    ++ans[dot[x].dfn];
    --ans[dot[x].out];
}
int main(){
    scanf("%d",&n);
    for(int i=2;i<=n;++i){
        scanf("%d",&dot[i].father);
        add(dot[i].father,i);
    }
    for(int i=1;i<=n;++i){
        int tem;
        scanf("%d",&tem);
        dot[tem].dfn=dot[tem].out=i;
    }
    dfs(1);
    for(int i=1;i<=n;++i){
        ans[i]+=ans[i-1];
        printf("%d ",ans[i]);
    }
    return 0;
}

T3 校门外的树

Meaning

给定几棵树的初始高度和初始日生长速度,每天在树木生长结束后改变一个区间内树的生长速度或查询一个区间内树高度之和。

Solution

理想的情况是每棵树的生长速度不变,这样对于第 \(i\) 棵树,若它的高度为 \(h_i\),生长速度为 \(v_i\),则第 \(k\) 天它的高度为:

\[h_i+v_i\times{k} \]

而对于生长速度的改变,可以将初始高度除去修改前生长的高度与修改速度后用相同天数生长的高度,即当在第 \(j\) 天速度增加 \(vp_i\) 时,\(h_i\) 应减去:

\[j\times{vp_i} \]

因为,第 \(k\)\(k>j\),且第 \(j+1\) 天到第 \(k\) 天没有改变速度)天的高度是:

\[\begin{aligned}&h_i+j\times{v_i}+(k-j)\times{(v_i+vp_i)}\\=&h_i+k\times{(v_i+vp_i)}-j\times{vp_i}\\=&(h_i-j\times{vp_i})+k\times{(v_i+vp_i)}\\\end{aligned} \]

多次变速同理,因为每一次变速相当于将从第一天开始至今每天的生长速度都变成了上一次修改后的速度,所以只需要按当前速度进行以上操作而不需要依次处理不同的速度。
综上所述,可用线段树维护每棵树的高度与生长速度,第 \(k\) 次修改速度时同时按上文修改区间内树的初始高度,查询时便可直接由 \(h_i+v_i\times{k}\) 求出区间高度和,即区间速度和与天数之积加上区间初始高度和。

代码
#include<bits/stdc++.h>
using namespace std;
struct node{
    int left,right;
    unsigned long long height,tagh,tagv,speed; 
}tree[3000000];
int n,m,opt,lft,rgt;
unsigned long long adding;
inline void build(int x,int y,int z){
    tree[x].left=y;
    tree[x].right=z;
    if(y==z){
        scanf("%llu%llu",&tree[x].height,&tree[x].speed);
        return;
    }
    int mid=(y+z)>>1;
    build(x<<1,y,mid);
    build(x<<1|1,mid+1,z);
    tree[x].height=tree[x<<1].height+tree[x<<1|1].height;
    tree[x].speed=tree[x<<1].speed+tree[x<<1|1].speed;
}
inline void pushdown(int x){
    if(tree[x].tagh){
        tree[x<<1].height+=(tree[x<<1].right-tree[x<<1].left+1)*tree[x].tagh;
        tree[x<<1|1].height+=(tree[x<<1|1].right-tree[x<<1|1].left+1)*tree[x].tagh;
        tree[x<<1].tagh+=tree[x].tagh;
        tree[x<<1|1].tagh+=tree[x].tagh;
        tree[x].tagh=0;
    }
    if(tree[x].tagv){
        tree[x<<1].speed+=(tree[x<<1].right-tree[x<<1].left+1)*tree[x].tagv;
        tree[x<<1|1].speed+=(tree[x<<1|1].right-tree[x<<1|1].left+1)*tree[x].tagv;
        tree[x<<1].tagv+=tree[x].tagv;
        tree[x<<1|1].tagv+=tree[x].tagv;
        tree[x].tagv=0;
    }
}
inline void modify(int x,int y,int z,unsigned long long adh,unsigned long long adv){
    if(z<tree[x].left||tree[x].right<y) return;
    if(y<=tree[x].left&&tree[x].right<=z){
        tree[x].height+=(tree[x].right-tree[x].left+1)*adh;
        tree[x].speed+=(tree[x].right-tree[x].left+1)*adv;
        tree[x].tagh+=adh;
        tree[x].tagv+=adv;
        return;
    }
    pushdown(x);
    modify(x<<1,y,z,adh,adv);
    modify(x<<1|1,y,z,adh,adv);
    tree[x].height=tree[x<<1].height+tree[x<<1|1].height;
    tree[x].speed=tree[x<<1].speed+tree[x<<1|1].speed;
}
inline pair<unsigned long long,unsigned long long> query(int x,int y,int z){
    if(z<tree[x].left||tree[x].right<y) return {0,0};
    if(y<=tree[x].left&&tree[x].right<=z) return {tree[x].height,tree[x].speed};
    pushdown(x);
    pair<unsigned long long,unsigned long long> p1=query(x<<1,y,z),p2=query(x<<1|1,y,z);
    return {p1.first+p2.first,p1.second+p2.second};
}
int main(){
    scanf("%d%d",&n,&m);
    build(1,1,n);
    for(int i=1;i<=m;++i){
        scanf("%d%d%d",&opt,&lft,&rgt);
        if(opt==1){
            scanf("%llu",&adding);
            modify(1,lft,rgt,-adding*i,adding);
        }else{
            pair<unsigned long long,unsigned long long> tem=query(1,lft,rgt);
            unsigned long long ans=tem.first+tem.second*i;
            printf("%llu\n",ans);
        }
    }
    return 0;
}

T4 种树

Meaning

在一个长度一定的点列上,规定两点间的距离为两点编号之差的绝对值。对于每次询问,给出两个黑点间的最小距离和不能涂黑的点的编号,求有多少种给这些点涂黑的方案(可以一个也不涂)。

Solution

定义:当一个点列中,若有 \(x\) 个点,黑点间的最小距离为 \(y\),则方案数为 \(f_{x,y}\)
下面,考虑第 \(i\) 次询问:
对于整个点列,方案数为 \(f_{n,k_i}\)
对于不能涂黑的点,需要减去包含该点的方案数。
对于 \(x_i\) 的左边,假设点 \(x_i\) 被涂黑,则最靠近 \(x_i\) 的点与 \(x_i\) 的距离至少是 \(k_i\),即该点的最大编号为 \(x_i-k_i\)。可得,在该点左侧的区间内的点的任意一种涂色方案都可以与涂黑 \(x_i\) 构成一种新的方案,总共可以构成的方案数为 \(f_{x_i-k_i,k_i}\)
而对于 \(x_i\) 右侧的区间,距离 \(x_i\) 最近的点的最小编号为 \(x_i+k_i\),与上述同理,该区间内可与 \(x_i\) 构成的合法方案有 \(f_{n-(x_i+k_i)+1,k_i}\) 种。
根据乘法原理,每一种 \(x_i\) 左侧的方案都能与一种右侧的方案结合成一种包含 \(x_i\) 的合法涂色方案,共有 \(f_{x_i-k_i,k_i}\times{f_{n-(x_i+k_i)+1,k_i}}\) 种需要排除的方案。
综上所述,总的涂色方案为:

\[f_{n,k_i}-f_{x_i-k_i,k_i}\times{f_{n-(x_i+k_i)+1,k_i}} \]

对于求 \(f_{x,y}\),考虑以下两种方法。

数学方法

先看这个植树问题:

\(n\) 棵树种在一条笔直的路上,每两颗树间距离为 \(s\),假设树没有宽度,求两端的树之间的距离。

因为两端都有树,所以只有 \(n-1\) 个树之间的空隙,总长度为 \(s\times{(n-1)}\)
对于 \(f_{x,y}\),当选出 \(i\) 个点涂黑时,与植树问题同理,应至少空出 \((i-1)\times{(y-1)}\) 个点,剩下的 \(x-(i-1)\times{(y-1)}\) 个位置可以任意分配给这 \(i\) 个点做可行放置位置,即方案数为:

\[C_{x-(i-1)\times{(y-1)}}^{i} \]

而只有当 \(0\leq{i}\leq{x-(i-1)\times{(y-1)}}\) 时,才存在有贡献的的 \(C_{x-(i-1)\times{(y-1)}}^{i}\)

\[\begin{aligned}i&\leq{x-(i-1)\times{(y-1)}}\\ i&\leq{x-i\times{y}+y+i-1}\\ 0&\leq{x-i\times{y}+y-1}\\ i\times{y}&\leq{x+y-1}\\ i&\leq{\frac{x+y-1}{y}}(y>0) \end{aligned} \]

因此,最终形式为:

\[f_{x,y}=\sum_{i=0}^{\frac{x+y-1}{y}}C_{x-(i-1)\times{(y-1)}}^{i} \]

对于求组合数,直接预处理出各个数阶乘(即其阶乘对于模数的逆元)即可。
时间复杂度为 \(O\left(\frac{n}{k}q\right)\)

如何 Hack 纯数学做法

\(k\) 值极小且 \(n\) 值极大时,最劣复杂度会达到 \(O(n^2)\)
一个可行的 Hack 数据生成器:

#include<bits/stdc++.h>
using namespace std;
int main(){
    srand(time(0));
    freopen("data.txt","w",stdout);
    int n=rand()%7+1,m=rand()%7+1;
    int a=1e5;
    cout<<a<<" "<<a<<endl;
    for(int i=1;i<1e5;++i) cout<<a-i<<" "<<2<<endl;
    return 0;
}

该生成器生成的数据无法用映射表优化卡过。

动态规划方法

考虑将所有 \(f_{x,y}\) 预处理出来,应对重复出现的 \(k\)
当点 \(x\) 被涂黑时,它能继承 \(f_{x-k,y}\) 的方案,因为此时距离点 \(x\) 最近的点为了与点 \(x\) 保持距离为 \(k\) ,距离 \(x\) 最近的点的编号应至少为 \(x-k\)
\(x\) 不被涂黑时,最优的方案是继承 \(f_{x-1,y}\),因为它一定不包含将 \(x\) 涂黑的方案,且经过累加后它是 \(f_{j,y}(j\in{[1,x-1]})\) 中最大的。
可得状态转移方程:

\[f_{x,y}=f_{x-1,y}+1+f_{x-k,y} \]

为了防止 \(f_{x-k,y}\) 越界,可以将数组反向存储,即 \(f_{n-i+1,y}\) 表示原来的 \(f_{i,y}\)
综上所述,最终的状态转移方程为:

\[f_{x,y}=f_{x+1,y}+1+f_{x+k,y} \]

实现时注意倒序枚举。
时间复杂度为 \(O(n+q)\)

如何 Hack 纯动态规划做法

\(k\) 值和 \(n\) 值同时极大时,算法效率低。

综合

根据上文所述,两种方法分别适合 \(k\) 值大和 \(k\) 值小的情况,可以按 \(k\) 值分类处理。
由于动态规划方法空间复杂度极大,所以应该使阈值尽量小。

代码
#include<bits/stdc++.h>
using namespace std;
const long long mdr=998244353;
long long n,m,empty,step,ans,tim[100010],timt[100010],invt[100010],inv[100010],sum[620][100010],tema,temb;
inline void exgcd(long long a,long long b,long long &x,long long &y){
    if(!b){
        x=1,y=0;
        return;
    }
    exgcd(b,a%b,x,y);
    long long tem=y;
    y=x-a/b*y;
    x=tem;
}
inline long long math(long long x,long long y){
    long long rtr=1;//任何个数中取 0 个的方案数均为 1。
    for(long long i=1;i<=(x+y-1)/y;++i){
        long long tem=x-(i-1)*(y-1);
        long long plr=((tim[tem]*inv[tem-i])%mdr*inv[i])%mdr;
        rtr=(rtr+plr)%mdr;
    }
    return rtr;
}
inline void dp(int x){
    for(int i=n;i;--i) sum[x][i]=(sum[x][i+1]+1+sum[x][i+x])%mdr;
}
inline long long query(int x,int y){
    if(y<0||y>n) return 1;
    return sum[x][y];
}
int main(){
    scanf("%lld%lld",&n,&m);
    tim[1]=tim[0]=1,timt[1]=1;
    for(int i=2;i<=n;++i){
        tim[i]=(tim[i-1]*i)%mdr;
        timt[i]=(timt[i-1]*tim[i])%mdr;
    }
    long long waste;
    exgcd(timt[n],mdr,invt[n],waste);
    invt[n]=(invt[n]%mdr+mdr)%mdr;
    for(int i=n-1;i;--i) invt[i]=(tim[i+1]*invt[i+1])%mdr;
    inv[1]=inv[0]=1;
    for(int i=2;i<=n;++i) inv[i]=(invt[i]*timt[i-1])%mdr;
    for(int i=1;i<502;++i){
        dp(i);
        for(int j=1;j<=n;++j) ++sum[i][j];
    }
    while(m--){
        scanf("%lld%lld",&empty,&step);
        if(step>500){
            ans=(math(n,step)-(math(empty-step,step)*math(n-empty-step+1,step))%mdr+mdr)%mdr;
            printf("%lld\n",ans);
        }else{
            tema=n-(empty-step)+1,temb=empty+step;
            ans=(sum[step][1]-((query(step,tema)*query(step,temb))%mdr)%mdr+mdr)%mdr;
            printf("%lld\n",ans);
        }
    }
    return 0;
}

Reference

Kruskal求得的生成树是最小生成树的证明

posted @ 2023-08-25 09:53  Pursuing_OIer  阅读(54)  评论(2)    收藏  举报