王道数据结构第六章: 图论及其应用

图这一章比较难,算法也很多,考试一般只会考察大家对算法的理解,算法的具体实现反而不是重点。

学习图这一章时,注意要和树对比着学习。图和树很多地方是相似的,我们还会学到最小生成树等概念。

6.1.1 图的基本概念

graph表示的是图,edge是边,vertex是顶点,建议这几个英文要记住,因为时间复杂度都是这样表示的。

我们图论研究的是简单图,也就是不存在自身环和平行边。

顶点的度、入度、出度:

无向图的度=依附于该顶点的边数,有向图的度=出度+入度之和。

出度:以该顶点为起点的边的数目,记为OD(v);入度定义相反,记为ID(v);

 无向图中,若顶点i j之间有路径存在,则称两个顶点是连通的。

有向图中,若顶点 i 到 j 和 j 到 i 之间都有路径,则称这两个顶点是强联通的。

连通图:无向图中任意两个顶点都是连通的。

对于n个结点的无向图,若其是连通的,则最少有n-1条边。

若其是非连通图,则最多有^{_{n-1}^{2}\textrm{C}}条边。

强连通图:有向图中任何一对顶点都是强连通的。

对于n个顶点的有向图,若其是强连通的,则最少有n条边(形成回路)

 

连通分量: 无向图中的极大连通子图称为连通分量(子图必须连通,且包含尽可能多的顶点和边)

强连通分量:有向图中的极大强连通子图称为有向图的强连通分量。

连通图的生成树是包含图中全部顶点的一个极小连通子图。(边要尽可能少,但是要包含全部顶点)

若图中顶点数为n,则它的生成树含有n-1条边。对生成树而言,若砍去它的一条边,则会变成非连通图,若加上一条边则会形成一个回路。

生成森林: 非连通图中,连通分量的生成树构成了非连通图的生成森林。

边的权、带权图/网

 一个图中,每条边都可以标上具有某种含义的数值,称为这条边的权值。

带权图/网,边上带有权值的图称为带权图,也称网。

带权路径长度,当图是带权图时,一条路径上的所有边权值之和,称为该路径的带权路径长度。

 n个结点的图,若边数>n-1,则必形成回路。

6.2.1 邻接矩阵法

图的存储一共有四种方法,邻接矩阵、邻接表、十字链表、临接多重表。其中我们使用最多、应用最广的是邻接矩阵和邻接表法。

对于无向图,邻接矩阵一定是对称的,但是对于有向图则不是如此。

#define MaxVertexNum 100//顶点数目最大值
typedef struct {
	char Vex[MaxVertexNum];//顶点表
	int Edge[MaxVertexNum][MaxVertexNum];//邻接矩阵,边表
	int vexnum, arcnum;//图的当前顶点数和边数
}MGraph;

 

 如何求一个顶点的出度?按照定义,找同一行为1的数组元素个数,入度找一列即可。

时间复杂度为O(V),因为我们要遍历顶点。

我们除了可以用邻接矩阵存储一般图,还可以存储带权图。

#define MaxVertexNum 100
#define INFINITY 最大的int值//int的最大值
typedef char VertexType;//顶点的数据类型
typedef int EdgeType;//带权图中边值的数据类型
typedef struct {
	VertexType Vex[MaxVertexNum];
	EdgeType Edge[MaxVertexNum][MaxVertexNum];
	int vexnum, arcnum;//图的当前顶点数和弧数
};

邻接矩阵的性能分析:空间复杂度O(V²),只和顶点数有关,和实际的边数无关,因此适合存储稠密图。

无向图的邻接矩阵是对称矩阵,可以压缩存储(只存储上三角/下三角区域) 

6.2.2 邻接表法

邻接表法师用顺序+链式存储的方式来存储图的。我们回顾树的孩子表示法:顺序存储各个结点,每个结点中保存孩子链表头指针。

