【读书笔记】《王道论坛计算机考研机试指南》第五章

第五章

并查集

本节讨论在图论问题中常常要使用到的一种数据结构一一集合,及其相关操作一一并查集。
我们先来看如下的数字集合:
集合A{1,2,3,4},集合B{5,6,7},集合C{8,0}
我们利用如下树结构来表示这些集合:
在这里插入图片描述
如图所示,我们用一棵树上的结点来表示在一个集合中的数字,要判断两个数字是否在一个集合中,我们只需判断它们是否在同一棵树中。那么我们使用双亲结点表示法来表示一棵树,即每个结点保存其双亲结点。若用数组来表示如上树,则得到如下结果:
在这里插入图片描述
即我们在数组单元i中保存结点i的双亲结点编号,若该结点已经是根结点
则其双亲结点信息保存为-1。有了这样的存储结构,我们就能通过不断地求双亲结点来找到该结点所在树的根结点,若两个元素所在树的根结点相同,则可以判定它们在同一棵树上,它们同属一个集合。
对于合并两个集合的要求,我们该如何操作呢?我们只需要让分别代表两个集合的两棵树合并,合并方法为其中一棵树变为另一棵树根结点的子树,如下图所示:
在这里插入图片描述
如图,若我们对2所在的集合与0所在的集合合并,则先找到表示2所在集合的树的根结点1和表示0所在集合的树的根结点4,并使其中之一(图中为4)为另一个根结点的儿子结点,这样其中一棵树变为另一棵树根结点的一棵新子树,完成合并。在双亲结点表示法中,该合并过程为:
在这里插入图片描述
在树的双亲结点表示法中,两树的合并即表示为其中一棵树的根节的双亲结点变为另一棵树的根结点。
但是,采用这种策略而不加以任何约束也可能造成某些致命的问题。如前文所述,我们对集合的操作主要通过查找树的根结点来实现,那么并查集中最主要的操作即查找某个结点所在树的根结点,我们的方法是通过不断查找结点的双亲结点直到找到双亲结点不存在的结点为止,该结点即为根结点。那么,这个过程所需耗费的时间和该结点与树根的距离有关,即和树高有关。在我们合并两树的过程中,若只简单的将两树合并而不采取任何措施,那么树高可能会逐渐增加,查找根结点的耗时逐渐增大,极端情况下该树可能会退化成一个单链表。那么在其上进行查找根结点的操作将会变得非常得耗时,如下两图所示,树不同的形态对查找的效率将会有巨大的影响:
在这里插入图片描述
为了避免因为树的退化而产生额外的时间消耗,我们在合并两棵树时就不能任由其发展而应该加入一定的约束和优化,使其尽可能的保持较低的树高。为了达到这一目的,我们可以在查找某个特定结点的根结点时,同时将其与根结点之间所有的结点都直接指向根结点,这个过程被称为路径压缩,如下图所示:
在这里插入图片描述
如图所示,在完成路径压缩的工作后,树的形态发生巨大改变,树高大大降低,而该树所表示的集合信息却没有发生任何改变,所以其在保证集合信息不变的情况下大大优化了树结构,为后续的查找工作节约了大量的时间。
首先,我们定义一个数组,用双亲表示法来表示各棵树(所有的集合元素个数总和为N):

int Tree[N];

用Tree[i]来表示结点i的双亲结点,若Tree[i]为-1则表示该结点不存在双亲结点,即结点i为其所在树的根结点。那么,为了查找结点x所在树的根结点,我们定义以下函数:
递归形式:

int findRoot(int x) {
    
    
	if (Tree[x] == -1) return x; //若当前结点为根结点则返回该结点号
	else return findRoot(Tree[x]); //否则递归查找其双亲结点的根结点
}

非递归形式:

int findRoot(int x) {
    
    
	int ret;
	while (Tree[x]!=-1)
		x = Tree[x]; //若当前结点为非根结点则一直查找其双亲结点
	ret = x;//返回根结点编号
	return ret;
}

另外若需要在查找过程中添加路径压缩的优化,我们修改以上两个函数为:

int findRoot(int x) {
    
    
	if (Tree[x] == -1)
		return x;
	else {
    
    
		int tmp = findRoot(Tree[x]);
		Tree[x] = tmp; //将当前结点的双亲结点设置为查找返回的根结点编号
		return tmp;
	}
}
int findRoot(int x) {
    
    
	int ret;
	int tmp = x;
	while (Tree[x] != -1)
		x = Tree[x];
	ret = x;
	x = tmp; //再做一次从结点X到根结点的遍历
	while(Tree[x]!=-1) {
    
    
		int t = Tree[x];
		Tree[x] = ret;
		x = t; //遍历过程中将这些结点的双亲结点都设置为已经查找得到的根结点编号
	}
	return ret;
}

Problem 1
在这里插入图片描述
在这里插入图片描述
题面中描述的是一个实际的问题,但该问题可以被抽象成在一个图上查找连通分量(彼此连通的结点集合)的个数,我们只需求得连通分量的个数,就能得到答案(新建一些边将这些连通分量连通)。这个问题可以使用并查集完成,初始时,每个结点都是孤立的连通分量,当读入已经建成的边后,我们将边的两个顶点所在集合合并,表示这两个集合中的所有结点已经连通。对所有的边重复该操作,最后计算所有的结点被保存在几个集合中,即存在多少棵树就能得知共有多少个连通分量(集合)。

#include <stdio.h>
using namespace std;
#define N 1000
int Tree[N];
int findRoot(int x) {
    
     //查找某个结点所在树的根结点
	if (Tree[x] == -1) return x;
	else {
    
    
		int tmp = findRoot(Tree[x]); 
		Tree[x] = tmp;
		return tmp;
	}
}
int main(){
    
    
	int n,m;
	while (scanf("%d",&n)!=EOF&&n!=0) {
    
    
		scanf("%d",&m);
		for (int i=1;i<=n;i++) Tree[i]=-1; //初始时,所有结点都是孤立的集合,即其所在集合只有一个结点,其本身就是所在树根結点
		while(m--!= 0) {
    
     //读入边信息
			int a,b;
			scanf("%d%d",&a,&b);
			a = findRoot(a); 
			b = findRoot(b); //查找边的两个顶点所在集合信息
			if (a != b) Tree[a] = b; //若两个项点不在同一个集合则合并这两个集合
		}
		int ans = 0;
		for(int i=1;i<=n;i++){
    
    
			if(Tree[i] == -1) ans++; //洗计所有结点中根结点的个数
		}
		printf("%d\n",ans-1); //答案即为在ans个集合间再修建ans-1栾道路即可使所有结点连通
	}
	return 0;
}

Problem 2
在这里插入图片描述
在这里插入图片描述
为了计算每个集合的元素个数,我们不妨在表示每个集合的树的根结点记录该集合所包含的元素个数,在合并时累加被合并两个集合包含的元素个数。最后,找出所有集合中所包含元素最多的集合即是所求。

#include <stdio.h>
using namespace std;
#define N 10000001
int Tree[N];
int findRoot(int x) {
    
     //查找结点x所在树的根结点
	if (Tree[x] == -1) return x;
	else {
    
    
		int tmp = findRoot(Tree[x]);
		Tree[x] = tmp;
		return tmp;
	}
}
int sum[N]; /*用sum[i]表示以结点i为根的树的结点个数,
其中保存数据仅当Tree[i]为-1即诚结点为树的根结点时有效*/
int main(){
    
    
	int n;
	while (scanf("%d",&n) != EOF) {
    
    
		for (int i=1;i<N;i++) {
    
     //初始化结点信息
			Tree[i] = -1; //所有结点为孤立集合
			sum[i] = 1; //所有集合的元素个数为1
		}
		while(n--!=0) {
    
    
			int a,b;
			scanf("%d%d",&a,&b);
			a = findRoot(a);
			b = findRoot(b);
			if(a != b) {
    
    
				Tree[a] = b;
				sum[b]+=sum[a];
			 /*合并两集时,将成为子树的树的根结点上保存
			 的该集合元素个数的数字累加到合并后新树的树根*/
			}
		}
		int ans = 1;//答案至少为1,固这里先出初始化为1。
		for(int i=1;i<=N;i++){
    
    
			if (Tree[i]==-1&&sum[i]>ans) ans=sum[i]; //统计最大值
		}
		printf("%d\n",ans);
	}
	return 0;
}

最小生成树(MST)

