连通性问题 学习笔记
强连通分量
定义
若一张有向图的节点两两互相可达,则称这张图是强连通的。
一张有向图的强连通分量是极大的强连通子图。
Tarjan算法
前置知识:dfs树
DFS 树就是对图进行 DFS 形成的图的生成树。通过 DFS 树可以把边分为四类:
1.树边:DFS 树上的边。
2.返祖边:不在 DFS 树上,由一个点连向其祖先。
3.前向边:不在 DFS 树上,由一个点连向其子树。
4.横叉边:不在 DFS 树上,由一个点连向另一个访问过的,不是这个节点祖先的节点。
如果某个节点是某个强连通分量在搜索树中遇到的第一个结点,那么这个强连通分量的其余结点肯定是在搜索树中以该节点为根的子树中。否则会有一条边是从子树连出去的,为返祖边或横叉边。而这两种边连向的点已被访问,这个点才是第一个被访问的,矛盾。
强连通分量在搜索树中遇到的第一个结点称作这个强连通分量的根。
算法过程
Tarjan 算法维护了一个栈,将搜到的节点入栈,找到强连通分量时出栈。且对每个节点维护两个值:\(dfn\),表示 dfs 序;\(low\),表示这个节点的子树能够通过不多于一条非树边到达的,dfs 序最小的栈中的结点。
对于节点 \(u\) 与子节点 \(v\),分类讨论 \(low\) 的值:
1.\(v\) 未访问:向 \(v\) 继续搜索,然后取 \(low_u=\min(low_u,low_v)\)。因为 \(u\) 可以走到 \(v\),\(v\) 能到达的 \(u\) 也可以到达。
2.\(v\) 被访问过且在栈中:取 \(low_u=\min(low_u,dfn_v)\)。此时不是用 \(low_v\) 更新,因为 \(u\to v\) 是一条非树边。
3.\(v\) 被访问过,不在栈中:没有影响。
对于一个强连通分量的根,必有 \(dfn=low\)。如果 \(low>dfn\),那么这个节点可以通过一条非树边到达上方的节点,形成一个环,这个环上的节点也属于该强连通分量,矛盾。
在强连通分量中有且仅有根满足 \(dfn=low\)。由于强连通分量中的节点互相可达,其他节点可以来到根,也就是说 \(low\) 的值不大于根的 \(low\) 值。
通过这两条性质,可以用根确定整个强连通分量。在回溯时判断 \(dfn=low\),如果是,将这个强连通分量出栈并记录。
int dfn[n+5],low[n+5],st[n+5],top,ans,d;
bool vis[n+5];
void dfs(int pos,vector<int>e[],vector<int>scc[],int num[]){
low[pos]=dfn[pos]=++d,st[++top]=pos,vis[pos]=1;
for(int i=0;i<e[pos].size();i++){
if(!dfn[e[pos][i]])dfs(e[pos][i],e,scc,num),low[pos]=min(low[pos],low[e[pos][i]]);
else if(vis[e[pos][i]])low[pos]=min(low[pos],dfn[e[pos][i]]);
}
if(dfn[pos]==low[pos]){
ans++;
do scc[ans].push_back(st[top]),num[st[top]]=ans,vis[st[top]]=0;
while(st[top--]!=pos);
}
}
int tarjan(int n,vector<int>e[],vector<int>scc[],int num[]){
for(int i=1;i<=n;i++)if(!dfn[i])dfs(i,e,scc,num);
return ans;
}
Kosaraju 算法
算法过程
Kosaraju 算法的过程有三步:
- 建反图。
- 对原图 DFS,将节点按出栈顺序排序。
- 不断以出栈最晚的点在反图中 DFS,能到达的点为强连通分量。
int st[n+5],top,ans;
bool vis[n+5];
vector<int>g[n+5];
void dfs1(int pos,vector<int>e[]){
vis[pos]=1;
for(int i=0;i<e[pos].size();i++)if(!vis[e[pos][i]])dfs1(e[pos][i],e);
st[++top]=pos;
}
void dfs2(int pos,vector<int>g[],vector<int>scc[],int num[]){
num[pos]=ans,scc[ans].push_back(pos);
for(int i=0;i<g[pos].size();i++)if(!num[g[pos][i]])dfs2(g[pos][i],g,scc,num);
}
int kosaraju(int n,vector<int>e[],vector<int>scc[],int num[]){
for(int i=1;i<=n;i++)for(int j=0;j<e[i].size();j++)g[e[i][j]].push_back(i);
for(int i=1;i<=n;i++)if(!vis[i])dfs1(i,e);
for(int i=top;i>=1;i--)if(!num[st[i]])ans++,dfs2(st[i],g,scc,num);
return ans;
}
证明
一个感性的证明:
每次取出出栈最晚的点,设为点 \(u\)。当 \(u\) 点在第二次 dfs 能到达点 \(v\),则在原图中 \(v\) 能到达 \(u\)。而 \(u\) 又在原图中比 \(v\) 晚出栈,有两种可能:一种是 \(u\) 可达 \(v\),此时 \(u,v\) 可互相到达;另一种是 \(u,v\) 没用同时在栈中过,但这样与 \(v\) 在反图中可达 \(u\) 矛盾。
Garbow 算法
Garbow 算法是 Tarjan 算法的另一种实现。Garbow 算法使用了第二个栈,与 Tarjan 算法的 \(low\) 数组作用相似,都是用来确定根。
int dfn[n+5],st1[n+5],st2[n+5],top1,top2,d,ans;
void dfs(int pos,vector<int>e[],vector<int>scc[],int num[]){
dfn[pos]=++d,st1[++top1]=pos,st2[++top2]=pos;
for(int i=0;i<e[pos].size();i++){
if(!dfn[e[pos][i]])dfs(e[pos][i],e,scc,num);
else if(!num[e[pos][i]])while(dfn[st2[top2]]>dfn[e[pos][i]])top2--;
}
if(st2[top2]==pos){
top2--,ans++;
do num[st1[top1]]=ans,scc[ans].push_back(st1[top1]);
while(st1[top1--]!=pos);
}
}
int garbow(int n,vector<int>e[],vector<int>scc[],int num[]){
for(int i=1;i<=n;i++)if(!dfn[i])dfs(i,e,scc,num);
return ans;
}
割点和割边
定义
对于一个无向图,如果把一个点删除后这个图的极大连通分量数增加了,那么这个点就是这个图的割点。
对于一个无向图,如果把一条边删除后这个图的极大连通分量数增加了,那么这个点就是这个图的割边。
Tarjan算法
同样定义 \(low\),为这个节点的子树能够通过不多于一条非树边到达的,DFS 序最小的结点。注意这里没有在栈中的限制。
对于点 \(u\) 的子节点 \(v\),如果 \(low_v\geq dfn_u\),说明 \(v\) 不能通过子树走到 \(u\) 的祖先。那么 \(v\) 与 \(u\) 的祖先之间仅通过 \(u\) 联通,删除 \(u\) 后就分开了。因此 \(u\) 是割点。
然而有特殊情况:\(u\) 为 DFS 树的根。\(v\) 不能通过子树走到 \(v\) 的祖先,然而此时 \(u\) 没有祖先。因此 \(u\) 为树根时,需要有两个以上的子节点满足 \(low_v\geq dfn_u\)。
int dfn[n+5],low[n+5],ans,d;
void dfs(int pos,bool f,vector<int>e[],int c[]){
int num=0;
low[pos]=dfn[pos]=++d;
for(int i=0;i<e[pos].size();i++){
if(!dfn[e[pos][i]])dfs(e[pos][i],0,e,c),low[pos]=min(low[pos],low[e[pos][i]]),num+=low[e[pos][i]]>=dfn[pos];
else low[pos]=min(low[pos],dfn[e[pos][i]]);
}
if(num>f)c[++ans]=pos;
}
int tarjan(int n,vector<int>e[],int c[]){
for(int i=1;i<=n;i++)if(!dfn[i])dfs(i,1,e,c);
return ans;
}
对于割边只需要改一下式子:\(low_v>dfn_u\),因为如果断开割边 \(u\to v\),\(v\) 是不能回到 \(u\) 的。
此处需要对 \(low\) 的定义增加限制:\(u\to v\) 不能沿着递归到 \(u\) 的边原路返回。因为删除割边时,由于是无向图,反向边会被一起删除。
有一个坑:判断反向边时,不能直接判 \(v\) 是否为 \(u\) 的父节点,而应该存递归到 \(u\) 的边,判断 \(u\to v\) 是否为反向边,否则出现重边时会错误。因此使用链式前向星会比较方便。
int dfn[n+5],low[n+5],ans,d;
void dfs(int pos,int p,edge e[],int head[],int c[]){
low[pos]=dfn[pos]=++d;
for(int i=head[pos];i;i=e[i].nxt){
if(!dfn[e[i].v]){
dfs(e[i].v,i,e,head,c),low[pos]=min(low[pos],low[e[i].v]);
if(low[e[i].v]>dfn[pos])c[++ans]=i;
}
else if(i!=(p^1))low[pos]=min(low[pos],dfn[e[i].v]);
}
}
int tarjan(int n,edge e[],int head[],int c[]){
for(int i=1;i<=n;i++)if(!dfn[i])dfs(i,0,e,head,c);
return ans;
}
树上差分
一个图没有割边,那么可以看作每两个点之间都有回路。
引用 OI-wiki 的一张图:
在 dfs 树上,一条非树边与两个端点的树上路径形成一个环,那么这些边都不是割边。因此非树边(红边)和绿边(被非树边覆盖的边)不是割边,黑边(未被非树边覆盖的点)是割边。
具体实现时可以用树上差分优化打标记的过程。第一遍 dfs 打标记,若有非树边 \(u\to v\),由于无向图中只有树边和返祖边,可以直接对深度大的点 \(+1\),深度小的点 \(-1\)。第二遍 dfs 作子树和,有点 \(u\) 的子树和为 \(fa_u\to u\) 被非树边覆盖的次数。如果这个值为 \(0\) 即为割边。
int cnt[n+5],d[n+5],ans;
bool vis[n+5];
void dfs1(int pos,int p,edge e[],int head[]){
vis[pos]=1;
for(int i=head[pos];i;i=e[i].nxt){
if(vis[e[i].v]&&(i^1)!=p&&d[e[i].v]>d[pos])cnt[e[i].v]++,cnt[pos]--;
if(!vis[e[i].v])d[e[i].v]=d[pos]+1,dfs1(e[i].v,i,e,head);
}
}
void dfs2(int pos,int p,edge e[],int head[],int c[]){
vis[pos]=1;
for(int i=head[pos];i;i=e[i].nxt)if(!vis[e[i].v])dfs2(e[i].v,i,e,head,c),cnt[pos]+=cnt[e[i].v];
if(!cnt[pos])c[++ans]=p,c[++ans]=p^1;
}
int solve(int n,int m,edge e[],int head[],int c[]){
for(int i=1;i<=n;i++)if(!vis[i])dfs1(i,0,e,head);
memset(vis,0,sizeof(vis));
for(int i=1;i<=n;i++)if(!vis[i])dfs2(i,0,e,head,c);
return ans;
}
双连通分量
定义
若一张无向图没有割点,则称这张图是点双连通的;若一张无向图没有割边,则称这张图是边双连通的;
一张无向图的点双连通分量是极大的点双连通子图,一张无向图的边双连通分量是极大的边双连通子图。
Tarjan 求点双连通分量
类比强连通分量,一个点双连通分量中 \(dfn\) 最小的点一定是割点或树根。如果这个点是割点,点双不可能有点是其祖先,否则删除这个点,点双就不连通。矛盾。而树根是整个连通块中 \(dfn\) 最小的。
因此可以通过这个点确定点双。维护一个栈,当出现 \(low_v\geq dfn_u\),就把 \(u\) 的子树记为新的点双并出栈。此时 \(u\) 不出栈,因为这个点可能不止属于一个点双。
两个坑:
-
特判孤立点。
-
在输入时去掉自环。
int dfn[n+5],low[n+5],st[n+5],top,ans,d;
void dfs(int pos,vector<int>e[],vector<int>dcc[]){
low[pos]=dfn[pos]=++d,st[++top]=pos;
if(!e[pos].size()){
dcc[++ans].push_back(pos);
return;
}
for(int i=0;i<e[pos].size();i++){
if(!dfn[e[pos][i]]){
dfs(e[pos][i],e,dcc),low[pos]=min(low[pos],low[e[pos][i]]);
if(low[e[pos][i]]>=dfn[pos]){
dcc[++ans].push_back(pos);
while(st[top+1]!=e[pos][i])dcc[ans].push_back(st[top--]);
}
}
else low[pos]=min(low[pos],dfn[e[pos][i]]);
}
}
int tarjan(int n,vector<int>e[],vector<int>dcc[]){
for(int i=1;i<=n;i++)if(!dfn[i])dfs(i,e,dcc);
return ans;
}
DFS 求边双连通分量
一个显然的想法是求出割边,把割边断掉。然后进行第二次 DFS,此时的各个连通块就是边双。
int dfn[n+5],low[n+5],ans,d;
bool c[2*m+5];
void dfs1(int pos,int p,edge e[],int head[]){
low[pos]=dfn[pos]=++d;
for(int i=head[pos];i;i=e[i].nxt){
if(!dfn[e[i].v]){
dfs1(e[i].v,i,e,head),low[pos]=min(low[pos],low[e[i].v]);
if(low[e[i].v]>dfn[pos])c[i]=c[i^1]=1;
}
else if(i!=(p^1))low[pos]=min(low[pos],dfn[e[i].v]);
}
}
void dfs2(int pos,edge e[],int head[],vector<int>dcc[],int num[]){
dcc[ans].push_back(pos),num[pos]=ans;
for(int i=head[pos];i;i=e[i].nxt)if(!num[e[i].v]&&!c[i])dfs2(e[i].v,e,head,dcc,num);
}
int tarjan(int n,edge e[],int head[],vector<int>dcc[],int num[]){
for(int i=1;i<=n;i++)if(!dfn[i])dfs1(i,0,e,head);
for(int i=1;i<=n;i++)if(!num[i])ans++,dfs2(i,e,head,dcc,num);
return ans;
}
2-SAT 问题
2-SAT 问题给出 \(n\) 个变量,再给出 \(m\) 个关系,每个关系形如 \(a\) 为真/假可以推出 \(b\) 为真,求是否有一种方案能满足所有关系。
考虑图论建模。把每个变量拆成一真一假两个点,用连边表示一个变量可以推出另一个变量。图中每个强连通分量确定任意一个点都可以推出其他的点。如果出现了自相矛盾,即一个变量的两个点在同一个强连通分量则无解。
一个命题成立,那么逆否命题也成立,也需要连边。
for(int i=1,u,v,a,b;i<=m;i++)cin>>u>>a>>v>>b,e[u+(!a?n:0)].push_back(v+(b?n:0)),e[v+(!b?n:0)].push_back(u+(a?n:0));
输出方案时,每个变量只需要输出强连通分量编号更小的值。
正确性证明:只需要证明 \(b_A<b_{\neg A},A\Rightarrow B\) 时 \(b_B<b_{\neg B}\)。由于 Tarjan 是深搜,处理 \(A\) 时 \(B\) 一定处理完,即 \(b_B\leq b_A<b_{\neg A}\leq b_{\neg B}\),证毕。
[[图论]]

浙公网安备 33010602011771号