【学习笔记】Tarjan 算法与无向图连通性
【学习笔记】Tarjan 算法与无向图连通性
参考资料:李煜东 《算法竞赛进阶指南》
Part.1 无向图的割点与桥
在无向连通图 \(G = (V,E)\) 上。
割点
若对于一点 \(x \in V\) 从原图中删去 \(x\) 及其所有的相连边,图分裂成互不相连的两个子图,则 \(x\) 为原图的一个割点。
割边
若对于一条边 \(e \in E\) 从原图中删去 \(e\) ,图分裂成互不相连的两个子图,则 \(e\) 为原图的一条割边。
方法基础
无向图深度优先遍历 (DFS)。
时间戳
DFS 过程中,按照每个节点第一次被访问的时间顺序,依次付给编号 \([1,n]\) 的标记,记为 \(dfn[x]\) 。
搜索树
在无向图中任取一点开始 DFS,DFS 过程中所有发生过递归的边及其端点构成的树。
在一般的无向图(非联通)中,表现为每一个连通块内搜索树形成的森林。
追溯值
记为 \(low[x]\)
设 \(subtree(x)\) 表示搜索树中以 \(x\) 为根的子树。
\(low[x]\) 定义为以下节点中 \(dfn[v]\) 的最小值:
-
\(v \in subtree(x)\) 。
-
通过一条非搜索树边,可以到达 \(subtree(x)\) 的节点。
计算方法:
先令 \(low[x]=dfn[x]\)。
遍历从 \(x\) 出发的所有边 \((x,y)\)。
在搜索树上 \(x\) 是 \(y\) 的父节点,则 \(low[x]=min(low[x],low[y])\)。
在递归回溯后执行计算。
若 \((x,y)\) 不是搜索树上的边,则 \(low[x]=min(low[x],dfn[y])\)。
判断割边
边 \((x,y)\) 为割边,当且仅当搜索树上存在 \(x\) 的一个子节点 \(y\),满足:
\(dfn[x]
从定义知道,上面的式子翻译一下,就是以 \(y\) 为根的子树 “卷” 起来了,形成了内环,而 \((x,y)\) 就好像两个环之间的“桥”一样。
一个问题:
如果图中有重边,怎么办?
可以考虑对一对边记录其编号(特征码唯一标识),然后只忽略进入这一点所经过的边的编号,也可以采用 \(xor 1\) 的成对变换的方法。
参考例题:
POJ3177 / BZOJ1718 / AcWing395 Redundant Paths(冗余路径)
参考代码:
冗余路径
判桥,缩点(e-DCC),然后对于“桥树”上度为 \(1\) 的点,两个两个地配对相连即可。
#include
#define pb push_back
using namespace std;
const int maxn=5010,maxm=10010;
struct edge{
int to,ch;
};
int d[maxn];
vectorgo[maxn];
bool onb[maxn],isb[maxm];
int dfn[maxn],low[maxn],tim=0;
int stk[maxn],top=0,cnt=0,from[maxn];
int vis[maxm];
void tarjan(int nowa,int come){
low[nowa]=dfn[nowa]=++tim;
stk[++top]=nowa;
for(edge tmp:go[nowa]){
if(tmp.ch==come)continue;
if(!dfn[tmp.to]){
tarjan(tmp.to,tmp.ch);
low[nowa]=min(low[nowa],low[tmp.to]);
if(dfn[nowa]>n>>m;
int u,v;
for(int i=1;i<=m;i++){
cin>>u>>v;
//d[u]++;d[v]++;
go[u].pb(edge{v,i});
go[v].pb(edge{u,i});
}
tarjan(1,0);
int tot=0;
for(int i=1;i<=n;i++){
for(edge tmp:go[i]){
if(isb[tmp.ch]==0||vis[tmp.ch])continue;
vis[tmp.ch]=1;
d[from[i]]++;d[from[tmp.to]]++;
}
}
for(int i=1;i<=cnt;i++){
if(d[i]==1)tot++;
}
cout<
判断割点
若 \(x\) 不是搜索树的根,则 \(x\) 是割点当且仅当搜索树上存在 \(x\) 的一个子节点 \(y\),满足:
\(dfn[x] \leq low[y]\)
特别地,若 \(x\) 是根,那么需要存在至少两个子节点满足上面的条件。
证明方法:翻译一下,如果是割点,就是自己在一个卷起来的环上,而且还是这个环的连接点(像脖子一样)(取等号),或者自己有一条割边;如果是根的话,两个是为了保证切自己不是仅仅切掉一个点而已。
因为判断规则是小于等于,所以不考虑父节点和重边。
参考题目:BZOJ1123 / AcWing363 / Luogu3469 BLO(B城)
求割点,对于割点与非割点,有着不同的计算方法。非割点切开后除了他剩下都联通;而割点切开后分为几部分:满足判断条件的搜索子树,割点自己,还有剩下的连通块。
参考代码:
#include
#define pb push_back
#define ll long long
using namespace std;
const int maxn=100010;
int n,m;
vectorgo[maxn];
int cut[maxn];
ll ans[maxn];
int siz[maxn];
int dfn[maxn],low[maxn],tim=0;
void tarjan(int x){
siz[x]=1;
dfn[x]=low[x]=++tim;
int flag=0;int sum=0;
for(int to:go[x]){
if(!dfn[to]){
tarjan(to);
low[x]=min(low[x],low[to]);
siz[x]+=siz[to];
if(dfn[x]<=low[to]){
sum+=siz[to];
flag++;
ans[x]+=1ll*siz[to]*(n-siz[to]);
}
}
else{
low[x]=min(low[x],dfn[to]);
}
}
if(x==1&&flag>=2){
ans[x]+=1ll*(n-1)+1ll*(n-sum-1)*(sum+1);
}
else if(x!=1&&flag){
ans[x]+=1ll*(n-1)+1ll*(n-sum-1)*(sum+1);
}
else{
ans[x]=2*(n-1);
}
}
int main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);cout.tie(nullptr);
cin>>n>>m;
int u,v;
for(int i=1;i<=m;i++){
cin>>u>>v;
go[u].pb(v);go[v].pb(u);
}
tarjan(1);
for(int i=1;i<=n;i++)cout<
Part.2 无向图的双联通分量
点双连通图
若一张无向连通图不存在割点,则称它为“点双连通图”。
无向图的极大点双联通子图称为“点双连通分量”,简记为 “v-DCC”。
边双连通图
若一张无向连通图不存在割边,则称它为“边双联通图”。
无向图的极大边双联通子图称为“边双联通分量”,简记为 “e-DCC”。
二者统称为 “DCC”。
注:“极大”指对于一个 DCC: \(G'=(V',E') ,G' \subseteq G\);$ \nexists G''=(V'',E'') ,G'' \subseteq G,G' \subseteq G''$ 使得 \(G''\) 也是一个 DCC。
定理
一张无向连通图是“点双连通图”,当且仅当满足下列两个条件之一:
-
图的顶点数不超过 2
-
图中任意两点都同时包含在至少一个简单环中,
一张无向连通图是“边双连通图”,当且仅当任意一条边都包含在至少一个简单环中。
证明
To:“点双连通图”
顶点数不超过 2,根据定义,显然成立。
充分性:若任意两点 \(x,y\) 都同时包含在至少一个简单环中,则 \(x,y\) 之间至少有两条不相交的路径,无论从图中删去哪一个节点,\(x,y\) 之间仍然可以相连(想一想前面的 BLO 那道题),所以图中没有割点。
必要性:反证法,假设一张无向连通图是“点双连通图”,并且存在两点 \(x,y\) 他们不同时处于任何一个简单环中。
如果 \(x,y\) 之间仅存在 1 条简单路径,那么肯定存在割点,与定义矛盾。
如果 \(x,y\) 之间存在 2 条及以上的简单路径,那么除了 \(x,y\) 这两个交点,任意两条路径都必须至少有一个交点,进而得知他们肯定至少都交于一点,那该交点根据定义就是一个割点,与定义矛盾;如果不相交,就会出现简单环,就与假设矛盾了。
To:“边双连通图”
充分性:如果所有边都包含在至少一个简单环中,那么无论切去哪条边,剩下的所有节点仍然可以连通,故不存在割边。
必要性:反证法,假设一张无向连通图是“边双连通图”,并且存在一条不包含在任意一个简单环中的边。
那么它的端点 \(x,y\) 也不包含在任意一个简单环中,所以这条边是 \(x,y\) 之间的唯一通路,切去这条边以后,\(x,y\) 就不连通了,那么这条边就是割边,与定义矛盾。
e-DCC 的求法
对无向图进行判桥,然后把桥挖掉,剩下的就是无向图中全部的 e-DCC。
e-DCC 的缩点
先说一句,挖一个坑,听说 LCT 可以动态地在只加边的情况下完成这个过程,以后做 LCT 的时候再填坑。
很简单,把每个 e-DCC 看成一个大点,内部的边不要,只留下链接两个不同 e-DCC 之间的边,建一个新图,新图的形态是一棵树或森林。
v-DCC 的求法
维护一个栈,用以下方法维护栈中的元素:
-
当一个节点首次被访问时,把该节点入栈。
-
当割点判定法则中的条件 \(dfn[x] \leq low[y]\) 成立时,无论 \(x\) 是否为根,都要从栈中不断弹出节点直到节点 \(y\) 被弹出;且刚才弹出的所有节点和节点 \(x\) 一起构成一个 v-DCC 。
v-DCC 的缩点
因为一个节点可能属于多个 v-DCC,设图中有 \(p\) 个割点和 \(t\) 个 v-DCC。然后建立一张包含 \(p+t\) 个节点的新图,把每个 \(v-DCC\) 和每个割点都作为新图中的节点,并且在每个割点和包含它的所有 v-DCC 之间连边。
建成的新图也是一棵树或森林。
参考题目: POJ3694 Network / AcWing364 网络
第一眼看到这狗东西,这不是我以前看见过的一个 LCT 在只加边的情况下动态维护无向图桥数量的题目吗?
然后一看数据范围,缩点,纯暴力标记法,可以过。
但我们不能局限于暴力,我们要优化。
先缩一个点,然后建一棵树,拎起来,预处理好树的父子结构和深度。
对于每个节点,开一个并查集(带路径压缩),初始 \(fa[x]\) 就是自己,表示所属的 e-DCC 集合。
每次来一个询问,若当前两个节点属于同一个 e-DCC (用 \(find(x)\) 找),那就忽略。
如果不属于同一个 e-DCC ,那么像 LCA 暴力上跳法一样,对于两个节点所属的 e-DCC ,在树上跳到父亲,且每次把当前节点的 \(fa[x]\) 指向自己在原树上的父亲,之后就可以愉快的路径压缩了。此过程中,统计跳过的边数,这些边就不再是桥了。
参考代码:
#include
#define pb push_back
using namespace std;
const int maxn=100010,maxm=200010;
int n,m;
struct edge{
int to,ch;
};
int vis[maxm];
vectorgo[maxn];
int dfn[maxn],low[maxn],isb[maxm],tim=0,cnt=0;
int stk[maxn],top=0;
vectorng[maxn];
int fa[maxn],f[maxn],dep[maxn];
int from[maxn];
int getfa(int x){
return (fa[x]==x)?(x):( fa[x]=getfa(fa[x]) );
}
int jump(int x,int y){
int res=0;
while(getfa(x)!=getfa(y)){
if(dep[getfa(x)]dfn[x]){
isb[tmp.ch]=1;
}
}
else{
low[x]=min(low[x],dfn[tmp.to]);
}
}
if(dfn[x]==low[x]){
int tmp;
cnt++;
do{
tmp=stk[top--];
from[tmp]=cnt;
}while(tmp!=x);
}
}
int main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);cout.tie(nullptr);
int cas=0;
while(cin>>n>>m){
if(n==0&&m==0)break;
for(int i=1;i>u>>v;
go[u].pb(edge{v,i});
go[v].pb(edge{u,i});
}
tarjan(1,0);
int ans=0;
for(int i=1;i<=n;i++){
for(edge tmp:go[i]){
if(vis[tmp.ch]||isb[tmp.ch]==0)continue;
vis[tmp.ch]=1;
ans++;
ng[from[i]].pb(from[tmp.to]);
ng[from[tmp.to]].pb(from[i]);
}
}
dfs(1,0);
int q;
cin>>q;
cout<<"Case "<<++cas<<":"<>u>>v;
if(getfa(from[u])!=getfa(from[v]))
ans-=jump(getfa(from[u]),getfa(from[v]));
cout<
参考题目:POJ2942 / AcWing365 圆桌骑士
就是找图中所有不属于任何一个“奇环”(奇数个点构成的简单环)的点总数。
求出图中的所有 v-DCC ,然后对于每个 v-DCC 进行染色判定是否有奇环。
一个 v-DCC 中有奇环,那么其中所有点都包含在至少一个奇环内。
注意一下效率与常数问题,这是我久违地不使用任何 STL,还开了 C++98 卡常的代码了。
参考代码:
#include
#include
#define si short int
#define re register
const si maxn=1010;
const int maxm=1000010;
const char z='0';
si inr[maxn];
si n;int m;
struct edg{
si u,v;
}e[maxm];
bool g[maxn][maxn];
si stk[maxn],top;
si dfn[maxn],low[maxn],tim;
bool dcc[maxn];
si ind[maxn],hed;
inline void init(){
re si u,v;
for(re int i=1;i<=m;++i){
u=e[i].u,v=e[i].v;
g[v][u]=g[u][v]=true;
}
tim=0;
for(re si i=1;i<=n;++i){
dfn[i]=low[i]=inr[i]=0;
}
}
inline si in(){
re si res=0;
re char tp=getchar();
while(!isdigit(tp))tp=getchar();
while(isdigit(tp))res=res*10+tp-z,tp=getchar();
return res;
}
inline int inm(){
re int res=0;
re char tp=getchar();
while(!isdigit(tp))tp=getchar();
while(isdigit(tp))res=res*10+tp-z,tp=getchar();
return res;
}
inline si minn(const si &x,const si &y){
return (x=dfn[x]){
re si tmp;
hed=0;
do{
tmp=stk[top--];
dcc[tmp]=true;
ind[++hed]=tmp;
}while(tmp!=y);
dcc[x]=true;
ind[++hed]=x;
re si p;
re bool res=!judge(x,0);
while(hed){
p=ind[hed];
dcc[p]=false;
inr[p]|=res;
col[p]=-1;
--hed;
}
}
}
else low[x]=minn(low[x],dfn[y]);
}
}
int main(){
//memset(col,0xcf,sizeof(col));
for(re si i=1;i
之后的习题:
P3225 【HNOI2012】矿场搭建
求 v-DCC,分类讨论:
1.无割点,那么得设两个逃生口互相保证。
2.一个割点,那么在非割点处设一个逃生口,割点没了就从自己这里除去,逃生口没了就去别的 v-DCC 逃生。
3.两个割点,不用设,任意一个割点塌了都可以从另一个去别的 v-DCC 逃生。
方案数就是所有 v-DCC 的方案贡献相乘。
参考代码:
#include
#define pb push_back
#define ull unsigned long long
using namespace std;
int n;
int maxx;
vectorgo[1010];
vectorvdcc[1010];int tot;
int gd[1010];
int dfn[1010],low[1010],rt,ord;
int sta[1010],top=0;
void tarjan(int x){
low[x]=dfn[x]=++ord;
sta[++top]=x;
int is=0;
for(int to:go[x]){
if(!dfn[to]){
tarjan(to);
low[x]=min(low[x],low[to]);
if(dfn[x]<=low[to]){
is++;tot++;
if(x!=rt||is>1)gd[x]=1;
int tmp=0;
do{
tmp=sta[top--];
vdcc[tot].pb(tmp);
}while(tmp!=to);
vdcc[tot].pb(x);
}
}
else{
low[x]=min(low[x],dfn[to]);
}
}
//if(x==rt&&is>=2)gd[x]=1;
//else if(is>=1)gd[x]=1;
}
void initialize(){
maxx=0;
tot=0;
ord=0;
top=0;
for(int i=1;i<=1000;i++){
go[i].clear();
vdcc[i].clear();
}
memset(gd,0,sizeof(gd));
memset(dfn,0,sizeof(dfn));
memset(low,0,sizeof(low));
}
int main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);cout.tie(nullptr);
int cas=0;
while(cin>>n&&n){
cas++;
initialize();
ull ans1=0,ans2=1;
int u,v;
for(int i=1;i<=n;i++){
cin>>u>>v;
maxx=max(u,maxx);
maxx=max(v,maxx);
go[u].pb(v);
go[v].pb(u);
}
// for(int i=1;i<=maxx;i++){
//
// if(!dfn[i]){
// rt=i;
// tarjan(i);
// }
//
// }
rt=1;
tarjan(1);
for(int i=1;i<=tot;i++){
int gdt=0;
ull siz=vdcc[i].size();
for(int tmp:vdcc[i]){
if(gd[tmp])gdt++;
}
if(gdt==0){
ans1+=2;
ans2*=(siz*(siz-1ull)/2ull);
}
else if(gdt==1){
ans1++;
ans2*=(siz-1ull);
}
}
cout<<"Case "<
CF700C Break Up
找到任意一条从 \(s\) 到 \(t\) 的合法路径,枚举其上面的边,作为第一条删边,因为第二条删边不存在或者在另一条删去第一条边的合法路径上,或者答案就为 \(-1\) ,所以取任意一条路径是正确的。
第二次也任意取一条从 \(s\) 到 \(t\) 的合法路径,若答案存在,则不可能取到另一条起点为 \(s\) 终点为 \(t\) 的不相交合法路径(否则就没有割边了)。
若取不到这条合法路径,就说明已经不连通,只切一条边就够了。
然后对删边以后的图 \(tarjan\) 求割边,第二次找到的合法路径上的割边就是第二次的删边。
注意细节
参考代码:
#include
#define pb push_back
using namespace std;
const int maxn=1010,maxm=30010;
int n,m,s,t;
int ans=0x7fffffff;
int c;
int s1,s2;
struct edge{
int to,len,ord;
};
int u[maxm],v[maxm],w[maxm];
vectorgo[maxn];
int vis[maxn];
bool dfs(int x,int fa,int del,vector&path){
vis[x]=1;
if(x==t)return true;
for(edge tmp:go[x]){
if(tmp.to==fa||vis[tmp.to]||tmp.ord==del)continue;
if(dfs(tmp.to,x,del,path)){
path.pb(tmp.ord);
return true;
}
}
return false;
}
int dfn[maxn],low[maxn],gb[maxm],seq=0;
//int stk[maxn],top=0;
void tarjan(int x,int come,int del){
//stk[++top]=x;
low[x]=dfn[x]=++seq;
for(edge tmp:go[x]){
if(tmp.ord==del||tmp.ord==come)continue;
if(!dfn[tmp.to]){
tarjan(tmp.to,tmp.ord,del);
low[x]=min(low[x],low[tmp.to]);
if(low[tmp.to]>dfn[x]){
gb[tmp.ord]=1;
}
}
else{
low[x]=min(low[x],dfn[tmp.to]);
}
}
}
int main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);cout.tie(nullptr);
cin>>n>>m;
cin>>s>>t;
for(int i=1;i<=m;i++){
cin>>u[i]>>v[i]>>w[i];
go[u[i]].pb((edge){v[i],w[i],i});
go[v[i]].pb((edge){u[i],w[i],i});
}
vectorpath;
path.clear();
if(!dfs(s,0,0,path)){
cout<<"0"<np;
np.clear();
memset(vis,0,sizeof(vis));
if(!dfs(s,0,now,np)){
if(w[now]<=ans){
ans=w[now];
c=1;
s1=now;
}
continue;
}
seq=0;
memset(dfn,0,sizeof(dfn));
memset(low,0,sizeof(low));
memset(gb,0,sizeof(gb));
tarjan(s,0,now);
for(int tmp:np){
if(!gb[tmp])continue;
if(w[now]+w[tmp]=0x7fffffff)cout<<"-1"<