資料結構學習(C++)——圖【4】(最短路徑) (轉)

worldblog發表於2007-08-16
資料結構學習(C++)——圖【4】(最短路徑) (轉)[@more@]

最短路徑恐怕是圖的各種演算法中最能吸引初學者眼球的了——在地圖上找一條最短的路或許每個人都曾經嘗試過。下面我們用來完成我們曾經的“願望”。

在圖的演算法中有個有趣的現象,就是問題的規模越大,演算法就越簡單。圖是個複雜的結構,對於一個特定問題,求解特定頂點的結果都會受到其他頂點的影響——就好比一堆互相碰撞的球體,要求解特定球體的狀態,就必須考慮其他球體的狀態。既然每個頂點都要掃描,如果對所有的頂點都求解,那麼演算法就非常的簡單——無非是遍歷嗎。然而,當我們降低問題規模,那很自然的,我們希望演算法的規模也降低——如果不降低,不是殺雞用牛刀?但是,正是由於圖的複雜性,使得這種降低不容易達到,因此,為了降低演算法的規模,使得演算法就複雜了。

在下面的介紹中,清楚了印證了上面的結論。人的認知過程是從簡單到複雜,雖然表面看上去,求每對頂點之間的最短路徑要位元定頂點到其他頂點之間的最短路徑複雜,但是,就像上面說的,本質上,前者更為簡單。下面的介紹沒有考慮歷史因素(就是指哪個演算法先提出來),也沒有考慮演算法提出者的真實想法(究竟是誰參考了或是沒參考誰),只是從演算法本身之間的聯絡來做一個闡述,如有疏漏,敬請原諒。

準備工作

一路走下來,圖類已經很“臃腫”了,為了更清晰的說明問題,需要“重起爐灶另開張”,同時也是為了使演算法和儲存方法分開,以便於複用。

首先要為基本圖類新增幾個介面。

template

classwork

{

public:

  int find(const name& v) { int n; if (!data.find(v, n)) return -1; return n; }

  dist& getE(int m, int n) { return data.getE(m, n); }

  const dist& NoEdge() { return data.NoEdge; }

};

template

class AdjMatrix

{

public:

  dist& getE(int m, int n) { return edge[m][n]; }

};

template

class Link

{

public:

  dist& getE(int m, int n)

  {

  for (list::iterator iter = vertices[m].e->begin();

  iter != vertices[m].e->end() && iter->vID < n; iter++);

  if (iter == vertices[m].e->end()) return NoEdge;

  if (iter->vID == n) return iter->cost;

  return NoEdge;

  }

};

然後就是為了最短路徑演算法“量身定做”的“演算法類”。求某個圖的最短路徑時,將圖繫結到演算法上,例如這樣:

Network > a(100);

//插入點、邊……

Weight > b(&a);

 :namespace prefix = o ns = "urn:schemas--com::office" />

#includework.h"

template

class Weight

{

public:

  Weight(Network* G) : G(G), all(false), N(G->vNum())

  {

  length = new dist*[N]; path = new int*[N];

    shortest = new bool[N]; int i, j;

  for (i = 0; i < N; i++)

  {

    length[i] = new dist[N]; path[i] = new int[N];

  }

  for (i = 0; i < N; i++)

  {

    shortest[i] = false;

    for (j = 0; j < N; j++)

  {

    length[i][j] = G->getE(i, j);

    if (length[i][j] != G->NoEdge()) path[i][j] = i;

    else path[i][j] = -1;

  }

  }

  }

  ~Weight()

  {

  for (int i = 0; i < N; i++) { delete []length[i]; delete []path[i]; }

  delete []length; delete []path; delete []shortest;

  }

private:

  void print(int i, int j)

  {

  if (path[i][j] == -1) cout << "No Path" << endl; return;

  cout << "Shortest Path: "; out(i, j); cout << G->getV(j) << endl;

  cout << "Path Length: " << length[i][j] << endl;

  }

  void out(int i, int j)

  {

  if (path[i][j] != i) out(i, path[i][j]);

  cout << G->getV(path[i][j]) << "->";

  }

  dist** length; int** path; bool* shortest; bool all; int N;

