虚拟化构建二分图(BZOJ2080 题解+浅谈几道双栈排序思想的题)

时间:2023-03-09 01:14:12
虚拟化构建二分图(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大爷不要打我~

分析:其实,这道题看起来已经是双栈排序的加强版了,但是由于入栈出栈的具体时间都已经给出,所以我觉得这其实还是双栈排序的简化版。

还记得上一题用到的带权并查集吗?没错,我们依然可以用并查集来解决这道题。现在我们思考,如何将此题转化成一个二分图模型。

如果我们将每个物品都看成一条线段,线段的端点分别是入栈时间和出栈时间,那么当出现下面的情况时,两个物品不能放在同一个栈中。虚拟化构建二分图(BZOJ2080 题解+浅谈几道双栈排序思想的题)

即,对于线段(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出去(看下面的图片)

虚拟化构建二分图(BZOJ2080 题解+浅谈几道双栈排序思想的题)

有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