最短路径算法总结和LeetCode题目实践

本文最后更新于:2021年2月10日 上午

常见的最短路径算法总结。

最短路径算法总结和 LeetCode 题目实践

最近复习到了图相关,总结了最短路径问题的几个常用算法(Dijsktra 算法、Floyd 算法、Bellman-Ford 算法、SPFA 算法)。给出了具体题目实现。

给定图 G(V,E),共有 n 个顶点:

Dijsktra 算法

通常用于计算单源最短路径问题,即给定源点src,计算其他每个顶点到源点的最短距离。(注意:Dijsktra 不能解决边权有负值的情况)

基本思想:设置一个顶点集合 S,存放已找到最短路径的顶点,每次从 V-S 集合中找距离源点距离最小的点,假设为u,将其加入集合 S。再次遍历 V-S 集合中的点,设为v,看是否能对边(u,v)进行松弛操作(dist[u]+G[u][v]<dist[v])。

伪代码

// 设置的数据结构:
bool visited[n]; // visited[i]=true表示顶点i已经加入集合S
int dist[n];	 // dist[i]表示顶点i距离源点的最短距离
int pre[n];	     // 到达pre[i]的最短路径上,i的前一个顶点
遍历n次:
    找出V-S中距离源点最近的点,记为u
    若不存在,说明G不是连通图
    visited[u]=true
    遍历V-S中所有点v
    	若dist[u]+G[u][v]<dist[v]:
    		更新dist[v]和pre[v]

例:

  1. LeetCode-743.网络延迟时间

    • 思路:计算源点到其他所有点所需的时间,记录在数组 dist[]中,返回 dist[]中的最大值,即延迟时间

      class Solution {
      public:
          int networkDelayTime(vector<vector<int>> &times, int n, int k) {
              // 将times转换为邻接矩阵G
              vector<vector<int>> G(n + 1, vector<int>(n + 1, INT32_MAX));
              int len = times.size(), ui, vi, wi;
              for (int i = 0; i < len; ++i) {
                  ui = times[i][0], vi = times[i][1], wi = times[i][2];
                  G[ui][vi] = wi;
              }
      
              vector<int> dist(n + 1, INT32_MAX); // 到源点的距离
              vector<int> visited(n + 1, false);  // 是否加入集合S
              dist[k] = 0;
      
              for (int i = 0; i < n; ++i) { // 遍历n次
                  // 找出集合V-S中距离源点最近的
                  int u = -1, minDist = INT32_MAX;
                  for (int j = 1; j <= n; ++j) {
                      if (!visited[j] && dist[j] < minDist) {
                          u = j;
                          minDist = dist[j];
                      }
                  }
                  if (u == -1) return -1;   // 若未找到,返回-1
                  visited[u] = true;        // 将u加入集合S
                  // 用顶点u更新V-S中其他顶点的最短距离
                  for (int v = 1; v <= n; ++v) {
                      if (!visited[v] && G[u][v] != INT32_MAX)
                          dist[v] = min(dist[v], dist[u] + G[u][v]);
                  }
      
              }
              return *max_element(dist.begin() + 1, dist.end());
          }
      };
  2. LeetCode-787. K 站中转内最便宜的航班

    • 思路:使用优先队列+Dijsktra 算法。记录到达每个结点(城市)处的中转次数和花费,优先操作花费最少的结点。

      class City {
      public:
          int index; // 城市编号
          int cost;  // 花费
          int hop;   // 到达该城市的中转次数
      City(int i, int c, int h) {
          index = i, cost = c, hop = h;
      }
      
          friend bool operator<(const City &a, const City &b) {
              return a.cost > b.cost;
          }
      };
      
      class Solution {
      public:
          int findCheapestPrice(int n, vector<vector<int>> &flights, int src, int dst, int K) {
              // 将flights转换为邻接矩阵
              vector<vector<int>> G(n, vector<int>(n, INT32_MAX));
              int len = flights.size(), ui, vi, wi;
              for (int i = 0; i < len; ++i) {
                  ui = flights[i][0], vi = flights[i][1], wi = flights[i][2];
                  G[ui][vi] = wi;
              }
      
              // 创建优先队列,花费少的结点优先
              priority_queue<City> pq;
              pq.push(City(src, 0, 0));
              while (!pq.empty()) {
                  City t = pq.top();
                  pq.pop();
                  if (t.hop > K + 1) // k站中转,hop最大可以等于K+1
                      continue;
                  int u = t.index, c = t.cost, h = t.hop;
                  if (u == dst)   // 若能到达目的地,则一定是最优花费的
                      return c;
                  for (int v = 0; v < n; ++v) {
                      if (G[u][v] != INT32_MAX)
                          pq.push(City(v, G[u][v] + c, h + 1));
                  }
              }
              return -1;
          }
      };
      

