最短路问题

可以分为两类:

  • 单元最短路

    求从一个点到其他所有点的最短距离,如从1号点到n号点的最短路

  • 多源汇最短路

    起点和终点数量不确定

n表示图中点的数量,m表示图中边的数量,一般m~\(n^2\)是稠密图

朴素Dijkstra适合稠密图,如边数比较多和\(n^2\)一个级别用朴素Dijkstra

m和n是一个级别,堆优化版比较好

SPFA是Bellman-Ford算法的一个优化,但是在某些时候SPFA是不能使用的,如对边数进行一个限制,经过不超过k条边就只能用Bellman-Ford

最短路不会让你去证明算法的正确性,最短路的难点在于如何把原问题抽象成最短路问题,如何定义我们的点和边使得这个问题变成一个最短路问题,从而套用公式去解决。难点在于建图。

Dijkstra基于贪心,Floyd基于动态规划,Bellman-Ford基于离散数学。

1、Dijkstra算法

一定不能存在负权边

伪代码:

  1. 初始化距离:dis[1] = 0,dis[others] = $ +\infty $。

  2. 集合s:存储当前已经确定最短路的点

  3. for(int i = 0;i < n; i++) {

    $t \gets $找到不在s中的距离最近的点

    \(s \gets to\)

    用t更新其他点的距离。更新方式:看从t出去的所有的边,组成的路径能不能更新其他点的距离。如下图:

    如果从1到x的距离大于从1到t再到x的距离,就用dis[t]+w代替1到x的距离。如下图:

    初始状态:

    ①②③ 上的数字表示距离起点的距离,红色表示待定,绿色表示确定已经放入了集合s。

    第一个点更新:

    第二个点更新:

    第三个点更新:

    }

1.1 Dijkstra练习

1.2 朴素Dijkstra算法解答

存在重边只保留最短的那条边就可以了

解答:if (!st[j] && (t == -1 || dist[t] > dist[j]))

#include <cstring>
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 510;

int n, m;
int g[N][N];
int dist[N]; // 从1号点走到其他每个点当前最短距离
bool st[N];

int dijkstra()
{
    memset(dist, 0x3f, sizeof dist);
    dist[1] = 0;

    for (int i = 0; i < n - 1; i ++ )
    {
        int t = -1; // 初始还没有确定点的时候
        for (int j = 1; j <= n; j ++ )
            // 这个循环找的就是所有st[j]=false即距离还没确定的点
            // 在这些点中找到距离最小的点
            if (!st[j] && (t == -1 || dist[t] > dist[j]))
                t = j;

        for (int j = 1; j <= n; j ++ )
            dist[j] = min(dist[j], dist[t] + g[t][j]);

        st[t] = true;
    }

    if (dist[n] == 0x3f3f3f3f) return -1;
    return dist[n];
}

int main()
{
    scanf("%d%d", &n, &m);

    memset(g, 0x3f, sizeof g);
    while (m -- )
    {
        int a, b, c;
        scanf("%d%d%d", &a, &b, &c);

        g[a][b] = min(g[a][b], c); // 取min是可能存在重边
    }

    printf("%d\n", dijkstra());

    return 0;
}
1.3堆优化Dijkstra算法解答

考虑如何优化:

因为t从1~n每次都是不同的点,所以用t更新其他点的距离这个操作就是遍历从t出去所有边,遍历n次即遍历所有点以后就是遍历了所有边,所以计算了m次

可以发现最慢的就是找最小距离这步,复杂度O(\(n^2\))。在一堆数中找最小的数就可以用堆来找。

堆中修改一个数O(\(log^n\))。

堆优化之后:

可以手写堆也可以用STL的priority_queue。

priority_queue使用的是冗余的思想,替换一个点的值实际是插入了一个新节点,所以原图有n个点但priority_queue里可能有m个点。待查证:priority_queue的替换实现实质。

#include <cstring>
#include <iostream>
#include <algorithm>
#include <queue>

using namespace std;

typedef pair<int, int> PII; // 表示编号和距离

const int N = 1e6 + 10;

int n, m;
int h[N], w[N], e[N], ne[N], idx;
int dist[N];
bool st[N];

