当前位置:   article > 正文

数据结构——图

数据结构——图

  本文参考一些书本加上个人的见解,简单地总结了一下图的相关内容。图在数据结构中也是个十分重要的数据结构。图是一种数学抽象模型,用于描述事物之间的关系。也是一种强大的工具,可以用来解决各种复杂的问题。图在各个领域都有广泛的应用,比如社交网络、交通规划、计算机网络等。

目录

图的基本概念

图的定义

图的基本概念及术语 

1.有向图和无向图

2.简单图和多重图

3.完全图

4.子图

5.连通图和连通分量

6.顶点的度

7.稠密图和稀疏图

8.边的权

9.路径、路径长度和回路

10.生成树 

图的存储结构

邻接矩阵

1.邻接矩阵的概念

2.无权图的邻接矩阵

3.带权图的邻接矩阵

4.邻接矩阵的优点

5.邻接矩阵的缺点

6.邻接矩阵存储结构定义 

邻接表和链式前向星

1.邻接表的概念

2.邻接表的顺序表存储

3.邻接表的链表存储

4.邻接表的应用

5.邻接表的优点

6.邻接表的缺点

7.邻接表存储结构定义

十字链表

邻接多重表

图的四种存储方式的总结 

图的遍历

深度优先搜索(DFS)

1)算法原理

2)算法实现 

3)代码

4)算法性能分析 

5)深度优先的生成树和生成森林

广度优先搜索(BFS)

1)算法原理

 2)算法实现

 3)代码

4)算法性能分析

5)BFS算法求解单源最短路径问题

6)广度优先生成树

图的应用 

最小生成树

定义

Prim算法 

kruskal 算法

例题

 最短路径

Floyd 算法

算法思想

代码 

Dijkstra 算法 

算法思想

代码 

Bellman-Ford 算法

算法思想

SPFA 算法

算法思想

代码 

例题 

最短路算法总结

最短路算法比较 

拓扑排序 

概念 

拓扑排序算法实现

关键路径 

废话


 

图的基本概念

图的定义

图 (Graph) 是由顶点的有穷非空集合和顶点之间边的集合组成,通常表示为:G(V,E), 其中 G 表示一个图, V是图G 中顶点的集合, E是图G中边的集合。 线性表中我们把数据元素叫元素,树中将数据元素叫结点,图中数据元素,我们则称之为顶点。

图的基本概念及术语 

1.有向图和无向图

图可以分为有向图和无向图。有向图中的边具有方向,从一个顶点指向另一个顶点,表示一个单向关系。无向图中 的边没有方向,连接两个顶点,表示双向关系。

1、无向边和无向图

如果两个顶点间的边没有方向,则称这条边为无向边,如果图中任意两个顶点之间的边都是无向边,则这个图被称 为无向图。

2、有向边和有向图

如果两个顶点之间的边是有方向的,则被称为有向边,也被称为弧(有向边的起点叫弧头,终点叫弧尾),如果图中任意两个顶点之间的遍都是有向边,则这个图被称为有向图。

2.简单图和多重图

简单图是指图中没有自循环(边的起始顶点和结束顶点相同)和多重边(两个顶点之间存在多条边)的图。若图中某两个顶点之间的边数大于1条,有允许顶点通过一条边和自身关联,则称多重图。

3.完全图

完全图是指每个顶点都与其他顶点直接相连的图。对于有向图,完全图要求每个顶点都有有向边指向其他顶点,并且每个顶点也接受其他顶点的有向边。

4.子图

给定一个图 G = (V, E),如果存在另一个图 G' = (V', E'),满足以下条件:

  1. V' 是 V 的一个子集,即 V' ⊆ V。
  2. 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.邻接矩阵存储结构定义 

  1. //c++代码定义
  2. #include <iostream>
  3. #include <vector>
  4. using namespace std;
  5. class Graph {
  6. private:
  7. int n; // 顶点数
  8. vector<vector<int>> adj_matrix; // 邻接矩阵
  9. public:
  10. // 构造函数
  11. Graph(int n) {
  12. this->n = n;
  13. adj_matrix.resize(n, vector<int>(n, 0)); // 初始化邻接矩阵为全 0
  14. }
  15. // 添加边
  16. void addEdge(int u, int v) {
  17. adj_matrix[u][v] = 1; // 设置 u 到 v 的边权为 1
  18. }
  19. // 打印邻接矩阵
  20. void printAdjMatrix() {
  21. cout << "Adjacency Matrix:" << endl;
  22. for (int i = 0; i < n; i++) {
  23. for (int j = 0; j < n; j++) {
  24. cout << adj_matrix[i][j] << " ";
  25. }
  26. cout << endl;
  27. }
  28. }
  29. };

