图论--最短路问题总结

图论–最短路问题

1.png

其中求最短路常用的算法有上面几种, 根据题目给出的相关限制条件来选选择对应的算法,例如Dijkstra算法是不能处理负边权的情况,朴素版本Dijkstra和堆优化版本的Dijkstra,并不是朴素版就比优化版差,当图为稠密图时偏向于使用朴素版本的,因为当 m > n 2 n^2 n2 时,此时堆优化版本相比朴素版本时间复杂度高。计算存在负边权时的两种算法,spfa是对bellman—ford算法的改进,但bellman-ford代码实现比较简单。对于多源汇合最短路,这里只有一个Floyd算法,其算法原理基于dp。下面具体讲解一下上面的算法,及其代码实现.

Dijkstra算法

Dijkstra算法用于单源最短路问题,且用于的情况是边权值都是正数的情况下,常见的问题为从1号节点到n号节点的最短距离, Dijkstra算法又分为朴素Dijkstra算法和堆优化版的Dijkstra算法 。

Dijkstra算法大致思路:主要特点是以起始点为中心向外层层扩展,直到扩展到终点为止.故首先从未遍历的点中找出一个距离起点最近的一个点, 然后比较从目前遍历的点到其下个点的距离 + 自身到起点的距离 与 下一个点目前到起点的距离决定是否更新, 由于其每次是从当前距离起点最近的点到其连接下一个点的距离来判断是否进行距离的更新,因为每次拿来更新的点都是先找出当前未遍历的点中距离起点最近的那个点,故用这个点更新其连接的下一个点到起点的距离,若满足更新条件那么被更新的点是目前遍历情况下该点的距离起点一定是最小的,故当遍历完所有的点后,即可以在d[n]得到正确的答案, 具体见下图. 其中d[] 数组表示: d[x]: 从1 ~ x 的最小距离
1.png

1.朴素Dijkstra算法实现 时间复杂度 O( n 2 n^2 n2)

练习的链接 : Dijkstra求最短路 I

#include<iostream>
#include<cstring>
using namespace std;

constexpr int N = 510;
constexpr int INF = 0x3f3f3f3f;

int n, m, g[N][N], d[N];    // 通过题目信息判断, 此时 n 最多为500, 而m最多为10^5.属于稠密图, 故这里用邻接矩阵存储比较好。
bool st[N];   // 保存某个节点是否被遍历过

auto dijkstra()
{
    d[1] = 0;           // 更新第一个点到起点的距离为0
    
    for(int i = 1; i <= n; ++i)       // 遍历所有节点    
    {
        int t = -1;
        for(int j = 1; j <= n; ++j)     // 找出目前剩余未被遍历节点中距离起点最小的节点
            if(!st[j] && (t == -1 || d[t] > d[j])) t = j;
        
        st[t] = true;            // 将该节点更新为已遍历状态
         
        for(int k = 1; k <= n; ++k)     // 更新距离 
            d[k] = min(d[k], d[t] + g[t][k]);
    }
    
    // 时间复杂度分析, 首先上面的大for循环为n , 然后里面看成2n, 故时间复杂度就是n2
    
    if(d[n] == INF) cout << -1 << endl;   // 若d[n] 未被更新则说明不存在到n号节点的路径
    else cout << d[n] << endl;
}

auto main() -> int
{
    memset(g, 0x3f, sizeof g);    // g邻接矩阵保存的信息为两条边之间的距离,初始化为一个很大的数
    memset(d, 0x3f, sizeof d);    // 同样初始化每个节点到起点1号节点的距离为一个很大的数
    cin >> n >> m;
    
    while(m--)      // 执行m次操作
    {
        int a, b, c; cin >> a >> b >> c;
        g[a][b] = min(g[a][b], c);      // 此时如果有多条重边,只保留一条最短距离的边
    }
    
    dijkstra();
    
    return 0;
}

2.堆优化的Dijkstra算法实现 O( m l o g n mlogn mlogn)

优化的思路,首先我们从上面的朴素做法可以看出,我们在找其中未被遍历且距离起点最短的点时间复杂度我们看为On, 故这里可以进行优化,使用小顶堆来保持,故此时找出距离最小的点的时间复杂度为O(1);然后对这个点的每一条边进行遍历,然后判断是否更新,若需要更新,则插入,堆的数据结构插入的时间复杂度为O( l o g n logn logn); 故整个过程最坏的复杂度就是 m l o g n mlogn mlogn; 但我们这里实现一般都是使用C++里的优先队列,故每次插入一个数而不是修改某个数,故造成堆里面最坏有m个数字,故最坏的时间复杂度为 m l o g m mlogm mlogm

