3.7 有向图的强连通分量

连通分量

对于一个有向图, 连通分量: 对于分量中任意u, v, 必然可以从u走到v, 也可以从v走到u

强(极大)连通分量

极大连通分量. 如果连通分量加上任何一个点之后, 都不是连通分量了, 那么称这个连通分量为极大连通分量(强连通分量)

强连通分量的用途

可以把任意一个有向图, 转化称一个有向无环图.
有向图 —>缩点(将所有连通分量缩成一个点)–> 有向无环图(DAG)(拓扑图)

有向无环图/拓扑图的好处: 求最短路/最长路的时候, 可以按照顺序递推O(n + m)
在这里插入图片描述

一般先去求下这个图中所有的强连通分量, 然后将强连通分量缩成一个点, 按照拓扑序来处理这个问题

如何求强连通分量

按照DFS来求
在深度优先搜索的时候将边分成四大类

  1. 树枝边(x,y) 如果x是y的父节点, 由(x,y)形成的边叫做树枝边
  2. 前向边(x,y) 如果x是y的祖先节点

因此树枝边是特殊的前向边
在这里插入图片描述

  1. 后向边(x,y)

如图中所示, 从x点往回搜
在这里插入图片描述

  1. 横插边

往之前搜过的其他分支搜的时候, 连向其他分支的一条边
在这里插入图片描述
深度优先遍历, 往左是可以的, 有无可能往右
因为是dfs, 所以右边的点是没搜过的, 往右就是树枝边在这里插入图片描述

比如说想问下x点是否在某个强连通分量(简称:SCC)中呢?
如果在某个SCC中

  1. 它可以回到路径中的某个点, 即:存在一条后向边指向祖先节点
  2. 走到某条横插边上, 意思是不是自己直接到祖先节点, 而是先走到某个横插边, 横插边上的点走到某个祖先节点上

前向边一直往前走, 通过前向边是不会走回到某个已经搜索过的点的, 是没有环存在的, 因此前向边是不用管的
在这里插入图片描述

Tarjan算法求强连通分量(SCC)

引入时间戳的概念
按照dfs顺序给每一个点一个编号
然后发现一些性质, 如果当前边是树枝边的话, y > x
前向边也是如此

后向边的话y < x
横向边的话往左走, 左边所有点已经遍历过了, 因此y < x
在这里插入图片描述
对每个点定义两个时间戳:
dfn[u]:表示遍历到u的时间戳
low[u]: 从u开始走, 所能遍历到的最小时间戳

在求强连通分量的时候, 求以下每个强连通分量最上面的一个点(即深度最小的点)(也即高度最高的点)

u是其所在强连通分量的最高点, 等价于dfn[u] = low[u], 此时就把u所在的强连通分量找出来了

yxc:证明的话, 就不讲了, 一来是比较麻烦, 而来是用不到

表示从x开始走的话, 无论如何也走不到x上面那个点, 也就是说明x是它所在的强连通分量的最高点

u是最高点, 意味着一旦u回溯之后, 这个点再也走不到强连通分量的任何一个点, 因为是强(极大)连通分量, 如果能走到回溯前的强连通分量的点, 那么与 当前的强连通分量的定义,矛盾

具体看下模板怎么写

void tarjan(int u){
	// 1.先让dfn[u]和low[u]的++时间戳;
	dfn[u] = low[u] = ++ timestamp;
	// 2. 将当前这个点加入到栈中, 再搞一个变量记下当前点是否在栈中
	stk[ ++ top] = u, in_stk[u] = true;
	// 遍历下u的邻点
	for (int i = h[u]; ~i; i = ne[i]){
		int j = e[i];
		if (!dfn[j]){ // 如果当前邻点没有被遍历过的话, 遍历下
			tarjan(j);
			// 然后再更新下, 看下u能到的最小值, 与邻点能到的最小值, 更新下u能到的最小值
			// 因为j是u下面的点, j能回溯到的点, 一定是u能到的点, 因为u->j->回溯点
			low[u] = min(low[u], low[j]);
		}else if (in_stk[j]){ // 如果当前这个点还是在栈当中的话
			// 因为在栈中, j可能在u上面
			// 用这个点的时间戳更新下u的low值 
			low[u] = min(low[u], dfn[j]);
		}
	}
	// 如果遍历完, 发现u能到的最前的一个点就是自己, 那么u就是所在的强连通分量的最高点
	if (dfn[u] == low[u]){
		int y;
		// 首先先给所有强连通分量的个数 + 1
		++ scc_cnt;
		do {
			y = stk[top -- ]; // 每次先取出栈顶元素
			in_stk[y] = false; // 标记下这个元素已经从栈里出来了
			id[y] = scc_cnt; // 一般来说标记下这个点是属于哪一个强连通分量的
		}while (y != u); // 当y == u的时候说明已经将u所在的强连通分量遍历完了
	}
}

