基础图论复习笔记

——Tarjan,二分图匹配,2-SAT,网络流

前言

复习笔记第8篇。RP++。考前仓促备战的整理,大纲是 lyd 老师的进阶指南。

二分图匹配

二分图判定

采用染色法,对两个相邻的节点采用不同的颜色,如果冲突说明不是二分图。

最大匹配

思想是找增广路。增广路是这样的:在一条连接两个非匹配点的路径上,非匹配边和匹配边交替出现。

这样的增广路具有显然的性质:长度为奇数,且非匹配边恰好多1。根据这些性质,如果把路径上所有边的匹配状态取反,那么正好能得到比原来多一条的匹配边集合。

进一步的推论:匹配 \(S\) 是最大匹配,当且仅当不存在 \(S\) 的增广路。

匈牙利算法

1、在最初设匹配 \(S\) 为空集

2、寻找增广路 \(path\) ,把所有边取反,得到更大的 \(S'\)

3、重复至没有增广路。

关键在于如何寻找增广路。依次尝试给每个左部点 \(x\) 找一个匹配的右部点 \(y\) ,能匹配当且仅当满足两个条件之一:

1、\(y\) 是非匹点

2、与 \(y\) 匹配的 \(x'\) 能找到一个 \(y'\) 与之匹配。

此时路径 \(x\to y\to x'\to y'\) 即为增广路。复杂度 \(O(NM)\)

实际实现中可以采用 dfs 的框架,这样判断第二个条件就直接递归即可。

码源:P3386 【模板】二分图最大匹配 (由于有重边所以用的邻接矩阵)

bool dfs( int x )
{
        for ( int i=1; i<=m; i++ )
                if ( mp[x][i] && !vis[i] )
                {
                        vis[i]=1;
                        if ( !match[i] || dfs( match[i] ) ) { match[i]=x; return 1;}
                }
        return 0;
}

例题 棋盘覆盖

link to AcWing link to Contest Hunter

题意: \(N\)\(N\) 列的棋盘,某些格子禁止放置。求最多能往棋盘上放多少块的长为2、宽为1的骨牌,骨牌的边界与格线重合(占两个格子),并且任意两张骨牌都不重叠。\(N\leq 100\)

思路:其实可以用状压,但是 \(N\) 挺小的,所以也可以来一个二分图的奇妙建模。

二分图匹配的模型要素:

  1. 节点能分成两个独立集合,每个集合内部没有边
  2. 每个节点只能和一条匹配边相连

考虑这道题如何建模。“每个节点和一条匹配边相连” 可以对应 “每个格子只能被一张骨牌覆盖”,那么把骨牌作为边,把格子作为点(可以放的格子),可选边的集合就是所有合法的相邻格子。

但是这时候还要分出左右部点。考虑一个经典问题:去掉象棋棋盘相对的两个角上的格子,能否用骨牌覆盖?这个问题的解决采用了染色法。结合二分图判定方法,本题中的格点同样可以按奇偶行列染色,那么就得到了左右部点。然后跑匈牙利即可。

bool dfs( int x )
{
        for ( int i=head[x]; i; i=e[i].nxt )
        {
                int v=e[i].to;
                if ( !vis[v] )
                {
                        vis[v]=1;
                        if ( !match[v] || dfs( match[v] ) ) { match[v]=x; match[x]=v; return 1; }
                }
        }
        return 0;
}

int main()
{
        scanf( "%d%d",&n,&m );
        for ( int i=1,x,y; i<=m; i++ )
                scanf( "%d%d",&x,&y ),a[x][y]=1;

        for ( int i=1; i<=n; i++ )
         for ( int j=1; j<=n; j++ )
        {
                if ( a[i][j] ) continue;
                int pos=(i-1)*n+j;
                if ( !a[i][j-1] && j>=2 ) add( pos,pos-1 );
                if ( !a[i][j+1] && j<n ) add( pos,pos+1 );
                if ( !a[i-1][j] && i>1 ) add( pos,pos-n );
                if ( !a[i+1][j] && i<n ) add( pos,pos+n );
        }
        for ( int i=1; i<=n*n; i++ )
                if ( !match[i] )
                {
                        if ( dfs(i) ) ans++;
                        memset( vis,0,sizeof(vis) );
                }

    printf( "%d",ans );
}

完备匹配

左右部节点均为 \(N\) 且最大匹配包含 \(N\) 条边。

多重匹配

左部 \(N\) 个点,右部 \(M\) 个点,第 \(i\) 个左部点之多连 \(kl_i\) 条边,第 \(j\) 个右部点至多 \(kr_j\) 条边。

一般有几种解决方案:

1、拆点

2、只有一侧是多重,直接让匈牙利算法在多重的一侧执行 \(kl_i\) 次 dfs。这种方法可能要交换左右两部分。

3、 网络流(

例题 导弹防御系塔

link to AcWing link to Contest Hunter

题意:有 \(M\) 个入侵者,\(N\) 座防御塔,发射时需要 \(T_1\) 秒射出,\(T_2\) 分钟冷却。计算打到时间只需要 \(dis/V\) 即可(所有 \(V\) 相等且匀速),单位为分钟。给出塔和入侵者坐标,问至少多少分钟才能击退所有入侵者。

思路:一个显然的多重匹配,每个入侵者只能匹配一个塔(的导弹),但是塔可以打多个人。首先考虑时间限制,可以二分。接下来考虑如何判定“mid 分钟内能否击退所有入侵者” 。通过 \(T_1,T_2,V\) 显然能确定 mid 分钟内每座塔的导弹数量。但是这些导弹并不等价,因为坐标不同,到达时间也就不同。所以此题必须要拆点,并对合法的(也就是可以在 mid 分钟内解决问题的导弹)左右部点连边。然后跑匈牙利即可。(注意这里要求一定要每个左部点都要匹配)

bool check( double mid )
{
   		memset( head,0,sizeof(head) ); memset( match,0,sizeof(match) );
    	tot=0;
    	int p=min( m,(int)(mid/(T1+T2))+1 );
    	for ( int i=1; i<=m; i++ )
     	 for ( int j=1; j<=n; j++ )
      	  for ( int k=1; k<=p; k++ )
        	if ( (k-1)*(T1+T2)+T1+dis( b[i],a[j] )/V<=mid ) add( i,(j-1)*p+k );
    	int cnt=0; 
    	for ( int i=1; i<=m; i++ )
    	{
        		memset( vis,0,sizeof(vis) ); 
        		if ( find( i ) ) cnt++;
    	}
    	return cnt==m;
}

带权匹配

在最大匹配的前提下最大化配边权值.

算法:费用流,KM(只能在完备匹配前提下求解,稠密图上效率较高)

最小点覆盖

最小的点集使得每一条边有至少一个端点属于这个点集.

定理:最小点覆盖包含的点数等于最大匹配包含的边数

最大独立集

任意两点之间没有边相连的点集中最大的一个;任意两点间有边相连的称为团.

最小路径点覆盖:尽量少的不相交简单路径覆盖有向无环图的所有顶点

定理

无向图的最大团等于补图的最大独立集.

\(n\) 点二分图的最大独立集大小等于 \(n\) 减去最大匹配数.

有向无环图的最小路径覆盖的路径条数等于 \(n\) 减去拆点二分图 \(G_2\) (就是拆成入点和出点)的最大匹配数.

Tarjan

定义 时间戳 dfn[x] 为dfs序编号。定义 dfs 所遍历的边和点构成 搜索树

定义 追溯值 low[x] 为以下两者时间戳的最小值:

1、subtree(x) 中的节点 2、通过一条不在搜索树上的边能到达 subtree(x) 的节点。

判定割边

\((x,y)\) 是割边当且仅当存在 \(x\) 的一个子节点 \(y\) 满足 \(dfn[x]<low[y]\) (表示不经过这条边的情况下都不能到达 \(x\) 及更早的点)

题目来源

void tarjan( int x,int pre )
{
	dfn[x]=low[x]=++cnt;
	for ( int i=head[x]; i; i=e[i].nxt )
	{
		int y=e[i].to;
		if ( i==pre ) continue;
		
		if ( !dfn[y] )
		{
			tarjan(y,i^1); 		
            //注意,考虑递归y的时候扫描的是y的出边,所以这里设成反向边会更方便。
             low[x]=min( low[x],low[y] );
			if ( low[y]>dfn[x] )  ans[++num]=e[i].id;
		}
		else low[x]=min( low[x],dfn[y] );
	}
} 
//-------------------------------------
		tarjan( 1,-1 );	
		//因为题目中保证联通,所以一次就够了 
		sort( ans+1,ans+1+num );

判定割点

\(x\) 是割点当且仅当存在一个子节点 \(y\) 满足 \(dfn[x]\leq low[y]\) (若为根节点那么要至少两个子节点)

题目来源

void tarjan( int x )
{
	dfn[x]=low[x]=++cnt; int fl=0;
	for ( int i=head[x]; i; i=e[i].nxt )
	{
		int y=e[i].to;
		if ( !dfn[y] )
		{
			tarjan( y ); low[x]=min( low[x],low[y] );
			if ( low[y]>=dfn[x] )
			{
				fl++; if ( x!=rt || fl>1 ) cut[x]=1;	
            //特别要注意的是,这里不能直接统计割点个数,因为一个x可能会被多个y算,所以要到最后用cut[i]统计
			}
		}
		else low[x]=min( low[x],dfn[y] );
	}				
}
//-------------------------
	memset( dfn,0,sizeof(dfn) );
	for ( int i=1; i<=n; i++ )
		if ( !dfn[i] ) rt=i,tarjan(i);

例题 BZOJ1123 BLO

link to darkBZOJ link to AcWing

题意:\(n\)\(m\) 边的无向图,没有重边,问分别去掉每个点和它关联的边,会有多少有序点对不连通。 \(n\le 1e5,m\le 5e5\)

思路:如果点 \(i\) 不是割点,那么去掉之后其他点仍然联通,答案就是 \(2\times (n-1)\)

如果 \(i\) 是割点,那么去掉之后会分成若干连通块,如果在点 \(i\) 的子节点中有 \(k\) 个满足割点判定,那么至多分成 \(t+2\) 个连通块。\(i\) 本身,\(t\) 个子节点的连通块,有可能还有一个其他节点的连通块。

那么在 Tarjan 的过程中同时求出每个子树的大小即可。

void tarjan( int x )
{
	dfn[x]=low[x]=++cnt; siz[x]=1; int fl=0,sum=0;
	for ( int i=head[x]; i; i=e[i].nxt )
	{
		int y=e[i].to;
		if ( !dfn[y] )
		{
			tarjan(y); siz[x]+=siz[y]; low[x]=min( low[x],low[y] );
			if ( low[y]>=dfn[x] )
			{
				fl++; ans[x]+=(ll)siz[y]*(n-siz[y]);
				sum+=siz[y];
				if ( x!=1 || fl>1 ) cut[x]=1;
			}
		}
		else low[x]=min( low[x],dfn[y] );
	}
	if ( cut[x] ) ans[x]+=(ll)(n-sum-1)*(sum+1)+(n-1);
	else ans[x]=2*(n-1);
}

无向图双连通分量

(此处充要的前提是无向连通图)

边双:求法很简单,就是 Tarjan 求出割边之后,再 dfs 一遍标记每个点的分量所属(遍历边时判断是不是割边即可)

缩点:在求出变双连通分量的基础上,遍历所有边,如果不在一个分量里面就加边。

边双连通图的充要条件是任意一条边都在至少一个简单环内

点双:维护一个栈。当一个节点第一次访问时,入栈。当割点判定法则成立时,无论 \(x\) 是否为根,都要:1)从栈顶不断弹出节点直到 \(y\) 弹出; 2)第一步中弹出的所有节点和 \(x\) 一起构成一个 vDCC

