强、双连通学习笔记
updata:修正错误
有向图强连通分量
有向图强连通分量就是在一个强连通分量里面,每个点都能到达分量里面其他所有点。(很明显的是每个点只能属于一个分量)
那么,如何求?
Tarjan算法
定义
我们定义一个low数组与一个dfs数组与一个ts(时间戳,不需要过多理解,下文看了就知道功能了)
做法
先说DFS树,Tarjan算法实际上是利用了DFS树和时间戳进行的寻找联通分量的算法。
一个图的DFS树是什么,对一个图进行DFS,每个点只能遍历一遍,走过的边和点构成了一棵树,例如下面这个例子。
时间戳是什么,你可以理解为访问这个点的时间,我们有个\(tim\)变量,每次访问一个点,就把\(tim++\),因此不难发现,一个点访问的早,时间戳也早。
好,那么我们进入到DFS的世界中去:
(其中,边权为\(1\)的边表示不是DFS树中的边,有一条边有两个箭头的,原本是想两条有向边的,但是画图网站直接给我合并了。。。)
这里我们对于每个点开两个变量,一个\(dfn\),储存时间戳,\(low\)刚开始储存时间戳,在中间可能更新,具体作用后面讲。
好,在DFS中,我们总是会遇到以下两种情况:
- 这个点访问过了,实际上就是已经有了时间戳。
- 这个点没有被访问过,表现上就是这个点没有时间戳。
第二种情况直接访问即可,因此我们要针对的是第一种情况。
对于\(4->2\)这条边,表现上是\(DFS\)树中儿子连向父亲,我们称其为\(A\)类边,那么代表这个儿子和父亲实际上是一个强连通分量。
对于\(2->4\)这条边,表现上是\(DFS\)树中父亲连向已经访问过的儿子,我们称其为\(B\)类边,那么这个儿子有没有可能跟这个这个父亲同一个分量呢?因为一个分量中的点能互相到达,所以默认是这个儿子来访问爸爸,而不是爸爸访问儿子,所以这个边我们先暂时不理会他。
对于\(3->5\)这条边,实际表现是一个子树连向另外一个子树,我们称其为\(C\)类边,这种情况比较复杂,所以我们将在下文的分析后,开始这三条边的分析。
(为了方便下文讨论,我们称第二种情况的边是\(D\)类边)
对于一个联通分量,两个点必定能互相访问(但是需要注意的是,\(x->y\)的路径和\(y->x\)的路径可能有相同的边,比如:\(1->2,2->3,3->4,3->1,4->2\)),加入这个分量中有一个点最先被访问,我们设他是\(fa\)吧,好,这个时候\(low\)派上了用场,如果一个点\(dfn==low\),那么其是\(fa\)点,反之不是。
先假如图中只有一个联通分量,那么有以下几个引理:
-
每个点都会被访问到。
证明:如果\(x\)没有被访问到,假设\(fa\)到\(x\)的路径是:\(fa->a_1->a_2->...->a_k->x\),那么\(a_k\)是会访问\(x\)的,什么?\(a_k\)也没有被访问,看\(a_{k-1}\),一直看下去,\(fa\)总被访问了吧。
-
一个分量的DFS树不一定只是一条链。
你问我这个怎么证明?构造法啊。
看,如果先更新了\(3\),那么\(DFS\)树就变成了\(1\)有两个儿子\(2,3\)。
-
如果\(x\)有一条边指向已经被访问过的点\(y\),我们采用\(low[x]=min(low[y],low[x])\)的更新方式,且如果\(x\)访问到未访问点\(y\),在\(y\)完成\(DFS\)之后,也执行上述语句,那么除了\(fa\)点外,每个点都不可能\(low==dfn\)。
证明:假如一个点,如果一个点,或者其DFS子树中有一个点可以到达\(fa\),那么这个点绝对不可能是\(low==dfn\),那么对于一个点\(x\),其一定存在一条到\(fa\)的路径,但是为什么其子树没有呢?还记得\(2\)中的例子吗,说明其路径中有点\(y\)已经被访问过了,如果假设\(y\)的\(low\)不等于\(y\)的\(dfn\),因为\(y\)比\(x\)先访问,所以\(x\)也满足要求,而刚开始\(DFS\)时,第一个访问到已访问点的点,一定满足\(low\)不等于\(dfn\)。
但实际上,\(low[x]=min(low[x],dfn[y])\)也不会错(但是需要注意,\(D\)类边的更新方式永远都是\(low[x]=min(low[y],low[x])\)),因为已访问点\(y\)比\(x\)先访问,所以也可以满足条件。
但是在实际使用中,是不可能只有一个联通分量的,因此,我们可以把一个强连通分量看成一个点,那么整个图就会变成DAG图,这个方法叫做缩点。
如这个例子,把左边变成了右边。
我们在\(DFS\)中,我们搜到一个分量中的一个点,上文讲了,称这个点为\(fa\),那么所有这个分量的点都在其子树中,还记得上面的三条边吗。
\(A\)类边其实就是更新\(low\)的途径。
\(B\)类边毫无作用,因为如果这个儿子跟我是同一个分量,设这条边为\(x->z\),则\(z\)在\(x\)的DFS树上的一个儿子\(y\)的子树中,那么可以直接通过\(y\)完成更新,完全没有必要考虑这种情况,但是考虑了也不会错,因此在打代码的过程中可以直接考虑,减少代码量(不难发现,在\(min(low[x],dfn[y])\)和\(min(low[x],low[y])\)两种更新模式都不会错)。
\(C\)类边,上文说了比较复杂,如:\(x->y\),有可能\(x,y\)不是同一个分量的,那么\(y\)已经被先访问了,时间戳也比\(x\)小,那不就更新了吗?但是\(y\)所在的分量的\(fa\)节点是否已经结束\(DFS\)了呢,如果没有,说明\(y\)所在分量的\(fa\)是\(x\)在DFS树中的父亲,换而言之,\(x\)可以到达其父亲节点,那么\(x\)确实不可能是\(fa\)节点,而且是和\(y\)是同一个分量的矛盾,所以其\(fa\)一定已经停止\(DFS\)了(一个分量的\(fa\)停止了思考,表示这个分量的点都被找过了)。因此我们可以设置\(be\)数组,表示这个点所属的分量的\(fa\)节点是否已经停止了思考,然后只用\(be\)为\(0\)的点更新自身即可。
\(D\)类边,只需要担心\(x\)在其\(DFS\)子树中如果有不是跟\(x\)同个联通块的怎么处理。
需要证明一个东西,一个联通块的除\(fa\)以外的点的父亲节点都是这个联通块的点,根据引理1,每个点都会被访问,如果这个点被非联通块的点\(x\)访问了,那么\(fa\)可以到\(x\),\(x\)可以到这个点,那么这个联通块的每个点都可以通过\(fa\)到\(x\),\(x\)可以通过这个点到每个点,那么\(x\)也是这个联通块的点,矛盾,证毕。
所以如果\(x\)通过\(D\)类边访问到了\(y\),\(y\)肯定是\(fa\)节点,假设\(y\)的\(low==dfn\),那么\(x\)也肯定是正确的啦,而\(x\)的子树中一定存在一个\(fa\)点,其子树中不存在\(fa\)点,但是这个\(fa\)点一定满足要求吗?但是对于这个点而言\(ABC\)类边都是正确的,所以这个\(fa\)点也是正确的,证毕。
至于如何处理\(be\)吗,考虑到一个一个分量在\(DFS\)中的连续性,我们可以用栈储存,访问时,加入栈,当\(fa\)点回溯时,把\(fa\)点以及\(fa\)点以上的点全部弹出,根据数学归纳法,一定存在一个\(fa\),其子树的不存在\(fa\),这样肯定可以,而且访问\(fa\)之前和访问\(fa\)之后的栈是一样的,因此可以不断的把底层的分量删除,最终删完整个图。(其实细心的人不难发现,这不是缩点后的DAG的拓扑排序吗,这也是为什么说联通块编号是反着的拓扑序的原因)
low,dfn的相等情况证明
那么为什么\(x\)的\(low\)不一定等于分量中\(fa\)的\(dfn\)呢?
这还不简单,构造法啊。
如果\(2\)先访问了\(4\),那么\(4\)的\(low\)并不会等于\(1\),当然,这并不代表我们的做法是错的,只是单纯的因为\(1\)到\(4\)再回到\(1\)必须经过\(4\)而已,当然点双需要注意这个东西(后文会讲)。
扩展芝士:
当然,现在我们尝试证明存在\(fa->x->fa\)的一条路径使得路径上没有一个点重复走过,是\(low[x]=dfn[x]\)的充分不必要条件。(在\(min(low[x],low[y])\)的更新条件下)
充分性:
这样子的话,设路径为\(fa->a_1->a_2->...->a_q->x->b_1->...->b_p->fa\),然后\(x\)访问到了\(b_i\),发现\(b_i\)访问过了,就把\(x\)换成\(b_i\),最终会换成一个\(b_j\),使得访问\(b_j\)时,其子树中的一个点走过\(fa\)。
不必要性:
也就是证明\(low[x]=dfn[x]\)时,存在\(fa->x->fa\)的一条路径使得路径上没有一个点重复走过不成立,证明逆否命题是错的。
不存在\(fa->x->fa\)的一条路径使得路径上没有一个点重复走过时,则\(low[x]≠dfn[x]\)。
事实上:
这张图中,如果\(2\)优先访问了\(3\),什么事都没有。
证毕
当然,至于\(min(low[x],dfn[y])\)的更新方式吗。懒得想
DFS处理问题
需要注意的一件事情是,一次\(DFS\)不一定可以搞出所有的点,所以需要判断每个点有没有\(DFS\)过,当然,也可以设置一个超级根节点,把这个点跟所有点连起来。
代码
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
int belong[61000],dfn[61000],cnt,low[61000],n,m,id;
struct node
{
int y,next;
}a[210000];int len,last[61000];
void ins(int x,int y)
{
len++;
a[len].y=y;a[len].next=last[x];last[x]=len;
}//边目录
int sta[61000],p;//栈
bool v[61000];//所在的分量的fa找完没?其实就是上文的be数组
void dfs(int x)
{
dfn[x]=low[x]=++id;sta[++p]=x;
for(int k=last[x];k;k=a[k].next)
{
int y=a[k].y;
if(dfn[y]==0)
{
dfs(y);//便历
low[x]=min(low[x],low[y]);
}
else if(v[y]==false)low[x]=min(low[x],dfn[y]);//low[x]=min(low[x],low[y]);也不会错
}
if(low[x]==dfn[x])
{
int now=0;cnt++;
do
{
now=sta[p--];
v[now]=true;//找完了
belong[now]=cnt;//所在的分量
}while(now!=x && p>0);
}
}
int main()
{
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++)
{
int x,y;scanf("%d%d",&x,&y);
ins(x,y);
}
for(int i=1;i<=n;i++)
{
if(dfn[i]==0)dfs(i);//遍历
}
printf("%d\n",cnt);
return 0;
}
练习
1
这道题目难度有点大,我们做一遍Tarjan算法,然后把每个强连通分量当成一个点,计算每个点的入度与出度,我们需要知道,为什么这些点(我们已经把所有强连通分量缩点了)不在一个强连通分量里面?
比如:
我们可以姑且的认为,一个长得像\(1->2->3->4->...\)的点叫伪点(非专业术语)
而一个伪点一般有一个点入度为0,一个点出度为0,当然,即使有特殊情况使得某个为0也是没问题的,代表他和其他伪点已经有联系了。
那么,我们只需要把一个伪点没入度的连向没出度的(当然,只有一个分量的话要特判,直接输出0),也就是max(rdcnt,cdcnt)。
虽然很难理解,但是画以下图就知道了。
#include<cstdio>
#include<cstring>
#include<cstdlib>
using namespace std;
int flog[21000],fa[21000],biao[21000],id,n,m,cnt,t;
struct node
{
int x,y,next;
}a[51000];int last[21000],len,list[21000],top;
bool v[21000];
void ins(int x,int y)
{
len++;
a[len].x=x;a[len].y=y;a[len].next=last[x];last[x]=len;
}
inline int mymin(int x,int y){return x<y?x:y;}
inline int mymax(int x,int y){return x>y?x:y;}
void dfs(int x)
{
fa[x]=biao[x]=++id;
list[++top]=x;v[x]=true;
for(int k=last[x];k;k=a[k].next)
{
int y=a[k].y;
if(biao[y]==0)
{
dfs(y);
fa[x]=mymin(fa[x],fa[y]);
}
else
{
if(v[y]==true)fa[x]=mymin(fa[x],fa[y]);
}
}
if(biao[x]==fa[x])
{
int i=0;cnt++;
while(i!=x)
{
i=list[top--];
flog[i]=cnt;
v[i]=false;
}
}
}
int rd[21000],cd[21000];
int main()
{
//freopen("b.in","r",stdin);
//freopen("1.out","w",stdout);
scanf("%d",&t);
while(t--)
{
memset(fa,0,sizeof(fa));
memset(biao,0,sizeof(biao));cnt=0;len=0;id=0;
memset(last,0,sizeof(last));top=0;
memset(rd,0,sizeof(rd));memset(cd,0,sizeof(cd));
scanf("%d%d",&n,&m);
int ans1=0,ans2=0;
for(int i=1;i<=m;i++)
{
int x,y;scanf("%d%d",&x,&y);
ins(x,y);
}
for(int i=1;i<=n;i++)
{
if(biao[i]==0)dfs(i);
}
if(cnt==1)
{
printf("0\n");
continue;
}
for(int i=1;i<=m;i++)
{
int tx=flog[a[i].x]/*缩点*/,ty=flog[a[i].y];
if(tx!=ty)
{
rd[ty]++;cd[tx]++;
}
}
for(int i=1;i<=cnt;i++)
{
if(rd[i]==0)ans1++;
if(cd[i]==0)ans2++;
}
printf("%d\n",mymax(ans1,ans2));
}
return 0;
}
2
我们先跑一遍二分匹配,然后把原本的边反向建(母牛连向公牛),并且连一条边,公牛连向他匹配的母牛,那么再跑一边强连通,我们就会发现每个分量里面都是公牛->母牛->公牛->母牛...
也就是说每个母牛至少有两个选择,公牛也是,然后我们在找公牛能****(手动打码)的每个母牛,如果母牛跟公牛在同一分量中,那么这个母牛原本的公牛也可以在找另外一头母牛,是不是很厉害?
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<bitset>
using namespace std;
const int cc=4010;
struct node
{
int y,next;
}a[200010];int len,last[cc];
struct trlen
{
int x,y,next;
}map[200010];int tlen,tlast[cc];
int cnt,id,p;
int sta[cc],low[cc],dfn[cc],belong[cc];
int chw[cc],match[cc],n;
bool v[cc];
void ins(int x,int y)
{
len++;
a[len].y=y;a[len].next=last[x];last[x]=len;
}
void ins1(int x,int y)
{
tlen++;
map[tlen].x=x;map[tlen].y=y;map[tlen].next=tlast[x];tlast[x]=tlen;
}
bool find(int x)
{
for(int k=tlast[x];k;k=map[k].next)
{
int y=map[k].y;
if(chw[y]!=id)
{
chw[y]=id;
if(match[y]==0 || find(match[y])==true)
{
match[y]=x;
return true;
}
}
}
return false;
}
void dfs(int x)
{
low[x]=dfn[x]=++id;v[x]=true;sta[++p]=x;
for(int k=last[x];k;k=a[k].next)
{
int y=a[k].y;
if(low[y]==0)
{
dfs(y);
low[x]=min(low[x],low[y]);
}
else if(v[y]==true)low[x]=min(low[x],dfn[y]);
}
if(low[x]==dfn[x])
{
int now=0;cnt++;
do
{
now=sta[p--];
v[now]=false;
belong[now]=cnt;
}while(now!=x);
}
}
int main()
{
scanf("%d",&n);
for(int i=1;i<=n;i++)
{
int kkk=0;scanf("%d",&kkk);
for(int j=1;j<=kkk;j++)
{
int x;scanf("%d",&x);
ins1(i,x+n);
}
}
for(int i=1;i<=n;i++)
{
id++;
find(i);
}//二分匹配
for(int i=1;i<=n;i++)ins(match[i+n],i+n);
for(int i=1;i<=tlen;i++)ins(map[i].y,map[i].x);
for(int i=1;i<=n;i++)
{
if(low[i]==0)dfs(i);
}
for(int i=1;i<=n;i++)
{
int jj=belong[i],ansl[cc];
ansl[0]=0;
for(int j=tlast[i];j;j=map[j].next)
{
if(belong[map[j].y]==jj)ansl[++ansl[0]]=map[j].y-n;
}
sort(ansl+1,ansl+1+ansl[0]);
for(int j=1;j<ansl[0];j++)printf("%d ",ansl[j]);
printf("%d\n",ansl[ansl[0]]);
}
//输出
return 0;
}
3
这道题比较简单,如果一个强连通分量有边连向其他分量,这个分量都没用了。
#include<cstdio>
#include<cstring>
using namespace std;
inline int mymin(int x,int y){return x<y?x:y;}
int n,m;
int low[21000],dfn[21000],belong[21000],cnt,out[21000],stp;
int sta[21000],tp=0;bool v[21000];
struct node
{
int x,y,next;
}a[21000];int last[21000],len;
int ansl[21000];
void ins(int x,int y)
{
len++;
a[len].x=x;a[len].y=y;a[len].next=last[x];last[x]=len;
}
void dfs(int x)
{
low[x]=dfn[x]=++stp;v[x]=true;sta[++tp]=x;
for(int k=last[x];k;k=a[k].next)
{
int y=a[k].y;
if(low[y]==0)
{
dfs(y);
low[x]=mymin(low[x],low[y]);
}
else if(v[y]==true)low[x]=mymin(low[x],dfn[y]);
}
if(low[x]==dfn[x])
{
int now=0;cnt++;
while(now!=x)
{
now=sta[tp--];
belong[now]=cnt;
v[now]=false;
}
}
}
int main()
{
memset(v,true,sizeof(v));
while(scanf("%d",&n)!=EOF)
{
if(n==0)break;
scanf("%d",&m);
memset(low,0,sizeof(low));stp=0;
memset(last,0,sizeof(last));len=0;
memset(out,0,sizeof(out));
for(int i=1;i<=m;i++)
{
int x,y;scanf("%d%d",&x,&y);
ins(x,y);
}
for(int i=1;i<=n;i++)
{
if(low[i]==0)dfs(i);
}
for(int i=1;i<=m;i++)
{
if(belong[a[i].x]!=belong[a[i].y])out[belong[a[i].x]]++;
}
for(int i=1;i<=n;i++)
{
if(out[belong[i]]==0)ansl[++ansl[0]]=i;
}
for(int i=1;i<ansl[0];i++)printf("%d ",ansl[i]);
printf("%d\n",ansl[ansl[0]]);ansl[0]=0;
}
return 0;
}
双连通分量
双联通分量就是无向图的强连通分量
UPDATA:由于后面又更新了博客,所以有些内容看起来比较神奇,可能就是前一次的内容和后一次的更新内容卡一块了。
边-双连通分量
桥
如果原本两个点是连通的,截断一条边就使得两个点不联通了,这条边叫桥。
边双
定义
边双连通分量就是分量中不含桥的联通块。
一个点只会属于一个边双,如果属于两个边双,可以把这两个边双合并(因为如果这两个边双间有桥,删掉后,有\(x\),不会不连通,所以矛盾,不存在桥)。
做法
没错,Tarjan永远的神!!!!
跟强连通一样的更新套路,但是需要注意的一件事情是,不可能存在\(C\)类边(比较重要的性质),因为如果\(x->y\),那么理论上来讲,因为这是双向边,所以\(y\)在\(DFS\)的时候就可以访问\(x\)。而且需要注意。需要保存父亲边(需要注意的是,这里保存的是父亲边,因为可能存在重边),不能走过父亲边(但是如果题目中要求重边视为一条,则保存父亲),所以不需要保存\(be\)数组。
至于判断条件,有两种方法。
-
对于\(low[x]==dfn[x]\)时,其到父亲的边是桥(但是如果\(x\)是\(DFS\)树的根则没有父亲),且\(x\)是\(fa\)点。
这种跟强连通是比较像的,仔细想想可以发现,栈的证明是一样的。
应该 -
对于\(x\)在\(DFS\)树中的一个儿子\(y\),若\(low[y]>dfn[x]\),则\(x-y\)的边是桥,\(y\)是\(fa\)节点,需要注意的是,这种写法,栈的弹出设定不应该是:\(sta[top]!=x\),而应该是\(now!=y\),因为\(y,x\)在栈中不一定连续,可能存在\(x\)的另外一个儿子和\(x\)是一个边双的。
缩点
缩点都是差不多的,但是无向图的缩点最后会变成树,而不是DAG,后面的点双也是。
dfn,low相等情况证明
需要注意的是,由于边双中,\(fa->x->fa\)的路径中存在点重复无所谓,所以\(min\)中可以\(dfn,low\)都可以,而其\(low[x]\)和\(dfn[fa]\)的等于情况和强连通基本相同(好像就是相同的),所以不做过多分析。(反正不存在\(C\)类边,随便分析)
DFS处理
由于是无向图,如果图联通,只需要跑一次\(DFS\),点双边双都一样,不需要像强连通一样用循环判断每个点时候\(DFS\)了。当然如果图不连通还是要的
代码
代码:
void dfs(int x,int fa)
{
low[x]=dfn[x]=++stp;
sta[++tp]=x;
for(int k=last[x];k;k=a[k].next)
{
int y=a[k].y;
if(y!=fa)
{
if(low[y]==0)
{
dfs(y,x);
low[x]=min(low[x],low[y]);
}
else low[x]=min(low[x],dfn[y]);
}
}
if(low[x]==dfn[x])
{
int now=0;cnt++;
while(now!=x)
{
now=sta[tp--];
belong[now]=cnt;
}
}
}
练习
这次没例题,直接放练习
像上次那样,我们记录每个分量的度(无向边),为0,ans+=2,为1,ans++
然后答案为ans/2+ans%2
#include<cstdio>
#include<cstring>
using namespace std;
inline int mymin(int x,int y){return x<y?x:y;}
int low[6000],dfn[6000],belong[6000],cnt,stp;
int sta[6000],tp;
struct node
{
int x,y,next;
}a[21000];int last[6000],len;
int ax[11000],ay[11000],n,m,io[11000],ans;
void ins(int x,int y)
{
len++;
a[len].x=x;a[len].y=y;a[len].next=last[x];last[x]=len;
}
void dfs(int x,int fa)
{
low[x]=dfn[x]=++stp;
sta[++tp]=x;
for(int k=last[x];k;k=a[k].next)
{
int y=a[k].y;
if(y!=fa)
{
if(low[y]==0)
{
dfs(y,x);
low[x]=mymin(low[x],low[y]);
}
else low[x]=mymin(low[x],dfn[y]);
}
}
if(low[x]==dfn[x])
{
int now=0;cnt++;
while(now!=x)
{
now=sta[tp--];
belong[now]=cnt;
}
}
}
int main()
{
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++)
{
scanf("%d%d",&ax[i],&ay[i]);
ins(ax[i],ay[i]);ins(ay[i],ax[i]);
}
for(int i=1;i<=n;i++)
{
if(low[i]==0)dfs(i,0);
}
if(cnt==1){printf("0\n");return 0;}//特判
for(int i=1;i<=m;i++)
{
if(belong[ax[i]]!=belong[ay[i]])io[belong[ax[i]]]++,io[belong[ay[i]]]++;
}
for(int i=1;i<=cnt;i++)
{
if(io[i]==0)ans+=2;
else if(io[i]==1)ans++;
}
printf("%d\n",ans/2+ans%2);
return 0;
}
点双连通分量
割点
就是把点割掉后,会导致原本联通块变成多个联通块,那么这个点就是割点。
点双连通分量
点双就是不含割点的连通分量(特别的,两个点一条边也是一个点双)。
点双最麻烦的就是一个点可能属于多个分量,如果一个点属于多个连通分量,此时这个点一定是割点,而割点也必然属于多个联通块,如下图。
我们发现,中间有一个点属于两个连通分量。
性质:
- 点双每两个点存在一个简单环,每三个点\(x,y,z\),一定存在\(x->y->z\)的一条简单路径。(应该吧,这个在下文小结给出的扩展资料有证明)
- 我们探究一下边双和点双的关系,不难一个桥左右两个点一定是割点,而一条边左右两个点是割点,但这条边不一定是桥,好,这样的话,一个边双中可能含有很多个割点,只要不含桥即可,因此一个边双可以被拆分成多个点双,而且,点双中一定不存在桥。
- 设\(x\)属于点双\(A\),属于边双\(B\),那么\(A∩B=B\),证明:如果存在\(y\)不属于\(B\)而属于\(A\),那么\(x,y\)之间存在简单环(点双的),环上不存在割边,那么\(y,x\)应该属于同一个边双,因此矛盾,证毕。
点双其实可以看作边的联通关系,因为每条边最多属于一个分量(这个证法就是删去证联通)。
点双依旧不存在\(C\)类边,且点的在\(DFS\)树中的连续性也是可以证明的,证法依旧类似。
但是突然发现一个十分重要的事情:如果不存在一条\(fa->x->fa\)的路径使得路径上点不重复(不包括起终点)的话,那么\(x,fa\)不可能是一个分量中的(实际上说明这个\(fa\)不是\(x\)所在连通分量的\(fa\))。我们当时证明了,如果我们采用了\(min(low[x],low[y])\)的更新方式,那么\(low[x]=dfn[fa]\),这个是很明显的错误,因此我们只能
那么就只能采用\(min(low[x],dfn[y])\)的更新方法了,但是这个为什么对呢?扩展资料中证明了,两个点之间不存在简单环,则一定存在割点,那么这种方法更新的话,\(x\)最多就等于\(x,fa\)中间那个割点的\(dfn\),因此不用担心此情况,好,那么存在简单环就一定对吗?
好,就证明一个引理:
\(x-a_1-a_2-a_3.-..-a_q-y\),其中\(x\)准备\(DFS\),\(x\)在DFS树中在\(y\)的子树中,\(a_i(1≤i≤q)\)没有被访问过,那么在\(x\)\(DFS\)完之后,\(low[x]≤dfn[y]\)。假设\(a_i\)是下一个\(DFS\)的对象,那么只要\(a_i-...-y\)成立即可,运用数学归纳法,发现两个点一定成立,则数学归纳法生效。
好,现在证明点双中的除\(fa\)外每个点的\(low\)都小于\(dfn[x]\)(需要注意的是,本文的证明的\(fa\)都不是指父亲节点,而是指联通块\(fa\)节点,父亲节点我会特地的打中文)。
假设存在简单环:\(fa-a_1-a_2-...-a_q-fa\),第一个被访问的点是\(a_i\),那么\(a_i\)一定等于的\(low\)一定等于\(dfn[fa]\),好,\(a_i\)到达\(a_j\),那么,\(a_j\)的\(low\)也等于\(dfn[fa]\),但是环上\(a_i-a_j\)中间的点呢?他们的\(low\)说不定不等于\(dfn[fa]\),于是我们继续利用数学归纳法,已知\(a_i-a_j\)中最先被访问的点是\(a_k\),那么\(a_k\)的\(low\)小于等于\(dfn[a_i]\),小于\(dfn[a_k]\),然后把区间切成了:\(a_i-a_k,a_k-a_i\),最终一定会被切成两个点,中间没有任何点,证毕。
好,然后直接用\(min(low[x],dfn[y])\)更新方法即可,当然,\(D\)类边一直不变都是用\(low[y]\)更新的。
但需要注意的是,点双不需要保留父亲边,允许走父亲,反正每个点肯定和其父亲是同一个点双(因为两个点也是点双,至少有个保底)。
什么,判定一个点是不是\(fa\)节点?如果\(x\)通过\(D\)类边到\(y\),然后\(low[y]==dfn[x]\)那么\(x\)是\(fa\)节点,和\(y\)一个点双。
点双缩点
由于每个割点,属于多个联通块,不能直接缩,因此,我们选择保留割点,删去所有的非割点,如果割点\(x\)属于联通块\(y\),那么\(x\)与\(y+n\)连一条边即可。
点双的点
也许就有人要问了,MAD,一个点可以属于多个点双,还要栈干什么,反正每个点的\(belong\)可以有多个?但是在点双缩点的恰恰就需要找出一个点双中的点(但是不用保存到后面继续使用,而是直接用链式前向星缩点建树),这个时候了,我们发现一个点在结束完\(DFS\)后,就不会作为\(fa\)节点了,所以需要特殊考虑的就是当前\(DFS\)的节点\(x\)。
难道是如果\(x\)是\(fa\)节点的话,且和\(y\)一个分量,就把栈中\(x\)及\(x\)以上的全部弹出,然后再把\(x\)加回队列中?
不,类似边双的问题\(x,y\)不一定连续,所以应该把\(y\)和\(y\)以上的点全部弹出,并算上\(x\)即可。
点双的边
当然,有的时候需要搞出一个点双中所有的边,但是边只属于一个点双,就比较方便,直接在\(DFS\)中往栈塞\(D\)类边,但是\(A、B\)类边呢?因为双向边,所以\(A=B\),所以只需要保存比较容易处理的\(A\)类边即可,然后在弹出的时候判断栈顶是不是\(x->\)y的\(D\)类边即可。
不对!!!!因为允许到父亲,所以实际上\(D⊂A\),所以,几种处理方法:
- 用\(v\)表示这个边是否用过。
- 不保存\(D\)类边,但是这样栈弹出的否决条件就比较难处理,因此改进的方法可以是优先到父亲,或者说是到父亲的边不入栈。
当然还有其他方法,不再赘述。
代码
int dfn[61000],cnt,low[61000],id;
int sta[61000],p;
void dfs(int x)
{
dfn[x]=low[x]=++id;sta[++p]=x;
for(int k=last[x];k;k=a[k].next)
{
int y=a[k].y;
if(!dfn[y])
{
dfs(y);
low[x]=min(low[x],low[y]);
if(low[y]==dfn[x])//我和你构成一个点双
{
int now=0;cnt++;
while(now!=y && p>0)now=sta[p--];
//可以在最后处理一下x
}
}
else low[x]=min(low[x],dfn[y]);
}
}
初始化问题
其他自己想,三个分量都差不多,这里只说栈。
其他两个分量一个点只属于一个分量,所以最后栈中一个点不剩,
但是边双最后会剩一个点,\(DFS\)树的根,所以在初始化的时候别忘了\(top=0;\)
求割点与桥
割点
我们研究DFS序就会发现,只要一个不是根结点的其中一个儿子的low小于等于(如果可以走父亲,可以直接换成等于)他的dfn,那么这个点就是割点,根节点就是他所在的点双个数大于等于\(2\),当然具体表现就是\(D\)类边的个数,因为根节点的\(dfn\)最小,不可能小于,只能等于。
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
int dfn[61000],low[61000],n,m,id;
struct node
{
int y,next;
}a[210000];int len,last[61000];
void ins(int x,int y)
{
len++;
a[len].y=y;a[len].next=last[x];last[x]=len;
}
int ans[130000];
void dfs(int x,bool type)//判断是不是根节点而已
{
bool bk=false;int cnt=0;
dfn[x]=low[x]=++id;
for(int k=last[x];k;k=a[k].next)
{
int y=a[k].y;
if(!dfn[y])
{
cnt++;
dfs(y,0);
low[x]=min(low[x],low[y]);
if(low[y]==dfn[x]/*桥就是>*/)bk=true;
}
else low[x]=min(low[x],dfn[y]);
}
if(type)//根节点特判
{
if(cnt>=2)ans[++ans[0]]=x;
}
else if(bk==true)ans[++ans[0]]=x;
}
int main()
{
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++)
{
int x,y;scanf("%d%d",&x,&y);
ins(x,y);ins(y,x);
}
for(int i=1;i<=n;i++)
{
if(dfn[i]==0)dfs(i,1);
}
sort(ans+1,ans+ans[0]+1);
printf("%d\n",ans[0]);
for(int i=1;i<ans[0];i++)printf("%d ",ans[i]);
if(ans[0])printf("%d\n",ans[ans[0]]);
return 0;
}
桥
桥就不打了吧,直接在边双的时候随便乱搞都可以啦。
看个人喜好吧。
小结
又水了一篇博客
补充资料:我博客中的另外一篇博客关于“双联通分量的存在条件的证明(对于算法进阶的补充)”,应该可以加深你对点双的理解。
对于练习中有些题目的证明,强烈建议去网上搜证明,我的这个太不严谨了。(其实就是懒得更新练习了)