diff --git a/zh/12.md b/zh/12.md index 3930f5c87a153b343f1c1a8a25ae50d264100b57..c6567a3f42427a8160cbd4a0ff985f2c64a2c569 100644 --- a/zh/12.md +++ b/zh/12.md @@ -2,33 +2,33 @@ > 译者:[yuanrw](https://github.com/yuanrw) -在计算机科学中,图是一种代表数学关系的数据结构,它包含一个*顶点(节点)*的集合和一个*边*的集合,每一个顶点和一条边组成了一个向量。如果图中的每条边是无方向的,那就是一个无向图,如果边是有方向的,那就是一个有向图,在有向图中每一条边都从一个顶点出发到另一个顶点。例如我们有顶点v和w,那么连接v和w的边就用(v,w)来表示,如果这是一条无向边,那么也可以用{v,w}表示,如果是有向边,边的方向是从v出发到w,则用[v,w]表示。(v,w)是指连接顶点v和顶点w的一条边,如果(v,w)是无向的,那么我们就说v和w是相邻顶点,顶点的*度*(degree)是指和这个顶点相关联的边的条数。有向图中,*入度*是指进入该顶点的边的条数,出度是指从该顶点出发的边的条数。一般来说,一条边所连接的两个顶点是不一样的,一条边不会从一个顶点出发又回到这个顶点。 +在计算机科学中,图是一种代表数学关系的数据结构,它包含一个*顶点(节点)*的集合和一个*边*(两个顶点组成的顶点对)的集合。如果顶点对是无序的,那就是一个*无向图*,如果顶点对是有序的,那就是一个*有向图*,有向图中每一条边都从一个顶点*出发*到另一个顶点*结束*。例如我们有顶点v和w,那么连接v和w的边就用(v,w)来表示,如果是一条无向边,那么也可以用{v,w}表示,如果是有向边,边的方向是从v出发到w,则用[v,w]表示。(v,w)是指连接顶点v和顶点w的一条边,如果(v,w)是无向的,那么我们就说v和w是*相邻顶点*,顶点的*度(degree)*是指和这个顶点相连的边的条数。有向图中,*入度(in-degree)*是指进入该顶点的边的条数,*出度(out-degree)*是指从该顶点出发的边的条数。一般来说,一条边所连接的两个顶点是不一样的,一条边不会从一个顶点出发又回到这个顶点。 -如果某一个图的顶点集和边集分别是图G的顶点集的子集和边集的子集的图,那这个图就叫做G的子图。 +如果某一个图的顶点集是图G的顶点集的子集,边集也是图G边集的子集,那这个图就叫做G的*子图(subgraph)*。 -若存在一个顶点序列![v0](http://latex.codecogs.com/gif.latex?v_0}),![v1](http://latex.codecogs.com/gif.latex?v_1}),…,![vk-1](http://latex.codecogs.com/gif.latex?v_k-1})其中v=![v0](http://latex.codecogs.com/gif.latex?v_0}),![v'](http://latex.codecogs.com/gif.latex?v'})=![vk-1](http://latex.codecogs.com/gif.latex?v_k-1}),序列中任意两个顶点组成的边(![vi](http://latex.codecogs.com/gif.latex?v_i}),![vi+1](http://latex.codecogs.com/gif.latex?v_i+1}))都在图中,那么这个序列就叫做顶点v到![v'](http://latex.codecogs.com/gif.latex?v'})的一条*路径*,路径的长度(该路径上边的数目)k![ge](http://latex.codecogs.com/gif.latex?\geq}) 0,这条定义对有向图和无向图都适用;在有向图中,路径是有方向的。如果路径中没有重复顶点,那我们就称它为*简单路径*。当k > 1且v=![v'](http://latex.codecogs.com/gif.latex?v'}),那么它就是*环*,如果![v0](http://latex.codecogs.com/gif.latex?v_0})…,![vk-2](http://latex.codecogs.com/gif.latex?v_k-2})是不重复的,我们就称它为一个*简单环*;在无向图中,环还必须满足另一个条件:环中不能有两条重复的边。一个没有环的图我们称之为*无环图*。 +若存在一个顶点序列![v0](http://latex.codecogs.com/gif.latex?v_0}),![v1](http://latex.codecogs.com/gif.latex?v_1}),…,![vk-1](http://latex.codecogs.com/gif.latex?v_k-1})其中v=![v0](http://latex.codecogs.com/gif.latex?v_0}),![v'](http://latex.codecogs.com/gif.latex?v'})=![vk-1](http://latex.codecogs.com/gif.latex?v_k-1}),序列中任意两个顶点组成的边(![vi](http://latex.codecogs.com/gif.latex?v_i}),![vi+1](http://latex.codecogs.com/gif.latex?v_i+1}))都在图中,那么这个序列就叫做顶点v到![v'](http://latex.codecogs.com/gif.latex?v'})的一条*路径(path)*,路径的长度(该路径上边的数目)k![ge](http://latex.codecogs.com/gif.latex?\geq}) 0,这条定义对有向图和无向图都适用;在有向图中,路径是有方向的。如果路径中没有重复顶点,那我们就称它为*简单(simple)*路径。当k > 1且v=![v'](http://latex.codecogs.com/gif.latex?v'}),那么它就是*环(cycle)*,如果环里的顶点![v0](http://latex.codecogs.com/gif.latex?v_0})…,![vk-2](http://latex.codecogs.com/gif.latex?v_k-2})不重复的,我们就称它*简单环(simple cycle)*;在无向图中,环还必须满足另一个条件:环中不能有两条重复的边。一个没有环的图我们称之为*无环图(acyclic)*。 -如果从顶点v到顶点![v'](http://latex.codecogs.com/gif.latex?v'})有路径,那么![v'](http://latex.codecogs.com/gif.latex?v'})和v就是*连通的*。在无向图中,如果存在一个子图,其中任何两个顶点都是联通的,并且子图外的没有顶点与子图内的顶点连通,那么这个子图就是一个*连通分量*。如果一个无向图中只有一个连通分量,那么这个图就是一个*连通图*(即连通分量中包含了左右顶点)。 +如果从顶点v到顶点![v'](http://latex.codecogs.com/gif.latex?v'})有路径,那么![v'](http://latex.codecogs.com/gif.latex?v'})对于v来说就是*可达的(reachable)*。在无向图中,若存在一个子图,其中每个顶点对于图中的任意一个顶点都是可达的,并且子图外的顶点对于子图内的顶点都不可达,那么这个子图就是一个*连通分量(connected component)*。如果一个无向图中只有一个连通分量,那么这个图就是一个*连通(connected)图*(即连通分量中包含图的所有顶点)。 -有向图的连通分量和无向图中一样,唯一的区别是所有的边都是有向边。有向图中如果有一子图,子图中任意两个顶点都是连通的,那么这个子图叫做*强连通分量*。见图12.1和图12.2。 +有向图的连通分量和无向图中一样,唯一的区别是所有的边都是有向边。有向图中如果有一子图,子图中任意两个顶点都是互相可达的,那么这个子图叫做*强连通分量(strongly connected component)*。见图12.1和图12.2。 ![12.1](https://github.com/yuanrw/cs61b-textbook-zh/blob/ch12/zh/img/12.1.png) -图12.1:一个无向图。星标的边与顶点1,2相连,顶点4的度为0;顶点3,7,8和9的度为1;顶点2和6的度为2;顶点0和度的度为3。被虚线包围的是连通分量;由于图中连通分量的数量大于1,因此这不是连通图。序列[2,1,0,3]顶点2到顶点3的一条路径。路径[2,1,0,2]是一个环。图中唯一包含顶点4的路径是[4],路径长度为0。最右图的连通分量是一个无环图,也叫*自由树*。 +图12.1:一个无向图。星标的边与顶点1,2相连,顶点4的度为0;顶点3,7,8和9的度为1;顶点1,2和6的度为2;顶点0和5的度为3。被虚线包围的是连通分量;由于图中连通分量的数量大于1,因此这不是连通图。序列[2,1,0,3]是顶点2到顶点3的一条路径。路径[2,1,0,2]是一个环。图中唯一包含顶点4的路径是[4],路径长度为0。最右图的连通分量是一个无环图,也叫自由树。 ![12.2](https://github.com/yuanrw/cs61b-textbook-zh/blob/ch12/zh/img/12.2.png) -图12.2:一个有向图。被虚线包围的是连通分量。顶点5,6和7形成了一个强连通分量。其他的单个顶点自身也形成了强连通分量。最左图是一个无环图。顶点0和4的入度都为0;顶点1,2和5-8的入度都为1;顶点3的入度为3.顶点3和8的出度为0;顶点1,2,4,5和7的出度为1;顶点0和6的出度为2。 +图12.2:一个有向图。被虚线包围的是连通分量。顶点5,6和7形成了一个强连通分量。其他的单个顶点自身也形成了强连通分量。最左图是一个无环图。顶点0和4的入度都为0;顶点1,2和5-8的入度都为1;顶点3的入度为3。顶点3和8的出度为0;顶点1,2,4,5和7的出度为1;顶点0和6的出度为2。 -一个无向连通无环图被称为*自由树*(图中任意两个顶点之间只有一条简单路径)。如果一个无向图中任意两个顶点之间有至少两条简单路径,那么这个图是*变连通的*(biconnected)。 +一个无向连通无环图被称为*自由树(free tree)*(图中任意两个顶点之间只有一条简单路径)。如果一个无向图中任意两个顶点之间有至少两条简单路径,那么这个图是*变连通的(biconnected)*。 -在实际运用中,我们用图中的边上记录一些信息。例如,如果顶点表示城市,那么边就表示道路,我们可能希望在边上记录城市间的距离。又例如顶点表示泵站,边表示管道,那我们可能希望在边上记录水的容量。这些数字信息我们叫做*权重*。 +在实际运用中,我们通常在图的边上绑定一些信息。例如,如果顶点表示城市,那么边就表示道路,我们可能希望在边上记录城市间的距离。又例如顶点表示泵站,边表示管道,那我们可能希望在边上记录水的容量。这些数字信息我们叫做*权重(weights)*。 ## 12.1 一名程序员的规范 对于处理图的程序,现在没有一个单独鲜明的规范,因为不同的算法的不同需求可能会对合适的描述和方便支持这些描述的操作产生巨大的影响。但是处于教学目的,图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进行编号。*/ +/** 一个常规的有向图。 对于这个类的任何实现, 下列操作的不同子集都有效。我* 们统一给所有顶点从0到N-1进行编号。*/ public interface Digraph { /** 顶点的数量。顶点的编号从0到numVertices()-1。 */ int numVertices(); @@ -64,7 +64,7 @@ public interface Digraph { 图12.3:有向图的简单抽象接口(java描述) ```java -/** 一个常规的无向图。For any given concrete extension of * this class, a different subset of the operations listed will work. * For uniformity, we take all vertices to be numbered with integers * between 0 and N-1. */ +/** 一个常规的有向图。 对于这个类的任何实现, 下列操作的不同子集都有效。我* 们统一给所有顶点从0到N-1进行编号。*/ public interface Graph { /** 顶点的数量。顶点的编号从0到numVertices()-1。*/ int numVertices(); @@ -76,78 +76,77 @@ public interface Graph { boolean isEdge(int v0, int v1); /** 顶点#v的度(和顶点#v连接的边的数量)。 */ int degree(int v); - /** The number of the Kth edge incident on V, 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展示了一个特定的有向图和用于表示此图的数据结构。 +如果有向图中的*后继(succ)*,*前驱(pred)*,*离开(leaving)*,*进入(entering)*(或者无向图中的*连接(incident)*和*相邻(adjacent)*)等操作对与我们研究的问题很重要,那么用邻接表来表示图会很方便,即把每个顶点和它的前驱节点列表,后继结点列表或者相邻节点列表放在一起存储。邻接表有很多存储形式,比如链表。图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; + /** 初始化一个包含N个独立顶点的有向图 */ + 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 number of the Kth edge leaving vertex V, 0<=K 0; k -= 1) - e = nextOutEdge[e]; - return e; + /** 边e离开和进入的顶点。 */ + public int leaves(int e) { return leaves[e]; } + public int enters(int e) { return enters[e]; } + /** 增加一条从v0出发到v1的边。 */ + public void addEdge(int v0, int v1) { + if (numEdges >= enters.length) + expandEdges(); // 扩展边数组 + enters[numEdges] = v1; + leaves[numEdges] = v0; + nextInEdge[numEdges] = edgeIn0[v1]; + edgeIn0[v1] = numEdges; + nextOutEdge[numEdges] = edgeOut0[v0]; + edgeOut0[v0] = numEdges; + numEdges += 1; + } } + /** 离开顶点v的第k条边的编号, 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; + /* 私有域 */ + private int numVertices, numEdges; + /* 下列域都以边的编号作为索引 */ + private int[] + enters, leaves, + nextOutEdge, /* 指向从节点出发的下一个兄弟边, 没有即为-1。 */ nextInEdge; /* 指向进入节点的下一个兄弟边, 没有即为-1。 */ + /* 离开节点的第一条边, 没有即为-1。 */ + private int[] edgeOut0; + /* 进入节点的第一条边, 没有即为-1。 */ + private int[] edgeIn0; } ``` @@ -155,32 +154,34 @@ private int[] edgeIn0; ![12.6](https://github.com/yuanrw/cs61b-textbook-zh/blob/ch12/zh/img/12.6.png) -图12.6:图和它的邻接表。在这个例子里,用数组来存储节点列表。下面四个数组的索引是边的编号,上面两个数组的索引是顶点编号。//todo: +图12.6:图和它的邻接表(其中一种实现)。在这个例子里,用数组来实现邻接表。下面四个数组的索引是边的编号,上面两个数组的索引是顶点编号。nextOutEdge是从每个顶点出发的边组成的链表,表头存储在edgeOut0内。同样,nextInEdge和edgeIn0由进入每个顶点的边组成的链表。数组enters和leaves存储了每个顶点连接的边。 这种数据结构的另一种变体是把顶点和边分开。顶点和边会包含如下属性: ```java class Vertex { -private int num; /* Number of this vertex */ -private Edge edgeOut0, edgeIn0; /* First outgoing & incoming edges. */ ... + private int num; /* 顶点的数量 */ + private Edge edgeOut0, edgeIn0; /* 以节点为起点、终点的第一条边。 */ + ... } class Edge { -private int num; /* Number of this edge */ private Vertex enters, leaves; -private Edge nextOutEdge, nextInEdge; + private int num; /* 边的数量 */ + private Vertex enters, leaves; + private Edge nextOutEdge, nextInEdge; } ``` ### 12.2.2 边集表示法 -如果我们只想列举出所有的边和所有相邻的节点,那我们可以把12.2.1简化一下:去掉edgeOut0, edgeIn0, nextOutEdge, 和 nextInEdge。稍后我们会看到某些场景非常适用于这个算法。 +如果我们只想列举出所有的边和所有相连的节点,那我们可以把12.2.1简化一下:去掉edgeOut0, edgeIn0, nextOutEdge, 和 nextInEdge。稍后我们会看到某些场景非常适用于这个算法。 -![12.7](https://github.com/yuanrw/cs61b-textbook-zh/blob/ch12/zh/img/12.7.png) +### 12.2.3 邻接矩阵 -图12.7:上图:一个有向图和它的邻接矩阵。下图:一个无向图的变体和它的邻接矩阵。 +如果图非常*稠密(dense)*(图中存在的边很多),并且以下操作很频繁:判断顶点之间是否存在边和获取边的权重,这种情况下我们可以使用*邻接矩阵(adjacency matrix)*。我们给顶点用数字0~|V|-1(|V|代表顶点集V的大小)编号,然后初始化一个|V|*|V|的矩阵,如果顶点i和顶点j之间有边,那么矩阵中的元素(i,j)的值设为1,反之设为0。如果是有权重的边,则把元素(i,j)的值设为权重,如果没有边也可以把值设为某个特殊值(这种情况可以由图12.3扩展而来)。无向图的矩阵是对称的。图12.7展示了一个无权有向图和一个无权无向图和他们的邻接矩阵。 -### 12.2.3 邻接矩阵 +![12.7](https://github.com/yuanrw/cs61b-textbook-zh/blob/ch12/zh/img/12.7.png) -如果图非常稠密(图中的边可能很多),并且以下操作很频繁:判断顶点之间是否存在边和获取边的权重,那么我们可以使用邻接矩阵。我们给顶点0~顶点|V|-1(|V|代表顶点集V的大小),然后初始化一个|V|*|V|的矩阵,如果顶点i和顶点j之间有边,那么矩阵中的元素(i,j)的值设为1,反之设为0。如果是有权重的边,则把元素(i,j)的值设为权重,如果没有边也可以把值设为某个特殊值(这种情况可以由图12.3扩展而来)。无向图的矩阵是对称的。图12.7展示了一个无权有向图和一个无权无向图和他们的邻接矩阵。 +图12.7:上图:一个有向图和它的邻接矩阵。下图:一个无向图的变体和它的邻接矩阵。 无权图的邻接矩阵有一些有趣的特性。比如说图12.7的第一张图,首先我们把矩阵与自己相乘的结果定义为:![expr1](http://latex.codecogs.com/gif.latex?(X%20\cdot%20X)_{ij}=\sum_{0%20\leq%20k<|V|}X_{ik}%20\cdot%20X_{kj}) 。然后把上述矩阵和自己相乘,得到结果: @@ -196,11 +197,11 @@ private Edge nextOutEdge, nextInEdge; #### 12.3.1 标记 -但是,和树不同的是,在图中顶点可以通过边远离最终返回到自身,这就需要记录哪些节点已经访问过,这个操作叫做*标记*(marking)。有以下几种方式可以标记。 +但是,和树不同的是,在图中顶点有可能通过边回到自身,这就需要记录哪些节点已经被访问过,这个操作叫做*标记(marking)*。有以下几种方式可以标记。 -**标记位。**如果顶点像图12.2.1中所示的类Vertex那样,是用对象表示的,我们可以在每个顶点中使用一个比特位,来记录是否访问过该顶点。这些位最开始必须全部置为开启(或关闭)状态,然后在第一次访问该顶点时被翻转。同样这种方法也适用于边。 +**标记位。**如果顶点像图12.2.1中所示的类Vertex那样,是用对象表示的,我们可以在每个顶点中使用一个比特位,来记录是否访问过该顶点。这些位最开始必须全部置为开启(或关闭)状态,然后在第一次访问该顶点时被翻转。这种方法也适用于边。 -**标记次数。**标记位有一个问题,我们必须保证所以顶点在遍历开始时都以相同的方式初始化。如果某次遍历不完全,会导致标记位在此次遍历之后被随机设置,解决这个问题我们可以使用数字更大的标记。每次遍历使用的标记数字递增(第一次遍历是1,第二次是2,以此类推)。每次访问节点,将其标记设置为当前的遍历数。每次遍历都确保产生一个和之前的标记数都不一样的新数字(假设标记字段已正确初始化,比如为0)。 +**标记次数。**标记位有一个问题,我们必须保证所有的顶点在遍历开始时都以相同的方式初始化。如果某次遍历不完全,会导致遍历结束后标记位不统一,解决这个问题我们可以使用更大的数字来标记。每次遍历使用的标记数字递增(第一次遍历是1,第二次是2,以此类推)。每次访问节点,将其标记位设置为当前的遍历数。每次遍历都确保产生一个和之前的标记数都不一样的新数字(假设标记字段已正确初始化,比如为0)。 **位数组。**如果在我们的抽象里,每个顶点有数字索引,我们可以使用一个位数组M来标记,如果顶点i被访问过了,就将M[i]设为1。在遍历开始时重置位数组是很方便的。 @@ -227,14 +228,14 @@ while (! fringe.isEmpty()) { } ``` -在接下来的几节中,我们会研究这个模板的各种算法。 +在接下来的几节中,我们会研究这个模板的各种实现算法。 #### 12.3.3通用的深度优先遍历和广度优先遍历 -图的深度优先算法基本上和树是一样的,唯一不同的是图需要检查节点是否已访问过。实现以下接口 +图的深度优先算法基本上和树是一样的,唯一不同的是图需要检查节点是否已被访问过。实现以下接口 ```java -/** Perform the operation VISIT on each vertex reachable from V * in depth-first order. */ +/** 在深度优先遍历中,对V可达的每个顶点都需要执行VISIT操作。 */ void depthFirstVisit(Vertex v) ``` @@ -250,29 +251,33 @@ MARK and MARKED 设置并检查一个标记位(参见上面的讨论)。 NEEDS PROCESSING 表示“未标记”。 -通常情况下,我们可以NEEDS PROCESSING字段角(使它总是正确的)。唯一的效果就是增加了堆栈的大小。 +通常情况下,我们可以去掉NEEDS PROCESSING字段(让它永远返回true)。唯一的影响就是增加了堆栈的大小。 宽度优先搜索基本相同。区别如下: COLLECTION OF VERTICES 是一个(FIFO)队列。 -REMOVE HIGHEST PRIORITY ITEM 是删除并返回队列中的第一个(最近添加最少的)元素。 +REMOVE HIGHEST PRIORITY ITEM 是删除并返回队列中的第一个(最新添加的)元素。 #### 12.3.4 拓扑排序 -有向图的*拓扑排序*是指把顶点按照特定规则排序:如果从顶点v出发到顶点w是可达的,那么w就排在v后面,如果我们把图想象成顶点的某种有序关系,那么拓扑排序就是一种线性的排序。循环有向图不存在拓扑排序。例如,UNIX系统的make程序使用了拓扑排序,用来查找执行命令的顺序,然后更新每个文件,以便在后续命令中使用。 +有向图的*拓扑排序(topological sort)*是指把顶点按照特定规则排序:如果对于顶点v来说,顶点w是可达的,那么w就排在v后面,如果我们把图想象成顶点的某种有序关系,那么拓扑排序就是一种线性的顺序。循环有向图不存在拓扑排序。例如,UNIX系统的make程序使用了拓扑排序,用来查找执行命令的顺序,然后更新每个文件,以便在后续命令中使用。 执行拓扑排序的时候,需要维护每个顶点在当前未处理的顶点集中的入度。以下版本使用一个数组来保存入度。拓扑排序的算法的当前实现: ```java -/** An array of the vertices in G in topologically sorted order. * Assumes G is acyclic. */ +/** 返回按照拓扑顺序排列的图G顶点的数组,假设图G是无环图。 */ static int[] topologicalSort(Digraph G) { -int[] count = new int[G.numVertices()]; int[] result = new int[G.numVertices()]; int k; -for (int v = 0; v < G.numVertices(); v += 1) count[v] = G.inDegree(v); -Graph-traversal schema replacement for topological sorting; return result; + int[] count = new int[G.numVertices()]; + int[] result = new int[G.numVertices()]; + int k; + for (int v = 0; v < G.numVertices(); v += 1) + count[v] = G.inDegree(v); + 把图遍历的模板替换成拓扑排序; + return result; } ``` -模板中有下列可替换的地方: +模板中有下列地方需要替换: COLLECTION OF VERTICES 可以是顶点的集合、multiset、列表或序列(栈、队列等)。 @@ -282,44 +287,46 @@ REMOVE HIGHEST PRIORITY ITEM 可以删除任意元素。 MARKED 和 MARK 可以是不重要的操作。(比如永远返回false,不做任何操作)。 -VISIT(v) 将顶点v设为结果集中的下一个非空元素,并且把与顶点v的所有边(v,w)相邻的顶点的入度count[w]值减一。 +VISIT(v) 将顶点v设为结果集中的下一个非空元素,并且把与顶点v的所有边(v,w)相邻的顶点的入度count[w]减1。 NEEDS PROCESSING 如果count[w]==0,则返回true。 图12.8展示了该算法。 -### 12.3.5 最小生成树 +![12.8](https://github.com/yuanrw/cs61b-textbook-zh/blob/ch12/zh/img/12.8.png) -现在讨论一个连通加权无向图。*最小(权值)生成树*(简称MST)是一种树,它是给定图的子图,包含给定图的所有顶点,并且权的总和最小。比如,我们有一些城市,现在想以电话线作为路径把这些城市连接起来,并且让电话线的成本达到最小。城市就是顶点,城市之间的连接就是边。寻找最小连接集合的过程就是寻找最小生成树(可能不止一个)。为此,我们利用了最小连通树的一个有用定理。 +图12.8:拓扑排序的输入(左上图)和处理的三个阶段。灰色的是已处理并且放入结果集的节点。第一个处理的节点是图的边缘上的某一节点。字母下标代表节点入度。最终的结果是:A, C, F, D, B, E, G,H(这只是其中一种可能)。 -![12.8](https://github.com/yuanrw/cs61b-textbook-zh/blob/ch12/zh/img/12.8.png) +### 12.3.5 最小生成树 -图12.8:拓扑排序的输入(左上图)和处理的三个阶段。灰色的是已处理并且放入结果集的节点。第一个处理的节点是图的边缘上的一个节点。字母下标代表节点入度。按照图中的处理。最终的结果是:A, C, F, D, B, E, G,H(这只是其中一种可能)。 +现在讨论一个连通加权无向图。*最小(权值)生成树(minimun(-weight)spanning tree)*(简称MST)是一种树,它是给定图的子图,包含给定图的所有顶点,并且边权值的总和最小。比如,我们有一些城市,现在想以电话线作为路径把这些城市连接起来,并且让电话线的总成本达到最小。城市就是顶点,城市之间的连接就是边。寻找总值最小的连接集合的过程就是寻找最小生成树(可能不止一个)。为了实现这个算法,我们利用了最小连通树的一个有用定理。 **定理:**如果连通图G的顶点被分成两个不相交的非空集,![v0](http://latex.codecogs.com/gif.latex?V_0)和![v1](http://latex.codecogs.com/gif.latex?V_1),那么G的任意最小连通树一定包含这样一条边:边所连接的两个节点分别属于![v0](http://latex.codecogs.com/gif.latex?V_0)和![v1](http://latex.codecogs.com/gif.latex?V_1)(即横跨![v0](http://latex.codecogs.com/gif.latex?V_0)和![v1](http://latex.codecogs.com/gif.latex?V_1)),并且边的权值是横跨两个图的所有边中最小的(可能不止一条)。 -**证明。**可以很方便地用反证法证明。假设最小生成树T不包含横跨![v0](http://latex.codecogs.com/gif.latex?V_0)和![v1](http://latex.codecogs.com/gif.latex?V_1)且权值最小的边。现在给T添加一条横跨![v0](http://latex.codecogs.com/gif.latex?V_0)和![v1](http://latex.codecogs.com/gif.latex?V_1)且权值最小的边e,得到图![T'](http://latex.codecogs.com/gif.latex?T')(必须有这样一条边,否则T就不是连通的)。因为T是一棵树,添加了这条新的边之后,会产出一个包含e的环(因为连接e的两个节点之间肯定是有路径的)。这个环中包含的另一条横跨![v0](http://latex.codecogs.com/gif.latex?V_0)和![v1](http://latex.codecogs.com/gif.latex?V_1)边![e'](http://latex.codecogs.com/gif.latex?e'),假设e的权值比![e'](http://latex.codecogs.com/gif.latex?e')小,把![e'](http://latex.codecogs.com/gif.latex?e')从![t'](http://latex.codecogs.com/gif.latex?T')中移除,得到一颗新的树,但是由于我们用![e'](http://latex.codecogs.com/gif.latex?e')替换了e,这棵新树的边权值之和小于T的边权值之和,和假设矛盾。得出T不包含从![v0](http://latex.codecogs.com/gif.latex?V_0)到![v1](http://latex.codecogs.com/gif.latex?V_1)的最小权值边的假设是错的。(证明) +**证明。**可以很方便地用反证法证明。假设最小生成树T不包含横跨![v0](http://latex.codecogs.com/gif.latex?V_0)和![v1](http://latex.codecogs.com/gif.latex?V_1)且权值最小的边。现在给T添加一条横跨![v0](http://latex.codecogs.com/gif.latex?V_0)和![v1](http://latex.codecogs.com/gif.latex?V_1)且权值最小的边e,得到图![T'](http://latex.codecogs.com/gif.latex?T')(一定存在这样一条边,否则T就不是连通的)。因为T是一棵树,添加了这条新的边之后,会产生一个包含e的环(因为e连接的两个节点之间肯定是有路径的)。这个环中包含的另一条横跨![v0](http://latex.codecogs.com/gif.latex?V_0)和![v1](http://latex.codecogs.com/gif.latex?V_1)边![e'](http://latex.codecogs.com/gif.latex?e'),假设e的权值比![e'](http://latex.codecogs.com/gif.latex?e')小,把![e'](http://latex.codecogs.com/gif.latex?e')从![t'](http://latex.codecogs.com/gif.latex?T')中移除,得到一颗新的树,但是由于我们用![e'](http://latex.codecogs.com/gif.latex?e')替换了e,这棵新树的边权值之和小于T的边权值之和,和假设矛盾。得出T不包含从![v0](http://latex.codecogs.com/gif.latex?V_0)到![v1](http://latex.codecogs.com/gif.latex?V_1)的最小权值边的假设是错的。(证明) -利用这个定理,我们把已经选中的要作为树的边所连接的顶点放入已处理(已标记)集合![v0](http://latex.codecogs.com/gif.latex?V_0),剩下的所有顶点放入集合![v1](http://latex.codecogs.com/gif.latex?V_1)。根据上述定理,我们可以安全地将标记顶点到未标记顶点的任何最小权值边添加到树中。 +利用这个定理,我们把已经选中的要作为树的边所连接的顶点放入已处理(已标记)集合![v0](http://latex.codecogs.com/gif.latex?V_0),剩下的所有顶点放入集合![v1](http://latex.codecogs.com/gif.latex?V_1)。根据上述定理,我们可以安全地将已标记顶点到未标记顶点的任意最小权值边添加到树中。 -这就是*普里姆算法*(Prim’s algorithm)。这次我们给每个节点引入两个额外信息,dist[v](权重集)和parent[v](顶点集)。在算法的每个点上,未处理顶点(仍然在树边缘上的顶点)的dist值是它与已处理顶点之间的最小距离(权值),parent的值是达到该最小距离的已处理顶点。 +这就是*普里姆算法(Prim’s algorithm)*。这次我们给每个节点引入两个额外信息,dist[v](权重集)和parent[v](顶点集)。在算法的每个点上,未处理顶点(仍然在队列中)的dist值是它与已处理顶点之间的最小距离(权重),parent的值是达到该最小距离的已处理顶点。 ```java -/** For all vertices v in G, set PARENT[v] to be the parent of v in -* a MST of G. For each v in G, DIST[v] may be altered arbitrarily. -* Assumes that G is connected. WEIGHT[e] is the weight of edge e. */ +/** 对于图G中的所有顶点, PARENT[v]是顶点v在MST中的父节点。 +* DIST[v]可能会被任意设初始值。 +* 假设图G是连通的。 WEIGHT[e]是边e的权值。 */ static void MST(Graph G, int[] weight, int[] parent, int[] dist) { -for (int v = 0; v < G.numVertices(); v += 1) { dist[v] = ∞; - parent[v] = -1; - } -Let r be an arbitrary vertex in G; dist[r] = 0; -Graph-traversal schema replacement for MST; } - + for (int v = 0; v < G.numVertices(); v += 1) { + dist[v] = ∞; + parent[v] = -1; + } + Let r be an arbitrary vertex in G; + dist[r] = 0; + 图遍历的模板替换成MST; +} ``` 图遍历模板的适当“设置”如下。 -COLLECTION OF VERTICES 是把顶点的dist值从小到大排列的优先队列。 +COLLECTION OF VERTICES 是把顶点按照dist值排列的优先队列,值小的优先级高。 INITIAL COLLECTION 包含图G的所有顶点。 @@ -333,122 +340,120 @@ NEEDS PROCESSING(v) 永远返回false。 #### 12.3.6 单源最短路径 -假设有一加权图(有向图或无向图),我们想找到从某个节点到它的每个可达节点的最短路径。最简洁的算法叫做*最短路径树*。这是一个生成树(不一定是最小生成树),它以所需节点为根,从根到树中任意其它节点的路径也是整个图中总权重最小的路径。 +假设有一加权图(有向图或无向图),我们想找到从某个节点到它的每个可达节点的最短路径。最简洁的算法叫做*最短路径树(shortest-path tree)*。这是一颗生成树(不一定是最小生成树),它以我们选择的起始节点为根,从根到树中任意其它节点的路径也是整个图中总权重最小的路径。 最常见的是迪杰斯特拉算法,它看起来和Prim的MST算法基本一样。PARENT和DIST数据和之前一样。但是在Prim的算法中,DIST代表从未标记顶点到已标记顶点的最短距离,而在迪杰斯特拉算法中,DIST代表从起始节点开始到顶点所有已知的最短路径的长度。 ```java -/** For all vertices v in G reachable from START, set PARENT[v] -* to be the parent of v in a shortest-path tree from START in G. For -* all vertices in this tree, DIST[v] is set to the distance from START * WEIGHT[e] are non-negative edge weights. Assumes that vertex -* STARTisinG.*/ +/** 对于起始点所有可达的顶点V, PARENT[v]表示以start为根的最短路径树中* v的父节点。 +* 对于树中的所有顶点, DIST[v]表示从起始点到顶点的距离 +* WEIGHT[e]是边的非负权值。假设start在图G中。*/ static void shortestPaths(Graph G, int start, int[] weight, int[] parent, double[] dist) { -for (int v = 0; v < G.numVertices(); v += 1) { -dist[v] = ∞; - parent[v] = -1; - } -dist[start] = 0; -Graph-traversal schema replacement for shortest-path tree; } - + for (int v = 0; v < G.numVertices(); v += 1) { + dist[v] = ∞; + parent[v] = -1; + } + dist[start] = 0; + 图遍历的模板替换成最短路径树算法; +} ``` 我们把算法模板做如下替换: -COLLECTION OF VERTICES 是把顶点的dist值从小到大排列的优先队列。 +COLLECTION OF VERTICES 是把顶点按照dist值排序的优先队列,值小的优先级高。 INITIAL COLLECTION 包含图G的所有顶点。 -![12.9](https://github.com/yuanrw/cs61b-textbook-zh/blob/ch12/zh/img/12.9.png) - -图12.9:最小生成树的Prim算法。顶点r是A。节点中的数字是dist值。虚线表示parent值;它们在最后形成一个最小生成树。白色的节点位于边缘。最后两个步骤(不会改变parent指针)合并为一个步骤。 - REMOVE HIGHEST PRIORITY ITEM 移除优先队列中的第一项。 MARKED 和 MARK可以是不重要的操作(返回false,什么都不做)。 -VISIT(v):每条边(v, w)和重量n,如果dist [w] > n + dist [v],集dist [w] n + dist [v]和设置父[w]诉重排边缘。 +VISIT(v):每条边(v, w)和重量n,如果dist [w] > n + dist [v],把dist [w] 设置为n + dist [v],并把parent[w]设置为v。根据需要重排队列。 NEEDS PROCESSING(v) 永远返回false。 +![12.9](https://github.com/yuanrw/cs61b-textbook-zh/blob/ch12/zh/img/12.9.png) + +图12.9:最小生成树的Prim算法。顶点r是A。节点中的数字是dist值。虚线表示parent值;虚线最后会形成一个最小生成树。白色的节点位于队列中。最后两个步骤(不会改变父指针)合并为一个步骤。 + 图12.10演示了迪杰斯特拉算法的实际应用。 -由于迪杰斯特拉算法和Prim算法的结构很相似,它们的时间复杂度也是相似的。每个节点只会访问一次(从优先队列中删除一个节点),每访问一条边最多只会重排一次优先队列。因此,如果V是图G的顶点数,E是边数,那算法运行时间的上界就是![expr10](http://latex.codecogs.com/gif.latex?O((V+E)%20\cdot%20lgv))。 +![12.10](https://github.com/yuanrw/cs61b-textbook-zh/blob/ch12/zh/img/12.10.png) -#### 12.3.7 A星算法 +图12.10:Dijkstra的最短路径算法。起始节点为A。节点中的编号表示到目前找到的该节点到A的最小距离(dist)。虚线箭头表示父指针;它们的最终值是形成一颗最短路径树。最后三步已合并为了一步。 -Dijkstra算法可以有效地从图中的一个起点找到所有最短路径。但是,如果只想得到从一个起点到一个终点的一条最短路径。我们也可以修改访问迪杰斯特拉算法的步: +由于迪杰斯特拉算法和Prim算法的结构很相似,它们的时间复杂度也是相似的。每个节点只会被访问一次(从优先队列中删除一个节点),每访问一条边最多只会重排一次优先队列。因此,如果V是图G的顶点数,E是边数,那算法运行时间的上界就是![expr10](http://latex.codecogs.com/gif.latex?O((V+E)%20\cdot%20lgv))。 -VISIT(v):[单个终点]如果v是终点则退出方法。否则,对于v的每条边(v, w),权值为n,如果dist[w] > n + dist[v],则将dist[w]设为n + dist[v],并将parent[w]设为v。根据需要重新排列。 +#### 12.3.7 A星算法 -这样就避免了计算距离起点更远的终点,但是Dijkstra算法还是可能做大量不必要的操作。 +Dijkstra算法可以有效地从图中的一个起点找到*所有*最短路径。但是,如果只想得到从一个起点到一个终点的一条最短路径。我们也可以修改访问迪杰斯特拉算法的步骤来实现: -例如,假设你想找到一条从丹佛到纽约市的最短道路。虽然可以保证当从优先队列中选择纽约市时,停止算法。但是在算法考虑曼哈顿的一条街道之前,它已经找到了从丹佛到西海岸(除了阿拉斯加)、墨西哥和加拿大西部各省的几乎所有目的地的最短路径——这些方向都是错的! +VISIT(v):[单个终点]如果v是终点则退出方法。否则,对于v的每条权值为n的边(v, w),如果dist[w] > n + dist[v],则将dist[w]设为n + dist[v],并将parent[w]设为v。根据需要重排队列。 -我们可以通过改变节点的顺序来修改算法——一个偏向于我们预期目标的节点。通过必要的调整后得到的算法称为A星算法: +这样就避免了去计算距离起点更远的终点,但是Dijkstra算法还是可能做大量不必要的操作。 -![12.10](https://github.com/yuanrw/cs61b-textbook-zh/blob/ch12/zh/img/12.10.png) +例如,假设你想找到一条从丹佛到纽约市的最短道路。虽然可以保证当从优先队列中选择纽约市时,停止算法。但是在算法考虑曼哈顿的某条街道之前,它已经找到了从丹佛到西海岸(除了阿拉斯加)、墨西哥和加拿大西部各省的几乎所有目的地的最短路径——然而这些方向都是错的! -图12.10:Dijkstra的最短路径算法。起始节点为A。节点中的编号表示到目前找到的该节点到A的最小距离(dist)。虚线箭头表示父指针;它们的最终值是形成一颗最短路径树。最后三步已合并为了一步。 +我们可以通过改变挑选节点的顺序来修改算法——选择距离预期终点更近的节点。通过必要的调整后得到的算法称为A星算法: ```java -/** For all vertices v in G along a shortest path from START to END, * set PARENT[v] to be the predecessor of v in the path, and set -* DIST[v] is set to the distance from START. WEIGHT[e] are -* non-negative edge weights. H[v] is a consistent heuristic -* estimate of the distance from v to END. Assumes that vertex START -* is in G, and that END is in G and reachable from START. */ -static void shortestPath(Graph G, int start, int end, int[] weight, int[] h, -int[] parent, double dist[]) +/** 对于从start到end的最短路径上的所有节点v,PARENT[v]代表v在这条路径 * 上的前驱节点,DIST[v]代表从start到该节点的距离。 WEIGHT[e]代表边的非* 负权值。 H[v]是v到end的启发式估计距离。假设顶点start和end都在图中,且* end对于start来说是可达的。*/ +static void shortestPath(Graph G, int start, int end, int[] weight, int[] h,int[] parent, double dist[]) { -for (int v = 0; v < G.numVertices(); v += 1) { -dist[v] = ∞; - parent[v] = -1; - } -dist[start] = 0; -Graph-traversal schema replacement for A* search; } + for (int v = 0; v < G.numVertices(); v += 1) { + dist[v] = ∞; + parent[v] = -1; + } + dist[start] = 0; + Graph-traversal schema replacement for A* search; +} ``` A星搜索算法的模板和Dijkstra算法相同,只是把步骤VISTT替换为上面的单个终点的版本。 -COLLECTION OF VERTICES [A* search]是把顶点按dist(v)+h[v]值排序的优先队列。 +COLLECTION OF VERTICES [A* search]是把顶点按照dist(v)+h[v]值排序的优先队列,值小的优先级高。 -换句话说,不同之处在于,我们需要假设通往终点的最短路径经过当前节点,然后估算出最短路径的大小。Dijkstra算法本质上是相同的,只不过h[v] = 0。 +换句话说,不同之处在于,我们要先假设通往终点的最短路径经过当前节点,然后估算出最短路径的大小。和Dijkstra算法本质上是相同的,只不过Dijkstra算法中h[v] = 0。 想要得到最优且正确的行为,我们需要h的做一些限制,即启发式距离估计。如注释所示,h是不变的。所以它必须是可容许的:h[v]不能比从v到终点的实际最短路径长度还要大。其次,如果(v, w)是一条边,那么 ![expr11](http://latex.codecogs.com/gif.latex?h[v]%20\leq%20weight[(v,w)]+h[w])) -这是我们熟悉的三角形不等式的一个版本:三角形任意一条边的长度必须小于或等于另外两条边的长度之和。满足这些条件的时候,A星算法是最优的,因为没有其它使用相同的启发式信息(h)的算法可以访问更少的节点(如果有多个权重相同的路径则需要其他限制)。 +这是我们熟悉的三角形不等式的一个版本:三角形任意一条边的长度必须小于等于另外两条边的长度之和。满足这些条件的时候,A星算法是最优的,因为其它使用相同的启发式信息(h)的算法访问的节点不会更少(如果有多个权重相同的路径则需要其他限制)。 现在重新考虑从丹佛出发的路线规划,我们可以使用到纽约直线距离作为我们的启发式信息,因为这个距离满足三角不等式,且比两点之间的任意组合都要小。然而在实际的运用中,普遍的做法是对数据进行大量的预处理,这样查询的时候就不需要进行完整的搜索,从而加快操作速度。 #### 12.3.8 克鲁斯克尔算法 -图遍历模板并不是唯一可行的方法,我们将考虑一种“经典”方法来形成最小生成树,称为*克鲁斯克尔算法*。该算法依赖于*并查集*。任何时候,这个结构都包含一个顶点分区:一个包含所有顶点的不相交顶点集的集合。最开始,每个集合只有单独的一个节点。做法是每次增加一条边,来构成最小生成树。我们选择一条权重最小的跨越两个不同顶点集的边,把它增加到最小生成树中,然后把这两个顶点集合并成一个集合,重复此操作,直到所有顶点集合成一个(集合中必须包含所有的顶点)。每个集合都是一组顶点,这些顶点被我们添加到最小生成树中的边连接起来,每两个顶点都是可达的。当只有一个集合时,就说明所有的顶点都是可达的,那我们就得到了能够连接整棵树的边。根据§12.3.5中的定理,如果我们每次添加的边都是连接两个不相交顶点集的最小加权边,那么该边总是最小生成树的一部分,因此最终得到的肯定是一颗最小生成树。图12.11展示了这个算法。 +图遍历模板并不是唯一可行的方法,我们将考虑一种“经典”方法来形成最小生成树,称为*克鲁斯克尔算法(Kruskal’s algorithm)*。该算法依赖于一种叫*并查集(union-find)*的数据结构。任何时候,这个结构都包含一个顶点*分区(partition)*:一个包含所有顶点的不相交顶点集的集合。最开始,每个集合只有单独的一个节点。做法是每次增加一条边,来构成最小生成树。我们选择一条权重最小的跨越两个不同顶点集的边,把它增加到最小生成树中,然后把这两个顶点集合并成一个集合,重复此操作,直到所有顶点集合成一个(集合中必须包含所有的顶点)。每个集合都是一组顶点,这些顶点被我们添加到最小生成树中的边所连接起来,任意两个顶点都是可达的。当只有一个集合时,就说明所有的顶点都是可达的,那我们就得到了能够连接整棵树的边。根据§12.3.5中的定理,如果我们每次添加的边都是连接两个不相交顶点集的最小加权边,那么该边一定是最小生成树的一部分,因此最终得到的肯定是一颗最小生成树。图12.11展示了这个算法。 -对于这个程序,假设我们有一种数据结构—并查集(UnionFind),用于存储顶点集。我们需要它进行两个操作:S.sameSet(v, w)返回顶点v和w是否在S中的同一个顶点集中,以及S.union(v, w),它将包含顶点v和w的集合合并成一个集合。还有一个边集用来存储最终结果。 +对于这个程序,假设我们使用并查集来存储顶点集。我们需要它进行两个操作:S.sameSet(v, w)返回顶点v和w是否在S中的同一个顶点集中,以及S.union(v, w),它将包含顶点v和w的集合合并成一个集合。还有一个边集用来存储最终结果。 -![12.11](/Users/yrw/Library/Application Support/typora-user-images/image-20190630122605425.png) +![12.11](https://github.com/yuanrw/cs61b-textbook-zh/blob/ch12/zh/img/12.11.png) -图12.11:克鲁斯克尔算法。顶点中的数字表示集合:标记相同数字的顶点位于同一集合中。虚线表示边已经被添加到最小生成树中。这和图12.9中的最小生成树不同。 +图12.11:克鲁斯克尔算法。顶点中的数字表示集合id:标记相同数字的顶点位于同一集合中。虚线表示边已经被添加到最小生成树中。这和图12.9中的最小生成树不同。 ```java -/** Return a subset of edges of G forming a minimum spanning tree for G. * G must be a connected undirected graph. WEIGHT gives edge weights. */ -EdgeSet MST(Graph G, int[] weight) { - UnionFind S; - EdgeSet E; -// Initialize S to {{v} | v is a vertex of G}; S = new UnionFind(G.numVertices()); -E = new EdgeSet(); -For each edge (v,w) in G in order of increasing weight { if (! S.sameSet(v, w)) { - Add (v,w) to E; - S.union(v, w); - } +/** 返回图G的最小生成树。G必须是一个连通无向图。weight表示边的权重 */ +EdgeSet MST(Graph G, int[] weight) +{ + UnionFind S; + EdgeSet E; + // 将S初始化为{{v} | v是图G的顶点}; + S = new UnionFind(G.numVertices()); + E = new EdgeSet(); + For each edge (v,w) in G in order of increasing weight { + if (! S.sameSet(v, w)) { + Add (v,w) to E; + S.union(v, w); + } + } + return E; } -return E; } - ``` -union-find位是比较复杂的部分。你可能认为每个sameSet操作的时间复杂度最坏情况下是Θ(nlgn)(在每个到N设置每个大小到N)。有一个更好的方法。假设(在这个问题中)集合包含从0到N - 1的整数。任何时候,最多有N个不相交的集合;我们从每个集合中选出一个数字,用该数字(一个数字0到N−1)作为集合id。对于某个数字代表的非空集合,可以通过顶点的集合id是否相同来判断两个顶点是否在同一个集合中。一种实现方式是将每个不相交集表示为一课顶点树,子节点指向父节点(您可能还记得我说过这样的数据结构会用用武之地)。每棵树的根都是集合id,可以通过父指针找到。例如,以下集合 +union-find位是比较复杂的部分。你可能认为每个sameSet操作的时间复杂度最坏情况下是Θ(nlgn)。有趣的是有一个更好的方法。假设(在这个问题中)集合包含从0到N - 1的整数。任何时候,最多有N个不相交的集合;我们从每个集合中选出一个数字,用该数字(一个数字0到N−1)作为集合id。对于某个数字代表的非空集合,可以通过顶点的集合id是否相同来判断两个顶点是否在同一个集合中。一种实现方式是将每个不相交集表示为一课顶点树,子节点指向父节点(您可能还记得我说过这样的数据结构会有用武之地)。每棵树的根都是集合id,可以通过父指针找到。例如,以下集合 {{1,2,4,6,7},{0,3,5},{8,9,10}} @@ -456,7 +461,7 @@ union-find位是比较复杂的部分。你可能认为每个sameSet操作的时 ![12.insert1](https://github.com/yuanrw/cs61b-textbook-zh/blob/ch12/zh/img/12.insert1.png) -我们用一个整数数组 parent 来存储,其中parent[v]的值为v的父节点id,如果v没有父节点(即v是集合id),parent[v]=-1。union操作很简单:执行S.union(v,w),找到包含v和w的树的根(通过父指针向上找),然后把其中一个根变成另一个根的孩子。例如,执行S.union(6,0),先找到6的集合id(1)和0的集合id(3),然后把3的父指针指向1: +我们用一个整数数组 parent 来存储,其中parent[v]的值为v的父节点id,如果v没有父节点(即v的id就是集合id),那么parent[v]=-1。union操作很简单:执行S.union(v,w),找到包含v和w的树的根(通过父指针向上找),然后把其中一个根变成另一个根的孩子。例如,执行S.union(6,0),先找到6的集合id(1)和0的集合id(3),然后把3的父指针指向1: ![12.insert2](https://github.com/yuanrw/cs61b-textbook-zh/blob/ch12/zh/img/12.insert2.png) @@ -466,17 +471,17 @@ union-find位是比较复杂的部分。你可能认为每个sameSet操作的时 ![12.insert3](https://github.com/yuanrw/cs61b-textbook-zh/blob/ch12/zh/img/12.insert3.png) -这种重排称为*路径压缩*,它会使后续关于顶点6、4和0的访问比原来快很多。事实证明,这个技巧(包括把浅树指向一颗深的树形成一个集合),使包含N个元素的多个集合中的任意一个集合上的任意序列M中的union和sameSet操作时间复杂度为O(α(M, N) M)。在这里,α(M, N)是一个逆*阿克曼*的函数。具体来说,α(M, N)被定义为当i最小时使A(i,⌊M/N⌋) > lgN,且 +这种重排称为*路径压缩(path compression)*,它会使后续关于顶点6、4和0的访问比原来快很多。事实证明,这个技巧(包括把浅树指向一颗深的树形成一个集合),使包含N个元素的多个集合中的任意一个集合上的任意序列M中的union和sameSet操作时间复杂度为O(α(M, N) M)。在这里,α(M, N)是一个逆*阿克曼*的函数。具体来说,α(M, N)被定义为当i最小时A(i,⌊M/N⌋) > lgN,且 A(1,j) = ![2^j](http://latex.codecogs.com/gif.latex?2^j),当![expr12](http://latex.codecogs.com/gif.latex?j%20\geq%201), A(i,1) = A(i−1,2),当![expr13](http://latex.codecogs.com/gif.latex?i%20\geq%202), A(i,j) = A(i − 1,A(i,j − 1)),当![expr14](http://latex.codecogs.com/gif.latex?i,j%20\geq%202) -好吧,这些相当复杂,但我只想说,A增长地非常快,所以α增长很缓慢,而且无论如何都小于等于4。简而言之,M的操作(任何组合中的union和sameSets)的*平摊成本*大致为常数。因此,Kruskal算法所需的时间主要由边的排序时间决定,对于边的数量E,趋近于O(elg E)。对于连通图等于O(elgv),其中V是顶点的数量。 +好吧,这些相当复杂,但我只想说,A增长地非常快,所以α增长很缓慢,而且无论如何都小于等于4。简而言之,M的操作(任何组合中的union和sameSets)的*平摊成本(amortized cost )*大致为常数。因此,Kruskal算法所需的时间主要由边的排序时间决定,对于边的数量E,趋近于O(elg E)。对于连通图等于O(elgv),其中V是顶点的数量。 ### 练习 -**12.1。**一只鹦鹉和一只魔鬼发现自己身处迷宫中,迷宫中蜿蜒曲折的小通道连接着许多房间,其中有一个是迷宫出口。魔鬼发现鹦鹉特别美味。对于鹦鹉来说(对魔鬼来说就是它未来的猎物)不幸的是,魔鬼跑得比鹦鹉快两倍,并且魔鬼有特殊能力可以找到通往出口的最短路径。对鹦鹉来说幸运的是,他异常敏锐的感官能随时准确地感应到魔鬼的位置,而且他对迷宫的结构了如指掌。如果他能比魔鬼先到达出口或魔鬼行走的路径上的任意房间(必须之前,不能是同时),它就能抓住魔鬼。魔鬼并不聪明,它总是走最短的路径,即使是鹦鹉已经前面等着它,他也不会改变路线。例如,在下面的迷宫中,鹦鹉(起点在S点)经过6个时间单位就能到阴影房间用餐,魔鬼(起点在B点)经过7个时间单位能到。连接通道上的数字表示距离(房间内的数字只是标签)。斯纳克的速度是0.5单位/小时,波罗戈夫的速度是1单位/小时。 +**12.1。**一只鹦鹉(snark)和一只魔鬼(borogove)发现自己身处迷宫中,迷宫中蜿蜒曲折的小通道连接着许多房间,其中有一个是迷宫出口。鹦鹉发现魔鬼特别美味。对于鹦鹉来说不幸的是,魔鬼跑得比鹦鹉快两倍,并且魔鬼有特殊能力可以找到通往出口的最短路径。对鹦鹉来说幸运的是,他异常敏锐的感官能随时准确地感应到魔鬼的位置,而且他对迷宫的结构了如指掌。如果他能比魔鬼先到达出口或魔鬼行走的路径上的任意房间(必须之前,不能是同时),它就能抓住魔鬼。魔鬼并不聪明,它总是走最短的路径,即使是鹦鹉已经前面等着它,他也不会改变路线。例如,在下面的迷宫中,鹦鹉(起点在S点)经过6个时间单位就能到阴影房间用餐,魔鬼(起点在B点)经过7个时间单位能到。连接通道上的数字表示距离(房间内的数字只是标签)。鹦鹉的速度是0.5单位/小时,魔鬼的速度是1单位/小时。 ![12.exec](https://github.com/yuanrw/cs61b-textbook-zh/blob/ch12/zh/img/12.exec.png) @@ -484,7 +489,7 @@ A(i,j) = A(i − 1,A(i,j − 1)),当![expr14](http://latex.codecogs.com/gif.la 输入如下: -* 一个正整数N≥3,表示房间数。你可以认为N < 1024。房间的编号从0到N - 1。0号房间总是出口。最开始魔鬼在1号房间,鹦鹉在2号房间。 +* 一个正整数N![ge](http://latex.codecogs.com/gif.latex?\geq})3,表示房间数。你可以认为N < 1024。房间的编号是从0到N - 1的整数。0号房间总是出口。最开始魔鬼在1号房间,鹦鹉在2号房间。 * 边的序列,每条边由两个房间号(房间号的顺序任意)和一个距离整数组成。 如果同时有两条最短路径,魔鬼总是选择房间号小的那条。