图的概念
图是由顶点集合及顶点间的关系组成的一种数据结构:G = (V, E),其中:
顶点集合V = {x|x属于某个数据对象集}是有穷非空集合;
E = {(x,y)|x,y属于V}或者E = {<x, y>|x,y属于V && Path(x, y)}是顶点间关系的有穷集合,也叫
做边的集合。
简单来说,图(graph)包含两个部分:顶点(vertex)和边(edge),所以很多地方将图写成G = (V,E)
老规矩,讲完定义,上图看看
图的基本概念
-
顶点和边:图中结点称为顶点,第i个顶点记作vi。两个顶点vi和vj相关联称作顶点vi和顶点vj之间
有一条边,图中的第k条边记作ek,ek = (vi,vj)或<vi,vj>。
-
有向图和无向图:在有向图中,顶点对<x, y>是有序的,顶点对<x,y>称为顶点x到顶点y的一条
边(弧),<x, y>和<y, x>是两条不同的边,比如上面G3和G4为有向图。在无向图中,顶点对(x, y)
是无序的,顶点对(x,y)称为顶点x和顶点y相关联的一条边,这条边没有特定方向,(x, y)和(y,x)
是同一条边,比如上面G1和G2为无向图。注意:无向边(x, y)等于有向边<x, y>和<y, x>。
-
完全图:在有n个顶点的无向图中,若有n * (n-1)/2条边,即任意两个顶点之间有且仅有一条边,
则称此图为无向完全图,比如上图G1;在n个顶点的有向图中,若有n * (n-1)条边,即任意两个
顶点之间有且仅有方向相反的边,则称此图为有向完全图,比如上图G4。
-
邻接顶点:在无向图中G中,若(u, v)是E(G)中的一条边,则称u和v互为邻接顶点,并称边(u,v)依
附于顶点u和v;在有向图G中,若<u, v>是E(G)中的一条边,则称顶点u邻接到v,顶点v邻接自顶
点u,并称边<u, v>与顶点u和顶点v相关联。
-
顶点的度:顶点v的度是指与它相关联的边的条数,记作deg(v)。在有向图中,顶点的度等于该顶
点的入度与出度之和,其中顶点v的入度是以v为终点的有向边的条数,记作indev(v);顶点v的出度
是以v为起始点的有向边的条数,记作outdev(v)。因此:dev(v) = indev(v) + outdev(v)。注
意:对于无向图,顶点的度等于该顶点的入度和出度,即dev(v) = indev(v) = outdev(v)。
-
路径:在图G = (V, E)中,若从顶点vi出发有一组边使其可到达顶点vj,则称顶点vi到顶点vj的顶
点序列为从顶点vi到顶点vj的路径。
-
路径长度:对于不带权的图,一条路径的路径长度是指该路径上的边的条数;对于带权的图,一
条路径的路径长度是指该路径上各个边权值的总和。
-
权值:边上附带的数据信息
-
简单路径与回路:若路径上各顶点v1,v2,v3,...,vm均不重复,则称这样的路径为简单路
径。若路径上第一个顶点v1和最后一个顶点vm重合,则称这样的路径为回路或环。
-
子图:设图G = {V, E}和图G1 = {V1,E1},若V1属于V且E1属于E,则称G1是G的子图。
-
连通图:在无向图中,若从顶点v1到顶点v2有路径,则称顶点v1与顶点v2是连通的。如果图中任
意一对顶点都是连通的,则称此图为连通图。
-
强连通图:在有向图中,若在每一对顶点vi和vj之间都存在一条从vi到vj的路径,也存在一条从vj
到vi的路径,则称此图是强连通图。
-
生成树:一个连通图(无向图)的最小连通子图称作该图的生成树。有n个顶点的连通图的生成树有n个顶点和n1条边。
图的基本结构
因为图中既有节点,又有边(节点与节点之间的关系),因此,在图的存储中,只需要保存:节点和
边关系即可。节点保存比较简单,只需要一段连续空间即可(顺序表,数组等都可),那边关系该怎么保存呢?
图的结构可以分为两种,邻接表和邻接矩阵,各自的适用场景不同,各自的优劣也不同,在本系列博客中,我们将以邻接矩阵为主要形式讲解后面的算法。
邻接矩阵和邻接表必备
首先我们需要用vector将节点存起来,我们取名为==_vertexs ==,
同时,每一个顶点都可以映射一个下标,这样通过下标就可以直接找到顶点
通过下标很容易找到顶点,那么怎么通过顶点来找下标呢?
显然要再次建立一组映射,直接拿出map即可,我们取名为==_indexMap==,
邻接矩阵
邻接矩阵基础
邻接矩阵顾名思义就是用矩阵(命名为weights)来表示边,矩阵的行列都表示节点,正好是vector中建立的下标和顶点的映射
那么矩阵中就可以存储权值了
这样我们就可以通过读取矩阵来获取边的信息
例如:_weights[i][j] = 6 就表示以_vertexs[i] 为起点,以_vertexs[j]为终点的边,这条边的权值为6
两条边连通,直接在矩阵中存权值即可,如果不连通,那该怎么办呢?
很简单,我们用无穷大(∞)来替代即可,int 类型我们就可以用INT_MAX来替代,其余类型类似。
邻接矩阵示例
注意:此处我的命名和图中有些区别,相信聪明的读者肯定能够发现这个问题,并自行理解
邻接矩阵的性质和优缺点
- 对于无向图,邻接矩阵是一个对称矩阵,第i行(列)元素之和,就是顶点i的度
- 邻接矩阵的优点是能够快速知道两个顶点是否连通,读邻接矩阵,如果权值是无穷大,就不相连,否则就是相连,判断两个顶点是否相连的时间复杂度是O(1)
- 邻接矩阵的缺点是如果顶点比较多,边比较少时,矩阵中存储了大量无穷大成为系数矩阵,比较浪费空间,并且要求与一个顶点相连的顶点不好求,需要遍历一行的所有节点才知道,时间复杂度是O(N),比较低效。
因此,对于邻接矩阵,比较适合用来存储稠密图(顶点非常多,边非常多)
邻接矩阵的结构代码示例
C++
template<class V,class W,W MAX_W = INT_MAX,bool DIRECTION = false>
class graph
{
private:
vector<V> _vertexs;//顶点
map<V, int> _indexMap;//顶点和下标的映射
vector<vector<W>> _weights;//矩阵
};
对代码中模版参数的说明:
V是顶点的类型,W是权值的类型,MAX_W是表示无穷大的非类型参数,默认为INT_MAX,DIRECTION是用来标识是有向图还是无向图的非类型参数,默认为无向图
邻接矩阵的初始化与添加边
构造图主要可以通过三种方法
- 从文件读取
- oj题直接给出
- 为了方便演示,我们在这里直接用手动添加边的方法来进行讲解
直接看代码就ok了,这一块还是比较简单的
C++
graph(const V* v, size_t n)//初始化
{
_vertexs.resize(n);
_weights.resize(n);
for (int i = 0; i < n; ++i)
{
_vertexs[i] = v[i];//将顶点存入数组
_indexMap[v[i]] = i;//建立映射
_weights[i].resize(n, MAX_W);
}
}
int getindex(const V& v)//获取下标,包含安全检查
{
auto it = _indexMap.find(v);
if (it == _indexMap.end())
{
throw invalid_argument("该顶点不存在");//抛异常
return -1;
}
return it->second;
}
void AddEdge(const V& src,const V& dst,W w)//手动添加边
{
int srci = getindex(src);
int dsti = getindex(dst);
_weights[srci][dsti] = w;
if (DIRECTION == false)//无向图需要双向添加边
{
_weights[dsti][srci] = w;
}
}
邻接矩阵演示
这里写了一个打印函数来打印一下图
C++
void Print()
{
for (int i = 0; i < _vertexs.size(); ++i)
{
cout << _vertexs[i] << ":" << _indexMap[_vertexs[i]] << endl;
}
cout << endl;
cout << " ";
for (int i = 0; i < _vertexs.size(); ++i)
{
cout << _vertexs[i] << " ";
}
cout << endl;
for (int i = 0; i < _vertexs.size(); ++i)
{
cout << _vertexs[i] << " ";
for (int j = 0; j < _weights[i].size(); ++j)
{
if (_weights[i][j] == MAX_W)
{
cout << "*" << " ";
}
else cout << _weights[i][j] << " ";
}
cout << endl;
}
}
这是测试代码
C++
void TestGraph1()
{
graph<char, int, INT_MAX, true> g("0123", 4);
g.AddEdge('0', '1', 1);
g.AddEdge('0', '3', 4);
g.AddEdge('1', '3', 2);
g.AddEdge('1', '2', 9);
g.AddEdge('2', '3', 8);
g.AddEdge('2', '1', 5);
g.AddEdge('2', '0', 3);
g.AddEdge('3', '2', 6);
g.Print();
}
测试结果
邻接表
邻接表基础
邻接表和哈希桶的实现思路比较像,在一个顶点下面挂一个顶点链表形成边
以下图为例,我们来看看邻接表
图中A B C D E分别映射到0 1 2 3 4 5
我们看图(注意图中示例是有向图),主要是使用出边表,入边表比较少用
以A为起点连接到B和D,那么就在A下面挂一个链表,链接B和D,即1 和 3,
同时我们可以使用自定义结构,定义出一个边的类型作为链表的节点
这个边类型只需存储三个东西即可:
1、边终点的下标(_dsti) 2、边的权值(_w) 3、在链表中的下一个节点的指针(_next)
这样,像hash桶一样链接起来就成了邻接表。
邻接表优缺点
- 邻接表的优点:邻接表用来查找一个顶点与哪些顶点相连是非常方便,直接将链表走到尾即可。而且邻接表用来存储很多不相连的边的时候很方便,比较节省空间
- 邻接表的缺点:邻接表想要直接查找任意两个顶点是否相连时并不好用,还是需要将链表走到尾,比较麻烦。
因此,邻接表比较适合用来存储稀疏图(相连的边很少)。
邻接表的结构代码示例
C++
template<class W>
struct Edge//边类
{
int _dsti;//目标顶点下标
W _weight;//边的权值
Edge<W>* _next;//指向下一个节点
Edge(int i,W weight)
:_dsti(i)
,_weight(weight)
,_next(nullptr)
{}
};
template<class V, class W, bool DIRECTION = false>
class graph
{
typedef Edge<W> Edge;
private:
vector<V> _vertexs;
map<V, int> _indexMap;
vector<Edge*> _weights;//这里的不再是矩阵,而是指针数组,用来存储链表
};
注意,邻接表和邻接矩阵相比,
邻接表里面需要额外定义一个边类,但是邻接表少了一个非类型参数MAX_W,因为在邻接表中不需要使用MAX_W来标识两个顶点是否相连。
另外,邻接表中不需要使用矩阵,直接用一个指针数组来存链表即可。
邻接表的初始化和添加边
与邻接矩阵一样,为了方便演示,我们还是使用手动添加边的方法
这里添加边和hash桶一样,相连的边直接在相应的链表中头插即可
还是比较简单的,不多解释
C++
graph(const V* v, size_t n)//初始化
{
_vertexs.resize(n);
_weights.resize(n, nullptr);
for (int i = 0; i < n; ++i)
{
_vertexs[i] = v[i];
_indexMap[_vertexs[i]] = i;
}
}
int getindex(const V& v)//获取下标
{
auto it = _indexMap.find(v);
if (it == _indexMap.end())
{
throw invalid_argument("该顶点不存在");//抛异常
return -1;
}
return it->second;
}
void AddEdge(const V& src, const V& dst, W w)//手动添加边
{
int srci = getindex(src);
int dsti = getindex(dst);
Edge* newedge = new Edge(dsti, w);
newedge->_next = _weights[srci];
_weights[srci] = newedge;
if (DIRECTION == false)//无向图要双向添加边
{
Edge* newedge2 = new Edge(srci, w);
newedge2->_next = _weights[dsti];
_weights[dsti] = newedge2;
}
}
邻接表演示
这里写了一个打印函数来打印一下图
C++
void Print()
{
for (int i = 0; i < _vertexs.size(); ++i)
{
cout << _vertexs[i] << ":" << i << endl;
}
cout << endl;
/*cout << " ";
for (int i = 0; i < _vertexs.size(); ++i)
{
cout << _vertexs[i] << " ";
}
cout << endl;*/
for (int i = 0; i < _weights.size(); ++i)
{
cout << _vertexs[i] << "->";
Edge* cur = _weights[i];
while (cur)
{
cout << "[" << _vertexs[cur->_dsti] << ":" << cur->_weight << "]->";
cur = cur->_next;
}
cout << "nullptr" << endl;
}
}
测试代码,两组测试用例都可以用
C++
void TestGraph1()
{
graph<char, int, true> g("0123", 4);
g.AddEdge('0', '1', 1);
g.AddEdge('0', '3', 4);
g.AddEdge('1', '3', 2);
g.AddEdge('1', '2', 9);
g.AddEdge('2', '3', 8);
g.AddEdge('2', '1', 5);
g.AddEdge('2', '0', 3);
g.AddEdge('3', '2', 6);
g.Print();
/*string a[] = { "张三", "李四", "王五", "赵六" };
graph<string, int, true> g1(a, 4);
g1.AddEdge("张三", "李四", 100);
g1.AddEdge("张三", "王五", 200);
g1.AddEdge("王五", "赵六", 30);
g1.Print();*/
}
测试结果
图的遍历
图的遍历分为深度优先遍历(DFS)和广度优先遍历(BFS)
分别从两个不同的纬度实现遍历,
深度优先遍历和树的前序遍历很像
广度优先遍历和树的层序遍历很像
只是到图的层面更加复杂一些而已
深度优先遍历(DFS)
深度优先遍历思路
深度优先遍历采用递归的思想实现,
需要使用一个visited数组来标记已经访问过的顶点(存bool值即可),已经访问过就不在访问
遍历需要一个起点,我们将其命名为src
我们从顶点src开始,先把src顶点给读一遍,并将其标记为已经访问状态
接下来,去邻接矩阵中遍历src顶点所在的一行,当遇到与其相连的顶点并且该顶点还没有被访问的时候,直接以该相连顶点为起点去再次进行递归进行DFS。
OK,完了,DFS就是这么多,本质上DFS就是一个多路递归。
嘿嘿,虚晃一招,其实还没有遍历完,
大家想一想,如果遇到了孤岛节点怎么办(孤岛节点就是与任何节点都不相连的节点)?
所以啊,DFS并没有结束,需要在最后再遍历一遍visited数组,遇到未访问的节点,以该顶点为起点再次进行DFS即可。
深度优先遍历代码
C++
void _DFS(const V& src, vector<bool>& visited)
{
int srci = getindex(src);
cout << _vertexs[srci] << endl;
visited[srci] = true;
for (int i = 0; i < _vertexs.size(); ++i)
{
if (_weights[srci][i] != MAX_W && visited[i] == false)
{
_DFS(_vertexs[i], visited);
}
}
}
void DFS(const V& src)
{
vector<bool> visited(_vertexs.size(), false);
_DFS(src, visited);
for (int i = 0; i < visited.size(); ++i)//防止有孤岛遗漏
{
if (visited[i] == false)
{
_DFS(_vertexs[i], visited);
}
}
}
深度优先遍历演示
测试代码
C++
void TestBDFS()
{
string a[] = { "张三", "王五","李四", "赵六", "周七" };
graph<string, int> g1(a, sizeof(a) / sizeof(string));
g1.AddEdge("张三", "李四", 100);
g1.AddEdge("张三", "王五", 200);
g1.AddEdge("王五", "赵六", 30);
g1.AddEdge("王五", "周七", 30);
g1.Print();
cout << endl;
//g1.BFS("张三");
cout << endl;
g1.DFS("张三");
}
测试结果
广度优先遍历(BFS)
广度优先遍历思路
广度优先遍历和层序遍历一样,需要借助队列这一数据结构
另外需要借助一个标记数组,记作visited
也是采用出一个,带下一层的思路
首先将起点进入队列
当队列不空的时候,
每次取队头元素,进行访问,并标记为已经访问
接着,在矩阵中遍历改行元素,遇见相连的并且未访问的顶点的时候,将其入队列即可。
与DFS类似,也需要处理一下孤岛节点
最后再遍历一遍visited数组,遇到未访问的节点,以该顶点为起点进行BFS即可。
广度优先遍历代码
C++
void _BFS(const V& src,vector<bool>& visited)
{
int srci = getindex(src);
queue<int> q;
q.push(srci);
while (!q.empty())
{
int front = q.front();
q.pop();
cout << _vertexs[front] << endl;
visited[front] = true;
for (int i = 0; i < _vertexs.size(); ++i)
{
if (_weights[front][i] != MAX_W && visited[i] == false)
{
q.push(i);
}
}
}
}
void BFS(const V& src)
{
int n = _vertexs.size();
vector<bool> visited(n,false);
_BFS(src, visited);
for (int i = 0; i < n; ++i)//再次遍历,防止孤岛节点
{
if (visited[i] == false)
{
_BFS(_vertexs[i], visited);
}
}
}
广度优先遍历演示
测试代码
C++
void TestBDFS()
{
string a[] = { "张三", "王五","李四", "赵六", "周七" };
graph<string, int> g1(a, sizeof(a) / sizeof(string));
g1.AddEdge("张三", "李四", 100);
g1.AddEdge("张三", "王五", 200);
g1.AddEdge("王五", "赵六", 30);
g1.AddEdge("王五", "周七", 30);
g1.Print();
cout << endl;
g1.BFS("张三");
cout << endl;
//g1.DFS("张三");
}
测试结果
图的分享还没有结束,后续 还会继续更新最小生成树和最短路径相关的知识,期待大家继续阅读。