圖論演算法

張其勳發表於2023-03-11

圖論演算法

第一節 基本概念

一、什麼是圖?   

很簡單,點用邊連起來就叫做圖,嚴格意義上講,圖是一種資料結構,定義為:graph=(V,E)。V是一個非空有限集合,代表頂點(結點),E代表邊的集合。

二、圖的一些定義和概念

(a)有向圖:圖的邊有方向,只能按箭頭方向從一點到另一點。(a)就是一個有向圖。 (b)無向圖:圖的邊沒有方向,可以雙向。(b)就是一個無向圖。

結點的度:無向圖中與結點相連的邊的數目,稱為結點的度。 結點的入度:在有向圖中,以這個結點為終點的有向邊的數目。

結點的出度:在有向圖中,以這個結點為起點的有向邊的數目。

權值:邊的“費用”,可以形象地理解為邊的長度。

連通:如果圖中結點U,V之間存在一條從U透過若干條邊、點到達V的通路,則稱U、V 是連通的。

迴路:起點和終點相同的路徑,稱為迴路,或“環”。

完全圖:一個n 階的完全無向圖含有n*(n-1)/2 條邊;一個n 階的完全有向圖含有n*(n-1)條邊;    

稠密圖:一個邊數接近完全圖的圖。     

稀疏圖:一個邊數遠遠少於完全圖的圖。     

強連通分量:有向圖中任意兩點都連通的最大子圖。右圖中,1-2-5構成一個強連通分量。特殊地,單個點也算一個強連通分量,所以右圖有三個強連通分量:1-2-5,4,3。

三、圖的儲存結構

1.二維陣列鄰接矩陣儲存

定義int G[101][101]; G[i][j]的值,表示從點i到點j的邊的權值,定義如下:

 

 

 

 

 

建立鄰接矩陣時,有兩個小技巧:  

 初始化陣列大可不必使用兩重for迴圈。   

1)  如果是int陣列,採用memset(g, 0x7f, sizeof(g))可全部初始化為一個很大的數(略小於0x7fffffff),使用memset(g, 0, sizeof(g)),全部清為0,使用memset(g, 0xaf, sizeof(g)),全部初始化為一個很小的數。  

2)如果是double陣列,採用memset(g,127,sizeof(g));可全部初始化為一個很大的數1.38*10306,使用memset(g, 0, sizeof(g))全部清為0.


 

下面是建立圖的鄰接矩陣的參考程式段:

#include<iostream>                                                                                             
using namespace std;
int i,j,k,e,n;
double g[101][101];
double w;
int main()
{
    int i,j;
    for (i = 1; i <= n; i++)
      for (j = 1; j <= n; j++)
        g[i][j] = 0x7fffffff(賦一個超大值);  //初始化,對於不帶權的圖g[i][j]=0,表示沒有邊連通。這裡用0x7fffffff代替無窮大。
    cin >> e;
    for (k = 1; k <= e; k++)
    {
         cin >> i >> j >> w;             //讀入兩個頂點序號及權值
         g[i][j] = w;                    //對於不帶權的圖g[i][j]=1
         g[j][i] = w;                    //無向圖的對稱性,如果是有向圖則不要有這句!
    } 
    …………
    return 0;
}

 

 


 

2.陣列模擬鄰接表儲存   

圖的鄰接表儲存法,又叫鏈式儲存法。本來是要採用連結串列實現的,但大多數情況下只要用陣列模擬即可。   

以下是用陣列模擬鄰接表儲存的參考程式段:

#include <iostream>
using namespace std;
const int maxn=1001,maxm=100001;
struct Edge
{
    int next;                               //下一條邊的編號 
    int to;                                 //這條邊到達的點 
    int dis;                                //這條邊的長度 
}edge[maxm];

int head[maxn],num_edge,n,m,u,v,d;

void add_edge(int from,int to,int dis)      //加入一條從from到to距離為dis的單向邊 
{
    edge[++num_edge].next=head[from];
    edge[num_edge].to=to;
    edge[num_edge].dis=dis;
    head[from]=num_edge;
}
int main()
{
    num_edge=0;
    scanf("%d %d",&n,&m);                    //讀入點數和邊數
    for(int i=1;i<=m;i++)
    {
          scanf("%d %d %d",&u,&v,&d);   //u、v之間有一條長度為d的邊 
          add_edge(u,v,d);
    }
    for(int i=head[1];i!=0;i=edge[i].next)   //遍歷從點1開始的所有邊 
    {
           //...
    }

    //...

    return 0;
}

