diff --git a/zh/12.md b/zh/12.md index a890a83b5911eb84dc6413bf35cc9483183a84f5..52fa84381e7dc83bdee648169c29852913cfa3ef 100644 --- a/zh/12.md +++ b/zh/12.md @@ -26,4 +26,240 @@ ## 12.1 一名程序员的规范 -对于处理图的程序,现在没有一个单独鲜明的规范,因为不同的算法的不同需求可能会对合适的描述和方便支持这些描述的操作产生巨大的影响。但是处于教学目的,图12.3给出了一个通用的有向图的“一刀切”抽象示例,图12.4对无向图也给出了同样的抽象示例。其思想是顶点和边由自然数(非负整数)表示。而想要添加在顶点或边上的任何附加数据(例如包含更多信息的标签或权重)都可以按照以顶点或边号为索引的附加数组的形式,加在旁边。 \ No newline at end of file +对于处理图的程序,现在没有一个单独鲜明的规范,因为不同的算法的不同需求可能会对合适的描述和方便支持这些描述的操作产生巨大的影响。但是处于教学目的,图12.3给出了一个通用的有向图的“一刀切”抽象示例,图12.4对无向图也给出了同样的抽象示例。其思想是顶点和边由自然数(非负整数)表示。而想要添加在顶点或边上的任何附加数据(例如包含更多信息的标签或权重)都可以按照以顶点或边号为索引的附加数组的形式,加在旁边。 +```java +/** 一个常规的有向图。 For any given concrete extension of this * class, a different subset of the operations listed will work. 我们统一给所有顶点从0到N-1进行编号。*/ +public interface Digraph { + /** 顶点的数量。顶点的编号从0到numVertices()-1。 */ + int numVertices(); + /** 边的数量。边的编号从0到numEdges()-1。 */ + int numEdges(); + /** 边E的起点顶点和终点顶点 */ + int leaves(int e); + int enters(int e); + /** 如果[v0,v1]是图中的边,则返回true。 */ + boolean isEdge(int v0, int v1); + /** 顶点v的出度和入度。 */ + int outDegree(int v); + int inDegree(int v); + /** 以顶点v为起点的第K条边,0<=K= enters.length) +expandEdges(); // Expand all edge-indexed arrays +enters[numEdges] = v1; leaves[numEdges] = v0; nextInEdge[numEdges] = edgeIn0[v1]; edgeIn0[v1] = numEdges; nextOutEdge[numEdges] = edgeOut0[v0]; edgeOut0[v0] = numEdges; + numEdges += 1; +} +} +``` + +## 12.2 图的表示方法 + +### 12.2.1邻接表 + +如果有向图中的*后继*,*前驱*,*离开*,*进入*(或者无向图中的*关联*和*相邻*)等操作对与我们来说很重要,那么用邻接表来表示图会很方便,即把每个顶点和它的前驱节点列表,后继结点列表或者相邻节点列表连在一起存储。邻接表有很多存储形式,也可以用链表。图12.5展示了用数组存储邻接表,这种方法的好处是程序员通过有向图的相邻节点,或者通过所有边的集合。我这里使用了两种指示操作展现数据结构的工作原理。实际上它就是一个链表集,只是把指针替换成了数组和整数。图12.6展示了一个特定的有向图和用于表示此图的数据结构。 + +```java +/** A digraph */ +public class AdjGraph implements Digraph { +/** A new Digraph with N unconnected vertices */ public AdjGraph(int N) { +numVertices = N; numEdges = 0; +enters = new int[N*N]; leaves = new int[N*N]; nextOutEdge = new int[N*N]; nextInEdge = new int[N*N]; edgeOut0 = new int[N]; edgeIn0 = new int[N]; +} +/** The vertices that edge E leaves and enters. */ public int leaves(int e) { return leaves[e]; } public int enters(int e) { return enters[e]; } +/** Add an edge from V0 to V1. */ public void addEdge(int v0, int v1) { +if (numEdges >= enters.length) +expandEdges(); // Expand all edge-indexed arrays +enters[numEdges] = v1; leaves[numEdges] = v0; nextInEdge[numEdges] = edgeIn0[v1]; edgeIn0[v1] = numEdges; nextOutEdge[numEdges] = edgeOut0[v0]; edgeOut0[v0] = numEdges; + numEdges += 1; +} + /** The number of the Kth edge leaving vertex V, 0<=K 0; k -= 1) + e = nextOutEdge[e]; + return e; +} +··· +/* Private section */ +private int numVertices, numEdges; +/* The following are indexed by edge number */ +private int[] + enters, leaves, + nextOutEdge, /* The # of sibling outgoing edge, or -1 */ nextInEdge; /* The # of sibling incoming edge, or -1 */ +/* edgeOut0[v] is # of first edge leaving v, or -1. */ +private int[] edgeOut0; +/* edgeIn0[v] is # of first edge entering v, or -1. */ +private int[] edgeIn0; +} +``` + +图12.5:用邻接表表示有向图。上述代码只展示了部分方法。 + +![12.6](https://github.com/yuanrw/cs61b-textbook-zh/blob/ch12/zh/img/12.6.png) + +图12.6:图和它的邻接表。在这个例子里,用数组来存储节点列表。下面四个数组的索引是边的编号,上面两个数组的索引是顶点编号。//todo: + +这种数据结构的另一种变体是把顶点和边分开。顶点和边会包含如下属性: + +```java +class Vertex { +private int num; /* Number of this vertex */ +private Edge edgeOut0, edgeIn0; /* First outgoing & incoming edges. */ ... +} +class Edge { +private int num; /* Number of this edge */ private Vertex enters, leaves; +private Edge nextOutEdge, nextInEdge; +} +``` + +### 12.2.2 边集表示法 + +如果我们只想列举出所有的边和所有相邻的节点,那我们可以把12.2.1简化一下:去掉edgeOut0, edgeIn0, nextOutEdge, 和 nextInEdge。稍后我们会看到某些场景非常适用于这个算法。 + +![12.7](https://github.com/yuanrw/cs61b-textbook-zh/blob/ch12/zh/img/12.7.png) + +图12.7:上图:一个有向图和它的邻接矩阵。下图:一个无向图的变体和它的邻接矩阵。 + +### 12.2.3 邻接矩阵 + +如果图非常稠密(图中的边可能很多),并且以下操作很频繁:判断顶点之间是否存在边和获取边的权重,那么我们可以使用邻接矩阵。我们给顶点0~顶点|V|-1(|V|代表顶点集V的大小),然后初始化一个|V|*|V|的矩阵,如果顶点i和顶点j之间有边,那么矩阵中的元素(i,j)的值设为1,反之设为0。如果是有权重的边,则把元素(i,j)的值设为权重,如果没有边也可以把值设为某个特殊值(这种情况可以由图12.3扩展而来)。无向图的矩阵是对称的。图12.7展示了一个无权有向图和一个无权无向图和他们的邻接矩阵。 + +无权图的邻接矩阵有一些有趣的特性。比如说图12.7的第一张图,首先我们把矩阵与自己相乘的结果定义为:![expr1](http://latex.codecogs.com/gif.latex? (X \cdot X)_{ij}=\sum_{0 \leq k<|V|}X_{ik} \cdot X_{kj}) 。然后把上述矩阵和自己相乘,得到结果: + +![12.multi-result](https://github.com/yuanrw/cs61b-textbook-zh/blob/ch12/zh/img/12.multi-result.png) + +通过分析这个结果,可以看到![expr2](http://latex.codecogs.com/gif.latex? (X \cdot X)_{ij})的值等于满足顶点i和顶点k之间有一条边(![expr3](http://latex.codecogs.com/gif.latex? M_{ik}=1)),顶点k和顶点j之间也有一条边(![expr4](http://latex.codecogs.com/gif.latex? M_{kj}=1))的顶点k的数量。而对于其他的节点,![expr5](http://latex.codecogs.com/gif.latex? M_{ik})和![expr6](http://latex.codecogs.com/gif.latex? M_{kj})中至少有一个为0。所以显然,![expr7](http://latex.codecogs.com/gif.latex? M_{ij}^2)代表顶点i,j两点间长度为2的路的条数。同样,![expr8](http://latex.codecogs.com/gif.latex? M_{ij}^3)代表顶点i,j两点间长度为3的路的条数。如果用布尔代数来计算(布尔代数中0+1=1+1=1),那么当两个顶点满足:顶点间有至少一条长度为2的路径,它在矩阵中的值就为1。 + +稀疏的图(边的数量远远小于![expr9](http://latex.codecogs.com/gif.latex? V^2)的图)不适合用邻接矩阵来存储。当你想动态地添加和删除顶点时,它们也会出问题。 + +### 12.3 图的算法 + +很多有趣的图算法都涉及到对图的顶点或边进行某种遍历。就像树一样,我们可以以深度优先或宽度优先的方式遍历一个图(直观来说,从起始点出发要尽可能快或尽可能慢)。 + +#### 12.3.1 标记 + +但是,和树不同的是,在图中顶点可以通过边远离最终返回到自身,这就需要记录哪些节点已经访问过,这个操作叫做*标记*(marking)。有以下几种方式可以标记。 + +**标记位。**如果顶点像图12.2.1中所示的类Vertex那样,是用对象表示的,我们可以在每个顶点中使用一个比特位,来记录是否访问过该顶点。这些位最开始必须全部置为开启(或关闭)状态,然后在第一次访问该顶点时被翻转。同样这种方法也适用于边。 + +**标记次数。**标记位有一个问题,我们必须保证所以顶点在遍历开始时都以相同的方式初始化。如果某次遍历不完全,会导致标记位在此次遍历之后被随机设置,解决这个问题我们可以使用数字更大的标记。每次遍历使用的标记数字递增(第一次遍历是1,第二次是2,以此类推)。每次访问节点,将其标记设置为当前的遍历数。每次遍历都确保产生一个和之前的标记数都不一样的新数字(假设标记字段已正确初始化,比如为0)。 + +**位数组。**如果在我们的抽象里,每个顶点有数字索引,我们可以使用一个位数组M来标记,如果顶点i被访问过了,就将M[i]设为1。在遍历开始时重置位数组是很方便的。 + +**其他。**有的时候,正在执行的某种特定遍历方法已经提供了一种识别节点是否被访问过的方法。所以我们不能笼统地谈论这个问题。 + +#### 12.3.2通用遍历模板 + +许多图形算法都具有以下通用形式。不同的应用,需要替换斜体大写字母的名称。 + +```java +/* 通用的遍历模板 */ +COLLECTION OF VERTICES fringe; +fringe = INITIAL COLLECTION; +while (! fringe.isEmpty()) { + Vertex v = fringe.REMOVE HIGHEST PRIORITY ITEM (); + if (! MARKED(v)) { + MARK (v); + VISIT (v); + For each edge (v,w) { + if (NEEDS PROCESSING(w)) + Add w to fringe; + } + } +} +``` + +在接下来的几节中,我们会研究这个模板的各种算法。 + +#### 12.3.3通用的深度优先遍历和广度优先遍历 + +图的深度优先算法基本上和树是一样的,唯一不同的是图需要检查节点是否已访问过。实现以下接口 + +```java +/** Perform the operation VISIT on each vertex reachable from V * in depth-first order. */ +void depthFirstVisit(Vertex v) +``` + +我们使用通用的图遍历模板,并且替换以下实现: + +COLLECTION OF VERTICES 是一个栈。 + +INITIAL COLLECTION 是一个集合{v}。 + +REMOVE HIGHEST PRIORITY ITEM 从顶部弹出一个元素。 + +MARK and MARKED 设置并检查一个标记位(参见上面的讨论)。 + +NEEDS PROCESSING 表示“未标记”。 + +通常情况下,我们可以NEEDS PROCESSING字段角(使它总是正确的)。唯一的效果就是增加了堆栈的大小。 + +宽度优先搜索基本相同。区别如下: + +COLLECTION OF VERTICES 是一个(FIFO)队列。 + +REMOVE HIGHEST PRIORITY ITEM 是删除并返回队列中的第一个(最近添加最少的)元素。 + +#### 12.3.4 拓扑排序 + +有向图的*拓扑排序*是指把顶点按照特定规则排序:如果从顶点v出发到顶点w是可达的,那么w就排在v后面,如果我们把图想象成顶点的某种有序关系,那么拓扑排序就是一种线性的排序。循环有向图不存在拓扑排序。例如,UNIX系统的make程序使用了拓扑排序,用来查找执行命令的顺序,然后更新每个文件,以便在后续命令中使用。 + +执行拓扑排序的时候,需要维护每个顶点在当前未处理的顶点集中的入度。以下版本使用一个数组来保存入度。拓扑排序的算法的当前实现: \ No newline at end of file diff --git a/zh/img/12.6.png b/zh/img/12.6.png new file mode 100644 index 0000000000000000000000000000000000000000..33fee54bab2209652e9b80e8392b36ef4ae5df7c Binary files /dev/null and b/zh/img/12.6.png differ diff --git a/zh/img/12.7.png b/zh/img/12.7.png new file mode 100644 index 0000000000000000000000000000000000000000..e6c4b7af965a6fc243667ab61ca4af695c1fe070 Binary files /dev/null and b/zh/img/12.7.png differ diff --git a/zh/img/12.multi-result.png b/zh/img/12.multi-result.png new file mode 100644 index 0000000000000000000000000000000000000000..dc728b5869d04e11eed3ab8e1e98e3751cde14e9 Binary files /dev/null and b/zh/img/12.multi-result.png differ