Tarjan算法详解(AcWing 1174 受欢迎的牛)

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

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

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

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

输入格式
第一行两个数 N,M;

接下来 M 行,每行两个数 A,B,意思是 A 认为 B 是受欢迎的(给出的信息有可能重复,即有可能出现多个 A,B)。

输出格式
输出被除自己之外的所有牛认为是受欢迎的牛的数量。

数据范围
1≤N≤104,
1≤M≤5×104
输入样例:
3 3
1 2
2 1
2 3
输出样例:
1
样例解释
只有第三头牛被除自己之外的所有牛认为是受欢迎的。
分析:
本题考察tarjan算法,又是一个精彩的图论算法。下面会分两部分展开论述,第一部分主要讲解一下 tarjan算法及其在求有向图的强连通分量上的应用,第二部分主要分析如何用tarjan算法去求解本题。

1、Tarjan算法

几个概念

  • DAG:有向无环图
  • 连通分量:简单地说就是一组顶点中每个顶点都可以到达其他的顶点
  • 强连通分量:极大的连通分量

上面的定义或许不够严谨,但是基本可以概括这些词的含义。或者说,有向图的强连通分量既要求顶点间的连通性,有要求顶点的极大性,即再增加任意一个节点都会使得原连通分量不再连通。

遍历图时点的状态

在之前写的数据结构专栏的图论那章,详细说过图的遍历中节点的状态以及边的分类,这里还是要简单的概述下这些概念。

在对一个图做遍历时,不论是BFS还是DFS,节点在遍历的不同节点被划分成了不同的状态。

  • undiscovered:节点还未被访问到
  • discovered:节点已经被访问到,但其相邻的节点还没有访问完
  • visited:节点及其相邻的节点均已被访问完

如果用伪码形容遍历过程中节点的状态就是

enum{
    undiscovered,
    discovered,
    visited
}
status(u) = undiscovered;
dfs(u) {
    status(u) = discovered;
    for(int i = h[u];~i;i = ne[i]) {
        j = e[i];
        dfs(j);
    }
    status(u) = visited;
}

遍历图时边的分类

用通俗语言对边进行分类就是

  • tree:dfs生成的dfs支撑树中的边就是树边
  • backward:dfs树中后代节点指向祖先节点的边就是后向边
  • forward:dfs树中祖先节点指向后代节点(孩子节点除外)的边就是前向边
  • cross:dfs树中节点指向在此之前访问的节点(祖先节点除外)的边就是跨越边

当然,这四类边还有更加精准的定义。在此之前,需要引入节点的生命周期的概念。在dfs遍历图时,一个节点 u u u刚被遍历到,状态随即转化为了 d i s c o v e r e d discovered discovered,记录下被访问到的时间 d t i m e dtime dtime;紧接着会去访问 u u u的相邻节点,在此之前,节点 u u u会被压入栈中。当 u u u的相邻节点都被遍历回溯完后, u u u从栈中弹出,状态随即转化为 v i s i t e d visited visited,记录下此时的时间为 f t i m e ftime ftime

dfs过程中,每个节点从刚被访问完的入栈到其相邻节点都访问完后的出栈的这段时间,被称为这个节点的生命周期(dtime, ftime)。(当然,还有些概念像括号引理,以及证明会用到的割边,桥,关节点之类的这里就不再说明了。)

于是我们可以得到更加精确的定义

  • tree:discovered节点到undiscovered节点的边
  • backward:discovered节点到discovered节点的边
  • forward:discovered节点u到visited节点v的边,并且dtime(u) < dtime(v)
  • cross:discovered节点u到visited节点v的边,并且dtime(u) > dtime(v)

上面这些概念虽然看起来繁杂,但是只要仔细体会dfs遍历的过程,也就不难理解了。

Tarjan算法的推导

对于一个DAG而言,每个节点都是单独的一个强连通分量,也就没必要去求解了,需要求解的图往往包含环路,如何判断一个图是否有环呢?

答案很简单,就是DFS过程中含有后向边的图就一定包含环路。解释起来也很简单,显然祖先节点一定可以到达后代节点,而存在后向边的条件就是后代节点存在指向祖先节点的边,所以环路一定存在。

1
2
3
4
5
6

如上图所示,图里一共有三个强连通分量:1,【2,3,4,5】,6。因为2 3 4 5构成了一条回路,他们之间是彼此可达的。我们需要求的是

  • 哪些点属于同一个强连通分量
  • 这些强连通分量可以缩到哪个节点里

2,3,4,5属于同一个强连通分量毫无疑问,如果将它们缩成一个节点2,原图就变成了1到2再到6的DAG了。为什么选择2作为这个强连通分量的代表呢?

