参考了这篇文章,以及《算法导论》.
本文代码中图的定义:
struct Graph{
struct edge{int Next,to;};
edge G[200010];
int head[10010];
int cnt;
Graph():cnt(2){}
void clear(int node_num=0){
cnt=2;
if(node_num==0) memset(head,0,sizeof(head));
else fill(head,head+node_num+5,0);
}
void add_edge(int u,int v){
G[cnt].to=v;
G[cnt].Next=head[u];
head[u]=cnt++;
}
};
Graph G;
边分类
树边(tree edges): 如果结点 (v) 是因算法对边 ((u,v))的探索而首先被发现,则 ((u,v)) 是一条树边。
后向边(back edges): 后向边 ((u,v)) 是将结点 (u) 连接到其在DFS树中一个祖先结点 (v) 的边。自环也被认为是后向边。
前向边(forward edges): 是将结点 (u) 连接到其在深度优先树中一个后代结点 (v) 的边 ((u,v))。
横向边(cross edges): 其它所有的边。这些边可以连接同一棵DFS树中的结点,只要其中一个结点不是另外一个结点的祖先,也可以连接不同DFS树中的两个结点。
无向图的DFS树
对图进行DFS后,形成了深度优先森林。
在对无向图进行DFS时,每条边要么是树边,要么是后向边,从来不会出现前向边和横向边。
非树边一定不是桥。边 ((u,v)) 是桥,当且仅当 ((u,v)) 是树边,并且没有非树边跨越这条边。
桥(割边)
在无向图中,如果将其中一条边删去,联通块的数目增多,那么这条边就称为桥。
如何求桥?
1.Tarjan 算法
设 (dfn[u]) 表示结点 (u) 的DFS时间戳, (low[u]) 表示追溯值,即通过结点 (u) 及其DFS子树中的结点且不经过树边,最早能追溯到的结点的时间戳。那么当 (dfn[u]=low[u]) 时,连接 (u) 与其DFS树中的父亲的这条边即为桥。
int dfn[1010],low[1010];
bool isBridge[2000010];
int Index;
void Tarjan(int u,int in_edge){//in_edge:DFS到u所经过的这条入边
dfn[u]=low[u]=++Index;
for(int i=G.head[u];i;i=G.G[i].Next){
int v=G.G[i].to;
if(!dfn[v]){Tarjan(v,i);low[u]=min(low[u],low[v]);}
else if(i!=(in_edge^1)) low[u]=min(low[u],dfn[v]);//非树边,且考虑了重边
}
if(dfn[u]==low[u])
isBridge[in_edge]=isBridge[in_edge^1]=true;
return;
}
2.树上dp
设 (fa) 是 (u) 的父亲,(dp[u]) 表示跨越 (u) 和它父亲的非树边的数量,则
如果 (dp[u]=0),则 (u) 和连接DFS树上的它父亲的这条边是桥。
int dfn[1010],dp[1010];
bool isBridge[2000010];
int Index;
void DFS(int u,int in_edge){//in_edge:DFS到u所经过的这条入边
dfn[u]=++Index;
dp[u]=0;
for(int i=G.head[u];i;i=G.G[i].Next){
int v=G.G[i].to;
if(!dfn[v]){DFS(v,i);dp[u]+=dp[v];}
else if(i!=(in_edge^1)){//非树边,且考虑了重边
if(dfn[v]<dfn[u]) ++dp[u];
else --dp[u];
}
}
if(dp[u]==0)
isBridge[in_edge]=isBridge[in_edge^1]=true;
return;
}
割点
在无向图中,如果将其中一个点以及所有连接该点的边删去,连通块的数目增多,那么这个点就称为割点。
还是使用Tarjan算法,若 (u) 是DFS树的根,并且它有多余1个儿子,那么 (u) 是割点。若 (u) 不是DFS树的根,并且存在一个DFS树上的子结点 (v),使得 (dfn[u]leq low[v]),那么此时,(u) 是割点。
int dfn[20010],low[20010];
bool cut_vertex[20010];
int Index;
void Tarjan(int u,int in_edge){
dfn[u]=low[u]=++Index;
int son_num=0;
for(int i=G.head[u];i;i=G.G[i].Next){
int v=G.G[i].to;
if(!dfn[v]){
if(!in_edge) ++son_num;
Tarjan(v,i);
low[u]=min(low[u],low[v]);
if(in_edge && low[v]>=dfn[u]) cut_vertex[u]=true;
}
else if(i!=(in_edge^1)) low[u]=min(low[u],dfn[v]);
}
if(!in_edge && son_num>1) cut_vertex[u]=true;
return;
}
双连通分量
点双连通分量
点双连通图:
(1)该连通图的任意两条边都至少在一个简单环中。
(2)该连通图没有割点。
(3)对于至少三个点的图,任意两点间至少存在两条点不重复路径。
(以上三个定义等价)
点双连通分量:
对于一张无向图,它的极大点双连通子图称为点双连通分量。
一个割点可能同时在多个点双连通分量中。点双连通分量之间以割点连接,并且两个点双连通分量之间有且仅有一个割点。
对于每个点双连通分量,如果边数大于点数,则该点双连通分量里的所有边都至少在两个简单环中。
奇环定理: 若某个点双连通分量中存在奇环,则该点双连通分量中的所有点都在某个奇环内。
Tarjan算法求点双连通分量
bool cut_vertex[10010];
int dfn[10010],low[10010],stk[20010];
vector<int> bcc[20010];
int Index,bcc_id,top;
void Tarjan(int u,int in_edge){
dfn[u]=low[u]=++Index;
stk[++top]=u;
if(in_edge==0 && G.head[u]==0){//孤立点
bcc[++bcc_id].push_back(u);
return;
}
int son_num=0;
for(int i=G.head[u];i;i=G.G[i].Next){
int v=G.G[i].to;
if(!dfn[v]){
Tarjan(v,i);
low[u]=min(low[u],low[v]);
if(dfn[u]<=low[v]){
++son_num;
if(in_edge || son_num>1) cut_vertex[u]=true;
++bcc_id;
int x;
do{
x=stk[top--];
bcc[bcc_id].push_back(x);
}while(x!=v);
bcc[bcc_id].push_back(u);
}
}
else if(i!=(in_edge^1))
low[u]=min(low[u],dfn[v]);
}
return;
}
点双连通分量的缩点
Graph G2;
int new_id[10010];
int num;
void build_new_graph(){
num=bcc_id;
for(RG i=1;i<=N;++i)
if(cut_vertex[i]) new_id[i]=++num;
for(RG i=1;i<=bcc_id;++i){
for(auto u:bcc[i]){
if(cut_vertex[u]){
G2.add_edge(i,new_id[u]);
G2.add_edge(new_id[u],i);
}// 除割点外,其它点仅属于1个v-BCC
}
}
return;
}
边双连通分量
边双连通图:
(1)该连通图的任意一条边都至少在一个简单环中。
(2)该连通图没有桥。
(3)该连通图中任意两点至少存在两条边不重复路径。
(以上三个定义等价)
边双连通分量: 对于一张无向图,它的极大边双连通子图称为边双连通分量。
将边双连通分量进行缩点后将形成一个森林(若原来只有一个连通块,则形成一棵树),树边为桥。
Tarjan算法求边双连通分量
把所有的桥删去就分成了一个个边双连通分量(网上大部分代码都是这样写的,先跑一遍Tarjan求桥,再跑一遍DFS,但其实只需要模仿SCC的求法,这样只需要一次DFS)。
每遍历到一个结点就把它加入栈中。若最终 (dfn[u]=low[u]),则从栈顶到 (u) 的所有结点同属一个边双连通分量。
int dfn[200010],low[200010],bcc[200010],stk[200010];
int Index,bcc_id,top;
void Tarjan(int u,int in_edge){
dfn[u]=low[u]=++Index;
stk[++top]=u;
for(int i=G.head[u];i;i=G.G[i].Next){
int v=G.G[i].to;
if(!dfn[v]){Tarjan(v,i);low[u]=min(low[u],low[v]);}
else if(i!=(in_edge^1)) low[u]=min(low[u],dfn[v]);
}
if(dfn[u]==low[u]){
++bcc_id;int x;
do{x=stk[top--];bcc[x]=bcc_id;
}while(x!=u);
}
return;
}
Tarjan判二分图
弦: 连接环中不相邻的两个点的边。
无弦环一定是由一条非树边和若干条树边组成的。含有多余一个非树边的简单环一定有弦。
如果无向图存在奇环,那么一定存在奇无弦环。
一张图是二分图,当且仅当不存在奇环,从而也不存在奇无弦环。
于是我们可以根据以上推论使用Tarjan算法对奇无弦环进行计数,从而判断无向图是否是二分图。
我们可以对DFS树使用树上差分来标记有多少个无弦环经过一条树边,可以帮助解决环的奇偶性相关的问题 (例如Codeforces 19E这题, 给出一张无向图,求删除一条边后此图变成二分图的所有删边种类)。
int dfn[10010],low[10010],deep[10010];
int Index,odd_circle_num;
void Tarjan(int u,int in_edge){
dfn[u]=low[u]=++Index;
for(int i=G.head[u];i;i=G.G[i].Next){
int v=G.G[i].to;
if(!dfn[v]){deep[v]=deep[u]+1;Tarjan(v,i);low[u]=min(low[u],low[v]);}
else if(i!=(in_edge^1) && dfn[v]<dfn[u]){
low[u]=min(low[u],dfn[v]);
if((deep[u]-deep[v]+1)%2==1) ++odd_circle_num;
}
}
return;
}
for(RG i=1;i<=N;++i)
if(!dfn[i]){deep[i]=0;Tarjan(i,0);}
if(odd_circle_num) printf("NO
");//有奇环
else printf("YES
");//无奇环,是二分图
强连通分量
之前讨论了无向图的连通性,接下来我们来讨论有向图的连通性。
强连通图
在有向图 (G) 中,如果两个结点 (u,v) 间有一条从 (u) 到 (v) 的有向路径,同时还有一条从 (v) 到 (u) 的有向路径,则称两个顶点强连通。如果有向图 (G) 的任意两个顶点都强连通,称 (G) 是一个强连通图。
强连通分量
有向图的极大强连通子图,称为强连通分量。
对有向图的所有强连通分量进行缩点后形成一个DAG,可以按拓扑序方便地进行dp。
Tarjan 算法求强连通分量
每DFS到一个结点,就把它入栈。因为有向图的DFS树存在横向边和前向边,我们在更新追溯值 (low[u]) 时,为了保证仍通过DFS树中 (u) 的子树中的结点来追溯,所以只能通过树边以及栈中结点来更新追溯值。当 (dfn[u]=low[u]) 时,从栈顶一直到结点 (u) 的部分构成一个强连通分量。
int stk[100010],dfn[100010],low[100010],scc[100010];
bool inS[100010];
int Index,top,scc_id;
void Tarjan(int u){
dfn[u]=low[u]=++Index;
stk[++top]=u;inS[u]=true;
for(int i=G.head[u];i;i=G.G[i].Next){
int v=G.G[i].to;
if(!dfn[v]){Tarjan(v);low[u]=min(low[u],low[v]);}
else if(inS[v]) low[u]=min(low[u],dfn[v]);
}
if(dfn[u]==low[u]){
++scc_id;int x;
do{x=stk[top--];scc[x]=scc_id;inS[x]=false;
}while(x!=u);
}
return;
}
以上的一系列 Tarjan 算法时间复杂度均为 (O(|V|+|E|))。
2-SAT 问题
2-SAT,全称为2-Satisfiability。k-SAT问题是NP完全的。
2-SAT问题: 有 (n) 个布尔变量 (x_1sim x_n),另有 (m) 个需要满足的条件,每个条件的形式都是「(x_i)为 true / false 或 (x_j)为 true / false」。比如 「(x_1)为真或(x_3)为假」、「(x_7)为假或 (x_2)为假」。2-SAT 问题的目标是给每个变量赋值使得所有条件得到满足。
我们可以使用图论来解决2-SAT问题。我们可以把一个变量 (a) 拆成两个点 (a) 和 (a+n),其中点 (a) 表示变量 (a) 赋值为 (1),即 (a),点 (a+n) 表示变量 (a) 赋值为 (0),即 (lnot a)。(a o b) 的意思是如果选了 (a),那么一定得选 (b)。那么分以下四种情况连有向边。
- (alor b: lnot a o b, lnot b o a)
G.add_edge(u+N,v);
G.add_edge(v+N,u);
- (a lor lnot b: lnot a o lnot b, b o a)
G.add_edge(u+N,v+N);
G.add_edge(v,u);
- (lnot a lor b: a o b,lnot b o lnot a)
G.add_edge(u,v);
G.add_edge(v+N,u+N);
- (lnot a lor lnot b: a o lnot b,b o lnot a)
G.add_edge(u,v+N);
G.add_edge(v,u+N);
可以发现同一个强连通分量里的变量取值是相同的。如果存在 (a) 和 (lnot a) 在同一个强连通分量里,那么无解。
我们把强连通分量进行缩点,得到一个DAG。那么现在 (a) 和 (lnot a)一定不在同一个强连通分量里面,我们考虑如何去构造一组解。如果 (a) 能到达 (lnot a),就说明如果 (a) 为真,那么 (a) 为假,那么我们令 (a) 为假即可。如果 (lnot a) 能到达 (a),就说明如果 (a) 为假,那么 (a) 为真,那么我们令 (a) 为真即可。即如果 (lnot a) 的拓扑序比 (a) 大,我们令 (a) 为假,如果 (a) 的拓扑序比 (lnot a) 大,我们令 (a) 为真。
所以我们只需求得缩点后这个DAG的拓扑序。注意到运行完Tarjan算法后原图中每个点现在所属的强连通分量的编号 (scc[u]) 即为拓扑序的逆序,所以我们无需再进行一次拓扑排序。
时间复杂度 (O(|V|+|E|)),因为一个点被拆成了2个点,注意开两倍空间。
Code (洛谷 P4782)
Graph G;
int N,M;
int dfn[maxn],low[maxn],scc[maxn],stk[maxn];
bool inS[maxn];
int Index,top,scc_id;
void Tarjan(int u){
dfn[u]=low[u]=++Index;
stk[++top]=u;inS[u]=true;
for(int i=G.head[u];i;i=G.G[i].Next){
int v=G.G[i].to;
if(!dfn[v]){Tarjan(v);low[u]=min(low[u],low[v]);}
else if(inS[v]) low[u]=min(low[u],dfn[v]);
}
if(dfn[u]==low[u]){
++scc_id;int x;
do{x=stk[top--];scc[x]=scc_id;inS[x]=false;}while(x!=u);
}
return;
}
int main(){
Read(N);Read(M);
for(RG i=1;i<=M;++i){
int u,a,v,b;
Read(u);Read(a);Read(v);Read(b);
if(a && b){
G.add_edge(u+N,v);
G.add_edge(v+N,u);
}else if(a && !b){
G.add_edge(u+N,v+N);
G.add_edge(v,u);
}else if(!a && b){
G.add_edge(u,v);
G.add_edge(v+N,u+N);
}else{
G.add_edge(u,v+N);
G.add_edge(v,u+N);
}
}
for(int i=1;i<=N*2;++i)
if(!dfn[i]) Tarjan(i);
for(RG i=1;i<=N;++i)
if(scc[i]==scc[i+N]){printf("IMPOSSIBLE
");return 0;}
printf("POSSIBLE
");
for(RG i=1;i<=N;++i){
printf("%d",(scc[i+N]>scc[i]));
if(i<N) printf(" ");
}
printf("
");
return 0;
}