最短路问题【图论】

目录


最短路问题思维导图 最短路问题思维导图 最短路问题思维导图

【注】思维导图中约定n是图的顶点的数量m是边的数量

1 单源最短路

1.1 正权边

1.1.1 朴素版dijkstra算法

朴素版dijkstra算法适用范围:当图中只有正权边,且是稠密图时就很适合使用朴素版的dijkstra算法

算法原理

朴素版dijkstra算法主要核心就是循环n次,每次从还没有确定最短路的点中拿出来一个离起点最近的点(初始时除了起点到起点的距离是0,其他点到起点的距离都初始化为正无穷),拿出来的这个点此时到起点的距离就是它到起点的最短距离,使用这个点去更新它的所有的邻接点

我们发现对于每次循环朴素版dijkstra算法都能确定一个还没有确定最小路径的点的最小路径,所以n次循环后,n个顶点到起点的最短路就都找到了

以此图来模拟讲解

【注】dist表示到起点的最短距离,st表示是否确定了到起点的最短距离

1.初始时 d i s t [ 1 ] = 0 dist[1] = 0 dist[1]=0其他点都是正无穷

顶点编号 1 2 3 4 5
dist 0 0x3f3f3f3f 0x3f3f3f3f 0x3f3f3f3f 0x3f3f3f3f
st false false false false false

< 初始状态 > <初始状态> <初始状态>

2.在所有没有确定最短距离的点中选择离起点最近的点此时是1确定此时的dist[1]就是离起点的最短距离,然后去更新它的邻接点2和3

顶点编号 1 2 3 4 5
dist 0 20 1 0x3f3f3f3f 0x3f3f3f3f
st true false false false false

< 1 > <1> <1>

3.然后再次执行【2】的操作,注意此时st[1]已经是true了,说明它到起点的最短距离已经确定了,就不能再选它了,所以此时就只要选点3,然后去更新3的所有邻接点

顶点编号 1 2 3 4 5
dist 0 20 1 3 0x3f3f3f3f
st true false true false false

< 2 > <2> <2>

4.重复操作【2】一共循环n次

顶点编号 1 2 3 4 5
dist 0 8 1 3 0x3f3f3f3f
st true false true true false

< 3 > <3> <3>

顶点编号 1 2 3 4 5
dist 0 8 1 3 12
st true true true true false

< 4 > <4> <4>

顶点编号 1 2 3 4 5
dist 0 8 1 3 12
st true true true true true

< 5 > <5> <5>

到这里我们可以发现其实dijkstra算法的主要核心思想就是贪心 ,每次都在没有确定最短距离的点集中拿出离起点最近的点,此时被拿出来这个点到起点的距离就是它到起点的最短距离,再用这个点去更新它的所有邻接点

朴素版dijkstra算法模板
cpp 复制代码
#include<iostream>
#include<cstring>

using namespace std;

const int N = 510;

int g[N][N];
int dist[N];
bool st[N];
int n, m;

int dijkstra()
{
    memset(dist, 0x3f, sizeof dist);
    dist[1] = 0;
    
    for(int i = 0; i < n; i++)
    {
        int t = -1;
        //每次都选出来当前所有点中到起点距离最近的点
        for(int j = 1; j <= n; j++)
        {
            if(!st[j] && (t == -1 || dist[t] > dist[j]))
                t = j;
        }
        
        //标记为true代表这个点的最短距离已经确定了的
        st[t] = true;
        
        //更新其他的和t这个点相连的点
        for(int j = 1; j <= n; j++)
            dist[j] = min(dist[j], dist[t] + g[t][j]);
    }
    
    if(dist[n] == 0x3f3f3f3f) return -1;
    else return dist[n];
}

int main()
{
    memset(g, 0x3f, sizeof g);
    cin >> n >> m;
    while(m--)
    {
        int a, b, c;
        scanf("%d%d%d", &a, &b, &c);
        g[a][b] = min(g[a][b], c);
    }
    
    int t = dijkstra();
    
    printf("%d\n", t);
    return 0;
}

这里需要注意,因为朴素版dijkstra很适合算稠密图的最短路,所以这里直接使用邻接矩阵来存储边和边权,但是因为邻接矩阵的空间复杂度是O(n^2)的所以如果题目给出的n的数量太大了会MLE比如下面给出的例题,这时就需要使用邻接表来存储边了

【提示】稠密图用邻接矩阵来存储,稀疏图用邻接表来存储

例题

朴素版dijkstra算法模板题

这题n个数据范围10^4,所以不能使用邻接矩阵来存储边,需要使用邻接表,思路还是一样循环n次,每次都确定一个点的最短距离,然后更新这个点的所有邻接点