Dijsktra 算法+DFS

上面两道题都没有要求输出具体的最短路径,所以没有使用 pre 数组。有两种稍微复杂的情况如下:

  1. 源点到某个点的最短路径不止一条
  2. 不光要求从源点到其他点的距离最短,还有其他最优要求(e.g. 每个点有一个属性值,要求总属性值最大/小)

第一个问题可以通过将 pre 数组修改为vector<vecotr<int> >pre,然后对pre进行 DFS 得到每条路径。

第二个问题,如果最优要求比较简单,可以直接在原有 Dijsktra 算法上修改,如果比较麻烦,可以先求出所有最短路径,再通过 DFS 对每条路径的属性进行比较,得到要求解。

例:PAT-1003.Emergency

  • 思路:该题中不仅要求距离最短,还要求所有最短路径中,输出救援队人数最多的那条(也就是路径经过的所有城市点权相加)

    #include <bits/stdc++.h>
    
    using namespace std;
    int maxSum = 0;
    int ansNum = 0;
    vector<int> curPath;
    
    void dfs(vector<vector<int>> pre, vector<int> w, int u, int src) {
        if (u == src) {
            ansNum++;
            curPath.push_back(u);
            // 计算救援队总人数
            int len = curPath.size(), curSum = 0;
            for (int i = 0; i < len; ++i)
                curSum += w[curPath[i]];
            maxSum = max(maxSum, curSum);
            curPath.pop_back();
            return;
        }
        curPath.push_back(u);
        vector<int> last = pre[u];
        for (int i = 0; i < last.size(); ++i)
            dfs(pre, w, last[i], src);
    
        curPath.pop_back();
    
    }
    
    int main() {
        int n, m, src, dst;
        cin >> n >> m >> src >> dst;
        vector<int> w(n);   // 每个城市的救援队人数
        vector<vector<int>> G(n, vector<int>(n, INT32_MAX));
        for (int i = 0; i < n; ++i)
            cin >> w[i];
        int ui, vi, wi;
        for (int i = 0; i < m; ++i) {
            cin >> ui >> vi >> wi;
            G[ui][vi] = G[vi][ui] = wi;
        }
    
        // 先单纯使用Dijsktra算法计算出所有最短路径
        vector<int> dist(n, INT32_MAX);
        vector<bool> visited(n, false);
        vector<vector<int>> pre(n, vector<int>());
        dist[src] = 0;
        for (int i = 0; i < n; ++i) {
            int u = -1, minDist = INT32_MAX;
            for (int j = 0; j < n; ++j) {
                if (!visited[j] && dist[j] < minDist) {
                    u = j;
                    minDist = dist[j];
                }
            }
            if (u == -1) return 0;
            visited[u] = true;
            for (int v = 0; v < n; ++v) {
                if (!visited[v] && G[u][v] != INT32_MAX) {
                    int tmp = G[u][v] + dist[u];
                    if (tmp < dist[v]) {    // 更新最短路径
                        dist[v] = tmp;
                        pre[v].clear();
                        pre[v].push_back(u);
                    } else if (tmp == dist[v])
                        pre[v].push_back(u);
                }
            }
        }
    
        // DFS得到最短路径条数和救援队最大人数
        dfs(pre, w, dst, src);
        cout << ansNum << " " << maxSum;
    }