  Network* G;

};

發現有了構造真好,演算法的結果陣列的初始化和演算法本身分開了,這樣一來,演算法的基本步驟就很容易看清楚了。

所有頂點之間的最短路徑(Floyed演算法)

從v1到v2的路徑要麼是v1->v2,要麼中間經過了若干頂點。顯然我們要求的是這些路徑中最短的一條。這樣一來,問題就很好解決了——最初都是源點到目的點,然後依次新增頂點,使得路徑逐漸縮短,頂點都新增完了,演算法就結束了。

void AllShortestPath()//Folyed

{

  if (all) return;

  for (int k = 0; k < N; k++)

  {

    shortest[k] = true;

  for (int i = 0; i < N; i++)

    for (int j = 0; j < N; j++)

    if (length[i][k] + length[k][j] < length[i][j])

    {

      length[i][j] = length[i][k] + length[k][j];

      path[i][j] = path[k][j];

    }

  }

  all = true;

}

單源最短路徑(Dijkstra演算法)

仿照上面的Floyed演算法,很容易的,我們能得出下面的演算法:

void ShortestPath(int v1)

{

//Bellman-Ford

  for (int k = 2; k < N; k++)

  for (int i = 0; i < N; i++)

    for (int j = 0; j < N; j++)

    if (length[v1][j] + length[j][i] < length[v1][i])

    {

      length[v1][i] = length[v1][j] + length[j][i];

      path[v1][i] = j;

    }

}

這就是Bellman-Ford演算法,可以看到,採用Floyed演算法的思想,不能使演算法的時間複雜度從O(n3)降到預期的O(n2),只是空間複雜度從O(n2)降到了O(n),但這也是應該的,因為只需要原來結果陣列中的一行。因此,我並不覺得這個演算法是解決“邊上權值為任意值的單源最短路徑問題”而專門提出來的,是Dijkstra演算法的“推廣”版本,他只是Floyed演算法的退化版本。

顯然,Floyed演算法是經過N次N2條邊迭代而產生最短路徑的;如果我們想把時間複雜度從O(n3) 降到預期的O(n2),就必須把N次迭代的N2條邊變為N條邊,也就是說每次參與迭代的只有一條邊——問題是如何找到這條邊。

先看看邊的權值非負的情況。假設從頂點0出發,到各個頂點的距離是a1,a2……,那麼,這其中的最短距離an必定是從0到n號頂點的最短距離。這是因為,如果an不是從0到n號頂點的最短距離,那麼必然是中間經過了某個頂點;但現在邊的權值非負,一個比現在這條邊還長的邊再加上另一條非負的邊,是不可能比這條邊短的。從這個原理出發,就能得出Dijkstra演算法,注意,這個和Prim演算法極其相似,不知道誰參考了誰;但這也是難免的事情,因為他們的原理是一樣的。

void ShortestPath(const name& vex1, const name& vex2)//Dijkstra

{

  int v1 = G->find(vex1); int v2 = G->find(vex2);

  if (shortest[v1]) { print(v1, v2); return; }

  bool* S = new bool[N]; int i, j, k;

  for (i = 0; i < N; i++) S[i] = false; S[v1] = true;

  for (i = 0; i < N - 1; i++)//Dijkstra Start, like Prim?

  {

  for (j = 0, k = v1; j < N; j++)

  if (!S[j] && length[v1][j] < length[v1][k]) k = j;

  S[k] = true;

  for (j = 0; j < N; j++)

  if (!S[j] && length[v1][k] + length[k][j] < length[v1][j])

  {

    length[v1][j] = length[v1][k] + length[k][j];

    path[v1][j] = k;

  }

  }

  shortest[v1] = true; print(v1, v2);

}

如果邊的權值有負值,那麼上面的原則不再適用,連帶的,Dijkstra演算法也就不再適用了。這時候,沒辦法,只有接受O(n3) Bellman-Ford演算法了,雖然他可以降低為O(n*e)。不過,何必讓邊的權值為負值呢?還是那句話,合理的並不好用。

特定兩個頂點之間的最短路徑(A*演算法)