缩点:在求出v-DCC的基础上,在 cnt 个分量编号后面增加每个割点编号并相连。

点双的充要条件是:顶点数不超过2,或者扔一两点同时包含在至少一个简单环中。

有向图强连通分量

重新定义 追溯值 low[x] 为满足以下条件的最小时间戳:

  1. 该点在栈中
  2. 存在一条从 subtree(x) 出发的有向边指向这个点

具体步骤:

  1. 当第一次访问时,\(low[x]=dfn[x]\) ,入栈
  2. 扫描每条出边。如果 \(y\) 没有访问过,递归 \(y\)\(low[x]=min(low[x],low[y])\) ;如果访问过了且在栈中,那么令 \(low[x]=min( low[x],dfn[y])\)
  3. 回溯之前,判断是否有 \(low[x]=dfn[x]\) ,如果成立那么不断出栈直到 \(x\) 出栈,这些点构成了一个强连通分量

例题 P2341 受欢迎的牛

link

题意:给定一个 \(N\) 点的有向图,问是否存在唯一的出度为0的强连通分量,如果存在那么输出这个强连通分量大小。

思路:已经把题面抽象完毕了,就是个板子题。

void tarjan( int x )
{
	dfn[x]=low[x]=++cnt; sta.push(x);
	for ( int i=head[x]; i; i=e[i].nxt )
	{
		int y=e[i].to;
		if ( !dfn[y] )
		{
			tarjan( y ); low[x]=min( low[x],low[y] );
		}
		else if ( !bel[y] ) low[x]=min( low[x],dfn[y] );
	}
	if ( dfn[x]==low[x] )
	{
		scc++;
		while ( 1 )
		{
			int y=sta.top(); sta.pop();
			bel[y]=scc; siz[scc]++;
			if ( y==x ) break;
		}
	}
}
//----------------------------------------
	for ( int i=1; i<=n; i++ )
		if ( !dfn[i] ) tarjan( i );
	for ( int x=1; x<=n; x++ )		//统计每个SCC的出度
	 for ( int i=head[x]; i; i=e[i].nxt )
	 {
	 	int y=e[i].to;
	 	if ( bel[x]==bel[y] ) continue;
	 	deg[bel[x]]++;
	 }