Floyd 算法

Floyd 算法可以用来解决多源最短路径问题,它会计算图中每两个点之间的最短路径。

基本思想:如果令 k 作为顶点 i 和 j 之间路径的中介点能够得到一条更短的路径,则令 k 作为其最短路径的中介点。

伪代码:(由伪代码可知 Floyd 算法的复杂度为 $O(n^3)$ ,所以通常用于图点数不太大的情况)

// dist[i][j]表示顶点i和j之间的最短距离

for k in (1,n):	// 令每个点分别作为中介点进行测试
	for i in (1,n):
		for j in (1,n):
			if dist[i][j]>dist[i][k]+dist[k][j]:
				dist[i][j]=dist[i][k]+dist[k][j]
  • 注意一下这里的 k,也就是枚举的中介点必须在最外层才能保证这个贪心的正确性。因为 Floyd 算法的本质是 DP,也就是说dist[k][i][j]=min(dist[k-1][i][j],dist[k-1][i][k]+dist[k-1][k][j]),如果 k 不在最外层就不能保证当计算到 k 时,所有 k-1 的值都是最优的。

例: LeetCode-1334. 阈值距离内邻居最少的城市

  • 思想:用 Floyd 算法计算出每两个城市之间的最短距离,然后分别遍历每个城市,计算满足要求的邻居数量,返回邻居最少的城市。

    class Solution {
    public:
        int findTheCity(int n, vector<vector<int>> &edges, int distanceThreshold) {
            // 初始化邻接矩阵,也作为距离矩阵dist
            vector<vector<int>> dist(n, vector<int>(n, INT32_MAX));
            int len = edges.size(), ui, vi, wi;
            for (int i = 0; i < len; ++i) {
                ui = edges[i][0], vi = edges[i][1], wi = edges[i][2];
                dist[ui][vi] = dist[vi][ui] = wi;
            }
            for (int i = 0; i < n; ++i) // 将到自身的距离初始化为0,下面的遍历中就不需要对kij三个点中有相等情况时的特殊处理
                dist[i][i] = 0;
    
            for (int k = 0; k < n; ++k) {
                for (int i = 0; i < n; ++i) {
                    for (int j = 0; j < n; ++j) {
                        if (dist[i][k] < INT32_MAX && dist[k][j] < INT32_MAX && (dist[i][k] + dist[k][j] < dist[i][j]))
                            dist[i][j] = dist[i][k] + dist[k][j];
                    }
                }
            }
    
            // 找出满足条件邻居最少的城市
            int ansCity, ansNum = INT32_MAX;
            for (int i = 0; i < n; ++i) {
                int tmpNum = 0;
                for (int j = 0; j < n; ++j) {
                    if (dist[i][j] <= distanceThreshold)
                        tmpNum++;
                }
                if (tmpNum <= ansNum) {
                    ansNum = tmpNum;
                    ansCity = i;
                }
            }
    
            return ansCity;
    
        }
    };

Bellman-Ford 算法

Bellman-Ford 算法也是解决单源最短路径,其还可以处理有负边权的图。

首先要明确一点,在带有负边权的图中,不能有源点可达的负环,否则当从源点出发,将该负环循环走无数次,路径值会越来越小,也就不存在最短路径。所以当图中存在从源点可达的负环时,函数要返回 false。

思路:将图 G 中的所有边,遍历 n-1 次。对于边(u,v,w),若dist[u]+w<dist[v],则更新dist[v]

  • 为什么要遍历所有边:第 i 次遍历,其实是确定其他点分别到源点的最短路径上,第 i 个顶点是谁,也可以说是经过的第 i 条边是谁。
  • 为什么要遍历 n-1 次:在每个顶点到源点的最短路径上,顶点数最多为 n 个,除非有负环,所以最多只需要遍历 n-1 次(第一个顶点已经确定下来了),就可以确定所有顶点的最短路径。

伪代码

遍历n-1次:
    对每条边(u,v,w):
    	dist[v]=min(dist[v],dist[u]+w)