注意栈中不仅存了路径上的点, 因为有横插边的存在, 有可能存了不在路径上的其他的点
比方说y可以走到上面的话, 那么y显然是在栈中的在这里插入图片描述

注意⚠️:这个栈和普通的dfs栈是有所区别的
栈里的元素可以大概理解成:里面存的所有点, 都不是它所在的强连通分量的最高点, 是当前还没有搜完的强连通分量的所有点
如果当前点还在栈中, 说明当前点还没出队, 那就说明还在强连通分量当中, 强连通分量还没有被遍历完

注意

		}else if (in_stk[j]){ // 如果当前这个点还是在栈当中的话
			// 用这个点的时间戳更新下u的low值 
			low[u] = min(low[u], dfn[j]);
		}	

此时这个j要么是u的祖先, 要么是横插点, 不管怎么说都是小于dfn[u]的

时间复杂度

总的来看, tarjan算法的时间复杂度应该是线性的O(n + m)

求完之后, 一般要进行后面几步

  1. 缩点
 遍历下所有边, 一般来说用邻接表来存的
 for i = 1; i <= n; i ++ (遍历下所有点)
 		for  i的所有邻点
 			if (i 与 j 不再同一scc中)
 					// id(i) 存的是i所在的scc的编号
 					加一条新边 id(i)->id(j)

注意i与j在同一个连通块中, 不要加边, 如果加边的话; 如果加边就相当于形成自环, 那就不是一个拓扑图了, 不方便(不太懂, 为什么加边会变成负环???)

通过这样的方式, 能够将有向无环图建立出来了, 然后在有向无环图上按照拓扑序来做, 很多问题就会变得非常简单

然后按照连通分量递减的顺序一定是拓扑序

拓扑排序回忆

bfs(基础课)
dfs(算法导论)

	void dfs(int u){
		for u 的所有邻点
			seq <- u
	}

最后拓扑排序一定是seq的逆序

仔细看下tarjan算法, 就是按照深度优先遍历的顺序, 遍历整张图, 遍历完, 然后把当前新的连通分量scc_cnt存到seq当中, 整个搜完之后, 拓扑排序就是seq的逆序

然后加这个点的时候, 一定是按照点的顺序来加的, 一定是按照scc递增的顺序来加的… , 先加1, 2, 3, 4, 因此最终从scc, scc - 1, scc - 2, 1, (可以想象以下, dfs最深出的scc先+ 1) , 得按照scc逆序, 就一定是拓扑序

