圖論(三)--各種基礎圖演算法總結

Eason.wxd發表於2019-01-03

圖論總結

G=(V,E),V代表圖中節點的合集,E代表圖中邊或是關係的合集。

稠密圖:圖中E的條數接近V*V也就是,接近任意兩點之間相連。
稀疏圖:圖中E的條數遠小於V*V

圖的資料結構

圖常用的有兩種表示方式,鄰接連結串列和鄰接矩陣。
鄰接矩陣和鄰接連結串列都中儲存的資訊都只是點與點的關係。並不表示點的資訊,如果要表示點的資訊,需要一個額外的容器,儲存。
比如,i節點代表某個村莊,該村莊有村名,村民數等資訊,這些資訊需要額外儲存在一個容器中。比如vector<T> detial

鄰接矩陣

使用一個V*V的二位陣列ve表示矩陣,假設i節點於j節點連通,那麼ve[i][j]=1
鄰接矩陣可以表示有向圖,和有權重圖:ve[j][i]=3表示j->i權重為3。
優點

  1. 對於稠密圖能夠有效的節省空間。
  2. 表示上很直觀,容易判斷出那兩點之間相連。

缺點

  1. 在矩陣生成時,節點個數就已經確定,只能新增邊,不能再新增節點。
    如果要新增節點,那麼需要重新分配矩陣。代價大。
  2. 對於稀疏圖,浪費空間.
  3. 在某些演算法的時候需要為節點儲存額外資訊的時候,需要使用額外的容器

鄰接矩陣

鄰接連結串列

使用一個結構體來表示每個節點:

struct Node
{
  int ket;
  Node to;
  //..其他資訊
}

其中to變數指向連結該節點i指向的第一個節點j,然後j節點中的to指向i節點指向的第二個節點,以此類推。
鄰接矩陣表示的方法靈活方便,能夠臨時新增節點,和邊。
表示有向圖,有權重圖,還可以根據演算法,新增不同的變數。但同時也增加了體積。

缺點

  1. 稠密圖中浪費空間
  2. 不直觀,在有向圖中如果要同時表示指向本節點的節點,和本節點指向的節點,需要額外的一個欄位。

鄰接連結串列

同時還有其他的一些表示方式,但這兩種是最常見的。

 

圖的相關演算法

圖中要的演算法有很多:

  1. 遍歷和搜尋
    深度優先搜尋:該遍歷方法不能找到最短路徑,並不是專用語搜尋路徑。但是其搜尋的性質(儘可能向深處,觸底返回)常用於其他方向,比如拓撲排序。
    廣度優先搜尋:該遍歷方法能夠找到最短路徑,因此常用於最短路徑搜尋,還有某些與最短,最近等相關的演算法應用

遍歷和搜尋

廣度優先搜尋BFS

廣度優先搜尋的搜尋過程為:

  • 每次搜尋的節點一定是距離起始點最近的節點(這裡的最近不是指權重,而是假設權重為1)。
  • 一層一層的搜尋,一層搜尋完以後在去搜尋另一層。

因為上述的搜尋過程,廣度優先搜尋能夠找到最短路徑。
深度優先遍歷,一般為了尋找最短路徑,因此不需要考慮不能到達的點

實現

非遞迴實現
廣度優先搜尋適合使用非遞迴實現。
其搜尋過程,適合使用佇列deque
同時需要有一個memo用來記錄,是否某個節點是否被遍歷過了。(因為環的存在)

int Graph::findBfs(int start,int end)
{
    deque<int> de;
    //用來記錄節點是否被遍歷過。
    vector<bool> memo(Vcontainer.size(),false);
    //先將其實節點push進去
    //其實點距離自己的距離為0,父節點設為自己吧。
    Vcontainer[start]->d=0;
    Vcontainer[start]->p=start;
    
    deque.push_back(start);
    memo[start] = true;

    int curKey;
    Node *tmp;
    while(!de.empty())
    {
        curKey=de.front();
        //處理cur:可是是比較是否等於終點,可能是其他,
        //這裡處理兩點之間的最短距離
        
        if(curKey == end )
        {
            return Vcontainer[curKey]->d;
        }

        //處理結束,將該節點彈出。
        de.pop_front();

        //節點處理完畢以後,將節點指向的節點以此新增到佇列中
        tmp=Vcontainer[curKey];
        //當前節點有指向別的節點
        while(tmp->to != NULL)
        {
            //當前指向的節點還沒有遍歷過
            if(memo[tmp->to->key] == false)
            {
                tmp->to->d = tmp->d+1;
                tmp->to->p = tm->key;
                de.push_back(tmp->to->key);
                memo[tmp->to->key]=true;                
            }
            //指向當前節點,指向的下一個節點
            tmp = tmp->to;
        }
    }
    //能夠達到這裡說明每個節點都遍歷過了,
    //以為這兩點之間不聯通,可能是有向圖,或者不是一個連通圖
    return -1;
}

說明:

  1. 在演算法導論中,使用的mome有三種狀態,未遍歷,正在遍歷(在佇列中,還未處理),遍歷完成(從佇列中取出遍歷完成)。
  2. dp
    d中儲存了該節點到其實點的距離。(假設權重為1,即使不為1).
    p中儲存,一個節點的父節點:所以只需要從endstart逆向遍歷,就可以獲得最短路徑。

遞迴實現
廣度優先搜尋適合用非遞迴實現。
應用
廣度優先搜尋的思想在於尋找最短路徑,分層遍歷,只要一個點被遍歷,那麼這個點一定是當前距離起始點最近。同時,也是該點距離起始點最近的距離,如果後續還能遍歷到該點,一定不是最近距離了(我們在第二次遍歷到該點的時候並不會將該點新增進佇列,不會去處理他)
凡是能夠用到廣度優先搜尋性質的演算法大多都可以使用廣度優先搜尋的思想:
比如:

  1. 01反轉的題:從最終狀態逆向搜尋到起始狀態。

深度優先搜尋DFS

