基础图论复习笔记
——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\) 挺小的,所以也可以来一个二分图的奇妙建模。
二分图匹配的模型要素:
- 节点能分成两个独立集合,每个集合内部没有边
- 每个节点只能和一条匹配边相连
考虑这道题如何建模。“每个节点和一条匹配边相连” 可以对应 “每个格子只能被一张骨牌覆盖”,那么把骨牌作为边,把格子作为点(可以放的格子),可选边的集合就是所有合法的相邻格子。
但是这时候还要分出左右部点。考虑一个经典问题:去掉象棋棋盘相对的两个角上的格子,能否用骨牌覆盖?这个问题的解决采用了染色法。结合二分图判定方法,本题中的格点同样可以按奇偶行列染色,那么就得到了左右部点。然后跑匈牙利即可。
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] 为满足以下条件的最小时间戳:
- 该点在栈中
- 存在一条从 subtree(x) 出发的有向边指向这个点
具体步骤:
- 当第一次访问时,\(low[x]=dfn[x]\) ,入栈
- 扫描每条出边。如果 \(y\) 没有访问过,递归 \(y\) ,\(low[x]=min(low[x],low[y])\) ;如果访问过了且在栈中,那么令 \(low[x]=min( low[x],dfn[y])\)
- 回溯之前,判断是否有 \(low[x]=dfn[x]\) ,如果成立那么不断出栈直到 \(x\) 出栈,这些点构成了一个强连通分量
例题 P2341 受欢迎的牛
题意:给定一个 \(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\) 在同一个连通分量里面,显然是不合理的,无解。
模板
“ \(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 );
}