Skip to main content

连通性

Summary

围绕 Tarjan 的 dfn 与 low 展开一系列连通性问题。强连通分量是有向图中两两互达的极大子图,缩点后原图变为 DAG。无向图的双连通分量分边双与点双,前者删去任一条边仍连通,后者删去任一个点仍连通。割点是删去后连通分量增多的点,桥则是删去后连通分量增多的边。圆方树把点双结构转成一棵树,便于处理仙人掌。2-SAT 把每个变量拆成真假两点建蕴含图,跑 SCC 判断可满足性并构造赋值。各部分均附参考代码。

参考资料​

强连通分量(SCC)​

强连通分量(Strongly Connected Component,SCC)是有向图的极大子图,其中任意两点互相可达。把每个 SCC 缩成一点后,原图变为有向无环图(DAG)。

Tarjan 算法​

Tarjan 用一次 DFS 求出所有 SCC。dfnu\mathrm{dfn}_u 为 uu 的 DFS 序,lowu\mathrm{low}_u 为 uu 经搜索树边及至多一条返祖边能到达的最小 dfn\mathrm{dfn}。DFS 时把点压栈,当 lowu=dfnu\mathrm{low}_u=\mathrm{dfn}_u 时,栈中 uu 及其上方的点构成一个 SCC。时间复杂度为 O(n+m)O(n+m)。

413 Bcpp
vector<int> G[N];
int dfn[N],low[N],sccno[N];
int cnt=0,scc_cnt=0;
stack<int> s;
void tarjan(int u)
{
low[u]=dfn[u]=++cnt;
s.push(u);
for(auto v:G[u])
{
if(!dfn[v])
{
tarjan(v);
low[u]=min(low[u],low[v]);
}
else if(!sccno[v])
{
low[u]=min(low[u],dfn[v]);
}
}
if(low[u]==dfn[u])
{
scc_cnt++;
while(1)
{
int x=s.top();
s.pop();
sccno[x]=scc_cnt;
if(x==u)break;
}
}
}

双连通分量(BCC)​

无向图的 双连通分量 分两种:边双连通分量(删去任意一条边后仍连通)与 点双连通分量(删去任意一个点后仍连通)。两者都借助 Tarjan 的 dfn/low\mathrm{dfn}/\mathrm{low} 求出。

边双连通分量​

边双内任意两点间存在两条无公共边的路径。求法:Tarjan 时不沿来时的边返回(用边编号 kk 区分重边),lowu=dfnu\mathrm{low}_u=\mathrm{dfn}_u 时弹栈得到一个边双。满足 lowv>dfnu\mathrm{low}_v>\mathrm{dfn}_u 的边 (u,v)(u,v) 即为桥。

965 Bcpp
#include <bits/stdc++.h>
using namespace std;

const int N=500005;
vector<pair<int,int>> G[N];
int dfn[N],low[N],bccno[N];
int cnt=0,bcc_cnt=0;
stack<int> s;
void tarjan(int u,int k)
{
low[u]=dfn[u]=++cnt;
s.push(u);
for(auto [v,i]:G[u])
{
if(!dfn[v])
{
tarjan(v,i);
low[u]=min(low[u],low[v]);
}
else if(i!=k)
{
low[u]=min(low[u],dfn[v]);
}
}
if(low[u]==dfn[u])
{
bcc_cnt++;
while(1)
{
int x=s.top();
s.pop();
bccno[x]=bcc_cnt;
if(x==u)break;
}
}
}
vector<int> ans[N];
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n,m;
cin>>n>>m;
for(int i=1;i<=m;i++)
{
int u,v;
cin>>u>>v;
G[u].push_back({v,i});
G[v].push_back({u,i});
}
for(int i=1;i<=n;i++)
{
if(!dfn[i])tarjan(i,-1);
}
for(int i=1;i<=n;i++)
{
ans[bccno[i]].push_back(i);
}
cout<<bcc_cnt<<'\n';
for(int i=1;i<=bcc_cnt;i++)
{
cout<<ans[i].size()<<' ';
for(auto x:ans[i])cout<<x<<' ';
cout<<'\n';
}
return 0;
}

点双连通分量​

点双内任意两点间存在两条除端点外无公共点的路径。求法:DFS 中当子节点 vv 满足 lowv≥dfnu\mathrm{low}_v\ge\mathrm{dfn}_u 时,把栈中直到 vv 的点连同 uu 一起取出,构成一个点双;孤立点单独算一个点双。

996 Bcpp
#include <bits/stdc++.h>
using namespace std;

const int N=500005;
vector<int> G[N];
int dfn[N],low[N];
int cnt=0,bcc_cnt=0;
stack<int> s;
vector<int> ans[N];
void tarjan(int u,int fa)
{
low[u]=dfn[u]=++cnt;
s.push(u);
int son=0;
for(auto v:G[u])
{
if(!dfn[v])
{
son++;
tarjan(v,u);
low[u]=min(low[u],low[v]);
if(low[v]>=dfn[u])
{
bcc_cnt++;
while(1)
{
int x=s.top();
s.pop();
ans[bcc_cnt].push_back(x);
if(x==v)break;
}
ans[bcc_cnt].push_back(u);
}
}
else if(v!=fa)
{
low[u]=min(low[u],dfn[v]);
}
}
if(fa==0&&son==0)ans[++bcc_cnt].push_back(u);
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n,m;
cin>>n>>m;
while(m--)
{
int u,v;
cin>>u>>v;
G[u].push_back(v);
G[v].push_back(u);
}
for(int i=1;i<=n;i++)
{
if(!dfn[i])tarjan(i,0);
}
cout<<bcc_cnt<<'\n';
for(int i=1;i<=bcc_cnt;i++)
{
cout<<ans[i].size()<<' ';
for(auto x:ans[i])cout<<x<<' ';
cout<<'\n';
}
return 0;
}

