图 - 最短路径 (二)

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了图 - 最短路径 (二)相关的知识,希望对你有一定的参考价值。

参考技术A

  ( )算法基本思想

  设S为最短距离已确定的顶点集(看作红点集) V S是最短距离尚未确定的顶点集(看作蓝点集)

  ①初始化

  初始化时 只有源点s的最短距离是已知的(SD(s)= ) 故红点集S=s 蓝点集为空

  ②重复以下工作 按路径长度递增次序产生各顶点最短路径

  在当前蓝点集中选择一个最短距离最小的蓝点来扩充红点集 以保证算法按路径长度递增的次序产生各顶点的最短路径

  当蓝点集中仅剩下最短距离为∞的蓝点 或者所有蓝点已扩充到红点集时 s到所有顶点的最短路径就求出来了

  注意

  ①若从源点到蓝点的路径不存在 则可假设该蓝点的最短路径是一条长度为无穷大的虚拟路径

  ②从源点s到终点v的最短路径简称为v的最短路径;s到v的最短路径长度简称为v的最短距离 并记为SD(v)

  ( )在蓝点集中选择一个最短距离最小的蓝点k来扩充红点集

  根据按长度递增序产生最短路径的思想 当前最短距离最小的蓝点k的最短路径是

  源点 红点 红点 … 红点n 蓝点k

  距离为 源点到红点n最短距离+<红点n 蓝点k>边长

  为求解方便 设置一个向量D[ n ] 对于每个蓝点v∈ V S 用D[v]记录从源点s到达v且除v外中间不经过任何蓝点(若有

  中间点 则必为红点)的 最短 路径长度(简称估计距离)

  若k是蓝点集中估计距离最小的顶点 则k的估计距离就是最短距离 即若D[k]=minD[i] i∈V S 则D[k]=SD(k)

  初始时 每个蓝点v的D[c]值应为权w 且从s到v的路径上没有中间点 因为该路径仅含一条边

  注意

  在蓝点集中选择一个最短距离最小的蓝点k来扩充红点集是Dijkstra算法的关键

  ( )k扩充红点集s后 蓝点集估计距离的修改

  将k扩充到红点后 剩余蓝点集的估计距离可能由于增加了新红点k而减小 此时必须调整相应蓝点的估计距离

  对于任意的蓝点j 若k由蓝变红后使D[j]变小 则必定是由于存在一条从s到j且包含新红点k的更短路径 P= 且

  D[j]减小的新路径P只可能是由于路径 和边 组成

  所以 当length(P)=D[k]+w 小于D[j]时 应该用P的长度来修改D[j]的值

  ( )Dijkstra算法

  Dijkstra(G D s)

  //用Dijkstra算法求有向网G的源点s到各顶点的最短路径长度

  //以下是初始化操作

  S=s;D[s]= ; //设置初始的红点集及最短距离

  for( all i∈ V S )do //对蓝点集中每个顶点i

  D[i]=G[s][i]; //设置i初始的估计距离为w

  //以下是扩充红点集

  for(i= ;i <n-1;i++)do 最多扩充n-1个蓝点到红点集

  D[k]=minD[i]:all i V-S; //在当前蓝点集中选估计距离最小的顶点k

  if(D[k]等于∞)

  return; //蓝点集中所有蓝点的估计距离均为∞时,

  //表示这些顶点的最短路径不存在。.wIngwit

  S=S∪k; //将蓝点k涂红后扩充到红点集

  for( all j∈V-S )do //调整剩余蓝点的估计距离

  if(D[j]>D[k]+G[k][j])

  //新红点k使原D[j]值变小时,用新路径的长度修改D[j],

  //使j离s更近。

  D[j]=D[k]+G[k][j];

  

  

  【例】对有向网G 8 以0为源点执行上述算法的过程及红点集、k和D向量的变化见【 参见动画演示 】。

  

  6)保存最短路径的Dijkstra算法

  设置记录顶点双亲的向量P[0..n-1]保存最短路径:

  当顶点i无双亲时,令P[i]=-1。

  当算法结束时,可从任一P[i]反复上溯至根(源点)求得顶点i的最短路径,只不过路径方向正好与从s到i的路径相反。

  具体的求精算法【参见教材】 。

  Dijkstra算法的时间复杂度为O(n 2 )

  其他最短路径问题

  最短路径问题的提法很多,其它的最短路径问题均可用单源最短路径算法予以解决:

  ① 单目标最短路径问题 (Single-Destination Shortest-Paths Problem):找出图中每一顶点v到某指定顶点u的最短路

  径。只需将图中每条边反向,就可将这一问题变为单源最短路径问题,单目标u变为单源点u。

  ② 单顶点对间最短路径问题 (Single-Pair Shortest-Path Problem):对于某对顶点u和v,找出从u到v的一条最短路径

  。显然,若解决了以u为源点的单源最短路径问题,则上述问题亦迎刃而解。而且从数量级来说,两问题的时间复杂度相同。

  ③ 所有顶点对间最短路径问题 (All-Pairs Shortest-Paths Problem):对图中每对顶点u和v,找出u到v的最短路径问题

  。这一问题可用每个顶点作为源点调用一次单源最短路径问题算法予以解决。

lishixinzhi/Article/program/sjjg/201311/23815

图-最短路径-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 的缺点就是遇到负权图的时候就很无力了,所以这个时候出现了新的算法。

以上是关于图 - 最短路径 (二)的主要内容,如果未能解决你的问题,请参考以下文章

数据结构—— 图:最短路径问题

算法总结图论-最短路径

最短路径问题-Dijkstra(基于图的ADT)

图-最短路径-Dijkstra及其变种

数据结构与算法图最短路径算法 ( Floyed 算法 | 图最短路径算法使用场景 | 求解图中任意两个点之间的最短路径 | 邻接矩阵存储图数据 | 弗洛伊德算法总结 )

图算法|Dijkstra最短路径算法