// 再遍历一次判断是否存在负环
对每条边(u,v,w):
    if dist[u]+w<dist[v]:	// 若数组中还有未达到最优的值,只能说明图中有负环,返回false
		return false;
  • 在某次遍历中,如果所有的 dist 值都没有被优化,说明 dist 中所有值已经达到最优,可以直接退出循环。
  • 如果要记录最短路径,可以设置一个set<int> pre,因为每次遍历会反复访问重复的点,而 set 可以自行去重。

例:

  1. LeetCode-787. K 站中转内最便宜的航班

    class Solution {
    public:
        int findCheapestPrice(int n, vector<vector<int>> &flights, int src, int dst, int K) {
            vector<int> dist(n, INT32_MAX);
            dist[src] = 0;
    
            int len = flights.size();
            vector<int> copy(dist);
            for (int i = 0; i < K + 1; ++i) { // K次中转,可以经过K+1条边
                for (int j = 0; j < len; ++j) { // 遍历每条边
                    int u = flights[j][0], v = flights[j][1], w = flights[j][2];
                    if (dist[u] < INT32_MAX)
                        copy[v] = min(copy[v], dist[u] + w);    // 更新在copy上是为了防止在一次遍历中多次修改dist[i]的值,则其不止确定了一条边
                }
                dist = copy;
            }
    
            return dist[dst] == INT32_MAX ? -1 : dist[dst];
        }
    };
  2. LeetCode-1514.概率最大的路径

    • 思路:这题咋一看有点唬人,其实也满足贪心策略,因为他要找的是最大相乘边权路径,而每个边权大小 ∈[0,1],随着路径上边数的增加,边权只会越来越小,所以用 Bellman-Ford 算法时,最先得到的最大路径一定是最优解。

      class Solution {
      public:
          double maxProbability(int n, vector<vector<int>> &edges, vector<double> &succProb, int start, int end) {
              int len = edges.size();
              vector<double> prob(n, 0);
              prob[start] = 1.0;
              for (int i = 0; i < n - 1; ++i) {
                  bool isChange = false;
                  for (int j = 0; j < len; ++j) {
                      int u = edges[j][0], v = edges[j][1];
                      double w = succProb[j];
                      if (prob[u] * w > prob[v]) {
                          prob[v] = prob[u] * w;
                          isChange = true;
                      }
                      if (prob[v] * w > prob[u]) {
                          prob[u] = prob[v] * w;
                          isChange = true;
                      }
                  }
                  if (!isChange)
                      break;
              }
      
              return prob[end];
          }
      };

SPFA 算法

SPFA 算法是基于 Bellman-Ford 算法改进得到的。B-F 算法每次边遍历,要对所有边都进行一次操作,但其实哪些边需要进行操作是可以提前确定的:只有当某个顶点 u 的 dist[u]值改变时,从他出发的边(u,v)才需要进行操作,因为只有此时顶点 v 的值才可能改变。

基本操作:使用队列来存储 dist 值改变的点。每次取队首的顶点,然后对该顶点的所有边进行操作,如果边的另一个顶点的 dist 值改变,则将该顶点加入到队列中(如果该顶点当前不在队列中)。这样一直到队列为空。

  • 如果图中存在从源点可达的负环,则可能存在顶点无限次入队,使得队列不可能为空,这时用一个数组来记录每个顶点的入队次数,当一个顶点入队次数超过 n-1 时,返回 false。

伪代码:

vector<bool> in;	// 标记顶点是否在队列中
vector<int> count;	// 记录顶点的入队次数
queue<int> q;
q.push(src);	// 源点入队
while(!q.empty()){
    int u = q.pop();
    in[u] = false;
    对u的每条边进行松弛操作:
        若v的dist值改变且!in[v]:
        	q.push(v);
    		count[v]++;
    		in[v] = true;
    		if count[v]>n-1:
    			return false;
}

