图算法(二)最短路

本文将介绍三种常见的最短路算法:Dijkstra,Floyd,SPFA

Dijkstra

Dijkstra是有向图上的单源最短路径算法,本质是一种贪心。给定一个有向图G(V,E)和起点s,基础的Dijkstra算法会在O(|V|^2)的时间复杂度内求出从s出发到所有点的最短路长度。

Dijkstra算法要求图中不能有负权边,其算法描述如下:

  1. 建立一个空的优先队列Q;
  2. 把所有顶点根据与s的距离dis[i]插入优先队列,其中dis[s]=0,与s不相连的顶点距离设为INF;
  3. 每次从Q中取出与s距离最近的顶点u,对点u的每条出边e=(u,v),更新dis[v] = min(dis[v],dis[u] + l(u, v));
  4. 重复3操作,直到所有顶点都被取出,结果中dis[i]代表s到i的最短距离。

如果用数组维护dis[i],那么每次查找与s最近的点和更新操作代价都是O(|V|),算法整体复杂度O(|V|^2),这个复杂度在稠密图的情况下是很理想的;如果用堆(优先队列)实现,那么每次查找最近点的代价变成O(lg|V|),在堆中的decrease-key操作是O(lg|V|)的,算法中最多有|V|次查找和|E|次更新,算法整体复杂度O((|V| + |E|) lg|V|),在稀疏图中是一种优化。

正确性证明:
Dijkstra每次取出的点都有唯一的“前驱”,因此我们是可以恢复出一条从起点到终点的路径的,我们只要证明这条路径就是达到目标点的最短路径。采取归纳法证明:

  1. 除起点s外第一个被取出的点u找不出比s->u更短的路径。假如有一条更短的路:s->v...->u,由于不存在负权边,那么l(s,v)<l(s,u),这与Dijkstra每次取dis最短的点矛盾;
  2. 假设已经被取出的点都满足条件,Dijkstra选中的下一个与s最近的点是v,其前驱是u(u可能与s重合),那么Dijkstra给出的路径L1为:s->s1->...->sk->u->v,其中s1...sk,u都是已经被取出的点,其中dis[u]+l(u,v)是最小的。假设有一条从s到v的路径L2长度小于L1,那么L2中v的前驱不能是u(如果是,与归纳假设矛盾),如果不是u,则只能是一个还没被取出的点t(如果不是,与刚才dis[u]+l(u,v)的最小性矛盾),那么此时dis[t]<dis[v],v不应该被从队列中选取,矛盾!
dijkstra.jpg

C++实现:

void dijkstra(int s) {  // s: starting vertix
    std::priority_queue<Node, std::vector<Node>, std::greater<Node> > q;
    for(int i = 0; i < n; i++)
        dis[i] = INF;
    dis[s] = 0;
    q.push(std::make_pair(0, s));

    for(int i = 0; i < n; i++) {
        while(!q.empty() && q.top().first > d[q.top().second])
            q.pop();
        // the graph may be unconnected
        if(q.empty()) break;
          
        int hd = q.top().second;
        q.pop();

        for(Edge *p = e[hd]; p; p = p->next)
            if(dis[hd] + p->len < dis[p->dst])
                q.push(std::make_pair(dis[p->dst] = p->len + dis[hd], p->dst));
    }
}

Floyd

Floyd是有向图上的多源最短路算法,本质是动态规划。给定有向图G(V, E),Floyd算法可以在O(|V|^3)的时间复杂度内算出图上任意两点之间的最短距离。算法描述如下:

  1. 初始化邻接矩阵dis:i=j时,dis[i][j] = 0,i != j时,若有从i到j的有向边e,dis[i][j]等于e的权值,若没有,dis[i][j] = INF;
  2. 选取一个新的中间节点k,对于所有的(i, j)对,如果dis[i][j] < dis[i][k] + dis[k][j],则更新dis[i][j]的值为dis[i][k] + dis[k][j];
  3. 重复操作2,直到所有的点都成为过中间节点,结果中dis[i][j]表示i到j的最短距离。

正确性证明:
Floyd算法中的中间节点k可以理解为:从起点i到终点j,只经过1-k这些点可以得到的最短路是多少。算法结束时dis[i][j]对应的就是从顶点i到顶点j只经过顶点1-|V|的最短路,就是所求的结果。所以我们只要证明:前k个中间节点处理完后,dis[i][j]的值为从起点i只经过1-k中的点到达终点j的最短长度。用归纳法描述:

  1. k=1时显然(dis[i][j]要么是i和j的距离,要么是i到1的距离加上1到j的距离,且保证是两者中较短的)
  2. 如果k-1得证,对于k的情况,从i到j的最短路要么经过k,要么不经过k。经过k时,dis[i][j]会更新为dis[i][k]+dis[k][j],否则dis[i][j]不变。这两种情况途径点的标号都不会超过k,而且由归纳假设可确保是最短的。

