单源最短路径问题:在图G<V,E,W>中,计算起始点 s 到图内每个节点 v 的最短路径长度(边权重之和)。
1、问题概述
首先,最短路径具有 OSP 最优子结构性质:
最短路径中的子路径也是最短路径。
🔺有一个特殊点说明:如果图 G 内包含从 s 可以到达的权重为负值的环路(负环),则计算最短路径是无意义的。且不失一般性,我们可以认为最短路径上是没有环路的,即全是简单路径。
关于最短路径的记录,可以考虑对每个节点表示为一个结构体(当然,直接弄数组储存也是可的):
- v.pre:源节点 s 到 v 的最短路径上 v 的前驱节点
- v.dis:记录源节点 s 到 v 的最短路径值
🔺注:最短路径不一定是唯一的。
struct node {
int dis; //记录源节点 s 到 v 的最短路径值
int pre; //源节点 s 到 v 的最短路径上 v 的前驱节点的id
int id; //节点的编号:对应节点数组V的下标
/* 重载 struct node 的比较运算符
* 方便后面建立优先队列的大小定位 */
friend bool operator < (struct node v1, struct node v2) {
return v1.dis < v2.dis;
}
} V[NUM_OF_VERTEX]; //定义一个节点集
图的单源最短路径问题有两类经典算法:
- Bellman-Ford 算法:允许图带有负权,且可以判断是否有负环的情况。
- Dijkstra 算法:只可处理所有边权均为非负值的图(此图中肯定不含负环喇)
2、松弛操作
三角不等式性质:对于任何边(u,v),均有 v.dis <= u.dis + w(u, v) 。
求最短路径的核心就是松弛操作,其原理就是基于三角不等式性质。松弛操作的对象是边(u,v),过程为:,若更新了 v.dis 则同时更新 v 的前驱节点为 u。
可以发现,松弛操作的效果就是尽量减小 v.dis 的值。松弛是唯一导致v.dis(最短路径估计)和 v.pre(前驱节点)发生改变的操作。
- Dijkstra 算法 和 用于有向无环图的最短路径算法 对每条边仅松弛 1 次
- Bellman-Ford 算法 则需要对每条边松弛 |V| -1 次
代码实现:
/* 初始化操作:
* 传入点集 V,源点 s */
void Initial(struct node V[], struct node s) {
for(int i = 0; i < NUM_OF_VERTEX; i++) {
V[i].pre = -1;
V[i].dis = INT_MAX;
V[i].id = i;
}
s.dis = 0;
}
/* 对边(u,v)实行松弛操作
* 且传入边的权重集合 w
* 返回:true进行了更新;
* false没有进行更新 */
bool Relax(struct node u, struct node v, int w[][NUM_OF_VERTEX]) {
if (v.dis > u.dis + w[u.id][v.id]) {
v.pre = u.id;
v.dis = u.dis + w[u.id][v.id];
return true;
}
return false;
}
3、Bellman-Ford 算法
此算法既不是动态规划也不是贪心算法,就是简单笨拙的操作,但是它适用范围广呀~边权可以为负,且可以判断负环。
算法的思路:循环 |V| - 1次,每次将图中的每一条边进行松弛。如果还有边可以松弛,则说明图中有负环,否则计算完毕。
如何理解将每条边松弛 |V| - 1 次就可以获得最短值?
性质:若松弛序列(边的松弛顺序序列)中有子列(顺序不连续地从序列中取出项组成)是沿着 s-v 的最短路,则该松弛序列执行完毕后,v.dis 必为最短值了。
(可以想象成直接沿着 s-v 的最短路一一松弛,那么结果肯定是最短值呀!只不过这里有可能是有间隔地,但是不影响效果)
Bellman-Ford 算法的完整松弛序列就是把边序列重复 |V| - 1 次,由于最短路径都是简单路径即 边数 <= |V| - 1,那么这个序列的子列中必包含所有最短路径!
代码实现:
/* 计算从源节点s出发到 V中每一个节点的最短路径
* 且传入边的权重集合 w
* 返回值:true即不存在负环;
* false即存在负环,最短路径的计算无意义 */
bool BellmanFord(struct node V[], int w[][NUM_OF_VERTEX], struct node s) {
Initial(V, s); //初始化操作
/* 循环 |V| - 1 次 */
for (int t = 0; t < NUM_OF_VERTEX - 1; t++)
/* 对所有边进行松弛操作 */
for (int i = 0; i < NUM_OF_VERTEX; i++)
for (int j = 0; j < NUM_OF_VERTEX; j++)
if (w[i][j] != INT_MAX) //节点i,j之间存在一条边
Relax(V[i], V[j], w);
/* 检查是否还有负环 */
for (int i = 0; i < NUM_OF_VERTEX; i++)
for (int j = 0; j < NUM_OF_VERTEX; j++)
if (w[i][j] != INT_MAX) //节点i,j之间存在一条边
if(V[j].dis > V[i].dis + w[i][j]) //如果还有边可以松弛
return false;
return true;
}
3、Dijkstra 算法
此算法是贪心算法的应用,执行如下贪心策略:
维持一个节点集合 S:源节点 s 到集合中的点的最短路径已经确定。重复从剩余节点集 V - S 中取出当前 dis 值最小的节点 u,将 u 加到集合 S 中,然后对从 u 出发的所有边进行松弛。
可以证明:每次选择的节点 u 来加入集合 S 时,一定有 u.dis 已经是 s 到 u 的最短路径了。
节点集 V - S 可以是最小堆(优先队列)或者一般数组,其效率取决于图的参数:
- 稠密图:使用一般数组实现节点集 V - S,时间复杂度为
- 稀疏图:使用优先队列实现节点集 V - S,时间复杂度为
应用优先队列的代码实现:
/* 在无负权的图中:
* 计算从源节点s出发到 V中每一个节点的最短路径
* 且传入边的权重集合 w */
void Dijkstra(struct node V[], int w[][NUM_OF_VERTEX], struct node s) {
Initial(V, s);
priority_queue<struct node> Q;
Q.push(s); //初始仅加入源节点s
bool vis[NUM_OF_VERTEX] = {false}; //记录节点是否被加入S集中
while (!Q.empty()) {
struct node cur = Q.top(); //取出 dis 值最小的节点
Q.pop();
if (vis[cur.id])
continue; //已经在S集中了,略过
vis[cur.id] = true; //标记已经加入S集中
for (int i = 0; i < NUM_OF_VERTEX; i++) {
//如果 i 是 cur 的后继节点
if (w[cur.id][i] != INT_MAX) {
if (Relax(cur, V[i], w)) //松弛
Q.push(V[i]); //加入队列
}
}
}
}
完整代码:
//
// Created by A on 2020/4/27.
// 单源最短路径问题:在图 G<V,E,W>中,计算起始点 s 到图内每个节点 v 的最短路径长度(边权重之和)。
#include <climits>
#include <queue>
using namespace std;
#define NUM_OF_VERTEX 12
struct node {
int dis; //记录源节点 s 到 v 的最短路径值
int pre; //源节点 s 到 v 的最短路径上 v 的前驱节点的id
int id; //节点的编号:对应节点数组V的下标
/* 重载 struct node 的比较运算符
* 方便后面建立优先队列的大小定位 */
friend bool operator<(struct node v1, struct node v2) {
return v1.dis < v2.dis;
}
} V[NUM_OF_VERTEX]; //定义一个节点集
/* 初始化操作:
* 传入点集 V,源点 s */
void Initial(struct node V[], struct node s) {
for (int i = 0; i < NUM_OF_VERTEX; i++) {
V[i].pre = -1;
V[i].dis = INT_MAX;
}
s.dis = 0;
}
/* 对边(u,v)实行松弛操作
* 且传入边的权重集合 w
* 返回:true进行了更新;
* false没有进行更新 */
bool Relax(struct node u, struct node v, int w[][NUM_OF_VERTEX]) {
if (v.dis > u.dis + w[u.id][v.id]) {
v.pre = u.id;
v.dis = u.dis + w[u.id][v.id];
return true;
}
return false;
}
/* 计算从源节点s出发到 V中每一个节点的最短路径
* 且传入边的权重集合 w
* 返回值:true即不存在负环;
* false即存在负环,最短路径的计算无意义 */
bool BellmanFord(struct node V[], int w[][NUM_OF_VERTEX], struct node s) {
Initial(V, s); //初始化操作
/* 循环 |V| - 1 次 */
for (int t = 0; t < NUM_OF_VERTEX - 1; t++)
/* 对所有边进行松弛操作 */
for (int i = 0; i < NUM_OF_VERTEX; i++)
for (int j = 0; j < NUM_OF_VERTEX; j++)
if (w[i][j] != INT_MAX) //节点i,j之间存在一条边
Relax(V[i], V[j], w);
/* 检查是否还有负环 */
for (int i = 0; i < NUM_OF_VERTEX; i++)
for (int j = 0; j < NUM_OF_VERTEX; j++)
if (w[i][j] != INT_MAX) //节点i,j之间存在一条边
if (V[j].dis > V[i].dis + w[i][j]) //如果还有边可以松弛
return false;
return true;
}
/* 在无负权的图中:
* 计算从源节点s出发到 V中每一个节点的最短路径
* 且传入边的权重集合 w */
void Dijkstra(struct node V[], int w[][NUM_OF_VERTEX], struct node s) {
Initial(V, s);
priority_queue<struct node> Q;
Q.push(s); //初始仅加入源节点s
bool vis[NUM_OF_VERTEX] = {false}; //记录节点是否被加入S集中
while (!Q.empty()) {
struct node cur = Q.top(); //取出 dis 值最小的节点
Q.pop();
if (vis[cur.id])
continue; //已经在S集中了,略过
vis[cur.id] = true; //标记已经加入S集中
for (int i = 0; i < NUM_OF_VERTEX; i++) {
//如果 i 是 cur 的后继节点
if (w[cur.id][i] != INT_MAX) {
if (Relax(cur, V[i], w)) //松弛
Q.push(V[i]); //加入队列
}
}
}
}