Files
912-notes/thu_dsa/chp6/Graph.md
2019-06-12 16:27:53 +08:00

12 KiB
Raw Blame History

Conclusion on Chapter Six: Graph

图的一些基本概念与术语

什么是图?为什么要有图这种数据结构?

图是一种更一般的数据结构,根据马克思辩证唯物主义中[万物的普遍联系与永恒发展]的观点,图正是表示了一个集合中所有元素之间相互联系关系的一种数据结构,因此更具有普遍性,理论上可以描述宇宙中的所有关联关系。

从结构上来看,图相对于前面介绍过的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的算法可以表述如下

template <typename Tv, typename Te>
void Graph<Tv, Te>::BFS(int x, int& clock){
	Queue<int> 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遍历得到的不再是一棵遍历数而是一个遍历森林。

template <typename Tv, typename Te>
void Graph<Tv, Te>::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)$的时间,所有顶点都会入队一次,出队一次,故上面$O(n)$的遍历操作一共需要进行n次故访问所有顶点需要$O(n^2)$的时间复杂度。此外,图中的每条边都需要进行一次标记,所以一共需要$O(n^2 + e) = O(n^2)$的时间。

而当采用邻接表表示图时,对于当前顶点访问到它的邻居只需要$O(1)$的时间,所有顶点都会被访问一次,所有边也会被访问一次,所以需要$O(n + e)$的时间,这个结果和图的规模相当,已经是期望的最优结果了。

但是,如果考虑到计算机体系结构中的缓存,当采用邻接矩阵表示时,邻接矩阵的结构很容易触发缓存机制,从而使邻接矩阵中一行数据全部存放到缓存中。由于缓存的速度往往是内存的百十万倍,这样,尽管从渐进的分析,仍然是$O(n^2)$的时间复杂度,但是前面的常数项已经非常小,可以忽略到为了找到全部邻居而需要的时间,从而仍然可以达到$O(n + e)$的时间复杂度。

bfs的应用。

由上面所述bfs显然可以用来进行连通性的检测。凡是连通的顶点都可以在一次BFS中被发现因此对一个图进行bfs调用BFS的次数即是图的连通分量的个数。

此外由bfs访问顶点的次序可以看出bfs总是优先访问离当前顶点最近的顶点这使得bfs可以用以发现图中的最短路径。但是这里的最短路径只能是拓扑结构的最短路径或者说无权图或者等权图的最短路径。

然后,邓公说还可以用来做连通分量的分解,无向图的环路检测,以后再探索一下。

深度优先搜索(dfs, Depth First Search)策略。

dfs其实等价于树的先序遍历。简单说来就是尽可能深地去遍历下一个顶点也就是所谓的深度优先遍历。

等价于bfsdfs也会在遍历结束后生成一棵支撑树(Spaning Tree)。除此以外dfs还需要保存原图的一些其他信息也就是对应遍历结束后除了树边的其他类型的边这里包括前向边(Forward),回边(Backward),以及跨边(Cross),详细说明如下:

  • 树边:访问到状态仍然是UNDISCOVERED的邻居它们之间的边构成支撑树的一条边。随后递归地进入该邻居继续dfs。
  • 回边:访问到状态是DISCOVERED的邻居。这意味着该邻居是当前顶点的一个祖先。该边指向一个祖先顶点,故称之为回边。不难看出,出现回边意味着图中存在环路。
  • 前向边:与回边相对应,表示指向后继结点的边。当访问到的邻居状态为VISITED,并且访问时间晚于当前顶点时,就会出现前向边。
  • 跨边类似于bfs中出现过的跨边表示当前顶点与被访问到的邻居没有直接血缘关系。当访问到的邻居状态为VISITED,并且访问时间早于当前顶点时,就会出现跨边。

需要注意的是,这里的边的信息仅限于有向图。这是因为,在无向图中,区分回边和前向边没有意义,因为有一条回边就会有一条后向边,两者完全等效。此外,无向图中不存在跨边(我说不清楚,自己思考一下吧)。

除了边的信息以外,根据上面的讨论,还需要记录各个顶点被发现以及被访问的时间,分别记为dtimeftime。至此DFS的代码可以表述如下

template <typename Tv, typename Te>
void Graph<Tv, Te>::DFS(int x, int& clock){
	dtime(x)  = ++clock;
	status(x) = DISCOVERED;
	for(int w = firstNeighbor(x); w != -1; w = nextNeighbor(x, w)){
		switch(status(w)){
			case UNDISCOVERD:
				parent(w) = x;
				type(x, w)= TREE;
				DFS(w, clock);
				break;

			case DISCOVERED:
				type(x, w) = BACKWARD;
				break;

			case VISITED:
			default:
				type(x, w) = dtime(x) < dtime(w)? FORWARD: CROSS;
				break;
		}
	}
	ftime(x)  = ++clock;
	status(x) = VISITED;
}

与bfs类似当图不连通时需要多次调用DFS才能完成全图的深度优先遍历。同样这时得到的是一个DFS遍历森林。

DFS的时间复杂度也与BFS一致。

dfs的括号引理。

根据各个顶点的发现时间dtime和访问时间ftime,可以对顶点进行分类,获得顶点之间的祖先与后代关系信息。

active(u) = [dtime(x), ftime(x)]为顶点x在有向图G中的活跃期括号引理即

  • 若$active(u) \subseteq active(v)$则u是v的后代。
  • 若$active(u) \supseteq active(v)$则u是v的祖先。
  • 若$active(u) \cap active(v) = \phi$则u和v无关。

dfs的应用。

dfs是图遍历算法中最重要的一个。大量与图相关的算法都是由dfs导出的比如连通分量分解拓扑排序等。此外dfs还可以用来做带权图的最短路径算法框架。