图是计算机经典算法的重要组成部分,从互联网结构到电力拓扑,从经济学的市场模型到医学对传染病的感染预测都具有非常广泛的应用。图的研究方面可以分为连通性、路径问题、可达性等多个方面。今天我们仅聚焦于有向图和无向图的环测定问题,先使用Java语言实现它们的基本算法,然后我将利用矩阵为大家展示如何通过数学模型来辅助算法。
由一系列相连的节点和所组成的数据结构,叫做图。无论作为社交网络还是地图,计算机在对这类问题进行处理的时候都可能会遇到各种数学问题。我们将学习两种重要的图模型:无向图(简单连接)和有向图(连接有方向性)。
一个图的结构可能非常复杂,但是为了简化我们的研究,我们只涉及以下两种最基本的结构,图1图2仅仅对调了2、3节点,图3表示有向图。
一、无向图与环测定
在我们首先要学习的这种图模型中,边(edge)仅仅是两个顶点(vertex)之间的连接。在无向图中,如果节点1和2之间存在连接表示0-1和1-0同时存在。首先我们需要定义表示无向图结构的对象:
import java.util.LinkedList; import java.util.List; public class Graph { private int v; // 顶点总数 private int e; // 边总数 private List[] adj; // 邻接表数组 public Graph(int v) { this.v = v; this.e = 0; adj = (List []) new List[v]; for (int i = 0; i < v; i++) { adj[i] = new LinkedList<>(); } } /** * 由于图中允许存在平行边,因此不排除在邻接表中保存相同的键 * * @param v 定点 * @param w 定点 */ public void addEdge(int v, int w) { adj[v].add(w); adj[w].add(v); e++; } public void delEdge(int v, int w) { if (adj[v].contains(w)) { adj[v].remove(w); adj[w].remove(v); } } public boolean checkEdge(int v, int w) { return adj[v].contains(w); } public Iterable adj(int v) { return adj[v]; } public int degree(int v) { return adj[v].size(); } public int V() { return v; } public int E() { return e; } }
要检测在一副无向图中是否存在环,我们需要用到一种称为深度优先搜索(dfs)的算法。这个算法的逻辑是,我们从原点(s)出发,从一条连接不断向下搜索,如果如果下一个节点是没有后继节点就原路返回然后从其它连接继续,直到对图中所有的节点都完成遍历。我们每遍历到一个节点就会做一次标记,当发现某一个节点的后继节点已经已经被标记就表示,图中存在环。算法如下:
/** * 深度优先算法的应用:环检测 */ public class Cycle { private boolean[] marked; private boolean isCycle; public Cycle(Graph g) { marked = new boolean[g.V()]; for (int s = 0; s < g.V(); s++) { if (!marked[s]) { dfs(g, s, s); } } } /** * 利用深度优先算法,检测无环图的节点,被标记过的节点w一定等于前节点u(v) * * @param g * @param v * @param u */ public void dfs(Graph g, int v, int u) { marked[v] = true; for (int w : g.adj(v)) { if (!marked[w]) { dfs(g, w, v); } else if (w != u) { this.isCycle = true; return; } } } public boolean isCycle() { return isCycle; } public static void main(String[] args) { Graph g = new Graph(3); g.addEdge(0, 1); g.addEdge(1, 2); g.addEdge(0, 2); Cycle c = new Cycle(g); System.out.println(c.isCycle()); } }
这个算法并不复杂,但是如果你不太熟悉,最好的方式是深入递归的每一步然后充分理解上面的解释。请注意,深度优先算法(dfs)图论算法解答很多问题的基本思想,所以你应该掌握它。
二、有向图与环测定
在有向图中,边是单向的:每条边所连接的两个顶点都是一个有序对,它们的邻接性是单向的。有向图的数据对象表示如下:
import java.util.ArrayList; import java.util.List; public class Digraph { private int v; private int e; private List[] adj; public Digraph(int v) { this.v = v; adj = (List []) new List[v]; for(int i = 0; i < v; i++) { adj[i] = new ArrayList (); } } public void addEdge(int v, int w) { adj[v].add(w); e++; } public Iterable adj(int v) { return adj[v]; } public Digraph reverse() { Digraph r = new Digraph(v); for (int i = 0; i < v; i++) { for (int w : adj(v)) { r.addEdge(w, v); // 节点方向转换 } } return r; } public int V() { return v; } public int E() { return e; } }
虽然,有向图比无向图增加了复杂度,但是对于环的检测思路却依然适用。我们依然会使用dfs算法,对路径进行标注。并查看每一个后继节点。与之前稍有不同的地方是,即使我们发现后继节点在之前的遍历中已经被标注,我们还需要另外一个标记来确保这些结点处于同一条路径上。例如下图就不存在有向环:
在几何学中,向量4(V4) 与 向量2(V2)和向量3(V3)的关系:V4 = V2 + V3。显然只有当:V4 + V2 + V3 = 0 时,环才成立。有向环的算法如下:
import java.util.Stack; /** * 寻找有向环 */ public class DirectedCycle { private boolean[] marked; private int[] edgeTo; private Stackcycle; private boolean[] onStack; public DirectedCycle(Digraph g) { onStack = new boolean[g.V()]; edgeTo = new int[g.V()]; marked = new boolean[g.V()]; for (int v = 0; v < g.V(); v++) { if (!marked[v]) { dfs(g, v); } } } /** * 每次进入递归时添加环标记,递归返回时取消环标记 * * @param g * @param v */ private void dfs(Digraph g, int v) { onStack[v] = true; marked[v] = true; for (int w : g.adj(v)) { if (hasCycle()) { return; } else if (!marked[w]) { edgeTo[w] = v; dfs(g, w); } else if (onStack[w]) { // 通过onStack记录深度优先搜索的每一个节点,如果节点记录marked。则表示图中存在有向环 cycle = new Stack<>(); for (int x = v; x != w; x = edgeTo[x]) { cycle.push(x); } cycle.push(w); cycle.push(v); } } onStack[v] = false; } public boolean hasCycle() { return cycle != null; } public Iterable cycle() { return cycle; } public static void main(String[] args) { Digraph digraph = new Digraph(3); digraph.addEdge(0, 1); digraph.addEdge(1, 2); digraph.addEdge(2, 0); DirectedCycle cycle = new DirectedCycle(digraph); boolean b = cycle.hasCycle(); System.out.println(b); } }
三、矩阵在图论中的应用*
在熟悉了无向图和有向图对环测定的算法后,我们应该深入算法的本质。我将尝试利用矩阵计算来向你展示数学模型的魅力。如果你不熟悉矩阵计算也不要紧,因为本文不会给出任何算法上的实现。或许你会疑惑,为什么要用引入矩阵。我们知道,大多数的算法在处理大型数据结构的时候,都很容易因为CPU的计算瓶颈而受到限制。如果能够利用GPU并行计算的能力就可以让运算能力获得极大改善。而GPU最擅长的运算就是矩阵。
回到上面给出的图,我们用矩阵的行向量表示一条连接而用列向量表示一个节点。左图表示如下:
A1 = -1 1 0 0 0 -1 1 0 0 0 -1 1 0 1 0 -1
矩阵的第一行[-1 1 0 0]表示边1(E1)连接了节点0(N0)和节点1(N1),第二行[0 -1 1 0]表示E2连接N1和N2,以此类推。那么如何才能知道图中是否存在环呢?我们使用Matlab作为工具,利用4 x 4的单位矩阵与A1组成增广矩阵,并对这个矩阵进行高斯-若尔当消元。
>> B1 = rref([A, eye(4)])
B1 =
1 0 0 -1 -1 0 0 1
0 1 0 -1 0 0 0 1
0 0 1 -1 0 0 -1 0
0 0 0 0 0 1 1 1
观察矩阵B1的前4列,我们可以得知N0,N1,N2,N3四个节点相互连通。并且E4不影响整个图的连通性。再观察矩阵B1的后四列,它由单位矩阵变化而来,记录了A1的消元过程。第4行[0 1 1 1]表示原矩阵A1的E2,E3和E4相加为0,你可以认为图的连接2、3、4组成了一个环。请注意,由于A1表示无向环,你可以任意选择连接中的两个顶点的方向。
同样的规律放到有向图中依然成立,只是为了表示连接的方向,我们需要规定N0 到 N1的边为[-1 1 0 0]。因此矩阵A1依然可以作为图3的数学模型。得到的B1依然可以观察到后4列最后一行[0 1 1 1],只是它的几何意义有所区别:还记得上文中我们有关有向图中环测定的公式吗:V4 + V2 + V3 = 0,没错,[0 1 1 1]真正的几何含义就是前面这个公式,只不过在无向图中,我们简化了判断。同样,如果我们交换有向图的2、3节点,利用矩阵运算依然可以得到相同结果:
A2 =
-1 1 0 0
0 -1 0 1
0 0 1 -1
0 1 -1 0
>> B2 = rref([A2, eye(4)])
B2 =
1 0 0 -1 -1 0 1 1
0 1 0 -1 0 0 1 1
0 0 1 -1 0 0 1 0
0 0 0 0 0 1 1 1
四、总结
到此,我有关环测定和矩阵使用的说明就基本完成了。如果你对线性代数不甚了解,可能对最后一段的理解有些吃力。不要紧,正如我在前文中说明的,你不需要任何与矩阵相关的知识,而我也并没有提供匹配的算法。我写最后一段的意义在于让你明白,图和矩阵运算有着紧密联系。很多看似深奥的算法逻辑背后其实时数学运算的结果。如果你在阅读了本文后能够掌握有向图和无向图的深度优先算法,并感觉在算法的理论上还有所启发,这就足够了。