【數據結構】圖


線性表可以是空表,樹可以是空樹,但圖G(Graph)不可以是空圖。就是說,圖中不能一個頂點也沒有,圖的頂點集V(Vertex)一定非空,但邊集E(Edge)可以為空,此時圖中只有頂點而沒有邊。

  • 若一個圖有n個頂點,並且邊數小於n-1,則此圖一定是非連通圖
  • 若一個圖有n個頂點,並且有大於n-1條邊,則此圖一定有

基本概念

  • 簡單圖:
    • 不存在重復的邊
    • 不存在頂點到自身的邊
  • 完全圖(簡單完全圖)
    • 無向圖中,任意兩個頂點之間都存在邊
    • 含有n個頂點的無向完全圖n(n-1)/2條邊。
    • 有向圖中,任意兩個頂點之間都存在反向相反的兩條弧。
    • 含有n個頂點的有向完全圖n(n-1)條有向邊。
  • 子圖:
    • 由V的子集和E的子集組合而成的圖G'。
    • 若有滿足V(G')=V(G)(即頂點集相同)的子圖G',則稱其為G的生成子圖。(詳情參照生成樹)

並非V和E的任何子集都能構成G的子圖,因為這樣的子集可能不是圖,即E的子集中的某些邊關聯的頂點可能不在這個V的子集中(單有一條邊,兩邊可能沒頂點)。

  • 連通圖和連通分量:
    • 無向圖中任意兩點都是連通的,那么圖被稱作連通圖
    • 無向圖中的極大連通子圖稱為連通分量
    • 如果此圖是有向圖,則稱為強連通圖(注意:需要雙向都有路徑)。
    • 有向圖中的極大強連通子圖稱為有向圖的強連通分量

      若一個圖有n個頂點,並且邊數小於n-1,則此圖一定是非連通圖

  • 生成樹、生成森林:
    • 連通圖的生成樹是包含圖中全部結點的一個極小連通子圖。
    • 若圖中頂點數為n,則它的生成樹含有n-1條邊。
    • 對於生成樹而言,若砍去它的一條邊,則會變成非連通圖;若加上一條邊,則會形成一個回路
    • 在非連通圖中,連通分量的生成樹構成了非連通圖的生成森林。

      包含無向圖中全部頂點極小連通子圖只有生成樹滿足條件,因為砍去生成樹的任一條邊,圖將不再連通。

  • 頂點的度:
    • 對於無向圖,頂點v的度是指依附於該頂點的邊的條數,記為TD(v)(Total Degree)
    • 在具有n個頂點、e條邊的無向圖中,無向圖的全部頂點的度的和等於邊數的2倍,因為每條邊和兩個頂點相關聯。
    • 對於有向圖,頂點的度分為入度和出度,入度是以頂點v為終點的有向邊的數目,記為ID(v),即(In Degree);而出度是以頂點v為起點的有向邊的數目,記為OD(v),即(Out Degree)。頂點v的度等於其入度和出度之和,即TD(v)=ID(v)+OD(v)
  • 網:邊上帶有權值的圖,稱為帶權圖,也稱為
  • 回路(環):
    • 第一個頂點和最后一個頂點相同的路徑稱為回路
    • 若一個圖有n個頂點,並且有大於n-1條邊,則此圖一定有

注意:有回路的圖不一定是連通圖,因為回路不一定包含圖的所有結點。

  • 簡單路徑、簡單回路:
    • 在路徑序列中,頂點不重復出現路徑,稱為簡單路徑
    • 除第一個頂點和最后一個頂點外,其余頂點不重復出現回路,稱為簡單回路

無向圖

  • 連通圖:
    在無向圖中,若從定點V1到V2有路徑,則稱頂點V1和V2是連通的。如果圖中任意一對頂點都是連通的,則稱此圖是連通圖。(連通的無向圖)

  • 極大連通子圖:包含該連通子圖中所有的邊(當連通時包含了所有的邊,當然也包含了所有的點)
    • 連通圖只有一個極大連通子圖,就是它本身。(是唯一的)
    • 非連通圖有多個極大連通子圖。(非連通圖的極大連通子圖叫做連通分量,每個分量都是一個連通圖)
    • 稱為極大是因為如果此時加入任何一個不在圖的點集中的點都會導致它不再連通
      下圖為非連通圖,圖中有兩個極大連通子圖(連通分量)。
  • 極小連通子圖:包含該無向連通圖中所有的頂點,最少的邊(即 包含圖中所有頂點及其比頂點數量少一個的邊(且不能成環))(只存在於連通的無向圖中

    注意:極小連通子圖只存在於連通的無向圖中,不存在於不連通的無向圖和有向圖中。

    • 一個連通圖的生成樹是該連通圖的的極小連通子圖。(同一個連通圖可以有不同的生成樹,所以生成樹不是唯一的)
      (極小連通子圖只存在於連通圖中)
    • 用邊把極小連通子圖中所有節點給連起來,若有n個節點,則有n-1條邊。如下圖生成樹有6個節點,有5條邊。
    • 之所以稱為極小是因為此時如果刪除一條邊,就無法構成生成樹,也就是說給極小連通子圖的每個邊都是不可少的。
    • 如果在生成樹上添加一條邊,一定會構成一個環。
    • 也就是說只要能連通圖的所有頂點而又不產生回路的任何子圖都是它的生成樹。

極大即要求改連通子圖包含其所有的邊極小連通子圖是既要保持圖的連通又要使得邊數最少的子圖。

極大加入任何一個不在圖的點集中的點都會導致它不再連通
極小是因為此時如果刪除一條邊,就導致不再連通加上一條邊,則會形成圖中的一條回路

總的來說:極大連通子圖是討論連通分量的,極小連通子圖是討論生成樹的。

有向圖

  • 強連通圖:
    在有向圖中,若對於每一對頂點Vi和Vj,都存在一條從Vi到Vj和從Vj到Vi的路徑,則稱此圖為強連通圖。(連通的有向圖)

  • n個頂點的強連通圖最多n(n-1)條邊最少n條邊(即)。(4個頂點的強連通圖圖示如上圖和下圖)

  • 極大強連通子圖:
  1. 強連通圖的極大強連通子圖為其本身。(是唯一的)
  2. 非強連通圖有多個極大強連通子圖。(有向圖的極大強連通子圖叫做強連通分量
  • 極小強連通子圖:不存在這個概念(因為極小是生成樹,而有向圖的強連通有兩條邊,不構成生成樹)

圖的存儲及基本操作

  • 十字有向,多重無向。

    助記:(十字准星,就代表有目標了,即 有向)

  • 是有多污:十有多無

鄰接矩陣法

  • 基本概念:
    所謂鄰接矩陣存儲,是指用一個以為數組存儲圖中頂點的信息,用一個二維數組存儲圖中邊的信息(即各頂點之間的鄰接關系),存儲頂點之間鄰接關系的二維數組稱為鄰接矩陣

鄰接矩陣是一種圖的順序存儲結構

  • 存儲結構:
#define MaxVertexNum 100    //頂點數目的最大值
typedef char VertexType;    //頂點的數據類型
typedef int EdgeType;       //帶權圖中邊上權值的數據類型
typedef struct {
    VertexType Vex[MaxVertexNum];   //頂點表
    EdgeType Edge[MaxVertexNum][MaxVertexNum];  //鄰接矩陣,邊表
    int vexnum, arcnum;     //圖的當前頂點數和弧數
}MGraph;    //Matrix Graph(矩陣圖)
  • 特點:行列出入(度)
    1. 無向圖的鄰接矩陣一定是一個對稱矩陣(並且唯一)。因此,在實際存儲鄰接矩陣時只需存儲上(或下)三角矩陣的元素。
    2. 對於無向圖,鄰接矩陣的第i行(或第i列)非零元素(或非∞元素)的個數正好是第i個頂點的\(TD(v_i)\)
    3. 對於有向圖,鄰接矩陣的第i行(或第i列)非零元素(或非∞元素)的個數正好是第i個頂點的出度\(OD(v_i)\)(或入度\(ID(v_i)\))。
    4. 設圖G的鄰接矩陣為A\(A^n\)的元素\(A^n[i][j]\)等於由頂點i頂點j長度為n的路徑數目
    5. 空間復雜度為O(\(n^2\)),其中n為圖的頂點數\(|V|\)
    6. 稠密圖適合使用鄰接矩陣的存儲表示。
  • 優點:
    1. 方便檢查任意一對頂點間是否存在邊
    2. 方便找任意頂點的所有“鄰接點”(有邊直接相連的頂點)
    3. 方便計算任意頂點的“度”(從該點出發的邊數為“出度”,指向該點的邊數為“入度”)
      • 無向圖:對應(或)非零元素的個數;
      • 有向圖:對應非零元素的個數是“出度”,對應非零元素的個數是“出度”
  • 缺點:
    確定圖中有多少邊,則必須按行、按列隊每個元素進行檢測,所花費的時間代價很大。這是用鄰接矩陣存儲圖的局限性。

鄰接表法

G[N]為指針數組,對應矩陣每行一個鏈表,只存非零元素。

  • 基本概念:
    所謂鄰接表,是指對圖G中的每個頂點\(v_i\)建立一個單鏈表,第i個單鏈表中的結點表示依附於頂點\(v_i\)(對於有向圖則是以頂點\(v_i\)為尾的弧),這個單鏈表就稱為頂點\(v_i\)邊表(對於有向圖則稱為出邊表)。邊表的頭指針頂點的數據信息采用順序存儲(稱為頂點表),所以在鄰接表中存在兩種結點:頂點表結點邊表結點
    • 頂點表結點:由頂點域(data)和指向第一條鄰接邊的指針(firstarc)(弧(Arc))構成。
    • 邊表結點:由鄰接點域(adjvex)(Adjacency vertex)和指向下一條鄰接邊的指針域(nextarc)構成。
  • 存儲結構:(當然,序號也可以從1開始)
    • 頂點表:
    data firstarc
    頂點域 邊表頭指針
    頂點信息 指向第一條鄰接邊的指針
    • 邊表:
    adjvex nextarc
    鄰接點域 指針域
    該弧所指向的頂點的位置 指向下一條鄰接邊的指針
#define MaxVertexNum 100    //圖中頂點數目的最大值
typedef struct ArcNode {    //邊表結點
    int adjvex;             //該弧所指向的頂點的位置
    struct ArcNode *next;   //指向下一條弧的指針
    //InfoType info;        //網的邊權值
}ArcNode;
typedef struct VNode {      //頂點表結點
    VertexType data;        //頂點信息
    ArcNode *first;         //指向第一條依附該結點的弧的指針
}VNode, AdjList[MaxVertexNum];
typedef struct {
    AdjList vertices;       //鄰接表(頂點表)
    int vexnum, arcnum;     //圖的頂點數和弧數
}ALGraph;                   //Adjacency List Graph是以鄰接表存儲的圖的類型
  • 特點:
    1. 若G為無向圖,則所需的存儲空間為O(|V|+2|E|);若G為有向圖,則所需的存儲空間為O(|V|+|E|)。前者的倍數2是由於無向圖中,每條邊在鄰接表中出現了兩次。
    2. 對於稀疏圖,采用鄰接表表示將極大地節省存儲空間。(稠密圖不一定,因為每個結點都不止只有一個data域,還存儲了下一指針)
    3. 圖的鄰接表表示並不唯一,因為在每個頂點對應的單鏈表中,各邊結點鏈接次序可以是任意的,它取決於建立鄰接表的算法邊的輸入次序
  • 優點:
    1. 方便找任一頂點的所有“鄰接點”
    2. 節約稀疏圖的空間
      • 需要N個頭指針 + 2E個結點(每個結點至少2個域)
    3. 計算任一頂點的“度”
      • 無向圖來說,很方便
      • 有向圖來說,只能計算“出度”;需要構造“逆鄰接表”(存指向自己的邊)(即 存矩陣的每一列)來方便計算“入度”
    4. 給定一頂點,很容易找出它的所有鄰邊
  • 缺點:
    若要確定給定的兩個頂點間是否存在邊,則需要在相應結點對應的邊表中查找另一結點,效率低。

十字鏈表

鄰接表逆鄰接表合並而成的鏈接表。

  • 基本概念:
    十字鏈表是有向圖的一種鏈式存儲結構。在十字鏈表中,對應於有向圖中的每條有一個結點,對應於每個頂點也有一個結點

  • 存儲結構:
    • 弧結點:
    tailvex headvex hlink tlink info
    尾域 頭域 頭鏈域 尾鏈域 相關信息
    指向弧尾頂點在圖中的位置 指向弧頭頂點在圖中的位置 指向弧頭相同的下一條弧 指向弧尾相同的下一條弧 指向該弧的相關信息
    • 頂點結點:
    data firstin firstout
    存放頂點相關的數據信息 指向以該頂點為弧頭的第一個弧結點 指向以該頂點為弧尾的第一個弧結點

注意,頂點結點之間順序存儲的。

  • 特點:
    圖的十字鏈表表示是不唯一的,但一個十字鏈表表示確定一個圖。

  • 優點:
    1. 既容易找到vi為尾的弧,又容易找到vi為頭的弧
    2. 容易求得頂點的出度和入度

鄰接多重表

  • 基本概念:
    鄰接多重表是無向圖的另一種鏈式存儲結構
    每條邊用一個結點表示,每個頂點也用一個結點表示。
  • 存儲結構:
    • 邊結點:
    mark ivex ilink jvex jlink info
    標志域,可用於標記該邊是否被搜索過 指向該邊依附的其中一個頂點位置 指向下一條依附於頂點ivex的邊 指向該邊依附的另一個頂點位置 指向下一條依附於頂點jvex的邊 指向和邊相關的各種信息的指針域
    • 頂點結點:
    data firstedge
    存儲該頂點的相關信息 指向第一條依附於該頂點的邊

PS:由於十字鏈表與鄰接多重表比較少見,所以詳情請各位讀者自行了解。

轉換算法

鄰接表轉換為鄰接矩陣

  • 算法思想:
    設圖的頂點分別設在V[n]數組中。首先初始化鄰接矩陣。遍歷鄰接表,在依次遍歷頂點V[i]的邊鏈表,修改鄰接矩陣的第i行的元素值。若鏈表邊結點的值為j,則置arcs[i][j]=1。遍歷完整個鄰接表時,整個轉換過程結束。此算法對無向圖,有向圖均適用。
void Convert(ALGraph &G, int arcs[M][N]){
//此算法是將鄰接表方式表示的圖G轉換為鄰接矩陣arcs
    for(int i=0; i<n; i++){         //依次遍歷各頂點表結點為頭的邊鏈表
        p=(G->v[i].firstarc);       //取出頂點i的第一條出邊
        while(p!=null){             //遍歷邊鏈表
            arcs[i][p->data]=1;
            p=p->nextarc;           //取下一條出邊
        }
    }
}

鄰接矩陣轉換為鄰接表

void MatToList(AdjMatrix &A, AdjList &B) {
    B.vertexNum = A.vertexNum;
    B.arcNum = A.arcNum;
    for(i=0; i<A.vertexNum; i++) {
        B.adjlist[i].firstedge = NULL;
    }
    for(i=0; i<A.vertexNum; i++) {
        for(j=0; j<i; j++) {
            if(A.arc[i][j] != 0) {
                p = new ArcNode;
                p->adjvex = j;
                p->next = B.adjlist[i].firstedge;
                B.adjlist[i].firstedge = p;
            }
        }
    }
}

圖的遍歷

圖的遍歷主要有兩種算法:廣度優先搜索和深度優先搜索,都可以抽象為優先級搜索或最佳優先搜索。
廣度優先搜索會優先考慮最早被發現的結點,也就是說離起點越近的結點其優先級越高。
深度優先搜索會優先考慮最后被發現的結點。

廣度優先算法由Edward F. Moore在向右迷宮路徑問題時發現;
深度優先搜索在20世紀50年代晚期獲得廣泛使用,尤其在人工智能方面。

畫深度優先或廣度優先生成樹的圖時,需要注意,當存儲結構固定時,生成樹的樹形也就固定了。要按存儲結構排列的先后順序先后訪問。

廣度優先搜索(BFS)

  • 基本概念:
    廣度優先搜索(Breadth-First-Search, BFS)類似於二叉樹的層次遍歷算法

  • 操作過程:
    首先訪問起始頂點v,接着由v出發,依次訪問v的各個未訪問過的鄰接頂點\(w_1, w_2, ..., w_i\),然后依次訪問\(w_1, w_2, ..., w_i\)的所有未被訪問過的鄰接頂點;再從這些訪問過頂點出發,訪問它們所有未被訪問過的頂點……依次類推,直到圖中所有頂點都被訪問過為止。

Dijkstra單源最短路徑算法和Prim最小生成樹算法也應用了類似的思想。

廣度優先搜索是一種分層的查找過程,每向前走一步可能訪問一批頂點,不像深度優先搜索那樣有往回退的情況,因此它不是一個遞歸的算法。為了實現逐層的訪問,算法必須借助一個輔助隊列,以記憶正在訪問的頂點的下一層頂點。

  • 具體實現:
//廣度優先搜索(Breadth-First-Search,BFS)
bool visited[MAX_VERTEX_NUM);   //訪問標記數組

void BFS(Graph G,int v){        //從頂點v出發,廣度優先遍歷圖G,算法借助一個輔助隊列Q
    Enqueue(Q,v);               //頂點v入隊列
    visited[v]=TRUE;            //對v做(已入隊待訪問)標志
    while(!isEmpty(Q)){
        DeQueue(Q,v);           //頂點v出隊列
        visit(v);               //訪問頂點v
        for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w)) {    //檢測v所有鄰接點
            if(!visited[w]){    //w為v的尚未訪問的鄰接頂點
                visited[w]=TRUE;//對w做(已入隊待訪問)標記
                EnQueue(Q,w);   //頂點w入隊列
            }
        }
    }
}

void BFSTraverse(Graph G){      //對圖G進行廣度優先遍歷,設訪問函數為visit()
    for(i=0;i<G.vexnum;i++) {
        visited[i]=FALSE;       //訪問標志數組初始化
    }
    InitQueue(Q);               //初始化輔助隊列Q
    for(i=0;i<G.vexnum;i++) {   //從0號頂點開始遍歷
        if(!visited[i]) {       //對每個連通分量開始遍歷(以免不連通)
            BFS(G,i);           //v[i]未訪問過,從v[i]開始BFS
        }
    }
}
  • 注意:對於for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w)) { //檢測v所有鄰接點,不同存儲結構有不同的寫法。

鄰接表作存儲結構的BFS

void BFSTraverse(ALGraph &G) {
    int queue[maxsize];
    int front = -1, rear = -1
    int i=0;
    for(i=0; i<G.vexnum; i++) { //初始化訪問狀態
        visited[i] = 0;
    }
    
    //遍歷其所有鄰接點
    i = 0;  //將第一個結點入隊
    queue[++rear] = i;  //隊內結點都是要訪問的,所以一入隊就修改其訪問狀態
    visited[i] = 1;
    while(front < rear) {
        i = queue[++front];     //出隊
        visit(i);   //訪問
        
        //將其所有鄰接結點入隊(待訪問)
        ArcNode *p = G->vertices[i].firstarc;
        while(p) {
            if(!visited[p->adjvex]) {   //頂點i的鄰接點沒有被訪問過,則入隊(待訪問),設置訪問狀態
                visited[p->adjvex] = 1; //設置(已入隊待訪問)標記
                queue[++rear] = p->adjvex;  //入隊
            }
            p = p->next;    //訪問下一個鄰接點
        }
    }
}

鄰接矩陣作存儲結構的BFS

void BFSTraverse(int arcs[M][N]) {
    int queue[maxsize];
    int front = -1, rear = -1
    int i=0;
    for(i=0; i<n; i++) {    //初始化訪問狀態
        visited[i] = 0;
    }
    
    //遍歷其所有鄰接點
    i = 0;  //將第一個結點入隊
    queue[++rear] = i;  //隊內結點都是要訪問的,所以一入隊就修改其訪問狀態
    visited[i] = 1;
    while(front < rear) {
        i = queue[++front];     //出隊
        visit(i);   //訪問
        
        //將其所有鄰接結點入隊(待訪問)
        for(int j=0; j<n; j++) {
            if(arcs[i][j]!=0 && !visited[j]) {  //若i、j之間存在弧,且j未被訪問過(即i的鄰接點j)
                visited[j] = 1;
                queue[++rear] = j;
            }
        }
    }
}
  • 性能分析:
    • 空間效率:O(|V|)
      無論是鄰接表還是鄰接矩陣的存儲方式,BFS算法都需要借助一個輔助隊列Q,n個頂點均需入隊一次,在最壞的情況下,空間復雜度為O(|V|)

    • 時間效率:
      • 鄰接表O(|V|+|E|)(其實就相當於把鄰接表整個掃描了一遍
        采用鄰接表存儲方式時,每個頂點均需搜索一次(或入隊一次),故時間復雜度為O(|V|),在搜索任一頂點的鄰接點時,每條邊至少訪問一次,故時間復雜度為O(|E|),所以算法中的復雜度為O(|V|+|E|)
      • 鄰接矩陣\(O(|V|^2)\)(其實就相當於把鄰接矩陣整個掃描了一遍
        采用鄰接矩陣存儲方式時,查找每個頂點的鄰接點所需的時間為O(|V|),故算法總的時間復雜度為\(O(|V|^2)\)
    • 適用性:
      適合在不斷擴大遍歷范圍時找到相對最優解的情況。

  • 求解單源最短路徑問題:

    若圖為無權圖,利用BFS算法的特性:一層一層的遍歷,可以求出某一個頂點到其他頂點的最短路徑。(這是由廣度優先搜索總是按照距離由近到遠來遍歷圖中每個頂點的性質決定的)

//BFS算法求解單源最短路徑問題的算法:
void BFS_MIN_Distance(Graph G,int u){   //dist[i]表示從u到i結點的最短距離,path[w]代表w在路徑上的前一個頂點
    for(i=0;i<G.vexnum;i++)
        dist[i]=INF;        //初始化路徑長度為無窮(infinite)
    //visited[u]=TRUE;
    dist[u]=0;
    EnQueue(Q,u);
    while(!isEmpty(Q)){     //BFS算法主過程
        DeQueue(Q,u);       //隊頭元素u出隊
        for(w=FirstNeighbor(G,u);w>=0;w=NextNeighbor(G,u,w)) {  //對於u的每個鄰接頂點w
            if(dist[w] == -1){      //w為u的尚未訪問的鄰接結點
                dist[w]=dist[u] + 1;    //路徑長度加1
                path[w] = u;        //到w路徑上經過的最后一個頂點u(即w的前一個頂點u)
                EnQueue(Q,w);
            }
        }
    }

T = O(|V|+|E|)
利用path一次次的尋找前一個結點,就可找到路徑

  • 廣度優先生成樹:
    在廣度遍歷的過程中,我們可以得到一棵遍歷樹,稱為廣度優先生成樹。需要注意的是,一給定圖的鄰接矩陣存儲表示是唯一的,故其廣度優先生成樹也是唯一的,但由於鄰接表存儲表示不唯一,故其廣度優先生成樹也是不唯一的。

深度優先搜索(DFS)

  • 基本概念:
    與廣度優先搜索不同,深度優先搜索(Depth-First-Search, DFS)類似於樹的先序遍歷。正如它的名字,這種搜索算法所遵循的搜索策略是盡可能“深”地搜索一個圖。
  • 操作過程:
    首先訪問圖中某一起始頂點v,然后由v出發訪問與v鄰接且未被訪問的任一頂點\(w_1\),再訪問與\(w_1\)鄰接且未被訪問的任一頂點\(w_2\)……重復上述過程。當不能再繼續向下訪問時,依次退回到最近被訪問的結點,若它還有鄰接頂點未被訪問過,則從該點開始繼續上述搜索過程,知道圖中所有頂點均被訪問過為止。

  • 具體實現:

遞歸實現

//深度優先搜索(Depth-First-Search,DFS)
bool visited[MAX_VERTEX_NUM];   //訪問標記數組

void DFS(Graph G,int v){        //從頂點v出發,采用遞歸思想,深度優先遍歷圖G
    //遍歷訪問完所有結點就出去了,所以無需遞歸出口
    visit(v);                   //訪問頂點v
    visited[v]=TRUE;            //設已訪問標記
    for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w)) {
        if(!visited[w]){        //w為u的尚未訪問的鄰接頂點
            DFS(G,w);
        }
    }
}

void DFSTraverse(Graph G){      //對圖G進行嘗試優先遍歷,訪問函數為visit()
    for(v=0;v<G.vexnum;v++) {
        visited[v]=FALSE;       //訪問標志數組初始化
    }
    for(v=0;v<G.vexnum;v++) {   //本代碼中是從v=0開始遍歷
        if(!visited[v]) {
            DFS(G,v);
        }
    }
}

非遞歸實現

在深度優先搜索的非遞歸算法中使用了一個棧S來記憶下一步可能訪問的結點,同時使用了一個訪問標記數組visited[i]來記憶第i個結點是否在棧內或曾經在棧內,若是則它以后不能再進棧。

void DFS_Non_RC(ALGraph &G, int v) {
//從頂點v開始進行深度優先搜索,一次遍歷一個連通分量的所有頂點
    int w;      //頂點序號
    InitStack(S);   //初始化棧S
    for(i=0; i<G.vexnum; i++) {
        visited[i] = FALSE; //初始化visited
    }
    Push(S, v);     //v入棧並置visited[v]
    visited[v] = TRUE;
    while(!IsEmpty(S)) {
        k = Pop(S); //棧中退出一個頂點
        visit(k);   //先訪問,再將其子結點入棧
        for(w=FirstNeighbor(G,k);w>=0;w=NextNeighbor(G,k,w)) {  //k所有鄰接點
            if(!visited[w]) {   //未進過棧的頂點進棧
                Push(S, w);
                visited[w] = TRUE;  //做標記,以免再次入棧
            }
        }
    }
}

注意:由於使用了棧,使得遍歷方式從右端到左端進行,不同於常規的從左端到右端,但仍然是深度優先遍歷。

  • 注意:對於for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w)) { //檢測v所有鄰接點,不同存儲結構有不同的寫法。

鄰接表作存儲結構的DFS

void DFSTraverse(ALGraph &G, int v) {
    visit(v);       //訪問頂點v
    visited[v] = 1; //設已訪問標記
    
    //遍歷其任一鄰接點
    ArcNode *p = G->vertices[v].firstarc;
    while(p) {
        if(!visited[p->adjvex]) {   //若頂點i的鄰接點沒有被訪問過,則DFS
            DFSTraverse(G, p->adjvex);  //遍歷其鄰接點
        }
        p = p->next;    //訪問下一個鄰接點
    }
}

鄰接矩陣作存儲結構的DFS

void DFSTraverse(int arcs[M][N], int v) {
    visit(v);       //訪問頂點v
    visited[v] = 1; //設已訪問標記
    
    //遍歷其任一鄰接點
    for(int w=0; w<n; w++) {
        if(arcs[v][w]!=0 && !visited[w]) {  //若v、w之間存在弧,且w未被訪問過(即v的鄰接點w)
            DFSTraverse(arcs[M][N], w);
        }
    }
}
  • 性能分析:
    • 空間效率:O(|V|)
      DFS算法是一個遞歸算法,需要借助一個遞歸工作棧,故其空間復雜度為O(|V|)

    • 時間效率:
      • 鄰接表O(|V|+|E|)(其實就相當於把鄰接表整個掃描了一遍
        采用鄰接表存儲方式時,查找所有頂點的鄰接點所需時間為O(|E|),訪問頂點所需的時間為O(|V|),此時總的時間復雜度為O(|V|+|E|)
      • 鄰接矩陣\(O(|V|^2)\)(其實就相當於把鄰接矩陣整個掃描了一遍
        采用鄰接矩陣存儲方式時,查找每個頂點的鄰接點所需的時間為O(|V|),故算法總的時間復雜度為\(O(|V|^2)\)
    • 適用性:
      適合目標比較明確,以找到目標為目的的情況。

  • 深度優先的生成樹和生成森林
    與廣度優先搜索一樣,深度優先搜索也會產生一棵深度優先生成樹。當然,這是有條件的,即對連通圖調用DFS才能產生深度優先生成樹,否則產生的將是深度優先生成森林。與BFS類似,其基於鄰接表存儲的深度優先生成樹也是不唯一的。

圖的連通性

圖的遍歷算法可以用來判斷圖的連通性。

  • 無向圖:
    • 連通:
      從任一結點出發,僅需一次遍歷就能夠訪問圖中的所有頂點。
    • 非連通:
      從某個頂點出發,一次遍歷只能訪問到該頂點所在連通分量的所有頂點,而對於圖中其他連通分量的頂點,則無法通過這次遍歷訪問。

      上述兩個函數調用BFS(G,i)或DFS(G,i)的次數等於該圖的連通分量數

for(v=0;v<G.vexnum;v++) {
    if(visited[v]!=TRUE) {  //如果一次遍歷未能訪問所有結點,則該圖不連通
        return false;   //圖不連通
    }
}
  • 有向圖:
    若從初始點到圖中的每個頂點都有路徑,則能夠訪問到圖中的所有頂點,否則不能訪問到所有頂點。

    而有向圖的調用次數則不等於連通分量數,因為一個連通的有向圖分為強連通非強連通,它的連通子圖也分為強連通分量非強連通分量,非強連通分量一次調用BFS或DFS無法訪問到該非強連通分量的所有頂點(但是圖卻是連通的)。

    • 強連通分量數:
      當某個頂點只有出弧而沒有入弧時,其他頂點無法到達這個頂點,不可能與其他頂點和邊構成強連通分量(這個單獨的頂點單獨構成一個強連通分量)(其實這個方法有點像拓撲排序
      • 頂點1無入弧構成一個強連通分量。刪除頂點1及所有以之為尾的弧。。。

注意:故在BFSTraverser()或DFSTraverse()中添加了第二個for循環,再選取初始點,繼續進行遍歷,以防止一次無法遍歷圖的所有頂點(即 非連通圖)。

圖的應用

最小生成樹(MST)

僅針對無向圖

一個連通圖的生成樹是圖的最小連通子圖,它包含圖中的所有頂點,並且只含盡可能少的邊。則意味着對於生成樹來說,若砍去它的一條邊,則會使生成樹變成非連通圖;若給它增加一條邊,則會形成圖中的一條回路。

邊的權值之和最小的那顆生成樹,則稱為最小生成樹(Minimum-Spanning-Tree, MST)。

  • 性質
    1. 是一棵樹
      • 無回路
      • |V|個頂點一定有|V|-1條邊
    2. 是生成樹
      • 包含全部頂點
      • |V|-1條邊都在
    3. 邊的權重和最小
  • 特點:
    1. 最小生成樹不是唯一的,即最小生成樹的樹形不唯一,圖中可能有多個最小生成樹。
    2. 當圖中的各邊權值互不相等時,圖的最小生成樹是唯一的。
    3. 當帶權連通圖的任意一個環中所包含的邊的權值均不相同時,其最小生成樹是唯一的。
    4. 若圖本身是一棵樹時,則圖的最小生成樹就是它本身。
    5. 最小生成樹的邊的權值之和總是唯一的。
    6. 最小生成樹的邊數為頂點數-1

下列算法都是基於貪心算法。

Prim(普里姆)算法

此算法可以稱為”加點法“

其實本質上來說,是“讓一棵小樹長大”

記憶:讓小樹破(P)土而出,長大,加點

  • 基本思想:
    偽最小生成樹(未完成)的所有頂點與中其余頂點相連接的邊中,找出距離生成樹權值最小的邊,加入,重復操作,構成最小生成樹。

  • 實現步驟:
    • 初始化:向空樹\(T=(V_T,E_T)\)中添加圖\(G=(V,E)\)的任一頂點\(u_0\),使\(V_T={u_0}\)\(E_T=∅\)
    • 循環(重復下列操作至\(V_T=V\)):從圖G中選擇滿足\({(u,v)|u∈V_T,v∈V-V_T}\)且具有最小權值的邊\((u,v)\),並置\(V_T=V_T∪{v}\)\(E_T=E_T∪{(u,v)}\)
  • 通俗說明:
    此算法可以稱為”加點法“,每次迭代選擇代價最小的邊對應的點,加入到最小生成樹中。
    算法從某一頂點s開始,逐漸長達覆蓋整個連通網的所有頂點。
    1. 圖的所有頂點集合為V;初始令集合u={s},v=V−u;
    2. 在兩個集合u,v能過夠組成的邊中,選擇一條代價最小的邊\((u_0,v_0)\),加入到最小生成樹中,並把v0並入到集合u中。
    3. 重復上述步驟,直到最小生成樹有n-1條邊或者n個頂點為止。

  • 簡單實現:
void Prim(G, T) {   //圖G = (V,E) 樹T = (T,U)
    //初始化樹
    T = ∅;      //存放樹的邊
    U = {w};    //存放樹的頂點,添加任一頂點w
    while((V-U) != ∅) {     //若樹中不含全部頂點
        設(u, v)是使u∈U與v∈(V-U),且權值最小的邊;   //即u為樹中頂點,v為圖中頂點,(u,v)為未完成樹與圖相連接的一條權值最小的邊
        T = T∪{(u, v)}; //邊歸入樹
        U = U∪{v};          //頂點歸入樹
    }
}
  • 性能分析:
    • 時間效率:O(\(|V^2|\))
      Prim算法時間復雜度為O(\(|V^2|\)),不依賴於|E|。

    • 適用性:
      適用於求解邊稠密的圖的最小生成樹。

Kruskal(克魯斯卡爾)算法

此算法可以稱為“加邊法”

其實本質上來說,是“將森林合並成樹”

記憶:把森林砍(K)成一顆樹,合並成樹,加邊

  • 基本思想:
    在整個中找權值最小的邊,不構成回路的情況下加入(連接兩棵不同的樹,合並為一棵),重復操作,拼接成最小生成樹。

  • 實現步驟:
    • 初始化:\(V_T=V\)\(E_T=∅\)。即每個頂點構成一棵獨立的樹,T此時是一個僅含|V|個頂點的森林。
    • 循環(重復下列操作至T是一棵樹):按G的邊的權值遞增排序依次從\(E-E_T\)中選擇一條邊,若這條邊加入T后不構成回路,則將其加入\(E_T\),否則舍棄,直到\(E_T\)中含有n-1條邊。
  • 通俗說明:
    此算法可以稱為“加邊法”,初始最小生成樹邊數為0,每迭代一次就選擇一條滿足條件的最小代價邊,加入到最小生成樹的邊集合里。
    1. 把圖中的所有邊按代價從小到大排序;
    2. 把圖中的n個頂點看成獨立的n棵樹組成的森林;
    3. 按權值從小到大選擇邊,所選的邊連接的兩個頂點\(u_i\),\(v_i\)\(u_i\),\(v_i\),應屬於兩顆不同的樹,則成為最小生成樹的一條邊,並將這兩顆樹合並作為一顆樹。
    4. 重復(3),直到所有頂點都在一顆樹內或者有n-1條邊為止。

  • 簡單實現:
void Kruskal(V,T) {
    T = V;      //初始化樹T,僅含頂點
    numS = n;   //連通分量數
    while(numS > 1) {   //若連通分量數大於1
        從E中取出權值最小的邊(v,u);       //利用最小堆
        if(v和u屬於T中不同的連通分量) {        //即 不構成回路   (利用並查集)
            T = T∪{(v,u)};      //將此邊加入生成樹中
            numS--;     //連通分量數減1
        }
    }
}
  • 性能分析:
    • 時間效率:O(\(|E|log|E|\))
      通常在Kruskal算法中,采用來存放邊的集合,因此每次選擇最小權值的邊只需O(log|E|)的時間。此外,由於生成樹T中的所有邊可視為一個等價類,因此每次添加新的邊的過程類似於求解等價類的過程,由此可以采用並查集的數據結構來描述T,從而構造T的時間復雜度為O(|E|log|E|)

    • 適用性:
      適用於邊稀疏頂點較多的圖。

最短路徑

針對有向圖無向圖

帶權圖中,從一個頂點到其余任意一個頂點的一條路徑的帶權路徑長度最短的那條路徑稱為最短路徑
帶權有向圖G的最短路徑問題一般可分為兩類:一是單源最短路徑,即求圖中某一固定源點出發到其他各頂點的最短路徑,可通過經典的Dijkstra算法求解;二是求多源最短路徑,即求圖中任意兩頂點間的最短路徑,可通過Floyd算法來求解。

Dijkstra(迪傑斯特拉)算法

該算法要求圖中不存在負權邊

  • 基本思想:
    偽最短路徑(未完成)的所有頂點與中其余頂點相連接的邊,並取出距離源點\(v_0\)權值最小的邊,加入最短路徑,重復操作,構成最短路徑。

可以看出Dijkstra算法與Prim算法極為相似,不過兩者的不同之處在於對“權值最低”的定義不同,
Prim的“權值最低”是相對於U中的任意一點而言的,也就是把U中的點看成一個整體,每次尋找V-U中跟U的距離最小(也就是跟U中任意一點的距離最小)的一點加入U;
Dijkstra的“權值最低”是相對於\(v_0\)而言的,也就是每次尋找V-U中跟\(v_0\)的距離最小的一點加入U。

  • 算法特性:
    • 每加入一個頂點,都保證了此頂點dist值是該路徑中的最小長度(但可能不是最終整個圖的最短路徑長度),直到頂點擴充到擁有最短路徑中的所有頂點,那么dist值才是最終的最短路徑長度。

    • 加入一個頂點v,影響的是它自己一圈鄰接點的dist值

  • 求解過程:

    頂點 第1輪 第2輪 第3輪 第4輪
    2 10
    v1->v2
    8
    v1->v5->v2
    8
    v1->v5->v2
    3 14
    v1->v5->v3
    13
    v1->v5->v4->v3
    9
    v1->v5->v2->v3
    4 7
    v1->v5->v4
    5 5
    v1->v5
    集合S {1,5} {1,5,4} {1,5,4,2} {1,5,4,2,3}
  • 簡單實現:
void Dijkstra(Graph G) {
    while(1) {
        V = 未收錄頂點中dist最小者;
        if(這樣的V不存在) {
            break;
        }
        collected[V] = true;
        for(V的每個鄰接點W) {
            if(collected[W] == false) {
                if(dist[V] + E<V,W> < dist[W]) {        //E<V,W>為v到w那條弧的權值
                    dist[W] = dist[V] + E<V,W>;
                    path[w] = V;    //path[w]代表w在路徑上的前一個頂點,利用path一次次的尋找前一個結點,就可找到路徑。
                }
            }
        }
    }
}
  • 性能分析:
    • 時間效率:
      • 鄰接矩陣\(O(|V|^2)\)
        采用鄰接矩陣存儲方式時,查找每個頂點的鄰接點所需的時間為O(|V|),故算法總的時間復雜度為\(O(|V|^2)\)
      • 帶權的鄰接表\(O(|V|^2)\)
        雖然修改dist[]的時間可以減少,但由於在dist[]中選擇最小分量的時間不變選V輪最小,每次比較V個),故時間復雜度仍為\(O(|V|^2)\)
    • 適用性:
      適用於不存在負權邊的圖。
      適用於稀疏圖

Floyd-Warshall(弗洛伊德)算法

又稱為“插點法”,是一種利用動態規划的思想尋找給定的加權圖中多源點之間最短路徑的算法

該算法允許存在負權邊,但不可存在負權回路(即 環上所有權值之和是負數)(因為負權回路不存在最短路徑)(轉一圈就比原來小,一直轉一直爽)。

  • 基本思想:
    初始化,以后逐步嘗試在原路徑上加入頂點k(k=0,1,...,n-1)作為中間頂點;若增加中間頂點后,得到的路徑比原來的路徑長度減少了,則以此新路徑代替原路徑。

  • 實現步驟:

    \(A^{(k)}[i][j]\)表示從頂點\(v_i\)到頂點\(v_j\)的路徑長度,k表示繞行k個頂點的運算步驟。
    \(A^{(k)}[i][j]\) = 路徑{i→{l≤k}→j}的最小長度,即 從頂點\(v_i\)\(v_j\)中間頂點序號不大於k的最短路徑長度。

    1. 定義一個n階方陣序列\(A^{(-1)}\),\(A^{(0)}\),...,\(A^{(n-1)}\)
    2. 起始\(A^{(-1)}[i][j]\) = arcs[i][j]; //arcs表示弧的權值
    3. \(A^{(k)}[i][j]\) = Min{\(A^{(k-1)}[i][j]\), \(A^{(k-1)}[i][k]\) + \(A^{(k-1)}[k][j]\)}, k=0,1,...,n-1 (即最短路徑取 加入中間頂點k 或 不加入k 的最小長度)
    4. Floyd算法是一個迭代的過程,每迭代一次,在從頂點\(v_i\)\(v_j\)的最短路徑上就多考慮了一個頂點(考慮但不一定會取它),經過n次迭代后,所得到的\(A^{(n-1)}[i][j]\)(即 已經考慮了其他n-1個頂點后)就是從頂點\(v_i\)\(v_j\)最短路徑長度,即方陣\(A^{(n-1)}\)中就保存了任意一對頂點之間的最短路徑長度。
  • 矩陣含義:
    • Dist矩陣:保存圖中由頂點i到頂點j的當前最短距離
    • Path矩陣:保存圖中由頂點i到頂點j的當前最短路徑上頂點j的前驅(這樣依次向前找能找到整條最短路徑)
  • 矩陣的計算:

    本文應用了十字交叉法,三條線:①主對角線每次沿着主對角線元素畫十字(主對角線上都是結點到本身的距離0,每畫一個十字都代表加入了該中間結點)。
    每加入一個中間結點i,按該點行、列找到元素位置,畫十字,十字(與主對角線)上的元素不會改變(因為十字上的節點與該節點相鄰,距離不會發生改變),故只用判斷十字(與主對角線)之外的元素是否發生改變,大大減少了判斷量。

    • 給出矩陣,其中矩陣A是鄰接矩陣,而矩陣Path記錄u,v兩點之間最短路徑所必須經過的點

    • 相應計算方法如下:


    • 最后A3即為所求結果

  • 簡單實現:
void Floyd() {
    for(i=0; i<N; i++) {    //初始化
        for(j=0; j<N; j++) {
            A[i][j] = G[i][j];
            path[i][j] = -1
        }
    }
    //算法
    for(k=0; k<N; k++) {    //依次加入n個中間頂點
        for(i=0; i<N; i++) {    //從每個頂點開始
            for(j=0; j<N; j++) {    //到每個頂點結束(考慮了有向圖)
                if(A[i][k] + A[k][j] < D[i][j]) {   //加入了中間頂點k,權值更小
                    A[i][j] = A[i][k] + A[k][j];
                    path[i][j] = k;     //path[i][j]代表頂點i到頂點j經過了path[i][j]記錄值所表示的頂點,利用path一次次的尋找前一個結點,就可找到路徑。
                }
            }
        }
    }
}
  • 性能分析:
    • 時間效率:\(O(|V|^3)\)

    • 適用性:
      適用於允許存在負權邊,但不可存在負權回路(即 環上所有權值之和負數)的圖。
      適用於稠密圖

雖然Dijkstra算法解決多源最短路徑問題的時間復雜度也為O(|V|^2)*|V|=O(|V|^3)(適用於稀疏圖),但是Floyd算法(適用於稠密圖)的代碼更加緊湊,且並不包含其他復雜的數據結構,因此隱含的常數稀疏更小,更適用。

拓撲排序

圖→線性排序二維→一維
拓撲排序相當於是工程的安排順序,而回路的存在相當於死鎖,所以拓撲排序不存在回路

有向無環圖(DAG圖):若一個有向圖中不存在環,則稱為有向無環圖,簡稱DAG圖(Directed Acyclic Graph)。

AOV網:若用DAG圖表示一個工程,其頂點表示活動,用有向邊表示活動之間的先后關系,將這種有向圖稱為頂點表示活動的網絡,記為AOV網(Activity On Vertex)。

  • 基本概念:
    拓撲排序是對有向無環圖頂點線性方式進行的一種排序,它使得若存在一條從頂點A到頂點B的路徑,則在排序中頂點B出現在頂點A的后面。每個DAG圖都有一個或多個拓撲排序序列。
    如選課,每個課程都有其先行課,需要先學習。

  • 實現步驟:
    1. 從DAG圖中選擇一個沒有前驅(即 入度為0 沒有指向它的箭頭)的頂點並輸出
    2. 從圖中刪除該頂點和所有以它為起點的有向邊
    3. 重復(1)和(2)直到當前的DAG圖為空或當前圖中不存在無前驅的頂點為止。后一種情況說明有向圖中必然存在環
  • 簡單實現:
void TopSort() {
    for(cnt=0; cnt<|V|; cnt++) {    //計數
        V = 未輸出的入度為0的頂點;
        if(這樣的V不存在) {
            Error("圖中有回路");
            break;
        }
        輸出V,或者記錄V的輸出序號;
        for(V的每個鄰接點W) {
            Indegree[W]--;  //並不是真的刪除,只是入度-1
        }
    }
}

時間復雜度:T=O(\(|V|^2\))

  • 聰明的算法:
    隨時將入度變為0的頂點放到一個容器里(隨便什么容器)
void TopSort() {
    for(圖中每個頂點) {
        if(Indegree[V] == 0) {
            EnQueue(Q, V);
        }
    }
    while(!IsEmpty(Q)) {
        DeQueue{Q, V};
        輸出V,或者記錄V的輸出序號; cnt++;  //cnt用於記錄輸出的結點數
        for(V的每個鄰接點W) {
            if(--Indegree[W] == 0) {    //入度-1
                EnQueue(W, Q);
            }
        }
    }
    if(cnt != |V|) {    //若還有結點沒被輸出,那么肯定有回路
        Error("圖中有回路");
    }
}

時間復雜度:T=O(|V|+|E|)

關鍵路徑

路徑長度最大

其實該題型一般不會太復雜,列出所有路徑並寫出長度,比較一下即可。

AOE網:在帶權有向圖中,以頂點表示事件,以有向邊表示活動(即 表示活動頂點表示活動的開始或結束)(即 只有在頂點所代表的事件發生后,邊代表的活動才能開始),以邊上的權值表示完成該活動的開銷(如完成活動所需的時間),則稱這種有向圖為邊表示活動的網絡,簡稱為AOV網(Activity On Edge)。(一般用於安排項目的工序)
注意:AOE網僅有一個開始和結束。

  • 在AOE網中僅有一個入度為0的頂點,稱為開始頂點(源點),它表示整個工程的開始;
  • 網中也僅存在一個出度為0的頂點,稱為結束頂點(匯點),它表示整個工程的結束。
  • 繪制AOE網時,需要注意,活動從同一頂點開始,從另外的同一頂點結束。(當某一活動不作為其他活動的先驅,則其到達了終點,終點記得匯於一點

從源點(開始頂點)到匯點(結束頂點)的所有路徑中,具有最大路徑長度的路徑稱為關鍵路徑(由絕對不允許延誤的活動組成的路徑),而把關鍵路徑上的活動稱為關鍵活動

  • AOV AOE兩者關系

    其實AOV網與工作流網(AOE)在模型結構上其實是很相似的,它們都是以節點表示活動,有向邊表示流程的流向,所不同的是AOV網的有向邊僅僅只表示活動的前后次序,也可以說是流程中的流程流向,而工作流網中的有向邊卻不僅如此,它還可以在每條邊上設置不同的條件來決定活動的下一環節是什么,它的出度就不一定是所有有向邊了。因此,AOV網其實是工作流網(AOE網)的一種特例,是一種全入全出的有向無環工作流網。

  • 參量定義:(最早前進,最遲后退)(當路徑相交時,要判斷取最大值還是最小值。前進取最大,后退取最小。)
    1. 事件(頂點)\(v_k\)的最早發生時間\(v_e(k)\)(即 前一個活動的最早完成時間)
      \(v_e(源點) = 0\);
      \(v_e(k)\) = MAX{\(v_e(j) + Weight(v_j,v_k)\)}\(Weight(v_j,v_k)\)表示<\(v_j,v_k\)>上的權值

      注意:從前往后的順序計算
      最早發生要滿足最大值,當前繼結點均結束后,才能執行后序結點。

    2. 事件\(v_k\)的最遲發生時間\(v_l(k)\)(即 前一個活動的最晚完成時間)
      \(v_l(匯點) = v_e(匯點)\);
      \(v_l(j)\) = MIN{\(v_l(k) - Weight(v_j,v_k)\)}\(Weight(v_j,v_k)\)表示<\(v_j,v_k\)>上的權值

      注意:從后往前的順序計算
      最遲發生要滿足最小值,只有取最小時,當前的進度才能按期完成。
      因為是從后往前在已知完成時間的情況下,求最遲,所以要取離完成最短的路徑(即 最小值)。

    3. 活動(弧)\(a_i\)的最早開始時間e(i)
      該時間是指該活動的起點所表示的事件最早發生的時間。
      若邊<\(v_k,v_j\)>表示活動\(a_i\),則有
      \(e(i) = v_e(k)\)

    4. 活動\(a_i\)的最遲開始時間l(i)
      該時間是指該活動的終點所表示事件的最遲發生時間該活動所需時間之差。
      若邊<vk,vj>表示活動ai,則有
      \(l(i) = v_l(j) - Weight(v_k,v_j)\)

    5. 一個活動ai的最遲開始時間l(i)和其最早開始時間e(i)的差額(機動時間,時間余量)
      它是指該活動完成的時間余量,即在不增加完成整個工程所需總時間的情況下,活動\(a_i\)可以拖延的時間。
      d(i) = l(i) - e(i)
      若一個活動的時間余量為0,則說明該活動必須要如期完成,否則就會拖延完成整個工程的進度,所以稱l(i) - e(i) = 0即l(i) = e(i)的活動\(a_i\)關鍵活動

  • 簡化參數:最早前進,最遲后退)(當路徑相交時,要判斷取最大值還是最小值。前進取最大,后退取最小。)

    1. 活動(邊)最早完成時間Earliest
      Earliest[0] = 0;
      Earliest[j] = max{Earliest[i] + \(C_{<i,j>}\)};

      注意:從前往后的順序計算

    2. 活動最晚完成時間Latest
      Latest[匯點] = Earliest[匯點];
      Latest[i] = min{Latest[j] - \(C_{<i,j>}\)};

      注意:從后往前的順序計算

    3. 活動的機動時間\(D_{<i,j>}\)
      \(D_{<i,j>}\) = Latest[j] - Earliest[i] - \(C_{<i,j>}\);

    • 設各事件的最早發生時間v_e和最遲發生時間v_l:
    \(v_1\) \(v_2\) \(v_3\) \(v_4\) \(v_5\) \(v_6\) 備注
    \(v_e(i)\) 0 3 2 6 6 8 從前往后(0→)
    \(v_l(i)\) 0 4 2 6 7 8 從后往前(8→)

    此題關鍵路徑為v1→v3→v4→v6,關鍵活動為B、E、G

    注意:\(v_e\)=\(v_l\)時,必須如期完成,即 為關鍵路徑

判斷回路的存在

  • 判斷無向圖是否存在回路的方法:
    1. 深度優先搜索DFS:若在搜索過程中兩次遍歷到同一結點,那么存在環。(當考察的點的下一個鄰接點w是已經被遍歷的點,並且不是自己之前的父親節點father[v](即 不是自己“原路返回”的結點,v不來源於w,w不是v的父親)的時候,我們就找到了一條逆向邊,因此可以判斷該無向圖中存在環路。)
    2. 在圖的鄰接表表示中,首先統計每個頂點的度,然后重復尋找一個度為1的頂點,將度為1和0的頂點從圖中刪除,並將與該頂點相關聯的頂點的度減1,然后繼續尋找度為1的頂點,在尋找過程中若出現若干頂點的度都為2,則這些頂點組成了一個回路;否則,圖中不存在回路。
    3. 廣度優先搜索BFS:在遍歷過程中,為每個頂點標記一個深度deep,如果存在某個結點為v,除了其父節點u外,還存在與v相鄰的結點w(即 在廣度優先生成樹中加入了一條邊,vw相鄰,所以肯定存在回路)使得deep[v]<=deep[w]的,那么該圖一定存在回路。
    4. 用BFS或DFS遍歷,判斷對於每一個連通分量當中,如果邊數m>=結點個數n,那么該圖一定存在回路。
  • 判斷有向圖是否存在回路的方法:
    1. 深度優先搜索:若在搜索過程中某一頂點出現兩次,則有回路出現。(當考察的點的下一個鄰接點是已經被遍歷的點,即存在回路)(與無向圖不同
    2. 拓撲排序:即重復尋找一個入隊為0的頂點,將該頂點從圖中刪除,並將該頂點及其所有的出邊從圖中刪除(即 與該點相應的頂點的入度減1),最終若途中全為入度為1的點,則這些點至少組成一個回路。

      有向圖中廣度優先搜索不能判斷回路,是因為:它是沿着層向下搜索的,無法判斷當前層是否有同時針的連接,所以無法判斷回路(要路徑上第一個結點和最后一個結點相同,即同時針(順時針或者逆時針))。
      不是環:這個情況實際上並不是一個環,它僅僅是訪問到了一個前面訪問過的節點。遍歷時無法與真正是環的區分開

      這樣才是環


DFS判斷無向圖回路

  • 算法思想:
    利用深度遍歷,若遍歷時兩次碰到同一結點,那么存在環。(當考察的點的下一個鄰接點w是已經被遍歷的點,並且不是自己之前的父親節點father[v]的時候(即 不是自己“原路返回”的結點,v不來源於w,w不是v的父親,即 繞了一圈回來了),我們就找到了一條逆向邊,因此可以判斷該無向圖中存在環路。)(也就是說 只允許這個圖一直向下訪問(子結點),不能向上訪問(它的父結點),否則逆向邊,有環
//深度優先搜索(Depth-First-Search,DFS)
bool visited[MAX_VERTEX_NUM];   //訪問標記數組
int father[MAX_VERTEX_NUM];     //標記下標對應結點的父親結點(即 該下標結點來源於哪個結點)

bool DFS(Graph G,int v){        //從頂點v出發,采用遞歸思想,深度優先遍歷圖G
//  for(v=0;v<G.vexnum;v++)
//      visited[v]=FALSE;       //訪問標志數組初始化
    visit(v);       //訪問頂點v
    visited[v]=TRUE;        //設已訪問標記
    for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w))    //<v,w>,v的鄰接點w
        if(!visit[w]) {
            father[w] = v;      //w的父親結點為v
            DFS(G,w);
        }else if(w != father[v]) {  //鄰接點w被訪問過,並且它不是當前結點v的父親結點(即 v不來源於w,w不是v的父親)(即 繞了一圈回來了)
            return true;    //存在回路
        }
    }
    return false;   //不存在回路
}

//如果是判斷是否有環,則需使用此算法遍歷整個圖(包括非連通圖),但如果只是判斷圖是否為一棵樹,那么如果圖一次性沒被遍歷完,那么就不可能是一棵樹
bool DFSTraverse(Graph G){      //對圖G進行嘗試優先遍歷,訪問函數為visit()
    for(v=0;v<G.vexnum;v++) {
        visited[v]=FALSE;       //訪問標志數組初始化
    }
    for(v=0;v<G.vexnum;v++) {   //本代碼中是從v=0開始遍歷
        if(!visited[v]) {
            if(DFS(G,v)==true) {
                return true;    //整個圖(包括非連通圖)中存在回路
            }
        }
    }
    return false;   //不存在回路
}

DFS判斷無向圖是否是一棵樹

一個無向圖G是一棵樹的條件是,G必須是無回路的連通圖有n-1條邊的連通圖

  • 算法思想:(無回路的連通圖
    利用上述判斷回路的算法,只需將遍歷整個圖修改為判斷該一次遍歷是否訪問了所有結點
//深度優先搜索(Depth-First-Search,DFS)
bool visited[MAX_VERTEX_NUM];   //訪問標記數組
int father[MAX_VERTEX_NUM];     //標記下標對應結點的父親結點(即 該下標結點來源於哪個結點)

bool DFS(Graph G,int v){        //從頂點v出發,采用遞歸思想,深度優先遍歷圖G
    for(v=0;v<G.vexnum;v++)
        visited[v]=FALSE;       //訪問標志數組初始化
    visit(v);       //訪問頂點v
    visited[v]=TRUE;    //設已訪問標記
    for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w))    //<v,w>,v的鄰接點w
        if(!visit[w]) {
            father[w] = v;      //w的父親結點為v
            DFS(G,w);
        }else if(w != father[v]) {  //鄰接點w被訪問過,並且它不是當前結點v的父親結點(即 v不來源於w,w不是v的父親)
            return false;   //存在回路,不是一棵樹
        }
    }
    for(v=0;v<G.vexnum;v++) {
        if(visited[v]!=TRUE) {  //如果一次遍歷未能訪問所有結點,則該圖不連通
            return false;   //圖不連通,不是一棵樹
        }
    }
    return true;    //不存在回路的連通圖,即是一棵樹
}
  • 算法思想:(n-1條邊
    連通的判定,可用能否遍歷全部頂點來實現。可以采用深度優先搜索算法在遍歷圖的過程中統計可能訪問到的頂點個數和邊的條數,若一次遍歷就能訪問到n個頂點和n-1條邊,則可斷定此圖是一棵樹。
bool isTree(Graph &G) {
    for(i=1; i<G.vexnum; i++) {
        visited[i]=FALSE;   //訪問標記visited[]初始化
    }
    int Vnum = 0, Enum = 0; //記錄頂點數和邊數
    DFS(G, 1, Vnum, Enum, visited);
    if(Vnum==G.vexnum && Enum==2*(G.vexnum-1))  //n個頂點,n-1條邊(注意這個邊因為是無向圖,訪問了兩遍,類似於度)
        return true;    //符合樹的條件
    else
        return false;   //不符合樹的條件
}

void DFS(Graph &G, int v, int &Vnum, int &Enum, int visited[]) {
//深度優先遍歷圖G,統計訪問過的頂點數和邊數沒通過Vnum和Enum返回
    visited[v] = TRUE;  //作訪問標記,頂點計數
    Vnum++;
    int w = FirstNeighbor(G, v);    //取v的第一個鄰接頂點
    while(w != -1) {    //當鄰接頂點存在
        Enum++;     //邊存在,邊計數
        if(!visited[w]) {   //當該鄰接頂點未訪問過
            DFS(G, w, Vnum, Enum, visited);
        }
        w = NextNeighbor(G, v, w);
    }
}

常用算法

自由樹(無權連通圖),任意兩個結點之間的路徑長度最大值為該樹的直徑,設計一算法要求最小的時間復雜度,求出最大直徑。

  • 算法思想:
    利用樹中求高的方法,求出子樹(即 該點除了至父親結點的那條邊之外的其他邊)的高,取最大兩個子樹的高相加再+1,即 為最大直徑。由於遍歷了所有頂點,類似於遍歷算法,故時間復雜度為O(n)
int father[MAX_VERTEX_NUM];     //標記下標對應結點的父親結點(即 該下標結點來源於哪個結點)

int THeight(Tree T, int v) {
//求自由樹的高
    if(!v) {    //遞歸出口
        return 0;
    }
    p = v->firstarc;
    //遍歷T中所有鄰接點
    while(p) {
        if(p->adjvex != father[v]) {    //不斷向下訪問,只能向下(子結點)訪問,不能向上訪問父結點
            father[p->adjvex] = v;  //設置鄰接點的父結點
            adjdep[v++] = THeight(T, p->adjvex);    //求鄰接點(子樹)中的高度
        }
        p = p->next;
    }
    max = adjdep[0];
    for(i=0; i<v; i++) {    //取出最大高度
        if(adjdep[i] > max) {
            max = adjdep[i];
        }
    }
    return max+1;
}

int MAX_D(Tree T) {
//利用求樹高算法,求自由樹的直徑
    p = T->vertices[0].firstarc;
    while(p) {
        adjdep[v++] = THeight(T, p->adjvex);    //求鄰接點(子樹)中的高度
        p = p->next;
    }
    //選擇排序,取出鄰接點兩個最大的高度
    for(i=0; i<2; i++) {
        min = adjdep[i];
        for(j=i+1; j<n; j++) {
            if(adjdep[j] < min) {
                min = adjdep[j];
            }
            a[i] = min;
        }
    }
    //取出鄰接點前兩個最大的高度,相加再+1,則為直徑
    return a[0]+a[1]+1;
}
  • 算法思想:
    下面用鄰接表作為存儲結構,依次刪去樹葉(度為1的結點),將與樹葉相連的結點度數-1,設在第一輪刪去原樹T的所有樹葉后,所得樹為T1;再依次做第二輪刪除,即刪除所有T1的葉子;如此重復,若剩下最后一個結點,則樹的直徑應為刪除的輪數*2。
/*算法思想:下面用鄰接表作為存儲結構,依次刪去樹葉(度為1的結點),將與樹葉相連的結點度數-1,設在第一輪刪去原樹T的所有樹葉后,所得樹為T1;再依次做第二輪刪除,即刪除所有T1的葉子;如此重復,若剩下最后一個結點,則樹的直徑應為刪除的輪數*2.*/
int MAX_D()
{
    m=0;    //m記錄當前一輪葉子數
    for(i=1;i<=n;i++)
        if(du(veil)-1){     //du(v)==1,即葉子結點
            enqueue(Q,v[i]);    //葉子vi入隊
            m=m+l;  //m記錄當前一輪葉子數
        }
    r=0;    //表示刪除葉子輪數
    while(m>=2){    //當前葉子輪數
        j=0;    //j計算新一輪葉子數目
        for(i=1; i<=m; i++){    //將一輪葉子結點全部刪光
            dequeue(Q, v);  //出隊,表示刪去葉子v將與v相鄰的結點w的度數減1
            if(du(w)==1){   //w是新一輪的葉子
                j=j+1;
                enqueue(Q, w);//w入隊
            }   
        }
        r=r+1;  //刪光一輪后,輪數+1進行下一輪
        m=j;    //新一輪葉子總數
    }
    if(m==0)
        return 2*r-1;   //m=0,直徑為輪數*2-1
    else 
        return 2*r; //m=l,直徑為輪數*2
}

判斷圖是否有環

看上面


判斷無向圖是否為一棵樹

看上面


判斷有向圖中是否存在由頂點vi到頂點vj的路徑(i≠j)

  • 算法思想:
    兩個不同的遍歷算法都采用從頂點vi出發,依次遍歷圖中每個頂點,直到搜索到頂點vj,若能夠搜索到vj,則說明存在由頂點vi到頂點vj的路徑。

深度優先遍歷算法

int visit[MAXSIZE] = {0};   //訪問標記數組

int Exist_Path_DFS(ALGraph G, int i, int j) {
//深度優先判斷有向圖G中頂點vi到頂點vj是否有路徑,是則返回1,否則返回0
    int p;      //頂點序號
    if(i == j) {
        return 1;   //i就是j
    }else {
        visited[i] = 1;     //置訪問標記
        for(p=FirstNeighbor(G,i); p>=0; p=NextNeighbor(G,i,p)) {
            k = p.adjvex;
            if(!visited[p] && Exist_Path_DFS(G,p,j)) {  //遞歸檢測鄰接點
                return 1;   //i下游的頂點到j有路徑
            }
        }
    }
    return 0;
}

廣度優先遍歷算法

int visit[MAXSIZE] = {0};   //訪問標記數組

int Exist_Path_BFS(ALGraph G, int i, int j) {
//廣度優先判斷有向圖G中頂點vi到頂點vj是否有路徑,是則返回1,否則返回0
    InitQueue(Q);
    EnQueue(Q,i);       //頂點i入隊
    while(!isEmpty(Q)) {    //非空循環
        DeQueue(Q,u);       //隊頭頂點出隊
        visited[u] = 1;     //置訪問標記
        for(p=FirstNeighbor(G,i); p>=0; p=NextNeighbor(G,i,p)) {    //檢查所有鄰接點
            k = p.adjvex;
            if(k == j) {    //若k==j,則查找成功
                return 1;
            }
            if(!visited[k]) {   //否則,頂點k入隊
                EnQueue(Q,k);
            }
        }
    }
    return 0;
}

輸出從頂點vi到頂點vj的所有簡單路徑

  • 算法思想:
    本題采用基於遞歸的深度優先遍歷算法,從結點u出發,遞歸深度優先圖中結點,若訪問到結點v,則輸出該搜索路徑上的結點。為此設置一個path數組來存放路徑上的結點(初始為空),d表示路徑長度(初始為-1)。查找從頂點u到v的簡單路徑過程說明如下(假設查找函數名為FindPath()):
    1. FindPath(G,u,v,path,d):d++;path[d]=u;若找到u的未訪問過的相鄰結點u1,則繼續下去,否則置visited[u]=0並返回;
    2. FindPath(G,u1,v,path,d):d++;path[d]=u1;若找到u1的未訪問過的相鄰接點u2,則繼續下去,否則置visited[u1]=0;
    3. 以此類推,繼續上述遞歸過程,直到ui=v,輸出path。
void FindPath(ALGraph *G, int u, int v, int path[], int d) {
    int w, i;
    ArcNode *p;
    d++;        //路徑長度+1
    path[d] = u;        //將當前頂點添加到路徑中
    visited[u] = 1;     //置已訪問標記
    if(u == v) {        //找到一條路徑則輸出(遞歸出口)
        print(path[]);  //輸出路徑上的結點
    }
    p = G->adjlist[u].firstarc; //p指向v的第一個鄰接點
    while(p != NULL) {
        w = p->adjvex;  //若頂點w未被訪問,遞歸訪問它
        if(visited[w] == 0) {
            FindPath(G, w, v, path, d);
        }
        p = p->nextarc; //p指向v的下一個鄰接點
    }
    visited[u] = 0;     //恢復環境,使該頂點可重新使用
}

歸納總結

  • 求關鍵路徑:
    這類題目一般不會很復雜,直接列出所有路徑及其長度,比較一下,取最大,即可得出關鍵路徑。
  • 求單源最短路徑:
    這類題目一般不會很復雜,若沒有要求用什么方法,則可以直接一個一個列出來即可,不必刻意用Dijkstra算法。
  • 關於圖的基本操作:
    • 用鄰接矩陣作為存儲結構:
    int NextNeighbor(MGraph &G, int x, int y) {
        if(x!=-1 && y!=-1) {
            for(int col=y+1; col<G.vexnum; col++) {
                if(G.Edge[x][col]>0 && G.Edge[x][col]<maxWeight) {  //maxWeight代表∞
                    return col;
                }
            }
        }
        return -1;
    }
    • 用鄰接表作為存儲結構:
    int NextNeighbor(ALGraph &G, int x, int y) {
        if(x != -1) {   //頂點x存在
            ArcNode *p = G.vertices[x].first;//對應邊鏈表第一個邊結點
            while(p!=NULL && p->data!=y) {  //尋找鄰接頂點y
                p = p->next;
            }
            if(p!=NULL && p->next!=NULL) {
                return p->next->data;       //返回下一個鄰接頂點
            }
        }
    }

本文參考:
https://blog.csdn.net/qq_37134008/article/details/85325251
https://blog.csdn.net/a2392008643/article/details/81781766
https://blog.csdn.net/leaf_130/article/details/50684679


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM