Files
leetcode-master/problems/kama0094.城市间货物运输I-SPFA.md
programmercarl f835589049 Update
2024-04-12 10:37:17 +08:00

9.6 KiB
Raw Blame History

Bellman_ford 队列优化算法又名SPFA

卡码网: 94. 城市间货物运输 I

题目描述

某国为促进城市间经济交流,决定对货物运输提供补贴。共有 n 个编号为 1 到 n 的城市,通过道路网络连接,网络中的道路仅允许从某个城市单向通行到另一个城市,不能反向通行。

网络中的道路都有各自的运输成本和政府补贴,道路的权值计算方式为:运输成本 - 政府补贴。权值为正表示扣除了政府补贴后运输货物仍需支付的费用;权值为负则表示政府的补贴超过了支出的运输成本,实际表现为运输过程中还能赚取一定的收益。

请找出从城市 1 到城市 n 的所有可能路径中,综合政府补贴后的最低运输成本。如果最低运输成本是一个负数,它表示在遵循最优路径的情况下,运输过程中反而能够实现盈利。

城市 1 到城市 n 之间可能会出现没有路径的情况,同时保证道路网络中不存在任何负权回路。

输入描述

第一行包含两个正整数,第一个正整数 n 表示该国一共有 n 个城市,第二个整数 m 表示这些城市中共有 m 条道路。

接下来为 m 行每行包括三个整数s、t 和 v表示 s 号城市运输货物到达 t 号城市,道路权值为 v单向图

输出描述

如果能够从城市 1 到连通到城市 n 请输出一个整数,表示运输成本。如果该整数是负数,则表示实现了盈利。如果从城市 1 没有路径可达城市 n请输出 "unconnected"。

输入示例:

6 7
5 6 -2
1 2 1
5 3 1
2 5 2
2 4 -3
4 6 4
1 3 5

思路

本题我们来系统讲解 Bellman_ford 队列优化算法 也叫SPFA算法Shortest Path Faster Algorithm

SPFA的称呼来自 1994年西南交通大学段凡丁的论文其实Bellman_ford 提出后不久 20世纪50年代末期 就有队列优化的版本,国际上不承认这个算法是是国内提出的。 所以国际上一般称呼 算法为 Bellman_ford 队列优化算法Queue improved Bellman-Ford

大家知道以上来历,知道 SPFA 和 Bellman_ford 队列优化算法 指的都是一个算法就好。

如果大家还不够了解 Bellman_ford 算法,强烈建议按照《代码随想录》的顺序学习,否则可能看不懂下面的讲解。

大家可以发现 Bellman_ford 算法每次松弛 都是对所有边进行松弛。

但真正有效的松弛,是基于已经计算过的节点在做的松弛。

给大家举一个例子:

本图中,对所有边进行松弛,真正有效的松弛,只有松弛 边节点1->节点2 和 边节点1->节点5

而松弛 边节点4->节点6 节点5->节点3等等 都是无效的操作,因为 节点4 和 节点 5 都是没有被计算过的节点。

所以 Bellman_ford 算法 每次都是对所有边进行松弛,其实是多做了一些无用功。

只需要对 上一次松弛的时候更新过的节点作为出发节点所连接的边 进行松弛就够了

基于以上思路,如何记录 上次松弛的时候更新过的节点呢?

用队列来记录。

接下来来举例这个队列是如何工作的。

以示例给出的所有边为例:

5 6 -2
1 2 1
5 3 1
2 5 2
2 4 -3
4 6 4
1 3 5

我们依然使用minDist数组来表达 起点到各个节点的最短距离例如minDist[3] = 5 表示起点到达节点3 的最小距离为5

初始化起点为节点1 起点到起点的最短距离为0所以minDist[1] 为 0。 将节点1 加入队列 下次松弛送节点1开始


从队列里取出节点1松弛节点1 作为出发点链接的边节点1 -> 节点2和边节点1 -> 节点3

节点1 -> 节点2权值为1 minDist[2] > minDist[1] + 1 ,更新 minDist[2] = minDist[1] + 1 = 0 + 1 = 1 。

节点1 -> 节点3权值为5 minDist[3] > minDist[1] + 5更新 minDist[3] = minDist[1] + 5 = 0 + 5 = 5。

将节点2节点3 加入队列,如图:


从队列里取出节点2松弛节点2 作为出发点链接的边节点2 -> 节点4和边节点2 -> 节点5

节点2 -> 节点4权值为1 minDist[4] > minDist[2] + (-3) ,更新 minDist[4] = minDist[2] + (-3) = 1 + (-3) = -2 。

