强连通分量
有向图的强连通分量是指有向图的极大强联通子图,在这个子图中,任意两点相互可达。
- 一个图强联通当且仅当存在一个包含所有顶点的有向回路;
对于下图这个例子来说:
强连通分量分别为 ,其中对于有向图中的边,我们分为三种:
- 有向图dfs搜索时每次访问没有搜索过的结点时的边,图中蓝色的边;
- 指向已经搜索过的祖先结点的边,图中黄色的边;
- 指向已经搜索过的非祖先结点的边,图中红色的边。
求有向图强连通分量可以用Tarjan算法,这是一种基于有向图dfs生成树的算法,算法流程如下:
用 dfn[i] 记录 i 号结点的dfs序,low[i] 记录 i 号结点的子树中的所有边指向的祖先结点的最小 dfn 值,然后循环对所有未搜索过的结点dfs搜索。用栈维护未归类 scc(强连通分量)的结点序列(这个结点序列按照搜索顺序放入,故具有父子关系),当搜索到结点 u 时,首先进栈,然后对于其所有子节点 v,如果 v 还未被访问过,那么递归处理 v;如果 v 访问过但是还没有归类 scc(说明这个 v 在栈中),可以得出 <u,v> 是一条指向祖先的边;以上两种情况分别更新 low[u] 。如果遇到 dfn[u]==low[u] 的情况时,说明以 u 为根节点的子树是一个强连通分量,进行出栈和归类 scc。
这个算法不要求有向图弱连通,而其正确性在于上面提到的强连通的等价条件。栈实际维护了一条单向连通的父子结点序列,然后如果找到了反向边,说明找到了一个环(回路),于是环上的所有结点都在同一个强连通分量中。但是找到了环我们并不马上进行 scc 归类,而是通过记录 low 的方式,这样只用最后处理根结点。
Tarjan算法的复杂度是 O(n+m) ,代码如下:
const int maxn=1e2+5;
vector<int> G[maxn];
int dfn[maxn],low[maxn],scc[maxn],dfs_cnt,scc_cnt;
int n,m;
stack<int> S;
void dfs(int u)
{
dfn[u]=low[u]=++dfs_cnt;
S.push(u);
REP(i,0,G[u].size()-1)
{
int v=G[u][i];
if(!dfn[v]) dfs(v),low[u]=min(low[u],low[v]);
else if(!scc[v]) low[u]=min(low[u],dfn[v]);
}
if(dfn[u]==low[u])
{
scc_cnt++;
while(1)
{
int x=S.top(); S.pop();
scc[x]=scc_cnt;
if(u==x) break;
}
}
}
void get_scc()
{
REP(i,1,n) if(!dfn[i]) dfs(i);
}
求解强连通分量有利于对有向图缩点,就是把一个强连通分量缩成一个点,缩点后的图是一个DAG(有向无环图),并且通过Tarjan算法缩点过后,这个DAG的结点编号满足反拓扑序(由dfs的顺序可以得出)。
割顶和桥
无向图的连通性定义为图中任意两点可以达到。无向图的极大连通子图(分支)就是一个跟任何其他顶点都没有连边的子图。
割顶的定义:无向图中的一个顶点,去掉这个点之后,无向图的分支个数增加。
桥的定义:无向图中的一条边,去掉这条边之后,无向图的分支个数增加。
求解无向图的割顶和桥也是用 Tarjan算法。不过跟有向图的不大一样,基本思路还是基于dfs搜索,但是在无向图之中,low[i] 记录的是结点 i 的子树不经过 i 所能达到的最小的 dfn 值。所以在搜索的过程中,对于某个结点 u,如果它的子节点 v 的 low[v]>=dfn[u],说明 v 无法不通过 u 到达 u 的任意祖先,所以 u 是割顶;而如果 low[v]>dfn[u],说明 v 无法不通过无向边 <u,v> 到达 u,所以无向边 <u,v> 是桥。
另外要注意更新 low 的时候不能把dfs生成树的父结点认为是子结点,所以dfs时要传入父结点,处理过程中特判。
该算法代码如下:
const int maxn=1e5+5;
vector<int> G[maxn];
int dfn[maxn],is_cut[maxn],dfs_cnt;
int n,m;
int dfs(int u,int far)
{
int lowu=dfn[u]=++dfs_cnt,ch=0;
REP(i,0,G[u].size()-1)
{
int v=G[u][i];
if(!dfn[v])
{
ch++;
int lowv=dfs(v,u);
lowu=min(lowu,lowv);
if(lowv>=dfn[u]) is_cut[u]=1;
//if(lowv>dfn[u]) is_bridge(u,v);
}
else if(dfn[v]<dfn[u] && v!=far) lowu=min(lowu,dfn[v]);
}
if(far<0 && ch<=1) is_cut[u]=0;
return lowu;
}
void get_cut()
{
REP(i,1,n) if(!dfn[i]) dfs(i,-1);
}
双连通分量
双连通这个性质分为两种:
- 点双连通:任意两点之间存在至少两条“点不重复”路径,这等价于任意两条边都在同一个简单环中,或者等价于删去任意一个点都不影响连通性,即内部没有割顶;
- 边双连通:任意两点之间存在至少两条“边不重复”路径,这等价于任意一条边都至少在一个简单环中,或者等价于删去任意一条边都不影响连通性,即内部没有桥;
边双连通分量的求解很简单,只要标记出所有的桥,去掉桥之后的所有分支就是边双连通分量。
点双连通分量(bcc)稍微复杂一些,先说几个它的性质:
- 任意两个bcc之间有且仅有一个公共点,且这个公共点是割顶;
- 任意一个割顶都属于至少两个bcc;
求解点双连通分量代码如下:
const int maxn=1e5+5;
vector<int> G[maxn],B[maxn];
int dfn[maxn],bcc[maxn],dfs_cnt,bcc_cnt;
int n,m;
struct edge {int u,v;};
stack<edge> S;
int dfs(int u,int far)
{
int lowu=dfn[u]=++dfs_cnt;
REP(i,0,G[u].size()-1)
{
int v=G[u][i]; edge e=(edge){u,v};
if(!dfn[v])
{
S.push(e);
int lowv=dfs(v,u);
lowu=min(lowu,lowv);
if(lowv>=dfn[u])
{
bcc_cnt++;
while(1)
{
edge x=S.top(); S.pop();
if(bcc[x.u]!=bcc_cnt) B[bcc_cnt].push_back(x.u),bcc[x.u]=bcc_cnt;
if(bcc[x.v]!=bcc_cnt) B[bcc_cnt].push_back(x.v),bcc[x.v]=bcc_cnt;
if(x.u==u && x.v==v) break;
}
}
}
else if(dfn[v]<dfn[u] && v!=far) S.push(e),lowu=min(lowu,dfn[v]);
}
return lowu;
}
void get_bcc()
{
REP(i,1,n) if(!dfn[i]) dfs(i,-1);
}
算法其实也属于Tarjan算法的一种,这个类似求割顶,不过使用栈将当前bcc的边存起来,然后对于每个 low[v]>=dfn[u] 的情况统计一次bcc,形象地理解如下图所示:
需要注意的是,尽管两个点和一条边这样的子图不太符合点双连通分量的定义,但按照该算法运行之后这也会被统计为点双连通分量。
2-SAT
2-SAT问题可以等价描述如下:对于逻辑表达式 ,其中 为文字,代表 或 , 代表某一种二元逻辑运算,我们的目标是找到合适的布尔变量,使得该逻辑表达式为真。
可以用图论的方法解决2-SAT问题。假设涉及到 n 个逻辑变量 ,构造一个含有 2n 个结点的有向图G,其中第 i 个结点代表 为真,第 i+n 个结点代表 为假;如果存在边 <i,j>,说明假设 成立,那么 必须成立,即 (注意这里如果 i>n,实际意义是 成立);对于常见的逻辑运算加边方法如下:
以此类推。(对于与运算其实就是两个都为1)
构造好图之后,我们实际上希望避免一切( )的情况出现,所以对图进行强连通分量缩点,如果存在 和 在同一个 scc 中,则一定无解;否则,对于每个逻辑变量,如果 拓扑序小于 ,则令 ,反之令 。
由于Tarjan算法求出的 scc 满足反拓扑序,故:如果 ,则 ;否则 。