因此,我们可以利用邻接表存储图,顺序存储各个结点,指针指向该顶点连接的所有结点。 

对于无向图:存储顶点需要O(V)的空间复杂度,存储边的指针需要O(2E)空间复杂度,因为一条边要存储两次,故整体复杂度为O(V+2E)

而有向图存储边的指针就不用存储两次,故空间复杂度降低为O(V+E)。

//边/弧
typedef struct ArcNode {
	int adjvex;//边/弧指向哪个结点
	struct ArcNode* next;//指向下一条弧的指针
	InfoType info;//边权值
}ArcNode;
//顶点
typedef struct VNode {
	VertexType data;//顶点信息
	ArcNode* first;//第一条边/弧
}VNode,AdjList[MaxVertexNum];
//用邻接表存储的图
typedef struct {
	AdjList vertices;
	int vexnum, arcnum;
}ALGraph;

 我们思考,如何求顶点的度、入度、出度?如何找到与一个顶点相连的边/弧?

出度只需要从该节点出发,看看有多少指针相连的结点,入度则需要遍历其余所有结点,度=出度+入度。找到一个顶点相邻的边或者弧,对于无向图来说,只需要从该节点出发就行,但是有向图必须遍历。

同时我们必须注意到,邻接矩阵表示法是唯一的,但是邻接表表示法并不唯一

考研最常见的存储结构便是这两种,其中邻接矩阵适合存储稠密图,邻接表适合稀疏图,并且邻接表找出度、入度很不方便(尤其是有向图),其余操作比较方便。

6.2.3 十字链表法与邻接多重表

一图胜千言。十字链表对于有向图来说操作十分方便,并且也只适合存储有向图,只不过这种代码实现起来比较复杂,因此直接考试的概率不大。 

接下来我们看另一种方法:邻接多重表。

在用邻接矩阵和邻接表法存储时,如果我们要删除一条边,邻接矩阵便于操作,但是空间复杂度很高;邻接表每条边要遍历两次,时间复杂度很大,有没有什么办法能综合二者优点呢?

有向图利用十字链表便于存储,无向图可以用邻接多重表。

 

与十字链表法非常相似,邻接多重表也是顺序表+链式存储。 邻接多重表的空间复杂度为O(V+E) ,相同的边只用存储一次。并且邻接多重表删除插入操作非常方便。

6.2.4 图的基本操作

Adjacent(G,x,y):判断图G是否存在边(x,y)或者<x,y>。判断图中是否有边,邻接矩阵法只需判断对应数组值是否为1,时间复杂度O(1);邻接表法需要从一个顶点出发,把对应指针指向的边找完,时间复杂度O(V);

Neighbours(G,x):列出图中与结点x邻接的边。对于邻接矩阵法,横行找一遍,纵列找一遍即可,时间复杂度O(V);对于邻接表法,如果找出边很容易,遍历该顶点对应的指针即可,时间复杂度O(V);若是找入边,则非常麻烦,需要从第一个顶点开始遍历,时间复杂度O(E)。(如果存储的是无向图就不用从每个顶点都遍历一遍,遍历该顶点就好了,时间复杂度O(V)。)

InsertVertex(G,x):在图中插入顶点x.操作简单,邻接矩阵自己多增加一行一列数组,值全都设置为0,邻接矩阵多加一个顶点,指针指向空,时间复杂度均为O(1)。

DeleteVertex(G,x):在图中删除顶点x.

AddEdge(G,x,y):若图中不存在边(x,y)或者<x,y>,则添加。

RemoveEdge(G,x,y):若图中存在边(x,y)或者<x,y>,则删除。

以上几个操作都比较简单,我们只要自己分析清楚就行,对于时间复杂度,一方面需要关注存储方式,另一方面需要注意是有向图还是无向图。 

重点是下面两种操作。

FirstNeighbor(G,x):求图中顶点x的第一个邻接点,若有则返回顶点号,若没有邻接点或图中不存在顶点x则返回-1.

