DS博客作业04-图

0.PTA得分截图

1.本周学习总结

1.1 总结图内容

图存储结构

邻接矩阵

用一个二维数组edges[][]保存两个顶点之间的关系。edges[i][j]表示从第i个顶点到第j个顶点的边信息。我们可以根据该二维数组每一行的数据判断每个顶点的入度,根据每一列的数据判断每个顶点的出度。每个顶点的其他信息(例如:顶点名称,顶点编号等)用一个一维数组去vexs[]保存;
结构体

typedef struct
{
  int **edges;//保存边关系,定义为二级指针的原因:可以根据结点个数申请相对的空间,提高空间的利用效率;
  int n,e;//n保存顶点个数,e保存图中边的条数;
  VertexType *vexs;//保存顶点其他信息;VertexType是顶点其他信息的类型,可以是int,char,或者自定义结构体等;
}

无向图
对于无向图来说,两个顶点之间存在一条边(i,j),那么这两个顶点互为邻接点,不仅可以从顶点i到顶点j,也可以从顶点j到顶点i。于是在建立邻接矩阵时,不仅要对edges[i][j]赋值,也要对edges[j][i]赋值(无权值,如果存在边就赋值为1,否则赋为0);于是我们可以看出,最后得到无向图的邻接矩阵一定是沿对角线对称的;

有向图
对于有向图来说,若存在一条边(i,j),则此边只表示为从顶点i到顶点j,不可以由边(i,j)得到可以从顶点j到顶点i的信息。所以在建有向图的邻接矩阵时,只对edges[i][j]赋值(无权值,如果存在边就赋值为1,否则赋为0);和无向图不一样的是,最后得到的邻接矩阵不一定是一个对称图形。


对于网来说,每一条边上都附有一个对应的数值————权,这时我们就不能像构造无权图的邻接矩阵一样,用1表示两个顶点之间存在边,用0表示两个顶点之间不存在边。因为权值可以是任意值。既然这样,不如我们直接保存所有边的权值,如果两个顶点之间没有边关系,直接赋为∞。

创建邻接矩阵



分析

  • 因为邻接矩阵需要申请一个二维数组,空间复杂度为O(n2),邻接矩阵的初始化需要初始化整个二维数组,所以时间复杂度为O(n2);
  • 好处:方便我们提取,修改边的信息;
  • 劣势:占用空间较大,如果图中边条数较少(稀疏图)的话,需要我们保存的边信息就比较少,用邻接矩阵就会有多余的空间被闲置,空间利用效率不高;不利于顶点的插入和删除。

邻接表

邻接表是数组和链表的结合。对于每个顶点都建立一个单链表存储该顶点所有的邻接点。然后将定义一个结构体VNode,里面保存顶点邻接点的链表和顶点其他信息。设置VNode类型的结构体数组AdjGraph[]就可以保存图中所有顶点的邻接点,达到保存图中所有边的目的。结构体数组AdjGraph[]即为邻接表。
结构体

typedef struct ANode //边结点;
{
   int adjvex;//指向该边的终点编号;
   struct ANode*nextarc;//指向下一个邻接点;
   INfoType info;//保存该边的权值等信息;
}ArcNode;
typedef struct  //头结点
{
   int data;//顶点;
   ArcNode *firstarc;//指向第一个邻接点;
}VNode;
typedef struct
{
   VNode adjlist[MAX];//邻接表;
   int n,e;//图中顶点数n和边数e;
}AdjGraph;

无向图
对于无向图,输入边(a,b),那么就代表可以从顶点a到顶点b,也可以从顶点b到顶点a,所以我们不仅要在顶点a的邻接点链表中插入结点b,还要在顶点b的邻接点链表中插入结点a。

有向图
对于有向图,输入边(a,b),只需在顶点a的邻接点链表中插入b就行。


对于网来说,只是需要多存储一个权值。

创建邻接表


分析

  • 因为共有e条边和n个结点,需要开辟n个空间来保存结点,e个空间来保存e条边信息,所以,创建邻接表的空间复杂度为O(n+e);因为对n个结点的单链表进行初始化,处理了n次,还要对e条边信息进行保存,故时间复杂度为O(n+e);
  • 优势:占用空间相对邻接矩阵来说较小。
  • 劣势:不方便我们提取两个顶点之间边的信息。

图遍历及应用

DFS遍历

从给定的的任意结点v(初始顶点)出发,寻找一个未被遍历且和当前结点v之间有联系的结点b,又以结点b,寻找下一未遍历且和该结点之间有联系的结点c……一直重复这个过程直到所有结点都遍历完。这种遍历方式称为深度搜索遍历,即DFS遍历。DFS遍历我们在用栈求解迷宫问题时就有接触过,DFS遍历是一个可回溯的过程,如果在当前结点找不到和该结点有联系的点,我们就可以将该结点弹出,返回到上一遍历结点去继续寻找。直到图中和初始顶点邻接的所有顶点都被访问过为止。
思路

访问v结点,置为已访问;
   遍历v的邻接点w
     若w未被访问,递归访问w结点;


根据深度遍历的过程,我们可以生成一棵深度优先生成树:

代码实现

BFS遍历

从给定的任意结点v(初始顶点)开始,访问v所有的未被访问过的邻接点,然后按照一定次序访问每一个顶点的所有未被访问过的邻接点,直到图中和初始顶点邻接的所有顶点都被访问过为止。BFS遍历我们在用队列求解迷宫问题时接触过,是不可回溯的,逐渐向外扩散的过程。
思路

建一个访问队列q;
访问v节点,进队;
while(队列不为空)
   出队一个节点w;
   遍历节点w的邻接点
      取邻接点j,如果j未被访问则入队列q,然后把j标记为已访问;
end while


根据广度优先遍历过程,我们可以得到一棵广度优先生成树。

代码实现


非连通图的遍历
调用一次DFS遍历或者BFS遍历,只能遍历一个连通分量。如果想要遍历非连通图,必须多次调用DFS/BFS遍历函数;

for i=0 to g->n
   if 顶点i未被访问过
      调用BFS/DFS(g,i);
   end if
end for

图遍历应用

判断该图是否为连通图
调用一次DFS/BFS遍历函数,只能访问在某一个连通分量中的所有顶点,如果该图为连通图,那么连通图就只有一个连通分量————就是连通图本身。而非连通图则会有多个连通分量,需要多次调用DFS/BFS遍历函数。所以我们可以通过调用一次遍历函数,最后判断是否存在未遍历的结点,如果不存在,就是连通图。否则为非连通图。

  • 代码
bool IsConnected(AdjGraph*g)
{
   int i;
   int visted[MAX]={0};
   DFS/BFS(g,0);//调用一次DFS/BFS遍历函数;
   for(i=0;i<g->n;i++)//检查是否每个结点都已经被访问;
      if(visited[i]==0)//如果没有则为非连通
         return false;
   return true;
}

查找图路径
类似我们之前用栈求解迷宫的问题。在查找图一条简单路径或者所有路径,我们可以用结合DFS遍历来实现。

  • 思路
void FindPath(AdjGraph *G, int u, int v, int path[], int d)//寻找一条简单路径(邻接表)
{  //数组path[]保存路径;变量d记录路径长度,初始化为-1;
     将顶点u设置为已访问过,并保存到路径中,d++;
     if u==v  //遍历到终点;
        输出路径;return;
     else 
        遍历u的所有邻接点,如果邻接点w未遍历,继续递归访问w:FindPath(G,w,v,path,d);
}
  • 代码

    如果我们想要输出起点到终点的所有简单路径,这样当我们找到终点并输出一条路径后,我们不直接return,还需要恢复当前节点的环境,就是标记为未访问状态,使得u可以重复利用,然后再回溯到上一级结点。

找最短路径(无权图)
类似用队列求解迷宫的问题,求一条最短路径,我们可以运用BFS遍历实现;

  • 思路
void FindPath(AdjGraph* g, int u, int v)//邻接表存储图结构
{
   定义队列q;
   定义数组path[],保存路径中的每个结点的前驱结点;
   访问起点u,进队列,标记u为已访问;
   
   while(队列不为空)
      出队一个结点node;
      if (node == v)
         输出一条顺序由终点到起点的路径;
      else
         访问结点node的邻接点,修改各个邻接点的前驱结点为node,并入队;
      end if
   end while 
}
  • 代码

7-3 判断DFS序列的合法性


伪代码:
结构体:邻接表
建邻接表:
  初始化各节点的第一条邻边为NULL;
  for i=0 to e
     输入边(a,b);
     创建一个节点p存放a的邻结点b,利用头插法插入保存结点a的邻结点链表中;
  End for
检验序列函数:
定义栈s保存验证成功的数据,用于模拟DFS遍历;
While(栈不为空&&序列未遍历完)
   出栈一个结点data,在结点data的邻结点中寻找待检验数据sequence[j];
     如果找到标记该待检验数据为已访问;
     如果没有找到且结点data还有邻接点未访问
         验证失败,该序列不合法,return false;
     如果没有找到且结点data没有邻接点
         待检验数据可能存在于上一级结点中,出栈栈顶元素;
End while
Return true;
多次调用检验序列函数,实现对非连通图的遍历







最小生成树相关算法及应用

最小生成树概念

  • 一个生成树中含有图中全部的n个顶点和构成一棵树的n-1条边,生成树实质上就是图的一个连通子图,所以其中不存在回路(n个顶点形成一个连通图最少需要n-1条边)。在一个带权连通图中,根据边选择的不同,生成的最小生成树也不同。最小生成树是所有生成树中,带权值最小的生成树。