兩種方法各有用武之地,需按具體情況,具體選用。


 

第二節 圖的遍歷

一、深度優先與廣度優先遍歷   

從圖中某一頂點出發系統地訪問圖中所有頂點,使每個頂點恰好被訪問一次,這種運算操作被稱為圖的遍歷。

為了避免重複訪問某個頂點,可以設一個標誌陣列visited[i],未訪問時值為false,訪問一次後就改為true。   

圖的遍歷分為深度優先遍歷和廣度優先遍歷兩種方法,兩者的時間效率都是O(n*n)。

1.深度優先遍歷   

深度優先遍歷與深搜DFS相似,從一個點A出發,將這個點標為已訪問visited[i]:=true;,然後再訪問所有與之相連,且未被訪問過的點。

當A的所有鄰接點都被訪問過後,再退回到A的上一個點(假設是B),再從B的另一個未被訪問的鄰接點出發,繼續遍歷。   

例如對右邊的這個無向圖深度優先遍歷,假定先從1出發   程式以如下順序遍歷:   1→2→5,然後退回到2,退回到1。   

從1開始再訪問未被訪問過的點3 ,3沒有未訪問的鄰接點,退回1。   

再從1開始訪問未被訪問過的點4,再退回1 。   

起點1的所有鄰接點都已訪問,遍歷結束。

2.廣度優先遍歷   

廣度優先遍歷並不常用,從程式設計複雜度的角度考慮,通常採用的是深度優先遍歷。   

廣度優先遍歷和廣搜BFS相似,因此使用廣度優先遍歷一張圖並不需要掌握什麼新的知識,在原有的廣度優先搜尋的基礎上,做一點小小的修改,就成了廣度優先遍歷演算法。


 

二、一筆畫問題   

如果一個圖存在一筆畫,則一筆畫的路徑叫做尤拉路,如果最後又回到起點,那這個路徑叫做尤拉回路。   

我們定義奇點是指跟這個點相連的邊數目有奇數個的點。對於能夠一筆畫的圖,我們有以下兩個定理。    

定理1:存在尤拉路的條件:圖是連通的,有且只有2個奇點。    

定理2:存在尤拉回路的條件:圖是連通的,有0個奇點。   

兩個定理的正確性是顯而易見的,既然每條邊都要經過一次,那麼對於尤拉路,除了起點和終點外,每個點如果進入了一次,顯然一定要出去一次,顯然是偶點。對於尤拉回路,每個點進入和出去次數一定都是相等的,顯然沒有奇點。  

求尤拉路的演算法很簡單,使用深度優先遍歷即可。   

根據一筆畫的兩個定理,如果尋找尤拉回路,對任意一個點執行深度優先遍歷;找尤拉路,則對一個奇點執行DFS,時間複雜度為O(m+n),m為邊數,n是點數。

以下是尋找一個圖的尤拉路的演算法實現:

樣例輸入:第一行n,m,有n個點,m條邊,以下m行描述每條邊連線的兩點。

5 5

1 2

2 3

3 4

4 5

5 1

樣例輸出:尤拉路或尤拉回路

1 5 4 3 2 1

#include<iostream>
#include<cstring>
using namespace std;
#define maxn 101
int g[maxn][maxn];               //此圖用鄰接矩陣儲存
int du[maxn];                    //記錄每個點的度,就是相連的邊的數目
int circuit[maxn];               //用來記錄找到的尤拉路的路徑
int n,e,circuitpos,i,j,x,y,start;
void find_circuit(int i){         //這個點深度優先遍歷過程尋找尤拉路
  int j;
  for (j = 1; j <= n; j++)
      if (g[i][j] == 1)          //從任意一個與它相連的點出發
     {
          g[j][i] = g[i][j] = 0; 
          find_circuit(j);
      } 
  circuit[++circuitpos] = i;   //記錄下路徑
}
int main()
  {
      memset(g,0,sizeof(g));
      cin >> n >> e;
      for (i = 1; i <= e; i++)
      {
          cin >> x >> y;
          g[y][x] = g[x][y] = 1;
          du[x]++;                    //統計每個點的度
          du[y]++;
      }
      start = 1;                      //如果有奇點,就從奇點開始尋找,這樣找到的就是
      for (i = 1; i <= n; i++)        //尤拉路。沒有奇點就從任意點開始,
         if (du[i]%2 == 1)            //這樣找到的就是尤拉回路。(因為每一個點都是偶點)
              start = i;
      circuitpos = 0;
      find_circuit(start);
      for (i = 1; i <= circuitpos; i++)
          cout << circuit[i] << ' ';
      cout << endl;
      return 0;
}