void add(int a, int b, int c)
{
    e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx ++ ;
}

int dijkstra()
{
    memset(dist, 0x3f, sizeof dist);
    dist[1] = 0;
    priority_queue<PII, vector<PII>, greater<PII>> heap;
    heap.push({0, 1}); // 距离是0编号是1

    while (heap.size())
    {
        auto t = heap.top(); // 最多有m个点,因为一共只有m条边,当前距离最小的点即堆的起点
        heap.pop();

        int ver = t.second, distance = t.first; // ver编号,dis距离

        if (st[ver]) continue; // 之前这个点出来过了,说明这个点是冗余备份的,跳过就可以
        st[ver] = true;

        for (int i = h[ver]; i != -1; i = ne[i])
        {
            int j = e[i];
            if (dist[j] > dist[ver] + w[i])
            {
                dist[j] = dist[ver] + w[i];
                heap.push({dist[j], j});
            }
        }
    }

    if (dist[n] == 0x3f3f3f3f) return -1;
    return dist[n];
}

int main()
{
    scanf("%d%d", &n, &m);

    memset(h, -1, sizeof h);
    while (m -- )
    {
        int a, b, c;
        scanf("%d%d%d", &a, &b, &c);
        add(a, b, c);
    }

    cout << dijkstra() << endl;

    return 0;
}

2、Bellman-Ford算法

存储边的方法任意,只要能让我找到这条边就行,开个结构体数组存储都可以。

伪代码:

  • for 1~n{ // 迭代几次就意味着最多不超过几条边

    for 所有边a,b,w(左右端点和权重)

    ​ dist[b] = min(dist[b],dist[a]+w); // 这步叫做松弛操作

    }

循环之后所有边一定满足dist[b]\(\le\)dist[a]+w(三角不等式)。

如果有负权回路,最短路不一定存在,如图:

求从1到5的最短距离,那么从1到2,转一圈距离就减少1,那么我们无限转下去,距离就会无限减少。

所以如果存在负权回路,最短路就不一定存在了。

所以Bellman-Ford算法可以用来判断是否存在负权回路。

如当前迭代了k次,那么求的最短距离的dist数组的含义就是:从1号点经过不超过k条边走到每个点的最短距离。

如果我们走到第n次迭代又更新了某些边,就说明存在一条最短路径,它上面边的数量是\(\ge n\),n条边就意味着有n+1个点,但实际只有n个点,所以一定有两个点重复了,即一定存在环。路径上存在环一定是第n次把第k次的更新了,说明第n次求得的距离比第k次已经确定的更短了,说明这个环一定是负环。但一般都会用SPFA去找负环。

2.1 Bellman-Ford练习

2.2 Bellman-Ford算法解答

串联问题:如下图:

k = 1即从1号点到3号点的路径不能超过一条。所以最短路的长度是3

而在Bellman-Ford算法中,如果我们不引入备份last[]数组,那么在更新枚举所有边的时候,可能发生串联。

dist数组的初始状态是:

  • dist[1] = 0,dist[2]=\(+\infty\),dist[3] =\(+\infty\)

比如我们第一次枚举的边是\(1 \to 2\),那么:

  • dist[1] = 0,dist[2]=1,dist[3] =\(+\infty\)

因为我们是枚举所有边,那么dist[3]也会被更新,因为dist[2]变成了1而不再是\(\infty\)

  • dist[1] = 0,dist[2]=1,dist[3] =2。

for 所有边:

​ dist[e.b] = min(dist[e.b], dist[e.a] + e.c)。

这时dist[]是被更改的,可能发生串联。

所以我们要引入备份数组:dist[e.b] = min(dist[e.b], last[e.a] + e.c);

这样对于dist[3]来说,\(+\infty+1 > +\infty\),所以dist[3]不会被更新。

  • dist[1] = 0,dist[2]=1,dist[3] =\(+\infty\)
#include <cstring>
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 510, M = 10010;

struct Edge
{
    int a, b, c;
}edges[M]; // 结构体数组存储所有边

int n, m, k;
int dist[N];
int last[N];