其實這才是我們最關心的問題,我們只是想知道從甲地到乙地怎麼走最近,並不想知道別的——甲地到丙地怎麼走關我什麼事?自然的,我們希望這個演算法的時間複雜度為O(n),但是,正像從Floyed演算法到Dijkstra演算法的變化一樣,並不是很容易達到這個目標的。

讓我們先來看看Dijkstra演算法求特定兩個頂點之間的最短路徑的時間複雜度究竟是多少。顯然,在上面的void ShortestPath(const name& vex1, const name& vex2)中,當S[v2]==true時,演算法就可以中止了。假設兩個頂點之間直接的路徑是他們之間的路徑最短的(不需要經過其他中間頂點),並且這個路徑長度是源點到所有目的點的最短路徑中最短的,那麼第一次迭代的時候,就可以得到結果了。也就是說是O(n)。然而當兩個頂點的最短路徑需要經過其他頂點,或者路徑長度不是源點到未求出最短路徑的目的點的最短路徑中最短的,那就要再進行若干次迭代,對應的,時間複雜度就變為O(2n)、O(3n)……到了最後才求出來(迭代了N-1次)的就是O(n2)。

很明顯的,迭代次數是有下限的,最短路徑上要經過多少個頂點,至少就要迭代多少次,我們只能使得最終的迭代次數接近最少需要的次數。如果再要減低演算法的時間複雜度,我們只能想辦法把搜尋範圍的O(n)變為O(1),這樣,即使迭代了N-1次才得到結果,那時間複雜度仍為O(n)。但這個想法實現起來卻是困難重重。

仍然看Dijkstra演算法,第一步要尋找S中的頂點到S外面頂點中最短的一條路徑,這個min運算使用基於比較的方法的時間複雜度下限是O(lo)(使用敗者樹),然後需要掃描結果陣列的每個分量進行修改,這裡的時間複雜度就只能是O(n)了。原始的Dijkstra演算法達不到預期的目的。

現在讓我們給圖新增一個附加條件——兩點之間直線最短,就是說如果v1和v2之間有直通的路徑(不經過其他頂點),那麼這條路徑就是他們之間的最短路徑。這樣一來,如果求的是v1能夠直接到達的頂點的最短路徑,時間複雜度就是O(1)了,顯然比原來最好的O(n)(這裡實際上是O(logN))提高了。

這個變化的產生,是因為我們新增了“兩點之間直線最短”這樣的附加條件,實際上就是引入一個判斷準則,把原來盲目的O(n)搜尋降到了O(1)。這個思想就是A*演算法的思想。關於A*演算法更深入的介紹,恕本人資料有限,不能滿足大家了。有興趣的可以到網上查查,這方面的中文資料實在太少了,大家做好看E文的準備吧。

總結

不同於現有的教科書,我把求最短路徑的演算法的介紹順序改成了上面的樣子。我認為這個順序才真正反映了問題的本質——當減低問題規模時,為了降低演算法的時間複雜度,應該想辦法縮小搜尋範圍。而縮小搜尋範圍,都用到了一個思想——儘可能的向接近最後結果的方向搜尋,這就是貪婪演算法的應用。

如果反向看一遍演算法的演化,我們還能得出新的結論。Dijkstra演算法實際上不用做n2次搜尋、比較和修改,當求出最短路徑的頂點後,搜尋範圍已經被縮小了,只是限於儲存結構,這種範圍的縮小並沒有體現出來。如果我們使得這種範圍縮小直接體現出來,那麼,用n次Dijkstra演算法代替Floyed演算法就能帶來效率上的提升。A*演算法也是如此,如果用求n點的A*演算法來代替Dijkstra演算法,也能帶來效率的提升。

但是,每一步的進化實際上都伴隨著附加條件的引入。從Floyed到Dijkstra是邊上的權值非負,如果這個條件不成立,那麼就只能退化成Bellman-Ford演算法。從Dijkstra到A*是另外的判斷準則的引入(本文中是兩點之間直線最短),如果這個條件不成立,同樣的,只能採用不完整的Dijkstra(求到目的頂點結束演算法)。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/10752043/viewspace-962130/,如需轉載,請註明出處,否則將追究法律責任。

相關文章