邻接表和链式前向星

邻接表(指针或数组下标)和链式前向星(容器模拟)的思路一样,只是表达方式不同。

1.邻接表的概念

邻接表是一种表示图的数据结构。邻接表的主要概念是:对于图中的每个顶点,维护一个由与其相邻的顶点组成的列表。这个列表可以用数组、链表或其他数据结构来实现。 实际上,邻接表可以用于有向图、无向图、带权图、无权图。这里只考虑无权图的情况,带权图只需要多存储一 个数据就可以了。

2.邻接表的顺序表存储

  在C语言的静态数组中,如果要实现邻接表,一般图中的点的数量控制在1000 左右的量级,是比较合适的,如果在大一点,存储会产生问题。

  在C++中,有 vector 这种柔性数组,所以可以支持百万的量级。当然,也可以用C语言的静态数组来模拟实现一个 C++中的柔性数组。

  这里不讨论柔性数组的情况,只考虑 1000 量级的情况,如下:

  1. #define maxn 1010
  2. int adjSize[maxn];
  3. 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.邻接表存储结构定义

  1. //c++代码定义
  2. #include <iostream>
  3. #include <vector>
  4. #include <list>
  5. using namespace std;
  6. class Graph {
  7. private:
  8. int n; // 顶点数
  9. vector<list<int>> adj_list; // 邻接表
  10. public:
  11. // 构造函数
  12. Graph(int n) {
  13. this->n = n;
  14. adj_list.resize(n); // 初始化邻接表
  15. }
  16. // 添加边
  17. void addEdge(int u, int v) {
  18. adj_list[u].push_back(v); // 将顶点 v 添加到顶点 u 的邻接表中
  19. }
  20. // 打印邻接表
  21. void printAdjList() {
  22. cout << "Adjacency List:" << endl;
  23. for (int i = 0; i < n; i++) {
  24. cout << i << ": ";
  25. for (int neighbor : adj_list[i]) {
  26. cout << neighbor << " ";
  27. }
  28. cout << endl;
  29. }
  30. }
  31. };

十字链表

十字链表是有向图的一种链式存储结构。在十字链表中,有向图的每条狐用一个节点(弧节点)来表示,每个顶点也用一个节点(顶点节点)来表示。两种节点的结构存储如下

有向图的十字链表表示:

 十字链表存储结构定义

  1. //c++代码定义
  2. #include <iostream>
  3. #include <vector>
  4. using namespace std;
  5. // 十字链表节点结构
  6. struct Node {
  7. int row, col; // 节点在矩阵中的行和列
  8. int value; // 节点的值
  9. Node* left; // 左指针
  10. Node* right; // 右指针
  11. Node* up; // 上指针
  12. Node* down; // 下指针
  13. };
  14. // 十字链表类
  15. class SparseMatrix {
  16. private:
  17. vector<Node*> rowHead; // 行头节点数组
  18. vector<Node*> colHead; // 列头节点数组
  19. int rows, cols; // 矩阵的行数和列数
  20. public:
  21. // 构造函数
  22. SparseMatrix(int r, int c) {
  23. rows = r;
  24. cols = c;
  25. rowHead.resize(r, nullptr);
  26. colHead.resize(c, nullptr);
  27. }
  28. // 插入元素
  29. void insert(int row, int col, int value) {
  30. Node* newNode = new Node{row, col, value, nullptr, nullptr, nullptr, nullptr};
  31. // 插入行链表
  32. if (rowHead[row] == nullptr || rowHead[row]->col > col) {
  33. newNode->right = rowHead[row];
  34. rowHead[row] = newNode;
  35. } else {
  36. Node* temp = rowHead[row];
  37. while (temp->right != nullptr && temp->right->col < col) {
  38. temp = temp->right;
  39. }
  40. newNode->right = temp->right;
  41. temp->right = newNode;
  42. }
  43. // 插入列链表
  44. if (colHead[col] == nullptr || colHead[col]->row > row) {
  45. newNode->down = colHead[col];
  46. colHead[col] = newNode;
  47. } else {
  48. Node* temp = colHead[col];
  49. while (temp->down != nullptr && temp->down->row < row) {
  50. temp = temp->down;
  51. }
  52. newNode->down = temp->down;
  53. temp->down = newNode;
  54. }
  55. }
  56. // 访问元素
  57. int get(int row, int col) {
  58. Node* temp = rowHead[row];
  59. while (temp != nullptr && temp->col <= col) {
  60. if (temp->col == col) {
  61. return temp->value;
  62. }
  63. temp = temp->right;
  64. }
  65. return 0;
  66. }
  67. };