void bellman_ford()
{
    memset(dist, 0x3f, sizeof dist);

    dist[1] = 0;
    for (int i = 0; i < k; i ++ )
    {
        // 每次操作前备份一次,last存储上一次迭代结果,防止串联
        // 串联即我先更新了b,又用b来更新其他点,如本来b->c距离10
        // 我用更新后的b更新了b->c,值变为5,会对其他运算造成影响
        memcpy(last, dist, sizeof dist); 
        for (int j = 0; j < m; j ++ )
        {
            auto e = edges[j];
            dist[e.b] = min(dist[e.b], last[e.a] + e.c); // 这里是用的上次结果更新就是避免串联
        }
    }
}

int main()
{
    scanf("%d%d%d", &n, &m, &k);

    for (int i = 0; i < m; i ++ )
    {
        int a, b, c;
        scanf("%d%d%d", &a, &b, &c);
        edges[i] = {a, b, c};
    }

    bellman_ford();

    if (dist[n] > 0x3f3f3f3f / 2) puts("impossible"); // 这里0x3f/2是因为存在负权重,可能有的无穷被更新了,如从9999变成了9997
    else printf("%d\n", dist[n]);

    return 0;
}

3、SPFA算法

SPFA就是对Bellman-Ford进行了优化。

只要没有负环就可以用SPFA,比如DIjkstra的题目。

每次迭代时都对所有边进行了更新:

​ for 所有边a,b,w(左右端点和权重)

​ dist[b] = min(dist[b],dist[a]+w);

但实际每次迭代不一定每条边都会更新,这里就可以去做优化了。

每次迭代dist[b]变小一定是dist[a]变小了,这就可以用宽搜做优化:迭代时用一个queue来做。queue里就是所有便小的a。

  • queue \(\gets\)起点
  • while queue不空:
    1. 取出队头$t \gets $队头,queue.pop()删除队头
    2. 更新t的所有出边,如果更新成功就把该点加入队列
2.1 解答
#include <cstring>
#include <iostream>
#include <algorithm>
#include <queue>

using namespace std;

const int N = 100010;

int n, m;
int h[N], w[N], e[N], ne[N], idx;
int dist[N];
bool st[N];

void add(int a, int b, int c)
{
    e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx ++ ;
}

int spfa()
{
    memset(dist, 0x3f, sizeof dist);
    dist[1] = 0;

    queue<int> q;
    q.push(1);
    st[1] = true; // st存储当前点是否在队列中,重复点是没意义的

    while (q.size()) // 不空
    {
        int t = q.front();
        q.pop();

        st[t] = false;

        for (int i = h[t]; i != -1; i = ne[i])
        {
            int j = e[i];
            if (dist[j] > dist[t] + w[i])
            {
                dist[j] = dist[t] + w[i];
                if (!st[j])
                {
                    q.push(j);
                    st[j] = true;
                }
            }
        }
    }

    return dist[n];
}

int main()
{
    scanf("%d%d", &n, &m);

    memset(h, -1, sizeof h);

    while (m -- )
    {
        int a, b, c;
        scanf("%d%d%d", &a, &b, &c);
        add(a, b, c);
    }

    int t = spfa();

    if (t == 0x3f3f3f3f) puts("impossible");
    else printf("%d\n", t);

    return 0;
}
2.2 求负环

如何用SPFA判断负环呢?

很简单,再开一个cnt数组存储路径长度。

如果cnt[x]\(\ge\)n,就意味着从1~x至少n条边,说明至少n+1个点,说明有2个点是相同的。路径上这个环不是白白存在的,他存在就一定会把dist[]变小,要不我根本就不会走这条边,因为我们是在找最短路。

2.3 求负环题目

2.4 解答
#include <cstring>
#include <iostream>
#include <algorithm>
#include <queue>

using namespace std;

const int N = 2010, M = 10010;

int n, m;
int h[N], w[M], e[M], ne[M], idx;
int dist[N], cnt[N]; // 分别存储1-x的最短距离,当前最短路边的数量
// cnt[x] >= n说明一定存在两个点一样,即负环
// 这个环不是白存在的,他一定会变小,如果不变小就不会存在这个环了
// 我们在找最短距离,不变小就应该去下个点,所以这个环就是负权的
bool st[N];

