纸上得来终觉浅,绝知此事要躬行。打算把图的常用算法梳理一遍,加深一下记忆和理解。

简介

前面写了一遍关于图的存储结构和遍历算法的文章,这一篇打算回顾一下图的一些常用算法,包括最小生成树、最短路径算法。这些算法很基础,在生活中经常用到,打算自己动手实现一下,加深理解~~

最小生成树

生成树的概念:r若图是连通的无向图或强连通的有向图,则从任何一个顶点出发调用一次BFS或者DFS便可访问图中所有的顶点,这种情况下,图中所有顶点加上遍历过程中经历的边构成的子图成为原图的生成树。对于不连通和不是强连通的有向图,这样得到的是生成森林。

最小生成树:对于加权图,权值最小的生成树成为最小生成树。可以用kruskal(克鲁斯卡尔)算法或prim(普里姆)算法求出,这两种算法依据的思想是贪心思想

prim(普利姆)算法

第一步:选择一个初始顶点V1,设置一个访问数组visit[]和最小距离数组distance[]。最小距离数组保存当前已访问顶点的生成树中到每个顶点最小的距离。
第二步:根据初始顶点V1,初始化visit和distance数组。
第三步:选择distance中权重最小的那个顶点,以此顶点为起始点更新distance数组,直至所有顶点都已访问。

未优化的prim算法时间复杂度为O(N^2),其C语言描述如下,:

/* 输入分别为:邻接链表、保存生成树遍历的数组、最小的权重 */
int prim( GraphAdjList * G, int tree[], int * weight )
{
	if( G == NULL ) return 0;

	int num_vertex = G->numVertex;
	int index = 0;
	int next_visit = 0;    // 下一个要选择的顶点

	int visit[num_vertex] = {0};
	int distance[num_vertex] = {MAX_INT};

	int first_node  = 0;
	EdgeNode * edge = G->adjList[first_node]->firstEdge;
	/* 初始化距离数组 */
	while( edge )
	{
		/* 当前访问节点的下标 */
		int vertex_index = edge->adjvex;
		distance[vertex_index] = edge->weight;
		edge = edge->next;
	}
	visit[first_node] = 1;
	tree[index++] = first_node;

	for( int i = 1; i < num_vertex; i++ )
	{
		int min_weight = MAX_INT;
		/* 从距离数组中找出最小距离的顶点 */
		for( int j = 0; j < num_vertex; j++ )
		{
			if( visit[j] == 0 && distance[j] < min_weight )
			{
				min_weight = distance[j];
				next_visit = j;
			}
		}
		visit[next_visit] = 1;
		tree[index++] = next_visit;
		*weight += min_weight;
		/* 新加入节点之后,更新距离数组 */
		EdgeNode * edge = G->adjList[next_visit]->firstEdge;
		while( edge )
		{
			/* 当前访问节点的下标 */
			int vertex_index = edge->adjvex;
			if( visit[vextex_index] == 0 && edge->weight < distance[vertex_index] )
			{
				distance[vertex_index] = edge->weight;
			}
			edge = edge->next;
		}
	}
	return 1;
}

kruskal(克鲁斯卡尔)算法

kruskal算法需要判断是否存在回路问题,判断是否存在回路问题方法很多,下面列出两种比较简单的方法。

如何判断存在回路

第一种方法

1、将度数小于2的顶点删去,并将与其相连的顶点度数减1。若是有向图则删除入读为0的顶点,并将与该点相连的顶点入读减1。
2、重复上述过程,若只剩下顶点,则存在回路,否则不存在。

第二种方法

1、将未遍历的节点涂成白色,已遍历的节点涂成灰色,若该节点的所有相邻边都被遍历了,则涂成黑色。 2、若遍历时,有一个节点的一条未遍历的边指向一个灰色节点,则存在回路。

实现过程

因为这个算法对稀疏图来说效率比较高,它的时间复杂度为O(ElogE)。所以这里我采用保存边集的数据结构实现kruskal算法。回路的判断方法是借鉴数据结构书上的,即对每一条边,进行编号,如果待加入的边不属于同一集合编号内,即可加入,如下图所示:

A B C D E F 初始化分别在一个边集,编号分别为 0 1 2 3 4 5

此时 A B C 节点构成一个边集,其编号都设为0.
D E 节点构成一个边集,其编号设为4

A B C F 节点构成一个边集,其编号设为0

A B C D E F 节点构成一个边集,其编号设为0

其C语言描述如下:

typedef struct Edge
{
	int begin;
	int end;
	int weight;
} Edge[EDGE_NUM];
/* 边的数目为EDGE_NUM,顶点的数目为VERTEX_NUM */
int kruskal( Edge edge[], Edge tree[], int * weight )
{
	int edge_set[NUM];  // 保存边的编号
	int index  = 0 ;

	sort( edge );  // 按权重对边集排序
	/* 初始化边集数组,把每个顶点单独放在一个编号里 */
	for( int i = 0; i < VERTEX_NUM; i++ )
	{
		edge_set[i] = i;
	}
	for( int j = 0; j < EDGE_NUM; j++ )
	{
		/* 选出第j小的边,所在的顶点 */
		int begin = edge[j].begin;
		int end = edge[j].end;
		/* 找到这两个顶点所在边集的编号 */
		int edge_num1 = edge_set[begin];
		int edge_num2 = edge_set[end];

		if( edge_num1 != edge_num2 )
		{
			/* 如果这两个顶点不在同一个边集上,则把这条边加入到生成树tree数组中 */
			tree[index++] = edge[j];
			for( int k = 0; k < NUM; k++ )
			{
				/* 将这两个顶点边集合并成一个边集,遍历所有这条边集的顶点,使其都属于同一个边集 */
				if( edge_set[k] == edge_num2 )
				{
					edge_set[k] = edge_num1;
				}
			}
		}
	}
}