邻接多重表

  邻接多重表是无向图的一种链式存储结构。在邻接表中,容易求得顶点和边的各种信息, 但在邻接表中求两个顶点之间是否存在边而对边执行删除等操作时,需要分别在两个顶点的边表中遍历,效率较低。与十字链表类似,在邻接多重表中,每条边用一个结点表示,其结构如下所示。

其中,ivex和jvex这两个域指示该边依附的两个顶点的编号:iink域指向下一条依附于顶点ivex的边;jlink域指向下一条依附于顶点jvex的边,info域存放该边的相关信息。 
每个顶点也用一个结点表示,它由如下所示的两个域组成。 


其中, data域存放该顶点的相关信息,firstedae 域指向第一条依附于该顶点的边。 

  在邻接多重表中,所有依附于同一顶点的边串联在同一链表中,因为每条边依附于两个顶点,所以每个边结点同时链接在两个链表中。对无向图而言,其邻接多重表和邻接表的差别仅在于,同一条边在邻接表中用两个结点表示,而在邻接多重表中只有一个结点。 

无向图的邻接多重表表示:

 邻接多重表存储结构定义

  1. //c++代码定义
  2. #include <iostream>
  3. #include <vector>
  4. #include <list>
  5. using namespace std;
  6. // 边的结构体
  7. struct Edge {
  8. int dest; // 目标顶点
  9. int weight; // 边的权重
  10. };
  11. // 顶点的结构体
  12. struct Vertex {
  13. int data; // 顶点数据
  14. list<Edge> adj_list; // 邻接表
  15. };
  16. // 图的类
  17. class Graph {
  18. private:
  19. int num_vertices; // 顶点数
  20. vector<Vertex> vertices; // 顶点数组
  21. public:
  22. // 构造函数
  23. Graph(int n) {
  24. num_vertices = n;
  25. vertices.resize(n);
  26. for (int i = 0; i < n; i++) {
  27. vertices[i].data = i;
  28. }
  29. }
  30. // 添加边
  31. void add_edge(int src, int dest, int weight) {
  32. Edge e = {dest, weight};
  33. vertices[src].adj_list.push_back(e);
  34. }
  35. // 打印图
  36. void print_graph() {
  37. for (int i = 0; i < num_vertices; i++) {
  38. cout << "Vertex " << vertices[i].data << ": ";
  39. for (auto it = vertices[i].adj_list.begin(); it != vertices[i].adj_list.end(); ++it) {
  40. cout << "(" << it->dest << ", " << it->weight << ") ";
  41. }
  42. cout << endl;
  43. }
  44. }
  45. };

图的四种存储方式的总结 

