之前接触过的图都是无向图,在这个小节中讲解有向图
实际生活中的很多问题都可以建模为一个有向图的模型:
社交网络:图中的节点看做是一个个的人,如果一个节点指向一个节点,表示这个人关注了这个人,这种关注关系是有方向的
学习课程:将图中的节点看做是一门门的课程,一门课程被其他课程所指向,表示学习这门课程之前需要学习其他的课程,这种学习顺序也是有指向关系的
模块引用:将图中的节点看做是一个个的程序模块,一个模块被其他模块指向,表示该模块依赖于其他模块,这种模块之间的依赖也是有方向的
有向图的表示 我们之前介绍的无向图可以看做是一种特殊的有向图,所谓的特殊在于每个节点都互相指向。我们之前在 g.txt
值规定规定了节点之间的连接关系,所以我们当我们读取到下面的形式的时候
表示的是节点 0 指向节点 1 以及节点 1 指向节点 0,所以我们在无向图中实现如下
adj[0 ].add(1 ); adj[1 ].add(0 );
如果我们只是想表示节点 0 指向节点 1,我们就不需要添加下面那一行语句就好了。所以有向图对比于无向图的实现只需要更改这一个部分
{48} import java.io.File;import java.io.FileNotFoundException;import java.util.Scanner;import java.util.TreeSet;public class DirectedGraph { private int E; private int V; private TreeSet<Integer>[] adj; public DirectedGraph (String filename) { File file = new File(filename); Scanner scanner = null ; try { scanner = new Scanner(file); this .V = scanner.nextInt(); if (this .V < 0 ) { throw new IllegalArgumentException("V Must Be Positive" ); } this .adj = new TreeSet[this .V]; for (int i = 0 ; i < this .V; i++) { this .adj[i] = new TreeSet<>(); } this .E = scanner.nextInt(); if (this .E < 0 ) { throw new IllegalArgumentException("E Must Be Positive" ); } this .inDegree = new int [V]; this .outDegree = new int [V]; for (int i = 0 ; i < this .E; i++) { int a = scanner.nextInt(); validateVertex(a); int b = scanner.nextInt(); validateVertex(b); if (a == b) { throw new IllegalArgumentException("Self loop exists" ); } if (adj[a].contains(b)) { throw new IllegalArgumentException("Parallel edge exists" ); } adj[a].add(b); } } catch (FileNotFoundException e) { e.printStackTrace(); } finally { assert scanner != null ; scanner.close(); } } private void validateVertex (int v) { if (v < 0 || v >= this .V) { throw new IllegalArgumentException("Vertex " + v + " is invalid" ); } } public int V () { return this .V; } public int E () { return this .E; } public Iterable<Integer> adj (int v) { validateVertex(v); return adj[v]; } @Override public String toString () { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.append(String.format("V: %d, E: %d\n" , this .V, this .E)); for (int v = 0 ; v < this .V; v++) { stringBuilder.append(String.format("%d : " , v)); for (int w: adj(v)) { stringBuilder.append(String.format("%d " , w)); } stringBuilder.append("\n" ); } return stringBuilder.toString(); } }
需要注意的是有一个概念同无向图有所不同,那就是度的概念,在有向图中度分为两类:
入度:有多少条边指向该节点
出度:有多少条件从该节点指向别的节点
例如对于下图,节点 2 的入度为 2,因为有两条边指向节点 2,出度为 1,因为有一条边从它指向节点 4。
所以我们再次修改一下类 DirectedGraph
如下
import java.io.File;import java.io.FileNotFoundException;import java.util.Scanner;import java.util.TreeSet;public class DirectedGraph { private int E; private int V; private TreeSet<Integer>[] adj; private int [] inDegree; private int [] outDegree; public DirectedGraph (String filename) { File file = new File(filename); Scanner scanner = null ; try { this .inDegree = new int [V]; this .outDegree = new int [V]; for (int i = 0 ; i < this .E; i++) { adj[a].add(b); inDegree[b]++; outDegree[a]++; } } catch (FileNotFoundException e) { e.printStackTrace(); } finally { assert scanner != null ; scanner.close(); } } public int inDegree (int v) { validateVertex(v); return inDegree[v]; } public int outDegree (int v) { validateVertex(v); return outDegree[v]; } }
DFS 对于有向图来说,它的 DFS 代码和 BFS 代码同无向图的写法完全是一样的,因为在后文中需要用到 DFS 遍历,所以这里贴出有向图的 DFS 遍历代码
import java.util.ArrayList;public class DFSDirectedGraph { private DirectedGraph graph; private ArrayList<Integer> pre; private ArrayList<Integer> post; private boolean [] visited; public DFSDirectedGraph (DirectedGraph graph) { this .graph = graph; this .pre = new ArrayList<>(); this .post = new ArrayList<>(); this .visited = new boolean [graph.V()]; for (int v = 0 ; v < graph.V(); v++) { if (!visited[v]) { dfs(v); } } } private void dfs (int v) { visited[v] = true ; pre.add(v); for (int w: graph.adj(v)) { if (!visited[w]) { dfs(w); } } post.add(v); } public ArrayList<Integer> pre () { return pre; } public ArrayList<Integer> post () { return post; } }
可见上面的代码与无向图的 DFS 并无不同。
有向图的环检测 检测有向图中是否存在环是一件很有必要的事情
如果将上面的图看做是一个课程之间的依赖关系,如果图中存在环,说明课程之间相互依赖,学习课程 A 之前需要先学习课程 B,而学习课程 B 之前又需要先学习课程 A,这样的学习顺序肯定是有问题的。
我们想一想我们在无向图中是怎么做环检测的,我们使用 DFS 遍历,当遍历到一个节点时,如果某个邻接节点已经被访问了,并且不是它的父亲节点,我们就可以认为图中存在一个环。但是对于有向图就不一样了
上面的动图是优先遍历的顺序,最后我们来到了节点 3
此时我们发现节点 3 的相邻节点 2 已经被访问过了,并且节点 2 不是 3 的上一个节点,但是我们能说图中存在一个环吗? 不能,因为节点 2 无法到达节点 3,所以形成不了一个环。
此时我们需要另外一个变量来表示节点是否在路径上,如果一个节点访问邻接节点时发现该节点已经被访问过且在路径上,我们就可以说构成了一个环。
什么叫在路径上,如果这个节点还没有回溯到上一个节点,我们就说这个节点在路径上。
如果我们在访问到节点 w 时,节点 v 还在路径上,说明节点 v 可以到达节点 w,如果此时节点 w 可以到达节点 v 不就说明存在一个环吗。
public class DirectedCycleDetect { private DirectedGraph graph; private boolean [] visited; private boolean [] onPath; private boolean hasCycle; public DirectedCycleDetect (DirectedGraph graph) { this .graph = graph; this .visited = new boolean [graph.V()]; this .onPath = new boolean [graph.V()]; this .hasCycle = false ; for (int v = 0 ; v < graph.V(); v++) { if (!visited[v]) { if (dfs(v)) { hasCycle = true ; break ; } } } } private boolean dfs (int v) { visited[v] = true ; onPath[v] = true ; for (int w: graph.adj(v)) { if (!visited[w]) { if (dfs(w)) { return true ; } } else if (onPath[w]) { return true ; } } onPath[v] = false ; return false ; } public boolean isHasCycle () { return hasCycle; } }
拓扑排序 还是对于下面的这么一副图
假设上面的节点代表一门课程的话,我们想知道应该以什么样的顺序学习所有的课程,这其实就是在寻找图的拓扑排序。
那么我们寻找图的拓扑排序呢? 还是以学习课程举例,要学习一门课,那么这门就不能依赖于任何一门其他的课,或者这门课已经被学过了,就比如上面我们应该先学习课程 0,因为没有任何课程指向它,它不依赖于任何课程,以图论的语言来说,因为节点 0 的入度为 0,所以先学习课程 0。
学习完课程 0 之后,为了表示这门课程已经学过,我们将依赖于节点 0 的节点的入度减一
接下来我们应该学习哪门课呢? 当然是入度为 0 的课,表示不需要前置课程或者前置课程已经学过了,所以我们接下来学习课程 1,并且将依赖课程 1 的其他课程的入度减一
接下来还是学习入度为 0 的课程,即课程 3,并且更新依赖课程 3 的其他课程的入度
接着学习课程 2,因为它的入度为 0,然后更新依赖课程 2 的其他课程的入度
最后学习课程 4
所以最终课程的学习顺序为 0 → 1 → 3 → 2 → 4
,也就是拓扑排序的结果。
所以我们对图做拓扑排序,就重复做两步:
另外一个需要注意的问题就是,拓扑排序只对有向无环图(DAG) 有效,如果图中有环,二者相互依赖,就不知道先学习哪一门课程了。
在代码的实现方面,我们使用队列来存储入度为 0 的节点,在更新相邻节点入度的时候,如果入度为 0 则添加进队列中,每次从队列取出一个值时,我们添加到一个 List 中,表示拓扑排序的顺序。另外如果图中存在环的话,那么这个 List 中保存的节点数目肯定小于整个图的节点数目,此时可以使用拓扑排序来做环检测。
import java.util.ArrayList;import java.util.LinkedList;import java.util.Queue;public class TopoSort { private DirectedGraph graph; private int [] inDegree; private ArrayList<Integer> result = new ArrayList<>(); private boolean hasCycle = false ; public TopoSort (DirectedGraph graph) { this .graph = graph; this .inDegree = new int [graph.V()]; Queue<Integer> queue = new LinkedList<>(); for (int v = 0 ; v < graph.V(); v++) { inDegree[v] = graph.inDegree(v); if (inDegree[v] == 0 ) { queue.add(v); } } while (!queue.isEmpty()) { int v = queue.remove(); result.add(v); for (int w: graph.adj(v)) { inDegree[w]--; if (inDegree[w] == 0 ) { queue.add(w); } } } if (result.size() != graph.V()) { hasCycle = true ; result.clear(); } } public boolean isHasCycle () { return hasCycle; } public ArrayList<Integer> result () { return result; } }
此时我们在介绍一种拓扑排序的算法,它需要借助于深度优先后序遍历。回顾一个后序遍历的概念,后序遍历指的是先访问完相邻节点,然后访问自己。
对于有向图意味着什么,意味着先访问依赖自己的节点,然后访问自己,说明自己是在依赖于自己的节点之后访问的,如果我们对深度优先后序遍历做一个逆序,这个顺序就表示自己在依赖于自己的节点之前被访问的,这不就是拓扑排序的结果吗。
使用深度优先后序遍历有一个缺点就是它不能做环检测,虽然图中有环,但是还是能够深度优先后序遍历,只不过此时结果便没有意义了。所以在使用这个方法做拓扑排序之前,我们需要使用我们之前讲解的有向图的环检测算法进行环检测
import java.util.ArrayList;import java.util.Collections;public class TopoSort2 { private DirectedGraph graph; private boolean hasCycle; private ArrayList<Integer> result = new ArrayList<>(); public TopoSort2 (DirectedGraph graph) { this .graph = graph; DirectedCycleDetect directedCycleDetect = new DirectedCycleDetect(graph); this .hasCycle = directedCycleDetect.isHasCycle(); if (hasCycle) { return ; } DFSDirectedGraph dfsDirectedGraph = new DFSDirectedGraph(graph); for (int v: dfsDirectedGraph.post()) { result.add(v); } Collections.reverse(result); } public boolean isHasCycle () { return hasCycle; } public ArrayList<Integer> result () { return result; } }
强连通分量 现在还有一个问题就是我们怎么检测图中有多少个强联通分量,以及每一个强联通分量中包含哪些节点。所谓的强联通分量指如果某些节点互相可达,那么这些节点就组成了一个强联通分量。
如上图就三个强联通分量,每个联通分量内部的节点都是互相可达的。
在求解有向图的联通分量之前,我们回顾一下无向图的联通分量是如何求解的,我们从任意一个节点开始做 DFS
,每次做一次 DFS
就表示有一个联通分量
for (int i = 0 ; i < visited.length; i++) { if (!visited[i]) { cccount++; dfs(i); } }
但是对于有向图我们能从任意一个节点开始吗? 答案是不行,而是要以特定的顺序遍历。如果我们将一个个的联通分量看做是一个图中的节点
如果我们分别遍历联通分量 $E \rightarrow C \rightarrow A \rightarrow D \rightarrow B$ 中的节点,那么每做一次 DFS 就就可以统计联通分量数目,那么为什么会是上面的顺序呢?
为什么从 $E$ 开始遍历,因为 $E$ 只依赖其他联通分量的节点,没有其他联通分量依赖于它,所以从 $E$ 中的某个节点做 DFS
,只能遍历到联通分量 $E$ 中的节点。同理遍历完了 $E$,此时 $C$ 也只依赖于其他联通分量,虽然 $E$ 依赖于 $C$,但是 $E$ 已经遍历完毕了,所以此时从 $C$ 中的节点开始做 DFS
只能遍历到 $C$ 中所有的节点,剩余的也是同理。
也就是说依赖于自己的联通分量要被先遍历到,也就是相邻节点要在自己之前被访问到,其实就是深度优先后序遍历的顺序,所以 $E \rightarrow C \rightarrow A \rightarrow D \rightarrow B$ 这个顺序其实是深度优先后序遍历的顺序。
但是这个深度优先后序遍历指的是以联通分量作为节点进行的遍历,如果我们从任意一个节点做深度优先后序遍历是得不到这个顺序的。例如我们从联通分量中的节点 $D$ 开始遍历,接着来到了联通分量 $C$,虽然遍历完联通分量 $C$ 中所有节点之前肯定需要遍历完联通分量 $E$,但是不能保证所有 $E$ 中的节点都在 $C$ 之前被遍历,即可能有这样的顺序 $C_1 \rightarrow E \rightarrow C_2 \rightarrow \cdots$,这样的顺序是我们不想要的,因为我们必须从联通分量 $E$ 中的节点开始遍历。
虽然上面的深度优先遍历不能严格按照 $E \rightarrow C \rightarrow A \rightarrow D \rightarrow B$ 的顺序遍历,但是我们发现一定有分量 $C$ 中的节点在分量 $E$ 遍历完之后遍历的 $C_1 \rightarrow E \rightarrow C_2 \rightarrow \cdots$,例如 $C_2$ 在遍历完 $E$ 之后被遍历了,这给了我们灵感,如果我们取个逆,就可以得到一定有 $C$ 中的节点在 $E$ 分量节点被遍历之前遍历。
而我们想要的是一定要 $E$ 中的节点在 $C$ 中的节点被遍历之前遍历,怎么办,我们将整个图的方向改变一下,原来是 $a \rightarrow b$,现在变为 $b \rightarrow a$
然后进行深度优先后序遍历,然后取个逆就可以保证一定有在 $E$ 中的节点在 $C$ 中的节点遍历之前被遍历到,同理可以得到一定有 $C$ 中的节点在 $D$ 中的节点被遍历之前被遍历到,这就达到我们的目的,我们再根据这个顺序做 DFS
就可以统计出联通分量的个数了。
上面这个算法就是 Kosaraju 算法,总结上面的步骤如下:
图翻转得到反图
对反图做深度优先后序遍历,然后对遍历结果取逆
根据取逆后的结果依次做 DFS,统计联通分量个数
import java.util.ArrayList;import java.util.Arrays;import java.util.Collections;import java.util.TreeSet;public class StrongCC { private DirectedGraph graph; private int [] visited; private int scccount; public StrongCC (DirectedGraph graph) { this .graph = graph; this .visited = new int [graph.V()]; Arrays.fill(visited, -1 ); this .scccount = 0 ; TreeSet<Integer>[] reverse = new TreeSet[graph.V()]; for (int i = 0 ; i < graph.V(); i++) { reverse[i] = new TreeSet<>(); } for (int v = 0 ; v < graph.V(); v++) { for (int w: graph.adj(v)) { reverse[w].add(v); } } DFSDirectedGraph dfsDirectedGraph = new DFSDirectedGraph(new DirectedGraph(reverse)); ArrayList<Integer> res = dfsDirectedGraph.post(); Collections.reverse(res); for (int v: res) { if (visited[v] == -1 ) { dfs(v, scccount); scccount++; } } } private void dfs (int v, int id) { visited[v] = id; for (int w: graph.adj(v)) { if (visited[w] == -1 ) { dfs(w, id); } } } public ArrayList<Integer>[] components() { ArrayList<Integer>[] results = new ArrayList[scccount]; for (int i = 0 ; i< scccount; i++) { results[i] = new ArrayList<>(); } for (int v = 0 ; v < graph.V(); v++) { results[visited[v]].add(v); } return results; } }