深度優先搜尋的搜尋過程為:
沿著某一某一條路徑儘可能的向深處遍歷,觸底以後返回一步,分了一個小叉,繼續向深處遍歷,觸底再返回。

因為上述的搜尋過程,深度優先遍歷並不能找到最短路徑。

實現

非遞迴實現
深度優先搜尋適合用遞迴進行實現,如果要使用非遞迴實現,需要手動維護一個棧。

遞迴實現
遞迴實現的深度優先搜尋,並不能停止,如果需要提前停止,需要一個額外的變數,來標記停止,而且需要是引用或者是指標。

void Graph::dfs_re_func(int curKey,vector<int> &memo)
{
    //如果想要儘早儘快結束遍歷,需要額外的變數:引用或者指標
    //這裡使用:finded
    //處理該節點,同時設定結束表示
    cout<<curKey<<endl;

    //標記為處理結束
    memo[curKey] = true;
    
    //處理結束,將遍歷該節點指向的節點
    Node *tmp=vContainer[curKey];

    //由於是遞迴,tmp儲存在棧中,同一層變數是同一個
    while(tmp->to != NULL)
    {
        if(memo[tmp->to->key] == false)
        {
            tmp->to->d = tmp->d+1;
            tmp->to->p = tm->key;
            dfs_re_func(tmp->to->key,end,memo)
            //節點返回代表該極點的子節點都處理完畢了。
            
            //如果希望儘快結束遍歷,需要額外的變數:引用或者指標
            //這裡檢查結束標誌return
        }
        //去遍歷同層的下一個
        tmp = tmp->to;
    }
}


int Graph::dfs_re()
{
    vector<bool> memo;
    //對於有向圖,可能存在多個不能到達的點,所以需要依次遍歷:
    for(Node *i:vContainer)
    {
        dfs_re_func(i->key,memo);
    }
    return finded;
}

應用

深度優先搜尋的搜尋性質:
只有一個節點的子節點(也就是該節點指向的節點,和指向節點的子節點)都處理完,該節點才算處理完。
能生成一個節點與節點之間先後順序的圖。
比如,A->B,C->B,那麼B需要在AC都發生以後才能發生。

深度優先搜尋生成的樹


拓撲排序

圖中節點視為事件,拓撲排序的目的是依據圖中事件發生的先後順序給出一個可能的排序

圖能夠進行圖譜排序的前提:

  • 有向圖:有向圖才能表示兩件事情之間發生的先後關係
  • 無環:也就是不存在環,如果存在的話,那麼事件發生的先後關係將不確定

全序和偏序

全序指的是一個集合中任意兩個元素之間能夠比較,也就是說能夠排序
偏序指的是,集合中存在不能比較的元素(這裡的不能是指的某一對之間不能,而不是該元素和其他元素不能比較)
比如說,快排是一種不穩定的排序,因為兩個值相同的元素之間的順序是不穩定的。(同一個陣列,如果分隔元素不隨機選的話也是一個穩定的?)
而選擇排序除了比較指的大小以外,還比較了兩個元素在陣列中出現的順序。

 

拓撲排序

深度優先搜尋實現拓撲排序

是有深度優先搜尋的性質

遞迴的深度優先搜尋保證了,只有當一個節點的所有自己點都遍歷結束以後,該節點才算是遍歷結束。
利用這個性質,當一個點遍歷結束以後就將該點新增進容器。

實現

void Graph::dfsTopo_func(int curKey, vector<int> &result ,vector<bool> &memo )
{

    memo[curKey]=true;
    Node *tmp = vContainer[curKey];
    while (tmp->to != NULL)
    {
        curKey=tmp->to->key;
        if (memo[curKey] == false)
        {
            dfs_re_func(curKey, result, memo);
            //當該遞迴返回時,表示該節點處理完了,
            //並且該節點所有的自己點,以及指向的子節點也都處理完了
            result.push_back(curKey);
        }
        tmp = tmp->to;
    }
}

vector<int> Graph::dfsTopo()
{
    vector<int> result;
    vector<bool> memo;
    
    for (Node *i : vContainer)
    {
        dfsTopo_func(i->key, result,memo) ;
    }
    reserve(result.begin(),result.end());
    return result;
}

如圖,上面右下角部分,result中應該儲存為:10,12,11,9。反轉以後,和拓撲排序的結果相同。

Kahn演算法

根據入度計算,其實就是不斷的尋找有向圖中沒有前驅(入度為0)的頂點,將之輸出。然後從有向圖中刪除所有以此頂點為尾的弧。重複操作,直至圖空,或者找不到沒有前驅的頂點為止。

該演算法還可以判斷有向圖是否存在環(存在環的有向圖肯定沒有拓撲序列),通過一個count記錄找出的頂點個數,如果少於N則說明存在環使剩餘的頂點的入度不為0。(degree陣列記錄每個點的入度數)

演算法步驟:

  1. 計算所有節點的入度,存放在indegree
  2. 將入度為零的節點放入佇列中中,
  3. 每次從de中取元素(隨即),放入result中。
    並遍歷取到元素指向的節點,將指向節點的入度--,如果此時指向節點的入度為0,那麼將該節點放入de中。
  4. 迴圈地3步。
    實現
void Graph::kahnTopolgical_sort()
{
    vector<int> result;//存放最終結果
    deque<int> de;//存放入度為0的節點
    vector<int> indegree(vContainer.size(),0);//存放節點的入度

    //計算所有節點的入度
    for (Node *i : vContainer)
    {
        while (i->to != NULL)
        {
            indegree[i->to->key]++;
            i = i->to;
        }
    }
    //將入度為0的節點放入de中
    for(int i= 0;i<indegree)
    {
        if(0 == indegree[i])
        {
            de.push_back(i);
        }
    }

    int curKey;
    Node *curNode;
    //每次從de中取元素,然後遍歷其指向的元素
    //並將指向節點的入度--,如果為0,也放入de。
    while(!de.empty())
    {
        curKey = de.top();
        de.pop_front();
        
        while(curNode->to != NULL)
        {
            curNode = curNode->to;
            curKey = curNode->key;
            indegree[curKey]--;
            if(0 == indegree[curKey])
            {
                de.push(curKey);
            }
        }
    }
}