普里姆算法(prim)构造最小生成树

  • 思路 : 局部最优(贪心算法) + 调整 = 全局最优
    1.集合u保存最小生成树的结点(表示已被访问过)。初始化u={v},v为初始顶点,由v开始往外扩散。将v到其他顶点的所有边作为候选边;
    2.重复一下n-1次,使得其他n-1个顶点都加入到集合u中
    (1)从候选边中挑选出权值最小的边,且终点k为未进入到集合u的顶点,然后将顶点k加入集合u中;
    (2)对候选边进行修正,如果顶点k到其他未被访问的顶点j的边权值要小于原来和顶点j关联的候选边,那么就修改和顶点j关联的候选边为(k,j)。

    为了更好了解整个过程,加入两个辅助数组,一个是closest[]:用于保存最小生成树中的边依附在集合u中的顶点编号————就是保存候选边的顶点信息,(closest[k],k)构成一条候选边;另一个是数组lowcost[]:用于保存候选边的权值。已经在集合u中的顶点k,lowcost[k]设置为0。
  • 伪代码
初始化lowcost,closest数组;
遍历lowcost数组;
   若lowcost[i]!=0
     寻找最小边,及对应的邻接点k;
将k标记为已访问过:lowcost[k]=0;
遍历lowcost数组,对lowcost进行修正
   若lowcost[i]!=0 && edges[i][k]<lowcost[i]
     修正lowcost[i]=edges[i][k];
  • 代码

  • 注意:这里我们用的是邻接矩阵,用邻接表也可以。但是用邻接矩阵方便我们直接提取两个顶点之间的边关系。并且在建立邻接矩阵时,如果两个顶点之间没有边关系时,要设置为∞,不可以设置为0!!否则在对lowcost数组初始化时,如果起始点和某顶点i之间没有边关系时会初始化为0,但是我们之前也设置lowcost[]为0表示为已经被访问过,这样在寻找最小边和修正过程中会忽略和起始点没有边关系的顶点。以上的操作时对于连通图来说。如果是非连通图,要求非连通图的最小生成森林,需要多次调用Prim算法。

  • 应用:7-4 公路村村通






克鲁斯卡尔(kruskal)构造最小生成树

  • 思路:全局最优
    1.设置边集E保存图中所有的边。集合TE保存最小生成树中的边。
    2.将图中的边按权值排好顺序后依次选取,若选取的边不会使生成树T形成回路,则将该边加入到集合TE中。如果形成回路就舍弃,直到TE中存在(n-1)条边。

  • 伪代码

构造数组E存储图中所有边;
将E中所有边按权值重新排序(堆排序,快排序);
构造集合树TE,将每个顶点的双亲节点都初始化为自己;
while(k<n-1)
   选取边集E中第i条最小边(u,v);
   若(u,v)加入生成树中不形成回路,将u和v进行并集合;
end while
  • 代码
    这里使用的是邻接表,收集边我们需要遍历图,使用邻接表我们可以更快的得到所有边信息(时间复杂度:邻接表O(e),邻接矩阵O(n2))。

  • 应用 7-5 通信网络设计


