连通性问题,这可真是tarjan的天下啊,不过这篇文章并没有打算扯到tarjan的起源模型强连通分量,主要还是说说自己对其它连通性问题的思考,所以,如果你还不会tarjan算法的话,嗯,点这里:byvoid的tarjan算法讲解 膜拜一下神牛。
当然了,关于连通性问题这里还有:byvoid的连通性问题讲解 再次膜拜。
1、连通:两个点之间存在若干条边将其连接,称其连通
2、强连通:有向图中的两点可以互达(A→B 并且 B→A),称其强连通
双连通分量有两种:点双连通分量、边双连通分量。那双连通分量又是什么?到底是点的还是边的?这样不清楚的表述屡见不鲜,参考了众多人的博客后,关于双连通分量的定义,还是确定不下来,主要有以下几种说法:
1、指点双连通,与块同义
求解割点的时候可以顺便求出所有的点双连通分量,也就是块,虽然我在一些人的博客中发现,他们认为具有两个点,一条边的子图不属于点双连通分量(块),但是算法却会求出这样双连通分量。个人认为,对于这种东西没有必要定义地特别死,毕竟这不像双连通分量的概念一样具有歧义,让人不知道是指点双连通分量还是边双连通分量。况且,这只是一个模型问题,算不算还得根据具体的题目意思来判断,那么当它算也就那么大的一点事,何乐而不为?
割点的定义为:
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; }
#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]; } }
总的来说,连通性问题也就差不多这么多东西,关键还是要学会看出模型,其实我个人感觉起来,只要是环有关的东西都跟连通性或多或少有那么点关系,而且很有可能正确算法就是tarjan的一个变种算法,真正掌握tarjan不应该只是强记代码,也不能只是搞懂了求几个东西的方法与步骤,而是应该取其精髓,怎么把tarjan找环的特性应用到更多的东西上去。Tarjan给我最大的启迪不是学会了求解连通性问题,而是对搜索,尤其是深度优先搜索的标记传递,有了更加深刻的领悟!
POJ 2942(点双连通)
POJ 3352(边双连通)