海亮寄 7.6
前言
业精于勤荒于嬉,行成于思毁于随
正文(模拟赛)
卦象:平
模拟赛原题来源:COCI 2023
感受:是 IOI 赛制,\(3\) 个小时 \(5\) 道题,分数 \(310/450\)。并不完美的策略,充足的运气,和不作妖的代码,组成了这一次模拟赛。第一个小时只过掉了 T2 和 T3。同一时间,隔壁墨鱼已经开 T5 了,心态确实有些小爆炸,感觉自己可能并没有发挥好。后来发现 T1 读错题,15 min 切掉。剩余时间大概有 100min 左右。大致扫了一眼 T4 T5 后,决定死磕 T4。然而比赛还剩余 45min 的时候仍然没有任何正解相关的思路,故及时调整转去打 T5 的暴力代码。幸运地是,暴力代码没有出一些神秘问题(以前是一定会出神秘问题的),30pts 入账。剩余 35min 没有罚坐,继续思考 T4,并获得了一个 \(O(n^3)\) 复杂度的 DP 算法。代码不长,又很幸运的一遍过了期望所得到的 80pts。最后剩余 1min,非常极限。然而正解其实就是赛时代吗增加一个后缀和优化,略有可惜。总而言之,今天运气不错,当然也归功于 IOI 赛制(要不然最后的 45min 一定是对拍时间),分数比较理想,并且很多都是一遍过。
T1
注意审题!是判断当前盘面,并非填充整个数独!
人口普查题,过
T2
人口普查题 \(\times 2\)
考虑把图建出来,边权即颜色,然后 DFS 暴搜枚举颜色的保留情况,依照保留情况对建出来的图判连通即可
细节上要处理每次判连通的初始化问题
T3
疑似学长学姐六人组的签到题
基本上就是金牌导航的原题,两边单调队列直接 \(O(nm)\) 解决
严厉谴责带 \(\log\) 时间复杂度的实现方式,应当加强数据!
T4
又是一道 DP 题目,看到这个神奇的游走方式,以及比较敏感的数据范围,忽然联想到一道古老的区间 DP 题目——关路灯
然而正是这个联想硬控了 50min 不止
事实上,关路灯的思维过程是对这道题目有启发的,但是状态设计是偏离航向的。
后来发现不关心区间端点,故涉及状态 \(f_{i,j}\) 表示区间长度为 \(i\),区间边界的积木编号为 \(j\)
至于为什么不用维护两个边界,比较显然的是其具有对称性
更进一步地,发现时光倒流是好做的,转移也很好写
获得了一份 \(27\) 行的 80pts 代码
最后,加一个后缀和,于是赛后 10min 过掉这个题目
点击查看代码
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=5e3+5,MOD=1e9+7;
int n,k,f[N][N],sum[N],ans;
inline void ADD(int &x,int y){x=(x+y)%MOD;return;}
signed main(){
// freopen("in.txt","r",stdin);
// freopen("out.txt","w",stdout);
ios::sync_with_stdio(0);
cin.tie(0),cout.tie(0);
cin>>n>>k;
f[2][n-1]=2;
for(int i=3;i<=k;i++){
sum[n-1]=f[i-1][n-1];
for(int j=n-2;j>=1;j--)sum[j]=(sum[j+2]+f[i-1][j])%MOD;
for(int j=n-2;j>=1;j--){
ADD(f[i][j],sum[j+1]);
if(i+j>n)continue;
ADD(f[i][j],sum[i+j-1]);
}
}
for(int i=2;i<=k;i++)
for(int j=1;j<=n-1;j++)
ADD(ans,f[i][j]*(k-i+1)%MOD);
cout<<ans<<'\n';
return 0;
}
T5
难得在提高到省选组见到一道黑题,而且是传说中的大分讨
吐槽 or 反思
图论学习的比较糟糕,并不会一些连通分量相关的做法
所以赛场上只敲了 30pts 的边集枚举的暴力而不会 55pts 的点集枚举并判断割点的暴力
讲题时还收获了第三档数据点的一个关于稠密图的乱搞做法,感觉很对,并且没有想到一些较为简单的 hack 方式
下午听取正解,并补题,老师讲的还是很通俗易懂的,然而似乎本人有更好的做法?(已经 AC 力!)
题解
首先和官方正解具有 LCP 的部分是做一棵 DFS 生成树
学习了一个重要性质:无向图的 DFS 生成树是不存在横叉边和前向边的,只有树边和返祖边
然后,分别考察删除树边或者返祖边能贡献答案的充要转化
以下部分是个人做法
树边(默认为树边 \((u,v)\),且 \(\text{dep}_u < \text{dep}_v\))
根据官方题解的启发,保留上中下部的模型,然后可以继续进行分讨
-
若无上部,即 \(u\) 结点是根
\(u\) 有 \(\ge 3\) 个儿子,贡献答案
\(u\) 有 \(2\) 个儿子,且 \(v\) 有儿子,贡献答案
\(u\) 仅有儿子 \(v\),且 \(v\) 有 \(\ge 2\) 个儿子,贡献答案
if(u==1){// u 是根 if(cnt[u]>=3)ans++; else if(cnt[u]==2&&cnt[v])ans++; else if(cnt[u]==1&&cnt[v]>1)ans++; } -
若有上部,即 \(u\) 结点不是根
不贡献答案,当且仅当 \(u\) 除了 \(v\) 以外的儿子所在的子树,其余任意一棵子树总有指向 \(u\) 祖先的返祖边。且 \(v\) 的所有儿子对应的任意一棵子树也总有指向 \(u\) 祖先的返祖边
else{// u 不是根 int tmp=f[u]; if(Mi[v]<dep[u]&&Mi[v])tmp--;// 刨除 v 子树的贡献 if(tmp!=cnt[u]-1){ans++;continue;}// check 是否存在 u 的所有子结点的子树没有贡献到 if(!cnt[v])continue; // check 子树 v 中是否存在无返祖到 u 的祖先的子树 if(g[v]!=cnt[v])ans++; }
返祖边(默认为返祖边 \((u,v)\),且 \(\text{dep}_u > \text{dep}_v\))
根据官方题解的启发,依旧保留上中下部的模型,还是继续进行分讨
-
若无上部,即 \(v\) 结点是根
\(v\) 有多个儿子,middle 无法指向 side,贡献答案
\(v\) 有且仅有一个儿子,判断 \(u\) 的所有儿子对应的子树,是否有,对于任意一棵子树,总存在返祖边指向 middle(即 down -> middle)
if(v==1){// v 是根,即不存在上部 if(cnt[v]>1)ans++;// 根有多个儿子,贡献答案 else{// 有且仅有一个儿子 for(int x:G1[u]){// 遍历 u 的子结点 x // check 所有的子树 x 是否存在返祖边 if(low[x]<=dep[v]){ans++;break;} } } } -
若有上部,即 \(v\) 结点不是根
-
side -> up
和上一部分 down -> middle 类似
-
down -> middle / up
依旧是做类似检查
-
middle -> up
这里有一个天坑,就是如果 middle -> up 并不成立不代表一定可以贡献答案,而是还需要再判断是否存在 down -> up,因为可以 middle -> down -> up
然而第一部分判定 middle -> up 是否成立也比较恶心,判定方式类似于一个区间 min 的形式,所以用 ST 表(搞不懂为什么官方题解和很多做法都是又臭又长的线段树,明明是静态 RMQ 问题呐)
else{// v 不是根 // side -> up int tmp=f[v],F=find(u,v); if(Mi[F]<dep[v]&&Mi[F])tmp--;// 刨除 (v,u) 链上且是 v 结点儿子 F 的子树贡献 if(tmp!=cnt[v]-1){ans++;continue;}// 结点 v 的所有儿子的其它子树,若存在子树无返祖边,则贡献答案 // down -> middle / up bool flag=false; for(int x:G1[u]){ if(Mi[x]<dep[v]&&Mi[x])continue; if(low[x]>dep[v]&&low[x])continue; ans++,flag=true; break; } // middle -> up if(flag)continue; int o1=(L[F]<=L[u]-1?T.ask(L[F],L[u]-1):INF); int o2=(R[u]+1<=R[F]?T.ask(R[u]+1,R[F]):INF); if(min(o1,o2)>=dep[v]){// middle -> up is fail bool flg=false; for(int x:G1[u]) if(Mi[x]<dep[v]&&low[x]>dep[v]){flg=true;break;}// check down -> up if(!flg)ans++; } } -
也许代码实现上可能有诸多疑问,比如上述的一些条件怎么判定,再比如上述的一些值如何维护。很多代码上的东西不好展开,就以注释的形式写在下方的参考代码中了
代码
高能预警!细节比较多!debug 难度较大!
点击查看代码
#include<bits/stdc++.h>
#define vi vector<int>
#define pb push_back
using namespace std;
const int N=1e5+5,INF=2e9;
int n,m,ans;vi G[N],G1[N],G2[N];// 原图 树边 返祖边
int fa[N][21],dep[N],L[N],tim,R[N],rev[N],low[N];bool vis[N];
int mn[N],cnt[N],f[N],g[N],Mi[N];
// mn[u] 结点 u 的所有返祖边中深度最浅的
// cnt[u] 表示结点 u 在生成树上的儿子个数
// f[u] 表示 u 子树中除了 u 本身能与 u 结点的祖先连上返祖边的个数
// g[u] 表示 u 子树中除了 u 本身能与 u 结点的祖先(不包括 u 的父亲)连上返祖边的个数
// Mi[u] 表示 u 子树中所有返祖边的最浅深度
set<int> S[N];
// S[u] 存储 u 子树内所有结点的所有返祖边深度
inline bool cmp(int x,int y){return S[x].size()>S[y].size();}
inline void dfs(int u,int fath){
fa[u][0]=fath;dep[u]=dep[fath]+1;
L[u]=++tim;rev[tim]=u;vis[u]=true;
for(int i=1;i<=20;i++)fa[u][i]=fa[fa[u][i-1]][i-1];
for(int v:G[u]){
if(v==fath)continue;
if(vis[v]){
if(dep[v]<dep[u]){
G2[u].pb(v);
S[u].insert(dep[v]);
mn[u]=min(mn[u],dep[v]);
}
}else{
dfs(v,u);
G1[u].pb(v);
cnt[u]++;
}
}
for(int v:G1[u]){
if(S[v].size()&&(*S[v].begin())<dep[u])
low[v]=(*--S[v].lower_bound(dep[u]));
if((*S[v].begin())<dep[u]&&S[v].size())f[u]++;
if((*S[v].begin())<dep[u]-1&&S[v].size())g[u]++;
Mi[v]=(*S[v].begin());
}
// v 子树贡献给 u,需要启发式合并保证时间复杂度
sort(G1[u].begin(),G1[u].end(),cmp);
for(int v:G1[u]){
if(S[v].size()>S[u].size())swap(S[u],S[v]);
for(auto x:S[v])S[u].insert(x);
S[v].clear();
}
R[u]=tim;
return;
}
struct Sparse_table{
int lg[N],f[N][21];
inline void init(){
lg[0]=-1;
for(int i=1;i<=n;i++)lg[i]=lg[i>>1]+1;
return;
}
inline void work(){
memset(f,0x3f,sizeof(f));
for(int i=1;i<=n;i++)f[i][0]=mn[rev[i]];
for(int j=1;j<=lg[n];j++)
for(int i=1;i<=n-(1<<j)+1;i++)
f[i][j]=min(f[i][j-1],f[i+(1<<(j-1))][j-1]);
return;
}
inline int ask(int l,int r){
int k=lg[r-l+1];
return min(f[l][k],f[r-(1<<k)+1][k]);
}
}T;
inline int find(int x,int y){
for(int i=20;i>=0;i--)
if(dep[fa[x][i]]>dep[y])x=fa[x][i];
return x;
}//查找x的祖先是y的儿子
inline void solve(int u,int fath){
//树边
for(int v:G1[u]){
if(u==1){// u 是根
if(cnt[u]>=3)ans++;
else if(cnt[u]==2&&cnt[v])ans++;
else if(cnt[u]==1&&cnt[v]>1)ans++;
}else{// u 不是根
int tmp=f[u];
if(Mi[v]<dep[u]&&Mi[v])tmp--;// 刨除 v 子树的贡献
if(tmp!=cnt[u]-1){ans++;continue;}// check 是否存在 u 的所有子结点的子树没有贡献到
if(!cnt[v])continue;
// check 子树 v 中是否存在无返祖到 u 的祖先的子树
if(g[v]!=cnt[v])ans++;
}
}
//返祖边
for(int v:G2[u]){
if(v==1){// v 是根,即不存在上部
if(cnt[v]>1)ans++;// 根有多个儿子,贡献答案
else{// 有且仅有一个儿子
for(int x:G1[u]){// 遍历 u 的子结点 x
// check 所有的子树 x 是否存在返祖边
if(low[x]<=dep[v]){ans++;break;}
}
}
}else{// v 不是根
// side -> up
int tmp=f[v],F=find(u,v);
if(Mi[F]<dep[v]&&Mi[F])tmp--;// 刨除 (v,u) 链上且是 v 结点儿子 F 的子树贡献
if(tmp!=cnt[v]-1){ans++;continue;}// 结点 v 的所有儿子的其它子树,若存在子树无返祖边,则贡献答案
// down -> middle / up
bool flag=false;
for(int x:G1[u]){
if(Mi[x]<dep[v]&&Mi[x])continue;
if(low[x]>dep[v]&&low[x])continue;
ans++,flag=true;
break;
}
// middle -> up
if(flag)continue;
int o1=(L[F]<=L[u]-1?T.ask(L[F],L[u]-1):INF);
int o2=(R[u]+1<=R[F]?T.ask(R[u]+1,R[F]):INF);
if(min(o1,o2)>=dep[v]){// middle -> up is fail
bool flg=false;
for(int x:G1[u])
if(Mi[x]<dep[v]&&low[x]>dep[v]){flg=true;break;}// check down -> up
if(!flg)ans++;
}
}
}
// 向下递归
for(int v:G1[u])solve(v,u);
return;
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0),cout.tie(0);
cin>>n>>m;
for(int i=1;i<=m;i++){
int u,v;cin>>u>>v;
G[u].pb(v),G[v].pb(u);
}
memset(mn,0x3f,sizeof(mn));dfs(1,0);
T.init(),T.work();solve(1,0);
cout<<ans<<'\n';
return 0;
}
最好要感谢墨鱼的重构大法,看来今天运气真的不错,重构一遍就过去了!
小结
可能还需要再敲一遍 T5 代码?比较考验代码能力的题目正是现在需要的
后记
世界孤立我任它奚落
完结撒花!

浙公网安备 33010602011771号