void add(int a, int b, int c)
{
    e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx ++ ;
}

bool spfa()
{
    queue<int> q;

    // 所有点放进队列,因为可能存在负环,但1这个点不经过
    for (int i = 1; i <= n; i ++ )
    {
        st[i] = true;
        q.push(i);
    }

    while (q.size())
    {
        int t = q.front();
        q.pop();

        st[t] = false;

        for (int i = h[t]; i != -1; i = ne[i])
        {
            int j = e[i];
            if (dist[j] > dist[t] + w[i])
            {
                dist[j] = dist[t] + w[i];
                cnt[j] = cnt[t] + 1;

                if (cnt[j] >= n) return true;
                if (!st[j])
                {
                    q.push(j);
                    st[j] = true;
                }
            }
        }
    }

    return false;
}

int main()
{
    scanf("%d%d", &n, &m);

    memset(h, -1, sizeof h);

    while (m -- )
    {
        int a, b, c;
        scanf("%d%d%d", &a, &b, &c);
        add(a, b, c);
    }

    if (spfa()) puts("Yes");
    else puts("No");

    return 0;
}

4、Floyd算法

可以处理负权,但是不能有负权回路。

基于动态规划,d[k,i,j]表示从i这个点出发只经过1~k这些中间点到达j的最短距离。更新的话首先枚举k,因为k是阶段,比如我们想算k,那么要先从k-1转移过来,d[k,i,j] = d[k-1,i,k]表示从i到k只经过k-1条边+d[k-1,k,j]从k到j只经过k-1条边。第一维没什么用,可以去掉,最后就是d[i,j] = d[i,k]+d[k,j]。

初始:邻接矩阵d[i,j]存储所有的边

伪代码:

for(int k = 0; k < n; k++)

​ for(int i = 0; i < n; i++)

​ for(int j = 0; j < n; j++)

​ d[i,j] = min(d[i,j]d[i,k]+d[k,j])

结束后d[i,j]存储从i到j的最短路的长度

4.1 练手题目

4.2 解答
#include <cstring>
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 210, INF = 1e9;

int n, m, Q;
int d[N][N];

void floyd()
{
    for (int k = 1; k <= n; k ++ )
        for (int i = 1; i <= n; i ++ )
            for (int j = 1; j <= n; j ++ )
                d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
}

int main()
{
    scanf("%d%d%d", &n, &m, &Q);

    for (int i = 1; i <= n; i ++ )
        for (int j = 1; j <= n; j ++ )
            if (i == j) d[i][j] = 0;
            else d[i][j] = INF;

    while (m -- )
    {
        int a, b, c;
        scanf("%d%d%d", &a, &b, &c);
        d[a][b] = min(d[a][b], c);
    }

    floyd();

    while (Q -- )
    {
        int a, b;
        scanf("%d%d", &a, &b);

        int t = d[a][b];
        if (t > INF / 2) puts("impossible"); // 因为存在负权边,如果a,b之间不存在通路那么可能不是正无穷,会比正无穷小一些
        else printf("%d\n", t);
    }

    return 0;
}

5、模板

5.1 朴素dijkstra算法

时间复杂是 O(n2+m)O(n2+m), nn 表示点数,mm 表示边数

int g[N][N];  // 存储每条边
int dist[N];  // 存储1号点到每个点的最短距离
bool st[N];   // 存储每个点的最短路是否已经确定

// 求1号点到n号点的最短路,如果不存在则返回-1
int dijkstra()
{
    memset(dist, 0x3f, sizeof dist);
    dist[1] = 0;

    for (int i = 0; i < n - 1; i ++ )
    {
        int t = -1;     // 在还未确定最短路的点中,寻找距离最小的点
        for (int j = 1; j <= n; j ++ )
            if (!st[j] && (t == -1 || dist[t] > dist[j]))
                t = j;

        // 用t更新其他点的距离
        for (int j = 1; j <= n; j ++ )
            dist[j] = min(dist[j], dist[t] + g[t][j]);

        st[t] = true;
    }

    if (dist[n] == 0x3f3f3f3f) return -1;
    return dist[n];
}
5.2 堆优化版dijkstra

