在上一篇文章当中我们分享了强连通分量分解的一个经典算法Kosaraju算法,它的核心原理是通过将图翻转,以及两次递归来实现。今天介绍的算法名叫Tarjan,同样是一个很奇怪的名字,奇怪就对了,这也是以人名命名的。和Kosaraju算法比起来,它除了名字更好记之外,另外一个优点是它只需要一次递归,虽然算法的复杂度是一样的,但是常数要小一些。它的知名度也更高,在竞赛当中经常出现。

先给大家提个醒,相比于Kosaraju算法,Tarjan算法更难理解一些。所以如果你看完本文没有搞明白的话,建议可以阅读一下上一篇文章。这两个算法的效果和复杂度都是一样的,其实学会一个就可以,没必要死磕

算法数据结构 | 三个步骤完成强连通分量分解的Kosaraju算法

算法框架

我们来思考一个问题,对于强连通分量分解的算法来说,它的核心原理是什么?

如果你看过我们之前的文章,那么这个问题对你来说应该不难回答。既然是强连通分量,意味着分量当中每个点都可以互相连通。所以我们很容易可以想到,我们可以从一个点出发,找到一个回路让它再回到起点。这样途中经过的点就都是强连通分量的一部分。

但是这样会有一个问题,就是需要保证强连通分量当中的每个点都被遍历到,不能有遗漏。针对这个问题我们也可以想到解法,比如可以用搜索算法去搜索它所有能够达到的点和所有的路径。但是这样一来,我们又会遇到另外一个问题。这个问题就是强连通分量之间的连通问题

我们来看个例子:

在上面这张图当中如果我们从点1出发,我们可以达到图中的每一个点。但是我们会发现1,2,3是一个强连通分量,4,5,6是另外一个。当我们寻找1所在的强连通分量的时候,很有可能会把4,5,6这三个点也带进来。但问题是它们是自成分量的,并不应该算在1的强连通分量当中。

我们整理一下上面的分析和思路可以发现强连通分量分解这个算法的核心其实就是解决这两个问题,就是完备性问题。完备意味着不能遗漏也不能冗余和错误,我们想明白核心问题所在之后就很容易搭建起思维框架,接下来我们再来看算法的描述会容易理解得多。

算法细节

Tarjan算法的第一个机制是时间戳,也就是在遍历的时候对每一个遍历到的点打上一个值。这个值表示这是第几个遍历的元素。

这个应该很好理解,我们只需要维护一个全局的变量,在遍历的时候去让它自增就可以了。我们来写下Python代码给大家演示一下:

stamp = 0
stamp_dict = {}
def dfs(u):
stamp_dict[u] = stamp
stamp += 1
for v in Graph[u]:
dfs(v)

通过时间戳我们可以知道每个点被访问的顺序,这个顺序是正向顺序。举个例子,比如说假设u和v两个点,u的时间戳比v小。那么它们之间的关系只有两种可能,第一种是u能够连通到v,说明从u到v的链路可以走通。第二种是u不能连通到v,这种情况不论反向的从v到u能否连通都不具有讨论意义,因为它们一定不能互相连通。

所以我们想要找到连通的通路还需要找到反向的路径,在Kosaraju算法当中我们是通过反向图来实现的。在Tarjan当中则采取了另外一种方法。因为我们已经知道各个点的时间戳了,我们完全可以通过时间戳来寻找反向的路径。什么意思呢?其实很简单,当我们在遍历u的时候如果遇到了一个比u时间戳更小的v,那么说明就存在一条反向的路径从u通向v。如果v这时候还没有出栈,意味着v是u的上游的话,那么也就说明存在一条路径从v通向u。这样就说明了u和v可以互相连通。

既然找到了一对互相连通的u和v,那么我们需要把它们记录下来。但问题是我们怎么知道记录到什么时候为止呢?这个边界在哪里?Tarjan算法设计了另外一个巧妙的机制解决了这个问题。

这个机制就是low机制,low[u]表示u这个点能够连通到的所有的点的时间戳的最小值。时间戳越小说明在搜索树当中的位置越高,也可以理解成u能够连通到的处在搜索树中最高的点。那么很明显了,这个点就是u这个点所在强连通分量所在搜索树某一棵子树的树根。

这里可能有一点点绕,我们再来看张图:

图中节点所在的序号就是递归遍历的时间戳,我们可以发现对于图上的每个点来说它们的low值都是1。很明显1这个点在搜索树当中是2,3,4这三个点的祖先。也就是说这一个强连通分量的遍历是从1这个点开始的。当1这个点出栈的时候,意味着以1位树根的子树已经遍历完了,所有可能存在的强连通分量也都已经找完了。

这就带来了另外一个问题,我们假设当前点是u,我们如何知道u这个点是否是图中1这样的树根呢?有没有什么办法可以标记出来呢?

当然是有的,这样的点有一个特性就是它们的时间戳等于它们的low。所以我们可以用一个数组维护找到的强连通分量,当这些强连通分量能够遍历到的树根出栈的时候,把数组清空。

我们把上面的逻辑整理一下就可以写出代码来了:

scc = []
stack = [] def tarjan(u):
dfn[u], low[u] = stamp, stamp
stamp += 1
stack.append(u) for v in Graph[u]:
if not dfn[v]:
tarjan(v)
low[u] = min(low[u], low[v])
elif v in stack:
low[u] = min(low[u], dfn[v]) if dfn[u] == low[u]:
cur = []
# 栈中u之后的元素是一个完整的强连通分量
while True:
cur.append(stack[-1])
stack.pop()
if cur[-1] == u:
break
scc.append(cur)
// Cpp
int dfn[N], low[N], dfncnt, s[N], in_stack[N], tp;
int scc[N], sc; // 结点 i 所在 scc 的编号
int sz[N]; // 强连通 i 的大小
void tarjan(int u) {
low[u] = dfn[u] = ++dfncnt, s[++tp] = u, in_stack[u] = 1;
for (int i = h[u]; i; i = e[i].nex) {
const int& v = e[i].t;
if (!dfn[v]) {
tarjan(v);
low[u] = min(low[u], low[v]);
} else if (in_stack[v]) {
low[u] = min(low[u], dfn[v]);
}
}
if (dfn[u] == low[u]) {
++sc;
while (s[tp] != u) {
scc[s[tp]] = sc;
sz[sc]++;
in_stack[s[tp]] = 0;
--tp;
}
scc[s[tp]] = sc;
sz[sc]++;
in_stack[s[tp]] = 0;
--tp;
}
}

最后,我们来看一下之前讲过的经典例子:

首先我们从1点开始,一直深搜到6结束,当遍历到6的时候,DFN[6]=4,low[6]=4,当6出栈时满足条件,6独立称为一个强连通分量。

同理,当5退出的时候也同样满足条件,我们得到了第二个强连通分量。

接着我们回溯到节点3,节点3还可以遍历到节点4,4又可以连向1。由于1点已经在栈中,所以不会继续递归1点,只会更新low[4] = 1,同样当4退出的时候又会更新3,使得low[3] = 1。

最后我们返回节点1,通过节点1遍历到节点2。2能连通的4点已经在栈中,并且DFN[4] > DFN[2],所以并不会更新2点。再次回到1点之后,1点没有其他点可以连通,退出。退出的时候发现low[1] = DFN[1],此时栈中剩下的4个元素全部都是强连通分量。


到这里,整个算法流程的介绍就算是结束了,希望大家都可以enjoy今天的内容。