伪代码:
结构体:邻接表
typedef struct              typedef struct 
{                         {
   int data;                    int u,v;//保存边的起点和终止结点;
   int parent;                  int weight;//保存边的权值;
}UFSTree;//建立集合树       }Edge;//用于克鲁斯卡尔算法

void  ConnectVillages(AdjGraph* g, int n, int m)
{
UFSTree*t;//集合树
初始化集合树,使每个结点的双亲指针都指向自己;
cost= Kruskal(g,t);//进行克鲁斯卡尔算法,计算建设成本 
如果为连通图,直接输出花费结果;
如果为非连通图
   for  i=1  to  n//遍历所有结点,确保每个结点都被访问过
      如果是未访问过的结点visited[i]==0
确定该节点双亲parent = FindParent(t,i);(递归寻找,直到双亲节点为自己)
         遍历所有未访问结点,确定双亲为parent的结点标记为已访问并输出;
         继续寻找下一个集合;
   end for 
}
克鲁斯卡尔算法:Int Kruskal (AdjGraph*g, UFSTree *& t) 
{
   Edge*E;//用于收集所有边关系;

   遍历邻接表,收集所有边关系的关系,保存到E中;
   sort(E,E+g->e,cmp);//根据边的权值进行堆排序;
   while(遍历边集E)
      取边集E中第i条权值最小边(u,v);
      如果u,v并集合成功,说明原本u,v双亲不同,加入这条边不形成回路; (同一个生成树中,所有结点的双亲指针相同);计算建设成本;
      如果u,v并集合失败,说明加入该边形成回路,舍弃;
   End while
}
并集合:bool UNION(UFSTree *& t, int x, int y)
{
   寻找x,y的双亲:
   如果x,y双亲相同,说明形成回路,return false;
   如果x,y双亲不同,说明没有形成回路
      比较x和y的高度,修改高度较小集合树的双亲指针 ,return true;
}

具体代码:
#include <iostream>
#include <algorithm>
#include <queue>
using namespace std;
typedef struct node
{
	int adjvex;
	int weight;
	struct node* nextarc;
}ArcNode;//边结点
typedef struct vnode
{
	int data;
	ArcNode* firstarc;
}VNode;//头结点
typedef struct
{
	VNode* adjlist;//邻接表
	int n, e;
}AdjGraph;
typedef struct
{
	int u, v;
	int weight;
}Edge;//用于克鲁斯卡尔算法,单纯保存顶点之间的边关系
typedef struct
{
	int data;
	int parent;
}UFSTree;//建立集合树

bool cmp(Edge a, Edge b);//sort的判断标准;
void CreatAgraph(AdjGraph*& g, int n, int m);//建立邻接表
void ConnectVilliages(AdjGraph* g, int n, int m);//实现村村通;
int Kruskal(AdjGraph* g, UFSTree*& t);//克鲁斯卡尔算法
bool UNION(UFSTree*& t, int x, int y);//并合集
int FindParent(UFSTree* t, int x);//寻找x的双亲;
bool IsConnect(AdjGraph* g);//用于判断是否为连通图;

int main()
{
	int n, m;
	AdjGraph* g;

	cin >> n >> m;
	CreatAgraph(g, n, m);
	ConnectVilliages(g, n, m);

	return 0;
}

bool cmp(Edge a, Edge b)//sort的判断标准;
{
	return a.weight < b.weight;
}
void CreatAgraph(AdjGraph*& G, int n, int e)//建立邻接表
{
	int a, b, weight;
	int i, j;
	ArcNode* p;
	G = new AdjGraph;

	G->adjlist = new VNode[n + 1];;
	for (i = 0; i <= n; i++)
	{
		G->adjlist[i].firstarc = NULL;
		G->adjlist[i].data = i;
	}
	for (i = 0; i < e; i++)
	{
		cin >> a >> b >> weight;
		p = new ArcNode;
		p->adjvex = b; p->weight = weight;
		p->nextarc = G->adjlist[a].firstarc;
		G->adjlist[a].firstarc = p;
	}
	G->e = e; G->n = n;
}
bool IsConnect(AdjGraph* G)//用于判断是否为连通图(BFS遍历);
{
	ArcNode* p;
	queue<ArcNode*>q;
	int i;
	int visited[53] = { 0 };

	p = G->adjlist[1].firstarc;
	visited[1] = 1;
	while (p)
	{
		if (visited[p->adjvex] == 0)
		{
			q.push(p);
			visited[p->adjvex] = 1;//记得置为1表示已经访问过该顶点;
		}
		p = p->nextarc;
	}
	while (!q.empty())
	{
		p = G->adjlist[q.front()->adjvex].firstarc;
		q.pop();
		while (p)
		{
			if (visited[p->adjvex] == 0)
			{
				q.push(p);
				visited[p->adjvex] = 1;//记得置为1表示已经访问过该顶点;
			}
			p = p->nextarc;
		}
	}
	for (i = 1; i <= G->n; i++)
		if (!visited[i])
			return false;
	return true;
}
void ConnectVilliages(AdjGraph* g, int n, int m)//实现村村通;
{
	int i, j, k;
	int* visited;
	int parent;
	int cost;
	UFSTree* t;//集合树;
	t = new UFSTree[g->n + 1];
	visited = new int[n + 1];
	for (i = 1; i <= g->n; i++)//初始化集合树;
	{
		t[i].data = i;
		t[i].parent = i;
	}
	for (i = 1; i <= n; i++)
		visited[i] = 0;
  cost=Kruskal(g,t);
	if (IsConnect(g))//连通图
		cout << "YES!" << endl << "Total cost:" << cost;
	else//非连通图
	{
		cout << "NO!";
		k = 1;
		for (i = 1; i <= n; i++)
		{
			if (!visited[i])
			{
				parent = FindParent(t, i);//先确定一个集合;
				visited[i] = 1; 
        cout << endl << k << " part:" << i; //输出
				for (j = i; j <= n; j++)
				{
					if (!visited[j] && FindParent(t, j) == parent)//寻找集合中其他顶点,输出
					{
						visited[j] = 1;
						cout << " " << j;
					}
				}
				k++;
			}
		}
	}
}
int Kruskal(AdjGraph* g, UFSTree*& t)//克鲁斯卡尔算法
{
	int i, j, k;
	int cost = 0;//计算总花费;
	int u, v;//记录边的两个顶点;
	Edge* E;//收集所有边的关系,利用该结构体数组寻找最小边;
	ArcNode* p;//遍历邻接表;

	E = new Edge[g->e];
	k = 0;
	for (i = 1; i <= g->n; i++)
	{
		p = g->adjlist[i].firstarc;
		while (p)
		{
			E[k].u = i; E[k].v = p->adjvex; E[k].weight = p->weight;//收集边关系;
			k++;
			p = p->nextarc;
		}
	}
	sort(E, E + g->e, cmp);//根据边的权值进行排序;

	k = 1, j = 0;//k记录生成树的中的元素个数,j用于遍历E;
	while (j < g->e)
	{
		u = E[j].u; v = E[j].v;
		if (UNION(t, u, v))//不是同一个集合,不形成回路
			cost += E[j].weight;
		j++;
	}
	return cost;
}
int FindParent(UFSTree* t, int x)//寻找x的双亲;
{
	if (x == t[x].parent)
		return x;
	else
		return FindParent(t, t[x].parent);
}
bool UNION(UFSTree*& t, int x, int y)//并合集
{
	x = FindParent(t, x);
	y = FindParent(t, y);

	if (x == y)//相同的双亲,说明形成了回路
		return false;
	else
	{
		t[y].parent = x;
		return true;
	}
}

两种算法分析

  • 时间复杂度:普利姆算法为O(n2),克鲁斯卡尔为O(eloge);
  • 普利姆算法实质上是一种贪心算法,每一步都是依据上一步的结果进行的。而克鲁斯卡尔算法是一种全局最优算法,每一步都是根据当前的整体情况而做出的结果。
  • 对于非连通带权图,求最小生成森林,普利姆算法需要多次调用才可以实现,但是克鲁斯卡尔只需要一次,因为每次寻找最小边时不只是在一个连通分量中寻找。算法结束后,根据生成的集合树,找各个顶点的双亲,双亲节点相同的在同一个最小生成树中————也就是说明在同一个连通分量中,这样就可以得到非连通带权图中各个连通分量的最小生成树。

最短路径相关算法及应用

最短路径概念

考虑带权有向图,把一条路径(仅仅考虑简单路径)上所经边的权值之和定义为该路径的路径长度或称带权路径长度。从源点到终点可能不止一条路径,路径长度最短的一条称为最短路径。

Dijkstra算法求单源最短路径

  • 思路
    最短路径中所有顶点都是最短路径!(就源点到最短路径中每个顶点的路径都是最短的)。所以我们直接求出源点到所有顶点的最短路径,最后在提取从源点到我们所需要的终点的最短路径。
    1.集合s为入选顶点集合,数组dist保存源点到各个顶点的最短路径,首先初始化为源点与各顶点之间的边的权值。数组path保存最短路径中各个顶点的前驱结点,首先对其初始化:如果该顶点和源点之间有边关系,初始化为源点编号,如果没有初始化为-1;
    2.重复下列步骤,直到s包含所有顶点
    (1).从未选入集合s的顶点中选择一个距离源点最小的顶点w,加入集合s;
    (2).剩下未被选择的顶点j,对其最短路径进行修正:将w作为中间顶点,从源点到顶点j的距离比不加入顶点w的路径长度短,那么修改dist[j]= dist[w]+ 边(u,j)的权值,修改j的前驱结点path[j]=w;

  • 伪代码

初始化dist数组,path数组,s数组;
遍历图中所有结点
{
   遍历dist数组,找为被s收入的距离源点最小的顶点w;
   s[w]=1;//将w加入集合s;
   for i=0 to g.n //修正未加入s集合的顶点的dist和path
       若dist[i]>dist[w]+g.edges[w][i];
          dist[i]=dist[w]+g.edges[w][i];
          path[i]=w;
   end for
}
  • 代码

  • 总结
    1.时间复杂度:O(n2);
    2.Dijkstra算法本质上是贪心算法,下一条路径都是由当前更短的路径派生出来的更长的路径。不存在回溯的过程。
    3.不适用带负权值的带权图求单源最短路径,也不适用于求最长路径长度:按Dijkstra算法,找第一个距离源点s最远的点a,这个距离在以后就不会改变。但A与S的最远距离一般不是直连。

弗洛伊德(Floyd)算法

当我们要求每一对顶点之间的最短路径,我们有两种方法:1.以一个顶点为源点,重复执行n次Dijkstra算法;2.弗洛伊德(Floyd)算法;两种方法的时间复杂度都为O(n3)Dijkstra算法上面已经解释过,这里我们解释弗洛伊德算法。

  • 思路
    1.二维数组A用于存放当前顶点之间的最短路径长度,分量A[i][j]表示当前顶点i到顶点j的最短路径长度;二维数组path用于存放结点在最短路径中的前驱结点。
    2.初始化数组A和数组path,数组A初始化为整个邻接矩阵,数组path[i][j]:如果顶点i和顶点j之间存在边,那么就初始化为i,否则初始化为-1;
    3.取每个顶点作为中间顶点k,遍历二维数组A,判断加入顶点k后,顶点i到顶点j的路径是否比原来要小,如果要小,则修改A[i][j]为A[i][k]+A[k][j],path[i][j]=k;

  • 伪代码

初始化二维数组A,二维数组path;
for k=0 to g.n
   遍历二维数组A
     将顶点k作为中间站,判断加入顶点k后的路径长度是否比原来小;
     若 A[i][j]>A[i][k]+A[k][j]
         修改A[i][j]=A[i][k]+A[k][j];
         修改path[i][j]=k;
end for
  • 代码

  • 总结
    1.弗洛伊德算法可以解决负权值的带权图,也可以解决求最长路径长度问题。
    2.弗洛伊德算法是一种动态规划的算法,在规划的同时又对之前的内容进行调整修改。

拓扑排序

在一个有向图中,如果我们需要访问一个节点,要先把这个节点的所有前驱节点都访问过后,才能访问该节点。按照这样的顺序访问所有节点得到的序列叫做拓扑序列。在一个有向图中求一个拓扑序列的过程叫做拓扑排序。

  • 拓扑排序思路
    1.选择一个没有前驱结点的顶点,输出该顶点编号;
    2.从有向图中删去此顶点,以及以他为起点的弧。这里的删除并不是在图结构中对该顶点的删除(物理删除),我们还是最好保留原来的图结构,这里我们可以借用顶点的入度实现模拟删除。当我们要'删除'某个顶点及以他为起点的弧时,我们可以直接将该顶点所有邻接点的入度-1;
    重复上述两步,直到找不到没有前驱的顶点。

  • 伪代码

遍历邻接表
   计算每个顶点的入度,存入头结点count成员中;
遍历图顶点
   找到一个入度为0的顶点,入栈/队列/数组;
while(栈不为空)
   出栈结点v,访问;
   遍历v的所有邻接点
   {
      所有邻接点的入度-1;
      若有邻接点入度为0,入栈/队列/数组;
   }
  • 代码

关键路径

在带权的有向无环图(A0E)中,我们用顶点表示事件,用有向边e表示活动,权重表示活动的持续时间,那么整个工程完成的时间为:从有向图的源点到汇点的最长路径,我们称为关键路径。

思路
1.确定事件最早的开始时间ve():当我们要进行某个事件i时,那么一定要确保该事件的前驱事件已经完成,然后把前驱事件到事件i的弧,看做进行事件i之前的准备活动。为了确保前面部分的准备工作能够全部完成,事件i的最早开始时间一定为ve(i)=MAX{ve(前驱1)+准备活动1,ve(前驱2)+准备活动2,···};源点的最早开始时间为0.所有事件的最早开始时间的计算要按照拓扑序列进行。

2.确定事件的最晚开始时间vl():在不会影响整个工程进度的前提下,事件i必须发生的时间。当我们要进行后续事件的准备工作时,根据后续最迟开始时间和准备活动的时间,在确保后继事件在最迟开始时间开始前,得到当前事件i的最迟开始时间。vl(i)=span style="color:red">MIN{vl(后继1)-准备活动1,vl(后继2)-准备活动2,···};终点的最迟开始时间为终点的最早开始时间ve。所有事件的最迟开始时间的计算要按照逆拓扑序列进行。

3.得到所有事件的最早开始时间和最迟开始时间后,我们要计算活动的最早开始时间和最迟开始时间。活动a的最早开始时间为活动的起点事件x最早开始时间,即e(a)=ve(x);活动a的最迟开始时间为该活动终点事件y的最迟开始时间和该活动所需的时间c之差,即l(a)=vl(y)-c;

4.比较活动的最早开始时间和最迟开始时间,如果相等则为关键路径中的关键活动。将所有关键活动联系起来就得到关键路径。

课堂拓展

set容器
set作为一个容器也是用来存储同一数据类型的数据类型,并且能从一个数据集合中取出数据,在set中每个元素的值都唯一,而且系统能根据元素的值自动进行排序。因为set中每个元素都唯一,所以可以用于计算种类。

函数:
begin()--返回指向第一个元素的迭代器
clear()--清除所有元素
count()--返回某个值元素的个数
empty()--如果集合为空,返回true
end()--返回指向最后一个元素的迭代器
equal_range()--返回集合中与给定值相等的上下限的两个迭代器
erase()--删除集合中的元素
find()--返回一个指向被查找到元素的迭代器
get_allocator()--返回集合的分配器
insert()--在集合中插入元素
lower_bound()--返回指向大于(或等于)某值的第一个元素的迭代器
key_comp()--返回一个用于元素间值比较的函数
max_size()--返回集合能容纳的元素的最大限值
rbegin()--返回指向集合中最后一个元素的反向迭代器
rend()--返回指向集合中第一个元素的反向迭代器
size()--集合中元素的数目
swap()--交换两个集合变量
upper_bound()--返回大于某个值元素的迭代器
value_comp()--返回一个用于比较元素间的值的函数

1.2.谈谈你对图的认识及学习体会。

在学习图的过程中,发现图的应用特别广泛,可以实现旅游规划路线,寻找最短路径,拓扑序列可以用于安排活动的先后顺序等等···在图的存储结构中,我们学习到了将数组和链表结合起来的邻接表,这对我来说是一个非常巧妙的存储结构,就好像一个图书馆(头结点好比带标签的书架,链表中每个邻接点就像一本本书一样)。在本章节中,我们学习了很多个算法,学到现在我还有点晕晕乎乎的,特别是求最小生成树和最短路径的四个算法,我在写代码的时候还经常会搞混掉,通过这次的学习总结,我对四个算法的知识进行了一次重新巩固,发现了之前根本就没有注意的问题,回想起我这段时间的学习态度,真真是太颓废了。这次实验报告有和同学一起交流经验,发现自己在刷pta时,大部分都是运用课件的代码,导致我写的部分代码有些赘余繁琐。想起我在预习时,遇到问题总是直接去翻课件找答案,没有自己一个独立思考的过程,这个问题非常非常严重!!再这样下去我就要变成一个搬运工了TAT!后续学习过程中一定一定要提醒自己,多独立思考,多创新!!

2.阅读代码

2.1 地图分析




class Solution {
public:
    int maxDistance(vector<vector<int>>& grid) {
        int n = grid.size();
        int dirs[4][2] = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}};
        queue<pair<int, int>> que;
        
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                if (grid[i][j] == 1) {
                    que.push(make_pair(i, j));
                }
            }
        }
        if (que.empty() || que.size() == n * n) return -1;

        pair<int, int> cur;
        while (!que.empty()) {
            cur = que.front();
            que.pop();
            for (int i = 0; i < 4; i++) {
                int r_ = cur.first + dirs[i][0];
                int c_ = cur.second + dirs[i][1];
                if (r_ >= 0 && r_ < n && c_ >= 0 && c_ < n && grid[r_][c_] == 0) {
                    grid[r_][c_] = grid[cur.first][cur.second] + 1;
                    que.push(make_pair(r_, c_));
                }
            }
        }
        return grid[cur.first][cur.second] - 1;
    }
};

作者:wonanut
链接:https://leetcode-cn.com/problems/as-far-from-land-as-possible/solution/c-bfsjie-fa-dai-shi-yi-tu-by-wonanut/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

2.1.1 该题的设计思路

这道题目有点类似迷宫。因为要求最短路径,所以采用BFS遍历。对于之前求迷宫的最短路径问题,我们只要求一个源点到终点的最短路径。而这道题目就类似于一个迷宫中有多个起点和终点,求从每个起点走出迷宫的最短路径中最长的路径,是一个多源求解最短路径。
这里采用的是多源的BFS算法:我们将陆地作为源点,然后从各个陆地开始往外一层一层的扩张,直到铺满整个地图,每个陆地最后遍历到的海洋就是最远的海洋。然后因为是多个源点一起同时开始扩散,每个海洋一定的都是被最近的陆地给扩散到的,且被扩散到之后直接标记为已被访问,这样就防止其他陆地继续访问该海洋,这里直接将扩散到的海洋区域的值修改为陆地的扩散层数。

虽然是求海洋到陆地的最短路径,但在该算法中,是以陆地为源点往外扩散,不可以以海洋为源点。如果以海洋为源点,那么在扩散过程中,我们就无法得知那个陆地是离海洋最近的,也就无法得知,哪个是最短路径,且多个海洋的最近陆地可能为同一个。

  • 时间复杂度:O(n2) ,起初需要遍历整个图寻找陆地;
  • 空间复杂度:O(n2),如果都为陆地,那么队列q需要n平方个位置存储;