NextNeighbor(G,x,y):假设图G中顶点y是顶点x的一个邻接点,返回除y之外顶点x下一个邻接点的顶点号,若y是x最后一个邻接点,则返回-1.

GetEdgevalue(G,x,y):获取图G边(x,y)或者<x,y>的权值。

SetEdgevalue(G,x,y):设置图G边(x,y)或者<x,y>的权值。

这两个操作与Adjacent(G,x,y)相似,只要找到边就好了。

6.3.1 广度优先遍历

树,不存在回路,我们搜索相邻的结点时,不可能搜索到已经访问过的结点。

图,图可能存在回路,我们搜索相邻结点是有可能搜索到已经访问过的结点。

树的广度优先遍历(层序遍历)需要做到以下几点:

(1)若树非空,则根结点入队;

(2)若队列非空,则队头元素出队,并将队头元素的孩子结点依次入队;

(3)重复(1)(2)操作直到队列为空。

对于图,我们也可以实现类似的广度优先遍历,我们称之为BFS(Breadth-First-Search)。要点如下:

1.找到与一个顶点相邻的所有结点。

2.标记哪些顶点被访问过

3.需要一个辅助队列。

我们一步步分析上面三个问题。找到与一个顶点相邻的所有结点,我们上一节实现了两种基本算法:FirstNeighbor,NextNeighbor,利用这两种算法即可找到所有相邻结点。

标记哪些顶点被访问过,我们在visit这个步骤里,加入一个bool 型数组,bool visit [MaxVervexNum],访问到了bool值设为1,这样即可标记。

问题三辅助队列提前设置好即可。

bool visit[MaxvertexNum] = false;//初始化visit数组
void BFS(Graph p, int v) {
	visit(p);//访问p结点
	visit[v] = true;//标记visit数组
	EnQueue(Q,v);//结点v入队列Q
	while (!isEmpty(Q)) {
		//检查顶点v的所有邻接点
		DeQueue(Q, v);
		for (int w = FirstNeighbor(G,v); w > 0; w = NextNeighbor(G,v,w)) {
			if (!visit[w]) {//w为v尚未访问的结点
				visit(w);
				visit[w] = true;//对w做已访问标记
				EnQueue(Q, w);
			}
		}
	}
}

同一个图,如果邻接矩阵方式存储,那么BFS序列唯一,如果用邻接表存储,邻接表指针连接的顺序可以改变,所以遍历序列一般可以改变。 

 我们上面写的代码只考虑了连通图,对于非连通图怎么办呢?在遍历完一次之后,如果visit数组还有值为FALSE的结点,我们继续从该节点开始BFS。

bool visit[MaxvertexNum] = false;//初始化visit数组
void BFSTraverse(Graph p) {
	for (int i = 0; i < G.vertexNum; i++) {
		visit[i] = false;//将所有visit数组值初始化为FALSE
	}
	InitQueue(Q);//初始化队列
	for (int i = 0; i < G.vertexnum; i++) {
		if (!visit[i]) {//如果有非连通分量
			BFS(p, i);//对每一个非连通分量调用一次BFS
		}
	}
}
void BFS(Graph p, int v) {
	visit(p);//访问p结点
	visit[v] = true;//标记visit数组
	EnQueue(Q,v);//结点v入队列Q
	while (!isEmpty(Q)) {
		//检查顶点v的所有邻接点
		DeQueue(Q, v);
		for (int w = FirstNeighbor(G,v); w > 0; w = NextNeighbor(G,v,w)) {
			if (!visit[w]) {//w为v尚未访问的结点
				visit(w);
				visit[w] = true;//对w做已访问标记
				EnQueue(Q, w);
			}
		}
	}
}

结论:对于无向图,调用BFS的次数=非连通分量的个数

复杂度分析:对于邻接矩阵存储的图,空间复杂度O(V),因为开了visit数组。邻接表法也是如此。