如圖,4節點一定是在5節點新增到result中以後才能遍歷到4。先後順序有了保證。


強連通分量

兩點強連通:圖中兩點可以相互到達,i可以到j,j也可以到i
強連通分量:圖中所有的儘可能多的點可以相互到達的集合的集合。

強連通圖

如圖,有四個強連通子圖,這四個組成的集合成為強連通分量。

對於無向圖,如果頂點之間是連通的那就是一個強連通圖,有幾個不聯通的,就有幾個前聯通分量。
對與有向圖來說,不是這樣的:一個聯通的圖,可能有多個聯通分量,如上圖。

Kosaraju

深度優先搜尋實現

該演算法是這樣的:

  1. 對圖進行深度優先遍歷,記錄每個點的發現時間,和處理完成時間
    圖可能是不連通的所以需要對每個點都遍歷一次,有一個memo記錄是否遍歷過。
  2. 對圖的反向圖進行深度優先遍歷,節點的遍歷順序按照正向圖中節點的處理完成時間的從大到小順序進行遍歷。
    每從新遍歷一次(就是最外面的那個for),那麼之前一次遍歷就構建了一個前聯通分量。
void Graph::dfs(int curKey, int &time ,vector<bool> &memo)
{

    memo[curKey]=true;
    Node *tmp = vContainer[curKey];
    while (tmp->to != NULL)
    {
        tmp=tmp->to;
        curKey=tmp->key;
        if (memo[curKey] == false)
        {
            tmp.reach = ++time;
            dfs(curKey, time, memo);
            tmp.leave = ++time;
        }
        tmp = tmp->to;
    }
}

void Graph::ksaraju_dfs(int curKey,vector<int> &memo,vector<int> &scc,&vContainer)
{
    memo[curKey]=true;
    Node *tmp = vContainer[curKey];
    while (tmp->to != NULL)
    {
        tmp=tmp->to;
        curKey=tmp->key;
        if (memo[curKey] == false)
        {
            scc.push_back(curKey);
            ksaraju_dfs(curKey, memo, vContainer,scc);
        }
        tmp = tmp->to;
    }
}

void Graph::ksaraju_calOrder(vector<Node*> &vContainer,&vector<int> order)
{
    int total = (vContainer.size()+1)*2;
    for(int i = total;total>0;i--)
    {
        for(Node *node:vContainer)
        {
            if(node->leave == total)
            {
                order.push_back(node->key);
                continue;
            }
        }
    }
}
void Graph::ksaraju_reverse(vector<Node *> &oldG,vector<Node*> &newG)
{
    //...
}


vector<vector<int>> Graph::ksaraju()
{

    //深度優先搜尋。
    int time=0;
    vector<bool> memo(vContainer.size(),false);
    for(Node *i:vContainer)
    {
        if(memo[i->key]==false)
        {
            dfs(i->key,time,memo);
        }
    }

    //獲取反向圖遍歷節點的順序
    vector<int> order;
    ksaraju_calOrder(vContainer,order);

    //構造反向圖
    vector<Node *> reverseGraph;
    ksaraju_reverse(vContainer,reverseGraph);

    //根據正線圖遍歷離開節點時間,從大到小的順序遍歷
    vector<vector> result;
    memo=vector<bool>(reverseGraph.size(),false);
    for(int curKey:order)
    {
        if(memo[curKey]==fasle)
        {
            vector<int> scc;
            ksaraju_dfs(i->key,memo,scc,reverseGraph);
            //這裡新增的是副本
            result.push_back(scc);
        }
    }
}

Tarjan演算法

深度優先搜尋實現參考 參考2
該演算法實際上是在利用深搜找環。如果結點 u 是某個強連通分量在搜尋樹中遇到的第一個結點(這通常被稱為這個強連通分量的),那麼這個強連通分量的其餘結點肯定是在搜尋樹中以 u 為根的子樹中。如果有個結點 v 在該強連通分量中但是不在以 u 為根的子樹中,那麼 u 到 v 的路徑中肯定有一條離開子樹的邊。但是這樣的邊只可能是橫叉邊或者反祖邊,然而這兩條邊都要求指向的結點已經被訪問過了,這就和 u 是第一個訪問的結點矛盾了。

該演算法是這樣的:

  • reach記錄一個節點的發現時間。
  • low記錄一個節點是否能在一個環中。
  • flag記錄一個節點是否已經屬於一個強連通分量(false代表是,true代表還沒有分配強連通分量)
  • 一個stack用來儲存遍歷過的節點。

上面三者的配合是這樣的:

  1. 在處理每個節點的時候,lowreach都設定為發現時間,其中reach的值始終是發現時間。flag設定為true,表示節點還沒有分配在一個強連通分量中。節點壓入stack中。
  2. 遍歷其子節點,如果節點沒有子節點,那麼reach = low,也就是該節點肯定不會存在在某個環中(也就是強連通分量中),因為節點在一個環中,那麼節點一定有子節點,所以該節點應該自己成為一個強聯通分量。
  3. 處理完一個節點以後,會進行最終結果的構造。如果當前節點的low = reach,那麼就從棧頂彈出(棧頂元素就是該元素),然後將他壓入一個scc中,當棧頂元素,不等於當前元素的時候結束。同時標記該節點已經是在一個強連通分量中flag = false.
  4. 如果子節點已經被遍歷過了,也就是reach != 0,此時沒如果子節點已經被分配在一個強連通分量中flag =true.那麼將當前節點的low設定為子節點的reach表示該節點已經在一個環中了。
    處理結束以後進行最終結果的構造,但是該節點的low = reach所以跳過容器的構造了。
  5. 如果一個節點已經被遍歷過了,而且flag = fasle那麼直接跳過,沒有這個條件的處理程式碼。
  6. 如果一個節點沒有被遍歷過,那麼遞迴遍歷該節點,然後當遞迴返回的時候,去判斷該節點的low和子節點的low,如果子節點的low小於父節點low,表示子節點已經在一個環中了(所以才修改了low),那麼父節點也應該是在一個環中了,所以該節點low設定為子節點的low

