图是由一组顶点和一组可以将两个顶点相连的边组成。html
顶点叫什么名字并不重要,但咱们须要一个方法来指代这些顶点。通常使用 0 至 V-1 来表示一张含有 V 个顶点的图中的各个顶点。这样约定是为了方便使用数组的索引来编写可以高效访问各个顶点信息的代码。用一张符号表来为顶点的名字和 0 到 V-1 的整数值创建一一对应的关系并不困难,所以直接使用数组索引做为结点的名称更方便且不失通常性,也不会损失什么效率。前端
咱们用 v-w 的记法来表示链接 v 和 w 的边, w-v 是这条边的另外一种表示方法。算法
在绘制一幅图时,用圆圈表示顶点,用链接两个顶点的线段表示边,这样就能直观地看出图地结构。但这种直觉有时可能会误导咱们,由于图地定义和绘制地图像是无关的,一组数据能够绘制不一样形态的图像。设计模式
特殊的图数组
自环:即一条链接一个顶点和其自身的边;数据结构
多重图:链接同一对顶点的两条边成为平行边,含有平行边的图称为多重图。ide
没有平行边的图称为简单图。函数
1.相关术语oop
当两个顶点经过一条边相连时,称这两个顶点是相邻得,并称这条边依附于这两个顶点。某个顶点的度数即为依附于它的边的总数。子图是由一幅图的全部边的一个子集(以及它们所依附的全部顶点)组成的图。许多计算问题都须要识别各类类型的子图,特别是由可以顺序链接一系列顶点的边所组成的子图。性能
在图中,路径是由边顺序链接的一系列顶点。简单路径是一条没有重复顶点的路径。环是一条至少含有一条边且起点和终点相同的路径。简单环是一条(除了起点和终点必须相同以外)不含有重复顶点和边的环。路径或环的长度为其中所包含的边数。
当两个顶点之间存在一条链接双方的路径时,咱们称一个顶点和另外一个顶点是连通的。
若是从任意一个顶点都存在一条路径到达另外一个任意顶点,咱们称这副图是连通图。一幅非连通的图由若干连通的部分组成,它们都是其极大连通子图。
通常来讲,要处理一张图须要一个个地处理它的连通份量(子图)。
树是一幅无环连通图。互不相连的树组成的集合称为森林。连通图的生成树是它的一幅子图,它含有图中的全部顶点且是一棵树。图的生成森林是它的全部连通子图的生成树的集合。
树的定义很是通用,稍做改动就能够变成用来描述程序行为(函数调用层次)模型和数据结构。当且仅当一幅含有 V 个结点的图 G 知足下列 5 个条件之一时,它就是一棵树:
G 有 V - 1 条边且不含有环;
G 有 V - 1 条边且是连通的;
G 是连通的,但删除任意一条都会使它再也不连通;
G 是无环图,但添加任意一条边都会产生一条环;
G 中的任意一对顶点之间仅存在一条简单路径;
图的密度是指已经链接的顶点对占全部可能被链接的顶点对的比例。在稀疏图中,被链接的顶点对不多;而在稠密图中,只有少部分顶点对之间没有边链接。通常来讲,若是一幅图中不一样的边的数量在顶点总数 v 的一个小的常数倍之内,那么咱们认为这幅图是稀疏的,不然就是稠密的。
二分图是一种可以将全部结点分为两部分的图,其中图的每条边所链接的两个顶点都分别属于不一样的集合。
2.表示无向图的数据结构
图的几种表示方法
接下来要面对的图处理问题就是用哪一种数据结构来表示图并实现这份API,包含下面两个要求:
1.必须为可能在应用中碰到的各类类型图预留出足够的空间;
2.Graph 的实例方法的实现必定要快。
下面有三种选择:
1.邻接矩阵:咱们可使用一个 V 乘 V 的布尔矩阵。当顶点 v 和 w 之间有链接的边时,定义 v 行 w 列的元素值为 true,不然为 false。这种表示方法不符合第一个条件--含有上百万个顶点的图所需的空间是不能知足的。
2.边的数组:咱们可使用一个 Edge 类,它含有两个 int 实例变量。这种表示方法很简单但不知足第二个条件--要实现 Adj 须要检查图中的全部边。
3.邻接表数组:使用一个以顶点为索引的列表数组,其中每一个元素都是和该顶点相连的顶点列表。
非稠密图的标准表示成为邻接表的数据结构,它将每一个顶点的全部相邻顶点都保存在该顶点对应的元素所指向的一张链表中。咱们使用这个数组就是为了快速访问给定顶点的邻接顶点列表。这里使用 Bag 来实现这个链表,这样咱们就能够在常数时间内添加新的边或遍历任意顶点的全部相邻顶点。
要添加一条链接 v 与 w 的边,咱们将 w 添加到 v 的邻接表中并把 v 添加到 w 的邻接表中。所以在这个数据结构中每条边都会出现两次。这种 Graph 的实现的性能特色:
1.使用的空间和 V+E 成正比;
2.添加一条边所需的时间为常数;
3.遍历顶点 v 的全部相邻顶点所需的时间和 v 的度数成正比。
对于这些操做,这样的特性已是最优的了,并且支持平行边和自环。注意,边的插入顺序决定了 Graph 的邻接表中顶点的出现顺序。多个不一样的邻接表可能表示着同一幅图。由于算法在使用 Adj() 处理全部相邻的顶点时不会考虑它们在邻接表中的出现顺序,这种差别不会影响算法的正确性,但在调试或是跟踪邻接表的轨迹时须要注意这一点。
public class Graph { private int v; private int e; private List<int>[] adj; //邻接表(用List 代替 bag) /// <summary> /// 建立一个含有V个顶点但不含有边的图 /// </summary> /// <param name="V"></param> public Graph(int V) { v = V; e = 0; adj = new List<int>[V]; for (var i = 0; i < V; i++) adj[i] = new List<int>(); } public Graph(string[] strs) { foreach (var str in strs) { var data = str.Split(' '); int v = Convert.ToInt32(data[0]); int w = Convert.ToInt32(data[1]); AddEdge(v,w); } } /// <summary> /// 顶点数 /// </summary> /// <returns></returns> public int V() { return v; } /// <summary> /// 边数 /// </summary> /// <returns></returns> public int E() { return e; } /// <summary> /// 向图中添加一条边 v-w /// </summary> /// <param name="v"></param> /// <param name="w"></param> public void AddEdge(int v, int w) { adj[v].Add(w); adj[w].Add(v); e++; } /// <summary> /// 和v相邻的全部顶点 /// </summary> /// <param name="v"></param> /// <returns></returns> public IEnumerable<int> Adj(int v) { return adj[v]; } /// <summary> /// 计算 V 的度数 /// </summary> /// <param name="G"></param> /// <param name="V"></param> /// <returns></returns> public static int Degree(Graph G, int V) { int degree = 0; foreach (int w in G.Adj(V)) degree++; return degree; } /// <summary> /// 计算全部顶点的最大度数 /// </summary> /// <param name="G"></param> /// <returns></returns> public static int MaxDegree(Graph G) { int max = 0; for (int v = 0; v < G.V(); v++) { var d = Degree(G, v); if (d > max) max = d; } return max; } /// <summary> /// 计算全部顶点的平均度数 /// </summary> /// <param name="G"></param> /// <returns></returns> public static double AvgDegree(Graph G) { return 2.0 * G.E() / G.V(); } /// <summary> /// 计算自环的个数 /// </summary> /// <param name="G"></param> /// <returns></returns> public static int NumberOfSelfLoops(Graph G) { int count = 0; for (int v = 0; v < G.V(); v++) { foreach (int w in G.Adj(v)) { if (v == w) count++; } } return count / 2; //每条边都被计算了两次 } public override string ToString() { string s = V() + " vertices, " + E() + " edges\n"; for (int v = 0; v < V(); v++) { s += v + ":"; foreach (int w in Adj(v)) { s += w + " "; } s += "\n"; } return s; } }
在实际应用中还有一些操做可能有用,例如:
添加一个顶点;
删除一个顶点。
实现这些操做的一种方法是,使用符号表 ST 来代替由顶点索引构成的数组,这样修改以后就不须要约定顶点名必须是整数了。可能还须要:
删除一条边;
检查图是否含有 v-w。
要实现这些方法,可能须要使用 SET 代替 Bag 来实现邻接表。咱们称这种方法为邻接集。如今还不须要,由于:
不须要添加,删除顶点和边或是检查一条边是否存在;
上述操做使用频率很低或者相关链表很短,能够直接使用穷举法遍历;
某些状况下会使性能损失 logV。
3.图的处理算法的设计模式
由于咱们会讨论大量关于图处理的算法,因此设计的首要目标是将图的表示和实现分离开来。为此,咱们会为每一个任务建立一个相应的类,用例能够建立相应的对象来完成任务。类的构造函数通常会在预处理中构造各类数据结构,以有效地响应用例的请求。典型的用例程序会构造一幅图,将图做为参数传递给某个算法类的构造函数,而后调用各类方法来获取图的各类性质。
咱们用起点 s 区分做为参数传递给构造函数的顶点与图中的其余顶点。在这份 API 中,构造函数的任务就是找到图中与起点连通的其余顶点。用例能够调用 marked 方法和 count 方法来了解图的性质。方法名 marked 指的是这种基本方法使用的一种实现方式:在图中从起点开始沿着路径到达其余顶点并标记每一个路过的顶点。
在 union-find算法 已经见过 Search API 的实现,它的构造函数会建立一个 UF 对象,对图中的每条边进行一次 union 操做并调用 connected(s,v) 来实现 marked 方法。实现 count 方法须要一个加权的 UF 实现并扩展它的API,以便使用 count 方法返回 sz[find(v)]。
下面的一种搜索算法是基于深度优先搜索(DFS)的,它会沿着图的边寻找喝起点连通的全部顶点。
4.深度优先搜索
要搜索一幅图,只须要一个递归方法来遍历全部顶点。在访问其中一个顶点时:
1.将它标记为已访问;
2.递归地访问它全部没有被标记过地邻居顶点。
这种方法称为深度优先搜索(DFS)。
namespace Graphs { /// <summary> /// 使用一个 bool 数组来记录和起点连通地全部顶点。递归方法会标记给定地顶点并调用本身来访问该顶点地相邻顶点列表中 /// 全部没有被标记过地顶点。 若是图是连通的,每一个邻接链表中的元素都会被标记。 /// </summary> public class DepthFirstSearch { private bool[] marked; private int count; public DepthFirstSearch(Graph G,int s) { marked = new bool[G.V()]; Dfs(G,s); } private void Dfs(Graph g, int V) { marked[V] = true; count++; foreach (var w in g.Adj(V)) { if (!marked[w]) Dfs(g,w); } } public bool Marked(int w) { return marked[w]; } } }
深度优先搜索标记与起点连通的全部顶点所需的时间和顶点的度数之和成正比。
这种简单的递归模式只是一个开始 -- 深度优先搜索可以有效处理许多和图有关的任务。
1.连通性。给定一幅图,两个给定的顶点是否连通?(两个给定的顶点之间是否存在一条路径?路径检测) 图中有多少个连通子图?
2.单点路径。给定一幅图和一个起点 s ,从 s 到给定目的顶点 v 是否存在一条路径?若是有,找出这条路径。
5.寻找路径
单点路径的API:
构造函数接受一个起点 s 做为参数,计算 s 到与 s 连通的每一个顶点之间的路径。在为起点 s 建立 Paths 对象以后,用例能够调用 PathTo 方法来遍历从 s 到任意和 s 连通的顶点的路径上的全部顶点。
实现
下面的算法基于深度优先搜索,它添加了一个 edgeTo[ ] 整型数组,这个数组能够找到从每一个与 s 连通的顶点回到 s 的路径。它会记住每一个顶点到起点的路径,而不是记录当前顶点到起点的路径。为了作到这一点,在由边 v-w 第一次任意访问 w 时,将 edgeTo[w] = v 来记住这条路径。换句话说, v-w 是从s 到 w 的路径上最后一条已知的边。这样,搜索的结果是一棵以起点为根结点的树,edgeTo[ ] 是一棵由父连接表示的树。 PathTo 方法用变量 x 遍历整棵树,将遇到的全部顶点压入栈中。
public class DepthFirstPaths { private bool[] marked; private int[] edgeTo; //从起点到一个顶点的已知路径上的最后一个顶点 private int s;//起点 public DepthFirstPaths(Graph G, int s) { marked = new bool[G.V()]; edgeTo = new int[G.V()]; this.s = s; Dfs(G,s); } private void Dfs(Graph G, int v) { marked[v] = true; foreach (int w in G.Adj(v)) { if (!marked[w]) { edgeTo[w] = v; Dfs(G,w); } } } public bool HasPathTo(int v) { return marked[v]; } public IEnumerable<int> PathTo(int v) { if (!HasPathTo(v)) return null; Stack<int> path = new Stack<int>(); for (int x = v; x != s; x = edgeTo[x]) path.Push(x); path.Push(s); return path; } }
使用深度优先搜索获得从给定起点到任意标记顶点的路径所需的时间与路径长度成正比。
6.广度优先搜索
深度优先搜索获得的路径不只取决于图的结构,还取决于图的表示和递归调用的性质。
单点最短路径:给定一幅图和一个起点 s ,从 s 到给定目的顶点 v 是否存在一条路径?若是有,找出其中最短的那条(所含边最少)。
解决这个问题的经典方法叫作广度优先搜索(BFS)。深度优先搜索在这个问题上没有什么做用,由于它遍历整个图的顺序和找出最短路径的目标没有任何关系。相比之下,广度又出现搜索正式为了这个目标才出现的。
要找到从 s 到 v 的最短路径,从 s 开始,在全部由一条边就能够到达的顶点中寻找 v ,若是找不到就继续在与 s 距离两条边的全部顶点中查找 v ,如此一直进行。
在程序中,在搜索一幅图时遇到有不少边须要遍历的状况时,咱们会选择其中一条并将其余边留到之后再继续搜索。在深度优先搜索中,咱们用了一个能够下压栈。使用LIFO (后进先出)的规则来描述下压栈和走迷宫时先探索相邻的
通道相似。从有待搜索的通道中选择最晚遇到过的那条。在广度优先搜索中,咱们但愿按照与起点距离的顺序来遍历全部顶点,使用(FIFO,先进先出)队列来代替栈便可。咱们将从有待搜索的通道中选择最先遇到的那条。
实现
下面的算法使用了一个队列来保存全部已经被标记过但其邻接表还未被检查过的顶点。先将顶点加入队列,而后重复下面步骤知道队列为空:
1.取队列的下一个顶点 v 并标记它;
2.将与 v 相邻的全部未被标记过的顶点加入队列。
下面的 Bfs 方法不是递归。它显示地使用了一个队列。和深度优先搜索同样,它的结果也是一个数组 edgeTo[ ] ,也是一棵用父连接表示的根结点为 s 的树。它表示了 s 到每一个与 s 连通的顶点的最短路径。
namespace Graphs { /// <summary> /// 广度优先搜索 /// </summary> public class BreadthFirstPaths { private bool[] marked;//到达该顶点的最短路径已知吗? private int[] edgeTo;//到达该顶点的已知路径上的最后一个顶点 private int s;//起点 public BreadthFirstPaths(Graph G,int s) { marked = new bool[G.V()]; edgeTo = new int[G.V()]; this.s = s; Bfs(G,s); } private void Bfs(Graph G, int s) { Queue<int> queue = new Queue<int>(); marked[s] = true;//标记起点 queue.Enqueue(s);//将它加入队列 while (queue.Count > 0) { int v = queue.Dequeue();//从队列中删去下一个顶点 foreach (var w in G.Adj(v)) { if (!marked[w])//对于每一个未标记的相邻顶点 { edgeTo[w] = v;//保存最短路径的最后一条边 marked[w] = true;//标记它,由于最短路径已知 queue.Enqueue(w);//并将它添加到队列中 } } } } public bool HasPathTo(int v) { return marked[v]; } } }
轨迹:
对于从 s 可达的任意顶点 v ,广度优先搜索都能找到一条从 s 到 v 的最短路径,没有其余从 s 到 v 的路径所含的边比这条路径更少。
广度优先搜索所需的时间在最坏状况下和 V+E 成正比。
咱们也可使用广度优先搜索来实现已经用深度优先搜索实现的 Search API,由于它检查全部与起点连通的顶点和边的方法只取决于查找能力。
广度优先搜索和深度优先搜索在搜索中都会先将起点存入数据结构,而后重复如下步骤直到数据结构清空:
1.取其中的下一个顶点并标记它;
2.将 v 的全部相邻而又未被标记的顶点加入数据结构。
这两个算法的不一样之处在于从数据结构中获取下一个顶点的规则(对于广度优先搜索来讲是最先加入的顶点,对于深度优先搜索来讲是最晚加入的顶点)。这种差别获得了处理图的两种彻底不一样的视角,尽管不管使用哪一种规则,全部与起点连通的顶点和边都会被检查到。
深度优先搜索不断深刻图中并在栈中保存了全部分叉的顶点;广度优先搜索则像扇面通常扫描图,用一个队列保存访问过的最前端的顶点。深度优先搜索探索一幅图的方式是寻找离起点更远的顶点,只在碰到死胡同时才访问进出的顶点;广度优先搜索则首先覆盖起点附近的顶点,只在临近的全部顶点都被访问了以后才向前进。根据应用的不一样,所须要的性质也不一样。
7.连通份量
深度优先搜索的下一个直接应用就是找出一幅图的全部连通份量。在 union-find 中 “与......连通” 是一种等价关系,它可以将全部顶点切分红等价类(连通份量)。
实现
CC 的实现使用了 marked 数组来寻找一个顶点做为每一个连通份量中深度优先搜索的起点。递归的深度优先搜索第一次调用的参数是顶点 0 -- 它会标记全部与 0 连通的顶点。而后构造函数中的 for 循环会查找每一个没有被标记的顶点并递归调用 Dfs 来标记和它相邻的全部顶点。另外,还使用了一个以顶点做为索引的数组 id[ ] ,值为连通份量的标识符,将同一连通份量中的顶点和连通份量的标识符关联起来。这个数组使得 Connected 方法的实现变得很是简单。
namespace Graphs { public class CC { private bool[] marked; private int[] id; private int count; public CC(Graph G) { marked = new bool[G.V()]; id = new int[G.V()]; for (var s = 0; s < G.V(); s++) { if (!marked[s]) { Dfs(G,s); count++; } } } private void Dfs(Graph G, int v) { marked[v] = true; id[v] = count; foreach (var w in G.Adj(v)) { if (!marked[w]) Dfs(G,w); } } public bool Connected(int v, int w) { return id[v] == id[w]; } public int Id(int v) { return id[v]; } public int Count() { return count; } } }
深度优先搜索的预处理使用的时间和空间与 V + E 成正比且能够在常数时间内处理关于图的连通性查询。由代码可知每一个邻接表的元素都只会被检查一次,共有 2E 个元素(每条边两个)。
union-find 算法
CC 中基于深度优先搜索来解决图连通性问题的方法与 union-find算法 中的算法相比,理论上,深度优先搜索更快,由于它能保证所需的时间是常数而 union-find算法不行;但在实际应用中,这点差别微不足道。union-find算法其实更快,由于它不须要完整地构造表示一幅图。更重要的是,union-find算法是一种动态算法(咱们在任什么时候候都能用接近常数的时间检查两个顶点是否连通,甚至是添加一条边的时候),但深度优先搜索则必须对图进行预处理。
所以,咱们在只须要判断连通性或是须要完成大量连通性查询和插入操做混合等相似的任务时,更倾向使用union-find算法,而深度优先搜索则适合实现图的抽象数据类型,由于它能更有效地利用已有的数据结构。
使用深度优先搜索还能够解决 检测环 和双色问题:
检测环,给定的图是无环图吗?
namespace Graphs { public class Cycle { private bool[] marked; private bool hasCycle; public Cycle(Graph G) { marked = new bool[G.V()]; for (var s = 0; s < G.V(); s++) { if (!marked[s]) Dfs(G,s,s); } } private void Dfs(Graph g, int v, int u) { marked[v] = true; foreach (var w in g.Adj(v)) { if (!marked[w]) Dfs(g, w, v); else if (w != u) hasCycle = true; } } public bool HasCycle() { return hasCycle; } } }
是二分图吗?(双色问题)
namespace Graphs { public class TwoColor { private bool[] marked; private bool[] color; private bool isTwoColorable = true; public TwoColor(Graph G) { marked = new bool[G.V()]; color = new bool[G.V()]; for(var s = 0;s<G.V();s++) { if (!marked[s]) Dfs(G,s); } } private void Dfs(Graph g, int v) { marked[v] = true; foreach (var w in g.Adj(v)) { if (!marked[w]) { color[w] = !color[v]; Dfs(g, w); } else if (color[w] == color[v]) isTwoColorable = false; } } public bool IsBipartite() { return isTwoColorable; } } }
8.符号图
在典型应用中,图都是经过文件或者网页定义的,使用的是字符串而非整数来表示和指代顶点。为了适应这样的应用,咱们使用符号图。符号图的API:
这份API 定义一个构造函数来读取并构造图,用 name() 和 index() 方法将输入流中的顶点名和图算法使用的顶点索引对应起来。
实现
须要用到3种数据结构:
1.一个符号表 st ,键的类型为 string(顶点名),值的类型 int (索引);
2.一个数组 keys[ ],用做反向索引,保存每一个顶点索引对应的顶点名;
3.一个 Graph 对象 G,它使用索引来引用图中顶点。
SymbolGraph 会遍历两遍数据来构造以上数据结构,这主要是由于构造 Graph 对象须要顶点总数 V。在典型的实际应用中,在定义图的文件中指明 V 和 E 可能会有些不便,而有了 SymbolGraph,就不须要担忧维护边或顶点的总数。
namespace Graphs { public class SymbolGraph { private Dictionary<string, int> st;//符号名 -> 索引 private string[] keys;//索引 -> 符号名 private Graph G; public SymbolGraph(string fileName, string sp) { var strs = File.ReadAllLines(fileName); st = new Dictionary<string, int>(); //第一遍 foreach (var str in strs) { var _strs = str.Split(sp); foreach (var _str in _strs) { st.Add(_str,st.Count); } } keys = new string[st.Count]; foreach (var name in st.Keys) { keys[st[name]] = name; } //第二遍 将每一行的第一个顶点和该行的其余顶点相连 foreach (var str in strs) { var _strs = str.Split(sp); int v = st[_strs[0]]; for (var i = 1; i < _strs.Length; i++) { G.AddEdge(v,st[_strs[i]]); } } } public bool Contains(string s) { return st.ContainsKey(s); } public int Index(string s) { return st[s]; } public string Name(int v) { return keys[v]; } public Graph Gra() { return G; } } }
间隔的度数
可使用 SymbolGraph 和 BreadthFirstPaths 来查找图中的最短路径:
总结