练习链接: Dijkstra求最短路 II

#include<iostream>
#include<queue>
#include<cstring>
using namespace std;
using T = pair<int,int>;

constexpr int N = 1e+6, INF = 0x3f3f3f3f;
int n, m, e[N],w[N], ne[N], d[N], g[N], idx;    // n, m 都是 $10^5$级别的, 故这里看作稀疏图,采用邻接表来存储整个图
bool st[N];

auto add(int a, int b, int c)   // 邻接表来存储图需要进行插入操作,这里用w数组保存每一条边的权值
{
    e[idx] = b;
    ne[idx] = g[a], w[idx] = c, g[a] = idx++;
}

auto dijkstra()
{
    priority_queue<T, vector<T>, greater<T> > q;    // 堆优化,采用优先队列,并以小顶堆的方式进行存储
    d[1] = 0;
    q.emplace(0, 1);
    while(q.size())
    {
        auto item = q.top(); q.pop();
        int dist = item.first;
        int node = item.second;
        if(st[node]) continue;
        st[node] = true;
        for(int i = g[node]; i != -1; i = ne[i])    // 判断与目前遍历点其连接的点是否需要更新
        {
            int t = e[i];
            if(d[t] > dist + w[i])
            {
                d[t] = dist + w[i];
                q.emplace(d[t], t);       // 更新完一个点则入堆,表示未被遍历且距离得到更新
            }
        }
    }
    
    if(d[n] == INF) cout << -1 << endl;
    else cout << d[n] << endl;
}

auto main() -> int
{
    memset(g, -1, sizeof g);
    memset(d, 0x3f, sizeof d);
    cin >> n >> m;
    while(m--)
    {
        int a, b, c; cin >> a >> b >>c;
        add(a,b,c);
    }
    dijkstra();
    return 0;
}

3.Dijkstra 算法不能用于负权边的情况

对于某些带负权边的情况,可能输出正确的结果,但某些情况是会输出错误的结果的,如下图的情况分析:
在这里插入图片描述

Bellman-Ford 算法

上面分析Dijkstra算法的时候,解释了为什么存在负权边的情况下不能用Dijkstra算法, 因为Dijkstra每次从堆顶取出的点都是距离起点最近的点,是基于贪心实现的,若存在负边权,虽然从堆顶出的点是距离起点最近的,但可能存在一条路径此时某个节点距离起点距离过大,只能在后面弹出,但该路径存在负权边,故这就造成了这个节点只能在其他节点遍历完毕之后再出堆,但此时这条路径已经不能继续遍历了,因为有的节点已经给标记为遍历状态,带来的结果就是,Dijkstra算法无法再确定从起点到这一点的最小距离,因为总可能有像这样未被遍历的点存在。故bellman_ford算法相比于Dijkstra算法就是没有了标记数组,直接每次都遍历所有的边,一直更新,即可重复遍历某个节点。

例题 : 853. 有边数限制的最短路

代码实现

#include<iostream>
#include<cstring>
using namespace std;

constexpr int N = 1e+6, INF = 0x3f3f3f3f;
int n, m,k, d[N], temp[N];

struct 
{
    int a, b, w;  // 直接存储每条边的信息便于遍历,因为全部都要遍历一遍,故这里直接结构体比邻接表方便
}edge[N];

auto bellman_ford()
{
    d[1] = 0;
    for(int i = 1; i <= k; ++i)   // 遍历k次的含义是计算出不超过k条边情况下的某个节点距离起点的最短距离
    {
        // 解释一下上面那句话,因为遍历一次是遍历一条边的, 例如边 a->b ,长度为m, 则d[b]会判断是否进行更新
        // 若进行更新是用上次更新的结果来的,故这里用temp数组保存一下上次的d数组的结果
        memcpy(temp, d, sizeof d); 
        for(int j = 1; j <= m; ++j) // 每次都遍历一遍所有边,即相比Dijkstra算法重复遍历某个节点
        {
            auto e = edge[j];
            d[e.b] = min(d[e.b], temp[e.a] + e.w);     // 更新
        }
    }
    if(d[n] > INF / 2) cout << "impossible" << endl;  // 由于存在负权边,故条件是 > INF , 而不能写成 == INF 
    else cout << d[n] << endl;
}

auto main() -> int
{
    memset(d, 0x3f, sizeof d);
    cin >> n >> m >> k;
    for(int i = 1;i <= m; ++i)
    {
        int a, b, c; cin >> a >> b >> c;
        edge[i] = {a, b, c};
    }
    bellman_ford();
}