图的遍历

  图的遍历是指从图中的某一顶点出发,按照某种搜索方法沿着图中的边对图中的所有顶点访问一次,且仅访问一次。注意到树是一种特殊的图,所以树的遍历实际上也可视为一种特殊的图的遍历。图的遍历算法是求解图的连通性问题、拓扑排序和求关键路径等算法的基础。 

  图的遍历比树的遍历要复杂得多,因为图的任意一个顶点都可能和其余的顶点相邻接,所以在访问某个顶点后,可能沿着某条路径搜索又回到该顶点。为避免同一顶点被访问多次,在遍历图的过程中,必须记下每个已访问过的顶点,为此可以设一个辅助数组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 ->

 3)代码

  1. const int MAXN = 7;
  2. void dfs(int u) {
  3. if(visit[u]) { // 1
  4. return ;
  5. }
  6. visit[u] = true; // 2
  7. dfs_add(u); // 3
  8. for(int i = 0; i < MAXN; ++i) {
  9. int v = i;
  10. if(adj[u][v]) { // 4
  11. dfs(v); // 5
  12. }
  13. }

1、visit[MAXN] 数组是一个bool数组,用于标记某个节点是否已访问,初始化都为 false;这里对已访问结点执行 回溯;

2、visit[u] = true; 对未访问结点 u 标记为已访问状态;

3、dfs_add(u); 用来将 u 存储到的访问序列中,实际函数实现如下: 

  1. void dfs_add(int u) {
  2. ans[ansSize++] = u;
  3. }

4、adj[MAXN][MAXN] 是图的邻接矩阵,用 0 或 1 来代表点是否连通,对于上面的例子,邻接矩阵表示如下: 

  1. bool adj[MAXN][MAXN] = {
  2. {0, 1, 1, 0, 0, 0, 0},
  3. {1, 0, 0, 1, 1, 0, 0},
  4. {1, 0, 0, 0, 0, 1, 1},
  5. {0, 1, 0, 0, 0, 0, 0},
  6. {0, 1, 0, 0, 0, 1, 0},
  7. {0, 0, 1, 0, 1, 0, 0},
  8. {0, 0, 1, 0, 0, 0, 0},
  9. };

( 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)算法是一种遍历图的方法,它从起点开始,先访问所有相邻的顶点,然后再访问这些顶点的相邻顶点,直到所有可达的顶点都被访问到。用一句话概括就是“一层又一层,泛起层层涟漪”。具体算法描述如下:

  1. 选择一个起始顶点作为根节点。
  2. 将根节点加入到一个队列中。
  3. 从队列中取出一个顶点,访问该顶点。
  4. 将该顶点的所有未访问过的相邻顶点加入到队列中。
  5. 重复步骤 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)代码

  1. void bfs(char start) {
  2. // 创建一个队列和一个访问标记数组
  3. queue<char> q;
  4. vector<bool> visited(graph.size(), false);
  5. // 将起始顶点入队并标记为已访问
  6. q.push(start);
  7. visited[start - 'a'] = true;
  8. // 执行 BFS 遍历
  9. while (!q.empty()) {
  10. char u = q.front(); //取出队头
  11. q.pop();
  12. cout << u << " ";
  13. // 将 u 的所有未访问过的相邻顶点入队并标记为已访问
  14. for (char v : graph[u - 'a']) {
  15. if (!visited[v - 'a']) {
  16. q.push(v);
  17. visited[v - 'a'] = true;
  18. }
  19. }
  20. }
  21. }

其中邻接表graph存储图:

  1. // 图的邻接表表示
  2. vector<vector<char>> graph = {
  3. {'b', 'c'}, // 顶点 a 的邻接顶点
  4. {'a', 'd', 'e'}, // 顶点 b 的邻接顶点
  5. {'a', 'f', 'g'}, // 顶点 c 的邻接顶点
  6. {'b'}, // 顶点 d 的邻接顶点
  7. {'b','h'}, // 顶点 e 的邻接顶点
  8. {'c'}, // 顶点 f 的邻接顶点
  9. {'c'}, // 顶点 g 的邻接顶点
  10. {'e'} // 顶点 h 的邻接顶点
  11. };

