序言:
连通性问题,这可真是tarjan的天下啊,不过这篇文章并没有打算扯到tarjan的起源模型强连通分量,主要还是说说自己对其它连通性问题的思考,所以,如果你还不会tarjan算法的话,嗯,点这里:byvoid的tarjan算法讲解 膜拜一下神牛。
这篇文章是自己将三篇研究日记汇总而成的,所以中间有一部分属于含有错误的,标题已经进行了警示,大家也可以找找看为什么不对,文章后面进行了订正与说明,好的,进入正题。
基本概念:
1、连通:两个点之间存在若干条边将其连接,称其连通
2、强连通:有向图中的两点可以互达(A→B 并且 B→A),称其强连通3、弱连通:有向图中的两点可以到达(A→B 或者 B→A),称其弱连通
4、连通图:图G中任意两点都连通,则G为连通图
5、强连通图:有向图G中任意两点都强连通,则G为强连通图
6、弱连通图:有向图G中任意两点都弱连通,则G为弱连通图
7、强连通分量:非强连通图的极大强连通子图,称为强连通分量(极大指不能再大,与最大的意义不同)
8、点连通度:使无向图G不连通的最少删点数量为其点连通度
9、边连通度:使无向图G不连通的最少删边数量为其边连通度
10、点双连通图:点连通度大于1的无向图
11、边双连通图:边连通度大于1的无向图
12、双连通图:点连通度和边连通度均大于1的无向图
13、点双连通分量:非点双连通图的极大点双连通子图
14、边双连通分量:非边双连通图的极大边双连通子图
15、双连通分量:非双连通图的极大双连通子图
16、割点:点连通度为1的无向图中,被删除后将导致原图不连通的点
17、桥:边连通度为1的无向图中,被删除后将导致原图不连通的边
18、返祖边:在DFS中连接当前点与未访问完毕的点之间的边
19、横叉边:在DFS中连接当前点与已访问完毕的点之间的边
双连通分量:
双连通分量有两种:点双连通分量、边双连通分量。那双连通分量又是什么?到底是点的还是边的?这样不清楚的表述屡见不鲜,参考了众多人的博客后,关于双连通分量的定义,还是确定不下来,主要有以下几种说法:
1、指点双连通,与块同义2、指边双连通
3、有时指点双连通,有时指边双连通
4、满足点双连通或者边双连通
5、同时满足点双连通与边双连通
关于双连通的定义,众说纷纭,我觉得还是不要盲目相信任何人,毕竟说清楚是点双连通还是边双连通并没有碍多少事,那么以后就说清楚为好,免得出现歧义。
关系:
算法:
用dfn表示时间戳,用low表示简单环内的最小时间戳
强连通分量:当dfn[u] == low[u]
桥:当dfn[u] < low[v]
割点:当dfn[u] <= low[v]
这又从算法的角度印证了上面的结论:有桥则一定有割点,但是有割点不一定有桥,因为该点可以是环内搜索树的根节点,当没有该点的时候,环上各点将与该点的搜索树祖先节点不连通,但是若消去环上一边,环上各点与该点依然连通,这意味着它们与该点的祖先节点依然连通。
横叉边:
割点与点双连通分量(此部分内容有错误):
求解割点的时候可以顺便求出所有的点双连通分量,也就是块,虽然我在一些人的博客中发现,他们认为具有两个点,一条边的子图不属于点双连通分量(块),但是算法却会求出这样双连通分量。个人认为,对于这种东西没有必要定义地特别死,毕竟这不像双连通分量的概念一样具有歧义,让人不知道是指点双连通分量还是边双连通分量。况且,这只是一个模型问题,算不算还得根据具体的题目意思来判断,那么当它算也就那么大的一点事,何乐而不为?
割点的定义为:
1、为搜索树根节点且包含多于1棵搜索子树(切记不是边)
2、不为搜索树根节点且存在一个邻接点v满足 dfn[u] <= low[v]
需要注意的就是,乍一看割点的定义是分情况的,其实割点的定义是唯一的,就是删除将导致原图不连通的点,只不过在实现起来的时候,需要特判,上述分情况的割点定义其实就是带特判的便于编程实现的定义,两种情况只是判断方法不同,性质还是一样的。
在实现的过程中,还要注意的就是,连通分量可以在同一个地方用一个程序段就求出来了,但是割点是分情况的,需要在后面特判根节点是否为割点。
具体实现如下:(非编译代码)
#include <cstdio>
void tarjan( int i, int fa ) // tarjan算法同时求解点双连通分量(块)与割点
{
int j;
dfn[i] = low[i] = ++time; // 标记数组初始化
stack[++top] = i; // 节点入栈
for( int k = h[i]; k; k = next[k] ) // 邻接表遍历当前点的所有邻接边
{
j = g[k]; // 取当前邻接边的指向节点
if( !dfn[j] ) // 若指向节点未访问过
{
tarjan( j, i ); // 对该节点进行带父亲递归操作
if( dfn[i] = 1 ) count++; // 对一个指向节点递归完毕即对一棵搜索子树递归完毕,搜索子树数目++
low[i] = low[i] < low[j]? low[i]: low[j]; // 用指向节点的low值更新当前节点的low值
if( dfn[i] <= low[j] ) // 若指向节点的low值大于当前节点的dfn值,表明出现了一个块,且当前点可能为割点
{
cnt++; // 块的数量++
do
{
j = stack[top--]; // 将属于一个块的节点弹栈
belg[j] = cnt; // 给当前块中节点打上块的编号
}
while( j != i ); // 一直弹栈直到当前点被弹出
top++; // 当前点为割点或是不为割点的根节点均可属于多个块,再将其压栈
if( dfn[i] != 1 ) // 若当前节点不为根节点
code[i] = 1; // 则当前节点为割点,打上割点标记
}
}
else if( j != fa ) // 若指向节点已经访问,则一定在栈中,检查是不是合法的边(指向父亲不合法)
low[i] = low[i] < dfn[j]? low[i]: dfn[j]; // 用指向节点的dfn值更新当前节点的low值
if( dfn[i] == 1 && count > 1 ) code[i] = 1; // 如果当前节点为根节点,且已经发现其有多于1棵子树,打上割点标记
}
}
int main( )
{
freopen( "input.txt", "r", stdin );
{
//读入数据
}
for( int i = 1; i <= n; i++ )
if( dfn[i] = 0 ) // 若当前节点未被访问过
{
time = count = 0; // 初始化时间戳与搜索子树计数器
tarjan( i, 0 ); // 对节点进行tarjan操作
}
return 0;
}
桥与边双连通分量(此部分内容有错误):
但是对于桥,如果只是简单根据上面的操作定义来求,还是会有一点问题,因为图是可能出现平行边(重边)的,相互平行的一组边,一定都不是桥,因为它们可以互相替代。但是问题也好解决,办法就是边从2开始计数,那么正反边的异或值为1,再进行下一层递归的时候只需要带边的标号,并且规定不允许访问与所带边异或值为1的边即可。
具体实现如下:(非编译代码)
#include <cstdio>
int tarjan( int i, int num ) // i记录当前点,num记录所带的边( 即搜出i节点的边 )
{
int j;
dfn[i] = low[i] = ++time; // 初始化标记数组
stack[++top] = i; // 当前节点入栈
for( int k = h[i]; k; k = next[k] ) // 遍历与当前节点邻接的所有边
{
j = g[k]; // 取邻接边的指向节点
if( num^k == 1 ) continue; // 若该边与所带边异或值为1,即为同一条边,不允许回头
if( !dfn[j] ) // 若指向节点未被访问过
{
tarjan( j ); // 对指向节点进行递归操作
low[i] = low[i] < low[j]? low[i]: low[j]; // 用指向节点的low值更新当前节点low值
if( dfn[i] < low[j] ) // 若满足当前节点的dfn值小于指向节点的low值
{
code[k] = code[k^1] = 1; // 将该无向边( 即两条异或和为1的有向边 )标记为桥
cnt++; // 边双连通分量的数量++
while( i != j ) // 因为(i,j)是桥,所以i,j分属两个边双连通分量,所以要将j的双连通分量弹栈
{
j = stack[top--]; // 因为j已经访问完毕,将j所在的双连通分量弹栈
belg[j] = cnt; // 给指向节点打上边双连通分量标号
}
}
}
else // 即指向节点还在栈中
{
low[i] = low[i] < dfn[j]? low[i]: dfn[j]; // 用指向节点的dfn值更新当前节点的low值
}
}
}
int main( )
{
freopen( "input.txt", "r", stdin );
{
// 读入数据( 邻接表边从2开始,无向边拆成两条有向边储存,使其异或和为1 )
}
for( int i = 1; i <= n; i++ )
if( !dfn[i] ) tarjan( i, 0 ); // 若当前节点未访问,带边进入递归操作
return 0;
}
错在哪里?
图中编号为该节点dfn值,那么算法得到的会是1,2,3,4,5在一个点双连通分量之内,因为2在访问完3之后,是不会弹栈的,因为此时3的low值为1,所以2节点再访问5节点,而访问完5节点后,发现5节点的low值已经大于2节点的dfn值会进行弹栈操作,直到2节点被弹出(之后会把2再入栈因为其可能属于另一点双连通分量),那么它们一起形成了一个点连通分量,这是显然错误的。
正确的做法应该是将边入栈,比如说1在访问2节点的时候,将1-2这条边入栈,退栈的时候就是直到当前边退出,这样一来就可以避免掉上述的情况了。
但是,边入栈的时候,还是有一定条件的,并不是说只要访问到一条边就将其入栈,也不是说只要目标节点被访问过就不入栈,正确的入栈应该是:若目标节点的dfn小于当前节点的dfn(囊括了返祖边与树枝边)就入栈,这样做的原因是:(编号代表其dfn值)
当1,2,3,4已经被确认为一个点双连通分量后,1会继续访问下一个点,若1访问到了4,立马将1-4边入栈,那么1-4边又会被包括在另一个点双连通分量中,但是任何一条边只会存在于一个点双连通分量中,所以这样会导致错误。另外,若只有目标节点的dfn为0时才将边入栈,那么就会导致4-1边不在栈中,从而其不被包括在该点双连通分量中。最妙的是,通过对dfn的判定,还可以除去重边,快哉快哉!
已通过的关键代码如下:
void tarjan( int i, int num )
{
int j, e; // j取点,e取边
dfn[i] = low[i] = ++index;
for( int k = h[i]; k; k = next[k] )
{
j = g[k];
if( (num^k) == 1 || dfn[j] > dfn[i] ) continue; // 是指向父亲的边则放弃
stack[++top] = k; // 当前边入栈
if( !dfn[j] ) // 若指向节点未被访问
{
tarjan( j, k ); // 带边进行递归
if( low[j] < low[i] ) low[i] = low[j]; // low值传递
if( dfn[i] <= low[j] ) // 判断当前是否产生了一个块
{
cnt++; // 块的数量增加
do
{
e = stack[top--];
p[e] = p[e^1] = cnt; // 将块内的边弹栈并打上块编号
}
while( e != k );
}
}
else
if( dfn[j] < low[i] ) low[i] = dfn[j]; // 修改low值
}
}
边双连通分量:是一个删除任何一条边仍然连通的子图,上面求解边双连通分量的算法也有问题,与上述的类似,只不过边双连通分量栈中加入的是点,但是在求出桥后的退栈过程中,还是会出现不该在一个边连通分量内的点出现在了一个边连通分量中,但是桥的求解是没有问题的,既然如此,我觉得更方便且保险的方法就是不用栈了,因为单求桥是不需要任何栈的,那么就求桥就好了,求出所有的桥后再把桥封锁,那么每一个点能遍历到的就是一个边双连通分量,这样不仅实现起来很清楚,而且不容易出现杂七杂八的问题。
已通过的关键代码如下(求桥):
void tarjan( int i, int num )
{
int j;
dfn[i] = low[i] = ++index;
for( int k = h[i]; k; k = next[k] )
{
j = g[k];
if( ( k^num ) == 1 ) continue;
if( !dfn[j] )
{
tarjan( j, k );
if( low[j] < low[i] ) low[i] = low[j];
if( dfn[i] < low[j] )
{
p[k] = p[k^1] = 1; // 桥的标记
l[++bcnt] = i; // 记录桥的一个端点
r[bcnt] = j; // 记录桥的另一个端点
}
}
else
if( dfn[j] < low[i] ) low[i] = dfn[j];
}
}
题目暗示:
1、删去点后还连通
2、删去边后还连通
当然题目不可能总给的这么直接,更多的会是一种不太明显的暗示:
3、两条路径
4、环
上述这些都要往连通性问题上来想,也许可以节约很多的时间!
总结:
总的来说,连通性问题也就差不多这么多东西,关键还是要学会看出模型,其实我个人感觉起来,只要是环有关的东西都跟连通性或多或少有那么点关系,而且很有可能正确算法就是tarjan的一个变种算法,真正掌握tarjan不应该只是强记代码,也不能只是搞懂了求几个东西的方法与步骤,而是应该取其精髓,怎么把tarjan找环的特性应用到更多的东西上去。Tarjan给我最大的启迪不是学会了求解连通性问题,而是对搜索,尤其是深度优先搜索的标记传递,有了更加深刻的领悟!
题目: