书接上回hhh
创建两个列表的方式比较好想也很直观,但是在处理一些操作的时候,时间复杂度比较高,所以我们应该考虑更为高效的实现方式。
邻接矩阵
像上图这样,我们创建一个二维数组来表示边,行列表示顶点的下标,且行表示第一个顶点,列表示指向(对于有向图来说)的节点。比如在第0行第1列的数为1,说明下标为0和下标为1的两个顶点之间有连接,没有连接的位置都置为0.对于该无向图,矩阵是对称的,其边数应该是1数的一半.
代码实现
#include<iostream>
#include<vector>
using namespace std;
class Graph{
private:
int vertexs;
vector<vector<int>> edges;//创建二维数组
public:
// 构造函数,初始化图的大小
Graph(int vertices) : vertexs(vertices), edges(vertexs,vector<int>(vertexs, 0)) {}
void Pushv(int i,int j)//无向图对称性
{
edges[i][j]=1;
edges[j][i]=1;
}
void Print()
{
for(int i=0;i<vertexs;i++)
{
for(int j=0;j<vertexs;j++)
{
cout<<edges[i][j]<<" ";
}
cout<<endl;
}
}
};
int main()
{
Graph g(5);
g.Pushv(0,1);
g.Pushv(0,2);
g.Pushv(1,2);
g.Pushv(2,3);
g.Pushv(3,4);
g.Pushv(4,1);
g.Print();
return 0;
}
一些解释
1.构造函数
在C++中,class类中可以包含多种类型的成员函数:
我们之前写过的,就像我们平常在class类之外的函数一样具有特殊功能的函数是一类
这里所提到的构造函数是一类: 构造函数负责初始化类的对象。它的名称与类名相同,没有返回类型。构造函数在对象创建时被调用,用于初始化对象的成员变量等。
还有析构函数:析构函数在对象生命周期结束时被调用,用于清理对象分配的资源。它的名称是在类名前面加上波浪号~。
格式:
class MyClass {
public:
// 构造函数
MyClass() {
// 初始化代码
}
// 析构函数
~MyClass() {
// 清理代码
}
};
在我们的代码中这是构造函数
// 构造函数,初始化图的大小
Graph(int vertices) : vertexs(vertices), edges(vertexs,vector<int>(vertexs, 0)) {}
我们写的好像和上面的格式不太一样:(每部分要说的太多了,写在图里啦
动态二维数组的创建还是要留个印象的。
2. private定义变量的必要性
在我们的代码中,这里将顶点数量和矩阵都设置为私有,防止外界访问。在这里其中一种预防就是避免在主函数中对矩阵的某个值进行了修改,因为我们在类里面对图进行的操作就是按规定正确存储的,对图的修改也要按正确的规则(class类中的函数)进行修改,而不是在外部无约束的进行修改。
附:
3.关于创建顶点数组-添加顶点问题
之后再写~
4.加权图
如果是加权图的话,我们将1改成对应权重即可。
我们可以选择正无穷/负无穷/其他任何非法的值作为权重。
我们在添加边的函数中传递权重参数,将二维矩阵中对应位置的值赋为权重,每次传入数据不同就代表不同的权重,就可以实现加权图的存储。
时间复杂度分析
对于这种存储方式,实现查找特定节点的相邻节点时间成本是多少呢?
由于我们边的存储记号为顶点的下标,所以我们知道给出节点之后还要知道其索引,为了找到其索引,我们要遍历顶点列表,找到索引之后,在二维数组中遍历该索引的行的每一个元素即可。
这种方式的时间复杂度为:首先遍历顶点数组O(|V|),之后遍历该索引的行(一行元素的数量由顶点数量决定)为O(|V|)。总时间复杂度为O(|V|)+O(|V|)即O(|V|)
判断两个节点是否相连的时间成本呢?
如果我们给出的直接就是两个顶点的索引,那么在二维数组中找到他们仅需O(1);如果给出的是顶点的名字,那么找到这个顶点所对的索引时间复杂度为O(|V|),下一步在二维数组中找出仍为O(1)
空间复杂度分析
时间上来说确实好了很多。但是对于空间的利用,我们每次都要创建V*V的矩阵,如果图很稠密,有很多边,那利用率就比较高,但如果图比较稀疏,我们浪费的空间就比较多了。我们同时存储了有连接的信息和没有连接的信息,然鹅二者有其一即可推算出另一个。由于大多数情况下图是稀疏的,所以我们选择只存储有链接的点更好。
关于图的应用的一个例子:在一个社交网络里,互相为朋友的用户之间会有连接,可是该网络上用户那么多,每个用户都是一个节点,可是他们之间互为朋友的可能远小于所有用户的数量。
我不在举更多例子,这个比较好理解。
邻接表
思路
由于邻接矩阵在空间上的弊端,我们想到大多数情况下图是稀疏的,所以我们选择只存储有链接的点更好。(注意这种方式适用于稀疏图)
邻接表的实质就是:我们对于每个顶点所相连的边的存储使用链表实现,然后我们又将这些顶点组在一起,这样是一个元素均为链表的数组,而对于链表来说,唯一的标识是链表的头节点,所以我们在代码中使用创建一个元素为指针的数组来将这些链表组在一起。每个顶点对应的链表都是一个邻接表。(在图论中,"邻接表"通常指的是整个数据结构,即包含所有顶点的链表的数组。每个顶点的链表可以被看作是该顶点的邻接表,但是当我们说"邻接表"时,我们通常指的是整个数据结构。)
复杂度
对于其空间复杂度,首先是存储顶点的数组会耗费O(|V|)空间;对于后面链表的边来说,在无向图中,每条边都会在两个顶点的链表中各出现一次,所以链表节点的总数是2|E|。在有向图中,每条边只会在一个顶点的链表中出现,所以链表节点的总数是|E|。
因此这种存储方式的空间复杂度为O(|E|+|V|)。相比邻接矩阵的V*V,优化了很多
时间复杂度,我们还从那两个问题的角度来分析:
如何判断这两个顶点是否有链接?首先我们要先在数组中找到其中一个顶点,一般来说,数组的索引通常会与顶点的标识符关联,让用户直接输入想要找到的顶点的索引。只有这样才能使得在数组中找到一个索引的时间复杂度为O(1)。如果顶点的标识符不是整数,或者不是连续的,那么我们就需要使用一个额外的数据结构(例如哈希表)来将顶点的标识符映射到数组的索引。找到其中一个顶点之后,就是遍历其对应的链表,就我们考虑最坏时间复杂度的原则来说,假设最坏的情况是该顶点与其他所有顶点都有链接,而目标顶点又在链表末尾,这样时间复杂度最坏为O(|V|)。(一般情况下,其时间复杂度为该顶点的度(与该顶点相连的边的数量))
对于如何找到一个顶点的所有直连节点,根据上面的分析,其时间复杂度应该为O(|V|)。这里我们考虑的都是最坏的情况,需要查找该顶点的邻接表。
虽然看起来对于时间复杂度来说好像并没有提升,但是我们上面考虑的是最坏的情况,下面将邻接矩阵和邻接表执行这两种操作进行对比:我们以社交网络为背景,假设有1e9个顶点,一个用户最多有1e4个连接,我们假设计算机1s处理1e6个数据;对于邻接矩阵来说我们扫描一行也就是1e9个数据,对于邻接表,一行最多只有1e4;二者所运行的时间上邻接表有很大优势
代码实现
在邻接表的实现中,可以使用结构体指针来构建图的每个顶点,其中每个顶点的结构体中包含一个指向邻接点的链表的指针。这样的结构体通常被称为“邻接表节点”或“邻接表条目”
#include <iostream>
#include <vector>
using namespace std;
// 邻接表节点的定义
struct AdjListNode {
int dest; // 目标顶点
int weight; // 边的权重
AdjListNode* next; // 下一个邻接表节点
};
// 邻接表中每个顶点的结构体
struct Vertex {
AdjListNode* head; // 邻接表头指针
};
class Graph {
private:
int numVertices; // 图的顶点数
vector<Vertex> adjacencyList; // 邻接表
public:
Graph(int vertices) : numVertices(vertices), adjacencyList(vertices, {nullptr}) {}
// 添加边到邻接表
void addEdge(int start, int end, int weight) {
// 添加起始顶点到目标顶点的边
// 创建一个新的邻接表节点
AdjListNode* newNode = new AdjListNode{end, weight, nullptr};
// 将新节点插入到起始顶点的邻接表的头部
newNode->next = adjacencyList[start].head;
adjacencyList[start].head = newNode;
// 无向图需要反向边
// 创建另一个新节点,表示反向的边
newNode = new AdjListNode{start, weight, nullptr};
// 将新节点插入到目标顶点的邻接表的头部
newNode->next = adjacencyList[end].head;
adjacencyList[end].head = newNode;
}
// 打印图的邻接表
void printGraph() {
for (int i = 0; i < numVertices; ++i) {
// 遍历每个顶点的邻接表
AdjListNode* current = adjacencyList[i].head;
// 打印顶点及其邻接节点
cout << "Adjacency List for vertex " << i << ": ";
while (current != nullptr) {
cout << "(" << current->dest << "," << current->weight << ") ";
current = current->next;
}
cout << endl;
}
}
};
int main() {
// 创建图并添加边
Graph g(5);
g.addEdge(0, 1, 2);
g.addEdge(0, 2, 4);
g.addEdge(1, 2, 1);
g.addEdge(2, 3, 3);
g.addEdge(3, 4, 5);
// 打印图的邻接表
g.printGraph();
return 0;
}
这个就是感觉很绕,可以画画图执行一下过程,帮助理解。
一些解释
1.邻接表节点结构体的定义
这里包含三个元素:目标节点(也就是针对某个顶点,与其相连的另一个顶点即为我们的目标节点)、边的权重、指向下一个节点的指针,这里可以类比一下链表的节点的结构体,当我们创建一个新的节点并将其插入到邻接表中时,我们通常会将这个指针设置为NULL,表示这是链表的末尾。然后,当我们添加更多的节点到链表中时,我们会更新这个指针,使其指向新添加的节点。这样,我们就可以通过遍历链表来访问所有的节点。
其对应到添加边的函数中void addEdge(int start, int end, int weight) ,start和end对应用户输入的两个相连的顶点,这里end就是我们上面的目标节点。
2.void addEdge(int start, int end, int weight)
当添加新的边时,实际上是在该顶点的邻接表中插入了一个新的节点(类比链表中的插入节点),然后更新了 head 指针,使其指向新的节点。虽然我们在不断更新head,可是不管我们怎么更新,都是在为已有的每一个顶点添加新的连接,都是做的完整我们这个顶点连接的操作(我刚开始有点懵hh)。注意无向图在一个顶点的邻接表中添加边的同时记得还要在另一个顶点里也添加对方。变量名我写的比较复杂注意每个变量所表示的含义。
3.输出打印函数
其思路是根据顶点数量,利用for循环我们找到每个顶点所对的邻接表的头结点,根据头节点利用while循环遍历其所对的链表,这在之前链表中也实现过,思想是一致的。
好啦。数据结构这个课程就更完啦。接下来就主要学习算法和写项目啦。
如果有哪里出现错误的说法欢迎指出,非常感谢。
也欢迎交流建议奥。一起加油!