P40-P42笔记,完结撒花啦~(不是[扶额苦笑]原本以为能写完的,只是发现才写完一个文章就好长了,剩下的留在后面吧hhh)
视频地址👇真的很推荐\^o^/~
http:www.bilibili.com/video/BV1Fv4y1f7T1?p=42&vd_source=02dfd57080e8f31bc9c4a323c13dd49c
在上节,我们已经初步认识了图。那么关于学习一种数据结构,最最基础的,肯定要知道它是如何存储的嘞。这里我们就来了解几种图的存储方式。
目录
创建两个列表
思路
我们知道,图包含一个顶点集和一个边集,那么我们最先想到的就是,创建两个列表将这两部分存起来就好啦。经过前面的学习,我们已经习惯使用动态存储方式来避免静态数组所带来的问题。在c++中,我们可以使用vector容器创建动态数组比较方便。
顶点数组只要存储下顶点就可以了,比较好实现。边数组应该包含边连接的两个顶点,在有向图中是起点和终点。作为一个数组,每个元素又应当包含两个元素,我们可以将边数组创建为结构体数组,该结构体包含两个顶点。同理,当该图为加权图时,每条边有其不同的权重,此时在结构体中在增加一个权重变量即可。
相信关于这两个数组的创建应该明白啦。
代码实现
#include<iostream>
#include<vector>
using namespace std;
struct edge{
int v1;
int v2;
int weight;
};
class Graph{
public:
//创建两个动态数组
vector<int> v;
vector<struct edge> e;
void Pushv(int x)
{
v.push_back(x);
}
void Pushe(int x1,int x2,int w)
{
e.push_back({x1,x2,w});
}
void Printg()
{
for(auto &edge:e)
{
cout<<"edges:"<<edge.v1<<"-"<<edge.v2<<"-"<<edge.weight;
}
for(auto &v:v)
{
cout<<"vertexs:"<<v<<" ";
}
}
};
int main()
{
//创建图
Graph g;
g.Pushv(1);
g.Pushv(10);
g.Pushv(12);
g.Pushv(15);
g.Pushe(1,10,5);
g.Pushe(1,12,6);
g.Pushe(1,15,8);
g.Pushe(10,15,10);
return 0;
}
一些语法的补充和解释
在这段代码中,我们应用到了很多c++语法,这里进行一个语法知识的补充:
1.vector<>容器
这里表示实现一个动态数组。使用时记得引头文件 #include<vector>。关于其他的应用我们就遇到再说吧。
2.class类
这里我们封装了一个关于图的创建及打印功能的class类。在C++中,类(class)是一种面向对象编程的基本结构。它允许我们将数据(属性)和操作这些数据的函数(方法)组合在一起。这有助于我们组织和结构化代码,使其更易于理解和维护。简单理解,这里创建了一个有关图的创建的class类,将图的创建有关的函数都封装进去,使得代码更清晰。这就是他的主要作用。关于类里面详细的知识点,这里暂时先不解释了,遇到再说hh。只是提一下我们要写上 public:来使得该类之外得其他位置都能够访问其中的函数呀什么的。
常见格式如下啦。
class ClassName {
public:
// 公有成员
private:
// 私有成员
protected:
// 受保护成员
};
public:这个关键字之后的成员可以被任何人访问。通常,我们会在这里声明类的公有方法。
private:这个关键字之后的成员只能被类的成员函数和友元函数访问。通常,我们会在这里声明类的私有数据成员。
protected:这个关键字之后的成员可以被类的成员函数、友元函数以及派生类访问。这通常用于继承。
3.for-each循环
感觉就是我们之前写循环算是一个简化版吧,如下格式
for (declaration : range) {
// 循环体
}
//以前的
for(int i=0;i<n;i++)
declaration是一个变量的声明(可以理解为之前的 i ),它将在每次循环中被初始化为range中的当前元素。range是一个可以遍历的对象,如数组或向量。
了解了这些,这里的循环就不难理解啦。这里我们将这个变量命名为edge,并且采用引用,避免了重新开辟空间。
for(auto &edge:e)
{
cout<<"edges:"<<edge.v1<<"-"<<edge.v2<<"-"<<edge.weight;
}
4.结构体数组的赋值
edges.push_back({src, dest});
我们这里采用直接 { } 使用两个大括号进行初始化赋值(我刚开始没反应过来hh
下面这种写法更清晰,可以帮助理解。
Edge e;
e.v1 = x1;
e.v2 = x2;
e.weight = w;
edges.push_back(e);
分析代码的复杂度
关于空间复杂度
由于空间复杂度主要与边数组存储的方式有关,所以这里我们就该问题讨论其空间复杂度
如果我们顶点的命名采用了字符串形式的命名:边数组存储的方式
前提我们先记住哪两个顶点进行链接是我们决定的,也就是两个顶点是已知的。(因为我发现我在想我怎么知道对应顶点的下标呢?我怎么知道引用谁呢?就是忘记了这其实是已知的hhh)
1.存储边集时使用前面已经创建过的顶点的引用
对于顶点数组中,其占用的空间复杂度就是O(|v|),与顶点数目有关。
不同字符串占用空间不同对顶点数组有什么影响么?是有的,但是我们并没有由此对其有什么特殊处理:这是因为首先顶点数肯定远远小于边数,其次顶点数组中的每个元素(即每个顶点)都是独立的,我们不需要为所有顶点分配相同的空间(因为顶点数组的大小(即空间复杂度)主要取决于顶点的数量,而与顶点的具体内容(这里是字符串)无关)。因此,即使字符串的长度有所不同,也不会影响顶点数组的空间复杂度。
可是在边集里,由于每个顶点的字符串长度可能不同,导致边数组中每个元素使用空间不同,而边数组的空间复杂度始是与边的数量有关的,每个边都需要存储两个顶点。如果我们直接存储顶点的内容(即字符串),那么边数组的大小(即空间复杂度)就会受到字符串长度的影响。这可能会导致空间使用不均衡。
由此我们想到引用,当我们使用引用时,无论引用的对象是什么类型(无论其大小如何),引用本身的大小都是固定的。这是因为引用本质上就是对象在内存中的地址,而所有的地址在给定的系统中都有相同的大小。因此,无论我们引用的字符串有多长,引用本身占用的空间都是一样的
这种方法要实现是在创建边的结构体的时候,直接以引用的方式创建变量,而不是在初始化的那一步加引用符
//前面结构体部分
struct edge{
string &v1;
string &v2;
int weight;
};
//后面赋值部分不变
e.push_back({v1, v2});
这里注意一下引用的用法:
引用必须在创建时进行初始化,并且一旦初始化后就不能更改引用的目标。这是因为引用本质上就是别名,它们必须引用一个已经存在的对象。
而这里我们不需要担心这个是因为我们后面赋值的时候就是一次一次的重新建立结构体并直接赋初值的写法。
2.将边直接设置为两个顶点的数组下标的形式存储
这种方法就更简便啦,我们直接将字符串简化为一个整型,不用再考虑占用空间不同的问题。
关于这种将边中两个顶点存储为该顶点下标时,我们可以通过更改打印的方式来实现
void Pushe(int v1_index, int v2_index, int w)//将名字做了修改方便明白其含义
{
e.push_back({v1_index, v2_index, w});
}
void Printg()
{
for(auto &edge:e)
{
cout<<"edges:"<<v[edge.v1_index]<<"-"<<v[edge.v2_index]<<"-"<<edge.weight;//作为下标索引
}
for(auto &v:v)
{
cout<<"vertexs:"<<v<<" ";
}
}
这样来看这种方法的空间复杂度为O(|V|++|E|)
时间复杂度
这种创建两个列表的实现方式,我们插入一个节点/边的时间复杂度是O(1),就像数组向后添加一个元素一样。
讨论其时间复杂度我们还要多考虑一下对于图的一些其他比较频繁使用的操作所具有的时间复杂度。
1.找到一个节点所有的相邻节点
遍历边列表,对于有向图我们只需要找列表中起始节点是否是给定节点的相邻节点,对于无向图我们要同时看起始节点和终止节点
在有向图中,每条边都有一个明确的方向,从一个节点(起始节点)指向另一个节点(终止节点)。因此,当我们在有向图中寻找一个给定节点的相邻节点时,我们通常只关注那些起始节点是给定节点的边,因为我们可以通过这些边从给定节点直接到达的节点。
然而,在无向图中,边是没有方向的,所以每条边的两个节点都可以视为彼此的相邻节点。因此,当我们在无向图中寻找一个给定节点的相邻节点时,我们需要查看每条包含给定节点的边的两个节点。无论给定节点在边上是作为起始节点还是终止节点,边上的另一个节点都是给定节点的相邻节点。
//无向图找给定节点的相邻节点
vector<int> Findnei(int node)
{
vector<int> neighbors;
for(auto &edge:e)
{
if(edge.v1==node)//起始点是给定节点
{
neighbors.push_back(edge.v2);
}
else if(edge.v2==node)//终点是给定节点
{
neighbors.push_back(edge.v1);
}
}
return neighbors;
}
这是我们把这个无向图的函数写在class内部的实现
//无向图找给定节点的相邻节点
vector<int> Findneig(Graph &g,int node)
{
vector<int> neighbors;
for(auto &edge:g.e)
{
if(edge.v1==node)
{
neighbors.push_back(edge.v2);
}
}
return neighbors;
}
对于有向图的实现,这里我把函数写在了class外部,有一些需要注意的点。
我们在class内部定义了数组e,尽管定义之前规定了范围public,说明了其可以被外部访问,但是我们在进行外部访问时,e是属于图里的,在使用的时候要先有图才行呀对吧,在主函数中,我们创建了图g,所以在主函数中直接g.e就可以访问,同样在我们定义的其他外部函数比如这里这个,同样要通过g图来访问,所以我们在函数参数上应该包含着图。
2.判断给定的两个节点是否相连
由于当我们说两个节点是“相连”的时候,我们通常是指存在一条边连接这两个节点,而不关心这条边的方向,所以无论是无向图还是有向图,我们都可以通过遍历边数组来判断两个给定的节点是否相连。如果在边数组中存在一条边的起始节点和终止节点分别是这两个给定的节点,那么我们就可以说这两个节点是相连的。
也比较好实现。
//判断两个给定节点是否相连
bool link(int n1,int n2)
{
for(auto &edge:e)
{
if((edge.v1==n1 && edge.v2==n2) || (edge.v1==n2 && edge.v2==n1))
{
return 1;
}
}
return 0;
}
通过对这两个问题的讨论,我们发现其时间复杂度与边的数量有关,即O(|E|)
但是我们之前说过,边的数量最大能达到顶点数的平方,这样看来这种实现还是不够高效的。
如果有哪里出现错误的说法欢迎指出,非常感谢。
也欢迎交流建议奥。