2.1.2 该题的伪代码

grid[][]为地图;
定义一个队列que;

遍历地图,将所有陆地入队;
若没有陆地或者海洋,返回-1;

while(队列不为空)
   出队一个元素cur;
   向四周的扩散,寻找到未访问过的海洋
      将未访问过海洋标志为已访问过;
      将该块海洋入队;
end while
返回最后一次遍历到海洋的距离;

2.1.3 运行结果


2.1.4分析该题目解题优势及难点。

  • 优势:刚开始拿到这道题目时,我的思路是深度遍历找每个海洋到到陆地的最短路径,就是取一个起点,然后深度遍历寻找最短路径,再取下一个起点点,然后继续深度遍历寻找该起点的最短路径···最后再比较每个起点到终点的最短路径,找到最大的那一个。然后我也在题解中看到了这个算法,发现这个算法的时间复杂度高达O(n4)!这个数据在我看来是比较恐怖的。用多源BFS算法,时间复杂度为O(2),效率相对于我自己的方法来说是要大大提高的。
  • 难点:这里的难点在于考虑到用陆地作为源点往外扩散,是用终点去找起点的一种思路,但是我们惯常思维就是用起点去找终点,如果在这个算法中以海洋为源点往外扩散,就会出现问题。

2.2 不同的子序列