注意以上程式具有一定的侷限性,對於下面這種情況它不能很好地處理:

 

 

 

上圖具有多個尤拉回路,而本程式只能找到一個迴路。讀者在遇到具體問題時,還應對程式作出相應的修改。

第三節  最短路徑演算法

如下圖所示,我們把邊帶有權值的圖稱為帶權圖。邊的權值可以理解為兩點之間的距離。一張圖中任意兩點間會有不同的路徑相連。最短路徑就是指連線兩點的這些路徑中最短的一條。   

 

 

 我們有四種演算法可以有效地解決最短路徑問題。有一點需要讀者特別注意:邊的權值可以為負。當出現負邊權時,有些演算法不適用。

  

一、求出最短路徑的長度   

以下沒有特別說明的話,dis[u][v]表示從u到v最短路徑長度,w[u][v]表示連線u,v的邊的長度。 1.Floyed-Warshall演算法 O(N3)   

簡稱Floyed(弗洛伊德)演算法,是最簡單的最短路徑演算法,可以計算圖中任意兩點間的最短路徑。Floyed的時間複雜度是O (N3),適用於出現負邊權的情況。 演算法描述: 初始化:點u、v如果有邊相連,則dis[u][v]=w[u][v]。   

如果不相連則dis[u][v]=0x7fffffff For (k = 1; k <= n; k++) For (i = 1; i <= n; i++) For (j = 1; j <= n; j++) If (dis[i][j] >dis[i][k] + dis[k][j]) dis[i][j] = dis[i][k] + dis[k][j]; 演算法結束:dis[i][j]得出的就是從i到j的最短路徑。

演算法分析&思想講解:   三層迴圈,第一層迴圈中間點k,第二第三層迴圈起點終點i、j,演算法的思想很容易理解:如果點i到點k的距離加上點k到點j的距離小於原先點i到點j的距離,那麼就用這個更短的路徑長度來更新原先點i到點j的距離。   在上圖中,因為dis[1][3]+dis[3][2]<dis[1][2],所以就用dis[1][3]+dis[3][2]來更新原先1到2的距離。   我們在初始化時,把不相連的點之間的距離設為一個很大的數,不妨可以看作這兩點相隔很遠很遠,如果兩者之間有最短路徑的話,就會更新成最短路徑的長度。Floyed演算法的時間複雜度是O(N3)。

第四節 圖的連通性問題

一、判斷圖中的兩點是否連通

1、Floyed演算法    

時間複雜度:O(N3 )

演算法實現:   把相連的兩點間的距離設為dis[i][j]=true,不相連的兩點設為dis[i][j]=false,用Floyed演算法的變形:  

for (k = 1; k <= n; k++)

for (i = 1; i <= n; i++)

for (j = 1; j <= n; j++)  

dis[i][j] = dis[i][j] || (dis[i][k] && dis[k][j]);   

最後如果dis[i][j]=true的話,那麼就說明兩點之間有路徑連通。  

2、遍歷演算法   

時間複雜度:O(N2 )

演算法實現:   從任意一個頂點出發,進行一次遍歷,能夠從這個點出發到達的點就與起點是聯通的。

這樣就可以求出此頂點和其它各個頂點的連通情況。

所以只要把每個頂點作為出發點都進行一次遍歷,就能知道任意兩個頂點之間是否有路存在。   

可以使用DFS實現。   

有向圖與無向圖都適用。

二、求有向圖的強連通分量

Kosaraju演算法可以求出有向圖中的強連通分量個數,並且對分屬於不同強連通分量的點進行標記。

它的演算法描述較為簡單: (1) 第一次對圖G進行DFS遍歷,並在遍歷過程中,記錄每一個點的退出順序。以下圖為例:

 

 

 

 

 

 

如果以1為起點遍歷,訪問結點的順序如下:

 

結點第二次被訪問即為退出之時,那麼我們可以得到結點的退出順序:

 

 

 

(2)倒轉每一條邊的方向,構造出一個反圖G’。

然後按照退出順序的逆序對反圖進行第二次DFS遍歷。我們按1、4、2、3、5的逆序第二次DFS遍歷:

 

 

 

訪問過程如下:

 

 

每次遍歷得到的那些點即屬於同一個強連通分量。

1、4屬於同一個強連通分量,2、3、5屬於另一個強連通分量。

 

相關文章