有向图必经点和必经边

1、在原图中按照拓扑序列DP,求起点 \(S\) 到每个点的路径数量 \(fs[x]\)

2、在反图上拓扑序DP,求每个点到终点的路径数量 \(ft[x]\)

必经边:对于一条有向边,如果 \(fs[x]\times ft[y]=fs[T]\) 那么是必经边

必经点:对于一个点,如果 \(fs[x]\times ft[x]=fs[T]\) 那么是必经点。

2-SAT

问题

\(n\) 个变量,每个只有两种可能的取值。有 \(m\) 个条件,每个条件是对两个变量的取值限制。求是否存在合法赋值。

Solution

建立 \(2n\) 个点的有向图,每个变量对应 \(a_i,a_{i+n}\)

考虑每个条件,形如 \(A_i=A_{i,p}=>A_j=A_{j,q}\) ,连一条有向边 \((i+p\times N,j+q\times N)\) ,由于逆否命题等价所以也可以加上。

Tarjan 求出强连通分量。

如果存在 \(i,i+n\) 在同一个连通分量里面,显然是不合理的,无解。

模板

link

\(x_i=a\)\(x_j=b\) ” 可以转化成,\(x_i=1-a=>x_j=b\)\(x_j=1-b=>x_i=a\)

void tarjan( int x )
{
	dfn[x]=low[x]=++cnt; sta.push(x);
	for ( int i=head[x]; i; i=e[i].nxt )
	{
		int y=e[i].to;
		if ( !dfn[y] )
		{
			tarjan( y ); low[x]=min( low[x],low[y] );
			
		}
		else if ( !bel[y] ) low[x]=min( low[x],dfn[y] );
	}
	if ( dfn[x]==low[x] )
	{
		scc++;
		while ( 1 )
		{
			int y=sta.top(); sta.pop();
			bel[y]=scc;
			if ( y==x ) break;
		}
	}
}

