参考链接: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值的确定,因为题目的要求是找到最早的那个搜索号,在这里会采用深度优先的方式一层层的寻找,如果找到的小的,就进行替换,如果最后找到的还是他自己的时候,说明这中间其实是一个环。然后把栈中当前节点的上方把节点全部移出。