【数据结构】基础:图的最短路径问题(附C++源码)
摘要:将会在数据结构专题中开展关于图论的内容介绍,其中包括四部分,分别为图的概念与实现、图的遍历、图的最小生成树以及图的最短路径问题。本文介绍图的最短路径问题,分别为Dijkstra算法、BellmanFord算法和FloydWarshall算法,从算法的概述内容出发,进行实例介绍,在进行代码的实现说明,最后对其进行测试。
文章目录
前言
0.1 图的实现方式
本文中图的实现方法为邻接矩阵法,以下是对其类的基本描述,若需查看更加具体的内容,可以参考博客图的概念与基本实现。其重点可以概括为:
- Direction:表示是否为有向图
- _vertexs:记录了对应检索下的顶点元素
- _vIndexMap:记录了检索与顶点的对应关系
- _matrix:表示邻接矩阵
具体代码如下:
template<class V, class W, bool Direction = false, W MAX_WEIGHT = INT_MAX>
class Graph {
typedef Graph<V, W, Direction, MAX_WEIGHT> Self;
private:
vector<V> _vertexs; // 顶点集合
map<V, int> _vIndexMap; // 顶点检索
vector<vector<W>> _matrix; // 邻接矩阵
public:
Graph() = default;
Graph(const V* vertexs,size_t vertexSize) {
_vertexs.reserve(vertexSize);
for (size_t i = 0; i < vertexSize; i++) {
_vertexs.push_back(vertexs[i]);
_vIndexMap[vertexs[i]] = i;
}
// 格式化
_matrix.resize(vertexSize);
for (auto& e : _matrix) {
e.resize(vertexSize, MAX_WEIGHT);
}
//for (size_t i = 0; i < _matrix.size(); i++) {
// _matrix[i][i] = 0;
//}
}
size_t GetVertexIndex(const V& v) {
auto ret = _vIndexMap.find(v);
if (ret != _vIndexMap.end()) {
return ret->second;
}
else {
throw invalid_argument("不存在的顶点");
return -1;
}
}
void AddEdge(const V& src, const V& dest, const W& weight) {
size_t srcIndex = GetVertexIndex(src);
size_t destIndex = GetVertexIndex(dest);
AddEdgeByIndex(srcIndex, destIndex, weight);
}
void AddEdgeByIndex(size_t srcIndex, size_t destIndex, const W& weight){
_matrix[srcIndex][destIndex] = weight;
if (Direction == false) {
_matrix[destIndex][srcIndex] = weight;
}
}
};
0.2 松弛操作
松弛即对每一个相邻结点v ,判断源节点s到结点u 的代价与u 到v 的代价之和是否比原来s 到v 的代价更小,若代价比原来小则要将s 到v 的代价更新为s 到u 与u 到v 的代价之和,否则维持原样。
一、概述
最短路径问题:从在带权有向图G中的某一顶点出发,找出一条通往另一顶点的最短路径,最短也就是沿路径各边的权值总和达到最小。
主要方法包括:
- 单源最短路径算法:Dijkstra算法、BellmanFord算法
- 多源最短路径算法:FloydWarshall算法
二、Dijkstra算法(迪杰斯特拉算法)
2.1 概述
迪杰斯特拉算法(Dijkstra)是由荷兰计算机科学家狄克斯特拉于1959年提出的,因此又叫狄克斯特拉算法。是从一个顶点到其余各顶点的最短路径算法,解决的是有权图中最短路径问题。迪杰斯特拉算法主要特点是从起始点开始,采用贪心算法的策略,每次遍历到始点距离最近且未访问过的顶点的邻接节点,直到扩展到终点为止。
Dijkstra算法存在的问题是不支持图中带负权路径,如果带有负权路径,则可能会找不到一些路径的最短路径。
对于Dijkstra算法的内容,同样将点的集合分为已确定最小路径和未确定最小路径的两个集合,在每次寻找路径的过程中,可以概括为选择离起点权值最小的边作为基准去更新其他未确定的边,并将以确定最小路径的点保存,不再修改。
具体内容为:
- 针对一个带权有向图G,将所有结点分为两组S和Q,S是已经确定最短路径的结点集合,在初始时为空(初始时就可以将源节点s放入,毕竟源节点到自己的代价是0),Q 为其余未确定最短路径的结点集合
- 每次从Q 中找出一个起点到该结点代价最小的结点u ,将u 从Q 中移出,并放入S 中,对u 的每一个相邻结点v 进行松弛操作。
- 如此一直循环直至集合Q 为空,即所有节点都已经查找过一遍并确定了最短路径,至于一些起点到达不了的结点在算法循环后其代价仍为初始设定的值,不发生变化。
- Dijkstra算法每次都是选择V-S中最小的路径节点来进行更新,并加入S中,所以该算法使用的是贪心策略。
2.2 具体实例
摘抄自《算法导论》的实例进行展示与说明:
- 以s为起点进行寻找最短路径,在最开始离s点最近只有s点,此时已确定为最短路径,权值为0。再以s为基准,访问其他的顶点,更新距离,对于t点和y点来说,可以更新,而x和z点不邻接,仍为无穷大
- 选择最短的路径作为基准,因此选择y点,此时说明y到s的最短路径已经确定,并以y为基准访问其他顶点进行更新,对于t顶点、x顶点和z顶点,根据计算需要进行松弛操作
- 选择最短的路径作为基准,因此选择z点,此时说明z到s的最短路径已经确定,并以z为基准访问其他顶点进行更新,对于x点需要进行松弛操作,而t点不需要
- 选择最短的路径作为基准,因此选择t点,此时说明t到s的最短路径已经确定,并以t为基准访问其他顶点进行更新,对于x点需要进行松弛操作
- 选择最短的路径作为基准,因此选择x点,此时说明x到s的最短路径已经确定,并且全部节点到s点的最短路径确定,为此不需要再更新,完成了最短路径的寻找
2.3 代码实现
实现方法:
- 创建并初始化初始数据结构:距离容器(记录离起点的最短距离)、父节点容器(记录连接父节点的索引)和访问容器(表示是否为确定最小路径的节点)
- 从起点开始,找出最小边,设置其为确定状态,添加更新松弛距离容器中起点到各个点的距离,当发生更新时,写入父节点容器,便于父节点查找,重复该过程n次,确定n个节点
具体代码:
void Dijkstra(const V& src, vector<W>& dist, vector<int>& parentPath) {
size_t n = _vertexs.size();
size_t srcIndex = GetVertexIndex(src);
// 初始化距离容器和父节点容器
dist.resize(n, MAX_WEIGHT);
parentPath.resize(n, -1);
// 设置起点访问自己权值为0
dist[srcIndex] = W();
parentPath[srcIndex] = srcIndex;
// 记录是否访问过
vector<bool> visitedV(n, false);
// 统计完n个节点
for (size_t num = 0; num < n; num++) {
// 找出最小边
int minEdgeIndex = 0;
W minEdgeWeight = MAX_WEIGHT;
for (size_t i = 0; i < n; i++) {
if (visitedV[i] == false && dist[i] < minEdgeWeight) {
minEdgeIndex = i;
minEdgeWeight = dist[i];
}
}
// 该边说明了 起点到该点的最小距离,因此要设置其为访问状态
visitedV[minEdgeIndex] = true;
// 添加更新dist中起点到各个点的距离
// 找最小值:原来的最小值和途径新点的最小值
for (size_t i = 0; i < n; i++) {
if (visitedV[i] == false && _matrix[minEdgeIndex][i] != MAX_WEIGHT) {
if (dist[minEdgeIndex] + _matrix[minEdgeIndex][i] < dist[i]) {
dist[i] = dist[minEdgeIndex] + _matrix[minEdgeIndex][i];
parentPath[i] = minEdgeIndex;
}
}
}
}
}
2.4 测试用例
测试样例与上文《算法导论》的图一致
void PrintShortestPath(const V& src, vector<W>& dist, vector<int>& parentPath) {
size_t srcIndex = GetVertexIndex(src);
size_t n = _vertexs.size();
for (size_t i = 0; i < n; ++i) {
// 找出i顶点的路径
vector<int> path;
size_t parentIndex = i;
while (parentIndex != srcIndex) {
path.push_back(parentIndex);
parentIndex = parentPath[parentIndex];
}
path.push_back(srcIndex);
reverse(path.begin(), path.end());
cout << "[" << i << "]|";
for (size_t j = 0; j < path.size() - 1; j++) {
cout << _vertexs[path[j]] << " -> ";
}
cout << _vertexs[path[path.size() - 1]];
cout << " 权值和:" << dist[i] << endl;
}
}
void TestGraphDijkstra()
{
const char* str = "syztx";
Graph<char, int, true> g(str, strlen(str));
g.AddEdge('s', 't', 10);
g.AddEdge('s', 'y', 5);
g.AddEdge('y', 't', 3);
g.AddEdge('y', 'x', 9);
g.AddEdge('y', 'z', 2);
g.AddEdge('z', 's', 7);
g.AddEdge('z', 'x', 6);
g.AddEdge('t', 'y', 2);
g.AddEdge('t', 'x', 1);
g.AddEdge('x', 'z', 4);
vector<int> dist;
vector<int> parentPath;
g.Dijkstra('s', dist, parentPath);
g.PrintShortestPath('s', dist, parentPath);
}
三、BellmanFord算法(贝尔曼-福特算法)
3.1 概述
贝尔曼-福特算法(Bellman-Ford)是由理查德·贝尔曼(Richard Bellman) 和 莱斯特·福特 创立的,求解单源最短路径问题的一种算法。有时候这种算法也被称为 Moore-Bellman-Ford 算法,因为 Edward F. Moore 也为这个算法的发展做出了贡献。它的原理是对图进行V-1次松弛操作,得到所有可能的最短路径。其优于迪科斯彻算法的方面是边的权值可以为负数、实现简单,缺点是时间复杂度过高,高达O(VE)。但算法可以进行若干种优化,提高了效率。
BellmanFord算法缺点在于时间复杂度 O(N*E) (N是点数,E是边数)普遍是要高于Dijkstra算法O(N²)的,本文使用邻接矩阵实现,那么遍历所有边的数量的时间复杂度就是O(N^3),可以看出来Bellman-Ford就是一种暴力求解更新。
不仅如此,该算法当负环存在时也是无法进行计算出最短路径的。原因在于每进行一次松弛,负环中的点的最短路径距离就会减少,进入循环过程中也会持续减小,没有终止。
3.2 具体实例
摘抄自《算法导论》的实例进行展示与说明:从点s开始对每个结点进行松弛操作
3.3 代码实现
算法理解:可以从每次松弛过程进行理解,对于松弛的结点来说,松弛过程中并不是此刻的距离容器中记录的就是最短距离。而每轮松弛过程,最多可能添加一个中转结点,而对于n个结点来说,最多增加n-1个中间结点。由于每个结点都有可能成为中间结点,因此进行n次遍历松弛过程。
实现方法:对图进行n次松弛操作,得到所有可能的最短路径
- 创建并初始化距离容器和父节点容器
- 松弛n轮:因为每次松弛可能让一个结点的路径影响,更新后就规避了这种影响,最多可能更改n个结点的次数的影响
- 设置目标位:当不需要更新时停止松弛
- 在每次松弛需要更新时,写入父节点容器,便于父节点查找
- 为防止出现负环的问题,在更新了n次后,判断是否还更新的,当存在负值环,进行错误返回
具体代码:
bool BellmanFord(const V& src, vector<W>& dist, vector<int>& parentPath) {
size_t n = _vertexs.size();
size_t srcIndex = GetVertexIndex(src);
// 初始化距离容器和父节点容器
dist.resize(n, MAX_WEIGHT);
parentPath.resize(n, -1);
// 设置起点访问自己权值为0
dist[srcIndex] = W();
parentPath[srcIndex] = srcIndex;
// 松弛n次:因为每次松弛可能让一个结点的路径影响,更新后就规避了这种影响,最多可能更改n个结点的次数的影响
for (size_t num = 0; num < n; num++) {
bool flag = false;
cout << "更新次数:" << num << endl;
// 每条边进行松弛
for (size_t i = 0; i < n; i++) {
for (size_t j = 0; j < n; j++) {
if (_matrix[i][j] != MAX_WEIGHT && dist[i] + _matrix[i][j] < dist[j]) {
flag = true;
dist[j] = dist[i] + _matrix[i][j];
parentPath[j] = i;
cout << _vertexs[i] << "->" << _vertexs[j] << " : " << _matrix[i][j] << endl;
}
}
}
// 没有更新边说明不需要松弛了
if (flag == false) {
cout << "没有更新边"<<endl;
break;
}
}
// 更新了n次,还有要更新的,说明存在负值环,当存在该环时,会导致src到src不断减小
for (size_t i = 0; i < n; i++) {
for (size_t j = 0; j < n; j++) {
if (_matrix[i][j] != MAX_WEIGHT && dist[i] + _matrix[i][j] < dist[j]) {
cout << "存在负值环";
return false;
}
}
}
return true;
}
3.4 测试用例
测试样例与上文《算法导论》的图一致,打印函数PrintShortestPath
也在上文测试用例中
void TestGraphBellmanFord()
{
const char* str = "syztx";
Graph<char, int,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('z', 's', 2);
g.AddEdge('z', 'x', 7);
g.AddEdge('t', 'x', 5);
g.AddEdge('t', 'y', 8);
g.AddEdge('t', 'z', -4);
g.AddEdge('x', 't', -2);
vector<int> dist;
vector<int> parentPath;
g.BellmanFord('s', dist, parentPath);
g.PrintShortestPath('s', dist, parentPath);
}
四、FloydWarshall(弗洛伊德算法)
4.1 概述
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}取得的一条最短路径。
以下是来自《算法导论》的摘抄:
4.2 代码实现
Floyd算法本质是三维动态规划,Dijk表示从点i到点j只经过0到k个点最短路径,然后建立起转移方程,然后通过空间优化,优化掉最后一维度,变成一个最短路径的迭代算法,最后即得到所以点的最短路。
可以设Di,j,k为i到j的只以(1…k)集合中的节点为中间节点的最短路径长度:
- 若最短路径经过点k,则Di,j,k = Di,k,k-1 + Dk,j,k-1
- 若最短路径不经过点k,则Di,j,k = Di,j,k-1
因此 Di,j,k = min(Di,k,k-1 + Dk,j,k-1 , Di,j,k-1)
实现方法:
- 初始化权值矩阵:与邻接矩阵一致,但对于达到自身的距离设置为0
- 初始化路径矩阵:每个节点的初始路径为自己
- 依次用顶点k作为中转点更新最短路径,即进行k次动态规划的过程,要筛选中间的n-2个节点,但每个节点都需要检测,因此进行n次更新检查
void FloydWarshall(vector<vector<W>>& vvDist, vector<vector<int>>& vvParentPath) {
size_t n = _vertexs.size();
// 初始化权值和路径矩阵
vvDist.resize(n);
vvParentPath.resize(n);
for (size_t i = 0; i < n; ++i){
vvDist[i].resize(n, MAX_WEIGHT);
vvParentPath[i].resize(n, -1);
}
for (size_t i = 0; i < n; ++i){
for (size_t j = 0; j < n; ++j){
if (_matrix[i][j] != MAX_WEIGHT){
vvDist[i][j] = _matrix[i][j];
vvParentPath[i][j] = i;
}
}
vvDist[i][i] = W();
}
// 依次用顶点k作为中转点更新最短路径
for (size_t k = 0; k < n; k++) {
for (size_t i = 0; i < n; i++) {
for (size_t j = 0; j < n; j++) {
// i->k + k->j 比 i->j前面更新的距离更短,则更新
if (vvDist[i][k] != MAX_WEIGHT && vvDist[k][j] != MAX_WEIGHT
&& vvDist[i][k] + vvDist[k][j] < vvDist[i][j]) {
vvDist[i][j] = vvDist[i][k] + vvDist[k][j];
vvParentPath[i][j] = vvParentPath[k][j];
}
}
}
// 打印矩阵与路径矩阵
for (size_t i = 0; i < n; i++) {
for (size_t j = 0; j < n; j++) {
if (vvDist[i][j] == MAX_WEIGHT) {
printf("%3c", '*');
}
else {
printf("%3d", vvDist[i][j]);
}
}
cout << " | ";
for (size_t j = 0; j < n; j++) {
if (vvParentPath[i][j] == -1) {
printf("%3d", vvParentPath[i][j]);
}
else {
printf("%3c", _vertexs[vvParentPath[i][j]]);
}
}
cout << endl;
}
cout << endl;
cout << "=================================" << endl;
cout << endl;
}
}
4.3 测试用例
使用算法导论的实例进行说明:
void TestFloydWarShall()
{
const char* str = "12345";
Graph<char, int, true> g(str, strlen(str));
g.AddEdge('1', '2', 3);
g.AddEdge('1', '3', 8);
g.AddEdge('1', '5', -4);
g.AddEdge('2', '4', 1);
g.AddEdge('2', '5', 7);
g.AddEdge('3', '2', 4);
g.AddEdge('4', '1', 2);
g.AddEdge('4', '3', -5);
g.AddEdge('5', '4', 6);
vector<vector<int>> vvDist;
vector<vector<int>> vvParentPath;
g.FloydWarshall(vvDist, vvParentPath);
// 打印任意两点之间的最短路径
for (size_t i = 0; i < strlen(str); ++i)
{
g.PrintShortestPath(str[i], vvDist[i], vvParentPath[i]);
cout << endl;
}
}
补充:
- 代码将会放到:C++/C/数据结构代码链接 ,欢迎查看!
- 欢迎各位点赞、评论、收藏与关注,大家的支持是我更新的动力,我会继续不断地分享更多的知识!