int main()
{
	scanf( "%d%d",&n,&m );
	while ( m-- )
	{
		int i,j,a,b; scanf( "%d%d%d%d",&i,&a,&j,&b );
		add( i+(1-a)*n,j+b*n ); add( j+(1-b)*n,i+a*n );
	}
	
	for ( int i=1; i<=2*n; i++ )
		if ( !dfn[i] ) tarjan( i );
	for ( int i=1; i<=n; i++ )
		if ( bel[i]==bel[i+n] ) { printf( "IMPOSSIBLE" ); return 0; }
	printf( "POSSIBLE\n" );
	for ( int i=1; i<=n; i++ )
		printf( "%d ",bel[i]>bel[i+n] );		
    //强联通分量编号越小 -> 拓扑序越大 -> 越优 
	
	return 0;
}

网络流

来不及了啊……放个板子。

Dinic

/*
Dinic算法(+当前弧优化&剪枝)
复杂度:O(nm^2),一般是 1e4~1e5 ,稠密图较明显 ,求解二分图最大匹配是O(msqrt(n)) 
思路:BFS得到d[x]表示层次(即S到x最少边数)。残量网络中满足d[y]=d[x]+1的边(x,y)构成的子图称为分层图,
明显是一张有向无环图,DFS从S开始每次向下找一个点,直到到达T,回溯回去,找另外的点搜索求出多条增广路
总结: 
在残量网络上BFS求出节点的层次,构造分层图
在分层图上DFS寻找增广路,在回溯时同时更新边权
当前弧优化:
对于一个节点x,在DFS中走到了第i条弧时,前i-1条弧到汇点的流一定流满了,再访问x节点时前i-1条弧就没有意义了
在每次枚举节点x所连的弧时,改变枚举起点即可。(now)
*/ 
#include <bits/stdc++.h>
#define ll long long
using namespace std;
const int N=5e5+10;
const ll inf=0x7f7f7f7f;
struct edge
{
	int to,nxt; ll val;
}e[N];
int n,m,S,T;
int tot=1,now[N],head[N];
ll ans,dis[N];