class Solution {
public:
    int numDistinct(string s, string t){
        vector<vector<long>> dp(t.size()+1, vector<long>(s.size()+1, 0));
        // 初始化第一行
        for(int j=0; j<=s.size(); ++j) dp[0][j] = 1;
        
        for(int i=1; i<=t.size(); i++)
            for(int j=1; j<=s.size(); j++){
                // 是否相等都要加上前面的值
                dp[i][j] = dp[i][j-1];
                // 相等时加上,上一个字符匹配得出的结果
                if(s[j-1] == t[i-1]) dp[i][j] += dp[i-1][j-1];
            }
        return dp[t.size()][s.size()];
    }
};

作者:Xdo
链接:https://leetcode-cn.com/problems/distinct-subsequences/solution/cong-bao-li-di-gui-dao-dong-tai-gui-hua-cong-dong-/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

2.2.1 该题的设计思路

动态规划:把原问题分解成若干个子问题进行求解,先求解子问题,然后从这些子问题得到原问题的解,不同的是,动态规划保存已解决的子问题的答案,便于在后面需要时能够马上得到,就可以节省大量的重复计算。这就是动态规划的核心。
用dp[i][j]表示:s的前 i 个字符(下标0到 i-1)有dp[i][j]种方法变为t的前 j 个字符(下标0到 j-1)。
初始化第一行数据为1,表示当子串为空时,在主串中只有一个子序序列。第一列除第一行以外,其他初始化为0,表示主串为空时,一个子串序列都没有。
比较s[i-1]和t[j-1]:因为二维数组中第一行和第一列表示的是主串和子串为空集的情况,从第二行和第二列开始才是主串和子串不为空集时的内容,所以dp[i][j]是对应的主串字符为s[i-1],子串字符为t[j-1]。
s[i-1]=t[j-1]时,不保留s[i-1],有dp[i-1][j]种方法。即:不使用s的第i个字符,s的前 i-1 个字符有多少种方法变为t的前 j 个字符。故s[i-1]==t[j-1]时,dp[i][j] = dp[i-1][j-1]+dp[i-1][j]。
s[i-1]!=t[j-1]时,有dp[i-1][j]种方法。即:已知s的第 i 个字符不能与t的第 j 个字符对应,s的前 i-1 个字符有多少种方法变为t的前 j 个字符。

  • 时间复杂度:O(MXN),M为主串长度,N为子串长度;
  • 空间复杂度:O(MXN),建立二维数组dp;