在一个无向连通图中,如果存在一个连通子图包含原图中所有的结点和部分:边,且这个子图不存在回路,那么我们称这个子图为原图的一棵生成树。在带权图中,所有的生成树中边权的和最小的那棵(或几棵)被称为最小生成树。
在这里插入图片描述
最小生成树问题是图论中最经典的问题之一,它在实际生活当中也有广泛应用,如在通信基站之间修建通信光缆使所有的基站间可以直接或间接通信,最少需要多少长的光缆。要利用最小生成树来解决实际问题,我们必须先学会怎样求解一个连通图的最小生成树。
我们先来看这样一个定理:
在要求解的连通图中,任意选择一些点属于集合A,剩余的点属于集合B,必定存在一棵最小生成树包含两个顶点分别属于集合A和集合B的边(即连通两个集合的边)中权值最小的边。
这个结论就是我们将要介绍的求最小生成树Kruskal算法的算法原理,它按照按如下步骤求解最小生成树:

  1. 初始时所有结点属于孤立的集合。
  2. 按照边权递增顺序遍历所有的边,若遍历到的边两个顶点仍分属不同的集合(该边即为连通这两个集合的边中权值最小的那条)则确定该边为最小生成树上的一条边,并将这两个顶点分属的集合合并。
  3. 遍历完所有边后,原图上所有结点属于同一个集合则被选取的边和原图中所有结点构成最小生成树;否则原图不连通,最小生成树不存在。

Problem 1
在这里插入图片描述
在这里插入图片描述
在给定的道路中选取一些,使所有的城市直接或间接连通且使道路的总长度最小,该例即为典型的最小生成树问题。我们将城市抽象成图上的结点,将道路抽象成连接点的边,其长度即为边的权值。经过这样的抽象,我们求得该图的最小生成树,其上所有的边权和即为所求。

#include <stdio.h>
#include <algorithm>
using namespace std;
#define N 101
int Tree[N];
int findRoot(int x) {
    
     //查找结点x所在树的根结点
	if (Tree[x] == -1) return x;
	else {
    
    
		int tmp = findRoot(Tree[x]);
		Tree[x] = tmp;
		return tmp;
	}
}
struct Edge {
    
     //边结构体
	int a,b;//边两个顶点的编号
	int cost;//该边的权值
	bool operator < (const Edge &A) const {
    
     //重载小于号使其可以按照边权从小到大排列
		return cost < A. cost;
	}
}edge[6000];
int main(){
    
    
	int n;
	while (scanf("%d",&n) != EOF&&n !=0) {
    
    
		for (int i=1;i<=n*(n-1)/2;i++) {
    
    
			scanf("%d%d%d",&edge[i].a,&edge[i].b,&edge[i].cost);
		} 
		sort(edge+1,edge+1+n*(n-1)/2); //按照边权值递增排列所有的for(int i=1;i<=n;i++)
			Tree[i] = -1;//初始时所有的结点都属于孤立的集合
		int ans = 0; //最小生成树上边权的和,初始值为0
		for(int i=1;i<=n*(n-1)/2;i++){
    
    //按照边权值递增顺序遍历所有的边
			int a = findRoot(edge[i].a);
			int b = findRoot(edge[i].b); //查找该边两个顶点的集合信息
			if (a != b) {
    
     //若它们属于不同集合,则选用该边
				Tree[a]=b; //合并两个集合
				ans+=edge[i].cost;//累加诫边权值
			)
		}
		printf("%d\n",ans);
	}
	return 0;
}

每个测试用例的第1行给出村庄数目N ( <100 );随后的N(N-1)/2 行对应村庄间的距离。代码之所有没有处理最小生成树不存在的情况,便是N(N-1)/2 条边。一个具有N个顶点的无向图最多有N(N-1)/2条边,所以本例中不会出现最小生成树不存在的情况。

Problem 2
在这里插入图片描述
题目大意为平面上有若干个点,我们需要用一些线段来将这些点连接起来使任意两个点能够通过一系列的线段相连,给出所有点的坐标,求一种连接方式使所有线段的长度和最小,求该长度和。
若我们将平面上的点抽象成图上的结点,将结点间直接相邻的线段抽象成连接结点的边,且权值为其长度,那么该类似于几何最优值的问题就被我们转化到了图论上的最小生成树问题。但在开始求最小生成树前,我们必领先建立该图,得出所有的边和相应的权值。

