wzOI 2023.8.24 模拟赛(附树的直径和三分法)

发布时间 2023-09-08 10:54:23作者: NBest

2023-08-28 15:53:53

$$A$$

典型错误

一知半解看样例型:

  • 如果该队某个数组的值比最大值大就算入答案。

上第一次厕所前我的思路:开局 \(30\) 分钟。

显然,我并不需要有一个数值最大才能赢,我只需要其中一个数值比 其中一个数值比 其中一个数值最大的那个 要大的队 要大即有可能获胜。(可以通过断句来理解)

看懂了但是没有完全理解,且没有深度分析大样例型:

  • 一个球队必输:当且仅当存在一个队伍,其每个属性都比它高。

上了第一次厕所后:开局第 \(30 \sim 100\) 分钟我一直把这个当作真理去打,就去找统计被淘汰的队伍数,结果发现大样例完全过不了。这时才分析大样例的 \(i=8\) 才发现,就算我所有数值都比一个组高,那个组还是可以通过打败可能打败我的组来获得胜利,因此这个策略错误。

正解

我苦思冥想,又上了一次厕所,然后在机房里散步(被吐槽了),在桌子上坐着。

然后想到了并查集合并。

发现了可以把每一个数值的上下界看作线段维护之后,很【容易想到用并查集维护 \(k\) 条线段构成的一个二维区间。我们用并查集维护每一个组的数量(顺便写个按秩合并),然后想什么情况合并——很简单,只要二维区间有交就合并。最后答案就是任意一个属性最大的那个区间(为什么?因为不会有交,所以每个集合之间相互独立,不可能出现一个值比一个组大,另外一个小于的情况,否则早被合并了)。

可能不太好理解,结合代码理解一下?

其实这道题跟并查集没有任何关系,只是我想用并查集写而已,关键在合并操作,而不是并查集。复杂度本来感觉过不了的啊,应该是没有把我卡掉的数据吧,大样例放洛谷上跑了 \(200ms\) 左右吧?

\(Code\)

#include<bits/stdc++.h>
using namespace std;
int read(){
    int x=0;char c=getchar();
    for(;!isdigit(c);c=getchar());
    for(;isdigit(c);c=getchar())x=(x<<3)+(x<<1)+(c^48);
    return x;
}
struct line{
    int u,d;
}a[6][100005];
int n,m,fa[100005],siz[100005];
unordered_set<int>S;
inline int find(int x){
    if(x==fa[x])return x;
    return fa[x]=find(fa[x]);
}
int merge(int x,int y){//合并操作
    if((x=find(x))==(y=find(y)))return y;
    if(siz[x]>siz[y])swap(x,y);
    for(int i=1;i<=m;i++){
        a[i][y].u=max(a[i][y].u,a[i][x].u);
        a[i][y].d=min(a[i][y].d,a[i][x].d);
    }
    fa[x]=y,siz[y]+=siz[x],siz[x]=0;
    return y;
}
bool check(int x,int y){//判断能不能合并
    bool up=0,down=0;
    x=find(x),y=find(y);
    for(int i=1;i<=m;i++){
        if(a[i][x].u<a[i][y].d)down=1;
        if(a[i][x].d>a[i][y].u)up=1;
        if(a[i][x].d<a[i][y].u&&a[i][x].u>a[i][y].u)return 1;
        if(a[i][x].d<a[i][y].d&&a[i][x].u>a[i][y].d)return 1;
        if(a[i][y].d<a[i][x].u&&a[i][y].u>a[i][x].u)return 1;
        if(a[i][y].d<a[i][x].d&&a[i][y].u>a[i][x].d)return 1;
    }
    return up&down;
}
queue<int>Q;
int main(){
    n=read(),m=read();
    if(m==1){//不加这个可能就只有 90,m=1 我就退化到 O(n^2) 了
        for(int i=1;i<=n;i++)
            printf("1 ");
        return 0;
    }
    for(int i=1;i<=n;i++){//预处理
        fa[i]=i;siz[i]=1;
        for(int j=1;j<=m;j++){
            a[j][i].u=a[j][i].d=read();
        }
    }
    for(int i=1;i<=n;i++){
        int x=i,maxx=0,ans;
        for(int y:S){
            if(!check(x,y))continue;
            Q.push(y);//直接删会本地就会报错,不知道为什么
            x=merge(x,y);
        }
        while(!Q.empty())S.erase(Q.front()),Q.pop();//拿出来删就好了,不差这点复杂度
        S.insert(x);
        for(int y:S){
            if(a[1][y].d>=maxx)maxx=a[1][y].d,ans=siz[y];//找最大的(这复杂度不爆???)
        }
        printf("%d ",ans);
    }
}

发完以后有人说完全看不懂,那我就结合图片讲一下?
我们把每个队各个属性的数值看成纵坐标,属性的编号看成横坐标,那么我们可以得到以这一个坐标图。

此时他们之间互不相交,彼此之间都是绝对碾压无法超过的关系,那么显然 1 就是冠军。

那此时我们加入一个 4?

此时 1 和 4 可以互相打过,那么 2 只需要在 4 打败 1 之后打败 4,那 2 也能成为冠军,那么此时可能的冠军就变成了 1,2,4。

