Tarjan算法--有向图强连通分量算法
参考链接:https://www.byvoid.com/blog/scc-tarjan/
我的算法库:https://github.com/linyiqun/lyq-algorithms-lib
算法介绍
正如标题所介绍的那样,Tarjan算法的目标就是找出图中的连通图的,其实前提条件是这样的,在一个有向图中,有时必然会出现节点成环的情况,而在图内部的这些形成环的节点所构成的图就是我们所要找的,这个在实际生活中非常的有用,可以帮助我们解决最短距离之类的问题。
算法原理
概念
在介绍算法原理的之前,必须先明白一些概念:
1、强连通。在有向图G中,如果2个点之间存在至少一条路径,我们称这2个点为强连通。
2、强连通图。在图G中,如果其中任意的2个点都是强连通,则称图G为强连通图。
3、强连通分量。并不是所有的图中的任意2点之间都存在路径的,有些是部分节点连通,我们称这样的图为非强连通图,其中的部分强连通子图,就称为强连通分量。
强连通分量就是本次算法要找的东西。下面给出一个图示:
在上面这个图中,{1, 2, 3, 4}是强连通分量,因为5,6是达不到的对于1, 2, 3, 4,来说,这里也将5,6单独作为强连通分量,可以理解为自己到自己是可达的(这样解释感觉比较勉强,但是定义也是允许这样的情况的)。
算法的过程
算法为每个节点定义了2个变量DFN[i]和LOW[i],DFN[i]代表的意思是i节点的搜索次序号,LOW[i]代表的是i节点或i的子节点能够追溯到的最早的节点的次序号,如果这么说没有理解的话,没有关系,可以看下面的伪代码:
tarjan(u)
{
DFN[u]=Low[u]=++Index // 为节点u设定次序编号和Low初值
Stack.push(u) // 将节点u压入栈中
for each (u, v) in E // 枚举每一条边
if (v is not visted) // 如果节点v未被访问过
tarjan(v) // 继续向下找
Low[u] = min(Low[u], Low[v])
else if (v in S) // 如果节点v还在栈内
Low[u] = min(Low[u], DFN[v])
if (DFN[u] == Low[u]) // 如果节点u是强连通分量的根
repeat
v = S.pop // 将v退栈,为该强连通分量中一个顶点
print v
until (u== v)
}
算法的实现
算法的实现采用的例子还是上面这个例子,输入数据graphData.txt:
1 2
1 3
2 4
3 4
3 5
4 1
4 6
5 6
输入格式为标号1 标号2,代表的意思是存在标号1指向标号2节点的边。
有向图类Graph.java:
package Tarjan;
import java.util.ArrayList;
/**
* 有向图类
*
* @author lyq
*
*/
public class Graph {
// 图包含的点的标号
ArrayList<Integer> vertices;
// 图包含的有向边的分布,edges[i][j]中,i,j代表的是图的标号
int[][] edges;
// 图数据
ArrayList<String[]> graphDatas;
public Graph(ArrayList<String[]> graphDatas) {
this.graphDatas = graphDatas;
vertices = new ArrayList<>();
}
/**
* 利用图数据构造有向图
*/
public void constructGraph() {
int v1 = 0;
int v2 = 0;
int verticNum = 0;
for (String[] array : graphDatas) {
v1 = Integer.parseInt(array[0]);
v2 = Integer.parseInt(array[1]);
if (!vertices.contains(v1)) {
vertices.add(v1);
}
if (!vertices.contains(v2)) {
vertices.add(v2);
}
}
verticNum = vertices.size();
// 多申请1个空间,是标号和下标一致
edges = new int[verticNum + 1][verticNum + 1];
// 做边的初始化操作,-1 代表的是此方向没有连通的边
for (int i = 1; i < verticNum + 1; i++) {
for (int j = 1; j < verticNum + 1; j++) {
edges[i][j] = -1;
}
}
for (String[] array : graphDatas) {
v1 = Integer.parseInt(array[0]);
v2 = Integer.parseInt(array[1]);
edges[v1][v2] = 1;
}
}
}
算法工具类TarjanTool.java:
package Tarjan;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Stack;
/**
* Tarjan算法-有向图强连通分量算法
*
* @author lyq
*
*/
public class TarjanTool {
// 当前节点的遍历号
public static int currentSeq = 1;
// 图构造数据文件地址
private String graphFile;
// 节点u搜索的次序编号
private int DFN[];
// u或u的子树能回溯到的最早的节点的次序编号
private int LOW[];
// 由图数据构造的有向图
private Graph graph;
// 图遍历节点栈
private Stack<Integer> verticStack;
// 强连通分量结果
private ArrayList<ArrayList<Integer>> resultGraph;
// 图的未遍历的点的标号列表
private ArrayList<Integer> remainVertices;
// 图未遍历的边的列表
private ArrayList<int[]> remainEdges;
public TarjanTool(String graphFile) {
this.graphFile = graphFile;
readDataFile();
}
/**
* 从文件中读取数据
*
*/
private void readDataFile() {
File file = new File(graphFile);
ArrayList<String[]> dataArray = new ArrayList<String[]>();
try {
BufferedReader in = new BufferedReader(new FileReader(file));
String str;
String[] tempArray;
while ((str = in.readLine()) != null) {
tempArray = str.split(" ");
dataArray.add(tempArray);
}
in.close();
} catch (IOException e) {
e.getStackTrace();
}
// 根据数据构造有向图
graph = new Graph(dataArray);
graph.constructGraph();
}
/**
* 初始化2个标量数组
*/
private void initDfnAndLow(){
int verticNum = 0;
verticStack = new Stack<>();
remainVertices = (ArrayList<Integer>) graph.vertices.clone();
remainEdges = new ArrayList<>();
resultGraph = new ArrayList<>();
for (int i = 0; i < graph.edges.length; i++) {
remainEdges.add(graph.edges[i]);
}
verticNum = graph.vertices.size();
DFN = new int[verticNum + 1];
LOW = new int[verticNum + 1];
// 初始化数组操作
for (int i = 1; i <= verticNum; i++) {
DFN[i] = Integer.MAX_VALUE;
LOW[i] = -1;
}
}
/**
* 搜索强连通分量
*/
public void searchStrongConnectedGraph() {
int label = 0;
int verticNum = graph.vertices.size();
initDfnAndLow();
// 设置第一个的DFN[1]=1;
DFN[1] = 1;
// 移除首个节点
label = remainVertices.get(0);
verticStack.add(label);
remainVertices.remove((Integer) 1);
while (remainVertices.size() > 0) {
for (int i = 1; i <= verticNum; i++) {
if (graph.edges[label][i] == 1) {
// 把与此边相连的节点也加入栈中
verticStack.add(i);
remainVertices.remove((Integer) i);
dfsSearch(verticStack);
}
}
LOW[label] = searchEarliestDFN(label);
// 重新回溯到第一个点进行DFN和LOW值的判断
if (LOW[label] == DFN[label]) {
popStackGraph(label);
}
}
printSCG();
}
/**
* 深度优先遍历的方式寻找强连通分量
*
* @param stack
* 存放的节点的当前栈
* @param seqNum
* 当前遍历的次序号
*/
private void dfsSearch(Stack<Integer> stack) {
int currentLabel = stack.peek();
// 设置搜索次序号,在原先的基础上增加1
currentSeq++;
DFN[currentLabel] = currentSeq;
LOW[currentLabel] = searchEarliestDFN(currentLabel);
int[] edgeVertic;
edgeVertic = remainEdges.get(currentLabel);
for (int i = 1; i < edgeVertic.length; i++) {
if (edgeVertic[i] == 1) {
// 如果剩余可选节点中包含此节点吗,则此节点添加
if (remainVertices.contains(i)) {
stack.add(i);
} else {
// 不包含,则跳过
continue;
}
// 将与此边相连的点加入栈中
remainVertices.remove((Integer) i);
remainEdges.set(currentLabel, null);
// 继续深度优先遍历
dfsSearch(stack);
}
}
if (LOW[currentLabel] == DFN[currentLabel]) {
popStackGraph(currentLabel);
}
}
/**
* 从栈中弹出局部结果
*
* @param label
* 弹出的临界标号
*/
private void popStackGraph(int label) {
// 如果2个值相等,则将此节点以及此节点后的点移出栈中
int value = 0;
ArrayList<Integer> scg = new ArrayList<>();
while (label != verticStack.peek()) {
value = verticStack.pop();
scg.add(0, value);
}
scg.add(0, verticStack.pop());
resultGraph.add(scg);
}
/**
* 当前的节点可能搜索到的最早的次序号
*
* @param label
* 当前的节点标号
* @return
*/
private int searchEarliestDFN(int label) {
// 判断此节点是否有子边
boolean hasSubEdge = false;
int minDFN = DFN[label];
// 如果搜索到的次序号已经是最小的次序号,则返回
if (DFN[label] == 1) {
return DFN[label];
}
int tempDFN = 0;
for (int i = 1; i <= graph.vertices.size(); i++) {
if (graph.edges[label][i] == 1) {
hasSubEdge = true;
// 如果在堆栈中和剩余节点中都未包含此节点说明已经被退栈了,不允许再次遍历
if (!remainVertices.contains(i) && !verticStack.contains(i)) {
continue;
}
tempDFN = searchEarliestDFN(i);
if (tempDFN < minDFN) {
minDFN = tempDFN;
}
}
}
// 如果没有子边,则搜索到的次序号就是它自身
if (!hasSubEdge && DFN[label] != -1) {
minDFN = DFN[label];
}
return minDFN;
}
/**
* 标准搜索强连通分量算法
*/
public void standardSearchSCG(){
initDfnAndLow();
verticStack.add(1);
remainVertices.remove((Integer)1);
//从标号为1的第一个节点开始搜索
dfsSearchSCG(1);
//输出结果中的强连通分量
printSCG();
}
/**
* 深度优先搜索强连通分量
*
* @param u
* 当前搜索的节点标号
*/
private void dfsSearchSCG(int u) {
DFN[u] = currentSeq;
LOW[u] = currentSeq;
currentSeq++;
for (int i = 1; i <graph.edges[u].length; i++) {
// 判断u,i两节点是否相连
if (graph.edges[u][i] == 1) {
// 相连的情况下,当i未被访问过的时候,加入栈中
if (remainVertices.contains(i)) {
verticStack.add(i);
remainVertices.remove((Integer) i);
// 递归搜索
dfsSearchSCG(i);
LOW[u] = (LOW[u] < LOW[i] ? LOW[u] : LOW[i]);
} else if(verticStack.contains(i)){
// 如果已经访问过,并且还未出栈过的
LOW[u] = (LOW[u] < DFN[i] ? LOW[u] : DFN[i]);
//LOW[u] = (LOW[u] < LOW[i] ? LOW[u] : LOW[i]); 如果都用LOW做判断,也可以通过测试
}
}
}
// 最后判断DFN和LOW是否相等
if (DFN[u] == LOW[u]) {
popStackGraph(u);
}
}
/**
* 输出有向图中的强连通分量
*/
private void printSCG() {
int i = 1;
String resultStr = "";
System.out.println("所有强连通分量子图:");
for (ArrayList<Integer> graph : resultGraph) {
resultStr = "";
resultStr += "强连通分量" + i + ":{";
for (Integer v : graph) {
resultStr += (v + ", ");
}
resultStr = (String) resultStr.subSequence(0,
resultStr.length() - 2);
resultStr += "}";
System.out.println(resultStr);
i++;
}
}
}
测试类Client.java:
package Tarjan;
/**
* Tarjan算法--有向图强连通分量算法
* @author lyq
*
*/
public class Client {
public static void main(String[] args){
//图构造数据文件地址
String graphFilePath = "C:\\Users\\lyq\\Desktop\\icon\\graphData.txt";
TarjanTool tool = new TarjanTool(graphFilePath);
//下面这个方法为改造的一点方法,还有点问题
//tool.searchStrongConnectedGraph();
tool.standardSearchSCG();
}
}
算法的执行步骤如图所示(手机拍摄的截图效果不佳,请不要见怪):
主要展示了随着遍历的顺序DFN和LOW数组的赋值情况,以及栈的内容变化情况
算法的输出结果:
所有强连通分量子图:
强连通分量1:{6}
强连通分量2:{5}
强连通分量3:{1, 2, 4, 3}
算法的遗漏点
在这个算法中,我写了2个算法,searchStrongConnectGraph是我自己在没有看伪代码写的,后来发现,意思有点曲解了,遇到循环图的时候也会有问题,后来马上看了伪代码,马上代码精简了很多,的确是非常强大的算法,第二点是我觉得在下面这个步骤中,判断是否可以合并在一起,因为我发现结果是一致的,都可以用LOW数组的值来判断。
// 相连的情况下,当i未被访问过的时候,加入栈中
if (remainVertices.contains(i)) {
verticStack.add(i);
remainVertices.remove((Integer) i);
// 递归搜索
dfsSearchSCG(i);
LOW[u] = (LOW[u] < LOW[i] ? LOW[u] : LOW[i]);
} else if(verticStack.contains(i)){
// 如果已经访问过,并且还未出栈过的
LOW[u] = (LOW[u] < DFN[i] ? LOW[u] : DFN[i]);
//LOW[u] = (LOW[u] < LOW[i] ? LOW[u] : LOW[i]); 如果都用LOW做判断,也可以通过测试
}
可能是为了让图中的边只允许被遍历一次的原因吧。
算法的突破点
很明显算法的突破口在于比较LOW和DFN的值,因为DFN的值在遍历顺序的就已经确定,所以问题的关键在于LOW值的确定,因为题目的要求是找到最早的那个搜索号,在这里会采用深度优先的方式一层层的寻找,如果找到的小的,就进行替换,如果最后找到的还是他自己的时候,说明这中间其实是一个环。然后把栈中当前节点的上方把节点全部移出。