最短路径

本科学过的最短路径问题,但当时仅限于概念上的理解,并没有实际动手实现过。这里我决定使用Dijkstra算法和Floyd算法,实现最短路径问题。Dijkstra算法时间复杂度要低于Floyd算法,但Dijkstra不能处理负权的情况??

Dijkstra算法

这个算法跟prim算法很相似,都是依据贪心算法的思想,不同点在于prim算法保存的距离数组,是整个生成树到每个顶点的距离,而Dijkstra算法保存起点到每个顶点的最短距离。但过程和prim算法类似,步骤如下:
第一步:选择一个起始点,初始化距离数组distance和访问数组visit数组。
第二步:从距离数组中选择距离最小的顶点,并更新distance数组。
第三步:重复第二步,直到所有的顶点都已被访问。

/* 输入分别为:邻接链表、保存遍历经过的顶点、最小的路径长度 */
int dijkstra( GraphAdjList * G, int tree[], int * length )
{
	if( G == NULL ) return 0;

	int num_vertex = G->numVertex;
	int index = 0;
	int next_visit = 0;    // 下一个要选择的顶点

	int visit[num_vertex] = {0};
	int distance[num_vertex] = {MAX_INT};

	int first_node  = 0;
	EdgeNode * edge = G->adjList[first_node]->firstEdge;
	/* 初始化距离数组 */
	while( edge )
	{
		/* 当前访问节点的下标 */
		int vertex_index = edge->adjvex;
		distance[vertex_index] = edge->weight;
		edge = edge->next;
	}
	visit[first_node] = 1;
	tree[index++] = first_node;

	for( int i = 1; i < num_vertex; i++ )
	{
		int min_weight = MAX_INT;
		/* 从距离数组中找出最小距离的顶点 */
		for( int j = 0; j < num_vertex; j++ )
		{
			if( visit[j] == 0 && distance[j] < min_weight )
			{
				min_weight = distance[j];
				next_visit = j;
			}
		}
		visit[next_visit] = 1;
		tree[index++] = next_visit;
		*weight += min_weight;
		/* 新加入节点之后,更新距离数组 */
		EdgeNode * edge = G->adjList[next_visit]->firstEdge;
		while( edge )
		{
			/* 当前访问节点的下标 */
			int vertex_index = edge->adjvex;
			/* 与prim算法的不同之处 */
			if( visit[vextex_index] == 0 && edge->weight + distance[next_visit] < distance[vertex_index] )
			{
				distance[vertex_index] = edge->weight + distance[next_visit];
			}
			edge = edge->next;
		}
	}
	return 1;
}

Floyd算法

Floyd算法是用动态规划思想求解的,它可以求出每一对顶点之间的距离,时间复杂度为O(N^3)。动态规划很重要的一步就是找到该问题的最优子结构。在最短路径中,可以看出从** A B 的最短距离,要么是 A B **直接相连的距离,要么经过一个中间节点。其最优子结构可以定义如下:

dist( i, j ) = min( weight( i, j ), weight( i, k ) + weight( k , j ) ) { i < k < j }

其算法过程描述如下:
第一步:初始化每两顶点之间的距离
第二步:对每一对顶点查看是否有一个顶点** k 使得从 i 到 j**之间的距离变得更短,如果存在就更新这个距离值。

这一题用邻接表做比较麻烦,因为需要找到一个顶点 k 既与 i 节点相连,又与 j 节点相连,那就得同时遍历 i 和 j 的邻接点。这一题采用邻接矩阵解出的,其C语言描述如下:

/* path[i][j]表示 i 到 j 的最短距离  */
int floyd( MGraph * G, int path[][VERTEX_NUM] )
{
	int num_vertex = G->numVertex;
	/* 初始化path数组 */
	for( int i = 0; i < num_vertex; i++ )
	{
		for( int j = 0; j < num_vertex; j++ )
		{
			path[i][j] = G->edge[i][j];
		}
	}

	for( int k = 0; k < num_vertex; k++ )
	{
		for( int i = 0; i < num_vertex; i++ )
		{
			for( int j = 0; j < num_vertex; j++ )
			{
				/* 遍历找到是否有一个中间节点,使 i 和 j 之间的距离更小 */
				if( ( path[i][k] + path[k][j] ) < path[i][j] )
				{
					path[i][j] = path[i][k] + path[k][j];
				}
			}
		}
	}
}

总结

理论和实践总是有一段距离的,自己动手写了一下,感觉自己多这两种算法的理解更深了。就像《程序员修炼之道》里说的那样,要做一个注重实践的程序员。在实现上述算法的过程中,借鉴了不少博主的文章,忽然间发现,有时候写文章不仅仅是为了自己,因为你写的文章有一天或许会帮助到其他人也说不定啊哈哈哈

参考

[1] 博客园 Veegin
[2] 博客园 华山大师兄

EOF


纸上得来终觉浅,绝知此事要躬行~