上一篇:【高阶数据结构】图 {图的基本概念;图的存储结构:邻接矩阵,邻接表;图的遍历:BFS,DFS}
四、最小生成树
无向连通图中的每一棵生成树,都是原图的一个极大无环子图,即:从其中删去任何一条边,生成树就不在连通;反之,在其中引入任何一条新边,都会形成一条回路。也就是说生成树就是要以最少的边将所有的顶点都连通起来。
最小生成树(Minimum Spanning Tree)其实是最小权重生成树的简称,即边权和最小的生成树。
若连通图由n个顶点组成,则其生成树必含n个顶点和n-1条边。因此构造最小生成树的准则有三条:
- 只能使用图中权值最小的边来构造最小生成树
- 只能使用恰好n-1条边来连接图中的n个顶点
- 选用的n-1条边不能构成回路
构造最小生成树的方法:Kruskal算法和Prim算法。这两个算法都采用了逐步求解的贪心策略。
贪心算法:是指在问题求解时,总是做出当前看起来最好的选择。也就是说贪心算法做出的不是整体最优的的选择,而是某种意义上的局部最优解。贪心算法不是对所有的问题都能得到整体最优解。
4.1 Kruskal算法
Kruskal算法是一种用于构建最小生成树的贪心算法。它通过逐步选择图中边的方式来构建最小生成树,直到包含了所有的顶点。具体步骤如下:
- 初始化一个空的最小生成树:一个由n个顶点组成、不含任何边的图。利用并查集控制最小生成树中的连通分量,初始时每个顶点自成一个连通分量。
- 将原图中的所有边按照权重建立一个小根堆(优先级队列)。
- 依次取出权值最小的一条边,如果边连接的两个顶点不在同一个连通分量中(即不会产生环),则将这条边加入到最小生成树中,同时将连接的两个顶点合并为一个连通分量。
- 重复步骤3,直到最小生成树中包含了n-1条边或者遍历完所有的边(优先级队列为空)。
Kruskal算法的时间复杂度通常为O(ElogE),其中E是边的数量。这是因为需要使用优先级队列对边进行筛选。
代码实现:
struct Edge
{
size_t _srci;
size_t _dsti;
W _w;
Edge(size_t srci, size_t dsti, W w)
: _srci(srci),
_dsti(dsti),
_w(w)
{
}
bool operator>(const Edge &e) const
{
return _w > e._w;
}
};
W Kruskal(Graph &mintree)
{
size_t n = _vertexs.size();
// 初始化最小生成树
mintree._vertexs = _vertexs; //n个顶点
mintree._indexmap = _indexmap;
mintree._matrix.resize(n);
for (vector<W> &e : mintree._matrix)
{
e.resize(n, W_MAX); //不含任何边
}
// 将原图的所有边添加到优先级队列(小根堆)
priority_queue<Edge, vector<Edge>, greater<Edge>> minque;
for (size_t i = 0; i < n; ++i)
{
for (size_t j = 0; j < i; ++j)
{
if (_matrix[i][j] != W_MAX)
{
minque.emplace(i, j, _matrix[i][j]);
}
}
}
// 为最小生成树添加边
UnionFindSet ufs(n); // 构造一个并查集,用于判断添加的边是否能构成回路
size_t edgeCount = 0; // 边数统计
W totalW = W(); // 最小生成树所有边的权值总和
while (edgeCount < n - 1 && !minque.empty())
{
Edge top = minque.top();
minque.pop();
// 如果不在一个连通分量当中,表示添加该边后不会构成回路
if (!ufs.InSet(top._srci, top._dsti))
{
mintree._AddEdge(top._srci, top._dsti, top._w);
ufs.Union(top._srci, top._dsti); //两个顶点合并为一个连通分量
++edgeCount;
totalW += top._w;
// cout << _vertexs[top._srci] << "->" << _vertexs[top._dsti] << ":" << top._w << endl;
}
// else
// {
// cout << "构成回路:" << _vertexs[top._srci] << "->" << _vertexs[top._dsti] << ":" << top._w << endl;
// }
}
if (edgeCount == n - 1)
{
return totalW;
}
else
{
return W(); // 如果不是连通图,没有最小生成树,返回默认值
}
}
4.2 Prim算法
Prim算法是一种解决最小生成树的算法,它的步骤如下:
- 选择一个起始顶点作为生成树的起点。
- 将与起始顶点相连的边加入候选边集合中。
- 从候选边集合中选择权重最小的边,并将其加入生成树中。
- 将新加入的顶点的相连边加入候选边集合中。
- 重复步骤3和步骤4,直到生成树包含了所有的顶点或者遍历完所有的边(优先级队列为空)。
prim算法和kruskal算法的区别
Prim算法和Kruskal算法都是用于解决最小生成树问题的算法,它们的区别在于它们的实现方式和适用场景。
- 实现方式:
- Prim算法是一种基于点的贪心算法,它从一个初始节点开始,每次选择与当前生成树距离最近的节点加入生成树中,直到所有节点都被包含在生成树中。
- Kruskal算法是一种基于边的贪心算法,它首先将所有边按照权值排序,然后依次加入生成树中,保证加入的边不会形成环,直到n-1条边都被包含在生成树中。
- 适用场景:
- Prim算法更适合于稠密图,因为它每次需要选择节点,对于稠密图而言效率较高,其时间复杂度为O(n²)。
- Kruskal算法更适合于稀疏图,因为它每次需要选择边,并且在排序后直接加入生成树中,对于稀疏图而言效率较高,其时间复杂度为O(eloge),其中e是图中边的数量。
代码实现:
W Prim(Graph &mintree, const V &start)
{
size_t n = _vertexs.size();
size_t starti = GetIndex(start);
// 初始化最小生成树
mintree._vertexs = _vertexs;
mintree._indexmap = _indexmap;
mintree._matrix.resize(n);
for (vector<W> &e : mintree._matrix)
{
e.resize(n, W_MAX);
}
// 将与起点相连的边添加到优先级队列
priority_queue<Edge, vector<Edge>, greater<Edge>> que;
for (size_t i = 0; i < n; ++i)
{
if (_matrix[starti][i] != W_MAX)
{
que.emplace(starti, i, _matrix[starti][i]);
}
}
vector<bool> inset(_vertexs.size(), false); // 标记集合中的顶点,防止成环
inset[starti] = true;
size_t vertexCount = 1; // 统计集合中顶点的个数
W totalW = W(); // 最小生成树所有边的权值总和
// 为最小生成树添加边
while (vertexCount < n && !que.empty())
{
Edge top = que.top();
que.pop();
// 判断添加的边是否成环
if (!inset[top._dsti])
{
mintree._AddEdge(top._srci, top._dsti, top._w);
++vertexCount; //根据Prim算法的特点每添加一条边就会新增一个顶点
totalW += top._w;
inset[top._dsti] = true;
// cout << _vertexs[top._srci] << "->" << _vertexs[top._dsti] << ":" << top._w << endl;
// 将与新顶点相连的边添加到优先级队列
for (size_t i = 0; i < n; ++i)
{
if (_matrix[top._dsti][i] != W_MAX && !inset[i])
{
que.emplace(top._dsti, i, _matrix[top._dsti][i]);
}
}
}
// else
// {
// cout << "构成回路:" << _vertexs[top._srci] << "->" << _vertexs[top._dsti] << ":" << top._w << endl;
// }
}
if (vertexCount == n)
{
return totalW;
}
else
{
return W(); // 如果不是连通图,没有最小生成树,返回默认值
}
}
五、最短路径
最短路径问题:在带权有向图中,从某一顶点出发,找出一条通往另一顶点的最短路径,最短也就是沿路径各边的权值总和达到最小。
5.1 Dijkstra算法—单源最短路径
Dijkstra算法是一种用来寻找图中单个节点到其他所有节点最短路径的算法。它不能用于处理有负权边的图。
以下是Dijkstra算法的基本步骤:
-
初始化:设置一个集合S来记录已经找到最短路径的节点,设置一个集合Q来记录还未找到最短路径的节点。初始时,S为空,Q包含所有节点。同时,为每个节点维护一个距离标量d(i),表示当前节点到源节点的距离;为每个节点维护一个路径标量p(i),表示当前节点在路径中的前驱节点下标。
-
选择距离最小的节点:从Q中选择一个节点u,使得d(u)最小。将u从Q中移出,并放入S中。
-
松弛操作:更新Q中所有u的邻接顶点的d值。对于每个节点v,如果通过u到达v的路径长度小于当前d(v),则更新d(v),p(v)。
-
重复步骤2和3,直到所有节点都在S中,即所有节点都已经查找过一遍并确定了最短路径。
注意:至于一些起点到达不了的结点在算法循环后其代价仍为初始设定的值,不发生变化。Dijkstra算法每次都是选择Q中最小的路径节点来进行更新,并加入S中,所以该算法使用的是贪心策略。
Dijkstra算法的执行过程如下:
源结点s为最左边的结点。每个结点中的数值为该结点的最短路径的估计值。加了阴影的边表明前驱值。黑色的结点属于集合S,白色的结点属于集合Q。
为什么步骤2可以直接确定节点u的最短路径?
答:因为d(u)最小,如果通过其他节点到达u,距离一定大于d(u)。
为什么Dijkstra算法不支持图中带负权路径?
答:如果带有负权路径,步骤2选择距离最小的节点,就不能确定该节点的最短路径。因为虽然d(u)最小,但如果通过其他节点的路径为负权,则距离可能小于d(u)。最终可能会导致找不到一些节点的最短路径。
贪心策略的体现:
- 每次从未访问的顶点中选择距离起点最短的顶点作为中转点。从该中转点更新出的路径距离可能会更小(局部最优)。
- 使用该中转点更新其他未访问顶点的距离,如果通过该中转点可以获得更短的路径,则更新距离。更新后的路径可能不是最短路径,只是局部最优解。
Dijkstra算法的复杂度
- 时间复杂度:O(V^2)(V是顶点数)
- 空间复杂度:O(V)
代码实现:
void Dijkstra(const V &start, vector<W> &dist, vector<int> &pPath)
{
size_t n = _vertexs.size();
size_t starti = GetIndex(start);
// 初始化dist和pPath数组
dist.resize(n, W_MAX);
pPath.resize(n, -1);
dist[starti] = 0;
pPath[starti] = starti;
vector<bool> S(n, false); // 标记已经确定最短路径的节点
size_t vertexCount = 0;
while (vertexCount < n)
{
// 选择距离最小的节点,确定最短路径
size_t u = 0;
W min = W_MAX;
for (size_t i = 0; i < n; ++i)
{
if (!S[i] && dist[i] < min)
{
u = i;
min = dist[i];
}
}
S[u] = true;
++vertexCount;
// 松弛更新,以u为中转点更新其他未确定节点的距离
for (size_t v = 0; v < n; ++v)
{
if (_matrix[u][v] != W_MAX && !S[v])
{
// 如果通过该中转点可以获得更短的路径,则更新距离
if (dist[u] + _matrix[u][v] < dist[v])
{
dist[v] = dist[u] + _matrix[u][v];
pPath[v] = u;
}
}
}
}
}
void PrintShortPath(const V &start, vector<W> &dist, vector<int> &pPath)
{
size_t n = _vertexs.size();
int starti = GetIndex(start);
for (int i = 0; i < n; ++i)
{
if (i != starti)
{
vector<int> rPath;
rPath.push_back(i);
int parent = pPath[i];
while (parent != starti)
{
rPath.push_back(parent);
parent = pPath[parent];
}
rPath.push_back(starti);
reverse(rPath.begin(), rPath.end());
for (auto e : rPath)
{
cout << "[" << e << "]" << _vertexs[e] << "->";
}
cout << "end length:" << dist[i] << endl;
}
}
}
};
5.2 Bellman-Ford算法—单源最短路径
Bellman-Ford算法是一种用于找到图中单源最短路径的算法,它可以处理负权重的边,而且可以用来判断是否有负权回路。其步骤如下:
- 初始化距离数组,将起始点的距离设为0,其他点的距离设为无穷大。
- 依次遍历所有边,如果存在一条边的起点距离加上权重小于终点距离,则更新终点距离为起点距离加上权重。
- 重复以上步骤V-1次(V为顶点数),确保最短路径的正确性。
- 遍历所有边,如果仍然存在某条边的起点距离加上权重小于终点距离,则图中存在负权重环。
为什么要重复V-1次?
重复V-1次遍历是为了确保算法的正确性。在一幅有向图中,最短路径的长度最多包含(V-1)条边(因为最短路径不能包含环路,最多经过全部的顶点减去起点),所以进行(V-1)次的松弛操作可以确保所有的最短路径被找到。
如果不重复进行(V-1)次遍历,而是进行更少的次数,可能导致一些最短路径没有被正确计算出来:
-
可能导致最短路径的总权值计算错误
解释一下:
- 上图中遍历边x->t时,将s->t更新得到更短的路径,但是影响了z节点的总权值:路径从s->t->z变到s->y->x->t->z但是总权值没变,最终导致了权值和路径不匹配的问题。
- 也就是说只要松弛更新出一条更短的路径,就可能会影响其他节点的总权值。最好情况下,更新的路径位于路径末尾,不影响其他节点的最短路径;最坏情况下,更新的路径位于路径起始,影响其他所有顶点的最短路径;因此在最坏情况下,最多需要重复进行n-1次边的遍历才可以确保所有的最短路径被正确计算出来。
当然,在某些情况下,可能不需要进行(V-1)次遍历就能找到最短路径,因此我们可以做一些小小的优化:如果在某轮边的遍历过程当中,没有进行过松弛更新,说明此时所有节点的最短路径都被找到,不需要再次进行遍历,直接跳出循环即可。
Bellman-Ford算法的复杂度
- 时间复杂度:O(VE)(V是顶点数,E是边数),如果使用邻接矩阵实现就是O(V^3)
- 空间复杂度:O(V)
- 拓展:Bellman-Ford的队列优化 - walanwalan - 博客园 (cnblogs.com)
Bellman-Ford算法和Dijkstra算法的区别
Bellman-Ford算法和Dijkstra算法是用于解决单源最短路径问题的两种算法,它们之间有以下几个主要的区别:
- 对负权值的处理不同。Dijkstra算法不适用于包含负权值的图,如果图中存在负权值,该算法将无法正确工作;Bellman-Ford算法可以处理包含负权值的图,并且能够检测图中是否存在负权环。
- 算法的性质不同。Dijkstra算法是一个贪心算法,它每次迭代都会找到当前未处理节点到源节点的最短路径,并以该节点为中转点进行松弛操作;而Bellman-Ford算法是一个暴力迭代算法,它通过多次遍历图中的边来进行松弛操作,更新节点的最短路径。
- 算法的复杂度不同。Dijkstra算法的时间复杂度为O(V^2),空间复杂度为O(V);Bellman-Ford算法的时间复杂度为O(VE),空间复杂度为O(V),在密集图上可能比Dijkstra算法更慢。
- 算法的应用场景不同。Dijkstra算法适用于节点数较多的情况,或者图中边的权重非负的情况;Bellman-Ford算法适用于节点数较少的稀疏图,边权重可能为负的情况。
总结来说,Dijkstra算法适用于边权重非负的图,具有较高的效率;Bellman-Ford算法适用于包含负权值的图,能够检测负权环,但效率相对较低。
带负权回路的图求不出最短路径
负权回路,也被称为负权环,是指在图论中,一个图中存在的环(从某个点出发又回到自己的路径),其包含的边的权值之和小于0。
上图中s->t->y->s就是一个负权环,其包含的边的权值之和为-1。也就是说节点s自己到自己的最短距离不再是0,每走一圈最短距离就会-1,每轮遍历都会进行松弛更新,永远不能计算出最短距离,程序死循环。
Bellman-Ford算法如何检测负权回路?理论上,最多进行V-1轮边的遍历更新就可以确定所有节点的最短路径。也就是说,如果再进行第V轮边的遍历时,不会再有松弛更新。如果有,就说明图中存在负权回路,带负权回路的图求不出最短路径。
代码实现:
bool Release(vector<W> &dist, vector<int> &pPath)
{
size_t n = _vertexs.size();
bool update = false;
for (size_t i = 0; i < n; ++i)
{
for (size_t j = 0; j < n; ++j)
{
if (_matrix[i][j] != W_MAX)
{
// 松弛更新
if (dist[i] + _matrix[i][j] < dist[j])
{
dist[j] = dist[i] + _matrix[i][j];
pPath[j] = i;
update = true;
// cout << _vertexs[i] << "->" << _vertexs[j] << endl;
}
}
}
}
return update;
}
bool BellmanFord(const V &src, vector<W> &dist, vector<int> &pPath)
{
size_t srci = GetIndex(src);
size_t n = _vertexs.size();
// 初始化dist和pPath数组
dist.resize(n, W_MAX);
pPath.resize(n, -1);
dist[srci] = 0;
pPath[srci] = srci;
// 进行V-1轮边的遍历
for (size_t k = 0; k < n - 1; ++k)
{
// cout << "第" << k << "轮遍历" <<endl;
if (!Release(dist, pPath))
break;
}
if (Release(dist, pPath))
return false;
return true;
}
5.3 Floyd-Warshall算法—多源最短路径
Dijkstra算法和Bellman-Ford算法是单源最短路径算法,但是也可以通过循环n次,以所有的点为源点求出任意两点间的最短路径。但是Dijkstra算法不能处理带负权的图,而bellman-ford算法的效率较低。因此出现了专门求多源最短路径的Floyd-Warshall算法。
Floyd-Warshall算法是解决图中任意两点间的最短路径的一种算法。
Floyd算法考虑的是一条最短路径的中间节点,即简单路径p={v1,v2,…,vn}上除v1和vn的任意节点。
设k是p的一个中间节点,那么从i到j的最短路径p就被分成i到k和k到j的两段最短路径p1,p2。p1是从i到k且中间节点属于{1,2,…,k-1}取得的一条最短路径。p2是从k到j且中间节点属于{1,2,…,k-1}取得的一条最短路径。
对比三种算法:Dijkstra算法是由起始边计算得到最短路径;Bellman-Ford算法是由终止边计算得到最短路径;Floyd-Warshall算法是由中间节点计算得到最短路径。
解释一下:上面原理中提到的集合(1~k),指的是从i到j的所有路径中除了起点i和终点j的其余最多n-2个节点组成的集合,点k可以是集合中的任意一个节点。而k-1则表示的是集合中除k以外的节点。
Floyd算法的复杂度:
- 时间复杂度:O(V^3)(V是顶点数)
- 空间复杂度:O(V^2)
代码实现:
void FloydWarshall(vector<vector<W>> &vvDist, vector<vector<int>> &vvpPath)
{
size_t n = _vertexs.size();
// 初始化距离和路径矩阵
vvDist.resize(n);
vvpPath.resize(n);
for (size_t i = 0; i < n; ++i)
{
vvDist[i].resize(n, W_MAX);
vvpPath[i].resize(n, -1);
}
// 将图中直接相连的边添加到矩阵
for (size_t i = 0; i < n; ++i)
{
for (size_t j = 0; j < n; ++j)
{
if (_matrix[i][j] != W_MAX)
{
vvDist[i][j] = _matrix[i][j];
vvpPath[i][j] = i;
}
if (i == j)
{
vvDist[i][j] = W();
}
}
}
// 计算任意两点间的最短路径
for (size_t k = 0; k < n; ++k) // [1]
{
for (size_t i = 0; i < n; ++i)
{
for (size_t j = 0; j < n; ++j)
{
if (vvDist[i][k] != W_MAX && vvDist[k][j] != W_MAX) // [2]
{
if (vvDist[i][k] + vvDist[k][j] < vvDist[i][j]) // [3]
{
vvDist[i][j] = vvDist[i][k] + vvDist[k][j];
vvpPath[i][j] = vvpPath[k][j]; // [4]
}
}
}
}
// 打印权值和路径矩阵观察数据
// for (size_t i = 0; i < n; ++i)
// {
// for (size_t j = 0; j < n; ++j)
// {
// if (vvDist[i][j] == W_MAX)
// {
// // cout << "*" << " ";
// printf("%3c", '*');
// }
// else
// {
// // cout << vvDist[i][j] << " ";
// printf("%3d", vvDist[i][j]);
// }
// }
// cout << endl;
// }
// cout << endl;
// for (size_t i = 0; i < n; ++i)
// {
// for (size_t j = 0; j < n; ++j)
// {
// // cout << vvpPath[i][j] << " ";
// printf("%3d", vvpPath[i][j]);
// }
// cout << endl;
// }
// cout << "=================================" << endl;
}
}
- 在余下的n-2个节点中,选取任意的节点作为i->j的中间节点,计算最短路径。这里虽然循环n次但是如果选中的是起点i或是终点j,也不会影响最短路径的更新。
- 必须保证节点k是路径i->j的中间节点。
- 如果i->…->k>…->j的路径长度小于i>…->j,就要进行更新。注意这里的距离取自vvDist,也就是说两点间的路径可能不是直接相连的边,而是要经过其他的顶点。
- 注意,在更新后的路径i->…->k>…->j中,节点j的前驱节点可能不是k而是k>…->j路径中的其他节点,所以应该取vvpPath[k][j]。