从上例不难看出,图的广度优先搜索的过程与二叉树的层序遍历是完全一致的,这也说明了图的广度优先搜索遍历算法是二叉树的层次遍历算法的扩展。

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代码:

  1. #include <iostream>
  2. #include <queue>
  3. using namespace std;
  4. const int N = 105;
  5. char mp[N][N]; //定义邻接矩阵
  6. bool vis[N][N]; //定义标记数组
  7. int n, m, x2, y2, ans = -1;
  8. int dir[4][2] = { {-1,0},{1,0},{0,1},{0,-1} }; //上下左右四个方向
  9. //定义位置及起点到该点的步数
  10. struct node {
  11. int x, y;
  12. int step;
  13. };
  14. queue<node> qe;
  15. //判断是否超出地图
  16. bool in(int x, int y) {
  17. return x > 0 && x <= n && y > 0 && y <= m;
  18. }
  19. void bfs(int x1, int y1) {
  20. //起点入队
  21. qe.push({ x1,y1,0 });
  22. vis[x1][y1] = true;
  23. while (!qe.empty()) {
  24. //队头出队
  25. node t = qe.front();
  26. qe.pop();
  27. //若找到终点,将step赋给答案
  28. if (t.x == x2 && t.y == y2) {
  29. ans = t.step;
  30. return;
  31. }
  32. //遍历四个方向
  33. for (int i = 0; i < 4; ++i) {
  34. int tx = t.x + dir[i][0];
  35. int ty = t.y + dir[i][1];
  36. if (in(tx, ty) && !vis[tx][ty] && mp[tx][ty] == '1') {
  37. vis[tx][ty] = true;
  38. qe.push({ tx,ty,t.step + 1 });
  39. }
  40. }
  41. }
  42. }
  43. int main() {
  44. cin >> n >> m;
  45. for (int i = 1; i <= n; ++i) {
  46. for (int j = 1; j <= m; ++j) {
  47. cin >> mp[i][j];
  48. }
  49. }
  50. int x1, y1;
  51. cin >> x1 >> y1 >> x2 >> y2;
  52. bfs(x1, y1);
  53. cout << ans;
  54. return 0;
  55. }

【例题】 2368. 受限条件下可到达节点的数目 - 力扣(LeetCode)

题目描述 

解题思路:

先用哈希表标记受限制的顶点,建图时,只需要建立未标记的顶点连接的图,然后用BFS从0开始扫一遍,将能访问的顶点计数器加一,最后就是0能访问不受限制的结点。

AC代码:

  1. class Solution {
  2. public:
  3. int bfs(vector<vector<int>> &G, vector<bool> &vis) {
  4. int ans = 0;
  5. queue<int> q;
  6. q.push(0);
  7. while (!q.empty()) {
  8. int u = q.front();
  9. q.pop();
  10. ++ans;
  11. vis[u] = true;
  12. for (auto v : G[u]) {
  13. if (!vis[v]) {
  14. q.push(v);
  15. }
  16. }
  17. }
  18. return ans;
  19. }
  20. int reachableNodes(int n, vector<vector<int>>& edges, vector<int>& restricted) {
  21. unordered_set<int> op(restricted.begin(), restricted.end()); // 使用集合加快查找效率
  22. // 建图
  23. vector<vector<int>> G(n);
  24. for (auto& edge : edges) {
  25. int u = edge[0], v = edge[1];
  26. // 确保这条边的两个端点都不在限制列表中
  27. if (op.find(u) == op.end() && op.find(v) == op.end()) {
  28. G[u].push_back(v);
  29. G[v].push_back(u);
  30. }
  31. }
  32. vector<bool> vis(n, false);
  33. int ans = bfs(G, vis);
  34. return ans;
  35. }
  36. };

 

 

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)}。 

 