例:

  1. LeetCode-1129.颜色交替的最短路径

    • 思路:每个结点处添加颜色信息,以实现交替查找不同颜色的边。由于每个点可能多次访问,但是边不能重复访问,所以设置一个边访问表来记录边的访问情况。只要当前边没有访问过,就可以新建结点入队,这样保证了能得到所有解。

      class Node {
      public:
          int color;  // 0表示由一条红边到该点,1表示由一条蓝边到该点
          int vertex; // 顶点编号
          int len;    // 长度
      
          Node(int c, int v, int l) {
              color = c;
              vertex = v;
              len = l;
          }
      };
      
      class Solution {
      public:
          vector<int> shortestAlternatingPaths(int n, vector<vector<int>> &red_edges, vector<vector<int>> &blue_edges) {
              // 建立邻接表和边访问表
              vector<vector<int>> redAdj(n, vector<int>()), blueAdj(n, vector<int>());
              vector<vector<bool>> redVis(n, vector<bool>()), blueVis(n, vector<bool>());
              int redNum = red_edges.size(), blueNum = blue_edges.size();
              for (int i = 0; i < redNum; ++i) {
                  redAdj[red_edges[i][0]].push_back(red_edges[i][1]);
                  redVis[red_edges[i][0]].push_back(false);
              }
              for (int i = 0; i < blueNum; ++i) {
                  blueAdj[blue_edges[i][0]].push_back(blue_edges[i][1]);
                  blueVis[blue_edges[i][0]].push_back(false);
              }
      
      
              vector<int> ans(n, INT32_MAX);
              queue<Node> q;
              q.push(Node(1, 0, 0));
              q.push(Node(0, 0, 0));
              ans[0] = 0;
              while (!q.empty()) {
                  Node u = q.front();
                  q.pop();
      
                  if (u.color == 1) { // 若u由一条蓝边到,找从u出发的红边
                      vector<int> nextRed = redAdj[u.vertex];
                      for (int i = 0; i < nextRed.size(); ++i) {
                          int v = nextRed[i];
                          // 若该边未访问过,将结点入队
                          if (!redVis[u.vertex][i]) {
                              redVis[u.vertex][i] = true;
                              q.push(Node(0, v, u.len + 1));
                              ans[v] = min(ans[v], u.len + 1);
                          }
                      }
                  } else {
                      vector<int> nextBlue = blueAdj[u.vertex];
                      for (int i = 0; i < nextBlue.size(); ++i) {
                          int v = nextBlue[i];
                          if (!blueVis[u.vertex][i]) {
                              blueVis[u.vertex][i] = true;
                              q.push(Node(1, v, u.len + 1));
                              ans[v] = min(ans[v], u.len + 1);
                          }
      
                      }
                  }
              }
      
              for (int i = 1; i < n; ++i)
                  ans[i] = (ans[i] == INT32_MAX) ? -1 : ans[i];
      
              return ans;
          }
      };
  2. LeetCode-787. K 站中转内最便宜的航班

    struct Edge {
        int v;
        int w;
        Edge(int iv, int iw) {
            v = iv;
            w = iw;
        }
    };
    struct Node {
        int v;
        int w;
        int k;
        Node(int iv, int iw, int ik) {
            v = iv, w = iw, k = ik;
        }
    };
    
    class Solution {
    public:
        int findCheapestPrice(int n, vector<vector<int>> &flights, int src, int dst, int K) {
            // 初始化邻接表
            vector<vector<Edge>> adj(n, vector<Edge>());
            for (const auto &edge:flights)
                adj[edge[0]].push_back(Edge(edge[1], edge[2]));
    
            vector<int> res(n, INT32_MAX);
            res[src] = 0;
            queue<Node> q;
            q.push(Node(src, 0, 0));
            while (!q.empty()) {
                Node t = q.front();
                q.pop();
                if (t.k > K + 1)
                    continue;
    
                vector<Edge> next = adj[t.v];
                for (const auto &tmp : next) {
                    int w = tmp.w + t.w;
                    if (w < res[tmp.v]) {
                        res[tmp.v] = w;
                        q.push(Node(tmp.v, w, t.k + 1));
                    }
                }
            }
    
            return res[dst] == INT32_MAX ? -1 : res[dst];
    
        }
    };