2.2.2 该题的伪代码

初始化第一行,标记为1;
for i=1 to 子串的长度
   for j=1 to 主串的长度
      dp[i][j]=dp[i][j-1];//首先赋值为未访问主串当前位置字符时的子序列个数;
      if (s[j-1]==t[i-1])//字符相等
         dp[i][j]+=dp[i-1][j-1];
      end if
   end for
end for
return dp[t.size()][s.size()];

2.2.3 运行结果

2.2.4分析该题目解题优势及难点。

  • 优势:起初看到这个题目我心里还是只有暴力法...但是暴力法的一个算法复杂度是十分恐怖的,数据稍微一大,就会崩。这里使用的动态规划可以将之前的结果保存起来,以便后面的计算,在时间复杂度上是大大的减少了。且该代码简洁,易于实现;
  • 难点:因为刚刚接触动态规划,不是很熟悉,比较难想到。还有二维数组中比较的是s[j-1]和t[i-1]是否相等,刚开始没有认真看,以为比较的是前面的字符,然后把结果记在后面一个字符的位置。然后才发现是因为二维数组把第一行位置拿去存放主串为空集的情况,把第一列位置存放子串为空集的情况,从第二行和第二列开始才是主串和子串的内容。

2.3 接雨水


class Solution {
public:
    //以最大值分界,左边非减,右边非增
    int trap(vector<int>& height) {
        int n=height.size();
        if(n==0) return 0;
        int m=max_element(height.begin(),height.end())-height.begin();
        //遍历最大值左边
        int res=0,cur=height[0];
        for(int i=1;i<m;i++)
        {
            if(height[i]<cur)
                res+=cur-height[i];
            else
                cur=height[i];
        }
        //遍历最大值右边
        cur=height[n-1];
        for(int i=n-2;i>m;i--)
        {
            if(height[i]<cur)
                res+=cur-height[i];
            else
                cur=height[i];
        }
        return res;
    }
};

作者:duan-she-chi-8
链接:https://leetcode-cn.com/problems/trapping-rain-water/solution/zhao-gui-lu-tou-guo-xian-xiang-kan-ben-zhi-by-duan/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

2.3.1 该题的设计思路

我们先寻找最高的一根柱子,将这个最高的柱子作为左半部分的右边界,和右半部分的左边界。取左部分来说:因为是最高,所以在往左部分注水时,就无需考虑右边界,不管怎么样,右边界一定会顶住,所以我们只需要考虑左边界就行。从最左边开始不断的往最高柱子缩进,每遍历一个位置时,其注水量不得超过左边界。如果在遍历时遇到跟高的柱子,那就更换左边界,继续往最高柱子缩进。右部分同理。


  • 时间复杂度:O(n),所有的位置都只遍历了一次;
  • 空间复杂度:O(1),没有开辟新的空间;

2.3.2 该题的伪代码

height数组保存每个位置的柱子高度;
定义变量res用于记录总储水量;
定义变量cur为左/右部分的左/右边界的柱子高度;

取高度最高的柱子,其位置为m;
定义第一个位置为左部分的左边界,左边界高度为cur=height[0];
for i=1 to m //遍历最高柱子的左部分
  if 当前位置柱子高度height[i] < 左边界高度cur
      当前位置可储水 cur - height[i],res+=当前位置可储水量;
  else 
      修改左边界,新的左边界柱子高度为cur=height[i];
  end if
end for
取最后一个位置为右部分的右边界,右边界高度为cur=height[n-1];
for i=n-2 to m//遍历最高柱子的右部分
   if 当前位置柱子高度height[i] < 右边界高度cur
      当前位置可储水 cur - height[i],res+=当前位置可储水量;
  else 
      修改右边界,新的右边界柱子高度为cur=height[i];
  end if
end for
return res;

2.3.3 运行结果

2.3.4分析该题目解题优势及难点。

  • 优势:该算法的时间和空间复杂度都比较小,代码看起来也清晰明了,容易理解。
  • 难点:这道题主要的难点我认为是首先选取最高的柱子,将该图分为两个部分,以最高柱子作为两个部分中的边界。这个思路很巧妙,不是很容易想到。
原文地址:https://www.cnblogs.com/xianerbian/p/12825398.html