SPFA 算法

SPFA算法是对bellman_ford算法的改进, 我们观察bellman_ford算法可以改进的地方,首先为了解决Dijkstra可能漏掉某些节点的重新遍历,bellman_ford算法的解决方式,每次都是遍历一遍所有边,即这个操作

        for(int j = 1; j <= m; ++j) 
        {
            auto e = edge[j];
            d[e.b] = min(d[e.b], temp[e.a] + e.w);     // 更新
        }
// 可以看出要想d[e.b]发生更新,必须d[e.a] 发生变化,因为d[e.b] 和 e.w 这两项是不变的
// 故我们改进的思路就是当某个节点的距离重新更新之后,才遍历其连接的下一节点
// 故为了便于找到其某个节点连接的下一节点,这里最好的方式还是邻接表的实现

代码实现

#include<iostream>
#include<queue>
#include<cstring>
using namespace std;

constexpr int N = 1e+5 + 10, INF = 0x3f3f3f3f;
int n, m, g[N], d[N], e[N], ne[N], idx, w[N];    // 为了便于找到其连接的下一节点这里采用邻接表
bool st[N];

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

auto spfa() 
{
    queue<int> q;     // 保存进行遍历的节点,这里其实用什么容器都行
    d[1] = 0;         // 初始化起点
    q.push(1);        
    st[1] = true;     // st数组并不是标记是否被遍历,而是标记是否在队列中等待遍历,减少无效操作次数
    while(q.size())
    {
        int node = q.front(); q.pop();
        st[node] = false;
        for(int i = g[node]; i != -1; i = ne[i])
        {
            int t = e[i];
            if(d[t] > d[node] + w[i])  // 若其到起点的距离发生更新,则将其入队表示下次更新其连接的下一节点
            {
                d[t] = d[node] + w[i];
                if(!st[t]) q.push(t), st[t] = true;
            }
        }
    }
    if(d[n] > INF / 2) cout << "impossible" << endl;
    else cout << d[n] << endl;
}

auto main() -> int
{
    memset(g, -1, sizeof g);
    memset(d, 0x3f, sizeof d);
    cin >> n >> m;
    while(m--)
    {
        int a,b,c; cin >> a >> b >> c;
        add(a, b, c);
    }
    spfa();
    return 0;
}

SPFA应用-判断存在负环

正如前面所分析的, Dijkstra算法并不能用于处理负值权边的问题, 故Bellman_Ford 和 SPFA算法均可以处理负边权的问题, 这里一般判断是否存在负环我们一般用SPFA, 如果此时一个图中存在负权回路, 那么其肯定会绕圈, 会一直经过负权回路中的几个点, 因为根据算法, 这几个点的dist数组肯定会被一直更新, 故其对应的点也会一直被反复遍历, 这里我们引入一个cnt[] 数组, 其中cnt[x]表示的是从1 ~ x的边数, 而我们知道在有 n 个点的图中, 最极端的一个情况, 就是n个点依次顺序相连, 那么最后一个点的cnt数组记录的值应该是 n - 1, 如果 ≥ \ge n;说明从1 ~ x 的这条回路上一共有 ≥ \ge n 条变, 那么其经过了 n + 1个点, 显然如果不存在负权回路是不会出现这种情况的, 故一旦判断某个点更新之后的cnt值 ≥ \ge n 说明这个图存在负权回路.

题目链接: acwing 852.spfa判断负环

具体代码实现

#include<iostream>
#include<queue>
#include<cstring>
using namespace std;
constexpr int INF = 0x3f3f3f3f, N = 10010; 
int d[N], e[N], ne[N], w[N], g[N], cnt[N], n, m, idx; 
bool st[N]; 

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

void spfa()
{
    queue<int> q;
    d[1] = 0; 
    for(int i = 1; i <= n; ++i)  // 由于是判断整个图中是否有负权回路,故初始应将所有点入队 
    {
        q.push(i); 
        st[i] = true; 
    }
    
    while(q.size())
    {
        auto node = q.front(); q.pop(); 
        st[node] = false; 
        for(int i = g[node]; ~i; i = ne[i]) 
        {
            int b = e[i]; 
            if(d[b] > d[node] + w[i]) 
            {
                d[b] = d[node] + w[i]; 
                cnt[b] = cnt[node] + 1; 
                if(cnt[b] > n) return true; 
                if(!st[b]) 
                {
                    q.push(b); 
                    st[b] = true;
                }
            }
        }
    }
    cout << "No" << endl; 
}

