Dijkstra算法与其最小堆优化

(仿佛回到了当年打比赛的时候呢

POJ 3013(Big Christmas Tree)

传送门:http://poj.org/problem?id=3013

题目大意:由一堆顶点和边构造出一棵圣诞树,1号顶点固定为树根,顶点和边各自有权重值(均为正数)。构造圣诞树的边的开销是边权乘以子树中所有顶点的权重之和,总开销则是所有边的开销之和。求圣诞树的最小开销。

Dijkstra算法与其最小堆优化_第1张图片

稍微变换一下,容易得知构造圣诞树的最小开销是:

Σ ( 某顶点到1号顶点的最小距离 * 该顶点的权重值 )

故这道题是个纯粹的单源最短路问题。

说起单源最短路,我们可以很快想起图论课程一定会教的两个经典算法,即Bellman-Ford算法Dijkstra算法。本文只讨论后者,前者(以及它的优化版本SPFA)之后有时间再说,毕竟今天是Christmas Eve,不想写太多咯。

朴素的Dijkstra算法

Dijkstra算法本质上是贪心+BFS的策略,即以源点为起始,不断探测相邻的顶点,每次都选择当前路径最短的那个顶点,并对该顶点的所有出边做松弛操作,各个顶点的最短路径就会一直扩散下去,直到所有顶点都处理完毕。

下面来描述一下它。设图G=(V,E)是一张带权有向图。声明一个数组dist,dist[v]表示当前从源点s开始到顶点v的最短路径长度(初始化dist[s]为0,其他为无穷大)。Dijkstra算法可以用如下伪码表示。

function dijkstra(G, s):
  // 初始化
  create vertex set V
  create current min-distance array dist[]
  foreach vertex v of G:
    add v to V
    dist[v] = INFINITY
  dist[s] = 0

  while V is not empty:
    // 选择未处理的顶点集合V中dist最小的那个顶点
    u = vertex in V with MINIMUM dist[u]
    remove u from V
    // 遍历所有出边顶点
    foreach out-edge vertex v of u:
      // 松弛,其实就是判断A经由B到C的路径以及A直接到C的路径哪个短
      alt_dist = dist[u] + length(u, v)
      if alt_dist < dist[v]:
        dist[v] = alt_dist

  return dist

英文维基上给了一个很好的GIF来描述Dijkstra算法的执行过程。

Dijkstra算法与其最小堆优化_第2张图片

需要注意,Dijkstra算法无法处理带负权边的图,即边“长度”小于0的图。由于该算法的贪心性质,它“看不到”远处的负权边,所以会破坏松弛操作的正确性(加上负权边之后本来已经确定的最短路径就不再是最短的了),得出的结果就是错的。特别地,如果图里存在负权环,那么Dijkstra算法就会陷入死循环出不来。所以,只要题目里存在负权边,我们就必须换用Bellman-Ford/SPFA算法。(没有负权边就别用SPFA了,SPFA已经被各种竞赛卡得不要不要的了)

算法介绍完了,来学以致用,做一下上面那道圣诞树的题目吧。代码如下。

#include 
#include 
#include 
using namespace std;

typedef long long ll;
const int MAXN = 50010;
const ll INF = 1e18;

int t, nv, ne, a, b, c;
int edgeNum;
int weight[MAXN];
bool visited[MAXN];
ll dist[MAXN];

struct Edge {
  int next, to, weight;
};
Edge edges[MAXN * 2];
int head[MAXN];

inline void addEdge(int from, int to, int weight) {
  edges[++edgeNum].weight = weight;
  edges[edgeNum].to = to;
  edges[edgeNum].next = head[from];
  head[from] = edgeNum;

  edges[++edgeNum].weight = weight;
  edges[edgeNum].to = from;
  edges[edgeNum].next = head[to];
  head[to] = edgeNum;
}

void dijkstra() {
  for (int i = 0; i <= nv; i++) {
    dist[i] = INF;
  }
  dist[1] = 0;
  memset(visited, 0, sizeof(visited));

  for (int i = 1; i <= nv; i++) {
    int u = -1;
    ll minDist = INF;
    for (int j = 1; j <= nv; j++) {
      if (!visited[j] && dist[j] < minDist) {
        minDist = dist[j];
        u = j;
      }
    }
    if (u == -1) {
      break;
    }
    visited[u] = true;

    for (int e = head[u]; e != 0; e = edges[e].next) {
      int v = edges[e].to;
      if (!visited[v] && dist[u] + edges[e].weight < dist[v]) {
        dist[v] = dist[u] + edges[e].weight;
      }
    }
  }
}

int main() {
  scanf("%d", &t);
  while (t--) {
    memset(head, 0, sizeof(head));
    edgeNum = 0;
    scanf("%d%d", &nv, &ne);
    for (int i = 1; i <= nv; i++) {
      scanf("%d", &weight[i]);
    }
    for (int i = 1; i <= ne; i++) {
      scanf("%d%d%d", &a, &b, &c);
      addEdge(a, b, c);
    }

    dijkstra();

    ll result = 0;
    for (int i = 1; i <= nv; i++) {
      if (dist[i] == INF) {
        result = -1;
        break;
      }
      result += weight[i] * dist[i];
    }
    if (result == -1) {
      printf("No Answer\n");
    } else {
      printf("%lld\n", result);
    }
  }
  return 0;
}

由于边的花费和节点的权重值都能达到216,所以dist数组和结果值要乖乖用long long。另外,这里采用链式前向星来存储图,它是一种介于邻接表和邻接矩阵之间的结构,在竞赛中很常用。关于链式前向星的细节请参考原作者Malash的这篇文章(orz)。

好了,提交一下。恭喜~我们得到了华丽丽热气腾腾的TLE。

195230-550de5935c05dbd0.png

从朴素Dijkstra算法的实现可知,它的时间复杂度是O(|E|+|V|2)=O(|V|2)。而题目给出的|V|最大可达50000,时间限制为3000ms,显然是非常紧张的。所以不管是在竞赛中还是在实际应用中,都会对朴素Dijkstra算法做优化,下面来看。

最小堆优化的Dijkstra算法

重新看一下上面的代码,可以发现遍历顶点并找出dist最小的点的过程效率很低,就是下面这一小段。

  for (int i = 1; i <= nv; i++) {
    int u = -1;
    ll minDist = INF;
    for (int j = 1; j <= nv; j++) {
      if (!visited[j] && dist[j] < minDist) {
        minDist = dist[j];
        u = j;
      }
    }
    // ....

那么能不能维护住这些dist最小的点,不必每次都去O(n2)地扫呢?答案自然是可以的,用最小堆就完事了,C++ STL自带有优先队列priority_queue。看官可以阅读笔者之前写的《二叉堆、优先队列与Top-N问题》这篇文章获得一点基础知识。

具体实现起来,还有两点要注意:

  • 要维护好顶点下标到dist值的映射;
  • C++的priority_queue与Java的PriorityQueue相反,默认是最大堆。

我们可以定义顶点下标与dist值的结构体,并重载<运算符使其变成最小堆,如下。

struct QNode {
  int vno, dist;
  bool operator < (const QNode &x) const {
    return x.dist < dist;
  }
};

接下来改写dijkstra()方法,轻松愉快了。将原版那个耗时的for循环换成从优先队列中pop出dist值最小的顶点,其他一切照常。

#include 

void dijkstra() {
  priority_queue q;
  for (int i = 0; i <= nv; i++) {
    dist[i] = INF;
  }
  dist[1] = 0;
  memset(visited, 0, sizeof(visited));

  QNode tn, qn;
  tn.vno = 1;
  tn.dist = 0;
  q.push(tn);

  while (!q.empty()) {
    tn = q.top();
    q.pop();
    int u = tn.vno;
    if (visited[u]) {
      continue;
    }
    visited[u] = true;

    for (int e = head[u]; e != 0; e = edges[e].next) {
      int v = edges[e].to;
      if (!visited[v] && dist[u] + edges[e].weight < dist[v]) {
        dist[v] = dist[u] + edges[e].weight;
        qn.vno = v;
        qn.dist = dist[v];
        q.push(qn);
      }
    }
  }
}

提交,成功AC。

195230-efabad78acf6abe8.png

最小堆优化的Dijkstra算法时间复杂度可以改进到O[(|E|+|V|)log|V|],对于稀疏图(即|E| << |V|2的图)尤为有效。

事实上,除了用最小堆优化Dijkstra算法之外,斐波那契堆、配对堆也都可以,并且效率会更高。但最小堆一般都够用了,并且笔者之前没有介绍过斐波那契堆和配对堆,它们俩还是有点难理解的,因此就不强行在这里讲了,择日介绍吧。

The End

Happy Christmas Eve~

民那晚安晚安。

你可能感兴趣的:(算法/数据结构)