图-最短路径-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\n", s);
    return;
  }
  DFS(s, pre[v]);			// 递归访问 v 的前驱顶点 pre[v]
  printf("%d\n", 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 的缺点就是遇到负权图的时候就很无力了,所以这个时候出现了新的算法。

posted @ 2020-03-22 00:39  南风sa  阅读(758)  评论(0编辑  收藏  举报