图论-最短路问题

首先明确一个代码实现时候的问题:

为什么使用0x3f3f3f3f而不是0x7f7f7f7f来表示无穷大INF?
虽然0x7f7f7f7f确实更大而且符号位是0,但是在最短路算法中,经常出现形如min(a, a+b);的表达式,如果此时a与b都是INF,使用0x7f7f7f7f则会造成溢出。因此0x3f3f3f3f是可以保证两个INF相加不越界的(每个字节都相同)的最大的数。
因为常用memset()进行初始化,而该函数按字节操作,所以需要每个字节相同。

最短路问题分类

假设图中点的数量为n,边的数量为m

算法名称 时间复杂度

  • 最短路问题
    • 单源最短路
      • 所有边都是正数
        • 朴素Dijkstra O ( n 2 ) O(n^2) O(n2)
          与边的数量无关,适用于稠密图 m ∼ n 2 m\sim n^2 mn2
        • 堆优化Dijkstra O ( m l o g n ) O(mlogn) O(mlogn)
          适用于稀疏图 m ∼ n m\sim n mn
      • 存在负权边
        • Bellman-Ford O ( n m ) O(nm) O(nm)
        • SPFA(对Bellman-Ford的优化) 一般 O ( m ) O(m) O(m),最坏 O ( n m ) O(nm) O(nm)
          虽然是Bellman-Ford的优化但不能解决Bellman-Ford可以解决的所有问题
    • 多源汇最短路
      • Floyd O ( n 3 ) O(n^3) O(n3)

在某种特定情况下,会有一种算法是最适配的,可以根据情况选择算法。

最短路问题的难点一般在于建模,即将问题转换成图的最短路问题。而不是最短路算法的实现。

单源最短路

以下默认求1号点距离其他点的最短距离

朴素Dijkstra

适用于稠密图(图常用邻接矩阵存储)

集合S:当前已经确定最短距离的点。

  1. 初始化距离dis[1] = 0; dis[i] = INF;
  2. 遍历所有点,找到不在S中距离最近的点t,将点t加入S,用t的距离更新其他所有点的距离(理论上只需要找到t能走到的点,更新距离即可,代码实现上是遍历所有的点,比较原距离和t的距离加上存储的t到该点的距离(如果没有连接则是INF))

循环n次(每次可以向S中增加一个点,则n次可以完成所有点的最短路径确定),每个循环里面循环2n次(一个n是寻找当前最近的点,另一个n是用当前最近的点更新其他点的距离),所以是 O ( n 2 ) O(n^2) O(n2)

这是一种基于贪心的最短路径算法,每次都找最近的点,则找到的就是最短路径。

举例

2
1
4
1
2
3

如上的一个图,步骤如下:
加亮表示该点最短路径已经确定。

步骤节点1节点2节点3
初始化0INFINF
确定距离最小的点0INFINF
用确定的点更新其他能走到的点024
找到最近的点024
用新找到的点更新其他能走到的点023
找到最近的点023

代码示例

const int N = 510;
int g[N][N];  // 邻接矩阵存储图
int dis[N];   // 距离节点1的距离
bool st[N];   // 是否已经确定最短路径
int n,m;      // n是节点个数,m是边的个数

void dijkstra()
{
    memset(dis, 0x3f, sizeof(dis));
    dis[1] = 0;
    
    for(int i=0;i<n;i++)
    {
        // 从所有未被确定路径的点里找到一个最近的点
        int t=-1;
        for(int j=1;j<=n;j++) 
        {
            if(!st[j] && (t==-1 || dis[t] > dis[j]))
            {
                t = j;
                // cout<<"t:"<<t<<endl;
            }
        }
        // 把找到的点加入集合
        st[t] = true;
        // 更新最短路径
        // cout<<"shortest path:"<<endl;
        for(int j=1;j<=n;j++) 
        {
            dis[j] = min(dis[j], dis[t] + g[t][j]);
            // cout<<j<<":"<<dis[j]<<'\t';
        }
        // cout<<endl<<endl;;
    }
}

堆优化的Dijkstra

适用于稀疏图

稀疏图使用邻接表存储时,每次用t更新其他所有点的距离将变成m次而不是n次。

将找到最近的点t优化为使用堆来找,则查找最近的点可以优化到 O ( 1 ) O(1) O(1),而更新其他距离的时候,在堆中修改数值为 O ( m l o g n ) O(mlogn) O(mlogn)