1.1.2 堆优化版dijistra算法

【约定】我们把所有没有确定到起点的最短距离的点都看成在一个点集A

算法原理

上面我们发现了,其实dijkstra算法的主要思想就是贪心 ,而这个贪心又很简单就是每次都从点集A 中找出此时到起点距离最近的点,拿出这个点来更新它的所有邻接点,但是我们每次找到这个点确要完整的遍历一遍dist数组,这样的效率属实太低了,此时我们不难想到可以使用 堆 来实现这个过程

所以堆优化的dijkstra算法本质上就是优化了朴素版dijkstra在点集A中找到离起点最小距离的点的过程

朴素版dijkstra 堆优化版dijkstra
适用范围 稠密图 稀疏图
算法不同点 通过遍历dist数组找点集A中最小点 直接使用堆来找到点集A中的最小点
时间复杂度 O(n^2) O(mlogn)

两个算法版本核心点对比 两个算法版本核心点对比 两个算法版本核心点对比

其实我们通过这个表格也能发现为什么堆优化dijkstra适用于稀疏图而不适用于稠密图,当是稠密图时m约等于n^2的,此时朴素版的dijkstra算法时间效率要优于堆优化版的

堆优化版dijkstra算法模板
cpp 复制代码
#include<iostream>
#include<cstring>
#include<queue>

using namespace std;

typedef pair<int, int> PII;

const int N = 10010;

int h[N], e[2 * N], ne[2 * N], w[2 * N], idx;
int dist[N];
bool st[N];
int n, m;
void add(int a, int b, int c)
{
    e[idx] = b, ne[idx] = h[a], w[idx] = c, h[a] = idx++;
}

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

    priority_queue<PII> q;
    q.push({0,1});
    while(q.size())
    {
        auto t = q.top(); q.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];
                q.push({dist[j], j});
            }
        }
    }
}

int main()
{
    memset(h, -1, sizeof h);
    
    cin >> n >> m;
    while(m--)
    {
        int a, b, c;
        scanf("%d%d%d", &a, &b, &c);
        add(a, b, c);   //这里要注意题目说的时有向图还是无向图
    }
    int t = dijkstra();
    cout << t << endl;
    return 0;
}

《算法竞赛》 中提到堆优化版dijkstra其实就等于优先队列+bfs,这里读者可以自行理解


关于dijkstra算法不能处理带负权边的图

为什么dijkstra算法不能处理带负权边的问题呢?我们知道dijkstra算法每次找点集A中最小距离点时就会确定这个点的最短距离(假设是点u),如果此时这个点的邻接点(假设是点v)对应的边u-->v是负权的,刚好v到起点又有其他的路径,那么点u到起点的最短距离就不一定是之前确定的那个了,所以这里就和dijkstra算法矛盾了


例题

堆优化版dijkstra模板题

1.2 负权边

1.2.1 bellman_ford算法

bellman_ford算法在多数情况下时间效率上没有spfa好,但是如果对于有路径数限制的问题,确只能使用bellman_ford算法来求解

算法原理

假如你在一个起点s,现在需要去目的地A,每个路口都有一个警察,你去问各个路口的警察哪个路口离A更近,这些警察都不知道(因为A离他们太远了),但是他们可以去问它们对应路口的下一个地点的所有警察,如果还是不知道就一层一层的往里问,直到问到A路口的警察,然后这条路径的警察把这条路是最短路的信息再一层一层的传回去,此时在起点s的你就可以得到走哪个路口是最短的路了

这就是bellman_ford算法的原理,一层一层的找最短的路,迭代n次,每次都更新所有已经更新过的点的邻接点,迭代n次之后,所有的点到起点的最短的距离就都能找到了


讲解示意图 讲解示意图 讲解示意图

bellman_ford算法步骤

  1. 最外层循环迭代n次
  2. 每次更新所有的已知道的点(已经被访问过的点)的所有邻接点到起点的最小距离
  3. 继续重复循环

如上步骤,初始时dist[1] = 0,只有点1是已经知道距离的点,更新所有点1的邻接点到,此时有

顶点编号 1 2 3 4 5
dist 0 3 1 0x3f3f3f3f 0x3f3f3f3f

< 1 > <1> <1>

再继续更新所有已经知道的点的邻接点到起点的最短距离

【注意】这里点3还把点2到起点的最短距离更新了

顶点编号 1 2 3 4 5
dist 0 2 1 5 4

< 2 > <2> <2>

顶点编号 1 2 3 4 5
dist 0 2 1 5 3

< 3 > <3> <3>

