虚拟化构建二分图(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

posted @ 2017-06-26 11:03  CQzhangyu  阅读(307)  评论(0编辑  收藏  举报