Dijkstra算法與其最小堆優(yōu)化

(仿佛回到了當年打比賽的時候呢

POJ 3013(Big Christmas Tree)

傳送門:http://poj.org/problem?id=3013

題目大意:由一堆頂點和邊構造出一棵圣誕樹,1號頂點固定為樹根,頂點和邊各自有權重值(均為正數(shù))。構造圣誕樹的邊的開銷是邊權乘以子樹中所有頂點的權重之和,總開銷則是所有邊的開銷之和。求圣誕樹的最小開銷。

稍微變換一下,容易得知構造圣誕樹的最小開銷是:

Σ ( 某頂點到1號頂點的最小距離 * 該頂點的權重值 )

故這道題是個純粹的單源最短路問題。

說起單源最短路,我們可以很快想起圖論課程一定會教的兩個經典算法,即Bellman-Ford算法Dijkstra算法。本文只討論后者,前者(以及它的優(yōu)化版本SPFA)之后有時間再說,畢竟今天是Christmas Eve,不想寫太多咯。

樸素的Dijkstra算法

Dijkstra算法本質上是貪心+BFS的策略,即以源點為起始,不斷探測相鄰的頂點,每次都選擇當前路徑最短的那個頂點,并對該頂點的所有出邊做松弛操作,各個頂點的最短路徑就會一直擴散下去,直到所有頂點都處理完畢。

下面來描述一下它。設圖G=(V,E)是一張帶權有向圖。聲明一個數(shù)組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算法的執(zhí)行過程。

需要注意,Dijkstra算法無法處理帶負權邊的圖,即邊“長度”小于0的圖。由于該算法的貪心性質,它“看不到”遠處的負權邊,所以會破壞松弛操作的正確性(加上負權邊之后本來已經確定的最短路徑就不再是最短的了),得出的結果就是錯的。特別地,如果圖里存在負權環(huán),那么Dijkstra算法就會陷入死循環(huán)出不來。所以,只要題目里存在負權邊,我們就必須換用Bellman-Ford/SPFA算法。(沒有負權邊就別用SPFA了,SPFA已經被各種競賽卡得不要不要的了)

算法介紹完了,來學以致用,做一下上面那道圣誕樹的題目吧。代碼如下。

#include <iostream>
#include <cstdio>
#include <cstring>
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;
}

由于邊的花費和節(jié)點的權重值都能達到216,所以dist數(shù)組和結果值要乖乖用long long。另外,這里采用鏈式前向星來存儲圖,它是一種介于鄰接表和鄰接矩陣之間的結構,在競賽中很常用。關于鏈式前向星的細節(jié)請參考原作者Malash的這篇文章(orz)。

好了,提交一下。恭喜~我們得到了華麗麗熱氣騰騰的TLE。

從樸素Dijkstra算法的實現(xiàn)可知,它的時間復雜度是O(|E|+|V|2)=O(|V|2)。而題目給出的|V|最大可達50000,時間限制為3000ms,顯然是非常緊張的。所以不管是在競賽中還是在實際應用中,都會對樸素Dijkstra算法做優(yōu)化,下面來看。

最小堆優(yōu)化的Dijkstra算法

重新看一下上面的代碼,可以發(fā)現(xiàn)遍歷頂點并找出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自帶有優(yōu)先隊列priority_queue??垂倏梢蚤喿x筆者之前寫的《二叉堆、優(yōu)先隊列與Top-N問題》這篇文章獲得一點基礎知識。

具體實現(xià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循環(huán)換成從優(yōu)先隊列中pop出dist值最小的頂點,其他一切照常。

#include <queue>

void dijkstra() {
  priority_queue<QNode> 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。

最小堆優(yōu)化的Dijkstra算法時間復雜度可以改進到O[(|E|+|V|)log|V|],對于稀疏圖(即|E| << |V|2的圖)尤為有效。

事實上,除了用最小堆優(yōu)化Dijkstra算法之外,斐波那契堆、配對堆也都可以,并且效率會更高。但最小堆一般都夠用了,并且筆者之前沒有介紹過斐波那契堆和配對堆,它們倆還是有點難理解的,因此就不強行在這里講了,擇日介紹吧。

The End

Happy Christmas Eve~

民那晚安晚安。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容