节点2 -> 节点5权值为2 minDist[5] > minDist[2] + 2 ,更新 minDist[5] = minDist[2] + 2 = 1 + 2 = 3 。

将节点4节点5 加入队列,如图:


从队列里出去节点3松弛节点3 作为出发点链接的边。

因为没有从节点3作为出发点的边所以这里就从队列里取出节点3就好不用做其他操作如图


从队列中取出节点4松弛节点4作为出发点链接的边节点4 -> 节点6

节点4 -> 节点6权值为4 minDist[6] > minDist[4] + 4更新 minDist[6] = minDist[4] + 4 = -2 + 4 = 2 。

讲节点6加入队列

如图:


从队列中取出节点5松弛节点5作为出发点链接的边节点5 -> 节点3节点5 -> 节点6

节点5 -> 节点3权值为1 minDist[3] > minDist[5] + 1 ,更新 minDist[3] = minDist[5] + 1 = 3 + 1 = 4

节点5 -> 节点6权值为-2 minDist[6] > minDist[5] + (-2) ,更新 minDist[6] = minDist[5] + (-2) = 3 - 2 = 1

如图:

因为节点3和 节点6 都曾经加入过队列,不用重复加入,避免重复计算。


从队列中取出节点6松弛节点6 作为出发点链接的边。

节点6作为终点没有可以出发的边。

所以直接从队列中取出,如图:


这样我们就完成了基于队列优化的bellman_ford的算法模拟过程。

大家可以发现 基于队列优化的算法要比bellman_ford 算法 减少很多无用的松弛情况,特别是对于边树众多的大图 优化效果明显。

了解了大体流程,我们再看代码应该怎么写。

在上面模拟过程中,我们每次都要知道 一个节点作为出发点 链接了哪些节点。

如果想方便这道这些数据,就需要使用邻接表来存储这个图,如果对于邻接表不了解的话,可以看 kama0047.参会dijkstra堆 中 图的存储 部分。

代码如下:

#include <iostream>
#include <vector>
#include <queue>
#include <list>
#include <climits>
using namespace std;

struct Edge { //邻接表
    int to;  // 链接的节点
    int val; // 边的权重

    Edge(int t, int w): to(t), val(w) {}  // 构造函数
};


int main() {
    int n, m, p1, p2, val;
    cin >> n >> m;

    vector<list<Edge>> grid(n + 1); // 邻接表

    // 将所有边保存起来
    for(int i = 0; i < m; i++){
        cin >> p1 >> p2 >> val;
        // p1 指向 p2权值为 val
        grid[p1].push_back(Edge(p2, val));
    }
    int start = 1;  // 起点
    int end = n;    // 终点

    vector<int> minDist(n + 1 , INT_MAX);
    minDist[start] = 0;

    queue<int> que;
    que.push(start);
    int que_size;
    while (!que.empty()) {
        // 注意这个数组放的位置
        vector<bool> visited(n + 1, false); // 可加,可不加,加了效率高一些,防止队列里重复访问,其数值已经算过了
        que_size = que.size();

        int node = que.front(); que.pop();

        for (Edge edge : grid[node]) {
            int from = node;
            int to = edge.to;
            int price = edge.val;
            if (minDist[to] > minDist[from] + price) { // 开始松弛
                minDist[to] = minDist[from] + price;
                if(visited[to]) continue; // 节点不用重复放入队列,但节点需要重复计算,所以放在这里位置
                visited[to] = true;
                que.push(to); 
            }
        }

    }

    if (minDist[end] == INT_MAX) cout << "unconnected" << endl; // 不能到达终点
    else cout << minDist[end] << endl; // 到达终点最短路径
}

代码中有一点需要注意,即 if(visited[to]) continue; 这段代码放的位置。

一些录友可能写成这样:

if (minDist[to] > minDist[from] + price) { // 开始松弛
    if(visited[to]) continue; 
    minDist[to] = minDist[from] + price;
    visited[to] = true;
    que.push(to); 
}

这是不对了,我们仅仅是控制节点不用重复加入队列,但对于边的松弛,节点数值的更新,是要重复计算的,要不然如何 不断更新最短路径呢?

所以 if(visited[to]) continue; 应该放在这里:

if (minDist[to] > minDist[from] + price) { // 开始松弛
    minDist[to] = minDist[from] + price;
    if(visited[to]) continue;  // 仅仅控制节点不要重复加入队列
    visited[to] = true;
    que.push(to); 
}

拓展

关于 加visited 方式节点重复方便,可能也有录友认为,加上 visited 也是防止 如果图中出现了环的话,会导致的 队列里一直不为空。