#include <stdio.h>
#include <math.h>
#include <algorithm>
using namespace std;
#define N 101
int Tree[N];
int findRoot(int x) {
    
     //查找结点x所在树的根结点
	if (Tree[x] == -1) return x;
	else {
    
    
		int tmp = findRoot(Tree[x]);
		Tree[x] = tmp;
		return tmp;
	}
}
struct Edge {
    
     //边结构体
	int a,b;//边两个顶点的编号
	int cost;//该边的权值
	bool operator < (const Edge &A) const {
    
     //重载小于号使其可以按照边权从小到大排列
		return cost < A. cost;
	}
}edge[6000];
struct Point {
    
     //点结构体
	double x,y; //点的两个坐标值
	double getDistance(Point A) {
    
     //计算点之间的距离
		double tmp=(x-A.x)*(x-A.x)+(y-A.y)*(y-A.y);
		return sqrt(tmp);
	}
}list[101];
int main(){
    
    
	int n;
	while (scanf("%d",&n)!=EOF) {
    
    
		for(int i=1;i<=n;i++){
    
    
			scanf("%lf%lf",&list[i].x,&list[i].y);
		} 
		int size=0; //抽象出的边的总数
		for(int i=1;i<=n;i++){
    
    
			for(int j=i+1;j<=n;j++){
    
    //连接两点的线段抽象成边
				edge[size].a=i;
				edge[size].b=j; //该边的两个顶点编号
				edge[size].cost=list[i].getDistance(list[j]); //边权值为两点之间的长度
				size++; //边的总数增加
			} //遍历所有的点对
		}
		sort(edge,edge+size); //对边按权值递增排序
		for(int i=1;i<=n;i++){
    
    
			Tree[i]=-1;
		}
		double ans=0;
		for(int i-0;i<size;i++){
    
    
			int a=findRoot(edge[i].a);
			int b=findRoot(edge[i].b);
			if(a!=b){
    
    
				Tree[a]=b;
				ans+=edge[i].cost;
			}
		} //最小生成树
		printf("%.2lf\n",ans); 
	}
	return 0;
}

最短路径

最短路径问题:即寻找图中某两个特定结点间最短的路径长度。所谓图上的路径,即从图中一个起始结点到一个终止结点途中经过的所有结点序列,路径的长度即所经过的边权和。
在这里插入图片描述
我们首先来介绍第一种计算最短路径长度的算法一一Floyd 算法
Floyd算法又被称为佛洛依德算法,其算法思路如下:
在图的邻接矩阵表示法中, edge[i][j]表示由结点i到结点j中间不经过任何结点时的最短距离,那么我们依次为中间允许经过的结点添加结点1、结点2、…直到结点N,当添加完这些结点后,从结点i到结点j允许经过所有结点的最短路径长度就可以确定了,该长度即为原图上由结点i到结点j的最短路径长度。
我们设ans[k][i][j]为从结点i到结点j允许经过编号小于等于k的结点时其最短路径长度。如上文,ans[0][i][j]即等于图的邻接矩阵表示中edge[i][j]的值。 我们通过如下循环,完成所有k对应的ans[k][i][j]值的求解:

for (int k=1;k<=n;k++) {
    
     //从1至n循环k
	for(int i=1;i<=n;i++){
    
    
		for(int j=1;j<=n;j++){
    
    //遍历所有的ij
			if (ans[k-1][i][k]==无穷 || ans[k-1][k][j]==无穷){
    
     
			/*若当允许经过前k-1个结点时,i或j不能与k连通,
			则ij之间到目前为止不存在经过k的路径*/
				ans[k][i][j]=ans[k-1][i][j]; /*保持原值,即从i到j
				允许经过前k个点和允许经过前k-1个结点时最短路径长度相同*/
				continue; //继续循环
			}
			if (ans[k-1][i][j]==无穷 || ans[k-1][i][k]+ans[k-1][k][j]<ans[k-1][i][j])
			 //若经过前k-1个结点,i和j不连通或者通过经过结点k可以得到比原来更短的路径
				ans[k][i][j]=ans[k-1][i][k] + ans[k-1][k][j];
			//更新该最短值
			else ans[k][i][j]=ans[k-1][i][j]; //否则保持原状
		}
	}
}

同时我们注意到,我们在通过ans[k-1][i][j]的各值来递推求得ans[k][i][j]的值时,所有的ans[k][i][i]值将由ans[k-1][i][j]和 ans[k-1][i][k] + ans[k-1][i][j]的大小关系确定,但同时ans[k][i][k]和ans[k][k][j]必定与ans[k-1][i][k]和ans[k-1][k][j]的值相同,即这些值不会因为本次更新而发生改变。所以我们将如上代码片段简化成如下形式:

