CSP-S & NOIP 2025 游记
省流:在排序面前一败涂地。
选手屡次在考场上重新发明轮子。
Day -INF
打 SCP-S 228 pts,打 T2 的时候发现本地跑 \(O(n\log n)\) TLE,于是想怎么进行 \(O(n)\) 排序,不知道为什么没有想到桶排,还好没有被卡常。
CSP-S
Day 1
打 CSP-S T2,发现复杂度差不多能容纳 \(O(nk2^k)\),只要发明一个 \(O(n)\) MST 算法即可!不知道为什么场上没想到外面排序里面归并,也同样没有想到先跑一遍 Kruskal 去掉多余边,连着两场都被排序算法击败了,我无敌了。然后一直在死磕 Prim,但是不知道为什么考场的 \(O(n\log m)\) 跑得奇慢无比,再加上外面套的 \(O(k2^k)\) 预估得分不超过 \(10\)。
由于冲不出来 T2 导致心态爆炸,哈哈哈只能等 NOIP 翻盘了,预估分段 \([100,130]\),真是艰苦卓绝啊。
反思:考试心态问题,T2 由于强制自己想到复杂度不与 \(m\) 相关的 MST 做法而一直死在一条死路上,且无法花费更多时间后续推进,没有坚持 Kruskal 做法,没有往下推想到优化。启示应该多尝试不同的思路并向下顺延至较深层次,不要一直被卡住。
UPD:选手发现自己离切 T3 只有几厘米,破防了。
UUPD:选手发现自己的 T2 Prim 写的实际上是 \(O(2^k(n+m+k)\log m)\) 的,这玩意 \(2\times 10^{10}\),跑第三个大洋里跑了 \(117.4s\),居然跑出了正确答案我是没想到的。选手场上没有想到缩边不知道选手在干什么。
反思:选手不相信自己能够切出 T3/4 因此硬冲 T2 陷入僵局。
感觉还是写个题解。
T1
开场 \(\text{15min}\) 左右切的,同样写了 \(3\) 个堆,但是比较另类的是大家要么动态用堆做要么离线用排序做反悔贪心,我直接离线用堆做(?。感觉这些个堆写得有点何意味,不伦不类了。
点击查看代码
#include<bits/stdc++.h>
using namespace std;
const int N=1e5+5;
int n,a[N][3],buk[3],rk[3],now;
priority_queue<int,vector<int>,greater<int> >q[3];
bool cmp(int x,int y){return a[now][x]>a[now][y];}
int main(){
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
int Tn;cin>>Tn;
while(Tn--){
cin>>n;int ans=0;
buk[0]=buk[1]=buk[2]=0;
for(now=1;now<=n;now++){
for(int j=0;j<3;j++)
cin>>a[now][j],rk[j]=j;
sort(rk,rk+3,cmp);
ans+=a[now][rk[0]];
q[rk[0]].push(a[now][rk[0]]-a[now][rk[1]]);
buk[rk[0]]++;
}
for(int j=0;j<3;j++){
while(buk[j]>n/2){
ans-=q[j].top();
q[j].pop();
buk[j]--;
}
while(!q[j].empty())q[j].pop();
}
cout<<ans<<'\n';
}
return 0;
}
T2
好像关于本题的失败上面写得已经够多了。首先不知道为什么选手一开始想了先对原树跑一边 MST 但是又觉得这样可能会有问题?(有问题在哪儿?)因为一开始开的思路是一条边 \((u,v,w)\) 可以被 \((u,v,a_{i,u}+a_{i,v})\) 代替,考试完发现不知道自己当时在想什么还想了高达 3h。这玩意跟原边完全没有关系,为什么要保留 \(m\) 条边。再者其实一开始这样转化就有问题,因为最终要形成最小生成树可能出现一个 \(c_i\) 上面接了多个并列连通块的情况,贡献会算重。
想到这些乱七八糟的东西时选手就应该考虑化繁为简了,即回归最朴素的 MST。选手开场观察数据范围,发现 \(O(n2^k)\) 左右可以直接通过。于是选手在草稿纸上写下,发明一个 O(n) 的 MST 算法即可!,笑死我了。发现 Kruskal 的瓶颈其实在于带一只 \(\log\) 的排序,因此在外面排好里面归并,或者直接在外面排好然后枚举 \(2^k\) 的时候直接忽略非法边就能直接做到 \(O(n\alpha(n))\)。
内部归并
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
const int N=1e5+15,M=1e6+5;
const LL INF=1e15;
LL ans=INF;
int n,m,k,c[N],cpt,fa[N];
struct Edge{int u,v,w;}Re[M],e[N],E[12][N];
Edge A[N+10*N],C[N+10*N];
bool cmp(Edge x,Edge y){return x.w<y.w;}
inline int fr(int x){return fa[x]==x?x:fa[x]=fr(fa[x]);}
bool ins(int x,int y){
int frx=fr(x),fry=fr(y);
if(frx==fry)return 0;
fa[frx]=fry;return 1;
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin>>n>>m>>k;
for(int i=1;i<=n;i++)fa[i]=i;
for(int i=1;i<=m;i++){
int u,v,w;cin>>u>>v>>w;
Re[i]=(Edge){u,v,w};
}
sort(Re+1,Re+1+m,cmp);
for(int i=1;i<=m;i++)
if(ins(Re[i].u,Re[i].v))
e[++cpt]=Re[i];
m=cpt;
for(int i=1;i<=k;i++){
cin>>c[i];
for(int j=1;j<=n;j++){
int v=j,w;cin>>w;
E[i][j]=(Edge){i+n,v,w};
}
sort(E[i]+1,E[i]+1+n,cmp);
}
for(int S=0;S<(1<<k);S++){
LL res=0;int cnt=cpt;
for(int i=1;i<=m;i++)
A[i]=e[i];
for(int i=1;i<=n+k;i++)
fa[i]=i;
for(int i=1;i<=k;i++)
if((S>>(i-1))&1){
res+=c[i];
int pos=1,omg=1;
for(int j=1;j<=n;j++){
while(pos<=cnt&&A[pos].w<=E[i][j].w)
C[omg++]=A[pos++];
C[omg++]=E[i][j];
}
while(pos<=cnt)C[omg++]=e[pos++];
cnt=omg-1;
for(int j=1;j<=cnt;j++)
A[j]=C[j];
}
for(int i=1;i<=cnt;i++)
if(ins(A[i].u,A[i].v))
res+=A[i].w;
ans=min(ans,res);
}
cout<<ans;
return 0;
}
无归并
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
const int N=1e5+15,M=1e6+5;
const LL INF=1e15;
LL ans=INF;
int n,m,k,c[N],cpt,fa[N];
struct Edge{int u,v,w;}Re[M],e[N];
bool cmp(Edge x,Edge y){return x.w<y.w;}
inline int fr(int x){return fa[x]==x?x:fa[x]=fr(fa[x]);}
bool ins(int x,int y){
int frx=fr(x),fry=fr(y);
if(frx==fry)return 0;
fa[frx]=fry;return 1;
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin>>n>>m>>k;
for(int i=1;i<=n;i++)fa[i]=i;
for(int i=1;i<=m;i++){
int u,v,w;cin>>u>>v>>w;
Re[i]=(Edge){u,v,w};
}
sort(Re+1,Re+1+m,cmp);
for(int i=1;i<=m;i++)
if(ins(Re[i].u,Re[i].v))
e[++cpt]=Re[i];
for(int i=1;i<=k;i++){
cin>>c[i];
for(int j=1;j<=n;j++){
int v=j,w;cin>>w;
e[++cpt]=(Edge){i+n,v,w};
}
}
sort(e+1,e+1+cpt,cmp);
for(int S=0;S<(1<<k);S++){
LL res=0;
for(int i=1;i<=n+k;i++)
fa[i]=i;
for(int i=1;i<=k;i++)
if((S>>(i-1))&1)res+=c[i];
for(int i=1;i<=cpt;i++){
if(e[i].u>n&&(!((S>>(e[i].u-n-1))&1)))continue;
if(ins(e[i].u,e[i].v))
res+=e[i].w;
}
ans=min(ans,res);
}
cout<<ans;
return 0;
}
T3
选手看这题的时候想到了自己曾经说过,我如果在 CSP 考场上看到字符串题绝对第一时间想哈希。想到了对 \(t_1,t_2\) 进行最长公共前后缀缩串,先打了个哈希的暴力(大概 \(O(nL_2)\)?),然后确实想了一会儿 ACAM,因为考试前才打过且感觉这个题挺可做的。选手想了想发现自己无法解决匹配问题,且如果要计算方案数可能还要搞个二维数点之类的东西,因为要分别对 \(1,2\) 跑 ACAM(选手不知道为什么没有想到把 \(s_1,s_2\) 也缩了,然后放在一起跑),然后继续磕哈希直接倒闭了。
结果选手考完看了一眼题解直接 \(\text{20min}\) 切了这个东西。不知道为什么那么显然的东西想不到。发现 \(s_1,s_2\) 不同的地方对应着 \(t_1,t_2\) 也不同,除此之外 \(t_1,t_2\) 的所有位置都相同。因此直接把 \(t_1,t_2\) 拆成 \(A?BC?D\) 的形式,其中 \(t_1\) 表示为 \(ABD\),\(t_2\) 表示为 \(ACD\),\(A,D\) 分别为最长公共前/后缀。\(s_1,s_2\) 也这样拆,判一下 \(|t_1|\neq |t_2|\) 然后直接跑多模匹配即可。
点击查看代码
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
const int N=2e5+5,L=6e6+5;
int n,qn,ncnt;
struct Node{int ch[27],cnt,fail;}t[L];
void ins(string s){
int now=0,len=s.size();
for(int i=0;i<len;i++){
int p=(s[i]>='a'&&s[i]<='z'?s[i]-'a':26);
if(!t[now].ch[p])t[now].ch[p]=++ncnt;
now=t[now].ch[p];
}
t[now].cnt++;
}
int hd,tl,q[L],num[L];
void build(){
hd=1,tl=0;
for(int p=0;p<27;p++)
if(t[0].ch[p])q[++tl]=t[0].ch[p];
while(hd<=tl){
int now=q[hd++];
num[now]=t[now].cnt+num[t[now].fail];
for(int p=0;p<27;p++){
if(t[now].ch[p])t[t[now].ch[p]].fail=t[t[now].fail].ch[p],
q[++tl]=t[now].ch[p];
else t[now].ch[p]=t[t[now].fail].ch[p];
}
}
}
string brk(string &a,string &b){
string A,B,C,D;
int L=1,R=a.size(),m=a.size();
for(int j=1;j<=m;j++)
if(a[j-1]!=b[j-1]){L=j;break;}
for(int j=m;j>=1;j--)
if(a[j-1]!=b[j-1]){R=j;break;}
int siz=R-L+1;
A=a.substr(0,L-1);B=a.substr(L-1,siz);
C=b.substr(L-1,siz);D=a.substr(R,m-R+1);
string my=A+"#"+B+C+"#"+D;
return my;
}
int query(string s){
int len=s.size(),res=0,now=0;
for(int i=0;i<len;i++){
int p=(s[i]>='a'&&s[i]<='z'?s[i]-'a':26);
now=t[now].ch[p];
res+=num[now];
}
return res;
}
int main(){
//freopen("replace.in","r",stdin);
//freopen("replace.out","w",stdout);
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin>>n>>qn;
for(int i=1;i<=n;i++){
string sa,sb,A,B,C;cin>>sa>>sb;
if(sa==sb)continue;
ins(brk(sa,sb));
}
build();
while(qn--){
string ta,tb;cin>>ta>>tb;
if(ta.size()!=tb.size()){
cout<<'0'<<'\n';
continue;
}
else cout<<query(brk(ta,tb))<<'\n';
}
return 0;
}
T4
感觉好题。感觉设出了大约正确的方程。感觉被 T2 击溃了不敢往后拼。
搬个出题人题解,感觉这种设计挺少见的,有点 Ad-hoc 那味了。
先做一个前缀和,让 \(c_i\) 表示针对真实的 \(c\) 开的一个桶,然后 \(pre_j=\sum_{i=0}^j c_i\)。这样可以快速计算全局耐心 \(\le j\) 的人数。然后考虑转移。
考虑 \(S_{i+1}=1\),我们要往 \(i\) 个人里面多塞一个。
考虑设 \(f_{i,j,k}\) 表示已面试 \(i\) 个人,有 \(j\) 个被拒绝/逃走,这 \(i\) 个人中有 \(k\) 个耐心 \(\le j\) 的,且这些人已完成决策计算。
这里我们需要注意一件事情:在本题的 DP 中,我们并非直接确定 \([1,i]\) 这个前缀用了哪些人,而是通过 \(k\) 指针往后推进确定。这被称为贡献延后 trick,注意到我们在选用耐心 \(>j\) 的转移时系数是 \(1\),这说明我们把这个抉择向其他抉择后推了。\(k\) 已解决的人中包含已经被决策的 \(>j'\) 的部分,同时包含已经被决策的 \(\le j'\) 的部分。\(i-k\) 部分即为之前选的还未被决策的 \(>j'\) 的部分。
考虑 \(S_{i+1}=1\),如果这个人被同意,说明一定有耐心 \(>j\),直接选用 \(f_{i,j,k}\to f_{i+1,j,k}\),其条件是 \(n-pre_j>i-k\),即耐心 \(>j\) 的人数不为 \(0\)。
若被拒,我们在前面还未被决策的点中选一个,同时决策前面选的 \(>j'\) 的点中有哪些是 \(j+1\)(\(j+1\) 剩余的点会被后续决策 \(\le j'\) 选到)。由于耐心相同的人等价,直接在里面枚举 \(l\) 个参与决策。考虑其贡献的位置,发现 \(i-k\) 个位置可用(即对 \([1,i]\) 中未决策的 \(>j'\) 点贡献),这些位置都是之前选择的 \(>j'\) 的位置(因为选择 \(\le j'\) 一定会被计入 \(k\))。转移就是:
\(k\) 维加的 \(1\) 就是选择的 \(\le j'\) 的点,\(l\) 就是前面作为 \(>j'\) 被选择的点。
讨论 \(S_{i+1}=0\) 的情况,发现我们无论怎么选都会导致被拒人数 \(+1\),那我们直接在上面的方程基础上改即可。
我们发现这个分类稍有不同。因为我们在选 \(>j\) 的时候可能选到 \(j+1\),而且我们要同时做 \(j+1\) 的决策。这时我们枚举的 \(l\) 仍然表示 \(j+1\) 对 \(i-k\) 这一段的贡献(不包含最新加入的 \(i+1\))。那我们换一下分类角度,分选的点是否立刻被决策。如果选 \(>j+1\),一定不会立刻被决策,同时满足条件 \(n-pre_{j+1}>i-k-l\):
否则选 \(\le j+1\),则我们可以在 \(pre_{j+1}-k-l\) 中抽取任意一个作为当前选的东西,也可能抽到 \(j+1\):
点击查看代码
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
const LL MOD=998244353;
const int N=505;
int n,m,c[N],pre[N];
LL f[N][N],tmp[N][N],jc[N],C[N][N];
char s[N];
void add(LL &x,LL y){x=(x+y>=MOD?x+y-MOD:x+y);}
int main(){
//freopen("employ.in","r",stdin);
//freopen("employ.out","w",stdout);
scanf("%d%d",&n,&m);
scanf("%s",s+1);
for(int i=1;i<=n;i++){
int x;scanf("%d",&x);
c[x]++;
}
pre[0]=c[0];jc[0]=1;
for(int i=1;i<=n;i++)
pre[i]=c[i]+pre[i-1],
jc[i]=jc[i-1]*i%MOD;
for(int i=0;i<=n;i++)
C[i][0]=C[i][i]=1;
for(int i=2;i<=n;i++)
for(int j=1;j<i;j++)
C[i][j]=(C[i-1][j]+C[i-1][j-1])%MOD;
f[0][0]=1;
for(int i=0;i<n;i++){
for(int j=0;j<=n;j++)
for(int k=0;k<=n;k++)
tmp[j][k]=f[j][k],f[j][k]=0;
for(int j=0;j<=i;j++){
for(int k=0;k<=pre[j]&&k<=i;k++){
if(s[i+1]=='0'){
for(int l=0;l<=c[j+1]&&l<=i-k;l++){
if(n-pre[j+1]>i-k-l)add(f[j+1][k+l],tmp[j][k]*
C[c[j+1]][l]%MOD*C[i-k][l]%MOD*jc[l]%MOD);
add(f[j+1][k+l+1],tmp[j][k]*(pre[j+1]-k-l)%MOD*
C[c[j+1]][l]%MOD*C[i-k][l]%MOD*jc[l]%MOD);
}
}
else {
if(n-pre[j]>i-k)add(f[j][k],tmp[j][k]);
for(int l=0;l<=c[j+1]&&l<=i-k;l++)
add(f[j+1][k+l+1],tmp[j][k]*(pre[j]-k)%MOD*
C[c[j+1]][l]%MOD*C[i-k][l]%MOD*jc[l]%MOD);
}
}
}
}
LL ans=0;
for(int j=0;j<=n-m;j++)
add(ans,f[j][pre[j]]*jc[n-pre[j]]%MOD);
cout<<ans;
return 0;
}
Summary
选手确实感觉今年 CSP-S 是比较可做的一场,但是发挥不好说是,原因也挺多的,先这样吧/oh,备战 NOIP。选手挺怀疑自己过去 \(2\) 年都在干什么,感觉到了正赛不但发挥不出自己的应有水平而且比某些迟自己一两年学 OI 的选手还要低分,且比不过一些付出不如自己的选手,挺失落的。试想一些人每天在机房后面抽机,你在前面认真打模拟赛/写总结/补题,到头来你正赛还不如他们,你不会很失落吗,哈哈。
大约是正赛经验不够丰富吧,还得多打 vp。
选手感觉就着这样的势头大约在今年 OI 生涯就迎来落幕了,挺遗憾的,毕竟在 S 的经历中选手充分认识到了自己的弱小,甚至连场切蓝的能力都不具备,可能往后 NOIP 的历程和本场也是如出一辙,甚至因为 S 没考好对自己的压力会更大。与其说是一场演练,不如说是一场映射。预见结局大概就是什么学了 \(4\) 年 OI 没拿到 \(7\) 钩,但生活如此安排演员也只能将就,大概吧。
选手还是挺想在高中打一次 GDOI 的,可惜现在已经失败一半了hh。我无法指责命运的不公因为我的命运终究由往昔之我的所作所为铺就,每次梦中醒来都会有一种刺痛感,意识到,哦,原来 CSP-S 2025 已经考过了,而且我考了个 \(3\) 年以来最低分同时也是机房倒数分。有很多时候都无法抬头正眼看其他 OI 选手,倏忽间又陷入自我否定中。
我想我大概是个自尊心胜过自信心的人,因此面对来往总要强颜欢笑。我同时也是个容易遗忘的人,忘掉不愉快的过去,也忘掉自己得来的种种宝贵教训。依仗着自己的好胜心前进的我啊,在被从头到尾击溃后还有什么可依赖呢。我确实认为自己的 OI 生涯无论是在天赋上还是在后期学习上都远不如同机房的人,但是一时对自己能力的错估又提高着我的自我期望,让我长期处于矛盾之中。
考前整理 NOIP 2024 游记时看到 PassName 和 hanss6 等人的 OI 生涯,当时还喟叹看生活在悲剧中的人总能燃起自己的希望,想着,哦,至少我比他们还强一点点。同时我也思考着 PassName 在博文中提到的“纯粹的 OI”,事实上对于竞赛的热血激情我不知道从何而来,至少你要先有攀登的欲望然后再有愈挫愈奋的勇气,站在中上层后才敢以“纯粹”称呼自己的竞赛目的吧。
GJ 说得是,近年来 OI 选手的竞赛目标确实是越来越功利了。但应该看到的也是,社会上竞争压力越发激烈,水母打着“多元化升学”的噱头将较为突出的学生收入囊中培养成竞赛生,阉割掉他们的部分文化课学习。显然,水母已经不缺高考成绩优异者,whk 氛围也较为优良。推举竞赛者我们看到的却是级组、教练组、区教育局各有各的目的,将学生作为棋子,上层“智慧教育发展中心”制定一套压根不合理的规则后丢给下面,没有算法竞赛背景、不了解也不理解算法竞赛的“老师”大范围管辖学生自由。在指控学生功利化之前,OI 确实越来越体系化规范化,但正因如此其本身也变得更加不像原来的 OI 了,尤其在过形式化的背景下。
坦白说,我的竞赛目的一点也不纯粹,我也没有那么热血的目标和持续奋斗的努力,也并不是每个人都是天才,每个人都能“定个小目标拿下 NOI 金牌”,每个人都能去微格。但我们应该看到的是,算法竞赛不是一条理想天国之路,而是一条尸横遍野的沙径。选手在受到理想目标追求的感染时必定会踏着前人的足迹考量现实,结合自身情况出发,而不是一味追求理想化。
在正赛稀少的机会中,我们作为算竞选手同时也在攀登一面的陡崖,稍有不慎就粉身碎骨。这不像 whk 一样这么多月考、模拟考让你有机会躲在被窝里哭或者反省收集错题集等,这完全不相同。你所能做的唯一一件事就是做好最充分的准备然后迎接你将要迎接的东西,无论结局是好是坏。\(26\) 班辩论赛 hjj 曾和我讨论过他们的辩题,“故事的结局是否重要”。他信誓旦旦地申明着一起组织的论点,“过度在意结局带来的心理效应影响”“现实既定发生与因果必然性”,进行逻辑的疾速驳斥时,我不禁想,难道你能不在乎结局去准备 OI?在退竞转化竞时,你难道能够做到毫不犹豫地为拿到省一奋力冲刺?他给我留下的印象是大型考试前敏感和脆弱,常见的和班主任交流与焦虑,这也恰证明了没有人能够不在意结局。高中生涯中只有寥寥几次参赛机会,没有反复的操练,一轮淘汰满盘皆输,难道你还能抱着完全的激情去拼搏,直到结局揭晓,一点也不担心?
让我想到了蔡崇达在《皮囊》中描写的厚朴,如果真有这样在学校的鼓励下盲目乐观不考量现实的人,想必他们和厚朴,或和叶圣陶笔下“永远感知不到悲伤”“周围裹着一层厚厚的屏障”的人一样可悲。
戛然而止带来的与其说是不甘,更不如说是迷惘。想着,这就结束了?然后反复咀嚼自己的悲伤,直至化成一滩脓水。我会认为自己的付出和成果不成正比的吧,我会在往后的人生反复回味这一次参赛的吧,然后哈哈哈地嘲笑一下自己给过去的自己解解围。然后回味着发现,其实自己不停在给场上的自己寻找借口,诸如心态没有调整好之类,实则最真实的水平与成果已以最赤裸裸的形式展现在眼前。你不够格,他这样对我说,你的真实水准就是如此。于是我又深刻地被刺痛了,的确这就是真相,我也只能眼睁睁地看着它发生而无力改变。
我缺少信仰,我也缺少改变既定现实的能力,更缺少自我评估和平衡心态的能力。也真不是我想悲观,而是我所见如此。好吧,献上我的最后一舞吧。至少我还能祈望拥有一个壮丽抑或悲壮的谢幕。
NOIP
Day -26
前话:选手考后回到机房的第一天。选手感觉自己翻盘的概率不到 \(10\%\),已严肃准备文化课大学习。选手的水平也就在那儿了,只能度过相对失败的一个 OI 生涯。
Day -25
补点题,颓飞了。

浙公网安备 33010602011771号