搜索与图论之最短路算法

目录

 1.朴素dijkstra算法

2.堆优化版dijkstra算法

        分析

为什么Dijkstra不能处理(有些)负权边

负权概念

3.Bellman-Ford算法

几点说明:

2.memcpy()函数:memcpy_百度百科,memcpy函数效率很高

4(1)spfa算法求最短路

几点说明:

1.spfa算法实际上是对Bellman-Ford算法的一个优化

2.区分顶点和顶点下标

3.如何判断最短路是否存在

解释:st数组的作用

4(2)spfa算法判断负环

原理:对于一个n个点,m条边的有向图,如果到达一个点的最短路大于等于n,那么就一定存在负环。

几点说明:

5.Flord算法

思想(动态规划)

说明:


 1.朴素dijkstra算法

例题引入:849. Dijkstra求最短路 I - AcWing题库

 

AC代码

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

using namespace std;

const int N = 510;

int g[N][N], dist[N];   //邻接矩阵,到节点i的最短路径    
bool st[N]; //判断i点是否已经找到了最短路径
int n, m;

void djikstra()
{
    memset(dist, 0x3f, sizeof dist);    //初始化所有最短路径为最大值
    
    dist[1] = 0;    //以节点1为起点,所以到节点1的最短距离为0
    
    for(int u = 1; u <= n; u ++ )   
    {        
        int k = -1;
        for(int i = 1; i <= n; i ++ )   //找到当前状态下所有最短路径中最短的一个
            if((k == -1 || dist[k] > dist[i]) && !st[i])  
                k = i;
        
        
        st[k] = true;   //标记这个节点的最短路径已经找到了
        
        for(int i = 1; i <= n; i ++ )   //用这个最短路径更新所有最短路径
            dist[i] = min(dist[i], dist[k] + g[k][i]);
        
    }
}


int main()
{
    scanf("%d%d", &n, &m);
    
    memset(g, 0x3f, sizeof g);  
    
    for(int i = 0; i < m; i ++ )
    {
        int a, b, c;
        cin >> a >> b >> c;
        g[a][b] = min(g[a][b], c);  //通过这一步可以忽略掉重边并取得最小的边值
    }
    
    djikstra();
    
    
    if(dist[n] == 0x3f3f3f3f)   puts("-1"); //要判断是否有路
    else    cout << dist[n] << endl;
    
    return 0;
}


2.堆优化版dijkstra算法

例题引入:850. Dijkstra求最短路 II - AcWing题库

AC代码

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

using namespace std;

typedef pair<int, int> PII;
const int N = 160010;

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

void add(int a, int b, int c)
{
    w[idx] = c; //在赋权值的时候,要在idx++之前!
    e[idx] = b, ne[idx] = h[a], h[a] = idx ++ ;
}

void djikstra()
{
    memset(dist, 0x3f, sizeof dist);
    
    priority_queue<PII, vector<PII>, greater<PII> > heap;
    heap.push({0,1});   //(距离,下标号)
    
    dist[1] = 0;
    
    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];   //我们在邻接表存的是下标,通过e[i]得到顶点
            if(dist[j] > distance + w[i])   //如果路径可以被优化
            {
                dist[j] = distance + w[i];  //更新路径
                heap.push({dist[j], j});    //假如队列
            }
        }
    }
}


int main()
{
    cin >> n >> m;
    
    memset(h, -1, sizeof h);
    
    while(m -- )
    {
        int a, b, c;
        cin >> a >> b >> c;
        add(a, b, c);
    }
    
    djikstra();
    
    if(dist[n] == 0x3f3f3f3f)   puts("-1");
    else    cout << dist[n] << endl;
    
    
    return 0;
}

分析

  1. 使用优先级队列
    在使用前需要先搞清楚优先级队列的时间复杂度,往一个长度为n的优先级队列中插入一个元素时间复杂度是O(logn)。那么把n个元素插入一个空的优先级队列中时间复杂度就为O(nlogn)。
  2. O(Log n)

    如果在一个大小为n的循环中,循环变量是指数级的递增或递减,这个循环的复杂度就为O(Log n).
  3. 堆优化,主要优化的是找当前状态下的最短的路径的时间,在朴素算法中,是通过一个for循环完成,时间复杂度是线性的,但是在堆优化版本中,使用优先队列可以在常数时间内取出这个最短的路径,即队头,不过需要注意的是维护优先队列的时间复杂度是为O(N)的
  4. 优先队列的实现方式通过一个pair,储存最短路和下标


为什么Dijkstra不能处理(有些)负权边

dijkstra不能在负权图上使用的理解


负权概念

不要把边权想象成距离,而是想象成代价(cost),就会好理解很多。
比如,cost(a, b)可以理解成开车从a到b的油耗。那么,如果a,b之间有一个加油站,油耗就可以是负的。


3.Bellman-Ford算法

 例题引入:853. 有边数限制的最短路 - AcWing题库

AC代码

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

using namespace std;

const int N = 510, M = 10010;

