Educational Codeforces Round 152 A~F
\(Educational\) \(Codeforces\) \(Round\) \(152\)
\(A-Morning\) \(Sandwich\)
题意:有 \(b\) 块面包,\(c\) 块沙拉, \(h\) 块火腿,求最多能吃多少层三明治。要求每两层一个面包。
while(t--){
int a,b,c;cin>>a>>b>>c;
cout<<min(a+a-1,b+c+b+c+1)<<"\n";
}
\(B-Monsters\)
题意:打怪兽,每次选择血量最高的怪兽,一次打掉 \(k\) 滴血,当血量一样时选择编号小的打。求怪物死亡顺序。
先把所有怪兽的血量模到 \([1,k]\),然后按这个新血量为第一关键字自大到小,下标为第二关键字自小到大排序。然后输出下标就OK。
\(C-Binary\) \(String\) \(Copying\)
题意:给定一个01序列,复制 \(k\) 份,每一份将 \([l_i,r_i]\) 区间排序。求最后得到的不同序列个数。
考虑将 \(l_i,r_i\) 扩大为 \(l'_i,r'_i\) 使得排序后结果不变且 \(r'_i-l'_i\) 最大。
显然,\([l'_i,l_i)\) 全部为0,\((r_i,r'_i]\) 全部为1。维护每个数前面第一个1的位置和每个数后面第一个0的位置即可。
注意原序列也算,所以要特判一下对序列不会有改变的情况。也即改的一段1/0的连续段,亦或者一段0+一段1
while(t--){
int n,k;cin>>n>>k;
for(int i=1;i<=n;i++){
char x;cin>>x;
a[i]=x-'0';
}
int c=0,d=0;a[0]=-1;
for(int i=1;i<=n;i++){
if(a[i]!=a[i-1])cnt[i]=++c;
else cnt[i]=cnt[i-1];
s[i]=a[i]+s[i-1];
if(a[i])pre[i]=i;
else pre[i]=pre[i-1];
}
nxt[n+1]=n+1;
for(int i=n;i;--i){
if(!a[i])nxt[i]=i;
else nxt[i]=nxt[i+1];
}
int tot=0;
while(k--){
int l,r;cin>>l>>r;
if(cnt[r]==cnt[l]||cnt[r]-cnt[l]==1&&a[l]==0)l=0,r=0;
else l=pre[l-1]+1,r=nxt[r+1]-1;
w[++tot]=(node){l,r};
}
sort(w+1,w+tot+1);
tot=unique(w+1,w+tot+1)-w-1;
cout<<tot<<"\n";
}
\(D-Array\) \(Painting\)
赛时眼瞎,打掉了一个加一,然后没了。
这种无后效性又具有明显可合并性的最优化问题,多半是DP。
注意到对于一个非零连续段而言,它实际可以扩展的取决于该连续段里最大的数(2就能扩展两边,1只能扩展一边。
我们将它合并为一个数,然后考虑动态规划。注意到这里与下一个数是不是已经被更新有关,故设 \(f_{i,0/1}\) 表示前 \(i\) 个数已经OK,第 \(i+1\) 个数是否需要花钱的最小代价。
当当前数为 \(0\) 时,显然有 \(f_{i,0}=\min(f_{i-1,1},f_{i-1,0}+1),f_{i,1}=\infty\)。
当当前数为 \(1\) 时,显然有 \(f_{i,0}=\min(f_{i-2,0}+1,f_{i-2,1}+1)\)。注意当 \(i=1\) 时有 \(f_{i,0}=1\)。
\(f_{i,1}=\min(f_{i-1,1},f_{i-1,0}+1)\)
当前数为2时,显然有 \(f_{i,0}=f_{i,1}=\min(f_{i-2,0}+1,f_{i-1,0}+1,f_{i-2,1}+1)\)
注意当 \(i=1\) 时,\(f_{i,0}=f_{i,1}=1\)
初始化:\(f_{0,0}=0,f_{0,1}=\infty\)
尤其注意状态转移的时候要每一个状态都考虑到
\(E-Max\) \(to\) \(the\) \(Right\) \(of\) \(Min\)
有意思,学到了。
题意:给定一个 \(1\sim n\) 的排列 \(a\),求出满足最大值下标大于最小值下标的子区间个数。
解决此类关于最大最小值的区间统计问题,一般套路是定最值。其目的是简化计算。
解决区间统计的问题的一般套路是定一端。其目的是减少重复计算。
下面我们就这两种思想解决这个问题。
解法1-最大值分治
我们考虑确定最大值去解决这个问题(最小值是同理的)
首先考虑当最大值为 \(n\) 的情况。我们找到 \(n\) 的下标 \(x\),则需统计 \(l\in[1,x-1],r\in[x,n]\) 的情况。
朴素的想法是:令 \(f_i=\min_{i\le j<x}(a_j),f_i=\min_{x\le j\le n}(a_j)\)。这时候双指针扫描即可。
然后我们惊奇地发现,\([1,x-1]\) 与 \([x+1,r]\) 两个区间的统计问题是一个一模一样的子问题,这启发我们递归解决问题。
不难写出以下递归函数:
void solve(int l,int r){
if(l>=r)return ;
int p=-1;
for(int i=l;i<=r;i++){
if(p==-1||a[p]<a[i])p=i;
}
solve(l,p-1);solve(p+1,r);
f[p]=a[p];
for(int i=p-1;i>=l;--i)f[i]=min(f[i+1],a[i]);
for(int i=p+1;i<=r;++i)f[i]=min(f[i-1],a[i]);
int j=p;
for(int i=p-1;i>=l;--i){
while(j<=r&&f[j]>f[i])++j;
ans+=j-p;
}
}
是的,这个算法在随机数据下表现良好,有着接近 \(O(n\log n)\) 的复杂度。
但,这个算法很容易被卡。因为它会扫遍整个区间,这会使得复杂度爆表(划分不均匀)我们必须要规避这种风险。
How?启发式合并(其实应该叫启发式分裂?)!这样,注意到我们算法里,\(f\) 的处理,\(p\) 的寻找,以及双指针的扫描,都有着扫完整个数组的风险(即使用了启发式,仍然可以构造数据使得双指针扫完整个区间),双指针就是个坑比。。。
那么我们考虑用单次时间换整体时间。显然,\(p\) 的寻找可以预处理,手段很多,例如线段树,ST表等等。然后对于 \(f\) 可以边扫边算。但想想,双指针的复杂度始终跑不掉。怎么办?我们只能选择另一种统计的方式,使得只用扫一半区间即可统计答案。这也是启发式分裂的思想,选择较短的一半区间,另一半区间用一些手段确定答案范围,必须避免扫完整个区间(因为有时候右半区间会比左半区间大千万倍,别闹,这是OI,啥扯淡都会发生。
顺带着,我们考虑选择左右较短的那个区间进行扫描处理:若选择了右边区间,可以用容斥的方法解决问题。
那么,考虑枚举较短的区间,维护扫过的那段的最小值,这时候怎么确定答案范围呢?很简单,用ST表维护区间最值,然后二分这个位置即可。不要用其他数据结构!ST表查询\(O(1)\),用其他会TLE。但wssb,我用线段树没想到ST。。。。
另外,注意到递归层数1e6,为了防爆,推荐各位壮士换成队列的广搜吧。
#include<iostream>
#include<cstdio>
#include<queue>
#define ll long long
using namespace std;
#define N 1050500
#define pr pair<int,int>
#define mk make_pair
#define l first
#define r second
queue<pr >q;
int a[N],n,f[N],mx[N][22],mn[N][22];
int lg[N];
ll ans;
void init_st(){
// cout<<"init_st:\n";
for(int i=1;i<=n;i++)mn[i][0]=a[i],mx[i][0]=i;
for(int i=2;i<=n;i++)lg[i]=lg[i/2]+1;
for(int i=1;i<=20;i++){
for(int j=1;j+(1<<i)-1<=n;j++){
mn[j][i]=min(mn[j][i-1],mn[j+(1<<(i-1))][i-1]);
mx[j][i]=a[mx[j][i-1]]>a[mx[j+(1<<(i-1))][i-1]]?mx[j][i-1]:mx[j+(1<<(i-1))][i-1];
}
}
}
int find_n(int l,int r){
int k=lg[r-l+1];
return min(mn[l][k],mn[r-(1<<k)+1][k]);
}
int find_x(int l,int r){
int k=lg[r-l+1];
return a[mx[l][k]]>a[mx[r-(1<<k)+1][k]]?mx[l][k]:mx[r-(1<<k)+1][k];
}
#define int long long
void solve(int l,int r){
if(l>=r)return ;
int p=find_x(l,r);
q.push(mk(l,p-1));q.push(mk(p+1,r));
// cout<<l<<" "<<r<<" "<<p<<"\n";
int s=ans;
if(p-l<=r-p){
int mn=a[p];
for(int i=p-1;i>=l;--i){
mn=min(mn,1ll*a[i]);
int L=p,R=r+1;
while(L<R){
int mid=L+R>>1;
if(find_n(p,mid)<=mn)R=mid;
else L=mid+1;
}
if(r==n&&find_n(p,r)>mn)L=n+1;
ans+=L-p;
}
}
else {
ans+=(r-p+1)*(p-l);int mn=a[p];
for(int i=p+1;i<=r;++i){
mn=min(mn,1ll*a[i]);
int L=l-1,R=p;
while(L<R){
int mid=L+R+1>>1;
if(find_n(mid,p)<mn)L=mid;
else R=mid-1;
}
if(l==1&&find_n(1,p)>mn)L=0;
ans-=p-L-1ll;
}
}
}
#undef int
void work(){
q.push(mk(1,n));
while(q.size()){
solve(q.front().l,q.front().r);
q.pop();
}
}
void read(int &x){
x=0;char ch=getchar();
while(ch>'9'||ch<'0')ch=getchar();
while(ch>='0'&&ch<='9')x=(x<<3)+(x<<1)+(ch^48),ch=getchar();
}
signed main(){
// freopen("data.in","r",stdin);
ios::sync_with_stdio(false);
read(n);
for(int i=1;i<=n;i++)read(a[i]);a[n+1]=0x3f3f3f3f;
init_st();
work();
cout<<ans<<"\n";
}
用启发式合并来分治,复杂度 \(O(n\log n)\) ,套个二分,总时间复杂度为:\(O(n\log^2 n)\)。
解法2-单调栈+线段树
我们考虑确定右端点(左端点同理),统计当区间右端点为 \(r\) 时答案的变化。
容易发现,对于 \(r\) 而言,答案只有两种情况会变化:
- \(a_r\) 为整个序列的最大值
- \(a_r\) 为整个序列的最小值
其余情况的答案在 \(r-1\) 已经统计过了。
而怎么维护这个答案呢?妙点就出来了。考虑维护合法的区间左端点集合以及更换右端点的影响范围。设 \(b_i\) 为在 \(i\) 的前面第一个大于它的数的下标,\(c_i\) 为在 \(i\) 的前面第一个小它的数的下标,显然可以单调栈求出二者:
a[0]=0x3f3f3f3f;s.push(0);
for(int i=1;i<=n;i++){
if(a[s.top()]>a[i]){
b[i]=s.top();s.push(i);continue;
}
while(a[s.top()]<a[i])s.pop();
b[i]=s.top();s.push(i);
}
while(!s.empty())s.pop();
a[0]=0;s.push(0);
for(int i=1;i<=n;i++){
if(a[s.top()]<a[i]){
c[i]=s.top();s.push(i);continue;
}
while(a[s.top()]>a[i])s.pop();
c[i]=s.top();s.push(i);
}
那么影响范围呢?当 \(a_r\) 成为最大值时,\([b_i+1,r-1]\) 都是正确的区间,而 \(a_r\) 为最小值时,\([c_i+1,r-1]\) 都不再合法。
这使得我们考虑维护答案序列 \(d\),\(d_i=1\) 表示区间 \([i,r]\) 是合法的,\(d_i=0\) 则表示区间 \([i,r]\) 是不合法的,则问题变为:
- \(a_r>a_{r-1}\),将 \([b_i+1,r-1]\) 全部改为 \(1\)
- \(a_r<a_{r-1}\),将 \([c_i+1,r-1]\) 全部改为 \(0\)
最后我们统计全局和就可以了。
这一步可以用线段树维护。时间复杂度 \(O(n\log n)\)。
\(F-XOR\) \(Partition\)
解法1
暴力的想法是二分答案,然后将符合条件的 \((i,j)\) 进行连边,最后判断是否是二分图。
考虑优化这个做法。因为它的边数是 \(O(n)\),完全无法承受。
这里有异或的一个性质:\(\forall k>i>j,i\oplus j\le i\oplus k\)。证明?考虑拿出二进制(自高到底),去掉LCP,三个数剩下的最高位就只有两个可能:\((0,0,1),(0,1,1)\)。对于 \((0,0,1)\) 的情况,高下立见。而对于 \((0,1,1)\) 的情况,把这一位删掉,不断往下直到找到 \((0,0,1)\) 的情况就可以了,毕竟有 \(j<k\)。
故,先排序,然后可以发现,若存在边 \((i,j),i<j\),则存在边 \((i,i+1),(i,i+2),…,(i,j-1)\)。这时候我们考虑有没有什么限制条件限制它的边数。
我们只需判断是否存在奇环即可。怎么搞?若存在边 \((i,i+1),(i+1,i+2),(i,i+2)\) 就废了呗。而且注意到随着二分答案的变大,这个三元环会变成五元环,七元环……所以,我们要找到一个强有力的限制条件,利用这个性质,不难发现:
若 \((i,i+4)\) 有连边,则必定构不成二分图。Why?一样,我们去掉LCP,则最高位的可能性只有:
四种情况。而这四种情况呢,很容易发现,无论哪一种,都必定存在至少三个数,互相异或后此位为0,而此位为 \(0,1\) 的 \(i,i+4\) 都有边,则它们必定互相有边,构成一个三元环,废掉!
所以,我们只需要判一下是否 \((i,i+4)\) 可以连边,如果可以直接返回无解,否则只需建立最多 \(3n\) 条边,完全没有压力好吧。
复杂度 \(O(n\log n)\)。
解法2
一个很简朴的思路是:不断找剩下的最小异或对,将其分到两个集合。
划分两个集合的常见套路是二分图。
那么我们换一种角度,将这些最小异或对看作一条边,连成图,直到它不是二分图为止。
则当这张图连通时,二分图的划分就已经确定了,所以只要 \(n\) 个点连成一棵树,就可以停止找最小异或对并黑白染色输出方案了。
再转化一下,就是在一张完全图,边 \((i,j)\) 的边权为 \(a_i\oplus a_j\) 的情况下,找出这样一颗生成树,使得非树边 \((u,v)\) 的边权大于树上 \((u,v)\) 路径上的任意一条边的边权。
容易看出,这就是最小异或生成树问题。
故:我们求出最小异或生成树,然后黑白染色输出方案即可。
#include<bits/stdc++.h>
using namespace std;
#define N 500500
#define int long long
int num,p,ch[N<<4][2],l[N<<4],r[N<<4],n,b[N];
int head[N],ver[N],nxt[N],c[N],tot,id;
struct node{
int x,id;
bool operator<(const node b)const {
return x<b.x;
}
}a[N];
void ad(int u,int v){
nxt[++tot]=head[u],ver[head[u]=tot]=v;
}
void add(int u,int v){
ad(u,v);ad(v,u);
}
void insert(int x,int p,int id,int dep){
if(dep==-1)return ;
int s=(x>>dep)&1;
if(ch[p][s])r[ch[p][s]]=id;
else ch[p][s]=++num,l[num]=r[num]=id;
insert(x,ch[p][s],id,dep-1);
}
int query(int x,int p,int dep){
if(dep==-1){
id=l[p];return 0;
}
int s=(x>>dep)&1;
if(ch[p][s])return query(x,ch[p][s],dep-1);
return query(x,ch[p][s^1],dep-1)+(1<<dep);
}
void solve(int p,int dep){
if(!p||dep==-1)return ;
int x=ch[p][0],y=ch[p][1];
if(x&&y){
if(r[x]-l[x]+1>r[y]-l[y]+1)swap(x,y);
int ans=0x3f3f3f3f,u=-1,v=-1;
for(int i=l[x];i<=r[x];i++){
int k=query(a[i].x,y,dep-1);
if(ans>k)ans=k,u=i,v=id;
}
add(a[u].id,a[v].id);
}
solve(x,dep-1);solve(y,dep-1);
}
void dfs(int u,int now){
c[u]=now;
for(int i=head[u];i;i=nxt[i]){
int v=ver[i];if(c[v]!=-1)continue;
dfs(v,now^1);
}
}
signed main(){
ios::sync_with_stdio(false);
cin>>n;num=1;for(int i=1;i<=n;i++)cin>>a[i].x,b[i]=a[i].x,a[i].id=i;
sort(a+1,a+n+1);
for(int i=1;i<=n;i++)insert(a[i].x,1,i,30),c[i]=-1;
solve(1,30);dfs(1,0);
for(int i=1;i<=n;i++)cout<<c[i];
}
至于最小异或生成树的求法,请看下文。
时间复杂度: \(O(n\log n\log V)\)
完全图最小异或生成树
挺有意思的。
首先证明:一颗Trie树插入 \(n\) 个不同的值,最后有且仅有 \(n-1\) 个节点有两个儿子。
理由:除插入第一个数外,其余每插入一颗树,Trie都会进行一次分叉,使得一个只有一个儿子的节点变为两个儿子,而新分出来的也只是一条链。
根据Kruskal的思想,我们找连接两个集合的权值最小的边进行合并两个集合,则我们在Trie上找这 \(n-1\) 个有两个儿子的节点,然后找边将这两个儿子所代表的集合合并在一起。
由于Trie的性质,这些数在排完序后是连续的,故这两个集合也是连续的一段。
我们考虑枚举其中较短的一半(启发式分裂),并在另一颗子树里找与枚举的数异或值最小的数。最终找出来的一对,将其连上,便合并了两个集合,递归处理即可。
详细介绍+模板题见CF888G
#include<bits/stdc++.h>
using namespace std;
#define N 250500
#define int long long
int a[N],num,p,ch[N<<5][2],l[N<<5],r[N<<5],n;
void insert(int x,int p,int id,int dep){
if(dep==-1)return ;
int s=(x>>dep)&1;
if(ch[p][s])r[ch[p][s]]=id;
else ch[p][s]=++num,l[num]=r[num]=id;
insert(x,ch[p][s],id,dep-1);
}
int query(int x,int p,int dep){
if(dep==-1)return 0;
int s=(x>>dep)&1;
if(ch[p][s])return query(x,ch[p][s],dep-1);
return query(x,ch[p][s^1],dep-1)+(1<<dep);
}
int solve(int p,int dep){
if(!p||dep==-1)return 0;
int x=ch[p][0],y=ch[p][1];
if(x&&y){
if(r[x]-l[x]+1>r[y]-l[y]+1)swap(x,y);
int ans=0x3f3f3f3f;
for(int i=l[x];i<=r[x];i++){
ans=min(ans,query(a[i],y,dep-1));
}
return solve(x,dep-1)+solve(y,dep-1)+ans+(1<<dep);
}
if(x)return solve(x,dep-1);
return solve(y,dep-1);
}
signed main(){
ios::sync_with_stdio(false);
cin>>n;num=1;for(int i=1;i<=n;i++)cin>>a[i];
sort(a+1,a+n+1);
for(int i=1;i<=n;i++)insert(a[i],1,i,30);
cout<<solve(1,30);
}
\(Trick\) 总结
- 区间统计问题的两个套路
- 单调栈维护前面第一个大于/小于 \(a_i\) 的位置
- 二分图解决不在同一集合,分为两个集合问题
- 黑白染色后集合划分在图连通时就确定了
- 完全图最小异或生成树
- Tire树的有序性
- Trie树证明异或性质,最小异或对的必要条件是两个数在Trie的LCA是最大的——>用DFS序的思想可以得到相邻数的异或差最小
- 一颗插入 \(n\) 个节点(相异)的Trie,有且仅有 \(n-1\) 个节点有两个儿子。