代码:

  1. #include <iostream>
  2. #include <vector>
  3. using namespace std;
  4. const int INF = 0x3f3f3f3f;
  5. const int MAXN = 1005;
  6. vector<int> demo;
  7. int visited[MAXN], lowcost[MAXN], m, n;//m为节点的个数,n为边的数量
  8. int G[MAXN][MAXN];//邻接矩阵
  9. int prim(){
  10. for (int i = 0; i < m; i++)
  11. lowcost[i] = INF;
  12. for (int i = 0; i < m; i++)
  13. visited[i] = 0;
  14. visited[0] = -1; //加入第一个点,-1表示该点在集合U中,否则在集合V中
  15. int num = 0, ans = 0, v = 0; //v为最新加入集合的点
  16. while (num < m - 1){ //加入m-1条边
  17. int mincost = INF, minvertex = -1;
  18. for (int i = 0; i < m; i++)
  19. if (visited[i] != -1){
  20. int temp = G[v][i];
  21. if (temp < lowcost[i]){
  22. lowcost[i] = temp;
  23. visited[i] = v;
  24. }
  25. if (lowcost[i] < mincost)
  26. mincost = lowcost[minvertex = i];
  27. }
  28. ans += mincost;
  29. demo.push_back(mincost);
  30. visited[v = minvertex] = -1; //标记已加入顶点
  31. num++;
  32. }
  33. return ans;
  34. }
  35. int main(){
  36. scanf("%d %d", &m, &n);
  37. memset(G, INF, sizeof(G));
  38. for (int i = 0; i < n; ++i){
  39. int a, b, c; //a为起点,b终点,c权值
  40. cin >> a >> b >> c;
  41. G[b][a] = G[a][b] = c;
  42. }
  43. cout << prim() << endl;
  44. for (int i = 0; i < m - 1; i++) cout << demo[i] << " ";
  45. return 0;
  46. }

 Prim算法的时间复杂度为O(|V|^2),不依赖于|E|,因此它适用于稠密图。

 

kruskal 算法

对边进行贪心操作:“最短的边一定在 MST 上”。 从最短的边开始,把它加入到 MST 中;在剩下的边中找最短的边,加入到 MST 中;继续这个过程,直到所有点都在 MST 中。

Kruskal算法的具体步骤如下:

kruskal 算法的 2 个关键技术: (1)对边进行排序。 (2)判断圈,即处理连通性问题。这个问题用并查集简单而高效,并查集是 kruskal 算法的实现基础。

初始时最小生成树 MST 为空。开始的时候,每个点属于独立的集。

按边长从小到大进行边的遍历操作:

尝试将最小边加入最小生成树:

  • 如果边的两个端点属于同一个集合,就说明这两个点已经被加入最小生成树。则不能将边加入,否则就会生成一个环。
  • 如果两个端点不属于同一个集合,就说明该点还未纳入最小生成树,此边可以加入。

重复上述操作,直到加入 n-1 条边。

 

代码:

  1. #include <iostream>
  2. #include <algorithm>
  3. using namespace std;
  4. const int maxn = 11000000;
  5. int n, m;
  6. int pre[maxn];
  7. struct edge {
  8. int x;
  9. int y;
  10. int k;
  11. } Q[maxn];
  12. int find(int x) {
  13. if (pre[x] == x)
  14. return x;
  15. return pre[x] = find(pre[x]); //路径压缩
  16. }
  17. bool cmp(edge a, edge b) { //按权值排序
  18. return a.k < b.k;
  19. }
  20. int main() {
  21. scanf("%d %d", &n, &m);
  22. int cont = 0, sum = 0, num = 0;
  23. for (int i = 0; i < m; i++) {
  24. scanf("%d %d %d", &Q[i].x, &Q[i].y, &Q[i].k);
  25. cont += Q[i].k;
  26. }
  27. sort(Q, Q + m, cmp);
  28. for (int i = 1; i <= n; i++) //初始化并查集
  29. pre[i] = i;
  30. for (int i = 0; i < m; i++) {
  31. int rx = find(Q[i].x);
  32. int ry = find(Q[i].y);
  33. if (rx != ry) { //判环
  34. sum += Q[i].k;
  35. pre[rx] = ry;
  36. num++;
  37. if (num == n - 1) //边数等于n-1时结束循环,此时已经构成最小生成树
  38. break;
  39. }
  40. }
  41. printf("%d\n", sum);
  42. return 0;
  43. }

kruskal 算法的复杂度包括两部分:对边的排序 O(ElogE),并查集的操作 O(E),一共是 O(ElogE + E),约等于 O(ElogE),时间主要花在排序上。如果图的边很多,kruskal 的复杂度要差一些。kruskal 适用于稀疏图。

 

例题

 