for(int k=1;k<=n;k++){
    
    
	for(int i=1;i<=n;i++){
    
    
		for(int j=1;j<=n;j++){
    
    
		if (ans[i][k]==无穷 || ans[k][j]==无穷) continue;
		if (ans[i][j]==无穷 || ans[i][k] + ans[k][j] < ans[i][j])
			ans[i][j]= ans[i][k] + ans[k][j];
		}
	}
}

如该代码片段所示,我们将原本的三维数组简化为二维数组,而每次更新时直接在该二维数组上进行更新。这是有原因的,当最外层循环由k-1变为k时,各ans[i][k]和ans[k][j]的值不会因为本次更新发生改变(当前i到k的最短路径中途必不经过结点k),而本次更新又是由它们的值和各ans[i][j]的值比较面进行的。所以我们直接在二维数组上进行本次更新,并不会影响到本次更新中其它各值的判定。节省了大量的内存空间,同时还省略了保持原值的操作。

Problem 1
在这里插入图片描述
在这里插入图片描述
我们首先分析复杂度,如我们在上文中给出的代码所示,floyd 算法主要包括一个三重循环,每重循环的循环次数均是N,这样Floyd算法的时间复杂度为O(N3),空间复杂度为0 (N2),其中N均为图中结点的个数。
在本例中N最大值为100, N3的时间复杂度尚在我们可以接受的范围内。

#include <stdio.h>
int ans[101][101];//二维数组,其初始值即为该图的邻接矩阵
int main(){
    
    
	int n,m;
	while (scanf("%d%d",&n,&m) != EOF) {
    
    
		if(n==0&&m==0)break;
		for(int i=1;i<=n;i++){
    
    
			for(int j=1;j<=n;j++){
    
    
				ans[i][j]=-1; //对邻接矩阵初始化,我们用-1代表无穷
			}
			ans[i][i]=0;//自己到自己的路径长度设为0
		}
		while(m--){
    
    
			int a,b,c;
			scanf("%d%d%d",&a,&b,&c); 
			ans[a][b]=ans[b][a]=c; //对邻接矩阵赋值, 由于是无向图,该赋值操作要进行两次
		}
		for(int k=1;k<=n;k++) {
    
     //k从1到N循环,依次代表允许经过的中间结点编号小于等于k
			for(int i=1;i<=n;i++){
    
    
				for(int j=1;j<=n;j++) {
    
     //遍历所有ans[i][j], 判断其值保持原值还是将要被更新
					if (ans[i][k]==-1||ans[k][j]==-1) continue; //若两值中有一个值为无穷,则ans[i][j]不能由于经过结点K而被更新,跳过循环,保持原值
					if (ans[i][j]==-1||ans[i][k]+ans[k][j]<ans[i][j])
						ans[i][j] = ans[i][k] + ans[k][j]; //当由于 经过K可以获得更短的最短路径时,更新该值
				}
			}
		}
		printf("%d\n",ans[1][n]);//输出商店到赛场n的最短时间
	}
	return 0;
}

在讨论完Floyd算法后,我们来看另一种与其特点完全不同的最短路径算法一Dijkstra 算法(迪杰斯特拉算法)。它与之前讨论的Floyd算法有一个非常明显的区别,Floyd算法可以计算出图上所有结点对之间的最短路径长度,Djjkstra算法只能求得某特定结点到其它所有结点的最短路径长度,即单源最短路路径问题。
Djkstra 算法流程如下:

  1. 初始化,集合K中加入结点1,结点1到结点1最短距离为0,到其它结点为无穷(或不确定)。
  2. 遍历与集合K中结点直接相邻的边(U, V, C),其中U属于集合K, V
    不属于集合K,计算由结点1出发按照已经得到的最短路到达U,再由U经过该边到达V时的路径长度。比较所有与集合K中结点直接相邻的非集合K结点该路径长度,其中路径长度最小的结点被确定为下一个最短路径确定的结点,其最短路径长度即为这个路径长度,最后将该结点加入集合K。
  3. 若集合K中已经包含了所有的点,算法结束;否则重复步骤2。

我们使用Dijstra算法重写Problem 1:

#include <stdio.h>
#include <vector>
using namespace std;
struct E{
    
     //邻接链表中的链表元素结构体
	int next;//代表直接相邻的结点
	int c; //代表该边的权值(长度)
};
vector<E> edge[101]; //邻接链表
bool mark[101]; /*标记,当mark[j]为true时表示结点j的最短路径
长度已经得到,该结点已经加入集合K*/
int Dis[101]; /*距离向量,当mark[i]为true时,表示已得的最短路径长度;
否则,表示所有从结点1出发,经过已知的最短路径达到集合K中的某结点,
再经过一条边到达结点i的路径中最短的距离*/
int main(){
    
    
	int n, m;
	while (scanf("%d%d",&n,&m)!=EOF) {
    
    
		if(n==0&&m==0)break;
		for(int i=1;i<=n;i++) edge[i].clear(); //初试化邻接链表
		while(m--) {
    
    
			int a,b,c;
			scanf("%d%d%d",&a,&b,&c);
			E tmp;
			tmp.c=c;
			tmp.next=b;
			edge[a].push_back(tmp);
			tmp.next=a;
			edge[b].push_back(tmp); /*将邻接信息加入邻接链表,由于原图为无向图,
			固每条边信息都要添加到其两个顶点的两条单链表中*/
		}
		for(int i=1;i<=n;i++){
    
    //初始化
			Dis[i] = -1; //所有距离为-1,即不可达
			mark[i] = false; //所有结点不属于集合K
		}
		Dis[1] = 0; //得到最近的点为结点1, 长度为0
		mark[1] = true; //将结点1加入集合K
		int newP = 1; //集合K中新加入的点为结点1
		for (int i = 1;i < n;i ++) {
    
     
			//循环n-1次,按照最短路径递增的顺序确定其他n-1个点的最短路径长度
			for (int j = 0;j < edge[newP].size();j++) {
    
     
				//遍历与该新加入集合K中的结点直接相邻的边
				int t = edge[newP][j].next; //该边的另一个结点
				int c = edge[newP][j].c; //该边的长度
				if (mark[t] == true) continue; //若另一个结点也属于集合K,则跳过
				if (Dis[t] == -1||Dis[t] > Dis[newP] + c) 
				//若该结点尚不可达,或者该结点从新加入的结点经过一条边到达时比以往距离更短
					Dis[t]=Dis[newP] + c; //更新其距离信息
			}
			int min = 123123123; //最小值初始化为一个大整数,为找最小值做准备
			for(int j=1;j<=n;j++){
    
    //遍历所有结点
				if (mark[j] == true) continue; //若其属于集合K则跳过
				if (Dis[j] = -1) continue; //若该结点仍不可达则跳过
				if (Dis[j] < min) {
    
     //若该结点经由结点1至集合K中的某点在经过一条边到达时距离小于当前最小值
					min = Dis[j]; //更新其为最小值
					newP = j; //新加入的点暂定为该点
				}
			}
			mark[newP] = true; /*将新加入的点加入集合K,Dis[newP]虽然数值不变,
			但意义发生变化,由所有经过集合K中的结点再经过一条边到达时的距离中的
			最小值变为从结点1到结点newP的最短距离*/
		}
		printf("%d\n",Dis[n]); 
	}
	return 0;
}

该代码中,使用了邻接链表保存图信息。由此可见,Dijstra算法很好的支持邻接链表,但同时它也可以被应用于邻接矩阵。这里使用邻接链表,是为了方便读者更详细的了解用vector模拟邻接链表的方法。

Problem 2
在这里插入图片描述
在这里插入图片描述
在该例中,我们不仅需要求得起点到终点的最短距离,还需要在有多条最短路径的时,选取花费最少的那一条。要解决这个问题,我们只要更改Dijstra 算法中关于“更近”的评判标准即可:有两条路径,若它们距离不一样时,距离小的更近;若距离一样时花费少的更近。当定义这种新的评判标准后,Dijstra 算法照样能为我们求得“最近”的路径长度。

#include <stdio.h>
#include <vector>
using namespace std;
struct E{
    