时间复杂度不好分析,图的时间复杂度基本都不好分析,我们并不主要考虑循环,而是应该主要分析FirstNeighbor和NextNeighbor这两个函数的时间复杂度。对于邻接矩阵,一共有V个结点,查找每个结点邻接的所有结点需要O(V)时间,故总体时间复杂度为O(V²)。对于邻接表法,一共V个结点,访问结点需要O(V);查找每个结点邻接所有结点时间复杂度一共为O(E),故时间复杂度为O(V+E)

遍历非连通图可得到广度优先生成森林。 

6.3.2 深度优先遍历

我们回忆下树的先根遍历:先用孩子兄弟表示法,把树转化为二叉树,接着对二叉树进行先序遍历。

//树的先根遍历
void PreOrder(TreeNode* p) {
	if (p != NULL) {
		visit(p);//访问根结点
		while (p还有下一棵子树T) {
			PreOrder(T);//先根遍历下一棵子树
		}
	}
}

基于此,我们给出树的深度优先遍历:

bool visit[MaxVertexNum];
void DFS(Graph p, int v) {
	visit(p);
	visit[v] = true;
	for (int w = FirstNeighbor(G, v); w > 0; w = NextNeighbor(G, v, w)) {
		//visit(w);
		//visit[w] = true;
		//DFS是不需要以上两行代码的,因为DFS是递归,会重复前面visit过程
		if (!visit[w]) {
			DFS(p, w);
		}
	}
}

 同理,如果是非连通图,则无法访问完所有结点,我们可以按照BFS一样处理。

bool visit[MaxVertexNum];
void DFSTraverse(Graph p) {
	for (int i = 0; i < G.vertexNum; i++) {
		visit[i] = false;//将所有visit数组值初始化为FALSE
	}
	InitQueue(Q);//初始化队列
	for (int i = 0; i < G.vertexnum; i++) {
		if (!visit[i]) {//如果有非连通分量
			DFS(p, i);//对每一个非连通分量调用一次BFS
		}
	}
}
void DFS(Graph p, int v) {
	visit(p);
	visit[v] = true;
	for (int w = FirstNeighbor(G, v); w > 0; w = NextNeighbor(G, v, w)) {
		//visit(w);
		//visit[w] = true;
		//DFS是不需要以上两行代码的,因为DFS是递归,会重复前面visit过程
		if (!visit[w]) {
			DFS(p, w);
		}
	}
}

复杂度分析:同BFS。

对于邻接矩阵,DFS得到的序列一定是唯一的,但邻接表得到的序列却不唯一,原因同上。

对无向图进行BFS或者DFS,调用函数次数=连通分量数。 故对于连通图,只需调用一次BFS或者DFS。

 6.4.1 生成树

连通图的生成树是包含图中全部顶点的一个极小连通子图,对于n个结点的连通图,生成树的边为n-1,如果去掉一条边那就是非连通图,如果增加一条边就形成回路。

最小生成树是针对有权值的树来说的, 对一个带权连通无向图,生成树不同,每棵树的权值也可能不同,若T是边的权值之和最小的生成树,则称T是最小生成树。

最小生成树可能有多个,但是边权值之和的最小值是唯一的。同时,最小生成树的边数=顶点数-1.

接下来我们研究构造最小生成树的两种方法:Prim算法(普里姆) ,Kruskal算法(克鲁斯卡尔)

普里姆算法:从某一个顶点开始构造生成树,每次将代价最小的新顶点纳入生成树,直到所有顶点都被纳入为止。时间复杂度O(V²),适合用于边稠密图。

实现思想:

第一轮,遍历各个顶点,找到lowCast最低的并且还没有加入树的顶点。为此我们需要两个数组,一个标记这个顶点是否加入树,另一个记录各个顶点的lowCast值。

第二轮:再次遍历循环,更新还没加入的各个顶点的lowCast值。