堆的实现:

  • 手写堆
  • 优先队列,优先队列不支持元素修改,可以通过插入新的数实现,这样元素最多是m个,复杂度成为 O ( m l o g m ) O(mlogm) O(mlogm),不过 l o g m logm logm l o g n logn logn在一个级别(即使在稠密图中也有 l o g m ∼ l o g n 2 = 2 l o g n ∼ l o g n logm \sim logn^2=2logn \sim logn logmlogn2=2lognlogn)。所以可以用优先队列直接实现而不用手写堆。

因为使用优先队列有冗余存在,所以找到的最小值可能是已经确定的节点,此时扔掉即可。

和朴素一样,不过每次确定某个节点的最优的时候,下一次便利更新距离只需要更新和新确定的这个节点相连的其他节点的距离即可,不用全部遍历。

在朴素里面实现的时候做了全部遍历来更新距离,遍历到和最新节点不相连的点其实是无用的。

代码示例

typedef pair<int,int> PII;

const int N = 1.5e5+10;
// 邻接表表示图,这里用的是静态链表
// h是头,e是节点编号,w是路径权重,ne是下一个节点
int h[N],e[N],w[N],ne[N], idx=0;
bool st[N];
int n,m;
// 优先队列,堆,自动排序,以省去循环找到最近路径的步骤
// 在堆中的排序比O(n)要小,以此优化时间
priority_queue<PII, vector<PII>, greater<PII>> q;
// 所有节点距离1号节点的距离
int dist[N];

int dijkstra()
{
    // 初始化距离,距离都先初始化为一个很大的数值INF
    memset(dist, 0x3f3f3f3f, sizeof(dist));
    // 1号点为0,必须初始化
    dist[1] = 0;
    // {distance, node},距离放在前面以让堆对距离进行升序排序
    // 1号点放进堆的初始化里,这是目前看到的图的部分,还没有确定的最小距离点
    q.push({0, 1});

    while(q.size())
    {
        PII t = q.top();
        q.pop();
        int node = t.second, distance = t.first;
        // 一个节点的距离可能被多次更新多次放入堆中,如果已经被确定了最近距离,则跳过
        if (st[node]) continue;
        // 当前节点是距离最近的点,确定下来该点的距离
        st[node] = true;
        // 遍历当前点能连接到的所有节点,更新他们的距离
        for(int i = h[node];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});
            }
        }
    }
    // 1号点走不到n号点返回-1
    if (dist[n]==0x3f3f3f3f) return -1;
    return dist[n];
}

Bellman-Ford

可以处理有负权边的图。

注意
如果存在负权回路,则最短路不一定存在。因为可以在负权回路上绕圈,路径权值变得无限小。

  • 循环n次 (n次)
    • 备份当前距离memcpy(backup, dist, sizeof(dist));
    • 每次遍历所有边a–(w)–>b (m次)
      • dist[b] = min(dist[b], backup[a] + w);(松弛操作)

松弛操作:判断该条边会不会对已有的距离做出改善。

比如下面的图中:

  1. 第一次遍历,因为2和3节点此时距离是INF,2->3这条边(实现由1–>2–>3,距离是INF+1)不能改善3的距离(INF),第一次循环后,可以得到2和3节点的距离分别是1和4。
  2. 第二次遍历,2和3节点的距离分别是1和4,此时2->3这条边(实现由1–>2–>3,距离是1+1=2)可以改善3的距离(4),则会更新dist[3]的值。

每次遍历更新一下距离,循环结束以后满足dist[b]<=dist[a]+w;

最外层循环k次的含义
从1号点经过不超过k条边走到每个点的最短距离。
当进行一次循环的时候,相当于可以看到当前节点相邻的节点,可以看一格的距离,下一次循环的时候,每个节点都带着一格最近距离的信息,此时再看一格,相当于在原有的基础上看的更远了一格。每当多一层循环的时候,相当于从这个节点可以看的远一格。
如果超过n次迭代路径仍有更新,表明存在一条经过大于等于n条边的路径,假设经过了n条边,表明重复经过了某个点,则一定存在环,且路径仍在不断更新,表明环是负环。
Bellman-Ford可以找到负环,但是找负环一般是使用SPFA算法。
当限制了最多经过的边的个数的时候,有负环也会受到次数限制。

备份的必要性

假设有图如下

