目录
Tarjan
首先记录说明一下图论中的常用的概念
- 无向图中,如果任意两个顶点之间都能够连通,则称此无向图为连通图
- 有向图中,若任意两个顶点 V i V_i Vi 和 V j V_j Vj,满足从 $V_i $到 V j V_j Vj 以及从 V j V_j Vj 到 V i V_i Vi 都连通,也就是都含有至少一条通路,则称此有向图为强连通图
- 若无向图不是连通图,但图中存储某个子图符合连通图的性质,则称该子图为连通分量
- 非强连通图有向图的极大强连通子图(最大的子图),称为强连通分量
- 若一个无向图中的去掉任意一个节点(一条边)都不会改变此图的连通性,即不存在割点(桥),则称作点(边)双连通图
- 在无向连通图中,如果将其中一个点以及所有连接该点的边去掉,图就不再连通或者连通分枝数增加,那么这个点就叫做割点
- 桥(割边)——指的是一条边,就是如果没有这条边,图的连通分量就会增加
- 没有圈的连通图叫树,树的边数恰好是顶点数减1,没有圈的非连通图叫做森林
一、算法介绍
T a r j a n Tarjan Tarjan是一种由 R o b e r t T a r j a n Robert Tarjan RobertTarjan提出的求解有向图强连通分量的线性时间的算法。通常可以用来求强连通分量、双连通分量、缩点、割点、割边等问题。
例如上图中顶点 1 、 2 、 3 1、2、3 1、2、3组成的子图就是这个有向图的强连通分量
T a r j a n Tarjan Tarjan算法可以找出这样的强连通分量, T a r j a n Tarjan Tarjan算法是基于对整个有向图的 d f s dfs dfs进行的,将整个图作为一棵搜索树,图中的强连通分量作为搜索树的子树。
对于上图,很容易看出强连通分量,但是要让计算机找出来,那么肯定要做好相应的标记,如果对于这张图进行 d f s dfs dfs遍历,假设从1开始搜索(用链式前向星存),搜索到的边如下
1 − > 2 − > 4 − > 5 1->2->4->5 1−>2−>4−>5、 2 − > 3 − > 1 2- >3->1 2−>3−>1,
可以发现,只有 3 3 3到 1 1 1这条边搜索到了已经走过的顶点 1 1 1,如果按照走过的顶点的先后顺序来表示时间,那么关系如下
顶点 | 访问时间(第几个访问到的) |
---|---|
1 | 1 |
2 | 2 |
3 | 5 |
4 | 3 |
5 | 4 |
很明显,如果从一个点出发不断遍历,发现有一个能够走回之前已经走过的点,说明形成了一个环,环上的点能够互相访问,环上的所有点都是强连通的
二、原理
上面举了一个例子,基于这一点,很容易想到需要维护一个访问时间顺序的数组,知道了访问顺序,那么怎么找出其中和这个点构成的所有强连通分量呢?同样需要一个数组来维护,在搜索每一个点的时候,如果发现这个点已经被访问过,说明形成了环,这时候就可以将当前的点进行更新,更新成已经访问过的点的顺序。因此 T a r j a n Tarjan Tarjan中重要的两个数组需要维护好
low[N];//low[i]的值表示i能够回溯的最小的祖先
dfn[N];//表示时间戳,dfn[i]的值表示第几个访问到i节点的
由于是递归实现的,明显数组 l o w low low是靠着回溯的时候更新的,而 d f n dfn dfn是靠着递进去的时候更新的
struct Edge {
int to, next;
}edge[M * 2];
void add_Edge(int u, int v) {
edge[++cnt].to = v;
edge[cnt].next = head[u];
head[u] = cnt;
}
void Tarjan(int x) { //表示当前的顶点x
dfn[x] = low[x] = ++c_time; //更新时间戳
for(int i = head[x]; i != -1; i = edge[i].next) { //访问所有x能够一步到达的顶点,用v表示
int v = edge[i].to;
if(!dfn[v]) { //如果顶点v没有访问过,就继续找下去
Tarjan(v);
low[x] = min(low[x], low[v]);//v是由x走过去的,因此x是v的祖先,如果有环,则可能low[v]小于low[x],因此要更新low[x]
}
low[x] = min(low[x], dfn[v]);//当v已经被访问过,出现了环,当前x则更新到小的那个节点
}
}
上图中更新结果
d f n [ 1 ] = 1 、 d f n [ 2 ] = 2 、 d f n [ 3 ] = 5 、 d f n [ 4 ] = 3 、 d f n [ 5 ] = 4 dfn[1] = 1、dfn[2] = 2、dfn[3] = 5、 dfn[4] = 3、 dfn[5] = 4 dfn[1]=1、dfn[2]=2、dfn[3]=5、dfn[4]=3、dfn[5]=4
l o w [ 1 ] = 1 、 l o w [ 2 ] = 1 、 l o w [ 3 ] = 1 、 l o w [ 4 ] = 3 、 l o w [ 5 ] = 4 low[1] = 1、low[2] = 1、low[3] = 1、low[4] = 3、low[5] = 4 low[1]=1、low[2]=1、low[3]=1、low[4]=3、low[5]=4
使用Tarjan的时候,如果不能保证可以一遍搜完整个图,那么使用方式如下
for(int i = 1; i <= n; i++)
if(!dfn[i])
Tarjan(i);
三、应用
1、求强连通分量
强连通分量需要引入栈来进行记录,每次进入递归的时候都进栈。考虑这样一种情况,当前的节点 x x x在更新完之后,如果 d f n [ x ] = l o w [ x ] dfn[x] = low[x] dfn[x]=low[x]说明,x以及它的所有子节点构成强连通分量,因此在这个时候需要把 x x x节点后面进栈的节点和 x x x全部弹出,这些节点都是和 x x x强连通的
void Tarjan(int x) {
dfn[x] = low[x] = ++c_time;
stack[++t] = x; //进栈
vst[x] = 1; //表示顶点x在栈中
for(int i = head[x]; i != -1; i = edge[i].next) {
int v = edge[i].to;
if(!dfn[v]) {
Tarjan(v);
low[x] = min(low[x], low[v]);
}
else if(vst[v]) //如果v在栈中,并且已经访问或,则肯定要更新low[x]
low[x] = min(low[x], dfn[v]);
}
if(dfn[x] == low[x]) { //出现强连通分量子树的最小根
int cur;
do {
cur = stack[t--]; //弹栈
vst[cur] = 0; //标记出栈
cout << cur << " ";
}while(x != cur);
cout << "\n";
}
}
例1 [POJ 3180] The Cow Prom
题意:给出 n n n个点 m m m条边,求出有向图中所有大于 1 1 1的强连通分量个数
输入样例
5 4
2 4
3 5
1 2
4 1
输出样例
1
模板题
#include <iostream>
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <cctype>
#define N 10005
#define M 50010
using namespace std;
inline int read() {
int x = 0, f = 1; char c = getchar();
while(!isdigit(c)) {if(c == '-') f = -1; c = getchar();}
while(isdigit(c)) {x = (x << 3) + (x << 1) + c - 48; c = getchar();}
return f * x;
}
struct Edge{
int to, next;
}edge[M * 2];
int n, m, cnt, c_time, t, ans;
int head[N], dfn[N], low[N], stack[N];
bool vst[N];
inline void addEdge(int u, int v) {
edge[++cnt].to = v;
edge[cnt].next = head[u];
head[u] = cnt;
}
void Tarjan(int x) {
dfn[x] = low[x] = ++ c_time;
stack[++t] = x;
vst[x] = 1;
for(int i = head[x]; i != -1; i = edge[i].next) {
int v = edge[i].to;
if(!dfn[v]) {
Tarjan(v);
low[x] = min(low[x], low[v]);
}else if(vst[v]) {
low[x] = min(low[x], dfn[v]);
}
}
int now = 0;
if(dfn[x] == low[x]) { //找到所有以x为根的强连通分量
int cur;
do{
cur = stack[t--];
vst[cur] = 0;
now ++;
}while(cur != x);
}
if(now > 1) ans ++;
}
int main() {
int u, v;
memset(head, -1, sizeof(head));
n = read(), m = read();
for(int i = 1; i <= m; i++) {
u = read(), v = read();
addEdge(u, v);
}
for(int i = 1; i <= n; i++)
if(!dfn[i]) Tarjan(i);
cout << ans;
return 0;
}
例2 [POJ 2186]受欢迎的牛
每一头牛的愿望就是变成一头最受欢迎的牛。现在有 N N N头牛,给你 M M M对整数 ( A , B ) (A,B) (A,B),表示牛 A A A认为牛 B B B受欢迎。这种关系是具有传递性的,如果 A A A认为 B B B受欢迎, B B B认为 C C C受欢迎,那么牛 A A A也认为牛 C C C受欢迎。你的任务是求出有多少头牛被除自己之外的所有牛认为是受欢迎的。
输入
第一行两个数 N , M N,M N,M($1\leq N\leq 10000,1 \leq M\leq 50000 ) ; 接 下 来 ); 接下来 );接下来M 行 , 每 行 两 个 数 行,每行两个数 行,每行两个数A,B$ ,意思是 A A A认为 B B B是受欢迎的
输出
输出被除自己之外的所有牛认为是受欢迎的牛的数量。
样例输入
3 3
1 2
2 1
2 3
样例输出
1
首先把整个图染色, 所有强连通分量为一种颜色,或者说打上相同标记,然后缩点,遍历所有的强连通分量,把整个图当成 D A G DAG DAG(有向无环图)考虑,那么出度为 0 0 0的点如果只有 1 1 1个,这个点一定是被所有牛喜欢的(可多举几个例子证明)。(可看代码注释)
#include <iostream>
#include <cctype>
#include <algorithm>
#include <cstring>
#include <cstdio>
#define M 50050
#define N 10050
using namespace std;
inline int read() {
int x = 0, f = 1; char c = getchar();
while(!isdigit(c)) {if(c == '-') f = -1; c = getchar();}
while(isdigit(c)){x = (x << 3) + (x << 1) + c - 48; c = getchar();}
return f * x;
}
struct Edge{
int to, next;
}edge[M * 2];
//color表示染色标记的数组,sum[i]表示标记为i的图中顶点的数量,r数组表示出度
int head[N], low[N], dfn[N], color[N], sum[N], stack[N], r[N];
//c_time 表示时间戳,t用来记录栈中的元素个数,rs用来记录染色的数量,也就是连通分量的数量
int cnt, n, m, c_time, t, rs;
bool vst[N];
inline void add_Edge(int u, int v) {
edge[++cnt].to = v;
edge[cnt].next = head[u];
head[u] = cnt;
}
void Tarjan(int x) {
dfn[x] = low[x] = ++ c_time;
stack[++t] = x;
vst[x] = 1;
for(int i = head[x]; i != -1; i = edge[i].next) {
int u = edge[i].to;
if(!dfn[u]) {
Tarjan(u);
low[x] = min(low[x], low[u]);
} else if (vst[u]) {
low[x] = min(low[x], dfn[u]);
}
}
if(dfn[x] == low[x]) {
int cur;
rs ++; //连通分量的数量增加
do{
cur = stack[t--];
color[cur] = rs; //染色,标记
sum[rs] ++; //记录该标记下的点数量
vst[cur] = 0;
}while(cur != x);
}
}
int main () {
memset(head, -1, sizeof(head));
int u, v, judge = 0, loc;
n = read(), m = read();
for(int i = 1; i <= m; i++) {
u = read(), v = read();
add_Edge(u, v);
}
for(int i = 1; i <= n; i++)
if(!dfn[i])
Tarjan(i);
for(int i = 1; i <= n; i++) {
for(int j = head[i]; j != -1; j = edge[j].next) {
int ve = edge[j].to;
if(color[i] != color[ve]) { //如果顶点i和ve不是同一连通分量,那么顶点i的标记出度+1(因为有缩点)
r[color[i]] ++;
}
}
}
for(int i = 1; i <= rs; i++) { //遍历DAG
if(r[i] == 0) { //出度为0
judge ++;
loc = i; //记录标记
}
}
if(judge == 1)
cout << sum[loc]; //输出带有这种标记的顶点数量
else
cout << 0;
return 0;
}
2、求割点
割点:在无向联通图 G = ( V , E ) G=(V,E) G=(V,E)中: 若对于 x ∈ V x∈V x∈V, 从图中删去节点 x x x以及所有与 x x x关联的边之后, G G G分裂成两个或两个以上不相连的子图, 则称 x x x为 G G G的割点。
割边(桥):在一个无向图中,如果有一个边集合,删除这个边集合以后,图的连通分量增多,就称这个边集为割边集合,如果某个割边集合只含有一条边 X(也即{X}是一个边集合),那么X称为一个割边,也叫做桥。
根据定义来看,割点为
3
、
4
3、4
3、4,桥为
d
、
e
d、e
d、e,有两种情况会出现割点
- 当对于点 x x x存在儿子节点 y y y,使得 d f n [ x ] ≤ l o w [ y ] dfn[x] \leq low[y] dfn[x]≤low[y]则 x x x一定是割点
- 如果根节点有 2 2 2个及以上的儿子,那么它也是割点(特判)
例题 [洛谷 3388] 割点(割顶)
题目
给出一个 n n n个点, m m m条边的无向图,求图的割点
输入格式
第一行输入 n , m n,m n,m
下面 m m m行每行输入 x , y x,y x,y表示 x x x到 y y y有一条边
输出格式
第一行输出割点个数
第二行按照节点编号从小到大输出节点,用空格隔开
样例输入
6 7
1 2
1 3
1 4
2 5
3 5
4 5
5 6
样例输出
1
5
#include <iostream>
#include <cstdio>
#include <cctype>
#include <cstring>
#define N 20005
#define M 100005
using namespace std;
inline int read() {
int x = 0, f = 1; char c = getchar();
while(!isdigit(c)) {if(c == '-') f = -1; c = getchar();}
while(isdigit(c)) {x = x * 10 + c - 48; c = getchar();}
return f * x;
}
struct Edge {
int to, next;
}edge[M * 2];
int s_clock, cnt, n, m;
int dfn[N], low[N], head[N];
//low[i]表示i能回溯到的最小祖先,dfn[i]表示时间戳,也就是第几个访问到的
bool vst[N], cut[N];
inline void add_edge(int u, int v) {
edge[++cnt].to = v;
edge[cnt].next = head[u];
head[u] = cnt;
}
void init() {
memset(head, -1, sizeof(head));
}
void Tarjan(int x, int root) {
int r = 0; //用来判断根节点是否是割点
dfn[x] = low[x] = ++s_clock; //更新时间戳
for(int i = head[x]; i != -1; i = edge[i].next) {
int u = edge[i].to;
if(!dfn[u]) {
dfn[u] = 1;
Tarjan(u, root);
low[x] = min(low[x], low[u]); // ****
if(dfn[x] <= low[u] && x != root) cut[x] = 1; //判断割点,当前节点x的子节点u能回溯的最小祖先小于当前节点的时间戳,说明一定没有往回的路,那么当前节点一定是一个割点
if(x == root) r ++; //最终回溯到了根节点
}
low[x] = min(low[x], dfn[u]); // ****
}
if(x == root && r > 1) cut[root] = 1; //如果有2条及以上的路 能回到根节点,那么根节点也是割点
}
int main() {
init();
int u, v, ans = 0;
n = read(), m = read();
for(int i = 1; i <= m; i++) {
u = read(), v = read();
add_edge(u, v);
add_edge(v, u);
}
for(int i = 1; i <= n; i++)
if(!dfn[i]) Tarjan(i, i);
for(int i = 1; i <= n; i++) //统计割点的个数
if(cut[i]) ans ++;
cout << ans << "\n";
for(int i = 1; i <= n; i++) //输出割点
if(cut[i]) cout << i << " ";
return 0;
}
3、求桥(割边)
割边(桥):在一个无向图中,如果有一个边集合,删除这个边集合以后,图的连通分量增多,就称这个边集为割边集合,如果某个割边集合只含有一条边 X(也即{X}是一个边集合),那么X称为一个割边,也叫做桥。
和求割点的方法类似,桥的判断如下
- u u u的子节点是 v v v,当且仅当 d f n [ u ] < l o w [ v ] dfn[u] < low[v] dfn[u]<low[v]时, ( u , v ) (u,v) (u,v)是桥
但是由于是无向图,可能会有重边的情况,为了统一处理,可以利用链式前向星存边的特性,同一条边的序号一定是相邻的,因此在更新 l o w [ x ] low[x] low[x]的时候,需要判断当前边是否和上一条边相同
void Tarjan(int x, int fa) {
low[x] = dfn[x] = ++c_time;
for(int i = head[x]; i != -1; i = edge[i].next) {
int u = edge[i].to;
if(!dfn[u]) {
Tarjan(u, i);
low[x] = min(low[x], low[u]);
if(dfn[x] < low[u]) ans++;
}
else if((i + 1) / 2 != (fa + 1) / 2) low[x] = min(low[x], dfn[u]); //不是同一条边
}
}
例题 [HDU 4378] Caocao’s Bridges
曹操建立了许多岛屿,同时还有连接岛屿的桥,周瑜有一枚炸弹,只能炸毁一座桥,周瑜想摧毁一座桥使得曹操的一个或者多个岛屿与其他岛屿分开。周瑜必须派人携带炸弹来炸毁桥,桥上有守卫,轰炸桥的士兵人数不能少于桥的守卫人数,请问周瑜至少要多少士兵才能完成分离任务
输入
测试用例不超过 12 12 12个。
在每个测试用例中:第一行包含两个整数 N N N和 M M M,意味着有 N N N个岛和 M M M个桥。所有岛都从 1 1 1到 N N N编号。 ( 2 ≤ N ≤ 1000 , 0 < M ≤ N 2 ) (2 \leq N \leq 1000,0 <M \leq N^2) (2≤N≤1000,0<M≤N2)
接下来的 M M M行描述了 M M M个桥。每条线包含三个整数 U , V U,V U,V和 W W W,意味着有一个连接岛 U U U和岛 V V V的桥,并且在该桥上有 W W W守卫。( U ≠ V U≠V U̸=V且 0 ≤ W ≤ 10 , 000 0 \leq W\leq 10,000 0≤W≤10,000)
输入以 N = 0 N = 0 N=0和 M = 0 M = 0 M=0结束。
输出
对于每个测试用例,输出周瑜必须发送的最小士兵号码才能完成任务。如果周瑜无法成功,请输出-1代替。
样例输入
3 3
1 2 7
2 3 4
3 1 4
3 2
1 2 7
2 3 4
0 0
样例输出
-1
4
策略
- 判断图是否连通,不连通输出 − 1 -1 −1(并查集)
- T a r j a n Tarjan Tarjan找权值最小的桥,如果没有桥输出 − 1 -1 −1
- 如果桥上没有人。要输出 1 1 1,要有一个人扛炸药(坑!)
#include <iostream>
#include <cstdio>
#include <cstring>
#include <cctype>
#include <algorithm>
#define N 1005
#define M 1000005
#define INF 0x7fffffff
using namespace std;
inline void read(int &x) {
x = 0; int f = 1; char c = getchar();
while(!isdigit(c)) {if(c == '-') f = -1; c = getchar();}
while(isdigit(c)){x = (x << 3) + (x << 1) + c - 48; c = getchar();}
x *= f;
}
struct Edge{
int to, next, w;
}edge[M * 2];
int head[N], low[N], dfn[N], f[N];
int cnt, c_time, ans, n, m;
inline void init() {
memset(head, -1, sizeof(head));
memset(low, 0, sizeof(low));
memset(dfn, 0, sizeof(dfn));
for(int i = 1; i <= n; i ++)
f[i] = i;
cnt = 0;
c_time = 0;
ans = INF;
}
inline void addEdge(int u, int v, int cost) {
edge[++cnt].to = v;
edge[cnt].w = cost;
edge[cnt].next = head[u];
head[u] = cnt;
}
inline int find(int x) {
if(x == f[x]) return x;
else return f[x] = find(f[x]);
}
inline void unite(int x, int y) {
int u = find(x);
int v = find(y);
f[u] = v;
}
inline bool IsSame(int x, int y) {
int u = find(x);
int v = find(y);
if(u == v) return true;
else return false;
}
inline void Tarjan(int x, int fa) {
dfn[x] = low[x] = ++c_time;
for(int i = head[x]; i != -1; i = edge[i].next) {
int u = edge[i].to;
if(!dfn[u]) {
Tarjan(u, i);
low[x] = min(low[x], low[u]);
if(dfn[x] < low[u]) ans = min(ans, edge[i].w);//更新桥的最小权值
}else if((i + 1) / 2 != (fa + 1) / 2)
low[x] = min(low[x], dfn[u]);
}
}
int main() {
int u, v, cost, flag;
while(1) {
read(n), read(m);
if(n == 0 && m == 0) break;
flag = 0;
init();
for(int i = 1; i <= m; i ++) {
read(u), read(v), read(cost);
unite(u, v);
addEdge(u, v, cost);
addEdge(v, u, cost);
}
for(int i = 1; i < n; i++) //判断图是否是连通的
if(!IsSame(i, i+1)) {
printf("0\n");
flag = 1;
break;
}
if(flag) continue;
Tarjan(1, 0);
if(ans == INF) ans = -1; //没有桥的情况
if(ans == 0) ans++; //桥上没有人的情况
printf("%d\n", ans);
}
return 0;
}