教你把有向图的强连通分量刻进DNA里

简单来说,在有向图里有几个点构成的集合,这个集合里任意两个点之间都可以相互走到,那么这个集合就被称为有向图的连通分量 ,比如 A->B->C->A构成的一个环就是一个连通分量。

 

一个连通分量可以有多个小的连通分量构成,强连通分量也就是该连通分量中的极大连通分量。(就是最大的那个,但是在整个图里来看,不能说最大,只能说极大)。

特别的,只有一个点也看做连通分量,这个连通分量里的强连通分量就是他本身。

那么强连通分量可以做什么呢?

先来看一条题目:

每一头牛的愿望就是变成一头最受欢迎的牛。

现在有 N 头牛,编号从 1 到 N,给你 M 对整数 (A,B),表示牛 A 认为牛 B 受欢迎。

这种关系是具有传递性的,如果 A 认为 B 受欢迎,B 认为 C 受欢迎,那么牛 A 也认为牛 C 受欢迎。

你的任务是求出有多少头牛被除自己之外的所有牛认为是受欢迎的。

(《信息学奥赛一本通》)

 我们可以将A认为B受欢迎看做A到B有一条单项边,那么所有的牛之间的关系就构成了一张图,最后我们要求有多少牛是“大明星”,也就是被所有牛欢迎,相当于每一头牛向这头明星牛之间都会有一条路径连接。(想一想,什么点会被其他所有的点都有路径相连?是不是拓扑图的终点?)

按照这个思路,如果关系图是一张拓扑图,那么明星牛就是拓扑序列的终点,求明星牛的个数也就是求拓扑序列的终点个数。那么我们的问题就变成为如何将这个图转化为有向无环图(也就是拓扑图)了。

根据连通分量的定义,在同一个连通分量里的任意两点都是可以相互连通的,那么其实我们就可以将这个连通分量看做一个点(这个操作被称为缩点,将所有的连通分量都看做一个点之后,我们可以惊奇的发现这个图变成了有向无环图!因为如果这个图不是有向无环图,那就是说这个图里还有环,只要有环就必定可以组建出一个连通分量,那么我们可以继续缩点,直到没有连通分量为止。最后的终点就是我们明星牛的个数,但其实这个“点”是缩点,是一整个连通分量,所以要统计一个这个连通分量中点的个数。

那么我们的问题解决了,捋一下思路:

1、将牛与牛之间的欢迎关系建立成一张有向图

2、找出所有的强连通分量

3、缩点,将图转换成拓扑图

4、求出拓扑图中的终“点”的个数

我知道你想说什么,如果原来的图不连通咋办?比如有个社恐牛,他不喜欢任何牛,也不被任何牛喜欢,其实不用担心,我们在找所有的强连通分量的时候就不带他玩了(社恐人狂喜)。

讲了这么多,是时候上点干货了:

如何找出强连通分量(tarjan算法)

首先引入一个时间戳的概念:我们用dfs遍历一遍一张图,第一次走到某个点的时候留下一个记号,最后按照记号的先后顺序把这张图的所有可以走到的点排成一队,这就是时间戳,用数组dfn来存储(也就是说dfn[1]表示起点,dfn[2]表示从起点出发走到的第一个点,以此类推)

Q:为什么要时间戳?

A:因为原来的节点编号除了告诉我们它是谁以外没有作用,时间戳相当于给他们了一个学号,这样我们后续操作不但知道他是谁,还能知道他是被第几个遍历到的,更加的方便。

然后介绍一个新的数组:low[i],这里面存的是从第 i 个节点出发所能走到的时间戳最小的节点。

因为每个点的时间戳都不一样,那么每个强连通分量里的最小时间戳也都不一样,那我们就把选它为“点大代表”,把整个连通分量都缩点缩到它身上。

首先,在初始化的时候我们进行一波dfs的遍历,然后令所有的low[i]等于它的时间戳,遍历回来的时候就可以知道这个节点最远可以走到哪。

在我们dfs搜索的过程中再引入一些概念:树枝边、前向边、后向边和横叉边

树枝边:即构成dfs搜索的每一条边,树枝边的两边时间戳相差1

前向边:从祖先结点连向子孙结点的边,前向边的两边时间戳相差大于1

后向边:从子孙结点连向祖先结点的边,后向边的两边时间戳相差大于1

横叉边:不同父节点的边相连,横叉边的两边时间戳相差不固定

不难发现,几个点在同一连通分量内当且仅当

1、存在后向边

2、存在横叉边且横叉边连接的点存在后向边

如果遍历完之后,low[i] = dfn[i],说明我们当前这个节点走不动了,最远就是它自己,也就是说它是包括它自己的这个连通分量时间戳最低的点。那么我们就新建一个连通分量,把刚才能走到的所有点都加进来,这就是这个点所在的强连通分量了。(刚才能走到的点我们可以用一个栈来存储,加点的过程即是清栈的过程)。

那知道原理之后代码就很好写了:

void tarjan(int u) {
    dfn[u] = low[u] = ++timestamp;         //初始化,设置时间戳
    stk[++top] = u; in_stk[u] = true;      //stk用来存储u所能走到的所有点
    
    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]);  //回来了,更新一下low
        }
        else if (in_stk[j]) {
            low[u] = min(low[u], dfn[j]);  //如果已经走过这个点了,康康时间戳是不是更小
        }
    }
    
    if (low[u] == dfn[u]) {       //这个点是“点大代表”
        scc_cnt++;                //scc就是强连通分量英文的缩写
        int y;
        do {
            y = stk[top--];
            in_stk[y] = false;    //加点即清栈
            id[y] = scc_cnt;      //这个点就属于编号为scc_cnt的强连通分量了
            Size[scc_cnt]++;      //用一个数组存储强连通分量里的点的个数,方便最后判断重点有几个
        } while (y != u);
    }
}