1
1
4
1
2
3

限制最多经过一条边到3,则答案应当是4。
当路径发生串联时:
只循环一次,如果不使用备份的距离,可能在第一次循环中,先更新了1–>2的距离是1,更新2->3的边时候,本来应该使用上一步的结果,用INF和INF+1比较,而因为前面已经更新了dist[2],直接用dist[2]会将3的距离更新为经过2的更短的路径,即使得一次循环就让3的距离变成了2。

对于Bellman-Ford算法,只要能遍历所有边即可,边的存储方式无所谓。
可以使用如下方式定义边的存储:

struct edge
{
	int a, b, w;
}edge[M];

最后路径的判断

不应当使用dist[n]==INF作为判断依据,当考虑源点不能到最终目的点,而存在某点到目的点的距离为负数,该负权边会更新目的点的距离小于INF。
只要dist足够大即可以判断不存在路径,可以使用dist[n]<INF/2进行判断,因为即使距离被更新,因为边数的限制也不会减少很大(图的边和权重的数量级与INF有差距)。

代码示例

int dist[N], backup[N];
struct Edge
{
    int a, b, w;
}edges[M];

void bellmanford()
{
    // 初始化路径距离
    memset(dist, 0x3f, sizeof(dist));
    dist[1] = 0;
    // 有k条边的限制所以只循环k次
    for (int j=0;j<k;j++)
    {
        // 备份距离,避免路径串联影响限制条件
        memcpy(backup, dist, sizeof(backup));
        for(int i = 0;i<m;i++)
        {
            // 取出当前边,更新距离
            int node_a = edges[i].a, node_b = edges[i].b, weight = edges[i].w;
            // 对比通过a->b的路径到b的距离要看之前的a的距离,所以使用备份backup
            // 可能有多条路径都通往b节点,使用dist可以在本轮循环后找到最近的通往b的
            dist[node_b] = min(dist[node_b], backup[node_a] + weight);
        }
    }
    // 对于n节点的判断:
    // 即使n节点到不了,但是之前有负边,距离n的距离仍有可能被更新而不等于INF
    // 上面循环了多少次dist[n]就可能被更新了多少次,一般有路径的不会更新到很大(INF数量级),所以可以取一半验证
    return dist[n];
}

SPFA

只要没有负环就可以使用SPFA算法,一般题目都不存在负环。

时间复杂度一般是 O ( M ) O(M) O(M),最差是 O ( N M ) O(NM) O(NM)

对Bellman-Ford算法的优化,BF算法每次循环每个边的遍历不能每次都有效更新。在dist[b] = min(dist[b], backup[a] + w);只有dist[a]在上一步进行了更新,这一步中才会对dist[b]做出更新。

用队列做,把起点放在队列中,当队列中有节点变小,将该节点放入队列中,从队列中取出节点时,更新这个节点的所有出边,如果进行了有效更新,则继续加入更新的点,如果该点已经在队列中则不用重复加入。

代码示例

const int N = 1e5+10;
const int M = 1e5+10;
queue<int> q;
// 使用邻接表存储图
int h[N], e[M], ne[M], w[M], idx=0;
int dist[N];
bool st[N];  // 某个节点是否在队列中

void spfa()
{
    // 初始化
    memset(dist, 0x3f, sizeof(dist));
    dist[1] = 0;
    q.push(1);
    st[1] = true;
    
    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;
                }
            }
        }
    }
    // 因为是从1号节点出发进行的最短路更新,所以如果到不了n节点的话距离也不会被更新
    if (dist[n]==0x3f3f3f3f) cout<<"impossible";
    else cout<< dist[n];
}

在没有负环的前提下,SPFA均可以进行,也可以解决Dijkstra算法的问题。而且速度往往更快。如果出题刻意构造数据使得SPFA达到NM级别的运算量,且数据量较大,可以考虑改用其他算法。

SPFA判断负环

Bellman-Ford同理也可以实现,但是BF算法绑定 O ( N M ) O(NM) O(NM)复杂度,而SPFA最坏是 O ( N M ) O(NM) O(NM)

原理
应用抽屉原理,dist[N]记录节点到1号点的最短距离,cnt[N]记录当前最短路的边数。
每次更新dist的时候,把cnt也更新,cnt[b] = cnt[a] + 1;即此时最短路是走到a点再走一步到b,所以走到b需要的边数是到a的边数+1。
如果出现cnt[x]>=n, 则至少经过n+1个点,一共n个点则至少经过了一个环。表明出现了负环。