算法学习笔记:Tarjan算法的更多相关文章

  1. 学习笔记--Tarjan算法之割点与桥

    前言 图论中联通性相关问题往往会牵扯到无向图的割点与桥或是下一篇博客会讲的强连通分量,强有力的\(Tarjan\)算法能在\(O(n)\)的时间找到割点与桥 定义 若您是第一次了解\(Tarjan\) ...

  2. [学习笔记] Tarjan算法求桥和割点

    在之前的博客中我们已经介绍了如何用Tarjan算法求有向图中的强连通分量,而今天我们要谈的Tarjan求桥.割点,也是和上篇有博客有类似之处的. 关于桥和割点: 桥:在一个有向图中,如果删去一条边,而 ...

  3. [学习笔记] Tarjan算法求强连通分量

    今天,我们要探讨的就是--Tarjan算法. Tarjan算法的主要作用便是求一张无向图中的强连通分量,并且用它缩点,把原本一个杂乱无章的有向图转化为一张DAG(有向无环图),以便解决之后的问题. 首 ...

  4. C / C++算法学习笔记(8)-SHELL排序

    原始地址:C / C++算法学习笔记(8)-SHELL排序 基本思想 先取一个小于n的整数d1作为第一个增量(gap),把文件的全部记录分成d1个组.所有距离为dl的倍数的记录放在同一个组中.先在各组 ...

  5. Manacher算法学习笔记 | LeetCode#5

    Manacher算法学习笔记 DECLARATION 引用来源:https://www.cnblogs.com/grandyang/p/4475985.html CONTENT 用途:寻找一个字符串的 ...

  6. [ML学习笔记] XGBoost算法

    [ML学习笔记] XGBoost算法 回归树 决策树可用于分类和回归,分类的结果是离散值(类别),回归的结果是连续值(数值),但本质都是特征(feature)到结果/标签(label)之间的映射. 这 ...

  7. 学习笔记 - Manacher算法

    Manacher算法 - 学习笔记 是从最近Codeforces的一场比赛了解到这个算法的~ 非常新奇,毕竟是第一次听说 \(O(n)\) 的回文串算法 我在 vjudge 上开了一个[练习],有兴趣 ...

  8. Johnson算法学习笔记

    \(Johnson\)算法学习笔记. 在最短路的学习中,我们曾学习了三种最短路的算法,\(Bellman-Ford\)算法及其队列优化\(SPFA\)算法,\(Dijkstra\)算法.这些算法可以快 ...

  9. 某科学的PID算法学习笔记

    最近,在某社团的要求下,自学了PID算法.学完后,深切地感受到PID算法之强大.PID算法应用广泛,比如加热器.平衡车.无人机等等,是自动控制理论中比较容易理解但十分重要的算法. 下面是博主学习过程中 ...

  10. Johnson 全源最短路径算法学习笔记

    Johnson 全源最短路径算法学习笔记 如果你希望得到带互动的极简文字体验,请点这里 我们来学习johnson Johnson 算法是一种在边加权有向图中找到所有顶点对之间最短路径的方法.它允许一些 ...

随机推荐

  1. 使用ModelForm校验数据唯一性

    在设计模型类的时候,将指定字段设置unique=true属性,可以保证该字段在数据库中的唯一性. 使用ModelForm可以将指定模型类快速生成表单元素.在提交数据后,使用is_valid()校验时, ...

  2. Deepin v15.11驱动安装问题

    最近想用Linux跑深度学习,试了好几个发行版,最终选择了Deepin v15.11,但由于配置比较新,它不能兼容很多驱动,还得自己装,以下是我失败N次后得到的经验: 电脑配置 配置如下: 型号:DE ...

  3. webpack5文档解析(下)

    声明:所有的文章demo都在我的仓库里 代码分离 代码分离的有点在于: 切割代码,生成不同的打包文件,按需加载这些文件. 每个bundle的体积更小 控制外部资源的加载顺序 常用的方法有: 入口起点: ...

  4. 503. 下一个更大元素 II

    503. 下一个更大元素 II 给定一个循环数组(最后一个元素的下一个元素是数组的第一个元素),输出每个元素的下一个更大元素.数字 x 的下一个更大的元素是按数组遍历顺序,这个数字之后的第一个比它更大 ...

  5. ssm整合所用全部依赖pom.xml(idea版)

    <?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven ...

  6. MySql基础(常用)

    MySQL常用语句 1.查看当前所有数据库 show databases; 2.打开指定的库 use 库名; 3.查看当前库中的所有表 show tables; 4.查看其他库的表 show tabl ...

  7. Flask常用扩展(Extentions)

    Flask常用扩展(Extentions) 官网;http://flask.pocoo.org/extensions/ 1.Flask-Script ​ 说明: 一个flask终端运行的解析器 安装: ...

  8. 深入web workers (上)

    前段时间,为了优化某个有点复杂的功能,我采用了shared workers + indexDB,构建了一个高性能的多页面共享的服务.由于是第一次真正意义上的运用workers,比以前单纯的学习有更多体 ...

  9. Java_包装类

    包装类 在实际应用中, 经常需要把基本数据类型转化为对象以便操作. 因此, Java在设计类时, 为每个基本数据类型设计了一个对应的类进行包装, 这样八个和基本数据类型对应的类统称为包装类(Wrapp ...

  10. 使用Python虚拟环境

    python 的虚拟环境可以为一个 python 项目提供独立的解释环境.依赖包等资源,既能够很好的隔离不同项目使用不同 python 版本带来的冲突,而且还能方便项目的发布. virtualenv ...