C++实现:

void floyd() {
    // suppose dis matrix is intialized
    for(int k = 1; k <= n; k++)
        for(int i = 1; i <= n; i++)
            for(int j = 1; j <= n; j++)
                dis[i][j] = std::min(dis[i][j], dis[i][k] + dis[j][k]);
}

因为只是一个三重循环,Floyd算法一定会终止,那么它是否能处理有负边的情况呢?答案是可以的,负边对我们的归纳并没有影响。

SPFA

SPFA(Shortest Path Faster Algorithm)是有向图上的单源最短路算法,它最大的亮点是可以处理负边,而且在大部分情况下运行效率很高。其算法描述如下:

  1. 初始化dis数组,其中dis[s]为0,其他值为INF,初始化一个队列,其中只有s一个元素;
  2. 从队列中取出第一个元素hd,对hd的每一条出边e(hd, i),更新dis[i] = min(dis[i], dis[hd] + weight[hd][i]),如果dis[i]被更新(更新有时被称为松弛操作)了且i不在队列中,那么把i加入队列末尾;
  3. 重复2操作直到队列为空

正确性证明:
SPFA的正确性是三种算法中最不显然的。首先证明算法是会终止的,向队列中加入顶点i要求dis[i]被更新为更小的值,而没有负环时,dis[i]是有下界的,即每个点只会被放入队列有限次,每一次循环都会取出一个顶点,所以没有负环时,循环一定会终止。而在有负环的时候,SPFA会陷入死循环。
接着证明SPFA所得的dis[i]就是从起点s到终点i的最短路,先证明一个引理:每次检查队列是否为空时,所有能引起松弛操作的点都在队列中。

We want to show that if dis[w] > dis[u] + weight[u][w] at the time the condition is checked, u is in the queue. We do so by induction on the number of iterations of the loop that have already occurred. First we note that this certainly holds before the loop is entered: if u!=v, then relaxation is not possible; relaxation is possible from u=v, and this is added to the queue immediately before the while loop is entered. Now, consider what happens inside the loop. A vertex u is popped, and is used to relax all its neighbors, if possible. Therefore, immediately after that iteration of the loop, u is not capable of causing any more relaxations (and does not have to be in the queue anymore). However, the relaxation by u might cause some other vertices to become capable of causing relaxation. If there exists some x such that dis[x] > dis[w] + weight[w][x] before the current loop iteration, then w is already in the queue. If this condition becomes true during the current loop iteration, then either dis[x] increased, which is impossible, or dis[w] decreased, implying that w was relaxed. But after w is relaxed, it is added to the queue if it is not already present.

SPFA算法结束时队列为空,代表没有松弛操作可以做了。而我们给出一个dis数组,它是最短路问题解的充要条件就是不可以再执行松弛操作。所以SPFA给出的解一定正确的。

时间复杂度:
段凡丁在提出SPFA的论文中指出SPFA的时间复杂度是O(|E|)的,但原文的证明很不严谨,关键在于他的一个结论:平均每个点进入队列的次数是一个常数。我暂时也没有找到很好的证明,不过一般认为SPFA的平均时间复杂度就是O(|E|)的。值得一说的是,SPFA在效率上并没有Dijkstra稳定,原因就在于顶点可能多次被加入队列,如果很多点都存在“多跳路径短于少跳路径”的话,SPFA就会变得很慢。

C++实现:

void spfa(int s) {
    std::queue<int> q;
    for(int i = 0; i < n; i++)
        dis[i] = INF;
    dis[s] = 0;
    q.push(s); vis[s] = true;

    while(!q.empty()) {
        int hd = q.front();
        q.pop(); vis[hd] = false;
        for(Edge *p = e[hd]; p; p = p->next)
            if(dis[hd] + p->len < dis[p->dst]) {
                dis[p->dst] = dis[hd] + p->len;
                if(!vis[p->dst])
                    q.push(p->dst), vis[p->dst] = true;
            }
    }
}

本文图片来自 Lecture Slides for Alogorithm Design by Jon Kleinberg and Éva Tardos.

推荐阅读更多精彩内容