此时就已经找了所有的点到起点的最短的距离,我们发现实际情况并不是每次都要循环完n次才可以得到最后得答案,但是为了确保所有点得准确的更新,所以还是需要循环n次更加保险

bellman_ford算法模板
cpp 复制代码
#include<iostream>
#include<cstring>

using namespace std;

const int N = 100010;

int n, m;
struct edge
{
    int a, b, w;
}edges[N];

void bellman_ford()
{
    memset(dist 0x3f, sizeof dist);
    dist[1] = 0;
    for(int i = 0; i < k; i++)
    {
        memcpy(backup, dist, sizeof dist);
        //看似是把全部的边都遍历了,但是真正会更新的只有已经访问过的点的邻接点
        //并且只有这些点有到起点距离更新时才会更新
        for(int j = 0; j < m; j++)
        {
            int a = edges[j].a, b = edges[j].b, w = edges[j].w;
            dist[b] = min(dist[b], backup[a] + w);
        }
    }
}

int main()
{
    cin >> n >> m;
    for(int i = 0; i < m; i++)
    {
    	cin >> edges[i].a >> edges[i].b >> edges[i].w;
    }
    
    bellman_ford();
    
    cout << dist[n];
    
    return 0;
}

结构体存储图的原因

我们发现使用bellman_ford算法时,图的存储可以直接使用结构体来存,这是因为bellman_ford算法每次循环都是更新所有已经访问过的点的所有邻接点,而遍历所有的边不会影响它的更新结果,所以就干脆使用最简单的结构体来存储图,不过注意spfa优化的地方就在这里


关于最外层循环的k

我们上面一直说的循环n次,为什么代码里面又是k次呢?注意到刚开始的时候说了,bellman_ford算法可以用来解决有边数限制的最短路问题,这里的k就是保证了路径最大只会是k条边,因为每次循环最多就往外扩1层也就是1条边,如果没有边数限制的话就可以改成n次,灵活应用


backup数组的作用

backup记录的是dist数组上一次的状态,这里更新最短距离时一定要使用dist的备份数组来更新,不然会发生串联的情况。就拿上面的图来举例,第二次更新时点3会把点2到起点的距离更新成2,此时如果不使用backup数组来更新点的话,点5到起点的距离会被点2的2更新成3,但是这样的话就不能符合上面的k条边数的限制了虽然5到起点的距离是最小的,但是是用了3条边才找到这个最短距离的

【概括】原本我第k次循环得到的所有点最短距离都是的路径都是<=k的,但是不用backup数组的话,算出来的最短距离的路径数可能会大于k条边


例题

【有边数限制的最短路问题】

1.2.2 spfa算法

spfa算法其实就是bellman_ford算法的优化,核心的思路还是一样的

算法原理

spfa算法的核心思路还是像bellman_ford算法一样,一层一层的往外扩来更新,就像乡村的邻居一样,如果我到起点的距离更新了就看看邻居有没有更新或者我能不能帮它更新出一个更离起点更近的距离

其优化点就是上面说的,bellman_ford算法来更新所有已知点时把所有的边都遍历了一遍!!!这样直接就导致它的实践复杂度稳定在了O(mn),其实完全没有这个必要,其实只需要遍历所有到起点的距离变短的点的邻接点就可以了

【提示】只有某个点离起点的距离变得更短了,那更新它得邻接点才能使得它得邻接点离起点得距离更短吧

spfa算法模板
cpp 复制代码
#include<iostream>
#include<cstring>
#include<queue>

using namespace std;

const int N = 1e5 + 10;

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

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

void 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])
                {
                    q.push(j);
                    st[j] = true;
                }
            }
        }
    }
    
    if(dist[n] == 0x3f3f3f3f) puts("impossible");
    else printf("%d\n", dist[n]);
}

int main()
{
    memset(h, -1, sizeof h);
    cin >> n >> m;
    
    while(m--)
    {
        int a, b, c;
        scanf("%d%d%d", &a, &b, &c);
        
        add(a, b, c);
    }
    
    spfa();
    
    return 0;
}
例题

P1744 采购特价商品

1.2.3 spfa判断负环

判断负环是spfa算法一个很重要的应用,一般的单源最短路判断负环都可以使用spfa来判断,注意bellman_ford算法也是可以用来判断负环的,因为它们两个的原理本质上都是一样的,但是使用spfa判断负环会更简单一些


负环是什么?

假设一个图中有一个环,这个环的边权之和为负数,那么就称这个环是负环


为什么要判断负环?

