在日常生活中,会出现很多场景,比如QQ、微信的人际关系,地图的交通、或者说微博、抖音这种,都需要图这种数据结构来记录每个账号之间的关系,每个城市之间的交通等,而本篇博客就是讲解什么是图,并且了解图的结构。
图的概念
图是由顶点集合和顶点之间的关系组成的一种数据结构。
可以用 G = {V,E} 表示,V表示顶点集合,E表示边的集合。
其中顶点集合我们可以看做是二叉树中的一个个节点,因为二叉树本身就是一种特殊的图。
而边的集合可以看做是一种通路,一般记作:(x,y),其中的 x 和 y 是顶点集合中的顶点。而图又分为无向图和有向图,(x,y) 是无向图的概念,即通路,表示可以从 x 到 y ,也可以从 y 到 x
Path(x,y) 则是有向图的概念,表示可以从 x 到 y,无法从 y 到 x,这是有序的一种体现,也可以看做是有方向的。
单纯看概念是有点迷糊的,我们可以配着图来看哪些是有向图哪些是无向图。
从图中可以看到,无向图是没有方向的,A能够到 B,那么B一定能够到A;而有向图有方向,A能够去B,但是B不一定能够去A。
了解了图的基础概念,以及有向图无向图的概念后,我们再来了解一些其他的概念。
- 完全图:假设无向图有 N 个顶点,而这个图中有 (N*(N-1))/2 条边时,就称这个图是完全图,而有向图中有N个顶点,而图中有 N*(N-1) 条边时,这个有向图就是完全图
- 邻接顶点:在无向图中,(u,v) 是 u 和 v 的一条边,即 u 和 v 互为邻接顶点,并且边(u,v)依附于顶点 u 和 v ;有向图中,<u,v> 是 图中的一条边, 称 u 邻接于 v ,v 邻接自 u,边 <u,v> 与 u、v相关联
- 顶点的度:顶点的度是指顶点 v 与和它相关联的边的条数,记作 deg(v); 有向图中,顶点的度是出度和入度之和,无向图中则是等于出度和入度;出度指 v 为起点的边的条数,记作indev(v),入度则是 v 为终点的边的条数 outdev(v)
- 路径:在图 G(V,E) 中,若能够从顶点 vi 到顶点 vj,则称从 vi 到 vj 的顶点序列为 vi 到 vj 的路径
- 路径长度:不带权的图,路径长度等于边的条数,带权则等于路径上边的权值总和(权值是指边附带的数据信息)
- 简单回路和环:如果一条路径上各顶点不重复,则是简单路径,如果起点和终点一样,则是环
- 子图:图G = {V,E} 和 图 G1 = {V1,E1},若 V1 属于 V 且 E1 属于 E,则G1是G的子图
- 连通图:在无向图中,若顶点 v1 到 v2 有路径,则 v1 和 v2 是连通的,若所有顶点都是连通的,则该图是连通图
- 强连通图: 在有向图中,若每一对顶点 v1 到 v2 有路径,且 v2 到 v1 之间也有路径,那这个有向图是强连通图
- 生成树: 一个连通图的最小连通子图称为该图的生成树,即 n 个顶点的连通图有 n-1 条边(生成树也许并不唯一)
图的概念了解之后,那么图是如何存储顶点之间的关系呢?让我们继续学习。
图的存储结构
在图中除了顶点需要存储之外,我们还需要存储顶点之间的关系,即边,而存储边一般有两种方式:邻接矩阵和邻接表。
邻接矩阵
顶点之间要么就是连通,要么就是不连通,那么我们可以采用一个二维数组,下标对应顶点,采用矩阵来表示顶点之间的关系。
在下面的情况下,没有权值,则1表示有连接,0表示无连接,而如果有权值,我们可以设置INT_MAX 表示无连接,其他为有连接

