本文参考一些书本加上个人的见解,简单地总结了一下图的相关内容。图在数据结构中也是个十分重要的数据结构。图是一种数学抽象模型,用于描述事物之间的关系。也是一种强大的工具,可以用来解决各种复杂的问题。图在各个领域都有广泛的应用,比如社交网络、交通规划、计算机网络等。
目录
[kruskal 算法](#kruskal 算法)
[Floyd 算法](#Floyd 算法)
[Dijkstra 算法](#Dijkstra 算法)
[Bellman-Ford 算法](#Bellman-Ford 算法)
[SPFA 算法](#SPFA 算法)
图的基本概念
图的定义
图 (Graph) 是由顶点的有穷非空集合和顶点之间边的集合组成,通常表示为:G(V,E), 其中 G 表示一个图, V是图G 中顶点的集合, E是图G中边的集合。 线性表中我们把数据元素叫元素,树中将数据元素叫结点,图中数据元素,我们则称之为顶点。
图的基本概念及术语
1.有向图和无向图
图可以分为有向图和无向图。有向图中的边具有方向,从一个顶点指向另一个顶点,表示一个单向关系。无向图中 的边没有方向,连接两个顶点,表示双向关系。
1、无向边和无向图
如果两个顶点间的边没有方向,则称这条边为无向边,如果图中任意两个顶点之间的边都是无向边,则这个图被称 为无向图。
2、有向边和有向图
如果两个顶点之间的边是有方向的,则被称为有向边,也被称为弧(有向边的起点叫弧头,终点叫弧尾),如果图中任意两个顶点之间的遍都是有向边,则这个图被称为有向图。
2.简单图和多重图
简单图是指图中没有自循环(边的起始顶点和结束顶点相同)和多重边(两个顶点之间存在多条边)的图。若图中某两个顶点之间的边数大于1条,有允许顶点通过一条边和自身关联,则称多重图。
3.完全图
完全图是指每个顶点都与其他顶点直接相连的图。对于有向图,完全图要求每个顶点都有有向边指向其他顶点,并且每个顶点也接受其他顶点的有向边。
4.子图
给定一个图 G = (V, E),如果存在另一个图 G' = (V', E'),满足以下条件:
- V' 是 V 的一个子集,即 V' ⊆ V。
- E' 是 E 中对应 V' 中节点的边的子集,即 E' ⊆ E。
那么 G' 就是 G 的一个子图。
换句话说,子图 G' 是由原图 G 中的一些节点和这些节点之间的边组成的一个新的图。
5.连通图和连通分量
连通图是指图中任意两个顶点之间都存在一条路径。如果图可以划分为多个子图,使得子图内部的顶点相互连通, 而子图之间没有连接,那么这些子图就称为连通分量。
无向图中的极大连通子图称为连通分量。
有向图中,如果对于任意两个顶点 a 和 b,从 a 到 b 和 从 b 到 a 都存在路径,则称这两个顶点是强连通的,若图中任一对顶点都是强连通的,称此图为强连通图。有向图中的极大强连通子图被称为这个有向图的强连通分量。
6.顶点的度
度是图中顶点的一个重要概念,它表示某个顶点与其他顶点直接相连的边的数量。无向图的全部顶点的度之和等于边数的两倍 。对于有向图,度分为入度和出度,分别表示指向该顶点的边数和从该顶点指出的边数。有向图的全部顶点的入度和出度之和相等,并且等于边数。
7.稠密图和稀疏图
根据图中边的数量,图可以分为稀疏图和稠密图。稀疏图中边的数量相对较少,顶点之间的连接比较稀疏。稠密图中边的数量较多,顶点之间的连接较为紧密。
8.边的权
权是指图中边的附加信息,可以表示边的权重、距离、成本等。
9.路径、路径长度和回路
路径是图中一系列连续的边,连接了两个顶点。路径可以是有向的或无向的。相关算法就是求两个顶点之间的最短 路径。比如 Dijkstra、Bellman-Ford、Floyd、Dijkstra+Heap、SPFA 都是求最短路径的算法。
路径长度就是路径上的边的的数。
若第一个顶点和最后一个顶点相同的路径称为回路或环。若一个图有n个顶点,且有大于n-1条边,则此图一定有环。
10.生成树
生成树是连通图的一个子图,它是一棵树(没有循环的连通图),并且包含了图中的所有顶点。生成树可以用于图的遍历、最小生成树等问题的解决。常见的最小生成树算法有 Kruscal 和 Prim,其中 Kruscal 用到了并查集。
图的存储结构
图结构是非常复杂的结构,所以图的存储就是一个非常重要的部分,因为我们不仅要表示顶点集,还要表示边集。
以下有四种存储结构来存储图结构。
邻接矩阵
1.邻接矩阵的概念
由于图是由顶点和边(或弧)两部分组成的。顶点可以用一个一维的顺序表来存储,但是边(或弧)由于是顶点与 顶点之间的关系,一维搞不定,所以可以考虑用一个二维的顺序表来存储,而二维的顺序表就是一个矩阵。
对于一个有 n 个顶点的图 G,邻接矩阵是一个 n x n 的方阵(方阵就是行列数相等的矩阵)。对于邻接矩阵而言, 不需要去考虑是有向的还是无向的,统一都可以理解成有向的,因为有向图可以兼容无向图,对于无向图而言,只不 过这个矩阵是按照主对角线对称的,因为 A 到 B 有边,则必然 B 到 A 有边。
对带权图和无权图,邻接矩阵的表示略有差别,接下来我们分别来讨论。
2.无权图的邻接矩阵
对于一个 n x n 图中,采用一个 n x n 的方阵 adj[n][n],在这样一个矩阵里:
1)矩阵的行和列都对应图中的一个顶点。
2)如果顶点 A 到 顶点 B 有一条边(这里是单向的),则对应矩阵单元为 1。
3)如果顶点 A 到 顶点 B 没有边(这里同样是单向的),则对应的矩阵单元就为 0。 例如,对于一个有四个顶点的无权图,首先需要有一个顺序表来存储所有的顶点的 (A, B, C, D),图的邻接矩阵如下:
从这个矩阵中我们可以看出,A 能够到 B、D,B 能够到 A、C,C能够到 B、D,D能够到 A、C。如图所示:
简单解释一下,对于矩阵的主对角线的值 adj[0][0]、adj[1][1]、adj[2][2]、adj[3][3] 全为 0,因为这个图中,不存在 顶点自己到自己的边,adj[0][1]=1 是因为 A 到 B 的边存在,而 adj[2][0]=0 是是因为 C 到 A 的边不存在。对于无向图 而言,
它的邻接矩阵是一个对称矩阵。 有了这个矩阵,我们就可以很容易地知道图中的信息。
1)我们要判定任意两顶点之间是否有边就非常容易。
2)我们要知道某个顶点的度,其实就是这个顶点在邻接矩阵中 i 行的元素之和。
3)求顶点 i 的所有邻接点就是将矩阵中第 i 行元素扫描一遍,arc[i][j] 为 1 就是邻接点。
3.带权图的邻接矩阵
在带权图的邻接矩阵中,每个矩阵元素表示一个有向边的权值。如果不存在从一个节点到另一个节点的边,则通常 将其表示为特殊的值,如0,-1或无穷。
假设有一个有向带权图,它有4个顶点(A, B, C, D),边及其权重如下:
• 边 A->B 的权重是3
• 边 A->C 的权重是7
• 边 B->A 的权重是4
• 边 B->D 的权重是1
• 边 C->D 的权重是2
• 边 D->A 的权重是1
我们可以将这个有向带权图表示为以下的邻接矩阵:
在这个矩阵中,行表示起始顶点,列表示目标顶点。矩阵元素的值代表起始顶点到目标顶点的边的权重。如果没有 边存在,我们用0来表示。例如,第一行表示从A到各点的边的权重,可以看出有从A到B的边,权重为3,有从A到C的 边,权重为7,没有从A出发到达D的边,所以为0。
当然,什么情况下不能用0来代表边不存在的情况?
大多数情况下边权是正值,但个别时候真的有可能就是0,甚至有可能是负值。因此必须要用一个不可能的值来代表不存在。
4.邻接矩阵的优点
1)简单直观:邻接矩阵是一个二维顺序表,通过矩阵中的元素值可以直接表示顶点之间的连接关系,非常直观和 易于理解。
2)存储效率高:对于小型图,邻接矩阵的存储效率较高,因为它可以一次性存储所有顶点之间的连接关系,不需 要额外的空间来存储边的信息。
3)算法实现简单:许多图算法可以通过邻接矩阵进行简单而高效的实现,例如 遍历图、检测连通性等。
5.邻接矩阵的缺点
1)空间复杂度高:对于大型图,邻接矩阵的空间复杂度较高,因为它需要存储一个 n × n 的矩阵,这可能导致存 储空间的浪费和效率问题。
2)不适合稀疏图:邻接矩阵对于稀疏图(即图中大部分顶点之间没有连接)的表示效率较低,因为它会浪费大量 的存储空间来存储零元素。
6.邻接矩阵存储结构定义
cpp
//c++代码定义
#include <iostream>
#include <vector>
using namespace std;
class Graph {
private:
int n; // 顶点数
vector<vector<int>> adj_matrix; // 邻接矩阵
public:
// 构造函数
Graph(int n) {
this->n = n;
adj_matrix.resize(n, vector<int>(n, 0)); // 初始化邻接矩阵为全 0
}
// 添加边
void addEdge(int u, int v) {
adj_matrix[u][v] = 1; // 设置 u 到 v 的边权为 1
}
// 打印邻接矩阵
void printAdjMatrix() {
cout << "Adjacency Matrix:" << endl;
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
cout << adj_matrix[i][j] << " ";
}
cout << endl;
}
}
};
邻接表和链式前向星
邻接表(指针或数组下标)和链式前向星(容器模拟)的思路一样,只是表达方式不同。
1.邻接表的概念
邻接表是一种表示图的数据结构。邻接表的主要概念是:对于图中的每个顶点,维护一个由与其相邻的顶点组成的列表。这个列表可以用数组、链表或其他数据结构来实现。 实际上,邻接表可以用于有向图、无向图、带权图、无权图。这里只考虑无权图的情况,带权图只需要多存储一 个数据就可以了。
2.邻接表的顺序表存储
在C语言的静态数组中,如果要实现邻接表,一般图中的点的数量控制在1000 左右的量级,是比较合适的,如果在大一点,存储会产生问题。
在C++中,有 vector 这种柔性数组,所以可以支持百万的量级。当然,也可以用C语言的静态数组来模拟实现一个 C++中的柔性数组。
这里不讨论柔性数组的情况,只考虑 1000 量级的情况,如下:
cpp
#define maxn 1010
int adjSize[maxn];
int adj[maxn][maxn];
其中 adjSize[i] 代表 从 i 出发,能够直接到达的点的数量;
而 adj[i][j] 代表 从 i 出发,能够到达的第 j 个顶点;
在一个 n 个顶点的图上,由于任何一个顶点最多都有 n-1 个顶点相连,所以在C语言中,定义时必然要定义成二维数组,空间复杂度就是 O(n^2),对于一个稀疏图来说,数组实现,浪费空间严重,建议采用链表实现。
3.邻接表的链表存储
用链表来实现邻接表,实际上就是对于每个顶点,它能够到达的顶点,都被存储在以它为头结点的链表上。
对于如上的图,存储的就是四个链表:
这就是用链表表示的邻接表。注意:这里实际上每个链表的头结点是存储在一个顺序表中的,所以严格意义上来说 是顺序表 + 链表的实现。
4.邻接表的应用
邻接表一般应用在图的遍历算法,比如 深度优先搜索、广度优先搜索。更加具体的,应用在最短路上,比如 Dijkstra、Bellman-Ford、SPFA;以及最小生成树,比如 Kruskal、Prim; 还有 拓扑排序、强连通分量、网络流、二 分图最大匹配 等等问题。
5.邻接表的优点
邻接表表示法的优点主要有 空间效率、遍历效率。
1)空间利用率高:邻接表通常比邻接矩阵更节省空间,尤其是对于稀疏图。因为邻接表仅需要存储实际存在的 边,而邻接矩阵需要存储所有的边。
2)遍历速度:邻接表表示法在遍历与某个顶点相邻的所有顶点时,时间复杂度与顶点的度成正比。对于稀疏图, 这比邻接矩阵表示法的时间复杂度要低。
6.邻接表的缺点
1)不适合存储稠密图:对于稠密图(即图中边的数量接近于 n^2),导致每个顶点的边列表过长,从而降低存储 和访问效率。
2)代码复杂:相比于邻接矩阵,实现代码会更加复杂一些。
7.邻接表存储结构定义
cpp
//c++代码定义
#include <iostream>
#include <vector>
#include <list>
using namespace std;
class Graph {
private:
int n; // 顶点数
vector<list<int>> adj_list; // 邻接表
public:
// 构造函数
Graph(int n) {
this->n = n;
adj_list.resize(n); // 初始化邻接表
}
// 添加边
void addEdge(int u, int v) {
adj_list[u].push_back(v); // 将顶点 v 添加到顶点 u 的邻接表中
}
// 打印邻接表
void printAdjList() {
cout << "Adjacency List:" << endl;
for (int i = 0; i < n; i++) {
cout << i << ": ";
for (int neighbor : adj_list[i]) {
cout << neighbor << " ";
}
cout << endl;
}
}
};
十字链表
十字链表是有向图的一种链式存储结构。在十字链表中,有向图的每条狐用一个节点(弧节点)来表示,每个顶点也用一个节点(顶点节点)来表示。两种节点的结构存储如下
有向图的十字链表表示:
十字链表存储结构定义
cpp
//c++代码定义
#include <iostream>
#include <vector>
using namespace std;
// 十字链表节点结构
struct Node {
int row, col; // 节点在矩阵中的行和列
int value; // 节点的值
Node* left; // 左指针
Node* right; // 右指针
Node* up; // 上指针
Node* down; // 下指针
};
// 十字链表类
class SparseMatrix {
private:
vector<Node*> rowHead; // 行头节点数组
vector<Node*> colHead; // 列头节点数组
int rows, cols; // 矩阵的行数和列数
public:
// 构造函数
SparseMatrix(int r, int c) {
rows = r;
cols = c;
rowHead.resize(r, nullptr);
colHead.resize(c, nullptr);
}
// 插入元素
void insert(int row, int col, int value) {
Node* newNode = new Node{row, col, value, nullptr, nullptr, nullptr, nullptr};
// 插入行链表
if (rowHead[row] == nullptr || rowHead[row]->col > col) {
newNode->right = rowHead[row];
rowHead[row] = newNode;
} else {
Node* temp = rowHead[row];
while (temp->right != nullptr && temp->right->col < col) {
temp = temp->right;
}
newNode->right = temp->right;
temp->right = newNode;
}
// 插入列链表
if (colHead[col] == nullptr || colHead[col]->row > row) {
newNode->down = colHead[col];
colHead[col] = newNode;
} else {
Node* temp = colHead[col];
while (temp->down != nullptr && temp->down->row < row) {
temp = temp->down;
}
newNode->down = temp->down;
temp->down = newNode;
}
}
// 访问元素
int get(int row, int col) {
Node* temp = rowHead[row];
while (temp != nullptr && temp->col <= col) {
if (temp->col == col) {
return temp->value;
}
temp = temp->right;
}
return 0;
}
};
邻接多重表
邻接多重表是无向图的一种链式存储结构。在邻接表中,容易求得顶点和边的各种信息, 但在邻接表中求两个顶点之间是否存在边而对边执行删除等操作时,需要分别在两个顶点的边表中遍历,效率较低。与十字链表类似,在邻接多重表中,每条边用一个结点表示,其结构如下所示。
其中,ivex和jvex这两个域指示该边依附的两个顶点的编号:iink域指向下一条依附于顶点ivex的边;jlink域指向下一条依附于顶点jvex的边,info域存放该边的相关信息。
每个顶点也用一个结点表示,它由如下所示的两个域组成。
其中, data域存放该顶点的相关信息,firstedae 域指向第一条依附于该顶点的边。
在邻接多重表中,所有依附于同一顶点的边串联在同一链表中,因为每条边依附于两个顶点,所以每个边结点同时链接在两个链表中。对无向图而言,其邻接多重表和邻接表的差别仅在于,同一条边在邻接表中用两个结点表示,而在邻接多重表中只有一个结点。
无向图的邻接多重表表示:
邻接多重表存储结构定义
cpp
//c++代码定义
#include <iostream>
#include <vector>
#include <list>
using namespace std;
// 边的结构体
struct Edge {
int dest; // 目标顶点
int weight; // 边的权重
};
// 顶点的结构体
struct Vertex {
int data; // 顶点数据
list<Edge> adj_list; // 邻接表
};
// 图的类
class Graph {
private:
int num_vertices; // 顶点数
vector<Vertex> vertices; // 顶点数组
public:
// 构造函数
Graph(int n) {
num_vertices = n;
vertices.resize(n);
for (int i = 0; i < n; i++) {
vertices[i].data = i;
}
}
// 添加边
void add_edge(int src, int dest, int weight) {
Edge e = {dest, weight};
vertices[src].adj_list.push_back(e);
}
// 打印图
void print_graph() {
for (int i = 0; i < num_vertices; i++) {
cout << "Vertex " << vertices[i].data << ": ";
for (auto it = vertices[i].adj_list.begin(); it != vertices[i].adj_list.end(); ++it) {
cout << "(" << it->dest << ", " << it->weight << ") ";
}
cout << endl;
}
}
};
图的四种存储方式的总结
图的遍历
图的遍历是指从图中的某一顶点出发,按照某种搜索方法沿着图中的边对图中的所有顶点访问一次,且仅访问一次。注意到树是一种特殊的图,所以树的遍历实际上也可视为一种特殊的图的遍历。图的遍历算法是求解图的连通性问题、拓扑排序和求关键路径等算法的基础。
图的遍历比树的遍历要复杂得多,因为图的任意一个顶点都可能和其余的顶点相邻接,所以在访问某个顶点后,可能沿着某条路径搜索又回到该顶点。为避免同一顶点被访问多次,在遍历图的过程中,必须记下每个已访问过的顶点,为此可以设一个辅助数组visite[ ]来标记顶点是否被访问过。图的遍历算法主要有两种:广度优先搜索和深度优先搜索。
深度优先搜索(DFS)
1)算法原理
深度优先搜索(Depth First Search),是图遍历算法的一种。用一句话概括就是:"一直往下走,走不通回头,换条路再走,直到无路可走"。具体算法描述为:
选择一个起始点 u 作为 当前结点,执行如下操作:
a. 访问 当前结点,并且标记该结点已被访问,然后跳转到 b;
b. 如果存在一个和 当前结点 相邻并且尚未被访问的结点 v,则将 v 设为 当前结点,继续执行 a;
c. 如果不存在这样的 v,则进行回溯,回溯的过程就是回退 当前结点;
上述所说的当前结点需要用一个栈来维护,每次访问到的结点入栈,回溯的时候出栈。
2)算法实现
【例题】给定一个 n 个结点的无向图,要求从 0 号结点出发遍历整个图,求输出整个过程的遍历序列。其中, 遍历规则为:
1)如果和 当前结点 相邻的结点已经访问过,则不能再访问;
2)每次从和 当前结点 相邻的结点中寻找一个编号最小的没有访问的结点进行访问;
对上图以深度优先的方式进行遍历,起点是 0;
第一步,当前结点为 0,标记已访问,然后从相邻结点中找到编号最小的且没有访问的结点 1;
第二步,当前结点为 1,标记已访问,然后从相邻结点中找到编号最小的且没有访问的结点 3;
第三步,当前结点为 3,标记已访问,没有尚未访问的相邻结点,执行回溯,回到结点 1;
第四步,当前结点为 1,从相邻结点中找到编号最小的且没有访问的结点 4;
第五步,当前结点为 4,标记已访问,然后从相邻结点中找到编号最小的且没有访问的结点 5;
第六步,当前结点为 5,标记已访问,然后从相邻结点中找到编号最小的且没有访问的结点 2;
第七步,当前结点为 2,标记已访问,然后从相邻结点中找到编号最小的且没有访问的结点 6;
第八步,当前结点为 6,标记已访问,没有尚未访问的相邻结点,执行回溯,回到结点 2;
第九步,按照 2 => 5 => 4 => 1 => 0 的顺序一路回溯,搜索结束;
如图所示,红色实箭头表示搜索路径,蓝色虚箭头表示回溯路径。
上图中,红色块表示往下搜索,蓝色块表示往上回溯,遍历序列为:
0 -> 1 -> 3 -> 4 -> 5 -> 2 -> 6
3)代码
cpp
const int MAXN = 7;
void dfs(int u) {
if(visit[u]) { // 1
return ;
}
visit[u] = true; // 2
dfs_add(u); // 3
for(int i = 0; i < MAXN; ++i) {
int v = i;
if(adj[u][v]) { // 4
dfs(v); // 5
}
}
1、visit[MAXN] 数组是一个bool数组,用于标记某个节点是否已访问,初始化都为 false;这里对已访问结点执行 回溯;
2、visit[u] = true; 对未访问结点 u 标记为已访问状态;
3、dfs_add(u); 用来将 u 存储到的访问序列中,实际函数实现如下:
cpp
void dfs_add(int u) {
ans[ansSize++] = u;
}
4、adj[MAXN][MAXN] 是图的邻接矩阵,用 0 或 1 来代表点是否连通,对于上面的例子,邻接矩阵表示如下:
cpp
bool adj[MAXN][MAXN] = {
{0, 1, 1, 0, 0, 0, 0},
{1, 0, 0, 1, 1, 0, 0},
{1, 0, 0, 0, 0, 1, 1},
{0, 1, 0, 0, 0, 0, 0},
{0, 1, 0, 0, 0, 1, 0},
{0, 0, 1, 0, 1, 0, 0},
{0, 0, 1, 0, 0, 0, 0},
};
( adj[u][v] = 1 代表 u 和 v 之间有一条有向边;adj[u][v] = 0 代表没有边)
5、递归调用相邻结点
4)算法性能分析
DFS算法是一个递归算法,需要借助一个递归工作栈,所以其空间复杂度为o(|V|)。
遍历图的过程实质上是通过边查找邻接点的过程,因此两种遍历方式的时间复杂度都相同,不同之处仅在于对顶点访问顺序的不同。采用邻接矩阵存储时,总时间复杂度为O(|V|^2)。采用邻接表存储时,总的时间复杂度为O(|V|+|E|)。
5)深度优先的生成树和生成森林
对连通图调用DFS才能产生深度优先生成树,否则产生的将是深度优先生成森林
广度优先搜索(BFS)
1)算法原理
广度优先搜索(Breadth-First Search, BFS)算法是一种遍历图的方法,它从起点开始,先访问所有相邻的顶点,然后再访问这些顶点的相邻顶点,直到所有可达的顶点都被访问到。用一句话概括就是"一层又一层,泛起层层涟漪"。具体算法描述如下:
- 选择一个起始顶点作为根节点。
- 将根节点加入到一个队列中。
- 从队列中取出一个顶点,访问该顶点。
- 将该顶点的所有未访问过的相邻顶点加入到队列中。
- 重复步骤 3 和 4,直到队列为空。
2)算法实现
【例题】给定一无向图G如图,要求从 结点a 开始,采用BFS算法遍历整个图。
对于上图采用广度优先搜索:
第一步,a先入队。
第二步,此时队列非空,取出队头元素a,因为b,c与a邻接且未被访问过,于是依次访问b, c,并将b, c依次入队。
第三步,队列非空,取出队头元素b,依次访问与b邻接且未被访问的顶点d, e,并将d,e入队(注意:a与b也邻接,但a已置访问标记,所以不再重复访问)。
第四步,此时队列非空,取出队头元素c,访问与c邻接且未被访问的顶点f,g,并将f,g 入队。
第五步,此时,取出队头元素d,但与d邻接且未被访问的顶点为空,所以不进行任何操作。继续取出队头元素e,将h入队列。
最终取出队头元素h后,队列为空,从而循环自动跳出。最终得到的遍历序列:
a -> b -> c -> d -> e -> f ->g -> h
3)代码
cpp
void bfs(char start) {
// 创建一个队列和一个访问标记数组
queue<char> q;
vector<bool> visited(graph.size(), false);
// 将起始顶点入队并标记为已访问
q.push(start);
visited[start - 'a'] = true;
// 执行 BFS 遍历
while (!q.empty()) {
char u = q.front(); //取出队头
q.pop();
cout << u << " ";
// 将 u 的所有未访问过的相邻顶点入队并标记为已访问
for (char v : graph[u - 'a']) {
if (!visited[v - 'a']) {
q.push(v);
visited[v - 'a'] = true;
}
}
}
}
其中邻接表graph存储图:
cpp
// 图的邻接表表示
vector<vector<char>> graph = {
{'b', 'c'}, // 顶点 a 的邻接顶点
{'a', 'd', 'e'}, // 顶点 b 的邻接顶点
{'a', 'f', 'g'}, // 顶点 c 的邻接顶点
{'b'}, // 顶点 d 的邻接顶点
{'b','h'}, // 顶点 e 的邻接顶点
{'c'}, // 顶点 f 的邻接顶点
{'c'}, // 顶点 g 的邻接顶点
{'e'} // 顶点 h 的邻接顶点
};
从上例不难看出,图的广度优先搜索的过程与二叉树的层序遍历是完全一致的,这也说明了图的广度优先搜索遍历算法是二叉树的层次遍历算法的扩展。
4)算法性能分析
无论是邻接表还是邻接矩阵的存储方式,BFS算法都需要借助一个辅助队列Q, n个顶点均需入队一次,在最坏的情况下,空间复杂度为O(|V|)。
遍历图的过程实质上是对每个顶点查找其邻接点的过程,耗费的时间取决于所采用的存储结构。采用邻接表存储时,每个顶点均需搜索(或入队)一次,时间复杂度为O(|V|),在搜索每个顶点的邻接点时,每条边至少访问一次,时间复杂度为O(|E|),总的时间复杂度为O(|V|+|E|)。采用邻接矩阵存储时,查找每个顶点的邻接点所需的时间为O(|V|),总时间复杂度为O(|V|^2)。
5)BFS算法求解单源最短路径问题
若图G=(V, E)为非带权图,定义从顶点u到顶点v的最短路径d(u,v)为从u到v的任何路径中最少的边数;若从u到v没有通路,则d(u, v)=∞。
使用BFS,我们可以求解一个满足上述定义的非带权图的单源最短路径问题,这是由广优先搜索总是按照距离由近到远来遍历图中每个顶点的性质决定的。
【例题】
解题思路:
用bfs算法更新起点到其他结点的距离,如果边界条件满足,令当前顶点step + 1 并入队,继续遍历其他顶点,当找到终点时,返回其距离就是我们找的最短路径。
AC代码:
cpp
#include <iostream>
#include <queue>
using namespace std;
const int N = 105;
char mp[N][N]; //定义邻接矩阵
bool vis[N][N]; //定义标记数组
int n, m, x2, y2, ans = -1;
int dir[4][2] = { {-1,0},{1,0},{0,1},{0,-1} }; //上下左右四个方向
//定义位置及起点到该点的步数
struct node {
int x, y;
int step;
};
queue<node> qe;
//判断是否超出地图
bool in(int x, int y) {
return x > 0 && x <= n && y > 0 && y <= m;
}
void bfs(int x1, int y1) {
//起点入队
qe.push({ x1,y1,0 });
vis[x1][y1] = true;
while (!qe.empty()) {
//队头出队
node t = qe.front();
qe.pop();
//若找到终点,将step赋给答案
if (t.x == x2 && t.y == y2) {
ans = t.step;
return;
}
//遍历四个方向
for (int i = 0; i < 4; ++i) {
int tx = t.x + dir[i][0];
int ty = t.y + dir[i][1];
if (in(tx, ty) && !vis[tx][ty] && mp[tx][ty] == '1') {
vis[tx][ty] = true;
qe.push({ tx,ty,t.step + 1 });
}
}
}
}
int main() {
cin >> n >> m;
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= m; ++j) {
cin >> mp[i][j];
}
}
int x1, y1;
cin >> x1 >> y1 >> x2 >> y2;
bfs(x1, y1);
cout << ans;
return 0;
}
【例题】 2368. 受限条件下可到达节点的数目 - 力扣(LeetCode)
题目描述
解题思路:
先用哈希表标记受限制的顶点,建图时,只需要建立未标记的顶点连接的图,然后用BFS从0开始扫一遍,将能访问的顶点计数器加一,最后就是0能访问不受限制的结点。
AC代码:
cpp
class Solution {
public:
int bfs(vector<vector<int>> &G, vector<bool> &vis) {
int ans = 0;
queue<int> q;
q.push(0);
while (!q.empty()) {
int u = q.front();
q.pop();
++ans;
vis[u] = true;
for (auto v : G[u]) {
if (!vis[v]) {
q.push(v);
}
}
}
return ans;
}
int reachableNodes(int n, vector<vector<int>>& edges, vector<int>& restricted) {
unordered_set<int> op(restricted.begin(), restricted.end()); // 使用集合加快查找效率
// 建图
vector<vector<int>> G(n);
for (auto& edge : edges) {
int u = edge[0], v = edge[1];
// 确保这条边的两个端点都不在限制列表中
if (op.find(u) == op.end() && op.find(v) == op.end()) {
G[u].push_back(v);
G[v].push_back(u);
}
}
vector<bool> vis(n, false);
int ans = bfs(G, vis);
return ans;
}
};
6)广度优先生成树
在广度遍历的过程中,我们可以得到一棵遍历树,称为广度优先生成树。 需要注意的是,同一个图的邻接矩阵存储表示是唯一的,所以其广度优先生成树也是唯一的, 但因为邻接表存储表示不唯一,所以其广度优先生成树也是不唯一的。
以上是图的基本内容,接下来才到本文的硬核
图的应用
最小生成树
定义
在无向图中,连通而且不含有圈(环路)的图,称为树。 最小生成树 MST:一个有
n
个结点的连通图的生成树是原图的极小连通子图,包含原图中的所有n
个结点,并且边的权值之和最小。
Prim算法
对点进行贪心操作:"最近的邻居一定在 MST 上"。 从任意一个点
u
开始,把距离它最近的点v
加入到 MST 中;下一步,把距离 {u, v
} 最近的点w
加入到 MST 中;继续这个过程,直到所有点都在 MST 中。
Prim算法的具体步骤如下:
假设G = {V,E}是连通图,其最小生成树T={U,Et},Et是最小生成树中边的集合。
初始化:向空树T=(U,Et)中添加图G = (V,E)的任意一个顶点u0,使U={u0},E{T}=Ø。
循环(重复下列操作直至U = V):从图G中选择满足{(u,v) | u∈U, v∈V-U}且具有最小权值的边(u, v),加入树T,置U = U ∪ {v} , Et=Et ∪ {(u, v)}。
代码:
cpp
#include <iostream>
#include <vector>
using namespace std;
const int INF = 0x3f3f3f3f;
const int MAXN = 1005;
vector<int> demo;
int visited[MAXN], lowcost[MAXN], m, n;//m为节点的个数,n为边的数量
int G[MAXN][MAXN];//邻接矩阵
int prim(){
for (int i = 0; i < m; i++)
lowcost[i] = INF;
for (int i = 0; i < m; i++)
visited[i] = 0;
visited[0] = -1; //加入第一个点,-1表示该点在集合U中,否则在集合V中
int num = 0, ans = 0, v = 0; //v为最新加入集合的点
while (num < m - 1){ //加入m-1条边
int mincost = INF, minvertex = -1;
for (int i = 0; i < m; i++)
if (visited[i] != -1){
int temp = G[v][i];
if (temp < lowcost[i]){
lowcost[i] = temp;
visited[i] = v;
}
if (lowcost[i] < mincost)
mincost = lowcost[minvertex = i];
}
ans += mincost;
demo.push_back(mincost);
visited[v = minvertex] = -1; //标记已加入顶点
num++;
}
return ans;
}
int main(){
scanf("%d %d", &m, &n);
memset(G, INF, sizeof(G));
for (int i = 0; i < n; ++i){
int a, b, c; //a为起点,b终点,c权值
cin >> a >> b >> c;
G[b][a] = G[a][b] = c;
}
cout << prim() << endl;
for (int i = 0; i < m - 1; i++) cout << demo[i] << " ";
return 0;
}
Prim算法的时间复杂度为O(|V|^2),不依赖于|E|,因此它适用于稠密图。
kruskal 算法
对边进行贪心操作:"最短的边一定在
MST
上"。 从最短的边开始,把它加入到MST
中;在剩下的边中找最短的边,加入到MST
中;继续这个过程,直到所有点都在MST
中。
Kruskal算法的具体步骤如下:
kruskal
算法的 2 个关键技术: (1)对边进行排序。 (2)判断圈,即处理连通性问题。这个问题用并查集简单而高效,并查集是 kruskal
算法的实现基础。
初始时最小生成树 MST
为空。开始的时候,每个点属于独立的集。
按边长从小到大进行边的遍历操作:
尝试将最小边加入最小生成树:
- 如果边的两个端点属于同一个集合,就说明这两个点已经被加入最小生成树。则不能将边加入,否则就会生成一个环。
- 如果两个端点不属于同一个集合,就说明该点还未纳入最小生成树,此边可以加入。
重复上述操作,直到加入 n-1
条边。
代码:
cpp
#include <iostream>
#include <algorithm>
using namespace std;
const int maxn = 11000000;
int n, m;
int pre[maxn];
struct edge {
int x;
int y;
int k;
} Q[maxn];
int find(int x) {
if (pre[x] == x)
return x;
return pre[x] = find(pre[x]); //路径压缩
}
bool cmp(edge a, edge b) { //按权值排序
return a.k < b.k;
}
int main() {
scanf("%d %d", &n, &m);
int cont = 0, sum = 0, num = 0;
for (int i = 0; i < m; i++) {
scanf("%d %d %d", &Q[i].x, &Q[i].y, &Q[i].k);
cont += Q[i].k;
}
sort(Q, Q + m, cmp);
for (int i = 1; i <= n; i++) //初始化并查集
pre[i] = i;
for (int i = 0; i < m; i++) {
int rx = find(Q[i].x);
int ry = find(Q[i].y);
if (rx != ry) { //判环
sum += Q[i].k;
pre[rx] = ry;
num++;
if (num == n - 1) //边数等于n-1时结束循环,此时已经构成最小生成树
break;
}
}
printf("%d\n", sum);
return 0;
}
kruskal
算法的复杂度包括两部分:对边的排序 O(ElogE)
,并查集的操作 O(E)
,一共是 O(ElogE + E)
,约等于 O(ElogE)
,时间主要花在排序上。如果图的边很多,kruskal
的复杂度要差一些。kruskal
适用于稀疏图。
例题
解题思路:
AC代码:
cpp
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
const int N = 100001;
int a[N], x[N], y[N], pre[N];
int n, m;
struct edge {
int u;
int v;
double w;
};
bool cmp(edge a, edge b) {
return a.w < b.w;
}
vector<edge> e;
int root(int x) {
return x == pre[x] ? x : pre[x] = root(pre[x]);
}
int main() {
cin >> m;
for (int i = 1; i <= m; ++i) cin >> a[i];
cin >> n;
for (int i = 1; i <= n; ++i) {
cin >> x[i] >> y[i];
pre[i] = i;
}
//建图
for (int i = 1; i <= n; ++i) {
for (int j = i + 1; j <= n; ++j) {
double w = sqrt((x[i] - x[j]) * (x[i] - x[j]) + (y[i] - y[j]) * (y[i] - y[j]));
e.push_back({ i,j,w });
}
}
sort(e.begin(), e.end(), cmp);
int edge_num = 0;
double maxw = 0.0;
for (int i = 0; i < e.size(); ++i) {
int fu = root(e[i].u);
int fv = root(e[i].v);
if (fu != fv) {
edge_num++;
pre[fu] = fv;
maxw = max(maxw, e[i].w);
}
if (edge_num == n - 1) {
break;
}
}
int res = 0;
for (int i = 1; i <= m; ++i) {
if (a[i] >= maxw) res++;
}
cout << res;
return 0;
}
例题
解题思路:
给了 n
个节点,又给了 n
个基点之间相互连接需要多少钱,现在要 n
个村庄都通电,只需要保证 n
个节点构成连通子图即可。
最小的连通子图是树,也就是构造一棵树,那么在图上构造一棵最最少花费的树的问题即为最小生成树。
AC代码:
cpp
#include <iostream>
#include <algorithm>
#include <vector>
#include <cstring>
using namespace std;
const int N = 1005;
int pre[N], x[N], y[N], h[N];
struct edges {
int u;
int v;
double w;
bool operator <(const edges& a) const { //重载小于号
return w < a.w;
}
};
vector<edges> e;
int root(int x) {
return x == pre[x] ? x : pre[x] = root(pre[x]);
}
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; ++i) {
cin >> x[i] >> y[i] >> h[i];
pre[i] = i; //初始化并查集
}
//建图
for (int i = 1; i <= n; ++i) {
for (int j = i + 1; j <= n; ++j) {
double w = sqrt((x[i] - x[j]) * (x[i] - x[j]) + (y[i] - y[j]) * (y[i] - y[j])) + (h[i] - h[j]) * (h[i] - h[j]);
e.push_back({ i,j,w });
}
}
sort(e.begin(), e.end());
int edge_num = 0;
double sumPrice = 0.0;
for (int i = 0; i < e.size(); ++i) {
int ru = root(e[i].u);
int rv = root(e[i].v);
if (ru != rv) {
edge_num++;
pre[ru] = rv;
sumPrice += e[i].w;
}
if (edge_num == n - 1) break;
}
printf("%.2lf", sumPrice);
return 0;
}
最短路径
最广为人知的图论问题就是最短路径问题。
简单图的最短路径
- 树上的路径:任意 2 点之间
只有一条路径
- 所有边长都为 1 的图:用
BFS
搜最短路径,复杂度 𝑂(𝑛+𝑚)普通图的最短路径
- 边长:不一定等于 1,而且可能为负数
- 算法:
Floyd
、Dijkstra
、SPFA
等,各有应用场景,不可互相替代
Floyd 算法
求所有顶点之间的最短路径问题
算法思想
Floyd 算法思想:动态规划
- 动态规划:求图上两点
i
、j
之间的最短距离,按"从小图到全图"的步骤,在逐步扩大图的过程中计算和更新最短路。- 定义状态:
dp[k][i][j],i、j、k
是点的编号,范围1 ~ n
。状态dp[k][i][j]
表示在包含1 ~ k
点的子图上,点对i、j
之间的最短路。- 状态转移方程:从子图
1 ~ k-1
扩展到子图1 ~ k
𝑑𝑝[𝑘][𝑖][𝑗]=𝑚𝑖𝑛(𝑑𝑝[𝑘−1][𝑖][𝑗],𝑑𝑝[𝑘−1][𝑖][𝑘]+𝑑𝑝[𝑘−1][𝑘][𝑗])dp[k][i][j]=min(dp[k−1][i][j],dp[k−1][i][k]+dp[k−1][k][j])
首先是包含 1 ~ k-1
点的子图。 𝑑𝑝[𝑘−1][𝑖][𝑗]dp[k−1][i][j]:不包含 k
点子图内的点对 i、j
的最短路; 𝑑𝑝[𝑘−1][𝑖][𝑘]+𝑑𝑝[𝑘−1][𝑘][𝑗]dp[k−1][i][k]+dp[k−1][k][j]:经过 k 点的新路径的长度,即这条路径从 i
出发,先到 k
,再从 k
到终点 j
。 比较:不经过 k
的最短路径𝑑𝑝[𝑘−1][𝑖][𝑗]dp[k−1][i][j]和经过 k
的新路径,较小者就是新的𝑑𝑝[𝑘][𝑖][𝑗]dp[k][i][j]。
所以 Floyd
的原理就是每次引入一个新的点,用它去更新其他点的最短距离。
k
从 1
逐步扩展到 n
:最后得到的𝑑𝑝[𝑛][𝑖][𝑗]dp[n][i][j]是点对 i、j
之间的最短路径长度。 初值𝑑𝑝[0][𝑖][𝑗]dp[0][i][j]:若 i、j
是直连的,就是它们的边长;若不直连,赋值为无穷大。 i、j
是任意点对:计算结束后得到了所有点对之间的最短路。
𝑑𝑝[𝑘][𝑖][𝑗]=𝑚𝑖𝑛(𝑑𝑝[𝑘−1][𝑖][𝑗],𝑑𝑝[𝑘−1][𝑖][𝑘]+𝑑𝑝[𝑘−1][𝑘][𝑗])dp[k][i][j]=min(dp[k−1][i][j],dp[k−1][i][k]+dp[k−1][k][j]) 用滚动数组简化: 𝑑𝑝[𝑖][𝑗]=𝑚𝑖𝑛(𝑑𝑝[𝑖][𝑗],𝑑𝑝[𝑖][𝑘]+𝑑𝑝[𝑘][𝑗])dp[i][j]=min(dp[i][j],dp[i][k]+dp[k][j])
效率不高,不能用于大图在某些场景下有自己的优势,难以替代。能做传递闭包问题(离散数学)
代码
cpp
//c/c++
for(int k = 1; k <= n; k++) //floyd的三重循环
for(int i = 1; i <= n; i++)
for(int j = 1; j <= n; j++) // k循环在i、j循环外面
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k][j]);
特点
- 在一次计算后求得所有结点之间的最短距离。
- 代码极其简单,是最简单的最短路算法。
- 效率低下,计算复杂度是 𝑂(𝑛^3),只能用于 n<300 的小规模的图。
- 存图用邻接矩阵
dp[][]
。因为Floyd
算法计算的结果是所有点对之间的最短路,本身就需要n^2
的空间,用矩阵存储最合适。 - 能判断负圈。 负圈:若图中有权值为负的边,某个经过这个负边的环路,所有边长相加的总长度也是负数,这就是负圈。在这个负圈上每绕一圈,总长度就更小,从而陷入在负圈上兜圈子的死循环。
Floyd
算法很容易判断负圈,只要在算法运行过程出现任意一个dp[i][i] < 0
就说明有负圈。因为dp[i][i]
是从i
出发,经过其他中转点绕一圈回到自己的最短路径,如果小于零,就存在负圈。
Dijkstra 算法
单源最短路径问题
算法思想
Dijkstra
算法算是贪心思想实现的,首先把起点到所有点的距离存下来找个最短的,然后松弛一次再找出最短的,所谓的松弛操作就是,遍历一遍看通过刚刚找到的距离最短的点作为中转站会不会更近,如果更近了就更新距离,这样把所有的点找遍之后就存下了起点到其他所有点的最短距离。
为什么是每次都是找最小的?
因为最小边的不会被其它的点松弛,只有可能最小边去松弛别人。 如果存在一个点 𝐾 能够松弛 𝑎𝑏 的话那么一定有 𝑎𝑘 距离加上 𝑘𝑏 的距离小于 𝑎𝑏,已知 𝑎𝑏 最短,所以不存在 𝑎𝑘+𝑘𝑏<𝑎𝑏+kb<ab。
Dijkstra
算法应用了贪心法的思想,即"抄近路走,肯定能找到最短路径"。
算法高效稳定:
Dijkstra
的每次迭代,只需要检查上次已经确定最短路径的那些结点的邻居,检查范围很小,算法是高效的;- 每次迭代,都能得到至少一个结点的最短路径,算法是稳定的
优先队列实现:
- 每次往队列中放新数据时,按从小到大的顺序放,采用小顶堆的方式,复杂度是 𝑂(𝑙𝑜𝑔𝑛),保证最小的数总在最前面;
- 找最小值,直接取
B
的第一个数,复杂度是 𝑂(1)。 - 复杂度:用优先队列时,
Dijkstra
算法的复杂度是 𝑂(𝑚𝑙𝑜𝑔𝑛),是最高效的最短路算法。
具体操作步骤:
维护两个集合:已确定最短路径的结点集合 A
、这些结点向外扩散的邻居结点集合 B
。
- 把起点
s
放到A
中,把s
所有的邻居放到B
中。此时,邻居到s
的距离就是直连距离。 - 从
B
中找出距离起点s
最短的结点u
,放到A
中。 - 把
u
所有的新邻居放到B
中。显然,u
的每一条边都连接了一个邻居,每个新邻居都要加进去。其中u
的一个新邻居v
,它到s
的距离dis(s, v)
等于dis(s, u) + dis(u, v)
。 - 重复(2)、(3),直到
B
为空时,结束。
Dijkstra 的局限性是边的权值不能为负数:
Dijkstra 基于 BFS
,计算过程是从起点 s
逐步往外扩散的过程,每扩散一次就用贪心得到到一个点的最短路。 扩散要求路径越来越长,如果遇到一个负权边,会导致路径变短,使扩散失效。
代码
朴素版:
cpp
#include <iostream>
#include <vector>
#include <climits>
#include <algorithm>
using namespace std;
void dijkstra(int src, vector<vector<pair<int, int>>> &graph, vector<int> &dist) {
int n = graph.size(); // 假设图以0到n-1编号
vector<bool> visited(n, false); // 记录每个顶点是否已被访问
dist[src] = 0; // 起始顶点到自身的距离为0
for (int count = 0; count < n - 1; ++count) {
// 在未访问的顶点中找到距离最小的顶点
int minDist = INT_MAX, minIndex;
for (int v = 0; v < n; ++v) {
if (!visited[v] && dist[v] < minDist) {
minDist = dist[v];
minIndex = v;
}
}
// 将找到的最小距离顶点标记为已访问
visited[minIndex] = true;
// 更新所有与minIndex相邻的未访问顶点的距离
for (const auto &neighbor : graph[minIndex]) {
int neighborNode = neighbor.first;
int edgeWeight = neighbor.second;
if (!visited[neighborNode] && dist[minIndex] + edgeWeight < dist[neighborNode]) {
dist[neighborNode] = dist[minIndex] + edgeWeight;
}
}
}
}
int main() {
int n, m; // n: 顶点数量, m: 边的数量
cin >> n >> m;
vector<vector<pair<int, int>>> graph(n); // 图用邻接表表示,每个元素是一个pair<int, int>(邻居节点, 边的权重)
// 输入边的信息
for (int i = 0; i < m; ++i) {
int u, v, w;
cin >> u >> v >> w;
graph[u].emplace_back(v, w);
// 如果图是无向的,还需要添加一条从v到u的边
// graph[v].emplace_back(u, w);
}
int src; // 起始顶点
cin >> src;
vector<int> dist(n, INT_MAX); // 初始化所有顶点到src的距离为无穷大
dijkstra(src, graph, dist);
// 输出从src到各顶点的最短距离
for (int i = 0; i < n; ++i) {
cout << "Distance from " << src << " to " << i << " is " << (dist[i] == INT_MAX ? "INF" : to_string(dist[i])) << endl;
}
return 0;
}
堆优化版:
cpp
#include <iostream>
#include <vector>
#include <queue>
#include <climits>
using namespace std;
void dijkstraHeap(int src, vector<vector<pair<int, int>>> &graph, vector<int> &dist) {
int n = graph.size(); // 假设图以0到n-1编号
vector<bool> visited(n, false); // 记录每个顶点是否已被访问
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> pq; // 小顶堆,按距离排序
dist[src] = 0; // 起始顶点到自身的距离为0
pq.push({0, src}); // 将起始顶点及其距离推入堆
while (!pq.empty()) {
int u = pq.top().second; // 取出堆顶元素(当前最短距离的顶点)
pq.pop();
if (visited[u]) continue; // 若该顶点已被访问过,则忽略
visited[u] = true;
// 更新所有与u相邻的未访问顶点的距离
for (const auto &neighbor : graph[u]) {
int v = neighbor.first;
int weight = neighbor.second;
if (!visited[v] && dist[u] + weight < dist[v]) {
dist[v] = dist[u] + weight;
pq.push({dist[v], v}); // 将更新后距离的顶点推入堆
}
}
}
}
int main() {
int n, m; // n: 顶点数量, m: 边的数量
cin >> n >> m;
vector<vector<pair<int, int>>> graph(n); // 图用邻接表表示,每个元素是一个pair<int, int>(邻居节点, 边的权重)
// 输入边的信息
for (int i = 0; i < m; ++i) {
int u, v, w;
cin >> u >> v >> w;
graph[u].emplace_back(v, w);
// 如果图是无向的,还需要添加一条从v到u的边
// graph[v].emplace_back(u, w);
}
int src; // 起始顶点
cin >> src;
vector<int> dist(n, INT_MAX); // 初始化所有顶点到src的距离为无穷大
dijkstraHeap(src, graph, dist);
// 输出从src到各顶点的最短距离
for (int i = 0; i < n; ++i) {
cout << "Distance from " << src << " to " << i << " is " << (dist[i] == INT_MAX ? "INF" : to_string(dist[i])) << endl;
}
return 0;
}
Bellman-Ford 算法
算法思想
BFS 的扩散思想,每个人都去问自己的相邻节点到
S
点的距离最近是多少。第一轮至少有一个点得到了到
S
的最短距离,即与S
相邻的节点,标记为T1
重复以上操作,那么必然至少又有一个节点找到了与
S
的最短距离,即与T1
相邻的节点,标记为T2。
一共需要几轮操作?
每一轮操作,都至少有一个新的结点得到了到 S
的最短路径。所以,最多只需要 n
轮操作,就能完成 n
个结点。在每一轮操作中,需要检查所有 m
个边,更新最短距离。
Bellman-Ford 算法的复杂度:O(nm)
。
Bellman-Ford 能判断负圈:
没有负圈时,只需要 n
轮就结束。
如果超过 n
轮,最短路径还有变化,那么肯定有负圈。
SPFA 算法
算法思想
队列优化版的 Bellman-Ford
SPFA = 队列处理+Bellman-Ford。
Bellman-Ford 算法有很多低效或无效的操作。其核心内容,是在每一轮操作中,更新所有结点到起点
S
的最短距离。 计算和调整一个结点U
到S
的最短距离后,如果紧接着调整U
的邻居结点,这些邻居肯定有新的计算结果;而如果漫无目的地计算不与U
相邻的结点,很可能毫无变化,所以这些操作是低效的。
改进: 计算结点 U
之后,下一步只计算和调整它的邻居,能加快收敛的过程。 这些步骤用队列进行操作,这就是 SPFA。
(1)起点 S
入队,计算它所有邻居到 S
的最短距离。把 S
出队,状态有更新的邻居入队,没更新的不入队。 (2)现在队列的头部是 S
的一个邻居 U
。弹出 U
,更新它所有邻居的状态,把其中有状态变化的邻居入队列。 (3)继续以上过程,直到队列空。这也意味着,所有结点的状态都不再更新。最后的状态就是到起点 S
的最短路径。
弹出 U
之后,在后面的计算中,U
可能会再次更新状态(后来发现,U
借道别的结点去 S
,路更近)。所以,U
可能需要重新入队列。 有可能只有很少结点重新进入队列,也有可能很多。这取决于图的特征。
所以,SPFA 是不稳定的,所以根据题目的类型,我们要选择合适的算法。
代码
cpp
#include <iostream>
#include <vector>
#include <queue>
using namespace std;
const long long INF = 0x3f3f3f3f3f3f3f3f;
const int N = 5e3 + 10;
struct edge {
int to;
long long w;
edge(int tt, long long ww) { to = tt; w = ww; }
};
long long dist[N];
int inq[N];
vector<edge> e[N];
void spfa(int s) {
memset(dist, 0x3f, sizeof(dist));
dist[s] = 0; //起点到自己的距离是0
queue<int> q;
q.push(s); //从s开始,s进队列
inq[s] = 1; //起点在队列中
while (!q.empty()) {
int u = q.front();
q.pop();
inq[u] = 0; //u已经不在队列中
if (dist[u] == INF) continue;
for (int i = 0; i < e[u].size(); i++) { //遍历u的邻居
int v = e[u][i].to;
long long w = e[u][i].w;
if (dist[v] > dist[u] + w) { //u的第i个邻居v,它借道u,到s更近
dist[v] = dist[u] + w; //更新邻居v到s的距离
if (!inq[v]) { //邻居v更新状态了,但v不在队列中,放进队列
q.push(v);
inq[v] = 1;
}
}
}
}
}
int main() {
int n, m, s; cin >> n >> m >> s;
for (int i = 1; i <= m; i++) {
int u, v; long long w;
cin >> u >> v >> w;
e[u].push_back(edge(v, w));
}
spfa(s);
for (int i = 1; i <= n; i++) {
if (dist[i] == INF) {
cout << -1;
}else {
cout << dist[i];
}
if (i != n) {
cout << " ";
}
else {
cout << endl;
}
}
return 0;
}
例题
解题思路:
可以把问题转换成单源最短路问题,计算从k出发到达所有顶点的最短时间,如果最远的顶点都能发送到,那么其他顶点都能收到信号。如果到某个顶点没有路径,则dist数组为无穷大。表示不能使所有结点收到信号。
AC代码:
cpp
const long long inf = 0x3f3f3f3f3f3f3f3fLL;
const int N = 101;
class Solution {
using ll = long long;
struct edge{
ll v;
ll w;
bool operator <(const edge &a)const{
return w > a.w;
}
};
vector<edge> g[N];
bool vis[N];
ll dis[N];
public:
void dijkstra(int s,int n){
for(int i = 1; i <= n; ++i) dis[i] = inf;
dis[s] = 0;
priority_queue<edge> qe;
qe.push({s,dis[s]});
while(!qe.empty()){
edge x = qe.top();
qe.pop();
if(vis[x.v]) continue;
vis[x.v] = true;
for(auto & y :g[x.v]){
if(vis[y.v]) continue;
if(dis[y.v] > y.w + dis[x.v]){
dis[y.v] = y.w + dis[x.v];
}
qe.push({y.v,dis[y.v]});
}
}
}
int networkDelayTime(vector<vector<int>>& times, int n, int k) {
for(int i = 1;i <= n; ++i) g[i].clear();
for(auto &eg : times){
ll u = eg[0],v = eg[1],w = eg[2];
g[u].push_back({v,w});
}
dijkstra(k,n);
ll ans = 0;
for(int i = 1;i <= n; ++i){
ans = max(ans,dis[i]);
}
return ans == inf ? -1 : ans;
}
};
1976. 到达目的地的方案数 - 力扣(LeetCode)
解题思路:
这里采用的弗洛伊德算法,也可以用dijkstra算法。本题是求最短路径的方案数,所以需要多一个记录路径数量的矩阵paths。
如果 i 到 j 的路径 > i 到 k 再到 j 的路径,(dist[i][j] > dist[i][k] + dist[k][j]) 则将最短路更新,并且将路径数量更新成paths[i][k] * paths[k][j],因为假定 i到k有 m条,而k到j有n条,所以从 i 到 j 有m * n条。
如果dist[i][j] == dist[i][k] + dist[k][j]。则将路径数量paths[i][j] += paths[i][k] * paths[k][j],原理如上。
AC代码:
cpp
const int N = 201;
const long long inf = 0x3f3f3f3f3f3f3f3fLL;
const int mod = 1e9 + 7;
class Solution {
long long dist[N][N];
long long paths[N][N]; //多一个记录路径数量
public:
void Floyd(int n){
for(int k = 0; k < n; ++k){
for(int i = 0; i < n; ++i){
for(int j = 0; j < n; ++j){
if(dist[i][k] == inf || dist[k][j] == inf) continue;
if(dist[i][k] + dist[k][j] < dist[i][j]){
// 碰到更短的路径,直接更新路径数
// 全局数量 = 左边数量 * 右边数量
dist[i][j] = dist[i][k] + dist[k][j];
paths[i][j] = paths[i][k] * paths[j][k];
}else if(dist[i][k] + dist[k][j] == dist[i][j]){
// 碰到相等的路径,需要累加路径数
paths[i][j] += paths[i][k] * paths[j][k];
}
paths[i][j] %= mod;
}
}
}
}
int countPaths(int n, vector<vector<int>>& roads) {
if(n < 2) return 1;
memset(dist,inf,sizeof(dist));
for(int i = 0; i < roads.size(); ++i){
int u = roads[i][0];
int v = roads[i][1];
long long w = roads[i][2];
dist[u][v] = dist[v][u] = w;
paths[u][v] = paths[v][u] = 1;
}
Floyd(n);
return paths[0][n-1];
}
};
解题思路:
这里采用朴素版dijkstra
AC代码:
cpp
#include <iostream>
#include <vector>
using namespace std;
const int N = 1010;
const long long INF = 0x3f3f3f3f3f3f3f3fLL;
//邻接矩阵
int edge[N][N];
int g[N];
int dis[N];
bool vis[N];
int n, m;
int dijkstra() {
memset(dis, 0x3f, sizeof dis);
dis[1] = 0;
for (int i = 0; i < n - 1; ++i) {
int t = -1;
for (int j = 1; j <= n; ++j) {
if (!vis[j] && (t == -1 || dis[j] < dis[t])) {
t = j;
}
}
vis[t] = true;
for (int j = 1; j <= n; ++j) {
if (!vis[j] && dis[t] != INF && dis[t] + edge[t][j] < dis[j]) {
dis[j] = dis[t] + edge[t][j];
}
}
}
return dis[n];
}
int main() {
cin >> n >> m;
memset(edge, 0x3f, sizeof(edge));
for (int i = 1; i <= n; ++i) cin >> g[i];
g[n] = 0;
for (int i = 1; i <= m; ++i) {
int u, v, w;
cin >> u >> v >> w;
edge[u][v] = w + g[v];
edge[v][u] = w + g[u];
}
cout << dijkstra();
return 0;
}
最短路算法总结
Dijkstra:适用于权值为非负的图的单源最短路径,用斐波那契堆的复度 O(E+VlgV)
BellmanFord:适用于权值有负值的图的单源最短路径,并且能够检测负圈,复杂度 O(VE)
SPFA:适用于权值有负值,且没有负圈的图的单源最短路径。论文中的复杂度为 O(kE)
, 其中 k
为每个节点进入队列的次数,且 k
一般 <=2
,但此处的复杂度证明是有问题的,其实 SPFA 的最坏情况应该是 O(VE)
。 Floyd:每对节点之间的最短路径。
所以:
单源最短路 (1)当权值为非负时,用 Dijkstra
。 (2)当权值有负值,且没有负圈,则用 SPFA
。SPFA
能检测负圈,但是不能输出负圈。 (3)当权值有负值,而且可能存在负圈需要输出,则用 BellmanFord
。能够检测并输出负圈。 多源最短路使用 Floyd
最短路算法比较
拓扑排序
概念
拓扑排序是一种经典的有向无环图(DAG, Directed Acyclic Graph)的排序算法,用于判断一个有向图是否可以拓扑排序,是现代计算 机科学领域中非常重要和实用的算法之一。它的核心思想是根据 DAG 图中节点之间的依赖关系,将图中的节点按照 一定顺序进行排序,从而实现有序处理。 拓扑排序常被用于解决任务调度、依赖分析和编译器等问题,以及交通规划、电路分析和工程设计等领域。在 计算机科学 中,拓扑排序是图论的重要应用之一,在其他领域也发挥了重要作用。
AOV网(Activity On Vertex Network)是一种特殊的有向图,用于表示一个工程项目中各个活动之间的依赖关系。在AOV网中,每个顶点代表一个活动,而有向边则表示活动之间的优先关系,即从一个活动指向另一个活动的边意味着前者必须在后者开始之前完成。这样的网络确保了活动的执行顺序,避免了逻辑上的时间矛盾。
在图论中,由一个有向无环图的顶点组成的序列,当且仅当满足下列条件时称为该图的一个拓扑排序:
1)每个顶点出现且只出现一次。
2)若顶点A在序列中排在顶点B的前面,则在图中不存在从B到A的路径。
或定义为:拓扑排序是对有向无环图的顶点的一种排序,它使得若存在一条从顶点A到顶B的路径,则在排序中B出现在A的后面。每个AOV 网都有一个或多个拓扑排序序列。
拓扑排序算法实现
- 计算每个顶点的入度:入度是指有向图中指向该顶点的边的数量。
- 找到入度为0的顶点:这些顶点没有前置任务,可以作为排序的起点。
- 进行排序:每次从入度为0的顶点集合中取出一个顶点,将其加入到排序结果中,并减小与其相连的所有顶点的入度。如果某个顶点的入度变为0,则将其加入到入度为0的顶点集合中。
- 重复步骤3,直到所有顶点都被处理,或者找不到入度为0的顶点(这表明图中存在环,无法进行拓扑排序)。
c++代码:
cpp
#include <iostream>
#include <vector>
#include <queue>
#include <algorithm>
using namespace std;
// 使用邻接表表示图
vector<vector<int>> adjList;
vector<int> inDegree; // 存储每个顶点的入度
vector<int> topoOrder; // 存储拓扑排序结果
void topologicalSort(int n) {
// 初始化队列,将所有入度为0的顶点加入队列
queue<int> q;
for (int i = 0; i < n; ++i) {
if (inDegree[i] == 0) {
q.push(i);
}
}
// 开始拓扑排序
while (!q.empty()) {
int curr = q.front();
q.pop();
topoOrder.push_back(curr); // 将当前顶点加入到排序结果中
// 减少与当前顶点相连的所有顶点的入度,并将新入度为0的顶点加入队列
for (auto neighbor : adjList[curr]) {
--inDegree[neighbor];
if (inDegree[neighbor] == 0) {
q.push(neighbor);
}
}
}
// 检查是否存在环
if (topoOrder.size() != n) {
cout << "图中有环,拓扑排序不存在" << endl;
} else {
// 输出拓扑排序结果
for (auto node : topoOrder) {
cout << node << " ";
}
cout << endl;
}
}
int main() {
int n, m; // n: 顶点数, m: 边数
cin >> n >> m;
adjList.resize(n);
inDegree.assign(n, 0);
// 输入边,构建邻接表并计算入度
for (int i = 0; i < m; ++i) {
int u, v;
cin >> u >> v;
adjList[u].push_back(v); // 假设边的方向是从u到v
++inDegree[v]; // 增加v的入度
}
topologicalSort(n);
return 0;
}
关键路径
AOE网(Activity On Edge Network) 是一种特殊的有向无环图(DAG, Directed Acyclic Graph),用于表示一个工程项目的活动及其相互依赖关系。在AOE网中,顶点 表示项目中的事件(通常是某个活动的开始或结束),有向边 表示活动,边上的权值 表示完成该活动所需的持续时间。AOE网的特点是它只有一个起点(表示项目的开始)和一个终点(表示项目的完成),并且能够清晰地展现出活动间的顺序和时间约束。
关键路径 是AOE网中从起点到终点的最长路径,即决定了完成整个工程所需的最短时间。换句话说,关键路径上的活动如果任何一项延误,都会直接影响到整个项目的完成时间。在关键路径上的活动称为关键活动,它们没有浮动时间(即不能延迟开始或结束而不影响整个项目的完成期限)。确定关键路径有助于识别哪些活动对项目进度至关重要,以及如何优化资源分配以减少项目风险。
关键路径的求解
下面给出求关键路径的几个参数:
- 事件Vk的最早发生时间Ve(Early Finish Time,EFT):指在不影响整个项目完成的时间前提下,该事件所能发生的最早时间。对于AOE-网中的每个事件,只有其所有前继活动均完成后,该事件才能开始发生。因此,事件的最早发生时间就是从源点到该事件的最长路径长度,通常将源点事件的最早发生时间定义为0,可根据拓扑排序从源点到汇点递推。
- 事件Vk的最晚发生时间Vl(Late Finish Time,LFT):指在不影响整个项目完成的时间前提下,该事件所能发生的最晚时间,也就是该事件不能延误每一后继事件的最迟发生时间。源点事件和汇点事件的最晚发生时间等于其最早发生时间,而事件的最晚发生时间等于其后继事件的最晚发生时间减去该活动持续时间中最小的一个值,因此,我们可以根据逆拓扑顺序从汇点到源点递推。
- 活动的最早开始时间(Early Start Time,EET):每个活动的最早发生时间表示它可以开始的最早时间,这个时间由该活动的所有前驱活动中最晚结束时间决定的,因此,活动的最早开始时间等于弧尾(弧尾表示发出箭头的顶点, 弧头箭头指向的顶点)事件的最早发生时间。
- 活动的最晚开始时间(Late Start Time,LET):每个活动的最晚开始时间是指在不影响整个项目最晚完成时间的前提下,这个活动可以推迟到哪个时间开始,即能够满足不延误后面事件的最晚发生时间。所以活动的最晚开始时间等于弧头事件的最晚发生时间减去该活动的持续时间。
- 时间余量(Slacktime):时间余量是指每个活动可以延迟的时间,而不会延误整个项目的最早完成时间。时间余量等于活动最晚开始时间与最早开始时间之差。如果存在时间余量大于0,则活动的最晚开始时间可以延迟,而不会影响整个项目进度。若余量为0则称其为关键活动。
求关键路径的算法步骤如下:
- 从源点出发,令Ve(源点)=0,按拓扑有序求其余顶点的最早发生时间Ve()。
- 从汇点出发,令Vl(汇点)=Ve(汇点),按逆拓扑有序求其余顶点的最迟发生时间Vl()。
- 根据各顶点的Ve()值求所有弧的最早开始时间e()。
- 根据各顶点的Vl()值求所有弧的最迟开始时间l() 。
- 求AOE 网中所有活动的差额d(),找出所有d() = 0的活动构成关键路径。
求关键路径的实例
简单说明如下:
- 求Ve():初始Ve(1) = 0,在拓扑排序输出顶点过程中,求得Ve(2) = 3, Ve(3) = 2, Ve(4) = mах { Ve(2) + 2, Ve(3) + 4} = mах{5,6} = 6, Ve(5 )= 6, Ve(6) = mах {Ve(5) +1, Ve(4) + 2, Ve(3) + 3} = max (7,8,5} = 8. 若这是一道选择题,根据上述求Ve()的过程就已经能知道关键路径。
- 求Vl()初始Vl(6)=8,在逆拓扑排序出栈过程中,求得Vl(5) = 7,Vl(4) = 6, Vl(3) = min {Vl(4)-4,Vl(6)-3}= min(2,5)=2,Vl(2)=min{Vl(5)-3, Vl(4) - 2) = min(4,4)=4,Vl(1)必然为0而无须再求。
- 弧的最早开始时间 e() 等于该弧的起点的顶点的Ve(),结果如下表。
- 弧的最迟开始时间 l(i) 等于该弧的终点的顶点的vl()减去该弧持续的时间,结果如下表。
- 根据l(i) - e(i) = 0的关键活动,得到的关键路径为(V1,V3,V4, V6)。
对于关键路径,需要注意以下几点:
1)关键路径上的所有活动都是关键活动,它是决定整个工程的关键因素,因此可以通过加
快关键活动来缩短整个工程的工期。但也不能任意缩短关键活动,因为一旦缩短到一定
的程度,该关键活动就可能会变成非关键活动。
2)网中的关键路径并不唯一,且对于有几条关键路径的网,只提高一条关键路径上的关键
活动速度并不能缩短整个工程的工期,只有加快那些包括在所有关键路径上的关键活动
才能达到缩短工期的目的。
废话
本章节为图论最基本的内容,要求都要尽量掌握。又花费了一天的时间总结,该下机了。
最后分享一波金句:借鉴历史的价值,就在于历史往往以相似的韵脚往复发生。了解过去发生的,但是我所处的时代还未发生的,如果将来降临在自己身上,就懂得如何去应对。