2025年信息科学与工程学院学生科协算法介绍——单源最短路

最短路问题——单源最短路

一、前置知识

1. 最短路问题引入

i. 什么是最短路问题

最短路问题(short-path problem)是网络理论解决的典型问题之一,可用来解决管路铺设、线路安装、厂区布局和设备更新等实际问题。基本内容是:若网络中的每条边都有一个数值(长度、成本、时间等),则找出两节点(通常是源节点和阱节点)之间总权和最小的路径就是最短路问题。1

用最简单的话语来说,最短路问题就是解决图中两点之间如何走路程最短的问题。

ii. 什么是单源最短路

最短路问题分为许多类别,而单源最短路是其中较为基础,也较为常见的一类问题。
单源最短路,与多源最短路相对应,是以图中的一个点作为起点,计算该点到其余所有点之间最短距离的方法。

2. 图的定义

图的定义有很多种方式,下面给出一种:

图 (graph) 是一个二元组 G = ( V ( G ) , E ( G ) ) G=(V(G), E(G)) G=(V(G),E(G))。其中 V ( G ) V(G) V(G) 是非空集,称为点集 (vertex set),对于 V V V 中的每个元素,我们称其为顶点 (vertex) 或节点 (node),简称点; E ( G ) E(G) E(G) V ( G ) V(G) V(G) 各结点之间边的集合,称为边集 (edge set)。2

简单来说,图就是由数个点和边构成的一种结构,两个点之间通过边连接。
边可分为有向边无向边,两者的区别在于有向边限定了两点之间行进的方向,而无向边没有做出限定。

无向边有向边
无向边有向边
允许的路径: 1 → 2 1\to2 12 2 → 1 2\to1 21允许的路径:仅允许 1 → 2 1\to2 12

同时,对于某些场景(例如最短路的计算),每一条边都可以被赋予一个值,使得不同的边具有不同的权重,叫做边的边权

以下就是一个完整的无向带权图(所有边都是无向边并且包含边权):
图

二、单源最短路算法

下面介绍几种常见的单源最短路计算方法:

1. Bellman-Ford

Bellman-Ford 算法是最基础,也是最经典的算法。其基本过程是:

  1. 初始化:

    创建一个大小为顶点数 ∣ V ∣ |V| V 的数组 d i s dis dis 用于记录 每一个点起始点 的距离,将起点的初始值设为 0 0 0,其余的点初始值设置为 ∞ \infin

  2. 松弛:

    对于图中的每一条边都进行一次松弛

    所谓松弛,指的是这样一个过程:

    对于一条 u → v u\to v uv 的边 e = ( u , v ) e=(u,v) e=(u,v),如果满足 d i s u + w e i g h t e < d i s v dis_{u}+weight_{e}<dis_{v} disu+weighte<disv ,则进行赋值 d i s v = d i s u + w e i g h t e dis_{v}=dis_{u}+weight_{e} disv=disu+weighte

    也就是说如果 节点 u u u 到起始点的距离 加上 e e e 的边权 仍然比 v v v 之前到起始点的距离 更近,则需要 更新 v v v 到起始点的距离

  3. 循环:

    重复 2 2 2 步操作 ∣ V ∣ − 1 |V|-1 V1 次。此时可以尝试再次重复步骤 2 2 2,如果发现还有边能够被松弛,则说明存在负权环

    (负权环:从一个顶点开始沿一条边和点都不重复的路径可以回到该点,且该路径上所有边的边权之和为负。有负权环的图不存在最短路。)

Bellman-Ford 算法的正确性可由数学归纳法推出,具体证明过程在这里省略。

参考核心代码

struct EDGE{
    int from,to,weight;
};// 这个结构体用于表示一条边
vector<EDGE> edge;// 这是可变长度数组,用于储存每一条边
int dis[10005];// 步骤 1 提到的 dis 数组
void BellmanFord() {
	int n,m,s;// n 代表总节点数,m 代表总边数,s 代表起始节点的编号
	fscanf(stdin,"%d %d %d",&n,&m,&s);
	for(int i=1;i<=n;i++){
		dis[i]=INT_MAX;// 初始化,INT_MAX 可看作正无穷
	}
	dis[s]=0;
	for(int i=0;i<m;i++){
		int from,to,weight;
		scanf("%d%d%d",&from,&to,&weight);
		edge.push_back({from,to,weight});// push_back 表示在数组最后插入数据
	}
	for(int i=1;i<n;i++){
        for(int j=0;j<edge.size();j++){
            if(dis[edge[j].from]+edge[j].weight<dis[edge[j].to])
            	dis[edge[j].to]=dis[edge[j].from]+edge[j].weight; //松弛操作
        }
    }
    // 此时 dis 中的所有值就是每个点对应的最短距离,特别的,如果此时距离仍是无穷大说明该点不可到达
}