解题思路:

 AC代码:

  1. #include <iostream>
  2. #include <vector>
  3. #include <algorithm>
  4. using namespace std;
  5. const int N = 100001;
  6. int a[N], x[N], y[N], pre[N];
  7. int n, m;
  8. struct edge {
  9. int u;
  10. int v;
  11. double w;
  12. };
  13. bool cmp(edge a, edge b) {
  14. return a.w < b.w;
  15. }
  16. vector<edge> e;
  17. int root(int x) {
  18. return x == pre[x] ? x : pre[x] = root(pre[x]);
  19. }
  20. int main() {
  21. cin >> m;
  22. for (int i = 1; i <= m; ++i) cin >> a[i];
  23. cin >> n;
  24. for (int i = 1; i <= n; ++i) {
  25. cin >> x[i] >> y[i];
  26. pre[i] = i;
  27. }
  28. //建图
  29. for (int i = 1; i <= n; ++i) {
  30. for (int j = i + 1; j <= n; ++j) {
  31. double w = sqrt((x[i] - x[j]) * (x[i] - x[j]) + (y[i] - y[j]) * (y[i] - y[j]));
  32. e.push_back({ i,j,w });
  33. }
  34. }
  35. sort(e.begin(), e.end(), cmp);
  36. int edge_num = 0;
  37. double maxw = 0.0;
  38. for (int i = 0; i < e.size(); ++i) {
  39. int fu = root(e[i].u);
  40. int fv = root(e[i].v);
  41. if (fu != fv) {
  42. edge_num++;
  43. pre[fu] = fv;
  44. maxw = max(maxw, e[i].w);
  45. }
  46. if (edge_num == n - 1) {
  47. break;
  48. }
  49. }
  50. int res = 0;
  51. for (int i = 1; i <= m; ++i) {
  52. if (a[i] >= maxw) res++;
  53. }
  54. cout << res;
  55. return 0;
  56. }

例题 

 

解题思路:

给了 n 个节点,又给了 n 个基点之间相互连接需要多少钱,现在要 n 个村庄都通电,只需要保证 n 个节点构成连通子图即可。

最小的连通子图是树,也就是构造一棵树,那么在图上构造一棵最最少花费的树的问题即为最小生成树。

AC代码: 

  1. #include <iostream>
  2. #include <algorithm>
  3. #include <vector>
  4. #include <cstring>
  5. using namespace std;
  6. const int N = 1005;
  7. int pre[N], x[N], y[N], h[N];
  8. struct edges {
  9. int u;
  10. int v;
  11. double w;
  12. bool operator <(const edges& a) const { //重载小于号
  13. return w < a.w;
  14. }
  15. };
  16. vector<edges> e;
  17. int root(int x) {
  18. return x == pre[x] ? x : pre[x] = root(pre[x]);
  19. }
  20. int main() {
  21. int n;
  22. cin >> n;
  23. for (int i = 1; i <= n; ++i) {
  24. cin >> x[i] >> y[i] >> h[i];
  25. pre[i] = i; //初始化并查集
  26. }
  27. //建图
  28. for (int i = 1; i <= n; ++i) {
  29. for (int j = i + 1; j <= n; ++j) {
  30. 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]);
  31. e.push_back({ i,j,w });
  32. }
  33. }
  34. sort(e.begin(), e.end());
  35. int edge_num = 0;
  36. double sumPrice = 0.0;
  37. for (int i = 0; i < e.size(); ++i) {
  38. int ru = root(e[i].u);
  39. int rv = root(e[i].v);
  40. if (ru != rv) {
  41. edge_num++;
  42. pre[ru] = rv;
  43. sumPrice += e[i].w;
  44. }
  45. if (edge_num == n - 1) break;
  46. }
  47. printf("%.2lf", sumPrice);
  48. return 0;
  49. }

 

 最短路径

最广为人知的图论问题就是最短路径问题。

简单图的最短路径

  • 树上的路径:任意 2 点之间

只有一条路径

  • 所有边长都为 1 的图:用 BFS 搜最短路径,复杂度 
    声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/2023面试高手/article/detail/671656
推荐阅读
相关标签