0.PTA得分截图
1.本周学习总结
1.1 总结图内容
图存储结构
- 邻接矩阵存储
1.图的邻接矩阵类型的声明
#define MAXV <最大顶点个数>
# define INF 32767//定义∞
typedef struct
{
int no;//顶点的编号
InfoType info;//顶点的其他信息
} VertexType;//顶点的类型
typedef struct
{
int edges[MAXV][MAXV];//邻接矩阵数组
int n, e;//顶点数,边数
VertexType vexs[MAXV];//存放顶点信息
} MatGraph;//完整的图邻接矩阵类型
2.邻接矩阵建图
void CreateMGraph(MGraph& g, int n, int e)//建图
{
int i;
int j;
int v1, v2;//一条边的两个端点的编号
for (i = 1; i <= n; i++)
{
for (j = 1; j <= n; j++)
{
g.edges[i][j] = 0;//初始化邻接矩阵
}
}
for (i = 0; i < e; i++)
{
cin >> v1 >> v2;//输入每条边的两个顶点
g.edges[v1][v2] = 1;//因为是无向图,所以对应的两个都需要变为1
g.edges[v2][v1] = 1;
}
g.n = n;//将顶点数存储
g.e = e;//将边数存储
}
3.邻接矩阵的特点
(1)图的邻接矩阵表示是唯一的。
(2)对于含有n个顶点的图,当采用邻接矩阵存储时,无论是有向图还是无向图,也无论边的数目是多少,其存储空间都为O(n^2),所以邻接矩阵适合于存储边的数目较多的稠密图。
(3)无向图的邻接矩阵数组一定是一个对称矩阵,因此可以采用压缩存储的思想,在存放邻接矩阵数组时只需存放上(或下)三角部分的元素即可。
(4)对于无向图,邻接矩阵数组的第i行或第i列非零元素、非∞元素的个数正好是顶点i的度。
(5)对于有向图,邻接矩阵数组的第i行(或第i列)非零元素、非∞元素的个数正好是顶点i的出度(或入度)。
(6)在邻接矩阵中,判断图中两个顶点之间是否有边或者求两个顶点之间边的权的执行时间为0(1)。所以在需要提取边权值的算法中通常采用邻接矩阵存储结构。
- 邻接表存储
1.图的邻接表存储类型的声明
#define MAX_VERTEX_NUM 20//最大顶点个数
#define VertexType int//顶点数据的类型
#define InfoType int//图中弧或者边包含的信息的类型
typedef struct ArcNode{
int adjvex;//邻接点在数组中的位置下标
struct ArcNode * nextarc;//指向下一个邻接点的指针
InfoType * info;//信息域
}ArcNode;
typedef struct VNode{
VertexType data;//顶点的数据域
ArcNode * firstarc;//指向邻接点的指针
}VNode,AdjList[MAX_VERTEX_NUM];//存储各链表头结点的数组
typedef struct {
AdjList vertices;//图中顶点的数组
int vexnum,arcnum;//记录图中顶点数和边或弧数
int kind;//记录图的种类
}ALGraph;
2.邻接表建图
void CreateAdj(AdjGraph*& G, int A[MAXV][MAXV], int n, int e)//创建图的邻接表
{
int i, j;
ArcNode* p;
G = (AdjGraph*)malloc(sizeof(AdjGraph));
for (i = 0; i < n; i++)//给邻接表中所有头结点的指针域置初值
G->adjlist[i].firstarc = NULL;
for (i = 0; i < n; i++)//检查邻接矩阵中的每个元素
for (j = n - l; j >= 0; j --)
if (A[i][j] != 0 && A[i][j] != INF)//存在一条边
{
p = (ArcNode*)malloc(sizeof(ArcNode));//创建 一个结点p
p->adjvex = j;//存放邻接点
p->weight = A[i][j];//存放权
p->nextarc = G->adjlist[i].firstarc;//采用头插法插人结点p
G->adjlist[i.firstarc = p;
}
G->n = n; G->e = n;
}
3.邻接表的特点
(1)邻接表的表示不唯一,这是因为在每个顶点对应的单链表中各边结点的链接次序可以是任意的,取决于建立邻接表的算法以及边的输入次序。
(2)对于有n个顶点和e条边的无向图,其邻接表有n个头结点和2e个边结点;对于有n个顶点和e条边的有向图,其邻接表有n个头结点和e个边结点。显然,对于边数目较少的稀疏图,邻接表比邻接矩阵更节省存储空间。
(3)对于无向图,邻接表中顶点i对应的第i个单链表的边结点数目正好是顶点i的度。
(4) 对于有向图,邻接表中顶点i对应的第i个单链表的边结点数目仅仅是顶点i的出度。顶点i的人度为邻接表中所有adjvex域值为i的边结点数目。
(5)在邻接表中,查找顶点i关联的所有边是非常快速的,所以在需要提取某个顶点的所有邻接点的算法中通常采用邻接表存储结构。
图遍历及应用
-
从给定图中任意指定的顶点(称为初始点)出发,按照某种搜索方法沿着图的边访问图中的所有顶点,使每个顶点仅被访问一次,这个过程称为图的遍历。
-
根据搜索方法的不同,图的遍历方法有两种:一种叫深度优先遍历(Depth First Search,DFS),另一种叫广度优先遍历(Breadth First Search, BFS)。
-
深度优先遍历(DFS)
1.遍历过程
从图中某个初始顶点v出发,首先访问初始顶点v,然后选择一个与顶点v相邻且没被访问过的顶点w为初始顶点,再从w出发进行深度优先搜索,直到图中与当前顶点v邻接的所有顶点都被访问过为止。这样的遍历方式就是深度优先遍历。
深度优先搜索用的是一种比较著名的算法思想,回溯思想。这种思想解决问题的过程,非常适合用递归来实现。
2.具体代码
void DFS(AdjGraph* G, int v)//v节点开始深度遍历
{
ArcNode* p;
visited[v] = 1;
cout << v;
p = G->adjlist[v].firstarc;
while (p)
{
if (!visited[p->adjvex])
{
DFS(G, p->adjvex);
}
p = p->nextarc;
}
}
遍历序列:V1、V2、V4、V5、V8、V3、V6、V7
3.时间复杂度分析
当用邻接表表示图时,需要遍历该顶点的所有邻接点,所以DFS的总时间为O(n+e);当用邻接矩阵表示图时,需要遍历该顶点行的所有元素,所以DFS的总时间为O(n^2)。
- 广度优先遍历(BFS)
1.遍历过程
广度优先遍历的过程是首先访问初始点v,接着访问顶点v的所有未被访问过的邻接点v1,v2,..,vt,然后再按照v1,v2,..,vt的次序访问每一个顶点的所有未被访问过的邻接点,依此类推,直到图中所有和初始点v有路径相通的顶点都被访问过为止。
2.具体代码
void BFS(AdjGraph* G, int v)//v节点开始广度遍历
{
queue<int>q;
ArcNode* node;
int n;//表示边的序号
int i;
visited[v] = 1;//改路径信息
cout << v;
q.push(v);//入队
while (!q.empty()//队不空
{
i = q.front();
q.pop();
node = G->adjlist[i].firstarc;
while (node)//按邻接表输出头结点后的所有节点
{
if (!visited[node->adjvex])
{
visited[node->adjvex] = 1;
cout << node->adjvex;
q.push(node->adjvex);
}
node = node->nextarc;
}
}
}
遍历序列:V1、V2、V3、V4、V5、V6、V7、V8
3.时间复杂度分析
对于具有n个顶点、e条边的有向图或无向图,在BFS算法中每个顶点都进队一次,因此执行时间与DFS相同。当图采用邻接表表示图时,BFS的总时间为O(n+e);当图采用邻接矩阵表示图时,BFS的总时间为O(n^2)。
- 非联通图的遍历
1.深度优先遍历非连通无向图
DFS(AdjGraph* G)
{
int i;
for (i = 0; i < G->n; i++)
if (visited[i] == 0) DFS(G, i);
}
2.广度优先遍历非连通无向图
BFS(AdjGraph* G)
{
int 1;
for (i = 0; i < G->n; i++)
if (visited[i] == 0) BFS(G, i);
}
- 图遍历算法的应用
1.判断图是否连通
进行图的遍历,同时记录路径visited[],如果是连通图,深度遍历经过图中所有的点,该点的visited[]值改变;否则,不是连通图,无法遍历所有的顶点,visited数组中存在数值为0的点。
bool check(AdjGraph* G)
{
int i;
bool flag = true;
for (i = 0; i < G->n; i++)//初始化visited数组
{
visited[i] = 0;
}
DFS(G, v);
for (i = 0; i < G->n; i++)
{
if (visited[i] == 0)//出现为0的点说明不连通
{
flag = false;
break;
}
}
return flag;
}
2.假设图G采用邻接表存储,判断图G中从顶点u到v是否存在简单路径
所谓简单路径是指路径上的顶点不重复。采用深度优先遍历的方法,从顶点u出发遍历到顶点v,为此在深度优先遍历算法的基础上增加v和
has两个形参,其中has表示顶点u到v是否有路径,其初值为false,当从顶点u遍历到顶点v后,置has为true并返回。
void ExistPath(AdjGraph* G, int u, int v, bool& has)
{//has表示u到v是否有路径,初值为false
int w; ArcNode* p;
visited[u] = 1;//置已访问标记
if (u == v)//找到了一条路径
{
has = true;//置has为true并返回
return;
}
p = G->adjlist[u].firstarc;//p 指向顶点u的第一个邻接点
while (p != NULL)
{
w = p->adjvex;//w为顶点u的邻接点
if (visited[w] == 0)//若w顶点未访问,递归访问它
ExistPath(G, w, V, has);
p = p->nextarc;//p指向顶点u的下一个邻接点
}
}
3.假设图G采用邻接表存储,找出输出图G中从顶点u到v的所有简单路径(假设图G中从顶点u到v至少有一条简单路径)
本题利用回溯的深度优先遍历方法,由于在遍历过程中每个顶点只访问一次,所以这条路径必定是一条简单路径。在深度优先遍历算法的基础上增加v、path和d几个形参,其中path存放顶点u到v的路径,d表示path中的路径长度,其初值为-1。
当从顶点u出发遍历时,先将visited[u]置为1,并将u加到路径path中,如果满足顶点u就是终点v的条件,则表示找到了一条从顶点u到v的简单路径,输出path。再从终点v回退(置v的访问标记为0)继续找其他路径,也就是说,允许曾经访问过的顶点出现在另外的路径中。
void FindAllPath(AdjGraph* G, int u, int v, int path[], int d)
{//d 表示path中的路径长度,初始为-1
int w, i;
ArcNode* p;
d++; path[d] = u;//路径长度d增1,顶点u加人到路径中
visited[u] = l;//置已访问标记
if (u == v && d >= 0)//若找到一条路径则输出
{
for (i = 0; i <= d; i++)
printf(" %2d",path[i]);
printf("
");
}
p = G->adjlist[u].firstarc;//p指向顶点u的第一个邻接点
while (p != NULL)
{
w = p->adjvex;//w为顶点u的邻接点
if (visited[w] == 0)//若w顶点未被访问,递归访问它
FindAllPath(G, w, v, path, d);
p = p->nextarc;//p指向顶点u的下一个邻接点
visited[u] = 0;//恢复环境,使该顶点可重新使用
}
}
最小生成树
-
一个连通图的生成树是一个极小连通子图,其中含有图中的全部顶点,和构成一棵树的(n-1)条边。如果在一棵生成树上添加任何一条边,必定构成一个环,因为添加的这条边使得它关联的那两个顶点之间有了第2条路径。
-
对于一个带权(假设每条边上的权均为大于零的实数)连通无向图G中的不同生成树,其每棵树的所有边上的权值之和也可能不同;图的所有生成树中具有边上的权值之和最小的树称为图的最小生成树。
-
按照生成树的定义,n个顶点的连通图的生成树有n个顶点、(n-1)条边。因此,构造最小生成树的准则有以下3条:
- 必须只使用该图中的边来构造最小生成树;
- 必须使用且仅使用(n- 1)条边来连接图中的n个顶点;
- 不能使用产生回路的边。
-
求图的最小生成树的两个算法为:普里姆算法和克鲁斯卡尔算法。
-
普里姆(Prim)算法
1.普里姆(Prim)算法是一种构造性算法。假设G=(V,E)是一个具有n个顶点的带权连通图,T=(U,TE)是G的最小生成树,其中U是T的顶点集,TE是T的边集,则由G构造从起始点v出发的最小生成树T的步骤如下:
(1)初始化U={v},以v到其他顶点的所有边为候选边。
(2)重复以下步骤(n-1)次,使得其他(n-1)个顶点被加入到U中。
①从候选边中挑选权值最小的边加入TE,设该边在V-U中的顶点是k,将k加入U中;
②考查当前V-U中的所有顶点j,修改候选边,若(k,j)的权值小于原来和顶点j关联的候选边,则用(k,j)取代后者作为候选边。
2.具体代码
void Prim(MatGraph g, int v)//Prim算法
{
int lowcost[MAXV];
int MIN;
int closest[MAXV], i,j, k;
for (i = 0; i < g.n; i++)//给lowcost[]和closest[]置初值
{
lowcost[i] = g.edges[v];
closest[i] = v;
}
for (i = 1; i < g.n; i++)//找出(n一1)个顶点
{
MIN = INF;
for (j = 0; j < g.n; j++)//在(V-U)中找出离U最近的顶点k
if (lowcost[j] != 0 & &lowcost[i] < MIN)
{
MIN = lowcost[j];
k = j;//k 记录最近顶点的编号
}
printf("边(%d, %d)权为: %d
", closest[k], k, MIN); //输 出最小生成树的一条边
lowcost[k] = 0;//标记k已经加入U
for (j = 0; j < g.n; j++)//对(V-U)中的顶点j进行调整
if (lowcost[j] != 0 && g.edges[k][j] < lowcost[j])
{
lowcost[j] = g.edges[k][j];
closest[i] = k;//修改数组lowcost和closest
}
}
}
3.复杂度分析
Prim()算法中有两重for循环,所以时间复杂度为O(n^2),其中n为图的顶点个数。由于Prim()算法的执行时间与图中的边数e无关,所以它特别适合用稠密图求最小生成树。
- 克鲁斯卡尔(Kruskal)算法
1.克鲁斯卡尔(Kruskal)算法是一种按权值的递增次序选择合适的边来构造最小生成树的方法。假设G=(V ,E)是一个具有n个顶点的带权连通无向图,T=(U,TE)是G的最小生成树,则构造最小生成树的步骤如下:
(1)置U的初值为V(即包含有G中的全部顶点),TE的初值为空集(即图T中的每一个顶点都构成一个分量)。
(2)将图G中的边按权值从小到大的顺序依次选取,若选取的边未使生成树T形成回路,则加入TE,否则舍弃,直到TE中包含(n-1)条边为止。
2.具体代码
void Kruskal(MatGraph g)//Kruskal算法
{
int i, j, k, u1, v1, sn1, sn2;
UFSTree t[MaxSize];
Edge E[MaxSize];
k = 1;//e 数组的下标从1开始计
for (i = 0; i < g.n; i++)//由g产生的边集E
for (j = 0; j <= i; j++)
if (g.edges[i][j] != 0 && g.edges[i][j] != INF)
{
E[k].u = i;
E[k].v = j;
E[k].w = g.edges[i][j];
k++;
}
HeapSort(E, g.e);//采用堆排序对E数组按权值递增排序
MAKE_SET(t, g.n);//初始化并查集树t
k = 1;//k表示当前构造生成树的第几条边,初值为1
j = 1;//E中边的下标从1开始
while (k < g.n)//生成的边数小于n时循环
{
u1 = E[j].u;
v1 = E[j].v;//取一条边的头尾顶点编号ul和v2
sn1 = FIND_SET(t, u1);
sn2 = FIND_SET(t, v1);//分别得到两个顶点所属的集合编号
if (sn1 != sn2)//两顶点属于不同的集合,该边是最小生成树的一条边
{
printf(" (%d, %d):%d
", u1, v1, E[j].w);
k++;//生成边数增1
UNION(t, u1, v1);//将ul和vl两个顶点合并
}
j++;//扫描下一条边
}
}
3.复杂度分析
如果给定的带权连通图G有n个顶点、e条边,Kruskal算法中不考虑生成边数组E的过程,堆排序的时间复杂度为O(elog2e)。while循环是在e条边中选取(n-1)条边,其中的UNION( )的执行时间为0(log2n), 因此while 循环的时间复杂度为O(elog2n)。对于连通无向图,e≥n-1,那么Kruskal算法构造最小生成树的时间复杂度为O(elog2e)。可以看出, Kruskal算法的执行时间仅与图中的边数有关,与顶点数无关,所以它特别适合用稀疏图求最小生成树。
最短路径
-
在一个不带权图中,若从一顶点到另一顶点存在着一条路径,则称该路径长度为该路径上所经过的边的数目,它等于该路径上的顶点数减1。由于从一顶点到另一顶点可能存在着多条路径,每条路径上所经过的边数可能不同,即路径长度不同,把路径长度最短(即经过的边数最少)的那条路径称为最短路径,其长度称为最短路径长度或最短距离。
-
狄克斯特拉(Dijkstra)算法——从一个顶点到其余各顶点的最短路径
1.算法思想:给定一个带权有向图G与源点v,求从源点v到G中其他顶点的最短路径,并限定各边上的权值大于0。采用狄克斯特拉(Dijkstra)算法求解,其基本思想是,设G=(V,E)是一个带权有向图,把图中的顶点集合V分成两组,第1组为已求出最短路径的顶点集合(用S表示,初始时S中只有一个源点,以后每求得一条最短路径v、...、u,就将顶点u加入到集合S中,直到全部顶点都加入到S中,算法就结束了),第2组为其余未确定最短路径的顶点集合(用U表示),按最短路径长度的递增次序依次把第2组的顶点加入S中。
2.操作步骤:
(1)初始时,S只包含起点s;U包含除s外的其他顶点,且U中顶点的距离为“起点s到该顶点的距离”[例如,U中顶点v的距离为(s,v)的长度,然后s和v不相邻,则v的距离为∞]。
(2)从U中选出“距离最短的顶点k”,并将顶点k加入到S中;同时,从U中移除顶点k。
(3)更新U中各个顶点到起点s的距离。之所以更新U中顶点的距离,是由于上一步中确定了k是求出最短路径的顶点,从而可以利用k来更新其它顶点的距离;例如,(s,v)的距离可能大于(s,k)+(k,v)的距离。
(4)重复步骤(2)和(3),直到遍历完所有顶点。
3.具体代码
void Dijkstra(MatGraph g,int v)//迪杰斯特拉算法
{
int dist[MAXV],path[MAXV];
int s[MAXV];
int mindis;
int i, j, u;
for (i = 0; i < g.n; i++)
{
dist[i] = g.edges[v][i];//距离初始化
s[i] = 0;//s[]置空
if (g.edges[v][i] < INF)//路径初始化
path[i] = v;//顶点v到i有边时
else
path[i] = -1;//顶点v到i没边时
}
s[v] = 1;
for (i = 0; i < g.n; i++)//循环n-1次
{
mindis = INF;
for (j = 0; j < g.n; j++)
if (s[j] == 0 && dist[j] < mindis)
{
u = j;
mindis = dist[j];
}
s[u] = 1;//顶点u加入S中
for (j = 0; j < g.n; j++)//修改不在s中的顶点的距离
if (s[j] == 0)
if (g.edges[u][j] < INF && dist[u] + g.edges[u][j] < dist[j])
{
dist[j] = dist[u] + g.edges[u][j];
path[j] = u;
}
}
Dispath(dist, path, s, g.n, v);//输出最短路径
}
4.复杂度分析
不考虑路径的输出,Dijkstra算法的时间复杂度为O(n^2),其中n为图中顶点的个数。
- 弗洛伊德(Floyd)算法——每对顶点之间的最短路径
1.算法分析:对于一个各边权值均大于零的有向图,对每一对顶点i≠j,求出顶点i与顶点j之间的最短路径和最短路径长度。可以通过以每个顶点作为源点循环求出每对顶点之间的最短路径。除此之外,弗洛伊德(Floyd)算法也可用于求两顶点之间的最短路径。
2.操作步骤:
(1)定义两个二维数组,A[i][j]表示顶点i到j的最短路径长度,path[i][j]表示对应路径的前继。
(2)考虑顶点x,修改数组A和path,(与这个点相关的路径长度不变,比如下标中有x的)
(3)重复考虑点直到所有点都遍历过。
(4)最后,根据数组path从终点找前继,直到起点,对应点就是路径的逆序列。数组A[起点编号][终点编号]就是所求的路径长度。
3.具体代码
void Floyd(MatGraph g)//弗洛伊德算法,求每对顶点之间的最短路径
{
int A[MAXVEX][MAXVEX];//建立A数组
int path[MAXVEX][MAXVEX];//建立path数组
int i, j, k;
for (i = 0; i < g.n; i++)
for (j = 0; j < g.n; j++)
{
A[i][j] = g.edges[i][j];
if (i != j && g.edges[i][j] < INF)
path[i][j] = i;//i和j顶点之间有一条边时
else
path[i][j] = -1;//i和j顶点之间没有一条边时
}
for (k = 0; k < g.n; k++)//求A[i][j]
{
for (i = 0; i < g.n; i++)
for (j = 0; j < g.n; j++)
if (A[i][j] > A[i][k] + A[k][j])//找到更短路径
{
A[i][j] = A[i][k] + A[k][j];//修改路径长度
path[i][j] = k;//修改经过顶点k
}
}
}
4.复杂度分析
不考虑路径输出,Floyd算法的时间复杂度为O(n^3),其中n为图中顶点的个数。
【最短路径Floyd算法详解推导过程】(https://www.jianshu.com/p/db0df9197073)
拓扑排序
-
概念:设G=(V,E)是一个具有n个顶点的有向图,V中的顶点序列v1,v2,…,vn,称为一个拓扑序列。若<vi,vj>是图中的一条边或者从顶点vi到顶点vj有路径,则在该序列中顶点vi必须排在顶点vj之前。所以在一个有向图中找一个拓扑序列的过程称为拓扑排序。即在一个有向无环图中找拓扑序列,要保证每个点只出现一次,且如果有A到B的一条路径存在,A必须排在B前面。也就是被指向的必须排在后面。
-
思路:
(1)从有向图中选择一个没有前驱(即入度为0)的顶点并且输出它。
(2)从图中删去该顶点,并且删去从该顶点发出的全部有向边。
(3)重复上述两步,直到剩余的图中不再存在没有前驱的顶点为止。
(4)这样操作的结果有两种:一种是图中全部顶点都被输出,即该图中所有顶点都在其拓扑序列中,这说明图中不存在回路;另一种就是图中顶点未被全部输出,这说明图中存在回路。所以可以通过对一个有向图进行拓扑排序,看是否产生全部顶点的拓扑序列来确定该图中是否存在回路。 -
图解:
1:删除1或2输出
2:删除2或3以及对应边
3:删除3或者4以及对应边
4:重复以上规则步骤
- 具体代码
typedef struct//表头节点类型
{
vertex data;//顶点信息
int count;//存放顶点入度
ArcNode* firstarc;//指向第一条弧
} VNode;//结构体定义
void TopSort(AdjGraph* G)//拓扑排序算法
{
int i,j;
int St[MAXV],top = -1;//栈St的指针为top
ArcNode* p;
for (i = 0; i < G->n; i++)//入度置初值0
G->adjlist[i].count = 0;
for (i = 0; i < G->n; i++)//求所有顶点的入度
{
p = G->adjlist[i].firstarc;
while (p != NULL)
{
G->adjlist[p->adjvex].count++;
p = p->nextarc;
}
}
for (i = 0; i < G->n; i++)//将入度为0的顶点进栈
if (G->adjlist[i].count == 0)
{
top++;
St[top] = i;
}
while (top > -1)//栈不空循环
{
i = St[top]; top--;//出栈一个顶点i
cout << i;//输出该顶点
p = G->adjlist[i].firstarc;//找第一个邻接点
while (p != NULL)//将顶点i的出边邻接点的入度减1
{
j = p->adjvex;
G->adjlist[j].count--;
if (G->adjlist[j].count == 0)//将入度为0的邻接点进栈
{
top++;
St[top] = j;
}
p = p->nextarc;//找下一个邻接点
}
}
}
关键路径
-
概念:关键路径是指设计中从输入到输出经过的延时最长的逻辑路径。
-
思路:
- 对图进行拓扑排序。
- 在拓扑排序得到的序列的基础上,计算出边的最早开始时间和最晚开始时间,分别得到ve和vl数组。ve是当前点到起始点的最长路径,vl有点像是找当前点到终点的最长路径,然后用ve[终点]-最长路径;
- 计算所有边的e和l,其中,对边,e=ve[i],l=vl[l]-边的权值
- 如果e=l,那么它就是关键活动,而所有的关键活动相连,就是它的关键路径。
1.2 谈谈你对图的认识及学习体会
- 图是一种比较复杂的非线性结构,它比起树结构会更加复杂,因为它的节点之间是多对多的关系。
- 在图的应用中有最小生成树和最短路径两个概念,求最小生成树使用的算法是Prim算法、Kruskal算法,求最短路径运用的算法是Dijkstra算法、Floyd算法,这几种算法非常容易混淆,一般都是在草稿纸上通过画图来寻找答案,不仅是不好理解,代码也不容易明白,还有对于其复杂度的分析也比较难懂。
- 图的学习综合了前面的许多知识点,光是理解题目就往往需要花费很多功夫,而且画图也不一定能够做明白。
- 对于PTA上的作业也是写得很想原地挖个坑把自己埋了啊,只想感叹好难好难好难……还需要再继续研究,还有综合前面的知识点,画图实践很重要!!!加油ヾ(◍°∇°◍)ノ゙!!!
2.阅读代码
2.1 找到最终的安全状态
- 题目
在有向图中, 我们从某个节点和每个转向处开始, 沿着图的有向边走。 如果我们到达的节点是终点 (即它没有连出的有向边), 我们停止。
现在, 如果我们最后能走到终点,那么我们的起始节点是最终安全的。 更具体地说, 存在一个自然数 K, 无论选择从哪里开始行走, 我们走了不到 K 步后必能停止在一个终点。
哪些节点最终是安全的? 结果返回一个有序的数组。
该有向图有 N 个节点,标签为 0, 1, ..., N-1, 其中 N 是 graph 的节点数. 图以以下的形式给出: graph[i] 是节点 j 的一个列表,满足 (i, j) 是图的一条有向边。
示例:
输入:graph = [[1,2],[2,3],[5],[0],[5],[],[]]
输出:[2,4,5,6]
这里是上图的示意图:
提示:
graph 节点数不超过 10000.
图的边数不会超过 32000.
每个 graph[i] 被排序为不同的整数列表, 在区间 [0, graph.length - 1] 中选取。
- 代码
class Solution {
public:
vector<int> eventualSafeNodes(vector<vector<int>>& graph)
{
vector<int> resVec;
int graphSize = graph.size();
vector<int> hashMap(graphSize, -1);
vector<bool> visited(graphSize, false);
for (int index = 0; index < graphSize; ++index)
{
if (eventualSafeNodes(graph, visited, hashMap, index))
{
resVec.push_back(index);
}
}
return resVec;
}
int eventualSafeNodes(vector<vector<int>>& graph, vector<bool>& visited, vector<int>& hashMap, int nowIndex)
{
if (hashMap[nowIndex] > -1)
{
return hashMap[nowIndex];
}
if (visited[nowIndex])
{
hashMap[nowIndex] = 0;
return hashMap[nowIndex];
}
visited[nowIndex] = true;
int tempVal = 1;
for (auto& nextIndex : graph[nowIndex])
{
tempVal = min(tempVal, eventualSafeNodes(graph, visited, hashMap, nextIndex));
}
hashMap[nowIndex] = tempVal;
return hashMap[nowIndex];
}
};
2.1.1 该题的设计思路
- 这道题是典型的图深度优先搜索。这里的graph采用邻接表的形式,即graph[i]表示节点i能够到达graph[i]里面的点。我们如果判断点index是否符合要求,只需要判断graph[index]是否都是合法,这是一个递归操作,只有当graph[index]中的每一个点都合法,则index才合法。
- 而如果graph[i].size() == 0,说明i节点是合法的;否则,如果从index往下搜索路径中出现某个点访问了多次的情况,则这条路径中的所有点都是不合法的。
- 复杂度分析
- 时间复杂度:O(N + E),其中 N 是图中的节点数,E 是图中的边数。
- 空间复杂度:O(N)。
2.1.2 该题的伪代码
vector<int> eventualSafeNodes(vector<vector<int>>& graph)
{
hashMap[i] == 1表示i符合要求, == 0表示不符合要求, == -1表示还未确定符不符合要求,是初始化状态
当前深度优先搜索已经走过的节点
由于需要按节点顺序确定是否符合要求,所以需要顺序访问
for (int index = 0 to < graphSize,++index)
{
if (nowIndex的合法)
{
resVec.push_back(index);
}
}
返回resVec;
}
判断nowIndex的合法性,visited是从起点index这条路径中已经访问过的点,返回1说明nowIndex合法,返回0说明不合法
int eventualSafeNodes(vector<vector<int>>& graph, vector<bool>& visited, vector<int>& hashMap, int nowIndex)
{
if (hashMap[nowIndex] > -1)
{
如果这个点在备忘录中,即合法性已经判断过了,直接放回
}
if (visited[nowIndex])
{
表示如果这个点已经访问过,说明出现了循环,必定不合法
}
否则,确定graph[nowIndex]中的所有点是否合法
标记访问visited[nowIndex]
tempVal 初始化为 1;
for (auto& nextIndex : graph[nowIndex])
{
eventualSafeNodes判断nextIndex的合法性,如果合法返回1,不合法返回0
}
现在nowIndex的合法性已经可以确定了,nowIndex的hashMap[]赋值
返回符合要求的点
}
2.1.3 运行结果
2.1.4 分析该题目解题优势及难点
- 优势:对于一个节点 u,如果我们从 u 开始任意行走能够走到一个环里,那么 u 就不是一个安全的节点。换句话说,u 是一个安全的节点,当且仅当 u 直接相连的节点(u 的出边相连的那些节点)都是安全的节点。因此我们需要首先考虑没有任何出边的节点,它们一定都是安全的。随后我们再考虑仅与这些节点直接相连的节点,它们也一定是安全的,以此类推。这样我们可以将所有的边全部反向,首先所有没有任何入边的节点都是安全的,我们把这些节点全部移除。随后新的图中没有任何入边的节点都是安全的,以此类推。而这种做法实际上就是对图进行拓扑排序。
- 难点:拿例题来讲,这道题的意思就是:比如graph[0]里面有1和2,意思是可以从0到1,从0到2;graph[1]里面有2和3,就是指可以从1到2和从1到3,以此类推,刚开始看了半天才读懂题目的意思,而最终要找出来的是从这一点开始最终一定可以停止的起始点,不太好理解。
2.2 网络延迟时间
- 题目
有 N 个网络节点,标记为 1 到 N。
给定一个列表 times,表示信号经过有向边的传递时间。 times[i] = (u, v, w),其中 u 是源节点,v 是目标节点, w 是一个信号从源节点传递到目标节点的时间。
现在,我们从某个节点 K 发出一个信号。需要多久才能使所有节点都收到信号?如果不能使所有节点收到信号,返回 -1。
示例:
输入:times = [[2,1,1],[2,3,1],[3,4,1]], N = 4, K = 2
输出:2
注意:
N 的范围在 [1, 100] 之间。
K 的范围在 [1, N] 之间。
times 的长度在 [1, 6000] 之间。
所有的边 times[i] = (u, v, w) 都有 1 <= u, v <= N 且 0 <= w <= 100。
- 代码
class Solution:
def networkDelayTime(self, times: List[List[int]], N: int, K: int) -> int:
dist = {i: float('inf') for i in range(1, N+1)}
dist[K] = 0
res = {}
while dist:
min_dis = min(dist.values())
if min_dis == float('inf'):
return -1
for key, v in dist.items():
if v == min_dis:
ind = key
for time in times:
if time[0] == ind and time[1] not in res.keys():
dist[time[1]] = min(dist[time[1]], dist[time[0]]+time[2])
res[ind] = min_dis
dist.pop(ind)
return max(res.values())
2.2.1 该题的设计思路
- 1.新建距离列表,保存起始节点至每个节点(包括自己)的距离,初始化自己到自己的距离为0,到其他节点距离为正无穷(或者一个特别大的数);
- 2.从距离列表中获取一个最小距离的点,即该点的最短路径已经确定,初始化之后因为初始点的距离就是自己到自己,所以K点就是第一个确定的最短路径,距离为0;
- 3.然后对该点到其他点进行松弛操作,更新该点到其他点的距离;
- 4.该点的最短路径已确定,即表示该点已求得最短路径,后面的循环不会再对该点进行操作;
- 5.循环2、3、4,当所有点的的最短路径都已求得的时候,最大距离就是网络延迟时间的答案,当在循环中发现最小距离为无穷大时,即表示还有点没有到达,返回-1。
- 复杂度分析
- 时间复杂度:O(N^2+E)。E 是 times 的长度,N 是 网络节点个数。
- 空间复杂度:O(N+E),图的大小是 O(E), 加上其他对象的大小 O(N)。
2.2.2 该题的伪代码
新建 dist 字典,存放网络节点n及对应的距离
初始化网络节点K的距离为0,其他节点距离为无穷大
新建 res 空字典,存放网络节点n级对应最短路径的距离
遍历 dist 字典
{
找出字典中最小的路径 min_dis 以及该路径对应的网络节点 ind
更新该网络节点
ind 所有相邻节点的距离(并且此相邻节点不在 res 字典中,即此相邻节点之前还未得出最短路径)
}
节点 ind 的最短路径为 min_dis,并且与之相邻节点也完成了距离的更新
删除 dist 字典中的 ind 节点
将节点 ind 和它的最短路径 min_dis 插入到 res 中
循环直到 dist 所有节点遍历完或者 min_dis 为无穷大
如果即还有节点没有遍历到
{
返回 - 1
}
返回 res 最大的最短路径,即为所有节点收到信号的时间
2.2.3 运行结果
2.2.4 分析该题目解题优势及难点
- 优势:可以使用 Dijkstra 算法找到从源节点到所有节点的最短路径,方法容易理解,题目也容易读懂
- 难点:只凭借数据不好想象,画图能够比较容易地找到思路,而且需要注意是有向图
2.3 课程表 II
- 题目
现在你总共有 n 门课需要选,记为 0 到 n-1。
在选修某些课程之前需要一些先修课程。 例如,想要学习课程 0 ,你需要先完成课程 1 ,我们用一个匹配来表示他们: [0,1]
给定课程总量以及它们的先决条件,返回你为了学完所有课程所安排的学习顺序。
可能会有多个正确的顺序,你只要返回一种就可以了。如果不可能完成所有课程,返回一个空数组。
示例 1:
输入: 2, [[1,0]]
输出: [0,1]
解释: 总共有 2 门课程。要学习课程 1,你需要先完成课程 0。因此,正确的课程顺序为 [0,1] 。
示例 2:
输入: 4, [[1,0],[2,0],[3,1],[3,2]]
输出: [0,1,2,3] or [0,2,1,3]
解释: 总共有 4 门课程。要学习课程 3,你应该先完成课程 1 和课程 2。并且课程 1 和课程 2 都应该排在课程 0 之后。
因此,一个正确的课程顺序是 [0,1,2,3] 。另一个正确的排序是 [0,2,1,3] 。
说明:
输入的先决条件是由边缘列表表示的图形,而不是邻接矩阵。详情请参见图的表示法。
你可以假定输入的先决条件中没有重复的边。
提示:
这个问题相当于查找一个循环是否存在于有向图中。如果存在循环,则不存在拓扑排序,因此不可能选取所有课程进行学习。
通过 DFS 进行拓扑排序 - 一个关于Coursera的精彩视频教程(21分钟),介绍拓扑排序的基本概念。
拓扑排序也可以通过 BFS 完成。
- 代码
class Solution {
public:
vector<int> findOrder(int numCourses, vector<vector<int>>& prerequisites) {
vector<vector<int>> ajac(numCourses, vector<int>());
vector<int> indegree(numCourses, 0);
queue<int> qu;
vector<int> res;
for(auto& pre: prerequisites)
{
ajac[pre[1]].push_back(pre[0]);
indegree[pre[0]]++; //计算入度
}
for(int i = 0; i < numCourses; i++) //入度为0的节点入队
{
if(indegree[i] == 0)
{
qu.push(i); //计算入度为0的节点数量
}
}
while(!qu.empty()){
int cur=qu.front();
qu.pop();
res.push_back(cur);
for(int i=0;i<ajac[cur].size();i++){ //判断以此节点为前提的其他课程
indegree[ajac[cur][i]]--; //入度减一为0的话,说明可以学习了
if(indegree[ajac[cur][i]]==0) qu.push(ajac[cur][i]);
}
}
if (res.size()==numCourses) return res;
else return {};
}
};
2.3.1 该题的设计思路
- 在开始排序前,扫描对应的存储空间,将入度为 0 的结点放入队列。
- 只要队列非空,就从队首取出入度为 0 的结点,将这个结点输出到结果集中,并且将这个结点的所有邻接结点(它指向的结点)的入度减 1,在减 1 以后,如果这个被减 1 的结点的入度为 0 ,就继续入队。
- 当队列为空的时候,检查结果集中的顶点个数是否和课程数相等即可。
- 复杂度分析
- 时间复杂度: O(N)。图中的每个结点都处理了一次。
- 空间复杂度: O(N)。使用了一个中间的队列数据结构来存储所有具有0入度的结点。在最坏的情况下,没有任何先修关系,队列会包括所有的结点。
2.3.2 该题的伪代码
for循环遍历图
{
计算每个点的入度且存储在indegree[pre[0]]中
}
for (i = 0 to numCourses,i++)
{
if (入度为0)
{
节点入队列qu,计算入度为0的节点数量
}
}
while (队列不为空)
{
cur存储队首元素
qu队首元素出队
rse队列入队cur
判断以此节点为前提的其他课程
{
if (入度减一为0)
{
说明可以学习
}
}
}
if (res队列元素的数目等于题目所给的课程数)
{
返回res队列
}
else
{
返回一个空数组
}
2.3.3 运行结果
2.3.4 分析该题目解题优势及难点
- 优势:题目主要就是遍历图的节点,计算其入度并存储,然后将入度为0的节点存入最终结果的队列中去,思路比较好理解
- 难点:题目可能有多个解,但是只能输出特定的一个点,因为遍历剩余节点的入度时,是按照1、2、3……的顺序来的,无法输出全部的解
附:阅读代码相关资料
- [ACM题库题解] (https://www.nowcoder.com/ta/acm-solutions?query=&asc=true&order=&page=2)
- [题库 - 力扣 (LeetCode) - 图1] (https://leetcode-cn.com/problemset/all/?search=图)
- [题库 - 力扣 (LeetCode) - 图2] (https://leetcode-cn.com/tag/graph/)