void add( int u,int v,ll w )
{
	tot++; e[tot].to=v; e[tot].nxt=head[u]; e[tot].val=w; head[u]=tot;
	tot++; e[tot].to=u; e[tot].nxt=head[v]; e[tot].val=0; head[v]=tot;
}

int bfs()
{
	for ( int i=1; i<=n; i++ )
		dis[i]=inf;
	queue<int> q; q.push(S); dis[S]=0; now[S]=head[S];
	while ( !q.empty() )
	{
		int u=q.front(); q.pop();
		for ( int i=head[u]; i; i=e[i].nxt )
		{
			int v=e[i].to;
			if ( e[i].val>0 && dis[v]==inf )
			{
				q.push(v); now[v]=head[v]; dis[v]=dis[u]+1;
				if ( v==T ) return 1;
			}
		}
	}
	return 0;
}

int dfs( int x,ll sum )		//sum是整条增广路对最大流的贡献
{
	if ( x==T ) return sum;
	ll k,res=0;		//k是当前最小的剩余容量 
	for ( int i=now[x]; i && sum; i=e[i].nxt )
	{
		now[x]=i; int y=e[i].to;
		if ( e[i].val>0 && (dis[y]==dis[x]+1) )
		{
			k=dfs( y,min(sum,e[i].val) );
			if ( k==0 ) dis[y]=inf;		//剪枝,去掉增广完毕的点 
			e[i].val-=k; e[i^1].val+=k;
			res+=k; sum-=k;
			//res表示经过该点的所有流量和(相当于流出的总量) 
			//sum表示经过该点的剩余流量 
		}
	}
	return res;
}

