比赛链接
官方题解(视频)
B题是个贪心。CD用同余最短路,预处理的完全背包,多重背包都能做,比较典型。E是个诈骗,暴力就完事了。F是个线段树。G是个分类大讨论,出题人钦定的本年度最佳最粪 题目
A 小红不想做炸鸡块粉丝粉丝题
思路:
签到
code:
#include <iostream>
#include <cstdio>
using namespace std;
int a,tot;
int main(){
cin>>a;
tot=a;
for(int i=2,t;i<=6;i++)
cin>>t,tot+=t;
if(a*30>=tot)puts("No");
else puts("Yes");
return 0;
}
B 小红不想做鸽巢原理
思路:
emmmm不太清楚和鸽巢原理有啥关系,就是个贪心的思路。
因为是 k k k 个 k k k 个取走,所以假设一共有 t o t tot tot 个小球,最后会剩下 t o t % k tot\%k tot%k 个小球。因为要剩下的种类尽可能少,那么我们尽可能留下数量多的种类就行了。
code:
#include <iostream>
#include <cstdio>
#include <set>
using namespace std;
const int maxn=1e5+5;
typedef long long ll;
int n,k;
ll tot=0;
set<int> S;
int main(){
cin>>n>>k;
for(int i=1,t;i<=n;i++){
cin>>t;
S.insert(t);
tot+=t;
}
tot%=k;
if(tot==0){
cout<<0<<endl;
return 0;
}
int ans=0;
for(auto it=S.rbegin();it!=S.rend();it++){
ans++;
if(tot>*it)tot-=*it;
else break;
}
cout<<ans<<endl;
return 0;
}
C,D 小红不想做完全背包
思路:
和寒假营第二场的D题是同种类型的题,做法有三:同余最短路,带预处理的完全背包,多重背包。
因为我们只想要是
p
p
p 的倍数,而这个数是什么我们不关心,所以我们用到的数都直接模
p
p
p 即可,当模
p
p
p 等于零的时候就是
p
p
p 的倍数了,这就算是比较典型板子 的带同余的完全背包问题了。
它和一般的完全背包还不太一样,普通的完全背包跑一趟就够了,因为重量不会无缘无故减少,我们只要从小到大跑一遍就能考虑到所有情况了,但是带同余的话重量就有可能减少了。比如容量为 7 7 7,物品的重量为 3 3 3,不带只跑一遍和带了跑到重复为止的结果是不一样的,如下图:
容量:0 1 2 3 4 5 6
不带:0 0 1 0 0 2 0
带了:5 3 1 6 4 2 7
一般来说,我们从某个位置出发,为了跑到第一次重复,至少需要跑 p 2 g c d ( a i , p ) \dfrac {p^2}{gcd(a_i,p)} gcd(ai,p)p2 次(设 a i ∗ x ≡ a i ( m o d p ) a_i*x\equiv a_i\pmod p ai∗x≡ai(modp),解出来 x = p g c d ( a i , p ) + 1 x=\dfrac p{gcd(a_i,p)}+1 x=gcd(ai,p)p+1,也就是说一趟只用跑 p g c d ( a i , p ) \dfrac p{gcd(a_i,p)} gcd(ai,p)p 次就可以停了,再以每位置开始跑一趟,一共 p ∗ p g c d ( a i , p ) p*\dfrac p{gcd(a_i,p)} p∗gcd(ai,p)p 次)。再算上有 n n n 件物品,这样时间复杂度就爆了, 最坏情况 O ( n ∗ p 2 ) O(n*p^2) O(n∗p2)。
大致代码如下,TLE了(数据太弱了,居然就T了三个点,而且如果就算不枚举开始位置,只跑一次,甚至只WA三个点,经过某些神秘的顺序安排,居然还能AC,给人一种做法是正确的的错觉)
cin>>n>>p;
vector<int> a(n);
for(int i=0,t;i<n;i++){
cin>>t;
a[i]=t%p;
}
sort(a.begin(),a.end());
a.erase(unique(a.begin(),a.end()),a.end());
n=a.size();
vector<int> dp(p+5,1e9);
for(auto x:a){
dp[x]=1;
for(int idx=0;idx<p;idx++)
for(int i=1,t=(idx+x)%p;i<p/gcd(x,p);i++,t=(t+x)%p)
dp[(t+x)%p]=min(dp[(t+x)%p],dp[t]+1);
}
cout<<dp[0];
发现我们没必要从每个位置开始都跑 p g c d ( a i , p ) \dfrac p{gcd(a_i,p)} gcd(ai,p)p 次,我们用多个物品得到的重量是固定的,我们不如一开始就处理好,然后直接拿来用。具体来说,设 c s t [ i ] cst[i] cst[i] 表示增加重量为 i i i 需要使用多少个物品,多个物品可以都处理到一个 c s t [ i ] cst[i] cst[i],预处理的时间复杂度为 n ∗ p g c d ( a i , p ) n*\dfrac p{gcd(a_i,p)} n∗gcd(ai,p)p,使用的时候直接把 c s t [ i ] cst[i] cst[i] 当作物品跑完全背包,每个物品只跑一趟即可。
为什么预处理后不需要跑多趟了?原来我们用物品 a i a_i ai 跑第一次的时候,这时相当于用 c s t [ a i % p ] cst[a_i\%p] cst[ai%p] 跑一次,原来我们用物品 a i a_i ai 跑第二次的时候,这时相当于用 c s t [ 2 ∗ a i % p ] cst[2*a_i\%p] cst[2∗ai%p] 跑一次,原来我们用物品 a i a_i ai 跑第三次的时候,这时相当于用 c s t [ 3 ∗ a i % p ] cst[3*a_i\%p] cst[3∗ai%p] 跑一次,类推,甚至我们多个物品同时处理后,这个 c s t cst cst 值还会更小,答案更优。因此我们原本每个位置每个物品跑多次,现在只要每个位置每个 c s t cst cst 跑一次就可以了。
因此完全背包部分时间复杂度就优化为了 O ( p 2 ) O(p^2) O(p2)。总的时间复杂度就优化为了 O ( n ∗ p g c d ( a i , p ) + p 2 ) O(\dfrac {n*p}{gcd(a_i,p)}+p^2) O(gcd(ai,p)n∗p+p2),最坏 O ( n p + p 2 ) O(np+p^2) O(np+p2)。
另外同余最短路和多重背包也是正确的做法,数据弱,奇奇怪怪的做法也能日过去。
code:
#include <iostream>
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
typedef long long ll;
const int maxn=2005;
int n,p;
int gcd(int a,int b){
while(b)b^=a^=b^=a%=b;
return a;
}
int main(){
cin>>n>>p;
vector<int> a(n);
for(int i=0,t;i<n;i++){
cin>>t;
a[i]=t%p;
}
sort(a.begin(),a.end());
a.erase(unique(a.begin(),a.end()),a.end());
n=a.size();
vector<int> cst(p+5,1e9),dp(p+5,1e9);
for(auto x:a){
for(int i=1,cur=x;i<=p;i++,cur=(cur+x)%p){
cst[cur]=min(cst[cur],i);
}
}
for(int i=0;i<p;i++){
int x=cst[i];//i步数 x代价
dp[i]=min(dp[i],x);
for(int j=i;j<p+i;j++)
dp[j%p]=min(dp[j%p],dp[(j-i+p)%p]+x);
}
cout<<dp[0];
return 0;
}
E 小红不想做莫比乌斯反演杜教筛求因子和的前缀和
思路:
之前练习赛出了一个名字差不多的题,特难。这次出题人估计是想骗做题人给自己上压力,不过可惜放在了 E E E 题的位置上。没骗到。
枚举长 n n n 和宽 m m m,然后算出 p p p,统计答案个数即可。
code:
#include <iostream>
#include <cstdio>
using namespace std;
int n,m,p,x;
int ans=0;
int main(){
cin>>n>>m>>p>>x;
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++)
if((x-i*j)%(2*(i+j))==0 && (x-i*j)/(2*(i+j))>=1 && (x-i*j)/(2*(i+j))<=p)
ans++;
cout<<ans;
return 0;
}
F 小红不想做模拟题
思路:
区间修改,区间查询,还是比较明显能想到线段树的。
当我们区间推平时,比如推平了 a a a 串的区间 [ l , r ] [l,r] [l,r],把它变成 1 1 1,那么这一段区间 1 1 1 的对数就变成了 b b b 串这段区间中 1 1 1 的个数,同理推平另一个串。所以我们为了维护区间 1 1 1 的对数,还需要分别维护住 a , b a,b a,b 串区间内 1 1 1 的个数。
另外因为这个题只进行了一个简单的推平操作,所以我们也不需要写懒节点并将节点信息向下传递,我们只要在线段树节点上打个标记,表示是否推平了,之后修改或者查询的时候查到标记就直接返回就行了。
code:
#include <iostream>
#include <cstdio>
#include <cstring>
using namespace std;
const int maxn=1e5+5;
int n,q;
string str[2];
struct segment_tree{
#define ls p<<1
#define rs p<<1|1
struct Node{
int l,r;
int a,b;//区间内 a串1的个数,b串1的个数
int sam;//ab串同为1的位置的个数
bool fa,fb;//是否填平
Node(int l=0,int r=0,int a=0,int b=0,int sam=0,bool fa=false,bool fb=false):l(l),r(r),a(a),b(b),sam(sam),fa(fa),fb(fb){};
}tr[maxn<<4];
void push_up(int p){
auto& [l,r,a,b,sam,fa,fb]=tr[p];
a=(fa)?r-l+1:tr[ls].a+tr[rs].a;
b=(fb)?r-l+1:tr[ls].b+tr[rs].b;
if(fa)sam=tr[p].b;
else if(fb)sam=tr[p].a;
else sam=tr[ls].sam+tr[rs].sam;
}
void build(int p,int l,int r){
tr[p].l=l;
tr[p].r=r;
if(l==r){
tr[p].a=(str[0][l-1]=='1');
tr[p].b=(str[1][l-1]=='1');
tr[p].sam=(tr[p].a && tr[p].b);
return;
}
int mid=l+r>>1;
build(ls,l,mid);
build(rs,mid+1,r);
push_up(p);
}
void print(int p,int l,int r){
printf("%d [%d,%d]:%d %d %d\n",p,l,r,tr[p].a,tr[p].b,tr[p].sam);
if(l==r){
return;
}
int mid=l+r>>1;
print(ls,l,mid);
print(rs,mid+1,r);
}
void print(){print(1,1,n);}
void modify(int p,int l,int r,int L,int R,bool st){
if(L<=l && r<=R){
if(st){//填平a串
tr[p].a=r-l+1;
tr[p].fa=true;
tr[p].sam=tr[p].b;
}
else {//填平b串
tr[p].b=r-l+1;
tr[p].fb=true;
tr[p].sam=tr[p].a;
}
return;
}
int mid=l+r>>1;
if(mid>=L)modify(ls,l,mid,L,R,st);
if(mid<R)modify(rs,mid+1,r,L,R,st);
push_up(p);
}
int q(){return tr[1].sam;}
#undef ls
#undef rs
}tr;
int main(){
// cin.tie(0)->sync_with_stdio(false);
cin>>n>>str[0]>>str[1];
tr.build(1,1,n);
for(cin>>q;q;q--){
char ch;
int l,r;
cin>>ch>>l>>r;
tr.modify(1,1,n,l,r,(ch=='A'));
cout<<tr.q()<<endl;
// tr.print();
// cout<<endl;
}
return 0;
}
G 小红不想做平衡树
思路:
我们发现,在一个数列里,数列一定是升序段降序段交替出现的,比如:升序-降序-升序-降序…,或者 降序-升序-降序-升序…。
我们选择一段区间,然后翻转其某个子段,使得反转后成为一个升序序列,有五种可能的情况(因为题目保证了数列中数各不相同,所以这里不讨论数值相等的情况,下面默认如此):
- 整段升序。
- 整段降序。
- 先升序,后降序。而且第一段最大值小于第二段最小值。
- 先降序,后升序。而且第一段最大值小于第二段最小值。
- 先升序,后降序,再升序。第一段最大值小于第二段最小值,且第二段最大值小于第三段最小值。
大概示例图如下:
我们对每种情况分别讨论,然后统计答案即可。
不过实际在写的时候会非常麻烦,因为前一段的末尾那个数正好就是后一段开头的那个数,导致上面的五种情况会出现重叠计数的情况。比如第三种情况中,第二段只取第一个数时,统计出来的答案就会和第一种情况完全重复,再比如第五种情况中第一段只选第一个数时统计出来的答案都和第四种情况出现重复等。在官方题解的写法中,细节也是蛮多的。下面只讲我的写法,和题解不太一样。
回到开头,在一个数列里,数列一定是升序段降序段交替出现的,而升序段和降序段交界处的那个数是两段共用的。这种“转折点”有两种类型:升序变降序(形如 ^),降序变升序(形如 √)。而且是交替出现的。
我们可以把这些转折点处理出来,然后根据转折点来统计答案。虽然仍然会出现重复的情况,但是更直观一些(大概?)
code:
#include <iostream>
#include <cstdio>
#include <vector>
#define pii pair<int,int>
using namespace std;
typedef long long ll;
const int maxn=2e5+5;
int n,a[maxn];
int main(){
cin>>n;
for(int i=1;i<=n;i++)cin>>a[i];
ll ans=0;
int st=-1;//第一个 ^ 点的位置
vector<int> ver;
for(int i=2,f;i<n;i++){
f=-1;
if(a[i-1]<a[i] && a[i]>a[i+1])f=0;// ^
if(a[i-1]>a[i] && a[i]<a[i+1])f=1;// √
if(!~st)st=f;
if(~f)ver.push_back(i);
}
int tl=1,len,tn=ver.size();//1 2
for(auto i:ver){
len=i-tl+1;
ans+=1ll*(len+1)*len/2;
tl=i;
}
len=n-tl+1;
ans+=1ll*(len+1)*len/2;
ans-=tn;
for(int i=st,id,l,r;i<ver.size();i+=2){//3
id=ver[i];
l=(i==0)?1:ver[i-1];
r=(i==ver.size()-1)?n:ver[i+1];
for(int j=id+1;j<=r;j++){//这边从id+1的位置开始统计,当j=id时会和情况2重复
if(a[j]>a[id-1])ans+=id-l;
else break;
}
}
for(int i=st^1,id,l,r;i<ver.size();i+=2){//4
id=ver[i];
l=(i==0)?1:ver[i-1];
r=(i==ver.size()-1)?n:ver[i+1];
for(int j=id-1;j>=l;j--){//这边从id-1的位置开始统计,当j=id时会和情况1重复
if(a[j]<a[id+1])ans+=r-id;
else break;
}
}
for(int i=st,id1,id2,l,r;i+1<ver.size();i+=2){//5
id1=ver[i];
id2=ver[i+1];
l=(i==0)?1:ver[i-1];
r=(i+1==ver.size()-1)?n:ver[i+2];
if(a[id1-1]<a[id2] && a[id1]<a[id2+1])ans+=1ll*(id1-l)*(r-id2);
}
cout<<ans<<endl;
return 0;
}