学习图论(四)——单源最短路问题

一年前写的最短路的博客写得太一般了,代码也很难看,刚好最近复习到相关知识,所以重新整理一下

下面涉及题目的代码都在力扣上通过。

 

一、DFS

 DFS可以用来求给定两点之间的最短路。即走遍两点间的所有路径,选择路程最小的那一个

我们可以从U出发,记录已经走过的路程。

采用邻接矩阵的方式来表示图:graph[u][v]=w表示u到v之间有一条权重为w的边,若u和v之间无边,则令graph[u][v]=0。

为了防止两点之间有回路导致DFS时一直在回路上绕圈子,我们定义vis数组来记录当前已经访问过的节点,已经访问过的节点不再访问。(回溯的思想)

设一个全局变量MinDistance表示要求的最短路程。

   void dfs(vector>& graph,vector& vis,int src,int dest,int distance){
        if(src==dest){
            //此时得到了起点到终点之间的一条路径,路程为distance
            //假设起点和终点之间的最短路为MinDistance,则执行 MinDistance=min(MinDistance,distance)
        }
        else{
            vis[src]=true;
            for(int next_node=1;next_node

 上面的代码做了优化,因为我们要求的是最短路,那么如果当前走过的路程已经超过了最短的路程,那么没必要再走下去了。


二、迪杰斯特拉算法

迪杰斯特拉算法用于求单源最短路程问题,即假设图中有N个顶点,其中一个顶点为U,那么我们可以用迪杰斯特拉算法求得顶点U与其他N-1个顶点之前的最短路程。

算法原理:在还未被访问的顶点中选择离U最近的顶点MID,以MID作为中介点,更新U通过MID到达与MID相邻的顶点的最短路程。

这里的更新被称为松弛操作。我们通过MID点更新U到V的最短路程,需要:

if(U到V的最短路 > U到MID的最短路+MID到V的距离)
    U到V的最短路 = U到MID的最短路+MID到V的距离

可以发现每次我们选择一个MID,一共有N个节点,除去U不参与选择,则我们要做N-1次相同的操作,每次取出不同的MID。

Leetcode上有对应的题目:743. 网络延迟时间

下面给出该题的题解,使用了迪杰斯特拉算法,核心就是求单源最短路程。如果不想看题目也可以直接看下面的迪杰斯特拉算法的实现。

class Solution {
public:
    int networkDelayTime(vector>& times, int N, int K) {
        vector> graph(N+1,vector(N+1,101));
        for(auto edge:times){
            int from=edge[0],to=edge[1],time=edge[2];
            graph[from][to]=time;
        }
        /*迪杰斯特拉算法*/
        vector dist=dijkstra(graph,K,N);
        int SpeedTime=0;
        for(int i=1;i<=N;++i) SpeedTime=max(SpeedTime,dist[i]),cout< dijkstra(vector>& graph,int src,int N){
        vector vis(N+1,false);
        vector distance(N+1,INT_MAX);

        for(int i=1;i<=N;++i) if(graph[src][i]!=101) distance[i]=graph[src][i];   //初始化与src直接相邻的顶点到src的距离
        distance[src]=0;
        vis[src]=true;

        for(int time=1;timedistance[i]) dist=distance[i],node=i;
            }
            vis[node]=true;
            for(int i=1;i<=N;++i){      //更新未被访问的与node相邻的顶点到src的距离
                if(graph[node][i]==101) continue;
                if(distance[i]>distance[node]+graph[node][i]){
                    distance[i]=distance[node]+graph[node][i];
                }
            }
        }

        return distance;
    }
};

个人对迪杰斯特拉算法的理解是:该算法实际上列举了每个顶点到源点的各种可能路径。对于顶点V,若V与[A,B,C]三个顶点相邻,那么从U到V的路径就可以包含了从U到A再从A到V,B、C同理。算法用A,B,C到U的最短路程以及A,B,C到V的距离来更新U到V的最短路程,保证了在所有U到V的可能路径中选择最小的一个。(有点像广度优先搜索,层层向外扩展)

上面的代码中每次查找相邻顶点我们都要遍历所有顶点(从1到N),很没必要,因此我们可以用邻接表的形式来表示图。

即graph[u]表示一个存放与顶点u相邻的顶点的列表,为了记录顶点之间边的权值,我们可以定义一个结构体或者定义一个vector来记录,比如:

struct edge{
    int to,distance;
};
vector> graph(n)

--------------------------------------

vector>> graph(n);
其中最内一层vector用来表示相邻顶点和边的距离

这样就可以只遍历相邻的节点。

此外,每次要先选取未被访问的与源点最近的一个顶点,我们也是用遍历所有顶点的方式。这里的本质是要取最值,那我们可以借助优先队列,来使得队首元素始终是未被访问的与源点最近的一个顶点,这个就不用每次都去遍历一遍去查找了。

这引申出了迪杰斯特拉算法的队列实现方式:(下面代码还是上面题目的题解,用了优先队列实现迪杰斯特拉算法)

class Solution {
public:
    struct adj{
        int to,time;
    };
    int networkDelayTime(vector>& times, int N, int K) {
        vector> graph(N+1);
        for(auto edge:times){
            int from=edge[0],to=edge[1],time=edge[2];
            graph[from].push_back({to,time});
        }
        /*迪杰斯特拉算法*/
        vector dist=dijkstra(graph,K,N);
        int SpeedTime=0;
        for(int i=1;i<=N;++i) SpeedTime=max(SpeedTime,dist[i]),cout< dijkstra(vector>& graph,int src,int N){
        vector vis(N+1,false);
        vector distance(N+1,100005);   //这里使用INT_MAX要注意计算时可能会整数溢出
        distance[src]=0;
        //优先队列的元素:pair表示{顶点,顶点到源点的距离}
        priority_queue,vector>,greater>> q;
        q.push({0,src});
        while(!q.empty()){
            int node=q.top().second;
            q.pop();
            if(vis[node]==true) continue;
            vis[node]=true;
            for(int i=0;idistance[node]+dist){
                    distance[next_node]=distance[node]+dist;
                    q.push({distance[next_node],next_node});
                }
            }
        }

        return distance;
    }
};

上面优先队列的元素采用了pair,利用了pair内置的比较方法:先比较第一个元素再比较第二个元素,使得优先队列队首元素始终是距离源点最近的一个。这种做法被称为迪杰斯特拉算法的堆优化。

算法 时间复杂度
原始迪杰斯特拉算法 O(N^2)
堆优化迪杰斯特拉算法 O(N*lgN)

三、贝尔曼福特算法(Bellman-Ford)

假设图有N个节点,则该算法一共进行N-1次相同类型的操作(每次可以得到一个顶点到源点的最短路),每次操作是对所有边进行松弛,如下:

int from=边的起点,to=边的终点,weight=边的权重
//distance[node]:表示顶点node到源点的最短距离
if(distance[from]

其中distance[from]

个人的理解是:每次对所有边进行松弛操作,则必有一个顶点到源点的最短距离被计算好。共计算N-1次可以保证N-1个顶点的最短路都被计算出来。(实际上可能不用N-1次就全部计算好,N-1次的极端情况可以联想当图的结构像一条长长的链,且源点是链的端点的时候)

这种算法可以检验出权重和为负值的环的有向图。因为计算N-1次已经保证了可以求得每个顶点到源点的最短路程,若再对所有边做一次松弛操作,由于有一个环的权重和为负值,所以会使得已经计算好的各顶点到源点的最短路程变得更小。以此来检验。

如上图,每对所有边松弛一次,各顶点到源点(①)的最短距离都会减小。

核心代码如下:

bool BellManFord(vector>& edges,int src,int N,vector& distance){
        distance.assign(N+1,INF);   //INF表未计算
        distance[src]=0;
        for(int i=1;i edge=edges[j];
                int from=edge[0],to=edge[1],wieght=edge[2];
                if(distance[from] edge=edges[j];
            int from=edge[0],to=edge[1],wieght=edge[2];
            //若顶点到源点的最短路程还可以更新,则说明有权重和为负值的环
            if(distance[to]>distance[from]+wieght) return false;    
        }

        return true;
    }

上面提到的题目也可以用该算法做,但效率很低。因为这个算法本身的效率就不高

但是该算法还是有好处的,除了上面说的可以检验图是否含有权值和为负值的环之外,该算法在求含有负数权重的边的图时可以得到正确结果,这是迪杰斯特拉算法没有的功能。以下图为例:

上图是用迪杰斯特拉算法求的,distance是各顶点到源点①的最短距离,最终得到的是[0,1,3,6],但实际上④到①的最短路径可以是5。

若用贝尔曼福特算法,每一次对所有边做松弛操作,则每一次至少可以让一个顶点得到其到源点的最短路程。如第一次的时候③到①的最短路就已经计算好了,在此基础上第二次的时候②到①的最短路也可以得到了,第三次的时候④到①的最短路也可以得到了。


四、SPFA算法

该算法是对贝尔曼福特算法的优化,因为当碰到存在负权边的图时,迪杰斯特拉算法不可用,贝尔曼福特算法又效率低下,所以采用SPFA算法会是比较好的选择。

回想贝尔曼福特算法,进行N-1轮循环,每一轮循环对所有边做松弛操作。但其实只有部分边真正可以做松弛操作,就是那些已经计算了到源点的最短路的顶点相连的边。而且这些边中,也只有一部分有真正地用于更新顶点到源点的最短路径。对于没有用于更新顶点到源点的最短路的边,在下一轮的计算中也不会用到。所以我们只需要关注每一轮中,更新了到源点的最短路的顶点相连的边,这样就很大的简化了每次松弛操作的复杂度。

那如何检验权值和为负数的环呢?

因为在贝尔曼福特算法中,每个顶点最多经历N-1松弛就能找到到源点的最短路,SPFA算法是贝尔曼福特算法的优化,所有检验的原理也相同:如果一个顶点被松弛的次数超过N-1次,那就说明图中有权值和为负数的环。

SPFA算法借助队列实现,队列中存放的是经过松弛操作后更新了到源点的最短路的顶点,所以如果一个顶点在队列中出现次数超过N-1次的话就说明图中有权值和为负数的环。

算法实现如下:

bool SPFA(vector>& graph,int src,int N,vector& distance){
        distance.assign(N+1,INF);
        vector inqueue(N+1,false);    //顶点是否在队列中,防止同一轮中对同一条边做多次松弛操作
        vector count(N+1,0);           //记录顶点出现在队列中的次数,用于检验权值和为负数的环
        distance[src]=0,count[src]=1;
        queue q;   
        q.push(src);
        inqueue[src]=true;
        //对于贝尔曼福特算法,其实每一轮松弛只松弛了与已经计算了最短路的节点有关的边
        //所以用队列存放每一轮经过松弛且更新了到源点的最短路径的节点
        //现在每次只松弛这些节点相关的边就好
        while(!q.empty()){
            int ndoe=q.front();
            q.pop();
            inqueue[ndoe]=false;
            for(int i=0;idistance[ndoe]+weight){
                    distance[next_node]=distance[ndoe]+weight;
                    if(inqueue[next_node]==false){
                        q.push(next_node),inqueue[next_node]=true;
                        count[next_node]+=1;
                        if(count[next_node]==N) return false;
                    }
                }
            }
        }
        return true;
    }

主要在于理解inqueue数组的用法,见代码中的注释。


五、小结

DFS是学习图论时必学的基础。

迪杰斯特拉算法是非常经典的求最短路的算法,掌握其采用优先队列实现的方法更好,算法效率更高,但注意无法正确求解某些带有负权边的图的最短路。

贝尔曼福特可以解带有负权边的图的最短路,但效率实在是太低了,可以采用对贝尔曼福特算法进行优化的SPFA算法。

上述的迪杰斯特拉、贝尔曼福特、SPFA算法都只是用于求单源最短路问题。

就效率来说,一般采用SPFA算法效率会高一些,但这要看图的结构。

就上面提到的题目来说,效率排行是:SPFA>迪杰斯特拉>贝尔曼福特

你可能感兴趣的:(图论)