     //邻接链表元素结构体
	int next;
	int c;
	int cost;
};
vector<E> edge[1001]; //邻接链表
int Dis[1001]; //距离数组
int cost[1001]; //花费数组
bool mark[1001]; //是否属于集合K数组
int main(){
    
    
	int n,m;
	int S,T; //起点,终点
	while(scanf("%d%d",&n,&m)!=EOF) {
    
    
		if(n==0&&m==0)break;
		for(int i=1;i<=n;i ++) edge[i ].clear(); //初始化邻接链表
		while(m--) {
    
    
			int a,b,c,cost;
			scanf("%d%d%d%d",&a,&b,&c,&cost);
			E tmp;
			tmp.c = c;
			tmp.cost = cost; //邻接链表中增加 了该边的花费信息
			tmp.next = b;
			edge[a].push_back(tmp);
			tmp.next = a;
			edge[b].push_back(tmp);
		}
		scanf("%d%d",&S,&T); //输入起点终点信息
		for(int i=1;1<=n;i++){
    
    //初始化
			Dis[i] = -1;
			mark[i] = false;
		}
		Dis[S] = 0;
		mark[S] = true;
		int newP = S; //起点为S,将其加入集合K,且其最短距离确定为0
		for(int i=1;i<n;i++){
    
    
			for (int j = 0;j< edge[newP].size();j ++) {
    
    
				int t = edge[newP][j].next;
				int c = edge[newP][j].c;
				int co = edge[newP][j].cost; //花费
				if (mark[t] == true) continue;
				if (Dis[t]==-1||Dis[t]>Dis[newP]+c||Dis[t]=Dis[newP]+c&&cost[t]>cost[newP]+co) {
    
     //比较大小时,将距离相同但花费更短也作为更新的条件之一
					Dis[t] = Dis[newP] + c;
					cost[t] = cost[newP] + co; //更新花费
				}
			}
			int min = 123123123;
			for (int j = 1;j <= n;j ++) {
    
     //选择最小值,选择时不用考虑花费的因素,因为距离最近的点的花费已经不可能由于经过其它点而发生改变了
				if (mark[j] == true) continue;
				if (Dis[j] == -1) continue;
				if (Dis[j] < min) {
    
    
					min = Dis[j];
					newP =j ;
				}
			}
			mark [newP] = true;
		}
		printf("%d %d\n",Dis[T],cost[T]); 
	}
	return 0;
}

值得一提的是,若由结点U到结点V的最短路径不存在,即他们不连通,那么当Dijstra算法完成以后,V结点仍然不属于集合K。即当完成Dijstra算法后,mark[V]依然为false即说明,结点U到结点V的最短路不存在。注:该最短路不存在,指结点U和V不连通的情况,我们不考虑存在负环的情况,边的权值为负这种特殊的情况在机试中考察的可能性不大,但若真的出现边的权值为负,若不存在负环则最短路存在,但我们不能使用Dijstra对其进行求解,因为Dijstra算法原理在存在负权值的图上不成立;若存在负环则最短路不存在。要求解包含负权值边.上的最短路问题,我们需要使用SPFA算法(该知识点在机试中考察的概率不大)。

拓扑排序

设有一个有向无环图(DAG图),对其进行拓扑排序即求其中结点的一个拓扑序列,对于所有的有向边(U, V) (由U指向V),在该序列中结点U都排列在结点V之前。满足该要求的结点序列,被称为满足拓扑次序的序列。求这个序列的过程,被称为拓扑排序。
由满足拓扑次序序列的特征我们也能得出其如下特点:若结点U经过若干条有向边后能够到达结点V,则在求得的序列中U必排在V之前。
在这里插入图片描述
拓扑排序的方法:
首先,所有有入度(即以该结点为弧头的弧的个数)的结点均不可能排在第一个。那么,我们选择一个入度为0的结点,作为序列的第一个结点。当该结点被选为序列的第一个顶点后,我们将该点从图中删去,同时删去以该结点为弧尾的所有边,得到一个新图。那么这个新图的拓扑序列即为原图的拓扑序列中除去第一个结点后剩余的序列。同样的,我们在新图,上选择一个入度为0的结点,将其作为原图的第二个结点,并在新图中删去该点以及以该点为弧尾的边。这样我们又得到了一张新图,重复同样的方法,直到所有的结点和边都从原图中删去。若在所有结点尚未被删去时即出现了找不到入度为0的结点的情况,则说明剩余的结点形成一个环路,拓扑排序失败,原图不存在拓扑序列。