如果当前的连通分量已经找出来了, 那么当前的连通分量是不可能走到上面的点的
当执行 if (dfn[u] == low[u]){这句话的时候, 这个点所能到所有点已经搜完, 即从这个点出去的所有点, 它的后继都搜完了, 那么将当前这个点放到序列当中, seq<- u, 逆序来看, 这个点的后继 都不会在这个点排序的后面, 因此必然是一个拓扑序

1174. 受欢迎的牛

分析

如果不用强连通分量来做的话, 怎么做?
如果判断一个点被所有点欢迎, 相当于问是否所有点都可以走到当前点
只要从反图上出发判断下这个点是否能遍历到所有点, 就可以了

如果不用强连通分量, 那么每次判断一个点都需要DFS/BFS O(n + m)
挨个遍历所有点 O(n * (n + m))

如果当前图是拓扑图DAG, 如果存在两个终点, 意味着 这两个终点(牛) 不能互相到达, 说明不存在任何一头牛被所有牛欢迎

因为任何点只能到达其中一个终点

所以存在2个出度为0的点(终点), 那么答案就是0
存在1个出度为0的点, 那么答案就是1(终点就是受所有牛欢迎的牛)
为什么是1呢
如果只有1个点出度为0, 是否说明所有点一定可以走到这点呢?
一定可以走到这点的, 因为是有向无环图,对于任何一个点来说, 一直沿着后继走, 因为没有环的存在, 必然会走到头

因此如果是拓扑图的话, 只需要判断下有多少个点出度是0就可以了
如果不是拓扑的话, 先用强连通分量转化成拓扑图, 即:先求下这个图的强连通分量, 缩点->DAG,
最终答案不为0的话, 就必然只有1个点的出度为0, 那么去看下出度为0的点的强连通分量里面有都少个点, 里面的所有点一定是可以被其他点走到的

对于分量外面点的来说, 一定可以走到这个点, 对于内部的点来说, 相互可以到达, 因此分量内的所有点可以被其他点走到的, 因此答案就是分量内的所有点的数量

如果说只有1个强连通分量出度为0的话, 那么答案就是scc内部的点的数量, 否则就是0

时间复杂度线性的

不需要将缩完点后的图建立出来, 只需要枚举下原来的每条边, 看下这条边的两个点是否在连通分量中, 如果不连通分量中, 统计下这个点的出度就可以了, 累加下点的出度

最后遍历下所有点, 看下是否只有一个点的出度为0

code

#include <iostream>
#include <cstring>

using namespace std;

const int N = 100010, M = 50010;

int n, m;
int h[N], e[M], ne[M], idx;
int dfn[N], low[N], timestamp;
int stk[N], top;
bool in_stk[N];
int id[N], scc_cnt, Size[N];
int dout[N];

void add(int a, int b){
    e[idx] = b, ne[idx] = h[a], h[a] = idx ++;
}

void tarjan(int u){
    dfn[u] = low[u] = ++ timestamp;
    stk[ ++ top] = u, in_stk[u] = true;
    for (int i = h[u]; ~i; i = ne[i]){
        int j = e[i];
        if (!dfn[j]){
            tarjan(j);
            low[u] = min(low[u], low[j]);
        }else if (in_stk[j]) low[u] = min(low[u], dfn[j]);
    }
    
    if (dfn[u] == low[u]){
        ++ scc_cnt;
        int y;
        do {
            y = stk[top -- ];
            in_stk[y] = false;
            id[y] = scc_cnt;
            Size[scc_cnt] ++; // 将当前连通分量里点的数量++
        }while (y != u);
    }
}

int main(){
    scanf("%d%d", &n, &m);
    memset(h, -1, sizeof h);
    while (m -- ){
        int a, b;
        scanf("%d%d", &a, &b);
        add(a, b);
    }
    
    for (int i = 1; i <= n; i ++ )
        if (!dfn[i]) // 如果当前点没搜过, 即没有时间戳记录, 那就搜
            tarjan(i);
            
    for (int i = 1; i <= n; i ++ ) // 计算新图上的出度
        for (int j = h[i]; ~j; j = ne[j]){
            int k = e[j];
            int a = id[i], b = id[k];
            if (a != b) dout[a] ++;
        }
        
    int zeros = 0, sum = 0;
    for (int i = 1; i <= scc_cnt; i ++ )
        if (!dout[i]){
            zeros ++;
            sum += Size[i];
            if (zeros > 1){
                sum = 0;
                break;
            }
        }
        
    printf("%d\n", sum);
    
    return 0;
}

367. 学校网络

分析

第1问: 需要给几个软件, 使得整个图中所有学校都有软件
第2问: 最少要加几条边, 使得图变成强连通分量

样例模拟:
1可以到所有点, 2->5->1, 因此2也可以到所有点, 5也可以
4不能到所有点, 3不能到所有点
因此4->1, 3->1 添加这2条边即可

在这里插入图片描述
看下这题怎么做
如果说这个图是一个任意有向图, 不太好求
如果通过缩点变成DAG的话, 问题是否会变得更加简单

假设已经变成DAG, 一共有p个起点(入度为0), q个终点(出度为0)
因为p个起点, 没有其他点可以给它传递信息, 因此至少需要p个软件

给了这p个软件, 那么整个软件能否传递信息呢?

一定的, 因为对于这个网络来说的话, 因为是DAG, 对于网路中的任何一点, 可以看下这个点的前驱是谁, 可以一直看, 由于无环, 路径一定是有限值, 最终一定会到某个起点为止, 因此一定存在起点可以到它

因此给每个起点发一个信息, 整个网络都可以收到信息

因此第1问答案就是p

首先至少要发p个, 而且发p个的话, 能保证每个点都获得信息, 所以答案是p, 其实就是证明了答案 >= p, 然后p是成立的

第2问:
首先结论是 m a x ( P , Q ) max(P, Q) max(P,Q), 只需要加 m a x ( P , Q ) max(P, Q) max(P,Q)里面的最大值条边, 就一定可以把当前图变成强连通分量, 而且 m a x ( P , Q ) max(P, Q) max(P,Q)是最小值, 而且这个最小值是可以达到的
⚠️: 如果只有1个强连通分量的话, 需要特判一下, 答案是0

这是结论
yxc: 网上都是错误的, 终于找到了官方题解, 分析下

首先起点和终点都是对称的
|P|:表示起点中元素个数, |Q|:表示终点元素个数
不妨设|P| <= |Q|, 如果|P| >= |Q|可以翻转过来
我们证明答案是|Q|

|P| = 1
那么图中的所有点可以被这个起点走到, 可以考虑图中每个点, 因为是DAG, 所以可以一直往前驱走, 一定可以走到起点

因此如果想要变成强连通分量, 只需要从每个终点往起点连边就可以了, 对于图中的任何点->终点->起点->任何点

因此在|P| == 1的情况下, 只需要+|Q|条边
当然需要特判下, 如果整个图只有一个点, 即本身是scc, 就返回0

|P| > 1, |Q| >= |P| > 1
|P|至少为2, 可以证明必然可以找到一个起点P1 和 P2, 使得P1---->Q1 P2—>Q2 (yxc:这个思路比较难想到)

证明:
假设无论如何也不能从起点中挑出2个起点, 使得这两个起点走到走到不同的终点, 那就意味着所有起点都走到同一个终点, 即使所有的Pi都会走到Qi. 由题目假设, 终点至少有2个, Qj一定是从某个起点过来的, 因为是DAG, 一定可以顺着前驱找起点. 这与假设 所有起点都只能走到同一个终点, 矛盾.

此时从Q1—>P2, 将P2从起点中去掉, Q1从终点中去掉
在这里插入图片描述

可以一直去|P| - 1次, 使得起点|P’| = 1, |Q’| - (|P| - 1), 那么转化为|P| = 1的情况, 所以总共要加 |Q’| - (|P| - 1), 然后加上头尾连的边|P| - 1

因此需要+ |Q’| - (|P| - 1) + |P| - 1 = |Q’|条

在这里插入图片描述

证明最小性质, 即证明|Q|是最小的

明确下定义: 其他点:不包含起点和终点的点
那么图中包含3种点, 起点, 其他点, 终点, 按照排列顺序枚举, 总共 A 3 2 = 6 A^2_3 = 6 A32=6种边,
连边的话, 只有这几种连边的情况, 1. 起点->其他点, 2.起点->终点 3.其他点->其他点 4.其他点->终点 5. 终点->其他点 6.终点->起点
马上排除1,2, 4. 因为本来就存在了
再者不可能是以下集中情况:
3. 其他点->其他点(❌), 因为这样连边会导致终点->起点这条路不同, 无论怎么连都不通路
5. 终点->其他点(❌), 这样连边会导致 其他点->起点不通
所以只剩可以连6.终点->起点, 不可能存在|Q‘| <|Q| 使得图的scc, 假设存在, 那么必定有一个终点没有连向起点, 与图应是scc,矛盾

code

#include <iostream>
#include <cstring>
using namespace std;
const int N = 110, M = N * 2;
int h[N], e[M], ne[M], idx;
int dfn[N], low[N], timestamp;
int stk[N], top;
bool in_stk[N];
int id[N], scc_cnt;
int din[N], dout[N];
int n;

void add(int a, int b){
    e[idx] = b, ne[idx] = h[a], h[a] = idx ++;
}

void tarjan(int u){
    dfn[u] = low[u] = ++ timestamp;
    stk[ ++ top] = u, in_stk[u] = true;
    for (int i = h[u]; ~i; i = ne[i]){
        int j = e[i];
        if (!dfn[j]){
            tarjan(j);
            low[u] = min(low[u], low[j]);
        }else if (in_stk[j]) low[u] = min(low[u], dfn[j]);
    }
    
    if (low[u] == dfn[u]){
        int y;
        ++ scc_cnt;
        do {
            y = stk[top -- ];
            in_stk[y] = false;
            id[y] = scc_cnt;
        }while (y != u);
    }
}

int main(){
    scanf("%d", &n);
    memset(h, -1, sizeof h);
    
    for (int i = 1; i <= n; i ++ ){
        int t;
        while (cin >> t, t) add(i, t);
    }
    
    for (int i = 1; i <= n; i ++ )
        if (!dfn[i])
            tarjan(i);
            
    // 计算新图上的每个scc的入度, 出度
    for (int i = 1; i <= n; i ++ )
        for (int j = h[i]; ~j; j = ne[j]){
            int k = e[j];
            int a = id[i], b = id[k];
            if (a != b) dout[a] ++, din[b] ++;
        }
    // 计算答案, 入读为0的scc, 和出度为0的scc
    int a = 0, b = 0;
    for (int i = 1; i <= scc_cnt; i ++ ){
        if (!din[i]) a ++;
        if (!dout[i]) b ++;
    }
    
    printf("%d\n", a);
    if (scc_cnt == 1) puts("0");
    else printf("%d\n", max(a, b));
    
    return 0;
            
}

1175. 最大半连通子图

分析

半连通子图
对于任何两个点u, v来说, 要么u–> v, 要么v–>u, 不一定两个都成立, 至少有一个成立

第1问就是求, 缩点之后的, 最长链, 链上点的权重是连通分量里的节点数量
第2问是统计下方案数, 统计下最长链的方案数

首先第1步的话, 可以将图变成拓扑图(DAG), 然后用dp去做
f[i]: 表示以i为终点的最长链的节点数量和是多少, 可以看下i的前驱, 那么所以i为终点的链只能从前驱转移过来, 在前驱里取一个最大值就可以了
同时再搞一个g(i), g(i)存的是让f(i)取到最大值的方案数, 这里的话就变成了经典的统计方案数的问题了

在这里插入图片描述
算方案的时候, 两个点之间的多条边都只算1条边

因此建图的时候, 一定要注意给边判重 , 不判重的话, 可能会有问题, 因为找前驱的时候是按边来算的, 如果有多条边是同一个点的话, 答案就会被计算多次, 就不对了
在这里插入图片描述

在这里插入图片描述

code

#include <iostream>
#include <cstring>
#include <algorithm>
#include <unordered_set>
using namespace std;

typedef long long LL;

const int N = 100010, M = 2000010;

int n, m, mod;
int h[N], hs[N], e[M], ne[M], idx;
int dfn[N], low[N], timestamp;
int stk[N], top;
bool in_stk[N];
int id[N], scc_cnt, scc_size[N];
int f[N], g[N];

void add(int h[], int a, int b) {
    e[idx] = b, ne[idx] = h[a], h[a] = idx ++;
}

void tarjan(int u) {
    dfn[u] = low[u] = ++ timestamp;
    stk[ ++ top] = u, in_stk[u] = true;
    for (int i = h[u]; ~i; i = ne[i]) {
        int j = e[i];
        if (!dfn[j]) {
            tarjan(j);
            low[u] = min(low[u], low[j]);
        }else if (in_stk[j]) low[u] = min(low[u], dfn[j]);
    }
    
    if (low[u] == dfn[u]) {
        ++ scc_cnt;
        int y;
        do {
            y = stk[top -- ];
            in_stk[y] = false;
            id[y] = scc_cnt;
            scc_size[scc_cnt] ++;
        }while (y != u);
    }
}

int main(){
    memset(h, -1, sizeof h);
    memset(hs, -1, sizeof hs);
    
    scanf("%d%d%d", &n, &m, &mod);
    while (m -- ) {
        int a, b;
        scanf("%d%d", &a, &b);
        add(h, a, b);
    }
    
    for (int i = 1; i <= n; i ++ ) 
        if (!dfn[i])
            tarjan(i);
            
    unordered_set<LL> S;
    for (int i = 1; i <= n; i ++ )  
        for (int j = h[i]; ~j; j = ne[j]) {
            int k = e[j];
            int a = id[i], b = id[k];
            LL hash = a * 1000000ll + b; // 对每条边作hash
            if (a != b && !S.count(hash))  {
                add(hs, a, b);
                S.insert(hash);
            }
        }
        
    for (int i = scc_cnt; i ; i -- ) {
        if (!f[i]) {
            f[i] = scc_size[i];
            g[i] = 1;
        }
        
        for (int j = hs[i]; ~j; j = ne[j]) {
            int k = e[j];
            if (f[k] < f[i] + scc_size[k]) {
                f[k] = f[i] + scc_size[k];
                g[k] = g[i];
            }else if (f[k] == f[i] + scc_size[k]) 
                g[k] = (g[k] + g[i]) % mod;
            
        }
    }
    
    int maxf = 0, sum = 0;
    for (int i = 1; i <= scc_cnt; i ++ ) 
        if (f[i] > maxf){
            maxf = f[i];
            sum = g[i];
        }else if (f[i] == maxf) sum = (sum + g[i]) % mod;
        
    printf("%d\n", maxf);
    printf("%d\n", sum);
    
    return 0;
}

368. 银河

分析

hhh, 用糖果那题的代码, 可以过这题

如果用差分约数的思路去求解, 那么题目无解的话, 等价于图中存在正环
即: 先用spfa看下有没有存在正环, 如果存在正环的话, 表示无解
如果没有正环的话, 我们要求一个最小值, 必须要有一个绝对值
而且要求差分约束的话, 需要一个超级源点, 而且超级源点 可以到所有边

在这里插入图片描述

code

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值