最終結果構造程式碼

    int tmp;
    if(low[curKey] == reach[curKey])
    {
        do
        {
            tmp = st.top();
            st.pop();
            scc.push_back(tmp);
            flag[curKey] = false;
        }while(curKey != tmp);
        result.push_back(scc);
        scc.erase(scc.begin(),scc.end());        
    }

上面這段程式碼,是在環開始節點處理完畢以後才呼叫(也就是環中最先被壓入棧中的節點),這時候才會構建這個強連通分量:原因在與子節點的low都設定為環開始節點的reach。然後從棧頂向下,直到取出了該有節點。

在該演算法中也就是說:強連通分量是在遞迴呼叫的同時建立的。

Gabow演算法

Gabow演算法是Tarjan演算法的提升版本,該演算法類似於Tarjan演算法。演算法維護了一個頂點棧,但是還是用了第二個棧來確定何時從第一個棧中彈出各個強分支中的頂點,它的功能類似於Tarjan演算法中的陣列low。從起始頂點w處開始進行DFS過程中,當一條迴路顯示這組頂點都屬於同一個強連通分支時,就會彈出棧二中頂點,只留下回邊的目的頂點,也即搜尋的起點w。

當回溯到遞迴起始頂點w時,如果此時該頂點在棧二頂部,則說明該頂點是一個強聯通分量的起始頂點,那麼在該頂點之後搜尋的頂點都屬於同一個強連通分支。於是,從第一個棧中彈出這些點,形成一個強連通分支。


/* Gabow 演算法實現圖的強連通區域查詢;
     * @param G    輸入為圖結構
     * @return:
     * 函式最終返回一個二維單連結串列slk,單連結串列每個節點又是一個單連結串列,
     * 每個節點處的單連結串列表示一個聯通區域;
     * slk的長度代表了圖中聯通區域的個數。
     */
    public static SingleLink2 Gabow(GraphLnk G){
        SingleLink2 slk = new SingleLink2();
        // 函式使用兩個堆疊
        LinkStack ls = new LinkStack();
        LinkStack P = new LinkStack();
        int pre[] = new int[G.get_nv()];
        int cnt[] = new int[1];
        // 標註各個頂點所在的連通分支的名稱
        int id[]  = new int[G.get_nv()];
        // 初始化
        for(int i = 0; i < G.get_nv(); i++){
            pre[i] = -1;
            id[i] = -1;
        }
        for(int i = 0; i < G.get_nv(); i++){
            if(pre[i] == -1) {
                GraphSearch.GabowDFS(G, 
                        i, pre, id, cnt, 
                        ls, P, slk);
            }
        }
        
        //列印所有的聯通區域
        for(slk.goFirst(); slk.getCurrVal() != null; slk.next()){
            //獲取一個連結串列元素項,即一個聯通區域
            GNodeSingleLink comp_i = 
                    (GNodeSingleLink)(slk.getCurrVal().elem);
            //列印這個聯通區域的每個圖節點
            for(comp_i.goFirst(); 
                comp_i.getCurrVal() != null; comp_i.next()){
                System.out.print(comp_i.getCurrVal().elem + "\t");
            }
            System.out.println();
        }
        return slk;
    }
    
    函式呼叫遞迴實現的深度優先搜尋GabowDFS,實現如下:
    
    /**
     * GabowDFS演算法的遞迴DFS函式
     * @param ls    棧1,
     * @param P    棧2,決定何時彈出棧1中頂點
     */
    public static void GabowDFS(GraphLnk G, int w, 
                int pre[],  int[] id, int cnt[], 
                LinkStack ls, LinkStack P, 
                SingleLink2 slk){
        int v;
        pre[w] = cnt[0]++;
        //將當前頂點號壓棧
        ls.push(new ElemItem<Integer>(w));
        System.out.print("+0 stack1 ");ls.printStack();
        P.push(new ElemItem<Integer>(w));
        System.out.print("+0 stack2 ");P.printStack();
        
        // 對當前頂點的每個鄰點迴圈
        for(Edge e = G.firstEdge(w); G.isEdge(e); e = G.nextEdge(e)){
            //如果鄰點沒有遍歷過,則對其遞迴呼叫
            if(pre[e.get_v2()] == -1){
                GabowDFS(G, e.get_v2(), pre, id, cnt, ls, P, slk);
            }
            // 否則,如果鄰點被遍歷過但又沒有被之前的連通域包含,則迴圈彈出
            else if(id[e.get_v2()] == -1){
                int ptop = ((Integer)(P.getTop().elem)).intValue();
                // 迴圈彈出,直到棧頂頂點的序號不小於鄰點的序號
                while(pre[ptop] > pre[e.get_v2()]) {
                    P.pop();
                    System.out.print("-1 stack2 ");P.printStack();
                    ptop = ((Integer)(P.getTop().elem)).intValue();
                }
            }
        }
        // 遍歷完頂點的所有相鄰頂點後,如果棧2頂部頂點與w相同則彈出;
        if(P.getTop() != null 
           && ((Integer)(P.getTop().elem)).intValue() == w){
            P.pop();
            System.out.print("-2 stack2 ");P.printStack();
        }
        // 否則函式返回
        else return;
        
        // 如果棧2頂部頂點與w相同,則彈出棧1中頂點,形成連通分支
        GNodeSingleLink gnslk = new GNodeSingleLink();
        do{
            v = ((Integer)(ls.pop().elem)).intValue();
            id[v] = 1;
            gnslk.append(new ElemItem<Integer>(v));
        }while(v != w);
        System.out.print("-3 stack1 ");ls.printStack();
        slk.append(new ElemItem<GNodeSingleLink>(gnslk));
        
    }