时间复杂度 O(mlogn)O(mlogn), nn 表示点数,mm 表示边数

typedef pair<int, int> PII;

int n;      // 点的数量
int h[N], w[N], e[N], ne[N], idx;       // 邻接表存储所有边
int dist[N];        // 存储所有点到1号点的距离
bool st[N];     // 存储每个点的最短距离是否已确定

// 求1号点到n号点的最短距离,如果不存在,则返回-1
int dijkstra()
{
    memset(dist, 0x3f, sizeof dist);
    dist[1] = 0;
    priority_queue<PII, vector<PII>, greater<PII>> heap;
    heap.push({0, 1});      // first存储距离,second存储节点编号

    while (heap.size())
    {
        auto t = heap.top();
        heap.pop();

        int ver = t.second, distance = t.first;

        if (st[ver]) continue;
        st[ver] = true;

        for (int i = h[ver]; i != -1; i = ne[i])
        {
            int j = e[i];
            if (dist[j] > distance + w[i])
            {
                dist[j] = distance + w[i];
                heap.push({dist[j], j});
            }
        }
    }

    if (dist[n] == 0x3f3f3f3f) return -1;
    return dist[n];
}

5.3 Bellman-Ford算法

时间复杂度 O(nm)O(nm), nn 表示点数,mm 表示边数

int n, m;       // n表示点数,m表示边数
int dist[N];        // dist[x]存储1到x的最短路距离

struct Edge     // 边,a表示出点,b表示入点,w表示边的权重
{
    int a, b, w;
}edges[M];

// 求1到n的最短路距离,如果无法从1走到n,则返回-1。
int bellman_ford()
{
    memset(dist, 0x3f, sizeof dist);
    dist[1] = 0;

    // 如果第n次迭代仍然会松弛三角不等式,就说明存在一条长度是n+1的最短路径,由抽屉原理,路径中至少存在两个相同的点,说明图中存在负权回路。
    for (int i = 0; i < n; i ++ )
    {
        for (int j = 0; j < m; j ++ )
        {
            int a = edges[j].a, b = edges[j].b, w = edges[j].w;
            if (dist[b] > dist[a] + w)
                dist[b] = dist[a] + w;
        }
    }

    if (dist[n] > 0x3f3f3f3f / 2) return -1;
    return dist[n];
}

5.4 spfa算法(队列优化的Bellman-Ford算法)

时间复杂度 平均情况下 O(m)O(m),最坏情况下 O(nm)O(nm), nn 表示点数,mm 表示边数

int n;      // 总点数
int h[N], w[N], e[N], ne[N], idx;       // 邻接表存储所有边
int dist[N];        // 存储每个点到1号点的最短距离
bool st[N];     // 存储每个点是否在队列中

// 求1号点到n号点的最短路距离,如果从1号点无法走到n号点则返回-1
int spfa()
{
    memset(dist, 0x3f, sizeof dist);
    dist[1] = 0;

    queue<int> q;
    q.push(1);
    st[1] = true;

    while (q.size())
    {
        auto t = q.front();
        q.pop();

        st[t] = false;

        for (int i = h[t]; i != -1; i = ne[i])
        {
            int j = e[i];
            if (dist[j] > dist[t] + w[i])
            {
                dist[j] = dist[t] + w[i];
                if (!st[j])     // 如果队列中已存在j,则不需要将j重复插入
                {
                    q.push(j);
                    st[j] = true;
                }
            }
        }
    }

    if (dist[n] == 0x3f3f3f3f) return -1;
    return dist[n];
}

5、最短路总结

分类 算法名称 适用场景 时间复杂度
单源最短路 朴素Dijkstra算法 稠密图,且不存在负权边 $O(n^2)$
堆优化Dijkstra算法 稀疏图,且不存在负权边 $O(mlog^n)$
Bellman-Ford算法 存在负权,但一般都用SPFA O(nm)
SPFA算法 存在负权,是Bellman的优化,但有些情况用不了 一般O(m)最坏O(nm)
多源最短路 Floyd算法 多源情况下 $O(n^3)$
posted @ 2021-04-04 13:28  晓尘  阅读(227)  评论(0)    收藏  举报