auto main() -> int
{
    ios::sync_with_stdio(false); 
    memset(d, 0x3f, sizeof d); 
    memset(g, -1, sizeof g); 
    cin >> n >> m;    
    for(int i = 0; i < m; ++i)
    {
        int a, b, c; cin >> a >> b >> c;  
        add(a, b, c); 
    }
    if(spfa()) cout << "Yes" << endl ; 
    else cout << "No" << endl; 
    return 0; 
}

Floyd 算法

这针对多源最短路的问题, 即此时求解的答案并不是计算从1号节点到n号节点的最短距离,是计算从任意节点到另外一个节点的最短距离, 这个算法的思路是基于DP的, 故这里采用闫氏dp分析法分析一波

闫氏dp分析法

                             | 集合: 从i这个点开始只经过第1 ~ k个中间点到达 j的路径的集合  
                             |		(只经过并不代表都要经过)
  |---------状态表示--------- |
  |                          |
  |             f(k,i,j)     | 属性 : 集合中可行方案路径最短的值 min
  | 
  | 
  |
  DP    
  |
  |
  |
  |
  |------------状态计算   根据集合划分,将一个大集合划分为若干个子集,
                         设我们要求解f(k, i, j), 可以将其分为两个集合
                         不经过第k个点到达j 和 必定经过第k个点到达j
                         即f(k - 1, i, j) 和 f(k - 1, i, k) + f(k - 1, k, j); 
                         即从i 开始只经过第 1 ~ k - 1个中间点到达 k 这个点的最短距离, 
                         加上从 k 这个点开始只经过 1 ~ k - 1 个中间点到达 j 的最短距离 
                         即此时的状态转移方程为 : 
                         f[k, i, j] = min(f[k - 1, i, j] ,f[k - 1, i, k] + f[k - 1, k, j]);
                         
即:
for(int k = 1; k <= n; ++k)
	for(int i = 1; i <= n; ++i)
    	for(int j = 1; j <= n; ++j)
        	f[k][i][j] = min(f[k - 1][i][j] ,f[k - 1][i][k] + f[k - 1][k][j]);
                        
对上述状态转移方程进行进一步优化去掉一维得到:
for(int k = 1; k <= n; ++k)
	for(int i = 1; i <= n; ++i)
    	for(int j = 1; j <= n; ++j)
        	f[i][j] = min(f[i][j] ,f[i][k] + f[k][j]);
 
 这个形式是否等价于前面的情况呢? 
 因为前面未优化之前的状态是从 k - 1层转移过来的, 这里分几种情况讨论下:
 (1) i < k && j < k; 
 此时更新f[i][j] 时的 f[i][k] 和 f[k][j] 均在 f[i][j] 之后才更新, 故这种情况直接去掉一个维度依然成立 
 
 (2) i > k && j < k; 
 跟上面的情况类似, f[i][k] 这一项属于前一层未被更新的, 但是由于 i > k, 故f[k][j] 应该属于第 k 层被更新的
 那是不是就不成立了呢? 那我们看它更新了个啥?
 f[k][k][j] = min(f[k - 1][k][j], f[k - 1][k][j] + f[k - 1][k][k]); 显然f[k - 1][k][k] = 0; 
 故k - 1层 和 k层的 f[k][j] 是相同的并没有被更新
 
 (3) i < k && j > k  和  i > k && j > k 这两种情况同理, 其实上一层的本层情况相同, 所以并没有被更新
 
 
 结论: 直接去掉第一维与原本的形式等价 
 
 
故优化后也就是平常见到的Floyd代码为:
for(int k = 1; k <= n; ++k)
	for(int i = 1; i <= n; ++i)
		for(int j = 1; j <= n; ++j)
			f[i][j] = min(f[i][j], f[i][k] + f[k][j]); 
              

练习链接: acwing 854.Floyd求最短路

代码实现 -----时间复杂度 O( n 3 n^3 n3)

#include<iostream>
using namespace std;
constexpr int N  = 210, INF = 0x3f3f3f3f;
int n, m, k, f[N][N];   // 采用邻接矩阵的方式存储图 

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

auto main() -> int
{
    cin >> n >> m >> k;
    for(int i = 1; i <= n; ++i)
        for(int j = 1; j <= n; ++j)
            if(i == j) f[i][j] = 0;
            else f[i][j] = INF;
    while(m--)
    {
        int a, b, c; cin >> a >> b >> c;
        f[a][b] = min(f[a][b], c);
    }
    floyd();
    while(k--)
    {
        int a, b; cin >> a >> b;
        if(f[a][b] > INF / 2) cout << "impossible" << endl;
        else cout << f[a][b] << endl;
    }
    return 0;
}
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值