注意
初始化的时候需要把所有点都放入队列中,因为负环可能存在但是无法由某个固定的出发点到达。

综上,其实该算法在列举所有图里可能的路径,并不限于某个点出发,如果存在负环,会在负环的地方死循环,负值沿着环不断传递,使得环上的点的dist不断变小,cnt会不断累加,在一定次数的循环以后,该点的cnt会超过节点数量,从而判断出出现负环。

什么叫负值会沿着路径传递
举例,即使如

-10
3
c
b
a

这样的路径,连接a的入边是正值,而c–>b的-10已经传递给了dist[b],所以即使b–>a是正值3,在更新的时候,仍然是比较初始化数值0dist[b]+w = -10+3 = -7,-7会保留。所以只要正值的权还没有超过负值权,这个负值会一直抵消正值的权进行传递。

dist的数值只有相对意义,可以任意初始化,而当使用初始化0,此时dist的实际意义是以该点为终点的路径最小值。

const int N = 2010;
const int M = 10010;
int h[N], e[M], ne[M], w[M], idx=0;
int dist[N], cnt[N];
bool st[N];

bool spfa()
{
    queue<int> q;
    // 此时路径的绝对值没有意义,只有相对大小有意义,可以使用初始化0
    // dist的实际意义是以该点为终点的路径最小值
    // memset(dist, 0x3f, sizeof(dist));
    // dist[1] = 0;
    // 负环不一定从1出发能走到,所以一开始要加入所有的点
    for(int i=1;i<=n;i++)
    {
        q.push(i);
        st[i] = true;
    }
    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];
            // 如果存在负环,负值将沿着环传递,环上的点dist不断变小,cnt不断变大,最终到达n个
            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;
}

多源汇最短路

Floyd

Floyd可以处理负权边,但是不能处理负环。

算法原理

Floyd算法是一个递归算法。

对于所有节点{1, 2, 3, …, n}中的一个子集{1, 2, 3, …, k}(k<=n)进行考虑。

现在选取n个节点中的一对i,j,考察由i节点出发,至j节点的最短路径,并且在路径上的节点一定属于{1, 2, 3, …, k}中。

将这个节点子集由空集开始依次扩增,每次扩增一个节点,每次扩增后都重新找最近的路径,每次新添进来的是最后一个节点k,因此特别地,对k节点进行特别考虑,此时有以下几种情况:

  1. 路径不存在,即无法从i出发,通过{1, 2, 3, …, k}中的一个或多个节点到达j
  2. 路径存在,则符合条件的最短路径存在(存在的路径中最短的一条),但k节点不在路径上。则此时路径的中间节点都属于集合{1, 2, 3, …, k-1}中,又因为 { 1 , 2 , 3 , . . . , k − 1 } ⊂ { 1 , 2 , 3 , . . . , k } \{1, 2, 3, ..., k-1\}\subset\{1, 2, 3, ..., k\} {1,2,3,...,k1}{1,2,3,...,k},即该路径就是符合条件(中间节点属于集合{1, 2, 3, …, k})的路径。
  3. 路径存在,且新加进来的节点k在最短路径中。将路径分为i–>k–>j两段,分别考虑i–>k和k–>j的路径,这两条路径都属于情况2。因为这是i–>j的最短路径,最短路径的子路径也是最短路径,所以前述的i–>k和k–>j的路径分别是其对应一组起点和终点的最短路径。

不妨将节点的集合{1, 2, 3, …, k}记作集合K。
使用矩阵D表示最短路径,其中D[i][j]表示节点i到节点j的最短路径。

初始,K为空集,此时可以走的最短路径即只有直达的路径,此时的D即为图的邻接矩阵(不存在的路径为正无穷,自己到自己的路径为0)。

K进行扩增,为{1},此时计算经过一个中间点(1号节点)的最短路径。使用以下公式:
d i j 1 = m i n ( d i j 0 , d i 1 0 + d 1 j 0 ) d_{ij}^{1}=min(d_{ij}^{0}, d_{i1}^{0}+d_{1j}^{0}) dij1=min(dij0,di10+d1j0)
如果经过1号点没有得到更短的路径,则会保留原来的路径 d i j 0 d_{ij}^{0} dij0,如果经过1号节点有了更短的路径,则会保留更短的 d i 1 0 + d 1 j 0 d_{i1}^{0}+d_{1j}^{0} di10+d1j0。如果路径不存在会得到正无穷。

