虚拟化构建二分图(BZOJ2080 题解+浅谈几道双栈排序思想的题)
虚拟化构建二分图
------BZOJ2080 题解+浅谈几道双栈排序思想的题
本题的题解在最下面↓↓↓
不得不说,第一次接触类似于双栈排序的这种题,是在BZOJ的五月月赛上。
【BZOJ4881】[Lydsy2017年5月月赛]线段游戏
简洁的题面:给你一个1到n(n<=100000)的排列,问你能否将这个排列分成两个升序的子序列,如果能,求方案数。(本人强行将问题拆成两个子任务,原因你一会就会知道~)
P.S.:比赛时除太空猫外唯一想出来的题,其实我已经写过一篇题解了,不过当时比赛还没结束,发的比较仓促,只说了怎么做的而没说为什么要这么做。这里填个小坑。
分析:先考虑第一问,如果你从正面来想这道题,那么你有很多种方法从原序列中找出一个升序的子序列,但是你无法保证剩下的那个子序列也是升序的。所以我们不妨换个角度,考虑什么样的子序列是不能选的。
容易发现,一个子序列是升序的=这个子序列中不存在逆序对。那么一旦原序列中存在逆序对(a,b),我们就需要将他们分到不同的序列中去。如果你善于抽象模型的话,你会发现这其实是一个二分图的染色问题。(重点!)也就是说,你如果将原序列看成一张图,那么对于所有逆序对(a,b),那么我们在a,b间连一条边,如果我们将这个二分图染色,那么如果其中一个点是黑色另一个点一定是白色。也就对应了:我们必须将它们分到不同的序列中去。
那么我们该如何构建这个二分图呢?(神犇GXZ告诉我们:可以用并查集。(其实不能,不过思想很好。))我们采用带权并查集的思想,如果某两个点之间的关系是确定的,我们就将它们放到同一个并查集中去。我们从左到右扫一遍原序列,每扫到一个点,就把之前所有比它大的数都拿出来,并合并二者所在的连通块(当然,你需要记录并查集中的每个点与父亲的关系)。在合并的时候,如果(a,b)已经在同一个并查集中,我们可以顺便判断一下二者的关系,如果出现矛盾,则输出不能。那么第二问的结果呢?答案就是(2^连通块的数量)。(这个需要自己yy一下了。)
不幸的是,单纯的并查集还是无法解决此题,我们应该想一种办法来优化并查集的实现过程。考虑,假设之前有一个连通块A,我们当前枚举到的数是b,如果b需要与A合并,只需要满足A中存在一个数a>b,也就是说A中最大的数amax>b。换句话说,我们将A中其它的数都忽视掉,只考虑A中最大的数amax,就让amax来代表整个并查集。于是我们想到用set来模拟这个过程,将set中所有比b大的数都取出来,再将这些数中最大的数扔回到set里,答案就是(2^set的大小)。但是第一问该怎么办呢?用set显然是处理不了的。发现如果原序列中存在矛盾,就是存在a-b,b-c,c-a(或者a-b-c-d-e-a。。。)之间都存在冲突,如果他们出现的顺序是a,b,c,那么有a>b>c。也就是说我们只需要求出原序列中最长下降子序列的长度,如果长度>=3,则输出无解。这个可以用树状数组搞定。
本人代码:
#include <cstdio> #include <cstring> #include <iostream> #include <set> using namespace std; int n,ans,v[100010],tr[100010]; set<int> s; void updata(int x,int val) { for(int i=x;i<=n;i+=i&-i) tr[i]=max(val,tr[i]); } int query(int x) { int i=x,ret=0; for(i=x;i;i-=i&-i) ret=max(ret,tr[i]); return ret; } int main() { scanf("%d",&n); int i,a,b; set<int>::iterator it; for(i=1;i<=n;i++) scanf("%d",&v[i]); for(i=n;i>=1;i--) { a=query(v[i]-1)+1; if(a>=3) { printf("0"); return 0; } updata(v[i],a); } for(i=1;i<=n;i++) { b=v[i]; while(!s.empty()) { it=s.upper_bound(b); if(it==s.end()) break; b=*it,s.erase(b); } s.insert(b); } b=s.size(),ans=1; while(b--) ans=(ans*2)%998244353; printf("%d",ans); return 0; }
说了这么多,重点还是在于二分图的抽象那块,以及并查集的应用与优化。但是如果上面那题你已经觉得太简单了,那么看下一道吧。
【由于版权原因,题目名称已屏蔽】
简洁的题面:你有两个栈和n(n<=100000)个物品,每个物品都有一个给定的进栈时间和出栈时间,这2n个时间组成了一个1到2n的全排列,要求在一个物品进栈时,它可以被放在任意一个栈的顶端,出栈时,它也必须在某个栈的顶端。问是否存在满足要求的进出栈方案,如果有,输出方案数。
P.S.:PoPoQQQ大爷不要打我~
分析:其实,这道题看起来已经是双栈排序的加强版了,但是由于入栈出栈的具体时间都已经给出,所以我觉得这其实还是双栈排序的简化版。
还记得上一题用到的带权并查集吗?没错,我们依然可以用并查集来解决这道题。现在我们思考,如何将此题转化成一个二分图模型。
如果我们将每个物品都看成一条线段,线段的端点分别是入栈时间和出栈时间,那么当出现下面的情况时,两个物品不能放在同一个栈中。
即,对于线段(x1,y1)和(x2,y2),如果x1<x2<y1<y2或x2<x1<y2<y1,那么他们不能在同一个栈中。我们在所有冲突的点对之间连边,这样就又构成了一个二分图,如果这个二分图中存在奇环,则说明无法将这个二分图染色。我们依旧试着采用并查集,但是这个二分图的边数是n2级别的,我们该如何连边呢?
我们只考虑x1<x2<y1<y2的情况,我们先将所有线段按y从大到小排序,然后我们枚举x1,y1,然后对于之前所有的x2∈(x1,y1),我们都要在二者之间连边(或者说将二者所在并查集合并)。这时,姜神犇说:可以采用线段树来优化并查集。没错,我们只需要让左端点在(x1,y1)中所有线段都合并到当前的并查集中,再把x1放入线段树就行了。具体实现略有些复杂,不过姜神毕竟是神,吾等蒟蒻也只有%姜神的代码的份了~
姜神的代码:
#include<iostream> #include<cstring> #include<ctime> #include<algorithm> #include<iomanip> #include<cstdlib> #include<cstdio> #include<cmath> #include<vector> #include<queue> #include<stack> #include<bitset> #include<set> #include<map> using namespace std; #define MAXN 100010 #define MAXM 1010 #define eps 1e-8 #define ll long long #define MOD 1000000007 #define INF 1000000000 struct line{ int x; int y; friend bool operator <(const line &x,const line &y){ return x.y<y.y; } }; int n; line a[MAXN]; int sum[MAXN<<3]; int ch[MAXN<<3]; int bel[MAXN],f[MAXN],fv[MAXN]; int fa(int x){ if(f[x]==x){ return x; } fa(f[x]); fv[x]*=fv[f[x]]; f[x]=f[f[x]]; return f[x]; } void add(int x,int y,int z,int p,int av){ sum[x]+=av; if(y==z){ return; } int mid=y+z>>1; if(p<=mid){ add(x<<1,y,mid,p,av); }else{ add(x<<1|1,mid+1,z,p,av); } } inline void toch(int x,int y){ if(!sum[x]){ return ; } if(!ch[x]){ ch[x]=y; }else{ int t=ch[x]; int tt=fa(abs(t)),ty=fa(abs(y)); int t1=fv[abs(t)],t2=fv[abs(y)]; if(t<0){ t1*=-1; } if(y<0){ t2*=-1; } if(tt!=ty){ f[tt]=ty; if(t1!=t2){ fv[tt]=-1; }else{ fv[tt]=1; } }else{ if(t1!=t2){ printf("0\n"); exit(0); } } } } inline void pd(int x){ if(ch[x]){ toch(x<<1,ch[x]); toch(x<<1|1,ch[x]); ch[x]=0; } } void change(int x,int y,int z,int l,int r,int cv){ if(!sum[x]){ return ; } if(y==l&&z==r){ toch(x,cv); return ; } pd(x); int mid=y+z>>1; if(r<=mid){ change(x<<1,y,mid,l,r,cv); }else if(l>mid){ change(x<<1|1,mid+1,z,l,r,cv); }else{ change(x<<1,y,mid,l,mid,cv); change(x<<1|1,mid+1,z,mid+1,r,cv); } } int ask(int x,int y,int z,int p){ if(y==z){ if(!ch[x]){ return 0; } int t=fa(abs(ch[x])); if(ch[x]>0){ return t*fv[abs(ch[x])]; }else{ return t*fv[abs(ch[x])]*-1; } } pd(x); int mid=y+z>>1; if(p<=mid){ return ask(x<<1,y,mid,p); }else{ return ask(x<<1|1,mid+1,z,p); } } int main(){ int i; scanf("%d",&n); for(i=1;i<=n;i++){ scanf("%d%d",&a[i].x,&a[i].y); } sort(a+1,a+n+1); for(i=1;i<=n;i++){ add(1,1,n<<1,a[i].x,1); } for(i=1;i<=n;i++){ int t=ask(1,1,n<<1,a[i].x); add(1,1,n<<1,a[i].x,-1); if(!t){ f[i]=i; fv[i]=1; }else{ f[i]=abs(t); fv[i]=abs(t)/t; } change(1,1,n<<1,a[i].x,a[i].y,i*-1); } int ans=1; for(i=1;i<=n;i++){ if(f[i]==i){ (ans*=2)%=MOD; } } printf("%d\n",ans); return 0; }
本人的代码:
#include <cstdio> #include <cstring> #include <iostream> #include <utility> #include <algorithm> #define mp(A,B) make_pair(A,B) #define lson x<<1 #define rson x<<1|1 #define F first #define S second using namespace std; typedef pair<int,int> pii; const int maxn=200010; int n,ans; int fa[maxn],d[maxn],vis[maxn],sum[maxn<<2]; pii s[maxn<<2],p[maxn]; //有必要说明一下s的意义,只有叶子节点的s记录的是区间中那个位置的线段与父亲的关系,其余的都是lazy标记 bool cmp(pii a,pii b) { return a.S>b.S; } int find(int x) { if(fa[x]!=x) { int tmp=fa[x]; fa[x]=find(fa[x]); d[x]^=d[tmp]; } return fa[x]; } void link(int x,pii y) //修改函数,也是用线段树维护并查集的核心函数 { if(!sum[x]) return ; if(!s[x].F) //如果没有标记,打一个 { s[x].F=y.F,s[x].S=y.S; return ; } int tmp; tmp=s[x].F,s[x].F=find(s[x].F),s[x].S^=d[tmp]; //注意:只有在pushdown或访问到目标节点时才进行路径压缩! tmp=y.F,y.F=find(y.F),y.S^=d[tmp]; if(s[x].F==y.F&&s[x].S==y.S) return ; if(s[x].F==y.F&&s[x].S!=y.S) //如果产生冲突,exit { printf("0\n"); exit(0); } if(s[x].F!=y.F) //否则合并并查集 { d[s[x].F]=s[x].S^y.S,fa[s[x].F]=y.F; s[x].F=y.F,s[x].S=y.S; } } void pushdown(int x) { if(s[x].F) { link(lson,s[x]),link(rson,s[x]); s[x]=mp(0,0); } } void add(int l,int r,int x,int a,pii y) { sum[x]++; //sum用来判断当前位置是否有值,如果没有的话显然就不用打标记了 if(l==r) { link(x,y); return ; } pushdown(x); int mid=l+r>>1; if(a<=mid) add(l,mid,lson,a,y); else add(mid+1,r,rson,a,y); } void updata(int l,int r,int x,int a,int b,pii y) { if(!sum[x]) return ; if(a<=l&&r<=b) { link(x,y); return ; } pushdown(x); int mid=l+r>>1; if(a<=mid) updata(l,mid,lson,a,b,y); if(b>mid) updata(mid+1,r,rson,a,b,y); } void dfs(int l,int r,int x) //最后要整体pushdown一次 { if(l==r) return ; pushdown(x); int mid=l+r>>1; dfs(l,mid,lson),dfs(mid+1,r,rson); } int main() { scanf("%d",&n); int i,t; pii tmp; for(i=1;i<=n;i++) scanf("%d%d",&p[i].F,&p[i].S); sort(p+1,p+n+1,cmp); for(i=1;i<=n;i++) fa[i]=i; for(i=1;i<=n;i++) { updata(1,n<<1,1,p[i].F,p[i].S,mp(i,1)); add(1,n<<1,1,p[i].F,mp(i,0)); } dfs(1,n<<1,1); for(ans=i=1;i<=n;i++) if(find(i)==i) ans=(ans<<1)%1000000007; printf("%d",ans); return 0; }
到这里,我们构建二分图的思路就又多了一个:用线段树维护带权并查集。虽说实现过程十分困难,但是二者的核心思想是差不多的:用各种优化,将二分图构建出来。不过,看了大爷的题解才发现还有另一种做法:
“对于每个区间,线段树上将Yj的位置设为Xj,查询时广搜,在线段树上每次找[Xi,Yi]区间的最小值,若这个最小值小于Xi则将其在线段树上删除并加入队列。
“将区间按照X排序,依次在线段树上将Y位置涂上自己的颜色,查询时若[Xi,Yi]中有与自己同色的位置则不是二分图。”
如果你能看懂的话,你会发现其实上面的做法已经很接近真正的双栈排序的做法了;如果看不懂的话,请再看下一道题:
【BZOJ2080】[Poi2010]Railway
简洁的原题面:一个铁路包含两个侧线1和2,左边边由A进入,右边由B出去(看下面的图片)
有n个车厢在通道A上,编号为1到n,它们被安排按照要求的顺序(a1,a2,a3,a4....an)进入侧线,进去还要出来,它们要按照编号顺序(1,2,3,4,5。。。。n)从通道B出去。他们从A到1或2,然后经过一系列转移从B出去,不用考虑容量问题。如果可以按照编号顺序到通道B,则输出两行,第一行为TAK,第二行为n个由空格隔开的整数,表示每个车厢进入的侧线编号(1,2)。要求输出字典序最小的方案。否则输出NIE。
P.S.:一开始我以为这道题用线段树优化并查集搞搞也能过,结果naive了~
分析:这道题看似限制条件变少了,但是用并查集却不那么容易了。因为并查集的核心依旧是:利用各种优化,将二分图建出来。但是我们不妨思考一下,这个二分图真的有建出来的必要吗?
答案是:没有必要。因为如果我们能够快速判断出两个点在二分图中是否连有边,那么我们就没必要 真的 在图中连上这一条边。(难以理解?)因为你可以想象,我们判断二分图是否能染色的方法是找奇环,但是我们并不需要n2那么多的边才能找出这样的奇环,我们只需要找出一个判定条件,使得满足这个条件的点对之间都连有边就行了。下面我们来寻找这个判定条件。
思考:当i,j满足什么条件时,i,j不能在同一个栈中?
1.假设i<j,若Ai<Aj,那么i必须先进先出,j后进后出,但是如果存在一个k,满足i<j<k且Ak<Ai<Aj,那么显然会产生冲突,所以i,j不能在同一个栈中;反之,若不存在这样的k,那么易证i,j一定是可以在同一个栈中的。
2.若i<j且Ai>Aj,那么我们完全可以让i先进后出,j后进先出。但这种情况下是否我们也能通过找出一些k,使得i,j不能在同一个栈中呢?如果仔细想想的话,发现这样的k一定满足了假设1中的条件。这里不必重复讨论。
所以,i,j不再同一个栈中的充要条件就是:存在i<j<k且Ak<Ai<Aj,那么我们能否找出一种快速的方法,使我们能够快速知道:对于每个i,有哪些j与它连有边呢?
这时,我们就要换一个角度了,我们对于每个i,可以贪心的找出最右面的k,使得Ak<Ai。我们设这样的k为Ci。那么对于(i,Ci)中的所有满足Aj>Ai的j,它们与i肯定都连有一条边。我们不妨将这样的边称之为后向边。
但是从i连出去的边不只有后向边,肯定还存在一些j,并且j有一条后向边连向了i。即j<i且存在k使得j<i<k且Ak<Aj<Ai。我们依旧采用贪心的思路,我们找出ak最小的k,满足i<k,设这样的k为Bi。那么,对于所有的Aj∈(Bi,Ai)且满足j<i的j,它们与i肯定也都连有一条边。我们称这样的边为前向边。
(前向边,后向边的定义十分重要,没看懂的请先动动笔列一下式子理解清楚!)
说到这里,我们自然而然的就得出了本题的标准算法(或许你看上面大爷的题解就已经看出来标算了)。标算的核心就是用线段树快速找出前向边和后向边,然后利用BFS不断给所有节点进行染色。
1.若Ai=j,设Pj=i。先O(n)扫两遍,求出每个i对应的Bi,Ci,然后构建两棵线段树,一棵为位置(P)的线段树,维护权值(A)的最大值;一棵为权值(A)的线段树,维护位置(P)的最小值。
2.我们从1到n枚举所有的点,若当前点i没有被访问过,从i开始进行BFS,每次找出当前队首元素的所有的前向边和后向边,将这些边指向的点全都删除(在两棵线段树上都要删除),并将其染色,再压入队列。
找前向边的方法:在权值线段树上不断找出[Bi,Ai]中编号最小的点j,并且满足j<i
找后向边的方法:在位置线段树上不断找出[i,Ci]中权值最大的点j,并且满足Aj>Ai
3.当所有点都染完色后,手动模拟一下双栈排序的过程,判断此方案是否可行。
代码:
#include <cstdio> #include <cstring> #include <iostream> #include <queue> #define lson x<<1 #define rson x<<1|1 using namespace std; const int maxn=100010; const int inf=0x3f3f3f3f; struct sag { int s[maxn<<2],flag; int btr(int a,int b) { if(flag) return max(a,b); else return min(a,b); } void updata(int l,int r,int x,int a,int b) { if(l==r) { s[x]=b; return ; } int mid=l+r>>1; if(a<=mid) updata(l,mid,lson,a,b); else updata(mid+1,r,rson,a,b); s[x]=btr(s[lson],s[rson]); } int query(int l,int r,int x,int a,int b) { if(a>b) return (flag^1)*inf; if(a<=l&&r<=b) return s[x]; int mid=l+r>>1; if(b<=mid) return query(l,mid,lson,a,b); if(a>mid) return query(mid+1,r,rson,a,b); return btr(query(l,mid,lson,a,b),query(mid+1,r,rson,a,b)); } }s1,s2; int n; int A[maxn],B[maxn],C[maxn],pos[maxn],st[maxn][2],top[2],vis[maxn]; queue<int> q; void bfs(int x) { q.push(x),vis[x]=0,s1.updata(1,n,1,x,0),s2.updata(1,n,1,A[x],inf); int t; while(!q.empty()) { x=q.front(),q.pop(); while(1) { t=s1.query(1,n,1,x,C[A[x]]-1); if(t>A[x]) { vis[pos[t]]=vis[x]^1,q.push(pos[t]); s1.updata(1,n,1,pos[t],0),s2.updata(1,n,1,t,inf); } else break; } while(1) { t=s2.query(1,n,1,B[x]+1,A[x]); if(t<x) { vis[t]=vis[x]^1,q.push(t); s1.updata(1,n,1,t,0),s2.updata(1,n,1,A[t],inf); } else break; } } } int main() { scanf("%d",&n); s1.flag=1,s2.flag=0; int i,j,t; for(i=1;i<=n;i++) { scanf("%d",&A[i]),pos[A[i]]=i; s1.updata(1,n,1,i,A[i]),s2.updata(1,n,1,A[i],i); } for(B[n]=n,i=n-1;i>=1;i--) B[i]=min(A[i+1],B[i+1]); for(C[1]=1,i=2;i<=n;i++) C[i]=max(pos[i-1],C[i-1]); memset(vis,-1,sizeof(vis)); for(i=1;i<=n;i++) if(vis[i]==-1) bfs(i); for(i=j=1;i<=n;i++) { t=vis[i],st[++top[t]][t]=A[i]; for(;st[top[0]][0]==j||st[top[1]][1]==j&&j<=n;j++) { if(st[top[0]][0]==j) top[0]--; else top[1]--; } } if(j<=n) { printf("NIE"); return 0; } printf("TAK\n"); for(i=1;i<n;i++) printf("%d ",vis[i]+1); printf("%d",vis[n]+1); return 0; }
总结:本文通过三道题,逐渐介绍了如何将一张现实的二分图通过各种优化(以至于到最后我们根本都没有建出这个图)来达到虚拟化的目的。或许略有繁琐,或许有些地方说的不到位,望见谅!
感谢曾经给过我灵感的人:神犇GXZ,姜神犇,Po大爷
By:CQzhangyu
| 欢迎来原网站坐坐! >原文链接<