图论 19. dijkstra(堆优化版)

47. 参加科学大会(第六期模拟笔试)

代码随想录

卡码网无难度标识

这。。。感觉不如先摘下来,以后再学吧,感觉笔试面试都不一定考得到(考到就直接认栽吧)

(学的太痛苦了,只想快点结束图论开始hot100和补作业)

  • 思路:(摘录修改自代码随想录)

    • 思路背景:

      上一篇文章给出了朴素版的dijkstra算法(图论 18. dijkstra算法(朴素版)(以及dijkstra与prim的区别)-CSDN博客),该解法的时间复杂度为 O(n^2),可以看出时间复杂度 只和 n (节点数量)有关系。

      如果n很大的话,我们可以换一个角度来优化性能:

      即,从边的数量出发。

      当 n 很大,边 的数量 也很多的时候 (稠密图) ,那么朴素dijkstra没问题;

      但 n 很大,边 的数量 很小的时候 (稀疏图) ,可以换成从边的角度来求最短路(毕竟边的数量少)

      有的录友可能会想,n (节点数量)很大,边不就多吗? 怎么会边的数量少呢?

      别忘了,谁也没有规定 节点之间一定要有边连接着,例如有一万个节点,只有一条边,这也是一张图。

    • 按照稀疏图的角度来分析本题:

      • 图的存储:

        创建图时,如果是稀疏图, 相比邻接矩阵,邻接表会更优秀一些;

      • 在朴素dijkstra的实现思路中,我们提到了三部曲:

        1. 第一步,选源点到哪个节点近且该节点未被访问过
        2. 第二步,该最近节点被标记访问过
        3. 第三步,更新非访问节点到源点的距离(即更新minDist数组)
      • 在朴素dijkstra的代码中,三部曲是套在一个 for 循环里,为什么?

        因为我们是从节点的角度来解决问题。

        三部曲中第一步(选源点到哪个节点近且该节点未被访问过),这个操作本身需要for循环遍历 minDist 来寻找最近的节点。

        同时我们需要 遍历所有 未访问过的节点,所以 我们从 节点角度出发,代码会有两层for循环

      • 那么当从 边 的角度出发,

        在处理 三部曲里的第一步(选源点到哪个节点近且该节点未被访问过)的时候 ,我们可以不用去遍历所有节点了。

        而是 ​**直接把 边(带权值)加入到 小顶堆(利用堆来自动排序),那么每次我们从 堆顶里 取出 边 自然就是 距离源点最近的节点所在的边。**

        这样我们就不需要两层for循环来寻找最近的节点了。

    • 代码实现要点:

      • 图的创建:

        用邻接表,邻接表数组存放的元素为,由一至多个边Edge类的实例组成的列表。

        Edge类里两个参数:边的入点t,和边的权重val。

        例如邻接表数组下标1对应结点1,存放的元素为一个列表,里面有一至多个Edge类的实例edge,edge.t是当前边从结点1出发抵达的入点t,edge.val是当前边的权重

      • 堆优化细节:

        思路依然是 dijkstra 三部曲:

        1. 第一步,选源点到哪个节点近且该节点未被访问过
        2. 第二步,该最近节点被标记访问过
        3. 第三步,更新非访问节点到源点的距离(即更新minDist数组)

        只不过之前是 通过遍历节点来遍历边,通过两层for循环来寻找距离源点最近节点。

        这次我们直接遍历边,且通过堆来对边进行排序,达到直接选择距离源点最近节点。

        先来看一下针对这三部曲,如何用 堆来优化。

        那么三部曲中的第一步(选源点到哪个节点近且该节点未被访问过),我们如何选?

        我们要选择距离源点近的节点(即:该边的权值最小),所以 我们需要一个 小顶堆 来帮我们对边的权值排序,每次从小顶堆堆顶 取边就是权值最小的边。

        C++定义小顶堆,可以用优先级队列实现,代码如下:

        // 小顶堆
        class mycomparison {
        public:
            bool operator()(const pair<int, int>& lhs, const pair<int, int>& rhs) {
                return lhs.second > rhs.second;
            }
        };
        // 优先队列中存放 pair<节点编号,源点到该节点的权值> 
        priority_queue<pair<int, int>, vector<pair<int, int>>, mycomparison> pq;
        

        (pair<int, int>​中 第二个int 为什么要存 源点到该节点的权值,因为 这个小顶堆需要按照权值来排序)

        有了小顶堆自动对边的权值排序,那我们只需要直接从 堆里取堆顶元素(小顶堆中,最小的权值在上面),就可以取到离源点最近的节点了 (未访问过的节点,不会加到堆里进行排序)

        所以三部曲中的第一步,我们不用 for循环去遍历,直接取堆顶元素:

        // pair<节点编号,源点到该节点的权值>
        pair<int, int> cur = pq.top(); pq.pop();
        
        

        第二步(该最近节点被标记访问过) 这个就是将 节点做访问标记,和 朴素dijkstra 一样 ,代码如下:

        // 2. 第二步,该最近节点被标记访问过
        visited[cur.first] = true;
        
        

        (cur.first​ 是指取 pair<int, int>​ 里的第一个int,即节点编号 )

        第三步(更新非访问节点到源点的距离),这里的思路 也是 和朴素dijkstra一样的。

        但很多录友对这里是最懵的,主要是因为两点:

        • 没有理解透彻 dijkstra 的思路
        • 没有理解 邻接表的表达方式

        我们来回顾一下 朴素dijkstra 在这一步的代码和思路(如果没看过我讲解的朴素版dijkstra,这里会看不懂)

        
        // 3、第三步,更新非访问节点到源点的距离(即更新minDist数组)
        for (int v = 1; v <= n; v++) {
            if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) {
                minDist[v] = minDist[cur] + grid[cur][v];
            }
        }
        

        其中 for循环是用来做什么的? 是为了 找到 节点cur 链接指向了哪些节点,因为使用邻接矩阵的表达方式 所以把所有节点遍历一遍。

        而在邻接表中,我们可以以相对高效的方式知道一个节点链接指向哪些节点。

        在邻接表中,我们要获取 节点cur 链接指向哪些节点,就是遍历 grid[cur节点编号] 这个链表。

        这个遍历方式,C++代码如下:

        for (Edge edge : grid[cur.first]) 
        

        (如果不知道 Edge 是什么,看上面「图的存储」中邻接表的讲解)

        ​cur.first​ 就是cur节点编号, 参考上面pair的定义: pair<节点编号,源点到该节点的权值>

        接下来就是更新 非访问节点到源点的距离,代码实现和 朴素dijkstra 是一样的,代码如下:

        // 3. 第三步,更新非访问节点到源点的距离(即更新minDist数组)
        for (Edge edge : grid[cur.first]) { // 遍历 cur指向的节点,cur指向的节点为 edge
            // cur指向的节点edge.to,这条边的权值为 edge.val
            if (!visited[edge.to] && minDist[cur.first] + edge.val < minDist[edge.to]) { // 更新minDist
                minDist[edge.to] = minDist[cur.first] + edge.val;
                pq.push(pair<int, int>(edge.to, minDist[edge.to]));
            }
        }
        

        但为什么思路一样,有的录友能写出朴素dijkstra,但堆优化这里的逻辑就是写不出来呢?

        主要就是因为对邻接表的表达方式不熟悉!

        以上代码中,cur 链接指向的节点编号 为 edge.to, 这条边的权值为 edge.val ,如果对这里模糊的就再回顾一下 Edge的定义:

        struct Edge {
            int to;  // 邻接顶点
            int val; // 边的权重
        
            Edge(int t, int w): to(t), val(w) {}  // 构造函数
        };
        

        确定该节点没有被访问过,!visited[edge.to]​ , 目前 源点到cur.first的最短距离(minDist) + cur.first 到 edge.to 的距离 (edge.val) 是否 小于 minDist已经记录的 源点到 edge.to 的距离 (minDist[edge.to])

        如果是的话,就开始更新操作。

        即:

        if (!visited[edge.to] && minDist[cur.first] + edge.val < minDist[edge.to]) { // 更新minDist
            minDist[edge.to] = minDist[cur.first] + edge.val;
            pq.push(pair<int, int>(edge.to, minDist[edge.to])); // 由于cur节点的加入,而新链接的边,加入到优先级队里中
        }
        
        

        同时,由于cur节点的加入,源点又有可以新链接到的边,将这些边加入到优先级队里中。

        以上代码思路 和 朴素版dijkstra 是一样一样的,主要区别是两点:

        • 邻接表的表示方式不同
        • 使用优先级队列(小顶堆)来对新链接的边排序
    • 代码随想录C++代码:

      堆优化dijkstra完整代码如下:

      #include <iostream>
      #include <vector>
      #include <list>
      #include <queue>
      #include <climits>
      using namespace std; 
      // 小顶堆
      class mycomparison {
      public:
          bool operator()(const pair<int, int>& lhs, const pair<int, int>& rhs) {
              return lhs.second > rhs.second;
          }
      };
      // 定义一个结构体来表示带权重的边
      struct Edge {
          int to;  // 邻接顶点
          int val; // 边的权重
      
          Edge(int t, int w): to(t), val(w) {}  // 构造函数
      };
      
      int main() {
          int n, m, p1, p2, val;
          cin >> n >> m;
      
          vector<list<Edge>> grid(n + 1);
      
          for(int i = 0; i < m; i++){
              cin >> p1 >> p2 >> val; 
              // p1 指向 p2,权值为 val
              grid[p1].push_back(Edge(p2, val));
      
          }
      
          int start = 1;  // 起点
          int end = n;    // 终点
      
          // 存储从源点到每个节点的最短距离
          std::vector<int> minDist(n + 1, INT_MAX);
      
          // 记录顶点是否被访问过
          std::vector<bool> visited(n + 1, false); 
      
          // 优先队列中存放 pair<节点,源点到该节点的权值>
          priority_queue<pair<int, int>, vector<pair<int, int>>, mycomparison> pq;
      
      
          // 初始化队列,源点到源点的距离为0,所以初始为0
          pq.push(pair<int, int>(start, 0)); 
      
          minDist[start] = 0;  // 起始点到自身的距离为0
      
          while (!pq.empty()) {
              // 1. 第一步,选源点到哪个节点近且该节点未被访问过 (通过优先级队列来实现)
              // <节点, 源点到该节点的距离>
              pair<int, int> cur = pq.top(); pq.pop();
      
              if (visited[cur.first]) continue;
      
              // 2. 第二步,该最近节点被标记访问过
              visited[cur.first] = true;
      
              // 3. 第三步,更新非访问节点到源点的距离(即更新minDist数组)
              for (Edge edge : grid[cur.first]) { // 遍历 cur指向的节点,cur指向的节点为 edge
                  // cur指向的节点edge.to,这条边的权值为 edge.val
                  if (!visited[edge.to] && minDist[cur.first] + edge.val < minDist[edge.to]) { // 更新minDist
                      minDist[edge.to] = minDist[cur.first] + edge.val;
                      pq.push(pair<int, int>(edge.to, minDist[edge.to]));
                  }
              }
      
          }
      
          if (minDist[end] == INT_MAX) cout << -1 << endl; // 不能到达终点
          else cout << minDist[end] << endl; // 到达终点最短路径
      }
      
      
      • 时间复杂度:O(ElogE) E 为边的数量
      • 空间复杂度:O(N + E) N 为节点的数量

      堆优化的时间复杂度 只和边的数量有关 和节点数无关,在 优先级队列中 放的也是边。

      以上代码中,while (!pq.empty())​ 里套了 for (Edge edge : grid[cur.first])​

      ​for​ 里 遍历的是 当前节点 cur 所连接边。

      那 当前节点cur 所连接的边 也是不固定的, 这就让大家分不清,这时间复杂度究竟是多少?

      其实 for (Edge edge : grid[cur.first])​ 里最终的数据走向 是 给队列里添加边。

      那么跳出局部代码,整个队列 一定是 所有边添加了一次,同时也弹出了一次。

      所以边添加一次时间复杂度是 O(E), while (!pq.empty())​ 里每次都要弹出一个边来进行操作,在优先级队列(小顶堆)中 弹出一个元素的时间复杂度是 O(logE) ,这是堆排序的时间复杂度。

      (当然小顶堆里 是 添加元素的时候 排序,还是 取数元素的时候排序,这个无所谓,时间复杂度都是O(E),总之是一定要排序的,而小顶堆里也不会滞留元素,有多少元素添加 一定就有多少元素弹出)

      所以 该算法整体时间复杂度为 O(ElogE)

      网上的不少分析 会把 n (节点的数量)算进来,这个分析是有问题的,举一个极端例子,在n 为 10000,且是有一条边的 图里,以上代码,大家感觉执行了多少次?

      ​while (!pq.empty())​ 中的 pq 存的是边,其实只执行了一次。

      所以该算法时间复杂度 和 节点没有关系。

      至于空间复杂度,邻接表是 数组 + 链表 数组的空间 是 N ,有E条边 就申请对应多少个链表节点,所以是 复杂度是 N + E

  • 代码随想录python代码:

    import heapq
    
    class Edge:
        def __init__(self, to, val):
            self.to = to
            self.val = val
    
    def dijkstra(n, m, edges, start, end):
        grid = [[] for _ in range(n + 1)]
    
        for p1, p2, val in edges:
            grid[p1].append(Edge(p2, val))
    
        minDist = [float('inf')] * (n + 1)
        visited = [False] * (n + 1)
    
        pq = []
        heapq.heappush(pq, (0, start))
        minDist[start] = 0
    
        while pq:
            cur_dist, cur_node = heapq.heappop(pq)
    
            if visited[cur_node]:
                continue
    
            visited[cur_node] = True
    
            for edge in grid[cur_node]:
                if not visited[edge.to] and cur_dist + edge.val < minDist[edge.to]:
                    minDist[edge.to] = cur_dist + edge.val
                    heapq.heappush(pq, (minDist[edge.to], edge.to))
    
        return -1 if minDist[end] == float('inf') else minDist[end]
    
    # 输入
    n, m = map(int, input().split())
    edges = [tuple(map(int, input().split())) for _ in range(m)]
    start = 1  # 起点
    end = n    # 终点
    
    # 运行算法并输出结果
    result = dijkstra(n, m, edges, start, end)
    print(result)
    

‍

更多推荐