Always believe nothing is impossible and just try my best! ------ 谓之谦实。

数据结构---图


| 这个作业属于哪个班级 | C语言--网络2011/2012 |
| ---- | ---- | ---- |
| 这个作业的地址 | DS博客作业04--图 |
| 这个作业的目标 | 学习图的结构设计及运算操作 |
| 姓名 | 骆锟宏 |

0.PTA得分截图

在这里插入图片描述

1.本周学习总结(6分)

图的大概的基本框架:

ps:试试自己造一个比较复杂的实例用这个实例来解释图的各种功能。

1.1 图的存储结构

1.1.1 邻接矩阵

那么就以这个图为例,大家来先感性认识一下怎么用邻接矩阵来表示一个图趴:

而把图转化成邻接矩阵后就会变成这个亚子:
在这里插入图片描述

  • 这里说说为什么要这样设置邻接矩阵:
  1. 其中的 ‘∞’ 用来表示两个顶点之间没有存在边的情况,一般可以用‘0’或者‘∞’来表示,不过由于如果用‘0’的话会在Prim算法中出现一些细节上的小问题,所以我们提倡用 ‘∞’,不过计算机中‘∞’是需要手动define的,一般define INF 32767(针对int)
  2. 而其他非无穷大的值,代表的是两个顶点之间的权值的大小
  3. 对于有向图,同一列上非无穷大值的个数代表该顶点的入度,而同一行上非无穷大值的个数代表该顶点的出度。
  • 接下来就着这张图说说邻接矩阵的一些特点:

    • 首先很显然,图的邻接矩阵的表示是唯一的。
    • 只要图的顶点的个数一确定下来,那图的邻接矩阵所需要的空间数也就确定下来了。无论是有向图还是无向图,对于有n个顶点的图,它的邻接矩阵所需要的内存单元的个数是 n^2 , 再进一步考虑的话,本质上需要的是 (n+1)^2 ,因为数组下标的起始是0,而实际使用中,我们对顶点的编号往往从1开始,为了让顶点编号能直接和数组下标对应,所以我们操作上往往直接省略0行0列的空间。不过相应的,在动态申请空间的时候就要记得申请的空间的大小是 (n+1)所以本质上邻接矩阵更适合存储边的数目比较多的稠密图,这是我们判断究竟是使用邻接表还是邻接矩阵来表示图的一个判断标准。
    • 无向图的邻接矩阵数组一定是一个对称矩阵!通过这一点我们可以只存储它的上(或下)三角部分的元素就可,这样可以实现压缩存储
    • 在邻接矩阵中,判断图的两个顶点之间是否有边或者求两个顶点之间边的权值的执行时间为 O(1),所以在需要提取边值的算法中,我们通常使用邻接矩阵存储结构。
  • 邻接矩阵的结构体定义:

typedef struct              //图的定义
{
	ElementType edges[MAXV][MAXV];   //这样定义的话,如果顶点从0开始编号,则需要进行细节上的转化。
    //ElementType edges[MAXV+1][MAXV+1];     //这样不需要。
    int n, e;              //顶点数,弧数
} MGraph;                //图的邻接矩阵表示类型
  • 这里之所以用ElementType来定义edges是因为,在具体应用中,边这个类,可能不止有权值这样一个属性,这时候,边就是一个含有多个属性的元素,可以定义一个结构体去表示边这一个类,结构体可以允许边有多个属性成员。
  • 需要特别注意的一点是,可能存在当数据量非常大的情况下,没办法直接用二维数组来定义边矩阵时,就需要我们使用二维指针来定义边矩阵。那么就会遇到一个问题就是要如何为二维指针动态开辟空间。
//需要先对二维指针开辟多个一维指针空间;
int** edge = new *int[n];
//然后再在循环中对每个一维指针动态开辟内存。
for(int i = 0;i < n;i++)
{
	edge[i] = new int [numb];
}
  • 建图函数:
//有对顶点转对应下标进行处理的无向图的建树:
void CreateMGraph(MGraph& g, int n, int e)//建图
{
    int i, k;
    int node1, node2;
    //初始化邻接矩阵;
    for (i = 0; i < n; i++)
    {
        for (k = 0; k < n; k++)
        {
            g.edges[i][k] = 0;
        }
    }

    //建图
    for (i = 0; i < e; i++)
    {
        cin >> node1 >> node2;
        g.edges[node1 - 1][node2 - 1] = 1;

        g.edges[node2 - 1][node1 - 1] = 1;//这一步是无向图建树特有的对称建树
    }

    //别忘了给边和结点数赋值!
    g.n = n, g.e = e;
}
// MAXV+1型的建树:
void CreateMGraph(MGraph& g, int n, int e)//建图
{
    int i, k;
    int node1, node2;
    //初始化邻接矩阵;
    for (i = 1; i <= n; i++)
    {
        for (k = 1; k <= n; k++)
        {
            g.edges[i][k] = 0;
        }
    }

    //建图
    for (i = 0; i < e; i++)
    {
        cin >> node1 >> node2;
        g.edges[node1][node2] = 1;

        g.edges[node2][node1] = 1;//这一步是无向图建树特有的对称建树
    }

    //别忘了给边和结点数赋值!
    g.n = n, g.e = e;
}

1.1.2 邻接表

我们不妨以刚才已经使用的在邻接矩阵中使用过的图为例:
在这里插入图片描述
将该图转化为邻接表表示如下:
在这里插入图片描述

  • 这里说说为什么要这样设置邻接表:
  1. 首先我们先看左边这一列数组,这是一个关于图的顶点的结构体数组,每一个结构体都可以包含多种顶点的内联属性,其中尤其明显的一个特点是它包含一个指向边结点的指针域。
  2. 往右边看去,右边的结点也是一个结构体,一个表示边的结构体,从图中知道,边结点保存了指向的顶点的顶点信息,以及两个顶点间的边的权值信息。
  • 然后我们来谈谈邻接表的一些特性:

    • 邻接表是不唯一的,因为边结点插入邻接表的顺序可以是任意的所以邻接表不唯一。
    • 对于有n个结点和e条边的有向图,用邻接表存储只需要e个边结点,而如果是无向图的话,则需要2e个边结点。所以一般来说,邻接表比较适合边数比较少的图,用邻接表来存储稀疏图的话会比较节省存储空间。
    • 在邻接表中,查找和某一个顶点相邻的其他顶点是非常快速的,所以在需要提取某个顶点的所有邻接点的算法中通常采用邻接表存储结构。
  • 邻接矩阵的结构体定义:

typedef struct ANode
{
    int adjvex;            //该边的终点编号
    struct ANode* nextarc;    //指向下一条边的指针
    int info;    //该边的相关信息,如权重
} ArcNode;                //边表节点类型

typedef int Vertex;
typedef struct Vnode
{
    Vertex data;            //顶点信息
    ArcNode* firstarc;        //指向第一条边
} VNode;                //邻接表头节点类型

typedef VNode AdjList[MAXV];

typedef struct
{
    AdjList adjlist;        //邻接表
    int n, e;        //图中顶点数n和边数e
} AdjGraph;
  • 从上面我们可以很清晰地看到这里就像我们前面对例子图所分析的那样 分别需要表示边结点的结构体,表示顶点的结构体和表示整个图整体的结构体, 这三个结构体来完整地表示出一个图。

  • 需要注意的一个点是,在图的结构体中,各个顶点元素的集合,可以用一个数组来表示,亦可以用一个指针来表示,只不过使用指针来表示的时候需要记得为指针动态开辟内存。

  • 建图函数:

//将顶点和下标有转化的建图
void CreateAdj(AdjGraph*& G, int n, int e)//创建图邻接表
{
    int i,k;
    int node1, node2;

    G = new AdjGraph;

    //初始化顶点数组
    for (i = 0; i < n; i++)
    {
        G->adjlist[i].firstarc = NULL;
    }

    for (k = 0; k < e; k++)
    {
        cin >> node1 >> node2;

        ArcNode* newnode1 = new ArcNode;
        newnode1->adjvex = node2;
        newnode1->nextarc = G->adjlist[node1 - 1].firstarc;
        G->adjlist[node1 - 1].firstarc = newnode1;
        //对称建表
        ArcNode* newnode2 = new ArcNode;
        newnode2->adjvex = node1;
        newnode2->nextarc = G->adjlist[node2 - 1].firstarc;
        G->adjlist[node2 - 1].firstarc = newnode2;
    }

    G->e = e, G->n = n;
}
  • 对于无转化的建图这里不在给出具体代码,但给出需要注意的关键点:
    • 其一,在给顶点集合开辟空间的时候需要多开辟一个空间。
    • 其二,在对顶点的访问的过程就不在需要对顶点编号-1,可以直接使用。

1.1.3 邻接矩阵和邻接表表示图的区别

  • 最直接的一点就是如果一个图是稠密图的话,那从节省空间的角度的话,更推荐使用邻接矩阵存储结构,而如果这个图是一个稀疏图的话,尤其是边的数量不那么多的图,一般使用邻接表存储结构。
  • 在邻接矩阵中,判断图的两个顶点之间是否有边或者求两个顶点之间边的权值的执行时间为 O(1),所以在需要提取边值的算法中,我们通常使用邻接矩阵存储结构。
  • 在邻接表中,查找和某一个顶点相邻的其他顶点是非常快速的,所以在需要提取某个顶点的所有邻接点的算法中通常采用邻接表存储结构。
  • 如果后续需要对图进行插入新结点或者删除图中的某个结点的操作的话,那也建议使用邻接表存储结构,因为邻接表具有链表的特性,在执行插入和删除操作上比较快速。
  • 由于邻接矩阵遍历的时间开销是O(n2),所以如果对于一些查找算法需要的时间复杂度也是O(n2)的话,直接使用邻接矩阵会合适一些。
  • 邻接矩阵的空间复杂度是O(n2),而邻接表的空间复杂度是O(n+e)。

1.2 图遍历

我们依然以这个图为例:
在这里插入图片描述
但是首先要把它的图的数据数据化一下,根据该图的具体特征:

  • 有从(a 到 h)8个顶点,我们从1开始编号,从a开始到依照字母序分别编号为1->8。
8 14
a b 4
a c 3
b c 5
b d 5 
b e 9
c d 5
c h 5
d e 7
d f 6
d g 5
d h 4
e f 3
f g 2
g h 6

由此这个顶点关系的输入可以转化为:

8 14
1 2 4
1 3 3
2 3 5
2 4 5
2 5 9
3 4 5
3 8 5
4 5 7
4 6 6
4 7 5
4 8 4
5 6 3
6 7 2
7 8 6

采用邻接表存储结构得到的结果:(以1为初始顶点。)
在这里插入图片描述
(以2为初始顶点)
在这里插入图片描述

采用邻接矩阵存储结构得到的结果:(以1为初始顶点。)
在这里插入图片描述
(以2为初始顶点)
在这里插入图片描述

  • 通过对上面这个例子的研究,我们不难发现这几点:
    • 同一张图,采用的存储结构不一样那它的深度遍历和广度遍历的序列是可能不一样的。
    • 同一张图,用同一种存储结构存储,如果遍历的初始顶点不一样的话那它的深度遍历和广度遍历的序列是可能不一样的。

1.2.1 深度优先遍历

  • 深度遍历代码:
    • 深度遍历首先要从一个给定的顶点出发;
    • 然后选择与初始顶点相邻的顶点中还未被访问过的一个顶点作为中心顶点,又在这个中心顶点的临近顶点中选择未被访问的顶点为中心顶点,这样不断交替下去。
    • 最后当所有顶点都被访问后,按照访问的优先顺序得到的序列就是深度优先遍历序列。
    • 当然如果是非连通图的话,一轮深度收缩完是可能存在还没有被访问的顶点的,此时就是要不断从尚未被访问的顶点中选出一个作为深度搜索的初始顶点再次执行深度搜索直到所有的顶点都被访问。
    • 深度遍历有一个特殊的点就是,由于深度遍历的算法是由递归作为基础来实现的,而递归本身带有回溯的过程,所以深度优先遍历本身并不是一路走到底,而是还有一个回溯的过程的。
  1. 用来区分是否被访问过采用的是一个全局的数组visited[ ],以0表示未被搜索,以1表示已经被收索过了。
  2. 判断可以进行深度搜索的条件是,首先这要是当前顶点的邻近顶点,其次需要该顶点尚未被访问。
  • 针对邻接矩阵存储结构:
void DFS(MGraph g, int v)//深度遍历
{
    int i;

    visited[v-1] = 1;//置已经被访问的结点为1
    //输出结点。
    if (!flag)
    {
        cout << v;
        flag = 1;
    }
    else
    {
        cout << " " << v;
    }

    for (i = 0; i < g.n; i++)//i要从0开始。
    {
        if (g.edges[v-1][i] != 0 && visited[i] == 0)
        {
            DFS(g, i + 1);
        }
    }
}

  • 针对邻接表存储结构:
void DFS(AdjGraph* G, int v)//v节点开始深度遍历
{
    ArcNode* ptr;

    visited[v - 1] = 1;

    if (!flag)
    {
        cout << v;
        flag = 1;
    }
    else
    {
        cout << " " << v;
    }

    ptr = G->adjlist[v - 1].firstarc;
    while (ptr != NULL)
    {
        if (visited[ptr->adjvex-1] == 0)
        {
            DFS(G, ptr->adjvex);
        }
        ptr = ptr->nextarc;
    }

}
  • 深度遍历适用哪些问题的求解:
    • 最早出现的是迷宫问题的求解,在栈和队列这一块用栈这个结构实现了迷宫问题的求解。
    • 而在图里面,深度优先遍历可以用来判断两个顶点之间是否存在简单路径。
    • 深度优先遍历还可以得到两个顶点之间存在的全部简单路径。(通过回溯法)
    • 深度优先遍历还可以得到两个顶点之间存在的指定路径长度的全部简单路径。
    • 深度优先遍历还可以得到通过某一顶点的所有简单回路。
  • 抽象出来看的话,深度优先遍历算法可以用来解决两点之间的条件路径问题。