需要说明的是,在此处,对应上方分析的k=1,如果路径不存在,对应情况1,如果路径本来就存在,且加入节点1以后,并没有对原有的路径有更短的路径优化,对应情况2,如果加入节点1以后,节点通过i–>1–>j有了更短的路径(包括出现新的路径,相当于当前路径的距离相比于无穷远更短),则对应情况3,其中子路径最短的条件,在上一层k=0时可以确保。

进一步扩增K,为{1, 2},此时在加入了1号节点的基础上(上一步k=1时更新的矩阵D)加入2号节点,去寻找最短路径。

以此类推,可以得到一个递推公式:
d i j k = m i n ( d i j k − 1 , d i k k − 1 + d k j k − 1 ) d_{ij}^{k}=min(d_{ij}^{k-1}, d_{ik}^{k-1}+d_{kj}^{k-1}) dijk=min(dijk1,dikk1+dkjk1)

则我们可以得到以下算法表述:

for (k=1;k<=n;k++)  // 每次新增加进来的k
	for (i=1;i<=n;i++)
		for(j=1;j<=n;j++)  // 两层循环遍历每一对起点终点对
			d[i][j] = min(d[i][j], d[i][k], d[k][j]);  // 递推公式

d[i][j]初始是邻接矩阵,结束以后d[i][j]表示节点i到节点j的最短路径。

注意
不存在的路径在理论上距离是无穷大,在实际代码实现中,路径不存在只能用一个很大的数INF来表示“无穷大”的概念,然而INF是有实际数值的。
当i–>j的路径不存在的时候,由于更新公式为 d i j k = m i n ( d i j k − 1 , d i k k − 1 + d k j k − 1 ) d_{ij}^{k}=min(d_{ij}^{k-1},d_{ik}^{k-1}+d_{kj}^{k-1}) dijk=min(dijk1,dikk1+dkjk1),在其中,上一层路径也一定不存在即 d i j k − 1 = = I N F d_{ij}^{k-1}==INF dijk1==INF,但 d i k k − 1 d_{ik}^{k-1} dikk1 d k j k − 1 d_{kj}^{k-1} dkjk1有可能其中一个存在,即i–>k和k–>j中一个存在另一个不存在(该情况仍然满足i–>j不存在),且存在的路径权重为负数,则递推公式会更新 d i j k d_{ij}^{k} dijk
假设 d i k k − 1 = = I N F d_{ik}^{k-1}==INF dikk1==INF,而 d k j k − 1 = = − 1 d_{kj}^{k-1}==-1 dkjk1==1,则 d i j k = = I N F − 1 d_{ij}^{k}==INF-1 dijk==INF1,尽管此时的i–>j路径是不存在的,理论上无穷大-1仍然是无穷大,但代码实现中已经不再是INF的数值。
此INF数值的衰减可能随着循环不断累积,所以不能依据 d i j = = I N F ? d_{ij}==INF? dij==INF?来判断路径存在与否。
但因为图的规模和INF的数量级往往有差距,所以只需要判断最终的 d i j d_{ij} dij是否足够大即可,不妨取阈值为INF/2,可以依据路径是否大于INF/2来判断路径是否存在。

如果确定存在路径,要找最短路径,需要一个额外数组,记为P,P[i][j]表示从i到j需要经过的中间点。P初始化为全0或全-1。
每次 d i j k = m i n ( d i j k − 1 , d i k k − 1 + d k j k − 1 ) d_{ij}^{k}=min(d_{ij}^{k-1},d_{ik}^{k-1}+d_{kj}^{k-1}) dijk=min(dijk1,dikk1+dkjk1)中出现 d i j k − 1 > d i k k − 1 + d k j k − 1 d_{ij}^{k-1}>d_{ik}^{k-1}+d_{kj}^{k-1} dijk1>dikk1+dkjk1时,即新的最短路径经过了k节点,则在P[i][j]中记录k。
寻找i–>j最短路时,先看P[i][j]是否为初始值,若不是(假设为k),则分别寻找P[i][k]P[k][j],如此递归直至找到初始值(此时意味着这两点之间直接有一条边相连),将所有的点串起来即找到了最短路径。

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]);
}
  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值