如果从节点1开始遍历,那么2的dtime就是2,是这个强连通分量里dtime最小的节点,也是dfs支撑树里3,4,5节点的祖先节点,所以选择2作为这个强连通分量的代表节点。

那么如何判断哪些节点属于同一个强连通分量呢?Tarjan算法可以解决这一问题。

首先需要引入low[u]的概念,我们暂且将low[u]理解为u可以到达的节点中dtime的最小值。对low数组的理解会直接影响对Tarjan算法的理解,我们前面说到,一个强连通分量里如果包含多个节点,这个强连通分量一定含环路,并且环路中一定含有backward边。如果这里的环路是像上面的图一样,仅仅由tree边和backward边构成,那么在low[u]表示u可以到达的dtime最小的节点的dtime时,可以看出low[1] = 1,low[2] = low[3] = low[4] = low[5] = 2,low[6] = 6,然后发现原来同一个强连通分量内节点的low值都相等,所以只用一遍DFS,一边统计每个节点的low值就可以解决该问题了。

问题并非这么简单。

1
2
7
3
4
5
6

如图所示,1遍历到2,2遍历到7,7的状态转为visited,然后2,继续遍历到3,4,5, 5在遍历到7的时候发现7的状态是visited,而dtime[7] < dtime[5],所以5到7的这条边是cross边,这条cross导致了5可以到达7,也就使得了low[5] = low[7] = 3,但是很显然,5和7并不在同一个强连通分量里,这就推翻了前面的节点low值相同,就在同一个强连通分量里的结论。那是否可以加上cross边不更新low值的限制呢?这样一来5的low值就和7不同了。

1
2
4
3
5

如图所示,1遍历到2,再到3, 3到1是backward边;然后3变成visited状态,接着1
遍历到4,5, 5遍历到3时发现3的状态是visited,并且dtime[3] < dtime[5],所以5到3是cross边,如果此时不用3的low值更新5的low值的话,3和5就被判定不在同一连通分量里了,但是显然,此时的图就是一个强连通分量。
既然不能通过cross边不更新low值这个条件解决该问题,那么还有其它什么办法可以解决问题呢?

不难发现,一个节点刚遍历到的low值就是等于dtime值的,此时能到达的最早遍历的节点就是节点本身,而最早更新low值的节点一定是通过backward边更新的low值,上图的例子是5经过cross边到达3,因为3可以经过backward边到达1,才使得包含cross的边也成为了回路的一员,这时5通过3的low值更新low值时,会发现3的low值是小于dtime值的;如果3没有通过backward边到1的回路,3的dtime值就等于low值,此时不可更新通过cross边连向3的节点5的low值。从而我们可以得出结论,通过跨边更新low值,只有当目标节点的low值小于dtime值时才能进行更新

这样一来,low数组的含义就要稍微改变下了,不再是节点能够到达的节点中最小的dtime值了,而是节点通过backward边能够到达节点的dtime值。

于是不难得出以下Tarjan算法的伪码:

void tarjan(int u) {
    dtime[u] = low[u] = ++t;
    for(int i = h[u];~i;i = ne[i]) {
        int j = e[i];
        if(!dtime[j]) { //Tree
            tarjan(j);
            low[u] = min(low[u], low[j]);
        } else if(!ftime[j] || (dtime[u] > dtime[j] && low[j] < dtime[j]) { //backward or cross which has ever passed backward
            low[u] = min(low[u], low[j]);
        }
    }
    ftime[u] = ++t;
}

稍微解释下就是dtime的值为0,说明节点第一次被访问到,对于tree边而言,我们需要用相邻节点的low值去更新u的low值;如果ftime[j]是0,说明j的相邻节点还没有访问完,此时的边是backward边,也可以更新u的low值;如果ftime[j]非0,并且u的dtime大于j的dtime,说明此时是cross边,只要这时候j的low值小于dtime值,就可以更新u的low值。

上面的算法看似可以准确的求出所有节点的low值,实际上存在着不小的漏洞,这点在后续的分析中会讲到。
我们暂且认为可以通过这种方式正确的求出每个节点的low值,然后low值相同的就是处在同一个强连通分量内的节点。且不说我们需要继续遍历所有节点的low值才能获取强连通分量,我们还需要遍历时记录下dtime对应的节点号,才能知道最后强连通分量需要缩到哪个点上,还是比较麻烦的。

真正的Tarjan算法

void tarjan(int u) {
    dtime[u] = low[u] = ++t;
    stk[++top] = u, in_stk[u] = true;
    for(int i = h[u];~i;i = ne[i]) {
        int j = e[i];
        if(!dtime[j]) { //Tree
            tarjan(j);
            low[u] = min(low[u], low[j]);
        } else if(in_stk[j]) { //backward or cross which has ever passed backward
            low[u] = min(low[u], dtime[j]);
        }
    }
    if (low[u] == dtime[u]) {//visited and u is the lowest node
        scc_cnt++;
        int y;
        do {
            y = stk[top--];
            id[y] = scc_cnt;
            scc_size[scc_cnt]++;
            in_stk[y] = false;
        } while(y != u);
    }
}

观察代码可以发现,真正的Tarjan算法和之前伪码的实现还是有些许差异的。这其中的差异包括,backward边以及符合条件cross边的判断,以及该分支下更新u的low值是采用了j的dtime值而不是low值。

之前我们在分析dfs遍历过程时,曾经说过,遍历到某节点时,该节点会被压入栈中,而当该节点相邻节点均被访问完成后,该节点会从栈中弹出,虽然这一过程是不需要我们进行干涉的,但是却是tarjan算法的重要思路。

强连通分量在进行缩点时,会选择缩到dtime最小的节点,该节点也是其他节点的祖先节点,因此,当强连通分量里dtime最小的节点出栈之际,这个强连通分量里的其他节点肯定也都被遍历过了,此时就可以开始统计这个强连通分量里所有的节点了。

既然缩点的节点是强连通分量里dtime最小的节点,那么它的dtime肯定是等于low的,换句话说,我们可以在dtime[u] == low[u]之际开始统计强连通分量节点,为了能方便统计强连通分量的节点,我们模仿系统栈也设置一个栈,节点首次被访问时入栈,节点相邻节点被访问完成之际却不一定可以出栈,因为我们需要在缩点的节点出栈之际统计下强连通分量的节点,因此,如果某个节点相邻节点被访问完成了,如果它不是强连通分量里最高的节点,dtime不等于low的话,就需要一直留在栈中。这样一来,遍历以u为根的dfs子树时,如果某个节点和u不在同一强连通分量里,那么在u的状态变成visited时,该节点所在的强连通分量肯定都已经出栈了,此时栈里面在u节点上方的节点,只会是和u在同一个强连通分量里的节点。

这个栈的作用不仅仅是方便统计强连通分量的节点,tarjan算法的else if分支里面的判断条件,是u到j的边是backward边或者是j的dtime值大于low值的cross边。此时栈中节点有两种状态,一种是discovered状态,表示这个节点孩子节点还没有访问完成,此时u到j就是backward边;另一种是visited状态,但是因为dtime值不等于low值,迟迟不能出栈,这也就正好是dtime值小于low值的cross边。可以发现,只要j还在栈中,就能够准确的表示这两种状态。到这里Tarjan算法的基本思想也就分析完了,但是还有值得深究的地方。

dtime[j] or low[j]

else if(in_stk[j]) { //backward or cross which has ever passed backward
            low[u] = min(low[u], dtime[j]);
        }

为什么在第二个分支下是用j的dtime值而不是low值去更新u的low值?这个问题算是Tarjan最大的难点之一了。如果直接用low[j]去更新low[u],程序执行结果完全一致,而且似乎更加合理。

1
2
4
3
5

如图所示,1遍历到2再到3,此时3的dtime是3, 3到1有一条backward边,所以3的low值会被更新成1,之后1通过4,5再次遍历到3时,5到3有一条cross边,如果通过3的low值更新5的low值,那么low[5] = low[3] = 1,最后所有节点的low值都是1,也就是整个强连通分量里面节点的low值都是1,符合low数组最初的定义,通过backward边能够到达的节点的最小dtime值。

如果通过3的dtime值更新5的low值,那么low[5] = 3,以至于后续的4,5节点的low值都被更新成了3,这与low数组的定义不符,明明这些节点都可以到达更高的节点1,low值记录的却是3,是否是tarjan算法这里写的不对呢?

如果low[u] = min(low[u], dtime[j]);不对,那么low[u] = min(low[u], low[j]);是否就正确呢?

6
7
1
2
4
3
5

对于上图的场景,6到7到1,1的dtime是3,然后到2到3,3的dtime是5,low[3] = 3,之后1遍历到4到5,此时通过3的low值更新5的low值的话,low[5] = 3,之后1开始遍历到下一个相邻节点6,通过6的low值更新了自己的low值为1。

可以发现,此时图中强连通分量的最小的low值应该是1,而3和5的low值却还是3,这是不是也不符合low数组的定义呢?

在j的low值后续会更新的场景下,即使通过j的low值去更新u的low值,u的low也不能保证是真正的low值,因为j的low值后续可能会被更新到更小。因此,写成low[u] = min(low[u], low[j]);low数组的定义会被破坏,而且我们无法找到一个合适的定义去修正此时low的定义。

实际上low数组并不需要表示节点通过backward边能够到达节点的最小的dtime值。只需要保证强连通分量里除了dtime最小节点外,其它节点的low值比dtime值小即可。我们只需要考虑low值小于dtime值就可以更新跨边的low值,至于用low表示节点能够到达节点的最小dtime,只是为了方便找出强连通分量里有哪些节点,但是tarjan算法里强连通分量的点都会保存在栈中,以致于low的具体值并没有那么重要了,这也是为什么不管用j的dtime还是low去更新u的low值都不会影响算法的正确性。

那么为什么最后还是选择通过dtime值去更新呢?因为low[u] = min(low[u], dtime[j]);会使得low数组有个准确的定义。尽管此时low数组无法表示节点通过backward边可以到达节点的最小dtime值,却可以表示节点通过backward边后能够到达的dtime值或者是通过cross边后能够到达的被backward边更新过low值节点的dtime值。这才是tarjan算法中low数组真正的含义。

2、受欢迎的牛

下面开始分析本题的求解思路。

题目要找出除自己外受其他所有牛欢迎的牛的数量,也就是在图中找出其它节点都可以到达节点的数量。如果这张图是DAG,那么如果图里只有一个出度为0的节点,这个节点就是所要求的点;如果有两个出度为0的节点,那么就没有符合要求的点。

1
2
3

像上图这样出度为0的节点只有3,所以3就是最受欢迎的牛。

1
2
3
4

而向这样的出度为0的点不止一个,那么就没有每个节点都可以到达的节点了,因为3和4是彼此不可达的。
对于含有环的图而言,则需要进行缩点,转化为DAG进行求解。

1
2
3
4

比如上图,2,3,4构成了一个强连通分量,将它们缩到节点2,得到了一个1到2的DAG,此时2的出度是0,而构成2的强连通分量由3个节点构成,那么最受欢迎的牛就是3头。

由于本题只需要求受欢迎的牛的数目,所以并不需要真的进行缩点,只需要记录下每个节点属于哪个强连通分量,以及每个强连通分量包含节点的数目。

总的代码如下:

#include <algorithm>
#include <cstdio>
#include <cstring>
using namespace std;
const int N = 10005, M = 50005;
int idx,h[N],e[M],ne[M];
int dtime[N],low[N];
int stk[N],id[N],scc_size[N];
int outd[N];
int t = 0, top = 0, scc_cnt = 0;
bool in_stk[N];
void add(int a,int b) {
    e[idx] = b,ne[idx] = h[a],h[a] = idx++;
}
void tarjan(int u) {
    dtime[u] = low[u] = ++t;
    stk[++top] = u, in_stk[u] = true;
    for(int i = h[u];~i;i = ne[i]) {
        int j = e[i];
        if(!dtime[j]) { //Tree
            tarjan(j);
            low[u] = min(low[u], low[j]);
        } else if(in_stk[j]) { //backward or cross which has ever passed backward
            low[u] = min(low[u], dtime[j]);
        }
    }
    if (low[u] == dtime[u]) {//visited and u is the lowest node
        scc_cnt++; //the num of SCC
        int y;
        do {
            y = stk[top--];
            id[y] = scc_cnt;
            scc_size[scc_cnt]++;
            in_stk[y] = false;
        } while(y != u);
    }
}
int main() {
    memset(h, -1, sizeof h);
    int n, m, a, b;
    scanf("%d%d",&n,&m);
    for(int i = 0;i < m;i++) {
        scanf("%d%d",&a,&b);
        add(a, b);
    }
    for(int i = 1;i <= n;i++) {
        if(!dtime[i]) { //undiscovered
            tarjan(i);
        }
    }
    for(int i = 1;i <= n;i++) {
        for(int j = h[i];~j;j = ne[j]) {
            int k = e[j];
            if(id[i] != id[k]) {
                outd[id[i]]++; //count the outdegree of SCC
            }
        }
    }
    int cnt = 0, ans = 0;
    for(int i = 1;i <= scc_cnt; i++) {
        if(!outd[i]) {
            cnt++;
            if(cnt > 1) {
                ans = 0;
                break;
            }
            ans += scc_size[i];
        }
    }
    printf("%d\n",ans);
}
  • 10
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 6
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值