邻接矩阵的优缺点
- 优点
- 适合稠密图
- 判断两个顶点的关系的时间是 O(1) 级别的
- 缺点
- 不适合稀疏图
- 查询一个顶点的所有边的时间是 O(N) 级别的
为什么邻接矩阵判断两个顶点的关系的时间是O(1) 级别的呢?首先我们要知道,邻接矩阵的下标就对应着顶点的下标,因此我们能够很快的从顶点得到下标,这样我们如果想要查询两个顶点的关系,只用获取下标就能够直接从矩阵中获得了。
而为何查询一个顶点的所有边的时间是 O(N) 级别的呢?这是因为邻接矩阵下,一个顶点所对应的那一组数据中,包含所有顶点的数据,无论他们是否相连都包含,而想查询一个顶点的所有边只能一个个去遍历,就降低了效率。
邻接矩阵的图的实现
作为一个图,内部必然包含一个储存顶点的数组,而为了快速获取顶点的下标,我们可以用一个map进行哈希,而邻接矩阵,我们可以采用一个二维数组进行保存。
而一个图至少有一个功能:新增边。
新增边的功能很简单,获取到定顶点对应的下标,然后进到矩阵中间存储权值即可,如果是无向图,就需要再反过来添加一遍。
下面给出完整代码:
namespace matrix {
template<class V,class W, W MAX_W = INT_MAX,bool is_direct = false>
class Graph {
typedef Graph<V, W, INT_MAX, is_direct> Self;
public:
Graph() = default;
//初始化
Graph(const V* a, size_t n)
{
_vertexs.reserve(n);
for (size_t i = 0; i < n; i++)
{
_vertexs.push_back(a[i]);
_indexMap[a[i]] = i;
}
_matrix.resize(n);
for (size_t i = 0; i < n; i++)
{
_matrix[i].resize(n, MAX_W);
}
}
//获取对应顶点的下标
size_t GetIndex(const V& v)
{
auto t = _indexMap.find(v);
if (t != _indexMap.end())
{
return t->second;
}
else {
cout << "该顶点不存在" << endl;
return -1;
}
}
void _AddEdge(const int& srci, const int& dsti, W w)
{
//并且存储到矩阵中
_matrix[srci][dsti] = w;
//如果是无向图,就需要再添加一次
if (is_direct == false)
{
_matrix[dsti][srci] = w;
}
}
//添加边的起点和终点,以及权值
void AddEdge(const V& src, const V& dst, W w)
{
//获取两个顶点对应的下标
size_t srci = GetIndex(src);
size_t dsti = GetIndex(dst);
_AddEdge(srci, dsti, w);
}
private:
//一个存储顶点的数组
vector<V> _vertexs;
//存储顶点对应的下标
map<V, int> _indexMap;
//邻接矩阵,下标对应顶点
vector<vector<W>> _matrix;
};
};
邻接表
和邻接矩阵相对的是邻接表,它内部采用 数组 + 链表的方式存储数据。
邻接表的优缺点
- 优点
- 适用于稀缺图
- 查询一个顶点的所有边的时间是 O(1) 级别的
- 缺点
- 不适用于稠密图
可以看到,邻接表和邻接矩阵是相对的,互补的。
其查询一个顶点的边的时间是 O(1) 的原因也是因为一个顶点对应只存储有关的节点。
邻接表的图的实现
而由于我们需要用链表存储边,因此我们需要创建一个边的结构体。
一条边应该有它终点的下标,对应的权值,以及它的下一条边的地址。
除了这点,其他功能和邻接矩阵并没有不同。
//邻接表的图
namespace link_table {
template<class W>
struct Edge {
int _dsti;
W _w;
Edge<W>* next;
Edge(const int dsti, const W w)
:_dsti(dsti),
_w(w),
next(nullptr)
{
}
};
template<class V, class W, bool is_direct = false>
class Graph {
public:
typedef Edge<W> Edge;
//初始化
Graph(const V* a, size_t n)
{
_vertexs.reserve(n);
for (int i = 0; i < n; i++)
{
_vertexs.push_back(a[i]);
_indexMap[a[i]] = i;
}
_link_table.resize(n,nullptr);
}
//获取对应顶点的下标
size_t GetIndex(const V& v)
{
auto t = _indexMap.find(v);
if (t != _indexMap.end())
{
return t->second;
}
else {
cout << "该顶点不存在" << endl;
return -1;
}
}
//添加边的起点和终点,以及权值
void AddEdge(const V& src, const V& dst, W w)
{
//获取两个顶点对应的下标
size_t srci = GetIndex(src);
size_t dsti = GetIndex(dst);
//并且头插到表中
Edge* eg = new Edge(dsti, w);
eg->next = _link_table[srci];
_link_table[srci] = eg;
//如果是无向图,就需要再添加一次
if (is_direct == false)
{
Edge* eg = new Edge(srci, w);
eg->next = _link_table[dsti];
_link_table[dsti] = eg;
}
}
void Print()
{
for (int i = 0; i < _vertexs.size(); i++)
{
cout << "vertexs[" << i << "]->" << _vertexs[i] << endl;
}
for (int i = 0; i < _link_table.size(); i++)
{
Edge* cur = _link_table[i];
cout << i << "->";
while (cur)
{
cout <<"[" << cur->_dsti << ": " << cur->_w <<"]" << "->";
cur = cur->next;
}
cout <<"nullptr" << endl;
}
}
private:
//一个存储顶点的数组
vector<V> _vertexs;
//存储顶点对应的下标
map<V, int> _indexMap;
//邻接矩阵,下标对应顶点
vector<Edge*> _link_table;
};
};
虽然邻接矩阵和邻接表是互补的,但是实际上邻接矩阵用的更多,因此下面的实现都是采用的邻接矩阵的实现。
图的搜索
图的搜索和树的搜索其实都是类似的,图的广度优先遍历和树的广度优先遍历其实都类似,采用队列进行遍历,但是不同的是,图有成环的问题,树没有成环问题,因此图的搜索只有队列是不行的,还需要额外的访问数组来保证一个顶点只被访问一次,而且图还有孤岛问题,也需要访问数组来保证每个顶点都被访问到。
广度优先遍历
类似树的层序遍历,图的广度优先遍历也是先找到一个顶点,然后将和它关联的顶点全部放入队列中,并且用访问数组保存,下一次遍历就只遍历队列中的顶点。
步骤:
- 将BFS的起始遍历顶点放入队列中,并且标记该顶点已经被遍历过了
- 将起始顶点的相连顶点一一放入队列中,并且标记已经被遍历过了
- 一直循环上述步骤,知道所有顶点被遍历了
- 如果有孤岛顶点,可以再重新遍历一遍标记数组,看哪些顶点未被遍历
因此其代码实现很简单。
//从v开始进行广度遍历
void BFS(const V& v)
{
int srci = GetIndex(v);
queue<int> q;
vector<bool> is_visited(_vertexs.size(), false);
q.push(srci);
is_visited[srci] = true;
int levelsize = q.size();
int n = _vertexs.size();
//然后遍历
while (!q.empty())
{
//每次只在这一层遍历
for (int i = 0; i < levelsize; i++)
{
//这是这一层内的顶点
int front = q.front();
q.pop();
cout << front << ": " <<_vertexs[front]<< "->";
//此时就需要找到和这一个顶点相连的所有顶点
for (int j = 0; j < n; j++)
{
//为 MAX_W 表示两个顶点没边
if (_matrix[front][j] != MAX_W)
{
if (is_visited[j] == false)
{
cout << _vertexs[j] << "->";
q.push(j);
is_visited[j] = true;
}
}
}
cout <<"nullptr"<< endl;
}
}
//防止孤岛问题
for (int i = 0; i < is_visited.size(); i++)
{
//此时就是孤岛问题
if (is_visited[i] == false)
{
BFS(_vertexs[i]);
}
}
}
深度优先遍历
深度优先遍历也很简单,类比树的深度优先遍历,主要方案采用递归,主要步骤和广度优先遍历类似。
步骤:
- 将起始顶点标记一下,然后去遍历寻找和起始顶点关联的顶点,找到一个就去递归并标记。
- 重复上面步骤,直到所有顶点遍历完
- 可能会有孤岛问题,因此需要在最后遍历一遍标记数组
void _DFS(int srci, vector<bool>& is_visited)
{
is_visited[srci] = true;
cout << srci << ": " << _vertexs[srci] << "->" << endl;
int n = _vertexs.size();
for (int i = 0; i < n; i++)
{
if (_matrix[srci][i] != MAX_W && !is_visited[i])
{
_DFS(i, is_visited);
}
}
}
//从v开始进行深度遍历
void DFS(const V& v)
{
int srci = GetIndex(v);
vector<bool> is_visited(_vertexs.size(), false);
_DFS(srci, is_visited);
//处理孤岛问题
for (int i = 0; i < is_visited.size(); i++)
{
if (!is_visited[i])
{
_DFS(i, is_visited);
}
}
}
以上就是图的搜索,其实步骤和二叉树的大差不差,不过由于孤岛问题和成环问题,需要用队列和标记数组辅助进行。
最小生成树
每一颗生成树,都是原连通图中的一个最大的无环子图,即删去一个边就不连通,引入一个新边就构成回路。
其有三个准则:
- 只能用图中的边构成最小生成树
- 只能用 n-1 条边连接 n 个顶点
- 选用的 n-1 条边不能构成回路
构成最小生产树的算法有两种:Kruskal 算法 和 Prim 算法,二者都用了逐步的贪心算法。
注意,最小生成树一般都是在无向图中使用的。
Kruskal算法
步骤:
- 首先构造一个由n个顶点组成的图,该图不含任何边
- 从原图中选取权值最小的边(有多条边则任意选其一),如果边的顶点来自不同的连通分量,则将此边加入到图中
- 每次添加边的时候需要通过并查集判断是否会构成环,添加后也需要将两边的顶点加入到并查集中
- 重复上述操作最后生成最小生成树
而从最小生成树的概念中有一个很重要的难题----如何不构成回路?
其实很简单,采用并查集即可,如果构成回路了,那么两个顶点一定有相同的连通分量,换句话说,即在并查集中有相同的主节点。
假设在 B->A->C->D 这条路径中,其在并查集中一定是都存在的,如果又添加 B->D 这条边,我们可以发现,B和D一定都有相同的主节点,这就构成了回路,因此我们可以在每次添加边的时候,都可以将其在并查集中进行 union 操作,这样就能够保证最小生成树中没有环存在。
下面给出代码:
//返回生成最小生成树的权值
//该算法的思路是根据边的权值,每次取最小的权值
//如果该权值成环就抛弃,否则就添加
//是否成环可以采用并查集
W Kruskal(Self& minTree)
{
//初始化最小生成树
int n = _vertexs.size();
minTree._vertexs = _vertexs;
minTree._indexMap = _indexMap;
minTree._matrix.resize(n);
for (int i = 0; i < n; i++)
{
minTree._matrix[i].resize(n, MAX_W);
}
//创建优先级队列,存储所有边
priority_queue<Edge, vector<Edge>, greater<Edge>> pq;
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n; j++)
{
if (i < j && _matrix[i][j] != MAX_W)
{
Edge eg(i, j, _matrix[i][j]);
pq.push(eg);
}
}
}
//创建并查集,要记住,最小生成树最多 n - 1 条边,size就是记录这条边是否只有 n-1条边
DSU dsu(n);
W total = W();
int size = 0;
while (!pq.empty())
{
Edge eg = pq.top();
pq.pop();
if (!dsu.InSet(eg._srci, eg._dsti))
{
minTree._AddEdge(eg._srci, eg._dsti, eg._w);
dsu.Union(eg._srci, eg._dsti);
size++;
total += eg._w;
}
else {
cout << "成环" << endl;
}
}
if (size == n - 1)
{
return total;
}
else {
return W();
}
}
Prim算法
Prim 算法的步骤和后续的最短路径算法的 Dijkstra 算法类似,可以先了解一下。
Prim算法步骤
- 首先构造一个由n个顶点组成的图,该图不含任何边
- 创建两个集合,一个表示在最小生成树的顶点集合X,一个表示不在最小生成树的顶点集合Y
- 将起始顶点放入X,并且获取它的所有边
- 从边的集合中选权值最小的边,然后获取的终点位置,将终点顶点放入X,并且获取它所有边
- 每次选取边的时候需要判断终点顶点是否在X中,在就会成环,不在才能添加
- 重复上面的操作,最终得出最小生成树
实际上Prim算法可以看成是一颗树成长的过程,起始顶点是一颗种子,每次都去选择和这个种子相关的边,然后获取到另一个种子,然后从另一个种子中选择相关的边,最后称为最小生成树。
W Prim(Self& minTree,const V& src)
{
int n = _vertexs.size();
minTree._vertexs = _vertexs;
minTree._indexMap = _indexMap;
minTree._matrix.resize(n);
for (int i = 0; i < n; i++)
{
minTree._matrix[i].resize(n, MAX_W);
}
int srci = GetIndex(src);
//两个集合,一个是已经在最小生成树中了,一个不在最小生成树
vector<bool> X(n, false);
vector<bool> Y(n, true);
//起始的顶点已经在最小生成树中了
X[srci] = true;
Y[srci] = false;
//然后将 src 相连的边放入优先级队列中
priority_queue<Edge, vector<Edge>, greater<Edge>> minq;
for (int i = 0; i < n; i++)
{
if (_matrix[srci][i] != MAX_W)
{
minq.push(Edge(srci, i, _matrix[srci][i]));
}
}
//记录最小生成树的大小是否是 n-1,以及总权值
size_t size = 0;
W totalW = W();
while (!minq.empty())
{
Edge eg = minq.top();
minq.pop();
//如果队列中的边在最小生成树的集合中,那这条边就会成环
if (X[eg._dsti])
{
cout << "成环 : " << eg._dsti << endl;
}
else {
//此时就是正常的最小生成树了
/* cout << eg._srci << " " << eg._dsti << endl;*/
minTree._AddEdge(eg._srci, eg._dsti, eg._w);
X[eg._dsti] = true;
Y[eg._dsti] = false;
size++;
totalW += eg._w;
if (size == n - 1)
{
break;
}
//每有一个顶点加入到生成树,就需要将这个顶点相连的边放入队列中
for (int j = 0; j < n; j++)
{
//和这个顶点相连的边并且不在最小生成树之中,即可加入到队列中
if (_matrix[eg._dsti][j] != MAX_W && Y[j])
{
minq.push(Edge(eg._dsti, j, _matrix[eg._dsti][j]));
}
}
}
}
if (size == n - 1)
{
return totalW;
}
else {
return W();
}
}
最短路径算法
最短路径概念
短路径问题:从在带权有向图G中的某一顶点出发,找出一条通往另一顶点的最短路径,最短也就是沿路径各边的权值总和达到最小。
单源最短路径算法---Dijkstra算法
Dijkstra 算法思路:
首先设置两个集合:S表示在最短路径的顶点集合,Q表示不在最短路径的顶点集合,初始时,将起始顶点 s 放入S中,然后从Q 中获取 s 的起始边,这个起始边 一定是 s 的所有边中,相连代价最小的,然后将这个边的终止顶点 u 放入 S 中,并且去找 u 的所有邻接顶点 v,并且进行松弛操作。
松弛操作: 即判断 s-> v 的路径长度是否比 s->u->v 的路径长度大,如果大,就说明 s->u->v 才是最短路径,需要更新。
Dijkstra 算法是很高效的一种算法,但是它有一个缺点:不支持图中带负权的图。
原因在于它每次去找边,只会取权值最小的边,大的边虽然会记录,但是不会从权值大的边来找最短路径,而有可能大的边的最短路径中带有负数,总体的权值比其他的小,但是 Dijkstra 算法无法考虑这种状况,因此不支持带负权的图
该算法虽然不支持带负权的图,不过时间复杂度是 O(N^2) ,是比较高效的算法。
接下来我们直接看代码。
//dist 记录从 v 顶点到其他顶点的距离
//pPath 则记录每个顶点最短路径中的上一个顶点
void Dijkstra(const V& v, vector<W>& dist, vector<int>& pPath)
{
size_t srci = GetIndex(v);
size_t n = _vertexs.size();
//初始化 dist 和 pPath
dist.resize(n, MAX_W);
pPath.resize(n, -1);
//设置起始顶点的距离为0,以及起始顶点的父节点为自己,这样方便后续起始操作
dist[srci] = 0;
pPath[srci] = srci;
//记录已经有最短路径的顶点
vector<bool> S(n, false);
for (int i = 0; i < n; i++)
{
//记录顶点,以及该顶点的最小权值
int u = 0;
W min = MAX_W;
for (int j = 0; j < n; j++)
{
//如果找到的顶点没有最短路径,并且有相关联的边,就存储下来,即找到最短路径的起始顶点
//这里也是 Dijkstra 算法的贪心策略,即找到最短路径中
if (S[j] == false && dist[j] < min)
{
u = j;
min = dist[j];
}
}
S[u] = true;
//此时已经找到了最短路径的下一个顶点了,就去找这个顶点相连的顶点,进行松弛操作
//松弛操作: srci -> u -> v,即判断 srci->v 的路径长度和 srci->u + u->v 的路径长度的大小,找到最短的那个路径
for (size_t v = 0; v < n; v++)
{
// dist[u] + _matrix[u][v] 表示 srci->u 到 u->v 的路径长度, dist[v] 表示 srci->v 的长度
if (S[v] == false && _matrix[u][v] != MAX_W && dist[u] + _matrix[u][v] < dist[v])
{
dist[v] = dist[u] + _matrix[u][v];
pPath[v] = u;
}
}
}
}
这里的 pPath 其实类似并查集的查询方式,对应下标存储的是最短路径中,这个顶点的上一级顶点的下标,这样我们想要知道一条最短路径经历了哪些顶点,可以通过这个数组快速的找到
这一块是 Dijkstra 算法的贪心策略,即每次只在集合 Q 中找顶点 u,S中的顶点是已经有最短路径的顶点了,因此不用管。
而第一次寻找可以视作是 起始顶点 v 到自身的距离,我们一般设为0,也是为了能够启动这个算法而初始化的。
而找到一个最小权值的边的顶点后,就将这个顶点放入S,并且找这个顶点的所有相连顶点,进行松弛操作。
如果是初始顶点,也可以认为是从初始顶点开始去延伸找它的邻接顶点。
最后就能找到一条最短路径了。
单源最短路径算法---Bellman-ford算法
Dijkstra算法是比较高效,但是它无法解决负权的图的问题。
而Bellman-ford 能够解决这个问题。
Bellman-ford 算法思路:
该算法通过暴力求解,即初始化完后,将所有顶点都进行松弛操作,并且重复进行N次。
如果我们是邻接矩阵的存储方式这个算法的时间复杂度就到了 O(N^3) ,而如果是采用的邻接表,则是 O(N*E)次,E表示边的个数。
接下来我们直接看代码,然后解释为什么需要重复进行N次松弛操作才可以。
//该算法通过暴力算法进行求解最短路径算法
bool BellmanFord(const V& v, vector<int>& dist, vector<int>& pPath)
{
size_t srci = GetIndex(v);
size_t n = _vertexs.size();
//初始化 dist 和 pPath
dist.resize(n, MAX_W);
pPath.resize(n, -1);
//设置起始顶点的距离为0,以及起始顶点的父节点为自己,这样方便后续起始操作
dist[srci] = W();
for (int k = 0; k < n; k++)
{
//去遍历每个顶点,如果两个顶点之间有边,就去进行松弛操作
//即 srci->j 和 srci->i->j 的距离哪个小
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n; j++)
{
//但是每次更新最短路径都有可能会影响其他路径,因此这个操作需要进行n次
if (_matrix[i][j] != MAX_W && dist[i] + _matrix[i][j] < dist[j])
{
dist[j] = dist[i] + _matrix[i][j];
pPath[j] = i;
}
}
}
}
//判断负权回路
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n; j++)
{
if (_matrix[i][j] != MAX_W && dist[i] + _matrix[i][j] < dist[j])
{
return false;
}
}
}
return true;
}
然后我们创建一张图,来看看这张图的正常的最短路径是多少。
void TestGraphBellmanFord()
{
const char* str = "syztx";
matrix::Graph<char, int, INT_MAX, true> g(str, strlen(str));
g.AddEdge('s', 't', 6);
g.AddEdge('s', 'y', 7);
g.AddEdge('y', 'z', 9);
g.AddEdge('y', 'x', -3);
//g.AddEdge('y', 's', 1); // 新增
g.AddEdge('z', 's', 2);
g.AddEdge('z', 'x', 7);
g.AddEdge('t', 'x', 5);
//g.AddEdge('t', 'y', -8); //更改
g.AddEdge('t', 'y', 8);
g.AddEdge('t', 'z', -4);
g.AddEdge('x', 't', -2);
vector<int> dist;
vector<int> parentPath;
if (g.BellmanFord('s', dist, parentPath))
g.PrintShortPath('s', dist, parentPath);
else
cout << "带负权回路" << endl;
}
可以看到正常的最短路径,确实是求出来了。
而如果我们屏蔽了第一层for循环,然后来看最短路径,就会发现有错误。
其他路径虽然没问题,但是 s->y->x->t->z 这条并不是最短路径,最短路径应该是 -2.
那为什么会导致这样呢?我们可以在 Bellman 算法中加个打印。
根据打印我们发现最短路径的计算过程是这样的。
从下面的动图我们可以发现,先是找到 s->t-z 这条权值为 2 的路径,然后发现 s->y->x->t 为2 的这条最短路径,但是没有去更新s->y->x->t->z 这条最短路径去。
虽然打印的时候找到了 s->y->x->t->z 这条最短路径,但是权值却没有计算出来,我们可以根据动图发现,z 的路径的上一个顶点本来就是 t,而在真正的最短路径中,z 的上一个顶点也是 t,所有打印出来的时候就正好是最短路径,但实际上,s->y->x->t 这条最短路径虽然找到了,但是 z 的路径依旧是 s->t->z,并不是 s->y->x->t->z 这条最短路径。
说了这么多,其实想说的就一句话,Bellman-ford 算法如果不进行 n 次最短路径查找,就会出现找到了最终的最短路径,但是影响了其他路径的情况。
负权回路
Bellman-ford 算法还有一个缺点,就是无法求出带有负权回路的图的最短路径。
因为本身该算法就是一个暴力求解算法,而如果图带有负权回路,那么每次去查找一个最短路径时,都会找到一个更短的路径,因为每遍历一次,权值都比上一次小,这是无法解决的。
就拿上面的例子来说,我们修改一下数据。
我们新增一条边,更改一条边。
图就变成了这样,其中标红的就是负权回路。
我们发现,这条负权回路每遍历一次,权值就会变小。
第一次:s->y->x->t = -5
第二次:s->y->x->t->y->x->t = -18
第三次: s->y->x->t->y->x->t->y->x->t = -21
.....
这样就没完没了了,无法找到最短路径,因为每次都是有更短路径出现,因此如果出现负权回路时,需要判断一下是否构成负权回路。
多源最短路径算法 ---Floyd-Shall算法
该算法通过动态规划的方式来计算任意两个顶点之间的最短路径。
Dijk 表示从 顶点 i 到顶点 j 这条路径中,经过包括 (l....k) 这个集合的0个或多个顶点的最短路径的长度。
如果最短路径经过 k 顶点,Dijk = Dik(k-1) + Dkj(k-1);
如果不经过则 Dijk = Dij(k-1) 。
这就是这个算法的状态转移方程。
接下来直接看代码。
void FloydWarshall(vector<vector<W>>& vvDist, vector<vector<int>>& vvpPath)
{
int n = _vertexs.size();
vvDist.resize(n);
vvpPaht.resize(n);
for (int i = 0; i < n; i++)
{
vvDist[i].resize(n, MAX_W);
vvpPath[i].resize(n, -1);
}
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n; j++)
{
if (_matrix[i][j] != MAX_W)
{
vvDist[i][j] = _matrix[i][j];
vvpPath[i][j] = i;
}
else {
vvpPath[i][j] = -1;
}
if (i == j)
{
vvDist[i][j] = W();
vvpPath[i][j] = -1;
}
}
}
for (int k = 0; k < n; k++)
{
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n; j++)
{
if (_matrix[i][k] != MAX_W && _matrix[k][j] != MAX_W
&& _matrix[k][j] + vvDist[i][k] < vvDist[i][j])
{
vvDist[i][j] = _matrix[k][j] + vvDist[i][k];
vvpPath[i][j] = vvpPath[k][j];
}
}
}
}
}
由于计算的是任意两个顶点之间的最短路径的长度,因此 vvDist 和 vvpPath 都是二维结构。
vvDist 的第 i 行表示其他顶点距离下标为 i 的顶点的最短路径长度。
vvpPath 的第 i 行表示以 i 顶点为起始位置,第 j 列表示以 j 顶点为终止位置,存储的值表示以 i 顶点为起始,j 顶点为终止的最短路径,j 顶点的上一个顶点下标。
这里是对两个矩阵进行初始化。
这里 k 是 i 到 j 这条路径的中间节点,如果 i 到 k 到 j 有一条路径时,就进行一个松弛操作,而如果松弛操作成功了,就修改最短路径长度,同时设置上一个顶点是哪个。
有一个问题是 为什么 设置 vvpPath[i][j] = vvpPath[k][j] 。
实际上这是因为可能经过 k 这个顶点了,但是可能 k 并不是最后一个顶点。
可能在 i->k->j 这条路径中,实际上是 i->k->z->j ,此时 j 的上一个顶点应该是 z ,因此需要这样设置。
总结
图作为一个数据结构,是比较复杂的一类。包含顶点集合和边的集合,又分为有向图和无向图两类,图的存储方式又分为邻接矩阵和邻接表。
而图有成环问题,因此图的遍历需要使用标记数组标记。
而最小生成树的 Kruskal 算法和 Prim 算法,为了防止成环,Kruskal 算法每次找边的时候都会将边放入并查集进行查询,而 Prim 算法用顶点集合来判断边的终点是否已经在最小生成树中。
最短路径算法有三种, Dijkstra算法虽然效率高,但是无法解决带负权的图,而Bellman算法虽然解决了负权的图,但是采用暴力求解方案,效率降低,而且遇到了负权回路无法解决。
多源最短路径算法 Floyd-Shall 算法采用动规来解决任意两个顶点的最短路径算法,不过效率也低。
最后将代码整体粘贴在这供各位学习。
#include<iostream>
#include<map>
#include<vector>
#include<queue>
#include"并查集.hpp"
using namespace std;
//邻接矩阵的图
namespace matrix {
template<class V,class W, W MAX_W = INT_MAX,bool is_direct = false>
class Graph {
typedef Graph<V, W, INT_MAX, is_direct> Self;
public:
Graph() = default;
//初始化
Graph(const V* a, size_t n)
{
_vertexs.reserve(n);
for (size_t i = 0; i < n; i++)
{
_vertexs.push_back(a[i]);
_indexMap[a[i]] = i;
}
_matrix.resize(n);
for (size_t i = 0; i < n; i++)
{
_matrix[i].resize(n, MAX_W);
}
}
//获取对应顶点的下标
size_t GetIndex(const V& v)
{
auto t = _indexMap.find(v);
if (t != _indexMap.end())
{
return t->second;
}
else {
cout << "该顶点不存在" << endl;
return -1;
}
}
void _AddEdge(const int& srci, const int& dsti, W w)
{
//并且存储到矩阵中
_matrix[srci][dsti] = w;
//如果是无向图,就需要再添加一次
if (is_direct == false)
{
_matrix[dsti][srci] = w;
}
}
//添加边的起点和终点,以及权值
void AddEdge(const V& src, const V& dst, W w)
{
//获取两个顶点对应的下标
size_t srci = GetIndex(src);
size_t dsti = GetIndex(dst);
_AddEdge(srci, dsti, w);
}
//从v开始进行广度遍历
void BFS(const V& v)
{
int srci = GetIndex(v);
queue<int> q;
vector<bool> is_visited(_vertexs.size(), false);
q.push(srci);
is_visited[srci] = true;
int levelsize = q.size();
int n = _vertexs.size();
//然后遍历
while (!q.empty())
{
//每次只在这一层遍历
for (int i = 0; i < levelsize; i++)
{
//这是这一层内的顶点
int front = q.front();
q.pop();
cout << front << ": " <<_vertexs[front]<< "->";
//此时就需要找到和这一个顶点相连的所有顶点
for (int j = 0; j < n; j++)
{
//为 MAX_W 表示两个顶点没边
if (_matrix[front][j] != MAX_W)
{
if (is_visited[j] == false)
{
cout << _vertexs[j] << "->";
q.push(j);
is_visited[j] = true;
}
}
}
cout <<"nullptr"<< endl;
}
}
//防止孤岛问题
for (int i = 0; i < is_visited.size(); i++)
{
//此时就是孤岛问题
if (is_visited[i] == false)
{
BFS(_vertexs[i]);
}
}
}
void _DFS(int srci, vector<bool>& is_visited)
{
is_visited[srci] = true;
cout << srci << ": " << _vertexs[srci] << "->" << endl;
int n = _vertexs.size();
for (int i = 0; i < n; i++)
{
if (_matrix[srci][i] != MAX_W && !is_visited[i])
{
_DFS(i, is_visited);
}
}
}
//从v开始进行深度遍历
void DFS(const V& v)
{
int srci = GetIndex(v);
vector<bool> is_visited(_vertexs.size(), false);
_DFS(srci, is_visited);
//处理孤岛问题
for (int i = 0; i < is_visited.size(); i++)
{
if (!is_visited[i])
{
_DFS(i, is_visited);
}
}
}
struct Edge {
int _srci;
int _dsti;
W _w;
Edge(const int srci, const int dsti, W w)
:_srci(srci),
_dsti(dsti),
_w(w)
{
}
//比较权值
bool operator > (const Edge& e) const
{
return _w > e._w;
}
};
//返回生成最小生成树的权值
//该算法的思路是根据边的权值,每次取最小的权值
//如果该权值成环就抛弃,否则就添加
//是否成环可以采用并查集
W Kruskal(Self& minTree)
{
//初始化最小生成树
int n = _vertexs.size();
minTree._vertexs = _vertexs;
minTree._indexMap = _indexMap;
minTree._matrix.resize(n);
for (int i = 0; i < n; i++)
{
minTree._matrix[i].resize(n, MAX_W);
}
//创建优先级队列,存储所有边
priority_queue<Edge, vector<Edge>, greater<Edge>> pq;
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n; j++)
{
if (i < j && _matrix[i][j] != MAX_W)
{
Edge eg(i, j, _matrix[i][j]);
pq.push(eg);
}
}
}
//创建并查集,要记住,最小生成树最多 n - 1 条边,size就是记录这条边是否只有 n-1条边
DSU dsu(n);
W total = W();
int size = 0;
while (!pq.empty())
{
Edge eg = pq.top();
pq.pop();
if (!dsu.InSet(eg._srci, eg._dsti))
{
minTree._AddEdge(eg._srci, eg._dsti, eg._w);
dsu.Union(eg._srci, eg._dsti);
size++;
total += eg._w;
}
else {
cout << "成环" << endl;
}
}
if (size == n - 1)
{
return total;
}
else {
return W();
}
}
W Prim(Self& minTree,const V& src)
{
int n = _vertexs.size();
minTree._vertexs = _vertexs;
minTree._indexMap = _indexMap;
minTree._matrix.resize(n);
for (int i = 0; i < n; i++)
{
minTree._matrix[i].resize(n, MAX_W);
}
int srci = GetIndex(src);
//两个集合,一个是已经在最小生成树中了,一个不在最小生成树
vector<bool> X(n, false);
vector<bool> Y(n, true);
//起始的顶点已经在最小生成树中了
X[srci] = true;
Y[srci] = false;
//然后将 src 相连的边放入优先级队列中
priority_queue<Edge, vector<Edge>, greater<Edge>> minq;
for (int i = 0; i < n; i++)
{
if (_matrix[srci][i] != MAX_W)
{
minq.push(Edge(srci, i, _matrix[srci][i]));
}
}
//记录最小生成树的大小是否是 n-1,以及总权值
size_t size = 0;
W totalW = W();
while (!minq.empty())
{
Edge eg = minq.top();
minq.pop();
//如果队列中的边在最小生成树的集合中,那这条边就会成环
if (X[eg._dsti])
{
cout << "成环 : " << eg._dsti << endl;
}
else {
//此时就是正常的最小生成树了
/* cout << eg._srci << " " << eg._dsti << endl;*/
minTree._AddEdge(eg._srci, eg._dsti, eg._w);
X[eg._dsti] = true;
Y[eg._dsti] = false;
size++;
totalW += eg._w;
if (size == n - 1)
{
break;
}
//每有一个顶点加入到生成树,就需要将这个顶点相连的边放入队列中
for (int j = 0; j < n; j++)
{
//和这个顶点相连的边并且不在最小生成树之中,即可加入到队列中
if (_matrix[eg._dsti][j] != MAX_W && Y[j])
{
minq.push(Edge(eg._dsti, j, _matrix[eg._dsti][j]));
}
}
}
}
if (size == n - 1)
{
return totalW;
}
else {
return W();
}
}
void PrintShortPath(const V& src, const vector<W>& dist, const vector<int>& pPath)
{
size_t srci = GetIndex(src);
size_t n = _vertexs.size();
for (size_t i = 0; i < n; ++i)
{
if (i != srci)
{
// 找出i顶点的路径
vector<int> path;
size_t parenti = i;
while (parenti != srci)
{
path.push_back(parenti);
parenti = pPath[parenti];
}
path.push_back(srci);
reverse(path.begin(), path.end());
for (auto index : path)
{
cout << _vertexs[index] << "->";
}
cout << dist[i] << endl;
}
}
}
//void PrintShortPath(const V& src, const vector<W>& dist, const vector<int>& pPath)
//{
// size_t srci = GetVertexIndex(src);
// size_t n = _vertexs.size();
// for (size_t i = 0; i < n; ++i)
// {
// if (i != srci)
// {
// // 找出i顶点的路径
// vector<int> path;
// size_t parenti = i;
// while (parenti != srci)
// {
// path.push_back(parenti);
// parenti = pPath[parenti];
// }
// path.push_back(srci);
// reverse(path.begin(), path.end());
// for (auto index : path)
// {
// cout << _vertexs[index] << "->";
// }
// cout << "权值和:" << dist[i] << endl;
// }
// }
//}
//dist 记录从 v 顶点到其他顶点的距离
//pPath 则记录每个顶点最短路径中的上一个顶点
void Dijkstra(const V& v, vector<W>& dist, vector<int>& pPath)
{
size_t srci = GetIndex(v);
size_t n = _vertexs.size();
//初始化 dist 和 pPath
dist.resize(n, MAX_W);
pPath.resize(n, -1);
//设置起始顶点的距离为0,以及起始顶点的父节点为自己,这样方便后续起始操作
dist[srci] = 0;
pPath[srci] = srci;
//记录已经有最短路径的顶点
vector<bool> S(n, false);
for (int i = 0; i < n; i++)
{
//记录顶点,以及该顶点的最小权值
int u = 0;
W min = MAX_W;
for (int j = 0; j < n; j++)
{
//如果找到的顶点没有最短路径,并且有相关联的边,就存储下来,即找到最短路径的起始顶点
//这里也是 Dijkstra 算法的贪心策略,即找到最短路径中
if (S[j] == false && dist[j] < min)
{
u = j;
min = dist[j];
}
}
S[u] = true;
//此时已经找到了最短路径的下一个顶点了,就去找这个顶点相连的顶点,进行松弛操作
//松弛操作: srci -> u -> v,即判断 srci->v 的路径长度和 srci->u + u->v 的路径长度的大小,找到最短的那个路径
for (size_t v = 0; v < n; v++)
{
// dist[u] + _matrix[u][v] 表示 srci->u 到 u->v 的路径长度, dist[v] 表示 srci->v 的长度
if (S[v] == false && _matrix[u][v] != MAX_W && dist[u] + _matrix[u][v] < dist[v])
{
dist[v] = dist[u] + _matrix[u][v];
pPath[v] = u;
}
}
}
}
//该算法通过暴力算法进行求解最短路径算法
bool BellmanFord(const V& v, vector<int>& dist, vector<int>& pPath)
{
size_t srci = GetIndex(v);
size_t n = _vertexs.size();
//初始化 dist 和 pPath
dist.resize(n, MAX_W);
pPath.resize(n, -1);
//设置起始顶点的距离为0,以及起始顶点的父节点为自己,这样方便后续起始操作
dist[srci] = W();
for (int k = 0; k < n; k++)
{
//去遍历每个顶点,如果两个顶点之间有边,就去进行松弛操作
//即 srci->j 和 srci->i->j 的距离哪个小
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n; j++)
{
//但是每次更新最短路径都有可能会影响其他路径,因此这个操作需要进行n次
if (_matrix[i][j] != MAX_W && dist[i] + _matrix[i][j] < dist[j])
{
cout << _vertexs[i] << "->" << _vertexs[j] << ":" << _matrix[i][j] << endl;
dist[j] = dist[i] + _matrix[i][j];
pPath[j] = i;
}
}
}
cout << endl;
}
//判断负权回路
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n; j++)
{
if (_matrix[i][j] != MAX_W && dist[i] + _matrix[i][j] < dist[j])
{
return false;
}
}
}
return true;
}
void FloydWarshall(vector<vector<W>>& vvDist, vector<vector<int>>& vvpPath)
{
int n = _vertexs.size();
vvDist.resize(n);
vvpPaht.resize(n);
for (int i = 0; i < n; i++)
{
vvDist[i].resize(n, MAX_W);
vvpPath[i].resize(n, -1);
}
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n; j++)
{
if (_matrix[i][j] != MAX_W)
{
vvDist[i][j] = _matrix[i][j];
vvpPath[i][j] = i;
}
else {
vvpPath[i][j] = -1;
}
if (i == j)
{
vvDist[i][j] = W();
vvpPath[i][j] = -1;
}
}
}
for (int k = 0; k < n; k++)
{
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n; j++)
{
if (_matrix[i][k] != MAX_W && _matrix[k][j] != MAX_W
&& _matrix[k][j] + vvDist[i][k] < vvDist[i][j])
{
vvDist[i][j] = _matrix[k][j] + vvDist[i][k];
vvpPath[i][j] = vvpPath[k][j];
}
}
}
}
}
void Print()
{
for (int i = 0; i < _vertexs.size(); i++)
{
cout << "vertexs[" << i << "]->" << _vertexs[i] << endl;
}
for (int i = 0; i < _matrix.size(); i++)
{
for (int j = 0; j < _matrix[i].size(); j++)
{
if (_matrix[i][j] != MAX_W)
{
cout << _matrix[i][j] << " ";
}
else {
cout << "* ";
}
}
cout << endl;
}
}
private:
//一个存储顶点的数组
vector<V> _vertexs;
//存储顶点对应的下标
map<V, int> _indexMap;
//邻接矩阵,下标对应顶点
vector<vector<W>> _matrix;
};
};
//邻接表的图
namespace link_table {
template<class W>
struct Edge {
int _dsti;
W _w;
Edge<W>* next;
Edge(const int dsti, const W w)
:_dsti(dsti),
_w(w),
next(nullptr)
{
}
};
template<class V, class W, bool is_direct = false>
class Graph {
public:
typedef Edge<W> Edge;
//初始化
Graph(const V* a, size_t n)
{
_vertexs.reserve(n);
for (int i = 0; i < n; i++)
{
_vertexs.push_back(a[i]);
_indexMap[a[i]] = i;
}
_link_table.resize(n,nullptr);
}
//获取对应顶点的下标
size_t GetIndex(const V& v)
{
auto t = _indexMap.find(v);
if (t != _indexMap.end())
{
return t->second;
}
else {
cout << "该顶点不存在" << endl;
return -1;
}
}
//添加边的起点和终点,以及权值
void AddEdge(const V& src, const V& dst, W w)
{
//获取两个顶点对应的下标
size_t srci = GetIndex(src);
size_t dsti = GetIndex(dst);
//并且头插到表中
Edge* eg = new Edge(dsti, w);
eg->next = _link_table[srci];
_link_table[srci] = eg;
//如果是无向图,就需要再添加一次
if (is_direct == false)
{
Edge* eg = new Edge(srci, w);
eg->next = _link_table[dsti];
_link_table[dsti] = eg;
}
}
void Print()
{
for (int i = 0; i < _vertexs.size(); i++)
{
cout << "vertexs[" << i << "]->" << _vertexs[i] << endl;
}
for (int i = 0; i < _link_table.size(); i++)
{
Edge* cur = _link_table[i];
cout << i << "->";
while (cur)
{
cout <<"[" << cur->_dsti << ": " << cur->_w <<"]" << "->";
cur = cur->next;
}
cout <<"nullptr" << endl;
}
}
private:
//一个存储顶点的数组
vector<V> _vertexs;
//存储顶点对应的下标
map<V, int> _indexMap;
//邻接矩阵,下标对应顶点
vector<Edge*> _link_table;
};
};
void TestGraph1()
{
matrix::Graph<char, int, INT_MAX, false> 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.Print();
//g.BFS('0');
//g.DFS('0');
matrix::Graph<char, int, INT_MAX, false> t;
g.Kruskal(t);
t.Print();
matrix::Graph<char, int, INT_MAX, false> p;
g.Prim(p, '0');
p.Print();
}
void TestGraph2()
{
link_table::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();
}
void TestGraphBellmanFord()
{
const char* str = "syztx";
matrix::Graph<char, int, INT_MAX, true> g(str, strlen(str));
g.AddEdge('s', 't', 6);
g.AddEdge('s', 'y', 7);
g.AddEdge('y', 'z', 9);
g.AddEdge('y', 'x', -3);
g.AddEdge('y', 's', 1); // 新增
g.AddEdge('z', 's', 2);
g.AddEdge('z', 'x', 7);
g.AddEdge('t', 'x', 5);
g.AddEdge('t', 'y', -8); //更改
//g.AddEdge('t', 'y', 8);
g.AddEdge('t', 'z', -4);
g.AddEdge('x', 't', -2);
vector<int> dist;
vector<int> parentPath;
if (g.BellmanFord('s', dist, parentPath))
g.PrintShortPath('s', dist, parentPath);
else
cout << "带负权回路" << endl;
}
int main()
{
TestGraphBellmanFord();
return 0;
}
以上就是本篇博客的总结,相信能够为各位的学习提供帮助。