克鲁斯卡尔算法:每次选择一条权值最小的边,使得这条边的两头连通(原本已经连通的就不选)直到所有顶点连通。时间复杂度O(ELog2E),适合用于边稀疏图。

算法思想:

第一轮,将所有边对应的权值进行排序,一共需要E轮。

第二轮,检查第一条边对应的两个顶点是否连通(是否属于同一个集合)。

第三轮:检查第二条边对应的两个顶点是否连通,以此循环下去,直到所有顶点都在一个集合里。

每一次需要判断两个顶点是否属于同一集合,根据并查集的知识,这个操作时间复杂度O(log2E),故总时间复杂度O(Elog2E)

6.4.2 最短路径问题BFS算法

其实BFS算法就是有权图,只不过每条边的权值都为1.

 利用BFS算法求解最短路径问题,只不过是对BFS算法的一个小修改,我们只需要在visit的时候加上两个操作:一是记录最短路径d[],二是记录最短路径的前驱结点path[]。

bool visit[MaxvertexNum] = false;//初始化visit数组
void BFS_MIN_Distance(Graph p, int u) {
    //初始化两个数组
    for(int i=0;i<G.vexnum;++i){
        d[i]=21000000;//初始化路径长度
        path[i]=-1;//最短路径从哪个顶点过来
    }
	d[u]=0;//访问p结点
	visit[u] = true;//标记visit数组
	EnQueue(Q,u);//结点v入队列Q
	while (!isEmpty(Q)) {
		//检查顶点v的所有邻接点
		DeQueue(Q, u);
		for (int w = FirstNeighbor(G,u); w > 0; w = NextNeighbor(G,u,w)) {
			if (!visit[w]) {//w为u尚未访问的结点
				d[w]=d[u]+1;//d数组是记录最短的路径长度
                path[w]=u;//path是记录路径从哪到哪
				visit[w] = true;//对w做已访问标记
				EnQueue(Q, w);
			}
		}
	}
}

6.4.3 最短路径问题Dijkstra算法

首先我要膜拜一下大佬迪杰斯特拉

 迪杰斯特拉算法与BFS算法很像,首先都要三个数组,final数组标记各顶点是否找到路径,dist数组记录最短路径长度,path数组记录路径上的前驱。

迪杰斯特拉算法主要由两个部分组成:一是找到final数组中值为FALSE并且dist数组中值最小的那个顶点,然后以这个顶点为出发,更新各个顶点的最短路径长度和前驱。之后就是不断循环,直到所有的final数组值都为TRUE即可。

 

 以上便是该算法的具体执行流程。接着我们分析该算法的时间复杂度。

一共n个顶点,需要n-1次循环,每次循环先找到一个满足条件的顶点,之后更新剩下的所有顶点,故时间复杂度O(V²)。

如果权值为负,迪杰斯特拉算法将失效。

6.4.4 最短路径问题Floyd算法

同样的,我们先膜拜一下弗洛伊德。

Floyd算法是利用动态规划的思想处理的, 对于n个顶点的图,我们首先考虑不允许在其它路径中转,最短路径是什么情况?那么考虑在一个顶点中转,最短路径是什么?考虑n-1个顶点都可以是中转顶点,那么最短路径又是什么?

Floyd算法机器实现简单,但人工手算很麻烦,考试最多出五个顶点的图,不会更多,因此我们要理解这个算法的思想。 

 

 Floyd算法看起来很麻烦,但是核心代码就几行,时间复杂度O(V^3),空间复杂度O(V²)

//初始化工作,初始化矩阵A和path
for(int k=0;k<n;k++){//考虑以vk作为中转点
    for(int i=0;i<n;i++){//遍历整个矩阵
        for(int j=0;j<n;j++){
            if(A[i][j]>A[i][k]+A[k][j]){如果vk为中转点的路径更短
                A[i][j]=A[i][k]+A[k][j];//更新最短路径长度
                path[i][j]=k;//中转点
            }
        }
    }
}