优缺点

  • 优点:

    • 代码简单

    • 可以在存在负权边的情况下使用,可以检测负权环

  • 缺点:

    • 时间复杂度较高,为 O ( V E ) O(VE) O(VE)(其中 V V V 是图中总点数, E E E 是总边数)

2. SPFA

SPFA (Shortest Path Faster Algorithm) 算法是 Bellman-Ford 算法的一种优化。能在一定程度(随机数据)上降低时间复杂度,然而在一些特定的图中,其时间复杂度仍与 Bellman-Ford 相同。

SPFA 算法采用 队列 进行优化。

具体过程如下:

  1. 初始化:

    d i s dis dis 数组的初始化同 Bellman-Ford。

    SPFA 需要初始化一个队列,并将起始点装入,还需要初始化一个标记数组,用于记录节点是否已经在队列中(此时初始节点的值为 t r u e true true,其余节点为 f a l s e false false)。

  2. 松弛:

    松弛的时候,先从队列头拿出一个节点(并修改该点的标记为 f a l s e false false)。

    然后针对与这个点相连的所有边进行松驰。如果一条边满足松弛的条件,在松弛后,还需要检查这条边的终点是否在队列里,如果不在,就将该节点推进队列中(并修改标记)。

  3. 循环:

    循环直到队列为空。

    特别的,可以额外使用一个数组记录每一个节点进入队列的次数,如果次数大于等于总节点数 ∣ V ∣ |V| V 则说明存在负环。

参考核心代码

struct EDGE{
    int to,weight;
};
vector<EDGE> edge[10005];// 这是另外一种存边方式,edge[i] 代表这个数组里储存了起点为 i 的全部边
queue<int> q;// 队列的 STL 写法
int dis[10005];
bool in[10005];// 标记数组
void SPFA(){
	int n,m,s;
	fscanf(stdin,"%d%d%d",&n,&m,&s);
	for(int i=1;i<=n;i++){
		dis[i]=INT_MAX;
	}
	for(int i=0;i<m;i++){
		int from,to,weight;
		fscanf(stdin,"%d%d%d",&from,&to,&weight);
		edge[from].push_back({to,weight});
	}
	dis[s]=0;
    q.push(s);// 将起始点推入队列
    in[s]=1;// 改变标记
    while(!q.empty()){
        int from=q.front();// 选取当前队列中最前的一个点作为起始点
        q.pop();
        in[from]=0;// 改变标记状态
        for(int j=0;j<edge[from].size();j++){
            int to=edge[from][j].to;
            int weight=edge[from][j].weight;
            // 对于与起始点相连的每一条边遍历
            if(dis[from]+weight<dis[to]){
                dis[to]=dis[from]+weight;
                // 松弛操作
                if(!in[to]){
                    q.push(to);
                    in[to]=1;
                    // 如果该点不在队列里就推入队列,并改变标记状态
                }
            }
        }
    }
}

优缺点

  • 优点:

    • 可以在存在负权边的情况下使用,可以检测负权环
    • 平均时间复杂度较低,随机数据下表现良好
  • 缺点:

    • 最大时间复杂度仍然较高,仍为 O ( V E ) O(VE) O(VE),特殊的数据下运行十分缓慢

3. 朴素 Dijkstra

Dijkstra 可以较快的求解无负权图的单源最短路。

Dijkstra 的基本思想是贪心,流程如下:

  1. 初始化:

    初始化 d i s dis dis 数组,步骤与 Bellman-Ford 的初始化相同。还需要构建两个集合 S S S T T T,并将所有点放在集合 T T T 中。

  2. T T T 集合中取一个到起始点距离最短的点(即 d i s dis dis 最小的点),将该点加入 S S S 集合

  3. 对于刚刚取到的点,遍历与它相连的每一条边并进行松弛(即尝试更新每一个相邻点的 d i s dis dis)。

  4. 不断重复第 2、3 步,直到 T T T 集合为空。

具体的正确性证明在此省略。

参考核心代码