最小生成樹

應用在無向圖,帶權重圖中。選擇部分邊將所有的點連線起來,使權重最小。

安全邊:指的是最小生生成樹中的邊。

而最小生成樹的演算法就是在尋找安全邊。
下面的兩種方法都使用貪心演算法

Krukal

每次選取剩下的邊中,權重最小的邊。

Krukal演算法的過程是:
遍歷選取目前還未選取的邊中權重最小的邊,並且這條邊需要滿足:邊連結的兩個點中,只能有一個或0個點已經被邊所連結。

Prim演算法

每次從連線的點集合中,選取權重最小的邊

Prim演算法的過程是:
任選一個點放入點集合,每次從點集合中選取所有點中,點指出的邊中權重最小的且指向的邊沒有被選中的邊,並將指向的點加入點集合中。直到所有點被放入集合。


單源最短路徑

在有向,有權重,可能有負權重,可能有環的圖中,從某個點到另一個點的最短路徑。

鬆弛操作:最短路徑演算法的設計都使用了鬆弛(relaxation)技術。在演算法開始時只知道圖中邊的權值,然後隨著處理逐漸得到各對頂點的最短路徑的資訊,演算法會逐漸更新這些資訊,每步都會檢查是否可以找到一條路徑比當前已有路徑更短,這一過程通常稱為鬆弛(relaxation)。

有向無環的有權重的圖中

直接廣度優先搜尋同時進行鬆弛操作,同時進行鬆弛操作,但是在搜尋的過程中不需要記錄某個點是否被搜尋過了。因為有權重,需要鬆弛。

這種圖中直接一遍廣度優先搜尋就可以了

int Graph::nonLoopShortest_dfs(int start,int end)
{
    deque<int> de;
    de.push_push(start);
    vContainer[start].d=0;
    vContainer[start].p=0;
    
    shared_ptr<Node> childNode;
    shared_ptr<Node> curNode;
    int curKey;
    int childKey;
    int tmpDis;

    //可以先計算入度,然後在每次遍歷到end節點的時候入度--。為0的時候可以返回。

    int endIndegree ;
    while(!de.empty)
    {
        curKey = de.front();
        de.pop_front();
        //
        //這裡可以處理endIndegree
        //

        curNode.reset(new Node(vContainer[curKey]));
        while(curNode->to != NULL)
        {
            childKey = curNode->to->key;
            tmpDis=vContainer[start].d+vContainer[childKey].weight;
            //如果該條路徑到達子節點的路徑短,那麼需要鬆弛操作,同時重新計運算元節點後面的節點。
            if(vContainer[childKey].d > tmpDis)
            {
                vContainer[childKey].d=tmpDis;
                vContainer[childKey].p=start;
                de.push_back(childKey);
            }
            curNode = curNode->to;
        }
    }
    //最後才能返回d的值。因為無法判斷有機條路徑能夠到達d;
    return vContainer[end].d;
}

Bellman-Ford演算法

有向,帶權重,無環,無負權重中的最短路徑

方法的過程和上面的演算法過程相同,只是需要迴圈V-1次。

Dijkstra演算法

適用於無負權重的圖

改進的是Bellman-Ford方法選取邊的方式。Dijkstra方法,總是選取目前.d值最小的,並且沒有選取過的點,然後計算其指向的邊。
用最小堆來維護,目前距離起始點最短的節點。


struct HeapSort
{
    int k;
    int v;
}

//建堆用的遞迴函式
void Graph::dijkstra_smallheap_build(int start, vector<HeapSort> &heap)
{
    int l = start * 2;
    int r = start * 2 + 1;
    int index = start;
    if (l < heap.size() && heap[index] > heap[l])
    {
        index = l;
    }
    if (r < heap.size() && heap[index] > heap[r])
    {
        index = r;
    }
    if (index != start)
    {
        swap(heap[start], heap[index]);
        dijkstra_smallheap_build(index, heap);
    }
}

//想堆中新增元素
void Graph::dijkstra_smallheap_add(vector<HeapSort> &smallheap, vector<HeapSort> &added)
{
    smallheap.insert(smallheap.end(),added.begin(),added.end());

    for(int i=smallheap.size()/2+1;i>0;i--)
    {
        dijkstra_smallheap_build(i,smallheap);
    }

}
//取出堆頂元素
HeapSort Graph::dijkstra_smallheap_take(vector<HeapSort> &smallheap)
{

    HeapSort ret= smallheap[1];
    smallheap[1]=smallheap.back();
    smallheap.pop_back();
    dijkstra_smallheap_build(1,smallheap);
    return ret;
}