Probelm 1
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
若我们将该群里的所有人都抽象成图上的结点,将所有的师徒关系都抽象成有向边(由师父指向徒弟),该实际问题就转化为一个数学问题一一该图上是否存在一个环, 即判断该图是否为有向无环图。无论何时,当需要判断某个图是否属于有向无环图时,我们都需要立刻联想到拓扑排序。若一个图,存在符合拓扑次序的结点序列,则该图为有向无环图;反之,该图为非有向无环图。也就是说,若在该图上拓扑排序成功,该图为有向无环图;反之,则存在环路。在给出本例的代码之前,我们先来了解标准模板库中又一个标准模板: std::queue,顾名思义,它的用处为建立一个队列并完成相关的操作。使用代码:

queue<int> Q;

将建立一个保存对象为int的队列Q,其相关操作如下:

Q.push(x); 将元素x放入对尾; 
x=Q.font();读取对头元素,将其值赋值给x;
Q.pop();   对头元素弹出;
Q.empty(); 判断队列是否为空,若返回值为true代表队列为空。

为了保存在拓扑排序中不断出现的和之前已经出现的入度为0的结点,我们使用一个队列。每当出现一个入度为0的结点,我们将其放入队列;若需要找到一个入度为0的结点,就从对头取出。值得一提的是,这里使用队列仅仅为了保存入度为0的结点,而与队列先进先出的性质无关,若读者愿意,也可以使用堆栈来保存,这与拓扑排序本身的原理无关。

#include <stdio.h> 
#include <vector>
#include <queue>
using namespace std;
vector<int> edge[501]; //邻接链表,因为边不存在权值,只需保存与其邻接的结点编号即可,所以vector中的元素为int
queue<int> Q; //保存入度为0的结点的队列
int main(){
    
    
	int inDegree[501]; //统计每个结点的入度
	int n,m;
	while(scanf("%d%d",&n,&m) != EOF) {
    
    
		if(n==0&&m==0)break;
		for (int i = 0;i < n;i ++) {
    
     //初始化所有结点,注意本题结点编号由0到n-1
			inDegree[i] = 0; //初始化入度信息, 所有结点入度均为0
			edge[i].clear(); //清空邻接链表
		}
		while(m--) {
    
    
			int a,b;
			scanf("%d%d",&a,&b); //读入一条由a指向b的有向边
			inDegree[b]++; //又出现了一条弧头指向b的边,累加结点b的入度
			edge[a].push_back(b); //将b加入a的邻接链表
		}
		while (Q.empty() == false) Q.pop(); /*若队列非空,则一直弹出队头元素,该
		操作的目的为清空队列中所有的元素(可能为上一组测试数据中遗留的数据)*/
		for (int i = 0;i < n;i ++) {
    
     //统计所有结点的入度
			if (inDegree[i] == 0) Q.push(i); //若结点入度为0,则将其放入队列
		}
		int cnt = 0; //计数器,初始值为0,用于累加已经确定拓扑序列的结点个数
		while (Q.empty() == false) {
    
     //当队列中入度为0的结点未被取完时,重复
			int newP = Q.front(); 
			/*读出队头结点编号,本例不需要求出确定的拓扑序列,固不做处理;
			若要求求出确定的拓扑次序,则将该结点紧接着放在已经确定的拓扑序列之后*/
			Q.pop(); //弹出对头元素
			cnt++;
			//被确定的结点个数加一
			for (int i = 0;i<edge[newP].size();i++) {
    
    //将该结点以及以其为弧尾的所有边去除
				inDegree[edge[newP][i]]--; //去除某条边后,该边所指后继结点入度减一
				if (inDegree[edge[newP][i]]==0) {
    
     //若该结点入度变为0 
					Q.push(edge[newP][i]); //将其放入队列当中
				}
			}
		}
		if (cnt == n) puts("YES"); //若所有结点都能被确定拓扑序列,则原图为有向无环图
		else puts("NO"); //否则, 原图为非有向无环图
	}
	return 0;
}

该代码所有结点至多进入队列一次,但在每个结点被取出时我们都要遍历以其为弧尾的边,固复杂度为O (N+E),其中N为结点的个数,E为边的个数。
如本例所示, 很多涉及拓扑排序的问题都没有直接给出图,而是将图隐藏在一个实际问题当中,需要自己将实际问题抽象成一个图论问题,而这恰恰是所有图论题共同的难点。

猜你喜欢

转载自blog.csdn.net/weixin_44029550/article/details/105598330