我们发现,当一个点到另一个点的路径上有一个负环的时候,这个点到另一个点的距离会是无穷小,因为每次走一次环它们的距离都会小一点,这样当我们求最短路的时候这个最短的距离就可以无穷小,当使用spfa算法直接求最短路时也就会进入死循环


如何判断负环?

判断一个图中是否有负环的原理其实很简单,用一个数组记录每个点的到起点的路径数,如果一个点到起点的路径数>=n,那么就代表这个图中有负环

证明:因为一共有n个点,如果一个点到起点的路径数>=n,那就说明这个路径一定经过了n+1个点,所以必有一个点重复了,也就是说一定有环,那么为什么一个点经过环之后还会再回来呢?那肯定是因为这个环使得它到起点的距离更近了啊,所以这个环就一定是负环


代码实现

cpp 复制代码
#include<iostream>
#include<cstring>
#include<queue>

using namespace std;

const int N = 1e5 + 10;

int n, m;
int dist[N], cnt[N]; //记录路径数量
bool st[N];

bool spfa()  //判断图中是否有环
{
    for(int i = 1; i <= n; i++)
    {
    	q.push(i);
    	st[i] = 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];
                cnt[j] = cnt[t] + 1;
                
                if(cnt[j] >= n) return true;
                
                if(!st[j])
                {
                    q.push(j);
                    st[j] = true;
                }
            }
        }
    }
    
    return false;
}

【注解】判断负环初始时全部点都入队是根据题意的,如果题目问的是图中是否有负环 就需要将全部点入队,如果问的是起点s到某个点的路径上是否有负环那初始时起点s入队就可以了

【扩展】spfa判断负环的应用-差分约束系统

【题目链接】

差分约束系统得出来得答案要么是无解,要么就是有无穷个解,因为找到一组解后给各个解都加减同一个数那么它们都还是满足给出来得关系式的

我们发现题目中的 x a − x b < = y x_a - x_b <= y xa−xb<=y可以转换成 x a < = x b + y x_a <= x_b + y xa<=xb+y,这个形式很像最短路算法的等式即 d i s t [ t ] < = d i s t [ t ] + w dist[t] <= dist[t] + w dist[t]<=dist[t]+w弄成这样就好理解了,所以对于题目中给的每一组关系我们都可以看成是一条 b b b指向 a a a的权值为 y y y的有向边

此时,我们再加上一个点0,给所有的点与0点连接上一条权值为0的边,此时得到的这个图如果是带有负环的话,那答案就无解,如果没有负环的话,一组解就是各个点到0点的最短距离


为什么图中有负环就无解?

我们假设图中有任意3个点构成了一个负环,如图

根据这个负环我们可以推导出题目中给的等式

  • x 2 < = x 1 + a x_2 <= x_1 + a x2<=x1+a
  • x 3 < = x 2 + b x_3 <= x_2 + b x3<=x2+b
  • x 1 < = x 3 + c x_1 <= x_3 + c x1<=x3+c

将3个不等式左右两边分别相加得到: 0 < = a + b + c 0 <=a + b + c 0<=a+b+c

又因为 a + b + c a + b + c a+b+c是负数,所以就矛盾了,所以如果图中有负环的话,就无解


代码实现

cpp 复制代码
#include<iostream>
#include<cstring>
#include<queue>

using namespace std;

const int N = 5e3 + 10;

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

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

bool spfa()
{
	queue<int> q;
	//全部点都入队因为还需要判断图中是否有负环 现在算的是每个点到0点的距离
	for (int i = 1; i <= n; i++)
	{
		q.push(i);
		st[i] = 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];
				cnt[j] = cnt[t] + 1;

				if (cnt[j] >= n) return true;

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

	return false;
}

int main()
{
	memset(h, -1, sizeof h);

	cin >> n >> m;

	for (int i = 1; i <= n; i++) add(0, i, 0);   //给所有点都往0点连接上一条权值是0的边

	while (m--)
	{
		int u, v, w;
		cin >> v >> u >> w;
		add(u, v, w);
	}

	if (spfa()) puts("NO");
	else for (int i = 1; i <= n; i++) printf("%d ", dist[i]);

	return 0;
}

2 多源最短路

2.1 Floyd算法

floyd算法的原理就是动态规划,它的代码很简单,但是确可以求出图中所有点对的最短距离,时间复杂度为O(n^3)

算法原理