//主要演算法
int Graph::dijkstra(int start,int end)
{
    vector<HeapSort> smallheap;
    vector<HeapSort> added;
    dijkstra_smallheap_add(HeapSort(0,start));

    shared_ptr<Node> curNode;
    int tmpDis;
    while(!smallheap.empty())
    {
        HeapSort cur=dijkstra_smallheap_take(smallheap);
        
        curNode = make_shared<Node>(new Node(vContainer[cur.v]);
        added.resize(0);
        while(curNode->to != NULL)
        {
            tmpDis = vContainer[cur.v].d+curNode->to->weight;
            if(tmpDis <  vContainer[urNode->to->key].d)
            {
                vContainer[urNode->to->key].d=tmpDis;
                added.push_back(HeapSort(tmpDis,curNode->to->key));
            }
            curNode=curNode->to;
        }
        dijkstra_smallheap_add(smallheap,added);
    }
    return vContainer[end].d;
}

所有節點對的最短路徑問題

Floyd-Warshall演算法

最短路徑的動態規劃解法,全源最短路徑問題可以認為是單源最短路徑問題(Single Source Shortest Paths Problem)的推廣,即分別以每個頂點作為源頂點並求其至其它頂點的最短距離

from copy import deepcopy
from math import inf

def floyd_warshall(G):
    D = deepcopy(G)
    for v in G:
        for u in G:
            for w in G:
                current = D[u].get(w, inf)
                shortcut = D[u].get(v, inf) + D[v].get(w, inf)
                D[u][w] = min(current, shortcut)
    return D

Johnson演算法

Johnson 演算法能調整權重為負的圖,使之能夠使用Dijkstra 演算法。

re-weight

以下圖為例,Johnson 演算法對下圖進行re-weight操作,使權重不為負,並且re-weight後,計算出來的最短路徑仍然正確。

首先,新增一個源頂點 ,並使其與所有頂點連通,新邊賦權值為 0,如下圖所示。

接下來重新計算新增頂點到其它頂點的最短路徑,利用單源最短路徑演算法,圖中存在負權重節點,使用bellman ford演算法,計算新增節點到其它節點的最短路徑 h[],然後使用如下公式對所有邊的權值進行 "re-weight":

w(u, v) = w(u, v) + (h[u] - h[v]).

對於此公式的證明請參考演算法導論一書。

現在除新增結點外,其它結點的相關邊權重值都已經為正數了,可以將新增結點刪除,對其它結點使用Dijkstra 演算法了。

public void johnson(MatrixGraph graph){
    Vertex s = new Vertex("s");
    graph.addVertex(s);
    for (int i = 0; i < graph.mList.size(); i++) {
        graph.addEdge(s, graph.mList.get(i), 0);
    }
    //計算s點到其它點的最短距離
    ArrayList<Integer> h = bellman_ford(graph, s);
    //重新計算除s以外的其它點權重
    ArrayList<MatrixEdge> edges = new ArrayList<>();
    MatrixEdge temp = null;
    for (int i = 0; i < VERTEX_NUM; i++) {
        for (int j = 0; j < VERTEX_NUM; j++) {
            temp = graph.mEdges[i][j];
            if (temp != null && temp.v1 != s && temp.v2 != s) {
                edges.add(temp);
            }
        }
    }
    
    System.out.println(" -------- ");
    
    for (int i = 0; i < edges.size(); i++) {
        temp = edges.get(i);
        temp.weight = temp.weight + h.get(graph.mList.indexOf(temp.v1)) - h.get(graph.mList.indexOf(temp.v2));
        System.out.print(temp + " | ");
    }
    System.out.println();
    System.out.println(" --------- ");
    
    graph.removeVertex(s);
    
    //根據重新計算的非負權重值,遍歷呼叫dijkstra演算法
    for (int i = 0; i < graph.mList.size(); i++) {
        dijkstra(graph, graph.mList.get(i));
    }
}

 


最大流問題

  • 比喻:有一個自來水管道運輸系統,起點是 s,終點是 t,途中經過的管道都有一個最大的容量,可以想象每條管道不能被水流“撐爆”。求從 s 到 t 的最大水流量是多少?

  • 應用:網路最大流問題是網路的另一個基本問題。許多系統包含了流量問題。例如交通系統有車流量,金融系統有現金流,控制系統有資訊流等。許多流問題主要是確定這類系統網路所能承受的最大流量以及如何達到這個最大流量。

  • 流網路(Flow Networks):指的是一個有向圖 G = (V, E),其中每條邊 (u, v) ∈ E 均有一非負容量 c(u, v) ≥ 0。如果 (u, v) ∉ E 則可以規定 c(u, v) = 0。流網路中有兩個特殊的頂點:源點 s (source)和匯點 t(sink)。為方便起見,假定每個頂點均處於從源點到匯點的某條路徑上,就是說,對每個頂點 v ∈ E,存在一條路徑 s --> v --> t。

  • 容量限制:對於所有的結點 u, v ∈ V,要求 0 ≤ f(u, v) ≤ c(u, v)

  • 流量限制:對於所有的結點 u ∈ V - {s, t},要求 Σf(v, u) = Σf(u, v)

  • 當(u, v) ∉ E時,從結點 u 到結點 v 之間沒有流,因此f(u, v) = 0。我們稱非負數值f(u, v)為從結點 u 到結點 v 的流,定義如下: |f| = Σf(s, v) - Σf(v, s),也就是說,流 f 的值是從源結點流出的總流量減去流入源結點的總流量。(有點類似電路中的基爾霍夫定律)

具有多個源結點和多個匯點的網路

  • 一個最大流問題可能會包含幾個源結點和幾個匯點,比如{s1, s2, ..., sm} 以及 {t1, t2, ..., tm},而不僅僅只有一個源結點和匯點,其解決方法並不比普通的最大流問題難。

  • 加入一個超級源結點 s,並對於多個源結點,加入有向邊 (s, si) 和容量 c(s, si) = ∞,同時建立一個超級匯點 t,並對於多個匯點,加入有向邊 (ti, t) 和容量 c(ti, t) = ∞。

  • 這樣單源結點能夠給原來的多個源結點 si 提供所需要的流量,而單匯點 t 則可以消費原來所有匯點 ti 所消費的流量。

Ford-Fulkerson 方法

  • 幾個重要的概念

    • 殘留網路(residual capacity):容量網路 - 流量網路 = 殘留網路

      1. 具體說來,就是假定一個網路 G =(V,E),其源點 s,匯點 t。設 f 為 G 中的一個流,對應頂點 u 到頂點 v 的流。在不超過 C(u,v)的條件下(C 代表邊容量),從 u 到 v 之間可以壓入的額外網路流量,就是邊(u,v)的殘餘容量(residual capacity)。
      2. 殘餘網路 Gf 還可能包含 G 中不存在的邊,演算法對流量進行操作的目的是增加總流量,為此,演算法可能對特定邊上的流量進行縮減。為了表示對一個正流量 f(u ,v) 的縮減,我們將邊 (u, v) 加入到 Gf中,並將其殘餘容量設定為 cf(v, u) = f(u ,v)。也就是說,一條邊所能允許的反向流量最多能將其正向流量抵消。
      3. 殘存網路中的這些反向邊允許演算法將已經傳送出來的流量傳送回去。而將流量從同一邊傳送回去等同於縮減該邊的流量,這種操作在很多演算法中都是必需的。
    • 增廣路徑(augmenting path): 這是一條不超過各邊容量的從 s 到 t 的簡單路徑,向這個路徑注入流量,可以增加整個網路的流量。我們稱在一條增廣路徑上能夠為每條邊增加的流量的最大值為路徑的殘餘容量,cf(p) = min{cf(u,v) : (u,v)∈路徑p}

    • 割:用來證明 “當殘留網路中找不到增廣路徑時,即找到最大流”,最大流最小切割定理,具體證明略。

  • 演算法過程:

    • 開始,對於所有結點 u, v ∈ V, f(u, v) = 0,給出的初始流值為0。

    • 在每一次迭代中,將 G 的流值增加,方法就是在殘留網路 Gf 中尋找一條增廣路徑(一般用 BFS 演算法遍歷殘留網路中各個結點,以此尋找增廣路徑),然後在增廣路徑中的每條邊都增加等量的流值,這個流值的大小就是增廣路徑上的最大殘餘流量。

    • 雖然 Ford-Fulkerson 方法每次迭代都增加流值,但是對於某條特定邊來說,其流量可能增加,也可能減小,這是必要的,詳情見下文的“反向邊”。

    • 重複這一過程,直到殘餘網路中不再存在增廣路徑為止。最大流最小切割定理將說明在演算法終結時,改演算法獲得一個最大流。

    • 虛擬碼:

      FORD-FULKERSON(G,t,s)
      
      1 for each edge(u,v) 屬於 E(G)
      
      2     do f[u,v]=0
      
      3          f[v,u]=0
      
      4 while there exists a path p from s to t in the residual network Gf // 根據最大流最小切割定理,當不再有增廣路徑時,流 f 就是最大流
      
      5       do cf(p)=min{cf(u,v):(u,v)is in p}  // cf(p)為該路徑的殘餘容量
      
      6        for each edge (u,v) in p
      
      7              do f[u,v]=f[u,v]+cf(p)  //為該路徑中的每條邊中注入剛才找到到的殘餘容量
      
      8                    f[v,u]=-f[u,v]   //反向邊注入反向流量
      
    • 反向邊是什麼?

      轉自:http://nano9th.wordpress.com.cn/2009/02/17/%E7%BD%91%E7%BB%9C%E6%B5%81%E5%9F%BA%E7%A1%80%E7%AF%87-edmond-karp%E7%AE%97%E6%B3%95/

      • 假設沒有上面虛擬碼中最後一步的操作,那麼對於如下的流網路:

        201798-maxflow1

      • 我們第一次找到了 1-2-3-4 這條增廣路,這條路上的最小邊剩餘流量顯然是 1。於是我們修改後得到了下面這個殘留網路:

        201798-maxflow2

      • 這時候 (1,2) 和 (3,4) 邊上的流量都等於容量了,我們再也找不到其他的增廣路了,當前的流量是 1。但這個答案明顯不是最大流,因為我們可以同時走 1-2-4 和 1-3-4,這樣可以得到流量為 2 的流。

      • 而這個演算法神奇的利用了一個叫做反向邊的概念來解決這個問題。即每條邊 (I,j) 都有一條反向邊 (j,i),反向邊也同樣有它的容量。那麼我們剛剛的演算法問題在哪裡呢?問題就在於我們沒有給程式一個” 後悔” 的機會,應該有一個不走 (2-3-4) 而改走 (2-4) 的機制。

      • 我們來看剛才的例子,在找到 1-2-3-4 這條增廣路之後,把容量修改成如下:

        201798-maxflow3

      • 這時再找增廣路的時候,就會找到 1-3-2-4 這條可增廣量,即 delta 值為 1 的可增廣路。將這條路增廣之後,得到了最大流 2。

        201798-maxflow4

      • 解釋:

        事實上,當我們第二次的增廣路走 3-2 這條反向邊的時候,就相當於把 2-3 這條正向邊已經是用了的流量給” 退” 了回去,不走 2-3 這條路,而改走從 2 點出發的其他的路也就是 2-4。(有人問如果這裡沒有 2-4 怎麼辦,這時假如沒有 2-4 這條路的話,最終這條增廣路也不會存在,因為他根本不能走到匯點)同時本來在 3-4 上的流量由 1-3-4 這條路來” 接管”。而最終 2-3 這條路正向流量 1,反向流量 1,等於沒有流量。

      • 這就是這個演算法的精華部分,利用反向邊,使程式有了一個後悔和改正的機會。

演算法的效率及其優化—— Edmonds-Karp 演算法

  • 如果使用廣度優先來尋找增廣路徑,那麼可以改善 FORD-FULKERSON 演算法的效率,也就是說,每次選擇的增廣路徑是一條從 s 到 t 的最短路徑,其中每條邊的權重為單位距離(即根據邊的數量來計算最短路徑),我們稱如此實現的 FORD-FULKERSON 方法為 Edmonds-Karp 演算法。其執行時間為 O(VE^2)。

  • 注意 E-K 演算法適用於改善 F-F 演算法的效率,邊的權重僅僅還是容量限制,而下文的“最小費用最大流”中的每條邊的權重有兩個值:(容量限制,單位流量損耗)。

最大流例項:

  • 對於如下拓撲圖,給出從S1到S6允許的流的方向和頻寬限制:

    • 求出S1到S6最大可能頻寬(提示Ford-Fulkerson演算法)。

    • 畫出流的流向及頻寬分配,使達到最大可能的頻寬。

      201798-maxflow

  • 根據演算法,最大流的值為23(定值),而下圖是一種可能的流量走向:

    201798-maxflowans

  • 原始碼:https://github.com/edisonleolhl/DataStructure-Algorithm/blob/master/Graph/MaxFlow/maxflow.py

  • 在尋找增廣路徑時用到了 BFS 演算法,以後有時間再寫寫 BFS、DFS 的文章,注意用到了 Python 中的標準庫:deque,這是雙端佇列。

最小費用最大流

  • 最小費用最大流問題是經濟學和管理學中的一類典型問題。在一個網路中每段路徑都有 “容量” 和 “費用” 兩個限制的條件下,此類問題的研究試圖尋找出:流量從 A 到 B,如何選擇路徑、分配經過路徑的流量,可以在流量最大的前提下,達到所用的費用最小的要求。如 n 輛卡車要運送物品,從 A 地到 B 地。由於每條路段都有不同的路費要繳納,每條路能容納的車的數量有限制,最小費用最大流問題指如何分配卡車的出發路徑可以達到費用最低,物品又能全部送到。

  • 注意:最後得到的流必須是最大流,最大流可能有多種情況,目標是找出最小費用的那種情況。

  • 解決最小費用最大流問題,一般有兩條途徑。

    • 一條途徑是先用最大流演算法算出最大流,然後根據邊費用,檢查是否有可能在流量平衡的前提下通過調整邊流量,使總費用得以減少?只要有這個可能,就進行這樣的調整。調整後,得到一個新的最大流。然後,在這個新流的基礎上繼續檢查,調整。這樣迭代下去,直至無調整可能,便得到最小費用最大流。這一思路的特點是保持問題的可行性(始終保持最大流),向最優推進。

    • 另一條解決途徑和前面介紹的最大流演算法思路相類似,一般首先給出零流作為初始流。這個流的費用為零,當然是最小費用的。然後尋找一條源點至匯點的增流鏈,但要求這條增流鏈必須是所有增流鏈中費用最小的一條。如果能找出增流鏈,則在增流鏈上增流,得出新流。將這個流做為初始流看待,繼續尋找增流鏈增流。這樣迭代下去,直至找不出增流鏈,這時的流即為最小費用最大流。這一演算法思路的特點是保持解的最優性(每次得到的新流都是費用最小的流),而逐漸向可行解靠近(直至最大流時才是一個可行解)。

  • 第二種辦法與前文的 Ford-fulkerson 方法很像,所以選擇它更方便,如何找到費用最小的增鏈流呢?可以用最短路徑演算法,這裡是單源最短路徑,所以選擇 Dijkstra 演算法找出最短路徑即可,關於 Dijkstra 的介紹見:http://www.jianshu.com/p/8ba71199a65f,裡面有 Python 實現的程式。

最小費用最大流例項:

  • 對於如下拓撲圖,給出從S1到S6允許的流的方向和頻寬限制,鏈路按頻寬收費,以括號形式表示為(頻寬容量,單位頻寬費用):

    • 求出S1到S6最小費用下最大可能頻寬,得出最小費用值,並標出選路狀況。

    • 寫出對給出任意拓撲圖的通用演算法描述。

      201798-mincostmaxflow

  • 原始碼:https://github.com/edisonleolhl/DataStructure-Algorithm/blob/master/Graph/MaxFlow/mincostmaxflow.py

  • 執行截圖:

    201799-run

  • 注意增廣路徑是回溯的,比如第一條增廣路徑,終點為5,path[5]=4,所以它的前驅是4,path[4]=2,所以4的前驅是2,2的前驅是1,1的前驅是0,所以這條路徑是 0-1-2-4-5,也就是 s1-s2-s3-s5-s6。

  • 注意在尋找增廣路徑時用到了 Dijkstra 演算法,至於為什麼用 heapq (最小堆的實現),見介紹 Dijkstra 演算法的文章。

  • 流量分佈情況:

    201799-mincostans

最大二分匹配

轉自:http://blog.csdn.net/smartxxyx/article/details/9672181

  • 最大匹配定義:給定一個無向圖 G = (V, E),一個匹配是指:E 的某個子集 M , 對於所有的結點 v ∈ V,子集 M 中最多有一條邊與 v 相連,如果子集 M 中的某條邊與 v 相連,那麼稱 v 由 M 匹配;否則 v 就是沒有匹配的。最大匹配是指:對於所有任意匹配 M',有 |M| ≥ |M'| 的匹配 M 。

  • 二分圖定義:設 G=(V,E) 是一個無向圖,如果頂點 V 可分割為兩個互不相交的子集 (A,B),並且圖中的每條邊(i,j)所關聯的兩個頂點 i 和 j 分別屬於這兩個不同的頂點集 (i in A,j in B),則稱圖 G 為一個二分圖。

  • 應用:把機器集合 L 與任務集合 R 相匹配, E 中有邊 (u, v) 就說明一臺特定的機器 u ∈ L 能夠完成一項特定的任務 v ∈ R,最大二分匹配就是讓儘可能多的機器執行起來,因為一臺機器只能同時做一個任務,一個任務也只能同時被一個機器完成,所以這裡也可理解為讓儘可能多的任務被完成。

  • 圖:

    圖 1 是二分圖,為了直觀,一般畫成 2 那樣,3、4 中紅色邊即為匹配,4 是最大匹配,同時也是完美匹配(所有頂點都是匹配點),圖 5 展示了男孩和女孩暗戀關係,有連線就說明這一對能成,求最大匹配就是求能成多少對。

  • FORD-FULKERSON 方法解決最大二分匹配

    • 給定如下的二分圖(忽略顏色):

    • 把已有的邊設為單向邊(方向 L -> R),且各邊容量設為 ∞ ;增加源結點 s 與匯點 t,將 s 與集合 L 中各個結點之間構造單向邊,且各邊容量設為 1;同樣的,將集合 R 中各個結點與 t 之間構造單向邊,且各邊容量設為1。這時得到一個流網路 G',如下:

    • 這時,最大匹配數值就等於流網路 G' 中最大流的值。

參考:

https://www.jianshu.com/p/fb2270a595c5

http://www.cnblogs.com/gaochundong/p/ford_fulkerson_maximum_flow_algorithm.html

https://www.jianshu.com/p/efb2d79e2b0f

相關文章