Floyd算法是可以解决负权值的图的,但是无法处理带负权回路的图, 这种图有可能没有最短路径。

6.4.5 有向无环图处理算数表达式

 对于这一节,我们记住这个做题顺序就可以了。

6.4.6 拓扑排序

AOV网一定是DAG图,里面不能有环,否则说明两个活动之间谁先谁后无所谓,并且一直重复这两个动作。

拓扑排序:找到做事的先后顺序。

在图论中,由一个有向无环图的顶点组成的序列,当且仅当满足下列条件时,称为该图的一个拓扑排序:
(1)每个顶点出现且只出现一次。
(2)若顶点A在序列中排在顶点B的前面,则在图中不存在从顶点B到顶点A的路径。

或定义为:拓扑排序是对有向无环图的顶点的一种排序,它使得若存在一条从顶点A到顶点B的路径,则在排序中顶点B出现在顶点A后面。每个AOV网都有一个或多个拓扑排序序列

 拓扑排序代码实现起来比较复杂,考试大多要求我们手动模拟该过程即可

#define MaxVertexNum 100
class ArcNode {//边表结点
public:
	int adjvex;//该弧指向的顶点的位置
	ArcNode* nextarc;//指向下一条弧的指针
	InfoType info;//网的边权值
};
class VNode {//顶点表结点
public:
	VertexType data;//顶点信息
	ArcNode* firstarc;//指向第一条依附于该顶点的弧的指针
};
class Graph {//邻接表存储的图
public:
	AdjList vertices;//邻接表
	int vexnum, arcnum;//图的顶点数和弧数
};
bool TopologicalSort(Graph G) {
	InitStack(S);//初始化栈,存入入度为0的结点
	for (int i = 0; i < G.vexnum; i++) {
		if (indegree[i] == 0) {
			Push(S, i);//将所有入度为0结点进栈
		}
	}
	int count = 0;//计数,记录当前已经输出的结点数
	while (!IsEmpty(S)) {//栈不空,则存在入度为0顶点
		Pop(S, i);//栈顶元素进栈
		print[count++] = i;//输出结点i
		for (p = G.vertices[i].firstarc; p; p = p->nextarc) {
			//将所有i指向的顶点入度-1,并将入度为0顶点压入栈S
			v = p->adjvex;
			if (!(--indegree[v]))
				Push(S, v);//入度为0则进栈
		}
	}
	if (count < G.vexnum)
		return false;
	else
		return true;
}

上图是我们用邻接表法存储的图, 时间复杂度O(V+E),如果采用邻接矩阵,那么复杂度O(V²)

这一节的概念很重要,也很多,必须要在理解概念的基础上再进行代码执行。

接下来我们研究逆拓扑排序,逆拓扑排序顺序一样,只是一二步操作不同罢了。

逆拓扑排序还可以用DFS深度优先搜索实现:

6.4.7 关键路径

同时,AOE网中的某些活动还可以并行执行。

 

从源点到汇点的有向路径可能有多条,所有路径中,具有最大路径长度的路径称为关键路径,而把关键路径上的活动称为关键活动 。

完成整个工程的最短时间就是关键路径的长度,若关键活动不能按时完成,则整个工程的完成时间就会延长。

这些概念看上去很多,但是从字面意思上也很好理解。

 

关键活动特性:

若关键活动耗时增加,则整个工程的工期将增长
缩短关键活动的时间可以缩短整个工程的工期,
当缩短到一定程度时,关键活动可能会变成非关键活动 。

关键路径特性:

可能有多条关键路径,只提高一条关键路径上的关键活动速度并不能缩短整个工程的工期,只有加快那些包括在所有关键路径上的关键活动才能达到缩短工期的目的。

这一节内容和上面的一脉相承,概念还是又多又重要,考试中也主要以考察概念为主。

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值