建立拓扑图也很简单,把每一个scc当做一个点来做就可以:

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]++;
    }
}

最后统计终“点”中包含的点的个数就是最终的答案。

有人会说:有向图强连通分量不就是环吗?

是,也不仅仅是。环一定是连通分量,但强连通分量里面可能包括不止一个环,若干环相扣也可以形成一个强连通分量。


完整代码如下:

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 10010, M = 1e5+10;

int n, m;
int dfn[N], low[N], timestamp;
int e[M], ne[M], h[N], idx;
int scc_cnt, Size[N], stk[N], top, dout[N], sum, id[N];
bool in_stk[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]) {
        scc_cnt++;
        int y;
        do {
            y = stk[top--];
            in_stk[y] = false;
            id[y] = scc_cnt;
            Size[scc_cnt]++;
        } while (y != u);
    }
}

int main() {
    cin >> n >> m;
    memset(h, -1, sizeof h);
    for (int i = 0; i < m; i++) {
        int a, b;
        cin >> 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 dcnt = 0;
    for (int i = 1; i <= scc_cnt; i++) {
        if (!dout[i]) {
            sum += Size[i];
            dcnt++;
        }
        if (dcnt > 1) {
            sum = 0;
            break;
        }
    }
    cout << sum << endl;
    return 0;
}

应用——2-SAT问题 

2-SAT:简单的说就是给出 n 个集合,每个集合有两个元素,已知若干个 <a,b>,表示 a 与 b 矛盾(其中 a 与 b 属于不同的集合)。然后从每个集合选择一个元素,判断能否一共选 n 个两两不矛盾的元素。显然可能有多种选择方案,一般题中只需要求出一种即可。

对于这类问题我们可以通过离散数学的数理逻辑分析来解决:

假设每一对

谢谢阅读到这里,如果觉得对你有帮助的话,求点赞,求收藏,最后再点一个大大的关注,我们下期再见!

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值