这里给出一个相关总结的博客的链接感谢作者:Fellow@
图的深度优先遍历的应用
————————————————
版权声明:本文为CSDN博主「Fellow@」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/qq_40512890/article/details/105696313

1.2.2 广度优先遍历

  • 广度遍历代码:
    • 广度优先遍历同样也是从一个给定的顶点出发,然后再优先访问与当前出发的顶点相邻的每一个相邻顶点,当所有相邻顶点都被访问后,再按所访问的相邻顶点的先后顺序,去访问相邻顶点的未被访问的相邻的顶点,依次循环往复,直到所有的顶点都被访问后,按照访问的优先顺序所得到的遍历序列就是广度优先遍历序列。
    • 当被访问的图是非连通图的时候,一层广度优先遍历确实无法访问到所有的顶点,所以很显然需要以未被访问的顶点中的一个顶点作为出发的顶点,再次进行广度遍历直到所有的顶点都被访问,才算一个图被访问完毕。
  1. 那么如何区分一个顶点是否已经被访问呢?和深度优先遍历是一样的,我们也是借用一个visited[ ]数组,用0表示未被访问,用1表示已经被访问了。
  2. 那么如何控制顶点的访问顺序是先访问所有邻近的未被访问的顶点后,再按照访问邻近顶点的顺序来依次访问邻接顶点的未被访问的邻近顶点呢?这时会发现这样的访问顺序其实存在先入先出的数据特点,所以我们可以借用队列这一结构来实现这种顺序的遍历。
  • 针对邻接矩阵存储结构:
void BFS(MGraph g, int v)//广度遍历
{
    int i, k;
    int cur_node;
    //手动建顺序队列
    int queue[MAXV];
    int front, rear;
    front = rear = 0;

    //初始化中没有给visited[0]初始化为0
    visited[0] = 0;

    visited[v - 1] = 1;
    queue[rear++] = v;//enqueue

    cout << v;

    while (front != rear)
    {
        cur_node = queue[front++];
        for (i = 0; i < g.n; i++)
        {
            if (visited[i] == 0 && g.edges[cur_node-1][i] != 0)
            {
                cout << " " << i + 1;
                queue[rear++] = i + 1;
                visited[i] = 1;
            }
        }
    }
}

  • 针对邻接表存储结构:
void BFS(AdjGraph* G, int v) //v节点开始广度遍历
{
    int i;
    ArcNode* Ptr;
    queue<int> que;
    int cur_vex;
    //给visited[0]初始化
    visited[0] = 0;

    visited[v - 1] = 1;
    que.push(v);

    cout << v;
    while (!que.empty())
    {
        cur_vex = que.front();
        que.pop();
        Ptr = G->adjlist[cur_vex-1].firstarc;
        while (Ptr)
        {
            if (visited[Ptr->adjvex-1] == 0)
            {
                cout << " "<< Ptr->adjvex;
                visited[Ptr->adjvex - 1] = 1;
                que.push(Ptr->adjvex);
            }
            Ptr = Ptr->nextarc;
        }
    }
}
  • 广度遍历适用哪些问题的求解。
    • 最早出现也是在栈与队列中使用队列这个数据结构实现了迷宫问题的求解,可以得到从入口到出口的最短路径,但缺点是费时间。
    • 广度优先遍历可以实现找到两个顶点之间的最短路径。
    • 广度优先遍历还可以实现找到距离一个顶点距离最远的另一个顶点。(最后一个进队的元素就是距离最远的顶点

这里也给出一个相关总结的博客的链接感谢作者:Fellow@
图的广度优先遍历的应用
————————————————
版权声明:本文为CSDN博主「Fellow@」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/qq_40512890/article/details/105713455

1.3 最小生成树

  • 首先最小生成树首当其次是一颗树结构。
  • 而之所以叫生成树,是因为它是由图生成而来的树。
  • 而最小有两层含义,一个是使用的边数刚刚好达到最少,对于有n个顶点的图,生成最小生成树后只保留其中的(n-1)条边来连接各个顶点;其二是,所选择边的标准是取权值组合的最优解。
  • 最小生成树所选择的边都是不会产生回路的边。

我们依然以这个图为例:
在这里插入图片描述

  1. Prim算法得到的最小生成树的边序列如下:
    在这里插入图片描述
    在这里插入图片描述
  2. Kruskal算法得到的最小生成树的边序列如下:
    在这里插入图片描述
    在这里插入图片描述

1.3.1 Prim算法求最小生成树

  • 细节解释:

    • 实现Prim算法有两个重要的辅助数组,一个是lowcost数组,另一个是closest数组。
    • 其中lowcost数组有两个作用,其中一个是记录当前已经被选中的顶点到达还没被选中的顶点的边值中的最小权值,另外一个作用是用来标记那些顶点已经被选中了。被选中的顶点在lowcost数组中的值会被置为零。
    • 而closest数组则是用来记录lowcost数组中所记录的最小权值的前驱顶点是那一个顶点,如果当前不存在边的话,也就没有前驱顶点用-1来表示。
  • 用PTA例题来解释Prim算法:
    在这里插入图片描述
    在这里插入图片描述

  • 题目思路梳理:

    • 公路村村通问题的本质实际上就是把村子当成顶点,把连通村子的路当成边,构造一个图,而求最节省成本的修路方案,本质上就是求这个所构造的图的最小生成树,所以这里可以用Prim算法来解决问题。(最小生成树可以用来解决工程造价问题。
  • Prim算法的思路:

1.变量定义。
2.根据初始顶点初始化closest数组和lowcost数组。
3.接下来就是去构造那n-1条边。
	3.1 首先是从lowcost数组中找到当前权值最小的值,纳入该边。
	3.2 纳完边后,要对lowcost数组和closest数组进行更新。
  • 其中用到的Prim算法的代码展示:
void Prim(MatGraph G, int vex,int& sum)//vex是起始的结点
{
	int* closest = new int[G.n];
	int* lowcost = new int[G.n];
	int i,j,keep;
	int MIN;//最小值;

	//初始化两个辅助数组
	for (i = 0; i < G.n; i++)
	{
		lowcost[i] = G.edges[vex-1][i];
		closest[i] = vex-1;
	}

	//找出(n-1)个顶点;
	for (i = 1; i < G.n; i++)
	{
		MIN = INF;
		//在lowcost中找出最小值;
		for (j = 0; j < G.n; j++)
		{
			if (lowcost[j] != 0 && lowcost[j] < MIN)
			{
				MIN = lowcost[j];
				//break;//不能中途退出要找完整个数组
				keep = j;//保存最小值的下标;
			}
		}
		//如果最小值仍为无限大,代表存在不连通的结点,需要建设更多的公路
		if (MIN == INF)
		{
			sum = -1;
			return;
		}
		
		lowcost[keep] = 0;//标记结点为已选中;
		//计算最低成本:
		sum += MIN;
		
		//更新辅助数组,循环控制的变量别用   i !!!
		for (j = 0; j < G.n; j++)
		{
			if (lowcost[j] != 0 && lowcost[j] > G.edges[keep][j])
			{
				lowcost[j] = G.edges[keep][j];
				closest[j] = keep; //修改lowcost数组和closest数组
			}
		}
	}
}
  • Prim算法的时间复杂度为O(n2),并且在算法中含有需要直接取用对应边的权值的情况,所以使用邻接矩阵存储结构是更方便更合适的。

1.3.2 Kruskal算法求解最小生成树

  • Kruskal算法的实现思路,以边为对象,将图中所有出现过的边按权值从低到高排序,排序好的边存入一个边的结构体数组当中,然后从权值低的边开始到权值高的边依次纳边,纳边的条件是边的两个顶点所对应的两个顶点不是等价类,要求该边的两个纳入的前后顶点要合并为等价类。
  • Kruskal算法首先需要重新定义一个以边为对象的结构体。
typedef struct
{
	int start_u;//起始顶点
	int end_v;//结束顶点
	int weight;//边的权重值

}Edge;
  • 实现Kruskal算法需要edges[]数组来存放每一条边。
  • 实现Kruskal算法需要一个vset[]数组来区分不同的类别。
  • 具体实现的代码如下:
//顶点编号从0开始;
void Kruskal(MatGraph G)
{
	int i, j, u1, v1, sn1, sn2, k;
	int* vset = new int[G.n];
	Edge* edges = new Edge[G.e];
	k = 0;  //表示edges数组当前的下标

	//初始化边的数据信息
	for (i = 0; i < G.n; i++)
	{
		for (j = 0; j <= i; j++)
		{
			if (G.edges[i][j] != 0 && G.edges[i][j] != INF)//也即是说存在边
			{
				edges[k].start_u = i, edges[k].end_v = j, edges[k].weight = G.edges[i][j];
				k++;
			}
		}
	}

	//按权值从小到大插入排序edges数组;
	//InsertSort(edges, G.e); //具体代码不给出

	//初始化vset数组
	for (i = 0; i < G.n; i++)
	{
		vset[i] = i;//给每一个顶点进行不同集合打标签
	}

	int numb = 1;//表示当前构造了生成树的第几条边,默认从1开始。
	int index = 0;//表示edges中边的下标,初值从0开始。

	while (numb < G.n)
	{
		//取一条边的两个顶点。
		u1 = edges[index].start_u; 
		v1 = edges[index].end_v; 

		//取两个顶点对应的标签
		sn1 = vset[u1];
		sn2 = vset[v1];

		if (sn1 != sn2) //如果两个顶点不是同一个集合,代表改边可用。
		{
			cout << u1 << "->" << edges[index].weight << "->" << v1 << endl;  //输出一条边;
			numb++;
			//统一两个集合的编号:
			for ( i = 0; i < G.n; i++)
			{
				if (vset[i] == sn2)
				{
					vset[i] = sn1;
				}
			}
		}

		index++;//继续构造下一条边。
	}

}
  • Kruskal算法时间复杂度:
  • 对e条边进行插入排序的时间复杂度为O(e2),while循环是构造(n-1)条边,内部又有一个for循环,所以整个while循环的时间复杂度是O(n2); 那么综合来看Kruskal算法的时间复杂度为O(e2+n2);又由于图为连通无向图,相对来说是稠密的所以 e > n, 所以最终来说,Kruskal算法的时间复杂度为O(e2)。
  • 这里更适合用邻接矩阵存储结构,因为这里涉及到需要频繁取用边的权值的操作使用邻接矩阵比较快速便捷。

1.4 最短路径

1.4.1 Dijkstra算法求解(非负权单源)最短路径

我们依然以这个图为例:
在这里插入图片描述
列出表格后得到的从a顶点到其他顶点的最短路径如下图:
在这里插入图片描述

  • 从以上过程可以了解到:
    • 在执行当中,一但顶点被纳入到S集合当中后,其最短路径的长度将不会再变动。
    • 并且在前面的特性的基础上,我们可以知道,dijkstra算法不适合权值带有负值的图。
    • 从原始顶点出发,到目的顶点的距离,随着其他顶点进入S集合的先后顺序,越往后的顶点的最短路径越长。
  • Dijkstra算法需要一个dist[]数组用来表示当前顶点到对象顶点的当前最短路径长度。
  • Dijkstra算法需要一个path[]数组来记录对应顶点的前驱顶点。
  • Dijkstra算法需要一个S[]数组来记录当前已经访问的顶点是哪些。

Dijkstra算法如何解决贪心算法无法求最优解问题?展示算法中解决的代码。

  • 首先来看看贪心算法的核心是什么:贪心算法(又称贪婪算法)是指,在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,算法得到的是在某种意义上的局部最优解 。
  • 也就是说使用贪心算法的话我们得到的只是局部的最优解而不是全局的最优解,那怎么样能够把对最短路径的搜索扩大到全局的环境来呢?
    • dijkstra算法中采用将当前的观察对象从单一的对象扩大到已经选中的元素的集合的方法来实现全局考量。
    • 简单的说就是,每次纳入一个新的顶点后,算法会进行一次更新,会把新纳入的顶点所新连通的到未连通顶点的路径新纳入的顶点所连通的到已连通但是未被选中的顶点的更短的路径更新到dist[]数组中从而实现全局的考量,解决了贪心算法所无法实现的全局最优解的问题。

从代码上来讲就是:

void Dijkstra(MGraph g, int v)//源点v到其他顶点最短路径
{
    int* S = new int[g.n];
    int* dist = new int[g.n];
    int* path = new int[g.n];
    int i,j,k,MINdis;

    //初始化各个数组
    for (i = 0; i < g.n; i++)
    {
        S[i] = 0;
        dist[i] = g.edges[v][i];//
        //不需要进行分类,因为不存在边的权值已经初始化为INF
        if (g.edges[v][i] < INF)
        {
            path[i] = v;
        }
        else
        {
            path[i] = -1;
        }
    }
    S[v] = 1, dist[v] = 0, path[v] = 0;

    for (i = 0; i < g.n-1; i++)
    {
        //根据dist中的距离从未选顶点中选择距离最小的纳入
        MINdis = INF;
        for (j = 0; j < g.n; j++)
        {
            if (S[j] == 0 && dist[j] < MINdis)
            {
                MINdis = dist[j];
                k = j;
            }
        }

        S[k] = 1;
        
        //纳入新顶点后更新dist信息和path信息
        for (j = 0; j < g.n; j++)
        {
            if (S[j] == 0)//针对还没被选中的顶点
            {
                if (g.edges[k][j] < INF //新纳的顶点到未被选中的顶点有边
                    && dist[k] + g.edges[k][j] < dist[j])//源点到k的距离加上k到j的距离比当前的源点到j的距离短
                {
                    dist[j] = dist[k] + g.edges[k][j];
                    path[j] = k;
                }
            }
        }
    }

    //接下来是输出的函数:
    Dispath(dist, path, S, g.n, v);
}
  • Dijkstra算法的时间复杂度为 O(n2);并且最好使用邻接矩阵存储结构来存储,因为在算法中出现了需要直接获取边的权值信息的情况。邻接矩阵获取边的权值信息的时间复杂度为O(1)。

1.4.2 Floyd算法求解(非负权多源)最短路径

  • 对于从一个顶点到其他所有顶点的最短路径问题,我们可以通过Dijkstra算法来解决,那么如果从一扩展到多呢?我们可以简单粗暴地通过多次调用dikstra算法来求解,这显然是可以行得通的,并且我们可以明显知道它的时间复杂度是O(n3),但是有没有可以简化的点呢?回答是肯定的。

  • Floyd算法就是针对这种思路所进行的改进,同样也是用来解决每对顶点之间的最短路径问题。

  • Floyd算法需要一个辅助二维数组A[][]用来存储每个顶点到邻近顶点的路径长度。

  • Floyd算法需要一个辅助二维数组path[][]用来存储每个顶点的前驱顶点。

  • 这两个二维数组,本质上是把dijkstra算法的每个顶点出发的dist[]数组和path[]数组整合起来,合成了一个二维数组。

  • Floyd算法优势

    • 充分利用了邻接矩阵的数据结构的特点,二维数组A在很大程度上直接和建好的图的二维矩阵契合,体现在代码上的优势就是代码量少不需要定义很多额外的变量。
    • 逻辑十分简单。
  • Floyd算法的前驱数学逻辑:

    • 此时我不妨假设某两个顶点之间存在最短路径。(要求 a != b)
    • 而如果两个顶点a,b之间又存在最短路径的话,那必然满足的条件就是,我任取这段路径当中所存在的任何顶点k,那起点a到k顶点的最短路径与k顶点到终点b的最短路径之和必定为a到b之间的最短路径。而在我不知道具体那些顶点会是在a,b顶点之间的最短路径上的顶点的时候,我就可以列举所有存在的顶点一一插入到a,b之间,一一列举从而保留那个最小的值,这样当最后列举结束的时候,我就可以得到连接a,k,b这三个顶点的最短路径,而要注意的是,在该算法中,a,k,b三个顶点我全部都一一把可能的组合情况都列出来了,所以最后就能得到所有顶点中任取的三个顶点的组合间的最短路径。
    • 再把每一组组合当成一个集合,把这些所有的集合求最大并集,那最后得到的所有独立的有先后顺序的集合就是所存在的每对顶点之间的最短路径的集合。
    • 这就是三生万物!!!!
    • 对影响同一结果量的三个变量的三层循环,很难不让人联想到这就像一个三维的坐标系!
  • Floyd算法的伪代码:

1.遍历邻接矩阵,为A矩阵和path矩阵初始化。
//对A矩阵进行初始化的含义是先载入当前所有的顶点之间,如果存在边的话,那它们之间直接连接的长度是多少,先记录到矩阵A当中。
2.依次检验从任意顶点a到任意顶点k再到任意顶点b的最短路径,如果存在更短的就更新,知道列举结束。
//从数学含义来讲这一步的操作就是一步一步对每两个顶点中间不断插入顶点来连接两个顶点,而在所有的可能中只保留最短的那种情况。
3.借用path矩阵输出每个顶点对应的最短路径。

最短路径算法还有其他算法,这里有一篇博客列举出其他的3种不同的算法。
感谢作者:qq_35710556
图的五种最短路径算法

————————————————
版权声明:本文为CSDN博主「qq_35710556」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/qq_35710556/article/details/79583229

1.5 拓扑排序

  • 能够进行拓扑排序的图结构基础是有向无环图
  • 拓扑排序的方法如下:
    • 从有向图中选择一个入度为0的顶点,并输出整个顶点。
    • 输出这个顶点后,要删除这个顶点指向的其他顶点的边,让它指向的顶点的入度值减1。
    • 然后再次选择一个入度为0的顶点继续输出,如此往返直到所有的顶点都被输出完毕,最终得到的输出序列就是该有向无环图的TopSort。
  • 以这个图为例:
    在这里插入图片描述
    在这里插入图片描述

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

  • 从上到下这样一个输出入度为0的顶点,最终得到输出序列的过程就是拓扑排序的过程。
  • 实现拓扑排序的结构体设计:
  • 拓扑排序的结构才用的是图的邻接表存储结构,不过不同的点是它引入了一个变量放入顶点的结构体中来统计这个顶点的入度。
typedef struct Vnode
{
    Vertex data;            //顶点信息
    int count;                   //入度    //这就是新加的变量也是TopSort中需要特别注意的点。
    ArcNode* firstarc;        //指向第一条边
} VNode;            //邻接表头节点类型
  • TopSort的伪代码:
定义辅助栈
for()
初始化所有顶点的入度为0
for()
遍历邻接表,修改每个顶点的入度信息。
先让当前入度为0的顶点入栈
while(栈不空)
{
	取栈顶元素输出
	while(指针不空)
	{
	让已出栈的栈顶顶点指向的顶点入度减一    //这就是删除入度为0的顶点的办法。
	if(入度减一后出现入度为0的顶点)
	让该顶点入栈
	}
}
  • 这里要探究几个问题:
  1. 为什么在拓扑排序中,不需要一个visited数组来区分那些顶点已经被访问而那些顶点尚未被访问呢?
    答:因为首先此处的图是有向的图,其次,我们先输出的是入度为0的顶点,在有向图中,后续查找是不会再查找到没有被顶点指向的顶点的,所以不会出现已经输出的顶点在后面还会出现的可能。
  2. 如何用拓扑排序代码检查一个有向图是否有环路?
    答:只需要用一个顺序表来依次存放拓扑序列,等排序结束后,再比较顺序表的长度是否等于有向图的结点个数,如果相等的话那就表明不是不存在环路,如果不等就代表是有环路,因为有环路的特性会导致一些结点的入度不为0。
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

1.6 关键路径

  • AOE-网
    • AOE-网的数据结构是有向无环图,其中图的顶点表示事件,而顾名思义,AOE指的是activity on edge,也就是边表示活动的意思,所以自然图的边表示也就是表示活动事件,而边的权值,则通常用来表示活动持续的时间。
    • 除此之外,通常每个工程都只有一个开始事件和一个结束事件,因此表示工程的AOE网都只有一个入度为0的顶点,称之为源点;以及一个出度为0的顶点称之为汇点
    • 当存在多个入度为0或者多个出度为0的顶点的时候,就可以增加一个到所有入度为零的顶点的边的权值为0的顶点作为虚拟源点或者增加一个所有出度为零的顶点到它的边的权值为0的顶点作为虚拟汇点就可以转化为单源点和单汇点的问题了。
  • 关键路径
    • 从源点到汇点的具有最大路径长度的路径称为关键路径
    • 在工程决策中,AOE网中关键路径的长度就是完成整个工程的最短的时间。
  • 关键活动
    • 关键路径上的活动称为关键活动,在数据结构上来理解就是构成这条关键路径的所有的边就是关键活动。
  • 特点:
    • AOE网中可能存在多条关键路径,但是他们的关键路径的长度是相同的。
    • 只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始。
    • 只有在进入某点的各有向边所代表的活动都已结束,该顶点所代表的时事件才能发生。

2.PTA实验作业(4分)

六度空间代码
公路村村通代码

2.1 六度空间(2分)

2.1.1 伪代码

采用邻接表存储结构。
首先先根据无权图建好图。
while (level < 6)//控制广度遍历往外遍历6层
{
	if (visited[cur_vex] == 0)
	{
	count++;(从1开始,1是本身,本身为与本身距离为0,也包含在满足条件的范围内)
	}
}

输出:
for(依次遍历每个顶点)
{
BFS(每个顶点)
输出: (与源顶点距离小于等于6的顶点总数) * 100.00 / 总顶点数  '%';
}

2.1.2 提交列表

在这里插入图片描述

  • 前面错误的点在于没有认清题目中六度的含义是以当前的顶点为起点出发,和当前顶点距离小于等于6的所有顶点都是符合条件的顶点,都可以被标记为符合条件的顶点,而以level = 0来控制的话,则条件的设置应该变化为(level < 6) 这样当level为6的时候,正好向外广度遍历了6次。

2.1.3 本题知识点

  1. 图的邻接表存储结构的定义。
  2. 在图的邻接表存储结构的定义下进行建图操作。
  3. 在图的邻接表存储结构的定义下进行广度优先遍历。
  4. 邻接表存储结构本质上包含了很多和链表有关的操作。

2.2 公路村村通(2分)

2.2.1 伪代码

本题为最小生成树问题,需要直接访问权值,故选择邻接矩阵存储结构。
首先要根据提供的带权无向图的信息来建树。
然后是采用Prim算法
  • Prim算法的思路:
初始化closest数组:和初始顶点有边的置为初始顶点,没有的置-1
初始化lowcost数组:和初始顶点有边的置为权值,没有的置为INF(无限大)
while(from 0 to n-1) //接下来就是去构造那n-1条边。
{
	从lowcost数组中找到当前权值最小的值,纳入该边。
	if (MIN == INF)
	{
		说明存在不连通的现象直接返回ERROR
	}
	对lowcost数组和closest数组进行更新。
}	

2.2.2 提交列表

在这里插入图片描述
知道是Prim算法后就没什么大问题了,因为本题上课老师有讲过。
其中一个值得讲的点是,判断是否存在非连通情况的话,正常思路是用一个visited[]数组来统计,如果存在没有被访问过的顶点就视为存在,但是这样时间开销大,所以按伪代码中的思路更好。

2.2.3 本题知识点

  1. 图的邻接矩阵储存结构及其建图方法。
    • 建树初始化的时候有一个小细节是矩阵对角线的初始化可以置0,其他地方才置INF。
  2. 二维指针动态开辟数组空间的方法。
  3. Prim算法。
posted @ 2021-05-23 21:59  嘟嘟勒个嘟  阅读(266)  评论(1编辑  收藏  举报