int dist[N], backup[N]; //backup是dist的一个备份
int n, m, k;

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

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 = edge[j].a, b = edge[j].b, w = edge[j].w;
            dist[b] = min(dist[b], backup[a] + w);  //每次更新到顶点b的边
        }
    }
}

int main()
{
    cin >> n >> m >> k;
    
    for(int i = 0; i < m; i ++ )
    {
        int a, b, w;
        cin >> a >> b >> w;
        edge[i] = {a, b, w};
    }
    
    Bellman_Ford();
    
    if(dist[n] > 5000010)    //一条边的最大距离
        cout << "impossible" << endl;
    else    cout << dist[n] << endl;
    
    return 0;
}

几点说明:

1.backup数组的作用:避免最短路串联

解释:如果我没不设置一个dist数组的拷贝backup数组,那么对于某些情况,例如样例:

3 3 1

1 2 2

1 3 4

2 3 1 

如果K = 1,只迭代一次,那么dist[3]的距离应该是min(dist[3], dist[2] + 1),即min(4,正无穷),注意第一次更新时dist[2]的距离应该是正无穷而不是2,只要理解了这一点问题就迎刃而解了。

我们在第一次迭代的时候,事实上在更新dist[3]之前,我们已经把dist[2]更新成2了,那么再更新dsit[3],就有dist[3] = min(dist[3],dist[2] + 1) = min(3,4) = 3 ≠ 4,答案就错误了,错误的原因就在于我们把1->2和->3串联起来了,实际上1->2现在是没有路可走的。

因为我们不可能不更新dist数组,所以我们就要想办法得到一份dist的复制品

所以在每一次迭代的时候拷贝一份dist数组,在更新的时候使用backup数组就可以了,这样前面更新的值就不会影响后面的结果。

2.memcpy()函数:memcpy_百度百科,memcpy函数效率很高

3.为什么判断不存在通路的时候是dist[n] > 5000010而不是dist[n] == 0x3f3f3f3f

1节点无法走到n节点,不代表任意节点都走不到n节点,如果有节点可以走到n节点,n节点就可以被更新。

例如数据:

2 4

1 2 1

3 4 -2

此时图中只存在两条边(1,2)和(3,4),1节点是无法走到4节点的,但是3节点可以走到4节点

又因为(3,4)的权值为-2,所以4节点的最短路被更新为0x3f3f3f3f - 2。


4(1)spfa算法求最短路

例题引入:851. spfa求最短路 - AcWing题库

 AC代码

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

using namespace std;

const int N = 100010; 

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

void add(int a, int b, int c)   //邻接表存贮的都是顶点下标
{
    w[idx] = c; //放在idx++前面   
    e[idx] = b, ne[idx] = h[a], 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]) //区分这里下标j,t,i的含义
            {
                dist[j] = dist[t] + w[i];
                if(!st[j])  //可以入队
                {
                    q.push(j);
                    st[j] = true;   //标记入队
                }
            }
        }
    }
}

int main()
{
    cin >> n >> m;
    
    memset(h, -1, sizeof h);    //邻接表初始化
    
    for(int i = 0; i < m; i ++ )
    {
        int a, b, c;
        cin >> a >> b >> c;
        add(a, b, c);   //邻接表不需要考虑重边和自环
    }
    
    spfa();
    
    if(dist[n] == 0x3f3f3f3f)    //不是dist[n] > INF / 2  !!!!!!!   
        cout << "impossible" << endl;
    else    cout << dist[n];
    
    return 0;
}

几点说明:

1.spfa算法实际上是对Bellman-Ford算法的一个优化

在Bellman-Ford算法中,我们在更新边的时候是使用了一个for循环,但这个for循环中很多操作是多余的,因为很多边并没有更新甚至无法更新(不相连),这就降低了算法的时间效率,而spfa算法正是对Bellman-Ford算法中更新边这一步操作的一个优化,由边的更新公式 dist[j] = dist[t] + w[i];可以知道,如果dist[t]的值不发生改变,那么dist[j]的值也就不会发生改变,所以说J是T的后继节点,我们只需要在T更新的时候再更新所有T的后继节点就可以了。

2.区分顶点和顶点下标

基本上邻接表存储的都是顶点下标,而最短路径dist数组以及状态数组st,队列中的元素存储的都是顶点,不要搞混了。

3.如何判断最短路是否存在

在Bellman_Ford算法中,我们判断最短路存在与否用的是 dist[n] > INF / 2
在spfa算法中,我们用的是 dist == INF
这是因为spfa算法优化了Bellman_Ford算法中的一些无效优化操作
也体现spfa算法和djikstra算法的相似性

解释:st数组的作用

  1. 用来标记一下该顶点是否已经入队,防止重复入队,可以减少操作次数,提高效率,所以没有的话不影响答案的正确性只影响效率。
  2. 但是如果这只st数组,有队头出队的话,要再标记该顶点已经出队,否则就会出错

例如数据:

4 4
1 2 1
2 3 2
3 4 1
1 3 4

 如果我们只标记元素入队而不标记元素出队的话的话,spfa流程是这样的:

初始化顶点1入队,st标记一下

只要队列不为空:

(1)队头(顶点1)出队,更新与顶点1相连的顶点2、3,更新距离:dist[2] = dist[1] + 1 = 1,dist[3] = dist[1] + 4 =  4。顶点2、3没有标记过,可以入队,标记顶点2、3。

(2)队头3出队,更新与顶点3相连的顶点4,更新距离:dist[4] = dist[3] + 1 = 5。顶点4没有标记过,入队,标记顶点4.

(3)顶点2出队,更新与顶点2相连的顶点3,更新距离:dist[3] = dist[2] + 2 = 3。因为顶点3已经标记过了,所以不再入队。

(4)队头4出队,没有相连边,退出。

(5)队列为空,结束。

当程序结束的时候,我们发现,dist[4] = 5,但其实dist[4]应该等于4

错误的原因就在于:顶点4的最短路应该是1->2->3->4,但这里是1->3->4,没有经过顶点2,说明dist[5]里面的dist[3]不是最短路径,所以dist[5]当然也不是最短路径,但最终dist[3]的结果又是正确的,这又是为什么呢?

因为我们使用的dist[3]是更新之前的dist[3],而不是更新过后的dist[3]。

在(2)(3)步骤中,我们先让3出队,直接更新了dist[4],但此时dist[3]不是最短路径,而3出队,2入队的时候,这时候dist[4]已经更新完了,我们才更新dist[3],又因为3已经入队过了,所以3不再入队,那么4就失去了更新的机会,导致答案出错。

不过如果把步骤(2)(3)颠倒一下,其实答案又是对的,但这显然是在瞎蒙。

综上所述:spfa算法中我们在设置st(state)数组时,不仅要标记元素入队,还要标记元素出队,让最短路径始终可以保持更新(即保证最短路径的子路径也是最优的)。


4(2)spfa算法判断负环

例题引入:852. spfa判断负环 - AcWing题库

原理:对于一个n个点,m条边的有向图,如果到达一个点的最短路大于等于n,那么就一定存在负环。

AC代码

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

using namespace std;

const int N = 10010;

int h[N], e[N], ne[N], w[N], idx;
int dist[N], cut[N];    //cut[i]用来存储从1到达顶点i走过的边数
int n, m;
bool st[N];

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

bool spfa()
{
    memset(dist, 0x3f, sizeof dist);
    dist[1] = 0;
    queue<int> q;
    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];
            if(dist[j] > dist[t] + w[i])
            {
                dist[j] = dist[t] + w[i];
                cut[j] = cut[t] + 1;
                if(cut[j] >= n) return true;
                if(!st[j])
                {
                    st[j] = true;
                    q.push(j);
                }
                
            }
        }
    }
    
    return false;
}

int main()
{
    cin >> n >> m;
    
    memset(h, -1, sizeof h);
    
    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;
}

几点说明:

  1. 初始化队列时不能只放入顶点1,因为我们的最短路不一定是从点1开始的,其他顶点同理,所以初始化时要把所有顶点都放入队列
  2. 整体上是spfa找最短路算法的基础上加了一个cut数组,以及多了一步判断


5.Flord算法

思想(动态规划):对于每一个顶点v,遍历所有边(i,j),判断顶点v是否可以更新边(i,j)为(i,k,j)

例题:854. Floyd求最短路 - AcWing题库

 

AC代码

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

using namespace std;

const int N = 210, INF = 1e9;

int g[N][N];
int n, m, q;

void floyd()
{
    for(int v = 1; v <= n; v ++ )   //遍历所有顶点
        for(int i = 1; i <= n; i ++ )
            for(int j = 1; j <= n; j ++ )
                g[i][j] = min(g[i][j], g[i][v] + g[v][j]);
}

int main()
{
    cin >> n >> m >> q;
    
    for(int i = 1; i <= n; i ++ )
        for(int j = 1; j <= n; j ++ )
            if(i == j)  g[i][j] = 0;    //删除自环(权值等于0没有影响)
            else    g[i][j] = INF;
    
    for(int i = 0; i < m; i ++ )
    {
        int a, b, c;
        cin >> a >> b >> c;
        g[a][b] = min(g[a][b], c);  //如果有重边就只选最短的那一条
    }
    
    floyd();
    
    while(q -- )
    {
        int a, b;
        cin >> a >> b;
        if(g[a][b] > INF / 2)   cout << "impossible" << endl;
        else    cout << g[a][b] << endl;
    }
    
    
    return 0;
}

说明:

  1. 首先通过数据范围可以看出这是一个稀疏图,所以用稀疏矩阵存储边的数据
  2. 因为题目说明会有重边和自环,对于重边我们只要选取最短的那一条就行了,对于自环我们在初始化为0(加或减0对最短路没有影响),就相当于删除自环了。
  3. 最后在判断有没有最短路的时候还是不能用等于号。具体的看Bellman-Ford算法的说明3
posted @ 2022-05-05 08:41  光風霽月  阅读(49)  评论(0编辑  收藏  举报