割点和桥​

割点(cut vertex)是删去后使连通分量增多的点,桥(bridge)是删去后使连通分量增多的边。用 Tarjan 求割点:非根节点 uu 若存在子节点 vv 满足 lowv≥dfnu\mathrm{low}_v\ge\mathrm{dfn}_u,则 uu 为割点;根节点当且仅当它在搜索树中有至少两棵子树时为割点。

761 Bcpp
#include <bits/stdc++.h>
using namespace std;

const int N=100005;
vector<int> G[N];
int dfn[N],low[N],cnt=0;
bool cut[N];
void tarjan(int u,int rt)
{
dfn[u]=low[u]=++cnt;
int son=0;
for(auto v:G[u])
{
if(!dfn[v])
{
tarjan(v,rt);
low[u]=min(low[u],low[v]);
if(low[v]>=dfn[u]&&u!=rt)cut[u]=1;
if(u==rt)son++;
}
else low[u]=min(low[u],dfn[v]);
}
if(u==rt&&son>=2)cut[u]=1;
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n,m;
cin>>n>>m;
while(m--)
{
int u,v;
cin>>u>>v;
G[u].push_back(v);
G[v].push_back(u);
}
for(int i=1;i<=n;i++)if(!dfn[i])tarjan(i,i);
vector<int> res;
for(int i=1;i<=n;i++)if(cut[i])res.push_back(i);
cout<<res.size()<<'\n';
for(auto x:res)cout<<x<<' ';
cout<<'\n';
return 0;
}

圆方树​

圆方树 把无向图的点双连通分量结构转化为一棵树:原图的点为「圆点」,每个点双新建一个「方点」连向其中所有圆点。原图上与两点间路径相关的问题,在圆方树上变成树上路径问题,常用于处理仙人掌(每条边至多在一个环上的图)。

2-SAT​

SAT 即布尔可满足性(Satisfiability)问题。kk-SAT 在 k>2k>2 时是 NP 完全的,OI 中只研究 k=2k=2 的 2-SAT:给定 nn 个布尔变量与若干形如「xi=ax_i=a 或 xj=bx_j=b」的约束,求一组可行赋值。

把每个变量拆成「真」「假」两个节点。约束「xi=ax_i=a 或 xj=bx_j=b」等价于两条蕴含边:若 xi≠ax_i\ne a 则必有 xj=bx_j=b,若 xj≠bx_j\ne b 则必有 xi=ax_i=a。对蕴含图跑 Tarjan 求 SCC:若某变量的真、假节点落在同一 SCC 则无解;否则每个变量取 SCC 编号较小(拓扑序较后)的节点对应的值,即为一组可行解。

978 Bcpp
#include <bits/stdc++.h>
using namespace std;

const int N=2000005;
vector<int> G[N];
int dfn[N],low[N],sccno[N],cnt=0,scc_cnt=0;
stack<int> s;
bool ins[N];
void tarjan(int u)
{
dfn[u]=low[u]=++cnt;
s.push(u);ins[u]=1;
for(auto v:G[u])
{
if(!dfn[v]){tarjan(v);low[u]=min(low[u],low[v]);}
else if(ins[v])low[u]=min(low[u],dfn[v]);
}
if(low[u]==dfn[u])
{
scc_cnt++;
while(1)
{
int x=s.top();s.pop();ins[x]=0;
sccno[x]=scc_cnt;
if(x==u)break;
}
}
}
int n,m;
int f(int i,int v){return v?i:i+n;}
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin>>n>>m;
while(m--)
{
int a,va,b,vb;
cin>>a>>va>>b>>vb;
G[f(a,1-va)].push_back(f(b,vb));
G[f(b,1-vb)].push_back(f(a,va));
}
for(int i=1;i<=2*n;i++)if(!dfn[i])tarjan(i);
for(int i=1;i<=n;i++)
{
if(sccno[i]==sccno[i+n])
{
cout<<"IMPOSSIBLE"<<'\n';
return 0;
}
}
cout<<"POSSIBLE"<<'\n';
for(int i=1;i<=n;i++)cout<<(sccno[i]<sccno[i+n])<<' ';
cout<<'\n';
return 0;
}

例题​

每头奶牛都梦想成为牛棚里的明星。被所有奶牛喜欢的奶牛就是一头明星奶牛。所有奶牛都是自恋狂,每头奶牛总是喜欢自己的。奶牛之间的「喜欢」是可以传递的——如果 AA 喜欢 BB,BB 喜欢 CC,那么 AA 也喜欢 CC。牛栏里共有 NN 头奶牛,给定一些奶牛之间的爱慕关系,请你算出有多少头奶牛可以当明星。

对于一个 nn 个节点 mm 条无向边的图,请输出其边双连通分量的个数,并且输出每个边双连通分量。

对于一个 nn 个节点 mm 条无向边的图,请输出其点双连通分量的个数,并且输出每个点双连通分量。

给定一个 nn 个点 mm 条边有向图,每个点有一个权值,求一条路径,使路径经过的点权值之和最大。你只需要求出这个权值和。

允许多次经过一条边或者一个点,但是,重复经过的点,权值只计算一次。

给出一个 nn 个点,mm 条边的无向图,求图的割点。

有 nn 个布尔变量 x1∼xnx_1\sim x_n,另有 mm 个需要满足的条件,每个条件的形式都是「xix_i 为 true / false 或 xjx_j 为 true / false」。例如「x1x_1 为真或 x3x_3 为假」、「x7x_7 为假或 x2x_2 为假」。

2-SAT 问题的目标是给每个变量赋值使得所有条件得到满足。

给定一棵边带权的仙人掌图,qq 次询问两点间的最短路。