struct EDGE{
    int to,weight;
};
vector<EDGE> edge[100005];
int dis[100005];
bool inS[100005]={0};// 这个数组表示点是否在 S 集合中
void Dijkstra() {
	int n,m,s;
	fscanf(stdin,"%d%d%d",&n,&m,&s);
	for(int i=1;i<=n;i++){
		dis[i]=INT_MAX;
	}
	dis[s]=0;// 初始化
	for(int i=0;i<m;i++){
		int from,to,weight;
		fscanf(stdin,"%d %d %d",&from,&to,&weight);
		edge[from].push_back({to,weight});
	}
    bool hasT=1;
    while(hasT){
        int from=0;
        int mindis=INT_MAX;
        hasT=0;
        for(int i=1;i<=n;i++){
            if(!inS[i]){
                hasT=1;
                if(dis[i]<=mindis){
                    mindis=dis[i];
                    from=i;
                }
            }
        }
        if(!hasT) break;
        // 通过循环找还在 T 集合中的 dis 最小的点
        inS[from]=1;
        for(int i=0;i<edge[from].size();i++){
            int to=edge[from][i].to;
            int weight=edge[from][i].weight;
            if(dis[from]<dis[to]-weight) // 这里用了减号,主要是防止溢出
                dis[to]=dis[from]+weight;
            	//松弛操作
        }
    }
}

优缺点

  • 优点:

    • 可较为快速求出单源最短路,朴素算法时间复杂度 O ( V 2 ) O(V^2) O(V2)
  • 缺点:

    • 不可以出现负权边,否则会破坏贪心的正确性

4. 优先队列/堆优化 Dijkstra

优先队列 Dijkstra 和 堆优化 Dijkstra 是同一种优化方法。

从上面的朴素 Dijkstra 中可以看出,使用循环求最小值是一个时间复杂度的瓶颈,如果能够较快的求出最小值,将会大大优化时间复杂度。

幸运的是,这样的数据结构是存在的。

优先队列是一种特殊的数据结构,一般使用二叉堆来实现,能够做到插入元素时自动进行排序,且能够快速取出最小/最大值。

优先队列插入/取出元素的时间复杂度均为 O ( log ⁡ n ) O(\log n) O(logn),非常迅速。

我们可以使用优先队列来替代循环取最小值的步骤,只需稍微修改朴素 Dijkstra 的代码。

参考核心代码

struct EDGE{
    int to,weight;
};
vector<EDGE> edge[100005];
priority_queue<pair<int,int>,vector<pair<int,int>>,greater<pair<int,int>>> q;
// 这里使用了 STL 中的 priority_queue,即优先队列来优化。
// 尖括号里的第一项是插入优先队列中的元素类型,这里使用 pair<int,int> 作为元素。
// 使用 pair 可以将两个数值绑定在一起自动排序,类似 struct,但是 struct 需要做修改才能在优先队列里自动排序。
// pair 排序时先以第一项的值排序,第一项的值相同时按第二项排序。
// 在该代码中,pair 的第一项会存储节点的 dis 值(并且以这一项为依据排序),第二项存储这个 dis 值对应的节点编号
// 尖括号里的第二项 vector<pair<int,int>> 是默认值,但不能省略。
// 尖括号里的第三项 greater<pair<int,int>> 可以改变排序方式,将默认的从大到小改为从小到大。
int dis[100005];
bool inS[100005]={0};
void OptimizedDijkstra() {
	int n,m,s;
	fscanf(stdin,"%d%d%d",&n,&m,&s);
	for(int i=1;i<=n;i++){
		dis[i]=INT_MAX;
	}
	for(int i=0;i<m;i++){
		int from,to,weight;
		fscanf(stdin,"%d %d %d",&from,&to,&weight);
		edge[from].push_back({to,weight});
	}
	dis[s]=0;
    // 以上都和朴素 Dijkstra 相同
    q.push({0,s});
    // 向优先队列插入元素
    while(!q.empty()){
        int from=q.top().second;
        // q.top() 可以获得优先队列最前(即 dis 最小)的一个 pair。
        // 这里我们只需要编号(即 pair 的第二项),可以通过 .second 取出 pair 中的第二项。
        q.pop();
        // q.pop() 可以删除优先队列最前的元素。
        if(inS[from]) continue;
        // 在这里检查节点是不是已经在集合 S 中,如果是,就不能继续接下来的松弛。
        // 这里采用的是“懒更新”的策略,一个节点可能会多次插入队列中,但是我们只在取到它的时候才判断它在不在集合 T 中。
        inS[from]=1;
        for(int i=0;i<edge[from].size();i++){
            int to=edge[from][i].to;
            int weight=edge[from][i].weight;
            if(dis[from]<dis[to]-weight){
                dis[to]=dis[from]+weight;
                q.push({dis[to],to});
                // 将更新过的节点插入优先队列中
            }
        }
    }
}

优缺点

  • 优点:
    • 可快速求出单源最短路,时间复杂度非常优秀,为 O ( ( V + E ) log ⁡ V ) O((V+E) \log V) O((V+E)logV)
  • 缺点:
    • 不可以出现负权边,与朴素 Dijkstra 相同

三、练习题


  1. 最短路问题_百度百科 ↩︎

  2. 图论相关概念 - OI Wiki ↩︎

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值