图论(五)单源最短路算法


一 单源最短路问题

给出单源最短路的定义:给定一个有向图G=(V,E),每条边都有一个权重 w i w_i wi,在现实问题中,权重可以代表长度、时间、成本、罚款损失…,我们可以简单的理解为长度。我们需要从一个源结点 s ∈ V s\in V sV出发,寻找到s到图中所有节点的最短有向路径。为了描述方便,我们给图中每个节点v定义一个最短路径权重 δ ( s , v ) \delta(s,v) δ(s,v)
δ ( s , v ) = { m i n { w ( p ) : s → p v } 存 在 一 条 路 径 p 从 s 到 v ∞ 无 \delta(s,v)=\left\{\begin{matrix} min\{w(p):s\overset{p}{\rightarrow}v\} & 存在一条路径p从s到v\\ \infty & 无 \end{matrix}\right. δ(s,v)={min{w(p):spv}psv

单源最短路问题有以下几个特点:

  • 最短路的最优子结构:两个节点之间的一条最短路径包含着其他最短路径。最优子结构是可以使用动态规划和贪心算法的一个重要指标。本篇要学习的Dijkstra算法就是一个贪心算法,而Bellman-Ford算法是一个动态规划算法。

  • 负权重的边:某些图中可能包含权重为负值的边,当形成权重为负值的环时,就会形成严重的问题:从源节点s到环上任意节点的路径都不可能是最短路,因为我们只要沿着环在走一圈,总权重就会变小,最后达到死循环。

  • 环路:一条最短路径可以包含环路吗?刚刚说明了最短路径不能包含权重为负的环路,事实上最短路径也不能包含权重为正值或零值的环路:因为只要将环路从路径上删除就可以得到一条源节点到目的节点比原来路径权重更小的路径。

  • 松弛操作:本篇的算法都会用到比较一个重要的松弛(Relax)的技术,对于所有的节点v,我们维护一个属性v.d用来记录源节点s到节点v的最短路径权重的上界,记为最短路径权估计,(图中记录在每个节点圈内),初始化为 ∞ \infty ,对一条边的松弛操作就是尝试对v.d的改善,对于每条边(u,v)满足: v . d = m i n { v . d , u . d + w u , v } v.d = min\{v.d,u.d+w_{u,v}\} v.d=min{v.d,u.d+wu,v}

    RELAX(u,v,w)
    	if v.d>u.d+w(u,v){
    		v.d = u.d+w(u,v)//更新v的最短路径估计
    		v.π = u//记录v的最短路径前驱为u
    	}
    

接下来我们将介绍三种算法,这三种算法都在各自的限制条件下解决了单源最短路问题。

算法限制条件:默认有向时间复杂度
有向无环图的单源最短路算法无环 Θ ( V + E ) \Theta(V+E) Θ(V+E)
Dijkstra算法所有边的权重非负值 Θ ( V 2 + E ) \Theta(V^2+E) Θ(V2+E),可改善
Bellman-Ford算法无限制 O ( V E ) O(VE) O(VE)

二 有向无环图的单源最短路算法

对于任意一个有向无环图G,我们通过DFS得到它的拓扑排序,**按照拓扑序对每个节点的进行松弛操作即可。**例如下面的一个图G,找到了G的拓扑序

更新的过程如下图所示

伪代码如下:

该算法的时间复杂度很好分析:拓扑排序(DFS)花费 Θ ( V + E ) \Theta(V+E) Θ(V+E),初始化花费 Θ ( V ) \Theta(V) Θ(V),双层for循环的效果是对图中所有的边进行一次松弛操作,花费 Θ ( E ) \Theta(E) Θ(E),因此总的时间复杂度为 Θ ( V + E ) \Theta(V+E) Θ(V+E)

三 Dijkstra算法

3.1 原理

Dijkstra算法解决的是带权重的有向图上的单源最短路问题,该算法要求所有边的权值非负,而对环路没有要求。

Dijkstra算法是一种贪心算法,在运行过程中维护的关键信息是一组节点集合S,从源节点s到该集合中的每个节点之间的最短路径已被找到,算法重复的进行这个操作:从节点集V-S中选择最短路径估计(见第一节松弛)最小的节点u(贪心规则),将u加入集合S,然后对所有从u出发的边进行松弛操作。直至所有节点被包括到集合S中。

通过一组图来说明整个过程,图中的α[]存储的就是每个节点的最短路径估计,π[]存储着前驱节点:

  1. 初始化S集合为空,将源节点的最短路径估计设为0

    在这里插入图片描述

  2. 寻找V-S集合中最短路径估计最小的顶点加入S集合,更新与该节点的邻居节点的α[]

    在这里插入图片描述

  3. 重复这个过程直至所有节点都被包括进来

    在这里插入图片描述

    在这里插入图片描述

    在这里插入图片描述

    在这里插入图片描述

    在这里插入图片描述

    在这里插入图片描述

    在这里插入图片描述

3.2 伪代码与代码实现

算法伪代码如下:

我们使用邻接链表实现有向图,使用map数据结构存储每条边的权值,自定义一个权值函数w:每次用一个pair对象作为key值去查找每一条边的权值。自定义compare结构——优先队列的优先顺序,到起始点的最短距离更短者更优先。

//
// Created by HP on 2021/11/22.
//
#include <iostream>
#include <map>
#include <utility>
#include <vector>
#include <queue>
using namespace std;

typedef vector<vector<int>> graph;// 邻接链表

typedef pair<int,int> edge;// 定义一对顶点,描述有向边
map<edge,int> edgeSet;// 有向边的权值map
int w(edge &e){// 有向边的权值函数
    return edgeSet[e];
}

int d[100];//定义当前节点到起始节点的最短距离
struct compare{
    bool operator ()(int &a,int &b){
        return d[a]>d[b];//当前节点到起始节点最短距离 最小者优先
    }
};

void Dijkstra(graph g,int start){
    int p[g.size()];//定义前驱数组
    for(int i=0;i<g.size();i++){
        p[i] = -1;
        if(i != start)
            d[i] = INT_MAX;  
        else
            d[i] = 0;//设置起始节点的距离为0
    }
    priority_queue<int,vector<int>,compare> pq;// d值最小优先队列

    for(int i=0;i<g.size();i++)// 优先队列初始化
        pq.push(i);

    while (!pq.empty()){
        int u = pq.top();// 找到 到起始顶点最近的顶点
        pq.pop();
        for(int v:g[u]){
            edge e(u,v);// 定义u->v的边
            if(d[v]>d[u]+w(e)){
                //进行松弛操作
                d[v] = d[u]+w(e);
                p[v] = u;
                // 更新pq
                priority_queue<int,vector<int>,compare> pq1;
                while(!pq.empty()){
                    pq1.push(pq.top());
                    pq.pop();
                }
                pq = pq1;
            }
        }
    }
    for(int i=0;i<g.size();i++){
        cout<<i<<" d: "<<d[i]<<" p:"<<p[i]<<endl;
    }
}

int main(){

    //第一个图
    graph g;
    vector<int> n0 = {1,2,3};
    g.push_back(n0);
    vector<int> n1 = {4};//1
    g.push_back(n1);
    vector<int> n2 = {3};
    g.push_back(n2);
    vector<int> n3 = {1,5};
    g.push_back(n3);
    vector<int> n4 = {0};
    g.push_back(n4);
    vector<int> n5 = {1,4};
    g.push_back(n5);

    // 添加边与权值
    edge e_01(0,1);
    edge e_02(0,2);
    edge e_03(0,3);
    edge e_14(1,4);
    edge e_23(2,3);
    edge e_31(3,1);
    edge e_35(3,5);
    edge e_51(5,1);
    edge e_54(5,4);
    edgeSet[e_01] = 16;
    edgeSet[e_02] = 4;
    edgeSet[e_03] = 8;
    edgeSet[e_14] = 2;
    edgeSet[e_23] = 3;
    edgeSet[e_31] = 7;
    edgeSet[e_35] = 1;
    edgeSet[e_51] = 5;
    edgeSet[e_54] = 6;

    Dijkstra(g,0);
}

以下图的结构为例:

打印出每个点到起始节点的最短距离与前驱节点:

3.3 时间复杂度分析

Dijkstra算法对所有的顶点都执行一次InsertExtract-Min操作(入队列与出队列),对于所有的边,算法都遍历一遍并且进行一次松弛操作Decrease-Key,因此总运行时间主要依赖于最小优先队列的实现方式。

  • 使用数组实现最小优先队列时,每次InsertDecrease-Key操作执行时间为O(1),每次Extract-Min操作时间是O(V)(因为需要搜索整个数组)。总运行时间: O ( V 2 + E ) = O ( V 2 ) O(V^2+E)=O(V^2) O(V2+E)=O(V2)
  • 使用堆实现优先队列时,每次Insert Decrease-KeyExtract-Min操作的执行时间为O(lgV)。总运行时间为: O ( V lg ⁡ V + E lg ⁡ V ) = O ( ( V + E ) lg ⁡ V ) O(V\lg V+E\lg V)=O((V+E)\lg V) O(VlgV+ElgV)=O((V+E)lgV)

3.4 正确性证明

使用归纳假设法证明该算法的正确性。

3.5 与BFS和Prim算法的比较

Dijkstra算法既类似广度优先搜索,又有点类似于Prim算法。与BFS的类似点在于集合S对应的是广度优先搜索中的黑色节点集合,正如集合S中的节点的最短路径已经被计算出来,在BFS中,黑色节点的正确的广度优先路径也已经被计算出来。

与Prim算法类似的地方,两个算法都使用最小优先队列来寻找给定集合(Dijkstra算法中的S与Prim算法中逐渐增长的树)之外最近的结点,将该节点加入到集合中,并对集合以外的点的权重进行调整。

四 Bellman-Ford算法

Bellman-Ford算法可以解决任意情况下的有向加权图的单元最短路问题。在介绍算法前我们先分析一些限制对之前学习的算法造成的影响。

  • 负边:Dijkstra算法会将s->t的最短路记为s->t,但实际上是s->v->w->t。

    在这里插入图片描述

  • 权值变化:给所有边的权值加上一个常量可能会使Dijkstar算法计算出的最短路发生变化。

    在这里插入图片描述

  • 负圈:

    在这里插入图片描述
    由上面的负圈引出第一个引理:

引理1:如果从s到v的路径上包含一个负圈,则从s到v不存在最短路径。

在这里插入图片描述

证明:最短路径的求解过程会一直循环在负圈中

相反的情况,给出引理2:

引理2:如果图G不存在负圈,那么一定存在一条从s到v的最短简单路径,长度为n-1

在这里插入图片描述

证明:假设存在一个0圈或正圈,我们可以通过去除这个圈来获得更短的路径

因此解决所有情况下的单源最短路问题可以被分成两个子问题:

  • 没有负圈情况下的最短路问题:给定一个加权有向图 G = ( V , E ) G=(V,E) G=(V,E),以及起始节点s,找到所有顶点到s的最短路。
  • 负圈检测问题:给定一个加权有向图 G = ( V , E ) G=(V,E) G=(V,E),检测G中是否含有负圈。

4.1 无负圈的最短路算法

该算法是一个动态规划算法,我们定义 O P T ( i , v ) OPT(i,v) OPT(i,v)是从s到v的最多包含i条边的最短路径。对于所有顶点v,只需要找到 O P T ( n − 1 , v ) OPT(n-1,v) OPT(n1,v),就可以解决问题(引理2:如果图G不存在负圈,那么一定存在一条从s到v的最短简单路径)。

定义该问题的Bellman方程:(u,v)代表第i条边。
O P T ( i , v ) = { 0 i = 0 , v = s ∞ i = 0 , v ≠ s O P T ( i − 1 , v ) i > 0 , ( u , v ) ∉ E m i n ( u , v ) ∈ E { O P T ( i − 1 , u ) + l u v } i > 0 , ( u , v ) ∈ E OPT(i,v)=\left\{\begin{matrix} 0 &i=0,v=s\\ \infty & i=0,v\neq s\\ OPT(i-1,v) & i>0,(u,v)\notin E\\ \underset{(u,v)\in E}{min}\{OPT(i-1,u)+l_{uv}\} & i>0,(u,v)\in E \end{matrix}\right. OPT(i,v)=0OPT(i1,v)(u,v)Emin{OPT(i1,u)+luv}i=0,v=si=0,v=si>0,(u,v)/Ei>0,(u,v)E
我们用一个二维数组存储OPT的值,对图中的每条边进行V-1次松弛操作,动态规划算法伪代码如下:

在这里插入图片描述

以下图为例:当我们想要计算OPT(3,a)时,查看a节点,指向a节点的边只有b->a与s->a,M[3,a]=min{M[2,b]+w(b,a),M[2,a]}=-4。

由于每次更新OPT值只有i-1行的数据有意义,我们可以把2维数组简化为1维数组从而节省空间,一维数组d[]保存:目前为止我们发现的从s->v的最短路径。

另外:我们可以通过设置一个bool变量,表示d[]是否有更新,如果没有更新就结束最外层的循环来缩减运算时间,4.4节的实现就给出的更优化的Bellman-Ford算法。

4.2 正确性分析

由之前的分析,我们可以总结两条引理:

引理3:d[v]是单调不增加的

引理4:通过i次循环后,d[v]≤任意s↝v最多使用i条边路径的长度

对于引理4,使用归纳假设法证明:

在这里插入图片描述

Bellman-Ford算法对于无负圈的最短路算法的正确性依赖于引理2+引理4

无负圈的最短路算法算法经过V-1次循环,每次循环对于所有的边进行一松弛操作。因此时间复杂度为 O ( V E ) O(VE) O(VE),空间复杂度为 Θ ( n ) \Theta(n) Θ(n)

4.3 检测负圈

检测负圈依赖于引理5:

引理5:对于所有的顶点,如果OPT(n,v)=OPT(n-1,v),则图中没有负圈。

证明引理5的思路是反证法,假设我们能找到一个最短路径,根据引理1,图中一定不含有负圈,而找到最短路径的标志就是OPT(n,v)=OPT(n-1,v),直观的来说就是OPT收敛到一个值,该值就是我们要求的最短路,下面是具体的证明过程:

为了考虑更周到,我们还要给出引理6的证明,

引理6:如果对于n个节点,有OPT(n,v)<OPT(n-1,v),那么这些顶点最多包含n条边的最短路径上一定包含负圈。

  • 因为OPT(n,v)<OPT(n-1,v),任何从s到v的最短路径P至少含有n条边。

  • 由鸽巢原理得,按顶点排列P,其中有一个节点x一定重复出现。

  • 设W为P中的圈

    如果W不是负圈,那么把W删除后的路径P’(含有至多n-1条边)一定有更小的权重,代表着OPT(n,v)>=OPT(n-1,v),这与前提相矛盾,因此该圈一定是一个负圈。

    在这里插入图片描述

4.4 完整的Bellman-Ford算法

完整的Bellman-Ford算法包括两个部分:

  • 解决无负圈情况下的有向加权图的单源最短路问题
  • 检测图是否含有负圈,如果有:该图没有最短路。

算法伪代码如下:其中检测负圈时d[v]代表OPT(n-1,v),d[u]+w(u,v)代表OPT(n,v),返回true代表有负圈,false代表没负圈

该算法外层执行V-1次循环,每次循环对所有的边遍历一遍,进行松弛操作,因此时间复杂度为 O ( V E ) O(VE) O(VE)

我们定义加权边的结构体,一个d[]数组表示到出发点的最短路径,p[]数组记录该点到起始节点的最短路径的前驱节点。

#include <iostream>
#include <vector>
#include <string.h>
using namespace std;

// 定义加权边
struct edge{
    int from;
    int to;
    int weight;
};

int V,E;
edge edgeSet[100];
int d[100];
int p[100];

void shortest_path(int s){
    for(int i=0;i<V;i++) {
        d[i] = INT_MAX;//没有最短距离
        p[i] = -1;
    }
    d[s] = 0;
    while(true){
        bool update = false;
        for(int i=0;i<E;i++){
            edge e = edgeSet[i];
            if(d[e.from] != INT_MAX && d[e.to]>d[e.from]+e.weight){
                d[e.to] = d[e.from] + e.weight;
                p[e.to] = e.from;
                update = true;
            }
        }
        // 只要d[]数组有更新,就继续执行这个过程
        if(!update)
            break;
    }
}
// 检测负边的函数
bool find_negative_loop(){
    memset(d,0,sizeof(d));

    for(int i=0;i<V;i++){
        for(int j=0;j<E;j++){
            edge e = edgeSet[j];
            if(d[e.to]>d[e.from]+e.weight){
                d[e.to] = d[e.from]+e.weight;
                // 最短路径至多包含V-1条边 第V次仍然有更新,有负圈
                if(i == V-1)
                    return true;
            }

        }
    }
    return false;
}


int main(){
    cin>>V>>E;
    cout<<"V: "<<V<<" E: "<<E<<endl;
    for(int i=0;i<E;i++){
        edge e{};
        cin>>e.from>>e.to>>e.weight;
        edgeSet[i] = e;
    }
    if(find_negative_loop()){
        cout<<"exist negative loop"<<endl;
        return 0;
    }
    else{
        int start;
        cout<<"start? ";cin>>start;
        shortest_path(start);
        for(int i=0;i<V;i++){
            cout<<"shortest weight to "<<start<<" : "<<d[i]<<" p: "<<p[i]<<endl;
        }
    }
}

以该图为例:

测试数据:6 10
0 1 -3
0 4 4
0 5 2
0 3 3
1 4 6
4 2 -1
5 2 -2
3 5 13
2 1 -4
2 3 8
0

结果如图

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Sunburst7

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值