diff --git a/thu_dsa/chp6/Graph.md b/thu_dsa/chp6/Graph.md index 4863354..f0cc12b 100644 --- a/thu_dsa/chp6/Graph.md +++ b/thu_dsa/chp6/Graph.md @@ -1,2 +1,104 @@ Conclusion on Chapter Six: Graph -================================ \ No newline at end of file +================================ + +## 图的一些基本概念与术语 + +> 什么是图?为什么要有图这种数据结构? + +图是一种更一般的数据结构,根据马克思辩证唯物主义中[万物的普遍联系与永恒发展]的观点,图正是表示了一个集合中所有元素之间相互联系关系的一种数据结构,因此更具有普遍性,理论上可以描述宇宙中的所有关联关系。 + +从结构上来看,图相对于前面介绍过的`Vector`,或者`Tree`,也具有更一般的结构性质。`Vector`是一个线性结构,在`Vector`中,只能描述一个元素与它的前驱/后继之间的关联关系;`Tree`是一个半线性结构,可以描述一个元素与它的父结点以及若干子结点之间的关系,相对于`Vector`更加普遍,而`Tree`的退化形式即成为了一个`List`,从而证明了这里的观点。图是一个非线性结构,可以表示一个结点与集合中所有元素的关联关系,采用类比的方法,我们可以归纳出研究图的一般方法--将图转化为我们熟悉的树,从而借用树的分析技巧来解决图的问题。 + +> 图的分类。 + +图的分类比较复杂,这里只讨论几个容易混淆的概念。 + +简单的概念,一般是表示不含有环路。 + ++ 简单图:不含有自环的图。 ++ 简单通路:沿途顶点互异的通路。 ++ 简单环路:除起点和终点外沿途所有顶点均互异的环路。 + +欧拉环路:经过图中各边一次且恰好一次的环路。比如说一笔画问题。 +哈密尔顿环路:经过图中各顶点一次且恰好一次的环路。 + +## 图的表示 + +由于图是表示一个集合中所有元素相互之间的关联关系的数据结构,因此表达这里的关联关系是表示图的重点。元素之间的关联关系我们又称之为邻接关系,对应有邻接矩阵,邻接表两种表示方法。除此以外,图中还存在顶点和边之间的关系,这种关系我们称之为关联关系,对应有关联矩阵表示法。 + +> 邻接矩阵表示法。 + +邻接矩阵是表示图中的邻接关系。简单说来,就是引入一个$n\times n$的矩阵,矩阵中的每个元素代表相应的两个顶点之间是否有邻接关系,或者这种邻接关系的强度。 + +> 邻接表表示法。 + +上面的邻接矩阵表示法其实具有比较大的冗余,因为它需要$O(n^2)$的空间,而实际中的图边往往渐进的不超过$O(n)$。 + +邻接表其实基本思想和邻接矩阵完全相同,都是表示图的这种邻接关系,但是它是采用一个列表来表示与某个顶点相关联的其他顶点,这样需要的空间总量为$O(n+e)$,与图本身的规模相当,这已经是相当好的结果了。 + +与邻接矩阵相比,邻接表具有更好的动态性能,而邻接矩阵的静态性能则更优。例如邻接矩阵可以在$O(1)$时间内访问任意两个顶点的邻接关系,或者判断它们是否存在邻接关系;而对于邻接表而言,则需要遍历某一个顶点的邻接表,在最坏情况下需要$O(n)$的时间。但是邻接表在诸如插入和删除这种动态性能上更优,这都是由于`List`以及`Vector`的特性决定的。 + +但是与邻接矩阵相比,邻接表还有一个巨大的优势,即可以快速地访问到一个顶点的所有邻居,分摊时间只需要$O(1)$;而对于邻接矩阵,则不得不完全遍历邻接矩阵的某一行或者某一列。这个优势使得依靠于遍历一个顶点的所有邻居的算法,如dfs和bfs,在采用邻接表表示的图上,具有更优的时间复杂度。 + +## 图的遍历 + +上面已经说过了,为了研究更一般的图结构,一个比较好的策略就是将它转化成我们熟悉树结构,从而可以应用树的策略与技巧。为了将图转化成树,就需要这里的图的遍历算法。 + +由于要将更一般的图,转化成相对特殊的树结构,整个过程必然会有信息的丢失。为了尽可能地保存图中的原有信息,除了组成遍历树(traversal tree)的边以外,还需要提供关于原图的其他信息,例如环路,前向边,后向边等。 + +> 广度优先搜索策略(bfs, Breadth First Search)。 + +对于当前的顶点,广度优先搜索,就是优先去访问它的邻居,等到它的邻居被访问完以后,再去访问邻居的邻居。它与树结构中的层次遍历相对应,因此这里bfs也需要引入一个队列,来存储当前顶点的全部邻居。 + +这里主要讨论一下遍历中可能会出现的几种情况。对于当前的顶点x,如果访问到它的邻居w时,w还是未被发现的,这意味着遍历树可以拓展一条边,因此将这两个顶点之间的边标记为'Tree';但如果w已经被发现(访问)了,这种情况对应了 + ++ w是x的父结点。例如一开始就从w进入x,然后x在遍历它的邻居时又发现了w。这种情况下x与w之间的边是一条回边(Backward),表示指向祖先顶点。要注意的是,这里的w只可能是x的父结点,而不可能是x更高的祖先结点。这是因为如果w是x比父结点更高的顶点的话,x早在访问w时就应该被发现了,从而w只能是x的父结点。 ++ w是x祖先结点的邻居,经由x的祖先结点就已经访问到了w。这种情形对应了跨边(Cross),表示x与w没有直接的血缘关系,来自相互独立的两个分支。 + +对于上面两种情况,由于指向父结点的回边感觉不是很有意思,我们在bfs中不加以区分,全部统一标记为跨边。因此bfs的算法可以表述如下: + +```cpp +template +void Graph::BFS(int x, int& clock){ + Queue Q; + dtime(x) = ++clock; + status(x) = DISCOVERED; + Q.enqueue(x); + while(!Q.empty()){ + x = Q.dequeue(); + for(int w = firstNeighbor(x); w != -1; w = nextNeighbor(x, w)) + if(status(w) == UNDISCOVERED){ + status(w) = DISCOVERED; + dtime(w) = ++clock; + parent(w) = x; + type(x, w) = TREE; + Q.enqueue(w); + } + else type(x, w) = CROSS; + } + status(x) = VISITED; +} +``` + +可以看到,为了保存图原有的信息,算法中做了大量的标记工作。 + +一次BFS可以完全遍历以当前顶点x为起始的连通分量,但是如果图不连通的话,则需要做多次BFS,才能完成对全图的遍历。这时BFS遍历得到的不再是一棵遍历数,而是一个遍历森林。 + +```cpp +template +void Graph::bfs(int start){ + reset(); + int clock = 0; + int curr = start; + do{ + if(status(curr) == UNDISCOVERED) BFS(curr, clock); + }while((curr = ++curr % num_of_vertices) != start); +} +``` + +> bfs算法复杂度的分析。 + +当采用邻接矩阵表示时,BFS中为了找到当前顶点的全部邻居,需要完全遍历邻接矩阵的一行,需要$O(n)$的时间。 + +> bfs的应用。 +