此时 3 如果争气一点,找到一个可能打过 1,2,4 其中一个的队然后战胜它,那么 3 也能成为冠军,那么我们假设这个点是 5,因为需要 3 打过 5,那么 5 至少一个数值比 3 小,又得 5 打过 1,2,4 那么 5 必然跟 1,2,4 三条线构成的图有交。

那么就可以得到结论了,只要两个图形有交就合并,最后取其中一个数值最大的图形的大小即为答案。

B

是一个很烦的模拟题,我不想打,所以就暂且跳了吧(挖坑)。

C

原题 CF662C

题意:

给定一个 \(n\times m\)\(01\) 数组,你可以同时翻转一行或者一列的状态 (\(1\)->\(0\),\(0\)->\(1\)),问翻转任意次后最少能有几个 \(1\)

\(n\le 20,m\le 10^5\),时限 6000ms

看到 \(n\) 这么小,我们想到朴素的贪心加暴力。

关注到我们如果选定了翻的一些行和列,那么其翻转的顺序不会对最终答案造成影响,而且同一行或者列翻多次没有意义。

所以我们可以考虑 \(O(2^n)\) 枚举行的翻转状态,然后贪心看每列要不要翻,复杂度 \(O(nm2^n)\),优化一下可以跑 \(n\le15,m\le1000\)\(60\) 部分分。

\(60pts\ Code\)

int n,m,ans=1e9;
bool a[20][100005],vis[20];
void dfs(int x){
    if(x>n){
        int res=0;
        for(int j=1;j<=m;j++){
            int p=0;
            for(int i=1;i<=n;i++){
                if(vis[i])p+=a[i][j]^1;
                else p+=a[i][j];
            }
            res+=min(p,n-p);
            if(res>ans)return;//优化
        }
        ans=res;
        return;
    }
    dfs(x+1);
    vis[x]=1;
    dfs(x+1);
    vis[x]=0;
}
int main(){
    cin>>n>>m;
    for(int i=1;i<=n;i++){
        string l;
        cin>>l;
        for(int j=0;j<m;j++){
            a[i][j+1]=l[j]^48;
        }
    }
    dfs(1);
    cout<<ans;
}

考虑正解。

这题可以用两个方法——dp或者FWT。这里先讲一下 dp,虽然复杂度多了一个 \(n\),但是毕竟前置知识少,所以方便写(可能不太好想到?毕竟当时好像CF赛时 tourist 都没打出来)。

注意到,每一列的很多状态很多都是重复的,而我们最终的答案相当于我们在一个全 0 的序列中翻转了一些数使得其变成了 1,求最少翻转次数。那最终我们只需要通过翻转和单点修改让每一列的状态即可,答案即为最小单点修改次数。

我们令 \(f_{i,s}\) 表示通过 \(i\)不重复单点修改变成状态 \(s\) 的列数。

最后答案就是枚举状态 \(s\),把对应的次数和数量相乘取最小值即可,复杂度 \(O(n^22^n)\)

int n,m,zt[100000],f[21][1<<20];
int main(){
    cin>>n>>m;
    for(int i=1;i<=n;i++){
        string l;cin>>l;
        for(int j=0;j<m;j++)
            zt[j]=(zt[j]<<1)+(l[j]^48);//计算列状态
    }
    for(int i=0;i<m;i++)
        f[0][zt[i]]++;//状态计数
    for(int k=0;k<n;k++)//注意循环顺序,因为不能重复翻一个点,所以最外层枚举翻的点
        for(int i=k+1;i;--i)//常数优化,减少无用状态转移,同时注意从大到小,也是防止翻同一个点
            for(int j=0;j<(1<<n);j++)
                f[i][j]+=f[i-1][j^(1<<k)];
    int ans=2000000;
    for(int i=0;i<(1<<n);i++){//枚举最后所有列的状态
        int res=0;
        for(int j=0;j<=n;j++){
            res+=f[j][i]*min(j,n-j);//翻转了的答案和没翻转的取最小值
        }
        ans=min(ans,res);
    }
    cout<<ans;
    return 0;
}

FWT 做法

不想学 FWT,推个式子吧。

xor_FWT 运用:

给定长度为 \(2^n\) 两个序列 \(A,B\),设

\[C_i=\sum_{j\oplus k = i}A_j \times B_k \]

可以在 \(O(n2^n)\) 里求出 \(C\) 序列。

设翻转行的二进制操作序列为 \(opt\)\(S_i\) 为第 \(i\) 列翻转前的序列,\(T_i\) 为翻转后的序列,不能得到 \(T_i=S_i\oplus opt\)

根据我们一开始的贪心思路,第 \(i\) 列对答案的贡献 \(ANS(T_i)=min\{popcount(T_i),n-popcount(T_i)\}\)

那么最终答案 \(ans\) 可以表示为:

\[\large\begin{aligned}ans&=\min_{opt}\sum_{i=1}^{m}ANS(S_i\oplus opt)\end{aligned} \]

复杂度 \(O(m2^n)\)

\(W(S)\) 表示状态为 \(S\) 的个数,那么:

\[\large\begin{aligned}ans&=\min_{opt}\sum_{S}ANS(S\oplus opt)\times W(S)\\&=\min_{opt}\sum_{S\oplus T=opt}ANS(T)\times W(S)\end{aligned} \]

然后就变成 xor_FWT 问题了,也就是在 \(C\) 序列里面找最小值。

D

题意:

给定一颗树,每条边的边权为 \(a+b\times delta\),问你当 \(delta\in[0,lim]\) 时,树的最小直径,并求出取到最小直径时的最小 \(delta\)

\(n\le 2.5\times 10^5,lim \le10^6,|a_i|\le10^8,|b_i|\le10^3\)

做法

D 好水啊,一开始没发现,暴力都没打,主要是因为之前上课都没听,不知道怎么求树的直径,然后看到要求直径就直接 skip 了。

这显然是个二分/三分题,我们把树上每条路径都拿出来,发现对于每条路径的长度,都是关于 \(delta\) 的一次函数,然后我们把这些直线放到图上,大概是长这样的:

(横坐标是 \(delta\),纵坐标是路径长度)

直径就是最长的路径,所以我们对这些线取个 \(max\),发现得到了一个半凸包(不知道是不是这么叫)。

显然不管怎样放都是一个类似单峰的折线,所以我们直接对答案进行三分就行了,三分可以参考题目:P3382 三分法

可是其实不需要像三分模板那样取三等分点,直接取中点然后比较 \(mid\)\(mid+1\) 代入函数的关系:

  • 如果 \(cal(mid)<=cal(mid+1)\),那么 \(mid+1\) 一定在答案的右侧,所以我们缩小范围时就不需要 \(mid+1\) 了,直接令 \(r=mid\) 即可。
  • 如果 \(cal(mid)>cal(mid+1)\),那么发现 \(mid\) 一定不是答案,且 \(mid\) 一定在答案右侧,因此令 \(l=mid+1\) 即可。

怎么算直径呢?

树的直径通常有两种求法——搜素和树形 dp,我个人更偏向选择树形 dp 的做法,因为不仅只需要跑一遍 dfs,而且就算有负权也能保证求出正确答案。

这是因为跑两遍搜索的原理是从一点出发找到它的最长路的结束点,然后再用这个最长路的结束点去更新答案,这样做保证正确只能当没有负权边的时候成立。

证明给出:

先假设没有负权边。

我们假设从 \(A\) 点出发,能到的最远点是 \(D\) 点,而此时的直径是 \(B-C\)

情况 1:当 \(A-D\)\(B-C\) 不相交

因为 \(A\) 的最长路到的是 \(D\),所以显然 \(path:4+5\ge4+3+2\iff path:5\ge 3+2\)

如果 \(3\) 是非负路,那么一定有 \(path:1+3+5\ge 1+2\)

那么此时直径可以为 \(B-D\)

情况 2:当 \(A-D\)\(B-C\) 相交

这个更好证明:

这个不用管是不是负权边,肯定有 \(path:1+4\ge 1+3\iff path: 2+4\ge 2+3\),所以 \(B-D\) 为不劣解。

树形 dp 求直径

而这个题的权值可能为负数,所以显然不能两次搜素法,那就打一个简单的树形 dp 即可。

\(f_x\) 表示以 \(x\) 为子树的根,到子树的最长路,那么我们只要边更新 \(f_x\) 边算直径即可。

void dfs(int x,int fa){
    for(auto PI:F[x]){
        int y=PI.to;ll w=PI.v;
        if(y==fa)continue;
        dfs(y,x,v);
        len=max(len,f[x]+f[y]+w);//此时 f[x]还没有被更新,因此是与其他子树的最长路
        //那么包含 x 的最长路径就可以通过找最长和次长的路更新,再贡献到直径答案中。
        f[x]=max(f[x],f[y]+w);//更新答案
    }
}

然后就做完了。

\(Code\)

struct edge{
    int to;ll a,b;
    inline ll v(ll w){return a+b*w;}
};
int n;
vector<edge>F[250004];
ll f[250004],len,lim;
void dfs(int x,int fa,ll v){
    for(auto PI:F[x]){
        int y=PI.to;ll w=PI.v(v);
        if(y==fa)continue;
        dfs(y,x,v);
        len=max(len,f[x]+f[y]+w);
        f[x]=max(f[x],f[y]+w);
    }
}
ll cal(ll v){
    memset(f,0,sizeof(f));
    len=0;
    dfs(1,0,v);
    return len;
}
int main(){
    n=read(),lim=read();
    for(int i=1;i<n;i++){
        int u=read(),v=read(),a=read(),b=read();
        F[u].push_back({v,a,b});
        F[v].push_back({u,a,b});
    }
    ll l=0,r=lim;
    while(l<r){
        ll mid=(l+r)>>1;
        if(cal(mid)<=cal(mid+1))r=mid;
        else l=mid+1;
    }
    printf("%lld\n%lld",l,cal(l));
}

总结:

玩原神玩多了。

太菜了,在如此秒的第一题上浪费了太久,没有写出如此秒的第四题。都是玩原神玩的太菜了。