int main() 
{
	scanf( "%d%d%d%d",&n,&m,&S,&T );
	for ( int i=1,u,v; i<=m; i++ ) 
	{
		ll w; scanf( "%d%d%lld",&u,&v,&w ),add( u,v,w );
	}
		
	
	while ( bfs() )
		ans+=dfs(S,inf); 
	 
	printf( "%lld",ans );
}

EK

/*
时隔多年重新写的网络最大流,EK板子 
复杂度: O(nm^2) 一般处理的是1e3~1e4左右 
思路:不断增广,直到不能再增广为止。其实增广就是更新,对一条路进行增广,那条路就是增广路。
从源点到汇点有很多条路径,EK就是每一次抽一条路径,在这条路径上找边权最小值(流量)。
然后把这条路径的所有边权都减去这个最小值。直到最后没有任何路径可以给汇点输送流量为止。
如果一条路径有0容量的边,那么那条路径就废了,因为最多只能流0容量的流量,和不流没区别。
用bfs写。如果普通建边是跑不了最大流的,EK支持一个反悔操作,之前有一条路径不可以形成最大流,将那条路径反悔,换其他路径。
实现这个反悔操作只需要建反向边,反向边刚开始的流量为0,因为暂时不能往回流,
如果流过一条边,那条边的容量自然减少,而反向边容量增加,之后反向边就可以流动了。
*/
#include <bits/stdc++.h>
#define ll long long
using namespace std;
const int N=5e5+10;
const ll inf=0x7f7f7f7f;
struct edge
{
	int to,nxt; ll val;
}e[N];
int n,m,S,T;
ll mxflow,dis[N];
int tot=1,head[N],vis[N],pre[N],flag[2510][2510];

void add( int u,int v,ll w )
{
	tot++; e[tot].to=v; e[tot].nxt=head[u]; e[tot].val=w; head[u]=tot;
	tot++; e[tot].to=u; e[tot].nxt=head[v]; e[tot].val=0; head[v]=tot;
}

int bfs()
{
	for ( int i=1; i<=n; i++ )
		vis[i]=0;
	queue<int> q; q.push(S); vis[S]=1; dis[S]=inf;
	while ( !q.empty() )
	{
		int u=q.front(); q.pop();
		for ( int i=head[u]; i; i=e[i].nxt )
		{
			if ( e[i].val==0 ) continue;
			int v=e[i].to;
			if ( vis[v] ) continue;
			dis[v]=min( dis[u],e[i].val ); pre[v]=i; q.push(v); vis[v]=1;
			if ( v==T ) return 1;
		}
	}
	return 0;
}

void update()
{
	int u=T;
	while ( u!=S )
	{
		int v=pre[u];
		e[v].val-=dis[T]; e[v^1].val+=dis[T];
		u=e[v^1].to;
	}
	mxflow+=dis[T];
}

int main()
{
	scanf( "%d%d%d%d",&n,&m,&S,&T );
	for ( int i=1,u,v; i<=m; i++ )
	{
		ll w; scanf( "%d%d%lld",&u,&v,&w );
		if ( flag[u][v]==0 ) add( u,v,w ),flag[u][v]=tot;		//重边 
		else e[flag[u][v]-1].val+=w;
	}
	
	while ( bfs()!=0 ) update();
	
	printf( "%lld",mxflow );
}
posted @ 2020-11-07 08:31  MontesquieuE  阅读(239)  评论(0编辑  收藏  举报