zoukankan      html  css  js  c++  java
  • 图-最短路径-Dijkstra及其变种

    最短路径

    最短路径问题:

    给定任意的图G(V,E) 和起点 S,终点 T,如何求从 S 到 T 的最短路径。

    解决最短路径的常用方法有

    • Dijkstra 算法
    • Bellman-Ford 算法
    • SPFA 算法
    • Floyd 算法

    这里主要对 Dijkstra 算法及其变种进行总结。

    Dijkstra 算法

    算法思想

    Dijkstra 算法用来解决单源最短路径问题,即给定图 G 和起点 s,通过算法得到 S 到达其他每个顶点的最短距离。

    Dijkstra 算法的基本思想

    对于图 G(V,E)设置集合 S,存放已被访问的顶点,然后每次从集合 V-S 中选择与起点 s 的最短距离最小的一个顶点(记为u),访问并加入集合 S。之后,令顶点 u 为中介点,优化起点 s 与所有从 u 能到达的顶点的最短距离。这样的操作执行 n (n为顶点个数),直到集合 S 已经包含所有顶点。

    详细策略:

    首先设置集合 S 存放已经被访问的顶点,然后执行 n 次(顶点数)下面的两个步骤

    1. 每次从集合 V - S 中选择与起点 s 最短距离的一个顶点(记为 u),访问并且加入集合 S
    2. 令顶点 u 为中介点,优化所有能从 u 到达的顶点 v 之间的最短距离

    具体实现

    在实现过程中,有两个主要问题需要考虑:

    • 集合 S 的实现
    • 起点 s 到达顶点 Vi ( 0<= i <= n-1)的最短距离的实现
    1. 集合 S可以用一个 bool 型数组 vis[] 来实现,即当 vis[i] == true 时表示顶点 Vi 已经被访问
    2. 令 int 型数组 d[] 表示起点到达顶点 Vi 的最短距离,初始时除了起点 s 的 d[s] = 0 以外,其余顶点都赋为一个很大的数

    伪代码如下:

    void Dijkstra(G, d[], s) {
      	初始化;
        for(循环n次) {
           u = 使 d[u] 最小的,还未访问的顶点标号;
           记录 q 被访问;
           for(从u出发能够到达的所有顶点v){
             if(v未被访问 && 以u为中介点使得s到v的最短距离d[v]更优) {
               优化d[v];
             }
           }
        }
    }
    

    邻接矩阵版

    const int MAXV = 1000;	// 最大顶点数
    const int INF = 1e9;		// INF很大的数字
    
    int n, G[MAXV][MAXV];		// n 为顶点数,MAXV为最大顶点数
    int d[MAXV];						// 起点到达各点的最短路径长度
    bool vis[MAXV] = {false};	// 是否被访问过
    
    // s 为起点
    void Dijkstra(int s) {
    		fill(d, d+MAXV, INF);	// 初始化距离为最大值
      	d[s] = 0;
      	for(int i = 0; i < n; i++) {	// 重复 n 次
          int u = -1, MIN = INF;
          
          // 遍历找到未访问顶点中 d[] 最小的
          for(int j = 0; j < n; j++) {
            if(vis[j] == false && d[j] < MIN) {
              u = j;
              MIN = d[j];
            }
          }
          
          if(u == -1) return;	// 找不到小于 INF 的 d[u],说明剩下的顶点和起点 s 不连通
          vis[u] = true;	// 标记 u 为已访问
          for(int v = 0; v < n; v++) {
            // 如果v未访问 && u 能到达 v && 以 u 为中介点可以使 d[v] 更优
            if(vis[v] == false && G[u][v] != INF && d[u] + G[u][v] < d[v]) {
              d[v] = d[u] + G[u][v];
            }
          }
        }  
    }
    

    时间复杂度:外层循环 O(V),内层循环 O(V),枚举 v 需要 O(V) ,总复杂度为 O(V*(V+V)) = O(V2 )。

    邻接表版

    struct Node {
      int v, dis;	// v 为目标顶点,dis 为边权
    };
    vector<Node> Adj[MAXV];	//图G,Adj[u]存放从顶点 v 出发可以到达的所有顶点
    int n;	// n为顶点数
    int d[MAXV];	// 起点到达各点的最短路径长度
    bool vis[MAXV] = {false};
    
    void Dijkstra(int s) {// s 为起点
      fill(d, d+MAXV, INF);
      d[s] = 0;	// 到自身为 0
      for(int i = 0; i < n; i++) {	// 循环 n 次
        int u = -1, MIN = INF;
        
        // 遍历找到未访问顶点中 d[] 最小的
          for(int j = 0; j < n; j++) {
            if(vis[j] == false && d[j] < MIN) {
              u = j;
              MIN = d[j];
            }
          }
          
          if(u == -1) return;	// 找不到小于 INF 的 d[u],说明剩下的顶点和起点 s 不连通
          vis[u] = true;	// 标记 u 为已访问
        
        // 和邻接矩阵不同
        for(int j=0; j < Adj[u][j].size(); j++) {
          int v = Adj[u][j].v;	// 获得u能直接到达的顶点
          // v 未访问 && 以 u 为中介点到达 v 比 d[v] 更短
          if(vis[v] == false && d[u] + Adj[u][j].dis < d[v]) {
            // 更新
            d[v] = d[u] + Adj[u][j].dis;	
          }
        } 
      }
    }
    

    当题目给的是无向边时(双向边)而不是有向边时,只需要把无向边当成两条指向相反的有向边即可。

    最短路径

    我们这时候还没说到最短路径如何记录,我们回到伪代码,有这样一步

    	for(从u出发能够到达的所有顶点v){
             if(v未被访问 && 以u为中介点使得s到v的最短距离d[v]更优) {
               优化d[v];
             }
           }
    

    我们在这个时候吧这个信息记录下来,也就是设置一个 pre[] 数组,令 pre[v] 表示从起点 s 到顶点 v 的最短路径上的前一个顶点(前驱结点)的编号,这样,当伪代码中条件成立时,就把 u 赋给 pre[v] ,最终就记录下来了。

    伪代码变成了:

    for(从u出发能够到达的所有顶点v){
             if(v未被访问 && 以u为中介点使得s到v的最短距离d[v]更优) {
               优化d[v];
               令 v 的前驱为 u
             }
           }
    

    以邻接矩阵为例:

    const int MAXV = 1000;	// 最大顶点数
    const int INF = 1e9;		// INF很大的数字
    
    int n, G[MAXV][MAXV];		// n 为顶点数,MAXV为最大顶点数
    int d[MAXV];						// 起点到达各点的最短路径长度
    int pre[MAXV];	// 记录最短路径
    bool vis[MAXV] = {false};	// 是否被访问过
    
    // s 为起点
    void Dijkstra(int s) {
    		fill(d, d+MAXV, INF);	// 初始化距离为最大值
      	d[s] = 0;
      	for(int i = 0; i < n; i++) {	// 重复 n 次
          int u = -1, MIN = INF;
          
          // 遍历找到未访问顶点中 d[] 最小的
          for(int j = 0; j < n; j++) {
            if(vis[j] == false && d[j] < MIN) {
              u = j;
              MIN = d[j];
            }
          }
          
          if(u == -1) return;	// 找不到小于 INF 的 d[u],说明剩下的顶点和起点 s 不连通
          vis[u] = true;	// 标记 u 为已访问
          for(int v = 0; v < n; v++) {
            // 如果v未访问 && u 能到达 v && 以 u 为中介点可以使 d[v] 更优
            if(vis[v] == false && G[u][v] != INF && d[u] + G[u][v] < d[v]) {
              d[v] = d[u] + G[u][v];
              pre[v] = u; // 记录 v 的前驱结点是 u
            }
          }
        }  
    }
    
    // 输出结点
    void DFS(int s, int v) {
      if(v == s) {	// 已经递归到起点 s
        printf("%d
    ", s);
        return;
      }
      DFS(s, pre[v]);			// 递归访问 v 的前驱顶点 pre[v]
      printf("%d
    ", v);	// 从最深处 return 回来之后输出每一层的结点号
    }
    

    多条最短路径

    我们此时已经学会了 Dijkstra 和最短路径的求法,但是通常情况下最短路径不止一条。

    于是碰到这种有两条以上可以达到的最短路径,题目就会给出第二标尺(第一标尺是距离),要求在所有最短路径中选择第二标尺最优的一条路径。

    通常有以下三种方式:

    1. 给每条边再增加一个边权(比如花费)
    2. 给每个点增加一个点权
    3. 直接问有多少条最短路径

    对于这三种提问,都只需要增加一个数组来存放新增的边权或点权或最短路径数,然后修改优化 d[v] 的那个步骤即可。

    对于以上三种提问,分别的解决办法:

    1. 新增边权。以新增边权代表花费为例,用 cost[u][v] 代表从 u->v 的花费,增加一个数组 c[] ,令从起点 s 到顶点 u 的最少花费为 c[u],初始化时只有 c[s] = 0,其余都为 INF(距离最大)。然后在 d[u] + G[u][v] < d[v] 时,更新 d[v] c[v],而当 d[u] + G[u][v] == d[v]c[u]+cost[u][v] < c[v]时更新 c[v]
    for(int v = 0; v < n; v++) {
            // 如果v未访问 && u 能到达 v && 以 u 为中介点可以使 d[v] 更优
            if(vis[v] == false && G[u][v] != INF) {
              if(d[u] + G[u][v] < d[v]) {
                d[v] = d[u] + G[u][v];
                c[v] = c[u] + cost[u][v];
              }else if(d[u] + G[u][v] == d[v] && c[u] + cost[u][v] < c[v]) {
                c[v] = c[u] + cost[u][v];
              }
            }
          }
    
    1. 同上,就是换成权重数组。
    2. 只需要增加一个数组 num[] ,令从起点 s 到达顶点 u 的最短路径条数为 num[u] ,初始化时,num[s] = 1,其余为 0。当 d[u] + G[u][v] < d[v] 时,让 num[v] 继承 num[u]。而当 d[u] + G[u][v] == d[v] ,将 num[u] 加到 num[v] 上。代码如下:
    for(int v = 0; v < n; v++) {
            // 如果v未访问 && u 能到达 v && 以 u 为中介点可以使 d[v] 更优
            if(vis[v] == false && G[u][v] != INF) {
              if(d[u] + G[u][v] < d[v]) {
                d[v] = d[u] + G[u][v];
                num[v] = num[u];
              }else if(d[u] + G[u][v] == d[v] && c[u] + cost[u][v] < c[v]) {
                num[v] += num[u]; //最短距离相同时累加 num
              }
            }
          }
    

    通过两个题目来巩固最短路径:

    PTA 1003,这个题目非常值得一做,考虑 Dijkstra 算法三种变种,能够很好的熟悉 Dijkstra。

    Dijkstra+DFS

    上述题目一般都用了 Dijkstra 来做,然后有多个标尺的情况下很容易出错,这里介绍一种更通用、模板化的方法——Dijkstra+DFS。

    在算法中 pre 数组总是保持着最优路径,而这显然需要在执行 Dijkstra 算法的过程中来确定何时更新每个节点 v 的前驱结点 pre[v] 。更简单的方法是:先在Dijkstra算法中记录下所有最短路径(只考虑距离),然后从这些路径中选择一条第二标尺最优的路径

    1. 使用 Dijkstra 算法记录所有最短路径

    由于需要记录所有最短路径,所以每个节点就会存在多个前驱结点,这样可以使用 vector<int> pre[MAXV] 来保存前驱结点。对于每个节点 v 来说,pre[v] 就是一个变长数组 vector ,里面用来存放结点 v 的所有能产生最短路径的前驱结点。

    接下来考虑更新 d[v] 的过程中 pre 数组的变化。首先,如果 d[u]+G[u][v] < d[v],说明以 u 为中介点可以使 d[v] 更优,此时令 v 的前驱结点为 u,并且即使之前 pre[v] 中已经存放了若干结点,此处也应该清空,然后再添加 u,因为此时之前保存的不是最优路径了。

    if(d[u] + G[u][v] < d[v]) {
      d[v] = d[u] + G[u][v];
      pre[v].clear();
      pre[v].push(u);
    }else if(d[u] + G[u][v] == d[v]) {
      pre[v].push(u);
    }
    

    那么我们就可以编写完整的代码如下:

    vector<int> pre[MAXV];
    // s 为起点
    void Dijkstra(int s) {
    		fill(d, d+MAXV, INF);	// 初始化距离为最大值
      	d[s] = 0;
      	for(int i = 0; i < n; i++) {	// 重复 n 次
          int u = -1, MIN = INF;
          
          // 遍历找到未访问顶点中 d[] 最小的
          for(int j = 0; j < n; j++) {
            if(vis[j] == false && d[j] < MIN) {
              u = j;
              MIN = d[j];
            }
          }
          
          if(u == -1) return;	// 找不到小于 INF 的 d[u],说明剩下的顶点和起点 s 不连通
          vis[u] = true;	// 标记 u 为已访问
          for(int v = 0; v < n; v++) {
            // 如果v未访问 && u 能到达 v && 以 u 为中介点可以使 d[v] 更优
            if(vis[v] == false && G[u][v] != INF) {
              if(d[u] + G[u][v] < d[v]) {
                d[v] = d[u] + G[u][v];
                pre[v].clear();
                pre[v].push_back(u);
              }else if(d[u] + G[u][v] == d[v]) {
                pre[v].push_back(u);
              }
            }
          }
        }  
    }
    
    1. 遍历所有最短路径,找出一条第二标尺最优的路径。

    由于每个结点的前驱结点可能有多个,遍历的过程就会形成一递归树,我们可以使用DFS来寻找到最优路径。对树进行遍历时,每次到达叶子结点时就会产生一条完整的最短路径,每次得到一条路径,就可以计算第二标尺的值,令其和当前第二标尺的最优值进行比较,如果比最优值更优,则更新最优值,并用这条路径覆盖当前的最优路径。

    我们考虑一下这个递归函数该如何实现。

    • 作为全局变量的第二标尺最优值 optValue
    • 记录最优路径的数组 path(使用 vector 来存储)
    • 临时记录 DFS 遍历到叶子结点时的路径 tempPath(使用vector存储)

    然后考虑递归函数的两大构成:递归边界和递归式,如果访问的结点是叶子结点(起点st),那么说明到达了递归边界,此时 tempPath 存放了一条路径,求出第二标尺的值和optValue比较。

    在递归过程中生成 tempPath。只要在访问当前结点 v 时将 v 加到 tempPath 的最后面,然后遍历 pre[v] 进行递归,等 pre[v] 的所有结点遍历完毕后再把 tempPath 最后面的 v 弹出。

    int optValue;
    vector<int> pre[MAXV];
    vector<int> tempPath, path;
    int st;	// 出发结点
    
    void DFS(int v) {
      if(st == v) {
        // 递归到了出发结点
        tempPath.push(v);
        int value;
        计算路径 tempPath 上的value值
        if(value优于optValue) {
          optValue = value;
          path = tempPath;
        }
        tempPath.pop_back();	// 把刚刚加入的结点弹出来哦
        return;
      }else {
        tempPath.push_back(v);	// 把当前访问结点加入临时路径 tempPath 的最后面
        for(int i = 0; i < pre[v].size(); i++) {
          DFS(pre[v][i]);
        }
        tempPath.pop_back();
      }
    }
    

    当我们遇到的是点权或者边权的时候,我们只需要修改计算value值的过程。

    但是需要注意的是,存放在 tempPath 中路径的结点是逆序的,因此访问结点需要倒着进行。

    // 边权之和
    int value = 0;
    for(int i = tempPath.size() - 1; i > 0; i--) {
      int id = tempPath[i], idNext = tempPath[i-1];
      value += V[id][nextId];
    }
    
    // 点权之和
    int value = 0;
    for(int i = tempPath.size() - 1; i > 0; i--) {
      int id = tempPath[i];
      value += W[id];
    }
    
    

    如果需要记录最短路径的条数,也可以在 DFS 的过程中,每到达一次叶子结点令该全局变量加 1。

    PTA 1030 ,这个题使用了 Dijkstra + DFS 的思想,可以好好学习借鉴一下。

    可以和之前的 PTA 1003 结合起来看,基本上 Dijkstra 就没啥毛病了。

    但是 DIjkstra 的缺点就是遇到负权图的时候就很无力了,所以这个时候出现了新的算法。

  • 相关阅读:
    查看详细linux系统信息的命令和方法
    linux下将当前目录下的文件名存到一个文本文件里
    详解linux下批量替换文件内容的三种方法(perl,sed,shell)
    将二维数组中某个值为空的数组进行删除!
    字符串截取,对数字,英文,汉字都可以
    根据二维数组的某列数值来对二维数组进行排序
    iOS开发之第三方分享QQ分享,史上最新最全第三方分享QQ方式实现
    iOS开发之第三方登录微博-- 史上最全最新第三方登录微博方式实现
    iOS开发之第三方登录微信-- 史上最全最新第三方登录微信方式实现
    iOS开发之第三方登录QQ -- 史上最全最新第三方登录QQ方式实现
  • 原文地址:https://www.cnblogs.com/veeupup/p/12543659.html
Copyright © 2011-2022 走看看