<数据结构>图的最短路径问题
最短路径问题
- 单源最短路径问题:Dijstra[不带负权边]\Bell_Ford(SPFA)[可带负权边]
- 多源最短路径问题:Floy
Dijstra算法:中介点优化
解决不带负权边的单源最短路问题
通过与源点s最短路径已知的点Vi为中介,优化Vi的邻接点与源点s的距离
基本步骤
MAXV:最大顶点数,源点s
数组d[MAXV]:各个顶点到源点s的距离。初始化为INF(无穷大,一般设为100,000,000),d[s]初始化为0。
数组vis[MAXV]:标记数组。 vis[Vi] == true 表示Vi结点已被访问过
- 取出未被标记过的顶点中与源点距离最短的顶点Vi,标记Vi
- 以Vi为中介点,更新Vi的邻接点到源点s的距离
- 将1.2循环n次(n为顶点数)
伪代码
//G为图,一般设成全局变量;数组d为源点到达各点的最短路径长度,s为起点
Dijstra(G, d[], s){
初始化;
for(循环n次){
u = 使d[u]最小的还未被访问的顶点的标号;
记u已被访问;
for(从u出发能到达的所有顶点v){
if(v未被访问 && 以u为中介点使s到顶点v的最短距离d[v]更优){
优化d[v];
}
}
}
}
在实现过程中的关键问题
- “u = 使d[u]最小的还未被访问的顶点的标号;”————>如何找到d[u]最小值
遍历d:O(MAXV)
将d构建为Min_Heap:O(logMAXV) - "v未被访问 && 以u为中介点使s到顶点v的最短距离d[v]更优"————>前者用标记数组vis解决;后者即"d[u] + G[u][v] < d[v]"(邻接矩阵实现时)
代码实现
邻接矩阵版
#include<stdio.h>
#include<algorithm>
using namespace std;
#define MAXV 100 //最大顶点数
#define INF 100000000 //一个很大的数
int n, G[MAXV][MAXV]; //n为顶点数
int d[MAXV]; //起点到各点的最短路径长度
bool vis[MAXV] = {false};//标记数组,vis[i] == false 表示已访问。初值均为false
void Dijstra(int s){ //s为起点
fill(d, d+MAXV*MAXV, INF); //fill函数将整个d数组赋为INF(慎用memset)
d[s] = 0; //起点s到自身的距离为0
for(int i = 0; i < n; i++){ //循环n次
int u = -1, MIN = INF; //u是d[u]最小,MIN存放最小的d[u]
for(int j = 0; j < n; j++){ //找到未访问的顶点中d[u]最小的,时间复杂度为O(MAXV)
if(vis[j] == false && d[j] < MIN){
u = j;
MIN = d[j];
}
}
//找不到小于INF的 d[u], 说明剩下的顶点和起点s不连通
if(u == -1) return ;
vis[u] = true; //标记u为已访问
for(int v = 0; v < n; v++){
//如果v未访问 && u能到达v && 以u为中介点可以使d[v]更优
if(vis[v] == false && G[u][v] != INF && d[u] + G[u][v] < d[v]){
d[v] = d[u] + G[u][v];
}
}
}
}
邻接表版
#include<stdio.h>
#include<algorithm>
#include<vector>
using namespace std;
#define MAXV 100 //最大顶点数
#define INF 100000000 //一个很大的数
struct Node
{
int v, dis; //v表示目标顶点, dis表示边权
};
vector<Node> Adj[MAXV];
int n; //n为顶点数
int d[MAXV]; //起点到各点的最短路径长度
bool vis[MAXV] = {false};//标记数组,vis[i] == false 表示已访问。初值均为false
void Dijstra(int s){ //s为起点
fill(d, d+MAXV*MAXV, INF); //fill函数将整个d数组赋为INF(慎用memset)
d[s] = 0; //起点s到自身的距离为0
for(int i = 0; i < n; i++){ //循环n次
int u = -1, MIN = INF; //u是d[u]最小,MIN存放最小的d[u]
for(int j = 0; j < n; j++){ //找到未访问的顶点中d[u]最小的
if(vis[j] == false && d[j] < MIN){
u = j;
MIN = d[j];
}
}
//找不到小于INF的 d[u], 说明剩下的顶点和起点s不连通
if(u == -1) return ;
vis[u] = true; //标记u为已访问
/**********************只有下面这个与邻接矩阵不同**************************/
for(int i = 0; i < Adj[u].size(); i++){
int v = Adj[u][i].v; //通过邻接表直接获得u能到达的顶点v
if(d[u] + Adj[u][v].dis < d[v]){
d[v] = d[u] + Adj[u][v].dis; //优化d[v]
}
}
}
}
时间复杂度:O(VlogV+E)
外循环:O(V)无法避免
影响因素:
- d[MAXV]最小值的获取方式
遍历d:O(V)
最小堆:O(logV) - 图G的实现方式
邻接矩阵:内循环O(V)
邻接表:与外循环共同作用等价于遍历了所有边,故内外循环的总时间复杂度为O(E)
总结:
邻接表 | 邻接矩阵 | |
---|---|---|
遍历d | O(V* V+E) | O(V* (V+V)) |
最小堆 | O(V* logV+E) | O(V*(logV+V)) |
算法存在的问题:存在负权边时会失效
一个简单的例子:
在该例中,如果以A顶点为源点,那么A先被标。而后由于 A到B的距离-1 < A到C的距离1,故B到源点的最短距离更新为1,B被标记。
由于被标记过的顶点的距离值不会再被更新,所以根据Dijstra算法得出B到源点A的最短距离为-1,但实际上应该为-4,可见Dijstra算法在存在负权边的图中就出错了。
Bell_Ford和SPFA算法:遍历边优化
解决单源最短路问题,可检测负权边
存在负权边时函数返回false, 不存在时返回true且d[]存放源点到各顶点的最短路径长
Dijstra算法相比:Dijstra主要操作对象是结点,无法处理负权边;BF主要操作对象是边,可以通过一轮循环判断是否存在负权边
基本步骤
构建d[]数组
- 对边执行n-1轮操作
- 每轮操作遍历所有边
- 对每条边u->v,如果以u为中介使得d[v]更小,则更新d[v] 【边松弛】
寻找负权环
再对所有边执行一轮操作,判断是否仍有边满足 d[u]+G[u][v] < d[v]
有则说明存在负权边,无则说明不存在,d数组中所有值已达最优
伪代码
//构建d[]数组
for(int i = 0; i < n-1; i++){ //执行n-1轮操作,其中n为顶点数
for(each edge u->v){ //每轮操作都遍历所有边
if(d[u] + length[u->v] < d[v]) //以u为中介点可以使得d[v]更小
d[v] = d[u] + length[u->v]; //松弛操作
}
}
//检查负环
for(each edge u->v){ //对每条边进行判断
if(d[u] + length[u->v] < d[v]){ //如果仍可以被松弛
return false; //说明图中有从原点可达的负环
}
return true; //数组d的所有值已达最优
}
代码本质:构建最短路径树
- 最短路径树:如果将从源点s到其他各点的最短路径连接起来,必然会构成一棵树(整体连通&&无环),该树称为最短路径树【别和最小生成树搞混了】。原图和源点一旦确定,最短路径树就唯一确定了
- BF算法的每一轮循环实际上就是在确定树的一层,树的层数不超过V-1,故循环V-1次
代码实现
/*Bellman_Ford:可处理带有负权环的单源最短路径问题 O(V^2)*/
#include<stdio.h>
#include<vector>
#include<algorithm>
using namespace std;
#define MAXV 100
#define INF 100000000
struct Node{
int v, dis;
};
vector<Node> Adj[MAXV];
int n;
int d[MAXV];
bool Bellman(int s){ //s为源点
fill(d, d+MAXV, INF);
d[s] = 0;
for(int i = 0; i < n-1; i++){ //执行n-1 轮操作,n为顶点数
for(int u = 0; u < n; u++){ //每轮操作都遍历所有边
for(int j = 0; j < Adj[u].size(); j++){
int v = Adj[u][j].v; //邻接边的顶点
int dis = Adj[u][j].dis;//邻接边的边权
if(d[u] + dis < d[v]){ //以u为中介点可以使d[v]更小
d[v] = d[u] + dis;
}
}
}
}
for(int u = 0; u < n; u++){ //对每条边进行判断
for(int j = 0; j < Adj[u].size(); j++){
int v = Adj[u][j].v;
int dis = Adj[u][j].dis;
if(d[u] + dis < d[v]){
return false; //说明图中有从源点可达的负权环
}
}
}
return true;
}
复杂度分析:O(VE)
外循环:执行V-1次,无法改变
内循环(遍历每条边):执行E次(邻接表),执行V^2(邻接矩阵)
邻接表 | 邻接矩阵 | |
---|---|---|
时间复杂度 | O(VE) | O(V^3) |
优化:SPFA(Shortest Path Faster Algorithm)
切入点: 在BF算法中,当某个顶点u的d[u]值改变时,从它出发的边的邻接点v的d[v]值才有可能改变,此时无需遍历所有边。
优化: 建立一个队列,每次将队首元素u取出,对u的所有邻接边u->v进行松弛操作,如果某个顶点v的d[v]改变了,就将v加入队列中。
循环直到 队列为空||某元素v入队次数大于n-1 退出循环。此时,队列为空,说明d数组已经最优,否则,说明存在负权边。
注:入队元素最大入队次数为n-1次: 顶点v最多与n-2个结点连通,如果每次边松弛时v都入队,则入队次数为n-2,如果v由恰好是源点,在初始化时入队1次,则总共入队n-1次。
伪代码
queue<int> q;
源点s入队;
while(队列非空){
取出队首元素u;
for(u的所有邻接边u->v){
if(d[u] + G[u][v] < d[v]){
if(v不在队列){
v入队;
if(v入队次数大于n-1){
说明有可达负环; return;
}
}
}
}
}
队列用stl的queue实现
v入队次数用数组num[MAXV]记录。(MAXV:最大顶点数)
代码实现:邻接表为例
/*基于Bellman-Ford的优化*/
/*无需遍历所有边,而只需遍历u的发出边(基于BFS思想) O(kE)*/
#include<stdio.h>
#include<vector>
#include<queue>
#include<algorithm>
using namespace std;
#define MAXV 100
#define INF 100000000
struct Node{
int v, dis;
};
vector<Node> Adj[MAXV];
int n, d[MAXV], num[MAXV]; //num数组记录入队次数
bool inq[MAXV]; //顶点是否在队列中
bool SPFA(int s){
//初始化部分
fill(inq, inq+MAXV, false);
fill(num, num+MAXV, 0);
fill(d, d+MAXV, INF);
//源点入队部分
queue<int> Q;
Q.push(s);
inq[s] = true; //源点已入队
num[s]++; //源点入队次数+1
d[0] = 0; //源点的d值为0
//主体部分
while(!Q.empty()){
int u = Q.front(); //队首顶点编号为u
Q.pop(); //出队
inq[u] = false; //设置u不在队列中
//遍历u所有的邻接边
for(int j = 0; j < Adj[u].size(); j++){
int v = Adj[u][j].v;
int dis = Adj[u][j].dis;
//松弛操作
if(d[u] + dis < d[v]){
d[v] = d[u] + dis;
if(!inq[v]){ //如果v不在队列中
Q.push(v); //v入队
inq[v] = true; //设置v在队列中
num[v]++; //v的入队次数+1
if(num[v] >= n) return false; //有可达负环
}
}
}
}
return true; //无可达负环
}
复杂度分析:O(kE)
证略。k是一个常数,一般不超过2。
- 经常优于堆优化的Dijstra算法
- 存在负权环时退化为O(VE)