floyd算法的原理就是动态规划,枚举的 k k k表示的是, i i i到 j j j中转点只能是 [ 1 , k ] [1,k] [1,k]范围的点的最短距离

  • 状态表示(重叠子问题): f [ k ] [ i ] [ j ] f[k][i][j] f[k][i][j]表示点 i i i到点 j j j且只经过了 [ 1 , k ] [1,k] [1,k]的点的最短距离

  • 状态转移方程(最优子结构):
    f [ k ] [ i ] [ j ] = m i n ( f [ k − 1 ] [ i ] [ j ] , f [ k − 1 ] [ i ] [ k ] + f [ k − 1 ] [ k ] [ j ] ; f[k][i][j] = min(f[k-1][i][j], f[k-1][i][k] +f[k-1][k][j]; f[k][i][j]=min(f[k−1][i][j],f[k−1][i][k]+f[k−1][k][j];

  • 初始化:

cpp 复制代码
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] = 0x3f3f3f3f;

因为这里每一层的更新都只用到了上一层的状态,所以其实可以用滚动数组进行空间优化

要理解floyd算法的本质,当循环在第k层的时候(还没有执行第k层循环),此时算出来的所有的 f [ i ] [ j ] f[i][j] f[i][j]的路径的中转点都是 [ 1 , k − 1 ] [1, k -1] [1,k−1]之间的点(不包括 i i i和 j j j)

Floyd算法模板
cpp 复制代码
void 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]);
}
例题

P2910 [USACO08OPEN] Clear And Present Danger S
P1119 灾后重建

2.2 Floyd算法扩展

2.2.1 最短路径

floyd算法不仅能算出图中所有点对的最短距离,而且还可以在算最短距离的时候顺便将每个点对的最短的距离的路径给维护了,实现如下:

cpp 复制代码
//这里N的数据范围是图的最大的点数
int path[N][N];  //p[i][j] 代表的是i到j的最短路径的第一个点

void floyd()
{
	for(int k = 1; k <= n; k++)
		for(int i = 1; i <= n; i++)
			for(int j = 1; j <= n; j++)
			{
				if(dist[i][j] > dist[i][k] + dist[k][j])
				{
					dist[i][j] = dist[i][k] + dist[k][j];
					path[i][j] = path[k][j];
				}
				else if(dist[i][j] == dist[i][k] + dist[k][j])
				{
					//如果相等就取字典序最小的
					path[i][j] = min(path[i][j], path[k][j]);
				}
			}
			
}

void print_path(int s, int t)
{
	if(s == t)
	{
		cout << t << " ";
		return;
	}
	cout << path[s][t] << " ";
	print_path(path[s][t], t);
}

int main()
{
	//主函数里面要注意 存储边的是要把路径也存一下
	//例如: a--->b
	// path[a][b] = b;
	//其他的存储成0即可
}

关于记录最短路径的代码,因为 i i i 到 j j j的最短路径是经过了 k k k的,所以 i i i到 j j j的路径的第一个点要更新成 k k k到 j j j的第一个点

例如:

之前 i i i到 j j j的最短路径是( i i i是2, j j j是3):
2 − − − > 1 − − − > 3 2--->1--->3 2−−−>1−−−>3

现在2到3的最短了被k更新了,发现2到3经过4会更近,所以此时就要更新成:

2 − − − > 4 − − − > 3 2--->4--->3 2−−−>4−−−>3

也就是path[k][j]

2.2.2 负环的判断

floyd算法判断负环的话其实很简单,只需要判断 d i s t [ i ] [ i ] dist[i][i] dist[i][i]是否小于0即可,如果小于0的话,就是有负环

相关推荐
研究点啥好呢1 小时前
dji机器人SLAM算法工程师 面试题精选:10道高频考题+答案解析
c++·算法·机器人·slam·dji
君万1 小时前
【LeetCode每日一题】3. 无重复字符的最长子串 560. 和为 K 的子数组
算法·leetcode·golang·go
代码地平线1 小时前
【排序】C语言实现八大排序算法(含完整源码与性能测试)
c语言·算法·排序算法
承渊政道1 小时前
【贪心算法】(经典实战应用解析(一):柠檬水找零、将数组和减半的最少操作次数、最大数、摆动序列)
数据结构·c++·学习·算法·leetcode·贪心算法·排序算法
05候补工程师1 小时前
【408考研】数据结构核心笔记:单链表与栈操作精髓总结
数据结构·笔记·考研·链表·c#
初心未改HD1 小时前
机器学习之支持向量机SVM详解
算法·机器学习·支持向量机
少司府1 小时前
C++基础入门:vector深度解析(七千字深度剖析)
c语言·开发语言·数据结构·c++·容器·vector·顺序表
he___H1 小时前
子串----
java·数据结构·算法·leetcode
05候补工程师2 小时前
【ROS 2 避坑指南】从 SLAM 实时建图到 Nav2 导航算法深度调优全过程
算法·ubuntu·机器人